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{}
}