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>
18
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+
|
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).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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).
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
BIN
doc/img/menu.png
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
BIN
doc/img/platform.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
BIN
doc/img/sign.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -35,20 +36,25 @@ func NewLevelScene(a *Assets, id string) (*LevelScene, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
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()
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ package level
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -55,23 +56,62 @@ 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
|
||||||
Line Text `json:"line"` // linjens namn
|
Track string `json:"track"` // spårnummer som visas på skyltar/HUD, t.ex. "2"
|
||||||
Destination Text `json:"destination"` // slutstation
|
Line Text `json:"line"` // linjens namn
|
||||||
Correct bool `json:"correct"` // tar spelaren till rätt resmål
|
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.
|
// Level är en bana.
|
||||||
type Level struct {
|
type Level struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name Text `json:"name"`
|
Name Text `json:"name"`
|
||||||
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
|
||||||
Grid []string `json:"grid"`
|
FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard)
|
||||||
Trains []Train `json:"trains"`
|
Grid []string `json:"grid"`
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package level
|
package level
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -69,15 +70,20 @@ func TestOutsideGridIsWall(t *testing.T) {
|
|||||||
|
|
||||||
func TestValidationErrors(t *testing.T) {
|
func TestValidationErrors(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"ojämn rad": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#.##"`, 1),
|
"ojämn rad": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#.##"`, 1),
|
||||||
"okänt tecken": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#X#"`, 1),
|
"okänt tecken": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#X#"`, 1),
|
||||||
"två starter": strings.Replace(sample, `"#S..#P#"`, `"#S.S#P#"`, 1),
|
"två starter": strings.Replace(sample, `"#S..#P#"`, `"#S.S#P#"`, 1),
|
||||||
"ingen start": strings.Replace(sample, `"#S..#P#"`, `"#...#P#"`, 1),
|
"ingen start": strings.Replace(sample, `"#S..#P#"`, `"#...#P#"`, 1),
|
||||||
"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),
|
||||||
"saknar id": strings.Replace(sample, `"id": "test"`, `"id": ""`, 1),
|
"dwell >= interval": strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"intervalSec": 10, "dwellSec": 10}`, 1),
|
||||||
"trasig json": "{",
|
"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 {
|
for name, src := range cases {
|
||||||
if _, err := Parse([]byte(src)); err == nil {
|
if _, err := Parse([]byte(src)); err == nil {
|
||||||
@@ -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
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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番線 成田エクスプレス 新宿方面"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 599 B After Width: | Height: | Size: 692 B |
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||