@jgengine/react 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -26,6 +26,57 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.32.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.32.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.29.0`; `@jgengine/github` is `0.19.0`.
34
+
35
+ ### Added
36
+
37
+ - Audio mixing grew a mute that composes with gain: `AudioEngine.setBusMute` / `setMasterMute` keep the player's volume while silencing a bus, `masterLevel()` / `busLevel()` report the composed value, and Settings carries a mute row per bus plus "Mute all" (`sound.muted`, `sound.bus.<id>.muted`). `AudioEngine.play` and `playOneShot` hold cues in a bounded 16-slot queue while the browser keeps the context suspended and play them on the first gesture instead of dropping them; `installGestureResume` wires that gesture for both presentation paths.
38
+ - `AudioEngine.measure()` reads master RMS and peak from a lazily built analyser, and `installAudioProbe` publishes `window.__jgengineAudio` so a headless capture can prove sound with a measured level rather than a screenshot.
39
+ - `@jgengine/core/audio/feedbackCues` ships seven procedural reactions - `hit`, `hurt`, `kill`, `pickup`, `objective`, `wave`, `deny` - on an `sfx` bus, merged into every game's catalog as `feel.<cue>`. A game sound with that id replaces one; `audio.feedbackCues` replaces or silences them.
40
+ - `@jgengine/shell/audio/eventFeedback` maps the events the engine already announces to those reactions: `combat.damage` (local player reads as hurt, anything else as a hit), `entity.died`, `worldItem.picked_up`, `quest.completed`, `stat.levelUp` and `command.refused`. Each row carries a sound, a gamepad pulse, camera trauma and a caption; the mapping and reactions are data (`DEFAULT_FEEDBACK_BINDINGS`, `DEFAULT_FEEDBACK_REACTIONS`) a game can replace, cooldowns fold a burst into one impact, reduced motion keeps sound and rumble but drops the camera kick, and the resolved reaction is pooled so handling an event allocates nothing per frame. `attachWaveFeedback(runner, ctx.game.feedback)` announces each new wave.
41
+ - `ctx.game.feedback.cue(cue, caption?)` announces a moment no engine event covers and emits `feedback.cue`.
42
+ - `@jgengine/shell/audio/captions` and `CaptionOverlay` render a bounded, deduping, self-expiring caption line under the accessibility captions setting; `FeedbackHost` mounts the whole layer - feedback mapping, gesture resume, audio probe and captions - for both the 3D and HUD presentation paths.
43
+
44
+ ### Changed
45
+
46
+ - The CI gate runs nine test shards instead of ten, and a `types` job whose stages overlap, so a docs-only pull request fits the account's 20-job runner cap with room for another run beside it. `scripts/test-shard.ts` owns which files each shard runs and what it prepares first; `bun scripts/type-shard.ts` runs the `check-types` plan two stages at a time and then the tsc fan-out alone.
47
+
48
+ ## 0.31.0
49
+
50
+ ### Migrate
51
+
52
+ - **Bump lockstep SDK packages to `^0.31.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.28.0`; `@jgengine/github` is `0.18.0`.
53
+
54
+ ### Added
55
+
56
+ - `createAgent` (`@jgengine/core/agents/agent`) runs one resident's whole loop on the world clock: needs decay through `sim/needs`, a utility brain picks among the targets the game offers, an authored day plan says where the agent belongs, and the agent falls back to a declared target when its pick disappears. Mood, action and the far flag snapshot and restore with the needs.
57
+ - `createMoodReader` / `readMood` (`@jgengine/core/agents/mood`) derive a 0-100 mood and a caller-labelled tier from weighted need satisfaction, plus named sources a queue, a litter patch or a cold snap raises and clears.
58
+ - `createAgentSchedule` (`@jgengine/core/agents/schedule`) resolves an authored day plan through `sim/routine` and adds a work beat: a stop names the places it patrols, so a janitor is an agent with a route rather than a special case. Off-hours behaviour is a stop without a beat.
59
+ - `createRelationshipBook` (`@jgengine/core/agents/relationships`) keeps undirected familiarity and opinion per pair. A social action moves both and reports the milestones crossed, friends prefer each other's company, and a bonded pair refuses to split. Pair keys come from `relation/keyedValues` and crossings from `relation/thresholds`.
60
+ - `GameSave.deleteSlot(slot)` and `SaveStore.deleteSlot(slot)` remove one slot's payload and drop it from the slot index; they resolve `true` when a document was removed and `false` for an unknown slot. Deleting the active slot resets the live state to the initial document. `load()` no longer lists a slot that holds nothing, so a deleted slot cannot return.
61
+ - `HudPanel` takes `lane` (`@jgengine/react`): blocks naming the same anchor and lane stack without overlap, lanes in one anchor sit side by side, and `resolveHudLanes` resolves the rectangles. A moved panel leaves its lane and keeps its authored placement.
62
+ - `hudBlockOverlaps` and `formatHudOverlapReport` (`@jgengine/react`) name every pair of rendered HUD blocks whose rectangles intersect, with both ids and the viewport; `HudCanvas` warns with that report in a dev build.
63
+ - `rollDrop` (`@jgengine/core/loot/drop`) rolls one deterministic item from a seed and `compareToEquipped` turns it into per-stat deltas against worn gear with a weighted verdict a HUD can render.
64
+ - `createRunHaul` (`@jgengine/core/loot/haul`) gates carried loot by weight through the shared encumbrance rules, banks what the persistent stash fits, and discards the run's haul when the game calls `fail`.
65
+ - `salvageItem` and `createItemLedger` (`@jgengine/core/loot/salvage`) break an item down for the currency its level and rarity say and claim the instance, so a salvaged item cannot be salvaged, equipped or sold again.
66
+ - `stockVendor` and `createVendorRotation` (`@jgengine/core/loot/vendor`) fill a `ShopStock` with rolled items priced through the economy wallet, with a run-count restock rule the game authors.
67
+ - `markerProximity` on `createMissionRuntime` picks the plane a marker proximity test compares in: `"horizontal"` (default) measures ground distance and allows the actor a `tolerance` (default 8 m) above or below the marker, `"spatial"` is the straight 3D check for worlds with stacked platforms. `MissionMarkerPlane`, `MissionMarkerProximity` and `DEFAULT_MARKER_TOLERANCE` are exported with it.
68
+
69
+ ### Changed
70
+
71
+ - `evaluatePressure` (`@jgengine/core/ai/director/pressure`) reads pressure as player control: a healthy player further into a run is leaned on harder, a hurt player is eased off, and every effect stays inside the rule's `maxTier`.
72
+ - Loot bases and affixes take `minLevel`, so `rollLoot` only draws from entries at or below the item level. Loot bases take `mass`; rolled items carry it for carry checks.
73
+
74
+ ### Fixed
75
+
76
+ - `HudCanvas` resolved its host with `canvas.parentElement`, which is `HudThemeRoot`'s `display: contents` element and measures 0x0 — the boundary overlay, the HUD-fit measurement and the new overlap report never ran under the standard HUD root. It now uses the nearest ancestor with a real box.
77
+ - The HUD boundary check returned early whenever nothing overflowed, so a later report in the same pass could never run.
78
+ - `reach`, `interact` and the `left-area` fail condition compared a marker in 3D, so a marker authored at `y=0` under rising ground was unreachable. They now read the same plane.
79
+
29
80
  ## 0.30.0
30
81
 
31
82
  ### Migrate
@@ -0,0 +1,96 @@
1
+ import { type HudAnchor, type HudRect, type HudSize } from "@jgengine/core/ui/hudLayout";
2
+ /**
3
+ * One HUD block's claim on a lane. Two blocks that name the same `anchor` and
4
+ * `lane` stack in `order` order; a lane is a column, so lanes in the same anchor
5
+ * sit side by side. The shell resolves every claim into a rectangle, which is
6
+ * what keeps blocks from arranging themselves into each other.
7
+ */
8
+ export interface HudLaneSlot {
9
+ id: string;
10
+ /** The anchored corner/edge the lane hangs from. */
11
+ anchor: HudAnchor;
12
+ /** Lane name inside the anchor. Default `"main"`. */
13
+ lane?: string;
14
+ /** Stack position within the lane, ascending away from the anchored edge. Default `0`. */
15
+ order?: number;
16
+ /** Declared width in px. Falls back to the size the block measured. */
17
+ width?: number;
18
+ /** Declared height in px. Falls back to the size the block measured. */
19
+ height?: number;
20
+ /**
21
+ * Game override: an exact rectangle in HUD space. The block is placed there
22
+ * and takes no part in lane stacking, so a game keeps full control of its own
23
+ * placement.
24
+ */
25
+ rect?: HudRect;
26
+ }
27
+ /** One block's resolved placement. */
28
+ export interface HudLanePlacement {
29
+ id: string;
30
+ anchor: HudAnchor;
31
+ lane: string;
32
+ rect: HudRect;
33
+ }
34
+ /** How the shell lays out lanes. */
35
+ export interface HudLaneConfig {
36
+ /** Gap between stacked blocks and between lanes, in px. Default `10`. */
37
+ gap?: number;
38
+ /** Inset from the frame edge, in px. Default `0` — the HUD layer already carries its padding. */
39
+ pad?: number;
40
+ /** Size used for a block that declares and has not yet measured one. */
41
+ fallback?: HudSize;
42
+ }
43
+ /** The resolved layout of every claimed block. */
44
+ export interface HudLaneLayout {
45
+ placements: HudLanePlacement[];
46
+ /** Rectangle by block id, for renderers that only lookup. */
47
+ rects: Record<string, HudRect>;
48
+ }
49
+ /**
50
+ * Resolve lane claims into non-overlapping rectangles for one frame.
51
+ *
52
+ * Blocks are grouped by anchor, then by lane (first-declared lane first), then
53
+ * sorted by `order`. A lane is one column: its blocks stack top-to-bottom with
54
+ * `gap` between them, and the anchor's lanes sit side by side with the same gap.
55
+ * The whole group is aligned to its anchored edge and the block is aligned
56
+ * within its lane, so a block added at any viewport can only take free space —
57
+ * never a neighbour's. A block carrying `rect` is placed there untouched.
58
+ *
59
+ * @capability hud-lanes lay HUD blocks out in non-overlapping anchored lanes
60
+ */
61
+ export declare function resolveHudLanes(slots: readonly HudLaneSlot[], viewport: HudSize, config?: HudLaneConfig): HudLaneLayout;
62
+ /** One intersection between two rendered HUD blocks. */
63
+ export interface HudBlockOverlap {
64
+ a: string;
65
+ b: string;
66
+ /** Overlapping area in px². */
67
+ area: number;
68
+ /** The intersection itself, in the same space as the inputs. */
69
+ rect: HudRect;
70
+ }
71
+ /** A rendered block, as measured from the DOM. */
72
+ export interface HudBlockBounds {
73
+ id: string;
74
+ rect: HudRect;
75
+ }
76
+ /** Options for {@link hudBlockOverlaps}. */
77
+ export interface HudBlockOverlapOptions {
78
+ /** Ignore intersections smaller than this many px². Default `1`. */
79
+ minArea?: number;
80
+ }
81
+ /**
82
+ * Every pair of rendered HUD blocks that intersects. This is the development-time
83
+ * check behind the console report: it reads what actually hit the screen rather
84
+ * than what a layout intended, so a block that overflows its own box or a game
85
+ * override that lands on a neighbour is named too.
86
+ *
87
+ * @capability hud-overlap-report name every intersecting pair of rendered HUD blocks
88
+ */
89
+ export declare function hudBlockOverlaps(blocks: readonly HudBlockBounds[], options?: HudBlockOverlapOptions): HudBlockOverlap[];
90
+ /**
91
+ * The console block for a set of overlaps — one line per pair naming both block
92
+ * ids, plus the viewport they collided at. Empty string when nothing intersects.
93
+ *
94
+ * @capability hud-overlap-report format the intersecting HUD block pairs for a development console
95
+ */
96
+ export declare function formatHudOverlapReport(overlaps: readonly HudBlockOverlap[], viewport: HudSize): string;
@@ -0,0 +1,148 @@
1
+ import { HUD_ANCHOR_FRACTIONS, } from "@jgengine/core/ui/hudLayout";
2
+ const DEFAULT_GAP = 10;
3
+ const DEFAULT_FALLBACK = { width: 240, height: 44 };
4
+ /**
5
+ * Resolve lane claims into non-overlapping rectangles for one frame.
6
+ *
7
+ * Blocks are grouped by anchor, then by lane (first-declared lane first), then
8
+ * sorted by `order`. A lane is one column: its blocks stack top-to-bottom with
9
+ * `gap` between them, and the anchor's lanes sit side by side with the same gap.
10
+ * The whole group is aligned to its anchored edge and the block is aligned
11
+ * within its lane, so a block added at any viewport can only take free space —
12
+ * never a neighbour's. A block carrying `rect` is placed there untouched.
13
+ *
14
+ * @capability hud-lanes lay HUD blocks out in non-overlapping anchored lanes
15
+ */
16
+ export function resolveHudLanes(slots, viewport, config = {}) {
17
+ const gap = config.gap ?? DEFAULT_GAP;
18
+ const pad = config.pad ?? 0;
19
+ const fallback = config.fallback ?? DEFAULT_FALLBACK;
20
+ const placements = [];
21
+ const rects = {};
22
+ const byAnchor = new Map();
23
+ slots.forEach((slot, index) => {
24
+ const claims = byAnchor.get(slot.anchor) ?? [];
25
+ claims.push({ slot, index });
26
+ byAnchor.set(slot.anchor, claims);
27
+ });
28
+ const place = (id, anchor, lane, rect) => {
29
+ placements.push({ id, anchor, lane, rect });
30
+ rects[id] = rect;
31
+ };
32
+ for (const [anchor, claims] of byAnchor) {
33
+ const { fx, fy } = HUD_ANCHOR_FRACTIONS[anchor];
34
+ for (const claim of claims) {
35
+ if (claim.slot.rect === undefined)
36
+ continue;
37
+ place(claim.slot.id, anchor, claim.slot.lane ?? "main", claim.slot.rect);
38
+ }
39
+ const flowing = claims.filter((claim) => claim.slot.rect === undefined);
40
+ if (flowing.length === 0)
41
+ continue;
42
+ const lanes = new Map();
43
+ for (const claim of flowing) {
44
+ const name = claim.slot.lane ?? "main";
45
+ const members = lanes.get(name) ?? [];
46
+ members.push(claim);
47
+ lanes.set(name, members);
48
+ }
49
+ const sizeOf = (claim) => ({
50
+ width: claim.slot.width ?? fallback.width,
51
+ height: claim.slot.height ?? fallback.height,
52
+ });
53
+ const laneSize = new Map();
54
+ for (const [name, members] of lanes) {
55
+ members.sort((a, b) => (a.slot.order ?? 0) - (b.slot.order ?? 0) || a.index - b.index);
56
+ let width = 0;
57
+ let height = 0;
58
+ members.forEach((claim, position) => {
59
+ const size = sizeOf(claim);
60
+ width = Math.max(width, size.width);
61
+ height += size.height + (position === 0 ? 0 : gap);
62
+ });
63
+ laneSize.set(name, { width, height });
64
+ }
65
+ const laneWidths = [...laneSize.values()].map((size) => size.width);
66
+ const groupWidth = laneWidths.reduce((sum, width) => sum + width, 0) + gap * Math.max(0, laneWidths.length - 1);
67
+ const groupHeight = Math.max(...[...laneSize.values()].map((size) => size.height));
68
+ let groupLeft = pad;
69
+ if (fx === 1)
70
+ groupLeft = viewport.width - pad - groupWidth;
71
+ else if (fx === 0.5)
72
+ groupLeft = (viewport.width - groupWidth) / 2;
73
+ let groupTop = pad;
74
+ if (fy === 1)
75
+ groupTop = viewport.height - pad - groupHeight;
76
+ else if (fy === 0.5)
77
+ groupTop = (viewport.height - groupHeight) / 2;
78
+ let columnX = groupLeft;
79
+ for (const [name, members] of lanes) {
80
+ const lane = laneSize.get(name);
81
+ const columnY = fy === 0 ? groupTop : fy === 1 ? groupTop + (groupHeight - lane.height) : groupTop + (groupHeight - lane.height) / 2;
82
+ let cursor = columnY;
83
+ for (const claim of members) {
84
+ const size = sizeOf(claim);
85
+ const alignedX = fx === 0 ? columnX : fx === 1 ? columnX + (lane.width - size.width) : columnX + (lane.width - size.width) / 2;
86
+ place(claim.slot.id, anchor, name, {
87
+ x: alignedX,
88
+ y: cursor,
89
+ width: size.width,
90
+ height: size.height,
91
+ });
92
+ cursor += size.height + gap;
93
+ }
94
+ columnX += lane.width + gap;
95
+ }
96
+ }
97
+ return { placements, rects };
98
+ }
99
+ /**
100
+ * Every pair of rendered HUD blocks that intersects. This is the development-time
101
+ * check behind the console report: it reads what actually hit the screen rather
102
+ * than what a layout intended, so a block that overflows its own box or a game
103
+ * override that lands on a neighbour is named too.
104
+ *
105
+ * @capability hud-overlap-report name every intersecting pair of rendered HUD blocks
106
+ */
107
+ export function hudBlockOverlaps(blocks, options = {}) {
108
+ const minArea = options.minArea ?? 1;
109
+ const out = [];
110
+ for (let i = 0; i < blocks.length; i += 1) {
111
+ for (let j = i + 1; j < blocks.length; j += 1) {
112
+ const a = blocks[i];
113
+ const b = blocks[j];
114
+ const width = Math.min(a.rect.x + a.rect.width, b.rect.x + b.rect.width) - Math.max(a.rect.x, b.rect.x);
115
+ const height = Math.min(a.rect.y + a.rect.height, b.rect.y + b.rect.height) - Math.max(a.rect.y, b.rect.y);
116
+ if (width <= 0 || height <= 0)
117
+ continue;
118
+ const area = width * height;
119
+ if (area < minArea)
120
+ continue;
121
+ out.push({
122
+ a: a.id,
123
+ b: b.id,
124
+ area,
125
+ rect: {
126
+ x: Math.max(a.rect.x, b.rect.x),
127
+ y: Math.max(a.rect.y, b.rect.y),
128
+ width,
129
+ height,
130
+ },
131
+ });
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+ /**
137
+ * The console block for a set of overlaps — one line per pair naming both block
138
+ * ids, plus the viewport they collided at. Empty string when nothing intersects.
139
+ *
140
+ * @capability hud-overlap-report format the intersecting HUD block pairs for a development console
141
+ */
142
+ export function formatHudOverlapReport(overlaps, viewport) {
143
+ if (overlaps.length === 0)
144
+ return "";
145
+ const header = `HUD BLOCK OVERLAP at ${Math.round(viewport.width)}x${Math.round(viewport.height)}`;
146
+ const lines = overlaps.map((overlap) => `${overlap.a} intersects ${overlap.b} by ${Math.round(overlap.area)} px² (${Math.round(overlap.rect.x)},${Math.round(overlap.rect.y)} ${Math.round(overlap.rect.width)}x${Math.round(overlap.rect.height)})`);
147
+ return [header, ...lines].join("\n");
148
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The same shape Deepward's top-left HUD uses — two blocks that used to arrange
3
+ * themselves into each other. Both declare `lane="status"`, so the shell stacks
4
+ * them; the second lane sits beside the first and the right-hand lane mirrors it.
5
+ * Screenshots at any viewport without a block landing on a neighbour.
6
+ *
7
+ * @capability hud-lanes two HUD blocks stacked in one lane, plus a second lane beside it
8
+ */
9
+ export declare function HudLanesPreview({ className }: {
10
+ className?: string;
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { HudFrame } from "./hudFrame.js";
3
+ import { HudSurface } from "./hudSurface.js";
4
+ import { HudThemeRoot } from "./hudThemeRoot.js";
5
+ import { HudCanvas, HudPanel, useHudLayout } from "./hudLayout.js";
6
+ function Chip({ label, value }) {
7
+ return (_jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10 }, children: [_jsx("span", { style: { color: "var(--jg-text-dim, rgba(255,255,255,0.62))", fontSize: 12 }, children: label }), _jsx("span", { style: { fontSize: 12, fontVariantNumeric: "tabular-nums" }, children: value })] }));
8
+ }
9
+ /**
10
+ * The same shape Deepward's top-left HUD uses — two blocks that used to arrange
11
+ * themselves into each other. Both declare `lane="status"`, so the shell stacks
12
+ * them; the second lane sits beside the first and the right-hand lane mirrors it.
13
+ * Screenshots at any viewport without a block landing on a neighbour.
14
+ *
15
+ * @capability hud-lanes two HUD blocks stacked in one lane, plus a second lane beside it
16
+ */
17
+ export function HudLanesPreview({ className }) {
18
+ const layout = useHudLayout();
19
+ return (_jsx("div", { className: className, style: {
20
+ position: "relative",
21
+ width: "100%",
22
+ height: "100%",
23
+ overflow: "hidden",
24
+ background: "radial-gradient(ellipse at 30% 20%, #3b5a46, #1b2a24 55%, #0f1714)",
25
+ }, children: _jsx(HudThemeRoot, { children: _jsxs(HudCanvas, { layout: layout, editChord: false, children: [_jsx(HudPanel, { id: "vitals", lane: "status", anchor: "top-left", width: 260, height: 104, children: _jsx(HudFrame, { title: "Warden \u00B7 Lv 12", padding: 10, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [_jsx(Chip, { label: "HP", value: "78 / 100" }), _jsx(Chip, { label: "MP", value: "50 / 80" }), _jsx(Chip, { label: "Lantern", value: "burning" })] }) }) }), _jsx(HudPanel, { id: "quest-log", lane: "status", anchor: "top-left", order: 1, width: 260, height: 132, children: _jsx(HudSurface, { title: "Expedition", padding: 10, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [_jsx(Chip, { label: "Reach the drowned keep", value: "1 / 3" }), _jsx(Chip, { label: "Recover the tide sigil", value: "0 / 1" }), _jsx(Chip, { label: "Return to camp", value: "\u2014" })] }) }) }), _jsx(HudPanel, { id: "party", lane: "roster", anchor: "top-left", width: 190, height: 248, children: _jsx(HudSurface, { title: "Party", padding: 10, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx(Chip, { label: "Mara", value: "Stable" }), _jsx(Chip, { label: "Osk", value: "Wounded" }), _jsx(Chip, { label: "Bel", value: "Stable" }), _jsx(Chip, { label: "Tallow", value: "Missing" })] }) }) }), _jsx(HudPanel, { id: "radar", lane: "radar", anchor: "top-right", width: 180, height: 180, children: _jsx(HudSurface, { title: "Survey", padding: 10, children: _jsx("div", { style: {
26
+ height: 122,
27
+ borderRadius: 999,
28
+ border: "1px solid var(--jg-slot-border, rgba(255,255,255,0.2))",
29
+ background: "radial-gradient(circle at 50% 50%, rgba(120,200,160,0.24), rgba(0,0,0,0.35))",
30
+ } }) }) }), _jsx(HudPanel, { id: "hint", lane: "radar", anchor: "top-right", order: 1, width: 180, height: 64, children: _jsx(HudSurface, { padding: 10, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsx(Chip, { label: "Hold", value: "Lantern up" }), _jsx(Chip, { label: "Tide", value: "Rising" })] }) }) })] }) }) }));
31
+ }
@@ -64,9 +64,16 @@ export declare function HudCanvas({ layout, editChord, compactScale, showDuring,
64
64
  *
65
65
  * @capability hud-panel anchored HUD slot — drop any widget in a named, player-movable panel
66
66
  */
67
- export declare function HudPanel({ id, anchor, order, compact: compactMode, chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region, width, height, type, className, style, children, }: {
67
+ export declare function HudPanel({ id, anchor, lane, order, compact: compactMode, chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region, width, height, type, className, style, children, }: {
68
68
  id: string;
69
69
  anchor?: HudAnchor;
70
+ /**
71
+ * Placement lane inside the anchor. Blocks naming the same lane stack in
72
+ * `order` and can never overlap; lanes in one anchor sit side by side. A block
73
+ * that has been moved (drag/edit, or a scene-document `ui.panels` entry) leaves
74
+ * the lane and keeps its authored position, so a game override always wins.
75
+ */
76
+ lane?: string;
70
77
  /** Stack position within the region, ascending outward from the screen edge. Default 0. */
71
78
  order?: number;
72
79
  /** Behavior on compact displays. Default `"keep"`. */
package/dist/hudLayout.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react";
2
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
  import { HUD_ANCHOR_FRACTIONS, anchoredPlacement, createHudLayout, isPanelDraggable, registerActiveHudLayout, } from "@jgengine/core/ui/hudLayout";
5
5
  import { resizePanelSize, resolvePanelResize, } from "@jgengine/core/ui/hudDocument";
@@ -9,6 +9,7 @@ import { useDisplayProfile } from "./display.js";
9
9
  import { useEngineState } from "./engineStore.js";
10
10
  import { useGameLayoutMode, useRegisterLayoutRegion } from "./gameViewport.js";
11
11
  import { useOptionalGamePhase } from "./hooks.js";
12
+ import { formatHudOverlapReport, hudBlockOverlaps, resolveHudLanes } from "./hudLanes.js";
12
13
  import { useHudLayoutPersist } from "./hudLayoutPersist.js";
13
14
  import { useHudViewport } from "./hudViewport.js";
14
15
  import { hudFont, hudType } from "./hudTokens.js";
@@ -34,13 +35,21 @@ const WINDOW_ATTR = "data-hud-window";
34
35
  /**
35
36
  * Dev-only, prod-safe. The on-screen boundary overlay must never reach players,
36
37
  * so anything we cannot positively identify as a dev build resolves to `false`.
38
+ *
39
+ * `import.meta.env.DEV` is read as a literal member chain because Vite replaces
40
+ * exactly those words at transform time; reading `env` off a cast `import.meta`
41
+ * survives to the browser as `undefined` and every dev-only report stays dark.
37
42
  */
38
43
  function isDevBuild() {
39
44
  try {
40
- const meta = import.meta;
41
- if (meta.env?.DEV === true)
45
+ // Literal members on purpose: Vite replaces exactly `import.meta.env.DEV`.
46
+ // Inlined as plain `@ts-ignore` because a workspace-wide type program that
47
+ // includes `vite/client` would otherwise fail an unused-directive check.
48
+ // @ts-ignore -- `vite/client` types are not part of this package's own tsconfig
49
+ if (import.meta.env.DEV === true)
42
50
  return true;
43
- if (meta.env?.PROD === true)
51
+ // @ts-ignore -- `vite/client` types are not part of this package's own tsconfig
52
+ if (import.meta.env.PROD === true)
44
53
  return false;
45
54
  }
46
55
  catch {
@@ -108,6 +117,21 @@ function collectBoundarySurfaces(canvas) {
108
117
  }
109
118
  return out;
110
119
  }
120
+ /**
121
+ * The nearest ancestor that actually has a box. `HudThemeRoot` is
122
+ * `display: contents`, so `canvas.parentElement` can be a layout-free element
123
+ * whose rect is 0x0 — measuring it would silently disable every check below.
124
+ */
125
+ function measurableHost(canvas) {
126
+ let element = canvas.parentElement;
127
+ while (element !== null) {
128
+ const rect = element.getBoundingClientRect();
129
+ if (rect.width > 0 && rect.height > 0)
130
+ return element;
131
+ element = element.parentElement;
132
+ }
133
+ return canvas;
134
+ }
111
135
  function sizeStyle(panel) {
112
136
  if (panel === undefined)
113
137
  return {};
@@ -197,6 +221,7 @@ export function useHudLayout(options) {
197
221
  }, [layout, storageKey, documentUi]);
198
222
  return layout;
199
223
  }
224
+ const EMPTY_LANE_RECTS = {};
200
225
  const HudCanvasContext = createContext(null);
201
226
  function HudEditBar({ layout }) {
202
227
  return (_jsxs("div", { "data-hud-edit-bar": "", style: {
@@ -305,7 +330,82 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
305
330
  }
306
331
  return refs;
307
332
  }, []);
308
- const value = useMemo(() => ({ layout, canvasRef, regions, compact }), [layout, regions, compact]);
333
+ const [laneLayer, setLaneLayer] = useState(null);
334
+ const [laneFrame, setLaneFrame] = useState(null);
335
+ const [laneRevision, setLaneRevision] = useState(0);
336
+ const laneSlots = useRef(new Map());
337
+ const laneSizes = useRef(new Map());
338
+ const declareLane = useCallback((slot) => {
339
+ const previous = laneSlots.current.get(slot.id);
340
+ if (previous !== undefined &&
341
+ previous.anchor === slot.anchor &&
342
+ previous.lane === slot.lane &&
343
+ previous.order === slot.order &&
344
+ previous.width === slot.width &&
345
+ previous.height === slot.height &&
346
+ previous.rect === slot.rect) {
347
+ return;
348
+ }
349
+ laneSlots.current.set(slot.id, slot);
350
+ setLaneRevision((value) => value + 1);
351
+ }, []);
352
+ const removeLane = useCallback((id) => {
353
+ if (!laneSlots.current.delete(id))
354
+ return;
355
+ laneSizes.current.delete(id);
356
+ setLaneRevision((value) => value + 1);
357
+ }, []);
358
+ const reportLaneSize = useCallback((id, size) => {
359
+ const previous = laneSizes.current.get(id);
360
+ if (previous !== undefined &&
361
+ Math.abs(previous.width - size.width) < 0.5 &&
362
+ Math.abs(previous.height - size.height) < 0.5) {
363
+ return;
364
+ }
365
+ laneSizes.current.set(id, size);
366
+ setLaneRevision((value) => value + 1);
367
+ }, []);
368
+ const laneRects = useMemo(() => {
369
+ if (laneFrame === null || laneSlots.current.size === 0)
370
+ return EMPTY_LANE_RECTS;
371
+ const slots = [];
372
+ for (const slot of laneSlots.current.values()) {
373
+ const measured = laneSizes.current.get(slot.id);
374
+ slots.push(measured === undefined
375
+ ? slot
376
+ : { ...slot, width: slot.width ?? measured.width, height: slot.height ?? measured.height });
377
+ }
378
+ return resolveHudLanes(slots, laneFrame, { gap: REGION_GAP }).rects;
379
+ }, [laneFrame, laneRevision]);
380
+ useEffect(() => {
381
+ if (laneLayer === null)
382
+ return;
383
+ const measure = () => {
384
+ const rect = laneLayer.getBoundingClientRect();
385
+ if (rect.width <= 0 || rect.height <= 0)
386
+ return;
387
+ setLaneFrame((prev) => prev !== null && Math.abs(prev.width - rect.width) < 0.5 && Math.abs(prev.height - rect.height) < 0.5
388
+ ? prev
389
+ : { width: rect.width, height: rect.height });
390
+ };
391
+ measure();
392
+ if (typeof ResizeObserver === "undefined")
393
+ return;
394
+ const observer = new ResizeObserver(measure);
395
+ observer.observe(laneLayer);
396
+ return () => observer.disconnect();
397
+ }, [laneLayer]);
398
+ const value = useMemo(() => ({
399
+ layout,
400
+ canvasRef,
401
+ regions,
402
+ compact,
403
+ laneLayer,
404
+ laneRects,
405
+ declareLane,
406
+ removeLane,
407
+ reportLaneSize,
408
+ }), [layout, regions, compact, laneLayer, laneRects, declareLane, removeLane, reportLaneSize]);
309
409
  const editing = useEngineState(layout).editing;
310
410
  useEffect(() => registerActiveHudLayout(layout), [layout]);
311
411
  const chordEnabled = editChord !== false;
@@ -350,7 +450,7 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
350
450
  useEffect(() => {
351
451
  if (!fitEnabled)
352
452
  return;
353
- const host = canvasRef.current?.parentElement;
453
+ const host = canvasRef.current === null ? null : measurableHost(canvasRef.current);
354
454
  if (host === undefined || host === null || typeof ResizeObserver === "undefined")
355
455
  return;
356
456
  const measure = () => {
@@ -365,13 +465,14 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
365
465
  }, [fitEnabled]);
366
466
  useEffect(() => {
367
467
  const canvas = canvasRef.current;
368
- const host = canvas?.parentElement;
369
- if (canvas === null || canvas === undefined || host === undefined || host === null)
468
+ if (canvas === null)
370
469
  return;
470
+ const host = measurableHost(canvas);
371
471
  if (typeof requestAnimationFrame !== "function")
372
472
  return;
373
473
  let frame = 0;
374
474
  let lastReport = "";
475
+ let lastOverlapReport = "";
375
476
  const dev = isDevBuild();
376
477
  const check = () => {
377
478
  frame = 0;
@@ -398,21 +499,46 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
398
499
  // cross an edge — a closed/parked/off-screen menu is never an offender.
399
500
  const escaped = overflowingPanels(surfaces, viewportSize);
400
501
  const report = escaped.length === 0 ? "" : JSON.stringify(escaped);
401
- if (report === lastReport)
402
- return;
403
- lastReport = report;
404
- if (report === "") {
405
- canvas.removeAttribute("data-hud-overflow");
406
- }
407
- else {
408
- canvas.setAttribute("data-hud-overflow", report);
409
- console.warn(`[jgengine] HUD UI escapes the ${Math.round(hostRect.width)}x${Math.round(hostRect.height)} viewport: ${report}`);
502
+ if (report !== lastReport) {
503
+ lastReport = report;
504
+ if (report === "") {
505
+ canvas.removeAttribute("data-hud-overflow");
506
+ }
507
+ else {
508
+ canvas.setAttribute("data-hud-overflow", report);
509
+ console.warn(`[jgengine] HUD UI escapes the ${Math.round(hostRect.width)}x${Math.round(hostRect.height)} viewport: ${report}`);
510
+ }
511
+ if (dev) {
512
+ setOffenders(escaped.map((overflow) => {
513
+ const rect = surfaces.find((s) => s.id === overflow.id)?.rect;
514
+ return { ...overflow, x: rect?.x ?? 0, y: rect?.y ?? 0, width: rect?.width ?? 0, height: rect?.height ?? 0 };
515
+ }));
516
+ }
410
517
  }
411
- if (dev) {
412
- setOffenders(escaped.map((overflow) => {
413
- const rect = surfaces.find((s) => s.id === overflow.id)?.rect;
414
- return { ...overflow, x: rect?.x ?? 0, y: rect?.y ?? 0, width: rect?.width ?? 0, height: rect?.height ?? 0 };
415
- }));
518
+ const blocks = [...canvas.querySelectorAll("[data-hud-panel]")]
519
+ .filter((el) => isSurfaceShown(el))
520
+ .map((el) => {
521
+ const rect = el.getBoundingClientRect();
522
+ return {
523
+ id: surfaceId(el),
524
+ rect: {
525
+ x: rect.left - hostRect.left,
526
+ y: rect.top - hostRect.top,
527
+ width: rect.width,
528
+ height: rect.height,
529
+ },
530
+ };
531
+ });
532
+ const overlapReport = dev ? formatHudOverlapReport(hudBlockOverlaps(blocks), viewportSize) : "";
533
+ if (overlapReport !== lastOverlapReport) {
534
+ lastOverlapReport = overlapReport;
535
+ if (overlapReport === "") {
536
+ canvas.removeAttribute("data-hud-overlap");
537
+ }
538
+ else {
539
+ canvas.setAttribute("data-hud-overlap", overlapReport);
540
+ console.warn(`[jgengine] ${overlapReport}`);
541
+ }
416
542
  }
417
543
  };
418
544
  const schedule = () => {
@@ -462,7 +588,14 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
462
588
  pointerEvents: "none",
463
589
  zIndex: 1,
464
590
  ...REGION_LAYOUT[anchor],
465
- } }, anchor))), children, offenders.length > 0 ? (_jsx("div", { "data-hud-overflow-overlay": "", style: { position: "absolute", inset: 0, pointerEvents: "none", zIndex: EDIT_BAR_Z - 1 }, children: offenders.map((o) => (_jsx("div", { style: {
591
+ } }, anchor))), _jsx("div", { "data-hud-lane-layer": "", style: {
592
+ position: "absolute",
593
+ inset: 0,
594
+ padding: `${PAD_TOP} ${PAD_RIGHT} ${PAD_BOTTOM} ${PAD_LEFT}`,
595
+ boxSizing: "border-box",
596
+ pointerEvents: "none",
597
+ zIndex: 1,
598
+ }, children: _jsx("div", { ref: setLaneLayer, style: { position: "relative", width: "100%", height: "100%" } }) }), children, offenders.length > 0 ? (_jsx("div", { "data-hud-overflow-overlay": "", style: { position: "absolute", inset: 0, pointerEvents: "none", zIndex: EDIT_BAR_Z - 1 }, children: offenders.map((o) => (_jsx("div", { style: {
466
599
  position: "absolute",
467
600
  left: o.x / scale,
468
601
  top: o.y / scale,
@@ -537,13 +670,13 @@ function HudChip({ label, anchor, children, }) {
537
670
  *
538
671
  * @capability hud-panel anchored HUD slot — drop any widget in a named, player-movable panel
539
672
  */
540
- export function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }) {
673
+ export function HudPanel({ id, anchor = "top-left", lane, order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }) {
541
674
  const ctx = useContext(HudCanvasContext);
542
675
  const phase = useOptionalGamePhase();
543
676
  const layoutMode = useGameLayoutMode();
544
677
  if (ctx === null)
545
678
  throw new Error("HudPanel must be rendered inside a HudCanvas");
546
- const { layout, canvasRef, regions, compact } = ctx;
679
+ const { layout, canvasRef, regions, compact, laneLayer, laneRects, declareLane, removeLane, reportLaneSize } = ctx;
547
680
  const onMobile = isMobileMode(layoutMode);
548
681
  const registeredOnRef = useRef(null);
549
682
  if (registeredOnRef.current !== layout) {
@@ -582,6 +715,59 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
582
715
  // A flowing panel portals into its anchor region, which HudCanvas only provides after its first commit.
583
716
  const awaitingRegion = panel !== undefined && (compact || !panel.moved) && regions[compact ? anchor : panel.placement.anchor] === undefined;
584
717
  useRegisterLayoutRegion(regionSpec, rootRef, region && !regionHidden && !awaitingRegion);
718
+ const laneActive = lane !== undefined && !regionHidden && !(panel !== undefined && panel.moved);
719
+ const laneRect = lane === undefined ? undefined : laneRects[id];
720
+ const [laneSize, setLaneSize] = useState(null);
721
+ useEffect(() => {
722
+ if (!laneActive)
723
+ return;
724
+ declareLane({
725
+ id,
726
+ anchor: panel?.placement.anchor ?? anchor,
727
+ lane,
728
+ order,
729
+ width: panel?.width ?? width,
730
+ height: panel?.height ?? height,
731
+ });
732
+ return () => removeLane(id);
733
+ }, [
734
+ laneActive,
735
+ declareLane,
736
+ removeLane,
737
+ id,
738
+ panel?.placement.anchor,
739
+ lane,
740
+ order,
741
+ panel?.width,
742
+ width,
743
+ panel?.height,
744
+ height,
745
+ ]);
746
+ const laneMounted = laneRect !== undefined;
747
+ useEffect(() => {
748
+ if (!laneActive || !laneMounted)
749
+ return;
750
+ const element = rootRef.current;
751
+ if (element === null)
752
+ return;
753
+ const measure = () => {
754
+ const rect = element.getBoundingClientRect();
755
+ if (rect.width <= 0 && rect.height <= 0)
756
+ return;
757
+ setLaneSize((previous) => previous !== null &&
758
+ Math.abs(previous.width - rect.width) < 0.5 &&
759
+ Math.abs(previous.height - rect.height) < 0.5
760
+ ? previous
761
+ : { width: rect.width, height: rect.height });
762
+ reportLaneSize(id, { width: rect.width, height: rect.height });
763
+ };
764
+ measure();
765
+ if (typeof ResizeObserver === "undefined")
766
+ return;
767
+ const observer = new ResizeObserver(measure);
768
+ observer.observe(element);
769
+ return () => observer.disconnect();
770
+ }, [laneActive, laneMounted, id, reportLaneSize]);
585
771
  const onPointerDown = (event) => {
586
772
  if (!draggable || event.button !== 0)
587
773
  return;
@@ -712,6 +898,31 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
712
898
  if (!hudVisibleInPhase(showDuring, phase))
713
899
  return null;
714
900
  const authoredSize = sizeStyle(panel);
901
+ if (laneActive) {
902
+ if (laneRect === undefined || laneLayer === null)
903
+ return null;
904
+ const declaredWidth = panel.width ?? width;
905
+ const declaredHeight = panel.height ?? height;
906
+ const settled = laneSize !== null || declaredWidth !== undefined || declaredHeight !== undefined;
907
+ const chipped = compact && compactMode === "chip";
908
+ const passThrough = interactive === false && !editing && !chipped;
909
+ return createPortal(_jsxs("div", { ref: rootRef, "data-hud-panel": id, "data-hud-lane": lane, "data-dragging": dragging ? "" : undefined, "data-hud-priority": priority, "data-hud-mobile-behavior": mobileBehavior, "data-hud-type": panel.type ?? type, className: className, onPointerDown: onPointerDown, onClickCapture: onClickCapture, style: {
910
+ position: "absolute",
911
+ left: laneRect.x,
912
+ top: laneRect.y,
913
+ ...(declaredWidth === undefined ? {} : { width: declaredWidth }),
914
+ ...(declaredHeight === undefined ? {} : { height: declaredHeight }),
915
+ visibility: settled ? undefined : "hidden",
916
+ pointerEvents: passThrough ? "none" : "auto",
917
+ touchAction: draggable ? "none" : undefined,
918
+ userSelect: editing || dragging ? "none" : undefined,
919
+ cursor: dragging ? "grabbing" : editing ? "grab" : undefined,
920
+ outline: editing ? "1px dashed rgba(255, 255, 255, 0.6)" : undefined,
921
+ outlineOffset: editing ? 2 : undefined,
922
+ boxSizing: "border-box",
923
+ ...style,
924
+ }, children: [chipped ? (_jsx(HudChip, { label: chip ?? id, anchor: anchor, children: children })) : (children), editingChrome] }), laneLayer);
925
+ }
715
926
  const flow = compact || !panel.moved;
716
927
  if (flow) {
717
928
  const regionEl = regions[compact ? anchor : panel.placement.anchor];
package/dist/index.d.ts CHANGED
@@ -67,6 +67,7 @@ export * from "./cards.js";
67
67
  export * from "./actionHud.js";
68
68
  export * from "./selectionHud.js";
69
69
  export * from "./hudLayout.js";
70
+ export * from "./hudLanes.js";
70
71
  export * from "./hudLayoutPersist.js";
71
72
  export * from "./hudViewport.js";
72
73
  export * from "./gameViewport.js";
package/dist/index.js CHANGED
@@ -67,6 +67,7 @@ export * from "./cards.js";
67
67
  export * from "./actionHud.js";
68
68
  export * from "./selectionHud.js";
69
69
  export * from "./hudLayout.js";
70
+ export * from "./hudLanes.js";
70
71
  export * from "./hudLayoutPersist.js";
71
72
  export * from "./hudViewport.js";
72
73
  export * from "./gameViewport.js";
@@ -1,5 +1,6 @@
1
1
  import { BarsPreview } from "./barsPreview.js";
2
2
  import { HudChromePreview } from "./hudChromePreview.js";
3
+ import { HudLanesPreview } from "./hudLanesPreview.js";
3
4
  import { HudThemePreview } from "./hudThemePreview.js";
4
5
  import { IconsPreview } from "./iconsPreview.js";
5
6
  import { StreetGeometryPreview } from "./streetGeometryPreview.js";
@@ -20,6 +21,11 @@ import { ScrollRailPreview } from "./scrollRailPreview.js";
20
21
  * declares.
21
22
  */
22
23
  export const PREVIEW_FIXTURES = {
24
+ HudLanesPreview: {
25
+ name: "HudLanesPreview",
26
+ description: "Two HUD blocks stacked in one lane, a second lane beside it, and the mirrored right-hand lane.",
27
+ component: HudLanesPreview,
28
+ },
23
29
  ScrollRailPreview: { name: "ScrollRailPreview", description: "Reachable caller-owned settings categories in shared scrolling chrome.", component: ScrollRailPreview },
24
30
  HudChromePreview: {
25
31
  name: "HudChromePreview",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/react",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "React UI layer for JGengine: GameProvider, hooks, and headless primitives over @jgengine/core.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  "styles": "bun scripts/styles.ts"
36
36
  },
37
37
  "dependencies": {
38
- "@jgengine/core": "^0.30.0"
38
+ "@jgengine/core": "^0.32.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "react": "^19.0.0",
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.30.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.32.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -285,7 +285,7 @@
285
285
 
286
286
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
287
287
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
288
- - `VERSION` (const): const VERSION: "0.30.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.32.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -410,6 +410,7 @@
410
410
  - `GameContextRace` (interface): interface GameContextRace { state(id: string, config?: RaceStateConfig): RaceState } — ⚠ undocumented
411
411
  - `GameContextTurn` (interface): interface GameContextTurn { loop(id: string, config?: TurnLoopConfig): TurnLoop } — ⚠ undocumented
412
412
  - `GameContextWorld` (interface): interface GameContextWorld { ground: TerrainField; groundHeightAt(x: number, z: number): number; solids: WorldSolids } — ⚠ undocumented
413
+ - `GameFeedback` (interface): interface GameFeedback — The game-announced feel seam on `ctx.game`: name a moment the engine has no event for and the shell plays its cue's sound, rumble, camera kick and caption. The reaction table lives in the shell, so a game never encodes what a hit or a wave should sound like.
413
414
  - `HitReactionInput` (interface): interface HitReactionInput { from: string; to: string; config: HitReactionConfig | ImpactPresetName; power?: number } — ⚠ undocumented · used by `resolveHitReaction` (@jgengine/core/combat): Resolves hit feel (hitstop, knockback impulse, camera shake) from either a named `impactPresets` event (`resolveHitReaction("explosion", inp…
414
415
  - `MoveTowardCommitOptions` (interface): interface MoveTowardCommitOptions extends MoveTowardOptions — Options for {@link SceneEntityContext.moveTowardCommit}: {@link MoveTowardOptions} plus an optional facing turn.
415
416
  - `SceneEntityContext` (interface): interface SceneEntityContext { spawn(name: string, options?: SpawnOptions): string; despawn(instanceId: string): boolean; update: EntityStore["update"]; setPose(instanceId: string, pose: EntityPose): boolean; setVelocity: EntityStore["setVelocity"]; setPoseConstraint: … — ⚠ undocumented
@@ -728,7 +729,7 @@
728
729
  - `GameLoop` (interface): interface GameLoop<TContext = unknown> — Lifecycle hooks a game implements to drive init, per-tick simulation, and player join/leave.
729
730
  - `Hotbar` (function): function Hotbar({ inventoryId, activeSlot, keys, slotSize = 46, itemIcon, style, className, }: { inventoryId: string; activeSlot?: number; keys?: readonly string[]; slotSize?: number; /** Caller-supplied item id → icon registry; return null/undefined to fall back to the default glyph. */ itemIcon?: … — A numbered hotbar bound to an inventory — painted iconed slots (a `GameIcon` glyph over a school-keyed gradient with a count badge, #1035), an active-slot highlight, and a keycap per slot. Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one. Supply `itemIcon` to map your item ids to your own glyphs — the default resolves a `GameIcon` from the item id.
730
731
  - `HudCanvas` (function): function HudCanvas({ layout, editChord, compactScale, showDuring, className, style, children, }: { layout: HudLayoutStore; editChord?: HudEditChord | false; /** Zoom applied to the whole HUD on compact displays. Default 0.85. */ compactScale?: number; /** Opt-in play-phase gate: render the HUD only … — Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine anchor regions and stack automatically with a gap — no per-panel pixel offsets, no manual clearance for sibling panels, the touch-control dock (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the whole surface scales down and each panel applies its `compact` behavior.
731
- - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** Stack … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
732
+ - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", lane, order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
732
733
  - `PlayableGame` (type): type PlayableGame = EnginePlayableGame< ComponentType, ComponentType, RenderEntity, RenderObject, ComponentType<ViewmodelProps>, ComponentType<WorldOverlayProps> > & { /** HUD theme the shell applies at the `GameUI` root. Unset uses `defaultHudTheme`; `false` opts out of the default theme and the en… — The concrete React-bound playable a shell `defineGame` returns and `GameHost` mounts — the one PlayableGame games handle.
733
734
  - `StatBar` (function): function StatBar({ statId = "health", entityId, tone = "health", label, showValue = true, width = 200, icon, style, className, }: { statId?: string; entityId?: string; tone?: StatTone; label?: string; showValue?: boolean; width?: number; icon?: ReactNode; style?: CSSProperties; className?: string; }… — A polished stat bar (health/mana/stamina/shield/xp) — a rounded, glassy meter with a tone-colored fill and an optional value readout. Reads `statId` off `entityId` (defaults to the local player). Renders nothing until the stat exists.
734
735
  - `StoreHandle` (interface): interface StoreHandle<T> — A typed, cast-free handle onto one slot of the reactive game store (`ctx.game.store`). The single type parameter is fixed at definition; `read`/`write`/`update` never widen to `unknown`, so no call site repeats `store.get(key) as T`. Writes flow through the engine store, so they bump `ctx.version()`, serialize into a {@link WorldSnapshot}, and replay under host authority — the same guarantees a hand-rolled `store.get`/`store.set` pair gives up the moment it forks run state into a module-level singleton.
@@ -46,6 +46,7 @@ Existing React games keep their entity store and use the focused
46
46
 
47
47
  - **Refusals and notices are engine feedback, not per-game toasts.** Return `{ reason: "insufficient-gold", message: "Needs 40 more gold" }` from a command's `validate`: the shell shows `message` in the shared notice feed with the deny cue and stacks repeats. Push other player messages with `ctx.game.notices.push({ text, tone })` (info/success/warn/deny) from game logic instead of a hand-built toast list. The stack renders inside the HUD theme root, so it takes the game's `hudTheme` colours and font; a top or bottom stack moves clear of `HudCanvas` panels and touch controls in its column and sits above `ModalHost` (z 85). Tune or hide it with `notices: { placement, offset, avoidHud, zIndex, cues, hidden }` (`hidden` keeps cues and lets the game render `useNoticeList(ctx.game.notices)` itself); `push({ text, silent: true })` skips one notice's sound.
48
48
  - **UI sounds come from `useUiCue()`.** Engine primitives already sound through the shell; for game-owned markup call `cue("click" | "hover" | "open" | "close" | "confirm" | "deny" | "notify")` from buttons and panels; replace a default by declaring `audio.sounds["ui.click"]`, or silence with `audio.uiCues: { hover: false }` / `false`.
49
+ - **A hit, kill, pickup, objective, wave and refusal react without any sound authored.** The shell maps the events the engine already announces (`combat.damage`, `entity.died`, `worldItem.picked_up`, `quest.completed`, `stat.levelUp`, `command.refused`) to a `feel.<cue>` reaction - sound, gamepad pulse, camera kick and caption - and merges procedural defaults on an `sfx` bus. Replace a sound by declaring `audio.sounds["feel.hit"]`, tune or silence with `audio.feedbackCues`, and announce a wave through `attachWaveFeedback(runner, ctx.game.feedback)` or any custom moment with `ctx.game.feedback.cue("wave", "WAVE 3")`. Captions follow the accessibility setting; sound follows the `sfx` bus volume and mute rows in Settings.
49
50
  - Every rejected verb displays its reason on the triggering surface. Use `useServerSession(transport, gameId)` for join lifecycle. Join failure blocks play with `JoinGate` and retry; returning `{ ok: false }` without feedback is incomplete.
50
51
  - Placement previews display claim cost and affordability inline with `TerritoryOverlay`.
51
52
  - `ChatPanel` owns collapsed/expanded state, own-message styling, Enter focus and Escape blur; standalone hosts pass `messages`, `userId`, and `onSend` (typed `{ ok, reason }`), while context-backed games use channel props. Use native inputs so shell typing-target suspension applies.
@@ -786,6 +786,9 @@
786
786
  - `HUD_THEME_PRESETS` (const): const HUD_THEME_PRESETS: { "arcane-stone": HudTheme; "survival-wood": HudTheme; "military-flat": HudTheme; "sleek-hex": HudTheme; "elemental-circle": HudTheme; "sandbox-slot": HudTheme; ember: HudTheme; synthwave: HudTheme; fieldkit: HudTheme; } — Built-in theme presets for demos/scaffold only — not product identity; games author their own theme.
787
787
  - `HealthBar` (const): const HealthBar: (props: AtomicBarProps) => React.JSX.Element — Player/enemy health — danger-flashes to `--jg-health-low` below a quarter full.
788
788
  - `Hotbar` (function): function Hotbar({ inventoryId, activeSlot, keys, slotSize = 46, itemIcon, style, className, }: { inventoryId: string; activeSlot?: number; keys?: readonly string[]; slotSize?: number; /** Caller-supplied item id → icon registry; return null/undefined to fall back to the default glyph. */ itemIcon?: … — A numbered hotbar bound to an inventory — painted iconed slots (a `GameIcon` glyph over a school-keyed gradient with a count badge, #1035), an active-slot highlight, and a keycap per slot. Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one. Supply `itemIcon` to map your item ids to your own glyphs — the default resolves a `GameIcon` from the item id.
789
+ - `HudBlockBounds` (interface): interface HudBlockBounds — A rendered block, as measured from the DOM.
790
+ - `HudBlockOverlap` (interface): interface HudBlockOverlap — One intersection between two rendered HUD blocks.
791
+ - `HudBlockOverlapOptions` (interface): interface HudBlockOverlapOptions — Options for {@link hudBlockOverlaps}.
789
792
  - `HudCanvas` (function): function HudCanvas({ layout, editChord, compactScale, showDuring, className, style, children, }: { layout: HudLayoutStore; editChord?: HudEditChord | false; /** Zoom applied to the whole HUD on compact displays. Default 0.85. */ compactScale?: number; /** Opt-in play-phase gate: render the HUD only … — Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine anchor regions and stack automatically with a gap — no per-panel pixel offsets, no manual clearance for sibling panels, the touch-control dock (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the whole surface scales down and each panel applies its `compact` behavior.
790
793
  - `HudCompactMode` (type): type HudCompactMode = "keep" | "chip" | "hide" — How a panel behaves on compact (phone-scale) displays. `keep` stays visible at the global compact scale, `chip` collapses to a small tap-to-expand pill, `hide` unmounts entirely.
791
794
  - `HudEditChord` (interface): interface HudEditChord { hold: string; press: string } — ⚠ undocumented · used by `HudCanvas`: Full-viewport HUD surface.
@@ -793,9 +796,13 @@
793
796
  - `HudFrameProps` (interface): interface HudFrameProps — Props for {@link HudFrame}.
794
797
  - `HudFrameShape` (type): type HudFrameShape = "rounded" | "circle" | "square" — Frame corner shape — `rounded` (the variation's default radius), `circle` (fully round), or `square` (no radius).
795
798
  - `HudFrameVariation` (type): type HudFrameVariation = "glass" | "plate" | "retro" | "themed" — Frame skin — `glass` (the shared dark-glass panel), `plate` (a heavier opaque plate), `retro` (a hard black-outlined frame), or `themed` (driven by the `--jg-frame-*` `HudTheme` tokens).
799
+ - `HudLaneConfig` (interface): interface HudLaneConfig — How the shell lays out lanes.
800
+ - `HudLaneLayout` (interface): interface HudLaneLayout — The resolved layout of every claimed block.
801
+ - `HudLanePlacement` (interface): interface HudLanePlacement — One block's resolved placement.
802
+ - `HudLaneSlot` (interface): interface HudLaneSlot — One HUD block's claim on a lane. Two blocks that name the same `anchor` and `lane` stack in `order` order; a lane is a column, so lanes in the same anchor sit side by side. The shell resolves every claim into a rectangle, which is what keeps blocks from arranging themselves into each other.
796
803
  - `HudLayoutPersist` (type): type HudLayoutPersist = (id: string, panel: EditorUiPanelLayout) => void — Host-owned callback that commits one HUD panel layout into the scene document (typically an undoable `setUiPanel` dispatch). Injected by the editor so product UI never reaches for editor globals.
797
804
  - `HudLayoutPersistProvider` (function): function HudLayoutPersistProvider({ onPanelCommit, children, }: { onPanelCommit: HudLayoutPersist; children?: ReactNode; }): React.JSX.Element — Provides {@link HudLayoutPersist} / `onPanelCommit` to descendant `useHudLayout` calls. Mount around game UI when a host can accept document UI patches (editor). Absent provider → canvas moves/resizes are no-ops for document writes.
798
- - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** Stack … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
805
+ - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", lane, order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
799
806
  - `HudSkin` (interface): interface HudSkin — A game-authored visual skin layered over a {@link HudTheme}.
800
807
  - `HudSkinMaterial` (interface): interface HudSkinMaterial — Optional raster-backed materials for shared HUD chrome.
801
808
  - `HudSkinPreview` (function): function HudSkinPreview({ className }: { className?: string }): React.JSX.Element — Procedural two-skin fixture for visual regression captures.
@@ -1033,8 +1040,10 @@
1033
1040
  - `defaultHudTheme` (const): const defaultHudTheme: HudTheme — The engine's default HUD look, applied by the shell at the HUD root of every game that does not opt out: a strong system UI font stack, layered translucent panels with a lit top rim and crisp hairline borders, a 12px type floor, and motion that honours reduced-motion. Games override any part with their own theme on a HUD ancestor.
1034
1041
  - `deriveHudTheme` (function): function deriveHudTheme(seed: HudThemeSeed): HudTheme — Derives a coherent full theme from an accent/surface pair — shades, edges, dim text, frames, bar troughs, and slots all fall out of the seed, so a game gets one consistent look across the `packages/react` primitives and the `registry/jgengine` blocks without naming 50 tokens. Hex seeds only (`#rgb` or `#rrggbb`).
1035
1042
  - `eventMeterNeedsHeartbeat` (function): function eventMeterNeedsHeartbeat(meter: EventMeter, previous: EventMeterView | null): boolean — True when `meter`'s value/fraction/tier/ready diverge from `previous` — the re-render check `useEventMeter` polls on its heartbeat.
1043
+ - `formatHudOverlapReport` (function): function formatHudOverlapReport(overlaps: readonly HudBlockOverlap[], viewport: HudSize): string — The console block for a set of overlaps — one line per pair naming both block ids, plus the viewport they collided at. Empty string when nothing intersects.
1036
1044
  - `formatTimerMs` (function): function formatTimerMs(ms: number, format: TimerFormat = "mm:ss"): string — Format a millisecond duration as digital timer text — `mm:ss` (default), `m:ss.d` (with tenths), or `ss.d` (seconds + tenths). Negative inputs clamp to zero. No genre meaning: the same helper serves a round clock, a respawn countdown, or an ability charge readout.
1037
1045
  - `guestIdentity` (function): function guestIdentity(seed?: string): IdentitySource — ⚠ undocumented
1046
+ - `hudBlockOverlaps` (function): function hudBlockOverlaps(blocks: readonly HudBlockBounds[], options: HudBlockOverlapOptions = {}): HudBlockOverlap[] — Every pair of rendered HUD blocks that intersects. This is the development-time check behind the console report: it reads what actually hit the screen rather than what a layout intended, so a block that overflows its own box or a game override that lands on a neighbour is named too.
1038
1047
  - `hudSkinVars` (function): function hudSkinVars(skin?: HudSkin): CSSProperties — Converts a skin into the CSS variables consumed by shared HUD components.
1039
1048
  - `hudTheme` (function): function hudTheme(overrides: HudThemeOverrides): HudTheme — Builds a full {@link HudTheme} from per-group partials, filling the rest from {@link defaultHudTheme}. Reach for this when a game wants to name exact values; use {@link deriveHudTheme} when a seed color pair is enough.
1040
1049
  - `hudThemeVars` (function): function hudThemeVars(theme: HudTheme): CSSProperties — Emits theme CSS properties, resetting inherited bar fills when absent. Spread onto a HUD ancestor.
@@ -1049,6 +1058,7 @@
1049
1058
  - `previewFixtureNames` (function): function previewFixtureNames(): string[] — Sorted list of registered fixture names, for discovery/listing.
1050
1059
  - `relativeSavedAt` (function): function relativeSavedAt(savedAt: number, nowMs = Date.now()): string — Turn a `savedAt` timestamp into a compact relative label ("just now", "4m ago", "2d ago"). Presentation-only helper — the model stores raw ms.
1051
1060
  - `resolveDialogueInvoke` (function): function resolveDialogueInvoke(choice: DialogueChoice, result: CheckResult | null): { command: string; args?: unknown } | null — ⚠ undocumented
1061
+ - `resolveHudLanes` (function): function resolveHudLanes(slots: readonly HudLaneSlot[], viewport: HudSize, config: HudLaneConfig = {}): HudLaneLayout — Resolve lane claims into non-overlapping rectangles for one frame.
1052
1062
  - `resolveHudTheme` (function): function resolveHudTheme(theme?: HudThemePreset | HudTheme): HudTheme — Resolves a preset name (or a full theme) to a `HudTheme`; falls back to the default theme.
1053
1063
  - `resolvePreviewFixture` (function): function resolvePreviewFixture(name: string): PreviewFixture | undefined — Resolve a fixture by name, or `undefined` when it is not registered.
1054
1064
  - `runDialogueChoice` (function): function runDialogueChoice(commands: { run(name: string, input?: unknown): unknown }, choice: DialogueChoice, result: CheckResult | null): void — Route a {@link DialogueBox} choice through the `features.dialogue` bridge: resolve the choice's invoke (honoring a skill-check `result`), run that command, and otherwise close the dialogue — the write side that replaces a per-game `onChoice` that hand-rolls `resolveDialogueInvoke` + `dialogue.close`.
@@ -1533,12 +1543,29 @@
1533
1543
  - `HudInteractProvider` (function): function HudInteractProvider({ onInteract, children }: { onInteract: HudInteractListener; children?: ReactNode }): ReactNode — Routes interactions from every engine HUD primitive below it (buttons, panels, windows, action slots) to one listener — the seam a UI sound or haptics system hooks without per-button wiring. Primitives also expose `data-jg-cue` and a per-instance `onInteract`.
1534
1544
  - `useHudInteract` (function): function useHudInteract(source: string, cue?: string, onInteract?: HudInteractListener): (kind: HudInteractKind) => void — Returns a reporter that calls the instance listener, then the nearest {@link HudInteractProvider}.
1535
1545
 
1546
+ ## @jgengine/react/hudLanes
1547
+
1548
+ - `HudBlockBounds` (interface): interface HudBlockBounds — A rendered block, as measured from the DOM.
1549
+ - `HudBlockOverlap` (interface): interface HudBlockOverlap — One intersection between two rendered HUD blocks.
1550
+ - `HudBlockOverlapOptions` (interface): interface HudBlockOverlapOptions — Options for {@link hudBlockOverlaps}.
1551
+ - `HudLaneConfig` (interface): interface HudLaneConfig — How the shell lays out lanes.
1552
+ - `HudLaneLayout` (interface): interface HudLaneLayout — The resolved layout of every claimed block.
1553
+ - `HudLanePlacement` (interface): interface HudLanePlacement — One block's resolved placement.
1554
+ - `HudLaneSlot` (interface): interface HudLaneSlot — One HUD block's claim on a lane. Two blocks that name the same `anchor` and `lane` stack in `order` order; a lane is a column, so lanes in the same anchor sit side by side. The shell resolves every claim into a rectangle, which is what keeps blocks from arranging themselves into each other.
1555
+ - `formatHudOverlapReport` (function): function formatHudOverlapReport(overlaps: readonly HudBlockOverlap[], viewport: HudSize): string — The console block for a set of overlaps — one line per pair naming both block ids, plus the viewport they collided at. Empty string when nothing intersects.
1556
+ - `hudBlockOverlaps` (function): function hudBlockOverlaps(blocks: readonly HudBlockBounds[], options: HudBlockOverlapOptions = {}): HudBlockOverlap[] — Every pair of rendered HUD blocks that intersects. This is the development-time check behind the console report: it reads what actually hit the screen rather than what a layout intended, so a block that overflows its own box or a game override that lands on a neighbour is named too.
1557
+ - `resolveHudLanes` (function): function resolveHudLanes(slots: readonly HudLaneSlot[], viewport: HudSize, config: HudLaneConfig = {}): HudLaneLayout — Resolve lane claims into non-overlapping rectangles for one frame.
1558
+
1559
+ ## @jgengine/react/hudLanesPreview
1560
+
1561
+ - `HudLanesPreview` (function): function HudLanesPreview({ className }: { className?: string }): React.JSX.Element — The same shape Deepward's top-left HUD uses — two blocks that used to arrange themselves into each other. Both declare `lane="status"`, so the shell stacks them; the second lane sits beside the first and the right-hand lane mirrors it. Screenshots at any viewport without a block landing on a neighbour.
1562
+
1536
1563
  ## @jgengine/react/hudLayout
1537
1564
 
1538
1565
  - `HudCanvas` (function): function HudCanvas({ layout, editChord, compactScale, showDuring, className, style, children, }: { layout: HudLayoutStore; editChord?: HudEditChord | false; /** Zoom applied to the whole HUD on compact displays. Default 0.85. */ compactScale?: number; /** Opt-in play-phase gate: render the HUD only … — Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine anchor regions and stack automatically with a gap — no per-panel pixel offsets, no manual clearance for sibling panels, the touch-control dock (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the whole surface scales down and each panel applies its `compact` behavior.
1539
1566
  - `HudCompactMode` (type): type HudCompactMode = "keep" | "chip" | "hide" — How a panel behaves on compact (phone-scale) displays. `keep` stays visible at the global compact scale, `chip` collapses to a small tap-to-expand pill, `hide` unmounts entirely.
1540
1567
  - `HudEditChord` (interface): interface HudEditChord { hold: string; press: string } — ⚠ undocumented · used by `HudCanvas`: Full-viewport HUD surface.
1541
- - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** Stack … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
1568
+ - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", lane, order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
1542
1569
  - `hudVisibleInPhase` (function): function hudVisibleInPhase(showDuring: readonly GamePhase[] | undefined, phase: GamePhase): boolean — Whether a HUD element opted into `showDuring` is visible in the current phase; `undefined` = always visible (default).
1543
1570
  - `useHudLayout` (function): function useHudLayout(options?: { storageKey?: string; snap?: number; locked?: boolean; /** * Scene-document `ui` section — source of truth for panel placement/size. * When provided, hydrates the layout store (and wins over legacy localStorage). */ documentUi?: EditorUiDocument; /** * When true (def… — Layout state for `HudCanvas` — panel placements, edit-mode drag/resize, and per-game persistence.
1544
1571
 
@@ -2112,15 +2139,64 @@
2112
2139
  - `EntityAudioEmitters` (function): function EntityAudioEmitters({ engine, entitySounds, }: { engine: AudioEngine; entitySounds: Record<string, string> | undefined; }): null — ⚠ undocumented
2113
2140
  - `ObjectAudioEmitters` (function): function ObjectAudioEmitters({ engine, objectSounds, }: { engine: AudioEngine; objectSounds: Record<string, string> | undefined; }): null — ⚠ undocumented
2114
2141
 
2142
+ ## @jgengine/shell/audio/CaptionOverlay
2143
+
2144
+ - `CaptionOverlay` (function): function CaptionOverlay({ feed, max = 2, zIndex = 84, }: { feed: CaptionFeed; /** How many lines to show at once. Default 2. */ max?: number; zIndex?: number; }): ReactNode — The captions line: a bounded, bottom-centred stack that renders only while the accessibility captions setting is on. Reads HudTheme tokens, so a game's theme reskins it, and lives in the same overlay layer as the notice stack.
2145
+
2146
+ ## @jgengine/shell/audio/FeedbackHost
2147
+
2148
+ - `FeedbackHost` (function): function FeedbackHost({ ctx, engine }: { ctx: GameContext; engine: AudioEngine }): ReactNode — Mounts the engine's feel layer for one game: announced events become a sound, a gamepad pulse, a camera kick and a caption line; the audio context unlocks on the first gesture; and the caption stack follows the accessibility setting. Games supply sounds, never the rules.
2149
+
2115
2150
  ## @jgengine/shell/audio/audioEngine
2116
2151
 
2117
2152
  - `AudioEmitterHandle` (interface): interface AudioEmitterHandle { isPlaying?(): boolean; setPosition(position: Vec3): void; setRate(rate: number): void; setGain(gain: number): void; setLowpass(hz: number): void; setHighpass(hz: number): void; setVelocity(velocity: Vec3): void; stop(): void } — ⚠ undocumented
2118
- - `AudioEngine` (interface): interface AudioEngine { setListenerPose(pose: ListenerPose | Vec3): void; setVoiceLimit(maxTotal: number, overflow?: VoiceAllocatorConfig["overflow"]): void; playOneShot(soundId: string, position?: Vec3): void; playLoop(soundId: string, position?: Vec3): AudioEm… — ⚠ undocumented · used by `createAudioEngine`: Game-owned sound catalog playback with bounded SFX reservations, spatial panning, and shared buses.
2153
+ - `AudioEngine` (interface): interface AudioEngine { setListenerPose(pose: ListenerPose | Vec3): void; setVoiceLimit(maxTotal: number, overflow?: VoiceAllocatorConfig["overflow"]): void; play(soundId: string, position?: Vec3): void; playOneShot(soundId: string, position?: Vec3): void; playL… — ⚠ undocumented · used by `createAudioEngine`: Game-owned sound catalog playback with bounded SFX reservations, spatial panning, and shared buses.
2154
+ - `AudioLevel` (interface): interface AudioLevel — Master-output level, `0..1`. Pooled by {@link AudioEngine.measure} - read it, don't retain it.
2119
2155
  - `AudioSceneConfig` (interface): interface AudioSceneConfig { sounds?: Record<string, SoundDef>; maxVoices?: number; voiceOverflow?: VoiceAllocatorConfig["overflow"]; voiceAllocator?: VoiceAllocator; buses?: Record<string, AudioBusDef>; music?: Record<string, MusicTheme>; musicBus?: string } — ⚠ undocumented · used by `createAudioEngine`: Game-owned sound catalog playback with bounded SFX reservations, spatial panning, and shared buses.
2120
2156
  - `ListenerPose` (interface): interface ListenerPose — Position and orientation for a spatial-audio listener.
2157
+ - `MAX_PENDING_CUES` (const): const MAX_PENDING_CUES: 16 — Cues held while the browser keeps the audio context suspended, before any user gesture.
2121
2158
  - `Vec3` (interface): interface Vec3 { x: number; y: number; z: number } — ⚠ undocumented · used by `coverPoints` (@jgengine/core/ai/tacticalQueries): Returns boundary locations that are hidden from a threat.
2122
2159
  - `createAudioEngine` (function): function createAudioEngine(config: AudioSceneConfig = {}): AudioEngine — Game-owned sound catalog playback with bounded SFX reservations, spatial panning, and shared buses.
2123
2160
 
2161
+ ## @jgengine/shell/audio/audioProbe
2162
+
2163
+ - `AudioProbe` (interface): interface AudioProbe — The read-only handle a capture script or devtools uses to prove the mix is producing sound.
2164
+ - `installAudioProbe` (function): function installAudioProbe(engine: AudioEngine): () => void — Publish the audio engine's own level readout on `window.__jgengineAudio`, so a headless capture can state a measured RMS instead of guessing from a screenshot. Reads only; it never plays or changes a bus.
2165
+
2166
+ ## @jgengine/shell/audio/busMix
2167
+
2168
+ - `MASTER_MUTE_SETTING_ID` (const): const MASTER_MUTE_SETTING_ID: "sound.muted" — Persisted mute for the whole mix, next to `sound.master`.
2169
+ - `busMuteSettingId` (function): function busMuteSettingId(busId: string): string — Persisted mute for one bus, next to its `busVolumeSettingId(busId)` volume.
2170
+
2171
+ ## @jgengine/shell/audio/captions
2172
+
2173
+ - `Caption` (interface): interface Caption — One caption line as the overlay renders it.
2174
+ - `CaptionFeed` (interface): interface CaptionFeed — A capped, self-expiring list of caption lines that the accessibility captions setting turns on.
2175
+ - `CaptionFeedConfig` (interface): interface CaptionFeedConfig — Tunables for {@link createCaptionFeed}; all retunable at runtime.
2176
+ - `createCaptionFeed` (function): function createCaptionFeed(config: CaptionFeedConfig = {}): CaptionFeed — The caption list the feedback seam writes to and the HUD overlay reads: bounded, self-expiring, and deduped so three hits read as "Hit x3" rather than three copies. Pure state with an injected clock, so it runs headless.
2177
+
2178
+ ## @jgengine/shell/audio/eventFeedback
2179
+
2180
+ - `AnyFeedbackBinding` (type): type AnyFeedbackBinding = { [Name in FeedbackEventName]: FeedbackBinding<Name> }[FeedbackEventName] — Any row of the event mapping, whichever event it binds.
2181
+ - `DEFAULT_FEEDBACK_BINDINGS` (const): const DEFAULT_FEEDBACK_BINDINGS: readonly AnyFeedbackBinding[] — The default mapping: each engine-announced gameplay event to the cue it raises. Damage to the local player reads as hurt, damage anywhere else as a hit; a wave start has no event of its own, so a game announces it through `notify("wave")` or `attachWaveFeedback`.
2182
+ - `DEFAULT_FEEDBACK_REACTIONS` (const): const DEFAULT_FEEDBACK_REACTIONS: Readonly<Record<FeedbackCue, FeedbackReaction>> — The default reactions. They are data: a game overrides one through `audio.feedbackCues` (sound) or `createEventFeedback({ reactions })` (haptics, shake, caption), and replaces the mapping through `bindings` without touching the shell.
2183
+ - `EventFeedback` (interface): interface EventFeedback — The event-to-reaction seam. One instance per running world.
2184
+ - `EventFeedbackOptions` (interface): interface EventFeedbackOptions — Options a game or host may override.
2185
+ - `FeedbackAnnouncer` (interface): interface FeedbackAnnouncer — The slice of `ctx.game.feedback` a wave runner needs, so this runs headless in a test.
2186
+ - `FeedbackBinding` (type): type FeedbackBinding<Name extends FeedbackEventName = FeedbackEventName> = { event: Name; cue: FeedbackCue; /** Default `"any"`. `"local"` only fires for the local player's own target/user/refusal. */ scope?: FeedbackScope; /** Overrides the cue's caption for this event, e.g. "You died". */ caption?… — One row of the event mapping: `event` announces, `cue` reacts.
2187
+ - `FeedbackDeps` (interface): interface FeedbackDeps — Everything the feedback seam reaches outside itself. Injected so it runs headless.
2188
+ - `FeedbackEventName` (type): type FeedbackEventName = | "combat.damage" | "entity.died" | "worldItem.picked_up" | "quest.completed" | "stat.levelUp" | "command.refused" — Event names the default mapping listens to; a game may add its own through `bindings`.
2189
+ - `FeedbackReaction` (interface): interface FeedbackReaction — One cue's reaction: what plays, what the hand feels, what the screen does, and what a caption says.
2190
+ - `FeedbackScope` (type): type FeedbackScope = "local" | "remote" | "any" — Which side of an event a binding reacts to: the local player's, someone else's, or either.
2191
+ - `ResolvedFeedback` (interface): interface ResolvedFeedback — A resolved trigger: pooled per binding, so a busy event stream allocates nothing to decide.
2192
+ - `attachEventFeedback` (function): function attachEventFeedback(events: Pick<GameEvents, "on">, feedback: EventFeedback, eventsToBind: readonly FeedbackEventName[] = Object.keys( DEFAULT_FEEDBACK_BINDINGS.reduce<Record<string, true>>((names, binding) => { names[binding.event] = true; return names; }, {}), ) as FeedbackEventName[]): (… — Subscribe the feedback seam to a game's event bus, including `feedback.cue` for moments the game announces itself. Returns the detach function; the mapping and reactions stay override-able through the seam's `retune`.
2193
+ - `attachWaveFeedback` (function): function attachWaveFeedback(runner: Pick<WaveRunner, "state" | "subscribe">, announce: FeedbackAnnouncer): () => void — Announce each new wave a {@link WaveRunner} starts as the `wave` cue, once per boundary. A wave has no event of its own, so this is the engine-side hookup for tower defense and wave survival games: `attachWaveFeedback(runner, ctx.game.feedback)`. Nothing is polled, the runner's own notification drives it, and one `update` that crosses two waves still fires once - the cue has a cooldown and the next boundary is announced by the next crossing.
2194
+ - `createEventFeedback` (function): function createEventFeedback(deps: FeedbackDeps, options: EventFeedbackOptions = {}): EventFeedback — Build the event-to-sound/haptic/shake/caption seam. The mapping is data ({@link DEFAULT_FEEDBACK_BINDINGS}), reactions are data ({@link DEFAULT_FEEDBACK_REACTIONS}), and the resolved trigger for each binding is allocated once - handling an event allocates only the caption string it actually shows, never per frame.
2195
+
2196
+ ## @jgengine/shell/audio/gestureResume
2197
+
2198
+ - `installGestureResume` (function): function installGestureResume(engine: Pick<AudioEngine, "resume">, target: Pick<Document, "addEventListener" | "removeEventListener"> | null = typeof document === "undefined" ? null : document): () => void — Resume the audio context on the first real user gesture. Chrome keeps a freshly created `AudioContext` suspended until the page sees a trusted input, so pressing a key, tapping or clicking is the only moment sound may start; the engine then flushes any cue it held. One listener set per game, removed on unmount.
2199
+
2124
2200
  ## @jgengine/shell/audio/loopParams
2125
2201
 
2126
2202
  - `MAX_LOOP_CUTOFF` (const): const MAX_LOOP_CUTOFF: 22050 — Highest live filter cutoff, Hz; the engine also caps it at the context's Nyquist frequency.
@@ -64,6 +64,18 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
64
64
 
65
65
  - `readModelAnimationParams` (function) · `import { readModelAnimationParams } from "@jgengine/shell/render/useModelAnimation"`
66
66
 
67
+ ## attach-event-feedback — one call wires every announced combat/loot/objective/refusal event to its reaction
68
+
69
+ - `attachEventFeedback` (function) · `import { attachEventFeedback } from "@jgengine/shell/audio/eventFeedback"`
70
+
71
+ ## attach-wave-feedback — fire the wave cue once whenever a wave runner crosses into a new wave
72
+
73
+ - `attachWaveFeedback` (function) · `import { attachWaveFeedback } from "@jgengine/shell/audio/eventFeedback"`
74
+
75
+ ## audio-probe — read the live master RMS/peak and bus gains from a running page for capture and diagnostics
76
+
77
+ - `installAudioProbe` (function) · `import { installAudioProbe } from "@jgengine/shell/audio/audioProbe"`
78
+
67
79
  ## authored-flocks — render authored habitat agents through caller-selected animated species models with velocity orientation and bounded model lifetime
68
80
 
69
81
  - `AuthoredFlocks` (function) · `import { AuthoredFlocks } from "@jgengine/shell/world/AuthoredFlocks"`
@@ -114,6 +126,14 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
114
126
 
115
127
  - `isCameraOccluderTransparent` (function) · `import { isCameraOccluderTransparent } from "@jgengine/shell/camera"`
116
128
 
129
+ ## caption-feed — bounded, self-expiring, deduped caption lines for the accessibility captions overlay
130
+
131
+ - `createCaptionFeed` (function) · `import { createCaptionFeed } from "@jgengine/shell/audio/captions"`
132
+
133
+ ## caption-overlay — render the caption feed as a themed, screen-reader-announced line stack gated by the captions accessibility setting
134
+
135
+ - `CaptionOverlay` (function) · `import { CaptionOverlay } from "@jgengine/shell/audio/CaptionOverlay"`
136
+
117
137
  ## capture-canvas — read the live R3F frame to a PNG data URL (needs preserveDrawingBuffer)
118
138
 
119
139
  - `captureCanvas` (function) · `import { captureCanvas } from "@jgengine/shell/render/sceneCapture"`
@@ -285,6 +305,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
285
305
 
286
306
  - `EntitySummary` (function) · `import { EntitySummary } from "@jgengine/react"`
287
307
 
308
+ ## event-feedback — map announced gameplay events (damage, kill, pickup, objective, wave, refusal) to a sound, gamepad pulse, camera shake and caption through one data mapping
309
+
310
+ - `createEventFeedback` (function) · `import { createEventFeedback } from "@jgengine/shell/audio/eventFeedback"`
311
+
288
312
  ## event-meter-hud — render a core event/heat meter's live value, fraction, tier, and ready state in a HUD gauge
289
313
 
290
314
  - `useEventMeter` (function) · `import { useEventMeter } from "@jgengine/react"`
@@ -293,6 +317,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
293
317
 
294
318
  - `FastTravelMenu` (function) · `import { FastTravelMenu } from "@jgengine/react"`
295
319
 
320
+ ## feedback-host — mount event feedback, gesture-gated audio and captions for a running game in one component
321
+
322
+ - `FeedbackHost` (function) · `import { FeedbackHost } from "@jgengine/shell/audio/FeedbackHost"`
323
+
296
324
  ## floating-text — overlay that renders on-screen floating combat text with per-kind color, alpha, and pop-in scale
297
325
 
298
326
  - `FloatingText` (function) · `import { FloatingText } from "@jgengine/react"`
@@ -309,6 +337,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
309
337
 
310
338
  - `GameContextBridge` (function) · `import { GameContextBridge } from "@jgengine/react"`
311
339
 
340
+ ## gesture-resume — unlock the audio context on the first pointer/key/touch gesture, so pre-gesture cues play instead of vanishing
341
+
342
+ - `installGestureResume` (function) · `import { installGestureResume } from "@jgengine/shell/audio/gestureResume"`
343
+
312
344
  ## ground-edge-fade — fade merged ground layers into the terrain at their outer edge, keeping inner seams crisp
313
345
 
314
346
  - `applyGroundEdgeFade` (function) · `import { applyGroundEdgeFade } from "@jgengine/shell/terrain"`
@@ -359,6 +391,11 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
359
391
 
360
392
  - `HudInteractProvider` (function) · `import { HudInteractProvider } from "@jgengine/react/hudInteract"`
361
393
 
394
+ ## hud-lanes — two HUD blocks stacked in one lane, plus a second lane beside it
395
+
396
+ - `HudLanesPreview` (function) · `import { HudLanesPreview } from "@jgengine/react/hudLanesPreview"`
397
+ - `resolveHudLanes` (function) · `import { resolveHudLanes } from "@jgengine/react"`
398
+
362
399
  ## hud-layout — persistent, live-editable HUD panel layout state for `HudCanvas`
363
400
 
364
401
  - `useHudLayout` (function) · `import { useHudLayout } from "@jgengine/react"`
@@ -371,6 +408,11 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
371
408
 
372
409
  - `useHudLayoutPersist` (function) · `import { useHudLayoutPersist } from "@jgengine/react"`
373
410
 
411
+ ## hud-overlap-report — format the intersecting HUD block pairs for a development console
412
+
413
+ - `formatHudOverlapReport` (function) · `import { formatHudOverlapReport } from "@jgengine/react"`
414
+ - `hudBlockOverlaps` (function) · `import { hudBlockOverlaps } from "@jgengine/react"`
415
+
374
416
  ## hud-panel — anchored HUD slot — drop any widget in a named, player-movable panel
375
417
 
376
418
  - `HudPanel` (function) · `import { HudPanel } from "@jgengine/react"`
@@ -351,7 +351,7 @@ Canyon Chase is the worked example: `movement: { axis: "horizontal" }` steering,
351
351
  **`presentation: "hud"` games get 3D parity.** A pure-HUD game (no camera rig) now reaches the same input/audio seams as a 3D game:
352
352
  - **Touch gestures** — the shell mounts a headless `TouchPlaySurface` in the hud branch too, so `touch.gestures` (swipe/tap) reach actions without the game hand-wiring pointer events on its own canvas. The visible dock stays game-authored per the rule above.
353
353
  - **No phantom reservations** — camera action names (`turnLeft`/`turnRight`/`interact`/…) are reserved *only* when a camera rig is active, so a hud game may bind them directly instead of renaming to `steer*`.
354
- - **Audio actually plays** — audio resumes on the first pointer gesture in hud games (not just 3D), and `playOneShot` self-resumes the suspended context. Trigger sound from anywhere holding `ctx` via `ctx.game.audio.play(soundId, at?)` / `ctx.game.audio.resume()` — the reachable seam over the shell's audio engine.
354
+ - **Audio actually plays** - audio resumes on the first pointer gesture in hud games (not just 3D), and `playOneShot` self-resumes the suspended context. Cues played before any gesture wait in a bounded queue and play on the first real input instead of vanishing. Trigger sound from anywhere holding `ctx` via `ctx.game.audio.play(soundId, at?)` / `ctx.game.audio.resume()` - the reachable seam over the shell's audio engine. Bus gain and mute compose (`engine.setBusMute`), so a muted bus keeps the player's chosen volume for unmute, and Settings carries per-bus and master mute. `ctx.game.feedback.cue("wave", "WAVE 3")` raises the engine's own reaction for a moment no event covers.
355
355
 
356
356
  ## 10. Progressive instruction
357
357