@jgengine/shell 0.29.0 → 0.31.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,59 @@ 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.31.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.31.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.28.0`; `@jgengine/github` is `0.18.0`.
34
+
35
+ ### Added
36
+
37
+ - `createAgent` (`@jgengine/core/agents/agent`) runs one resident's whole loop on the world clock: needs decay through `sim/needs`, a utility brain picks among the targets the game offers, an authored day plan says where the agent belongs, and the agent falls back to a declared target when its pick disappears. Mood, action and the far flag snapshot and restore with the needs.
38
+ - `createMoodReader` / `readMood` (`@jgengine/core/agents/mood`) derive a 0-100 mood and a caller-labelled tier from weighted need satisfaction, plus named sources a queue, a litter patch or a cold snap raises and clears.
39
+ - `createAgentSchedule` (`@jgengine/core/agents/schedule`) resolves an authored day plan through `sim/routine` and adds a work beat: a stop names the places it patrols, so a janitor is an agent with a route rather than a special case. Off-hours behaviour is a stop without a beat.
40
+ - `createRelationshipBook` (`@jgengine/core/agents/relationships`) keeps undirected familiarity and opinion per pair. A social action moves both and reports the milestones crossed, friends prefer each other's company, and a bonded pair refuses to split. Pair keys come from `relation/keyedValues` and crossings from `relation/thresholds`.
41
+ - `GameSave.deleteSlot(slot)` and `SaveStore.deleteSlot(slot)` remove one slot's payload and drop it from the slot index; they resolve `true` when a document was removed and `false` for an unknown slot. Deleting the active slot resets the live state to the initial document. `load()` no longer lists a slot that holds nothing, so a deleted slot cannot return.
42
+ - `HudPanel` takes `lane` (`@jgengine/react`): blocks naming the same anchor and lane stack without overlap, lanes in one anchor sit side by side, and `resolveHudLanes` resolves the rectangles. A moved panel leaves its lane and keeps its authored placement.
43
+ - `hudBlockOverlaps` and `formatHudOverlapReport` (`@jgengine/react`) name every pair of rendered HUD blocks whose rectangles intersect, with both ids and the viewport; `HudCanvas` warns with that report in a dev build.
44
+ - `rollDrop` (`@jgengine/core/loot/drop`) rolls one deterministic item from a seed and `compareToEquipped` turns it into per-stat deltas against worn gear with a weighted verdict a HUD can render.
45
+ - `createRunHaul` (`@jgengine/core/loot/haul`) gates carried loot by weight through the shared encumbrance rules, banks what the persistent stash fits, and discards the run's haul when the game calls `fail`.
46
+ - `salvageItem` and `createItemLedger` (`@jgengine/core/loot/salvage`) break an item down for the currency its level and rarity say and claim the instance, so a salvaged item cannot be salvaged, equipped or sold again.
47
+ - `stockVendor` and `createVendorRotation` (`@jgengine/core/loot/vendor`) fill a `ShopStock` with rolled items priced through the economy wallet, with a run-count restock rule the game authors.
48
+ - `markerProximity` on `createMissionRuntime` picks the plane a marker proximity test compares in: `"horizontal"` (default) measures ground distance and allows the actor a `tolerance` (default 8 m) above or below the marker, `"spatial"` is the straight 3D check for worlds with stacked platforms. `MissionMarkerPlane`, `MissionMarkerProximity` and `DEFAULT_MARKER_TOLERANCE` are exported with it.
49
+
50
+ ### Changed
51
+
52
+ - `evaluatePressure` (`@jgengine/core/ai/director/pressure`) reads pressure as player control: a healthy player further into a run is leaned on harder, a hurt player is eased off, and every effect stays inside the rule's `maxTier`.
53
+ - Loot bases and affixes take `minLevel`, so `rollLoot` only draws from entries at or below the item level. Loot bases take `mass`; rolled items carry it for carry checks.
54
+
55
+ ### Fixed
56
+
57
+ - `HudCanvas` resolved its host with `canvas.parentElement`, which is `HudThemeRoot`'s `display: contents` element and measures 0x0 — the boundary overlay, the HUD-fit measurement and the new overlap report never ran under the standard HUD root. It now uses the nearest ancestor with a real box.
58
+ - The HUD boundary check returned early whenever nothing overflowed, so a later report in the same pass could never run.
59
+ - `reach`, `interact` and the `left-area` fail condition compared a marker in 3D, so a marker authored at `y=0` under rising ground was unreachable. They now read the same plane.
60
+
61
+ ## 0.30.0
62
+
63
+ ### Migrate
64
+
65
+ - **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`.
66
+
67
+ ### Added
68
+
69
+ - `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.
70
+ - `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.
71
+ - `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.
72
+ - `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.
73
+ - `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.
74
+ - `createUpgradeTree` (`@jgengine/core/progression/upgrades`) prices each rank from the shared curve primitive and gates purchases on prerequisites and standing.
75
+
76
+ ### Changed
77
+
78
+ - `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>)`.
79
+ - 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.
80
+ - 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.
81
+
29
82
  ## 0.29.0
30
83
 
31
84
  ### 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.31.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.31.0",
39
+ "@jgengine/react": "^0.31.0",
40
+ "@jgengine/ws": "^0.31.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.31.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.31.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -728,7 +728,7 @@
728
728
  - `GameLoop` (interface): interface GameLoop<TContext = unknown> — Lifecycle hooks a game implements to drive init, per-tick simulation, and player join/leave.
729
729
  - `Hotbar` (function): function Hotbar({ inventoryId, activeSlot, keys, slotSize = 46, itemIcon, style, className, }: { inventoryId: string; activeSlot?: number; keys?: readonly string[]; slotSize?: number; /** Caller-supplied item id → icon registry; return null/undefined to fall back to the default glyph. */ itemIcon?: … — A numbered hotbar bound to an inventory — painted iconed slots (a `GameIcon` glyph over a school-keyed gradient with a count badge, #1035), an active-slot highlight, and a keycap per slot. Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one. Supply `itemIcon` to map your item ids to your own glyphs — the default resolves a `GameIcon` from the item id.
730
730
  - `HudCanvas` (function): function HudCanvas({ layout, editChord, compactScale, showDuring, className, style, children, }: { layout: HudLayoutStore; editChord?: HudEditChord | false; /** Zoom applied to the whole HUD on compact displays. Default 0.85. */ compactScale?: number; /** Opt-in play-phase gate: render the HUD only … — Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine anchor regions and stack automatically with a gap — no per-panel pixel offsets, no manual clearance for sibling panels, the touch-control dock (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the whole surface scales down and each panel applies its `compact` behavior.
731
- - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** Stack … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
731
+ - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", lane, order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
732
732
  - `PlayableGame` (type): type PlayableGame = EnginePlayableGame< ComponentType, ComponentType, RenderEntity, RenderObject, ComponentType<ViewmodelProps>, ComponentType<WorldOverlayProps> > & { /** HUD theme the shell applies at the `GameUI` root. Unset uses `defaultHudTheme`; `false` opts out of the default theme and the en… — The concrete React-bound playable a shell `defineGame` returns and `GameHost` mounts — the one PlayableGame games handle.
733
733
  - `StatBar` (function): function StatBar({ statId = "health", entityId, tone = "health", label, showValue = true, width = 200, icon, style, className, }: { statId?: string; entityId?: string; tone?: StatTone; label?: string; showValue?: boolean; width?: number; icon?: ReactNode; style?: CSSProperties; className?: string; }… — A polished stat bar (health/mana/stamina/shield/xp) — a rounded, glassy meter with a tone-colored fill and an optional value readout. Reads `statId` off `entityId` (defaults to the local player). Renders nothing until the stat exists.
734
734
  - `StoreHandle` (interface): interface StoreHandle<T> — A typed, cast-free handle onto one slot of the reactive game store (`ctx.game.store`). The single type parameter is fixed at definition; `read`/`write`/`update` never widen to `unknown`, so no call site repeats `store.get(key) as T`. Writes flow through the engine store, so they bump `ctx.version()`, serialize into a {@link WorldSnapshot}, and replay under host authority — the same guarantees a hand-rolled `store.get`/`store.set` pair gives up the moment it forks run state into a module-level singleton.
@@ -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}.
@@ -783,6 +786,9 @@
783
786
  - `HUD_THEME_PRESETS` (const): const HUD_THEME_PRESETS: { "arcane-stone": HudTheme; "survival-wood": HudTheme; "military-flat": HudTheme; "sleek-hex": HudTheme; "elemental-circle": HudTheme; "sandbox-slot": HudTheme; ember: HudTheme; synthwave: HudTheme; fieldkit: HudTheme; } — Built-in theme presets for demos/scaffold only — not product identity; games author their own theme.
784
787
  - `HealthBar` (const): const HealthBar: (props: AtomicBarProps) => React.JSX.Element — Player/enemy health — danger-flashes to `--jg-health-low` below a quarter full.
785
788
  - `Hotbar` (function): function Hotbar({ inventoryId, activeSlot, keys, slotSize = 46, itemIcon, style, className, }: { inventoryId: string; activeSlot?: number; keys?: readonly string[]; slotSize?: number; /** Caller-supplied item id → icon registry; return null/undefined to fall back to the default glyph. */ itemIcon?: … — A numbered hotbar bound to an inventory — painted iconed slots (a `GameIcon` glyph over a school-keyed gradient with a count badge, #1035), an active-slot highlight, and a keycap per slot. Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one. Supply `itemIcon` to map your item ids to your own glyphs — the default resolves a `GameIcon` from the item id.
789
+ - `HudBlockBounds` (interface): interface HudBlockBounds — A rendered block, as measured from the DOM.
790
+ - `HudBlockOverlap` (interface): interface HudBlockOverlap — One intersection between two rendered HUD blocks.
791
+ - `HudBlockOverlapOptions` (interface): interface HudBlockOverlapOptions — Options for {@link hudBlockOverlaps}.
786
792
  - `HudCanvas` (function): function HudCanvas({ layout, editChord, compactScale, showDuring, className, style, children, }: { layout: HudLayoutStore; editChord?: HudEditChord | false; /** Zoom applied to the whole HUD on compact displays. Default 0.85. */ compactScale?: number; /** Opt-in play-phase gate: render the HUD only … — Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine anchor regions and stack automatically with a gap — no per-panel pixel offsets, no manual clearance for sibling panels, the touch-control dock (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the whole surface scales down and each panel applies its `compact` behavior.
787
793
  - `HudCompactMode` (type): type HudCompactMode = "keep" | "chip" | "hide" — How a panel behaves on compact (phone-scale) displays. `keep` stays visible at the global compact scale, `chip` collapses to a small tap-to-expand pill, `hide` unmounts entirely.
788
794
  - `HudEditChord` (interface): interface HudEditChord { hold: string; press: string } — ⚠ undocumented · used by `HudCanvas`: Full-viewport HUD surface.
@@ -790,9 +796,13 @@
790
796
  - `HudFrameProps` (interface): interface HudFrameProps — Props for {@link HudFrame}.
791
797
  - `HudFrameShape` (type): type HudFrameShape = "rounded" | "circle" | "square" — Frame corner shape — `rounded` (the variation's default radius), `circle` (fully round), or `square` (no radius).
792
798
  - `HudFrameVariation` (type): type HudFrameVariation = "glass" | "plate" | "retro" | "themed" — Frame skin — `glass` (the shared dark-glass panel), `plate` (a heavier opaque plate), `retro` (a hard black-outlined frame), or `themed` (driven by the `--jg-frame-*` `HudTheme` tokens).
799
+ - `HudLaneConfig` (interface): interface HudLaneConfig — How the shell lays out lanes.
800
+ - `HudLaneLayout` (interface): interface HudLaneLayout — The resolved layout of every claimed block.
801
+ - `HudLanePlacement` (interface): interface HudLanePlacement — One block's resolved placement.
802
+ - `HudLaneSlot` (interface): interface HudLaneSlot — One HUD block's claim on a lane. Two blocks that name the same `anchor` and `lane` stack in `order` order; a lane is a column, so lanes in the same anchor sit side by side. The shell resolves every claim into a rectangle, which is what keeps blocks from arranging themselves into each other.
793
803
  - `HudLayoutPersist` (type): type HudLayoutPersist = (id: string, panel: EditorUiPanelLayout) => void — Host-owned callback that commits one HUD panel layout into the scene document (typically an undoable `setUiPanel` dispatch). Injected by the editor so product UI never reaches for editor globals.
794
804
  - `HudLayoutPersistProvider` (function): function HudLayoutPersistProvider({ onPanelCommit, children, }: { onPanelCommit: HudLayoutPersist; children?: ReactNode; }): React.JSX.Element — Provides {@link HudLayoutPersist} / `onPanelCommit` to descendant `useHudLayout` calls. Mount around game UI when a host can accept document UI patches (editor). Absent provider → canvas moves/resizes are no-ops for document writes.
795
- - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** Stack … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
805
+ - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", lane, order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
796
806
  - `HudSkin` (interface): interface HudSkin — A game-authored visual skin layered over a {@link HudTheme}.
797
807
  - `HudSkinMaterial` (interface): interface HudSkinMaterial — Optional raster-backed materials for shared HUD chrome.
798
808
  - `HudSkinPreview` (function): function HudSkinPreview({ className }: { className?: string }): React.JSX.Element — Procedural two-skin fixture for visual regression captures.
@@ -1030,8 +1040,10 @@
1030
1040
  - `defaultHudTheme` (const): const defaultHudTheme: HudTheme — The engine's default HUD look, applied by the shell at the HUD root of every game that does not opt out: a strong system UI font stack, layered translucent panels with a lit top rim and crisp hairline borders, a 12px type floor, and motion that honours reduced-motion. Games override any part with their own theme on a HUD ancestor.
1031
1041
  - `deriveHudTheme` (function): function deriveHudTheme(seed: HudThemeSeed): HudTheme — Derives a coherent full theme from an accent/surface pair — shades, edges, dim text, frames, bar troughs, and slots all fall out of the seed, so a game gets one consistent look across the `packages/react` primitives and the `registry/jgengine` blocks without naming 50 tokens. Hex seeds only (`#rgb` or `#rrggbb`).
