M7: svårighet, tid, poäng, debuffar, egna kort och sittplatsval
All checks were successful
release / build-release (push) Successful in 1m37s

- sim/stats.go: Stats (fart, törst, fokus, toalett, tur), debuffar som
  multiplikatorer, Profile som följer med mellan banor och räknar ned
- sim.Run: tidsgräns och maxantal missade tåg förlorar banan; poängformel
  (tid mot par, pengar, sällskapets mätare, utforskat, avdrag); debuffar
  tjänas av många steg, lång tid, tom matmätare och seedad otur vid
  kiosken; stats påverkar fart, mätartakter, läsning och hjälptid
- egna kort (individualCards): Member.Card, spärren kräver allas saldo och
  drar från alla, automaten frågar vems kort, biljetter köps åt alla
- sittplatsval (seatSelection): Ticket får ett sittplatssteg med seedade
  önskemål (fönster/gång/sida/bredvid); missnöjda ger mätar- och poängavdrag
- level: maxMissedTrains, individualCards, seatSelection; bana 003 Shibuya
  (svårighet 3, fyra i sällskapet) och 002 med tidsgräns och sittplatser;
  ordboken utökad; generatorn är spec-driven
- game: tid kvar/missade i HUD, debuffar i meny/HUD/resultat, poäng och
  förlustorsaker på resultatet, vyer för personval och vagn, skript-pick,
  flaggan -debuffs; HUD och resultat komprimerade för fyra i sällskapet
- plan (Björn 2026-10-24): flera våningar med trappor som M9a, egna kort
  och sittplatsval som svårighetsinställningar i M7 – i spelbeskrivning,
  roadmap, testplan
- docs/README/wiki med sittplatsbilden

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 10:18:17 +02:00
parent 9fc7e94074
commit b2995d4ca9
44 changed files with 1567 additions and 177 deletions

View File

@@ -10,12 +10,12 @@ Storyn följer några polare på semester i Japan, från landningen på
flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+ flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+
upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md). upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md).
**Status:** M6 klar (2026-10-24) – japanska skyltar och ordinlärning, **Status:** M7 klar (2026-10-24) – tre banor med stigande svårighet.
lämmeltåget med sina behov, minikarta med dimma, och nu pengarna: tågkortet Japanska skyltar och ordinlärning, lämmeltåget med sina behov, minikarta med
fylls på vid automaten, svårare banor kräver rätt pappersbiljett, kortet kan dimma, tågkort, biljetter och minispel, och nu svårigheten: tidsgräns, max
ligga i telefonen (snabbare, men batteri och omskanningar), fulla tåg kräver antal missade tåg, en poängformel, debuffar som följer med mellan banor, och
att man tränger sig på, och på tåget måste man kliva av på rätt station. inställningarna egna kort per person och sittplatsval med sällskapets
Nästa är M7: svårighet, tid, poäng och debuffar. Se önskemål. Nästa är M7b: tågen ska synas och kännas när de kommer och går. Se
[doc/roadmap.md](doc/roadmap.md). [doc/roadmap.md](doc/roadmap.md).
![Skylt på japanska med delvis översättning](doc/img/sign.png) ![Skylt på japanska med delvis översättning](doc/img/sign.png)
@@ -39,6 +39,13 @@ på japanska.
![Automaten](doc/img/ticket.png) ![Automaten](doc/img/ticket.png)
På de svåraste banorna har alla i sällskapet egna kort som måste ha saldo,
alla behöver biljett, och när biljetten är köpt ska sällskapet placeras i
vagnen efter sina önskemål: fönster eller gång, vänster eller höger sida,
eller bredvid en viss person. Missnöjda blir sura och drar ned poängen.
![Sittplatsval](doc/img/seats.png)
Ditt sällskap följer efter i en rad. Mätarna uppe till höger visar mat, Ditt sällskap följer efter i en rad. Mätarna uppe till höger visar mat,
dryck och toa för var och en; faller de får någon ett behov som saktar ned dryck och toa för var och en; faller de får någon ett behov som saktar ned
er alla tills ni hittar en kiosk eller en toalett. Minikartan i hörnet visar er alla tills ni hittar en kiosk eller en toalett. Minikartan i hörnet visar
@@ -124,6 +131,7 @@ metro-puzzle-jp -smoke 90 # öppna fönster, avsluta efter 90 bil
metro-puzzle-jp -know 改札,番線 -points 3 # förkunskap och språkpoäng (tester/skärmdumpar) metro-puzzle-jp -know 改札,番線 -points 3 # förkunskap och språkpoäng (tester/skärmdumpar)
metro-puzzle-jp -seed 7 # fast seed för händelser, läsning och minispel (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 -phone -card 0 # kortet i telefonen; kortets startsaldo (tester/skärmdumpar)
metro-puzzle-jp -debuffs sore_feet,tired # debuffar från start (tester/skärmdumpar)
metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go
metro-puzzle-jp -version metro-puzzle-jp -version
``` ```

View File

@@ -24,6 +24,7 @@ func main() {
seed := flag.Uint64("seed", 0, "fast seed för banorna, 0 = slumpad (tester/skärmdumpar)") seed := flag.Uint64("seed", 0, "fast seed för banorna, 0 = slumpad (tester/skärmdumpar)")
phone := flag.Bool("phone", false, "ha tågkortet i telefonen från start") 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)") card := flag.Int("card", -1, "kortets startsaldo i yen, -1 = standard (tester/skärmdumpar)")
debuffs := flag.String("debuffs", "", "debuffar från start, kommaseparerade, t.ex. sore_feet,tired (tester/skärmdumpar)")
version := flag.Bool("version", false, "skriv ut version och avsluta") version := flag.Bool("version", false, "skriv ut version och avsluta")
flag.Parse() flag.Parse()
@@ -32,7 +33,7 @@ func main() {
return return
} }
g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card}) g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card, Debuffs: splitWords(*debuffs)})
if err != nil { if err != nil {
log.Fatalf("kunde inte starta: %v", err) log.Fatalf("kunde inte starta: %v", err)
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

BIN
doc/img/seats.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -34,7 +34,7 @@ att bygga på Linux som på Windows.
| Inkrementellt debug-bygge | < 10 s | 4,7 s | | Inkrementellt debug-bygge | < 10 s | 4,7 s |
| Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) | | Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) |
| Testsviten | `go test ./...` grön på < 30 s | ~0,1 s | | Testsviten | `go test ./...` grön på < 30 s | ~0,1 s |
| Banor | 100 % passerar det automatiska lösbarhetstestet | 2 av 2 | | Banor | 100 % passerar det automatiska lösbarhetstestet | 3 av 3 |
| Windows-korsbygge från Linux | fungerar utan extra verktyg | ✓ (CGO_ENABLED=0) | | Windows-korsbygge från Linux | fungerar utan extra verktyg | ✓ (CGO_ENABLED=0) |
| Linux-binärens länkade bibliotek | bara libc | ✓ (GL/X11 laddas dynamiskt vid körning) | | Linux-binärens länkade bibliotek | bara libc | ✓ (GL/X11 laddas dynamiskt vid körning) |
| Bildfrekvens | 60 fps vid 4× skala på integrerad grafik | ej mätt | | Bildfrekvens | 60 fps vid 4× skala på integrerad grafik | ej mätt |
@@ -111,6 +111,11 @@ att bygga på Linux som på Windows.
| `internal/sim` | `TestWalletPayTopUpAndPhone` | Kort, kontanter, påfyllning, spenderat, batteri gör kortet i telefonen oanvändbart | | `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` | `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/sim` | `TestPhoneSpeedRescanAndBattery`, `TestRideWrongOrMissedStopLoses` | Fartbuff, seedad omskanning som fryser spelaren, dött batteri stoppar spärren; fel station / åka förbi förlorar |
| `internal/sim` | `TestDebuffEffectsAndProfile` | **M7:** varje debuff påverkar något, kombineras multiplikativt, profilen förlänger i stället för dubblerar och räknar ned per bana |
| `internal/sim` | `TestTimeLimitAndMissedTrainsLose`, `TestScoreBreakdown` | Tidsgräns och maxantal missade tåg förlorar banan (001 har inga gränser); poängformeln och avdragen |
| `internal/sim` | `TestDebuffsAffectRunAndAreEarned` | Skoskav/matförgiftning/hunger sänker fart, snabbar mätare och förlänger hjälptiden; många steg, lång tid och tom matmätare ger debuffar |
| `internal/sim` | `TestIndividualCardsAtGateAndMachine`, `TestLevel003Flow` | Egna kort: spärren kräver allas saldo, automaten frågar vems kort, spärren drar från alla; **hela bana 003 headless** med biljetter åt alla, sittplatser, trängsel och resa |
| `internal/minigame` | `TestTopUpPicksMemberWithIndividualCards`, `TestSeatSelectionPreferences` | Personval och 2×2-rutnät; seedade önskemål, upptagna platser, missnöje, fönster/gång/sida/bredvid, Esc avbryter köpet |
| `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan | | `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan |
### Planerade tester per milstolpe ### Planerade tester per milstolpe
@@ -123,9 +128,10 @@ att bygga på Linux som på Windows.
| ~~M4 Lämmeltåget~~ ✓ | Mätare, händelsefrekvens efter gruppstorlek, seedade händelser – finns, se tabellen ovan | | ~~M4 Lämmeltåget~~ ✓ | Mätare, händelsefrekvens efter gruppstorlek, seedade händelser – finns, se tabellen ovan |
| ~~M5 Minikarta~~ ✓ | Upptäckta rutor, nollställning per bana, avgångar från start – finns, se tabellen ovan | | ~~M5 Minikarta~~ ✓ | Upptäckta rutor, nollställning per bana, avgångar från start – finns, se tabellen ovan |
| ~~M6 Kort/biljetter/minispel~~ ✓ | Saldo, automat, biljetter, omskanning, batteri – finns, se tabellen ovan | | ~~M6 Kort/biljetter/minispel~~ ✓ | Saldo, automat, biljetter, omskanning, batteri – finns, se tabellen ovan |
| M7 Svårighet/poäng/debuffs | Poängformeln (tid, pengar, mätare, läge); tidsgräns och maxantal missade tåg; varje debuff ändrar rätt stat; temporära debuffar räknar ned 1–4 banor | | ~~M7 Svårighet/poäng/debuffs~~ ✓ | Poäng, tidsgräns, debuffar, egna kort, sittplatsval – finns, se tabellen ovan |
| 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 | | M7b Tåg som kommer och går | `TrainPhase(t)` ger approaching/arriving/dwelling/departing/away med rätt fas ur tidtabellen; indikatorn aktiv bara inom N rutor från rälsen; vindzonen = rutor längs perrongkanten |
| M8 Progression & sparfil | Sparfil round-trip (skriv → läs ger samma tillstånd inkl. seed); skill tree-krav; upplåsningar efter rätt bana; "< 33 %"-regeln kontrolleras mot itemlistan | | M8 Progression & sparfil | Sparfil round-trip (skriv → läs ger samma tillstånd inkl. seed); skill tree-krav; upplåsningar efter rätt bana; "< 33 %"-regeln kontrolleras mot itemlistan |
| M9a Flera våningar | Trappa byter våning för Mover och följare; `Path` över våningar; lösbarhetstest med trappor; dimma/minikarta per våning; gamla banor = en våning |
| M9 Navigeringslägen | Listläge genererar rätt ordning av tåg; skyltläge: 1–4 giltiga vägar; fel-riktnings-förlust triggar vid rätt slutstation | | M9 Navigeringslägen | Listläge genererar rätt ordning av tåg; skyltläge: 1–4 giltiga vägar; fel-riktnings-förlust triggar vid rätt slutstation |
| M10 Story-innehåll | Alla banor lösbara (redan automatiskt); flerbytesbanor har sammanhängande byten; sista banan slutar på flygplatsen | | M10 Story-innehåll | Alla banor lösbara (redan automatiskt); flerbytesbanor har sammanhängande byten; sista banan slutar på flygplatsen |
| M11 Oändligt läge & NG+ | Målsekvens deterministisk per seed; NG+ behåller skills men nollställer items | | M11 Oändligt läge & NG+ | Målsekvens deterministisk per seed; NG+ behåller skills men nollställer items |

View File

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

View File

@@ -287,15 +287,46 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
spelbart; `TestTicketLevelFlow` spelar hela 002 headless: fel biljett spelbart; `TestTicketLevelFlow` spelar hela 002 headless: fel biljett
stoppas, rätt biljett, spärr, trängsel, resa, vinst. ✓ stoppas, rätt biljett, spärr, trängsel, resa, vinst. ✓
### M7 – Svårighet, tid, poäng och debuffar ### M7 – Svårighet, tid, poäng och debuffar ✓ (klar 2026-10-24)
- **Mål:** banor som kan misslyckas och poäng att jaga. - **Mål:** banor som kan misslyckas och poäng att jaga.
- Tidsgräns och maxantal missade tåg på svåra banor; lätta banor obegränsade. - Tidsgräns och maxantal missade tåg på svåra banor; lätta banor
- Poängformel: tid, pengar/påfyllningar, mätarnas status, navigeringsläge. obegränsade.
- Stats (speed, törst, fokus, toalett, tur) och debuffar (onda fötter, - Poängformel: tid mot par, pengar spenderade, sällskapets mätare vid
trötthet, hunger/irritation, matförgiftning); temporära debuffar 1–4 banor. målgång, utforskat, avdrag för missade tåg och omskanningar;
- Svårighetsprofil per bana i JSON. navigeringsläge ger multiplikator i M9.
- **Teknik:** `internal/score`, `internal/sim/stats`. - Stats (speed, törst, fokus, toalett, tur) som multiplikatorer och
**debuffar** som påverkar dem: onda fötter (många steg), trötthet (lång
bana), hunger/irritation (tom matmätare), matförgiftning (seedad chans
vid kioskköp, snabbare toamätare). De temporära debuffarna från
sidouppdrag (träningsvärk, skoskav, tungt köp, proppmätt, svettig) finns
i systemet med varaktighet 1–4 banor; sidouppdragen som ger dem kommer i
M10.
- **Svårighetsinställningar per bana** (Björns tillägg 2026-10-24):
- **Egna kort** (`individualCards`): varje person i sällskapet har sitt
eget tågkort; spärren kräver att alla har pengar, biljettbanor att alla
har biljett. Påfyllningsautomaten frågar vems kort.
- **Sittplatsval** (`seatSelection`, kräver biljettbana): efter resmålet
väljer man platser i vagnen (fönster/gång, vänster/höger, bredvid
någon) efter sällskapets seedade önskemål. Missnöjda i sällskapet blir
sura: mätaravdrag och poängavdrag.
- Våningar (`floors`) är också en svårighetsvariabel men byggs i M9a.
- **Teknik:** `sim/stats.go` (Stats, Debuff, Profile – ren Go),
`Run.Score()`, `level` får `maxMissedTrains`, `individualCards`,
`seatSelection`; `minigame.TopUp` får personval, `minigame.Ticket` får
sittplatssteg.
- **Poängformeln som byggdes:** tid 500 × min(1, par/tid) + pengar
(300 − spenderat/10) + mätare (200 × sällskapets medel) + utforskat
(100 × andel) − avdrag (50/missat tåg, 10/omskanning, 30/missnöjd).
- **Bana 003 Shibuya** (svårighet 3, fyra i sällskapet): biljettbana med
egna kort, sittplatsval, fullt tåg, fem stopp, 6 min tidsgräns, max ett
missat tåg. Bana 002 fick tidsgräns, maxantal missade tåg och
sittplatsval. Generatorn är nu spec-driven (JSON) så fler banor går fort.
- **Klar när:** poängformeln, tidsgränsen, debuffarna och de två
inställningarna är testade (`TestScoreBreakdown`,
`TestTimeLimitAndMissedTrainsLose`, `TestDebuffsAffectRunAndAreEarned`,
`TestIndividualCardsAtGateAndMachine`, `TestLevel003Flow`,
`TestSeatSelectionPreferences`); bana 003 använder dem. ✓
### M7b – Tåg som kommer och går (Björns tillägg 2026-10-24) ### M7b – Tåg som kommer och går (Björns tillägg 2026-10-24)
@@ -329,6 +360,23 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
- **Klar när:** sparfil round-trip-test grön; "< 33 % per omgång" går att - **Klar när:** sparfil round-trip-test grön; "< 33 % per omgång" går att
räkna ut från datan. räkna ut från datan.
### M9a – Flera våningar (Björns tillägg 2026-10-24)
- **Mål:** stationer i flera plan – ännu en svårighetsvariabel.
- Banformatet får **flera rutnät** (`floors`), ett per våning, med
**trappor** (och rulltrappor/hissar) som kopplar en ruta på en våning
till en ruta på en annan. Start kan ligga på en trappa upp eller ner:
under mark, ovan mark eller marknivå.
- Kamera, dimma, minikarta (en flik per våning eller stapel) och BFS-vägar
(`level.Path`, lösbarhetstestet, botarna) blir våningsmedvetna; skyltar
kan peka "↑ 2F / ↓ B1".
- **Teknik:** `level.Level.Floors []Grid` med `Stairs []Link`, `Point`
får `Z`; `sim.Mover` byter våning vid trappa; `tools/genart` får
trappruta; generatorn placerar trappor som flaskhalsar.
- **Klar när:** en bana med två våningar går att klara av boten headless,
minikartan visar rätt våning, och alla gamla banor fungerar oförändrat
(en våning).
### M9 – Navigeringslägen: lista och skyltläge ### M9 – Navigeringslägen: lista och skyltläge
- **Mål:** de två sätten att spela story. - **Mål:** de två sätten att spela story.
@@ -341,7 +389,9 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
### M10 – Story-innehåll och flerbytesbanor ### M10 – Story-innehåll och flerbytesbanor
- **Mål:** hela resan från flygplats till flygplats. - **Mål:** hela resan från flygplats till flygplats.
- Alla huvudbanor och sidobanor; banor med flera stopp och byten; buss. - Alla huvudbanor och sidobanor; banor med flera stopp och byten; buss;
stationer i flera våningar (M9a) och svårighetsinställningarna från M7
i stigande kombinationer.
- Slutanimation (karaktärerna flyger iväg, items och poäng visas) och - Slutanimation (karaktärerna flyger iväg, items och poäng visas) och
eftertexter. eftertexter.
- Överstimulerande UI/banor som extra svårighet. - Överstimulerande UI/banor som extra svårighet.

