All checks were successful
release / build-release (push) Successful in 1m35s
tools/gensound syntetiserar 21 ljud: fyra stationsmelodier i klockklang (en per station via melody-fältet eller banans id), ankomstjingel, ding-dong vid inrullning, dörrsignal vid avgång, spärrens pip och nekande, kiosk, toalett, lärt ord, bekräfta/varning, byte, personal, vinst, förlust, flygplan, sorl och menymusik. sfx.go spelar effekter utifrån vad som hänt i sim.Run sedan förra bildrutan. Ljudlagret har huvudvolym × effekter/musik och byter inte slinga i onödan. Inställningar (S på bankartan, sparas): volymer, språk sv/en som laddas om direkt, fönsterskala, fullskärm (F11 överallt) och tangentöversikt. Fönsterikon från genart. CHANGELOG.md. Tester för ljudfiler, melodival och inställningar. Docs, wiki och skärmdumpar uppdaterade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
486 lines
14 KiB
Go
486 lines
14 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"
|
|
"time"
|
|
|
|
"github.com/hajimehoshi/ebiten/v2"
|
|
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
|
|
|
"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
|
|
flagScale int // fönsterskala från flaggan (när inställningarna säger 0)
|
|
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)
|
|
Scale int // fönsterskala från flaggan (när inställningarna säger 0)
|
|
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)
|
|
}
|
|
g.flagScale = max(1, opt.Scale)
|
|
g.applySettings()
|
|
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) {
|
|
switch s.(type) {
|
|
case *LevelScene, *ResultScene: // banans sorl fortsätter
|
|
case *MapScene, *SettingsScene, *EndingScene, *WordsScene, *SkillsScene, *ItemsScene:
|
|
g.Audio.Loop(SoundMusicMap, 0.6) // menymusiken (fortsätter om den redan spelas)
|
|
default:
|
|
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 inpututil.IsKeyJustPressed(ebiten.KeyF11) { // fullskärm av/på överallt
|
|
g.State.Settings.Fullscreen = !g.State.Settings.Fullscreen
|
|
g.applySettings()
|
|
g.Save()
|
|
}
|
|
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,
|
|
}
|
|
}
|
|
|
|
// applySettings för ut inställningarna: volymer, språk, fönster.
|
|
func (g *Game) applySettings() {
|
|
st := g.State.Settings
|
|
g.Audio.SetVolumes(float64(st.Master)/10, float64(st.Effects)/10, float64(st.Music)/10)
|
|
if st.Lang != UILang {
|
|
if err := g.Assets.LoadLang(st.Lang); err != nil {
|
|
log.Printf("språk %s: %v", st.Lang, err)
|
|
}
|
|
}
|
|
scale := st.Scale
|
|
if scale <= 0 {
|
|
scale = g.flagScale
|
|
}
|
|
if scale > 0 {
|
|
ebiten.SetWindowSize(ScreenW*scale, ScreenH*scale)
|
|
}
|
|
ebiten.SetFullscreen(st.Fullscreen)
|
|
}
|
|
|
|
// 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) }
|