1032
1042
  - `eventMeterNeedsHeartbeat` (function): function eventMeterNeedsHeartbeat(meter: EventMeter, previous: EventMeterView | null): boolean — True when `meter`'s value/fraction/tier/ready diverge from `previous` — the re-render check `useEventMeter` polls on its heartbeat.
1043
+ - `formatHudOverlapReport` (function): function formatHudOverlapReport(overlaps: readonly HudBlockOverlap[], viewport: HudSize): string — The console block for a set of overlaps — one line per pair naming both block ids, plus the viewport they collided at. Empty string when nothing intersects.
1033
1044
  - `formatTimerMs` (function): function formatTimerMs(ms: number, format: TimerFormat = "mm:ss"): string — Format a millisecond duration as digital timer text — `mm:ss` (default), `m:ss.d` (with tenths), or `ss.d` (seconds + tenths). Negative inputs clamp to zero. No genre meaning: the same helper serves a round clock, a respawn countdown, or an ability charge readout.
1034
1045
  - `guestIdentity` (function): function guestIdentity(seed?: string): IdentitySource — ⚠ undocumented
1046
+ - `hudBlockOverlaps` (function): function hudBlockOverlaps(blocks: readonly HudBlockBounds[], options: HudBlockOverlapOptions = {}): HudBlockOverlap[] — Every pair of rendered HUD blocks that intersects. This is the development-time check behind the console report: it reads what actually hit the screen rather than what a layout intended, so a block that overflows its own box or a game override that lands on a neighbour is named too.
1035
1047
  - `hudSkinVars` (function): function hudSkinVars(skin?: HudSkin): CSSProperties — Converts a skin into the CSS variables consumed by shared HUD components.
