@jgengine/react 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,7 +10,79 @@ let consumers pick up the better stuff, not just to list what moved.
10
10
  Agents building on the published SDK can also read this programmatically:
11
11
  `import { VERSION, CHANGELOG } from "@jgengine/core/meta/changelog"` gives the
12
12
  same data as typed values, so an updater can diff its installed version against
13
- the latest and surface the migration steps.
13
+ the latest and surface the migration steps. Easier still: `npx jgengine upgrade`
14
+ run inside a game project diffs the installed `@jgengine/*` versions against the
15
+ latest release and prints every Migrate step and Adopt-worthy addition in
16
+ between (`--json` for structured output).
17
+
18
+ ## [Unreleased]
19
+
20
+ <!--
21
+ Every PR that changes `packages/*/src` records its consumer-facing change here, so
22
+ the next release's notes are complete by construction. Add a bullet under the right
23
+ subhead below. `bun run check-changelog` fails a source PR whose `[Unreleased]` block
24
+ is untouched; pure refactors/tests bypass with `[skip changelog]` in a commit message.
25
+ At publish, rename this heading to the new version and mirror the entries into
26
+ `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
+ -->
28
+
29
+ ### Migrate
30
+
31
+ - _Nothing yet._
32
+
33
+ ### Added
34
+
35
+ ### Changed
36
+
37
+ ### Removed
38
+
39
+ ## 0.14.0
40
+
41
+ ### Migrate
42
+
43
+ - **Nothing required** — this release is additive drop-in primitives, editor authoring, CLI discovery, and fixes. Bump `@jgengine/*` pins to `^0.14.0` (CLI `jgengine` is 0.11.0; `@jgengine/github` unchanged at 0.1.0) and rebuild.
44
+ - One behavior fix to know about: in a `rig: "chase"` game, on-foot movement and aim are now camera-relative (the chase rig reports its yaw to the shell like every other rig). If a game somehow relied on world-yaw-0 movement under a chase camera, that was the bug this fixes.
45
+
46
+ ### Added
47
+
48
+ - One authoring gesture for scatterable coverage: `@jgengine/core/world/scatterCoverage` owns shared density/budget semantics (per-kind unit, requested→count→capped, one clamp-and-warn phrasing) for `grass_field`/`scatter`/`city`; the editor inspector leads each kind with the same Area → Assets → Density coverage section, scatter truncates to the shared 250k instance budget, and city single-sources its 2,600-lot cap.
49
+ - **Created games ship a drive/playtest script** (#1248) — `npx jgengine create` scaffolds now include `scripts/drive.mjs` (`bun run drive`): dependency-free headless play/testing of the running game — ordered `--click`/`--key`/`--wait`/`--shot`/`--rpc` steps against the agent bridge, plus `--playtest --strict` progress/softlock verdicts off `capture.probe`. Shared Chrome/CDP machinery lives in `scripts/browser.mjs`; `scripts/shoot.mjs` is unchanged in behavior, now a thin CLI over it.
50
+ - Editor: viewport clip preview for rigged assets (Animation dock "Clips" mode — pick a rig or placed instance, play/scrub/loop/speed any catalog clip) and an Inspector "Animation" section that authors a placement's `ModelConfig.animation` (role→clip dropdowns, auto/none, walk/run/fade, one-shot event bindings) as undoable `marker.meta.animation` edits. New subpaths: `@jgengine/editor/shell/clipPreview`, `@jgengine/editor/modelAnimationAuthoring`, `@jgengine/editor/ClipPreviewLayer`.
51
+ - **`npx jgengine find <intent>`** — active capability discovery: searches every shipped domain's `capabilities.md` (staged inside the CLI tarball, so it works regardless of which `@jgengine/*` packages a project installed) and prints the drop-in primitive + its import for an intent like `"toggleable window"`, `inventory`, or `minimap`. Scaffolded games are now briefed (in `AGENTS.md` and the `jgengine` intake skill) to reach for it before hand-rolling a HUD/inventory/window/rig.
52
+ - **`EntityPreview`** (`@jgengine/shell/render/EntityPreview`) — drop-in live 3D entity portrait for character screens, unit inspectors, and loadout viewers. Owns the nested `<Canvas>`, the `GameContextBridge`, and a `StudioStage` lighting rig with optional turntable / face-camera; the game passes its own `renderEntity` as children (or a native `model` + `instanceId`) and, bound to a live entity, the portrait walks/flinches/topples in sync with the world.
53
+ - **`GameContextBridge`** (`@jgengine/react`) — re-provide the running `GameContext` across a nested React reconciler boundary (the R3F `<Canvas>`), so a game building its own preview canvas no longer re-derives the bridge by hand.
54
+
55
+ ### Changed
56
+
57
+ - **Chase rig reports its yaw to the shell** — `ChaseRig` now writes the camera yaw back to the shared yaw ref like every other player-facing rig, so on-foot movement and aim in a `rig: "chase"` game are camera-relative instead of frozen to world yaw 0 (WASD no longer fights the camera the moment it swings behind the player). Interior views (cockpit/hood/rear) report the vehicle heading.
58
+ - **Combat VFX no longer render as black squares under AO/DOF post-processing** (#1247) — GTAO/Bokeh scene prepasses skip overlay effects. Games with custom additive overlay effects opt out the same way: spread `POSTFX_OVERLAY_USERDATA` (`@jgengine/shell/postfx/postfxOverlay`) onto the overlay group's `userData`.
59
+ - **`PanelHost` windows stack above the HUD by default** (`@jgengine/react`) — the host establishes its own stacking context at `zIndexBase` (default 40), so open windows always paint over stat bars / nameplates / frames instead of bleeding through. Overridable per instance.
60
+
61
+ ## 0.13.0
62
+
63
+ ### Migrate
64
+
65
+ - **Nothing required** — this release is additive fixes, defaults, and new drop-in primitives. Bump `@jgengine/*` pins to `^0.13.0` (CLI `jgengine` is 0.10.0; `@jgengine/github` unchanged at 0.1.0) and rebuild.
66
+ - Two defaults changed in the player's favor, both overridable: **offline games now persist to localStorage automatically** (#1207) and **rigged catalog assets animate by default** via clip metadata + semantic clip roles (#1209). If a game relied on rigged models staying frozen or on saves being memory-only, opt out explicitly; otherwise you get better behavior for free.
67
+
68
+ ### Added
69
+
70
+ - **Drop-in inventory grid, window manager, and character sheet** (#1220) — `@jgengine/react` ships `InventoryGrid` (real drag/stack/split), a toggleable-window system (`panels`: bag, character sheet, spellbook-style windows with `@jgengine/core/ui/panelModel` state), and a `CharacterSheet` paperdoll, all HudTheme-skinnable — wire in a line or two instead of re-deriving from raw divs.
71
+ - **Serializable runtime snapshots** (#1179) — capture/restore of live runtime state, including magazine state (`@jgengine/core/combat/magazine`) and stat modifiers, for saves and replication.
72
+ - **Portable weapon plumbing** (#1211) — `@jgengine/core/combat/weaponFire` composes trigger → magazine → spread → projectile/hitscan as data, no game-local glue.
73
+ - **Portable leveling** (#1206) and **portable damage-pool access** (#1208) — progression and damage pools exposed as genre-agnostic seams on the game context.
74
+ - **Named combat VFX presets** (#1204) — `@jgengine/core/combat/vfxPresets`: a visible attack is one line.
75
+ - **Runtime-measured render-bounds hitboxes** (#1227) — colliders wrap what the shell actually mounts, not the raw asset bounds.
76
+ - **Character motion feel** — procedural part motion for rig-less part-composed characters (#1222) plus squash & stretch and death splat (#1229).
77
+ - **City fabric** (#1191) — `streetGenerator` + `cityGenerator`, editor bake verb, street-aware lots, road junctions, grounding, and a web playground.
78
+ - **`jgengine recipe` CLI** (#1214, #1231) — vetted wired compositions, compile-pinned against the SDK, listed and copied from the CLI.
79
+ - **Editor/debug modes mirror to the URL** with a proper editor exit (#1203); game template ships a dependency-free WebGL screenshot script (#1202).
80
+
81
+ ### Changed
82
+
83
+ - Offline games persist to localStorage by default (#1207); rigged assets animate by default (#1209).
84
+ - RTS camera pan inversion fixed (#1228); sprite raycast warning silenced and duplicate nameplate health bars removed (#1194); scaffolded F2+E editor Tailwind `@source` renders its theme (#1195).
85
+ - Outside-game DX: stat-pool no-op stability, camera-transparent decor, cleaner install/version output, headless UI-intent seam (#1223); new-game briefs direct engine bugs/gaps upstream (#1200).
14
86
 
15
87
  ## 0.12.0
16
88
 
@@ -0,0 +1,120 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type SlotItem } from "./slots.js";
3
+ import { type HudFrameVariation } from "./hudFrame.js";
4
+ /**
5
+ * Character-sheet / paperdoll composition (#1033 follow-up): a drop-in `<CharacterSheet>` that
6
+ * arranges caller-supplied equip slots as a paperdoll around a portrait and lists derived stats,
7
+ * all inside a {@link HudFrame}. It reuses the atomic slot rendering ({@link EquipmentSlots}) and the
8
+ * shared `--jg-slot-*` / `--jg-accent` HudTheme tokens (#1034) — the game owns which slots and which
9
+ * stats matter (both are props), the skin, and the window that toggles it open/closed. No genre list
10
+ * is baked in; {@link defaultEquipLayout} is a convenience arrangement the game can override wholesale.
11
+ */
12
+ /** Which flank of the paperdoll a slot sits on — `left`/`right` columns, or the `bottom` row under the portrait. */
13
+ export type EquipSlotPosition = "left" | "right" | "bottom";
14
+ /** One named equip slot: a stable `id`, a display `label`, its worn `item` (or `null` = empty), and where it sits. */
15
+ export interface EquipSlot {
16
+ /** Stable slot id passed to `onSlotActivate` (e.g. `head`, `mainHand`, `ring1`). */
17
+ id: string;
18
+ /** Human label shown under/beside the slot and used as its accessible name. */
19
+ label: string;
20
+ /** The worn item, or `null` for an empty slot (renders the themed placeholder). */
21
+ item: SlotItem | null;
22
+ /** Which flank the slot sits on. Default `left`. */
23
+ position?: EquipSlotPosition;
24
+ }
25
+ /** An ordered set of equip slots that make up a paperdoll. Caller-supplied; see {@link defaultEquipLayout}. */
26
+ export type EquipSlotLayout = readonly EquipSlot[];
27
+ /** Slot ids used by {@link defaultEquipLayout}'s convenience arrangement. */
28
+ export type DefaultEquipSlotId = "head" | "chest" | "hands" | "legs" | "feet" | "mainHand" | "offHand" | "ring1" | "ring2" | "trinket";
29
+ /**
30
+ * A sensible default paperdoll arrangement — worn armor down the left flank, held gear and jewelry
31
+ * down the right — that the game fills by passing worn items keyed by slot id. Every id is optional;
32
+ * unfilled slots render empty. Games that need a different slot set pass their own {@link EquipSlotLayout}.
33
+ */
34
+ export declare function defaultEquipLayout(items?: Partial<Record<DefaultEquipSlotId, SlotItem | null>>): EquipSlot[];
35
+ /** Props for {@link Paperdoll}. */
36
+ export interface PaperdollProps {
37
+ /** The equip slots to arrange. Order within a flank is preserved. */
38
+ slots: EquipSlotLayout;
39
+ /** Center node — a portrait, 3D preview, or custom art. A silhouette placeholder is drawn when omitted. */
40
+ portrait?: ReactNode;
41
+ /** Slot tile size in px. Default 44. */
42
+ slotSize?: number;
43
+ /** Fired with the slot `id` on click / Enter / Space — wire drag or an equip picker here. */
44
+ onSlotActivate?: (id: string) => void;
45
+ className?: string;
46
+ style?: CSSProperties;
47
+ }
48
+ /**
49
+ * Arranges caller-supplied equip slots as a paperdoll — worn gear in two flanking columns around a
50
+ * center `portrait` (or a silhouette placeholder), with any `bottom` slots in a row beneath it. Each
51
+ * slot reuses the atomic {@link EquipmentSlots} tile (filled icon or themed empty placeholder), carries
52
+ * a label, and is a focusable `role="group"` with an `aria-label`; `onSlotActivate` fires on
53
+ * click/Enter/Space so games can wire drag or an equip picker. Composition and skin stay the game's.
54
+ *
55
+ * @capability character-paperdoll equip-slot paperdoll — flanking columns around a portrait, activatable slots
56
+ */
57
+ export declare function Paperdoll({ slots, portrait, slotSize, onSlotActivate, className, style }: PaperdollProps): ReactNode;
58
+ /** One derived-stat row. `group` optionally buckets rows under a small header. */
59
+ export interface StatRow {
60
+ /** Stable row id. */
61
+ id: string;
62
+ /** Small-caps row label (e.g. `Armor`, `Crit`). */
63
+ label: string;
64
+ /** The value to show — the game formats it (a number, `"12%"`, `"1.2/s"`). */
65
+ value: string | number;
66
+ /** Optional secondary hint shown dimmed after the value (e.g. a delta or source). */
67
+ hint?: string;
68
+ /** Optional group header this row falls under; consecutive rows sharing a group render one header. */
69
+ group?: string;
70
+ }
71
+ /** Props for {@link StatList}. */
72
+ export interface StatListProps {
73
+ /** Derived stat rows, in display order. The game owns which stats matter. */
74
+ stats: readonly StatRow[];
75
+ className?: string;
76
+ style?: CSSProperties;
77
+ }
78
+ /**
79
+ * Renders derived character stats as clean small-caps label→value rows, token-themed. Rows carrying a
80
+ * `group` field are bucketed under a small uppercase header (consecutive same-group rows share one).
81
+ * Pure and prop-driven — the game supplies the rows (from `useEntityStat`/derived values); the engine
82
+ * ships no stat list of its own.
83
+ *
84
+ * @capability character-statlist derived-stat readout — grouped small-caps label→value rows, token-themed
85
+ */
86
+ export declare function StatList({ stats, className, style }: StatListProps): ReactNode;
87
+ /** Props for {@link CharacterSheet}. */
88
+ export interface CharacterSheetProps {
89
+ /** Equip slots to arrange as the paperdoll (see {@link defaultEquipLayout}). */
90
+ slots: EquipSlotLayout;
91
+ /** Derived stat rows for the {@link StatList}. */
92
+ stats: readonly StatRow[];
93
+ /** Character name shown in the header. */
94
+ name?: ReactNode;
95
+ /** Secondary header line — level, class, faction, etc. */
96
+ subtitle?: ReactNode;
97
+ /** Portrait node passed through to the {@link Paperdoll} center. */
98
+ portrait?: ReactNode;
99
+ /** Replace the whole header block (name/subtitle) with custom content. */
100
+ header?: ReactNode;
101
+ /** Frame title label. Default `Character`. */
102
+ title?: ReactNode;
103
+ /** {@link HudFrame} skin. Default `themed`. */
104
+ variation?: HudFrameVariation;
105
+ /** Slot tile size in px, forwarded to {@link Paperdoll}. */
106
+ slotSize?: number;
107
+ /** Fired with the equip slot `id` on activate. */
108
+ onSlotActivate?: (id: string) => void;
109
+ className?: string;
110
+ style?: CSSProperties;
111
+ }
112
+ /**
113
+ * Drop-in character sheet: a {@link HudFrame} wrapping an optional name/subtitle/portrait header, the
114
+ * equip-slot {@link Paperdoll}, and the derived-stat {@link StatList}. The game supplies slots, stats,
115
+ * header content, and the activation callback, and owns the window that toggles it (this component does
116
+ * not manage its own open/close). Pure composition over existing primitives and HudTheme tokens.
117
+ *
118
+ * @capability character-sheet drop-in paperdoll + derived-stat sheet in a HudFrame — game supplies slots/stats
119
+ */
120
+ export declare function CharacterSheet({ slots, stats, name, subtitle, portrait, header, title, variation, slotSize, onSlotActivate, className, style, }: CharacterSheetProps): ReactNode;
@@ -0,0 +1,134 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import {} from "react";
3
+ import { EquipmentSlots } from "./slots.js";
4
+ import { HudFrame } from "./hudFrame.js";
5
+ const DEFAULT_ARRANGEMENT = [
6
+ { id: "head", label: "Head", position: "left" },
7
+ { id: "chest", label: "Chest", position: "left" },
8
+ { id: "hands", label: "Hands", position: "left" },
9
+ { id: "legs", label: "Legs", position: "left" },
10
+ { id: "feet", label: "Feet", position: "left" },
11
+ { id: "mainHand", label: "Main Hand", position: "right" },
12
+ { id: "offHand", label: "Off Hand", position: "right" },
13
+ { id: "ring1", label: "Ring", position: "right" },
14
+ { id: "ring2", label: "Ring", position: "right" },
15
+ { id: "trinket", label: "Trinket", position: "right" },
16
+ ];
17
+ /**
18
+ * A sensible default paperdoll arrangement — worn armor down the left flank, held gear and jewelry
19
+ * down the right — that the game fills by passing worn items keyed by slot id. Every id is optional;
20
+ * unfilled slots render empty. Games that need a different slot set pass their own {@link EquipSlotLayout}.
21
+ */
22
+ export function defaultEquipLayout(items = {}) {
23
+ return DEFAULT_ARRANGEMENT.map((slot) => ({
24
+ id: slot.id,
25
+ label: slot.label,
26
+ item: items[slot.id] ?? null,
27
+ position: slot.position,
28
+ }));
29
+ }
30
+ function SlotLabel({ children }) {
31
+ return (_jsx("span", { style: {
32
+ fontSize: 9,
33
+ letterSpacing: 0.6,
34
+ fontVariant: "small-caps",
35
+ textTransform: "lowercase",
36
+ color: "var(--jg-bar-text, #f4f6fb)",
37
+ opacity: 0.62,
38
+ whiteSpace: "nowrap",
39
+ }, children: children }));
40
+ }
41
+ function EquipSlotCell({ slot, slotSize, align, onSlotActivate, }) {
42
+ const interactive = onSlotActivate !== undefined;
43
+ const activate = interactive ? () => onSlotActivate(slot.id) : undefined;
44
+ return (_jsxs("div", { "data-equip-slot": slot.id, role: "group", "aria-label": slot.label, tabIndex: interactive ? 0 : undefined, ...(activate === undefined ? {} : { onClick: activate }), ...(activate === undefined
45
+ ? {}
46
+ : {
47
+ onKeyDown: (event) => {
48
+ if (event.key === "Enter" || event.key === " ") {
49
+ event.preventDefault();
50
+ activate();
51
+ }
52
+ },
53
+ }), style: {
54
+ display: "flex",
55
+ flexDirection: "column",
56
+ alignItems: align === "center" ? "center" : align === "right" ? "flex-end" : "flex-start",
57
+ gap: 3,
58
+ cursor: interactive ? "pointer" : undefined,
59
+ }, children: [_jsx(EquipmentSlots, { items: [slot.item], size: slotSize }), _jsx(SlotLabel, { children: slot.label })] }));
60
+ }
61
+ function Silhouette({ size }) {
62
+ return (_jsx("div", { "data-paperdoll-silhouette": "", "aria-hidden": true, style: {
63
+ width: size,
64
+ height: Math.round(size * 2.6),
65
+ borderRadius: "var(--jg-slot-radius, 9px)",
66
+ background: "var(--jg-slot-bg, rgba(12,14,20,0.5))",
67
+ border: "var(--jg-slot-border, 1px solid rgba(255,255,255,0.1))",
68
+ boxShadow: "inset 0 2px 10px rgba(0,0,0,0.5)",
69
+ display: "flex",
70
+ alignItems: "center",
71
+ justifyContent: "center",
72
+ color: "var(--jg-bar-text, #f4f6fb)",
73
+ opacity: 0.28,
74
+ fontSize: Math.round(size * 0.9),
75
+ boxSizing: "border-box",
76
+ }, children: _jsx("span", { "aria-hidden": true, children: "\u265F" }) }));
77
+ }
78
+ /**
79
+ * Arranges caller-supplied equip slots as a paperdoll — worn gear in two flanking columns around a
80
+ * center `portrait` (or a silhouette placeholder), with any `bottom` slots in a row beneath it. Each
81
+ * slot reuses the atomic {@link EquipmentSlots} tile (filled icon or themed empty placeholder), carries
82
+ * a label, and is a focusable `role="group"` with an `aria-label`; `onSlotActivate` fires on
83
+ * click/Enter/Space so games can wire drag or an equip picker. Composition and skin stay the game's.
84
+ *
85
+ * @capability character-paperdoll equip-slot paperdoll — flanking columns around a portrait, activatable slots
86
+ */
87
+ export function Paperdoll({ slots, portrait, slotSize = 44, onSlotActivate, className, style }) {
88
+ const left = slots.filter((s) => (s.position ?? "left") === "left");
89
+ const right = slots.filter((s) => s.position === "right");
90
+ const bottom = slots.filter((s) => s.position === "bottom");
91
+ const column = (cells, align) => (_jsx("div", { style: { display: "flex", flexDirection: "column", gap: 10, alignItems: align === "right" ? "flex-end" : "flex-start" }, children: cells.map((slot) => (_jsx(EquipSlotCell, { slot: slot, slotSize: slotSize, align: align, ...(onSlotActivate === undefined ? {} : { onSlotActivate }) }, slot.id))) }));
92
+ return (_jsxs("div", { "data-paperdoll": "", className: className, style: { display: "flex", flexDirection: "column", alignItems: "center", gap: 12, ...style }, children: [_jsxs("div", { style: { display: "flex", gap: 16, alignItems: "flex-start", justifyContent: "center" }, children: [column(left, "right"), _jsx("div", { "data-paperdoll-portrait": "", style: { display: "flex", alignItems: "center", justifyContent: "center", alignSelf: "stretch" }, children: portrait ?? _jsx(Silhouette, { size: slotSize * 1.4 }) }), column(right, "left")] }), bottom.length > 0 ? (_jsx("div", { style: { display: "flex", gap: 12, justifyContent: "center" }, children: bottom.map((slot) => (_jsx(EquipSlotCell, { slot: slot, slotSize: slotSize, align: "center", ...(onSlotActivate === undefined ? {} : { onSlotActivate }) }, slot.id))) })) : null] }));
93
+ }
94
+ function StatListRow({ row }) {
95
+ return (_jsxs("div", { "data-stat-row": row.id, style: { display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 12, padding: "2px 0" }, children: [_jsx("span", { style: { fontSize: 11, fontVariant: "small-caps", letterSpacing: 0.5, color: "var(--jg-bar-text, #f4f6fb)", opacity: 0.72 }, children: row.label }), _jsxs("span", { style: { fontSize: 12, fontWeight: 700, fontVariantNumeric: "tabular-nums", color: "var(--jg-accent, #38bdf8)" }, children: [row.value, row.hint !== undefined ? (_jsx("span", { style: { marginLeft: 5, fontWeight: 500, color: "var(--jg-bar-text, #f4f6fb)", opacity: 0.5 }, children: row.hint })) : null] })] }));
96
+ }
97
+ /**
98
+ * Renders derived character stats as clean small-caps label→value rows, token-themed. Rows carrying a
99
+ * `group` field are bucketed under a small uppercase header (consecutive same-group rows share one).
100
+ * Pure and prop-driven — the game supplies the rows (from `useEntityStat`/derived values); the engine
101
+ * ships no stat list of its own.
102
+ *
103
+ * @capability character-statlist derived-stat readout — grouped small-caps label→value rows, token-themed
104
+ */
105
+ export function StatList({ stats, className, style }) {
106
+ const groups = [];
107
+ for (const row of stats) {
108
+ const last = groups[groups.length - 1];
109
+ if (last !== undefined && last.name === row.group)
110
+ last.rows.push(row);
111
+ else
112
+ groups.push({ name: row.group, rows: [row] });
113
+ }
114
+ return (_jsx("div", { "data-stat-list": "", className: className, style: { display: "flex", flexDirection: "column", gap: 4, ...style }, children: groups.map((group, index) => (_jsxs("div", { "data-stat-group": group.name ?? "", children: [group.name !== undefined ? (_jsx("div", { style: {
115
+ fontSize: 9,
116
+ letterSpacing: 1.4,
117
+ textTransform: "uppercase",
118
+ opacity: 0.55,
119
+ margin: "6px 0 2px",
120
+ color: "var(--jg-bar-text, #f4f6fb)",
121
+ }, children: group.name })) : null, group.rows.map((row) => (_jsx(StatListRow, { row: row }, row.id)))] }, index))) }));
122
+ }
123
+ /**
124
+ * Drop-in character sheet: a {@link HudFrame} wrapping an optional name/subtitle/portrait header, the
125
+ * equip-slot {@link Paperdoll}, and the derived-stat {@link StatList}. The game supplies slots, stats,
126
+ * header content, and the activation callback, and owns the window that toggles it (this component does
127
+ * not manage its own open/close). Pure composition over existing primitives and HudTheme tokens.
128
+ *
129
+ * @capability character-sheet drop-in paperdoll + derived-stat sheet in a HudFrame — game supplies slots/stats
130
+ */
131
+ export function CharacterSheet({ slots, stats, name, subtitle, portrait, header, title = "Character", variation = "themed", slotSize, onSlotActivate, className, style, }) {
132
+ const hasHeader = header !== undefined || name !== undefined || subtitle !== undefined;
133
+ return (_jsx(HudFrame, { variation: variation, title: title, interactive: true, padding: 14, ...(className === undefined ? {} : { className }), ...(style === undefined ? {} : { style }), children: _jsxs("div", { "data-character-sheet": "", style: { display: "flex", flexDirection: "column", gap: 12, minWidth: 260 }, children: [hasHeader ? (_jsx("div", { "data-character-header": "", style: { display: "flex", flexDirection: "column", gap: 1 }, children: header ?? (_jsxs(_Fragment, { children: [name !== undefined ? _jsx("div", { style: { fontSize: 16, fontWeight: 800, letterSpacing: 0.2 }, children: name }) : null, subtitle !== undefined ? (_jsx("div", { style: { fontSize: 11, opacity: 0.65, fontVariant: "small-caps", letterSpacing: 0.4 }, children: subtitle })) : null] })) })) : null, _jsxs("div", { style: { display: "flex", gap: 18, alignItems: "flex-start", flexWrap: "wrap" }, children: [_jsx(Paperdoll, { slots: slots, ...(portrait === undefined ? {} : { portrait }), ...(slotSize === undefined ? {} : { slotSize }), ...(onSlotActivate === undefined ? {} : { onSlotActivate }) }), _jsx(StatList, { stats: stats, style: { flex: "1 1 140px", minWidth: 140 } })] })] }) }));
134
+ }
package/dist/hooks.d.ts CHANGED
@@ -55,6 +55,17 @@ export declare function useNearestWorldItem(radius: number): WorldItemRecord | n
55
55
  export declare function useEntityStat(instanceId: string, statId: string): StatValue | null;
