M6: plånbok, biljetter, kortet i telefonen och seedade minispel
All checks were successful
release / build-release (push) Successful in 1m41s

- sim.Wallet: IC-kort, kontanter, pappersbiljett, telefon med batteri,
  spenderat; följer med mellan banor (Game.Wallet)
- internal/minigame: gemensamt Game-gränssnitt (Update/Done/Success), all
  slump från del-seeds: TopUp (hitta チャージ, belopp, mata sedlar),
  Ticket (taxetavla med resmål på japanska), Crowd (tidsfönster, två
  försök), Ride (stationerna passerar, kliv av vid resmålet)
- sim.Run: spärren kräver biljett på biljettbanor annars användbart kort;
  telefonen ger ×1,1 fart men 15 % seedad omskanning (frysning 1,5 s),
  dött batteri gör kortet oanvändbart; automaten startar påfyllning eller
  biljettköp; tåget kontrollerar biljetten; fulla tåg kräver trängsel;
  resan avgör vinst/förlust (fel station, åkte förbi, fel tåg); StepMini
  låter klockan och sällskapet ticka medan rörelsen pausar
- level: ticketRequired, rideStops, Train.Crowded; 002 är biljettbana med
  fullt tåg och fyra stopp; ordboken har stationer (kind) + sju nya namn
- game: vyer för minispelen, plånbok i HUD, P i menyn = kort i telefonen,
  förlustorsaker och spenderat på resultatet, flaggor -phone/-card,
  skriptkommandon hold/waitstop; inforuta/minikarta döljs under minispel
- plan: M7b (Björn) – tågvind, ankomstindikator nära rälsen,
  in-/utkörningsanimation för tåg och bussar – i spelbeskrivning, roadmap,
  testplan
- docs/README/wiki med tre nya bilder (automat, biljettavla, tågresa)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 09:50:12 +02:00
parent 3f56020777
commit 9fc7e94074
45 changed files with 1779 additions and 166 deletions

View File

