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

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