View File

@@ -78,6 +78,14 @@ otydligt står tolkningen i [§15](#15-tolkningar-av-originalet).
- **Tåg och bussar har animationer** när de kör in till och ut från sina - **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. stationer och hållplatser, i stället för att bara dyka upp och försvinna.
**Flera våningar** (tillägg 2026-10-24):
- Banor och stationer kan ha **flera våningar**. Trappor (och rulltrappor/
hissar) tar dig upp och ner mellan dem – ännu ett sätt att justera
svårigheten på en bana.
- En bana kan **börja med en trappa** upp eller ner, eftersom en station kan
ligga under mark, ovan mark eller i marknivå.
## 5. Sällskapet – lämmeltåget ## 5. Sällskapet – lämmeltåget
- Dina polare följer efter dig i en rad – därav *lämmeltåget*. - Dina polare följer efter dig i en rad – därav *lämmeltåget*.
@@ -136,6 +144,11 @@ otydligt står tolkningen i [§15](#15-tolkningar-av-originalet).
påfyllning. påfyllning.
- Telefonen har **batteri**. Dör det måste du köpa biljett i stället. Detta - Telefonen har **batteri**. Dör det måste du köpa biljett i stället. Detta
ska nästan bara kunna hända på **långa banor**. ska nästan bara kunna hända på **långa banor**.
- **Sittplatser** (tillägg 2026-10-24): som svårighet i biljettminispelet
kan sällskapet ha önskemål om platser – någon vill sitta **bredvid en
viss person**, någon vill sitta vid **gången eller fönstret**, någon på
**höger eller vänster sida** av tåget. Du måste placera alla så att det
stämmer.
## 9. Svårighet och tid ## 9. Svårighet och tid
@@ -145,7 +158,12 @@ otydligt står tolkningen i [§15](#15-tolkningar-av-originalet).
- **Extra svårigheter:** överstimulerande UI och banor byggda för att förvirra - **Extra svårigheter:** överstimulerande UI och banor byggda för att förvirra
spelaren räknas som egna, extra svårigheter. spelaren räknas som egna, extra svårigheter.
- Variabler som styr svårighet: antal i sällskapet, andel engelska på - Variabler som styr svårighet: antal i sällskapet, andel engelska på
skyltar, kort-/biljettkrav, tidsgräns, antal byten, UI-störningar. skyltar, kort-/biljettkrav, tidsgräns, antal byten, UI-störningar, antal
våningar, egna kort, sittplatsönskemål.
- **Egna kort** (tillägg 2026-10-24): som svårighetsinställning har varje
person i sällskapet **sitt eget tågkort**. Alla måste ha pengar på kortet
för att komma igenom spärren – och på biljettbanor måste alla ha en
biljett.
## 10. Poäng ## 10. Poäng

View File

@@ -32,6 +32,7 @@ type Game struct {
Assets *Assets Assets *Assets
Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8) Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8)
Wallet *sim.Wallet // pengar, kort, telefon, biljett – följer med mellan banor Wallet *sim.Wallet // pengar, kort, telefon, biljett – följer med mellan banor
Profile *sim.Profile // aktiva debuffar – följer med mellan banor
fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar) fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar)
scene Scene scene Scene
next Scene next Scene
@@ -49,6 +50,7 @@ type Options struct {
Points int // språkpoäng från start (för tester/skärmdumpar) Points int // språkpoäng från start (för tester/skärmdumpar)
Seed uint64 // >0: fast seed för banorna (för tester/skärmdumpar) Seed uint64 // >0: fast seed för banorna (för tester/skärmdumpar)
Phone bool // kortet i telefonen från start Phone bool // kortet i telefonen från start
Debuffs []string // debuffar från start (för tester/skärmdumpar)
CardYen int // >=0: kortets startsaldo (för tester/skärmdumpar), -1 = standard CardYen int // >=0: kortets startsaldo (för tester/skärmdumpar), -1 = standard
} }
@@ -65,6 +67,10 @@ func New(opt Options) (*Game, error) {
g.Know.Points = opt.Points g.Know.Points = opt.Points
g.fixedSeed = opt.Seed g.fixedSeed = opt.Seed
g.Wallet = sim.NewWallet() g.Wallet = sim.NewWallet()
g.Profile = &sim.Profile{}
for _, d := range opt.Debuffs {
g.Profile.Add(sim.DebuffKind(d), 1)
}
g.Wallet.Phone = opt.Phone g.Wallet.Phone = opt.Phone
if opt.CardYen >= 0 { if opt.CardYen >= 0 {
g.Wallet.Card = opt.CardYen g.Wallet.Card = opt.CardYen
@@ -75,7 +81,7 @@ func New(opt Options) (*Game, error) {
} }
} }
if opt.LevelID != "" { if opt.LevelID != "" {
s, err := NewLevelScene(a, opt.LevelID, g.Know, g.Wallet, g.seed()) s, err := NewLevelScene(a, opt.LevelID, g, g.seed())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -127,3 +133,12 @@ func (g *Game) seed() rng.Seed {
} }
return rng.NewSeed() return rng.NewSeed()
} }
// levelEnded appliceras exakt en gång när en bana avgjorts: banans nya
// debuffar läggs på profilen och alla räknas ned en bana.
func (g *Game) levelEnded(run *sim.Run) {
for _, d := range run.NewDebuffs {
g.Profile.Add(d.Kind, d.LevelsLeft)
}
g.Profile.EndLevel()
}

View File

@@ -81,19 +81,21 @@ func wordsJustPressed() bool {
return virtual.pressed["l"] || inpututil.IsKeyJustPressed(ebiten.KeyL) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftRight) return virtual.pressed["l"] || inpututil.IsKeyJustPressed(ebiten.KeyL) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftRight)
} }
// miniMove: -1/+1 vid just-tryck på pil/WASD (upp/vänster = -1, ner/höger = +1). // miniMoveXY: just-tryck på pil/WASD/d-pad som (x, y) i -1/0/+1.
func miniMove() int { func miniMoveXY() (x, y int) {
switch { switch {
case virtual.pressed["up"] || virtual.pressed["left"] || inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyArrowLeft) || case virtual.pressed["left"] || inpututil.IsKeyJustPressed(ebiten.KeyArrowLeft) || inpututil.IsKeyJustPressed(ebiten.KeyA) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftLeft):
inpututil.IsKeyJustPressed(ebiten.KeyW) || inpututil.IsKeyJustPressed(ebiten.KeyA) || x = -1
gamepadJustPressed(ebiten.StandardGamepadButtonLeftTop) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftLeft): case virtual.pressed["right"] || inpututil.IsKeyJustPressed(ebiten.KeyArrowRight) || inpututil.IsKeyJustPressed(ebiten.KeyD) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftRight):
return -1 x = 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 switch {
case virtual.pressed["up"] || inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyW) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftTop):
y = -1
case virtual.pressed["down"] || inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) || inpututil.IsKeyJustPressed(ebiten.KeyS) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftBottom):
y = 1
}
return x, y
} }
// confirmHeld: Enter/mellanslag eller A hålls nere (sedelinmatning). // confirmHeld: Enter/mellanslag eller A hålls nere (sedelinmatning).

View File

