@jgengine/shell 0.11.0 → 0.12.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 (130) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/CombatPresentation.d.ts +5 -0
  3. package/dist/CombatPresentation.js +8 -0
  4. package/dist/GameHost.d.ts +25 -1
  5. package/dist/GameHost.js +60 -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 +48 -358
  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/cameraRigs.d.ts +3 -1
  25. package/dist/camera/cameraRigs.js +21 -3
  26. package/dist/camera/rigMath.d.ts +20 -0
  27. package/dist/camera/rigMath.js +29 -0
  28. package/dist/defineGame.d.ts +15 -1
  29. package/dist/defineGame.js +26 -4
  30. package/dist/devtools/DevtoolsOverlay.d.ts +4 -4
  31. package/dist/devtools/DevtoolsOverlay.js +28 -12
  32. package/dist/devtools/agentBridge.js +4 -0
  33. package/dist/devtools/perfDiagnose.js +1 -1
  34. package/dist/diagnostics/RuntimeDiagnostics.d.ts +7 -0
  35. package/dist/diagnostics/RuntimeDiagnostics.js +23 -12
  36. package/dist/environment/EnvironmentScene.js +12 -3
  37. package/dist/environment/terrainGroundColor.d.ts +9 -0
  38. package/dist/environment/terrainGroundColor.js +22 -0
  39. package/dist/gameKit.d.ts +27 -0
  40. package/dist/gameKit.js +29 -0
  41. package/dist/map/MapMarkerBeacons.d.ts +3 -2
  42. package/dist/postfx/PostProcessing.d.ts +9 -1
  43. package/dist/postfx/PostProcessing.js +11 -4
  44. package/dist/presentationResolve.d.ts +35 -0
  45. package/dist/presentationResolve.js +51 -0
  46. package/dist/registry.d.ts +1 -0
  47. package/dist/render/assetBase.d.ts +17 -0
  48. package/dist/render/assetBase.js +35 -0
  49. package/dist/render/modelLoad.js +4 -1
  50. package/dist/render/resolveModel.d.ts +15 -0
  51. package/dist/render/resolveModel.js +37 -2
  52. package/dist/scatter/InstancedScatter.js +5 -0
  53. package/dist/scatter/scatterProxies.js +24 -0
  54. package/dist/scene/AuthoredScene.d.ts +3 -1
  55. package/dist/scene/AuthoredScene.js +7 -2
  56. package/dist/scene/CityRenderer.d.ts +2 -0
  57. package/dist/scene/CityRenderer.js +1031 -0
  58. package/dist/scene/GrassFieldRenderer.js +53 -5
  59. package/dist/scene/PoleLineRenderer.d.ts +2 -0
  60. package/dist/scene/PoleLineRenderer.js +287 -0
  61. package/dist/scene/SoilPatchRenderer.js +70 -7
  62. package/dist/scene/WaterRenderer.js +34 -10
  63. package/dist/scene/builtinSceneKindRenderers.js +4 -0
  64. package/dist/scene/cityWindowMaterial.d.ts +16 -0
  65. package/dist/scene/cityWindowMaterial.js +81 -0
  66. package/dist/scene/sceneKindRenderers.d.ts +2 -0
  67. package/dist/settings/appliedSettings.d.ts +2 -1
  68. package/dist/settings/appliedSettings.js +4 -2
  69. package/dist/structures/TransformGizmo.d.ts +11 -1
  70. package/dist/structures/TransformGizmo.js +9 -5
  71. package/dist/terrain/GrassField.d.ts +11 -3
  72. package/dist/terrain/GrassField.js +16 -6
  73. package/dist/terrain/grassGeometry.d.ts +33 -1
  74. package/dist/terrain/grassGeometry.js +83 -21
  75. package/dist/terrain/grassMaterial.d.ts +18 -0
  76. package/dist/terrain/grassMaterial.js +66 -13
  77. package/dist/terrain/heightfieldGeometry.d.ts +34 -0
  78. package/dist/terrain/heightfieldGeometry.js +109 -0
  79. package/dist/terrain/index.d.ts +1 -0
  80. package/dist/terrain/index.js +1 -0
  81. package/dist/terrain/soilPatchMaterial.d.ts +15 -2
  82. package/dist/terrain/soilPatchMaterial.js +46 -8
  83. package/dist/terrain/terrainDetailMaterial.js +15 -2
  84. package/dist/water/Ocean.d.ts +7 -1
  85. package/dist/water/Ocean.js +16 -3
  86. package/dist/water/OceanConfig.d.ts +9 -0
  87. package/dist/water/OceanConfig.js +6 -0
  88. package/dist/water/OceanMaterial.d.ts +12 -0
  89. package/dist/water/OceanMaterial.js +9 -0
  90. package/dist/water/OceanShader.d.ts +2 -2
  91. package/dist/water/OceanShader.js +78 -10
  92. package/dist/world/PlaceScene.d.ts +11 -0
  93. package/dist/world/PlaceScene.js +27 -0
  94. package/dist/world/WorldScene.js +29 -2
  95. package/dist/worldSky.d.ts +7 -1
  96. package/dist/worldSky.js +11 -1
  97. package/package.json +4 -4
  98. package/skills/game-design/SKILL.md +28 -0
  99. package/skills/game-design/agents/openai.yaml +4 -0
  100. package/skills/game-design/references/application-playbook.md +170 -0
  101. package/skills/game-design/references/field-guide.md +288 -0
  102. package/skills/jgengine/SKILL.md +7 -4
  103. package/skills/jgengine/api.md +172 -79
  104. package/skills/jgengine/capabilities.md +104 -2
  105. package/skills/jgengine/recipes/minimal-game.md +88 -0
  106. package/skills/jgengine-ui/SKILL.md +15 -10
  107. package/skills/jgengine-ui/api.md +202 -47
  108. package/skills/jgengine-ui/capabilities.md +71 -2
  109. package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
  110. package/skills/jgengine-ui/reference.md +20 -7
  111. package/skills/jgengine-ui/references/visual-scorecard.md +1 -1
  112. package/skills/jgengine-world/SKILL.md +13 -0
  113. package/skills/jgengine-world/api.md +419 -15
  114. package/skills/jgengine-world/capabilities.md +108 -2
  115. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  116. package/skills/jgengine-world/reference.md +43 -4
  117. package/skills/level-design/SKILL.md +54 -0
  118. package/skills/level-design/agents/openai.yaml +4 -0
  119. package/skills/level-design/references/application-playbook.md +192 -0
  120. package/skills/level-design/references/field-guide.md +337 -0
  121. package/skills/level-design/references/live-editor-loop.md +179 -0
  122. package/dist/cartridge/runtime.d.ts +0 -15
  123. package/dist/cartridge/runtime.js +0 -556
  124. package/dist/cartridge/spec.d.ts +0 -188
  125. package/dist/cartridge/spec.js +0 -19
  126. package/dist/cartridge/validate.d.ts +0 -2
  127. package/dist/cartridge/validate.js +0 -78
  128. package/dist/cartridge.d.ts +0 -145
  129. package/dist/cartridge.js +0 -246
  130. 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) {
@@ -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, 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";
@@ -424,6 +424,7 @@ export function ChaseRig(props) {
424
424
  const posRef = useRef(null);
425
425
  const lastFollowRef = useRef(null);
426
426
  const lastYawRef = useRef(null);
427
+ const anchorYawRef = useRef(null);
427
428
  const rollRef = useRef(0);
428
429
  const view = config?.view ?? "chase";
429
430
  useEffect(beginTransition, [view]);
@@ -454,15 +455,24 @@ export function ChaseRig(props) {
454
455
  commit(pose, dt);
455
456
  return;
456
457
  }
458
+ let anchorYaw = yaw;
459
+ if (resolved.velocityYawBlend > 0) {
460
+ const velocity = dt > 0
461
+ ? { x: (follow.x - last.x) / dt, y: 0, z: (follow.z - last.z) / dt }
462
+ : { x: 0, y: 0, z: 0 };
463
+ const targetYaw = velocityYawTarget(yaw, velocity, resolved);
464
+ anchorYaw = smoothYaw(anchorYawRef.current ?? targetYaw, targetYaw, resolved.velocityYawResponse, dt);
465
+ anchorYawRef.current = anchorYaw;
466
+ }
457
467
  const led = leadFollowPoint(follow, last, dt, resolved);
458
- const desired = chaseDesiredPosition(led, yaw, resolved);
468
+ const desired = chaseDesiredPosition(led, anchorYaw, resolved);
459
469
  const prev = posRef.current ?? desired;
460
470
  const smoothed = springArmStep(prev, desired, resolved.springDamping, dt);
461
471
  posRef.current = smoothed;
462
472
  if (resolved.shakePerSpeed > 0 && speed > 0) {
463
473
  shake.shake(Math.min(resolved.shakePerSpeed * speed * dt, 0.1));
464
474
  }
465
- const pose = { position: smoothed, lookAt: chaseLookAt(led, yaw, resolved), fov };
475
+ const pose = { position: smoothed, lookAt: chaseLookAt(led, anchorYaw, resolved), fov };
466
476
  commit(pose, dt);
467
477
  if (rollRef.current !== 0)
468
478
  camera.rotateZ(rollRef.current);
@@ -510,8 +520,12 @@ export function CinematicRig(props) {
510
520
  const cinematic = props.config?.cinematic;
511
521
  const { camera, commit, beginTransition } = useCameraCommit(props, followId);
512
522
  const elapsedRef = useRef(0);
523
+ const completedRef = useRef(false);
524
+ const onCompleteRef = useRef(props.onComplete);
525
+ onCompleteRef.current = props.onComplete;
513
526
  useEffect(() => {
514
527
  elapsedRef.current = 0;
528
+ completedRef.current = false;
515
529
  beginTransition();
516
530
  }, [cinematic]);
517
531
  useFrame((_, dt) => {
@@ -520,6 +534,10 @@ export function CinematicRig(props) {
520
534
  elapsedRef.current += dt;
521
535
  const sample = cinematicSample(cinematic.keyframes, elapsedRef.current, cinematic.loop ?? false, currentFov(camera));
522
536
  commit(sample.pose, dt);
537
+ if (sample.done && !completedRef.current) {
538
+ completedRef.current = true;
539
+ onCompleteRef.current?.();
540
+ }
523
541
  }, CAMERA_RIG_FRAME_PRIORITY);
524
542
  return null;
525
543
  }
@@ -121,6 +121,12 @@ export interface ResolvedChase {
121
121
  bankPerYawRate: number;
122
122
  bankMax: number;
123
123
  bankDamping: number;
124
+ /** Max fraction of the slip angle the anchor yaw follows toward velocity; 0 = drift-lag off (#1051). */
125
+ velocityYawBlend: number;
126
+ /** Below this planar speed the drift-lag feature is fully off. */
127
+ velocityYawMinSpeed: number;
128
+ /** Exponential smoothing rate toward the blended anchor yaw (1-exp(-response*dt)). */
129
+ velocityYawResponse: number;
124
130
  }
125
131
  /** @internal */
126
132
  export declare function resolveChase(config: ChaseCameraConfig | undefined): ResolvedChase;
@@ -152,6 +158,20 @@ export declare function chaseDesiredPosition(follow: Vec3, yaw: number, resolved
152
158
  * @internal
153
159
  */
154
160
  export declare function chaseLookAt(follow: Vec3, yaw: number, resolved: ResolvedChase): Vec3;
161
+ /**
162
+ * Drift-lag (#1051): the yaw the chase anchor should aim before temporal
163
+ * smoothing. When the target slides, its planar `velocity` diverges from its
164
+ * `heading`; this rotates the anchor from heading partway toward the velocity
165
+ * direction so the camera reveals the car's side while travel stays framed. The
166
+ * traversed fraction is `velocityYawBlend` scaled by how far the slip angle has
167
+ * opened (0 at straight-line travel, full at a 90° slide), so ordinary driving
168
+ * is untouched. Returns the raw heading — no swing — when the feature is off,
169
+ * when planar speed is below `velocityYawMinSpeed`, or when the target is
170
+ * reversing (velocity opposed to heading forward). Blending is shortest-arc, so
171
+ * it stays correct across the ±PI wrap seam.
172
+ * @internal
173
+ */
174
+ export declare function velocityYawTarget(heading: number, velocity: Vec3, resolved: ResolvedChase): number;
155
175
  /** World-space seat pose (cockpit/hood/rear) rigidly attached to the vehicle.
156
176
  * @internal
157
177
  */
@@ -242,6 +242,9 @@ export function resolveChase(config) {
242
242
  bankPerYawRate: config?.bank?.perYawRate ?? 0,
243
243
  bankMax: config?.bank?.max ?? 0.35,
244
244
  bankDamping: config?.bank?.damping ?? 8,
245
+ velocityYawBlend: config?.velocityYaw === undefined ? 0 : config.velocityYaw.blend ?? 0.65,
246
+ velocityYawMinSpeed: config?.velocityYaw?.minSpeed ?? 4,
247
+ velocityYawResponse: config?.velocityYaw?.response ?? 6,
245
248
  };
246
249
  }
247
250
  /** Velocity-lead the follow point (#286.9): aim `leadTime` seconds along the target's frame velocity, clamped to `leadMax`.
@@ -323,6 +326,32 @@ export function chaseLookAt(follow, yaw, resolved) {
323
326
  z: follow.z + forward.z * 2,
324
327
  };
325
328
  }
329
+ /**
330
+ * Drift-lag (#1051): the yaw the chase anchor should aim before temporal
331
+ * smoothing. When the target slides, its planar `velocity` diverges from its
332
+ * `heading`; this rotates the anchor from heading partway toward the velocity
333
+ * direction so the camera reveals the car's side while travel stays framed. The
334
+ * traversed fraction is `velocityYawBlend` scaled by how far the slip angle has
335
+ * opened (0 at straight-line travel, full at a 90° slide), so ordinary driving
336
+ * is untouched. Returns the raw heading — no swing — when the feature is off,
337
+ * when planar speed is below `velocityYawMinSpeed`, or when the target is
338
+ * reversing (velocity opposed to heading forward). Blending is shortest-arc, so
339
+ * it stays correct across the ±PI wrap seam.
340
+ * @internal
341
+ */
342
+ export function velocityYawTarget(heading, velocity, resolved) {
343
+ if (resolved.velocityYawBlend <= 0)
344
+ return heading;
345
+ const speed = Math.hypot(velocity.x, velocity.z);
346
+ if (speed < resolved.velocityYawMinSpeed)
347
+ return heading;
348
+ const forward = forwardVector(heading);
349
+ if (forward.x * velocity.x + forward.z * velocity.z < 0)
350
+ return heading;
351
+ const slip = angleDelta(heading, Math.atan2(velocity.x, velocity.z));
352
+ const slipFraction = clamp(Math.abs(slip) / (Math.PI / 2), 0, 1);
353
+ return heading + slip * resolved.velocityYawBlend * slipFraction;
354
+ }
326
355
  /** World-space seat pose (cockpit/hood/rear) rigidly attached to the vehicle.
327
356
  * @internal
328
357
  */
@@ -1,7 +1,8 @@
1
- import type { ComponentType } from "react";
1
+ import { type ComponentType } from "react";
2
2
  import { type GameDefinitionConfig, type GameLoop } from "@jgengine/core/game/defineGame";
3
3
  import type { GameContext, GameContextContent } from "@jgengine/core/runtime/gameContext";
4
4
  import type { ModelAssetRef } from "@jgengine/core/scene/assetCatalog";
5
+ import type { ModelConfig } from "@jgengine/core/game/playableGame";
5
6
  import type { PlayableGame } from "./registry.js";
6
7
  type EngineFields<TAssetRef extends ModelAssetRef> = Omit<GameDefinitionConfig<TAssetRef>, "loop" | "ui" | "multiplayer"> & {
7
8
  multiplayer?: GameDefinitionConfig<TAssetRef>["multiplayer"];
@@ -10,7 +11,20 @@ type PresentationFields = Omit<PlayableGame, "game" | "content" | "loop" | "Game
10
11
  content?: GameContextContent;
11
12
  loop?: Partial<GameLoop<GameContext>>;
12
13
  GameUI?: ComponentType;
14
+ /** Tunes how the auto-mounted `AuthoredScene` places the document's catalog-id markers into the object store. Default `true`; pass `false` when the game spawns its placed content as entities itself (`placeAuthoredObjects`) to avoid a double render. */
15
+ scenePlacement?: boolean | {
16
+ verticalOffset?: number;
17
+ };
18
+ /** GLB models for the auto-mounted scene's scatter palette items, keyed by palette item id; string ids resolve through the game's asset catalog. Unmatched items keep the built-in proxy meshes. */
19
+ sceneScatterModels?: Record<string, string | ModelConfig>;
13
20
  };
14
21
  export type GameConfig<TAssetRef extends ModelAssetRef = ModelAssetRef> = EngineFields<TAssetRef> & PresentationFields;
22
+ /**
23
+ * The one public authoring entry point: compose engine fields (systems, world, physics, input) and
24
+ * presentation fields (camera, HUD, audio, authored scene) into a `PlayableGame` ready for `GameHost`.
25
+ * Defaults to solo/offline multiplayer; `editorLayers` auto-mounts the authored scene document.
26
+ *
27
+ * @capability define-game single public game-authoring path — compose systems, world, presentation, and authored scene in one definition
28
+ */
15
29
  export declare function defineGame<TAssetRef extends ModelAssetRef = ModelAssetRef>(config: GameConfig<TAssetRef>): PlayableGame;
16
30
  export {};
@@ -1,17 +1,34 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { defineGame as defineEngineGame, } from "@jgengine/core/game/defineGame";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo } from "react";
3
+ import { defineGameDefinition as defineEngineGame, } from "@jgengine/core/game/defineGame";
3
4
  import { syncLifecyclePhase } from "@jgengine/core/game/gamePhase";
4
5
  import { offline } from "@jgengine/core/runtime/adapter";
5
6
  import { EnvironmentScene } from "./environment.js";
7
+ import { terrainGroundColorSampler } from "./environment/terrainGroundColor.js";
8
+ import { AuthoredScene } from "./scene/AuthoredScene.js";
6
9
  const noop = () => { };
7
10
  function worldBackdrop(feature) {
8
11
  return function WorldBackdrop() {
9
12
  return _jsx(EnvironmentScene, { feature: feature });
10
13
  };
11
14
  }
15
+ function authoredSceneOverlay(document, placement, scatterModels, assets, world, Vfx) {
16
+ const terrain = world !== undefined && world.kind === "environment" ? world.terrain : undefined;
17
+ return function AuthoredSceneOverlay(props) {
18
+ const groundColorAt = useMemo(() => terrainGroundColorSampler(terrain, props.ctx.world.ground), [props.ctx.world.ground]);
19
+ return (_jsxs(_Fragment, { children: [_jsx(AuthoredScene, { document: document, field: props.ctx.world.ground, placeObjects: placement, ...(scatterModels === undefined ? {} : { scatterModels, assets }), ...(groundColorAt === undefined ? {} : { groundColorAt }) }), Vfx === undefined ? null : _jsx(Vfx, { ...props })] }));
20
+ };
21
+ }
12
22
  const emptyUi = () => null;
23
+ /**
24
+ * The one public authoring entry point: compose engine fields (systems, world, physics, input) and
25
+ * presentation fields (camera, HUD, audio, authored scene) into a `PlayableGame` ready for `GameHost`.
26
+ * Defaults to solo/offline multiplayer; `editorLayers` auto-mounts the authored scene document.
27
+ *
28
+ * @capability define-game single public game-authoring path — compose systems, world, presentation, and authored scene in one definition
29
+ */
13
30
  export function defineGame(config) {
14
- const { content, loop, GameUI, environment, camera, multiplayer, WorldOverlay, viewmodel, renderEntity, renderObject, entitySprites, entityModels, objectModels, hotbarSelection, prompts, pointer, touch, orientation, worldHealthBars, nameplates, audio, entitySounds, objectSounds, worldItem, collision, movement, lighting, backdrop, postProcessing, shadows, presentation, objectStyles, devtools, capture, platforms, hudFit, ...engineFields } = config;
31
+ const { content, loop, GameUI, environment, camera, multiplayer, editorLayers, editorCatalogs, scenePlacement, sceneScatterModels, WorldOverlay, viewmodel, renderEntity, renderObject, entitySprites, entityModels, objectModels, hotbarSelection, prompts, pointer, touch, orientation, worldHealthBars, nameplates, presentationEffects, audio, entitySounds, objectSounds, worldItem, collision, movement, lighting, backdrop, postProcessing, shadows, presentation, objectStyles, devtools, capture, platforms, hudFit, ...engineFields } = config;
15
32
  const game = defineEngineGame({
16
33
  ...engineFields,
17
34
  multiplayer: multiplayer ?? offline(),
@@ -39,7 +56,11 @@ export function defineGame(config) {
39
56
  environment: environment ??
40
57
  (game.world?.kind === "environment" ? worldBackdrop(game.world) : undefined),
41
58
  camera: camera ?? { perspective: "third" },
42
- WorldOverlay,
59
+ editorLayers,
60
+ editorCatalogs,
61
+ WorldOverlay: editorLayers === undefined
62
+ ? WorldOverlay
63
+ : authoredSceneOverlay(editorLayers, scenePlacement ?? true, sceneScatterModels, game.assets, game.world, WorldOverlay),
43
64
  viewmodel,
44
65
  renderEntity,
45
66
  renderObject,
@@ -53,6 +74,7 @@ export function defineGame(config) {
53
74
  orientation,
54
75
  worldHealthBars,
55
76
  nameplates,
77
+ presentationEffects,
56
78
  audio,
57
79
  entitySounds,
58
80
  objectSounds,
@@ -30,8 +30,8 @@ export declare function buildLeanReport(playable: PlayableGame): {
30
30
  pctOfSim: number;
31
31
  }[];
32
32
  } | null;
33
- render: import("@jgengine/core/devtools/devtools").RenderSample | null;
34
- latency: import("@jgengine/core/devtools/devtools").LatencyStats | null;
33
+ render: import("@jgengine/core/devtools/types").RenderSample | null;
34
+ latency: import("@jgengine/core/devtools/types").LatencyStats | null;
35
35
  longFrameSummary: {
36
36
  count: number;
37
37
  culprits: {
@@ -54,10 +54,10 @@ export declare function buildLeanReport(playable: PlayableGame): {
54
54
  ms: number;
55
55
  }[];
56
56
  probes: Record<string, unknown>;
57
- render: import("@jgengine/core/devtools/devtools").RenderSample | null;
57
+ render: import("@jgengine/core/devtools/types").RenderSample | null;
58
58
  }[];
59
59
  probes: Record<string, unknown>;
60
- logs: import("@jgengine/core/devtools/devtools").DevtoolsLogEntry[] | undefined;
60
+ logs: import("@jgengine/core/devtools/types").DevtoolsLogEntry[] | undefined;
61
61
  controls: {
62
62
  name: string;
63
63
  value: unknown;