@@ -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+ flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+
upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md). upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md).
**Status:** M5 klar (2026-10-24) – skyltarna är på japanska och du lär dig **Status:** M6 klar (2026-10-24) – japanska skyltar och ordinlärning,
ord genom att läsa, fråga om hjälp, klara banor med bra tid eller lägga lämmeltåget med sina behov, minikarta med dimma, och nu pengarna: tågkortet
språkpoäng i ordlistan. Lämmeltåget följer efter dig med sina behov, och fylls på vid automaten, svårare banor kräver rätt pappersbiljett, kortet kan
minikartan visar bara avgångar och riktmärken tills du utforskat stationen. ligga i telefonen (snabbare, men batteri och omskanningar), fulla tåg kräver
Nästa är M6: tågkort, biljetter, telefon och minispel. Se 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). [doc/roadmap.md](doc/roadmap.md).
![Skylt på japanska med delvis översättning](doc/img/sign.png) ![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 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 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. 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. 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) ![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, 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 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 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 -scale 3 # fönsterstorlek (320x180 × skala)
metro-puzzle-jp -smoke 90 # öppna fönster, avsluta efter 90 bildrutor (smoke-test) 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 -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 -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go
metro-puzzle-jp -version metro-puzzle-jp -version
``` ```
### Styrning ### Styrning
Pilar eller WASD går. Enter väljer, kliver på tåget, använder toaletten 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 handlar i kiosken eller startar automaten; i minispelen väljer pilarna och
ordlistan, M döljer/visar minikartan, Esc går tillbaka, R spelar om. Gamepad 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, med standardlayout fungerar: d-pad/vänster spak, A = välj, B = tillbaka,
Y = hjälp, X = ordlista, Back/Select = karta. Y = hjälp, X = ordlista, Back/Select = karta.
@@ -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/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/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/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/level/ banformat, validering, framkomlighet
internal/res/ hittar och läser resources/ internal/res/ hittar och läser resources/
internal/rng/ seedad slump internal/rng/ seedad slump

View File

@@ -22,6 +22,8 @@ func main() {
know := flag.String("know", "", "japanska ord spelaren kan från start, kommaseparerade (tester/skärmdumpar)") 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)") 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)") 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") version := flag.Bool("version", false, "skriv ut version och avsluta")
flag.Parse() flag.Parse()
@@ -30,7 +32,7 @@ func main() {
return 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 { if err != nil {
log.Fatalf("kunde inte starta: %v", err) log.Fatalf("kunde inte starta: %v", err)
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

BIN
doc/img/ride.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

BIN
doc/img/ticket.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
doc/img/topup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -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` | `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` | `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/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 | | `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan |
### Planerade tester per milstolpe ### 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 | | ~~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 | | ~~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 | | ~~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 | | 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 | | 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 | | 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 | | M10 Story-innehåll | Alla banor lösbara (redan automatiskt); flerbytesbanor har sammanhängande byten; sista banan slutar på flygplatsen |

View File

@@ -33,8 +33,9 @@ brasse-linux01-runnern, inte Pi5:n.
## Milstolpar ## Milstolpar
M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar ✓ M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar ✓
→ M4 lämmeltåget ✓ → M5 minikarta ✓ → M6 kort/biljetter/minispel → M7 → M4 lämmeltåget ✓ → M5 minikarta ✓ → M6 kort/biljetter/minispel ✓ → M7
svårighet/poäng/debuffar → M8 progression/sparfiler → M9 navigeringslägen → svårighet/poäng/debuffar → 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. M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release.
Innehåll per milstolpe: [roadmap.md › Milstolpar](roadmap.md#milstolpar). 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). Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md).

View File

@@ -60,6 +60,7 @@ metro-puzzle-jp/
│ ├── level/ banformat, validering, BFS (ren Go) │ ├── level/ banformat, validering, BFS (ren Go)
│ ├── sim/ rutbaserad rörelse, kollision, kamera, banomgång (Run), sällskap (Party), dimma (Fog) – 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 │ ├── 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 │ ├── game/ Ebitengine-loop, scener, input, rendering, skriptläge ← får använda Ebitengine
│ ├── archtest/ beroenderegler │ ├── archtest/ beroenderegler
│ └── … kommande: lang/, nav/, score/, save/, minigame/, ui/, audio/ │ └── … kommande: lang/, nav/, score/, save/, minigame/, ui/, audio/
@@ -254,18 +255,37 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
syns från start – `TestFogRevealsAlongCorridorsNotThroughWalls`, syns från start – `TestFogRevealsAlongCorridorsNotThroughWalls`,
`TestLandmarksAlwaysVisible`, `TestFogGrowsWithMovementAndResetsPerRun`. ✓ `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. - **Mål:** resurshantering och de seedade minispelen.
- Kortsaldo som dras i spärren; tomt kort → hitta automat → påfyllningsminispel. - **Plånbok** (`sim.Wallet`, följer med mellan banor): IC-kort (2000 ¥),
- Separata biljetter på svårare banor → biljettköpsminispel (rätt biljett). kontanter (5000 ¥), ev. pappersbiljett, kortet i telefonen + batteri,
- Kort i telefonen: speed-buff + procentchans för omskanning. Batteri som totalt spenderat (poäng i M7). Spärren drar från kortet; kiosken också.
tar slut på långa banor → tvingar till biljett. - **Påfyllning** (automaten T): minispel – hitta チャージ bland japanska
- Minispel på tåget: få plats, komma av, gå av på rätt station. knappar i seedad ordning (fel knapp = straffsekund), välj belopp, håll
- **Teknik:** `internal/minigame/*` med gemensamt gränssnitt Enter för att mata in sedlar. Kontanter → kort.
(`Start(seed) / Update / Result`), all slump via del-seeds. - **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 - **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 ### 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. - Svårighetsprofil per bana i JSON.
- **Teknik:** `internal/score`, `internal/sim/stats`. - **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 ### M8 – Progression, items och sparfiler
- **Mål:** något att låsa upp och en anledning att fortsätta. - **Mål:** något att låsa upp och en anledning att fortsätta.

View File

@@ -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å - 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). 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 ## 5. Sällskapet – lämmeltåget
- Dina polare följer efter dig i en rad – därav *lämmeltåget*. - Dina polare följer efter dig i en rad – därav *lämmeltåget*.

View File

@@ -9,6 +9,7 @@ import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" "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 // Intern upplösning. Allt ritas i denna storlek och skalas sedan upp med
@@ -30,6 +31,7 @@ type Scene interface {
type Game struct { type Game struct {
Assets *Assets Assets *Assets
Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8) 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) fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar)
scene Scene scene Scene
next Scene next Scene
@@ -46,6 +48,8 @@ type Options struct {
KnownWords []string // ord spelaren kan från start (för tester/skärmdumpar) 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) 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) 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. // New laddar resurser och bygger spelet.
@@ -60,13 +64,18 @@ func New(opt Options) (*Game, error) {
} }
g.Know.Points = opt.Points g.Know.Points = opt.Points
g.fixedSeed = opt.Seed 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 opt.Script != "" {
if g.script, err = parseScript(opt.Script); err != nil { if g.script, err = parseScript(opt.Script); err != nil {
return nil, err return nil, err
} }
} }
if opt.LevelID != "" { 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 { if err != nil {
return nil, err return nil, err
} }

View File

@@ -81,6 +81,40 @@ func wordsJustPressed() bool {
return virtual.pressed["l"] || inpututil.IsKeyJustPressed(ebiten.KeyL) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftRight) 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å. // mapJustPressed: M eller gamepadens vänstra mittknapp (Back/Select) = minikartan av/på.
func mapJustPressed() bool { func mapJustPressed() bool {
return virtual.pressed["m"] || inpututil.IsKeyJustPressed(ebiten.KeyM) || gamepadJustPressed(ebiten.StandardGamepadButtonCenterLeft) return virtual.pressed["m"] || inpututil.IsKeyJustPressed(ebiten.KeyM) || gamepadJustPressed(ebiten.StandardGamepadButtonCenterLeft)

View File

@@ -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)
}

View File

@@ -13,6 +13,7 @@ import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
) )
@@ -22,6 +23,7 @@ const (
deniedShowSec = 2.0 deniedShowSec = 2.0
learnedShowSec = 3.0 learnedShowSec = 3.0
progressedShowSec = 2.5 progressedShowSec = 2.5
flashShowSec = 2.5
) )
// LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run; // 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 // NewLevelScene laddar banan och ställer spelaren på startrutan. know är
// spelarens japanska, som delas mellan banor; seed styr omgångens slump. // 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")) lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json"))
if err != nil { if err != nil {
return nil, err return nil, err
} }
s := &LevelScene{ s := &LevelScene{
id: id, 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)), cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
} }
s.followPlayer() s.followPlayer()
@@ -62,12 +64,27 @@ func (s *LevelScene) TrainHere() bool {
return tr != nil && at 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() { func (s *LevelScene) followPlayer() {
x, y := s.run.Player.WorldPos(TileSize) x, y := s.run.Player.WorldPos(TileSize)
s.cam.Follow(x+TileSize/2, y+TileSize/2) s.cam.Follow(x+TileSize/2, y+TileSize/2)
} }
func (s *LevelScene) Update(g *Game) error { 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() { if backJustPressed() {
m, err := NewMenuScene(g.Assets) m, err := NewMenuScene(g.Assets)
if err != nil { 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.drawUrge(g, screen, s.run.Party.Members[0], int(ox+math.Round(px)), int(oy+math.Round(py)))
s.drawHUD(g, screen) s.drawHUD(g, screen)
if s.run.Mini != nil {
s.drawMinigame(g, screen)
}
} }
// memberTints färgar följarnas sprites tills de har egna. // 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 if r.Know.ShowsDestinationJP() { // hint från nivå 1: resmålet på japanska, att matcha mot skyltarna
dest += " " + r.Level.Destination.Get("ja") dest += " " + r.Level.Destination.Get("ja")
} }
a.Draw(screen, r.Level.Name.Get(UILang), 4, 0, colText) name := r.Level.Name.Get(UILang)
a.Draw(screen, a.T("hud.destination")+": "+dest, 4+a.Width(r.Level.Name.Get(UILang))+12, 0, colKnown) a.Draw(screen, name, 4, 0, colText)
a.Draw(screen, fmt.Sprintf("%s %3.0fs %s %d %s %d¥ %s Lv%d·%d", a.T("hud.time"), r.Time, destX := 4 + a.Width(name) + 12
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) 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) s.drawMeters(g, screen)
if r.Outcome != sim.Ongoing { if r.Outcome != sim.Ongoing || r.Mini != nil {
return // resultatpanelen tar över; ingen inforuta eller minikarta return // resultatpanelen eller minispelet tar över; ingen inforuta eller minikarta
} }
lines, accent, bar := s.infoLines(a) lines, accent, bar := s.infoLines(a)
if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec { if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec {
@@ -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)...) 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 // Inforutans läge: nederkant, men flyttas upp under HUD:en om spelaren
// (eller tåget under hen) annars skulle hamna bakom den. // (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(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 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 { 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 return nil, accent, false
} }