@@ -45,6 +45,21 @@ func (s *LevelScene) drawTopUp(a *Assets, screen *ebiten.Image, m *minigame.TopU
a.Draw(screen, a.T("mini.topup.title"), x+60, y+2, colText) a.Draw(screen, a.T("mini.topup.title"), x+60, y+2, colText)
y += 20 y += 20
switch m.Stage { switch m.Stage {
case minigame.TopUpPickMember:
a.Draw(screen, a.T("mini.topup.member"), x, y, colDim)
y += LineH + 4
for i, name := range m.Members {
col := colText
if i == m.Cursor {
vector.DrawFilledRect(screen, float32(x-2), float32(y+i*LineH-1), 200, LineH, colBar, false)
col = colKnown
}
bal := s.run.Wallet.Card
if i > 0 {
bal = s.run.Party.Members[i].Card
}
a.Draw(screen, fmt.Sprintf("%-8s %5d¥", name, bal), x, y+i*LineH, col)
}
case minigame.TopUpPickButton: case minigame.TopUpPickButton:
a.Draw(screen, a.T("mini.topup.pick"), x, y, colDim) a.Draw(screen, a.T("mini.topup.pick"), x, y, colDim)
y += LineH + 4 y += LineH + 4
@@ -85,6 +100,10 @@ func (s *LevelScene) drawTicket(a *Assets, screen *ebiten.Image, m *minigame.Tic
a.DrawBig(screen, "きっぷ", x, y, colKnown) a.DrawBig(screen, "きっぷ", x, y, colKnown)
a.Draw(screen, a.T("mini.ticket.title"), x+48, y+2, colText) a.Draw(screen, a.T("mini.ticket.title"), x+48, y+2, colText)
y += 20 y += 20
if m.Stage == minigame.TicketPickSeat {
s.drawSeats(a, screen, m, p, x, y)
return
}
a.Draw(screen, fmt.Sprintf(a.T("mini.ticket.hint"), s.run.Level.Destination.Get("romaji"), m.Cash), x, y, colDim) a.Draw(screen, fmt.Sprintf(a.T("mini.ticket.hint"), s.run.Level.Destination.Get("romaji"), m.Cash), x, y, colDim)
y += LineH + 4 y += LineH + 4
for i, o := range m.Options { for i, o := range m.Options {
@@ -154,3 +173,64 @@ func (s *LevelScene) drawRide(a *Assets, screen *ebiten.Image, m *minigame.Ride,
} }
a.Draw(screen, hint, x, p.Max.Y-16, colGood) a.Draw(screen, hint, x, p.Max.Y-16, colGood)
} }
// prefText beskriver ett önskemål.
func prefText(a *Assets, m *minigame.Ticket, p minigame.SeatPref) string {
switch p.Kind {
case minigame.PrefWindow:
return a.T("seat.window")
case minigame.PrefAisle:
return a.T("seat.aisle")
case minigame.PrefLeft:
return a.T("seat.left")
case minigame.PrefRight:
return a.T("seat.right")
default:
return fmt.Sprintf(a.T("seat.with"), m.Members[p.With])
}
}
// drawSeats ritar vagnen: tre rader, fönster V | gång | fönster H, och
// sällskapets önskemål till höger.
func (s *LevelScene) drawSeats(a *Assets, screen *ebiten.Image, m *minigame.Ticket, p image.Rectangle, x, y int) {
a.Draw(screen, fmt.Sprintf(a.T("mini.seat.hint"), m.Members[m.Current]), x, y, colDim)
y += LineH + 4
const sw, sh, gap = 22, 18, 10
a.Draw(screen, a.T("seat.front"), x+2, y-2, colDim)
y += 10
for row := 0; row < minigame.SeatRows; row++ {
for col := 0; col < minigame.SeatCols; col++ {
sx := x + col*(sw+2)
if col >= 2 {
sx += gap
}
sy := y + row*(sh+3)
seat := minigame.Seat{Row: row, Col: col}
bg := colBar
if m.SeatTaken(seat) {
bg = color.RGBA{90, 105, 136, 255}
}
vector.DrawFilledRect(screen, float32(sx), float32(sy), sw, sh, bg, false)
if seat == m.Seat {
vector.DrawFilledRect(screen, float32(sx), float32(sy), sw, 2, colKnown, false)
vector.DrawFilledRect(screen, float32(sx), float32(sy+sh-2), sw, 2, colKnown, false)
}
for i, as := range m.Assigned {
if as == seat {
a.Draw(screen, string([]rune(m.Members[i])[:1]), sx+8, sy+3, colText)
}
}
}
}
// Önskemål.
px := x + 4*(sw+2) + gap + 14
a.Draw(screen, a.T("mini.seat.prefs"), px, y-10, colDim)
for i, pr := range m.Prefs {
col := colText
if pr.Member == m.Current {
col = colKnown
}
a.Draw(screen, m.Members[pr.Member]+": "+prefText(a, m, pr), px, y+i*LineH, col)
}
a.Draw(screen, a.T("mini.cancel"), x, p.Max.Y-16, colDim)
}

View File

@@ -36,16 +36,16 @@ type LevelScene struct {
hideMap bool // M döljer/visar minikartan hideMap bool // M döljer/visar minikartan
} }
// NewLevelScene laddar banan och ställer spelaren på startrutan. know är // NewLevelScene laddar banan och ställer spelaren på startrutan. Kunskap,
// spelarens japanska, som delas mellan banor; seed styr omgångens slump. // plånbok och debuffar tas från Game (delas mellan banor); seed styr slumpen.
func NewLevelScene(a *Assets, id string, know *lang.Knowledge, wallet *sim.Wallet, seed rng.Seed) (*LevelScene, error) { func NewLevelScene(a *Assets, id string, g *Game, seed rng.Seed) (*LevelScene, error) {
lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json")) lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json"))
if err != nil { if err != nil {
return nil, err return nil, err
} }
s := &LevelScene{ s := &LevelScene{
id: id, id: id,
run: sim.NewRun(lvl, sim.RunOptions{Dict: a.Dict, Know: know, Wallet: wallet, Seed: seed}), run: sim.NewRun(lvl, sim.RunOptions{Dict: a.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed}),
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)), cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
} }
s.followPlayer() s.followPlayer()
@@ -79,8 +79,10 @@ func (s *LevelScene) followPlayer() {
func (s *LevelScene) Update(g *Game) error { func (s *LevelScene) Update(g *Game) error {
if s.run.Mini != nil { if s.run.Mini != nil {
// Minispel: all input går dit; Esc avbryter minispelet, inte banan. // 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()}) mx, my := miniMoveXY()
s.run.StepMini(dt, minigame.Input{Move: my, MoveX: mx, Confirm: confirmJustPressed(), Hold: confirmHeld(), Cancel: backJustPressed()})
if s.run.Outcome != sim.Ongoing { if s.run.Outcome != sim.Ongoing {
g.levelEnded(s.run)
g.SetScene(NewResultScene(s)) g.SetScene(NewResultScene(s))
} }
return nil return nil
@@ -107,11 +109,43 @@ func (s *LevelScene) Update(g *Game) error {
} }
s.followPlayer() s.followPlayer()
if s.run.Outcome != sim.Ongoing { if s.run.Outcome != sim.Ongoing {
g.levelEnded(s.run)
g.SetScene(NewResultScene(s)) g.SetScene(NewResultScene(s))
} }
return nil return nil
} }
// MiniPick flyttar markören i det pågående minispelet ett steg mot
// alternativet med texten text; true när det är valt (skriptläget).
func (s *LevelScene) MiniPick(text string) bool {
switch m := s.run.Mini.(type) {
case *minigame.Ticket:
if m.Stage == minigame.TicketPickDest {
if m.Options[m.Cursor].Dest == text {
return true
}
s.run.StepMini(0, minigame.Input{Move: 1})
return false
}
case *minigame.TopUp:
switch m.Stage {
case minigame.TopUpPickMember:
if m.Members[m.Cursor] == text {
return true
}
s.run.StepMini(0, minigame.Input{Move: 1})
return false
case minigame.TopUpPickButton:
if m.Buttons[m.Cursor] == text {
return true
}
s.run.StepMini(0, minigame.Input{MoveX: 1})
return false
}
}
return true
}
// trainTile är rutan "bortom perrongkanten" där tågvagnen ritas: första // trainTile är rutan "bortom perrongkanten" där tågvagnen ritas: första
// väggruta nedanför, till höger, till vänster eller ovanför perrongen. // väggruta nedanför, till höger, till vänster eller ovanför perrongen.
func (s *LevelScene) trainTile(p level.Point) level.Point { func (s *LevelScene) trainTile(p level.Point) level.Point {
@@ -243,14 +277,26 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
a.Draw(screen, a.T("hud.destination")+": "+dest, destX, 0, colKnown) 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()), 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) 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) timeStr := fmt.Sprintf("%s %3.0fs", a.T("hud.time"), r.Time)
if r.Level.TimeLimitSec > 0 {
left := r.TimeLeft()
timeStr = fmt.Sprintf("%s %d:%02d", a.T("hud.time_left"), int(left)/60, int(left)%60)
}
wallet := fmt.Sprintf("%s %s %d¥ %s %d¥", timeStr, a.T("hud.card"), r.Wallet.Card, a.T("hud.cash_short"), r.Wallet.Cash)
if r.Level.MaxMissedTrains > 0 {
wallet += fmt.Sprintf(" %s %d/%d", a.T("hud.missed_short"), r.MissedTrains, r.Level.MaxMissedTrains)
}
if r.Wallet.Phone { if r.Wallet.Phone {
wallet += fmt.Sprintf(" %s %.0f%%", a.T("hud.phone"), r.Wallet.Battery) wallet += fmt.Sprintf(" %s %.0f%%", a.T("hud.phone"), r.Wallet.Battery)
} }
if r.Wallet.Ticket != "" { if r.Wallet.Ticket != "" {
wallet += " " + a.T("hud.ticket") + " " + r.Wallet.Ticket wallet += " " + a.T("hud.ticket") + " " + r.Wallet.Ticket
} }
a.Draw(screen, wallet, 4, LineH, colDim) walletCol := colDim
if r.Level.TimeLimitSec > 0 && r.TimeLeft() < 30 {
walletCol = colBad
}
a.Draw(screen, wallet, 4, LineH, walletCol)
s.drawMeters(g, screen) s.drawMeters(g, screen)
if r.Outcome != sim.Ongoing || r.Mini != nil { if r.Outcome != sim.Ongoing || r.Mini != nil {
@@ -278,6 +324,13 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
} }
lines = append(lines, a.wrapLine(plainLine(a.T(r.FlashKey), col), ScreenW-8)...) lines = append(lines, a.wrapLine(plainLine(a.T(r.FlashKey), col), ScreenW-8)...)
} }
if debuffs := g.Profile.Debuffs; len(debuffs) > 0 && r.Time < 6 {
var parts []string
for _, d := range debuffs {
parts = append(parts, fmt.Sprintf("%s (%d)", a.T("debuff."+string(d.Kind)), d.LevelsLeft))
}
lines = append(lines, a.wrapLine(plainLine(a.T("hud.debuffs")+": "+strings.Join(parts, ", "), colBad), ScreenW-8)...)
}
// Inforutans läge: nederkant, men flyttas upp under HUD:en om spelaren // Inforutans läge: nederkant, men flyttas upp under HUD:en om spelaren
// (eller tåget under hen) annars skulle hamna bakom den. // (eller tåget under hen) annars skulle hamna bakom den.
@@ -390,10 +443,18 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec { if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec {
key := "hud.gate_denied" key := "hud.gate_denied"
switch { switch {
case lvl.TicketRequired: case lvl.TicketRequired && r.Wallet.Ticket == "":
key = sim.FlashNoTicket key = sim.FlashNoTicket
case lvl.TicketRequired:
key = "hud.need_all_tickets"
case r.Wallet.Phone && r.Wallet.Battery <= 0: case r.Wallet.Phone && r.Wallet.Battery <= 0:
key = "hud.phone_dead" key = "hud.phone_dead"
case len(r.MembersWithoutFare()) > 0:
var names []string
for _, m := range r.MembersWithoutFare() {
names = append(names, m.Name)
}
return []line{plainLine(fmt.Sprintf(a.T(sim.FlashNoCards), strings.Join(names, ", ")), colBad)}, colBad, false
} }
return []line{plainLine(a.T(key), colBad)}, colBad, false return []line{plainLine(a.T(key), colBad)}, colBad, false
} }
@@ -403,11 +464,11 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
// drawMeters ritar sällskapets mat/dryck/toa-mätare uppe till höger. // drawMeters ritar sällskapets mat/dryck/toa-mätare uppe till höger.
func (s *LevelScene) drawMeters(g *Game, screen *ebiten.Image) { func (s *LevelScene) drawMeters(g *Game, screen *ebiten.Image) {
members := s.run.Party.Members members := s.run.Party.Members
const w, gap = 14, 4 const w, gap = 11, 3
x0 := ScreenW - 4 - len(members)*(w+gap) x0 := ScreenW - 4 - len(members)*(w+gap)
for i, m := range members { for i, m := range members {
x := float32(x0 + i*(w+gap)) x := float32(x0 + i*(w+gap))
g.Assets.Draw(screen, string([]rune(m.Name)[:1]), int(x)+3, 0, colDim) g.Assets.Draw(screen, string([]rune(m.Name)[:1]), int(x)+2, 0, colDim)
for j, v := range []struct { for j, v := range []struct {
val float64 val float64
col color.Color col color.Color

View File

@@ -64,7 +64,7 @@ func (m *MenuScene) Update(g *Game) error {
if n := len(m.entries); n > 0 { if n := len(m.entries); n > 0 {
m.sel = (m.sel + menuMove() + n) % n m.sel = (m.sel + menuMove() + n) % n
if confirmJustPressed() { if confirmJustPressed() {
s, err := NewLevelScene(g.Assets, m.entries[m.sel].id, g.Know, g.Wallet, g.seed()) s, err := NewLevelScene(g.Assets, m.entries[m.sel].id, g, g.seed())
if err != nil { if err != nil {
m.err = err m.err = err
return nil return nil
@@ -100,6 +100,13 @@ func (m *MenuScene) Draw(g *Game, screen *ebiten.Image) {
} }
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.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.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)
if len(g.Profile.Debuffs) > 0 {
var parts []string
for _, d := range g.Profile.Debuffs {
parts = append(parts, fmt.Sprintf("%s (%d)", a.T("debuff."+string(d.Kind)), d.LevelsLeft))
}
a.Draw(screen, a.T("hud.debuffs")+": "+strings.Join(parts, ", "), 8, ScreenH-50, colBad)
}
a.Draw(screen, a.T("menu.hint"), 8, ScreenH-26, colDim) a.Draw(screen, a.T("menu.hint"), 8, ScreenH-26, colDim)
a.Draw(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-13, colDim) a.Draw(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-13, colDim)
} }

View File

@@ -23,7 +23,7 @@ func (r *ResultScene) Update(g *Game) error {
case wordsJustPressed(): case wordsJustPressed():
g.SetScene(NewWordsScene(g.Assets, r)) g.SetScene(NewWordsScene(g.Assets, r))
case inpututil_R(): case inpututil_R():
s, err := NewLevelScene(g.Assets, r.from.id, g.Know, g.Wallet, g.seed()) s, err := NewLevelScene(g.Assets, r.from.id, g, g.seed())
if err != nil { if err != nil {
return err return err
} }
@@ -42,26 +42,28 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
r.from.Draw(g, screen) // banan i bakgrunden r.from.Draw(g, screen) // banan i bakgrunden
run := r.from.run run := r.from.run
a := g.Assets a := g.Assets
vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 120, colPanel, false) vector.DrawFilledRect(screen, 20, 30, ScreenW-40, 138, colPanel, false)
accent, headline := colBad, a.T("level.lost") accent, headline := colBad, a.T("level.lost")
if run.Outcome == sim.Won { if run.Outcome == sim.Won {
accent, headline = colGood, a.T("level.cleared") accent, headline = colGood, a.T("level.cleared")
} }
vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 3, accent, false) vector.DrawFilledRect(screen, 20, 30, ScreenW-40, 3, accent, false)
a.DrawBig(screen, headline, 28, 44, accent) a.DrawBig(screen, headline, 28, 36, accent)
if run.Outcome == sim.Won { if run.Outcome == sim.Won {
stars := strings.Repeat("★", run.Stars) + strings.Repeat("☆", 3-run.Stars) stars := strings.Repeat("★", run.Stars) + strings.Repeat("☆", 3-run.Stars)
a.DrawBig(screen, stars, ScreenW-28-a.Width(stars)-8, 44, colWarn) a.DrawBig(screen, stars, ScreenW-28-a.Width(stars)-8, 36, colWarn)
} }
y := 62 y := 54
if run.Boarded == nil && run.Outcome == sim.Lost {
a.Draw(screen, a.T(run.LostReason), 28, y, colText)
y += LineH
}
if tr := run.Boarded; tr != nil { if tr := run.Boarded; tr != nil {
a.Draw(screen, fmt.Sprintf("%s%s %s %s%s", tr.Track, a.T("hud.track_suffix_ja"), tr.Line.Get("ja"), a.Draw(screen, fmt.Sprintf("%s%s %s %s%s", tr.Track, a.T("hud.track_suffix_ja"), tr.Line.Get("ja"),
tr.Destination.Get("ja"), a.T("hud.for_ja")), 28, y, colKnown) tr.Destination.Get("ja"), a.T("hud.for_ja")), 28, y, colKnown)
y += LineH y += LineH
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 { if run.Outcome == sim.Lost {
reason := a.T(run.LostReason) reason := a.T(run.LostReason)
if run.LostReason == sim.LostWrongTrain { if run.LostReason == sim.LostWrongTrain {
@@ -77,18 +79,32 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
a.Draw(screen, fmt.Sprintf("%s %.1fs (%s %.0fs) %s %d %s %.0f %%", a.T("hud.time"), run.Time, a.T("result.par"), run.ParSec(), a.Draw(screen, fmt.Sprintf("%s %.1fs (%s %.0fs) %s %d %s %.0f %%", a.T("hud.time"), run.Time, a.T("result.par"), run.ParSec(),
a.T("hud.steps"), run.Steps, a.T("result.explored"), 100*run.Fog.Explored(run.Level)), 28, y, colText) a.T("hud.steps"), run.Steps, a.T("result.explored"), 100*run.Fog.Explored(run.Level)), 28, y, colText)
y += LineH y += LineH
a.Draw(screen, fmt.Sprintf("%s %d %s %d¥ %s %d¥ %s %d %s %d", a.T("result.gates"), run.GatePasses, a.T("result.card"), run.Wallet.Card, a.Draw(screen, fmt.Sprintf("%s %d %s %d¥ %s %d¥ %s %d", a.T("result.gates"), run.GatePasses, a.T("hud.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) a.T("result.spent"), run.Wallet.Spent, a.T("result.missed_short"), run.MissedTrains), 28, y, colText)
y += LineH y += LineH
if run.Outcome == sim.Won { if run.Outcome == sim.Won {
msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("words.points")) sc := run.Score
a.Draw(screen, fmt.Sprintf("%s %d", a.T("result.score"), sc.Total), 28, y, colText)
y += LineH
a.Draw(screen, fmt.Sprintf("%s %d %s %d %s %d %s %d %s %d", strings.ToLower(a.T("hud.time")), sc.Time, a.T("result.money"), sc.Money,
a.T("result.meters"), sc.Meters, a.T("result.map"), sc.Explore, a.T("result.penalty"), sc.Penalty), 28, y, colDim)
y += LineH
msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("result.lang_points"))
if len(run.LearnedDest) > 0 { if len(run.LearnedDest) > 0 {
msg += " " + a.T("hud.learned") + ": " + strings.Join(run.LearnedDest, " ") + " (" + a.T("result.destination") + ")" msg += " " + a.T("hud.learned") + ": " + strings.Join(run.LearnedDest, " ") + " (" + a.T("result.destination") + ")"
} }
a.Draw(screen, msg, 28, y, colKnown) a.Draw(screen, msg, 28, y, colKnown)
y += LineH y += LineH
} }
if len(run.NewDebuffs) > 0 {
var parts []string
for _, d := range run.NewDebuffs {
parts = append(parts, fmt.Sprintf("%s (%d)", a.T("debuff."+string(d.Kind)), d.LevelsLeft))
}
a.Draw(screen, a.T("result.new_debuffs")+": "+strings.Join(parts, ", "), 28, y, colBad)
y += LineH
}
a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d (%d %s)", a.T("hud.japanese"), run.Know.Level(), run.Know.Count(), a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d (%d %s)", a.T("hud.japanese"), run.Know.Level(), run.Know.Count(),
a.T("hud.words"), a.T("words.points"), run.Know.Points, run.Know.Helps, a.T("result.helps")), 28, y, colDim) a.T("hud.words"), a.T("words.points"), run.Know.Points, run.Know.Helps, a.T("result.helps")), 28, y, colDim)
a.Draw(screen, a.T("result.hint"), 28, 141, colDim) a.Draw(screen, a.T("result.hint"), 28, 155, colDim)
} }

View File

@@ -22,6 +22,7 @@ import (
// hold N håll Enter nere N bildrutor (sedelinmatning) // hold N håll Enter nere N bildrutor (sedelinmatning)
// waittrain vänta tills tåget vid perrongen spelaren står på är inne // waittrain vänta tills tåget vid perrongen spelaren står på är inne
// waitstop vänta tills tågresan står vid resmålet // waitstop vänta tills tågresan står vid resmålet
// pick TEXT flytta markören i minispelet tills alternativet TEXT är valt
// press KEY tryck enter | esc | r | h | l | up | down en bildruta // press KEY tryck enter | esc | r | h | l | up | down en bildruta
// move DIR N gå N rutor åt up | down | left | right (stannar exakt där) // move DIR N gå N rutor åt up | down | left | right (stannar exakt där)
// shot PATH spara aktuell bild som PNG (intern upplösning × shotScale) // shot PATH spara aktuell bild som PNG (intern upplösning × shotScale)
@@ -86,9 +87,9 @@ func parseScript(src string) (*script, error) {
return nil, fmt.Errorf("script: okänd riktning %q", f[1]) return nil, fmt.Errorf("script: okänd riktning %q", f[1])
} }
st.n, _ = strconv.Atoi(f[2]) st.n, _ = strconv.Atoi(f[2])
case "shot": case "shot", "pick":
if len(f) != 2 { if len(f) != 2 {
return nil, fmt.Errorf("script: 'shot PATH'") return nil, fmt.Errorf("script: '%s TEXT'", f[0])
} }
st.arg = f[1] st.arg = f[1]
case "quit", "waittrain", "waitstop": case "quit", "waittrain", "waitstop":
@@ -124,6 +125,7 @@ type playerTiler interface {
type trainWaiter interface { type trainWaiter interface {
TrainHere() bool TrainHere() bool
RideAtDest() bool RideAtDest() bool
MiniPick(text string) bool
} }
// tick körs i början av varje Update. frame är senast ritade bild. // tick körs i början av varje Update. frame är senast ritade bild.
@@ -191,6 +193,11 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error {
if tw, ok := g.scene.(trainWaiter); !ok || tw.RideAtDest() { if tw, ok := g.scene.(trainWaiter); !ok || tw.RideAtDest() {
s.advance() s.advance()
} }
case "pick":
s.counter++
if tw, ok := g.scene.(trainWaiter); !ok || tw.MiniPick(st.arg) || s.counter > 60 {
s.advance()
}
case "shot": case "shot":
if err := saveShot(frame, st.arg); err != nil { if err := saveShot(frame, st.arg); err != nil {
return err return err

View File

@@ -117,6 +117,9 @@ type Level struct {
Party int `json:"party"` // antal i sällskapet inkl. spelaren (0 = 1) Party int `json:"party"` // antal i sällskapet inkl. spelaren (0 = 1)
TicketRequired bool `json:"ticketRequired"` // spärren kräver pappersbiljett (köps i automaten) i stället för kort 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) RideStops int `json:"rideStops"` // antal stationer på tågresan (0 = DefaultRideStops)
MaxMissedTrains int `json:"maxMissedTrains"` // >0: fler missade tåg än så förlorar banan
IndividualCards bool `json:"individualCards"` // alla i sällskapet har egna kort (och biljetter) som spärren kräver
SeatSelection bool `json:"seatSelection"` // biljettköpet fortsätter med sittplatsval efter sällskapets önskemål
FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard) FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard)
Grid []string `json:"grid"` Grid []string `json:"grid"`
Trains []Train `json:"trains"` Trains []Train `json:"trains"`
@@ -253,6 +256,12 @@ func (l *Level) Validate() error {
if l.TicketRequired && len(l.Find(TicketMachine)) == 0 { if l.TicketRequired && len(l.Find(TicketMachine)) == 0 {
return fmt.Errorf("level %s: biljettkrav utan biljettautomat (T)", l.ID) return fmt.Errorf("level %s: biljettkrav utan biljettautomat (T)", l.ID)
} }
if l.SeatSelection && !l.TicketRequired {
return fmt.Errorf("level %s: sittplatsval kräver biljettbana", l.ID)
}
if l.MaxMissedTrains < 0 || l.TimeLimitSec < 0 {
return fmt.Errorf("level %s: negativ tidsgräns/maxantal missade tåg", l.ID)
}
if l.Party > MaxParty { if l.Party > MaxParty {
return fmt.Errorf("level %s: sällskapet får vara högst %d", l.ID, MaxParty) return fmt.Errorf("level %s: sällskapet får vara högst %d", l.ID, MaxParty)
} }

View File

@@ -306,3 +306,18 @@ func TestTicketAndRideFields(t *testing.T) {
t.Fatal("fälten ska läsas") t.Fatal("fälten ska läsas")
} }
} }
func TestDifficultySettings(t *testing.T) {
if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "seatSelection": true`, 1))); err == nil {
t.Fatal("sittplatsval utan biljettbana ska ge fel")
}
if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "maxMissedTrains": -1`, 1))); err == nil {
t.Fatal("negativt maxantal ska ge fel")
}
src := strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#ST.#P#"`, 1), `"difficulty": 1`,
`"difficulty": 3, "ticketRequired": true, "seatSelection": true, "individualCards": true, "maxMissedTrains": 2, "timeLimitSec": 300`, 1)
l := mustParse(t, src)
if !l.SeatSelection || !l.IndividualCards || l.MaxMissedTrains != 2 || l.TimeLimitSec != 300 {
t.Fatal("inställningarna ska läsas")
}
}

View File

@@ -12,7 +12,8 @@ import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
// Input är vad spelaren gjorde den här bildrutan. // Input är vad spelaren gjorde den här bildrutan.
type Input struct { type Input struct {
Move int // -1 / +1 (upp/vänster resp. ner/höger), 0 = inget Move int // -1 / +1 (upp resp. ner), 0 = inget
MoveX int // -1 / +1 (vänster resp. höger), 0 = inget
Confirm bool // Enter tryckt (just nu) Confirm bool // Enter tryckt (just nu)
Hold bool // Enter hålls nere Hold bool // Enter hålls nere
Cancel bool // Esc tryckt Cancel bool // Esc tryckt

View File

@@ -11,13 +11,13 @@ import (
const frame = 1.0 / 60 const frame = 1.0 / 60
func TestTopUpFlowAndDeterminism(t *testing.T) { func TestTopUpFlowAndDeterminism(t *testing.T) {
g := NewTopUp(rng.FromInt(1), 5000) g := NewTopUp(rng.FromInt(1), 5000, nil)
h := NewTopUp(rng.FromInt(1), 5000) h := NewTopUp(rng.FromInt(1), 5000, nil)
if strings.Join(g.Buttons, ",") != strings.Join(h.Buttons, ",") { if strings.Join(g.Buttons, ",") != strings.Join(h.Buttons, ",") {
t.Fatal("samma seed ska ge samma knappordning") t.Fatal("samma seed ska ge samma knappordning")
} }
if strings.Join(g.Buttons, ",") == strings.Join(NewTopUp(rng.FromInt(2), 5000).Buttons, ",") && if strings.Join(g.Buttons, ",") == strings.Join(NewTopUp(rng.FromInt(2), 5000, nil).Buttons, ",") &&
strings.Join(g.Buttons, ",") == strings.Join(NewTopUp(rng.FromInt(3), 5000).Buttons, ",") { strings.Join(g.Buttons, ",") == strings.Join(NewTopUp(rng.FromInt(3), 5000, nil).Buttons, ",") {
t.Fatal("olika seeds ska (nästan alltid) ge olika ordning") t.Fatal("olika seeds ska (nästan alltid) ge olika ordning")
} }
// Fel knapp: straff och frysning. // Fel knapp: straff och frysning.
@@ -37,7 +37,7 @@ func TestTopUpFlowAndDeterminism(t *testing.T) {
} }
// Rätt knapp -> belopp -> mata sedlar. // Rätt knapp -> belopp -> mata sedlar.
for g.Buttons[g.Cursor] != "チャージ" { for g.Buttons[g.Cursor] != "チャージ" {
g.Update(Input{Move: 1}, frame) g.Update(Input{MoveX: 1}, frame)
} }
g.Update(Input{Confirm: true}, frame) g.Update(Input{Confirm: true}, frame)
if g.Stage != TopUpPickAmount { if g.Stage != TopUpPickAmount {
@@ -65,14 +65,14 @@ func TestTopUpFlowAndDeterminism(t *testing.T) {
t.Fatalf("done=%v success=%v amount=%d", g.Done(), g.Success(), g.Amount) t.Fatalf("done=%v success=%v amount=%d", g.Done(), g.Success(), g.Amount)
} }
// Kontanter begränsar beloppen; utan pengar är spelet över direkt. // Kontanter begränsar beloppen; utan pengar är spelet över direkt.
if a := NewTopUp(rng.FromInt(1), 2500).Amounts; len(a) != 2 || a[1] != 2000 { if a := NewTopUp(rng.FromInt(1), 2500, nil).Amounts; len(a) != 2 || a[1] != 2000 {
t.Fatalf("amounts med 2500 kr: %v", a) t.Fatalf("amounts med 2500 kr: %v", a)
} }
if !NewTopUp(rng.FromInt(1), 500).Done() { if !NewTopUp(rng.FromInt(1), 500, nil).Done() {
t.Fatal("utan råd ska automaten vara klar direkt") t.Fatal("utan råd ska automaten vara klar direkt")
} }
// Esc avbryter utan köp. // Esc avbryter utan köp.
c := NewTopUp(rng.FromInt(1), 5000) c := NewTopUp(rng.FromInt(1), 5000, nil)
c.Update(Input{Cancel: true}, frame) c.Update(Input{Cancel: true}, frame)
if !c.Done() || c.Success() { if !c.Done() || c.Success() {
t.Fatal("avbrutet = klart utan framgång") t.Fatal("avbrutet = klart utan framgång")
@@ -80,8 +80,8 @@ func TestTopUpFlowAndDeterminism(t *testing.T) {
} }
func TestTicketBoardSeededAndAffordable(t *testing.T) { func TestTicketBoardSeededAndAffordable(t *testing.T) {
g := NewTicket(rng.FromInt(4), "渋谷", []string{"東京", "上野", "新宿"}, 5000) g := NewTicket(rng.FromInt(4), "渋谷", []string{"東京", "上野", "新宿"}, 5000, nil)
h := NewTicket(rng.FromInt(4), "渋谷", []string{"東京", "上野", "新宿"}, 5000) h := NewTicket(rng.FromInt(4), "渋谷", []string{"東京", "上野", "新宿"}, 5000, nil)
if len(g.Options) != 4 { if len(g.Options) != 4 {
t.Fatalf("%d alternativ", len(g.Options)) t.Fatalf("%d alternativ", len(g.Options))
} }
@@ -102,7 +102,7 @@ func TestTicketBoardSeededAndAffordable(t *testing.T) {
t.Fatalf("köp: %+v", g.Chosen) t.Fatalf("köp: %+v", g.Chosen)
} }
// Utan råd går det inte att köpa. // Utan råd går det inte att köpa.
p := NewTicket(rng.FromInt(4), "渋谷", []string{"東京"}, 100) p := NewTicket(rng.FromInt(4), "渋谷", []string{"東京"}, 100, nil)
p.Update(Input{Confirm: true}, frame) p.Update(Input{Confirm: true}, frame)
if p.Done() { if p.Done() {
t.Fatal("utan råd ska köpet inte gå igenom") t.Fatal("utan råd ska köpet inte gå igenom")
@@ -204,3 +204,92 @@ func TestRideWithoutFillersStillHasDestination(t *testing.T) {
t.Fatalf("stops=%v dest=%d", g.Stops, g.Dest) t.Fatalf("stops=%v dest=%d", g.Stops, g.Dest)
} }
} }
func TestTopUpPicksMemberWithIndividualCards(t *testing.T) {
g := NewTopUp(rng.FromInt(1), 5000, []string{"Du", "Anna", "Ville"})
if g.Stage != TopUpPickMember {
t.Fatal("med egna kort väljs person först")
}
g.Update(Input{Move: 1}, frame)
g.Update(Input{Confirm: true}, frame)
if g.Member != 1 || g.Stage != TopUpPickButton || g.Cursor != 0 {
t.Fatalf("member=%d stage=%v", g.Member, g.Stage)
}
// 2×2-rutnätet: höger = +1, ner = +2.
g.Update(Input{MoveX: 1}, frame)
g.Update(Input{Move: 1}, frame)
if g.Cursor != 3 {
t.Fatalf("cursor=%d", g.Cursor)
}
if NewTopUp(rng.FromInt(1), 5000, []string{"Du"}).Stage != TopUpPickButton {
t.Fatal("ensam = inget personval")
}
}
func TestSeatSelectionPreferences(t *testing.T) {
members := []string{"Du", "Anna", "Ville", "Sara"}
g := NewTicket(rng.FromInt(9), "渋谷", []string{"東京", "上野"}, 5000, members)
h := NewTicket(rng.FromInt(9), "渋谷", []string{"東京", "上野"}, 5000, members)
if len(g.Prefs) != 3 || len(g.Assigned) != 4 {
t.Fatalf("prefs=%d assigned=%d", len(g.Prefs), len(g.Assigned))
}
for i := range g.Prefs {
if g.Prefs[i] != h.Prefs[i] {
t.Fatal("önskemålen ska vara seedade")
}
if g.Prefs[i].Kind == PrefWith && g.Prefs[i].With == g.Prefs[i].Member {
t.Fatal("ingen vill sitta bredvid sig själv")
}
}
// Köp resmålet -> sittplatssteget.
for g.Options[g.Cursor].Dest != "渋谷" {
g.Update(Input{Move: 1}, frame)
}
g.Update(Input{Confirm: true}, frame)
if g.Done() || g.Stage != TicketPickSeat || g.Chosen == nil {
t.Fatal("efter resmålet kommer sittplatserna")
}
// Placera alla på rad 0: plats 0,1,2,3 i ordning; upptagen plats går inte.
g.Update(Input{Confirm: true}, frame) // Du på (0,0)
g.Update(Input{Confirm: true}, frame) // upptagen: ignoreras
if g.Current != 1 {
t.Fatal("upptagen plats ska inte gå att välja")
}
for i := 1; i < 4; i++ {
g.Update(Input{MoveX: 1}, frame)
g.Update(Input{Confirm: true}, frame)
}
if !g.Done() || !g.Success() {
t.Fatal("alla placerade = köpet klart")
}
// Facit: räkna ut vilka som borde vara missnöjda med placeringen rad 0 / plats i.
var want []int
for _, p := range g.Prefs {
if !p.Satisfied(g.Assigned) {
want = append(want, p.Member)
}
}
if len(g.Unhappy) != len(want) {
t.Fatalf("unhappy=%v want=%v (prefs %+v)", g.Unhappy, want, g.Prefs)
}
// Egenskaperna.
if !(Seat{0, 0}).Window() || !(Seat{0, 3}).Window() || (Seat{0, 1}).Window() || !(Seat{0, 1}).Aisle() {
t.Fatal("fönster/gång")
}
if !(Seat{0, 0}).Left() || !(Seat{0, 2}).Right() || !(Seat{1, 0}).Beside(Seat{1, 1}) || (Seat{1, 1}).Beside(Seat{1, 2}) || (Seat{0, 0}).Beside(Seat{1, 0}) {
t.Fatal("sida/bredvid")
}
if (SeatPref{Member: 1, Kind: PrefWindow}).Satisfied([]Seat{{0, 0}, {-1, 0}}) {
t.Fatal("oplacerad = inte nöjd")
}
// Esc under sittplatsvalet avbryter köpet.
c := NewTicket(rng.FromInt(9), "渋谷", []string{"東京"}, 5000, members)
for c.Options[c.Cursor].Dest != "渋谷" {
c.Update(Input{Move: 1}, frame)
}
c.Update(Input{Confirm: true}, frame)
c.Update(Input{Cancel: true}, frame)
if !c.Done() || c.Success() {
t.Fatal("avbrutet sittplatsval = inget köp")
}
}

View File

@@ -4,13 +4,91 @@ import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
// Ticket är biljettautomaten: en taxetavla med resmål (på japanska) i seedad // 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 // ordning. Spelaren väljer ett resmål och betalar – om det var rätt avgör
// spärren/tåget senare. // tåget senare. Med sittplatsval följer ett steg där sällskapet placeras i
// vagnen efter sina seedade önskemål.
type Ticket struct { type Ticket struct {
base base
Options []TicketOption Options []TicketOption
Cursor int Cursor int
Chosen *TicketOption Chosen *TicketOption
Cash int Cash int
// Sittplatser (nil Members = inget sittplatssteg).
Stage TicketStage
Members []string
Prefs []SeatPref // önskemål för medlemmar 1..n-1
Assigned []Seat // vald plats per medlem, Row -1 = ingen än
Current int // medlemmen som placeras just nu
Seat Seat // markören i vagnen
Unhappy []int // medlemmar vars önskemål inte uppfylldes (efter köpet)
}
// TicketStage är var i köpet man är.
type TicketStage int
// Steg.
const (
TicketPickDest TicketStage = iota
TicketPickSeat
)
// Vagnen: SeatRows rader med fyra platser – fönster V, gång V | gång H, fönster H.
const (
SeatRows = 3
SeatCols = 4
)
// Seat är en plats i vagnen.
type Seat struct{ Row, Col int }
// Window/Aisle/Left/Right-egenskaper för en plats.
func (s Seat) Window() bool { return s.Col == 0 || s.Col == 3 }
func (s Seat) Aisle() bool { return s.Col == 1 || s.Col == 2 }
func (s Seat) Left() bool { return s.Col <= 1 }
func (s Seat) Right() bool { return s.Col >= 2 }
// Beside: samma rad och samma platspar (0–1 eller 2–3), inte över gången.
func (s Seat) Beside(o Seat) bool { return s.Row == o.Row && s.Col/2 == o.Col/2 && s.Col != o.Col }
// PrefKind är ett slags önskemål.
type PrefKind int
// Önskemål.
const (
PrefWindow PrefKind = iota
PrefAisle
PrefLeft
PrefRight
PrefWith // bredvid en viss person
)
// SeatPref är en medlems önskemål.
type SeatPref struct {
Member int
Kind PrefKind
With int // medlemsindex vid PrefWith
}
// Satisfied kontrollerar önskemålet mot de valda platserna.
func (p SeatPref) Satisfied(assigned []Seat) bool {
s := assigned[p.Member]
if s.Row < 0 {
return false
}
switch p.Kind {
case PrefWindow:
return s.Window()
case PrefAisle:
return s.Aisle()
case PrefLeft:
return s.Left()
case PrefRight:
return s.Right()
case PrefWith:
o := assigned[p.With]
return o.Row >= 0 && s.Beside(o)
}
return true
} }
// TicketOption är ett resmål på tavlan. // TicketOption är ett resmål på tavlan.
@@ -20,8 +98,9 @@ type TicketOption struct {
} }
// NewTicket bygger tavlan: rätt resmål + lockande alternativ, blandade. // NewTicket bygger tavlan: rätt resmål + lockande alternativ, blandade.
// Taxan är seedad per resmål (140–620 yen i steg om 10). // Taxan är seedad per resmål (140–620 yen i steg om 10). members (≥2)
func NewTicket(seed rng.Seed, correct string, others []string, cash int) *Ticket { // aktiverar sittplatsvalet med seedade önskemål för alla utom spelaren.
func NewTicket(seed rng.Seed, correct string, others []string, cash int, members []string) *Ticket {
r := newRand(seed, "ticket") r := newRand(seed, "ticket")
g := &Ticket{Cash: cash} g := &Ticket{Cash: cash}
names := append([]string{correct}, others...) names := append([]string{correct}, others...)
@@ -29,18 +108,47 @@ func NewTicket(seed rng.Seed, correct string, others []string, cash int) *Ticket
g.Options = append(g.Options, TicketOption{Dest: n, Fare: 140 + 10*r.intn(49)}) 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] }) r.shuffle(len(g.Options), func(i, j int) { g.Options[i], g.Options[j] = g.Options[j], g.Options[i] })
if len(members) >= 2 && len(members) <= SeatRows*SeatCols {
g.Members = append([]string(nil), members...)
for i := 1; i < len(members); i++ {
p := SeatPref{Member: i, Kind: PrefKind(r.intn(5))}
if p.Kind == PrefWith {
p.With = r.intn(len(members) - 1)
if p.With >= i {
p.With++ // aldrig sig själv
}
}
g.Prefs = append(g.Prefs, p)
}
g.Assigned = make([]Seat, len(members))
for i := range g.Assigned {
g.Assigned[i] = Seat{Row: -1}
}
}
return g return g
} }
// Update flyttar markören och köper. // SeatTaken rapporterar om platsen redan är vald.
func (g *Ticket) SeatTaken(s Seat) bool {
for _, a := range g.Assigned {
if a == s {
return true
}
}
return false
}
// Update flyttar markören, köper och placerar sällskapet.
func (g *Ticket) Update(in Input, dt float64) { func (g *Ticket) Update(in Input, dt float64) {
if g.done { if g.done {
return return
} }
if in.Cancel { if in.Cancel {
g.done = true g.done = true // avbrutet: inget köp
return return
} }
switch g.Stage {
case TicketPickDest:
g.Cursor = wrap(g.Cursor+in.Move, len(g.Options)) g.Cursor = wrap(g.Cursor+in.Move, len(g.Options))
if in.Confirm { if in.Confirm {
opt := g.Options[g.Cursor] opt := g.Options[g.Cursor]
@@ -48,6 +156,26 @@ func (g *Ticket) Update(in Input, dt float64) {
return // har inte råd: välj annat eller avbryt return // har inte råd: välj annat eller avbryt
} }
g.Chosen = &opt g.Chosen = &opt
if g.Members == nil {
g.done, g.success = true, true
return
}
g.Stage = TicketPickSeat
}
case TicketPickSeat:
g.Seat.Row = wrap(g.Seat.Row+in.Move, SeatRows)
g.Seat.Col = wrap(g.Seat.Col+in.MoveX, SeatCols)
if in.Confirm && !g.SeatTaken(g.Seat) {
g.Assigned[g.Current] = g.Seat
g.Current++
if g.Current >= len(g.Members) {
for _, p := range g.Prefs {
if !p.Satisfied(g.Assigned) {
g.Unhappy = append(g.Unhappy, p.Member)
}
}
g.done, g.success = true, true g.done, g.success = true, true
} }
} }
}
}

View File

@@ -2,11 +2,13 @@ package minigame
import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
// TopUp är påfyllningsautomaten: hitta チャージ-knappen bland japanska // TopUp är påfyllningsautomaten: (vems kort, om alla har egna), hitta
// knappar i seedad ordning, välj belopp, mata in sedlar genom att hålla // チャージ-knappen bland japanska knappar i seedad ordning, välj belopp, mata
// Enter. Fel knapp kostar en straffsekund eller två. // in sedlar genom att hålla Enter. Fel knapp kostar en straffsekund eller två.
type TopUp struct { type TopUp struct {
base base
Members []string // namn när alla har egna kort (annars tom)
Member int // vald person (index i Members, 0 = spelaren)
Stage TopUpStage Stage TopUpStage
Buttons []string // knapparnas text, seedad ordning Buttons []string // knapparnas text, seedad ordning
Cursor int Cursor int
@@ -26,6 +28,7 @@ const (
TopUpPickButton TopUpStage = iota TopUpPickButton TopUpStage = iota
TopUpPickAmount TopUpPickAmount
TopUpFeed TopUpFeed
TopUpPickMember // först, bara med egna kort
) )
// Knapparna på en japansk biljettautomat. チャージ är den rätta. // Knapparna på en japansk biljettautomat. チャージ är den rätta.
@@ -40,10 +43,15 @@ const (
topUpDrainPerSec = 1.2 topUpDrainPerSec = 1.2
) )
// NewTopUp skapar automaten. maxCash begränsar beloppen. // NewTopUp skapar automaten. maxCash begränsar beloppen. members anges
func NewTopUp(seed rng.Seed, maxCash int) *TopUp { // när alla i sällskapet har egna kort; då väljs person först.
func NewTopUp(seed rng.Seed, maxCash int, members []string) *TopUp {
r := newRand(seed, "topup") r := newRand(seed, "topup")
g := &TopUp{Buttons: append([]string(nil), topUpButtons...), MaxCash: maxCash} g := &TopUp{Buttons: append([]string(nil), topUpButtons...), MaxCash: maxCash}
if len(members) > 1 {
g.Members = append([]string(nil), members...)
g.Stage = TopUpPickMember
}
r.shuffle(len(g.Buttons), func(i, j int) { g.Buttons[i], g.Buttons[j] = g.Buttons[j], g.Buttons[i] }) 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 { for _, a := range TopUpAmounts {
if a <= maxCash { if a <= maxCash {
@@ -70,8 +78,14 @@ func (g *TopUp) Update(in Input, dt float64) {
return return
} }
switch g.Stage { switch g.Stage {
case TopUpPickMember:
g.Cursor = wrap(g.Cursor+in.Move, len(g.Members))
if in.Confirm {
g.Member = g.Cursor
g.Stage, g.Cursor = TopUpPickButton, 0
}
case TopUpPickButton: case TopUpPickButton:
g.Cursor = wrap(g.Cursor+in.Move, len(g.Buttons)) g.Cursor = wrap(g.Cursor+2*in.Move+in.MoveX, len(g.Buttons)) // 2×2-rutnät
if in.Confirm { if in.Confirm {
if g.Buttons[g.Cursor] == topUpButtons[0] { if g.Buttons[g.Cursor] == topUpButtons[0] {
g.Stage, g.Cursor = TopUpPickAmount, 0 g.Stage, g.Cursor = TopUpPickAmount, 0

View File

@@ -48,8 +48,16 @@ type Member struct {
Meters Meters Meters Meters
Urge Urge Urge Urge
UrgeAt float64 // när behovet uppstod UrgeAt float64 // när behovet uppstod
Card int // eget tågkort (yen) när banan kräver egna kort; spelaren använder plånboken
} }
// MemberCardYen är vad följarnas egna kort har från start (lite – de får
// fyllas på).
const MemberCardYen = 500
// Rates skalar hur fort mätarna sjunker (debuffar/stats).
type Rates struct{ Thirst, Toilet float64 }
// PartyNames är namnen i den ordning de följer med. // PartyNames är namnen i den ordning de följer med.
var PartyNames = []string{"Du", "Anna", "Ville", "Sara", "Omar"} var PartyNames = []string{"Du", "Anna", "Ville", "Sara", "Omar"}
@@ -72,6 +80,7 @@ const (
// Party är sällskapet – lämmeltåget – med mätare, behov och följarnas spår. // Party är sällskapet – lämmeltåget – med mätare, behov och följarnas spår.
type Party struct { type Party struct {
Members []*Member Members []*Member
Rates Rates
trail []level.Point // ledarens rutor, senaste sist trail []level.Point // ledarens rutor, senaste sist
rand *rand.Rand rand *rand.Rand
nextEventAt float64 nextEventAt float64
@@ -83,9 +92,9 @@ type Party struct {
// sällskapet kommer från seed. // sällskapet kommer från seed.
func NewParty(n int, start level.Point, seed rng.Seed) *Party { func NewParty(n int, start level.Point, seed rng.Seed) *Party {
n = max(1, min(n, len(PartyNames))) n = max(1, min(n, len(PartyNames)))
p := &Party{rand: seed.New()} p := &Party{rand: seed.New(), Rates: Rates{1, 1}}
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
p.Members = append(p.Members, &Member{Name: PartyNames[i], Meters: Meters{100, 100, 100}}) p.Members = append(p.Members, &Member{Name: PartyNames[i], Meters: Meters{100, 100, 100}, Card: MemberCardYen})
} }
p.trail = []level.Point{start} p.trail = []level.Point{start}
p.nextEventAt = p.interval() * (0.5 + p.rand.Float64()) p.nextEventAt = p.interval() * (0.5 + p.rand.Float64())
@@ -106,8 +115,8 @@ func (p *Party) interval() float64 { return BaseEventSec / float64(p.Size()) }
func (p *Party) Advance(dt, t float64) { func (p *Party) Advance(dt, t float64) {
for _, m := range p.Members { for _, m := range p.Members {
m.Meters.Food = max(0, m.Meters.Food-FoodPerSec*dt) m.Meters.Food = max(0, m.Meters.Food-FoodPerSec*dt)
m.Meters.Drink = max(0, m.Meters.Drink-DrinkPerSec*dt) m.Meters.Drink = max(0, m.Meters.Drink-DrinkPerSec*p.Rates.Thirst*dt)
m.Meters.Toilet = max(0, m.Meters.Toilet-ToiletPerSec*dt) m.Meters.Toilet = max(0, m.Meters.Toilet-ToiletPerSec*p.Rates.Toilet*dt)
if m.Urge == Distracted && t-m.UrgeAt >= DistractionSec { if m.Urge == Distracted && t-m.UrgeAt >= DistractionSec {
m.Urge = NoUrge m.Urge = NoUrge
} }
@@ -185,6 +194,24 @@ func (p *Party) SpeedFactor() float64 {
return f return f
} }
// Names är medlemmarnas namn i ordning.
func (p *Party) Names() []string {
out := make([]string, len(p.Members))
for i, m := range p.Members {
out[i] = m.Name
}
return out
}
// AvgMeters är medelvärdet av allas lägsta mätare, 0..1 (poängen).
func (p *Party) AvgMeters() float64 {
sum := 0.0
for _, m := range p.Members {
sum += m.Meters.Min()
}
return sum / float64(len(p.Members)) / 100
}
// NeedsToilet är de som har nytta av en toalett just nu. // NeedsToilet är de som har nytta av en toalett just nu.
func (p *Party) NeedsToilet() []*Member { func (p *Party) NeedsToilet() []*Member {
var out []*Member var out []*Member

View File

@@ -76,8 +76,46 @@ type Run struct {
LostReason string // varför banan förlorades (nyckel för UI) LostReason string // varför banan förlorades (nyckel för UI)
FlashKey string // kort meddelande till HUD (textnyckel) FlashKey string // kort meddelande till HUD (textnyckel)
FlashAt float64 // < 0 = aldrig FlashAt float64 // < 0 = aldrig
// Svårighet, poäng och debuffar (M7).
Stats Stats // multiplikatorer från profilens debuffar
Grumpy int // missnöjda med sittplatsen
poisoned bool // otur vid kiosken: matförgiftning efter banan
spentStart int // plånbokens Spent vid banstart
Score ScoreBreakdown
NewDebuffs []Debuff // debuffar banan gav (appliceras på profilen efteråt)
} }
// ScoreBreakdown är poängen för en vunnen bana.
type ScoreBreakdown struct {
Time, Money, Meters, Explore, Penalty, Total int
}
// Poängformelns vikter.
const (
ScoreTimeMax = 500 // par eller snabbare
ScoreMoneyMax = 300 // minus 1 per 10 yen spenderade på banan
ScoreMetersMax = 200 // sällskapets mätare vid målgång
ScoreExploreMax = 100 // utforskad andel
PenaltyMissed = 50
PenaltyRescan = 10
PenaltyGrumpy = 30
GrumpyFoodPenalty = 25.0
)
// Fler förlustorsaker.
const (
LostTimeUp = "result.lost_time_up"
LostMissedTrains = "result.lost_missed_trains"
)
// Fler flash-nycklar.
const (
FlashGrumpy = "hud.grumpy"
FlashPoisoned = "hud.poisoned"
FlashNoCards = "hud.need_all_cards"
)
// MiniKind är vilket minispel som pågår. // MiniKind är vilket minispel som pågår.
type MiniKind int type MiniKind int
@@ -123,6 +161,7 @@ type RunOptions struct {
Dict *lang.Dictionary // nil = tom ordbok Dict *lang.Dictionary // nil = tom ordbok
Know *lang.Knowledge // nil = ny kunskap Know *lang.Knowledge // nil = ny kunskap
Wallet *Wallet // nil = startkassa Wallet *Wallet // nil = startkassa
Debuffs []Debuff // aktiva debuffar från profilen
Seed rng.Seed // omgångens seed (sparas med spelet) Seed rng.Seed // omgångens seed (sparas med spelet)
} }
@@ -177,7 +216,10 @@ func NewRun(l *level.Level, o RunOptions) *Run {
baseSpeed: o.Speed, baseSpeed: o.Speed,
NoticeAt: -1, NoticeAt: -1,
Fog: NewFog(l, FogRadius), Fog: NewFog(l, FogRadius),
Stats: CombinedStats(o.Debuffs),
spentStart: o.Wallet.Spent,
} }
r.Party.Rates = Rates{Thirst: r.Stats[StatThirst], Toilet: r.Stats[StatToilet]}
r.Fog.Reveal(l, l.SpawnPoint()) r.Fog.Reveal(l, l.SpawnPoint())
if r.parSec <= 0 { if r.parSec <= 0 {
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter)) n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
@@ -218,9 +260,36 @@ func (r *Run) CanEnter(p level.Point) bool {
func (r *Run) canPassGate() bool { func (r *Run) canPassGate() bool {
if r.Level.TicketRequired { if r.Level.TicketRequired {
return r.Wallet.Ticket != "" if r.Wallet.Ticket == "" {
return false
} }
return r.Wallet.CardUsable() && r.Wallet.Card >= r.Level.FareYen return !r.Level.IndividualCards || r.Wallet.TicketCount >= r.Party.Size()
}
if !r.Wallet.CardUsable() || r.Wallet.Card < r.Level.FareYen {
return false
}
if r.Level.IndividualCards {
for _, m := range r.Party.Followers() {
if m.Card < r.Level.FareYen {
return false
}
}
}
return true
}
// MembersWithoutFare listar följare vars egna kort inte räcker (HUD).
func (r *Run) MembersWithoutFare() []*Member {
if !r.Level.IndividualCards {
return nil
}
var out []*Member
for _, m := range r.Party.Followers() {
if m.Card < r.Level.FareYen {
out = append(out, m)
}
}
return out
} }
func (r *Run) inGate() bool { func (r *Run) inGate() bool {
@@ -240,7 +309,7 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
r.Wallet.Drain(dt) r.Wallet.Drain(dt)
before := r.Player.Tile before := r.Player.Tile
wasGate := r.inGate() wasGate := r.inGate()
r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor() r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor() * r.Stats[StatSpeed]
if r.Wallet.Phone && r.Wallet.Battery > 0 { if r.Wallet.Phone && r.Wallet.Battery > 0 {
r.Player.Speed *= PhoneSpeedFactor r.Player.Speed *= PhoneSpeedFactor
} }
@@ -260,11 +329,18 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
r.stepHelp(dt) r.stepHelp(dt)
r.stepFacility(dt) r.stepFacility(dt)
r.stepReading() r.stepReading()
r.checkLimits()
if r.Player.Tile != before && r.inGate() && !wasGate { if r.Player.Tile != before && r.inGate() && !wasGate {
r.GatePasses++ r.GatePasses++
if !r.Level.TicketRequired { if !r.Level.TicketRequired {
r.Wallet.PayCard(r.Level.FareYen) r.Wallet.PayCard(r.Level.FareYen)
if r.Wallet.Phone && r.Seed.Derive(fmt.Sprintf("rescan:%d", r.GatePasses)).New().Float64() < PhoneRescanChance { if r.Level.IndividualCards {
for _, m := range r.Party.Followers() {
m.Card -= r.Level.FareYen
r.Wallet.Spent += r.Level.FareYen
}
}
if r.Wallet.Phone && r.Seed.Derive(fmt.Sprintf("rescan:%d", r.GatePasses)).New().Float64() < PhoneRescanChance/r.Stats[StatLuck] {
r.Rescans++ r.Rescans++
r.RescanUntil = r.Time + RescanSec r.RescanUntil = r.Time + RescanSec
r.flash(FlashRescan) r.flash(FlashRescan)
@@ -321,10 +397,22 @@ func (r *Run) startMachine() {
} }
if r.Level.TicketRequired { if r.Level.TicketRequired {
dest := r.Level.Destination.Get("ja") dest := r.Level.Destination.Get("ja")
r.Mini, r.MiniKind = minigame.NewTicket(r.miniSeed(), dest, r.stationPool(dest, 4), r.Wallet.Cash), MiniTicket var seats []string
if r.Level.SeatSelection {
seats = r.Party.Names()
}
cash := r.Wallet.Cash
if r.Level.IndividualCards {
cash /= r.Party.Size() // alla behöver en biljett: visa vad som går att köpa per person
}
r.Mini, r.MiniKind = minigame.NewTicket(r.miniSeed(), dest, r.stationPool(dest, 4), cash, seats), MiniTicket
return return
} }
r.Mini, r.MiniKind = minigame.NewTopUp(r.miniSeed(), r.Wallet.Cash), MiniTopUp var members []string
if r.Level.IndividualCards {
members = r.Party.Names()
}
r.Mini, r.MiniKind = minigame.NewTopUp(r.miniSeed(), r.Wallet.Cash, members), MiniTopUp
} }
// tryBoard försöker kliva på: biljetten måste gälla tåget, fulla tåg kräver // tryBoard försöker kliva på: biljetten måste gälla tåget, fulla tåg kräver
@@ -348,7 +436,7 @@ func (r *Run) tryBoard(tr *level.Train) {
func (r *Run) startRide() { func (r *Run) startRide() {
tr := r.PendingTrain tr := r.PendingTrain
if r.Level.TicketRequired { if r.Level.TicketRequired {
r.Wallet.Ticket, r.Wallet.TicketFare = "", 0 // biljetten är förbrukad r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = "", 0, 0 // biljetterna är förbrukade
} }
dest := r.Level.Destination.Get("ja") 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 r.Mini, r.MiniKind = minigame.NewRide(r.miniSeed(), dest, r.stationPool(dest, 8), r.Level.RideStops, tr.Correct), MiniRide
@@ -365,18 +453,43 @@ func (r *Run) StepMini(dt float64, in minigame.Input) {
r.Party.Advance(dt, r.Time) r.Party.Advance(dt, r.Time)
r.Mini.Update(in, dt) r.Mini.Update(in, dt)
if !r.Mini.Done() { if !r.Mini.Done() {
r.checkLimits()
return return
} }
m := r.Mini m := r.Mini
r.Mini, r.MiniKind = nil, MiniNone r.Mini, r.MiniKind = nil, MiniNone
switch g := m.(type) { switch g := m.(type) {
case *minigame.TopUp: case *minigame.TopUp:
if g.Success() && r.Wallet.TopUp(g.Amount) { if !g.Success() {
break
}
if g.Member == 0 {
if r.Wallet.TopUp(g.Amount) {
r.TopUps++
}
} else if r.Wallet.Cash >= g.Amount {
r.Wallet.Cash -= g.Amount
r.Party.Members[g.Member].Card += g.Amount
r.TopUps++ r.TopUps++
} }
case *minigame.Ticket: case *minigame.Ticket:
if g.Success() && g.Chosen != nil && r.Wallet.PayCash(g.Chosen.Fare) { if !g.Success() || g.Chosen == nil {
r.Wallet.Ticket, r.Wallet.TicketFare = g.Chosen.Dest, g.Chosen.Fare break
}
count := 1
if r.Level.IndividualCards {
count = r.Party.Size()
}
if r.Wallet.PayCash(g.Chosen.Fare * count) {
r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = g.Chosen.Dest, g.Chosen.Fare, count
}
if len(g.Unhappy) > 0 {
r.Grumpy += len(g.Unhappy)
for _, i := range g.Unhappy {
m := r.Party.Members[i]
m.Meters.Food = max(0, m.Meters.Food-GrumpyFoodPenalty)
}
r.flash(FlashGrumpy)
} }
case *minigame.Crowd: case *minigame.Crowd:
if g.Success() { if g.Success() {
@@ -432,8 +545,73 @@ func (r *Run) stepFacility(dt float64) {
} }
// buyAtKiosk köper mat och dryck åt alla som behöver, betalat med kortet. // buyAtKiosk köper mat och dryck åt alla som behöver, betalat med kortet.
// Med liten seedad otur blir maten dålig: matförgiftning efter banan.
func (r *Run) buyAtKiosk() { func (r *Run) buyAtKiosk() {
r.Served += r.Party.BuyAtKiosk(r.Wallet.PayCard) n := r.Party.BuyAtKiosk(r.Wallet.PayCard)
if n == 0 {
return
}
r.Served += n
if !r.poisoned && r.Seed.Derive(fmt.Sprintf("poison:%d", r.Served)).New().Float64() < FoodPoisonChance/r.Stats[StatLuck] {
r.poisoned = true
r.flash(FlashPoisoned)
}
}
// checkLimits förlorar banan när tidsgränsen eller maxantalet missade tåg passeras.
func (r *Run) checkLimits() {
if r.Outcome != Ongoing {
return
}
if r.Level.TimeLimitSec > 0 && r.Time > float64(r.Level.TimeLimitSec) {
r.Mini, r.MiniKind, r.PendingTrain = nil, MiniNone, nil
r.finish(nil, false, LostTimeUp)
return
}
if r.Level.MaxMissedTrains > 0 && r.MissedTrains > r.Level.MaxMissedTrains {
r.finish(nil, false, LostMissedTrains)
}
}
// TimeLeft är sekunder kvar av tidsgränsen (0 utan gräns).
func (r *Run) TimeLeft() float64 {
if r.Level.TimeLimitSec == 0 {
return 0
}
return max(0, float64(r.Level.TimeLimitSec)-r.Time)
}
// computeScore fyller i poängen för en vunnen bana.
func (r *Run) computeScore() {
var sc ScoreBreakdown
sc.Time = int(ScoreTimeMax * min(1, r.parSec/max(r.Time, 0.001)))
sc.Money = max(0, ScoreMoneyMax-(r.Wallet.Spent-r.spentStart)/10)
sc.Meters = int(ScoreMetersMax * r.Party.AvgMeters())
sc.Explore = int(ScoreExploreMax * r.Fog.Explored(r.Level))
sc.Penalty = PenaltyMissed*r.MissedTrains + PenaltyRescan*r.Rescans + PenaltyGrumpy*r.Grumpy
sc.Total = max(0, sc.Time+sc.Money+sc.Meters+sc.Explore-sc.Penalty)
r.Score = sc
}
// earnedDebuffs räknar ut vilka debuffar banan gav.
func (r *Run) earnedDebuffs() []Debuff {
var out []Debuff
if r.Steps >= SoreFeetSteps {
out = append(out, Debuff{SoreFeet, 1})
}
if r.Time >= TiredSec {
out = append(out, Debuff{Tired, 1})
}
for _, m := range r.Party.Members {
if m.Meters.Food <= 0 {
out = append(out, Debuff{Hangry, 1})
break
}
}
if r.poisoned {
out = append(out, Debuff{FoodPoisoning, FoodPoisonLevels})
}
return out
} }
// FacilityProgress är 0..1 för ett pågående toalettbesök. // FacilityProgress är 0..1 för ett pågående toalettbesök.
@@ -472,12 +650,14 @@ func (r *Run) SignHere() *level.SignText {
// tillräckligt bra resultat lär man sig resmålets ord. // tillräckligt bra resultat lär man sig resmålets ord.
func (r *Run) finish(tr *level.Train, won bool, reason string) { func (r *Run) finish(tr *level.Train, won bool, reason string) {
r.Boarded = tr r.Boarded = tr
r.NewDebuffs = r.earnedDebuffs()
if !won { if !won {
r.Outcome = Lost r.Outcome = Lost
r.LostReason = reason r.LostReason = reason
return return
} }
r.Outcome = Won r.Outcome = Won
r.computeScore()
r.Stars = r.StarsFor(r.Time) r.Stars = r.StarsFor(r.Time)
r.PointsEarned = r.Know.Award(r.Stars) r.PointsEarned = r.Know.Award(r.Stars)
if r.Stars >= lang.StarsToLearnDestination { if r.Stars >= lang.StarsToLearnDestination {
@@ -515,7 +695,7 @@ func (r *Run) stepReading() {
continue continue
} }
seen[w] = true seen[w] = true
if roll.Float64() >= lang.ReadChance { if roll.Float64() >= lang.ReadChance*r.Stats[StatFocus] {
continue continue
} }
if r.Know.AddProgress(w) { if r.Know.AddProgress(w) {
@@ -547,7 +727,7 @@ func (r *Run) AskHelp() bool {
if sg == nil || r.Help.Active || r.Revealed[r.Player.Tile] || r.Outcome != Ongoing { if sg == nil || r.Help.Active || r.Revealed[r.Player.Tile] || r.Outcome != Ongoing {
return false return false
} }
secs := lang.HelpSeconds(r.Know.Level()) secs := lang.HelpSeconds(r.Know.Level()) / r.Stats[StatFocus]
r.Help = HelpState{Active: true, At: r.Player.Tile, Remaining: secs, Total: secs} r.Help = HelpState{Active: true, At: r.Player.Tile, Remaining: secs, Total: secs}
return true return true
} }

View File

@@ -12,6 +12,19 @@ import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
) )
// fillSeats placerar sällskapet på första lediga platser (sittplatssteget).
func fillSeats(r *Run, tk *minigame.Ticket) {
for r.Mini != nil && tk.Stage == minigame.TicketPickSeat {
for tk.SeatTaken(tk.Seat) {
r.StepMini(frame, minigame.Input{MoveX: 1})
if tk.Seat.Col == 0 {
r.StepMini(frame, minigame.Input{Move: 1})
}
}
r.StepMini(frame, minigame.Input{Confirm: true})
}
}
// ride spelar tågresan: kliver av när tåget står vid resmålet. På fel tåg // 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. // finns inget resmål, så resan går till slutstationen.
func ride(t *testing.T, r *Run) { func ride(t *testing.T, r *Run) {
@@ -487,7 +500,7 @@ func TestTopUpFlowAtMachine(t *testing.T) {
} }
cash := r.Wallet.Cash cash := r.Wallet.Cash
for g.Buttons[g.Cursor] != "チャージ" { for g.Buttons[g.Cursor] != "チャージ" {
r.StepMini(frame, minigame.Input{Move: 1}) r.StepMini(frame, minigame.Input{MoveX: 1})
} }
r.StepMini(frame, minigame.Input{Confirm: true}) r.StepMini(frame, minigame.Input{Confirm: true})
r.StepMini(frame, minigame.Input{Confirm: true}) // första beloppet (1000) r.StepMini(frame, minigame.Input{Confirm: true}) // första beloppet (1000)
@@ -537,6 +550,7 @@ func TestTicketLevelFlow(t *testing.T) {
} }
wrong := tk.Options[tk.Cursor] wrong := tk.Options[tk.Cursor]
r.StepMini(frame, minigame.Input{Confirm: true}) r.StepMini(frame, minigame.Input{Confirm: true})
fillSeats(r, tk) // 002 har sittplatsval
if r.Wallet.Ticket != wrong.Dest || r.Wallet.Cash != StartCashYen-wrong.Fare { if r.Wallet.Ticket != wrong.Dest || r.Wallet.Cash != StartCashYen-wrong.Fare {
t.Fatalf("biljett=%q cash=%d", r.Wallet.Ticket, r.Wallet.Cash) t.Fatalf("biljett=%q cash=%d", r.Wallet.Ticket, r.Wallet.Cash)
} }
@@ -556,6 +570,7 @@ func TestTicketLevelFlow(t *testing.T) {
r.StepMini(frame, minigame.Input{Move: 1}) r.StepMini(frame, minigame.Input{Move: 1})
} }
r.StepMini(frame, minigame.Input{Confirm: true}) r.StepMini(frame, minigame.Input{Confirm: true})
fillSeats(r, tk)
if r.Wallet.Ticket != "渋谷" { if r.Wallet.Ticket != "渋谷" {
t.Fatalf("biljett=%q", r.Wallet.Ticket) t.Fatalf("biljett=%q", r.Wallet.Ticket)
} }
@@ -664,3 +679,225 @@ func TestPhoneSpeedRescanAndBattery(t *testing.T) {
t.Fatal("ingen fartbuff med dött batteri") t.Fatal("ingen fartbuff med dött batteri")
} }
} }
func TestTimeLimitAndMissedTrainsLose(t *testing.T) {
l := loadLevel(t, "002-shinjuku")
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
if r.TimeLeft() != float64(l.TimeLimitSec) {
t.Fatalf("TimeLeft=%.0f", r.TimeLeft())
}
r.Time = float64(l.TimeLimitSec) - 0.5
for i := 0; i < 60 && r.Outcome == Ongoing; i++ {
r.Step(frame, None, false)
}
if r.Outcome != Lost || r.LostReason != LostTimeUp || r.Boarded != nil {
t.Fatalf("tidsgräns: outcome=%v reason=%q", r.Outcome, r.LostReason)
}
r2 := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
r2.MissedTrains = l.MaxMissedTrains + 1
r2.Step(frame, None, false)
if r2.Outcome != Lost || r2.LostReason != LostMissedTrains {
t.Fatalf("missade tåg: outcome=%v reason=%q", r2.Outcome, r2.LostReason)
}
// Bana 001 har inga gränser.
r3 := NewRun(loadLevel(t, "001-flygplatsen"), RunOptions{Seed: rng.FromInt(1)})
r3.Time, r3.MissedTrains = 99999, 99
r3.Step(frame, None, false)
if r3.Outcome != Ongoing || r3.TimeLeft() != 0 {
t.Fatal("utan gränser förlorar man inte på tid eller missade tåg")
}
}
func TestScoreBreakdown(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(2)})
r.Party.nextEventAt = 1e9
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter))
waitForTrain(t, r)
r.Step(frame, None, true)
ride(t, r)
sc := r.Score
if r.Outcome != Won || sc.Total <= 0 || sc.Total != sc.Time+sc.Money+sc.Meters+sc.Explore-sc.Penalty {
t.Fatalf("score=%+v", sc)
}
if sc.Time != ScoreTimeMax || sc.Money != ScoreMoneyMax-l.FareYen/10 || sc.Penalty != 0 {
t.Fatalf("snabb, billig vinst: %+v", sc)
}
if sc.Meters <= 0 || sc.Meters > ScoreMetersMax || sc.Explore <= 0 || sc.Explore > ScoreExploreMax {
t.Fatalf("mätare/utforskat: %+v", sc)
}
// Avdrag räknas.
r2 := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(2)})
r2.MissedTrains, r2.Rescans, r2.Grumpy = 1, 2, 1
r2.Time = 10 * r2.ParSec()
r2.computeScore()
if r2.Score.Penalty != PenaltyMissed+2*PenaltyRescan+PenaltyGrumpy || r2.Score.Time != ScoreTimeMax/10 {
t.Fatalf("avdrag: %+v", r2.Score)
}
}
func TestDebuffsAffectRunAndAreEarned(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, RunOptions{Seed: rng.FromInt(1), Debuffs: []Debuff{{Blisters, 1}, {FoodPoisoning, 1}, {Hangry, 1}}})
r.Party.nextEventAt = 1e9
r.Step(frame, Right, false)
if math.Abs(r.Player.Speed-DefaultSpeed*0.8) > 1e-9 || r.Party.Rates.Toilet != 2 || r.Party.Rates.Thirst != 1.2 {
t.Fatalf("speed=%.2f rates=%+v", r.Player.Speed, r.Party.Rates)
}
r.Step(frame, None, false)
for r.Player.Moving() {
r.Step(frame, None, false)
}
walk(t, r, l.Path(r.Player.Tile, l.Find(level.Sign)[1], r.CanEnter))
r.AskHelp()
if want := lang.HelpSeconds(0) / 0.7; math.Abs(r.Help.Total-want) > 1e-9 {
t.Fatalf("hjälptid med dåligt fokus %.2f, vill ha %.2f", r.Help.Total, want)
}
// Intjänade debuffar: många steg, lång tid, tom matmätare.
r.Steps, r.Time = SoreFeetSteps, TiredSec
r.Party.Members[1].Meters.Food = 0
r.finish(nil, false, LostTimeUp)
kinds := map[DebuffKind]bool{}
for _, d := range r.NewDebuffs {
kinds[d.Kind] = true
}
if !kinds[SoreFeet] || !kinds[Tired] || !kinds[Hangry] || kinds[FoodPoisoning] {
t.Fatalf("intjänade=%v", r.NewDebuffs)
}
}
func TestIndividualCardsAtGateAndMachine(t *testing.T) {
l := loadLevel(t, "003-shibuya")
if !l.IndividualCards || !l.TicketRequired || !l.SeatSelection || l.Party != 4 {
t.Fatal("003 ska ha egna kort, biljetter, sittplatser och fyra i sällskapet")
}
// Egna kort på en kortbana (utan biljettkrav): alla måste ha saldo.
l1 := loadLevel(t, "001-flygplatsen")
l1.IndividualCards = true
r := NewRun(l1, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
r.Party.nextEventAt = 1e9
gate := l1.Find(level.Gate)[0]
anna := r.Party.Members[1]
if anna.Card != MemberCardYen || !r.CanEnter(gate) {
t.Fatalf("Annas kort=%d canEnter=%v", anna.Card, r.CanEnter(gate))
}
anna.Card = 0
if r.CanEnter(gate) || len(r.MembersWithoutFare()) != 1 {
t.Fatal("utan saldo på Annas kort stoppar spärren alla")
}
// Fyll på Annas kort i automaten (personval först).
walk(t, r, l1.Path(r.Player.Tile, l1.Find(level.TicketMachine)[0], r.CanEnter))
r.Step(frame, None, true)
g := r.Mini.(*minigame.TopUp)
if g.Stage != minigame.TopUpPickMember || len(g.Members) != 2 {
t.Fatalf("stage=%v members=%v", g.Stage, g.Members)
}
r.StepMini(frame, minigame.Input{Move: 1})
r.StepMini(frame, minigame.Input{Confirm: true})
for g.Buttons[g.Cursor] != "チャージ" {
r.StepMini(frame, minigame.Input{MoveX: 1})
}
r.StepMini(frame, minigame.Input{Confirm: true})
r.StepMini(frame, minigame.Input{Confirm: true})
cash := r.Wallet.Cash
for i := 0; i < 600 && r.Mini != nil; i++ {
r.StepMini(frame, minigame.Input{Hold: true})
}
if anna.Card != 1000 || r.Wallet.Cash != cash-1000 || r.Wallet.Card != StartCardYen {
t.Fatalf("Annas kort=%d cash=%d eget=%d", anna.Card, r.Wallet.Cash, r.Wallet.Card)
}
// Spärren drar från allas kort.
before := r.Wallet.Spent
path := l1.Path(r.Player.Tile, gate, r.CanEnter)
for i, p := range path {
if l1.At(p.X, p.Y) == level.Gate {
path = path[:i+1]
break
}
}
walk(t, r, path)
if anna.Card != 1000-l1.FareYen || r.Wallet.Card != StartCardYen-l1.FareYen || r.Wallet.Spent != before+2*l1.FareYen {
t.Fatalf("efter spärren: anna=%d egen=%d spent=%d", anna.Card, r.Wallet.Card, r.Wallet.Spent)
}
}
// TestLevel003Flow: biljettbana med egna kort och sittplatser – alla
// behöver biljett, sällskapet placeras, och boten vinner.
func TestLevel003Flow(t *testing.T) {
l := loadLevel(t, "003-shibuya")
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(21)})
r.Party.nextEventAt = 1e9
machine := l.Find(level.TicketMachine)[0]
walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter))
r.Step(frame, None, true)
tk := r.Mini.(*minigame.Ticket)
if len(tk.Members) != 4 || tk.Cash != StartCashYen/4 {
t.Fatalf("members=%v cash=%d", tk.Members, tk.Cash)
}
for tk.Options[tk.Cursor].Dest != "池袋" {
r.StepMini(frame, minigame.Input{Move: 1})
}
fare := tk.Options[tk.Cursor].Fare
r.StepMini(frame, minigame.Input{Confirm: true})
if tk.Stage != minigame.TicketPickSeat {
t.Fatal("sittplatsval ska följa")
}
// Placera alla så att önskemålen uppfylls när det går: prova platser tills nöjd.
for cur := 0; r.Mini != nil; cur++ {
best := minigame.Seat{Row: -1}
for row := 0; row < minigame.SeatRows && best.Row < 0; row++ {
for col := 0; col < minigame.SeatCols; col++ {
cand := minigame.Seat{Row: row, Col: col}
if tk.SeatTaken(cand) {
continue
}
trial := append([]minigame.Seat(nil), tk.Assigned...)
trial[cur] = cand
ok := true
for _, p := range tk.Prefs {
if p.Member == cur && !p.Satisfied(trial) {
ok = false
}
}
if ok {
best = cand
break
}
}
}
if best.Row < 0 {
t.Fatal("ingen ledig plats")
}
for tk.Seat != best {
in := minigame.Input{}
if tk.Seat.Row != best.Row {
in.Move = 1
} else {
in.MoveX = 1
}
r.StepMini(frame, in)
}
r.StepMini(frame, minigame.Input{Confirm: true})
}
if r.Wallet.TicketCount != 4 || r.Wallet.Ticket != "池袋" || r.Wallet.Cash != StartCashYen-4*fare {
t.Fatalf("biljetter=%d dest=%q cash=%d", r.Wallet.TicketCount, r.Wallet.Ticket, r.Wallet.Cash)
}
// Den giriga placeringen kan missa "bredvid X" som placeras senare; det
// är tillåtet men ska då synas som missnöje.
if r.Grumpy > 0 && r.FlashKey != FlashGrumpy {
t.Fatal("missnöje ska flashas")
}
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 i := 0; i < 600 && !cr.InZone(); i++ {
r.StepMini(frame, minigame.Input{})
}
r.StepMini(frame, minigame.Input{Confirm: true})
ride(t, r)
if r.Outcome != Won || r.Wallet.TicketCount != 0 {
t.Fatalf("outcome=%v reason=%q tickets=%d", r.Outcome, r.LostReason, r.Wallet.TicketCount)
}
}

121
internal/sim/stats.go Normal file
View File

@@ -0,0 +1,121 @@
package sim
// Stats är spelarens multiplikatorer: 1,0 = normalt. Debuffar (och i M8
// items/skills) ändrar dem.
type Stats [5]float64
// Statindex.
const (
StatSpeed = iota // gångfart
StatThirst // hur fort dryckesmätaren sjunker (>1 = fortare)
StatFocus // chans att lära av skyltar, hjälptid (>1 = bättre)
StatToilet // hur fort toamätaren sjunker (>1 = fortare)
StatLuck // omskanning, trängsel (>1 = bättre; items i M8)
)
// DefaultStats är allt på 1,0.
func DefaultStats() Stats { return Stats{1, 1, 1, 1, 1} }
// DebuffKind är en typ av debuff (textnyckel: "debuff.<kind>").
type DebuffKind string
// Debuffar. De fyra första uppstår av spelandet (M7), resten delas ut av
// sidouppdrag (M10) och varar 1–4 banor.
const (
SoreFeet DebuffKind = "sore_feet" // många steg
Tired DebuffKind = "tired" // lång bana
Hangry DebuffKind = "hangry" // tom matmätare
FoodPoisoning DebuffKind = "food_poisoning" // otur vid kiosken
DOMS DebuffKind = "doms" // träningsvärk
Blisters DebuffKind = "blisters" // skoskav
HeavyBag DebuffKind = "heavy_bag" // köpt något tungt/stort
Stuffed DebuffKind = "stuffed" // proppmätt
Sweaty DebuffKind = "sweaty" // svettig och varm
)
// DebuffEffects är vad varje debuff gör med statsen.
var DebuffEffects = map[DebuffKind]Stats{
SoreFeet: {0.85, 1, 1, 1, 1},
Tired: {0.9, 1, 0.8, 1, 1},
Hangry: {1, 1.2, 0.7, 1, 1},
FoodPoisoning: {1, 1, 1, 2.0, 1},
DOMS: {0.9, 1, 1, 1, 1},
Blisters: {0.8, 1, 1, 1, 1},
HeavyBag: {0.9, 1.2, 1, 1, 1},
Stuffed: {0.9, 1, 1, 1.3, 1},
Sweaty: {1, 1.5, 0.9, 1, 1},
}
// Utlösare för de spelgenererade debuffarna.
const (
SoreFeetSteps = 150
TiredSec = 300.0
FoodPoisonChance = 0.05
FoodPoisonLevels = 2
)
// Debuff är en aktiv debuff med återstående antal banor.
type Debuff struct {
Kind DebuffKind `json:"kind"`
LevelsLeft int `json:"levelsLeft"`
}
// CombinedStats multiplicerar ihop effekterna av alla debuffar.
func CombinedStats(debuffs []Debuff) Stats {
st := DefaultStats()
for _, d := range debuffs {
if e, ok := DebuffEffects[d.Kind]; ok {
for i := range st {
st[i] *= e[i]
}
}
}
return st
}
// Profile är det som följer spelaren mellan banor och inte hör till
// kunskap eller plånbok: aktiva debuffar (items och skills i M8).
type Profile struct {
Debuffs []Debuff `json:"debuffs"`
}
// Add lägger till en debuff; finns den redan förlängs den till det längsta.
func (p *Profile) Add(kind DebuffKind, levels int) {
for i := range p.Debuffs {
if p.Debuffs[i].Kind == kind {
p.Debuffs[i].LevelsLeft = max(p.Debuffs[i].LevelsLeft, levels)
return
}
}
p.Debuffs = append(p.Debuffs, Debuff{Kind: kind, LevelsLeft: levels})
}
// Has rapporterar om debuffen är aktiv.
func (p *Profile) Has(kind DebuffKind) bool {
for _, d := range p.Debuffs {
if d.Kind == kind {
return true
}
}
return false
}
// EndLevel räknar ned alla debuffar en bana och tar bort de som gått ut.
// Returnerar de som försvann.
func (p *Profile) EndLevel() []DebuffKind {
var expired []DebuffKind
kept := p.Debuffs[:0]
for _, d := range p.Debuffs {
d.LevelsLeft--
if d.LevelsLeft <= 0 {
expired = append(expired, d.Kind)
continue
}
kept = append(kept, d)
}
p.Debuffs = kept
return expired
}
// Stats är de sammanlagda multiplikatorerna just nu.
func (p *Profile) Stats() Stats { return CombinedStats(p.Debuffs) }

View File

@@ -0,0 +1,45 @@
package sim
import "testing"
func TestDebuffEffectsAndProfile(t *testing.T) {
if DefaultStats() != (Stats{1, 1, 1, 1, 1}) {
t.Fatal("default")
}
for kind, e := range DebuffEffects {
changed := false
for i := range e {
if e[i] != 1 {
changed = true
}
}
if !changed {
t.Errorf("%s påverkar inget", kind)
}
}
st := CombinedStats([]Debuff{{SoreFeet, 1}, {Blisters, 1}})
if st[StatSpeed] < 0.67 || st[StatSpeed] > 0.69 || st[StatThirst] != 1 {
t.Fatalf("kombinerad fart %.3f", st[StatSpeed])
}
var p Profile
p.Add(Tired, 1)
p.Add(DOMS, 3)
p.Add(Tired, 2) // förlängs, inte dubbleras
if len(p.Debuffs) != 2 || !p.Has(Tired) || p.Debuffs[0].LevelsLeft != 2 {
t.Fatalf("debuffs=%v", p.Debuffs)
}
if p.Stats()[StatSpeed] != 0.9*0.9 {
t.Fatalf("stats=%v", p.Stats())
}
if exp := p.EndLevel(); len(exp) != 0 || len(p.Debuffs) != 2 {
t.Fatalf("efter bana 1: exp=%v debuffs=%v", exp, p.Debuffs)
}
if exp := p.EndLevel(); len(exp) != 1 || exp[0] != Tired || len(p.Debuffs) != 1 || p.Debuffs[0].Kind != DOMS {
t.Fatalf("efter bana 2: exp=%v debuffs=%v", exp, p.Debuffs)
}
p.EndLevel()
if len(p.Debuffs) != 0 || p.Has(DOMS) {
t.Fatal("DOMS ska ha gått ut efter tre banor")
}
}

View File

@@ -9,6 +9,7 @@ type Wallet struct {
Battery float64 `json:"battery"` // telefonens batteri 0..100 Battery float64 `json:"battery"` // telefonens batteri 0..100
Ticket string `json:"ticket"` // köpt pappersbiljett: resmålets namn (ja), "" = ingen Ticket string `json:"ticket"` // köpt pappersbiljett: resmålets namn (ja), "" = ingen
TicketFare int `json:"ticketFare"` // vad biljetten kostade TicketFare int `json:"ticketFare"` // vad biljetten kostade
TicketCount int `json:"ticketCount"` // antal biljetter (egna kort: en per person)
Spent int `json:"spent"` // totalt spenderat (poäng i M7) Spent int `json:"spent"` // totalt spenderat (poäng i M7)
} }

View File

@@ -1,21 +1,11 @@
{ {
"id": "002-shinjuku",
"name": {
"en": "Shinjuku Station",
"ja": "新宿駅",
"sv": "Shinjuku station"
},
"difficulty": 2,
"party": 3,
"ticketRequired": true,
"rideStops": 4,
"destination": { "destination": {
"en": "Shibuya", "en": "Shibuya",
"ja": "渋谷", "ja": "渋谷",
"romaji": "Shibuya", "romaji": "Shibuya",
"sv": "Shibuya" "sv": "Shibuya"
}, },
"timeLimitSec": 0, "difficulty": 2,
"fareYen": 200, "fareYen": 200,
"grid": [ "grid": [
"#################################", "#################################",
@@ -23,7 +13,7 @@
"#.#.#.#.#.#.#######.#.#########.#", "#.#.#.#.#.#.#######.#.#########.#",
"#.#W#.#.#.#.#.....#.#...........#", "#.#W#.#.#.#.#.....#.#...........#",
"#.###.#.#.#.#####.#.###########.#", "#.###.#.#.#.#####.#.###########.#",
"#.#...#.#.........#.#.......#...#", "#.#...#.#..i......#.#.......#...#",
"#.#.###.###########.#.#.###.#.###", "#.#.###.###########.#.#.###.#.###",
"#.....#.......#...#.#.#.........#", "#.....#.......#...#.#.#.........#",
"#####.#######.#.#.#.#.#########.#", "#####.#######.#.#.#.#.#########.#",
@@ -35,14 +25,56 @@
"#.#.###.###.#.###.#.#.#######.#.#", "#.#.###.###.#.###.#.#.#######.#.#",
"#...#T#.#..i#.#.#...#.....#...#.#", "#...#T#.#..i#.#.#...#.....#...#.#",
"#####.#.#.#.#.#.#####.#####.###.#", "#####.#.#.#.#.#.#####.#####.###.#",
"#.....#.#.#.#..i....#.......#...#", "#.....#.#.#.#.......#.......#...#",
"#G#.#.#.#.#########.#########.#G#", "#G#.#.#.#.#########.#########.#G#",
"#pG.#...#.....................#P#", "#pG.#...#.....................#P#",
"#################################" "#################################"
], ],
"id": "002-shinjuku",
"maxMissedTrains": 2,
"name": {
"en": "Shinjuku Station",
"ja": "新宿駅",
"sv": "Shinjuku station"
},
"party": 3,
"rideStops": 4,
"seatSelection": true,
"signs": [
{
"text": {
"en": "Shinjuku Station. Track 14 Yamanote Line for Shibuya: far bottom right. Track 8 Chuo Line for Tokyo: far bottom left.",
"ja": "新宿駅 14番線 山手線 渋谷方面 → 南東 8番線 中央線 東京方面 → 南西",
"sv": "Shinjuku station. Spår 14 Yamanote-linjen mot Shibuya: längst ner till höger. Spår 8 Chuo-linjen mot Tokyo: längst ner till vänster."
},
"x": 5,
"y": 1
},
{
"text": {
"en": "\u003c- Track 8 Chuo Line (Tokyo) Track 14 Yamanote Line (Shibuya) -\u003e",
"ja": "← 8番線 中央線 東京方面 14番線 山手線 渋谷方面 →",
"sv": "\u003c- Spår 8 Chuo-linjen (Tokyo) Spår 14 Yamanote-linjen (Shibuya) -\u003e"
},
"x": 11,
"y": 15
},
{
"text": {
"en": "Track 14 Yamanote Line for Shibuya: the gate in the south-east corner.",
"ja": "14番線 山手線 渋谷方面 → 南東口 改札",
"sv": "Spår 14 Yamanote-linjen mot Shibuya: spärren i sydöstra hörnet."
},
"x": 11,
"y": 5
}
],
"ticketRequired": true,
"timeLimitSec": 420,
"trains": [ "trains": [
{ {
"correct": true, "correct": true,
"crowded": true,
"destination": { "destination": {
"en": "Shibuya", "en": "Shibuya",
"ja": "渋谷", "ja": "渋谷",
@@ -59,8 +91,7 @@
"intervalSec": 20, "intervalSec": 20,
"offsetSec": 3 "offsetSec": 3
}, },
"track": "14", "track": "14"
"crowded": true
}, },
{ {
"correct": false, "correct": false,
@@ -82,34 +113,5 @@
}, },
"track": "8" "track": "8"
} }
],
"signs": [
{
"text": {
"en": "Shinjuku Station. Track 14 Yamanote Line for Shibuya: far bottom right. Track 8 Chuo Line for Tokyo: far bottom left.",
"ja": "新宿駅 14番線 山手線 渋谷方面 → 南東 8番線 中央線 東京方面 → 南西",
"sv": "Shinjuku station. Spår 14 Yamanote-linjen mot Shibuya: längst ner till höger. Spår 8 Chuo-linjen mot Tokyo: längst ner till vänster."
},
"x": 5,
"y": 1
},
{
"text": {
"en": "<- Track 8 Chuo Line (Tokyo) Track 14 Yamanote Line (Shibuya) ->",
"ja": "← 8番線 中央線 東京方面 14番線 山手線 渋谷方面 →",
"sv": "<- Spår 8 Chuo-linjen (Tokyo) Spår 14 Yamanote-linjen (Shibuya) ->"
},
"x": 11,
"y": 15
},
{
"text": {
"en": "Track 14 Yamanote Line for Shibuya: the gate in the south-east corner.",
"ja": "14番線 山手線 渋谷方面 → 南東口 改札",
"sv": "Spår 14 Yamanote-linjen mot Shibuya: spärren i sydöstra hörnet."
},
"x": 15,
"y": 17
}
] ]
} }

View File

@@ -0,0 +1,118 @@
{
"destination": {
"en": "Ikebukuro",
"ja": "池袋",
"romaji": "Ikebukuro",
"sv": "Ikebukuro"
},
"difficulty": 3,
"fareYen": 200,
"grid": [
"#################################",
"#S.........W#....i........#.....#",
"#.#####.#.###.###.#####.#.#.#####",
"#.#...#.#.....#...#.....#.#.....#",
"#.#.#.#.#######.###.#####.#.###.#",
"#.#.#.#.....#...#......i..#.#...#",
"#.#.#.#####.#.#.#.#####.#####.###",
"#...#.#...#...#.#.....#.#...#...#",
"#####.#.#.#####.#####.#.#.#.###.#",
"#T#...#.#...#K#.....#.#...#...#.#",
"#.#.###.###.#.#####.#.#######.#.#",
"#.#.#...#...#...#...#...#...#...#",
"#.#.#.#.#.#.#.###.#####.#.#.#####",
"#...#.#.#.#.#.....#...#...#..i..#",
"#.#####.#.#.#######.#.#######.#.#",
"#.....#.#.#.....#...#...#...#.#.#",
"#####.#.#.#####.#.#####.#.#.#.#.#",
"#...#...#.....#.#.#.#...#.#.#.#.#",
"#G#######.#####.#.#.#.###.#.#.#G#",
"#pG...............#.......#...GP#",
"#################################"
],
"id": "003-shibuya",
"individualCards": true,
"maxMissedTrains": 1,
"name": {
"en": "Shibuya Station",
"ja": "渋谷駅",
"sv": "Shibuya station"
},
"party": 4,
"rideStops": 5,
"seatSelection": true,
"signs": [
{
"text": {
"en": "Shibuya Station. Track 2 Yamanote Line for Ikebukuro: south-east. Track 4 Tokyu Toyoko Line for Yokohama: south-west. Tickets at the machine.",
"ja": "渋谷駅 2番線 山手線 池袋方面 → 南東 4番線 東急東横線 横浜方面 → 南西 切符 自動券売機",
"sv": "Shibuya station. Spår 2 Yamanote-linjen mot Ikebukuro: sydöst. Spår 4 Tokyu Toyoko-linjen mot Yokohama: sydväst. Biljetter i automaten."
},
"x": 17,
"y": 1
},
{
"text": {
"en": "\u003c- Track 4 Tokyu Toyoko Line (Yokohama) Track 2 Yamanote Line (Ikebukuro) -\u003e",
"ja": "← 4番線 東急東横線 横浜方面 2番線 山手線 池袋方面 →",
"sv": "\u003c- Spår 4 Tokyu Toyoko-linjen (Yokohama) Spår 2 Yamanote-linjen (Ikebukuro) -\u003e"
},
"x": 23,
"y": 5
},
{
"text": {
"en": "Track 2 Yamanote Line for Ikebukuro: the gate in the south-east corner. Everyone needs a ticket and card balance.",
"ja": "2番線 山手線 池袋方面 → 南東口 改札",
"sv": "Spår 2 Yamanote-linjen mot Ikebukuro: spärren i sydöstra hörnet. Alla behöver biljett och saldo på kortet."
},
"x": 29,
"y": 13
}
],
"ticketRequired": true,
"timeLimitSec": 360,
"trains": [
{
"correct": true,
"crowded": true,
"destination": {
"en": "Ikebukuro",
"ja": "池袋",
"romaji": "Ikebukuro"
},
"line": {
"en": "Yamanote Line",
"ja": "山手線",
"romaji": "Yamanote-sen"
},
"platform": "P",
"schedule": {
"dwellSec": 7,
"intervalSec": 25,
"offsetSec": 6
},
"track": "2"
},
{
"correct": false,
"destination": {
"en": "Yokohama",
"ja": "横浜",
"romaji": "Yokohama"
},
"line": {
"en": "Tokyu Toyoko Line",
"ja": "東急東横線",
"romaji": "Tokyu Toyoko-sen"
},
"platform": "p",
"schedule": {
"dwellSec": 8,
"intervalSec": 30,
"offsetSec": 12
},
"track": "4"
}
]
}

View File

@@ -91,5 +91,41 @@
"mini.crowd.title": "Crowded train - squeeze in", "mini.crowd.title": "Crowded train - squeeze in",
"mini.crowd.hint": "Press Enter when the marker is in the green zone (%d tries left)", "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.title": "On the train - next stop",
"mini.ride.hint": "Enter: get off here (you are going to %s)" "mini.ride.hint": "Enter: get off here (you are going to %s)",
"hud.time_left": "Left",
"hud.debuffs": "Debuffs",
"hud.grumpy": "Someone is grumpy about their seat…",
"hud.poisoned": "That snack tasted odd…",
"hud.need_all_cards": "Gate: %s has no money on the card.",
"hud.need_all_tickets": "Gate: everyone needs a ticket.",
"result.lost_time_up": "Time ran out.",
"result.lost_missed_trains": "Too many missed trains.",
"result.score": "Score",
"result.money": "money",
"result.meters": "meters",
"result.new_debuffs": "New debuffs",
"mini.topup.member": "Whose card to top up?",
"mini.seat.hint": "Pick a seat for %s (arrows, Enter)",
"mini.seat.prefs": "Wishes:",
"seat.front": "← direction of travel",
"seat.window": "window",
"seat.aisle": "aisle",
"seat.left": "left side",
"seat.right": "right side",
"seat.with": "next to %s",
"debuff.sore_feet": "sore feet",
"debuff.tired": "tired",
"debuff.hangry": "hangry",
"debuff.food_poisoning": "food poisoning",
"debuff.doms": "sore muscles",
"debuff.blisters": "blisters",
"debuff.heavy_bag": "heavy bag",
"debuff.stuffed": "stuffed",
"debuff.sweaty": "sweaty and hot",
"hud.cash_short": "Cash",
"hud.missed_short": "Miss",
"result.penalty": "penalty",
"result.lang_points": "language points",
"result.missed_short": "Missed",
"result.map": "map"
} }

View File

@@ -481,5 +481,55 @@
"sv": "Nippori", "sv": "Nippori",
"tier": 3, "tier": 3,
"kind": "station" "kind": "station"
},
"横浜": {
"kana": "よこはま",
"romaji": "Yokohama",
"en": "Yokohama",
"sv": "Yokohama",
"tier": 3,
"kind": "station"
},
"東急東横線": {
"kana": "とうきゅうとうよこせん",
"romaji": "Tokyu Toyoko-sen",
"en": "Tokyu Toyoko Line",
"sv": "Tokyu Toyoko-linjen",
"tier": 3
},
"渋谷駅": {
"kana": "しぶやえき",
"romaji": "Shibuya-eki",
"en": "Shibuya Station",
"sv": "Shibuya station",
"tier": 3
},
"北": {
"kana": "きた",
"romaji": "kita",
"en": "north",
"sv": "norr",
"tier": 2
},
"南": {
"kana": "みなみ",
"romaji": "minami",
"en": "south",
"sv": "söder",
"tier": 2
},
"東": {
"kana": "ひがし",
"romaji": "higashi",
"en": "east",
"sv": "öster",
"tier": 2
},
"西": {
"kana": "にし",
"romaji": "nishi",
"en": "west",
"sv": "väster",
"tier": 2
} }
} }

View File

@@ -91,5 +91,41 @@
"mini.crowd.title": "Fullt tåg - tränga sig på", "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.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.title": "På tåget - nästa station",
"mini.ride.hint": "Enter: kliv av här (du ska till %s)" "mini.ride.hint": "Enter: kliv av här (du ska till %s)",
"hud.time_left": "Kvar",
"hud.debuffs": "Debuffar",
"hud.grumpy": "Någon är sur över sittplatsen…",
"hud.poisoned": "Den där matbiten smakade konstigt…",
"hud.need_all_cards": "Spärren: %s har inte pengar på kortet.",
"hud.need_all_tickets": "Spärren: alla behöver en biljett.",
"result.lost_time_up": "Tiden tog slut.",
"result.lost_missed_trains": "För många missade tåg.",
"result.score": "Poäng",
"result.money": "pengar",
"result.meters": "mätare",
"result.new_debuffs": "Nya debuffar",
"mini.topup.member": "Vems kort ska fyllas på?",
"mini.seat.hint": "Välj plats åt %s (pilar, Enter)",
"mini.seat.prefs": "Önskemål:",
"seat.front": "← färdriktning",
"seat.window": "fönster",
"seat.aisle": "gången",
"seat.left": "vänster sida",
"seat.right": "höger sida",
"seat.with": "bredvid %s",
"debuff.sore_feet": "onda fötter",
"debuff.tired": "trötthet",
"debuff.hangry": "hunger/irritation",
"debuff.food_poisoning": "matförgiftning",
"debuff.doms": "träningsvärk",
"debuff.blisters": "skoskav",
"debuff.heavy_bag": "tungt köp",
"debuff.stuffed": "proppmätt",
"debuff.sweaty": "svettig och varm",
"hud.cash_short": "Kont",
"hud.missed_short": "Miss",
"result.penalty": "avdrag",
"result.lang_points": "språkpoäng",
"result.missed_short": "Missade",
"result.map": "karta"
} }

View File

@@ -56,6 +56,14 @@ func buildShots(levelsDir string) error {
if err != nil { if err != nil {
return err return err
} }
l3, err := level.LoadFile(filepath.Join(levelsDir, "003-shibuya.json"))
if err != nil {
return err
}
toMachine3, err := moves(l3, l3.SpawnPoint(), l3.Find(level.TicketMachine)[0])
if err != nil {
return err
}
// Skyltbilderna tas med några kända ord så att markeringen och den // Skyltbilderna tas med några kända ord så att markeringen och den
// delvisa översättningen syns; hjälpen lär sedan in nästa ord. // delvisa översättningen syns; hjälpen lär sedan in nästa ord.
known := []string{"-know", "改札,番線,方面", "-seed", "7"} known := []string{"-know", "改札,番線,方面", "-seed", "7"}
@@ -68,6 +76,8 @@ func buildShots(levelsDir string) error {
"; 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}, "; 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"}}, {"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", "渋谷"}}, {"002-shinjuku", toMachine2 + "; press enter; wait 5; shot $OUT/ticket.png; quit", []string{"-seed", "7", "-know", "渋谷"}},
{"003-shibuya", toMachine3 + "; press enter; pick 池袋; press enter; wait 5; press enter; press right; wait 5; shot $OUT/seats.png; quit",
[]string{"-seed", "7", "-know", "池袋", "-debuffs", "sore_feet"}},
} }
return nil return nil
} }