1036
1048
  - `hudTheme` (function): function hudTheme(overrides: HudThemeOverrides): HudTheme — Builds a full {@link HudTheme} from per-group partials, filling the rest from {@link defaultHudTheme}. Reach for this when a game wants to name exact values; use {@link deriveHudTheme} when a seed color pair is enough.
1037
1049
  - `hudThemeVars` (function): function hudThemeVars(theme: HudTheme): CSSProperties — Emits theme CSS properties, resetting inherited bar fills when absent. Spread onto a HUD ancestor.
@@ -1046,6 +1058,7 @@
1046
1058
  - `previewFixtureNames` (function): function previewFixtureNames(): string[] — Sorted list of registered fixture names, for discovery/listing.
1047
1059
  - `relativeSavedAt` (function): function relativeSavedAt(savedAt: number, nowMs = Date.now()): string — Turn a `savedAt` timestamp into a compact relative label ("just now", "4m ago", "2d ago"). Presentation-only helper — the model stores raw ms.
1048
1060
  - `resolveDialogueInvoke` (function): function resolveDialogueInvoke(choice: DialogueChoice, result: CheckResult | null): { command: string; args?: unknown } | null — ⚠ undocumented
1061
+ - `resolveHudLanes` (function): function resolveHudLanes(slots: readonly HudLaneSlot[], viewport: HudSize, config: HudLaneConfig = {}): HudLaneLayout — Resolve lane claims into non-overlapping rectangles for one frame.
1049
1062
  - `resolveHudTheme` (function): function resolveHudTheme(theme?: HudThemePreset | HudTheme): HudTheme — Resolves a preset name (or a full theme) to a `HudTheme`; falls back to the default theme.
