@jgengine/shell 0.31.0 → 0.33.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 (62) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/GamePlayerShell.js +7 -0
  3. package/dist/Shell3dPresentation.js +6 -4
  4. package/dist/ShellChrome.d.ts +3 -1
  5. package/dist/ShellChrome.js +3 -2
  6. package/dist/ShellHudPresentation.js +1 -1
  7. package/dist/audio/AudioComponents.js +61 -51
  8. package/dist/audio/CaptionOverlay.d.ts +15 -0
  9. package/dist/audio/CaptionOverlay.js +50 -0
  10. package/dist/audio/FeedbackHost.d.ts +14 -0
  11. package/dist/audio/FeedbackHost.js +46 -0
  12. package/dist/audio/audioEngine.d.ts +25 -0
  13. package/dist/audio/audioEngine.js +267 -32
  14. package/dist/audio/audioProbe.d.ts +22 -0
  15. package/dist/audio/audioProbe.js +20 -0
  16. package/dist/audio/busMix.d.ts +4 -0
  17. package/dist/audio/busMix.js +7 -0
  18. package/dist/audio/captions.d.ts +48 -0
  19. package/dist/audio/captions.js +77 -0
  20. package/dist/audio/emitterTracking.d.ts +40 -0
  21. package/dist/audio/emitterTracking.js +153 -0
  22. package/dist/audio/eventFeedback.d.ts +137 -0
  23. package/dist/audio/eventFeedback.js +273 -0
  24. package/dist/audio/gestureResume.d.ts +10 -0
  25. package/dist/audio/gestureResume.js +25 -0
  26. package/dist/audio/hapticsRoute.d.ts +28 -0
  27. package/dist/audio/hapticsRoute.js +59 -0
  28. package/dist/audio/loopParams.js +20 -10
  29. package/dist/audio/playableAudio.d.ts +1 -1
  30. package/dist/audio/playableAudio.js +3 -1
  31. package/dist/devtools/FrameTelemetry.d.ts +1 -1
  32. package/dist/devtools/FrameTelemetry.js +4 -2
  33. package/dist/devtools/agentBridge.js +13 -0
  34. package/dist/devtools/frameSampler.js +4 -0
  35. package/dist/drivers/CanvasCoverFrameloop.d.ts +13 -0
  36. package/dist/drivers/CanvasCoverFrameloop.js +22 -0
  37. package/dist/drivers/FrameRateLimiter.d.ts +2 -1
  38. package/dist/drivers/FrameRateLimiter.js +6 -3
  39. package/dist/postfx/PostProcessing.d.ts +78 -2
  40. package/dist/postfx/PostProcessing.js +149 -76
  41. package/dist/postfx/postfxOverlay.d.ts +14 -4
  42. package/dist/postfx/postfxOverlay.js +90 -10
  43. package/dist/postfx/shadowPrepass.d.ts +6 -2
  44. package/dist/postfx/shadowPrepass.js +7 -3
  45. package/dist/render/characterLod.js +6 -0
  46. package/dist/render/useFootIk.js +54 -15
  47. package/dist/render/useModelAnimation.js +22 -5
  48. package/dist/settings/appliedSettings.js +3 -0
  49. package/dist/settings/settingsController.d.ts +10 -1
  50. package/dist/settings/settingsController.js +94 -67
  51. package/dist/vision/RevealVision.d.ts +3 -1
  52. package/dist/vision/RevealVision.js +34 -15
  53. package/dist/world/WorldItems.js +11 -3
  54. package/package.json +4 -4
  55. package/skills/jgengine/api.md +3 -2
  56. package/skills/jgengine-ui/SKILL.md +1 -0
  57. package/skills/jgengine-ui/api.md +94 -24
  58. package/skills/jgengine-ui/capabilities.md +40 -0
  59. package/skills/jgengine-ui/reference-primitives.md +2 -0
  60. package/skills/jgengine-ui/reference.md +1 -1
  61. package/skills/jgengine-world/api.md +18 -8
  62. package/skills/jgengine-world/capabilities.md +4 -0
@@ -15,10 +15,20 @@ export declare function isPostfxOverlay(object: {
15
15
  userData?: Record<string, unknown>;
16
16
  }): boolean;
17
17
  /**
18
- * Hide every currently visible marked object under `scene`, recording what was hidden into
19
- * `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can
20
- * undo exactly that set.
18
+ * Hide every currently visible object `scene`'s prepasses must skip, recording what was hidden into
19
+ * `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can undo
20
+ * exactly that set. `withUnmodelled` also hides points and lines, which three's GTAO pass excludes
21
+ * from its normal buffer but a depth prepass keeps.
22
+ *
23
+ * The set is cached and rescanned only when the scene graph changes: walking the whole scene per
24
+ * prepass cost 2.4 ms/frame in Drift Foundry and 0.6 ms/frame in Harbor Heat, and the graph changes
25
+ * when something mounts, not between frames.
21
26
  */
22
- export declare function hidePostfxOverlays(scene: THREE.Object3D, out: THREE.Object3D[]): void;
27
+ export declare function hidePostfxOverlays(scene: THREE.Object3D, out: THREE.Object3D[], withUnmodelled?: boolean): void;
23
28
  /** Restore visibility for the objects hidden by {@link hidePostfxOverlays} and clear the list. */
