M8: progression, items, bankarta och sparfil
All checks were successful
release / build-release (push) Successful in 1m46s

- 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>
This commit is contained in:
2026-10-24 11:29:30 +02:00
parent d466333f01
commit 915c684dc2
46 changed files with 3058 additions and 316 deletions

View File

@@ -5,10 +5,17 @@
package game
import (
"fmt"
"log"
"github.com/hajimehoshi/ebiten/v2"
"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/progress"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/save"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
@@ -21,7 +28,7 @@ const (
dt = 1.0 / 60 // Ebitengine kör Update med fast 60 TPS
)
// Scene är ett spelläge: meny, bana, resultat …
// Scene är ett spelläge: bankarta, bana, resultat …
type Scene interface {
Update(g *Game) error
Draw(g *Game, screen *ebiten.Image)
@@ -29,12 +36,18 @@ type Scene interface {
// Game är Ebitengine-tillståndet för hela programmet.
type Game struct {
Assets *Assets
Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8)
Wallet *sim.Wallet // pengar, kort, telefon, biljett – följer med mellan banor
Profile *sim.Profile // aktiva debuffar – följer med mellan banor
Audio *Audio // nil = tyst
fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar)
Assets *Assets
State *save.State // allt som sparas
Know *lang.Knowledge // = State.Know
Wallet *sim.Wallet // = State.Wallet
Profile *sim.Profile // = State.Profile
Progress *progress.Progress // = State.Progress
Catalog *progress.Catalog
Levels []*level.Level // alla banor, sorterade på id
Audio *Audio // nil = tyst
savePath string // "" = spara inte
fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar)
scene Scene
next Scene
smokeLeft int // >0: avsluta automatiskt efter så många uppdateringar
@@ -44,62 +57,113 @@ type Game struct {
// Options styr uppstarten.
type Options struct {
LevelID string // hoppa direkt in i en bana (tom = meny)
LevelID string // hoppa direkt in i en bana (tom = bankartan)
SmokeFrames int // >0 avslutar efter N uppdateringar (smoke-test)
Script string // skriptade steg för skärmdumpar/tester, se script.go
KnownWords []string // ord spelaren kan från start (för tester/skärmdumpar)
Points int // språkpoäng från start (för tester/skärmdumpar)
Seed uint64 // >0: fast seed för banorna (för tester/skärmdumpar)
Phone bool // kortet i telefonen från start
CardYen int // >=0: kortets startsaldo (för tester/skärmdumpar), -1 = standard
Debuffs []string // debuffar från start (för tester/skärmdumpar)
Mute bool // inget ljud
CardYen int // >=0: kortets startsaldo (för tester/skärmdumpar), -1 = standard
SavePath string // sparfil ("" = standardplatsen)
NoSave bool // läs sparfilen men skriv aldrig
}
// New laddar resurser och bygger spelet.
// New laddar resurser, sparfil och bygger spelet.
func New(opt Options) (*Game, error) {
a, err := LoadAssets()
if err != nil {
return nil, err
}
g := &Game{Assets: a, Know: lang.NewKnowledge(), smokeLeft: opt.SmokeFrames, frame: ebiten.NewImage(ScreenW, ScreenH)}
for _, w := range opt.KnownWords {
g.Know.Learn(w)
}
g.Know.Points = opt.Points
g.fixedSeed = opt.Seed
if g.Audio, err = newAudio(opt.Mute); err != nil {
return nil, err
}
g.Wallet = sim.NewWallet()
g.Profile = &sim.Profile{}
for _, d := range opt.Debuffs {
g.Profile.Add(sim.DebuffKind(d), 1)
}
g.Wallet.Phone = opt.Phone
if opt.CardYen >= 0 {
g.Wallet.Card = opt.CardYen
}
g := &Game{Assets: a, smokeLeft: opt.SmokeFrames, frame: ebiten.NewImage(ScreenW, ScreenH), fixedSeed: opt.Seed}
if opt.Script != "" {
if g.script, err = parseScript(opt.Script); err != nil {
return nil, err
}
}
if g.Audio, err = newAudio(opt.Mute); err != nil {
return nil, err
}
if g.Catalog, err = loadCatalog(); err != nil {
return nil, err
}
if g.Levels, err = level.LoadAll(a.LevelsDir); err != nil {
return nil, err
}
// Sparfil.
path := opt.SavePath
if path == "" {
if path, err = save.DefaultPath(); err != nil {
return nil, err
}
}
st, exists, err := save.Load(path)
if err != nil {
return nil, err
}
if exists {
log.Printf("sparfil läst: %s", path)
}
g.State = st
g.Know, g.Wallet, g.Profile, g.Progress = st.Know, st.Wallet, st.Profile, st.Progress
if !opt.NoSave {
g.savePath = path
}
// Startvärden för tester/skärmdumpar.
for _, w := range opt.KnownWords {
g.Know.Learn(w)
}
if opt.Points > 0 {
g.Know.Points = opt.Points
}
for _, d := range opt.Debuffs {
g.Profile.Add(sim.DebuffKind(d), 1)
}
if opt.Phone {
g.Wallet.Phone = true
}
if opt.CardYen >= 0 {
g.Wallet.Card = opt.CardYen
}
if opt.LevelID != "" {
s, err := NewLevelScene(a, opt.LevelID, g, g.seed())
s, err := g.startLevel(opt.LevelID)
if err != nil {
return nil, err
}
g.scene = s
} else {
g.scene, err = NewMenuScene(a)
if err != nil {
return nil, err
}
g.scene = NewMapScene(g)
}
return g, nil
}
func loadCatalog() (*progress.Catalog, error) {
sk, err := res.ReadFile("data", "skills.json")
if err != nil {
return nil, err
}
it, err := res.ReadFile("data", "items.json")
if err != nil {
return nil, err
}
return progress.LoadCatalog(sk, it)
}
// Save skriver sparfilen (no-op utan sparväg).
func (g *Game) Save() {
if g.savePath == "" {
return
}
if err := save.Store(g.savePath, g.State); err != nil {
log.Printf("kunde inte spara: %v", err)
}
}
// SetScene byter scen från och med nästa uppdatering.
func (g *Game) SetScene(s Scene) { g.next = s }
@@ -131,6 +195,106 @@ func (g *Game) Draw(screen *ebiten.Image) {
// Layout talar om för Ebitengine vilken intern upplösning vi ritar i.
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { return ScreenW, ScreenH }
// LevelByID slår upp en bana.
func (g *Game) LevelByID(id string) *level.Level {
for _, l := range g.Levels {
if l.ID == id {
return l
}
}
return nil
}
// Effects är de sammanlagda effekterna av skills och burna items.
func (g *Game) Effects() progress.Aggregate { return g.Progress.Effects(g.Catalog) }
// runOptions bygger banans inställningar från progressionen.
func (g *Game) runOptions(id string, seed rng.Seed) sim.RunOptions {
eff := g.Effects()
if eff.Kana {
g.Know.Kana = true
}
return 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,
}
}
// startLevel startar (eller återupptar) en bana och sparar seeden så att en
// laddad omgång ger samma slump.
func (g *Game) startLevel(id string) (*LevelScene, error) {
if g.LevelByID(id) == nil {
return nil, fmt.Errorf("okänd bana %q", id)
}
var seed rng.Seed
switch {
case g.fixedSeed > 0:
seed = rng.FromInt(g.fixedSeed)
case g.State.Current != nil && g.State.Current.LevelID == id:
seed = rng.Seed{A: g.State.Current.Seed[0], B: g.State.Current.Seed[1]} // återuppta med samma slump
default:
seed = rng.NewSeed()
}
s, err := NewLevelScene(g, id, seed)
if err != nil {
return nil, err
}
g.State.Current = &save.RunRef{LevelID: id, Seed: [2]uint64{seed.A, seed.B}}
g.Save()
return s, nil
}
// LevelOutcome är vad en avslutad bana gav i progression (för resultatskärmen).
type LevelOutcome struct {
XP int
LevelUps int
FirstClear bool
NewItems []string
NewPoints int
Unlocked []string // banor som öppnades
}
// levelEnded appliceras exakt en gång när en bana avgjorts: debuffar,
// banresultat, XP, belöningar och upplåsningar; sedan sparas allt.
func (g *Game) levelEnded(run *sim.Run, id string) LevelOutcome {
var out LevelOutcome
for _, d := range run.NewDebuffs {
g.Profile.Add(d.Kind, d.LevelsLeft)
}
g.Profile.EndLevel()
before := map[string]bool{}
for _, l := range g.Levels {
before[l.ID] = g.Progress.Unlocked(g.Levels, l.ID)
}
won := run.Outcome == sim.Won
out.FirstClear = g.Progress.RecordLevel(id, won, run.Stars, run.Score.Total)
if won {
out.XP = progress.XPFor(run.Score.Total, run.Stars)
out.LevelUps = g.Progress.AddXP(out.XP)
if out.FirstClear {
if l := g.LevelByID(id); l != nil {
for _, it := range l.Rewards.Items {
if g.Progress.Give(g.Catalog, it) {
out.NewItems = append(out.NewItems, it)
}
}
out.NewPoints = l.Rewards.SkillPoints
g.Progress.SkillPoints += l.Rewards.SkillPoints
}
}
for _, l := range g.Levels {
if !before[l.ID] && g.Progress.Unlocked(g.Levels, l.ID) {
out.Unlocked = append(out.Unlocked, l.ID)
}
}
}
g.State.Current = nil
g.Save()
return out
}
// seed ger seeden för en ny banomgång: fast om -seed angetts, annars färsk.
func (g *Game) seed() rng.Seed {
if g.fixedSeed > 0 {
@@ -139,11 +303,5 @@ func (g *Game) seed() rng.Seed {
return rng.NewSeed()
}
// levelEnded appliceras exakt en gång när en bana avgjorts: banans nya
// debuffar läggs på profilen och alla räknas ned en bana.
func (g *Game) levelEnded(run *sim.Run) {
for _, d := range run.NewDebuffs {
g.Profile.Add(d.Kind, d.LevelsLeft)
}
g.Profile.EndLevel()
}
// progress_XPToNext är ett kort alias för scenerna.
func progress_XPToNext(xp int) int { return progress.XPToNext(xp) }

View File

@@ -114,6 +114,15 @@ func confirmHeld() bool {
return false
}
// skillsJustPressed: T = skillträdet.
func skillsJustPressed() bool { return virtual.pressed["t"] || inpututil.IsKeyJustPressed(ebiten.KeyT) }
// itemsJustPressed: I = garderoben/items.
func itemsJustPressed() bool { return virtual.pressed["i"] || inpututil.IsKeyJustPressed(ebiten.KeyI) }
// shopJustPressed: B = butiken i kiosken.
func shopJustPressed() bool { return virtual.pressed["b"] || inpututil.IsKeyJustPressed(ebiten.KeyB) }
// phoneJustPressed: P = kortet i telefonen av/på (meny).
func phoneJustPressed() bool { return virtual.pressed["p"] || inpututil.IsKeyJustPressed(ebiten.KeyP) }

View File

@@ -0,0 +1,144 @@
package game
import (
"fmt"
"image/color"
"sort"
"strings"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress"
)
// ItemsScene är garderoben: ägda items per plats, Enter bär/tar av.
type ItemsScene struct {
back Scene
items []progress.Item
sel int
top int
msg string
}
const itemRows = 8
// NewItemsScene listar ägda items, plats för plats.
func NewItemsScene(g *Game, back Scene) *ItemsScene {
var owned []progress.Item
for _, it := range g.Catalog.Items {
if g.Progress.Own(it.ID) {
owned = append(owned, it)
}
}
slotOrder := map[string]int{}
for i, s := range progress.Slots {
slotOrder[s] = i
}
sort.SliceStable(owned, func(i, j int) bool {
if owned[i].Slot != owned[j].Slot {
return slotOrder[owned[i].Slot] < slotOrder[owned[j].Slot]
}
return owned[i].Price < owned[j].Price
})
return &ItemsScene{back: back, items: owned}
}
func (s *ItemsScene) Update(g *Game) error {
if backJustPressed() || itemsJustPressed() {
g.SetScene(s.back)
return nil
}
if n := len(s.items); n > 0 {
s.sel = (s.sel + menuMove() + n) % n
if s.sel < s.top {
s.top = s.sel
}
if s.sel >= s.top+itemRows {
s.top = s.sel - itemRows + 1
}
if confirmJustPressed() {
it := s.items[s.sel]
if g.Progress.Equipped[it.Slot] == it.ID {
g.Progress.Unequip(it.Slot)
s.msg = fmt.Sprintf(g.Assets.T("items.removed"), it.Name.Get(UILang))
} else {
g.Progress.Equip(g.Catalog, it.ID)
s.msg = fmt.Sprintf(g.Assets.T("items.worn"), it.Name.Get(UILang))
}
g.Save()
}
}
return nil
}
// effectText beskriver en effekt kort.
func effectText(a *Assets, e progress.Effect) string {
var parts []string
keys := make([]string, 0, len(e.Stats))
for k := range e.Stats {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s %+.0f%%", a.T("stat."+k), (e.Stats[k]-1)*100))
}
if e.Kana {
parts = append(parts, a.T("effect.kana"))
}
if e.FogRadius > 0 {
parts = append(parts, fmt.Sprintf(a.T("effect.fog"), e.FogRadius))
}
if e.Landmarks {
parts = append(parts, a.T("effect.landmarks"))
}
if e.FreeGates {
parts = append(parts, a.T("effect.free_gates"))
}
if e.RevealLevel != "" {
parts = append(parts, fmt.Sprintf(a.T("effect.reveal"), e.RevealLevel))
}
if e.Battery {
parts = append(parts, a.T("effect.battery"))
}
return strings.Join(parts, ", ")
}
func (s *ItemsScene) Draw(g *Game, screen *ebiten.Image) {
a := g.Assets
p := g.Progress
screen.Fill(color.RGBA{24, 20, 37, 255})
vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false)
a.DrawBig(screen, a.T("items.title"), 8, 5, colText)
hdr := fmt.Sprintf("%d/%d %s %s %d¥", len(s.items), len(g.Catalog.Items), a.T("items.owned"), a.T("hud.cash"), g.Wallet.Cash)
a.Draw(screen, hdr, ScreenW-8-a.Width(hdr), 6, colKnown)
y0 := 30
for i := s.top; i < len(s.items) && i < s.top+itemRows; i++ {
it := s.items[i]
y := y0 + (i-s.top)*LineH
if i == s.sel {
vector.DrawFilledRect(screen, 4, float32(y-1), ScreenW-8, LineH, colBar, false)
}
col, status := colText, ""
if p.Equipped[it.Slot] == it.ID {
col, status = colGood, a.T("items.wearing")
}
a.Draw(screen, a.T("slot."+it.Slot), 8, y, colDim)
a.Draw(screen, it.Name.Get(UILang), 60, y, col)
a.Draw(screen, effectText(a, it.Effect), 150, y, colDim)
a.Draw(screen, status, ScreenW-8-a.Width(status), y, col)
}
if len(s.items) == 0 {
a.Draw(screen, a.T("items.none"), 8, y0, colDim)
}
eff := g.Effects()
sum := fmt.Sprintf("%s: %s %.0f%% %s %.0f%% %s %.0f%% %s %.0f%% %s %.0f%%", a.T("items.total"),
a.T("stat.speed"), 100*eff.Stats[0], a.T("stat.thirst"), 100*eff.Stats[1], a.T("stat.focus"), 100*eff.Stats[2],
a.T("stat.toilet"), 100*eff.Stats[3], a.T("stat.luck"), 100*eff.Stats[4])
a.Draw(screen, sum, 8, 134, colDim)
if s.msg != "" {
a.Draw(screen, s.msg, 8, ScreenH-26, colKnown)
}
a.Draw(screen, a.T("items.hint"), 8, ScreenH-13, colDim)
}

View File

@@ -36,6 +36,9 @@ type LevelScene struct {
animT float64
hideMap bool // M döljer/visar minikartan
shop *shop // öppen butik i kiosken (pausar banan)
outcome LevelOutcome // progression från banan (fylls i när den avgjorts)
// Tåg och effekter (M7b).
tracks []trackInfo
rails map[level.Point]bool
@@ -45,15 +48,16 @@ type LevelScene struct {
}
// NewLevelScene laddar banan och ställer spelaren på startrutan. Kunskap,
// plånbok och debuffar tas från Game (delas mellan banor); seed styr slumpen.
func NewLevelScene(a *Assets, id string, g *Game, seed rng.Seed) (*LevelScene, error) {
// plånbok, debuffar, skills och items tas från Game; seed styr slumpen.
func NewLevelScene(g *Game, id string, seed rng.Seed) (*LevelScene, error) {
a := g.Assets
lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json"))
if err != nil {
return nil, err
}
s := &LevelScene{
id: id,
run: sim.NewRun(lvl, sim.RunOptions{Dict: a.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed}),
run: sim.NewRun(lvl, g.runOptions(id, seed)),
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
}
s.cam.Margin = TileSize // rälsen ligger i kantraden: visa en ruta utanför kartan
@@ -102,23 +106,27 @@ func (s *LevelScene) followPlayer() {
}
func (s *LevelScene) Update(g *Game) error {
if s.shop != nil { // butiken pausar banan
s.updateShop(g)
return nil
}
s.updateEffects(g)
if s.run.Mini != nil {
// Minispel: all input går dit; Esc avbryter minispelet, inte banan.
mx, my := miniMoveXY()
s.run.StepMini(dt, minigame.Input{Move: my, MoveX: mx, Confirm: confirmJustPressed(), Hold: confirmHeld(), Cancel: backJustPressed()})
if s.run.Outcome != sim.Ongoing {
g.levelEnded(s.run)
s.outcome = g.levelEnded(s.run, s.id)
g.SetScene(NewResultScene(s))
}
return nil
}
if backJustPressed() {
m, err := NewMenuScene(g.Assets)
if err != nil {
return err
}
g.SetScene(m)
g.SetScene(NewMapScene(g))
return nil
}
if shopJustPressed() && !s.run.Player.Moving() && s.run.Level.At(s.run.Player.Tile.X, s.run.Player.Tile.Y) == level.Kiosk {
s.openShop(g)
return nil
}
if helpJustPressed() {
@@ -135,7 +143,7 @@ func (s *LevelScene) Update(g *Game) error {
}
s.followPlayer()
if s.run.Outcome != sim.Ongoing {
g.levelEnded(s.run)
s.outcome = g.levelEnded(s.run, s.id)
g.SetScene(NewResultScene(s))
}
return nil
@@ -227,7 +235,11 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
px, py := leader.WorldPos(TileSize)
op.GeoM.Reset()
op.GeoM.Translate(ox+math.Round(px), oy+math.Round(py))
tint := g.Effects().Tint
op.ColorScale.Reset()
op.ColorScale.Scale(tint[0], tint[1], tint[2], 1)
screen.DrawImage(g.Assets.PlayerFrame(leader.Facing, leader.Moving(), s.animT), &op)
op.ColorScale.Reset()
s.drawUrge(g, screen, s.run.Party.Members[0], int(ox+math.Round(px)), int(oy+math.Round(py)))
s.drawIndicator(screen, ox, oy)
@@ -235,6 +247,9 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
if s.run.Mini != nil {
s.drawMinigame(g, screen)
}
if s.shop != nil {
s.drawShop(g, screen)
}
}
// memberTints färgar följarnas sprites tills de har egna.
@@ -308,8 +323,8 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
a.Draw(screen, wallet, 4, LineH, walletCol)
s.drawMeters(g, screen)
if r.Outcome != sim.Ongoing || r.Mini != nil {
return // resultatpanelen eller minispelet tar över; ingen inforuta eller minikarta
if r.Outcome != sim.Ongoing || r.Mini != nil || s.shop != nil {
return // resultatpanelen, minispelet eller butiken tar över; ingen inforuta eller minikarta
}
lines, accent, bar := s.infoLines(a)
if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec {
@@ -457,9 +472,9 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
return []line{plainLine(a.T("hud.toilet_none"), colDim)}, accent, false
case level.Kiosk:
if n := len(r.Party.NeedsKiosk()); n > 0 {
return []line{plainLine(fmt.Sprintf(a.T("hud.kiosk_prompt"), n, sim.KioskPriceYen*n), colGood)}, colGood, false
return []line{plainLine(fmt.Sprintf(a.T("hud.kiosk_prompt"), n, sim.KioskPriceYen*n)+" "+a.T("hud.shop_prompt"), colGood)}, colGood, false
}
return []line{plainLine(a.T("hud.kiosk_none"), colDim)}, accent, false
return []line{plainLine(a.T("hud.kiosk_none")+" "+a.T("hud.shop_prompt"), colDim)}, accent, false
case level.TicketMachine:
if lvl.TicketRequired {
return []line{plainLine(fmt.Sprintf(a.T("hud.machine_ticket"), r.Wallet.Cash), colGood)}, colGood, false

227
internal/game/scene_map.go Normal file
View File

@@ -0,0 +1,227 @@
package game
import (
"fmt"
"image/color"
"sort"
"strings"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/buildinfo"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
)
// MapScene är bankartan i spelbrädesstil: huvudbanorna på en slingrande
// linje från flygplatsen, sidobanorna som avstickare ovanför. Låsta banor
// är grå, klarade visar stjärnor.
type MapScene struct {
mains []*level.Level
sides map[string][]*level.Level // parent -> sidobanor
sel int // vald huvudbana
side int // 0 = huvudbanan, 1.. = sidobana nr
msg string
}
type mapNode struct {
lvl *level.Level
x, y int
}
// NewMapScene bygger kartan från alla banor.
func NewMapScene(g *Game) *MapScene {
m := &MapScene{sides: map[string][]*level.Level{}}
for _, l := range g.Levels {
if l.IsSide() {
m.sides[l.Parent] = append(m.sides[l.Parent], l)
} else {
m.mains = append(m.mains, l)
}
}
sort.Slice(m.mains, func(i, j int) bool { return m.mains[i].Order < m.mains[j].Order })
// Välj den första banan som inte är klarad.
for i, l := range m.mains {
if !g.Progress.Cleared(l.ID) {
m.sel = i
break
}
}
return m
}
// nodes ger positionerna på kartan.
func (m *MapScene) nodes() ([]mapNode, map[string][]mapNode) {
n := len(m.mains)
var mains []mapNode
sides := map[string][]mapNode{}
for i, l := range m.mains {
x := 36
if n > 1 {
x = 36 + i*(ScreenW-72)/(n-1)
}
y := 104
if i%2 == 1 {
y = 116
}
mains = append(mains, mapNode{l, x, y})
for k, s := range m.sides[l.ID] {
sides[l.ID] = append(sides[l.ID], mapNode{s, x + 12 + k*18, y - 40})
}
}
return mains, sides
}
func (m *MapScene) current() *level.Level {
if len(m.mains) == 0 {
return nil
}
main := m.mains[m.sel]
if m.side > 0 {
if s := m.sides[main.ID]; m.side-1 < len(s) {
return s[m.side-1]
}
}
return main
}
func (m *MapScene) Update(g *Game) error {
switch {
case backJustPressed():
return ebiten.Termination
case wordsJustPressed():
g.SetScene(NewWordsScene(g.Assets, m))
return nil
case skillsJustPressed():
g.SetScene(NewSkillsScene(g, m))
return nil
case itemsJustPressed():
g.SetScene(NewItemsScene(g, m))
return nil
case phoneJustPressed():
g.Wallet.Phone = !g.Wallet.Phone
g.Save()
}
if n := len(m.mains); n > 0 {
mx, my := miniMoveXY()
if mx != 0 {
m.sel = (m.sel + mx + n) % n
m.side = 0
}
if my != 0 {
sides := len(m.sides[m.mains[m.sel].ID])
m.side = max(0, min(sides, m.side-my)) // upp = ut på sidospåret
}
if confirmJustPressed() {
l := m.current()
if !g.Progress.Unlocked(g.Levels, l.ID) {
m.msg = g.Assets.T("map.locked")
return nil
}
s, err := g.startLevel(l.ID)
if err != nil {
m.msg = err.Error()
return nil
}
g.SetScene(s)
}
}
return nil
}
func (m *MapScene) Draw(g *Game, screen *ebiten.Image) {
a := g.Assets
p := g.Progress
screen.Fill(color.RGBA{24, 20, 37, 255})
vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false)
a.DrawBig(screen, a.T("game.title"), 8, 5, colText)
hdr := fmt.Sprintf("%s %d %s %d/%d ★ %d", a.T("map.level"), p.Level(), a.T("map.xp"), p.XP, p.XP+progress_XPToNext(p.XP), p.TotalStars())
a.Draw(screen, hdr, ScreenW-8-a.Width(hdr), 6, colKnown)
mains, sides := m.nodes()
// Linjen mellan huvudbanorna.
for i := 1; i < len(mains); i++ {
drawLine(screen, mains[i-1].x, mains[i-1].y, mains[i].x, mains[i].y, colBar)
}
for _, mn := range mains {
for _, sn := range sides[mn.lvl.ID] {
drawLine(screen, mn.x, mn.y, sn.x, sn.y, colBar)
}
}
cur := m.current()
drawNode := func(n mapNode, side bool) {
sz := float32(14)
if side {
sz = 10
}
col := colDim
unlocked := p.Unlocked(g.Levels, n.lvl.ID)
switch {
case p.Cleared(n.lvl.ID):
col = colGood
case unlocked:
col = colText
}
vector.DrawFilledRect(screen, float32(n.x)-sz/2, float32(n.y)-sz/2, sz, sz, col, false)
if n.lvl == cur {
vector.DrawFilledRect(screen, float32(n.x)-sz/2-2, float32(n.y)-sz/2-2, sz+4, 2, colKnown, false)
vector.DrawFilledRect(screen, float32(n.x)-sz/2-2, float32(n.y)+sz/2, sz+4, 2, colKnown, false)
}
if r := p.Levels[n.lvl.ID]; r != nil && r.Cleared {
st := strings.Repeat("★", r.Stars)
a.Draw(screen, st, n.x-a.Width(st)/2, n.y+int(sz)/2+2, colWarn)
}
if !unlocked { // lås: en liten bygel + låda
vector.DrawFilledRect(screen, float32(n.x)-3, float32(n.y)-1, 6, 5, colPanel, false)
vector.DrawFilledRect(screen, float32(n.x)-2, float32(n.y)-4, 4, 3, colPanel, false)
vector.DrawFilledRect(screen, float32(n.x)-1, float32(n.y)-3, 2, 2, colDim, false)
}
}
for _, n := range mains {
drawNode(n, false)
for _, s := range sides[n.lvl.ID] {
drawNode(s, true)
}
}
// Info om vald bana.
if cur != nil {
name := cur.Name.Get(UILang) + " " + cur.Name.Get("ja")
if cur.IsSide() {
name = a.T("map.side") + ": " + name
}
a.Draw(screen, name, 8, 134, colText)
info := fmt.Sprintf("→ %s %s %s %s %d", cur.Destination.Get("ja"), cur.Destination.Get("romaji"),
strings.Repeat("★", cur.Difficulty)+strings.Repeat("☆", 5-cur.Difficulty), a.T("map.party"), cur.Party)
if g.State.Current != nil && g.State.Current.LevelID == cur.ID {
info += " " + a.T("map.resume")
}
a.Draw(screen, info, 8, 146, colDim)
if !p.Unlocked(g.Levels, cur.ID) {
a.Draw(screen, a.T("map.locked"), 150, 30, colBad)
} else if m.msg != "" {
a.Draw(screen, m.msg, 150, 30, colBad)
}
}
onoff := a.T("menu.off")
if g.Wallet.Phone {
onoff = a.T("menu.on")
}
foot := fmt.Sprintf("%s %d %s %d %s %d¥", a.T("map.skill_points"), p.SkillPoints, a.T("words.points"), g.Know.Points,
a.T("hud.cash"), g.Wallet.Cash)
a.Draw(screen, foot, 8, 30, colDim)
a.Draw(screen, fmt.Sprintf(a.T("menu.phone"), onoff), 8, 42, colDim)
if len(g.Profile.Debuffs) > 0 {
var parts []string
for _, d := range g.Profile.Debuffs {
parts = append(parts, fmt.Sprintf("%s (%d)", a.T("debuff."+string(d.Kind)), d.LevelsLeft))
}
a.Draw(screen, a.T("hud.debuffs")+": "+strings.Join(parts, ", "), 8, 54, colBad)
}
a.Draw(screen, a.T("map.hint"), 8, ScreenH-24, colDim)
a.Draw(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-12, colDim)
}
// drawLine ritar en tjock linje mellan två punkter.
func drawLine(screen *ebiten.Image, x0, y0, x1, y1 int, col color.Color) {
vector.StrokeLine(screen, float32(x0), float32(y0), float32(x1), float32(y1), 2, col, false)
}

View File

@@ -1,112 +0,0 @@
package game
import (
"fmt"
"image/color"
"path/filepath"
"strings"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/buildinfo"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
)
type menuEntry struct {
id string
name string
dest string
stars string
}
// MenuScene listar banorna och låter spelaren välja.
type MenuScene struct {
entries []menuEntry
sel int
err error
}
// NewMenuScene läser in banlistan.
func NewMenuScene(a *Assets) (*MenuScene, error) {
ids, err := level.List(a.LevelsDir)
if err != nil {
return nil, err
}
m := &MenuScene{}
for _, id := range ids {
l, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json"))
if err != nil {
m.err = err
continue
}
m.entries = append(m.entries, menuEntry{
id: id,
name: l.Name.Get(UILang) + " " + l.Name.Get("ja"),
dest: l.Destination.Get("ja") + " " + l.Destination.Get("romaji"),
stars: strings.Repeat("★", l.Difficulty) + strings.Repeat("☆", 5-l.Difficulty),
})
}
return m, nil
}
func (m *MenuScene) Update(g *Game) error {
if backJustPressed() {
return ebiten.Termination
}
if wordsJustPressed() {
g.SetScene(NewWordsScene(g.Assets, m))
return nil
}
if phoneJustPressed() {
g.Wallet.Phone = !g.Wallet.Phone
}
if n := len(m.entries); n > 0 {
m.sel = (m.sel + menuMove() + n) % n
if confirmJustPressed() {
s, err := NewLevelScene(g.Assets, m.entries[m.sel].id, g, g.seed())
if err != nil {
m.err = err
return nil
}
g.SetScene(s)
}
}
return nil
}
func (m *MenuScene) Draw(g *Game, screen *ebiten.Image) {
a := g.Assets
screen.Fill(color.RGBA{24, 20, 37, 255})
vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false)
a.DrawBig(screen, a.T("game.title"), 8, 5, colText)
a.Draw(screen, a.T("menu.choose_level"), 8, 30, colDim)
for i, e := range m.entries {
y := 46 + i*14
if i == m.sel {
vector.DrawFilledRect(screen, 6, float32(y-2), ScreenW-12, 13, colBar, false)
}
a.Draw(screen, e.name, 12, y, colText)
a.Draw(screen, "→ "+e.dest, 150, y, colKnown)
a.Draw(screen, e.stars, ScreenW-62, y, colWarn)
}
if m.err != nil {
a.Draw(screen, "FEL: "+m.err.Error(), 8, ScreenH-40, colBad)
}
onoff := a.T("menu.off")
if g.Wallet.Phone {
onoff = a.T("menu.on")
}
a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d %s %d¥ %s %d¥ %s", a.T("hud.japanese"), g.Know.Level(), g.Know.Count(), a.T("hud.words"),
a.T("words.points"), g.Know.Points, a.T("hud.card"), g.Wallet.Card, a.T("hud.cash"), g.Wallet.Cash, fmt.Sprintf(a.T("menu.phone"), onoff)), 8, ScreenH-38, colDim)
if len(g.Profile.Debuffs) > 0 {
var parts []string
for _, d := range g.Profile.Debuffs {
parts = append(parts, fmt.Sprintf("%s (%d)", a.T("debuff."+string(d.Kind)), d.LevelsLeft))
}
a.Draw(screen, a.T("hud.debuffs")+": "+strings.Join(parts, ", "), 8, ScreenH-50, colBad)
}
a.Draw(screen, a.T("menu.hint"), 8, ScreenH-26, colDim)
a.Draw(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-13, colDim)
}

View File

@@ -23,17 +23,13 @@ func (r *ResultScene) Update(g *Game) error {
case wordsJustPressed():
g.SetScene(NewWordsScene(g.Assets, r))
case inpututil_R():
s, err := NewLevelScene(g.Assets, r.from.id, g, g.seed())
s, err := g.startLevel(r.from.id)
if err != nil {
return err
}
g.SetScene(s)
case confirmJustPressed() || backJustPressed():
m, err := NewMenuScene(g.Assets)
if err != nil {
return err
}
g.SetScene(m)
g.SetScene(NewMapScene(g))
}
return nil
}
@@ -42,7 +38,7 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
r.from.Draw(g, screen) // banan i bakgrunden
run := r.from.run
a := g.Assets
vector.DrawFilledRect(screen, 20, 30, ScreenW-40, 138, colPanel, false)
vector.DrawFilledRect(screen, 20, 30, ScreenW-40, 144, colPanel, false)
accent, headline := colBad, a.T("level.lost")
if run.Outcome == sim.Won {
@@ -96,6 +92,37 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
a.Draw(screen, msg, 28, y, colKnown)
y += LineH
}
if o := r.from.outcome; run.Outcome == sim.Won {
prog := fmt.Sprintf("+%d %s", o.XP, a.T("map.xp"))
if o.LevelUps > 0 {
prog += fmt.Sprintf(" %s %d (+%d %s)", a.T("result.level_up"), g.Progress.Level(), o.LevelUps+o.NewPoints, a.T("map.skill_points"))
} else if o.NewPoints > 0 {
prog += fmt.Sprintf(" +%d %s", o.NewPoints, a.T("map.skill_points"))
}
if len(o.NewItems) > 0 {
var names []string
for _, id := range o.NewItems {
names = append(names, g.Catalog.Item(id).Name.Get(UILang))
}
prog += " " + a.T("result.new_items") + ": " + strings.Join(names, ", ")
}
if len(o.Unlocked) > 0 {
var names []string
for _, id := range o.Unlocked {
if l := g.LevelByID(id); l != nil {
names = append(names, l.Name.Get(UILang))
}
}
prog += " " + a.T("result.unlocked") + ": " + strings.Join(names, ", ")
}
for i, l := range a.wrapLine(plainLine(prog, colGood), ScreenW-56) {
if i > 1 {
break
}
a.drawLines(screen, []line{l}, 28, y)
y += LineH
}
}
if len(run.NewDebuffs) > 0 {
var parts []string
for _, d := range run.NewDebuffs {
@@ -104,7 +131,9 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
a.Draw(screen, a.T("result.new_debuffs")+": "+strings.Join(parts, ", "), 28, y, colBad)
y += LineH
}
a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d (%d %s)", a.T("hud.japanese"), run.Know.Level(), run.Know.Count(),
a.T("hud.words"), a.T("words.points"), run.Know.Points, run.Know.Helps, a.T("result.helps")), 28, y, colDim)
a.Draw(screen, a.T("result.hint"), 28, 155, colDim)
if y <= 149 {
a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d (%d %s)", a.T("hud.japanese"), run.Know.Level(), run.Know.Count(),
a.T("hud.words"), a.T("words.points"), run.Know.Points, run.Know.Helps, a.T("result.helps")), 28, y, colDim)
}
a.Draw(screen, a.T("result.hint"), 28, 161, colDim)
}

View File

@@ -0,0 +1,113 @@
package game
import (
"fmt"
"image/color"
"sort"
"strings"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress"
)
// SkillsScene är skillträdet: grenar med noder, kostnad i skillpoäng och krav.
type SkillsScene struct {
back Scene
skills []progress.Skill
sel int
top int
msg string
}
const skillRows = 8
// NewSkillsScene listar skills gren för gren.
func NewSkillsScene(g *Game, back Scene) *SkillsScene {
sk := append([]progress.Skill(nil), g.Catalog.Skills...)
sort.SliceStable(sk, func(i, j int) bool {
if sk[i].Branch != sk[j].Branch {
return sk[i].Branch < sk[j].Branch
}
return len(sk[i].Requires) < len(sk[j].Requires)
})
return &SkillsScene{back: back, skills: sk}
}
func (s *SkillsScene) Update(g *Game) error {
if backJustPressed() || skillsJustPressed() {
g.SetScene(s.back)
return nil
}
if n := len(s.skills); n > 0 {
s.sel = (s.sel + menuMove() + n) % n
if s.sel < s.top {
s.top = s.sel
}
if s.sel >= s.top+skillRows {
s.top = s.sel - skillRows + 1
}
if confirmJustPressed() {
sk := s.skills[s.sel]
switch {
case g.Progress.Has(sk.ID):
s.msg = g.Assets.T("skills.already")
case g.Progress.Unlock(g.Catalog, sk.ID):
s.msg = fmt.Sprintf(g.Assets.T("skills.unlocked"), sk.Name.Get(UILang))
g.Save()
case g.Progress.SkillPoints < sk.Cost:
s.msg = fmt.Sprintf(g.Assets.T("skills.need_points"), sk.Cost)
default:
s.msg = g.Assets.T("skills.need_req")
}
}
}
return nil
}
func (s *SkillsScene) Draw(g *Game, screen *ebiten.Image) {
a := g.Assets
p := g.Progress
screen.Fill(color.RGBA{24, 20, 37, 255})
vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false)
a.DrawBig(screen, a.T("skills.title"), 8, 5, colText)
hdr := fmt.Sprintf("%s %d %s %d %s %d", a.T("map.skill_points"), p.SkillPoints, a.T("map.level"), p.Level(), a.T("skills.to_next"), progress_XPToNext(p.XP))
a.Draw(screen, hdr, ScreenW-8-a.Width(hdr), 6, colKnown)
y0 := 30
for i := s.top; i < len(s.skills) && i < s.top+skillRows; i++ {
sk := s.skills[i]
y := y0 + (i-s.top)*LineH
if i == s.sel {
vector.DrawFilledRect(screen, 4, float32(y-1), ScreenW-8, LineH, colBar, false)
}
col, status := colDim, strings.Repeat("●", sk.Cost)
switch {
case p.Has(sk.ID):
col, status = colGood, "✓"
case p.CanUnlock(g.Catalog, sk.ID):
col = colKnown
}
a.Draw(screen, sk.Branch, 8, y, colDim)
a.Draw(screen, sk.Name.Get(UILang), 80, y, col)
if len(sk.Requires) > 0 {
req := make([]string, len(sk.Requires))
for k, r := range sk.Requires {
req[k] = g.Catalog.Skill(r).Name.Get(UILang)
}
a.Draw(screen, "← "+strings.Join(req, ", "), 190, y, colDim)
}
a.Draw(screen, status, ScreenW-8-a.Width(status), y, col)
}
if len(s.skills) > 0 {
sel := s.skills[s.sel]
for i, l := range a.wrapLine(plainLine(sel.Desc.Get(UILang), colText), ScreenW-16) {
a.drawLines(screen, []line{l}, 8, 130+i*LineH)
}
}
if s.msg != "" {
a.Draw(screen, s.msg, 8, ScreenH-26, colKnown)
}
a.Draw(screen, a.T("skills.hint"), 8, ScreenH-13, colDim)
}

