@jgengine/shell 0.13.0 → 0.14.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
@@ -10,7 +10,53 @@ let consumers pick up the better stuff, not just to list what moved.
10
10
  Agents building on the published SDK can also read this programmatically:
11
11
  `import { VERSION, CHANGELOG } from "@jgengine/core/meta/changelog"` gives the
12
12
  same data as typed values, so an updater can diff its installed version against
13
- the latest and surface the migration steps.
13
+ the latest and surface the migration steps. Easier still: `npx jgengine upgrade`
14
+ run inside a game project diffs the installed `@jgengine/*` versions against the
15
+ latest release and prints every Migrate step and Adopt-worthy addition in
16
+ between (`--json` for structured output).
17
+
18
+ ## [Unreleased]
19
+
20
+ <!--
21
+ Every PR that changes `packages/*/src` records its consumer-facing change here, so
22
+ the next release's notes are complete by construction. Add a bullet under the right
23
+ subhead below. `bun run check-changelog` fails a source PR whose `[Unreleased]` block
24
+ is untouched; pure refactors/tests bypass with `[skip changelog]` in a commit message.
25
+ At publish, rename this heading to the new version and mirror the entries into
26
+ `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
+ -->
28
+
29
+ ### Migrate
30
+
31
+ - _Nothing yet._
32
+
33
+ ### Added
34
+
35
+ ### Changed
36
+
37
+ ### Removed
38
+
39
+ ## 0.14.0
40
+
41
+ ### Migrate
42
+
43
+ - **Nothing required** — this release is additive drop-in primitives, editor authoring, CLI discovery, and fixes. Bump `@jgengine/*` pins to `^0.14.0` (CLI `jgengine` is 0.11.0; `@jgengine/github` unchanged at 0.1.0) and rebuild.
44
+ - One behavior fix to know about: in a `rig: "chase"` game, on-foot movement and aim are now camera-relative (the chase rig reports its yaw to the shell like every other rig). If a game somehow relied on world-yaw-0 movement under a chase camera, that was the bug this fixes.
45
+
46
+ ### Added
47
+
48
+ - One authoring gesture for scatterable coverage: `@jgengine/core/world/scatterCoverage` owns shared density/budget semantics (per-kind unit, requested→count→capped, one clamp-and-warn phrasing) for `grass_field`/`scatter`/`city`; the editor inspector leads each kind with the same Area → Assets → Density coverage section, scatter truncates to the shared 250k instance budget, and city single-sources its 2,600-lot cap.
49
+ - **Created games ship a drive/playtest script** (#1248) — `npx jgengine create` scaffolds now include `scripts/drive.mjs` (`bun run drive`): dependency-free headless play/testing of the running game — ordered `--click`/`--key`/`--wait`/`--shot`/`--rpc` steps against the agent bridge, plus `--playtest --strict` progress/softlock verdicts off `capture.probe`. Shared Chrome/CDP machinery lives in `scripts/browser.mjs`; `scripts/shoot.mjs` is unchanged in behavior, now a thin CLI over it.
50
+ - Editor: viewport clip preview for rigged assets (Animation dock "Clips" mode — pick a rig or placed instance, play/scrub/loop/speed any catalog clip) and an Inspector "Animation" section that authors a placement's `ModelConfig.animation` (role→clip dropdowns, auto/none, walk/run/fade, one-shot event bindings) as undoable `marker.meta.animation` edits. New subpaths: `@jgengine/editor/shell/clipPreview`, `@jgengine/editor/modelAnimationAuthoring`, `@jgengine/editor/ClipPreviewLayer`.
51
+ - **`npx jgengine find <intent>`** — active capability discovery: searches every shipped domain's `capabilities.md` (staged inside the CLI tarball, so it works regardless of which `@jgengine/*` packages a project installed) and prints the drop-in primitive + its import for an intent like `"toggleable window"`, `inventory`, or `minimap`. Scaffolded games are now briefed (in `AGENTS.md` and the `jgengine` intake skill) to reach for it before hand-rolling a HUD/inventory/window/rig.
52
+ - **`EntityPreview`** (`@jgengine/shell/render/EntityPreview`) — drop-in live 3D entity portrait for character screens, unit inspectors, and loadout viewers. Owns the nested `<Canvas>`, the `GameContextBridge`, and a `StudioStage` lighting rig with optional turntable / face-camera; the game passes its own `renderEntity` as children (or a native `model` + `instanceId`) and, bound to a live entity, the portrait walks/flinches/topples in sync with the world.
53
+ - **`GameContextBridge`** (`@jgengine/react`) — re-provide the running `GameContext` across a nested React reconciler boundary (the R3F `<Canvas>`), so a game building its own preview canvas no longer re-derives the bridge by hand.
54
+
55
+ ### Changed
56
+
57
+ - **Chase rig reports its yaw to the shell** — `ChaseRig` now writes the camera yaw back to the shared yaw ref like every other player-facing rig, so on-foot movement and aim in a `rig: "chase"` game are camera-relative instead of frozen to world yaw 0 (WASD no longer fights the camera the moment it swings behind the player). Interior views (cockpit/hood/rear) report the vehicle heading.
58
+ - **Combat VFX no longer render as black squares under AO/DOF post-processing** (#1247) — GTAO/Bokeh scene prepasses skip overlay effects. Games with custom additive overlay effects opt out the same way: spread `POSTFX_OVERLAY_USERDATA` (`@jgengine/shell/postfx/postfxOverlay`) onto the overlay group's `userData`.
59
+ - **`PanelHost` windows stack above the HUD by default** (`@jgengine/react`) — the host establishes its own stacking context at `zIndexBase` (default 40), so open windows always paint over stat bars / nameplates / frames instead of bleeding through. Overridable per instance.
14
60
 
15
61
  ## 0.13.0
16
62
 
@@ -2,7 +2,8 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { CombatCameraShake, ProjectileTracers, WorldFloatText, WorldTelegraphs, } from "./world/WorldHud.js";
3
3
  import { WorldSpellVfx } from "./world/WorldVfx.js";
4
4
  import { RetainedVfx } from "./world/RetainedVfx.js";
5
+ import { POSTFX_OVERLAY_USERDATA } from "./postfx/postfxOverlay.js";
5
6
  /** Mounts the opt-in combat VFX stack inside the 3D canvas. @internal */
6
7
  export function CombatPresentation({ effects }) {
7
- return (_jsxs(_Fragment, { children: [effects.telegraphs ? _jsx(WorldTelegraphs, {}) : null, effects.vfx ? (_jsxs(_Fragment, { children: [_jsx(WorldSpellVfx, {}), _jsx(RetainedVfx, {})] })) : null, effects.floatText ? _jsx(WorldFloatText, {}) : null, effects.tracers ? _jsx(ProjectileTracers, {}) : null, effects.shake ? _jsx(CombatCameraShake, {}) : null] }));
8
+ return (_jsxs("group", { userData: POSTFX_OVERLAY_USERDATA, children: [effects.telegraphs ? _jsx(WorldTelegraphs, {}) : null, effects.vfx ? (_jsxs(_Fragment, { children: [_jsx(WorldSpellVfx, {}), _jsx(RetainedVfx, {})] })) : null, effects.floatText ? _jsx(WorldFloatText, {}) : null, effects.tracers ? _jsx(ProjectileTracers, {}) : null, effects.shake ? _jsx(CombatCameraShake, {}) : null] }));
8
9
  }
@@ -448,6 +448,7 @@ export function ChaseRig(props) {
448
448
  if (lookBack) {
449
449
  pose.position = seatPose(follow, yaw, seat, fov).position;
450
450
  }
451
+ props.yawRef.current = yaw;
451
452
  commit(pose, dt);
452
453
  return;
453
454
  }
@@ -460,6 +461,9 @@ export function ChaseRig(props) {
460
461
  anchorYaw = smoothYaw(anchorYawRef.current ?? targetYaw, targetYaw, resolved.velocityYawResponse, dt);
461
462
  anchorYawRef.current = anchorYaw;
462
463
  }
464
+ // Report camera yaw back to the shell like every other player-facing rig, so
465
+ // on-foot movement and aim stay camera-relative instead of frozen at yaw 0.
466
+ props.yawRef.current = anchorYaw;
463
467
  const led = leadFollowPoint(follow, last, dt, resolved);
464
468
  const desired = chaseDesiredPosition(led, anchorYaw, resolved);
465
469
  const prev = posRef.current ?? desired;
@@ -4,6 +4,7 @@ import { useFrame } from "@react-three/fiber";
4
4
  import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
5
  import * as THREE from "three";
6
6
  import { useGameContext } from "@jgengine/react/provider";
7
+ import { POSTFX_OVERLAY_USERDATA } from "../postfx/postfxOverlay.js";
7
8
  import { aimProbeNeeded, anyCollisionLayerOn, colliderScanNeeded, collisionDebug, projectileListenNeeded, } from "./collisionDebug.js";
8
9
  import { AIM_DAMAGE_COLOR, AIM_LASER_COLOR, AIM_MISS_COLOR, AIM_SOLID_COLOR, BODY_WIRE_COLOR, collectDebugShapes, computeAimLaser, HITBOX_WIRE_COLOR, muzzleMarkerFromOrigin, PROJECTILE_PATH_COLOR, } from "./collisionDebugMath.js";
