diff --git a/README.md b/README.md index 62e6eef..3579290 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,11 @@ Storyn följer några polare på semester i Japan, från landningen på flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+ upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md). -**Status:** M3 klar (2026-10-23) – skyltarna är på japanska. Ord du kan -lyser gult och översätts, resten står kvar tills du frågar någon om hjälp -och lär dig ett ord till. Lätta banor har engelska på skyltarna, svåra inte. -Nästa är M4: lämmeltåget och mätarna. Se [doc/roadmap.md](doc/roadmap.md). +**Status:** M4 klar (2026-10-23) – skyltarna är på japanska och du lär dig +ord genom att läsa, fråga om hjälp, klara banor med bra tid eller lägga +språkpoäng i ordlistan. Lämmeltåget följer efter dig: polarna blir hungriga, +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) @@ -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 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. +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) +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 [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 -scale 3 # fönsterstorlek (320x180 × skala) 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 -version ``` ### Styrning -Pilar eller WASD går, Enter väljer/kliver på, H frågar om hjälp vid en -skylt, Esc går tillbaka, R spelar om. Gamepad med standardlayout fungerar: -d-pad/vänster spak, A = välj, B = tillbaka, Y = hjälp. +Pilar eller WASD går. Enter väljer, kliver på tåget, använder toaletten +eller handlar i kiosken. H frågar om hjälp vid en skylt, L öppnar +ordlistan, 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 diff --git a/cmd/metro-puzzle-jp/main.go b/cmd/metro-puzzle-jp/main.go index 18a4061..c7b2040 100644 --- a/cmd/metro-puzzle-jp/main.go +++ b/cmd/metro-puzzle-jp/main.go @@ -20,6 +20,8 @@ func main() { 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)") 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") flag.Parse() @@ -28,7 +30,7 @@ func main() { 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 { log.Fatalf("kunde inte starta: %v", err) } diff --git a/doc/img/level-001.png b/doc/img/level-001.png index b373d2e..b20a8ad 100644 Binary files a/doc/img/level-001.png and b/doc/img/level-001.png differ diff --git a/doc/img/level-002.png b/doc/img/level-002.png index 32220dc..af2c800 100644 Binary files a/doc/img/level-002.png and b/doc/img/level-002.png differ diff --git a/doc/img/menu.png b/doc/img/menu.png index 71a0827..3a116ac 100644 Binary files a/doc/img/menu.png and b/doc/img/menu.png differ diff --git a/doc/img/platform.png b/doc/img/platform.png index 4926385..592b0b7 100644 Binary files a/doc/img/platform.png and b/doc/img/platform.png differ diff --git a/doc/img/result.png b/doc/img/result.png index 98ca2bc..db3a1f5 100644 Binary files a/doc/img/result.png and b/doc/img/result.png differ diff --git a/doc/img/sign-help.png b/doc/img/sign-help.png index 33299b9..f569a40 100644 Binary files a/doc/img/sign-help.png and b/doc/img/sign-help.png differ diff --git a/doc/img/sign-learned.png b/doc/img/sign-learned.png index e1dd541..6ad0243 100644 Binary files a/doc/img/sign-learned.png and b/doc/img/sign-learned.png differ diff --git a/doc/img/sign.png b/doc/img/sign.png index 282283e..7cac225 100644 Binary files a/doc/img/sign.png and b/doc/img/sign.png differ diff --git a/doc/img/words.png b/doc/img/words.png new file mode 100644 index 0000000..187b980 Binary files /dev/null and b/doc/img/words.png differ diff --git a/doc/mal-och-tester.md b/doc/mal-och-tester.md index 8f29b35..f72b226 100644 --- a/doc/mal-och-tester.md +++ b/doc/mal-och-tester.md @@ -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` | `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/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 | ### 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 | | ~~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 | -| 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 | | 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 | diff --git a/doc/plan.md b/doc/plan.md index e90b34b..ac28c83 100644 --- a/doc/plan.md +++ b/doc/plan.md @@ -33,7 +33,7 @@ brasse-linux01-runnern, inte Pi5:n. ## Milstolpar M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar ✓ -→ M4 lämmeltåget → M5 minikarta → M6 kort/biljetter/minispel → M7 +→ M4 lämmeltåget ✓ → M5 minikarta → M6 kort/biljetter/minispel → M7 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. 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/`. - "Game box-liknande" banval: tolkat som spelbräde/världskarta – stämmer det? - ~~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 - en bana lär ut resmålets namn; skill tree i M8 kan ge kana direkt. +- ~~Hur lär man sig ord utöver hjälp?~~ – beslutat av Björn 2026-10-23: + 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? - Hur många huvudbanor siktar vi på i storyn? (Påverkar M10:s omfattning.) diff --git a/doc/roadmap.md b/doc/roadmap.md index f58c731..44054ac 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -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 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 - spara den på disk är M8. Flaggan `-know ord,ord` ger förkunskap för - tester och skärmdumpar. + spara den på disk är M8. Flaggorna `-know ord,ord`, `-points N` och + `-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` (hjälp-tillstånd, avslöjade skyltar), `internal/game/text.go` (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 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. -- Följare som går i rad efter spelaren. -- Mätare mat/dryck/toa per karaktär; händelser (toabesök, köpa dricka, - distraktion) vars frekvens skalar med gruppstorlek. -- Toaletter, kiosker och automater som rutor i banan. -- **Teknik:** `internal/sim/party`, `internal/sim/events` (seedade via - `internal/rng`). -- **Klar när:** fler i gruppen ger mätbart fler händelser i test; händelser - går att lösa i spelet. +- Följare som går i rad efter spelaren längs ledarens spår (`party` i + bandatan: 001 har två, 002 tre; max fem). Egna färgtoner tills de får + egna sprites. +- Mätare mat/dryck/toa per person (100 → 0 på ~10/7/8 min). Under 25 + uppstår ett behov av sig självt. +- **Händelser** var `45 s / antal i sällskapet` (± 30 %), slumpade från + omgångens seed: någon måste på toa, blir törstig, hungrig eller + 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 diff --git a/doc/spelbeskrivning.md b/doc/spelbeskrivning.md index 307204b..5737962 100644 --- a/doc/spelbeskrivning.md +++ b/doc/spelbeskrivning.md @@ -91,6 +91,19 @@ otydligt står tolkningen i [§15](#15-tolkningar-av-originalet). - Huvudsyftet med stationslabyrinten är att navigera med symboler och 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 - Visar bara **avgångar** och några **riktmärken** som man kan förhålla sig diff --git a/internal/game/assets.go b/internal/game/assets.go index 5ee36bc..ebaeb5c 100644 --- a/internal/game/assets.go +++ b/internal/game/assets.go @@ -24,8 +24,12 @@ const LineH = 12 // Ordningen på rutorna i resources/sprites/tiles.png (måste matcha tools/genart). var tileOrder = []level.Tile{ 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/. type Assets struct { ResDir string @@ -55,14 +59,17 @@ func LoadAssets() (*Assets, error) { sub := func(i int) *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) } for i, t := range tileOrder { + if i == trainIndex { + continue + } a.Tiles[t] = sub(i) } 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") if err != nil { diff --git a/internal/game/game.go b/internal/game/game.go index 3cbcc0d..56f0568 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -8,6 +8,7 @@ import ( "github.com/hajimehoshi/ebiten/v2" "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 @@ -29,6 +30,7 @@ type Scene interface { type Game struct { Assets *Assets 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 next Scene 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) 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) + 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. @@ -54,13 +58,15 @@ func New(opt Options) (*Game, error) { for _, w := range opt.KnownWords { g.Know.Learn(w) } + g.Know.Points = opt.Points + g.fixedSeed = opt.Seed if opt.Script != "" { if g.script, err = parseScript(opt.Script); err != nil { return nil, err } } if opt.LevelID != "" { - s, err := NewLevelScene(a, opt.LevelID, g.Know) + s, err := NewLevelScene(a, opt.LevelID, g.Know, g.seed()) if err != nil { 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. 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() +} diff --git a/internal/game/input.go b/internal/game/input.go index 40b6126..86412e6 100644 --- a/internal/game/input.go +++ b/internal/game/input.go @@ -76,6 +76,11 @@ func helpJustPressed() bool { 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. func menuMove() int { switch { diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go index 306e1af..7500093 100644 --- a/internal/game/scene_level.go +++ b/internal/game/scene_level.go @@ -5,22 +5,22 @@ import ( "image/color" "math" "path/filepath" + "strings" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/vector" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" "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. const ( - deniedShowSec = 2.0 - learnedShowSec = 3.0 + deniedShowSec = 2.0 + learnedShowSec = 3.0 + progressedShowSec = 2.5 ) // 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 -// spelarens japanska, som delas mellan banor. -func NewLevelScene(a *Assets, id string, know *lang.Knowledge) (*LevelScene, error) { +// spelarens japanska, som delas mellan banor; seed styr omgångens slump. +func NewLevelScene(a *Assets, id string, know *lang.Knowledge, seed rng.Seed) (*LevelScene, error) { lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json")) if err != nil { return nil, err } s := &LevelScene{ 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)), } 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). 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). func (s *LevelScene) TrainHere() bool { 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.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) } +// 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 // inforutan (perrong, skylt, nekad spärr, nytt ord). 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, 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.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.controls")), 4, LineH, colDim) + a.Draw(screen, fmt.Sprintf("%s %3.0fs %s %d %s %d¥ %s Lv%d·%d", a.T("hud.time"), r.Time, + a.T("hud.steps"), r.Steps, a.T("hud.card"), r.Card.Balance, a.T("hud.japanese"), r.Know.Level(), r.Know.Count()), 4, LineH, colDim) + s.drawMeters(g, screen) if r.Outcome != sim.Ongoing { 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, 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 { return } @@ -182,9 +254,13 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { vector.DrawFilledRect(screen, 0, float32(y), ScreenW, 2, accent, false) a.drawLines(screen, lines, 4, y+4) if bar { + p := r.HelpProgress() + if r.Facility.Active { + p = r.FacilityProgress() + } by := float32(y + h - 7) 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 { // 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, ja, UILang) + rd := lang.Render(a.Dict, r.Know, tr.BoardText(), UILang) lines = append(lines, a.wrapLine(shownLine(rd.Original, colText, r.Know.ShowsReadings()), maxW)...) lines = append(lines, a.wrapLine(shownLine(rd.Partial, colText, false), maxW)...) if english { @@ -240,8 +315,46 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, 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 { return []line{plainLine(a.T("hud.gate_denied"), colBad)}, colBad, 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) + } + } +} diff --git a/internal/game/scene_menu.go b/internal/game/scene_menu.go index 55ed5a0..8783884 100644 --- a/internal/game/scene_menu.go +++ b/internal/game/scene_menu.go @@ -54,10 +54,14 @@ func (m *MenuScene) Update(g *Game) error { if backJustPressed() { return ebiten.Termination } + if wordsJustPressed() { + g.SetScene(NewWordsScene(g.Assets, m)) + return nil + } if n := len(m.entries); n > 0 { m.sel = (m.sel + menuMove() + n) % n if confirmJustPressed() { - s, err := NewLevelScene(g.Assets, m.entries[m.sel].id, g.Know) + s, err := NewLevelScene(g.Assets, m.entries[m.sel].id, g.Know, g.seed()) if err != nil { m.err = err return nil @@ -87,7 +91,8 @@ func (m *MenuScene) Draw(g *Game, screen *ebiten.Image) { if m.err != nil { a.Draw(screen, "FEL: "+m.err.Error(), 8, ScreenH-40, colBad) } - a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s", 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, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-13, colDim) } diff --git a/internal/game/scene_result.go b/internal/game/scene_result.go index d0f350e..481e734 100644 --- a/internal/game/scene_result.go +++ b/internal/game/scene_result.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "strings" "github.com/hajimehoshi/ebiten/v2" "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 { switch { + case wordsJustPressed(): + g.SetScene(NewWordsScene(g.Assets, 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 { 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) 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 if tr := run.Boarded; tr != nil { @@ -64,10 +71,18 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { } } 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) y += LineH - a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s (%d %s)", a.T("hud.japanese"), run.Know.Level(), run.Know.Count(), - a.T("hud.words"), run.Know.Helps, a.T("result.helps")), 28, y, colDim) + if run.Outcome == sim.Won { + 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) } diff --git a/internal/game/scene_words.go b/internal/game/scene_words.go new file mode 100644 index 0000000..c2be25e --- /dev/null +++ b/internal/game/scene_words.go @@ -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) +} diff --git a/internal/game/script.go b/internal/game/script.go index d73f987..69f603a 100644 --- a/internal/game/script.go +++ b/internal/game/script.go @@ -20,8 +20,8 @@ import ( // // wait N vänta N bildrutor // waittrain vänta tills tåget vid perrongen spelaren står på är inne -// press KEY tryck enter | esc | r | up | down en bildruta -// move DIR N gå N rutor åt up | down | left | right (släpper när rutorna är avklarade) +// press KEY tryck enter | esc | r | h | l | up | down en bildruta +// move DIR N gå N rutor åt up | down | left | right (stannar exakt där) // shot PATH spara aktuell bild som PNG (intern upplösning × shotScale) // quit avsluta spelet // @@ -29,18 +29,22 @@ import ( 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 { - cmd string - arg string - n int - dir sim.Dir - done bool + cmd string + arg string + n int + dir sim.Dir } type script struct { steps []scriptStep i int counter int + stall int lastPos level.Point havePos bool } @@ -108,7 +112,10 @@ func parseDir(s string) sim.Dir { } // 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. type trainWaiter interface{ TrainHere() bool } @@ -139,26 +146,39 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error { } cur := pt.PlayerTile() if !s.havePos { - s.lastPos, s.havePos = cur, true + s.lastPos, s.havePos, s.stall = cur, true, 0 } if cur != s.lastPos { s.lastPos = cur s.counter++ + s.stall = 0 + } else { + s.stall++ } - if s.counter >= st.n { + if s.counter >= st.n && !pt.PlayerMoving() { s.advance() return nil } - virtual.held = st.dir + 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 + } + case "waittrain": + if tw, ok := g.scene.(trainWaiter); !ok || tw.TrainHere() { + s.advance() + } case "shot": if err := saveShot(frame, st.arg); err != nil { return err } s.advance() - case "waittrain": - if tw, ok := g.scene.(trainWaiter); !ok || tw.TrainHere() { - s.advance() - } case "quit": return ebiten.Termination } @@ -168,6 +188,7 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error { func (s *script) advance() { s.i++ s.counter = 0 + s.stall = 0 s.havePos = false } diff --git a/internal/lang/knowledge.go b/internal/lang/knowledge.go index 7f85f29..ff287bb 100644 --- a/internal/lang/knowledge.go +++ b/internal/lang/knowledge.go @@ -4,9 +4,11 @@ import "math" // Knowledge är vad spelaren kan: ord ur ordboken och om hen kan läsa kana. type Knowledge struct { - Words map[string]bool `json:"words"` - Kana bool `json:"kana"` - Helps int `json:"helps"` // antal gånger spelaren frågat om hjälp + 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"` + Helps int `json:"helps"` // antal gånger spelaren frågat om hjälp } // KanaUnlockWords: när så här många kända ord innehåller kana "lossnar" @@ -20,7 +22,9 @@ const ( ) // 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. 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 } + +// 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 +} diff --git a/internal/lang/lang_test.go b/internal/lang/lang_test.go index 68d1c49..6488924 100644 --- a/internal/lang/lang_test.go +++ b/internal/lang/lang_test.go @@ -208,3 +208,43 @@ func TestCharClasses(t *testing.T) { 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") + } +} diff --git a/internal/level/level.go b/internal/level/level.go index 8c521e8..ef3d8be 100644 --- a/internal/level/level.go +++ b/internal/level/level.go @@ -29,11 +29,13 @@ const ( Gate Tile = 'G' // spärr (kräver kort/biljett) TicketMachine Tile = 'T' // biljett-/påfyllningsautomat 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{ 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). @@ -69,6 +71,7 @@ const ( DefaultIntervalSec = 30.0 DefaultDwellSec = 10.0 DefaultFareYen = 200 + MaxParty = 5 ) // 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) Destination Text `json:"destination"` // dit spelaren ska TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns + ParSec int `json:"parSec"` // "bra tid" för tre stjärnor (0 = räknas ut från kortaste vägen) + Party int `json:"party"` // antal i sällskapet inkl. spelaren (0 = 1) FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard) Grid []string `json:"grid"` Trains []Train `json:"trains"` @@ -229,8 +234,14 @@ func (l *Level) Validate() error { if l.FareYen == 0 { l.FareYen = DefaultFareYen } - if l.FareYen < 0 { - return fmt.Errorf("level %s: negativ avgift", l.ID) + if l.FareYen < 0 || l.ParSec < 0 || l.Party < 0 { + 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. seen := map[Point]bool{} @@ -369,7 +380,7 @@ func (l *Level) UngatedPlatforms() []Point { func (l *Level) Unreachable() []Point { reach := l.Reachable() 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) { if !reach[p] { out = append(out, p) @@ -379,6 +390,24 @@ func (l *Level) Unreachable() []Point { 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). func (l *Level) CanEnter(p Point) bool { return l.At(p.X, p.Y).Passable() } diff --git a/internal/level/level_test.go b/internal/level/level_test.go index 8289ff6..6edb413 100644 --- a/internal/level/level_test.go +++ b/internal/level/level_test.go @@ -147,6 +147,9 @@ func TestAllShippedLevels(t *testing.T) { 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 { if sg.Text["sv"] == "" || sg.Text["en"] == "" { 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") } } + +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") + } +} diff --git a/internal/sim/party.go b/internal/sim/party.go new file mode 100644 index 0000000..282deff --- /dev/null +++ b/internal/sim/party.go @@ -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) +} diff --git a/internal/sim/party_test.go b/internal/sim/party_test.go new file mode 100644 index 0000000..03ebdcb --- /dev/null +++ b/internal/sim/party_test.go @@ -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") + } +} diff --git a/internal/sim/run.go b/internal/sim/run.go index a35fa5b..4075c28 100644 --- a/internal/sim/run.go +++ b/internal/sim/run.go @@ -1,8 +1,11 @@ package sim import ( + "fmt" + "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/rng" ) // 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 Learned string // senast inlärda ord (för HUD-notis) 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". type HelpState struct { Active bool @@ -63,24 +114,54 @@ type HelpState struct { Total float64 } -// NewRun startar banan med spelaren på startrutan. dict och know får vara -// nil (tom ordbok respektive ny kunskap). -func NewRun(l *level.Level, speed float64, dict *lang.Dictionary, know *lang.Knowledge) *Run { - if dict == nil { - dict = lang.NewDictionary() +// NewRun startar banan med spelaren på startrutan. +func NewRun(l *level.Level, o RunOptions) *Run { + if o.Dict == nil { + o.Dict = lang.NewDictionary() } - if know == nil { - know = lang.NewKnowledge() + if o.Know == nil { + o.Know = lang.NewKnowledge() } - return &Run{ - Level: l, - Player: NewMover(l.SpawnPoint(), speed), - Card: Card{Balance: StartBalanceYen}, - DeniedAt: -1, - Dict: dict, - Know: know, - Revealed: map[level.Point]bool{}, - LearnedAt: -1, + if o.Speed <= 0 { + o.Speed = DefaultSpeed + } + r := &Run{ + Level: l, + Player: NewMover(l.SpawnPoint(), o.Speed), + Card: Card{Balance: StartBalanceYen}, + DeniedAt: -1, + Dict: o.Dict, + Know: o.Know, + Revealed: map[level.Point]bool{}, + 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,19 +192,79 @@ func (r *Run) Step(dt float64, want Dir, board bool) { r.Time += dt before := r.Player.Tile wasGate := r.inGate() + r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor() 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.stepFacility(dt) + r.stepReading() if r.Player.Tile != before && r.inGate() && !wasGate { r.Card.Pay(r.Level.FareYen) r.GatePasses++ } if board && !r.Player.Moving() { - if tr, at, _ := r.PlatformHere(); tr != nil && at { - r.Board(tr) + 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 { + 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 // spelaren inte står på en perrong), om det är inne, och sekunder kvar // till avgång respektive ankomst. @@ -148,13 +289,63 @@ func (r *Run) SignHere() *level.SignText { 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) { r.Boarded = tr - if tr.Correct { - r.Outcome = Won - } else { + if !tr.Correct { 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 } } diff --git a/internal/sim/run_test.go b/internal/sim/run_test.go index 28e9eb3..d66e2de 100644 --- a/internal/sim/run_test.go +++ b/internal/sim/run_test.go @@ -8,6 +8,7 @@ import ( "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" ) 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. func TestBotWinsLevel001(t *testing.T) { 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] path := l.Path(r.Player.Tile, goal, r.CanEnter) if path == nil { @@ -116,6 +117,9 @@ func TestBotWinsLevel001(t *testing.T) { if 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. before := r.Time r.Step(frame, Right, true) @@ -126,7 +130,7 @@ func TestBotWinsLevel001(t *testing.T) { func TestBotLosesOnWrongTrain(t *testing.T) { 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] walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) tr := waitForTrain(t, r) @@ -138,7 +142,7 @@ func TestBotLosesOnWrongTrain(t *testing.T) { func TestGateNeedsCardBalance(t *testing.T) { 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] r.Card.Balance = 0 if r.CanEnter(gate) { @@ -165,7 +169,7 @@ func TestGateNeedsCardBalance(t *testing.T) { func TestBoardOnlyWhenTrainPresent(t *testing.T) { 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] walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) // Spola fram till en tidpunkt då tåget är borta. @@ -184,7 +188,7 @@ func TestBoardOnlyWhenTrainPresent(t *testing.T) { func TestSignHereOnlyWhenStill(t *testing.T) { 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] walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter)) if sg := r.SignHere(); sg == nil || sg.Text.Get("sv") == "" { @@ -201,7 +205,7 @@ func TestSignHereOnlyWhenStill(t *testing.T) { func TestAskHelpRevealsSignAndTeachesWord(t *testing.T) { l := loadLevel(t, "001-flygplatsen") 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] 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) { 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] walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter)) r.AskHelp() @@ -256,8 +260,155 @@ func TestHelpIsCancelledByWalkingAway(t *testing.T) { func TestNoHelpOffSign(t *testing.T) { 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() { 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) + } +} diff --git a/resources/data/levels/001-flygplatsen.json b/resources/data/levels/001-flygplatsen.json index 29bfb2b..8be217b 100644 --- a/resources/data/levels/001-flygplatsen.json +++ b/resources/data/levels/001-flygplatsen.json @@ -6,6 +6,7 @@ "ja": "空港" }, "difficulty": 1, + "party": 2, "destination": { "ja": "新宿", "romaji": "Shinjuku", @@ -18,11 +19,11 @@ "####################", "#S.....#.....i.....#", "#.###.##.###.#.###.#", - "#...#....#...#...#.#", + "#...#....#..K#...#.#", "###.######.#####.#.#", "#...#....#.....#.#.#", "#.###.##.#####.#.#.#", - "#.....#T.......#...#", + "#W....#T.......#...#", "#######.#########.##", "#....GG......i..GG.#", "#..p.############P.#", diff --git a/resources/data/levels/002-shinjuku.json b/resources/data/levels/002-shinjuku.json index 9c28fda..511c6de 100644 --- a/resources/data/levels/002-shinjuku.json +++ b/resources/data/levels/002-shinjuku.json @@ -9,9 +9,9 @@ "fareYen": 200, "grid": [ "#################################", - "#S#..i..#.#...........#.........#", + "#S#..i..#K#...........#.........#", "#.#.#.#.#.#.#######.#.#########.#", - "#.#.#.#.#.#.#.....#.#...........#", + "#.#W#.#.#.#.#.....#.#...........#", "#.###.#.#.#.#####.#.###########.#", "#.#...#.#.........#.#.......#...#", "#.#.###.###########.#.#.###.#.###", @@ -36,6 +36,7 @@ "ja": "新宿駅", "sv": "Shinjuku station" }, + "party": 3, "signs": [ { "text": { diff --git a/resources/sprites/tiles.png b/resources/sprites/tiles.png index ab95afe..d0fa12f 100644 Binary files a/resources/sprites/tiles.png and b/resources/sprites/tiles.png differ diff --git a/resources/text/en.json b/resources/text/en.json index 4c66cb7..238a199 100644 --- a/resources/text/en.json +++ b/resources/text/en.json @@ -15,12 +15,12 @@ "level.failed": "You missed too many trains…", "level.cleared": "Arrived!", "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.steps": "Steps", "hud.controls": "Esc: menu", "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.track": "Track", "hud.next_train_in": "Next train in", @@ -40,5 +40,23 @@ "hud.help_progress": "Asking a passer-by…", "hud.learned": "New word", "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." } diff --git a/resources/text/sv.json b/resources/text/sv.json index 88d00a6..6041f55 100644 --- a/resources/text/sv.json +++ b/resources/text/sv.json @@ -15,12 +15,12 @@ "level.failed": "Du missade för många tåg…", "level.cleared": "Framme!", "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.steps": "Steg", "hud.controls": "Esc: meny", "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.track": "Spår", "hud.next_train_in": "Nästa tåg om", @@ -40,5 +40,23 @@ "hud.help_progress": "Frågar en förbipasserande…", "hud.learned": "Nytt ord", "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." } diff --git a/tools/genart/main.go b/tools/genart/main.go index ef2451e..fdae450 100644 --- a/tools/genart/main.go +++ b/tools/genart/main.go @@ -3,7 +3,7 @@ // go run ./tools/genart // // 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, // vänster, höger; kolumn 0 = stilla, 1–2 = gå). Ritas med enkla rektanglar // 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) 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) + toiletBlue, awningRed = rgb(40, 110, 200), rgb(228, 59, 68) ) type canvas struct { @@ -122,6 +123,26 @@ var tiles = []func(canvas){ c.rect(2, 14, 3, 2, boxDark) // hjul 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) diff --git a/tools/shots/main.go b/tools/shots/main.go index 0177e8b..4b8a643 100644 --- a/tools/shots/main.go +++ b/tools/shots/main.go @@ -46,9 +46,10 @@ func buildShots(levelsDir string) error { } // Skyltbilderna tas med några kända ord så att markeringen och den // delvisa översättningen syns; hjälpen lär sedan in nästa ord. - known := []string{"-know", "改札,番線,方面"} + known := []string{"-know", "改札,番線,方面", "-seed", "7"} 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}, {"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 +