new features
This commit is contained in:
138
README.md
138
README.md
@@ -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)
|
||||||
│
|
│
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
214
asset.audio.go
214
asset.audio.go
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
63
core.game.go
63
core.game.go
@@ -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
4
go.mod
@@ -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
10
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/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
208
scene.path.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
248
state.save.go
248
state.save.go
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user