diff --git a/README.md b/README.md index dbd050b..5773f3f 100644 --- a/README.md +++ b/README.md @@ -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+ 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 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 -avgångsmelodin, progression med XP, skillträd, items och sparfil. Och nu -lever stationen: folk som går i vägen, filer där man ska hålla till vänster, -reklam på väggarna, sorl i bakgrunden, och tåg med flera vagnar där man -väljer vagn, låter folk kliva av först och aktar sig för kvinnovagnen. -Nästa är M9: navigeringslägena lista och skyltläge. Se +avgångsmelodin, progression med XP, skillträd, items och sparfil, folk, +filer, reklam, sorl och vagnval. Och nu ett riktigt linjenät: tågen bedöms +efter var de faktiskt går, resan visar linjens riktiga stationer, och man +väljer **listläge** (nästa tåg visas) eller **skyltläge** (lista ut det +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). ![Bankartan](doc/img/map.png) @@ -65,6 +67,13 @@ sällskapet. ![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, 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, @@ -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 -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 -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 -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 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 -döljer/visar minikartan, P på kartan lägger kortet i telefonen, Esc går -tillbaka, R spelar om. Gamepad +döljer/visar minikartan, P på kartan lägger kortet i telefonen, N på kartan +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, 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/progress/ XP, skillträd, items, banresultat, upplåsning (ren Go) 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/rng/ seedad slump tools/ build/ genart/ shots/ diff --git a/cmd/metro-puzzle-jp/main.go b/cmd/metro-puzzle-jp/main.go index 96e2645..bf5712d 100644 --- a/cmd/metro-puzzle-jp/main.go +++ b/cmd/metro-puzzle-jp/main.go @@ -28,6 +28,7 @@ func main() { mute := flag.Bool("mute", false, "inget ljud") 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)") + mode := flag.String("mode", "", "navigeringsläge från start: list eller signs (tomt = sparfilens)") version := flag.Bool("version", false, "skriv ut version och avsluta") flag.Parse() @@ -36,7 +37,7 @@ func main() { 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 { log.Fatalf("kunde inte starta: %v", err) } diff --git a/doc/img/cars-alight.png b/doc/img/cars-alight.png index e73f7dc..17eaae8 100644 Binary files a/doc/img/cars-alight.png and b/doc/img/cars-alight.png differ diff --git a/doc/img/cars.png b/doc/img/cars.png index 37f38d4..6b5deb1 100644 Binary files a/doc/img/cars.png and b/doc/img/cars.png differ diff --git a/doc/img/crowd.png b/doc/img/crowd.png index 8f4d2f0..40a0862 100644 Binary files a/doc/img/crowd.png and b/doc/img/crowd.png differ diff --git a/doc/img/floors-1f.png b/doc/img/floors-1f.png index 1f33a1a..9d68c3f 100644 Binary files a/doc/img/floors-1f.png and b/doc/img/floors-1f.png differ diff --git a/doc/img/floors-b1.png b/doc/img/floors-b1.png index e3e8fc7..4604ddc 100644 Binary files a/doc/img/floors-b1.png and b/doc/img/floors-b1.png differ diff --git a/doc/img/level-001.png b/doc/img/level-001.png index a7c4529..23aad12 100644 Binary files a/doc/img/level-001.png and b/doc/img/level-001.png differ diff --git a/doc/img/level-002.png b/doc/img/level-002.png index b119809..be6719f 100644 Binary files a/doc/img/level-002.png and b/doc/img/level-002.png differ diff --git a/doc/img/map-signs.png b/doc/img/map-signs.png new file mode 100644 index 0000000..f60f5f9 Binary files /dev/null and b/doc/img/map-signs.png differ diff --git a/doc/img/map.png b/doc/img/map.png index cab1396..54d8890 100644 Binary files a/doc/img/map.png and b/doc/img/map.png differ diff --git a/doc/img/nav-list.png b/doc/img/nav-list.png new file mode 100644 index 0000000..18508d5 Binary files /dev/null and b/doc/img/nav-list.png differ diff --git a/doc/img/result.png b/doc/img/result.png index d8e3cbd..975f3d0 100644 Binary files a/doc/img/result.png and b/doc/img/result.png differ diff --git a/doc/img/ride-real.png b/doc/img/ride-real.png new file mode 100644 index 0000000..f9f9b64 Binary files /dev/null and b/doc/img/ride-real.png differ diff --git a/doc/img/ride.png b/doc/img/ride.png index 7073889..fbafe98 100644 Binary files a/doc/img/ride.png and b/doc/img/ride.png differ diff --git a/doc/img/terminal.png b/doc/img/terminal.png new file mode 100644 index 0000000..2345059 Binary files /dev/null and b/doc/img/terminal.png differ diff --git a/doc/img/ticket.png b/doc/img/ticket.png index 371059c..c2fae58 100644 Binary files a/doc/img/ticket.png and b/doc/img/ticket.png differ diff --git a/doc/img/too-far.png b/doc/img/too-far.png new file mode 100644 index 0000000..17a48d5 Binary files /dev/null and b/doc/img/too-far.png differ diff --git a/doc/img/words.png b/doc/img/words.png index 655f36d..23c42f9 100644 Binary files a/doc/img/words.png and b/doc/img/words.png differ diff --git a/doc/mal-och-tester.md b/doc/mal-och-tester.md index 0b7e6c3..ddbe60e 100644 --- a/doc/mal-och-tester.md +++ b/doc/mal-och-tester.md @@ -34,7 +34,7 @@ att bygga på Linux som på Windows. | Inkrementellt debug-bygge | < 10 s | 4,7 s | | Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) | | 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) | | 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 | @@ -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/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/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/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 | @@ -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) | | ~~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 | -| 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 | | 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) | diff --git a/doc/roadmap.md b/doc/roadmap.md index 7a14957..71b11da 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -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 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. - 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. - **Teknik:** `internal/nav` (graf över stationer/linjer, giltiga vägar, 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 diff --git a/doc/spelbeskrivning.md b/doc/spelbeskrivning.md index 8503b7e..3dfcd32 100644 --- a/doc/spelbeskrivning.md +++ b/doc/spelbeskrivning.md @@ -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 | | "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 | +| "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` | diff --git a/internal/game/assets.go b/internal/game/assets.go index d0cbc20..6f6d626 100644 --- a/internal/game/assets.go +++ b/internal/game/assets.go @@ -80,7 +80,9 @@ func LoadAssets() (*Assets, error) { } 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.Rails = sub(railsIndex) for i := 0; i < posterCount; i++ { diff --git a/internal/game/game.go b/internal/game/game.go index f6c3df0..4338e9b 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -12,6 +12,7 @@ import ( "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/nav" "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/rng" @@ -45,6 +46,7 @@ type Game struct { Catalog *progress.Catalog Levels []*level.Level // alla banor, sorterade på id Audio *Audio // nil = tyst + Mode nav.Mode // navigeringsläge (= State.NavMode), växlas på bankartan savePath string // "" = spara inte fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar) @@ -69,6 +71,7 @@ type Options struct { Mute bool // inget ljud SavePath string // sparfil ("" = standardplatsen) 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. @@ -109,6 +112,10 @@ func New(opt Options) (*Game, error) { } g.State = st 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 { g.savePath = path } @@ -233,10 +240,21 @@ func (g *Game) runOptions(id string, seed rng.Seed) sim.RunOptions { return sim.RunOptions{ Dict: g.Assets.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed, Extra: eff.Stats, FogRadius: eff.FogRadius, Landmarks: eff.Landmarks, FreeGates: eff.FreeGates, - RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery, + 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 // laddad omgång ger samma slump. func (g *Game) startLevel(id string) (*LevelScene, error) { diff --git a/internal/game/input.go b/internal/game/input.go index 24fc4cb..cb0f0cc 100644 --- a/internal/game/input.go +++ b/internal/game/input.go @@ -126,6 +126,9 @@ func shopJustPressed() bool { return virtual.pressed["b"] || inpututil.IsKeyJust // phoneJustPressed: P = kortet i telefonen av/på (meny). 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å. func mapJustPressed() bool { return virtual.pressed["m"] || inpututil.IsKeyJustPressed(ebiten.KeyM) || gamepadJustPressed(ebiten.StandardGamepadButtonCenterLeft) diff --git a/internal/game/minimap.go b/internal/game/minimap.go index f4e8230..4180a5b 100644 --- a/internal/game/minimap.go +++ b/internal/game/minimap.go @@ -24,6 +24,10 @@ var minimapColors = map[level.Tile]color.RGBA{ level.Spawn: {99, 199, 77, 255}, level.Platform: {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.TicketMachine: {247, 118, 34, 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) 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 } vector.DrawFilledRect(screen, float32(x0+x*sc), float32(y0+y*sc), float32(sc), float32(sc), c, false) diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go index bae8a35..f185c6c 100644 --- a/internal/game/scene_level.go +++ b/internal/game/scene_level.go @@ -15,6 +15,7 @@ import ( "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/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/sim" ) @@ -104,6 +105,31 @@ func (s *LevelScene) RideAtDest() bool { 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() { x, y := s.run.Player.WorldPos(TileSize) 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}) 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: switch m.Stage { 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 } lines, accent, bar := s.infoLines(a) + if len(lines) == 0 { + s.drawModeHint(a, screen) + } if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec { lines = append(lines, plainLine(fmt.Sprintf("%s: %s = %s (%s)", a.T("hud.learned"), r.Learned, entry.Translation(UILang), entry.Romaji), colKnown)) @@ -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 case level.TicketMachine: - if lvl.TicketRequired { - return []line{plainLine(fmt.Sprintf(a.T("hud.machine_ticket"), r.Wallet.Cash), colGood)}, colGood, false + // Terminalen: linjetavlan för stationen (nätkopplade banor), läst genom kunskapen som en skylt. + 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 { key := "hud.gate_denied" diff --git a/internal/game/scene_map.go b/internal/game/scene_map.go index f13972f..b85addb 100644 --- a/internal/game/scene_map.go +++ b/internal/game/scene_map.go @@ -101,6 +101,8 @@ func (m *MapScene) Update(g *Game) error { case phoneJustPressed(): g.Wallet.Phone = !g.Wallet.Phone g.Save() + case modeJustPressed(): + g.toggleMode() } if n := len(m.mains); n > 0 { 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, a.T("hud.cash"), g.Wallet.Cash) 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 { var parts []string for _, d := range g.Profile.Debuffs { diff --git a/internal/game/scene_result.go b/internal/game/scene_result.go index 9e420d1..1c49ed0 100644 --- a/internal/game/scene_result.go +++ b/internal/game/scene_result.go @@ -62,8 +62,12 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { y += LineH if run.Outcome == sim.Lost { reason := a.T(run.LostReason) - if run.LostReason == sim.LostWrongTrain { - reason = fmt.Sprintf(a.T("result.wrong_detail"), tr.Destination.Get("romaji"), run.Level.Destination.Get("romaji")) + if detail, ok := map[string]string{ + 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) { a.drawLines(screen, []line{l}, 28, y) @@ -80,11 +84,26 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { y += LineH if run.Outcome == sim.Won { sc := run.Score - a.Draw(screen, fmt.Sprintf("%s %d", a.T("result.score"), sc.Total), 28, y, colText) - y += LineH - 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, - 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) - y += LineH + // Poängen som en uträkning; radbryts till högst två rader. + parts := []string{ + fmt.Sprintf("%s %d", strings.ToLower(a.T("hud.time")), sc.Time), fmt.Sprintf("%s %d", a.T("result.money"), sc.Money), + 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 + } msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("result.lang_points")) if len(run.LearnedDest) > 0 { msg += " " + a.T("hud.learned") + ": " + strings.Join(run.LearnedDest, " ") + " (" + a.T("result.destination") + ")" diff --git a/internal/game/script.go b/internal/game/script.go index f3aa2ad..5a20c52 100644 --- a/internal/game/script.go +++ b/internal/game/script.go @@ -93,7 +93,7 @@ func parseScript(src string) (*script, error) { return nil, fmt.Errorf("script: '%s TEXT'", f[0]) } st.arg = f[1] - case "quit", "waittrain", "waitstop": + case "quit", "waittrain", "waitstop", "waitover": default: return nil, fmt.Errorf("script: okänt kommando %q", f[0]) } @@ -126,6 +126,7 @@ type playerTiler interface { type trainWaiter interface { TrainHere() bool RideAtDest() bool + LevelOver() bool MiniPick(text string) bool 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() { s.advance() } + case "waitover": + s.counter++ + if tw, ok := g.scene.(trainWaiter); !ok || tw.LevelOver() || s.counter > 60*120 { + s.advance() + } case "waitphase": s.counter++ if tw, ok := g.scene.(trainWaiter); !ok || tw.TrackPhaseName() == st.arg || s.counter > 60*60 { diff --git a/internal/game/trains.go b/internal/game/trains.go index e4f1318..efcde97 100644 --- a/internal/game/trains.go +++ b/internal/game/trains.go @@ -36,7 +36,7 @@ type trackInfo struct { func (s *LevelScene) buildTracks() { s.tracks = nil 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) { tt, horizontal := s.run.Level.TrackTile(p) s.tracks = append(s.tracks, trackInfo{tile: t, platform: p, track: tt, horizontal: horizontal}) diff --git a/internal/lang/data_test.go b/internal/lang/data_test.go index 882f359..e36a365 100644 --- a/internal/lang/data_test.go +++ b/internal/lang/data_test.go @@ -7,6 +7,7 @@ import ( "testing" "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" ) @@ -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, ", ")) + } + } +} diff --git a/internal/level/level.go b/internal/level/level.go index fcb5999..a3c9254 100644 --- a/internal/level/level.go +++ b/internal/level/level.go @@ -14,6 +14,8 @@ import ( "path/filepath" "sort" "strings" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav" ) // Tile är ett tecken i rutnätet. @@ -24,8 +26,12 @@ const ( Wall Tile = '#' // vägg, ej passerbar Floor Tile = '.' // golv Spawn Tile = 'S' // startpunkt (exakt en) - Platform Tile = 'P' // perrong med ett tåg (målet om tåget är rätt) - WrongPlatform Tile = 'p' // perrong med fel tåg + Platform Tile = 'P' // perrong med ett tåg (vilket tåg som är rätt avgör tåget, inte tecknet) + 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) TicketMachine Tile = 'T' // biljett-/påfyllningsautomat Sign Tile = 'i' // skylt @@ -37,12 +43,26 @@ const ( 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{ 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, 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. 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" Line Text `json:"line"` // linjens namn 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) Cars int `json:"cars"` // antal vagnar (0 = DefaultCars) 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 WomenOnlyTo int `json:"womenOnlyTo"` // …till den här bantiden (s) 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. @@ -232,6 +256,8 @@ type Level struct { Name Text `json:"name"` Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår) 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 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) @@ -245,6 +271,8 @@ type Level struct { Floors []Storey `json:"floors,omitempty"` // flervåningsbana; alla våningar lika stora Trains []Train `json:"trains"` 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. @@ -393,11 +421,12 @@ func (l *Level) Validate() error { if !knownTiles[t] { 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 { case Spawn: spawns++ - case Platform, WrongPlatform: - platforms++ case StairsUp: 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) @@ -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) } 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 { return fmt.Errorf("level %s: saknar tåg", l.ID) @@ -422,7 +451,7 @@ func (l *Level) Validate() error { correct := 0 for i := range l.Trains { tr := &l.Trains[i] - if len(tr.Platform) != 1 || (Tile(tr.Platform[0]) != Platform && Tile(tr.Platform[0]) != WrongPlatform) { + if len(tr.Platform) != 1 || !Tile(tr.Platform[0]).IsPlatform() { 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 { @@ -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) } } - if correct == 0 { + if correct == 0 && !l.Networked() { return fmt.Errorf("level %s: inget tåg är markerat som rätt", l.ID) } if l.FareYen == 0 { @@ -623,7 +652,7 @@ func (l *Level) UngatedPlatforms() []Point { func (l *Level) Unreachable() []Point { reach := l.Reachable() 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) { if !reach[p] { out = append(out, p) @@ -677,28 +706,106 @@ func (l *Level) TrainAt(t Tile) *Train { 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) { b, err := os.ReadFile(path) if err != nil { 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. 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) { ids, err := List(dir) if err != nil { 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 for _, id := range ids { - l, err := LoadFile(filepath.Join(dir, id+".json")) + b, err := os.ReadFile(filepath.Join(dir, id+".json")) 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 } out = append(out, l) diff --git a/internal/level/level_test.go b/internal/level/level_test.go index 5202fe5..b7d7ccb 100644 --- a/internal/level/level_test.go +++ b/internal/level/level_test.go @@ -2,12 +2,12 @@ package level import ( "math" - "os" "path/filepath" "sort" "strings" "testing" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res" ) @@ -122,11 +122,7 @@ func TestAllShippedLevels(t *testing.T) { } for _, f := range files { t.Run(filepath.Base(f), func(t *testing.T) { - b, err := os.ReadFile(f) - if err != nil { - t.Fatal(err) - } - l, err := Parse(b) + 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) } @@ -146,6 +142,18 @@ func TestAllShippedLevels(t *testing.T) { if tr.Track == "" { 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) { 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) { // Perrong utan spärr framför -> rapporteras. l := mustParse(t, sample) diff --git a/internal/minigame/ride.go b/internal/minigame/ride.go index 07ed416..aba768c 100644 --- a/internal/minigame/ride.go +++ b/internal/minigame/ride.go @@ -34,23 +34,42 @@ func NewRide(seed rng.Seed, dest string, fillers []string, stops int, correctTra f := append([]string(nil), fillers...) r.shuffle(len(f), func(i, j int) { f[i], f[j] = f[j], f[i] }) stops = max(2, stops) - crowd = max(0, min(2, crowd)) - sec := RideStopSec(crowd) - g := &Ride{StopSec: sec, Timer: sec, GotOff: -1, Dest: -1, Crowd: crowd} + di := -1 if correctTrain { - g.Dest = 1 + r.intn(stops-1) + di = 1 + r.intn(stops-1) } + var names []string for i := 0; i < stops; i++ { switch { - case i == g.Dest: - g.Stops = append(g.Stops, dest) + case i == di: + names = append(names, dest) case len(f) > 0: - g.Stops = append(g.Stops, f[0]) + names = append(names, f[0]) f = f[1:] 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)) chance := [...]float64{0, 0.4, 0.7}[crowd] for i := range g.Stops { diff --git a/internal/nav/nav.go b/internal/nav/nav.go new file mode 100644 index 0000000..c076aad --- /dev/null +++ b/internal/nav/nav.go @@ -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 +} diff --git a/internal/nav/nav_test.go b/internal/nav/nav_test.go new file mode 100644 index 0000000..4ac9152 --- /dev/null +++ b/internal/nav/nav_test.go @@ -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) + } +} diff --git a/internal/save/save.go b/internal/save/save.go index 14bfea1..5469fd5 100644 --- a/internal/save/save.go +++ b/internal/save/save.go @@ -39,6 +39,7 @@ type State struct { Profile *sim.Profile `json:"profile"` Progress *progress.Progress `json:"progress"` Current *RunRef `json:"current,omitempty"` + NavMode string `json:"navMode,omitempty"` // "list" (standard) eller "signs" } // New är ett nytt spel. diff --git a/internal/sim/fog.go b/internal/sim/fog.go index 0d96861..510b89f 100644 --- a/internal/sim/fog.go +++ b/internal/sim/fog.go @@ -84,7 +84,7 @@ func (f *Fog) Reveal(l *level.Level, from level.Point) { // starten och spärrarna – det man orienterar sig efter. func Landmark(l *level.Level, p level.Point) bool { 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 false diff --git a/internal/sim/nav_test.go b/internal/sim/nav_test.go new file mode 100644 index 0000000..57e8c4d --- /dev/null +++ b/internal/sim/nav_test.go @@ -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") + } +} diff --git a/internal/sim/npc.go b/internal/sim/npc.go index 05819d4..b602565 100644 --- a/internal/sim/npc.go +++ b/internal/sim/npc.go @@ -41,7 +41,7 @@ const ( // npcTargets är rutor som folk vill till. func npcTargets(l *level.Level) []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)...) } return out diff --git a/internal/sim/run.go b/internal/sim/run.go index f3bd6e6..5a310ac 100644 --- a/internal/sim/run.go +++ b/internal/sim/run.go @@ -7,6 +7,7 @@ import ( "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/minigame" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav" "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 Outcome Outcome 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. Dict *lang.Dictionary @@ -120,9 +122,12 @@ const ( // ScoreBreakdown är poängen för en vunnen bana. 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. const ( ScoreTimeMax = 500 // par eller snabbare @@ -163,9 +168,11 @@ const ( // Förlustorsaker (textnycklar). const ( - LostWrongTrain = "result.lost_wrong_train" - LostWrongStop = "result.lost_wrong_stop" - LostMissedStop = "result.lost_missed_stop" + 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" + LostMissedStop = "result.lost_missed_stop" ) // Flash-nycklar. @@ -204,6 +211,8 @@ type RunOptions struct { FreeGates bool // spärrarna kostar inget och kräver inget RevealAll bool // hela banan känd från start InfiniteBattery bool // telefonen laddar inte ur + + Mode nav.Mode // navigeringsläge (nollvärdet = listläge) } // 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.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) } 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)) r.parSec = float64(max(0, n-1))*ParSecPerTile + ParSlackSec } + r.Mode = o.Mode 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 } 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 } +// 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 // under tiden men spelaren står still. 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(): r.finish(tr, true, "") 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: r.finish(tr, false, LostWrongStop) default: @@ -739,7 +834,11 @@ func (r *Run) computeScore() { if r.CarChosen != nil && r.CarChosen.Crowd == 0 { 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 } @@ -770,7 +869,7 @@ func (r *Run) earnedDebuffs() []Debuff { // stepNPCs flyttar folket, låter avstigande kliva av när dörrarna öppnas och // tar bort dem som nått utgången. func (r *Run) stepNPCs(dt float64) { - for _, t := range []level.Tile{level.Platform, level.WrongPlatform} { + for _, t := range level.PlatformTiles { ph, _ := r.PlatformPhase(t) if r.platformPhase[t] != ph { r.platformPhase[t] = ph @@ -806,7 +905,7 @@ func (r *Run) FacilityProgress() float64 { // till avgång respektive ankomst. func (r *Run) PlatformHere() (tr *level.Train, atPlatform bool, seconds float64) { t := r.Level.At(r.Player.Tile) - if t != level.Platform && t != level.WrongPlatform { + if !t.IsPlatform() { return nil, false, 0 } 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. func (r *Run) NearTrack(maxDist int) (tr *level.Train, platform level.Point, phase level.Phase, progress float64, dist int, ok bool) { best := maxDist + 1 - for _, t := range []level.Tile{level.Platform, level.WrongPlatform} { + for _, t := range level.PlatformTiles { for _, p := range r.Level.Find(t) { if p.Z != r.Player.Tile.Z { continue // man ser och hör bara tågen på sin egen våning diff --git a/resources/data/levels/001-flygplatsen.json b/resources/data/levels/001-flygplatsen.json index a9f6647..462cb9d 100644 --- a/resources/data/levels/001-flygplatsen.json +++ b/resources/data/levels/001-flygplatsen.json @@ -49,13 +49,14 @@ "romaji": "Shinjuku", "en": "Shinjuku" }, - "correct": true, "track": "2", "schedule": { "offsetSec": 0, "intervalSec": 30, "dwellSec": 12 - } + }, + "lineId": "narita-express", + "terminus": "shinjuku" }, { "platform": "p", @@ -69,13 +70,14 @@ "romaji": "Keisei-Ueno", "en": "Keisei-Ueno" }, - "correct": false, "track": "1", "schedule": { "offsetSec": 8, "intervalSec": 30, "dwellSec": 12 - } + }, + "lineId": "keisei-main", + "terminus": "keisei-ueno" } ], "signs": [ @@ -97,5 +99,7 @@ "ja": "改札 ← 1番線 京成線 京成上野方面 → 2番線 成田エクスプレス 新宿方面" } } - ] + ], + "station": "narita-airport", + "to": "shinjuku" } diff --git a/resources/data/levels/002-shinjuku.json b/resources/data/levels/002-shinjuku.json index 2d575e6..1da786e 100644 --- a/resources/data/levels/002-shinjuku.json +++ b/resources/data/levels/002-shinjuku.json @@ -53,7 +53,6 @@ ], "trains": [ { - "correct": true, "crowded": true, "destination": { "en": "Shibuya", @@ -71,10 +70,11 @@ "intervalSec": 20, "offsetSec": 3 }, - "track": "14" + "track": "14", + "lineId": "yamanote", + "terminus": "shibuya" }, { - "correct": false, "destination": { "en": "Tokyo", "ja": "東京", @@ -91,7 +91,9 @@ "intervalSec": 25, "offsetSec": 10 }, - "track": "8" + "track": "8", + "lineId": "chuo", + "terminus": "tokyo" } ], "signs": [ @@ -122,5 +124,7 @@ "x": 11, "y": 5 } - ] + ], + "station": "shinjuku", + "to": "shibuya" } diff --git a/resources/data/levels/003-shibuya.json b/resources/data/levels/003-shibuya.json index fb96d03..bedd0c2 100644 --- a/resources/data/levels/003-shibuya.json +++ b/resources/data/levels/003-shibuya.json @@ -55,7 +55,6 @@ ], "trains": [ { - "correct": true, "crowded": true, "destination": { "en": "Ikebukuro", @@ -73,10 +72,11 @@ "intervalSec": 25, "offsetSec": 6 }, - "track": "2" + "track": "2", + "lineId": "yamanote", + "terminus": "ikebukuro" }, { - "correct": false, "destination": { "en": "Yokohama", "ja": "横浜", @@ -93,7 +93,9 @@ "intervalSec": 30, "offsetSec": 12 }, - "track": "4" + "track": "4", + "lineId": "toyoko", + "terminus": "yokohama" } ], "signs": [ @@ -124,5 +126,7 @@ "x": 29, "y": 13 } - ] + ], + "station": "shibuya", + "to": "ikebukuro" } diff --git a/resources/data/levels/004-tokyo.json b/resources/data/levels/004-tokyo.json index 2d928b7..7618707 100644 --- a/resources/data/levels/004-tokyo.json +++ b/resources/data/levels/004-tokyo.json @@ -92,9 +92,9 @@ }, { "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番線 山手線 上野方面 →", - "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, "y": 3, @@ -115,7 +115,6 @@ "timeLimitSec": 420, "trains": [ { - "correct": true, "crowded": true, "destination": { "en": "Ueno", @@ -133,10 +132,11 @@ "intervalSec": 24, "offsetSec": 5 }, - "track": "4" + "track": "4", + "lineId": "yamanote", + "terminus": "ueno" }, { - "correct": false, "destination": { "en": "Shinagawa", "ja": "品川", @@ -153,7 +153,11 @@ "intervalSec": 28, "offsetSec": 11 }, - "track": "7" + "track": "7", + "lineId": "tokaido", + "terminus": "shinagawa" } - ] -} \ No newline at end of file + ], + "station": "tokyo", + "to": "ueno" +} diff --git a/resources/data/levels/005-ueno.json b/resources/data/levels/005-ueno.json index 4b5186c..0f02b1f 100644 --- a/resources/data/levels/005-ueno.json +++ b/resources/data/levels/005-ueno.json @@ -11,23 +11,23 @@ "fareYen": 200, "grid": [ "#################################", - "#S.\u003c..\u003c..#..\u003c..\u003c..\u003c..\u003c..#..\u003c..###", - "#..\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..###", - "#.i\u003e..\u003e..#..\u003e..\u003e..\u003e..#..\u003eK.\u003e..###", - "#\u003c\u003e#############\u003c\u003e#######\u003c\u003e#\u003c\u003e###", - "#..#..\u003c..\u003c..\u003c.i\u003c..\u003c..\u003c..#..\u003c..###", - "#..#..\u003e..\u003e..\u003e..\u003e..\u003e..\u003e..#.T\u003ei.###", - "#\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###", - "#pG\u003e..\u003e..\u003e..#..\u003e..\u003e..\u003e..\u003e..\u003eGP###", + "#S.<..<..#..<..<..<..<..#..<..###", + "#..>..>..#..>..>..>..>..#..>..###", + "#######<>#<>#<>####<>#<>####<>###", + "#..<..<..#.W<..<..<..#..<..<..###", + "#.i>..>..#..>..>..>..#..>K.>..###", + "#<>#############<>#######<>#<>###", + "#..#..<..<..<.i<..<..<..#..<..###", + "#..#..>..>..>..>..>..>..#.T>i.###", + "#<>####<>##########<>#<>#<>######", + "#..<..<..<..#..<..<..#..#..<..###", + "#..>..>..>..#..>..>..#..#..>..###", + "####<>#<>#<>#<>####<>#######<>###", + "#..<..#..#..#..<..#..<..<..<..###", + "#..>..#..#..#..>..#..>..>..>..###", + "#<>#######<>####<>###############", + "#G.<..<..<..#..<..<..<..<..<.G###", + "#pG>..>..>..#..>..>..>..>..>GP###", "#################################", "#################################", "#################################" @@ -64,9 +64,9 @@ }, { "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番線 山手線 秋葉原方面 →", - "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, "y": 7, @@ -87,7 +87,6 @@ "trains": [ { "cars": 4, - "correct": true, "destination": { "en": "Akihabara", "ja": "秋葉原", @@ -107,11 +106,12 @@ "track": "3", "womenOnlyCar": 1, "womenOnlyFrom": 0, - "womenOnlyTo": 150 + "womenOnlyTo": 150, + "lineId": "yamanote", + "terminus": "tokyo" }, { "cars": 3, - "correct": false, "destination": { "en": "Omiya", "ja": "大宮", @@ -128,7 +128,11 @@ "intervalSec": 30, "offsetSec": 13 }, - "track": "9" + "track": "9", + "lineId": "keihin-tohoku", + "terminus": "omiya" } - ] -} \ No newline at end of file + ], + "station": "ueno", + "to": "akihabara" +} diff --git a/resources/data/levels/006-ikebukuro.json b/resources/data/levels/006-ikebukuro.json new file mode 100644 index 0000000..84b8b72 --- /dev/null +++ b/resources/data/levels/006-ikebukuro.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/resources/data/network.json b/resources/data/network.json new file mode 100644 index 0000000..c6f0f53 --- /dev/null +++ b/resources/data/network.json @@ -0,0 +1,1009 @@ +{ + "_comment": "Linjenätet för navigeringen (internal/nav): stationer med namn och linjer i stationsordning. loop = ringlinje. Banorna pekar hit med station/to/lineId/terminus; då räknas rätt/fel tåg ut härifrån.", + "stations": [ + { + "id": "tokyo", + "name": { + "ja": "東京", + "kana": "とうきょう", + "romaji": "Tokyo", + "en": "Tokyo", + "sv": "Tokyo" + } + }, + { + "id": "kanda", + "name": { + "ja": "神田", + "kana": "かんだ", + "romaji": "Kanda", + "en": "Kanda", + "sv": "Kanda" + } + }, + { + "id": "akihabara", + "name": { + "ja": "秋葉原", + "kana": "あきはばら", + "romaji": "Akihabara", + "en": "Akihabara", + "sv": "Akihabara" + } + }, + { + "id": "okachimachi", + "name": { + "ja": "御徒町", + "kana": "おかちまち", + "romaji": "Okachimachi", + "en": "Okachimachi", + "sv": "Okachimachi" + } + }, + { + "id": "ueno", + "name": { + "ja": "上野", + "kana": "うえの", + "romaji": "Ueno", + "en": "Ueno", + "sv": "Ueno" + } + }, + { + "id": "uguisudani", + "name": { + "ja": "鶯谷", + "kana": "うぐいすだに", + "romaji": "Uguisudani", + "en": "Uguisudani", + "sv": "Uguisudani" + } + }, + { + "id": "nippori", + "name": { + "ja": "日暮里", + "kana": "にっぽり", + "romaji": "Nippori", + "en": "Nippori", + "sv": "Nippori" + } + }, + { + "id": "nishi-nippori", + "name": { + "ja": "西日暮里", + "kana": "にしにっぽり", + "romaji": "Nishi-Nippori", + "en": "Nishi-Nippori", + "sv": "Nishi-Nippori" + } + }, + { + "id": "tabata", + "name": { + "ja": "田端", + "kana": "たばた", + "romaji": "Tabata", + "en": "Tabata", + "sv": "Tabata" + } + }, + { + "id": "komagome", + "name": { + "ja": "駒込", + "kana": "こまごめ", + "romaji": "Komagome", + "en": "Komagome", + "sv": "Komagome" + } + }, + { + "id": "sugamo", + "name": { + "ja": "巣鴨", + "kana": "すがも", + "romaji": "Sugamo", + "en": "Sugamo", + "sv": "Sugamo" + } + }, + { + "id": "otsuka", + "name": { + "ja": "大塚", + "kana": "おおつか", + "romaji": "Otsuka", + "en": "Otsuka", + "sv": "Otsuka" + } + }, + { + "id": "ikebukuro", + "name": { + "ja": "池袋", + "kana": "いけぶくろ", + "romaji": "Ikebukuro", + "en": "Ikebukuro", + "sv": "Ikebukuro" + } + }, + { + "id": "mejiro", + "name": { + "ja": "目白", + "kana": "めじろ", + "romaji": "Mejiro", + "en": "Mejiro", + "sv": "Mejiro" + } + }, + { + "id": "takadanobaba", + "name": { + "ja": "高田馬場", + "kana": "たかだのばば", + "romaji": "Takadanobaba", + "en": "Takadanobaba", + "sv": "Takadanobaba" + } + }, + { + "id": "shin-okubo", + "name": { + "ja": "新大久保", + "kana": "しんおおくぼ", + "romaji": "Shin-Okubo", + "en": "Shin-Okubo", + "sv": "Shin-Okubo" + } + }, + { + "id": "shinjuku", + "name": { + "ja": "新宿", + "kana": "しんじゅく", + "romaji": "Shinjuku", + "en": "Shinjuku", + "sv": "Shinjuku" + } + }, + { + "id": "yoyogi", + "name": { + "ja": "代々木", + "kana": "よよぎ", + "romaji": "Yoyogi", + "en": "Yoyogi", + "sv": "Yoyogi" + } + }, + { + "id": "harajuku", + "name": { + "ja": "原宿", + "kana": "はらじゅく", + "romaji": "Harajuku", + "en": "Harajuku", + "sv": "Harajuku" + } + }, + { + "id": "shibuya", + "name": { + "ja": "渋谷", + "kana": "しぶや", + "romaji": "Shibuya", + "en": "Shibuya", + "sv": "Shibuya" + } + }, + { + "id": "ebisu", + "name": { + "ja": "恵比寿", + "kana": "えびす", + "romaji": "Ebisu", + "en": "Ebisu", + "sv": "Ebisu" + } + }, + { + "id": "meguro", + "name": { + "ja": "目黒", + "kana": "めぐろ", + "romaji": "Meguro", + "en": "Meguro", + "sv": "Meguro" + } + }, + { + "id": "gotanda", + "name": { + "ja": "五反田", + "kana": "ごたんだ", + "romaji": "Gotanda", + "en": "Gotanda", + "sv": "Gotanda" + } + }, + { + "id": "osaki", + "name": { + "ja": "大崎", + "kana": "おおさき", + "romaji": "Osaki", + "en": "Osaki", + "sv": "Osaki" + } + }, + { + "id": "shinagawa", + "name": { + "ja": "品川", + "kana": "しながわ", + "romaji": "Shinagawa", + "en": "Shinagawa", + "sv": "Shinagawa" + } + }, + { + "id": "takanawa-gateway", + "name": { + "ja": "高輪ゲートウェイ", + "kana": "たかなわげーとうぇい", + "romaji": "Takanawa Gateway", + "en": "Takanawa Gateway", + "sv": "Takanawa Gateway" + } + }, + { + "id": "tamachi", + "name": { + "ja": "田町", + "kana": "たまち", + "romaji": "Tamachi", + "en": "Tamachi", + "sv": "Tamachi" + } + }, + { + "id": "hamamatsucho", + "name": { + "ja": "浜松町", + "kana": "はままつちょう", + "romaji": "Hamamatsucho", + "en": "Hamamatsucho", + "sv": "Hamamatsucho" + } + }, + { + "id": "shimbashi", + "name": { + "ja": "新橋", + "kana": "しんばし", + "romaji": "Shimbashi", + "en": "Shimbashi", + "sv": "Shimbashi" + } + }, + { + "id": "yurakucho", + "name": { + "ja": "有楽町", + "kana": "ゆうらくちょう", + "romaji": "Yurakucho", + "en": "Yurakucho", + "sv": "Yurakucho" + } + }, + { + "id": "ochanomizu", + "name": { + "ja": "御茶ノ水", + "kana": "おちゃのみず", + "romaji": "Ochanomizu", + "en": "Ochanomizu", + "sv": "Ochanomizu" + } + }, + { + "id": "yotsuya", + "name": { + "ja": "四ツ谷", + "kana": "よつや", + "romaji": "Yotsuya", + "en": "Yotsuya", + "sv": "Yotsuya" + } + }, + { + "id": "nakano", + "name": { + "ja": "中野", + "kana": "なかの", + "romaji": "Nakano", + "en": "Nakano", + "sv": "Nakano" + } + }, + { + "id": "kichijoji", + "name": { + "ja": "吉祥寺", + "kana": "きちじょうじ", + "romaji": "Kichijoji", + "en": "Kichijoji", + "sv": "Kichijoji" + } + }, + { + "id": "mitaka", + "name": { + "ja": "三鷹", + "kana": "みたか", + "romaji": "Mitaka", + "en": "Mitaka", + "sv": "Mitaka" + } + }, + { + "id": "tachikawa", + "name": { + "ja": "立川", + "kana": "たちかわ", + "romaji": "Tachikawa", + "en": "Tachikawa", + "sv": "Tachikawa" + } + }, + { + "id": "hachioji", + "name": { + "ja": "八王子", + "kana": "はちおうじ", + "romaji": "Hachioji", + "en": "Hachioji", + "sv": "Hachioji" + } + }, + { + "id": "takao", + "name": { + "ja": "高尾", + "kana": "たかお", + "romaji": "Takao", + "en": "Takao", + "sv": "Takao" + } + }, + { + "id": "omiya", + "name": { + "ja": "大宮", + "kana": "おおみや", + "romaji": "Omiya", + "en": "Omiya", + "sv": "Omiya" + } + }, + { + "id": "urawa", + "name": { + "ja": "浦和", + "kana": "うらわ", + "romaji": "Urawa", + "en": "Urawa", + "sv": "Urawa" + } + }, + { + "id": "akabane", + "name": { + "ja": "赤羽", + "kana": "あかばね", + "romaji": "Akabane", + "en": "Akabane", + "sv": "Akabane" + } + }, + { + "id": "oji", + "name": { + "ja": "王子", + "kana": "おうじ", + "romaji": "Oji", + "en": "Oji", + "sv": "Oji" + } + }, + { + "id": "kawasaki", + "name": { + "ja": "川崎", + "kana": "かわさき", + "romaji": "Kawasaki", + "en": "Kawasaki", + "sv": "Kawasaki" + } + }, + { + "id": "yokohama", + "name": { + "ja": "横浜", + "kana": "よこはま", + "romaji": "Yokohama", + "en": "Yokohama", + "sv": "Yokohama" + } + }, + { + "id": "ofuna", + "name": { + "ja": "大船", + "kana": "おおふな", + "romaji": "Ofuna", + "en": "Ofuna", + "sv": "Ofuna" + } + }, + { + "id": "narita-airport", + "name": { + "ja": "成田空港", + "kana": "なりたくうこう", + "romaji": "Narita Airport", + "en": "Narita Airport", + "sv": "Narita Airport" + } + }, + { + "id": "airport-terminal-2", + "name": { + "ja": "空港第2ビル", + "kana": "くうこうだいにびる", + "romaji": "Airport Terminal 2", + "en": "Airport Terminal 2", + "sv": "Airport Terminal 2" + } + }, + { + "id": "narita", + "name": { + "ja": "成田", + "kana": "なりた", + "romaji": "Narita", + "en": "Narita", + "sv": "Narita" + } + }, + { + "id": "funabashi", + "name": { + "ja": "船橋", + "kana": "ふなばし", + "romaji": "Funabashi", + "en": "Funabashi", + "sv": "Funabashi" + } + }, + { + "id": "aoto", + "name": { + "ja": "青砥", + "kana": "あおと", + "romaji": "Aoto", + "en": "Aoto", + "sv": "Aoto" + } + }, + { + "id": "keisei-ueno", + "name": { + "ja": "京成上野", + "kana": "けいせいうえの", + "romaji": "Keisei-Ueno", + "en": "Keisei-Ueno", + "sv": "Keisei-Ueno" + } + }, + { + "id": "jujo", + "name": { + "ja": "十条", + "kana": "じゅうじょう", + "romaji": "Jujo", + "en": "Jujo", + "sv": "Jujo" + } + }, + { + "id": "itabashi", + "name": { + "ja": "板橋", + "kana": "いたばし", + "romaji": "Itabashi", + "en": "Itabashi", + "sv": "Itabashi" + } + }, + { + "id": "ogikubo", + "name": { + "ja": "荻窪", + "kana": "おぎくぼ", + "romaji": "Ogikubo", + "en": "Ogikubo", + "sv": "Ogikubo" + } + }, + { + "id": "shinjuku-sanchome", + "name": { + "ja": "新宿三丁目", + "kana": "しんじゅくさんちょうめ", + "romaji": "Shinjuku-sanchome", + "en": "Shinjuku-sanchome", + "sv": "Shinjuku-sanchome" + } + }, + { + "id": "shinjuku-gyoemmae", + "name": { + "ja": "新宿御苑前", + "kana": "しんじゅくぎょえんまえ", + "romaji": "Shinjuku-gyoemmae", + "en": "Shinjuku-gyoemmae", + "sv": "Shinjuku-gyoemmae" + } + }, + { + "id": "yotsuya-sanchome", + "name": { + "ja": "四谷三丁目", + "kana": "よつやさんちょうめ", + "romaji": "Yotsuya-sanchome", + "en": "Yotsuya-sanchome", + "sv": "Yotsuya-sanchome" + } + }, + { + "id": "akasaka-mitsuke", + "name": { + "ja": "赤坂見附", + "kana": "あかさかみつけ", + "romaji": "Akasaka-mitsuke", + "en": "Akasaka-mitsuke", + "sv": "Akasaka-mitsuke" + } + }, + { + "id": "kokkai-gijidomae", + "name": { + "ja": "国会議事堂前", + "kana": "こっかいぎじどうまえ", + "romaji": "Kokkai-gijidomae", + "en": "Kokkai-gijidomae", + "sv": "Kokkai-gijidomae" + } + }, + { + "id": "kasumigaseki", + "name": { + "ja": "霞ケ関", + "kana": "かすみがせき", + "romaji": "Kasumigaseki", + "en": "Kasumigaseki", + "sv": "Kasumigaseki" + } + }, + { + "id": "ginza", + "name": { + "ja": "銀座", + "kana": "ぎんざ", + "romaji": "Ginza", + "en": "Ginza", + "sv": "Ginza" + } + }, + { + "id": "otemachi", + "name": { + "ja": "大手町", + "kana": "おおてまち", + "romaji": "Otemachi", + "en": "Otemachi", + "sv": "Otemachi" + } + }, + { + "id": "awajicho", + "name": { + "ja": "淡路町", + "kana": "あわじちょう", + "romaji": "Awajicho", + "en": "Awajicho", + "sv": "Awajicho" + } + }, + { + "id": "hongo-sanchome", + "name": { + "ja": "本郷三丁目", + "kana": "ほんごうさんちょうめ", + "romaji": "Hongo-sanchome", + "en": "Hongo-sanchome", + "sv": "Hongo-sanchome" + } + }, + { + "id": "korakuen", + "name": { + "ja": "後楽園", + "kana": "こうらくえん", + "romaji": "Korakuen", + "en": "Korakuen", + "sv": "Korakuen" + } + }, + { + "id": "myogadani", + "name": { + "ja": "茗荷谷", + "kana": "みょうがだに", + "romaji": "Myogadani", + "en": "Myogadani", + "sv": "Myogadani" + } + }, + { + "id": "shin-otsuka", + "name": { + "ja": "新大塚", + "kana": "しんおおつか", + "romaji": "Shin-Otsuka", + "en": "Shin-Otsuka", + "sv": "Shin-Otsuka" + } + }, + { + "id": "naka-meguro", + "name": { + "ja": "中目黒", + "kana": "なかめぐろ", + "romaji": "Naka-Meguro", + "en": "Naka-Meguro", + "sv": "Naka-Meguro" + } + }, + { + "id": "roppongi", + "name": { + "ja": "六本木", + "kana": "ろっぽんぎ", + "romaji": "Roppongi", + "en": "Roppongi", + "sv": "Roppongi" + } + }, + { + "id": "kita-senju", + "name": { + "ja": "北千住", + "kana": "きたせんじゅ", + "romaji": "Kita-Senju", + "en": "Kita-Senju", + "sv": "Kita-Senju" + } + }, + { + "id": "omotesando", + "name": { + "ja": "表参道", + "kana": "おもてさんどう", + "romaji": "Omotesando", + "en": "Omotesando", + "sv": "Omotesando" + } + }, + { + "id": "nihombashi", + "name": { + "ja": "日本橋", + "kana": "にほんばし", + "romaji": "Nihombashi", + "en": "Nihombashi", + "sv": "Nihombashi" + } + }, + { + "id": "asakusa", + "name": { + "ja": "浅草", + "kana": "あさくさ", + "romaji": "Asakusa", + "en": "Asakusa", + "sv": "Asakusa" + } + }, + { + "id": "jiyugaoka", + "name": { + "ja": "自由が丘", + "kana": "じゆうがおか", + "romaji": "Jiyugaoka", + "en": "Jiyugaoka", + "sv": "Jiyugaoka" + } + }, + { + "id": "musashi-kosugi", + "name": { + "ja": "武蔵小杉", + "kana": "むさしこすぎ", + "romaji": "Musashi-Kosugi", + "en": "Musashi-Kosugi", + "sv": "Musashi-Kosugi" + } + }, + { + "id": "kikuna", + "name": { + "ja": "菊名", + "kana": "きくな", + "romaji": "Kikuna", + "en": "Kikuna", + "sv": "Kikuna" + } + } + ], + "lines": [ + { + "id": "yamanote", + "name": { + "ja": "山手線", + "romaji": "Yamanote-sen", + "en": "Yamanote Line" + }, + "stations": [ + "tokyo", + "kanda", + "akihabara", + "okachimachi", + "ueno", + "uguisudani", + "nippori", + "nishi-nippori", + "tabata", + "komagome", + "sugamo", + "otsuka", + "ikebukuro", + "mejiro", + "takadanobaba", + "shin-okubo", + "shinjuku", + "yoyogi", + "harajuku", + "shibuya", + "ebisu", + "meguro", + "gotanda", + "osaki", + "shinagawa", + "takanawa-gateway", + "tamachi", + "hamamatsucho", + "shimbashi", + "yurakucho" + ], + "loop": true + }, + { + "id": "chuo", + "name": { + "ja": "中央線", + "romaji": "Chuo-sen", + "en": "Chuo Line" + }, + "stations": [ + "tokyo", + "kanda", + "ochanomizu", + "yotsuya", + "shinjuku", + "nakano", + "kichijoji", + "mitaka", + "tachikawa", + "hachioji", + "takao" + ] + }, + { + "id": "keihin-tohoku", + "name": { + "ja": "京浜東北線", + "romaji": "Keihin-Tohoku-sen", + "en": "Keihin-Tohoku Line" + }, + "stations": [ + "omiya", + "urawa", + "akabane", + "oji", + "tabata", + "nishi-nippori", + "nippori", + "uguisudani", + "ueno", + "okachimachi", + "akihabara", + "kanda", + "tokyo", + "yurakucho", + "shimbashi", + "hamamatsucho", + "tamachi", + "takanawa-gateway", + "shinagawa", + "kawasaki", + "yokohama", + "ofuna" + ] + }, + { + "id": "narita-express", + "name": { + "ja": "成田エクスプレス", + "romaji": "Narita Ekusupuresu", + "en": "Narita Express" + }, + "stations": [ + "narita-airport", + "airport-terminal-2", + "tokyo", + "shinagawa", + "shibuya", + "shinjuku" + ] + }, + { + "id": "keisei-main", + "name": { + "ja": "京成本線", + "romaji": "Keisei Honsen", + "en": "Keisei Main Line" + }, + "stations": [ + "narita-airport", + "airport-terminal-2", + "narita", + "funabashi", + "aoto", + "nippori", + "keisei-ueno" + ] + }, + { + "id": "saikyo", + "name": { + "ja": "埼京線", + "romaji": "Saikyo-sen", + "en": "Saikyo Line" + }, + "stations": [ + "omiya", + "akabane", + "jujo", + "itabashi", + "ikebukuro", + "shinjuku", + "shibuya", + "ebisu", + "osaki" + ] + }, + { + "id": "marunouchi", + "name": { + "ja": "丸ノ内線", + "romaji": "Marunouchi-sen", + "en": "Marunouchi Line" + }, + "stations": [ + "ogikubo", + "shinjuku", + "shinjuku-sanchome", + "shinjuku-gyoemmae", + "yotsuya-sanchome", + "yotsuya", + "akasaka-mitsuke", + "kokkai-gijidomae", + "kasumigaseki", + "ginza", + "tokyo", + "otemachi", + "awajicho", + "ochanomizu", + "hongo-sanchome", + "korakuen", + "myogadani", + "shin-otsuka", + "ikebukuro" + ] + }, + { + "id": "hibiya", + "name": { + "ja": "日比谷線", + "romaji": "Hibiya-sen", + "en": "Hibiya Line" + }, + "stations": [ + "naka-meguro", + "ebisu", + "roppongi", + "ginza", + "akihabara", + "ueno", + "kita-senju" + ] + }, + { + "id": "ginza", + "name": { + "ja": "銀座線", + "romaji": "Ginza-sen", + "en": "Ginza Line" + }, + "stations": [ + "shibuya", + "omotesando", + "akasaka-mitsuke", + "ginza", + "nihombashi", + "kanda", + "ueno", + "asakusa" + ] + }, + { + "id": "toyoko", + "name": { + "ja": "東急東横線", + "romaji": "Tokyu Toyoko-sen", + "en": "Tokyu Toyoko Line" + }, + "stations": [ + "shibuya", + "naka-meguro", + "jiyugaoka", + "musashi-kosugi", + "kikuna", + "yokohama" + ] + }, + { + "id": "tokaido", + "name": { + "ja": "東海道線", + "romaji": "Tokaido-sen", + "en": "Tokaido Line" + }, + "stations": [ + "tokyo", + "shimbashi", + "shinagawa", + "kawasaki", + "yokohama", + "ofuna" + ] + } + ] +} \ No newline at end of file diff --git a/resources/text/en.json b/resources/text/en.json index 02c115e..f8044be 100644 --- a/resources/text/en.json +++ b/resources/text/en.json @@ -140,7 +140,7 @@ "map.party": "Party", "map.resume": "⟳ resume (saved seed)", "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.to_next": "XP to next", "skills.already": "Already unlocked.", @@ -197,5 +197,16 @@ "hud.early_board": "Let people off first!", "hud.bump": "Sumimasen…", "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." } diff --git a/resources/text/jp-dictionary.json b/resources/text/jp-dictionary.json index 1de6034..9a932ed 100644 --- a/resources/text/jp-dictionary.json +++ b/resources/text/jp-dictionary.json @@ -645,5 +645,528 @@ "en": "passage / keep", "sv": "gång / håll", "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 } -} +} \ No newline at end of file diff --git a/resources/text/sv.json b/resources/text/sv.json index 8ef8235..b8777a1 100644 --- a/resources/text/sv.json +++ b/resources/text/sv.json @@ -140,7 +140,7 @@ "map.party": "Sällskap", "map.resume": "⟳ fortsätt (sparad seed)", "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.to_next": "XP till nästa", "skills.already": "Redan upplåst.", @@ -197,5 +197,16 @@ "hud.early_board": "Låt folk kliva av först!", "hud.bump": "Ursäkta…", "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." } diff --git a/tools/shots/main.go b/tools/shots/main.go index 653b2ce..5f17616 100644 --- a/tools/shots/main.go +++ b/tools/shots/main.go @@ -110,6 +110,18 @@ func buildShots(levelsDir string) error { if err != nil { 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 // delvisa översättningen syns; hjälpen lär sedan in nästa ord. 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}, {"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 + - "; 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"}}, {"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", []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"}}, {"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 }