M8: progression, items, bankarta och sparfil
All checks were successful
release / build-release (push) Successful in 1m46s
All checks were successful
release / build-release (push) Successful in 1m46s
- internal/progress: katalog (skills.json 12 noder, items.json 25 items) med validering; XP/nivåer (150·n(n+1)/2) och skillpoäng; skillträd med krav och kostnad; items per plats med buffar, genvägar (JR Pass, kartor, powerbank) och färgton; banresultat och upplåsning (huvudbanor i ordning, sidospår efter föräldern); 33 %-regeln som test - internal/save: versionerad JSON med kunskap, plånbok, debuffar, progression och pågående bana + seed; atomisk skrivning; migrering; ~/.config/metro-puzzle-jp/save.json resp. %AppData% - sim: RunOptions får Extra/FogRadius/Landmarks/FreeGates/RevealAll/ InfiniteBattery; luck påverkar trängselzonen; Fog.RevealAll och extra landmärken - game: bankarta i spelbrädesstil ersätter menyn (⟳ återupptar med sparad seed), skillträd (T), garderob (I), butik i kiosken (B), XP/nivå/items/ upplåsningar på resultatet, spelaren tonas av skins; autospar vid banstart, efter banan och vid köp; flaggor -save/-nosave - level: kind/order/parent/rewards/shop; ny sidobana 002b Kabukicho som belönar skinnet Kimono; 001 belönar Salaryman-skinnet; butikssortiment på 002/003 - tools/shots: demo-sparfil (build/, aldrig den riktiga), bilder av karta, skills, garderob och butik; alla körningar med -nosave - docs/README/wiki uppdaterade Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,17 @@
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -21,7 +28,7 @@ const (
|
||||
dt = 1.0 / 60 // Ebitengine kör Update med fast 60 TPS
|
||||
)
|
||||
|
||||
// Scene är ett spelläge: meny, bana, resultat …
|
||||
// Scene är ett spelläge: bankarta, bana, resultat …
|
||||
type Scene interface {
|
||||
Update(g *Game) error
|
||||
Draw(g *Game, screen *ebiten.Image)
|
||||
@@ -29,12 +36,18 @@ type Scene interface {
|
||||
|
||||
// Game är Ebitengine-tillståndet för hela programmet.
|
||||
type Game struct {
|
||||
Assets *Assets
|
||||
Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8)
|
||||
Wallet *sim.Wallet // pengar, kort, telefon, biljett – följer med mellan banor
|
||||
Profile *sim.Profile // aktiva debuffar – följer med mellan banor
|
||||
Audio *Audio // nil = tyst
|
||||
fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar)
|
||||
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
|
||||
@@ -44,62 +57,113 @@ type Game struct {
|
||||
|
||||
// Options styr uppstarten.
|
||||
type Options struct {
|
||||
LevelID string // hoppa direkt in i en bana (tom = meny)
|
||||
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
|
||||
CardYen int // >=0: kortets startsaldo (för tester/skärmdumpar), -1 = standard
|
||||
SavePath string // sparfil ("" = standardplatsen)
|
||||
NoSave bool // läs sparfilen men skriv aldrig
|
||||
}
|
||||
|
||||
// New laddar resurser och bygger spelet.
|
||||
// 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, Know: lang.NewKnowledge(), smokeLeft: opt.SmokeFrames, frame: ebiten.NewImage(ScreenW, ScreenH)}
|
||||
for _, w := range opt.KnownWords {
|
||||
g.Know.Learn(w)
|
||||
}
|
||||
g.Know.Points = opt.Points
|
||||
g.fixedSeed = opt.Seed
|
||||
if g.Audio, err = newAudio(opt.Mute); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Wallet = sim.NewWallet()
|
||||
g.Profile = &sim.Profile{}
|
||||
for _, d := range opt.Debuffs {
|
||||
g.Profile.Add(sim.DebuffKind(d), 1)
|
||||
}
|
||||
g.Wallet.Phone = opt.Phone
|
||||
if opt.CardYen >= 0 {
|
||||
g.Wallet.Card = opt.CardYen
|
||||
}
|
||||
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 := NewLevelScene(a, opt.LevelID, g, g.seed())
|
||||
s, err := g.startLevel(opt.LevelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.scene = s
|
||||
} else {
|
||||
g.scene, err = NewMenuScene(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 }
|
||||
|
||||
@@ -131,6 +195,106 @@ func (g *Game) Draw(screen *ebiten.Image) {
|
||||
// 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 {
|
||||
@@ -139,11 +303,5 @@ func (g *Game) seed() rng.Seed {
|
||||
return rng.NewSeed()
|
||||
}
|
||||
|
||||
// levelEnded appliceras exakt en gång när en bana avgjorts: banans nya
|
||||
// debuffar läggs på profilen och alla räknas ned en bana.
|
||||
func (g *Game) levelEnded(run *sim.Run) {
|
||||
for _, d := range run.NewDebuffs {
|
||||
g.Profile.Add(d.Kind, d.LevelsLeft)
|
||||
}
|
||||
g.Profile.EndLevel()
|
||||
}
|
||||
// progress_XPToNext är ett kort alias för scenerna.
|
||||
func progress_XPToNext(xp int) int { return progress.XPToNext(xp) }
|
||||
|
||||
Reference in New Issue
Block a user