@jgengine/shell 0.11.0 → 0.13.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 (151) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/CombatPresentation.d.ts +5 -0
  3. package/dist/CombatPresentation.js +8 -0
  4. package/dist/GameHost.d.ts +27 -1
  5. package/dist/GameHost.js +83 -4
  6. package/dist/GamePlayer.d.ts +5 -0
  7. package/dist/GamePlayer.js +5 -0
  8. package/dist/GamePlayerShell.d.ts +5 -0
  9. package/dist/GamePlayerShell.js +63 -359
  10. package/dist/GameUiPreview.js +3 -0
  11. package/dist/Shell3dPresentation.d.ts +63 -0
  12. package/dist/Shell3dPresentation.js +302 -0
  13. package/dist/ShellChrome.d.ts +51 -0
  14. package/dist/ShellChrome.js +51 -0
  15. package/dist/ShellHudPresentation.d.ts +41 -0
  16. package/dist/ShellHudPresentation.js +22 -0
  17. package/dist/audio/audioEngine.d.ts +4 -0
  18. package/dist/audio/audioEngine.js +111 -26
  19. package/dist/audio/audioWire.d.ts +22 -4
  20. package/dist/audio/audioWire.js +46 -3
  21. package/dist/audio/loopParams.d.ts +20 -0
  22. package/dist/audio/loopParams.js +28 -0
  23. package/dist/camera/GameCameraRig.js +6 -1
  24. package/dist/camera/GameOrbitCamera.js +7 -1
  25. package/dist/camera/cameraCollision.d.ts +28 -0
  26. package/dist/camera/cameraCollision.js +34 -0
  27. package/dist/camera/cameraRigs.d.ts +3 -1
  28. package/dist/camera/cameraRigs.js +24 -10
  29. package/dist/camera/index.d.ts +1 -0
  30. package/dist/camera/index.js +1 -0
  31. package/dist/camera/rigMath.d.ts +27 -0
  32. package/dist/camera/rigMath.js +45 -0
  33. package/dist/defineGame.d.ts +15 -1
  34. package/dist/defineGame.js +26 -4
  35. package/dist/devtools/DevtoolsOverlay.d.ts +4 -4
  36. package/dist/devtools/DevtoolsOverlay.js +28 -12
  37. package/dist/devtools/agentBridge.js +4 -0
  38. package/dist/devtools/perfDiagnose.js +1 -1
  39. package/dist/diagnostics/RuntimeDiagnostics.d.ts +7 -0
  40. package/dist/diagnostics/RuntimeDiagnostics.js +23 -12
  41. package/dist/environment/EnvironmentScene.js +17 -3
  42. package/dist/environment/RoadJunctions.d.ts +12 -0
  43. package/dist/environment/RoadJunctions.js +69 -0
  44. package/dist/environment/RoadRibbons.d.ts +8 -2
  45. package/dist/environment/RoadRibbons.js +26 -8
  46. package/dist/environment/terrainGroundColor.d.ts +9 -0
  47. package/dist/environment/terrainGroundColor.js +22 -0
  48. package/dist/gameKit.d.ts +27 -0
  49. package/dist/gameKit.js +29 -0
  50. package/dist/map/MapMarkerBeacons.d.ts +3 -2
  51. package/dist/postfx/PostProcessing.d.ts +9 -1
  52. package/dist/postfx/PostProcessing.js +11 -4
  53. package/dist/presentationResolve.d.ts +39 -0
  54. package/dist/presentationResolve.js +53 -0
  55. package/dist/registry.d.ts +1 -0
  56. package/dist/render/PartMotion.d.ts +18 -0
  57. package/dist/render/PartMotion.js +91 -0
  58. package/dist/render/SceneModels.d.ts +9 -2
  59. package/dist/render/SceneModels.js +31 -180
  60. package/dist/render/assetBase.d.ts +17 -0
  61. package/dist/render/assetBase.js +35 -0
  62. package/dist/render/measureBounds.d.ts +34 -0
  63. package/dist/render/measureBounds.js +127 -0
  64. package/dist/render/modelLoad.js +4 -1
  65. package/dist/render/resolveModel.d.ts +15 -0
  66. package/dist/render/resolveModel.js +43 -2
  67. package/dist/render/useModelAnimation.d.ts +10 -0
  68. package/dist/render/useModelAnimation.js +198 -0
  69. package/dist/scatter/InstancedScatter.js +5 -0
  70. package/dist/scatter/scatterProxies.js +24 -0
  71. package/dist/scene/AuthoredScene.d.ts +3 -1
  72. package/dist/scene/AuthoredScene.js +7 -2
  73. package/dist/scene/CityRenderer.d.ts +2 -0
  74. package/dist/scene/CityRenderer.js +1031 -0
  75. package/dist/scene/GrassFieldRenderer.js +53 -5
  76. package/dist/scene/PoleLineRenderer.d.ts +2 -0
  77. package/dist/scene/PoleLineRenderer.js +287 -0
  78. package/dist/scene/SoilPatchRenderer.js +70 -7
  79. package/dist/scene/WaterRenderer.js +34 -10
  80. package/dist/scene/builtinSceneKindRenderers.js +4 -0
  81. package/dist/scene/cityWindowMaterial.d.ts +16 -0
  82. package/dist/scene/cityWindowMaterial.js +81 -0
  83. package/dist/scene/sceneKindRenderers.d.ts +2 -0
  84. package/dist/settings/appliedSettings.d.ts +2 -1
  85. package/dist/settings/appliedSettings.js +4 -2
  86. package/dist/structures/GeneratedBuilding.d.ts +4 -0
  87. package/dist/structures/GeneratedBuilding.js +17 -7
  88. package/dist/structures/TransformGizmo.d.ts +11 -1
  89. package/dist/structures/TransformGizmo.js +9 -5
  90. package/dist/terrain/GrassField.d.ts +11 -3
  91. package/dist/terrain/GrassField.js +16 -6
  92. package/dist/terrain/grassGeometry.d.ts +33 -1
  93. package/dist/terrain/grassGeometry.js +83 -21
  94. package/dist/terrain/grassMaterial.d.ts +18 -0
  95. package/dist/terrain/grassMaterial.js +66 -13
  96. package/dist/terrain/heightfieldGeometry.d.ts +34 -0
  97. package/dist/terrain/heightfieldGeometry.js +109 -0
  98. package/dist/terrain/index.d.ts +1 -0
  99. package/dist/terrain/index.js +1 -0
  100. package/dist/terrain/soilPatchMaterial.d.ts +15 -2
  101. package/dist/terrain/soilPatchMaterial.js +46 -8
  102. package/dist/terrain/terrainDetailMaterial.js +15 -2
  103. package/dist/water/Ocean.d.ts +7 -1
  104. package/dist/water/Ocean.js +16 -3
  105. package/dist/water/OceanConfig.d.ts +9 -0
  106. package/dist/water/OceanConfig.js +6 -0
  107. package/dist/water/OceanMaterial.d.ts +12 -0
  108. package/dist/water/OceanMaterial.js +9 -0
  109. package/dist/water/OceanShader.d.ts +2 -2
  110. package/dist/water/OceanShader.js +78 -10
  111. package/dist/world/PlaceScene.d.ts +11 -0
  112. package/dist/world/PlaceScene.js +27 -0
  113. package/dist/world/WorldHud.d.ts +3 -1
  114. package/dist/world/WorldHud.js +2 -2
  115. package/dist/world/WorldScene.js +32 -4
  116. package/dist/worldSky.d.ts +7 -1
  117. package/dist/worldSky.js +11 -1
  118. package/package.json +4 -4
  119. package/skills/game-design/SKILL.md +28 -0
  120. package/skills/game-design/agents/openai.yaml +4 -0
  121. package/skills/game-design/references/application-playbook.md +170 -0
  122. package/skills/game-design/references/field-guide.md +288 -0
  123. package/skills/jgengine/SKILL.md +8 -4
  124. package/skills/jgengine/api.md +182 -81
  125. package/skills/jgengine/capabilities.md +104 -2
  126. package/skills/jgengine/recipes/minimal-game.md +88 -0
  127. package/skills/jgengine-ui/SKILL.md +17 -10
  128. package/skills/jgengine-ui/api.md +316 -51
  129. package/skills/jgengine-ui/capabilities.md +135 -2
  130. package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
  131. package/skills/jgengine-ui/reference.md +59 -9
  132. package/skills/jgengine-ui/references/visual-scorecard.md +1 -1
  133. package/skills/jgengine-world/SKILL.md +13 -0
  134. package/skills/jgengine-world/api.md +453 -21
  135. package/skills/jgengine-world/capabilities.md +112 -2
  136. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  137. package/skills/jgengine-world/reference.md +43 -4
  138. package/skills/level-design/SKILL.md +54 -0
  139. package/skills/level-design/agents/openai.yaml +4 -0
  140. package/skills/level-design/references/application-playbook.md +192 -0
  141. package/skills/level-design/references/field-guide.md +337 -0
  142. package/skills/level-design/references/live-editor-loop.md +179 -0
  143. package/dist/cartridge/runtime.d.ts +0 -15
  144. package/dist/cartridge/runtime.js +0 -556
  145. package/dist/cartridge/spec.d.ts +0 -188
  146. package/dist/cartridge/spec.js +0 -19
  147. package/dist/cartridge/validate.d.ts +0 -2
  148. package/dist/cartridge/validate.js +0 -78
  149. package/dist/cartridge.d.ts +0 -145
  150. package/dist/cartridge.js +0 -246
  151. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -1,7 +1,11 @@