View File

@@ -60,6 +60,7 @@ func (w *WordsScene) Update(g *Game) error {
w.msg = g.Assets.T("words.already")
case g.Know.Buy(e):
w.msg = fmt.Sprintf(g.Assets.T("words.bought"), e.Word, e.Translation(UILang))
g.Save()
default:
w.msg = fmt.Sprintf(g.Assets.T("words.too_expensive"), lang.WordCost(e))
}

104
internal/game/shop.go Normal file
View File

@@ -0,0 +1,104 @@
package game
import (
"fmt"
"image"
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress"
)
// Butiken i kiosken: banans sortiment (eller ett seedat urval), köps kontant.
type shop struct {
items []progress.Item
cursor int
msg string
}
const shopSize = 5
func (s *LevelScene) openShop(g *Game) {
sh := &shop{}
ids := s.run.Level.Shop
if len(ids) == 0 {
// Seedat urval ur allt köpbart, så att samma omgång ger samma butik.
all := g.Catalog.Purchasable()
r := s.run.Seed.Derive("shop").New()
r.Shuffle(len(all), func(i, j int) { all[i], all[j] = all[j], all[i] })
for _, it := range all {
ids = append(ids, it.ID)
if len(ids) >= shopSize {
break
}
}
}
for _, id := range ids {
if it := g.Catalog.Item(id); it != nil {
sh.items = append(sh.items, *it)
}
}
s.shop = sh
}
func (s *LevelScene) updateShop(g *Game) {
sh := s.shop
if backJustPressed() || shopJustPressed() {
s.shop = nil
return
}
if n := len(sh.items); n > 0 {
sh.cursor = (sh.cursor + menuMove() + n) % n
if confirmJustPressed() {
it := sh.items[sh.cursor]
switch {
case g.Progress.Own(it.ID):
sh.msg = g.Assets.T("shop.owned")
case g.Progress.Buy(g.Catalog, it.ID, g.Wallet.PayCash):
sh.msg = fmt.Sprintf(g.Assets.T("shop.bought"), it.Name.Get(UILang))
g.Save()
default:
sh.msg = g.Assets.T("shop.too_expensive")
}
}
}
}
func (s *LevelScene) drawShop(g *Game, screen *ebiten.Image) {
a := g.Assets
sh := s.shop
p := image.Rect(12, 28, ScreenW-12, ScreenH-10)
vector.DrawFilledRect(screen, float32(p.Min.X), float32(p.Min.Y), float32(p.Dx()), float32(p.Dy()), colPanel, false)
vector.DrawFilledRect(screen, float32(p.Min.X), float32(p.Min.Y), float32(p.Dx()), 3, colKnown, false)
x, y := p.Min.X+8, p.Min.Y+8
a.DrawBig(screen, "売店", x, y, colKnown)
a.Draw(screen, fmt.Sprintf("%s %s %d¥", a.T("shop.title"), a.T("hud.cash"), g.Wallet.Cash), x+36, y+2, colText)
y += 24
for i, it := range sh.items {
ry := y + i*LineH
if i == sh.cursor {
vector.DrawFilledRect(screen, float32(x-2), float32(ry-1), float32(p.Dx()-12), LineH, colBar, false)
}
col := colText
if g.Progress.Own(it.ID) {
col = colGood
} else if it.Price > g.Wallet.Cash {
col = colDim
}
a.Draw(screen, it.Name.Get(UILang), x, ry, col)
a.Draw(screen, effectText(a, it.Effect), x+110, ry, colDim)
price := fmt.Sprintf("%d円", it.Price)
if g.Progress.Own(it.ID) {
price = a.T("items.owned_short")
}
a.Draw(screen, price, p.Max.X-10-a.Width(price), ry, col)
}
if sh.msg != "" {
a.Draw(screen, sh.msg, x, p.Max.Y-28, colKnown)
}
a.Draw(screen, a.T("shop.hint"), x, p.Max.Y-16, colDim)
_ = color.RGBA{}
}

