M2: tåg med tidtabell, spärrar med kort, skyltar, vinst/förlust
All checks were successful
release / build-release (push) Successful in 1m35s

- level: Schedule (ankomst/intervall/uppehåll) med Status(t), spårnummer,
  avgift, skylttexter per i-ruta, Path (BFS) och UngatedPlatforms – regeln
  att perronger bara nås via spärrar testas för alla banor
- sim.Run: klocka, tågkort (saldo dras vid spärr, tomt kort stoppas),
  ombordstigning med Enter när tåget är inne, vinst/förlust; headless
  bot-test klarar bana 001 och förlorar på fel tåg
- game: perronginfo (avgår om / nästa tåg om), skyltruta, nekad spärr,
  tågvagn som ritas när tåget är inne, inforutan flyttas upp när spelaren
  står nära nederkanten; resultat med spärrpassager och saldo
- bana 001 ombyggd så spärrarna är flaskhalsar; bana 002 omgenererad med
  spärrar runt perrongerna och skylttexter
- tools/shots räknar ut vägar med BFS; skriptkommandot waittrain
- docs/README/wiki uppdaterade med nya bilder

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 19:36:45 +02:00
parent 3c00c7f356
commit a5a660b32f
26 changed files with 1031 additions and 135 deletions

View File

@@ -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+ 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). 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 **Status:** M2 klar (2026-10-23) – de två första banorna går att spela på
som följer, välja bana i menyn och nå perrongen. Nästa är M2: tåg med riktigt: labyrint, skyltar, spärrar som drar från tågkortet, tåg som kommer
tidtabell, spärrar och första riktigt spelbara banan. Se 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). [doc/roadmap.md](doc/roadmap.md).
![Bana 001 – Flygplatsen](doc/img/level-001.png) ![Bana 001 – Flygplatsen](doc/img/level-001.png)
Så fungerar det: du startar på den gröna dörrmattan, letar dig genom Så fungerar det: du startar på den gröna dörrmattan och letar dig genom
stationen förbi skyltar, automater och spärrar, och ställer dig på en stationen. Skyltar visar vilket spår som går vart. Spärrarna släpper bara
perrong (gul ledlinje). Rätt perrong tar dig till resmålet, fel perrong ger igenom dig om kortet har saldo. På perrongen (gul ledlinje) ser du vilket
fel tåg. 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 Fler bilder och en genomgång finns i
[wikin](https://gitea.brasse-pc.eu/brasse/metro-puzzle-jp/wiki). [wikin](https://gitea.brasse-pc.eu/brasse/metro-puzzle-jp/wiki).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

BIN
doc/img/platform.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

BIN
doc/img/sign.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -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` | `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` | `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/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 | | `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan |
### Planerade tester per milstolpe ### 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 | | 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 | | ~~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å) | | 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 | | 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 | | M5 Minikarta | Upptäckta rutor markeras, återställs per bana; avgångar syns från start |

View File

@@ -32,7 +32,7 @@ brasse-linux01-runnern, inte Pi5:n.
## Milstolpar ## 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 → M4 lämmeltåget → M5 minikarta → M6 kort/biljetter/minispel → M7
svårighet/poäng/debuffar → M8 progression/sparfiler → M9 navigeringslägen → 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. M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release.

View File

@@ -58,7 +58,7 @@ metro-puzzle-jp/
│ ├── res/ hittar och läser resources/ (ren Go) │ ├── res/ hittar och läser resources/ (ren Go)
│ ├── rng/ seedad slump (ren Go) │ ├── rng/ seedad slump (ren Go)
│ ├── level/ banformat, validering, BFS (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 │ ├── game/ Ebitengine-loop, scener, input, rendering, skriptläge ← får använda Ebitengine
│ ├── archtest/ beroenderegler │ ├── archtest/ beroenderegler
│ └── … kommande: lang/, nav/, score/, save/, minigame/, ui/, audio/ │ └── … 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 det naturliga slutet på en bana; tidtabell, spärrar och väntan på tåg
kommer i M2 som planerat. 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. - **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 - Tåg med tidtabell per perrong (`schedule`: första ankomst, intervall,
visas vid perrongen. uppehåll). Tågvagnen ritas bortom perrongkanten när tåget är inne; HUD:en
- Spärrar som kräver kort (kortet finns och har alltid saldo i M2). visar spår, linje, resmål och "avgår om / nästa tåg om".
- Vinst när rätt tåg tas; förlust när fel tåg tas. Resultatskärm. - Ombordstigning är ett aktivt val: stå på perrongen och tryck Enter medan
- Interaktion med skyltar (visar text, än så länge på svenska/engelska). tåget är inne. Rätt tåg = vinst, fel tåg = förlust, resultatskärm med tid,
- **Teknik:** `internal/sim/schedule`, `internal/level` utökas med steg, spärrpassager och kortsaldo.
tidtabell + skyltinnehåll. - Spärrar kräver kort med saldo (startsaldo 2000 ¥, avgift per bana, dras
- **Klar när:** en simuleringstest låter en bot klara bana 001 headless; när man går in i en spärr från en vanlig ruta). Tomt kort stoppas med
manuellt spel från start till mål fungerar. 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 ### M3 – Japanska och skyltsystemet

View File

@@ -21,6 +21,7 @@ type Assets struct {
ResDir string ResDir string
LevelsDir string LevelsDir string
Tiles map[level.Tile]*ebiten.Image 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] Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
Text map[string]string Text map[string]string
} }
@@ -38,13 +39,17 @@ func LoadAssets() (*Assets, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if w := tiles.Bounds().Dx(); w < TileSize*len(tileOrder) { sub := func(i int) *ebiten.Image {
return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, TileSize*len(tileOrder)) 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 { 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.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
a.Train = sub(len(tileOrder))
player, err := loadImage("sprites", "player.png") player, err := loadImage("sprites", "player.png")
if err != nil { if err != nil {

View File

@@ -17,15 +17,16 @@ import (
// Spelarens grundfart i rutor per sekund (blir en stat i M7). // Spelarens grundfart i rutor per sekund (blir en stat i M7).
const playerSpeed = 6.0 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 { type LevelScene struct {
id string id string
lvl *level.Level run *sim.Run
player *sim.Mover
cam *sim.Camera cam *sim.Camera
animT float64 animT float64
elapsed float64
steps int
} }
// NewLevelScene laddar banan och ställer spelaren på startrutan. // NewLevelScene laddar banan och ställer spelaren på startrutan.
@@ -36,8 +37,7 @@ func NewLevelScene(a *Assets, id string) (*LevelScene, error) {
} }
s := &LevelScene{ s := &LevelScene{
id: id, id: id,
lvl: lvl, run: sim.NewRun(lvl, playerSpeed),
player: sim.NewMover(lvl.SpawnPoint(), playerSpeed),
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)), cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
} }
s.followPlayer() s.followPlayer()
@@ -45,10 +45,16 @@ func NewLevelScene(a *Assets, id string) (*LevelScene, error) {
} }
// PlayerTile returnerar rutan spelaren står på/lämnar (används av skriptläget). // 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() { 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) s.cam.Follow(x+TileSize/2, y+TileSize/2)
} }
@@ -61,50 +67,116 @@ func (s *LevelScene) Update(g *Game) error {
g.SetScene(m) g.SetScene(m)
return nil return nil
} }
s.elapsed += dt s.run.Step(dt, dirInput(), confirmJustPressed())
s.steps += s.player.Step(dt, dirInput(), s.lvl.CanEnter) if s.run.Player.Moving() {
if s.player.Moving() {
s.animT += dt s.animT += dt
} else { } else {
s.animT = 0 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() s.followPlayer()
if s.run.Outcome != sim.Ongoing {
g.SetScene(NewResultScene(s))
}
return nil 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) { func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
screen.Fill(color.RGBA{24, 20, 37, 255}) screen.Fill(color.RGBA{24, 20, 37, 255})
lvl := s.run.Level
ox, oy := s.cam.Offset() ox, oy := s.cam.Offset()
ox, oy = math.Round(ox), math.Round(oy) ox, oy = math.Round(ox), math.Round(oy)
// Rita bara rutor som syns. // Rita bara rutor som syns.
x0 := max(0, int(s.cam.X)/TileSize) x0 := max(0, int(s.cam.X)/TileSize)
y0 := max(0, int(s.cam.Y)/TileSize) y0 := max(0, int(s.cam.Y)/TileSize)
x1 := min(s.lvl.Width(), int(s.cam.X+ScreenW)/TileSize+2) x1 := min(lvl.Width(), int(s.cam.X+ScreenW)/TileSize+2)
y1 := min(s.lvl.Height(), int(s.cam.Y+ScreenH)/TileSize+2) y1 := min(lvl.Height(), int(s.cam.Y+ScreenH)/TileSize+2)
var op ebiten.DrawImageOptions var op ebiten.DrawImageOptions
for y := y0; y < y1; y++ { for y := y0; y < y1; y++ {
for x := x0; x < x1; x++ { for x := x0; x < x1; x++ {
op.GeoM.Reset() op.GeoM.Reset()
op.GeoM.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize)) 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.Reset()
op.GeoM.Translate(ox+math.Round(px), oy+math.Round(py)) 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) 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. // 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"), ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %s: %s", r.Level.Name.Get("sv"), a.T("hud.destination"),
s.lvl.Destination.Get("romaji")), 4, 1) r.Level.Destination.Get("romaji")), 4, 1)
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %3.0fs %s %d %s", g.Assets.T("hud.time"), s.elapsed, ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %3.0fs %s %d %s %d¥ %s", a.T("hud.time"), r.Time,
g.Assets.T("hud.steps"), s.steps, g.Assets.T("hud.controls")), 4, 12) 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)
}
} }

View File

@@ -8,21 +8,16 @@ import (
"github.com/hajimehoshi/ebiten/v2/ebitenutil" "github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/vector" "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 { type ResultScene struct {
from *LevelScene from *LevelScene
train *level.Train
} }
// NewResultScene bygger resultatet för banan som just spelades. // NewResultScene bygger resultatet för banan som just spelades.
func NewResultScene(from *LevelScene, train *level.Train) *ResultScene { func NewResultScene(from *LevelScene) *ResultScene { return &ResultScene{from: from} }
return &ResultScene{from: from, train: train}
}
func (r *ResultScene) correct() bool { return r.train != nil && r.train.Correct }
func (r *ResultScene) Update(g *Game) error { func (r *ResultScene) Update(g *Game) error {
switch { switch {
@@ -44,22 +39,33 @@ func (r *ResultScene) Update(g *Game) error {
func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
r.from.Draw(g, screen) // banan i bakgrunden r.from.Draw(g, screen) // banan i bakgrunden
bg := color.RGBA{24, 20, 37, 230} run := r.from.run
vector.DrawFilledRect(screen, 20, 40, ScreenW-40, 100, bg, false) a := g.Assets
accent := color.RGBA{228, 59, 68, 255} vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 112, color.RGBA{24, 20, 37, 235}, false)
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)
ebitenutil.DebugPrintAt(screen, headline, 28, 50) accent, headline := color.RGBA{228, 59, 68, 255}, a.T("level.wrong_train")
if r.train != nil { if run.Outcome == sim.Won {
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s -> %s", r.train.Line.Get("romaji"), accent, headline = color.RGBA{99, 199, 77, 255}, a.T("level.cleared")
r.train.Destination.Get("romaji")), 28, 66) // TODO(M3): japanska när fonten finns
} }
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %.1fs %s %d", g.Assets.T("hud.time"), r.from.elapsed, vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 3, accent, false)
g.Assets.T("hud.steps"), r.from.steps), 28, 86) ebitenutil.DebugPrintAt(screen, headline, 28, 46)
ebitenutil.DebugPrintAt(screen, g.Assets.T("result.hint"), 28, 118)
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)
} }

View File

@@ -19,6 +19,7 @@ import (
// Stegen skiljs med ';': // Stegen skiljs med ';':
// //
// wait N vänta N bildrutor // 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 // 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) // 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) // 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'") return nil, fmt.Errorf("script: 'shot PATH'")
} }
st.arg = f[1] st.arg = f[1]
case "quit": case "quit", "waittrain":
default: default:
return nil, fmt.Errorf("script: okänt kommando %q", f[0]) 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. // playerTiler implementeras av scener som har en spelare i rutnätet.
type playerTiler interface{ PlayerTile() level.Point } 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. // tick körs i början av varje Update. frame är senast ritade bild.
// Returnerar ebiten.Termination när skriptet vill avsluta. // Returnerar ebiten.Termination när skriptet vill avsluta.
func (s *script) tick(g *Game, frame *ebiten.Image) error { 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 return err
} }
s.advance() s.advance()
case "waittrain":
if tw, ok := g.scene.(trainWaiter); !ok || tw.TrainHere() {
s.advance()
}
case "quit": case "quit":
return ebiten.Termination return ebiten.Termination
} }

27
internal/game/text.go Normal file
View File

@@ -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
}

View File

@@ -9,6 +9,7 @@ package level
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -55,12 +56,49 @@ func (t Text) Get(lang string) string {
return "" 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. // Train är ett tåg (eller en buss) som avgår från en perrong i banan.
type Train struct { type Train struct {
Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid 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 Line Text `json:"line"` // linjens namn
Destination Text `json:"destination"` // slutstation Destination Text `json:"destination"` // slutstation
Correct bool `json:"correct"` // tar spelaren till rätt resmål 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. // Level är en bana.
@@ -70,8 +108,10 @@ type Level struct {
Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår) Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår)
Destination Text `json:"destination"` // dit spelaren ska Destination Text `json:"destination"` // dit spelaren ska
TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns 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"` Grid []string `json:"grid"`
Trains []Train `json:"trains"` Trains []Train `json:"trains"`
Signs []SignText `json:"signs"`
} }
// Point är en rutkoordinat. // Point är en rutkoordinat.
@@ -161,7 +201,8 @@ func (l *Level) Validate() error {
return fmt.Errorf("level %s: saknar tåg", l.ID) return fmt.Errorf("level %s: saknar tåg", l.ID)
} }
correct := 0 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) { 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) 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 { if tr.Correct {
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 { if correct == 0 {
return fmt.Errorf("level %s: inget tåg är markerat som rätt", l.ID) 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 return nil
} }
@@ -215,6 +333,37 @@ func (l *Level) Solvable() bool {
return false 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 // Unreachable listar interaktiva rutor (perronger, automater, skyltar) som
// inte går att nå – praktiskt som varning när man bygger banor. // inte går att nå – praktiskt som varning när man bygger banor.
func (l *Level) Unreachable() []Point { func (l *Level) Unreachable() []Point {

View File

@@ -1,6 +1,7 @@
package level package level
import ( import (
"math"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -76,6 +77,11 @@ func TestValidationErrors(t *testing.T) {
"ingen perrong": strings.Replace(sample, `"#S..#P#"`, `"#S..#.#"`, 1), "ingen perrong": strings.Replace(sample, `"#S..#P#"`, `"#S..#.#"`, 1),
"inget rätt tåg": strings.Replace(sample, `"correct": true`, `"correct": false`, 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), "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), "saknar id": strings.Replace(sample, `"id": "test"`, `"id": ""`, 1),
"trasig json": "{", "trasig json": "{",
} }
@@ -133,6 +139,19 @@ func TestAllShippedLevels(t *testing.T) {
if u := l.Unreachable(); len(u) > 0 { if u := l.Unreachable(); len(u) > 0 {
t.Errorf("onåbara interaktiva rutor: %v", u) 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") 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")
}
}

138
internal/sim/run.go Normal file
View File

@@ -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
}

182
internal/sim/run_test.go Normal file
View File

@@ -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")
}
}

View File

@@ -1,9 +1,19 @@
{ {
"id": "001-flygplatsen", "id": "001-flygplatsen",
"name": { "sv": "Flygplatsen", "en": "The Airport", "ja": "空港" }, "name": {
"sv": "Flygplatsen",
"en": "The Airport",
"ja": "空港"
},
"difficulty": 1, "difficulty": 1,
"destination": { "ja": "新宿", "romaji": "Shinjuku", "sv": "Shinjuku", "en": "Shinjuku" }, "destination": {
"ja": "新宿",
"romaji": "Shinjuku",
"sv": "Shinjuku",
"en": "Shinjuku"
},
"timeLimitSec": 0, "timeLimitSec": 0,
"fareYen": 200,
"grid": [ "grid": [
"####################", "####################",
"#S.....#.....i.....#", "#S.....#.....i.....#",
@@ -13,23 +23,71 @@
"#...#....#.....#.#.#", "#...#....#.....#.#.#",
"#.###.##.#####.#.#.#", "#.###.##.#####.#.#.#",
"#.....#T.......#...#", "#.....#T.......#...#",
"#.#####.#########.##", "#######.#########.##",
"#....GG......i..GG.#", "#....GG......i..GG.#",
"#..p.....##......P.#", "#..p.############P.#",
"####################" "####################"
], ],
"trains": [ "trains": [
{ {
"platform": "P", "platform": "P",
"line": { "ja": "成田エクスプレス", "romaji": "Narita Ekusupuresu", "en": "Narita Express" }, "line": {
"destination": { "ja": "新宿", "romaji": "Shinjuku", "en": "Shinjuku" }, "ja": "成田エクスプレス",
"correct": true "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", "platform": "p",
"line": { "ja": "京成本線", "romaji": "Keisei Honsen", "en": "Keisei Main Line" }, "line": {
"destination": { "ja": "京成上野", "romaji": "Keisei-Ueno", "en": "Keisei-Ueno" }, "ja": "京成本線",
"correct": false "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番線 成田エクスプレス 新宿方面"
}
} }
] ]
} }

View File

@@ -6,6 +6,7 @@
"sv": "Shibuya" "sv": "Shibuya"
}, },
"difficulty": 2, "difficulty": 2,
"fareYen": 200,
"grid": [ "grid": [
"#################################", "#################################",
"#S#..i..#.#...........#.........#", "#S#..i..#.#...........#.........#",
@@ -24,9 +25,9 @@
"#.#.###.###.#.###.#.#.#######.#.#", "#.#.###.###.#.###.#.#.#######.#.#",
"#...#T#.#..i#.#.#...#.....#...#.#", "#...#T#.#..i#.#.#...#.....#...#.#",
"#####.#.#.#.#.#.#####.#####.###.#", "#####.#.#.#.#.#.#####.#####.###.#",
"#.....#.#.#.#..i....#.......#G..#", "#.....#.#.#.#..i....#.......#...#",
"#.#.#.#.#.#########.#########.#.#", "#G#.#.#.#.#########.#########.#G#",
"#p..#...#.....................#P#", "#pG.#...#.....................#P#",
"#################################" "#################################"
], ],
"id": "002-shinjuku", "id": "002-shinjuku",
@@ -35,6 +36,35 @@
"ja": "新宿駅", "ja": "新宿駅",
"sv": "Shinjuku station" "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, "timeLimitSec": 0,
"trains": [ "trains": [
{ {
@@ -49,21 +79,33 @@
"ja": "山手線", "ja": "山手線",
"romaji": "Yamanote-sen" "romaji": "Yamanote-sen"
}, },
"platform": "P" "platform": "P",
"schedule": {
"dwellSec": 8,
"intervalSec": 20,
"offsetSec": 3
},
"track": "14"
}, },
{ {
"correct": false, "correct": false,
"destination": { "destination": {
"en": "Tokyo", "en": "Tokyo",
"ja": "東京", "ja": "東京",
"romaji": "Tōkyō" "romaji": "Tokyo"
}, },
"line": { "line": {
"en": "Chūō Line", "en": "Chuo Line",
"ja": "中央線", "ja": "中央線",
"romaji": "Chūō-sen" "romaji": "Chuo-sen"
}, },
"platform": "p" "platform": "p",
"schedule": {
"dwellSec": 8,
"intervalSec": 25,
"offsetSec": 10
},
"track": "8"
} }
] ]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 599 B