1050
1063
  - `resolvePreviewFixture` (function): function resolvePreviewFixture(name: string): PreviewFixture | undefined — Resolve a fixture by name, or `undefined` when it is not registered.
1051
1064
  - `runDialogueChoice` (function): function runDialogueChoice(commands: { run(name: string, input?: unknown): unknown }, choice: DialogueChoice, result: CheckResult | null): void — Route a {@link DialogueBox} choice through the `features.dialogue` bridge: resolve the choice's invoke (honoring a skill-check `result`), run that command, and otherwise close the dialogue — the write side that replaces a per-game `onChoice` that hand-rolls `resolveDialogueInvoke` + `dialogue.close`.
@@ -1067,6 +1080,7 @@
1067
1080
  - `useChat` (function): function useChat(channelId: string, options?: { limit?: number }): ChatMessage[] — ⚠ undocumented
1068
1081
  - `useChatBubbles` (function): function useChatBubbles(options?: ChatBubblesOptions): readonly ChatBubble[] — ⚠ undocumented
1069
1082
  - `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).
1083
+ - `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
1084
  - `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
1085
  - `useCurrency` (function): function useCurrency(currencyId: string): number — ⚠ undocumented
1072
1086
  - `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 +1124,7 @@
1110
1124
  - `useLocalPlayerDead` (function): function useLocalPlayerDead(healthStatId = "health"): boolean — ⚠ undocumented
