All checks were successful
release / build-release (push) Successful in 1m33s
Folk på stationen (NPC:er som vandrar, kliver av tåg och går mot utgången, blockerar spelaren men inte varandra), vänster-/högerfiler som bromsar på fel sida, bredare gångar, reklamaffischer (clutter), japanskt sorl i loop (noise), vagnval med kvinnovagn (stort avdrag med man i sällskapet, tidsstyrd), kömarkeringar på perrongen, trängsel per vagn (bonus för tom vagn, svettig-debuff och svårare resa i full vagn, släpp förbi passagerare) och att folk kliver av innan man får gå på. Ny bana 005 Ueno. Skärmdumpsverktyget kan filtrera på bana/skript. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
324 lines
9.1 KiB
Go
324 lines
9.1 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. 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,
|
|
}
|
|
}
|
|
|
|
// 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) }
|