new features

This commit is contained in:
2026-05-25 22:27:53 +02:00
parent c4c99260bd
commit f75ada75ba
11 changed files with 1017 additions and 59 deletions

View File

@@ -1,6 +1,246 @@
package pncdsl
// Save/Load are stubbed out in this milestone — the runtime state machine
// is in place, but JSON serialization will land alongside the polish pass.
func (g *Game) Save(slot int) error { _ = slot; return nil }
func (g *Game) Load(slot int) error { _ = slot; return nil }
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// saveVersion is bumped when the on-disk schema changes. Load returns
// ErrSaveVersion if the file is from a future version.
const saveVersion = 1
// saveFile is the JSON-serialisable snapshot of a game session. Only the
// runtime mutable state lands here — managers, themes and assets are
// reconstructed by the domain's Build() on every launch.
type saveFile struct {
Version int `json:"version"`
Title string `json:"title"`
CurrentScene string `json:"current_scene"`
SelectedVerb string `json:"selected_verb"`
ActiveTheme string `json:"active_theme"`
Characters map[string]savedChar `json:"characters"`
Inventory savedInventory `json:"inventory"`
State savedState `json:"state"`
}
type savedChar struct {
Pos Point `json:"pos"`
Target Point `json:"target"`
Moving bool `json:"moving"`
}
type savedInventory struct {
Items []string `json:"items"`
Selected string `json:"selected"`
}
type savedState struct {
Flags map[string]bool `json:"flags"`
Vars map[string]any `json:"vars"`
Visited map[string]int `json:"visited"`
Talked map[string]int `json:"talked"`
}
// SaveDir overrides the on-disk directory used by Save/Load. When empty,
// the library falls back to "saves" relative to the working directory.
func (g *Game) saveDir() string {
if g.SaveDir != "" {
return g.SaveDir
}
return "saves"
}
func (g *Game) slotPath(slot int) string {
return filepath.Join(g.saveDir(), fmt.Sprintf("slot%d.json", slot))
}
// Save serialises the current session into saves/slot<N>.json (or
// SaveDir/slot<N>.json if set). The in-flight script and dialog are
// dropped — saves capture state at idle/ready boundaries.
func (g *Game) Save(slot int) error {
if err := os.MkdirAll(g.saveDir(), 0o755); err != nil {
return fmt.Errorf("pncdsl: save mkdir: %w", err)
}
sf := g.buildSave()
data, err := json.MarshalIndent(sf, "", " ")
if err != nil {
return fmt.Errorf("pncdsl: save marshal: %w", err)
}
if err := os.WriteFile(g.slotPath(slot), data, 0o644); err != nil {
return fmt.Errorf("pncdsl: save write: %w", err)
}
return nil
}
// Load replaces the runtime state with the contents of saves/slot<N>.json.
// Managers (items, scenes, characters, …) are left intact — Build() owns
// those. References in the save (scene name, theme, character names) are
// validated against the current managers before any state is touched.
func (g *Game) Load(slot int) error {
data, err := os.ReadFile(g.slotPath(slot))
if err != nil {
return fmt.Errorf("pncdsl: load read: %w", err)
}
var sf saveFile
if err := json.Unmarshal(data, &sf); err != nil {
return fmt.Errorf("pncdsl: load unmarshal: %w", err)
}
if sf.Version > saveVersion {
return fmt.Errorf("pncdsl: save version %d unsupported (max %d)", sf.Version, saveVersion)
}
return g.applySave(&sf)
}
func (g *Game) buildSave() saveFile {
sf := saveFile{
Version: saveVersion,
Title: g.Title,
CurrentScene: g.currentScene,
SelectedVerb: g.selectedVerb,
ActiveTheme: g.activeTheme,
Characters: make(map[string]savedChar, len(g.chars)),
Inventory: savedInventory{
Items: g.Inventory.Items(),
Selected: g.Inventory.Selected(),
},
State: savedState{
Flags: copyBoolMap(g.State.flags),
Vars: copyAnyMap(g.State.vars),
Visited: copyIntMap(g.State.visited),
Talked: copyIntMap(g.State.talked),
},
}
for name, c := range g.chars {
sf.Characters[name] = savedChar{
Pos: c.pos,
Target: c.target,
Moving: c.moving,
}
}
return sf
}
func (g *Game) applySave(sf *saveFile) error {
// Validate references first so a corrupt save can't leave Game in a
// half-restored state.
if sf.CurrentScene != "" && !g.SceneManager.Has(sf.CurrentScene) {
return fmt.Errorf("%w: %q (in save)", ErrUnknownScene, sf.CurrentScene)
}
if sf.ActiveTheme != "" && !g.ThemeManager.Has(sf.ActiveTheme) {
return fmt.Errorf("pncdsl: unknown theme %q in save", sf.ActiveTheme)
}
g.scriptRunner = nil
g.scriptCtx = nil
g.dialog = nil
g.activeDialog = ""
g.ClearSpeech()
g.endCard = ""
g.flash = ""
g.flashTimer = 0
g.currentScene = sf.CurrentScene
if sf.SelectedVerb != "" {
g.selectedVerb = sf.SelectedVerb
}
if sf.ActiveTheme != "" {
g.activeTheme = sf.ActiveTheme
}
st := NewState()
for k, v := range sf.State.Flags {
if v {
st.flags[k] = true
}
}
for k, v := range sf.State.Vars {
st.vars[k] = v
}
for k, v := range sf.State.Visited {
st.visited[k] = v
}
for k, v := range sf.State.Talked {
st.talked[k] = v
}
g.State = st
inv := NewInventory()
for _, it := range sf.Inventory.Items {
inv.items = append(inv.items, it)
}
if sf.Inventory.Selected != "" && inv.Has(sf.Inventory.Selected) {
inv.selected = sf.Inventory.Selected
}
g.Inventory = inv
g.chars = make(map[string]*runtimeChar)
for name, ch := range sf.Characters {
if !g.CharacterManager.Has(name) {
continue
}
def := g.CharacterManager.MustGet(name)
g.chars[name] = &runtimeChar{
def: def,
pos: ch.Pos,
target: ch.Target,
moving: ch.Moving,
}
}
// Backfill any actors required by the current scene that the save
// missed (e.g. character added after the save was taken).
if g.currentScene != "" {
s := g.SceneManager.MustGet(g.currentScene)
for _, a := range s.Actors {
if _, ok := g.chars[a.CharacterName]; ok {
continue
}
if !g.CharacterManager.Has(a.CharacterName) {
continue
}
def := g.CharacterManager.MustGet(a.CharacterName)
g.chars[a.CharacterName] = &runtimeChar{def: def, pos: a.At}
}
// Resume the scene's music; PlayMusic no-ops on the same track.
if s.Music != "" {
g.Audio.PlayMusic(s.Music)
}
}
g.resetTriggers()
return nil
}
func copyBoolMap(m map[string]bool) map[string]bool {
if len(m) == 0 {
return nil
}
out := make(map[string]bool, len(m))
for k, v := range m {
out[k] = v
}
return out
}
func copyIntMap(m map[string]int) map[string]int {
if len(m) == 0 {
return nil
}
out := make(map[string]int, len(m))
for k, v := range m {
out[k] = v
}
return out
}
func copyAnyMap(m map[string]any) map[string]any {
if len(m) == 0 {
return nil
}
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = v
}
return out
}