@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 +73 -1
- package/dist/characterSheet.d.ts +120 -0
- package/dist/characterSheet.js +134 -0
- package/dist/hooks.d.ts +11 -0
- package/dist/hooks.js +15 -0
- package/dist/hudLayout.js +138 -9
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/inventoryGrid.d.ts +20 -0
- package/dist/inventoryGrid.js +135 -0
- package/dist/panels.d.ts +115 -0
- package/dist/panels.js +221 -0
- package/dist/provider.d.ts +15 -0
- package/dist/provider.js +17 -1
- package/dist/slots.d.ts +7 -4
- package/package.json +2 -2
- package/skills/jgengine/SKILL.md +5 -2
- package/skills/jgengine/api.md +13 -5
- package/skills/jgengine-ui/SKILL.md +9 -4
- package/skills/jgengine-ui/api.md +138 -8
- package/skills/jgengine-ui/capabilities.md +72 -0
- package/skills/jgengine-ui/reference.md +40 -3
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
|
|
3
|
+
import { moveGridFocus } from "@jgengine/core/ui/actionModel";
|
|
4
|
+
import { DragGhost, DraggableCard, DropZone, useDragLayer } from "./dragLayer.js";
|
|
5
|
+
import { iconForItemId } from "./gameIcons.js";
|
|
6
|
+
import { IconTreatment, schoolForItem } from "./iconTreatment.js";
|
|
7
|
+
import { useInventoryGrid } from "./hooks.js";
|
|
8
|
+
/**
|
|
9
|
+
* A drop-in NxN inventory grid bound to a live inventory: `<InventoryGrid inventoryId="bag" columns={4} />`
|
|
10
|
+
* renders every slot (painted {@link IconTreatment} tile + count badge for filled, a themed `--jg-slot-*`
|
|
11
|
+
* placeholder for empty), and wires the real mechanics on top of the engine's inventory model —
|
|
12
|
+
* drag-to-move, swap, stack-merge with remainder (all via the `inventory.move` command), and stack-split
|
|
13
|
+
* (shift/right-click a stack, via `inventory.split`). Fully keyboard-accessible: `role="grid"`, arrow-key
|
|
14
|
+
* roving focus, Enter/Space to pick up and drop. Games own placement, skin, and terminology; the engine
|
|
15
|
+
* ships the working grid.
|
|
16
|
+
*
|
|
17
|
+
* @capability inventory-grid-hud drop-in NxN inventory grid with drag/swap/stack/split bound to a live inventory
|
|
18
|
+
*/
|
|
19
|
+
const ARROW_DIRECTIONS = {
|
|
20
|
+
ArrowRight: "right",
|
|
21
|
+
ArrowLeft: "left",
|
|
22
|
+
ArrowUp: "up",
|
|
23
|
+
ArrowDown: "down",
|
|
24
|
+
Home: "first",
|
|
25
|
+
End: "last",
|
|
26
|
+
};
|
|
27
|
+
function EmptySlot({ size, picked }) {
|
|
28
|
+
return (_jsx("div", { "data-slot-empty": "", style: {
|
|
29
|
+
width: size,
|
|
30
|
+
height: size,
|
|
31
|
+
borderRadius: "var(--jg-slot-radius, 9px)",
|
|
32
|
+
background: "var(--jg-slot-bg, rgba(12,14,20,0.5))",
|
|
33
|
+
border: picked
|
|
34
|
+
? "1px solid var(--jg-accent, #38bdf8)"
|
|
35
|
+
: "var(--jg-slot-border, 1px solid rgba(255,255,255,0.1))",
|
|
36
|
+
boxShadow: "inset 0 2px 6px rgba(0,0,0,0.45)",
|
|
37
|
+
boxSizing: "border-box",
|
|
38
|
+
} }));
|
|
39
|
+
}
|
|
40
|
+
/** A drop-in inventory grid with drag/swap/stack-merge/split and full keyboard control, bound to `inventoryId`. */
|
|
41
|
+
export function InventoryGrid({ inventoryId, columns, size = 44, gap = 6, itemIcon, onActivate, className, style, }) {
|
|
42
|
+
const { slots, move, split } = useInventoryGrid(inventoryId);
|
|
43
|
+
const cols = columns ?? Math.max(1, Math.ceil(Math.sqrt(slots.length)));
|
|
44
|
+
const [focus, setFocus] = useState(0);
|
|
45
|
+
const [picked, setPicked] = useState(null);
|
|
46
|
+
const cellRefs = useRef(new Map());
|
|
47
|
+
const layer = useDragLayer({
|
|
48
|
+
onDrop: (info) => {
|
|
49
|
+
const target = info.target === null ? null : Number(info.target);
|
|
50
|
+
if (target !== null && Number.isInteger(target) && target !== info.payload.value) {
|
|
51
|
+
move(info.payload.value, target);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (focus < slots.length)
|
|
57
|
+
cellRefs.current.get(focus)?.focus();
|
|
58
|
+
}, [focus, slots.length]);
|
|
59
|
+
const splitHalf = useCallback((index) => {
|
|
60
|
+
const slot = slots[index];
|
|
61
|
+
if (slot !== null && slot !== undefined && slot.count > 1)
|
|
62
|
+
split(index, Math.floor(slot.count / 2));
|
|
63
|
+
}, [slots, split]);
|
|
64
|
+
const onKeyDown = useCallback((event) => {
|
|
65
|
+
const dir = ARROW_DIRECTIONS[event.key];
|
|
66
|
+
if (dir !== undefined) {
|
|
67
|
+
const next = moveGridFocus(focus, slots.length, cols, dir);
|
|
68
|
+
if (next >= 0) {
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
setFocus(next);
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (event.key === "Enter" || event.key === " " || event.key === "Spacebar") {
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
if (picked === null) {
|
|
77
|
+
if (slots[focus] !== null && slots[focus] !== undefined)
|
|
78
|
+
setPicked(focus);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
if (picked !== focus)
|
|
82
|
+
move(picked, focus);
|
|
83
|
+
setPicked(null);
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (event.key === "Escape" && picked !== null) {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
setPicked(null);
|
|
90
|
+
}
|
|
91
|
+
}, [focus, slots, cols, picked, move]);
|
|
92
|
+
const rows = useMemo(() => {
|
|
93
|
+
const out = [];
|
|
94
|
+
for (let i = 0; i < slots.length; i += cols) {
|
|
95
|
+
out.push(Array.from({ length: Math.min(cols, slots.length - i) }, (_, k) => i + k));
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}, [slots.length, cols]);
|
|
99
|
+
const renderTile = (index) => {
|
|
100
|
+
const slot = slots[index] ?? null;
|
|
101
|
+
const isPicked = picked === index;
|
|
102
|
+
const label = slot === null
|
|
103
|
+
? `Empty slot ${index + 1}`
|
|
104
|
+
: `${slot.itemId}${slot.count > 1 ? ` (${slot.count})` : ""}, slot ${index + 1}`;
|
|
105
|
+
const custom = slot !== null ? itemIcon?.(slot.itemId) : undefined;
|
|
106
|
+
const named = slot !== null ? iconForItemId(slot.itemId) : null;
|
|
107
|
+
return (_jsx(DropZone, { id: String(index), layer: layer, children: _jsx("div", { role: "gridcell", "aria-label": label, "aria-selected": isPicked, tabIndex: focus === index ? 0 : -1, "data-slot": index, ref: (node) => {
|
|
108
|
+
if (node === null)
|
|
109
|
+
cellRefs.current.delete(index);
|
|
110
|
+
else
|
|
111
|
+
cellRefs.current.set(index, node);
|
|
112
|
+
}, onFocus: () => setFocus(index), onKeyDown: onKeyDown, onDoubleClick: () => {
|
|
113
|
+
if (slot !== null)
|
|
114
|
+
onActivate?.(index);
|
|
115
|
+
}, onClick: (event) => {
|
|
116
|
+
if (event.shiftKey) {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
splitHalf(index);
|
|
119
|
+
}
|
|
120
|
+
}, onContextMenu: (event) => {
|
|
121
|
+
if (slot !== null && slot.count > 1) {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
splitHalf(index);
|
|
124
|
+
}
|
|
125
|
+
}, style: { width: size, height: size, position: "relative", cursor: slot !== null ? "grab" : "default" }, children: slot !== null ? (_jsx(DraggableCard, { id: String(index), value: index, layer: layer, onRotate: false, children: custom !== undefined && custom !== null ? (_jsx(IconTreatment, { glyph: custom, size: size, count: slot.count, active: isPicked })) : (_jsx(IconTreatment, { ...(named === null ? {} : { icon: named }), school: schoolForItem(slot.itemId), size: size, count: slot.count, active: isPicked })) })) : (_jsx(EmptySlot, { size: size, picked: isPicked })) }) }, index));
|
|
126
|
+
};
|
|
127
|
+
return (_jsxs("div", { role: "grid", "aria-label": `${inventoryId} inventory`, "aria-colcount": cols, className: className, "data-inventory-grid": inventoryId, style: { display: "grid", gap, ...style }, children: [rows.map((row, r) => (_jsx("div", { role: "row", style: { display: "grid", gridTemplateColumns: `repeat(${cols}, ${size}px)`, gap }, children: row.map(renderTile) }, r))), _jsx(DragGhost, { layer: layer, children: (payload) => {
|
|
128
|
+
const slot = slots[payload.value] ?? null;
|
|
129
|
+
if (slot === null)
|
|
130
|
+
return null;
|
|
131
|
+
const named = iconForItemId(slot.itemId);
|
|
132
|
+
const custom = itemIcon?.(slot.itemId);
|
|
133
|
+
return custom !== undefined && custom !== null ? (_jsx(IconTreatment, { glyph: custom, size: size, count: slot.count })) : (_jsx(IconTreatment, { ...(named === null ? {} : { icon: named }), school: schoolForItem(slot.itemId), size: size, count: slot.count }));
|
|
134
|
+
} })] }));
|
|
135
|
+
}
|
package/dist/panels.d.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { type PanelDef, type PanelPosition, type PanelState } from "@jgengine/core/ui/panelModel";
|
|
3
|
+
import { type HudFrameShape, type HudFrameVariation } from "./hudFrame.js";
|
|
4
|
+
/**
|
|
5
|
+
* React chrome over the headless panel/window model (`@jgengine/core/ui/panelModel`) — the toggleable,
|
|
6
|
+
* draggable, z-stacked windows a WoW-style UI is made of (B for bag, C for character, ESC to close).
|
|
7
|
+
* `usePanels` is the DATA/HOOK layer (open/close/toggle/focus + a keybind listener), `PanelHost` renders
|
|
8
|
+
* the open windows in z-order, and `Window` is a standalone one-off window. All unskinned and
|
|
9
|
+
* token-driven: games reskin via `HudTheme` tokens and by swapping the `HudFrame` variation.
|
|
10
|
+
*/
|
|
11
|
+
/** Intent a key press resolves to over a panel set: toggle a panel, close the top window, or ignore. */
|
|
12
|
+
export interface PanelKeyResult {
|
|
13
|
+
handled: boolean;
|
|
14
|
+
type?: "toggle" | "closeTop";
|
|
15
|
+
id?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Pure keybind resolver for a panel set: ESC resolves to `closeTop` when a closable window is open, and
|
|
19
|
+
* any other key routes through {@link panelByHotkey} (trying `code` then `key`) to a `toggle`. Returns
|
|
20
|
+
* the intent without touching the DOM, so it unit-tests headless and `usePanels` is a thin shell over it.
|
|
21
|
+
*/
|
|
22
|
+
export declare function panelKeyAction(defs: readonly PanelDef[], state: PanelState, event: {
|
|
23
|
+
code?: string;
|
|
24
|
+
key?: string;
|
|
25
|
+
}): PanelKeyResult;
|
|
26
|
+
/** Options for {@link usePanels}. */
|
|
27
|
+
export interface UsePanelsOptions {
|
|
28
|
+
/** Fired when a panel transitions from closed to open (not on refocus of an already-open panel). */
|
|
29
|
+
onOpen?: (id: string) => void;
|
|
30
|
+
/** Fired when an open panel is closed. */
|
|
31
|
+
onClose?: (id: string) => void;
|
|
32
|
+
/** Set false to skip installing the window keybind/ESC listener (default true). */
|
|
33
|
+
hotkeys?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** The live panel manager returned by {@link usePanels} and consumed by {@link PanelHost}. */
|
|
36
|
+
export interface PanelsManager {
|
|
37
|
+
state: PanelState;
|
|
38
|
+
defs: readonly PanelDef[];
|
|
39
|
+
isOpen: (id: string) => boolean;
|
|
40
|
+
open: (id: string) => void;
|
|
41
|
+
close: (id: string) => void;
|
|
42
|
+
toggle: (id: string) => void;
|
|
43
|
+
focus: (id: string) => void;
|
|
44
|
+
move: (id: string, pos: PanelPosition) => void;
|
|
45
|
+
byHotkey: (code: string) => string | null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The DATA/HOOK layer: wrap the core panel model in state and install a window keybind listener —
|
|
49
|
+
* per-panel hotkeys toggle their window and ESC closes the topmost closable one. Hotkeys are ignored
|
|
50
|
+
* while the player is typing in an input/textarea/contenteditable. The listener is cleaned up on
|
|
51
|
+
* unmount and SSR-safe (armed in an effect). Feed the returned manager to {@link PanelHost}, or drive a
|
|
52
|
+
* bespoke window layout from it directly.
|
|
53
|
+
*
|
|
54
|
+
* @capability use-panels headless toggleable-window manager with keybind + ESC handling over the core panel model
|
|
55
|
+
*/
|
|
56
|
+
export declare function usePanels(defs: readonly PanelDef[], options?: UsePanelsOptions): PanelsManager;
|
|
57
|
+
/**
|
|
58
|
+
* Renders the open panels of a {@link usePanels} manager as absolutely-positioned, draggable, closable
|
|
59
|
+
* windows in z-order — the WoW window layer. Each window uses {@link HudFrame} chrome, is dragged by its
|
|
60
|
+
* title bar, raises focus on pointer-down, and gets its content from the `render` prop (or a `panels`
|
|
61
|
+
* map). Accessible: every window is a `role="dialog"` with an `aria-label` and a focusable close button;
|
|
62
|
+
* ESC-close is wired by the manager. Unskinned — art-direct via `HudTheme` tokens and the `variation`.
|
|
63
|
+
*
|
|
64
|
+
* The host establishes its own stacking context at `zIndexBase` (default 40) so windows always paint
|
|
65
|
+
* *above* the HUD (stat bars, nameplates, frames typically sit at z 10–30) — no game should have to
|
|
66
|
+
* rediscover "my vitals bar bleeds through the open window" and wrap this in a `z-*` of its own. Per-
|
|
67
|
+
* window focus order still resolves inside that context, so clicking a window raises it over its peers.
|
|
68
|
+
* Raise `zIndexBase` to sit above a full-screen modal, or lower it to tuck windows beneath one.
|
|
69
|
+
*
|
|
70
|
+
* @capability panel-host render a manager's open windows as draggable, closable, z-stacked dialogs above the HUD
|
|
71
|
+
*/
|
|
72
|
+
export declare function PanelHost({ manager, render, panels, variation, shape, width, zIndexBase, className, windowClassName, windowStyle, bodyStyle, }: {
|
|
73
|
+
manager: PanelsManager;
|
|
74
|
+
/** Content for a panel by id. Takes precedence over `panels`. */
|
|
75
|
+
render?: (id: string) => ReactNode;
|
|
76
|
+
/** Static content map by panel id, used when `render` is omitted. */
|
|
77
|
+
panels?: Record<string, ReactNode>;
|
|
78
|
+
variation?: HudFrameVariation;
|
|
79
|
+
shape?: HudFrameShape;
|
|
80
|
+
/** Default window width (per-panel unless overridden by the panel content). */
|
|
81
|
+
width?: number | string;
|
|
82
|
+
/** z-index of the host's stacking context — the floor every window paints above. Default 40 (above the HUD). */
|
|
83
|
+
zIndexBase?: number;
|
|
84
|
+
className?: string;
|
|
85
|
+
windowClassName?: string;
|
|
86
|
+
windowStyle?: CSSProperties;
|
|
87
|
+
bodyStyle?: CSSProperties;
|
|
88
|
+
}): import("react").JSX.Element;
|
|
89
|
+
/**
|
|
90
|
+
* A single standalone window — title bar, close button, and title-bar drag over {@link HudFrame} chrome —
|
|
91
|
+
* usable without the {@link usePanels} manager for a one-off dialog. Position is uncontrolled (seeded by
|
|
92
|
+
* `x`/`y`) unless both `x`/`y` and `onMove` are supplied, in which case the caller owns placement.
|
|
93
|
+
* Accessible: `role="dialog"` with an `aria-label` and a focusable close button.
|
|
94
|
+
*
|
|
95
|
+
* @capability window standalone draggable, closable window primitive over HudFrame — no manager required
|
|
96
|
+
*/
|
|
97
|
+
export declare function Window({ title, ariaLabel, closable, onClose, x, y, onMove, z, width, variation, shape, className, style, bodyStyle, children, }: {
|
|
98
|
+
title: ReactNode;
|
|
99
|
+
ariaLabel?: string;
|
|
100
|
+
closable?: boolean;
|
|
101
|
+
onClose?: () => void;
|
|
102
|
+
/** Initial (uncontrolled) or current (controlled, with `onMove`) x position. Default 56. */
|
|
103
|
+
x?: number;
|
|
104
|
+
/** Initial (uncontrolled) or current (controlled, with `onMove`) y position. Default 56. */
|
|
105
|
+
y?: number;
|
|
106
|
+
onMove?: (pos: PanelPosition) => void;
|
|
107
|
+
z?: number;
|
|
108
|
+
width?: number | string;
|
|
109
|
+
variation?: HudFrameVariation;
|
|
110
|
+
shape?: HudFrameShape;
|
|
111
|
+
className?: string;
|
|
112
|
+
style?: CSSProperties;
|
|
113
|
+
bodyStyle?: CSSProperties;
|
|
114
|
+
children?: ReactNode;
|
|
115
|
+
}): import("react").JSX.Element;
|
package/dist/panels.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState, } from "react";
|
|
3
|
+
import { closePanel, closeTopPanel, createPanelState, focusPanel, isOpen as isPanelOpen, movePanel, openPanel, orderedOpen, panelByHotkey, } from "@jgengine/core/ui/panelModel";
|
|
4
|
+
import { HudFrame } from "./hudFrame.js";
|
|
5
|
+
/**
|
|
6
|
+
* Pure keybind resolver for a panel set: ESC resolves to `closeTop` when a closable window is open, and
|
|
7
|
+
* any other key routes through {@link panelByHotkey} (trying `code` then `key`) to a `toggle`. Returns
|
|
8
|
+
* the intent without touching the DOM, so it unit-tests headless and `usePanels` is a thin shell over it.
|
|
9
|
+
*/
|
|
10
|
+
export function panelKeyAction(defs, state, event) {
|
|
11
|
+
if (event.key === "Escape" || event.code === "Escape") {
|
|
12
|
+
return closeTopPanel(state) === state ? { handled: false } : { handled: true, type: "closeTop" };
|
|
13
|
+
}
|
|
14
|
+
const id = (event.code !== undefined ? panelByHotkey(defs, event.code) : null) ??
|
|
15
|
+
(event.key !== undefined ? panelByHotkey(defs, event.key) : null);
|
|
16
|
+
return id === null ? { handled: false } : { handled: true, type: "toggle", id };
|
|
17
|
+
}
|
|
18
|
+
/** @internal True when a key event targets a text input / textarea / contenteditable, where hotkeys must not fire. */
|
|
19
|
+
function isEditableTarget(target) {
|
|
20
|
+
const el = target;
|
|
21
|
+
if (el === null || typeof el.tagName !== "string")
|
|
22
|
+
return false;
|
|
23
|
+
const tag = el.tagName.toLowerCase();
|
|
24
|
+
return tag === "input" || tag === "textarea" || tag === "select" || el.isContentEditable === true;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The DATA/HOOK layer: wrap the core panel model in state and install a window keybind listener —
|
|
28
|
+
* per-panel hotkeys toggle their window and ESC closes the topmost closable one. Hotkeys are ignored
|
|
29
|
+
* while the player is typing in an input/textarea/contenteditable. The listener is cleaned up on
|
|
30
|
+
* unmount and SSR-safe (armed in an effect). Feed the returned manager to {@link PanelHost}, or drive a
|
|
31
|
+
* bespoke window layout from it directly.
|
|
32
|
+
*
|
|
33
|
+
* @capability use-panels headless toggleable-window manager with keybind + ESC handling over the core panel model
|
|
34
|
+
*/
|
|
35
|
+
export function usePanels(defs, options) {
|
|
36
|
+
const [state, setState] = useState(() => createPanelState(defs));
|
|
37
|
+
const stateRef = useRef(state);
|
|
38
|
+
stateRef.current = state;
|
|
39
|
+
const defsRef = useRef(defs);
|
|
40
|
+
defsRef.current = defs;
|
|
41
|
+
const onOpenRef = useRef(options?.onOpen);
|
|
42
|
+
onOpenRef.current = options?.onOpen;
|
|
43
|
+
const onCloseRef = useRef(options?.onClose);
|
|
44
|
+
onCloseRef.current = options?.onClose;
|
|
45
|
+
const open = useCallback((id) => {
|
|
46
|
+
const wasOpen = isPanelOpen(stateRef.current, id);
|
|
47
|
+
setState((s) => openPanel(s, id));
|
|
48
|
+
if (!wasOpen)
|
|
49
|
+
onOpenRef.current?.(id);
|
|
50
|
+
}, []);
|
|
51
|
+
const close = useCallback((id) => {
|
|
52
|
+
const wasOpen = isPanelOpen(stateRef.current, id);
|
|
53
|
+
setState((s) => closePanel(s, id));
|
|
54
|
+
if (wasOpen)
|
|
55
|
+
onCloseRef.current?.(id);
|
|
56
|
+
}, []);
|
|
57
|
+
const toggle = useCallback((id) => {
|
|
58
|
+
if (isPanelOpen(stateRef.current, id))
|
|
59
|
+
close(id);
|
|
60
|
+
else
|
|
61
|
+
open(id);
|
|
62
|
+
}, [open, close]);
|
|
63
|
+
const focus = useCallback((id) => setState((s) => focusPanel(s, id)), []);
|
|
64
|
+
const move = useCallback((id, pos) => setState((s) => movePanel(s, id, pos)), []);
|
|
65
|
+
const byHotkey = useCallback((code) => panelByHotkey(defsRef.current, code), []);
|
|
66
|
+
const hotkeysEnabled = options?.hotkeys !== false;
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!hotkeysEnabled || typeof window === "undefined")
|
|
69
|
+
return undefined;
|
|
70
|
+
const onKeyDown = (event) => {
|
|
71
|
+
if (event.defaultPrevented)
|
|
72
|
+
return;
|
|
73
|
+
if (event.key !== "Escape" && isEditableTarget(event.target))
|
|
74
|
+
return;
|
|
75
|
+
const result = panelKeyAction(defsRef.current, stateRef.current, {
|
|
76
|
+
code: event.code,
|
|
77
|
+
key: event.key,
|
|
78
|
+
});
|
|
79
|
+
if (!result.handled)
|
|
80
|
+
return;
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
if (result.type === "closeTop") {
|
|
83
|
+
const topId = topClosableOpen(stateRef.current);
|
|
84
|
+
setState((s) => closeTopPanel(s));
|
|
85
|
+
if (topId !== null)
|
|
86
|
+
onCloseRef.current?.(topId);
|
|
87
|
+
}
|
|
88
|
+
else if (result.type === "toggle" && result.id !== undefined) {
|
|
89
|
+
toggle(result.id);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
window.addEventListener("keydown", onKeyDown);
|
|
93
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
94
|
+
}, [hotkeysEnabled, toggle]);
|
|
95
|
+
return {
|
|
96
|
+
state,
|
|
97
|
+
defs,
|
|
98
|
+
isOpen: (id) => isPanelOpen(state, id),
|
|
99
|
+
open,
|
|
100
|
+
close,
|
|
101
|
+
toggle,
|
|
102
|
+
focus,
|
|
103
|
+
move,
|
|
104
|
+
byHotkey,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** @internal The id the next ESC would close, so the caller can fire `onClose` with it. */
|
|
108
|
+
function topClosableOpen(state) {
|
|
109
|
+
const next = closeTopPanel(state);
|
|
110
|
+
if (next === state)
|
|
111
|
+
return null;
|
|
112
|
+
for (const id of Object.keys(state.open)) {
|
|
113
|
+
if (next.open[id] !== true)
|
|
114
|
+
return id;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
// ---- Window chrome ---------------------------------------------------------
|
|
119
|
+
/** @internal Begin a pointer drag from the title bar, streaming new positions to `onMove` until release. */
|
|
120
|
+
function beginWindowDrag(event, origin, onMove) {
|
|
121
|
+
if (typeof window === "undefined")
|
|
122
|
+
return;
|
|
123
|
+
const startX = event.clientX;
|
|
124
|
+
const startY = event.clientY;
|
|
125
|
+
const base = { x: origin.x, y: origin.y };
|
|
126
|
+
const onPointerMove = (moveEvent) => {
|
|
127
|
+
onMove({ x: base.x + (moveEvent.clientX - startX), y: base.y + (moveEvent.clientY - startY) });
|
|
128
|
+
};
|
|
129
|
+
const onPointerUp = () => {
|
|
130
|
+
window.removeEventListener("pointermove", onPointerMove);
|
|
131
|
+
window.removeEventListener("pointerup", onPointerUp);
|
|
132
|
+
window.removeEventListener("pointercancel", onPointerUp);
|
|
133
|
+
};
|
|
134
|
+
window.addEventListener("pointermove", onPointerMove);
|
|
135
|
+
window.addEventListener("pointerup", onPointerUp);
|
|
136
|
+
window.addEventListener("pointercancel", onPointerUp);
|
|
137
|
+
}
|
|
138
|
+
const TITLEBAR_STYLE = {
|
|
139
|
+
display: "flex",
|
|
140
|
+
alignItems: "center",
|
|
141
|
+
justifyContent: "space-between",
|
|
142
|
+
gap: 8,
|
|
143
|
+
padding: "6px 8px 6px 10px",
|
|
144
|
+
cursor: "grab",
|
|
145
|
+
touchAction: "none",
|
|
146
|
+
userSelect: "none",
|
|
147
|
+
fontSize: 12,
|
|
148
|
+
fontWeight: 600,
|
|
149
|
+
letterSpacing: 0.3,
|
|
150
|
+
borderBottom: "var(--jg-frame-border, 1px solid rgba(255,255,255,0.10))",
|
|
151
|
+
};
|
|
152
|
+
const CLOSE_BUTTON_STYLE = {
|
|
153
|
+
display: "inline-flex",
|
|
154
|
+
alignItems: "center",
|
|
155
|
+
justifyContent: "center",
|
|
156
|
+
width: 20,
|
|
157
|
+
height: 20,
|
|
158
|
+
padding: 0,
|
|
159
|
+
borderRadius: 6,
|
|
160
|
+
border: "1px solid transparent",
|
|
161
|
+
background: "transparent",
|
|
162
|
+
color: "inherit",
|
|
163
|
+
font: "inherit",
|
|
164
|
+
lineHeight: 1,
|
|
165
|
+
cursor: "pointer",
|
|
166
|
+
opacity: 0.75,
|
|
167
|
+
};
|
|
168
|
+
/** @internal Shared, fully-controlled window view — title bar (drag + close) plus a HudFrame body. */
|
|
169
|
+
function WindowView({ title, ariaLabel, closable, onClose, onFocus, onMove, pos, z, width, variation, shape, className, style, bodyStyle, children, }) {
|
|
170
|
+
return (_jsx("div", { role: "dialog", "aria-label": ariaLabel, "data-jg-window": "", className: className, onPointerDown: () => onFocus?.(), style: { position: "absolute", left: pos.x, top: pos.y, zIndex: z, pointerEvents: "auto", ...style }, children: _jsxs(HudFrame, { variation: variation, shape: shape, width: width, padding: 0, interactive: true, children: [_jsxs("div", { "data-jg-window-titlebar": "", onPointerDown: (event) => {
|
|
171
|
+
if (event.button !== 0)
|
|
172
|
+
return;
|
|
173
|
+
onFocus?.();
|
|
174
|
+
if (onMove !== undefined)
|
|
175
|
+
beginWindowDrag(event, pos, onMove);
|
|
176
|
+
}, style: TITLEBAR_STYLE, children: [_jsx("span", { "data-jg-window-title": "", children: title }), closable ? (_jsx("button", { type: "button", "data-jg-window-close": "", "aria-label": "Close", onClick: onClose, onPointerDown: (event) => event.stopPropagation(), style: CLOSE_BUTTON_STYLE, children: "\u2715" })) : null] }), _jsx("div", { "data-jg-window-body": "", style: { padding: 10, ...bodyStyle }, children: children })] }) }));
|
|
177
|
+
}
|
|
178
|
+
/** @internal Fallback cascade position for a window whose state has no override yet. */
|
|
179
|
+
function cascade(index) {
|
|
180
|
+
return { x: 56 + index * 28, y: 56 + index * 28 };
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Renders the open panels of a {@link usePanels} manager as absolutely-positioned, draggable, closable
|
|
184
|
+
* windows in z-order — the WoW window layer. Each window uses {@link HudFrame} chrome, is dragged by its
|
|
185
|
+
* title bar, raises focus on pointer-down, and gets its content from the `render` prop (or a `panels`
|
|
186
|
+
* map). Accessible: every window is a `role="dialog"` with an `aria-label` and a focusable close button;
|
|
187
|
+
* ESC-close is wired by the manager. Unskinned — art-direct via `HudTheme` tokens and the `variation`.
|
|
188
|
+
*
|
|
189
|
+
* The host establishes its own stacking context at `zIndexBase` (default 40) so windows always paint
|
|
190
|
+
* *above* the HUD (stat bars, nameplates, frames typically sit at z 10–30) — no game should have to
|
|
191
|
+
* rediscover "my vitals bar bleeds through the open window" and wrap this in a `z-*` of its own. Per-
|
|
192
|
+
* window focus order still resolves inside that context, so clicking a window raises it over its peers.
|
|
193
|
+
* Raise `zIndexBase` to sit above a full-screen modal, or lower it to tuck windows beneath one.
|
|
194
|
+
*
|
|
195
|
+
* @capability panel-host render a manager's open windows as draggable, closable, z-stacked dialogs above the HUD
|
|
196
|
+
*/
|
|
197
|
+
export function PanelHost({ manager, render, panels, variation, shape, width, zIndexBase = 40, className, windowClassName, windowStyle, bodyStyle, }) {
|
|
198
|
+
const open = orderedOpen(manager.state, manager.defs);
|
|
199
|
+
const content = (id) => render !== undefined ? render(id) : (panels?.[id] ?? null);
|
|
200
|
+
return (_jsx("div", { "data-jg-panel-host": "", className: className, style: { position: "absolute", inset: 0, zIndex: zIndexBase, pointerEvents: "none" }, children: open.map((def, index) => (_jsx(WindowView, { title: def.title ?? def.id, ariaLabel: def.title ?? def.id, closable: def.closable !== false, onClose: () => manager.close(def.id), onFocus: () => manager.focus(def.id), onMove: (pos) => manager.move(def.id, pos), pos: manager.state.pos[def.id] ?? cascade(index), z: manager.state.z[def.id] ?? 1, width: width, variation: variation, shape: shape, className: windowClassName, style: windowStyle, bodyStyle: bodyStyle, children: content(def.id) }, def.id))) }));
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* A single standalone window — title bar, close button, and title-bar drag over {@link HudFrame} chrome —
|
|
204
|
+
* usable without the {@link usePanels} manager for a one-off dialog. Position is uncontrolled (seeded by
|
|
205
|
+
* `x`/`y`) unless both `x`/`y` and `onMove` are supplied, in which case the caller owns placement.
|
|
206
|
+
* Accessible: `role="dialog"` with an `aria-label` and a focusable close button.
|
|
207
|
+
*
|
|
208
|
+
* @capability window standalone draggable, closable window primitive over HudFrame — no manager required
|
|
209
|
+
*/
|
|
210
|
+
export function Window({ title, ariaLabel, closable = true, onClose, x, y, onMove, z, width, variation, shape, className, style, bodyStyle, children, }) {
|
|
211
|
+
const controlled = x !== undefined && y !== undefined && onMove !== undefined;
|
|
212
|
+
const [localPos, setLocalPos] = useState({ x: x ?? 56, y: y ?? 56 });
|
|
213
|
+
const pos = controlled ? { x: x, y: y } : localPos;
|
|
214
|
+
const handleMove = useCallback((next) => {
|
|
215
|
+
if (!controlled)
|
|
216
|
+
setLocalPos(next);
|
|
217
|
+
onMove?.(next);
|
|
218
|
+
}, [controlled, onMove]);
|
|
219
|
+
const label = ariaLabel ?? (typeof title === "string" ? title : undefined);
|
|
220
|
+
return (_jsx(WindowView, { title: title, ariaLabel: label, closable: closable, onClose: onClose, onMove: handleMove, pos: pos, z: z, width: width, variation: variation, shape: shape, className: className, style: style, bodyStyle: bodyStyle, children: children }));
|
|
221
|
+
}
|
package/dist/provider.d.ts
CHANGED
|
@@ -4,6 +4,21 @@ export declare function GameProvider({ context, children }: {
|
|
|
4
4
|
context: GameContext;
|
|
5
5
|
children?: ReactNode;
|
|
6
6
|
}): import("react").JSX.Element;
|
|
7
|
+
/**
|
|
8
|
+
* Re-provide a captured {@link GameContext} across a nested React reconciler boundary. The `<Canvas>`
|
|
9
|
+
* of `@react-three/fiber` renders its children in a *separate* React root, so a `GameProvider` in the
|
|
10
|
+
* outer tree does NOT reach inside it — hooks like `useGameContext` / `useEntityRenderCues` throw. The
|
|
11
|
+
* fix the shell already uses internally: capture the context outside the boundary (with
|
|
12
|
+
* {@link useOptionalGameContext}) and wrap the inner tree in `GameContextBridge`. A `null` context
|
|
13
|
+
* renders children unchanged, so the same code works in showcases with no running game. This is the
|
|
14
|
+
* seam behind `EntityPreview` — reach for it directly when building a bespoke nested canvas.
|
|
15
|
+
*
|
|
16
|
+
* @capability game-context-bridge re-provide the GameContext across a nested Canvas/reconciler boundary
|
|
17
|
+
*/
|
|
18
|
+
export declare function GameContextBridge({ context, children, }: {
|
|
19
|
+
context: GameContext | null;
|
|
20
|
+
children?: ReactNode;
|
|
21
|
+
}): import("react").JSX.Element;
|
|
7
22
|
export declare function useGameContext(): GameContext;
|
|
8
23
|
/** The game context if a `GameProvider` is present, otherwise `null` — for chrome that may render outside a running game (showcases, previews). */
|
|
9
24
|
export declare function useOptionalGameContext(): GameContext | null;
|
package/dist/provider.js
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useContext } from "react";
|
|
3
3
|
const GameReactContext = createContext(null);
|
|
4
4
|
export function GameProvider({ context, children }) {
|
|
5
5
|
return _jsx(GameReactContext.Provider, { value: context, children: children });
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Re-provide a captured {@link GameContext} across a nested React reconciler boundary. The `<Canvas>`
|
|
9
|
+
* of `@react-three/fiber` renders its children in a *separate* React root, so a `GameProvider` in the
|
|
10
|
+
* outer tree does NOT reach inside it — hooks like `useGameContext` / `useEntityRenderCues` throw. The
|
|
11
|
+
* fix the shell already uses internally: capture the context outside the boundary (with
|
|
12
|
+
* {@link useOptionalGameContext}) and wrap the inner tree in `GameContextBridge`. A `null` context
|
|
13
|
+
* renders children unchanged, so the same code works in showcases with no running game. This is the
|
|
14
|
+
* seam behind `EntityPreview` — reach for it directly when building a bespoke nested canvas.
|
|
15
|
+
*
|
|
16
|
+
* @capability game-context-bridge re-provide the GameContext across a nested Canvas/reconciler boundary
|
|
17
|
+
*/
|
|
18
|
+
export function GameContextBridge({ context, children, }) {
|
|
19
|
+
if (context === null)
|
|
20
|
+
return _jsx(_Fragment, { children: children });
|
|
21
|
+
return _jsx(GameReactContext.Provider, { value: context, children: children });
|
|
22
|
+
}
|
|
7
23
|
export function useGameContext() {
|
|
8
24
|
const ctx = useContext(GameReactContext);
|
|
9
25
|
if (ctx === null)
|
package/dist/slots.d.ts
CHANGED
|
@@ -3,10 +3,13 @@ import { type IconSchool } from "./iconTreatment.js";
|
|
|
3
3
|
import type { GameIconName } from "./gameIcons.js";
|
|
4
4
|
/**
|
|
5
5
|
* Atomic, purpose-named slot grids (#1033): one component per category — `EquipmentSlots`,
|
|
6
|
-
* `WeaponSlots`, `ArmorSlots`, `PotionSlots` —
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* `WeaponSlots`, `ArmorSlots`, `PotionSlots` — for paperdoll / equip layouts where each grid holds a
|
|
7
|
+
* single gear category. A full multi-category backpack that binds to a live inventory with
|
|
8
|
+
* drag/swap/stack/split is a different, supported shape: reach for `<InventoryGrid>` (`inventoryGrid.tsx`)
|
|
9
|
+
* there, and use these atomic grids for the equip slots around it. Each renders a grid of painted
|
|
10
|
+
* {@link IconTreatment} tiles (glyph + gradient + count badge) for filled slots and a themed placeholder
|
|
11
|
+
* for empty ones, all reading the shared `--jg-slot-*` / `--jg-accent` HudTheme tokens (#1034).
|
|
12
|
+
* Composition stays the game's; the engine ships the grid.
|
|
10
13
|
*
|
|
11
14
|
* @capability hud-slots atomic themed equipment/weapon/armor/potion slot grids with painted icons
|
|
12
15
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "bun test src"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@jgengine/core": "^0.
|
|
34
|
+
"@jgengine/core": "^0.14.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"react": "^19.0.0",
|
package/skills/jgengine/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Route game work to the smallest useful JGengine guidance set.
|
|
|
5
5
|
|
|
6
6
|
# JGengine intake and router
|
|
7
7
|
|
|
8
|
-
Read the
|
|
8
|
+
Read the JGengine README for package truth, commands, layering, and license — the repo-root [`README.md`](../../../README.md) in the monorepo, or [jgengine.com](https://jgengine.com) from a scaffolded consumer project (where the relative link does not resolve). This skill decides what to load; it is not an engine manual.
|
|
9
9
|
|
|
10
10
|
## 1. Establish the target
|
|
11
11
|
|
|
@@ -20,6 +20,8 @@ Treat the pitch as a unique composition of needs, not a genre to fill in — nev
|
|
|
20
20
|
|
|
21
21
|
For a new in-repo game, scaffold with `bun run new:game "<id-or-title>"` (thin script alias for `jgengine create`, auto-detects the in-repo variant). Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
|
|
22
22
|
|
|
23
|
+
For an existing game asked to pick up a new engine release ("jgengine published a new version, grab it"), run `npx jgengine upgrade` in the project first: it diffs installed `@jgengine/*` versions against the latest release and prints every Migrate step and Adopt-worthy addition in between (`--json` for structured output). Bump, migrate oldest-first, then work the Adopt lists instead of reconstructing the changelog by hand.
|
|
24
|
+
|
|
23
25
|
## 2. Select domains
|
|
24
26
|
|
|
25
27
|
Load only rows the target needs. Do not preload every domain.
|
|
@@ -42,7 +44,8 @@ World content routes editor-first: any request that adds, moves, restyles, or re
|
|
|
42
44
|
|
|
43
45
|
Within each selected domain:
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) for `combat-loop`, `boss-telegraph`, `loot`, `quest`, `coop-presence`, `third-person-camera`; `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
|
|
48
|
+
1. Search `capabilities.md` by intent; it maps needs to imports. Outside the monorepo (or before a domain skill is even loaded), `npx jgengine find <intent>` greps every shipped capability index at once and prints the primitive + import — reach for it the moment you're tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. If you're writing a `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob, a shipped drop-in almost certainly exists (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
|
|
46
49
|
2. Open `api.md` only when exact signatures or export inventory are needed.
|
|
47
50
|
3. Open the linked reference only for a deeper recipe or trap.
|
|
48
51
|
4. For how several primitives wire into a running loop, read the domain's `recipes/` — connected, genre-free walkthroughs organized by composition seam; this skill's own [recipes/minimal-game.md](recipes/minimal-game.md) is the default whole-game walkthrough. Prefer a recipe over reading a game's source; the games under `Games/*` are not templates or references, and a hybrid game is just a different composition of the same primitives.
|