@jgengine/react 0.11.0 → 0.13.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 +52 -0
- package/dist/actionHud.d.ts +5 -5
- package/dist/actionHud.js +14 -12
- package/dist/bars.d.ts +103 -0
- package/dist/bars.js +180 -0
- package/dist/barsPreview.d.ts +4 -0
- package/dist/barsPreview.js +47 -0
- package/dist/characterSheet.d.ts +120 -0
- package/dist/characterSheet.js +134 -0
- package/dist/components.d.ts +8 -1
- package/dist/components.js +8 -1
- package/dist/hooks.d.ts +31 -0
- package/dist/hooks.js +53 -0
- package/dist/hud.d.ts +21 -4
- package/dist/hud.js +38 -27
- package/dist/hudFrame.d.ts +47 -0
- package/dist/hudFrame.js +80 -0
- package/dist/hudLayout.d.ts +13 -1
- package/dist/hudLayout.js +18 -12
- package/dist/hudLayoutPersist.d.ts +23 -0
- package/dist/hudLayoutPersist.js +19 -0
- package/dist/hudTheme.d.ts +76 -0
- package/dist/hudTheme.js +156 -0
- package/dist/hudThemePreview.d.ts +4 -0
- package/dist/hudThemePreview.js +37 -0
- package/dist/iconTreatment.d.ts +50 -0
- package/dist/iconTreatment.js +84 -0
- package/dist/iconsPreview.d.ts +4 -0
- package/dist/iconsPreview.js +29 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/inventoryGrid.d.ts +20 -0
- package/dist/inventoryGrid.js +135 -0
- package/dist/map.d.ts +72 -10
- package/dist/map.js +84 -15
- package/dist/panels.d.ts +107 -0
- package/dist/panels.js +215 -0
- package/dist/selectionHud.d.ts +11 -5
- package/dist/selectionHud.js +11 -5
- package/dist/slots.d.ts +47 -0
- package/dist/slots.js +37 -0
- package/package.json +2 -2
- package/skills/jgengine/SKILL.md +8 -4
- package/skills/jgengine/api.md +182 -81
- package/skills/jgengine/capabilities.md +104 -2
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-ui/SKILL.md +17 -10
- package/skills/jgengine-ui/api.md +316 -51
- package/skills/jgengine-ui/capabilities.md +135 -2
- package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
- package/skills/jgengine-ui/reference.md +59 -9
- package/skills/jgengine-ui/references/visual-scorecard.md +1 -1
- package/skills/jgengine/reference-cartridge.md +0 -72
package/dist/panels.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
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
|
+
* @capability panel-host render a manager's open windows as draggable, closable, z-stacked dialogs
|
|
190
|
+
*/
|
|
191
|
+
export function PanelHost({ manager, render, panels, variation, shape, width, className, windowClassName, windowStyle, bodyStyle, }) {
|
|
192
|
+
const open = orderedOpen(manager.state, manager.defs);
|
|
193
|
+
const content = (id) => render !== undefined ? render(id) : (panels?.[id] ?? null);
|
|
194
|
+
return (_jsx("div", { "data-jg-panel-host": "", className: className, style: { position: "absolute", inset: 0, 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))) }));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* A single standalone window — title bar, close button, and title-bar drag over {@link HudFrame} chrome —
|
|
198
|
+
* usable without the {@link usePanels} manager for a one-off dialog. Position is uncontrolled (seeded by
|
|
199
|
+
* `x`/`y`) unless both `x`/`y` and `onMove` are supplied, in which case the caller owns placement.
|
|
200
|
+
* Accessible: `role="dialog"` with an `aria-label` and a focusable close button.
|
|
201
|
+
*
|
|
202
|
+
* @capability window standalone draggable, closable window primitive over HudFrame — no manager required
|
|
203
|
+
*/
|
|
204
|
+
export function Window({ title, ariaLabel, closable = true, onClose, x, y, onMove, z, width, variation, shape, className, style, bodyStyle, children, }) {
|
|
205
|
+
const controlled = x !== undefined && y !== undefined && onMove !== undefined;
|
|
206
|
+
const [localPos, setLocalPos] = useState({ x: x ?? 56, y: y ?? 56 });
|
|
207
|
+
const pos = controlled ? { x: x, y: y } : localPos;
|
|
208
|
+
const handleMove = useCallback((next) => {
|
|
209
|
+
if (!controlled)
|
|
210
|
+
setLocalPos(next);
|
|
211
|
+
onMove?.(next);
|
|
212
|
+
}, [controlled, onMove]);
|
|
213
|
+
const label = ariaLabel ?? (typeof title === "string" ? title : undefined);
|
|
214
|
+
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 }));
|
|
215
|
+
}
|
package/dist/selectionHud.d.ts
CHANGED
|
@@ -35,6 +35,9 @@ export declare function useSelectionView(members: readonly EntitySummaryDef[], o
|
|
|
35
35
|
* A compact current/max vital meter for an entity summary; tone maps to a colour ramp. Reuse it as
|
|
36
36
|
* the vital row inside {@link EntitySummary} or any custom entity panel.
|
|
37
37
|
*
|
|
38
|
+
* @deprecated The `tone`/`vital`-API umbrella is retired (#1033). Use the atomic, purpose-named,
|
|
39
|
+
* token-themed bars from `@jgengine/react/bars` (`HealthBar`, `ShieldBar`, `ManaBar`, …) and compose
|
|
40
|
+
* your own panel — each does one readout and restyles globally via the shared `--jg-*` tokens.
|
|
38
41
|
* @capability vital-bar compact current/max vital meter for an entity summary
|
|
39
42
|
*/
|
|
40
43
|
export declare function VitalBar({ vital, width, showLabel, className, style, }: {
|
|
@@ -67,6 +70,9 @@ export declare function EntityPortrait({ entity, selected, size, vitalId, tabInd
|
|
|
67
70
|
* "who is selected" summary a single-selection or the head of a multi-selection shows. Swap the vital
|
|
68
71
|
* rows via `renderVital`.
|
|
69
72
|
*
|
|
73
|
+
* @deprecated Bundling a whole vitals array in one component is the combo anti-pattern #1033 retires.
|
|
74
|
+
* Compose your own summary — name/tags plus the atomic `@jgengine/react/bars` (`HealthBar`, …) you
|
|
75
|
+
* need — so no engine component packages multiple readouts by default. Kept during migration.
|
|
70
76
|
* @capability entity-summary primary selected-entity detail panel (name, tags, vitals)
|
|
71
77
|
*/
|
|
72
78
|
export declare function EntitySummary({ entity, renderVital, className, style, }: {
|
|
@@ -111,12 +117,12 @@ export declare function SelectionCollectionChrome({ model, itemSize, gap, window
|
|
|
111
117
|
style?: CSSProperties;
|
|
112
118
|
}): import("react").JSX.Element;
|
|
113
119
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* `
|
|
117
|
-
*
|
|
120
|
+
* Convenience composition: skinned glass panel with {@link EntitySummary} for the primary member
|
|
121
|
+
* beside {@link SelectionCollectionChrome} for the rest — demos/scaffold only, not a finished game
|
|
122
|
+
* face. Pass `members` (or a prebuilt `model`); swap sub-renderers or compose the headless pieces
|
|
123
|
+
* under game-owned chrome for product UI.
|
|
118
124
|
*
|
|
119
|
-
* @capability selection-panel
|
|
125
|
+
* @capability selection-panel optional selection-panel composition: summary + portrait strip / group chips
|
|
120
126
|
*/
|
|
121
127
|
export declare function SelectionPanel({ members, model: providedModel, groupThreshold, labelOf, windowSize, renderPrimary, renderMember, renderVital, onFocusChange, className, style, }: {
|
|
122
128
|
members?: readonly EntitySummaryDef[];
|
package/dist/selectionHud.js
CHANGED
|
@@ -65,6 +65,9 @@ const VITAL_TONES = {
|
|
|
65
65
|
* A compact current/max vital meter for an entity summary; tone maps to a colour ramp. Reuse it as
|
|
66
66
|
* the vital row inside {@link EntitySummary} or any custom entity panel.
|
|
67
67
|
*
|
|
68
|
+
* @deprecated The `tone`/`vital`-API umbrella is retired (#1033). Use the atomic, purpose-named,
|
|
69
|
+
* token-themed bars from `@jgengine/react/bars` (`HealthBar`, `ShieldBar`, `ManaBar`, …) and compose
|
|
70
|
+
* your own panel — each does one readout and restyles globally via the shared `--jg-*` tokens.
|
|
68
71
|
* @capability vital-bar compact current/max vital meter for an entity summary
|
|
69
72
|
*/
|
|
70
73
|
export function VitalBar({ vital, width = 120, showLabel = true, className, style, }) {
|
|
@@ -115,6 +118,9 @@ export function EntityPortrait({ entity, selected = false, size = 44, vitalId =
|
|
|
115
118
|
* "who is selected" summary a single-selection or the head of a multi-selection shows. Swap the vital
|
|
116
119
|
* rows via `renderVital`.
|
|
117
120
|
*
|
|
121
|
+
* @deprecated Bundling a whole vitals array in one component is the combo anti-pattern #1033 retires.
|
|
122
|
+
* Compose your own summary — name/tags plus the atomic `@jgengine/react/bars` (`HealthBar`, …) you
|
|
123
|
+
* need — so no engine component packages multiple readouts by default. Kept during migration.
|
|
118
124
|
* @capability entity-summary primary selected-entity detail panel (name, tags, vitals)
|
|
119
125
|
*/
|
|
120
126
|
export function EntitySummary({ entity, renderVital, className, style, }) {
|
|
@@ -165,12 +171,12 @@ const PANEL = {
|
|
|
165
171
|
font: "500 12px/1.2 ui-sans-serif, system-ui, sans-serif",
|
|
166
172
|
};
|
|
167
173
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* `
|
|
171
|
-
*
|
|
174
|
+
* Convenience composition: skinned glass panel with {@link EntitySummary} for the primary member
|
|
175
|
+
* beside {@link SelectionCollectionChrome} for the rest — demos/scaffold only, not a finished game
|
|
176
|
+
* face. Pass `members` (or a prebuilt `model`); swap sub-renderers or compose the headless pieces
|
|
177
|
+
* under game-owned chrome for product UI.
|
|
172
178
|
*
|
|
173
|
-
* @capability selection-panel
|
|
179
|
+
* @capability selection-panel optional selection-panel composition: summary + portrait strip / group chips
|
|
174
180
|
*/
|
|
175
181
|
export function SelectionPanel({ members, model: providedModel, groupThreshold, labelOf, windowSize, renderPrimary, renderMember, renderVital, onFocusChange, className, style, }) {
|
|
176
182
|
const ownModel = useSelectionView(members ?? [], { groupThreshold, labelOf, onFocusChange });
|
package/dist/slots.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { type IconSchool } from "./iconTreatment.js";
|
|
3
|
+
import type { GameIconName } from "./gameIcons.js";
|
|
4
|
+
/**
|
|
5
|
+
* Atomic, purpose-named slot grids (#1033): one component per category — `EquipmentSlots`,
|
|
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.
|
|
13
|
+
*
|
|
14
|
+
* @capability hud-slots atomic themed equipment/weapon/armor/potion slot grids with painted icons
|
|
15
|
+
*/
|
|
16
|
+
/** One slot's content — a `GameIcon` glyph (or custom node), school tint, count, keycap, active flag. */
|
|
17
|
+
export interface SlotItem {
|
|
18
|
+
icon?: GameIconName;
|
|
19
|
+
glyph?: ReactNode;
|
|
20
|
+
school?: IconSchool;
|
|
21
|
+
count?: number;
|
|
22
|
+
keycap?: string;
|
|
23
|
+
active?: boolean;
|
|
24
|
+
/** Accessible label / tooltip. */
|
|
25
|
+
label?: string;
|
|
26
|
+
}
|
|
27
|
+
/** Props shared by every atomic slot grid. */
|
|
28
|
+
export interface SlotGridProps {
|
|
29
|
+
/** Slots in order; `null` (or a slot with no icon/glyph) renders an empty placeholder. */
|
|
30
|
+
items: readonly (SlotItem | null)[];
|
|
31
|
+
/** Tile size in px. Default 44. */
|
|
32
|
+
size?: number;
|
|
33
|
+
/** Grid columns; default = one row (all slots). */
|
|
34
|
+
columns?: number;
|
|
35
|
+
/** Gap between tiles in px. Default 6. */
|
|
36
|
+
gap?: number;
|
|
37
|
+
className?: string;
|
|
38
|
+
style?: CSSProperties;
|
|
39
|
+
}
|
|
40
|
+
/** Worn-gear slots (head/chest/hands/…): one atomic grid, painted icons, themed from tokens. */
|
|
41
|
+
export declare function EquipmentSlots(props: SlotGridProps): import("react").JSX.Element;
|
|
42
|
+
/** Weapon loadout slots. */
|
|
43
|
+
export declare function WeaponSlots(props: SlotGridProps): import("react").JSX.Element;
|
|
44
|
+
/** Armor piece slots. */
|
|
45
|
+
export declare function ArmorSlots(props: SlotGridProps): import("react").JSX.Element;
|
|
46
|
+
/** Consumable (health/shield potion) slots. */
|
|
47
|
+
export declare function PotionSlots(props: SlotGridProps): import("react").JSX.Element;
|
package/dist/slots.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import {} from "react";
|
|
3
|
+
import { IconTreatment } from "./iconTreatment.js";
|
|
4
|
+
function EmptySlot({ size }) {
|
|
5
|
+
return (_jsx("div", { "data-slot-empty": "", style: {
|
|
6
|
+
width: size,
|
|
7
|
+
height: size,
|
|
8
|
+
borderRadius: "var(--jg-slot-radius, 9px)",
|
|
9
|
+
background: "var(--jg-slot-bg, rgba(12,14,20,0.5))",
|
|
10
|
+
border: "var(--jg-slot-border, 1px solid rgba(255,255,255,0.1))",
|
|
11
|
+
boxShadow: "inset 0 2px 6px rgba(0,0,0,0.45)",
|
|
12
|
+
boxSizing: "border-box",
|
|
13
|
+
} }));
|
|
14
|
+
}
|
|
15
|
+
function SlotGrid({ category, items, size = 44, columns, gap = 6, className, style }) {
|
|
16
|
+
const cols = columns ?? items.length;
|
|
17
|
+
return (_jsx("div", { "data-slots": category, className: className, style: { display: "grid", gridTemplateColumns: `repeat(${Math.max(1, cols)}, ${size}px)`, gap, ...style }, children: items.map((item, index) => {
|
|
18
|
+
const filled = item !== null && (item.icon !== undefined || item.glyph !== undefined);
|
|
19
|
+
return filled ? (_jsx(IconTreatment, { ...(item.icon === undefined ? {} : { icon: item.icon }), ...(item.glyph === undefined ? {} : { glyph: item.glyph }), school: item.school ?? "neutral", size: size, ...(item.count === undefined ? {} : { count: item.count }), ...(item.keycap === undefined ? {} : { keycap: item.keycap }), active: item.active ?? false }, index)) : (_jsx(EmptySlot, { size: size }, index));
|
|
20
|
+
}) }));
|
|
21
|
+
}
|
|
22
|
+
/** Worn-gear slots (head/chest/hands/…): one atomic grid, painted icons, themed from tokens. */
|
|
23
|
+
export function EquipmentSlots(props) {
|
|
24
|
+
return _jsx(SlotGrid, { category: "equipment", ...props });
|
|
25
|
+
}
|
|
26
|
+
/** Weapon loadout slots. */
|
|
27
|
+
export function WeaponSlots(props) {
|
|
28
|
+
return _jsx(SlotGrid, { category: "weapon", ...props });
|
|
29
|
+
}
|
|
30
|
+
/** Armor piece slots. */
|
|
31
|
+
export function ArmorSlots(props) {
|
|
32
|
+
return _jsx(SlotGrid, { category: "armor", ...props });
|
|
33
|
+
}
|
|
34
|
+
/** Consumable (health/shield potion) slots. */
|
|
35
|
+
export function PotionSlots(props) {
|
|
36
|
+
return _jsx(SlotGrid, { category: "potion", ...props });
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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.13.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"react": "^19.0.0",
|
package/skills/jgengine/SKILL.md
CHANGED
|
@@ -9,16 +9,16 @@ Read the repository [README](../../../README.md) for package truth, commands, la
|
|
|
9
9
|
|
|
10
10
|
## 1. Establish the target
|
|
11
11
|
|
|
12
|
-
Capture only decisions that change architecture:
|
|
12
|
+
Treat the pitch as a unique composition of needs, not a genre to fill in — never reach for a genre kit, preset, archetype, or class template ("default sports car", "default RPG", "default FPS") in the SDK or skills (see [AGENTS.md](../../../AGENTS.md)). Use `game-design` to turn the pitch into a testable player promise, verbs, loops, progression, failure, and completion scenario. Use `level-design` when play depends on authored spaces, routes, encounters, exploration, or spatial pacing. Capture only decisions that change architecture:
|
|
13
13
|
|
|
14
14
|
- player point of view and controls
|
|
15
15
|
- world shape and authored content
|
|
16
16
|
- core loop, interaction, combat, and progression
|
|
17
17
|
- solo, local, hosted, or persistent multiplayer
|
|
18
|
-
- HUD/menu states and art direction
|
|
18
|
+
- HUD/menu states and **custom** UI art direction (every game owns its presentation — no stock drop-in face)
|
|
19
19
|
- one observable completion scenario
|
|
20
20
|
|
|
21
|
-
For a new in-repo game, scaffold with `bun run new:game <id>
|
|
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
23
|
## 2. Select domains
|
|
24
24
|
|
|
@@ -26,6 +26,8 @@ Load only rows the target needs. Do not preload every domain.
|
|
|
26
26
|
|
|
27
27
|
| Need | Skill | Owner |
|
|
28
28
|
| --- | --- | --- |
|
|
29
|
+
| player promise, pillars, verbs, loops, economy, progression, balance | `game-design` | experience and rule intent |
|
|
30
|
+
| metrics, topology, encounters, navigation, spatial pacing | `level-design` | playable space intent |
|
|
29
31
|
| scene placement, terrain painting, paths, asset authoring | `jgengine-editor` | editor document and authoring tools |
|
|
30
32
|
| runtime world, movement, input, interaction, AI, navigation, audio | `jgengine-world` | spatial simulation and world runtime |
|
|
31
33
|
| game state, systems, inventory, crafting, quests, economy, turns | `jgengine-gameplay` | non-combat rules and state |
|
|
@@ -34,15 +36,17 @@ Load only rows the target needs. Do not preload every domain.
|
|
|
34
36
|
| models, sprites, materials, audio files, source licensing | `jgengine-assets` | asset discovery and catalogs |
|
|
35
37
|
| authority, transport, replication, sessions, host persistence | `jgengine-multiplayer` | network topology and persistence adapters |
|
|
36
38
|
|
|
37
|
-
Scenes normally require both `jgengine-editor` for authoring and `jgengine-world` for runtime consumption. Combat and multiplayer are opt-in, not default intake.
|
|
39
|
+
World content routes editor-first: any request that adds, moves, restyles, or removes something visible in the world — however phrased ("design this world", "place the enemies", "make it look better") — starts in `jgengine-editor`; the other domains consume the authored document. Scenes normally require both `jgengine-editor` for authoring and `jgengine-world` for runtime consumption. Combat and multiplayer are opt-in, not default intake.
|
|
38
40
|
|
|
39
41
|
## 3. Discover before designing
|
|
40
42
|
|
|
41
43
|
Within each selected domain:
|
|
42
44
|
|
|
45
|
+
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.
|
|
43
46
|
1. Search `capabilities.md` by intent; it maps needs to imports.
|
|
44
47
|
2. Open `api.md` only when exact signatures or export inventory are needed.
|
|
45
48
|
3. Open the linked reference only for a deeper recipe or trap.
|
|
49
|
+
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.
|
|
46
50
|
|
|
47
51
|
If no capability fits, identify the upstream package seam before writing game-local code. In this repository, implement a reusable primitive with its first adopter; if editor-owned content cannot be expressed, file the editor gap first.
|
|
48
52
|
|