@waica/engine 0.12.0 → 0.14.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.
- package/dist/animation/clip-player.js +6 -1
- package/dist/archetype.d.ts +11 -0
- package/dist/audio/audio-subsystem.d.ts +141 -0
- package/dist/audio/audio-subsystem.js +398 -0
- package/dist/audio/backend.d.ts +69 -0
- package/dist/audio/backend.js +1 -0
- package/dist/audio/spatial.d.ts +22 -0
- package/dist/audio/spatial.js +32 -0
- package/dist/audio/types.d.ts +42 -0
- package/dist/audio/types.js +1 -0
- package/dist/audio/web-audio-backend.d.ts +39 -0
- package/dist/audio/web-audio-backend.js +149 -0
- package/dist/component.d.ts +1 -1
- package/dist/fixed-step-test-support.d.ts +9 -0
- package/dist/fixed-step-test-support.js +9 -0
- package/dist/fixed-step.d.ts +93 -0
- package/dist/fixed-step.js +109 -0
- package/dist/game.d.ts +76 -1
- package/dist/game.js +177 -43
- package/dist/index.d.ts +6 -1
- package/dist/index.js +2 -0
- package/dist/runtime-bridge.d.ts +16 -7
- package/dist/runtime-bridge.js +18 -14
- package/dist/runtime-inspection.d.ts +19 -0
- package/dist/runtime-inspection.js +12 -0
- package/dist/state/state-machine.js +10 -2
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SIMULATION_TIME_EPSILON } from '../fixed-step.js';
|
|
1
2
|
/**
|
|
2
3
|
* Advances a clip through time. Pure logic (no three, no DOM) so it can
|
|
3
4
|
* be tested deterministically.
|
|
@@ -16,7 +17,11 @@ export class ClipPlayer {
|
|
|
16
17
|
/** Advances the clock and returns the sheet frame to show. */
|
|
17
18
|
advance(dt) {
|
|
18
19
|
this.t += dt;
|
|
19
|
-
|
|
20
|
+
// this.t is a sum of SIMULATION_STEP-sized dts, which float error can
|
|
21
|
+
// leave a hair under an exact multiple of a frame's duration; without
|
|
22
|
+
// the epsilon, floor(t * fps) holds some frames one step short and
|
|
23
|
+
// others one step long instead of a flat, even count per frame.
|
|
24
|
+
const idx = Math.floor((this.t + SIMULATION_TIME_EPSILON) * this.fps);
|
|
20
25
|
const n = this.frames.length;
|
|
21
26
|
const clamped = this.loop ? idx % n : Math.min(idx, n - 1);
|
|
22
27
|
return this.frames[clamped] ?? 0;
|
package/dist/archetype.d.ts
CHANGED
|
@@ -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;
|