1111
1125
  - `useLocale` (function): function useLocale(): Locale — Subscribe to the active locale — re-renders on `setLocale`.
1112
1126
  - `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.
1127
+ - `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
1128
  - `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
1129
  - `useNearestWorldItem` (function): function useNearestWorldItem(radius: number): WorldItemRecord | null — Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight.
1116
1130
  - `useNotifications` (function): function useNotifications<TMeta = unknown>(store: NotificationStore<TMeta>): readonly NotificationEntry<TMeta>[] — Subscribe to a notification store's live list.
@@ -1161,9 +1175,12 @@
1161
1175
 
1162
1176
  ## @jgengine/react/accessibility
1163
1177
 
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.
1178
+ - `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`.
1179
+ - `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.
1180
+ - `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.
1181
+ - `ColorblindFrameProps` (interface): interface ColorblindFrameProps — Props for {@link ColorblindFrame}.
1166
1182
  - `useAccessibility` (function): function useAccessibility(): AccessibilityContextValue — Read the live accessibility state + its store; throws without an {@link AccessibilityProvider} above.
1183
+ - `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
1184
  - `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
1185
  - `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
1186
 
@@ -1526,12 +1543,29 @@
1526
1543
  - `HudInteractProvider` (function): function HudInteractProvider({ onInteract, children }: { onInteract: HudInteractListener; children?: ReactNode }): ReactNode — Routes interactions from every engine HUD primitive below it (buttons, panels, windows, action slots) to one listener — the seam a UI sound or haptics system hooks without per-button wiring. Primitives also expose `data-jg-cue` and a per-instance `onInteract`.
1527
1544
  - `useHudInteract` (function): function useHudInteract(source: string, cue?: string, onInteract?: HudInteractListener): (kind: HudInteractKind) => void — Returns a reporter that calls the instance listener, then the nearest {@link HudInteractProvider}.