24
29
  export declare function restorePostfxOverlays(hidden: THREE.Object3D[]): void;
30
+ /**
31
+ * Drop the cached set and its listeners for a scene that is going away, so a torn-down world does
32
+ * not keep its node graph reachable through this module.
33
+ */
34
+ export declare function releasePostfxOverlayTracking(scene: THREE.Object3D): void;
@@ -13,19 +13,84 @@ export const POSTFX_OVERLAY_USERDATA = {
13
13
  export function isPostfxOverlay(object) {
14
14
  return object.userData?.jgPostfxOverlay === true;
15
15
  }
16
+ /** Whether a normal-material prepass must leave `object` out: three's AO pass cannot describe these. */
17
+ function isUnmodelledDrawable(object) {
18
+ const drawable = object;
19
+ return drawable.isPoints === true || drawable.isLine === true || drawable.isLine2 === true;
20
+ }
21
+ const tracks = new WeakMap();
22
+ function unwatchAll(track, node) {
23
+ track.listeners.get(node)?.();
24
+ track.listeners.delete(node);
25
+ for (const child of node.children)
26
+ unwatchAll(track, child);
27
+ }
28
+ function watch(track, node) {
29
+ if (track.listeners.has(node))
30
+ return;
31
+ const added = ({ child }) => {
32
+ track.revision += 1;
33
+ watch(track, child);
34
+ };
35
+ const removed = ({ child }) => {
36
+ track.revision += 1;
37
+ unwatchAll(track, child);
38
+ };
39
+ node.addEventListener("childadded", added);
40
+ node.addEventListener("childremoved", removed);
41
+ track.listeners.set(node, () => {
42
+ node.removeEventListener("childadded", added);
43
+ node.removeEventListener("childremoved", removed);
44
+ });
45
+ for (const child of node.children)
46
+ watch(track, child);
47
+ }
48
+ function trackFor(scene) {
49
+ const existing = tracks.get(scene);
50
+ if (existing !== undefined)
51
+ return existing;
52
+ const track = { revision: 0, scanned: -1, overlays: [], drawables: [], listeners: new Map() };
53
+ tracks.set(scene, track);
54
+ watch(track, scene);
55
+ return track;
56
+ }
57
+ function rescan(scene, track) {
58
+ track.overlays.length = 0;
59
+ track.drawables.length = 0;
60
+ scene.traverse((object) => {
61
+ if (isPostfxOverlay(object))
62
+ track.overlays.push(object);
63
+ else if (isUnmodelledDrawable(object))
64
+ track.drawables.push(object);
65
+ });
66
+ track.scanned = track.revision;
67
+ }
68
+ function hideAll(objects, out) {
69
+ for (const object of objects) {
70
+ if (!object.visible)
71
+ continue;
72
+ object.visible = false;
73
+ out.push(object);
74
+ }
75
+ }
16
76
  /**
17
- * Hide every currently visible marked object under `scene`, recording what was hidden into
18
- * `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can
19
- * undo exactly that set.
77
+ * Hide every currently visible object `scene`'s prepasses must skip, recording what was hidden into
78
+ * `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can undo
79
+ * exactly that set. `withUnmodelled` also hides points and lines, which three's GTAO pass excludes
80
+ * from its normal buffer but a depth prepass keeps.
81
+ *
82
+ * The set is cached and rescanned only when the scene graph changes: walking the whole scene per
83
+ * prepass cost 2.4 ms/frame in Drift Foundry and 0.6 ms/frame in Harbor Heat, and the graph changes
84
+ * when something mounts, not between frames.
20
85
  */
21
- export function hidePostfxOverlays(scene, out) {
86
+ export function hidePostfxOverlays(scene, out, withUnmodelled = false) {
22
87
  out.length = 0;
23
- scene.traverse((object) => {
24
- if (object.visible && isPostfxOverlay(object)) {
25
- object.visible = false;
26
- out.push(object);
27
- }
28
- });
88
+ const track = trackFor(scene);
89
+ if (track.scanned !== track.revision)
90
+ rescan(scene, track);
91
+ hideAll(track.overlays, out);
92
+ if (withUnmodelled)
93
+ hideAll(track.drawables, out);
29
94
  }
30
95
  /** Restore visibility for the objects hidden by {@link hidePostfxOverlays} and clear the list. */
31
96
  export function restorePostfxOverlays(hidden) {
@@ -33,3 +98,18 @@ export function restorePostfxOverlays(hidden) {
33
98
  object.visible = true;
34
99
  hidden.length = 0;
35
100
  }
101
+ /**
102
+ * Drop the cached set and its listeners for a scene that is going away, so a torn-down world does
103
+ * not keep its node graph reachable through this module.
104
+ */
105
+ export function releasePostfxOverlayTracking(scene) {
106
+ const track = tracks.get(scene);
107
+ if (track === undefined)
108
+ return;
109
+ tracks.delete(scene);
110
+ for (const off of track.listeners.values())
111
+ off();
112
+ track.listeners.clear();
113
+ track.overlays.length = 0;
114
+ track.drawables.length = 0;
115
+ }
@@ -6,5 +6,9 @@ import type * as THREE from "three";
6
6
  * @internal
7
7
  */
8
8
  export declare function renderWithCurrentMatrices<T>(scene: THREE.Object3D, render: () => T): T;
9
- /** Reuse the beauty pass's shadow maps and world matrices while drawing a scene prepass. @internal */
10
- export declare function renderShadowlessPrepass<T>(renderer: Pick<THREE.WebGLRenderer, "shadowMap">, scene: THREE.Object3D, hiddenOverlays: THREE.Object3D[], render: () => T): T;
9
+ /**
10
+ * Reuse the beauty pass's shadow maps and world matrices while drawing a scene prepass.
11
+ * `withUnmodelled` also hides points and lines, which GTAO's normal buffer excludes anyway.
12
+ * @internal
13
+ */
14
+ export declare function renderShadowlessPrepass<T>(renderer: Pick<THREE.WebGLRenderer, "shadowMap">, scene: THREE.Object3D, hiddenOverlays: THREE.Object3D[], render: () => T, withUnmodelled?: boolean): T;
@@ -15,12 +15,16 @@ export function renderWithCurrentMatrices(scene, render) {
15
15
  scene.matrixWorldAutoUpdate = autoUpdate;
16
16
  }
17
17
  }
18
- /** Reuse the beauty pass's shadow maps and world matrices while drawing a scene prepass. @internal */
19
- export function renderShadowlessPrepass(renderer, scene, hiddenOverlays, render) {
18
+ /**
19
+ * Reuse the beauty pass's shadow maps and world matrices while drawing a scene prepass.
20
+ * `withUnmodelled` also hides points and lines, which GTAO's normal buffer excludes anyway.
21
+ * @internal
22
+ */
23
+ export function renderShadowlessPrepass(renderer, scene, hiddenOverlays, render, withUnmodelled = false) {
20
24
  const shadowMap = renderer.shadowMap;
21
25
  const autoUpdate = shadowMap.autoUpdate;
22
26
  const needsUpdate = shadowMap.needsUpdate;
23
- hidePostfxOverlays(scene, hiddenOverlays);
27
+ hidePostfxOverlays(scene, hiddenOverlays, withUnmodelled);
24
28
  shadowMap.autoUpdate = false;
25
29
  shadowMap.needsUpdate = false;
26
30
  try {
@@ -94,6 +94,12 @@ export function noteCharacterPose(instanceId) {
94
94
  }
95
95
  /** Publish the clip and time a rig is posing at, so the crowd can continue the same pose at the swap. @internal */
96
96
  export function noteAnimationPhase(instanceId, clip, time) {
97
+ const published = phases.get(instanceId);
98
+ if (published !== undefined) {
99
+ published.clip = clip;
100
+ published.time = time;
101
+ return;
102
+ }
97
103
  phases.set(instanceId, { clip, time });
98
104
  }
99
105
  /** The clip phase a character was last posed at, or `undefined` when it never posed. @internal */
@@ -174,6 +174,14 @@ function restoreFootIkPose(rig) {
174
174
  record.bone.position.copy(record.animated.p);
175
175
  }
176
176
  }
177
+ /**
178
+ * Reused per frame by {@link applyFootIk}: the ground probes, their hits and the solver input are read
179
+ * synchronously inside one call, so a rig posing every frame allocates nothing.
180
+ */
181
+ const probeInput = { origin: [0, 0, 0], direction: [0, -1, 0], maxDistance: 0 };
182
+ const samplesScratch = [];
183
+ const normalsScratch = [];
184
+ const samplePool = [];
177
185
  /**
178
186
  * Applies one frame of foot IK after the animation mixer: probes the ground under each foot,
179
187
  * resolves targets with `placeFeet`, lowers the pelvis, solves each leg with `solveTwoBone` bending
@@ -187,8 +195,8 @@ export function applyFootIk(rig, originY, probe, state, delta, lookAtTarget) {
187
195
  record.animated.q.copy(record.bone.quaternion);
188
196
  record.animated.p.copy(record.bone.position);
189
197
  }
190
- const samples = [];
191
- const normals = [];
198
+ samplesScratch.length = 0;
199
+ normalsScratch.length = 0;
192
200
  let length = 0;
193
201
  let ankleHeight = 0;
194
202
  for (const leg of rig.legs) {
@@ -197,11 +205,19 @@ export function applyFootIk(rig, originY, probe, state, delta, lookAtTarget) {
197
205
  length = Math.max(length, legSize);
198
206
  ankleHeight = Math.max(ankleHeight, leg.ankleRatio * legSize);
199
207
  const from = Math.max(scratch.tip.y, originY) + legSize * 0.5;
200
- const hit = probe({ origin: [scratch.tip.x, from, scratch.tip.z], direction: [0, -1, 0], maxDistance: legSize * 2 });
201
- samples.push({ ankleY: scratch.tip.y, groundY: hit === null ? null : hit.point[1] });
202
- normals.push(hit === null ? null : hit.normal);
208
+ probeInput.origin[0] = scratch.tip.x;
209
+ probeInput.origin[1] = from;
210
+ probeInput.origin[2] = scratch.tip.z;
211
+ probeInput.maxDistance = legSize * 2;
212
+ const hit = probe(probeInput);
213
+ const index = samplesScratch.length;
214
+ const sample = samplePool[index] ?? (samplePool[index] = { ankleY: 0, groundY: null });
215
+ sample.ankleY = scratch.tip.y;
216
+ sample.groundY = hit === null ? null : hit.point[1];
217
+ samplesScratch.push(sample);
218
+ normalsScratch.push(hit === null ? null : hit.normal);
203
219
  }
204
- const placement = placeFeet({ feet: samples, originY, ankleHeight, maxAdjust: rig.maxAdjust * length, airborneGap: length * 0.3 });
220
+ const placement = placeFeet({ feet: samplesScratch, originY, ankleHeight, maxAdjust: rig.maxAdjust * length, airborneGap: length * 0.3 });
205
221
  const blend = Math.min(1, delta * (placement.grounded ? 12 : 6));
206
222
  state.weight += ((placement.grounded ? 1 : 0) - state.weight) * blend;
207
223
  state.pelvis += (placement.pelvisOffset - state.pelvis) * Math.min(1, delta * 15);
@@ -230,9 +246,9 @@ export function applyFootIk(rig, originY, probe, state, delta, lookAtTarget) {
230
246
  aimSegment(leg.root, root, mid, solved.mid);
231
247
  // Rotating the thigh also moved the tip; aim the shin from that updated pose.
232
248
  aimSegment(leg.mid, leg.mid.getWorldPosition(scratch.mid), leg.tip.getWorldPosition(scratch.tip), solved.tip);
233
- const normal = normals[i];
249
+ const normal = normalsScratch[i];
234
250
  if (normal !== null && normal !== undefined && rig.alignToGround > 0) {
235
- const planted = 1 - Math.min(1, Math.max(0, samples[i].ankleY - ankleHeight - samples[i].groundY) / Math.max(ankleHeight, length * 0.05));
251
+ const planted = 1 - Math.min(1, Math.max(0, samplesScratch[i].ankleY - ankleHeight - samplesScratch[i].groundY) / Math.max(ankleHeight, length * 0.05));
236
252
  const tilt = scratch.q.setFromUnitVectors(UP, scratch.b.set(normal[0], normal[1], normal[2]).normalize());
237
253
  const current = leg.tip.getWorldQuaternion(scratch.q2);
238
254
  writeWorldQuaternion(leg.tip, current.slerp(tilt.multiply(current), planted * weight * rig.alignToGround));
@@ -251,6 +267,8 @@ export function applyFootIk(rig, originY, probe, state, delta, lookAtTarget) {
251
267
  }
252
268
  return placement.grounded;
253
269
  }
270
+ /** Materials a foot probe may hit: blocking physical objects and terrain, never the model's own entity. */
271
+ const IK_FILTER = { entities: false, objects: true, walls: false, terrain: true };
254
272
  /**
255
273
  * Runs foot IK after the model's animation mixer for `ModelConfig.ik`. Ground probes hit terrain and
256
274
  * blocking physical objects, never the model's own entity. `groundOffset` is the model-space height
@@ -262,6 +280,16 @@ export function useFootIk(scene, config, ctx, instanceId, groundOffset = 0) {
262
280
  const state = useRef({ weight: 0, pelvis: 0 });
263
281
  const lookAtConfig = config === undefined || config === "auto" ? undefined : config.lookAt;
264
282
  const target = useRef(null);
283
+ // One probe input and one look-at triple per mount: a rig that poses every frame allocates nothing.
284
+ const probe = useMemo(() => ({
285
+ origin: [0, 0, 0],
286
+ direction: [0, -1, 0],
287
+ maxDistance: 0,
288
+ excludeInstanceIds: instanceId === undefined ? [] : [instanceId],
289
+ filter: IK_FILTER,
290
+ accept: (hit) => hit.blocks && hit.purpose === "physical",
291
+ }), [instanceId]);
292
+ const lookAtPoint = useMemo(() => [0, 0, 0], []);
265
293
  useLayoutEffect(() => {
266
294
  state.current = { weight: 0, pelvis: 0 };
267
295
  target.current = null;
@@ -277,15 +305,26 @@ export function useFootIk(scene, config, ctx, instanceId, groundOffset = 0) {
277
305
  return;
278
306
  const parent = scene.parent;
279
307
  const originY = parent === null ? scene.position.y : parent.localToWorld(scratch.a.set(0, groundOffset, 0)).y;
280
- const exclude = [instanceId];
281
308
  const aim = resolveLookAtTarget(lookAtConfig?.target, ctx, target.current, lookAtConfig?.entityEyeHeight ?? DEFAULT_EYE_HEIGHT);
282
309
  target.current = aim;
283
- applyFootIk(rig, originY, (input) => ctx.scene.raycast({
284
- ...input,
285
- excludeInstanceIds: exclude,
286
- filter: { entities: false, objects: true, walls: false, terrain: true },
287
- accept: (hit) => hit.blocks && hit.purpose === "physical",
288
- }), state.current, delta, aim === null ? camera.getWorldPosition(scratch.b).toArray() : [aim.x, aim.y, aim.z]);
310
+ if (aim === null) {
311
+ const world = camera.getWorldPosition(scratch.b);
312
+ lookAtPoint[0] = world.x;
313
+ lookAtPoint[1] = world.y;
314
+ lookAtPoint[2] = world.z;
315
+ }
316
+ else {
317
+ lookAtPoint[0] = aim.x;
318
+ lookAtPoint[1] = aim.y;
319
+ lookAtPoint[2] = aim.z;
320
+ }
321
+ applyFootIk(rig, originY, (input) => {
322
+ probe.origin[0] = input.origin[0];
323
+ probe.origin[1] = input.origin[1];
324
+ probe.origin[2] = input.origin[2];
325
+ probe.maxDistance = input.maxDistance;
326
+ return ctx.scene.raycast(probe);
327
+ }, state.current, delta, lookAtPoint);
289
328
  });
290
329
  }
291
330
  const DEFAULT_EYE_HEIGHT = 1.5;
@@ -187,13 +187,17 @@ function applyGraphClips(actions, clips) {
187
187
  }
188
188
  }
189
189
  /** The clip with the largest weight in one graph output: the pose the rig is mostly showing. */
190
- function dominantPhase(clips) {
190
+ function dominantPhase(clips, out) {
191
191
  let best;
192
192
  for (const entry of clips) {
193
193
  if (entry.weight > 0 && (best === undefined || entry.weight > best.weight))
194
194
  best = entry;
195
195
  }
196
- return best === undefined ? undefined : { clip: best.clip, time: best.time };
196
+ if (best === undefined)
197
+ return undefined;
198
+ out.clip = best.clip;
199
+ out.time = best.time;
200
+ return out;
197
201
  }
198
202
  /**
199
203
  * Binds a graph's clips to a rig exactly as `useModelAnimation` does (masked layers get filtered
@@ -282,6 +286,8 @@ function buildGraphPlayback(scene, mixer, graph, clips, rng) {
282
286
  lastYaw: null,
283
287
  smoothedSpeed: 0,
284
288
  params: {},
289
+ step: { dx: 0, dz: 0, dyaw: 0, seconds: 0 },
290
+ phase: { clip: "", time: 0 },
285
291
  };
286
292
  }
287
293
  /**
@@ -465,10 +471,21 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
465
471
  else {
466
472
  const instantSpeed = Math.hypot(dx, dz) / playbackDelta;
467
473
  playback.smoothedSpeed += (instantSpeed - playback.smoothedSpeed) * Math.min(1, playbackDelta * 12);
468
- step = { dx, dz, dyaw: playback.lastYaw === null ? 0 : shortestAngle(yaw - playback.lastYaw), seconds: playbackDelta };
474
+ const sample = playback.step;
475
+ sample.dx = dx;
476
+ sample.dz = dz;
477
+ sample.dyaw = playback.lastYaw === null ? 0 : shortestAngle(yaw - playback.lastYaw);
478
+ sample.seconds = playbackDelta;
479
+ step = sample;
469
480
  }
470
481
  }
471
- playback.lastPos = [x, entity.position[1], z];
482
+ if (playback.lastPos === null)
483
+ playback.lastPos = [x, entity.position[1], z];
484
+ else {
485
+ playback.lastPos[0] = x;
486
+ playback.lastPos[1] = entity.position[1];
487
+ playback.lastPos[2] = z;
488
+ }
472
489
  playback.lastYaw = yaw;
473
490
  readModelAnimationParams(ctx, instanceId, playback.smoothedSpeed, params, step);
474
491
  }
@@ -487,7 +504,7 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
487
504
  mixerRef.current.update(0);
488
505
  if (instanceId !== undefined) {
489
506
  noteCharacterPose(instanceId);
490
- const phase = dominantPhase(out.clips);
507
+ const phase = dominantPhase(out.clips, playback.phase);
491
508
  if (phase !== undefined)
492
509
  noteAnimationPhase(instanceId, phase.clip, phase.time);
493
510
  }
@@ -3,6 +3,7 @@ import { busVolumeSettingId, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, SETTING_ID
3
3
  import { readGraphicsSettings } from "@jgengine/core/settings/graphicsSettings";
4
4
  import { readFrameRateLimit } from "@jgengine/core/settings/frameRateLimit";
5
5
  import { DEFAULT_TOUCH_JOYSTICK_VARIANT, TOUCH_JOYSTICK_VARIANTS, TOUCH_STYLES, } from "@jgengine/core/input/touchScheme";
6
+ import { busMuteSettingId, MASTER_MUTE_SETTING_ID } from "../audio/busMix.js";
6
7
  /** Sentinel Controls value meaning "defer to the game's suggested touch skin". */
7
8
  export const TOUCH_STYLE_AUTO = "auto";
8
9
  /** Shell-internal: binds the SettingsStore touch-style choice to the touch renderer. @internal */
@@ -49,8 +50,10 @@ export function AudioSettingsBridge({ store, engine, buses, }) {
49
50
  const rev = useSettingsRevision(store);
50
51
  useEffect(() => {
51
52
  engine.setMasterGain(store.get(SETTING_IDS.masterVolume, DEFAULT_MASTER_VOLUME));
53
+ engine.setMasterMute(Boolean(store.get(MASTER_MUTE_SETTING_ID, false)));
52
54
  for (const bus of Object.values(buses ?? {})) {
53
55
  engine.setBusGain(bus.id, store.get(busVolumeSettingId(bus.id), bus.gain ?? 1));
56
+ engine.setBusMute(bus.id, Boolean(store.get(busMuteSettingId(bus.id), false)));
54
57
  }
55
58
  }, [store, engine, buses, rev]);
56
59
  return null;
@@ -1,8 +1,9 @@
1
1
  import { bindingLabel, type ActionCodesMap } from "@jgengine/core/input/actionBindings";
2
2
  import type { BindingOverrides } from "@jgengine/core/input/bindingOverrides";
3
3
  import type { AudioBusDef } from "@jgengine/core/audio/audioFalloff";
4
- import { type GameSettingDef, type SettingCategory, type SettingCategoryDef } from "@jgengine/core/settings/settingsModel";
4
+ import { type GameSettingDef, type SettingCategory, type SettingCategoryDef, type SettingsStore } from "@jgengine/core/settings/settingsModel";
5
5
  import { type GraphicsProfileOverrides } from "@jgengine/core/settings/graphicsSettings";
6
+ import type { PostProcessingConfig } from "@jgengine/core/render/postProcessing";
6
7
  import { type SettingsActionView, type SettingsKeybindRow, type SettingsCategoryView, type SettingsController, type SettingsRow } from "@jgengine/react/settings";
7
8
  export type { SettingsActionView, SettingsKeybindRow, SettingsCategoryView, SettingsController, SettingsRow };
8
9
  export interface SettingsControllerInput {
@@ -14,6 +15,8 @@ export interface SettingsControllerInput {
14
15
  fovEnabled: boolean;
15
16
  /** The game's per-tier profile overrides (`defineGame({ graphics })`), so tier defaults match what renders. */
16
17
  graphics?: GraphicsProfileOverrides;
18
+ /** The post chain this game actually runs, so a stage row never outlives its pass. */
19
+ postProcessing?: PostProcessingConfig;
17
20
  hideBindings: readonly string[];
18
21
  /** Show the touch-controls skin selector in Controls (touch device with a dock). */
19
22
  touchStyle: boolean;
@@ -22,4 +25,10 @@ export interface SettingsControllerInput {
22
25
  resetBinding: (action: string) => void;
23
26
  }
24
27
  export declare function useSettingsCategories(config: SettingsControllerInput): SettingsCategoryView[];
28
+ /**
29
+ * The built-in Graphics rows: tier, scale, filtering, shadows, one toggle per postfx stage this
30
+ * game's chain can build, then UI scale and FPS.
31
+ * @internal
32
+ */
33
+ export declare function graphicsSettingRows(store: SettingsStore, overrides: GraphicsProfileOverrides | undefined, postProcessing: PostProcessingConfig | undefined): SettingsRow[];
25
34
  export { bindingLabel };
@@ -1,8 +1,10 @@
1
1
  import { actionLabel, bindingLabel } from "@jgengine/core/input/actionBindings";
2
2
  import { BUILT_IN_SETTING_CATEGORIES, busVolumeSettingId, DEFAULT_GRAPHICS_SHADOWS, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, GRAPHICS_QUALITY_OPTIONS, SETTING_IDS, UI_SCALE_MAX, UI_SCALE_MIN, } from "@jgengine/core/settings/settingsModel";
3
- import { ANISOTROPY_OPTIONS, applyGraphicsQuality, GRAPHICS_POST_STAGE_LABELS, GRAPHICS_POST_STAGES, graphicsPostStageSettingId, readGraphicsSettings, RENDER_SCALE_MAX, RENDER_SCALE_MIN, RENDER_SCALE_STEP, } from "@jgengine/core/settings/graphicsSettings";
3
+ import { ANISOTROPY_OPTIONS, applyGraphicsQuality, GRAPHICS_POST_STAGE_LABELS, graphicsPostStageSettingId, readGraphicsSettings, RENDER_SCALE_MAX, RENDER_SCALE_MIN, RENDER_SCALE_STEP, } from "@jgengine/core/settings/graphicsSettings";
4
4
  import { FRAME_RATE_LIMIT_DISPLAY, FRAME_RATE_LIMIT_OPTIONS } from "@jgengine/core/settings/frameRateLimit";
5
5
  import { accessibilitySettingRows } from "@jgengine/core/settings/accessibilitySettings";
6
+ import { busMuteSettingId, MASTER_MUTE_SETTING_ID } from "../audio/busMix.js";
7
+ import { availablePostfxStages } from "../postfx/PostProcessing.js";
6
8
  import { DEFAULT_TOUCH_JOYSTICK_VARIANT, TOUCH_JOYSTICK_VARIANT_OPTIONS, TOUCH_STYLE_OPTIONS, } from "@jgengine/core/input/touchScheme";
7
9
  import { TOUCH_STYLE_AUTO, useSettingsRevision } from "./appliedSettings.js";
8
10
  import { useSettingsStore, } from "@jgengine/react/settings";
@@ -63,77 +65,24 @@ export function useSettingsCategories(config) {
63
65
  set: (value) => store.set(id, value),
64
66
  };
65
67
  }),
66
- ...extrasFor("sound"),
67
- ];
68
- const graphics = readGraphicsSettings(store, config.graphics);
69
- const graphicsRows = [
70
- {
71
- id: SETTING_IDS.graphicsQuality,
72
- label: "Quality",
73
- kind: "select",
74
- value: graphics.quality,
75
- options: GRAPHICS_QUALITY_OPTIONS,
76
- set: (value) => applyGraphicsQuality(store, String(value), config.graphics),
77
- },
78
- {
79
- id: SETTING_IDS.graphicsRenderScale,
80
- label: "Render scale",
81
- kind: "slider",
82
- value: graphics.profile.renderScale,
83
- min: RENDER_SCALE_MIN,
84
- max: RENDER_SCALE_MAX,
85
- step: RENDER_SCALE_STEP,
86
- format: percent,
87
- set: (value) => store.set(SETTING_IDS.graphicsRenderScale, value),
88
- },
89
- {
90
- id: SETTING_IDS.graphicsFrameRateLimit,
91
- label: "Frame rate limit",
92
- kind: "select",
93
- value: store.get(SETTING_IDS.graphicsFrameRateLimit, FRAME_RATE_LIMIT_DISPLAY),
94
- options: FRAME_RATE_LIMIT_OPTIONS,
95
- set: (value) => store.set(SETTING_IDS.graphicsFrameRateLimit, String(value)),
96
- },
97
- {
98
- id: SETTING_IDS.graphicsAnisotropy,
99
- label: "Texture filtering",
100
- kind: "select",
101
- value: String(graphics.profile.anisotropy),
102
- options: ANISOTROPY_OPTIONS,
103
- set: (value) => store.set(SETTING_IDS.graphicsAnisotropy, String(value)),
104
- },
105
- {
106
- id: SETTING_IDS.graphicsShadows,
107
- label: "Shadows",
68
+ ...Object.values(config.buses ?? {}).map((bus) => ({
69
+ id: busMuteSettingId(bus.id),
70
+ label: bus.id === "ui" ? "Mute UI" : `Mute ${bus.id}`,
108
71
  kind: "toggle",
109
- value: store.get(SETTING_IDS.graphicsShadows, DEFAULT_GRAPHICS_SHADOWS),
110
- set: (value) => store.set(SETTING_IDS.graphicsShadows, value),
111
- },
112
- ...GRAPHICS_POST_STAGES.map((stage) => ({
113
- id: graphicsPostStageSettingId(stage),
114
- label: GRAPHICS_POST_STAGE_LABELS[stage],
115
- kind: "toggle",
116
- value: graphics.profile.postStages[stage],
117
- set: (value) => store.set(graphicsPostStageSettingId(stage), value),
72
+ value: Boolean(store.get(busMuteSettingId(bus.id), false)),
73
+ set: (value) => store.set(busMuteSettingId(bus.id), Boolean(value)),
118
74
  })),
119
75
  {
120
- id: SETTING_IDS.graphicsUiScale,
121
- label: "UI scale",
122
- kind: "slider",
123
- value: store.get(SETTING_IDS.graphicsUiScale, DEFAULT_UI_SCALE),
124
- min: UI_SCALE_MIN,
125
- max: UI_SCALE_MAX,
126
- step: 0.05,
127
- format: percent,
128
- set: (value) => store.set(SETTING_IDS.graphicsUiScale, value),
129
- },
130
- {
131
- id: SETTING_IDS.graphicsShowFps,
132
- label: "Show FPS",
76
+ id: MASTER_MUTE_SETTING_ID,
77
+ label: "Mute all",
133
78
  kind: "toggle",
134
- value: store.get(SETTING_IDS.graphicsShowFps, false),
135
- set: (value) => store.set(SETTING_IDS.graphicsShowFps, value),
79
+ value: Boolean(store.get(MASTER_MUTE_SETTING_ID, false)),
80
+ set: (value) => store.set(MASTER_MUTE_SETTING_ID, Boolean(value)),
136
81
  },
82
+ ...extrasFor("sound"),
83
+ ];
84
+ const graphicsRows = [
85
+ ...graphicsSettingRows(store, config.graphics, config.postProcessing),
137
86
  ...extrasFor("graphics"),
138
87
  ];
139
88
  const gameplayRows = [
@@ -225,6 +174,84 @@ export function useSettingsCategories(config) {
225
174
  views.sort((a, b) => (declaredOrder.get(a.id) ?? indexOrder(order, a.id)) - (declaredOrder.get(b.id) ?? indexOrder(order, b.id)));
226
175
  return views;
227
176
  }
177
+ /**
178
+ * The built-in Graphics rows: tier, scale, filtering, shadows, one toggle per postfx stage this
179
+ * game's chain can build, then UI scale and FPS.
180
+ * @internal
181
+ */
182
+ export function graphicsSettingRows(store, overrides, postProcessing) {
183
+ const graphics = readGraphicsSettings(store, overrides);
184
+ const stages = postProcessing === undefined || postProcessing.enabled === false ? [] : availablePostfxStages(postProcessing);
185
+ return [
186
+ {
187
+ id: SETTING_IDS.graphicsQuality,
188
+ label: "Quality",
189
+ kind: "select",
190
+ value: graphics.quality,
191
+ options: GRAPHICS_QUALITY_OPTIONS,
192
+ set: (value) => applyGraphicsQuality(store, String(value), overrides),
193
+ },
194
+ {
195
+ id: SETTING_IDS.graphicsRenderScale,
196
+ label: "Render scale",
197
+ kind: "slider",
198
+ value: graphics.profile.renderScale,
199
+ min: RENDER_SCALE_MIN,
200
+ max: RENDER_SCALE_MAX,
201
+ step: RENDER_SCALE_STEP,
202
+ format: percent,
203
+ set: (value) => store.set(SETTING_IDS.graphicsRenderScale, value),
204
+ },
205
+ {
206
+ id: SETTING_IDS.graphicsFrameRateLimit,
207
+ label: "Frame rate limit",
208
+ kind: "select",
209
+ value: store.get(SETTING_IDS.graphicsFrameRateLimit, FRAME_RATE_LIMIT_DISPLAY),
210
+ options: FRAME_RATE_LIMIT_OPTIONS,
211
+ set: (value) => store.set(SETTING_IDS.graphicsFrameRateLimit, String(value)),
212
+ },
213
+ {
214
+ id: SETTING_IDS.graphicsAnisotropy,
215
+ label: "Texture filtering",
216
+ kind: "select",
217
+ value: String(graphics.profile.anisotropy),
218
+ options: ANISOTROPY_OPTIONS,
219
+ set: (value) => store.set(SETTING_IDS.graphicsAnisotropy, String(value)),
220
+ },
221
+ {
222
+ id: SETTING_IDS.graphicsShadows,
223
+ label: "Shadows",
224
+ kind: "toggle",
225
+ value: store.get(SETTING_IDS.graphicsShadows, DEFAULT_GRAPHICS_SHADOWS),
226
+ set: (value) => store.set(SETTING_IDS.graphicsShadows, value),
227
+ },
228
+ ...stages.map((stage) => ({
229
+ id: graphicsPostStageSettingId(stage),
230
+ label: GRAPHICS_POST_STAGE_LABELS[stage],
231
+ kind: "toggle",
232
+ value: graphics.profile.postStages[stage],
233
+ set: (value) => store.set(graphicsPostStageSettingId(stage), value),
234
+ })),
235
+ {
236
+ id: SETTING_IDS.graphicsUiScale,
237
+ label: "UI scale",
238
+ kind: "slider",
239
+ value: store.get(SETTING_IDS.graphicsUiScale, DEFAULT_UI_SCALE),
240
+ min: UI_SCALE_MIN,
241
+ max: UI_SCALE_MAX,
242
+ step: 0.05,
243
+ format: percent,
244
+ set: (value) => store.set(SETTING_IDS.graphicsUiScale, value),
245
+ },
246
+ {
247
+ id: SETTING_IDS.graphicsShowFps,
248
+ label: "Show FPS",
249
+ kind: "toggle",
250
+ value: store.get(SETTING_IDS.graphicsShowFps, false),
251
+ set: (value) => store.set(SETTING_IDS.graphicsShowFps, value),
252
+ },
253
+ ];
254
+ }
228
255
  function indexOrder(order, id) {
229
256
  const index = order.indexOf(id);
230
257
  return index === -1 ? 999 : index;
@@ -6,7 +6,7 @@ export interface RevealVisionOptions {
6
6
  tags: readonly string[];
7
7
  resolveTags: (entity: SceneEntity) => readonly string[];
8
8
  }
9
- /** Occlusion-ignoring tagged-entity radius query (#115), bound to the live scene. */
9
+ /** Occlusion-ignoring tagged-entity radius query (#115), bound to the live scene and refreshed on render. */
10
10
  export declare function useRevealHits(options: RevealVisionOptions): readonly RevealHit[];
11
11
  export interface RevealHighlightsProps extends RevealVisionOptions {
12
12
  enabled: boolean;
@@ -17,6 +17,8 @@ export interface RevealHighlightsProps extends RevealVisionOptions {
17
17
  * occluders (Dark Sight / detective-vision / wallhack style). Renders with
18
18
  * `depthTest: false` so the highlight draws over any wall standing between
19
19
  * the origin and the revealed entity, rather than the usual depth-sorted scene.
20
+ * Highlights track the origin and their targets every frame while enabled, so
21
+ * a disabled mode costs no renders.
20
22
  */
21
23
  export declare function RevealHighlights(props: RevealHighlightsProps): import("react").JSX.Element | null;
22
24
  export interface RevealScreenTintProps {