56
56
  export declare function useTarget(fromInstanceId: string): string | null;
57
57
  export declare function useInventory(inventoryId: string): readonly InventorySlot[];
58
+ /** Live slots plus `move`/`split` actions bound to `inventoryId`, routed through the notifying `inventory.move`/`inventory.split` commands so React re-renders. */
59
+ export interface InventoryGridBinding {
60
+ slots: readonly InventorySlot[];
61
+ move(from: number, to: number): void;
62
+ split(slot: number, amount: number, toSlot?: number): void;
63
+ }
64
+ /**
65
+ * Binds a live inventory to a HUD grid: `slots` from {@link useInventory}, and `move`/`split` that run
66
+ * the built-in `inventory.move`/`inventory.split` commands (which notify, so the grid re-renders).
67
+ */
68
+ export declare function useInventoryGrid(inventoryId: string): InventoryGridBinding;
58
69
  export declare function useCurrency(currencyId: string): number;
59
70
  export declare function useFeed({ action, limit }: {
60
71
  action: string;
package/dist/hooks.js CHANGED
@@ -87,6 +87,21 @@ export function useTarget(fromInstanceId) {
87
87
  export function useInventory(inventoryId) {
88
88
  return useGameStore((ctx) => ctx.player.inventory.state(inventoryId).slots);
89
89
  }
90
+ /**
91
+ * Binds a live inventory to a HUD grid: `slots` from {@link useInventory}, and `move`/`split` that run
92
+ * the built-in `inventory.move`/`inventory.split` commands (which notify, so the grid re-renders).
93
+ */
94
+ export function useInventoryGrid(inventoryId) {
95
+ const slots = useInventory(inventoryId);
96
+ const { commands } = useGame();
97
+ const move = useCallback((from, to) => {
98
+ commands.run("inventory.move", { inventoryId, from, to });
99
+ }, [commands, inventoryId]);
100
+ const split = useCallback((slot, amount, toSlot) => {
101
+ commands.run("inventory.split", toSlot === undefined ? { inventoryId, slot, amount } : { inventoryId, slot, amount, toSlot });
102
+ }, [commands, inventoryId]);
103
+ return useMemo(() => ({ slots, move, split }), [slots, move, split]);
104
+ }
90
105
  export function useCurrency(currencyId) {
91
106
  return useGameStore((ctx) => ctx.game.economy.balance(ctx.player.userId, currencyId));
92
107
  }
package/dist/hudLayout.js CHANGED
@@ -22,6 +22,91 @@ const EDIT_BAR_Z = 100000;
22
22
  const REGION_GAP = 10;
23
23
  const COMPACT_HUD_SCALE = 0.85;
24
24
  const HUD_ANCHORS = Object.keys(HUD_ANCHOR_FRACTIONS);
25
+ /**
26
+ * Attribute a game puts on any HUD surface it *wants* to bleed past the viewport
27
+ * (a full-bleed backdrop, a deliberately clipped ticker), so the boundary check
28
+ * skips it. Opt-out, on purpose: the default is that shown UI must fit on screen.
29
+ */
30
+ const ALLOW_OVERFLOW_ATTR = "data-hud-allow-overflow";
31
+ /** Opt-in marker for a custom window mounted deeper than a direct HudCanvas child. */
32
+ const WINDOW_ATTR = "data-hud-window";
33
+ /**
34
+ * Dev-only, prod-safe. The on-screen boundary overlay must never reach players,
35
+ * so anything we cannot positively identify as a dev build resolves to `false`.
36
+ */
37
+ function isDevBuild() {
38
+ try {
39
+ const meta = import.meta;
40
+ if (meta.env?.DEV === true)
41
+ return true;
42
+ if (meta.env?.PROD === true)
43
+ return false;
44
+ }
45
+ catch {
46
+ /* import.meta.env unavailable under this bundler */
47
+ }
48
+ const nodeEnv = globalThis.process
49
+ ?.env?.NODE_ENV;
50
+ if (typeof nodeEnv === "string")
51
+ return nodeEnv !== "production";
52
+ return false;
53
+ }
54
+ /** True unless the element (or an ancestor) is hidden, collapsed, or removed from the a11y tree. */
55
+ function isSurfaceShown(el) {
56
+ if (el.closest('[aria-hidden="true"]') !== null)
57
+ return false;
58
+ const checkable = el;
59
+ if (typeof checkable.checkVisibility === "function") {
60
+ return checkable.checkVisibility({
61
+ contentVisibilityAuto: true,
62
+ opacityProperty: true,
63
+ visibilityProperty: true,
64
+ });
65
+ }
66
+ if (typeof getComputedStyle !== "function")
67
+ return true;
68
+ const style = getComputedStyle(el);
69
+ return style.visibility !== "hidden" && style.display !== "none" && Number(style.opacity) > 0.01;
70
+ }
71
+ function surfaceId(el) {
72
+ return (el.getAttribute("data-hud-panel") ??
73
+ el.getAttribute(WINDOW_ATTR) ??
74
+ el.getAttribute("data-hud-id") ??
75
+ el.getAttribute("aria-label") ??
76
+ (el.id.length > 0 ? el.id : null) ??
77
+ "<ui>");
78
+ }
79
+ /**
80
+ * Every HUD surface whose bounds we hold to the viewport: registered panels,
81
+ * any element tagged as a window, and the game's own direct children of the
82
+ * canvas (custom modals dropped straight into the HUD layer). Opted-out and
83
+ * hidden surfaces are dropped here so a closed drawer never reaches the check.
84
+ */
85
+ function collectBoundarySurfaces(canvas) {
86
+ const seen = new Set();
87
+ const out = [];
88
+ const consider = (el) => {
89
+ if (seen.has(el))
90
+ return;
91
+ seen.add(el);
92
+ if (el.closest(`[${ALLOW_OVERFLOW_ATTR}]`) !== null)
93
+ return;
94
+ if (!isSurfaceShown(el))
95
+ return;
96
+ out.push({ id: surfaceId(el), el });
97
+ };
98
+ for (const el of canvas.querySelectorAll(`[data-hud-panel],[${WINDOW_ATTR}]`))
99
+ consider(el);
100
+ for (const el of Array.from(canvas.children)) {
101
+ if (el.hasAttribute("data-hud-region") ||
102
+ el.hasAttribute("data-hud-edit-bar") ||
103
+ el.hasAttribute("data-hud-overflow-overlay")) {
104
+ continue;
105
+ }
106
+ consider(el);
107
+ }
108
+ return out;
109
+ }
25
110
  function sizeStyle(panel) {
26
111
  if (panel === undefined)
27
112
  return {};
@@ -200,6 +285,7 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
200
285
  const fitEnabled = hudViewport?.fitEnabled === true;
201
286
  const [viewport, setViewport] = useState(null);
202
287
  const [regions, setRegions] = useState({});
288
+ const [offenders, setOffenders] = useState([]);
203
289
  const regionRefs = useMemo(() => {
204
290
  const refs = {};
205
291
  for (const anchor of HUD_ANCHORS) {
@@ -285,18 +371,20 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
285
371
  return;
286
372
  let frame = 0;
287
373
  let lastReport = "";
374
+ const dev = isDevBuild();
288
375
  const check = () => {
289
376
  frame = 0;
290
377
  const hostRect = host.getBoundingClientRect();
291
378
  if (hostRect.width <= 0 || hostRect.height <= 0)
292
379
  return;
293
- const panels = [];
294
- for (const el of canvas.querySelectorAll("[data-hud-panel]")) {
295
- const rect = el.getBoundingClientRect();
380
+ const viewportSize = { width: hostRect.width, height: hostRect.height };
381
+ const surfaces = [];
382
+ for (const surface of collectBoundarySurfaces(canvas)) {
383
+ const rect = surface.el.getBoundingClientRect();
296
384
  if (rect.width === 0 && rect.height === 0)
297
385
  continue;
298
- panels.push({
299
- id: el.getAttribute("data-hud-panel") ?? "?",
386
+ surfaces.push({
387
+ id: surface.id,
300
388
  rect: {
301
389
  x: rect.left - hostRect.left,
302
390
  y: rect.top - hostRect.top,
@@ -305,8 +393,10 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
305
393
  },
306
394
  });
307
395
  }
308
- const offenders = overflowingPanels(panels, { width: hostRect.width, height: hostRect.height });
309
- const report = offenders.length === 0 ? "" : JSON.stringify(offenders);
396
+ // overflowingPanels reports only surfaces that are partly on screen *and*
397
+ // cross an edge — a closed/parked/off-screen menu is never an offender.
398
+ const escaped = overflowingPanels(surfaces, viewportSize);
399
+ const report = escaped.length === 0 ? "" : JSON.stringify(escaped);
310
400
  if (report === lastReport)
311
401
  return;
312
402
  lastReport = report;
@@ -315,7 +405,13 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
315
405
  }
316
406
  else {
317
407
  canvas.setAttribute("data-hud-overflow", report);
318
- console.warn(`[jgengine] HUD panels overflow the ${Math.round(hostRect.width)}x${Math.round(hostRect.height)} viewport: ${report}`);
408
+ console.warn(`[jgengine] HUD UI escapes the ${Math.round(hostRect.width)}x${Math.round(hostRect.height)} viewport: ${report}`);
409
+ }
410
+ if (dev) {
411
+ setOffenders(escaped.map((overflow) => {
412
+ const rect = surfaces.find((s) => s.id === overflow.id)?.rect;
413
+ return { ...overflow, x: rect?.x ?? 0, y: rect?.y ?? 0, width: rect?.width ?? 0, height: rect?.height ?? 0 };
414
+ }));
319
415
  }
320
416
  };
321
417
  const schedule = () => {
@@ -365,7 +461,40 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
365
461
  pointerEvents: "none",
366
462
  zIndex: 1,
367
463
  ...REGION_LAYOUT[anchor],
368
- } }, anchor))), children, editing ? _jsx(HudEditBar, { layout: layout }) : null] }) }));
464
+ } }, 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: {
465
+ position: "absolute",
466
+ left: o.x / scale,
467
+ top: o.y / scale,
468
+ width: o.width / scale,
469
+ height: o.height / scale,
470
+ border: "2px solid rgba(255, 64, 64, 0.95)",
471
+ boxShadow: "0 0 0 2px rgba(255, 64, 64, 0.3)",
472
+ boxSizing: "border-box",
473
+ }, children: _jsxs("span", { style: {
474
+ position: "absolute",
475
+ left: 0,
476
+ bottom: "100%",
477
+ marginBottom: 2,
478
+ padding: "2px 6px",
479
+ borderRadius: 4,
480
+ background: "rgba(180, 20, 20, 0.95)",
481
+ color: "#fff",
482
+ font: "600 10px/1.4 system-ui, sans-serif",
483
+ whiteSpace: "nowrap",
484
+ }, children: ["\u26A0 ", o.id, " escapes viewport (", overflowEdges(o), ")"] }) }, o.id))) })) : null, editing ? _jsx(HudEditBar, { layout: layout }) : null] }) }));
485
+ }
486
+ /** Human-readable edge list for the dev boundary badge, e.g. `right +260 · bottom +56`. */
487
+ function overflowEdges(overflow) {
488
+ const parts = [];
489
+ if (overflow.left > 0)
490
+ parts.push(`left +${overflow.left}`);
491
+ if (overflow.top > 0)
492
+ parts.push(`top +${overflow.top}`);
493
+ if (overflow.right > 0)
494
+ parts.push(`right +${overflow.right}`);
495
+ if (overflow.bottom > 0)
496
+ parts.push(`bottom +${overflow.bottom}`);
497
+ return parts.length > 0 ? parts.join(" · ") : "edge";
369
498
  }
