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>
31
README.md
@@ -10,11 +10,12 @@ Storyn följer några polare på semester i Japan, från landningen på
|
||||
flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+
|
||||
upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md).
|
||||
|
||||
**Status:** M5 klar (2026-10-24) – skyltarna är på japanska och du lär dig
|
||||
ord genom att läsa, fråga om hjälp, klara banor med bra tid eller lägga
|
||||
språkpoäng i ordlistan. Lämmeltåget följer efter dig med sina behov, och
|
||||
minikartan visar bara avgångar och riktmärken tills du utforskat stationen.
|
||||
Nästa är M6: tågkort, biljetter, telefon och minispel. Se
|
||||
**Status:** M6 klar (2026-10-24) – japanska skyltar och ordinlärning,
|
||||
lämmeltåget med sina behov, minikarta med dimma, och nu pengarna: tågkortet
|
||||
fylls på vid automaten, svårare banor kräver rätt pappersbiljett, kortet kan
|
||||
ligga i telefonen (snabbare, men batteri och omskanningar), fulla tåg kräver
|
||||
att man tränger sig på, och på tåget måste man kliva av på rätt station.
|
||||
Nästa är M7: svårighet, tid, poäng och debuffar. Se
|
||||
[doc/roadmap.md](doc/roadmap.md).
|
||||
|
||||

|
||||
@@ -26,10 +27,18 @@ väntar några sekunder och får hela skylten översatt plus ett nytt ord.
|
||||
Spärrarna släpper bara igenom dig om kortet har saldo. På perrongen (gul
|
||||
ledlinje) är avgångstavlan också japansk. Står tåget inne trycker du Enter
|
||||
för att kliva på. Rätt tåg tar dig till resmålet, fel tåg avslutar banan.
|
||||
På tåget passerar stationerna en i taget; kliv av med Enter vid ditt resmål.
|
||||
Klarar du banan under par får du stjärnor, språkpoäng och resmålets namn.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

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