diff --git a/README.md b/README.md index 5773f3f..aaa3415 100644 --- a/README.md +++ b/README.md @@ -10,17 +10,14 @@ 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:** 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, 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 +**Status:** M10 klar (2026-10-24) – hela storyn från flygplats till +flygplats: åtta huvudbanor och ett sidospår på en bankarta i spelbrädesstil, +med flera våningar, folk, reklam, sorl, vagnval och ett riktigt linjenät med +list- och skyltläge. De två sista resorna har **byten**: man kliver av på en +mellanstation och fortsätter i samma omgång, med klockan, plånboken och +sällskapet i behåll. Klarar man finalen (Tokyo → Nippori → Narita) visas +**hemresan**: planet lyfter, resan summeras och eftertexterna rullar. Nästa +är M10b (bussar) eller M11 (oändligt läge och New Game+). Se [doc/roadmap.md](doc/roadmap.md). ![Bankartan](doc/img/map.png) @@ -74,6 +71,13 @@ 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) +Resorna med byten fortsätter på nästa station när du klivit av: nytt +labyrintgolv, samma klocka och samma hungriga sällskap. + +![Byte i Ueno](doc/img/transfer.png) + +![Hemresan](doc/img/ending.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, @@ -173,6 +177,7 @@ 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 -ending # öppna slutscenen (hemresan) direkt (tester/skärmdumpar) metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go metro-puzzle-jp -version ``` @@ -193,7 +198,8 @@ Y = hjälp, X = ordlista, Back/Select = karta. | Kommando | Gör | |----------|-----| | `go run ./tools/build …` | bygger (se ovan) | -| `go run ./tools/genart` | genererar placeholder-grafiken `resources/sprites/tiles.png` och `player.png` | +| `go run ./tools/genart` | genererar placeholder-grafiken `resources/sprites/tiles.png`, `player.png` och `plane.png` | +| `go run ./tools/levelcheck [ord…]` | ritar varje bana (per våning) som PNG i `build/levelcheck/` och rapporterar lösbarhet, onåbara rutor, perronger utanför spärrar, tågens bedömning i linjenätet och resornas byten; avslutar med fel om något är trasigt | | `go run ./tools/gensound` | genererar placeholder-ljuden `resources/sound/*.wav` (avgångsmelodi, dörrsignal) | | `go run ./tools/shots [ord…]` | tar dokumentationens skärmdumpar till `doc/img/` – **kör efter varje grafik-/UI-ändring och committa bilderna**. Med argument körs bara skott vars bana eller skript innehåller något av orden (t.ex. `go run ./tools/shots 005 ride.png`). På Linux körs spelet på en osynlig skärm (headless Wayfire + Xwayland) så att inget fönster syns eller tar fokus; `METRO_HEADLESS=0` använder skärmen | diff --git a/cmd/metro-puzzle-jp/main.go b/cmd/metro-puzzle-jp/main.go index bf5712d..4f0eb18 100644 --- a/cmd/metro-puzzle-jp/main.go +++ b/cmd/metro-puzzle-jp/main.go @@ -29,6 +29,7 @@ func main() { 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)") + ending := flag.Bool("ending", false, "öppna slutscenen direkt (tester/skärmdumpar)") version := flag.Bool("version", false, "skriv ut version och avsluta") flag.Parse() @@ -37,7 +38,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, Mode: *mode}) + 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, Ending: *ending}) if err != nil { log.Fatalf("kunde inte starta: %v", err) } diff --git a/doc/img/credits.png b/doc/img/credits.png new file mode 100644 index 0000000..3f899ba Binary files /dev/null and b/doc/img/credits.png differ diff --git a/doc/img/ending-summary.png b/doc/img/ending-summary.png new file mode 100644 index 0000000..687ac62 Binary files /dev/null and b/doc/img/ending-summary.png differ diff --git a/doc/img/ending.png b/doc/img/ending.png new file mode 100644 index 0000000..7335a3b Binary files /dev/null and b/doc/img/ending.png differ diff --git a/doc/img/map-signs.png b/doc/img/map-signs.png index f60f5f9..b9aa000 100644 Binary files a/doc/img/map-signs.png and b/doc/img/map-signs.png differ diff --git a/doc/img/map.png b/doc/img/map.png index 54d8890..528d5b7 100644 Binary files a/doc/img/map.png and b/doc/img/map.png differ diff --git a/doc/img/terminal.png b/doc/img/terminal.png index 2345059..4a5ff0d 100644 Binary files a/doc/img/terminal.png and b/doc/img/terminal.png differ diff --git a/doc/img/transfer.png b/doc/img/transfer.png new file mode 100644 index 0000000..78561a7 Binary files /dev/null and b/doc/img/transfer.png differ diff --git a/doc/img/words.png b/doc/img/words.png index 23c42f9..1910f4c 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 ddbe60e..c5343c6 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 | 7 av 7 | +| Banor | 100 % passerar det automatiska lösbarhetstestet | 11 av 11 (inkl. 4 delbanor) | | 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 | @@ -131,6 +131,8 @@ att bygga på Linux som på Windows. | `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/level` | `TestStoryChains` | **M10:** `next`-kedjor validerade (saknad/fel sort/dubbel/cykel/osammanhängande byte ger fel), `ChainOf`/`FinalOf`, storyn börjar och slutar på flygplatsen | +| `internal/sim` | `TestJourneyWithTransfer`, `TestJourneyWrongTrainOnSecondLeg`, `TestChainLevelsLoad` | **M10:** bytet fortsätter omgången på nästa station med klocka, plånbok, sällskap och par-summa; vinst först på sista delbanan; fel håll på bytesstationen förlorar; delbanor laddas enskilt | | `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 | @@ -152,7 +154,8 @@ att bygga på Linux som på Windows. | ~~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~~ ✓ | Nätets bedömningar (rätt/fel håll/för långt/ingen trafik), 1–4 rätta tåg, riktiga stationer på resan, fel-håll- och för-långt-förlust, skyltbonus, terminal – finns, se tabellen ovan | -| M10 Story-innehåll | Alla banor lösbara (redan automatiskt); flerbytesbanor har sammanhängande byten; sista banan slutar på flygplatsen | +| ~~M10 Story-innehåll~~ ✓ | Kedjorna valideras, byten hänger ihop, storyn börjar och slutar på flygplatsen, bytet för med sig tid/plånbok/sällskap – finns, se tabellen ovan; `tools/levelcheck` som manuell kontroll | +| M10b Bussar och vägar | Bussar följer tidtabell och vägrutor; hållplatser bedöms som perronger; överstimulerande UI-reglaget påverkar bara presentationen | | 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 71b11da..8b3af63 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -521,7 +521,7 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n. - **Kvar till senare:** flera tåg i rad (byten) kommer med M10; bussar; "terminaler" som egna rutor i stället för biljettautomaten. -### M10 – Story-innehåll och flerbytesbanor +### M10 – Story-innehåll och flerbytesbanor ✓ (klar 2026-10-24) - **Mål:** hela resan från flygplats till flygplats. - Alla huvudbanor och sidobanor; banor med flera stopp och byten; buss (med @@ -532,6 +532,46 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n. - Överstimulerande UI/banor som extra svårighet. - **Teknik:** baneditor-stöd behövs troligen här: ett `tools/levelcheck` som ritar banan som bild och rapporterar onåbara rutor. +- **Så blev det:** **Byten** är kedjor av banfiler: en huvudbana har `next` + som pekar på en delbana (`kind: "leg"`, syns inte på bankartan), som i sin + tur kan ha `next`. När man klivit av rätt tåg fortsätter *samma omgång* + på nästa station: klockan, plånboken, sällskapets mätare och räknarna + följer med, par-tiden adderas, dimma och folk är nya, och en "Byte!"- + banner visas. HUD:en visar resans slutmål "via" delbanans mål och + "1/2". Tidsgräns och missade tåg gäller hela resan (första banans + värden). `level.ValidateChains` kräver att delbanor används exakt en gång, + att inga cykler finns och att nästa delbana börjar där den förra slutade + (nätets stationer). **Två nya resor:** 007 Akihabara → Ueno (Hibiya eller + Yamanote) → Asakusa (Ginza), med fel-håll- och fel-varv-fällor, egna kort + och folk; 008 Tokyo (två våningar) → Nippori (Yamanote eller + Keihin-Tohoku) → Narita flygplats (Keisei, biljetter med sittplatsval) – + storyns final. **Slutet:** när sista huvudbanan klaras första gången + visas hemresan (`EndingScene`): planet lyfter över skymningshimlen medan + sällskapet vinkar, resan summeras (banor, stjärnor, poäng, ord, + souvenirer) och eftertexterna rullar; `Progress.StoryCleared` sparas (M11 + låser upp oändligt läge/NG+ på den). `-ending` öppnar scenen direkt. + **`tools/levelcheck`** ritar varje våning som PNG (onåbara rutor röda, + rätt perrong grönkantad, kortaste vägen prickad) och rapporterar + lösbarhet, onåbara rutor, perronger utanför spärrar, tågens bedömning och + resornas kedjor; `go run ./tools/levelcheck [ord…]` → `build/levelcheck/`. + Generatorn fick `platforms` (fler perronger) utöver `floors`/`wide`. +- **Klar när:** kedjorna valideras och storyn börjar/slutar på flygplatsen + (`level.TestStoryChains`), bytet för med sig tid/plånbok/sällskap och + vinner först på sista delbanan (`sim.TestJourneyWithTransfer`), fel tåg på + bytesstationen förlorar (`TestJourneyWrongTrainOnSecondLeg`), delbanor + laddas (`TestChainLevelsLoad`), alla banor passerar levelcheck. ✓ +- **Kvar till M10b:** bussar och vägrutor, fler sidobanor, överstimulerande + UI som eget svårighetsreglage. + +### M10b – Bussar, vägar och fler sidospår + +- **Mål:** resten av M10-listan som inte rymdes: buss med vägrutor och + hållplatser (egen fordonssprite, hållplatsskylt, tidtabell som tågen), + fler sidobanor längs storyn (minst en per stad), och ett + "överstimulerande UI"-reglage (fler lockskyltar, blinkande reklam, falska + avgångstavlor) som kan slås på per bana. +- **Teknik:** vägrutor i banformatet, `Vehicle`-abstraktion i sim som tåg + och buss delar, återanvänd `nav` för busslinjer. ### M11 – Oändligt läge och New Game+ diff --git a/doc/spelbeskrivning.md b/doc/spelbeskrivning.md index 3dfcd32..c6035c5 100644 --- a/doc/spelbeskrivning.md +++ b/doc/spelbeskrivning.md @@ -267,4 +267,7 @@ från skills i skill tree, och från unika skills man får av sidouppdrag. | "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 | +| "flera stopp och byten" | En resa är en kedja av banfiler (`next` → delbana med `kind: "leg"`). Samma omgång fortsätter på nästa station med klocka, plånbok och sällskap; resmålet i HUD:en visas "via" bytesstationen. Vilken bytesstation som gäller bestämmer banan – spelaren väljer tåg, inte rutt | Fri ruttplanering (välj själv var man byter) är en möjlig utbyggnad | +| "slutar när man ska tillbaka till flygplatsen" | Sista huvudbanan (008) går Tokyo → Nippori → Narita flygplats med Keisei. När den klaras första gången visas hemresan: planet lyfter, resan summeras, eftertexter | Slutet går att se igen via `-ending` | +| Buss | Uppskjuten till M10b (vägrutor, hållplatser, egen sprite) | | | 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 6f6d626..7a6a6bc 100644 --- a/internal/game/assets.go +++ b/internal/game/assets.go @@ -48,6 +48,7 @@ type Assets struct { Train *ebiten.Image // tågvagn, ritas längs rälsen när tåget syns Rails *ebiten.Image // rälsruta (horisontell), roteras för vertikala spår Posters []*ebiten.Image // affischer för visuell röra (ritas över väggrutor) + Plane *ebiten.Image // flygplanet i slutscenen Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2] Text map[string]string Dict *lang.Dictionary @@ -93,6 +94,9 @@ func LoadAssets() (*Assets, error) { if err != nil { return nil, err } + if a.Plane, err = loadImage("sprites", "plane.png"); err != nil { + return nil, err + } for row := 0; row < 4; row++ { for col := 0; col < 3; col++ { a.Player[row][col] = player.SubImage(image.Rect(col*TileSize, row*TileSize, (col+1)*TileSize, (row+1)*TileSize)).(*ebiten.Image) diff --git a/internal/game/game.go b/internal/game/game.go index 4338e9b..2ae5e9f 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -71,6 +71,7 @@ type Options struct { Mute bool // inget ljud SavePath string // sparfil ("" = standardplatsen) NoSave bool // läs sparfilen men skriv aldrig + Ending bool // öppna slutscenen direkt (tester/skärmdumpar) Mode string // navigeringsläge från start: "list" eller "signs" (tomt = sparfilens) } @@ -137,18 +138,40 @@ func New(opt Options) (*Game, error) { g.Wallet.Card = opt.CardYen } - if opt.LevelID != "" { + switch { + case opt.Ending: + g.scene = NewEndingScene(g) + case opt.LevelID != "": s, err := g.startLevel(opt.LevelID) if err != nil { return nil, err } g.scene = s - } else { + default: g.scene = NewMapScene(g) } return g, nil } +// chainAfter är resans fortsättning efter banan id (delbanorna via next). +func (g *Game) chainAfter(id string) []*level.Level { + if c := level.ChainOf(g.Levels, id); len(c) > 1 { + return c[1:] + } + return nil +} + +// isLastMain rapporterar om banan är storyns sista huvudbana. +func (g *Game) isLastMain(id string) bool { + var last *level.Level + for _, l := range g.Levels { + if !l.IsSide() && !l.IsLeg() && (last == nil || l.Order > last.Order) { + last = l + } + } + return last != nil && last.ID == id +} + func loadCatalog() (*progress.Catalog, error) { sk, err := res.ReadFile("data", "skills.json") if err != nil { @@ -240,7 +263,7 @@ 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, Mode: g.Mode, + RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery, Mode: g.Mode, Chain: g.chainAfter(id), } } @@ -281,12 +304,13 @@ func (g *Game) startLevel(id string) (*LevelScene, error) { // LevelOutcome är vad en avslutad bana gav i progression (för resultatskärmen). type LevelOutcome struct { - XP int - LevelUps int - FirstClear bool - NewItems []string - NewPoints int - Unlocked []string // banor som öppnades + XP int + LevelUps int + FirstClear bool + NewItems []string + NewPoints int + Unlocked []string // banor som öppnades + StoryCleared bool // sista huvudbanan klarades för första gången: visa slutet } // levelEnded appliceras exakt en gång när en bana avgjorts: debuffar, @@ -323,6 +347,10 @@ func (g *Game) levelEnded(run *sim.Run, id string) LevelOutcome { out.Unlocked = append(out.Unlocked, l.ID) } } + if g.isLastMain(id) && !g.Progress.StoryCleared { + g.Progress.StoryCleared = true + out.StoryCleared = true + } } g.State.Current = nil g.Save() diff --git a/internal/game/scene_ending.go b/internal/game/scene_ending.go new file mode 100644 index 0000000..1a895b2 --- /dev/null +++ b/internal/game/scene_ending.go @@ -0,0 +1,178 @@ +package game + +import ( + "fmt" + "image/color" + "math" + "sort" + "strings" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" +) + +// EndingScene är storyns slut: sällskapet flyger hem, resan summeras och +// eftertexterna rullar. Enter går vidare mellan delarna, Esc hoppar till +// bankartan. +type EndingScene struct { + t float64 // sekunder i aktuell del + phase int // 0 flyget, 1 summeringen, 2 eftertexterna + lines []string +} + +const ( + endingFlightSec = 7.0 // flyget innan summeringen kommer av sig själv + endingCreditsPxS = 14.0 // eftertexternas rullhastighet +) + +// NewEndingScene startar hemresan. +func NewEndingScene(g *Game) *EndingScene { + return &EndingScene{lines: strings.Split(g.Assets.T("ending.credits"), "\n")} +} + +func (e *EndingScene) Update(g *Game) error { + e.t += dt + switch { + case backJustPressed(): + g.SetScene(NewMapScene(g)) + return nil + case confirmJustPressed(): + e.phase++ + e.t = 0 + } + if e.phase == 0 && e.t > endingFlightSec { + e.phase, e.t = 1, 0 + } + if e.phase == 2 && e.t*endingCreditsPxS > float64(len(e.lines)*LineH+ScreenH) { + e.phase++ // eftertexterna har rullat förbi + } + if e.phase > 2 { + g.SetScene(NewMapScene(g)) + } + return nil +} + +func (e *EndingScene) Draw(g *Game, screen *ebiten.Image) { + a := g.Assets + e.drawSky(screen) + switch e.phase { + case 0: + e.drawFlight(g, screen) + a.DrawBig(screen, a.T("ending.title"), 24, 20, colText) + a.Draw(screen, a.T("ending.sub"), 24, 44, colDim) + case 1: + e.drawSummary(g, screen) + default: + e.drawCredits(g, screen) + } + a.Draw(screen, a.T("ending.hint"), 8, ScreenH-12, colDim) +} + +// drawSky är en skymningshimmel i band, från mörkblått upptill till orange +// vid horisonten, med långsamt drivande moln. +func (e *EndingScene) drawSky(screen *ebiten.Image) { + top, bottom := [3]float64{30, 32, 80}, [3]float64{230, 120, 70} + const bands = 12 + for i := 0; i < bands; i++ { + f := float64(i) / float64(bands-1) + col := color.RGBA{uint8(top[0] + (bottom[0]-top[0])*f), uint8(top[1] + (bottom[1]-top[1])*f), uint8(top[2] + (bottom[2]-top[2])*f), 255} + vector.DrawFilledRect(screen, 0, float32(i*ScreenH/bands), ScreenW, float32(ScreenH/bands+1), col, false) + } + cloud := color.RGBA{250, 220, 200, 120} + for i := 0; i < 5; i++ { + x := math.Mod(float64(i)*73+e.t*(4+float64(i)), ScreenW+60) - 30 + y := float64(40 + i*22) + vector.DrawFilledRect(screen, float32(x), float32(y), 28, 6, cloud, false) + vector.DrawFilledRect(screen, float32(x+6), float32(y-4), 14, 5, cloud, false) + } + vector.DrawFilledRect(screen, 0, ScreenH-20, ScreenW, 20, color.RGBA{24, 20, 37, 255}, false) // marken +} + +// drawFlight: planet lyfter åt höger medan sällskapet vinkar på marken. +func (e *EndingScene) drawFlight(g *Game, screen *ebiten.Image) { + a := g.Assets + f := min(1, e.t/endingFlightSec) + px := -40 + f*(ScreenW+80) + py := 120 - f*80 + 3*math.Sin(e.t*2) + var op ebiten.DrawImageOptions + op.GeoM.Scale(2, 2) + op.GeoM.Translate(px, py) + screen.DrawImage(a.Plane, &op) + for i := range memberTints { + var o ebiten.DrawImageOptions + t := memberTints[i] + o.ColorScale.Scale(t[0], t[1], t[2], 1) + o.GeoM.Translate(float64(ScreenW/2-50+i*20), ScreenH-20-TileSize+math.Abs(2*math.Sin(e.t*3+float64(i)))) + screen.DrawImage(a.PlayerFrame(sim.Up, true, e.t+float64(i)*0.3), &o) + } +} + +// drawSummary är resan i siffror: banor, stjärnor, poäng, ord och souvenirer. +func (e *EndingScene) drawSummary(g *Game, screen *ebiten.Image) { + a := g.Assets + p := g.Progress + vector.DrawFilledRect(screen, 16, 14, ScreenW-32, ScreenH-40, colPanel, false) + vector.DrawFilledRect(screen, 16, 14, ScreenW-32, 2, colGood, false) + a.DrawBig(screen, a.T("ending.summary"), 24, 20, colGood) + levels, cleared, score := 0, 0, 0 + for _, l := range g.Levels { + if l.IsLeg() { + continue + } + levels++ + if r := p.Levels[l.ID]; r != nil && r.Cleared { + cleared++ + score += r.Score + } + } + y := 44 + rows := []string{ + fmt.Sprintf("%s: %d/%d", a.T("ending.levels"), cleared, levels), + fmt.Sprintf("%s: %d/%d", a.T("ending.stars"), p.TotalStars(), 3*levels), + fmt.Sprintf("%s: %d", a.T("ending.score"), score), + fmt.Sprintf("%s: %d", a.T("ending.words"), g.Know.Count()), + } + for _, r := range rows { + a.Draw(screen, r, 24, y, colText) + y += LineH + } + var items []string + for id := range p.Items { + if it := g.Catalog.Item(id); it != nil { + items = append(items, it.Name.Get(UILang)) + } + } + sort.Strings(items) + souvenirs := a.T("ending.none") + if len(items) > 0 { + souvenirs = strings.Join(items, ", ") + } + for _, l := range a.wrapLine(plainLine(a.T("ending.items")+": "+souvenirs, colKnown), ScreenW-48) { + a.drawLines(screen, []line{l}, 24, y) + y += LineH + } + y += 4 + for _, l := range a.wrapLine(plainLine(a.T("ending.story_done"), colWarn), ScreenW-48) { + a.drawLines(screen, []line{l}, 24, y) + y += LineH + } +} + +// drawCredits rullar eftertexterna nerifrån och upp. +func (e *EndingScene) drawCredits(g *Game, screen *ebiten.Image) { + a := g.Assets + y0 := ScreenH - int(e.t*endingCreditsPxS) + for i, l := range e.lines { + y := y0 + i*LineH + if y < -LineH || y > ScreenH { + continue + } + col := colDim + if i == 0 || (i > 0 && e.lines[i-1] == "" && i+1 < len(e.lines) && e.lines[i+1] != "") { + col = colText // rubriker + } + a.Draw(screen, l, (ScreenW-a.Width(l))/2, y, col) + } +} diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go index f185c6c..af87d72 100644 --- a/internal/game/scene_level.go +++ b/internal/game/scene_level.go @@ -26,12 +26,20 @@ const ( learnedShowSec = 3.0 progressedShowSec = 2.5 flashShowSec = 2.5 + transferBannerSec = 3.0 +) + +// Mätarnas bredd och mellanrum uppe till höger (per medlem). +const ( + meterW = 11 + meterGap = 3 ) // LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run; // scenen översätter input och ritar. type LevelScene struct { id string + level *level.Level // aktuell delbana (byts när resan fortsätter på nästa station) run *sim.Run cam *sim.Camera animT float64 @@ -58,14 +66,21 @@ func NewLevelScene(g *Game, id string, seed rng.Seed) (*LevelScene, error) { if err != nil { return nil, err } - s := &LevelScene{ - id: id, - run: sim.NewRun(lvl, g.runOptions(id, seed)), - cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)), - } + s := &LevelScene{id: id, run: sim.NewRun(lvl, g.runOptions(id, seed))} + s.rng = rand.New(rand.NewPCG(1, 2)) + s.rebind(g) + return s, nil +} + +// rebind bygger om allt som hör till den aktuella delbanan: kamera, räls, +// affischer, melodifaser och bakgrundsprat. Körs vid start och vid byte. +func (s *LevelScene) rebind(g *Game) { + lvl := s.run.Level + s.level = lvl + s.cam = sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)) s.cam.Margin = TileSize // rälsen ligger i kantraden: visa en ruta utanför kartan s.lastPhase = map[level.Tile]level.Phase{} - s.rng = rand.New(rand.NewPCG(1, 2)) + s.particles = nil s.buildTracks() s.buildPosters() g.startChatter(lvl.Noise) @@ -73,7 +88,6 @@ func NewLevelScene(g *Game, id string, seed rng.Seed) (*LevelScene, error) { s.lastPhase[tr.tile], _ = s.run.PlatformPhase(tr.tile) } s.followPlayer() - return s, nil } // TrackPhaseName är fasen för närmaste perrong inom indikatoravståndet @@ -145,6 +159,9 @@ func (s *LevelScene) Update(g *Game) error { // Minispel: all input går dit; Esc avbryter minispelet, inte banan. mx, my := miniMoveXY() s.run.StepMini(dt, minigame.Input{Move: my, MoveX: mx, Confirm: confirmJustPressed(), Hold: confirmHeld(), Cancel: backJustPressed()}) + if s.run.Level != s.level { + s.rebind(g) // bytet: resan fortsätter på nästa station + } if s.run.Outcome != sim.Ongoing { s.outcome = g.levelEnded(s.run, s.id) g.SetScene(NewResultScene(s)) @@ -299,6 +316,14 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) { s.drawIndicator(screen, ox, oy) s.drawHUD(g, screen) + if r := s.run; r.TransferAt >= 0 && r.Time-r.TransferAt < transferBannerSec && r.Mini == nil && r.Outcome == sim.Ongoing { + txt := g.Assets.T("hud.transfer_done") + w := g.Assets.Width(txt) + 16 + x := (ScreenW - w) / 2 + vector.DrawFilledRect(screen, float32(x), 60, float32(w), LineH+8, colPanel, false) + vector.DrawFilledRect(screen, float32(x), 60, float32(w), 2, colWarn, false) + g.Assets.Draw(screen, txt, x+8, 64, colWarn) + } if s.run.Mini != nil { s.drawMinigame(g, screen) } @@ -346,27 +371,41 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { a := g.Assets vector.DrawFilledRect(screen, 0, 0, ScreenW, 24, color.RGBA{24, 20, 37, 200}, false) - dest := r.Level.Destination.Get("romaji") - if r.Know.ShowsDestinationJP() { // hint från nivå 1: resmålet på japanska, att matcha mot skyltarna + dest := r.FinalDestination().Get("romaji") + if !r.IsLastLeg() { // resa med byte: den här delbanan slutar vid bytesstationen + dest += " " + a.T("hud.via") + " " + r.Level.Destination.Get("romaji") + } + if r.Know.ShowsDestinationJP() { // hint från nivå 1: delbanans mål på japanska, att matcha mot skyltarna dest += " " + r.Level.Destination.Get("ja") } name := r.Level.Name.Get(UILang) if r.Level.NumFloors() > 1 { name += " " + r.Level.FloorName(r.Player.Tile.Z) } + if r.Legs() > 1 { + name += fmt.Sprintf(" %d/%d", r.LegNo(), r.Legs()) + } a.Draw(screen, name, 4, 0, colText) destX := 4 + a.Width(name) + 12 a.Draw(screen, a.T("hud.destination")+": "+dest, destX, 0, colKnown) - a.Draw(screen, fmt.Sprintf("%s Lv%d·%d", a.T("hud.japanese"), r.Know.Level(), r.Know.Count()), - destX+a.Width(a.T("hud.destination")+": "+dest)+12, 0, colDim) + // Japanska-nivån får plats till vänster om mätarna; annars kortas den. + jpX := destX + a.Width(a.T("hud.destination")+": "+dest) + 12 + metersX := ScreenW - 4 - len(r.Party.Members)*(meterW+meterGap) + jp := fmt.Sprintf("%s Lv%d·%d", a.T("hud.japanese"), r.Know.Level(), r.Know.Count()) + if jpX+a.Width(jp) > metersX-4 { + jp = fmt.Sprintf("Lv%d", r.Know.Level()) + } + if jpX+a.Width(jp) <= metersX-4 { + a.Draw(screen, jp, jpX, 0, colDim) + } timeStr := fmt.Sprintf("%s %3.0fs", a.T("hud.time"), r.Time) - if r.Level.TimeLimitSec > 0 { + if r.TimeLimit() > 0 { left := r.TimeLeft() timeStr = fmt.Sprintf("%s %d:%02d", a.T("hud.time_left"), int(left)/60, int(left)%60) } wallet := fmt.Sprintf("%s %s %d¥ %s %d¥", timeStr, a.T("hud.card"), r.Wallet.Card, a.T("hud.cash_short"), r.Wallet.Cash) - if r.Level.MaxMissedTrains > 0 { - wallet += fmt.Sprintf(" %s %d/%d", a.T("hud.missed_short"), r.MissedTrains, r.Level.MaxMissedTrains) + if r.MaxMissed() > 0 { + wallet += fmt.Sprintf(" %s %d/%d", a.T("hud.missed_short"), r.MissedTrains, r.MaxMissed()) } if r.Wallet.Phone { wallet += fmt.Sprintf(" %s %.0f%%", a.T("hud.phone"), r.Wallet.Battery) @@ -375,7 +414,7 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { wallet += " " + a.T("hud.ticket") + " " + r.Wallet.Ticket } walletCol := colDim - if r.Level.TimeLimitSec > 0 && r.TimeLeft() < 30 { + if r.TimeLimit() > 0 && r.TimeLeft() < 30 { walletCol = colBad } a.Draw(screen, wallet, 4, LineH, walletCol) @@ -385,9 +424,6 @@ 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)) @@ -402,7 +438,7 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { } lines = append(lines, a.wrapLine(plainLine(strings.Join(parts, " "), colWarn), ScreenW-8)...) } - if r.Flashing(flashShowSec) { + if r.Flashing(flashShowSec) && r.FlashKey != sim.FlashTransfer { // bytet har sin egen banner col := colBad if r.FlashKey == sim.FlashRescan { col = colWarn @@ -426,6 +462,9 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { // (eller tåget under hen) annars skulle hamna bakom den. var box image.Rectangle hasBox := len(lines) > 0 + if !hasBox { + s.drawModeHint(a, screen) // lägesrutan bara när ingen inforuta visas + } if hasBox { h := 8 + LineH*len(lines) if bar { @@ -579,7 +618,7 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar // drawMeters ritar sällskapets mat/dryck/toa-mätare uppe till höger. func (s *LevelScene) drawMeters(g *Game, screen *ebiten.Image) { members := s.run.Party.Members - const w, gap = 11, 3 + const w, gap = meterW, meterGap x0 := ScreenW - 4 - len(members)*(w+gap) for i, m := range members { x := float32(x0 + i*(w+gap)) diff --git a/internal/game/scene_map.go b/internal/game/scene_map.go index b85addb..5a6ae0b 100644 --- a/internal/game/scene_map.go +++ b/internal/game/scene_map.go @@ -33,9 +33,11 @@ type mapNode struct { func NewMapScene(g *Game) *MapScene { m := &MapScene{sides: map[string][]*level.Level{}} for _, l := range g.Levels { - if l.IsSide() { + switch { + case l.IsLeg(): // delbanor nås via sin resa + case l.IsSide(): m.sides[l.Parent] = append(m.sides[l.Parent], l) - } else { + default: m.mains = append(m.mains, l) } } @@ -192,8 +194,12 @@ func (m *MapScene) Draw(g *Game, screen *ebiten.Image) { name = a.T("map.side") + ": " + name } a.Draw(screen, name, 8, 134, colText) - info := fmt.Sprintf("→ %s %s %s %s %d", cur.Destination.Get("ja"), cur.Destination.Get("romaji"), + fd := level.FinalOf(g.Levels, cur).Destination + info := fmt.Sprintf("→ %s %s %s %s %d", fd.Get("ja"), fd.Get("romaji"), strings.Repeat("★", cur.Difficulty)+strings.Repeat("☆", 5-cur.Difficulty), a.T("map.party"), cur.Party) + if n := len(level.ChainOf(g.Levels, cur.ID)); n > 1 { + info += fmt.Sprintf(" %s %d", a.T("map.transfers"), n-1) + } if g.State.Current != nil && g.State.Current.LevelID == cur.ID { info += " " + a.T("map.resume") } diff --git a/internal/game/scene_result.go b/internal/game/scene_result.go index 1c49ed0..b71fb33 100644 --- a/internal/game/scene_result.go +++ b/internal/game/scene_result.go @@ -29,6 +29,10 @@ func (r *ResultScene) Update(g *Game) error { } g.SetScene(s) case confirmJustPressed() || backJustPressed(): + if r.from.outcome.StoryCleared { + g.SetScene(NewEndingScene(g)) // storyn klar: hemresan + return nil + } g.SetScene(NewMapScene(g)) } return nil diff --git a/internal/level/level.go b/internal/level/level.go index a3c9254..79edee7 100644 --- a/internal/level/level.go +++ b/internal/level/level.go @@ -248,6 +248,7 @@ type Level struct { Kind string `json:"kind"` // "main" (standard) eller "side" Order int `json:"order"` // huvudbanornas ordning på bankartan (1, 2, …); sidobanor ärver förälderns Parent string `json:"parent"` // sidobanans huvudbana – låses upp när den är klarad + Next string `json:"next"` // nästa delbana (kind "leg") i en resa med byten: när man klivit av här fortsätter banan där Rewards Rewards `json:"rewards"` Shop []string `json:"shop"` // items som kiosken säljer på banan (tom = seedat urval) Crowd int `json:"crowd"` // folk på stationen 0–3 (NPC:er) @@ -381,9 +382,9 @@ func (l *Level) Validate() error { switch l.Kind { case "": l.Kind = "main" - case "main", "side": + case "main", "side", "leg": default: - return fmt.Errorf("level %s: kind måste vara main eller side", l.ID) + return fmt.Errorf("level %s: kind måste vara main, side eller leg", l.ID) } if l.Kind == "side" && l.Parent == "" { return fmt.Errorf("level %s: sidobana utan parent", l.ID) @@ -782,6 +783,81 @@ func (l *Level) Annotate(net *nav.Network) error { // IsSide rapporterar om banan är en sidobana. func (l *Level) IsSide() bool { return l.Kind == "side" } +// IsLeg rapporterar om banan är en delbana i en resa med byten (nås bara +// via en annan banas next, visas inte på bankartan). +func (l *Level) IsLeg() bool { return l.Kind == "leg" } + +// ChainOf ger resan som börjar med banan id: banan själv följd av dess +// delbanor i ordning. nil om id är okänt. +func ChainOf(all []*Level, id string) []*Level { + byID := map[string]*Level{} + for _, l := range all { + byID[l.ID] = l + } + var out []*Level + for l := byID[id]; l != nil && len(out) <= len(all); l = byID[l.Next] { + out = append(out, l) + if l.Next == "" { + break + } + } + return out +} + +// FinalOf är resans sista delbana (banan själv utan byten) – dess +// destination är resans resmål. +func FinalOf(all []*Level, l *Level) *Level { + if c := ChainOf(all, l.ID); len(c) > 0 { + return c[len(c)-1] + } + return l +} + +// ValidateChains kontrollerar resorna med byten: next finns och är en +// delbana, varje delbana används exakt en gång, inga cykler, och på +// nätkopplade banor börjar nästa delbana där den förra slutade. +func ValidateChains(all []*Level) error { + byID := map[string]*Level{} + for _, l := range all { + byID[l.ID] = l + } + refs := map[string]int{} + for _, l := range all { + if l.Next == "" { + continue + } + n := byID[l.Next] + if n == nil { + return fmt.Errorf("level %s: next %q finns inte", l.ID, l.Next) + } + if !n.IsLeg() { + return fmt.Errorf("level %s: next %q måste ha kind leg", l.ID, l.Next) + } + refs[n.ID]++ + if l.Networked() && n.Networked() && n.Station != l.To { + return fmt.Errorf("level %s: bytet hänger inte ihop – slutar i %q men %s börjar i %q", l.ID, l.To, n.ID, n.Station) + } + } + for _, l := range all { + if l.IsLeg() && refs[l.ID] != 1 { + return fmt.Errorf("level %s: delbanan refereras av %d banor, ska vara exakt 1", l.ID, refs[l.ID]) + } + } + for _, l := range all { + if l.IsLeg() { + continue + } + seen := map[string]bool{} + for c := l; c != nil && c.Next != ""; c = byID[c.Next] { + if seen[c.ID] { + return fmt.Errorf("level %s: resan går i cirkel", l.ID) + } + seen[c.ID] = true + } + } + return nil +} + // 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) { @@ -810,6 +886,9 @@ func LoadAll(dir string) ([]*Level, error) { } out = append(out, l) } + if err := ValidateChains(out); err != nil { + return nil, err + } return out, nil } diff --git a/internal/level/level_test.go b/internal/level/level_test.go index b7d7ccb..1b3f130 100644 --- a/internal/level/level_test.go +++ b/internal/level/level_test.go @@ -665,3 +665,70 @@ func TestCarsWomenOnlyAndCrowdFields(t *testing.T) { t.Fatal("filer") } } + +// TestStoryChains: resorna med byten hänger ihop, storyn börjar och slutar på +// flygplatsen, och felaktiga kedjor ger fel. +func TestStoryChains(t *testing.T) { + dir, err := res.Path("data", "levels") + if err != nil { + t.Fatal(err) + } + all, err := LoadAll(dir) // kör ValidateChains + if err != nil { + t.Fatal(err) + } + var first, last *Level + legs, chains := 0, 0 + for _, l := range all { + switch { + case l.IsLeg(): + legs++ + if l.Order != 0 { + t.Errorf("%s: delbanor har ingen order", l.ID) + } + case !l.IsSide(): + if first == nil || l.Order < first.Order { + first = l + } + if last == nil || l.Order > last.Order { + last = l + } + } + if l.Next != "" { + chains++ + c := ChainOf(all, l.ID) + if len(c) < 2 || c[0] != l || FinalOf(all, l) != c[len(c)-1] { + t.Errorf("%s: ChainOf/FinalOf = %v", l.ID, c) + } + } + } + if legs == 0 || chains == 0 { + t.Fatal("storyn ska ha resor med byten") + } + if first == nil || first.Station != "narita-airport" { + t.Errorf("storyn ska börja på flygplatsen, första banan är %v", first) + } + if last == nil || FinalOf(all, last).To != "narita-airport" { + t.Errorf("storyn ska sluta på flygplatsen, sista banan slutar i %q", FinalOf(all, last).To) + } + + mk := func(id, kind, next, station, to string) *Level { + return &Level{ID: id, Kind: kind, Next: next, Station: station, To: to} + } + bad := map[string][]*Level{ + "saknad next": {mk("a", "main", "zzz", "", "")}, + "next inte leg": {mk("a", "main", "b", "", ""), mk("b", "main", "", "", "")}, + "oanvänd delbana": {mk("a", "main", "", "", ""), mk("b", "leg", "", "", "")}, + "delbana två gånger": {mk("a", "main", "b", "", ""), mk("c", "main", "b", "", ""), mk("b", "leg", "", "", "")}, + "cirkel": {mk("a", "main", "b", "", ""), mk("b", "leg", "c", "", ""), mk("c", "leg", "b", "", "")}, + "byte hänger inte": {mk("a", "main", "b", "x", "y"), mk("b", "leg", "", "z", "w")}, + } + for name, lv := range bad { + if err := ValidateChains(lv); err == nil { + t.Errorf("%s: skulle ha gett fel", name) + } + } + if err := ValidateChains([]*Level{mk("a", "main", "b", "x", "y"), mk("b", "leg", "", "y", "w")}); err != nil { + t.Errorf("giltig kedja: %v", err) + } +} diff --git a/internal/progress/progress.go b/internal/progress/progress.go index aec4e07..da2a581 100644 --- a/internal/progress/progress.go +++ b/internal/progress/progress.go @@ -19,12 +19,13 @@ type LevelResult struct { // Progress är det som växer mellan banor. type Progress struct { - XP int `json:"xp"` - SkillPoints int `json:"skillPoints"` - Skills map[string]bool `json:"skills"` - Items map[string]bool `json:"items"` - Equipped map[string]string `json:"equipped"` // slot -> item-id - Levels map[string]*LevelResult `json:"levels"` + XP int `json:"xp"` + SkillPoints int `json:"skillPoints"` + Skills map[string]bool `json:"skills"` + Items map[string]bool `json:"items"` + Equipped map[string]string `json:"equipped"` // slot -> item-id + Levels map[string]*LevelResult `json:"levels"` + StoryCleared bool `json:"storyCleared"` // sista huvudbanan klarad: slutet har visats, oändligt läge/NG+ (M11) låses upp } // New börjar från noll. @@ -235,8 +236,8 @@ func (p *Progress) Unlocked(all []*level.Level, id string) bool { me = l } } - if me == nil { - return false + if me == nil || me.IsLeg() { + return false // delbanor nås bara via sin resa } if me.IsSide() { return p.Cleared(me.Parent) diff --git a/internal/sim/legs_test.go b/internal/sim/legs_test.go new file mode 100644 index 0000000..a19b261 --- /dev/null +++ b/internal/sim/legs_test.go @@ -0,0 +1,101 @@ +package sim + +import ( + "path/filepath" + "testing" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" +) + +// loadChain laddar alla banor och ger resan som börjar med id. +func loadChain(t *testing.T, id string) []*level.Level { + t.Helper() + dir, err := res.Path("data", "levels") + if err != nil { + t.Fatal(err) + } + all, err := level.LoadAll(dir) + if err != nil { + t.Fatal(err) + } + c := level.ChainOf(all, id) + if len(c) < 2 { + t.Fatalf("%s är ingen resa med byten: %v", id, c) + } + return c +} + +// TestJourneyWithTransfer: bana 007 fortsätter på Ueno efter första tåget – +// klockan, plånboken och sällskapet följer med, par-tiden adderas, och +// resan vinns först när andra tåget nått Asakusa. +func TestJourneyWithTransfer(t *testing.T) { + c := loadChain(t, "007-akihabara") + head, leg := c[0], c[1] + r := NewRun(head, RunOptions{Seed: rng.FromInt(7), Chain: c[1:]}) + if r.Legs() != 2 || r.LegNo() != 1 || r.IsLastLeg() || r.FinalDestination().Get("ja") != "浅草" || r.TimeLimit() != head.TimeLimitSec { + t.Fatalf("resans start: legs=%d no=%d final=%s limit=%d", r.Legs(), r.LegNo(), r.FinalDestination().Get("ja"), r.TimeLimit()) + } + par1 := r.ParSec() + for _, m := range r.Party.Members { + m.Card = 100000 // egna kort med gott om saldo: testet handlar om bytet + } + r.Wallet.Card = 100000 + tr := boardAt(t, r, level.Platform) + ride(t, r) + if r.Outcome != Ongoing || r.Level != leg || r.LegNo() != 2 || !r.IsLastLeg() { + t.Fatalf("efter första tåget: outcome=%v level=%s legNo=%d", r.Outcome, r.Level.ID, r.LegNo()) + } + if r.TransferAt < 0 || r.FlashKey != FlashTransfer || r.Player.Tile != leg.SpawnPoint() || len(r.Rides) != 1 || r.Rides[0] != tr { + t.Fatalf("bytet: transferAt=%.1f flash=%q tile=%v rides=%d", r.TransferAt, r.FlashKey, r.Player.Tile, len(r.Rides)) + } + if r.Time < 10 || r.ParSec() <= par1 || r.Fog.Explored(leg) >= 0.5 { + t.Fatalf("klockan ska fortsätta (%.1f), par adderas (%.0f > %.0f) och dimman är ny (%.2f)", r.Time, r.ParSec(), par1, r.Fog.Explored(leg)) + } + if from, to := r.Party.FollowerTiles(1); from != leg.SpawnPoint() || to != leg.SpawnPoint() { + t.Fatalf("sällskapet ska stå på nya starten: %v %v", from, to) + } + boardAt(t, r, level.Platform) + ride(t, r) + if r.Outcome != Won || len(r.Rides) != 2 || r.Score.Total <= 0 { + t.Fatalf("resan ska vinnas efter andra tåget: outcome=%v (%s) rides=%d", r.Outcome, r.LostReason, len(r.Rides)) + } + if r.Score.Explore <= 0 || r.Score.Explore > ScoreExploreMax { + t.Fatalf("utforskat är snittet över delbanorna: %d", r.Score.Explore) + } +} + +// TestJourneyWrongTrainOnSecondLeg: fel tåg på bytesstationen förlorar resan. +func TestJourneyWrongTrainOnSecondLeg(t *testing.T) { + c := loadChain(t, "007-akihabara") + r := NewRun(c[0], RunOptions{Seed: rng.FromInt(8), Chain: c[1:]}) + for _, m := range r.Party.Members { + m.Card = 100000 + } + r.Wallet.Card = 100000 + boardAt(t, r, level.Platform2) // Yamanote mot Ueno – också rätt + ride(t, r) + if r.Outcome != Ongoing || r.LegNo() != 2 { + t.Fatalf("första tåget: %v", r.Outcome) + } + boardAt(t, r, level.WrongPlatform) // Ginza mot Shibuya: fel håll + ride(t, r) + if r.Outcome != Lost || r.LostReason != LostWrongDirection { + t.Fatalf("outcome=%v reason=%q", r.Outcome, r.LostReason) + } +} + +// TestChainLevelsLoad: alla delbanor går att läsa en och en också. +func TestChainLevelsLoad(t *testing.T) { + dir, err := res.Path("data", "levels") + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"007b-ueno", "008b-nippori"} { + l, err := level.LoadFile(filepath.Join(dir, id+".json")) + if err != nil || !l.IsLeg() { + t.Fatalf("%s: %v", id, err) + } + } +} diff --git a/internal/sim/party.go b/internal/sim/party.go index 29d5ebf..59ced2c 100644 --- a/internal/sim/party.go +++ b/internal/sim/party.go @@ -105,6 +105,9 @@ func NewParty(n int, start level.Point, seed rng.Seed) *Party { return p } +// Reset ställer hela sällskapet på en ny ruta (byte till nästa delbana). +func (p *Party) Reset(at level.Point) { p.trail = []level.Point{at} } + // Size är antalet i sällskapet inkl. spelaren. func (p *Party) Size() int { return len(p.Members) } diff --git a/internal/sim/run.go b/internal/sim/run.go index 5a310ac..5272eeb 100644 --- a/internal/sim/run.go +++ b/internal/sim/run.go @@ -35,6 +35,18 @@ type Run struct { Boarded *level.Train Mode nav.Mode // navigeringsläge: listan visar nästa tåg, skyltläget ger bonus + // Resor med byten: Level är aktuell delbana; tiden, plånboken och + // sällskapet följer med mellan delbanorna. + LegIndex int // 0 = första delbanan + TransferAt float64 // när senaste bytet skedde (< 0 = inget) + Rides []*level.Train // tågen man åkt med hittills + calmCars int // resor i en lugn vagn (bonus) + crowdedRide bool // någon resa i en full vagn (svettig-risk) + exploreSum float64 // utforskad andel i avklarade delbanor + legsDone int + timeLimit int // resans tidsgräns (första delbanans), 0 = ingen + maxMissed int // resans gräns för missade tåg (första delbanans) + // Språk: ordboken, spelarens kunskap (delas mellan banor) och hjälpen. Dict *lang.Dictionary Know *lang.Knowledge @@ -116,6 +128,7 @@ const ( // Fler flash-nycklar. const ( FlashWomenOnly = "hud.women_only" + FlashTransfer = "hud.transfer_done" FlashEarly = "hud.early_board" FlashBump = "hud.bump" ) @@ -213,6 +226,8 @@ type RunOptions struct { InfiniteBattery bool // telefonen laddar inte ur Mode nav.Mode // navigeringsläge (nollvärdet = listläge) + + Chain []*level.Level // resans fortsättning: delbanorna efter den första (byten), i ordning } // DefaultSpeed är spelarens grundfart i rutor per sekund. @@ -261,7 +276,10 @@ func NewRun(l *level.Level, o RunOptions) *Run { Visits: map[level.Point]int{}, lastTile: l.SpawnPoint(), ProgressedAt: -1, - parSec: float64(l.ParSec), + parSec: parFor(l), + timeLimit: l.TimeLimitSec, + maxMissed: l.MaxMissedTrains, + TransferAt: -1, Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")), baseSpeed: o.Speed, NoticeAt: -1, @@ -288,14 +306,91 @@ func NewRun(l *level.Level, o RunOptions) *Run { r.Fog.RevealAll(l) } r.Fog.Reveal(l, l.SpawnPoint()) - if r.parSec <= 0 { - 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 } +// parFor är "bra tid" för en delbana: parSec i bandatan, annars kortaste +// vägen i rutor × ParSecPerTile + ParSlackSec. +func parFor(l *level.Level) float64 { + if l.ParSec > 0 { + return float64(l.ParSec) + } + n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter)) + return float64(max(0, n-1))*ParSecPerTile + ParSlackSec +} + +// Legs är antalet delbanor i resan, LegNo den aktuella (1-baserad). +func (r *Run) Legs() int { return 1 + len(r.opts.Chain) } +func (r *Run) LegNo() int { return r.LegIndex + 1 } + +// IsLastLeg rapporterar om det inte blir fler byten. +func (r *Run) IsLastLeg() bool { return r.LegIndex >= len(r.opts.Chain) } + +// FinalDestination är resans slutmål (sista delbanans destination). +func (r *Run) FinalDestination() level.Text { + if n := len(r.opts.Chain); n > 0 { + return r.opts.Chain[n-1].Destination + } + return r.Level.Destination +} + +// TimeLimit och MaxMissed är resans gränser (0 = ingen). +func (r *Run) TimeLimit() int { return r.timeLimit } +func (r *Run) MaxMissed() int { return r.maxMissed } + +// nextLeg är nästa delbana, eller nil på den sista. +func (r *Run) nextLeg() *level.Level { + if r.IsLastLeg() { + return nil + } + return r.opts.Chain[r.LegIndex] +} + +// startLeg byter till nästa delbana: ny station, spelaren och sällskapet på +// dess start, ny dimma och nytt folk – men klockan, plånboken, mätarna och +// räknarna fortsätter. Par-tiden adderas. +func (r *Run) startLeg(l *level.Level) { + r.exploreSum += r.Fog.Explored(r.Level) + r.legsDone++ + r.LegIndex++ + r.Level = l + spawn := l.SpawnPoint() + r.Player = NewMover(spawn, r.baseSpeed) + r.Party.Reset(spawn) + r.Fog = NewFog(l, FogRadius+r.opts.FogRadius) + r.Fog.ExtraLandmarks = r.opts.Landmarks + if r.opts.RevealAll { + r.Fog.RevealAll(l) + } + r.Fog.Reveal(l, spawn) + r.NPCs = spawnNPCs(l, l.Crowd*NPCPerCrowd, r.npcRand) + r.platformPhase = map[level.Tile]level.Phase{} + for _, t := range level.PlatformTiles { + r.platformPhase[t], _ = r.PlatformPhase(t) + } + r.Revealed = map[level.Point]bool{} + r.Visits = map[level.Point]int{} + r.lastTile = spawn + r.Mini, r.MiniKind, r.PendingTrain, r.CarChosen = nil, MiniNone, nil, nil + r.Help, r.Facility = HelpState{}, FacilityState{} + r.DeniedAt, r.BumpedAt, r.WrongLane = -1, -1, false + r.parSec += parFor(l) + r.TransferAt = r.Time + r.flash(FlashTransfer) +} + +// rideDone bokför en avslutad tågresa (vagnbonus, svettig-risk). +func (r *Run) rideDone(tr *level.Train) { + r.Rides = append(r.Rides, tr) + if r.CarChosen != nil && r.CarChosen.Crowd == 0 { + r.calmCars++ + } + if r.CarChosen != nil && r.CarChosen.Crowd == 2 { + r.crowdedRide = true + } +} + // ParSec är tiden för tre stjärnor. func (r *Run) ParSec() float64 { return r.parSec } @@ -735,6 +830,11 @@ func (r *Run) StepMini(dt float64, in minigame.Input) { r.Blocked += g.Blocked switch { case g.Success(): + if next := r.nextLeg(); next != nil { + r.rideDone(tr) + r.startLeg(next) // bytet: resan fortsätter på nästa station + return + } r.finish(tr, true, "") case !tr.Correct: reason := LostWrongTrain @@ -801,22 +901,22 @@ func (r *Run) checkLimits() { if r.Outcome != Ongoing { return } - if r.Level.TimeLimitSec > 0 && r.Time > float64(r.Level.TimeLimitSec) { + if r.timeLimit > 0 && r.Time > float64(r.timeLimit) { r.Mini, r.MiniKind, r.PendingTrain = nil, MiniNone, nil r.finish(nil, false, LostTimeUp) return } - if r.Level.MaxMissedTrains > 0 && r.MissedTrains > r.Level.MaxMissedTrains { + if r.maxMissed > 0 && r.MissedTrains > r.maxMissed { r.finish(nil, false, LostMissedTrains) } } // TimeLeft är sekunder kvar av tidsgränsen (0 utan gräns). func (r *Run) TimeLeft() float64 { - if r.Level.TimeLimitSec == 0 { + if r.timeLimit == 0 { return 0 } - return max(0, float64(r.Level.TimeLimitSec)-r.Time) + return max(0, float64(r.timeLimit)-r.Time) } // computeScore fyller i poängen för en vunnen bana. @@ -825,15 +925,13 @@ func (r *Run) computeScore() { sc.Time = int(ScoreTimeMax * min(1, r.parSec/max(r.Time, 0.001))) sc.Money = max(0, ScoreMoneyMax-(r.Wallet.Spent-r.spentStart)/10) sc.Meters = int(ScoreMetersMax * r.Party.AvgMeters()) - sc.Explore = int(ScoreExploreMax * r.Fog.Explored(r.Level)) + sc.Explore = int(ScoreExploreMax * (r.exploreSum + r.Fog.Explored(r.Level)) / float64(r.legsDone+1)) sc.Penalty = PenaltyMissed*r.MissedTrains + PenaltyRescan*r.Rescans + PenaltyGrumpy*r.Grumpy + PenaltyEarlyBoard*r.EarlyBoard + PenaltyBlocked*r.Blocked if r.WomenOnlyViolation { sc.Penalty += PenaltyWomenOnly } - if r.CarChosen != nil && r.CarChosen.Crowd == 0 { - sc.Car = ScoreCarBonus - } + sc.Car = ScoreCarBonus * r.calmCars base := sc.Time + sc.Money + sc.Meters + sc.Explore + sc.Car if r.Mode == nav.Signs { sc.Bonus = base * ScoreSignsBonusPct / 100 @@ -860,7 +958,7 @@ func (r *Run) earnedDebuffs() []Debuff { if r.poisoned { out = append(out, Debuff{FoodPoisoning, FoodPoisonLevels}) } - if r.CarChosen != nil && r.CarChosen.Crowd == 2 && r.Seed.Derive("sweaty").New().Float64() < SweatyChance/r.Stats[StatLuck] { + if r.crowdedRide && r.Seed.Derive("sweaty").New().Float64() < SweatyChance/r.Stats[StatLuck] { out = append(out, Debuff{Sweaty, 1}) } return out @@ -968,6 +1066,7 @@ func (r *Run) SignHere() *level.SignText { // tillräckligt bra resultat lär man sig resmålets ord. func (r *Run) finish(tr *level.Train, won bool, reason string) { r.Boarded = tr + r.rideDone(tr) r.NewDebuffs = r.earnedDebuffs() if !won { r.Outcome = Lost diff --git a/resources/data/levels/007-akihabara.json b/resources/data/levels/007-akihabara.json new file mode 100644 index 0000000..5bb2739 --- /dev/null +++ b/resources/data/levels/007-akihabara.json @@ -0,0 +1,179 @@ +{ + "clutter": 2, + "crowd": 2, + "destination": { + "en": "Ueno", + "ja": "上野", + "romaji": "Ueno", + "sv": "Ueno" + }, + "difficulty": 4, + "fareYen": 170, + "grid": [ + "#################################", + "#S....#...........#...........#W#", + "#####.#.#########.#.#.###.###.#.#", + "#.....#.#...#...#.#.#...#...#...#", + "#.#####.#.#.#.#.#.#.#.#.#.#.###.#", + "#i..#.....#...#.#.#.#.#.#.#.#...#", + "#.#.#.#########.#.#.#.#.#.#.#.###", + "#.#...#.......#.#...#..i#.#.#...#", + "#.#####.#####.#.#######.#.#.#####", + "#.......#T#...#.......#.#K#.#...#", + "#########.#.#####.###.#.###.#.#.#", + "#...#....i..#...#.#.#.#.#...#.#.#", + "#.###.#######.#.#.#.#.#.#.#.#.#.#", + "#.#...#.......#.#...#.#...#.#.#.#", + "#.#.#####.#.###.#####.#######.#.#", + "#.#.....#.#.#...#...#.........#.#", + "#.#####.#.#.#.###.#.###.#######.#", + "#.......#.#.#.#...#...#.#.......#", + "#G###.###G#.#.#.#####.###.#####G#", + "#pG.....Gq#.....#QG.......#...GP#", + "#################################" + ], + "id": "007-akihabara", + "individualCards": true, + "kind": "main", + "maxMissedTrains": 3, + "name": { + "en": "Akihabara Station", + "ja": "秋葉原駅", + "sv": "Akihabara station" + }, + "next": "007b-ueno", + "noise": 1, + "order": 7, + "party": 4, + "rideStops": 3, + "shop": [ + "mask_tengu", + "onigiri_box", + "rail_pass" + ], + "signs": [ + { + "text": { + "en": "Akihabara Station. Track 4 Hibiya Line for Kita-Senju: south-east. Track 2 Yamanote Line for Ueno: south. Track 1 Yamanote Line for Tokyo and track 3 Hibiya Line for Naka-Meguro: south-west.", + "ja": "秋葉原駅 4番線 日比谷線 北千住方面 → 南東 2番線 山手線 上野方面 → 南 1番線 山手線 東京方面・3番線 日比谷線 中目黒方面 → 南西", + "sv": "Akihabara station. Spår 4 Hibiya-linjen mot Kita-Senju: sydost. Spår 2 Yamanote-linjen mot Ueno: söderut. Spår 1 Yamanote-linjen mot Tokyo och spår 3 Hibiya-linjen mot Naka-Meguro: sydväst." + }, + "x": 1, + "y": 5, + "z": 0 + }, + { + "text": { + "en": "\u003c- Track 3 Hibiya Line for Naka-Meguro Track 4 Hibiya Line for Kita-Senju -\u003e", + "ja": "← 3番線 日比谷線 中目黒方面 4番線 日比谷線 北千住方面 →", + "sv": "\u003c- Spår 3 Hibiya-linjen mot Naka-Meguro Spår 4 Hibiya-linjen mot Kita-Senju -\u003e" + }, + "x": 9, + "y": 11, + "z": 0 + }, + { + "text": { + "en": "Ueno: transfer to the Ginza Line for Asakusa.", + "ja": "上野 → 乗り換え 銀座線 浅草方面", + "sv": "Ueno: byte till Ginza-linjen mot Asakusa." + }, + "x": 23, + "y": 7, + "z": 0 + } + ], + "station": "akihabara", + "timeLimitSec": 600, + "to": "ueno", + "trains": [ + { + "cars": 4, + "destination": { + "en": "Kita-Senju", + "ja": "北千住", + "romaji": "Kita-Senju" + }, + "line": { + "en": "Hibiya Line", + "ja": "日比谷線", + "romaji": "Hibiya-sen" + }, + "lineId": "hibiya", + "platform": "P", + "schedule": { + "dwellSec": 9, + "intervalSec": 28, + "offsetSec": 8 + }, + "terminus": "kita-senju", + "track": "4" + }, + { + "cars": 3, + "destination": { + "en": "Naka-Meguro", + "ja": "中目黒", + "romaji": "Naka-Meguro" + }, + "line": { + "en": "Hibiya Line", + "ja": "日比谷線", + "romaji": "Hibiya-sen" + }, + "lineId": "hibiya", + "platform": "p", + "schedule": { + "dwellSec": 8, + "intervalSec": 28, + "offsetSec": 16 + }, + "terminus": "naka-meguro", + "track": "3" + }, + { + "cars": 4, + "destination": { + "en": "Ueno", + "ja": "上野", + "romaji": "Ueno" + }, + "line": { + "en": "Yamanote Line", + "ja": "山手線", + "romaji": "Yamanote-sen" + }, + "lineId": "yamanote", + "platform": "Q", + "schedule": { + "dwellSec": 9, + "intervalSec": 32, + "offsetSec": 12 + }, + "terminus": "ueno", + "track": "2" + }, + { + "cars": 4, + "destination": { + "en": "Tokyo", + "ja": "東京", + "romaji": "Tokyo" + }, + "line": { + "en": "Yamanote Line", + "ja": "山手線", + "romaji": "Yamanote-sen" + }, + "lineId": "yamanote", + "platform": "q", + "schedule": { + "dwellSec": 8, + "intervalSec": 32, + "offsetSec": 4 + }, + "terminus": "tokyo", + "track": "1" + } + ] +} \ No newline at end of file diff --git a/resources/data/levels/007b-ueno.json b/resources/data/levels/007b-ueno.json new file mode 100644 index 0000000..0d4778c --- /dev/null +++ b/resources/data/levels/007b-ueno.json @@ -0,0 +1,148 @@ +{ + "clutter": 1, + "crowd": 2, + "destination": { + "en": "Asakusa", + "ja": "浅草", + "romaji": "Asakusa", + "sv": "Asakusa" + }, + "difficulty": 4, + "fareYen": 170, + "grid": [ + "#################################", + "#S.#..\u003c..\u003c..\u003c..\u003c..\u003c..\u003c..\u003c..\u003c..###", + "#..#..\u003e..\u003e..\u003e..\u003e..\u003e..\u003e..\u003e..\u003e..###", + "#\u003c\u003e################\u003c\u003e#######\u003c\u003e###", + "#..#..\u003ci.\u003c..\u003c..\u003c..#..#..\u003c..#..###", + "#..#..\u003e..\u003e..\u003e..\u003eW.#..#..\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#\u003c\u003e#\u003c\u003e#\u003c\u003e#########", + "#..#..\u003c..\u003c..#..#..#..\u003c..#..\u003c..###", + "#..#..\u003e..\u003e..#..#..#..\u003e..#..\u003e..###", + "#\u003c\u003e#\u003c\u003e#######\u003c\u003e#\u003c\u003e#\u003c\u003e#\u003c\u003e####\u003c\u003e###", + "#..#K.\u003c..\u003c..#..#..\u003c.i#.T\u003c..\u003c..###", + "#..#i.\u003e..\u003e..#..#..\u003e..#..\u003e..\u003e..###", + "#\u003c\u003e#\u003c\u003e####\u003c\u003e#\u003c\u003e#\u003c\u003e##########\u003c\u003e###", + "#G.\u003c..\u003c..\u003c..#..\u003cG.\u003c..\u003c..\u003c..\u003c.G###", + "#pG\u003e..\u003e..\u003e..#..GQG\u003e..\u003e..\u003e..\u003eGP###", + "#################################", + "#################################", + "#################################" + ], + "id": "007b-ueno", + "individualCards": true, + "kind": "leg", + "name": { + "en": "Ueno Station", + "ja": "上野駅", + "sv": "Ueno station" + }, + "noise": 2, + "party": 4, + "rideStops": 3, + "signs": [ + { + "text": { + "en": "Ueno Station. Track 1 Ginza Line for Asakusa: south-east. Track 2 Ginza Line for Shibuya: south-west. Track 3 Hibiya Line for Kita-Senju: south.", + "ja": "上野駅 1番線 銀座線 浅草方面 → 南東 2番線 銀座線 渋谷方面 → 南西 3番線 日比谷線 北千住方面 → 南", + "sv": "Ueno station. Spår 1 Ginza-linjen mot Asakusa: sydost. Spår 2 Ginza-linjen mot Shibuya: sydväst. Spår 3 Hibiya-linjen mot Kita-Senju: söderut." + }, + "x": 7, + "y": 4, + "z": 0 + }, + { + "text": { + "en": "\u003c- Track 2 Ginza Line for Shibuya Track 1 Ginza Line for Asakusa -\u003e", + "ja": "← 2番線 銀座線 渋谷方面 1番線 銀座線 浅草方面 →", + "sv": "\u003c- Spår 2 Ginza-linjen mot Shibuya Spår 1 Ginza-linjen mot Asakusa -\u003e" + }, + "x": 20, + "y": 13, + "z": 0 + }, + { + "text": { + "en": "Track 1 Ginza Line for Asakusa: the gate in the south-east corner.", + "ja": "1番線 銀座線 浅草方面 → 南東口 改札", + "sv": "Spår 1 Ginza-linjen mot Asakusa: spärren i sydöstra hörnet." + }, + "x": 4, + "y": 14, + "z": 0 + } + ], + "station": "ueno", + "to": "asakusa", + "trains": [ + { + "cars": 3, + "destination": { + "en": "Asakusa", + "ja": "浅草", + "romaji": "Asakusa" + }, + "line": { + "en": "Ginza Line", + "ja": "銀座線", + "romaji": "Ginza-sen" + }, + "lineId": "ginza", + "platform": "P", + "schedule": { + "dwellSec": 8, + "intervalSec": 26, + "offsetSec": 10 + }, + "terminus": "asakusa", + "track": "1" + }, + { + "cars": 3, + "destination": { + "en": "Shibuya", + "ja": "渋谷", + "romaji": "Shibuya" + }, + "line": { + "en": "Ginza Line", + "ja": "銀座線", + "romaji": "Ginza-sen" + }, + "lineId": "ginza", + "platform": "p", + "schedule": { + "dwellSec": 8, + "intervalSec": 26, + "offsetSec": 18 + }, + "terminus": "shibuya", + "track": "2" + }, + { + "cars": 4, + "destination": { + "en": "Kita-Senju", + "ja": "北千住", + "romaji": "Kita-Senju" + }, + "line": { + "en": "Hibiya Line", + "ja": "日比谷線", + "romaji": "Hibiya-sen" + }, + "lineId": "hibiya", + "platform": "Q", + "schedule": { + "dwellSec": 8, + "intervalSec": 30, + "offsetSec": 6 + }, + "terminus": "kita-senju", + "track": "3" + } + ] +} \ No newline at end of file diff --git a/resources/data/levels/008-tokyo-hemresa.json b/resources/data/levels/008-tokyo-hemresa.json new file mode 100644 index 0000000..38fe937 --- /dev/null +++ b/resources/data/levels/008-tokyo-hemresa.json @@ -0,0 +1,214 @@ +{ + "clutter": 2, + "crowd": 2, + "destination": { + "en": "Nippori", + "ja": "日暮里", + "romaji": "Nippori", + "sv": "Nippori" + }, + "difficulty": 5, + "fareYen": 200, + "floors": [ + { + "grid": [ + "#################################", + "#S#.........#...........#.......#", + "#.#.#.###.#.#.#########.#.#####.#", + "#.#.#.#.#.#.#.#.#...#...#.....#.#", + "#.#.#.#.#.###.#.#.#.#.#####.###.#", + "#.#.....#.....#...#.#.......#...#", + "#.#####.###########.#########.###", + "#.....#...#.......#.....#...#...#", + "#####.###.###.###.#####.#.#.###.#", + "#...#.#.........#.......#.#.#...#", + "###.#.#########.#######.#.#.#.###", + "#...#...#......U#.......#.#.#...#", + "#.#####.#.#######.#######.#.###.#", + "#...#...#.........#.......#...#.#", + "###.#.#############.#########.#.#", + "#...#.#...#.........#...#.....#.#", + "#.###.#.#.#.###.#####.#.#.#####.#", + "#...#...#...#...#.....#.#...#...#", + "#.#.#########.###.#.#######.#.#.#", + "#.#...............#.............#", + "#################################" + ], + "name": "B1" + }, + { + "grid": [ + "#################################", + "#T#..................K#.........#", + "#.#.#######.#####.#####.###.###.#", + "#.#.............#.....#.#...#.#.#", + "#.#####.#######.###.#.#.#.###.#.#", + "#.#.....#.....#.....#...#.#...#.#", + "#.#.#####.###.#.#########.#.###.#", + "#...#.....#...#.#.......#.#.....#", + "#.###.#####.###.###.###.#.#####.#", + "#...#.#..W#...#...#...#.........#", + "###.#.#.#####.###.#.#.#####.#.###", + "#.....#i....#..D#.#.#.#...#.#...#", + "#.#####.###.###.#.###.#.#.#####.#", + "#.......#.#.....#.#...#.#.#.....#", + "#.#######.#######.#.###.#.#.###.#", + "#.....#..........i..#...#...#...#", + "#####.###.###########.#######.###", + "#...#...#.#...........#.....#...#", + "#G#.###.###.###########.#######G#", + "#p#....iGqG.#...GQG...........GP#", + "#################################" + ], + "name": "1F" + } + ], + "id": "008-tokyo-hemresa", + "individualCards": true, + "kind": "main", + "maxMissedTrains": 3, + "name": { + "en": "Tokyo Station, going home", + "ja": "東京駅", + "sv": "Tokyo station, hemresan" + }, + "next": "008b-nippori", + "noise": 2, + "order": 8, + "party": 4, + "rewards": { + "skillPoints": 2 + }, + "rideStops": 4, + "shop": [ + "suit", + "mask_tengu", + "onigiri_box", + "rail_pass" + ], + "signs": [ + { + "text": { + "en": "Tokyo Station. Track 4 Yamanote Line for Ueno: south-east. Track 3 Keihin-Tohoku Line for Omiya: south. Track 1 Chuo Line for Takao and track 5 Yamanote Line for Shinagawa: south-west. The platforms are on 1F.", + "ja": "東京駅 4番線 山手線 上野方面 → 南東 3番線 京浜東北線 大宮方面 → 南 1番線 中央線 高尾方面・5番線 山手線 品川方面 → 南西", + "sv": "Tokyo station. Spår 4 Yamanote-linjen mot Ueno: sydost. Spår 3 Keihin-Tohoku-linjen mot Omiya: söderut. Spår 1 Chuo-linjen mot Takao och spår 5 Yamanote-linjen mot Shinagawa: sydväst. Perrongerna ligger på 1F." + }, + "x": 7, + "y": 11, + "z": 1 + }, + { + "text": { + "en": "\u003c- Track 5 Yamanote Line for Shinagawa Track 4 Yamanote Line for Ueno -\u003e", + "ja": "← 5番線 山手線 品川方面 4番線 山手線 上野方面 →", + "sv": "\u003c- Spår 5 Yamanote-linjen mot Shinagawa Spår 4 Yamanote-linjen mot Ueno -\u003e" + }, + "x": 7, + "y": 19, + "z": 1 + }, + { + "text": { + "en": "Nippori: transfer to the Keisei Line for Narita Airport.", + "ja": "日暮里 → 乗り換え 京成本線 成田空港方面", + "sv": "Nippori: byte till Keisei-linjen mot Narita flygplats." + }, + "x": 17, + "y": 15, + "z": 1 + } + ], + "station": "tokyo", + "timeLimitSec": 720, + "to": "nippori", + "trains": [ + { + "cars": 4, + "destination": { + "en": "Ueno", + "ja": "上野", + "romaji": "Ueno" + }, + "line": { + "en": "Yamanote Line", + "ja": "山手線", + "romaji": "Yamanote-sen" + }, + "lineId": "yamanote", + "platform": "P", + "schedule": { + "dwellSec": 9, + "intervalSec": 30, + "offsetSec": 10 + }, + "terminus": "ueno", + "track": "4" + }, + { + "cars": 4, + "destination": { + "en": "Shinagawa", + "ja": "品川", + "romaji": "Shinagawa" + }, + "line": { + "en": "Yamanote Line", + "ja": "山手線", + "romaji": "Yamanote-sen" + }, + "lineId": "yamanote", + "platform": "p", + "schedule": { + "dwellSec": 8, + "intervalSec": 30, + "offsetSec": 20 + }, + "terminus": "shinagawa", + "track": "5" + }, + { + "cars": 4, + "destination": { + "en": "Omiya", + "ja": "大宮", + "romaji": "Omiya" + }, + "line": { + "en": "Keihin-Tohoku Line", + "ja": "京浜東北線", + "romaji": "Keihin-Tohoku-sen" + }, + "lineId": "keihin-tohoku", + "platform": "Q", + "schedule": { + "dwellSec": 9, + "intervalSec": 34, + "offsetSec": 4 + }, + "terminus": "omiya", + "track": "3" + }, + { + "cars": 3, + "destination": { + "en": "Takao", + "ja": "高尾", + "romaji": "Takao" + }, + "line": { + "en": "Chuo Line", + "ja": "中央線", + "romaji": "Chuo-sen" + }, + "lineId": "chuo", + "platform": "q", + "schedule": { + "dwellSec": 8, + "intervalSec": 28, + "offsetSec": 14 + }, + "terminus": "takao", + "track": "1" + } + ] +} \ No newline at end of file diff --git a/resources/data/levels/008b-nippori.json b/resources/data/levels/008b-nippori.json new file mode 100644 index 0000000..e34a47d --- /dev/null +++ b/resources/data/levels/008b-nippori.json @@ -0,0 +1,150 @@ +{ + "clutter": 1, + "crowd": 1, + "destination": { + "en": "Narita Airport", + "ja": "成田空港", + "romaji": "Narita Airport", + "sv": "Narita flygplats" + }, + "difficulty": 5, + "fareYen": 500, + "grid": [ + "#################################", + "#S#W....#.....#.....#...#.......#", + "#.#####.#.###.#.#.#.#.#.#.#.#.#.#", + "#.....#.#...#...#.....#...#.#...#", + "#####.#.###.###############.#####", + "#K#...#...#...#.......#...#.....#", + "#.#.#####.###.#.#.###.#.#.#####.#", + "#.#.#...#.....#.#....i#.#...#...#", + "#.#.#.#.###.###.#####.#.###.#.#.#", + "#.#...#...#...#.....#...#.#...#.#", + "#.#######.#######.#.#####.#####.#", + "#...#...#.#.....#.#.#.#.......#.#", + "#.#.###.#.#.###.#.#.#.#.#####.#.#", + "#.#.....#...#T#i..#.#...#.....#.#", + "#.#.#########.#.###.#####.#####.#", + "#.#.#.....#..i..#...#.....#.....#", + "#.###.#.#.#.#####.###.###.#.#####", + "#.....#.#.#.......#...#...#.....#", + "#G#####.#.#########.#.#########G#", + "#pG...#.........GQG.#.........GP#", + "#################################" + ], + "id": "008b-nippori", + "individualCards": true, + "kind": "leg", + "name": { + "en": "Nippori Station", + "ja": "日暮里駅", + "sv": "Nippori station" + }, + "noise": 1, + "party": 4, + "rideStops": 4, + "seatSelection": true, + "signs": [ + { + "text": { + "en": "Nippori Station. Track 1 Keisei Line for Narita Airport: south-east. Track 2 Keisei Line for Keisei-Ueno: south-west. Track 11 Yamanote Line for Ueno: south. Ticket required.", + "ja": "日暮里駅 1番線 京成本線 成田空港方面 → 南東 2番線 京成本線 京成上野方面 → 南西 11番線 山手線 上野方面 → 南", + "sv": "Nippori station. Spår 1 Keisei-linjen mot Narita flygplats: sydost. Spår 2 Keisei-linjen mot Keisei-Ueno: sydväst. Spår 11 Yamanote-linjen mot Ueno: söderut. Biljett krävs." + }, + "x": 15, + "y": 13, + "z": 0 + }, + { + "text": { + "en": "\u003c- Track 2 Keisei Line for Keisei-Ueno Track 1 Keisei Line for Narita Airport -\u003e", + "ja": "← 2番線 京成本線 京成上野方面 1番線 京成本線 成田空港方面 →", + "sv": "\u003c- Spår 2 Keisei-linjen mot Keisei-Ueno Spår 1 Keisei-linjen mot Narita flygplats -\u003e" + }, + "x": 13, + "y": 15, + "z": 0 + }, + { + "text": { + "en": "Narita Airport: track 1. Tickets at the machine.", + "ja": "成田空港 → 1番線 切符 → 券売機", + "sv": "Narita flygplats: spår 1. Biljetter i automaten." + }, + "x": 21, + "y": 7, + "z": 0 + } + ], + "station": "nippori", + "ticketRequired": true, + "to": "narita-airport", + "trains": [ + { + "cars": 4, + "destination": { + "en": "Narita Airport", + "ja": "成田空港", + "romaji": "Narita Airport" + }, + "line": { + "en": "Keisei Main Line", + "ja": "京成本線", + "romaji": "Keisei Honsen" + }, + "lineId": "keisei-main", + "platform": "P", + "schedule": { + "dwellSec": 10, + "intervalSec": 36, + "offsetSec": 12 + }, + "terminus": "narita-airport", + "track": "1" + }, + { + "cars": 3, + "destination": { + "en": "Keisei-Ueno", + "ja": "京成上野", + "romaji": "Keisei-Ueno" + }, + "line": { + "en": "Keisei Main Line", + "ja": "京成本線", + "romaji": "Keisei Honsen" + }, + "lineId": "keisei-main", + "platform": "p", + "schedule": { + "dwellSec": 8, + "intervalSec": 36, + "offsetSec": 22 + }, + "terminus": "keisei-ueno", + "track": "2" + }, + { + "cars": 4, + "destination": { + "en": "Ueno", + "ja": "上野", + "romaji": "Ueno" + }, + "line": { + "en": "Yamanote Line", + "ja": "山手線", + "romaji": "Yamanote-sen" + }, + "lineId": "yamanote", + "platform": "Q", + "schedule": { + "dwellSec": 8, + "intervalSec": 30, + "offsetSec": 6 + }, + "terminus": "ueno", + "track": "11" + } + ] +} \ No newline at end of file diff --git a/resources/sprites/plane.png b/resources/sprites/plane.png new file mode 100644 index 0000000..a598abe Binary files /dev/null and b/resources/sprites/plane.png differ diff --git a/resources/text/en.json b/resources/text/en.json index f8044be..26d17b4 100644 --- a/resources/text/en.json +++ b/resources/text/en.json @@ -208,5 +208,20 @@ "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." + "result.too_far_detail": "The train for %[1]s only reaches %[2]s after a long detour.", + "hud.via": "via", + "hud.transfer_done": "Transfer! Find the next train.", + "map.transfers": "transfers", + "ending.title": "Flying home", + "ending.sub": "Last day. The party flies home from Narita.", + "ending.summary": "The trip in numbers", + "ending.levels": "Levels cleared", + "ending.stars": "Stars", + "ending.score": "Best scores total", + "ending.words": "Japanese words learned", + "ending.items": "Souvenirs", + "ending.none": "none", + "ending.story_done": "Story complete! Endless mode and New Game+ are up next.", + "ending.hint": "Enter: continue Esc: level map", + "ending.credits": "Metro Puzzle JP\n\nIdea and game design\nBjörn\n\nCode and pixels\nClaude (Anthropic)\n\nEngine\nEbitengine\n\nFont\nPixelMplus (M+ FONT LICENSE)\n\nThe trains, the signs and the chatter\nTokyo\n\nThanks for riding!\nまたね" } diff --git a/resources/text/jp-dictionary.json b/resources/text/jp-dictionary.json index 9a932ed..8897e38 100644 --- a/resources/text/jp-dictionary.json +++ b/resources/text/jp-dictionary.json @@ -1168,5 +1168,12 @@ "en": "ticket machine", "sv": "biljettautomat", "tier": 2 + }, + "乗り換え": { + "kana": "のりかえ", + "romaji": "norikae", + "en": "transfer", + "sv": "byte", + "tier": 2 } } \ No newline at end of file diff --git a/resources/text/sv.json b/resources/text/sv.json index b8777a1..cab3156 100644 --- a/resources/text/sv.json +++ b/resources/text/sv.json @@ -208,5 +208,20 @@ "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." + "result.too_far_detail": "Tåget mot %[1]s når %[2]s först efter en lång omväg.", + "hud.via": "via", + "hud.transfer_done": "Byte! Hitta nästa tåg.", + "map.transfers": "byten", + "ending.title": "Hemresa", + "ending.sub": "Sista dagen. Sällskapet flyger hem från Narita.", + "ending.summary": "Resan i siffror", + "ending.levels": "Banor klarade", + "ending.stars": "Stjärnor", + "ending.score": "Bästa poäng totalt", + "ending.words": "Japanska ord lärda", + "ending.items": "Souvenirer", + "ending.none": "inga", + "ending.story_done": "Storyn är klar! Oändligt läge och New Game+ kommer härnäst.", + "ending.hint": "Enter: vidare Esc: bankartan", + "ending.credits": "Metro Puzzle JP\n\nIdé och speldesign\nBjörn\n\nKod och pixlar\nClaude (Anthropic)\n\nMotor\nEbitengine\n\nTypsnitt\nPixelMplus (M+ FONT LICENSE)\n\nTågen, skyltarna och sorlet\nTokyo\n\nTack för resan!\nまたね" } diff --git a/tools/genart/main.go b/tools/genart/main.go index 07848d3..4706e83 100644 --- a/tools/genart/main.go +++ b/tools/genart/main.go @@ -287,6 +287,26 @@ func main() { } } save(filepath.Join(out, "player.png"), playerImg) + + planeImg := image.NewNRGBA(image.Rect(0, 0, 32, 12)) + plane(canvas{planeImg, 0, 0}) + save(filepath.Join(out, "plane.png"), planeImg) +} + +// plane är flygplanet i slutscenen (32×12, nosen åt höger). +func plane(c canvas) { + white, grey, blue, red := rgb(236, 240, 245), rgb(150, 160, 175), rgb(90, 140, 220), rgb(214, 60, 60) + c.rect(4, 5, 24, 4, white) // kropp + c.rect(28, 6, 3, 2, white) // nos + c.rect(2, 4, 3, 5, white) // akter + c.rect(1, 1, 4, 4, red) // fena + c.rect(10, 3, 10, 2, grey) // bortre vingen + c.rect(12, 9, 8, 2, grey) // främre vingen + c.rect(14, 10, 3, 2, grey) // motor + for x := 8; x < 26; x += 3 { + c.rect(x, 6, 1, 1, blue) // fönster + } + c.rect(26, 6, 2, 1, blue) // cockpit } func save(path string, img image.Image) { diff --git a/tools/levelcheck/main.go b/tools/levelcheck/main.go new file mode 100644 index 0000000..7b4f88a --- /dev/null +++ b/tools/levelcheck/main.go @@ -0,0 +1,186 @@ +// levelcheck ritar banorna som bilder och rapporterar vad som är fel eller +// värt att veta: onåbara rutor, perronger utanför spärrarna, tågens +// bedömning i linjenätet och resornas byten. Ren Go (image/png), inga +// fönster. +// +// go run ./tools/levelcheck # alla banor → build/levelcheck/ +// go run ./tools/levelcheck 007 008b # bara banor vars id innehåller något av orden +package main + +import ( + "fmt" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "strings" + + "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" +) + +const scale = 8 // px per ruta + +var palette = map[level.Tile]color.RGBA{ + level.Wall: {38, 43, 68, 255}, + level.Floor: {160, 170, 190, 255}, + level.LaneLeft: {150, 165, 185, 255}, + level.LaneRight: {170, 175, 195, 255}, + level.Spawn: {80, 200, 100, 255}, + level.Gate: {70, 120, 230, 255}, + level.TicketMachine: {60, 200, 220, 255}, + level.Sign: {40, 70, 150, 255}, + level.Toilet: {240, 240, 250, 255}, + level.Kiosk: {240, 200, 60, 255}, + level.StairsUp: {170, 110, 220, 255}, + level.StairsDown: {120, 70, 170, 255}, +} + +func main() { + dir, err := res.Path("data", "levels") + fail(err) + all, err := level.LoadAll(dir) + fail(err) + out := filepath.Join(repoRoot(), "build", "levelcheck") + fail(os.MkdirAll(out, 0o755)) + problems := 0 + for _, l := range all { + if len(os.Args) > 1 && !matches(l.ID, os.Args[1:]) { + continue + } + problems += report(l, all, out) + } + if problems > 0 { + fmt.Printf("\n%d problem\n", problems) + os.Exit(1) + } + fmt.Println("\ninga problem") +} + +func matches(id string, words []string) bool { + for _, w := range words { + if strings.Contains(id, w) { + return true + } + } + return false +} + +// report skriver rapporten för en bana och ritar varje våning. +func report(l *level.Level, all []*level.Level, out string) (problems int) { + fmt.Printf("== %s (%s, %dx%d, %d våning(ar), svårighet %d, sällskap %d)\n", l.ID, l.Kind, l.Width(), l.Height(), l.NumFloors(), l.Difficulty, l.Party) + reach := l.Reachable() + if !l.Solvable() { + fmt.Println(" FEL: går inte att klara från start") + problems++ + } + for _, p := range l.Unreachable() { + fmt.Printf(" FEL: onåbar %c på %v\n", l.At(p), p) + problems++ + } + for _, p := range l.UngatedPlatforms() { + fmt.Printf(" FEL: perrong %v nås utan spärr\n", p) + problems++ + } + for _, tr := range l.Trains { + pts := l.Find(level.Tile(tr.Platform[0])) + dist := len(l.Path(l.SpawnPoint(), pts[0], l.CanEnter)) - 1 + v := "correct=" + fmt.Sprint(tr.Correct) + if tr.Verdict != nav.Unjudged { + v = tr.Verdict.String() + } + fmt.Printf(" tåg %s spår %-3s %-8s → %-10s %-16s %3d steg från start\n", tr.Platform, tr.Track, tr.Line.Get("romaji"), tr.Destination.Get("romaji"), v, dist) + } + if l.Next != "" { + var ids []string + for _, c := range level.ChainOf(all, l.ID) { + ids = append(ids, c.ID) + } + fmt.Printf(" resa: %s\n", strings.Join(ids, " → ")) + } + fmt.Printf(" skyltar %d, utforskbart %d rutor\n", len(l.Signs), len(reach)) + for z := 0; z < l.NumFloors(); z++ { + name := fmt.Sprintf("%s/%s-%d.png", out, l.ID, z) + fail(savePNG(name, draw(l, z, reach))) + fmt.Printf(" bild: %s\n", name) + } + return problems +} + +// draw ritar en våning: rutor i palettfärg, onåbara rutor rödmarkerade, +// perronger orange (rätt tåg grönkantad), spelarens kortaste väg prickad. +func draw(l *level.Level, z int, reach map[level.Point]bool) image.Image { + img := image.NewRGBA(image.Rect(0, 0, l.Width()*scale, l.Height()*scale)) + fill := func(x, y int, col color.RGBA, inset int) { + for dy := inset; dy < scale-inset; dy++ { + for dx := inset; dx < scale-inset; dx++ { + img.SetRGBA(x*scale+dx, y*scale+dy, col) + } + } + } + correct := map[level.Tile]bool{} + for _, tr := range l.Trains { + correct[level.Tile(tr.Platform[0])] = tr.Correct + } + for y := 0; y < l.Height(); y++ { + for x := 0; x < l.Width(); x++ { + p := level.Point{X: x, Y: y, Z: z} + t := l.At(p) + col, ok := palette[t] + switch { + case t.IsPlatform(): + col = color.RGBA{250, 150, 50, 255} + if correct[t] { + fill(x, y, color.RGBA{60, 220, 90, 255}, 0) + fill(x, y, col, 1) + continue + } + case !ok: + col = color.RGBA{255, 0, 255, 255} + } + fill(x, y, col, 0) + if t.Passable() && !reach[p] { + fill(x, y, color.RGBA{220, 40, 40, 255}, 2) + } + } + } + if path := l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter); path != nil { + for _, p := range path { + if p.Z == z { + fill(p.X, p.Y, color.RGBA{30, 30, 30, 255}, 3) + } + } + } + return img +} + +func savePNG(path string, img image.Image) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + return png.Encode(f, img) +} + +func repoRoot() string { + dir, err := os.Getwd() + fail(err) + for d := dir; ; d = filepath.Dir(d) { + if _, err := os.Stat(filepath.Join(d, "go.mod")); err == nil { + return d + } + if filepath.Dir(d) == d { + return dir + } + } +} + +func fail(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, "levelcheck:", err) + os.Exit(2) + } +} diff --git a/tools/shots/main.go b/tools/shots/main.go index 5f17616..01f3a38 100644 --- a/tools/shots/main.go +++ b/tools/shots/main.go @@ -122,6 +122,14 @@ func buildShots(levelsDir string) error { if err != nil { return err } + l7, err := level.LoadFile(filepath.Join(levelsDir, "007-akihabara.json")) + if err != nil { + return err + } + toPlatform7, err := moves(l7, l7.SpawnPoint(), l7.Find(level.Platform)[0]) // Hibiya mot Kita-Senju: byte i Ueno + 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"} @@ -143,6 +151,9 @@ func buildShots(levelsDir string) error { {"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"}}, + {"007-akihabara", toPlatform7 + "; waittrain; wait 5; press enter; wait 100; pick calm; press enter; waitstop; press enter; wait 40; shot $OUT/transfer.png; quit", + []string{"-seed", "7", "-mode", "list", "-know", "上野,番線,方面,乗り換え"}}, + {"", "wait 150; shot $OUT/ending.png; press enter; wait 5; shot $OUT/ending-summary.png; press enter; wait 250; shot $OUT/credits.png; quit", []string{"-ending", "-save", "$DEMO"}}, } return nil }