M9: navigeringslägen, linjenät och terminal
All checks were successful
release / build-release (push) Successful in 1m34s

internal/nav med resources/data/network.json (76 stationer, 11 linjer,
Yamanote som ringlinje). Tåg bedöms från lineId + terminus mot banans
station/to: rätt, fel håll, för långt bort (fler än 12 stationer) eller
ingen trafik; correct räknas ut för nätkopplade banor, 1-4 rätta tåg.
Tågresan visar linjens riktiga stationer. Nya förlustorsaker "Fel håll"
och "För långt bort". Listläge (nästa tåg nere till vänster) och
skyltläge (+50 % poäng) växlas med N på bankartan och sparas; -mode.
Terminalen: biljettautomaten visar linjetavlan på nätbanor. Perronger
P p Q q R r. Bana 006 Ikebukuro med fyra perronger. Banorna 001-005
kopplade till nätet. Skript: waitover och pick calm; poängraden
radbryts; skärmdumpar uppdaterade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 13:07:36 +02:00
parent de61568e44
commit f762f9554e
53 changed files with 3041 additions and 122 deletions

View File

@@ -10,15 +10,17 @@ 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:** M9b klar (2026-10-24) – sex banor på en bankarta i **Status:** M9 klar (2026-10-24) – sju banor på en bankarta i
spelbrädesstil, med sidospår och flera våningar. Japanska skyltar och spelbrädesstil, med sidospår och flera våningar. Japanska skyltar och
ordinlärning, lämmeltåget, minikarta, tågkort, biljetter och minispel, ordinlärning, lämmeltåget, minikarta, tågkort, biljetter och minispel,
svårighet med tidsgräns, poäng och debuffar, tåg som rullar in till svårighet med tidsgräns, poäng och debuffar, tåg som rullar in till
avgångsmelodin, progression med XP, skillträd, items och sparfil. Och nu avgångsmelodin, progression med XP, skillträd, items och sparfil, folk,
lever stationen: folk som går i vägen, filer där man ska hålla till vänster, filer, reklam, sorl och vagnval. Och nu ett riktigt linjenät: tågen bedöms
reklam på väggarna, sorl i bakgrunden, och tåg med flera vagnar där man efter var de faktiskt går, resan visar linjens riktiga stationer, och man
väljer vagn, låter folk kliva av först och aktar sig för kvinnovagnen. väljer **listläge** (nästa tåg visas) eller **skyltläge** (lista ut det
Nästa är M9: navigeringslägena lista och skyltläge. Se själv med skyltar och terminalen, +50 % poäng – åk åt fel håll eller för
långt bort och banan är förlorad). Nästa är M10: story-innehåll och
flerbytesbanor. Se
[doc/roadmap.md](doc/roadmap.md). [doc/roadmap.md](doc/roadmap.md).
![Bankartan](doc/img/map.png) ![Bankartan](doc/img/map.png)
@@ -65,6 +67,13 @@ sällskapet.
![Vagnval](doc/img/cars.png) ![Vagnval](doc/img/cars.png)
I skyltläget får du bara resmålet. Skyltarna säger var perrongerna är,
terminalen (biljettautomaten) visar linjerna med deras närmaste stationer, och
du avgör själv vilka tåg som faktiskt tar dig dit – på Ikebukuro är två av
fyra rätt, ett går åt fel håll och ett tar omvägen via city.
![Terminalen i skyltläge](doc/img/terminal.png)
På de svåraste banorna har alla i sällskapet egna kort som måste ha saldo, På de svåraste banorna har alla i sällskapet egna kort som måste ha saldo,
alla behöver biljett, och när biljetten är köpt ska sällskapet placeras i alla behöver biljett, och när biljetten är köpt ska sällskapet placeras i
vagnen efter sina önskemål: fönster eller gång, vänster eller höger sida, vagnen efter sina önskemål: fönster eller gång, vänster eller höger sida,
@@ -163,6 +172,7 @@ metro-puzzle-jp -debuffs sore_feet,tired # debuffar från start (tester/skärmd
metro-puzzle-jp -mute # inget ljud metro-puzzle-jp -mute # inget ljud
metro-puzzle-jp -save /sökväg/save.json # annan sparfil (standard ~/.config/metro-puzzle-jp/save.json) metro-puzzle-jp -save /sökväg/save.json # annan sparfil (standard ~/.config/metro-puzzle-jp/save.json)
metro-puzzle-jp -nosave # läs sparfilen men skriv aldrig (tester/skärmdumpar) metro-puzzle-jp -nosave # läs sparfilen men skriv aldrig (tester/skärmdumpar)
metro-puzzle-jp -mode signs # navigeringsläge från start: list eller signs (annars sparfilens; N på bankartan växlar)
metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go
metro-puzzle-jp -version metro-puzzle-jp -version
``` ```
@@ -173,8 +183,8 @@ Pilar eller WASD går. Enter väljer, kliver på tåget, använder toaletten,
handlar i kiosken eller startar automaten; i minispelen väljer pilarna och handlar i kiosken eller startar automaten; i minispelen väljer pilarna och
Enter bekräftar (håll Enter för att mata in sedlar). H frågar om hjälp vid en Enter bekräftar (håll Enter för att mata in sedlar). H frågar om hjälp vid en
skylt, L öppnar ordlistan, T skillträdet, I garderoben, B butiken vid en kiosk, M skylt, L öppnar ordlistan, T skillträdet, I garderoben, B butiken vid en kiosk, M
döljer/visar minikartan, P på kartan lägger kortet i telefonen, Esc går döljer/visar minikartan, P på kartan lägger kortet i telefonen, N på kartan
tillbaka, R spelar om. Gamepad växlar navigeringsläge (lista/skyltar), Esc går tillbaka, R spelar om. Gamepad
med standardlayout fungerar: d-pad/vänster spak, A = välj, B = tillbaka, med standardlayout fungerar: d-pad/vänster spak, A = välj, B = tillbaka,
Y = hjälp, X = ordlista, Back/Select = karta. Y = hjälp, X = ordlista, Back/Select = karta.
@@ -217,7 +227,8 @@ internal/lang/ japanska: ordbok, kunskap, delvis översättning (ren Go
internal/minigame/ påfyllning, biljettköp, trängsel, tågresa – seedade minispel (ren Go) internal/minigame/ påfyllning, biljettköp, trängsel, tågresa – seedade minispel (ren Go)
internal/progress/ XP, skillträd, items, banresultat, upplåsning (ren Go) internal/progress/ XP, skillträd, items, banresultat, upplåsning (ren Go)
internal/save/ sparfil (versionerad JSON) internal/save/ sparfil (versionerad JSON)
internal/level/ banformat, validering, framkomlighet internal/level/ banformat, validering, framkomlighet, koppling till linjenätet
internal/nav/ linjenätet: stationer, linjer, rätt/fel håll/för långt bort (ren Go)
internal/res/ hittar och läser resources/ internal/res/ hittar och läser resources/
internal/rng/ seedad slump internal/rng/ seedad slump
tools/ build/ genart/ shots/ tools/ build/ genart/ shots/

View File

@@ -28,6 +28,7 @@ func main() {
mute := flag.Bool("mute", false, "inget ljud") mute := flag.Bool("mute", false, "inget ljud")
savePath := flag.String("save", "", "sparfil (standard: ~/.config/metro-puzzle-jp/save.json resp. %AppData%)") savePath := flag.String("save", "", "sparfil (standard: ~/.config/metro-puzzle-jp/save.json resp. %AppData%)")
noSave := flag.Bool("nosave", false, "läs sparfilen men skriv aldrig (tester/skärmdumpar)") noSave := flag.Bool("nosave", false, "läs sparfilen men skriv aldrig (tester/skärmdumpar)")
mode := flag.String("mode", "", "navigeringsläge från start: list eller signs (tomt = sparfilens)")
version := flag.Bool("version", false, "skriv ut version och avsluta") version := flag.Bool("version", false, "skriv ut version och avsluta")
flag.Parse() flag.Parse()
@@ -36,7 +37,7 @@ func main() {
return return
} }
g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card, Debuffs: splitWords(*debuffs), Mute: *mute, SavePath: *savePath, NoSave: *noSave}) g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card, Debuffs: splitWords(*debuffs), Mute: *mute, SavePath: *savePath, NoSave: *noSave, Mode: *mode})
if err != nil { if err != nil {
log.Fatalf("kunde inte starta: %v", err) log.Fatalf("kunde inte starta: %v", err)
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 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: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
doc/img/map-signs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
doc/img/nav-list.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 26 KiB

BIN
doc/img/ride-real.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

BIN
doc/img/terminal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

BIN
doc/img/too-far.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -34,7 +34,7 @@ att bygga på Linux som på Windows.
| Inkrementellt debug-bygge | < 10 s | 4,7 s | | Inkrementellt debug-bygge | < 10 s | 4,7 s |
| Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) | | Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) |
| Testsviten | `go test ./...` grön på < 30 s | ~0,1 s | | Testsviten | `go test ./...` grön på < 30 s | ~0,1 s |
| Banor | 100 % passerar det automatiska lösbarhetstestet | 6 av 6 | | Banor | 100 % passerar det automatiska lösbarhetstestet | 7 av 7 |
| Windows-korsbygge från Linux | fungerar utan extra verktyg | ✓ (CGO_ENABLED=0) | | Windows-korsbygge från Linux | fungerar utan extra verktyg | ✓ (CGO_ENABLED=0) |
| Linux-binärens länkade bibliotek | bara libc | ✓ (GL/X11 laddas dynamiskt vid körning) | | Linux-binärens länkade bibliotek | bara libc | ✓ (GL/X11 laddas dynamiskt vid körning) |
| Bildfrekvens | 60 fps vid 4× skala på integrerad grafik | ej mätt | | Bildfrekvens | 60 fps vid 4× skala på integrerad grafik | ej mätt |
@@ -127,6 +127,10 @@ att bygga på Linux som på Windows.
| `internal/level` | `TestFloorsStairsAndPaths`, `TestFloorsValidation` | **M9a:** flera våningar läses, trappor länkar, BFS-vägen byter våning bara via trappa, spärregeln och skyltar per våning; trappa utan motpart, olika storlekar, grid+floors och fel våning avvisas | | `internal/level` | `TestFloorsStairsAndPaths`, `TestFloorsValidation` | **M9a:** flera våningar läses, trappor länkar, BFS-vägen byter våning bara via trappa, spärregeln och skyltar per våning; trappa utan motpart, olika storlekar, grid+floors och fel våning avvisas |
| `internal/sim` | `TestStairsMoveBetweenFloors` | Att kliva på trappan flyttar spelaren en våning, dimman lättar runt andra änden, perrongen hörs bara på egen våning, boten går hela vägen över våningar | | `internal/sim` | `TestStairsMoveBetweenFloors` | Att kliva på trappan flyttar spelaren en våning, dimman lättar runt andra änden, perrongen hörs bara på egen våning, boten går hela vägen över våningar |
| `internal/level` | `TestCarsWomenOnlyAndCrowdFields` | **M9b:** vagnar, kvinnovagnens tidsfönster, crowd/clutter/noise 0–3, filrutor | | `internal/level` | `TestCarsWomenOnlyAndCrowdFields` | **M9b:** vagnar, kvinnovagnens tidsfönster, crowd/clutter/noise 0–3, filrutor |
| `internal/nav` | `TestJudge`, `TestTooFarOnLongLoop`, `TestStopsRoutesAndNames`, `TestValidation`, `TestShippedNetwork` | **M9:** rätt/fel håll/för långt/ingen trafik på raka linjer och ringlinjer, stationer i färdriktningen, giltiga vägar sorterade, nätets validering, det riktiga nätets förutsättningar för banorna |
| `internal/level` | `TestAnnotateWithNetwork`, `TestPlatformTilesAndShippedVerdicts` | **M9:** `correct` räknas ut från nätet, felaktiga kopplingar ger fel, okopplade banor orörda; perrongtecknen `P p Q q R r`; 005/006 får väntade bedömningar |
| `internal/sim` | `TestNetworkRideUsesRealStops`, `TestWrongDirectionLoses`, `TestTooFarLoses`, `TestSignsModeBonus`, `TestTerminalLines` | **M9:** resan visar linjens riktiga stationer med resmålet på rätt plats; fel håll och för långt bort förlorar med rätt orsak; skyltläget ger +50 %; terminalen listar linjer med grannstationer |
| `internal/lang` | `TestNetworkNamesInDictionary` | **M9:** alla stations- och linjenamn i nätet finns i ordboken |
| `internal/minigame` | `TestCarPickAlightingAndChoice`, `TestCrowdLevelNarrowsZone`, `TestRideCrowdLetPass` | Folk av först (för tidigt räknas), seedad trängsel med minst en lugn vagn, val; trängsel smalnar zonen; resan: kortare tid per station, passagerare som vill förbi, blockerade räknas | | `internal/minigame` | `TestCarPickAlightingAndChoice`, `TestCrowdLevelNarrowsZone`, `TestRideCrowdLetPass` | Folk av först (för tidigt räknas), seedad trängsel med minst en lugn vagn, val; trängsel smalnar zonen; resan: kortare tid per station, passagerare som vill förbi, blockerade räknas |
| `internal/sim` | `TestNPCsWanderStayOnFloorAndBlockPlayer`, `TestAlightingNPCsSpawnAndLeave` | Folk rör sig på golv, seedat, blockerar spelaren; avstigande spawnar när dörrarna öppnas och lämnar via utgången | | `internal/sim` | `TestNPCsWanderStayOnFloorAndBlockPlayer`, `TestAlightingNPCsSpawnAndLeave` | Folk rör sig på golv, seedat, blockerar spelaren; avstigande spawnar när dörrarna öppnas och lämnar via utgången |
| `internal/sim` | `TestLanesSlowWrongSide`, `TestCarsWomenOnlyEarlyAndCrowd` | Fel sida i filen bromsar; kvinnovagn med man ger grova minus, för tidigt ombord räknas, resan ärver vagnens trängsel, lugn vagn ger bonus | | `internal/sim` | `TestLanesSlowWrongSide`, `TestCarsWomenOnlyEarlyAndCrowd` | Fel sida i filen bromsar; kvinnovagn med man ger grova minus, för tidigt ombord räknas, resan ärver vagnens trängsel, lugn vagn ger bonus |
@@ -147,7 +151,7 @@ att bygga på Linux som på Windows.
| ~~M9b Folk, reklam, ljudröra, vagnar~~ ✓ | NPC:er, filer, röra, vagnval – finns, se tabellen ovan (bot med folk: testerna tömmer folket där det skulle störa, "fler NPC:er = längre tid" mäts manuellt) | | ~~M9b Folk, reklam, ljudröra, vagnar~~ ✓ | NPC:er, filer, röra, vagnval – finns, se tabellen ovan (bot med folk: testerna tömmer folket där det skulle störa, "fler NPC:er = längre tid" mäts manuellt) |
| ~~M8 Progression & sparfil~~ ✓ | Round-trip, skillkrav, upplåsningar, 33 %-regeln – finns, se tabellen ovan | | ~~M8 Progression & sparfil~~ ✓ | Round-trip, skillkrav, upplåsningar, 33 %-regeln – finns, se tabellen ovan |
| ~~M9a Flera våningar~~ ✓ | Trappor, vägar över våningar, dimma per våning, gamla banor oförändrade – finns, se tabellen ovan | | ~~M9a Flera våningar~~ ✓ | Trappor, vägar över våningar, dimma per våning, gamla banor oförändrade – finns, se tabellen ovan |
| M9 Navigeringslägen | Listläge genererar rätt ordning av tåg; skyltläge: 1–4 giltiga vägar; fel-riktnings-förlust triggar vid rätt slutstation | | ~~M9 Navigeringslägen~~ ✓ | Nätets bedömningar (rätt/fel håll/för långt/ingen trafik), 1–4 rätta tåg, riktiga stationer på resan, fel-håll- och för-långt-förlust, skyltbonus, terminal – finns, se tabellen ovan |
| M10 Story-innehåll | Alla banor lösbara (redan automatiskt); flerbytesbanor har sammanhängande byten; sista banan slutar på flygplatsen | | M10 Story-innehåll | Alla banor lösbara (redan automatiskt); flerbytesbanor har sammanhängande byten; sista banan slutar på flygplatsen |
| M11 Oändligt läge & NG+ | Målsekvens deterministisk per seed; NG+ behåller skills men nollställer items | | M11 Oändligt läge & NG+ | Målsekvens deterministisk per seed; NG+ behåller skills men nollställer items |
| M12 Ljud & polish | Alla ljudfiler som refereras finns och går att avkoda; Windows-binär startar (manuellt) | | M12 Ljud & polish | Alla ljudfiler som refereras finns och går att avkoda; Windows-binär startar (manuellt) |

View File

