@jgengine/shell 0.29.0 → 0.30.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.
package/CHANGELOG.md CHANGED
@@ -26,6 +26,27 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.30.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.30.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.27.0`; `@jgengine/github` is `0.17.0`.
34
+
35
+ ### Added
36
+
37
+ - `ColorblindFrame` (`@jgengine/react/accessibility`) wraps the 3D canvas and applies the player's colour-vision mode to that box alone, so HUD, menu and caption text keep their own contrast. `colorblindFrameFilter` (`@jgengine/core/ui/accessibility`) resolves a mode to the CSS filter, and `useColorblindMode` reads the mode from the settings store the settings rows write; the 3D shell does both for the mounted game.
38
+ - `SettingsSection` (`@jgengine/react/settingsBlocks`) is one `useMenuNav` menu: a d-pad or left stick moves between rows and left/right steps the focused value — slider by its step, toggle flipped, select to the next option, confirm arms a keybind — through the same `onAdjust` path the arrow keys use. `onAdjust` (`@jgengine/react/menuNav`) is the option that makes left/right adjust instead of move.
39
+ - `createStanding` (`@jgengine/core/progression/standing`) holds per-faction or per-district standing over authored tracks and moves it from named outcomes, so an outcome that resolves to nothing throws at the call instead of silently moving nothing.
40
+ - `createGateSet` (`@jgengine/core/progression/gates`) evaluates authored milestones against standing and money, and refuses to load when a gate's unlock id resolves to nothing.
41
+ - `createCareerBank` (`@jgengine/core/progression/careerBank`) keeps persistent career money apart from a run's haul, so a failed run loses the haul and keeps the bank.
42
+ - `createUpgradeTree` (`@jgengine/core/progression/upgrades`) prices each rank from the shared curve primitive and gates purchases on prerequisites and standing.
43
+
44
+ ### Changed
45
+
46
+ - `AccessibilityProvider` (`@jgengine/react/accessibility`) sets its data attributes and text-scale var without filtering its own subtree. A colour matrix over the HUD and menu root recoloured the text the rest of the UI assumes, so wrap the game canvas in `ColorblindFrame` instead; a custom root can still offset it with its own `ColorblindFilters` and `filter: url(#jg-cb-<mode>)`.
47
+ - World health bars and nameplates derive their range from the render camera and the target height when a game sets no `maxDistance`: an overhead camera reaches the units it looks down at, and a bar is kept while the target it annotates still covers 18 px, instead of hiding every target past a fixed 60 units.
48
+ - A configured `worldHealthBars.maxDistance` or `nameplates.maxDistance` that hides every target in view now warns once with the nearest measured distance and the prop to raise, instead of rendering nothing.
49
+
29
50
  ## 0.29.0
30
51
 
31
52
  ### Migrate
@@ -13,6 +13,7 @@ import { balanceLightingForIbl } from "@jgengine/core/render/lightingBalance";
13
13
  import { skyNoonKeyframes } from "@jgengine/core/render/skyOwnedLighting";
14
14
  import { GameViewportProvider } from "@jgengine/react/gameViewport";
15
15
  import { GameProvider } from "@jgengine/react/provider";
16
+ import { ColorblindFrame, useColorblindMode } from "@jgengine/react/accessibility";
16
17
  import { SettingsProvider } from "@jgengine/react/settings";
17
18
  import { resolveWorldSky } from "./worldSky.js";
18
19
  import { resolveCaptureCameraConfig } from "./camera/rigResolve.js";
@@ -355,6 +356,8 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
355
356
  run: () => action.run(ctx),
356
357
  }));
357
358
  const touchScale = compact ? 0.88 : 1;
359
+ // The frame carries the player's colour-vision mode; the HUD and menu text above it stay unfiltered.
360
+ const colorblind = useColorblindMode(settingsStore);
358
361
  const dockMounted = !poster &&
359
362
  !orientationGate &&
360
363
  coarsePointer &&
@@ -383,31 +386,31 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
383
386
  else if (event.deltaY > 0 && ctx.game.commands.has("ui.hotbarScrollPrev")) {
384
387
  ctx.game.commands.run("ui.hotbarScrollPrev", {});
385
388
  }
386
- }, children: _jsxs(GameViewportProvider, { platforms: playable.platforms, children: [_jsxs(Canvas, { onCreated: ({ gl }) => {
387
- if (ktx2SupportDetected.current)
388
- return;
389
- ktx2SupportDetected.current = true;
390
- detectKtx2Support(gl);
391
- }, frameloop: poster && posterFrozen ? "demand" : graphics.frameRateLimit > 0 && !poster ? "never" : "always", orthographic: orthographic, camera: orthographic
392
- ? {
393
- zoom: playable.camera?.frustum?.zoom ?? CAMERA_FRUSTUM_DEFAULTS.zoom,
394
- near: playable.camera?.frustum?.near ?? CAMERA_FRUSTUM_DEFAULTS.near,
395
- far: playable.camera?.frustum?.far ?? CAMERA_FRUSTUM_DEFAULTS.far,
396
- }
397
- : {
398
- fov: playable.camera?.frustum?.fov ?? CAMERA_FRUSTUM_DEFAULTS.fov,
399
- near: playable.camera?.frustum?.near ?? CAMERA_FRUSTUM_DEFAULTS.near,
400
- far: playable.camera?.frustum?.far ?? CAMERA_FRUSTUM_DEFAULTS.far,
401
- }, shadows: graphics.shadows ? "percentage" : false, 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: sceneLighting?.hemisphere?.skyColor ?? "#87b5e0", groundColor: sceneLighting?.hemisphere?.groundColor ?? "#3d4a38", horizonTint: sceneLighting?.ambient?.color, sunDirection: sceneLighting?.directional?.[0]?.position, sunColor: sceneLighting?.directional?.[0]?.color ?? "#fff2d6", ...(authoredDaylight ? { daylight: daylightModulation } : {}) })) : null, sceneLighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: sceneLighting, profile: graphics.profile, ...(authoredDaylight ? { modulation: daylightModulation } : {}) })) : effectiveSky === undefined ? (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: cinematicLook ? 0.3 : 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] })) : null, authoredDaylight && effectiveSky !== undefined ? (_jsx(AuthoredDaylight, { sky: effectiveSky, clock: ctx.time, modulation: daylightModulation })) : null, effectiveSky !== undefined ? (effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)), fogRange: backdrop?.fog })) : (_jsx(SkyDaylight, { sky: effectiveSky, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)), fogRange: backdrop?.fog }))) : null, irradianceVolumes.length > 0 ? _jsx(IrradianceVolumeLighting, { volumes: irradianceVolumes }) : null, effectiveSky === undefined ? _jsx(BackdropFog, { fog: backdrop?.fog }) : null, _jsx(GameProvider, { context: ctx, children: _jsx(TerrainQualityContext.Provider, { value: graphics.quality, 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, particleBudget: graphics.profile.particleBudget, diagnostics: devtoolsEnabled }), _jsx(CombatPresentation, { effects: effects }), devtoolsEnabled ? _jsx(CollisionDebugWorld, {}) : null, _jsx(AudioListener, { engine: audioEngine, anchor: playable.camera?.rig === "rts" || playable.camera?.rig === "topDown" ? "ground" : "camera" }), _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) => {
402
- cameraDraggingRef.current = dragging;
403
- } }), _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) => {
404
- try {
405
- playable.loop.onNewPlayer(ctx, { userId: slot.userId, isNew: true });
406
- }
407
- catch (error) {
408
- reportRuntimeError(error, "tick");
409
- }
410
- }, seatsActive: () => !gateRef.current && playControlsActive(ctx) }), _jsx(DevtoolsRendererProbe, {}), devtoolsEnabled ? (_jsx(FrameTelemetry, { game: playable.game.name, quality: graphics.quality === "low" ? "draft" : "full" })) : null, splitScreen ? (_jsx(SplitScreenRenderer, { ctx: ctx, viewports: seatViewports, config: playable.viewports, mainCamera: playable.camera })) : null, !splitScreen && resolvedLook.postProcessing !== undefined && resolvedLook.postProcessing.enabled !== false ? (_jsx(PostProcessing, { config: resolvedLook.postProcessing, quality: graphics.quality, stages: graphics.profile.postStages })) : null, poster ? null : _jsx(SceneWarmupGate, { onState: setWarmup })] }), _jsx(SceneWarmupCover, { state: warmup }), graphics.showFps && !poster ? _jsx(FpsCounter, { tally: frameTally }) : null, !poster &&
389
+ }, children: _jsxs(GameViewportProvider, { platforms: playable.platforms, children: [_jsx(ColorblindFrame, { mode: colorblind, style: { position: "absolute", inset: 0 }, children: _jsxs(Canvas, { onCreated: ({ gl }) => {
390
+ if (ktx2SupportDetected.current)
391
+ return;
392
+ ktx2SupportDetected.current = true;
393
+ detectKtx2Support(gl);
394
+ }, frameloop: poster && posterFrozen ? "demand" : graphics.frameRateLimit > 0 && !poster ? "never" : "always", orthographic: orthographic, camera: orthographic
395
+ ? {
396
+ zoom: playable.camera?.frustum?.zoom ?? CAMERA_FRUSTUM_DEFAULTS.zoom,
397
+ near: playable.camera?.frustum?.near ?? CAMERA_FRUSTUM_DEFAULTS.near,
398
+ far: playable.camera?.frustum?.far ?? CAMERA_FRUSTUM_DEFAULTS.far,
399
+ }
400
+ : {
401
+ fov: playable.camera?.frustum?.fov ?? CAMERA_FRUSTUM_DEFAULTS.fov,
402
+ near: playable.camera?.frustum?.near ?? CAMERA_FRUSTUM_DEFAULTS.near,
403
+ far: playable.camera?.frustum?.far ?? CAMERA_FRUSTUM_DEFAULTS.far,
404
+ }, shadows: graphics.shadows ? "percentage" : false, 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: sceneLighting?.hemisphere?.skyColor ?? "#87b5e0", groundColor: sceneLighting?.hemisphere?.groundColor ?? "#3d4a38", horizonTint: sceneLighting?.ambient?.color, sunDirection: sceneLighting?.directional?.[0]?.position, sunColor: sceneLighting?.directional?.[0]?.color ?? "#fff2d6", ...(authoredDaylight ? { daylight: daylightModulation } : {}) })) : null, sceneLighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: sceneLighting, profile: graphics.profile, ...(authoredDaylight ? { modulation: daylightModulation } : {}) })) : effectiveSky === undefined ? (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: cinematicLook ? 0.3 : 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] })) : null, authoredDaylight && effectiveSky !== undefined ? (_jsx(AuthoredDaylight, { sky: effectiveSky, clock: ctx.time, modulation: daylightModulation })) : null, effectiveSky !== undefined ? (effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)), fogRange: backdrop?.fog })) : (_jsx(SkyDaylight, { sky: effectiveSky, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)), fogRange: backdrop?.fog }))) : null, irradianceVolumes.length > 0 ? _jsx(IrradianceVolumeLighting, { volumes: irradianceVolumes }) : null, effectiveSky === undefined ? _jsx(BackdropFog, { fog: backdrop?.fog }) : null, _jsx(GameProvider, { context: ctx, children: _jsx(TerrainQualityContext.Provider, { value: graphics.quality, 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, particleBudget: graphics.profile.particleBudget, diagnostics: devtoolsEnabled }), _jsx(CombatPresentation, { effects: effects }), devtoolsEnabled ? _jsx(CollisionDebugWorld, {}) : null, _jsx(AudioListener, { engine: audioEngine, anchor: playable.camera?.rig === "rts" || playable.camera?.rig === "topDown" ? "ground" : "camera" }), _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) => {
405
+ cameraDraggingRef.current = dragging;
406
+ } }), _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) => {
407
+ try {
408
+ playable.loop.onNewPlayer(ctx, { userId: slot.userId, isNew: true });
409
+ }
410
+ catch (error) {
411
+ reportRuntimeError(error, "tick");
412
+ }
413
+ }, seatsActive: () => !gateRef.current && playControlsActive(ctx) }), _jsx(DevtoolsRendererProbe, {}), devtoolsEnabled ? (_jsx(FrameTelemetry, { game: playable.game.name, quality: graphics.quality === "low" ? "draft" : "full" })) : null, splitScreen ? (_jsx(SplitScreenRenderer, { ctx: ctx, viewports: seatViewports, config: playable.viewports, mainCamera: playable.camera })) : null, !splitScreen && resolvedLook.postProcessing !== undefined && resolvedLook.postProcessing.enabled !== false ? (_jsx(PostProcessing, { config: resolvedLook.postProcessing, quality: graphics.quality, stages: graphics.profile.postStages })) : null, poster ? null : _jsx(SceneWarmupGate, { onState: setWarmup })] }) }), _jsx(SceneWarmupCover, { state: warmup }), graphics.showFps && !poster ? _jsx(FpsCounter, { tally: frameTally }) : null, !poster &&
411
414
  !orientationGate &&
