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>
277 lines
7.0 KiB
Go
277 lines
7.0 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+ 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
|
||
}
|