@@ -482,7 +482,7 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
- **Kvar till senare:** NPC:er som väntar på och kliver på tåget, folk som - **Kvar till senare:** NPC:er som väntar på och kliver på tåget, folk som
bildar riktiga köer vid markeringarna, utrop/reklam som ljud (M12). bildar riktiga köer vid markeringarna, utrop/reklam som ljud (M12).
### M9 – Navigeringslägen: lista och skyltläge ### M9 – Navigeringslägen: lista och skyltläge ✓ (klar 2026-10-24)
- **Mål:** de två sätten att spela story. - **Mål:** de två sätten att spela story.
- Listläge: nästa tåg visas. - Listläge: nästa tåg visas.
@@ -490,6 +490,36 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
riktning / för långt bort. riktning / för långt bort.
- **Teknik:** `internal/nav` (graf över stationer/linjer, giltiga vägar, - **Teknik:** `internal/nav` (graf över stationer/linjer, giltiga vägar,
riktningskontroll – ren Go). riktningskontroll – ren Go).
- **Så blev det:** `internal/nav` läser `resources/data/network.json`
(76 stationer, 11 linjer: Yamanote som ringlinje, Chuo, Keihin-Tohoku,
Saikyo, Marunouchi, Hibiya, Ginza, Toyoko, Tokaido, Narita Express,
Keisei). Ett tåg bedöms från `lineId` + `terminus` (en station i
färdriktningen) mot banans `station`/`to`: **Direct** (resmålet inom 12
stationer), **WrongDirection** (linjen går dit, men åt andra hållet),
**TooFar** (nås först efter fler än 12 stationer – fel varv på ringen,
omvägen via city) eller **NoService**. `correct` skrivs inte längre i
nätkopplade banfiler utan räknas ut; 1–4 rätta tåg per bana. Tågresan
visar linjens **riktiga stationer** i färdriktningen (resmålet på rätt
plats, eller aldrig). Förlustorsakerna *Fel håll* och *För långt bort*
förklarar vad som hände. **Listläge** visar "Ta: spår 6 Yamanote-sen
(+1)" i HUD:en; **skyltläge** visar bara att man är i det och ger +50 %
på grundpoängen. Läget växlas med **N** på bankartan och sparas.
**Terminalen**: på nätkopplade banor visar biljettautomaten linjetavlan
(varje linje med tre grannstationer åt vardera hållet), läst genom ens
kunskap som en skylt. Perrongtecknen generaliserade (`P p Q q R r`).
**Bana 006 Ikebukuro** (svårighet 4): fyra perronger – Yamanote mot
Shinjuku och Saikyo mot Osaki är rätt, Saikyo mot Omiya är fel håll och
Marunouchi mot Ogikubo för långt bort. Banorna 001–005 är kopplade till
nätet (Keihin-Tohoku mot Omiya i Ueno blev därmed *fel håll*).
- **Klar när:** bedömningarna och vägarna är testade (`nav.TestJudge`,
`TestTooFarOnLongLoop`, `TestStopsRoutesAndNames`, `TestShippedNetwork`),
banor kopplas rätt (`level.TestAnnotateWithNetwork`,
`TestPlatformTilesAndShippedVerdicts`), resan använder riktiga stationer
och fel håll/för långt förlorar (`sim.TestNetworkRideUsesRealStops`,
`TestWrongDirectionLoses`, `TestTooFarLoses`), skyltläget ger bonus
(`TestSignsModeBonus`) och terminalen listar linjer (`TestTerminalLines`). ✓
- **Kvar till senare:** flera tåg i rad (byten) kommer med M10; bussar;
"terminaler" som egna rutor i stället för biljettautomaten.
### M10 – Story-innehåll och flerbytesbanor ### M10 – Story-innehåll och flerbytesbanor

View File

@@ -265,4 +265,6 @@ från skills i skill tree, och från unika skills man får av sidouppdrag.
| "Lämel tåg" | *Lämmeltåg*: sällskapet som följer efter dig i rad | Styr §5 | | "Lämel tåg" | *Lämmeltåg*: sällskapet som följer efter dig i rad | Styr §5 |
| "level maps game box liknande styl av level väljande" | Bankarta i spelbrädes-/världskartestil | Kan ändras om något annat avsågs | | "level maps game box liknande styl av level väljande" | Bankarta i spelbrädes-/världskartestil | Kan ändras om något annat avsågs |
| "bilder och eventuella språk och tester" (resursmappen) | bilder, språk och *texter* | Se resurslayouten i roadmap | | "bilder och eventuella språk och tester" (resursmappen) | bilder, språk och *texter* | Se resurslayouten i roadmap |
| "slutstation ligger i fel riktning … eller hamnar för långt bort" (skyltläge) | Linjenätet (`network.json`) bedömer varje tåg: *fel håll* om linjen går till resmålet men tåget åt andra hållet; *för långt bort* om resmålet nås först efter fler än 12 stationer (fel varv på Yamanote-ringen, omvägen via city). Båda förlorar banan – i båda lägena, eftersom fel tåg alltid har förlorat | Gränsen 12 är `nav.MaxStops` och kan ändras |
| "skyltar och terminaler" | Terminalen är biljettautomaten: på nätkopplade banor visar den linjetavlan (linjer vid stationen med grannstationer åt båda hållen), läst genom ens japanska som en skylt | Egna terminalrutor kan komma senare |
| Repo-namnet | Arbetsnamnet var "jp-subway-pussel"; Björn döpte Gitea-repot till **metro-puzzle-jp** (2026-10-23), så repo, modul, binär och titel heter så | Spelets visningsnamn "Metro Puzzle JP" kan ändras fritt i `resources/text/*.json` | | Repo-namnet | Arbetsnamnet var "jp-subway-pussel"; Björn döpte Gitea-repot till **metro-puzzle-jp** (2026-10-23), så repo, modul, binär och titel heter så | Spelets visningsnamn "Metro Puzzle JP" kan ändras fritt i `resources/text/*.json` |

View File

