M10: byten, hemresan och levelcheck
All checks were successful
release / build-release (push) Successful in 1m37s

Resor med byten: en bana kan ha next som pekar på en delbana (kind leg,
syns inte på bankartan). När man klivit av rätt tåg fortsätter samma
omgång på nästa station med klocka, plånbok, sällskap och räknare;
par-tiden adderas, dimma och folk är nya, en Byte!-banner visas och
HUD:en visar resmålet via bytesstationen. Tidsgräns och missade tåg
gäller hela resan. level.ValidateChains kräver sammanhängande kedjor.
Nya resor: 007 Akihabara -> Ueno -> Asakusa och finalen 008 Tokyo (två
våningar) -> Nippori -> Narita flygplats. Slutscen när sista huvudbanan
klaras första gången: planet lyfter, resan summeras, eftertexter;
Progress.StoryCleared sparas; flaggan -ending. tools/levelcheck ritar
banorna och rapporterar problem. HUD-nivån kortas när mätarna tar plats;
poängraden radbryts. Skärmdumpar och docs uppdaterade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 13:37:48 +02:00
parent f762f9554e
commit 4ae6a1d24c
36 changed files with 1681 additions and 74 deletions

View File

@@ -48,6 +48,7 @@ type Assets struct {
Train *ebiten.Image // tågvagn, ritas längs rälsen när tåget syns
Rails *ebiten.Image // rälsruta (horisontell), roteras för vertikala spår
Posters []*ebiten.Image // affischer för visuell röra (ritas över väggrutor)
Plane *ebiten.Image // flygplanet i slutscenen
Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
Text map[string]string
Dict *lang.Dictionary
@@ -93,6 +94,9 @@ func LoadAssets() (*Assets, error) {
if err != nil {
return nil, err
}
if a.Plane, err = loadImage("sprites", "plane.png"); err != nil {
return nil, err
}
for row := 0; row < 4; row++ {
for col := 0; col < 3; col++ {
a.Player[row][col] = player.SubImage(image.Rect(col*TileSize, row*TileSize, (col+1)*TileSize, (row+1)*TileSize)).(*ebiten.Image)

View File

@@ -71,6 +71,7 @@ type Options struct {
Mute bool // inget ljud
SavePath string // sparfil ("" = standardplatsen)
NoSave bool // läs sparfilen men skriv aldrig
Ending bool // öppna slutscenen direkt (tester/skärmdumpar)
Mode string // navigeringsläge från start: "list" eller "signs" (tomt = sparfilens)
}
@@ -137,18 +138,40 @@ func New(opt Options) (*Game, error) {
g.Wallet.Card = opt.CardYen
}
if opt.LevelID != "" {
switch {
case opt.Ending:
g.scene = NewEndingScene(g)
case opt.LevelID != "":
s, err := g.startLevel(opt.LevelID)
if err != nil {
return nil, err
}
g.scene = s
} else {
default:
g.scene = NewMapScene(g)
}
return g, nil
}
// chainAfter är resans fortsättning efter banan id (delbanorna via next).
func (g *Game) chainAfter(id string) []*level.Level {
if c := level.ChainOf(g.Levels, id); len(c) > 1 {
return c[1:]
}
return nil
}
// isLastMain rapporterar om banan är storyns sista huvudbana.
func (g *Game) isLastMain(id string) bool {
var last *level.Level
for _, l := range g.Levels {
if !l.IsSide() && !l.IsLeg() && (last == nil || l.Order > last.Order) {
last = l
}
}
return last != nil && last.ID == id
}
func loadCatalog() (*progress.Catalog, error) {
sk, err := res.ReadFile("data", "skills.json")
if err != nil {
@@ -240,7 +263,7 @@ func (g *Game) runOptions(id string, seed rng.Seed) sim.RunOptions {
return sim.RunOptions{
Dict: g.Assets.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed,
Extra: eff.Stats, FogRadius: eff.FogRadius, Landmarks: eff.Landmarks, FreeGates: eff.FreeGates,
RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery, Mode: g.Mode,
RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery, Mode: g.Mode, Chain: g.chainAfter(id),
}
}
@@ -281,12 +304,13 @@ func (g *Game) startLevel(id string) (*LevelScene, error) {
// LevelOutcome är vad en avslutad bana gav i progression (för resultatskärmen).
type LevelOutcome struct {
XP int
LevelUps int
FirstClear bool
NewItems []string
NewPoints int
Unlocked []string // banor som öppnades
XP int
LevelUps int
FirstClear bool
NewItems []string
NewPoints int
Unlocked []string // banor som öppnades
StoryCleared bool // sista huvudbanan klarades för första gången: visa slutet
}
// levelEnded appliceras exakt en gång när en bana avgjorts: debuffar,
@@ -323,6 +347,10 @@ func (g *Game) levelEnded(run *sim.Run, id string) LevelOutcome {
out.Unlocked = append(out.Unlocked, l.ID)
}
}
if g.isLastMain(id) && !g.Progress.StoryCleared {
g.Progress.StoryCleared = true
out.StoryCleared = true
}
}
g.State.Current = nil
g.Save()

View File

@@ -0,0 +1,178 @@
package game
import (
"fmt"
"image/color"
"math"
"sort"
"strings"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// EndingScene är storyns slut: sällskapet flyger hem, resan summeras och
// eftertexterna rullar. Enter går vidare mellan delarna, Esc hoppar till
// bankartan.
type EndingScene struct {
t float64 // sekunder i aktuell del
phase int // 0 flyget, 1 summeringen, 2 eftertexterna
lines []string
}
const (
endingFlightSec = 7.0 // flyget innan summeringen kommer av sig själv
endingCreditsPxS = 14.0 // eftertexternas rullhastighet
)
// NewEndingScene startar hemresan.
func NewEndingScene(g *Game) *EndingScene {
return &EndingScene{lines: strings.Split(g.Assets.T("ending.credits"), "\n")}
}
func (e *EndingScene) Update(g *Game) error {
e.t += dt
switch {
case backJustPressed():
g.SetScene(NewMapScene(g))
return nil
case confirmJustPressed():
e.phase++
e.t = 0
}
if e.phase == 0 && e.t > endingFlightSec {
e.phase, e.t = 1, 0
}
if e.phase == 2 && e.t*endingCreditsPxS > float64(len(e.lines)*LineH+ScreenH) {
e.phase++ // eftertexterna har rullat förbi
}
if e.phase > 2 {
g.SetScene(NewMapScene(g))
}
return nil
}
func (e *EndingScene) Draw(g *Game, screen *ebiten.Image) {
a := g.Assets
e.drawSky(screen)
switch e.phase {
case 0:
e.drawFlight(g, screen)
a.DrawBig(screen, a.T("ending.title"), 24, 20, colText)
a.Draw(screen, a.T("ending.sub"), 24, 44, colDim)
case 1:
e.drawSummary(g, screen)
default:
e.drawCredits(g, screen)
}
a.Draw(screen, a.T("ending.hint"), 8, ScreenH-12, colDim)
}
// drawSky är en skymningshimmel i band, från mörkblått upptill till orange
// vid horisonten, med långsamt drivande moln.
func (e *EndingScene) drawSky(screen *ebiten.Image) {
top, bottom := [3]float64{30, 32, 80}, [3]float64{230, 120, 70}
const bands = 12
for i := 0; i < bands; i++ {
f := float64(i) / float64(bands-1)
col := color.RGBA{uint8(top[0] + (bottom[0]-top[0])*f), uint8(top[1] + (bottom[1]-top[1])*f), uint8(top[2] + (bottom[2]-top[2])*f), 255}
vector.DrawFilledRect(screen, 0, float32(i*ScreenH/bands), ScreenW, float32(ScreenH/bands+1), col, false)
}
cloud := color.RGBA{250, 220, 200, 120}
for i := 0; i < 5; i++ {
x := math.Mod(float64(i)*73+e.t*(4+float64(i)), ScreenW+60) - 30
y := float64(40 + i*22)
vector.DrawFilledRect(screen, float32(x), float32(y), 28, 6, cloud, false)
vector.DrawFilledRect(screen, float32(x+6), float32(y-4), 14, 5, cloud, false)
}
vector.DrawFilledRect(screen, 0, ScreenH-20, ScreenW, 20, color.RGBA{24, 20, 37, 255}, false) // marken
}
// drawFlight: planet lyfter åt höger medan sällskapet vinkar på marken.
func (e *EndingScene) drawFlight(g *Game, screen *ebiten.Image) {
a := g.Assets
f := min(1, e.t/endingFlightSec)
px := -40 + f*(ScreenW+80)
py := 120 - f*80 + 3*math.Sin(e.t*2)
var op ebiten.DrawImageOptions
op.GeoM.Scale(2, 2)
op.GeoM.Translate(px, py)
screen.DrawImage(a.Plane, &op)
for i := range memberTints {
var o ebiten.DrawImageOptions
t := memberTints[i]
o.ColorScale.Scale(t[0], t[1], t[2], 1)
o.GeoM.Translate(float64(ScreenW/2-50+i*20), ScreenH-20-TileSize+math.Abs(2*math.Sin(e.t*3+float64(i))))
screen.DrawImage(a.PlayerFrame(sim.Up, true, e.t+float64(i)*0.3), &o)
}
}
// drawSummary är resan i siffror: banor, stjärnor, poäng, ord och souvenirer.
func (e *EndingScene) drawSummary(g *Game, screen *ebiten.Image) {
a := g.Assets
p := g.Progress
vector.DrawFilledRect(screen, 16, 14, ScreenW-32, ScreenH-40, colPanel, false)
vector.DrawFilledRect(screen, 16, 14, ScreenW-32, 2, colGood, false)
a.DrawBig(screen, a.T("ending.summary"), 24, 20, colGood)
levels, cleared, score := 0, 0, 0
for _, l := range g.Levels {
if l.IsLeg() {
continue
}
levels++
if r := p.Levels[l.ID]; r != nil && r.Cleared {
cleared++
score += r.Score
}
}
y := 44
rows := []string{
fmt.Sprintf("%s: %d/%d", a.T("ending.levels"), cleared, levels),
fmt.Sprintf("%s: %d/%d", a.T("ending.stars"), p.TotalStars(), 3*levels),
fmt.Sprintf("%s: %d", a.T("ending.score"), score),
fmt.Sprintf("%s: %d", a.T("ending.words"), g.Know.Count()),
}
for _, r := range rows {
a.Draw(screen, r, 24, y, colText)
y += LineH
}
var items []string
for id := range p.Items {
if it := g.Catalog.Item(id); it != nil {
items = append(items, it.Name.Get(UILang))
}
}
sort.Strings(items)
souvenirs := a.T("ending.none")
if len(items) > 0 {
souvenirs = strings.Join(items, ", ")
}
for _, l := range a.wrapLine(plainLine(a.T("ending.items")+": "+souvenirs, colKnown), ScreenW-48) {
a.drawLines(screen, []line{l}, 24, y)
y += LineH
}
y += 4
for _, l := range a.wrapLine(plainLine(a.T("ending.story_done"), colWarn), ScreenW-48) {
a.drawLines(screen, []line{l}, 24, y)
y += LineH
}
}
// drawCredits rullar eftertexterna nerifrån och upp.
func (e *EndingScene) drawCredits(g *Game, screen *ebiten.Image) {
a := g.Assets
y0 := ScreenH - int(e.t*endingCreditsPxS)
for i, l := range e.lines {
y := y0 + i*LineH
if y < -LineH || y > ScreenH {
continue
}
col := colDim
if i == 0 || (i > 0 && e.lines[i-1] == "" && i+1 < len(e.lines) && e.lines[i+1] != "") {
col = colText // rubriker
}
a.Draw(screen, l, (ScreenW-a.Width(l))/2, y, col)
}
}

View File

@@ -26,12 +26,20 @@ const (
learnedShowSec = 3.0
progressedShowSec = 2.5
flashShowSec = 2.5
transferBannerSec = 3.0
)
// Mätarnas bredd och mellanrum uppe till höger (per medlem).
const (
meterW = 11
meterGap = 3
)
// LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run;
// scenen översätter input och ritar.
type LevelScene struct {
id string
level *level.Level // aktuell delbana (byts när resan fortsätter på nästa station)
run *sim.Run
cam *sim.Camera
animT float64
@@ -58,14 +66,21 @@ func NewLevelScene(g *Game, id string, seed rng.Seed) (*LevelScene, error) {
if err != nil {
return nil, err
}
s := &LevelScene{
id: id,
run: sim.NewRun(lvl, g.runOptions(id, seed)),
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
}
s := &LevelScene{id: id, run: sim.NewRun(lvl, g.runOptions(id, seed))}
s.rng = rand.New(rand.NewPCG(1, 2))
s.rebind(g)
return s, nil
}
// rebind bygger om allt som hör till den aktuella delbanan: kamera, räls,
// affischer, melodifaser och bakgrundsprat. Körs vid start och vid byte.
func (s *LevelScene) rebind(g *Game) {
lvl := s.run.Level
s.level = lvl
s.cam = sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize))
s.cam.Margin = TileSize // rälsen ligger i kantraden: visa en ruta utanför kartan
s.lastPhase = map[level.Tile]level.Phase{}
s.rng = rand.New(rand.NewPCG(1, 2))
s.particles = nil
s.buildTracks()
s.buildPosters()
g.startChatter(lvl.Noise)
@@ -73,7 +88,6 @@ func NewLevelScene(g *Game, id string, seed rng.Seed) (*LevelScene, error) {
s.lastPhase[tr.tile], _ = s.run.PlatformPhase(tr.tile)
}
s.followPlayer()
return s, nil
}
// TrackPhaseName är fasen för närmaste perrong inom indikatoravståndet
@@ -145,6 +159,9 @@ func (s *LevelScene) Update(g *Game) error {
// Minispel: all input går dit; Esc avbryter minispelet, inte banan.
mx, my := miniMoveXY()
s.run.StepMini(dt, minigame.Input{Move: my, MoveX: mx, Confirm: confirmJustPressed(), Hold: confirmHeld(), Cancel: backJustPressed()})
if s.run.Level != s.level {
s.rebind(g) // bytet: resan fortsätter på nästa station
}
if s.run.Outcome != sim.Ongoing {
s.outcome = g.levelEnded(s.run, s.id)
g.SetScene(NewResultScene(s))
@@ -299,6 +316,14 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
s.drawIndicator(screen, ox, oy)
s.drawHUD(g, screen)
if r := s.run; r.TransferAt >= 0 && r.Time-r.TransferAt < transferBannerSec && r.Mini == nil && r.Outcome == sim.Ongoing {
txt := g.Assets.T("hud.transfer_done")
w := g.Assets.Width(txt) + 16
x := (ScreenW - w) / 2
vector.DrawFilledRect(screen, float32(x), 60, float32(w), LineH+8, colPanel, false)
vector.DrawFilledRect(screen, float32(x), 60, float32(w), 2, colWarn, false)
g.Assets.Draw(screen, txt, x+8, 64, colWarn)
}
if s.run.Mini != nil {
s.drawMinigame(g, screen)
}
@@ -346,27 +371,41 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
a := g.Assets
vector.DrawFilledRect(screen, 0, 0, ScreenW, 24, color.RGBA{24, 20, 37, 200}, false)
dest := r.Level.Destination.Get("romaji")
if r.Know.ShowsDestinationJP() { // hint från nivå 1: resmålet på japanska, att matcha mot skyltarna
dest := r.FinalDestination().Get("romaji")
if !r.IsLastLeg() { // resa med byte: den här delbanan slutar vid bytesstationen
dest += " " + a.T("hud.via") + " " + r.Level.Destination.Get("romaji")
}
if r.Know.ShowsDestinationJP() { // hint från nivå 1: delbanans mål på japanska, att matcha mot skyltarna
dest += " " + r.Level.Destination.Get("ja")
}
name := r.Level.Name.Get(UILang)
if r.Level.NumFloors() > 1 {
name += " " + r.Level.FloorName(r.Player.Tile.Z)
}
if r.Legs() > 1 {
name += fmt.Sprintf(" %d/%d", r.LegNo(), r.Legs())
}
a.Draw(screen, name, 4, 0, colText)
destX := 4 + a.Width(name) + 12
a.Draw(screen, a.T("hud.destination")+": "+dest, destX, 0, colKnown)
a.Draw(screen, fmt.Sprintf("%s Lv%d·%d", a.T("hud.japanese"), r.Know.Level(), r.Know.Count()),
destX+a.Width(a.T("hud.destination")+": "+dest)+12, 0, colDim)
// Japanska-nivån får plats till vänster om mätarna; annars kortas den.
jpX := destX + a.Width(a.T("hud.destination")+": "+dest) + 12
metersX := ScreenW - 4 - len(r.Party.Members)*(meterW+meterGap)
jp := fmt.Sprintf("%s Lv%d·%d", a.T("hud.japanese"), r.Know.Level(), r.Know.Count())
if jpX+a.Width(jp) > metersX-4 {
jp = fmt.Sprintf("Lv%d", r.Know.Level())
}
if jpX+a.Width(jp) <= metersX-4 {
a.Draw(screen, jp, jpX, 0, colDim)
}
timeStr := fmt.Sprintf("%s %3.0fs", a.T("hud.time"), r.Time)
if r.Level.TimeLimitSec > 0 {
if r.TimeLimit() > 0 {
left := r.TimeLeft()
timeStr = fmt.Sprintf("%s %d:%02d", a.T("hud.time_left"), int(left)/60, int(left)%60)
}
wallet := fmt.Sprintf("%s %s %d¥ %s %d¥", timeStr, a.T("hud.card"), r.Wallet.Card, a.T("hud.cash_short"), r.Wallet.Cash)
if r.Level.MaxMissedTrains > 0 {
wallet += fmt.Sprintf(" %s %d/%d", a.T("hud.missed_short"), r.MissedTrains, r.Level.MaxMissedTrains)
if r.MaxMissed() > 0 {
wallet += fmt.Sprintf(" %s %d/%d", a.T("hud.missed_short"), r.MissedTrains, r.MaxMissed())
}
if r.Wallet.Phone {
wallet += fmt.Sprintf(" %s %.0f%%", a.T("hud.phone"), r.Wallet.Battery)
@@ -375,7 +414,7 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
wallet += " " + a.T("hud.ticket") + " " + r.Wallet.Ticket
}
walletCol := colDim
if r.Level.TimeLimitSec > 0 && r.TimeLeft() < 30 {
if r.TimeLimit() > 0 && r.TimeLeft() < 30 {
walletCol = colBad
}
a.Draw(screen, wallet, 4, LineH, walletCol)
@@ -385,9 +424,6 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
return // resultatpanelen, minispelet eller butiken tar över; ingen inforuta eller minikarta
}
lines, accent, bar := s.infoLines(a)
if len(lines) == 0 {
s.drawModeHint(a, screen)
}
if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec {
lines = append(lines, plainLine(fmt.Sprintf("%s: %s = %s (%s)", a.T("hud.learned"), r.Learned,
entry.Translation(UILang), entry.Romaji), colKnown))
@@ -402,7 +438,7 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
}
lines = append(lines, a.wrapLine(plainLine(strings.Join(parts, " "), colWarn), ScreenW-8)...)
}
if r.Flashing(flashShowSec) {
if r.Flashing(flashShowSec) && r.FlashKey != sim.FlashTransfer { // bytet har sin egen banner
col := colBad
if r.FlashKey == sim.FlashRescan {
col = colWarn
@@ -426,6 +462,9 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
// (eller tåget under hen) annars skulle hamna bakom den.
var box image.Rectangle
hasBox := len(lines) > 0
if !hasBox {
s.drawModeHint(a, screen) // lägesrutan bara när ingen inforuta visas
}
if hasBox {
h := 8 + LineH*len(lines)
if bar {
@@ -579,7 +618,7 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
// drawMeters ritar sällskapets mat/dryck/toa-mätare uppe till höger.
func (s *LevelScene) drawMeters(g *Game, screen *ebiten.Image) {
members := s.run.Party.Members
const w, gap = 11, 3
const w, gap = meterW, meterGap
x0 := ScreenW - 4 - len(members)*(w+gap)
for i, m := range members {
x := float32(x0 + i*(w+gap))

View File

@@ -33,9 +33,11 @@ type mapNode struct {
func NewMapScene(g *Game) *MapScene {
m := &MapScene{sides: map[string][]*level.Level{}}
for _, l := range g.Levels {
if l.IsSide() {
switch {
case l.IsLeg(): // delbanor nås via sin resa
case l.IsSide():
m.sides[l.Parent] = append(m.sides[l.Parent], l)
} else {
default:
m.mains = append(m.mains, l)
}
}
@@ -192,8 +194,12 @@ func (m *MapScene) Draw(g *Game, screen *ebiten.Image) {
name = a.T("map.side") + ": " + name
}
a.Draw(screen, name, 8, 134, colText)
info := fmt.Sprintf("→ %s %s %s %s %d", cur.Destination.Get("ja"), cur.Destination.Get("romaji"),
fd := level.FinalOf(g.Levels, cur).Destination
info := fmt.Sprintf("→ %s %s %s %s %d", fd.Get("ja"), fd.Get("romaji"),
strings.Repeat("★", cur.Difficulty)+strings.Repeat("☆", 5-cur.Difficulty), a.T("map.party"), cur.Party)
if n := len(level.ChainOf(g.Levels, cur.ID)); n > 1 {
info += fmt.Sprintf(" %s %d", a.T("map.transfers"), n-1)
}
if g.State.Current != nil && g.State.Current.LevelID == cur.ID {
info += " " + a.T("map.resume")
}

View File

@@ -29,6 +29,10 @@ func (r *ResultScene) Update(g *Game) error {
}
g.SetScene(s)
case confirmJustPressed() || backJustPressed():
if r.from.outcome.StoryCleared {
g.SetScene(NewEndingScene(g)) // storyn klar: hemresan
return nil
}
g.SetScene(NewMapScene(g))
}
return nil