9
10
  function useCollisionDebugState() {
@@ -15,11 +16,24 @@ function WireSphere({ center, radius, color, label, }) {
15
16
  function WireAabb({ center, halfExtents, rotationY, color, label, }) {
16
17
  return (_jsxs("group", { position: [center[0], center[1], center[2]], rotation: [0, rotationY, 0], children: [_jsxs("mesh", { children: [_jsx("boxGeometry", { args: [halfExtents[0] * 2, halfExtents[1] * 2, halfExtents[2] * 2] }), _jsx("meshBasicMaterial", { color: color, wireframe: true, transparent: true, opacity: 0.85, depthTest: false })] }), _jsx(Html, { position: [0, halfExtents[1] + 0.15, 0], center: true, distanceFactor: 14, zIndexRange: [25, 0], style: { pointerEvents: "none" }, children: _jsx("span", { className: "rounded bg-black/70 px-1 text-[9px] font-medium text-white/90", children: label }) })] }));
17
18
  }
19
+ function WireTriangleMesh({ entry, shape, color, }) {
20
+ const geometry = useMemo(() => {
21
+ const built = new THREE.BufferGeometry();
22
+ built.setAttribute("position", new THREE.BufferAttribute(shape.mesh.positions, 3));
23
+ built.setIndex(new THREE.BufferAttribute(shape.mesh.indices, 1));
24
+ return built;
25
+ }, [shape.mesh]);
26
+ useEffect(() => () => geometry.dispose(), [geometry]);
27
+ return (_jsxs("group", { children: [_jsx("group", { position: [entry.position[0], entry.position[1], entry.position[2]], rotation: [0, entry.rotationY, 0], children: _jsx("group", { position: [shape.meshTranslate[0], shape.meshTranslate[1], shape.meshTranslate[2]], scale: shape.meshScale, children: _jsx("mesh", { geometry: geometry, children: _jsx("meshBasicMaterial", { color: color, wireframe: true, transparent: true, opacity: 0.85, depthTest: false }) }) }) }), _jsx(Html, { position: [shape.center[0], shape.center[1] + shape.halfExtents[1] + 0.15, shape.center[2]], center: true, distanceFactor: 14, zIndexRange: [25, 0], style: { pointerEvents: "none" }, children: _jsx("span", { className: "rounded bg-black/70 px-1 text-[9px] font-medium text-white/90", children: entry.label }) })] }));
28
+ }
18
29
  function ColliderShapeMesh({ entry }) {
19
30
  const color = entry.style === "hitbox" ? HITBOX_WIRE_COLOR : BODY_WIRE_COLOR;
20
31
  if (entry.shape.kind === "sphere") {
21
32
  return (_jsx(WireSphere, { center: entry.shape.center, radius: entry.shape.radius, color: color, label: entry.label }));
22
33
  }
34
+ if (entry.shape.kind === "mesh") {
35
+ return _jsx(WireTriangleMesh, { entry: entry, shape: entry.shape, color: color });
36
+ }
23
37
  return (_jsx(WireAabb, { center: entry.shape.center, halfExtents: entry.shape.halfExtents, rotationY: entry.rotationY, color: color, label: entry.label }));
24
38
  }
25
39
  function endpointColor(kind) {
@@ -174,7 +188,7 @@ export function CollisionDebugWorld() {
174
188
  }, [state.layers.muzzles, state.layers.aimLaser, state.projectileTraces, laser]);
175
189
  if (!anyCollisionLayerOn(state.layers))
176
190
  return null;
177
- return (_jsxs("group", { children: [shapes.map((entry) => (_jsx(ColliderShapeMesh, { entry: entry }, entry.key))), projectilePoints.map((path) => (_jsx(Line, { points: path.points, color: PROJECTILE_PATH_COLOR, lineWidth: 2, transparent: true, opacity: 0.9, depthTest: false }, path.id))), muzzleOrigins.map((entry) => (_jsx(MuzzleMark, { origin: entry.origin }, `muzzle-${entry.id}`))), laser !== null ? (_jsxs(_Fragment, { children: [_jsx(Line, { points: [
191
+ return (_jsxs("group", { userData: POSTFX_OVERLAY_USERDATA, children: [shapes.map((entry) => (_jsx(ColliderShapeMesh, { entry: entry }, entry.key))), projectilePoints.map((path) => (_jsx(Line, { points: path.points, color: PROJECTILE_PATH_COLOR, lineWidth: 2, transparent: true, opacity: 0.9, depthTest: false }, path.id))), muzzleOrigins.map((entry) => (_jsx(MuzzleMark, { origin: entry.origin }, `muzzle-${entry.id}`))), laser !== null ? (_jsxs(_Fragment, { children: [_jsx(Line, { points: [
178
192
  new THREE.Vector3(laser.origin[0], laser.origin[1], laser.origin[2]),
179
193
  new THREE.Vector3(laser.end[0], laser.end[1], laser.end[2]),
180
194
  ], color: AIM_LASER_COLOR, lineWidth: 1.5, transparent: true, opacity: 0.95, depthTest: false }), _jsx(AimEndpointMark, { point: laser.end, kind: laser.kind })] })) : null] }));
@@ -1,5 +1,6 @@
1
1
  import type { EntityPosition } from "@jgengine/core/scene/entityStore";
2
2
  import { type ColliderPurpose, type EntityColliderSet, type ResolvedCollider } from "@jgengine/core/scene/colliders";
3
+ import type { PreparedCollisionMesh } from "@jgengine/core/scene/collisionMesh";
3
4
  import { type SceneRaycastApi, type SceneRaycastHit } from "@jgengine/core/scene/sceneRaycast";
4
5
  import { type ShotOriginPolicy } from "@jgengine/core/combat/shotOrigin";
5
6
  import type { Aim } from "@jgengine/core/scene/spatial";
@@ -24,6 +25,13 @@ export interface DebugShapeEntry {
24
25
  kind: "aabb";
25
26
  halfExtents: EntityPosition;
26
27
  center: EntityPosition;
28
+ } | {
29
+ kind: "mesh";
30
+ mesh: PreparedCollisionMesh;
31
+ meshScale: number;
32
+ meshTranslate: EntityPosition;
33
+ halfExtents: EntityPosition;
34
+ center: EntityPosition;
27
35
  };
28
36
  label: string;
29
37
  style: "hitbox" | "body";
@@ -36,11 +36,20 @@ function pushResolved(out, targetKind, instanceId, catalogId, position, rotation
36
36
  const center = shapeWorldCenter(collider, position, rotationY);
37
37
  const shape = collider.shape.kind === "sphere"
38
38
  ? { kind: "sphere", radius: collider.shape.radius, center }
39
- : {
40
- kind: "aabb",
41
- halfExtents: collider.shape.halfExtents,
42
- center,
43
- };
39
+ : collider.shape.kind === "mesh"
40
+ ? {
41
+ kind: "mesh",
42
+ mesh: collider.shape.mesh,
43
+ meshScale: collider.shape.meshScale,
44
+ meshTranslate: collider.shape.meshTranslate,
45
+ halfExtents: collider.shape.halfExtents,
46
+ center,
47
+ }
48
+ : {
49
+ kind: "aabb",
50
+ halfExtents: collider.shape.halfExtents,
51
+ center,
52
+ };
44
53
  out.push({
45
54
  key: `${targetKind}:${instanceId}:${collider.purpose}:${collider.name}`,
46
55
  targetKind,
@@ -6,6 +6,31 @@ import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
6
6
  import { GTAOPass } from "three/examples/jsm/postprocessing/GTAOPass.js";
7
7
  import { BokehPass } from "three/examples/jsm/postprocessing/BokehPass.js";
8
8
  import { createGradePass } from "./gradeShader.js";
9
+ import { hidePostfxOverlays, restorePostfxOverlays } from "./postfxOverlay.js";
10
+ /**
11
+ * GTAO renders the scene with an `overrideMaterial` normal/depth prepass, which stamps
12
+ * transparent overlay quads (additive VFX sprites, telegraphs, debug gizmos) into the AO
13
+ * buffers as opaque squares — the AO composite then multiplies them toward black. Hide
14
+ * marked overlay subtrees for the whole pass: the beauty buffer it composites over was
15
+ * already rendered with them in.
16
+ */
17
+ class OverlayAwareGTAOPass extends GTAOPass {
18
+ hiddenOverlays = [];
19
+ render(renderer, writeBuffer, readBuffer, deltaTime, maskActive) {
20
+ hidePostfxOverlays(this.scene, this.hiddenOverlays);
21
+ super.render(renderer, writeBuffer, readBuffer, deltaTime, maskActive);
22
+ restorePostfxOverlays(this.hiddenOverlays);
23
+ }
24
+ }
25
+ /** DOF's depth prepass has the same `overrideMaterial` overlay-stamping problem as GTAO. */
26
+ class OverlayAwareBokehPass extends BokehPass {
27
+ hiddenOverlays = [];
28
+ render(renderer, writeBuffer, readBuffer, deltaTime, maskActive) {
29
+ hidePostfxOverlays(this.scene, this.hiddenOverlays);
30
+ super.render(renderer, writeBuffer, readBuffer, deltaTime, maskActive);
31
+ restorePostfxOverlays(this.hiddenOverlays);
32
+ }
33
+ }
9
34
  const TONE_MAPPING = {
10
35
  aces: THREE.ACESFilmicToneMapping,
11
36
  agx: THREE.AgXToneMapping,
@@ -60,7 +85,7 @@ export function PostProcessing({ config, quality = "high" }) {
60
85
  composer.addPass(new RenderPass(scene, camera));
61
86
  const heavyPasses = quality === "high";
62
87
  if (heavyPasses && config.ao !== undefined && config.ao !== false) {
63
- const ao = new GTAOPass(scene, camera, width, height);
88
+ const ao = new OverlayAwareGTAOPass(scene, camera, width, height);
64
89
  ao.blendIntensity = config.ao.blend ?? 1;
65
90
  ao.updateGtaoMaterial({
66
91
  radius: config.ao.radius ?? 1.8,
@@ -78,7 +103,7 @@ export function PostProcessing({ config, quality = "high" }) {
78
103
  let dof = null;
79
104
  if (heavyPasses && config.dof !== undefined && config.dof !== false) {
80
105
  const d = config.dof;
81
- dof = new BokehPass(scene, camera, {
106
+ dof = new OverlayAwareBokehPass(scene, camera, {
82
107
  focus: d.focus ?? 18,
83
108
  aperture: d.aperture ?? 0.00025,
84
109
  maxblur: d.maxBlur ?? 0.01,
@@ -0,0 +1,24 @@
1
+ import type * as THREE from "three";
2
+ /**
3
+ * Spread onto a group's `userData` to mark its subtree as a screen/world overlay effect —
4
+ * additive glows, telegraphs, tracers, debug gizmos — that post-processing scene prepasses
5
+ * (GTAO's normal/depth render, DOF's depth render) must skip. Those prepasses render the
6
+ * scene with `overrideMaterial`, which turns transparent overlay quads into opaque stamps
7
+ * in the AO/DOF buffers (visible as black squares over hit sparks). The shell marks its own
8
+ * combat VFX and collision-debug mounts; games mark custom overlay effects the same way.
9
+ */
10
+ export declare const POSTFX_OVERLAY_USERDATA: {
11
+ readonly jgPostfxOverlay: true;
12
+ };
13
+ /** True when the object opted out of postfx scene prepasses via {@link POSTFX_OVERLAY_USERDATA}. */
14
+ export declare function isPostfxOverlay(object: {
15
+ userData?: Record<string, unknown>;
16
+ }): boolean;
17
+ /**
18
+ * Hide every currently visible marked object under `scene`, recording what was hidden into
19
+ * `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can
20
+ * undo exactly that set.
21
+ */
22
+ export declare function hidePostfxOverlays(scene: THREE.Object3D, out: THREE.Object3D[]): void;
23
+ /** Restore visibility for the objects hidden by {@link hidePostfxOverlays} and clear the list. */
24
+ export declare function restorePostfxOverlays(hidden: THREE.Object3D[]): void;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Spread onto a group's `userData` to mark its subtree as a screen/world overlay effect —
3
+ * additive glows, telegraphs, tracers, debug gizmos — that post-processing scene prepasses
4
+ * (GTAO's normal/depth render, DOF's depth render) must skip. Those prepasses render the
5
+ * scene with `overrideMaterial`, which turns transparent overlay quads into opaque stamps
6
+ * in the AO/DOF buffers (visible as black squares over hit sparks). The shell marks its own
7
+ * combat VFX and collision-debug mounts; games mark custom overlay effects the same way.
8
+ */
9
+ export const POSTFX_OVERLAY_USERDATA = {
10
+ jgPostfxOverlay: true,
11
+ };
12
+ /** True when the object opted out of postfx scene prepasses via {@link POSTFX_OVERLAY_USERDATA}. */
13
+ export function isPostfxOverlay(object) {
14
+ return object.userData?.jgPostfxOverlay === true;
15
+ }
16
+ /**
17
+ * Hide every currently visible marked object under `scene`, recording what was hidden into
18
+ * `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can
19
+ * undo exactly that set.
20
+ */
21
+ export function hidePostfxOverlays(scene, out) {
22
+ out.length = 0;
23
+ scene.traverse((object) => {
24
+ if (object.visible && isPostfxOverlay(object)) {
25
+ object.visible = false;
26
+ out.push(object);
27
+ }
28
+ });
29
+ }
30
+ /** Restore visibility for the objects hidden by {@link hidePostfxOverlays} and clear the list. */
31
+ export function restorePostfxOverlays(hidden) {
32
+ for (const object of hidden)
33
+ object.visible = true;
34
+ hidden.length = 0;
35
+ }
@@ -0,0 +1,53 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import type { ModelConfig } from "@jgengine/core/game/playableGame";
3
+ import { type StudioMood } from "../scene/StudioStage.js";
4
+ /** Props for {@link EntityPreview}. */
5
+ export interface EntityPreviewProps {
6
+ /**
7
+ * The rendered entity — the game's *own* `renderEntity` content (e.g. its procedural body component),
8
+ * which may call `useEntityRenderCues(id)` / `useGameContext()` internally to mirror the live game.
9
+ * Takes precedence over `model`. Omit both to render an empty lit stage.
10
+ */
11
+ children?: ReactNode;
12
+ /**
13
+ * Convenience for engine-native models: render this {@link ModelConfig} via `IsolatedEntityModel`.
14
+ * Pair with `instanceId` to bind it to a live entity so the preview walks / flinches / topples in
15
+ * sync with the world. Ignored when `children` is supplied.
16
+ */
17
+ model?: ModelConfig;
18
+ /** Live entity id driving motion cues for `model` (also lets a custom child resolve the same entity). */
19
+ instanceId?: string;
20
+ /** Lighting mood for the stage. Default `"studio"`. */
21
+ mood?: StudioMood;
22
+ /** Override the backdrop color (hex). Default: the mood's backdrop. */
23
+ backdrop?: string;
24
+ /** Turntable spin (rad/s). Default 0 — a still portrait that mirrors the live entity's own motion. */
25
+ turntable?: number;
26
+ /** Yaw the subject to face the camera each frame instead of spinning. Overrides `turntable`. */
27
+ faceCamera?: boolean;
28
+ /** The subject's declared forward axis for `faceCamera`. Default `[0, 0, 1]`. */
29
+ forward?: readonly [number, number, number];
30
+ /** Draw the backdrop + floor (true) or keep a transparent stage as a pure lighting rig (false). Default true. */
31
+ environment?: boolean;
32
+ /** Camera position framing the subject. Default `[0, 1, 3]`. */
33
+ cameraPosition?: [number, number, number];
34
+ /** Camera field of view. Default 35. */
35
+ fov?: number;
36
+ /** Device pixel ratio clamp. Default `[1, 2]`. */
37
+ dpr?: number | [number, number];
38
+ className?: string;
39
+ style?: CSSProperties;
40
+ }
41
+ /**
42
+ * Drop-in live 3D preview of a single entity — the character-screen portrait, unit inspector, or loadout
43
+ * viewer. It owns all the plumbing a game would otherwise re-derive by hand: a nested `<Canvas>`, the
44
+ * {@link GameContextBridge} that carries the running `GameContext` across the R3F reconciler boundary (so
45
+ * the subject's `useEntityRenderCues` / `useGameContext` hooks resolve and it mirrors the live game), a
46
+ * {@link StudioStage} 3-point lighting rig with optional turntable / face-camera, and a capture-friendly
47
+ * GL surface. The game keeps ownership of its `renderEntity`: pass it as `children`, or hand a native
48
+ * `model` (+ `instanceId`) for an engine-composed body. Bound to a live `instanceId`, the portrait walks,
49
+ * flinches, and topples in lockstep with the world instead of a canned spin.
50
+ *
51
+ * @capability entity-preview live 3D entity portrait — nested Canvas + context bridge + studio stage, driven by the game's own renderEntity
52
+ */
53
+ export declare function EntityPreview({ children, model, instanceId, mood, backdrop, turntable, faceCamera, forward, environment, cameraPosition, fov, dpr, className, style, }: EntityPreviewProps): import("react").JSX.Element;
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Canvas } from "@react-three/fiber";
3
+ import { GameContextBridge, useOptionalGameContext } from "@jgengine/react/provider";
4
+ import { StudioStage } from "../scene/StudioStage.js";
5
+ import { IsolatedEntityModel } from "./SceneModels.js";
6
+ /**
7
+ * Drop-in live 3D preview of a single entity — the character-screen portrait, unit inspector, or loadout
8
+ * viewer. It owns all the plumbing a game would otherwise re-derive by hand: a nested `<Canvas>`, the
9
+ * {@link GameContextBridge} that carries the running `GameContext` across the R3F reconciler boundary (so
10
+ * the subject's `useEntityRenderCues` / `useGameContext` hooks resolve and it mirrors the live game), a
11
+ * {@link StudioStage} 3-point lighting rig with optional turntable / face-camera, and a capture-friendly
12
+ * GL surface. The game keeps ownership of its `renderEntity`: pass it as `children`, or hand a native
13
+ * `model` (+ `instanceId`) for an engine-composed body. Bound to a live `instanceId`, the portrait walks,
14
+ * flinches, and topples in lockstep with the world instead of a canned spin.
15
+ *
16
+ * @capability entity-preview live 3D entity portrait — nested Canvas + context bridge + studio stage, driven by the game's own renderEntity
17
+ */
18
+ export function EntityPreview({ children, model, instanceId, mood = "studio", backdrop, turntable = 0, faceCamera = false, forward, environment = true, cameraPosition = [0, 1, 3], fov = 35, dpr = [1, 2], className, style, }) {
19
+ const ctx = useOptionalGameContext();
20
+ const subject = children ??
21
+ (model !== undefined ? _jsx(IsolatedEntityModel, { model: model, instanceId: instanceId }) : null);
22
+ return (_jsx("div", { "data-jg-entity-preview": "", className: className, style: { position: "relative", width: "100%", height: "100%", ...style }, children: _jsx(Canvas, { shadows: true, dpr: dpr, camera: { position: cameraPosition, fov }, gl: { preserveDrawingBuffer: true }, style: { touchAction: "none" }, children: _jsx(GameContextBridge, { context: ctx, children: _jsx(StudioStage, { mood: mood, ...(backdrop === undefined ? {} : { backdrop }), turntable: turntable, faceCamera: faceCamera, ...(forward === undefined ? {} : { forward }), environment: environment, children: subject }) }) }) }));
23
+ }
@@ -6,6 +6,7 @@ import * as THREE from "three";
6
6
  import { useGameContext } from "@jgengine/react/provider";
7
7
  import { sharedGltfLoader } from "./modelLoad.js";
8
8
  import { measureLocalBounds, reportMeasuredBounds } from "./measureBounds.js";
9
+ import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "./measureCollisionMesh.js";
9
10
  import { useModelAnimation } from "./useModelAnimation.js";
10
11
  import { PartMotionRig } from "./PartMotion.js";
11
12
  import { applyMaterialOverride } from "../materialOverride.js";
@@ -140,6 +141,18 @@ export function EntityModel({ model, instanceId, measure, }) {
140
141
  meshCount: raw.meshCount,
141
142
  });
142
143
  }, [ctx, scene, scale, positionX, positionY, positionZ, measureTarget, measureKey, dimsMaxY]);
144
+ // Runs with or without index dims: the loaded geometry upgrades whatever box the kind resolved
145
+ // (fitted or measured) to a hitbox that raycasts the model's own triangles.
146
+ useEffect(() => {
147
+ if (measureTarget === undefined || measureKey === undefined)
148
+ return;
149
+ const triangles = measureLocalCollisionTriangles(scene, {
150
+ scale,
151
+ offset: [positionX, positionY, positionZ],
152
+ });
153
+ if (triangles !== null)
154
+ reportMeasuredCollisionMesh(ctx, measureTarget, measureKey, triangles);
155
+ }, [ctx, scene, scale, positionX, positionY, positionZ, measureTarget, measureKey]);
143
156
  useModelAnimation(scene, gltf.animations, model.animation, instanceId);
144
157
  const paintCanvasRef = useRef(null);
145
158
  const paintDrawnCountRef = useRef(0);
@@ -3,6 +3,7 @@ import { useFrame } from "@react-three/fiber";
3
3
  import { useRef } from "react";
4
4
  import * as THREE from "three";
5
5
  import { useGameContext } from "@jgengine/react/provider";
6
+ import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "./measureCollisionMesh.js";
6
7
  /** Subtrees flagged with this userData key are excluded from bounds measurement — debug gizmos,
7
8
  * selection quads, effect billboards that should not inflate the hitbox. Sprites and invisible
8
9
  * subtrees are always excluded. */
@@ -122,6 +123,9 @@ export function MeasuredBoundsGroup({ target, measureKey, children, }) {
122
123
  return;
123
124
  lastMeshCountRef.current = measured.meshCount;
124
125
  reportMeasuredBounds(ctx, target, measureKey, measured);
126
+ const triangles = measureLocalCollisionTriangles(group);
127
+ if (triangles !== null)
128
+ reportMeasuredCollisionMesh(ctx, target, measureKey, triangles);
125
129
  });
126
130
  return _jsx("group", { ref: groupRef, children: children });
127
131
  }
@@ -0,0 +1,26 @@
1
+ import * as THREE from "three";
2
+ import type { CollisionMeshSource } from "@jgengine/core/scene/collisionMesh";
3
+ import type { GameContext } from "@jgengine/core/runtime/gameContext";
4
+ /** Entity-local triangle soup of the meshes under a measured root, plus the contributing mesh count
5
+ * (same async fill-in detection as measured bounds). Satisfies {@link CollisionMeshSource}. */
6
+ export interface MeasuredCollisionTriangles extends CollisionMeshSource {
7
+ positions: Float32Array;
8
+ indices: Uint32Array;
9
+ triangleCount: number;
10
+ meshCount: number;
11
+ }
12
+ /**
13
+ * Collect the triangle soup of the meshes under `root` in `root`'s own frame — the mesh-accurate
14
+ * counterpart to `measureLocalBounds`, with the same visibility/sprite/{@link MEASURE_EXCLUDE_KEY}
15
+ * exclusions (skinned meshes contribute their bind pose). An optional uniform `scale` + `offset`
16
+ * maps the result into entity-local space when the caller mounts the root scaled/translated (the
17
+ * model-primitive path). Returns `null` when nothing is measurable or the soup exceeds the
18
+ * triangle budget — callers keep their box fallback.
19
+ */
20
+ export declare function measureLocalCollisionTriangles(root: THREE.Object3D, transform?: {
21
+ scale: number;
22
+ offset: readonly [number, number, number];
23
+ }): MeasuredCollisionTriangles | null;
24
+ /** Forward measured triangles into the context (entity kind or object catalog id), deduping repeat
25
+ * reports of the same soup so remounts and multi-instance kinds don't rebuild the BVH. */
26
+ export declare function reportMeasuredCollisionMesh(ctx: GameContext, target: "entity" | "object", key: string, triangles: MeasuredCollisionTriangles): void;
@@ -0,0 +1,108 @@
1
+ import * as THREE from "three";
2
+ import { MEASURE_EXCLUDE_KEY } from "./measureBounds.js";
3
+ /** Above this the model is too dense to double as its own hitbox — keep the fitted box instead of
4
+ * paying BVH build + per-ray traversal on a hero-detail mesh. Game-ready low-poly assets sit far under it. */
5
+ const MAX_COLLISION_TRIANGLES = 60_000;
6
+ const IDENTITY = new THREE.Matrix4();
7
+ const tmpVec = new THREE.Vector3();
8
+ function collectTriangles(object, parentMatrix, sink) {
9
+ if (sink.overflow)
10
+ return;
11
+ if (parentMatrix !== null) {
12
+ if (!object.visible)
13
+ return;
14
+ if (object.isSprite === true)
15
+ return;
16
+ if (object.userData[MEASURE_EXCLUDE_KEY] === true)
17
+ return;
18
+ }
19
+ let matrix = null;
20
+ if (parentMatrix !== null) {
21
+ if (object.matrixAutoUpdate)
22
+ object.updateMatrix();
23
+ matrix = new THREE.Matrix4().multiplyMatrices(parentMatrix, object.matrix);
24
+ }
25
+ const mesh = object;
26
+ if (mesh.isMesh === true && mesh.geometry !== undefined) {
27
+ const geometry = mesh.geometry;
28
+ const positionAttribute = geometry.getAttribute("position");
29
+ if (positionAttribute !== undefined && positionAttribute.itemSize === 3 && positionAttribute.count >= 3) {
30
+ const base = sink.positions.length / 3;
31
+ const applied = matrix ?? IDENTITY;
32
+ for (let v = 0; v < positionAttribute.count; v += 1) {
33
+ tmpVec.fromBufferAttribute(positionAttribute, v).applyMatrix4(applied);
34
+ sink.positions.push(tmpVec.x, tmpVec.y, tmpVec.z);
35
+ }
36
+ const index = geometry.getIndex();
37
+ const triangleCount = index !== null ? Math.floor(index.count / 3) : Math.floor(positionAttribute.count / 3);
38
+ if (sink.triangleCount + triangleCount > MAX_COLLISION_TRIANGLES) {
39
+ sink.overflow = true;
40
+ return;
41
+ }
42
+ if (index !== null) {
43
+ for (let i = 0; i + 2 < index.count; i += 3) {
44
+ sink.indices.push(base + index.getX(i), base + index.getX(i + 1), base + index.getX(i + 2));
45
+ }
46
+ }
47
+ else {
48
+ for (let i = 0; i + 2 < positionAttribute.count; i += 3) {
49
+ sink.indices.push(base + i, base + i + 1, base + i + 2);
50
+ }
51
+ }
52
+ sink.triangleCount += triangleCount;
53
+ sink.meshCount += 1;
54
+ }
55
+ }
56
+ const childMatrix = matrix ?? IDENTITY;
57
+ for (const child of object.children)
58
+ collectTriangles(child, childMatrix, sink);
59
+ }
60
+ /**
61
+ * Collect the triangle soup of the meshes under `root` in `root`'s own frame — the mesh-accurate
62
+ * counterpart to `measureLocalBounds`, with the same visibility/sprite/{@link MEASURE_EXCLUDE_KEY}
63
+ * exclusions (skinned meshes contribute their bind pose). An optional uniform `scale` + `offset`
64
+ * maps the result into entity-local space when the caller mounts the root scaled/translated (the
65
+ * model-primitive path). Returns `null` when nothing is measurable or the soup exceeds the
66
+ * triangle budget — callers keep their box fallback.
67
+ */
68
+ export function measureLocalCollisionTriangles(root, transform) {
69
+ const sink = { positions: [], indices: [], meshCount: 0, triangleCount: 0, overflow: false };
70
+ collectTriangles(root, null, sink);
71
+ if (sink.overflow || sink.meshCount === 0 || sink.triangleCount === 0)
72
+ return null;
73
+ const positions = new Float32Array(sink.positions);
74
+ if (transform !== undefined) {
75
+ const { scale, offset } = transform;
76
+ for (let v = 0; v < positions.length; v += 3) {
77
+ positions[v] = positions[v] * scale + offset[0];
78
+ positions[v + 1] = positions[v + 1] * scale + offset[1];
79
+ positions[v + 2] = positions[v + 2] * scale + offset[2];
80
+ }
81
+ }
82
+ return {
83
+ positions,
84
+ indices: new Uint32Array(sink.indices),
85
+ triangleCount: sink.triangleCount,
86
+ meshCount: sink.meshCount,
87
+ };
88
+ }
89
+ const reportedByContext = new WeakMap();
90
+ /** Forward measured triangles into the context (entity kind or object catalog id), deduping repeat
91
+ * reports of the same soup so remounts and multi-instance kinds don't rebuild the BVH. */
92
+ export function reportMeasuredCollisionMesh(ctx, target, key, triangles) {
93
+ let map = reportedByContext.get(ctx);
94
+ if (map === undefined) {
95
+ map = new Map();
96
+ reportedByContext.set(ctx, map);
97
+ }
98
+ const registryKey = `${target}:${key}`;
99
+ const prev = map.get(registryKey);
100
+ if (prev !== undefined && prev.meshCount === triangles.meshCount && prev.triangleCount === triangles.triangleCount) {
101
+ return;
102
+ }
103
+ const accepted = target === "entity"
104
+ ? ctx.scene.entity.reportCollisionMesh(key, triangles)
105
+ : ctx.scene.object.reportCollisionMesh(key, triangles);
106
+ if (accepted)
107
+ map.set(registryKey, { meshCount: triangles.meshCount, triangleCount: triangles.triangleCount });
108
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/shell",
3
- "version": "0.13.0",
3
+ "version": "0.14.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.13.0",
39
- "@jgengine/react": "^0.13.0",
40
- "@jgengine/ws": "^0.13.0"
38
+ "@jgengine/core": "^0.14.0",
39
+ "@jgengine/react": "^0.14.0",
40
+ "@jgengine/ws": "^0.14.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@react-three/drei": "^10.0.0",
@@ -5,7 +5,7 @@ description: Route game work to the smallest useful JGengine guidance set.
5
5
 
6
6
  # JGengine intake and router
7
7
 
8
- Read the repository [README](../../../README.md) for package truth, commands, layering, and license. This skill decides what to load; it is not an engine manual.
8
+ Read the JGengine README for package truth, commands, layering, and license — the repo-root [`README.md`](../../../README.md) in the monorepo, or [jgengine.com](https://jgengine.com) from a scaffolded consumer project (where the relative link does not resolve). This skill decides what to load; it is not an engine manual.
9
9
 
10
10
  ## 1. Establish the target
11
11
 
@@ -20,6 +20,8 @@ Treat the pitch as a unique composition of needs, not a genre to fill in — nev
20
20
 
21
21
  For a new in-repo game, scaffold with `bun run new:game "<id-or-title>"` (thin script alias for `jgengine create`, auto-detects the in-repo variant). Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
22
22
 
23
+ For an existing game asked to pick up a new engine release ("jgengine published a new version, grab it"), run `npx jgengine upgrade` in the project first: it diffs installed `@jgengine/*` versions against the latest release and prints every Migrate step and Adopt-worthy addition in between (`--json` for structured output). Bump, migrate oldest-first, then work the Adopt lists instead of reconstructing the changelog by hand.
24
+
23
25
  ## 2. Select domains
24
26
 
25
27
  Load only rows the target needs. Do not preload every domain.
@@ -43,7 +45,7 @@ World content routes editor-first: any request that adds, moves, restyles, or re
43
45
  Within each selected domain:
44
46
 
45
47
  0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) for `combat-loop`, `boss-telegraph`, `loot`, `quest`, `coop-presence`, `third-person-camera`; `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
46
- 1. Search `capabilities.md` by intent; it maps needs to imports.
48
+ 1. Search `capabilities.md` by intent; it maps needs to imports. Outside the monorepo (or before a domain skill is even loaded), `npx jgengine find <intent>` greps every shipped capability index at once and prints the primitive + import — reach for it the moment you're tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. If you're writing a `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob, a shipped drop-in almost certainly exists (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
47
49
  2. Open `api.md` only when exact signatures or export inventory are needed.
48
50
  3. Open the linked reference only for a deeper recipe or trap.
49
51
  4. For how several primitives wire into a running loop, read the domain's `recipes/` — connected, genre-free walkthroughs organized by composition seam; this skill's own [recipes/minimal-game.md](recipes/minimal-game.md) is the default whole-game walkthrough. Prefer a recipe over reading a game's source; the games under `Games/*` are not templates or references, and a hybrid game is just a different composition of the same primitives.
@@ -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.13.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.14.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -220,7 +220,7 @@
220
220
 
221
221
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
222
222
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
223
- - `VERSION` (const): const VERSION: "0.13.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
223
+ - `VERSION` (const): const VERSION: "0.14.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
224
224
 
225
225
  ## @jgengine/core/runtime/adapter
226
226
 
@@ -16,7 +16,7 @@ Search [capabilities.md](capabilities.md), use [api.md](api.md) for signatures,
16
16
  1. Write the game's UI art direction first (see [reference.md](reference.md) §1) — fantasy, shape/material language, hierarchy, forbidden patterns. Stock glass widgets are not a stand-in for this step.
17
17
  2. Define the real UI states: attract, main menu (the game's front-end), character create/select where relevant, live play, pause, settings, credits, results, empty/error, and relevant overlays.
18
18
  3. Read state through selectors/hooks; keep simulation mutation behind commands.
19
- 4. Compose this game's UI, reaching first for the shipped building blocks — `InventoryGrid`, `usePanels`/`PanelHost`/`Window`, `CharacterSheet`/`Paperdoll`, and the action/selection bars — and composing + reskinning them; only hand-roll custom markup where no building block fits. Toggleable windows (bag `B`, character `C`, etc.) come from `usePanels`, not hand-rolled open/close state. Prefer headless data + game-owned chrome. When a second presentation is plausible, split data / renderer / chrome (minimap stack example: `useLiveMarkers` → track/renderer → game frame; see reference §6).
19
+ 4. Compose this game's UI, reaching first for the shipped building blocks — `InventoryGrid`, `usePanels`/`PanelHost`/`Window`, `CharacterSheet`/`Paperdoll`, and the action/selection bars — and composing + reskinning them; only hand-roll custom markup where no building block fits. Toggleable windows (bag `B`, character `C`, etc.) come from `usePanels`, not hand-rolled open/close state — and `PanelHost` already stacks windows above the HUD (`zIndexBase`) and wires hotkeys/ESC, so never hand-roll a `z-*` or keydown listener for them. A live 3D character-screen portrait is `EntityPreview` (`@jgengine/shell/render/EntityPreview`): it owns the nested `<Canvas>`, the `GameContextBridge` across the R3F boundary, and the `StudioStage` lighting — pass the game's own `renderEntity` as children (or a native `model`+`instanceId`) and it mirrors the live entity; don't re-derive the context bridge by hand. Prefer headless data + game-owned chrome. When a second presentation is plausible, split data / renderer / chrome (minimap stack example: `useLiveMarkers` → track/renderer → game frame; see reference §6).
20
20
  5. Make keyboard, pointer, touch, controller, focus, and screen-reader behavior explicit.
21
21
  6. Add preview fixtures using the real components for fast deterministic capture.
22
22
  7. Verify desktop and mobile layouts through `jgengine-verify`. A HUD that could pass for another game's default chrome fails visual review.
@@ -31,6 +31,8 @@ Existing React games keep their entity store and use the focused
31
31
  - **The main menu is a game front-end, not marketing.** The runner loads straight into the game's own menu; it offers the real front-end verbs the game supports — New Game, Continue/Load (save slots), Create/Choose character or class, Settings, Credits — each an authored screen in the game's art direction, not a landing page or raw scaffold. See [reference.md](reference.md) §"Main menu".
32
32
  - **Settings is game-owned and reachable from a sensible place.** Every game ships settings as *its* settings — skinned and placed inline with its UI (main menu and pause), never the engine's stock face floated into a corner. Credits are a reachable in-game screen, not attribution buried in a build artifact.
33
33
  - Engine packages ship both headless seams *and* good drop-in building blocks. The seams are layout (`HudCanvas`/`HudPanel`), data hooks, interaction models, and tokens; the building blocks are the parts almost every game needs — inventory grids (`InventoryGrid`), toggleable windows (`usePanels`/`PanelHost`/`Window`), character sheets (`CharacterSheet`/`Paperdoll`), stat/vitals bars, and action/selection bars. Composing and reskinning these is using the engine correctly, not out of policy. What stays out of scope is a whole finished game *face* or a genre theme preset shipped as the product (see [AGENTS.md](../../../AGENTS.md)).
34
+ - **Skin the shared chrome with one `HudTheme` token object.** `hudThemeVars(theme)` emits the `--jg-*` custom properties that the atomic bars, `HudFrame variation="themed"`, painted slots, and the minimap ring all read, so one theme spread on any HUD ancestor reskins the whole subtree — palette, frame material, corner radius, glow, and slot chrome. Author your own theme (tower-guard's `fieldkitVars` is the model); `HUD_THEME_PRESETS` / `resolveHudTheme("<preset>")` are demo/scaffold starting points, never a game's identity. Caller `style`/`renderItem` overrides still win over the theme default.
35
+ - **Paint slot icons with `IconTreatment`, never render raw `itemId` strings.** It draws a `GameIcon` glyph (or any node) over a school/element-keyed radial gradient with a gloss + vignette and optional count/keycap badges, and reads the `--jg-slot-*` / `--jg-accent` `HudTheme` tokens so a theme change reskins every treated icon. `schoolForItem`/`schoolForAction` infer the gradient from an id; `treatedItemIcon(itemId)` / `treatedActionIcon(action)` are ready adapters. Use it in hotbars and ability bars so slots read as painted icons, not debug text.
34
36
  - Never reach for a genre HUD kit, theme preset, or "default RPG/FPS chrome" as the game's *identity*. Composing the shared building blocks (bag, character, spellbook, action-bar windows) is fine and expected — it's a prefab genre *look/identity* dropped in unchanged that's discouraged. Build the look this pitch needs on top of them.
35
37
  - Layout and skin remain caller-controlled; shared primitives own reusable behavior, not product look.
36
38
  - SSR-visible output is hydration-stable; round computed SVG values at the boundary.
@@ -41,3 +43,4 @@ Existing React games keep their entity store and use the focused
41
43
  - Do not duplicate inventory, combat, quest, or selection state inside components.
42
44
  - A static facsimile preview does not test the real UI; compose production components with fixtures.
43
45
  - Visual quality is judged from rendered evidence, not component counts or prose.
46
+ - Shown HUD UI must fit the player viewport. `HudCanvas` runs a live boundary check on every registered `HudPanel`, any element tagged `data-hud-window`, and its own direct children: if a *visible* surface crosses a viewport edge it draws a red dev overlay on the offender, `console.warn`s, and sets `data-hud-overflow` (which fails the `bun run shoot` gate). A closed/parked/hidden surface (off-screen drawer, unopened menu, `visibility:hidden`, `aria-hidden`) never trips it — only surfaces partly on screen and partly past an edge. Deliberate bleed opts out with `data-hud-allow-overflow`. Mount custom windows inside `HudCanvas` (tag deeper ones `data-hud-window`) so the check can see them.
@@ -172,7 +172,7 @@
172
172
  - `orderedOpen` (function): function orderedOpen(state: PanelState, defs: readonly PanelDef[]): PanelDef[] — The open panels' defs sorted ascending by z — the render order for a window host (map to elements so the last, highest-z window paints on top). Defs unknown to the state are skipped.
173
173
  - `orientationGateActive` (function): function orientationGateActive(requirement: OrientationRequirement, liveOrientation: LayoutOrientation): boolean — The rotate gate blocks gameplay: a hard requirement (or `unsupported`) the live orientation doesn't satisfy.
174
174
  - `orientationHintActive` (function): function orientationHintActive(requirement: OrientationRequirement, liveOrientation: LayoutOrientation): boolean — An advisory rotate hint applies: a preference (not a hard gate) the live orientation doesn't satisfy.
175
- - `overflowingPanels` (function): function overflowingPanels(panels: readonly { id: string; rect: HudRect }[], viewport: HudSize, tolerance = 1.5): HudOverflow[] — Every panel rect that escapes the viewport — the data behind the HUD overflow gate.
175
+ - `overflowingPanels` (function): function overflowingPanels(panels: readonly { id: string; rect: HudRect }[], viewport: HudSize, tolerance = 1.5): HudOverflow[] — Every panel rect that escapes the viewport — the data behind the HUD overflow gate. A rect is only reported when it is partly visible *and* crosses an edge; a fully off-screen (closed/hidden) surface is skipped, so parked drawers and unopened menus never raise a false alarm.
176
176
  - `panelByHotkey` (function): function panelByHotkey(defs: readonly PanelDef[], code: string): string | null — Find the panel whose `hotkey` matches `code`, case-insensitively — the keybind router. `code` may be a `KeyboardEvent.code` (`"KeyB"`) or key (`"b"`); it is compared verbatim to each def's `hotkey`, so author the hotkey in whichever form the caller feeds in. Returns the first match's id, or null.
177
177
  - `placePopover` (function): function placePopover(anchor: UiRect, content: UiSize, viewport: UiSize, options?: PopoverOptions): PopoverPlacement — Position a popover/tooltip of `content` size against its `anchor` rectangle inside `viewport`: open on the `preferred` side, flip to the opposite when that side lacks room and the opposite has more, then clamp the top-left so the box stays fully on screen (respecting `margin`). Pure and deterministic — no DOM reads — so it is SSR-safe and unit-testable, and the React tooltip is a thin shell over it.
178
178
  - `registerHudPanelType` (function): function registerHudPanelType(def: HudPanelTypeDef): void — Register a HUD panel type so the editor can list/add/configure it and canvas resize knows axes.
@@ -256,7 +256,7 @@
256
256
  - `HudPlatform` (type): type HudPlatform = "web" | "mobile" — Where a game is meant to be played. `"web"` alone keeps today's desktop-first HUD; adding `"mobile"` turns on design-resolution fit scaling on compact displays.
257
257
  - `HudViewportConfig` (interface): interface HudViewportConfig extends HudFitConfig — Per-game HUD viewport declaration carried on `PlayableGame.hudFit`; `mobile` overrides the fit on compact displays so the owner can tune the phone layout separately.
258
258
  - `hudScaleForViewport` (function): function hudScaleForViewport(fit: Required<HudFitConfig>, viewport: HudSize): number — The one scaling rule for every display: the ratio of the live viewport to the authored design size along the limiting axis, clamped. 1 on a viewport at or above design size; smoothly below 1 down to `minScale` on phones.
259
- - `overflowingPanels` (function): function overflowingPanels(panels: readonly { id: string; rect: HudRect }[], viewport: HudSize, tolerance = 1.5): HudOverflow[] — Every panel rect that escapes the viewport — the data behind the HUD overflow gate.
259
+ - `overflowingPanels` (function): function overflowingPanels(panels: readonly { id: string; rect: HudRect }[], viewport: HudSize, tolerance = 1.5): HudOverflow[] — Every panel rect that escapes the viewport — the data behind the HUD overflow gate. A rect is only reported when it is partly visible *and* crosses an edge; a fully off-screen (closed/hidden) surface is skipped, so parked drawers and unopened menus never raise a false alarm.
260
260
  - `rectOverflow` (function): function rectOverflow(rect: HudRect, viewport: HudSize, tolerance = 1.5): Omit<HudOverflow, "id"> | null — Non-null when any edge of `rect` falls outside `viewport` by more than `tolerance` px; each field is the overflow distance past that edge (0 when inside).
261
261
  - `resolveHudFit` (function): function resolveHudFit(config: HudViewportConfig | undefined, mobile: boolean): Required<HudFitConfig> — ⚠ undocumented
262
262
 
@@ -392,6 +392,7 @@
392
392
  - `FriendRequestsList` (function): function FriendRequestsList({ className, rowClassName, acceptClassName, declineClassName, emptyState, renderRequest, }: { className?: string; rowClassName?: string; acceptClassName?: string; declineClassName?: string; emptyState?: ReactNode; renderRequest?: (request: FriendRequestEntry) => ReactNode… — ⚠ undocumented
393
393
  - `FriendRow` (function): function FriendRow({ friend, className, dotClassName, children, }: { friend: FriendEntry; className?: string; dotClassName?: string; children?: ReactNode; }): React.JSX.Element — ⚠ undocumented
394
394
  - `FriendsList` (function): function FriendsList({ className, rowClassName, dotClassName, emptyState, renderFriend, }: { className?: string; rowClassName?: string; dotClassName?: string; emptyState?: ReactNode; renderFriend?: (friend: FriendEntry) => ReactNode; }): React.JSX.Element — ⚠ undocumented
395
+ - `GameContextBridge` (function): function GameContextBridge({ context, children, }: { context: GameContext | null; children?: ReactNode; }): React.JSX.Element — Re-provide a captured {@link GameContext} across a nested React reconciler boundary. The `<Canvas>` of `@react-three/fiber` renders its children in a *separate* React root, so a `GameProvider` in the outer tree does NOT reach inside it — hooks like `useGameContext` / `useEntityRenderCues` throw. The fix the shell already uses internally: capture the context outside the boundary (with {@link useOptionalGameContext}) and wrap the inner tree in `GameContextBridge`. A `null` context renders children unchanged, so the same code works in showcases with no running game. This is the seam behind `EntityPreview` — reach for it directly when building a bespoke nested canvas.
395
396
  - `GameIdentityProvider` (function): function GameIdentityProvider({ source, children, }: { source: IdentitySource; children?: ReactNode; }): React.JSX.Element — ⚠ undocumented
396
397
  - `GamePreviewComponent` (type): type GamePreviewComponent = ComponentType<GamePreviewProps> — ⚠ undocumented
397
398
  - `GamePreviewProps` (type): type GamePreviewProps = { className?: string; } — ⚠ undocumented
@@ -450,7 +451,7 @@
450
451
  - `MinimapTrackPip` (interface): interface MinimapTrackPip — A point marker on the {@link MinimapTrack} rail at a 0..1 fraction (e.g. a gate, the exit, or the player).
451
452
  - `MinimapTrackProps` (interface): interface MinimapTrackProps — Props for {@link MinimapTrack}.
452
453
  - `MinimapTrackSpan` (interface): interface MinimapTrackSpan — A colored span across the {@link MinimapTrack} rail, given by 0..1 `start`/`end` fractions (e.g. from core `trackFraction`).
453
- - `PanelHost` (function): function PanelHost({ manager, render, panels, variation, shape, width, className, windowClassName, windowStyle, bodyStyle, }: { manager: PanelsManager; /** Content for a panel by id. Takes precedence over `panels`. */ render?: (id: string) => ReactNode; /** Static content map by panel id, used when … — Renders the open panels of a {@link usePanels} manager as absolutely-positioned, draggable, closable windows in z-order — the WoW window layer. Each window uses {@link HudFrame} chrome, is dragged by its title bar, raises focus on pointer-down, and gets its content from the `render` prop (or a `panels` map). Accessible: every window is a `role="dialog"` with an `aria-label` and a focusable close button; ESC-close is wired by the manager. Unskinned — art-direct via `HudTheme` tokens and the `variation`.
454
+ - `PanelHost` (function): function PanelHost({ manager, render, panels, variation, shape, width, zIndexBase = 40, className, windowClassName, windowStyle, bodyStyle, }: { manager: PanelsManager; /** Content for a panel by id. Takes precedence over `panels`. */ render?: (id: string) => ReactNode; /** Static content map by pan… — Renders the open panels of a {@link usePanels} manager as absolutely-positioned, draggable, closable windows in z-order — the WoW window layer. Each window uses {@link HudFrame} chrome, is dragged by its title bar, raises focus on pointer-down, and gets its content from the `render` prop (or a `panels` map). Accessible: every window is a `role="dialog"` with an `aria-label` and a focusable close button; ESC-close is wired by the manager. Unskinned — art-direct via `HudTheme` tokens and the `variation`.
454
455
  - `PanelKeyResult` (interface): interface PanelKeyResult — React chrome over the headless panel/window model (`@jgengine/core/ui/panelModel`) — the toggleable, draggable, z-stacked windows a WoW-style UI is made of (B for bag, C for character, ESC to close). `usePanels` is the DATA/HOOK layer (open/close/toggle/focus + a keybind listener), `PanelHost` renders the open windows in z-order, and `Window` is a standalone one-off window. All unskinned and token-driven: games reskin via `HudTheme` tokens and by swapping the `HudFrame` variation.
455
456
  - `PanelsManager` (interface): interface PanelsManager — The live panel manager returned by {@link usePanels} and consumed by {@link PanelHost}.
456
457
  - `Paperdoll` (function): function Paperdoll({ slots, portrait, slotSize = 44, onSlotActivate, className, style }: PaperdollProps): ReactNode — Arranges caller-supplied equip slots as a paperdoll — worn gear in two flanking columns around a center `portrait` (or a silhouette placeholder), with any `bottom` slots in a row beneath it. Each slot reuses the atomic {@link EquipmentSlots} tile (filled icon or themed empty placeholder), carries a label, and is a focusable `role="group"` with an `aria-label`; `onSlotActivate` fires on click/Enter/Space so games can wire drag or an equip picker. Composition and skin stay the game's.
@@ -949,7 +950,7 @@
949
950
 
950
951
  ## @jgengine/react/panels
951
952
 
952
- - `PanelHost` (function): function PanelHost({ manager, render, panels, variation, shape, width, className, windowClassName, windowStyle, bodyStyle, }: { manager: PanelsManager; /** Content for a panel by id. Takes precedence over `panels`. */ render?: (id: string) => ReactNode; /** Static content map by panel id, used when … — Renders the open panels of a {@link usePanels} manager as absolutely-positioned, draggable, closable windows in z-order — the WoW window layer. Each window uses {@link HudFrame} chrome, is dragged by its title bar, raises focus on pointer-down, and gets its content from the `render` prop (or a `panels` map). Accessible: every window is a `role="dialog"` with an `aria-label` and a focusable close button; ESC-close is wired by the manager. Unskinned — art-direct via `HudTheme` tokens and the `variation`.
953
+ - `PanelHost` (function): function PanelHost({ manager, render, panels, variation, shape, width, zIndexBase = 40, className, windowClassName, windowStyle, bodyStyle, }: { manager: PanelsManager; /** Content for a panel by id. Takes precedence over `panels`. */ render?: (id: string) => ReactNode; /** Static content map by pan… — Renders the open panels of a {@link usePanels} manager as absolutely-positioned, draggable, closable windows in z-order — the WoW window layer. Each window uses {@link HudFrame} chrome, is dragged by its title bar, raises focus on pointer-down, and gets its content from the `render` prop (or a `panels` map). Accessible: every window is a `role="dialog"` with an `aria-label` and a focusable close button; ESC-close is wired by the manager. Unskinned — art-direct via `HudTheme` tokens and the `variation`.
953
954
  - `PanelKeyResult` (interface): interface PanelKeyResult — React chrome over the headless panel/window model (`@jgengine/core/ui/panelModel`) — the toggleable, draggable, z-stacked windows a WoW-style UI is made of (B for bag, C for character, ESC to close). `usePanels` is the DATA/HOOK layer (open/close/toggle/focus + a keybind listener), `PanelHost` renders the open windows in z-order, and `Window` is a standalone one-off window. All unskinned and token-driven: games reskin via `HudTheme` tokens and by swapping the `HudFrame` variation.
954
955
  - `PanelsManager` (interface): interface PanelsManager — The live panel manager returned by {@link usePanels} and consumed by {@link PanelHost}.
955
956
  - `UsePanelsOptions` (interface): interface UsePanelsOptions — Options for {@link usePanels}.
@@ -965,6 +966,7 @@
965
966
 
966
967
  ## @jgengine/react/provider
967
968
 
969
+ - `GameContextBridge` (function): function GameContextBridge({ context, children, }: { context: GameContext | null; children?: ReactNode; }): React.JSX.Element — Re-provide a captured {@link GameContext} across a nested React reconciler boundary. The `<Canvas>` of `@react-three/fiber` renders its children in a *separate* React root, so a `GameProvider` in the outer tree does NOT reach inside it — hooks like `useGameContext` / `useEntityRenderCues` throw. The fix the shell already uses internally: capture the context outside the boundary (with {@link useOptionalGameContext}) and wrap the inner tree in `GameContextBridge`. A `null` context renders children unchanged, so the same code works in showcases with no running game. This is the seam behind `EntityPreview` — reach for it directly when building a bespoke nested canvas.
968
970
  - `GameProvider` (function): function GameProvider({ context, children }: { context: GameContext; children?: ReactNode }): React.JSX.Element — ⚠ undocumented
969
971
  - `useGameContext` (function): function useGameContext(): GameContext — ⚠ undocumented
970
972
  - `useOptionalGameContext` (function): function useOptionalGameContext(): GameContext | null — The game context if a `GameProvider` is present, otherwise `null` — for chrome that may render outside a running game (showcases, previews).
@@ -1494,6 +1496,13 @@
1494
1496
 
1495
1497
  - `createGradePass` (function): function createGradePass(config: GradeConfig = {}): ShaderPass — Build the display-space colour-grade pass (lift/gain/gamma, saturation, vignette, grain). Advance `uniforms.uTime.value` each frame to animate the grain.
1496
1498
 
1499
+ ## @jgengine/shell/postfx/postfxOverlay
1500
+
1501
+ - `POSTFX_OVERLAY_USERDATA` (const): const POSTFX_OVERLAY_USERDATA: { readonly jgPostfxOverlay: true } — Spread onto a group's `userData` to mark its subtree as a screen/world overlay effect — additive glows, telegraphs, tracers, debug gizmos — that post-processing scene prepasses (GTAO's normal/depth render, DOF's depth render) must skip. Those prepasses render the scene with `overrideMaterial`, which turns transparent overlay quads into opaque stamps in the AO/DOF buffers (visible as black squares over hit sparks). The shell marks its own combat VFX and collision-debug mounts; games mark custom overlay effects the same way.
1502
+ - `hidePostfxOverlays` (function): function hidePostfxOverlays(scene: THREE.Object3D, out: THREE.Object3D[]): void — Hide every currently visible marked object under `scene`, recording what was hidden into `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can undo exactly that set.
1503
+ - `isPostfxOverlay` (function): function isPostfxOverlay(object: { userData?: Record<string, unknown> }): boolean — True when the object opted out of postfx scene prepasses via {@link POSTFX_OVERLAY_USERDATA}.
1504
+ - `restorePostfxOverlays` (function): function restorePostfxOverlays(hidden: THREE.Object3D[]): void — Restore visibility for the objects hidden by {@link hidePostfxOverlays} and clear the list.
1505
+
1497
1506
  ## @jgengine/shell/registry
1498
1507
 
1499
1508
  - `GameRegistry` (type): type GameRegistry = Record<string, () => Promise<PlayableGame>> — ⚠ undocumented
@@ -1502,6 +1511,11 @@
1502
1511
  - `RenderObject` (type): type RenderObject = (object: SceneObject) => ReactNode — ⚠ undocumented
1503
1512
  - `resolveGameLoader` (function): function resolveGameLoader(registry: GameRegistry, gameId: string, fallbackGameId?: string): (() => Promise<PlayableGame>) | undefined — ⚠ undocumented
1504
1513
 
1514
+ ## @jgengine/shell/render/EntityPreview
1515
+
1516
+ - `EntityPreview` (function): function EntityPreview({ children, model, instanceId, mood = "studio", backdrop, turntable = 0, faceCamera = false, forward, environment = true, cameraPosition = [0, 1, 3], fov = 35, dpr = [1, 2], className, style, }: EntityPreviewProps): React.JSX.Element — Drop-in live 3D preview of a single entity — the character-screen portrait, unit inspector, or loadout viewer. It owns all the plumbing a game would otherwise re-derive by hand: a nested `<Canvas>`, the {@link GameContextBridge} that carries the running `GameContext` across the R3F reconciler boundary (so the subject's `useEntityRenderCues` / `useGameContext` hooks resolve and it mirrors the live game), a {@link StudioStage} 3-point lighting rig with optional turntable / face-camera, and a capture-friendly GL surface. The game keeps ownership of its `renderEntity`: pass it as `children`, or hand a native `model` (+ `instanceId`) for an engine-composed body. Bound to a live `instanceId`, the portrait walks, flinches, and topples in lockstep with the world instead of a canned spin.
1517
+ - `EntityPreviewProps` (interface): interface EntityPreviewProps — Props for {@link EntityPreview}.
1518
+
1505
1519
  ## @jgengine/shell/render/PartMotion
1506
1520
 
1507
1521
  - `PartMotionRig` (function): function PartMotionRig({ parts, model, instanceId, renderPart, children, }: { parts: readonly ModelPart[]; model: ModelConfig; instanceId?: string; /** Renders one part's model content (the shell passes its part renderer to avoid an import cycle). */ renderPart: (part: ModelPart, index: number) => R… — Procedural motion rig for a rig-less part-composed character (`ModelPart.role` — see `@jgengine/core/game/partAnimation`). Wraps the whole composition in a root group that bobs, breathes, flinches on `combat.hitReaction`, and topples on `entity.died`, while each role-tagged part swings around its authored transform — legs/arms counter-phase from the entity's live movement speed, head counter-sway, tail wag, wing flap. Untagged parts render as static kit pieces. Children are the base model content (primitive, attachments).
@@ -1531,6 +1545,12 @@
1531
1545
  - `measureLocalBounds` (function): function measureLocalBounds(root: THREE.Object3D): MeasuredLocalBounds | null — Measure the meshes under `root` in `root`'s own frame (root's transform is the measuring frame, children compose their local matrices below it). Returns `null` when nothing measurable is mounted. Skinned meshes measure at bind pose — the standard engine approximation.
1532
1546
  - `reportMeasuredBounds` (function): function reportMeasuredBounds(ctx: GameContext, target: "entity" | "object", key: string, bounds: MeasuredLocalBounds): void — Forward a measured result into the context (entity kind or object catalog id), deduping repeat reports of the same shape so remounts and multi-instance kinds don't churn collider resolution.
1533
1547
 
1548
+ ## @jgengine/shell/render/measureCollisionMesh
1549
+
1550
+ - `MeasuredCollisionTriangles` (interface): interface MeasuredCollisionTriangles extends CollisionMeshSource — Entity-local triangle soup of the meshes under a measured root, plus the contributing mesh count (same async fill-in detection as measured bounds). Satisfies {@link CollisionMeshSource}.
1551
+ - `measureLocalCollisionTriangles` (function): function measureLocalCollisionTriangles(root: THREE.Object3D, transform?: { scale: number; offset: readonly [number, number, number] }): MeasuredCollisionTriangles | null — Collect the triangle soup of the meshes under `root` in `root`'s own frame — the mesh-accurate counterpart to `measureLocalBounds`, with the same visibility/sprite/{@link MEASURE_EXCLUDE_KEY} exclusions (skinned meshes contribute their bind pose). An optional uniform `scale` + `offset` maps the result into entity-local space when the caller mounts the root scaled/translated (the model-primitive path). Returns `null` when nothing is measurable or the soup exceeds the triangle budget — callers keep their box fallback.
1552
+ - `reportMeasuredCollisionMesh` (function): function reportMeasuredCollisionMesh(ctx: GameContext, target: "entity" | "object", key: string, triangles: MeasuredCollisionTriangles): void — Forward measured triangles into the context (entity kind or object catalog id), deduping repeat reports of the same soup so remounts and multi-instance kinds don't rebuild the BVH.
1553
+
1534
1554
  ## @jgengine/shell/render/modelRender
1535
1555
 
1536
1556
  - `MaterialCache` (interface): interface MaterialCache — ⚠ undocumented
@@ -65,6 +65,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
65
65
 
66
66
  - `EntityPortrait` (function) · `import { EntityPortrait } from "@jgengine/react"`
67
67
 
68
+ ## entity-preview — live 3D entity portrait — nested Canvas + context bridge + studio stage, driven by the game's own renderEntity
69
+
70
+ - `EntityPreview` (function) · `import { EntityPreview } from "@jgengine/shell/render/EntityPreview"`
71
+
68
72
  ## entity-render-cues — live velocity/bob/fire/reload/hit/death cues for a custom renderEntity or viewmodel component
69
73
 
70
74
  - `useEntityRenderCues` (function) · `import { useEntityRenderCues } from "@jgengine/shell/render/useEntityRenderCues"`
@@ -77,6 +81,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
77
81
 
78
82
  - `useEventMeter` (function) · `import { useEventMeter } from "@jgengine/react"`
79
83
 
84
+ ## game-context-bridge — re-provide the GameContext across a nested Canvas/reconciler boundary
85
+
86
+ - `GameContextBridge` (function) · `import { GameContextBridge } from "@jgengine/react"`
87
+
80
88
  ## heightfield-mesh-update — in-place partial update of a displaced ground plane mesh
81
89
 
82
90
  - `displaceHeightfieldGeometry` (function) · `import { displaceHeightfieldGeometry } from "@jgengine/shell/terrain"`
@@ -186,7 +194,7 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
186
194
 
187
195
  - `focusPanel` (function) · `import { focusPanel } from "@jgengine/core/ui"`
188
196
 
189
- ## panel-host — render a manager's open windows as draggable, closable, z-stacked dialogs
197
+ ## panel-host — render a manager's open windows as draggable, closable, z-stacked dialogs above the HUD
190
198
 
191
199
  - `PanelHost` (function) · `import { PanelHost } from "@jgengine/react"`
192
200
 
@@ -925,7 +925,7 @@
925
925
 
926
926
  ## @jgengine/core/scene/builtinSceneKinds
927
927
 
928
- - `SCATTER_SCHEMA` (const): const SCATTER_SCHEMA: ParamSchema — The scatter/foliage region schema — the fields the inspector exposed as hand-written `ScatterFields`.
928
+ - `SCATTER_SCHEMA` (const): const SCATTER_SCHEMA: ParamSchema — The scatter/foliage region schema. `palette` (what fills the area) leads the schema body and `density` is the single coverage slider hoisted into the shared `CoverageSection` (see the kind's `coverage` descriptor), so a scatter region reads as Area → Assets → Density like every other scatterable kind; the rest is kind-specific tuning.
929
929
 
930
930
  ## @jgengine/core/scene/captureCheck
931
931
 
@@ -955,6 +955,7 @@
955
955
  - `PreparedCollisionMesh` (interface): interface PreparedCollisionMesh — Decoded, BVH-indexed triangles ready for per-ray queries — build once per asset via {@link prepareCollisionMesh}. Treat the fields as opaque; they exist so the raycast hot path can run allocation-free over flat arrays.
956
956
  - `encodeCollisionMesh` (function): function encodeCollisionMesh(source: CollisionMeshSource): CollisionMeshData | null — Quantize a triangle soup onto the 16-bit grid, weld coincident vertices, and drop triangles that collapse — the reindex-time compressor producing {@link CollisionMeshData}. Returns `null` when no finite, non-degenerate triangle survives.
957
957
  - `prepareCollisionMesh` (function): function prepareCollisionMesh(data: CollisionMeshData): PreparedCollisionMesh | null — Decode a stored collision mesh and index it with a BVH. Memoized per data object, so the shell and a headless host preparing the same catalog entry share one build. Returns `null` (also memoized) when the payload is malformed.
958
+ - `prepareCollisionMeshSource` (function): function prepareCollisionMeshSource(source: CollisionMeshSource): PreparedCollisionMesh | null — Build a prepared BVH mesh straight from a runtime triangle soup (float positions + indices) — the renderer-fed counterpart to {@link prepareCollisionMesh}, feeding `ctx.scene.entity.reportCollisionMesh` / `ctx.scene.object.reportCollisionMesh` without a quantize/encode round-trip. Copies and sanitizes the input: triangles referencing out-of-range, non-finite, or repeated vertices are dropped. Returns `null` when no triangle survives.
958
959
  - `raycastCollisionMesh` (function): function raycastCollisionMesh(mesh: PreparedCollisionMesh, origin: EntityPosition, direction: EntityPosition, maxDistance: number, position: EntityPosition, rotationY: number, scale: number, translate: EntityPosition): CollisionMeshHit | null — Nearest triangle hit for a world-space ray against a prepared mesh placed like its rendered model: model space scaled uniformly by `scale`, translated by entity-local `translate`, yaw-rotated by `rotationY`, and moved to `position` — the same composition {@link "scene/colliders".ModelBodySource} fitting applies to the AABB. Allocation-free until the returned hit. `distance` is world distance along `direction` (callers pass `direction` normalized); `normal` is the world-space geometric triangle normal facing the ray.
959
960
 
960
961
  ## @jgengine/core/scene/companion
@@ -1098,6 +1099,7 @@
1098
1099
  - `ParamSchema` (interface): interface ParamSchema — A kind's full parameter surface: an ordered list of fields the inspector renders top-to-bottom.
1099
1100
  - `ParsedParams` (type): type ParsedParams = Record<string, number | boolean | string | WeightedParamEntry[]> — Parsed params after `parseParams`: every schema field present with a validated, defaulted value.
1100
1101
  - `RangeParamField` (interface): interface RangeParamField — A numeric slider row: bounded range with a live readout.
1102
+ - `SceneKindCoverage` (interface): interface SceneKindCoverage — Marks a kind as *scatterable* — it fills an authored area with instances at one density under a bounded budget — so the editor renders the shared `CoverageSection` (Area → Assets → Density slider + budget note) that gives grass/scatter/city one authoring gesture. `spec` selects the unit + budget from `@jgengine/core/world/scatterCoverage`; `densityKey` is the single density slider hoisted into the coverage header (and hidden from the schema body below); `assetsKey` is the optional weighted "what fills it" palette summarized in the header.
1101
1103
  - `SceneKindDefinition` (interface): interface SceneKindDefinition<TResolved = unknown> — A registered scene kind — everything the editor/engine need to author and render a studio without bespoke code. `schema` drives the inspector + parse; `resolve` (optional) turns one document object into pure renderable data a matching `shell` renderer consumes; `add*` fields build the `+ Add` menu.
1102
1104
  - `SceneKindObject` (interface): interface SceneKindObject — The raw document object a resolver receives — shape shared by markers, volumes, and paths.
1103
1105
  - `SceneKindResolveContext` (interface): interface SceneKindResolveContext — Ground sampler + options a resolver may read (terrain height/normal snap).
@@ -1453,6 +1455,7 @@
1453
1455
  - `BuildingIndex` (interface): interface BuildingIndex — ⚠ undocumented
1454
1456
  - `BuildingPaletteOverrides` (type): type BuildingPaletteOverrides = Partial<BuildingPalette> — ⚠ undocumented
1455
1457
  - `BuildingStyle` (type): type BuildingStyle = | "generic" | "capital" | "village" | "desert" | "industrial" | "coastal" | "neon" | "ruin" | "frontier" | "aerial" — ⚠ undocumented
1458
+ - `CITY_BUILDING_BUDGET` (const): const CITY_BUILDING_BUDGET: 2600 — Max buildings a single `city` district generates — the block/parcel pipeline's bounded lot cap.
1456
1459
  - `CITY_DEFAULTS` (const): const CITY_DEFAULTS: CityRules — City defaults: a zoned mixed metropolis — towers downtown, slabs mid-ring, houses at the edge.
1457
1460
  - `CITY_KIND` (const): const CITY_KIND: "city" — The editor volume kind marking a box as a procedural city district.
1458
1461
  - `CITY_LOT_CLASSES` (const): const CITY_LOT_CLASSES: readonly CityLotClass[] — All classes, for schema hints and validation.
@@ -1697,6 +1700,8 @@
1697
1700
  - `RosterEntry` (interface): interface RosterEntry — ⚠ undocumented
1698
1701
  - `RoundGround` (interface): interface RoundGround — A planet/sphere you play on the outside of. Sized by `radius` only.
1699
1702
  - `RoundGroundSize` (interface): interface RoundGroundSize — Size of a `round` ground: the planet/sphere radius in world units. Nothing else.
1703
+ - `SCATTER_COVERAGE_SPECS` (const): const SCATTER_COVERAGE_SPECS: Record<ScatterCoverageKind, ScatterCoverageSpec> — The coverage spec per scatterable kind — the single source for its unit + budget.
1704
+ - `SCATTER_INSTANCE_BUDGET` (const): const SCATTER_INSTANCE_BUDGET: 250000 — Max blade/placement instances a single `grass_field` or `scatter` patch places. Mirrors the grass renderer's instance-buffer ceiling; the scatter resolver truncates to it too, so a patch never renders more than this many placements regardless of density × area.
1700
1705
  - `SCATTER_PATH_KIND` (const): const SCATTER_PATH_KIND: "scatter" — The editor path kind that marks a closed polyline as a foliage/scatter region.
1701
1706
  - `SHAPE_BOX` (const): const SHAPE_BOX: 0 — ⚠ undocumented
1702
1707
  - `SHAPE_SPHERE` (const): const SHAPE_SPHERE: 1 — ⚠ undocumented
@@ -1710,6 +1715,9 @@
1710
1715
  - `SampleReason` (type): type SampleReason = "accepted" | "exhausted" | "empty-region" — Why a sample ended: a clean hit, budget exhausted, or the region was degenerate.
1711
1716
  - `SampleRegion` (interface): interface SampleRegion<P extends SamplePoint = SamplePoint> — A geometric region a sampler can draw from. Built-ins cover point sets, rect/box, circle/sphere, annulus/shell, polygon, and weighted composites; {@link customRegion} wraps a caller-defined sampler + bounds. `sample` draws one raw candidate under the region's own distribution policy; `contains` is the membership test reused for include/exclude constraints and rejection.
1712
1717
  - `SampleResult` (interface): interface SampleResult<P extends SamplePoint = SamplePoint> — Structured single-sample outcome — never a bare point, so failure and fallback are visible.
1718
+ - `ScatterCoverage` (interface): interface ScatterCoverage — A resolved coverage readout: how many instances an authored patch WANTS (`requested`, when it can be computed cheaply from geometry × density), how many it will actually place after the budget clamp (`count`), the `budget` ceiling, and whether the request hit it (`capped`). `requested` is `null` for kinds whose pre-cap ask is not knowable without running the generator (city, where the count comes from the resolved street fabric).
1719
+ - `ScatterCoverageKind` (type): type ScatterCoverageKind = "grass_field" | "scatter" | "city" — The shared "how much stuff grows here" contract for scatterable scene kinds (`grass_field` / `scatter` / `city`). Every scatterable kind fills an authored AREA with instances at a DENSITY, and every kind caps the placed count at a bounded BUDGET so a huge or dense request can never generate unbounded work. Before this module each kind hand-rolled its own readout and clamp story (grass silently capped in the renderer, scatter never capped, city had no budget note); this is the one place that
1720
+ - `ScatterCoverageSpec` (interface): interface ScatterCoverageSpec — Per-kind coverage descriptor: the unit noun, whether density is per-m², and the placement budget.
1713
1721
  - `ScatterInstance` (interface): interface ScatterInstance — ⚠ undocumented
1714
1722
  - `ScatterPoint` (interface): interface ScatterPoint — ⚠ undocumented
1715
1723
  - `ScatterTerrain` (interface): interface ScatterTerrain — Ground sampler a scatter resolve reads height/normal from (the sculpt terrain or the game's ground).
@@ -1848,6 +1856,7 @@
1848
1856
  - `boundaryNeighbors` (function): function boundaryNeighbors(grid: FootprintGrid, cells: readonly GridCell[]): AdjacentCell[] — Every occupied cell orthogonally touching `cells` but outside them — the connective-piece neighbor set.
1849
1857
  - `boxFormation` (function): function boxFormation(options: BoxFormationOptions): FormationSlotGenerator — A rectangular grid centered on the destination, front row toward `+forward` — a marching block or a phalanx. Rows fill front-to-back, left-to-right.
1850
1858
  - `boxRegion` (function): function boxRegion(min: Point3, max: Point3): SampleRegion<Point3> — An axis-aligned box `[min..max]` in 3D. Uniform density; draws x, y, z in order.
1859
+ - `budgetWarning` (function): function budgetWarning(coverage: ScatterCoverage): string — The shared clamp-and-warn clause every scatterable kind appends, worded identically: `""` when under budget, else ` · requested N, capped at M (budget)` when the pre-cap ask is known, or ` · capped at M (budget)` when only the ceiling is (city). This is the "surfaced, never silent" budget signal from #1112.
1851
1860
  - `buildContextMenu` (function): function buildContextMenu(input: BuildContextMenuInput): ContextMenu | null — Assemble a menu from a target's catalog verbs; null when the target lists none.
1852
1861
  - `buildRoadRibbon` (function): function buildRoadRibbon(path: readonly RoadPoint[], width: number, sampleHeight: (x: number, z: number) => number, options: RoadRibbonOptions = {}): RoadRibbon — Triangulate a road centerline into a ground-draped ribbon mesh: the polyline is subdivided, each vertex is offset half a `width` along the local perpendicular, and every vertex sits at `sampleHeight(x, z) + elevation`. Pure geometry — the shell (or any renderer) turns the result into a mesh, and tests can assert on it directly.
1853
1862
  - `building` (function): function building(config: BuildingEnvironmentConfig = {}): BuildingEnvironmentDescriptor — Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style. Pass `along` to line road frontage instead of gridding around `position`.
@@ -1929,6 +1938,8 @@
1929
1938
  - `definePatrolOrder` (function): function definePatrolOrder<TCtx extends OrderMover>(config: OrderKindConfig & { arriveRadius?: number } = {}): OrderKind<TCtx, PatrolOrderPayload> — Patrol: walk a waypoint route, advancing the serialized `index` as each point is reached. Loops forever by default, or completes at the last waypoint when `loop` is `false`.
1930
1939
  - `defineStopOrder` (function): function defineStopOrder<TCtx extends OrderMover>(config: OrderKindConfig = {}): OrderKind<TCtx, EmptyOrderPayload> — Stop: halt immediately and complete the same tick — the interrupt that clears a unit's intent to standstill.
1931
1940
  - `defineTargetedOrder` (function): function defineTargetedOrder<TCtx extends OrderMover & OrderTargeting>(config: EngagementKindConfig = {}): OrderKind<TCtx, TargetedOrderPayload> — Targeted action: pursue one specific target until within `range`, writing the engagement intent into `order.state`. Completes when the target is gone (despawned/dead), so the game's effect loop runs while `inRange` is true.
1941
+ - `densityCoverage` (function): function densityCoverage(kind: "grass_field" | "scatter", area: number, density: number): ScatterCoverage — Coverage for a per-m² kind (`grass_field` / `scatter`): `requested = floor(density × area)`, clamped to the kind's budget. `capped` is true when the request exceeds the budget.
1942
+ - `describeScatterCoverage` (function): function describeScatterCoverage(coverage: ScatterCoverage): string — The full one-line coverage readout: `≈ N unit · A m²` plus the {@link budgetWarning} when capped.
1932
1943
  - `discRegion` (function): function discRegion(center: Vec2, radius: number, options: { distribution?: RadialDistribution } = {}): SampleRegion<Vec2> — A filled circle. `"area"` is area-uniform (√-corrected radius, even density); `"radial"` is radius-uniform (clumps toward the center). Angle is drawn first, then radius — matching the common `angle = rng()·2π; r = rng()·R` idiom so migrations keep their stream.
1933
1944
  - `distance` (function): function distance(a: Vec3, b: Vec3): number — ⚠ undocumented
1934
1945
  - `distance3` (function): function distance3(a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }): number — ⚠ undocumented
@@ -2002,6 +2013,7 @@
2002
2013
  - `placeAuthoredObjects` (function): function placeAuthoredObjects(store: AuthoredObjectPlaceTarget, objects: readonly AuthoredObject[], sampleHeight: (x: number, z: number) => number, options: PlaceAuthoredObjectsOptions = {}): string[] — Places resolved authored objects into an object store, grounding each on `sampleHeight(x,z)` plus per-object and options vertical offsets. Returns the instance ids that were placed (or kept).
2003
2014
  - `placeAuthoredObjectsFromDocument` (function): function placeAuthoredObjectsFromDocument(store: AuthoredObjectPlaceTarget, document: AuthoredObjectsDocumentLike, sampleHeight: (x: number, z: number) => number, options: PlaceAuthoredObjectsOptions = {}): string[] — Convenience: resolve a document then place every authored catalog prop.
2004
2015
  - `placeFormation` (function): function placeFormation(destination: Vec2, facing: number, count: number, generator: FormationSlotGenerator): Vec2[] — Transform a generator's local slot offsets into world XZ positions around a `destination`, rotated by `facing` (engine yaw). Slot `i` in the returned array is `generator(count)[i]` mapped through the group frame, so it stays aligned with {@link assignFormationSlots}' slot indices. Pure and allocation- light: one array of `count` points, no per-call closures retained.
2016
+ - `placedCoverage` (function): function placedCoverage(kind: ScatterCoverageKind, area: number, placed: number): ScatterCoverage — Coverage for a kind whose placed `count` is already known from its generator (city, from its resolved lots). No pre-cap `requested` is available — the generator stops at the budget — so `capped` is inferred from the placed count reaching the ceiling.
2005
2017
  - `player` (function): function player(): PlayerBehavior — ⚠ undocumented
2006
2018
  - `plots` (function): function plots(config: PlotsWorldConfig = {}): WorldFeature — Declares a subdivided-plots world — farming, base-building, and other parcel-based layouts.
2007
2019
  - `pointGravity` (function): function pointGravity(options: PointGravityOptions): GravityField — Radial gravity toward one center, with optional inverse-square falloff for planetary worlds.
@@ -2057,6 +2069,8 @@
2057
2069
  - `sampleStratified` (function): function sampleStratified(options: StratifiedOptions): Vec2[] — Stratified 2D sampling: one jittered point per grid cell over `area`, in deterministic row-major order. Unlike pure rejection sampling this guarantees even coverage (no clumps, no gaps) — the distinct "stratified" distribution policy, kept separate from area-uniform draws.
2058
2070
  - `sanitizeGameTimeScale` (function): function sanitizeGameTimeScale(timeScale?: number | null): number — ⚠ undocumented
2059
2071
  - `scatter` (function): function scatter(config: ScatterConfig): ScatterPoint[] — ⚠ undocumented
2072
+ - `scatterCoverageSpec` (function): function scatterCoverageSpec(kind: string): ScatterCoverageSpec | null — The coverage spec for a scatterable kind, or `null` when the kind is not scatterable.
2073
+ - `scatterFootprintArea` (function): function scatterFootprintArea(object: { points?: readonly { x: number; z: number }[]; halfExtents?: { x: number; z: number }; radius?: number; }): number — Footprint area (m²) of a scatterable object: a closed path polygon (shoelace), a volume box (`halfExtents`), or a disc (`radius`). Structural so it accepts a `SceneKindObject`, an editor volume, or a scatter path without a hard type dependency.
2060
2074
  - `scatterItems` (function): function scatterItems<T>(field: RegionField<T>, area: Aabb, layersFor: (sample: RegionSample<T>) => readonly ScatterLayer[], options: { cell?: number; max?: number; saltKey?: number } = {}): ScatterInstance[] — Deterministically place opaque items across `area`, grounded on a region field. For each grid cell it asks `layersFor` which items may appear in that region and rolls one against their densities. The engine never interprets `item` — a game maps it to a mesh or entity. Content scatter (region-driven density) as opposed to `scatter` in `./scatter`, which is renderer-free geometric point distribution.
2061
2075
  - `scatterRegionEstimate` (function): function scatterRegionEstimate(path: ScenePathLike): { area: number; count: number } — Estimated placement count for a scatter path — density × polygon area, for a live UI readout.
2062
2076
  - `scatterRegionFromPath` (function): function scatterRegionFromPath(path: ScenePathLike): ScatterRegion | null — Builds a resolvable {@link ScatterRegion} from a scatter path (XZ polygon + rules), or null.
@@ -2750,6 +2764,21 @@
2750
2764
  - `scatter` (function): function scatter(config: ScatterConfig): ScatterPoint[] — ⚠ undocumented
2751
2765
  - `scatterAabb` (function): function scatterAabb(area: ScatterArea | Aabb): Aabb — ⚠ undocumented
2752
2766
 
2767
+ ## @jgengine/core/world/scatterCoverage
2768
+
2769
+ - `CITY_BUILDING_BUDGET` (const): const CITY_BUILDING_BUDGET: 2600 — Max buildings a single `city` district generates — the block/parcel pipeline's bounded lot cap.
2770
+ - `SCATTER_COVERAGE_SPECS` (const): const SCATTER_COVERAGE_SPECS: Record<ScatterCoverageKind, ScatterCoverageSpec> — The coverage spec per scatterable kind — the single source for its unit + budget.
2771
+ - `SCATTER_INSTANCE_BUDGET` (const): const SCATTER_INSTANCE_BUDGET: 250000 — Max blade/placement instances a single `grass_field` or `scatter` patch places. Mirrors the grass renderer's instance-buffer ceiling; the scatter resolver truncates to it too, so a patch never renders more than this many placements regardless of density × area.
2772
+ - `ScatterCoverage` (interface): interface ScatterCoverage — A resolved coverage readout: how many instances an authored patch WANTS (`requested`, when it can be computed cheaply from geometry × density), how many it will actually place after the budget clamp (`count`), the `budget` ceiling, and whether the request hit it (`capped`). `requested` is `null` for kinds whose pre-cap ask is not knowable without running the generator (city, where the count comes from the resolved street fabric).
2773
+ - `ScatterCoverageKind` (type): type ScatterCoverageKind = "grass_field" | "scatter" | "city" — The shared "how much stuff grows here" contract for scatterable scene kinds (`grass_field` / `scatter` / `city`). Every scatterable kind fills an authored AREA with instances at a DENSITY, and every kind caps the placed count at a bounded BUDGET so a huge or dense request can never generate unbounded work. Before this module each kind hand-rolled its own readout and clamp story (grass silently capped in the renderer, scatter never capped, city had no budget note); this is the one place that
2774
+ - `ScatterCoverageSpec` (interface): interface ScatterCoverageSpec — Per-kind coverage descriptor: the unit noun, whether density is per-m², and the placement budget.
2775
+ - `budgetWarning` (function): function budgetWarning(coverage: ScatterCoverage): string — The shared clamp-and-warn clause every scatterable kind appends, worded identically: `""` when under budget, else ` · requested N, capped at M (budget)` when the pre-cap ask is known, or ` · capped at M (budget)` when only the ceiling is (city). This is the "surfaced, never silent" budget signal from #1112.
2776
+ - `densityCoverage` (function): function densityCoverage(kind: "grass_field" | "scatter", area: number, density: number): ScatterCoverage — Coverage for a per-m² kind (`grass_field` / `scatter`): `requested = floor(density × area)`, clamped to the kind's budget. `capped` is true when the request exceeds the budget.
2777
+ - `describeScatterCoverage` (function): function describeScatterCoverage(coverage: ScatterCoverage): string — The full one-line coverage readout: `≈ N unit · A m²` plus the {@link budgetWarning} when capped.
2778
+ - `placedCoverage` (function): function placedCoverage(kind: ScatterCoverageKind, area: number, placed: number): ScatterCoverage — Coverage for a kind whose placed `count` is already known from its generator (city, from its resolved lots). No pre-cap `requested` is available — the generator stops at the budget — so `capped` is inferred from the placed count reaching the ceiling.
2779
+ - `scatterCoverageSpec` (function): function scatterCoverageSpec(kind: string): ScatterCoverageSpec | null — The coverage spec for a scatterable kind, or `null` when the kind is not scatterable.
2780
+ - `scatterFootprintArea` (function): function scatterFootprintArea(object: { points?: readonly { x: number; z: number }[]; halfExtents?: { x: number; z: number }; radius?: number; }): number — Footprint area (m²) of a scatterable object: a closed path polygon (shoelace), a volume box (`halfExtents`), or a disc (`radius`). Structural so it accepts a `SceneKindObject`, an editor volume, or a scatter path without a hard type dependency.
2781
+
2753
2782
  ## @jgengine/core/world/scatterItems
2754
2783
 
2755
2784
  - `ScatterInstance` (interface): interface ScatterInstance — ⚠ undocumented
@@ -355,6 +355,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
355
355
  - `controlGroupKey` (function) · `import { controlGroupKey } from "@jgengine/core/world"`
356
356
  - `resolveControlGroupIntent` (function) · `import { resolveControlGroupIntent } from "@jgengine/core/world"`
357
357
 
358
+ ## scatter-coverage — shared density/budget semantics for scatterable kinds
359
+
360
+ - `ScatterCoverageKind` (type) · `import { ScatterCoverageKind } from "@jgengine/core/world"`
361
+
358
362
  ## selection-bookmark-recall — fold a saved set into the active selection with stale-ref pruning and a caller focus hook
359
363
 
360
364
  - `recallSelectionBookmark` (function) · `import { recallSelectionBookmark } from "@jgengine/core/world"`