diff --git a/README.md b/README.md index d144ae2..f805b11 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,12 @@ 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:** M5 klar (2026-10-24) – skyltarna är på japanska och du lär dig -ord genom att läsa, fråga om hjälp, klara banor med bra tid eller lägga -språkpoäng i ordlistan. Lämmeltåget följer efter dig med sina behov, och -minikartan visar bara avgångar och riktmärken tills du utforskat stationen. -Nästa är M6: tågkort, biljetter, telefon och minispel. Se +**Status:** M6 klar (2026-10-24) – japanska skyltar och ordinlärning, +lämmeltåget med sina behov, minikarta med dimma, och nu pengarna: tågkortet +fylls på vid automaten, svårare banor kräver rätt pappersbiljett, kortet kan +ligga i telefonen (snabbare, men batteri och omskanningar), fulla tåg kräver +att man tränger sig på, och på tåget måste man kliva av på rätt station. +Nästa är M7: svårighet, tid, poäng och debuffar. Se [doc/roadmap.md](doc/roadmap.md). ![Skylt på japanska med delvis översättning](doc/img/sign.png) @@ -26,10 +27,18 @@ väntar några sekunder och får hela skylten översatt plus ett nytt ord. Spärrarna släpper bara igenom dig om kortet har saldo. På perrongen (gul ledlinje) är avgångstavlan också japansk. Står tåget inne trycker du Enter för att kliva på. Rätt tåg tar dig till resmålet, fel tåg avslutar banan. +På tåget passerar stationerna en i taget; kliv av med Enter vid ditt resmål. Klarar du banan under par får du stjärnor, språkpoäng och resmålets namn. ![Perrongen – tåget är inne](doc/img/platform.png) +Spärren drar från tågkortet. Tar saldot slut fyller du på vid automaten, +där du först måste hitta rätt knapp bland de japanska. På svårare banor +krävs i stället en pappersbiljett till rätt resmål, köpt från en taxetavla +på japanska. + +![Automaten](doc/img/ticket.png) + Ditt sällskap följer efter i en rad. Mätarna uppe till höger visar mat, dryck och toa för var och en; faller de får någon ett behov som saktar ned er alla tills ni hittar en kiosk eller en toalett. Minikartan i hörnet visar @@ -113,16 +122,19 @@ metro-puzzle-jp -level 001-flygplatsen # hoppa direkt in i en bana metro-puzzle-jp -scale 3 # fönsterstorlek (320x180 × skala) metro-puzzle-jp -smoke 90 # öppna fönster, avsluta efter 90 bildrutor (smoke-test) metro-puzzle-jp -know 改札,番線 -points 3 # förkunskap och språkpoäng (tester/skärmdumpar) -metro-puzzle-jp -seed 7 # fast seed för händelser och läsning (tester/skärmdumpar) +metro-puzzle-jp -seed 7 # fast seed för händelser, läsning och minispel (tester/skärmdumpar) +metro-puzzle-jp -phone -card 0 # kortet i telefonen; kortets startsaldo (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 ``` ### Styrning -Pilar eller WASD går. Enter väljer, kliver på tåget, använder toaletten -eller handlar i kiosken. H frågar om hjälp vid en skylt, L öppnar -ordlistan, M döljer/visar minikartan, Esc går tillbaka, R spelar om. Gamepad +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, M döljer/visar minikartan, P i menyn lägger +kortet i telefonen, 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. @@ -161,6 +173,7 @@ cmd/metro-puzzle-jp/ programstart internal/game/ Ebitengine: scener, input, rendering, skriptläge (enda paketet som får känna till Ebitengine) internal/sim/ rutbaserad rörelse, kollision, kamera, banomgång med tåg/spärrar/hjälp (ren Go) 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/level/ banformat, validering, framkomlighet internal/res/ hittar och läser resources/ internal/rng/ seedad slump diff --git a/cmd/metro-puzzle-jp/main.go b/cmd/metro-puzzle-jp/main.go index c7b2040..c7f4521 100644 --- a/cmd/metro-puzzle-jp/main.go +++ b/cmd/metro-puzzle-jp/main.go @@ -22,6 +22,8 @@ func main() { know := flag.String("know", "", "japanska ord spelaren kan från start, kommaseparerade (tester/skärmdumpar)") points := flag.Int("points", 0, "språkpoäng från start (tester/skärmdumpar)") seed := flag.Uint64("seed", 0, "fast seed för banorna, 0 = slumpad (tester/skärmdumpar)") + phone := flag.Bool("phone", false, "ha tågkortet i telefonen från start") + card := flag.Int("card", -1, "kortets startsaldo i yen, -1 = standard (tester/skärmdumpar)") version := flag.Bool("version", false, "skriv ut version och avsluta") flag.Parse() @@ -30,7 +32,7 @@ func main() { return } - g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed}) + g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card}) if err != nil { log.Fatalf("kunde inte starta: %v", err) } diff --git a/doc/img/level-001.png b/doc/img/level-001.png index ba95970..219975f 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 884b333..50d6f11 100644 Binary files a/doc/img/level-002.png and b/doc/img/level-002.png differ diff --git a/doc/img/menu.png b/doc/img/menu.png index da9541c..321ed41 100644 Binary files a/doc/img/menu.png and b/doc/img/menu.png differ diff --git a/doc/img/platform.png b/doc/img/platform.png index 5b38c4d..1854c7d 100644 Binary files a/doc/img/platform.png and b/doc/img/platform.png differ diff --git a/doc/img/result.png b/doc/img/result.png index 5ad2513..f08306f 100644 Binary files a/doc/img/result.png and b/doc/img/result.png differ diff --git a/doc/img/ride.png b/doc/img/ride.png new file mode 100644 index 0000000..91d80ad Binary files /dev/null and b/doc/img/ride.png differ diff --git a/doc/img/sign-help.png b/doc/img/sign-help.png index 4b2c4b3..8399f21 100644 Binary files a/doc/img/sign-help.png and b/doc/img/sign-help.png differ diff --git a/doc/img/sign-learned.png b/doc/img/sign-learned.png index baa6590..5fa66c8 100644 Binary files a/doc/img/sign-learned.png and b/doc/img/sign-learned.png differ diff --git a/doc/img/sign.png b/doc/img/sign.png index c8af9ba..431b1ad 100644 Binary files a/doc/img/sign.png and b/doc/img/sign.png differ diff --git a/doc/img/ticket.png b/doc/img/ticket.png new file mode 100644 index 0000000..a65bc23 Binary files /dev/null and b/doc/img/ticket.png differ diff --git a/doc/img/topup.png b/doc/img/topup.png new file mode 100644 index 0000000..07117f0 Binary files /dev/null and b/doc/img/topup.png differ diff --git a/doc/img/words.png b/doc/img/words.png index 187b980..a18e791 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 66c97ba..55c6a55 100644 --- a/doc/mal-och-tester.md +++ b/doc/mal-och-tester.md @@ -107,6 +107,10 @@ att bygga på Linux som på Windows. | `internal/sim` | `TestUrgesSlowAndFacilitiesRestore`, `TestFollowersTrailLeader`, `TestPartySizeIsClamped` | Fartstraff, toalett/kiosk återställer och kostar, distraktion går över, följarna står på ledarens spår | | `internal/sim` | `TestToiletAndKioskInRun`, `TestPartySlowsLeader` | Enter på W startar ett besök som tar tid och avbryts om man går; K drar från kortet; sällskapet saktar ledaren | | `internal/sim` | `TestFogRevealsAlongCorridorsNotThroughWalls`, `TestLandmarksAlwaysVisible`, `TestFogGrowsWithMovementAndResetsPerRun` | **M5:** dimman lättar längs gångar men inte genom väggar; perronger/start/spärrar syns alltid; dimman växer när man går och nollställs per omgång | +| `internal/minigame` | `TestTopUpFlowAndDeterminism`, `TestTicketBoardSeededAndAffordable`, `TestCrowdZoneAndAttempts`, `TestRideStopsAndGettingOff`, `TestRideWithoutFillersStillHasDestination` | **M6:** varje minispel är deterministiskt per seed; fel knapp straffar, sedlar rinner tillbaka; taxor och råd; zon och försök; kliva av rätt/fel/förbi; fel tåg saknar resmålet | +| `internal/sim` | `TestWalletPayTopUpAndPhone` | Kort, kontanter, påfyllning, spenderat, batteri gör kortet i telefonen oanvändbart | +| `internal/sim` | `TestTopUpFlowAtMachine`, `TestTicketLevelFlow`, `TestCrowdFailureMissesTrain` | Tomt kort → automaten → påfyllning → spärren släpper; **hela bana 002 headless** med fel/rätt biljett, trängsel och resa; två bom = missat tåg | +| `internal/sim` | `TestPhoneSpeedRescanAndBattery`, `TestRideWrongOrMissedStopLoses` | Fartbuff, seedad omskanning som fryser spelaren, dött batteri stoppar spärren; fel station / åka förbi förlorar | | `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan | ### Planerade tester per milstolpe @@ -118,8 +122,9 @@ att bygga på Linux som på Windows. | ~~M3 Japanska & skyltar~~ ✓ | Delvis översättning, engelska-andel, ordbokens fullständighet och täckning, hjälptid – finns, se tabellen ovan | | ~~M4 Lämmeltåget~~ ✓ | Mätare, händelsefrekvens efter gruppstorlek, seedade händelser – finns, se tabellen ovan | | ~~M5 Minikarta~~ ✓ | Upptäckta rutor, nollställning per bana, avgångar från start – finns, se tabellen ovan | -| M6 Kort/biljetter/minispel | Saldo dras i spärr; tomt kort → automatkrav; biljettminispel: rätt/fel biljett; telefon-skanning misslyckas med given procent per seed; batteri tar bara slut över tröskeltid | +| ~~M6 Kort/biljetter/minispel~~ ✓ | Saldo, automat, biljetter, omskanning, batteri – finns, se tabellen ovan | | M7 Svårighet/poäng/debuffs | Poängformeln (tid, pengar, mätare, läge); tidsgräns och maxantal missade tåg; varje debuff ändrar rätt stat; temporära debuffar räknar ned 1–4 banor | +| M7b Tåg som kommer och går | `TrainPhase(t)` ger approaching/arriving/dwelling/departing/away med rätt fas ur tidtabellen; indikatorn aktiv bara inom N rutor från rälsen; vindzonen = rutor längs perrongkanten | | M8 Progression & sparfil | Sparfil round-trip (skriv → läs ger samma tillstånd inkl. seed); skill tree-krav; upplåsningar efter rätt bana; "< 33 %"-regeln kontrolleras mot itemlistan | | 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 | | M10 Story-innehåll | Alla banor lösbara (redan automatiskt); flerbytesbanor har sammanhängande byten; sista banan slutar på flygplatsen | diff --git a/doc/plan.md b/doc/plan.md index 19007a2..e26e443 100644 --- a/doc/plan.md +++ b/doc/plan.md @@ -33,8 +33,9 @@ brasse-linux01-runnern, inte Pi5:n. ## Milstolpar M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar ✓ -→ M4 lämmeltåget ✓ → M5 minikarta ✓ → M6 kort/biljetter/minispel → M7 -svårighet/poäng/debuffar → M8 progression/sparfiler → M9 navigeringslägen → +→ M4 lämmeltåget ✓ → M5 minikarta ✓ → M6 kort/biljetter/minispel ✓ → M7 +svårighet/poäng/debuffar → M7b tåg som kommer och går (vind, indikator, +animation) → M8 progression/sparfiler → M9 navigeringslägen → M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release. Innehåll per milstolpe: [roadmap.md › Milstolpar](roadmap.md#milstolpar). Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md). diff --git a/doc/roadmap.md b/doc/roadmap.md index 01a8118..0ca330d 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -60,6 +60,7 @@ metro-puzzle-jp/ │ ├── level/ banformat, validering, BFS (ren Go) │ ├── sim/ rutbaserad rörelse, kollision, kamera, banomgång (Run), sällskap (Party), dimma (Fog) – ren Go │ ├── lang/ ordbok, tokenisering, kunskap, delvis översättning, engelska-andel, hjälptid – ren Go +│ ├── minigame/ påfyllning, biljettköp, trängsel, tågresa – seedade, ren Go │ ├── game/ Ebitengine-loop, scener, input, rendering, skriptläge ← får använda Ebitengine │ ├── archtest/ beroenderegler │ └── … kommande: lang/, nav/, score/, save/, minigame/, ui/, audio/ @@ -254,18 +255,37 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n. syns från start – `TestFogRevealsAlongCorridorsNotThroughWalls`, `TestLandmarksAlwaysVisible`, `TestFogGrowsWithMovementAndResetsPerRun`. ✓ -### M6 – Kort, biljetter, telefon och minispel +### M6 – Kort, biljetter, telefon och minispel ✓ (klar 2026-10-24) - **Mål:** resurshantering och de seedade minispelen. -- Kortsaldo som dras i spärren; tomt kort → hitta automat → påfyllningsminispel. -- Separata biljetter på svårare banor → biljettköpsminispel (rätt biljett). -- Kort i telefonen: speed-buff + procentchans för omskanning. Batteri som - tar slut på långa banor → tvingar till biljett. -- Minispel på tåget: få plats, komma av, gå av på rätt station. -- **Teknik:** `internal/minigame/*` med gemensamt gränssnitt - (`Start(seed) / Update / Result`), all slump via del-seeds. +- **Plånbok** (`sim.Wallet`, följer med mellan banor): IC-kort (2000 ¥), + kontanter (5000 ¥), ev. pappersbiljett, kortet i telefonen + batteri, + totalt spenderat (poäng i M7). Spärren drar från kortet; kiosken också. +- **Påfyllning** (automaten T): minispel – hitta チャージ bland japanska + knappar i seedad ordning (fel knapp = straffsekund), välj belopp, håll + Enter för att mata in sedlar. Kontanter → kort. +- **Biljettbanor** (`ticketRequired`, bana 002): spärren kräver + pappersbiljett i stället för kort. Automaten visar en taxetavla med + resmål på japanska i seedad ordning – köp rätt resmål kontant. Tåget + släpper bara på den vars biljett gäller dit; biljetten förbrukas. +- **Kortet i telefonen** (P i menyn, `-phone`): fart ×1,1, men 15 % + seedad chans per spärr att behöva skanna igen (1,5 s stillastående). + Batteriet laddar ur på 15 min; dött = kortet oanvändbart → köp biljett. +- **Fullt tåg** (`crowded`, 002:s rätta tåg): tidsminispel – tryck Enter + när markören är i den gröna zonen, två försök, annars går tåget + (räknas som missat tåg, M7). +- **Tågresan** (alla banor): stationerna passerar med 次は-utrop; kliv av + med Enter vid resmålet. Fel station eller att åka förbi förlorar banan; + på fel tåg finns resmålet inte på linjen. Namnen renderas genom + kunskapen, med romaji efter svårighetens engelska-andel. `rideStops` + styr längden (3 standard, 002 har 4). +- **Teknik:** `internal/minigame` (ren Go, gemensamt `Game`-gränssnitt, + all slump från del-seeds, 5 tester), `sim.Run.StepMini` (klockan och + sällskapet tickar, rörelsen pausar), vyer i `internal/game/minigame_view.go`, + skriptkommandon `hold N` och `waitstop`. - **Klar när:** varje minispel är deterministiskt för en seed (test) och - spelbart. + spelbart; `TestTicketLevelFlow` spelar hela 002 headless: fel biljett + stoppas, rätt biljett, spärr, trängsel, resa, vinst. ✓ ### M7 – Svårighet, tid, poäng och debuffar @@ -277,6 +297,25 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n. - Svårighetsprofil per bana i JSON. - **Teknik:** `internal/score`, `internal/sim/stats`. +### M7b – Tåg som kommer och går (Björns tillägg 2026-10-24) + +- **Mål:** tågen ska kännas – man ska se och förstå när de kommer och går, + särskilt när missade tåg kostar (M7). +- **Ankomstindikator nära rälsen:** några sekunder före ankomst (från + tidtabellen) tänds lampor vid perrongkanten och strålkastare syns i + tunneln/vid kartkanten, bara för den som står nära rälsen. +- **Vind:** när tåget kör in eller avgår blåser det längs perrongen – + partiklar (damm, papper) och fladdrande kläder/hår på alla som står vid + kanten. Kan senare påverka items (hattar som blåser av, M8). +- **In- och utkörningsanimation:** tågvagnarna rullar in från kartkanten, + bromsar, står (dörrar öppna) och accelererar ut – samma för bussar. Ersätter + dagens "dyker upp/försvinner". +- **Teknik:** ren logik i `sim` (`TrainPhase(t)`: approaching/arriving/ + dwelling/departing/away med fas 0–1 ur `level.Schedule`), partiklar och + tweening i `internal/game`. Ljudet (M12) hakar på samma faser. +- **Klar när:** faserna beräknas rätt ur tidtabellen (testat), indikatorn + syns bara nära rälsen, tåget rullar in/ut i stället för att blinka till. + ### M8 – Progression, items och sparfiler - **Mål:** något att låsa upp och en anledning att fortsätta. diff --git a/doc/spelbeskrivning.md b/doc/spelbeskrivning.md index 5737962..9aab742 100644 --- a/doc/spelbeskrivning.md +++ b/doc/spelbeskrivning.md @@ -67,6 +67,17 @@ otydligt står tolkningen i [§15](#15-tolkningar-av-originalet). - Tåg avgår enligt tidtabell. Missar du ett tåg får du vänta på nästa, och på svåra banor kostar det tid du inte har (se §9). +**Tåg som kommer och går** (tillägg 2026-10-24): + +- När ett tåg kör in eller avgår **blåser det nära rälsen** – en synlig + vind (damm, papper, kläder och hår som fladdrar) för den som står vid + perrongkanten. +- Står man nära rälsen ska det finnas en **visuell indikator på att ett tåg + är på väg in** till stationen, innan det syns: vinden drar igång, lampor + vid perrongkanten, strålkastare i tunneln. +- **Tåg och bussar har animationer** när de kör in till och ut från sina + stationer och hållplatser, i stället för att bara dyka upp och försvinna. + ## 5. Sällskapet – lämmeltåget - Dina polare följer efter dig i en rad – därav *lämmeltåget*. diff --git a/internal/game/game.go b/internal/game/game.go index 56f0568..e1a826f 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -9,6 +9,7 @@ import ( "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" ) // Intern upplösning. Allt ritas i denna storlek och skalas sedan upp med @@ -30,6 +31,7 @@ type Scene interface { type Game struct { Assets *Assets Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8) + Wallet *sim.Wallet // pengar, kort, telefon, biljett – följer med mellan banor fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar) scene Scene next Scene @@ -46,6 +48,8 @@ type Options struct { KnownWords []string // ord spelaren kan från start (för tester/skärmdumpar) Points int // språkpoäng från start (för tester/skärmdumpar) Seed uint64 // >0: fast seed för banorna (för tester/skärmdumpar) + Phone bool // kortet i telefonen från start + CardYen int // >=0: kortets startsaldo (för tester/skärmdumpar), -1 = standard } // New laddar resurser och bygger spelet. @@ -60,13 +64,18 @@ func New(opt Options) (*Game, error) { } g.Know.Points = opt.Points g.fixedSeed = opt.Seed + g.Wallet = sim.NewWallet() + g.Wallet.Phone = opt.Phone + if opt.CardYen >= 0 { + g.Wallet.Card = opt.CardYen + } if opt.Script != "" { if g.script, err = parseScript(opt.Script); err != nil { return nil, err } } if opt.LevelID != "" { - s, err := NewLevelScene(a, opt.LevelID, g.Know, g.seed()) + s, err := NewLevelScene(a, opt.LevelID, g.Know, g.Wallet, g.seed()) if err != nil { return nil, err } diff --git a/internal/game/input.go b/internal/game/input.go index 5f11924..d8e0a45 100644 --- a/internal/game/input.go +++ b/internal/game/input.go @@ -81,6 +81,40 @@ func wordsJustPressed() bool { return virtual.pressed["l"] || inpututil.IsKeyJustPressed(ebiten.KeyL) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftRight) } +// miniMove: -1/+1 vid just-tryck på pil/WASD (upp/vänster = -1, ner/höger = +1). +func miniMove() int { + switch { + case virtual.pressed["up"] || virtual.pressed["left"] || inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyArrowLeft) || + inpututil.IsKeyJustPressed(ebiten.KeyW) || inpututil.IsKeyJustPressed(ebiten.KeyA) || + gamepadJustPressed(ebiten.StandardGamepadButtonLeftTop) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftLeft): + return -1 + case virtual.pressed["down"] || virtual.pressed["right"] || inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) || inpututil.IsKeyJustPressed(ebiten.KeyArrowRight) || + inpututil.IsKeyJustPressed(ebiten.KeyS) || inpututil.IsKeyJustPressed(ebiten.KeyD) || + gamepadJustPressed(ebiten.StandardGamepadButtonLeftBottom) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftRight): + return 1 + } + return 0 +} + +// confirmHeld: Enter/mellanslag eller A hålls nere (sedelinmatning). +func confirmHeld() bool { + if virtual.pressed["enter"] || virtual.holdEnter { + return true + } + if ebiten.IsKeyPressed(ebiten.KeyEnter) || ebiten.IsKeyPressed(ebiten.KeySpace) { + return true + } + for _, id := range ebiten.AppendGamepadIDs(nil) { + if ebiten.IsStandardGamepadLayoutAvailable(id) && ebiten.IsStandardGamepadButtonPressed(id, ebiten.StandardGamepadButtonRightBottom) { + return true + } + } + return false +} + +// phoneJustPressed: P = kortet i telefonen av/på (meny). +func phoneJustPressed() bool { return virtual.pressed["p"] || inpututil.IsKeyJustPressed(ebiten.KeyP) } + // 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/minigame_view.go b/internal/game/minigame_view.go new file mode 100644 index 0000000..0bd536a --- /dev/null +++ b/internal/game/minigame_view.go @@ -0,0 +1,156 @@ +package game + +import ( + "fmt" + "image" + "image/color" + "strings" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame" +) + +// Minispelen ritas som en panel över banan. Logiken bor i internal/minigame. + +func (s *LevelScene) drawMinigame(g *Game, screen *ebiten.Image) { + a := g.Assets + r := s.run + panel := image.Rect(12, 28, ScreenW-12, ScreenH-10) + vector.DrawFilledRect(screen, float32(panel.Min.X), float32(panel.Min.Y), float32(panel.Dx()), float32(panel.Dy()), colPanel, false) + vector.DrawFilledRect(screen, float32(panel.Min.X), float32(panel.Min.Y), float32(panel.Dx()), 3, colKnown, false) + switch m := r.Mini.(type) { + case *minigame.TopUp: + s.drawTopUp(a, screen, m, panel) + case *minigame.Ticket: + s.drawTicket(a, screen, m, panel) + case *minigame.Crowd: + s.drawCrowd(a, screen, m, panel) + case *minigame.Ride: + s.drawRide(a, screen, m, panel) + } +} + +// jp ritar japansk text sedd genom spelarens kunskap (kända ord i gult). +func (s *LevelScene) jp(a *Assets, screen *ebiten.Image, text string, x, y int) { + rd := lang.Render(a.Dict, s.run.Know, text, UILang) + a.drawLines(screen, []line{shownLine(rd.Original, colText, false)}, x, y) +} + +func (s *LevelScene) drawTopUp(a *Assets, screen *ebiten.Image, m *minigame.TopUp, p image.Rectangle) { + x, y := p.Min.X+8, p.Min.Y+8 + a.DrawBig(screen, "チャージ", x, y, colKnown) + a.Draw(screen, a.T("mini.topup.title"), x+60, y+2, colText) + y += 20 + switch m.Stage { + case minigame.TopUpPickButton: + a.Draw(screen, a.T("mini.topup.pick"), x, y, colDim) + y += LineH + 4 + for i, b := range m.Buttons { + bx, by := x+(i%2)*140, y+(i/2)*26 + col := colBar + if i == m.Cursor { + col = color.RGBA{90, 105, 136, 255} + } + vector.DrawFilledRect(screen, float32(bx), float32(by), 132, 22, col, false) + s.jp(a, screen, b, bx+6, by+5) + } + if m.Penalty > 0 { + a.Draw(screen, "ピッ! "+a.T("mini.topup.wrong"), x, y+56, colBad) + } + case minigame.TopUpPickAmount: + a.Draw(screen, a.T("mini.topup.amount"), x, y, colDim) + y += LineH + 4 + for i, amt := range m.Amounts { + col := colText + if i == m.Cursor { + vector.DrawFilledRect(screen, float32(x-2), float32(y+i*LineH-1), 120, LineH, colBar, false) + col = colKnown + } + a.Draw(screen, fmt.Sprintf("%d円 (%d¥)", amt, amt), x, y+i*LineH, col) + } + case minigame.TopUpFeed: + a.Draw(screen, fmt.Sprintf(a.T("mini.topup.feed"), m.Amount), x, y, colText) + y += LineH + 8 + vector.DrawFilledRect(screen, float32(x), float32(y), float32(p.Dx()-16), 8, colBar, false) + vector.DrawFilledRect(screen, float32(x), float32(y), float32(float64(p.Dx()-16)*m.Fill), 8, colGood, false) + } + a.Draw(screen, a.T("mini.cancel"), x, p.Max.Y-16, colDim) +} + +func (s *LevelScene) drawTicket(a *Assets, screen *ebiten.Image, m *minigame.Ticket, p image.Rectangle) { + x, y := p.Min.X+8, p.Min.Y+8 + a.DrawBig(screen, "きっぷ", x, y, colKnown) + a.Draw(screen, a.T("mini.ticket.title"), x+48, y+2, colText) + y += 20 + a.Draw(screen, fmt.Sprintf(a.T("mini.ticket.hint"), s.run.Level.Destination.Get("romaji"), m.Cash), x, y, colDim) + y += LineH + 4 + for i, o := range m.Options { + ry := y + i*LineH + col := colText + if i == m.Cursor { + vector.DrawFilledRect(screen, float32(x-2), float32(ry-1), float32(p.Dx()-12), LineH, colBar, false) + } + if o.Fare > m.Cash { + col = colDim + } + s.jp(a, screen, o.Dest, x, ry) + fare := fmt.Sprintf("%d円", o.Fare) + a.Draw(screen, fare, p.Max.X-10-a.Width(fare), ry, col) + } + a.Draw(screen, a.T("mini.cancel"), x, p.Max.Y-16, colDim) +} + +func (s *LevelScene) drawCrowd(a *Assets, screen *ebiten.Image, m *minigame.Crowd, p image.Rectangle) { + x, y := p.Min.X+8, p.Min.Y+8 + a.DrawBig(screen, "満員", x, y, colBad) + a.Draw(screen, a.T("mini.crowd.title"), x+36, y+2, colText) + y += 24 + a.Draw(screen, fmt.Sprintf(a.T("mini.crowd.hint"), m.MaxTries-m.Attempts), x, y, colDim) + y += LineH + 10 + w := float32(p.Dx() - 16) + vector.DrawFilledRect(screen, float32(x), float32(y), w, 14, colBar, false) + vector.DrawFilledRect(screen, float32(x)+w*float32(m.ZoneLo), float32(y), w*float32(m.ZoneHi-m.ZoneLo), 14, colGood, false) + vector.DrawFilledRect(screen, float32(x)+w*float32(m.Pos)-1, float32(y)-3, 3, 20, colText, false) +} + +func (s *LevelScene) drawRide(a *Assets, screen *ebiten.Image, m *minigame.Ride, p image.Rectangle) { + x, y := p.Min.X+8, p.Min.Y+8 + a.DrawBig(screen, "次は", x, y, colDim) + a.Draw(screen, a.T("mini.ride.title"), x+36, y+2, colText) + y += 22 + cur := m.Current() + a.DrawBig(screen, cur, x, y, colText) + rd := lang.Render(a.Dict, s.run.Know, cur, UILang) + a.drawLines(screen, []line{shownLine(rd.Partial, colText, false)}, x+a.Width(cur)*12/10+12, y+2) + lvl := s.run.Level + if lang.ShowEnglish(lvl.ID, m.Index, 77, lvl.Difficulty) { + if e := a.Dict.Lookup(cur); e != nil { + a.Draw(screen, e.Romaji, x, y+16, colEnglish) + } + } + y += 34 + // Linjen: en prick per station, den aktuella större. + for i := range m.Stops { + cx := x + 6 + i*22 + col := colDim + if i == m.Index { + col = colText + } + if i < m.Index { + col = colBar + } + vector.DrawFilledRect(screen, float32(cx), float32(y+3), 14, 2, colBar, false) + vector.DrawFilledRect(screen, float32(cx-3), float32(y), 6, 8, col, false) + } + y += 14 + vector.DrawFilledRect(screen, float32(x), float32(y), float32(p.Dx()-16), 3, colBar, false) + vector.DrawFilledRect(screen, float32(x), float32(y), float32(float64(p.Dx()-16)*(1-m.Timer/m.StopSec)), 3, colKnown, false) + hint := a.T("mini.ride.hint") + if dest := lvl.Destination.Get("romaji"); dest != "" { + hint = strings.Replace(hint, "%s", dest, 1) + } + a.Draw(screen, hint, x, p.Max.Y-16, colGood) +} diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go index 3de34b1..69c94eb 100644 --- a/internal/game/scene_level.go +++ b/internal/game/scene_level.go @@ -13,6 +13,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/rng" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" ) @@ -22,6 +23,7 @@ const ( deniedShowSec = 2.0 learnedShowSec = 3.0 progressedShowSec = 2.5 + flashShowSec = 2.5 ) // LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run; @@ -36,14 +38,14 @@ type LevelScene struct { // NewLevelScene laddar banan och ställer spelaren på startrutan. know är // spelarens japanska, som delas mellan banor; seed styr omgångens slump. -func NewLevelScene(a *Assets, id string, know *lang.Knowledge, seed rng.Seed) (*LevelScene, error) { +func NewLevelScene(a *Assets, id string, know *lang.Knowledge, wallet *sim.Wallet, seed rng.Seed) (*LevelScene, error) { lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json")) if err != nil { return nil, err } s := &LevelScene{ id: id, - run: sim.NewRun(lvl, sim.RunOptions{Dict: a.Dict, Know: know, Seed: seed}), + run: sim.NewRun(lvl, sim.RunOptions{Dict: a.Dict, Know: know, Wallet: wallet, Seed: seed}), cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)), } s.followPlayer() @@ -62,12 +64,27 @@ func (s *LevelScene) TrainHere() bool { return tr != nil && at } +// RideAtDest rapporterar om tågresan står vid resmålet (skriptläget). Utan +// pågående resa: true, så att skriptet inte hänger. +func (s *LevelScene) RideAtDest() bool { + g, ok := s.run.Mini.(*minigame.Ride) + return !ok || g.Index == g.Dest +} + func (s *LevelScene) followPlayer() { x, y := s.run.Player.WorldPos(TileSize) s.cam.Follow(x+TileSize/2, y+TileSize/2) } func (s *LevelScene) Update(g *Game) error { + if s.run.Mini != nil { + // Minispel: all input går dit; Esc avbryter minispelet, inte banan. + s.run.StepMini(dt, minigame.Input{Move: miniMove(), Confirm: confirmJustPressed(), Hold: confirmHeld(), Cancel: backJustPressed()}) + if s.run.Outcome != sim.Ongoing { + g.SetScene(NewResultScene(s)) + } + return nil + } if backJustPressed() { m, err := NewMenuScene(g.Assets) if err != nil { @@ -172,6 +189,9 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) { s.drawUrge(g, screen, s.run.Party.Members[0], int(ox+math.Round(px)), int(oy+math.Round(py))) s.drawHUD(g, screen) + if s.run.Mini != nil { + s.drawMinigame(g, screen) + } } // memberTints färgar följarnas sprites tills de har egna. @@ -217,14 +237,24 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { if r.Know.ShowsDestinationJP() { // hint från nivå 1: resmålet på japanska, att matcha mot skyltarna dest += " " + r.Level.Destination.Get("ja") } - a.Draw(screen, r.Level.Name.Get(UILang), 4, 0, colText) - a.Draw(screen, a.T("hud.destination")+": "+dest, 4+a.Width(r.Level.Name.Get(UILang))+12, 0, colKnown) - a.Draw(screen, fmt.Sprintf("%s %3.0fs %s %d %s %d¥ %s Lv%d·%d", a.T("hud.time"), r.Time, - a.T("hud.steps"), r.Steps, a.T("hud.card"), r.Card.Balance, a.T("hud.japanese"), r.Know.Level(), r.Know.Count()), 4, LineH, colDim) + name := r.Level.Name.Get(UILang) + 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) + wallet := fmt.Sprintf("%s %3.0fs %s %d¥ %s %d¥", a.T("hud.time"), r.Time, a.T("hud.card"), r.Wallet.Card, a.T("hud.cash"), r.Wallet.Cash) + if r.Wallet.Phone { + wallet += fmt.Sprintf(" %s %.0f%%", a.T("hud.phone"), r.Wallet.Battery) + } + if r.Wallet.Ticket != "" { + wallet += " " + a.T("hud.ticket") + " " + r.Wallet.Ticket + } + a.Draw(screen, wallet, 4, LineH, colDim) s.drawMeters(g, screen) - if r.Outcome != sim.Ongoing { - return // resultatpanelen tar över; ingen inforuta eller minikarta + if r.Outcome != sim.Ongoing || r.Mini != nil { + return // resultatpanelen eller minispelet tar över; ingen inforuta eller minikarta } lines, accent, bar := s.infoLines(a) if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec { @@ -241,6 +271,13 @@ 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) { + col := colBad + if r.FlashKey == sim.FlashRescan { + col = colWarn + } + lines = append(lines, a.wrapLine(plainLine(a.T(r.FlashKey), col), ScreenW-8)...) + } // Inforutans läge: nederkant, men flyttas upp under HUD:en om spelaren // (eller tåget under hen) annars skulle hamna bakom den. @@ -344,9 +381,21 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar return []line{plainLine(fmt.Sprintf(a.T("hud.kiosk_prompt"), n, sim.KioskPriceYen*n), colGood)}, colGood, false } return []line{plainLine(a.T("hud.kiosk_none"), 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 + } + return []line{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 { - return []line{plainLine(a.T("hud.gate_denied"), colBad)}, colBad, false + key := "hud.gate_denied" + switch { + case lvl.TicketRequired: + key = sim.FlashNoTicket + case r.Wallet.Phone && r.Wallet.Battery <= 0: + key = "hud.phone_dead" + } + return []line{plainLine(a.T(key), colBad)}, colBad, false } return nil, accent, false } diff --git a/internal/game/scene_menu.go b/internal/game/scene_menu.go index 8783884..2ccc106 100644 --- a/internal/game/scene_menu.go +++ b/internal/game/scene_menu.go @@ -58,10 +58,13 @@ func (m *MenuScene) Update(g *Game) error { g.SetScene(NewWordsScene(g.Assets, m)) return nil } + if phoneJustPressed() { + g.Wallet.Phone = !g.Wallet.Phone + } if n := len(m.entries); n > 0 { m.sel = (m.sel + menuMove() + n) % n if confirmJustPressed() { - s, err := NewLevelScene(g.Assets, m.entries[m.sel].id, g.Know, g.seed()) + s, err := NewLevelScene(g.Assets, m.entries[m.sel].id, g.Know, g.Wallet, g.seed()) if err != nil { m.err = err return nil @@ -91,8 +94,12 @@ func (m *MenuScene) Draw(g *Game, screen *ebiten.Image) { if m.err != nil { a.Draw(screen, "FEL: "+m.err.Error(), 8, ScreenH-40, colBad) } - a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d", a.T("hud.japanese"), g.Know.Level(), g.Know.Count(), a.T("hud.words"), - a.T("words.points"), g.Know.Points), 8, ScreenH-38, colDim) + onoff := a.T("menu.off") + if g.Wallet.Phone { + onoff = a.T("menu.on") + } + a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d %s %d¥ %s %d¥ %s", a.T("hud.japanese"), g.Know.Level(), g.Know.Count(), a.T("hud.words"), + a.T("words.points"), g.Know.Points, a.T("hud.card"), g.Wallet.Card, a.T("hud.cash"), g.Wallet.Cash, fmt.Sprintf(a.T("menu.phone"), onoff)), 8, ScreenH-38, colDim) a.Draw(screen, a.T("menu.hint"), 8, ScreenH-26, colDim) a.Draw(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-13, colDim) } diff --git a/internal/game/scene_result.go b/internal/game/scene_result.go index ac60c71..e170d92 100644 --- a/internal/game/scene_result.go +++ b/internal/game/scene_result.go @@ -23,7 +23,7 @@ func (r *ResultScene) Update(g *Game) error { case wordsJustPressed(): g.SetScene(NewWordsScene(g.Assets, r)) case inpututil_R(): - s, err := NewLevelScene(g.Assets, r.from.id, g.Know, g.seed()) + s, err := NewLevelScene(g.Assets, r.from.id, g.Know, g.Wallet, g.seed()) if err != nil { return err } @@ -44,7 +44,7 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { a := g.Assets vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 120, colPanel, false) - accent, headline := colBad, a.T("level.wrong_train") + accent, headline := colBad, a.T("level.lost") if run.Outcome == sim.Won { accent, headline = colGood, a.T("level.cleared") } @@ -63,8 +63,11 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { a.Draw(screen, fmt.Sprintf("%s %s %s → %s", a.T("hud.track"), tr.Track, tr.Line.Get("romaji"), tr.Destination.Get("romaji")), 28, y, colDim) y += LineH if run.Outcome == sim.Lost { - for _, l := range a.wrapLine(plainLine(fmt.Sprintf(a.T("result.wrong_detail"), tr.Destination.Get("romaji"), - run.Level.Destination.Get("romaji")), colText), ScreenW-56) { + 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")) + } + for _, l := range a.wrapLine(plainLine(reason, colText), ScreenW-56) { a.drawLines(screen, []line{l}, 28, y) y += LineH } @@ -74,8 +77,8 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { a.Draw(screen, fmt.Sprintf("%s %.1fs (%s %.0fs) %s %d %s %.0f %%", a.T("hud.time"), run.Time, a.T("result.par"), run.ParSec(), a.T("hud.steps"), run.Steps, a.T("result.explored"), 100*run.Fog.Explored(run.Level)), 28, y, colText) y += LineH - a.Draw(screen, fmt.Sprintf("%s %d %s %d¥ %s %d", a.T("result.gates"), run.GatePasses, a.T("result.card"), run.Card.Balance, - a.T("result.served"), run.Served), 28, y, colText) + a.Draw(screen, fmt.Sprintf("%s %d %s %d¥ %s %d¥ %s %d %s %d", a.T("result.gates"), run.GatePasses, a.T("result.card"), run.Wallet.Card, + a.T("result.spent"), run.Wallet.Spent, a.T("result.missed"), run.MissedTrains, a.T("result.served"), run.Served), 28, y, colText) y += LineH if run.Outcome == sim.Won { msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("words.points")) diff --git a/internal/game/script.go b/internal/game/script.go index 69f603a..864b551 100644 --- a/internal/game/script.go +++ b/internal/game/script.go @@ -19,7 +19,9 @@ import ( // Stegen skiljs med ';': // // wait N vänta N bildrutor +// hold N håll Enter nere N bildrutor (sedelinmatning) // waittrain vänta tills tåget vid perrongen spelaren står på är inne +// waitstop vänta tills tågresan står vid resmålet // press KEY tryck enter | esc | r | h | l | up | down en bildruta // move DIR N gå N rutor åt up | down | left | right (stannar exakt där) // shot PATH spara aktuell bild som PNG (intern upplösning × shotScale) @@ -51,8 +53,9 @@ type script struct { // virtual är den simulerade inputen som input.go läser före riktig input. var virtual struct { - held sim.Dir - pressed map[string]bool + held sim.Dir + pressed map[string]bool + holdEnter bool } func parseScript(src string) (*script, error) { @@ -64,9 +67,9 @@ func parseScript(src string) (*script, error) { } st := scriptStep{cmd: f[0]} switch f[0] { - case "wait": + case "wait", "hold": if len(f) != 2 { - return nil, fmt.Errorf("script: 'wait N'") + return nil, fmt.Errorf("script: '%s N'", f[0]) } st.n, _ = strconv.Atoi(f[1]) case "press": @@ -88,7 +91,7 @@ func parseScript(src string) (*script, error) { return nil, fmt.Errorf("script: 'shot PATH'") } st.arg = f[1] - case "quit", "waittrain": + case "quit", "waittrain", "waitstop": default: return nil, fmt.Errorf("script: okänt kommando %q", f[0]) } @@ -118,13 +121,17 @@ type playerTiler interface { } // trainWaiter implementeras av scener där man kan vänta på ett tåg. -type trainWaiter interface{ TrainHere() bool } +type trainWaiter interface { + TrainHere() bool + RideAtDest() bool +} // tick körs i början av varje Update. frame är senast ritade bild. // Returnerar ebiten.Termination när skriptet vill avsluta. func (s *script) tick(g *Game, frame *ebiten.Image) error { virtual.held = sim.None virtual.pressed = map[string]bool{} + virtual.holdEnter = false if s.i >= len(s.steps) { return nil } @@ -135,6 +142,12 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error { if s.counter >= st.n { s.advance() } + case "hold": + virtual.holdEnter = true + s.counter++ + if s.counter >= st.n { + s.advance() + } case "press": virtual.pressed[st.arg] = true s.advance() @@ -174,6 +187,10 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error { if tw, ok := g.scene.(trainWaiter); !ok || tw.TrainHere() { s.advance() } + case "waitstop": + if tw, ok := g.scene.(trainWaiter); !ok || tw.RideAtDest() { + s.advance() + } case "shot": if err := saveShot(frame, st.arg); err != nil { return err diff --git a/internal/lang/dictionary.go b/internal/lang/dictionary.go index 9bb87ca..914cadf 100644 --- a/internal/lang/dictionary.go +++ b/internal/lang/dictionary.go @@ -19,6 +19,7 @@ type Entry struct { En string `json:"en"` Sv string `json:"sv"` Tier int `json:"tier"` + Kind string `json:"kind"` // valfri kategori, t.ex. "station" } // Translation returnerar översättningen på UI-språket (sv/en) med fallback. @@ -91,6 +92,18 @@ func (d *Dictionary) Len() int { return len(d.entries) } // Words returnerar alla ord, längsta först. func (d *Dictionary) Words() []string { return append([]string(nil), d.words...) } +// OfKind returnerar orden i en kategori (t.ex. "station"), sorterade. +func (d *Dictionary) OfKind(kind string) []string { + var out []string + for _, w := range d.words { + if d.entries[w].Kind == kind { + out = append(out, w) + } + } + sort.Strings(out) + return out +} + // IsKana rapporterar om tecknet är hiragana eller katakana (inkl. ー). func IsKana(r rune) bool { return unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) || r == 'ー' diff --git a/internal/lang/lang_test.go b/internal/lang/lang_test.go index 6488924..28d3f1c 100644 --- a/internal/lang/lang_test.go +++ b/internal/lang/lang_test.go @@ -248,3 +248,15 @@ func TestBuyWordsWithPoints(t *testing.T) { t.Fatal("negativa stjärnor ger inget") } } + +func TestOfKind(t *testing.T) { + d := testDict() + d.Add("池袋", Entry{Kana: "いけぶくろ", Romaji: "Ikebukuro", En: "Ikebukuro", Sv: "Ikebukuro", Tier: 3, Kind: "station"}) + d.Add("品川", Entry{Kana: "しながわ", Romaji: "Shinagawa", En: "Shinagawa", Sv: "Shinagawa", Tier: 3, Kind: "station"}) + if got := strings.Join(d.OfKind("station"), ","); got != "品川,池袋" && got != "池袋,品川" { + t.Fatalf("OfKind = %q", got) + } + if len(d.OfKind("x")) != 0 { + t.Fatal("okänd kategori är tom") + } +} diff --git a/internal/level/level.go b/internal/level/level.go index ef3d8be..40bd318 100644 --- a/internal/level/level.go +++ b/internal/level/level.go @@ -72,6 +72,7 @@ const ( DefaultDwellSec = 10.0 DefaultFareYen = 200 MaxParty = 5 + DefaultRideStops = 3 ) // Status returnerar om tåget står vid perrongen vid tiden t, och hur många @@ -94,6 +95,7 @@ type Train struct { Line Text `json:"line"` // linjens namn Destination Text `json:"destination"` // slutstation Correct bool `json:"correct"` // tar spelaren till rätt resmål + Crowded bool `json:"crowded"` // fullt tåg: minispel för att få plats Schedule Schedule `json:"schedule"` } @@ -106,17 +108,19 @@ type SignText struct { // Level är en bana. type Level struct { - ID string `json:"id"` - Name Text `json:"name"` - Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår) - Destination Text `json:"destination"` // dit spelaren ska - 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) - FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard) - Grid []string `json:"grid"` - Trains []Train `json:"trains"` - Signs []SignText `json:"signs"` + ID string `json:"id"` + Name Text `json:"name"` + Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår) + Destination Text `json:"destination"` // dit spelaren ska + 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) + TicketRequired bool `json:"ticketRequired"` // spärren kräver pappersbiljett (köps i automaten) i stället för kort + RideStops int `json:"rideStops"` // antal stationer på tågresan (0 = DefaultRideStops) + FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard) + Grid []string `json:"grid"` + Trains []Train `json:"trains"` + Signs []SignText `json:"signs"` } // Point är en rutkoordinat. @@ -240,6 +244,15 @@ func (l *Level) Validate() error { if l.Party == 0 { l.Party = 1 } + if l.RideStops == 0 { + l.RideStops = DefaultRideStops + } + if l.RideStops < 2 || l.RideStops > 8 { + return fmt.Errorf("level %s: rideStops måste vara 2–8", l.ID) + } + if l.TicketRequired && len(l.Find(TicketMachine)) == 0 { + return fmt.Errorf("level %s: biljettkrav utan biljettautomat (T)", l.ID) + } if l.Party > MaxParty { return fmt.Errorf("level %s: sällskapet får vara högst %d", l.ID, MaxParty) } diff --git a/internal/level/level_test.go b/internal/level/level_test.go index 6edb413..50a63fd 100644 --- a/internal/level/level_test.go +++ b/internal/level/level_test.go @@ -288,3 +288,21 @@ func TestPartyAndParDefaults(t *testing.T) { t.Fatal("W och K ska vara passerbara rutor") } } + +func TestTicketAndRideFields(t *testing.T) { + l := mustParse(t, sample) + if l.RideStops != DefaultRideStops || l.TicketRequired || l.Trains[0].Crowded { + t.Fatalf("standard: stops=%d ticket=%v crowded=%v", l.RideStops, l.TicketRequired, l.Trains[0].Crowded) + } + if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "ticketRequired": true`, 1))); err == nil { + t.Fatal("biljettkrav utan automat ska ge fel") + } + if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "rideStops": 1`, 1))); err == nil { + t.Fatal("för få stopp ska ge fel") + } + ok := strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#ST.#P#"`, 1), `"difficulty": 1`, `"difficulty": 1, "ticketRequired": true, "rideStops": 4`, 1) + l2 := mustParse(t, ok) + if !l2.TicketRequired || l2.RideStops != 4 { + t.Fatal("fälten ska läsas") + } +} diff --git a/internal/minigame/crowd.go b/internal/minigame/crowd.go new file mode 100644 index 0000000..88e65e2 --- /dev/null +++ b/internal/minigame/crowd.go @@ -0,0 +1,56 @@ +package minigame + +import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" + +// Crowd är "få plats på det fulla tåget": en markör pendlar över en stapel, +// tryck Enter när den är i den gröna zonen. Två försök, sedan går tåget. +type Crowd struct { + base + Pos float64 // markörens läge 0..1 + dir float64 + Speed float64 // enheter per sekund + ZoneLo float64 + ZoneHi float64 + Attempts int + MaxTries int +} + +const crowdZoneWidth = 0.22 + +// NewCrowd seedar zonens läge och markörens fart. +func NewCrowd(seed rng.Seed, tries int) *Crowd { + r := newRand(seed, "crowd") + lo := r.between(0.1, 0.9-crowdZoneWidth) + return &Crowd{dir: 1, Speed: r.between(0.9, 1.5), ZoneLo: lo, ZoneHi: lo + crowdZoneWidth, MaxTries: max(1, tries)} +} + +// InZone rapporterar om markören är i zonen just nu. +func (g *Crowd) InZone() bool { return g.Pos >= g.ZoneLo && g.Pos <= g.ZoneHi } + +// Update pendlar markören och tar emot försök. +func (g *Crowd) Update(in Input, dt float64) { + if g.done { + return + } + if in.Cancel { + g.done = true + return + } + g.Pos += g.dir * g.Speed * dt + for g.Pos < 0 || g.Pos > 1 { + if g.Pos < 0 { + g.Pos = -g.Pos + } else { + g.Pos = 2 - g.Pos + } + g.dir = -g.dir + } + if in.Confirm { + g.Attempts++ + if g.InZone() { + g.done, g.success = true, true + } else if g.Attempts >= g.MaxTries { + g.done = true + } + } +} diff --git a/internal/minigame/minigame.go b/internal/minigame/minigame.go new file mode 100644 index 0000000..a8d5ca5 --- /dev/null +++ b/internal/minigame/minigame.go @@ -0,0 +1,38 @@ +// Package minigame innehåller de små spelen i spelet: fylla på kortet vid +// automaten, köpa rätt biljett, få plats på ett fullt tåg och gå av på rätt +// station. Ren logik utan Ebitengine; all slump kommer från en seed så att +// ett minispel är exakt likadant varje gång för samma seed. +// +// Gemensamt mönster: skapa med en seed, mata Update med input varje +// bildruta, läs Done/Success när det är klart. Renderingen görs av +// internal/game per typ. +package minigame + +import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" + +// Input är vad spelaren gjorde den här bildrutan. +type Input struct { + Move int // -1 / +1 (upp/vänster resp. ner/höger), 0 = inget + Confirm bool // Enter tryckt (just nu) + Hold bool // Enter hålls nere + Cancel bool // Esc tryckt +} + +// Game är ett pågående minispel. +type Game interface { + Update(in Input, dt float64) + Done() bool + Success() bool +} + +// base är det gemensamma tillståndet. +type base struct { + done, success bool +} + +func (b *base) Done() bool { return b.done } +func (b *base) Success() bool { return b.success } + +func newRand(seed rng.Seed, context string) *randSrc { + return &randSrc{seed.Derive(context).New()} +} diff --git a/internal/minigame/minigame_test.go b/internal/minigame/minigame_test.go new file mode 100644 index 0000000..5cc41fe --- /dev/null +++ b/internal/minigame/minigame_test.go @@ -0,0 +1,206 @@ +package minigame + +import ( + "math" + "strings" + "testing" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" +) + +const frame = 1.0 / 60 + +func TestTopUpFlowAndDeterminism(t *testing.T) { + g := NewTopUp(rng.FromInt(1), 5000) + h := NewTopUp(rng.FromInt(1), 5000) + if strings.Join(g.Buttons, ",") != strings.Join(h.Buttons, ",") { + t.Fatal("samma seed ska ge samma knappordning") + } + if strings.Join(g.Buttons, ",") == strings.Join(NewTopUp(rng.FromInt(2), 5000).Buttons, ",") && + strings.Join(g.Buttons, ",") == strings.Join(NewTopUp(rng.FromInt(3), 5000).Buttons, ",") { + t.Fatal("olika seeds ska (nästan alltid) ge olika ordning") + } + // Fel knapp: straff och frysning. + wrong := 0 + for wrong = range g.Buttons { + if g.Buttons[wrong] != "チャージ" { + break + } + } + g.Cursor = wrong + g.Update(Input{Confirm: true}, frame) + if g.Mistakes != 1 || g.Penalty <= 0 || g.Stage != TopUpPickButton { + t.Fatalf("fel knapp: mistakes=%d penalty=%.1f stage=%v", g.Mistakes, g.Penalty, g.Stage) + } + for g.Penalty > 0 { + g.Update(Input{Move: 1}, frame) // ignoreras under straffet + } + // Rätt knapp -> belopp -> mata sedlar. + for g.Buttons[g.Cursor] != "チャージ" { + g.Update(Input{Move: 1}, frame) + } + g.Update(Input{Confirm: true}, frame) + if g.Stage != TopUpPickAmount { + t.Fatal("ska välja belopp nu") + } + g.Update(Input{Move: 1}, frame) + g.Update(Input{Confirm: true}, frame) + if g.Stage != TopUpFeed || g.Amount != 2000 { + t.Fatalf("stage=%v amount=%d", g.Stage, g.Amount) + } + for i := 0; i < 60 && !g.Done(); i++ { + g.Update(Input{Hold: true}, frame) + } + if g.Fill < 0.5 || g.Done() { + t.Fatalf("efter 1 s hållning: fill=%.2f done=%v", g.Fill, g.Done()) + } + g.Update(Input{}, 0.5) // släppt: rinner tillbaka + if g.Fill >= 0.8 { + t.Fatalf("släppt Enter ska tömma: fill=%.2f", g.Fill) + } + for i := 0; i < 600 && !g.Done(); i++ { + g.Update(Input{Hold: true}, frame) + } + if !g.Done() || !g.Success() || g.Amount != 2000 { + t.Fatalf("done=%v success=%v amount=%d", g.Done(), g.Success(), g.Amount) + } + // Kontanter begränsar beloppen; utan pengar är spelet över direkt. + if a := NewTopUp(rng.FromInt(1), 2500).Amounts; len(a) != 2 || a[1] != 2000 { + t.Fatalf("amounts med 2500 kr: %v", a) + } + if !NewTopUp(rng.FromInt(1), 500).Done() { + t.Fatal("utan råd ska automaten vara klar direkt") + } + // Esc avbryter utan köp. + c := NewTopUp(rng.FromInt(1), 5000) + c.Update(Input{Cancel: true}, frame) + if !c.Done() || c.Success() { + t.Fatal("avbrutet = klart utan framgång") + } +} + +func TestTicketBoardSeededAndAffordable(t *testing.T) { + g := NewTicket(rng.FromInt(4), "渋谷", []string{"東京", "上野", "新宿"}, 5000) + h := NewTicket(rng.FromInt(4), "渋谷", []string{"東京", "上野", "新宿"}, 5000) + if len(g.Options) != 4 { + t.Fatalf("%d alternativ", len(g.Options)) + } + for i := range g.Options { + if g.Options[i] != h.Options[i] { + t.Fatal("samma seed ska ge samma tavla") + } + if f := g.Options[i].Fare; f < 140 || f > 620 || f%10 != 0 { + t.Fatalf("taxa %d", f) + } + } + // Flytta till 渋谷 och köp. + for g.Options[g.Cursor].Dest != "渋谷" { + g.Update(Input{Move: 1}, frame) + } + g.Update(Input{Confirm: true}, frame) + if !g.Done() || !g.Success() || g.Chosen == nil || g.Chosen.Dest != "渋谷" { + t.Fatalf("köp: %+v", g.Chosen) + } + // Utan råd går det inte att köpa. + p := NewTicket(rng.FromInt(4), "渋谷", []string{"東京"}, 100) + p.Update(Input{Confirm: true}, frame) + if p.Done() { + t.Fatal("utan råd ska köpet inte gå igenom") + } + p.Update(Input{Cancel: true}, frame) + if !p.Done() || p.Success() { + t.Fatal("Esc avbryter") + } +} + +func TestCrowdZoneAndAttempts(t *testing.T) { + g := NewCrowd(rng.FromInt(7), 2) + if g.ZoneLo < 0.1 || g.ZoneHi > 0.9 || math.Abs((g.ZoneHi-g.ZoneLo)-crowdZoneWidth) > 1e-9 { + t.Fatalf("zon %.2f–%.2f", g.ZoneLo, g.ZoneHi) + } + // Vänta tills markören är utanför zonen och tryck: ett bommat försök. + for g.InZone() { + g.Update(Input{}, frame) + } + g.Update(Input{Confirm: true}, frame) + if g.Done() || g.Attempts != 1 { + t.Fatalf("done=%v attempts=%d", g.Done(), g.Attempts) + } + // Vänta in zonen och tryck: lyckas. + for i := 0; i < 600 && !g.InZone(); i++ { + g.Update(Input{}, frame) + } + g.Update(Input{Confirm: true}, frame) + if !g.Done() || !g.Success() { + t.Fatal("träff i zonen ska lyckas") + } + // Två bom = tåget går. + f := NewCrowd(rng.FromInt(7), 2) + for n := 0; n < 2; n++ { + for f.InZone() { + f.Update(Input{}, frame) + } + f.Update(Input{Confirm: true}, frame) + } + if !f.Done() || f.Success() { + t.Fatal("två bom ska misslyckas") + } + // Markören studsar och håller sig i 0..1. + b := NewCrowd(rng.FromInt(8), 1) + for i := 0; i < 600; i++ { + b.Update(Input{}, frame) + if b.Pos < 0 || b.Pos > 1 { + t.Fatalf("pos=%.2f", b.Pos) + } + } +} + +func TestRideStopsAndGettingOff(t *testing.T) { + fillers := []string{"東京", "上野", "新宿", "成田"} + g := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true) + if len(g.Stops) != 4 || g.Dest < 1 || g.Stops[g.Dest] != "渋谷" { + t.Fatalf("stops=%v dest=%d", g.Stops, g.Dest) + } + h := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true) + if strings.Join(g.Stops, ",") != strings.Join(h.Stops, ",") { + t.Fatal("samma seed = samma linje") + } + // Åk till resmålet och kliv av. + for g.Index != g.Dest { + g.Update(Input{}, frame) + } + g.Update(Input{Confirm: true}, frame) + if !g.Done() || !g.Success() || g.GotOff != g.Dest { + t.Fatal("kliva av på resmålet ska lyckas") + } + // Kliva av för tidigt. + e := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true) + e.Update(Input{Confirm: true}, frame) + if !e.Done() || e.Success() || e.GotOff != 0 { + t.Fatal("fel station = misslyckat") + } + // Åka förbi. + m := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true) + for i := 0; i < 60*20 && !m.Done(); i++ { + m.Update(Input{}, frame) + } + if !m.Done() || m.Success() || m.Index != 3 { + t.Fatalf("åka förbi: done=%v success=%v index=%d", m.Done(), m.Success(), m.Index) + } + // Fel tåg: resmålet finns inte på linjen, så det går aldrig att lyckas. + w := NewRide(rng.FromInt(3), "渋谷", fillers, 4, false) + if w.Dest != -1 || strings.Contains(strings.Join(w.Stops, ","), "渋谷") { + t.Fatalf("fel tåg: %v", w.Stops) + } + w.Update(Input{Confirm: true}, frame) + if w.Success() { + t.Fatal("på fel tåg kan man inte komma rätt") + } +} + +func TestRideWithoutFillersStillHasDestination(t *testing.T) { + g := NewRide(rng.FromInt(1), "渋谷", nil, 3, true) + if len(g.Stops) != 3 || g.Dest < 1 || g.Stops[g.Dest] != "渋谷" || g.Stops[0] != UnknownStop { + t.Fatalf("stops=%v dest=%d", g.Stops, g.Dest) + } +} diff --git a/internal/minigame/rand.go b/internal/minigame/rand.go new file mode 100644 index 0000000..2db5684 --- /dev/null +++ b/internal/minigame/rand.go @@ -0,0 +1,12 @@ +package minigame + +import "math/rand/v2" + +// randSrc är ett tunt lager över math/rand/v2 så att minispelen bara använder +// ett fåtal väldefinierade operationer (lätt att hålla deterministiska). +type randSrc struct{ r *rand.Rand } + +func (s *randSrc) intn(n int) int { return s.r.IntN(n) } +func (s *randSrc) float() float64 { return s.r.Float64() } +func (s *randSrc) between(lo, hi float64) float64 { return lo + (hi-lo)*s.r.Float64() } +func (s *randSrc) shuffle(n int, swap func(i, j int)) { s.r.Shuffle(n, swap) } diff --git a/internal/minigame/ride.go b/internal/minigame/ride.go new file mode 100644 index 0000000..6ccc209 --- /dev/null +++ b/internal/minigame/ride.go @@ -0,0 +1,70 @@ +package minigame + +import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" + +// Ride är tågresan: stationerna passerar en i taget med "次は …"-utrop; +// tryck Enter vid ditt resmål. Fel station eller att åka förbi är misslyckat. +type Ride struct { + base + Stops []string // stationsnamn på japanska i ordning + Dest int // index för resmålet, -1 om det inte finns på linjen (fel tåg) + Index int // aktuell station + Timer float64 // sekunder kvar vid aktuell station + StopSec float64 + GotOff int // index där spelaren klev av, -1 = inte än +} + +const rideStopSec = 2.5 + +// NewRide bygger linjen: fillers är andra stationsnamn (blandas), dest läggs +// på en seedad plats (aldrig först) om det är rätt tåg, annars utelämnas det. +func NewRide(seed rng.Seed, dest string, fillers []string, stops int, correctTrain bool) *Ride { + r := newRand(seed, "ride") + 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) + g := &Ride{StopSec: rideStopSec, Timer: rideStopSec, GotOff: -1, Dest: -1} + if correctTrain { + g.Dest = 1 + r.intn(stops-1) + } + for i := 0; i < stops; i++ { + switch { + case i == g.Dest: + g.Stops = append(g.Stops, dest) + case len(f) > 0: + g.Stops = append(g.Stops, f[0]) + f = f[1:] + default: + g.Stops = append(g.Stops, UnknownStop) // slut på namn: en namnlös station + } + } + return g +} + +// UnknownStop används när det inte finns fler stationsnamn att ta av. +const UnknownStop = "・・・" + +// Current är stationen tåget står vid. +func (g *Ride) Current() string { return g.Stops[g.Index] } + +// Update låter stationerna passera och tar emot "kliv av". +func (g *Ride) Update(in Input, dt float64) { + if g.done { + return + } + if in.Confirm { + g.GotOff = g.Index + g.done = true + g.success = g.Index == g.Dest + return + } + g.Timer -= dt + if g.Timer <= 0 { + g.Index++ + g.Timer = g.StopSec + if g.Index >= len(g.Stops) { + g.Index = len(g.Stops) - 1 + g.done = true // åkte till slutstationen utan att kliva av + } + } +} diff --git a/internal/minigame/ticket.go b/internal/minigame/ticket.go new file mode 100644 index 0000000..39e1e91 --- /dev/null +++ b/internal/minigame/ticket.go @@ -0,0 +1,53 @@ +package minigame + +import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" + +// Ticket är biljettautomaten: en taxetavla med resmål (på japanska) i seedad +// ordning. Spelaren väljer ett resmål och betalar – om det var rätt avgör +// spärren/tåget senare. +type Ticket struct { + base + Options []TicketOption + Cursor int + Chosen *TicketOption + Cash int +} + +// TicketOption är ett resmål på tavlan. +type TicketOption struct { + Dest string // resmålets namn på japanska + Fare int +} + +// NewTicket bygger tavlan: rätt resmål + lockande alternativ, blandade. +// Taxan är seedad per resmål (140–620 yen i steg om 10). +func NewTicket(seed rng.Seed, correct string, others []string, cash int) *Ticket { + r := newRand(seed, "ticket") + g := &Ticket{Cash: cash} + names := append([]string{correct}, others...) + for _, n := range names { + g.Options = append(g.Options, TicketOption{Dest: n, Fare: 140 + 10*r.intn(49)}) + } + r.shuffle(len(g.Options), func(i, j int) { g.Options[i], g.Options[j] = g.Options[j], g.Options[i] }) + return g +} + +// Update flyttar markören och köper. +func (g *Ticket) Update(in Input, dt float64) { + if g.done { + return + } + if in.Cancel { + g.done = true + return + } + g.Cursor = wrap(g.Cursor+in.Move, len(g.Options)) + if in.Confirm { + opt := g.Options[g.Cursor] + if opt.Fare > g.Cash { + return // har inte råd: välj annat eller avbryt + } + g.Chosen = &opt + g.done, g.success = true, true + } +} diff --git a/internal/minigame/topup.go b/internal/minigame/topup.go new file mode 100644 index 0000000..1d8943e --- /dev/null +++ b/internal/minigame/topup.go @@ -0,0 +1,107 @@ +package minigame + +import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" + +// TopUp är påfyllningsautomaten: hitta チャージ-knappen bland japanska +// knappar i seedad ordning, välj belopp, mata in sedlar genom att hålla +// Enter. Fel knapp kostar en straffsekund eller två. +type TopUp struct { + base + Stage TopUpStage + Buttons []string // knapparnas text, seedad ordning + Cursor int + Amounts []int + Mistakes int + Penalty float64 // sekunder kvar av frysning efter fel knapp + Fill float64 // 0..1 sedelinmatning + Amount int // valt belopp (resultat) + MaxCash int // vad spelaren har råd med +} + +// TopUpStage är var i flödet man är. +type TopUpStage int + +// Steg. +const ( + TopUpPickButton TopUpStage = iota + TopUpPickAmount + TopUpFeed +) + +// Knapparna på en japansk biljettautomat. チャージ är den rätta. +var topUpButtons = []string{"チャージ", "きっぷ", "定期券", "取消"} + +// TopUpAmounts är beloppen man kan välja. +var TopUpAmounts = []int{1000, 2000, 3000, 5000} + +const ( + topUpPenaltySec = 1.5 + topUpFeedPerSec = 0.8 // hålla Enter: fyller på 1/0.8 s + topUpDrainPerSec = 1.2 +) + +// NewTopUp skapar automaten. maxCash begränsar beloppen. +func NewTopUp(seed rng.Seed, maxCash int) *TopUp { + r := newRand(seed, "topup") + g := &TopUp{Buttons: append([]string(nil), topUpButtons...), MaxCash: maxCash} + r.shuffle(len(g.Buttons), func(i, j int) { g.Buttons[i], g.Buttons[j] = g.Buttons[j], g.Buttons[i] }) + for _, a := range TopUpAmounts { + if a <= maxCash { + g.Amounts = append(g.Amounts, a) + } + } + if len(g.Amounts) == 0 { + g.done = true // inga pengar: inget att göra + } + return g +} + +// Update för automaten framåt. +func (g *TopUp) Update(in Input, dt float64) { + if g.done { + return + } + if in.Cancel { + g.done = true + return + } + if g.Penalty > 0 { + g.Penalty -= dt + return + } + switch g.Stage { + case TopUpPickButton: + g.Cursor = wrap(g.Cursor+in.Move, len(g.Buttons)) + if in.Confirm { + if g.Buttons[g.Cursor] == topUpButtons[0] { + g.Stage, g.Cursor = TopUpPickAmount, 0 + } else { + g.Mistakes++ + g.Penalty = topUpPenaltySec + } + } + case TopUpPickAmount: + g.Cursor = wrap(g.Cursor+in.Move, len(g.Amounts)) + if in.Confirm { + g.Amount = g.Amounts[g.Cursor] + g.Stage = TopUpFeed + } + case TopUpFeed: + if in.Hold { + g.Fill += topUpFeedPerSec * dt + } else { + g.Fill = max(0, g.Fill-topUpDrainPerSec*dt) + } + if g.Fill >= 1 { + g.Fill = 1 + g.done, g.success = true, true + } + } +} + +func wrap(i, n int) int { + if n == 0 { + return 0 + } + return ((i % n) + n) % n +} diff --git a/internal/sim/run.go b/internal/sim/run.go index 0f9bec2..fd7b52d 100644 --- a/internal/sim/run.go +++ b/internal/sim/run.go @@ -5,6 +5,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/rng" ) @@ -18,30 +19,12 @@ const ( Lost // klev på fel tåg ) -// Card är tågkortet (IC-kortet). Saldo i yen. -type Card struct{ Balance int } - -// CanPay rapporterar om saldot räcker. -func (c *Card) CanPay(yen int) bool { return c.Balance >= yen } - -// Pay drar beloppet om det går. -func (c *Card) Pay(yen int) bool { - if !c.CanPay(yen) { - return false - } - c.Balance -= yen - return true -} - -// StartBalanceYen är kortets saldo vid banstart tills M6 ger riktig ekonomi. -const StartBalanceYen = 2000 - // Run är tillståndet för en pågående bana: spelaren, klockan, kortet och // utfallet. Ren logik – LevelScene ritar den, testerna kör den headless. type Run struct { Level *level.Level Player *Mover - Card Card + Wallet *Wallet // pengar, kort, telefon, biljett – delas mellan banor Time float64 // sekunder sedan banstart (styr tidtabellerna) Steps int GatePasses int @@ -80,8 +63,51 @@ type Run struct { Served int // personer som fått mat/dryck/toa (statistik) Notice string // senaste meddelande från sällskapet (HUD) NoticeAt float64 // < 0 = aldrig + + // Minispel, biljetter och telefon. + Mini minigame.Game // pågående minispel (nil = inget); rörelsen pausar + MiniKind MiniKind + miniCount int + PendingTrain *level.Train // tåget vi håller på att kliva på + MissedTrains int + TopUps int + Rescans int + RescanUntil float64 // telefonen måste skannas igen: spelaren står still tills dess + LostReason string // varför banan förlorades (nyckel för UI) + FlashKey string // kort meddelande till HUD (textnyckel) + FlashAt float64 // < 0 = aldrig } +// MiniKind är vilket minispel som pågår. +type MiniKind int + +// Minispel. +const ( + MiniNone MiniKind = iota + MiniTopUp + MiniTicket + MiniCrowd + MiniRide +) + +// Förlustorsaker (textnycklar). +const ( + LostWrongTrain = "result.lost_wrong_train" + LostWrongStop = "result.lost_wrong_stop" + LostMissedStop = "result.lost_missed_stop" +) + +// Flash-nycklar. +const ( + FlashWrongTicket = "hud.wrong_ticket" + FlashMissedTrain = "hud.missed_train" + FlashRescan = "hud.rescan" + FlashNoTicket = "hud.need_ticket" +) + +// CrowdTries är antalet försök att få plats på ett fullt tåg. +const CrowdTries = 2 + // FacilityState är ett pågående toalettbesök (tar tid, avbryts om man går). type FacilityState struct { Active bool @@ -93,10 +119,11 @@ type FacilityState struct { // RunOptions styr en banomgång. type RunOptions struct { - Speed float64 // rutor per sekund (0 = DefaultSpeed) - Dict *lang.Dictionary // nil = tom ordbok - Know *lang.Knowledge // nil = ny kunskap - Seed rng.Seed // omgångens seed (sparas med spelet) + Speed float64 // rutor per sekund (0 = DefaultSpeed) + Dict *lang.Dictionary // nil = tom ordbok + Know *lang.Knowledge // nil = ny kunskap + Wallet *Wallet // nil = startkassa + Seed rng.Seed // omgångens seed (sparas med spelet) } // DefaultSpeed är spelarens grundfart i rutor per sekund. @@ -128,10 +155,14 @@ func NewRun(l *level.Level, o RunOptions) *Run { if o.Speed <= 0 { o.Speed = DefaultSpeed } + if o.Wallet == nil { + o.Wallet = NewWallet() + } r := &Run{ Level: l, Player: NewMover(l.SpawnPoint(), o.Speed), - Card: Card{Balance: StartBalanceYen}, + Wallet: o.Wallet, + FlashAt: -1, DeniedAt: -1, Dict: o.Dict, Know: o.Know, @@ -171,19 +202,27 @@ func (r *Run) StarsFor(t float64) int { } // CanEnter avgör om spelaren får gå in på en ruta: inte vägg, och en spärr -// kräver att kortet kan betala avgiften (att gå mellan två spärrutor är gratis). +// kräver biljett (på biljettbanor) eller ett användbart kort med saldo (att +// gå mellan två spärrutor är gratis). func (r *Run) CanEnter(p level.Point) bool { t := r.Level.At(p.X, p.Y) if !t.Passable() { return false } - if t == level.Gate && !r.inGate() && !r.Card.CanPay(r.Level.FareYen) { + if t == level.Gate && !r.inGate() && !r.canPassGate() { r.DeniedAt = r.Time return false } return true } +func (r *Run) canPassGate() bool { + if r.Level.TicketRequired { + return r.Wallet.Ticket != "" + } + return r.Wallet.CardUsable() && r.Wallet.Card >= r.Level.FareYen +} + func (r *Run) inGate() bool { return r.Level.At(r.Player.Tile.X, r.Player.Tile.Y) == level.Gate } @@ -194,10 +233,20 @@ func (r *Run) Step(dt float64, want Dir, board bool) { if r.Outcome != Ongoing { return } + if r.Mini != nil { + return // minispel pågår: använd StepMini + } r.Time += dt + r.Wallet.Drain(dt) before := r.Player.Tile wasGate := r.inGate() r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor() + if r.Wallet.Phone && r.Wallet.Battery > 0 { + r.Player.Speed *= PhoneSpeedFactor + } + if r.Time < r.RescanUntil { + r.Player.Speed = 0 // står och skannar om + } r.Steps += r.Player.Step(dt, want, r.CanEnter) if r.Player.Tile != before { r.Party.OnLeaderArrived(r.Player.Tile) @@ -212,8 +261,15 @@ func (r *Run) Step(dt float64, want Dir, board bool) { r.stepFacility(dt) r.stepReading() if r.Player.Tile != before && r.inGate() && !wasGate { - r.Card.Pay(r.Level.FareYen) r.GatePasses++ + if !r.Level.TicketRequired { + r.Wallet.PayCard(r.Level.FareYen) + if r.Wallet.Phone && r.Seed.Derive(fmt.Sprintf("rescan:%d", r.GatePasses)).New().Float64() < PhoneRescanChance { + r.Rescans++ + r.RescanUntil = r.Time + RescanSec + r.flash(FlashRescan) + } + } } if board && !r.Player.Moving() { switch r.Level.At(r.Player.Tile.X, r.Player.Tile.Y) { @@ -221,14 +277,131 @@ func (r *Run) Step(dt float64, want Dir, board bool) { r.useToilet() case level.Kiosk: r.buyAtKiosk() + case level.TicketMachine: + r.startMachine() default: if tr, at, _ := r.PlatformHere(); tr != nil && at { - r.Board(tr) + r.tryBoard(tr) } } } } +func (r *Run) flash(key string) { r.FlashKey, r.FlashAt = key, r.Time } + +// Flashing rapporterar om ett kort meddelande ska visas just nu. +func (r *Run) Flashing(within float64) bool { return r.FlashAt >= 0 && r.Time-r.FlashAt < within } + +func (r *Run) miniSeed() rng.Seed { + r.miniCount++ + return r.Seed.Derive(fmt.Sprintf("mini:%d", r.miniCount)) +} + +// stationPool ger andra stationsnamn ur ordboken (seedat urval) till +// taxetavlan och tågresan. +func (r *Run) stationPool(exclude string, n int) []string { + var pool []string + for _, w := range r.Dict.OfKind("station") { + if w != exclude { + pool = append(pool, w) + } + } + rr := r.Seed.Derive("pool:" + exclude).New() + rr.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] }) + if len(pool) > n { + pool = pool[:n] + } + return pool +} + +// startMachine öppnar automaten: biljettköp på biljettbanor, annars påfyllning. +func (r *Run) startMachine() { + if r.Mini != nil { + return + } + if r.Level.TicketRequired { + dest := r.Level.Destination.Get("ja") + r.Mini, r.MiniKind = minigame.NewTicket(r.miniSeed(), dest, r.stationPool(dest, 4), r.Wallet.Cash), MiniTicket + return + } + r.Mini, r.MiniKind = minigame.NewTopUp(r.miniSeed(), r.Wallet.Cash), MiniTopUp +} + +// tryBoard försöker kliva på: biljetten måste gälla tåget, fulla tåg kräver +// trängselminispelet, sedan börjar resan. +func (r *Run) tryBoard(tr *level.Train) { + if r.Mini != nil { + return + } + if r.Level.TicketRequired && r.Wallet.Ticket != tr.Destination.Get("ja") { + r.flash(FlashWrongTicket) + return + } + r.PendingTrain = tr + if tr.Crowded { + r.Mini, r.MiniKind = minigame.NewCrowd(r.miniSeed(), CrowdTries), MiniCrowd + return + } + r.startRide() +} + +func (r *Run) startRide() { + tr := r.PendingTrain + if r.Level.TicketRequired { + r.Wallet.Ticket, r.Wallet.TicketFare = "", 0 // biljetten är förbrukad + } + dest := r.Level.Destination.Get("ja") + r.Mini, r.MiniKind = minigame.NewRide(r.miniSeed(), dest, r.stationPool(dest, 8), r.Level.RideStops, tr.Correct), MiniRide +} + +// 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) { + if r.Outcome != Ongoing || r.Mini == nil { + return + } + r.Time += dt + r.Wallet.Drain(dt) + r.Party.Advance(dt, r.Time) + r.Mini.Update(in, dt) + if !r.Mini.Done() { + return + } + m := r.Mini + r.Mini, r.MiniKind = nil, MiniNone + switch g := m.(type) { + case *minigame.TopUp: + if g.Success() && r.Wallet.TopUp(g.Amount) { + r.TopUps++ + } + case *minigame.Ticket: + if g.Success() && g.Chosen != nil && r.Wallet.PayCash(g.Chosen.Fare) { + r.Wallet.Ticket, r.Wallet.TicketFare = g.Chosen.Dest, g.Chosen.Fare + } + case *minigame.Crowd: + if g.Success() { + r.startRide() + } else { + r.MissedTrains++ + r.PendingTrain = nil + r.flash(FlashMissedTrain) + } + case *minigame.Ride: + tr := r.PendingTrain + r.PendingTrain = nil + switch { + case g.Success(): + r.finish(tr, true, "") + case !tr.Correct: + r.finish(tr, false, LostWrongTrain) + case g.GotOff >= 0: + r.finish(tr, false, LostWrongStop) + default: + r.finish(tr, false, LostMissedStop) + } + } +} + // useToilet startar ett toalettbesök för alla som behöver (tar tid). func (r *Run) useToilet() { if r.Facility.Active { @@ -260,7 +433,7 @@ func (r *Run) stepFacility(dt float64) { // buyAtKiosk köper mat och dryck åt alla som behöver, betalat med kortet. func (r *Run) buyAtKiosk() { - r.Served += r.Party.BuyAtKiosk(r.Card.Pay) + r.Served += r.Party.BuyAtKiosk(r.Wallet.PayCard) } // FacilityProgress är 0..1 för ett pågående toalettbesök. @@ -295,12 +468,13 @@ func (r *Run) SignHere() *level.SignText { return r.Level.SignAt(r.Player.Tile) } -// Board kliver på ett tåg och avgör banan. En vinst ger stjärnor efter tid, -// språkpoäng, och vid tillräckligt bra resultat lär man sig resmålets ord. -func (r *Run) Board(tr *level.Train) { +// finish avgör banan. En vinst ger stjärnor efter tid, språkpoäng, och vid +// 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 - if !tr.Correct { + if !won { r.Outcome = Lost + r.LostReason = reason return } r.Outcome = Won diff --git a/internal/sim/run_test.go b/internal/sim/run_test.go index d66e2de..4744492 100644 --- a/internal/sim/run_test.go +++ b/internal/sim/run_test.go @@ -7,10 +7,27 @@ 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/res" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" ) +// ride spelar tågresan: kliver av när tåget står vid resmålet. På fel tåg +// finns inget resmål, så resan går till slutstationen. +func ride(t *testing.T, r *Run) { + t.Helper() + if r.MiniKind != MiniRide { + t.Fatalf("ingen tågresa pågår (kind=%v)", r.MiniKind) + } + for i := 0; i < 60*120 && r.Mini != nil; i++ { + g := r.Mini.(*minigame.Ride) + r.StepMini(frame, minigame.Input{Confirm: g.Index == g.Dest}) + } + if r.Mini != nil { + t.Fatal("resan tog aldrig slut") + } +} + func loadDict(t *testing.T) *lang.Dictionary { t.Helper() b, err := res.ReadFile("text", "jp-dictionary.json") @@ -52,7 +69,9 @@ func dirTo(from, to level.Point) Dir { } } -// walk låter en bot följa en väg ruta för ruta, en bildruta i taget. +// walk låter en bot följa en väg ruta för ruta, en bildruta i taget. Tangenten +// släpps så fort steget mot nästa ruta påbörjats, så att boten stannar exakt +// där även när farten inte är bildruteexakt (telefon- eller sällskapsfaktor). func walk(t *testing.T, r *Run, path []level.Point) { t.Helper() for _, next := range path[1:] { @@ -61,9 +80,13 @@ func walk(t *testing.T, r *Run, path []level.Point) { if r.Player.Tile == next && !r.Player.Moving() { break } - r.Step(frame, d, false) + if r.Player.Target == next || r.Player.Tile == next { + r.Step(frame, None, false) + } else { + r.Step(frame, d, false) + } } - if r.Player.Tile != next { + if r.Player.Tile != next || r.Player.Moving() { t.Fatalf("boten fastnade på %v på väg mot %v", r.Player.Tile, next) } } @@ -108,11 +131,15 @@ func TestBotWinsLevel001(t *testing.T) { t.Fatal("rätt perrong ska ha rätt tåg") } r.Step(frame, None, true) + if r.Outcome != Ongoing || r.MiniKind != MiniRide { + t.Fatalf("att kliva på ska starta tågresan: outcome=%v kind=%v", r.Outcome, r.MiniKind) + } + ride(t, r) if r.Outcome != Won || r.Boarded != tr { t.Fatalf("outcome=%v boarded=%v", r.Outcome, r.Boarded) } - if r.GatePasses != 1 || r.Card.Balance != StartBalanceYen-l.FareYen { - t.Fatalf("spärrpassager=%d saldo=%d (vill ha 1 och %d)", r.GatePasses, r.Card.Balance, StartBalanceYen-l.FareYen) + if r.GatePasses != 1 || r.Wallet.Card != StartCardYen-l.FareYen { + t.Fatalf("spärrpassager=%d saldo=%d (vill ha 1 och %d)", r.GatePasses, r.Wallet.Card, StartCardYen-l.FareYen) } if r.Steps != len(path)-1 { t.Fatalf("steg=%d, vägen är %d rutor", r.Steps, len(path)-1) @@ -135,8 +162,9 @@ func TestBotLosesOnWrongTrain(t *testing.T) { walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) tr := waitForTrain(t, r) r.Step(frame, None, true) - if r.Outcome != Lost || r.Boarded != tr || tr.Correct { - t.Fatalf("outcome=%v boarded=%+v", r.Outcome, r.Boarded) + ride(t, r) + if r.Outcome != Lost || r.Boarded != tr || tr.Correct || r.LostReason != LostWrongTrain { + t.Fatalf("outcome=%v boarded=%+v reason=%q", r.Outcome, r.Boarded, r.LostReason) } } @@ -144,7 +172,7 @@ func TestGateNeedsCardBalance(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) gate := l.Find(level.Gate)[0] - r.Card.Balance = 0 + r.Wallet.Card = 0 if r.CanEnter(gate) { t.Fatal("spärren ska stoppa ett tomt kort") } @@ -154,13 +182,13 @@ func TestGateNeedsCardBalance(t *testing.T) { if l.Path(r.Player.Tile, l.Find(level.Platform)[0], r.CanEnter) != nil { t.Fatal("utan saldo ska perrongen vara onåbar") } - r.Card.Balance = l.FareYen + r.Wallet.Card = l.FareYen if !r.CanEnter(gate) { t.Fatal("med exakt avgiften ska spärren släppa igenom") } // Mellan två spärrutor är det gratis: ställ spelaren i spärren. r.Player.Tile, r.Player.Target = gate, gate - r.Card.Balance = 0 + r.Wallet.Card = 0 other := level.Point{X: gate.X + 1, Y: gate.Y} if l.At(other.X, other.Y) == level.Gate && !r.CanEnter(other) { t.Fatal("att gå mellan två spärrutor ska inte kosta") @@ -302,6 +330,7 @@ func TestGoodResultTeachesDestination(t *testing.T) { waitForTrain(t, r) } r.Step(frame, None, true) + ride(t, r) if r.Outcome != Won || r.Stars != tc.stars { t.Fatalf("%s: outcome=%v stars=%d", tc.name, r.Outcome, r.Stars) } @@ -395,10 +424,10 @@ func TestToiletAndKioskInRun(t *testing.T) { walk(t, r, l.Path(r.Player.Tile, kiosk, r.CanEnter)) anna.Meters.Drink = 5 anna.Urge = Thirsty - before := r.Card.Balance + before := r.Wallet.Card r.Step(frame, None, true) - if anna.Meters.Drink != 100 || anna.Urge != NoUrge || r.Card.Balance != before-KioskPriceYen || r.Served != 2 { - t.Fatalf("kiosken: dryck=%.0f urge=%v saldo=%d->%d served=%d", anna.Meters.Drink, anna.Urge, before, r.Card.Balance, r.Served) + if anna.Meters.Drink != 100 || anna.Urge != NoUrge || r.Wallet.Card != before-KioskPriceYen || r.Served != 2 { + t.Fatalf("kiosken: dryck=%.0f urge=%v saldo=%d->%d served=%d", anna.Meters.Drink, anna.Urge, before, r.Wallet.Card, r.Served) } } @@ -412,3 +441,226 @@ func TestPartySlowsLeader(t *testing.T) { t.Fatalf("fart=%.2f", r.Player.Speed) } } + +func TestRideWrongOrMissedStopLoses(t *testing.T) { + l := loadLevel(t, "001-flygplatsen") + for _, tc := range []struct { + name string + getOff bool + reason string + }{{"fel station", true, LostWrongStop}, {"åker förbi", false, LostMissedStop}} { + r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)}) + walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter)) + waitForTrain(t, r) + r.Step(frame, None, true) + g := r.Mini.(*minigame.Ride) + if g.Dest < 1 { + t.Fatalf("resmålet ska ligga på linjen men inte först: %d", g.Dest) + } + if tc.getOff { + r.StepMini(frame, minigame.Input{Confirm: true}) // första stationen = fel + } else { + for i := 0; i < 60*120 && r.Mini != nil; i++ { + r.StepMini(frame, minigame.Input{}) + } + } + if r.Outcome != Lost || r.LostReason != tc.reason { + t.Fatalf("%s: outcome=%v reason=%q", tc.name, r.Outcome, r.LostReason) + } + } +} + +func TestTopUpFlowAtMachine(t *testing.T) { + l := loadLevel(t, "001-flygplatsen") + r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)}) + r.Wallet.Card = 0 + gate := l.Find(level.Gate)[0] + if r.CanEnter(gate) { + t.Fatal("tomt kort stoppas") + } + machine := l.Find(level.TicketMachine)[0] + walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter)) + r.Step(frame, None, true) + g, ok := r.Mini.(*minigame.TopUp) + if !ok || r.MiniKind != MiniTopUp { + t.Fatalf("automaten ska starta påfyllning: %T", r.Mini) + } + cash := r.Wallet.Cash + for g.Buttons[g.Cursor] != "チャージ" { + r.StepMini(frame, minigame.Input{Move: 1}) + } + r.StepMini(frame, minigame.Input{Confirm: true}) + r.StepMini(frame, minigame.Input{Confirm: true}) // första beloppet (1000) + for i := 0; i < 600 && r.Mini != nil; i++ { + r.StepMini(frame, minigame.Input{Hold: true}) + } + if r.Mini != nil || r.Wallet.Card != 1000 || r.Wallet.Cash != cash-1000 || r.TopUps != 1 { + t.Fatalf("efter påfyllning: card=%d cash=%d topups=%d", r.Wallet.Card, r.Wallet.Cash, r.TopUps) + } + if !r.CanEnter(gate) { + t.Fatal("med påfyllt kort ska spärren släppa igenom") + } + // Minispelet pausar rörelsen men inte klockan. + r.Step(frame, None, true) + t0 := r.Time + r.Step(frame, Right, false) + if r.Mini == nil || r.Time != t0 || r.Player.Moving() { + t.Fatal("under minispelet ska Step vara en no-op") + } + r.StepMini(frame, minigame.Input{Cancel: true}) + if r.Mini != nil || r.Time <= t0 { + t.Fatal("Esc avbryter och klockan går") + } +} + +// TestTicketLevelFlow spelar bana 002: köp rätt biljett, passera spärren, +// tränga sig på det fulla tåget och kliva av i Shibuya. +func TestTicketLevelFlow(t *testing.T) { + l := loadLevel(t, "002-shinjuku") + if !l.TicketRequired || !l.Trains[0].Crowded { + t.Fatal("002 ska kräva biljett och ha ett fullt tåg") + } + r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(11)}) + r.Party.nextEventAt = 1e9 + gate := l.Find(level.Gate)[0] + if r.CanEnter(gate) { + t.Fatal("utan biljett stoppar spärren även med kort") + } + machine := l.Find(level.TicketMachine)[0] + walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter)) + + // Fel biljett först: tåget vägrar. + r.Step(frame, None, true) + tk := r.Mini.(*minigame.Ticket) + for tk.Options[tk.Cursor].Dest == "渋谷" { + r.StepMini(frame, minigame.Input{Move: 1}) + } + wrong := tk.Options[tk.Cursor] + r.StepMini(frame, minigame.Input{Confirm: true}) + if r.Wallet.Ticket != wrong.Dest || r.Wallet.Cash != StartCashYen-wrong.Fare { + t.Fatalf("biljett=%q cash=%d", r.Wallet.Ticket, r.Wallet.Cash) + } + goal := l.CorrectPlatform() + walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) + waitForTrain(t, r) + r.Step(frame, None, true) + if r.Mini != nil || r.FlashKey != FlashWrongTicket || r.Outcome != Ongoing { + t.Fatal("fel biljett ska stoppas vid tåget") + } + + // Rätt biljett. + walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter)) + r.Step(frame, None, true) + tk = r.Mini.(*minigame.Ticket) + for tk.Options[tk.Cursor].Dest != "渋谷" { + r.StepMini(frame, minigame.Input{Move: 1}) + } + r.StepMini(frame, minigame.Input{Confirm: true}) + if r.Wallet.Ticket != "渋谷" { + t.Fatalf("biljett=%q", r.Wallet.Ticket) + } + walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) + waitForTrain(t, r) + r.Step(frame, None, true) + cr, ok := r.Mini.(*minigame.Crowd) + if !ok { + t.Fatalf("fullt tåg ska ge trängselminispel, fick %T", r.Mini) + } + for i := 0; i < 600 && !cr.InZone(); i++ { + r.StepMini(frame, minigame.Input{}) + } + r.StepMini(frame, minigame.Input{Confirm: true}) + if r.MiniKind != MiniRide || r.Wallet.Ticket != "" { + t.Fatalf("efter trängseln: kind=%v biljett=%q", r.MiniKind, r.Wallet.Ticket) + } + ride(t, r) + if r.Outcome != Won { + t.Fatalf("outcome=%v reason=%q", r.Outcome, r.LostReason) + } +} + +func TestCrowdFailureMissesTrain(t *testing.T) { + l := loadLevel(t, "002-shinjuku") + r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(11)}) + r.Party.nextEventAt = 1e9 + r.Wallet.Ticket = "渋谷" + goal := l.CorrectPlatform() + walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) + waitForTrain(t, r) + r.Step(frame, None, true) + cr := r.Mini.(*minigame.Crowd) + for n := 0; n < CrowdTries; n++ { + for cr.InZone() { + r.StepMini(frame, minigame.Input{}) + } + r.StepMini(frame, minigame.Input{Confirm: true}) + } + if r.Mini != nil || r.MissedTrains != 1 || r.Outcome != Ongoing || r.FlashKey != FlashMissedTrain { + t.Fatalf("missat tåg: mini=%v missed=%d outcome=%v", r.Mini, r.MissedTrains, r.Outcome) + } + if r.Wallet.Ticket != "渋谷" { + t.Fatal("biljetten ska vara kvar när man inte kom med") + } +} + +func TestPhoneSpeedRescanAndBattery(t *testing.T) { + l := loadLevel(t, "001-flygplatsen") + rescans := map[int]int{} + for seed := uint64(1); seed <= 12; seed++ { + r := NewRun(l, RunOptions{Seed: rng.FromInt(seed)}) + r.Party.nextEventAt = 1e9 + r.Wallet.Phone = true + r.Step(frame, Right, false) + if math.Abs(r.Player.Speed-DefaultSpeed*PhoneSpeedFactor) > 1e-9 { + t.Fatalf("telefonen ska ge fartbuff: %.2f", r.Player.Speed) + } + for r.Player.Moving() { // gå klart rutan innan vi väljer väg + r.Step(frame, None, false) + } + gate := l.Find(level.Gate)[0] + path := l.Path(r.Player.Tile, gate, r.CanEnter) + for i, p := range path { // stanna på den första spärrutan – där sker skanningen + if l.At(p.X, p.Y) == level.Gate { + path = path[:i+1] + break + } + } + walk(t, r, path) + rescans[r.Rescans]++ + if r.Rescans == 1 { + if r.Time >= r.RescanUntil || r.FlashKey != FlashRescan { + t.Fatal("omskanning ska frysa spelaren en stund") + } + before := r.Player.Tile + r.Step(frame, Right, false) + if r.Player.Moving() || r.Player.Tile != before { + t.Fatal("under omskanningen står man still") + } + } + } + if rescans[1] == 0 || rescans[0] == 0 { + t.Fatalf("på 12 seeds borde några men inte alla ge omskanning: %v", rescans) + } + // Determinism: samma seed -> samma utfall. + run := func() int { + r := NewRun(l, RunOptions{Seed: rng.FromInt(5)}) + r.Party.nextEventAt = 1e9 + r.Wallet.Phone = true + walk(t, r, l.Path(r.Player.Tile, l.Find(level.Gate)[0], r.CanEnter)) + return r.Rescans + } + if run() != run() { + t.Fatal("omskanningen ska vara seedad") + } + // Dött batteri: kortet i telefonen går inte -> spärren stoppar. + r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) + r.Wallet.Phone = true + r.Wallet.Battery = 0.001 + r.Step(frame, None, false) + if r.Wallet.Battery != 0 || r.CanEnter(l.Find(level.Gate)[0]) { + t.Fatal("dött batteri ska stoppa spärren") + } + if r.Player.Speed != DefaultSpeed { + t.Fatal("ingen fartbuff med dött batteri") + } +} diff --git a/internal/sim/wallet.go b/internal/sim/wallet.go new file mode 100644 index 0000000..b263679 --- /dev/null +++ b/internal/sim/wallet.go @@ -0,0 +1,67 @@ +package sim + +// Wallet är spelarens pengar och biljetter. Följer med mellan banor +// (sparas i M8). +type Wallet struct { + Card int `json:"card"` // IC-kortets saldo (yen) + Cash int `json:"cash"` // kontanter (yen) + Phone bool `json:"phone"` // kortet ligger i telefonen + Battery float64 `json:"battery"` // telefonens batteri 0..100 + Ticket string `json:"ticket"` // köpt pappersbiljett: resmålets namn (ja), "" = ingen + TicketFare int `json:"ticketFare"` // vad biljetten kostade + Spent int `json:"spent"` // totalt spenderat (poäng i M7) +} + +// Pengar och telefon. +const ( + StartCardYen = 2000 + StartCashYen = 5000 + BatteryPerSec = 100.0 / 900 // telefonen håller 15 min – tar bara slut på långa banor + PhoneSpeedFactor = 1.1 // kortet i telefonen: man slipper rota i plånboken + PhoneRescanChance = 0.15 // ...men ibland måste man skanna igen + RescanSec = 1.5 +) + +// NewWallet ger startkassan med kortet i plånboken (inte telefonen). +func NewWallet() *Wallet { return &Wallet{Card: StartCardYen, Cash: StartCashYen, Battery: 100} } + +// CardUsable: kortet i telefonen går inte att använda med dött batteri. +func (w *Wallet) CardUsable() bool { return !w.Phone || w.Battery > 0 } + +// PayCard drar från kortet om det går. +func (w *Wallet) PayCard(yen int) bool { + if !w.CardUsable() || w.Card < yen { + return false + } + w.Card -= yen + w.Spent += yen + return true +} + +// PayCash betalar kontant om det går. +func (w *Wallet) PayCash(yen int) bool { + if w.Cash < yen { + return false + } + w.Cash -= yen + w.Spent += yen + return true +} + +// TopUp flyttar kontanter till kortet (påfyllning räknas inte som spenderat +// förrän kortet används). +func (w *Wallet) TopUp(yen int) bool { + if w.Cash < yen { + return false + } + w.Cash -= yen + w.Card += yen + return true +} + +// Drain laddar ur telefonen när kortet ligger i den. +func (w *Wallet) Drain(dt float64) { + if w.Phone { + w.Battery = max(0, w.Battery-BatteryPerSec*dt) + } +} diff --git a/internal/sim/wallet_test.go b/internal/sim/wallet_test.go new file mode 100644 index 0000000..5edb96c --- /dev/null +++ b/internal/sim/wallet_test.go @@ -0,0 +1,35 @@ +package sim + +import "testing" + +func TestWalletPayTopUpAndPhone(t *testing.T) { + w := NewWallet() + if w.Card != StartCardYen || w.Cash != StartCashYen || !w.CardUsable() { + t.Fatal("startvärden") + } + if !w.PayCard(200) || w.Card != StartCardYen-200 || w.Spent != 200 { + t.Fatal("PayCard") + } + if w.PayCard(99999) { + t.Fatal("kan inte betala mer än saldot") + } + if !w.TopUp(1000) || w.Card != StartCardYen-200+1000 || w.Cash != StartCashYen-1000 || w.Spent != 200 { + t.Fatal("TopUp flyttar kontanter till kortet utan att räknas som spenderat") + } + if w.TopUp(99999) || !w.PayCash(150) || w.PayCash(99999) { + t.Fatal("kontantgränser") + } + w.Phone = true + w.Drain(450) // halva batteriet + if w.Battery < 49 || w.Battery > 51 || !w.CardUsable() { + t.Fatalf("battery=%.1f", w.Battery) + } + w.Drain(10000) + if w.Battery != 0 || w.CardUsable() || w.PayCard(1) { + t.Fatal("dött batteri = kortet i telefonen går inte att använda") + } + w.Phone = false + if !w.CardUsable() { + t.Fatal("kortet i plånboken bryr sig inte om batteriet") + } +} diff --git a/resources/data/levels/002-shinjuku.json b/resources/data/levels/002-shinjuku.json index 511c6de..65e60c0 100644 --- a/resources/data/levels/002-shinjuku.json +++ b/resources/data/levels/002-shinjuku.json @@ -1,11 +1,21 @@ { + "id": "002-shinjuku", + "name": { + "en": "Shinjuku Station", + "ja": "新宿駅", + "sv": "Shinjuku station" + }, + "difficulty": 2, + "party": 3, + "ticketRequired": true, + "rideStops": 4, "destination": { "en": "Shibuya", "ja": "渋谷", "romaji": "Shibuya", "sv": "Shibuya" }, - "difficulty": 2, + "timeLimitSec": 0, "fareYen": 200, "grid": [ "#################################", @@ -30,13 +40,49 @@ "#pG.#...#.....................#P#", "#################################" ], - "id": "002-shinjuku", - "name": { - "en": "Shinjuku Station", - "ja": "新宿駅", - "sv": "Shinjuku station" - }, - "party": 3, + "trains": [ + { + "correct": true, + "destination": { + "en": "Shibuya", + "ja": "渋谷", + "romaji": "Shibuya" + }, + "line": { + "en": "Yamanote Line", + "ja": "山手線", + "romaji": "Yamanote-sen" + }, + "platform": "P", + "schedule": { + "dwellSec": 8, + "intervalSec": 20, + "offsetSec": 3 + }, + "track": "14", + "crowded": true + }, + { + "correct": false, + "destination": { + "en": "Tokyo", + "ja": "東京", + "romaji": "Tokyo" + }, + "line": { + "en": "Chuo Line", + "ja": "中央線", + "romaji": "Chuo-sen" + }, + "platform": "p", + "schedule": { + "dwellSec": 8, + "intervalSec": 25, + "offsetSec": 10 + }, + "track": "8" + } + ], "signs": [ { "text": { @@ -65,48 +111,5 @@ "x": 15, "y": 17 } - ], - "timeLimitSec": 0, - "trains": [ - { - "correct": true, - "destination": { - "en": "Shibuya", - "ja": "渋谷", - "romaji": "Shibuya" - }, - "line": { - "en": "Yamanote Line", - "ja": "山手線", - "romaji": "Yamanote-sen" - }, - "platform": "P", - "schedule": { - "dwellSec": 8, - "intervalSec": 20, - "offsetSec": 3 - }, - "track": "14" - }, - { - "correct": false, - "destination": { - "en": "Tokyo", - "ja": "東京", - "romaji": "Tokyo" - }, - "line": { - "en": "Chuo Line", - "ja": "中央線", - "romaji": "Chuo-sen" - }, - "platform": "p", - "schedule": { - "dwellSec": 8, - "intervalSec": 25, - "offsetSec": 10 - }, - "track": "8" - } ] } diff --git a/resources/text/en.json b/resources/text/en.json index 82673f7..d78973a 100644 --- a/resources/text/en.json +++ b/resources/text/en.json @@ -49,7 +49,7 @@ "words.hint": "Arrows: select Enter: learn the word for points Esc/L: back", "words.already": "You already know that word.", "words.bought": "New word: %s = %s", - "words.too_expensive": "Costs %d points – clear levels with good times for more.", + "words.too_expensive": "Costs %d points - clear levels with good times for more.", "urge.toilet": "needs the toilet (W)", "urge.thirsty": "is thirsty (K)", "urge.hungry": "is hungry (K)", @@ -60,5 +60,36 @@ "hud.kiosk_prompt": "Enter: buy food/drink for %d person(s), %d¥", "hud.kiosk_none": "Nobody is hungry or thirsty right now.", "result.explored": "Explored", - "result.served": "served" + "result.served": "served", + "hud.cash": "Cash", + "hud.phone": "Phone", + "hud.ticket": "Ticket", + "hud.machine_topup": "Enter: top up the card (card %d¥, cash %d¥)", + "hud.machine_ticket": "Enter: buy a ticket (cash %d¥)", + "hud.wrong_ticket": "This ticket is not valid for this train!", + "hud.need_ticket": "Gate: you need a ticket - buy one at the machine (T).", + "hud.phone_dead": "Gate: your phone is dead - buy a ticket instead.", + "hud.rescan": "Beep… scan again.", + "hud.missed_train": "You could not get on - the train left.", + "level.lost": "Level failed", + "result.lost_wrong_train": "Wrong train.", + "result.lost_wrong_stop": "You got off at the wrong station.", + "result.lost_missed_stop": "You rode past your destination.", + "result.spent": "Spent", + "result.missed": "Missed trains", + "menu.phone": "P: card in phone %s", + "menu.on": "on", + "menu.off": "off", + "mini.cancel": "Esc: cancel", + "mini.topup.title": "Top up the card", + "mini.topup.pick": "Pick the right button on the machine:", + "mini.topup.amount": "Choose an amount:", + "mini.topup.feed": "Hold Enter to feed %d¥ in bills", + "mini.topup.wrong": "Wrong button…", + "mini.ticket.title": "Tickets", + "mini.ticket.hint": "Buy a ticket to %s. Cash: %d¥", + "mini.crowd.title": "Crowded train - squeeze in", + "mini.crowd.hint": "Press Enter when the marker is in the green zone (%d tries left)", + "mini.ride.title": "On the train - next stop", + "mini.ride.hint": "Enter: get off here (you are going to %s)" } diff --git a/resources/text/jp-dictionary.json b/resources/text/jp-dictionary.json index 33ad95e..457d2c7 100644 --- a/resources/text/jp-dictionary.json +++ b/resources/text/jp-dictionary.json @@ -145,35 +145,40 @@ "romaji": "Shinjuku", "en": "Shinjuku", "sv": "Shinjuku", - "tier": 2 + "tier": 2, + "kind": "station" }, "渋谷": { "kana": "しぶや", "romaji": "Shibuya", "en": "Shibuya", "sv": "Shibuya", - "tier": 2 + "tier": 2, + "kind": "station" }, "東京": { "kana": "とうきょう", "romaji": "Tōkyō", "en": "Tokyo", "sv": "Tokyo", - "tier": 1 + "tier": 1, + "kind": "station" }, "上野": { "kana": "うえの", "romaji": "Ueno", "en": "Ueno", "sv": "Ueno", - "tier": 2 + "tier": 2, + "kind": "station" }, "京成上野": { "kana": "けいせいうえの", "romaji": "Keisei-Ueno", "en": "Keisei-Ueno", "sv": "Keisei-Ueno", - "tier": 3 + "tier": 3, + "kind": "station" }, "京成線": { "kana": "けいせいせん", @@ -208,7 +213,8 @@ "romaji": "Narita", "en": "Narita", "sv": "Narita", - "tier": 3 + "tier": 3, + "kind": "station" }, "駅": { "kana": "えき", @@ -419,5 +425,61 @@ "en": "Keisei Main Line", "sv": "Keisei huvudlinje", "tier": 3 + }, + "池袋": { + "kana": "いけぶくろ", + "romaji": "Ikebukuro", + "en": "Ikebukuro", + "sv": "Ikebukuro", + "tier": 3, + "kind": "station" + }, + "品川": { + "kana": "しながわ", + "romaji": "Shinagawa", + "en": "Shinagawa", + "sv": "Shinagawa", + "tier": 3, + "kind": "station" + }, + "秋葉原": { + "kana": "あきはばら", + "romaji": "Akihabara", + "en": "Akihabara", + "sv": "Akihabara", + "tier": 3, + "kind": "station" + }, + "浅草": { + "kana": "あさくさ", + "romaji": "Asakusa", + "en": "Asakusa", + "sv": "Asakusa", + "tier": 3, + "kind": "station" + }, + "銀座": { + "kana": "ぎんざ", + "romaji": "Ginza", + "en": "Ginza", + "sv": "Ginza", + "tier": 3, + "kind": "station" + }, + "原宿": { + "kana": "はらじゅく", + "romaji": "Harajuku", + "en": "Harajuku", + "sv": "Harajuku", + "tier": 3, + "kind": "station" + }, + "日暮里": { + "kana": "にっぽり", + "romaji": "Nippori", + "en": "Nippori", + "sv": "Nippori", + "tier": 3, + "kind": "station" } } diff --git a/resources/text/sv.json b/resources/text/sv.json index 710d592..c315b37 100644 --- a/resources/text/sv.json +++ b/resources/text/sv.json @@ -49,7 +49,7 @@ "words.hint": "Pilar: välj Enter: lär dig ordet för poäng Esc/L: tillbaka", "words.already": "Det ordet kan du redan.", "words.bought": "Nytt ord: %s = %s", - "words.too_expensive": "Kostar %d poäng – klara banor med bra tid för fler.", + "words.too_expensive": "Kostar %d poäng - klara banor med bra tid för fler.", "urge.toilet": "måste på toa (W)", "urge.thirsty": "är törstig (K)", "urge.hungry": "är hungrig (K)", @@ -60,5 +60,36 @@ "hud.kiosk_prompt": "Enter: köp mat/dryck åt %d person(er), %d¥", "hud.kiosk_none": "Ingen är hungrig eller törstig just nu.", "result.explored": "Utforskat", - "result.served": "försörjda" + "result.served": "försörjda", + "hud.cash": "Kontant", + "hud.phone": "Tel", + "hud.ticket": "Biljett", + "hud.machine_topup": "Enter: fyll på kortet (kort %d¥, kontant %d¥)", + "hud.machine_ticket": "Enter: köp biljett (kontant %d¥)", + "hud.wrong_ticket": "Biljetten gäller inte för det här tåget!", + "hud.need_ticket": "Spärren: du behöver en biljett - köp i automaten (T).", + "hud.phone_dead": "Spärren: telefonen är död - köp en biljett i stället.", + "hud.rescan": "Pip… skanna igen.", + "hud.missed_train": "Ni fick inte plats - tåget gick.", + "level.lost": "Banan misslyckades", + "result.lost_wrong_train": "Fel tåg.", + "result.lost_wrong_stop": "Du klev av på fel station.", + "result.lost_missed_stop": "Du åkte förbi ditt resmål.", + "result.spent": "Spenderat", + "result.missed": "Missade tåg", + "menu.phone": "P: kort i telefonen %s", + "menu.on": "på", + "menu.off": "av", + "mini.cancel": "Esc: avbryt", + "mini.topup.title": "Fyll på kortet", + "mini.topup.pick": "Välj rätt knapp på automaten:", + "mini.topup.amount": "Välj belopp:", + "mini.topup.feed": "Håll Enter för att mata in %d¥ i sedlar", + "mini.topup.wrong": "Fel knapp…", + "mini.ticket.title": "Biljetter", + "mini.ticket.hint": "Köp biljett till %s. Kontant: %d¥", + "mini.crowd.title": "Fullt tåg - tränga sig på", + "mini.crowd.hint": "Tryck Enter när markören är i det gröna fältet (%d försök kvar)", + "mini.ride.title": "På tåget - nästa station", + "mini.ride.hint": "Enter: kliv av här (du ska till %s)" } diff --git a/tools/shots/main.go b/tools/shots/main.go index 4b8a643..62e4eb6 100644 --- a/tools/shots/main.go +++ b/tools/shots/main.go @@ -44,6 +44,18 @@ func buildShots(levelsDir string) error { if err != nil { return err } + toMachine, err := moves(l, l.SpawnPoint(), l.Find(level.TicketMachine)[0]) + if err != nil { + return err + } + l2, err := level.LoadFile(filepath.Join(levelsDir, "002-shinjuku.json")) + if err != nil { + return err + } + toMachine2, err := moves(l2, l2.SpawnPoint(), l2.Find(level.TicketMachine)[0]) + 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"} @@ -53,7 +65,9 @@ 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 + - "; waittrain; wait 30; shot $OUT/platform.png; press enter; wait 5; shot $OUT/result.png; quit", known}, + "; 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}, + {"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", "渋谷"}}, } return nil }