1528
1545
 
1546
+ ## @jgengine/react/hudLanes
1547
+
1548
+ - `HudBlockBounds` (interface): interface HudBlockBounds — A rendered block, as measured from the DOM.
1549
+ - `HudBlockOverlap` (interface): interface HudBlockOverlap — One intersection between two rendered HUD blocks.
1550
+ - `HudBlockOverlapOptions` (interface): interface HudBlockOverlapOptions — Options for {@link hudBlockOverlaps}.
1551
+ - `HudLaneConfig` (interface): interface HudLaneConfig — How the shell lays out lanes.
1552
+ - `HudLaneLayout` (interface): interface HudLaneLayout — The resolved layout of every claimed block.
1553
+ - `HudLanePlacement` (interface): interface HudLanePlacement — One block's resolved placement.
1554
+ - `HudLaneSlot` (interface): interface HudLaneSlot — One HUD block's claim on a lane. Two blocks that name the same `anchor` and `lane` stack in `order` order; a lane is a column, so lanes in the same anchor sit side by side. The shell resolves every claim into a rectangle, which is what keeps blocks from arranging themselves into each other.
1555
+ - `formatHudOverlapReport` (function): function formatHudOverlapReport(overlaps: readonly HudBlockOverlap[], viewport: HudSize): string — The console block for a set of overlaps — one line per pair naming both block ids, plus the viewport they collided at. Empty string when nothing intersects.
1556
+ - `hudBlockOverlaps` (function): function hudBlockOverlaps(blocks: readonly HudBlockBounds[], options: HudBlockOverlapOptions = {}): HudBlockOverlap[] — Every pair of rendered HUD blocks that intersects. This is the development-time check behind the console report: it reads what actually hit the screen rather than what a layout intended, so a block that overflows its own box or a game override that lands on a neighbour is named too.
1557
+ - `resolveHudLanes` (function): function resolveHudLanes(slots: readonly HudLaneSlot[], viewport: HudSize, config: HudLaneConfig = {}): HudLaneLayout — Resolve lane claims into non-overlapping rectangles for one frame.
1558
+
1559
+ ## @jgengine/react/hudLanesPreview
1560
+
1561
+ - `HudLanesPreview` (function): function HudLanesPreview({ className }: { className?: string }): React.JSX.Element — The same shape Deepward's top-left HUD uses — two blocks that used to arrange themselves into each other. Both declare `lane="status"`, so the shell stacks them; the second lane sits beside the first and the right-hand lane mirrors it. Screenshots at any viewport without a block landing on a neighbour.
1562
+
1529
1563
  ## @jgengine/react/hudLayout
1530
1564
 
