@jgengine/shell 0.34.0 → 0.36.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/GamePlayerShell.js +2 -0
  3. package/dist/Shell3dPresentation.js +1 -1
  4. package/dist/audio/AudioComponents.js +7 -0
  5. package/dist/audio/audioEngine.d.ts +59 -0
  6. package/dist/audio/audioEngine.js +334 -15
  7. package/dist/audio/audioProbe.d.ts +10 -5
  8. package/dist/audio/audioProbe.js +6 -3
  9. package/dist/audio/occlusion.d.ts +11 -0
  10. package/dist/audio/occlusion.js +20 -0
  11. package/dist/play/index.d.ts +3 -0
  12. package/dist/play/index.js +3 -0
  13. package/dist/play/run.d.ts +27 -0
  14. package/dist/play/run.js +266 -0
  15. package/dist/play/script.d.ts +103 -0
  16. package/dist/play/script.js +52 -0
  17. package/dist/play/timeline.d.ts +43 -0
  18. package/dist/play/timeline.js +27 -0
  19. package/dist/pointer/pointerService.d.ts +2 -0
  20. package/dist/pointer/pointerService.js +4 -1
  21. package/dist/postfx/PostProcessing.d.ts +15 -1
  22. package/dist/postfx/PostProcessing.js +16 -6
  23. package/dist/postfx/postfxTierPolicy.d.ts +25 -0
  24. package/dist/postfx/postfxTierPolicy.js +17 -0
  25. package/dist/render/CascadedShadows.d.ts +4 -1
  26. package/dist/render/CascadedShadows.js +15 -7
  27. package/dist/render/CharacterCrowd.d.ts +11 -7
  28. package/dist/render/CharacterCrowd.js +103 -90
  29. package/dist/render/SceneLighting.js +14 -6
  30. package/dist/render/SceneWarmupGate.d.ts +6 -1
  31. package/dist/render/SceneWarmupGate.js +33 -7
  32. package/dist/render/SkinnedInstances.d.ts +16 -8
  33. package/dist/render/SkinnedInstances.js +215 -65
  34. package/dist/render/characterLod.d.ts +7 -0
  35. package/dist/render/characterLod.js +11 -0
  36. package/dist/render/crowdBatch.d.ts +45 -0
  37. package/dist/render/crowdBatch.js +68 -0
  38. package/dist/render/crowdRegistry.d.ts +55 -5
  39. package/dist/render/crowdRegistry.js +92 -9
  40. package/dist/render/shaderWarmup.d.ts +75 -18
  41. package/dist/render/shaderWarmup.js +221 -64
  42. package/dist/render/shadowCasters.d.ts +1 -0
  43. package/dist/render/shadowCasters.js +1 -1
  44. package/dist/render/shadowTiers.d.ts +36 -0
  45. package/dist/render/shadowTiers.js +50 -0
  46. package/dist/world/WorldScene.js +28 -11
  47. package/package.json +8 -4
  48. package/skills/jgengine/api.md +3 -3
  49. package/skills/jgengine-ui/api.md +79 -9
  50. package/skills/jgengine-ui/capabilities.md +7 -2
  51. package/skills/jgengine-world/api.md +18 -2
  52. package/skills/jgengine-world/capabilities.md +8 -0
@@ -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
- return mix.muted ? 0 : mix.gain;
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 = currentUserGain;
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(currentLowpass, MAX_LOOP_CUTOFF, nyquist);
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
- if (currentLowpass === writtenLowpass)
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
- if (currentUserGain === writtenGain)
579
- return;
580
- writtenGain = currentUserGain;
581
- if (userGainNode !== null)
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 mute. */
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 readout on `window.__jgengineAudio`, so a headless capture can
18
- * state a measured RMS instead of guessing from a screenshot. Reads only; it never plays or changes a bus.
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 and bus gains from a running page for capture and diagnostics
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;
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Publish the audio engine's own level readout on `window.__jgengineAudio`, so a headless capture can
3
- * state a measured RMS instead of guessing from a screenshot. Reads only; it never plays or changes a bus.
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 and bus gains from a running page for capture and diagnostics
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,3 @@
1
+ export { createPlayScript, PLAY_MOVE_ACTIONS, } from "./script.js";
2
+ export { playTimelineDigest, formatPlayTimelineEntry, } from "./timeline.js";
3
+ export { runPlayScript } 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;