View File

@@ -172,9 +172,20 @@ type SignText struct {
Text Text `json:"text"`
}
// Rewards är vad en klarad bana ger utöver poäng.
type Rewards struct {
Items []string `json:"items"` // item-id:n (t.ex. skins) som delas ut första gången
SkillPoints int `json:"skillPoints"` // extra skillpoäng första gången
}
// Level är en bana.
type Level struct {
ID string `json:"id"`
Kind string `json:"kind"` // "main" (standard) eller "side"
Order int `json:"order"` // huvudbanornas ordning på bankartan (1, 2, …); sidobanor ärver förälderns
Parent string `json:"parent"` // sidobanans huvudbana – låses upp när den är klarad
Rewards Rewards `json:"rewards"`
Shop []string `json:"shop"` // items som kiosken säljer på banan (tom = seedat urval)
Name Text `json:"name"`
Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår)
Destination Text `json:"destination"` // dit spelaren ska
@@ -247,6 +258,22 @@ func (l *Level) Validate() error {
if l.ID == "" {
return fmt.Errorf("level: saknar id")
}
switch l.Kind {
case "":
l.Kind = "main"
case "main", "side":
default:
return fmt.Errorf("level %s: kind måste vara main eller side", l.ID)
}
if l.Kind == "side" && l.Parent == "" {
return fmt.Errorf("level %s: sidobana utan parent", l.ID)
}
if l.Kind == "main" && l.Parent != "" {
return fmt.Errorf("level %s: huvudbana med parent", l.ID)
}
if l.Order < 0 {
return fmt.Errorf("level %s: negativ order", l.ID)
}
if len(l.Grid) == 0 {
return fmt.Errorf("level %s: tomt rutnät", l.ID)
}
@@ -534,6 +561,26 @@ func LoadFile(path string) (*Level, error) {
return Parse(b)
}
// IsSide rapporterar om banan är en sidobana.
func (l *Level) IsSide() bool { return l.Kind == "side" }
// LoadAll läser alla banor i en mapp, sorterade på id.
func LoadAll(dir string) ([]*Level, error) {
ids, err := List(dir)
if err != nil {
return nil, err
}
var out []*Level
for _, id := range ids {
l, err := LoadFile(filepath.Join(dir, id+".json"))
if err != nil {
return nil, err
}
out = append(out, l)
}
return out, nil
}
// List returnerar id:n (filnamn utan .json) för alla banor i en mapp, sorterade.
func List(dir string) ([]string, error) {
files, err := filepath.Glob(filepath.Join(dir, "*.json"))

View File

@@ -150,6 +150,9 @@ func TestAllShippedLevels(t *testing.T) {
if l.Party > 1 && (len(l.Find(Toilet)) == 0 || len(l.Find(Kiosk)) == 0) {
t.Errorf("banor med sällskap behöver minst en toalett (W) och en kiosk (K)")
}
if l.Kind == "main" && l.Order == 0 {
t.Errorf("huvudbanor behöver order för bankartan")
}
for _, sg := range l.Signs {
if sg.Text["sv"] == "" || sg.Text["en"] == "" {
t.Errorf("skylt (%d,%d) saknar sv/en-text", sg.X, sg.Y)
@@ -384,3 +387,46 @@ func TestTrackTile(t *testing.T) {
t.Fatal("räls till höger om perrongen är vertikal")
}
}
func TestKindOrderParentAndLoadAll(t *testing.T) {
l := mustParse(t, sample)
if l.Kind != "main" || l.IsSide() {
t.Fatal("standard är main")
}
if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "kind": "side"`, 1))); err == nil {
t.Fatal("sidobana utan parent ska ge fel")
}
if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "parent": "x"`, 1))); err == nil {
t.Fatal("huvudbana med parent ska ge fel")
}
if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "kind": "bus"`, 1))); err == nil {
t.Fatal("okänd kind ska ge fel")
}
side := mustParse(t, strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "kind": "side", "parent": "001", "rewards": {"items": ["skin_kimono"], "skillPoints": 1}`, 1))
if !side.IsSide() || side.Parent != "001" || side.Rewards.SkillPoints != 1 || len(side.Rewards.Items) != 1 {
t.Fatal("sidobanans fält ska läsas")
}
dir, _ := res.Path("data", "levels")
all, err := LoadAll(dir)
if err != nil || len(all) < 4 {
t.Fatalf("LoadAll: %d banor, %v", len(all), err)
}
sides := 0
for _, l := range all {
if l.IsSide() {
sides++
found := false
for _, m := range all {
if m.ID == l.Parent && !m.IsSide() {
found = true
}
}
if !found {
t.Errorf("%s: föräldern %q finns inte bland huvudbanorna", l.ID, l.Parent)
}
}
}
if sides == 0 {
t.Fatal("det ska finnas minst en sidobana")
}
}

