@jgengine/react 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -26,6 +26,59 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.31.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.31.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.28.0`; `@jgengine/github` is `0.18.0`.
34
+
35
+ ### Added
36
+
37
+ - `createAgent` (`@jgengine/core/agents/agent`) runs one resident's whole loop on the world clock: needs decay through `sim/needs`, a utility brain picks among the targets the game offers, an authored day plan says where the agent belongs, and the agent falls back to a declared target when its pick disappears. Mood, action and the far flag snapshot and restore with the needs.
38
+ - `createMoodReader` / `readMood` (`@jgengine/core/agents/mood`) derive a 0-100 mood and a caller-labelled tier from weighted need satisfaction, plus named sources a queue, a litter patch or a cold snap raises and clears.
39
+ - `createAgentSchedule` (`@jgengine/core/agents/schedule`) resolves an authored day plan through `sim/routine` and adds a work beat: a stop names the places it patrols, so a janitor is an agent with a route rather than a special case. Off-hours behaviour is a stop without a beat.
40
+ - `createRelationshipBook` (`@jgengine/core/agents/relationships`) keeps undirected familiarity and opinion per pair. A social action moves both and reports the milestones crossed, friends prefer each other's company, and a bonded pair refuses to split. Pair keys come from `relation/keyedValues` and crossings from `relation/thresholds`.
41
+ - `GameSave.deleteSlot(slot)` and `SaveStore.deleteSlot(slot)` remove one slot's payload and drop it from the slot index; they resolve `true` when a document was removed and `false` for an unknown slot. Deleting the active slot resets the live state to the initial document. `load()` no longer lists a slot that holds nothing, so a deleted slot cannot return.
42
+ - `HudPanel` takes `lane` (`@jgengine/react`): blocks naming the same anchor and lane stack without overlap, lanes in one anchor sit side by side, and `resolveHudLanes` resolves the rectangles. A moved panel leaves its lane and keeps its authored placement.
43
+ - `hudBlockOverlaps` and `formatHudOverlapReport` (`@jgengine/react`) name every pair of rendered HUD blocks whose rectangles intersect, with both ids and the viewport; `HudCanvas` warns with that report in a dev build.
44
+ - `rollDrop` (`@jgengine/core/loot/drop`) rolls one deterministic item from a seed and `compareToEquipped` turns it into per-stat deltas against worn gear with a weighted verdict a HUD can render.
45
+ - `createRunHaul` (`@jgengine/core/loot/haul`) gates carried loot by weight through the shared encumbrance rules, banks what the persistent stash fits, and discards the run's haul when the game calls `fail`.
46
+ - `salvageItem` and `createItemLedger` (`@jgengine/core/loot/salvage`) break an item down for the currency its level and rarity say and claim the instance, so a salvaged item cannot be salvaged, equipped or sold again.
47
+ - `stockVendor` and `createVendorRotation` (`@jgengine/core/loot/vendor`) fill a `ShopStock` with rolled items priced through the economy wallet, with a run-count restock rule the game authors.
48
+ - `markerProximity` on `createMissionRuntime` picks the plane a marker proximity test compares in: `"horizontal"` (default) measures ground distance and allows the actor a `tolerance` (default 8 m) above or below the marker, `"spatial"` is the straight 3D check for worlds with stacked platforms. `MissionMarkerPlane`, `MissionMarkerProximity` and `DEFAULT_MARKER_TOLERANCE` are exported with it.
49
+
50
+ ### Changed
51
+
52
+ - `evaluatePressure` (`@jgengine/core/ai/director/pressure`) reads pressure as player control: a healthy player further into a run is leaned on harder, a hurt player is eased off, and every effect stays inside the rule's `maxTier`.
53
+ - Loot bases and affixes take `minLevel`, so `rollLoot` only draws from entries at or below the item level. Loot bases take `mass`; rolled items carry it for carry checks.
54
+
55
+ ### Fixed
56
+
57
+ - `HudCanvas` resolved its host with `canvas.parentElement`, which is `HudThemeRoot`'s `display: contents` element and measures 0x0 — the boundary overlay, the HUD-fit measurement and the new overlap report never ran under the standard HUD root. It now uses the nearest ancestor with a real box.
58
+ - The HUD boundary check returned early whenever nothing overflowed, so a later report in the same pass could never run.
59
+ - `reach`, `interact` and the `left-area` fail condition compared a marker in 3D, so a marker authored at `y=0` under rising ground was unreachable. They now read the same plane.
60
+
61
+ ## 0.30.0
62
+
63
+ ### Migrate
64
+
65
+ - **Bump lockstep SDK packages to `^0.30.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.27.0`; `@jgengine/github` is `0.17.0`.
66
+
67
+ ### Added
68
+
69
+ - `ColorblindFrame` (`@jgengine/react/accessibility`) wraps the 3D canvas and applies the player's colour-vision mode to that box alone, so HUD, menu and caption text keep their own contrast. `colorblindFrameFilter` (`@jgengine/core/ui/accessibility`) resolves a mode to the CSS filter, and `useColorblindMode` reads the mode from the settings store the settings rows write; the 3D shell does both for the mounted game.
70
+ - `SettingsSection` (`@jgengine/react/settingsBlocks`) is one `useMenuNav` menu: a d-pad or left stick moves between rows and left/right steps the focused value — slider by its step, toggle flipped, select to the next option, confirm arms a keybind — through the same `onAdjust` path the arrow keys use. `onAdjust` (`@jgengine/react/menuNav`) is the option that makes left/right adjust instead of move.
71
+ - `createStanding` (`@jgengine/core/progression/standing`) holds per-faction or per-district standing over authored tracks and moves it from named outcomes, so an outcome that resolves to nothing throws at the call instead of silently moving nothing.
72
+ - `createGateSet` (`@jgengine/core/progression/gates`) evaluates authored milestones against standing and money, and refuses to load when a gate's unlock id resolves to nothing.
73
+ - `createCareerBank` (`@jgengine/core/progression/careerBank`) keeps persistent career money apart from a run's haul, so a failed run loses the haul and keeps the bank.
74
+ - `createUpgradeTree` (`@jgengine/core/progression/upgrades`) prices each rank from the shared curve primitive and gates purchases on prerequisites and standing.
75
+
76
+ ### Changed
77
+
78
+ - `AccessibilityProvider` (`@jgengine/react/accessibility`) sets its data attributes and text-scale var without filtering its own subtree. A colour matrix over the HUD and menu root recoloured the text the rest of the UI assumes, so wrap the game canvas in `ColorblindFrame` instead; a custom root can still offset it with its own `ColorblindFilters` and `filter: url(#jg-cb-<mode>)`.
79
+ - World health bars and nameplates derive their range from the render camera and the target height when a game sets no `maxDistance`: an overhead camera reaches the units it looks down at, and a bar is kept while the target it annotates still covers 18 px, instead of hiding every target past a fixed 60 units.
80
+ - A configured `worldHealthBars.maxDistance` or `nameplates.maxDistance` that hides every target in view now warns once with the nearest measured distance and the prop to raise, instead of rendering nothing.
81
+
29
82
  ## 0.29.0
30
83
 
31
84
  ### Migrate
@@ -1,5 +1,6 @@
1
1
  import { type CSSProperties, type ReactNode } from "react";
2
- import { type AccessibilityState, type AccessibilityStore } from "@jgengine/core/ui/accessibility";
2
+ import { type AccessibilityState, type AccessibilityStore, type ColorblindMode } from "@jgengine/core/ui/accessibility";
3
+ import type { SettingsStore } from "@jgengine/core/settings/settingsModel";
3
4
  interface AccessibilityContextValue {
4
5
  state: AccessibilityState;
5
6
  store: AccessibilityStore;
@@ -8,21 +9,53 @@ interface AccessibilityContextValue {
8
9
  export declare function useAccessibility(): AccessibilityContextValue;
9
10
  /**
10
11
  * Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that
11
- * `AccessibilityProvider` references by `filter: url(#jg-cb-<mode>)`. Rendered
12
- * automatically inside the provider; export standalone for custom roots.
12
+ * `filter: url(#jg-cb-<mode>)` references. Mounted by {@link ColorblindFrame};
13
+ * export it standalone for a root that applies the filter itself.
13
14
  *
14
15
  * @capability colorblind-filters SVG feColorMatrix defs (protanopia/deuteranopia/tritanopia/grayscale) referenced by the accessibility colorblind filter
15
16
  */
16
17
  export declare function ColorblindFilters(): ReactNode;
18
+ /** Props for {@link ColorblindFrame}. */
19
+ export interface ColorblindFrameProps {
20
+ /** The player's colour-vision mode. `none` (default) renders the frame untouched. */
21
+ mode?: ColorblindMode;
22
+ children?: ReactNode;
23
+ className?: string;
24
+ style?: CSSProperties;
25
+ }
26
+ /**
27
+ * Apply the player's colour-vision mode to one rendered frame: a box carrying
28
+ * `filter: url(#jg-cb-<mode>)` plus the filter defs it references. Put it around the canvas of a 3D
29
+ * game and nothing else — the filter is one compositor pass over that box's pixels, so HUD, menu and
30
+ * caption text outside it keep the contrast and hues the rest of the UI assumes.
31
+ *
32
+ * The filter needs no framebuffer copy and no extra WebGL draw: the browser filters the canvas layer
33
+ * as it composites, so switching modes costs nothing per frame beyond that pass. Gameplay stays
34
+ * unaffected; the cost is one full-screen colour-matrix pass while a mode other than `none` is set.
35
+ *
36
+ * @capability colorblind-frame apply the player's colour-vision mode to the 3D frame alone, leaving HUD and menu text unfiltered
37
+ */
38
+ export declare function ColorblindFrame({ mode, children, className, style }: ColorblindFrameProps): ReactNode;
39
+ /**
40
+ * The live colour-vision mode from a settings store — the same store the settings rows write, so the
41
+ * frame follows the player's choice the moment they pick it. Pass the store the shell owns; read
42
+ * `useAccessibilitySettings().colorblind` instead when a `SettingsProvider` is above the caller.
43
+ *
44
+ * @capability colorblind-frame-mode read the live colour-vision mode from the settings store that the settings rows write
45
+ */
46
+ export declare function useColorblindMode(store: SettingsStore): ColorblindMode;
17
47
  /**
18
48
  * Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for
19
- * text to scale off, sets `data-reduced-motion` / `data-high-contrast` /
20
- * `data-colorblind` / `data-captions` for CSS to respond to, and wraps the tree
21
- * in the selected colorblind `feColorMatrix` filter. Reads a
49
+ * text to scale off, and sets `data-reduced-motion` / `data-high-contrast` /
50
+ * `data-colorblind` / `data-captions` for CSS to respond to. Reads a
22
51
  * `createAccessibilityStore` and re-renders on change; provides the state to
23
52
  * `useAccessibility`.
24
53
  *
25
- * @capability accessibility-provider apply accessibility preferences to a subtree — text-scale CSS var, reduced-motion/high-contrast/colorblind/captions data attrs, and the colorblind color-matrix filter
54
+ * It deliberately does not filter its own subtree: this is the HUD and menu root, and a colour matrix
55
+ * over text costs the contrast the rest of the UI was designed around. Wrap the 3D canvas in
56
+ * {@link ColorblindFrame} instead.
57
+ *
58
+ * @capability accessibility-provider apply accessibility preferences to a subtree — text-scale CSS var and reduced-motion/high-contrast/colorblind/captions data attrs
26
59
  */
27
60
  export declare function AccessibilityProvider({ store, children, className, style, }: {
28
61
  store: AccessibilityStore;
@@ -1,6 +1,7 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { createContext, useContext, useSyncExternalStore } from "react";
3
- import { COLORBLIND_MATRICES, } from "@jgengine/core/ui/accessibility";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useContext, useSyncExternalStore, } from "react";
3
+ import { COLORBLIND_MATRICES, colorblindFrameFilter, } from "@jgengine/core/ui/accessibility";
4
+ import { readColorblind } from "@jgengine/core/settings/accessibilitySettings";
4
5
  const AccessibilityContext = createContext(null);
5
6
  const FILTER_MODES = [
6
7
  "protanopia",
@@ -17,31 +18,58 @@ export function useAccessibility() {
17
18
  }
18
19
  /**
19
20
  * Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that
20
- * `AccessibilityProvider` references by `filter: url(#jg-cb-<mode>)`. Rendered
21
- * automatically inside the provider; export standalone for custom roots.
21
+ * `filter: url(#jg-cb-<mode>)` references. Mounted by {@link ColorblindFrame};
22
+ * export it standalone for a root that applies the filter itself.
22
23
  *
23
24
  * @capability colorblind-filters SVG feColorMatrix defs (protanopia/deuteranopia/tritanopia/grayscale) referenced by the accessibility colorblind filter
24
25
  */
25
26
  export function ColorblindFilters() {
26
27
  return (_jsx("svg", { "aria-hidden": true, width: 0, height: 0, style: { position: "absolute" }, "data-colorblind-filters": true, children: _jsx("defs", { children: FILTER_MODES.map((mode) => (_jsx("filter", { id: `jg-cb-${mode}`, colorInterpolationFilters: "sRGB", children: _jsx("feColorMatrix", { type: "matrix", values: COLORBLIND_MATRICES[mode] ?? "" }) }, mode))) }) }));
27
28
  }
29
+ /**
30
+ * Apply the player's colour-vision mode to one rendered frame: a box carrying
31
+ * `filter: url(#jg-cb-<mode>)` plus the filter defs it references. Put it around the canvas of a 3D
32
+ * game and nothing else — the filter is one compositor pass over that box's pixels, so HUD, menu and
33
+ * caption text outside it keep the contrast and hues the rest of the UI assumes.
34
+ *
35
+ * The filter needs no framebuffer copy and no extra WebGL draw: the browser filters the canvas layer
36
+ * as it composites, so switching modes costs nothing per frame beyond that pass. Gameplay stays
37
+ * unaffected; the cost is one full-screen colour-matrix pass while a mode other than `none` is set.
38
+ *
39
+ * @capability colorblind-frame apply the player's colour-vision mode to the 3D frame alone, leaving HUD and menu text unfiltered
40
+ */
41
+ export function ColorblindFrame({ mode = "none", children, className, style }) {
42
+ const filter = colorblindFrameFilter(mode);
43
+ return (_jsxs(_Fragment, { children: [_jsx(ColorblindFilters, {}), _jsx("div", { className: className, "data-colorblind-frame": mode, style: filter === null ? style : { ...style, filter }, children: children })] }));
44
+ }
45
+ /**
46
+ * The live colour-vision mode from a settings store — the same store the settings rows write, so the
47
+ * frame follows the player's choice the moment they pick it. Pass the store the shell owns; read
48
+ * `useAccessibilitySettings().colorblind` instead when a `SettingsProvider` is above the caller.
49
+ *
50
+ * @capability colorblind-frame-mode read the live colour-vision mode from the settings store that the settings rows write
51
+ */
52
+ export function useColorblindMode(store) {
53
+ return useSyncExternalStore(store.subscribe, () => readColorblind(store), () => readColorblind(store));
54
+ }
28
55
  /**
29
56
  * Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for
30
- * text to scale off, sets `data-reduced-motion` / `data-high-contrast` /
31
- * `data-colorblind` / `data-captions` for CSS to respond to, and wraps the tree
32
- * in the selected colorblind `feColorMatrix` filter. Reads a
57
+ * text to scale off, and sets `data-reduced-motion` / `data-high-contrast` /
58
+ * `data-colorblind` / `data-captions` for CSS to respond to. Reads a
33
59
  * `createAccessibilityStore` and re-renders on change; provides the state to
34
60
  * `useAccessibility`.
35
61
  *
36
- * @capability accessibility-provider apply accessibility preferences to a subtree — text-scale CSS var, reduced-motion/high-contrast/colorblind/captions data attrs, and the colorblind color-matrix filter
62
+ * It deliberately does not filter its own subtree: this is the HUD and menu root, and a colour matrix
63
+ * over text costs the contrast the rest of the UI was designed around. Wrap the 3D canvas in
64
+ * {@link ColorblindFrame} instead.
65
+ *
66
+ * @capability accessibility-provider apply accessibility preferences to a subtree — text-scale CSS var and reduced-motion/high-contrast/colorblind/captions data attrs
37
67
  */
38
68
  export function AccessibilityProvider({ store, children, className, style, }) {
39
69
  const state = useSyncExternalStore(store.subscribe, store.get, store.get);
40
70
  const rootStyle = { ...style };
41
71
  rootStyle["--jg-text-scale"] = String(state.textScale);
42
- if (state.colorblind !== "none")
43
- rootStyle.filter = `url(#jg-cb-${state.colorblind})`;
44
- return (_jsx(AccessibilityContext.Provider, { value: { state, store }, children: _jsxs("div", { className: className, "data-accessibility": true, "data-reduced-motion": state.reducedMotion, "data-high-contrast": state.highContrast, "data-colorblind": state.colorblind, "data-captions": state.captions, style: rootStyle, children: [_jsx(ColorblindFilters, {}), children] }) }));
72
+ return (_jsx(AccessibilityContext.Provider, { value: { state, store }, children: _jsx("div", { className: className, "data-accessibility": true, "data-reduced-motion": state.reducedMotion, "data-high-contrast": state.highContrast, "data-colorblind": state.colorblind, "data-captions": state.captions, style: rootStyle, children: children }) }));
45
73
  }
46
74
  /**
47
75
  * OS "reduce motion" preference (`prefers-reduced-motion: reduce`) as a live
@@ -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"`. */