M8: progression, items, bankarta och sparfil
All checks were successful
release / build-release (push) Successful in 1m46s
- internal/progress: katalog (skills.json 12 noder, items.json 25 items) med validering; XP/nivåer (150·n(n+1)/2) och skillpoäng; skillträd med krav och kostnad; items per plats med buffar, genvägar (JR Pass, kartor, powerbank) och färgton; banresultat och upplåsning (huvudbanor i ordning, sidospår efter föräldern); 33 %-regeln som test - internal/save: versionerad JSON med kunskap, plånbok, debuffar, progression och pågående bana + seed; atomisk skrivning; migrering; ~/.config/metro-puzzle-jp/save.json resp. %AppData% - sim: RunOptions får Extra/FogRadius/Landmarks/FreeGates/RevealAll/ InfiniteBattery; luck påverkar trängselzonen; Fog.RevealAll och extra landmärken - game: bankarta i spelbrädesstil ersätter menyn (⟳ återupptar med sparad seed), skillträd (T), garderob (I), butik i kiosken (B), XP/nivå/items/ upplåsningar på resultatet, spelaren tonas av skins; autospar vid banstart, efter banan och vid köp; flaggor -save/-nosave - level: kind/order/parent/rewards/shop; ny sidobana 002b Kabukicho som belönar skinnet Kimono; 001 belönar Salaryman-skinnet; butikssortiment på 002/003 - tools/shots: demo-sparfil (build/, aldrig den riktiga), bilder av karta, skills, garderob och butik; alla körningar med -nosave - docs/README/wiki uppdaterade Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
38
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).
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -42,6 +44,13 @@ på japanska.
|
||||
|
||||

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

|
||||
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
BIN
doc/img/items.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
doc/img/map.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
doc/img/menu.png
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
BIN
doc/img/ride.png
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
BIN
doc/img/shop.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
doc/img/skills.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
@@ -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 |
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
144
internal/game/scene_items.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
227
internal/game/scene_map.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
113
internal/game/scene_skills.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
104
internal/game/shop.go
Normal file
@@ -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{}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
168
internal/progress/catalog.go
Normal file
@@ -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
|
||||
}
|
||||
262
internal/progress/progress.go
Normal file
@@ -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
|
||||
}
|
||||
216
internal/progress/progress_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
122
internal/save/save.go
Normal file
@@ -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)
|
||||
}
|
||||
78
internal/save/save_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
432
resources/data/items.json
Normal file
@@ -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
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.....#",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
122
resources/data/levels/002b-kabukicho.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
239
resources/data/skills.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -531,5 +531,13 @@
|
||||
"en": "west",
|
||||
"sv": "väster",
|
||||
"tier": 2
|
||||
},
|
||||
"歌舞伎町": {
|
||||
"kana": "かぶきちょう",
|
||||
"romaji": "Kabukichō",
|
||||
"en": "Kabukicho",
|
||||
"sv": "Kabukicho",
|
||||
"tier": 3,
|
||||
"kind": "station"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||