370
499
  function chipAlignment(anchor) {
371
500
  if (anchor.endsWith("right"))
package/dist/index.d.ts CHANGED
@@ -15,6 +15,9 @@ export * from "./barsPreview.js";
15
15
  export * from "./iconTreatment.js";
16
16
  export * from "./iconsPreview.js";
17
17
  export * from "./slots.js";
18
+ export * from "./inventoryGrid.js";
19
+ export * from "./panels.js";
20
+ export * from "./characterSheet.js";
18
21
  export * from "./hudTheme.js";
19
22
  export * from "./hudThemePreview.js";
20
23
  export * from "./hudFrame.js";
package/dist/index.js CHANGED
@@ -15,6 +15,9 @@ export * from "./barsPreview.js";
15
15
  export * from "./iconTreatment.js";
16
16
  export * from "./iconsPreview.js";
17
17
  export * from "./slots.js";
18
+ export * from "./inventoryGrid.js";
19
+ export * from "./panels.js";
20
+ export * from "./characterSheet.js";
18
21
  export * from "./hudTheme.js";
19
22
  export * from "./hudThemePreview.js";
20
23
  export * from "./hudFrame.js";
@@ -0,0 +1,20 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ /** Props for {@link InventoryGrid}. */
3
+ export interface InventoryGridProps {
4
+ /** The declared inventory id to bind (its slot count sets the tile count). */
5
+ inventoryId: string;
6
+ /** Grid columns; default derives a squarish grid from the slot count. */
7
+ columns?: number;
8
+ /** Tile size in px. Default 44. */
9
+ size?: number;
10
+ /** Gap between tiles in px. Default 6. */
11
+ gap?: number;
12
+ /** Caller-supplied item id → icon/glyph registry; return null/undefined to fall back to the default `GameIcon`. */
13
+ itemIcon?: (itemId: string) => ReactNode;
14
+ /** Invoked when a filled slot is activated (double-click) — e.g. use/equip the item. */
15
+ onActivate?: (slot: number) => void;
16
+ className?: string;
17
+ style?: CSSProperties;
18
+ }
19
+ /** A drop-in inventory grid with drag/swap/stack-merge/split and full keyboard control, bound to `inventoryId`. */
20
+ export declare function InventoryGrid({ inventoryId, columns, size, gap, itemIcon, onActivate, className, style, }: InventoryGridProps): import("react").JSX.Element;