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

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