// 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" "time" "github.com/hajimehoshi/ebiten/v2" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/endless" "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/nav" "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 Mode nav.Mode // navigeringsläge (= State.NavMode), växlas på bankartan Endless *EndlessRun // pågående omgång i oändligt läge (nil = ingen) 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 Ending bool // öppna slutscenen direkt (tester/skärmdumpar) Endless bool // starta oändligt läge direkt (tester/skärmdumpar) Mode string // navigeringsläge från start: "list" eller "signs" (tomt = sparfilens) } // 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 g.Mode = nav.ParseMode(st.NavMode) if opt.Mode != "" { g.Mode = nav.ParseMode(opt.Mode) } 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 } switch { case opt.Ending: g.scene = NewEndingScene(g) case opt.Endless: s, err := g.startEndless(g.seed()) if err != nil { return nil, err } g.scene = s case opt.LevelID != "": s, err := g.startLevel(opt.LevelID) if err != nil { return nil, err } g.scene = s default: g.scene = NewMapScene(g) } return g, nil } // EndlessRun är en pågående omgång i oändligt läge. type EndlessRun struct { Seed rng.Seed Step int // 0-baserat: målet som spelas nu Score int // summan av klarade måls poäng Goals int // klarade mål } // startEndless börjar en omgång i oändligt läge med första målet. func (g *Game) startEndless(seed rng.Seed) (*LevelScene, error) { g.Endless = &EndlessRun{Seed: seed} return g.endlessGoalScene() } // endlessGoalScene bygger scenen för omgångens aktuella mål. func (g *Game) endlessGoalScene() (*LevelScene, error) { e := g.Endless goal, err := endless.Plan(g.Levels, e.Seed, e.Step) if err != nil { return nil, err } s := newLevelSceneFor(g, goal.Level, goal.Base, e.Seed.Derive(fmt.Sprintf("run:%d", e.Step))) s.endless = true return s, nil } // endlessEnded bokför ett avgjort mål: vinst ger poäng och nästa mål, // förlust avslutar omgången och lägger in den i poänglistan. func (g *Game) endlessEnded(run *sim.Run) LevelOutcome { var out LevelOutcome e := g.Endless if e == nil { return out } for _, d := range run.NewDebuffs { g.Profile.Add(d.Kind, d.LevelsLeft) } g.Profile.EndLevel() if run.Outcome == sim.Won { e.Score += run.Score.Total e.Goals++ e.Step++ out.XP = progress.XPFor(run.Score.Total, run.Stars) out.LevelUps = g.Progress.AddXP(out.XP) } else { out.EndlessBest = g.State.AddEndless(save.EndlessRecord{Score: e.Score, Goals: e.Goals, Seed: [2]uint64{e.Seed.A, e.Seed.B}, When: time.Now()}) out.EndlessOver = true } g.Save() return out } // endEndless avslutar en pågående omgång i förtid (Esc på resultatet). func (g *Game) endEndless() { if e := g.Endless; e != nil && e.Goals > 0 { g.State.AddEndless(save.EndlessRecord{Score: e.Score, Goals: e.Goals, Seed: [2]uint64{e.Seed.A, e.Seed.B}, When: time.Now()}) g.Save() } g.Endless = nil } // newGamePlus börjar om storyn med skills kvar: items, banresultat, pengar // och pågående omgång nollställs; sällskapet blir större på alla banor. func (g *Game) newGamePlus() { g.Progress.NewGamePlus() *g.Wallet = *sim.NewWallet() g.Profile.Debuffs = nil g.State.Current = nil g.Save() } // chainAfter är resans fortsättning efter banan id (delbanorna via next). func (g *Game) chainAfter(id string) []*level.Level { if c := level.ChainOf(g.Levels, id); len(c) > 1 { return c[1:] } return nil } // isLastMain rapporterar om banan är storyns sista huvudbana. func (g *Game) isLastMain(id string) bool { var last *level.Level for _, l := range g.Levels { if !l.IsSide() && !l.IsLeg() && (last == nil || l.Order > last.Order) { last = l } } return last != nil && last.ID == id } 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. Bakgrundsprat hör // till banan och tystnar när man lämnar den. func (g *Game) SetScene(s Scene) { if _, inLevel := s.(*LevelScene); !inLevel { if _, isResult := s.(*ResultScene); !isResult { g.Audio.StopLoop() } } g.next = s } // startChatter startar banans bakgrundsprat efter ljudröra-nivån. func (g *Game) startChatter(noise int) { g.Audio.Loop(SoundChatter, 0.2*float64(noise)) } // noiseMask dämpar perrongens ljud när bakgrundspratet konkurrerar. func noiseMask(noise int) float64 { return 1 - 0.2*float64(noise) } // 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, Mode: g.Mode, Chain: g.chainAfter(id), ExtraParty: g.Progress.NGPlus, } } // toggleMode växlar navigeringsläge och sparar. func (g *Game) toggleMode() { if g.Mode == nav.List { g.Mode = nav.Signs } else { g.Mode = nav.List } g.State.NavMode = g.Mode.String() g.Save() } // 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 StoryCleared bool // sista huvudbanan klarades för första gången: visa slutet EndlessOver bool // oändligt läge: omgången tog slut EndlessBest bool // … och blev ny bästa } // 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) } } if g.isLastMain(id) && !g.Progress.StoryCleared { g.Progress.StoryCleared = true out.StoryCleared = true } } 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) }