@waica/engine 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,8 @@ export interface ArchetypeArt {
16
16
  file: string;
17
17
  /** Registry URI resolved by the archetype at runtime. */
18
18
  uri: string;
19
+ /** What kind of asset this is — a sprite sheet or texture, or a sound file. */
20
+ kind: 'image' | 'sound';
19
21
  }
20
22
  /** The conventional contract exported by every archetype package. */
21
23
  export interface ArchetypeManifest {
@@ -40,6 +42,15 @@ export interface ArchetypeManifest {
40
42
  bundle: ArchetypeBundle;
41
43
  /** Directional animation contract, for genres where characters face around. */
42
44
  animation?: DirectionalAnimation;
45
+ /**
46
+ * The archetype's own looping music bed, as a "waica:" registry uri —
47
+ * absent for archetypes that ship no music (G8). A host starts it itself
48
+ * (`game.audio.play(manifest.music, { channel: 'music', loop: true, scope:
49
+ * 'session' })`) when present; `installArchetype(bundle)` runs before
50
+ * `new Game(...)` exists, so the bundle has no `game` to call, and this
51
+ * field is what tells the host what to ask for instead.
52
+ */
53
+ music?: string;
43
54
  }
44
55
  /** Browser manifest enriched with URLs produced by an asset-aware bundler. */
45
56
  export interface BrowserArchetypeManifest extends ArchetypeManifest {
@@ -0,0 +1,141 @@
1
+ import type { AudioBackend } from './backend.js';
2
+ import type { AudioChannelState, AudioPlayOptions, LiveSoundInfo, SoundHandle } from './types.js';
3
+ export interface AudioSubsystemOptions {
4
+ /** The game's canvas — the audio unlock listens for a pointerdown on it (CA-6). */
5
+ canvas: HTMLCanvasElement;
6
+ /** Replaces the real WebAudio implementation (ADR 0013); defaults to it. */
7
+ backend?: AudioBackend;
8
+ /**
9
+ * Resolves a uri the same way the scene loader resolves every prefab's
10
+ * string prop (`resolveProps` in scene.ts), but for direct `play()`/
11
+ * `preload()` calls — a project role or the host, not a spawned prefab.
12
+ * Looked up on every call rather than captured once, since `Game` wires
13
+ * this to its registered scene catalog, which can be (re)registered at
14
+ * any time and — unlike `Game.registry` — survives `unloadScene()`.
15
+ * Defaults to identity, so an unresolvable or already-resolved uri (a
16
+ * pre-resolving caller) passes through unchanged either way.
17
+ */
18
+ resolveAsset?: (uri: string) => string;
19
+ }
20
+ /**
21
+ * The engine's audio mixer (`game.audio`). Talks to WebAudio only through
22
+ * the AudioBackend seam (ADR 0013), so the whole contract is assertable in
23
+ * `happy-dom` against an injected fake. A sound dies with its scene unless
24
+ * it says `{ scope: 'session' }` — the opposite default from GameUi, on
25
+ * purpose (ADR 0012); `unloadScene()` is Game's hook for that (CA-7).
26
+ * `updatePlacements()` is Game's per-frame hook for positional audio (CA-8).
27
+ */
28
+ export declare class AudioSubsystem {
29
+ private readonly backend;
30
+ private readonly canvas;
31
+ private readonly resolveAsset;
32
+ private readonly channelsMap;
33
+ private readonly live;
34
+ /**
35
+ * Loops requested before unlock (defect: a music bed started at boot,
36
+ * before any gesture, used to be discarded forever). Each is already in
37
+ * `live` and already has a real handle; only its attach() to the backend
38
+ * is deferred until the unlock latch flips. A non-looping call before
39
+ * unlock is still discarded exactly as before — see `play()`.
40
+ */
41
+ private readonly pendingUnlock;
42
+ private readonly resourceStates;
43
+ private readonly resourcePromises;
44
+ private masterVolume;
45
+ private active;
46
+ private silenced;
47
+ private unlocked;
48
+ /** Whether the backend was last told to be resumed (true) or suspended (false). */
49
+ private outputLive;
50
+ constructor(options: AudioSubsystemOptions);
51
+ /**
52
+ * Starts a sound. Before the first unlock (CA-6), a one-shot registers
53
+ * nothing and touches no backend at all — the returned handle just
54
+ * reports `playing: false` forever. A *looping* call is different: it is
55
+ * remembered (a deliberate, singular bed, unlike a burst of one-shots
56
+ * that would all fire at once and sound broken) and started once the
57
+ * unlock happens, on this same handle — nothing reaches the backend
58
+ * until then either way. `opts.at` (CA-8, positional audio) sets up
59
+ * placement tracking; Game calls updatePlacements() once per frame to
60
+ * actually compute pan/attenuation and forward them to the backend.
61
+ */
62
+ play(uri: string, opts?: AudioPlayOptions): SoundHandle;
63
+ /** Every channel name, factory and runtime-created, in creation order. */
64
+ channels(): string[];
65
+ /** A channel's current volume/mute. Reading an unnamed channel reports defaults without creating it. */
66
+ channelState(name: string): AudioChannelState;
67
+ setChannelVolume(name: string, volume: number): void;
68
+ /** Silences (or restores) every sound on the channel without stopping them. */
69
+ setChannelMuted(name: string, muted: boolean): void;
70
+ get master(): number;
71
+ /** Scales every channel's effective output. */
72
+ set master(value: number);
73
+ /**
74
+ * Fetches and decodes every uri ahead of time. Resolves even if one (or
75
+ * all) of them fail to load — each failure still only warns once, the
76
+ * same as a failing play() (CA-9).
77
+ */
78
+ preload(uris: string[]): Promise<void>;
79
+ /**
80
+ * Every sound currently registered with the subsystem — not "every sound
81
+ * currently audible". A sound enters this set in play() and leaves only
82
+ * when the backend reports onEnded or drop() removes it, so it also
83
+ * reports: a loop retained before the autoplay unlock (no backend touched
84
+ * yet, CA-6); a sound whose load() has not resolved yet; and even one
85
+ * whose fetch/decode is about to fail, which drops out a tick later once
86
+ * attach() catches up and calls drop(). Sorted by uri then channel.
87
+ */
88
+ liveSounds(): LiveSoundInfo[];
89
+ /** Called by Game from runFrame: the editor's pause suspends output without touching sounds in flight (CA-4). */
90
+ setActive(active: boolean): void;
91
+ /**
92
+ * Called by Game when a Runtime Bridge registers/unregisters (CA-5).
93
+ * Registering also counts as an unlock: the Runtime Bridge drives the
94
+ * game programmatically (injectAction/injectClick), never through a real
95
+ * trusted keydown/pointerdown, so CA-6's gesture-gate would otherwise
96
+ * discard every sound for the whole life of an automated run. Silencing
97
+ * keeps the same run producing no audio output regardless.
98
+ */
99
+ setSilenced(silenced: boolean): void;
100
+ /**
101
+ * Stops every scene-scoped sound; a sound started with `{ scope: 'session'
102
+ * }` keeps playing, untouched (CA-7, ADR 0012). Called by Game.unloadScene().
103
+ */
104
+ unloadScene(): void;
105
+ /**
106
+ * Recomputes pan and distance attenuation for every live sound started
107
+ * with `at` (CA-8) — called by Game once per frame. `listener` and every
108
+ * placement's source position are logical coordinates, so attenuation
109
+ * reflects real game distance; `toRenderSpace` (`game.renderPoint`)
110
+ * converts both to render space for panning, so an isometric source that
111
+ * reads to the right on screen pans right regardless of its logical
112
+ * distance. A sound with no placement (a flat sound) is never touched.
113
+ */
114
+ updatePlacements(listener: {
115
+ x: number;
116
+ y: number;
117
+ }, toRenderSpace: (x: number, y: number) => {
118
+ x: number;
119
+ y: number;
120
+ }): void;
121
+ /** Stops every live sound — including session-scoped ones — and closes the backend (CA-10). */
122
+ dispose(): void;
123
+ private ensureChannel;
124
+ private attach;
125
+ private startPlayback;
126
+ private drop;
127
+ private ensureLoading;
128
+ private stopSound;
129
+ private handleFor;
130
+ private handleUnlockEvent;
131
+ /**
132
+ * Flips the one-way unlock latch, detaches the DOM listeners, and releases
133
+ * every loop that was retained while locked (the boot-music-bed fix) —
134
+ * without syncing output. Callers decide when to sync so a silencing
135
+ * change arriving in the same call (setSilenced) composes into a single,
136
+ * correct resume/suspend decision instead of a spurious resume-then-
137
+ * suspend pair.
138
+ */
139
+ private markUnlocked;
140
+ private syncOutput;
141
+ }
@@ -0,0 +1,398 @@
1
+ import { Entity } from '../entity.js';
2
+ import { attenuationForDistance, panForOffset } from './spatial.js';
3
+ import { WebAudioBackend } from './web-audio-backend.js';
4
+ function resolvePlacement(at) {
5
+ if (!at)
6
+ return null;
7
+ if (at instanceof Entity)
8
+ return { kind: 'entity', entity: at };
9
+ return { kind: 'point', x: at.x, y: at.y };
10
+ }
11
+ const FACTORY_CHANNELS = ['music', 'sfx'];
12
+ /**
13
+ * The engine's audio mixer (`game.audio`). Talks to WebAudio only through
14
+ * the AudioBackend seam (ADR 0013), so the whole contract is assertable in
15
+ * `happy-dom` against an injected fake. A sound dies with its scene unless
16
+ * it says `{ scope: 'session' }` — the opposite default from GameUi, on
17
+ * purpose (ADR 0012); `unloadScene()` is Game's hook for that (CA-7).
18
+ * `updatePlacements()` is Game's per-frame hook for positional audio (CA-8).
19
+ */
20
+ export class AudioSubsystem {
21
+ backend;
22
+ canvas;
23
+ resolveAsset;
24
+ channelsMap = new Map();
25
+ live = new Set();
26
+ /**
27
+ * Loops requested before unlock (defect: a music bed started at boot,
28
+ * before any gesture, used to be discarded forever). Each is already in
29
+ * `live` and already has a real handle; only its attach() to the backend
30
+ * is deferred until the unlock latch flips. A non-looping call before
31
+ * unlock is still discarded exactly as before — see `play()`.
32
+ */
33
+ pendingUnlock = new Set();
34
+ resourceStates = new Map();
35
+ resourcePromises = new Map();
36
+ masterVolume = 1;
37
+ active = true;
38
+ silenced = false;
39
+ unlocked = false;
40
+ /** Whether the backend was last told to be resumed (true) or suspended (false). */
41
+ outputLive = false;
42
+ constructor(options) {
43
+ this.backend = options.backend ?? new WebAudioBackend();
44
+ this.canvas = options.canvas;
45
+ this.resolveAsset = options.resolveAsset ?? ((uri) => uri);
46
+ for (const name of FACTORY_CHANNELS)
47
+ this.channelsMap.set(name, { volume: 1, muted: false });
48
+ window.addEventListener('keydown', this.handleUnlockEvent);
49
+ this.canvas.addEventListener('pointerdown', this.handleUnlockEvent);
50
+ }
51
+ /**
52
+ * Starts a sound. Before the first unlock (CA-6), a one-shot registers
53
+ * nothing and touches no backend at all — the returned handle just
54
+ * reports `playing: false` forever. A *looping* call is different: it is
55
+ * remembered (a deliberate, singular bed, unlike a burst of one-shots
56
+ * that would all fire at once and sound broken) and started once the
57
+ * unlock happens, on this same handle — nothing reaches the backend
58
+ * until then either way. `opts.at` (CA-8, positional audio) sets up
59
+ * placement tracking; Game calls updatePlacements() once per frame to
60
+ * actually compute pan/attenuation and forward them to the backend.
61
+ */
62
+ play(uri, opts = {}) {
63
+ const resolvedUri = this.resolveAsset(uri);
64
+ const channel = opts.channel ?? 'sfx';
65
+ const volume = opts.volume ?? 1;
66
+ const loop = opts.loop ?? false;
67
+ const scope = opts.scope === 'session' ? 'session' : 'scene';
68
+ const placement = resolvePlacement(opts.at);
69
+ this.ensureChannel(channel);
70
+ if (!this.unlocked && !loop)
71
+ return inertHandle(volume);
72
+ const sound = {
73
+ uri: resolvedUri,
74
+ channel,
75
+ scope,
76
+ loop,
77
+ volume,
78
+ attenuation: 1,
79
+ pan: 0,
80
+ placement,
81
+ ended: false,
82
+ backendHandle: null,
83
+ fading: false,
84
+ };
85
+ this.live.add(sound);
86
+ if (this.unlocked)
87
+ this.attach(resolvedUri, sound);
88
+ else
89
+ this.pendingUnlock.add(sound);
90
+ return this.handleFor(sound);
91
+ }
92
+ /** Every channel name, factory and runtime-created, in creation order. */
93
+ channels() {
94
+ return [...this.channelsMap.keys()];
95
+ }
96
+ /** A channel's current volume/mute. Reading an unnamed channel reports defaults without creating it. */
97
+ channelState(name) {
98
+ const state = this.channelsMap.get(name);
99
+ return state ? { ...state } : { volume: 1, muted: false };
100
+ }
101
+ setChannelVolume(name, volume) {
102
+ this.ensureChannel(name).volume = volume;
103
+ this.backend.setChannelVolume(name, volume);
104
+ }
105
+ /** Silences (or restores) every sound on the channel without stopping them. */
106
+ setChannelMuted(name, muted) {
107
+ this.ensureChannel(name).muted = muted;
108
+ this.backend.setChannelMuted(name, muted);
109
+ }
110
+ get master() {
111
+ return this.masterVolume;
112
+ }
113
+ /** Scales every channel's effective output. */
114
+ set master(value) {
115
+ this.masterVolume = value;
116
+ this.backend.setMasterVolume(value);
117
+ }
118
+ /**
119
+ * Fetches and decodes every uri ahead of time. Resolves even if one (or
120
+ * all) of them fail to load — each failure still only warns once, the
121
+ * same as a failing play() (CA-9).
122
+ */
123
+ async preload(uris) {
124
+ await Promise.all(uris.map((uri) => this.ensureLoading(this.resolveAsset(uri))));
125
+ }
126
+ /**
127
+ * Every sound currently registered with the subsystem — not "every sound
128
+ * currently audible". A sound enters this set in play() and leaves only
129
+ * when the backend reports onEnded or drop() removes it, so it also
130
+ * reports: a loop retained before the autoplay unlock (no backend touched
131
+ * yet, CA-6); a sound whose load() has not resolved yet; and even one
132
+ * whose fetch/decode is about to fail, which drops out a tick later once
133
+ * attach() catches up and calls drop(). Sorted by uri then channel.
134
+ */
135
+ liveSounds() {
136
+ return [...this.live]
137
+ .map(({ uri, channel, scope }) => ({ uri, channel, scope }))
138
+ .sort((a, b) => (a.uri === b.uri ? a.channel.localeCompare(b.channel) : a.uri.localeCompare(b.uri)));
139
+ }
140
+ /** Called by Game from runFrame: the editor's pause suspends output without touching sounds in flight (CA-4). */
141
+ setActive(active) {
142
+ if (this.active === active)
143
+ return;
144
+ this.active = active;
145
+ this.syncOutput();
146
+ }
147
+ /**
148
+ * Called by Game when a Runtime Bridge registers/unregisters (CA-5).
149
+ * Registering also counts as an unlock: the Runtime Bridge drives the
150
+ * game programmatically (injectAction/injectClick), never through a real
151
+ * trusted keydown/pointerdown, so CA-6's gesture-gate would otherwise
152
+ * discard every sound for the whole life of an automated run. Silencing
153
+ * keeps the same run producing no audio output regardless.
154
+ */
155
+ setSilenced(silenced) {
156
+ if (silenced)
157
+ this.markUnlocked();
158
+ if (this.silenced === silenced)
159
+ return;
160
+ this.silenced = silenced;
161
+ this.syncOutput();
162
+ }
163
+ /**
164
+ * Stops every scene-scoped sound; a sound started with `{ scope: 'session'
165
+ * }` keeps playing, untouched (CA-7, ADR 0012). Called by Game.unloadScene().
166
+ */
167
+ unloadScene() {
168
+ for (const sound of [...this.live]) {
169
+ if (sound.scope === 'session')
170
+ continue;
171
+ this.stopSound(sound, undefined);
172
+ }
173
+ }
174
+ /**
175
+ * Recomputes pan and distance attenuation for every live sound started
176
+ * with `at` (CA-8) — called by Game once per frame. `listener` and every
177
+ * placement's source position are logical coordinates, so attenuation
178
+ * reflects real game distance; `toRenderSpace` (`game.renderPoint`)
179
+ * converts both to render space for panning, so an isometric source that
180
+ * reads to the right on screen pans right regardless of its logical
181
+ * distance. A sound with no placement (a flat sound) is never touched.
182
+ */
183
+ updatePlacements(listener, toRenderSpace) {
184
+ if (this.live.size === 0)
185
+ return;
186
+ let listenerRender = null;
187
+ for (const sound of this.live) {
188
+ if (sound.fading)
189
+ continue;
190
+ const placement = sound.placement;
191
+ if (!placement)
192
+ continue;
193
+ const source = placement.kind === 'entity'
194
+ ? { x: placement.entity.position.x, y: placement.entity.position.y }
195
+ : placement;
196
+ sound.attenuation = attenuationForDistance(Math.hypot(source.x - listener.x, source.y - listener.y));
197
+ listenerRender ??= toRenderSpace(listener.x, listener.y);
198
+ const sourceRender = toRenderSpace(source.x, source.y);
199
+ sound.pan = panForOffset(sourceRender.x - listenerRender.x);
200
+ sound.backendHandle?.setVolume(sound.volume * sound.attenuation);
201
+ sound.backendHandle?.setPan(sound.pan);
202
+ }
203
+ }
204
+ /** Stops every live sound — including session-scoped ones — and closes the backend (CA-10). */
205
+ dispose() {
206
+ window.removeEventListener('keydown', this.handleUnlockEvent);
207
+ this.canvas.removeEventListener('pointerdown', this.handleUnlockEvent);
208
+ for (const sound of [...this.live]) {
209
+ sound.ended = true;
210
+ sound.backendHandle?.stop();
211
+ }
212
+ this.live.clear();
213
+ this.pendingUnlock.clear();
214
+ this.backend.close();
215
+ }
216
+ ensureChannel(name) {
217
+ let state = this.channelsMap.get(name);
218
+ if (!state) {
219
+ state = { volume: 1, muted: false };
220
+ this.channelsMap.set(name, state);
221
+ }
222
+ return state;
223
+ }
224
+ attach(uri, sound) {
225
+ const state = this.resourceStates.get(uri);
226
+ if (state?.status === 'ready') {
227
+ this.startPlayback(sound, state.resource);
228
+ return;
229
+ }
230
+ if (state?.status === 'failed') {
231
+ this.drop(sound);
232
+ return;
233
+ }
234
+ this.ensureLoading(uri)
235
+ .then(() => {
236
+ if (sound.ended)
237
+ return;
238
+ const resolved = this.resourceStates.get(uri);
239
+ if (resolved?.status === 'ready')
240
+ this.startPlayback(sound, resolved.resource);
241
+ else
242
+ this.drop(sound);
243
+ })
244
+ .catch(() => {
245
+ // ensureLoading never rejects; this is defensive, never expected to run.
246
+ this.drop(sound);
247
+ });
248
+ }
249
+ startPlayback(sound, resource) {
250
+ sound.backendHandle = this.backend.play(resource, {
251
+ channel: sound.channel,
252
+ volume: sound.volume * sound.attenuation,
253
+ loop: sound.loop,
254
+ onEnded: () => {
255
+ if (sound.ended)
256
+ return;
257
+ sound.ended = true;
258
+ this.live.delete(sound);
259
+ },
260
+ });
261
+ // A placement update may have already run while this sound was still
262
+ // loading (CA-8) — carry its pan over now that a backend handle exists.
263
+ if (sound.placement)
264
+ sound.backendHandle.setPan(sound.pan);
265
+ }
266
+ drop(sound) {
267
+ sound.ended = true;
268
+ this.live.delete(sound);
269
+ }
270
+ ensureLoading(uri) {
271
+ const existing = this.resourcePromises.get(uri);
272
+ if (existing)
273
+ return existing;
274
+ this.resourceStates.set(uri, { status: 'pending' });
275
+ const promise = this.backend.load(uri).then((resource) => {
276
+ this.resourceStates.set(uri, { status: 'ready', resource });
277
+ }, (error) => {
278
+ console.warn(`[waica] audio: failed to load "${uri}"`, error);
279
+ this.resourceStates.set(uri, { status: 'failed' });
280
+ });
281
+ this.resourcePromises.set(uri, promise);
282
+ return promise;
283
+ }
284
+ stopSound(sound, fadeMs) {
285
+ if (sound.ended)
286
+ return;
287
+ if (!sound.backendHandle) {
288
+ // Still loading: nothing audible exists yet to fade, so cancel
289
+ // outright — it must never start once the load resolves.
290
+ this.drop(sound);
291
+ return;
292
+ }
293
+ if (fadeMs && this.outputLive) {
294
+ sound.fading = true;
295
+ sound.backendHandle.stop(fadeMs);
296
+ // playing stays true until the backend's onEnded fires, once the ramp completes.
297
+ }
298
+ else {
299
+ // Either no fade was requested, or output is currently suspended
300
+ // (setActive(false) / setSilenced(true)): the real backend schedules
301
+ // the ramp and the source's stop() against context.currentTime, which
302
+ // does not advance while suspended, so neither would ever come due —
303
+ // onEnded would never fire and the sound would stay `playing: true`
304
+ // forever. Falling back to an immediate stop sidesteps that: this
305
+ // branch never waits for the backend's onEnded anyway, it finishes
306
+ // the sound in the model synchronously right here.
307
+ sound.ended = true;
308
+ this.live.delete(sound);
309
+ sound.backendHandle.stop();
310
+ }
311
+ }
312
+ handleFor(sound) {
313
+ const subsystem = this;
314
+ return {
315
+ get playing() {
316
+ return !sound.ended;
317
+ },
318
+ get volume() {
319
+ return sound.volume;
320
+ },
321
+ set volume(value) {
322
+ sound.volume = value;
323
+ // While a fade-out ramp is running (fading), skip the backend write:
324
+ // a plain gain assignment is equivalent to a setValueAtTime inserted
325
+ // before the ramp's end (Web Audio spec), which jumps the gain back
326
+ // up and only then resumes descending — cutting the fade short,
327
+ // same mechanism updatePlacements() already guards against. The
328
+ // stored value above is updated regardless, so the sound reads back
329
+ // correctly however long it stays `fading` before it truly ends.
330
+ if (!sound.fading)
331
+ sound.backendHandle?.setVolume(value * sound.attenuation);
332
+ },
333
+ stop(opts = {}) {
334
+ subsystem.stopSound(sound, opts.fadeMs);
335
+ },
336
+ };
337
+ }
338
+ handleUnlockEvent = () => {
339
+ this.markUnlocked();
340
+ this.syncOutput();
341
+ };
342
+ /**
343
+ * Flips the one-way unlock latch, detaches the DOM listeners, and releases
344
+ * every loop that was retained while locked (the boot-music-bed fix) —
345
+ * without syncing output. Callers decide when to sync so a silencing
346
+ * change arriving in the same call (setSilenced) composes into a single,
347
+ * correct resume/suspend decision instead of a spurious resume-then-
348
+ * suspend pair.
349
+ */
350
+ markUnlocked() {
351
+ if (this.unlocked)
352
+ return;
353
+ this.unlocked = true;
354
+ window.removeEventListener('keydown', this.handleUnlockEvent);
355
+ this.canvas.removeEventListener('pointerdown', this.handleUnlockEvent);
356
+ const pending = [...this.pendingUnlock];
357
+ this.pendingUnlock.clear();
358
+ for (const sound of pending) {
359
+ // stop() while still pending drops the sound outright (no backend
360
+ // handle to fade) and marks it ended — it must never start.
361
+ if (sound.ended)
362
+ continue;
363
+ this.attach(sound.uri, sound);
364
+ }
365
+ }
366
+ syncOutput() {
367
+ const desired = this.unlocked && this.active && !this.silenced;
368
+ if (desired === this.outputLive)
369
+ return;
370
+ this.outputLive = desired;
371
+ if (desired)
372
+ this.backend.resume();
373
+ else
374
+ this.backend.suspend();
375
+ }
376
+ }
377
+ /**
378
+ * Returned by play() for a non-looping call made before the first unlock:
379
+ * registers nothing in `live`, touches no backend, and reports `playing`
380
+ * false forever. A uri already known to have failed takes a different path
381
+ * — it still registers a real sound (attach() -> drop()), so play() returns
382
+ * a handleFor() handle whose `sound.ended` is already true instead of this one.
383
+ */
384
+ function inertHandle(initialVolume) {
385
+ let volume = initialVolume;
386
+ return {
387
+ get playing() {
388
+ return false;
389
+ },
390
+ get volume() {
391
+ return volume;
392
+ },
393
+ set volume(value) {
394
+ volume = value;
395
+ },
396
+ stop() { },
397
+ };
398
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * A decoded, backend-specific playback resource (e.g. a real AudioBuffer).
3
+ * Opaque to the audio subsystem: it caches these by uri and hands them back
4
+ * to `play()` unchanged.
5
+ */
6
+ export type AudioResource = unknown;
7
+ export interface BackendPlayOptions {
8
+ /** Mixer channel this sound plays through (e.g. 'sfx', 'music'). */
9
+ channel: string;
10
+ /** The sound's own gain, independent of the channel/master mix. */
11
+ volume: number;
12
+ loop: boolean;
13
+ /**
14
+ * Called exactly once, when the sound truly stops producing audio:
15
+ * reaching its natural end, or after stop()/a fade-out ramp completes.
16
+ * Never called for a sound still looping.
17
+ */
18
+ onEnded: () => void;
19
+ }
20
+ export interface BackendPlayHandle {
21
+ /** Changes the sound's own gain while it plays. */
22
+ setVolume(volume: number): void;
23
+ /**
24
+ * Sets the stereo pan in [-1, 1] (-1 fully left, 0 centered, 1 fully
25
+ * right). Only ever called for a sound started with `at` (CA-8); a flat
26
+ * sound never receives a call.
27
+ */
28
+ setPan(pan: number): void;
29
+ /**
30
+ * Stops the sound. With no fadeMs, releases immediately (onEnded still
31
+ * fires, but the caller doesn't wait for it). With fadeMs, ramps gain to
32
+ * zero over that many milliseconds before releasing — onEnded fires once
33
+ * the ramp completes, not before. Idempotent.
34
+ */
35
+ stop(fadeMs?: number): void;
36
+ }
37
+ /**
38
+ * The seam ADR 0013 exists for: everything Game needs from WebAudio,
39
+ * abstracted so `happy-dom` — which has no AudioContext, AudioBuffer or
40
+ * GainNode at all — can exercise the whole audio contract against an
41
+ * injected fake. The real implementation (see web-audio-backend.ts) is the
42
+ * default; a host replaces it via `GameOptions.audio`, e.g. to test its own
43
+ * project's audio without a browser.
44
+ */
45
+ export interface AudioBackend {
46
+ /**
47
+ * Fetches and decodes a uri into an opaque resource. The subsystem calls
48
+ * this at most once per uri — the result is cached and reused.
49
+ */
50
+ load(uri: string): Promise<AudioResource>;
51
+ /** Starts playing a decoded resource. */
52
+ play(resource: AudioResource, options: BackendPlayOptions): BackendPlayHandle;
53
+ /** Sets a channel's own gain (independent of mute). */
54
+ setChannelVolume(channel: string, volume: number): void;
55
+ /** Silences (true) or restores (false) every sound on a channel without stopping them. */
56
+ setChannelMuted(channel: string, muted: boolean): void;
57
+ /** Scales every channel's effective output. */
58
+ setMasterVolume(volume: number): void;
59
+ /** Freezes all output; sounds in flight are neither stopped nor restarted. */
60
+ suspend(): void;
61
+ /**
62
+ * Resumes output. The real implementation lazily creates its underlying
63
+ * AudioContext here (or at load()) — never eagerly, and never in a
64
+ * constructor — so nothing touches the device before an unlock (CA-6).
65
+ */
66
+ resume(): void;
67
+ /** Stops everything permanently and releases the underlying context. */
68
+ close(): void;
69
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Engine constants for CA-8 positional audio. There are no public tuning
3
+ * knobs (spec decision 20): the curve is picked once, in the spirit of
4
+ * `CAMERA_DEFAULTS` (`camera.ts`), and frozen — CA-21 is the human-only
5
+ * game-feel pass that may revisit these numbers by ear, never a per-call or
6
+ * per-channel option.
7
+ *
8
+ * The attenuation curve mirrors WebAudio's own `PannerNode` "linear"
9
+ * distance model: full volume at or inside `referenceDistance`, a straight
10
+ * ramp down to silence at `maxDistance`, silent beyond it. `panDistance` is
11
+ * the render-space horizontal offset (world units) that reaches full
12
+ * left/right pan.
13
+ */
14
+ export declare const AUDIO_SPATIAL_DEFAULTS: {
15
+ readonly referenceDistance: 3;
16
+ readonly maxDistance: 16;
17
+ readonly panDistance: 6;
18
+ };
19
+ /** 1 at/inside referenceDistance, 0 at/beyond maxDistance, linear in between. */
20
+ export declare function attenuationForDistance(distance: number): number;
21
+ /** Maps a render-space horizontal offset (source minus listener) to a [-1, 1] stereo pan. */
22
+ export declare function panForOffset(dx: number): number;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Engine constants for CA-8 positional audio. There are no public tuning
3
+ * knobs (spec decision 20): the curve is picked once, in the spirit of
4
+ * `CAMERA_DEFAULTS` (`camera.ts`), and frozen — CA-21 is the human-only
5
+ * game-feel pass that may revisit these numbers by ear, never a per-call or
6
+ * per-channel option.
7
+ *
8
+ * The attenuation curve mirrors WebAudio's own `PannerNode` "linear"
9
+ * distance model: full volume at or inside `referenceDistance`, a straight
10
+ * ramp down to silence at `maxDistance`, silent beyond it. `panDistance` is
11
+ * the render-space horizontal offset (world units) that reaches full
12
+ * left/right pan.
13
+ */
14
+ export const AUDIO_SPATIAL_DEFAULTS = {
15
+ referenceDistance: 3,
16
+ maxDistance: 16,
17
+ panDistance: 6,
18
+ };
19
+ /** 1 at/inside referenceDistance, 0 at/beyond maxDistance, linear in between. */
20
+ export function attenuationForDistance(distance) {
21
+ const { referenceDistance, maxDistance } = AUDIO_SPATIAL_DEFAULTS;
22
+ if (distance <= referenceDistance)
23
+ return 1;
24
+ if (distance >= maxDistance)
25
+ return 0;
26
+ return 1 - (distance - referenceDistance) / (maxDistance - referenceDistance);
27
+ }
28
+ /** Maps a render-space horizontal offset (source minus listener) to a [-1, 1] stereo pan. */
29
+ export function panForOffset(dx) {
30
+ const { panDistance } = AUDIO_SPATIAL_DEFAULTS;
31
+ return Math.max(-1, Math.min(1, dx / panDistance));
32
+ }
@@ -0,0 +1,42 @@
1
+ import type { Entity } from '../entity.js';
2
+ export interface AudioPlayOptions {
3
+ /** Mixer channel; defaults to 'sfx'. Naming any other channel creates it at volume 1. */
4
+ channel?: string;
5
+ /** The sound's own gain, independent of the channel/master mix. Defaults to 1. */
6
+ volume?: number;
7
+ loop?: boolean;
8
+ /**
9
+ * 'session' survives Game.unloadScene() (CA-7); omitted means scene-scoped
10
+ * — the default, and the common case (ADR 0012).
11
+ */
12
+ scope?: 'session';
13
+ /**
14
+ * Positional playback (CA-8). An `Entity` tracks its current position
15
+ * every frame; a plain `{ x, y }` fixes the placement. Omitted, the sound
16
+ * is flat: no panning, no distance attenuation. Attenuation is computed
17
+ * from the distance to the listener (the camera) in logical coordinates;
18
+ * panning is computed from both positions after `game.renderPoint()`, so
19
+ * an isometric source that reads to the right on screen pans right even
20
+ * though its volume reflects real (logical) game distance.
21
+ */
22
+ at?: Entity | {
23
+ x: number;
24
+ y: number;
25
+ };
26
+ }
27
+ export interface SoundHandle {
28
+ stop(opts?: {
29
+ fadeMs?: number;
30
+ }): void;
31
+ readonly playing: boolean;
32
+ volume: number;
33
+ }
34
+ export interface AudioChannelState {
35
+ volume: number;
36
+ muted: boolean;
37
+ }
38
+ export interface LiveSoundInfo {
39
+ uri: string;
40
+ channel: string;
41
+ scope: 'scene' | 'session';
42
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import type { AudioBackend, AudioResource, BackendPlayHandle, BackendPlayOptions } from './backend.js';
2
+ /**
3
+ * The real WebAudio implementation (ADR 0013's default). Builds a small
4
+ * mixing graph — one GainNode per sound feeding a StereoPannerNode, feeding
5
+ * a per-channel GainNode, feeding a single master GainNode — so
6
+ * muting/volume/master changes are plain WebAudio gain assignments, and
7
+ * positional panning (CA-8) is a plain WebAudio pan assignment, neither
8
+ * something this class recomputes by hand for every live sound.
9
+ *
10
+ * The AudioContext itself is never created eagerly: `happy-dom` (this
11
+ * repo's test environment) has no AudioContext/AudioBuffer/GainNode at all,
12
+ * and real browsers block unsolicited audio output before a user gesture.
13
+ * `ensureContext()` is the one lazy constructor, reached only from
14
+ * `resume()` (the unlock, CA-6) or `load()` (so `preload()` can decode
15
+ * ahead of an unlock). If AudioContext isn't available in the current
16
+ * environment, every method degrades to a safe no-op / rejected load
17
+ * instead of throwing — the audio subsystem's own load-failure handling
18
+ * (CA-9) turns that rejection into a single warning, never a crash.
19
+ */
20
+ export declare class WebAudioBackend implements AudioBackend {
21
+ private context;
22
+ private masterGain;
23
+ private masterVolume;
24
+ private readonly channelGains;
25
+ private readonly channelVolumes;
26
+ private readonly channelMuted;
27
+ load(uri: string): Promise<AudioResource>;
28
+ play(resource: AudioResource, options: BackendPlayOptions): BackendPlayHandle;
29
+ setChannelVolume(channel: string, volume: number): void;
30
+ setChannelMuted(channel: string, muted: boolean): void;
31
+ setMasterVolume(volume: number): void;
32
+ suspend(): void;
33
+ resume(): void;
34
+ close(): void;
35
+ private ensureContext;
36
+ private ensureChannelGain;
37
+ private applyChannelGain;
38
+ private effectiveChannelGain;
39
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The real WebAudio implementation (ADR 0013's default). Builds a small
3
+ * mixing graph — one GainNode per sound feeding a StereoPannerNode, feeding
4
+ * a per-channel GainNode, feeding a single master GainNode — so
5
+ * muting/volume/master changes are plain WebAudio gain assignments, and
6
+ * positional panning (CA-8) is a plain WebAudio pan assignment, neither
7
+ * something this class recomputes by hand for every live sound.
8
+ *
9
+ * The AudioContext itself is never created eagerly: `happy-dom` (this
10
+ * repo's test environment) has no AudioContext/AudioBuffer/GainNode at all,
11
+ * and real browsers block unsolicited audio output before a user gesture.
12
+ * `ensureContext()` is the one lazy constructor, reached only from
13
+ * `resume()` (the unlock, CA-6) or `load()` (so `preload()` can decode
14
+ * ahead of an unlock). If AudioContext isn't available in the current
15
+ * environment, every method degrades to a safe no-op / rejected load
16
+ * instead of throwing — the audio subsystem's own load-failure handling
17
+ * (CA-9) turns that rejection into a single warning, never a crash.
18
+ */
19
+ export class WebAudioBackend {
20
+ context = null;
21
+ masterGain = null;
22
+ masterVolume = 1;
23
+ channelGains = new Map();
24
+ channelVolumes = new Map();
25
+ channelMuted = new Map();
26
+ async load(uri) {
27
+ const context = this.ensureContext();
28
+ if (!context)
29
+ throw new Error('AudioContext is not available in this environment');
30
+ const response = await fetch(uri);
31
+ if (!response.ok)
32
+ throw new Error(`HTTP ${response.status} fetching "${uri}"`);
33
+ const data = await response.arrayBuffer();
34
+ return context.decodeAudioData(data);
35
+ }
36
+ play(resource, options) {
37
+ const context = this.ensureContext();
38
+ if (!context)
39
+ return noopHandle();
40
+ const source = context.createBufferSource();
41
+ source.buffer = resource;
42
+ source.loop = options.loop;
43
+ const soundGain = context.createGain();
44
+ soundGain.gain.value = options.volume;
45
+ const panner = context.createStereoPanner();
46
+ source.connect(soundGain);
47
+ soundGain.connect(panner);
48
+ panner.connect(this.ensureChannelGain(options.channel));
49
+ let ended = false;
50
+ const finish = () => {
51
+ if (ended)
52
+ return;
53
+ ended = true;
54
+ options.onEnded();
55
+ };
56
+ source.onended = finish;
57
+ source.start();
58
+ return {
59
+ setVolume: (volume) => {
60
+ soundGain.gain.value = volume;
61
+ },
62
+ setPan: (pan) => {
63
+ panner.pan.value = pan;
64
+ },
65
+ stop: (fadeMs) => {
66
+ if (ended)
67
+ return;
68
+ const now = context.currentTime;
69
+ if (fadeMs && fadeMs > 0) {
70
+ soundGain.gain.linearRampToValueAtTime(0, now + fadeMs / 1000);
71
+ source.stop(now + fadeMs / 1000);
72
+ }
73
+ else {
74
+ source.stop();
75
+ }
76
+ },
77
+ };
78
+ }
79
+ setChannelVolume(channel, volume) {
80
+ this.channelVolumes.set(channel, volume);
81
+ this.applyChannelGain(channel);
82
+ }
83
+ setChannelMuted(channel, muted) {
84
+ this.channelMuted.set(channel, muted);
85
+ this.applyChannelGain(channel);
86
+ }
87
+ setMasterVolume(volume) {
88
+ this.masterVolume = volume;
89
+ if (this.masterGain)
90
+ this.masterGain.gain.value = volume;
91
+ }
92
+ suspend() {
93
+ void this.context?.suspend();
94
+ }
95
+ resume() {
96
+ void this.ensureContext()?.resume();
97
+ }
98
+ close() {
99
+ void this.context?.close();
100
+ this.context = null;
101
+ this.masterGain = null;
102
+ this.channelGains.clear();
103
+ }
104
+ ensureContext() {
105
+ if (this.context)
106
+ return this.context;
107
+ if (typeof AudioContext === 'undefined')
108
+ return null;
109
+ const context = new AudioContext();
110
+ this.context = context;
111
+ const masterGain = context.createGain();
112
+ masterGain.gain.value = this.masterVolume;
113
+ masterGain.connect(context.destination);
114
+ this.masterGain = masterGain;
115
+ for (const channel of this.channelVolumes.keys())
116
+ this.ensureChannelGain(channel);
117
+ return context;
118
+ }
119
+ ensureChannelGain(channel) {
120
+ const existing = this.channelGains.get(channel);
121
+ if (existing)
122
+ return existing;
123
+ // Safe: only called once ensureContext() has already produced a context.
124
+ const context = this.context;
125
+ const gain = context.createGain();
126
+ gain.gain.value = this.effectiveChannelGain(channel);
127
+ gain.connect(this.masterGain);
128
+ this.channelGains.set(channel, gain);
129
+ return gain;
130
+ }
131
+ applyChannelGain(channel) {
132
+ const gain = this.channelGains.get(channel);
133
+ if (gain)
134
+ gain.gain.value = this.effectiveChannelGain(channel);
135
+ }
136
+ effectiveChannelGain(channel) {
137
+ if (this.channelMuted.get(channel))
138
+ return 0;
139
+ return this.channelVolumes.get(channel) ?? 1;
140
+ }
141
+ }
142
+ /** Returned when the environment has no AudioContext at all: inert, never throws. */
143
+ function noopHandle() {
144
+ return {
145
+ setVolume: () => { },
146
+ setPan: () => { },
147
+ stop: () => { },
148
+ };
149
+ }
@@ -10,7 +10,7 @@ export interface ParamSpec {
10
10
  /** Allowed values for a string param; rendered as a dropdown. Takes precedence over ref. */
11
11
  options?: string[];
12
12
  /** Project value this string param names; rendered and validated as a typed reference. */
13
- ref?: 'prefab' | 'stat' | 'action' | 'clip';
13
+ ref?: 'prefab' | 'stat' | 'action' | 'clip' | 'sound';
14
14
  }
15
15
  export interface ComponentClass<T extends Component = Component> {
16
16
  new (): T;
package/dist/game.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import * as THREE from 'three';
2
+ import type { AudioBackend } from './audio/backend.js';
3
+ import { AudioSubsystem } from './audio/audio-subsystem.js';
2
4
  import { type SceneCameraJson } from './camera.js';
3
5
  import type { Component } from './component.js';
4
6
  import { Entity } from './entity.js';
@@ -26,6 +28,14 @@ export interface GameOptions {
26
28
  bindings?: InputBindings;
27
29
  /** Initial stat values (points, lives…) from the project's stats.json. */
28
30
  stats?: Record<string, StatValue>;
31
+ /**
32
+ * Replaces the real WebAudio implementation (ADR 0013) — mainly for a
33
+ * project's own tests, since `happy-dom` has no AudioContext, AudioBuffer
34
+ * or GainNode at all. Defaults to the real backend either way; `game.audio`
35
+ * always exists, and the real AudioContext is constructed lazily, at the
36
+ * first unlock (CA-6), never eagerly here.
37
+ */
38
+ audio?: AudioBackend;
29
39
  }
30
40
  export type UpdateFn = (dt: number) => void;
31
41
  export interface SpawnPrefabOptions {
@@ -53,6 +63,8 @@ export declare class Game {
53
63
  readonly stats: Stats;
54
64
  /** The HTML UI layer: presentation-only pieces toggled from code. */
55
65
  readonly ui: GameUi;
66
+ /** The audio mixer: channels, master, playback. See ADR 0012, ADR 0013. */
67
+ readonly audio: AudioSubsystem;
56
68
  /** Registry retained by loadScene for runtime prefab spawning. */
57
69
  registry: SceneRegistry | null;
58
70
  paramOverrides: ParamOverrides;
@@ -156,6 +168,14 @@ export declare class Game {
156
168
  private componentUpdateSchedule;
157
169
  private updateSceneCamera;
158
170
  private renderPoint;
171
+ /**
172
+ * The audio listener's position (CA-8) in logical coordinates. The camera
173
+ * itself only ever holds render-space coordinates (see `updateSceneCamera`,
174
+ * `setSceneCamera`), so under `projection: 'isometric'` this is the exact
175
+ * inverse of `renderPoint` — without it, distance-based attenuation would
176
+ * measure render-space distance instead of real game distance.
177
+ */
178
+ private audioListenerPosition;
159
179
  private dispatchCollisions;
160
180
  private resize;
161
181
  }
package/dist/game.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as THREE from 'three';
2
+ import { AudioSubsystem } from './audio/audio-subsystem.js';
2
3
  import { collisionOverlap } from './collision-shape.js';
3
4
  import { isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera, } from './camera.js';
4
5
  import { resolveComponentUpdateSchedule } from './component-update-schedule.js';
@@ -9,7 +10,7 @@ import { Input } from './input.js';
9
10
  import { Pointer } from './pointer.js';
10
11
  import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
11
12
  import { RuntimeInspector } from './runtime-inspection.js';
12
- import { projectIsometric } from './projection.js';
13
+ import { projectIsometric, unprojectIsometric } from './projection.js';
13
14
  import { isYSortParticipant, ySortZ } from './render-sort.js';
14
15
  import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
15
16
  import { Stats } from './stats.js';
@@ -28,6 +29,8 @@ export class Game {
28
29
  stats;
29
30
  /** The HTML UI layer: presentation-only pieces toggled from code. */
30
31
  ui;
32
+ /** The audio mixer: channels, master, playback. See ADR 0012, ADR 0013. */
33
+ audio;
31
34
  /** Registry retained by loadScene for runtime prefab spawning. */
32
35
  registry = null;
33
36
  paramOverrides = {};
@@ -66,6 +69,15 @@ export class Game {
66
69
  this.input = new Input(options.bindings);
67
70
  this.stats = new Stats(options.stats);
68
71
  this.ui = new GameUi(this.stats, () => canvas.parentElement ?? document.body);
72
+ this.audio = new AudioSubsystem({
73
+ canvas,
74
+ backend: options.audio,
75
+ // The catalog registered via registerSceneCatalog, never game.registry:
76
+ // unloadScene() nulls the latter but leaves the catalog (and its
77
+ // resolver) untouched, which is exactly what a { scope: 'session' }
78
+ // music bed needs across a scene swap.
79
+ resolveAsset: (uri) => this.sceneCatalog?.registry.resolveAsset?.(uri) ?? uri,
80
+ });
69
81
  this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
70
82
  this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
71
83
  this.scene.background = new THREE.Color(background);
@@ -119,6 +131,7 @@ export class Game {
119
131
  */
120
132
  unloadScene() {
121
133
  this.ui.unloadScene();
134
+ this.audio.unloadScene();
122
135
  // An explicit unload means "no scene": a swap queued earlier this frame
123
136
  // would otherwise flush next frame and resurrect one.
124
137
  this.pendingSceneLoad = null;
@@ -266,6 +279,7 @@ export class Game {
266
279
  availableScenes: () => this.availableScenes,
267
280
  });
268
281
  activation.register(this.runtimeBridge);
282
+ this.audio.setSilenced(true);
269
283
  window.addEventListener('pagehide', this.unregisterRuntimeBridge);
270
284
  }
271
285
  this.renderSurface();
@@ -302,6 +316,7 @@ export class Game {
302
316
  this.pointer.dispose();
303
317
  this.resizeObserver.disconnect();
304
318
  this.ui.dispose();
319
+ this.audio.dispose();
305
320
  for (const entity of [...this.entities])
306
321
  entity.destroy();
307
322
  this.renderer.dispose();
@@ -340,6 +355,11 @@ export class Game {
340
355
  }
341
356
  // The UI must react to the pause itself (hide until resumed).
342
357
  this.ui.setActive(this.simulate);
358
+ this.audio.setActive(this.simulate);
359
+ // Positional audio (CA-8): recomputed every frame, on this same pass —
360
+ // never a second walk of `this.entities`, since `this.audio` already
361
+ // holds direct references to whichever entities are tracked.
362
+ this.audio.updatePlacements(this.audioListenerPosition(), (x, y) => this.renderPoint(x, y));
343
363
  for (const fn of this.updateFns)
344
364
  fn(dt);
345
365
  this.input.endFrame();
@@ -360,6 +380,7 @@ export class Game {
360
380
  window.removeEventListener('pagehide', this.unregisterRuntimeBridge);
361
381
  this.runtimeBridge?.unregister();
362
382
  this.runtimeBridge = null;
383
+ this.audio.setSilenced(false);
363
384
  };
364
385
  /** Under y-sort, re-derives every participant's z from layer band + entity Y. */
365
386
  applyYSort() {
@@ -456,6 +477,17 @@ export class Game {
456
477
  renderPoint(x, y) {
457
478
  return this.sceneProjection === 'isometric' ? projectIsometric(x, y) : { x, y };
458
479
  }
480
+ /**
481
+ * The audio listener's position (CA-8) in logical coordinates. The camera
482
+ * itself only ever holds render-space coordinates (see `updateSceneCamera`,
483
+ * `setSceneCamera`), so under `projection: 'isometric'` this is the exact
484
+ * inverse of `renderPoint` — without it, distance-based attenuation would
485
+ * measure render-space distance instead of real game distance.
486
+ */
487
+ audioListenerPosition() {
488
+ const { x, y } = this.camera.position;
489
+ return this.sceneProjection === 'isometric' ? unprojectIsometric(x, y) : { x, y };
490
+ }
459
491
  dispatchCollisions() {
460
492
  const boxed = this.entities.filter((e) => e.has(Hitbox));
461
493
  for (let i = 0; i < boxed.length; i++) {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export { Game } from './game.js';
2
2
  export type { GameOptions, GameResolution, SceneCatalog, SpawnPrefabOptions, UpdateFn, ParamOverrides, } from './game.js';
3
+ export { AudioSubsystem } from './audio/audio-subsystem.js';
4
+ export type { AudioSubsystemOptions } from './audio/audio-subsystem.js';
5
+ export type { AudioBackend, AudioResource, BackendPlayHandle, BackendPlayOptions } from './audio/backend.js';
6
+ export type { AudioChannelState, AudioPlayOptions, LiveSoundInfo, SoundHandle } from './audio/types.js';
3
7
  export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
4
8
  export type { AnimationFacingProvider, DirectionalAnimation, DirectionalFallback, ResolvedDirectionalClip, } from './animation/directional.js';
5
9
  export { isYSortParticipant, ySortZ } from './render-sort.js';
@@ -25,7 +29,7 @@ export type { PointerCamera, PointerDeps, PointerPick, PointerResolution } from
25
29
  export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
26
30
  export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
27
31
  export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
28
- export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotFilters, RuntimeTransformSnapshot, } from './runtime-inspection.js';
32
+ export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotFilters, RuntimeTransformSnapshot, } from './runtime-inspection.js';
29
33
  export type { ArchetypeArt, ArchetypeManifest, BrowserArchetypeManifest, EntityTemplate, } from './archetype.js';
30
34
  export { Stats } from './stats.js';
31
35
  export type { StatValue } from './stats.js';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { Game } from './game.js';
2
+ export { AudioSubsystem } from './audio/audio-subsystem.js';
2
3
  export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
3
4
  export { isYSortParticipant, ySortZ } from './render-sort.js';
4
5
  export { projectIsometric, screenInputToLogical, unprojectIsometric } from './projection.js';
@@ -1,3 +1,4 @@
1
+ import type { AudioChannelState, LiveSoundInfo } from './audio/types.js';
1
2
  import type { Game } from './game.js';
2
3
  import type { RuntimeMetadata } from './runtime-bridge.js';
3
4
  import type { StatValue } from './stats.js';
@@ -48,12 +49,29 @@ export interface RuntimeEntitySnapshot {
48
49
  transform: RuntimeTransformSnapshot;
49
50
  components: RuntimeComponentSnapshot[];
50
51
  }
52
+ /**
53
+ * The mixer's state (CA-15): `master` and every channel's volume/mute,
54
+ * sorted by name, plus `playing` — `game.audio.liveSounds()` verbatim,
55
+ * already sorted (by uri then channel). Despite the name, `playing` is not
56
+ * "every currently-audible sound": per `liveSounds()`'s own docstring it
57
+ * also carries a loop retained before the autoplay unlock, a sound still
58
+ * loading, and even one about to fail to load (gone a tick later). Emitted
59
+ * unconditionally, like every other snapshot section —
60
+ * `[DEVIATION 2026-09-08]` in the spec: no section of RuntimeSnapshot is
61
+ * filterable today, so audio does not invent the first one.
62
+ */
63
+ export interface RuntimeSnapshotAudio {
64
+ master: number;
65
+ channels: Record<string, AudioChannelState>;
66
+ playing: LiveSoundInfo[];
67
+ }
51
68
  export interface RuntimeSnapshot extends RuntimeMetadata {
52
69
  stats: Record<string, StatValue>;
53
70
  /** The live scene's name (its catalog key), or null with no scene loaded. */
54
71
  scene: string | null;
55
72
  entities: RuntimeEntitySnapshot[];
56
73
  projectionIssues: ProjectionIssue[];
74
+ audio: RuntimeSnapshotAudio;
57
75
  }
58
76
  export declare const RUNTIME_PROJECTION_LIMITS: {
59
77
  readonly depth: 5;
@@ -68,6 +86,7 @@ export declare class RuntimeInspector {
68
86
  private nextId;
69
87
  constructor(game: Game);
70
88
  snapshot(metadata: RuntimeMetadata, filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
89
+ private audioSnapshot;
71
90
  private capSnapshot;
72
91
  private idFor;
73
92
  }
@@ -237,8 +237,20 @@ export class RuntimeInspector {
237
237
  scene: this.game.sceneName,
238
238
  entities,
239
239
  projectionIssues,
240
+ audio: this.audioSnapshot(),
240
241
  });
241
242
  }
243
+ audioSnapshot() {
244
+ const channels = {};
245
+ for (const name of this.game.audio.channels().sort()) {
246
+ channels[name] = this.game.audio.channelState(name);
247
+ }
248
+ return {
249
+ master: this.game.audio.master,
250
+ channels,
251
+ playing: this.game.audio.liveSounds(),
252
+ };
253
+ }
242
254
  capSnapshot(snapshot) {
243
255
  if (utf8Bytes(JSON.stringify(snapshot)) <= RUNTIME_PROJECTION_LIMITS.snapshotBytes) {
244
256
  return snapshot;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/engine",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",