@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,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
2
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
3
3
  import { createActionStateTracker, toActionStateBindingMap, } from "@jgengine/core/input/actionBindings";
4
4
  import { RESERVED_INPUT_ACTIONS } from "./boundActionDispatch.js";
5
5
  import { deriveTouchScheme, withTouchCodes, DEFAULT_TOUCH_STYLE } from "@jgengine/core/input/touchScheme";
@@ -13,14 +13,16 @@ import { RotateDeviceScreen } from "@jgengine/react/rotateDevice";
13
13
  import { createSettingsStore } from "@jgengine/core/settings/settingsModel";
14
14
  import { applyBindingOverrides, clearBindingOverride, loadBindingOverrides, saveBindingOverride, } from "@jgengine/core/input/bindingOverrides";
15
15
  import { orientationGateActive, orientationHintActive, resolveOrientationRequirement, } from "@jgengine/core/ui/orientation";
16
+ import { devtools } from "@jgengine/core/devtools/devtools";
16
17
  import { armFallbackSeams } from "@jgengine/core/devtools/fallbackSeams";
17
18
  import { armTextureErrors } from "@jgengine/core/devtools/textureErrors";
18
19
  import { readUrlFlag, subscribeUrlChange, writeUrlParam } from "@jgengine/core/devtools/urlFlags";
19
20
  import { createAudioEngine } from "./audio/audioEngine.js";
20
21
  import { attachAudioEventWire } from "./audio/audioWire.js";
21
22
  import { installAgentBridge } from "./devtools/agentBridge.js";
22
- import { withDevtoolsLatency } from "./devtools/DevtoolsOverlay.js";
23
- import { resolveRigKind } from "./camera/index.js";
23
+ import { attachPlaytestProbe } from "./devtools/playtestProbe.js";
24
+ import { withDevtoolsLatency } from "./devtools/latencyInstrumentation.js";
25
+ import { resolveRigKind } from "./camera/rigResolve.js";
24
26
  import { contextModels } from "./render/resolveModel.js";
25
27
  import { OrientationHint } from "./touch/OrientationHint.js";
26
28
  import { useTouchStyle, useTouchJoystickVariant, useGraphicsSettings } from "./settings/appliedSettings.js";
@@ -29,7 +31,13 @@ import { EMPTY_RESERVED } from "./shellConstants.js";
29
31
  import { JoinGate } from "./JoinGate.js";
30
32
  import { useShellMultiplayerSync } from "./useShellMultiplayerSync.js";
31
33
  import { ShellHudPresentation } from "./ShellHudPresentation.js";
32
- import { Shell3dPresentation } from "./Shell3dPresentation.js";
34
+ import { isServerAuthoritative } from "@jgengine/core/runtime/adapter";
35
+ import { playControlsActive } from "@jgengine/core/game/controlGate";
36
+ import { resolveInputSink } from "./inputSink.js";
37
+ import { observableShellTracker, attachShellInputPublication } from "./shellInputPublication.js";
38
+ import { attachShellControlSuspension } from "./shellControlSuspension.js";
39
+ import { LazyShell3dPresentation } from "./lazyShell3dPresentation.js";
40
+ import { createPresentationDiagnosticOwnership, createPlaySurfaceFocusOwnership, PresentationRecovery } from "./presentationRecovery.js";
33
41
  const DEV_USER_ID = "dev-player";
34
42
  /** The query param that mirrors the devtools overlay into the URL — present = open. */
35
43
  const DEBUG_PARAM = "debug";
@@ -73,8 +81,11 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
73
81
  const posterSettledRef = useRef(false);
74
82
  const [ctx, setCtx] = useState(null);
75
83
  const [diagnostics, setDiagnostics] = useState([]);
84
+ const diagnosticSequence = useRef(0);
85
+ const presentationDiagnostics = useRef(createPresentationDiagnosticOwnership());
76
86
  const [remotePlayers, setRemotePlayers] = useState([]);
77
87
  const wrapperRef = useRef(null);
88
+ const recoveredFocus = useRef(createPlaySurfaceFocusOwnership());
78
89
  const yawRef = useRef(0);
79
90
  const pitchRef = useRef(0);
80
91
  const serverIdRef = useRef(null);
@@ -100,7 +111,7 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
100
111
  [ctx, effectiveInput, contextVersion]);
101
112
  const activeBindingMap = useMemo(() => toActionStateBindingMap(withTouchCodes(activeInput)), [activeInput]);
102
113
  // One tracker per context, rebound in place so keys held across a context swap stay held.
103
- const tracker = useMemo(() => createActionStateTracker(activeBindingMap), [ctx]);
114
+ const tracker = useMemo(() => observableShellTracker(createActionStateTracker(activeBindingMap)), [ctx]);
104
115
  const boundMapRef = useRef(activeBindingMap);
105
116
  if (boundMapRef.current !== activeBindingMap) {
106
117
  boundMapRef.current = activeBindingMap;
@@ -115,8 +126,10 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
115
126
  };
116
127
  const deactivatePointerAxis = () => {
117
128
  const state = pointerAxisRef.current;
118
- if (state !== null && state.active)
129
+ if (state !== null && state.active) {
119
130
  pointerAxisRef.current = { ...state, active: false };
131
+ tracker.notify("discrete");
132
+ }
120
133
  };
121
134
  // Reads fresh every render: the ctx-version useSyncExternalStore below re-renders on store
122
135
  // writes, so a gameplay setTouchControlsMode("car") swaps the visible control set that frame.
@@ -138,9 +151,31 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
138
151
  onCodeUp: (code) => tracker.handleUp(code),
139
152
  onAnalog: (values) => {
140
153
  analogRef.current = values;
154
+ tracker.notify("analog");
141
155
  },
142
156
  }), [tracker]);
157
+ useLayoutEffect(() => {
158
+ if (ctx === null)
159
+ return;
160
+ return attachShellControlSuspension({
161
+ ctx, tracker, pointerAxisRef, analogRef, primaryClickRef, cameraDraggingRef, f2HeldRef,
162
+ });
163
+ }, [ctx, tracker]);
143
164
  const gateRef = useRef(false);
165
+ useLayoutEffect(() => {
166
+ if (ctx === null || poster)
167
+ return;
168
+ return attachShellInputPublication({
169
+ ctx, tracker,
170
+ active: () => !gateRef.current && playControlsActive(ctx),
171
+ analog: () => analogRef.current,
172
+ pointer: () => pointerAxisRef.current,
173
+ sink: () => resolveInputSink({
174
+ serverAuthoritative: isServerAuthoritative(playable.game.multiplayer) && multiplayer !== null,
175
+ backend: multiplayer?.backend ?? null, serverId: serverIdRef.current,
176
+ }),
177
+ });
178
+ }, [ctx, tracker, multiplayer, playable, poster]);
144
179
  const orientationPlatform = coarsePointer ? "mobile" : "desktop";
