@galfus/engine 0.0.0-stage → 0.31.1-alpha

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.
Files changed (80) hide show
  1. package/README.md +190 -2
  2. package/package.json +59 -4
  3. package/src/anchors/cache.ts +101 -0
  4. package/src/anchors/component.ts +123 -0
  5. package/src/anchors/hierarchy.ts +192 -0
  6. package/src/anchors/index.ts +8 -0
  7. package/src/anchors/solver.ts +247 -0
  8. package/src/anchors/strategy.ts +142 -0
  9. package/src/animation/blending.ts +163 -0
  10. package/src/animation/clip.ts +132 -0
  11. package/src/animation/coordinator.ts +568 -0
  12. package/src/animation/index.ts +7 -0
  13. package/src/animation/native-targets.ts +37 -0
  14. package/src/animation/player.ts +287 -0
  15. package/src/audio/index.ts +4 -0
  16. package/src/audio/manager.ts +371 -0
  17. package/src/collision/components.ts +211 -0
  18. package/src/collision/index.ts +16 -0
  19. package/src/collision/query2d.ts +167 -0
  20. package/src/collision/query3d.ts +170 -0
  21. package/src/ecs/component.ts +130 -0
  22. package/src/ecs/entity.ts +75 -0
  23. package/src/ecs/index.ts +9 -0
  24. package/src/ecs/intent.ts +162 -0
  25. package/src/ecs/metadata.ts +44 -0
  26. package/src/ecs/system.ts +224 -0
  27. package/src/ecs/world.ts +291 -0
  28. package/src/index.ts +19 -0
  29. package/src/interaction/focus.ts +327 -0
  30. package/src/interaction/index.ts +9 -0
  31. package/src/interaction/listener.ts +58 -0
  32. package/src/interaction/snapshot2d.ts +210 -0
  33. package/src/interaction/snapshot3d.ts +214 -0
  34. package/src/interaction/system.ts +544 -0
  35. package/src/interaction/types.ts +27 -0
  36. package/src/math/common.ts +67 -0
  37. package/src/math/index.ts +17 -0
  38. package/src/math/mat2d.ts +320 -0
  39. package/src/math/mat4.ts +576 -0
  40. package/src/math/quat.ts +438 -0
  41. package/src/math/vec2.ts +161 -0
  42. package/src/math/vec3.ts +242 -0
  43. package/src/math/vec4.ts +171 -0
  44. package/src/multiverse/coordinator.ts +540 -0
  45. package/src/multiverse/index.ts +13 -0
  46. package/src/multiverse/presentation-graph.ts +306 -0
  47. package/src/multiverse/presentation-links.ts +134 -0
  48. package/src/multiverse/realm.ts +49 -0
  49. package/src/platform/index.ts +2 -0
  50. package/src/platform/platform.ts +149 -0
  51. package/src/platform/window.ts +181 -0
  52. package/src/realm2d/components.ts +254 -0
  53. package/src/realm2d/index.ts +12 -0
  54. package/src/realm2d/presets/index.ts +36 -0
  55. package/src/realm2d/realm.ts +522 -0
  56. package/src/realm2d/sync.ts +284 -0
  57. package/src/realm2d/utils/index.ts +32 -0
  58. package/src/realm3d/components.ts +341 -0
  59. package/src/realm3d/geometry.ts +224 -0
  60. package/src/realm3d/index.ts +12 -0
  61. package/src/realm3d/presets/index.ts +67 -0
  62. package/src/realm3d/realm.ts +588 -0
  63. package/src/realm3d/resource-ids.ts +62 -0
  64. package/src/realm3d/skinning.ts +128 -0
  65. package/src/realm3d/sync.ts +442 -0
  66. package/src/realm3d/utils/index.ts +26 -0
  67. package/src/resources/index.ts +4 -0
  68. package/src/resources/manager.ts +92 -0
  69. package/src/runtime.ts +588 -0
  70. package/src/spatial/dirty_registry.ts +217 -0
  71. package/src/spatial/flags.ts +14 -0
  72. package/src/spatial/index.ts +6 -0
  73. package/src/spatial/metrics.ts +124 -0
  74. package/src/text/editor.ts +224 -0
  75. package/src/text/encoding.ts +38 -0
  76. package/src/text/index.ts +13 -0
  77. package/src/text/input.ts +160 -0
  78. package/src/text/keyboard.ts +50 -0
  79. package/src/text/svg-decorations.ts +147 -0
  80. package/src/types.ts +85 -0
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Multi-track Animation Player component and ECS evaluation system.
3
+ */
4
+
5
+ import type { Component } from '../ecs/component';
6
+ import type { System } from '../ecs/system';
7
+ import type { EcsWorld } from '../ecs/world';
8
+ import { common } from '../math/index';
9
+ import type { Model3DComponent } from '../realm3d/components';
10
+ import type { Realm3D } from '../realm3d/realm';
11
+ import {
12
+ blendPoses,
13
+ clearAnimationPose,
14
+ copyAnimationPose,
15
+ createAnimationPose,
16
+ evaluateClip
17
+ } from './blending';
18
+ import type { AnimationClip } from './clip';
19
+
20
+ export type AnimationLoopMode = 'once' | 'repeat' | 'ping-pong';
21
+
22
+ export interface ActiveClipState {
23
+ clipName: string;
24
+ clip: AnimationClip;
25
+ time: number;
26
+ weight: number;
27
+ speed: number;
28
+ loopMode: AnimationLoopMode;
29
+ isPlaying: boolean;
30
+ isForward: boolean;
31
+ }
32
+
33
+ export interface AnimationPlayer3DComponent extends Component {
34
+ readonly type: 'AnimationPlayer3D';
35
+ clips: Map<string, AnimationClip>;
36
+ activeStates: Map<string, ActiveClipState>;
37
+ fadeTransition?: {
38
+ fromClip: string;
39
+ toClip: string;
40
+ duration: number;
41
+ elapsed: number;
42
+ };
43
+
44
+ /** Adds a clip to the player repository */
45
+ addClip(clip: AnimationClip): void;
46
+
47
+ /** Starts playing a clip with an initial weight and loop mode */
48
+ play(clipName: string, loopMode?: AnimationLoopMode, speed?: number): ActiveClipState | undefined;
49
+
50
+ /** Stops playback of a specific clip or all active clips */
51
+ stop(clipName?: string): void;
52
+
53
+ /** Initiates a smooth crossfade from the currently playing clip to target clip */
54
+ crossfade(
55
+ toClipName: string,
56
+ durationSecs: number,
57
+ loopMode?: AnimationLoopMode
58
+ ): ActiveClipState | undefined;
59
+ }
60
+
61
+ export function createAnimationPlayer3D(clips?: AnimationClip[]): AnimationPlayer3DComponent {
62
+ const clipMap = new Map<string, AnimationClip>();
63
+ if (clips) {
64
+ for (const clip of clips) {
65
+ clipMap.set(clip.name, clip);
66
+ }
67
+ }
68
+
69
+ const player: AnimationPlayer3DComponent = {
70
+ type: 'AnimationPlayer3D',
71
+ clips: clipMap,
72
+ activeStates: new Map(),
73
+
74
+ addClip(clip: AnimationClip) {
75
+ this.clips.set(clip.name, clip);
76
+ },
77
+
78
+ play(clipName: string, loopMode: AnimationLoopMode = 'repeat', speed = 1.0) {
79
+ const clip = this.clips.get(clipName);
80
+ if (!clip) return undefined;
81
+
82
+ const state: ActiveClipState = {
83
+ clipName,
84
+ clip,
85
+ time: 0,
86
+ weight: 1.0,
87
+ speed,
88
+ loopMode,
89
+ isPlaying: true,
90
+ isForward: true
91
+ };
92
+
93
+ this.activeStates.set(clipName, state);
94
+ return state;
95
+ },
96
+
97
+ stop(clipName?: string) {
98
+ if (clipName) {
99
+ this.activeStates.delete(clipName);
100
+ } else {
101
+ this.activeStates.clear();
102
+ }
103
+ },
104
+
105
+ crossfade(toClipName: string, durationSecs: number, loopMode: AnimationLoopMode = 'repeat') {
106
+ const targetClip = this.clips.get(toClipName);
107
+ if (!targetClip) return undefined;
108
+
109
+ // Find first playing clip to crossfade from
110
+ let fromClipName: string | undefined;
111
+ for (const [name, state] of this.activeStates) {
112
+ if (state.isPlaying && state.weight > 0 && name !== toClipName) {
113
+ fromClipName = name;
114
+ break;
115
+ }
116
+ }
117
+
118
+ const toState: ActiveClipState = {
119
+ clipName: toClipName,
120
+ clip: targetClip,
121
+ time: 0,
122
+ weight: fromClipName ? 0.0 : 1.0,
123
+ speed: 1.0,
124
+ loopMode,
125
+ isPlaying: true,
126
+ isForward: true
127
+ };
128
+ this.activeStates.set(toClipName, toState);
129
+
130
+ if (fromClipName && durationSecs > 0) {
131
+ this.fadeTransition = {
132
+ fromClip: fromClipName,
133
+ toClip: toClipName,
134
+ duration: durationSecs,
135
+ elapsed: 0
136
+ };
137
+ } else {
138
+ this.fadeTransition = undefined;
139
+ if (fromClipName) {
140
+ this.activeStates.delete(fromClipName);
141
+ }
142
+ }
143
+
144
+ return toState;
145
+ }
146
+ };
147
+
148
+ return player;
149
+ }
150
+
151
+ // Scratch poses for zero-allocation ECS updates
152
+ const SCRATCH_COMBINED_POSE = createAnimationPose();
153
+ const SCRATCH_CLIP_POSE = createAnimationPose();
154
+ const SCRATCH_ACCUMULATOR_POSE = createAnimationPose();
155
+
156
+ /**
157
+ * Creates the ECS System driving continuous animation playback and blending.
158
+ */
159
+ export function createAnimationPlaybackSystem(realm3d?: Realm3D): System<EcsWorld> {
160
+ return (world: EcsWorld, context) => {
161
+ const playerEntities = world.query('AnimationPlayer3D');
162
+ if (playerEntities.length === 0) return;
163
+
164
+ const dt = context.dt;
165
+
166
+ for (const playerEntity of playerEntities) {
167
+ const player = world.getComponent<AnimationPlayer3DComponent>(
168
+ playerEntity,
169
+ 'AnimationPlayer3D'
170
+ );
171
+ if (!player || player.activeStates.size === 0) continue;
172
+
173
+ // 1. Advance crossfade transition if active
174
+ if (player.fadeTransition) {
175
+ const trans = player.fadeTransition;
176
+ trans.elapsed += dt;
177
+ const progress = common.clamp(trans.elapsed / trans.duration, 0, 1);
178
+
179
+ const fromState = player.activeStates.get(trans.fromClip);
180
+ const toState = player.activeStates.get(trans.toClip);
181
+
182
+ if (fromState) fromState.weight = 1.0 - progress;
183
+ if (toState) toState.weight = progress;
184
+
185
+ if (progress >= 1.0) {
186
+ if (fromState) player.activeStates.delete(trans.fromClip);
187
+ player.fadeTransition = undefined;
188
+ }
189
+ }
190
+
191
+ // 2. Advance time for all active playing clips
192
+ for (const [_, state] of player.activeStates) {
193
+ if (!state.isPlaying || state.speed === 0) continue;
194
+
195
+ const dur = Math.max(0.0001, state.clip.duration);
196
+ const delta = dt * state.speed;
197
+
198
+ if (state.loopMode === 'repeat') {
199
+ state.time = (state.time + delta) % dur;
200
+ if (state.time < 0) state.time += dur;
201
+ } else if (state.loopMode === 'ping-pong') {
202
+ if (state.isForward) {
203
+ state.time += delta;
204
+ if (state.time >= dur) {
205
+ state.time = dur;
206
+ state.isForward = false;
207
+ }
208
+ } else {
209
+ state.time -= delta;
210
+ if (state.time <= 0) {
211
+ state.time = 0;
212
+ state.isForward = true;
213
+ }
214
+ }
215
+ } else {
216
+ // 'once'
217
+ state.time += delta;
218
+ if (state.time >= dur) {
219
+ state.time = dur;
220
+ state.isPlaying = false;
221
+ }
222
+ }
223
+ }
224
+
225
+ // 3. Evaluate and blend poses
226
+ clearAnimationPose(SCRATCH_COMBINED_POSE);
227
+ let isFirst = true;
228
+
229
+ for (const [_, state] of player.activeStates) {
230
+ if (state.weight <= 0) continue;
231
+
232
+ clearAnimationPose(SCRATCH_CLIP_POSE);
233
+ evaluateClip(state.clip, state.time, SCRATCH_CLIP_POSE);
234
+
235
+ if (isFirst) {
236
+ copyAnimationPose(SCRATCH_CLIP_POSE, SCRATCH_COMBINED_POSE);
237
+ isFirst = false;
238
+ } else {
239
+ copyAnimationPose(SCRATCH_COMBINED_POSE, SCRATCH_ACCUMULATOR_POSE);
240
+ blendPoses(
241
+ SCRATCH_COMBINED_POSE,
242
+ SCRATCH_ACCUMULATOR_POSE,
243
+ SCRATCH_CLIP_POSE,
244
+ state.weight
245
+ );
246
+ }
247
+ }
248
+
249
+ if (isFirst) continue; // No active clips with weight > 0
250
+
251
+ // 4. Apply combined pose to ECS entities
252
+ // Apply Translations
253
+ for (const [id, t] of SCRATCH_COMBINED_POSE.translations) {
254
+ const model = world.getComponent<Model3DComponent>(id, 'Model3D');
255
+ if (model) {
256
+ model.setPosition(t[0], t[1], t[2]);
257
+ }
258
+ }
259
+
260
+ // Apply Rotations
261
+ for (const [id, r] of SCRATCH_COMBINED_POSE.rotations) {
262
+ const model = world.getComponent<Model3DComponent>(id, 'Model3D');
263
+ if (model) {
264
+ model.setRotation(r[0], r[1], r[2], r[3]);
265
+ }
266
+ }
267
+
268
+ // Apply Scales
269
+ for (const [id, s] of SCRATCH_COMBINED_POSE.scales) {
270
+ const model = world.getComponent<Model3DComponent>(id, 'Model3D');
271
+ if (model) {
272
+ model.setScale(s[0], s[1], s[2]);
273
+ }
274
+ }
275
+
276
+ // Apply Morph Weights
277
+ for (const [id, weights] of SCRATCH_COMBINED_POSE.weights) {
278
+ if (realm3d) {
279
+ const model = world.getComponent<Model3DComponent>(id, 'Model3D');
280
+ if (model) {
281
+ realm3d.setModelMorphWeights(model.modelId, weights);
282
+ }
283
+ }
284
+ }
285
+ }
286
+ };
287
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Subpath export for Audio subsystem.
3
+ */
4
+ export * from './manager';
@@ -0,0 +1,371 @@
1
+ /**
2
+ * AudioManager manages audio sound loading, voices, and direct 3D spatial playback.
3
+ */
4
+
5
+ import type {
6
+ GalfusEngine,
7
+ SpatialAudio,
8
+ SpatialAudioConfig,
9
+ SpatialTransform
10
+ } from '@galfus/transport-types';
11
+ import { IdAllocator, type SoundId, type VoiceId } from '../types';
12
+
13
+ export interface PlaySoundOptions {
14
+ volume?: number;
15
+ panning?: number;
16
+ speed?: number;
17
+ looping?: boolean;
18
+ }
19
+
20
+ interface VoiceSpatialEntry {
21
+ spatial: SpatialAudio;
22
+ explicitListener: boolean;
23
+ }
24
+
25
+ function vec3Equals(
26
+ a: [number, number, number],
27
+ x: number,
28
+ y: number,
29
+ z: number,
30
+ eps = 1e-5
31
+ ): boolean {
32
+ return Math.abs(a[0] - x) < eps && Math.abs(a[1] - y) < eps && Math.abs(a[2] - z) < eps;
33
+ }
34
+
35
+ function quatEquals(
36
+ a?: [number, number, number, number],
37
+ x?: number,
38
+ y?: number,
39
+ z?: number,
40
+ w?: number,
41
+ eps = 1e-5
42
+ ): boolean {
43
+ if (!a) return false;
44
+ return (
45
+ Math.abs(a[0] - (x ?? 0)) < eps &&
46
+ Math.abs(a[1] - (y ?? 0)) < eps &&
47
+ Math.abs(a[2] - (z ?? 0)) < eps &&
48
+ Math.abs(a[3] - (w ?? 1)) < eps
49
+ );
50
+ }
51
+
52
+ export class AudioManager {
53
+ private _engine: GalfusEngine | null = null;
54
+ private readonly _idAllocator = new IdAllocator<SoundId>(1);
55
+ private readonly _sounds = new Set<SoundId>();
56
+ private readonly _activeVoices = new Set<VoiceId>();
57
+
58
+ // Global listener for voices without an explicit spatial listener
59
+ private readonly _listener: SpatialTransform = {
60
+ position: [0, 0, 0],
61
+ rotation: [0, 0, 0, 1]
62
+ };
63
+ private readonly _voiceSpatialCache = new Map<VoiceId, VoiceSpatialEntry>();
64
+ private readonly _voiceFinishListeners = new Map<VoiceId, Set<() => void>>();
65
+
66
+ bindEngine(engine: GalfusEngine): void {
67
+ this._engine = engine;
68
+ }
69
+
70
+ /**
71
+ * Loads raw audio bytes (WAV, MP3, OGG, FLAC) into core memory under a tracked SoundId.
72
+ */
73
+ loadSound(data: Uint8Array): SoundId {
74
+ const soundId = this._idAllocator.allocate('SoundId');
75
+ if (this._engine) {
76
+ this._engine.audioLoadSound(soundId, data, data.byteLength);
77
+ }
78
+ this._sounds.add(soundId);
79
+ return soundId;
80
+ }
81
+
82
+ /**
83
+ * Unloads a sound from core memory.
84
+ */
85
+ unloadSound(soundId: SoundId): void {
86
+ if (!this._sounds.has(soundId)) return;
87
+ if (this._engine) {
88
+ this._engine.audioUnloadSound(soundId);
89
+ }
90
+ this._sounds.delete(soundId);
91
+ }
92
+
93
+ /**
94
+ * Plays a loaded sound and returns its controllable VoiceId.
95
+ */
96
+ playSound(soundId: SoundId, options: PlaySoundOptions = {}): VoiceId {
97
+ if (!this._engine) {
98
+ throw new Error('Cannot play sound: AudioManager is not bound to an engine instance.');
99
+ }
100
+ const voiceId = this._engine.audioPlaySound(
101
+ soundId,
102
+ options.volume ?? 1.0,
103
+ options.panning ?? 0.0,
104
+ options.speed ?? 1.0,
105
+ options.looping ?? false
106
+ ) as VoiceId;
107
+ this._activeVoices.add(voiceId);
108
+ return voiceId;
109
+ }
110
+
111
+ /**
112
+ * Plays a 3D spatial sound and returns its controllable VoiceId.
113
+ * Can be called with a full SpatialAudio descriptor or directly with emitter position.
114
+ * Preserves explicit 1:1 acoustic listeners without overwriting them with the global listener.
115
+ */
116
+ playSoundSpatial(
117
+ soundId: SoundId,
118
+ spatialOrPosition: SpatialAudio | [number, number, number],
119
+ config?: SpatialAudioConfig
120
+ ): VoiceId {
121
+ if (!this._engine) {
122
+ throw new Error(
123
+ 'Cannot play spatial sound: AudioManager is not bound to an engine instance.'
124
+ );
125
+ }
126
+
127
+ const hasExplicitListener = !Array.isArray(spatialOrPosition) && !!spatialOrPosition.listener;
128
+ const listenerToUse = hasExplicitListener
129
+ ? {
130
+ position: [...(spatialOrPosition as SpatialAudio).listener.position] as [
131
+ number,
132
+ number,
133
+ number
134
+ ],
135
+ rotation: (spatialOrPosition as SpatialAudio).listener.rotation
136
+ ? ([...(spatialOrPosition as SpatialAudio).listener.rotation!] as [
137
+ number,
138
+ number,
139
+ number,
140
+ number
141
+ ])
142
+ : undefined
143
+ }
144
+ : this._listener;
145
+
146
+ const spatial: SpatialAudio = Array.isArray(spatialOrPosition)
147
+ ? {
148
+ emitter: { position: spatialOrPosition },
149
+ listener: listenerToUse,
150
+ config: config ?? { volume: 1.0 }
151
+ }
152
+ : { ...spatialOrPosition, listener: listenerToUse };
153
+
154
+ const voiceId = this._engine.audioPlaySoundSpatial(soundId, spatial) as VoiceId;
155
+ this._activeVoices.add(voiceId);
156
+ this._voiceSpatialCache.set(voiceId, {
157
+ spatial: {
158
+ emitter: {
159
+ position: [...spatial.emitter.position],
160
+ rotation: spatial.emitter.rotation ? [...spatial.emitter.rotation] : undefined
161
+ },
162
+ listener: listenerToUse,
163
+ config: spatial.config
164
+ },
165
+ explicitListener: hasExplicitListener
166
+ });
167
+ return voiceId;
168
+ }
169
+
170
+ /**
171
+ * Updates listener world-space position directly without allocating JSON objects.
172
+ * Only propagates to voices bound to the global listener.
173
+ */
174
+ setListenerPosition(x: number, y: number, z: number): void {
175
+ if (vec3Equals(this._listener.position, x, y, z)) return;
176
+ this._listener.position = [x, y, z];
177
+ if (this._engine) {
178
+ for (const [voiceId, entry] of this._voiceSpatialCache) {
179
+ if (!entry.explicitListener) {
180
+ entry.spatial.listener = this._listener;
181
+ this._engine.audioUpdateSoundSpatial(voiceId, entry.spatial);
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Updates listener orientation quaternion directly.
189
+ * Only propagates to voices bound to the global listener.
190
+ */
191
+ setListenerRotation(x: number, y: number, z: number, w: number): void {
192
+ if (quatEquals(this._listener.rotation, x, y, z, w)) return;
193
+ this._listener.rotation = [x, y, z, w];
194
+ if (this._engine) {
195
+ for (const [voiceId, entry] of this._voiceSpatialCache) {
196
+ if (!entry.explicitListener) {
197
+ entry.spatial.listener = this._listener;
198
+ this._engine.audioUpdateSoundSpatial(voiceId, entry.spatial);
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Updates emitter position of an active 3D voice directly with coalescing dirty check.
206
+ */
207
+ setVoiceEmitterPosition(voiceId: VoiceId, x: number, y: number, z: number): void {
208
+ const entry = this._voiceSpatialCache.get(voiceId);
209
+ if (!entry) {
210
+ const spatial: SpatialAudio = {
211
+ emitter: { position: [x, y, z] },
212
+ listener: this._listener,
213
+ config: { volume: 1.0 }
214
+ };
215
+ this._voiceSpatialCache.set(voiceId, { spatial, explicitListener: false });
216
+ if (this._engine) {
217
+ this._engine.audioUpdateSoundSpatial(voiceId, spatial);
218
+ }
219
+ return;
220
+ }
221
+
222
+ if (vec3Equals(entry.spatial.emitter.position, x, y, z)) {
223
+ return; // Identical coordinates: zero ABI calls issued
224
+ }
225
+
226
+ entry.spatial.emitter.position = [x, y, z];
227
+ if (this._engine) {
228
+ this._engine.audioUpdateSoundSpatial(voiceId, entry.spatial);
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Updates emitter rotation quaternion of an active 3D voice with coalescing dirty check.
234
+ */
235
+ setVoiceEmitterRotation(voiceId: VoiceId, x: number, y: number, z: number, w: number): void {
236
+ const entry = this._voiceSpatialCache.get(voiceId);
237
+ if (!entry) return;
238
+
239
+ if (quatEquals(entry.spatial.emitter.rotation, x, y, z, w)) {
240
+ return; // Identical orientation: zero ABI calls issued
241
+ }
242
+
243
+ entry.spatial.emitter.rotation = [x, y, z, w];
244
+ if (this._engine) {
245
+ this._engine.audioUpdateSoundSpatial(voiceId, entry.spatial);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Updates spatial positioning of an active sound voice.
251
+ */
252
+ updateSoundSpatial(voiceId: VoiceId, spatial: SpatialAudio): void {
253
+ this._voiceSpatialCache.set(voiceId, {
254
+ spatial,
255
+ explicitListener: !!spatial.listener
256
+ });
257
+ if (this._engine) {
258
+ this._engine.audioUpdateSoundSpatial(voiceId, spatial);
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Registers a natural termination completion listener for a voice.
264
+ * Multiple independent listeners are supported with cumulative execution and isolated unsubscribes.
265
+ */
266
+ onVoiceFinished(voiceId: VoiceId, callback: () => void): () => void {
267
+ let set = this._voiceFinishListeners.get(voiceId);
268
+ if (!set) {
269
+ set = new Set();
270
+ this._voiceFinishListeners.set(voiceId, set);
271
+ }
272
+ set.add(callback);
273
+ return () => {
274
+ set?.delete(callback);
275
+ if (set && set.size === 0) {
276
+ this._voiceFinishListeners.delete(voiceId);
277
+ }
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Handles voice completion (via engine callback or natural playback termination).
283
+ */
284
+ handleVoiceFinished(voiceId: VoiceId): void {
285
+ this._activeVoices.delete(voiceId);
286
+ this._voiceSpatialCache.delete(voiceId);
287
+ const listeners = this._voiceFinishListeners.get(voiceId);
288
+ if (listeners) {
289
+ for (const listener of [...listeners]) {
290
+ try {
291
+ listener();
292
+ } catch (err) {
293
+ console.error(`[AudioManager] Error in voice finish listener for voice ${voiceId}:`, err);
294
+ }
295
+ }
296
+ this._voiceFinishListeners.delete(voiceId);
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Pauses an active sound voice.
302
+ */
303
+ pauseSound(voiceId: VoiceId): void {
304
+ if (this._engine) {
305
+ this._engine.audioPauseSound(voiceId);
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Resumes a paused sound voice.
311
+ */
312
+ resumeSound(voiceId: VoiceId): void {
313
+ if (this._engine) {
314
+ this._engine.audioResumeSound(voiceId);
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Stops an active sound voice, unregistering spatial caches and firing finish handlers.
320
+ */
321
+ stopSound(voiceId: VoiceId): void {
322
+ if (this._engine) {
323
+ this._engine.audioStopSound(voiceId);
324
+ }
325
+ this.handleVoiceFinished(voiceId);
326
+ }
327
+
328
+ /**
329
+ * Updates the volume of an active sound voice.
330
+ */
331
+ setVolume(voiceId: VoiceId, volume: number): void {
332
+ if (this._engine) {
333
+ this._engine.audioSetVolume(voiceId, volume);
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Updates the panning of an active sound voice.
339
+ */
340
+ setPanning(voiceId: VoiceId, panning: number): void {
341
+ if (this._engine) {
342
+ this._engine.audioSetPanning(voiceId, panning);
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Returns the count of active cached spatial voices.
348
+ */
349
+ getActiveSpatialVoiceCount(): number {
350
+ return this._voiceSpatialCache.size;
351
+ }
352
+
353
+ /**
354
+ * Unloads all active sounds and resets all voices and spatial caches.
355
+ */
356
+ dispose(): void {
357
+ if (this._engine) {
358
+ for (const voiceId of this._activeVoices) {
359
+ this._engine.audioStopSound(voiceId);
360
+ }
361
+ for (const soundId of this._sounds) {
362
+ this._engine.audioUnloadSound(soundId);
363
+ }
364
+ }
365
+ this._sounds.clear();
366
+ this._activeVoices.clear();
367
+ this._voiceSpatialCache.clear();
368
+ this._voiceFinishListeners.clear();
369
+ this._engine = null;
370
+ }
371
+ }