diff --git a/README.md b/README.md index 0dcc93c..b6454cc 100644 --- a/README.md +++ b/README.md @@ -445,8 +445,15 @@ type SceneActor struct { `OnLeave` runs on the transition out. `Actors` lists which registered characters are placed in the scene and where their feet start. -`Walkboxes` is reserved for pathfinding (currently a stub — characters -walk in a straight line). `Triggers` is also a stub at this milestone. +`Walkboxes` constrain character movement. When non-empty, `Walk(...)` +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 @@ -503,9 +510,17 @@ type Trigger struct { } ``` -Reserved for "fire when condition becomes true after scene enter" — the -struct exists so domain code can declare them, but the engine doesn't -sweep triggers yet. +Triggers arm when the scene is entered. Every idle frame (when the +script slot is free), the engine samples each trigger's `When` +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 @@ -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 an `RGBA`) colours both the speech-bubble text and the placeholder body. -Movement is driven by the `Walk` action and `Game.tickCharacters` -(straight-line stepping at `Speed` pixels/sec, default 60). +`Animations` maps a clip name to an `AnimationClip`. The engine +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 @@ -1308,8 +1335,9 @@ func (la *loadedAssets) isPlaceholder(name string) bool character placeholder (humanoid / quadruped) or the real sprite. Supported image formats: PNG and JPEG (registered via blank imports in -`asset.manager.go`). Fonts and audio share the same registry but are not -loaded by the library at this milestone. +`asset.manager.go`). Audio assets (`AssetAudio`) are decoded on demand +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) ``` -Currently a **stub**: requests are logged via `logf` and the -"current music" string is tracked so duplicate `PlayMusic(same)` calls -no-op. The `PlayMusic` / `PlaySound` / `StopMusic` actions wire through -unchanged, so when the real Ebiten audio backend lands the domain code -keeps working. +Backed by Ebiten's `audio` package. The shared `*audio.Context` is +created lazily on the first `PlayMusic` / `PlaySound`, at a sample rate +of 48 kHz; existing audio contexts (e.g. created by a host process) are +detected via `audio.CurrentContext` and reused. The `Asset.Kind` must +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 -`Game.tickCharacters` runs every frame, stepping moving characters towards -their `target` at `def.Speed` pixels per second. The `Walk(name, to)` -runner returns `StatusRunning` until `characterMoving(name)` is false. +`Game.tickCharacters` runs every frame. For each moving character it +steps the head of its waypoint queue at `def.Speed` pixels per second +(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 ``` -**Stub** at this milestone — both return `nil` without doing anything. -The intended scope when filled in: +Slots are written as JSON files under `g.SaveDir` (default `saves/`, +relative to the working directory), one file per slot named +`slot.json`. The save captures the **mutable runtime state**: -- Persist `currentScene`, character positions, the entire `State` - (flags, vars, visited, talked), inventory contents, and the active - script PC. -- The managers themselves (Items, Scenes, …) are **not** persisted; they - are reconstructed by `domain.Build()` on every launch. +- `currentScene`, the active verb, and the active theme. +- Every character's position, target, and moving flag. +- The full `State`: flags, vars, visited and talked counters. +- The inventory item list plus the currently selected slot. + +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.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 │ ├── scene.def.go # Scene, SceneActor ├── scene.manager.go # SceneManager alias ├── 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.camera.go # Camera (stub identity transform) │ @@ -1604,7 +1686,7 @@ pncdsl/ # module git.teletypegames.org/games/pncdsl │ ├── actor.def.go # Character ├── actor.manager.go # CharacterManager alias -├── actor.animation.go # AnimationClip (stub) +├── actor.animation.go # AnimationClip + tickAnimation │ ├── dialog.def.go # Dialogue, DialogueNode, DialogueChoice ├── dialog.manager.go # DialogueManager alias @@ -1615,7 +1697,7 @@ pncdsl/ # module git.teletypegames.org/games/pncdsl ├── action.manager.go # ScriptManager alias │ ├── 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) │ diff --git a/actor.animation.go b/actor.animation.go index 1defbcf..3397f46 100644 --- a/actor.animation.go +++ b/actor.animation.go @@ -1,10 +1,93 @@ package pncdsl -// AnimationClip is a placeholder for sprite-sheet animation data. Not used -// for rendering yet — characters draw as flat colored rectangles in this -// milestone — but the field exists so domain code can reference it. +import "image" + +// 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 { - Frames []Rectangle // source rects on the sprite sheet - FrameTime float64 - Loop bool + Frames []Rectangle + FrameTime float64 + 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 } diff --git a/asset.audio.go b/asset.audio.go index f957d9e..eedfbed 100644 --- a/asset.audio.go +++ b/asset.audio.go @@ -1,30 +1,220 @@ package pncdsl -// AudioPlayer is a stub for music/sfx playback. The action constructors -// (PlayMusic/PlaySound/StopMusic) call through here; on this milestone we -// just log the request so the game runs without an audio device. +import ( + "bytes" + "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 { - 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) { - if a.currentMusic == name { + if a.musicName == name && a.musicPlayer != nil && a.musicPlayer.IsPlaying() { return } - a.currentMusic = name - logf("audio.PlayMusic %q", name) + a.stopMusic() + 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() { - if a.currentMusic == "" { + if a.musicName == "" && a.musicPlayer == nil { return } - logf("audio.StopMusic (was %q)", a.currentMusic) - a.currentMusic = "" + logf("audio.StopMusic (was %q)", a.musicName) + 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) { - 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) } diff --git a/core.dsl.go b/core.dsl.go index 7ad4b12..582d7fe 100644 --- a/core.dsl.go +++ b/core.dsl.go @@ -22,6 +22,7 @@ func Run(g *Game) error { g.currentScene = g.startID g.State.NoteVisit(g.startID) g.placeActors(g.startID) + g.resetTriggers() if s.Music != "" { g.Audio.PlayMusic(s.Music) } diff --git a/core.engine.go b/core.engine.go index 16af78d..fe0d310 100644 --- a/core.engine.go +++ b/core.engine.go @@ -52,6 +52,12 @@ func (e *engine) Update() error { // Clear per-frame transient state that widgets / scene-resolution write. 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 // click first, then the next-to-last, etc. A widget signals "I took it" // 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) { 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() if sw > 0 && sh > 0 { op := &ebiten.DrawImageOptions{} diff --git a/core.game.go b/core.game.go index fce7cfd..bc9df78 100644 --- a/core.game.go +++ b/core.game.go @@ -55,6 +55,14 @@ type Game struct { // in-game message log for ChatLog widgets; ring buffer behavior. messages []LogMessage 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. @@ -117,6 +125,7 @@ func NewGame(title string, w, h int) *Game { transition: &transition{}, selectedVerb: "look", } + g.Audio.attach(g.AssetManager) for _, v := range defaultVerbs() { g.VerbManager.Register(v) } @@ -248,10 +257,21 @@ func (g *Game) Validate() error { // ----- runtime: characters & scenes ------------------------------------- type runtimeChar struct { - def Character - pos Point + def Character + 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 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 { @@ -279,6 +299,7 @@ func (g *Game) changeScene(name string) { g.currentScene = name g.State.NoteVisit(name) g.placeActors(name) + g.resetTriggers() s := g.SceneManager.MustGet(name) if s.Music != "" { g.Audio.PlayMusic(s.Music) @@ -315,7 +336,18 @@ func (g *Game) walkCharacter(name string, to Point) { if !ok { 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 } @@ -326,20 +358,35 @@ func (g *Game) characterMoving(name string) bool { func (g *Game) tickCharacters(dt float64) { for _, c := range g.chars { + g.tickAnimation(c, dt) if !c.moving { continue } - dx := c.target.X - c.pos.X - dy := c.target.Y - c.pos.Y - d := c.pos.Dist(c.target) + // Resume after Load: moving with no live path means we lost the + // route but still know the final destination; rebuild a trivial + // 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 if speed <= 0 { speed = 60 } step := speed * dt if d <= step { - c.pos = c.target - c.moving = false + c.pos = next + c.path = c.path[1:] + if len(c.path) == 0 { + c.moving = false + } continue } c.pos.X += dx / d * step diff --git a/go.mod b/go.mod index ebf9e76..a6e551d 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,12 @@ require github.com/hajimehoshi/ebiten/v2 v2.9.9 require ( github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // 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/hajimehoshi/go-mp3 v0.3.4 // 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/sys v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 03c113f..fa3639c 100644 --- a/go.sum +++ b/go.sum @@ -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/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= 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/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/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/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/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 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/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/scene.path.go b/scene.path.go new file mode 100644 index 0000000..b83441d --- /dev/null +++ b/scene.path.go @@ -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 +} diff --git a/scene.trigger.go b/scene.trigger.go index ecb5a15..9681008 100644 --- a/scene.trigger.go +++ b/scene.trigger.go @@ -1,10 +1,82 @@ package pncdsl // 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 { Name string When Condition Do Action 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 + } +} diff --git a/state.save.go b/state.save.go index 8b027b6..eac5b46 100644 --- a/state.save.go +++ b/state.save.go @@ -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.json (or +// SaveDir/slot.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.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 +}