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

138
README.md
View File

@@ -445,8 +445,15 @@ type SceneActor struct {
`OnLeave` runs on the transition out. `Actors` lists which registered `OnLeave` runs on the transition out. `Actors` lists which registered
characters are placed in the scene and where their feet start. characters are placed in the scene and where their feet start.
`Walkboxes` is reserved for pathfinding (currently a stub — characters `Walkboxes` constrain character movement. When non-empty, `Walk(...)`
walk in a straight line). `Triggers` is also a stub at this milestone. routes through a BFS over the polygon adjacency graph (polygons that
share an edge are neighbours), with the midpoint of each shared edge
used as a waypoint. Destinations outside every walkbox are clipped to
the nearest boundary. With no walkboxes the character walks in a
straight line — see [§6.7](#67-character) and [§15.4](#154-character-movement).
`Triggers` fire on the rising edge of their `When` condition. The
engine samples each trigger once per idle frame; see [§6.4](#64-trigger).
### 6.3 Hotspot ### 6.3 Hotspot
@@ -503,9 +510,17 @@ type Trigger struct {
} }
``` ```
Reserved for "fire when condition becomes true after scene enter" — the Triggers arm when the scene is entered. Every idle frame (when the
struct exists so domain code can declare them, but the engine doesn't script slot is free), the engine samples each trigger's `When`
sweep triggers yet. condition; on a **rising edge** (false → true) it queues `Do` into the
script slot. `Once: true` makes the trigger fire at most once per
arming. A trigger with `nil` `When` or `nil` `Do` is silently skipped.
Triggers are not sampled while a cutscene or dialog is running, so a
condition that becomes true mid-script is detected on the next idle
frame — the edge is preserved across the busy window. Save/Load resets
trigger state to "freshly armed", matching the behaviour of scene
re-entry.
### 6.5 Item ### 6.5 Item
@@ -575,8 +590,20 @@ When no real sprite is on disk, `drawCharacter` falls back to a stylised
placeholder: humanoid if `W < H`, quadruped otherwise. `SpeechColor` (if placeholder: humanoid if `W < H`, quadruped otherwise. `SpeechColor` (if
an `RGBA`) colours both the speech-bubble text and the placeholder body. an `RGBA`) colours both the speech-bubble text and the placeholder body.
Movement is driven by the `Walk` action and `Game.tickCharacters` `Animations` maps a clip name to an `AnimationClip`. The engine
(straight-line stepping at `Speed` pixels/sec, default 60). auto-selects clips by name: `"walk"` while the character has an active
path, `"idle"` otherwise. If only one of the two is registered, it
plays in both states. `FrameTime ≤ 0` or `Frames` empty disables the
clip; missing clips fall back to the whole-sprite (or placeholder) draw.
Each `Frames` entry is a source rectangle into the character's `Sprite`
image, blitted via `SubImage` and scaled to the character's `W×H`
footprint. `Loop: true` rewinds to frame 0 after the last frame;
`Loop: false` freezes on the final frame.
Movement is driven by the `Walk` action and `Game.tickCharacters`
stepping at `Speed` pixels/sec (default 60) along the waypoint list
computed by [walkbox routing](#62-scene).
### 6.8 Dialogue ### 6.8 Dialogue
@@ -1308,8 +1335,9 @@ func (la *loadedAssets) isPlaceholder(name string) bool
character placeholder (humanoid / quadruped) or the real sprite. character placeholder (humanoid / quadruped) or the real sprite.
Supported image formats: PNG and JPEG (registered via blank imports in Supported image formats: PNG and JPEG (registered via blank imports in
`asset.manager.go`). Fonts and audio share the same registry but are not `asset.manager.go`). Audio assets (`AssetAudio`) are decoded on demand
loaded by the library at this milestone. by `AudioPlayer` — see [§13](#13-audio). Fonts (`AssetFont`) share the
same registry but are not loaded by the library at this milestone.
--- ---
@@ -1326,11 +1354,27 @@ func (a *AudioPlayer) StopMusic()
func (a *AudioPlayer) PlaySound(name string) func (a *AudioPlayer) PlaySound(name string)
``` ```
Currently a **stub**: requests are logged via `logf` and the Backed by Ebiten's `audio` package. The shared `*audio.Context` is
"current music" string is tracked so duplicate `PlayMusic(same)` calls created lazily on the first `PlayMusic` / `PlaySound`, at a sample rate
no-op. The `PlayMusic` / `PlaySound` / `StopMusic` actions wire through of 48 kHz; existing audio contexts (e.g. created by a host process) are
unchanged, so when the real Ebiten audio backend lands the domain code detected via `audio.CurrentContext` and reused. The `Asset.Kind` must
keeps working. be `AssetAudio` for the player to consider it.
- **Music** streams from disk and loops infinitely. `PlayMusic(name)`
on the currently-playing track no-ops, so it's safe to call from a
scene's `OnEnter` on every visit.
- **Sound effects** are decoded once, cached as raw PCM, and replayed
via `NewPlayerFromBytes` for near-zero-latency triggering. Finished
players are pruned on the next `PlaySound` call.
Supported formats: **WAV** (`.wav`), **OGG Vorbis** (`.ogg`), and
**MP3** (`.mp3`), picked by file extension. Anything else fails to
decode and the asset is flagged so subsequent plays no-op.
Failure modes (missing file, unsupported codec, no audio device) all
degrade to a debug-log line; the game keeps running with no sound. The
`PlayMusic` / `PlaySound` / `StopMusic` actions route through the same
methods, so a domain authored against the original stub still works.
--- ---
@@ -1427,9 +1471,22 @@ Runs only after every widget had a chance. The flow:
### 15.4 Character movement ### 15.4 Character movement
`Game.tickCharacters` runs every frame, stepping moving characters towards `Game.tickCharacters` runs every frame. For each moving character it
their `target` at `def.Speed` pixels per second. The `Walk(name, to)` steps the head of its waypoint queue at `def.Speed` pixels per second
runner returns `StatusRunning` until `characterMoving(name)` is false. (default 60); on arrival, the waypoint is popped and the character
proceeds to the next one. The character becomes idle when the queue
empties.
The waypoint queue is built when `Walk(name, to)` runs — see
[§6.2](#62-scene) and [`scene.path.go`](#21-project-layout) for the
walkbox routing. The `Walk` runner returns `StatusRunning` until
`characterMoving(name)` reports false.
Each frame also advances the character's animation clock via
`tickAnimation`, which auto-selects `"walk"` (when `moving == true`)
or `"idle"` from `Character.Animations`. The renderer uses the active
clip's source rectangle to blit a sprite-sheet frame, or falls back to
the whole-sprite / placeholder draw when no clip applies.
--- ---
@@ -1490,14 +1547,38 @@ func (g *Game) Save(slot int) error
func (g *Game) Load(slot int) error func (g *Game) Load(slot int) error
``` ```
**Stub** at this milestone — both return `nil` without doing anything. Slots are written as JSON files under `g.SaveDir` (default `saves/`,
The intended scope when filled in: relative to the working directory), one file per slot named
`slot<N>.json`. The save captures the **mutable runtime state**:
- Persist `currentScene`, character positions, the entire `State` - `currentScene`, the active verb, and the active theme.
(flags, vars, visited, talked), inventory contents, and the active - Every character's position, target, and moving flag.
script PC. - The full `State`: flags, vars, visited and talked counters.
- The managers themselves (Items, Scenes, …) are **not** persisted; they - The inventory item list plus the currently selected slot.
are reconstructed by `domain.Build()` on every launch.
Static registrations (items, scenes, characters, dialogues, scripts,
assets, verbs, widgets, themes) are **not** persisted — they are
reconstructed by `domain.Build()` on every launch. References in the
save are validated against the current managers before any runtime
state is touched, so a corrupt or schema-drift save returns an error
without mutating the live `*Game`.
What is **not** saved:
- The in-flight script `Runner` or any active dialog. `Load` cancels
both, so saves are effectively taken at an idle/ready boundary.
- The animation playback state. After load, characters are placed at
the saved position; the next idle frame re-derives the clip.
- Walkbox path queues. If a character is still `moving`, `tickCharacters`
rebuilds a one-step straight-line path from `pos` to `target` so the
walk finishes.
`Var` values are JSON-marshaled as-is. Numbers round-trip through
`float64`, strings stay strings, and any value that survives
`encoding/json` round-tripping survives the save.
The save format is versioned (`saveVersion`); loading a file from a
future version returns an error rather than silently truncating fields.
--- ---
@@ -1588,13 +1669,14 @@ pncdsl/ # module git.teletypegames.org/games/pncdsl
├── asset.def.go # Asset, AssetKind ├── asset.def.go # Asset, AssetKind
├── asset.manager.go # AssetManager alias + lazy loader ├── asset.manager.go # AssetManager alias + lazy loader
├── asset.audio.go # AudioPlayer (stub) ├── asset.audio.go # AudioPlayer (WAV/OGG/MP3, looping music)
├── asset.text.go # drawText / wrapText helpers ├── asset.text.go # drawText / wrapText helpers
├── scene.def.go # Scene, SceneActor ├── scene.def.go # Scene, SceneActor
├── scene.manager.go # SceneManager alias ├── scene.manager.go # SceneManager alias
├── scene.hotspot.go # Hotspot, CursorKind ├── scene.hotspot.go # Hotspot, CursorKind
├── scene.trigger.go # Trigger (stub) ├── scene.trigger.go # Trigger + rising-edge engine sweep
├── scene.path.go # walkbox routing (BFS over polygon adjacency)
├── scene.transition.go # fade-to-black overlay (internal) ├── scene.transition.go # fade-to-black overlay (internal)
├── scene.camera.go # Camera (stub identity transform) ├── scene.camera.go # Camera (stub identity transform)
@@ -1604,7 +1686,7 @@ pncdsl/ # module git.teletypegames.org/games/pncdsl
├── actor.def.go # Character ├── actor.def.go # Character
├── actor.manager.go # CharacterManager alias ├── actor.manager.go # CharacterManager alias
├── actor.animation.go # AnimationClip (stub) ├── actor.animation.go # AnimationClip + tickAnimation
├── dialog.def.go # Dialogue, DialogueNode, DialogueChoice ├── dialog.def.go # Dialogue, DialogueNode, DialogueChoice
├── dialog.manager.go # DialogueManager alias ├── dialog.manager.go # DialogueManager alias
@@ -1615,7 +1697,7 @@ pncdsl/ # module git.teletypegames.org/games/pncdsl
├── action.manager.go # ScriptManager alias ├── action.manager.go # ScriptManager alias
├── state.def.go # State (flags, vars, visited, talked) ├── state.def.go # State (flags, vars, visited, talked)
├── state.save.go # Save/Load (stub) ├── state.save.go # Save/Load JSON persistence
├── input.def.go # Input (consume-on-use) ├── input.def.go # Input (consume-on-use)

View File

@@ -1,10 +1,93 @@
package pncdsl package pncdsl
// AnimationClip is a placeholder for sprite-sheet animation data. Not used import "image"
// for rendering yet — characters draw as flat colored rectangles in this
// milestone — but the field exists so domain code can reference it. // AnimationClip describes one named sprite-sheet animation. Frames are
// source rectangles into the character's Sprite asset; the renderer
// projects them onto the character's screen-space W×H footprint.
//
// FrameTime is the time (seconds) each frame is shown; Loop=true rewinds
// to the first frame after the last, Loop=false freezes on the final
// frame. A clip with zero frames or zero FrameTime is treated as static
// and the renderer falls back to the whole-sprite draw path.
type AnimationClip struct { type AnimationClip struct {
Frames []Rectangle // source rects on the sprite sheet Frames []Rectangle
FrameTime float64 FrameTime float64
Loop bool Loop bool
}
// Reserved clip names the engine picks automatically:
//
// "idle" — selected when the character is not moving.
// "walk" — selected while the character has an active path.
//
// A character with neither name falls back to the static whole-sprite
// (or placeholder) draw.
const (
clipIdle = "idle"
clipWalk = "walk"
)
// tickAnimation advances a character's current animation clip and
// switches between idle/walk based on the moving flag.
func (g *Game) tickAnimation(c *runtimeChar, dt float64) {
desired := clipIdle
if c.moving {
desired = clipWalk
}
clip, has := c.def.Animations[desired]
if !has {
// Fall back to the other clip if the desired one is missing —
// a character with only "idle" defined keeps idling during walks.
other := clipIdle
if desired == clipIdle {
other = clipWalk
}
if cl, ok := c.def.Animations[other]; ok {
desired = other
clip = cl
has = true
}
}
if !has || len(clip.Frames) == 0 || clip.FrameTime <= 0 {
c.currentClip = ""
c.clipElapsed = 0
c.clipFrame = 0
return
}
if c.currentClip != desired {
c.currentClip = desired
c.clipElapsed = 0
c.clipFrame = 0
}
c.clipElapsed += dt
for c.clipElapsed >= clip.FrameTime {
c.clipElapsed -= clip.FrameTime
c.clipFrame++
if c.clipFrame >= len(clip.Frames) {
if clip.Loop {
c.clipFrame = 0
} else {
c.clipFrame = len(clip.Frames) - 1
c.clipElapsed = 0
break
}
}
}
}
// currentFrameRect returns the source rectangle of the character's
// current animation frame, in image.Rectangle form so it can feed
// ebiten.Image.SubImage directly. ok=false when the character should be
// drawn without animation (no clip / empty clip / out-of-range frame).
func (c *runtimeChar) currentFrameRect() (image.Rectangle, bool) {
if c.currentClip == "" {
return image.Rectangle{}, false
}
clip, ok := c.def.Animations[c.currentClip]
if !ok || c.clipFrame < 0 || c.clipFrame >= len(clip.Frames) {
return image.Rectangle{}, false
}
r := clip.Frames[c.clipFrame]
return image.Rect(int(r.X), int(r.Y), int(r.X+r.W), int(r.Y+r.H)), true
} }

View File

@@ -1,30 +1,220 @@
package pncdsl package pncdsl
// AudioPlayer is a stub for music/sfx playback. The action constructors import (
// (PlayMusic/PlaySound/StopMusic) call through here; on this milestone we "bytes"
// just log the request so the game runs without an audio device. "fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/hajimehoshi/ebiten/v2/audio"
"github.com/hajimehoshi/ebiten/v2/audio/mp3"
"github.com/hajimehoshi/ebiten/v2/audio/vorbis"
"github.com/hajimehoshi/ebiten/v2/audio/wav"
)
// audioSampleRate is the playback rate of the shared audio context. WAV,
// OGG and MP3 streams are resampled to this rate on decode.
const audioSampleRate = 48000
// AudioPlayer plays music and one-shot sound effects through Ebiten's
// audio subsystem. Music is streamed and looped infinitely; sound effects
// are decoded once, cached as raw PCM, and replayed via
// NewPlayerFromBytes for low-latency triggering.
//
// AudioPlayer is fail-soft by design: missing asset files, decode errors
// and a missing audio device all degrade to no-ops with a debug log
// message — the game keeps running. The action constructors
// (PlayMusic/PlaySound/StopMusic) call through here unchanged.
type AudioPlayer struct { type AudioPlayer struct {
currentMusic string am *AssetManager
ctx *audio.Context // lazy — created on first real play
musicName string
musicPlayer *audio.Player
sfxCache map[string][]byte // resampled PCM, keyed by Asset.Name
sfxActive []*audio.Player // in-flight one-shots; pruned each PlaySound
failed map[string]bool // assets that errored once — don't retry every frame
} }
func NewAudioPlayer() *AudioPlayer { return &AudioPlayer{} } func NewAudioPlayer() *AudioPlayer {
return &AudioPlayer{
sfxCache: make(map[string][]byte),
failed: make(map[string]bool),
}
}
// attach wires the audio player to the asset registry. Called from
// NewGame after AssetManager is constructed.
func (a *AudioPlayer) attach(am *AssetManager) { a.am = am }
func (a *AudioPlayer) ensureContext() *audio.Context {
if a.ctx != nil {
return a.ctx
}
// audio.NewContext panics if called twice, so check for an existing
// singleton first — this lets a test or a host app pre-create one.
if c := audio.CurrentContext(); c != nil {
a.ctx = c
return c
}
a.ctx = audio.NewContext(audioSampleRate)
return a.ctx
}
// resolveAsset returns the registered Asset for name, or false if it's
// unknown, the wrong kind, or already on the failure list.
func (a *AudioPlayer) resolveAsset(name string) (Asset, bool) {
if name == "" || a.am == nil || a.failed[name] {
return Asset{}, false
}
asset, ok := a.am.Get(name)
if !ok || asset.Kind != AssetAudio || asset.Path == "" {
return Asset{}, false
}
return asset, true
}
func (a *AudioPlayer) PlayMusic(name string) { func (a *AudioPlayer) PlayMusic(name string) {
if a.currentMusic == name { if a.musicName == name && a.musicPlayer != nil && a.musicPlayer.IsPlaying() {
return return
} }
a.currentMusic = name a.stopMusic()
logf("audio.PlayMusic %q", name) a.musicName = name
asset, ok := a.resolveAsset(name)
if !ok {
logf("audio.PlayMusic %q: no asset", name)
return
}
ctx := a.ensureContext()
stream, length, err := decodeAudioStream(asset.Path, audioSampleRate)
if err != nil {
logf("audio.PlayMusic %q: %v", name, err)
a.failed[name] = true
return
}
loop := audio.NewInfiniteLoop(stream, length)
p, err := audio.NewPlayer(ctx, loop)
if err != nil {
logf("audio.PlayMusic %q: player: %v", name, err)
a.failed[name] = true
return
}
a.musicPlayer = p
p.Play()
} }
func (a *AudioPlayer) StopMusic() { func (a *AudioPlayer) StopMusic() {
if a.currentMusic == "" { if a.musicName == "" && a.musicPlayer == nil {
return return
} }
logf("audio.StopMusic (was %q)", a.currentMusic) logf("audio.StopMusic (was %q)", a.musicName)
a.currentMusic = "" a.stopMusic()
a.musicName = ""
}
func (a *AudioPlayer) stopMusic() {
if a.musicPlayer == nil {
return
}
_ = a.musicPlayer.Close()
a.musicPlayer = nil
} }
func (a *AudioPlayer) PlaySound(name string) { func (a *AudioPlayer) PlaySound(name string) {
logf("audio.PlaySound %q", name) asset, ok := a.resolveAsset(name)
if !ok {
logf("audio.PlaySound %q: no asset", name)
return
}
ctx := a.ensureContext()
pcm, ok := a.sfxCache[name]
if !ok {
data, err := decodeAudioBytes(asset.Path, audioSampleRate)
if err != nil {
logf("audio.PlaySound %q: %v", name, err)
a.failed[name] = true
return
}
a.sfxCache[name] = data
pcm = data
}
p := audio.NewPlayerFromBytes(ctx, pcm)
p.Play()
a.sfxActive = append(a.sfxActive, p)
a.pruneSfx()
}
func (a *AudioPlayer) pruneSfx() {
n := 0
for _, p := range a.sfxActive {
if p.IsPlaying() {
a.sfxActive[n] = p
n++
} else {
_ = p.Close()
}
}
for i := n; i < len(a.sfxActive); i++ {
a.sfxActive[i] = nil
}
a.sfxActive = a.sfxActive[:n]
}
// audioStream is the common interface satisfied by wav/vorbis/mp3 Stream
// types — io.ReadSeeker plus a known total length for loop bookkeeping.
type audioStream interface {
io.ReadSeeker
Length() int64
}
func decodeAudioStream(path string, sampleRate int) (audioStream, int64, error) {
f, err := os.Open(path)
if err != nil {
return nil, 0, err
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, 0, err
}
r := bytes.NewReader(data)
switch strings.ToLower(filepath.Ext(path)) {
case ".wav":
s, err := wav.DecodeWithSampleRate(sampleRate, r)
if err != nil {
return nil, 0, err
}
return s, s.Length(), nil
case ".ogg":
s, err := vorbis.DecodeWithSampleRate(sampleRate, r)
if err != nil {
return nil, 0, err
}
return s, s.Length(), nil
case ".mp3":
s, err := mp3.DecodeWithSampleRate(sampleRate, r)
if err != nil {
return nil, 0, err
}
return s, s.Length(), nil
default:
return nil, 0, fmt.Errorf("unsupported audio extension %q", filepath.Ext(path))
}
}
// decodeAudioBytes decodes a file to raw int16 stereo PCM at the given
// sample rate. Used for SFX so NewPlayerFromBytes can replay them
// without re-decoding on every trigger.
func decodeAudioBytes(path string, sampleRate int) ([]byte, error) {
s, _, err := decodeAudioStream(path, sampleRate)
if err != nil {
return nil, err
}
return io.ReadAll(s)
} }

View File

@@ -22,6 +22,7 @@ func Run(g *Game) error {
g.currentScene = g.startID g.currentScene = g.startID
g.State.NoteVisit(g.startID) g.State.NoteVisit(g.startID)
g.placeActors(g.startID) g.placeActors(g.startID)
g.resetTriggers()
if s.Music != "" { if s.Music != "" {
g.Audio.PlayMusic(s.Music) g.Audio.PlayMusic(s.Music)
} }

View File

@@ -52,6 +52,12 @@ func (e *engine) Update() error {
// Clear per-frame transient state that widgets / scene-resolution write. // Clear per-frame transient state that widgets / scene-resolution write.
g.SetHoverLabel("") g.SetHoverLabel("")
// Scene triggers run before widgets/input — a firing trigger queues
// an action that consumes the next frame's script slot, so this
// frame's UI work still happens, but the player's clicks won't
// race a cutscene that's about to start.
g.tickTriggers()
// Top-down input: the widget drawn last (= registered last) gets the // Top-down input: the widget drawn last (= registered last) gets the
// click first, then the next-to-last, etc. A widget signals "I took it" // click first, then the next-to-last, etc. A widget signals "I took it"
// via g.Input.ConsumeLeft / ConsumeRight. // via g.Input.ConsumeLeft / ConsumeRight.
@@ -222,6 +228,21 @@ func drawCharacter(dst *ebiten.Image, g *Game, c *runtimeChar) {
} }
if c.def.Sprite != "" && !g.loaded.isPlaceholder(c.def.Sprite) { if c.def.Sprite != "" && !g.loaded.isPlaceholder(c.def.Sprite) {
img := g.loaded.image(g.AssetManager, c.def.Sprite) img := g.loaded.image(g.AssetManager, c.def.Sprite)
// Active animation clip → blit only the current frame's sub-rect
// from the sprite sheet, scaled into the character's W×H box.
if r, ok := c.currentFrameRect(); ok {
ib := img.Bounds()
clamped := r.Intersect(ib)
fw, fh := clamped.Dx(), clamped.Dy()
if fw > 0 && fh > 0 {
sub := img.SubImage(clamped).(*ebiten.Image)
op := &ebiten.DrawImageOptions{}
op.GeoM.Scale(w/float64(fw), h/float64(fh))
op.GeoM.Translate(c.pos.X-w/2, c.pos.Y-h)
dst.DrawImage(sub, op)
return
}
}
sw, sh := img.Bounds().Dx(), img.Bounds().Dy() sw, sh := img.Bounds().Dx(), img.Bounds().Dy()
if sw > 0 && sh > 0 { if sw > 0 && sh > 0 {
op := &ebiten.DrawImageOptions{} op := &ebiten.DrawImageOptions{}

View File

@@ -55,6 +55,14 @@ type Game struct {
// in-game message log for ChatLog widgets; ring buffer behavior. // in-game message log for ChatLog widgets; ring buffer behavior.
messages []LogMessage messages []LogMessage
MaxLogLines int // 0 = unlimited (memory grows); set per-game. MaxLogLines int // 0 = unlimited (memory grows); set per-game.
// SaveDir overrides the on-disk directory used by Save/Load.
// Empty falls back to "saves" relative to the working directory.
SaveDir string
// triggerStates tracks per-trigger rising-edge / fired bookkeeping
// for the current scene. Cleared on scene change and on Load.
triggerStates map[string]*triggerState
} }
// LogKind classifies a chat-log entry — UI widgets style them differently. // LogKind classifies a chat-log entry — UI widgets style them differently.
@@ -117,6 +125,7 @@ func NewGame(title string, w, h int) *Game {
transition: &transition{}, transition: &transition{},
selectedVerb: "look", selectedVerb: "look",
} }
g.Audio.attach(g.AssetManager)
for _, v := range defaultVerbs() { for _, v := range defaultVerbs() {
g.VerbManager.Register(v) g.VerbManager.Register(v)
} }
@@ -248,10 +257,21 @@ func (g *Game) Validate() error {
// ----- runtime: characters & scenes ------------------------------------- // ----- runtime: characters & scenes -------------------------------------
type runtimeChar struct { type runtimeChar struct {
def Character def Character
pos Point pos Point
// path is the queue of remaining waypoints, head-first. Empty when
// idle. The last entry equals target.
path []Point
// target is the final destination — kept separately so saves and the
// post-Load resume in tickCharacters can rebuild a missing path.
target Point target Point
moving bool moving bool
// animation playback state — see actor.animation.go for the rules
// that drive currentClip selection.
currentClip string
clipElapsed float64
clipFrame int
} }
func (g *Game) makeCtx() *Ctx { func (g *Game) makeCtx() *Ctx {
@@ -279,6 +299,7 @@ func (g *Game) changeScene(name string) {
g.currentScene = name g.currentScene = name
g.State.NoteVisit(name) g.State.NoteVisit(name)
g.placeActors(name) g.placeActors(name)
g.resetTriggers()
s := g.SceneManager.MustGet(name) s := g.SceneManager.MustGet(name)
if s.Music != "" { if s.Music != "" {
g.Audio.PlayMusic(s.Music) g.Audio.PlayMusic(s.Music)
@@ -315,7 +336,18 @@ func (g *Game) walkCharacter(name string, to Point) {
if !ok { if !ok {
return return
} }
c.target = to var boxes []Polygon
if g.currentScene != "" {
s := g.SceneManager.MustGet(g.currentScene)
boxes = s.Walkboxes
}
path := pathfind(c.pos, to, boxes)
if len(path) == 0 {
c.moving = false
return
}
c.path = path
c.target = path[len(path)-1]
c.moving = true c.moving = true
} }
@@ -326,20 +358,35 @@ func (g *Game) characterMoving(name string) bool {
func (g *Game) tickCharacters(dt float64) { func (g *Game) tickCharacters(dt float64) {
for _, c := range g.chars { for _, c := range g.chars {
g.tickAnimation(c, dt)
if !c.moving { if !c.moving {
continue continue
} }
dx := c.target.X - c.pos.X // Resume after Load: moving with no live path means we lost the
dy := c.target.Y - c.pos.Y // route but still know the final destination; rebuild a trivial
d := c.pos.Dist(c.target) // straight-line path so the character finishes its walk.
if len(c.path) == 0 {
if c.pos == c.target {
c.moving = false
continue
}
c.path = []Point{c.target}
}
next := c.path[0]
dx := next.X - c.pos.X
dy := next.Y - c.pos.Y
d := c.pos.Dist(next)
speed := c.def.Speed speed := c.def.Speed
if speed <= 0 { if speed <= 0 {
speed = 60 speed = 60
} }
step := speed * dt step := speed * dt
if d <= step { if d <= step {
c.pos = c.target c.pos = next
c.moving = false c.path = c.path[1:]
if len(c.path) == 0 {
c.moving = false
}
continue continue
} }
c.pos.X += dx / d * step c.pos.X += dx / d * step

4
go.mod
View File

@@ -7,8 +7,12 @@ require github.com/hajimehoshi/ebiten/v2 v2.9.9
require ( require (
github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect
github.com/ebitengine/hideconsole v1.0.0 // indirect github.com/ebitengine/hideconsole v1.0.0 // indirect
github.com/ebitengine/oto/v3 v3.4.0 // indirect
github.com/ebitengine/purego v0.9.0 // indirect github.com/ebitengine/purego v0.9.0 // indirect
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
github.com/jezek/xgb v1.1.1 // indirect github.com/jezek/xgb v1.1.1 // indirect
github.com/jfreymuth/oggvorbis v1.0.5 // indirect
github.com/jfreymuth/vorbis v1.0.2 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect golang.org/x/sys v0.36.0 // indirect
) )

10
go.sum
View File

@@ -2,15 +2,25 @@ github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 h1:+kz5iTT3L7u
github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1/go.mod h1:lKJoeixeJwnFmYsBny4vvCJGVFc3aYDalhuDsfZzWHI= github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1/go.mod h1:lKJoeixeJwnFmYsBny4vvCJGVFc3aYDalhuDsfZzWHI=
github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE=
github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A=
github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/bQ=
github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI=
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k= github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/hajimehoshi/ebiten/v2 v2.9.9 h1:JdDag6Ndj12iD4lxQGG8kbsrh7ssj4Sbzth6r929H/M= github.com/hajimehoshi/ebiten/v2 v2.9.9 h1:JdDag6Ndj12iD4lxQGG8kbsrh7ssj4Sbzth6r929H/M=
github.com/hajimehoshi/ebiten/v2 v2.9.9/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM= github.com/hajimehoshi/ebiten/v2 v2.9.9/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ=
github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

208
scene.path.go Normal file
View File

@@ -0,0 +1,208 @@
package pncdsl
import "math"
// pathEps is the tolerance in world units (≈ pixels) used when comparing
// polygon vertices for adjacency. Authors typically snap walkbox corners
// to integer or half-pixel coordinates, so 0.5 is generous.
const pathEps = 0.5
// pathfind returns a sequence of waypoints from start to end that stays
// within the union of the given walkboxes. The last entry is always the
// effective destination (which may differ from end if end was outside
// every walkbox — in that case the closest boundary point is used).
//
// When boxes is empty, walkbox routing is off and pathfind degenerates
// to a straight line ([]Point{end}). The caller (walkCharacter) treats
// this as the legacy single-target behaviour.
func pathfind(start, end Point, boxes []Polygon) []Point {
if len(boxes) == 0 {
return []Point{end}
}
startIdx := containingPolygon(start, boxes)
if startIdx < 0 {
start, startIdx = nearestPolygonPoint(start, boxes)
if startIdx < 0 {
return []Point{end}
}
}
endIdx := containingPolygon(end, boxes)
if endIdx < 0 {
end, endIdx = nearestPolygonPoint(end, boxes)
if endIdx < 0 {
return []Point{end}
}
}
if startIdx == endIdx {
return []Point{end}
}
adj := buildAdjacency(boxes)
seq := bfsPolygons(startIdx, endIdx, adj, len(boxes))
if seq == nil {
// Disconnected components — give up and walk straight; the
// caller's tickCharacters will at least move toward end.
return []Point{end}
}
out := make([]Point, 0, len(seq))
for i := 0; i < len(seq)-1; i++ {
mid, ok := sharedEdgeMidpoint(boxes[seq[i]], boxes[seq[i+1]])
if !ok {
continue
}
out = append(out, mid)
}
out = append(out, end)
return out
}
func containingPolygon(p Point, boxes []Polygon) int {
for i, b := range boxes {
if b.Contains(p) {
return i
}
}
return -1
}
// nearestPolygonPoint projects p onto the boundary of the nearest
// walkbox and returns the projection plus its polygon index.
func nearestPolygonPoint(p Point, boxes []Polygon) (Point, int) {
bestIdx := -1
bestPt := p
bestD := math.Inf(1)
for i, b := range boxes {
pt, d := closestOnPolygonBoundary(p, b)
if d < bestD {
bestD = d
bestPt = pt
bestIdx = i
}
}
return bestPt, bestIdx
}
func closestOnPolygonBoundary(p Point, b Polygon) (Point, float64) {
if len(b.Points) < 2 {
return p, math.Inf(1)
}
bestPt := b.Points[0]
bestD := p.Dist(bestPt)
n := len(b.Points)
for i := 0; i < n; i++ {
a := b.Points[i]
c := b.Points[(i+1)%n]
q := closestOnSegment(p, a, c)
d := p.Dist(q)
if d < bestD {
bestD = d
bestPt = q
}
}
return bestPt, bestD
}
func closestOnSegment(p, a, b Point) Point {
dx, dy := b.X-a.X, b.Y-a.Y
denom := dx*dx + dy*dy
if denom == 0 {
return a
}
t := ((p.X-a.X)*dx + (p.Y-a.Y)*dy) / denom
if t < 0 {
t = 0
} else if t > 1 {
t = 1
}
return Point{X: a.X + t*dx, Y: a.Y + t*dy}
}
// buildAdjacency maps each polygon index to the indices of polygons it
// shares an edge with. O(n²·k²) where k is avg edge count — fine for
// the handful of walkboxes per scene that adventure games use.
func buildAdjacency(boxes []Polygon) map[int][]int {
adj := make(map[int][]int, len(boxes))
for i := range boxes {
for j := i + 1; j < len(boxes); j++ {
if _, ok := sharedEdgeMidpoint(boxes[i], boxes[j]); ok {
adj[i] = append(adj[i], j)
adj[j] = append(adj[j], i)
}
}
}
return adj
}
// sharedEdgeMidpoint returns the midpoint of an edge shared between two
// polygons, if any. Two edges are considered shared when their endpoints
// match (within pathEps) in either orientation — the typical case where
// neighbouring walkboxes are authored with a common boundary.
func sharedEdgeMidpoint(a, b Polygon) (Point, bool) {
na, nb := len(a.Points), len(b.Points)
if na < 2 || nb < 2 {
return Point{}, false
}
for i := 0; i < na; i++ {
ai, ai1 := a.Points[i], a.Points[(i+1)%na]
for j := 0; j < nb; j++ {
bj, bj1 := b.Points[j], b.Points[(j+1)%nb]
if (pointsEq(ai, bj1) && pointsEq(ai1, bj)) ||
(pointsEq(ai, bj) && pointsEq(ai1, bj1)) {
return Point{X: (ai.X + ai1.X) / 2, Y: (ai.Y + ai1.Y) / 2}, true
}
}
}
return Point{}, false
}
func pointsEq(a, b Point) bool {
dx := a.X - b.X
dy := a.Y - b.Y
if dx < 0 {
dx = -dx
}
if dy < 0 {
dy = -dy
}
return dx <= pathEps && dy <= pathEps
}
// bfsPolygons returns the shortest polygon-index sequence from src to
// dst over the adjacency graph, inclusive on both ends. nil if no
// path exists.
func bfsPolygons(src, dst int, adj map[int][]int, n int) []int {
if src == dst {
return []int{src}
}
prev := make([]int, n)
for i := range prev {
prev[i] = -1
}
prev[src] = src
queue := []int{src}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
if cur == dst {
break
}
for _, nxt := range adj[cur] {
if prev[nxt] != -1 {
continue
}
prev[nxt] = cur
queue = append(queue, nxt)
}
}
if prev[dst] == -1 {
return nil
}
// Walk back from dst.
out := []int{dst}
for cur := dst; cur != src; cur = prev[cur] {
out = append([]int{prev[cur]}, out...)
}
return out
}

View File

@@ -1,10 +1,82 @@
package pncdsl package pncdsl
// Trigger fires Do when its When condition first becomes true after the // Trigger fires Do when its When condition first becomes true after the
// trigger arms (on scene enter). One-shot per scene visit by default. // trigger arms (on scene enter). Rising-edge semantics: the Do action is
// queued only on a false→true transition, so a condition that becomes
// true and stays true fires exactly once per arming. Once=true makes the
// trigger fire at most once per arming regardless of further toggles.
type Trigger struct { type Trigger struct {
Name string Name string
When Condition When Condition
Do Action Do Action
Once bool Once bool
} }
// triggerState is the per-trigger bookkeeping for the active scene.
// Cleared on scene enter and on Load.
type triggerState struct {
lastTrue bool
fired bool
}
// resetTriggers re-arms every trigger on the current scene — called on
// scene change and after Load. Triggers are not pre-armed: the first
// frame samples their condition fresh, so a Flag that's already true
// will fire the trigger on the rising-edge of the FIRST sample (we treat
// "no prior sample" as false).
func (g *Game) resetTriggers() {
g.triggerStates = make(map[string]*triggerState)
if g.currentScene == "" {
return
}
s, ok := g.SceneManager.Get(g.currentScene)
if !ok {
return
}
for _, t := range s.Triggers {
if t.Name == "" {
continue
}
g.triggerStates[t.Name] = &triggerState{}
}
}
// tickTriggers evaluates every armed trigger on the current scene. The
// engine calls this only when the script slot is idle (so we never
// preempt a running cutscene). The first matching rising-edge fires the
// trigger's Do action and consumes the script slot for this frame.
func (g *Game) tickTriggers() {
if g.scriptRunner != nil || g.currentScene == "" {
return
}
s, ok := g.SceneManager.Get(g.currentScene)
if !ok {
return
}
ctx := g.makeCtx()
for _, t := range s.Triggers {
if t.Name == "" || t.When == nil {
continue
}
st := g.triggerStates[t.Name]
if st == nil {
st = &triggerState{}
g.triggerStates[t.Name] = st
}
if st.fired && t.Once {
continue
}
now := t.When.Eval(ctx)
// Rising edge: was false, now true.
if now && !st.lastTrue {
st.fired = true
st.lastTrue = true
if t.Do != nil {
g.queueAction(t.Do, "trigger "+t.Name)
return // one trigger per frame keeps ordering deterministic
}
continue
}
st.lastTrue = now
}
}

View File

@@ -1,6 +1,246 @@
package pncdsl package pncdsl
// Save/Load are stubbed out in this milestone — the runtime state machine import (
// is in place, but JSON serialization will land alongside the polish pass. "encoding/json"
func (g *Game) Save(slot int) error { _ = slot; return nil } "fmt"
func (g *Game) Load(slot int) error { _ = slot; return nil } "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
}