145
180
  const orientationRequirement = useMemo(() => resolveOrientationRequirement(playable.orientation, orientationPlatform), [playable, orientationPlatform]);
146
181
  const liveOrientation = portrait ? "portrait" : "landscape";
@@ -150,6 +185,8 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
150
185
  const orientationGateEl = orientationGate ? (_jsx(RotateDeviceScreen, { requiredOrientation: orientationRequirement.required ?? "landscape", title: orientationRequirement.required === "portrait" ? "Turn your phone upright" : "Turn your phone sideways", description: `${playable.game.name} is built for ${orientationRequirement.required ?? "landscape"} play.` })) : orientationHint && orientationRequirement.preferred !== null ? (_jsx(OrientationHint, { wanted: orientationRequirement.preferred })) : null;
151
186
  const audioEngine = useMemo(() => createAudioEngine({
152
187
  sounds: playable.audio?.sounds,
188
+ maxVoices: playable.audio?.maxVoices,
189
+ voiceOverflow: playable.audio?.voiceOverflow,
153
190
  buses: playable.audio?.buses,
154
191
  music: playable.audio?.music,
155
192
  musicBus: playable.audio?.musicBus,
@@ -185,10 +222,59 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
185
222
  const userId = multiplayer?.userId ?? DEV_USER_ID;
186
223
  const reportRuntimeError = (error, phase, componentStack) => {
187
224
  const diagnostic = logRuntimeError(error, phase, componentStack);
188
- setDiagnostics((current) => [...current.slice(-4), { ...diagnostic, id: Date.now() + current.length }]);
225
+ const id = ++diagnosticSequence.current;
226
+ presentationDiagnostics.current.register(error, id, phase);
227
+ setDiagnostics((current) => [...current.slice(-4), { ...diagnostic, id }]);
228
+ };
229
+ const recoveryTrace = useRef([]);
230
+ const traceRecovery = (event) => {
231
+ if (!devtoolsEnabled)
232
+ return;
233
+ const wrapper = wrapperRef.current;
234
+ const active = document.activeElement;
235
+ const rect = wrapper?.getBoundingClientRect();
236
+ recoveryTrace.current.push({ ...event, at: performance.now(), connected: wrapper?.isConnected ?? false,
237
+ rects: wrapper?.getClientRects().length ?? 0, width: rect?.width ?? null, height: rect?.height ?? null,
238
+ display: wrapper === null ? null : getComputedStyle(wrapper).display,
239
+ active: active?.tagName ?? null, ownsFocus: active === wrapper, focusInside: wrapper?.contains(active) ?? false });
240
+ };
241
+ useEffect(() => {
242
+ recoveryTrace.current = [];
243
+ if (!devtoolsEnabled)
244
+ return;
245
+ return devtools.probes.register("presentationRecovery", () => recoveryTrace.current);
246
+ }, [ctx, devtoolsEnabled]);
247
+ const revealRecoveredPresentation = () => {
248
+ if (ctx === null)
249
+ return;
250
+ traceRecovery({ phase: "shell-reveal-layout" });
251
+ recoveredFocus.current.reveal(ctx, wrapperRef.current, document.activeElement, document.body);
252
+ traceRecovery({ phase: "shell-focus-result" });
253
+ };
254
+ const retireRecoveredPresentation = (error) => {
255
+ traceRecovery({ phase: "shell-recovered-callback" });
256
+ const owned = presentationDiagnostics.current.recovered(error);
257
+ setDiagnostics((current) => current.filter((diagnostic) => !owned.has(diagnostic.id)));
258
+ if (ctx !== null)
259
+ recoveredFocus.current.request(ctx);
260
+ revealRecoveredPresentation();
189
261
  };
190
262
  useEffect(() => {
191
263
  setDiagnostics([]);
264
+ let detachProbe = () => { };
265
+ let ownedContext = null;
266
+ const disposeOwnedContext = () => {
267
+ const context = ownedContext;
268
+ ownedContext = null;
269
+ if (context === null)
270
+ return;
271
+ try {
272
+ playable.loop.onDispose(context);
273
+ }
274
+ catch (error) {
275
+ reportRuntimeError(error, "dispose");
276
+ }
277
+ };
192
278
  try {
193
279
  const models = contextModels(playable);
194
280
  const context = createGameContext({
@@ -197,19 +283,31 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
197
283
  player: { userId, isNew: true },
198
284
  ...(models === undefined ? {} : { models }),
199
285
  });
286
+ ownedContext = context;
200
287
  if (playable.localPlayers !== undefined)
201
288
  localPlayers(context).retune(playable.localPlayers);
202
289
  playable.loop.onInit(context);
203
290
  playable.loop.onNewPlayer(context);
204
291
  onContextReady?.(context);
292
+ const probe = playable.capture?.probe;
293
+ if (probe !== undefined && typeof window !== "undefined") {
294
+ detachProbe = attachPlaytestProbe(window, () => probe(context));
295
+ }
205
296
  setCtx(context);
206
297
  }
207
298
  catch (error) {
299
+ disposeOwnedContext();
208
300
  reportRuntimeError(error, "init");
209
301
  setCtx(null);
210
302
  }
211
303
  return () => {
212
- setCtx(null);
304
+ try {
305
+ detachProbe();
306
+ }
307
+ finally {
308
+ disposeOwnedContext();
309
+ setCtx(null);
310
+ }
213
311
  };
214
312
  }, [playable, userId]);
215
313
  const authoritativeFrameRef = useRef(null);
@@ -254,12 +352,12 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
254
352
  deactivatePointerAxis,
255
353
  };
256
354
  if (rigKind === "none" || playable.presentation === "hud") {
257
- return (_jsx(ShellHudPresentation, { ...shared, uiScale: graphics.uiScale, onPointerResumeAudio: () => audioEngine.resume() }));
355
+ return (_jsx(PresentationRecovery, { ctx: ctx, onRuntimeError: reportRuntimeError, onRetryCommitted: () => wrapperRef.current?.focus({ preventScroll: true }), onRecoveredDraw: retireRecoveredPresentation, onRecoveryEvent: traceRecovery, children: _jsx(ShellHudPresentation, { ...shared, serverIdRef: serverIdRef, uiScale: graphics.uiScale, onPointerResumeAudio: () => audioEngine.resume(), settingsStore: settingsStore, bindingOverrides: bindingOverrides, rebindAction: rebindAction, resetActionBinding: resetActionBinding, audioEngine: audioEngine, poster: poster }) }));
258
356
  }
259
- return (_jsx(Shell3dPresentation, { ...shared, primaryClickRef: primaryClickRef, cameraDraggingRef: cameraDraggingRef, serverIdRef: serverIdRef, remotePlayers: remotePlayers, touchStyle: touchStyle, compact: compact, graphics: graphics, settingsStore: settingsStore, bindingOverrides: bindingOverrides, rebindAction: rebindAction, resetActionBinding: resetActionBinding, audioEngine: audioEngine, poster: poster, posterFrozen: posterFrozen, onPosterSettled: () => {
260
- if (posterSettledRef.current)
261
- return;
262
- posterSettledRef.current = true;
263
- setPosterFrozen(true);
264
- }, authoritativeFrameRef: authoritativeFrameRef }));
357
+ return (_jsx(PresentationRecovery, { ctx: ctx, onRuntimeError: reportRuntimeError, onRetryCommitted: () => wrapperRef.current?.focus({ preventScroll: true }), onRecoveredDraw: retireRecoveredPresentation, onRecoveryEvent: traceRecovery, children: _jsx(LazyShell3dPresentation, { ...shared, onPresentationReveal: revealRecoveredPresentation, primaryClickRef: primaryClickRef, cameraDraggingRef: cameraDraggingRef, serverIdRef: serverIdRef, remotePlayers: remotePlayers, touchStyle: touchStyle, compact: compact, graphics: graphics, settingsStore: settingsStore, bindingOverrides: bindingOverrides, rebindAction: rebindAction, resetActionBinding: resetActionBinding, audioEngine: audioEngine, poster: poster, posterFrozen: posterFrozen, onPosterSettled: () => {
358
+ if (posterSettledRef.current)
359
+ return;
360
+ posterSettledRef.current = true;
361
+ setPosterFrozen(true);
362
+ }, authoritativeFrameRef: authoritativeFrameRef }) }));
265
363
  }
@@ -1,6 +1,6 @@
1
1
  import { type Dispatch, type MutableRefObject, type RefObject, type SetStateAction } from "react";
2
2
  import type { PointerAxisState } from "@jgengine/core/input/pointerAxis";
3
- import type { ActionStateTracker } from "@jgengine/core/input/actionBindings";
3
+ import type { observableShellTracker } from "./shellInputPublication.js";
4
4
  import type { BindingOverrides } from "@jgengine/core/input/bindingOverrides";
5
5
  import type { TouchJoystickVariant, TouchScheme, TouchStyle } from "@jgengine/core/input/touchScheme";
6
6
  import { type SettingsStore } from "@jgengine/core/settings/settingsModel";
@@ -18,7 +18,7 @@ export declare function Shell3dPresentation({ playable, ctx, multiplayer, tracke
18
18
  playable: PlayableGame;
19
19
  ctx: GameContext;
20
20
  multiplayer: ShellMultiplayer | null;
21
- tracker: ActionStateTracker<string>;
21
+ tracker: ReturnType<typeof observableShellTracker>;
22
22
  pointerAxisRef: MutableRefObject<PointerAxisState | null>;
23
23
  gateRef: MutableRefObject<boolean>;
24
24
  wrapperRef: RefObject<HTMLDivElement | null>;
@@ -1,6 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Canvas } from "@react-three/fiber";
3
- import { useMemo, useRef, useState, } from "react";
3
+ import { useMemo, useEffect, useRef, useState, useSyncExternalStore, } from "react";
4
4
  import { useFrame, useThree } from "@react-three/fiber";
5
5
  import { contextVerbInput, } from "@jgengine/core/interaction/contextMenu";
6
6
  import { createSelectionSet, isMarquee, screenRect, selectWithinRect, } from "@jgengine/core/scene/selection";
@@ -15,7 +15,9 @@ import { GameViewportProvider } from "@jgengine/react/gameViewport";
15
15
  import { GameProvider } from "@jgengine/react/provider";
16
16
  import { SettingsProvider } from "@jgengine/react/settings";
17
17
  import { resolveWorldSky } from "./worldSky.js";
18
+ import { resolveRuntimeCameraConfig } from "./camera/rigResolve.js";
18
19
  import { pointerAimFor, pointerContextMenu } from "./shellPointer.js";
20
+ import { shellPointerInput } from "./shellPointerInput.js";
19
21
  import { AudioListener, EntityAudioEmitters, ObjectAudioEmitters } from "./audio/AudioComponents.js";
20
22
  import { PostProcessing } from "./postfx/PostProcessing.js";
21
23
  import { EnvironmentLighting } from "./render/EnvironmentLighting.js";
@@ -23,6 +25,7 @@ import { CollisionDebugWorld } from "./devtools/CollisionDebugWorld.js";
23
25
  import { DevtoolsRendererProbe } from "./devtools/DevtoolsOverlay.js";
24
26
  import { CAMERA_TRANSPARENT_USERDATA, GameCameraRig, PlayerFovProvider, PlayerFovSlider, resolveRigKind, rtsPanKeysConflict, } from "./camera/index.js";
25
27
  import { CullingProvider } from "./visibility/CullingProvider.js";
28
+ import { BuildingRenderBudget } from "./structures/BuildingRenderBudget.js";
26
29
  import { SkyDaylight, TimeOfDayDaylight } from "./environment.js";
27
30
  import { bearingFromDirection } from "./environment/daylightCycle.js";
28
31
  import { resolveSkyLightOwnership, skyEmitsLights } from "./environment/skyLightingPolicy.js";
@@ -81,12 +84,14 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
81
84
  const pointerService = useMemo(() => createPointerService(), []);
82
85
  const selection = useMemo(() => createSelectionSet(), [playable]);
83
86
  const controlledEntityId = ctx.player.possession.active(ctx.player.userId);
84
- const cameraConfig = playable.camera?.followEntityId !== undefined
87
+ const cameraOverride = useSyncExternalStore(ctx.camera.subscribe, ctx.camera.rig, ctx.camera.rig);
88
+ const configuredCamera = playable.camera?.followEntityId !== undefined
85
89
  ? playable.camera
86
90
  : { ...playable.camera, followEntityId: controlledEntityId };
91
+ const cameraConfig = resolveRuntimeCameraConfig(configuredCamera, cameraOverride);
87
92
  const rigKind = resolveRigKind(cameraConfig);
88
93
  const firstPerson = rigKind === "first";
89
- const showReticle = (firstPerson && playable.camera?.firstPerson?.reticle !== false) || rigKind === "shoulder";
94
+ const showReticle = (firstPerson && cameraConfig?.firstPerson?.reticle !== false) || rigKind === "shoulder";
90
95
  const rtsPanKeysEnabled = !rtsPanKeysConflict(playable.game.input);
91
96
  const bars = resolveWorldOverlayBars(playable.worldHealthBars);
92
97
  const nameplates = resolveWorldOverlayBars(playable.nameplates);
@@ -152,7 +157,36 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
152
157
  };
153
158
  /** DOM UI sits in the same wrapper as the canvas — only canvas-targeted clicks are world input. */
154
159
  const isWorldPointerTarget = (event) => event.target instanceof HTMLCanvasElement;
160
+ const pointerInput = useMemo(() => shellPointerInput(tracker, () => playControlsActive(ctx)), [tracker, ctx]);
161
+ useEffect(() => {
162
+ const clearGesture = () => { pointerDownRef.current = null; marqueeStartRef.current = null; setMarquee(null); };
163
+ const up = (event) => {
164
+ pointerInput.up(event);
165
+ if (!(event.target instanceof Node) || !wrapperRef.current?.contains(event.target))
166
+ clearGesture();
167
+ };
168
+ const cancel = (event) => { pointerInput.cancel(event.pointerId); clearGesture(); };
169
+ const retire = () => { pointerInput.cancel(); clearGesture(); };
170
+ const blur = () => { tracker.reset(); retire(); };
171
+ const move = (event) => { pointerInput.move(event, false); };
172
+ const detachReset = pointerInput.attachReset(tracker, clearGesture);
173
+ window.addEventListener("pointerup", up);
174
+ window.addEventListener("pointermove", move);
175
+ window.addEventListener("pointercancel", cancel);
176
+ window.addEventListener("blur", blur);
177
+ return () => {
178
+ window.removeEventListener("pointerup", up);
179
+ window.removeEventListener("pointermove", move);
180
+ window.removeEventListener("pointercancel", cancel);
181
+ window.removeEventListener("blur", blur);
182
+ detachReset();
183
+ pointerInput.cancel();
184
+ };
185
+ }, [pointerInput, tracker]);
155
186
  const handlePointerDown = (event) => {
187
+ pointerInput.down(event, isWorldPointerTarget(event));
188
+ if (!playControlsActive(ctx))
189
+ return;
156
190
  if (isWorldPointerTarget(event))
157
191
  wrapperRef.current?.focus();
158
192
  trackPointerAxis(event);
@@ -167,6 +201,7 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
167
201
  }
168
202
  };
