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>
This commit is contained in:
2026-10-24 11:29:30 +02:00
parent d466333f01
commit 915c684dc2
46 changed files with 3058 additions and 316 deletions

View File

@@ -10,14 +10,16 @@ Storyn följer några polare på semester i Japan, från landningen på
flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+
upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md).
**Status:** M7b klar (2026-10-24) – tre banor med stigande svårighet.
Japanska skyltar och ordinlärning, lämmeltåget med sina behov, minikarta med
dimma, tågkort, biljetter och minispel, svårighet med tidsgräns, poäng,
debuffar, egna kort och sittplatsval. Och nu lever tågen: de rullar in på
rälsen till avgångsmelodin, står med öppna dörrar, dörrsignalen ljuder och
de accelererar ut igen, med vind längs perrongen och lampor som varnar den
som står nära rälsen. Nästa är M8: progression, items och sparfiler. Se
[doc/roadmap.md](doc/roadmap.md).
**Status:** M8 klar (2026-10-24) – fyra banor på en bankarta i
spelbrädesstil, med sidospår. Japanska skyltar och ordinlärning,
lämmeltåget, minikarta, tågkort, biljetter och minispel, svårighet med
tidsgräns, poäng och debuffar, tåg som rullar in till avgångsmelodin. Och nu
progression: XP och nivåer, ett skillträd, items med buffar och genvägar som
köps i kioskernas butiker eller vinns på sidospår, och en sparfil som minns
allt inklusive seeden på den bana du höll på med. Nästa är M9a: stationer i
flera våningar. Se [doc/roadmap.md](doc/roadmap.md).
![Bankartan](doc/img/map.png)
![Skylt på japanska med delvis översättning](doc/img/sign.png)
@@ -42,6 +44,13 @@ på japanska.
![Automaten](doc/img/ticket.png)
I kioskens butik (B) köper du hattar, masker, kläder, skor och prylar med
buffar; några är genvägar, som JR Pass som öppnar alla spärrar. Klarade
banor ger XP, nivåer ger skillpoäng till skillträdet (T), och garderoben (I)
visar vad du bär.
![Skillträdet](doc/img/skills.png)
På de svåraste banorna har alla i sällskapet egna kort som måste ha saldo,
alla behöver biljett, och när biljetten är köpt ska sällskapet placeras i
vagnen efter sina önskemål: fönster eller gång, vänster eller höger sida,
@@ -122,7 +131,9 @@ build/
Spelet letar efter `resources/` bredvid binären, annars uppåt från
arbetskatalogen (så `go run` och F5 fungerar), och `JP_RESOURCES=/sökväg`
överstyr allt. Att distribuera spelet = zippa en plattformsmapp.
överstyr allt. Att distribuera spelet = zippa en plattformsmapp. Sparfilen
ligger utanför spelmappen, i `~/.config/metro-puzzle-jp/` (Linux) eller
`%AppData%\metro-puzzle-jp\` (Windows).
### Flaggor
@@ -136,6 +147,8 @@ metro-puzzle-jp -seed 7 # fast seed för händelser, läsning
metro-puzzle-jp -phone -card 0 # kortet i telefonen; kortets startsaldo (tester/skärmdumpar)
metro-puzzle-jp -debuffs sore_feet,tired # debuffar från start (tester/skärmdumpar)
metro-puzzle-jp -mute # inget ljud
metro-puzzle-jp -save /sökväg/save.json # annan sparfil (standard ~/.config/metro-puzzle-jp/save.json)
metro-puzzle-jp -nosave # läs sparfilen men skriv aldrig (tester/skärmdumpar)
metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go
metro-puzzle-jp -version
```
@@ -145,8 +158,9 @@ metro-puzzle-jp -version
Pilar eller WASD går. Enter väljer, kliver på tåget, använder toaletten,
handlar i kiosken eller startar automaten; i minispelen väljer pilarna och
Enter bekräftar (håll Enter för att mata in sedlar). H frågar om hjälp vid en
skylt, L öppnar ordlistan, M döljer/visar minikartan, P i menyn lägger
kortet i telefonen, Esc går tillbaka, R spelar om. Gamepad
skylt, L öppnar ordlistan, T skillträdet, I garderoben, B butiken vid en kiosk, M
döljer/visar minikartan, P på kartan lägger kortet i telefonen, Esc går
tillbaka, R spelar om. Gamepad
med standardlayout fungerar: d-pad/vänster spak, A = välj, B = tillbaka,
Y = hjälp, X = ordlista, Back/Select = karta.
@@ -187,6 +201,8 @@ internal/game/ Ebitengine: scener, input, rendering, skriptläge (enda
internal/sim/ rutbaserad rörelse, kollision, kamera, banomgång med tåg/spärrar/hjälp (ren Go)
internal/lang/ japanska: ordbok, kunskap, delvis översättning (ren Go)
internal/minigame/ påfyllning, biljettköp, trängsel, tågresa – seedade minispel (ren Go)
internal/progress/ XP, skillträd, items, banresultat, upplåsning (ren Go)
internal/save/ sparfil (versionerad JSON)
internal/level/ banformat, validering, framkomlighet
internal/res/ hittar och läser resources/
internal/rng/ seedad slump

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
doc/img/map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

BIN
doc/img/shop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
doc/img/skills.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"
}
]
}

View File

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

View File

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

View File

@@ -531,5 +531,13 @@
"en": "west",
"sv": "väster",
"tier": 2
},
"歌舞伎町": {
"kana": "かぶきちょう",
"romaji": "Kabukichō",
"en": "Kabukicho",
"sv": "Kabukicho",
"tier": 3,
"kind": "station"
}
}

View File

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

View File

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