@galfus/engine 0.0.0-stage → 0.31.0-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.
- package/README.md +190 -2
- package/package.json +59 -4
- package/src/anchors/cache.ts +101 -0
- package/src/anchors/component.ts +123 -0
- package/src/anchors/hierarchy.ts +192 -0
- package/src/anchors/index.ts +8 -0
- package/src/anchors/solver.ts +247 -0
- package/src/anchors/strategy.ts +142 -0
- package/src/animation/blending.ts +163 -0
- package/src/animation/clip.ts +132 -0
- package/src/animation/coordinator.ts +568 -0
- package/src/animation/index.ts +7 -0
- package/src/animation/native-targets.ts +37 -0
- package/src/animation/player.ts +287 -0
- package/src/audio/index.ts +4 -0
- package/src/audio/manager.ts +371 -0
- package/src/collision/components.ts +211 -0
- package/src/collision/index.ts +16 -0
- package/src/collision/query2d.ts +167 -0
- package/src/collision/query3d.ts +170 -0
- package/src/ecs/component.ts +130 -0
- package/src/ecs/entity.ts +75 -0
- package/src/ecs/index.ts +9 -0
- package/src/ecs/intent.ts +162 -0
- package/src/ecs/metadata.ts +44 -0
- package/src/ecs/system.ts +224 -0
- package/src/ecs/world.ts +291 -0
- package/src/index.ts +19 -0
- package/src/interaction/focus.ts +327 -0
- package/src/interaction/index.ts +9 -0
- package/src/interaction/listener.ts +58 -0
- package/src/interaction/snapshot2d.ts +210 -0
- package/src/interaction/snapshot3d.ts +214 -0
- package/src/interaction/system.ts +544 -0
- package/src/interaction/types.ts +27 -0
- package/src/math/common.ts +67 -0
- package/src/math/index.ts +17 -0
- package/src/math/mat2d.ts +320 -0
- package/src/math/mat4.ts +576 -0
- package/src/math/quat.ts +438 -0
- package/src/math/vec2.ts +161 -0
- package/src/math/vec3.ts +242 -0
- package/src/math/vec4.ts +171 -0
- package/src/multiverse/coordinator.ts +540 -0
- package/src/multiverse/index.ts +13 -0
- package/src/multiverse/presentation-graph.ts +306 -0
- package/src/multiverse/presentation-links.ts +134 -0
- package/src/multiverse/realm.ts +49 -0
- package/src/platform/index.ts +2 -0
- package/src/platform/platform.ts +149 -0
- package/src/platform/window.ts +181 -0
- package/src/realm2d/components.ts +254 -0
- package/src/realm2d/index.ts +12 -0
- package/src/realm2d/presets/index.ts +36 -0
- package/src/realm2d/realm.ts +522 -0
- package/src/realm2d/sync.ts +284 -0
- package/src/realm2d/utils/index.ts +32 -0
- package/src/realm3d/components.ts +341 -0
- package/src/realm3d/geometry.ts +224 -0
- package/src/realm3d/index.ts +12 -0
- package/src/realm3d/presets/index.ts +67 -0
- package/src/realm3d/realm.ts +588 -0
- package/src/realm3d/resource-ids.ts +62 -0
- package/src/realm3d/skinning.ts +128 -0
- package/src/realm3d/sync.ts +442 -0
- package/src/realm3d/utils/index.ts +26 -0
- package/src/resources/index.ts +4 -0
- package/src/resources/manager.ts +92 -0
- package/src/runtime.ts +588 -0
- package/src/spatial/dirty_registry.ts +217 -0
- package/src/spatial/flags.ts +14 -0
- package/src/spatial/index.ts +6 -0
- package/src/spatial/metrics.ts +124 -0
- package/src/text/editor.ts +224 -0
- package/src/text/encoding.ts +38 -0
- package/src/text/index.ts +13 -0
- package/src/text/input.ts +160 -0
- package/src/text/keyboard.ts +50 -0
- package/src/text/svg-decorations.ts +147 -0
- 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,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
|
+
}
|