@jgengine/shell 0.33.0 → 0.35.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/CHANGELOG.md +38 -0
- package/dist/GamePlayerShell.js +2 -0
- package/dist/Shell3dPresentation.js +1 -1
- package/dist/audio/AudioComponents.js +7 -0
- package/dist/audio/audioEngine.d.ts +59 -0
- package/dist/audio/audioEngine.js +334 -15
- package/dist/audio/audioProbe.d.ts +10 -5
- package/dist/audio/audioProbe.js +6 -3
- package/dist/audio/occlusion.d.ts +11 -0
- package/dist/audio/occlusion.js +20 -0
- package/dist/play/index.d.ts +3 -0
- package/dist/play/index.js +3 -0
- package/dist/play/run.d.ts +27 -0
- package/dist/play/run.js +266 -0
- package/dist/play/script.d.ts +103 -0
- package/dist/play/script.js +52 -0
- package/dist/play/timeline.d.ts +43 -0
- package/dist/play/timeline.js +27 -0
- package/dist/pointer/pointerService.d.ts +2 -0
- package/dist/pointer/pointerService.js +4 -1
- package/dist/postfx/PostProcessing.d.ts +15 -1
- package/dist/postfx/PostProcessing.js +16 -6
- package/dist/postfx/postfxTierPolicy.d.ts +25 -0
- package/dist/postfx/postfxTierPolicy.js +17 -0
- package/dist/render/CascadedShadows.d.ts +4 -1
- package/dist/render/CascadedShadows.js +15 -7
- package/dist/render/CharacterCrowd.d.ts +11 -7
- package/dist/render/CharacterCrowd.js +103 -90
- package/dist/render/SceneLighting.js +14 -6
- package/dist/render/SceneWarmupGate.d.ts +6 -1
- package/dist/render/SceneWarmupGate.js +33 -7
- package/dist/render/SkinnedInstances.d.ts +16 -8
- package/dist/render/SkinnedInstances.js +215 -65
- package/dist/render/characterLod.d.ts +7 -0
- package/dist/render/characterLod.js +11 -0
- package/dist/render/crowdBatch.d.ts +45 -0
- package/dist/render/crowdBatch.js +68 -0
- package/dist/render/crowdRegistry.d.ts +55 -5
- package/dist/render/crowdRegistry.js +92 -9
- package/dist/render/shaderWarmup.d.ts +75 -18
- package/dist/render/shaderWarmup.js +221 -64
- package/dist/render/shadowCasters.d.ts +1 -0
- package/dist/render/shadowCasters.js +1 -1
- package/dist/render/shadowTiers.d.ts +36 -0
- package/dist/render/shadowTiers.js +50 -0
- package/dist/world/WorldScene.js +28 -11
- package/package.json +8 -4
- package/skills/jgengine/api.md +3 -3
- package/skills/jgengine-ui/api.md +79 -9
- package/skills/jgengine-ui/capabilities.md +7 -2
- package/skills/jgengine-world/api.md +21 -3
- package/skills/jgengine-world/capabilities.md +8 -0
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { activeAmbienceZone, ambienceWeights } from "@jgengine/core/audio/ambience";
|
|
1
2
|
import { distance3, resolveEmitterGain } from "@jgengine/core/audio/audioFalloff";
|
|
2
3
|
import { dopplerRate } from "@jgengine/core/audio/doppler";
|
|
4
|
+
import { resolveOcclusionConfig } from "@jgengine/core/audio/occlusion";
|
|
3
5
|
import { patchDuration } from "@jgengine/core/audio/synth";
|
|
4
6
|
import { createVoiceAllocator } from "@jgengine/core/audio/voiceAllocator";
|
|
5
7
|
import { createDisposer } from "@jgengine/core/game/defineGame";
|
|
@@ -8,11 +10,16 @@ import { MusicDirector } from "./musicDirector.js";
|
|
|
8
10
|
import { createNoiseBuffer, realizeSynthPatch } from "./synthEngine.js";
|
|
9
11
|
/** setTargetAtTime time constant (~20 ms) for zipper-free live rate/gain ramps on retained loops (#1051). */
|
|
10
12
|
const LOOP_PARAM_SMOOTH_TC = 0.02;
|
|
13
|
+
/** Ambient beds ramp over ~80 ms so crossing a zone boundary never steps a gain. */
|
|
14
|
+
const BED_SMOOTH_TC = 0.08;
|
|
15
|
+
/** Ducking ramps over ~30 ms: fast enough to feel like a cut, slow enough not to click. */
|
|
16
|
+
const DUCK_SMOOTH_TC = 0.03;
|
|
11
17
|
const ZERO_VELOCITY = { x: 0, y: 0, z: 0 };
|
|
12
18
|
/** Cues held while the browser keeps the audio context suspended, before any user gesture. */
|
|
13
19
|
export const MAX_PENDING_CUES = 16;
|
|
14
20
|
function createNoopEngine() {
|
|
15
21
|
const level = { rms: 0, peak: 0 };
|
|
22
|
+
const stats = { tests: 0, blocked: 0, testMs: 0, perTestMs: 0, lastTest: null };
|
|
16
23
|
return {
|
|
17
24
|
setListenerPose: () => undefined,
|
|
18
25
|
setVoiceLimit: () => undefined,
|
|
@@ -20,6 +27,10 @@ function createNoopEngine() {
|
|
|
20
27
|
playOneShot: () => undefined,
|
|
21
28
|
playLoop: () => null,
|
|
22
29
|
playMusic: () => undefined,
|
|
30
|
+
setOcclusionSource: () => undefined,
|
|
31
|
+
occlusionStats: () => stats,
|
|
32
|
+
ambience: () => ({ zone: null, levels: [] }),
|
|
33
|
+
duck: () => () => undefined,
|
|
23
34
|
setBusGain: () => undefined,
|
|
24
35
|
setBusMute: () => undefined,
|
|
25
36
|
setMasterGain: () => undefined,
|
|
@@ -102,7 +113,8 @@ export function createAudioEngine(config = {}) {
|
|
|
102
113
|
return createNoopEngine();
|
|
103
114
|
}
|
|
104
115
|
const busGains = new Map();
|
|
105
|
-
// Authored gain survives a mute, so unmuting restores the player's volume exactly.
|
|
116
|
+
// Authored gain survives a mute, so unmuting restores the player's volume exactly. `duck` is a
|
|
117
|
+
// separate multiplier a cue applies for its own duration, so it never overwrites that volume.
|
|
106
118
|
const busMix = new Map();
|
|
107
119
|
let masterVolume = 1;
|
|
108
120
|
let masterMuted = false;
|
|
@@ -115,7 +127,7 @@ export function createAudioEngine(config = {}) {
|
|
|
115
127
|
function busMixOf(busId) {
|
|
116
128
|
let mix = busMix.get(busId);
|
|
117
129
|
if (mix === undefined) {
|
|
118
|
-
mix = { gain: busDefs[busId]?.gain ?? 1, muted: false };
|
|
130
|
+
mix = { gain: busDefs[busId]?.gain ?? 1, muted: false, duck: 0 };
|
|
119
131
|
busMix.set(busId, mix);
|
|
120
132
|
}
|
|
121
133
|
return mix;
|
|
@@ -124,7 +136,9 @@ export function createAudioEngine(config = {}) {
|
|
|
124
136
|
const mix = busMix.get(busId);
|
|
125
137
|
if (mix === undefined)
|
|
126
138
|
return busDefs[busId]?.gain ?? 1;
|
|
127
|
-
|
|
139
|
+
if (mix.muted)
|
|
140
|
+
return 0;
|
|
141
|
+
return mix.gain * (1 - mix.duck);
|
|
128
142
|
}
|
|
129
143
|
function busGainNode(busId) {
|
|
130
144
|
let node = busGains.get(busId);
|
|
@@ -147,6 +161,73 @@ export function createAudioEngine(config = {}) {
|
|
|
147
161
|
if (node !== undefined)
|
|
148
162
|
node.gain.value = busLevel(busId);
|
|
149
163
|
}
|
|
164
|
+
const duckHolds = new Map();
|
|
165
|
+
/** Recompute the strongest hold on one bus; release restores the player's gain by construction. */
|
|
166
|
+
function applyDuck(busId) {
|
|
167
|
+
const holds = duckHolds.get(busId);
|
|
168
|
+
let amount = 0;
|
|
169
|
+
if (holds !== undefined) {
|
|
170
|
+
for (const hold of holds)
|
|
171
|
+
amount = Math.max(amount, hold.amount);
|
|
172
|
+
}
|
|
173
|
+
const mix = busMixOf(busId);
|
|
174
|
+
mix.duck = Math.min(1, Math.max(0, amount));
|
|
175
|
+
const node = busGains.get(busId);
|
|
176
|
+
if (node === undefined)
|
|
177
|
+
return;
|
|
178
|
+
const level = busLevel(busId);
|
|
179
|
+
if (typeof node.gain.setTargetAtTime === "function") {
|
|
180
|
+
node.gain.setTargetAtTime(level, context.currentTime, DUCK_SMOOTH_TC);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
node.gain.value = level;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function duckBus(busId, options = {}) {
|
|
187
|
+
if (disposed)
|
|
188
|
+
return () => undefined;
|
|
189
|
+
const amount = Math.min(1, Math.max(0, options.amount ?? 0.6));
|
|
190
|
+
if (amount <= 0)
|
|
191
|
+
return () => undefined;
|
|
192
|
+
const hold = { amount, timer: undefined };
|
|
193
|
+
let holds = duckHolds.get(busId);
|
|
194
|
+
if (holds === undefined) {
|
|
195
|
+
holds = new Set();
|
|
196
|
+
duckHolds.set(busId, holds);
|
|
197
|
+
}
|
|
198
|
+
holds.add(hold);
|
|
199
|
+
applyDuck(busId);
|
|
200
|
+
let released = false;
|
|
201
|
+
const release = () => {
|
|
202
|
+
if (released)
|
|
203
|
+
return;
|
|
204
|
+
released = true;
|
|
205
|
+
if (hold.timer !== undefined)
|
|
206
|
+
clearTimeout(hold.timer);
|
|
207
|
+
const current = duckHolds.get(busId);
|
|
208
|
+
current?.delete(hold);
|
|
209
|
+
if (current !== undefined && current.size === 0)
|
|
210
|
+
duckHolds.delete(busId);
|
|
211
|
+
applyDuck(busId);
|
|
212
|
+
};
|
|
213
|
+
const seconds = options.seconds;
|
|
214
|
+
if (seconds !== undefined && Number.isFinite(seconds) && seconds > 0 && typeof setTimeout === "function") {
|
|
215
|
+
hold.timer = setTimeout(release, seconds * 1000);
|
|
216
|
+
hold.timer.unref?.();
|
|
217
|
+
}
|
|
218
|
+
return release;
|
|
219
|
+
}
|
|
220
|
+
/** A cue's own `duck` policy: hold the named buses for its duration, then release. */
|
|
221
|
+
function applyCueDuck(sound, durationSeconds) {
|
|
222
|
+
const policy = sound.duck;
|
|
223
|
+
if (policy === undefined)
|
|
224
|
+
return;
|
|
225
|
+
const seconds = policy.seconds !== undefined && Number.isFinite(policy.seconds) ? policy.seconds : durationSeconds;
|
|
226
|
+
const buses = policy.buses ?? ["music"];
|
|
227
|
+
for (const busId of buses) {
|
|
228
|
+
duckBus(busId, seconds > 0 ? { amount: policy.amount, seconds } : { amount: policy.amount });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
150
231
|
let noiseBuffer = null;
|
|
151
232
|
function sharedNoiseBuffer() {
|
|
152
233
|
if (noiseBuffer === null)
|
|
@@ -214,6 +295,139 @@ export function createAudioEngine(config = {}) {
|
|
|
214
295
|
setAudioParam(param, value);
|
|
215
296
|
return true;
|
|
216
297
|
}
|
|
298
|
+
const occlusionConfig = resolveOcclusionConfig(config.occlusion);
|
|
299
|
+
let occlusionBlocked = null;
|
|
300
|
+
let occlusionTestsLeft = occlusionConfig.maxTestsPerTick;
|
|
301
|
+
const occlusionStatsState = { tests: 0, blocked: 0, testMs: 0, lastTest: null };
|
|
302
|
+
const occlusionTargets = new Set();
|
|
303
|
+
function clockMs() {
|
|
304
|
+
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* One budgeted test for one retained loop. Skipped while the loop is too close (the listener is
|
|
308
|
+
* inside its own engine hum), too far, or re-tested inside the interval; once the tick's budget
|
|
309
|
+
* is spent every other loop simply stays on distance-only attenuation.
|
|
310
|
+
*/
|
|
311
|
+
function requestOcclusionTest(target) {
|
|
312
|
+
if (occlusionBlocked === null || occlusionConfig.maxTestsPerTick <= 0) {
|
|
313
|
+
target.set(0);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const distance = distance3(target, listenerPosition);
|
|
317
|
+
if (distance < 1 || distance > occlusionConfig.maxDistance) {
|
|
318
|
+
target.set(0);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const now = context.currentTime;
|
|
322
|
+
if (now - target.lastTest < occlusionConfig.intervalMs / 1000)
|
|
323
|
+
return;
|
|
324
|
+
if (occlusionTestsLeft <= 0) {
|
|
325
|
+
if (!target.tested)
|
|
326
|
+
target.set(0);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
occlusionTestsLeft -= 1;
|
|
330
|
+
target.lastTest = now;
|
|
331
|
+
target.tested = true;
|
|
332
|
+
const started = clockMs();
|
|
333
|
+
let blocked = false;
|
|
334
|
+
const from = { x: listenerPosition.x, y: listenerPosition.y, z: listenerPosition.z };
|
|
335
|
+
const to = { x: target.x, y: target.y, z: target.z };
|
|
336
|
+
try {
|
|
337
|
+
blocked = occlusionBlocked(from, to);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
blocked = false;
|
|
341
|
+
}
|
|
342
|
+
occlusionStatsState.testMs += clockMs() - started;
|
|
343
|
+
occlusionStatsState.tests += 1;
|
|
344
|
+
occlusionStatsState.lastTest = { from, to, blocked };
|
|
345
|
+
if (blocked)
|
|
346
|
+
occlusionStatsState.blocked += 1;
|
|
347
|
+
target.set(blocked ? 1 : 0);
|
|
348
|
+
}
|
|
349
|
+
function stepOcclusion() {
|
|
350
|
+
occlusionTestsLeft = occlusionConfig.maxTestsPerTick;
|
|
351
|
+
for (const target of occlusionTargets)
|
|
352
|
+
requestOcclusionTest(target);
|
|
353
|
+
}
|
|
354
|
+
const zones = config.zones ?? [];
|
|
355
|
+
const zoneBeds = new Map();
|
|
356
|
+
const ambienceScratch = [];
|
|
357
|
+
let ambienceActive = null;
|
|
358
|
+
let ambienceInitialized = false;
|
|
359
|
+
function zoneWeight(id) {
|
|
360
|
+
for (const entry of ambienceScratch) {
|
|
361
|
+
if (entry.id === id)
|
|
362
|
+
return entry.weight;
|
|
363
|
+
}
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
function ensureBed(zone, sound) {
|
|
367
|
+
const existing = zoneBeds.get(zone.id);
|
|
368
|
+
if (existing !== undefined)
|
|
369
|
+
return existing;
|
|
370
|
+
let bus;
|
|
371
|
+
try {
|
|
372
|
+
bus = busGainNode(sound.bus);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
const gain = context.createGain();
|
|
378
|
+
gain.gain.value = 0;
|
|
379
|
+
gain.connect(bus);
|
|
380
|
+
const bed = { gain, source: null, level: -1 };
|
|
381
|
+
zoneBeds.set(zone.id, bed);
|
|
382
|
+
const pending = sound.synth !== undefined
|
|
383
|
+
? renderSynthBuffer(zone.sound, sound.synth)
|
|
384
|
+
: sound.url !== undefined
|
|
385
|
+
? loadBuffer(sound.url)
|
|
386
|
+
: Promise.resolve(null);
|
|
387
|
+
void pending.then((buffer) => {
|
|
388
|
+
if (disposed || buffer === null || zoneBeds.get(zone.id) !== bed) {
|
|
389
|
+
gain.disconnect();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
// A looping AudioBufferSourceNode repeats sample-accurately, so the bed has no gap at the seam.
|
|
394
|
+
const source = context.createBufferSource();
|
|
395
|
+
source.buffer = buffer;
|
|
396
|
+
source.loop = true;
|
|
397
|
+
source.connect(gain);
|
|
398
|
+
source.start();
|
|
399
|
+
bed.source = source;
|
|
400
|
+
applyAmbience();
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
gain.disconnect();
|
|
404
|
+
}
|
|
405
|
+
}).catch(() => undefined);
|
|
406
|
+
return bed;
|
|
407
|
+
}
|
|
408
|
+
function applyAmbience() {
|
|
409
|
+
if (zones.length === 0)
|
|
410
|
+
return;
|
|
411
|
+
ambienceWeights(zones, listenerPosition.x, listenerPosition.y, listenerPosition.z, ambienceScratch);
|
|
412
|
+
ambienceActive = activeAmbienceZone(ambienceScratch);
|
|
413
|
+
for (const zone of zones) {
|
|
414
|
+
const weight = zoneWeight(zone.id);
|
|
415
|
+
const existing = zoneBeds.get(zone.id);
|
|
416
|
+
if (weight <= 0 && existing === undefined)
|
|
417
|
+
continue;
|
|
418
|
+
const sound = sounds[zone.sound];
|
|
419
|
+
if (sound === undefined)
|
|
420
|
+
continue;
|
|
421
|
+
const bed = existing ?? ensureBed(zone, sound);
|
|
422
|
+
if (bed === null)
|
|
423
|
+
continue;
|
|
424
|
+
const level = weight * (zone.gain ?? 1) * (sound.gain ?? 1);
|
|
425
|
+
if (level === bed.level)
|
|
426
|
+
continue;
|
|
427
|
+
bed.level = level;
|
|
428
|
+
bed.gain.gain.setTargetAtTime(level, context.currentTime, BED_SMOOTH_TC);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
217
431
|
// Browser autoplay: until the first user gesture the context stays suspended and a played cue
|
|
218
432
|
// would vanish. Hold a bounded FIFO of ids (preallocated slots) and flush it on resume.
|
|
219
433
|
const pendingCues = Array.from({ length: MAX_PENDING_CUES }, () => ({
|
|
@@ -336,6 +550,7 @@ export function createAudioEngine(config = {}) {
|
|
|
336
550
|
}
|
|
337
551
|
const currentPosition = { ...(position ?? listenerPosition) };
|
|
338
552
|
if (sound.synth !== undefined && !loop) {
|
|
553
|
+
applyCueDuck(sound, patchDuration(sound.synth));
|
|
339
554
|
let cueGain = null;
|
|
340
555
|
let panner = null;
|
|
341
556
|
let playback = null;
|
|
@@ -400,6 +615,7 @@ export function createAudioEngine(config = {}) {
|
|
|
400
615
|
let currentUserGain = 1;
|
|
401
616
|
let currentLowpass = MAX_LOOP_CUTOFF;
|
|
402
617
|
let currentHighpass = MIN_LOOP_CUTOFF;
|
|
618
|
+
let currentOcclusion = 0;
|
|
403
619
|
let currentVelocity = { x: 0, y: 0, z: 0 };
|
|
404
620
|
const dopplerFactor = loop ? sound.doppler ?? 0 : 0;
|
|
405
621
|
let sourceNode = null;
|
|
@@ -417,6 +633,24 @@ export function createAudioEngine(config = {}) {
|
|
|
417
633
|
let writtenGain = NaN;
|
|
418
634
|
let writtenLowpass = NaN;
|
|
419
635
|
let writtenHighpass = NaN;
|
|
636
|
+
function applyOcclusionAmount(amount) {
|
|
637
|
+
const next = Math.min(1, Math.max(0, Number.isFinite(amount) ? amount : 0));
|
|
638
|
+
if (next === currentOcclusion)
|
|
639
|
+
return;
|
|
640
|
+
currentOcclusion = next;
|
|
641
|
+
applyUserGain();
|
|
642
|
+
applyLowpass();
|
|
643
|
+
}
|
|
644
|
+
const occlusionTarget = {
|
|
645
|
+
x: initialPosition.x,
|
|
646
|
+
y: initialPosition.y,
|
|
647
|
+
z: initialPosition.z,
|
|
648
|
+
lastTest: -Infinity,
|
|
649
|
+
tested: false,
|
|
650
|
+
set: applyOcclusionAmount,
|
|
651
|
+
};
|
|
652
|
+
if (loop)
|
|
653
|
+
occlusionTargets.add(occlusionTarget);
|
|
420
654
|
function effectiveRate() {
|
|
421
655
|
if (dopplerFactor === 0)
|
|
422
656
|
return currentRate;
|
|
@@ -432,6 +666,34 @@ export function createAudioEngine(config = {}) {
|
|
|
432
666
|
writtenRate = rate;
|
|
433
667
|
sourceNode.playbackRate.setTargetAtTime(rate, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
434
668
|
}
|
|
669
|
+
/** Player/loop gain composed with occlusion; the game's own gain is never overwritten by it. */
|
|
670
|
+
function effectiveUserGain() {
|
|
671
|
+
const blocked = occlusionConfig.attenuation + (1 - occlusionConfig.attenuation) * (1 - currentOcclusion);
|
|
672
|
+
return clampLoopGain(currentUserGain) * blocked;
|
|
673
|
+
}
|
|
674
|
+
function applyUserGain() {
|
|
675
|
+
const gain = effectiveUserGain();
|
|
676
|
+
if (gain === writtenGain)
|
|
677
|
+
return;
|
|
678
|
+
writtenGain = gain;
|
|
679
|
+
if (userGainNode !== null)
|
|
680
|
+
userGainNode.gain.setTargetAtTime(gain, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
681
|
+
}
|
|
682
|
+
/** Darkest of the game's own cutoff and the occlusion floor, blended by the blocked amount. */
|
|
683
|
+
function effectiveLowpass() {
|
|
684
|
+
if (currentOcclusion <= 0)
|
|
685
|
+
return currentLowpass;
|
|
686
|
+
const floor = Math.min(currentLowpass, occlusionConfig.lowpassHz);
|
|
687
|
+
return currentLowpass - (currentLowpass - floor) * currentOcclusion;
|
|
688
|
+
}
|
|
689
|
+
function applyLowpass() {
|
|
690
|
+
const cutoff = clampLoopCutoff(effectiveLowpass(), MAX_LOOP_CUTOFF, nyquist);
|
|
691
|
+
if (cutoff === writtenLowpass)
|
|
692
|
+
return;
|
|
693
|
+
writtenLowpass = cutoff;
|
|
694
|
+
if (lowpassNode !== null)
|
|
695
|
+
lowpassNode.frequency.setTargetAtTime(cutoff, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
696
|
+
}
|
|
435
697
|
function updateFalloff() {
|
|
436
698
|
if (falloffGain !== null && spatial === undefined) {
|
|
437
699
|
const gain = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
|
|
@@ -458,6 +720,7 @@ export function createAudioEngine(config = {}) {
|
|
|
458
720
|
applyRate();
|
|
459
721
|
}
|
|
460
722
|
disposer.onDispose(() => {
|
|
723
|
+
occlusionTargets.delete(occlusionTarget);
|
|
461
724
|
if (sourceNode !== null)
|
|
462
725
|
sourceNode.onended = null;
|
|
463
726
|
try {
|
|
@@ -485,6 +748,8 @@ export function createAudioEngine(config = {}) {
|
|
|
485
748
|
handle.stop();
|
|
486
749
|
return;
|
|
487
750
|
}
|
|
751
|
+
if (!loop)
|
|
752
|
+
applyCueDuck(sound, buffer.duration);
|
|
488
753
|
const src = context.createBufferSource();
|
|
489
754
|
sourceNode = src;
|
|
490
755
|
src.buffer = buffer;
|
|
@@ -494,7 +759,7 @@ export function createAudioEngine(config = {}) {
|
|
|
494
759
|
// Spatial panners own distance attenuation; the scalar stage keeps only authored gain.
|
|
495
760
|
const uGain = context.createGain();
|
|
496
761
|
userGainNode = uGain;
|
|
497
|
-
writtenGain =
|
|
762
|
+
writtenGain = effectiveUserGain();
|
|
498
763
|
uGain.gain.value = writtenGain;
|
|
499
764
|
const fGain = context.createGain();
|
|
500
765
|
falloffGain = fGain;
|
|
@@ -504,7 +769,7 @@ export function createAudioEngine(config = {}) {
|
|
|
504
769
|
const lp = context.createBiquadFilter();
|
|
505
770
|
lowpassNode = lp;
|
|
506
771
|
lp.type = "lowpass";
|
|
507
|
-
writtenLowpass = clampLoopCutoff(
|
|
772
|
+
writtenLowpass = clampLoopCutoff(effectiveLowpass(), MAX_LOOP_CUTOFF, nyquist);
|
|
508
773
|
lp.frequency.value = writtenLowpass;
|
|
509
774
|
const hp = context.createBiquadFilter();
|
|
510
775
|
highpassNode = hp;
|
|
@@ -536,6 +801,8 @@ export function createAudioEngine(config = {}) {
|
|
|
536
801
|
src.start();
|
|
537
802
|
}).catch(() => handle.stop());
|
|
538
803
|
activeSpatialUpdaters.add(updateFalloff);
|
|
804
|
+
if (loop)
|
|
805
|
+
requestOcclusionTest(occlusionTarget);
|
|
539
806
|
const handle = {
|
|
540
807
|
isPlaying: () => !stopped,
|
|
541
808
|
setPosition(next) {
|
|
@@ -544,7 +811,12 @@ export function createAudioEngine(config = {}) {
|
|
|
544
811
|
currentPosition.x = next.x;
|
|
545
812
|
currentPosition.y = next.y;
|
|
546
813
|
currentPosition.z = next.z;
|
|
814
|
+
occlusionTarget.x = next.x;
|
|
815
|
+
occlusionTarget.y = next.y;
|
|
816
|
+
occlusionTarget.z = next.z;
|
|
547
817
|
updateFalloff();
|
|
818
|
+
if (loop)
|
|
819
|
+
requestOcclusionTest(occlusionTarget);
|
|
548
820
|
},
|
|
549
821
|
setRate(rate) {
|
|
550
822
|
currentRate = clampLoopRate(rate);
|
|
@@ -552,11 +824,7 @@ export function createAudioEngine(config = {}) {
|
|
|
552
824
|
},
|
|
553
825
|
setLowpass(hz) {
|
|
554
826
|
currentLowpass = clampLoopCutoff(hz, MAX_LOOP_CUTOFF, nyquist);
|
|
555
|
-
|
|
556
|
-
return;
|
|
557
|
-
writtenLowpass = currentLowpass;
|
|
558
|
-
if (lowpassNode !== null)
|
|
559
|
-
lowpassNode.frequency.setTargetAtTime(currentLowpass, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
827
|
+
applyLowpass();
|
|
560
828
|
},
|
|
561
829
|
setHighpass(hz) {
|
|
562
830
|
currentHighpass = clampLoopCutoff(hz, MIN_LOOP_CUTOFF, nyquist);
|
|
@@ -575,11 +843,10 @@ export function createAudioEngine(config = {}) {
|
|
|
575
843
|
},
|
|
576
844
|
setGain(gain) {
|
|
577
845
|
currentUserGain = clampLoopGain(gain);
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
userGainNode.gain.setTargetAtTime(currentUserGain, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
846
|
+
applyUserGain();
|
|
847
|
+
},
|
|
848
|
+
setOcclusion(amount) {
|
|
849
|
+
applyOcclusionAmount(amount);
|
|
583
850
|
},
|
|
584
851
|
stop() {
|
|
585
852
|
if (stopped)
|
|
@@ -605,6 +872,9 @@ export function createAudioEngine(config = {}) {
|
|
|
605
872
|
if (disposed)
|
|
606
873
|
return;
|
|
607
874
|
const pose = "position" in position ? position : { position, forward: { x: 0, y: 0, z: -1 }, up: { x: 0, y: 1, z: 0 } };
|
|
875
|
+
const positionChanged = listenerPosition.x !== pose.position.x ||
|
|
876
|
+
listenerPosition.y !== pose.position.y ||
|
|
877
|
+
listenerPosition.z !== pose.position.z;
|
|
608
878
|
listenerPosition.x = pose.position.x;
|
|
609
879
|
listenerPosition.y = pose.position.y;
|
|
610
880
|
listenerPosition.z = pose.position.z;
|
|
@@ -613,6 +883,10 @@ export function createAudioEngine(config = {}) {
|
|
|
613
883
|
listenerVelocity.y = velocity.y;
|
|
614
884
|
listenerVelocity.z = velocity.z;
|
|
615
885
|
const listener = context.listener;
|
|
886
|
+
if (positionChanged || !ambienceInitialized) {
|
|
887
|
+
ambienceInitialized = true;
|
|
888
|
+
applyAmbience();
|
|
889
|
+
}
|
|
616
890
|
let listenerMoved = writeChanged(listenerWritten, 0, listener.positionX, pose.position.x);
|
|
617
891
|
listenerMoved = writeChanged(listenerWritten, 1, listener.positionY, pose.position.y) || listenerMoved;
|
|
618
892
|
listenerMoved = writeChanged(listenerWritten, 2, listener.positionZ, pose.position.z) || listenerMoved;
|
|
@@ -626,6 +900,8 @@ export function createAudioEngine(config = {}) {
|
|
|
626
900
|
let velocityChanged = writeChanged(listenerVelocityWritten, 0, undefined, velocity.x);
|
|
627
901
|
velocityChanged = writeChanged(listenerVelocityWritten, 1, undefined, velocity.y) || velocityChanged;
|
|
628
902
|
velocityChanged = writeChanged(listenerVelocityWritten, 2, undefined, velocity.z) || velocityChanged;
|
|
903
|
+
if (positionChanged)
|
|
904
|
+
stepOcclusion();
|
|
629
905
|
if (!listenerMoved && !velocityChanged)
|
|
630
906
|
return;
|
|
631
907
|
for (const updateGain of activeSpatialUpdaters)
|
|
@@ -646,6 +922,31 @@ export function createAudioEngine(config = {}) {
|
|
|
646
922
|
void context.resume().catch(() => undefined);
|
|
647
923
|
musicDirector()?.crossfadeTo(themeId, options);
|
|
648
924
|
},
|
|
925
|
+
setOcclusionSource(blocked) {
|
|
926
|
+
if (disposed)
|
|
927
|
+
return;
|
|
928
|
+
occlusionBlocked = blocked;
|
|
929
|
+
if (blocked === null) {
|
|
930
|
+
for (const target of occlusionTargets)
|
|
931
|
+
target.set(0);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
stepOcclusion();
|
|
935
|
+
},
|
|
936
|
+
occlusionStats() {
|
|
937
|
+
const { tests, blocked, testMs, lastTest } = occlusionStatsState;
|
|
938
|
+
return { tests, blocked, testMs, perTestMs: tests > 0 ? testMs / tests : 0, lastTest };
|
|
939
|
+
},
|
|
940
|
+
ambience() {
|
|
941
|
+
const levels = [];
|
|
942
|
+
for (const zone of zones) {
|
|
943
|
+
const weight = zoneWeight(zone.id);
|
|
944
|
+
if (weight > 0)
|
|
945
|
+
levels.push({ id: zone.id, level: weight * (zone.gain ?? 1) });
|
|
946
|
+
}
|
|
947
|
+
return { zone: ambienceActive, levels };
|
|
948
|
+
},
|
|
949
|
+
duck: duckBus,
|
|
649
950
|
setBusGain(busId, gain) {
|
|
650
951
|
if (disposed)
|
|
651
952
|
return;
|
|
@@ -688,6 +989,24 @@ export function createAudioEngine(config = {}) {
|
|
|
688
989
|
for (const stop of [...activeVoices.values()])
|
|
689
990
|
stop();
|
|
690
991
|
activeSpatialUpdaters.clear();
|
|
992
|
+
occlusionTargets.clear();
|
|
993
|
+
for (const bed of zoneBeds.values()) {
|
|
994
|
+
try {
|
|
995
|
+
bed.source?.stop();
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
}
|
|
999
|
+
bed.source?.disconnect();
|
|
1000
|
+
bed.gain.disconnect();
|
|
1001
|
+
}
|
|
1002
|
+
zoneBeds.clear();
|
|
1003
|
+
for (const holds of duckHolds.values()) {
|
|
1004
|
+
for (const hold of holds) {
|
|
1005
|
+
if (hold.timer !== undefined)
|
|
1006
|
+
clearTimeout(hold.timer);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
duckHolds.clear();
|
|
691
1010
|
director?.dispose();
|
|
692
1011
|
for (const node of busGains.values())
|
|
693
1012
|
node.disconnect();
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import type { AudioEngine, AudioLevel } from "./audioEngine.js";
|
|
1
|
+
import type { AudioAmbienceState, AudioEngine, AudioLevel, AudioOcclusionStats } from "./audioEngine.js";
|
|
2
2
|
/** The read-only handle a capture script or devtools uses to prove the mix is producing sound. */
|
|
3
3
|
export interface AudioProbe {
|
|
4
4
|
/** Master RMS and peak since the last read, `0..1`. Non-zero RMS means audio reached the output. */
|
|
5
5
|
measure(): AudioLevel;
|
|
6
6
|
/** Master gain after volume and mute. */
|
|
7
7
|
masterLevel(): number;
|
|
8
|
-
/** One bus's effective gain after its volume and
|
|
8
|
+
/** One bus's effective gain after its volume, mute and any active duck. */
|
|
9
9
|
busLevel(busId: string): number;
|
|
10
|
+
/** Current zone mix, so a capture can state which zone bed is audible and at what level. */
|
|
11
|
+
ambience(): AudioAmbienceState;
|
|
12
|
+
/** Cumulative geometry-occlusion cost, so a capture can state the measured per-test price. */
|
|
13
|
+
occlusion(): AudioOcclusionStats;
|
|
10
14
|
}
|
|
11
15
|
declare global {
|
|
12
16
|
interface Window {
|
|
@@ -14,9 +18,10 @@ declare global {
|
|
|
14
18
|
}
|
|
15
19
|
}
|
|
16
20
|
/**
|
|
17
|
-
* Publish the audio engine's own level
|
|
18
|
-
*
|
|
21
|
+
* Publish the audio engine's own level, ambience and occlusion readouts on `window.__jgengineAudio`,
|
|
22
|
+
* so a headless capture can state measured values instead of guessing from a screenshot. Reads only;
|
|
23
|
+
* it never plays or changes a bus.
|
|
19
24
|
*
|
|
20
|
-
* @capability audio-probe read the live master RMS/peak
|
|
25
|
+
* @capability audio-probe read the live master RMS/peak, bus gains, ambience zone mix and occlusion cost from a running page for capture and diagnostics
|
|
21
26
|
*/
|
|
22
27
|
export declare function installAudioProbe(engine: AudioEngine): () => void;
|
package/dist/audio/audioProbe.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Publish the audio engine's own level
|
|
3
|
-
*
|
|
2
|
+
* Publish the audio engine's own level, ambience and occlusion readouts on `window.__jgengineAudio`,
|
|
3
|
+
* so a headless capture can state measured values instead of guessing from a screenshot. Reads only;
|
|
4
|
+
* it never plays or changes a bus.
|
|
4
5
|
*
|
|
5
|
-
* @capability audio-probe read the live master RMS/peak
|
|
6
|
+
* @capability audio-probe read the live master RMS/peak, bus gains, ambience zone mix and occlusion cost from a running page for capture and diagnostics
|
|
6
7
|
*/
|
|
7
8
|
export function installAudioProbe(engine) {
|
|
8
9
|
if (typeof window === "undefined")
|
|
@@ -11,6 +12,8 @@ export function installAudioProbe(engine) {
|
|
|
11
12
|
measure: () => engine.measure(),
|
|
12
13
|
masterLevel: () => engine.masterLevel(),
|
|
13
14
|
busLevel: (busId) => engine.busLevel(busId),
|
|
15
|
+
ambience: () => engine.ambience(),
|
|
16
|
+
occlusion: () => engine.occlusionStats(),
|
|
14
17
|
};
|
|
15
18
|
window.__jgengineAudio = probe;
|
|
16
19
|
return () => {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SceneRaycastHit, SceneRaycastInput } from "@jgengine/core/scene/sceneRaycast";
|
|
2
|
+
import type { Vec3 } from "./audioEngine.js";
|
|
3
|
+
/** The narrow slice of the scene a line-of-hearing test needs. */
|
|
4
|
+
export interface BlockingRaycastSource {
|
|
5
|
+
raycast(input: SceneRaycastInput): SceneRaycastHit | null;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Whether blocking world geometry (objects and wall segments, never creatures or terrain) lies
|
|
9
|
+
* between the listener and an emitter. One scene query per call; the engine owns how often it runs.
|
|
10
|
+
*/
|
|
11
|
+
export declare function sceneLineBlocked(source: BlockingRaycastSource, from: Vec3, to: Vec3): boolean;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether blocking world geometry (objects and wall segments, never creatures or terrain) lies
|
|
3
|
+
* between the listener and an emitter. One scene query per call; the engine owns how often it runs.
|
|
4
|
+
*/
|
|
5
|
+
export function sceneLineBlocked(source, from, to) {
|
|
6
|
+
const dx = to.x - from.x;
|
|
7
|
+
const dy = to.y - from.y;
|
|
8
|
+
const dz = to.z - from.z;
|
|
9
|
+
const length = Math.hypot(dx, dy, dz);
|
|
10
|
+
if (!(length > 0.001))
|
|
11
|
+
return false;
|
|
12
|
+
const hit = source.raycast({
|
|
13
|
+
origin: [from.x, from.y, from.z],
|
|
14
|
+
direction: [dx / length, dy / length, dz / length],
|
|
15
|
+
maxDistance: length - 0.001,
|
|
16
|
+
filter: { entities: false, terrain: false },
|
|
17
|
+
accept: (candidate) => candidate.blocks,
|
|
18
|
+
});
|
|
19
|
+
return hit !== null;
|
|
20
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { createPlayScript, PLAY_MOVE_ACTIONS, type PlayAimStep, type PlayCommandStep, type PlayExpectation, type PlayFireStep, type PlayMove, type PlayMoveStep, type PlayScript, type PlayScriptSpec, type PlayStep, type PlayUseStep, type PlayValue, type PlayWaitStep, } from "./script.js";
|
|
2
|
+
export { playTimelineDigest, formatPlayTimelineEntry, type PlayExpectationResult, type PlayRunResult, type PlayTimelineEntry, type PlayTimelineKind, } from "./timeline.js";
|
|
3
|
+
export { runPlayScript, type PlayRunOptions } from "./run.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { GameContext } from "@jgengine/core/runtime/gameContext";
|
|
2
|
+
import type { PlayableGame } from "../registry.js";
|
|
3
|
+
import { type PlayScript, type PlayScriptSpec } from "./script.js";
|
|
4
|
+
import { type PlayRunResult } from "./timeline.js";
|
|
5
|
+
/** Options for one scripted play run. */
|
|
6
|
+
export interface PlayRunOptions {
|
|
7
|
+
/** Local player id the script acts as. Default `"player"`. */
|
|
8
|
+
userId?: string;
|
|
9
|
+
/** Override the script's fixed tick length, in seconds. */
|
|
10
|
+
dt?: number;
|
|
11
|
+
/** Stop recording world events after this many rows so a long run stays readable. Default 1000. */
|
|
12
|
+
maxEvents?: number;
|
|
13
|
+
/** Called after `setup` and after every tick, for a script that wants to watch the run (progress bars, debug output). */
|
|
14
|
+
onTick?: (tick: number, ctx: GameContext) => void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Play a real game without a browser: boot its `PlayableGame` through the headless runner, apply a
|
|
18
|
+
* tick-based {@link PlayScript}, and return what happened plus whether the script's assertions held.
|
|
19
|
+
*
|
|
20
|
+
* Runs the shell's own per-tick pipeline, not an imitation of it — held movement actions reach the walk
|
|
21
|
+
* controller, a held `fire` (or any game action) resolves to its bound command on the press tick and on
|
|
22
|
+
* its repeat cadence, `interact` picks the active proximity prompt, and a hotbar press activates the
|
|
23
|
+
* slot. So a script proves the same code path a player's hands prove, in seconds.
|
|
24
|
+
*
|
|
25
|
+
* @capability play-script play a real game from a deterministic script — spawn, move, aim, fire, command, and assert world state with no browser and no wall clock
|
|
26
|
+
*/
|
|
27
|
+
export declare function runPlayScript(playable: PlayableGame, spec: PlayScript | PlayScriptSpec, options?: PlayRunOptions): PlayRunResult;
|