View File

@@ -58,10 +58,13 @@ func (m *MenuScene) Update(g *Game) error {
g.SetScene(NewWordsScene(g.Assets, m)) g.SetScene(NewWordsScene(g.Assets, m))
return nil return nil
} }
if phoneJustPressed() {
g.Wallet.Phone = !g.Wallet.Phone
}
if n := len(m.entries); n > 0 { if n := len(m.entries); n > 0 {
m.sel = (m.sel + menuMove() + n) % n m.sel = (m.sel + menuMove() + n) % n
if confirmJustPressed() { 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 { if err != nil {
m.err = err m.err = err
return nil return nil
@@ -91,8 +94,12 @@ func (m *MenuScene) Draw(g *Game, screen *ebiten.Image) {
if m.err != nil { if m.err != nil {
a.Draw(screen, "FEL: "+m.err.Error(), 8, ScreenH-40, colBad) 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"), onoff := a.T("menu.off")
a.T("words.points"), g.Know.Points), 8, ScreenH-38, colDim) 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, a.T("menu.hint"), 8, ScreenH-26, colDim)
a.Draw(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-13, colDim) a.Draw(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-13, colDim)
} }

View File

@@ -23,7 +23,7 @@ func (r *ResultScene) Update(g *Game) error {
case wordsJustPressed(): case wordsJustPressed():
g.SetScene(NewWordsScene(g.Assets, r)) g.SetScene(NewWordsScene(g.Assets, r))
case inpututil_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 { if err != nil {
return err return err
} }
@@ -44,7 +44,7 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
a := g.Assets a := g.Assets
vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 120, colPanel, false) 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 { if run.Outcome == sim.Won {
accent, headline = colGood, a.T("level.cleared") 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) 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 y += LineH
if run.Outcome == sim.Lost { if run.Outcome == sim.Lost {
for _, l := range a.wrapLine(plainLine(fmt.Sprintf(a.T("result.wrong_detail"), tr.Destination.Get("romaji"), reason := a.T(run.LostReason)
run.Level.Destination.Get("romaji")), colText), ScreenW-56) { 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) a.drawLines(screen, []line{l}, 28, y)
y += LineH 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.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) a.T("hud.steps"), run.Steps, a.T("result.explored"), 100*run.Fog.Explored(run.Level)), 28, y, colText)
y += LineH 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.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.served"), run.Served), 28, y, colText) a.T("result.spent"), run.Wallet.Spent, a.T("result.missed"), run.MissedTrains, a.T("result.served"), run.Served), 28, y, colText)
y += LineH y += LineH
if run.Outcome == sim.Won { if run.Outcome == sim.Won {
msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("words.points")) msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("words.points"))

View File

@@ -19,7 +19,9 @@ import (
// Stegen skiljs med ';': // Stegen skiljs med ';':
// //
// wait N vänta N bildrutor // 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 // 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 // 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) // 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) // shot PATH spara aktuell bild som PNG (intern upplösning × shotScale)
@@ -53,6 +55,7 @@ type script struct {
var virtual struct { var virtual struct {
held sim.Dir held sim.Dir
pressed map[string]bool pressed map[string]bool
holdEnter bool
} }
func parseScript(src string) (*script, error) { func parseScript(src string) (*script, error) {
@@ -64,9 +67,9 @@ func parseScript(src string) (*script, error) {
} }
st := scriptStep{cmd: f[0]} st := scriptStep{cmd: f[0]}
switch f[0] { switch f[0] {
case "wait": case "wait", "hold":
if len(f) != 2 { 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]) st.n, _ = strconv.Atoi(f[1])
case "press": case "press":
@@ -88,7 +91,7 @@ func parseScript(src string) (*script, error) {
return nil, fmt.Errorf("script: 'shot PATH'") return nil, fmt.Errorf("script: 'shot PATH'")
} }
st.arg = f[1] st.arg = f[1]
case "quit", "waittrain": case "quit", "waittrain", "waitstop":
default: default:
return nil, fmt.Errorf("script: okänt kommando %q", f[0]) 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. // 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. // tick körs i början av varje Update. frame är senast ritade bild.
// Returnerar ebiten.Termination när skriptet vill avsluta. // Returnerar ebiten.Termination när skriptet vill avsluta.
func (s *script) tick(g *Game, frame *ebiten.Image) error { func (s *script) tick(g *Game, frame *ebiten.Image) error {
virtual.held = sim.None virtual.held = sim.None
virtual.pressed = map[string]bool{} virtual.pressed = map[string]bool{}
virtual.holdEnter = false
if s.i >= len(s.steps) { if s.i >= len(s.steps) {
return nil return nil
} }
@@ -135,6 +142,12 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error {
if s.counter >= st.n { if s.counter >= st.n {
s.advance() s.advance()
} }
case "hold":
virtual.holdEnter = true
s.counter++
if s.counter >= st.n {
s.advance()
}
case "press": case "press":
virtual.pressed[st.arg] = true virtual.pressed[st.arg] = true
s.advance() 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() { if tw, ok := g.scene.(trainWaiter); !ok || tw.TrainHere() {
s.advance() s.advance()
} }
case "waitstop":
if tw, ok := g.scene.(trainWaiter); !ok || tw.RideAtDest() {
s.advance()
}
case "shot": case "shot":
if err := saveShot(frame, st.arg); err != nil { if err := saveShot(frame, st.arg); err != nil {
return err return err

View File

@@ -19,6 +19,7 @@ type Entry struct {
En string `json:"en"` En string `json:"en"`
Sv string `json:"sv"` Sv string `json:"sv"`
Tier int `json:"tier"` 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. // 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. // Words returnerar alla ord, längsta först.
func (d *Dictionary) Words() []string { return append([]string(nil), d.words...) } 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. ー). // IsKana rapporterar om tecknet är hiragana eller katakana (inkl. ー).
func IsKana(r rune) bool { func IsKana(r rune) bool {
return unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) || r == 'ー' return unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) || r == 'ー'

View File

@@ -248,3 +248,15 @@ func TestBuyWordsWithPoints(t *testing.T) {
t.Fatal("negativa stjärnor ger inget") 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")
}
}

View File

@@ -72,6 +72,7 @@ const (
DefaultDwellSec = 10.0 DefaultDwellSec = 10.0
DefaultFareYen = 200 DefaultFareYen = 200
MaxParty = 5 MaxParty = 5
DefaultRideStops = 3
) )
// Status returnerar om tåget står vid perrongen vid tiden t, och hur många // 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 Line Text `json:"line"` // linjens namn
Destination Text `json:"destination"` // slutstation Destination Text `json:"destination"` // slutstation
Correct bool `json:"correct"` // tar spelaren till rätt resmål Correct bool `json:"correct"` // tar spelaren till rätt resmål
Crowded bool `json:"crowded"` // fullt tåg: minispel för att få plats
Schedule Schedule `json:"schedule"` Schedule Schedule `json:"schedule"`
} }
@@ -113,6 +115,8 @@ type Level struct {
TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns
ParSec int `json:"parSec"` // "bra tid" för tre stjärnor (0 = räknas ut från kortaste vägen) ParSec int `json:"parSec"` // "bra tid" för tre stjärnor (0 = räknas ut från kortaste vägen)
Party int `json:"party"` // antal i sällskapet inkl. spelaren (0 = 1) Party int `json:"party"` // antal i sällskapet inkl. spelaren (0 = 1)
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) FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard)
Grid []string `json:"grid"` Grid []string `json:"grid"`
Trains []Train `json:"trains"` Trains []Train `json:"trains"`
@@ -240,6 +244,15 @@ func (l *Level) Validate() error {
if l.Party == 0 { if l.Party == 0 {
l.Party = 1 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 { if l.Party > MaxParty {
return fmt.Errorf("level %s: sällskapet får vara högst %d", l.ID, MaxParty) return fmt.Errorf("level %s: sällskapet får vara högst %d", l.ID, MaxParty)
} }

View File

@@ -288,3 +288,21 @@ func TestPartyAndParDefaults(t *testing.T) {
t.Fatal("W och K ska vara passerbara rutor") 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")
}
}

View File

@@ -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
}
}
}

View File

@@ -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()}
}

View File

@@ -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)
}
}

