M11: oändligt läge och New Game+
All checks were successful
release / build-release (push) Successful in 1m34s

internal/endless lottar mål ur en seed: en av storyns nätkopplade
stationer (aldrig samma två gånger i rad) med ett resmål som minst ett
tåg går rakt till men inte alla; banan kopieras, bedöms om mot nätet,
sällskapet växer var tredje mål och tidsgränsen krymper. O på bankartan
(eller -endless) startar en omgång; klarade mål ger poäng och XP, Enter
ger nästa mål, förlust eller Esc avslutar och omgången hamnar i
sparfilens poänglista (fem bästa). G två gånger startar New Game+: items,
banresultat, pengar och debuffar nollställs, skills, XP och japanskan
behålls, sällskapet är en person större per varv. Docs, wiki, tester och
skärmdumpar uppdaterade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 23:20:12 +02:00
parent 46bd330dfc
commit 36a45563ec
22 changed files with 556 additions and 19 deletions

View File

@@ -7,9 +7,11 @@ 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"
@@ -47,6 +49,7 @@ type Game struct {
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)
@@ -72,6 +75,7 @@ type Options struct {
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)
}
@@ -141,6 +145,12 @@ func New(opt Options) (*Game, error) {
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 {
@@ -153,6 +163,77 @@ func New(opt Options) (*Game, error) {
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 {
@@ -264,6 +345,7 @@ func (g *Game) runOptions(id string, seed rng.Seed) 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,
}
}
@@ -311,6 +393,9 @@ type LevelOutcome struct {
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,