Files
metro-puzzle-jp/internal/game/game.go
claude 915c684dc2
All checks were successful
release / build-release (push) Successful in 1m46s
M8: progression, items, bankarta och sparfil
- 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>
2026-10-24 11:29:30 +02:00

308 lines
8.6 KiB
Go

// Package game innehåller Ebitengine-spelloopen och scenerna. Detta är det
// enda paketet (tillsammans med cmd/) som får känna till Ebitengine; all
// spellogik ligger i paket som går att testa utan fönster (internal/sim,
// internal/level …).
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"
)
// Intern upplösning. Allt ritas i denna storlek och skalas sedan upp med
// heltalsfaktor av Ebitengine så pixlarna förblir skarpa.
const (
ScreenW = 320
ScreenH = 180
TileSize = 16
dt = 1.0 / 60 // Ebitengine kör Update med fast 60 TPS
)
// Scene är ett spelläge: bankarta, bana, resultat …
type Scene interface {
Update(g *Game) error
Draw(g *Game, screen *ebiten.Image)
}
// Game är Ebitengine-tillståndet för hela programmet.
type Game struct {
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
script *script
frame *ebiten.Image // allt ritas hit och blittas sedan till skärmen (gör skärmdumpar möjliga)
}
// Options styr uppstarten.
type Options struct {
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
SavePath string // sparfil ("" = standardplatsen)
NoSave bool // läs sparfilen men skriv aldrig
}
// 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, 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 := g.startLevel(opt.LevelID)
if err != nil {
return nil, err
}
g.scene = s
} else {
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 }
// Update körs 60 ggr/s.
func (g *Game) Update() error {
if g.smokeLeft > 0 {
g.smokeLeft--
if g.smokeLeft == 0 {
return ebiten.Termination
}
}
if g.next != nil {
g.scene, g.next = g.next, nil
}
if g.script != nil {
if err := g.script.tick(g, g.frame); err != nil {
return err
}
}
return g.scene.Update(g)
}
// Draw ritar aktuell scen till den interna bilden och sedan till skärmen.
func (g *Game) Draw(screen *ebiten.Image) {
g.scene.Draw(g, g.frame)
screen.DrawImage(g.frame, nil)
}
// 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 {
return rng.FromInt(g.fixedSeed)
}
return rng.NewSeed()
}
// progress_XPToNext är ett kort alias för scenerna.
func progress_XPToNext(xp int) int { return progress.XPToNext(xp) }