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