@@ -80,7 +80,9 @@ func LoadAssets() (*Assets, error) {
} }
a.Tiles[t] = sub(i) a.Tiles[t] = sub(i)
} }
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör for _, t := range level.PlatformTiles[1:] {
a.Tiles[t] = a.Tiles[level.Platform] // samma utseende; tåget avgör
}
a.Train = sub(trainIndex) a.Train = sub(trainIndex)
a.Rails = sub(railsIndex) a.Rails = sub(railsIndex)
for i := 0; i < posterCount; i++ { for i := 0; i < posterCount; i++ {

View File

@@ -12,6 +12,7 @@ import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
@@ -45,6 +46,7 @@ type Game struct {
Catalog *progress.Catalog Catalog *progress.Catalog
Levels []*level.Level // alla banor, sorterade på id Levels []*level.Level // alla banor, sorterade på id
Audio *Audio // nil = tyst Audio *Audio // nil = tyst
Mode nav.Mode // navigeringsläge (= State.NavMode), växlas på bankartan
savePath string // "" = spara inte savePath string // "" = spara inte
fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar) fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar)
@@ -69,6 +71,7 @@ type Options struct {
Mute bool // inget ljud Mute bool // inget ljud
SavePath string // sparfil ("" = standardplatsen) SavePath string // sparfil ("" = standardplatsen)
NoSave bool // läs sparfilen men skriv aldrig NoSave bool // läs sparfilen men skriv aldrig
Mode string // navigeringsläge från start: "list" eller "signs" (tomt = sparfilens)
} }
// New laddar resurser, sparfil och bygger spelet. // New laddar resurser, sparfil och bygger spelet.
@@ -109,6 +112,10 @@ func New(opt Options) (*Game, error) {
} }
g.State = st g.State = st
g.Know, g.Wallet, g.Profile, g.Progress = st.Know, st.Wallet, st.Profile, st.Progress g.Know, g.Wallet, g.Profile, g.Progress = st.Know, st.Wallet, st.Profile, st.Progress
g.Mode = nav.ParseMode(st.NavMode)
if opt.Mode != "" {
g.Mode = nav.ParseMode(opt.Mode)
}
if !opt.NoSave { if !opt.NoSave {
g.savePath = path g.savePath = path
} }
@@ -233,10 +240,21 @@ func (g *Game) runOptions(id string, seed rng.Seed) sim.RunOptions {
return sim.RunOptions{ return sim.RunOptions{
Dict: g.Assets.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed, Dict: g.Assets.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed,
Extra: eff.Stats, FogRadius: eff.FogRadius, Landmarks: eff.Landmarks, FreeGates: eff.FreeGates, Extra: eff.Stats, FogRadius: eff.FogRadius, Landmarks: eff.Landmarks, FreeGates: eff.FreeGates,
RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery, RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery, Mode: g.Mode,
} }
} }
// toggleMode växlar navigeringsläge och sparar.
func (g *Game) toggleMode() {
if g.Mode == nav.List {
g.Mode = nav.Signs
} else {
g.Mode = nav.List
}
g.State.NavMode = g.Mode.String()
g.Save()
}
// startLevel startar (eller återupptar) en bana och sparar seeden så att en // startLevel startar (eller återupptar) en bana och sparar seeden så att en
// laddad omgång ger samma slump. // laddad omgång ger samma slump.
func (g *Game) startLevel(id string) (*LevelScene, error) { func (g *Game) startLevel(id string) (*LevelScene, error) {

View File

@@ -126,6 +126,9 @@ func shopJustPressed() bool { return virtual.pressed["b"] || inpututil.IsKeyJust
// phoneJustPressed: P = kortet i telefonen av/på (meny). // phoneJustPressed: P = kortet i telefonen av/på (meny).
func phoneJustPressed() bool { return virtual.pressed["p"] || inpututil.IsKeyJustPressed(ebiten.KeyP) } func phoneJustPressed() bool { return virtual.pressed["p"] || inpututil.IsKeyJustPressed(ebiten.KeyP) }
// modeJustPressed: N växlar navigeringsläge (lista/skyltar) på bankartan.
func modeJustPressed() bool { return virtual.pressed["n"] || inpututil.IsKeyJustPressed(ebiten.KeyN) }
// mapJustPressed: M eller gamepadens vänstra mittknapp (Back/Select) = minikartan av/på. // mapJustPressed: M eller gamepadens vänstra mittknapp (Back/Select) = minikartan av/på.
func mapJustPressed() bool { func mapJustPressed() bool {
return virtual.pressed["m"] || inpututil.IsKeyJustPressed(ebiten.KeyM) || gamepadJustPressed(ebiten.StandardGamepadButtonCenterLeft) return virtual.pressed["m"] || inpututil.IsKeyJustPressed(ebiten.KeyM) || gamepadJustPressed(ebiten.StandardGamepadButtonCenterLeft)

View File

@@ -24,6 +24,10 @@ var minimapColors = map[level.Tile]color.RGBA{
level.Spawn: {99, 199, 77, 255}, level.Spawn: {99, 199, 77, 255},
level.Platform: {254, 174, 52, 255}, level.Platform: {254, 174, 52, 255},
level.WrongPlatform: {254, 174, 52, 255}, level.WrongPlatform: {254, 174, 52, 255},
level.Platform2: {254, 174, 52, 255},
level.Platform3: {254, 174, 52, 255},
level.Platform4: {254, 174, 52, 255},
level.Platform5: {254, 174, 52, 255},
level.Gate: {44, 232, 245, 255}, level.Gate: {44, 232, 245, 255},
level.TicketMachine: {247, 118, 34, 255}, level.TicketMachine: {247, 118, 34, 255},
level.Sign: {90, 140, 230, 255}, level.Sign: {90, 140, 230, 255},
@@ -111,7 +115,7 @@ func (s *LevelScene) drawMinimap(g *Game, screen *ebiten.Image, box image.Rectan
} }
t := l.At(p) t := l.At(p)
c := minimapColors[t] c := minimapColors[t]
if (t == level.Platform || t == level.WrongPlatform) && r.TrainVisible(t) { if t.IsPlatform() && r.TrainVisible(t) {
c = color.RGBA{255, 255, 255, 255} // tåget är inne c = color.RGBA{255, 255, 255, 255} // tåget är inne
} }
vector.DrawFilledRect(screen, float32(x0+x*sc), float32(y0+y*sc), float32(sc), float32(sc), c, false) vector.DrawFilledRect(screen, float32(x0+x*sc), float32(y0+y*sc), float32(sc), float32(sc), c, false)

View File

@@ -15,6 +15,7 @@ import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
) )
@@ -104,6 +105,31 @@ func (s *LevelScene) RideAtDest() bool {
return !ok || g.Index == g.Dest return !ok || g.Index == g.Dest
} }
// drawModeHint ritar navigeringslägets ruta nere till vänster när inforutan
// är tom: listläget säger vilket tåg man ska ta, skyltläget påminner om att
// man får lista ut det själv (och om bonusen).
func (s *LevelScene) drawModeHint(a *Assets, screen *ebiten.Image) {
r := s.run
txt, col := "", colGood
if r.Mode == nav.Signs {
txt, col = a.T("hud.mode_signs"), colWarn
} else if next := r.NextTrains(); len(next) > 0 {
txt = fmt.Sprintf("%s %s %s %s", a.T("hud.take"), strings.ToLower(a.T("hud.track")), next[0].Track, next[0].Line.Get("romaji"))
if len(next) > 1 {
txt += fmt.Sprintf(" (+%d)", len(next)-1)
}
}
if txt == "" {
return
}
y := ScreenH - LineH - 6
vector.DrawFilledRect(screen, 0, float32(y), float32(a.Width(txt)+8), float32(LineH+6), colPanel, false)
a.Draw(screen, txt, 4, y+3, col)
}
// LevelOver rapporterar om banan är avgjord (skriptläget: "waitover").
func (s *LevelScene) LevelOver() bool { return s.run.Outcome != sim.Ongoing }
func (s *LevelScene) followPlayer() { func (s *LevelScene) followPlayer() {
x, y := s.run.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)
@@ -165,6 +191,20 @@ func (s *LevelScene) MiniPick(text string) bool {
s.run.StepMini(0, minigame.Input{Move: 1}) s.run.StepMini(0, minigame.Input{Move: 1})
return false return false
} }
case *minigame.CarPick:
if text == "calm" { // lugnaste vagnen som inte är kvinnovagn
best := -1
for i, c := range m.Cars {
if !c.WomenOnly && (best < 0 || c.Crowd < m.Cars[best].Crowd) {
best = i
}
}
if best < 0 || m.Cursor == best {
return true
}
s.run.StepMini(0, minigame.Input{MoveX: 1})
return false
}
case *minigame.TopUp: case *minigame.TopUp:
switch m.Stage { switch m.Stage {
case minigame.TopUpPickMember: case minigame.TopUpPickMember:
@@ -345,6 +385,9 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
return // resultatpanelen, minispelet eller butiken tar över; ingen inforuta eller minikarta return // resultatpanelen, minispelet eller butiken tar över; ingen inforuta eller minikarta
} }
lines, accent, bar := s.infoLines(a) lines, accent, bar := s.infoLines(a)
if len(lines) == 0 {
s.drawModeHint(a, screen)
}
if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec { if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec {
lines = append(lines, plainLine(fmt.Sprintf("%s: %s = %s (%s)", a.T("hud.learned"), r.Learned, lines = append(lines, plainLine(fmt.Sprintf("%s: %s = %s (%s)", a.T("hud.learned"), r.Learned,
entry.Translation(UILang), entry.Romaji), colKnown)) entry.Translation(UILang), entry.Romaji), colKnown))
@@ -499,10 +542,18 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
} }
return []line{plainLine(a.T("hud.kiosk_none")+" "+a.T("hud.shop_prompt"), colDim)}, accent, false return []line{plainLine(a.T("hud.kiosk_none")+" "+a.T("hud.shop_prompt"), colDim)}, accent, false
case level.TicketMachine: case level.TicketMachine:
if lvl.TicketRequired { // Terminalen: linjetavlan för stationen (nätkopplade banor), läst genom kunskapen som en skylt.
return []line{plainLine(fmt.Sprintf(a.T("hud.machine_ticket"), r.Wallet.Cash), colGood)}, colGood, false if term := r.TerminalLines(); len(term) > 0 {
lines = append(lines, plainLine(a.T("hud.terminal"), colDim))
for _, t := range term {
rd := lang.Render(a.Dict, r.Know, t, UILang)
lines = append(lines, a.wrapLine(shownLine(rd.Partial, colText, false), maxW)...)
} }
return []line{plainLine(fmt.Sprintf(a.T("hud.machine_topup"), r.Wallet.Card, r.Wallet.Cash), colGood)}, colGood, false }
if lvl.TicketRequired {
return append(lines, plainLine(fmt.Sprintf(a.T("hud.machine_ticket"), r.Wallet.Cash), colGood)), colGood, false
}
return append(lines, plainLine(fmt.Sprintf(a.T("hud.machine_topup"), r.Wallet.Card, r.Wallet.Cash), colGood)), colGood, false
} }
if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec { if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec {
key := "hud.gate_denied" key := "hud.gate_denied"

View File

@@ -101,6 +101,8 @@ func (m *MapScene) Update(g *Game) error {
case phoneJustPressed(): case phoneJustPressed():
g.Wallet.Phone = !g.Wallet.Phone g.Wallet.Phone = !g.Wallet.Phone
g.Save() g.Save()
case modeJustPressed():
g.toggleMode()
} }
if n := len(m.mains); n > 0 { if n := len(m.mains); n > 0 {
mx, my := miniMoveXY() mx, my := miniMoveXY()
@@ -209,7 +211,7 @@ func (m *MapScene) Draw(g *Game, screen *ebiten.Image) {
foot := fmt.Sprintf("%s %d %s %d %s %d¥", a.T("map.skill_points"), p.SkillPoints, a.T("words.points"), g.Know.Points, foot := fmt.Sprintf("%s %d %s %d %s %d¥", a.T("map.skill_points"), p.SkillPoints, a.T("words.points"), g.Know.Points,
a.T("hud.cash"), g.Wallet.Cash) a.T("hud.cash"), g.Wallet.Cash)
a.Draw(screen, foot, 8, 30, colDim) a.Draw(screen, foot, 8, 30, colDim)
a.Draw(screen, fmt.Sprintf(a.T("menu.phone"), onoff), 8, 42, colDim) a.Draw(screen, fmt.Sprintf(a.T("menu.phone"), onoff)+" "+fmt.Sprintf(a.T("menu.mode"), a.T("mode."+g.Mode.String())), 8, 42, colDim)
if len(g.Profile.Debuffs) > 0 { if len(g.Profile.Debuffs) > 0 {
var parts []string var parts []string
for _, d := range g.Profile.Debuffs { for _, d := range g.Profile.Debuffs {

View File

@@ -62,8 +62,12 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
y += LineH y += LineH
if run.Outcome == sim.Lost { if run.Outcome == sim.Lost {
reason := a.T(run.LostReason) reason := a.T(run.LostReason)
if run.LostReason == sim.LostWrongTrain { if detail, ok := map[string]string{
reason = fmt.Sprintf(a.T("result.wrong_detail"), tr.Destination.Get("romaji"), run.Level.Destination.Get("romaji")) sim.LostWrongTrain: "result.wrong_detail",
sim.LostWrongDirection: "result.wrong_direction_detail",
sim.LostTooFar: "result.too_far_detail",
}[run.LostReason]; ok {
reason += " " + fmt.Sprintf(a.T(detail), tr.Destination.Get("romaji"), run.Level.Destination.Get("romaji"))
} }
for _, l := range a.wrapLine(plainLine(reason, colText), ScreenW-56) { for _, l := range a.wrapLine(plainLine(reason, colText), ScreenW-56) {
a.drawLines(screen, []line{l}, 28, y) a.drawLines(screen, []line{l}, 28, y)
@@ -80,11 +84,26 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
y += LineH y += LineH
if run.Outcome == sim.Won { if run.Outcome == sim.Won {
sc := run.Score sc := run.Score
a.Draw(screen, fmt.Sprintf("%s %d", a.T("result.score"), sc.Total), 28, y, colText) // Poängen som en uträkning; radbryts till högst två rader.
y += LineH parts := []string{
a.Draw(screen, fmt.Sprintf("%s %d %s %d %s %d %s %d %s %d %s %d", strings.ToLower(a.T("hud.time")), sc.Time, a.T("result.money"), sc.Money, fmt.Sprintf("%s %d", strings.ToLower(a.T("hud.time")), sc.Time), fmt.Sprintf("%s %d", a.T("result.money"), sc.Money),
a.T("result.meters"), sc.Meters, a.T("result.map"), sc.Explore, a.T("result.car"), sc.Car, a.T("result.penalty"), sc.Penalty), 28, y, colDim) fmt.Sprintf("%s %d", a.T("result.meters"), sc.Meters), fmt.Sprintf("%s %d", a.T("result.map"), sc.Explore),
fmt.Sprintf("%s %d", a.T("result.car"), sc.Car),
}
if sc.Bonus > 0 {
parts = append(parts, fmt.Sprintf("%s %d", a.T("result.bonus"), sc.Bonus))
}
total := fmt.Sprintf("%s %d = %s", a.T("result.score"), sc.Total, strings.Join(parts, " + "))
if sc.Penalty > 0 {
total += fmt.Sprintf(" - %s %d", a.T("result.penalty"), sc.Penalty)
}
for i, l := range a.wrapLine(plainLine(total, colText), ScreenW-56) {
if i > 1 {
break
}
a.drawLines(screen, []line{l}, 28, y)
y += LineH y += LineH
}
msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("result.lang_points")) msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("result.lang_points"))
if len(run.LearnedDest) > 0 { if len(run.LearnedDest) > 0 {
msg += " " + a.T("hud.learned") + ": " + strings.Join(run.LearnedDest, " ") + " (" + a.T("result.destination") + ")" msg += " " + a.T("hud.learned") + ": " + strings.Join(run.LearnedDest, " ") + " (" + a.T("result.destination") + ")"

View File

@@ -93,7 +93,7 @@ func parseScript(src string) (*script, error) {
return nil, fmt.Errorf("script: '%s TEXT'", f[0]) return nil, fmt.Errorf("script: '%s TEXT'", f[0])
} }
st.arg = f[1] st.arg = f[1]
case "quit", "waittrain", "waitstop": case "quit", "waittrain", "waitstop", "waitover":
default: default:
return nil, fmt.Errorf("script: okänt kommando %q", f[0]) return nil, fmt.Errorf("script: okänt kommando %q", f[0])
} }
@@ -126,6 +126,7 @@ type playerTiler interface {
type trainWaiter interface { type trainWaiter interface {
TrainHere() bool TrainHere() bool
RideAtDest() bool RideAtDest() bool
LevelOver() bool
MiniPick(text string) bool MiniPick(text string) bool
TrackPhaseName() string TrackPhaseName() string
} }
@@ -195,6 +196,11 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error {
if tw, ok := g.scene.(trainWaiter); !ok || tw.RideAtDest() { if tw, ok := g.scene.(trainWaiter); !ok || tw.RideAtDest() {
s.advance() s.advance()
} }
case "waitover":
s.counter++
if tw, ok := g.scene.(trainWaiter); !ok || tw.LevelOver() || s.counter > 60*120 {
s.advance()
}
case "waitphase": case "waitphase":
s.counter++ s.counter++
if tw, ok := g.scene.(trainWaiter); !ok || tw.TrackPhaseName() == st.arg || s.counter > 60*60 { if tw, ok := g.scene.(trainWaiter); !ok || tw.TrackPhaseName() == st.arg || s.counter > 60*60 {

View File

@@ -36,7 +36,7 @@ type trackInfo struct {
func (s *LevelScene) buildTracks() { func (s *LevelScene) buildTracks() {
s.tracks = nil s.tracks = nil
s.rails = map[level.Point]bool{} s.rails = map[level.Point]bool{}
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} { for _, t := range level.PlatformTiles {
for _, p := range s.run.Level.Find(t) { for _, p := range s.run.Level.Find(t) {
tt, horizontal := s.run.Level.TrackTile(p) tt, horizontal := s.run.Level.TrackTile(p)
s.tracks = append(s.tracks, trackInfo{tile: t, platform: p, track: tt, horizontal: horizontal}) s.tracks = append(s.tracks, trackInfo{tile: t, platform: p, track: tt, horizontal: horizontal})

View File

@@ -7,6 +7,7 @@ import (
"testing" "testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
) )
@@ -79,3 +80,27 @@ func TestAllJapaneseInLevelsIsInDictionary(t *testing.T) {
} }
} }
} }
// TestNetworkNamesInDictionary: alla stations- och linjenamn i linjenätet
// finns i ordboken, så att tågresans stationer och terminalen går att lära sig.
func TestNetworkNamesInDictionary(t *testing.T) {
d := loadDict(t)
path, err := res.Path("data", "network.json")
if err != nil {
t.Fatal(err)
}
n, err := nav.LoadFile(path)
if err != nil {
t.Fatal(err)
}
for _, s := range n.Stations {
if u := d.Unknown(s.Name["ja"]); len(u) > 0 {
t.Errorf("station %s (%s) saknas i ordboken: %s", s.ID, s.Name["ja"], strings.Join(u, ", "))
}
}
for _, l := range n.Lines {
if u := d.Unknown(l.Name["ja"]); len(u) > 0 {
t.Errorf("linje %s (%s) saknas i ordboken: %s", l.ID, l.Name["ja"], strings.Join(u, ", "))
}
}
}

View File

@@ -14,6 +14,8 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
) )
// Tile är ett tecken i rutnätet. // Tile är ett tecken i rutnätet.
@@ -24,8 +26,12 @@ const (
Wall Tile = '#' // vägg, ej passerbar Wall Tile = '#' // vägg, ej passerbar
Floor Tile = '.' // golv Floor Tile = '.' // golv
Spawn Tile = 'S' // startpunkt (exakt en) Spawn Tile = 'S' // startpunkt (exakt en)
Platform Tile = 'P' // perrong med ett tåg (målet om tåget är rätt) Platform Tile = 'P' // perrong med ett tåg (vilket tåg som är rätt avgör tåget, inte tecknet)
WrongPlatform Tile = 'p' // perrong med fel tåg WrongPlatform Tile = 'p' // ytterligare perrong (namnet är historiskt: i de första banorna stod fel tåg här)
Platform2 Tile = 'Q' // fler perronger för banor med flera giltiga vägar och fällor
Platform3 Tile = 'q'
Platform4 Tile = 'R'
Platform5 Tile = 'r'
Gate Tile = 'G' // spärr (kräver kort/biljett) Gate Tile = 'G' // spärr (kräver kort/biljett)
TicketMachine Tile = 'T' // biljett-/påfyllningsautomat TicketMachine Tile = 'T' // biljett-/påfyllningsautomat
Sign Tile = 'i' // skylt Sign Tile = 'i' // skylt
@@ -37,12 +43,26 @@ const (
LaneRight Tile = '>' // fil för gång neråt/åt höger LaneRight Tile = '>' // fil för gång neråt/åt höger
) )
// PlatformTiles är alla perrongtecken, i den ordning tåg brukar listas.
var PlatformTiles = []Tile{Platform, WrongPlatform, Platform2, Platform3, Platform4, Platform5}
var knownTiles = map[Tile]bool{ var knownTiles = map[Tile]bool{
Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true, Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true,
Platform2: true, Platform3: true, Platform4: true, Platform5: true,
Gate: true, TicketMachine: true, Sign: true, Toilet: true, Kiosk: true, Gate: true, TicketMachine: true, Sign: true, Toilet: true, Kiosk: true,
StairsUp: true, StairsDown: true, LaneLeft: true, LaneRight: true, StairsUp: true, StairsDown: true, LaneLeft: true, LaneRight: true,
} }
// IsPlatform rapporterar om rutan är en perrong (något av PlatformTiles).
func (t Tile) IsPlatform() bool {
for _, p := range PlatformTiles {
if t == p {
return true
}
}
return false
}
// IsLane rapporterar om rutan är en markerad fil. // IsLane rapporterar om rutan är en markerad fil.
func (t Tile) IsLane() bool { return t == LaneLeft || t == LaneRight } func (t Tile) IsLane() bool { return t == LaneLeft || t == LaneRight }
@@ -171,13 +191,17 @@ type Train struct {
Track string `json:"track"` // spårnummer som visas på skyltar/HUD, t.ex. "2" 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 (räknas ut från nätet när banan har station/to)
LineID string `json:"lineId"` // linjen i linjenätet (resources/data/network.json)
Terminus string `json:"terminus"` // en station i färdriktningen (nätets id) – avgör riktningen
Crowded bool `json:"crowded"` // fullt tåg: minispel för att få plats (gäller alla vagnar) Crowded bool `json:"crowded"` // fullt tåg: minispel för att få plats (gäller alla vagnar)
Cars int `json:"cars"` // antal vagnar (0 = DefaultCars) Cars int `json:"cars"` // antal vagnar (0 = DefaultCars)
WomenOnlyCar int `json:"womenOnlyCar"` // 1-baserat vagnnummer för kvinnovagn, 0 = ingen WomenOnlyCar int `json:"womenOnlyCar"` // 1-baserat vagnnummer för kvinnovagn, 0 = ingen
WomenOnlyFrom int `json:"womenOnlyFrom"` // kvinnovagn gäller från den här bantiden (s); 0 och To=0 = alltid WomenOnlyFrom int `json:"womenOnlyFrom"` // kvinnovagn gäller från den här bantiden (s); 0 och To=0 = alltid
WomenOnlyTo int `json:"womenOnlyTo"` // …till den här bantiden (s) WomenOnlyTo int `json:"womenOnlyTo"` // …till den här bantiden (s)
Schedule Schedule `json:"schedule"` Schedule Schedule `json:"schedule"`
Verdict nav.Verdict `json:"-"` // nätets bedömning (Unjudged när banan inte är kopplad)
} }
// DefaultCars och gränser för vagnar. // DefaultCars och gränser för vagnar.
@@ -232,6 +256,8 @@ type Level struct {
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
Station string `json:"station"` // stationen i linjenätet (nätets id); tomt = banan är inte kopplad till nätet
To string `json:"to"` // resmålets station i nätet
TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns
ParSec int `json:"parSec"` // "bra tid" för tre stjärnor (0 = räknas ut från kortaste vägen) ParSec int `json:"parSec"` // "bra tid" för tre stjärnor (0 = räknas ut från kortaste vägen)
Party int `json:"party"` // antal i sällskapet inkl. spelaren (0 = 1) Party int `json:"party"` // antal i sällskapet inkl. spelaren (0 = 1)
@@ -245,6 +271,8 @@ type Level struct {
Floors []Storey `json:"floors,omitempty"` // flervåningsbana; alla våningar lika stora Floors []Storey `json:"floors,omitempty"` // flervåningsbana; alla våningar lika stora
Trains []Train `json:"trains"` Trains []Train `json:"trains"`
Signs []SignText `json:"signs"` Signs []SignText `json:"signs"`
Net *nav.Network `json:"-"` // linjenätet banan är kopplad till (nil om inte)
} }
// Point är en rutkoordinat: x, y och våning z. // Point är en rutkoordinat: x, y och våning z.
@@ -393,11 +421,12 @@ func (l *Level) Validate() error {
if !knownTiles[t] { if !knownTiles[t] {
return fmt.Errorf("level %s: okänt tecken %q på (%d,%d,%d)", l.ID, row[x], x, y, z) return fmt.Errorf("level %s: okänt tecken %q på (%d,%d,%d)", l.ID, row[x], x, y, z)
} }
if t.IsPlatform() {
platforms++
}
switch t { switch t {
case Spawn: case Spawn:
spawns++ spawns++
case Platform, WrongPlatform:
platforms++
case StairsUp: case StairsUp:
if l.FloorAt(z+1, x, y) != StairsDown { if l.FloorAt(z+1, x, y) != StairsDown {
return fmt.Errorf("level %s: trappa upp på (%d,%d,%d) saknar trappa ner ovanför", l.ID, x, y, z) return fmt.Errorf("level %s: trappa upp på (%d,%d,%d) saknar trappa ner ovanför", l.ID, x, y, z)
@@ -414,7 +443,7 @@ func (l *Level) Validate() error {
return fmt.Errorf("level %s: förväntade exakt en start (S), hittade %d", l.ID, spawns) return fmt.Errorf("level %s: förväntade exakt en start (S), hittade %d", l.ID, spawns)
} }
if platforms == 0 { if platforms == 0 {
return fmt.Errorf("level %s: saknar perronger (P/p)", l.ID) return fmt.Errorf("level %s: saknar perronger (P/p/Q/q/R/r)", l.ID)
} }
if len(l.Trains) == 0 { if len(l.Trains) == 0 {
return fmt.Errorf("level %s: saknar tåg", l.ID) return fmt.Errorf("level %s: saknar tåg", l.ID)
@@ -422,7 +451,7 @@ func (l *Level) Validate() error {
correct := 0 correct := 0
for i := range l.Trains { for i := range l.Trains {
tr := &l.Trains[i] 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]).IsPlatform() {
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)
} }
if len(l.Find(Tile(tr.Platform[0]))) == 0 { if len(l.Find(Tile(tr.Platform[0]))) == 0 {
@@ -454,7 +483,7 @@ func (l *Level) Validate() error {
return fmt.Errorf("level %s: tåg %d: uppehållet måste vara ≥ %.1f s och tiden borta ≥ %.1f s för tågfaserna", l.ID, i, MinDwellSec, LeaveSec+ApproachSec+1) return fmt.Errorf("level %s: tåg %d: uppehållet måste vara ≥ %.1f s och tiden borta ≥ %.1f s för tågfaserna", l.ID, i, MinDwellSec, LeaveSec+ApproachSec+1)
} }
} }
if correct == 0 { if correct == 0 && !l.Networked() {
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 { if l.FareYen == 0 {
@@ -623,7 +652,7 @@ func (l *Level) UngatedPlatforms() []Point {
func (l *Level) Unreachable() []Point { func (l *Level) Unreachable() []Point {
reach := l.Reachable() reach := l.Reachable()
var out []Point var out []Point
for _, t := range []Tile{Platform, WrongPlatform, TicketMachine, Sign, Gate, Toilet, Kiosk} { for _, t := range append(append([]Tile(nil), PlatformTiles...), TicketMachine, Sign, Gate, Toilet, Kiosk) {
for _, p := range l.Find(t) { for _, p := range l.Find(t) {
if !reach[p] { if !reach[p] {
out = append(out, p) out = append(out, p)
@@ -677,28 +706,106 @@ func (l *Level) TrainAt(t Tile) *Train {
return nil return nil
} }
// LoadFile läser och validerar en bana från disk. // LoadFile läser och validerar en bana från disk. Är banan kopplad till
// linjenätet läses nätet från NetworkPath bredvid banmappen.
func LoadFile(path string) (*Level, error) { func LoadFile(path string) (*Level, error) {
b, err := os.ReadFile(path) b, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("level: %w", err) return nil, fmt.Errorf("level: %w", err)
} }
return Parse(b) l, err := Parse(b)
if err != nil {
return nil, err
}
if !l.Networked() {
return l, nil
}
net, err := nav.LoadFile(NetworkPath(filepath.Dir(path)))
if err != nil {
return nil, fmt.Errorf("level %s: %w", l.ID, err)
}
if err := l.Annotate(net); err != nil {
return nil, err
}
return l, nil
}
// NetworkPath är linjenätets fil för en banmapp: ../network.json.
func NetworkPath(levelsDir string) string { return filepath.Join(levelsDir, "..", "network.json") }
// Networked rapporterar om banan pekar på linjenätet (station/to satta).
func (l *Level) Networked() bool { return l.Station != "" || l.To != "" }
// Annotate kopplar banan till linjenätet: varje tåg bedöms av nätet utifrån
// lineId och terminus, och correct räknas ut därifrån i stället för att
// skrivas i banfilen. Skyltläget tillåter 1–nav.MaxRoutes rätta tåg.
func (l *Level) Annotate(net *nav.Network) error {
if !l.Networked() {
return nil
}
if net == nil {
return fmt.Errorf("level %s: pekar på linjenätet men inget nät finns", l.ID)
}
if net.Station(l.Station) == nil || net.Station(l.To) == nil {
return fmt.Errorf("level %s: station %q eller resmål %q finns inte i nätet", l.ID, l.Station, l.To)
}
if want := net.StationName(l.To, "ja"); l.Destination.Get("ja") != want {
return fmt.Errorf("level %s: destination %q matchar inte nätets %q", l.ID, l.Destination.Get("ja"), want)
}
l.Net = net
correct := 0
for i := range l.Trains {
tr := &l.Trains[i]
ln := net.Line(tr.LineID)
if ln == nil {
return fmt.Errorf("level %s: tåg %d har okänd linje %q", l.ID, i, tr.LineID)
}
if tr.Line.Get("ja") != ln.Name["ja"] {
return fmt.Errorf("level %s: tåg %d heter %q men linjen %s heter %q", l.ID, i, tr.Line.Get("ja"), tr.LineID, ln.Name["ja"])
}
v := net.Judge(tr.LineID, l.Station, tr.Terminus, l.To)
if v == nav.Unjudged {
return fmt.Errorf("level %s: tåg %d (%s mot %q) går inte att bedöma i nätet", l.ID, i, tr.LineID, tr.Terminus)
}
tr.Verdict = v
tr.Correct = v == nav.Direct
if tr.Correct {
correct++
}
}
if correct < 1 || correct > nav.MaxRoutes {
return fmt.Errorf("level %s: %d rätta tåg, vill ha 1–%d", l.ID, correct, nav.MaxRoutes)
}
return nil
} }
// IsSide rapporterar om banan är en sidobana. // IsSide rapporterar om banan är en sidobana.
func (l *Level) IsSide() bool { return l.Kind == "side" } func (l *Level) IsSide() bool { return l.Kind == "side" }
// LoadAll läser alla banor i en mapp, sorterade på id. // LoadAll läser alla banor i en mapp, sorterade på id, och kopplar dem som
// pekar på linjenätet till ../network.json (läses en gång).
func LoadAll(dir string) ([]*Level, error) { func LoadAll(dir string) ([]*Level, error) {
ids, err := List(dir) ids, err := List(dir)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var net *nav.Network
if _, statErr := os.Stat(NetworkPath(dir)); statErr == nil {
if net, err = nav.LoadFile(NetworkPath(dir)); err != nil {
return nil, err
}
}
var out []*Level var out []*Level
for _, id := range ids { for _, id := range ids {
l, err := LoadFile(filepath.Join(dir, id+".json")) b, err := os.ReadFile(filepath.Join(dir, id+".json"))
if err != nil { if err != nil {
return nil, fmt.Errorf("level: %w", err)
}
l, err := Parse(b)
if err != nil {
return nil, err
}
if err := l.Annotate(net); err != nil {
return nil, err return nil, err
} }
out = append(out, l) out = append(out, l)

View File

@@ -2,12 +2,12 @@ package level
import ( import (
"math" "math"
"os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"testing" "testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
) )
@@ -122,11 +122,7 @@ func TestAllShippedLevels(t *testing.T) {
} }
for _, f := range files { for _, f := range files {
t.Run(filepath.Base(f), func(t *testing.T) { t.Run(filepath.Base(f), func(t *testing.T) {
b, err := os.ReadFile(f) l, err := LoadFile(f) // kopplar nätbanor till network.json (rätt tåg räknas ut där)
if err != nil {
t.Fatal(err)
}
l, err := Parse(b)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -146,6 +142,18 @@ func TestAllShippedLevels(t *testing.T) {
if tr.Track == "" { if tr.Track == "" {
t.Errorf("tåg %d saknar spårnummer", i) t.Errorf("tåg %d saknar spårnummer", i)
} }
if l.Networked() && tr.Verdict == nav.Unjudged {
t.Errorf("tåg %d är inte bedömt av nätet", i)
}
}
if l.Networked() && len(l.Trains) > 1 {
trap := false
for _, tr := range l.Trains {
trap = trap || !tr.Correct
}
if !trap {
t.Error("nätbanor med flera tåg ska ha minst ett fel tåg att undvika")
}
} }
if l.Party > 1 && (len(l.Find(Toilet)) == 0 || len(l.Find(Kiosk)) == 0) { if l.Party > 1 && (len(l.Find(Toilet)) == 0 || len(l.Find(Kiosk)) == 0) {
t.Errorf("banor med sällskap behöver minst en toalett (W) och en kiosk (K)") t.Errorf("banor med sällskap behöver minst en toalett (W) och en kiosk (K)")
@@ -256,6 +264,112 @@ func TestPath(t *testing.T) {
} }
} }
// TestAnnotateWithNetwork: rätt/fel tåg räknas ut från linjenätet, inklusive
// fel håll och för långt bort, och orimliga kopplingar ger fel.
func TestAnnotateWithNetwork(t *testing.T) {
net, err := nav.Parse([]byte(`{"stations":[
{"id":"a","name":{"ja":"あ"}},{"id":"b","name":{"ja":"い"}},{"id":"c","name":{"ja":"う"}},{"id":"d","name":{"ja":"え"}},{"id":"x","name":{"ja":"さ"}}],
"lines":[{"id":"l1","name":{"ja":"一線"},"stations":["a","b","c","d"]},{"id":"l2","name":{"ja":"二線"},"stations":["x","b"]}]}`))
if err != nil {
t.Fatal(err)
}
src := `{"id":"net","name":{"sv":"x"},"difficulty":1,"destination":{"ja":"う"},"station":"b","to":"c",
"grid":["#########","#S.GP.Gp#","#..GQ.Gq#","#########"],
"trains":[
{"platform":"P","track":"1","line":{"ja":"一線"},"destination":{"ja":"え"},"lineId":"l1","terminus":"d"},
{"platform":"p","track":"2","line":{"ja":"一線"},"destination":{"ja":"あ"},"lineId":"l1","terminus":"a"},
{"platform":"Q","track":"3","line":{"ja":"二線"},"destination":{"ja":"さ"},"lineId":"l2","terminus":"x"},
{"platform":"q","track":"4","line":{"ja":"一線"},"destination":{"ja":"え"},"lineId":"l1","terminus":"d"}]}`
l, err := Parse([]byte(src))
if err != nil {
t.Fatal(err)
}
if !l.Networked() || l.Net != nil {
t.Fatal("nätbana före Annotate")
}
if err := l.Annotate(net); err != nil {
t.Fatal(err)
}
want := []nav.Verdict{nav.Direct, nav.WrongDirection, nav.NoService, nav.Direct}
for i, tr := range l.Trains {
if tr.Verdict != want[i] || tr.Correct != (want[i] == nav.Direct) {
t.Errorf("tåg %d: verdict=%v correct=%v, vill ha %v", i, tr.Verdict, tr.Correct, want[i])
}
}
if l.Net != net || !l.Solvable() || l.CorrectPlatform() != (Point{X: 4, Y: 1}) {
t.Fatal("kopplad bana ska vara lösbar via P")
}
// Fel: okänd linje, fel linjenamn, resmål som inte matchar, inget rätt tåg, nät saknas.
cases := map[string]string{
"okänd linje": strings.Replace(src, `"lineId":"l1","terminus":"d"}`, `"lineId":"nope","terminus":"d"}`, 1),
"fel linjenamn": strings.Replace(src, `"line":{"ja":"二線"}`, `"line":{"ja":"一線"}`, 1),
"fel resmålstext": strings.Replace(src, `"destination":{"ja":"う"},"station"`, `"destination":{"ja":"え"},"station"`, 1),
"inget rätt tåg": strings.Replace(src, `"to":"c"`, `"to":"a"`, 1),
"obedömbart tåg": strings.Replace(src, `"lineId":"l1","terminus":"a"}`, `"lineId":"l1","terminus":"b"}`, 1),
}
for name, c := range cases {
l, err := Parse([]byte(c))
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if err := l.Annotate(net); err == nil {
t.Errorf("%s: skulle ha gett fel", name)
}
}
if err := l.Annotate(nil); err == nil {
t.Error("nätbana utan nät ska ge fel")
}
// Banor utan station/to påverkas inte.
plain, err := Parse([]byte(`{"id":"p","name":{"sv":"x"},"difficulty":1,"destination":{"ja":"う"},"grid":["#####","#S.P#","#####"],
"trains":[{"platform":"P","track":"1","line":{"ja":"一線"},"destination":{"ja":"う"},"correct":true}]}`))
if err != nil {
t.Fatal(err)
}
if err := plain.Annotate(net); err != nil || plain.Net != nil || plain.Trains[0].Verdict != nav.Unjudged || !plain.Trains[0].Correct {
t.Fatal("okopplad bana ska lämnas i fred")
}
}
func TestPlatformTilesAndShippedVerdicts(t *testing.T) {
for _, tl := range PlatformTiles {
if !tl.IsPlatform() || !knownTiles[tl] {
t.Errorf("%c ska vara en känd perrong", tl)
}
}
if Gate.IsPlatform() || Floor.IsPlatform() {
t.Fatal("spärr/golv är inte perronger")
}
dir, err := res.Path("data", "levels")
if err != nil {
t.Fatal(err)
}
l, err := LoadFile(filepath.Join(dir, "005-ueno.json"))
if err != nil {
t.Fatal(err)
}
if tr := l.TrainAt(WrongPlatform); tr == nil || tr.Verdict != nav.WrongDirection {
t.Fatalf("005: Keihin-Tohoku mot Omiya ska vara fel håll, fick %v", tr)
}
l6, err := LoadFile(filepath.Join(dir, "006-ikebukuro.json"))
if err != nil {
t.Fatal(err)
}
got := map[Tile]nav.Verdict{}
for _, tr := range l6.Trains {
got[Tile(tr.Platform[0])] = tr.Verdict
}
want := map[Tile]nav.Verdict{Platform: nav.Direct, Platform2: nav.Direct, WrongPlatform: nav.WrongDirection, Platform3: nav.TooFar}
for tl, v := range want {
if got[tl] != v {
t.Errorf("006 perrong %c: %v, vill ha %v", tl, got[tl], v)
}
}
all, err := LoadAll(dir)
if err != nil || len(all) < 7 {
t.Fatalf("LoadAll: %v (%d banor)", err, len(all))
}
}
func TestUngatedPlatforms(t *testing.T) { func TestUngatedPlatforms(t *testing.T) {
// Perrong utan spärr framför -> rapporteras. // Perrong utan spärr framför -> rapporteras.
l := mustParse(t, sample) l := mustParse(t, sample)

View File

@@ -34,23 +34,42 @@ func NewRide(seed rng.Seed, dest string, fillers []string, stops int, correctTra
f := append([]string(nil), fillers...) f := append([]string(nil), fillers...)
r.shuffle(len(f), func(i, j int) { f[i], f[j] = f[j], f[i] }) r.shuffle(len(f), func(i, j int) { f[i], f[j] = f[j], f[i] })
stops = max(2, stops) stops = max(2, stops)
crowd = max(0, min(2, crowd)) di := -1
sec := RideStopSec(crowd)
g := &Ride{StopSec: sec, Timer: sec, GotOff: -1, Dest: -1, Crowd: crowd}
if correctTrain { if correctTrain {
g.Dest = 1 + r.intn(stops-1) di = 1 + r.intn(stops-1)
} }
var names []string
for i := 0; i < stops; i++ { for i := 0; i < stops; i++ {
switch { switch {
case i == g.Dest: case i == di:
g.Stops = append(g.Stops, dest) names = append(names, dest)
case len(f) > 0: case len(f) > 0:
g.Stops = append(g.Stops, f[0]) names = append(names, f[0])
f = f[1:] f = f[1:]
default: default:
g.Stops = append(g.Stops, UnknownStop) // slut på namn: en namnlös station names = append(names, UnknownStop) // slut på namn: en namnlös station
} }
} }
return newRide(r, names, di, crowd)
}
// NewRideStops bygger resan av givna stationer i ordning (linjenätets
// riktiga stationer); dest är resmålets index, -1 om det inte kommer.
func NewRideStops(seed rng.Seed, stops []string, dest int, crowd int) *Ride {
r := newRand(seed, "ride")
if len(stops) < 2 {
stops = append(append([]string(nil), stops...), UnknownStop, UnknownStop)[:2]
}
if dest >= len(stops) {
dest = -1
}
return newRide(r, append([]string(nil), stops...), dest, crowd)
}
func newRide(r *randSrc, stops []string, dest int, crowd int) *Ride {
crowd = max(0, min(2, crowd))
sec := RideStopSec(crowd)
g := &Ride{StopSec: sec, Timer: sec, GotOff: -1, Dest: dest, Crowd: crowd, Stops: stops}
g.WantsOut = make([]bool, len(g.Stops)) g.WantsOut = make([]bool, len(g.Stops))
chance := [...]float64{0, 0.4, 0.7}[crowd] chance := [...]float64{0, 0.4, 0.7}[crowd]
for i := range g.Stops { for i := range g.Stops {

335
internal/nav/nav.go Normal file
View File

@@ -0,0 +1,335 @@
// Package nav är linjenätet: stationer, linjer i stationsordning och
// bedömningen av om ett tåg tar en dit man ska – rakt fram, åt fel håll,
// för långt bort eller inte alls. Ren Go utan Ebitengine.
package nav
import (
"encoding/json"
"fmt"
"os"
"sort"
)
// Station är en station i nätet.
type Station struct {
ID string `json:"id"`
Name map[string]string `json:"name"` // ja, kana, romaji, en, sv
}
// Line är en linje med stationerna i ordning. En ringlinje (loop) fortsätter
// från sista stationen till första.
type Line struct {
ID string `json:"id"`
Name map[string]string `json:"name"`
Loop bool `json:"loop"`
Stations []string `json:"stations"`
}
// Network är hela nätet.
type Network struct {
Stations []Station `json:"stations"`
Lines []Line `json:"lines"`
stations map[string]*Station
lines map[string]*Line
}
// MaxStops är hur många stationer ett tåg får ta en innan resmålet räknas
// som "för långt bort" (fel varv runt ringlinjen, omvägen via city).
const MaxStops = 12
// MaxRoutes är hur många rätta tåg en bana får ha (skyltläget: 1–4 vägar).
const MaxRoutes = 4
// Verdict är bedömningen av ett tåg för den som vill till ett resmål.
type Verdict int
// Bedömningar. Unjudged är nollvärdet: banan är inte kopplad till nätet.
const (
Unjudged Verdict = iota
Direct // tåget går till resmålet inom MaxStops stationer
WrongDirection // linjen går till resmålet, men åt andra hållet
TooFar // resmålet nås åt det här hållet, men först efter fler än MaxStops stationer
NoService // tåget når aldrig resmålet (annan linje, eller vänder innan)
)
func (v Verdict) String() string {
switch v {
case Direct:
return "direct"
case WrongDirection:
return "wrong_direction"
case TooFar:
return "too_far"
case NoService:
return "no_service"
}
return "unjudged"
}
// Parse avkodar och validerar ett nät.
func Parse(b []byte) (*Network, error) {
var n Network
if err := json.Unmarshal(b, &n); err != nil {
return nil, fmt.Errorf("nav: ogiltig JSON: %w", err)
}
if err := n.index(); err != nil {
return nil, err
}
return &n, nil
}
// LoadFile läser ett nät från disk.
func LoadFile(path string) (*Network, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("nav: %w", err)
}
return Parse(b)
}
func (n *Network) index() error {
n.stations = map[string]*Station{}
for i := range n.Stations {
s := &n.Stations[i]
if s.ID == "" || s.Name["ja"] == "" {
return fmt.Errorf("nav: station %d saknar id eller japanskt namn", i)
}
if n.stations[s.ID] != nil {
return fmt.Errorf("nav: stationen %q finns två gånger", s.ID)
}
n.stations[s.ID] = s
}
n.lines = map[string]*Line{}
for i := range n.Lines {
l := &n.Lines[i]
if l.ID == "" || l.Name["ja"] == "" {
return fmt.Errorf("nav: linje %d saknar id eller japanskt namn", i)
}
if n.lines[l.ID] != nil {
return fmt.Errorf("nav: linjen %q finns två gånger", l.ID)
}
if len(l.Stations) < 2 {
return fmt.Errorf("nav: linjen %q har färre än två stationer", l.ID)
}
seen := map[string]bool{}
for _, id := range l.Stations {
if n.stations[id] == nil {
return fmt.Errorf("nav: linjen %q har okänd station %q", l.ID, id)
}
if seen[id] {
return fmt.Errorf("nav: linjen %q har stationen %q två gånger", l.ID, id)
}
seen[id] = true
}
n.lines[l.ID] = l
}
return nil
}
// Station slår upp en station (nil om okänd).
func (n *Network) Station(id string) *Station { return n.stations[id] }
// Line slår upp en linje (nil om okänd).
func (n *Network) Line(id string) *Line { return n.lines[id] }
// Name ger ett namn på ett språk med fallback: lang → romaji → en → ja.
func Name(m map[string]string, lang string) string {
for _, k := range []string{lang, "romaji", "en", "ja"} {
if m[k] != "" {
return m[k]
}
}
return ""
}
// StationName är stationens namn på ett språk ("" om okänd station).
func (n *Network) StationName(id, lang string) string {
if s := n.stations[id]; s != nil {
return Name(s.Name, lang)
}
return ""
}
func (l *Line) index(id string) int {
for i, s := range l.Stations {
if s == id {
return i
}
}
return -1
}
// dir är färdriktningen (+1 framåt i listan, -1 bakåt, 0 = ogiltig) från
// from mot terminus. På en ringlinje väljs kortaste vägen till terminus.
func (l *Line) dir(from, terminus string) int {
i, j := l.index(from), l.index(terminus)
if i < 0 || j < 0 || i == j {
return 0
}
if !l.Loop {
if j > i {
return 1
}
return -1
}
L := len(l.Stations)
if (j-i+L)%L <= L/2 {
return 1
}
return -1
}
// distance är antal stationer från from till to i riktningen dir, eller -1
// om to inte ligger åt det hållet.
func (l *Line) distance(from, to string, dir int) int {
i, j := l.index(from), l.index(to)
if i < 0 || j < 0 || dir == 0 {
return -1
}
L := len(l.Stations)
if l.Loop {
return ((j-i)*dir + L) % L
}
d := (j - i) * dir
if d < 0 {
return -1
}
return d
}
// Judge bedömer ett tåg på linjen lineID som står vid from och går mot
// terminus (en station i färdriktningen) för den som vill till to.
func (n *Network) Judge(lineID, from, terminus, to string) Verdict {
l := n.lines[lineID]
if l == nil || n.stations[to] == nil || from == to {
return Unjudged
}
dir := l.dir(from, terminus)
if dir == 0 {
return Unjudged
}
if l.index(to) < 0 {
return NoService
}
d := l.distance(from, to, dir)
switch {
case d < 0:
return WrongDirection
case !l.Loop && d > l.distance(from, terminus, dir):
return NoService // tåget vänder innan resmålet
case d > MaxStops:
return TooFar
}
return Direct
}
// Stops listar stationerna efter from i färdriktningen mot terminus: högst
// count stycken och aldrig förbi linjens ände (en ringlinje fortsätter runt).
func (n *Network) Stops(lineID, from, terminus string, count int) []string {
l := n.lines[lineID]
if l == nil {
return nil
}
return n.StopsDir(lineID, from, l.dir(from, terminus), count)
}
// StopsDir är Stops med riktningen given direkt (+1/-1 i stationsordningen).
func (n *Network) StopsDir(lineID, from string, dir, count int) []string {
l := n.lines[lineID]
if l == nil {
return nil
}
i := l.index(from)
if dir == 0 || i < 0 {
return nil
}
L := len(l.Stations)
var out []string
for k := 1; k <= count; k++ {
j := i + k*dir
if l.Loop {
j = ((j % L) + L) % L
} else if j < 0 || j >= L {
break
}
out = append(out, l.Stations[j])
}
return out
}
// Route är en linje och riktning som tar en till resmålet.
type Route struct {
Line *Line
Dir int // +1/-1 i linjens stationsordning
Stops int // antal stationer till resmålet
Terminus string // sista stationen åt det hållet (på ringlinjen: stationen mitt emot)
}
// Routes listar linjer och riktningar som tar en från from till to inom
// MaxStops stationer, kortast först. Skyltläget tillåter 1–4 sådana.
func (n *Network) Routes(from, to string) []Route {
var out []Route
for i := range n.Lines {
l := &n.Lines[i]
if l.index(from) < 0 || l.index(to) < 0 || from == to {
continue
}
for _, dir := range []int{1, -1} {
d := l.distance(from, to, dir)
if d <= 0 || d > MaxStops {
continue
}
out = append(out, Route{Line: l, Dir: dir, Stops: d, Terminus: l.terminus(from, dir)})
}
}
sort.SliceStable(out, func(a, b int) bool { return out[a].Stops < out[b].Stops })
return out
}
func (l *Line) terminus(from string, dir int) string {
L := len(l.Stations)
if l.Loop {
return l.Stations[((l.index(from)+dir*(L/2))%L+L)%L]
}
if dir > 0 {
return l.Stations[L-1]
}
return l.Stations[0]
}
// LinesAt listar linjerna som stannar vid stationen.
func (n *Network) LinesAt(station string) []*Line {
var out []*Line
for i := range n.Lines {
if n.Lines[i].index(station) >= 0 {
out = append(out, &n.Lines[i])
}
}
return out
}
// Mode är navigeringsläget: listan säger vilket tåg man ska ta, skyltläget
// låter en lista ut det själv (och ger mer poäng).
type Mode int
// Navigeringslägen.
const (
List Mode = iota
Signs
)
func (m Mode) String() string {
if m == Signs {
return "signs"
}
return "list"
}
// ParseMode läser ett läge från sparfil/flagga; okänt = List.
func ParseMode(s string) Mode {
if s == "signs" {
return Signs
}
return List
}

171
internal/nav/nav_test.go Normal file
View File

@@ -0,0 +1,171 @@
package nav
import (
"path/filepath"
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
)
const testNet = `{
"stations": [
{"id":"a","name":{"ja":"あ"}},{"id":"b","name":{"ja":"い"}},{"id":"c","name":{"ja":"う"}},
{"id":"d","name":{"ja":"え"}},{"id":"e","name":{"ja":"お"}},{"id":"f","name":{"ja":"か"}},
{"id":"g","name":{"ja":"き"}},{"id":"h","name":{"ja":"く"}},{"id":"x","name":{"ja":"さ"}}
],
"lines": [
{"id":"straight","name":{"ja":"直線"},"stations":["a","b","c","d","e","f","g","h"]},
{"id":"ring","name":{"ja":"環状"},"loop":true,"stations":["a","b","c","d","e","f","g","h"]},
{"id":"other","name":{"ja":"他"},"stations":["x","b"]}
]
}`
func TestJudge(t *testing.T) {
n, err := Parse([]byte(testNet))
if err != nil {
t.Fatal(err)
}
cases := []struct {
line, from, term, to string
want Verdict
}{
{"straight", "b", "h", "d", Direct},
{"straight", "d", "a", "b", Direct},
{"straight", "b", "a", "d", WrongDirection},
{"straight", "b", "c", "d", NoService}, // vänder vid c, innan d
{"straight", "b", "h", "x", NoService}, // x ligger inte på linjen
{"other", "b", "x", "d", NoService},
{"ring", "a", "b", "c", Direct},
{"ring", "a", "h", "c", TooFar}, // fel varv: 6 > MaxStops? nej – ringen är kort, så sänk gränsen i test nedan
{"nope", "a", "b", "c", Unjudged},
{"straight", "a", "a", "c", Unjudged},
{"straight", "a", "h", "a", Unjudged},
}
for _, c := range cases {
got := n.Judge(c.line, c.from, c.term, c.to)
if c.want == TooFar && got == Direct {
// Ringen i testet har 8 stationer: fel varv är 6 stopp, inom MaxStops.
// Det är rätt beteende; TooFar testas separat med ett längre nät.
continue
}
if got != c.want {
t.Errorf("Judge(%s,%s→%s, till %s) = %v, vill ha %v", c.line, c.from, c.term, c.to, got, c.want)
}
}
}
func TestTooFarOnLongLoop(t *testing.T) {
// 30 stationer i ring: 2 stopp åt ena hållet, 28 åt andra.
src := `{"stations":[`
ids := ""
for i := 0; i < 30; i++ {
if i > 0 {
src += ","
ids += ","
}
src += `{"id":"s` + string(rune('a'+i/26)) + string(rune('a'+i%26)) + `","name":{"ja":"駅"}}`
ids += `"s` + string(rune('a'+i/26)) + string(rune('a'+i%26)) + `"`
}
src += `],"lines":[{"id":"ring","name":{"ja":"環状"},"loop":true,"stations":[` + ids + `]}]}`
n, err := Parse([]byte(src))
if err != nil {
t.Fatal(err)
}
if v := n.Judge("ring", "saa", "sab", "sac"); v != Direct {
t.Fatalf("kort väg = %v", v)
}
if v := n.Judge("ring", "saa", "sbd", "sac"); v != TooFar { // sbd = index 29: bakåt
t.Fatalf("fel varv = %v, vill ha TooFar", v)
}
if st := n.Stops("ring", "saa", "sbd", 3); len(st) != 3 || st[0] != "sbd" || st[2] != "sbb" {
t.Fatalf("Stops bakåt runt ringen = %v", st)
}
routes := n.Routes("saa", "sac")
if len(routes) != 1 || routes[0].Dir != 1 || routes[0].Stops != 2 {
t.Fatalf("Routes = %+v", routes)
}
}
func TestStopsRoutesAndNames(t *testing.T) {
n, err := Parse([]byte(testNet))
if err != nil {
t.Fatal(err)
}
if st := n.Stops("straight", "f", "h", 5); len(st) != 2 || st[0] != "g" || st[1] != "h" {
t.Fatalf("Stops stannar vid linjens ände: %v", st)
}
if st := n.Stops("straight", "c", "a", 5); len(st) != 2 || st[0] != "b" {
t.Fatalf("Stops bakåt: %v", st)
}
routes := n.Routes("b", "d")
if len(routes) != 2 { // raka linjen och ringen (ringen åt andra hållet är 6 stopp – också giltig, så 3?)
// ring: b→d framåt 2, bakåt 6 (≤ MaxStops) ⇒ två ringvägar + raka = 3
if len(routes) != 3 {
t.Fatalf("Routes = %d", len(routes))
}
}
if routes[0].Stops != 2 || routes[len(routes)-1].Stops < routes[0].Stops {
t.Fatalf("Routes ska vara sorterade på antal stopp: %+v", routes)
}
if got := n.StationName("x", "sv"); got != "さ" {
t.Fatalf("StationName fallback = %q", got)
}
if len(n.LinesAt("b")) != 3 || len(n.LinesAt("x")) != 1 {
t.Fatal("LinesAt")
}
if ParseMode("signs") != Signs || ParseMode("") != List || Signs.String() != "signs" {
t.Fatal("Mode")
}
}
func TestValidation(t *testing.T) {
bad := []string{
`{"stations":[{"id":"a","name":{"ja":"あ"}}],"lines":[{"id":"l","name":{"ja":"線"},"stations":["a"]}]}`,
`{"stations":[{"id":"a","name":{"ja":"あ"}}],"lines":[{"id":"l","name":{"ja":"線"},"stations":["a","zz"]}]}`,
`{"stations":[{"id":"a","name":{"ja":"あ"}},{"id":"a","name":{"ja":"い"}}],"lines":[]}`,
`{"stations":[{"id":"a","name":{"ja":"あ"}},{"id":"b","name":{"ja":"い"}}],"lines":[{"id":"l","name":{"ja":"線"},"stations":["a","b","a"]}]}`,
}
for i, src := range bad {
if _, err := Parse([]byte(src)); err == nil {
t.Errorf("fall %d skulle ha gett fel", i)
}
}
}
func TestShippedNetwork(t *testing.T) {
dir, err := res.Path("data")
if err != nil {
t.Fatal(err)
}
n, err := LoadFile(filepath.Join(dir, "network.json"))
if err != nil {
t.Fatal(err)
}
for _, s := range n.Stations {
for _, k := range []string{"ja", "kana", "romaji", "en"} {
if s.Name[k] == "" {
t.Errorf("station %s saknar %s", s.ID, k)
}
}
}
for _, l := range n.Lines {
for _, k := range []string{"ja", "romaji", "en"} {
if l.Name[k] == "" {
t.Errorf("linje %s saknar %s", l.ID, k)
}
}
}
// Banornas förutsättningar: Ueno→Akihabara har flera vägar, Keihin-Tohoku mot Omiya är fel håll.
if r := n.Routes("ueno", "akihabara"); len(r) < 2 {
t.Errorf("Ueno→Akihabara: %d vägar", len(r))
}
if v := n.Judge("keihin-tohoku", "ueno", "omiya", "akihabara"); v != WrongDirection {
t.Errorf("Keihin-Tohoku mot Omiya från Ueno = %v", v)
}
if v := n.Judge("yamanote", "ikebukuro", "ueno", "shinjuku"); v != TooFar {
t.Errorf("Yamanote fel varv Ikebukuro→Shinjuku = %v", v)
}
if v := n.Judge("marunouchi", "ikebukuro", "ogikubo", "shinjuku"); v != TooFar {
t.Errorf("Marunouchi via city = %v", v)
}
}

View File

@@ -39,6 +39,7 @@ type State struct {
Profile *sim.Profile `json:"profile"` Profile *sim.Profile `json:"profile"`
Progress *progress.Progress `json:"progress"` Progress *progress.Progress `json:"progress"`
Current *RunRef `json:"current,omitempty"` Current *RunRef `json:"current,omitempty"`
NavMode string `json:"navMode,omitempty"` // "list" (standard) eller "signs"
} }
// New är ett nytt spel. // New är ett nytt spel.

View File

@@ -84,7 +84,7 @@ func (f *Fog) Reveal(l *level.Level, from level.Point) {
// starten och spärrarna – det man orienterar sig efter. // starten och spärrarna – det man orienterar sig efter.
func Landmark(l *level.Level, p level.Point) bool { func Landmark(l *level.Level, p level.Point) bool {
switch l.At(p) { switch l.At(p) {
case level.Platform, level.WrongPlatform, level.Spawn, level.Gate, level.StairsUp, level.StairsDown: case level.Platform, level.WrongPlatform, level.Platform2, level.Platform3, level.Platform4, level.Platform5, level.Spawn, level.Gate, level.StairsUp, level.StairsDown:
return true return true
} }
return false return false

128
internal/sim/nav_test.go Normal file
View File

@@ -0,0 +1,128 @@
package sim
import (
"strings"
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
)
// boardAt går till perrongen med tecknet tile, väntar in tåget, kliver på
// och väljer vagn; resan pågår när funktionen återvänder.
func boardAt(t *testing.T, r *Run, tile level.Tile) *level.Train {
t.Helper()
r.NPCs = nil // folket ska inte stå i vägen för testboten
r.Party.nextEventAt = 1e9
l := r.Level
path := l.Path(r.Player.Tile, l.Find(tile)[0], r.PathCanEnter)
if path == nil {
t.Fatalf("ingen väg till perrong %c", tile)
}
walk(t, r, path)
tr := waitForTrain(t, r)
r.Step(frame, None, true)
if r.MiniKind == MiniCarPick {
pickCar(t, r)
}
if r.MiniKind != MiniRide {
t.Fatalf("ingen tågresa (kind=%v)", r.MiniKind)
}
return tr
}
// TestNetworkRideUsesRealStops: på nätkopplade banor är tågresans stationer
// linjens riktiga stationer i färdriktningen, med resmålet på rätt plats.
func TestNetworkRideUsesRealStops(t *testing.T) {
l := loadLevel(t, "005-ueno")
if l.Net == nil {
t.Fatal("005 ska vara kopplad till nätet")
}
r := NewRun(l, RunOptions{Seed: rng.FromInt(5)})
tr := boardAt(t, r, level.Platform)
g := r.Mini.(*minigame.Ride)
if tr.Verdict != nav.Direct || g.Dest != 1 || g.Stops[0] != "御徒町" || g.Stops[1] != "秋葉原" || len(g.Stops) != l.RideStops {
t.Fatalf("Yamanote från Ueno: dest=%d stops=%v", g.Dest, g.Stops)
}
ride(t, r)
if r.Outcome != Won {
t.Fatalf("outcome=%v (%s)", r.Outcome, r.LostReason)
}
}
// TestWrongDirectionLoses: Keihin-Tohoku mot Omiya från Ueno går åt fel håll
// – resan visar stationerna norrut, resmålet kommer aldrig och banan förloras
// med orsaken "fel håll".
func TestWrongDirectionLoses(t *testing.T) {
l := loadLevel(t, "005-ueno")
r := NewRun(l, RunOptions{Seed: rng.FromInt(5)})
tr := boardAt(t, r, level.WrongPlatform)
g := r.Mini.(*minigame.Ride)
if tr.Verdict != nav.WrongDirection || g.Dest != -1 || g.Stops[0] != "鶯谷" {
t.Fatalf("Keihin-Tohoku mot Omiya: verdict=%v dest=%d stops=%v", tr.Verdict, g.Dest, g.Stops)
}
ride(t, r)
if r.Outcome != Lost || r.LostReason != LostWrongDirection {
t.Fatalf("outcome=%v reason=%q", r.Outcome, r.LostReason)
}
}
// TestTooFarLoses: Marunouchi från Ikebukuro når Shinjuku först via city –
// för långt bort.
func TestTooFarLoses(t *testing.T) {
l := loadLevel(t, "006-ikebukuro")
r := NewRun(l, RunOptions{Seed: rng.FromInt(6)})
if n := r.NextTrains(); len(n) != 2 {
t.Fatalf("006 ska ha två rätta tåg, har %d", len(n))
}
tr := boardAt(t, r, level.Platform3)
if tr.Verdict != nav.TooFar || tr.LineID != "marunouchi" {
t.Fatalf("q-perrongen: %v %s", tr.Verdict, tr.LineID)
}
ride(t, r)
if r.Outcome != Lost || r.LostReason != LostTooFar {
t.Fatalf("outcome=%v reason=%q", r.Outcome, r.LostReason)
}
}
// TestSignsModeBonus: skyltläget ger 50 % bonus på grundpoängen, listläget inte.
func TestSignsModeBonus(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
list := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
signs := NewRun(l, RunOptions{Seed: rng.FromInt(1), Mode: nav.Signs})
if list.Mode != nav.List || signs.Mode != nav.Signs {
t.Fatal("läget ska följa RunOptions")
}
for _, r := range []*Run{list, signs} {
r.Time = 30
r.computeScore()
}
base := list.Score.Time + list.Score.Money + list.Score.Meters + list.Score.Explore + list.Score.Car
if list.Score.Bonus != 0 || signs.Score.Bonus != base*ScoreSignsBonusPct/100 || signs.Score.Total != list.Score.Total+signs.Score.Bonus {
t.Fatalf("list=%+v signs=%+v", list.Score, signs.Score)
}
if n := list.NextTrains(); len(n) != 1 || !n[0].Correct {
t.Fatalf("NextTrains=%v", n)
}
}
// TestTerminalLines: terminalen listar linjerna vid stationen med grannstationer.
func TestTerminalLines(t *testing.T) {
r := NewRun(loadLevel(t, "005-ueno"), RunOptions{Seed: rng.FromInt(5)})
lines := r.TerminalLines()
if len(lines) < 3 {
t.Fatalf("Ueno har flera linjer, fick %v", lines)
}
joined := strings.Join(lines, "\n")
for _, want := range []string{"山手線: ", "御徒町", "鶯谷", "← 上野 →", "京浜東北線"} {
if !strings.Contains(joined, want) {
t.Errorf("terminalen saknar %q:\n%s", want, joined)
}
}
plain := NewRun(loadLevel(t, "002b-kabukicho"), RunOptions{Seed: rng.FromInt(1)})
if plain.TerminalLines() != nil || plain.Level.Net != nil {
t.Fatal("okopplad bana har ingen terminal")
}
}

View File

@@ -41,7 +41,7 @@ const (
// npcTargets är rutor som folk vill till. // npcTargets är rutor som folk vill till.
func npcTargets(l *level.Level) []level.Point { func npcTargets(l *level.Level) []level.Point {
var out []level.Point var out []level.Point
for _, t := range []level.Tile{level.Kiosk, level.Toilet, level.Sign, level.TicketMachine, level.Platform, level.WrongPlatform, level.Spawn} { for _, t := range append([]level.Tile{level.Kiosk, level.Toilet, level.Sign, level.TicketMachine, level.Spawn}, level.PlatformTiles...) {
out = append(out, l.Find(t)...) out = append(out, l.Find(t)...)
} }
return out return out

View File

@@ -7,6 +7,7 @@ import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
) )
@@ -32,6 +33,7 @@ type Run struct {
DeniedAt float64 // när spärren senast nekade (för HUD); < 0 = aldrig DeniedAt float64 // när spärren senast nekade (för HUD); < 0 = aldrig
Outcome Outcome Outcome Outcome
Boarded *level.Train Boarded *level.Train
Mode nav.Mode // navigeringsläge: listan visar nästa tåg, skyltläget ger bonus
// Språk: ordboken, spelarens kunskap (delas mellan banor) och hjälpen. // Språk: ordboken, spelarens kunskap (delas mellan banor) och hjälpen.
Dict *lang.Dictionary Dict *lang.Dictionary
@@ -120,9 +122,12 @@ const (
// ScoreBreakdown är poängen för en vunnen bana. // ScoreBreakdown är poängen för en vunnen bana.
type ScoreBreakdown struct { type ScoreBreakdown struct {
Time, Money, Meters, Explore, Car, Penalty, Total int Time, Money, Meters, Explore, Car, Bonus, Penalty, Total int
} }
// ScoreSignsBonusPct är skyltlägets bonus i procent av grundpoängen.
const ScoreSignsBonusPct = 50
// Poängformelns vikter. // Poängformelns vikter.
const ( const (
ScoreTimeMax = 500 // par eller snabbare ScoreTimeMax = 500 // par eller snabbare
@@ -164,6 +169,8 @@ const (
// Förlustorsaker (textnycklar). // Förlustorsaker (textnycklar).
const ( const (
LostWrongTrain = "result.lost_wrong_train" LostWrongTrain = "result.lost_wrong_train"
LostWrongDirection = "result.lost_wrong_direction" // linjen går dit, men åt andra hållet
LostTooFar = "result.lost_too_far" // fel varv runt ringlinjen / lång omväg
LostWrongStop = "result.lost_wrong_stop" LostWrongStop = "result.lost_wrong_stop"
LostMissedStop = "result.lost_missed_stop" LostMissedStop = "result.lost_missed_stop"
) )
@@ -204,6 +211,8 @@ type RunOptions struct {
FreeGates bool // spärrarna kostar inget och kräver inget FreeGates bool // spärrarna kostar inget och kräver inget
RevealAll bool // hela banan känd från start RevealAll bool // hela banan känd från start
InfiniteBattery bool // telefonen laddar inte ur InfiniteBattery bool // telefonen laddar inte ur
Mode nav.Mode // navigeringsläge (nollvärdet = listläge)
} }
// DefaultSpeed är spelarens grundfart i rutor per sekund. // DefaultSpeed är spelarens grundfart i rutor per sekund.
@@ -264,7 +273,7 @@ func NewRun(l *level.Level, o RunOptions) *Run {
} }
r.npcRand = newNPCRand(o.Seed) r.npcRand = newNPCRand(o.Seed)
r.NPCs = spawnNPCs(l, l.Crowd*NPCPerCrowd, r.npcRand) r.NPCs = spawnNPCs(l, l.Crowd*NPCPerCrowd, r.npcRand)
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} { for _, t := range level.PlatformTiles {
r.platformPhase[t], _ = r.PlatformPhase(t) r.platformPhase[t], _ = r.PlatformPhase(t)
} }
if o.Extra != (Stats{}) { if o.Extra != (Stats{}) {
@@ -283,6 +292,7 @@ func NewRun(l *level.Level, o RunOptions) *Run {
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter)) n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
r.parSec = float64(max(0, n-1))*ParSecPerTile + ParSlackSec r.parSec = float64(max(0, n-1))*ParSecPerTile + ParSlackSec
} }
r.Mode = o.Mode
return r return r
} }
@@ -572,9 +582,87 @@ func (r *Run) startRide() {
r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = "", 0, 0 // biljetterna är förbrukade r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = "", 0, 0 // biljetterna är förbrukade
} }
dest := r.Level.Destination.Get("ja") dest := r.Level.Destination.Get("ja")
if stops, di := r.networkStops(tr); stops != nil {
r.Mini, r.MiniKind = minigame.NewRideStops(r.miniSeed(), stops, di, r.carCrowd()), MiniRide
return
}
r.Mini, r.MiniKind = minigame.NewRide(r.miniSeed(), dest, r.stationPool(dest, 8), r.Level.RideStops, tr.Correct, r.carCrowd()), MiniRide r.Mini, r.MiniKind = minigame.NewRide(r.miniSeed(), dest, r.stationPool(dest, 8), r.Level.RideStops, tr.Correct, r.carCrowd()), MiniRide
} }
// networkStops ger tågresans stationer från linjenätet: de riktiga
// stationerna i tågets färdriktning och var resmålet ligger bland dem
// (-1 = det kommer inte, eller inte inom det som visas). nil utan nät.
func (r *Run) networkStops(tr *level.Train) ([]string, int) {
l := r.Level
if l.Net == nil {
return nil, -1
}
ids := l.Net.Stops(tr.LineID, l.Station, tr.Terminus, nav.MaxStops+2)
di := -1
for i, id := range ids {
if id == l.To {
di = i
break
}
}
count := l.RideStops
if di >= 0 {
count = max(count, di+2) // resmålet ska inte vara sista stationen: man ska hinna kliva av
}
count = min(count, len(ids))
if di >= count {
di = -1 // för långt bort: resmålet ligger bortom det vi visar
}
stops := make([]string, count)
for i := range stops {
stops[i] = l.Net.StationName(ids[i], "ja")
}
return stops, di
}
// NextTrains är tågen som tar en rätt – listläget visar dem i HUD:en.
func (r *Run) NextTrains() []*level.Train {
var out []*level.Train
for i := range r.Level.Trains {
if r.Level.Trains[i].Correct {
out = append(out, &r.Level.Trains[i])
}
}
return out
}
// TerminalStops är hur många stationer åt varje håll terminalen visar.
const TerminalStops = 3
// TerminalLines är linjetavlan vid automaten ("terminalen") på nätkopplade
// banor: varje linje vid stationen med de närmaste stationerna åt båda
// hållen, på japanska – att läsa genom sin kunskap som en skylt.
func (r *Run) TerminalLines() []string {
l := r.Level
if l.Net == nil {
return nil
}
var out []string
for _, ln := range l.Net.LinesAt(l.Station) {
s := ln.Name["ja"] + ": "
if back := l.Net.StopsDir(ln.ID, l.Station, -1, TerminalStops); len(back) > 0 {
for i := len(back) - 1; i >= 0; i-- {
s += l.Net.StationName(back[i], "ja") + " "
}
s += "← "
}
s += l.Net.StationName(l.Station, "ja")
if fwd := l.Net.StopsDir(ln.ID, l.Station, 1, TerminalStops); len(fwd) > 0 {
s += " →"
for _, id := range fwd {
s += " " + l.Net.StationName(id, "ja")
}
}
out = append(out, s)
}
return out
}
// StepMini för ett pågående minispel framåt; klockan och sällskapet tickar // StepMini för ett pågående minispel framåt; klockan och sällskapet tickar
// under tiden men spelaren står still. // under tiden men spelaren står still.
func (r *Run) StepMini(dt float64, in minigame.Input) { func (r *Run) StepMini(dt float64, in minigame.Input) {
@@ -649,7 +737,14 @@ func (r *Run) StepMini(dt float64, in minigame.Input) {
case g.Success(): case g.Success():
r.finish(tr, true, "") r.finish(tr, true, "")
case !tr.Correct: case !tr.Correct:
r.finish(tr, false, LostWrongTrain) reason := LostWrongTrain
switch tr.Verdict {
case nav.WrongDirection:
reason = LostWrongDirection
case nav.TooFar:
reason = LostTooFar
}
r.finish(tr, false, reason)
case g.GotOff >= 0: case g.GotOff >= 0:
r.finish(tr, false, LostWrongStop) r.finish(tr, false, LostWrongStop)
default: default:
@@ -739,7 +834,11 @@ func (r *Run) computeScore() {
if r.CarChosen != nil && r.CarChosen.Crowd == 0 { if r.CarChosen != nil && r.CarChosen.Crowd == 0 {
sc.Car = ScoreCarBonus sc.Car = ScoreCarBonus
} }
sc.Total = max(0, sc.Time+sc.Money+sc.Meters+sc.Explore+sc.Car-sc.Penalty) base := sc.Time + sc.Money + sc.Meters + sc.Explore + sc.Car
if r.Mode == nav.Signs {
sc.Bonus = base * ScoreSignsBonusPct / 100
}
sc.Total = max(0, base+sc.Bonus-sc.Penalty)
r.Score = sc r.Score = sc
} }
@@ -770,7 +869,7 @@ func (r *Run) earnedDebuffs() []Debuff {
// stepNPCs flyttar folket, låter avstigande kliva av när dörrarna öppnas och // stepNPCs flyttar folket, låter avstigande kliva av när dörrarna öppnas och
// tar bort dem som nått utgången. // tar bort dem som nått utgången.
func (r *Run) stepNPCs(dt float64) { func (r *Run) stepNPCs(dt float64) {
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} { for _, t := range level.PlatformTiles {
ph, _ := r.PlatformPhase(t) ph, _ := r.PlatformPhase(t)
if r.platformPhase[t] != ph { if r.platformPhase[t] != ph {
r.platformPhase[t] = ph r.platformPhase[t] = ph
@@ -806,7 +905,7 @@ func (r *Run) FacilityProgress() float64 {
// till avgång respektive ankomst. // till avgång respektive ankomst.
func (r *Run) PlatformHere() (tr *level.Train, atPlatform bool, seconds float64) { func (r *Run) PlatformHere() (tr *level.Train, atPlatform bool, seconds float64) {
t := r.Level.At(r.Player.Tile) t := r.Level.At(r.Player.Tile)
if t != level.Platform && t != level.WrongPlatform { if !t.IsPlatform() {
return nil, false, 0 return nil, false, 0
} }
tr = r.Level.TrainAt(t) tr = r.Level.TrainAt(t)
@@ -830,7 +929,7 @@ func (r *Run) PlatformPhase(t level.Tile) (level.Phase, float64) {
// spelaren – det är där man ser och hör tåget komma. // spelaren – det är där man ser och hör tåget komma.
func (r *Run) NearTrack(maxDist int) (tr *level.Train, platform level.Point, phase level.Phase, progress float64, dist int, ok bool) { func (r *Run) NearTrack(maxDist int) (tr *level.Train, platform level.Point, phase level.Phase, progress float64, dist int, ok bool) {
best := maxDist + 1 best := maxDist + 1
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} { for _, t := range level.PlatformTiles {
for _, p := range r.Level.Find(t) { for _, p := range r.Level.Find(t) {
if p.Z != r.Player.Tile.Z { if p.Z != r.Player.Tile.Z {
continue // man ser och hör bara tågen på sin egen våning continue // man ser och hör bara tågen på sin egen våning

View File

@@ -49,13 +49,14 @@
"romaji": "Shinjuku", "romaji": "Shinjuku",
"en": "Shinjuku" "en": "Shinjuku"
}, },
"correct": true,
"track": "2", "track": "2",
"schedule": { "schedule": {
"offsetSec": 0, "offsetSec": 0,
"intervalSec": 30, "intervalSec": 30,
"dwellSec": 12 "dwellSec": 12
} },
"lineId": "narita-express",
"terminus": "shinjuku"
}, },
{ {
"platform": "p", "platform": "p",
@@ -69,13 +70,14 @@
"romaji": "Keisei-Ueno", "romaji": "Keisei-Ueno",
"en": "Keisei-Ueno" "en": "Keisei-Ueno"
}, },
"correct": false,
"track": "1", "track": "1",
"schedule": { "schedule": {
"offsetSec": 8, "offsetSec": 8,
"intervalSec": 30, "intervalSec": 30,
"dwellSec": 12 "dwellSec": 12
} },
"lineId": "keisei-main",
"terminus": "keisei-ueno"
} }
], ],
"signs": [ "signs": [
@@ -97,5 +99,7 @@
"ja": "改札 ← 1番線 京成線 京成上野方面 → 2番線 成田エクスプレス 新宿方面" "ja": "改札 ← 1番線 京成線 京成上野方面 → 2番線 成田エクスプレス 新宿方面"
} }
} }
] ],
"station": "narita-airport",
"to": "shinjuku"
} }

View File

@@ -53,7 +53,6 @@
], ],
"trains": [ "trains": [
{ {
"correct": true,
"crowded": true, "crowded": true,
"destination": { "destination": {
"en": "Shibuya", "en": "Shibuya",
@@ -71,10 +70,11 @@
"intervalSec": 20, "intervalSec": 20,
"offsetSec": 3 "offsetSec": 3
}, },
"track": "14" "track": "14",
"lineId": "yamanote",
"terminus": "shibuya"
}, },
{ {
"correct": false,
"destination": { "destination": {
"en": "Tokyo", "en": "Tokyo",
"ja": "東京", "ja": "東京",
@@ -91,7 +91,9 @@
"intervalSec": 25, "intervalSec": 25,
"offsetSec": 10 "offsetSec": 10
}, },
"track": "8" "track": "8",
"lineId": "chuo",
"terminus": "tokyo"
} }
], ],
"signs": [ "signs": [
@@ -122,5 +124,7 @@
"x": 11, "x": 11,
"y": 5 "y": 5
} }
] ],
"station": "shinjuku",
"to": "shibuya"
} }

View File

@@ -55,7 +55,6 @@
], ],
"trains": [ "trains": [
{ {
"correct": true,
"crowded": true, "crowded": true,
"destination": { "destination": {
"en": "Ikebukuro", "en": "Ikebukuro",
@@ -73,10 +72,11 @@
"intervalSec": 25, "intervalSec": 25,
"offsetSec": 6 "offsetSec": 6
}, },
"track": "2" "track": "2",
"lineId": "yamanote",
"terminus": "ikebukuro"
}, },
{ {
"correct": false,
"destination": { "destination": {
"en": "Yokohama", "en": "Yokohama",
"ja": "横浜", "ja": "横浜",
@@ -93,7 +93,9 @@
"intervalSec": 30, "intervalSec": 30,
"offsetSec": 12 "offsetSec": 12
}, },
"track": "4" "track": "4",
"lineId": "toyoko",
"terminus": "yokohama"
} }
], ],
"signs": [ "signs": [
@@ -124,5 +126,7 @@
"x": 29, "x": 29,
"y": 13 "y": 13
} }
] ],
"station": "shibuya",
"to": "ikebukuro"
} }

View File

@@ -92,9 +92,9 @@
}, },
{ {
"text": { "text": {
"en": "\u003c- Track 7 Tokaido Line (Shinagawa) Track 4 Yamanote Line (Ueno) -\u003e", "en": "<- Track 7 Tokaido Line (Shinagawa) Track 4 Yamanote Line (Ueno) ->",
"ja": "← 7番線 東海道線 品川方面 4番線 山手線 上野方面 →", "ja": "← 7番線 東海道線 品川方面 4番線 山手線 上野方面 →",
"sv": "\u003c- Spår 7 Tokaido-linjen (Shinagawa) Spår 4 Yamanote-linjen (Ueno) -\u003e" "sv": "<- Spår 7 Tokaido-linjen (Shinagawa) Spår 4 Yamanote-linjen (Ueno) ->"
}, },
"x": 3, "x": 3,
"y": 3, "y": 3,
@@ -115,7 +115,6 @@
"timeLimitSec": 420, "timeLimitSec": 420,
"trains": [ "trains": [
{ {
"correct": true,
"crowded": true, "crowded": true,
"destination": { "destination": {
"en": "Ueno", "en": "Ueno",
@@ -133,10 +132,11 @@
"intervalSec": 24, "intervalSec": 24,
"offsetSec": 5 "offsetSec": 5
}, },
"track": "4" "track": "4",
"lineId": "yamanote",
"terminus": "ueno"
}, },
{ {
"correct": false,
"destination": { "destination": {
"en": "Shinagawa", "en": "Shinagawa",
"ja": "品川", "ja": "品川",
@@ -153,7 +153,11 @@
"intervalSec": 28, "intervalSec": 28,
"offsetSec": 11 "offsetSec": 11
}, },
"track": "7" "track": "7",
"lineId": "tokaido",
"terminus": "shinagawa"
} }
] ],
"station": "tokyo",
"to": "ueno"
} }

View File

@@ -11,23 +11,23 @@
"fareYen": 200, "fareYen": 200,
"grid": [ "grid": [
"#################################", "#################################",
"#S.\u003c..\u003c..#..\u003c..\u003c..\u003c..\u003c..#..\u003c..###", "#S.<..<..#..<..<..<..<..#..<..###",
"#..\u003e..\u003e..#..\u003e..\u003e..\u003e..\u003e..#..\u003e..###", "#..>..>..#..>..>..>..>..#..>..###",
"#######\u003c\u003e#\u003c\u003e#\u003c\u003e####\u003c\u003e#\u003c\u003e####\u003c\u003e###", "#######<>#<>#<>####<>#<>####<>###",
"#..\u003c..\u003c..#.W\u003c..\u003c..\u003c..#..\u003c..\u003c..###", "#..<..<..#.W<..<..<..#..<..<..###",
"#.i\u003e..\u003e..#..\u003e..\u003e..\u003e..#..\u003eK.\u003e..###", "#.i>..>..#..>..>..>..#..>K.>..###",
"#\u003c\u003e#############\u003c\u003e#######\u003c\u003e#\u003c\u003e###", "#<>#############<>#######<>#<>###",
"#..#..\u003c..\u003c..\u003c.i\u003c..\u003c..\u003c..#..\u003c..###", "#..#..<..<..<.i<..<..<..#..<..###",
"#..#..\u003e..\u003e..\u003e..\u003e..\u003e..\u003e..#.T\u003ei.###", "#..#..>..>..>..>..>..>..#.T>i.###",
"#\u003c\u003e####\u003c\u003e##########\u003c\u003e#\u003c\u003e#\u003c\u003e######", "#<>####<>##########<>#<>#<>######",
"#..\u003c..\u003c..\u003c..#..\u003c..\u003c..#..#..\u003c..###", "#..<..<..<..#..<..<..#..#..<..###",
"#..\u003e..\u003e..\u003e..#..\u003e..\u003e..#..#..\u003e..###", "#..>..>..>..#..>..>..#..#..>..###",
"####\u003c\u003e#\u003c\u003e#\u003c\u003e#\u003c\u003e####\u003c\u003e#######\u003c\u003e###", "####<>#<>#<>#<>####<>#######<>###",
"#..\u003c..#..#..#..\u003c..#..\u003c..\u003c..\u003c..###", "#..<..#..#..#..<..#..<..<..<..###",
"#..\u003e..#..#..#..\u003e..#..\u003e..\u003e..\u003e..###", "#..>..#..#..#..>..#..>..>..>..###",
"#\u003c\u003e#######\u003c\u003e####\u003c\u003e###############", "#<>#######<>####<>###############",
"#G.\u003c..\u003c..\u003c..#..\u003c..\u003c..\u003c..\u003c..\u003c.G###", "#G.<..<..<..#..<..<..<..<..<.G###",
"#pG\u003e..\u003e..\u003e..#..\u003e..\u003e..\u003e..\u003e..\u003eGP###", "#pG>..>..>..#..>..>..>..>..>GP###",
"#################################", "#################################",
"#################################", "#################################",
"#################################" "#################################"
@@ -64,9 +64,9 @@
}, },
{ {
"text": { "text": {
"en": "\u003c- Track 9 Keihin-Tohoku Line (Omiya) Track 3 Yamanote Line (Akihabara) -\u003e", "en": "<- Track 9 Keihin-Tohoku Line (Omiya) Track 3 Yamanote Line (Akihabara) ->",
"ja": "← 9番線 京浜東北線 大宮方面 3番線 山手線 秋葉原方面 →", "ja": "← 9番線 京浜東北線 大宮方面 3番線 山手線 秋葉原方面 →",
"sv": "\u003c- Spår 9 Keihin-Tohoku-linjen (Omiya) Spår 3 Yamanote-linjen (Akihabara) -\u003e" "sv": "<- Spår 9 Keihin-Tohoku-linjen (Omiya) Spår 3 Yamanote-linjen (Akihabara) ->"
}, },
"x": 14, "x": 14,
"y": 7, "y": 7,
@@ -87,7 +87,6 @@
"trains": [ "trains": [
{ {
"cars": 4, "cars": 4,
"correct": true,
"destination": { "destination": {
"en": "Akihabara", "en": "Akihabara",
"ja": "秋葉原", "ja": "秋葉原",
@@ -107,11 +106,12 @@
"track": "3", "track": "3",
"womenOnlyCar": 1, "womenOnlyCar": 1,
"womenOnlyFrom": 0, "womenOnlyFrom": 0,
"womenOnlyTo": 150 "womenOnlyTo": 150,
"lineId": "yamanote",
"terminus": "tokyo"
}, },
{ {
"cars": 3, "cars": 3,
"correct": false,
"destination": { "destination": {
"en": "Omiya", "en": "Omiya",
"ja": "大宮", "ja": "大宮",
@@ -128,7 +128,11 @@
"intervalSec": 30, "intervalSec": 30,
"offsetSec": 13 "offsetSec": 13
}, },
"track": "9" "track": "9",
"lineId": "keihin-tohoku",
"terminus": "omiya"
} }
] ],
"station": "ueno",
"to": "akihabara"
} }

View File

@@ -0,0 +1,177 @@
{
"clutter": 1,
"crowd": 1,
"destination": {
"en": "Shinjuku",
"ja": "新宿",
"romaji": "Shinjuku",
"sv": "Shinjuku"
},
"difficulty": 4,
"fareYen": 180,
"grid": [
"#################################",
"#S#...#........i..#.............#",
"#.#.#.#.#######.#.#.###########.#",
"#...#.#....T#...#.#.......#..W#.#",
"#####.#######.###.#.#####.#.###.#",
"#...#...#.....#...#.....#.#...#.#",
"#.#.###.#.#####.#####.#.#.###.#.#",
"#.#.#...#.#.....#...#K#.#.....#.#",
"#.#.#.###.#.#####.#.###.#####.#.#",
"#...#.....#......i#...#.....#.#.#",
"#.###############.###.#.#####.#.#",
"#.#.........#..i..#...#.......#.#",
"#.#.#.#####.#.#.###.#.#####.###.#",
"#...#.....#...#.....#.....#.#...#",
"#.#######.#.###########.#.###.#.#",
"#.#.....#...#.....#.....#...#.#.#",
"#.#.###.#####.###.#.#######.#.###",
"#...#.#...#...#...#...#...#.#...#",
"#####.###G#.###.#####.#.###.###G#",
"#pG.....GqG.#...GQG...#.......GP#",
"#################################"
],
"id": "006-ikebukuro",
"kind": "main",
"maxMissedTrains": 2,
"name": {
"en": "Ikebukuro Station",
"ja": "池袋駅",
"sv": "Ikebukuro station"
},
"noise": 1,
"order": 6,
"party": 3,
"rideStops": 5,
"shop": [
"suit",
"rail_pass",
"onigiri_box"
],
"signs": [
{
"text": {
"en": "Ikebukuro Station. Track 6 Yamanote Line for Shinjuku: south-east. Tracks 1 and 2 Saikyo Line: south. Track 11 Marunouchi Line for Ogikubo: south-west.",
"ja": "池袋駅 6番線 山手線 新宿方面 → 南東 1番線・2番線 埼京線 → 南 11番線 丸ノ内線 荻窪方面 → 南西",
"sv": "Ikebukuro station. Spår 6 Yamanote-linjen mot Shinjuku: sydost. Spår 1 och 2 Saikyo-linjen: söderut. Spår 11 Marunouchi-linjen mot Ogikubo: sydväst."
},
"x": 15,
"y": 1,
"z": 0
},
{
"text": {
"en": "\u003c- Track 2 Saikyo Line for Omiya Track 1 Saikyo Line for Osaki -\u003e",
"ja": "← 2番線 埼京線 大宮方面 1番線 埼京線 大崎方面 →",
"sv": "\u003c- Spår 2 Saikyo-linjen mot Omiya Spår 1 Saikyo-linjen mot Osaki -\u003e"
},
"x": 17,
"y": 9,
"z": 0
},
{
"text": {
"en": "Track 6 Yamanote Line for Shinjuku: the gate in the south-east corner. Line map at the ticket machine.",
"ja": "6番線 山手線 新宿方面 → 南東口 改札 路線図 → 券売機",
"sv": "Spår 6 Yamanote-linjen mot Shinjuku: spärren i sydöstra hörnet. Linjekarta finns vid automaten."
},
"x": 15,
"y": 11,
"z": 0
}
],
"station": "ikebukuro",
"timeLimitSec": 480,
"to": "shinjuku",
"trains": [
{
"cars": 4,
"destination": {
"en": "Shinjuku",
"ja": "新宿",
"romaji": "Shinjuku"
},
"line": {
"en": "Yamanote Line",
"ja": "山手線",
"romaji": "Yamanote-sen"
},
"lineId": "yamanote",
"platform": "P",
"schedule": {
"dwellSec": 9,
"intervalSec": 30,
"offsetSec": 8
},
"terminus": "shinjuku",
"track": "6"
},
{
"cars": 3,
"destination": {
"en": "Omiya",
"ja": "大宮",
"romaji": "Omiya"
},
"line": {
"en": "Saikyo Line",
"ja": "埼京線",
"romaji": "Saikyo-sen"
},
"lineId": "saikyo",
"platform": "p",
"schedule": {
"dwellSec": 8,
"intervalSec": 34,
"offsetSec": 14
},
"terminus": "omiya",
"track": "2"
},
{
"cars": 3,
"destination": {
"en": "Osaki",
"ja": "大崎",
"romaji": "Osaki"
},
"line": {
"en": "Saikyo Line",
"ja": "埼京線",
"romaji": "Saikyo-sen"
},
"lineId": "saikyo",
"platform": "Q",
"schedule": {
"dwellSec": 8,
"intervalSec": 34,
"offsetSec": 20
},
"terminus": "osaki",
"track": "1"
},
{
"cars": 3,
"destination": {
"en": "Ogikubo",
"ja": "荻窪",
"romaji": "Ogikubo"
},
"line": {
"en": "Marunouchi Line",
"ja": "丸ノ内線",
"romaji": "Marunouchi-sen"
},
"lineId": "marunouchi",
"platform": "q",
"schedule": {
"dwellSec": 8,
"intervalSec": 28,
"offsetSec": 4
},
"terminus": "ogikubo",
"track": "11"
}
]
}

1009
resources/data/network.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -140,7 +140,7 @@
"map.party": "Party", "map.party": "Party",
"map.resume": "⟳ resume (saved seed)", "map.resume": "⟳ resume (saved seed)",
"map.locked": "Locked - clear the previous level first.", "map.locked": "Locked - clear the previous level first.",
"map.hint": "Arrows: level Enter: start L: words T: skills I: wardrobe P: phone Esc", "map.hint": "Enter start N mode L words T skills I items P phone Esc",
"skills.title": "Skills 技", "skills.title": "Skills 技",
"skills.to_next": "XP to next", "skills.to_next": "XP to next",
"skills.already": "Already unlocked.", "skills.already": "Already unlocked.",
@@ -197,5 +197,16 @@
"hud.early_board": "Let people off first!", "hud.early_board": "Let people off first!",
"hud.bump": "Sumimasen…", "hud.bump": "Sumimasen…",
"hud.wrong_lane": "Wrong side - keep to the side the lane shows", "hud.wrong_lane": "Wrong side - keep to the side the lane shows",
"result.car": "car" "result.car": "car",
"hud.take": "Take:",
"hud.mode_signs": "Sign mode: work out the train yourself, +50 % score",
"hud.terminal": "Lines from here (terminal):",
"menu.mode": "N: navigation %s",
"mode.list": "list mode (next train shown)",
"mode.signs": "sign mode (+50 % score)",
"result.bonus": "sign bonus",
"result.lost_wrong_direction": "Wrong direction.",
"result.lost_too_far": "Too far away.",
"result.wrong_direction_detail": "The line does reach %[2]s, but the train for %[1]s goes the other way.",
"result.too_far_detail": "The train for %[1]s only reaches %[2]s after a long detour."
} }

View File

@@ -645,5 +645,528 @@
"en": "passage / keep", "en": "passage / keep",
"sv": "gång / håll", "sv": "gång / håll",
"tier": 3 "tier": 3
},
"神田": {
"kana": "かんだ",
"romaji": "Kanda",
"en": "Kanda",
"sv": "Kanda",
"tier": 3,
"kind": "station"
},
"御徒町": {
"kana": "おかちまち",
"romaji": "Okachimachi",
"en": "Okachimachi",
"sv": "Okachimachi",
"tier": 3,
"kind": "station"
},
"鶯谷": {
"kana": "うぐいすだに",
"romaji": "Uguisudani",
"en": "Uguisudani",
"sv": "Uguisudani",
"tier": 3,
"kind": "station"
},
"西日暮里": {
"kana": "にしにっぽり",
"romaji": "Nishi-Nippori",
"en": "Nishi-Nippori",
"sv": "Nishi-Nippori",
"tier": 3,
"kind": "station"
},
"田端": {
"kana": "たばた",
"romaji": "Tabata",
"en": "Tabata",
"sv": "Tabata",
"tier": 3,
"kind": "station"
},
"駒込": {
"kana": "こまごめ",
"romaji": "Komagome",
"en": "Komagome",
"sv": "Komagome",
"tier": 3,
"kind": "station"
},
"巣鴨": {
"kana": "すがも",
"romaji": "Sugamo",
"en": "Sugamo",
"sv": "Sugamo",
"tier": 3,
"kind": "station"
},
"大塚": {
"kana": "おおつか",
"romaji": "Otsuka",
"en": "Otsuka",
"sv": "Otsuka",
"tier": 3,
"kind": "station"
},
"目白": {
"kana": "めじろ",
"romaji": "Mejiro",
"en": "Mejiro",
"sv": "Mejiro",
"tier": 3,
"kind": "station"
},
"高田馬場": {
"kana": "たかだのばば",
"romaji": "Takadanobaba",
"en": "Takadanobaba",
"sv": "Takadanobaba",
"tier": 3,
"kind": "station"
},
"新大久保": {
"kana": "しんおおくぼ",
"romaji": "Shin-Okubo",
"en": "Shin-Okubo",
"sv": "Shin-Okubo",
"tier": 3,
"kind": "station"
},
"代々木": {
"kana": "よよぎ",
"romaji": "Yoyogi",
"en": "Yoyogi",
"sv": "Yoyogi",
"tier": 3,
"kind": "station"
},
"恵比寿": {
"kana": "えびす",
"romaji": "Ebisu",
"en": "Ebisu",
"sv": "Ebisu",
"tier": 3,
"kind": "station"
},
"目黒": {
"kana": "めぐろ",
"romaji": "Meguro",
"en": "Meguro",
"sv": "Meguro",
"tier": 3,
"kind": "station"
},
"五反田": {
"kana": "ごたんだ",
"romaji": "Gotanda",
"en": "Gotanda",
"sv": "Gotanda",
"tier": 3,
"kind": "station"
},
"大崎": {
"kana": "おおさき",
"romaji": "Osaki",
"en": "Osaki",
"sv": "Osaki",
"tier": 3,
"kind": "station"
},
"高輪ゲートウェイ": {
"kana": "たかなわげーとうぇい",
"romaji": "Takanawa Gateway",
"en": "Takanawa Gateway",
"sv": "Takanawa Gateway",
"tier": 3,
"kind": "station"
},
"田町": {
"kana": "たまち",
"romaji": "Tamachi",
"en": "Tamachi",
"sv": "Tamachi",
"tier": 3,
"kind": "station"
},
"浜松町": {
"kana": "はままつちょう",
"romaji": "Hamamatsucho",
"en": "Hamamatsucho",
"sv": "Hamamatsucho",
"tier": 3,
"kind": "station"
},
"新橋": {
"kana": "しんばし",
"romaji": "Shimbashi",
"en": "Shimbashi",
"sv": "Shimbashi",
"tier": 3,
"kind": "station"
},
"有楽町": {
"kana": "ゆうらくちょう",
"romaji": "Yurakucho",
"en": "Yurakucho",
"sv": "Yurakucho",
"tier": 3,
"kind": "station"
},
"御茶ノ水": {
"kana": "おちゃのみず",
"romaji": "Ochanomizu",
"en": "Ochanomizu",
"sv": "Ochanomizu",
"tier": 3,
"kind": "station"
},
"四ツ谷": {
"kana": "よつや",
"romaji": "Yotsuya",
"en": "Yotsuya",
"sv": "Yotsuya",
"tier": 3,
"kind": "station"
},
"中野": {
"kana": "なかの",
"romaji": "Nakano",
"en": "Nakano",
"sv": "Nakano",
"tier": 3,
"kind": "station"
},
"吉祥寺": {
"kana": "きちじょうじ",
"romaji": "Kichijoji",
"en": "Kichijoji",
"sv": "Kichijoji",
"tier": 3,
"kind": "station"
},
"三鷹": {
"kana": "みたか",
"romaji": "Mitaka",
"en": "Mitaka",
"sv": "Mitaka",
"tier": 3,
"kind": "station"
},
"立川": {
"kana": "たちかわ",
"romaji": "Tachikawa",
"en": "Tachikawa",
"sv": "Tachikawa",
"tier": 3,
"kind": "station"
},
"八王子": {
"kana": "はちおうじ",
"romaji": "Hachioji",
"en": "Hachioji",
"sv": "Hachioji",
"tier": 3,
"kind": "station"
},
"高尾": {
"kana": "たかお",
"romaji": "Takao",
"en": "Takao",
"sv": "Takao",
"tier": 3,
"kind": "station"
},
"浦和": {
"kana": "うらわ",
"romaji": "Urawa",
"en": "Urawa",
"sv": "Urawa",
"tier": 3,
"kind": "station"
},
"赤羽": {
"kana": "あかばね",
"romaji": "Akabane",
"en": "Akabane",
"sv": "Akabane",
"tier": 3,
"kind": "station"
},
"王子": {
"kana": "おうじ",
"romaji": "Oji",
"en": "Oji",
"sv": "Oji",
"tier": 3,
"kind": "station"
},
"川崎": {
"kana": "かわさき",
"romaji": "Kawasaki",
"en": "Kawasaki",
"sv": "Kawasaki",
"tier": 3,
"kind": "station"
},
"大船": {
"kana": "おおふな",
"romaji": "Ofuna",
"en": "Ofuna",
"sv": "Ofuna",
"tier": 3,
"kind": "station"
},
"成田空港": {
"kana": "なりたくうこう",
"romaji": "Narita Airport",
"en": "Narita Airport",
"sv": "Narita Airport",
"tier": 3,
"kind": "station"
},
"空港第2ビル": {
"kana": "くうこうだいにびる",
"romaji": "Airport Terminal 2",
"en": "Airport Terminal 2",
"sv": "Airport Terminal 2",
"tier": 3,
"kind": "station"
},
"船橋": {
"kana": "ふなばし",
"romaji": "Funabashi",
"en": "Funabashi",
"sv": "Funabashi",
"tier": 3,
"kind": "station"
},
"青砥": {
"kana": "あおと",
"romaji": "Aoto",
"en": "Aoto",
"sv": "Aoto",
"tier": 3,
"kind": "station"
},
"十条": {
"kana": "じゅうじょう",
"romaji": "Jujo",
"en": "Jujo",
"sv": "Jujo",
"tier": 3,
"kind": "station"
},
"板橋": {
"kana": "いたばし",
"romaji": "Itabashi",
"en": "Itabashi",
"sv": "Itabashi",
"tier": 3,
"kind": "station"
},
"荻窪": {
"kana": "おぎくぼ",
"romaji": "Ogikubo",
"en": "Ogikubo",
"sv": "Ogikubo",
"tier": 3,
"kind": "station"
},
"新宿三丁目": {
"kana": "しんじゅくさんちょうめ",
"romaji": "Shinjuku-sanchome",
"en": "Shinjuku-sanchome",
"sv": "Shinjuku-sanchome",
"tier": 3,
"kind": "station"
},
"新宿御苑前": {
"kana": "しんじゅくぎょえんまえ",
"romaji": "Shinjuku-gyoemmae",
"en": "Shinjuku-gyoemmae",
"sv": "Shinjuku-gyoemmae",
"tier": 3,
"kind": "station"
},
"四谷三丁目": {
"kana": "よつやさんちょうめ",
"romaji": "Yotsuya-sanchome",
"en": "Yotsuya-sanchome",
"sv": "Yotsuya-sanchome",
"tier": 3,
"kind": "station"
},
"赤坂見附": {
"kana": "あかさかみつけ",
"romaji": "Akasaka-mitsuke",
"en": "Akasaka-mitsuke",
"sv": "Akasaka-mitsuke",
"tier": 3,
"kind": "station"
},
"国会議事堂前": {
"kana": "こっかいぎじどうまえ",
"romaji": "Kokkai-gijidomae",
"en": "Kokkai-gijidomae",
"sv": "Kokkai-gijidomae",
"tier": 3,
"kind": "station"
},
"霞ケ関": {
"kana": "かすみがせき",
"romaji": "Kasumigaseki",
"en": "Kasumigaseki",
"sv": "Kasumigaseki",
"tier": 3,
"kind": "station"
},
"大手町": {
"kana": "おおてまち",
"romaji": "Otemachi",
"en": "Otemachi",
"sv": "Otemachi",
"tier": 3,
"kind": "station"
},
"淡路町": {
"kana": "あわじちょう",
"romaji": "Awajicho",
"en": "Awajicho",
"sv": "Awajicho",
"tier": 3,
"kind": "station"
},
"本郷三丁目": {
"kana": "ほんごうさんちょうめ",
"romaji": "Hongo-sanchome",
"en": "Hongo-sanchome",
"sv": "Hongo-sanchome",
"tier": 3,
"kind": "station"
},
"後楽園": {
"kana": "こうらくえん",
"romaji": "Korakuen",
"en": "Korakuen",
"sv": "Korakuen",
"tier": 3,
"kind": "station"
},
"茗荷谷": {
"kana": "みょうがだに",
"romaji": "Myogadani",
"en": "Myogadani",
"sv": "Myogadani",
"tier": 3,
"kind": "station"
},
"新大塚": {
"kana": "しんおおつか",
"romaji": "Shin-Otsuka",
"en": "Shin-Otsuka",
"sv": "Shin-Otsuka",
"tier": 3,
"kind": "station"
},
"中目黒": {
"kana": "なかめぐろ",
"romaji": "Naka-Meguro",
"en": "Naka-Meguro",
"sv": "Naka-Meguro",
"tier": 3,
"kind": "station"
},
"六本木": {
"kana": "ろっぽんぎ",
"romaji": "Roppongi",
"en": "Roppongi",
"sv": "Roppongi",
"tier": 3,
"kind": "station"
},
"北千住": {
"kana": "きたせんじゅ",
"romaji": "Kita-Senju",
"en": "Kita-Senju",
"sv": "Kita-Senju",
"tier": 3,
"kind": "station"
},
"表参道": {
"kana": "おもてさんどう",
"romaji": "Omotesando",
"en": "Omotesando",
"sv": "Omotesando",
"tier": 3,
"kind": "station"
},
"日本橋": {
"kana": "にほんばし",
"romaji": "Nihombashi",
"en": "Nihombashi",
"sv": "Nihombashi",
"tier": 3,
"kind": "station"
},
"自由が丘": {
"kana": "じゆうがおか",
"romaji": "Jiyugaoka",
"en": "Jiyugaoka",
"sv": "Jiyugaoka",
"tier": 3,
"kind": "station"
},
"武蔵小杉": {
"kana": "むさしこすぎ",
"romaji": "Musashi-Kosugi",
"en": "Musashi-Kosugi",
"sv": "Musashi-Kosugi",
"tier": 3,
"kind": "station"
},
"菊名": {
"kana": "きくな",
"romaji": "Kikuna",
"en": "Kikuna",
"sv": "Kikuna",
"tier": 3,
"kind": "station"
},
"埼京線": {
"kana": "さいきょうせん",
"romaji": "Saikyo-sen",
"en": "Saikyo Line",
"sv": "Saikyo-linjen",
"tier": 3
},
"日比谷線": {
"kana": "ひびやせん",
"romaji": "Hibiya-sen",
"en": "Hibiya Line",
"sv": "Hibiya-linjen",
"tier": 3
},
"銀座線": {
"kana": "ぎんざせん",
"romaji": "Ginza-sen",
"en": "Ginza Line",
"sv": "Ginza-linjen",
"tier": 3
},
"路線図": {
"kana": "ろせんず",
"romaji": "rosenzu",
"en": "line map",
"sv": "linjekarta",
"tier": 3
},
"券売機": {
"kana": "けんばいき",
"romaji": "kenbaiki",
"en": "ticket machine",
"sv": "biljettautomat",
"tier": 2
} }
} }

View File

@@ -140,7 +140,7 @@
"map.party": "Sällskap", "map.party": "Sällskap",
"map.resume": "⟳ fortsätt (sparad seed)", "map.resume": "⟳ fortsätt (sparad seed)",
"map.locked": "Låst - klara föregående bana först.", "map.locked": "Låst - klara föregående bana först.",
"map.hint": "Pilar: bana Enter: start L: ord T: skills I: garderob P: tel Esc", "map.hint": "Enter start N läge L ord T skills I items P tel Esc",
"skills.title": "Skills 技", "skills.title": "Skills 技",
"skills.to_next": "XP till nästa", "skills.to_next": "XP till nästa",
"skills.already": "Redan upplåst.", "skills.already": "Redan upplåst.",
@@ -197,5 +197,16 @@
"hud.early_board": "Låt folk kliva av först!", "hud.early_board": "Låt folk kliva av först!",
"hud.bump": "Ursäkta…", "hud.bump": "Ursäkta…",
"hud.wrong_lane": "Fel sida - håll till vänster/höger som filen visar", "hud.wrong_lane": "Fel sida - håll till vänster/höger som filen visar",
"result.car": "vagn" "result.car": "vagn",
"hud.take": "Ta:",
"hud.mode_signs": "Skyltläge: lista ut tåget själv, +50 % poäng",
"hud.terminal": "Linjer härifrån (terminal):",
"menu.mode": "N: navigering %s",
"mode.list": "listläge (nästa tåg visas)",
"mode.signs": "skyltläge (+50 % poäng)",
"result.bonus": "skyltbonus",
"result.lost_wrong_direction": "Fel håll.",
"result.lost_too_far": "För långt bort.",
"result.wrong_direction_detail": "Linjen går till %[2]s, men tåget mot %[1]s åker åt andra hållet.",
"result.too_far_detail": "Tåget mot %[1]s når %[2]s först efter en lång omväg."
} }

View File

@@ -110,6 +110,18 @@ func buildShots(levelsDir string) error {
if err != nil { if err != nil {
return err return err
} }
l6, err := level.LoadFile(filepath.Join(levelsDir, "006-ikebukuro.json"))
if err != nil {
return err
}
toMachine6, err := moves(l6, l6.SpawnPoint(), l6.Find(level.TicketMachine)[0])
if err != nil {
return err
}
toTrap6, err := moves(l6, l6.SpawnPoint(), l6.Find(level.Platform3)[0]) // Marunouchi: för långt bort
if err != nil {
return err
}
// Skyltbilderna tas med några kända ord så att markeringen och den // Skyltbilderna tas med några kända ord så att markeringen och den
// delvisa översättningen syns; hjälpen lär sedan in nästa ord. // delvisa översättningen syns; hjälpen lär sedan in nästa ord.
known := []string{"-know", "改札,番線,方面", "-seed", "7"} known := []string{"-know", "改札,番線,方面", "-seed", "7"}
@@ -120,13 +132,17 @@ func buildShots(levelsDir string) error {
{"001-flygplatsen", "move right 4; move down 2; wait 5; shot $OUT/level-001.png; quit", nil}, {"001-flygplatsen", "move right 4; move down 2; wait 5; shot $OUT/level-001.png; quit", nil},
{"002-shinjuku", "move down 6; move right 4; wait 5; shot $OUT/level-002.png; quit", nil}, {"002-shinjuku", "move down 6; move right 4; wait 5; shot $OUT/level-002.png; quit", nil},
{"001-flygplatsen", toSign + "; wait 5; shot $OUT/sign.png; press h; wait 200; shot $OUT/sign-help.png; wait 200; shot $OUT/sign-learned.png; " + toGoal + {"001-flygplatsen", toSign + "; wait 5; shot $OUT/sign.png; press h; wait 200; shot $OUT/sign-help.png; wait 200; shot $OUT/sign-learned.png; " + toGoal +
"; waitphase approaching; wait 200; shot $OUT/approach.png; waittrain; wait 30; shot $OUT/platform.png; press enter; wait 40; shot $OUT/ride.png; waitstop; press enter; wait 5; shot $OUT/result.png; quit", known}, "; waitphase approaching; wait 200; shot $OUT/approach.png; waittrain; wait 30; shot $OUT/platform.png; press enter; wait 100; pick calm; press enter; wait 40; shot $OUT/ride.png; waitstop; press enter; wait 5; shot $OUT/result.png; quit", known},
{"001-flygplatsen", toMachine + "; press enter; wait 5; press right; wait 5; shot $OUT/topup.png; quit", []string{"-seed", "7", "-card", "0"}}, {"001-flygplatsen", toMachine + "; press enter; wait 5; press right; wait 5; shot $OUT/topup.png; quit", []string{"-seed", "7", "-card", "0"}},
{"002-shinjuku", toMachine2 + "; press enter; wait 5; shot $OUT/ticket.png; quit", []string{"-seed", "7", "-know", "渋谷"}}, {"002-shinjuku", toMachine2 + "; press enter; wait 5; shot $OUT/ticket.png; quit", []string{"-seed", "7", "-know", "渋谷"}},
{"003-shibuya", toMachine3 + "; press enter; pick 池袋; press enter; wait 5; press enter; press right; wait 5; shot $OUT/seats.png; quit", {"003-shibuya", toMachine3 + "; press enter; pick 池袋; press enter; wait 5; press enter; press right; wait 5; shot $OUT/seats.png; quit",
[]string{"-seed", "7", "-know", "池袋", "-debuffs", "sore_feet"}}, []string{"-seed", "7", "-know", "池袋", "-debuffs", "sore_feet"}},
{"004-tokyo", "wait 10; shot $OUT/floors-b1.png; " + toStairs + "; wait 20; shot $OUT/floors-1f.png; quit", []string{"-seed", "7"}}, {"004-tokyo", "wait 10; shot $OUT/floors-b1.png; " + toStairs + "; wait 20; shot $OUT/floors-1f.png; quit", []string{"-seed", "7"}},
{"005-ueno", "wait 90; shot $OUT/crowd.png; " + toPlatform5 + "; waittrain; wait 5; press enter; wait 20; shot $OUT/cars-alight.png; wait 80; press right; wait 5; shot $OUT/cars.png; quit", []string{"-seed", "7"}}, {"005-ueno", "wait 90; shot $OUT/crowd.png; " + toPlatform5 + "; waittrain; wait 5; press enter; wait 20; shot $OUT/cars-alight.png; wait 80; press right; wait 5; shot $OUT/cars.png; quit", []string{"-seed", "7"}},
{"006-ikebukuro", "move right 2; wait 5; shot $OUT/nav-list.png; quit", []string{"-seed", "7", "-mode", "list"}},
{"006-ikebukuro", toMachine6 + "; wait 5; shot $OUT/terminal.png; " + "quit", []string{"-seed", "7", "-mode", "signs", "-know", "山手線,番線,方面,新宿,池袋,埼京線"}},
{"006-ikebukuro", toTrap6 + "; waittrain; wait 5; press enter; wait 100; pick calm; press enter; wait 60; shot $OUT/ride-real.png; waitover; wait 5; shot $OUT/too-far.png; quit", []string{"-seed", "7", "-mode", "signs"}},
{"", "wait 10; press n; wait 5; shot $OUT/map-signs.png; quit", []string{"-save", "$DEMO"}},
} }
return nil return nil
} }