M12: ljud, inställningar och release v0.1.0
All checks were successful
release / build-release (push) Successful in 1m35s

tools/gensound syntetiserar 21 ljud: fyra stationsmelodier i klockklang
(en per station via melody-fältet eller banans id), ankomstjingel,
ding-dong vid inrullning, dörrsignal vid avgång, spärrens pip och
nekande, kiosk, toalett, lärt ord, bekräfta/varning, byte, personal,
vinst, förlust, flygplan, sorl och menymusik. sfx.go spelar effekter
utifrån vad som hänt i sim.Run sedan förra bildrutan. Ljudlagret har
huvudvolym × effekter/musik och byter inte slinga i onödan.
Inställningar (S på bankartan, sparas): volymer, språk sv/en som laddas
om direkt, fönsterskala, fullskärm (F11 överallt) och tangentöversikt.
Fönsterikon från genart. CHANGELOG.md. Tester för ljudfiler, melodival
och inställningar. Docs, wiki och skärmdumpar uppdaterade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 08:31:38 +01:00
parent 36a45563ec
commit 86f8130735
47 changed files with 800 additions and 107 deletions

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"image"
"image/color"
"image/png"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text/v2"
@@ -15,8 +16,8 @@ import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// UILang är UI-språket (sv/en) – blir en inställning i M12.
const UILang = "sv"
// UILang är UI-språket (sv/en), från inställningarna (Assets.LoadLang).
var UILang = "sv"
// Radhöjd för 10-pixelsfonten.
const LineH = 12
@@ -49,6 +50,7 @@ type Assets struct {
Rails *ebiten.Image // rälsruta (horisontell), roteras för vertikala spår
Posters []*ebiten.Image // affischer för visuell röra (ritas över väggrutor)
Plane *ebiten.Image // flygplanet i slutscenen
Icon image.Image // fönsterikonen (nil om den saknas)
Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
Text map[string]string
Dict *lang.Dictionary
@@ -103,9 +105,12 @@ func LoadAssets() (*Assets, error) {
}
}
if err := res.LoadJSON(&a.Text, UILang+".json"); err != nil {
if err := res.LoadJSON(&a.Text, "text", UILang+".json"); err != nil {
return nil, err
if err := a.LoadLang(UILang); err != nil {
return nil, err
}
if b, err := res.ReadFile("sprites", "icon.png"); err == nil {
if img, err := png.Decode(bytes.NewReader(b)); err == nil {
a.Icon = img
}
}
@@ -159,6 +164,19 @@ func (a *Assets) drawFace(dst *ebiten.Image, face *text.GoTextFace, s string, x,
// Width är textens bredd i pixlar med 10-pixelsfonten.
func (a *Assets) Width(s string) int { return int(text.Advance(s, a.Font)) }
// LoadLang läser UI-texterna för ett språk och gör det till UILang.
func (a *Assets) LoadLang(lang string) error {
texts := map[string]string{}
if err := res.LoadJSON(&texts, lang+".json"); err != nil {
if err := res.LoadJSON(&texts, "text", lang+".json"); err != nil {
return err
}
}
a.Text = texts
UILang = lang
return nil
}
// T slår upp en UI-text; saknas nyckeln visas nyckeln själv så att det syns.
func (a *Assets) T(key string) string {
if s, ok := a.Text[key]; ok {

View File

@@ -3,32 +3,75 @@ package game
import (
"bytes"
"fmt"
"hash/fnv"
"io"
"math"
"github.com/hajimehoshi/ebiten/v2/audio"
"github.com/hajimehoshi/ebiten/v2/audio/wav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
)
// Audio spelar korta effekter. nil-säkert: utan ljud (−mute eller fel) är
// alla anrop no-ops.
// Audio spelar effekter och en bakgrundsslinga, med volymer i tre lager:
// huvudvolym × effekter respektive musik/sorl. nil-säkert: utan ljud
// (−mute eller fel) är alla anrop no-ops.
type Audio struct {
ctx *audio.Context
clips map[string][]byte // avkodad PCM (16-bit stereo LE) per klipp
loop *audio.Player // pågående bakgrundsloop (bakgrundsprat)
ctx *audio.Context
clips map[string][]byte // avkodad PCM (16-bit stereo LE) per klipp
loop *audio.Player // pågående bakgrundsslinga
loopName string
loopBase float64 // slingans egen volym innan inställningarna
master, effects, music float64 // 0..1 från inställningarna
}
const sampleRate = 44100
// Ljudklipp (filnamn utan .wav i resources/sound).
const (
SoundApproach = "melody-approach"
SoundDoors = "door-chime"
SoundChatter = "chatter"
SoundApproach = "melody-approach" // ankomstjingel: tåget är på väg in
SoundAnnounce = "announce" // ding-dong när tåget rullar in
SoundDoors = "door-chime" // dörrarna stängs
SoundChatter = "chatter" // stationssorl (slinga)
SoundMusicMap = "music-map" // bankartans musik (slinga)
SoundGateBeep = "gate-beep"
SoundGateDeny = "gate-deny"
SoundKiosk = "kiosk"
SoundToilet = "toilet"
SoundLearn = "learn"
SoundConfirm = "confirm"
SoundCancel = "cancel"
SoundTransfer = "transfer"
SoundWin = "win"
SoundLose = "lose"
SoundStaff = "staff"
SoundPlane = "plane"
)
// StationMelodies är avgångsmelodierna; varje station har sin (level.Melody
// eller en vald efter banans id).
var StationMelodies = []string{"melody-a", "melody-b", "melody-c", "melody-d"}
// AllSounds är alla klipp som måste finnas i resources/sound.
func AllSounds() []string {
return append([]string{SoundApproach, SoundAnnounce, SoundDoors, SoundChatter, SoundMusicMap, SoundGateBeep, SoundGateDeny,
SoundKiosk, SoundToilet, SoundLearn, SoundConfirm, SoundCancel, SoundTransfer, SoundWin, SoundLose, SoundStaff, SoundPlane},
StationMelodies...)
}
// MelodyFor är stationens avgångsmelodi: banans melody-fält, annars en som
// väljs deterministiskt av banans id.
func MelodyFor(l *level.Level) string {
if l.Melody != "" {
return "melody-" + l.Melody
}
h := fnv.New32a()
h.Write([]byte(l.Station + l.ID))
return StationMelodies[int(h.Sum32()%uint32(len(StationMelodies)))]
}
// AudibleTiles är hur många rutor bort man hör perrongens ljud.
const AudibleTiles = 12.0
@@ -37,17 +80,9 @@ func newAudio(mute bool) (*Audio, error) {
if mute {
return nil, nil
}
a := &Audio{ctx: audio.NewContext(sampleRate), clips: map[string][]byte{}}
for _, name := range []string{SoundApproach, SoundDoors, SoundChatter} {
b, err := res.ReadFile("sound", name+".wav")
if err != nil {
return nil, err
}
stream, err := wav.DecodeWithoutResampling(bytes.NewReader(b))
if err != nil {
return nil, fmt.Errorf("ljud %s: %w", name, err)
}
pcm, err := io.ReadAll(stream)
a := &Audio{ctx: audio.NewContext(sampleRate), clips: map[string][]byte{}, master: 1, effects: 1, music: 1}
for _, name := range AllSounds() {
pcm, err := decodeSound(name)
if err != nil {
return nil, err
}
@@ -56,7 +91,33 @@ func newAudio(mute bool) (*Audio, error) {
return a, nil
}
// Play spelar ett klipp med volym 0..1.
// decodeSound läser och avkodar ett klipp till PCM.
func decodeSound(name string) ([]byte, error) {
b, err := res.ReadFile("sound", name+".wav")
if err != nil {
return nil, err
}
stream, err := wav.DecodeWithoutResampling(bytes.NewReader(b))
if err != nil {
return nil, fmt.Errorf("ljud %s: %w", name, err)
}
return io.ReadAll(stream)
}
// SetVolumes sätter lagren (0..1) och justerar en pågående slinga.
func (a *Audio) SetVolumes(master, effects, music float64) {
if a == nil {
return
}
a.master, a.effects, a.music = clamp01(master), clamp01(effects), clamp01(music)
if a.loop != nil {
a.loop.SetVolume(clamp01(a.loopBase * a.music * a.master))
}
}
func clamp01(v float64) float64 { return math.Max(0, math.Min(1, v)) }
// Play spelar ett klipp med volym 0..1 (gånger effekt- och huvudvolym).
func (a *Audio) Play(name string, volume float64) {
if a == nil || volume <= 0 {
return
@@ -65,19 +126,33 @@ func (a *Audio) Play(name string, volume float64) {
if !ok {
return
}
v := clamp01(volume * a.effects * a.master)
if v <= 0 {
return
}
p := a.ctx.NewPlayerFromBytes(pcm)
p.SetVolume(math.Min(1, volume))
p.SetVolume(v)
p.Play()
}
// Loop startar (eller byter) bakgrundsloopen med given volym; 0 stoppar.
// Loop startar (eller byter) bakgrundsslingan med given volym; 0 stoppar.
// Samma klipp som redan spelas fortsätter bara med ny volym.
func (a *Audio) Loop(name string, volume float64) {
if a == nil {
return
}
if volume <= 0 {
a.StopLoop()
return
}
if a.loop != nil && a.loopName == name {
a.loopBase = volume
a.loop.SetVolume(clamp01(volume * a.music * a.master))
return
}
a.StopLoop()
pcm, ok := a.clips[name]
if !ok || volume <= 0 {
if !ok {
return
}
stream := audio.NewInfiniteLoop(bytes.NewReader(pcm), int64(len(pcm)))
@@ -85,18 +160,18 @@ func (a *Audio) Loop(name string, volume float64) {
if err != nil {
return
}
p.SetVolume(math.Min(1, volume))
p.SetVolume(clamp01(volume * a.music * a.master))
p.Play()
a.loop = p
a.loop, a.loopName, a.loopBase = p, name, volume
}
// StopLoop stoppar bakgrundsloopen.
// StopLoop stoppar bakgrundsslingan.
func (a *Audio) StopLoop() {
if a == nil || a.loop == nil {
return
}
a.loop.Close()
a.loop = nil
a.loop, a.loopName = nil, ""
}
// volumeAt ger volym efter avstånd i rutor.

View File

@@ -0,0 +1,46 @@
package game
import (
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
)
// TestAllSoundsDecode: alla ljud som spelet refererar finns och går att
// avkoda (utan ljudkontext eller fönster).
func TestAllSoundsDecode(t *testing.T) {
seen := map[string]bool{}
for _, name := range AllSounds() {
if seen[name] {
t.Errorf("%s listas två gånger", name)
}
seen[name] = true
pcm, err := decodeSound(name)
if err != nil {
t.Errorf("%s: %v", name, err)
continue
}
if len(pcm) < sampleRate/20*4 { // minst 50 ms stereo 16-bit
t.Errorf("%s: för kort (%d byte)", name, len(pcm))
}
}
}
func TestMelodyForIsStableAndKnown(t *testing.T) {
known := map[string]bool{}
for _, m := range StationMelodies {
known[m] = true
}
a := &level.Level{ID: "005-ueno", Station: "ueno"}
if m := MelodyFor(a); !known[m] || m != MelodyFor(a) {
t.Fatalf("MelodyFor = %q", m)
}
if m := MelodyFor(&level.Level{ID: "x", Melody: "c"}); m != "melody-c" {
t.Fatalf("melody-fältet ska vinna: %q", m)
}
var nilAudio *Audio
nilAudio.Play(SoundWin, 1) // nil-säkert
nilAudio.Loop(SoundMusicMap, 1)
nilAudio.SetVolumes(1, 1, 1)
nilAudio.StopLoop()
}

View File

@@ -10,6 +10,7 @@ import (
"time"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/endless"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
@@ -52,6 +53,7 @@ type Game struct {
Endless *EndlessRun // pågående omgång i oändligt läge (nil = ingen)
savePath string // "" = spara inte
flagScale int // fönsterskala från flaggan (när inställningarna säger 0)
fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar)
scene Scene
next Scene
@@ -76,6 +78,7 @@ type Options struct {
NoSave bool // läs sparfilen men skriv aldrig
Ending bool // öppna slutscenen direkt (tester/skärmdumpar)
Endless bool // starta oändligt läge direkt (tester/skärmdumpar)
Scale int // fönsterskala från flaggan (när inställningarna säger 0)
Mode string // navigeringsläge från start: "list" eller "signs" (tomt = sparfilens)
}
@@ -121,6 +124,8 @@ func New(opt Options) (*Game, error) {
if opt.Mode != "" {
g.Mode = nav.ParseMode(opt.Mode)
}
g.flagScale = max(1, opt.Scale)
g.applySettings()
if !opt.NoSave {
g.savePath = path
}
@@ -278,10 +283,12 @@ func (g *Game) Save() {
// SetScene byter scen från och med nästa uppdatering. Bakgrundsprat hör
// till banan och tystnar när man lämnar den.
func (g *Game) SetScene(s Scene) {
if _, inLevel := s.(*LevelScene); !inLevel {
if _, isResult := s.(*ResultScene); !isResult {
g.Audio.StopLoop()
}
switch s.(type) {
case *LevelScene, *ResultScene: // banans sorl fortsätter
case *MapScene, *SettingsScene, *EndingScene, *WordsScene, *SkillsScene, *ItemsScene:
g.Audio.Loop(SoundMusicMap, 0.6) // menymusiken (fortsätter om den redan spelas)
default:
g.Audio.StopLoop()
}
g.next = s
}
@@ -305,6 +312,11 @@ func (g *Game) Update() error {
if g.next != nil {
g.scene, g.next = g.next, nil
}
if inpututil.IsKeyJustPressed(ebiten.KeyF11) { // fullskärm av/på överallt
g.State.Settings.Fullscreen = !g.State.Settings.Fullscreen
g.applySettings()
g.Save()
}
if g.script != nil {
if err := g.script.tick(g, g.frame); err != nil {
return err
@@ -349,6 +361,25 @@ func (g *Game) runOptions(id string, seed rng.Seed) sim.RunOptions {
}
}
// applySettings för ut inställningarna: volymer, språk, fönster.
func (g *Game) applySettings() {
st := g.State.Settings
g.Audio.SetVolumes(float64(st.Master)/10, float64(st.Effects)/10, float64(st.Music)/10)
if st.Lang != UILang {
if err := g.Assets.LoadLang(st.Lang); err != nil {
log.Printf("språk %s: %v", st.Lang, err)
}
}
scale := st.Scale
if scale <= 0 {
scale = g.flagScale
}
if scale > 0 {
ebiten.SetWindowSize(ScreenW*scale, ScreenH*scale)
}
ebiten.SetFullscreen(st.Fullscreen)
}
// toggleMode växlar navigeringsläge och sparar.
func (g *Game) toggleMode() {
if g.Mode == nav.List {

View File

@@ -126,6 +126,11 @@ func shopJustPressed() bool { return virtual.pressed["b"] || inpututil.IsKeyJust
// phoneJustPressed: P = kortet i telefonen av/på (meny).
func phoneJustPressed() bool { return virtual.pressed["p"] || inpututil.IsKeyJustPressed(ebiten.KeyP) }
// settingsJustPressed: S öppnar inställningarna från bankartan.
func settingsJustPressed() bool {
return virtual.pressed["s"] || inpututil.IsKeyJustPressed(ebiten.KeyS)
}
// endlessJustPressed: O startar oändligt läge (när storyn är klar).
func endlessJustPressed() bool {
return virtual.pressed["o"] || inpututil.IsKeyJustPressed(ebiten.KeyO)

View File

@@ -29,6 +29,7 @@ const (
// NewEndingScene startar hemresan.
func NewEndingScene(g *Game) *EndingScene {
g.Audio.Play(SoundPlane, 0.8)
return &EndingScene{lines: strings.Split(g.Assets.T("ending.credits"), "\n")}
}

View File

@@ -42,6 +42,7 @@ type LevelScene struct {
level *level.Level // aktuell delbana (byts när resan fortsätter på nästa station)
run *sim.Run
endless bool // målet är ett lottat mål i oändligt läge (inte storyn)
sfx sfxState
cam *sim.Camera
animT float64
hideMap bool // M döljer/visar minikartan
@@ -183,6 +184,7 @@ func (s *LevelScene) Update(g *Game) error {
return nil
}
s.updateEffects(g)
s.updateSfx(g)
if s.run.Mini != nil {
// Minispel: all input går dit; Esc avbryter minispelet, inte banan.
mx, my := miniMoveXY()

View File

@@ -106,6 +106,9 @@ func (m *MapScene) Update(g *Game) error {
g.Save()
case modeJustPressed():
g.toggleMode()
case settingsJustPressed():
g.SetScene(NewSettingsScene(m))
return nil
case endlessJustPressed() && g.Progress.StoryCleared:
s, err := g.startEndless(g.seed())
if err != nil {

View File

@@ -0,0 +1,117 @@
package game
import (
"fmt"
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/save"
)
// SettingsScene är inställningarna: volymer, språk, fönsterskala och
// fullskärm. Pilar väljer rad och ändrar, Enter växlar, Esc/S går tillbaka.
// Allt sparas direkt.
type SettingsScene struct {
back Scene
sel int
}
const settingsRows = 6
// NewSettingsScene öppnar inställningarna.
func NewSettingsScene(back Scene) *SettingsScene { return &SettingsScene{back: back} }
func (s *SettingsScene) Update(g *Game) error {
if backJustPressed() || settingsJustPressed() {
g.SetScene(s.back)
return nil
}
s.sel = (s.sel + menuMove() + settingsRows) % settingsRows
dx, _ := miniMoveXY()
toggle := confirmJustPressed()
if dx == 0 && !toggle {
return nil
}
st := g.State.Settings
step := func(v *int, lo, hi int) {
*v = max(lo, min(hi, *v+dx))
}
switch s.sel {
case 0:
step(&st.Master, 0, 10)
case 1:
step(&st.Effects, 0, 10)
case 2:
step(&st.Music, 0, 10)
case 3:
if st.Lang == "sv" {
st.Lang = "en"
} else {
st.Lang = "sv"
}
case 4:
step(&st.Scale, 0, 6)
case 5:
st.Fullscreen = !st.Fullscreen
}
g.applySettings()
g.Save()
g.Audio.Play(SoundConfirm, 0.6)
return nil
}
func (s *SettingsScene) Draw(g *Game, screen *ebiten.Image) {
a := g.Assets
st := g.State.Settings
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("settings.title"), 8, 5, colText)
scale := a.T("settings.scale_auto")
if st.Scale > 0 {
scale = fmt.Sprintf("%d× (%d×%d)", st.Scale, ScreenW*st.Scale, ScreenH*st.Scale)
}
fs := a.T("menu.off")
if st.Fullscreen {
fs = a.T("menu.on")
}
rows := [settingsRows][2]string{
{a.T("settings.master"), ""},
{a.T("settings.effects"), ""},
{a.T("settings.music"), ""},
{a.T("settings.lang"), a.T("lang." + st.Lang)},
{a.T("settings.scale"), scale},
{a.T("settings.fullscreen"), fs},
}
levels := [3]int{st.Master, st.Effects, st.Music}
y := 30
for i, r := range rows {
col := colText
if i == s.sel {
vector.DrawFilledRect(screen, 6, float32(y-2), ScreenW-12, LineH+1, colBar, false)
col = colKnown
}
a.Draw(screen, r[0], 10, y, col)
if i < 3 { // volymstapel i tio segment
for k := 0; k < 10; k++ {
c := color.RGBA{70, 80, 110, 255}
if k < levels[i] {
c = color.RGBA{120, 200, 120, 255}
}
vector.DrawFilledRect(screen, float32(130+k*9), float32(y+2), 7, 7, c, false)
}
a.Draw(screen, fmt.Sprintf("%d", levels[i]), 224, y, col)
} else {
a.Draw(screen, r[1], 130, y, col)
}
y += LineH + 1
}
y += 4
for _, l := range a.wrapLine(plainLine(a.T("settings.keys"), colDim), ScreenW-20) {
a.drawLines(screen, []line{l}, 10, y)
y += LineH
}
a.Draw(screen, a.T("settings.hint"), 8, ScreenH-12, colDim)
_ = save.DefaultSettings // (inställningarnas standardvärden ligger i save)
}

66
internal/game/sfx.go Normal file
View File

@@ -0,0 +1,66 @@
package game
import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
// sfxState är vad ljudlagret senast såg av omgången; skillnader mot nu ger
// effekter (spärr, kiosk, toalett, lärt ord, byte, personal, vinst/förlust).
type sfxState struct {
init bool
gatePasses int
served int
staffCalls int
deniedAt float64
noExitAt float64
learnedAt float64
transferAt float64
flashAt float64
facility bool
mini bool
outcome sim.Outcome
}
// updateSfx spelar effekter för det som hänt sedan förra bildrutan.
func (s *LevelScene) updateSfx(g *Game) {
r := s.run
x := &s.sfx
if x.init {
switch {
case r.GatePasses > x.gatePasses:
g.Audio.Play(SoundGateBeep, 0.7)
case r.DeniedAt >= 0 && r.DeniedAt != x.deniedAt, r.NoExitAt >= 0 && r.NoExitAt != x.noExitAt:
g.Audio.Play(SoundGateDeny, 0.7)
}
if r.Served > x.served {
if x.facility && !r.Facility.Active {
g.Audio.Play(SoundToilet, 0.6)
} else {
g.Audio.Play(SoundKiosk, 0.7)
}
}
if r.LearnedAt >= 0 && r.LearnedAt != x.learnedAt {
g.Audio.Play(SoundLearn, 0.8)
}
if r.TransferAt >= 0 && r.TransferAt != x.transferAt {
g.Audio.Play(SoundTransfer, 0.8)
}
if r.StaffCalls > x.staffCalls {
g.Audio.Play(SoundStaff, 0.7)
}
if r.Mini != nil && !x.mini {
g.Audio.Play(SoundConfirm, 0.5)
}
if r.FlashAt >= 0 && r.FlashAt != x.flashAt && r.FlashKey != sim.FlashTransfer && r.FlashKey != sim.FlashStaff {
g.Audio.Play(SoundCancel, 0.6)
}
if r.Outcome != x.outcome {
switch r.Outcome {
case sim.Won:
g.Audio.Play(SoundWin, 0.9)
case sim.Lost:
g.Audio.Play(SoundLose, 0.9)
}
}
}
*x = sfxState{init: true, gatePasses: r.GatePasses, served: r.Served, staffCalls: r.StaffCalls, deniedAt: r.DeniedAt, noExitAt: r.NoExitAt,
learnedAt: r.LearnedAt, transferAt: r.TransferAt, flashAt: r.FlashAt, facility: r.Facility.Active, mini: r.Mini != nil, outcome: r.Outcome}
}

View File

@@ -163,9 +163,13 @@ func (s *LevelScene) updateEffects(g *Game) {
dist := math.Hypot(float64(tr.platform.X*TileSize)-px, float64(tr.platform.Y*TileSize)-py) / TileSize
mask := noiseMask(r.Level.Noise)
switch phase {
case level.Approaching:
case level.Approaching: // ankomstjingeln: tåget är på väg
g.Audio.Play(SoundApproach, volumeAt(dist)*mask)
case level.Closing:
case level.Arriving: // ding-dong när det rullar in
g.Audio.Play(SoundAnnounce, volumeAt(dist)*mask)
case level.Closing: // stationens avgångsmelodi innan dörrarna stängs
g.Audio.Play(MelodyFor(r.Level), volumeAt(dist)*mask)
case level.Leaving: // dörrsignalen när det går
g.Audio.Play(SoundDoors, volumeAt(dist)*mask)
}
}

View File

@@ -268,6 +268,7 @@ type Level struct {
IndividualCards bool `json:"individualCards"` // alla i sällskapet har egna kort (och biljetter) som spärren kräver
SeatSelection bool `json:"seatSelection"` // biljettköpet fortsätter med sittplatsval efter sällskapets önskemål
OneWayGates bool `json:"oneWayGates"` // spärren släpper inte ut en igen förrän man åkt; personal vid spärren låser upp (tar tid)
Melody string `json:"melody"` // stationens avgångsmelodi: "a".."d" (tomt = vald efter id)
FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard)
Grid []string `json:"grid,omitempty"` // envåningsbana (kompatibilitet) – blir Floors[0]
Floors []Storey `json:"floors,omitempty"` // flervåningsbana; alla våningar lika stora
@@ -396,6 +397,9 @@ func (l *Level) Validate() error {
if l.Order < 0 {
return fmt.Errorf("level %s: negativ order", l.ID)
}
if l.Melody != "" && (len(l.Melody) != 1 || l.Melody[0] < 'a' || l.Melody[0] > 'd') {
return fmt.Errorf("level %s: melody måste vara a–d", l.ID)
}
if len(l.Floors) == 0 {
if len(l.Grid) == 0 {
return fmt.Errorf("level %s: tomt rutnät", l.ID)

View File

@@ -42,6 +42,32 @@ type State struct {
Current *RunRef `json:"current,omitempty"`
NavMode string `json:"navMode,omitempty"` // "list" (standard) eller "signs"
Endless []EndlessRecord `json:"endless,omitempty"` // oändligt läge: bästa omgångarna, högst poäng först
Settings *Settings `json:"settings,omitempty"`
}
// Settings är spelarens inställningar (M12).
type Settings struct {
Master int `json:"master"` // 0–10
Effects int `json:"effects"` // 0–10
Music int `json:"music"` // 0–10 (musik och sorl)
Lang string `json:"lang"` // "sv" eller "en"
Scale int `json:"scale"` // fönsterskala 1–6, 0 = flaggan -scale
Fullscreen bool `json:"fullscreen"`
}
// DefaultSettings är standardinställningarna.
func DefaultSettings() *Settings { return &Settings{Master: 8, Effects: 8, Music: 6, Lang: "sv"} }
// normalize ser till att inställningarna är rimliga (saknade = standard).
func (s *Settings) normalize() {
clamp := func(v *int) { *v = max(0, min(10, *v)) }
clamp(&s.Master)
clamp(&s.Effects)
clamp(&s.Music)
if s.Lang != "sv" && s.Lang != "en" {
s.Lang = "sv"
}
s.Scale = max(0, min(6, s.Scale))
}
// EndlessRecord är en avslutad omgång i oändligt läge.
@@ -77,7 +103,7 @@ func (s *State) BestEndless() *EndlessRecord {
// New är ett nytt spel.
func New() *State {
return &State{Version: Version, Know: lang.NewKnowledge(), Wallet: sim.NewWallet(), Profile: &sim.Profile{}, Progress: progress.New()}
return &State{Version: Version, Know: lang.NewKnowledge(), Wallet: sim.NewWallet(), Profile: &sim.Profile{}, Progress: progress.New(), Settings: DefaultSettings()}
}
// DefaultPath är sparfilens plats: ~/.config/metro-puzzle-jp/save.json på
@@ -116,6 +142,10 @@ func migrate(s *State) error {
}
// Version 0 (saknat fält) och 1 har samma form; här läggs framtida steg.
s.Version = Version
if s.Settings == nil {
s.Settings = DefaultSettings()
}
s.Settings.normalize()
if s.Know == nil {
s.Know = lang.NewKnowledge()
}

View File

@@ -92,3 +92,27 @@ func TestEndlessRecords(t *testing.T) {
t.Fatalf("listan: %+v", s.Endless)
}
}
func TestSettingsDefaultsAndNormalize(t *testing.T) {
s := New()
if s.Settings == nil || s.Settings.Master != 8 || s.Settings.Lang != "sv" {
t.Fatalf("standardinställningar: %+v", s.Settings)
}
path := filepath.Join(t.TempDir(), "save.json")
if err := os.WriteFile(path, []byte(`{"version":1,"settings":{"master":99,"music":-3,"lang":"xx","scale":12}}`), 0o644); err != nil {
t.Fatal(err)
}
st, _, err := Load(path)
if err != nil {
t.Fatal(err)
}
if st.Settings.Master != 10 || st.Settings.Music != 0 || st.Settings.Lang != "sv" || st.Settings.Scale != 6 {
t.Fatalf("normaliserade: %+v", st.Settings)
}
if err := os.WriteFile(path, []byte(`{"version":1}`), 0o644); err != nil {
t.Fatal(err)
}
if st, _, err = Load(path); err != nil || st.Settings == nil || st.Settings.Effects != 8 {
t.Fatalf("saknade inställningar ska bli standard: %+v %v", st.Settings, err)
}
}