@jgengine/react 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -26,6 +26,38 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.31.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.31.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.28.0`; `@jgengine/github` is `0.18.0`.
34
+
35
+ ### Added
36
+
37
+ - `createAgent` (`@jgengine/core/agents/agent`) runs one resident's whole loop on the world clock: needs decay through `sim/needs`, a utility brain picks among the targets the game offers, an authored day plan says where the agent belongs, and the agent falls back to a declared target when its pick disappears. Mood, action and the far flag snapshot and restore with the needs.
38
+ - `createMoodReader` / `readMood` (`@jgengine/core/agents/mood`) derive a 0-100 mood and a caller-labelled tier from weighted need satisfaction, plus named sources a queue, a litter patch or a cold snap raises and clears.
39
+ - `createAgentSchedule` (`@jgengine/core/agents/schedule`) resolves an authored day plan through `sim/routine` and adds a work beat: a stop names the places it patrols, so a janitor is an agent with a route rather than a special case. Off-hours behaviour is a stop without a beat.
40
+ - `createRelationshipBook` (`@jgengine/core/agents/relationships`) keeps undirected familiarity and opinion per pair. A social action moves both and reports the milestones crossed, friends prefer each other's company, and a bonded pair refuses to split. Pair keys come from `relation/keyedValues` and crossings from `relation/thresholds`.
41
+ - `GameSave.deleteSlot(slot)` and `SaveStore.deleteSlot(slot)` remove one slot's payload and drop it from the slot index; they resolve `true` when a document was removed and `false` for an unknown slot. Deleting the active slot resets the live state to the initial document. `load()` no longer lists a slot that holds nothing, so a deleted slot cannot return.
42
+ - `HudPanel` takes `lane` (`@jgengine/react`): blocks naming the same anchor and lane stack without overlap, lanes in one anchor sit side by side, and `resolveHudLanes` resolves the rectangles. A moved panel leaves its lane and keeps its authored placement.
43
+ - `hudBlockOverlaps` and `formatHudOverlapReport` (`@jgengine/react`) name every pair of rendered HUD blocks whose rectangles intersect, with both ids and the viewport; `HudCanvas` warns with that report in a dev build.
44
+ - `rollDrop` (`@jgengine/core/loot/drop`) rolls one deterministic item from a seed and `compareToEquipped` turns it into per-stat deltas against worn gear with a weighted verdict a HUD can render.
45
+ - `createRunHaul` (`@jgengine/core/loot/haul`) gates carried loot by weight through the shared encumbrance rules, banks what the persistent stash fits, and discards the run's haul when the game calls `fail`.
46
+ - `salvageItem` and `createItemLedger` (`@jgengine/core/loot/salvage`) break an item down for the currency its level and rarity say and claim the instance, so a salvaged item cannot be salvaged, equipped or sold again.
47
+ - `stockVendor` and `createVendorRotation` (`@jgengine/core/loot/vendor`) fill a `ShopStock` with rolled items priced through the economy wallet, with a run-count restock rule the game authors.
48
+ - `markerProximity` on `createMissionRuntime` picks the plane a marker proximity test compares in: `"horizontal"` (default) measures ground distance and allows the actor a `tolerance` (default 8 m) above or below the marker, `"spatial"` is the straight 3D check for worlds with stacked platforms. `MissionMarkerPlane`, `MissionMarkerProximity` and `DEFAULT_MARKER_TOLERANCE` are exported with it.
49
+
50
+ ### Changed
51
+
52
+ - `evaluatePressure` (`@jgengine/core/ai/director/pressure`) reads pressure as player control: a healthy player further into a run is leaned on harder, a hurt player is eased off, and every effect stays inside the rule's `maxTier`.
53
+ - Loot bases and affixes take `minLevel`, so `rollLoot` only draws from entries at or below the item level. Loot bases take `mass`; rolled items carry it for carry checks.
54
+
55
+ ### Fixed
56
+
57
+ - `HudCanvas` resolved its host with `canvas.parentElement`, which is `HudThemeRoot`'s `display: contents` element and measures 0x0 — the boundary overlay, the HUD-fit measurement and the new overlap report never ran under the standard HUD root. It now uses the nearest ancestor with a real box.
58
+ - The HUD boundary check returned early whenever nothing overflowed, so a later report in the same pass could never run.
59
+ - `reach`, `interact` and the `left-area` fail condition compared a marker in 3D, so a marker authored at `y=0` under rising ground was unreachable. They now read the same plane.
60
+
29
61
  ## 0.30.0
30
62
 
31
63
  ### 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.31.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.31.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.31.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -285,7 +285,7 @@
285
285
 
286
286
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
287
287
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
288
- - `VERSION` (const): const VERSION: "0.30.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.31.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -728,7 +728,7 @@
728
728
  - `GameLoop` (interface): interface GameLoop<TContext = unknown> — Lifecycle hooks a game implements to drive init, per-tick simulation, and player join/leave.
729
729
  - `Hotbar` (function): function Hotbar({ inventoryId, activeSlot, keys, slotSize = 46, itemIcon, style, className, }: { inventoryId: string; activeSlot?: number; keys?: readonly string[]; slotSize?: number; /** Caller-supplied item id → icon registry; return null/undefined to fall back to the default glyph. */ itemIcon?: … — A numbered hotbar bound to an inventory — painted iconed slots (a `GameIcon` glyph over a school-keyed gradient with a count badge, #1035), an active-slot highlight, and a keycap per slot. Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one. Supply `itemIcon` to map your item ids to your own glyphs — the default resolves a `GameIcon` from the item id.
730
730
  - `HudCanvas` (function): function HudCanvas({ layout, editChord, compactScale, showDuring, className, style, children, }: { layout: HudLayoutStore; editChord?: HudEditChord | false; /** Zoom applied to the whole HUD on compact displays. Default 0.85. */ compactScale?: number; /** Opt-in play-phase gate: render the HUD only … — Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine anchor regions and stack automatically with a gap — no per-panel pixel offsets, no manual clearance for sibling panels, the touch-control dock (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the whole surface scales down and each panel applies its `compact` behavior.
731
- - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** Stack … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
731
+ - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", lane, order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
732
732
  - `PlayableGame` (type): type PlayableGame = EnginePlayableGame< ComponentType, ComponentType, RenderEntity, RenderObject, ComponentType<ViewmodelProps>, ComponentType<WorldOverlayProps> > & { /** HUD theme the shell applies at the `GameUI` root. Unset uses `defaultHudTheme`; `false` opts out of the default theme and the en… — The concrete React-bound playable a shell `defineGame` returns and `GameHost` mounts — the one PlayableGame games handle.
733
733
  - `StatBar` (function): function StatBar({ statId = "health", entityId, tone = "health", label, showValue = true, width = 200, icon, style, className, }: { statId?: string; entityId?: string; tone?: StatTone; label?: string; showValue?: boolean; width?: number; icon?: ReactNode; style?: CSSProperties; className?: string; }… — A polished stat bar (health/mana/stamina/shield/xp) — a rounded, glassy meter with a tone-colored fill and an optional value readout. Reads `statId` off `entityId` (defaults to the local player). Renders nothing until the stat exists.
734
734
  - `StoreHandle` (interface): interface StoreHandle<T> — A typed, cast-free handle onto one slot of the reactive game store (`ctx.game.store`). The single type parameter is fixed at definition; `read`/`write`/`update` never widen to `unknown`, so no call site repeats `store.get(key) as T`. Writes flow through the engine store, so they bump `ctx.version()`, serialize into a {@link WorldSnapshot}, and replay under host authority — the same guarantees a hand-rolled `store.get`/`store.set` pair gives up the moment it forks run state into a module-level singleton.
@@ -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
 
@@ -359,6 +359,11 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
359
359
 
360
360
  - `HudInteractProvider` (function) · `import { HudInteractProvider } from "@jgengine/react/hudInteract"`
361
361
 
362
+ ## hud-lanes — two HUD blocks stacked in one lane, plus a second lane beside it
363
+
364
+ - `HudLanesPreview` (function) · `import { HudLanesPreview } from "@jgengine/react/hudLanesPreview"`
365
+ - `resolveHudLanes` (function) · `import { resolveHudLanes } from "@jgengine/react"`
366
+
362
367
  ## hud-layout — persistent, live-editable HUD panel layout state for `HudCanvas`
363
368
 
364
369
  - `useHudLayout` (function) · `import { useHudLayout } from "@jgengine/react"`
@@ -371,6 +376,11 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
371
376
 
372
377
  - `useHudLayoutPersist` (function) · `import { useHudLayoutPersist } from "@jgengine/react"`
373
378
 
379
+ ## hud-overlap-report — format the intersecting HUD block pairs for a development console
380
+
381
+ - `formatHudOverlapReport` (function) · `import { formatHudOverlapReport } from "@jgengine/react"`
382
+ - `hudBlockOverlaps` (function) · `import { hudBlockOverlaps } from "@jgengine/react"`
383
+
374
384
  ## hud-panel — anchored HUD slot — drop any widget in a named, player-movable panel
375
385
 
376
386
  - `HudPanel` (function) · `import { HudPanel } from "@jgengine/react"`