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

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