Files
metro-puzzle-jp/internal/progress/progress.go
claude 915c684dc2
All checks were successful
release / build-release (push) Successful in 1m46s
M8: progression, items, bankarta och sparfil
- 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>
2026-10-24 11:29:30 +02:00

263 lines
6.3 KiB
Go
Raw 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"`
}
// 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 {
return false
}
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
}
// 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
}