View File

@@ -17,11 +17,13 @@ type Crowd struct {
const crowdZoneWidth = 0.22
// NewCrowd seedar zonens läge och markörens fart.
func NewCrowd(seed rng.Seed, tries int) *Crowd {
// NewCrowd seedar zonens läge och markörens fart. luck (1 = normalt) gör
// zonen bredare eller smalare, inom rimliga gränser.
func NewCrowd(seed rng.Seed, tries int, luck float64) *Crowd {
r := newRand(seed, "crowd")
lo := r.between(0.1, 0.9-crowdZoneWidth)
return &Crowd{dir: 1, Speed: r.between(0.9, 1.5), ZoneLo: lo, ZoneHi: lo + crowdZoneWidth, MaxTries: max(1, tries)}
width := min(0.6, max(0.1, crowdZoneWidth*max(0.5, min(2, luck))))
lo := r.between(0.1, 0.9-width)
return &Crowd{dir: 1, Speed: r.between(0.9, 1.5), ZoneLo: lo, ZoneHi: lo + width, MaxTries: max(1, tries)}
}
// InZone rapporterar om markören är i zonen just nu.

View File

@@ -114,7 +114,7 @@ func TestTicketBoardSeededAndAffordable(t *testing.T) {
}
func TestCrowdZoneAndAttempts(t *testing.T) {
g := NewCrowd(rng.FromInt(7), 2)
g := NewCrowd(rng.FromInt(7), 2, 1)
if g.ZoneLo < 0.1 || g.ZoneHi > 0.9 || math.Abs((g.ZoneHi-g.ZoneLo)-crowdZoneWidth) > 1e-9 {
t.Fatalf("zon %.2f–%.2f", g.ZoneLo, g.ZoneHi)
}
@@ -135,7 +135,7 @@ func TestCrowdZoneAndAttempts(t *testing.T) {
t.Fatal("träff i zonen ska lyckas")
}
// Två bom = tåget går.
f := NewCrowd(rng.FromInt(7), 2)
f := NewCrowd(rng.FromInt(7), 2, 1)
for n := 0; n < 2; n++ {
for f.InZone() {
f.Update(Input{}, frame)
@@ -146,7 +146,7 @@ func TestCrowdZoneAndAttempts(t *testing.T) {
t.Fatal("två bom ska misslyckas")
}
// Markören studsar och håller sig i 0..1.
b := NewCrowd(rng.FromInt(8), 1)
b := NewCrowd(rng.FromInt(8), 1, 1)
for i := 0; i < 600; i++ {
b.Update(Input{}, frame)
if b.Pos < 0 || b.Pos > 1 {
@@ -293,3 +293,16 @@ func TestSeatSelectionPreferences(t *testing.T) {
t.Fatal("avbrutet sittplatsval = inget köp")
}
}
func TestCrowdLuckWidensZone(t *testing.T) {
normal := NewCrowd(rng.FromInt(3), 2, 1)
lucky := NewCrowd(rng.FromInt(3), 2, 1.5)
unlucky := NewCrowd(rng.FromInt(3), 2, 0.5)
if lucky.ZoneHi-lucky.ZoneLo <= normal.ZoneHi-normal.ZoneLo || unlucky.ZoneHi-unlucky.ZoneLo >= normal.ZoneHi-normal.ZoneLo {
t.Fatal("tur ska ge bredare zon, otur smalare")
}
huge := NewCrowd(rng.FromInt(3), 2, 100)
if huge.ZoneHi-huge.ZoneLo > 0.6+1e-9 || huge.ZoneHi > 0.9+1e-9 {
t.Fatal("zonen klampas")
}
}

View File

@@ -0,0 +1,168 @@
// Package progress är spelarens progression: XP och nivåer, skill tree,
// items med buffar, klarade banor och upplåsning av bankartan. Ren Go;
// katalogerna (skills.json, items.json) läses från resources/data.
package progress
import (
"encoding/json"
"fmt"
"sort"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// Effect är vad en skill eller ett item gör. Allt är valfritt.
type Effect struct {
Stats map[string]float64 `json:"stats"` // speed/thirst/focus/toilet/luck → multiplikator
Kana bool `json:"kana"` // kan läsa kana
FogRadius int `json:"fogRadius"` // extra siktsteg i gångarna
Landmarks bool `json:"landmarks"` // toaletter/kiosker/automater syns alltid på minikartan
FreeGates bool `json:"freeGates"` // spärrarna kostar inget (genväg)
RevealLevel string `json:"revealLevel"` // hela minikartan känd på banan
Battery bool `json:"battery"` // telefonen laddar inte ur
}
// Skill är en nod i språk-/skillträdet.
type Skill struct {
ID string `json:"id"`
Branch string `json:"branch"`
Name level.Text `json:"name"`
Desc level.Text `json:"desc"`
Cost int `json:"cost"`
Requires []string `json:"requires"`
Effect Effect `json:"effect"`
}
// Item är kläder, masker, hattar, prylar och skins.
type Item struct {
ID string `json:"id"`
Kind string `json:"kind"`
Slot string `json:"slot"`
Name level.Text `json:"name"`
Price int `json:"price"` // 0 = bara belöning
Effect Effect `json:"effect"`
Tint []float64 `json:"tint"` // färgton på spelaren (r, g, b) när det bärs
}
// Slots är kroppsplatserna; ett item per plats bärs.
var Slots = []string{"head", "face", "body", "feet", "bag"}
var statIndex = map[string]int{"speed": sim.StatSpeed, "thirst": sim.StatThirst, "focus": sim.StatFocus, "toilet": sim.StatToilet, "luck": sim.StatLuck}
// Catalog är alla skills och items.
type Catalog struct {
Skills []Skill
Items []Item
skills map[string]*Skill
items map[string]*Item
}
// LoadCatalog läser och validerar skills.json och items.json.
func LoadCatalog(skillsJSON, itemsJSON []byte) (*Catalog, error) {
var sk struct {
Skills []Skill `json:"skills"`
}
if err := json.Unmarshal(skillsJSON, &sk); err != nil {
return nil, fmt.Errorf("skills.json: %w", err)
}
var it struct {
Items []Item `json:"items"`
}
if err := json.Unmarshal(itemsJSON, &it); err != nil {
return nil, fmt.Errorf("items.json: %w", err)
}
c := &Catalog{Skills: sk.Skills, Items: it.Items, skills: map[string]*Skill{}, items: map[string]*Item{}}
for i := range c.Skills {
s := &c.Skills[i]
if s.ID == "" || s.Cost < 1 || s.Name["sv"] == "" || s.Name["en"] == "" {
return nil, fmt.Errorf("skill %q: saknar id/cost/namn", s.ID)
}
if _, dup := c.skills[s.ID]; dup {
return nil, fmt.Errorf("skill %q: dubblett", s.ID)
}
if err := s.Effect.validate(); err != nil {
return nil, fmt.Errorf("skill %q: %w", s.ID, err)
}
c.skills[s.ID] = s
}
for _, s := range c.Skills {
for _, r := range s.Requires {
if _, ok := c.skills[r]; !ok {
return nil, fmt.Errorf("skill %q kräver okänd skill %q", s.ID, r)
}
}
}
for i := range c.Items {
it := &c.Items[i]
if it.ID == "" || it.Name["sv"] == "" || it.Name["en"] == "" {
return nil, fmt.Errorf("item %q: saknar id/namn", it.ID)
}
if _, dup := c.items[it.ID]; dup {
return nil, fmt.Errorf("item %q: dubblett", it.ID)
}
if !validSlot(it.Slot) {
return nil, fmt.Errorf("item %q: okänd slot %q", it.ID, it.Slot)
}
if it.Price < 0 || (len(it.Tint) != 0 && len(it.Tint) != 3) {
return nil, fmt.Errorf("item %q: pris/tint", it.ID)
}
if err := it.Effect.validate(); err != nil {
return nil, fmt.Errorf("item %q: %w", it.ID, err)
}
c.items[it.ID] = it
}
return c, nil
}
func (e Effect) validate() error {
for k, v := range e.Stats {
if _, ok := statIndex[k]; !ok {
return fmt.Errorf("okänd stat %q", k)
}
if v <= 0 || v > 3 {
return fmt.Errorf("stat %s=%v utanför 0..3", k, v)
}
}
return nil
}
func validSlot(s string) bool {
for _, x := range Slots {
if x == s {
return true
}
}
return false
}
// Skill slår upp en skill (nil om okänd).
func (c *Catalog) Skill(id string) *Skill { return c.skills[id] }
// Item slår upp ett item (nil om okänt).
func (c *Catalog) Item(id string) *Item { return c.items[id] }
// Purchasable är alla items med pris, billigast först.
func (c *Catalog) Purchasable() []Item {
var out []Item
for _, it := range c.Items {
if it.Price > 0 {
out = append(out, it)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Price < out[j].Price })
return out
}
// ThirdCost är vad den billigaste tredjedelen av alla köpbara items kostar
// tillsammans – designregeln säger att det ska vara mer än en omgångs
// startkassa, så att man knappt får tag på 33 % under en omgång.
func (c *Catalog) ThirdCost() int {
p := c.Purchasable()
n := (len(p) + 2) / 3
sum := 0
for _, it := range p[:n] {
sum += it.Price
}
return sum
}

View File

@@ -0,0 +1,262 @@
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
}

View File

@@ -0,0 +1,216 @@
package progress
import (
"math"
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
func loadCatalog(t *testing.T) *Catalog {
t.Helper()
sk, err := res.ReadFile("data", "skills.json")
if err != nil {
t.Fatal(err)
}
it, err := res.ReadFile("data", "items.json")
if err != nil {
t.Fatal(err)
}
c, err := LoadCatalog(sk, it)
if err != nil {
t.Fatal(err)
}
return c
}
func TestShippedCatalogAndThirdRule(t *testing.T) {
c := loadCatalog(t)
if len(c.Skills) < 10 || len(c.Items) < 20 {
t.Fatalf("skills=%d items=%d", len(c.Skills), len(c.Items))
}
// Designregeln: billigaste tredjedelen av alla köpbara items kostar mer
// än startkassan, så att man knappt får tag på 33 % under en omgång.
if c.ThirdCost() <= sim.StartCashYen {
t.Fatalf("billigaste tredjedelen kostar %d ≤ startkassan %d", c.ThirdCost(), sim.StartCashYen)
}
// Alla banor som items pekar på finns.
dir, _ := res.Path("data", "levels")
all, err := level.LoadAll(dir)
if err != nil {
t.Fatal(err)
}
ids := map[string]bool{}
for _, l := range all {
ids[l.ID] = true
for _, r := range l.Rewards.Items {
if c.Item(r) == nil {
t.Errorf("%s belönar okänt item %q", l.ID, r)
}
}
for _, s := range l.Shop {
if it := c.Item(s); it == nil || it.Price == 0 {
t.Errorf("%s säljer okänt/gratis item %q", l.ID, s)
}
}
}
for _, it := range c.Items {
if it.Effect.RevealLevel != "" && !ids[it.Effect.RevealLevel] {
t.Errorf("%s avslöjar okänd bana %q", it.ID, it.Effect.RevealLevel)
}
}
}
func TestCatalogValidation(t *testing.T) {
bad := []struct{ name, skills, items string }{
{"okänd stat", `{"skills":[{"id":"a","cost":1,"name":{"sv":"A","en":"A"},"effect":{"stats":{"mana":2}}}]}`, `{"items":[]}`},
{"okänt krav", `{"skills":[{"id":"a","cost":1,"name":{"sv":"A","en":"A"},"requires":["zzz"]}]}`, `{"items":[]}`},
{"dubblett", `{"skills":[{"id":"a","cost":1,"name":{"sv":"A","en":"A"}},{"id":"a","cost":1,"name":{"sv":"A","en":"A"}}]}`, `{"items":[]}`},
{"okänd slot", `{"skills":[]}`, `{"items":[{"id":"x","slot":"tail","name":{"sv":"X","en":"X"},"price":1}]}`},
{"trasig tint", `{"skills":[]}`, `{"items":[{"id":"x","slot":"head","name":{"sv":"X","en":"X"},"price":1,"tint":[1,2]}]}`},
}
for _, b := range bad {
if _, err := LoadCatalog([]byte(b.skills), []byte(b.items)); err == nil {
t.Errorf("%s: förväntade fel", b.name)
}
}
}
func TestXPLevelsAndSkillPoints(t *testing.T) {
cases := map[int]int{0: 1, 149: 1, 150: 2, 449: 2, 450: 3, 900: 4, 1500: 5}
for xp, lvl := range cases {
if LevelFor(xp) != lvl {
t.Errorf("LevelFor(%d)=%d, vill ha %d", xp, LevelFor(xp), lvl)
}
}
if XPToNext(0) != 150 || XPToNext(150) != 300 || XPToNext(449) != 1 {
t.Fatalf("XPToNext: %d %d %d", XPToNext(0), XPToNext(150), XPToNext(449))
}
p := New()
if ups := p.AddXP(XPFor(941, 3)); ups != 1 || p.SkillPoints != 1 || p.Level() != 2 {
t.Fatalf("ups=%d points=%d level=%d", ups, p.SkillPoints, p.Level())
}
if ups := p.AddXP(1000); ups != 2 || p.SkillPoints != 3 {
t.Fatalf("två nivåer på en gång: ups=%d points=%d", ups, p.SkillPoints)
}
if p.AddXP(-5) != 0 || p.XP != XPFor(941, 3)+1000 {
t.Fatal("negativ XP ignoreras")
}
}
func TestSkillTreeRequirementsAndCost(t *testing.T) {
c := loadCatalog(t)
p := New()
if p.CanUnlock(c, "focus1") || p.Unlock(c, "focus1") {
t.Fatal("utan poäng går inget")
}
p.SkillPoints = 3
if p.CanUnlock(c, "focus2") {
t.Fatal("focus2 kräver focus1")
}
if !p.Unlock(c, "focus1") || p.SkillPoints != 2 || !p.Has("focus1") {
t.Fatal("focus1 kostar 1")
}
if !p.Unlock(c, "focus2") || p.SkillPoints != 0 {
t.Fatal("focus2 kostar 2 och kräver focus1")
}
if p.Unlock(c, "focus1") {
t.Fatal("inte två gånger")
}
eff := p.Effects(c)
if math.Abs(eff.Stats[sim.StatFocus]-1.25*1.25) > 1e-9 || eff.Kana {
t.Fatalf("effekter: %+v", eff)
}
p.SkillPoints = 5
p.Unlock(c, "kana")
p.Unlock(c, "scout")
p.Unlock(c, "local")
eff = p.Effects(c)
if !eff.Kana || eff.FogRadius != 1 || !eff.Landmarks {
t.Fatalf("flaggor: %+v", eff)
}
}
func TestItemsBuyEquipAndEffects(t *testing.T) {
c := loadCatalog(t)
p := New()
cash := 10000
pay := func(yen int) bool {
if cash < yen {
return false
}
cash -= yen
return true
}
if !p.Buy(c, "sneakers", pay) || !p.Own("sneakers") || p.Equipped["feet"] != "sneakers" || cash != 10000-3500 {
t.Fatalf("köp: own=%v equipped=%v cash=%d", p.Own("sneakers"), p.Equipped, cash)
}
if p.Buy(c, "sneakers", pay) || p.Buy(c, "skin_kimono", pay) || p.Buy(c, "rail_pass", pay) {
t.Fatal("inte igen, inte gratisitems, inte utan råd (8000 > 6500)")
}
if !p.Buy(c, "geta", pay) || p.Equipped["feet"] != "sneakers" {
t.Fatal("andra skor köps men byter inte automatiskt")
}
if !p.Equip(c, "geta") || p.Equipped["feet"] != "geta" {
t.Fatal("Equip byter")
}
eff := p.Effects(c)
if math.Abs(eff.Stats[sim.StatSpeed]-0.9) > 1e-9 || math.Abs(eff.Stats[sim.StatLuck]-1.2) > 1e-9 {
t.Fatalf("geta: %+v", eff.Stats)
}
p.Unequip("feet")
if p.Effects(c).Stats[sim.StatSpeed] != 1 || p.Worn(c, "feet") != nil {
t.Fatal("Unequip")
}
if !p.Give(c, "skin_kimono") || p.Give(c, "skin_kimono") || p.Equipped["body"] != "skin_kimono" {
t.Fatal("belöning ges en gång och bärs om platsen är ledig")
}
eff = p.Effects(c)
if eff.Tint == [3]float32{1, 1, 1} {
t.Fatal("skinnet ska tona spelaren")
}
p.Give(c, "rail_pass")
p.Give(c, "map_shibuya")
p.Equip(c, "map_shibuya") // bara en pryl i väskan åt gången
eff = p.Effects(c)
if eff.FreeGates || !eff.Reveal["003-shibuya"] {
t.Fatalf("väskan: %+v", eff)
}
p.Equip(c, "rail_pass")
if !p.Effects(c).FreeGates {
t.Fatal("JR Pass i väskan ger gratis spärrar")
}
}
func TestLevelRecordsAndUnlocking(t *testing.T) {
dir, _ := res.Path("data", "levels")
all, err := level.LoadAll(dir)
if err != nil {
t.Fatal(err)
}
p := New()
if !p.Unlocked(all, "001-flygplatsen") || p.Unlocked(all, "002-shinjuku") || p.Unlocked(all, "002b-kabukicho") || p.Unlocked(all, "003-shibuya") {
t.Fatal("bara första huvudbanan är öppen från start")
}
if p.RecordLevel("001-flygplatsen", false, 0, 0) || p.Cleared("001-flygplatsen") || p.Levels["001-flygplatsen"].Attempts != 1 {
t.Fatal("förlust räknas som försök")
}
if !p.RecordLevel("001-flygplatsen", true, 2, 700) || !p.Unlocked(all, "002-shinjuku") || p.Unlocked(all, "002b-kabukicho") {
t.Fatal("första klaring öppnar nästa huvudbana men inte sidobanan under 002")
}
if p.RecordLevel("001-flygplatsen", true, 3, 500) {
t.Fatal("andra klaring är inte första")
}
if r := p.Levels["001-flygplatsen"]; r.Stars != 3 || r.Score != 700 || r.Attempts != 3 {
t.Fatalf("bästa behålls: %+v", r)
}
p.RecordLevel("002-shinjuku", true, 1, 300)
if !p.Unlocked(all, "002b-kabukicho") || !p.Unlocked(all, "003-shibuya") || p.TotalStars() != 4 {
t.Fatalf("efter 002: sidobana och 003 öppna, stjärnor=%d", p.TotalStars())
}
if p.Unlocked(all, "finns-inte") {
t.Fatal("okänd bana")
}
}

122
internal/save/save.go Normal file
View File

@@ -0,0 +1,122 @@
// Package save läser och skriver sparfilen: kunskap, plånbok, debuffar,
// progression och den pågående omgångens bana + seed. JSON med
// versionsfält så att gamla filer kan migreras.
package save
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// Version är sparfilens formatversion.
const Version = 1
// AppDir är mappnamnet under användarens konfigmapp.
const AppDir = "metro-puzzle-jp"
// RunRef pekar ut en pågående omgång så att den kan återupptas med samma
// slump (minispel, händelser, läsning).
type RunRef struct {
LevelID string `json:"levelId"`
Seed [2]uint64 `json:"seed"`
Started time.Time `json:"started"`
}
// State är allt som sparas.
type State struct {
Version int `json:"version"`
SavedAt time.Time `json:"savedAt"`
Know *lang.Knowledge `json:"knowledge"`
Wallet *sim.Wallet `json:"wallet"`
Profile *sim.Profile `json:"profile"`
Progress *progress.Progress `json:"progress"`
Current *RunRef `json:"current,omitempty"`
}
// New är ett nytt spel.
func New() *State {
return &State{Version: Version, Know: lang.NewKnowledge(), Wallet: sim.NewWallet(), Profile: &sim.Profile{}, Progress: progress.New()}
}
// DefaultPath är sparfilens plats: ~/.config/metro-puzzle-jp/save.json på
// Linux, %AppData%\metro-puzzle-jp\save.json på Windows.
func DefaultPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, AppDir, "save.json"), nil
}
// Load läser sparfilen. Saknas den returneras ett nytt spel (och exists=false).
func Load(path string) (st *State, exists bool, err error) {
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return New(), false, nil
}
if err != nil {
return nil, false, err
}
var s State
if err := json.Unmarshal(b, &s); err != nil {
return nil, true, fmt.Errorf("sparfilen %s är trasig: %w", path, err)
}
if err := migrate(&s); err != nil {
return nil, true, err
}
return &s, true, nil
}
// migrate lyfter äldre versioner till den aktuella och fyller i luckor.
func migrate(s *State) error {
if s.Version > Version {
return fmt.Errorf("sparfilen är från en nyare version (%d > %d)", s.Version, Version)
}
// Version 0 (saknat fält) och 1 har samma form; här läggs framtida steg.
s.Version = Version
if s.Know == nil {
s.Know = lang.NewKnowledge()
}
if s.Know.Words == nil {
s.Know.Words = map[string]bool{}
}
if s.Know.Progress == nil {
s.Know.Progress = map[string]int{}
}
if s.Wallet == nil {
s.Wallet = sim.NewWallet()
}
if s.Profile == nil {
s.Profile = &sim.Profile{}
}
if s.Progress == nil {
s.Progress = progress.New()
}
return nil
}
// Store skriver sparfilen atomiskt (tempfil + byte av namn).
func Store(path string, s *State) error {
s.Version = Version
s.SavedAt = time.Now()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}

View File

@@ -0,0 +1,78 @@
package save
import (
"os"
"path/filepath"
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
func TestRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "save.json")
st, exists, err := Load(path)
if err != nil || exists || st.Version != Version || st.Progress == nil || st.Know.Count() != 0 {
t.Fatalf("saknad fil ska ge nytt spel: exists=%v err=%v", exists, err)
}
st.Know.Learn("改札")
st.Know.Points = 3
st.Wallet.Card = 1234
st.Wallet.Phone = true
st.Profile.Add(sim.Tired, 2)
st.Progress.AddXP(500)
st.Progress.Skills["kana"] = true
st.Progress.Items["geta"] = true
st.Progress.Equipped["feet"] = "geta"
st.Progress.RecordLevel("001-flygplatsen", true, 3, 941)
st.Current = &RunRef{LevelID: "002-shinjuku", Seed: [2]uint64{7, 99}}
if err := Store(path, st); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path + ".tmp"); err == nil {
t.Fatal("tempfilen ska vara borta")
}
got, exists, err := Load(path)
if err != nil || !exists {
t.Fatalf("exists=%v err=%v", exists, err)
}
if !got.Know.Knows("改札") || got.Know.Points != 3 || got.Wallet.Card != 1234 || !got.Wallet.Phone {
t.Fatal("kunskap/plånbok")
}
if !got.Profile.Has(sim.Tired) || got.Progress.XP != 500 || got.Progress.Level() != 3 || !got.Progress.Has("kana") || got.Progress.Equipped["feet"] != "geta" {
t.Fatalf("profil/progression: %+v", got.Progress)
}
if !got.Progress.Cleared("001-flygplatsen") || got.Progress.Levels["001-flygplatsen"].Score != 941 {
t.Fatal("banresultat")
}
if got.Current == nil || got.Current.LevelID != "002-shinjuku" || got.Current.Seed != [2]uint64{7, 99} {
t.Fatalf("pågående omgång: %+v", got.Current)
}
if got.SavedAt.IsZero() {
t.Fatal("SavedAt")
}
}
func TestCorruptAndFutureVersions(t *testing.T) {
path := filepath.Join(t.TempDir(), "save.json")
os.WriteFile(path, []byte("{nope"), 0o644)
if _, _, err := Load(path); err == nil {
t.Fatal("trasig fil ska ge fel")
}
os.WriteFile(path, []byte(`{"version": 99}`), 0o644)
if _, _, err := Load(path); err == nil {
t.Fatal("nyare version ska ge fel")
}
// Version 0 utan fält migreras till ett komplett tillstånd.
os.WriteFile(path, []byte(`{"knowledge": {"words": {"改札": true}}}`), 0o644)
st, exists, err := Load(path)
if err != nil || !exists || st.Version != Version || !st.Know.Knows("改札") || st.Wallet == nil || st.Progress == nil || st.Profile == nil {
t.Fatalf("migrering: %+v err=%v", st, err)
}
}
func TestDefaultPath(t *testing.T) {
p, err := DefaultPath()
if err != nil || filepath.Base(p) != "save.json" || filepath.Base(filepath.Dir(p)) != AppDir {
t.Fatalf("path=%q err=%v", p, err)
}
}

View File

@@ -9,10 +9,11 @@ const FogRadius = 3
// Fog håller reda på vilka rutor spelaren har sett i den här omgången.
// Minikartan ritar bara sedda rutor plus riktmärken som alltid syns.
type Fog struct {
w, h int
seen []bool
Radius int
count int
w, h int
seen []bool
Radius int
count int
ExtraLandmarks bool // toaletter, kiosker och automater syns alltid (skillen Lokalkännedom)
}
// NewFog skapar en tom dimma för banan.
@@ -83,7 +84,25 @@ func Landmark(l *level.Level, p level.Point) bool {
// Visible rapporterar om rutan ska ritas på minikartan.
func (f *Fog) Visible(l *level.Level, p level.Point) bool {
return f.Seen(p) || Landmark(l, p)
if f.Seen(p) || Landmark(l, p) {
return true
}
if f.ExtraLandmarks {
switch l.At(p.X, p.Y) {
case level.Toilet, level.Kiosk, level.TicketMachine:
return true
}
}
return false
}
// RevealAll avslöjar hela banan (kartan köpt som item).
func (f *Fog) RevealAll(l *level.Level) {
for y := 0; y < l.Height(); y++ {
for x := 0; x < l.Width(); x++ {
f.mark(level.Point{X: x, Y: y})
}
}
}
// Count är antalet sedda rutor (inkl. väggar).

View File

@@ -84,6 +84,7 @@ type Run struct {
spentStart int // plånbokens Spent vid banstart
Score ScoreBreakdown
NewDebuffs []Debuff // debuffar banan gav (appliceras på profilen efteråt)
opts RunOptions
}
// ScoreBreakdown är poängen för en vunnen bana.
@@ -163,6 +164,14 @@ type RunOptions struct {
Wallet *Wallet // nil = startkassa
Debuffs []Debuff // aktiva debuffar från profilen
Seed rng.Seed // omgångens seed (sparas med spelet)
// Från skills och items (progression):
Extra Stats // multipliceras in i statsen (nollvärde = inga)
FogRadius int // extra siktsteg
Landmarks bool // toaletter/kiosker/automater alltid på minikartan
FreeGates bool // spärrarna kostar inget och kräver inget
RevealAll bool // hela banan känd från start
InfiniteBattery bool // telefonen laddar inte ur
}
// DefaultSpeed är spelarens grundfart i rutor per sekund.
@@ -215,11 +224,21 @@ func NewRun(l *level.Level, o RunOptions) *Run {
Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")),
baseSpeed: o.Speed,
NoticeAt: -1,
Fog: NewFog(l, FogRadius),
Stats: CombinedStats(o.Debuffs),
spentStart: o.Wallet.Spent,
opts: o,
}
if o.Extra != (Stats{}) {
for i := range r.Stats {
r.Stats[i] *= o.Extra[i]
}
}
r.Fog = NewFog(l, FogRadius+o.FogRadius)
r.Fog.ExtraLandmarks = o.Landmarks
r.Party.Rates = Rates{Thirst: r.Stats[StatThirst], Toilet: r.Stats[StatToilet]}
if o.RevealAll {
r.Fog.RevealAll(l)
}
r.Fog.Reveal(l, l.SpawnPoint())
if r.parSec <= 0 {
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
@@ -259,6 +278,9 @@ func (r *Run) CanEnter(p level.Point) bool {
}
func (r *Run) canPassGate() bool {
if r.opts.FreeGates {
return true // JR Pass: genväg genom alla spärrar
}
if r.Level.TicketRequired {
if r.Wallet.Ticket == "" {
return false
@@ -306,7 +328,9 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
return // minispel pågår: använd StepMini
}
r.Time += dt
r.Wallet.Drain(dt)
if !r.opts.InfiniteBattery {
r.Wallet.Drain(dt)
}
before := r.Player.Tile
wasGate := r.inGate()
r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor() * r.Stats[StatSpeed]
@@ -332,7 +356,7 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
r.checkLimits()
if r.Player.Tile != before && r.inGate() && !wasGate {
r.GatePasses++
if !r.Level.TicketRequired {
if !r.Level.TicketRequired && !r.opts.FreeGates {
r.Wallet.PayCard(r.Level.FareYen)
if r.Level.IndividualCards {
for _, m := range r.Party.Followers() {
@@ -427,7 +451,7 @@ func (r *Run) tryBoard(tr *level.Train) {
}
r.PendingTrain = tr
if tr.Crowded {
r.Mini, r.MiniKind = minigame.NewCrowd(r.miniSeed(), CrowdTries), MiniCrowd
r.Mini, r.MiniKind = minigame.NewCrowd(r.miniSeed(), CrowdTries, r.Stats[StatLuck]), MiniCrowd
return
}
r.startRide()
@@ -449,7 +473,9 @@ func (r *Run) StepMini(dt float64, in minigame.Input) {
return
}
r.Time += dt
r.Wallet.Drain(dt)
if !r.opts.InfiniteBattery {
r.Wallet.Drain(dt)
}
r.Party.Advance(dt, r.Time)
r.Mini.Update(in, dt)
if !r.Mini.Done() {

View File

@@ -954,3 +954,50 @@ func TestNearTrackAndPhases(t *testing.T) {
t.Fatal("tre rutor bort är utom räckhåll")
}
}
func TestRunOptionsFromProgression(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
extra := DefaultStats()
extra[StatSpeed] = 1.2
r := NewRun(l, RunOptions{Seed: rng.FromInt(1), Extra: extra, FogRadius: 2, Landmarks: true, FreeGates: true, RevealAll: true, InfiniteBattery: true})
r.Party.nextEventAt = 1e9
r.Wallet.Card, r.Wallet.Phone, r.Wallet.Battery = 0, true, 50
r.Step(frame, Right, false)
if math.Abs(r.Player.Speed-DefaultSpeed*1.2*PhoneSpeedFactor) > 1e-9 {
t.Fatalf("extra fart: %.2f", r.Player.Speed)
}
if r.Wallet.Battery != 50 {
t.Fatal("powerbank: batteriet laddar inte ur")
}
if r.Fog.Radius != FogRadius+2 || !r.Fog.ExtraLandmarks || r.Fog.Explored(l) != 1 {
t.Fatalf("dimma: radius=%d landmarks=%v explored=%.2f", r.Fog.Radius, r.Fog.ExtraLandmarks, r.Fog.Explored(l))
}
gate := l.Find(level.Gate)[0]
if !r.CanEnter(gate) {
t.Fatal("JR Pass: spärren släpper igenom utan saldo")
}
for r.Player.Moving() {
r.Step(frame, None, false)
}
path := l.Path(r.Player.Tile, gate, r.CanEnter)
for i, p := range path {
if l.At(p.X, p.Y) == level.Gate {
path = path[:i+1]
break
}
}
walk(t, r, path)
if r.Wallet.Spent != 0 || r.GatePasses != 1 {
t.Fatalf("gratis spärr: spent=%d passes=%d", r.Wallet.Spent, r.GatePasses)
}
// Utan extras: standard.
plain := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
if plain.Stats != DefaultStats() || plain.Fog.Radius != FogRadius || plain.Fog.ExtraLandmarks {
t.Fatal("standardvärden utan progression")
}
// Lokalkännedom: kiosken syns på minikartan utan att vara sedd.
k := l.Find(level.Kiosk)[0]
if plain.Fog.Visible(l, k) || !r.Fog.Visible(l, k) {
t.Fatal("landmärken från skillen")
}
}