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>
This commit is contained in:
2026-10-23 21:29:09 +02:00
parent b67493fddb
commit daa2089eda
38 changed files with 1436 additions and 115 deletions

View File

@@ -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).
![Skylt på japanska med delvis översättning](doc/img/sign.png) ![Skylt på japanska med delvis översättning](doc/img/sign.png)
@@ -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.
![Perrongen – tåget är inne](doc/img/platform.png) ![Perrongen – tåget är inne](doc/img/platform.png)
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.
![Shinjuku station med sällskapet](doc/img/level-002.png)
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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

BIN
doc/img/words.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,18 +192,78 @@ 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
// spelaren inte står på en perrong), om det är inne, och sekunder kvar // spelaren inte står på en perrong), om det är inne, och sekunder kvar
@@ -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
} }
} }

View File

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

View File

@@ -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.#",

View File

@@ -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": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 692 B

After

Width:  |  Height:  |  Size: 879 B

View File

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

View File

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

View File

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

View File

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