1
1
  import { distance3, resolveEmitterGain } from "@jgengine/core/audio/audioFalloff";
2
+ import { patchDuration } from "@jgengine/core/audio/synth";
2
3
  import { createDisposer } from "@jgengine/core/game/defineGame";
4
+ import { clampLoopGain, clampLoopRate } from "./loopParams.js";
3
5
  import { MusicDirector } from "./musicDirector.js";
4
6
  import { createNoiseBuffer, realizeSynthPatch } from "./synthEngine.js";
7
+ /** setTargetAtTime time constant (~20 ms) for zipper-free live rate/gain ramps on retained loops (#1051). */
8
+ const LOOP_PARAM_SMOOTH_TC = 0.02;
5
9
  function createNoopEngine() {
6
10
  return {
7
11
  setListenerPose: () => undefined,
@@ -20,6 +24,12 @@ function resolveAudioContextCtor() {
20
24
  return (window.AudioContext ??
21
25
  window.webkitAudioContext);
22
26
  }
27
+ function resolveOfflineAudioContextCtor() {
28
+ if (typeof window === "undefined")
29
+ return undefined;
30
+ return (window.OfflineAudioContext ??
31
+ window.webkitOfflineAudioContext);
32
+ }
23
33
  export function createAudioEngine(config = {}) {
24
34
  const sounds = config.sounds ?? {};
25
35
  const busDefs = config.buses ?? {};
@@ -74,65 +84,138 @@ export function createAudioEngine(config = {}) {
74
84
  }
75
85
  return pending;
76
86
  }
87
+ // Retained synth loops render the procedural patch to one cached buffer, then loop it as a plain
88
+ // BufferSource — so live pitch is `playbackRate` and live gain is a gain node, exactly like sample loops (#1051).
89
+ const synthBufferCache = new Map();
90
+ function renderSynthBuffer(soundId, patch) {
91
+ let pending = synthBufferCache.get(soundId);
92
+ if (pending === undefined) {
93
+ const OfflineCtor = resolveOfflineAudioContextCtor();
94
+ if (OfflineCtor === undefined) {
95
+ pending = Promise.resolve(null);
96
+ }
97
+ else {
98
+ try {
99
+ const seconds = Math.max(patchDuration(patch), 0.05);
100
+ const frames = Math.max(1, Math.ceil(seconds * context.sampleRate));
101
+ const offline = new OfflineCtor(1, frames, context.sampleRate);
102
+ realizeSynthPatch(offline, offline.destination, createNoiseBuffer(offline), patch);
103
+ pending = offline.startRendering().catch(() => null);
104
+ }
105
+ catch {
106
+ pending = Promise.resolve(null);
107
+ }
108
+ }
109
+ synthBufferCache.set(soundId, pending);
110
+ }
111
+ return pending;
112
+ }
77
113
  let listenerPosition = { x: 0, y: 0, z: 0 };
78
114
  const activeSpatialUpdaters = new Set();
115
+ const activeLoops = new Set();
116
+ const NOOP_HANDLE = {
117
+ setPosition: () => undefined,
118
+ setRate: () => undefined,
119
+ setGain: () => undefined,
120
+ stop: () => undefined,
121
+ };
79
122
  function playInternal(soundId, position, loop) {
80
123
  const sound = sounds[soundId];
81
124
  if (sound === undefined)
82
125
  return null;
83
126
  const bus = busGainNode(sound.bus);
84
- let currentPosition = position ?? listenerPosition;
85
- if (sound.synth !== undefined) {
127
+ const currentPosition = position ?? listenerPosition;
128
+ // A one-shot synth cue realises its decaying voices live and is fire-and-forget.
129
+ if (sound.synth !== undefined && !loop) {
86
130
  const cueGain = context.createGain();
87
131
  cueGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
88
132
  cueGain.connect(bus);
89
133
  realizeSynthPatch(context, cueGain, sharedNoiseBuffer(), sound.synth);
90
- return { setPosition: () => undefined, stop: () => undefined };
134
+ return NOOP_HANDLE;
91
135
  }
92
- if (sound.url === undefined)
136
+ // Everything else resolves to one AudioBuffer we loop/play: sample URL, or a synth patch rendered once.
137
+ let bufferPromise;
138
+ if (sound.synth !== undefined)
139
+ bufferPromise = renderSynthBuffer(soundId, sound.synth);
140
+ else if (sound.url !== undefined)
141
+ bufferPromise = loadBuffer(sound.url);
142
+ else
93
143
  return null;
144
+ return playBufferSource(sound, bus, bufferPromise, currentPosition, loop);
145
+ }
146
+ function playBufferSource(sound, bus, bufferPromise, initialPosition, loop) {
94
147
  const disposer = createDisposer();
95
- let liveGain = null;
96
- function updateGain() {
97
- if (liveGain !== null) {
98
- liveGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
148
+ let currentPosition = initialPosition;
149
+ // Live control state, applied on source creation so updates that race the async buffer load stick.
150
+ let currentRate = 1;
151
+ let currentUserGain = 1;
152
+ let sourceNode = null;
153
+ let userGainNode = null;
154
+ let falloffGain = null;
155
+ function updateFalloff() {
156
+ if (falloffGain !== null) {
157
+ falloffGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
99
158
  }
100
159
  }
101
- void loadBuffer(sound.url).then((buffer) => {
160
+ void bufferPromise.then((buffer) => {
102
161
  if (buffer === null)
103
162
  return;
104
- const sourceNode = context.createBufferSource();
105
- sourceNode.buffer = buffer;
106
- sourceNode.loop = loop || (sound.loop ?? false);
107
- const gainNode = context.createGain();
108
- gainNode.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
109
- sourceNode.connect(gainNode);
110
- gainNode.connect(bus);
111
- sourceNode.start();
112
- liveGain = gainNode;
163
+ const src = context.createBufferSource();
164
+ src.buffer = buffer;
165
+ src.loop = loop || (sound.loop ?? false);
166
+ src.playbackRate.value = currentRate;
167
+ // source → per-loop user gain (setGain) → distance falloff → bus
168
+ const uGain = context.createGain();
169
+ uGain.gain.value = currentUserGain;
170
+ const fGain = context.createGain();
171
+ fGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
172
+ src.connect(uGain);
173
+ uGain.connect(fGain);
174
+ fGain.connect(bus);
175
+ src.start();
176
+ sourceNode = src;
177
+ userGainNode = uGain;
178
+ falloffGain = fGain;
113
179
  disposer.onDispose(() => {
114
180
  try {
115
- sourceNode.stop();
181
+ src.stop();
116
182
  }
117
183
  catch {
118
184
  }
119
- sourceNode.disconnect();
120
- gainNode.disconnect();
121
- liveGain = null;
185
+ src.disconnect();
186
+ uGain.disconnect();
187
+ fGain.disconnect();
188
+ sourceNode = null;
189
+ userGainNode = null;
190
+ falloffGain = null;
122
191
  });
123
192
  });
124
193
  if (loop)
125
- activeSpatialUpdaters.add(updateGain);
126
- return {
194
+ activeSpatialUpdaters.add(updateFalloff);
195
+ const handle = {
127
196
  setPosition(next) {
128
197
  currentPosition = next;
129
- updateGain();
198
+ updateFalloff();
199
+ },
200
+ setRate(rate) {
201
+ currentRate = clampLoopRate(rate);
202
+ if (sourceNode !== null)
203
+ sourceNode.playbackRate.setTargetAtTime(currentRate, context.currentTime, LOOP_PARAM_SMOOTH_TC);
204
+ },
205
+ setGain(gain) {
206
+ currentUserGain = clampLoopGain(gain);
207
+ if (userGainNode !== null)
208
+ userGainNode.gain.setTargetAtTime(currentUserGain, context.currentTime, LOOP_PARAM_SMOOTH_TC);
130
209
  },
131
210
  stop() {
132
- activeSpatialUpdaters.delete(updateGain);
211
+ activeSpatialUpdaters.delete(updateFalloff);
212
+ activeLoops.delete(handle);
133
213
  disposer.dispose();
134
214
  },
135
215
  };
216
+ if (loop)
217
+ activeLoops.add(handle);
218
+ return handle;
136
219
  }
137
220
  return {
138
221
  setListenerPose(position) {
@@ -161,6 +244,8 @@ export function createAudioEngine(config = {}) {
161
244
  void context.resume().catch(() => undefined);
162
245
  },
163
246
  dispose() {
247
+ for (const handle of [...activeLoops])
248
+ handle.stop();
164
249
  activeSpatialUpdaters.clear();
165
250
  director?.dispose();
166
251
  void context.close().catch(() => undefined);
@@ -7,16 +7,34 @@ type AudioMusicPayload = {
7
7
  theme: string | null;
8
8
  transpose?: number;
9
9
  };
10
- /** Minimal event bus the shell wires to the audio engine. @internal */
10
+ type AudioLoopStartPayload = {
11
+ id: string;
12
+ sound: string;
13
+ at?: readonly [number, number, number];
14
+ };
15
+ type AudioLoopSetPayload = {
16
+ id: string;
17
+ rate?: number;
18
+ gain?: number;
19
+ at?: readonly [number, number, number];
20
+ };
21
+ type AudioLoopStopPayload = {
22
+ id: string;
23
+ };
24
+ /** Minimal event bus the shell wires to the audio engine, including retained id-keyed loops (#1051). @internal */
11
25
  export type AudioEventBus = {
12
26
  on(event: "audio.play", handler: (payload: AudioPlayPayload) => void): () => void;
13
27
  on(event: "audio.music", handler: (payload: AudioMusicPayload) => void): () => void;
14
28
  on(event: "audio.resume", handler: () => void): () => void;
29
+ on(event: "audio.loopStart", handler: (payload: AudioLoopStartPayload) => void): () => void;
30
+ on(event: "audio.loopSet", handler: (payload: AudioLoopSetPayload) => void): () => void;
31
+ on(event: "audio.loopStop", handler: (payload: AudioLoopStopPayload) => void): () => void;
15
32
  };
16
33
  /**
17
- * Subscribe game audio events to the shell audio engine.
18
- * Returns a single unsubscribe that tears all three listeners down.
34
+ * Subscribe game audio events to the shell audio engine. Manages the retained-loop registry
35
+ * (`audio.loopStart`/`audio.loopSet`/`audio.loopStop`), keyed by stable id. Returns a single
36
+ * unsubscribe that tears every listener down and stops any loops still playing.
19
37
  * @internal
20
38
  */
21
- export declare function attachAudioEventWire(events: AudioEventBus, audioEngine: Pick<AudioEngine, "playOneShot" | "playMusic" | "resume">): () => void;
39
+ export declare function attachAudioEventWire(events: AudioEventBus, audioEngine: Pick<AudioEngine, "playOneShot" | "playLoop" | "playMusic" | "resume">): () => void;
22
40
  export {};
@@ -1,19 +1,62 @@
1
+ function toVec(at) {
2
+ return at === undefined ? undefined : { x: at[0], y: at[1], z: at[2] };
3
+ }
1
4
  /**
2
- * Subscribe game audio events to the shell audio engine.
3
- * Returns a single unsubscribe that tears all three listeners down.
5
+ * Subscribe game audio events to the shell audio engine. Manages the retained-loop registry
6
+ * (`audio.loopStart`/`audio.loopSet`/`audio.loopStop`), keyed by stable id. Returns a single
7
+ * unsubscribe that tears every listener down and stops any loops still playing.
4
8
  * @internal
5
9
  */
6
10
  export function attachAudioEventWire(events, audioEngine) {
11
+ const loops = new Map();
7
12
  const offPlay = events.on("audio.play", ({ sound, at }) => {
8
- audioEngine.playOneShot(sound, at === undefined ? undefined : { x: at[0], y: at[1], z: at[2] });
13
+ audioEngine.playOneShot(sound, toVec(at));
9
14
  });
10
15
  const offMusic = events.on("audio.music", ({ theme, transpose }) => {
11
16
  audioEngine.playMusic(theme, transpose === undefined ? undefined : { transpose });
12
17
  });
13
18
  const offResume = events.on("audio.resume", () => audioEngine.resume());
19
+ const offLoopStart = events.on("audio.loopStart", ({ id, sound, at }) => {
20
+ const existing = loops.get(id);
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)
24
+ return;
25
+ existing.handle.stop();
26
+ loops.delete(id);
27
+ }
28
+ const handle = audioEngine.playLoop(sound, toVec(at));
29
+ if (handle !== null)
30
+ loops.set(id, { sound, handle });
31
+ });
32
+ const offLoopSet = events.on("audio.loopSet", ({ id, rate, gain, at }) => {
33
+ const entry = loops.get(id);
34
+ // Unknown id: a live update may race a stop — silently drop it.
35
+ if (entry === undefined)
36
+ return;
37
+ if (rate !== undefined)
38
+ entry.handle.setRate(rate);
39
+ if (gain !== undefined)
40
+ entry.handle.setGain(gain);
41
+ if (at !== undefined)
42
+ entry.handle.setPosition({ x: at[0], y: at[1], z: at[2] });
43
+ });
44
+ const offLoopStop = events.on("audio.loopStop", ({ id }) => {
45
+ const entry = loops.get(id);
46
+ if (entry === undefined)
47
+ return;
48
+ entry.handle.stop();
49
+ loops.delete(id);
50
+ });
14
51
  return () => {
15
52
  offPlay();
16
53
  offMusic();
17
54
  offResume();
55
+ offLoopStart();
56
+ offLoopSet();
57
+ offLoopStop();
58
+ for (const { handle } of loops.values())
59
+ handle.stop();
60
+ loops.clear();
18
61
  };
19
62
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Pure clamps for retained-loop live control (#1051). Kept dependency-free so the
3
+ * playback-rate/gain windows are one testable source of truth the audio engine reads.
4
+ */
5
+ /** Lowest live playback-rate multiplier (two octaves below authored pitch). */
6
+ export declare const MIN_LOOP_RATE = 0.25;
7
+ /** Highest live playback-rate multiplier (two octaves above authored pitch). */
8
+ export declare const MAX_LOOP_RATE = 4;
9
+ /**
10
+ * Clamp a loop playback-rate multiplier into the safe pitch window `MIN_LOOP_RATE`–`MAX_LOOP_RATE`
11
+ * (1 = authored pitch). Non-finite input (NaN/±Infinity) falls back to the authored `1`.
12
+ * @internal — the audio engine applies this; game code sets rate through `ctx.game.audio.setLoop`.
13
+ */
14
+ export declare function clampLoopRate(rate: number): number;
15
+ /**
16
+ * Clamp a loop gain multiplier into `0`–`1`. Non-finite input falls back to `0` (silence) so a
17
+ * stray NaN never blasts a loop to full volume.
18
+ * @internal — the audio engine applies this; game code sets gain through `ctx.game.audio.setLoop`.
19
+ */
20
+ export declare function clampLoopGain(gain: number): number;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Pure clamps for retained-loop live control (#1051). Kept dependency-free so the
3
+ * playback-rate/gain windows are one testable source of truth the audio engine reads.
4
+ */
5
+ /** Lowest live playback-rate multiplier (two octaves below authored pitch). */
6
+ export const MIN_LOOP_RATE = 0.25;
7
+ /** Highest live playback-rate multiplier (two octaves above authored pitch). */
8
+ export const MAX_LOOP_RATE = 4;
9
+ /**
10
+ * Clamp a loop playback-rate multiplier into the safe pitch window `MIN_LOOP_RATE`–`MAX_LOOP_RATE`
11
+ * (1 = authored pitch). Non-finite input (NaN/±Infinity) falls back to the authored `1`.
12
+ * @internal — the audio engine applies this; game code sets rate through `ctx.game.audio.setLoop`.
13
+ */
14
+ export function clampLoopRate(rate) {
15
+ if (!Number.isFinite(rate))
16
+ return 1;
17
+ return Math.min(MAX_LOOP_RATE, Math.max(MIN_LOOP_RATE, rate));
18
+ }
19
+ /**
20
+ * Clamp a loop gain multiplier into `0`–`1`. Non-finite input falls back to `0` (silence) so a
21
+ * stray NaN never blasts a loop to full volume.
22
+ * @internal — the audio engine applies this; game code sets gain through `ctx.game.audio.setLoop`.
23
+ */
24
+ export function clampLoopGain(gain) {
25
+ if (!Number.isFinite(gain))
26
+ return 0;
27
+ return Math.min(1, Math.max(0, gain));
28
+ }
@@ -22,7 +22,12 @@ export function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerC
22
22
  const followEntityId = directed.followEntityId;
23
23
  const rig = (() => {
24
24
  if (directed.cinematic !== undefined) {
25
- return (_jsx(CinematicRig, { yawRef: yawRef, pitchRef: pitchRef, config: { ...config, cinematic: directed.cinematic }, followEntityId: followEntityId, absoluteFov: true }));
25
+ // A director-set cinematic auto-clears when its keyframe path completes, so a
26
+ // one-shot flyover can never strand the camera if a game-side clear timer drifts
27
+ // (the rig plays in real time; sim-clock timers do not). Static config cinematics
28
+ // hold their final frame as before.
29
+ const owner = director !== undefined && director.cinematic() !== null ? director : undefined;
30
+ return (_jsx(CinematicRig, { yawRef: yawRef, pitchRef: pitchRef, config: { ...config, cinematic: directed.cinematic }, followEntityId: followEntityId, absoluteFov: true, ...(owner === undefined ? {} : { onComplete: () => owner.setCinematic(null) }) }));
26
31
  }
27
32
  const kind = resolveRigKind(config);
28
33
  switch (kind) {
@@ -3,6 +3,7 @@ import { OrbitControls } from "@react-three/drei";
3
3
  import { useFrame, useThree } from "@react-three/fiber";
4
4
  import { useEffect, useRef } from "react";
5
5
  import { MOUSE, PerspectiveCamera, Raycaster, Vector3 } from "three";
6
+ import { isCameraOccluderTransparent } from "./cameraCollision.js";
6
7
  import { useGameContext } from "@jgengine/react/provider";
7
8
  import { usePlayer } from "@jgengine/react/hooks";
8
9
  import { cameraLookPitch, compensatedFov, ORBIT_CAMERA_FRAME_PRIORITY, orbitFollowStep, orbitYawFromCamera, resolveFollowTargetFromPosition, resolveOrbitCameraConfig, seedOrbitFollowState, } from "./orbitCameraMath.js";
@@ -81,6 +82,10 @@ export function GameOrbitCamera({ yawRef, pitchRef, config: configPatch, followE
81
82
  ray.set(collisionOriginRef.current.set(t.x, t.y, t.z), dir);
82
83
  ray.near = config.collision.minTargetDistance;
83
84
  ray.far = dist;
85
+ // Sprites (VFX, billboards) live in the scene; three's Sprite.raycast
86
+ // errors every frame unless the raycaster has a camera, even though we
87
+ // discard sprite hits below. Setting it silences that per-frame log.
88
+ ray.camera = camera;
84
89
  let blocked = 0;
85
90
  for (const hit of ray.intersectObjects(scene.children, true)) {
86
91
  const obj = hit.object;
@@ -88,7 +93,8 @@ export function GameOrbitCamera({ yawRef, pitchRef, config: configPatch, followE
88
93
  continue;
89
94
  if (obj.isSprite === true)
90
95
  continue;
91
- if (obj.userData.jgCameraTransparent === true)
96
+ // Inheritable: decor flags a group jgCameraTransparent and all children pass through.
97
+ if (isCameraOccluderTransparent(obj))
92
98
  continue;
93
99
  blocked = hit.distance;
94
100
  break;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Camera spring-arm occlusion filtering.
3
+ *
4
+ * The orbit rig raycasts target→camera each frame and pulls the boom in past
5
+ * occluders. Author decor mounted through the `WorldOverlay` presentation seam
6
+ * is dressing, not collision geometry, so it should never yank the camera. A
7
+ * decor group flags itself camera-transparent once (`jgCameraTransparent`) and
8
+ * every descendant inherits it — a child can opt back into blocking the camera
9
+ * with `jgCameraCollide`.
10
+ */
11
+ /** Minimal structural view of a raycast hit object: its own tags plus a parent link to walk. Keeps this filter free of a three.js import so it stays unit-testable. */
12
+ export interface CameraOccluder {
13
+ readonly userData?: Record<string, unknown>;
14
+ readonly parent?: CameraOccluder | null;
15
+ }
16
+ /** userData applied to the group wrapping author `WorldOverlay` decor so the spring-arm ignores it by default. */
17
+ export declare const CAMERA_TRANSPARENT_USERDATA: {
18
+ readonly jgCameraTransparent: true;
19
+ };
20
+ /**
21
+ * Should the camera spring-arm ignore this raycast hit? Walks the object up its
22
+ * `.parent` chain and honors the nearest camera tag: `jgCameraCollide === true`
23
+ * blocks (opt back in), `jgCameraTransparent === true` passes through. Untagged
24
+ * geometry blocks as before, so engine-owned ground/entities are unaffected.
25
+ *
26
+ * @capability camera-transparent-decor let author decor pass through the orbit spring-arm; a child opts back in with jgCameraCollide
27
+ */
28
+ export declare function isCameraOccluderTransparent(object: CameraOccluder | null | undefined): boolean;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Camera spring-arm occlusion filtering.
3
+ *
4
+ * The orbit rig raycasts target→camera each frame and pulls the boom in past
5
+ * occluders. Author decor mounted through the `WorldOverlay` presentation seam
6
+ * is dressing, not collision geometry, so it should never yank the camera. A
7
+ * decor group flags itself camera-transparent once (`jgCameraTransparent`) and
8
+ * every descendant inherits it — a child can opt back into blocking the camera
9
+ * with `jgCameraCollide`.
10
+ */
11
+ /** userData applied to the group wrapping author `WorldOverlay` decor so the spring-arm ignores it by default. */
12
+ export const CAMERA_TRANSPARENT_USERDATA = {
13
+ jgCameraTransparent: true,
14
+ };
15
+ /**
16
+ * Should the camera spring-arm ignore this raycast hit? Walks the object up its
17
+ * `.parent` chain and honors the nearest camera tag: `jgCameraCollide === true`
18
+ * blocks (opt back in), `jgCameraTransparent === true` passes through. Untagged
19
+ * geometry blocks as before, so engine-owned ground/entities are unaffected.
20
+ *
21
+ * @capability camera-transparent-decor let author decor pass through the orbit spring-arm; a child opts back in with jgCameraCollide
22
+ */
23
+ export function isCameraOccluderTransparent(object) {
24
+ for (let node = object ?? null; node != null; node = node.parent ?? null) {
25
+ const data = node.userData;
26
+ if (data === undefined)
27
+ continue;
28
+ if (data.jgCameraCollide === true)
29
+ return false;
30
+ if (data.jgCameraTransparent === true)
31
+ return true;
32
+ }
33
+ return false;
34
+ }
@@ -26,4 +26,6 @@ export declare function ChaseRig(props: RigProps): null;
26
26
  * kill-cam rig. Distinct from every other rig, which drives from mouse/keys.
27
27
  */
28
28
  export declare function ObserverRig(props: RigProps): null;
29
- export declare function CinematicRig(props: RigProps): null;
29
+ export declare function CinematicRig(props: RigProps & {
30
+ onComplete?: () => void;
31
+ }): null;
@@ -4,7 +4,7 @@ import { PerspectiveCamera, Quaternion, Vector3 } from "three";
4
4
  import { usePlayer } from "@jgengine/react/hooks";
5
5
  import { useGameContext } from "@jgengine/react/provider";
6
6
  import { ORBIT_CAMERA_FRAME_PRIORITY } from "./orbitCameraMath.js";
7
- import { bankRollStep, chaseDesiredPosition, chaseLookAt, cinematicSample, clamp, leadFollowPoint, lockOnPose, observerPose, resolveChase, resolveObserver, resolveShoulder, resolveSideScroll, resolveSideScrollPose, resolveTopDown, seatPose, shoulderPose, sideScrollFollowBlend, smoothYaw, speedToFov, springArmStep, topDownPose, } from "./rigMath.js";
7
+ import { bankRollStep, chaseDesiredPosition, chaseLookAt, cinematicSample, clamp, leadFollowPoint, lockOnPose, observerPose, resolveChase, resolveObserver, resolveShoulder, resolveSideScroll, resolveSideScrollPose, resolveTopDown, rtsPanWorldDir, seatPose, shoulderPose, sideScrollFollowBlend, smoothYaw, speedToFov, springArmStep, topDownPose, velocityYawTarget, } from "./rigMath.js";
8
8
  import { usePlayerFov } from "./PlayerFov.js";
9
9
  import { useCameraShake } from "./shakeChannel.js";
10
10
  import { applyCameraBlendStep, captureCameraBlendFrom, createCameraBlendScratch, } from "./cameraBlendMath.js";
@@ -262,13 +262,9 @@ export function RtsRig(props) {
262
262
  }
263
263
  const center = centerRef.current;
264
264
  if (panX !== 0 || panZ !== 0) {
265
- const cos = Math.cos(yawRef.current);
266
- const sin = Math.sin(yawRef.current);
267
- const worldX = panX * cos + panZ * sin;
268
- const worldZ = panZ * cos - panX * sin;
269
- const len = Math.hypot(worldX, worldZ) || 1;
270
- center.x += (worldX / len) * panSpeed * dt;
271
- center.z += (worldZ / len) * panSpeed * dt;
265
+ const dir = rtsPanWorldDir(panX, panZ, yawRef.current);
266
+ center.x += dir.x * panSpeed * dt;
267
+ center.z += dir.z * panSpeed * dt;
272
268
  }
273
269
  else {
274
270
  const follow = readFollow(ctx, followId);
@@ -424,6 +420,7 @@ export function ChaseRig(props) {
424
420
  const posRef = useRef(null);
425
421
  const lastFollowRef = useRef(null);
426
422
  const lastYawRef = useRef(null);
423
+ const anchorYawRef = useRef(null);
427
424
  const rollRef = useRef(0);
428
425
  const view = config?.view ?? "chase";
429
426
  useEffect(beginTransition, [view]);
@@ -454,15 +451,24 @@ export function ChaseRig(props) {
454
451
  commit(pose, dt);
455
452
  return;
456
453
  }
454
+ let anchorYaw = yaw;
455
+ if (resolved.velocityYawBlend > 0) {
456
+ const velocity = dt > 0
457
+ ? { x: (follow.x - last.x) / dt, y: 0, z: (follow.z - last.z) / dt }
458
+ : { x: 0, y: 0, z: 0 };
459
+ const targetYaw = velocityYawTarget(yaw, velocity, resolved);
460
+ anchorYaw = smoothYaw(anchorYawRef.current ?? targetYaw, targetYaw, resolved.velocityYawResponse, dt);
461
+ anchorYawRef.current = anchorYaw;
462
+ }
457
463
  const led = leadFollowPoint(follow, last, dt, resolved);
458
- const desired = chaseDesiredPosition(led, yaw, resolved);
464
+ const desired = chaseDesiredPosition(led, anchorYaw, resolved);
459
465
  const prev = posRef.current ?? desired;
460
466
  const smoothed = springArmStep(prev, desired, resolved.springDamping, dt);
461
467
  posRef.current = smoothed;
462
468
  if (resolved.shakePerSpeed > 0 && speed > 0) {
463
469
  shake.shake(Math.min(resolved.shakePerSpeed * speed * dt, 0.1));
464
470
  }
465
- const pose = { position: smoothed, lookAt: chaseLookAt(led, yaw, resolved), fov };
471
+ const pose = { position: smoothed, lookAt: chaseLookAt(led, anchorYaw, resolved), fov };
466
472
  commit(pose, dt);
467
473
  if (rollRef.current !== 0)
468
474
  camera.rotateZ(rollRef.current);
@@ -510,8 +516,12 @@ export function CinematicRig(props) {
510
516
  const cinematic = props.config?.cinematic;
511
517
  const { camera, commit, beginTransition } = useCameraCommit(props, followId);
512
518
  const elapsedRef = useRef(0);
519
+ const completedRef = useRef(false);
520
+ const onCompleteRef = useRef(props.onComplete);
521
+ onCompleteRef.current = props.onComplete;
513
522
  useEffect(() => {
514
523
  elapsedRef.current = 0;
524
+ completedRef.current = false;
515
525
  beginTransition();
516
526
  }, [cinematic]);
517
527
  useFrame((_, dt) => {
@@ -520,6 +530,10 @@ export function CinematicRig(props) {
520
530
  elapsedRef.current += dt;
521
531
  const sample = cinematicSample(cinematic.keyframes, elapsedRef.current, cinematic.loop ?? false, currentFov(camera));
522
532
  commit(sample.pose, dt);
533
+ if (sample.done && !completedRef.current) {
534
+ completedRef.current = true;
535
+ onCompleteRef.current?.();
536
+ }
523
537
  }, CAMERA_RIG_FRAME_PRIORITY);
524
538
  return null;
525
539
  }
@@ -7,3 +7,4 @@ export { PlayerFovProvider, PlayerFovSlider, usePlayerFov, type PlayerFovState,
7
7
  export { CameraShakeContext, cameraShake, createCameraShakeChannel, defaultCameraShakeChannel, useCameraShake, type CameraShakeChannel, } from "./shakeChannel.js";
8
8
  export { rtsPanKeysConflict } from "./rigMath.js";
9
9
  export { GAME_SIM_FRAME_PRIORITY } from "./orbitCameraMath.js";
10
+ export { CAMERA_TRANSPARENT_USERDATA, isCameraOccluderTransparent, type CameraOccluder, } from "./cameraCollision.js";
@@ -7,3 +7,4 @@ export { PlayerFovProvider, PlayerFovSlider, usePlayerFov, } from "./PlayerFov.j
7
7
  export { CameraShakeContext, cameraShake, createCameraShakeChannel, defaultCameraShakeChannel, useCameraShake, } from "./shakeChannel.js";
8
8
  export { rtsPanKeysConflict } from "./rigMath.js";
9
9
  export { GAME_SIM_FRAME_PRIORITY } from "./orbitCameraMath.js";
10
+ export { CAMERA_TRANSPARENT_USERDATA, isCameraOccluderTransparent, } from "./cameraCollision.js";
@@ -37,6 +37,13 @@ export declare function resolveTopDown(config: TopDownCameraConfig | undefined):
37
37
  * @internal
38
38
  */
39
39
  export declare function topDownPose(follow: Vec3, resolved: ResolvedTopDown, fov: number): CameraPose;
40
+ /**
41
+ * World-space direction for RTS pan input: `panX` slides along screen-right,
42
+ * `panZ` along the camera's ground-plane forward, for a camera posed by
43
+ * `topDownPose` at the same yaw. Normalized; zero input returns zero.
44
+ * @internal
45
+ */
46
+ export declare function rtsPanWorldDir(panX: number, panZ: number, yaw: number): Vec3;
40
47
  /** Exponential spring-arm approach toward a desired point (frame-rate independent).
41
48
  * @internal
42
49
  */
@@ -121,6 +128,12 @@ export interface ResolvedChase {
121
128
  bankPerYawRate: number;
122
129
  bankMax: number;
123
130
  bankDamping: number;
131
+ /** Max fraction of the slip angle the anchor yaw follows toward velocity; 0 = drift-lag off (#1051). */
132
+ velocityYawBlend: number;
133
+ /** Below this planar speed the drift-lag feature is fully off. */
134
+ velocityYawMinSpeed: number;
135
+ /** Exponential smoothing rate toward the blended anchor yaw (1-exp(-response*dt)). */
136
+ velocityYawResponse: number;
124
137
  }
125
138
  /** @internal */
126
139
  export declare function resolveChase(config: ChaseCameraConfig | undefined): ResolvedChase;
@@ -152,6 +165,20 @@ export declare function chaseDesiredPosition(follow: Vec3, yaw: number, resolved
152
165
  * @internal
153
166
  */
154
167
  export declare function chaseLookAt(follow: Vec3, yaw: number, resolved: ResolvedChase): Vec3;
168
+ /**
169
+ * Drift-lag (#1051): the yaw the chase anchor should aim before temporal
170
+ * smoothing. When the target slides, its planar `velocity` diverges from its
171
+ * `heading`; this rotates the anchor from heading partway toward the velocity
172
+ * direction so the camera reveals the car's side while travel stays framed. The
173
+ * traversed fraction is `velocityYawBlend` scaled by how far the slip angle has
174
+ * opened (0 at straight-line travel, full at a 90° slide), so ordinary driving
175
+ * is untouched. Returns the raw heading — no swing — when the feature is off,
176
+ * when planar speed is below `velocityYawMinSpeed`, or when the target is
177
+ * reversing (velocity opposed to heading forward). Blending is shortest-arc, so
178
+ * it stays correct across the ±PI wrap seam.
179
+ * @internal
180
+ */
181
+ export declare function velocityYawTarget(heading: number, velocity: Vec3, resolved: ResolvedChase): number;
155
182
  /** World-space seat pose (cockpit/hood/rear) rigidly attached to the vehicle.
156
183
  * @internal
157
184
  */