Files
metro-puzzle-jp/internal/progress/progress.go
claude 36a45563ec
All checks were successful
release / build-release (push) Successful in 1m34s
M11: oändligt läge och New Game+
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>
2026-10-24 23:20:12 +02:00

277 lines
7.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package progress
import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// XP: nivå n+1 nås när man samlat 150·n(n+1)/2 XP (150, 450, 900, …).
// Varje level-up ger en skillpoäng.
const XPPerLevelStep = 150
// LevelResult är bästa resultatet på en bana.
type LevelResult struct {
Cleared bool `json:"cleared"`
Stars int `json:"stars"`
Score int `json:"score"`
Attempts int `json:"attempts"`
}
// Progress är det som växer mellan banor.
type Progress struct {
XP int `json:"xp"`
SkillPoints int `json:"skillPoints"`
Skills map[string]bool `json:"skills"`
Items map[string]bool `json:"items"`
Equipped map[string]string `json:"equipped"` // slot -> item-id
Levels map[string]*LevelResult `json:"levels"`
StoryCleared bool `json:"storyCleared"` // sista huvudbanan klarad: slutet har visats, oändligt läge/NG+ låses upp
NGPlus int `json:"ngPlus"` // antal New Game+ som startats (svårighetspåslag)
}
// New börjar från noll.
func New() *Progress {
return &Progress{Skills: map[string]bool{}, Items: map[string]bool{}, Equipped: map[string]string{}, Levels: map[string]*LevelResult{}}
}
func (p *Progress) ensure() {
if p.Skills == nil {
p.Skills = map[string]bool{}
}
if p.Items == nil {
p.Items = map[string]bool{}
}
if p.Equipped == nil {
p.Equipped = map[string]string{}
}
if p.Levels == nil {
p.Levels = map[string]*LevelResult{}
}
}
// LevelFor är nivån (från 1) för en XP-summa.
func LevelFor(xp int) int {
n := 0
for XPPerLevelStep*(n+1)*(n+2)/2 <= xp {
n++
}
return n + 1
}
// XPToNext är hur mycket som saknas till nästa nivå.
func XPToNext(xp int) int {
n := LevelFor(xp) // nästa nivå är n+1, som kräver 150·n(n+1)/2
return XPPerLevelStep*n*(n+1)/2 - xp
}
// XPFor ger XP för en klarad bana.
func XPFor(score, stars int) int { return score/10 + 25*stars }
// AddXP lägger till XP och ger en skillpoäng per level-up. Returnerar antalet level-ups.
func (p *Progress) AddXP(n int) int {
before := LevelFor(p.XP)
p.XP += max(0, n)
ups := LevelFor(p.XP) - before
p.SkillPoints += ups
return ups
}
// Level är spelarens nivå.
func (p *Progress) Level() int { return LevelFor(p.XP) }
// Has rapporterar om skillen är upplåst.
func (p *Progress) Has(skill string) bool { return p.Skills[skill] }
// CanUnlock: har poängen, kraven uppfyllda och inte redan upplåst.
func (p *Progress) CanUnlock(c *Catalog, id string) bool {
s := c.Skill(id)
if s == nil || p.Has(id) || p.SkillPoints < s.Cost {
return false
}
for _, r := range s.Requires {
if !p.Has(r) {
return false
}
}
return true
}
// Unlock låser upp en skill mot skillpoäng.
func (p *Progress) Unlock(c *Catalog, id string) bool {
if !p.CanUnlock(c, id) {
return false
}
p.ensure()
p.SkillPoints -= c.Skill(id).Cost
p.Skills[id] = true
return true
}
// Own rapporterar om spelaren har itemet.
func (p *Progress) Own(item string) bool { return p.Items[item] }
// Give ger ett item (belöning). Bärs direkt om platsen är ledig. Returnerar
// false om spelaren redan hade det.
func (p *Progress) Give(c *Catalog, id string) bool {
it := c.Item(id)
if it == nil || p.Own(id) {
return false
}
p.ensure()
p.Items[id] = true
if p.Equipped[it.Slot] == "" {
p.Equipped[it.Slot] = id
}
return true
}
// Buy köper ett item kontant via pay. Returnerar false om det inte gick.
func (p *Progress) Buy(c *Catalog, id string, pay func(yen int) bool) bool {
it := c.Item(id)
if it == nil || it.Price <= 0 || p.Own(id) || !pay(it.Price) {
return false
}
return p.Give(c, id)
}
// Equip bär ett ägt item på sin plats (ersätter det som satt där).
func (p *Progress) Equip(c *Catalog, id string) bool {
it := c.Item(id)
if it == nil || !p.Own(id) {
return false
}
p.ensure()
p.Equipped[it.Slot] = id
return true
}
// Unequip tar av det som bärs på platsen.
func (p *Progress) Unequip(slot string) { delete(p.Equipped, slot) }
// Worn är itemet på platsen, eller nil.
func (p *Progress) Worn(c *Catalog, slot string) *Item {
if id := p.Equipped[slot]; id != "" {
return c.Item(id)
}
return nil
}
// Aggregate är de sammanlagda effekterna av skills och burna items.
type Aggregate struct {
Stats sim.Stats
Kana bool
FogRadius int
Landmarks bool
FreeGates bool
Battery bool
Reveal map[string]bool
Tint [3]float32
}
// Effects räknar ihop allt spelaren har.
func (p *Progress) Effects(c *Catalog) Aggregate {
a := Aggregate{Stats: sim.DefaultStats(), Reveal: map[string]bool{}, Tint: [3]float32{1, 1, 1}}
apply := func(e Effect) {
for k, v := range e.Stats {
a.Stats[statIndex[k]] *= v
}
a.Kana = a.Kana || e.Kana
a.FogRadius += e.FogRadius
a.Landmarks = a.Landmarks || e.Landmarks
a.FreeGates = a.FreeGates || e.FreeGates
a.Battery = a.Battery || e.Battery
if e.RevealLevel != "" {
a.Reveal[e.RevealLevel] = true
}
}
for id := range p.Skills {
if s := c.Skill(id); s != nil {
apply(s.Effect)
}
}
for _, slot := range Slots {
if it := p.Worn(c, slot); it != nil {
apply(it.Effect)
if len(it.Tint) == 3 {
for i := range a.Tint {
a.Tint[i] *= float32(it.Tint[i])
}
}
}
}
return a
}
// RecordLevel sparar ett resultat; bästa stjärnor/poäng behålls. Returnerar
// true första gången banan klaras.
func (p *Progress) RecordLevel(id string, won bool, stars, score int) (first bool) {
p.ensure()
r := p.Levels[id]
if r == nil {
r = &LevelResult{}
p.Levels[id] = r
}
r.Attempts++
if !won {
return false
}
first = !r.Cleared
r.Cleared = true
r.Stars = max(r.Stars, stars)
r.Score = max(r.Score, score)
return first
}
// Cleared rapporterar om banan är klarad.
func (p *Progress) Cleared(id string) bool {
r := p.Levels[id]
return r != nil && r.Cleared
}
// Unlocked avgör om en bana går att spela: huvudbana 1 alltid, huvudbana n
// när huvudbana n−1 är klarad, sidobana när föräldern är klarad.
func (p *Progress) Unlocked(all []*level.Level, id string) bool {
var me *level.Level
for _, l := range all {
if l.ID == id {
me = l
}
}
if me == nil || me.IsLeg() {
return false // delbanor nås bara via sin resa
}
if me.IsSide() {
return p.Cleared(me.Parent)
}
if me.Order <= 1 {
return true
}
for _, l := range all {
if !l.IsSide() && l.Order == me.Order-1 {
return p.Cleared(l.ID)
}
}
return false
}
// NewGamePlus börjar om storyn med skills och XP kvar: banresultat, items
// och det man har på sig nollställs. Storyn förblir "klar" så att oändligt
// läge är kvar. Returnerar det nya NG+-numret.
func (p *Progress) NewGamePlus() int {
p.ensure()
p.Levels = map[string]*LevelResult{}
p.Items = map[string]bool{}
p.Equipped = map[string]string{}
p.NGPlus++
return p.NGPlus
}
// TotalStars är summan av bästa stjärnor.
func (p *Progress) TotalStars() int {
n := 0
for _, r := range p.Levels {
n += r.Stars
}
return n
}