12
internal/minigame/rand.go Normal file
View File

@@ -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) }

70
internal/minigame/ride.go Normal file
View File

@@ -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
}
}
}

View File

@@ -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
}
}

107
internal/minigame/topup.go Normal file
View File

@@ -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
}

View File

@@ -5,6 +5,7 @@ import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
) )
@@ -18,30 +19,12 @@ const (
Lost // klev på fel tåg Lost // klev på fel tåg
) )
// Card är tågkortet (IC-kortet). Saldo i yen.
type Card struct{ Balance int }
// CanPay rapporterar om saldot räcker.
func (c *Card) CanPay(yen int) bool { return c.Balance >= yen }
// Pay drar beloppet om det går.
func (c *Card) Pay(yen int) bool {
if !c.CanPay(yen) {
return false
}
c.Balance -= yen
return true
}
// StartBalanceYen är kortets saldo vid banstart tills M6 ger riktig ekonomi.
const StartBalanceYen = 2000
// Run är tillståndet för en pågående bana: spelaren, klockan, kortet och // 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. // utfallet. Ren logik – LevelScene ritar den, testerna kör den headless.
type Run struct { type Run struct {
Level *level.Level Level *level.Level
Player *Mover Player *Mover
Card Card Wallet *Wallet // pengar, kort, telefon, biljett – delas mellan banor
Time float64 // sekunder sedan banstart (styr tidtabellerna) Time float64 // sekunder sedan banstart (styr tidtabellerna)
Steps int Steps int
GatePasses int GatePasses int
@@ -80,8 +63,51 @@ type Run struct {
Served int // personer som fått mat/dryck/toa (statistik) Served int // personer som fått mat/dryck/toa (statistik)
Notice string // senaste meddelande från sällskapet (HUD) Notice string // senaste meddelande från sällskapet (HUD)
NoticeAt float64 // < 0 = aldrig 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). // FacilityState är ett pågående toalettbesök (tar tid, avbryts om man går).
type FacilityState struct { type FacilityState struct {
Active bool Active bool
@@ -96,6 +122,7 @@ type RunOptions struct {
Speed float64 // rutor per sekund (0 = DefaultSpeed) Speed float64 // rutor per sekund (0 = DefaultSpeed)
Dict *lang.Dictionary // nil = tom ordbok Dict *lang.Dictionary // nil = tom ordbok
Know *lang.Knowledge // nil = ny kunskap Know *lang.Knowledge // nil = ny kunskap
Wallet *Wallet // nil = startkassa
Seed rng.Seed // omgångens seed (sparas med spelet) Seed rng.Seed // omgångens seed (sparas med spelet)
} }
@@ -128,10 +155,14 @@ func NewRun(l *level.Level, o RunOptions) *Run {
if o.Speed <= 0 { if o.Speed <= 0 {
o.Speed = DefaultSpeed o.Speed = DefaultSpeed
} }
if o.Wallet == nil {
o.Wallet = NewWallet()
}
r := &Run{ r := &Run{
Level: l, Level: l,
Player: NewMover(l.SpawnPoint(), o.Speed), Player: NewMover(l.SpawnPoint(), o.Speed),
Card: Card{Balance: StartBalanceYen}, Wallet: o.Wallet,
FlashAt: -1,
DeniedAt: -1, DeniedAt: -1,
Dict: o.Dict, Dict: o.Dict,
Know: o.Know, 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 // 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 { func (r *Run) CanEnter(p level.Point) bool {
t := r.Level.At(p.X, p.Y) t := r.Level.At(p.X, p.Y)
if !t.Passable() { if !t.Passable() {
return false 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 r.DeniedAt = r.Time
return false return false
} }
return true 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 { func (r *Run) inGate() bool {
return r.Level.At(r.Player.Tile.X, r.Player.Tile.Y) == level.Gate 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 { if r.Outcome != Ongoing {
return return
} }
if r.Mini != nil {
return // minispel pågår: använd StepMini
}
r.Time += dt r.Time += dt
r.Wallet.Drain(dt)
before := r.Player.Tile before := r.Player.Tile
wasGate := r.inGate() wasGate := r.inGate()
r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor() 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) r.Steps += r.Player.Step(dt, want, r.CanEnter)
if r.Player.Tile != before { if r.Player.Tile != before {
r.Party.OnLeaderArrived(r.Player.Tile) r.Party.OnLeaderArrived(r.Player.Tile)
@@ -212,8 +261,15 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
r.stepFacility(dt) r.stepFacility(dt)
r.stepReading() r.stepReading()
if r.Player.Tile != before && r.inGate() && !wasGate { if r.Player.Tile != before && r.inGate() && !wasGate {
r.Card.Pay(r.Level.FareYen)
r.GatePasses++ 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() { if board && !r.Player.Moving() {
switch r.Level.At(r.Player.Tile.X, r.Player.Tile.Y) { 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() r.useToilet()
case level.Kiosk: case level.Kiosk:
r.buyAtKiosk() r.buyAtKiosk()
case level.TicketMachine:
r.startMachine()
default: default:
if tr, at, _ := r.PlatformHere(); tr != nil && at { 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). // useToilet startar ett toalettbesök för alla som behöver (tar tid).
func (r *Run) useToilet() { func (r *Run) useToilet() {
if r.Facility.Active { 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. // buyAtKiosk köper mat och dryck åt alla som behöver, betalat med kortet.
func (r *Run) buyAtKiosk() { 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. // 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) return r.Level.SignAt(r.Player.Tile)
} }
// Board kliver på ett tåg och avgör banan. En vinst ger stjärnor efter tid, // finish avgör banan. En vinst ger stjärnor efter tid, språkpoäng, och vid
// språkpoäng, och vid tillräckligt bra resultat lär man sig resmålets ord. // tillräckligt bra resultat lär man sig resmålets ord.
func (r *Run) Board(tr *level.Train) { func (r *Run) finish(tr *level.Train, won bool, reason string) {
r.Boarded = tr r.Boarded = tr
if !tr.Correct { if !won {
r.Outcome = Lost r.Outcome = Lost
r.LostReason = reason
return return
} }
r.Outcome = Won r.Outcome = Won

View File

@@ -7,10 +7,27 @@ import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
) )
// 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 { func loadDict(t *testing.T) *lang.Dictionary {
t.Helper() t.Helper()
b, err := res.ReadFile("text", "jp-dictionary.json") 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) { func walk(t *testing.T, r *Run, path []level.Point) {
t.Helper() t.Helper()
for _, next := range path[1:] { 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() { if r.Player.Tile == next && !r.Player.Moving() {
break break
} }
if r.Player.Target == next || r.Player.Tile == next {
r.Step(frame, None, false)
} else {
r.Step(frame, d, false) 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) 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") t.Fatal("rätt perrong ska ha rätt tåg")
} }
r.Step(frame, None, true) 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 { if r.Outcome != Won || r.Boarded != tr {
t.Fatalf("outcome=%v boarded=%v", r.Outcome, r.Boarded) t.Fatalf("outcome=%v boarded=%v", r.Outcome, r.Boarded)
} }
if r.GatePasses != 1 || 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.Card.Balance, StartBalanceYen-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 { if r.Steps != len(path)-1 {
t.Fatalf("steg=%d, vägen är %d rutor", 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)) walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
tr := waitForTrain(t, r) tr := waitForTrain(t, r)
r.Step(frame, None, true) r.Step(frame, None, true)
if r.Outcome != Lost || r.Boarded != tr || tr.Correct { ride(t, r)
t.Fatalf("outcome=%v boarded=%+v", r.Outcome, r.Boarded) 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") l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
gate := l.Find(level.Gate)[0] gate := l.Find(level.Gate)[0]
r.Card.Balance = 0 r.Wallet.Card = 0
if r.CanEnter(gate) { if r.CanEnter(gate) {
t.Fatal("spärren ska stoppa ett tomt kort") 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 { if l.Path(r.Player.Tile, l.Find(level.Platform)[0], r.CanEnter) != nil {
t.Fatal("utan saldo ska perrongen vara onåbar") t.Fatal("utan saldo ska perrongen vara onåbar")
} }
r.Card.Balance = l.FareYen r.Wallet.Card = l.FareYen
if !r.CanEnter(gate) { if !r.CanEnter(gate) {
t.Fatal("med exakt avgiften ska spärren släppa igenom") t.Fatal("med exakt avgiften ska spärren släppa igenom")
} }
// Mellan två spärrutor är det gratis: ställ spelaren i spärren. // Mellan två spärrutor är det gratis: ställ spelaren i spärren.
r.Player.Tile, r.Player.Target = gate, gate 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} other := level.Point{X: gate.X + 1, Y: gate.Y}
if l.At(other.X, other.Y) == level.Gate && !r.CanEnter(other) { if l.At(other.X, other.Y) == level.Gate && !r.CanEnter(other) {
t.Fatal("att gå mellan två spärrutor ska inte kosta") t.Fatal("att gå mellan två spärrutor ska inte kosta")
@@ -302,6 +330,7 @@ func TestGoodResultTeachesDestination(t *testing.T) {
waitForTrain(t, r) waitForTrain(t, r)
} }
r.Step(frame, None, true) r.Step(frame, None, true)
ride(t, r)
if r.Outcome != Won || r.Stars != tc.stars { if r.Outcome != Won || r.Stars != tc.stars {
t.Fatalf("%s: outcome=%v stars=%d", tc.name, r.Outcome, r.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)) walk(t, r, l.Path(r.Player.Tile, kiosk, r.CanEnter))
anna.Meters.Drink = 5 anna.Meters.Drink = 5
anna.Urge = Thirsty anna.Urge = Thirsty
before := r.Card.Balance before := r.Wallet.Card
r.Step(frame, None, true) r.Step(frame, None, true)
if anna.Meters.Drink != 100 || anna.Urge != NoUrge || r.Card.Balance != before-KioskPriceYen || r.Served != 2 { 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.Card.Balance, r.Served) 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) 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")
}
}

67
internal/sim/wallet.go Normal file
View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -1,11 +1,21 @@
{ {
"id": "002-shinjuku",
"name": {
"en": "Shinjuku Station",
"ja": "新宿駅",
"sv": "Shinjuku station"
},
"difficulty": 2,
"party": 3,
"ticketRequired": true,
"rideStops": 4,
"destination": { "destination": {
"en": "Shibuya", "en": "Shibuya",
"ja": "渋谷", "ja": "渋谷",
"romaji": "Shibuya", "romaji": "Shibuya",
"sv": "Shibuya" "sv": "Shibuya"
}, },
"difficulty": 2, "timeLimitSec": 0,
"fareYen": 200, "fareYen": 200,
"grid": [ "grid": [
"#################################", "#################################",
@@ -30,13 +40,49 @@
"#pG.#...#.....................#P#", "#pG.#...#.....................#P#",
"#################################" "#################################"
], ],
"id": "002-shinjuku", "trains": [
"name": { {
"en": "Shinjuku Station", "correct": true,
"ja": "新宿駅", "destination": {
"sv": "Shinjuku station" "en": "Shibuya",
"ja": "渋谷",
"romaji": "Shibuya"
}, },
"party": 3, "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": [ "signs": [
{ {
"text": { "text": {
@@ -65,48 +111,5 @@
"x": 15, "x": 15,
"y": 17 "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"
}
] ]
} }

View File

@@ -49,7 +49,7 @@
"words.hint": "Arrows: select Enter: learn the word for points Esc/L: back", "words.hint": "Arrows: select Enter: learn the word for points Esc/L: back",
"words.already": "You already know that word.", "words.already": "You already know that word.",
"words.bought": "New word: %s = %s", "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.toilet": "needs the toilet (W)",
"urge.thirsty": "is thirsty (K)", "urge.thirsty": "is thirsty (K)",
"urge.hungry": "is hungry (K)", "urge.hungry": "is hungry (K)",
@@ -60,5 +60,36 @@
"hud.kiosk_prompt": "Enter: buy food/drink for %d person(s), %d¥", "hud.kiosk_prompt": "Enter: buy food/drink for %d person(s), %d¥",
"hud.kiosk_none": "Nobody is hungry or thirsty right now.", "hud.kiosk_none": "Nobody is hungry or thirsty right now.",
"result.explored": "Explored", "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)"
} }

View File

@@ -145,35 +145,40 @@
"romaji": "Shinjuku", "romaji": "Shinjuku",
"en": "Shinjuku", "en": "Shinjuku",
"sv": "Shinjuku", "sv": "Shinjuku",
"tier": 2 "tier": 2,
"kind": "station"
}, },
"渋谷": { "渋谷": {
"kana": "しぶや", "kana": "しぶや",
"romaji": "Shibuya", "romaji": "Shibuya",
"en": "Shibuya", "en": "Shibuya",
"sv": "Shibuya", "sv": "Shibuya",
"tier": 2 "tier": 2,
"kind": "station"
}, },
"東京": { "東京": {
"kana": "とうきょう", "kana": "とうきょう",
"romaji": "Tōkyō", "romaji": "Tōkyō",
"en": "Tokyo", "en": "Tokyo",
"sv": "Tokyo", "sv": "Tokyo",
"tier": 1 "tier": 1,
"kind": "station"
}, },
"上野": { "上野": {
"kana": "うえの", "kana": "うえの",
"romaji": "Ueno", "romaji": "Ueno",
"en": "Ueno", "en": "Ueno",
"sv": "Ueno", "sv": "Ueno",
"tier": 2 "tier": 2,
"kind": "station"
}, },
"京成上野": { "京成上野": {
"kana": "けいせいうえの", "kana": "けいせいうえの",
"romaji": "Keisei-Ueno", "romaji": "Keisei-Ueno",
"en": "Keisei-Ueno", "en": "Keisei-Ueno",
"sv": "Keisei-Ueno", "sv": "Keisei-Ueno",
"tier": 3 "tier": 3,
"kind": "station"
}, },
"京成線": { "京成線": {
"kana": "けいせいせん", "kana": "けいせいせん",
@@ -208,7 +213,8 @@
"romaji": "Narita", "romaji": "Narita",
"en": "Narita", "en": "Narita",
"sv": "Narita", "sv": "Narita",
"tier": 3 "tier": 3,
"kind": "station"
}, },
"駅": { "駅": {
"kana": "えき", "kana": "えき",
@@ -419,5 +425,61 @@
"en": "Keisei Main Line", "en": "Keisei Main Line",
"sv": "Keisei huvudlinje", "sv": "Keisei huvudlinje",
"tier": 3 "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"
} }
} }

View File

@@ -49,7 +49,7 @@
"words.hint": "Pilar: välj Enter: lär dig ordet för poäng Esc/L: tillbaka", "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.already": "Det ordet kan du redan.",
"words.bought": "Nytt ord: %s = %s", "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.toilet": "måste på toa (W)",
"urge.thirsty": "är törstig (K)", "urge.thirsty": "är törstig (K)",
"urge.hungry": "är hungrig (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_prompt": "Enter: köp mat/dryck åt %d person(er), %d¥",
"hud.kiosk_none": "Ingen är hungrig eller törstig just nu.", "hud.kiosk_none": "Ingen är hungrig eller törstig just nu.",
"result.explored": "Utforskat", "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)"
} }

View File

@@ -44,6 +44,18 @@ func buildShots(levelsDir string) error {
if err != nil { if err != nil {
return err 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 // Skyltbilderna tas med några kända ord så att markeringen och den
// delvisa översättningen syns; hjälpen lär sedan in nästa ord. // delvisa översättningen syns; hjälpen lär sedan in nästa ord.
known := []string{"-know", "改札,番線,方面", "-seed", "7"} known := []string{"-know", "改札,番線,方面", "-seed", "7"}
@@ -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}, {"001-flygplatsen", "move right 4; move down 2; wait 5; shot $OUT/level-001.png; quit", nil},
{"002-shinjuku", "move down 6; move right 4; wait 5; shot $OUT/level-002.png; quit", nil}, {"002-shinjuku", "move down 6; move right 4; wait 5; shot $OUT/level-002.png; quit", nil},
{"001-flygplatsen", toSign + "; wait 5; shot $OUT/sign.png; press h; wait 200; shot $OUT/sign-help.png; wait 200; shot $OUT/sign-learned.png; " + toGoal + {"001-flygplatsen", toSign + "; wait 5; shot $OUT/sign.png; press h; wait 200; shot $OUT/sign-help.png; wait 200; shot $OUT/sign-learned.png; " + toGoal +
"; 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 return nil
} }