Inlärning (framsteg, stjärnor, ordlista) och M4: lämmeltåget
All checks were successful
release / build-release (push) Successful in 1m49s
Inlärning enligt Björns regler: - lang.Knowledge: framsteg per ord (3 läsningar = känt), språkpoäng, köp av valfria ord (kostnad = tier), Award per stjärna - sim.Run: RunOptions med seed; stjärnor 1–3 mot par (parSec eller kortaste vägen × 0,5 s + 20 s); ≥2 stjärnor lär resmålets ord; att stanna på skylt/perrong ger seedad 35 % chans till framsteg per okänt ord - game: ordlista (L) med kända ord, framsteg och kostnad; stjärnor, poäng och inlärt resmål på resultatskärmen; framstegsnotis; flaggor -points/-seed M4 lämmeltåget: - sim.Party: medlemmar med mat/dryck/toa-mätare, behov under tröskel, seedade händelser var 45 s / antal (toa, törst, hunger, distraktion), fartstraff, toalett (W, 3 s/person) och kiosk (K, 150 ¥/person), följare på ledarens spår - level: rutor W och K, party och parSec i bandatan; banor med sällskap måste ha toalett och kiosk (testas); 001 har 2, 002 har 3 i sällskapet - game: följare med färgton, behovsmarkering över huvudet, mätarpanel, facilitetsrutor med förlopp; genart ritar toalett och kiosk - skriptläget: move stannar exakt på målrutan även när farten inte är bildruteexakt (sällskapets fartstraff), stall-skydd mot väggar - docs/README/wiki/spelbeskrivning uppdaterade Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
26
README.md
@@ -10,10 +10,11 @@ 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:** M3 klar (2026-10-23) – skyltarna är på japanska. Ord du kan
|
**Status:** M4 klar (2026-10-23) – skyltarna är på japanska och du lär dig
|
||||||
lyser gult och översätts, resten står kvar tills du frågar någon om hjälp
|
ord genom att läsa, fråga om hjälp, klara banor med bra tid eller lägga
|
||||||
och lär dig ett ord till. Lätta banor har engelska på skyltarna, svåra inte.
|
språkpoäng i ordlistan. Lämmeltåget följer efter dig: polarna blir hungriga,
|
||||||
Nästa är M4: lämmeltåget och mätarna. Se [doc/roadmap.md](doc/roadmap.md).
|
törstiga och måste på toa, oftare ju fler ni är, och du löser det vid kiosker
|
||||||
|
och toaletter. Nästa är M5: minikartan. Se [doc/roadmap.md](doc/roadmap.md).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -24,9 +25,16 @@ väntar några sekunder och får hela skylten översatt plus ett nytt ord.
|
|||||||
Spärrarna släpper bara igenom dig om kortet har saldo. På perrongen (gul
|
Spärrarna släpper bara igenom dig om kortet har saldo. På perrongen (gul
|
||||||
ledlinje) är avgångstavlan också japansk. Står tåget inne trycker du Enter
|
ledlinje) är avgångstavlan också japansk. Står tåget inne trycker du Enter
|
||||||
för att kliva på. Rätt tåg tar dig till resmålet, fel tåg avslutar banan.
|
för att kliva på. Rätt tåg tar dig till resmålet, fel tåg avslutar banan.
|
||||||
|
Klarar du banan under par får du stjärnor, språkpoäng och resmålets namn.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
Ditt sällskap följer efter i en rad. Mätarna uppe till höger visar mat,
|
||||||
|
dryck och toa för var och en; faller de får någon ett behov som saktar ned
|
||||||
|
er alla tills ni hittar en kiosk eller en toalett.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
Fler bilder och en genomgång finns i
|
Fler bilder och en genomgång finns i
|
||||||
[wikin](https://gitea.brasse-pc.eu/brasse/metro-puzzle-jp/wiki).
|
[wikin](https://gitea.brasse-pc.eu/brasse/metro-puzzle-jp/wiki).
|
||||||
|
|
||||||
@@ -101,16 +109,18 @@ metro-puzzle-jp # meny
|
|||||||
metro-puzzle-jp -level 001-flygplatsen # hoppa direkt in i en bana
|
metro-puzzle-jp -level 001-flygplatsen # hoppa direkt in i en bana
|
||||||
metro-puzzle-jp -scale 3 # fönsterstorlek (320x180 × skala)
|
metro-puzzle-jp -scale 3 # fönsterstorlek (320x180 × skala)
|
||||||
metro-puzzle-jp -smoke 90 # öppna fönster, avsluta efter 90 bildrutor (smoke-test)
|
metro-puzzle-jp -smoke 90 # öppna fönster, avsluta efter 90 bildrutor (smoke-test)
|
||||||
metro-puzzle-jp -know 改札,番線 # japanska ord du kan från start (tester/skärmdumpar)
|
metro-puzzle-jp -know 改札,番線 -points 3 # förkunskap och språkpoäng (tester/skärmdumpar)
|
||||||
|
metro-puzzle-jp -seed 7 # fast seed för händelser och läsning (tester/skärmdumpar)
|
||||||
metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go
|
metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go
|
||||||
metro-puzzle-jp -version
|
metro-puzzle-jp -version
|
||||||
```
|
```
|
||||||
|
|
||||||
### Styrning
|
### Styrning
|
||||||
|
|
||||||
Pilar eller WASD går, Enter väljer/kliver på, H frågar om hjälp vid en
|
Pilar eller WASD går. Enter väljer, kliver på tåget, använder toaletten
|
||||||
skylt, Esc går tillbaka, R spelar om. Gamepad med standardlayout fungerar:
|
eller handlar i kiosken. H frågar om hjälp vid en skylt, L öppnar
|
||||||
d-pad/vänster spak, A = välj, B = tillbaka, Y = hjälp.
|
ordlistan, Esc går tillbaka, R spelar om. Gamepad med standardlayout
|
||||||
|
fungerar: d-pad/vänster spak, A = välj, B = tillbaka, Y = hjälp, X = ordlista.
|
||||||
|
|
||||||
### Verktyg
|
### Verktyg
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ func main() {
|
|||||||
smoke := flag.Int("smoke", 0, "avsluta automatiskt efter N bildrutor (smoke-test)")
|
smoke := flag.Int("smoke", 0, "avsluta automatiskt efter N bildrutor (smoke-test)")
|
||||||
script := flag.String("script", "", "skriptade steg för skärmdumpar/tester, t.ex. \"wait 10; shot menu.png; quit\" (se internal/game/script.go)")
|
script := flag.String("script", "", "skriptade steg för skärmdumpar/tester, t.ex. \"wait 10; shot menu.png; quit\" (se internal/game/script.go)")
|
||||||
know := flag.String("know", "", "japanska ord spelaren kan från start, kommaseparerade (tester/skärmdumpar)")
|
know := flag.String("know", "", "japanska ord spelaren kan från start, kommaseparerade (tester/skärmdumpar)")
|
||||||
|
points := flag.Int("points", 0, "språkpoäng från start (tester/skärmdumpar)")
|
||||||
|
seed := flag.Uint64("seed", 0, "fast seed för banorna, 0 = slumpad (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()
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know)})
|
g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("kunde inte starta: %v", err)
|
log.Fatalf("kunde inte starta: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
BIN
doc/img/menu.png
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 24 KiB |
BIN
doc/img/sign.png
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 24 KiB |
BIN
doc/img/words.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
@@ -98,6 +98,14 @@ att bygga på Linux som på Windows.
|
|||||||
| `internal/lang` | `TestHelpSeconds`, `TestEnglishShareAndDeterminism` | Hjälptiden sjunker med nivån men inte under golvet; engelska-andelen per svårighet (1 = alltid, 5 = aldrig), deterministisk, ~60 % på svårighet 2 |
|
| `internal/lang` | `TestHelpSeconds`, `TestEnglishShareAndDeterminism` | Hjälptiden sjunker med nivån men inte under golvet; engelska-andelen per svårighet (1 = alltid, 5 = aldrig), deterministisk, ~60 % på svårighet 2 |
|
||||||
| `internal/lang` | `TestParseDictionaryValidation`, `TestShippedDictionary`, `TestAllJapaneseInLevelsIsInDictionary` | Ordboken är komplett per post och **täcker all japanska i banorna** |
|
| `internal/lang` | `TestParseDictionaryValidation`, `TestShippedDictionary`, `TestAllJapaneseInLevelsIsInDictionary` | Ordboken är komplett per post och **täcker all japanska i banorna** |
|
||||||
| `internal/sim` | `TestAskHelpRevealsSignAndTeachesWord`, `TestHelpIsCancelledByWalkingAway`, `TestNoHelpOffSign` | Hjälpen tar rätt tid, avslöjar skylten, lär första okända ordet; avbryts när man går; kräver skylt |
|
| `internal/sim` | `TestAskHelpRevealsSignAndTeachesWord`, `TestHelpIsCancelledByWalkingAway`, `TestNoHelpOffSign` | Hjälpen tar rätt tid, avslöjar skylten, lär första okända ordet; avbryts när man går; kräver skylt |
|
||||||
|
| `internal/lang` | `TestProgressLearnsAfterEnoughReads`, `TestBuyWordsWithPoints` | Tre framsteg lär ordet; köp för poäng (kostnad = tier), stjärnor ger poäng |
|
||||||
|
| `internal/level` | `TestPartyAndParDefaults` | Sällskap/par-standardvärden, max sällskap, toalett/kiosk passerbara, avgångstavlans text |
|
||||||
|
| `internal/sim` | `TestStarsFromParTime`, `TestGoodResultTeachesDestination` | Par från kortaste vägen eller bandatan, stjärntrösklar; tre stjärnor lär resmålet och ger poäng, långsam vinst gör det inte |
|
||||||
|
| `internal/sim` | `TestReadingGivesSeededProgress` | Att stanna på en skylt ger seedade framsteg; samma seed = samma utfall; ord lossnar efter många läsningar |
|
||||||
|
| `internal/sim` | `TestMetersDecayAtRates`, `TestUrgeArisesBelowThreshold` | Mätarnas takt; behov under tröskeln |
|
||||||
|
| `internal/sim` | `TestMoreMembersMoreEvents`, `TestEventsAreSeeded` | **M4:** fyra i sällskapet ger minst 3× så många händelser som en; samma seed = samma logg |
|
||||||
|
| `internal/sim` | `TestUrgesSlowAndFacilitiesRestore`, `TestFollowersTrailLeader`, `TestPartySizeIsClamped` | Fartstraff, toalett/kiosk återställer och kostar, distraktion går över, följarna står på ledarens spår |
|
||||||
|
| `internal/sim` | `TestToiletAndKioskInRun`, `TestPartySlowsLeader` | Enter på W startar ett besök som tar tid och avbryts om man går; K drar från kortet; sällskapet saktar ledaren |
|
||||||
| `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan |
|
| `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan |
|
||||||
|
|
||||||
### Planerade tester per milstolpe
|
### Planerade tester per milstolpe
|
||||||
@@ -107,7 +115,7 @@ att bygga på Linux som på Windows.
|
|||||||
| ~~M1 Rörelse & kamera~~ ✓ | Kollision mot vägg, rörelse i 4 riktningar, kamera klampas till kartan – finns, se tabellen ovan |
|
| ~~M1 Rörelse & kamera~~ ✓ | Kollision mot vägg, rörelse i 4 riktningar, kamera klampas till kartan – finns, se tabellen ovan |
|
||||||
| ~~M2 Stationen~~ ✓ | Tidtabell, spärr med/utan saldo, vinst/förlust, bot klarar bana 001 headless – finns, se tabellen ovan |
|
| ~~M2 Stationen~~ ✓ | Tidtabell, spärr med/utan saldo, vinst/förlust, bot klarar bana 001 headless – finns, se tabellen ovan |
|
||||||
| ~~M3 Japanska & skyltar~~ ✓ | Delvis översättning, engelska-andel, ordbokens fullständighet och täckning, hjälptid – finns, se tabellen ovan |
|
| ~~M3 Japanska & skyltar~~ ✓ | Delvis översättning, engelska-andel, ordbokens fullständighet och täckning, hjälptid – finns, se tabellen ovan |
|
||||||
| M4 Lämmeltåget | Mätare sjunker med rätt takt; event-frekvens ökar med gruppstorlek; event väljs deterministiskt per seed |
|
| ~~M4 Lämmeltåget~~ ✓ | Mätare, händelsefrekvens efter gruppstorlek, seedade händelser – finns, se tabellen ovan |
|
||||||
| M5 Minikarta | Upptäckta rutor markeras, återställs per bana; avgångar syns från start |
|
| M5 Minikarta | Upptäckta rutor markeras, återställs per bana; avgångar syns från start |
|
||||||
| M6 Kort/biljetter/minispel | Saldo dras i spärr; tomt kort → automatkrav; biljettminispel: rätt/fel biljett; telefon-skanning misslyckas med given procent per seed; batteri tar bara slut över tröskeltid |
|
| M6 Kort/biljetter/minispel | Saldo dras i spärr; tomt kort → automatkrav; biljettminispel: rätt/fel biljett; telefon-skanning misslyckas med given procent per seed; batteri tar bara slut över tröskeltid |
|
||||||
| M7 Svårighet/poäng/debuffs | Poängformeln (tid, pengar, mätare, läge); tidsgräns och maxantal missade tåg; varje debuff ändrar rätt stat; temporära debuffar räknar ned 1–4 banor |
|
| M7 Svårighet/poäng/debuffs | Poängformeln (tid, pengar, mätare, läge); tidsgräns och maxantal missade tåg; varje debuff ändrar rätt stat; temporära debuffar räknar ned 1–4 banor |
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ brasse-linux01-runnern, inte Pi5:n.
|
|||||||
## Milstolpar
|
## Milstolpar
|
||||||
|
|
||||||
M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar ✓
|
M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar ✓
|
||||||
→ M4 lämmeltåget → M5 minikarta → M6 kort/biljetter/minispel → M7
|
→ M4 lämmeltåget ✓ → M5 minikarta → M6 kort/biljetter/minispel → M7
|
||||||
svårighet/poäng/debuffar → M8 progression/sparfiler → M9 navigeringslägen →
|
svårighet/poäng/debuffar → M8 progression/sparfiler → M9 navigeringslägen →
|
||||||
M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release.
|
M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release.
|
||||||
Innehåll per milstolpe: [roadmap.md › Milstolpar](roadmap.md#milstolpar).
|
Innehåll per milstolpe: [roadmap.md › Milstolpar](roadmap.md#milstolpar).
|
||||||
@@ -59,7 +59,8 @@ Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md).
|
|||||||
- ~~Repo-namn~~ – beslutat: `metro-puzzle-jp` (Gitea 2026-10-23). Visningsnamnet "Metro Puzzle JP" är bara text i `resources/text/`.
|
- ~~Repo-namn~~ – beslutat: `metro-puzzle-jp` (Gitea 2026-10-23). Visningsnamnet "Metro Puzzle JP" är bara text i `resources/text/`.
|
||||||
- "Game box-liknande" banval: tolkat som spelbräde/världskarta – stämmer det?
|
- "Game box-liknande" banval: tolkat som spelbräde/världskarta – stämmer det?
|
||||||
- ~~Font~~ – beslutat: PixelMplus 10/12 px (M3).
|
- ~~Font~~ – beslutat: PixelMplus 10/12 px (M3).
|
||||||
- Hur ska spelaren lära sig ord utöver "fråga om hjälp"? Förslag: att klara
|
- ~~Hur lär man sig ord utöver hjälp?~~ – beslutat av Björn 2026-10-23:
|
||||||
en bana lär ut resmålets namn; skill tree i M8 kan ge kana direkt.
|
bra resultat lär resmålet, läsning ger seedade framsteg, språkpoäng på
|
||||||
|
valfria ord i ordlistan. Implementerat; trädet (M8) bygger vidare på listan.
|
||||||
- Ska Oändligt läge dela sparfil med story eller ha egen?
|
- Ska Oändligt läge dela sparfil med story eller ha egen?
|
||||||
- Hur många huvudbanor siktar vi på i storyn? (Påverkar M10:s omfattning.)
|
- Hur många huvudbanor siktar vi på i storyn? (Påverkar M10:s omfattning.)
|
||||||
|
|||||||
@@ -188,8 +188,18 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
|
|||||||
- **Hintar efter nivå:** från nivå 1 visar HUD:en resmålet på japanska (att
|
- **Hintar efter nivå:** från nivå 1 visar HUD:en resmålet på japanska (att
|
||||||
matcha mot skyltarna), från nivå 2 visas läsningen efter kända ord.
|
matcha mot skyltarna), från nivå 2 visas läsningen efter kända ord.
|
||||||
- Kunskapen följer med mellan banor under en session (`Game.Know`); att
|
- Kunskapen följer med mellan banor under en session (`Game.Know`); att
|
||||||
spara den på disk är M8. Flaggan `-know ord,ord` ger förkunskap för
|
spara den på disk är M8. Flaggorna `-know ord,ord`, `-points N` och
|
||||||
tester och skärmdumpar.
|
`-seed N` ger förkunskap och determinism för tester och skärmdumpar.
|
||||||
|
- **Inlärning utöver hjälp** (Björns regler, tillägg samma dag):
|
||||||
|
- **Bra resultat lär ut resmålet.** Vinst ger 1–3 stjärnor efter tid mot
|
||||||
|
par (`parSec` i bandatan, annars kortaste vägen × 0,5 s + 20 s); från
|
||||||
|
två stjärnor lärs resmålets ord in.
|
||||||
|
- **Läsning ger framsteg.** Varje gång spelaren stannar på en skylt eller
|
||||||
|
perrong har varje okänt ord 35 % chans (seedat per bana, skylt och
|
||||||
|
besök) att få +1 framsteg; vid 3 framsteg kan man ordet.
|
||||||
|
- **Språkpoäng** (1 per stjärna) läggs på valfria ord i **ordlistan**
|
||||||
|
(L i menyn och på resultatskärmen): hela ordboken sorterad på tier,
|
||||||
|
kända ord, framsteg och kostnad (= tier). Blir ett riktigt träd i M8.
|
||||||
- **Teknik:** `internal/lang` (ren Go, 11 tester + datatester), `sim.Run`
|
- **Teknik:** `internal/lang` (ren Go, 11 tester + datatester), `sim.Run`
|
||||||
(hjälp-tillstånd, avslöjade skyltar), `internal/game/text.go`
|
(hjälp-tillstånd, avslöjade skyltar), `internal/game/text.go`
|
||||||
(pixelbaserad radbrytning av färgade textsegment).
|
(pixelbaserad radbrytning av färgade textsegment).
|
||||||
@@ -198,17 +208,30 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
|
|||||||
`TestAskHelpRevealsSignAndTeachesWord`); bana 001 spelas med japanska
|
`TestAskHelpRevealsSignAndTeachesWord`); bana 001 spelas med japanska
|
||||||
skyltar. ✓
|
skyltar. ✓
|
||||||
|
|
||||||
### M4 – Lämmeltåget och mätarna
|
### M4 – Lämmeltåget och mätarna ✓ (klar 2026-10-23)
|
||||||
|
|
||||||
- **Mål:** sällskapet följer med och ställer till det.
|
- **Mål:** sällskapet följer med och ställer till det.
|
||||||
- Följare som går i rad efter spelaren.
|
- Följare som går i rad efter spelaren längs ledarens spår (`party` i
|
||||||
- Mätare mat/dryck/toa per karaktär; händelser (toabesök, köpa dricka,
|
bandatan: 001 har två, 002 tre; max fem). Egna färgtoner tills de får
|
||||||
distraktion) vars frekvens skalar med gruppstorlek.
|
egna sprites.
|
||||||
- Toaletter, kiosker och automater som rutor i banan.
|
- Mätare mat/dryck/toa per person (100 → 0 på ~10/7/8 min). Under 25
|
||||||
- **Teknik:** `internal/sim/party`, `internal/sim/events` (seedade via
|
uppstår ett behov av sig självt.
|
||||||
`internal/rng`).
|
- **Händelser** var `45 s / antal i sällskapet` (± 30 %), slumpade från
|
||||||
- **Klar när:** fler i gruppen ger mätbart fler händelser i test; händelser
|
omgångens seed: någon måste på toa, blir törstig, hungrig eller
|
||||||
går att lösa i spelet.
|
distraherad. Fler i lämmeltåget = oftare, testat.
|
||||||
|
- Behov saktar ned hela sällskapet (×0,8; distraktion ×0,5 i 4 s; tom
|
||||||
|
mätare ×0,5) och syns som markering över huvudet och i HUD:en.
|
||||||
|
- **Toalett** (W, Enter: 3 s per person) och **kiosk** (K, Enter: 150 ¥
|
||||||
|
per person från kortet) återställer mätarna och tar bort behoven.
|
||||||
|
Mätarpanel uppe till höger.
|
||||||
|
- **Teknik:** `sim.Party` (ren Go, 7 tester), `sim.Run` (fart, faciliteter),
|
||||||
|
nya rutor `W`/`K` i `level` och `tools/genart`; banor med sällskap måste ha
|
||||||
|
minst en toalett och en kiosk (testas).
|
||||||
|
- **Klar när:** fler i gruppen ger mätbart fler händelser i test
|
||||||
|
(`TestMoreMembersMoreEvents`), händelser är seedade
|
||||||
|
(`TestEventsAreSeeded`) och går att lösa i spelet (`TestToiletAndKioskInRun`). ✓
|
||||||
|
- **Skjutet till M7:** mätarnas värde vid målgång påverkar poängen; debuffar
|
||||||
|
vid tomma mätare.
|
||||||
|
|
||||||
### M5 – Minikarta och utforskning
|
### M5 – Minikarta och utforskning
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,19 @@ otydligt står tolkningen i [§15](#15-tolkningar-av-originalet).
|
|||||||
- Huvudsyftet med stationslabyrinten är att navigera med symboler och
|
- Huvudsyftet med stationslabyrinten är att navigera med symboler och
|
||||||
skyltar, med stöd av dina skills.
|
skyltar, med stöd av dina skills.
|
||||||
|
|
||||||
|
**Så lär man sig ord** (tillägg 2026-10-23):
|
||||||
|
|
||||||
|
- **Bra poäng på en bana lär ut resmålets namn.** Klarar man banan med
|
||||||
|
tillräckligt bra resultat kan man resmålets ord efteråt.
|
||||||
|
- **Ord man läser kan ge framsteg.** Varje gång man läser en skylt har varje
|
||||||
|
okänt ord på den en chans att ge ett steg framåt. Har man fått tillräckligt
|
||||||
|
många steg kan man ordet.
|
||||||
|
- **Fråga någon om hjälp** översätter skylten och lär ut ett ord direkt (§6
|
||||||
|
ovan).
|
||||||
|
- **Språkpoäng kan läggas på valfria ord.** Man tjänar språkpoäng på banor
|
||||||
|
och kan i sitt språkträd/sin ordlista välja fritt vilka ord ur hela listan
|
||||||
|
man vill lära sig, mot en kostnad i poäng.
|
||||||
|
|
||||||
## 7. Minikarta
|
## 7. Minikarta
|
||||||
|
|
||||||
- Visar bara **avgångar** och några **riktmärken** som man kan förhålla sig
|
- Visar bara **avgångar** och några **riktmärken** som man kan förhålla sig
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ const LineH = 12
|
|||||||
// Ordningen på rutorna i resources/sprites/tiles.png (måste matcha tools/genart).
|
// Ordningen på rutorna i resources/sprites/tiles.png (måste matcha tools/genart).
|
||||||
var tileOrder = []level.Tile{
|
var tileOrder = []level.Tile{
|
||||||
level.Wall, level.Floor, level.Spawn, level.Platform, level.Gate, level.TicketMachine, level.Sign,
|
level.Wall, level.Floor, level.Spawn, level.Platform, level.Gate, level.TicketMachine, level.Sign,
|
||||||
|
level.Wall, // plats 7 = tågvagnen (laddas separat som Train)
|
||||||
|
level.Toilet, level.Kiosk,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trainIndex = 7
|
||||||
|
|
||||||
// Assets är allt som laddats från resources/.
|
// Assets är allt som laddats från resources/.
|
||||||
type Assets struct {
|
type Assets struct {
|
||||||
ResDir string
|
ResDir string
|
||||||
@@ -55,14 +59,17 @@ func LoadAssets() (*Assets, error) {
|
|||||||
sub := func(i int) *ebiten.Image {
|
sub := func(i int) *ebiten.Image {
|
||||||
return tiles.SubImage(image.Rect(i*TileSize, 0, (i+1)*TileSize, TileSize)).(*ebiten.Image)
|
return tiles.SubImage(image.Rect(i*TileSize, 0, (i+1)*TileSize, TileSize)).(*ebiten.Image)
|
||||||
}
|
}
|
||||||
if w, need := tiles.Bounds().Dx(), TileSize*(len(tileOrder)+1); w < need {
|
if w, need := tiles.Bounds().Dx(), TileSize*len(tileOrder); w < need {
|
||||||
return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, need)
|
return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, need)
|
||||||
}
|
}
|
||||||
for i, t := range tileOrder {
|
for i, t := range tileOrder {
|
||||||
|
if i == trainIndex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
a.Tiles[t] = sub(i)
|
a.Tiles[t] = sub(i)
|
||||||
}
|
}
|
||||||
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
|
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
|
||||||
a.Train = sub(len(tileOrder))
|
a.Train = sub(trainIndex)
|
||||||
|
|
||||||
player, err := loadImage("sprites", "player.png")
|
player, err := loadImage("sprites", "player.png")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/hajimehoshi/ebiten/v2"
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
|
|
||||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
||||||
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Intern upplösning. Allt ritas i denna storlek och skalas sedan upp med
|
// Intern upplösning. Allt ritas i denna storlek och skalas sedan upp med
|
||||||
@@ -29,6 +30,7 @@ type Scene interface {
|
|||||||
type Game struct {
|
type Game struct {
|
||||||
Assets *Assets
|
Assets *Assets
|
||||||
Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8)
|
Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8)
|
||||||
|
fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar)
|
||||||
scene Scene
|
scene Scene
|
||||||
next Scene
|
next Scene
|
||||||
smokeLeft int // >0: avsluta automatiskt efter så många uppdateringar
|
smokeLeft int // >0: avsluta automatiskt efter så många uppdateringar
|
||||||
@@ -42,6 +44,8 @@ type Options struct {
|
|||||||
SmokeFrames int // >0 avslutar efter N uppdateringar (smoke-test)
|
SmokeFrames int // >0 avslutar efter N uppdateringar (smoke-test)
|
||||||
Script string // skriptade steg för skärmdumpar/tester, se script.go
|
Script string // skriptade steg för skärmdumpar/tester, se script.go
|
||||||
KnownWords []string // ord spelaren kan från start (för tester/skärmdumpar)
|
KnownWords []string // ord spelaren kan från start (för tester/skärmdumpar)
|
||||||
|
Points int // språkpoäng från start (för tester/skärmdumpar)
|
||||||
|
Seed uint64 // >0: fast seed för banorna (för tester/skärmdumpar)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New laddar resurser och bygger spelet.
|
// New laddar resurser och bygger spelet.
|
||||||
@@ -54,13 +58,15 @@ func New(opt Options) (*Game, error) {
|
|||||||
for _, w := range opt.KnownWords {
|
for _, w := range opt.KnownWords {
|
||||||
g.Know.Learn(w)
|
g.Know.Learn(w)
|
||||||
}
|
}
|
||||||
|
g.Know.Points = opt.Points
|
||||||
|
g.fixedSeed = opt.Seed
|
||||||
if opt.Script != "" {
|
if opt.Script != "" {
|
||||||
if g.script, err = parseScript(opt.Script); err != nil {
|
if g.script, err = parseScript(opt.Script); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if opt.LevelID != "" {
|
if opt.LevelID != "" {
|
||||||
s, err := NewLevelScene(a, opt.LevelID, g.Know)
|
s, err := NewLevelScene(a, opt.LevelID, g.Know, g.seed())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -104,3 +110,11 @@ func (g *Game) Draw(screen *ebiten.Image) {
|
|||||||
|
|
||||||
// Layout talar om för Ebitengine vilken intern upplösning vi ritar i.
|
// Layout talar om för Ebitengine vilken intern upplösning vi ritar i.
|
||||||
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { return ScreenW, ScreenH }
|
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { return ScreenW, ScreenH }
|
||||||
|
|
||||||
|
// seed ger seeden för en ny banomgång: fast om -seed angetts, annars färsk.
|
||||||
|
func (g *Game) seed() rng.Seed {
|
||||||
|
if g.fixedSeed > 0 {
|
||||||
|
return rng.FromInt(g.fixedSeed)
|
||||||
|
}
|
||||||
|
return rng.NewSeed()
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ func helpJustPressed() bool {
|
|||||||
return virtual.pressed["h"] || inpututil.IsKeyJustPressed(ebiten.KeyH) || gamepadJustPressed(ebiten.StandardGamepadButtonRightTop)
|
return virtual.pressed["h"] || inpututil.IsKeyJustPressed(ebiten.KeyH) || gamepadJustPressed(ebiten.StandardGamepadButtonRightTop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wordsJustPressed: L = ordlistan/språkträdet.
|
||||||
|
func wordsJustPressed() bool {
|
||||||
|
return virtual.pressed["l"] || inpututil.IsKeyJustPressed(ebiten.KeyL) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftRight)
|
||||||
|
}
|
||||||
|
|
||||||
// menuMove returnerar -1/+1 när användaren just tryckte upp/ner i en meny.
|
// menuMove returnerar -1/+1 när användaren just tryckte upp/ner i en meny.
|
||||||
func menuMove() int {
|
func menuMove() int {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -5,22 +5,22 @@ import (
|
|||||||
"image/color"
|
"image/color"
|
||||||
"math"
|
"math"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/hajimehoshi/ebiten/v2"
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||||
|
|
||||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
||||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||||
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
|
||||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Spelarens grundfart i rutor per sekund (blir en stat i M7).
|
|
||||||
const playerSpeed = 6.0
|
|
||||||
|
|
||||||
// Hur länge tillfälliga meddelanden visas.
|
// Hur länge tillfälliga meddelanden visas.
|
||||||
const (
|
const (
|
||||||
deniedShowSec = 2.0
|
deniedShowSec = 2.0
|
||||||
learnedShowSec = 3.0
|
learnedShowSec = 3.0
|
||||||
|
progressedShowSec = 2.5
|
||||||
)
|
)
|
||||||
|
|
||||||
// LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run;
|
// LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run;
|
||||||
@@ -33,15 +33,15 @@ type LevelScene struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewLevelScene laddar banan och ställer spelaren på startrutan. know är
|
// NewLevelScene laddar banan och ställer spelaren på startrutan. know är
|
||||||
// spelarens japanska, som delas mellan banor.
|
// spelarens japanska, som delas mellan banor; seed styr omgångens slump.
|
||||||
func NewLevelScene(a *Assets, id string, know *lang.Knowledge) (*LevelScene, error) {
|
func NewLevelScene(a *Assets, id string, know *lang.Knowledge, 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, playerSpeed, a.Dict, know),
|
run: sim.NewRun(lvl, sim.RunOptions{Dict: a.Dict, Know: know, 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()
|
||||||
@@ -51,6 +51,9 @@ func NewLevelScene(a *Assets, id string, know *lang.Knowledge) (*LevelScene, err
|
|||||||
// PlayerTile returnerar rutan spelaren står på/lämnar (används av skriptläget).
|
// PlayerTile returnerar rutan spelaren står på/lämnar (används av skriptläget).
|
||||||
func (s *LevelScene) PlayerTile() level.Point { return s.run.Player.Tile }
|
func (s *LevelScene) PlayerTile() level.Point { return s.run.Player.Tile }
|
||||||
|
|
||||||
|
// PlayerMoving rapporterar om spelaren är mellan två rutor (skriptläget).
|
||||||
|
func (s *LevelScene) PlayerMoving() bool { return s.run.Player.Moving() }
|
||||||
|
|
||||||
// TrainHere rapporterar om spelaren står på en perrong vars tåg är inne (skriptläget).
|
// TrainHere rapporterar om spelaren står på en perrong vars tåg är inne (skriptläget).
|
||||||
func (s *LevelScene) TrainHere() bool {
|
func (s *LevelScene) TrainHere() bool {
|
||||||
tr, at, _ := s.run.PlatformHere()
|
tr, at, _ := s.run.PlatformHere()
|
||||||
@@ -131,14 +134,73 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
px, py := s.run.Player.WorldPos(TileSize)
|
// Följarna ritas bakifrån så att ledaren hamnar överst.
|
||||||
|
leader := s.run.Player
|
||||||
|
followers := s.run.Party.Followers()
|
||||||
|
for i := len(followers); i >= 1; i-- {
|
||||||
|
from, to := s.run.Party.FollowerTiles(i)
|
||||||
|
fx := float64(from.X*TileSize) + float64((to.X-from.X)*TileSize)*leader.Progress
|
||||||
|
fy := float64(from.Y*TileSize) + float64((to.Y-from.Y)*TileSize)*leader.Progress
|
||||||
|
facing := sim.Down
|
||||||
|
switch {
|
||||||
|
case to.X > from.X:
|
||||||
|
facing = sim.Right
|
||||||
|
case to.X < from.X:
|
||||||
|
facing = sim.Left
|
||||||
|
case to.Y < from.Y:
|
||||||
|
facing = sim.Up
|
||||||
|
}
|
||||||
|
op.GeoM.Reset()
|
||||||
|
op.GeoM.Translate(ox+math.Round(fx), oy+math.Round(fy))
|
||||||
|
op.ColorScale.Reset()
|
||||||
|
t := memberTints[i%len(memberTints)]
|
||||||
|
op.ColorScale.Scale(t[0], t[1], t[2], 1)
|
||||||
|
screen.DrawImage(g.Assets.PlayerFrame(facing, leader.Moving() && from != to, s.animT), &op)
|
||||||
|
op.ColorScale.Reset()
|
||||||
|
s.drawUrge(g, screen, followers[i-1], int(ox+math.Round(fx)), int(oy+math.Round(fy)))
|
||||||
|
}
|
||||||
|
|
||||||
|
px, py := leader.WorldPos(TileSize)
|
||||||
op.GeoM.Reset()
|
op.GeoM.Reset()
|
||||||
op.GeoM.Translate(ox+math.Round(px), oy+math.Round(py))
|
op.GeoM.Translate(ox+math.Round(px), oy+math.Round(py))
|
||||||
screen.DrawImage(g.Assets.PlayerFrame(s.run.Player.Facing, s.run.Player.Moving(), s.animT), &op)
|
screen.DrawImage(g.Assets.PlayerFrame(leader.Facing, leader.Moving(), s.animT), &op)
|
||||||
|
s.drawUrge(g, screen, s.run.Party.Members[0], int(ox+math.Round(px)), int(oy+math.Round(py)))
|
||||||
|
|
||||||
s.drawHUD(g, screen)
|
s.drawHUD(g, screen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// memberTints färgar följarnas sprites tills de har egna.
|
||||||
|
var memberTints = [][3]float32{{1, 1, 1}, {1, 0.75, 0.55}, {0.7, 0.95, 0.75}, {0.8, 0.7, 1}, {1, 0.95, 0.55}}
|
||||||
|
|
||||||
|
// urgeMark är tecknet som visas över huvudet på någon med ett behov.
|
||||||
|
func urgeMark(u sim.Urge) (string, color.Color) {
|
||||||
|
switch u {
|
||||||
|
case sim.NeedToilet:
|
||||||
|
return "WC", colBad
|
||||||
|
case sim.Thirsty:
|
||||||
|
return "飲", colEnglish
|
||||||
|
case sim.Hungry:
|
||||||
|
return "食", colWarn
|
||||||
|
case sim.Distracted:
|
||||||
|
return "?", colDim
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LevelScene) drawUrge(g *Game, screen *ebiten.Image, m *sim.Member, x, y int) {
|
||||||
|
mark, col := urgeMark(m.Urge)
|
||||||
|
if mark == "" {
|
||||||
|
if m.Meters.Min() <= 0 {
|
||||||
|
mark, col = "!!", colBad
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w := g.Assets.Width(mark)
|
||||||
|
vector.DrawFilledRect(screen, float32(x+8-w/2-1), float32(y-11), float32(w+2), 11, colPanel, false)
|
||||||
|
g.Assets.Draw(screen, mark, x+8-w/2, y-12, col)
|
||||||
|
}
|
||||||
|
|
||||||
// drawHUD ritar den övre raden (bana, resmål, tid, kort, japanska-nivå) och
|
// drawHUD ritar den övre raden (bana, resmål, tid, kort, japanska-nivå) och
|
||||||
// inforutan (perrong, skylt, nekad spärr, nytt ord).
|
// inforutan (perrong, skylt, nekad spärr, nytt ord).
|
||||||
func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
|
func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
|
||||||
@@ -152,9 +214,9 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
|
|||||||
}
|
}
|
||||||
a.Draw(screen, r.Level.Name.Get(UILang), 4, 0, colText)
|
a.Draw(screen, r.Level.Name.Get(UILang), 4, 0, colText)
|
||||||
a.Draw(screen, a.T("hud.destination")+": "+dest, 4+a.Width(r.Level.Name.Get(UILang))+12, 0, colKnown)
|
a.Draw(screen, a.T("hud.destination")+": "+dest, 4+a.Width(r.Level.Name.Get(UILang))+12, 0, colKnown)
|
||||||
a.Draw(screen, fmt.Sprintf("%s %3.0fs %s %d %s %d¥ %s Lv%d·%d %s", a.T("hud.time"), r.Time,
|
a.Draw(screen, fmt.Sprintf("%s %3.0fs %s %d %s %d¥ %s Lv%d·%d", a.T("hud.time"), r.Time,
|
||||||
a.T("hud.steps"), r.Steps, a.T("hud.card"), r.Card.Balance, a.T("hud.japanese"), r.Know.Level(), r.Know.Count(),
|
a.T("hud.steps"), r.Steps, a.T("hud.card"), r.Card.Balance, a.T("hud.japanese"), r.Know.Level(), r.Know.Count()), 4, LineH, colDim)
|
||||||
a.T("hud.controls")), 4, LineH, colDim)
|
s.drawMeters(g, screen)
|
||||||
|
|
||||||
if r.Outcome != sim.Ongoing {
|
if r.Outcome != sim.Ongoing {
|
||||||
return
|
return
|
||||||
@@ -164,6 +226,16 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
|
|||||||
lines = append(lines, plainLine(fmt.Sprintf("%s: %s = %s (%s)", a.T("hud.learned"), r.Learned,
|
lines = append(lines, plainLine(fmt.Sprintf("%s: %s = %s (%s)", a.T("hud.learned"), r.Learned,
|
||||||
entry.Translation(UILang), entry.Romaji), colKnown))
|
entry.Translation(UILang), entry.Romaji), colKnown))
|
||||||
}
|
}
|
||||||
|
if len(r.Progressed) > 0 && r.Time-r.ProgressedAt < progressedShowSec {
|
||||||
|
lines = append(lines, a.wrapLine(plainLine(a.T("hud.progress")+": "+strings.Join(r.Progressed, ", "), colWarn), ScreenW-8)...)
|
||||||
|
}
|
||||||
|
if urges := r.Party.Urges(); len(urges) > 0 {
|
||||||
|
var parts []string
|
||||||
|
for _, m := range urges {
|
||||||
|
parts = append(parts, m.Name+": "+a.T("urge."+m.Urge.String()))
|
||||||
|
}
|
||||||
|
lines = append(lines, a.wrapLine(plainLine(strings.Join(parts, " "), colWarn), ScreenW-8)...)
|
||||||
|
}
|
||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -182,9 +254,13 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
|
|||||||
vector.DrawFilledRect(screen, 0, float32(y), ScreenW, 2, accent, false)
|
vector.DrawFilledRect(screen, 0, float32(y), ScreenW, 2, accent, false)
|
||||||
a.drawLines(screen, lines, 4, y+4)
|
a.drawLines(screen, lines, 4, y+4)
|
||||||
if bar {
|
if bar {
|
||||||
|
p := r.HelpProgress()
|
||||||
|
if r.Facility.Active {
|
||||||
|
p = r.FacilityProgress()
|
||||||
|
}
|
||||||
by := float32(y + h - 7)
|
by := float32(y + h - 7)
|
||||||
vector.DrawFilledRect(screen, 4, by, ScreenW-8, 4, colBar, false)
|
vector.DrawFilledRect(screen, 4, by, ScreenW-8, 4, colBar, false)
|
||||||
vector.DrawFilledRect(screen, 4, by, float32((ScreenW-8)*r.HelpProgress()), 4, colKnown, false)
|
vector.DrawFilledRect(screen, 4, by, float32((ScreenW-8)*p), 4, colKnown, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,8 +275,7 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
|
|||||||
|
|
||||||
if tr, at, secs := r.PlatformHere(); tr != nil {
|
if tr, at, secs := r.PlatformHere(); tr != nil {
|
||||||
// Avgångstavlan är en skylt som alla andra: japanska, sedd genom kunskapen.
|
// Avgångstavlan är en skylt som alla andra: japanska, sedd genom kunskapen.
|
||||||
ja := 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"))
|
rd := lang.Render(a.Dict, r.Know, tr.BoardText(), UILang)
|
||||||
rd := lang.Render(a.Dict, r.Know, ja, UILang)
|
|
||||||
lines = append(lines, a.wrapLine(shownLine(rd.Original, colText, r.Know.ShowsReadings()), maxW)...)
|
lines = append(lines, a.wrapLine(shownLine(rd.Original, colText, r.Know.ShowsReadings()), maxW)...)
|
||||||
lines = append(lines, a.wrapLine(shownLine(rd.Partial, colText, false), maxW)...)
|
lines = append(lines, a.wrapLine(shownLine(rd.Partial, colText, false), maxW)...)
|
||||||
if english {
|
if english {
|
||||||
@@ -240,8 +315,46 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
|
|||||||
return lines, accent, bar
|
return lines, accent, bar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch lvl.At(tile.X, tile.Y) {
|
||||||
|
case level.Toilet:
|
||||||
|
if r.Facility.Active {
|
||||||
|
return []line{plainLine(a.T("hud.toilet_busy"), colDim)}, colGood, true
|
||||||
|
}
|
||||||
|
if n := len(r.Party.NeedsToilet()); n > 0 {
|
||||||
|
return []line{plainLine(fmt.Sprintf(a.T("hud.toilet_prompt"), n, sim.ToiletSecPerMember*float64(n)), colGood)}, colGood, false
|
||||||
|
}
|
||||||
|
return []line{plainLine(a.T("hud.toilet_none"), colDim)}, accent, false
|
||||||
|
case level.Kiosk:
|
||||||
|
if n := len(r.Party.NeedsKiosk()); n > 0 {
|
||||||
|
return []line{plainLine(fmt.Sprintf(a.T("hud.kiosk_prompt"), n, sim.KioskPriceYen*n), colGood)}, colGood, false
|
||||||
|
}
|
||||||
|
return []line{plainLine(a.T("hud.kiosk_none"), colDim)}, accent, false
|
||||||
|
}
|
||||||
if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec {
|
if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec {
|
||||||
return []line{plainLine(a.T("hud.gate_denied"), colBad)}, colBad, false
|
return []line{plainLine(a.T("hud.gate_denied"), colBad)}, colBad, false
|
||||||
}
|
}
|
||||||
return nil, accent, false
|
return nil, accent, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drawMeters ritar sällskapets mat/dryck/toa-mätare uppe till höger.
|
||||||
|
func (s *LevelScene) drawMeters(g *Game, screen *ebiten.Image) {
|
||||||
|
members := s.run.Party.Members
|
||||||
|
const w, gap = 14, 4
|
||||||
|
x0 := ScreenW - 4 - len(members)*(w+gap)
|
||||||
|
for i, m := range members {
|
||||||
|
x := float32(x0 + i*(w+gap))
|
||||||
|
g.Assets.Draw(screen, string([]rune(m.Name)[:1]), int(x)+3, 0, colDim)
|
||||||
|
for j, v := range []struct {
|
||||||
|
val float64
|
||||||
|
col color.Color
|
||||||
|
}{{m.Meters.Food, colWarn}, {m.Meters.Drink, colEnglish}, {m.Meters.Toilet, colGood}} {
|
||||||
|
y := float32(13 + j*3)
|
||||||
|
vector.DrawFilledRect(screen, x, y, w, 2, colBar, false)
|
||||||
|
c := v.col
|
||||||
|
if v.val < sim.UrgeThreshold {
|
||||||
|
c = colBad
|
||||||
|
}
|
||||||
|
vector.DrawFilledRect(screen, x, y, float32(w*v.val/100), 2, c, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,10 +54,14 @@ func (m *MenuScene) Update(g *Game) error {
|
|||||||
if backJustPressed() {
|
if backJustPressed() {
|
||||||
return ebiten.Termination
|
return ebiten.Termination
|
||||||
}
|
}
|
||||||
|
if wordsJustPressed() {
|
||||||
|
g.SetScene(NewWordsScene(g.Assets, m))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
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)
|
s, err := NewLevelScene(g.Assets, m.entries[m.sel].id, g.Know, g.seed())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.err = err
|
m.err = err
|
||||||
return nil
|
return nil
|
||||||
@@ -87,7 +91,8 @@ func (m *MenuScene) Draw(g *Game, screen *ebiten.Image) {
|
|||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
a.Draw(screen, "FEL: "+m.err.Error(), 8, ScreenH-40, colBad)
|
a.Draw(screen, "FEL: "+m.err.Error(), 8, ScreenH-40, colBad)
|
||||||
}
|
}
|
||||||
a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s", a.T("hud.japanese"), g.Know.Level(), g.Know.Count(), a.T("hud.words")), 8, ScreenH-38, colDim)
|
a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d", a.T("hud.japanese"), g.Know.Level(), g.Know.Count(), a.T("hud.words"),
|
||||||
|
a.T("words.points"), g.Know.Points), 8, ScreenH-38, colDim)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package game
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/hajimehoshi/ebiten/v2"
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||||
@@ -19,8 +20,10 @@ func NewResultScene(from *LevelScene) *ResultScene { return &ResultScene{from: f
|
|||||||
|
|
||||||
func (r *ResultScene) Update(g *Game) error {
|
func (r *ResultScene) Update(g *Game) error {
|
||||||
switch {
|
switch {
|
||||||
|
case wordsJustPressed():
|
||||||
|
g.SetScene(NewWordsScene(g.Assets, r))
|
||||||
case inpututil_R():
|
case inpututil_R():
|
||||||
s, err := NewLevelScene(g.Assets, r.from.id, g.Know)
|
s, err := NewLevelScene(g.Assets, r.from.id, g.Know, g.seed())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -47,6 +50,10 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
|
|||||||
}
|
}
|
||||||
vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 3, accent, false)
|
vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 3, accent, false)
|
||||||
a.DrawBig(screen, headline, 28, 44, accent)
|
a.DrawBig(screen, headline, 28, 44, accent)
|
||||||
|
if run.Outcome == sim.Won {
|
||||||
|
stars := strings.Repeat("★", run.Stars) + strings.Repeat("☆", 3-run.Stars)
|
||||||
|
a.DrawBig(screen, stars, ScreenW-28-a.Width(stars)-8, 44, colWarn)
|
||||||
|
}
|
||||||
|
|
||||||
y := 62
|
y := 62
|
||||||
if tr := run.Boarded; tr != nil {
|
if tr := run.Boarded; tr != nil {
|
||||||
@@ -64,10 +71,18 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
y += 4
|
y += 4
|
||||||
a.Draw(screen, fmt.Sprintf("%s %.1fs %s %d %s %d %s %d¥", a.T("hud.time"), run.Time,
|
a.Draw(screen, fmt.Sprintf("%s %.1fs (%s %.0fs) %s %d %s %d %s %d¥", a.T("hud.time"), run.Time, a.T("result.par"), run.ParSec(),
|
||||||
a.T("hud.steps"), run.Steps, a.T("result.gates"), run.GatePasses, a.T("result.card"), run.Card.Balance), 28, y, colText)
|
a.T("hud.steps"), run.Steps, a.T("result.gates"), run.GatePasses, a.T("result.card"), run.Card.Balance), 28, y, colText)
|
||||||
y += LineH
|
y += LineH
|
||||||
a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s (%d %s)", a.T("hud.japanese"), run.Know.Level(), run.Know.Count(),
|
if run.Outcome == sim.Won {
|
||||||
a.T("hud.words"), run.Know.Helps, a.T("result.helps")), 28, y, colDim)
|
msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("words.points"))
|
||||||
|
if len(run.LearnedDest) > 0 {
|
||||||
|
msg += " " + a.T("hud.learned") + ": " + strings.Join(run.LearnedDest, " ") + " (" + a.T("result.destination") + ")"
|
||||||
|
}
|
||||||
|
a.Draw(screen, msg, 28, y, colKnown)
|
||||||
|
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.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, 130, colDim)
|
a.Draw(screen, a.T("result.hint"), 28, 130, colDim)
|
||||||
}
|
}
|
||||||
|
|||||||
112
internal/game/scene_words.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image/color"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
|
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||||
|
|
||||||
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WordsScene är ordlistan/språkträdet: alla ord i ordboken, vad spelaren
|
||||||
|
// kan, framsteg på resten, och möjligheten att lägga språkpoäng på valfria
|
||||||
|
// ord. Blir ett riktigt träd med skills i M8.
|
||||||
|
type WordsScene struct {
|
||||||
|
back Scene
|
||||||
|
entries []*lang.Entry
|
||||||
|
sel int
|
||||||
|
top int
|
||||||
|
msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordsRows = 9
|
||||||
|
|
||||||
|
// NewWordsScene listar ordboken sorterad på tier och ord.
|
||||||
|
func NewWordsScene(a *Assets, back Scene) *WordsScene {
|
||||||
|
var es []*lang.Entry
|
||||||
|
for _, w := range a.Dict.Words() {
|
||||||
|
es = append(es, a.Dict.Lookup(w))
|
||||||
|
}
|
||||||
|
sort.Slice(es, func(i, j int) bool {
|
||||||
|
if es[i].Tier != es[j].Tier {
|
||||||
|
return es[i].Tier < es[j].Tier
|
||||||
|
}
|
||||||
|
return es[i].Word < es[j].Word
|
||||||
|
})
|
||||||
|
return &WordsScene{back: back, entries: es}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WordsScene) Update(g *Game) error {
|
||||||
|
if backJustPressed() || wordsJustPressed() {
|
||||||
|
g.SetScene(w.back)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if n := len(w.entries); n > 0 {
|
||||||
|
w.sel = (w.sel + menuMove() + n) % n
|
||||||
|
if w.sel < w.top {
|
||||||
|
w.top = w.sel
|
||||||
|
}
|
||||||
|
if w.sel >= w.top+wordsRows {
|
||||||
|
w.top = w.sel - wordsRows + 1
|
||||||
|
}
|
||||||
|
if confirmJustPressed() {
|
||||||
|
e := w.entries[w.sel]
|
||||||
|
switch {
|
||||||
|
case g.Know.Knows(e.Word):
|
||||||
|
w.msg = g.Assets.T("words.already")
|
||||||
|
case g.Know.Buy(e):
|
||||||
|
w.msg = fmt.Sprintf(g.Assets.T("words.bought"), e.Word, e.Translation(UILang))
|
||||||
|
default:
|
||||||
|
w.msg = fmt.Sprintf(g.Assets.T("words.too_expensive"), lang.WordCost(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WordsScene) Draw(g *Game, screen *ebiten.Image) {
|
||||||
|
a := g.Assets
|
||||||
|
k := g.Know
|
||||||
|
screen.Fill(color.RGBA{24, 20, 37, 255})
|
||||||
|
vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false)
|
||||||
|
a.DrawBig(screen, a.T("words.title"), 8, 5, colText)
|
||||||
|
hdr := fmt.Sprintf("%s %d %s Lv%d %d/%d %s", a.T("words.points"), k.Points, a.T("hud.japanese"),
|
||||||
|
k.Level(), k.Count(), len(w.entries), a.T("hud.words"))
|
||||||
|
a.Draw(screen, hdr, ScreenW-8-a.Width(hdr), 6, colKnown)
|
||||||
|
|
||||||
|
y0 := 30
|
||||||
|
for i := w.top; i < len(w.entries) && i < w.top+wordsRows; i++ {
|
||||||
|
e := w.entries[i]
|
||||||
|
y := y0 + (i-w.top)*LineH
|
||||||
|
if i == w.sel {
|
||||||
|
vector.DrawFilledRect(screen, 4, float32(y-1), ScreenW-8, LineH, colBar, false)
|
||||||
|
}
|
||||||
|
status, scol := strings.Repeat("●", lang.WordCost(e)), colDim
|
||||||
|
if k.CanBuy(e) {
|
||||||
|
scol = colKnown
|
||||||
|
}
|
||||||
|
wcol := colText
|
||||||
|
switch {
|
||||||
|
case k.Knows(e.Word):
|
||||||
|
status, scol, wcol = "✓", colGood, colKnown
|
||||||
|
case k.ProgressOf(e.Word) > 0:
|
||||||
|
status, scol = fmt.Sprintf("%d/%d", k.ProgressOf(e.Word), lang.ProgressToLearn), colWarn
|
||||||
|
}
|
||||||
|
a.Draw(screen, e.Word, 8, y, wcol)
|
||||||
|
a.Draw(screen, e.Romaji, 92, y, colDim)
|
||||||
|
a.Draw(screen, e.Translation(UILang), 180, y, colText)
|
||||||
|
a.Draw(screen, status, ScreenW-8-a.Width(status), y, scol)
|
||||||
|
}
|
||||||
|
// rullningsindikator
|
||||||
|
if len(w.entries) > wordsRows {
|
||||||
|
a.Draw(screen, fmt.Sprintf("%d-%d / %d", w.top+1, min(len(w.entries), w.top+wordsRows), len(w.entries)), 8, ScreenH-38, colDim)
|
||||||
|
}
|
||||||
|
if w.msg != "" {
|
||||||
|
a.Draw(screen, w.msg, 8, ScreenH-26, colKnown)
|
||||||
|
}
|
||||||
|
a.Draw(screen, a.T("words.hint"), 8, ScreenH-13, colDim)
|
||||||
|
}
|
||||||
@@ -20,8 +20,8 @@ import (
|
|||||||
//
|
//
|
||||||
// wait N vänta N bildrutor
|
// wait N vänta N bildrutor
|
||||||
// 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
|
||||||
// press KEY tryck enter | esc | r | 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 (släpper när rutorna är avklarade)
|
// 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)
|
||||||
// quit avsluta spelet
|
// quit avsluta spelet
|
||||||
//
|
//
|
||||||
@@ -29,18 +29,22 @@ import (
|
|||||||
|
|
||||||
const shotScale = 3
|
const shotScale = 3
|
||||||
|
|
||||||
|
// moveStallFrames: så många bildrutor utan ny ruta räknas som "vägg" och
|
||||||
|
// avslutar ett move-steg (annars skulle skriptet hänga för evigt).
|
||||||
|
const moveStallFrames = 120
|
||||||
|
|
||||||
type scriptStep struct {
|
type scriptStep struct {
|
||||||
cmd string
|
cmd string
|
||||||
arg string
|
arg string
|
||||||
n int
|
n int
|
||||||
dir sim.Dir
|
dir sim.Dir
|
||||||
done bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type script struct {
|
type script struct {
|
||||||
steps []scriptStep
|
steps []scriptStep
|
||||||
i int
|
i int
|
||||||
counter int
|
counter int
|
||||||
|
stall int
|
||||||
lastPos level.Point
|
lastPos level.Point
|
||||||
havePos bool
|
havePos bool
|
||||||
}
|
}
|
||||||
@@ -108,7 +112,10 @@ func parseDir(s string) sim.Dir {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// playerTiler implementeras av scener som har en spelare i rutnätet.
|
// playerTiler implementeras av scener som har en spelare i rutnätet.
|
||||||
type playerTiler interface{ PlayerTile() level.Point }
|
type playerTiler interface {
|
||||||
|
PlayerTile() level.Point
|
||||||
|
PlayerMoving() bool
|
||||||
|
}
|
||||||
|
|
||||||
// trainWaiter implementeras av scener där man kan vänta på ett tåg.
|
// trainWaiter implementeras av scener där man kan vänta på ett tåg.
|
||||||
type trainWaiter interface{ TrainHere() bool }
|
type trainWaiter interface{ TrainHere() bool }
|
||||||
@@ -139,26 +146,39 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error {
|
|||||||
}
|
}
|
||||||
cur := pt.PlayerTile()
|
cur := pt.PlayerTile()
|
||||||
if !s.havePos {
|
if !s.havePos {
|
||||||
s.lastPos, s.havePos = cur, true
|
s.lastPos, s.havePos, s.stall = cur, true, 0
|
||||||
}
|
}
|
||||||
if cur != s.lastPos {
|
if cur != s.lastPos {
|
||||||
s.lastPos = cur
|
s.lastPos = cur
|
||||||
s.counter++
|
s.counter++
|
||||||
|
s.stall = 0
|
||||||
|
} else {
|
||||||
|
s.stall++
|
||||||
}
|
}
|
||||||
if s.counter >= st.n {
|
if s.counter >= st.n && !pt.PlayerMoving() {
|
||||||
s.advance()
|
s.advance()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if s.stall > moveStallFrames { // blockerad av vägg: ge upp steget
|
||||||
|
s.advance()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Håll riktningen tills sista rutan har påbörjats; släpp sedan så att
|
||||||
|
// figuren stannar exakt där. Hålls tangenten kvar i ankomstbildrutan
|
||||||
|
// fortsätter figuren annars en ruta till när farten (t.ex. med
|
||||||
|
// sällskapets fartstraff) inte är bildruteexakt.
|
||||||
|
if s.counter < st.n-1 || (s.counter == st.n-1 && !pt.PlayerMoving()) {
|
||||||
virtual.held = st.dir
|
virtual.held = st.dir
|
||||||
|
}
|
||||||
|
case "waittrain":
|
||||||
|
if tw, ok := g.scene.(trainWaiter); !ok || tw.TrainHere() {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
s.advance()
|
s.advance()
|
||||||
case "waittrain":
|
|
||||||
if tw, ok := g.scene.(trainWaiter); !ok || tw.TrainHere() {
|
|
||||||
s.advance()
|
|
||||||
}
|
|
||||||
case "quit":
|
case "quit":
|
||||||
return ebiten.Termination
|
return ebiten.Termination
|
||||||
}
|
}
|
||||||
@@ -168,6 +188,7 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error {
|
|||||||
func (s *script) advance() {
|
func (s *script) advance() {
|
||||||
s.i++
|
s.i++
|
||||||
s.counter = 0
|
s.counter = 0
|
||||||
|
s.stall = 0
|
||||||
s.havePos = false
|
s.havePos = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import "math"
|
|||||||
// Knowledge är vad spelaren kan: ord ur ordboken och om hen kan läsa kana.
|
// Knowledge är vad spelaren kan: ord ur ordboken och om hen kan läsa kana.
|
||||||
type Knowledge struct {
|
type Knowledge struct {
|
||||||
Words map[string]bool `json:"words"`
|
Words map[string]bool `json:"words"`
|
||||||
|
Progress map[string]int `json:"progress"` // framsteg på ord som ännu inte är kända
|
||||||
|
Points int `json:"points"` // språkpoäng att lägga på valfria ord
|
||||||
Kana bool `json:"kana"`
|
Kana bool `json:"kana"`
|
||||||
Helps int `json:"helps"` // antal gånger spelaren frågat om hjälp
|
Helps int `json:"helps"` // antal gånger spelaren frågat om hjälp
|
||||||
}
|
}
|
||||||
@@ -20,7 +22,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NewKnowledge börjar från noll.
|
// NewKnowledge börjar från noll.
|
||||||
func NewKnowledge() *Knowledge { return &Knowledge{Words: map[string]bool{}} }
|
func NewKnowledge() *Knowledge {
|
||||||
|
return &Knowledge{Words: map[string]bool{}, Progress: map[string]int{}}
|
||||||
|
}
|
||||||
|
|
||||||
// Knows rapporterar om ordet är känt.
|
// Knows rapporterar om ordet är känt.
|
||||||
func (k *Knowledge) Knows(word string) bool { return k.Words[word] }
|
func (k *Knowledge) Knows(word string) bool { return k.Words[word] }
|
||||||
@@ -115,3 +119,69 @@ func ShowEnglish(levelID string, x, y, difficulty int) bool {
|
|||||||
}
|
}
|
||||||
return float64(h%1000)/1000 < share
|
return float64(h%1000)/1000 < share
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inlärning utöver hjälp (Björns regler 2026-10-23):
|
||||||
|
// - bra resultat på en bana lär ut resmålets ord (StarsToLearnDestination),
|
||||||
|
// - varje läst okänt ord har chansen ReadChance att ge +1 framsteg; vid
|
||||||
|
// ProgressToLearn framsteg kan man ordet,
|
||||||
|
// - språkpoäng (Points) tjänas på banor och läggs på valfria ord i ordlistan.
|
||||||
|
const (
|
||||||
|
ReadChance = 0.35
|
||||||
|
ProgressToLearn = 3
|
||||||
|
StarsToLearnDestination = 2
|
||||||
|
PointsPerStar = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProgressOf är hur många framsteg ordet har (0 om känt eller aldrig läst).
|
||||||
|
func (k *Knowledge) ProgressOf(word string) int {
|
||||||
|
if k.Progress == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return k.Progress[word]
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddProgress ger ett framsteg på ett okänt ord. Returnerar true om ordet
|
||||||
|
// därmed blev känt.
|
||||||
|
func (k *Knowledge) AddProgress(word string) bool {
|
||||||
|
if k.Knows(word) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if k.Progress == nil {
|
||||||
|
k.Progress = map[string]int{}
|
||||||
|
}
|
||||||
|
k.Progress[word]++
|
||||||
|
if k.Progress[word] >= ProgressToLearn {
|
||||||
|
delete(k.Progress, word)
|
||||||
|
k.Learn(word)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// WordCost är vad ett ord kostar i språkpoäng: dess tier (minst 1).
|
||||||
|
func WordCost(e *Entry) int { return max(1, e.Tier) }
|
||||||
|
|
||||||
|
// CanBuy rapporterar om ordet går att köpa för poängen.
|
||||||
|
func (k *Knowledge) CanBuy(e *Entry) bool {
|
||||||
|
return e != nil && !k.Knows(e.Word) && k.Points >= WordCost(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buy lär in ordet mot språkpoäng. Returnerar false om det inte går.
|
||||||
|
func (k *Knowledge) Buy(e *Entry) bool {
|
||||||
|
if !k.CanBuy(e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
k.Points -= WordCost(e)
|
||||||
|
if k.Progress != nil {
|
||||||
|
delete(k.Progress, e.Word)
|
||||||
|
}
|
||||||
|
k.Learn(e.Word)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Award ger språkpoäng för stjärnor på en klarad bana.
|
||||||
|
func (k *Knowledge) Award(stars int) int {
|
||||||
|
p := max(0, stars) * PointsPerStar
|
||||||
|
k.Points += p
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|||||||
@@ -208,3 +208,43 @@ func TestCharClasses(t *testing.T) {
|
|||||||
t.Fatal("HasKana/IsJapanese")
|
t.Fatal("HasKana/IsJapanese")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProgressLearnsAfterEnoughReads(t *testing.T) {
|
||||||
|
k := NewKnowledge()
|
||||||
|
for i := 1; i < ProgressToLearn; i++ {
|
||||||
|
if k.AddProgress("改札") || k.ProgressOf("改札") != i || k.Knows("改札") {
|
||||||
|
t.Fatalf("efter %d framsteg: progress=%d known=%v", i, k.ProgressOf("改札"), k.Knows("改札"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !k.AddProgress("改札") || !k.Knows("改札") || k.ProgressOf("改札") != 0 {
|
||||||
|
t.Fatal("tredje framsteget ska lära ordet och nollställa framstegen")
|
||||||
|
}
|
||||||
|
if k.AddProgress("改札") {
|
||||||
|
t.Fatal("kända ord får inga framsteg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuyWordsWithPoints(t *testing.T) {
|
||||||
|
d := testDict()
|
||||||
|
k := NewKnowledge()
|
||||||
|
e1, e3 := d.Lookup("改札"), d.Lookup("新宿駅") // tier 1 resp. 3
|
||||||
|
if WordCost(e1) != 1 || WordCost(e3) != 3 {
|
||||||
|
t.Fatal("kostnad = tier")
|
||||||
|
}
|
||||||
|
if k.CanBuy(e1) || k.Buy(e1) {
|
||||||
|
t.Fatal("utan poäng ska inget gå att köpa")
|
||||||
|
}
|
||||||
|
if got := k.Award(3); got != 3*PointsPerStar || k.Points != got {
|
||||||
|
t.Fatalf("Award = %d, points=%d", got, k.Points)
|
||||||
|
}
|
||||||
|
k.Progress["新宿駅"] = 2
|
||||||
|
if !k.Buy(e3) || !k.Knows("新宿駅") || k.Points != 0 || k.ProgressOf("新宿駅") != 0 {
|
||||||
|
t.Fatalf("köp misslyckades: points=%d known=%v", k.Points, k.Knows("新宿駅"))
|
||||||
|
}
|
||||||
|
if k.CanBuy(e3) {
|
||||||
|
t.Fatal("redan känt ord ska inte gå att köpa")
|
||||||
|
}
|
||||||
|
if k.Award(-2) != 0 || k.Points != 0 {
|
||||||
|
t.Fatal("negativa stjärnor ger inget")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,11 +29,13 @@ const (
|
|||||||
Gate Tile = 'G' // spärr (kräver kort/biljett)
|
Gate Tile = 'G' // spärr (kräver kort/biljett)
|
||||||
TicketMachine Tile = 'T' // biljett-/påfyllningsautomat
|
TicketMachine Tile = 'T' // biljett-/påfyllningsautomat
|
||||||
Sign Tile = 'i' // skylt
|
Sign Tile = 'i' // skylt
|
||||||
|
Toilet Tile = 'W' // toalett (sällskapets toamätare)
|
||||||
|
Kiosk Tile = 'K' // kiosk/automat för mat och dryck
|
||||||
)
|
)
|
||||||
|
|
||||||
var knownTiles = map[Tile]bool{
|
var knownTiles = map[Tile]bool{
|
||||||
Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true,
|
Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true,
|
||||||
Gate: true, TicketMachine: true, Sign: true,
|
Gate: true, TicketMachine: true, Sign: true, Toilet: true, Kiosk: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passable rapporterar om en ruta går att gå på (utan hänsyn till spärrar).
|
// Passable rapporterar om en ruta går att gå på (utan hänsyn till spärrar).
|
||||||
@@ -69,6 +71,7 @@ const (
|
|||||||
DefaultIntervalSec = 30.0
|
DefaultIntervalSec = 30.0
|
||||||
DefaultDwellSec = 10.0
|
DefaultDwellSec = 10.0
|
||||||
DefaultFareYen = 200
|
DefaultFareYen = 200
|
||||||
|
MaxParty = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
// Status returnerar om tåget står vid perrongen vid tiden t, och hur många
|
// Status returnerar om tåget står vid perrongen vid tiden t, och hur många
|
||||||
@@ -108,6 +111,8 @@ type Level struct {
|
|||||||
Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår)
|
Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår)
|
||||||
Destination Text `json:"destination"` // dit spelaren ska
|
Destination Text `json:"destination"` // dit spelaren ska
|
||||||
TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns
|
TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns
|
||||||
|
ParSec int `json:"parSec"` // "bra tid" för tre stjärnor (0 = räknas ut från kortaste vägen)
|
||||||
|
Party int `json:"party"` // antal i sällskapet inkl. spelaren (0 = 1)
|
||||||
FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard)
|
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"`
|
||||||
@@ -229,8 +234,14 @@ func (l *Level) Validate() error {
|
|||||||
if l.FareYen == 0 {
|
if l.FareYen == 0 {
|
||||||
l.FareYen = DefaultFareYen
|
l.FareYen = DefaultFareYen
|
||||||
}
|
}
|
||||||
if l.FareYen < 0 {
|
if l.FareYen < 0 || l.ParSec < 0 || l.Party < 0 {
|
||||||
return fmt.Errorf("level %s: negativ avgift", l.ID)
|
return fmt.Errorf("level %s: negativ avgift/partid/sällskap", l.ID)
|
||||||
|
}
|
||||||
|
if l.Party == 0 {
|
||||||
|
l.Party = 1
|
||||||
|
}
|
||||||
|
if l.Party > MaxParty {
|
||||||
|
return fmt.Errorf("level %s: sällskapet får vara högst %d", l.ID, MaxParty)
|
||||||
}
|
}
|
||||||
// Skyltar: varje post måste stå på en 'i'-ruta och varje 'i'-ruta ha text.
|
// Skyltar: varje post måste stå på en 'i'-ruta och varje 'i'-ruta ha text.
|
||||||
seen := map[Point]bool{}
|
seen := map[Point]bool{}
|
||||||
@@ -369,7 +380,7 @@ func (l *Level) UngatedPlatforms() []Point {
|
|||||||
func (l *Level) Unreachable() []Point {
|
func (l *Level) Unreachable() []Point {
|
||||||
reach := l.Reachable()
|
reach := l.Reachable()
|
||||||
var out []Point
|
var out []Point
|
||||||
for _, t := range []Tile{Platform, WrongPlatform, TicketMachine, Sign, Gate} {
|
for _, t := range []Tile{Platform, WrongPlatform, TicketMachine, Sign, Gate, Toilet, Kiosk} {
|
||||||
for _, p := range l.Find(t) {
|
for _, p := range l.Find(t) {
|
||||||
if !reach[p] {
|
if !reach[p] {
|
||||||
out = append(out, p)
|
out = append(out, p)
|
||||||
@@ -379,6 +390,24 @@ func (l *Level) Unreachable() []Point {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BoardText är avgångstavlans text på japanska, t.ex. "2番線 成田エクスプレス 新宿方面".
|
||||||
|
func (tr *Train) BoardText() string {
|
||||||
|
return tr.Track + "番線 " + tr.Line.Get("ja") + " " + tr.Destination.Get("ja") + "方面"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CorrectPlatform returnerar en perrongruta med rätt tåg (valideringen
|
||||||
|
// garanterar att det finns minst ett rätt tåg vid en befintlig perrong).
|
||||||
|
func (l *Level) CorrectPlatform() Point {
|
||||||
|
for _, tr := range l.Trains {
|
||||||
|
if tr.Correct {
|
||||||
|
if pts := l.Find(Tile(tr.Platform[0])); len(pts) > 0 {
|
||||||
|
return pts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return l.SpawnPoint()
|
||||||
|
}
|
||||||
|
|
||||||
// CanEnter rapporterar om rutan går att gå in på (för sim.Mover).
|
// CanEnter rapporterar om rutan går att gå in på (för sim.Mover).
|
||||||
func (l *Level) CanEnter(p Point) bool { return l.At(p.X, p.Y).Passable() }
|
func (l *Level) CanEnter(p Point) bool { return l.At(p.X, p.Y).Passable() }
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ func TestAllShippedLevels(t *testing.T) {
|
|||||||
t.Errorf("tåg %d saknar spårnummer", i)
|
t.Errorf("tåg %d saknar spårnummer", i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if l.Party > 1 && (len(l.Find(Toilet)) == 0 || len(l.Find(Kiosk)) == 0) {
|
||||||
|
t.Errorf("banor med sällskap behöver minst en toalett (W) och en kiosk (K)")
|
||||||
|
}
|
||||||
for _, sg := range l.Signs {
|
for _, sg := range l.Signs {
|
||||||
if sg.Text["sv"] == "" || sg.Text["en"] == "" {
|
if sg.Text["sv"] == "" || sg.Text["en"] == "" {
|
||||||
t.Errorf("skylt (%d,%d) saknar sv/en-text", sg.X, sg.Y)
|
t.Errorf("skylt (%d,%d) saknar sv/en-text", sg.X, sg.Y)
|
||||||
@@ -265,3 +268,23 @@ func TestUngatedPlatforms(t *testing.T) {
|
|||||||
t.Fatal("banan ska vara lösbar med spärren passerbar")
|
t.Fatal("banan ska vara lösbar med spärren passerbar")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPartyAndParDefaults(t *testing.T) {
|
||||||
|
l := mustParse(t, sample)
|
||||||
|
if l.Party != 1 || l.ParSec != 0 {
|
||||||
|
t.Fatalf("party=%d par=%d", l.Party, l.ParSec)
|
||||||
|
}
|
||||||
|
if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "party": 9`, 1))); err == nil {
|
||||||
|
t.Fatal("för stort sällskap ska ge fel")
|
||||||
|
}
|
||||||
|
if l.CorrectPlatform() != (Point{5, 1}) {
|
||||||
|
t.Fatalf("CorrectPlatform = %v", l.CorrectPlatform())
|
||||||
|
}
|
||||||
|
if got := l.Trains[0].BoardText(); got != "番線 A B方面" {
|
||||||
|
t.Fatalf("BoardText = %q", got)
|
||||||
|
}
|
||||||
|
l2 := mustParse(t, strings.Replace(sample, `"#S..#P#"`, `"#SW.KP#"`, 1))
|
||||||
|
if l2.At(2, 1) != Toilet || l2.At(4, 1) != Kiosk || !l2.Solvable() {
|
||||||
|
t.Fatal("W och K ska vara passerbara rutor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
265
internal/sim/party.go
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
package sim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
|
||||||
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||||
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Urge är ett behov som en i sällskapet kan få.
|
||||||
|
type Urge int
|
||||||
|
|
||||||
|
// Behov.
|
||||||
|
const (
|
||||||
|
NoUrge Urge = iota
|
||||||
|
NeedToilet // måste på toa -> toalettruta (W)
|
||||||
|
Thirsty // törstig -> kiosk (K)
|
||||||
|
Hungry // hungrig -> kiosk (K)
|
||||||
|
Distracted // distraherad av något – går över av sig självt
|
||||||
|
)
|
||||||
|
|
||||||
|
func (u Urge) String() string {
|
||||||
|
return [...]string{"", "toilet", "thirsty", "hungry", "distracted"}[u]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meters är mat, dryck och toa: 100 = allt väl, 0 = akut.
|
||||||
|
type Meters struct{ Food, Drink, Toilet float64 }
|
||||||
|
|
||||||
|
// Min är den lägsta mätaren.
|
||||||
|
func (m Meters) Min() float64 { return min(m.Food, m.Drink, m.Toilet) }
|
||||||
|
|
||||||
|
// Lowest returnerar behovet som hör till den lägsta mätaren.
|
||||||
|
func (m Meters) Lowest() (Urge, float64) {
|
||||||
|
u, v := Hungry, m.Food
|
||||||
|
if m.Drink < v {
|
||||||
|
u, v = Thirsty, m.Drink
|
||||||
|
}
|
||||||
|
if m.Toilet < v {
|
||||||
|
u, v = NeedToilet, m.Toilet
|
||||||
|
}
|
||||||
|
return u, v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member är en i sällskapet. Members[0] är spelaren själv.
|
||||||
|
type Member struct {
|
||||||
|
Name string
|
||||||
|
Meters Meters
|
||||||
|
Urge Urge
|
||||||
|
UrgeAt float64 // när behovet uppstod
|
||||||
|
}
|
||||||
|
|
||||||
|
// PartyNames är namnen i den ordning de följer med.
|
||||||
|
var PartyNames = []string{"Du", "Anna", "Ville", "Sara", "Omar"}
|
||||||
|
|
||||||
|
// Takter och trösklar. Mätarna går från 100 till 0 på ungefär 10/7/8 minuter.
|
||||||
|
const (
|
||||||
|
FoodPerSec = 100.0 / 600
|
||||||
|
DrinkPerSec = 100.0 / 420
|
||||||
|
ToiletPerSec = 100.0 / 480
|
||||||
|
UrgeThreshold = 25.0 // under detta uppstår ett behov av sig självt
|
||||||
|
BaseEventSec = 45.0 // händelseintervall för en ensam resenär; delas med antalet i sällskapet
|
||||||
|
DistractionSec = 4.0
|
||||||
|
UrgeSpeedFactor = 0.8 // någon har ett behov
|
||||||
|
DistractedSpeedFactor = 0.5
|
||||||
|
EmptySpeedFactor = 0.5 // någon mätare har nått 0
|
||||||
|
KioskPriceYen = 150
|
||||||
|
ToiletSecPerMember = 3.0
|
||||||
|
RestoreBelow = 50.0 // kiosk/toalett hjälper alla under den här nivån
|
||||||
|
)
|
||||||
|
|
||||||
|
// Party är sällskapet – lämmeltåget – med mätare, behov och följarnas spår.
|
||||||
|
type Party struct {
|
||||||
|
Members []*Member
|
||||||
|
trail []level.Point // ledarens rutor, senaste sist
|
||||||
|
rand *rand.Rand
|
||||||
|
nextEventAt float64
|
||||||
|
Events int // antal händelser som inträffat
|
||||||
|
Log []string // händelselogg (tester/HUD)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewParty skapar n medlemmar (minst 1) på startrutan. All slump i
|
||||||
|
// sällskapet kommer från seed.
|
||||||
|
func NewParty(n int, start level.Point, seed rng.Seed) *Party {
|
||||||
|
n = max(1, min(n, len(PartyNames)))
|
||||||
|
p := &Party{rand: seed.New()}
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
p.Members = append(p.Members, &Member{Name: PartyNames[i], Meters: Meters{100, 100, 100}})
|
||||||
|
}
|
||||||
|
p.trail = []level.Point{start}
|
||||||
|
p.nextEventAt = p.interval() * (0.5 + p.rand.Float64())
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size är antalet i sällskapet inkl. spelaren.
|
||||||
|
func (p *Party) Size() int { return len(p.Members) }
|
||||||
|
|
||||||
|
// Followers är alla utom spelaren.
|
||||||
|
func (p *Party) Followers() []*Member { return p.Members[1:] }
|
||||||
|
|
||||||
|
// interval är tiden mellan händelser: fler i sällskapet = oftare.
|
||||||
|
func (p *Party) interval() float64 { return BaseEventSec / float64(p.Size()) }
|
||||||
|
|
||||||
|
// Advance sänker mätarna, låter behov uppstå och kör de slumpade
|
||||||
|
// händelserna. t är omgångens klocka.
|
||||||
|
func (p *Party) Advance(dt, t float64) {
|
||||||
|
for _, m := range p.Members {
|
||||||
|
m.Meters.Food = max(0, m.Meters.Food-FoodPerSec*dt)
|
||||||
|
m.Meters.Drink = max(0, m.Meters.Drink-DrinkPerSec*dt)
|
||||||
|
m.Meters.Toilet = max(0, m.Meters.Toilet-ToiletPerSec*dt)
|
||||||
|
if m.Urge == Distracted && t-m.UrgeAt >= DistractionSec {
|
||||||
|
m.Urge = NoUrge
|
||||||
|
}
|
||||||
|
if m.Urge == NoUrge {
|
||||||
|
if u, v := m.Meters.Lowest(); v < UrgeThreshold {
|
||||||
|
m.setUrge(u, t)
|
||||||
|
p.log(t, m, "behovet uppstod av sig självt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t >= p.nextEventAt {
|
||||||
|
p.event(t)
|
||||||
|
p.nextEventAt = t + p.interval()*(0.7+0.6*p.rand.Float64())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// event: en slumpad i sällskapet får ett behov – oftast det som mätaren
|
||||||
|
// står lägst i, ibland bara en distraktion.
|
||||||
|
func (p *Party) event(t float64) {
|
||||||
|
p.Events++
|
||||||
|
m := p.Members[p.rand.IntN(len(p.Members))]
|
||||||
|
if m.Urge != NoUrge {
|
||||||
|
p.log(t, m, "händelse utan effekt (hade redan ett behov)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.rand.Float64() < 0.25 {
|
||||||
|
m.setUrge(Distracted, t)
|
||||||
|
p.log(t, m, "blev distraherad")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, _ := m.Meters.Lowest()
|
||||||
|
m.setUrge(u, t)
|
||||||
|
// Behovet är på riktigt: dra ned mätaren till tröskeln.
|
||||||
|
switch u {
|
||||||
|
case NeedToilet:
|
||||||
|
m.Meters.Toilet = min(m.Meters.Toilet, UrgeThreshold)
|
||||||
|
case Thirsty:
|
||||||
|
m.Meters.Drink = min(m.Meters.Drink, UrgeThreshold)
|
||||||
|
case Hungry:
|
||||||
|
m.Meters.Food = min(m.Meters.Food, UrgeThreshold)
|
||||||
|
}
|
||||||
|
p.log(t, m, "fick behovet "+u.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Member) setUrge(u Urge, t float64) { m.Urge, m.UrgeAt = u, t }
|
||||||
|
|
||||||
|
func (p *Party) log(t float64, m *Member, what string) {
|
||||||
|
p.Log = append(p.Log, fmt.Sprintf("%.1fs %s: %s", t, m.Name, what))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Urges returnerar de som har ett aktivt behov.
|
||||||
|
func (p *Party) Urges() []*Member {
|
||||||
|
var out []*Member
|
||||||
|
for _, m := range p.Members {
|
||||||
|
if m.Urge != NoUrge {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpeedFactor är hur mycket sällskapet saktar ned ledaren.
|
||||||
|
func (p *Party) SpeedFactor() float64 {
|
||||||
|
f := 1.0
|
||||||
|
for _, m := range p.Members {
|
||||||
|
switch {
|
||||||
|
case m.Meters.Min() <= 0:
|
||||||
|
f = min(f, EmptySpeedFactor)
|
||||||
|
case m.Urge == Distracted:
|
||||||
|
f = min(f, DistractedSpeedFactor)
|
||||||
|
case m.Urge != NoUrge:
|
||||||
|
f = min(f, UrgeSpeedFactor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsToilet är de som har nytta av en toalett just nu.
|
||||||
|
func (p *Party) NeedsToilet() []*Member {
|
||||||
|
var out []*Member
|
||||||
|
for _, m := range p.Members {
|
||||||
|
if m.Urge == NeedToilet || m.Meters.Toilet < RestoreBelow {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsKiosk är de som har nytta av mat eller dryck just nu.
|
||||||
|
func (p *Party) NeedsKiosk() []*Member {
|
||||||
|
var out []*Member
|
||||||
|
for _, m := range p.Members {
|
||||||
|
if m.Urge == Thirsty || m.Urge == Hungry || m.Meters.Drink < RestoreBelow || m.Meters.Food < RestoreBelow {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// UseToilet fyller toamätaren för alla som behövde och tar bort behovet.
|
||||||
|
// Returnerar antalet som gick.
|
||||||
|
func (p *Party) UseToilet() int {
|
||||||
|
n := 0
|
||||||
|
for _, m := range p.NeedsToilet() {
|
||||||
|
m.Meters.Toilet = 100
|
||||||
|
if m.Urge == NeedToilet {
|
||||||
|
m.Urge = NoUrge
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuyAtKiosk köper mat/dryck åt alla som behöver, en betalning per person
|
||||||
|
// via pay (returnerar false när pengarna är slut). Returnerar antalet
|
||||||
|
// försörjda.
|
||||||
|
func (p *Party) BuyAtKiosk(pay func(yen int) bool) int {
|
||||||
|
n := 0
|
||||||
|
for _, m := range p.NeedsKiosk() {
|
||||||
|
if !pay(KioskPriceYen) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
m.Meters.Food, m.Meters.Drink = 100, 100
|
||||||
|
if m.Urge == Thirsty || m.Urge == Hungry {
|
||||||
|
m.Urge = NoUrge
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnLeaderArrived registrerar att ledaren nått en ny ruta; följarna följer
|
||||||
|
// spåret bakom.
|
||||||
|
func (p *Party) OnLeaderArrived(tile level.Point) {
|
||||||
|
if p.trail[len(p.trail)-1] == tile {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.trail = append(p.trail, tile)
|
||||||
|
if keep := p.Size() + 1; len(p.trail) > keep {
|
||||||
|
p.trail = p.trail[len(p.trail)-keep:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FollowerTiles returnerar var följare i (1 = närmast ledaren) står och vart
|
||||||
|
// hen är på väg; följaren interpoleras med samma framsteg som ledaren.
|
||||||
|
func (p *Party) FollowerTiles(i int) (from, to level.Point) {
|
||||||
|
at := func(k int) level.Point {
|
||||||
|
if k < 0 {
|
||||||
|
return p.trail[0]
|
||||||
|
}
|
||||||
|
return p.trail[k]
|
||||||
|
}
|
||||||
|
n := len(p.trail)
|
||||||
|
return at(n - 1 - i), at(n - i)
|
||||||
|
}
|
||||||
143
internal/sim/party_test.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package sim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||||
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMetersDecayAtRates(t *testing.T) {
|
||||||
|
p := NewParty(2, pt(1, 1), rng.FromInt(1))
|
||||||
|
for i := 0; i < 60*60; i++ { // 60 s
|
||||||
|
p.Advance(frame, float64(i)*frame)
|
||||||
|
}
|
||||||
|
m := p.Members[1].Meters
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
got float64
|
||||||
|
rate float64
|
||||||
|
}{{"mat", m.Food, FoodPerSec}, {"dryck", m.Drink, DrinkPerSec}, {"toa", m.Toilet, ToiletPerSec}} {
|
||||||
|
if want := 100 - c.rate*60; c.got > want+0.5 || c.got < want-0.5 {
|
||||||
|
t.Errorf("%s = %.1f, vill ha ~%.1f", c.name, c.got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMoreMembersMoreEvents: fler i lämmeltåget ger fler händelser.
|
||||||
|
func TestMoreMembersMoreEvents(t *testing.T) {
|
||||||
|
count := func(n int) int {
|
||||||
|
p := NewParty(n, pt(1, 1), rng.FromInt(3))
|
||||||
|
for i := 0; i < 60*600; i++ { // 10 min
|
||||||
|
p.Advance(frame, float64(i)*frame)
|
||||||
|
}
|
||||||
|
return p.Events
|
||||||
|
}
|
||||||
|
one, four := count(1), count(4)
|
||||||
|
if one < 8 || one > 20 {
|
||||||
|
t.Fatalf("ensam: %d händelser på 10 min (väntat ~13)", one)
|
||||||
|
}
|
||||||
|
if four < 3*one {
|
||||||
|
t.Fatalf("fyra i sällskapet (%d) ska ge klart fler händelser än en (%d)", four, one)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventsAreSeeded(t *testing.T) {
|
||||||
|
run := func(seed uint64) []string {
|
||||||
|
p := NewParty(3, pt(1, 1), rng.FromInt(seed))
|
||||||
|
for i := 0; i < 60*300; i++ {
|
||||||
|
p.Advance(frame, float64(i)*frame)
|
||||||
|
}
|
||||||
|
return p.Log
|
||||||
|
}
|
||||||
|
a, b := run(9), run(9)
|
||||||
|
if strings.Join(a, "|") != strings.Join(b, "|") {
|
||||||
|
t.Fatal("samma seed ska ge samma händelser")
|
||||||
|
}
|
||||||
|
if strings.Join(a, "|") == strings.Join(run(10), "|") {
|
||||||
|
t.Fatal("olika seeds ska ge olika händelser")
|
||||||
|
}
|
||||||
|
if len(a) == 0 {
|
||||||
|
t.Fatal("inga händelser på fem minuter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUrgesSlowAndFacilitiesRestore(t *testing.T) {
|
||||||
|
p := NewParty(3, pt(1, 1), rng.FromInt(1))
|
||||||
|
if p.SpeedFactor() != 1 {
|
||||||
|
t.Fatal("utan behov full fart")
|
||||||
|
}
|
||||||
|
anna, ville := p.Members[1], p.Members[2]
|
||||||
|
anna.setUrge(NeedToilet, 0)
|
||||||
|
anna.Meters.Toilet = 10
|
||||||
|
if p.SpeedFactor() != UrgeSpeedFactor || len(p.NeedsToilet()) != 1 {
|
||||||
|
t.Fatalf("toabehov: fart=%.2f behöver=%d", p.SpeedFactor(), len(p.NeedsToilet()))
|
||||||
|
}
|
||||||
|
if n := p.UseToilet(); n != 1 || anna.Urge != NoUrge || anna.Meters.Toilet != 100 || p.SpeedFactor() != 1 {
|
||||||
|
t.Fatalf("toaletten: n=%d urge=%v toa=%.0f", n, anna.Urge, anna.Meters.Toilet)
|
||||||
|
}
|
||||||
|
|
||||||
|
ville.setUrge(Thirsty, 0)
|
||||||
|
ville.Meters.Drink = 5
|
||||||
|
anna.Meters.Food = 40 // under RestoreBelow men utan behov – kiosken hjälper ändå
|
||||||
|
paid := 0
|
||||||
|
pay := func(yen int) bool { paid += yen; return true }
|
||||||
|
if n := p.BuyAtKiosk(pay); n != 2 || paid != 2*KioskPriceYen || ville.Urge != NoUrge || ville.Meters.Drink != 100 || anna.Meters.Food != 100 {
|
||||||
|
t.Fatalf("kiosken: n=%d betalt=%d", n, paid)
|
||||||
|
}
|
||||||
|
// Slut på pengar: ingen försörjs.
|
||||||
|
ville.Meters.Drink = 5
|
||||||
|
if n := p.BuyAtKiosk(func(int) bool { return false }); n != 0 || ville.Meters.Drink != 5 {
|
||||||
|
t.Fatal("utan pengar ska ingen få något")
|
||||||
|
}
|
||||||
|
|
||||||
|
ville.Meters.Drink = 100 // annars uppstår törsten igen när distraktionen går över
|
||||||
|
p.nextEventAt = 1e9 // inga slumphändelser i resten av testet
|
||||||
|
ville.setUrge(Distracted, 100)
|
||||||
|
if p.SpeedFactor() != DistractedSpeedFactor {
|
||||||
|
t.Fatal("distraktion halverar farten")
|
||||||
|
}
|
||||||
|
p.Advance(frame, 100+DistractionSec)
|
||||||
|
if ville.Urge != NoUrge {
|
||||||
|
t.Fatal("distraktionen ska gå över av sig själv")
|
||||||
|
}
|
||||||
|
ville.Meters.Food = 0
|
||||||
|
if p.SpeedFactor() != EmptySpeedFactor {
|
||||||
|
t.Fatal("tom mätare ger stor fartstraff")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUrgeArisesBelowThreshold(t *testing.T) {
|
||||||
|
p := NewParty(1, pt(1, 1), rng.FromInt(1))
|
||||||
|
p.Members[0].Meters.Drink = UrgeThreshold - 1
|
||||||
|
p.Advance(frame, 0)
|
||||||
|
if p.Members[0].Urge != Thirsty {
|
||||||
|
t.Fatalf("urge=%v", p.Members[0].Urge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFollowersTrailLeader(t *testing.T) {
|
||||||
|
p := NewParty(3, pt(1, 1), rng.FromInt(1))
|
||||||
|
for _, tile := range []level.Point{pt(2, 1), pt(3, 1), pt(3, 2)} {
|
||||||
|
p.OnLeaderArrived(tile)
|
||||||
|
}
|
||||||
|
p.OnLeaderArrived(pt(3, 2)) // samma ruta igen: ingen dubblett
|
||||||
|
if from, to := p.FollowerTiles(1); from != pt(3, 1) || to != pt(3, 2) {
|
||||||
|
t.Fatalf("följare 1: %v -> %v", from, to)
|
||||||
|
}
|
||||||
|
if from, to := p.FollowerTiles(2); from != pt(2, 1) || to != pt(3, 1) {
|
||||||
|
t.Fatalf("följare 2: %v -> %v", from, to)
|
||||||
|
}
|
||||||
|
// Vid start står alla på startrutan.
|
||||||
|
q := NewParty(3, pt(5, 5), rng.FromInt(1))
|
||||||
|
if from, to := q.FollowerTiles(2); from != pt(5, 5) || to != pt(5, 5) {
|
||||||
|
t.Fatalf("start: %v -> %v", from, to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartySizeIsClamped(t *testing.T) {
|
||||||
|
if NewParty(0, pt(0, 0), rng.FromInt(1)).Size() != 1 || NewParty(99, pt(0, 0), rng.FromInt(1)).Size() != len(PartyNames) {
|
||||||
|
t.Fatal("storleken ska klampas")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package sim
|
package sim
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
||||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||||
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Outcome är hur en banomgång står sig.
|
// Outcome är hur en banomgång står sig.
|
||||||
@@ -53,8 +56,56 @@ type Run struct {
|
|||||||
Revealed map[level.Point]bool // skyltar som översatts helt via hjälp
|
Revealed map[level.Point]bool // skyltar som översatts helt via hjälp
|
||||||
Learned string // senast inlärda ord (för HUD-notis)
|
Learned string // senast inlärda ord (för HUD-notis)
|
||||||
LearnedAt float64 // när det lärdes in; < 0 = aldrig
|
LearnedAt float64 // när det lärdes in; < 0 = aldrig
|
||||||
|
|
||||||
|
// Slump och inlärning genom läsning.
|
||||||
|
Seed rng.Seed // all slump i omgången härleds härifrån
|
||||||
|
Visits map[level.Point]int // hur många gånger spelaren stannat på en skylt/perrong
|
||||||
|
lastTile level.Point
|
||||||
|
Progressed []string // ord som fick framsteg vid senaste läsningen, "ord n/3"
|
||||||
|
ProgressedAt float64 // < 0 = aldrig
|
||||||
|
|
||||||
|
// Resultat när banan vunnits.
|
||||||
|
Stars int // 1–3 efter tid mot par
|
||||||
|
PointsEarned int // språkpoäng
|
||||||
|
LearnedDest []string // resmålets ord som lärdes in tack vare bra resultat
|
||||||
|
parSec float64
|
||||||
|
|
||||||
|
// Sällskapet (lämmeltåget) och pågående toalettbesök.
|
||||||
|
Party *Party
|
||||||
|
Facility FacilityState
|
||||||
|
baseSpeed float64
|
||||||
|
Served int // personer som fått mat/dryck/toa (statistik)
|
||||||
|
Notice string // senaste meddelande från sällskapet (HUD)
|
||||||
|
NoticeAt float64 // < 0 = aldrig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FacilityState är ett pågående toalettbesök (tar tid, avbryts om man går).
|
||||||
|
type FacilityState struct {
|
||||||
|
Active bool
|
||||||
|
At level.Point
|
||||||
|
Remaining float64
|
||||||
|
Total float64
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunOptions styr en banomgång.
|
||||||
|
type RunOptions struct {
|
||||||
|
Speed float64 // rutor per sekund (0 = DefaultSpeed)
|
||||||
|
Dict *lang.Dictionary // nil = tom ordbok
|
||||||
|
Know *lang.Knowledge // nil = ny kunskap
|
||||||
|
Seed rng.Seed // omgångens seed (sparas med spelet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultSpeed är spelarens grundfart i rutor per sekund.
|
||||||
|
const DefaultSpeed = 6.0
|
||||||
|
|
||||||
|
// Par: utan parSec i bandatan räknas "bra tid" som kortaste vägen i rutor ×
|
||||||
|
// ParSecPerTile + ParSlackSec. Tre stjärnor under par, två under 1,5 × par.
|
||||||
|
const (
|
||||||
|
ParSecPerTile = 0.5
|
||||||
|
ParSlackSec = 20.0
|
||||||
|
)
|
||||||
|
|
||||||
// HelpState är en pågående "fråga någon om hjälp".
|
// HelpState är en pågående "fråga någon om hjälp".
|
||||||
type HelpState struct {
|
type HelpState struct {
|
||||||
Active bool
|
Active bool
|
||||||
@@ -63,24 +114,54 @@ type HelpState struct {
|
|||||||
Total float64
|
Total float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRun startar banan med spelaren på startrutan. dict och know får vara
|
// NewRun startar banan med spelaren på startrutan.
|
||||||
// nil (tom ordbok respektive ny kunskap).
|
func NewRun(l *level.Level, o RunOptions) *Run {
|
||||||
func NewRun(l *level.Level, speed float64, dict *lang.Dictionary, know *lang.Knowledge) *Run {
|
if o.Dict == nil {
|
||||||
if dict == nil {
|
o.Dict = lang.NewDictionary()
|
||||||
dict = lang.NewDictionary()
|
|
||||||
}
|
}
|
||||||
if know == nil {
|
if o.Know == nil {
|
||||||
know = lang.NewKnowledge()
|
o.Know = lang.NewKnowledge()
|
||||||
}
|
}
|
||||||
return &Run{
|
if o.Speed <= 0 {
|
||||||
|
o.Speed = DefaultSpeed
|
||||||
|
}
|
||||||
|
r := &Run{
|
||||||
Level: l,
|
Level: l,
|
||||||
Player: NewMover(l.SpawnPoint(), speed),
|
Player: NewMover(l.SpawnPoint(), o.Speed),
|
||||||
Card: Card{Balance: StartBalanceYen},
|
Card: Card{Balance: StartBalanceYen},
|
||||||
DeniedAt: -1,
|
DeniedAt: -1,
|
||||||
Dict: dict,
|
Dict: o.Dict,
|
||||||
Know: know,
|
Know: o.Know,
|
||||||
Revealed: map[level.Point]bool{},
|
Revealed: map[level.Point]bool{},
|
||||||
LearnedAt: -1,
|
LearnedAt: -1,
|
||||||
|
Seed: o.Seed,
|
||||||
|
Visits: map[level.Point]int{},
|
||||||
|
lastTile: l.SpawnPoint(),
|
||||||
|
ProgressedAt: -1,
|
||||||
|
parSec: float64(l.ParSec),
|
||||||
|
Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")),
|
||||||
|
baseSpeed: o.Speed,
|
||||||
|
NoticeAt: -1,
|
||||||
|
}
|
||||||
|
if r.parSec <= 0 {
|
||||||
|
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
|
||||||
|
r.parSec = float64(max(0, n-1))*ParSecPerTile + ParSlackSec
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParSec är tiden för tre stjärnor.
|
||||||
|
func (r *Run) ParSec() float64 { return r.parSec }
|
||||||
|
|
||||||
|
// StarsFor ger 3 stjärnor under par, 2 under 1,5 × par, annars 1.
|
||||||
|
func (r *Run) StarsFor(t float64) int {
|
||||||
|
switch {
|
||||||
|
case t <= r.parSec:
|
||||||
|
return 3
|
||||||
|
case t <= 1.5*r.parSec:
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,17 +192,77 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
|
|||||||
r.Time += dt
|
r.Time += dt
|
||||||
before := r.Player.Tile
|
before := r.Player.Tile
|
||||||
wasGate := r.inGate()
|
wasGate := r.inGate()
|
||||||
|
r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor()
|
||||||
r.Steps += r.Player.Step(dt, want, r.CanEnter)
|
r.Steps += r.Player.Step(dt, want, r.CanEnter)
|
||||||
|
if r.Player.Tile != before {
|
||||||
|
r.Party.OnLeaderArrived(r.Player.Tile)
|
||||||
|
}
|
||||||
|
logged := len(r.Party.Log)
|
||||||
|
r.Party.Advance(dt, r.Time)
|
||||||
|
if len(r.Party.Log) > logged {
|
||||||
|
r.Notice, r.NoticeAt = r.Party.Log[len(r.Party.Log)-1], r.Time
|
||||||
|
}
|
||||||
r.stepHelp(dt)
|
r.stepHelp(dt)
|
||||||
|
r.stepFacility(dt)
|
||||||
|
r.stepReading()
|
||||||
if r.Player.Tile != before && r.inGate() && !wasGate {
|
if r.Player.Tile != before && r.inGate() && !wasGate {
|
||||||
r.Card.Pay(r.Level.FareYen)
|
r.Card.Pay(r.Level.FareYen)
|
||||||
r.GatePasses++
|
r.GatePasses++
|
||||||
}
|
}
|
||||||
if board && !r.Player.Moving() {
|
if board && !r.Player.Moving() {
|
||||||
|
switch r.Level.At(r.Player.Tile.X, r.Player.Tile.Y) {
|
||||||
|
case level.Toilet:
|
||||||
|
r.useToilet()
|
||||||
|
case level.Kiosk:
|
||||||
|
r.buyAtKiosk()
|
||||||
|
default:
|
||||||
if tr, at, _ := r.PlatformHere(); tr != nil && at {
|
if tr, at, _ := r.PlatformHere(); tr != nil && at {
|
||||||
r.Board(tr)
|
r.Board(tr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// useToilet startar ett toalettbesök för alla som behöver (tar tid).
|
||||||
|
func (r *Run) useToilet() {
|
||||||
|
if r.Facility.Active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := len(r.Party.NeedsToilet())
|
||||||
|
if n == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secs := ToiletSecPerMember * float64(n)
|
||||||
|
r.Facility = FacilityState{Active: true, At: r.Player.Tile, Remaining: secs, Total: secs, Count: n}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Run) stepFacility(dt float64) {
|
||||||
|
if !r.Facility.Active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Player.Moving() || r.Player.Tile != r.Facility.At {
|
||||||
|
r.Facility = FacilityState{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Facility.Remaining -= dt
|
||||||
|
if r.Facility.Remaining > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Served += r.Party.UseToilet()
|
||||||
|
r.Facility = FacilityState{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buyAtKiosk köper mat och dryck åt alla som behöver, betalat med kortet.
|
||||||
|
func (r *Run) buyAtKiosk() {
|
||||||
|
r.Served += r.Party.BuyAtKiosk(r.Card.Pay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FacilityProgress är 0..1 för ett pågående toalettbesök.
|
||||||
|
func (r *Run) FacilityProgress() float64 {
|
||||||
|
if !r.Facility.Active || r.Facility.Total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 1 - r.Facility.Remaining/r.Facility.Total
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlatformHere returnerar tåget vid perrongen spelaren står på (nil om
|
// PlatformHere returnerar tåget vid perrongen spelaren står på (nil om
|
||||||
@@ -148,13 +289,63 @@ func (r *Run) SignHere() *level.SignText {
|
|||||||
return r.Level.SignAt(r.Player.Tile)
|
return r.Level.SignAt(r.Player.Tile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Board kliver på ett tåg och avgör banan.
|
// Board kliver på ett tåg och avgör banan. En vinst ger stjärnor efter tid,
|
||||||
|
// språkpoäng, och vid tillräckligt bra resultat lär man sig resmålets ord.
|
||||||
func (r *Run) Board(tr *level.Train) {
|
func (r *Run) Board(tr *level.Train) {
|
||||||
r.Boarded = tr
|
r.Boarded = tr
|
||||||
if tr.Correct {
|
if !tr.Correct {
|
||||||
r.Outcome = Won
|
|
||||||
} else {
|
|
||||||
r.Outcome = Lost
|
r.Outcome = Lost
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Outcome = Won
|
||||||
|
r.Stars = r.StarsFor(r.Time)
|
||||||
|
r.PointsEarned = r.Know.Award(r.Stars)
|
||||||
|
if r.Stars >= lang.StarsToLearnDestination {
|
||||||
|
for _, w := range r.Dict.WordsIn(r.Level.Destination.Get("ja")) {
|
||||||
|
if r.Know.Learn(w) {
|
||||||
|
r.LearnedDest = append(r.LearnedDest, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepReading: när spelaren stannar på en skylt eller perrong "läser" hen
|
||||||
|
// texten – varje okänt ord har chansen lang.ReadChance att ge ett framsteg,
|
||||||
|
// slumpat deterministiskt från seeden, skyltens position och besöksnumret.
|
||||||
|
func (r *Run) stepReading() {
|
||||||
|
if r.Player.Moving() || r.Player.Tile == r.lastTile {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.lastTile = r.Player.Tile
|
||||||
|
text := ""
|
||||||
|
if sg := r.Level.SignAt(r.Player.Tile); sg != nil {
|
||||||
|
text = sg.Text.Get("ja")
|
||||||
|
} else if tr, _, _ := r.PlatformHere(); tr != nil {
|
||||||
|
text = tr.BoardText()
|
||||||
|
}
|
||||||
|
if text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Visits[r.Player.Tile]++
|
||||||
|
roll := r.Seed.Derive(fmt.Sprintf("read:%d,%d:%d", r.Player.Tile.X, r.Player.Tile.Y, r.Visits[r.Player.Tile])).New()
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var progressed []string
|
||||||
|
for _, w := range r.Dict.WordsIn(text) {
|
||||||
|
if seen[w] || r.Know.Knows(w) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[w] = true
|
||||||
|
if roll.Float64() >= lang.ReadChance {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Know.AddProgress(w) {
|
||||||
|
r.Learned, r.LearnedAt = w, r.Time
|
||||||
|
} else {
|
||||||
|
progressed = append(progressed, fmt.Sprintf("%s %d/%d", w, r.Know.ProgressOf(w), lang.ProgressToLearn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(progressed) > 0 {
|
||||||
|
r.Progressed, r.ProgressedAt = progressed, r.Time
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
||||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
|
||||||
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
|
||||||
)
|
)
|
||||||
|
|
||||||
func loadDict(t *testing.T) *lang.Dictionary {
|
func loadDict(t *testing.T) *lang.Dictionary {
|
||||||
@@ -90,7 +91,7 @@ func waitForTrain(t *testing.T, r *Run) *level.Train {
|
|||||||
// och kliver på – helt utan fönster.
|
// och kliver på – helt utan fönster.
|
||||||
func TestBotWinsLevel001(t *testing.T) {
|
func TestBotWinsLevel001(t *testing.T) {
|
||||||
l := loadLevel(t, "001-flygplatsen")
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
r := NewRun(l, 6, nil, nil)
|
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
||||||
goal := l.Find(level.Platform)[0]
|
goal := l.Find(level.Platform)[0]
|
||||||
path := l.Path(r.Player.Tile, goal, r.CanEnter)
|
path := l.Path(r.Player.Tile, goal, r.CanEnter)
|
||||||
if path == nil {
|
if path == nil {
|
||||||
@@ -116,6 +117,9 @@ func TestBotWinsLevel001(t *testing.T) {
|
|||||||
if r.Steps != len(path)-1 {
|
if r.Steps != len(path)-1 {
|
||||||
t.Fatalf("steg=%d, vägen är %d rutor", r.Steps, len(path)-1)
|
t.Fatalf("steg=%d, vägen är %d rutor", r.Steps, len(path)-1)
|
||||||
}
|
}
|
||||||
|
if r.Stars != 3 || r.PointsEarned != 3*lang.PointsPerStar || r.Know.Points != r.PointsEarned {
|
||||||
|
t.Fatalf("snabb vinst ska ge 3 stjärnor: stars=%d points=%d", r.Stars, r.PointsEarned)
|
||||||
|
}
|
||||||
// Efter utfall händer inget mer.
|
// Efter utfall händer inget mer.
|
||||||
before := r.Time
|
before := r.Time
|
||||||
r.Step(frame, Right, true)
|
r.Step(frame, Right, true)
|
||||||
@@ -126,7 +130,7 @@ func TestBotWinsLevel001(t *testing.T) {
|
|||||||
|
|
||||||
func TestBotLosesOnWrongTrain(t *testing.T) {
|
func TestBotLosesOnWrongTrain(t *testing.T) {
|
||||||
l := loadLevel(t, "001-flygplatsen")
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
r := NewRun(l, 6, nil, nil)
|
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
||||||
goal := l.Find(level.WrongPlatform)[0]
|
goal := l.Find(level.WrongPlatform)[0]
|
||||||
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
|
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
|
||||||
tr := waitForTrain(t, r)
|
tr := waitForTrain(t, r)
|
||||||
@@ -138,7 +142,7 @@ func TestBotLosesOnWrongTrain(t *testing.T) {
|
|||||||
|
|
||||||
func TestGateNeedsCardBalance(t *testing.T) {
|
func TestGateNeedsCardBalance(t *testing.T) {
|
||||||
l := loadLevel(t, "001-flygplatsen")
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
r := NewRun(l, 6, nil, nil)
|
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
||||||
gate := l.Find(level.Gate)[0]
|
gate := l.Find(level.Gate)[0]
|
||||||
r.Card.Balance = 0
|
r.Card.Balance = 0
|
||||||
if r.CanEnter(gate) {
|
if r.CanEnter(gate) {
|
||||||
@@ -165,7 +169,7 @@ func TestGateNeedsCardBalance(t *testing.T) {
|
|||||||
|
|
||||||
func TestBoardOnlyWhenTrainPresent(t *testing.T) {
|
func TestBoardOnlyWhenTrainPresent(t *testing.T) {
|
||||||
l := loadLevel(t, "001-flygplatsen")
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
r := NewRun(l, 6, nil, nil)
|
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
||||||
goal := l.Find(level.Platform)[0]
|
goal := l.Find(level.Platform)[0]
|
||||||
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
|
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
|
||||||
// Spola fram till en tidpunkt då tåget är borta.
|
// Spola fram till en tidpunkt då tåget är borta.
|
||||||
@@ -184,7 +188,7 @@ func TestBoardOnlyWhenTrainPresent(t *testing.T) {
|
|||||||
|
|
||||||
func TestSignHereOnlyWhenStill(t *testing.T) {
|
func TestSignHereOnlyWhenStill(t *testing.T) {
|
||||||
l := loadLevel(t, "001-flygplatsen")
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
r := NewRun(l, 6, nil, nil)
|
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
||||||
sign := l.Find(level.Sign)[0]
|
sign := l.Find(level.Sign)[0]
|
||||||
walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter))
|
walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter))
|
||||||
if sg := r.SignHere(); sg == nil || sg.Text.Get("sv") == "" {
|
if sg := r.SignHere(); sg == nil || sg.Text.Get("sv") == "" {
|
||||||
@@ -201,7 +205,7 @@ func TestSignHereOnlyWhenStill(t *testing.T) {
|
|||||||
func TestAskHelpRevealsSignAndTeachesWord(t *testing.T) {
|
func TestAskHelpRevealsSignAndTeachesWord(t *testing.T) {
|
||||||
l := loadLevel(t, "001-flygplatsen")
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
know := lang.NewKnowledge()
|
know := lang.NewKnowledge()
|
||||||
r := NewRun(l, 6, loadDict(t), know)
|
r := NewRun(l, RunOptions{Dict: loadDict(t), Know: know, Seed: rng.FromInt(1)})
|
||||||
sign := l.Find(level.Sign)[1]
|
sign := l.Find(level.Sign)[1]
|
||||||
walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter))
|
walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter))
|
||||||
|
|
||||||
@@ -241,7 +245,7 @@ func TestAskHelpRevealsSignAndTeachesWord(t *testing.T) {
|
|||||||
|
|
||||||
func TestHelpIsCancelledByWalkingAway(t *testing.T) {
|
func TestHelpIsCancelledByWalkingAway(t *testing.T) {
|
||||||
l := loadLevel(t, "001-flygplatsen")
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
r := NewRun(l, 6, loadDict(t), nil)
|
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
|
||||||
sign := l.Find(level.Sign)[1]
|
sign := l.Find(level.Sign)[1]
|
||||||
walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter))
|
walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter))
|
||||||
r.AskHelp()
|
r.AskHelp()
|
||||||
@@ -256,8 +260,155 @@ func TestHelpIsCancelledByWalkingAway(t *testing.T) {
|
|||||||
|
|
||||||
func TestNoHelpOffSign(t *testing.T) {
|
func TestNoHelpOffSign(t *testing.T) {
|
||||||
l := loadLevel(t, "001-flygplatsen")
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
r := NewRun(l, 6, loadDict(t), nil)
|
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
|
||||||
if r.AskHelp() {
|
if r.AskHelp() {
|
||||||
t.Fatal("ingen skylt på startrutan")
|
t.Fatal("ingen skylt på startrutan")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStarsFromParTime(t *testing.T) {
|
||||||
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
|
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
||||||
|
par := r.ParSec()
|
||||||
|
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter)) - 1
|
||||||
|
if want := float64(n)*ParSecPerTile + ParSlackSec; par != want {
|
||||||
|
t.Fatalf("par=%.1f, vill ha %.1f (%d rutor)", par, want, n)
|
||||||
|
}
|
||||||
|
if r.StarsFor(par) != 3 || r.StarsFor(par*1.5) != 2 || r.StarsFor(par*1.51) != 1 {
|
||||||
|
t.Fatal("stjärntrösklarna")
|
||||||
|
}
|
||||||
|
l.ParSec = 40
|
||||||
|
if NewRun(l, RunOptions{}).ParSec() != 40 {
|
||||||
|
t.Fatal("parSec i bandatan ska vinna")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGoodResultTeachesDestination: tre stjärnor lär ut resmålets ord (新宿),
|
||||||
|
// en långsam vinst gör det inte men ger ändå en poäng.
|
||||||
|
func TestGoodResultTeachesDestination(t *testing.T) {
|
||||||
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
slow bool
|
||||||
|
stars int
|
||||||
|
knowsDst bool
|
||||||
|
}{{"snabb", false, 3, true}, {"långsam", true, 1, false}} {
|
||||||
|
know := lang.NewKnowledge()
|
||||||
|
r := NewRun(l, RunOptions{Dict: loadDict(t), Know: know, Seed: rng.FromInt(2)})
|
||||||
|
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter))
|
||||||
|
waitForTrain(t, r)
|
||||||
|
if tc.slow {
|
||||||
|
r.Time = 10 * r.ParSec()
|
||||||
|
waitForTrain(t, r)
|
||||||
|
}
|
||||||
|
r.Step(frame, None, true)
|
||||||
|
if r.Outcome != Won || r.Stars != tc.stars {
|
||||||
|
t.Fatalf("%s: outcome=%v stars=%d", tc.name, r.Outcome, r.Stars)
|
||||||
|
}
|
||||||
|
if know.Knows("新宿") != tc.knowsDst || (tc.knowsDst && (len(r.LearnedDest) != 1 || r.LearnedDest[0] != "新宿")) {
|
||||||
|
t.Fatalf("%s: knows 新宿=%v learnedDest=%v", tc.name, know.Knows("新宿"), r.LearnedDest)
|
||||||
|
}
|
||||||
|
if know.Points != tc.stars*lang.PointsPerStar {
|
||||||
|
t.Fatalf("%s: poäng=%d", tc.name, know.Points)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadingGivesSeededProgress: att stanna på en skylt ger okända ord en
|
||||||
|
// chans till framsteg; samma seed ger exakt samma utfall, och efter många
|
||||||
|
// besök har något ord lossnat.
|
||||||
|
func TestReadingGivesSeededProgress(t *testing.T) {
|
||||||
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
|
sign := l.Find(level.Sign)[1]
|
||||||
|
left := level.Point{X: sign.X - 1, Y: sign.Y}
|
||||||
|
play := func(seed uint64) *lang.Knowledge {
|
||||||
|
know := lang.NewKnowledge()
|
||||||
|
r := NewRun(l, RunOptions{Dict: loadDict(t), Know: know, Seed: rng.FromInt(seed)})
|
||||||
|
walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter))
|
||||||
|
for i := 0; i < 15; i++ { // gå av och på skylten = nya besök
|
||||||
|
walk(t, r, []level.Point{sign, left})
|
||||||
|
walk(t, r, []level.Point{left, sign})
|
||||||
|
}
|
||||||
|
if r.Visits[sign] != 16 {
|
||||||
|
t.Fatalf("besök=%d", r.Visits[sign])
|
||||||
|
}
|
||||||
|
return know
|
||||||
|
}
|
||||||
|
a, b := play(5), play(5)
|
||||||
|
if a.Count() != b.Count() || len(a.Progress) != len(b.Progress) {
|
||||||
|
t.Fatalf("samma seed ska ge samma inlärning: %v/%v vs %v/%v", a.Words, a.Progress, b.Words, b.Progress)
|
||||||
|
}
|
||||||
|
for w, n := range a.Progress {
|
||||||
|
if b.Progress[w] != n {
|
||||||
|
t.Fatalf("framsteg skiljer sig för %s", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.Count() == 0 {
|
||||||
|
t.Fatalf("efter 16 läsningar borde minst ett ord ha lossnat: progress=%v", a.Progress)
|
||||||
|
}
|
||||||
|
for w := range a.Words {
|
||||||
|
if a.ProgressOf(w) != 0 {
|
||||||
|
t.Fatalf("känt ord %s ska inte ha framsteg kvar", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToiletAndKioskInRun(t *testing.T) {
|
||||||
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
|
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
||||||
|
if r.Party.Size() != l.Party || r.Party.Size() < 2 {
|
||||||
|
t.Fatalf("sällskap=%d, banan säger %d", r.Party.Size(), l.Party)
|
||||||
|
}
|
||||||
|
anna := r.Party.Members[1]
|
||||||
|
r.Party.nextEventAt = 1e9 // inga slumphändelser som stör testet
|
||||||
|
|
||||||
|
// Toalett: tar ToiletSecPerMember per person, avbryts om man går.
|
||||||
|
toilet := l.Find(level.Toilet)[0]
|
||||||
|
walk(t, r, l.Path(r.Player.Tile, toilet, r.CanEnter))
|
||||||
|
anna.Meters.Toilet = 10
|
||||||
|
anna.Urge = NeedToilet
|
||||||
|
r.Step(frame, None, true)
|
||||||
|
if !r.Facility.Active || r.Facility.Count != 1 || r.Facility.Total != ToiletSecPerMember {
|
||||||
|
t.Fatalf("toalettbesök: %+v", r.Facility)
|
||||||
|
}
|
||||||
|
r.Step(frame, Right, false)
|
||||||
|
if r.Facility.Active {
|
||||||
|
t.Fatal("att gå avbryter besöket")
|
||||||
|
}
|
||||||
|
for r.Player.Moving() { // gå klart rutan innan vi vänder tillbaka
|
||||||
|
r.Step(frame, None, false)
|
||||||
|
}
|
||||||
|
walk(t, r, l.Path(r.Player.Tile, toilet, r.CanEnter))
|
||||||
|
r.Step(frame, None, true)
|
||||||
|
if !r.Facility.Active {
|
||||||
|
t.Fatal("besöket ska starta igen")
|
||||||
|
}
|
||||||
|
for i := 0; i < int(ToiletSecPerMember/frame)+2 && r.Facility.Active; i++ {
|
||||||
|
r.Step(frame, None, false)
|
||||||
|
}
|
||||||
|
if r.Facility.Active || anna.Meters.Toilet != 100 || anna.Urge != NoUrge || r.Served != 1 {
|
||||||
|
t.Fatalf("efter besöket: active=%v toa=%.0f urge=%v served=%d", r.Facility.Active, anna.Meters.Toilet, anna.Urge, r.Served)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiosk: direkt, kostar KioskPriceYen per person från kortet.
|
||||||
|
kiosk := l.Find(level.Kiosk)[0]
|
||||||
|
walk(t, r, l.Path(r.Player.Tile, kiosk, r.CanEnter))
|
||||||
|
anna.Meters.Drink = 5
|
||||||
|
anna.Urge = Thirsty
|
||||||
|
before := r.Card.Balance
|
||||||
|
r.Step(frame, None, true)
|
||||||
|
if anna.Meters.Drink != 100 || anna.Urge != NoUrge || r.Card.Balance != before-KioskPriceYen || r.Served != 2 {
|
||||||
|
t.Fatalf("kiosken: dryck=%.0f urge=%v saldo=%d->%d served=%d", anna.Meters.Drink, anna.Urge, before, r.Card.Balance, r.Served)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartySlowsLeader(t *testing.T) {
|
||||||
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
|
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
||||||
|
r.Party.Members[1].Urge = Distracted
|
||||||
|
r.Party.Members[1].UrgeAt = 1000 // går inte över under testet
|
||||||
|
r.Step(frame, Right, false)
|
||||||
|
if r.Player.Speed != DefaultSpeed*DistractedSpeedFactor {
|
||||||
|
t.Fatalf("fart=%.2f", r.Player.Speed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"ja": "空港"
|
"ja": "空港"
|
||||||
},
|
},
|
||||||
"difficulty": 1,
|
"difficulty": 1,
|
||||||
|
"party": 2,
|
||||||
"destination": {
|
"destination": {
|
||||||
"ja": "新宿",
|
"ja": "新宿",
|
||||||
"romaji": "Shinjuku",
|
"romaji": "Shinjuku",
|
||||||
@@ -18,11 +19,11 @@
|
|||||||
"####################",
|
"####################",
|
||||||
"#S.....#.....i.....#",
|
"#S.....#.....i.....#",
|
||||||
"#.###.##.###.#.###.#",
|
"#.###.##.###.#.###.#",
|
||||||
"#...#....#...#...#.#",
|
"#...#....#..K#...#.#",
|
||||||
"###.######.#####.#.#",
|
"###.######.#####.#.#",
|
||||||
"#...#....#.....#.#.#",
|
"#...#....#.....#.#.#",
|
||||||
"#.###.##.#####.#.#.#",
|
"#.###.##.#####.#.#.#",
|
||||||
"#.....#T.......#...#",
|
"#W....#T.......#...#",
|
||||||
"#######.#########.##",
|
"#######.#########.##",
|
||||||
"#....GG......i..GG.#",
|
"#....GG......i..GG.#",
|
||||||
"#..p.############P.#",
|
"#..p.############P.#",
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
"fareYen": 200,
|
"fareYen": 200,
|
||||||
"grid": [
|
"grid": [
|
||||||
"#################################",
|
"#################################",
|
||||||
"#S#..i..#.#...........#.........#",
|
"#S#..i..#K#...........#.........#",
|
||||||
"#.#.#.#.#.#.#######.#.#########.#",
|
"#.#.#.#.#.#.#######.#.#########.#",
|
||||||
"#.#.#.#.#.#.#.....#.#...........#",
|
"#.#W#.#.#.#.#.....#.#...........#",
|
||||||
"#.###.#.#.#.#####.#.###########.#",
|
"#.###.#.#.#.#####.#.###########.#",
|
||||||
"#.#...#.#.........#.#.......#...#",
|
"#.#...#.#.........#.#.......#...#",
|
||||||
"#.#.###.###########.#.#.###.#.###",
|
"#.#.###.###########.#.#.###.#.###",
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"ja": "新宿駅",
|
"ja": "新宿駅",
|
||||||
"sv": "Shinjuku station"
|
"sv": "Shinjuku station"
|
||||||
},
|
},
|
||||||
|
"party": 3,
|
||||||
"signs": [
|
"signs": [
|
||||||
{
|
{
|
||||||
"text": {
|
"text": {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 692 B After Width: | Height: | Size: 879 B |
@@ -15,12 +15,12 @@
|
|||||||
"level.failed": "You missed too many trains…",
|
"level.failed": "You missed too many trains…",
|
||||||
"level.cleared": "Arrived!",
|
"level.cleared": "Arrived!",
|
||||||
"menu.choose_level": "Choose a level:",
|
"menu.choose_level": "Choose a level:",
|
||||||
"menu.hint": "Arrows/WASD: select Enter: start Esc: quit",
|
"menu.hint": "Arrows/WASD: select Enter: start L: word list Esc: quit",
|
||||||
"hud.time": "Time",
|
"hud.time": "Time",
|
||||||
"hud.steps": "Steps",
|
"hud.steps": "Steps",
|
||||||
"hud.controls": "Esc: menu",
|
"hud.controls": "Esc: menu",
|
||||||
"level.wrong_train": "Wrong train!",
|
"level.wrong_train": "Wrong train!",
|
||||||
"result.hint": "R: replay Enter/Esc: menu",
|
"result.hint": "R: replay L: word list Enter/Esc: menu",
|
||||||
"hud.card": "Card",
|
"hud.card": "Card",
|
||||||
"hud.track": "Track",
|
"hud.track": "Track",
|
||||||
"hud.next_train_in": "Next train in",
|
"hud.next_train_in": "Next train in",
|
||||||
@@ -40,5 +40,23 @@
|
|||||||
"hud.help_progress": "Asking a passer-by…",
|
"hud.help_progress": "Asking a passer-by…",
|
||||||
"hud.learned": "New word",
|
"hud.learned": "New word",
|
||||||
"hud.revealed": "Translated",
|
"hud.revealed": "Translated",
|
||||||
"result.helps": "asked for help"
|
"result.helps": "asked for help",
|
||||||
|
"result.par": "par",
|
||||||
|
"result.destination": "destination",
|
||||||
|
"hud.progress": "Progress",
|
||||||
|
"words.title": "Word list 言葉",
|
||||||
|
"words.points": "Points",
|
||||||
|
"words.hint": "Arrows: select Enter: learn the word for points Esc/L: back",
|
||||||
|
"words.already": "You already know that word.",
|
||||||
|
"words.bought": "New word: %s = %s",
|
||||||
|
"words.too_expensive": "Costs %d points – clear levels with good times for more.",
|
||||||
|
"urge.toilet": "needs the toilet (W)",
|
||||||
|
"urge.thirsty": "is thirsty (K)",
|
||||||
|
"urge.hungry": "is hungry (K)",
|
||||||
|
"urge.distracted": "got distracted…",
|
||||||
|
"hud.toilet_prompt": "Enter: toilet for %d person(s), %.0f s",
|
||||||
|
"hud.toilet_busy": "In the toilet…",
|
||||||
|
"hud.toilet_none": "Nobody needs the toilet right now.",
|
||||||
|
"hud.kiosk_prompt": "Enter: buy food/drink for %d person(s), %d¥",
|
||||||
|
"hud.kiosk_none": "Nobody is hungry or thirsty right now."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,12 @@
|
|||||||
"level.failed": "Du missade för många tåg…",
|
"level.failed": "Du missade för många tåg…",
|
||||||
"level.cleared": "Framme!",
|
"level.cleared": "Framme!",
|
||||||
"menu.choose_level": "Välj bana:",
|
"menu.choose_level": "Välj bana:",
|
||||||
"menu.hint": "Pilar/WASD: välj Enter: starta Esc: avsluta",
|
"menu.hint": "Pilar/WASD: välj Enter: starta L: ordlista Esc: avsluta",
|
||||||
"hud.time": "Tid",
|
"hud.time": "Tid",
|
||||||
"hud.steps": "Steg",
|
"hud.steps": "Steg",
|
||||||
"hud.controls": "Esc: meny",
|
"hud.controls": "Esc: meny",
|
||||||
"level.wrong_train": "Fel tåg!",
|
"level.wrong_train": "Fel tåg!",
|
||||||
"result.hint": "R: spela om Enter/Esc: meny",
|
"result.hint": "R: spela om L: ordlista Enter/Esc: meny",
|
||||||
"hud.card": "Kort",
|
"hud.card": "Kort",
|
||||||
"hud.track": "Spår",
|
"hud.track": "Spår",
|
||||||
"hud.next_train_in": "Nästa tåg om",
|
"hud.next_train_in": "Nästa tåg om",
|
||||||
@@ -40,5 +40,23 @@
|
|||||||
"hud.help_progress": "Frågar en förbipasserande…",
|
"hud.help_progress": "Frågar en förbipasserande…",
|
||||||
"hud.learned": "Nytt ord",
|
"hud.learned": "Nytt ord",
|
||||||
"hud.revealed": "Översatt",
|
"hud.revealed": "Översatt",
|
||||||
"result.helps": "frågat om hjälp"
|
"result.helps": "frågat om hjälp",
|
||||||
|
"result.par": "par",
|
||||||
|
"result.destination": "resmålet",
|
||||||
|
"hud.progress": "Framsteg",
|
||||||
|
"words.title": "Ordlista 言葉",
|
||||||
|
"words.points": "Poäng",
|
||||||
|
"words.hint": "Pilar: välj Enter: lär dig ordet för poäng Esc/L: tillbaka",
|
||||||
|
"words.already": "Det ordet kan du redan.",
|
||||||
|
"words.bought": "Nytt ord: %s = %s",
|
||||||
|
"words.too_expensive": "Kostar %d poäng – klara banor med bra tid för fler.",
|
||||||
|
"urge.toilet": "måste på toa (W)",
|
||||||
|
"urge.thirsty": "är törstig (K)",
|
||||||
|
"urge.hungry": "är hungrig (K)",
|
||||||
|
"urge.distracted": "blev distraherad…",
|
||||||
|
"hud.toilet_prompt": "Enter: toalett för %d person(er), %.0f s",
|
||||||
|
"hud.toilet_busy": "På toaletten…",
|
||||||
|
"hud.toilet_none": "Ingen behöver toa just nu.",
|
||||||
|
"hud.kiosk_prompt": "Enter: köp mat/dryck åt %d person(er), %d¥",
|
||||||
|
"hud.kiosk_none": "Ingen är hungrig eller törstig just nu."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// go run ./tools/genart
|
// go run ./tools/genart
|
||||||
//
|
//
|
||||||
// Skriver resources/sprites/tiles.png (en remsa 16×16-rutor i ordningen
|
// Skriver resources/sprites/tiles.png (en remsa 16×16-rutor i ordningen
|
||||||
// vägg, golv, start, perrong, spärr, automat, skylt, tågvagn) och
|
// vägg, golv, start, perrong, spärr, automat, skylt, tågvagn, toalett, kiosk) och
|
||||||
// resources/sprites/player.png (3 bildrutor × 4 riktningar: ner, upp,
|
// resources/sprites/player.png (3 bildrutor × 4 riktningar: ner, upp,
|
||||||
// vänster, höger; kolumn 0 = stilla, 1–2 = gå). Ritas med enkla rektanglar
|
// vänster, höger; kolumn 0 = stilla, 1–2 = gå). Ritas med enkla rektanglar
|
||||||
// så att allt är deterministiskt och lätt att justera.
|
// så att allt är deterministiskt och lätt att justera.
|
||||||
@@ -34,6 +34,7 @@ var (
|
|||||||
jacket, jacketDark = rgb(44, 232, 245), rgb(30, 170, 185)
|
jacket, jacketDark = rgb(44, 232, 245), rgb(30, 170, 185)
|
||||||
pants, shoe, eye = rgb(38, 43, 68), rgb(255, 255, 255), rgb(38, 43, 68)
|
pants, shoe, eye = rgb(38, 43, 68), rgb(255, 255, 255), rgb(38, 43, 68)
|
||||||
trainBody, trainStripe = rgb(230, 234, 240), rgb(228, 59, 68)
|
trainBody, trainStripe = rgb(230, 234, 240), rgb(228, 59, 68)
|
||||||
|
toiletBlue, awningRed = rgb(40, 110, 200), rgb(228, 59, 68)
|
||||||
)
|
)
|
||||||
|
|
||||||
type canvas struct {
|
type canvas struct {
|
||||||
@@ -122,6 +123,26 @@ var tiles = []func(canvas){
|
|||||||
c.rect(2, 14, 3, 2, boxDark) // hjul
|
c.rect(2, 14, 3, 2, boxDark) // hjul
|
||||||
c.rect(11, 14, 3, 2, boxDark)
|
c.rect(11, 14, 3, 2, boxDark)
|
||||||
},
|
},
|
||||||
|
func(c canvas) { // toalett: vit dörr med blå skylt
|
||||||
|
floor(c)
|
||||||
|
c.rect(3, 1, 10, 13, white)
|
||||||
|
c.rect(3, 1, 10, 1, metalDark)
|
||||||
|
c.rect(5, 3, 6, 5, toiletBlue)
|
||||||
|
c.rect(7, 4, 2, 3, white) // figur
|
||||||
|
c.rect(10, 9, 1, 1, boxDark)
|
||||||
|
},
|
||||||
|
func(c canvas) { // kiosk: randig markis och disk
|
||||||
|
floor(c)
|
||||||
|
c.rect(1, 1, 14, 3, awningRed)
|
||||||
|
for x := 1; x < 15; x += 4 {
|
||||||
|
c.rect(x, 1, 2, 3, white)
|
||||||
|
}
|
||||||
|
c.rect(2, 4, 12, 6, boxDark)
|
||||||
|
c.rect(3, 5, 3, 2, screenC) // flaskor
|
||||||
|
c.rect(7, 5, 3, 2, yellow)
|
||||||
|
c.rect(11, 5, 2, 2, green)
|
||||||
|
c.rect(2, 10, 12, 3, metal) // disk
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// player ritar en figur som tittar åt facing (0 ner, 1 upp, 2 vänster, 3 höger)
|
// player ritar en figur som tittar åt facing (0 ner, 1 upp, 2 vänster, 3 höger)
|
||||||
|
|||||||
@@ -46,9 +46,10 @@ func buildShots(levelsDir string) error {
|
|||||||
}
|
}
|
||||||
// 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", "改札,番線,方面"}
|
known := []string{"-know", "改札,番線,方面", "-seed", "7"}
|
||||||
shots = []shot{
|
shots = []shot{
|
||||||
{"", "wait 10; shot $OUT/menu.png; quit", nil},
|
{"", "wait 10; shot $OUT/menu.png; press l; wait 5; press down; press down; press down; wait 5; shot $OUT/words.png; quit",
|
||||||
|
[]string{"-know", "改札,出口,トイレ", "-points", "3"}},
|
||||||
{"001-flygplatsen", "move right 4; move down 2; wait 5; shot $OUT/level-001.png; quit", nil},
|
{"001-flygplatsen", "move right 4; move down 2; wait 5; shot $OUT/level-001.png; quit", nil},
|
||||||
{"002-shinjuku", "move down 6; move right 4; wait 5; shot $OUT/level-002.png; quit", nil},
|
{"002-shinjuku", "move down 6; move right 4; wait 5; shot $OUT/level-002.png; quit", nil},
|
||||||
{"001-flygplatsen", toSign + "; wait 5; shot $OUT/sign.png; press h; wait 200; shot $OUT/sign-help.png; wait 200; shot $OUT/sign-learned.png; " + toGoal +
|
{"001-flygplatsen", toSign + "; wait 5; shot $OUT/sign.png; press h; wait 200; shot $OUT/sign-help.png; wait 200; shot $OUT/sign-learned.png; " + toGoal +
|
||||||
|
|||||||