@jgengine/shell 0.19.0 → 0.21.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 (203) hide show
  1. package/CHANGELOG.md +346 -0
  2. package/dist/GameHost.d.ts +12 -1
  3. package/dist/GameHost.js +14 -1
  4. package/dist/GamePlayerShell.js +113 -15
  5. package/dist/Shell3dPresentation.d.ts +2 -2
  6. package/dist/Shell3dPresentation.js +49 -7
  7. package/dist/ShellChrome.d.ts +2 -1
  8. package/dist/ShellChrome.js +22 -6
  9. package/dist/ShellHudPresentation.d.ts +11 -1
  10. package/dist/ShellHudPresentation.js +20 -6
  11. package/dist/audio/audioEngine.d.ts +16 -0
  12. package/dist/audio/audioEngine.js +178 -89
  13. package/dist/audio/audioWire.js +2 -2
  14. package/dist/audio/synthEngine.d.ts +7 -1
  15. package/dist/audio/synthEngine.js +103 -38
  16. package/dist/camera/GameCameraRig.js +2 -1
  17. package/dist/camera/GameFirstPersonCamera.d.ts +2 -2
  18. package/dist/camera/GameFirstPersonCamera.js +17 -13
  19. package/dist/camera/cameraRigs.js +8 -2
  20. package/dist/camera/chaseHeading.d.ts +9 -0
  21. package/dist/camera/chaseHeading.js +10 -0
  22. package/dist/camera/customMuzzle.d.ts +14 -0
  23. package/dist/camera/customMuzzle.js +68 -0
  24. package/dist/camera/index.d.ts +1 -0
  25. package/dist/camera/index.js +1 -0
  26. package/dist/camera/rigResolve.d.ts +3 -0
  27. package/dist/camera/rigResolve.js +11 -0
  28. package/dist/combat/ProjectileModels.d.ts +31 -0
  29. package/dist/combat/ProjectileModels.js +70 -0
  30. package/dist/commandSink.d.ts +1 -2
  31. package/dist/commandSink.js +3 -2
  32. package/dist/defineGame.d.ts +12 -5
  33. package/dist/defineGame.js +24 -9
  34. package/dist/devtools/DevtoolsOverlay.d.ts +2 -69
  35. package/dist/devtools/DevtoolsOverlay.js +14 -168
  36. package/dist/devtools/DevtoolsRuntime.d.ts +7 -0
  37. package/dist/devtools/DevtoolsRuntime.js +61 -0
  38. package/dist/devtools/agentBridge.js +1 -1
  39. package/dist/devtools/devtoolsReports.d.ts +68 -0
  40. package/dist/devtools/devtoolsReports.js +100 -0
  41. package/dist/devtools/latencyInstrumentation.d.ts +3 -0
  42. package/dist/devtools/latencyInstrumentation.js +11 -0
  43. package/dist/devtools/playtestProbe.d.ts +4 -0
  44. package/dist/devtools/playtestProbe.js +22 -0
  45. package/dist/drivers/FrameDriver.js +25 -21
  46. package/dist/drivers/HudOnlyDriver.d.ts +6 -1
  47. package/dist/drivers/HudOnlyDriver.js +32 -14
  48. package/dist/environment/EnvironmentScene.js +11 -10
  49. package/dist/inputSink.d.ts +6 -6
  50. package/dist/inputSink.js +60 -21
  51. package/dist/lazyShell3dPresentation.d.ts +9 -0
  52. package/dist/lazyShell3dPresentation.js +24 -0
  53. package/dist/materialOverride.d.ts +42 -0
  54. package/dist/materialOverride.js +95 -36
  55. package/dist/multiplayer.d.ts +33 -3
  56. package/dist/multiplayer.js +105 -22
  57. package/dist/pointer/pointerService.d.ts +2 -0
  58. package/dist/pointer/pointerService.js +12 -2
  59. package/dist/postfx/PostProcessing.d.ts +10 -0
  60. package/dist/postfx/PostProcessing.js +9 -41
  61. package/dist/postfx/gradeShader.d.ts +1 -1
  62. package/dist/postfx/gradeShader.js +30 -2
  63. package/dist/postfx/shadowPrepass.d.ts +3 -0
  64. package/dist/postfx/shadowPrepass.js +18 -0
  65. package/dist/presentationRecovery.d.ts +75 -0
  66. package/dist/presentationRecovery.js +204 -0
  67. package/dist/presentationRecovery3d.d.ts +4 -0
  68. package/dist/presentationRecovery3d.js +32 -0
  69. package/dist/presentationResolve.d.ts +8 -1
  70. package/dist/presentationResolve.js +5 -2
  71. package/dist/render/CascadedShadows.d.ts +7 -2
  72. package/dist/render/CascadedShadows.js +98 -20
  73. package/dist/render/EnvironmentLighting.js +74 -35
  74. package/dist/render/HairCards.d.ts +7 -0
  75. package/dist/render/HairCards.js +23 -0
  76. package/dist/render/SceneModels.js +51 -77
  77. package/dist/render/authoredSurfaceMaterial.d.ts +43 -0
  78. package/dist/render/authoredSurfaceMaterial.js +108 -0
  79. package/dist/render/materialAppearanceSignals.d.ts +23 -0
  80. package/dist/render/materialAppearanceSignals.js +58 -0
  81. package/dist/render/materialAsset.d.ts +98 -0
  82. package/dist/render/materialAsset.js +454 -0
  83. package/dist/render/measureBounds.js +16 -3
  84. package/dist/render/measureCollisionMesh.js +8 -1
  85. package/dist/render/modelAssets.d.ts +8 -0
  86. package/dist/render/modelAssets.js +43 -0
  87. package/dist/render/modelBindPose.d.ts +5 -0
  88. package/dist/render/modelBindPose.js +35 -0
  89. package/dist/render/modelLoad.d.ts +3 -0
  90. package/dist/render/modelLoad.js +9 -0
  91. package/dist/render/modelNodeVisibility.d.ts +6 -0
  92. package/dist/render/modelNodeVisibility.js +18 -0
  93. package/dist/render/modelRender.d.ts +18 -1
  94. package/dist/render/modelRender.js +81 -3
  95. package/dist/render/projectedOverdraw.d.ts +35 -0
  96. package/dist/render/projectedOverdraw.js +204 -0
  97. package/dist/render/resolveModel.d.ts +12 -2
  98. package/dist/render/resolveModel.js +21 -4
  99. package/dist/render/rigNode.d.ts +26 -0
  100. package/dist/render/rigNode.js +32 -0
  101. package/dist/render/spriteRender.d.ts +4 -0
  102. package/dist/render/spriteRender.js +5 -0
  103. package/dist/render/staticShapeInstances.d.ts +34 -0
  104. package/dist/render/staticShapeInstances.js +78 -0
  105. package/dist/render/useFootIk.js +76 -11
  106. package/dist/render/useModelAnimation.d.ts +28 -2
  107. package/dist/render/useModelAnimation.js +194 -69
  108. package/dist/render/useModelInstance.d.ts +25 -0
  109. package/dist/render/useModelInstance.js +43 -0
  110. package/dist/scatter/scatterModels.d.ts +3 -2
  111. package/dist/scatter/scatterModels.js +15 -7
  112. package/dist/scene/AuthoredScene.d.ts +14 -6
  113. package/dist/scene/AuthoredScene.js +30 -9
  114. package/dist/scene/GeneratedAssetRenderer.js +11 -42
  115. package/dist/settings/SettingsMenu.js +11 -18
  116. package/dist/settings/SettingsRuntime.d.ts +5 -0
  117. package/dist/settings/SettingsRuntime.js +29 -2
  118. package/dist/shellControlSuspension.d.ts +18 -0
  119. package/dist/shellControlSuspension.js +28 -0
  120. package/dist/shellInputPublication.d.ts +28 -0
  121. package/dist/shellInputPublication.js +49 -0
  122. package/dist/shellPointerInput.d.ts +18 -0
  123. package/dist/shellPointerInput.js +49 -0
  124. package/dist/structures/BuildingKitBatch.d.ts +3 -1
  125. package/dist/structures/BuildingKitBatch.js +34 -59
  126. package/dist/structures/BuildingRenderBudget.d.ts +23 -0
  127. package/dist/structures/BuildingRenderBudget.js +54 -0
  128. package/dist/structures/GeneratedBuilding.js +13 -8
  129. package/dist/structures/buildingKitMaterials.d.ts +20 -0
  130. package/dist/structures/buildingKitMaterials.js +70 -0
  131. package/dist/structures/buildingSpatialBatch.d.ts +11 -0
  132. package/dist/structures/buildingSpatialBatch.js +45 -0
  133. package/dist/terrain/GrassField.d.ts +2 -1
  134. package/dist/terrain/GrassField.js +59 -5
  135. package/dist/terrain/grassGeometry.d.ts +10 -0
  136. package/dist/terrain/grassGeometry.js +55 -0
  137. package/dist/terrain/grassMaterial.js +2 -2
  138. package/dist/terrain/terrainDetailMaterial.d.ts +3 -3
  139. package/dist/terrain/terrainDetailMaterial.js +7 -7
  140. package/dist/touch/TouchControlsOverlay.js +4 -4
  141. package/dist/useShellMultiplayerSync.js +4 -0
  142. package/dist/vfx/ParticleField.d.ts +23 -20
  143. package/dist/vfx/ParticleField.js +168 -50
  144. package/dist/visibility/CullingProvider.js +2 -62
  145. package/dist/visibility/cullingDriver.d.ts +9 -0
  146. package/dist/visibility/cullingDriver.js +68 -0
  147. package/dist/weather/AuthoredWeatherLayer.d.ts +16 -0
  148. package/dist/weather/AuthoredWeatherLayer.js +31 -0
  149. package/dist/weather/DustField.d.ts +2 -1
  150. package/dist/weather/DustField.js +17 -3
  151. package/dist/weather/FireSpreadLayer.d.ts +4 -2
  152. package/dist/weather/FireSpreadLayer.js +5 -11
  153. package/dist/weather/LightningStrike.d.ts +4 -1
  154. package/dist/weather/LightningStrike.js +13 -17
  155. package/dist/weather/RainField.d.ts +2 -1
  156. package/dist/weather/RainField.js +17 -3
  157. package/dist/weather/RainImpactField.d.ts +16 -0
  158. package/dist/weather/RainImpactField.js +89 -0
  159. package/dist/weather/SnowField.d.ts +2 -1
  160. package/dist/weather/SnowField.js +17 -3
  161. package/dist/weather/WeatherLayer.d.ts +11 -2
  162. package/dist/weather/WeatherLayer.js +30 -6
  163. package/dist/weather/index.d.ts +2 -0
  164. package/dist/weather/index.js +2 -0
  165. package/dist/weather/weatherMath.d.ts +2 -0
  166. package/dist/weather/weatherMath.js +8 -0
  167. package/dist/weather/weatherShelter.d.ts +18 -0
  168. package/dist/weather/weatherShelter.js +75 -0
  169. package/dist/weather/weatherUniforms.d.ts +40 -1
  170. package/dist/weather/weatherUniforms.js +29 -9
  171. package/dist/world/AuthoredFlocks.d.ts +22 -0
  172. package/dist/world/AuthoredFlocks.js +87 -0
  173. package/dist/world/StaticObjectModels.d.ts +8 -0
  174. package/dist/world/StaticObjectModels.js +75 -0
  175. package/dist/world/WorldHud.d.ts +14 -6
  176. package/dist/world/WorldHud.js +25 -12
  177. package/dist/world/WorldParticles.d.ts +10 -23
  178. package/dist/world/WorldParticles.js +209 -61
  179. package/dist/world/WorldScene.d.ts +3 -1
  180. package/dist/world/WorldScene.js +41 -26
  181. package/dist/world/staticObjectBatches.d.ts +35 -0
  182. package/dist/world/staticObjectBatches.js +163 -0
  183. package/dist/world/worldBarSamples.d.ts +37 -33
  184. package/dist/world/worldBarSamples.js +117 -75
  185. package/package.json +4 -4
  186. package/skills/ce-handoff/SKILL.md +1 -1
  187. package/skills/jgengine/SKILL.md +22 -2
  188. package/skills/jgengine/api.md +28 -17
  189. package/skills/jgengine/recipes/minimal-game.md +7 -2
  190. package/skills/jgengine-ui/SKILL.md +3 -1
  191. package/skills/jgengine-ui/api.md +204 -67
  192. package/skills/jgengine-ui/capabilities.md +115 -1
  193. package/skills/jgengine-ui/reference-primitives.md +93 -3
  194. package/skills/jgengine-ui/reference.md +2 -0
  195. package/skills/jgengine-world/SKILL.md +12 -3
  196. package/skills/jgengine-world/api.md +200 -57
  197. package/skills/jgengine-world/capabilities.md +116 -3
  198. package/skills/jgengine-world/recipes/character-animation.md +31 -8
  199. package/skills/jgengine-world/recipes/controls.md +4 -0
  200. package/skills/jgengine-world/recipes/npc-senses.md +12 -0
  201. package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
  202. package/skills/jgengine-world/reference.md +152 -5
  203. package/skills/jgengine-world/visibility.md +28 -1