412
415
  coarsePointer &&
413
416
  controlsActive &&
@@ -10,7 +10,7 @@ export declare function WorldEntityBars({ statId, height, roles, resolveRole, ma
10
10
  height?: number;
11
11
  roles?: readonly CatalogEntityRole[];
12
12
  resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined;
13
- /** Hide bars beyond this camera distance in world units. Default 60. */
13
+ /** Hide bars beyond this camera distance in world units. Unset derives the range from the camera and the target height. */
14
14
  maxDistance?: number;
15
15
  /** Hide bars behind blocking geometry from the render camera. Default true; false explicitly reveals through walls. */
16
16
  occlude?: boolean;
@@ -25,7 +25,7 @@ export interface WorldNameplatesProps {
25
25
  height?: number;
26
26
  roles?: readonly CatalogEntityRole[];
27
27
  resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined;
28
- /** Hide nameplates beyond this camera distance in world units. Default 40. */
28
+ /** Hide nameplates beyond this camera distance in world units. Unset derives the range from the camera and the plate height. */
29
29
  maxDistance?: number;
30
30
  /** Hide plates behind blocking geometry from the render camera. Default true; false explicitly reveals through walls. */
31
31
  occlude?: boolean;
@@ -9,7 +9,7 @@ import { useCameraShake } from "../camera/shakeChannel.js";
9
9
  import { CALIBRATED_TRAUMA_SHAKE_DECAY_PER_SECOND } from "../camera/rigMath.js";
10
10
  import { readFirstPersonMuzzle } from "../camera/GameFirstPersonCamera.js";
11
11
  import { resolveFloatTextStyle } from "./floatTextStyle.js";
12
- import { collectNameplateSamples, collectWorldBarSamples, paintWorldBarSamples, refreshWorldBarSamples, } from "./worldBarSamples.js";
12
+ import { collectNameplateSamples, collectWorldBarSamples, createOverlayRangeWarn, deriveWorldBarRange, nearestOverlayTargetDistance, paintWorldBarSamples, refreshWorldBarSamples, worldBarTargetFilter, worldNameplateTargetFilter, } from "./worldBarSamples.js";
13
13
  import { telegraphPulseOpacity } from "./telegraphPulse.js";
14
14
  import { useRenderVisibility } from "../visibility/CullingProvider.js";
15
15
  import { CAMERA_POST_FRAME_PRIORITY } from "../camera/cameraRigs.js";
@@ -18,7 +18,27 @@ export { telegraphPulseOpacity } from "./telegraphPulse.js";
18
18
  function pinOverlayToViewport(_el, _camera, size) {
19
19
  return [size.width / 2, size.height / 2];
20
20
  }
21
- export function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxDistance = 60, occlude = true, tickMs = 80, maxSamples = 64, }) {
21
+ /**
22
+ * A configured range that hides every overlay is a silent blank screen; measure the nearest
23
+ * on-screen target and name the prop to raise. Probes at most once a second and reports once.
24
+ */
25
+ function reportHiddenRange(ref, block, configured, measure) {
26
+ const state = (ref.current ??= { warn: createOverlayRangeWarn(), reported: false, at: -Infinity });
27
+ if (state.reported)
28
+ return;
29
+ const now = performance.now();
30
+ if (now - state.at < 1000)
31
+ return;
32
+ state.at = now;
33
+ const nearest = measure();
34
+ if (nearest === null)
35
+ return;
36
+ state.reported = true;
37
+ const message = state.warn(block, configured, nearest);
38
+ if (message !== null)
39
+ console.warn(message);
40
+ }
41
+ export function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxDistance, occlude = true, tickMs = 80, maxSamples = 64, }) {
22
42
  const ctx = useGameContext();
23
43
  const camera = useThree((state) => state.camera);
24
44
  const size = useThree((state) => state.size);
@@ -27,6 +47,7 @@ export function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxD
27
47
  const samplesRef = useRef([]);
28
48
  const projectRef = useRef(new THREE.Vector3());
29
49
  const lastTickRef = useRef(-Infinity);
50
+ const warnRef = useRef(null);
30
51
  const visibility = useRenderVisibility();
31
52
  useFrame((state) => {
32
53
  const nowMs = state.clock.elapsedTime * 1000;
@@ -43,12 +64,17 @@ export function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxD
43
64
  canvas.height = pixelH;
44
65
  }
45
66
  camera.updateMatrixWorld();
67
+ const viewport = { width: cssW, height: cssH };
68
+ const range = maxDistance ?? deriveWorldBarRange(camera, viewport, height);
46
69
  if (nowMs - lastTickRef.current >= Math.max(0, tickMs)) {
47
70
  lastTickRef.current = nowMs;
48
- collectWorldBarSamples(ctx, statId, height, roles, resolveRole, camera, { width: cssW, height: cssH }, samplesRef.current, projectRef.current, maxDistance, occlude, { maxSamples, isVisible: visibility.current });
71
+ collectWorldBarSamples(ctx, statId, height, roles, resolveRole, camera, viewport, samplesRef.current, projectRef.current, range, occlude, { maxSamples, isVisible: visibility.current });
72
+ if (maxDistance !== undefined && samplesRef.current.length === 0) {
73
+ reportHiddenRange(warnRef, "worldHealthBars", maxDistance, () => nearestOverlayTargetDistance(ctx, camera, height, deriveWorldBarRange(camera, viewport, height), projectRef.current, { maxSamples, isVisible: visibility.current }, worldBarTargetFilter(ctx, statId, roles, resolveRole)));
74
+ }
49
75
  }
50
76
  else {
51
- refreshWorldBarSamples(ctx, height, camera, { width: cssW, height: cssH }, samplesRef.current, projectRef.current, maxDistance, { isVisible: visibility.current });
77
+ refreshWorldBarSamples(ctx, height, camera, viewport, samplesRef.current, projectRef.current, range, { isVisible: visibility.current });
52
78
  }
53
79
  paintWorldBarSamples(canvas, samplesRef.current, dpr);
54
80
  }, CAMERA_POST_FRAME_PRIORITY + 0.01);
@@ -62,7 +88,7 @@ export function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxD
62
88
  *
63
89
  * @capability nameplates billboarded name + HP bar over nearby entities
64
90
  */
