Files
claude f0a0bc1522
All checks were successful
release / build-release (push) Successful in 1m40s
M13: titelskärm, språkval, tutorial, tips och pausmeny
Björns feedback efter v0.1.0. Titelskärm med premissen i text och menyn
Fortsätt/Starta, Tutorial, Inställningar, Avsluta (Enter två gånger);
språkval första gången spelet startar. Tutorial i fem sidor med riktiga
sprites och en exempelskylt där resmålet markeras och resten tonas ned,
plus nyckeln 番線/方面/改札 och biljettipset. Kontextuella tips i spelet
tills första banan är klarad, och resmålets tecken markeras på skyltar
och avgångstavlor under tiden. Esc i en bana öppnar en pausmeny
(Fortsätt, Lämna banan, Inställningar, Avsluta spelet) i stället för att
lämna banan; Esc på bankartan går till titeln. Kortare lägestexter så att
bankartans rader ryms på svenska. Test för markeringen, docs, wiki och
skärmdumpar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 17:13:32 +01:00

488 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
case !exists:
g.scene = &LangScene{} // första starten: välj språk, sedan titelskärmen
default:
g.scene = NewTitleScene()
}
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, *PauseScene: // banans sorl fortsätter
case *MapScene, *SettingsScene, *EndingScene, *WordsScene, *SkillsScene, *ItemsScene, *TitleScene, *LangScene, *TutorialScene:
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) }