All checks were successful
release / build-release (push) Successful in 1m37s
Resor med byten: en bana kan ha next som pekar på en delbana (kind leg, syns inte på bankartan). När man klivit av rätt tåg fortsätter samma omgång på nästa station med klocka, plånbok, sällskap och räknare; par-tiden adderas, dimma och folk är nya, en Byte!-banner visas och HUD:en visar resmålet via bytesstationen. Tidsgräns och missade tåg gäller hela resan. level.ValidateChains kräver sammanhängande kedjor. Nya resor: 007 Akihabara -> Ueno -> Asakusa och finalen 008 Tokyo (två våningar) -> Nippori -> Narita flygplats. Slutscen när sista huvudbanan klaras första gången: planet lyfter, resan summeras, eftertexter; Progress.StoryCleared sparas; flaggan -ending. tools/levelcheck ritar banorna och rapporterar problem. HUD-nivån kortas när mätarna tar plats; poängraden radbryts. Skärmdumpar och docs uppdaterade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
264 lines
6.5 KiB
Go
264 lines
6.5 KiB
Go
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+ (M11) låses upp
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|