65
- export function WorldNameplates({ statId = "health", height = 2.3, roles, resolveRole, maxDistance = 40, occlude = true, tickMs = 120, maxSamples = 64, resolveName, showHealth = true, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }) {
91
+ export function WorldNameplates({ statId = "health", height = 2.3, roles, resolveRole, maxDistance, occlude = true, tickMs = 120, maxSamples = 64, resolveName, showHealth = true, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }) {
66
92
  const ctx = useGameContext();
67
93
  const camera = useThree((state) => state.camera);
68
94
  const size = useThree((state) => state.size);
@@ -70,6 +96,7 @@ export function WorldNameplates({ statId = "health", height = 2.3, roles, resolv
70
96
  const samplesRef = useRef([]);
71
97
  const projectRef = useRef(new THREE.Vector3());
72
98
  const lastTickRef = useRef(-Infinity);
99
+ const warnRef = useRef(null);
73
100
  const visibility = useRenderVisibility();
74
101
  useFrame((state) => {
75
102
  const nowMs = state.clock.elapsedTime * 1000;
@@ -77,7 +104,11 @@ export function WorldNameplates({ statId = "health", height = 2.3, roles, resolv
77
104
  return;
78
105
  lastTickRef.current = nowMs;
79
106
  camera.updateMatrixWorld();
80
- collectNameplateSamples(ctx, statId, height, roles, resolveRole, camera, { width: size.width, height: size.height }, samplesRef.current, projectRef.current, maxDistance, occlude, { maxSamples, isVisible: visibility.current, ...(resolveName === undefined ? {} : { resolveName }) });
107
+ const viewport = { width: size.width, height: size.height };
108
+ collectNameplateSamples(ctx, statId, height, roles, resolveRole, camera, viewport, samplesRef.current, projectRef.current, maxDistance ?? deriveWorldBarRange(camera, viewport, height), occlude, { maxSamples, isVisible: visibility.current, ...(resolveName === undefined ? {} : { resolveName }) });
109
+ if (maxDistance !== undefined && samplesRef.current.length === 0) {
110
+ reportHiddenRange(warnRef, "nameplates", maxDistance, () => nearestOverlayTargetDistance(ctx, camera, height, deriveWorldBarRange(camera, viewport, height), projectRef.current, { maxSamples, isVisible: visibility.current, ...(resolveName === undefined ? {} : { resolveName }) }, worldNameplateTargetFilter(roles, resolveRole, resolveName)));
111
+ }
81
112
  setSamples([...samplesRef.current]);
82
113
  }, CAMERA_POST_FRAME_PRIORITY + 0.01);
83
114
  return (_jsx(Html, { fullscreen: true, calculatePosition: pinOverlayToViewport, zIndexRange: [19, 0], style: { pointerEvents: "none" }, children: _jsx("div", { className: className, "data-nameplates": true, children: samples.map((sample) => renderNameplate !== undefined ? (_jsx("div", { style: { position: "absolute", left: sample.x, top: sample.y }, children: renderNameplate(sample) }, sample.id)) : (_jsxs("div", { className: nameplateClassName, "data-nameplate": sample.id, style: {
@@ -28,6 +28,8 @@ export interface WorldOverlayCamera {
28
28
  elements: ArrayLike<number>;
29
29
  };
30
30
  projectionMatrix: unknown;
31
+ /** Clipping distance; bounds every derived overlay range. */
32
+ far?: number;
31
33
  }
32
34
  /** Sampling policy shared by health bars and nameplates. */
33
35
  export interface WorldOverlaySampleOptions {
@@ -40,6 +42,36 @@ export interface WorldOverlaySampleOptions {
40
42
  }
41
43
  /** True when blocking scene geometry lies between the render camera and overlay anchor. @internal */
42
44
  export declare function worldBarOccluded(ctx: GameContext, from: EntityPosition, target: EntityPosition): boolean;
45
+ /**
46
+ * Overlay range for a camera that configured none. Two measured factors replace the magic
47
+ * distance: the camera's own view of the ground (the frustum corner at the depth it looks down
48
+ * through, so an overhead camera always reaches the units below it) and the distance at which an
49
+ * `anchorHeight`-tall target stops covering {@link MIN_TARGET_PX} pixels. Clamped to the camera's
50
+ * far plane.
51
+ * @internal
52
+ */
53
+ export declare function deriveWorldBarRange(camera: WorldOverlayCamera, viewport: {
54
+ width: number;
55
+ height: number;
56
+ }, anchorHeight: number): number;
57
+ /**
58
+ * Nearest on-screen target the overlay would annotate, in world units from the render camera, or
59
+ * `null` when nothing it covers is in view. Used to name the real distance when a configured
60
+ * range hides every bar. Scans one frozen list and compares squared distances — no per-target
61
+ * allocation — inside `searchRadius`.
62
+ * @internal
63
+ */
64
+ export declare function nearestOverlayTargetDistance(ctx: GameContext, camera: WorldOverlayCamera, anchorHeight: number, searchRadius: number, project: Projectable, options: WorldOverlaySampleOptions, accept: (entity: SceneEntity) => boolean): number | null;
65
+ /**
66
+ * One-shot warning text for a configured overlay range that hides everything in view, or `null`
67
+ * once it has reported the same block and distance.
68
+ * @internal
69
+ */
70
+ export declare function createOverlayRangeWarn(): (block: string, configured: number, nearest: number) => string | null;
71
+ /** Target filter mirroring the bars' own eligibility, for range diagnostics. @internal */
72
+ export declare function worldBarTargetFilter(ctx: GameContext, statId: string, roles: readonly CatalogEntityRole[] | undefined, resolveRole: ((entity: SceneEntity) => CatalogEntityRole | undefined) | undefined): (entity: SceneEntity) => boolean;
73
+ /** Target filter mirroring the nameplates' own eligibility, for range diagnostics. @internal */
74
+ export declare function worldNameplateTargetFilter(roles: readonly CatalogEntityRole[] | undefined, resolveRole: ((entity: SceneEntity) => CatalogEntityRole | undefined) | undefined, resolveName: ((entity: SceneEntity) => string | null) | undefined): (entity: SceneEntity) => boolean;
43
75
  /** Project nearby live non-local entities carrying `statId`; occlusion defaults on. @internal */
44
76
  export declare function collectWorldBarSamples(ctx: GameContext, statId: string, height: number, roles: readonly CatalogEntityRole[] | undefined, resolveRole: ((entity: SceneEntity) => CatalogEntityRole | undefined) | undefined, camera: WorldOverlayCamera, viewport: {
45
77
  width: number;
@@ -19,6 +19,80 @@ function cameraOrigin(camera) {
19
19
  const matrix = camera.matrixWorld.elements;
20
20
  return [matrix[12], matrix[13], matrix[14]];
21
21
  }
22
+ /** Screen height at which an annotated target is still plainly visible. */
23
+ const MIN_TARGET_PX = 18;
24
+ /** `1 / tan(fovY / 2)` for the engine's default 55° camera, used when the projection is not perspective. */
25
+ const FALLBACK_PROJECTION_SCALE = 1 / Math.tan((55 * Math.PI) / 360);
26
+ /**
27
+ * Overlay range for a camera that configured none. Two measured factors replace the magic
28
+ * distance: the camera's own view of the ground (the frustum corner at the depth it looks down
29
+ * through, so an overhead camera always reaches the units below it) and the distance at which an
30
+ * `anchorHeight`-tall target stops covering {@link MIN_TARGET_PX} pixels. Clamped to the camera's
31
+ * far plane.
32
+ * @internal
33
+ */
34
+ export function deriveWorldBarRange(camera, viewport, anchorHeight) {
35
+ const elements = camera.matrixWorld.elements;
36
+ const cameraY = elements[13] ?? 0;
37
+ const projection = camera.projectionMatrix?.elements;
38
+ const perspective = projection !== undefined && projection[15] === 0 && (projection[5] ?? 0) > 0;
39
+ const scale = perspective ? projection[5] : FALLBACK_PROJECTION_SCALE;
40
+ const tanHalf = 1 / scale;
41
+ const height = Math.max(0.1, anchorHeight);
42
+ const readable = viewport.height * 0.5 * scale * height / MIN_TARGET_PX;
43
+ const depth = Math.max(0, cameraY - height);
44
+ const aspect = viewport.width / Math.max(1, viewport.height);
45
+ const groundReach = depth * Math.sqrt(1 + tanHalf * tanHalf * (1 + aspect * aspect));
46
+ const reach = Math.max(readable, groundReach);
47
+ const far = camera.far;
48
+ const clamped = Number.isFinite(far) && far !== undefined && far > 0 ? Math.min(reach, far) : reach;
49
+ return Math.max(1, clamped);
50
+ }
51
+ /**
52
+ * Nearest on-screen target the overlay would annotate, in world units from the render camera, or
53
+ * `null` when nothing it covers is in view. Used to name the real distance when a configured
54
+ * range hides every bar. Scans one frozen list and compares squared distances — no per-target
55
+ * allocation — inside `searchRadius`.
56
+ * @internal
57
+ */
58
+ export function nearestOverlayTargetDistance(ctx, camera, anchorHeight, searchRadius, project, options, accept) {
59
+ if (!Number.isFinite(searchRadius) || searchRadius <= 0)
60
+ return null;
61
+ const origin = cameraOrigin(camera);
62
+ const limitSquared = searchRadius * searchRadius;
63
+ let nearest = Infinity;
64
+ for (const id of ctx.scene.entity.inRadius(origin, searchRadius)) {
65
+ const entity = eligibleEntity(ctx, id, options);
66
+ if (entity === null || !accept(entity))
67
+ continue;
68
+ const dx = entity.position[0] - origin[0];
69
+ const dy = entity.position[1] - origin[1];
70
+ const dz = entity.position[2] - origin[2];
71
+ const distanceSquared = dx * dx + dy * dy + dz * dz;
72
+ if (distanceSquared > limitSquared || distanceSquared >= nearest)
73
+ continue;
74
+ if (projectAnchor(entity, anchorHeight, camera, project) === null)
75
+ continue;
76
+ nearest = distanceSquared;
77
+ }
78
+ return Number.isFinite(nearest) ? Math.sqrt(nearest) : null;
79
+ }
80
+ /**
81
+ * One-shot warning text for a configured overlay range that hides everything in view, or `null`
82
+ * once it has reported the same block and distance.
83
+ * @internal
84
+ */
85
+ export function createOverlayRangeWarn() {
86
+ let reported = "";
87
+ return (block, configured, nearest) => {
88
+ const key = `${block}:${configured}`;
89
+ if (reported === key)
90
+ return null;
91
+ reported = key;
92
+ const suggested = Math.ceil(nearest + 1);
93
+ return `[jgengine] ${block}.maxDistance ${configured} hides every overlay on screen: the nearest target is ${nearest.toFixed(1)} world units away. Raise ${block}.maxDistance to about ${suggested}, or drop it to derive the range from the camera.`;
94
+ };
95
+ }
22
96
  function projectAnchor(entity, height, camera, project) {
23
97
  const anchor = [entity.position[0], entity.position[1] + height, entity.position[2]];
24
98
  const inverse = camera.matrixWorldInverse.elements;
@@ -62,15 +136,25 @@ function eligibleEntity(ctx, id, options) {
62
136
  function sampleLimit(options) {
63
137
  return Math.max(0, Math.min(256, Math.floor(options.maxSamples ?? 64) || 0));
64
138
  }
139
+ /** Target filter mirroring the bars' own eligibility, for range diagnostics. @internal */
140
+ export function worldBarTargetFilter(ctx, statId, roles, resolveRole) {
141
+ return (entity) => worldHealthBarAllowsRole(roles, resolveRole?.(entity)) && ctx.scene.entity.stats.get(entity.id, statId) !== null;
142
+ }
143
+ /** Target filter mirroring the nameplates' own eligibility, for range diagnostics. @internal */
144
+ export function worldNameplateTargetFilter(roles, resolveRole, resolveName) {
145
+ return (entity) => worldHealthBarAllowsRole(roles, resolveRole?.(entity)) &&
146
+ Boolean((resolveName ?? defaultDisplayName)(entity)?.trim());
147
+ }
65
148
  /** Project nearby live non-local entities carrying `statId`; occlusion defaults on. @internal */
66
- export function collectWorldBarSamples(ctx, statId, height, roles, resolveRole, camera, viewport, into, project, maxDistance = 60, occlude = true, options = {}) {
149
+ export function collectWorldBarSamples(ctx, statId, height, roles, resolveRole, camera, viewport, into, project, maxDistance, occlude = true, options = {}) {
67
150
  into.length = 0;
68
151
  const origin = cameraOrigin(camera);
69
152
  const limit = sampleLimit(options);
70
- if (limit === 0 || !Number.isFinite(maxDistance) || maxDistance <= 0)
153
+ const range = maxDistance ?? deriveWorldBarRange(camera, viewport, height);
154
+ if (limit === 0 || !Number.isFinite(range) || range <= 0)
71
155
  return 0;
72
156
  let checked = 0;
73
- for (const id of ctx.scene.entity.inRadius(origin, maxDistance)) {
157
+ for (const id of ctx.scene.entity.inRadius(origin, range)) {
74
158
  const entity = eligibleEntity(ctx, id, options);
75
159
  if (entity === null || !worldHealthBarAllowsRole(roles, resolveRole?.(entity)))
76
160
  continue;
@@ -97,14 +181,21 @@ export function collectWorldBarSamples(ctx, statId, height, roles, resolveRole,
97
181
  return into.length;
98
182
  }
99
183
  /** Reproject retained bar anchors without a nearby query or visibility ray. @internal */
100
- export function refreshWorldBarSamples(ctx, height, camera, viewport, samples, project, maxDistance = 60, options = {}) {
184
+ export function refreshWorldBarSamples(ctx, height, camera, viewport, samples, project, maxDistance, options = {}) {
101
185
  const origin = cameraOrigin(camera);
186
+ const range = maxDistance ?? deriveWorldBarRange(camera, viewport, height);
187
+ const rangeSquared = range * range;
102
188
  let kept = 0;
103
189
  for (const sample of samples) {
104
190
  if (sample.entityId === undefined)
105
191
  continue;
106
192
  const entity = eligibleEntity(ctx, sample.entityId, options);
107
- if (entity === null || Math.hypot(entity.position[0] - origin[0], entity.position[1] - origin[1], entity.position[2] - origin[2]) > maxDistance)
193
+ if (entity === null)
194
+ continue;
195
+ const dx = entity.position[0] - origin[0];
196
+ const dy = entity.position[1] - origin[1];
197
+ const dz = entity.position[2] - origin[2];
198
+ if (dx * dx + dy * dy + dz * dz > rangeSquared)
108
199
  continue;
109
200
  const anchor = projectAnchor(entity, height, camera, project);
110
201
  if (anchor === null)
@@ -122,14 +213,15 @@ function defaultDisplayName(entity) {
122
213
  return name === entity.id || /^[a-z0-9_-]+$/.test(name) || /[_:/]/.test(name) ? null : name || null;
123
214
  }
124
215
  /** Project nearby live non-local nameplates; occlusion defaults on. @internal */
125
- export function collectNameplateSamples(ctx, statId, height, roles, resolveRole, camera, viewport, into, project, maxDistance = 40, occlude = true, options = {}) {
216
+ export function collectNameplateSamples(ctx, statId, height, roles, resolveRole, camera, viewport, into, project, maxDistance, occlude = true, options = {}) {
126
217
  into.length = 0;
127
218
  const origin = cameraOrigin(camera);
128
219
  const limit = sampleLimit(options);
129
- if (limit === 0 || !Number.isFinite(maxDistance) || maxDistance <= 0)
220
+ const range = maxDistance ?? deriveWorldBarRange(camera, viewport, height);
221
+ if (limit === 0 || !Number.isFinite(range) || range <= 0)
130
222
  return 0;
131
223
  let checked = 0;
132
- for (const id of ctx.scene.entity.inRadius(origin, maxDistance)) {
224
+ for (const id of ctx.scene.entity.inRadius(origin, range)) {
133
225
  const entity = eligibleEntity(ctx, id, options);
134
226
  if (entity === null || !worldHealthBarAllowsRole(roles, resolveRole?.(entity)))
135
227
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/shell",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Game player shell for JGengine: React Three Fiber canvas, orbit camera, input tracking, HUD mounting, GameUiPreview, and a demo game. Consumers supply a GameRegistry.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -35,9 +35,9 @@
35
35
  "test": "bun test src"
36
36
  },
37
37
  "dependencies": {
38
- "@jgengine/core": "^0.29.0",
39
- "@jgengine/react": "^0.29.0",
40
- "@jgengine/ws": "^0.29.0"
38
+ "@jgengine/core": "^0.30.0",
39
+ "@jgengine/react": "^0.30.0",
40
+ "@jgengine/ws": "^0.30.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@react-three/drei": "^10.0.0",
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.29.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.30.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -285,7 +285,7 @@
285
285
 
286
286
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
287
287
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
288
- - `VERSION` (const): const VERSION: "0.29.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.30.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -407,6 +407,7 @@
407
407
  - `TEXT_SCALE_MAX` (const): const TEXT_SCALE_MAX: 2 — Maximum UI text scale.
408
408
  - `TEXT_SCALE_MIN` (const): const TEXT_SCALE_MIN: 0.75 — Minimum UI text scale.
409
409
  - `clampTextScale` (function): function clampTextScale(scale: number): number — Clamp a text-scale multiplier into the supported `[TEXT_SCALE_MIN, TEXT_SCALE_MAX]` range.
410
+ - `colorblindFrameFilter` (function): function colorblindFrameFilter(mode: ColorblindMode): string | null — The CSS filter that applies `mode` to one rendered frame — a `url(#jg-cb-<mode>)` reference to the `feColorMatrix` defs {@link COLORBLIND_MATRICES} drives. `"none"` maps to `null` (no filter), so a caller can spread it onto the canvas alone and leave HUD and menu text at its own contrast.
410
411
  - `createAccessibilityStore` (function): function createAccessibilityStore(initial: Partial<AccessibilityState> = {}): AccessibilityStore — Serializable, observable store of accessibility preferences (reduced motion, high contrast, text scale, colorblind mode, captions). Genre-agnostic plumbing like input rebinding — a game binds these to its settings UI and adapts its presentation; `snapshot`/`restore` round-trip through a save or settings blob.
411
412
  - `reducedMotionDuration` (function): function reducedMotionDuration(state: AccessibilityState, ms: number, reduced = 0): number — Return `ms` when motion is allowed, or `reduced` (default 0) when `reducedMotion` is set — the animation-duration gate.
412
413
 
@@ -652,7 +653,7 @@
652
653
  ## @jgengine/react
653
654
 
654
655
  - `AbilitySlotBindingOptions` (interface): interface AbilitySlotBindingOptions { intervalMs?: number } — ⚠ undocumented · used by `useEventMeter`: Bind a `createEventMeter` (`@jgengine/core/stats/eventMeter`) heat/streak gauge to a component — the react-render half of the ult/adrenaline…
655
- - `AccessibilityProvider` (function): function AccessibilityProvider({ store, children, className, style, }: { store: AccessibilityStore; children: ReactNode; className?: string; style?: CSSProperties; }): ReactNode — Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for text to scale off, sets `data-reduced-motion` / `data-high-contrast` / `data-colorblind` / `data-captions` for CSS to respond to, and wraps the tree in the selected colorblind `feColorMatrix` filter. Reads a `createAccessibilityStore` and re-renders on change; provides the state to `useAccessibility`.
656
+ - `AccessibilityProvider` (function): function AccessibilityProvider({ store, children, className, style, }: { store: AccessibilityStore; children: ReactNode; className?: string; style?: CSSProperties; }): ReactNode — Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for text to scale off, and sets `data-reduced-motion` / `data-high-contrast` / `data-colorblind` / `data-captions` for CSS to respond to. Reads a `createAccessibilityStore` and re-renders on change; provides the state to `useAccessibility`.
656
657
  - `AchievementGallery` (function): function AchievementGallery({ achievements, title = "Achievements", maskSecrets = true, renderIcon, columns = 2, emptyLabel = "No achievements yet.", className, style, }: AchievementGalleryProps): ReactNode — Achievement/trophy gallery — a responsive grid of cards showing unlocked vs. locked state, a progress bar for counter achievements, and a header summary of completion and score. Secret+locked entries mask their name/description. Feed it `useAchievements(tracker)`.
657
658
  - `AchievementGalleryProps` (interface): interface AchievementGalleryProps — Props for {@link AchievementGallery}.
658
659
  - `AchievementToast` (function): function AchievementToast({ name, description, icon, points, heading = "Achievement Unlocked", className, style, }: AchievementToastProps): ReactNode — The unlock-moment banner — icon, "Achievement Unlocked" heading, name, and optional points. Purely presentational; pair the game's `onUnlock` seam with a toast queue and render one of these per entry.
@@ -703,7 +704,9 @@
703
704
  - `Codex` (function): function Codex({ entries, title = "Codex", columns = 3, maskSecrets = true, renderIcon, onSelect, emptyLabel = "Nothing discovered yet.", className, style, }: CodexProps): ReactNode — Codex / bestiary gallery — category tabs, a responsive grid of entry cards (discovered vs. locked, secret entries masked until found), and a header summarizing completion. Feed it `codex.list()`; wire `onSelect` to a detail pane.
704
705
  - `CodexProps` (interface): interface CodexProps — Props for {@link Codex}.
705
706
  - `Coins` (function): function Coins({ currencyId, icon = "🪙", style, className, }: { currencyId: string; icon?: ReactNode; style?: CSSProperties; className?: string; }): React.JSX.Element — A currency counter — an icon (emoji/char, default a coin) plus the live amount for `currencyId`. *
706
- - `ColorblindFilters` (function): function ColorblindFilters(): ReactNode — Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that `AccessibilityProvider` references by `filter: url(#jg-cb-<mode>)`. Rendered automatically inside the provider; export standalone for custom roots.
707
+ - `ColorblindFilters` (function): function ColorblindFilters(): ReactNode — Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that `filter: url(#jg-cb-<mode>)` references. Mounted by {@link ColorblindFrame}; export it standalone for a root that applies the filter itself.
708
+ - `ColorblindFrame` (function): function ColorblindFrame({ mode = "none", children, className, style }: ColorblindFrameProps): ReactNode — Apply the player's colour-vision mode to one rendered frame: a box carrying `filter: url(#jg-cb-<mode>)` plus the filter defs it references. Put it around the canvas of a 3D game and nothing else — the filter is one compositor pass over that box's pixels, so HUD, menu and caption text outside it keep the contrast and hues the rest of the UI assumes.
709
+ - `ColorblindFrameProps` (interface): interface ColorblindFrameProps — Props for {@link ColorblindFrame}.
707
710
  - `ComboMeterHud` (function): function ComboMeterHud({ meter, tierColors, theme, animate = true, hideWhenEmpty = true, barWidth = 220, className, style, }: ComboMeterHudProps): ReactNode — A drop-in HUD for a core {@link ComboMeter}: the big live combo count ("×12"), the current free-string tier label, the derived score multiplier, and a draining window bar (reusing the shared atomic bar) that empties as the decay window runs out and re-fills on every hit. The active tier's color — supplied by the game via `tierColors`, keyed on the free-string tier id the model never interprets — drives the count glow, the label, and the bar fill, so a game reskins the escalation ("good" → "great" → "savage") without the engine hardcoding any tier meaning. Presentation only: all counting, timing, tiers, and the multiplier live in the core model.
708
711
  - `ComboMeterHudProps` (interface): interface ComboMeterHudProps — Props for {@link ComboMeterHud}.
709
712
  - `ComboMeterTheme` (interface): interface ComboMeterTheme — Reskin tokens for {@link ComboMeterHud}.
@@ -1067,6 +1070,7 @@
1067
1070
  - `useChat` (function): function useChat(channelId: string, options?: { limit?: number }): ChatMessage[] — ⚠ undocumented
1068
1071
  - `useChatBubbles` (function): function useChatBubbles(options?: ChatBubblesOptions): readonly ChatBubble[] — ⚠ undocumented
1069
1072
  - `useCoachMarks` (function): function useCoachMarks(sequence: CoachMarkSequence): CoachMarkView | null — Subscribe to a coach-mark sequence and re-render on every change, returning the current step view (or `null` when the tour is complete or waiting on a gate).
1073
+ - `useColorblindMode` (function): function useColorblindMode(store: SettingsStore): ColorblindMode — The live colour-vision mode from a settings store — the same store the settings rows write, so the frame follows the player's choice the moment they pick it. Pass the store the shell owns; read `useAccessibilitySettings().colorblind` instead when a `SettingsProvider` is above the caller.
1070
1074
  - `useComboMeter` (function): function useComboMeter(meter: ComboMeter, animate = true): ComboMeterView — Subscribe to a combo meter and re-render it each animation frame so the decay window bar drains live, returning the pooled view to draw right now. Reading the view each frame advances the meter's clock and fires window expiry, so a combo that times out visibly drops even with no further hits. The returned object is the model's pooled view — read its fields immediately, do not retain it.
1071
1075
  - `useCurrency` (function): function useCurrency(currencyId: string): number — ⚠ undocumented
1072
1076
  - `useDamageDirection` (function): function useDamageDirection(tracker: DamageDirectionTracker): readonly DamageIndicator[] — Subscribe to a damage-direction tracker and animate. Because indicators fade continuously on a clock (not just on discrete events), this drives a `requestAnimationFrame` loop while any indicator is live and stops when the screen is clear, then wakes again on the next hit. Returns the tracker's current `active()` array (reused between frames — copy if you retain it).
@@ -1110,7 +1114,7 @@
1110
1114
  - `useLocalPlayerDead` (function): function useLocalPlayerDead(healthStatId = "health"): boolean — ⚠ undocumented
1111
1115
  - `useLocale` (function): function useLocale(): Locale — Subscribe to the active locale — re-renders on `setLocale`.
1112
1116
  - `useMarkers` (function): function useMarkers<TMeta = unknown>(markers: MarkerSet<TMeta>): readonly MapMarker<TMeta>[] — Subscribe to a native marker set or external marker source, or read a static marker array.
1113
- - `useMenuNav` (function): function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, gamepad = true, repeat, }: UseMenuNavOptions): MenuNavController — Bind a menu's focus order to the DOM: arrow keys, WASD, Home/End and a standard gamepad move a roving focus, Enter/Space/A confirm, Escape/Backspace/B/Start go back. Tab is left to the browser, so a menu never traps focus, and typing in a text field is never intercepted. The menu is pure state + listeners — it unmounts with the screen, so it costs nothing during play.
1117
+ - `useMenuNav` (function): function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, onAdjust, gamepad = true, repeat, }: UseMenuNavOptions): MenuNavController — Bind a menu's focus order to the DOM: arrow keys, WASD, Home/End and a standard gamepad move a roving focus, Enter/Space/A confirm, Escape/Backspace/B/Start go back. Tab is left to the browser, so a menu never traps focus, and typing in a text field is never intercepted. The menu is pure state + listeners — it unmounts with the screen, so it costs nothing during play.
1114
1118
  - `useModalStack` (function): function useModalStack(stack: ModalStack): ModalStackView — Subscribe to a modal stack and re-render on every change, returning the active modal and depth.
1115
1119
  - `useNearestWorldItem` (function): function useNearestWorldItem(radius: number): WorldItemRecord | null — Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight.
1116
1120
  - `useNotifications` (function): function useNotifications<TMeta = unknown>(store: NotificationStore<TMeta>): readonly NotificationEntry<TMeta>[] — Subscribe to a notification store's live list.
@@ -1161,9 +1165,12 @@
1161
1165
 
1162
1166
  ## @jgengine/react/accessibility
1163
1167
 
1164
- - `AccessibilityProvider` (function): function AccessibilityProvider({ store, children, className, style, }: { store: AccessibilityStore; children: ReactNode; className?: string; style?: CSSProperties; }): ReactNode — Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for text to scale off, sets `data-reduced-motion` / `data-high-contrast` / `data-colorblind` / `data-captions` for CSS to respond to, and wraps the tree in the selected colorblind `feColorMatrix` filter. Reads a `createAccessibilityStore` and re-renders on change; provides the state to `useAccessibility`.
1165
- - `ColorblindFilters` (function): function ColorblindFilters(): ReactNode — Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that `AccessibilityProvider` references by `filter: url(#jg-cb-<mode>)`. Rendered automatically inside the provider; export standalone for custom roots.
1168
+ - `AccessibilityProvider` (function): function AccessibilityProvider({ store, children, className, style, }: { store: AccessibilityStore; children: ReactNode; className?: string; style?: CSSProperties; }): ReactNode — Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for text to scale off, and sets `data-reduced-motion` / `data-high-contrast` / `data-colorblind` / `data-captions` for CSS to respond to. Reads a `createAccessibilityStore` and re-renders on change; provides the state to `useAccessibility`.
1169
+ - `ColorblindFilters` (function): function ColorblindFilters(): ReactNode — Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that `filter: url(#jg-cb-<mode>)` references. Mounted by {@link ColorblindFrame}; export it standalone for a root that applies the filter itself.
1170
+ - `ColorblindFrame` (function): function ColorblindFrame({ mode = "none", children, className, style }: ColorblindFrameProps): ReactNode — Apply the player's colour-vision mode to one rendered frame: a box carrying `filter: url(#jg-cb-<mode>)` plus the filter defs it references. Put it around the canvas of a 3D game and nothing else — the filter is one compositor pass over that box's pixels, so HUD, menu and caption text outside it keep the contrast and hues the rest of the UI assumes.
1171
+ - `ColorblindFrameProps` (interface): interface ColorblindFrameProps — Props for {@link ColorblindFrame}.
1166
1172
  - `useAccessibility` (function): function useAccessibility(): AccessibilityContextValue — Read the live accessibility state + its store; throws without an {@link AccessibilityProvider} above.
1173
+ - `useColorblindMode` (function): function useColorblindMode(store: SettingsStore): ColorblindMode — The live colour-vision mode from a settings store — the same store the settings rows write, so the frame follows the player's choice the moment they pick it. Pass the store the shell owns; read `useAccessibilitySettings().colorblind` instead when a `SettingsProvider` is above the caller.
1167
1174
  - `usePrefersReducedMotion` (function): function usePrefersReducedMotion(): boolean — OS "reduce motion" preference (`prefers-reduced-motion: reduce`) as a live boolean — SSR-safe (defaults false). Use it to seed an accessibility store's `reducedMotion` default.
1168
1175
  - `useReducedMotion` (function): function useReducedMotion(): boolean — True when motion should be skipped: the OS `prefers-reduced-motion` preference, or the nearest {@link AccessibilityProvider}'s `reducedMotion` setting. Works without a provider.
1169
1176
 
@@ -1746,7 +1753,7 @@
1746
1753
  - `MenuNavItemProps` (interface): interface MenuNavItemProps — Props a menu item needs so {@link useMenuNav} can focus it and track pointer/focus.
1747
1754
  - `MenuNavRender` (type): type MenuNavRender = (nav: MenuNavController) => ReactNode — Render prop for a menu whose items come from {@link useMenuNav}.
1748
1755
  - `UseMenuNavOptions` (interface): interface UseMenuNavOptions — How {@link useMenuNav} is wired.
1749
- - `useMenuNav` (function): function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, gamepad = true, repeat, }: UseMenuNavOptions): MenuNavController — Bind a menu's focus order to the DOM: arrow keys, WASD, Home/End and a standard gamepad move a roving focus, Enter/Space/A confirm, Escape/Backspace/B/Start go back. Tab is left to the browser, so a menu never traps focus, and typing in a text field is never intercepted. The menu is pure state + listeners — it unmounts with the screen, so it costs nothing during play.
1756
+ - `useMenuNav` (function): function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, onAdjust, gamepad = true, repeat, }: UseMenuNavOptions): MenuNavController — Bind a menu's focus order to the DOM: arrow keys, WASD, Home/End and a standard gamepad move a roving focus, Enter/Space/A confirm, Escape/Backspace/B/Start go back. Tab is left to the browser, so a menu never traps focus, and typing in a text field is never intercepted. The menu is pure state + listeners — it unmounts with the screen, so it costs nothing during play.
1750
1757
 
1751
1758
  ## @jgengine/react/menuRouter
1752
1759
 
@@ -1921,16 +1928,17 @@
1921
1928
 
1922
1929
  ## @jgengine/react/settingsBlocks
1923
1930
 
1924
- - `SettingsKeybindControl` (function): function SettingsKeybindControl({ row, armed = false, onArm, buttonProps }: SettingsKeybindControlProps): ReactNode — A controls row for one action: its label, the bound key (click to rebind), a conflict/armed state and reset. The caller owns capture; this is the row's presentation.
1931
+ - `SettingsKeybindControl` (function): function SettingsKeybindControl({ row, armed = false, onArm, buttonProps, rowProps }: SettingsKeybindControlProps): ReactNode — A controls row for one action: its label, the bound key (click to rebind), a conflict/armed state and reset. The caller owns capture; this is the row's presentation.
1925
1932
  - `SettingsKeybindControlProps` (interface): interface SettingsKeybindControlProps — One rebindable action row: current key glyph, armed-capture state and a reset button.
1926
- - `SettingsRowControl` (function): function SettingsRowControl({ row }: { row: SettingsRow }): ReactNode — Render the control a {@link SettingsRow} kind asks for.
1927
- - `SettingsSection` (function): function SettingsSection({ category, heading = true, capture = true, className, style, onCaptureChange, }: SettingsSectionProps): ReactNode — One settings category as a block: its rows, then its rebindable actions with click-to-capture. The game picks where it sits and how wide it is; the block reads HudTheme tokens and the live rows the settings controller hands it, and writes every change straight back to that controller's store.
1933
+ - `SettingsRowControl` (function): function SettingsRowControl({ row, itemProps }: { row: SettingsRow; itemProps?: MenuNavItemProps }): ReactNode — Render the control a {@link SettingsRow} kind asks for. `itemProps` carries `useMenuNav` focus tracking from {@link SettingsSection}.
1934
+ - `SettingsSection` (function): function SettingsSection({ category, heading = true, capture = true, enabled = true, initialId, className, style, onCaptureChange, }: SettingsSectionProps): ReactNode — One settings category as a block: its rows, then its rebindable actions with click-to-capture. The game picks where it sits and how wide it is; the block reads HudTheme tokens and the live rows the settings controller hands it, and writes every change straight back to that controller's store.
1928
1935
  - `SettingsSectionProps` (interface): interface SettingsSectionProps — How a {@link SettingsSection} handles a rebind capture.
1929
- - `SettingsSelect` (function): function SettingsSelect({ row }: { row: SettingsRow }): ReactNode — One choice row from a {@link SettingsRow}, rendered as a pressed-state option group.
1930
- - `SettingsSlider` (function): function SettingsSlider({ row }: { row: SettingsRow }): ReactNode — One slider row from a {@link SettingsRow}: mirrors the thumb locally and commits on release, so a drag never storms the settings store or the graphics rebuild it drives.
1936
+ - `SettingsSelect` (function): function SettingsSelect({ row, itemProps }: { row: SettingsRow; itemProps?: MenuNavItemProps }): ReactNode — One choice row from a {@link SettingsRow}, rendered as a pressed-state option group.
1937
+ - `SettingsSlider` (function): function SettingsSlider({ row, itemProps }: { row: SettingsRow; itemProps?: MenuNavItemProps }): ReactNode — One slider row from a {@link SettingsRow}: mirrors the thumb locally and commits on release, so a drag never storms the settings store or the graphics rebuild it drives.
1931
1938
  - `SettingsTabs` (function): function SettingsTabs({ categories, activeId, onSelect, leading, tabProps, className, style }: SettingsTabsProps): ReactNode — The category tab rail for a settings screen: a scrollable row of buttons with pressed state. The game owns the surrounding layout; this block keeps every category reachable on narrow screens.
1932
1939
  - `SettingsTabsProps` (interface): interface SettingsTabsProps — The live category tabs for a settings screen: a scrollable rail with pressed state.
1933
- - `SettingsToggle` (function): function SettingsToggle({ row }: { row: SettingsRow }): ReactNode — One on/off row from a {@link SettingsRow}.
1940
+ - `SettingsToggle` (function): function SettingsToggle({ row, itemProps }: { row: SettingsRow; itemProps?: MenuNavItemProps }): ReactNode — One on/off row from a {@link SettingsRow}.
1941
+ - `adjustSettingRow` (function): function adjustSettingRow(row: SettingsRow, delta: -1 | 1): SettingValue | undefined — One adjustment of a settings row: a slider moves by its step inside its bounds, a toggle flips, a select moves one option (wrapping at both ends). Returns the value to write, or `undefined` when the kind has nothing to step. Shared by the keyboard and the gamepad so both change a row the same way.
1934
1942
 
1935
1943
  ## @jgengine/react/shopGrid
1936
1944
 
@@ -3654,9 +3662,9 @@
3654
3662
  - `ProjectileTracers` (function): function ProjectileTracers({ lifeMs = 130 }: { lifeMs?: number }): React.JSX.Element — ⚠ undocumented
3655
3663
  - `Reticle` (function): function Reticle({ className }: { className?: string }): React.JSX.Element — ⚠ undocumented
3656
3664
  - `WorldBarSample` (interface): interface WorldBarSample { entityId?: string; x: number; y: number; percent: number; pxPerMeter?: number } — ⚠ undocumented
3657
- - `WorldEntityBars` (function): function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxDistance = 60, occlude = true, tickMs = 80, maxSamples = 64, }: { statId: string; height?: number; roles?: readonly CatalogEntityRole[]; resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined; /** Hide bars beyond … — ⚠ undocumented
3665
+ - `WorldEntityBars` (function): function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxDistance, occlude = true, tickMs = 80, maxSamples = 64, }: { statId: string; height?: number; roles?: readonly CatalogEntityRole[]; resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined; /** Hide bars beyond this … — ⚠ undocumented
3658
3666
  - `WorldFloatText` (function): function WorldFloatText({ height = 1.9, lifeMs = 950 }: { height?: number; lifeMs?: number }): React.JSX.Element — ⚠ undocumented
3659
- - `WorldNameplates` (function): function WorldNameplates({ statId = "health", height = 2.3, roles, resolveRole, maxDistance = 40, occlude = true, tickMs = 120, maxSamples = 64, resolveName, showHealth = true, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }: WorldNameplatesProps): React… — Billboarded name + 78×6px HP bar over every nearby non-local entity that passes `roles`/`maxDistance` — headless (className/data-* slots on every part, `renderNameplate` for a full swap), turned on declaratively via `defineGame({ nameplates })` rather than mounted by hand.
3667
+ - `WorldNameplates` (function): function WorldNameplates({ statId = "health", height = 2.3, roles, resolveRole, maxDistance, occlude = true, tickMs = 120, maxSamples = 64, resolveName, showHealth = true, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }: WorldNameplatesProps): React.JSX.… — Billboarded name + 78×6px HP bar over every nearby non-local entity that passes `roles`/`maxDistance` — headless (className/data-* slots on every part, `renderNameplate` for a full swap), turned on declaratively via `defineGame({ nameplates })` rather than mounted by hand.
3660
3668
  - `WorldNameplatesProps` (interface): interface WorldNameplatesProps — Props for `WorldNameplates` — entity filter, refresh rate, and headless className/render hooks.
3661
3669
  - `WorldObjectHighlights` (function): function WorldObjectHighlights({ color = "#facc15", radius, y = 0.05 }: WorldObjectHighlightsProps): React.JSX.Element — Ground ring over every `ctx.scene.object.selection`-ed placed object — the object-layer counterpart to `WorldEntityBars`/`WorldNameplates`. Mount it once in the game's scene (headless: no defaults are imposed beyond a visible ring) instead of hand-rolling a selection highlight through `WorldOverlay` against external state.
3662
3670
  - `WorldObjectHighlightsProps` (interface): interface WorldObjectHighlightsProps — Props for {@link WorldObjectHighlights}.
@@ -4,7 +4,7 @@
4
4
 
5
5
  Reach for these before hand-rolling. Each row is *the thing you need* → *the primitive that already does it*.
6
6
 
7
- ## accessibility-provider — apply accessibility preferences to a subtree — text-scale CSS var, reduced-motion/high-contrast/colorblind/captions data attrs, and the colorblind color-matrix filter
7
+ ## accessibility-provider — apply accessibility preferences to a subtree — text-scale CSS var and reduced-motion/high-contrast/colorblind/captions data attrs
8
8
 
9
9
  - `AccessibilityProvider` (function) · `import { AccessibilityProvider } from "@jgengine/react"`
10
10
 
@@ -52,6 +52,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
52
52
 
53
53
  - `ActionTooltip` (function) · `import { ActionTooltip } from "@jgengine/react"`
54
54
 
55
+ ## adjust-setting-row — step a settings row value once, for keyboard and gamepad alike
56
+
57
+ - `adjustSettingRow` (function) · `import { adjustSettingRow } from "@jgengine/react/settingsBlocks"`
58
+
55
59
  ## animation — turn a killed rigged character into a physics ragdoll on death
56
60
 
57
61
  - `useRagdoll` (function) · `import { useRagdoll } from "@jgengine/shell/render/useRagdoll"`
@@ -159,6 +163,18 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
159
163
 
160
164
  - `ColorblindFilters` (function) · `import { ColorblindFilters } from "@jgengine/react"`
161
165
 
166
+ ## colorblind-frame — apply the player's colour-vision mode to the 3D frame alone, leaving HUD and menu text unfiltered
167
+
168
+ - `ColorblindFrame` (function) · `import { ColorblindFrame } from "@jgengine/react"`
169
+
170
+ ## colorblind-frame-filter — resolve a colorblind mode to the CSS filter for the 3D frame alone
171
+
172
+ - `colorblindFrameFilter` (function) · `import { colorblindFrameFilter } from "@jgengine/core/ui/accessibility"`
173
+
174
+ ## colorblind-frame-mode — read the live colour-vision mode from the settings store that the settings rows write
175
+
176
+ - `useColorblindMode` (function) · `import { useColorblindMode } from "@jgengine/react"`
177
+
162
178
  ## combo-meter-hud — drop-in HUD for a core combo meter — big live count, free-string tier label, derived multiplier, and a draining decay-window bar, per-tier colored from a caller map and HudTheme-token skinnable
163
179
 
164
180
  - `ComboMeterHud` (function) · `import { ComboMeterHud } from "@jgengine/react"`
@@ -758,7 +774,7 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
758
774
 
759
775
  - `SettingsKeybindControl` (function) · `import { SettingsKeybindControl } from "@jgengine/react/settingsBlocks"`
760
776
 
761
- ## settings-section — settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed
777
+ ## settings-section — settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed, keyboard and gamepad navigable
762
778
 
763
779
  - `SettingsSection` (function) · `import { SettingsSection } from "@jgengine/react/settingsBlocks"`
764
780
 
@@ -279,9 +279,9 @@ HUD-only games (`presentation: "hud"` or a camera with rig `"none"`) use the sam
279
279
 
280
280
  **Present it any way you want.** `useSettings()` (`@jgengine/react`) returns the live controller — `{ categories, actions, variant, surface, isOpen, open, close, setOpen }` — so a game can drive its own pause-menu button, or render `categories`/`actions` (rows carry `value`/`set`/bounds, keybinds carry `rebind`/`reset`) entirely inside its own HUD. `useHasSettings()` gates a custom entry; `useSetting(id, fallback)` reads/writes one value. Set a slider's `min`/`max` explicitly — an omitted range collapses the thumb to 0/1.
281
281
 
282
- **Composing your own settings layout.** `SettingsSection` (`@jgengine/react/settingsBlocks`) renders one controller category — its rows, then its click-to-capture keybind rows — and `SettingsTabs` renders the category rail (pass `tabProps` from `useMenuNav` so a gamepad can switch categories). `SettingsRowControl`, `SettingsSlider`, `SettingsToggle`, `SettingsSelect` and `SettingsKeybindControl` are the individual rows if the layout is not a list. All of them read `--jg-*` tokens and write through the controller, so the game's rebinds land in the same keybinding store as the stock menu. The stock menu uses these same blocks, so a game-composed screen and the engine dialog cannot drift.
282
+ **Composing your own settings layout.** `SettingsSection` (`@jgengine/react/settingsBlocks`) renders one controller category - its rows, then its click-to-capture keybind rows - as one `useMenuNav` menu: the d-pad and left stick move between rows, left/right step the focused value (slider by its step, toggle flipped, select to the next option), and confirm arms a keybind. Arrow keys take the same `onAdjust` path, so a keyboard and a pad cannot diverge. `SettingsTabs` renders the category rail (pass `tabProps` from `useMenuNav` so a gamepad can switch categories). `SettingsRowControl`, `SettingsSlider`, `SettingsToggle`, `SettingsSelect` and `SettingsKeybindControl` are the individual rows if the layout is not a list; pass the `itemProps` they accept so focus tracking still works. All of them read `--jg-*` tokens and write through the controller, so the game's rebinds land in the same keybinding store as the stock menu. The stock menu uses these same blocks, so a game-composed screen and the engine dialog cannot drift.
283
283
 
284
- **Accessibility is a shipped category.** The settings menu includes `accessibility` — text size, colourblind palette, reduce motion, high contrast, captions and hold-or-toggle — unless the game hides it. Text size multiplies every `--jg-type-*` token through `--jg-text-scale`, reduce motion zeroes `--jg-motion-scale`, and high contrast/colourblind/captions set `data-*` hooks on the HUD root; read them with `useAccessibilitySettings()` and `useHoldVsToggle()` for a custom layout. Apply the colourblind matrix (`ColorblindFilters`, `@jgengine/react/accessibility`) around the surface that should be daltonized — the shell sets the hook but does not wrap the 3D canvas.
284
+ **Accessibility is a shipped category.** The settings menu includes `accessibility` — text size, colourblind palette, reduce motion, high contrast, captions and hold-or-toggle — unless the game hides it. Text size multiplies every `--jg-type-*` token through `--jg-text-scale`, reduce motion zeroes `--jg-motion-scale`, and high contrast/colourblind/captions set `data-*` hooks on the HUD root; read them with `useAccessibilitySettings()` and `useHoldVsToggle()` for a custom layout. The 3D shell applies the colourblind matrix to its canvas alone with `ColorblindFrame` (`@jgengine/react/accessibility`), so HUD and menu text keep the contrast the rest of the UI assumes. A custom presentation wraps its own canvas in `ColorblindFrame` with `useColorblindMode(store)`; wrapping the HUD or menu root instead recolours the text, which is why `AccessibilityProvider` sets `data-colorblind` and leaves the filtering to the frame.
285
285
 
286
286
  ## 8. Motion and game feel
287
287
 
@@ -46,6 +46,58 @@
46
46
  - `planLookahead` (function): function planLookahead<S, M>(root: S, domain: LookaheadDomain<S, M>, profile: DifficultyProfile, rng: () => number): M | null — Pick the agent's next move by bounded lookahead: a depth-limited negamax (or single-perspective sequence search when `adversarial: false`) over the caller's {@link LookaheadDomain}, exploring at most `profile.planWidth` moves per ply to `profile.planDepth` plies, with the final root choice run through {@link pickScored} so `decisionNoise` still injects tier-appropriate blunders. Depth `1` is greedy — an easy profile literally cannot see the fork coming; an expert profile finds multi-ply tactics. Work is bounded by `planWidth ** planDepth` domain calls; run it at decision cadence (turns, ability windows, order re-evaluation), not per frame.
47
47
  - `shouldUseAbility` (function): function shouldUseAbility(opportunity: number, profile: DifficultyProfile, rng: () => number): boolean — Should the agent spend an ability now? `opportunity` is the caller-scored quality of this moment in `0..1` (targets clumped for the AoE, player mid-animation, burn phase). The perceived opportunity is fuzzed by `decisionNoise` and compared against `abilityDiscipline`: an easy profile (low discipline, high noise) wastes cooldowns on weak moments and sometimes sits on a perfect window; an expert profile fires exactly on strong windows. One rng draw when noisy.
48
48
 
49
+ ## @jgengine/core/ai/director/boss
50
+
51
+ - `BossBehaviour` (type): type BossBehaviour = Omit<SquadRole, "id" | "count"> — The role shape a boss fights with in one phase: the same dials a squad role uses.
52
+ - `BossPhase` (interface): interface BossPhase — One authored phase: the health it starts at, how the boss fights, and its tell.
53
+ - `BossState` (interface): interface BossState — Serializable boss state: which phase holds, how long it has held, and the tell still on screen.
54
+ - `BossStep` (interface): interface BossStep — One step of the boss clock.
55
+ - `BossTell` (interface): interface BossTell — The warning a boss gives while it changes phase, and how long the player has to read it.
56
+ - `advanceBoss` (function): function advanceBoss(phases: readonly BossPhase[], state: BossState, health: number, dt: number): BossStep — Advance the boss by `dt` at a health fraction and return the phase in effect.
57
+ - `bossPhase` (function): function bossPhase(phases: readonly BossPhase[], health: number): BossPhase — The phase a boss fights with at a health fraction. Pure read for a HUD; never mutates.
58
+ - `createBossState` (function): function createBossState(phases: readonly BossPhase[]): BossState — Fresh boss state for a phase list, opening in phase 0 with no tell showing.
59
+
60
+ ## @jgengine/core/ai/director/encounter
61
+
62
+ - `Encounter` (interface): interface Encounter — Live encounter: opens waves, emits timed spawn orders, and round-trips through a save.
63
+ - `EncounterConfig` (interface): interface EncounterConfig — Construction options for {@link createEncounter}.
64
+ - `EncounterDefinition` (interface): interface EncounterDefinition — A whole encounter, authored as data instead of spawned in game code.
65
+ - `EncounterGroup` (interface): interface EncounterGroup — One authored group inside a wave: what arrives, how many, where from, and when. `unit` and `entry` are game-owned ids — the engine never resolves them.
66
+ - `EncounterOrder` (interface): interface EncounterOrder — One unit the director says should enter now.
67
+ - `EncounterPressure` (interface): interface EncounterPressure — The subset of a pressure plan the encounter layer applies.
68
+ - `EncounterState` (interface): interface EncounterState — Serializable encounter state.
69
+ - `EncounterView` (interface): interface EncounterView — Pooled per-frame readout of where the encounter stands.
70
+ - `EncounterWave` (interface): interface EncounterWave — One authored wave.
71
+ - `createEncounter` (function): function createEncounter(config: EncounterConfig): Encounter — Create a live encounter over an authored definition: open a wave, step its clock, take the orders whose time has come, and close the wave when the game says it is cleared.
72
+ - `expandWave` (function): function expandWave(config: EncounterConfig, waveIndex: number, pressure: EncounterPressure = {}): EncounterOrder[] — Expand one authored wave into the units it sends, at the times it sends them.
73
+
74
+ ## @jgengine/core/ai/director/pressure
75
+
76
+ - `PressurePlan` (interface): interface PressurePlan — One bounded answer to "how hard now", plus the effects the encounter layer applies.
77
+ - `PressureReading` (interface): interface PressureReading — Encounter pressure: how hard a director leans on a player who is struggling, read from the player's own state, quantized to a tier, and bounded by the rule's cap. The plan is a value, not a callback, so a test can sweep it and a probe can print it.
78
+ - `PressureRule` (interface): interface PressureRule — The weights and window that turn a reading into an index, plus the hard cap.
79
+ - `evaluatePressure` (function): function evaluatePressure(reading: PressureReading, rule: PressureRule = {}): PressurePlan — Turn one reading into a bounded pressure plan.
80
+
81
+ ## @jgengine/core/ai/director/roles
82
+
83
+ - `FightIntent` (type): type FightIntent = "retreat" | "hold" | "flank" | "cover" | "assault" — The five duties a fighter can be given in a fight. A duty is *who does what*; the squad role a game dials (range, angles, fire discipline) is *how that duty fights* and stays content.
84
+ - `FightOrders` (interface): interface FightOrders — What the director decided, per fighter, and why.
85
+ - `FightSituation` (interface): interface FightSituation — The squad as it stands and the enemy it is facing, or no enemy at all.
86
+ - `FighterState` (interface): interface FighterState — One fighter the director can order, with the state the decision reads.
87
+ - `assignFightIntents` (function): function assignFightIntents(situation: FightSituation): FightOrders — Assign each fighter a duty from the squad's own state — who is hurt, who is under fire, and who already stands on the outside of the formation — never from an authored order or a spawn index.
88
+ - `compositionForIntents` (function): function compositionForIntents(order: readonly string[], intents: ReadonlyMap<string, FightIntent>, roleFor: (intent: FightIntent) => SquadRole): SquadComposition — Turn duties into a squad composition `createSquadCombat` accepts: one slot per fighter, in the order the game gives its members, each mapped to the role that game dials for the duty. Build it through `composition` so squad combat executes the director's assignment instead of its own static list.
89
+
90
+ ## @jgengine/core/ai/director/sound
91
+
92
+ - `Investigation` (interface): interface Investigation — A hunter still working a noise, as the squad layer reads it this tick.
93
+ - `NoiseEvent` (interface): interface NoiseEvent — One noise in the world, as the game produces it.
94
+ - `SoundHunt` (interface): interface SoundHunt — Sound hunting: raise a noise, get the bounded set of enemies that go and look.
95
+ - `SoundHuntConfig` (interface): interface SoundHuntConfig — Everything the hunt reads and its bounds.
96
+ - `SoundHuntEntry` (interface): interface SoundHuntEntry — One noise and the hunters sent to it. Serializable, so a hunt survives a save.
97
+ - `SoundHuntSnapshot` (interface): interface SoundHuntSnapshot — Serializable book-keeping: which noises are still being worked, and by whom.
98
+ - `SoundHunter` (interface): interface SoundHunter — A unit that could hear a noise: its id and where it stands when the noise lands.
99
+ - `createSoundHunt` (function): function createSoundHunt(config: SoundHuntConfig): SoundHunt — Make enemies investigate a noise, reusing the hearing owner.
100
+
49
101
  ## @jgengine/core/ai/driver
50
102
 
51
103
  - `DrivePathPoint` (type): type DrivePathPoint = readonly [number, number] — A 2D polyline point (`[x, z]`), structurally compatible with authored road path centerlines.
@@ -192,6 +192,14 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
192
192
 
193
193
  - `createBodyBind` (function) · `import { createBodyBind } from "@jgengine/core/world"`
194
194
 
195
+ ## boss-phase-state — fresh serializable boss phase state that opens in the first phase
196
+
197
+ - `createBossState` (function) · `import { createBossState } from "@jgengine/core/ai/director/boss"`
198
+
199
+ ## boss-phases — data-authored boss phases that change behaviour and fire a tell at health thresholds
200
+
201
+ - `advanceBoss` (function) · `import { advanceBoss } from "@jgengine/core/ai/director/boss"`
202
+
195
203
  ## bounded-grid-route — distinguish no path from exhausted grid search work
196
204
 
197
205
  - `findPathResult` (function) · `import { findPathResult } from "@jgengine/core/nav/navGrid"`
@@ -334,6 +342,18 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
334
342
 
335
343
  - `summarizeElevation` (function) · `import { summarizeElevation } from "@jgengine/core/world"`
336
344
 
345
+ ## encounter-expand — turn an authored wave into a deterministic, pressure-adjusted list of timed spawn orders
346
+
347
+ - `expandWave` (function) · `import { expandWave } from "@jgengine/core/ai/director/encounter"`
348
+
349
+ ## encounter-pressure — bounded difficulty tier read from player health, encounter progress and time
350
+
351
+ - `evaluatePressure` (function) · `import { evaluatePressure } from "@jgengine/core/ai/director/pressure"`
352
+
353
+ ## encounter-runner — open authored waves, emit their timed orders, and track wave progress through a save
354
+
355
+ - `createEncounter` (function) · `import { createEncounter } from "@jgengine/core/ai/director/encounter"`
356
+
337
357
  ## engine-layers — layered engine audio: rpm-keyed samples crossfaded, with on-load and off-load sets picked by engine load
338
358
 
339
359
  - `createEngineLayers` (function) · `import { createEngineLayers } from "@jgengine/core/audio/engineLayers"`
@@ -354,6 +374,14 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
354
374
 
355
375
  - `createFeedbackMixer` (function) · `import { createFeedbackMixer } from "@jgengine/core/vfx/feedbackMixer"`
356
376
 
377
+ ## fight-intent-composition — map assigned duties onto a squad combat composition in member order
378
+
379
+ - `compositionForIntents` (function) · `import { compositionForIntents } from "@jgengine/core/ai/director/roles"`
380
+
381
+ ## fight-intents — assign hold, flank, cover, assault and retreat duties from live squad state
382
+
383
+ - `assignFightIntents` (function) · `import { assignFightIntents } from "@jgengine/core/ai/director/roles"`
384
+
357
385
  ## find-effect — look up one named particle effect in the shared library, or undefined when unknown
358
386
 
359
387
  - `findEffect` (function) · `import { findEffect } from "@jgengine/core/vfx/effectLibrary"`
@@ -812,6 +840,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
812
840
 
813
841
  - `planSolidRoute` (function) · `import { planSolidRoute } from "@jgengine/core/nav/solidRoute"`
814
842
 
843
+ ## sound-hunting — send a bounded set of nearby enemies to investigate a noise with decaying memory
844
+
845
+ - `createSoundHunt` (function) · `import { createSoundHunt } from "@jgengine/core/ai/director/sound"`
846
+
815
847
  ## spatial-incremental — Update one entity's spatial cell after a committed write without scanning the population
816
848
 
817
849
  - `createSpatialApi` (function) · `import { createSpatialApi } from "@jgengine/core/scene/spatial"`