diff --git a/README.md b/README.md index 6760975..f53c8fe 100644 --- a/README.md +++ b/README.md @@ -10,19 +10,21 @@ Storyn följer några polare på semester i Japan, från landningen på flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+ upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md). -**Status:** M1 klar (2026-10-23) – man kan gå runt i labyrinterna med kamera -som följer, välja bana i menyn och nå perrongen. Nästa är M2: tåg med -tidtabell, spärrar och första riktigt spelbara banan. Se +**Status:** M2 klar (2026-10-23) – de två första banorna går att spela på +riktigt: labyrint, skyltar, spärrar som drar från tågkortet, tåg som kommer +och går enligt tidtabell, och vinst eller förlust beroende på vilket tåg du +kliver på. Nästa är M3: japanska skyltar och språksystemet. Se [doc/roadmap.md](doc/roadmap.md). ![Bana 001 – Flygplatsen](doc/img/level-001.png) -Så fungerar det: du startar på den gröna dörrmattan, letar dig genom -stationen förbi skyltar, automater och spärrar, och ställer dig på en -perrong (gul ledlinje). Rätt perrong tar dig till resmålet, fel perrong ger -fel tåg. +Så fungerar det: du startar på den gröna dörrmattan och letar dig genom +stationen. Skyltar visar vilket spår som går vart. Spärrarna släpper bara +igenom dig om kortet har saldo. På perrongen (gul ledlinje) ser du vilket +tåg som går, och när. Står tåget inne trycker du Enter för att kliva på. +Rätt tåg tar dig till resmålet, fel tåg avslutar banan med förlust. -![Resultat – rätt tåg](doc/img/result.png) +![Perrongen – tåget är inne](doc/img/platform.png) Fler bilder och en genomgång finns i [wikin](https://gitea.brasse-pc.eu/brasse/metro-puzzle-jp/wiki). diff --git a/doc/img/level-001.png b/doc/img/level-001.png index 7071982..c9df6fd 100644 Binary files a/doc/img/level-001.png and b/doc/img/level-001.png differ diff --git a/doc/img/level-002.png b/doc/img/level-002.png index 55063cd..216f6ef 100644 Binary files a/doc/img/level-002.png and b/doc/img/level-002.png differ diff --git a/doc/img/menu.png b/doc/img/menu.png index c8483a7..f6a73fa 100644 Binary files a/doc/img/menu.png and b/doc/img/menu.png differ diff --git a/doc/img/platform.png b/doc/img/platform.png new file mode 100644 index 0000000..3cc3bdd Binary files /dev/null and b/doc/img/platform.png differ diff --git a/doc/img/result.png b/doc/img/result.png index f933cf9..5e0e3e1 100644 Binary files a/doc/img/result.png and b/doc/img/result.png differ diff --git a/doc/img/sign.png b/doc/img/sign.png new file mode 100644 index 0000000..a125515 Binary files /dev/null and b/doc/img/sign.png differ diff --git a/doc/mal-och-tester.md b/doc/mal-och-tester.md index b79eca6..29b6f84 100644 --- a/doc/mal-och-tester.md +++ b/doc/mal-och-tester.md @@ -83,6 +83,12 @@ att bygga på Linux som på Windows. | `internal/sim` | `TestContinuousMovementKeepsPace`, `TestStopsAtTileBoundaryWithoutInput`, `TestDirectionChangesOnlyAtTileBoundary` | Jämn fart med hållen tangent, stopp vid rutgräns, riktningsbyte bara vid rutgräns | | `internal/sim` | `TestWorldPosInterpolates`, `TestFrameExactArrival`, `TestHugeDtIsBounded` | Pixelposition mellan rutor; exakt 10 bildrutor per ruta vid 6 rutor/s; inga oändliga loopar | | `internal/sim` | `TestCamera*` | Kameran centrerar, klampas mot kanter och centrerar små världar | +| `internal/level` | `TestScheduleStatus`, `TestScheduleAndFareDefaults` | Tidtabellen ger rätt "inne/borta" och sekunder kvar; standardvärden fylls i | +| `internal/level` | `TestSignAt`, `TestPath`, `TestUngatedPlatforms` | Skylttext per ruta; BFS-väg är kortast och sammanhängande; perronger utan spärr framför upptäcks | +| `internal/level` | `TestAllShippedLevels` (utökat) | …plus: tidtabeller giltiga, alla skyltrutor har sv/en-text, alla tåg har spår, **inga perronger nås utan spärr** | +| `internal/sim` | `TestBotWinsLevel001` | **Simuleringstest M2:** bot går kortaste vägen, passerar spärren (saldo dras en gång), väntar in tåget, kliver på → vinst; Step är no-op efteråt | +| `internal/sim` | `TestBotLosesOnWrongTrain`, `TestBoardOnlyWhenTrainPresent` | Fel tåg → förlust; Enter utan tåg inne gör inget | +| `internal/sim` | `TestGateNeedsCardBalance`, `TestSignHereOnlyWhenStill` | Tomt kort stoppas och registreras för HUD; mellan två spärrutor gratis; skylt visas bara stilla | | `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan | ### Planerade tester per milstolpe @@ -90,7 +96,7 @@ att bygga på Linux som på Windows. | Milstolpe (se roadmap) | Tester som ska finnas när den är klar | |------------------------|----------------------------------------| | ~~M1 Rörelse & kamera~~ ✓ | Kollision mot vägg, rörelse i 4 riktningar, kamera klampas till kartan – finns, se tabellen ovan | -| M2 Stationen | Tidtabell: nästa avgång beräknas rätt; spärr släpper igenom med giltigt kort/biljett och stoppar annars; vinst när rätt tåg tas, förlust vid fel tåg; simuleringstest: bot går kortaste vägen och klarar bana 001 | +| ~~M2 Stationen~~ ✓ | Tidtabell, spärr med/utan saldo, vinst/förlust, bot klarar bana 001 headless – finns, se tabellen ovan | | M3 Japanska & skyltar | Delvis översättning ger exakt de ord spelaren kan; andel engelska följer svårighetstabellen; ordboken: alla nycklar har kana/romaji/en/sv; alla skyltord i banorna finns i ordboken; hjälptid = f(japanska-nivå) | | M4 Lämmeltåget | Mätare sjunker med rätt takt; event-frekvens ökar med gruppstorlek; event väljs deterministiskt per seed | | M5 Minikarta | Upptäckta rutor markeras, återställs per bana; avgångar syns från start | diff --git a/doc/plan.md b/doc/plan.md index b259372..acc33f7 100644 --- a/doc/plan.md +++ b/doc/plan.md @@ -32,7 +32,7 @@ brasse-linux01-runnern, inte Pi5:n. ## Milstolpar -M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 första spelbara banan → M3 japanska/skyltar +M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar → M4 lämmeltåget → M5 minikarta → M6 kort/biljetter/minispel → M7 svårighet/poäng/debuffar → M8 progression/sparfiler → M9 navigeringslägen → M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release. diff --git a/doc/roadmap.md b/doc/roadmap.md index 9eb479a..ae6661f 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -58,7 +58,7 @@ metro-puzzle-jp/ │ ├── res/ hittar och läser resources/ (ren Go) │ ├── rng/ seedad slump (ren Go) │ ├── level/ banformat, validering, BFS (ren Go) -│ ├── sim/ rutbaserad rörelse, kollision, kamera (ren Go) +│ ├── sim/ rutbaserad rörelse, kollision, kamera, banomgång (Run: klocka, kort, spärrar, tåg) – ren Go │ ├── game/ Ebitengine-loop, scener, input, rendering, skriptläge ← får använda Ebitengine │ ├── archtest/ beroenderegler │ └── … kommande: lang/, nav/, score/, save/, minigame/, ui/, audio/ @@ -140,18 +140,27 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n. det naturliga slutet på en bana; tidtabell, spärrar och väntan på tåg kommer i M2 som planerat. -### M2 – Stationen: tåg, spärrar och vinst/förlust → första spelbara banan +### M2 – Stationen: tåg, spärrar och vinst/förlust ✓ (klar 2026-10-23) - **Mål:** bana 001 går att klara och misslyckas. -- Tåg med tidtabell som står vid perrongen en stund och avgår; nästa avgång - visas vid perrongen. -- Spärrar som kräver kort (kortet finns och har alltid saldo i M2). -- Vinst när rätt tåg tas; förlust när fel tåg tas. Resultatskärm. -- Interaktion med skyltar (visar text, än så länge på svenska/engelska). -- **Teknik:** `internal/sim/schedule`, `internal/level` utökas med - tidtabell + skyltinnehåll. -- **Klar när:** en simuleringstest låter en bot klara bana 001 headless; - manuellt spel från start till mål fungerar. +- Tåg med tidtabell per perrong (`schedule`: första ankomst, intervall, + uppehåll). Tågvagnen ritas bortom perrongkanten när tåget är inne; HUD:en + visar spår, linje, resmål och "avgår om / nästa tåg om". +- Ombordstigning är ett aktivt val: stå på perrongen och tryck Enter medan + tåget är inne. Rätt tåg = vinst, fel tåg = förlust, resultatskärm med tid, + steg, spärrpassager och kortsaldo. +- Spärrar kräver kort med saldo (startsaldo 2000 ¥, avgift per bana, dras + när man går in i en spärr från en vanlig ruta). Tomt kort stoppas med + meddelande. Riktig ekonomi och påfyllning kommer i M6. +- Skyltar har text (`signs` i bandatan, sv/en/ja) som visas när man står + stilla på skyltrutan. Japanskan ritas först i M3. +- **Regel för banor:** alla perronger ska ligga innanför spärrar. + `level.UngatedPlatforms` kontrolleras i det datadrivna testet; bana 001 + byggdes om när regeln avslöjade två genvägar. +- **Teknik:** `internal/sim.Run` (klocka, kort, spärrlogik, ombordstigning – + ren Go), `level.Schedule`, `level.Path` (BFS), skriptkommandot `waittrain`. +- **Klar när:** `TestBotWinsLevel001` låter en bot gå kortaste vägen, + passera spärren, vänta in tåget och kliva på – headless. ✓ ### M3 – Japanska och skyltsystemet diff --git a/internal/game/assets.go b/internal/game/assets.go index 6cf7b64..b012d34 100644 --- a/internal/game/assets.go +++ b/internal/game/assets.go @@ -21,6 +21,7 @@ type Assets struct { ResDir string LevelsDir string Tiles map[level.Tile]*ebiten.Image + Train *ebiten.Image // tågvagn, ritas vid perrongen när tåget är inne Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2] Text map[string]string } @@ -38,13 +39,17 @@ func LoadAssets() (*Assets, error) { if err != nil { return nil, err } - if w := tiles.Bounds().Dx(); w < TileSize*len(tileOrder) { - return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, TileSize*len(tileOrder)) + sub := func(i int) *ebiten.Image { + return tiles.SubImage(image.Rect(i*TileSize, 0, (i+1)*TileSize, TileSize)).(*ebiten.Image) + } + if w, need := tiles.Bounds().Dx(), TileSize*(len(tileOrder)+1); w < need { + return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, need) } for i, t := range tileOrder { - a.Tiles[t] = tiles.SubImage(image.Rect(i*TileSize, 0, (i+1)*TileSize, TileSize)).(*ebiten.Image) + a.Tiles[t] = sub(i) } a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör + a.Train = sub(len(tileOrder)) player, err := loadImage("sprites", "player.png") if err != nil { diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go index 11725ab..4f49b22 100644 --- a/internal/game/scene_level.go +++ b/internal/game/scene_level.go @@ -17,15 +17,16 @@ import ( // Spelarens grundfart i rutor per sekund (blir en stat i M7). const playerSpeed = 6.0 -// LevelScene är själva spelandet av en bana. +// Så länge "spärren nekade" visas efter försöket. +const deniedShowSec = 2.0 + +// 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 - lvl *level.Level - player *sim.Mover - cam *sim.Camera - animT float64 - elapsed float64 - steps int + id string + run *sim.Run + cam *sim.Camera + animT float64 } // NewLevelScene laddar banan och ställer spelaren på startrutan. @@ -35,20 +36,25 @@ func NewLevelScene(a *Assets, id string) (*LevelScene, error) { return nil, err } s := &LevelScene{ - id: id, - lvl: lvl, - player: sim.NewMover(lvl.SpawnPoint(), playerSpeed), - cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)), + id: id, + run: sim.NewRun(lvl, playerSpeed), + cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)), } s.followPlayer() return s, nil } // PlayerTile returnerar rutan spelaren står på/lämnar (används av skriptläget). -func (s *LevelScene) PlayerTile() level.Point { return s.player.Tile } +func (s *LevelScene) PlayerTile() level.Point { return s.run.Player.Tile } + +// TrainHere rapporterar om spelaren står på en perrong vars tåg är inne (skriptläget). +func (s *LevelScene) TrainHere() bool { + tr, at, _ := s.run.PlatformHere() + return tr != nil && at +} func (s *LevelScene) followPlayer() { - x, y := s.player.WorldPos(TileSize) + x, y := s.run.Player.WorldPos(TileSize) s.cam.Follow(x+TileSize/2, y+TileSize/2) } @@ -61,50 +67,116 @@ func (s *LevelScene) Update(g *Game) error { g.SetScene(m) return nil } - s.elapsed += dt - s.steps += s.player.Step(dt, dirInput(), s.lvl.CanEnter) - if s.player.Moving() { + s.run.Step(dt, dirInput(), confirmJustPressed()) + if s.run.Player.Moving() { s.animT += dt } else { s.animT = 0 - // Står vi på en perrong är banan slut (M2 ger tåg, tidtabell och spärrar). - if t := s.lvl.At(s.player.Tile.X, s.player.Tile.Y); t == level.Platform || t == level.WrongPlatform { - g.SetScene(NewResultScene(s, s.lvl.TrainAt(t))) - } } s.followPlayer() + if s.run.Outcome != sim.Ongoing { + g.SetScene(NewResultScene(s)) + } return nil } +// trainTile är rutan "bortom perrongkanten" där tågvagnen ritas: första +// väggruta nedanför, till höger, till vänster eller ovanför perrongen. +func (s *LevelScene) trainTile(p level.Point) level.Point { + for _, d := range [...]level.Point{{X: 0, Y: 1}, {X: 1, Y: 0}, {X: -1, Y: 0}, {X: 0, Y: -1}} { + n := level.Point{X: p.X + d.X, Y: p.Y + d.Y} + if s.run.Level.At(n.X, n.Y) == level.Wall { + return n + } + } + return p +} + func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) { screen.Fill(color.RGBA{24, 20, 37, 255}) + lvl := s.run.Level ox, oy := s.cam.Offset() ox, oy = math.Round(ox), math.Round(oy) // Rita bara rutor som syns. x0 := max(0, int(s.cam.X)/TileSize) y0 := max(0, int(s.cam.Y)/TileSize) - x1 := min(s.lvl.Width(), int(s.cam.X+ScreenW)/TileSize+2) - y1 := min(s.lvl.Height(), int(s.cam.Y+ScreenH)/TileSize+2) + x1 := min(lvl.Width(), int(s.cam.X+ScreenW)/TileSize+2) + y1 := min(lvl.Height(), int(s.cam.Y+ScreenH)/TileSize+2) var op ebiten.DrawImageOptions for y := y0; y < y1; y++ { for x := x0; x < x1; x++ { op.GeoM.Reset() op.GeoM.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize)) - screen.DrawImage(g.Assets.Tiles[s.lvl.At(x, y)], &op) + screen.DrawImage(g.Assets.Tiles[lvl.At(x, y)], &op) + } + } + // Tåg som står inne. + for _, t := range []level.Tile{level.Platform, level.WrongPlatform} { + if !s.run.TrainVisible(t) { + continue + } + for _, p := range lvl.Find(t) { + tt := s.trainTile(p) + op.GeoM.Reset() + op.GeoM.Translate(ox+float64(tt.X*TileSize), oy+float64(tt.Y*TileSize)) + screen.DrawImage(g.Assets.Train, &op) } } - px, py := s.player.WorldPos(TileSize) + px, py := s.run.Player.WorldPos(TileSize) op.GeoM.Reset() op.GeoM.Translate(ox+math.Round(px), oy+math.Round(py)) - screen.DrawImage(g.Assets.PlayerFrame(s.player.Facing, s.player.Moving(), s.animT), &op) + screen.DrawImage(g.Assets.PlayerFrame(s.run.Player.Facing, s.run.Player.Moving(), s.animT), &op) - // HUD + s.drawHUD(g, screen) +} + +func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { + r := s.run + a := g.Assets vector.DrawFilledRect(screen, 0, 0, ScreenW, 24, color.RGBA{24, 20, 37, 200}, false) // TODO(M3): visa japanska när en font med kana/kanji finns – debugfonten saknar dem. - ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %s: %s", s.lvl.Name.Get("sv"), g.Assets.T("hud.destination"), - s.lvl.Destination.Get("romaji")), 4, 1) - ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %3.0fs %s %d %s", g.Assets.T("hud.time"), s.elapsed, - g.Assets.T("hud.steps"), s.steps, g.Assets.T("hud.controls")), 4, 12) + ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %s: %s", r.Level.Name.Get("sv"), a.T("hud.destination"), + r.Level.Destination.Get("romaji")), 4, 1) + ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %3.0fs %s %d %s %d¥ %s", a.T("hud.time"), r.Time, + a.T("hud.steps"), r.Steps, a.T("hud.card"), r.Card.Balance, a.T("hud.controls")), 4, 12) + + // Inforutan: perronginfo, skylt eller nekad spärr. Visas inte när banan är avgjord. + if r.Outcome != sim.Ongoing { + return + } + var lines []string + accent := color.RGBA{58, 68, 102, 255} + if tr, at, secs := r.PlatformHere(); tr != nil { + lines = append(lines, fmt.Sprintf("%s %s %s -> %s", a.T("hud.track"), tr.Track, tr.Line.Get("romaji"), tr.Destination.Get("romaji"))) + if at { + accent = color.RGBA{99, 199, 77, 255} + lines = append(lines, fmt.Sprintf("%s %.0f s %s", a.T("hud.departs_in"), math.Ceil(secs), a.T("hud.board"))) + } else { + accent = color.RGBA{254, 174, 52, 255} + lines = append(lines, fmt.Sprintf("%s %.0f s", a.T("hud.next_train_in"), math.Ceil(secs))) + } + } else if sg := r.SignHere(); sg != nil { + lines = wrap(a.T("hud.sign")+": "+sg.Text.Get("sv"), textCols) + } else if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec { + accent = color.RGBA{228, 59, 68, 255} + lines = []string{a.T("hud.gate_denied")} + } + if len(lines) == 0 { + return + } + h := float32(8 + 12*len(lines)) + // Rutan ligger i nederkant, men flyttas upp under HUD:en om spelaren + // (eller tåget under hen) annars skulle hamna bakom den. + _, py := r.Player.WorldPos(TileSize) + y := float32(ScreenH) - h + if float32(py-s.cam.Y)+2*TileSize > y { + y = 24 + } + vector.DrawFilledRect(screen, 0, y, ScreenW, h, color.RGBA{24, 20, 37, 230}, false) + vector.DrawFilledRect(screen, 0, y, ScreenW, 2, accent, false) + for i, l := range lines { + ebitenutil.DebugPrintAt(screen, l, 4, int(y)+4+12*i) + } } diff --git a/internal/game/scene_result.go b/internal/game/scene_result.go index 69724c3..9ee6650 100644 --- a/internal/game/scene_result.go +++ b/internal/game/scene_result.go @@ -8,21 +8,16 @@ import ( "github.com/hajimehoshi/ebiten/v2/ebitenutil" "github.com/hajimehoshi/ebiten/v2/vector" - "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" ) -// ResultScene visas när spelaren nått en perrong. +// ResultScene visas när banan är avgjord (rätt eller fel tåg). type ResultScene struct { - from *LevelScene - train *level.Train + from *LevelScene } // NewResultScene bygger resultatet för banan som just spelades. -func NewResultScene(from *LevelScene, train *level.Train) *ResultScene { - return &ResultScene{from: from, train: train} -} - -func (r *ResultScene) correct() bool { return r.train != nil && r.train.Correct } +func NewResultScene(from *LevelScene) *ResultScene { return &ResultScene{from: from} } func (r *ResultScene) Update(g *Game) error { switch { @@ -44,22 +39,33 @@ func (r *ResultScene) Update(g *Game) error { func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { r.from.Draw(g, screen) // banan i bakgrunden - bg := color.RGBA{24, 20, 37, 230} - vector.DrawFilledRect(screen, 20, 40, ScreenW-40, 100, bg, false) - accent := color.RGBA{228, 59, 68, 255} - headline := g.Assets.T("level.wrong_train") - if r.correct() { - accent = color.RGBA{99, 199, 77, 255} - headline = g.Assets.T("level.cleared") - } - vector.DrawFilledRect(screen, 20, 40, ScreenW-40, 3, accent, false) + run := r.from.run + a := g.Assets + vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 112, color.RGBA{24, 20, 37, 235}, false) - ebitenutil.DebugPrintAt(screen, headline, 28, 50) - if r.train != nil { - ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s -> %s", r.train.Line.Get("romaji"), - r.train.Destination.Get("romaji")), 28, 66) // TODO(M3): japanska när fonten finns + accent, headline := color.RGBA{228, 59, 68, 255}, a.T("level.wrong_train") + if run.Outcome == sim.Won { + accent, headline = color.RGBA{99, 199, 77, 255}, a.T("level.cleared") } - ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %.1fs %s %d", g.Assets.T("hud.time"), r.from.elapsed, - g.Assets.T("hud.steps"), r.from.steps), 28, 86) - ebitenutil.DebugPrintAt(screen, g.Assets.T("result.hint"), 28, 118) + vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 3, accent, false) + ebitenutil.DebugPrintAt(screen, headline, 28, 46) + + y := 62 + if tr := run.Boarded; tr != nil { + // TODO(M3): japanska när fonten finns + ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %s %s -> %s", a.T("hud.track"), tr.Track, + tr.Line.Get("romaji"), tr.Destination.Get("romaji")), 28, y) + y += 12 + if run.Outcome == sim.Lost { + for _, l := range wrap(fmt.Sprintf(a.T("result.wrong_detail"), tr.Destination.Get("romaji"), + run.Level.Destination.Get("romaji")), 44) { + ebitenutil.DebugPrintAt(screen, l, 28, y) + y += 12 + } + } + } + y += 6 + ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %.1fs %s %d %s %d %s %d¥", a.T("hud.time"), run.Time, + a.T("hud.steps"), run.Steps, a.T("result.gates"), run.GatePasses, a.T("result.card"), run.Card.Balance), 28, y) + ebitenutil.DebugPrintAt(screen, a.T("result.hint"), 28, 128) } diff --git a/internal/game/script.go b/internal/game/script.go index 99af1c7..d73f987 100644 --- a/internal/game/script.go +++ b/internal/game/script.go @@ -19,6 +19,7 @@ import ( // Stegen skiljs med ';': // // wait N vänta N bildrutor +// waittrain vänta tills tåget vid perrongen spelaren står på är inne // press KEY tryck enter | esc | r | up | down en bildruta // move DIR N gå N rutor åt up | down | left | right (släpper när rutorna är avklarade) // shot PATH spara aktuell bild som PNG (intern upplösning × shotScale) @@ -83,7 +84,7 @@ func parseScript(src string) (*script, error) { return nil, fmt.Errorf("script: 'shot PATH'") } st.arg = f[1] - case "quit": + case "quit", "waittrain": default: return nil, fmt.Errorf("script: okänt kommando %q", f[0]) } @@ -109,6 +110,9 @@ func parseDir(s string) sim.Dir { // playerTiler implementeras av scener som har en spelare i rutnätet. type playerTiler interface{ PlayerTile() level.Point } +// trainWaiter implementeras av scener där man kan vänta på ett tåg. +type trainWaiter interface{ TrainHere() bool } + // tick körs i början av varje Update. frame är senast ritade bild. // Returnerar ebiten.Termination när skriptet vill avsluta. func (s *script) tick(g *Game, frame *ebiten.Image) error { @@ -151,6 +155,10 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error { return err } s.advance() + case "waittrain": + if tw, ok := g.scene.(trainWaiter); !ok || tw.TrainHere() { + s.advance() + } case "quit": return ebiten.Termination } diff --git a/internal/game/text.go b/internal/game/text.go new file mode 100644 index 0000000..39b8430 --- /dev/null +++ b/internal/game/text.go @@ -0,0 +1,27 @@ +package game + +import "strings" + +// Debugfonten är 6 px bred per tecken; 320 px ger drygt 50 tecken. +const textCols = 50 + +// wrap radbryter en text på ordgränser till högst width tecken per rad. +func wrap(s string, width int) []string { + var lines []string + cur := "" + for _, w := range strings.Fields(s) { + switch { + case cur == "": + cur = w + case len([]rune(cur))+1+len([]rune(w)) > width: + lines = append(lines, cur) + cur = w + default: + cur += " " + w + } + } + if cur != "" { + lines = append(lines, cur) + } + return lines +} diff --git a/internal/level/level.go b/internal/level/level.go index f4fbb07..8c521e8 100644 --- a/internal/level/level.go +++ b/internal/level/level.go @@ -9,6 +9,7 @@ package level import ( "encoding/json" "fmt" + "math" "os" "path/filepath" "sort" @@ -55,23 +56,62 @@ func (t Text) Get(lang string) string { return "" } +// Schedule är ett tågs tidtabell i sekunder från banstart: första ankomst +// vid OffsetSec, därefter var IntervalSec; tåget står DwellSec vid perrongen. +type Schedule struct { + OffsetSec float64 `json:"offsetSec"` + IntervalSec float64 `json:"intervalSec"` + DwellSec float64 `json:"dwellSec"` +} + +// Standardvärden som fylls i av Validate när fältet är 0. +const ( + DefaultIntervalSec = 30.0 + DefaultDwellSec = 10.0 + DefaultFareYen = 200 +) + +// Status returnerar om tåget står vid perrongen vid tiden t, och hur många +// sekunder det är kvar – till avgång om det står där, annars till ankomst. +func (s Schedule) Status(t float64) (atPlatform bool, seconds float64) { + if t < s.OffsetSec { + return false, s.OffsetSec - t + } + phase := math.Mod(t-s.OffsetSec, s.IntervalSec) + if phase < s.DwellSec { + return true, s.DwellSec - phase + } + return false, s.IntervalSec - phase +} + // Train är ett tåg (eller en buss) som avgår från en perrong i banan. type Train struct { - Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid - Line Text `json:"line"` // linjens namn - Destination Text `json:"destination"` // slutstation - Correct bool `json:"correct"` // tar spelaren till rätt resmål + Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid + Track string `json:"track"` // spårnummer som visas på skyltar/HUD, t.ex. "2" + Line Text `json:"line"` // linjens namn + Destination Text `json:"destination"` // slutstation + Correct bool `json:"correct"` // tar spelaren till rätt resmål + Schedule Schedule `json:"schedule"` +} + +// SignText är texten på en skyltruta ('i'). +type SignText struct { + X int `json:"x"` + Y int `json:"y"` + Text Text `json:"text"` } // Level är en bana. type Level struct { - ID string `json:"id"` - Name Text `json:"name"` - Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår) - Destination Text `json:"destination"` // dit spelaren ska - TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns - Grid []string `json:"grid"` - Trains []Train `json:"trains"` + ID string `json:"id"` + Name Text `json:"name"` + Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår) + Destination Text `json:"destination"` // dit spelaren ska + TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns + FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard) + Grid []string `json:"grid"` + Trains []Train `json:"trains"` + Signs []SignText `json:"signs"` } // Point är en rutkoordinat. @@ -161,7 +201,8 @@ func (l *Level) Validate() error { return fmt.Errorf("level %s: saknar tåg", l.ID) } correct := 0 - for i, tr := range l.Trains { + for i := range l.Trains { + tr := &l.Trains[i] if len(tr.Platform) != 1 || (Tile(tr.Platform[0]) != Platform && Tile(tr.Platform[0]) != WrongPlatform) { return fmt.Errorf("level %s: tåg %d har ogiltig perrong %q", l.ID, i, tr.Platform) } @@ -171,10 +212,87 @@ func (l *Level) Validate() error { if tr.Correct { correct++ } + sc := &tr.Schedule + if sc.IntervalSec == 0 { + sc.IntervalSec = DefaultIntervalSec + } + if sc.DwellSec == 0 { + sc.DwellSec = DefaultDwellSec + } + if sc.OffsetSec < 0 || sc.IntervalSec <= 0 || sc.DwellSec <= 0 || sc.DwellSec >= sc.IntervalSec { + return fmt.Errorf("level %s: tåg %d har orimlig tidtabell %+v (kräver offset ≥ 0, 0 < dwell < interval)", l.ID, i, *sc) + } } if correct == 0 { return fmt.Errorf("level %s: inget tåg är markerat som rätt", l.ID) } + if l.FareYen == 0 { + l.FareYen = DefaultFareYen + } + if l.FareYen < 0 { + return fmt.Errorf("level %s: negativ avgift", l.ID) + } + // Skyltar: varje post måste stå på en 'i'-ruta och varje 'i'-ruta ha text. + seen := map[Point]bool{} + for i, sg := range l.Signs { + p := Point{sg.X, sg.Y} + if l.At(p.X, p.Y) != Sign { + return fmt.Errorf("level %s: skylt %d på (%d,%d) står inte på en skyltruta", l.ID, i, p.X, p.Y) + } + if seen[p] { + return fmt.Errorf("level %s: två skyltar på (%d,%d)", l.ID, p.X, p.Y) + } + if len(sg.Text) == 0 { + return fmt.Errorf("level %s: skylt på (%d,%d) saknar text", l.ID, p.X, p.Y) + } + seen[p] = true + } + for _, p := range l.Find(Sign) { + if !seen[p] { + return fmt.Errorf("level %s: skyltrutan (%d,%d) saknar text i \"signs\"", l.ID, p.X, p.Y) + } + } + return nil +} + +// SignAt returnerar skylten på en ruta, eller nil. +func (l *Level) SignAt(p Point) *SignText { + for i := range l.Signs { + if l.Signs[i].X == p.X && l.Signs[i].Y == p.Y { + return &l.Signs[i] + } + } + return nil +} + +// Path returnerar kortaste vägen från from till to (båda inkluderade) via +// rutor som canEnter godkänner, eller nil om ingen väg finns. +func (l *Level) Path(from, to Point, canEnter func(Point) bool) []Point { + prev := map[Point]Point{from: from} + queue := []Point{from} + for len(queue) > 0 { + p := queue[0] + queue = queue[1:] + if p == to { + var path []Point + for ; p != from; p = prev[p] { + path = append(path, p) + } + path = append(path, from) + for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 { + path[i], path[j] = path[j], path[i] + } + return path + } + for _, d := range [...]Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} { + n := Point{p.X + d.X, p.Y + d.Y} + if _, ok := prev[n]; ok || !canEnter(n) { + continue + } + prev[n] = p + queue = append(queue, n) + } + } return nil } @@ -215,6 +333,37 @@ func (l *Level) Solvable() bool { return false } +// UngatedPlatforms listar perronger som går att nå från start utan att +// passera någon spärr. Speldesignen kräver att alla perronger ligger +// innanför spärrar, så listan ska vara tom för skeppade banor. +func (l *Level) UngatedPlatforms() []Point { + start := l.SpawnPoint() + seen := map[Point]bool{start: true} + queue := []Point{start} + for len(queue) > 0 { + p := queue[0] + queue = queue[1:] + for _, d := range [...]Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} { + n := Point{p.X + d.X, p.Y + d.Y} + t := l.At(n.X, n.Y) + if seen[n] || !t.Passable() || t == Gate { + continue + } + seen[n] = true + queue = append(queue, n) + } + } + var out []Point + for _, t := range []Tile{Platform, WrongPlatform} { + for _, p := range l.Find(t) { + if seen[p] { + out = append(out, p) + } + } + } + return out +} + // Unreachable listar interaktiva rutor (perronger, automater, skyltar) som // inte går att nå – praktiskt som varning när man bygger banor. func (l *Level) Unreachable() []Point { diff --git a/internal/level/level_test.go b/internal/level/level_test.go index 2013450..8289ff6 100644 --- a/internal/level/level_test.go +++ b/internal/level/level_test.go @@ -1,6 +1,7 @@ package level import ( + "math" "os" "path/filepath" "sort" @@ -69,15 +70,20 @@ func TestOutsideGridIsWall(t *testing.T) { func TestValidationErrors(t *testing.T) { cases := map[string]string{ - "ojämn rad": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#.##"`, 1), - "okänt tecken": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#X#"`, 1), - "två starter": strings.Replace(sample, `"#S..#P#"`, `"#S.S#P#"`, 1), - "ingen start": strings.Replace(sample, `"#S..#P#"`, `"#...#P#"`, 1), - "ingen perrong": strings.Replace(sample, `"#S..#P#"`, `"#S..#.#"`, 1), - "inget rätt tåg": strings.Replace(sample, `"correct": true`, `"correct": false`, 1), - "tåg vid fel perrong": strings.Replace(sample, `"platform": "P"`, `"platform": "p"`, 1), - "saknar id": strings.Replace(sample, `"id": "test"`, `"id": ""`, 1), - "trasig json": "{", + "ojämn rad": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#.##"`, 1), + "okänt tecken": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#X#"`, 1), + "två starter": strings.Replace(sample, `"#S..#P#"`, `"#S.S#P#"`, 1), + "ingen start": strings.Replace(sample, `"#S..#P#"`, `"#...#P#"`, 1), + "ingen perrong": strings.Replace(sample, `"#S..#P#"`, `"#S..#.#"`, 1), + "inget rätt tåg": strings.Replace(sample, `"correct": true`, `"correct": false`, 1), + "tåg vid fel perrong": strings.Replace(sample, `"platform": "P"`, `"platform": "p"`, 1), + "dwell >= interval": strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"intervalSec": 10, "dwellSec": 10}`, 1), + "negativ offset": strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"offsetSec": -1}`, 1), + "skylt utanför i-ruta": strings.Replace(sample, `"trains"`, `"signs": [{"x": 1, "y": 1, "text": {"sv": "x"}}], "trains"`, 1), + "i-ruta utan skylt": strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1), + "skylt utan text": strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1), `"trains"`, `"signs": [{"x": 2, "y": 1, "text": {}}], "trains"`, 1), + "saknar id": strings.Replace(sample, `"id": "test"`, `"id": ""`, 1), + "trasig json": "{", } for name, src := range cases { if _, err := Parse([]byte(src)); err == nil { @@ -133,6 +139,19 @@ func TestAllShippedLevels(t *testing.T) { if u := l.Unreachable(); len(u) > 0 { t.Errorf("onåbara interaktiva rutor: %v", u) } + if u := l.UngatedPlatforms(); len(u) > 0 { + t.Errorf("perronger som nås utan att passera en spärr: %v – spärrarna måste vara flaskhalsar", u) + } + for i, tr := range l.Trains { + if tr.Track == "" { + t.Errorf("tåg %d saknar spårnummer", i) + } + } + for _, sg := range l.Signs { + if sg.Text["sv"] == "" || sg.Text["en"] == "" { + t.Errorf("skylt (%d,%d) saknar sv/en-text", sg.X, sg.Y) + } + } }) } } @@ -169,3 +188,80 @@ func TestListAndLoadFile(t *testing.T) { t.Fatal("saknad fil ska ge fel") } } + +func TestScheduleStatus(t *testing.T) { + s := Schedule{OffsetSec: 5, IntervalSec: 30, DwellSec: 10} + cases := []struct { + t float64 + at bool + secs float64 + }{ + {0, false, 5}, {5, true, 10}, {14.9, true, 0.1}, {15, false, 20}, {34.9, false, 0.1}, {35, true, 10}, {70, true, 5}, {80, false, 15}, + } + for _, c := range cases { + at, secs := s.Status(c.t) + if at != c.at || math.Abs(secs-c.secs) > 1e-9 { + t.Errorf("t=%v: at=%v secs=%.2f, vill ha at=%v secs=%.2f", c.t, at, secs, c.at, c.secs) + } + } +} + +func TestScheduleAndFareDefaults(t *testing.T) { + l := mustParse(t, sample) + sc := l.Trains[0].Schedule + if sc.IntervalSec != DefaultIntervalSec || sc.DwellSec != DefaultDwellSec || sc.OffsetSec != 0 { + t.Fatalf("standardtidtabell saknas: %+v", sc) + } + if l.FareYen != DefaultFareYen { + t.Fatalf("standardavgift saknas: %d", l.FareYen) + } +} + +func TestSignAt(t *testing.T) { + src := strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1), + `"trains"`, `"signs": [{"x": 2, "y": 1, "text": {"sv": "Hej"}}], "trains"`, 1) + l := mustParse(t, src) + if sg := l.SignAt(Point{2, 1}); sg == nil || sg.Text.Get("sv") != "Hej" { + t.Fatalf("SignAt = %v", sg) + } + if l.SignAt(Point{1, 1}) != nil { + t.Fatal("ingen skylt på startrutan") + } +} + +func TestPath(t *testing.T) { + l := mustParse(t, sample) + path := l.Path(l.SpawnPoint(), Point{5, 1}, l.CanEnter) + // S(1,1) -> (1,2) -> (1,3) -> (2,3) -> (3,3) -> (4,3) -> (5,3) -> (5,2) -> (5,1) + if len(path) != 9 || path[0] != (Point{1, 1}) || path[len(path)-1] != (Point{5, 1}) { + t.Fatalf("path = %v", path) + } + for i := 1; i < len(path); i++ { + dx, dy := path[i].X-path[i-1].X, path[i].Y-path[i-1].Y + if dx*dx+dy*dy != 1 { + t.Fatalf("steg %d är inte en grannruta: %v -> %v", i, path[i-1], path[i]) + } + } + if l.Path(l.SpawnPoint(), Point{0, 0}, l.CanEnter) != nil { + t.Fatal("väg in i en vägg ska vara nil") + } + if p := l.Path(l.SpawnPoint(), l.SpawnPoint(), l.CanEnter); len(p) != 1 { + t.Fatalf("väg till sig själv = %v", p) + } +} + +func TestUngatedPlatforms(t *testing.T) { + // Perrong utan spärr framför -> rapporteras. + l := mustParse(t, sample) + if u := l.UngatedPlatforms(); len(u) != 1 || u[0] != (Point{5, 1}) { + t.Fatalf("UngatedPlatforms = %v, vill ha [(5,1)]", u) + } + // Spärr i enda passagen -> inget rapporteras, men banan är fortfarande lösbar. + l = mustParse(t, strings.Replace(sample, `"#.#...#"`, `"#.#.G.#"`, 1)) + if u := l.UngatedPlatforms(); len(u) != 0 { + t.Fatalf("UngatedPlatforms = %v, vill ha inga", u) + } + if !l.Solvable() { + t.Fatal("banan ska vara lösbar med spärren passerbar") + } +} diff --git a/internal/sim/run.go b/internal/sim/run.go new file mode 100644 index 0000000..7364bdf --- /dev/null +++ b/internal/sim/run.go @@ -0,0 +1,138 @@ +package sim + +import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + +// Outcome är hur en banomgång står sig. +type Outcome int + +// Utfall. +const ( + Ongoing Outcome = iota + Won // klev på rätt tåg + Lost // klev på fel tåg +) + +// Card är tågkortet (IC-kortet). Saldo i yen. +type Card struct{ Balance int } + +// CanPay rapporterar om saldot räcker. +func (c *Card) CanPay(yen int) bool { return c.Balance >= yen } + +// Pay drar beloppet om det går. +func (c *Card) Pay(yen int) bool { + if !c.CanPay(yen) { + return false + } + c.Balance -= yen + return true +} + +// StartBalanceYen är kortets saldo vid banstart tills M6 ger riktig ekonomi. +const StartBalanceYen = 2000 + +// Run är tillståndet för en pågående bana: spelaren, klockan, kortet och +// utfallet. Ren logik – LevelScene ritar den, testerna kör den headless. +type Run struct { + Level *level.Level + Player *Mover + Card Card + Time float64 // sekunder sedan banstart (styr tidtabellerna) + Steps int + GatePasses int + DeniedAt float64 // när spärren senast nekade (för HUD); < 0 = aldrig + Outcome Outcome + Boarded *level.Train +} + +// NewRun startar banan med spelaren på startrutan. +func NewRun(l *level.Level, speed float64) *Run { + return &Run{ + Level: l, + Player: NewMover(l.SpawnPoint(), speed), + Card: Card{Balance: StartBalanceYen}, + DeniedAt: -1, + } +} + +// CanEnter avgör om spelaren får gå in på en ruta: inte vägg, och en spärr +// kräver att kortet kan betala avgiften (att gå mellan två spärrutor är gratis). +func (r *Run) CanEnter(p level.Point) bool { + t := r.Level.At(p.X, p.Y) + if !t.Passable() { + return false + } + if t == level.Gate && !r.inGate() && !r.Card.CanPay(r.Level.FareYen) { + r.DeniedAt = r.Time + return false + } + return true +} + +func (r *Run) inGate() bool { + return r.Level.At(r.Player.Tile.X, r.Player.Tile.Y) == level.Gate +} + +// Step för banan framåt dt sekunder. want är hållen riktning, board=true +// betyder att spelaren försöker kliva på tåget där hen står. +func (r *Run) Step(dt float64, want Dir, board bool) { + if r.Outcome != Ongoing { + return + } + r.Time += dt + before := r.Player.Tile + wasGate := r.inGate() + r.Steps += r.Player.Step(dt, want, r.CanEnter) + if r.Player.Tile != before && r.inGate() && !wasGate { + r.Card.Pay(r.Level.FareYen) + r.GatePasses++ + } + if board && !r.Player.Moving() { + if tr, at, _ := r.PlatformHere(); tr != nil && at { + r.Board(tr) + } + } +} + +// PlatformHere returnerar tåget vid perrongen spelaren står på (nil om +// spelaren inte står på en perrong), om det är inne, och sekunder kvar +// till avgång respektive ankomst. +func (r *Run) PlatformHere() (tr *level.Train, atPlatform bool, seconds float64) { + t := r.Level.At(r.Player.Tile.X, r.Player.Tile.Y) + if t != level.Platform && t != level.WrongPlatform { + return nil, false, 0 + } + tr = r.Level.TrainAt(t) + if tr == nil { + return nil, false, 0 + } + atPlatform, seconds = tr.Schedule.Status(r.Time) + return tr, atPlatform, seconds +} + +// SignHere returnerar skylten spelaren står stilla på, annars nil. +func (r *Run) SignHere() *level.SignText { + if r.Player.Moving() { + return nil + } + return r.Level.SignAt(r.Player.Tile) +} + +// Board kliver på ett tåg och avgör banan. +func (r *Run) Board(tr *level.Train) { + r.Boarded = tr + if tr.Correct { + r.Outcome = Won + } else { + r.Outcome = Lost + } +} + +// TrainVisible rapporterar om tåget för en perrongruta står inne just nu. +func (r *Run) TrainVisible(t level.Tile) bool { + tr := r.Level.TrainAt(t) + if tr == nil { + return false + } + at, _ := tr.Schedule.Status(r.Time) + return at +} diff --git a/internal/sim/run_test.go b/internal/sim/run_test.go new file mode 100644 index 0000000..b96d66b --- /dev/null +++ b/internal/sim/run_test.go @@ -0,0 +1,182 @@ +package sim + +import ( + "path/filepath" + "testing" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res" +) + +const frame = 1.0 / 60 + +func loadLevel(t *testing.T, id string) *level.Level { + t.Helper() + dir, err := res.Path("data", "levels") + if err != nil { + t.Fatal(err) + } + l, err := level.LoadFile(filepath.Join(dir, id+".json")) + if err != nil { + t.Fatal(err) + } + return l +} + +func dirTo(from, to level.Point) Dir { + switch { + case to.X > from.X: + return Right + case to.X < from.X: + return Left + case to.Y > from.Y: + return Down + default: + return Up + } +} + +// walk låter en bot följa en väg ruta för ruta, en bildruta i taget. +func walk(t *testing.T, r *Run, path []level.Point) { + t.Helper() + for _, next := range path[1:] { + d := dirTo(r.Player.Tile, next) + for i := 0; i < 600; i++ { + if r.Player.Tile == next && !r.Player.Moving() { + break + } + r.Step(frame, d, false) + } + if r.Player.Tile != next { + t.Fatalf("boten fastnade på %v på väg mot %v", r.Player.Tile, next) + } + } +} + +// waitForTrain står still tills tåget vid perrongen är inne. +func waitForTrain(t *testing.T, r *Run) *level.Train { + t.Helper() + for i := 0; i < 60*120; i++ { + tr, at, _ := r.PlatformHere() + if tr == nil { + t.Fatalf("står inte på en perrong: %v", r.Player.Tile) + } + if at { + return tr + } + r.Step(frame, None, false) + } + t.Fatal("tåget kom aldrig") + return nil +} + +// TestBotWinsLevel001 är simuleringstestet för M2: en bot går kortaste vägen +// från start till rätt perrong i bana 001, passerar spärren, väntar in tåget +// och kliver på – helt utan fönster. +func TestBotWinsLevel001(t *testing.T) { + l := loadLevel(t, "001-flygplatsen") + r := NewRun(l, 6) + goal := l.Find(level.Platform)[0] + path := l.Path(r.Player.Tile, goal, r.CanEnter) + if path == nil { + t.Fatal("ingen väg till perrongen") + } + walk(t, r, path) + + if r.Outcome != Ongoing { + t.Fatal("banan ska inte vara avgjord innan man kliver på") + } + r.Step(frame, None, true) // Enter medan tåget kanske inte är inne får inte avgöra fel + tr := waitForTrain(t, r) + if !tr.Correct { + t.Fatal("rätt perrong ska ha rätt tåg") + } + r.Step(frame, None, true) + if r.Outcome != Won || r.Boarded != tr { + t.Fatalf("outcome=%v boarded=%v", r.Outcome, r.Boarded) + } + if r.GatePasses != 1 || r.Card.Balance != StartBalanceYen-l.FareYen { + t.Fatalf("spärrpassager=%d saldo=%d (vill ha 1 och %d)", r.GatePasses, r.Card.Balance, StartBalanceYen-l.FareYen) + } + if r.Steps != len(path)-1 { + t.Fatalf("steg=%d, vägen är %d rutor", r.Steps, len(path)-1) + } + // Efter utfall händer inget mer. + before := r.Time + r.Step(frame, Right, true) + if r.Time != before { + t.Fatal("Step ska vara en no-op när banan är avgjord") + } +} + +func TestBotLosesOnWrongTrain(t *testing.T) { + l := loadLevel(t, "001-flygplatsen") + r := NewRun(l, 6) + goal := l.Find(level.WrongPlatform)[0] + walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) + tr := waitForTrain(t, r) + r.Step(frame, None, true) + if r.Outcome != Lost || r.Boarded != tr || tr.Correct { + t.Fatalf("outcome=%v boarded=%+v", r.Outcome, r.Boarded) + } +} + +func TestGateNeedsCardBalance(t *testing.T) { + l := loadLevel(t, "001-flygplatsen") + r := NewRun(l, 6) + gate := l.Find(level.Gate)[0] + r.Card.Balance = 0 + if r.CanEnter(gate) { + t.Fatal("spärren ska stoppa ett tomt kort") + } + if r.DeniedAt < 0 { + t.Fatal("nekandet ska registreras för HUD:en") + } + if l.Path(r.Player.Tile, l.Find(level.Platform)[0], r.CanEnter) != nil { + t.Fatal("utan saldo ska perrongen vara onåbar") + } + r.Card.Balance = l.FareYen + if !r.CanEnter(gate) { + t.Fatal("med exakt avgiften ska spärren släppa igenom") + } + // Mellan två spärrutor är det gratis: ställ spelaren i spärren. + r.Player.Tile, r.Player.Target = gate, gate + r.Card.Balance = 0 + other := level.Point{X: gate.X + 1, Y: gate.Y} + if l.At(other.X, other.Y) == level.Gate && !r.CanEnter(other) { + t.Fatal("att gå mellan två spärrutor ska inte kosta") + } +} + +func TestBoardOnlyWhenTrainPresent(t *testing.T) { + l := loadLevel(t, "001-flygplatsen") + r := NewRun(l, 6) + goal := l.Find(level.Platform)[0] + walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) + // Spola fram till en tidpunkt då tåget är borta. + for { + _, at, _ := r.PlatformHere() + if !at { + break + } + r.Step(frame, None, false) + } + r.Step(frame, None, true) + if r.Outcome != Ongoing { + t.Fatal("man ska inte kunna kliva på ett tåg som inte är inne") + } +} + +func TestSignHereOnlyWhenStill(t *testing.T) { + l := loadLevel(t, "001-flygplatsen") + r := NewRun(l, 6) + sign := l.Find(level.Sign)[0] + walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter)) + if sg := r.SignHere(); sg == nil || sg.Text.Get("sv") == "" { + t.Fatal("stilla på skyltrutan ska ge skylten") + } + r.Step(frame, Left, false) + if r.Player.Moving() && r.SignHere() != nil { + t.Fatal("i rörelse visas ingen skylt") + } +} diff --git a/resources/data/levels/001-flygplatsen.json b/resources/data/levels/001-flygplatsen.json index ba78b00..29bfb2b 100644 --- a/resources/data/levels/001-flygplatsen.json +++ b/resources/data/levels/001-flygplatsen.json @@ -1,9 +1,19 @@ { "id": "001-flygplatsen", - "name": { "sv": "Flygplatsen", "en": "The Airport", "ja": "空港" }, + "name": { + "sv": "Flygplatsen", + "en": "The Airport", + "ja": "空港" + }, "difficulty": 1, - "destination": { "ja": "新宿", "romaji": "Shinjuku", "sv": "Shinjuku", "en": "Shinjuku" }, + "destination": { + "ja": "新宿", + "romaji": "Shinjuku", + "sv": "Shinjuku", + "en": "Shinjuku" + }, "timeLimitSec": 0, + "fareYen": 200, "grid": [ "####################", "#S.....#.....i.....#", @@ -13,23 +23,71 @@ "#...#....#.....#.#.#", "#.###.##.#####.#.#.#", "#.....#T.......#...#", - "#.#####.#########.##", + "#######.#########.##", "#....GG......i..GG.#", - "#..p.....##......P.#", + "#..p.############P.#", "####################" ], "trains": [ { "platform": "P", - "line": { "ja": "成田エクスプレス", "romaji": "Narita Ekusupuresu", "en": "Narita Express" }, - "destination": { "ja": "新宿", "romaji": "Shinjuku", "en": "Shinjuku" }, - "correct": true + "line": { + "ja": "成田エクスプレス", + "romaji": "Narita Ekusupuresu", + "en": "Narita Express" + }, + "destination": { + "ja": "新宿", + "romaji": "Shinjuku", + "en": "Shinjuku" + }, + "correct": true, + "track": "2", + "schedule": { + "offsetSec": 0, + "intervalSec": 30, + "dwellSec": 12 + } }, { "platform": "p", - "line": { "ja": "京成本線", "romaji": "Keisei Honsen", "en": "Keisei Main Line" }, - "destination": { "ja": "京成上野", "romaji": "Keisei-Ueno", "en": "Keisei-Ueno" }, - "correct": false + "line": { + "ja": "京成本線", + "romaji": "Keisei Honsen", + "en": "Keisei Main Line" + }, + "destination": { + "ja": "京成上野", + "romaji": "Keisei-Ueno", + "en": "Keisei-Ueno" + }, + "correct": false, + "track": "1", + "schedule": { + "offsetSec": 8, + "intervalSec": 30, + "dwellSec": 12 + } + } + ], + "signs": [ + { + "x": 13, + "y": 1, + "text": { + "sv": "Till tågen: följ gången neråt. Spår 1 Keisei-linjen till vänster, spår 2 Narita Express till höger.", + "en": "To the trains: follow the corridor down. Track 1 Keisei Line to the left, track 2 Narita Express to the right.", + "ja": "のりば ↓ 1番線 京成線 ← 2番線 成田エクスプレス →" + } + }, + { + "x": 13, + "y": 9, + "text": { + "sv": "Spärrar. <- Spår 1: Keisei-linjen mot Keisei-Ueno. -> Spår 2: Narita Express mot Shinjuku.", + "en": "Ticket gates. <- Track 1: Keisei Line for Keisei-Ueno. -> Track 2: Narita Express for Shinjuku.", + "ja": "改札 ← 1番線 京成線 京成上野方面 → 2番線 成田エクスプレス 新宿方面" + } } ] } diff --git a/resources/data/levels/002-shinjuku.json b/resources/data/levels/002-shinjuku.json index a24a240..eb4235c 100644 --- a/resources/data/levels/002-shinjuku.json +++ b/resources/data/levels/002-shinjuku.json @@ -6,6 +6,7 @@ "sv": "Shibuya" }, "difficulty": 2, + "fareYen": 200, "grid": [ "#################################", "#S#..i..#.#...........#.........#", @@ -24,9 +25,9 @@ "#.#.###.###.#.###.#.#.#######.#.#", "#...#T#.#..i#.#.#...#.....#...#.#", "#####.#.#.#.#.#.#####.#####.###.#", - "#.....#.#.#.#..i....#.......#G..#", - "#.#.#.#.#.#########.#########.#.#", - "#p..#...#.....................#P#", + "#.....#.#.#.#..i....#.......#...#", + "#G#.#.#.#.#########.#########.#G#", + "#pG.#...#.....................#P#", "#################################" ], "id": "002-shinjuku", @@ -35,6 +36,35 @@ "ja": "新宿駅", "sv": "Shinjuku station" }, + "signs": [ + { + "text": { + "en": "Shinjuku Station. Track 14 Yamanote Line for Shibuya: far bottom right. Track 8 Chuo Line for Tokyo: far bottom left.", + "ja": "新宿駅 14番線 山手線 渋谷方面 → 南東 8番線 中央線 東京方面 → 南西", + "sv": "Shinjuku station. Spår 14 Yamanote-linjen mot Shibuya: längst ner till höger. Spår 8 Chuo-linjen mot Tokyo: längst ner till vänster." + }, + "x": 5, + "y": 1 + }, + { + "text": { + "en": "\u003c- Track 8 Chuo Line (Tokyo) Track 14 Yamanote Line (Shibuya) -\u003e", + "ja": "← 8番線 中央線 東京方面 14番線 山手線 渋谷方面 →", + "sv": "\u003c- Spår 8 Chuo-linjen (Tokyo) Spår 14 Yamanote-linjen (Shibuya) -\u003e" + }, + "x": 11, + "y": 15 + }, + { + "text": { + "en": "Track 14 Yamanote Line for Shibuya: the gate in the south-east corner.", + "ja": "14番線 山手線 渋谷方面 → 南東口改札", + "sv": "Spår 14 Yamanote-linjen mot Shibuya: spärren i sydöstra hörnet." + }, + "x": 15, + "y": 17 + } + ], "timeLimitSec": 0, "trains": [ { @@ -49,21 +79,33 @@ "ja": "山手線", "romaji": "Yamanote-sen" }, - "platform": "P" + "platform": "P", + "schedule": { + "dwellSec": 8, + "intervalSec": 20, + "offsetSec": 3 + }, + "track": "14" }, { "correct": false, "destination": { "en": "Tokyo", "ja": "東京", - "romaji": "Tōkyō" + "romaji": "Tokyo" }, "line": { - "en": "Chūō Line", + "en": "Chuo Line", "ja": "中央線", - "romaji": "Chūō-sen" + "romaji": "Chuo-sen" }, - "platform": "p" + "platform": "p", + "schedule": { + "dwellSec": 8, + "intervalSec": 25, + "offsetSec": 10 + }, + "track": "8" } ] } \ No newline at end of file diff --git a/resources/sprites/tiles.png b/resources/sprites/tiles.png index c2df2e3..ab95afe 100644 Binary files a/resources/sprites/tiles.png and b/resources/sprites/tiles.png differ diff --git a/resources/text/en.json b/resources/text/en.json index 9617310..5583654 100644 --- a/resources/text/en.json +++ b/resources/text/en.json @@ -20,5 +20,15 @@ "hud.steps": "Steps", "hud.controls": "Esc: menu", "level.wrong_train": "Wrong train!", - "result.hint": "R: replay Enter/Esc: menu" + "result.hint": "R: replay Enter/Esc: menu", + "hud.card": "Card", + "hud.track": "Track", + "hud.next_train_in": "Next train in", + "hud.departs_in": "Departs in", + "hud.board": "Enter: board", + "hud.gate_denied": "Gate: card has no balance!", + "hud.sign": "Sign", + "result.wrong_detail": "You went to %s instead of %s.", + "result.gates": "Gates", + "result.card": "Card left" } diff --git a/resources/text/sv.json b/resources/text/sv.json index cec5466..14c980f 100644 --- a/resources/text/sv.json +++ b/resources/text/sv.json @@ -20,5 +20,15 @@ "hud.steps": "Steg", "hud.controls": "Esc: meny", "level.wrong_train": "Fel tåg!", - "result.hint": "R: spela om Enter/Esc: meny" + "result.hint": "R: spela om Enter/Esc: meny", + "hud.card": "Kort", + "hud.track": "Spår", + "hud.next_train_in": "Nästa tåg om", + "hud.departs_in": "Avgår om", + "hud.board": "Enter: kliv på", + "hud.gate_denied": "Spärren: kortet saknar saldo!", + "hud.sign": "Skylt", + "result.wrong_detail": "Du åkte mot %s i stället för %s.", + "result.gates": "Spärrar", + "result.card": "Kort kvar" } diff --git a/tools/genart/main.go b/tools/genart/main.go index 35d9e4f..ef2451e 100644 --- a/tools/genart/main.go +++ b/tools/genart/main.go @@ -3,7 +3,7 @@ // go run ./tools/genart // // Skriver resources/sprites/tiles.png (en remsa 16×16-rutor i ordningen -// vägg, golv, start, perrong, spärr, automat, skylt) och +// vägg, golv, start, perrong, spärr, automat, skylt, tågvagn) och // resources/sprites/player.png (3 bildrutor × 4 riktningar: ner, upp, // vänster, höger; kolumn 0 = stilla, 1–2 = gå). Ritas med enkla rektanglar // så att allt är deterministiskt och lätt att justera. @@ -33,6 +33,7 @@ var ( skin, hair, capC, capDark = rgb(240, 200, 160), rgb(60, 40, 30), rgb(228, 59, 68), rgb(170, 40, 50) jacket, jacketDark = rgb(44, 232, 245), rgb(30, 170, 185) pants, shoe, eye = rgb(38, 43, 68), rgb(255, 255, 255), rgb(38, 43, 68) + trainBody, trainStripe = rgb(230, 234, 240), rgb(228, 59, 68) ) type canvas struct { @@ -109,6 +110,18 @@ var tiles = []func(canvas){ c.rect(3, 3, 10, 1, white) c.rect(3, 5, 6, 1, white) }, + func(c canvas) { // tågvagn (ritas på rutan bortom perrongkanten när tåget är inne) + c.rect(0, 0, 16, 16, wallDark) + c.rect(0, 2, 16, 12, trainBody) + c.rect(0, 2, 16, 1, metalDark) + c.rect(0, 9, 16, 2, trainStripe) + for _, x := range []int{1, 5, 12} { + c.rect(x, 4, 3, 4, screenC) + } + c.rect(9, 4, 2, 9, metalDark) // dörr + c.rect(2, 14, 3, 2, boxDark) // hjul + c.rect(11, 14, 3, 2, boxDark) + }, } // player ritar en figur som tittar åt facing (0 ner, 1 upp, 2 vänster, 3 höger) diff --git a/tools/shots/main.go b/tools/shots/main.go index 3009f9a..a1eba7f 100644 --- a/tools/shots/main.go +++ b/tools/shots/main.go @@ -3,8 +3,9 @@ // go run ./tools/shots // // Bygger debug-binären, kör den med -script för varje bild och skriver -// PNG:er till doc/img/. Kör om detta när grafik eller UI ändrats, och -// committa bilderna – README och wikin pekar på dem. +// PNG:er till doc/img/. Vägarna genom banorna räknas ut med BFS från +// bandatan. Kör om detta när grafik, UI eller banor ändrats, och committa +// bilderna – README och wikin pekar på dem. package main import ( @@ -13,26 +14,88 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" ) type shot struct { - file string level string - script string + script string // $OUT ersätts med doc/img-mappen } -var shots = []shot{ - {"menu.png", "", "wait 10; shot %s; quit"}, - {"level-001.png", "001-flygplatsen", "move right 4; move down 2; wait 5; shot %s; quit"}, - {"level-002.png", "002-shinjuku", "move down 6; move right 4; wait 5; shot %s; quit"}, - {"result.png", "001-flygplatsen", - "move down 2; move right 2; move down 2; move left 2; move down 4; move right 17; move down 1; move left 1; wait 15; shot %s; quit"}, +// shots byggs i main eftersom vägarna räknas ut från bandatan (BFS), så att +// bilderna överlever ändringar i banorna. +var shots []shot + +func buildShots(levelsDir string) error { + l, err := level.LoadFile(filepath.Join(levelsDir, "001-flygplatsen.json")) + if err != nil { + return err + } + sign := l.Find(level.Sign)[1] // skylten vid spärrarna + goal := l.Find(level.Platform)[0] + toSign, err := moves(l, l.SpawnPoint(), sign) + if err != nil { + return err + } + toGoal, err := moves(l, sign, goal) + if err != nil { + return err + } + shots = []shot{ + {"", "wait 10; shot $OUT/menu.png; quit"}, + {"001-flygplatsen", "move right 4; move down 2; wait 5; shot $OUT/level-001.png; quit"}, + {"002-shinjuku", "move down 6; move right 4; wait 5; shot $OUT/level-002.png; quit"}, + {"001-flygplatsen", toSign + "; wait 5; shot $OUT/sign.png; " + toGoal + + "; waittrain; wait 30; shot $OUT/platform.png; press enter; wait 5; shot $OUT/result.png; quit"}, + } + return nil +} + +// moves gör om kortaste vägen mellan två rutor till "move DIR N"-steg. +func moves(l *level.Level, from, to level.Point) (string, error) { + path := l.Path(from, to, l.CanEnter) + if path == nil { + return "", fmt.Errorf("ingen väg %v -> %v i %s", from, to, l.ID) + } + var parts []string + dir, n := "", 0 + flush := func() { + if n > 0 { + parts = append(parts, fmt.Sprintf("move %s %d", dir, n)) + } + } + for i := 1; i < len(path); i++ { + d := dirName(path[i-1], path[i]) + if d != dir { + flush() + dir, n = d, 0 + } + n++ + } + flush() + return strings.Join(parts, "; "), nil +} + +func dirName(a, b level.Point) string { + switch { + case b.X > a.X: + return "right" + case b.X < a.X: + return "left" + case b.Y > a.Y: + return "down" + default: + return "up" + } } func main() { root := repoRoot() out := filepath.Join(root, "doc", "img") must(os.MkdirAll(out, 0o755)) + must(buildShots(filepath.Join(root, "resources", "data", "levels"))) build := exec.Command("go", "run", "./tools/build", "-mode", "debug") build.Dir, build.Stdout, build.Stderr = root, os.Stdout, os.Stderr @@ -43,14 +106,14 @@ func main() { exe += ".exe" } for _, s := range shots { - args := []string{"-script", fmt.Sprintf(s.script, filepath.Join(out, s.file))} + args := []string{"-script", strings.ReplaceAll(s.script, "$OUT", out)} if s.level != "" { args = append(args, "-level", s.level) } run := exec.Command(exe, args...) run.Dir, run.Stdout, run.Stderr = filepath.Dir(exe), os.Stdout, os.Stderr if err := run.Run(); err != nil { - fail(fmt.Errorf("%s: %w", s.file, err)) + fail(fmt.Errorf("%s: %w", s.script, err)) } } fmt.Println("klart:", out)