1531
1565
  - `HudCanvas` (function): function HudCanvas({ layout, editChord, compactScale, showDuring, className, style, children, }: { layout: HudLayoutStore; editChord?: HudEditChord | false; /** Zoom applied to the whole HUD on compact displays. Default 0.85. */ compactScale?: number; /** Opt-in play-phase gate: render the HUD only … — Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine anchor regions and stack automatically with a gap — no per-panel pixel offsets, no manual clearance for sibling panels, the touch-control dock (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the whole surface scales down and each panel applies its `compact` behavior.
1532
1566
  - `HudCompactMode` (type): type HudCompactMode = "keep" | "chip" | "hide" — How a panel behaves on compact (phone-scale) displays. `keep` stays visible at the global compact scale, `chip` collapses to a small tap-to-expand pill, `hide` unmounts entirely.
1533
1567
  - `HudEditChord` (interface): interface HudEditChord { hold: string; press: string } — ⚠ undocumented · used by `HudCanvas`: Full-viewport HUD surface.
1534
- - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** Stack … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
1568
+ - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", lane, order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
1535
1569
  - `hudVisibleInPhase` (function): function hudVisibleInPhase(showDuring: readonly GamePhase[] | undefined, phase: GamePhase): boolean — Whether a HUD element opted into `showDuring` is visible in the current phase; `undefined` = always visible (default).
1536
1570
  - `useHudLayout` (function): function useHudLayout(options?: { storageKey?: string; snap?: number; locked?: boolean; /** * Scene-document `ui` section — source of truth for panel placement/size. * When provided, hydrates the layout store (and wins over legacy localStorage). */ documentUi?: EditorUiDocument; /** * When true (def… — Layout state for `HudCanvas` — panel placements, edit-mode drag/resize, and per-game persistence.
1537
1571
 
@@ -1746,7 +1780,7 @@
1746
1780
  - `MenuNavItemProps` (interface): interface MenuNavItemProps — Props a menu item needs so {@link useMenuNav} can focus it and track pointer/focus.
1747
1781
  - `MenuNavRender` (type): type MenuNavRender = (nav: MenuNavController) => ReactNode — Render prop for a menu whose items come from {@link useMenuNav}.
1748
1782
  - `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.
1783
+ - `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
1784
 
1751
1785
  ## @jgengine/react/menuRouter
1752
1786
 
@@ -1921,16 +1955,17 @@
1921
1955
 
1922
1956
  ## @jgengine/react/settingsBlocks
1923
1957
 
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.
1958
+ - `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
1959
  - `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.
1960
+ - `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}.
1961
+ - `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
1962
  - `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.
1963
+ - `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.
1964
+ - `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
1965
  - `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
1966
  - `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}.
1967
+ - `SettingsToggle` (function): function SettingsToggle({ row, itemProps }: { row: SettingsRow; itemProps?: MenuNavItemProps }): ReactNode — One on/off row from a {@link SettingsRow}.
1968
+ - `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
1969
 
1935
1970
  ## @jgengine/react/shopGrid
1936
1971
 
@@ -3654,9 +3689,9 @@
3654
3689
  - `ProjectileTracers` (function): function ProjectileTracers({ lifeMs = 130 }: { lifeMs?: number }): React.JSX.Element — ⚠ undocumented
3655
3690
  - `Reticle` (function): function Reticle({ className }: { className?: string }): React.JSX.Element — ⚠ undocumented
3656
3691
  - `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
3692
+ - `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
3693
  - `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.
3694
+ - `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
3695
  - `WorldNameplatesProps` (interface): interface WorldNameplatesProps — Props for `WorldNameplates` — entity filter, refresh rate, and headless className/render hooks.
3661
3696
  - `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
3697
  - `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"`
@@ -343,6 +359,11 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
343
359
 
344
360
  - `HudInteractProvider` (function) · `import { HudInteractProvider } from "@jgengine/react/hudInteract"`
345
361
 
362
+ ## hud-lanes — two HUD blocks stacked in one lane, plus a second lane beside it
363
+
364
+ - `HudLanesPreview` (function) · `import { HudLanesPreview } from "@jgengine/react/hudLanesPreview"`
365
+ - `resolveHudLanes` (function) · `import { resolveHudLanes } from "@jgengine/react"`
366
+
346
367
  ## hud-layout — persistent, live-editable HUD panel layout state for `HudCanvas`
347
368
 
348
369
  - `useHudLayout` (function) · `import { useHudLayout } from "@jgengine/react"`
@@ -355,6 +376,11 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
355
376
 
356
377
  - `useHudLayoutPersist` (function) · `import { useHudLayoutPersist } from "@jgengine/react"`
357
378
 
379
+ ## hud-overlap-report — format the intersecting HUD block pairs for a development console
380
+
381
+ - `formatHudOverlapReport` (function) · `import { formatHudOverlapReport } from "@jgengine/react"`
382
+ - `hudBlockOverlaps` (function) · `import { hudBlockOverlaps } from "@jgengine/react"`
383
+
358
384
  ## hud-panel — anchored HUD slot — drop any widget in a named, player-movable panel
359
385
 
360
386
  - `HudPanel` (function) · `import { HudPanel } from "@jgengine/react"`
@@ -758,7 +784,7 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
758
784
 
759
785
  - `SettingsKeybindControl` (function) · `import { SettingsKeybindControl } from "@jgengine/react/settingsBlocks"`
760
786
 
761
- ## settings-section — settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed
787
+ ## settings-section — settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed, keyboard and gamepad navigable
762
788
 
763
789
  - `SettingsSection` (function) · `import { SettingsSection } from "@jgengine/react/settingsBlocks"`
764
790
 
@@ -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"`