169
203
  const handlePointerMove = (event) => {
204
+ pointerInput.move(event, isWorldPointerTarget(event));
170
205
  trackPointerAxis(event);
171
206
  if (pointer?.select !== true)
172
207
  return;
@@ -177,6 +212,13 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
177
212
  setMarquee(screenRect(start.x, start.y, point.x, point.y));
178
213
  };
179
214
  const handlePointerUp = (event) => {
215
+ const boundPointer = pointerInput.up(event);
216
+ if (!playControlsActive(ctx)) {
217
+ pointerDownRef.current = null;
218
+ marqueeStartRef.current = null;
219
+ setMarquee(null);
220
+ return;
221
+ }
180
222
  if (event.button !== 0 || pointerDownRef.current === null)
181
223
  return;
182
224
  const start = pointerDownRef.current;
@@ -227,7 +269,7 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
227
269
  return;
228
270
  }
229
271
  const moved = (end.x - start.x) ** 2 + (end.y - start.y) ** 2;
230
- if (moved <= PRIMARY_CLICK_MOVE_THRESHOLD_PX * PRIMARY_CLICK_MOVE_THRESHOLD_PX)
272
+ if (!boundPointer && moved <= PRIMARY_CLICK_MOVE_THRESHOLD_PX * PRIMARY_CLICK_MOVE_THRESHOLD_PX)
231
273
  primaryClickRef.current = true;
