diff --git a/README.md b/README.md index 5bce7bc..e2f3c1b 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,16 @@ 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:** M7b klar (2026-10-24) – tre banor med stigande svårighet. -Japanska skyltar och ordinlärning, lämmeltåget med sina behov, minikarta med -dimma, tågkort, biljetter och minispel, svårighet med tidsgräns, poäng, -debuffar, egna kort och sittplatsval. Och nu lever tågen: de rullar in på -rälsen till avgångsmelodin, står med öppna dörrar, dörrsignalen ljuder och -de accelererar ut igen, med vind längs perrongen och lampor som varnar den -som står nära rälsen. Nästa är M8: progression, items och sparfiler. Se -[doc/roadmap.md](doc/roadmap.md). +**Status:** M8 klar (2026-10-24) – fyra banor på en bankarta i +spelbrädesstil, med sidospår. Japanska skyltar och ordinlärning, +lämmeltåget, minikarta, tågkort, biljetter och minispel, svårighet med +tidsgräns, poäng och debuffar, tåg som rullar in till avgångsmelodin. Och nu +progression: XP och nivåer, ett skillträd, items med buffar och genvägar som +köps i kioskernas butiker eller vinns på sidospår, och en sparfil som minns +allt inklusive seeden på den bana du höll på med. Nästa är M9a: stationer i +flera våningar. Se [doc/roadmap.md](doc/roadmap.md). + +![Bankartan](doc/img/map.png) ![Skylt på japanska med delvis översättning](doc/img/sign.png) @@ -42,6 +44,13 @@ på japanska. ![Automaten](doc/img/ticket.png) +I kioskens butik (B) köper du hattar, masker, kläder, skor och prylar med +buffar; några är genvägar, som JR Pass som öppnar alla spärrar. Klarade +banor ger XP, nivåer ger skillpoäng till skillträdet (T), och garderoben (I) +visar vad du bär. + +![Skillträdet](doc/img/skills.png) + På de svåraste banorna har alla i sällskapet egna kort som måste ha saldo, alla behöver biljett, och när biljetten är köpt ska sällskapet placeras i vagnen efter sina önskemål: fönster eller gång, vänster eller höger sida, @@ -122,7 +131,9 @@ build/ Spelet letar efter `resources/` bredvid binären, annars uppåt från arbetskatalogen (så `go run` och F5 fungerar), och `JP_RESOURCES=/sökväg` -överstyr allt. Att distribuera spelet = zippa en plattformsmapp. +överstyr allt. Att distribuera spelet = zippa en plattformsmapp. Sparfilen +ligger utanför spelmappen, i `~/.config/metro-puzzle-jp/` (Linux) eller +`%AppData%\metro-puzzle-jp\` (Windows). ### Flaggor @@ -136,6 +147,8 @@ metro-puzzle-jp -seed 7 # fast seed för händelser, läsning metro-puzzle-jp -phone -card 0 # kortet i telefonen; kortets startsaldo (tester/skärmdumpar) metro-puzzle-jp -debuffs sore_feet,tired # debuffar från start (tester/skärmdumpar) metro-puzzle-jp -mute # inget ljud +metro-puzzle-jp -save /sökväg/save.json # annan sparfil (standard ~/.config/metro-puzzle-jp/save.json) +metro-puzzle-jp -nosave # läs sparfilen men skriv aldrig (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 ``` @@ -145,8 +158,9 @@ metro-puzzle-jp -version Pilar eller WASD går. Enter väljer, kliver på tåget, använder toaletten, handlar i kiosken eller startar automaten; i minispelen väljer pilarna och Enter bekräftar (håll Enter för att mata in sedlar). H frågar om hjälp vid en -skylt, L öppnar ordlistan, M döljer/visar minikartan, P i menyn lägger -kortet i telefonen, Esc går tillbaka, R spelar om. Gamepad +skylt, L öppnar ordlistan, T skillträdet, I garderoben, B butiken vid en kiosk, M +döljer/visar minikartan, P på kartan lägger kortet i telefonen, 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, Back/Select = karta. @@ -187,6 +201,8 @@ internal/game/ Ebitengine: scener, input, rendering, skriptläge (enda internal/sim/ rutbaserad rörelse, kollision, kamera, banomgång med tåg/spärrar/hjälp (ren Go) internal/lang/ japanska: ordbok, kunskap, delvis översättning (ren Go) internal/minigame/ påfyllning, biljettköp, trängsel, tågresa – seedade minispel (ren Go) +internal/progress/ XP, skillträd, items, banresultat, upplåsning (ren Go) +internal/save/ sparfil (versionerad JSON) internal/level/ banformat, validering, framkomlighet internal/res/ hittar och läser resources/ internal/rng/ seedad slump diff --git a/cmd/metro-puzzle-jp/main.go b/cmd/metro-puzzle-jp/main.go index fdabec7..96e2645 100644 --- a/cmd/metro-puzzle-jp/main.go +++ b/cmd/metro-puzzle-jp/main.go @@ -26,6 +26,8 @@ func main() { card := flag.Int("card", -1, "kortets startsaldo i yen, -1 = standard (tester/skärmdumpar)") debuffs := flag.String("debuffs", "", "debuffar från start, kommaseparerade, t.ex. sore_feet,tired (tester/skärmdumpar)") mute := flag.Bool("mute", false, "inget ljud") + savePath := flag.String("save", "", "sparfil (standard: ~/.config/metro-puzzle-jp/save.json resp. %AppData%)") + noSave := flag.Bool("nosave", false, "läs sparfilen men skriv aldrig (tester/skärmdumpar)") version := flag.Bool("version", false, "skriv ut version och avsluta") flag.Parse() @@ -34,7 +36,7 @@ func main() { return } - g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card, Debuffs: splitWords(*debuffs), Mute: *mute}) + g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card, Debuffs: splitWords(*debuffs), Mute: *mute, SavePath: *savePath, NoSave: *noSave}) if err != nil { log.Fatalf("kunde inte starta: %v", err) } diff --git a/doc/img/items.png b/doc/img/items.png new file mode 100644 index 0000000..37e4466 Binary files /dev/null and b/doc/img/items.png differ diff --git a/doc/img/map.png b/doc/img/map.png new file mode 100644 index 0000000..37e4466 Binary files /dev/null and b/doc/img/map.png differ diff --git a/doc/img/menu.png b/doc/img/menu.png deleted file mode 100644 index 7ec5b57..0000000 Binary files a/doc/img/menu.png and /dev/null differ diff --git a/doc/img/result.png b/doc/img/result.png index 9bb8d95..16a17ce 100644 Binary files a/doc/img/result.png and b/doc/img/result.png differ diff --git a/doc/img/ride.png b/doc/img/ride.png index 3d14fc3..39e4f6d 100644 Binary files a/doc/img/ride.png and b/doc/img/ride.png differ diff --git a/doc/img/shop.png b/doc/img/shop.png new file mode 100644 index 0000000..f2f6798 Binary files /dev/null and b/doc/img/shop.png differ diff --git a/doc/img/skills.png b/doc/img/skills.png new file mode 100644 index 0000000..91be59d Binary files /dev/null and b/doc/img/skills.png differ diff --git a/doc/img/ticket.png b/doc/img/ticket.png index 435d60e..27231f4 100644 Binary files a/doc/img/ticket.png and b/doc/img/ticket.png differ diff --git a/doc/img/words.png b/doc/img/words.png index 8332044..37e4466 100644 Binary files a/doc/img/words.png and b/doc/img/words.png differ diff --git a/doc/mal-och-tester.md b/doc/mal-och-tester.md index d10321a..c30209b 100644 --- a/doc/mal-och-tester.md +++ b/doc/mal-och-tester.md @@ -34,7 +34,7 @@ att bygga på Linux som på Windows. | Inkrementellt debug-bygge | < 10 s | 4,7 s | | Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) | | Testsviten | `go test ./...` grön på < 30 s | ~0,1 s | -| Banor | 100 % passerar det automatiska lösbarhetstestet | 3 av 3 | +| Banor | 100 % passerar det automatiska lösbarhetstestet | 4 av 4 | | Windows-korsbygge från Linux | fungerar utan extra verktyg | ✓ (CGO_ENABLED=0) | | Linux-binärens länkade bibliotek | bara libc | ✓ (GL/X11 laddas dynamiskt vid körning) | | Bildfrekvens | 60 fps vid 4× skala på integrerad grafik | ej mätt | @@ -118,6 +118,12 @@ att bygga på Linux som på Windows. | `internal/minigame` | `TestTopUpPicksMemberWithIndividualCards`, `TestSeatSelectionPreferences` | Personval och 2×2-rutnät; seedade önskemål, upptagna platser, missnöje, fönster/gång/sida/bredvid, Esc avbryter köpet | | `internal/level` | `TestSchedulePhases`, `TestScheduleValidationForPhases`, `TestTrackTile` | **M7b:** faserna away→approaching→arriving→dwelling→closing→leaving ur tidtabellen, ombordstigning bara med öppna dörrar, Status och Phase konsekventa; för korta uppehåll/borta-tider avvisas; rälsrutan | | `internal/sim` | `TestNearTrackAndPhases` | Närmaste perrong inom två rutor, inte tre; under inrullningen syns tåget men man kan inte kliva på; med öppna dörrar kan man | +| `internal/progress` | `TestShippedCatalogAndThirdRule`, `TestCatalogValidation` | **M8:** katalogerna parsar och validerar; **billigaste tredjedelen av items kostar mer än startkassan**; banornas belöningar/butiker pekar på riktiga items | +| `internal/progress` | `TestXPLevelsAndSkillPoints`, `TestSkillTreeRequirementsAndCost` | Nivåtrösklar, skillpoäng per level-up, krav och kostnad, effekter multipliceras | +| `internal/progress` | `TestItemsBuyEquipAndEffects`, `TestLevelRecordsAndUnlocking` | Köp, bär/ta av, en per plats, tint, genvägar; bästa resultat behålls, upplåsning av huvud- och sidobanor | +| `internal/save` | `TestRoundTrip`, `TestCorruptAndFutureVersions`, `TestDefaultPath` | Skriv → läs ger samma tillstånd inkl. seed; trasig/nyare fil ger fel; version 0 migreras | +| `internal/sim` | `TestRunOptionsFromProgression` | Extra stats, sikt, landmärken, gratis spärrar, hela kartan, powerbank | +| `internal/minigame` | `TestCrowdLuckWidensZone` | Tur gör zonen bredare, otur smalare, klampat | | `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan | ### Planerade tester per milstolpe @@ -133,7 +139,7 @@ att bygga på Linux som på Windows. | ~~M7 Svårighet/poäng/debuffs~~ ✓ | Poäng, tidsgräns, debuffar, egna kort, sittplatsval – finns, se tabellen ovan | | ~~M7b Tåg som kommer och går~~ ✓ | Faser, ombordstigning, indikatoravstånd – finns, se tabellen ovan; ljud och animation verifieras manuellt/med skärmdumpar | | M9b Folk, reklam, ljudröra | NPC:er följer seedade mål utan att fastna; krock saktar ned; fel sida i gång bromsar; bot klarar bana med folk; fler NPC:er = längre tid; clutter/noise läses per bana | -| M8 Progression & sparfil | Sparfil round-trip (skriv → läs ger samma tillstånd inkl. seed); skill tree-krav; upplåsningar efter rätt bana; "< 33 %"-regeln kontrolleras mot itemlistan | +| ~~M8 Progression & sparfil~~ ✓ | Round-trip, skillkrav, upplåsningar, 33 %-regeln – finns, se tabellen ovan | | M9a Flera våningar | Trappa byter våning för Mover och följare; `Path` över våningar; lösbarhetstest med trappor; dimma/minikarta per våning; gamla banor = en våning | | M9 Navigeringslägen | Listläge genererar rätt ordning av tåg; skyltläge: 1–4 giltiga vägar; fel-riktnings-förlust triggar vid rätt slutstation | | M10 Story-innehåll | Alla banor lösbara (redan automatiskt); flerbytesbanor har sammanhängande byten; sista banan slutar på flygplatsen | diff --git a/doc/plan.md b/doc/plan.md index 540b59d..ec6edaa 100644 --- a/doc/plan.md +++ b/doc/plan.md @@ -34,7 +34,7 @@ brasse-linux01-runnern, inte Pi5:n. 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 -svårighet/poäng/debuffar ✓ → M7b tåg som kommer och går ✓ → M8 progression/sparfiler → M9a flera våningar → M9b folk/reklam/ljudröra → M9 navigeringslägen → +svårighet/poäng/debuffar ✓ → M7b tåg som kommer och går ✓ → M8 progression/sparfiler ✓ → M9a flera våningar → M9b folk/reklam/ljudröra → 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). Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md). @@ -62,5 +62,7 @@ Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md). - ~~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? +- Ska Oändligt läge dela sparfil med story eller ha egen? (Sparfilen har + nu ett versionsfält och `Current`-referens; oändligt läge kan få en egen + sektion i samma fil.) - 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 7c067fa..b2b3c7a 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -61,6 +61,8 @@ metro-puzzle-jp/ │ ├── sim/ rutbaserad rörelse, kollision, kamera, banomgång (Run), sällskap (Party), dimma (Fog) – ren Go │ ├── lang/ ordbok, tokenisering, kunskap, delvis översättning, engelska-andel, hjälptid – ren Go │ ├── minigame/ påfyllning, biljettköp, trängsel, tågresa – seedade, ren Go +│ ├── progress/ XP, skillträd, items, banresultat, upplåsning – ren Go +│ ├── save/ sparfil: versionerad JSON, migrering, atomisk skrivning │ ├── game/ Ebitengine-loop, scener, input, rendering, skriptläge ← får använda Ebitengine │ ├── archtest/ beroenderegler │ └── … kommande: lang/, nav/, score/, save/, minigame/, ui/, audio/ @@ -71,7 +73,7 @@ metro-puzzle-jp/ │ ├── sprites/ bilder, tilesets, UI │ ├── sound/ OGG/WAV │ ├── text/ sv.json, en.json, jp-dictionary.json, fonts/PixelMplus*.ttf -│ └── data/ levels/, items, skills (JSON) +│ └── data/ levels/, items.json, skills.json ├── doc/ denna dokumentation (doc/img/ = skärmdumpar från tools/shots) ├── .gitea/workflows/ CI: test + release-zippar för linux/windows på brasse-linux01-runnern ├── .vscode/ tasks.json, launch.json @@ -355,18 +357,40 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n. - **Konsekvens för banorna:** uppehållet måste vara minst 5,5 s och tiden borta minst 7,5 s (valideras), så att faserna får plats. -### M8 – Progression, items och sparfiler +### M8 – Progression, items och sparfiler ✓ (klar 2026-10-24) - **Mål:** något att låsa upp och en anledning att fortsätta. -- XP/level-ups, skill tree, items (kläder, masker, hattar) med buffar. -- Bankarta i spelbrädesstil; sidobanor som ger skins/powers/items; items - som låser upp genvägar i huvudbanor. -- Sparfil med framsteg, items, skills, debuffar och seed. Autospar vid - banstart. -- **Teknik:** `internal/save` (JSON, versionsfält för migrering), - `resources/data/items.json`, `skills.json`. -- **Klar när:** sparfil round-trip-test grön; "< 33 % per omgång" går att - räkna ut från datan. +- **XP och nivåer:** en klarad bana ger `poäng/10 + 25 × stjärnor` XP; + nivå n+1 nås vid 150·n(n+1)/2 XP (150, 450, 900 …). Varje level-up ger + en **skillpoäng**. +- **Skillträd** (`resources/data/skills.json`, 12 noder i fyra grenar: + språk, kropp, resenär, utforskare): kana, fokus, hjälp, fart, törst, toa, + tur, sikt, landmärken. Krav och kostnad valideras; effekterna är + stats-multiplikatorer plus flaggor. Språkpoängen på ord (M3) finns kvar + som egen valuta. +- **Items** (`resources/data/items.json`, 25 st: hattar, masker, kläder, + skor, prylar, skins) med buffar, färgton på spelaren och genvägar: + JR Pass (gratis spärrar), kartor (hela minikartan på en bana), powerbank + (oändligt batteri). Ett item per plats (huvud, ansikte, kropp, fötter, + väska). Köps kontant i **kioskens butik** (B) – banans sortiment eller ett + seedat urval. Designregeln "knappt 33 % per omgång" är ett test: + billigaste tredjedelen kostar mer än startkassan. +- **Bankartan** i spelbrädesstil ersätter menyn: huvudbanorna på en linje, + sidospår ovanför, låsta grå, klarade med stjärnor; nästa huvudbana öppnas + när föregående är klarad, sidospår när föräldern är. Ny sidobana + **002b Kabukicho** belönar skinnet Kimono och en skillpoäng. +- **Sparfil** (`internal/save`): versionerad JSON med kunskap, plånbok, + debuffar, progression och pågående bana + seed, atomisk skrivning, + migrering av äldre filer. `~/.config/metro-puzzle-jp/save.json` på Linux, + `%AppData%` på Windows. Autospar vid banstart (seeden), efter banan och + vid köp/upplåsningar. `⟳` på kartan betyder att banan återupptas med samma + slump. Flaggor `-save PATH`, `-nosave`. +- **Teknik:** `internal/progress` (ren Go, 6 tester), `internal/save` + (3 tester), `sim.RunOptions` får Extra/FogRadius/Landmarks/FreeGates/ + RevealAll/InfiniteBattery, luck påverkar trängselzonen, nya scener + karta/skills/garderob och butiken i `internal/game`. +- **Klar när:** sparfil round-trip-test grön; "< 33 %" kontrolleras mot + itemlistan; skillkrav, upplåsningar och belöningar testade. ✓ ### M9a – Flera våningar (Björns tillägg 2026-10-24) diff --git a/internal/game/game.go b/internal/game/game.go index 042cc9a..29aeb73 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -5,10 +5,17 @@ package game import ( + "fmt" + "log" + "github.com/hajimehoshi/ebiten/v2" "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/progress" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/save" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" ) @@ -21,7 +28,7 @@ const ( dt = 1.0 / 60 // Ebitengine kör Update med fast 60 TPS ) -// Scene är ett spelläge: meny, bana, resultat … +// Scene är ett spelläge: bankarta, bana, resultat … type Scene interface { Update(g *Game) error Draw(g *Game, screen *ebiten.Image) @@ -29,12 +36,18 @@ type Scene interface { // Game är Ebitengine-tillståndet för hela programmet. type Game struct { - Assets *Assets - Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8) - Wallet *sim.Wallet // pengar, kort, telefon, biljett – följer med mellan banor - Profile *sim.Profile // aktiva debuffar – följer med mellan banor - Audio *Audio // nil = tyst - fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar) + Assets *Assets + State *save.State // allt som sparas + Know *lang.Knowledge // = State.Know + Wallet *sim.Wallet // = State.Wallet + Profile *sim.Profile // = State.Profile + Progress *progress.Progress // = State.Progress + Catalog *progress.Catalog + Levels []*level.Level // alla banor, sorterade på id + Audio *Audio // nil = tyst + + savePath string // "" = spara inte + 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 @@ -44,62 +57,113 @@ type Game struct { // Options styr uppstarten. type Options struct { - LevelID string // hoppa direkt in i en bana (tom = meny) + LevelID string // hoppa direkt in i en bana (tom = bankartan) 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) Phone bool // kortet i telefonen från start + CardYen int // >=0: kortets startsaldo (för tester/skärmdumpar), -1 = standard Debuffs []string // debuffar från start (för tester/skärmdumpar) Mute bool // inget ljud - CardYen int // >=0: kortets startsaldo (för tester/skärmdumpar), -1 = standard + SavePath string // sparfil ("" = standardplatsen) + NoSave bool // läs sparfilen men skriv aldrig } -// New laddar resurser och bygger spelet. +// New laddar resurser, sparfil och bygger spelet. func New(opt Options) (*Game, error) { a, err := LoadAssets() if err != nil { return nil, err } - g := &Game{Assets: a, Know: lang.NewKnowledge(), smokeLeft: opt.SmokeFrames, frame: ebiten.NewImage(ScreenW, ScreenH)} - for _, w := range opt.KnownWords { - g.Know.Learn(w) - } - g.Know.Points = opt.Points - g.fixedSeed = opt.Seed - if g.Audio, err = newAudio(opt.Mute); err != nil { - return nil, err - } - g.Wallet = sim.NewWallet() - g.Profile = &sim.Profile{} - for _, d := range opt.Debuffs { - g.Profile.Add(sim.DebuffKind(d), 1) - } - g.Wallet.Phone = opt.Phone - if opt.CardYen >= 0 { - g.Wallet.Card = opt.CardYen - } + g := &Game{Assets: a, smokeLeft: opt.SmokeFrames, frame: ebiten.NewImage(ScreenW, ScreenH), fixedSeed: opt.Seed} if opt.Script != "" { if g.script, err = parseScript(opt.Script); err != nil { return nil, err } } + if g.Audio, err = newAudio(opt.Mute); err != nil { + return nil, err + } + if g.Catalog, err = loadCatalog(); err != nil { + return nil, err + } + if g.Levels, err = level.LoadAll(a.LevelsDir); err != nil { + return nil, err + } + + // Sparfil. + path := opt.SavePath + if path == "" { + if path, err = save.DefaultPath(); err != nil { + return nil, err + } + } + st, exists, err := save.Load(path) + if err != nil { + return nil, err + } + if exists { + log.Printf("sparfil läst: %s", path) + } + g.State = st + g.Know, g.Wallet, g.Profile, g.Progress = st.Know, st.Wallet, st.Profile, st.Progress + if !opt.NoSave { + g.savePath = path + } + + // Startvärden för tester/skärmdumpar. + for _, w := range opt.KnownWords { + g.Know.Learn(w) + } + if opt.Points > 0 { + g.Know.Points = opt.Points + } + for _, d := range opt.Debuffs { + g.Profile.Add(sim.DebuffKind(d), 1) + } + if opt.Phone { + g.Wallet.Phone = true + } + if opt.CardYen >= 0 { + g.Wallet.Card = opt.CardYen + } + if opt.LevelID != "" { - s, err := NewLevelScene(a, opt.LevelID, g, g.seed()) + s, err := g.startLevel(opt.LevelID) if err != nil { return nil, err } g.scene = s } else { - g.scene, err = NewMenuScene(a) - if err != nil { - return nil, err - } + g.scene = NewMapScene(g) } return g, nil } +func loadCatalog() (*progress.Catalog, error) { + sk, err := res.ReadFile("data", "skills.json") + if err != nil { + return nil, err + } + it, err := res.ReadFile("data", "items.json") + if err != nil { + return nil, err + } + return progress.LoadCatalog(sk, it) +} + +// Save skriver sparfilen (no-op utan sparväg). +func (g *Game) Save() { + if g.savePath == "" { + return + } + if err := save.Store(g.savePath, g.State); err != nil { + log.Printf("kunde inte spara: %v", err) + } +} + // SetScene byter scen från och med nästa uppdatering. func (g *Game) SetScene(s Scene) { g.next = s } @@ -131,6 +195,106 @@ 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 } +// LevelByID slår upp en bana. +func (g *Game) LevelByID(id string) *level.Level { + for _, l := range g.Levels { + if l.ID == id { + return l + } + } + return nil +} + +// Effects är de sammanlagda effekterna av skills och burna items. +func (g *Game) Effects() progress.Aggregate { return g.Progress.Effects(g.Catalog) } + +// runOptions bygger banans inställningar från progressionen. +func (g *Game) runOptions(id string, seed rng.Seed) sim.RunOptions { + eff := g.Effects() + if eff.Kana { + g.Know.Kana = true + } + return sim.RunOptions{ + Dict: g.Assets.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed, + Extra: eff.Stats, FogRadius: eff.FogRadius, Landmarks: eff.Landmarks, FreeGates: eff.FreeGates, + RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery, + } +} + +// startLevel startar (eller återupptar) en bana och sparar seeden så att en +// laddad omgång ger samma slump. +func (g *Game) startLevel(id string) (*LevelScene, error) { + if g.LevelByID(id) == nil { + return nil, fmt.Errorf("okänd bana %q", id) + } + var seed rng.Seed + switch { + case g.fixedSeed > 0: + seed = rng.FromInt(g.fixedSeed) + case g.State.Current != nil && g.State.Current.LevelID == id: + seed = rng.Seed{A: g.State.Current.Seed[0], B: g.State.Current.Seed[1]} // återuppta med samma slump + default: + seed = rng.NewSeed() + } + s, err := NewLevelScene(g, id, seed) + if err != nil { + return nil, err + } + g.State.Current = &save.RunRef{LevelID: id, Seed: [2]uint64{seed.A, seed.B}} + g.Save() + return s, nil +} + +// LevelOutcome är vad en avslutad bana gav i progression (för resultatskärmen). +type LevelOutcome struct { + XP int + LevelUps int + FirstClear bool + NewItems []string + NewPoints int + Unlocked []string // banor som öppnades +} + +// levelEnded appliceras exakt en gång när en bana avgjorts: debuffar, +// banresultat, XP, belöningar och upplåsningar; sedan sparas allt. +func (g *Game) levelEnded(run *sim.Run, id string) LevelOutcome { + var out LevelOutcome + for _, d := range run.NewDebuffs { + g.Profile.Add(d.Kind, d.LevelsLeft) + } + g.Profile.EndLevel() + + before := map[string]bool{} + for _, l := range g.Levels { + before[l.ID] = g.Progress.Unlocked(g.Levels, l.ID) + } + won := run.Outcome == sim.Won + out.FirstClear = g.Progress.RecordLevel(id, won, run.Stars, run.Score.Total) + if won { + out.XP = progress.XPFor(run.Score.Total, run.Stars) + out.LevelUps = g.Progress.AddXP(out.XP) + if out.FirstClear { + if l := g.LevelByID(id); l != nil { + for _, it := range l.Rewards.Items { + if g.Progress.Give(g.Catalog, it) { + out.NewItems = append(out.NewItems, it) + } + } + out.NewPoints = l.Rewards.SkillPoints + g.Progress.SkillPoints += l.Rewards.SkillPoints + } + } + for _, l := range g.Levels { + if !before[l.ID] && g.Progress.Unlocked(g.Levels, l.ID) { + out.Unlocked = append(out.Unlocked, l.ID) + } + } + } + g.State.Current = nil + g.Save() + return out +} + // 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 { @@ -139,11 +303,5 @@ func (g *Game) seed() rng.Seed { return rng.NewSeed() } -// levelEnded appliceras exakt en gång när en bana avgjorts: banans nya -// debuffar läggs på profilen och alla räknas ned en bana. -func (g *Game) levelEnded(run *sim.Run) { - for _, d := range run.NewDebuffs { - g.Profile.Add(d.Kind, d.LevelsLeft) - } - g.Profile.EndLevel() -} +// progress_XPToNext är ett kort alias för scenerna. +func progress_XPToNext(xp int) int { return progress.XPToNext(xp) } diff --git a/internal/game/input.go b/internal/game/input.go index cc6d325..24fc4cb 100644 --- a/internal/game/input.go +++ b/internal/game/input.go @@ -114,6 +114,15 @@ func confirmHeld() bool { return false } +// skillsJustPressed: T = skillträdet. +func skillsJustPressed() bool { return virtual.pressed["t"] || inpututil.IsKeyJustPressed(ebiten.KeyT) } + +// itemsJustPressed: I = garderoben/items. +func itemsJustPressed() bool { return virtual.pressed["i"] || inpututil.IsKeyJustPressed(ebiten.KeyI) } + +// shopJustPressed: B = butiken i kiosken. +func shopJustPressed() bool { return virtual.pressed["b"] || inpututil.IsKeyJustPressed(ebiten.KeyB) } + // phoneJustPressed: P = kortet i telefonen av/på (meny). func phoneJustPressed() bool { return virtual.pressed["p"] || inpututil.IsKeyJustPressed(ebiten.KeyP) } diff --git a/internal/game/scene_items.go b/internal/game/scene_items.go new file mode 100644 index 0000000..c45e54f --- /dev/null +++ b/internal/game/scene_items.go @@ -0,0 +1,144 @@ +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/progress" +) + +// ItemsScene är garderoben: ägda items per plats, Enter bär/tar av. +type ItemsScene struct { + back Scene + items []progress.Item + sel int + top int + msg string +} + +const itemRows = 8 + +// NewItemsScene listar ägda items, plats för plats. +func NewItemsScene(g *Game, back Scene) *ItemsScene { + var owned []progress.Item + for _, it := range g.Catalog.Items { + if g.Progress.Own(it.ID) { + owned = append(owned, it) + } + } + slotOrder := map[string]int{} + for i, s := range progress.Slots { + slotOrder[s] = i + } + sort.SliceStable(owned, func(i, j int) bool { + if owned[i].Slot != owned[j].Slot { + return slotOrder[owned[i].Slot] < slotOrder[owned[j].Slot] + } + return owned[i].Price < owned[j].Price + }) + return &ItemsScene{back: back, items: owned} +} + +func (s *ItemsScene) Update(g *Game) error { + if backJustPressed() || itemsJustPressed() { + g.SetScene(s.back) + return nil + } + if n := len(s.items); n > 0 { + s.sel = (s.sel + menuMove() + n) % n + if s.sel < s.top { + s.top = s.sel + } + if s.sel >= s.top+itemRows { + s.top = s.sel - itemRows + 1 + } + if confirmJustPressed() { + it := s.items[s.sel] + if g.Progress.Equipped[it.Slot] == it.ID { + g.Progress.Unequip(it.Slot) + s.msg = fmt.Sprintf(g.Assets.T("items.removed"), it.Name.Get(UILang)) + } else { + g.Progress.Equip(g.Catalog, it.ID) + s.msg = fmt.Sprintf(g.Assets.T("items.worn"), it.Name.Get(UILang)) + } + g.Save() + } + } + return nil +} + +// effectText beskriver en effekt kort. +func effectText(a *Assets, e progress.Effect) string { + var parts []string + keys := make([]string, 0, len(e.Stats)) + for k := range e.Stats { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s %+.0f%%", a.T("stat."+k), (e.Stats[k]-1)*100)) + } + if e.Kana { + parts = append(parts, a.T("effect.kana")) + } + if e.FogRadius > 0 { + parts = append(parts, fmt.Sprintf(a.T("effect.fog"), e.FogRadius)) + } + if e.Landmarks { + parts = append(parts, a.T("effect.landmarks")) + } + if e.FreeGates { + parts = append(parts, a.T("effect.free_gates")) + } + if e.RevealLevel != "" { + parts = append(parts, fmt.Sprintf(a.T("effect.reveal"), e.RevealLevel)) + } + if e.Battery { + parts = append(parts, a.T("effect.battery")) + } + return strings.Join(parts, ", ") +} + +func (s *ItemsScene) Draw(g *Game, screen *ebiten.Image) { + a := g.Assets + p := g.Progress + 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("items.title"), 8, 5, colText) + hdr := fmt.Sprintf("%d/%d %s %s %d¥", len(s.items), len(g.Catalog.Items), a.T("items.owned"), a.T("hud.cash"), g.Wallet.Cash) + a.Draw(screen, hdr, ScreenW-8-a.Width(hdr), 6, colKnown) + + y0 := 30 + for i := s.top; i < len(s.items) && i < s.top+itemRows; i++ { + it := s.items[i] + y := y0 + (i-s.top)*LineH + if i == s.sel { + vector.DrawFilledRect(screen, 4, float32(y-1), ScreenW-8, LineH, colBar, false) + } + col, status := colText, "" + if p.Equipped[it.Slot] == it.ID { + col, status = colGood, a.T("items.wearing") + } + a.Draw(screen, a.T("slot."+it.Slot), 8, y, colDim) + a.Draw(screen, it.Name.Get(UILang), 60, y, col) + a.Draw(screen, effectText(a, it.Effect), 150, y, colDim) + a.Draw(screen, status, ScreenW-8-a.Width(status), y, col) + } + if len(s.items) == 0 { + a.Draw(screen, a.T("items.none"), 8, y0, colDim) + } + eff := g.Effects() + sum := fmt.Sprintf("%s: %s %.0f%% %s %.0f%% %s %.0f%% %s %.0f%% %s %.0f%%", a.T("items.total"), + a.T("stat.speed"), 100*eff.Stats[0], a.T("stat.thirst"), 100*eff.Stats[1], a.T("stat.focus"), 100*eff.Stats[2], + a.T("stat.toilet"), 100*eff.Stats[3], a.T("stat.luck"), 100*eff.Stats[4]) + a.Draw(screen, sum, 8, 134, colDim) + if s.msg != "" { + a.Draw(screen, s.msg, 8, ScreenH-26, colKnown) + } + a.Draw(screen, a.T("items.hint"), 8, ScreenH-13, colDim) +} diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go index bb2f06e..9dda08d 100644 --- a/internal/game/scene_level.go +++ b/internal/game/scene_level.go @@ -36,6 +36,9 @@ type LevelScene struct { animT float64 hideMap bool // M döljer/visar minikartan + shop *shop // öppen butik i kiosken (pausar banan) + outcome LevelOutcome // progression från banan (fylls i när den avgjorts) + // Tåg och effekter (M7b). tracks []trackInfo rails map[level.Point]bool @@ -45,15 +48,16 @@ type LevelScene struct { } // NewLevelScene laddar banan och ställer spelaren på startrutan. Kunskap, -// plånbok och debuffar tas från Game (delas mellan banor); seed styr slumpen. -func NewLevelScene(a *Assets, id string, g *Game, seed rng.Seed) (*LevelScene, error) { +// plånbok, debuffar, skills och items tas från Game; seed styr slumpen. +func NewLevelScene(g *Game, id string, seed rng.Seed) (*LevelScene, error) { + a := g.Assets lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json")) if err != nil { return nil, err } s := &LevelScene{ id: id, - run: sim.NewRun(lvl, sim.RunOptions{Dict: a.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed}), + run: sim.NewRun(lvl, g.runOptions(id, seed)), cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)), } s.cam.Margin = TileSize // rälsen ligger i kantraden: visa en ruta utanför kartan @@ -102,23 +106,27 @@ func (s *LevelScene) followPlayer() { } func (s *LevelScene) Update(g *Game) error { + if s.shop != nil { // butiken pausar banan + s.updateShop(g) + return nil + } s.updateEffects(g) if s.run.Mini != nil { // Minispel: all input går dit; Esc avbryter minispelet, inte banan. mx, my := miniMoveXY() s.run.StepMini(dt, minigame.Input{Move: my, MoveX: mx, Confirm: confirmJustPressed(), Hold: confirmHeld(), Cancel: backJustPressed()}) if s.run.Outcome != sim.Ongoing { - g.levelEnded(s.run) + s.outcome = g.levelEnded(s.run, s.id) g.SetScene(NewResultScene(s)) } return nil } if backJustPressed() { - m, err := NewMenuScene(g.Assets) - if err != nil { - return err - } - g.SetScene(m) + g.SetScene(NewMapScene(g)) + return nil + } + if shopJustPressed() && !s.run.Player.Moving() && s.run.Level.At(s.run.Player.Tile.X, s.run.Player.Tile.Y) == level.Kiosk { + s.openShop(g) return nil } if helpJustPressed() { @@ -135,7 +143,7 @@ func (s *LevelScene) Update(g *Game) error { } s.followPlayer() if s.run.Outcome != sim.Ongoing { - g.levelEnded(s.run) + s.outcome = g.levelEnded(s.run, s.id) g.SetScene(NewResultScene(s)) } return nil @@ -227,7 +235,11 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) { px, py := leader.WorldPos(TileSize) op.GeoM.Reset() op.GeoM.Translate(ox+math.Round(px), oy+math.Round(py)) + tint := g.Effects().Tint + op.ColorScale.Reset() + op.ColorScale.Scale(tint[0], tint[1], tint[2], 1) screen.DrawImage(g.Assets.PlayerFrame(leader.Facing, leader.Moving(), s.animT), &op) + op.ColorScale.Reset() s.drawUrge(g, screen, s.run.Party.Members[0], int(ox+math.Round(px)), int(oy+math.Round(py))) s.drawIndicator(screen, ox, oy) @@ -235,6 +247,9 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) { if s.run.Mini != nil { s.drawMinigame(g, screen) } + if s.shop != nil { + s.drawShop(g, screen) + } } // memberTints färgar följarnas sprites tills de har egna. @@ -308,8 +323,8 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { a.Draw(screen, wallet, 4, LineH, walletCol) s.drawMeters(g, screen) - if r.Outcome != sim.Ongoing || r.Mini != nil { - return // resultatpanelen eller minispelet tar över; ingen inforuta eller minikarta + if r.Outcome != sim.Ongoing || r.Mini != nil || s.shop != nil { + return // resultatpanelen, minispelet eller butiken tar över; ingen inforuta eller minikarta } lines, accent, bar := s.infoLines(a) if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec { @@ -457,9 +472,9 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar 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(fmt.Sprintf(a.T("hud.kiosk_prompt"), n, sim.KioskPriceYen*n)+" "+a.T("hud.shop_prompt"), colGood)}, colGood, false } - return []line{plainLine(a.T("hud.kiosk_none"), colDim)}, accent, false + return []line{plainLine(a.T("hud.kiosk_none")+" "+a.T("hud.shop_prompt"), colDim)}, accent, false case level.TicketMachine: if lvl.TicketRequired { return []line{plainLine(fmt.Sprintf(a.T("hud.machine_ticket"), r.Wallet.Cash), colGood)}, colGood, false diff --git a/internal/game/scene_map.go b/internal/game/scene_map.go new file mode 100644 index 0000000..f13972f --- /dev/null +++ b/internal/game/scene_map.go @@ -0,0 +1,227 @@ +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/buildinfo" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" +) + +// MapScene är bankartan i spelbrädesstil: huvudbanorna på en slingrande +// linje från flygplatsen, sidobanorna som avstickare ovanför. Låsta banor +// är grå, klarade visar stjärnor. +type MapScene struct { + mains []*level.Level + sides map[string][]*level.Level // parent -> sidobanor + sel int // vald huvudbana + side int // 0 = huvudbanan, 1.. = sidobana nr + msg string +} + +type mapNode struct { + lvl *level.Level + x, y int +} + +// NewMapScene bygger kartan från alla banor. +func NewMapScene(g *Game) *MapScene { + m := &MapScene{sides: map[string][]*level.Level{}} + for _, l := range g.Levels { + if l.IsSide() { + m.sides[l.Parent] = append(m.sides[l.Parent], l) + } else { + m.mains = append(m.mains, l) + } + } + sort.Slice(m.mains, func(i, j int) bool { return m.mains[i].Order < m.mains[j].Order }) + // Välj den första banan som inte är klarad. + for i, l := range m.mains { + if !g.Progress.Cleared(l.ID) { + m.sel = i + break + } + } + return m +} + +// nodes ger positionerna på kartan. +func (m *MapScene) nodes() ([]mapNode, map[string][]mapNode) { + n := len(m.mains) + var mains []mapNode + sides := map[string][]mapNode{} + for i, l := range m.mains { + x := 36 + if n > 1 { + x = 36 + i*(ScreenW-72)/(n-1) + } + y := 104 + if i%2 == 1 { + y = 116 + } + mains = append(mains, mapNode{l, x, y}) + for k, s := range m.sides[l.ID] { + sides[l.ID] = append(sides[l.ID], mapNode{s, x + 12 + k*18, y - 40}) + } + } + return mains, sides +} + +func (m *MapScene) current() *level.Level { + if len(m.mains) == 0 { + return nil + } + main := m.mains[m.sel] + if m.side > 0 { + if s := m.sides[main.ID]; m.side-1 < len(s) { + return s[m.side-1] + } + } + return main +} + +func (m *MapScene) Update(g *Game) error { + switch { + case backJustPressed(): + return ebiten.Termination + case wordsJustPressed(): + g.SetScene(NewWordsScene(g.Assets, m)) + return nil + case skillsJustPressed(): + g.SetScene(NewSkillsScene(g, m)) + return nil + case itemsJustPressed(): + g.SetScene(NewItemsScene(g, m)) + return nil + case phoneJustPressed(): + g.Wallet.Phone = !g.Wallet.Phone + g.Save() + } + if n := len(m.mains); n > 0 { + mx, my := miniMoveXY() + if mx != 0 { + m.sel = (m.sel + mx + n) % n + m.side = 0 + } + if my != 0 { + sides := len(m.sides[m.mains[m.sel].ID]) + m.side = max(0, min(sides, m.side-my)) // upp = ut på sidospåret + } + if confirmJustPressed() { + l := m.current() + if !g.Progress.Unlocked(g.Levels, l.ID) { + m.msg = g.Assets.T("map.locked") + return nil + } + s, err := g.startLevel(l.ID) + if err != nil { + m.msg = err.Error() + return nil + } + g.SetScene(s) + } + } + return nil +} + +func (m *MapScene) Draw(g *Game, screen *ebiten.Image) { + a := g.Assets + p := g.Progress + 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("game.title"), 8, 5, colText) + hdr := fmt.Sprintf("%s %d %s %d/%d ★ %d", a.T("map.level"), p.Level(), a.T("map.xp"), p.XP, p.XP+progress_XPToNext(p.XP), p.TotalStars()) + a.Draw(screen, hdr, ScreenW-8-a.Width(hdr), 6, colKnown) + + mains, sides := m.nodes() + // Linjen mellan huvudbanorna. + for i := 1; i < len(mains); i++ { + drawLine(screen, mains[i-1].x, mains[i-1].y, mains[i].x, mains[i].y, colBar) + } + for _, mn := range mains { + for _, sn := range sides[mn.lvl.ID] { + drawLine(screen, mn.x, mn.y, sn.x, sn.y, colBar) + } + } + cur := m.current() + drawNode := func(n mapNode, side bool) { + sz := float32(14) + if side { + sz = 10 + } + col := colDim + unlocked := p.Unlocked(g.Levels, n.lvl.ID) + switch { + case p.Cleared(n.lvl.ID): + col = colGood + case unlocked: + col = colText + } + vector.DrawFilledRect(screen, float32(n.x)-sz/2, float32(n.y)-sz/2, sz, sz, col, false) + if n.lvl == cur { + vector.DrawFilledRect(screen, float32(n.x)-sz/2-2, float32(n.y)-sz/2-2, sz+4, 2, colKnown, false) + vector.DrawFilledRect(screen, float32(n.x)-sz/2-2, float32(n.y)+sz/2, sz+4, 2, colKnown, false) + } + if r := p.Levels[n.lvl.ID]; r != nil && r.Cleared { + st := strings.Repeat("★", r.Stars) + a.Draw(screen, st, n.x-a.Width(st)/2, n.y+int(sz)/2+2, colWarn) + } + if !unlocked { // lås: en liten bygel + låda + vector.DrawFilledRect(screen, float32(n.x)-3, float32(n.y)-1, 6, 5, colPanel, false) + vector.DrawFilledRect(screen, float32(n.x)-2, float32(n.y)-4, 4, 3, colPanel, false) + vector.DrawFilledRect(screen, float32(n.x)-1, float32(n.y)-3, 2, 2, colDim, false) + } + } + for _, n := range mains { + drawNode(n, false) + for _, s := range sides[n.lvl.ID] { + drawNode(s, true) + } + } + // Info om vald bana. + if cur != nil { + name := cur.Name.Get(UILang) + " " + cur.Name.Get("ja") + if cur.IsSide() { + name = a.T("map.side") + ": " + name + } + a.Draw(screen, name, 8, 134, colText) + info := fmt.Sprintf("→ %s %s %s %s %d", cur.Destination.Get("ja"), cur.Destination.Get("romaji"), + strings.Repeat("★", cur.Difficulty)+strings.Repeat("☆", 5-cur.Difficulty), a.T("map.party"), cur.Party) + if g.State.Current != nil && g.State.Current.LevelID == cur.ID { + info += " " + a.T("map.resume") + } + a.Draw(screen, info, 8, 146, colDim) + if !p.Unlocked(g.Levels, cur.ID) { + a.Draw(screen, a.T("map.locked"), 150, 30, colBad) + } else if m.msg != "" { + a.Draw(screen, m.msg, 150, 30, colBad) + } + } + onoff := a.T("menu.off") + if g.Wallet.Phone { + onoff = a.T("menu.on") + } + foot := fmt.Sprintf("%s %d %s %d %s %d¥", a.T("map.skill_points"), p.SkillPoints, a.T("words.points"), g.Know.Points, + a.T("hud.cash"), g.Wallet.Cash) + a.Draw(screen, foot, 8, 30, colDim) + a.Draw(screen, fmt.Sprintf(a.T("menu.phone"), onoff), 8, 42, colDim) + if len(g.Profile.Debuffs) > 0 { + var parts []string + for _, d := range g.Profile.Debuffs { + parts = append(parts, fmt.Sprintf("%s (%d)", a.T("debuff."+string(d.Kind)), d.LevelsLeft)) + } + a.Draw(screen, a.T("hud.debuffs")+": "+strings.Join(parts, ", "), 8, 54, colBad) + } + a.Draw(screen, a.T("map.hint"), 8, ScreenH-24, colDim) + a.Draw(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-12, colDim) +} + +// drawLine ritar en tjock linje mellan två punkter. +func drawLine(screen *ebiten.Image, x0, y0, x1, y1 int, col color.Color) { + vector.StrokeLine(screen, float32(x0), float32(y0), float32(x1), float32(y1), 2, col, false) +} diff --git a/internal/game/scene_menu.go b/internal/game/scene_menu.go deleted file mode 100644 index cd1ea64..0000000 --- a/internal/game/scene_menu.go +++ /dev/null @@ -1,112 +0,0 @@ -package game - -import ( - "fmt" - "image/color" - "path/filepath" - "strings" - - "github.com/hajimehoshi/ebiten/v2" - "github.com/hajimehoshi/ebiten/v2/vector" - - "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/buildinfo" - "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" -) - -type menuEntry struct { - id string - name string - dest string - stars string -} - -// MenuScene listar banorna och låter spelaren välja. -type MenuScene struct { - entries []menuEntry - sel int - err error -} - -// NewMenuScene läser in banlistan. -func NewMenuScene(a *Assets) (*MenuScene, error) { - ids, err := level.List(a.LevelsDir) - if err != nil { - return nil, err - } - m := &MenuScene{} - for _, id := range ids { - l, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json")) - if err != nil { - m.err = err - continue - } - m.entries = append(m.entries, menuEntry{ - id: id, - name: l.Name.Get(UILang) + " " + l.Name.Get("ja"), - dest: l.Destination.Get("ja") + " " + l.Destination.Get("romaji"), - stars: strings.Repeat("★", l.Difficulty) + strings.Repeat("☆", 5-l.Difficulty), - }) - } - return m, nil -} - -func (m *MenuScene) Update(g *Game) error { - if backJustPressed() { - return ebiten.Termination - } - if wordsJustPressed() { - g.SetScene(NewWordsScene(g.Assets, m)) - return nil - } - if phoneJustPressed() { - g.Wallet.Phone = !g.Wallet.Phone - } - 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, g.seed()) - if err != nil { - m.err = err - return nil - } - g.SetScene(s) - } - } - return nil -} - -func (m *MenuScene) Draw(g *Game, screen *ebiten.Image) { - a := g.Assets - 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("game.title"), 8, 5, colText) - a.Draw(screen, a.T("menu.choose_level"), 8, 30, colDim) - - for i, e := range m.entries { - y := 46 + i*14 - if i == m.sel { - vector.DrawFilledRect(screen, 6, float32(y-2), ScreenW-12, 13, colBar, false) - } - a.Draw(screen, e.name, 12, y, colText) - a.Draw(screen, "→ "+e.dest, 150, y, colKnown) - a.Draw(screen, e.stars, ScreenW-62, y, colWarn) - } - if m.err != nil { - a.Draw(screen, "FEL: "+m.err.Error(), 8, ScreenH-40, colBad) - } - onoff := a.T("menu.off") - if g.Wallet.Phone { - onoff = a.T("menu.on") - } - a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d %s %d¥ %s %d¥ %s", a.T("hud.japanese"), g.Know.Level(), g.Know.Count(), a.T("hud.words"), - a.T("words.points"), g.Know.Points, a.T("hud.card"), g.Wallet.Card, a.T("hud.cash"), g.Wallet.Cash, fmt.Sprintf(a.T("menu.phone"), onoff)), 8, ScreenH-38, colDim) - if len(g.Profile.Debuffs) > 0 { - var parts []string - for _, d := range g.Profile.Debuffs { - parts = append(parts, fmt.Sprintf("%s (%d)", a.T("debuff."+string(d.Kind)), d.LevelsLeft)) - } - a.Draw(screen, a.T("hud.debuffs")+": "+strings.Join(parts, ", "), 8, ScreenH-50, colBad) - } - a.Draw(screen, a.T("menu.hint"), 8, ScreenH-26, colDim) - a.Draw(screen, 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 09a3e9a..1efe3f0 100644 --- a/internal/game/scene_result.go +++ b/internal/game/scene_result.go @@ -23,17 +23,13 @@ func (r *ResultScene) Update(g *Game) error { case wordsJustPressed(): g.SetScene(NewWordsScene(g.Assets, r)) case inpututil_R(): - s, err := NewLevelScene(g.Assets, r.from.id, g, g.seed()) + s, err := g.startLevel(r.from.id) if err != nil { return err } g.SetScene(s) case confirmJustPressed() || backJustPressed(): - m, err := NewMenuScene(g.Assets) - if err != nil { - return err - } - g.SetScene(m) + g.SetScene(NewMapScene(g)) } return nil } @@ -42,7 +38,7 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { r.from.Draw(g, screen) // banan i bakgrunden run := r.from.run a := g.Assets - vector.DrawFilledRect(screen, 20, 30, ScreenW-40, 138, colPanel, false) + vector.DrawFilledRect(screen, 20, 30, ScreenW-40, 144, colPanel, false) accent, headline := colBad, a.T("level.lost") if run.Outcome == sim.Won { @@ -96,6 +92,37 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { a.Draw(screen, msg, 28, y, colKnown) y += LineH } + if o := r.from.outcome; run.Outcome == sim.Won { + prog := fmt.Sprintf("+%d %s", o.XP, a.T("map.xp")) + if o.LevelUps > 0 { + prog += fmt.Sprintf(" %s %d (+%d %s)", a.T("result.level_up"), g.Progress.Level(), o.LevelUps+o.NewPoints, a.T("map.skill_points")) + } else if o.NewPoints > 0 { + prog += fmt.Sprintf(" +%d %s", o.NewPoints, a.T("map.skill_points")) + } + if len(o.NewItems) > 0 { + var names []string + for _, id := range o.NewItems { + names = append(names, g.Catalog.Item(id).Name.Get(UILang)) + } + prog += " " + a.T("result.new_items") + ": " + strings.Join(names, ", ") + } + if len(o.Unlocked) > 0 { + var names []string + for _, id := range o.Unlocked { + if l := g.LevelByID(id); l != nil { + names = append(names, l.Name.Get(UILang)) + } + } + prog += " " + a.T("result.unlocked") + ": " + strings.Join(names, ", ") + } + for i, l := range a.wrapLine(plainLine(prog, colGood), ScreenW-56) { + if i > 1 { + break + } + a.drawLines(screen, []line{l}, 28, y) + y += LineH + } + } if len(run.NewDebuffs) > 0 { var parts []string for _, d := range run.NewDebuffs { @@ -104,7 +131,9 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { a.Draw(screen, a.T("result.new_debuffs")+": "+strings.Join(parts, ", "), 28, y, colBad) y += LineH } - a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d (%d %s)", a.T("hud.japanese"), run.Know.Level(), run.Know.Count(), - a.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, 155, colDim) + if y <= 149 { + 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, 161, colDim) } diff --git a/internal/game/scene_skills.go b/internal/game/scene_skills.go new file mode 100644 index 0000000..6651861 --- /dev/null +++ b/internal/game/scene_skills.go @@ -0,0 +1,113 @@ +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/progress" +) + +// SkillsScene är skillträdet: grenar med noder, kostnad i skillpoäng och krav. +type SkillsScene struct { + back Scene + skills []progress.Skill + sel int + top int + msg string +} + +const skillRows = 8 + +// NewSkillsScene listar skills gren för gren. +func NewSkillsScene(g *Game, back Scene) *SkillsScene { + sk := append([]progress.Skill(nil), g.Catalog.Skills...) + sort.SliceStable(sk, func(i, j int) bool { + if sk[i].Branch != sk[j].Branch { + return sk[i].Branch < sk[j].Branch + } + return len(sk[i].Requires) < len(sk[j].Requires) + }) + return &SkillsScene{back: back, skills: sk} +} + +func (s *SkillsScene) Update(g *Game) error { + if backJustPressed() || skillsJustPressed() { + g.SetScene(s.back) + return nil + } + if n := len(s.skills); n > 0 { + s.sel = (s.sel + menuMove() + n) % n + if s.sel < s.top { + s.top = s.sel + } + if s.sel >= s.top+skillRows { + s.top = s.sel - skillRows + 1 + } + if confirmJustPressed() { + sk := s.skills[s.sel] + switch { + case g.Progress.Has(sk.ID): + s.msg = g.Assets.T("skills.already") + case g.Progress.Unlock(g.Catalog, sk.ID): + s.msg = fmt.Sprintf(g.Assets.T("skills.unlocked"), sk.Name.Get(UILang)) + g.Save() + case g.Progress.SkillPoints < sk.Cost: + s.msg = fmt.Sprintf(g.Assets.T("skills.need_points"), sk.Cost) + default: + s.msg = g.Assets.T("skills.need_req") + } + } + } + return nil +} + +func (s *SkillsScene) Draw(g *Game, screen *ebiten.Image) { + a := g.Assets + p := g.Progress + 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("skills.title"), 8, 5, colText) + hdr := fmt.Sprintf("%s %d %s %d %s %d", a.T("map.skill_points"), p.SkillPoints, a.T("map.level"), p.Level(), a.T("skills.to_next"), progress_XPToNext(p.XP)) + a.Draw(screen, hdr, ScreenW-8-a.Width(hdr), 6, colKnown) + + y0 := 30 + for i := s.top; i < len(s.skills) && i < s.top+skillRows; i++ { + sk := s.skills[i] + y := y0 + (i-s.top)*LineH + if i == s.sel { + vector.DrawFilledRect(screen, 4, float32(y-1), ScreenW-8, LineH, colBar, false) + } + col, status := colDim, strings.Repeat("●", sk.Cost) + switch { + case p.Has(sk.ID): + col, status = colGood, "✓" + case p.CanUnlock(g.Catalog, sk.ID): + col = colKnown + } + a.Draw(screen, sk.Branch, 8, y, colDim) + a.Draw(screen, sk.Name.Get(UILang), 80, y, col) + if len(sk.Requires) > 0 { + req := make([]string, len(sk.Requires)) + for k, r := range sk.Requires { + req[k] = g.Catalog.Skill(r).Name.Get(UILang) + } + a.Draw(screen, "← "+strings.Join(req, ", "), 190, y, colDim) + } + a.Draw(screen, status, ScreenW-8-a.Width(status), y, col) + } + if len(s.skills) > 0 { + sel := s.skills[s.sel] + for i, l := range a.wrapLine(plainLine(sel.Desc.Get(UILang), colText), ScreenW-16) { + a.drawLines(screen, []line{l}, 8, 130+i*LineH) + } + } + if s.msg != "" { + a.Draw(screen, s.msg, 8, ScreenH-26, colKnown) + } + a.Draw(screen, a.T("skills.hint"), 8, ScreenH-13, colDim) +} diff --git a/internal/game/scene_words.go b/internal/game/scene_words.go index c2be25e..badb4b4 100644 --- a/internal/game/scene_words.go +++ b/internal/game/scene_words.go @@ -60,6 +60,7 @@ func (w *WordsScene) Update(g *Game) error { 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)) + g.Save() default: w.msg = fmt.Sprintf(g.Assets.T("words.too_expensive"), lang.WordCost(e)) } diff --git a/internal/game/shop.go b/internal/game/shop.go new file mode 100644 index 0000000..7eaea31 --- /dev/null +++ b/internal/game/shop.go @@ -0,0 +1,104 @@ +package game + +import ( + "fmt" + "image" + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress" +) + +// Butiken i kiosken: banans sortiment (eller ett seedat urval), köps kontant. + +type shop struct { + items []progress.Item + cursor int + msg string +} + +const shopSize = 5 + +func (s *LevelScene) openShop(g *Game) { + sh := &shop{} + ids := s.run.Level.Shop + if len(ids) == 0 { + // Seedat urval ur allt köpbart, så att samma omgång ger samma butik. + all := g.Catalog.Purchasable() + r := s.run.Seed.Derive("shop").New() + r.Shuffle(len(all), func(i, j int) { all[i], all[j] = all[j], all[i] }) + for _, it := range all { + ids = append(ids, it.ID) + if len(ids) >= shopSize { + break + } + } + } + for _, id := range ids { + if it := g.Catalog.Item(id); it != nil { + sh.items = append(sh.items, *it) + } + } + s.shop = sh +} + +func (s *LevelScene) updateShop(g *Game) { + sh := s.shop + if backJustPressed() || shopJustPressed() { + s.shop = nil + return + } + if n := len(sh.items); n > 0 { + sh.cursor = (sh.cursor + menuMove() + n) % n + if confirmJustPressed() { + it := sh.items[sh.cursor] + switch { + case g.Progress.Own(it.ID): + sh.msg = g.Assets.T("shop.owned") + case g.Progress.Buy(g.Catalog, it.ID, g.Wallet.PayCash): + sh.msg = fmt.Sprintf(g.Assets.T("shop.bought"), it.Name.Get(UILang)) + g.Save() + default: + sh.msg = g.Assets.T("shop.too_expensive") + } + } + } +} + +func (s *LevelScene) drawShop(g *Game, screen *ebiten.Image) { + a := g.Assets + sh := s.shop + p := image.Rect(12, 28, ScreenW-12, ScreenH-10) + vector.DrawFilledRect(screen, float32(p.Min.X), float32(p.Min.Y), float32(p.Dx()), float32(p.Dy()), colPanel, false) + vector.DrawFilledRect(screen, float32(p.Min.X), float32(p.Min.Y), float32(p.Dx()), 3, colKnown, false) + x, y := p.Min.X+8, p.Min.Y+8 + a.DrawBig(screen, "売店", x, y, colKnown) + a.Draw(screen, fmt.Sprintf("%s %s %d¥", a.T("shop.title"), a.T("hud.cash"), g.Wallet.Cash), x+36, y+2, colText) + y += 24 + for i, it := range sh.items { + ry := y + i*LineH + if i == sh.cursor { + vector.DrawFilledRect(screen, float32(x-2), float32(ry-1), float32(p.Dx()-12), LineH, colBar, false) + } + col := colText + if g.Progress.Own(it.ID) { + col = colGood + } else if it.Price > g.Wallet.Cash { + col = colDim + } + a.Draw(screen, it.Name.Get(UILang), x, ry, col) + a.Draw(screen, effectText(a, it.Effect), x+110, ry, colDim) + price := fmt.Sprintf("%d円", it.Price) + if g.Progress.Own(it.ID) { + price = a.T("items.owned_short") + } + a.Draw(screen, price, p.Max.X-10-a.Width(price), ry, col) + } + if sh.msg != "" { + a.Draw(screen, sh.msg, x, p.Max.Y-28, colKnown) + } + a.Draw(screen, a.T("shop.hint"), x, p.Max.Y-16, colDim) + _ = color.RGBA{} +} diff --git a/internal/level/level.go b/internal/level/level.go index 5a925c3..3420778 100644 --- a/internal/level/level.go +++ b/internal/level/level.go @@ -172,9 +172,20 @@ type SignText struct { Text Text `json:"text"` } +// Rewards är vad en klarad bana ger utöver poäng. +type Rewards struct { + Items []string `json:"items"` // item-id:n (t.ex. skins) som delas ut första gången + SkillPoints int `json:"skillPoints"` // extra skillpoäng första gången +} + // Level är en bana. type Level struct { ID string `json:"id"` + Kind string `json:"kind"` // "main" (standard) eller "side" + Order int `json:"order"` // huvudbanornas ordning på bankartan (1, 2, …); sidobanor ärver förälderns + Parent string `json:"parent"` // sidobanans huvudbana – låses upp när den är klarad + Rewards Rewards `json:"rewards"` + Shop []string `json:"shop"` // items som kiosken säljer på banan (tom = seedat urval) Name Text `json:"name"` Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår) Destination Text `json:"destination"` // dit spelaren ska @@ -247,6 +258,22 @@ func (l *Level) Validate() error { if l.ID == "" { return fmt.Errorf("level: saknar id") } + switch l.Kind { + case "": + l.Kind = "main" + case "main", "side": + default: + return fmt.Errorf("level %s: kind måste vara main eller side", l.ID) + } + if l.Kind == "side" && l.Parent == "" { + return fmt.Errorf("level %s: sidobana utan parent", l.ID) + } + if l.Kind == "main" && l.Parent != "" { + return fmt.Errorf("level %s: huvudbana med parent", l.ID) + } + if l.Order < 0 { + return fmt.Errorf("level %s: negativ order", l.ID) + } if len(l.Grid) == 0 { return fmt.Errorf("level %s: tomt rutnät", l.ID) } @@ -534,6 +561,26 @@ func LoadFile(path string) (*Level, error) { return Parse(b) } +// IsSide rapporterar om banan är en sidobana. +func (l *Level) IsSide() bool { return l.Kind == "side" } + +// LoadAll läser alla banor i en mapp, sorterade på id. +func LoadAll(dir string) ([]*Level, error) { + ids, err := List(dir) + if err != nil { + return nil, err + } + var out []*Level + for _, id := range ids { + l, err := LoadFile(filepath.Join(dir, id+".json")) + if err != nil { + return nil, err + } + out = append(out, l) + } + return out, nil +} + // List returnerar id:n (filnamn utan .json) för alla banor i en mapp, sorterade. func List(dir string) ([]string, error) { files, err := filepath.Glob(filepath.Join(dir, "*.json")) diff --git a/internal/level/level_test.go b/internal/level/level_test.go index 436c92c..cc1261e 100644 --- a/internal/level/level_test.go +++ b/internal/level/level_test.go @@ -150,6 +150,9 @@ func TestAllShippedLevels(t *testing.T) { 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)") } + if l.Kind == "main" && l.Order == 0 { + t.Errorf("huvudbanor behöver order för bankartan") + } 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) @@ -384,3 +387,46 @@ func TestTrackTile(t *testing.T) { t.Fatal("räls till höger om perrongen är vertikal") } } + +func TestKindOrderParentAndLoadAll(t *testing.T) { + l := mustParse(t, sample) + if l.Kind != "main" || l.IsSide() { + t.Fatal("standard är main") + } + if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "kind": "side"`, 1))); err == nil { + t.Fatal("sidobana utan parent ska ge fel") + } + if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "parent": "x"`, 1))); err == nil { + t.Fatal("huvudbana med parent ska ge fel") + } + if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "kind": "bus"`, 1))); err == nil { + t.Fatal("okänd kind ska ge fel") + } + side := mustParse(t, strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "kind": "side", "parent": "001", "rewards": {"items": ["skin_kimono"], "skillPoints": 1}`, 1)) + if !side.IsSide() || side.Parent != "001" || side.Rewards.SkillPoints != 1 || len(side.Rewards.Items) != 1 { + t.Fatal("sidobanans fält ska läsas") + } + dir, _ := res.Path("data", "levels") + all, err := LoadAll(dir) + if err != nil || len(all) < 4 { + t.Fatalf("LoadAll: %d banor, %v", len(all), err) + } + sides := 0 + for _, l := range all { + if l.IsSide() { + sides++ + found := false + for _, m := range all { + if m.ID == l.Parent && !m.IsSide() { + found = true + } + } + if !found { + t.Errorf("%s: föräldern %q finns inte bland huvudbanorna", l.ID, l.Parent) + } + } + } + if sides == 0 { + t.Fatal("det ska finnas minst en sidobana") + } +} diff --git a/internal/minigame/crowd.go b/internal/minigame/crowd.go index 88e65e2..e407f43 100644 --- a/internal/minigame/crowd.go +++ b/internal/minigame/crowd.go @@ -17,11 +17,13 @@ type Crowd struct { const crowdZoneWidth = 0.22 -// NewCrowd seedar zonens läge och markörens fart. -func NewCrowd(seed rng.Seed, tries int) *Crowd { +// NewCrowd seedar zonens läge och markörens fart. luck (1 = normalt) gör +// zonen bredare eller smalare, inom rimliga gränser. +func NewCrowd(seed rng.Seed, tries int, luck float64) *Crowd { r := newRand(seed, "crowd") - lo := r.between(0.1, 0.9-crowdZoneWidth) - return &Crowd{dir: 1, Speed: r.between(0.9, 1.5), ZoneLo: lo, ZoneHi: lo + crowdZoneWidth, MaxTries: max(1, tries)} + width := min(0.6, max(0.1, crowdZoneWidth*max(0.5, min(2, luck)))) + lo := r.between(0.1, 0.9-width) + return &Crowd{dir: 1, Speed: r.between(0.9, 1.5), ZoneLo: lo, ZoneHi: lo + width, MaxTries: max(1, tries)} } // InZone rapporterar om markören är i zonen just nu. diff --git a/internal/minigame/minigame_test.go b/internal/minigame/minigame_test.go index e167d5c..c660266 100644 --- a/internal/minigame/minigame_test.go +++ b/internal/minigame/minigame_test.go @@ -114,7 +114,7 @@ func TestTicketBoardSeededAndAffordable(t *testing.T) { } func TestCrowdZoneAndAttempts(t *testing.T) { - g := NewCrowd(rng.FromInt(7), 2) + g := NewCrowd(rng.FromInt(7), 2, 1) if g.ZoneLo < 0.1 || g.ZoneHi > 0.9 || math.Abs((g.ZoneHi-g.ZoneLo)-crowdZoneWidth) > 1e-9 { t.Fatalf("zon %.2f–%.2f", g.ZoneLo, g.ZoneHi) } @@ -135,7 +135,7 @@ func TestCrowdZoneAndAttempts(t *testing.T) { t.Fatal("träff i zonen ska lyckas") } // Två bom = tåget går. - f := NewCrowd(rng.FromInt(7), 2) + f := NewCrowd(rng.FromInt(7), 2, 1) for n := 0; n < 2; n++ { for f.InZone() { f.Update(Input{}, frame) @@ -146,7 +146,7 @@ func TestCrowdZoneAndAttempts(t *testing.T) { t.Fatal("två bom ska misslyckas") } // Markören studsar och håller sig i 0..1. - b := NewCrowd(rng.FromInt(8), 1) + b := NewCrowd(rng.FromInt(8), 1, 1) for i := 0; i < 600; i++ { b.Update(Input{}, frame) if b.Pos < 0 || b.Pos > 1 { @@ -293,3 +293,16 @@ func TestSeatSelectionPreferences(t *testing.T) { t.Fatal("avbrutet sittplatsval = inget köp") } } + +func TestCrowdLuckWidensZone(t *testing.T) { + normal := NewCrowd(rng.FromInt(3), 2, 1) + lucky := NewCrowd(rng.FromInt(3), 2, 1.5) + unlucky := NewCrowd(rng.FromInt(3), 2, 0.5) + if lucky.ZoneHi-lucky.ZoneLo <= normal.ZoneHi-normal.ZoneLo || unlucky.ZoneHi-unlucky.ZoneLo >= normal.ZoneHi-normal.ZoneLo { + t.Fatal("tur ska ge bredare zon, otur smalare") + } + huge := NewCrowd(rng.FromInt(3), 2, 100) + if huge.ZoneHi-huge.ZoneLo > 0.6+1e-9 || huge.ZoneHi > 0.9+1e-9 { + t.Fatal("zonen klampas") + } +} diff --git a/internal/progress/catalog.go b/internal/progress/catalog.go new file mode 100644 index 0000000..d5839a4 --- /dev/null +++ b/internal/progress/catalog.go @@ -0,0 +1,168 @@ +// Package progress är spelarens progression: XP och nivåer, skill tree, +// items med buffar, klarade banor och upplåsning av bankartan. Ren Go; +// katalogerna (skills.json, items.json) läses från resources/data. +package progress + +import ( + "encoding/json" + "fmt" + "sort" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" +) + +// Effect är vad en skill eller ett item gör. Allt är valfritt. +type Effect struct { + Stats map[string]float64 `json:"stats"` // speed/thirst/focus/toilet/luck → multiplikator + Kana bool `json:"kana"` // kan läsa kana + FogRadius int `json:"fogRadius"` // extra siktsteg i gångarna + Landmarks bool `json:"landmarks"` // toaletter/kiosker/automater syns alltid på minikartan + FreeGates bool `json:"freeGates"` // spärrarna kostar inget (genväg) + RevealLevel string `json:"revealLevel"` // hela minikartan känd på banan + Battery bool `json:"battery"` // telefonen laddar inte ur +} + +// Skill är en nod i språk-/skillträdet. +type Skill struct { + ID string `json:"id"` + Branch string `json:"branch"` + Name level.Text `json:"name"` + Desc level.Text `json:"desc"` + Cost int `json:"cost"` + Requires []string `json:"requires"` + Effect Effect `json:"effect"` +} + +// Item är kläder, masker, hattar, prylar och skins. +type Item struct { + ID string `json:"id"` + Kind string `json:"kind"` + Slot string `json:"slot"` + Name level.Text `json:"name"` + Price int `json:"price"` // 0 = bara belöning + Effect Effect `json:"effect"` + Tint []float64 `json:"tint"` // färgton på spelaren (r, g, b) när det bärs +} + +// Slots är kroppsplatserna; ett item per plats bärs. +var Slots = []string{"head", "face", "body", "feet", "bag"} + +var statIndex = map[string]int{"speed": sim.StatSpeed, "thirst": sim.StatThirst, "focus": sim.StatFocus, "toilet": sim.StatToilet, "luck": sim.StatLuck} + +// Catalog är alla skills och items. +type Catalog struct { + Skills []Skill + Items []Item + skills map[string]*Skill + items map[string]*Item +} + +// LoadCatalog läser och validerar skills.json och items.json. +func LoadCatalog(skillsJSON, itemsJSON []byte) (*Catalog, error) { + var sk struct { + Skills []Skill `json:"skills"` + } + if err := json.Unmarshal(skillsJSON, &sk); err != nil { + return nil, fmt.Errorf("skills.json: %w", err) + } + var it struct { + Items []Item `json:"items"` + } + if err := json.Unmarshal(itemsJSON, &it); err != nil { + return nil, fmt.Errorf("items.json: %w", err) + } + c := &Catalog{Skills: sk.Skills, Items: it.Items, skills: map[string]*Skill{}, items: map[string]*Item{}} + for i := range c.Skills { + s := &c.Skills[i] + if s.ID == "" || s.Cost < 1 || s.Name["sv"] == "" || s.Name["en"] == "" { + return nil, fmt.Errorf("skill %q: saknar id/cost/namn", s.ID) + } + if _, dup := c.skills[s.ID]; dup { + return nil, fmt.Errorf("skill %q: dubblett", s.ID) + } + if err := s.Effect.validate(); err != nil { + return nil, fmt.Errorf("skill %q: %w", s.ID, err) + } + c.skills[s.ID] = s + } + for _, s := range c.Skills { + for _, r := range s.Requires { + if _, ok := c.skills[r]; !ok { + return nil, fmt.Errorf("skill %q kräver okänd skill %q", s.ID, r) + } + } + } + for i := range c.Items { + it := &c.Items[i] + if it.ID == "" || it.Name["sv"] == "" || it.Name["en"] == "" { + return nil, fmt.Errorf("item %q: saknar id/namn", it.ID) + } + if _, dup := c.items[it.ID]; dup { + return nil, fmt.Errorf("item %q: dubblett", it.ID) + } + if !validSlot(it.Slot) { + return nil, fmt.Errorf("item %q: okänd slot %q", it.ID, it.Slot) + } + if it.Price < 0 || (len(it.Tint) != 0 && len(it.Tint) != 3) { + return nil, fmt.Errorf("item %q: pris/tint", it.ID) + } + if err := it.Effect.validate(); err != nil { + return nil, fmt.Errorf("item %q: %w", it.ID, err) + } + c.items[it.ID] = it + } + return c, nil +} + +func (e Effect) validate() error { + for k, v := range e.Stats { + if _, ok := statIndex[k]; !ok { + return fmt.Errorf("okänd stat %q", k) + } + if v <= 0 || v > 3 { + return fmt.Errorf("stat %s=%v utanför 0..3", k, v) + } + } + return nil +} + +func validSlot(s string) bool { + for _, x := range Slots { + if x == s { + return true + } + } + return false +} + +// Skill slår upp en skill (nil om okänd). +func (c *Catalog) Skill(id string) *Skill { return c.skills[id] } + +// Item slår upp ett item (nil om okänt). +func (c *Catalog) Item(id string) *Item { return c.items[id] } + +// Purchasable är alla items med pris, billigast först. +func (c *Catalog) Purchasable() []Item { + var out []Item + for _, it := range c.Items { + if it.Price > 0 { + out = append(out, it) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Price < out[j].Price }) + return out +} + +// ThirdCost är vad den billigaste tredjedelen av alla köpbara items kostar +// tillsammans – designregeln säger att det ska vara mer än en omgångs +// startkassa, så att man knappt får tag på 33 % under en omgång. +func (c *Catalog) ThirdCost() int { + p := c.Purchasable() + n := (len(p) + 2) / 3 + sum := 0 + for _, it := range p[:n] { + sum += it.Price + } + return sum +} diff --git a/internal/progress/progress.go b/internal/progress/progress.go new file mode 100644 index 0000000..aec4e07 --- /dev/null +++ b/internal/progress/progress.go @@ -0,0 +1,262 @@ +package progress + +import ( + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" +) + +// XP: nivå n+1 nås när man samlat 150·n(n+1)/2 XP (150, 450, 900, …). +// Varje level-up ger en skillpoäng. +const XPPerLevelStep = 150 + +// LevelResult är bästa resultatet på en bana. +type LevelResult struct { + Cleared bool `json:"cleared"` + Stars int `json:"stars"` + Score int `json:"score"` + Attempts int `json:"attempts"` +} + +// Progress är det som växer mellan banor. +type Progress struct { + XP int `json:"xp"` + SkillPoints int `json:"skillPoints"` + Skills map[string]bool `json:"skills"` + Items map[string]bool `json:"items"` + Equipped map[string]string `json:"equipped"` // slot -> item-id + Levels map[string]*LevelResult `json:"levels"` +} + +// New börjar från noll. +func New() *Progress { + return &Progress{Skills: map[string]bool{}, Items: map[string]bool{}, Equipped: map[string]string{}, Levels: map[string]*LevelResult{}} +} + +func (p *Progress) ensure() { + if p.Skills == nil { + p.Skills = map[string]bool{} + } + if p.Items == nil { + p.Items = map[string]bool{} + } + if p.Equipped == nil { + p.Equipped = map[string]string{} + } + if p.Levels == nil { + p.Levels = map[string]*LevelResult{} + } +} + +// LevelFor är nivån (från 1) för en XP-summa. +func LevelFor(xp int) int { + n := 0 + for XPPerLevelStep*(n+1)*(n+2)/2 <= xp { + n++ + } + return n + 1 +} + +// XPToNext är hur mycket som saknas till nästa nivå. +func XPToNext(xp int) int { + n := LevelFor(xp) // nästa nivå är n+1, som kräver 150·n(n+1)/2 + return XPPerLevelStep*n*(n+1)/2 - xp +} + +// XPFor ger XP för en klarad bana. +func XPFor(score, stars int) int { return score/10 + 25*stars } + +// AddXP lägger till XP och ger en skillpoäng per level-up. Returnerar antalet level-ups. +func (p *Progress) AddXP(n int) int { + before := LevelFor(p.XP) + p.XP += max(0, n) + ups := LevelFor(p.XP) - before + p.SkillPoints += ups + return ups +} + +// Level är spelarens nivå. +func (p *Progress) Level() int { return LevelFor(p.XP) } + +// Has rapporterar om skillen är upplåst. +func (p *Progress) Has(skill string) bool { return p.Skills[skill] } + +// CanUnlock: har poängen, kraven uppfyllda och inte redan upplåst. +func (p *Progress) CanUnlock(c *Catalog, id string) bool { + s := c.Skill(id) + if s == nil || p.Has(id) || p.SkillPoints < s.Cost { + return false + } + for _, r := range s.Requires { + if !p.Has(r) { + return false + } + } + return true +} + +// Unlock låser upp en skill mot skillpoäng. +func (p *Progress) Unlock(c *Catalog, id string) bool { + if !p.CanUnlock(c, id) { + return false + } + p.ensure() + p.SkillPoints -= c.Skill(id).Cost + p.Skills[id] = true + return true +} + +// Own rapporterar om spelaren har itemet. +func (p *Progress) Own(item string) bool { return p.Items[item] } + +// Give ger ett item (belöning). Bärs direkt om platsen är ledig. Returnerar +// false om spelaren redan hade det. +func (p *Progress) Give(c *Catalog, id string) bool { + it := c.Item(id) + if it == nil || p.Own(id) { + return false + } + p.ensure() + p.Items[id] = true + if p.Equipped[it.Slot] == "" { + p.Equipped[it.Slot] = id + } + return true +} + +// Buy köper ett item kontant via pay. Returnerar false om det inte gick. +func (p *Progress) Buy(c *Catalog, id string, pay func(yen int) bool) bool { + it := c.Item(id) + if it == nil || it.Price <= 0 || p.Own(id) || !pay(it.Price) { + return false + } + return p.Give(c, id) +} + +// Equip bär ett ägt item på sin plats (ersätter det som satt där). +func (p *Progress) Equip(c *Catalog, id string) bool { + it := c.Item(id) + if it == nil || !p.Own(id) { + return false + } + p.ensure() + p.Equipped[it.Slot] = id + return true +} + +// Unequip tar av det som bärs på platsen. +func (p *Progress) Unequip(slot string) { delete(p.Equipped, slot) } + +// Worn är itemet på platsen, eller nil. +func (p *Progress) Worn(c *Catalog, slot string) *Item { + if id := p.Equipped[slot]; id != "" { + return c.Item(id) + } + return nil +} + +// Aggregate är de sammanlagda effekterna av skills och burna items. +type Aggregate struct { + Stats sim.Stats + Kana bool + FogRadius int + Landmarks bool + FreeGates bool + Battery bool + Reveal map[string]bool + Tint [3]float32 +} + +// Effects räknar ihop allt spelaren har. +func (p *Progress) Effects(c *Catalog) Aggregate { + a := Aggregate{Stats: sim.DefaultStats(), Reveal: map[string]bool{}, Tint: [3]float32{1, 1, 1}} + apply := func(e Effect) { + for k, v := range e.Stats { + a.Stats[statIndex[k]] *= v + } + a.Kana = a.Kana || e.Kana + a.FogRadius += e.FogRadius + a.Landmarks = a.Landmarks || e.Landmarks + a.FreeGates = a.FreeGates || e.FreeGates + a.Battery = a.Battery || e.Battery + if e.RevealLevel != "" { + a.Reveal[e.RevealLevel] = true + } + } + for id := range p.Skills { + if s := c.Skill(id); s != nil { + apply(s.Effect) + } + } + for _, slot := range Slots { + if it := p.Worn(c, slot); it != nil { + apply(it.Effect) + if len(it.Tint) == 3 { + for i := range a.Tint { + a.Tint[i] *= float32(it.Tint[i]) + } + } + } + } + return a +} + +// RecordLevel sparar ett resultat; bästa stjärnor/poäng behålls. Returnerar +// true första gången banan klaras. +func (p *Progress) RecordLevel(id string, won bool, stars, score int) (first bool) { + p.ensure() + r := p.Levels[id] + if r == nil { + r = &LevelResult{} + p.Levels[id] = r + } + r.Attempts++ + if !won { + return false + } + first = !r.Cleared + r.Cleared = true + r.Stars = max(r.Stars, stars) + r.Score = max(r.Score, score) + return first +} + +// Cleared rapporterar om banan är klarad. +func (p *Progress) Cleared(id string) bool { + r := p.Levels[id] + return r != nil && r.Cleared +} + +// Unlocked avgör om en bana går att spela: huvudbana 1 alltid, huvudbana n +// när huvudbana n−1 är klarad, sidobana när föräldern är klarad. +func (p *Progress) Unlocked(all []*level.Level, id string) bool { + var me *level.Level + for _, l := range all { + if l.ID == id { + me = l + } + } + if me == nil { + return false + } + if me.IsSide() { + return p.Cleared(me.Parent) + } + if me.Order <= 1 { + return true + } + for _, l := range all { + if !l.IsSide() && l.Order == me.Order-1 { + return p.Cleared(l.ID) + } + } + return false +} + +// TotalStars är summan av bästa stjärnor. +func (p *Progress) TotalStars() int { + n := 0 + for _, r := range p.Levels { + n += r.Stars + } + return n +} diff --git a/internal/progress/progress_test.go b/internal/progress/progress_test.go new file mode 100644 index 0000000..f02fbb6 --- /dev/null +++ b/internal/progress/progress_test.go @@ -0,0 +1,216 @@ +package progress + +import ( + "math" + "testing" + + "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/sim" +) + +func loadCatalog(t *testing.T) *Catalog { + t.Helper() + sk, err := res.ReadFile("data", "skills.json") + if err != nil { + t.Fatal(err) + } + it, err := res.ReadFile("data", "items.json") + if err != nil { + t.Fatal(err) + } + c, err := LoadCatalog(sk, it) + if err != nil { + t.Fatal(err) + } + return c +} + +func TestShippedCatalogAndThirdRule(t *testing.T) { + c := loadCatalog(t) + if len(c.Skills) < 10 || len(c.Items) < 20 { + t.Fatalf("skills=%d items=%d", len(c.Skills), len(c.Items)) + } + // Designregeln: billigaste tredjedelen av alla köpbara items kostar mer + // än startkassan, så att man knappt får tag på 33 % under en omgång. + if c.ThirdCost() <= sim.StartCashYen { + t.Fatalf("billigaste tredjedelen kostar %d ≤ startkassan %d", c.ThirdCost(), sim.StartCashYen) + } + // Alla banor som items pekar på finns. + dir, _ := res.Path("data", "levels") + all, err := level.LoadAll(dir) + if err != nil { + t.Fatal(err) + } + ids := map[string]bool{} + for _, l := range all { + ids[l.ID] = true + for _, r := range l.Rewards.Items { + if c.Item(r) == nil { + t.Errorf("%s belönar okänt item %q", l.ID, r) + } + } + for _, s := range l.Shop { + if it := c.Item(s); it == nil || it.Price == 0 { + t.Errorf("%s säljer okänt/gratis item %q", l.ID, s) + } + } + } + for _, it := range c.Items { + if it.Effect.RevealLevel != "" && !ids[it.Effect.RevealLevel] { + t.Errorf("%s avslöjar okänd bana %q", it.ID, it.Effect.RevealLevel) + } + } +} + +func TestCatalogValidation(t *testing.T) { + bad := []struct{ name, skills, items string }{ + {"okänd stat", `{"skills":[{"id":"a","cost":1,"name":{"sv":"A","en":"A"},"effect":{"stats":{"mana":2}}}]}`, `{"items":[]}`}, + {"okänt krav", `{"skills":[{"id":"a","cost":1,"name":{"sv":"A","en":"A"},"requires":["zzz"]}]}`, `{"items":[]}`}, + {"dubblett", `{"skills":[{"id":"a","cost":1,"name":{"sv":"A","en":"A"}},{"id":"a","cost":1,"name":{"sv":"A","en":"A"}}]}`, `{"items":[]}`}, + {"okänd slot", `{"skills":[]}`, `{"items":[{"id":"x","slot":"tail","name":{"sv":"X","en":"X"},"price":1}]}`}, + {"trasig tint", `{"skills":[]}`, `{"items":[{"id":"x","slot":"head","name":{"sv":"X","en":"X"},"price":1,"tint":[1,2]}]}`}, + } + for _, b := range bad { + if _, err := LoadCatalog([]byte(b.skills), []byte(b.items)); err == nil { + t.Errorf("%s: förväntade fel", b.name) + } + } +} + +func TestXPLevelsAndSkillPoints(t *testing.T) { + cases := map[int]int{0: 1, 149: 1, 150: 2, 449: 2, 450: 3, 900: 4, 1500: 5} + for xp, lvl := range cases { + if LevelFor(xp) != lvl { + t.Errorf("LevelFor(%d)=%d, vill ha %d", xp, LevelFor(xp), lvl) + } + } + if XPToNext(0) != 150 || XPToNext(150) != 300 || XPToNext(449) != 1 { + t.Fatalf("XPToNext: %d %d %d", XPToNext(0), XPToNext(150), XPToNext(449)) + } + p := New() + if ups := p.AddXP(XPFor(941, 3)); ups != 1 || p.SkillPoints != 1 || p.Level() != 2 { + t.Fatalf("ups=%d points=%d level=%d", ups, p.SkillPoints, p.Level()) + } + if ups := p.AddXP(1000); ups != 2 || p.SkillPoints != 3 { + t.Fatalf("två nivåer på en gång: ups=%d points=%d", ups, p.SkillPoints) + } + if p.AddXP(-5) != 0 || p.XP != XPFor(941, 3)+1000 { + t.Fatal("negativ XP ignoreras") + } +} + +func TestSkillTreeRequirementsAndCost(t *testing.T) { + c := loadCatalog(t) + p := New() + if p.CanUnlock(c, "focus1") || p.Unlock(c, "focus1") { + t.Fatal("utan poäng går inget") + } + p.SkillPoints = 3 + if p.CanUnlock(c, "focus2") { + t.Fatal("focus2 kräver focus1") + } + if !p.Unlock(c, "focus1") || p.SkillPoints != 2 || !p.Has("focus1") { + t.Fatal("focus1 kostar 1") + } + if !p.Unlock(c, "focus2") || p.SkillPoints != 0 { + t.Fatal("focus2 kostar 2 och kräver focus1") + } + if p.Unlock(c, "focus1") { + t.Fatal("inte två gånger") + } + eff := p.Effects(c) + if math.Abs(eff.Stats[sim.StatFocus]-1.25*1.25) > 1e-9 || eff.Kana { + t.Fatalf("effekter: %+v", eff) + } + p.SkillPoints = 5 + p.Unlock(c, "kana") + p.Unlock(c, "scout") + p.Unlock(c, "local") + eff = p.Effects(c) + if !eff.Kana || eff.FogRadius != 1 || !eff.Landmarks { + t.Fatalf("flaggor: %+v", eff) + } +} + +func TestItemsBuyEquipAndEffects(t *testing.T) { + c := loadCatalog(t) + p := New() + cash := 10000 + pay := func(yen int) bool { + if cash < yen { + return false + } + cash -= yen + return true + } + if !p.Buy(c, "sneakers", pay) || !p.Own("sneakers") || p.Equipped["feet"] != "sneakers" || cash != 10000-3500 { + t.Fatalf("köp: own=%v equipped=%v cash=%d", p.Own("sneakers"), p.Equipped, cash) + } + if p.Buy(c, "sneakers", pay) || p.Buy(c, "skin_kimono", pay) || p.Buy(c, "rail_pass", pay) { + t.Fatal("inte igen, inte gratisitems, inte utan råd (8000 > 6500)") + } + if !p.Buy(c, "geta", pay) || p.Equipped["feet"] != "sneakers" { + t.Fatal("andra skor köps men byter inte automatiskt") + } + if !p.Equip(c, "geta") || p.Equipped["feet"] != "geta" { + t.Fatal("Equip byter") + } + eff := p.Effects(c) + if math.Abs(eff.Stats[sim.StatSpeed]-0.9) > 1e-9 || math.Abs(eff.Stats[sim.StatLuck]-1.2) > 1e-9 { + t.Fatalf("geta: %+v", eff.Stats) + } + p.Unequip("feet") + if p.Effects(c).Stats[sim.StatSpeed] != 1 || p.Worn(c, "feet") != nil { + t.Fatal("Unequip") + } + if !p.Give(c, "skin_kimono") || p.Give(c, "skin_kimono") || p.Equipped["body"] != "skin_kimono" { + t.Fatal("belöning ges en gång och bärs om platsen är ledig") + } + eff = p.Effects(c) + if eff.Tint == [3]float32{1, 1, 1} { + t.Fatal("skinnet ska tona spelaren") + } + p.Give(c, "rail_pass") + p.Give(c, "map_shibuya") + p.Equip(c, "map_shibuya") // bara en pryl i väskan åt gången + eff = p.Effects(c) + if eff.FreeGates || !eff.Reveal["003-shibuya"] { + t.Fatalf("väskan: %+v", eff) + } + p.Equip(c, "rail_pass") + if !p.Effects(c).FreeGates { + t.Fatal("JR Pass i väskan ger gratis spärrar") + } +} + +func TestLevelRecordsAndUnlocking(t *testing.T) { + dir, _ := res.Path("data", "levels") + all, err := level.LoadAll(dir) + if err != nil { + t.Fatal(err) + } + p := New() + if !p.Unlocked(all, "001-flygplatsen") || p.Unlocked(all, "002-shinjuku") || p.Unlocked(all, "002b-kabukicho") || p.Unlocked(all, "003-shibuya") { + t.Fatal("bara första huvudbanan är öppen från start") + } + if p.RecordLevel("001-flygplatsen", false, 0, 0) || p.Cleared("001-flygplatsen") || p.Levels["001-flygplatsen"].Attempts != 1 { + t.Fatal("förlust räknas som försök") + } + if !p.RecordLevel("001-flygplatsen", true, 2, 700) || !p.Unlocked(all, "002-shinjuku") || p.Unlocked(all, "002b-kabukicho") { + t.Fatal("första klaring öppnar nästa huvudbana men inte sidobanan under 002") + } + if p.RecordLevel("001-flygplatsen", true, 3, 500) { + t.Fatal("andra klaring är inte första") + } + if r := p.Levels["001-flygplatsen"]; r.Stars != 3 || r.Score != 700 || r.Attempts != 3 { + t.Fatalf("bästa behålls: %+v", r) + } + p.RecordLevel("002-shinjuku", true, 1, 300) + if !p.Unlocked(all, "002b-kabukicho") || !p.Unlocked(all, "003-shibuya") || p.TotalStars() != 4 { + t.Fatalf("efter 002: sidobana och 003 öppna, stjärnor=%d", p.TotalStars()) + } + if p.Unlocked(all, "finns-inte") { + t.Fatal("okänd bana") + } +} diff --git a/internal/save/save.go b/internal/save/save.go new file mode 100644 index 0000000..14bfea1 --- /dev/null +++ b/internal/save/save.go @@ -0,0 +1,122 @@ +// Package save läser och skriver sparfilen: kunskap, plånbok, debuffar, +// progression och den pågående omgångens bana + seed. JSON med +// versionsfält så att gamla filer kan migreras. +package save + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" +) + +// Version är sparfilens formatversion. +const Version = 1 + +// AppDir är mappnamnet under användarens konfigmapp. +const AppDir = "metro-puzzle-jp" + +// RunRef pekar ut en pågående omgång så att den kan återupptas med samma +// slump (minispel, händelser, läsning). +type RunRef struct { + LevelID string `json:"levelId"` + Seed [2]uint64 `json:"seed"` + Started time.Time `json:"started"` +} + +// State är allt som sparas. +type State struct { + Version int `json:"version"` + SavedAt time.Time `json:"savedAt"` + Know *lang.Knowledge `json:"knowledge"` + Wallet *sim.Wallet `json:"wallet"` + Profile *sim.Profile `json:"profile"` + Progress *progress.Progress `json:"progress"` + Current *RunRef `json:"current,omitempty"` +} + +// New är ett nytt spel. +func New() *State { + return &State{Version: Version, Know: lang.NewKnowledge(), Wallet: sim.NewWallet(), Profile: &sim.Profile{}, Progress: progress.New()} +} + +// DefaultPath är sparfilens plats: ~/.config/metro-puzzle-jp/save.json på +// Linux, %AppData%\metro-puzzle-jp\save.json på Windows. +func DefaultPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, AppDir, "save.json"), nil +} + +// Load läser sparfilen. Saknas den returneras ett nytt spel (och exists=false). +func Load(path string) (st *State, exists bool, err error) { + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return New(), false, nil + } + if err != nil { + return nil, false, err + } + var s State + if err := json.Unmarshal(b, &s); err != nil { + return nil, true, fmt.Errorf("sparfilen %s är trasig: %w", path, err) + } + if err := migrate(&s); err != nil { + return nil, true, err + } + return &s, true, nil +} + +// migrate lyfter äldre versioner till den aktuella och fyller i luckor. +func migrate(s *State) error { + if s.Version > Version { + return fmt.Errorf("sparfilen är från en nyare version (%d > %d)", s.Version, Version) + } + // Version 0 (saknat fält) och 1 har samma form; här läggs framtida steg. + s.Version = Version + if s.Know == nil { + s.Know = lang.NewKnowledge() + } + if s.Know.Words == nil { + s.Know.Words = map[string]bool{} + } + if s.Know.Progress == nil { + s.Know.Progress = map[string]int{} + } + if s.Wallet == nil { + s.Wallet = sim.NewWallet() + } + if s.Profile == nil { + s.Profile = &sim.Profile{} + } + if s.Progress == nil { + s.Progress = progress.New() + } + return nil +} + +// Store skriver sparfilen atomiskt (tempfil + byte av namn). +func Store(path string, s *State) error { + s.Version = Version + s.SavedAt = time.Now() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} diff --git a/internal/save/save_test.go b/internal/save/save_test.go new file mode 100644 index 0000000..ad94e8f --- /dev/null +++ b/internal/save/save_test.go @@ -0,0 +1,78 @@ +package save + +import ( + "os" + "path/filepath" + "testing" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" +) + +func TestRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "save.json") + st, exists, err := Load(path) + if err != nil || exists || st.Version != Version || st.Progress == nil || st.Know.Count() != 0 { + t.Fatalf("saknad fil ska ge nytt spel: exists=%v err=%v", exists, err) + } + st.Know.Learn("改札") + st.Know.Points = 3 + st.Wallet.Card = 1234 + st.Wallet.Phone = true + st.Profile.Add(sim.Tired, 2) + st.Progress.AddXP(500) + st.Progress.Skills["kana"] = true + st.Progress.Items["geta"] = true + st.Progress.Equipped["feet"] = "geta" + st.Progress.RecordLevel("001-flygplatsen", true, 3, 941) + st.Current = &RunRef{LevelID: "002-shinjuku", Seed: [2]uint64{7, 99}} + if err := Store(path, st); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path + ".tmp"); err == nil { + t.Fatal("tempfilen ska vara borta") + } + got, exists, err := Load(path) + if err != nil || !exists { + t.Fatalf("exists=%v err=%v", exists, err) + } + if !got.Know.Knows("改札") || got.Know.Points != 3 || got.Wallet.Card != 1234 || !got.Wallet.Phone { + t.Fatal("kunskap/plånbok") + } + if !got.Profile.Has(sim.Tired) || got.Progress.XP != 500 || got.Progress.Level() != 3 || !got.Progress.Has("kana") || got.Progress.Equipped["feet"] != "geta" { + t.Fatalf("profil/progression: %+v", got.Progress) + } + if !got.Progress.Cleared("001-flygplatsen") || got.Progress.Levels["001-flygplatsen"].Score != 941 { + t.Fatal("banresultat") + } + if got.Current == nil || got.Current.LevelID != "002-shinjuku" || got.Current.Seed != [2]uint64{7, 99} { + t.Fatalf("pågående omgång: %+v", got.Current) + } + if got.SavedAt.IsZero() { + t.Fatal("SavedAt") + } +} + +func TestCorruptAndFutureVersions(t *testing.T) { + path := filepath.Join(t.TempDir(), "save.json") + os.WriteFile(path, []byte("{nope"), 0o644) + if _, _, err := Load(path); err == nil { + t.Fatal("trasig fil ska ge fel") + } + os.WriteFile(path, []byte(`{"version": 99}`), 0o644) + if _, _, err := Load(path); err == nil { + t.Fatal("nyare version ska ge fel") + } + // Version 0 utan fält migreras till ett komplett tillstånd. + os.WriteFile(path, []byte(`{"knowledge": {"words": {"改札": true}}}`), 0o644) + st, exists, err := Load(path) + if err != nil || !exists || st.Version != Version || !st.Know.Knows("改札") || st.Wallet == nil || st.Progress == nil || st.Profile == nil { + t.Fatalf("migrering: %+v err=%v", st, err) + } +} + +func TestDefaultPath(t *testing.T) { + p, err := DefaultPath() + if err != nil || filepath.Base(p) != "save.json" || filepath.Base(filepath.Dir(p)) != AppDir { + t.Fatalf("path=%q err=%v", p, err) + } +} diff --git a/internal/sim/fog.go b/internal/sim/fog.go index fdb7fe4..a0a7588 100644 --- a/internal/sim/fog.go +++ b/internal/sim/fog.go @@ -9,10 +9,11 @@ const FogRadius = 3 // Fog håller reda på vilka rutor spelaren har sett i den här omgången. // Minikartan ritar bara sedda rutor plus riktmärken som alltid syns. type Fog struct { - w, h int - seen []bool - Radius int - count int + w, h int + seen []bool + Radius int + count int + ExtraLandmarks bool // toaletter, kiosker och automater syns alltid (skillen Lokalkännedom) } // NewFog skapar en tom dimma för banan. @@ -83,7 +84,25 @@ func Landmark(l *level.Level, p level.Point) bool { // Visible rapporterar om rutan ska ritas på minikartan. func (f *Fog) Visible(l *level.Level, p level.Point) bool { - return f.Seen(p) || Landmark(l, p) + if f.Seen(p) || Landmark(l, p) { + return true + } + if f.ExtraLandmarks { + switch l.At(p.X, p.Y) { + case level.Toilet, level.Kiosk, level.TicketMachine: + return true + } + } + return false +} + +// RevealAll avslöjar hela banan (kartan köpt som item). +func (f *Fog) RevealAll(l *level.Level) { + for y := 0; y < l.Height(); y++ { + for x := 0; x < l.Width(); x++ { + f.mark(level.Point{X: x, Y: y}) + } + } } // Count är antalet sedda rutor (inkl. väggar). diff --git a/internal/sim/run.go b/internal/sim/run.go index 1ed302f..5966e67 100644 --- a/internal/sim/run.go +++ b/internal/sim/run.go @@ -84,6 +84,7 @@ type Run struct { spentStart int // plånbokens Spent vid banstart Score ScoreBreakdown NewDebuffs []Debuff // debuffar banan gav (appliceras på profilen efteråt) + opts RunOptions } // ScoreBreakdown är poängen för en vunnen bana. @@ -163,6 +164,14 @@ type RunOptions struct { Wallet *Wallet // nil = startkassa Debuffs []Debuff // aktiva debuffar från profilen Seed rng.Seed // omgångens seed (sparas med spelet) + + // Från skills och items (progression): + Extra Stats // multipliceras in i statsen (nollvärde = inga) + FogRadius int // extra siktsteg + Landmarks bool // toaletter/kiosker/automater alltid på minikartan + FreeGates bool // spärrarna kostar inget och kräver inget + RevealAll bool // hela banan känd från start + InfiniteBattery bool // telefonen laddar inte ur } // DefaultSpeed är spelarens grundfart i rutor per sekund. @@ -215,11 +224,21 @@ func NewRun(l *level.Level, o RunOptions) *Run { Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")), baseSpeed: o.Speed, NoticeAt: -1, - Fog: NewFog(l, FogRadius), Stats: CombinedStats(o.Debuffs), spentStart: o.Wallet.Spent, + opts: o, } + if o.Extra != (Stats{}) { + for i := range r.Stats { + r.Stats[i] *= o.Extra[i] + } + } + r.Fog = NewFog(l, FogRadius+o.FogRadius) + r.Fog.ExtraLandmarks = o.Landmarks r.Party.Rates = Rates{Thirst: r.Stats[StatThirst], Toilet: r.Stats[StatToilet]} + if o.RevealAll { + r.Fog.RevealAll(l) + } r.Fog.Reveal(l, l.SpawnPoint()) if r.parSec <= 0 { n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter)) @@ -259,6 +278,9 @@ func (r *Run) CanEnter(p level.Point) bool { } func (r *Run) canPassGate() bool { + if r.opts.FreeGates { + return true // JR Pass: genväg genom alla spärrar + } if r.Level.TicketRequired { if r.Wallet.Ticket == "" { return false @@ -306,7 +328,9 @@ func (r *Run) Step(dt float64, want Dir, board bool) { return // minispel pågår: använd StepMini } r.Time += dt - r.Wallet.Drain(dt) + if !r.opts.InfiniteBattery { + r.Wallet.Drain(dt) + } before := r.Player.Tile wasGate := r.inGate() r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor() * r.Stats[StatSpeed] @@ -332,7 +356,7 @@ func (r *Run) Step(dt float64, want Dir, board bool) { r.checkLimits() if r.Player.Tile != before && r.inGate() && !wasGate { r.GatePasses++ - if !r.Level.TicketRequired { + if !r.Level.TicketRequired && !r.opts.FreeGates { r.Wallet.PayCard(r.Level.FareYen) if r.Level.IndividualCards { for _, m := range r.Party.Followers() { @@ -427,7 +451,7 @@ func (r *Run) tryBoard(tr *level.Train) { } r.PendingTrain = tr if tr.Crowded { - r.Mini, r.MiniKind = minigame.NewCrowd(r.miniSeed(), CrowdTries), MiniCrowd + r.Mini, r.MiniKind = minigame.NewCrowd(r.miniSeed(), CrowdTries, r.Stats[StatLuck]), MiniCrowd return } r.startRide() @@ -449,7 +473,9 @@ func (r *Run) StepMini(dt float64, in minigame.Input) { return } r.Time += dt - r.Wallet.Drain(dt) + if !r.opts.InfiniteBattery { + r.Wallet.Drain(dt) + } r.Party.Advance(dt, r.Time) r.Mini.Update(in, dt) if !r.Mini.Done() { diff --git a/internal/sim/run_test.go b/internal/sim/run_test.go index ba1f2da..b0572c1 100644 --- a/internal/sim/run_test.go +++ b/internal/sim/run_test.go @@ -954,3 +954,50 @@ func TestNearTrackAndPhases(t *testing.T) { t.Fatal("tre rutor bort är utom räckhåll") } } + +func TestRunOptionsFromProgression(t *testing.T) { + l := loadLevel(t, "001-flygplatsen") + extra := DefaultStats() + extra[StatSpeed] = 1.2 + r := NewRun(l, RunOptions{Seed: rng.FromInt(1), Extra: extra, FogRadius: 2, Landmarks: true, FreeGates: true, RevealAll: true, InfiniteBattery: true}) + r.Party.nextEventAt = 1e9 + r.Wallet.Card, r.Wallet.Phone, r.Wallet.Battery = 0, true, 50 + r.Step(frame, Right, false) + if math.Abs(r.Player.Speed-DefaultSpeed*1.2*PhoneSpeedFactor) > 1e-9 { + t.Fatalf("extra fart: %.2f", r.Player.Speed) + } + if r.Wallet.Battery != 50 { + t.Fatal("powerbank: batteriet laddar inte ur") + } + if r.Fog.Radius != FogRadius+2 || !r.Fog.ExtraLandmarks || r.Fog.Explored(l) != 1 { + t.Fatalf("dimma: radius=%d landmarks=%v explored=%.2f", r.Fog.Radius, r.Fog.ExtraLandmarks, r.Fog.Explored(l)) + } + gate := l.Find(level.Gate)[0] + if !r.CanEnter(gate) { + t.Fatal("JR Pass: spärren släpper igenom utan saldo") + } + for r.Player.Moving() { + r.Step(frame, None, false) + } + path := l.Path(r.Player.Tile, gate, r.CanEnter) + for i, p := range path { + if l.At(p.X, p.Y) == level.Gate { + path = path[:i+1] + break + } + } + walk(t, r, path) + if r.Wallet.Spent != 0 || r.GatePasses != 1 { + t.Fatalf("gratis spärr: spent=%d passes=%d", r.Wallet.Spent, r.GatePasses) + } + // Utan extras: standard. + plain := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) + if plain.Stats != DefaultStats() || plain.Fog.Radius != FogRadius || plain.Fog.ExtraLandmarks { + t.Fatal("standardvärden utan progression") + } + // Lokalkännedom: kiosken syns på minikartan utan att vara sedd. + k := l.Find(level.Kiosk)[0] + if plain.Fog.Visible(l, k) || !r.Fog.Visible(l, k) { + t.Fatal("landmärken från skillen") + } +} diff --git a/resources/data/items.json b/resources/data/items.json new file mode 100644 index 0000000..6c5d48e --- /dev/null +++ b/resources/data/items.json @@ -0,0 +1,432 @@ +{ + "_comment": "Items: kläder, masker, hattar, prylar. slot = head/face/body/feet/bag (ett per plats bärs). price = kontantpris i kiosken (0 = bara belöning). effect som skills + freeGates (spärrar gratis), revealLevel (minikartan avslöjad på en bana), battery (telefonen laddar inte ur). tint = färgton på spelaren när det bärs.", + "items": [ + { + "id": "cap_red", + "kind": "hat", + "slot": "head", + "name": { + "sv": "Röd keps", + "en": "Red cap" + }, + "price": 800, + "effect": { + "stats": { + "focus": 1.05 + } + } + }, + { + "id": "sun_hat", + "kind": "hat", + "slot": "head", + "name": { + "sv": "Solhatt", + "en": "Sun hat" + }, + "price": 1200, + "effect": { + "stats": { + "thirst": 0.9 + } + }, + "tint": [ + 1, + 0.95, + 0.7 + ] + }, + { + "id": "beanie", + "kind": "hat", + "slot": "head", + "name": { + "sv": "Mössa", + "en": "Beanie" + }, + "price": 900, + "effect": { + "stats": { + "toilet": 1.1, + "focus": 1.1 + } + } + }, + { + "id": "conductor_cap", + "kind": "hat", + "slot": "head", + "name": { + "sv": "Konduktörsmössa", + "en": "Conductor cap" + }, + "price": 3000, + "effect": { + "stats": { + "luck": 1.15 + } + }, + "tint": [ + 0.8, + 0.85, + 1 + ] + }, + { + "id": "mask_surgical", + "kind": "mask", + "slot": "face", + "name": { + "sv": "Munskydd", + "en": "Surgical mask" + }, + "price": 300, + "effect": { + "stats": { + "toilet": 0.95 + } + } + }, + { + "id": "mask_fox", + "kind": "mask", + "slot": "face", + "name": { + "sv": "Rävmask", + "en": "Fox mask" + }, + "price": 1500, + "effect": { + "stats": { + "luck": 1.1 + } + }, + "tint": [ + 1, + 0.85, + 0.7 + ] + }, + { + "id": "mask_tengu", + "kind": "mask", + "slot": "face", + "name": { + "sv": "Tengumask", + "en": "Tengu mask" + }, + "price": 2200, + "effect": { + "stats": { + "focus": 0.9, + "speed": 1.08 + } + }, + "tint": [ + 1, + 0.7, + 0.7 + ] + }, + { + "id": "sunglasses", + "kind": "mask", + "slot": "face", + "name": { + "sv": "Solglasögon", + "en": "Sunglasses" + }, + "price": 1100, + "effect": { + "stats": { + "focus": 0.95, + "luck": 1.05 + } + } + }, + { + "id": "happi", + "kind": "clothes", + "slot": "body", + "name": { + "sv": "Happi-jacka", + "en": "Happi coat" + }, + "price": 2500, + "effect": { + "stats": { + "speed": 1.05 + } + }, + "tint": [ + 0.7, + 0.8, + 1 + ] + }, + { + "id": "yukata", + "kind": "clothes", + "slot": "body", + "name": { + "sv": "Yukata", + "en": "Yukata" + }, + "price": 4000, + "effect": { + "stats": { + "speed": 0.95, + "focus": 1.15 + } + }, + "tint": [ + 0.8, + 0.9, + 1 + ] + }, + { + "id": "rain_poncho", + "kind": "clothes", + "slot": "body", + "name": { + "sv": "Regnponcho", + "en": "Rain poncho" + }, + "price": 700, + "effect": { + "stats": { + "thirst": 1.05, + "toilet": 0.95 + } + }, + "tint": [ + 0.8, + 1, + 0.8 + ] + }, + { + "id": "suit", + "kind": "clothes", + "slot": "body", + "name": { + "sv": "Kostym", + "en": "Suit" + }, + "price": 5500, + "effect": { + "stats": { + "luck": 1.2, + "thirst": 1.1 + } + }, + "tint": [ + 0.75, + 0.75, + 0.8 + ] + }, + { + "id": "sneakers", + "kind": "shoes", + "slot": "feet", + "name": { + "sv": "Sneakers", + "en": "Sneakers" + }, + "price": 3500, + "effect": { + "stats": { + "speed": 1.1 + } + } + }, + { + "id": "geta", + "kind": "shoes", + "slot": "feet", + "name": { + "sv": "Geta", + "en": "Geta" + }, + "price": 1800, + "effect": { + "stats": { + "speed": 0.9, + "luck": 1.2 + } + } + }, + { + "id": "hiking_boots", + "kind": "shoes", + "slot": "feet", + "name": { + "sv": "Vandringskängor", + "en": "Hiking boots" + }, + "price": 4200, + "effect": { + "stats": { + "speed": 1.05, + "toilet": 0.9 + } + } + }, + { + "id": "flipflops", + "kind": "shoes", + "slot": "feet", + "name": { + "sv": "Badtofflor", + "en": "Flip-flops" + }, + "price": 400, + "effect": { + "stats": { + "speed": 0.92, + "thirst": 0.9 + } + } + }, + { + "id": "powerbank", + "kind": "gadget", + "slot": "bag", + "name": { + "sv": "Powerbank", + "en": "Power bank" + }, + "price": 2000, + "effect": { + "battery": true + } + }, + { + "id": "water_bottle", + "kind": "gadget", + "slot": "bag", + "name": { + "sv": "Vattenflaska", + "en": "Water bottle" + }, + "price": 600, + "effect": { + "stats": { + "thirst": 0.8 + } + } + }, + { + "id": "onigiri_box", + "kind": "gadget", + "slot": "bag", + "name": { + "sv": "Onigiri-låda", + "en": "Onigiri box" + }, + "price": 900, + "effect": { + "stats": { + "focus": 1.05, + "toilet": 1.05 + } + } + }, + { + "id": "map_shinjuku", + "kind": "map", + "slot": "bag", + "name": { + "sv": "Karta: Shinjuku", + "en": "Map: Shinjuku" + }, + "price": 600, + "effect": { + "revealLevel": "002-shinjuku" + } + }, + { + "id": "map_shibuya", + "kind": "map", + "slot": "bag", + "name": { + "sv": "Karta: Shibuya", + "en": "Map: Shibuya" + }, + "price": 800, + "effect": { + "revealLevel": "003-shibuya" + } + }, + { + "id": "rail_pass", + "kind": "pass", + "slot": "bag", + "name": { + "sv": "JR Pass", + "en": "JR Pass" + }, + "price": 8000, + "effect": { + "freeGates": true + } + }, + { + "id": "omamori", + "kind": "charm", + "slot": "bag", + "name": { + "sv": "Omamori", + "en": "Omamori" + }, + "price": 1300, + "effect": { + "stats": { + "luck": 1.15 + } + } + }, + { + "id": "skin_kimono", + "kind": "skin", + "slot": "body", + "name": { + "sv": "Kimono (skin)", + "en": "Kimono (skin)" + }, + "price": 0, + "effect": { + "stats": { + "luck": 1.05 + } + }, + "tint": [ + 1, + 0.75, + 0.85 + ] + }, + { + "id": "skin_salaryman", + "kind": "skin", + "slot": "body", + "name": { + "sv": "Salaryman (skin)", + "en": "Salaryman (skin)" + }, + "price": 0, + "effect": { + "stats": { + "speed": 1.03 + } + }, + "tint": [ + 0.7, + 0.7, + 0.75 + ] + } + ] +} diff --git a/resources/data/levels/001-flygplatsen.json b/resources/data/levels/001-flygplatsen.json index 8be217b..a9f6647 100644 --- a/resources/data/levels/001-flygplatsen.json +++ b/resources/data/levels/001-flygplatsen.json @@ -1,5 +1,7 @@ { "id": "001-flygplatsen", + "kind": "main", + "order": 1, "name": { "sv": "Flygplatsen", "en": "The Airport", @@ -15,6 +17,11 @@ }, "timeLimitSec": 0, "fareYen": 200, + "rewards": { + "items": [ + "skin_salaryman" + ] + }, "grid": [ "####################", "#S.....#.....i.....#", diff --git a/resources/data/levels/002-shinjuku.json b/resources/data/levels/002-shinjuku.json index 1fa634d..2d575e6 100644 --- a/resources/data/levels/002-shinjuku.json +++ b/resources/data/levels/002-shinjuku.json @@ -1,12 +1,33 @@ { + "id": "002-shinjuku", + "kind": "main", + "order": 2, + "name": { + "en": "Shinjuku Station", + "ja": "新宿駅", + "sv": "Shinjuku station" + }, + "difficulty": 2, + "party": 3, + "ticketRequired": true, + "seatSelection": true, + "rideStops": 4, "destination": { "en": "Shibuya", "ja": "渋谷", "romaji": "Shibuya", "sv": "Shibuya" }, - "difficulty": 2, + "timeLimitSec": 420, + "maxMissedTrains": 2, "fareYen": 200, + "shop": [ + "cap_red", + "water_bottle", + "mask_surgical", + "map_shinjuku", + "sneakers" + ], "grid": [ "#################################", "#S#..i..#K#...........#.........#", @@ -30,47 +51,6 @@ "#pG.#...#.....................#P#", "#################################" ], - "id": "002-shinjuku", - "maxMissedTrains": 2, - "name": { - "en": "Shinjuku Station", - "ja": "新宿駅", - "sv": "Shinjuku station" - }, - "party": 3, - "rideStops": 4, - "seatSelection": true, - "signs": [ - { - "text": { - "en": "Shinjuku Station. Track 14 Yamanote Line for Shibuya: far bottom right. Track 8 Chuo Line for Tokyo: far bottom left.", - "ja": "新宿駅 14番線 山手線 渋谷方面 → 南東 8番線 中央線 東京方面 → 南西", - "sv": "Shinjuku station. Spår 14 Yamanote-linjen mot Shibuya: längst ner till höger. Spår 8 Chuo-linjen mot Tokyo: längst ner till vänster." - }, - "x": 5, - "y": 1 - }, - { - "text": { - "en": "\u003c- Track 8 Chuo Line (Tokyo) Track 14 Yamanote Line (Shibuya) -\u003e", - "ja": "← 8番線 中央線 東京方面 14番線 山手線 渋谷方面 →", - "sv": "\u003c- Spår 8 Chuo-linjen (Tokyo) Spår 14 Yamanote-linjen (Shibuya) -\u003e" - }, - "x": 11, - "y": 15 - }, - { - "text": { - "en": "Track 14 Yamanote Line for Shibuya: the gate in the south-east corner.", - "ja": "14番線 山手線 渋谷方面 → 南東口 改札", - "sv": "Spår 14 Yamanote-linjen mot Shibuya: spärren i sydöstra hörnet." - }, - "x": 11, - "y": 5 - } - ], - "ticketRequired": true, - "timeLimitSec": 420, "trains": [ { "correct": true, @@ -113,5 +93,34 @@ }, "track": "8" } + ], + "signs": [ + { + "text": { + "en": "Shinjuku Station. Track 14 Yamanote Line for Shibuya: far bottom right. Track 8 Chuo Line for Tokyo: far bottom left.", + "ja": "新宿駅 14番線 山手線 渋谷方面 → 南東 8番線 中央線 東京方面 → 南西", + "sv": "Shinjuku station. Spår 14 Yamanote-linjen mot Shibuya: längst ner till höger. Spår 8 Chuo-linjen mot Tokyo: längst ner till vänster." + }, + "x": 5, + "y": 1 + }, + { + "text": { + "en": "<- Track 8 Chuo Line (Tokyo) Track 14 Yamanote Line (Shibuya) ->", + "ja": "← 8番線 中央線 東京方面 14番線 山手線 渋谷方面 →", + "sv": "<- Spår 8 Chuo-linjen (Tokyo) Spår 14 Yamanote-linjen (Shibuya) ->" + }, + "x": 11, + "y": 15 + }, + { + "text": { + "en": "Track 14 Yamanote Line for Shibuya: the gate in the south-east corner.", + "ja": "14番線 山手線 渋谷方面 → 南東口 改札", + "sv": "Spår 14 Yamanote-linjen mot Shibuya: spärren i sydöstra hörnet." + }, + "x": 11, + "y": 5 + } ] -} \ No newline at end of file +} diff --git a/resources/data/levels/002b-kabukicho.json b/resources/data/levels/002b-kabukicho.json new file mode 100644 index 0000000..ac19d38 --- /dev/null +++ b/resources/data/levels/002b-kabukicho.json @@ -0,0 +1,122 @@ +{ + "destination": { + "en": "Shinjuku", + "ja": "新宿", + "romaji": "Shinjuku", + "sv": "Shinjuku" + }, + "difficulty": 2, + "fareYen": 200, + "grid": [ + "#################################", + "#S....#....K#.....#.............#", + "#.###.#.#####.###.#.#.#########.#", + "#...#.#.........#.#.#.....#.....#", + "#.###.###########.#.#####.#######", + "#...#.#.....#...#.#.....#.#.....#", + "#.#.#.#.###.#.#.#.#.#####.#.###.#", + "#.#...#.#T....#.#.#.#...#.....#.#", + "#.#.###.#######.#.#.#.#.#####.#.#", + "#.#....i......#.#...#.#.....#.#.#", + "#.#####.#####.#.#####.#####.#.#.#", + "#.....#....W#.#.....#.#...#.#.#.#", + "#####.###.###.#####.#.#.#.#.###.#", + "#...#.#...#...#...#.#.#.#...#...#", + "#.#.#.###.#.###.#.#.#.#.#####.###", + "#.#.#...#.#.#.....#.#.#...#.....#", + "#.#.###.#.#.#.#####.#.###.#.#.#.#", + "#.#i..#.#.#.#.#...#...#...#...#.#", + "#G#.#.#.###.#.#.#.#####.#######G#", + "#pG.#.......#...#......i......GP#", + "#################################" + ], + "id": "002b-kabukicho", + "kind": "side", + "name": { + "en": "Kabukicho exit", + "ja": "歌舞伎町", + "sv": "Kabukicho-utgången" + }, + "order": 2, + "parent": "002-shinjuku", + "party": 2, + "rewards": { + "items": [ + "skin_kimono" + ], + "skillPoints": 1 + }, + "rideStops": 3, + "signs": [ + { + "text": { + "en": "Side trip: Kabukicho. Back to Shinjuku on the Yamanote Line, track 1 south-east. The Chuo Line goes the wrong way.", + "ja": "歌舞伎町 1番線 山手線 新宿方面 → 南東 3番線 中央線 東京方面", + "sv": "Sidospår: Kabukicho. Tillbaka till Shinjuku med Yamanote-linjen, spår 1 i sydost. Chuo-linjen går åt fel håll." + }, + "x": 7, + "y": 9 + }, + { + "text": { + "en": "\u003c- Track 3 Chuo Line (Tokyo) Track 1 Yamanote Line (Shinjuku) -\u003e", + "ja": "← 3番線 中央線 東京方面 1番線 山手線 新宿方面 →", + "sv": "\u003c- Spår 3 Chuo-linjen (Tokyo) Spår 1 Yamanote-linjen (Shinjuku) -\u003e" + }, + "x": 3, + "y": 17 + }, + { + "text": { + "en": "Track 1 Yamanote Line: the gate in the south-east corner.", + "ja": "1番線 山手線 新宿方面 → 南東口 改札", + "sv": "Spår 1 Yamanote-linjen: spärren i sydöstra hörnet." + }, + "x": 23, + "y": 19 + } + ], + "timeLimitSec": 240, + "trains": [ + { + "correct": true, + "destination": { + "en": "Shinjuku", + "ja": "新宿", + "romaji": "Shinjuku" + }, + "line": { + "en": "Yamanote Line", + "ja": "山手線", + "romaji": "Yamanote-sen" + }, + "platform": "P", + "schedule": { + "dwellSec": 7, + "intervalSec": 22, + "offsetSec": 4 + }, + "track": "1" + }, + { + "correct": false, + "destination": { + "en": "Tokyo", + "ja": "東京", + "romaji": "Tokyo" + }, + "line": { + "en": "Chuo Line", + "ja": "中央線", + "romaji": "Chuo-sen" + }, + "platform": "p", + "schedule": { + "dwellSec": 7, + "intervalSec": 26, + "offsetSec": 9 + }, + "track": "3" + } + ] +} \ No newline at end of file diff --git a/resources/data/levels/003-shibuya.json b/resources/data/levels/003-shibuya.json index dd7815c..fb96d03 100644 --- a/resources/data/levels/003-shibuya.json +++ b/resources/data/levels/003-shibuya.json @@ -1,12 +1,35 @@ { + "id": "003-shibuya", + "kind": "main", + "order": 3, + "name": { + "en": "Shibuya Station", + "ja": "渋谷駅", + "sv": "Shibuya station" + }, + "difficulty": 3, + "party": 4, + "ticketRequired": true, + "seatSelection": true, + "individualCards": true, + "rideStops": 5, "destination": { "en": "Ikebukuro", "ja": "池袋", "romaji": "Ikebukuro", "sv": "Ikebukuro" }, - "difficulty": 3, + "timeLimitSec": 360, + "maxMissedTrains": 1, "fareYen": 200, + "shop": [ + "sun_hat", + "happi", + "geta", + "map_shibuya", + "powerbank", + "rail_pass" + ], "grid": [ "#################################", "#S.........W#....i........#.....#", @@ -30,48 +53,6 @@ "#pG...............#.......#...GP#", "#################################" ], - "id": "003-shibuya", - "individualCards": true, - "maxMissedTrains": 1, - "name": { - "en": "Shibuya Station", - "ja": "渋谷駅", - "sv": "Shibuya station" - }, - "party": 4, - "rideStops": 5, - "seatSelection": true, - "signs": [ - { - "text": { - "en": "Shibuya Station. Track 2 Yamanote Line for Ikebukuro: south-east. Track 4 Tokyu Toyoko Line for Yokohama: south-west. Tickets at the machine.", - "ja": "渋谷駅 2番線 山手線 池袋方面 → 南東 4番線 東急東横線 横浜方面 → 南西 切符 自動券売機", - "sv": "Shibuya station. Spår 2 Yamanote-linjen mot Ikebukuro: sydöst. Spår 4 Tokyu Toyoko-linjen mot Yokohama: sydväst. Biljetter i automaten." - }, - "x": 17, - "y": 1 - }, - { - "text": { - "en": "\u003c- Track 4 Tokyu Toyoko Line (Yokohama) Track 2 Yamanote Line (Ikebukuro) -\u003e", - "ja": "← 4番線 東急東横線 横浜方面 2番線 山手線 池袋方面 →", - "sv": "\u003c- Spår 4 Tokyu Toyoko-linjen (Yokohama) Spår 2 Yamanote-linjen (Ikebukuro) -\u003e" - }, - "x": 23, - "y": 5 - }, - { - "text": { - "en": "Track 2 Yamanote Line for Ikebukuro: the gate in the south-east corner. Everyone needs a ticket and card balance.", - "ja": "2番線 山手線 池袋方面 → 南東口 改札", - "sv": "Spår 2 Yamanote-linjen mot Ikebukuro: spärren i sydöstra hörnet. Alla behöver biljett och saldo på kortet." - }, - "x": 29, - "y": 13 - } - ], - "ticketRequired": true, - "timeLimitSec": 360, "trains": [ { "correct": true, @@ -114,5 +95,34 @@ }, "track": "4" } + ], + "signs": [ + { + "text": { + "en": "Shibuya Station. Track 2 Yamanote Line for Ikebukuro: south-east. Track 4 Tokyu Toyoko Line for Yokohama: south-west. Tickets at the machine.", + "ja": "渋谷駅 2番線 山手線 池袋方面 → 南東 4番線 東急東横線 横浜方面 → 南西 切符 自動券売機", + "sv": "Shibuya station. Spår 2 Yamanote-linjen mot Ikebukuro: sydöst. Spår 4 Tokyu Toyoko-linjen mot Yokohama: sydväst. Biljetter i automaten." + }, + "x": 17, + "y": 1 + }, + { + "text": { + "en": "<- Track 4 Tokyu Toyoko Line (Yokohama) Track 2 Yamanote Line (Ikebukuro) ->", + "ja": "← 4番線 東急東横線 横浜方面 2番線 山手線 池袋方面 →", + "sv": "<- Spår 4 Tokyu Toyoko-linjen (Yokohama) Spår 2 Yamanote-linjen (Ikebukuro) ->" + }, + "x": 23, + "y": 5 + }, + { + "text": { + "en": "Track 2 Yamanote Line for Ikebukuro: the gate in the south-east corner. Everyone needs a ticket and card balance.", + "ja": "2番線 山手線 池袋方面 → 南東口 改札", + "sv": "Spår 2 Yamanote-linjen mot Ikebukuro: spärren i sydöstra hörnet. Alla behöver biljett och saldo på kortet." + }, + "x": 29, + "y": 13 + } ] -} \ No newline at end of file +} diff --git a/resources/data/skills.json b/resources/data/skills.json new file mode 100644 index 0000000..1954d5b --- /dev/null +++ b/resources/data/skills.json @@ -0,0 +1,239 @@ +{ + "_comment": "Språkträdet/skill tree. cost = skillpoäng (1 per level-up). requires = id:n som måste vara upplåsta. effect: stats (multiplikatorer: speed/thirst/focus/toilet/luck), kana, fogRadius (+rutor), landmarks (toaletter/kiosker/automater syns alltid på minikartan).", + "skills": [ + { + "id": "kana", + "branch": "språk", + "name": { + "sv": "Kana", + "en": "Kana" + }, + "desc": { + "sv": "Du kan läsa hiragana och katakana – alla kana-ord visas som romaji.", + "en": "You can read hiragana and katakana – kana words show as romaji." + }, + "cost": 2, + "requires": [], + "effect": { + "kana": true + } + }, + { + "id": "focus1", + "branch": "språk", + "name": { + "sv": "Skarp blick", + "en": "Sharp eye" + }, + "desc": { + "sv": "+25 % chans att lära ord av skyltar, snabbare hjälp.", + "en": "+25% chance to learn words from signs, faster help." + }, + "cost": 1, + "requires": [], + "effect": { + "stats": { + "focus": 1.25 + } + } + }, + { + "id": "focus2", + "branch": "språk", + "name": { + "sv": "Fotografiskt minne", + "en": "Photographic memory" + }, + "desc": { + "sv": "Ytterligare +25 % fokus.", + "en": "Another +25% focus." + }, + "cost": 2, + "requires": [ + "focus1" + ], + "effect": { + "stats": { + "focus": 1.25 + } + } + }, + { + "id": "polite", + "branch": "språk", + "name": { + "sv": "Sumimasen!", + "en": "Sumimasen!" + }, + "desc": { + "sv": "Folk hjälper dig gärna: hjälpen går dubbelt så fort.", + "en": "People like helping you: help takes half the time." + }, + "cost": 1, + "requires": [ + "focus1" + ], + "effect": { + "stats": { + "focus": 1.5 + } + } + }, + { + "id": "legs1", + "branch": "kropp", + "name": { + "sv": "Snabba fötter", + "en": "Quick feet" + }, + "desc": { + "sv": "+10 % gångfart.", + "en": "+10% walking speed." + }, + "cost": 1, + "requires": [], + "effect": { + "stats": { + "speed": 1.1 + } + } + }, + { + "id": "legs2", + "branch": "kropp", + "name": { + "sv": "Pendlarsteg", + "en": "Commuter stride" + }, + "desc": { + "sv": "Ytterligare +10 % gångfart.", + "en": "Another +10% speed." + }, + "cost": 2, + "requires": [ + "legs1" + ], + "effect": { + "stats": { + "speed": 1.1 + } + } + }, + { + "id": "camel", + "branch": "kropp", + "name": { + "sv": "Kamel", + "en": "Camel" + }, + "desc": { + "sv": "Törsten kommer 25 % långsammare.", + "en": "Thirst builds 25% slower." + }, + "cost": 1, + "requires": [], + "effect": { + "stats": { + "thirst": 0.75 + } + } + }, + { + "id": "bladder", + "branch": "kropp", + "name": { + "sv": "Stålblåsa", + "en": "Steel bladder" + }, + "desc": { + "sv": "Toamätaren sjunker 25 % långsammare.", + "en": "Toilet meter drops 25% slower." + }, + "cost": 1, + "requires": [ + "camel" + ], + "effect": { + "stats": { + "toilet": 0.75 + } + } + }, + { + "id": "lucky", + "branch": "resenär", + "name": { + "sv": "Omikuji", + "en": "Omikuji" + }, + "desc": { + "sv": "+30 % tur: färre omskanningar, lättare att få plats, färre matförgiftningar.", + "en": "+30% luck: fewer rescans, easier to squeeze in, fewer food poisonings." + }, + "cost": 1, + "requires": [], + "effect": { + "stats": { + "luck": 1.3 + } + } + }, + { + "id": "commuter", + "branch": "resenär", + "name": { + "sv": "Rusningsvan", + "en": "Rush-hour pro" + }, + "desc": { + "sv": "Ytterligare +30 % tur.", + "en": "Another +30% luck." + }, + "cost": 2, + "requires": [ + "lucky" + ], + "effect": { + "stats": { + "luck": 1.3 + } + } + }, + { + "id": "scout", + "branch": "utforskare", + "name": { + "sv": "Spanare", + "en": "Scout" + }, + "desc": { + "sv": "Du ser ett steg längre i gångarna.", + "en": "You see one step further down the corridors." + }, + "cost": 1, + "requires": [], + "effect": { + "fogRadius": 1 + } + }, + { + "id": "local", + "branch": "utforskare", + "name": { + "sv": "Lokalkännedom", + "en": "Local knowledge" + }, + "desc": { + "sv": "Toaletter, kiosker och automater syns alltid på minikartan.", + "en": "Toilets, kiosks and machines always show on the minimap." + }, + "cost": 2, + "requires": [ + "scout" + ], + "effect": { + "landmarks": true + } + } + ] +} diff --git a/resources/text/en.json b/resources/text/en.json index 1518813..1b9bd4a 100644 --- a/resources/text/en.json +++ b/resources/text/en.json @@ -20,7 +20,7 @@ "hud.steps": "Steps", "hud.controls": "Esc: menu M: map", "level.wrong_train": "Wrong train!", - "result.hint": "R: replay L: word list Enter/Esc: menu", + "result.hint": "R: replay L: word list Enter/Esc: map", "hud.card": "Card", "hud.track": "Track", "hud.next_train_in": "Next train in", @@ -132,5 +132,54 @@ "hud.arriving": "The train is pulling in…", "hud.closing": "Doors closing - board now!", "hud.leaving": "The train is leaving…", - "hud.train_near": "Train approaching!" + "hud.train_near": "Train approaching!", + "map.level": "Level", + "map.xp": "XP", + "map.skill_points": "Skill points", + "map.side": "Side trip", + "map.party": "Party", + "map.resume": "⟳ resume (saved seed)", + "map.locked": "Locked – clear the previous level first.", + "map.hint": "Arrows: level Enter: start L: words T: skills I: wardrobe P: phone Esc", + "skills.title": "Skills 技", + "skills.to_next": "XP to next", + "skills.already": "Already unlocked.", + "skills.unlocked": "Unlocked: %s", + "skills.need_points": "Costs %d skill points – level up by clearing levels.", + "skills.need_req": "Requires an earlier skill in the branch.", + "skills.hint": "Arrows: select Enter: unlock Esc/T: back", + "items.title": "Wardrobe 持ち物", + "items.owned": "items", + "items.owned_short": "owned", + "items.wearing": "worn", + "items.none": "Nothing yet – buy at kiosks (B) or clear side trips.", + "items.worn": "You wear %s.", + "items.removed": "You took off %s.", + "items.total": "Total", + "items.hint": "Arrows: select Enter: wear/remove Esc/I: back", + "shop.title": "Shop", + "shop.owned": "You already have that.", + "shop.bought": "Bought: %s", + "shop.too_expensive": "You cannot afford it.", + "shop.hint": "Arrows: select Enter: buy Esc/B: close", + "hud.shop_prompt": "B: shop", + "result.level_up": "Level up!", + "result.new_items": "New", + "result.unlocked": "Unlocked", + "stat.speed": "speed", + "stat.thirst": "thirst", + "stat.focus": "focus", + "stat.toilet": "toilet", + "stat.luck": "luck", + "slot.head": "head", + "slot.face": "face", + "slot.body": "body", + "slot.feet": "feet", + "slot.bag": "bag", + "effect.kana": "reads kana", + "effect.fog": "+%d sight", + "effect.landmarks": "landmarks on map", + "effect.free_gates": "free gates", + "effect.reveal": "map: %s", + "effect.battery": "infinite battery" } diff --git a/resources/text/jp-dictionary.json b/resources/text/jp-dictionary.json index bdc4872..f37c172 100644 --- a/resources/text/jp-dictionary.json +++ b/resources/text/jp-dictionary.json @@ -531,5 +531,13 @@ "en": "west", "sv": "väster", "tier": 2 + }, + "歌舞伎町": { + "kana": "かぶきちょう", + "romaji": "Kabukichō", + "en": "Kabukicho", + "sv": "Kabukicho", + "tier": 3, + "kind": "station" } } diff --git a/resources/text/sv.json b/resources/text/sv.json index e3d406a..f4c7500 100644 --- a/resources/text/sv.json +++ b/resources/text/sv.json @@ -20,7 +20,7 @@ "hud.steps": "Steg", "hud.controls": "Esc: meny M: karta", "level.wrong_train": "Fel tåg!", - "result.hint": "R: spela om L: ordlista Enter/Esc: meny", + "result.hint": "R: spela om L: ordlista Enter/Esc: karta", "hud.card": "Kort", "hud.track": "Spår", "hud.next_train_in": "Nästa tåg om", @@ -132,5 +132,54 @@ "hud.arriving": "Tåget rullar in…", "hud.closing": "Dörrarna stängs - kliv på nu!", "hud.leaving": "Tåget går…", - "hud.train_near": "Tåg på väg in!" + "hud.train_near": "Tåg på väg in!", + "map.level": "Nivå", + "map.xp": "XP", + "map.skill_points": "Skillpoäng", + "map.side": "Sidospår", + "map.party": "Sällskap", + "map.resume": "⟳ fortsätt (sparad seed)", + "map.locked": "Låst – klara föregående bana först.", + "map.hint": "Pilar: bana Enter: start L: ord T: skills I: garderob P: tel Esc", + "skills.title": "Skills 技", + "skills.to_next": "XP till nästa", + "skills.already": "Redan upplåst.", + "skills.unlocked": "Upplåst: %s", + "skills.need_points": "Kostar %d skillpoäng – gå upp i nivå genom att klara banor.", + "skills.need_req": "Kräver en tidigare skill i grenen.", + "skills.hint": "Pilar: välj Enter: lås upp Esc/T: tillbaka", + "items.title": "Garderob 持ち物", + "items.owned": "items", + "items.owned_short": "ägs", + "items.wearing": "bärs", + "items.none": "Inget än – köp i kioskerna (B) eller klara sidospår.", + "items.worn": "Du bär %s.", + "items.removed": "Du tog av %s.", + "items.total": "Totalt", + "items.hint": "Pilar: välj Enter: bär/ta av Esc/I: tillbaka", + "shop.title": "Butik", + "shop.owned": "Det har du redan.", + "shop.bought": "Köpt: %s", + "shop.too_expensive": "Du har inte råd.", + "shop.hint": "Pilar: välj Enter: köp Esc/B: stäng", + "hud.shop_prompt": "B: butik", + "result.level_up": "Nivå upp!", + "result.new_items": "Nytt", + "result.unlocked": "Upplåst", + "stat.speed": "fart", + "stat.thirst": "törst", + "stat.focus": "fokus", + "stat.toilet": "toa", + "stat.luck": "tur", + "slot.head": "huvud", + "slot.face": "ansikte", + "slot.body": "kropp", + "slot.feet": "fötter", + "slot.bag": "väska", + "effect.kana": "kan läsa kana", + "effect.fog": "+%d sikt", + "effect.landmarks": "landmärken på kartan", + "effect.free_gates": "gratis spärrar", + "effect.reveal": "karta: %s", + "effect.battery": "oändligt batteri" } diff --git a/tools/shots/main.go b/tools/shots/main.go index 70effdb..d3d6d35 100644 --- a/tools/shots/main.go +++ b/tools/shots/main.go @@ -17,8 +17,28 @@ import ( "strings" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/save" ) +// demoSave skriver en sparfil med lite progression så att kartan, skills +// och garderoben har något att visa. Används bara av skärmdumparna. +func demoSave(path string) error { + st := save.New() + st.Know.Learn("改札") + st.Know.Learn("出口") + st.Know.Points = 2 + st.Progress.RecordLevel("001-flygplatsen", true, 3, 941) + st.Progress.RecordLevel("002-shinjuku", true, 2, 760) + st.Progress.AddXP(600) + st.Progress.Items["skin_salaryman"] = true + st.Progress.Items["geta"] = true + st.Progress.Items["water_bottle"] = true + st.Progress.Equipped["body"] = "skin_salaryman" + st.Progress.Equipped["feet"] = "geta" + st.Progress.Skills["focus1"] = true + return save.Store(path, st) +} + type shot struct { level string script string // $OUT ersätts med doc/img-mappen @@ -29,6 +49,8 @@ type shot struct { // bilderna överlever ändringar i banorna. var shots []shot +var demo string + func buildShots(levelsDir string) error { l, err := level.LoadFile(filepath.Join(levelsDir, "001-flygplatsen.json")) if err != nil { @@ -48,6 +70,10 @@ func buildShots(levelsDir string) error { if err != nil { return err } + toKiosk, err := moves(l, l.SpawnPoint(), l.Find(level.Kiosk)[0]) + if err != nil { + return err + } l2, err := level.LoadFile(filepath.Join(levelsDir, "002-shinjuku.json")) if err != nil { return err @@ -68,8 +94,9 @@ func buildShots(levelsDir string) error { // delvisa översättningen syns; hjälpen lär sedan in nästa ord. known := []string{"-know", "改札,番線,方面", "-seed", "7"} shots = []shot{ - {"", "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"}}, + {"", "wait 10; shot $OUT/map.png; press l; wait 5; press down; press down; press down; wait 5; shot $OUT/words.png; press l; wait 3; press t; wait 5; press down; wait 3; shot $OUT/skills.png; press t; wait 3; press i; wait 5; shot $OUT/items.png; quit", + []string{"-know", "改札,出口,トイレ", "-points", "3", "-save", "$DEMO"}}, + {"001-flygplatsen", toKiosk + "; press b; wait 5; press down; wait 3; shot $OUT/shop.png; quit", []string{"-seed", "7", "-save", "$DEMO"}}, {"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 + @@ -124,6 +151,9 @@ func main() { root := repoRoot() out := filepath.Join(root, "doc", "img") must(os.MkdirAll(out, 0o755)) + demo = filepath.Join(root, "build", "demo-save.json") + must(os.MkdirAll(filepath.Dir(demo), 0o755)) + must(demoSave(demo)) must(buildShots(filepath.Join(root, "resources", "data", "levels"))) build := exec.Command("go", "run", "./tools/build", "-mode", "debug") @@ -134,12 +164,15 @@ func main() { if runtime.GOOS == "windows" { exe += ".exe" } + _ = demo for _, s := range shots { - args := []string{"-mute", "-script", strings.ReplaceAll(s.script, "$OUT", out)} + args := []string{"-mute", "-nosave", "-script", strings.ReplaceAll(s.script, "$OUT", out)} if s.level != "" { args = append(args, "-level", s.level) } - args = append(args, s.args...) + for _, a := range s.args { + args = append(args, strings.ReplaceAll(a, "$DEMO", demo)) + } run := exec.Command(exe, args...) run.Dir, run.Stdout, run.Stderr = filepath.Dir(exe), os.Stdout, os.Stderr if err := run.Run(); err != nil {