After

Width:  |  Height:  |  Size: 692 B

View File

@@ -20,5 +20,15 @@
"hud.steps": "Steps", "hud.steps": "Steps",
"hud.controls": "Esc: menu", "hud.controls": "Esc: menu",
"level.wrong_train": "Wrong train!", "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"
} }

View File

@@ -20,5 +20,15 @@
"hud.steps": "Steg", "hud.steps": "Steg",
"hud.controls": "Esc: meny", "hud.controls": "Esc: meny",
"level.wrong_train": "Fel tåg!", "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"
} }

View File

@@ -3,7 +3,7 @@
// go run ./tools/genart // go run ./tools/genart
// //
// Skriver resources/sprites/tiles.png (en remsa 16×16-rutor i ordningen // 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, // resources/sprites/player.png (3 bildrutor × 4 riktningar: ner, upp,
// vänster, höger; kolumn 0 = stilla, 1–2 = gå). Ritas med enkla rektanglar // vänster, höger; kolumn 0 = stilla, 1–2 = gå). Ritas med enkla rektanglar
// så att allt är deterministiskt och lätt att justera. // 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) 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) 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) 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 { type canvas struct {
@@ -109,6 +110,18 @@ var tiles = []func(canvas){
c.rect(3, 3, 10, 1, white) c.rect(3, 3, 10, 1, white)
c.rect(3, 5, 6, 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) // player ritar en figur som tittar åt facing (0 ner, 1 upp, 2 vänster, 3 höger)

View File

@@ -3,8 +3,9 @@
// go run ./tools/shots // go run ./tools/shots
// //
// Bygger debug-binären, kör den med -script för varje bild och skriver // 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 // PNG:er till doc/img/. Vägarna genom banorna räknas ut med BFS från
// committa bilderna – README och wikin pekar på dem. // bandatan. Kör om detta när grafik, UI eller banor ändrats, och committa
// bilderna – README och wikin pekar på dem.
package main package main
import ( import (
@@ -13,26 +14,88 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
) )
type shot struct { type shot struct {
file string
level string level string
script string script string // $OUT ersätts med doc/img-mappen
} }
var shots = []shot{ // shots byggs i main eftersom vägarna räknas ut från bandatan (BFS), så att
{"menu.png", "", "wait 10; shot %s; quit"}, // bilderna överlever ändringar i banorna.
{"level-001.png", "001-flygplatsen", "move right 4; move down 2; wait 5; shot %s; quit"}, var shots []shot
{"level-002.png", "002-shinjuku", "move down 6; move right 4; wait 5; shot %s; quit"},
{"result.png", "001-flygplatsen", func buildShots(levelsDir string) error {
"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"}, 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() { func main() {
root := repoRoot() root := repoRoot()
out := filepath.Join(root, "doc", "img") out := filepath.Join(root, "doc", "img")
must(os.MkdirAll(out, 0o755)) must(os.MkdirAll(out, 0o755))
must(buildShots(filepath.Join(root, "resources", "data", "levels")))
build := exec.Command("go", "run", "./tools/build", "-mode", "debug") build := exec.Command("go", "run", "./tools/build", "-mode", "debug")
build.Dir, build.Stdout, build.Stderr = root, os.Stdout, os.Stderr build.Dir, build.Stdout, build.Stderr = root, os.Stdout, os.Stderr
@@ -43,14 +106,14 @@ func main() {
exe += ".exe" exe += ".exe"
} }
for _, s := range shots { 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 != "" { if s.level != "" {
args = append(args, "-level", s.level) args = append(args, "-level", s.level)
} }
run := exec.Command(exe, args...) run := exec.Command(exe, args...)
run.Dir, run.Stdout, run.Stderr = filepath.Dir(exe), os.Stdout, os.Stderr run.Dir, run.Stdout, run.Stderr = filepath.Dir(exe), os.Stdout, os.Stderr
if err := run.Run(); err != nil { 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) fmt.Println("klart:", out)