@@ -1,6 +1,7 @@
1
1
  import { distance3, resolveEmitterGain } from "@jgengine/core/audio/audioFalloff";
2
2
  import { dopplerRate } from "@jgengine/core/audio/doppler";
3
3
  import { patchDuration } from "@jgengine/core/audio/synth";
4
+ import { createVoiceAllocator } from "@jgengine/core/audio/voiceAllocator";
4
5
  import { createDisposer } from "@jgengine/core/game/defineGame";
5
6
  import { clampLoopCutoff, clampLoopGain, clampLoopRate, MIN_LOOP_CUTOFF, MAX_LOOP_CUTOFF } from "./loopParams.js";
6
7
  import { MusicDirector } from "./musicDirector.js";
@@ -11,6 +12,7 @@ const ZERO_VELOCITY = { x: 0, y: 0, z: 0 };
11
12
  function createNoopEngine() {
12
13
  return {
13
14
  setListenerPose: () => undefined,
15
+ setVoiceLimit: () => undefined,
14
16
  playOneShot: () => undefined,
15
17
  playLoop: () => null,
16
18
  playMusic: () => undefined,
@@ -44,25 +46,38 @@ function setPannerPosition(panner, position) {
44
46
  }
45
47
  function createPanner(context, spatial, position) {
46
48
  const panner = context.createPanner();
47
- panner.panningModel = spatial.panning === "hrtf" ? "HRTF" : "equalpower";
48
- panner.distanceModel = "inverse";
49
- if (spatial.refDistance !== undefined)
50
- panner.refDistance = spatial.refDistance;
51
- if (spatial.maxDistance !== undefined)
52
- panner.maxDistance = spatial.maxDistance;
53
- if (spatial.rolloff !== undefined)
54
- panner.rolloffFactor = spatial.rolloff;
55
- if (spatial.coneInner !== undefined)
56
- panner.coneInnerAngle = spatial.coneInner;
57
- if (spatial.coneOuter !== undefined)
58
- panner.coneOuterAngle = spatial.coneOuter;
59
- if (spatial.coneOuterGain !== undefined)
60
- panner.coneOuterGain = spatial.coneOuterGain;
61
- setPannerPosition(panner, position);
49
+ try {
50
+ panner.panningModel = spatial.panning === "hrtf" ? "HRTF" : "equalpower";
51
+ panner.distanceModel = "inverse";
52
+ if (spatial.refDistance !== undefined)
53
+ panner.refDistance = spatial.refDistance;
54
+ if (spatial.maxDistance !== undefined)
55
+ panner.maxDistance = spatial.maxDistance;
56
+ if (spatial.rolloff !== undefined)
57
+ panner.rolloffFactor = spatial.rolloff;
58
+ if (spatial.coneInner !== undefined)
59
+ panner.coneInnerAngle = spatial.coneInner;
60
+ if (spatial.coneOuter !== undefined)
61
+ panner.coneOuterAngle = spatial.coneOuter;
62
+ if (spatial.coneOuterGain !== undefined)
63
+ panner.coneOuterGain = spatial.coneOuterGain;
64
+ setPannerPosition(panner, position);
65
+ }
66
+ catch (error) {
67
+ panner.disconnect();
68
+ throw error;
69
+ }
62
70
  return panner;
63
71
  }
72
+ /**
73
+ * Game-owned sound catalog playback with bounded SFX reservations, spatial panning, and shared buses.
74
+ * @capability bounded-spatial-audio play positional or flat cues with listener orientation, voice budgets, and per-source cleanup
75
+ */
64
76
  export function createAudioEngine(config = {}) {
65
77
  const sounds = config.sounds ?? {};
78
+ const allocator = config.voiceAllocator ?? createVoiceAllocator({ maxTotal: config.maxVoices, overflow: config.voiceOverflow });
79
+ let disposed = false;
80
+ const activeVoices = new Map();
66
81
  const busDefs = config.buses ?? {};
67
82
  const AudioContextCtor = resolveAudioContextCtor();
68
83
  if (AudioContextCtor === undefined)
@@ -82,9 +97,15 @@ export function createAudioEngine(config = {}) {
82
97
  let node = busGains.get(busId);
83
98
  if (node === undefined) {
84
99
  node = context.createGain();
85
- node.gain.value = busDefs[busId]?.gain ?? 1;
86
- node.connect(masterGain);
87
- busGains.set(busId, node);
100
+ try {
101
+ node.gain.value = busDefs[busId]?.gain ?? 1;
102
+ node.connect(masterGain);
103
+ busGains.set(busId, node);
104
+ }
105
+ catch (error) {
106
+ node.disconnect();
107
+ throw error;
108
+ }
88
109
  }
89
110
  return node;
90
111
  }
@@ -145,38 +166,66 @@ export function createAudioEngine(config = {}) {
145
166
  let listenerVelocity = { x: 0, y: 0, z: 0 };
146
167
  const nyquist = context.sampleRate / 2;
147
168
  const activeSpatialUpdaters = new Set();
148
- const activeLoops = new Set();
149
- const NOOP_HANDLE = {
150
- setPosition: () => undefined,
151
- setRate: () => undefined,
152
- setGain: () => undefined,
153
- setLowpass: () => undefined,
154
- setHighpass: () => undefined,
155
- setVelocity: () => undefined,
156
- stop: () => undefined,
157
- };
158
169
  function playInternal(soundId, position, loop) {
170
+ if (disposed)
171
+ return null;
159
172
  const sound = sounds[soundId];
160
- if (sound === undefined)
173
+ if (sound === undefined || (sound.synth === undefined && sound.url === undefined))
161
174
  return null;
162
- const bus = busGainNode(sound.bus);
163
- const currentPosition = position ?? listenerPosition;
164
- // A one-shot synth cue realises its decaying voices live and is fire-and-forget.
175
+ const allocation = allocator.request(soundId, sound.priority, sound.maxVoices);
176
+ if (!allocation.ok)
177
+ return null;
178
+ for (const id of allocation.stolen)
179
+ activeVoices.get(id)?.();
180
+ const voiceId = allocation.voiceId;
181
+ let bus;
182
+ try {
183
+ bus = busGainNode(sound.bus);
184
+ }
185
+ catch {
186
+ allocator.release(voiceId);
187
+ return null;
188
+ }
189
+ const currentPosition = { ...(position ?? listenerPosition) };
165
190
  if (sound.synth !== undefined && !loop) {
166
- const cueGain = context.createGain();
167
- cueGain.gain.value = sound.spatial === undefined
168
- ? resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1)
169
- : sound.gain ?? 1;
170
- if (sound.spatial !== undefined) {
171
- const panner = createPanner(context, sound.spatial, currentPosition);
172
- cueGain.connect(panner);
173
- panner.connect(bus);
191
+ let cueGain = null;
192
+ let panner = null;
193
+ let playback = null;
194
+ let stopped = false;
195
+ function updateGain() {
196
+ if (cueGain === null)
197
+ return;
198
+ cueGain.gain.value = panner === null
199
+ ? resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1)
200
+ : sound.gain ?? 1;
174
201
  }
175
- else {
176
- cueGain.connect(bus);
202
+ function stop() {
203
+ if (stopped)
204
+ return;
205
+ stopped = true;
206
+ activeVoices.delete(voiceId);
207
+ activeSpatialUpdaters.delete(updateGain);
208
+ allocator.release(voiceId);
209
+ playback?.stop();
210
+ cueGain?.disconnect();
211
+ panner?.disconnect();
177
212
  }
178
- realizeSynthPatch(context, cueGain, sharedNoiseBuffer(), sound.synth);
179
- return NOOP_HANDLE;
213
+ activeVoices.set(voiceId, stop);
214
+ try {
215
+ cueGain = context.createGain();
216
+ const spatial = sound.positional === false ? undefined : sound.spatial;
217
+ panner = spatial === undefined ? null : createPanner(context, spatial, currentPosition);
218
+ updateGain();
219
+ cueGain.connect(panner ?? bus);
220
+ panner?.connect(bus);
221
+ playback = realizeSynthPatch(context, cueGain, sharedNoiseBuffer(), sound.synth);
222
+ activeSpatialUpdaters.add(updateGain);
223
+ playback.onEnded(stop);
224
+ }
225
+ catch {
226
+ stop();
227
+ }
228
+ return null;
180
229
  }
181
230
  // Everything else resolves to one AudioBuffer we loop/play: sample URL, or a synth patch rendered once.
182
231
  let bufferPromise;
@@ -186,11 +235,13 @@ export function createAudioEngine(config = {}) {
186
235
  bufferPromise = loadBuffer(sound.url);
187
236
  else
188
237
  return null;
189
- return playBufferSource(sound, bus, bufferPromise, currentPosition, loop);
238
+ return playBufferSource(sound, bus, bufferPromise, currentPosition, loop, voiceId);
190
239
  }
191
- function playBufferSource(sound, bus, bufferPromise, initialPosition, loop) {
240
+ function playBufferSource(sound, bus, bufferPromise, initialPosition, loop, voiceId) {
192
241
  const disposer = createDisposer();
193
- let currentPosition = initialPosition;
242
+ let currentPosition = { ...initialPosition };
243
+ let stopped = false;
244
+ const spatial = sound.positional === false ? undefined : sound.spatial;
194
245
  // Live control state, applied on source creation so updates that race the async buffer load stick.
195
246
  let currentRate = 1;
196
247
  let currentUserGain = 1;
@@ -215,7 +266,7 @@ export function createAudioEngine(config = {}) {
215
266
  sourceNode.playbackRate.setTargetAtTime(effectiveRate(), context.currentTime, LOOP_PARAM_SMOOTH_TC);
216
267
  }
217
268
  function updateFalloff() {
218
- if (falloffGain !== null && sound.spatial === undefined) {
269
+ if (falloffGain !== null && spatial === undefined) {
219
270
  falloffGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
220
271
  }
221
272
  if (pannerNode !== null)
@@ -223,73 +274,83 @@ export function createAudioEngine(config = {}) {
223
274
  if (dopplerFactor !== 0)
224
275
  applyRate();
225
276
  }
277
+ disposer.onDispose(() => {
278
+ if (sourceNode !== null)
279
+ sourceNode.onended = null;
280
+ try {
281
+ sourceNode?.stop();
282
+ }
283
+ catch {
284
+ }
285
+ sourceNode?.disconnect();
286
+ lowpassNode?.disconnect();
287
+ highpassNode?.disconnect();
288
+ userGainNode?.disconnect();
289
+ falloffGain?.disconnect();
290
+ pannerNode?.disconnect();
291
+ sourceNode = null;
292
+ lowpassNode = null;
293
+ highpassNode = null;
294
+ userGainNode = null;
295
+ falloffGain = null;
296
+ pannerNode = null;
297
+ });
226
298
  void bufferPromise.then((buffer) => {
227
- if (buffer === null)
299
+ if (stopped || disposed)
300
+ return;
301
+ if (buffer === null) {
302
+ handle.stop();
228
303
  return;
304
+ }
229
305
  const src = context.createBufferSource();
306
+ sourceNode = src;
230
307
  src.buffer = buffer;
231
308
  src.loop = loop || (sound.loop ?? false);
232
309
  src.playbackRate.value = effectiveRate();
233
- // source → [lowpass → highpass, loops only] → per-loop user gain (setGain) → distance falloff → bus
310
+ // Spatial panners own distance attenuation; the scalar stage keeps only authored gain.
234
311
  const uGain = context.createGain();
312
+ userGainNode = uGain;
235
313
  uGain.gain.value = currentUserGain;
236
314
  const fGain = context.createGain();
315
+ falloffGain = fGain;
237
316
  fGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
238
317
  if (loop) {
239
318
  const lp = context.createBiquadFilter();
319
+ lowpassNode = lp;
240
320
  lp.type = "lowpass";
241
321
  lp.frequency.value = clampLoopCutoff(currentLowpass, MAX_LOOP_CUTOFF, nyquist);
242
322
  const hp = context.createBiquadFilter();
323
+ highpassNode = hp;
243
324
  hp.type = "highpass";
244
325
  hp.frequency.value = clampLoopCutoff(currentHighpass, MIN_LOOP_CUTOFF, nyquist);
245
326
  src.connect(lp);
246
327
  lp.connect(hp);
247
328
  hp.connect(uGain);
248
- lowpassNode = lp;
249
- highpassNode = hp;
250
329
  }
251
330
  else {
252
331
  src.connect(uGain);
253
332
  }
254
333
  uGain.connect(fGain);
255
- if (sound.spatial !== undefined) {
256
- const panner = createPanner(context, sound.spatial, currentPosition);
257
- fGain.gain.value = 1;
334
+ if (spatial !== undefined) {
335
+ const panner = createPanner(context, spatial, currentPosition);
336
+ pannerNode = panner;
337
+ fGain.gain.value = sound.gain ?? 1;
258
338
  fGain.connect(panner);
259
339
  panner.connect(bus);
260
- pannerNode = panner;
261
340
  }
262
341
  else {
263
342
  fGain.connect(bus);
264
343
  }
344
+ src.onended = () => handle.stop();
265
345
  src.start();
266
- sourceNode = src;
267
- userGainNode = uGain;
268
- falloffGain = fGain;
269
- disposer.onDispose(() => {
270
- try {
271
- src.stop();
272
- }
273
- catch {
274
- }
275
- src.disconnect();
276
- lowpassNode?.disconnect();
277
- highpassNode?.disconnect();
278
- uGain.disconnect();
279
- fGain.disconnect();
280
- pannerNode?.disconnect();
281
- sourceNode = null;
282
- lowpassNode = null;
283
- highpassNode = null;
284
- userGainNode = null;
285
- falloffGain = null;
286
- });
287
- });
288
- if (loop)
289
- activeSpatialUpdaters.add(updateFalloff);
346
+ }).catch(() => handle.stop());
347
+ activeSpatialUpdaters.add(updateFalloff);
290
348
  const handle = {
349
+ isPlaying: () => !stopped,
291
350
  setPosition(next) {
292
- currentPosition = next;
351
+ if (stopped)
352
+ return;
353
+ currentPosition = { ...next };
293
354
  updateFalloff();
294
355
  },
295
356
  setRate(rate) {
@@ -307,7 +368,7 @@ export function createAudioEngine(config = {}) {
307
368
  highpassNode.frequency.setTargetAtTime(currentHighpass, context.currentTime, LOOP_PARAM_SMOOTH_TC);
308
369
  },
309
370
  setVelocity(velocity) {
310
- currentVelocity = velocity;
371
+ currentVelocity = { ...velocity };
311
372
  if (dopplerFactor !== 0)
312
373
  applyRate();
313
374
  },
@@ -317,20 +378,31 @@ export function createAudioEngine(config = {}) {
317
378
  userGainNode.gain.setTargetAtTime(currentUserGain, context.currentTime, LOOP_PARAM_SMOOTH_TC);
318
379
  },
319
380
  stop() {
381
+ if (stopped)
382
+ return;
383
+ stopped = true;
320
384
  activeSpatialUpdaters.delete(updateFalloff);
321
- activeLoops.delete(handle);
385
+ activeVoices.delete(voiceId);
386
+ allocator.release(voiceId);
322
387
  disposer.dispose();
323
388
  },
324
389
  };
325
- if (loop)
326
- activeLoops.add(handle);
390
+ activeVoices.set(voiceId, () => handle.stop());
327
391
  return handle;
328
392
  }
329
393
  return {
394
+ setVoiceLimit(maxTotal, overflow) {
395
+ if (disposed)
396
+ return;
397
+ for (const id of allocator.retune({ maxTotal, overflow }))
398
+ activeVoices.get(id)?.();
399
+ },
330
400
  setListenerPose(position) {
401
+ if (disposed)
402
+ return;
331
403
  const pose = "position" in position ? position : { position, forward: { x: 0, y: 0, z: -1 }, up: { x: 0, y: 1, z: 0 } };
332
- listenerPosition = pose.position;
333
- listenerVelocity = "velocity" in pose && pose.velocity !== undefined ? pose.velocity : ZERO_VELOCITY;
404
+ listenerPosition = { ...pose.position };
405
+ listenerVelocity = "velocity" in pose && pose.velocity !== undefined ? { ...pose.velocity } : ZERO_VELOCITY;
334
406
  const listener = context.listener;
335
407
  setAudioParam(listener.positionX, pose.position.x);
336
408
  setAudioParam(listener.positionY, pose.position.y);
@@ -345,6 +417,8 @@ export function createAudioEngine(config = {}) {
345
417
  updateGain();
346
418
  },
347
419
  playOneShot(soundId, position) {
420
+ if (disposed)
421
+ return;
348
422
  void context.resume().catch(() => undefined);
349
423
  playInternal(soundId, position, false);
350
424
  },
@@ -352,23 +426,38 @@ export function createAudioEngine(config = {}) {
352
426
  return playInternal(soundId, position, true);
353
427
  },
354
428
  playMusic(themeId, options) {
429
+ if (disposed)
430
+ return;
355
431
  void context.resume().catch(() => undefined);
356
432
  musicDirector()?.crossfadeTo(themeId, options);
357
433
  },
358
434
  setBusGain(busId, gain) {
435
+ if (disposed)
436
+ return;
359
437
  busGainNode(busId).gain.value = gain;
360
438
  },
361
439
  setMasterGain(gain) {
440
+ if (disposed)
441
+ return;
362
442
  masterGain.gain.value = gain;
363
443
  },
364
444
  resume() {
445
+ if (disposed)
446
+ return;
365
447
  void context.resume().catch(() => undefined);
366
448
  },
367
449
  dispose() {
368
- for (const handle of [...activeLoops])
369
- handle.stop();
450
+ if (disposed)
451
+ return;
452
+ disposed = true;
453
+ for (const stop of [...activeVoices.values()])
454
+ stop();
370
455
  activeSpatialUpdaters.clear();
371
456
  director?.dispose();
457
+ for (const node of busGains.values())
458
+ node.disconnect();
459
+ busGains.clear();
460
+ masterGain.disconnect();
372
461
  void context.close().catch(() => undefined);
373
462
  },
374
463
  };
@@ -19,8 +19,8 @@ export function attachAudioEventWire(events, audioEngine) {
19
19
  const offLoopStart = events.on("audio.loopStart", ({ id, sound, at }) => {
20
20
  const existing = loops.get(id);
21
21
  if (existing !== undefined) {
22
- // Same sound: idempotent — keep the running source so a per-tick re-issue never clicks.
23
- if (existing.sound === sound)
22
+ // Keep active/pending loops; a stolen voice can retry on the next explicit start.
23
+ if (existing.sound === sound && existing.handle.isPlaying?.() !== false)
24
24
  return;
25
25
  existing.handle.stop();
26
26
  loops.delete(id);
@@ -1,9 +1,15 @@
1
1
  import type { SynthPatch } from "@jgengine/core/audio/synth";
2
2
  /** Build the shared 1-second mono white-noise buffer every noise voice samples from. */
3
3
  export declare function createNoiseBuffer(ctx: BaseAudioContext): AudioBuffer;
4
+ /** Scheduled procedural sources; stopping releases their source/filter/envelope graph. */
5
+ export interface SynthPlayback {
6
+ stop(): void;
7
+ /** Called once after every source ends; late subscribers are notified immediately. */
8
+ onEnded(callback: () => void): void;
9
+ }
4
10
  /**
5
11
  * Realise a procedural cue on Web Audio: every voice is scheduled at
6
12
  * `ctx.currentTime + delay` into `out`, summed into one one-shot. `noiseBuf` is
7
13
  * the shared buffer from {@link createNoiseBuffer}.
8
14
  */
9
- export declare function realizeSynthPatch(ctx: BaseAudioContext, out: AudioNode, noiseBuf: AudioBuffer, patch: SynthPatch): void;
15
+ export declare function realizeSynthPatch(ctx: BaseAudioContext, out: AudioNode, noiseBuf: AudioBuffer, patch: SynthPatch): SynthPlayback;
@@ -7,48 +7,90 @@ export function createNoiseBuffer(ctx) {
7
7
  data[i] = Math.random() * 2 - 1;
8
8
  return buf;
9
9
  }
10
- function realizeTone(ctx, out, voice, cueGain, at) {
10
+ function realizeTone(ctx, out, voice, cueGain, at, ended) {
11
11
  const t = at + (voice.delay ?? 0);
12
12
  const peak = (voice.gain ?? 1) * cueGain;
13
13
  const attack = voice.attack ?? 0.012;
14
14
  const osc = ctx.createOscillator();
15
- osc.type = voice.wave ?? "sine";
16
- osc.frequency.setValueAtTime(voice.freq, t);
17
- if (voice.slideTo !== undefined)
18
- osc.frequency.exponentialRampToValueAtTime(Math.max(1, voice.slideTo), t + voice.duration);
19
- const sustainEnd = Math.max(attack, voice.sustain ?? 0);
20
- const g = ctx.createGain();
21
- g.gain.setValueAtTime(0, t);
22
- g.gain.linearRampToValueAtTime(peak, t + attack);
23
- if (sustainEnd > attack)
24
- g.gain.setValueAtTime(peak, t + sustainEnd);
25
- g.gain.exponentialRampToValueAtTime(0.001, t + Math.max(voice.duration, sustainEnd + 0.001));
26
- osc.connect(g).connect(out);
27
- osc.start(t);
28
- osc.stop(t + voice.duration + 0.05);
15
+ const nodes = [];
16
+ const stop = trackVoice(osc, nodes, ended);
17
+ try {
18
+ osc.type = voice.wave ?? "sine";
19
+ osc.frequency.setValueAtTime(voice.freq, t);
20
+ if (voice.slideTo !== undefined)
21
+ osc.frequency.exponentialRampToValueAtTime(Math.max(1, voice.slideTo), t + voice.duration);
22
+ const sustainEnd = Math.max(attack, voice.sustain ?? 0);
23
+ const g = ctx.createGain();
24
+ nodes.push(g);
25
+ g.gain.setValueAtTime(0, t);
26
+ g.gain.linearRampToValueAtTime(peak, t + attack);
27
+ if (sustainEnd > attack)
28
+ g.gain.setValueAtTime(peak, t + sustainEnd);
29
+ g.gain.exponentialRampToValueAtTime(0.001, t + Math.max(voice.duration, sustainEnd + 0.001));
30
+ osc.connect(g).connect(out);
31
+ osc.start(t);
32
+ osc.stop(t + voice.duration + 0.05);
33
+ return stop;
34
+ }
35
+ catch (error) {
36
+ stop();
37
+ throw error;
38
+ }
29
39
  }
30
- function realizeNoise(ctx, out, noiseBuf, voice, cueGain, at) {
40
+ function realizeNoise(ctx, out, noiseBuf, voice, cueGain, at, ended) {
31
41
  const t = at + (voice.delay ?? 0);
32
42
  const peak = (voice.gain ?? 1) * cueGain;
33
43
  const decay = voice.decay ?? 0.9;
34
44
  const src = ctx.createBufferSource();
35
- src.buffer = noiseBuf;
36
- src.playbackRate.value = 0.8 + Math.random() * 0.4;
37
- // The shared buffer is 1 s and playback starts at a random offset inside it, so any voice longer
38
- // than the remainder used to run off the end and go silent early. Wrapping keeps it noise throughout;
39
- // the `duration` argument to start() still ends the voice on time.
40
- src.loop = true;
41
- const filter = ctx.createBiquadFilter();
42
- filter.type = voice.filterType ?? "lowpass";
43
- filter.frequency.value = voice.filterFreq;
44
- const sustain = Math.max(0, voice.sustain ?? 0);
45
- const g = ctx.createGain();
46
- g.gain.setValueAtTime(peak, t);
47
- if (sustain > 0)
48
- g.gain.setValueAtTime(peak, t + sustain);
49
- g.gain.exponentialRampToValueAtTime(0.001, t + Math.max(voice.duration * decay, sustain + 0.001));
50
- src.connect(filter).connect(g).connect(out);
51
- src.start(t, Math.random() * 0.5, voice.duration);
45
+ const nodes = [];
46
+ const stop = trackVoice(src, nodes, ended);
47
+ try {
48
+ src.buffer = noiseBuf;
49
+ src.playbackRate.value = 0.8 + Math.random() * 0.4;
50
+ // Loop the shared 1-second buffer so a random offset cannot truncate a longer voice.
51
+ src.loop = true;
52
+ const filter = ctx.createBiquadFilter();
53
+ nodes.push(filter);
54
+ filter.type = voice.filterType ?? "lowpass";
55
+ filter.frequency.value = voice.filterFreq;
56
+ const sustain = Math.max(0, voice.sustain ?? 0);
57
+ const g = ctx.createGain();
58
+ nodes.push(g);
59
+ g.gain.setValueAtTime(peak, t);
60
+ if (sustain > 0)
61
+ g.gain.setValueAtTime(peak, t + sustain);
62
+ g.gain.exponentialRampToValueAtTime(0.001, t + Math.max(voice.duration * decay, sustain + 0.001));
63
+ src.connect(filter).connect(g).connect(out);
64
+ src.start(t, Math.random() * 0.5, voice.duration);
65
+ return stop;
66
+ }
67
+ catch (error) {
68
+ stop();
69
+ throw error;
70
+ }
71
+ }
72
+ function trackVoice(source, nodes, ended) {
73
+ let disposed = false;
74
+ function cleanup() {
75
+ if (disposed)
76
+ return;
77
+ disposed = true;
78
+ source.onended = null;
79
+ source.disconnect();
80
+ for (const node of nodes)
81
+ node.disconnect();
82
+ ended();
83
+ }
84
+ source.onended = cleanup;
85
+ return () => {
86
+ if (disposed)
87
+ return;
88
+ try {
89
+ source.stop();
90
+ }
91
+ catch { }
92
+ cleanup();
93
+ };
52
94
  }
53
95
  /**
54
96
  * Realise a procedural cue on Web Audio: every voice is scheduled at
@@ -58,10 +100,33 @@ function realizeNoise(ctx, out, noiseBuf, voice, cueGain, at) {
58
100
  export function realizeSynthPatch(ctx, out, noiseBuf, patch) {
59
101
  const cueGain = patch.gain ?? 1;
60
102
  const at = ctx.currentTime;
61
- for (const voice of patch.voices) {
62
- if (voice.kind === "tone")
63
- realizeTone(ctx, out, voice, cueGain, at);
64
- else
65
- realizeNoise(ctx, out, noiseBuf, voice, cueGain, at);
103
+ let remaining = patch.voices.length;
104
+ const callbacks = [];
105
+ const stops = [];
106
+ function ended() {
107
+ remaining -= 1;
108
+ if (remaining === 0)
109
+ for (const callback of callbacks.splice(0))
110
+ callback();
111
+ }
112
+ try {
113
+ for (const voice of patch.voices) {
114
+ stops.push(voice.kind === "tone"
115
+ ? realizeTone(ctx, out, voice, cueGain, at, ended)
116
+ : realizeNoise(ctx, out, noiseBuf, voice, cueGain, at, ended));
117
+ }
66
118
  }
119
+ catch (error) {
120
+ for (const stop of stops)
121
+ stop();
122
+ throw error;
123
+ }
124
+ return {
125
+ stop() { for (const stop of stops)
126
+ stop(); },
127
+ onEnded(callback) { if (remaining === 0)
128
+ callback();
129
+ else
130
+ callbacks.push(callback); },
131
+ };
67
132
  }
@@ -5,10 +5,11 @@ import { GameFirstPersonCamera } from "./GameFirstPersonCamera.js";
5
5
  import { GameInspectionCamera } from "./GameInspectionCamera.js";
6
6
  import { GameOrbitCamera } from "./GameOrbitCamera.js";
7
7
  import { resolveDirectedCamera } from "./rigMath.js";
8
- import { resolveRigKind, turntableAsObserver } from "./rigResolve.js";
8
+ import { resolveRigKind, resolveRuntimeCameraConfig, turntableAsObserver } from "./rigResolve.js";
9
9
  import { CameraShakeContext, createCameraShakeChannel } from "./shakeChannel.js";
10
10
  export { resolveRigKind } from "./rigResolve.js";
11
11
  export function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerControls, panKeysEnabled, director, viewmodel, }) {
12
+ config = resolveRuntimeCameraConfig(config, director?.rig());
12
13
  const channel = useMemo(() => createCameraShakeChannel(config?.shake?.decayPerSecond), [config?.shake?.decayPerSecond]);
13
14
  const directorVersionRef = useRef(0);
14
15
  const subscribeDirector = useCallback((onStoreChange) => {
@@ -2,8 +2,8 @@ import { type ComponentType, type MutableRefObject } from "react";
2
2
  import * as THREE from "three";
3
3
  import type { CameraWeaponView, FirstPersonCameraConfig } from "@jgengine/core/game/playableGame";
4
4
  import type { EntityRenderCues } from "@jgengine/core/combat/renderCues";
5
- /** World position of the first-person weapon muzzle, or false when no viewmodel is mounted. */
6
- export declare function readFirstPersonMuzzle(target: THREE.Vector3): boolean;
5
+ /** Presentation muzzle in world space; camera-scoped when supplied, false leaves target unchanged. */
6
+ export declare function readFirstPersonMuzzle(target: THREE.Vector3, camera?: THREE.Camera): boolean;
7
7
  /** Props handed to a custom viewmodel component (#542): a live cue ref (velocity/bob/firing/reloading/recoil/hit) for the followed entity, driven from your own `useFrame` — read `cuesRef.current` there rather than storing it as render state. */
8
8
  export interface ViewmodelProps {
9
9
  cuesRef: MutableRefObject<EntityRenderCues>;