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>
20
README.md
@@ -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).
|
||||||
|
|
||||||

|

|
||||||
@@ -39,6 +39,13 @@ på japanska.
|
|||||||
|
|
||||||

|

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

|
||||||
|
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
BIN
doc/img/menu.png
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
BIN
doc/img/ride.png
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
BIN
doc/img/seats.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
BIN
doc/img/sign.png
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@@ -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 |
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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) }
|
||||||
45
internal/sim/stats_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
118
resources/data/levels/003-shibuya.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||