232
274
  };
233
275
  const handleContextMenu = (event) => {
@@ -331,9 +373,9 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
331
373
  fov: playable.camera?.frustum?.fov ?? CAMERA_FRUSTUM_DEFAULTS.fov,
332
374
  near: playable.camera?.frustum?.near ?? CAMERA_FRUSTUM_DEFAULTS.near,
333
375
  far: playable.camera?.frustum?.far ?? CAMERA_FRUSTUM_DEFAULTS.far,
334
- }, shadows: graphics.shadows, dpr: [Math.min(1, graphics.dpr), graphics.dpr], gl: { preserveDrawingBuffer: true, powerPreference: "high-performance" }, style: { touchAction: "none" }, children: [_jsx(TextureFiltering, { anisotropy: graphics.profile.anisotropy }), graphics.frameRateLimit > 0 && !poster ? _jsx(FrameRateLimiter, { fps: graphics.frameRateLimit }) : null, graphics.showFps ? _jsx(FpsProbe, { tally: frameTally }) : null, orthographic && playable.camera?.pixelPerfect !== undefined ? (_jsx(PixelPerfectCamera, { ...playable.camera.pixelPerfect })) : null, backgroundColor !== undefined ? _jsx("color", { attach: "background", args: [backgroundColor] }) : null, cinematicLook ? (_jsx(EnvironmentLighting, { source: playable.environmentSource, skyColor: lighting?.hemisphere?.skyColor ?? "#87b5e0", groundColor: lighting?.hemisphere?.groundColor ?? "#3d4a38", sunDirection: lighting?.directional?.[0]?.position, sunColor: lighting?.directional?.[0]?.color ?? "#fff2d6" })) : null, lighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: lighting, profile: graphics.profile })) : effectiveSky === undefined ? (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] })) : null, effectiveSky !== undefined ? (effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) })) : (_jsx(SkyDaylight, { sky: effectiveSky, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) }))) : null, _jsx(BackdropFog, { fog: backdrop?.fog }), _jsxs(GameProvider, { context: ctx, children: [_jsx(CullingProvider, { config: playable.visibility, drawDistance: graphics.profile.drawDistance, children: _jsx(WorldView, { entitySprites: playable.entitySprites, entityModels: playable.entityModels, objectModels: playable.objectModels, objectStyles: playable.objectStyles, environment: AutoEnvironment, assets: playable.game.assets, renderEntity: playable.renderEntity, renderObject: playable.renderObject, selectedIds: selectedIds, hideLocalActor: firstPerson }) }), WorldOverlay !== undefined ? (_jsx("group", { userData: CAMERA_TRANSPARENT_USERDATA, children: _jsx(WorldOverlay, { ctx: ctx }) })) : null, bars !== null ? (_jsx(WorldEntityBars, { statId: bars.statId, roles: bars.roles, resolveRole: resolveEntityRole, ...(bars.maxDistance === undefined ? {} : { maxDistance: bars.maxDistance }), ...(bars.occlude === undefined ? {} : { occlude: bars.occlude }) })) : null, nameplates !== null ? (_jsx(WorldNameplates, { statId: nameplates.statId, roles: nameplates.roles, resolveRole: resolveEntityRole, ...(nameplates.maxDistance === undefined ? {} : { maxDistance: nameplates.maxDistance }), ...(nameplates.showHealth === undefined ? {} : { showHealth: nameplates.showHealth }) })) : null, _jsx(WorldItems, { config: playable.worldItem }), _jsx(WorldParticles, { quality: graphics.quality, particleCap: graphics.profile.particleCap }), _jsx(CombatPresentation, { effects: effects }), devtoolsEnabled ? _jsx(CollisionDebugWorld, {}) : null, _jsx(AudioListener, { engine: audioEngine }), _jsx(EntityAudioEmitters, { engine: audioEngine, entitySounds: playable.entitySounds }), _jsx(ObjectAudioEmitters, { engine: audioEngine, objectSounds: playable.objectSounds }), _jsx(GameCameraRig, { yawRef: yawRef, pitchRef: pitchRef, config: cameraConfig, viewmodel: playable.viewmodel, pointerControls: pointerUsesLeft, panKeysEnabled: rtsPanKeysEnabled, director: ctx.camera, onDragChange: (dragging) => {
335
- cameraDraggingRef.current = dragging;
336
- } }), _jsx(PointerProbe, { service: pointerService })] }), _jsx(RemotePlayers, { rows: remotePlayers }), _jsx(FrameDriver, { ctx: ctx, playable: playable, tracker: tracker, yawRef: yawRef, pitchRef: pitchRef, primaryClickRef: primaryClickRef, pointerAxisRef: pointerAxisRef, analogRef: analogRef, gateRef: gateRef, onRuntimeError: reportRuntimeError, multiplayer: multiplayer, serverIdRef: serverIdRef, pointerService: pointerService, pointerAim: pointer?.aim === true, pingCommand: pointer?.pingCommand, poster: poster, onPosterSettled: onPosterSettled, authoritativeFrameRef: authoritativeFrameRef }), _jsx(GamepadSource, { tracker: tracker, bindings: inputBindings ?? {}, analogRef: analogRef, input: ctx.input, feel: playable.gamepad, seats: localPlayers(ctx), onSeatJoin: (slot) => {
376
+ }, shadows: graphics.shadows, dpr: [Math.min(1, graphics.dpr), graphics.dpr], gl: { preserveDrawingBuffer: true, powerPreference: "high-performance" }, style: { touchAction: "none" }, children: [_jsx(TextureFiltering, { anisotropy: graphics.profile.anisotropy }), graphics.frameRateLimit > 0 && !poster ? _jsx(FrameRateLimiter, { fps: graphics.frameRateLimit }) : null, graphics.showFps ? _jsx(FpsProbe, { tally: frameTally }) : null, orthographic && playable.camera?.pixelPerfect !== undefined ? (_jsx(PixelPerfectCamera, { ...playable.camera.pixelPerfect })) : null, backgroundColor !== undefined ? _jsx("color", { attach: "background", args: [backgroundColor] }) : null, cinematicLook ? (_jsx(EnvironmentLighting, { source: playable.environmentSource, skyColor: lighting?.hemisphere?.skyColor ?? "#87b5e0", groundColor: lighting?.hemisphere?.groundColor ?? "#3d4a38", sunDirection: lighting?.directional?.[0]?.position, sunColor: lighting?.directional?.[0]?.color ?? "#fff2d6" })) : null, lighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: lighting, profile: graphics.profile })) : effectiveSky === undefined ? (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] })) : null, effectiveSky !== undefined ? (effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) })) : (_jsx(SkyDaylight, { sky: effectiveSky, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) }))) : null, _jsx(BackdropFog, { fog: backdrop?.fog }), _jsx(GameProvider, { context: ctx, children: _jsxs(BuildingRenderBudget, { drawDistance: graphics.profile.drawDistance, visibility: playable.visibility, cullingDisabled: splitScreen, children: [_jsxs(CullingProvider, { config: playable.visibility, drawDistance: graphics.profile.drawDistance, children: [_jsx(WorldView, { entitySprites: playable.entitySprites, entityModels: playable.entityModels, objectModels: playable.objectModels, objectStyles: playable.objectStyles, environment: AutoEnvironment, assets: playable.game.assets, renderEntity: playable.renderEntity, renderObject: playable.renderObject, selectedIds: selectedIds, hideLocalActor: firstPerson, editorLayers: playable.editorLayers }), bars !== null ? (_jsx(WorldEntityBars, { statId: bars.statId, roles: bars.roles, resolveRole: resolveEntityRole, ...(bars.maxSamples === undefined ? {} : { maxSamples: bars.maxSamples }), ...(bars.tickMs === undefined ? {} : { tickMs: bars.tickMs }), ...(bars.maxDistance === undefined ? {} : { maxDistance: bars.maxDistance }), ...(bars.occlude === undefined ? {} : { occlude: bars.occlude }) })) : null, nameplates !== null ? (_jsx(WorldNameplates, { statId: nameplates.statId, roles: nameplates.roles, resolveRole: resolveEntityRole, ...(nameplates.maxSamples === undefined ? {} : { maxSamples: nameplates.maxSamples }), ...(nameplates.tickMs === undefined ? {} : { tickMs: nameplates.tickMs }), ...(nameplates.maxDistance === undefined ? {} : { maxDistance: nameplates.maxDistance }), occlude: nameplates.occlude, ...(nameplates.resolveName === undefined ? {} : { resolveName: nameplates.resolveName }), ...(nameplates.showHealth === undefined ? {} : { showHealth: nameplates.showHealth }) })) : null, WorldOverlay !== undefined ? (_jsx("group", { userData: CAMERA_TRANSPARENT_USERDATA, children: _jsx(WorldOverlay, { ctx: ctx }) })) : null] }), _jsx(WorldItems, { config: playable.worldItem }), _jsx(WorldParticles, { quality: graphics.quality, particleCap: graphics.profile.particleCap, diagnostics: devtoolsEnabled }), _jsx(CombatPresentation, { effects: effects }), devtoolsEnabled ? _jsx(CollisionDebugWorld, {}) : null, _jsx(AudioListener, { engine: audioEngine }), _jsx(EntityAudioEmitters, { engine: audioEngine, entitySounds: playable.entitySounds }), _jsx(ObjectAudioEmitters, { engine: audioEngine, objectSounds: playable.objectSounds }), _jsx(GameCameraRig, { yawRef: yawRef, pitchRef: pitchRef, config: cameraConfig, viewmodel: playable.viewmodel, pointerControls: pointerUsesLeft, panKeysEnabled: rtsPanKeysEnabled, director: ctx.camera, onDragChange: (dragging) => {
377
+ cameraDraggingRef.current = dragging;
378
+ } }), _jsx(PointerProbe, { service: pointerService })] }) }), _jsx(RemotePlayers, { rows: remotePlayers }), _jsx(FrameDriver, { ctx: ctx, playable: playable, tracker: tracker, yawRef: yawRef, pitchRef: pitchRef, primaryClickRef: primaryClickRef, pointerAxisRef: pointerAxisRef, analogRef: analogRef, gateRef: gateRef, onRuntimeError: reportRuntimeError, multiplayer: multiplayer, serverIdRef: serverIdRef, pointerService: pointerService, pointerAim: pointer?.aim === true, pingCommand: pointer?.pingCommand, poster: poster, onPosterSettled: onPosterSettled, authoritativeFrameRef: authoritativeFrameRef }), _jsx(GamepadSource, { tracker: tracker, bindings: inputBindings ?? {}, analogRef: analogRef, input: ctx.input, feel: playable.gamepad, seats: localPlayers(ctx), onSeatJoin: (slot) => {
337
379
  try {
338
380
  playable.loop.onNewPlayer(ctx, { userId: slot.userId, isNew: true });
339
381
  }
@@ -1,4 +1,4 @@
1
- import type { ComponentType } from "react";
1
+ import { type ComponentType } from "react";
2
2
  import type { GameContext } from "@jgengine/core/runtime/gameContext";
3
3
  import { type RuntimeDiagnostic } from "./diagnostics/RuntimeDiagnostics.js";
4
4
  import type { ShellMultiplayer } from "./multiplayer.js";
@@ -27,6 +27,7 @@ export type ShellKeyHandlers = {
27
27
  onKeyDown: (event: {
28
28
  code: string;
29
29
  target?: EventTarget | null;
30
+ defaultPrevented?: boolean;
30
31
  preventDefault: () => void;
31
32
  }) => void;
32
33
  onKeyUp: (event: {
@@ -1,21 +1,32 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { lazy, Suspense, useEffect, useState } from "react";
2
3
  import { HudViewportProvider } from "@jgengine/react/hudViewport";
3
4
  import { GameProvider } from "@jgengine/react/provider";
4
- import { DevtoolsOverlay } from "./devtools/DevtoolsOverlay.js";
5
5
  import { DiagnosticOverlay, GameUiErrorBoundary, } from "./diagnostics/RuntimeDiagnostics.js";
6
+ import { DevtoolsRuntime } from "./devtools/DevtoolsRuntime.js";
6
7
  import { GamePhaseStamp } from "./GamePhaseStamp.js";
8
+ import { SettingsPlayControlGate } from "./settings/SettingsRuntime.js";
9
+ const DevtoolsOverlay = lazy(() => import("./devtools/DevtoolsOverlay.js").then((module) => ({ default: module.DevtoolsOverlay })));
7
10
  /** Shared GameUI mount: error boundary → GameProvider → phase stamp → HudViewport. @internal */
8
11
  export function ShellGameUiChrome({ ctx, playable, GameUI, uiScale, orientationGate, onRuntimeError, }) {
9
- return (_jsx(GameUiErrorBoundary, { onRuntimeError: onRuntimeError, children: _jsxs(GameProvider, { context: ctx, children: [_jsx(GamePhaseStamp, {}), _jsx(HudViewportProvider, { platforms: playable.platforms, config: playable.hudFit, userScale: uiScale, children: orientationGate ? null : _jsx(GameUI, {}) })] }) }));
12
+ return (_jsx(GameUiErrorBoundary, { onRuntimeError: onRuntimeError, children: _jsxs(GameProvider, { context: ctx, children: [_jsx(SettingsPlayControlGate, { ctx: ctx }), _jsx(GamePhaseStamp, {}), _jsx(HudViewportProvider, { platforms: playable.platforms, config: playable.hudFit, userScale: uiScale, children: orientationGate ? null : _jsx(GameUI, {}) })] }) }));
10
13
  }
11
14
  /** Devtools + diagnostic overlays shared by HUD and 3D presentation paths. @internal */
12
15
  export function ShellDebugOverlays({ ctx, playable, multiplayer, diagnostics, devtoolsEnabled, devtoolsOpen, hideDiagnostics = false, }) {
13
- return (_jsxs(_Fragment, { children: [devtoolsEnabled ? (_jsx(DevtoolsOverlay, { open: devtoolsOpen, ctx: ctx, playable: playable, multiplayer: multiplayer })) : null, hideDiagnostics ? null : (_jsx(DiagnosticOverlay, { diagnostics: diagnostics, gameName: playable.game.name }))] }));
16
+ const [openedOnce, setOpenedOnce] = useState(false);
17
+ useEffect(() => { if (devtoolsOpen)
18
+ setOpenedOnce(true); }, [devtoolsOpen]);
19
+ return (_jsxs(_Fragment, { children: [devtoolsEnabled ? _jsx(DevtoolsRuntime, { ctx: ctx, playable: playable }) : null, devtoolsEnabled && (devtoolsOpen || openedOnce) ? (_jsx(Suspense, { fallback: devtoolsOpen ? _jsx("div", { role: "status", style: { position: "absolute", right: 16, top: 16, zIndex: 90, color: "#e2e8f0", background: "#0f172a", padding: "10px 14px", borderRadius: 8 }, children: "Loading developer tools\u2026" }) : null, children: _jsx(DevtoolsOverlay, { open: devtoolsOpen, ctx: ctx, playable: playable, multiplayer: multiplayer }) })) : null, hideDiagnostics ? null : (_jsx(DiagnosticOverlay, { diagnostics: diagnostics, gameName: playable.game.name }))] }));
14
20
  }
15
21
  /** F2 chord + gameplay action tracker key handlers shared by both presentation paths. @internal */
16
22
  export function createShellKeyHandlers({ f2HeldRef, tracker, devtoolsEnabled, setDevtoolsOpen, controlsActive, exitPointerLockOnDevtools = false, }) {
17
23
  return {
18
24
  onKeyDown: (event) => {
25
+ if (event.defaultPrevented) {
26
+ f2HeldRef.current = false;
27
+ tracker.reset();
28
+ return;
29
+ }
19
30
  const target = event.target;
20
31
  if (target?.closest?.("input, textarea, select, [contenteditable]:not([contenteditable=false])")) {
21
32
  f2HeldRef.current = false;
@@ -36,10 +47,15 @@ export function createShellKeyHandlers({ f2HeldRef, tracker, devtoolsEnabled, se
36
47
  }
37
48
  return;
38
49
  }
39
- if (event.code === "Tab" || event.code === "Space")
40
- event.preventDefault();
41
- if (controlsActive())
50
+ if (target?.closest?.('button, a[href], [role="button"], [role="link"], [role="dialog"], [role="menu"], [role="listbox"], [role="grid"], [role="gridcell"], [role="toolbar"]')) {
51
+ tracker.reset();
52
+ return;
53
+ }
54
+ if (controlsActive()) {
55
+ if (event.code === "Tab" || event.code === "Space")
56
+ event.preventDefault();
42
57
  tracker.handleDown(event.code);
58
+ }
43
59
  },
44
60
  onKeyUp: (event) => {
45
61
  if (event.code === "F2") {
@@ -2,10 +2,13 @@ import { type Dispatch, type MutableRefObject, type RefObject, type SetStateActi
2
2
  import type { PointerAxisState } from "@jgengine/core/input/pointerAxis";
3
3
  import type { ActionStateTracker } from "@jgengine/core/input/actionBindings";
4
4
  import type { GameContext } from "@jgengine/core/runtime/gameContext";
5
+ import type { BindingOverrides } from "@jgengine/core/input/bindingOverrides";
6
+ import { type SettingsStore } from "@jgengine/core/settings/settingsModel";
5
7
  import type { TouchScheme } from "@jgengine/core/input/touchScheme";
6
8
  import type { RuntimeDiagnostic } from "./diagnostics/RuntimeDiagnostics.js";
7
9
  import type { ShellMultiplayer } from "./multiplayer.js";
8
10
  import type { PlayableGame } from "./registry.js";
11
+ import type { AudioEngine } from "./audio/audioEngine.js";
9
12
  /**
10
13
  * Marks a HUD-only page ready for capture (`data-jg-capture="ready"`) unless a capture host already
11
14
  * owns the flag. A HUD game has no `<canvas>`, so capture tools that wait for one would time out.
@@ -15,10 +18,11 @@ export declare function markHudCaptureReady(root: {
15
18
  dataset: DOMStringMap;
16
19
  }): void;
17
20
  /** HUD-only play surface for non-3D shells. @internal */
18
- export declare function ShellHudPresentation({ playable, ctx, multiplayer, tracker, pointerAxisRef, gateRef, wrapperRef, f2HeldRef, yawRef, pitchRef, touchScheme, touchSink, orientationGate, orientationGateEl, coarsePointer, uiScale, diagnostics, devtoolsEnabled, devtoolsOpen, setDevtoolsOpen, reportRuntimeError, trackPointerAxis, deactivatePointerAxis, onPointerResumeAudio, }: {
21
+ export declare function ShellHudPresentation({ playable, ctx, multiplayer, serverIdRef, tracker, pointerAxisRef, gateRef, wrapperRef, f2HeldRef, yawRef, pitchRef, touchScheme, touchSink, orientationGate, orientationGateEl, coarsePointer, uiScale, diagnostics, devtoolsEnabled, devtoolsOpen, setDevtoolsOpen, reportRuntimeError, trackPointerAxis, deactivatePointerAxis, onPointerResumeAudio, settingsStore, bindingOverrides, rebindAction, resetActionBinding, audioEngine, poster, }: {
19
22
  playable: PlayableGame;
20
23
  ctx: GameContext;
21
24
  multiplayer: ShellMultiplayer | null;
25
+ serverIdRef: MutableRefObject<string | null>;
22
26
  tracker: ActionStateTracker<string>;
23
27
  pointerAxisRef: MutableRefObject<PointerAxisState | null>;
24
28
  gateRef: MutableRefObject<boolean>;
@@ -46,4 +50,10 @@ export declare function ShellHudPresentation({ playable, ctx, multiplayer, track
46
50
  }) => void;
47
51
  deactivatePointerAxis: () => void;
48
52
  onPointerResumeAudio: () => void;
53
+ settingsStore: SettingsStore;
54
+ bindingOverrides: BindingOverrides;
55
+ rebindAction: (action: string, code: string) => void;
56
+ resetActionBinding: (action: string) => void;
57
+ audioEngine: AudioEngine;
58
+ poster: boolean;
49
59
  }): import("react").JSX.Element;
@@ -1,9 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect } from "react";
3
3
  import { playControlsActive } from "@jgengine/core/game/controlGate";
4
+ import { BUILT_IN_SETTING_CATEGORIES } from "@jgengine/core/settings/settingsModel";
5
+ import { SettingsProvider } from "@jgengine/react/settings";
4
6
  import { GameViewportProvider } from "@jgengine/react/gameViewport";
5
7
  import { HudOnlyDriver } from "./drivers/HudOnlyDriver.js";
6
8
  import { createShellKeyHandlers, ShellDebugOverlays, ShellGameUiChrome } from "./ShellChrome.js";
9
+ import { AudioSettingsBridge } from "./settings/appliedSettings.js";
10
+ import { SettingsRuntime } from "./settings/SettingsRuntime.js";
11
+ import { SettingsChrome } from "./settings/SettingsChrome.js";
7
12
  import { TouchPlaySurface } from "./touch/TouchControlsOverlay.js";
8
13
  /**
9
14
  * Marks a HUD-only page ready for capture (`data-jg-capture="ready"`) unless a capture host already
@@ -15,7 +20,7 @@ export function markHudCaptureReady(root) {
15
20
  root.dataset.jgCapture = "ready";
16
21
  }
17
22
  /** HUD-only play surface for non-3D shells. @internal */
18
- export function ShellHudPresentation({ playable, ctx, multiplayer, tracker, pointerAxisRef, gateRef, wrapperRef, f2HeldRef, yawRef, pitchRef, touchScheme, touchSink, orientationGate, orientationGateEl, coarsePointer, uiScale, diagnostics, devtoolsEnabled, devtoolsOpen, setDevtoolsOpen, reportRuntimeError, trackPointerAxis, deactivatePointerAxis, onPointerResumeAudio, }) {
23
+ export function ShellHudPresentation({ playable, ctx, multiplayer, serverIdRef, tracker, pointerAxisRef, gateRef, wrapperRef, f2HeldRef, yawRef, pitchRef, touchScheme, touchSink, orientationGate, orientationGateEl, coarsePointer, uiScale, diagnostics, devtoolsEnabled, devtoolsOpen, setDevtoolsOpen, reportRuntimeError, trackPointerAxis, deactivatePointerAxis, onPointerResumeAudio, settingsStore, bindingOverrides, rebindAction, resetActionBinding, audioEngine, poster, }) {
19
24
  const GameUI = playable.GameUI;
20
25
  useEffect(() => {
21
26
  // Two frames so the first GameUI paint has landed before tools capture.
@@ -35,9 +40,18 @@ export function ShellHudPresentation({ playable, ctx, multiplayer, tracker, poin
35
40
  setDevtoolsOpen,
36
41
  controlsActive: () => playControlsActive(ctx),
37
42
  });
38
- return (_jsx("div", { ref: wrapperRef, tabIndex: 0, className: "relative h-full w-full bg-neutral-950 outline-none", onKeyDown: keys.onKeyDown, onKeyUp: keys.onKeyUp, onBlur: keys.onBlur, onPointerDown: onPointerResumeAudio, onPointerMove: trackPointerAxis, onPointerLeave: deactivatePointerAxis, onPointerCancel: deactivatePointerAxis, children: _jsxs(GameViewportProvider, { platforms: playable.platforms, children: [_jsx(HudOnlyDriver, { ctx: ctx, playable: playable, tracker: tracker, pointerAxisRef: pointerAxisRef, gateRef: gateRef, onRuntimeError: reportRuntimeError }), !orientationGate &&
39
- coarsePointer &&
40
- touchScheme !== null &&
41
- touchScheme.gestures !== null &&
42
- playControlsActive(ctx) ? (_jsx(TouchPlaySurface, { scheme: touchScheme, sink: touchSink, yawRef: yawRef, pitchRef: pitchRef, maxPitch: 0, onPrimaryTap: () => undefined })) : null, _jsx(ShellGameUiChrome, { ctx: ctx, playable: playable, GameUI: GameUI, uiScale: uiScale, orientationGate: orientationGate, onRuntimeError: reportRuntimeError }), orientationGateEl, _jsx(ShellDebugOverlays, { ctx: ctx, playable: playable, multiplayer: multiplayer, diagnostics: diagnostics, devtoolsEnabled: devtoolsEnabled, devtoolsOpen: devtoolsOpen })] }) }));
43
+ const settingsDisabled = playable.settings === false;
44
+ const settingsConfig = playable.settings === false || playable.settings === undefined ? {} : playable.settings;
45
+ const settingsActions = (settingsConfig.actions ?? []).map((action) => ({
46
+ id: action.id,
47
+ label: action.label,
48
+ kind: action.kind ?? "default",
49
+ description: action.description,
50
+ run: () => action.run(ctx),
51
+ }));
52
+ return (_jsxs(SettingsProvider, { store: settingsStore, children: [_jsx(AudioSettingsBridge, { store: settingsStore, engine: audioEngine, buses: playable.audio?.buses }), _jsx(SettingsRuntime, { variant: settingsConfig.variant ?? "panel", surface: settingsDisabled ? false : settingsConfig.surface ?? false, actions: settingsActions, input: playable.game.input ?? {}, buses: playable.audio?.buses, extra: settingsConfig.extra ?? [], categories: settingsConfig.categories ?? [], hide: settingsDisabled ? BUILT_IN_SETTING_CATEGORIES : settingsConfig.hide ?? [], fovEnabled: false, graphics: playable.graphics, hideBindings: settingsConfig.hideBindings ?? [], touchStyle: coarsePointer && touchScheme !== null && (touchScheme.joystick !== null || touchScheme.buttons.length > 0), overrides: bindingOverrides, rebind: rebindAction, resetBinding: resetActionBinding, children: _jsx("div", { ref: wrapperRef, tabIndex: 0, className: "relative h-full w-full bg-neutral-950 outline-none", onKeyDown: keys.onKeyDown, onKeyUp: keys.onKeyUp, onBlur: keys.onBlur, onPointerDown: onPointerResumeAudio, onPointerMove: trackPointerAxis, onPointerLeave: deactivatePointerAxis, onPointerCancel: deactivatePointerAxis, children: _jsxs(GameViewportProvider, { platforms: playable.platforms, children: [_jsx(HudOnlyDriver, { ctx: ctx, multiplayer: multiplayer, serverIdRef: serverIdRef, playable: playable, tracker: tracker, pointerAxisRef: pointerAxisRef, gateRef: gateRef, onRuntimeError: reportRuntimeError }), !orientationGate &&
53
+ coarsePointer &&
54
+ touchScheme !== null &&
55
+ touchScheme.gestures !== null &&
56
+ playControlsActive(ctx) ? (_jsx(TouchPlaySurface, { scheme: touchScheme, sink: touchSink, yawRef: yawRef, pitchRef: pitchRef, maxPitch: 0, onPrimaryTap: () => undefined })) : null, _jsx(ShellGameUiChrome, { ctx: ctx, playable: playable, GameUI: GameUI, uiScale: uiScale, orientationGate: orientationGate, onRuntimeError: reportRuntimeError }), orientationGateEl, _jsx(ShellDebugOverlays, { ctx: ctx, playable: playable, multiplayer: multiplayer, diagnostics: diagnostics, devtoolsEnabled: devtoolsEnabled, devtoolsOpen: devtoolsOpen }), poster ? null : _jsx(SettingsChrome, {})] }) }) })] }));
43
57
  }
@@ -1,5 +1,6 @@
1
1
  import { type AudioBusDef, type SoundDef } from "@jgengine/core/audio/audioFalloff";
2
2
  import type { MusicTheme } from "@jgengine/core/audio/music";
3
+ import { type VoiceAllocator, type VoiceAllocatorConfig } from "@jgengine/core/audio/voiceAllocator";
3
4
  import { type CrossfadeOptions } from "./musicDirector.js";
4
5
  export interface Vec3 {
5
6
  x: number;
@@ -16,6 +17,12 @@ export interface ListenerPose {
16
17
  }
17
18
  export interface AudioSceneConfig {
18
19
  sounds?: Record<string, SoundDef>;
20
+ /** SFX playback/patch slots, including pending loads; individual synth oscillators share one slot. Default 64; zero disables SFX. */
21
+ maxVoices?: number;
22
+ /** Budget overflow policy. Default "steal-lowest"; lower priorities never steal higher ones. */
23
+ voiceOverflow?: VoiceAllocatorConfig["overflow"];
24
+ /** Dedicated caller-owned allocator for inspectable reservations and injected storage. Music has its own lifecycle. */
25
+ voiceAllocator?: VoiceAllocator;
19
26
  buses?: Record<string, AudioBusDef>;
20
27
  /** Procedural music themes, crossfaded by {@link AudioEngine.playMusic}. Mixed through the `musicBus` (default "music") so the settings volume applies. */
21
28
  music?: Record<string, MusicTheme>;
@@ -23,6 +30,8 @@ export interface AudioSceneConfig {
23
30
  musicBus?: string;
24
31
  }
25
32
  export interface AudioEmitterHandle {
33
+ /** Whether playback is reserved or playing; false after stop, stealing, failed load, or natural end. Optional for legacy hosts. */
34
+ isPlaying?(): boolean;
26
35
  setPosition(position: Vec3): void;
27
36
  /** Live pitch of a retained loop: `rate` multiplies the authored playback rate (1 = authored), clamped to 0.25–4 and ramped ~20 ms to avoid zipper noise (#1051). */
28
37
  setRate(rate: number): void;
@@ -37,7 +46,10 @@ export interface AudioEmitterHandle {
37
46
  stop(): void;
38
47
  }
39
48
  export interface AudioEngine {
49
+ /** Full listener orientation, or a legacy position using forward -Z and up +Y. */
40
50
  setListenerPose(pose: ListenerPose | Vec3): void;
51
+ /** Retune the SFX budget and optional admission policy, stopping the lowest-priority oldest voices first. */
52
+ setVoiceLimit(maxTotal: number, overflow?: VoiceAllocatorConfig["overflow"]): void;
41
53
  playOneShot(soundId: string, position?: Vec3): void;
42
54
  playLoop(soundId: string, position?: Vec3): AudioEmitterHandle | null;
43
55
  /** Crossfade the procedural soundtrack to `themeId` (null fades out). No-op when no `music` catalog is configured. */
@@ -47,4 +59,8 @@ export interface AudioEngine {
47
59
  resume(): void;
48
60
  dispose(): void;
49
61
  }
62
+ /**
63
+ * Game-owned sound catalog playback with bounded SFX reservations, spatial panning, and shared buses.
64
+ * @capability bounded-spatial-audio play positional or flat cues with listener orientation, voice budgets, and per-source cleanup
65
+ */
50
66
  export declare function createAudioEngine(config?: AudioSceneConfig): AudioEngine;