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>
187 lines
5.5 KiB
Go
187 lines
5.5 KiB
Go
// 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"
|
||
"sort"
|
||
"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"`
|
||
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.
|
||
type EndlessRecord struct {
|
||
Score int `json:"score"`
|
||
Goals int `json:"goals"` // antal klarade mål
|
||
Seed [2]uint64 `json:"seed"`
|
||
When time.Time `json:"when"`
|
||
}
|
||
|
||
// MaxEndlessRecords är hur många omgångar som sparas i listan.
|
||
const MaxEndlessRecords = 5
|
||
|
||
// AddEndless lägger in en omgång i listan (sorterad på poäng, högst först)
|
||
// och rapporterar om den är ny bästa.
|
||
func (s *State) AddEndless(r EndlessRecord) (best bool) {
|
||
best = len(s.Endless) == 0 || r.Score > s.Endless[0].Score
|
||
s.Endless = append(s.Endless, r)
|
||
sort.SliceStable(s.Endless, func(i, j int) bool { return s.Endless[i].Score > s.Endless[j].Score })
|
||
if len(s.Endless) > MaxEndlessRecords {
|
||
s.Endless = s.Endless[:MaxEndlessRecords]
|
||
}
|
||
return best
|
||
}
|
||
|
||
// BestEndless är bästa omgången (nil om ingen).
|
||
func (s *State) BestEndless() *EndlessRecord {
|
||
if len(s.Endless) == 0 {
|
||
return nil
|
||
}
|
||
return &s.Endless[0]
|
||
}
|
||
|
||
// New är ett nytt spel.
|
||
func New() *State {
|
||
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å
|
||
// 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.Settings == nil {
|
||
s.Settings = DefaultSettings()
|
||
}
|
||
s.Settings.normalize()
|
||
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)
|
||
}
|