@jgengine/react 0.10.0 → 0.12.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 +39 -2
- package/LICENSE +203 -661
- package/README.md +1 -1
- package/dist/actionHud.d.ts +174 -0
- package/dist/actionHud.js +294 -0
- 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/cards.d.ts +59 -0
- package/dist/cards.js +116 -0
- package/dist/chat.d.ts +3 -1
- package/dist/chat.js +2 -2
- package/dist/components.d.ts +28 -1
- package/dist/components.js +36 -1
- package/dist/display.d.ts +1 -0
- package/dist/display.js +1 -0
- package/dist/engineStore.d.ts +3 -0
- package/dist/engineStore.js +3 -0
- package/dist/fogOverlay.d.ts +3 -0
- package/dist/fogOverlay.js +3 -0
- package/dist/gameViewport.js +16 -0
- package/dist/hooks.d.ts +40 -0
- package/dist/hooks.js +69 -1
- package/dist/hud.d.ts +104 -0
- package/dist/hud.js +155 -0
- package/dist/hudFrame.d.ts +47 -0
- package/dist/hudFrame.js +80 -0
- package/dist/hudLayout.d.ts +31 -2
- package/dist/hudLayout.js +127 -10
- 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 +17 -0
- package/dist/index.js +17 -0
- package/dist/keyHint.d.ts +30 -0
- package/dist/keyHint.js +31 -0
- package/dist/map.d.ts +141 -11
- package/dist/map.js +138 -16
- package/dist/save.d.ts +25 -0
- package/dist/save.js +37 -0
- package/dist/selectSnapshot.d.ts +2 -0
- package/dist/selectSnapshot.js +2 -0
- package/dist/selectionHud.d.ts +139 -0
- package/dist/selectionHud.js +188 -0
- package/dist/skillCheckPaint.d.ts +2 -0
- package/dist/skillCheckPaint.js +2 -0
- package/dist/slots.d.ts +44 -0
- package/dist/slots.js +37 -0
- package/dist/startScreen.d.ts +62 -0
- package/dist/startScreen.js +62 -0
- package/dist/store.d.ts +24 -0
- package/dist/store.js +9 -0
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +54 -0
- package/skills/jgengine/api.md +527 -0
- package/skills/jgengine/capabilities.md +131 -0
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-ui/SKILL.md +41 -0
- package/skills/jgengine-ui/api.md +2007 -0
- package/skills/jgengine-ui/capabilities.md +211 -0
- package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
- package/skills/jgengine-ui/reference.md +513 -0
- package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
- package/llms.txt +0 -1724
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useMemo, useState, } from "react";
|
|
3
|
+
import { moveSelectionFocus, selectionWindow, summarizeSelection, } from "@jgengine/core/ui/selectionModel";
|
|
4
|
+
/**
|
|
5
|
+
* Composable React renderers over the headless selection view model
|
|
6
|
+
* (`@jgengine/core/ui/selectionModel`) — the data/renderer/chrome split for an RTS selection panel, a
|
|
7
|
+
* party frame, or a squad summary. `useSelectionView` is the DATA/HOOK layer (summary + focus +
|
|
8
|
+
* virtualization), `EntityPortrait`/`EntitySummary`/`SelectionCollectionChrome` are composable
|
|
9
|
+
* renderers, and `SelectionPanel` is the batteries-included default. A game supplies the entity data
|
|
10
|
+
* from wherever selection lives and swaps renderer or chrome without forking the logic.
|
|
11
|
+
*/
|
|
12
|
+
/** A move that steps the selection focus by keyboard/controller. */
|
|
13
|
+
const SELECTION_KEYS = {
|
|
14
|
+
ArrowRight: "next",
|
|
15
|
+
ArrowDown: "next",
|
|
16
|
+
ArrowLeft: "prev",
|
|
17
|
+
ArrowUp: "prev",
|
|
18
|
+
Home: "first",
|
|
19
|
+
End: "last",
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The DATA/HOOK layer: summarize a selected-entity list into a live model with a focused primary,
|
|
23
|
+
* ordered members, same-kind groups, keyboard focus stepping, and a virtualization window.
|
|
24
|
+
* Rendering-agnostic — feed it to {@link SelectionPanel} or read the `view` directly.
|
|
25
|
+
*
|
|
26
|
+
* @capability selection-model headless multi-entity selection model with primary focus, grouping, and virtualization
|
|
27
|
+
*/
|
|
28
|
+
export function useSelectionView(members, options) {
|
|
29
|
+
const [focusId, setFocusId] = useState(null);
|
|
30
|
+
const summarizeOptions = useMemo(() => {
|
|
31
|
+
const opts = {};
|
|
32
|
+
if (focusId !== null)
|
|
33
|
+
opts.primaryId = focusId;
|
|
34
|
+
if (options?.groupThreshold !== undefined)
|
|
35
|
+
opts.groupThreshold = options.groupThreshold;
|
|
36
|
+
if (options?.labelOf !== undefined)
|
|
37
|
+
opts.labelOf = options.labelOf;
|
|
38
|
+
return opts;
|
|
39
|
+
}, [focusId, options?.groupThreshold, options?.labelOf]);
|
|
40
|
+
const view = useMemo(() => summarizeSelection(members, summarizeOptions), [members, summarizeOptions]);
|
|
41
|
+
const setFocus = useCallback((id) => {
|
|
42
|
+
setFocusId(id);
|
|
43
|
+
options?.onFocusChange?.(id);
|
|
44
|
+
}, [options]);
|
|
45
|
+
const onKeyDown = useCallback((event) => {
|
|
46
|
+
const dir = SELECTION_KEYS[event.key];
|
|
47
|
+
if (dir === undefined)
|
|
48
|
+
return;
|
|
49
|
+
const next = moveSelectionFocus(view.count, view.focusIndex, dir);
|
|
50
|
+
const nextMember = view.members[next];
|
|
51
|
+
if (nextMember !== undefined)
|
|
52
|
+
setFocus(nextMember.id);
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
}, [view, setFocus]);
|
|
55
|
+
const windowFn = useCallback((scroll, size) => selectionWindow(members, scroll, size), [members]);
|
|
56
|
+
return { view, focusId, setFocus, onKeyDown, window: windowFn };
|
|
57
|
+
}
|
|
58
|
+
const VITAL_TONES = {
|
|
59
|
+
health: ["#22c55e", "#4ade80"],
|
|
60
|
+
shield: ["#0891b2", "#22d3ee"],
|
|
61
|
+
energy: ["#2563eb", "#60a5fa"],
|
|
62
|
+
neutral: ["#64748b", "#94a3b8"],
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* A compact current/max vital meter for an entity summary; tone maps to a colour ramp. Reuse it as
|
|
66
|
+
* the vital row inside {@link EntitySummary} or any custom entity panel.
|
|
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.
|
|
71
|
+
* @capability vital-bar compact current/max vital meter for an entity summary
|
|
72
|
+
*/
|
|
73
|
+
export function VitalBar({ vital, width = 120, showLabel = true, className, style, }) {
|
|
74
|
+
const denom = vital.max <= 0 ? 1 : vital.max;
|
|
75
|
+
const fraction = Math.max(0, Math.min(1, vital.current / denom));
|
|
76
|
+
const [from, to] = VITAL_TONES[vital.tone ?? "neutral"] ?? VITAL_TONES.neutral;
|
|
77
|
+
return (_jsxs("div", { className: className, "data-vital": vital.id, style: { width, ...style }, children: [showLabel ? (_jsxs("div", { style: { display: "flex", fontSize: 10, opacity: 0.75, marginBottom: 2 }, children: [_jsx("span", { children: vital.label ?? vital.id }), _jsxs("span", { style: { marginLeft: "auto", fontVariantNumeric: "tabular-nums" }, children: [Math.round(vital.current), "/", Math.round(vital.max)] })] })) : null, _jsx("div", { style: { height: 6, borderRadius: 999, background: "rgba(255,255,255,0.12)", overflow: "hidden" }, children: _jsx("div", { style: { width: `${fraction * 100}%`, height: "100%", background: `linear-gradient(90deg, ${from}, ${to})` } }) })] }));
|
|
78
|
+
}
|
|
79
|
+
const PORTRAIT_BASE = {
|
|
80
|
+
position: "relative",
|
|
81
|
+
boxSizing: "border-box",
|
|
82
|
+
display: "flex",
|
|
83
|
+
flexDirection: "column",
|
|
84
|
+
alignItems: "center",
|
|
85
|
+
justifyContent: "center",
|
|
86
|
+
border: "1px solid rgba(255,255,255,0.14)",
|
|
87
|
+
borderRadius: 8,
|
|
88
|
+
background: "rgba(14,17,22,0.72)",
|
|
89
|
+
color: "#eef2f8",
|
|
90
|
+
cursor: "pointer",
|
|
91
|
+
userSelect: "none",
|
|
92
|
+
padding: 2,
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* A selectable entity portrait tile — icon/initials, an optional thin health strip, and complete
|
|
96
|
+
* selected/hover/focus states as a real `<button>` (`aria-pressed`). The unit-of-a-selection chrome;
|
|
97
|
+
* swap the face via `renderIcon`. Accessible and keyboard-focusable.
|
|
98
|
+
*
|
|
99
|
+
* @capability entity-portrait selectable entity portrait tile with vitals and selected/focus states
|
|
100
|
+
*/
|
|
101
|
+
export function EntityPortrait({ entity, selected = false, size = 44, vitalId = "health", tabIndex, onSelect, renderIcon, className, style, }) {
|
|
102
|
+
const vital = entity.vitals?.find((v) => v.id === vitalId) ?? entity.vitals?.[0];
|
|
103
|
+
return (_jsxs("button", { type: "button", className: className, "data-entity-id": entity.id, "data-selected": selected ? "" : undefined, "aria-pressed": selected, "aria-label": entity.name ?? entity.id, title: entity.name ?? entity.id, tabIndex: tabIndex, onClick: () => onSelect?.(entity.id), style: {
|
|
104
|
+
...PORTRAIT_BASE,
|
|
105
|
+
width: size,
|
|
106
|
+
height: size,
|
|
107
|
+
borderColor: selected ? "rgba(56,189,248,0.95)" : "rgba(255,255,255,0.14)",
|
|
108
|
+
boxShadow: selected ? "0 0 0 2px rgba(56,189,248,0.6)" : "none",
|
|
109
|
+
...style,
|
|
110
|
+
}, children: [_jsx("span", { "data-entity-icon": true, "aria-hidden": true, style: { fontSize: renderIcon ? undefined : 16 }, children: renderIcon ? renderIcon(entity) : (entity.icon ?? (entity.name ?? entity.id).slice(0, 2)) }), vital !== undefined ? (_jsx("div", { style: { position: "absolute", left: 3, right: 3, bottom: 3, height: 3, borderRadius: 999, background: "rgba(255,255,255,0.12)", overflow: "hidden" }, children: _jsx("div", { style: {
|
|
111
|
+
width: `${Math.max(0, Math.min(1, vital.max <= 0 ? 0 : vital.current / vital.max)) * 100}%`,
|
|
112
|
+
height: "100%",
|
|
113
|
+
background: (VITAL_TONES[vital.tone ?? "health"] ?? VITAL_TONES.neutral)[0],
|
|
114
|
+
} }) })) : null] }));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The primary-entity detail chrome — name, tags, and full vital bars for the focused member. The
|
|
118
|
+
* "who is selected" summary a single-selection or the head of a multi-selection shows. Swap the vital
|
|
119
|
+
* rows via `renderVital`.
|
|
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.
|
|
124
|
+
* @capability entity-summary primary selected-entity detail panel (name, tags, vitals)
|
|
125
|
+
*/
|
|
126
|
+
export function EntitySummary({ entity, renderVital, className, style, }) {
|
|
127
|
+
return (_jsxs("div", { className: className, "data-entity-summary": entity.id, style: { display: "flex", flexDirection: "column", gap: 4, minWidth: 140, ...style }, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: 6 }, children: [_jsx("strong", { style: { fontSize: 14 }, children: entity.name ?? entity.id }), entity.kind !== undefined ? (_jsx("span", { style: { fontSize: 10, textTransform: "uppercase", letterSpacing: 0.4, opacity: 0.6 }, children: entity.kind })) : null] }), entity.tags !== undefined && entity.tags.length > 0 ? (_jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 4 }, children: entity.tags.map((tag) => (_jsx("span", { style: { fontSize: 9, padding: "1px 5px", borderRadius: 999, background: "rgba(255,255,255,0.1)" }, children: tag }, tag))) })) : null, (entity.vitals ?? []).map((vital) => (_jsx("div", { children: renderVital ? renderVital(vital) : _jsx(VitalBar, { vital: vital }) }, vital.id)))] }));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* A same-kind selection chip — icon plus count — for the grouped large-selection view. The default
|
|
131
|
+
* chrome {@link SelectionCollectionChrome} renders per group; swap it via `renderGroup`.
|
|
132
|
+
*
|
|
133
|
+
* @capability selection-group-chip same-kind selection chip (icon + count) for a grouped selection
|
|
134
|
+
*/
|
|
135
|
+
export function SelectionGroupChip({ group, onSelect, renderIcon, className, style, }) {
|
|
136
|
+
return (_jsxs("button", { type: "button", className: className, "data-selection-group": group.kind, "aria-label": `${group.count} ${group.label}`, title: `${group.count} ${group.label}`, onClick: () => onSelect?.(group), style: { ...PORTRAIT_BASE, width: 44, height: 44, ...style }, children: [_jsx("span", { "aria-hidden": true, style: { fontSize: 16 }, children: renderIcon ? renderIcon(group) : (group.icon ?? group.label.slice(0, 2)) }), _jsx("span", { style: { position: "absolute", right: 2, bottom: 1, fontSize: 10, fontWeight: 700 }, children: group.count })] }));
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The composable RENDERER for the member collection: a portrait strip for a small selection, or the
|
|
140
|
+
* same-kind group chips once the model is `grouped`. Roving tabindex + arrow-key focus. Swap
|
|
141
|
+
* `renderMember`/`renderGroup` for custom chrome, or read the model and lay it out yourself. No panel
|
|
142
|
+
* skin — a behavior/accessibility layer only.
|
|
143
|
+
*
|
|
144
|
+
* @capability selection-collection accessible portrait-strip / group-chip renderer over a selection model
|
|
145
|
+
*/
|
|
146
|
+
export function SelectionCollectionChrome({ model, itemSize = 44, gap = 4, windowSize, scroll = 0, ariaLabel = "Selection", renderMember, renderGroup, onGroupSelect, className, style, }) {
|
|
147
|
+
const { view } = model;
|
|
148
|
+
const rovingId = model.focusId ?? view.primary?.id ?? null;
|
|
149
|
+
const containerStyle = { display: "flex", flexWrap: "wrap", gap };
|
|
150
|
+
if (view.grouped) {
|
|
151
|
+
return (_jsx("div", { role: "group", "aria-label": ariaLabel, className: className, "data-selection-groups": "", style: { ...containerStyle, ...style }, children: view.groups.map((group) => renderGroup ? (_jsx("div", { children: renderGroup(group) }, group.kind)) : (_jsx(SelectionGroupChip, { group: group, onSelect: onGroupSelect }, group.kind))) }));
|
|
152
|
+
}
|
|
153
|
+
const win = windowSize !== undefined ? model.window(scroll, windowSize) : { start: 0, end: view.count, items: view.members };
|
|
154
|
+
return (_jsx("div", { role: "listbox", "aria-label": ariaLabel, "aria-orientation": "horizontal", className: className, "data-selection-strip": "", onKeyDown: model.onKeyDown, style: { ...containerStyle, ...style }, children: win.items.map((entity) => {
|
|
155
|
+
const selected = view.primary?.id === entity.id;
|
|
156
|
+
if (renderMember !== undefined) {
|
|
157
|
+
return (_jsx("div", { role: "option", "aria-selected": selected, children: renderMember(entity, selected) }, entity.id));
|
|
158
|
+
}
|
|
159
|
+
return (_jsx("div", { role: "option", "aria-selected": selected, children: _jsx(EntityPortrait, { entity: entity, selected: selected, size: itemSize, tabIndex: rovingId === entity.id ? 0 : -1, onSelect: model.setFocus }) }, entity.id));
|
|
160
|
+
}) }));
|
|
161
|
+
}
|
|
162
|
+
const PANEL = {
|
|
163
|
+
padding: 8,
|
|
164
|
+
borderRadius: 12,
|
|
165
|
+
background: "rgba(10,12,16,0.6)",
|
|
166
|
+
backdropFilter: "blur(6px)",
|
|
167
|
+
WebkitBackdropFilter: "blur(6px)",
|
|
168
|
+
border: "1px solid rgba(255,255,255,0.1)",
|
|
169
|
+
boxShadow: "0 4px 16px rgba(0,0,0,0.35)",
|
|
170
|
+
color: "#eef2f8",
|
|
171
|
+
font: "500 12px/1.2 ui-sans-serif, system-ui, sans-serif",
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
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.
|
|
178
|
+
*
|
|
179
|
+
* @capability selection-panel optional selection-panel composition: summary + portrait strip / group chips
|
|
180
|
+
*/
|
|
181
|
+
export function SelectionPanel({ members, model: providedModel, groupThreshold, labelOf, windowSize, renderPrimary, renderMember, renderVital, onFocusChange, className, style, }) {
|
|
182
|
+
const ownModel = useSelectionView(members ?? [], { groupThreshold, labelOf, onFocusChange });
|
|
183
|
+
const model = providedModel ?? ownModel;
|
|
184
|
+
const { view } = model;
|
|
185
|
+
if (view.count === 0)
|
|
186
|
+
return null;
|
|
187
|
+
return (_jsxs("div", { className: className, "data-selection-panel": "", style: { ...PANEL, display: "flex", gap: 12, alignItems: "flex-start", ...style }, children: [view.primary !== null ? (_jsx("div", { children: renderPrimary ? renderPrimary(view.primary) : _jsx(EntitySummary, { entity: view.primary, renderVital: renderVital }) })) : null, view.count > 1 ? (_jsx(SelectionCollectionChrome, { model: model, windowSize: windowSize, renderMember: renderMember, style: { maxWidth: 320 } })) : null] }));
|
|
188
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { SkillCheckConfig, SkillCheckResult } from "@jgengine/core/interaction/skillCheck";
|
|
2
2
|
import type { QteStep } from "@jgengine/core/interaction/qte";
|
|
3
|
+
/** @internal */
|
|
3
4
|
export declare function paintSkillCheckDom(root: HTMLElement, zone: HTMLElement, marker: HTMLElement, config: SkillCheckConfig, result: SkillCheckResult): void;
|
|
5
|
+
/** @internal */
|
|
4
6
|
export declare function paintQteStepDom(elements: ReadonlyMap<string, HTMLElement>, steps: readonly QteStep[], elapsed: number, activeId: string | null, stepClassName?: string, activeClassName?: string, doneClassName?: string): void;
|
package/dist/skillCheckPaint.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** @internal */
|
|
1
2
|
export function paintSkillCheckDom(root, zone, marker, config, result) {
|
|
2
3
|
const zoneLeft = (result.zone.start / config.trackWidth) * 100;
|
|
3
4
|
const zoneWidth = ((result.zone.end - result.zone.start) / config.trackWidth) * 100;
|
|
@@ -8,6 +9,7 @@ export function paintSkillCheckDom(root, zone, marker, config, result) {
|
|
|
8
9
|
root.dataset.inZone = result.success ? "true" : "false";
|
|
9
10
|
root.dataset.timedOut = result.timedOut ? "true" : "false";
|
|
10
11
|
}
|
|
12
|
+
/** @internal */
|
|
11
13
|
export function paintQteStepDom(elements, steps, elapsed, activeId, stepClassName, activeClassName, doneClassName) {
|
|
12
14
|
for (const step of steps) {
|
|
13
15
|
const el = elements.get(step.id);
|
package/dist/slots.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
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` — never a mega inventory that bundles every category.
|
|
7
|
+
* Each renders a grid of painted {@link IconTreatment} tiles (glyph + gradient + count badge) for
|
|
8
|
+
* filled slots and a themed placeholder for empty ones, all reading the shared `--jg-slot-*` /
|
|
9
|
+
* `--jg-accent` HudTheme tokens (#1034). Composition stays the game's; the engine ships the grid.
|
|
10
|
+
*
|
|
11
|
+
* @capability hud-slots atomic themed equipment/weapon/armor/potion slot grids with painted icons
|
|
12
|
+
*/
|
|
13
|
+
/** One slot's content — a `GameIcon` glyph (or custom node), school tint, count, keycap, active flag. */
|
|
14
|
+
export interface SlotItem {
|
|
15
|
+
icon?: GameIconName;
|
|
16
|
+
glyph?: ReactNode;
|
|
17
|
+
school?: IconSchool;
|
|
18
|
+
count?: number;
|
|
19
|
+
keycap?: string;
|
|
20
|
+
active?: boolean;
|
|
21
|
+
/** Accessible label / tooltip. */
|
|
22
|
+
label?: string;
|
|
23
|
+
}
|
|
24
|
+
/** Props shared by every atomic slot grid. */
|
|
25
|
+
export interface SlotGridProps {
|
|
26
|
+
/** Slots in order; `null` (or a slot with no icon/glyph) renders an empty placeholder. */
|
|
27
|
+
items: readonly (SlotItem | null)[];
|
|
28
|
+
/** Tile size in px. Default 44. */
|
|
29
|
+
size?: number;
|
|
30
|
+
/** Grid columns; default = one row (all slots). */
|
|
31
|
+
columns?: number;
|
|
32
|
+
/** Gap between tiles in px. Default 6. */
|
|
33
|
+
gap?: number;
|
|
34
|
+
className?: string;
|
|
35
|
+
style?: CSSProperties;
|
|
36
|
+
}
|
|
37
|
+
/** Worn-gear slots (head/chest/hands/…): one atomic grid, painted icons, themed from tokens. */
|
|
38
|
+
export declare function EquipmentSlots(props: SlotGridProps): import("react").JSX.Element;
|
|
39
|
+
/** Weapon loadout slots. */
|
|
40
|
+
export declare function WeaponSlots(props: SlotGridProps): import("react").JSX.Element;
|
|
41
|
+
/** Armor piece slots. */
|
|
42
|
+
export declare function ArmorSlots(props: SlotGridProps): import("react").JSX.Element;
|
|
43
|
+
/** Consumable (health/shield potion) slots. */
|
|
44
|
+
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
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { type ActionCodesMap } from "@jgengine/core/input/actionBindings";
|
|
3
|
+
/**
|
|
4
|
+
* One row of a control legend. Name the game action(s) whose bound key(s) to
|
|
5
|
+
* show (`action`) so the glyphs come straight from the keybind map — never
|
|
6
|
+
* re-typed — or give literal `keys` for controls that live outside the map
|
|
7
|
+
* (`"Mouse"`, `"LMB"`). `label` says what the control does.
|
|
8
|
+
*/
|
|
9
|
+
export interface ControlHint {
|
|
10
|
+
action?: string | readonly string[];
|
|
11
|
+
keys?: string | readonly string[];
|
|
12
|
+
label: string;
|
|
13
|
+
separator?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Renders a controls legend whose key glyphs come from the game's keybind map,
|
|
17
|
+
* so bindings live in one place and never drift from a hand-typed table. Hides
|
|
18
|
+
* itself on coarse-pointer devices (a touchscreen has no keyboard) via the same
|
|
19
|
+
* `data-jg-kbd-hint` marker as `KeyHint`. Headless: every part carries a
|
|
20
|
+
* `className` slot and `data-*` hook; pass `renderKey`/`renderRow` to fully own
|
|
21
|
+
* the markup.
|
|
22
|
+
*
|
|
23
|
+
* @capability controls-list keybind-derived control legend that hides on touch
|
|
24
|
+
*/
|
|
25
|
+
export declare function ControlsList({ bindings, controls, hideOnCoarsePointer, separator, className, rowClassName, keysClassName, keyClassName, separatorClassName, labelClassName, renderKey, renderRow, }: {
|
|
26
|
+
bindings?: ActionCodesMap;
|
|
27
|
+
controls: readonly ControlHint[];
|
|
28
|
+
hideOnCoarsePointer?: boolean;
|
|
29
|
+
separator?: string;
|
|
30
|
+
className?: string;
|
|
31
|
+
rowClassName?: string;
|
|
32
|
+
keysClassName?: string;
|
|
33
|
+
keyClassName?: string;
|
|
34
|
+
separatorClassName?: string;
|
|
35
|
+
labelClassName?: string;
|
|
36
|
+
renderKey?: (key: string, index: number) => ReactNode;
|
|
37
|
+
renderRow?: (row: {
|
|
38
|
+
keys: readonly string[];
|
|
39
|
+
label: string;
|
|
40
|
+
}, index: number) => ReactNode;
|
|
41
|
+
}): ReactNode;
|
|
42
|
+
/** Which corner of a `StartScreen` the opt-in settings slot is pinned to. */
|
|
43
|
+
export type StartScreenCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
|
44
|
+
/**
|
|
45
|
+
* Composable title/attract-screen scaffold: a full-bleed `data-jg-menu` overlay
|
|
46
|
+
* that positions and centers the game's own content, with an opt-in settings
|
|
47
|
+
* corner. It imposes no look — the game supplies the title, art, and buttons as
|
|
48
|
+
* children and styles the container through `className`/`style` (per the
|
|
49
|
+
* composable-chrome rule: a placement hook, not a mandated menu).
|
|
50
|
+
*
|
|
51
|
+
* @capability start-screen headless title/attract overlay the game fills and skins
|
|
52
|
+
*/
|
|
53
|
+
export declare function StartScreen({ open, className, style, children, settings, settingsPlacement, settingsClassName, settingsWrapperClassName, }: {
|
|
54
|
+
open?: boolean;
|
|
55
|
+
className?: string;
|
|
56
|
+
style?: CSSProperties;
|
|
57
|
+
children?: ReactNode;
|
|
58
|
+
settings?: boolean | ReactNode;
|
|
59
|
+
settingsPlacement?: StartScreenCorner;
|
|
60
|
+
settingsClassName?: string;
|
|
61
|
+
settingsWrapperClassName?: string;
|
|
62
|
+
}): ReactNode;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment } from "react";
|
|
3
|
+
import { actionLabel } from "@jgengine/core/input/actionBindings";
|
|
4
|
+
import { useDisplayProfile } from "./display.js";
|
|
5
|
+
import { Keycap } from "./keyHint.js";
|
|
6
|
+
import { SettingsTrigger } from "./settings.js";
|
|
7
|
+
function toArray(value) {
|
|
8
|
+
if (value === undefined)
|
|
9
|
+
return [];
|
|
10
|
+
return typeof value === "string" ? [value] : value;
|
|
11
|
+
}
|
|
12
|
+
function resolveKeys(hint, bindings) {
|
|
13
|
+
const literal = toArray(hint.keys);
|
|
14
|
+
if (literal.length > 0)
|
|
15
|
+
return [...literal];
|
|
16
|
+
if (bindings === undefined)
|
|
17
|
+
return [];
|
|
18
|
+
const glyphs = [];
|
|
19
|
+
for (const action of toArray(hint.action)) {
|
|
20
|
+
const glyph = actionLabel(bindings, action);
|
|
21
|
+
if (glyph !== null)
|
|
22
|
+
glyphs.push(glyph);
|
|
23
|
+
}
|
|
24
|
+
return glyphs;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Renders a controls legend whose key glyphs come from the game's keybind map,
|
|
28
|
+
* so bindings live in one place and never drift from a hand-typed table. Hides
|
|
29
|
+
* itself on coarse-pointer devices (a touchscreen has no keyboard) via the same
|
|
30
|
+
* `data-jg-kbd-hint` marker as `KeyHint`. Headless: every part carries a
|
|
31
|
+
* `className` slot and `data-*` hook; pass `renderKey`/`renderRow` to fully own
|
|
32
|
+
* the markup.
|
|
33
|
+
*
|
|
34
|
+
* @capability controls-list keybind-derived control legend that hides on touch
|
|
35
|
+
*/
|
|
36
|
+
export function ControlsList({ bindings, controls, hideOnCoarsePointer = true, separator = " / ", className, rowClassName, keysClassName, keyClassName, separatorClassName, labelClassName, renderKey, renderRow, }) {
|
|
37
|
+
const { coarsePointer } = useDisplayProfile();
|
|
38
|
+
if (hideOnCoarsePointer && coarsePointer)
|
|
39
|
+
return null;
|
|
40
|
+
const rows = controls.map((hint) => ({ keys: resolveKeys(hint, bindings), label: hint.label, separator: hint.separator }));
|
|
41
|
+
return (_jsx("div", { "data-jg-controls": "", "data-jg-kbd-hint": hideOnCoarsePointer ? "" : undefined, className: className, children: rows.map((row, index) => renderRow !== undefined ? (_jsx(Fragment, { children: renderRow(row, index) }, index)) : (_jsxs("div", { "data-jg-control": "", className: rowClassName, children: [_jsx("span", { "data-jg-control-keys": "", className: keysClassName, children: row.keys.map((key, keyIndex) => (_jsxs(Fragment, { children: [keyIndex > 0 ? (_jsx("span", { "data-jg-control-sep": "", className: separatorClassName, children: row.separator ?? separator })) : null, renderKey !== undefined ? renderKey(key, keyIndex) : _jsx(Keycap, { className: keyClassName, children: key })] }, keyIndex))) }), _jsx("span", { "data-jg-control-label": "", className: labelClassName, children: row.label })] }, index))) }));
|
|
42
|
+
}
|
|
43
|
+
const CORNER_STYLE = {
|
|
44
|
+
"top-left": { position: "absolute", top: 0, left: 0 },
|
|
45
|
+
"top-right": { position: "absolute", top: 0, right: 0 },
|
|
46
|
+
"bottom-left": { position: "absolute", bottom: 0, left: 0 },
|
|
47
|
+
"bottom-right": { position: "absolute", bottom: 0, right: 0 },
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Composable title/attract-screen scaffold: a full-bleed `data-jg-menu` overlay
|
|
51
|
+
* that positions and centers the game's own content, with an opt-in settings
|
|
52
|
+
* corner. It imposes no look — the game supplies the title, art, and buttons as
|
|
53
|
+
* children and styles the container through `className`/`style` (per the
|
|
54
|
+
* composable-chrome rule: a placement hook, not a mandated menu).
|
|
55
|
+
*
|
|
56
|
+
* @capability start-screen headless title/attract overlay the game fills and skins
|
|
57
|
+
*/
|
|
58
|
+
export function StartScreen({ open = true, className, style, children, settings, settingsPlacement = "top-right", settingsClassName, settingsWrapperClassName, }) {
|
|
59
|
+
if (!open)
|
|
60
|
+
return null;
|
|
61
|
+
return (_jsxs("div", { "data-jg-menu": "", "data-screen": "start", className: className, style: style, children: [settings !== undefined && settings !== false ? (_jsx("div", { "data-jg-menu-settings": "", className: settingsWrapperClassName, style: settingsWrapperClassName === undefined ? CORNER_STYLE[settingsPlacement] : undefined, children: settings === true ? _jsx(SettingsTrigger, { className: settingsClassName }) : settings })) : null, children] }));
|
|
62
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { StoreHandle } from "@jgengine/core/store/defineStore";
|
|
2
|
+
import type { KeyedStoreHandle } from "@jgengine/core/store/defineKeyedStore";
|
|
3
|
+
/**
|
|
4
|
+
* Subscribe a component to a typed store slot defined with `defineStore`. Returns the current value
|
|
5
|
+
* (or the definition's initial before any write), re-rendering only when the slot changes — the
|
|
6
|
+
* cast-free, boilerplate-free replacement for a hand-written `useGameStore((ctx) => ctx.game.store.get(KEY) as T)`.
|
|
7
|
+
*
|
|
8
|
+
* Pass a `selector` to read a derived slice with its own equality, so a HUD that watches one field
|
|
9
|
+
* doesn't re-render on every unrelated store write.
|
|
10
|
+
*/
|
|
11
|
+
export declare function useStore<T>(handle: StoreHandle<T>): T;
|
|
12
|
+
export declare function useStore<T, S>(handle: StoreHandle<T>, selector: (value: T) => S, isEqual?: (previous: S, next: S) => boolean): S;
|
|
13
|
+
/**
|
|
14
|
+
* Subscribe a component to one owner's slot of a typed keyed-family store defined with
|
|
15
|
+
* `defineKeyedStore`. Returns the current value for `id` (or the definition's initial before any
|
|
16
|
+
* write for that id), re-rendering only when that owner's slot changes — the cast-free,
|
|
17
|
+
* boilerplate-free replacement for a hand-written
|
|
18
|
+
* `useGameStore((ctx) => ctx.game.store.get(`prefix:${id}`) as T)`.
|
|
19
|
+
*
|
|
20
|
+
* Pass a `selector` to read a derived slice with its own equality, so a HUD that watches one field
|
|
21
|
+
* of one owner's state doesn't re-render on every unrelated store write.
|
|
22
|
+
*/
|
|
23
|
+
export declare function useKeyedStore<T>(handle: KeyedStoreHandle<T>, id: string): T;
|
|
24
|
+
export declare function useKeyedStore<T, S>(handle: KeyedStoreHandle<T>, id: string, selector: (value: T) => S, isEqual?: (previous: S, next: S) => boolean): S;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useGameStore } from "./hooks.js";
|
|
2
|
+
export function useStore(handle, selector, isEqual = Object.is) {
|
|
3
|
+
const select = selector;
|
|
4
|
+
return useGameStore((ctx) => (select === undefined ? handle.read(ctx) : select(handle.read(ctx))), select === undefined ? Object.is : isEqual);
|
|
5
|
+
}
|
|
6
|
+
export function useKeyedStore(handle, id, selector, isEqual = Object.is) {
|
|
7
|
+
const select = selector;
|
|
8
|
+
return useGameStore((ctx) => (select === undefined ? handle.read(ctx, id) : select(handle.read(ctx, id))), select === undefined ? Object.is : isEqual);
|
|
9
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "React UI layer for JGengine: GameProvider, hooks, and headless primitives over @jgengine/core.",
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": false,
|
|
8
8
|
"repository": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"dist",
|
|
15
15
|
"CHANGELOG.md",
|
|
16
|
-
"
|
|
16
|
+
"skills"
|
|
17
17
|
],
|
|
18
18
|
"exports": {
|
|
19
19
|
".": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "bun test src"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@jgengine/core": "^0.
|
|
34
|
+
"@jgengine/core": "^0.12.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"react": "^19.0.0",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: jgengine
|
|
3
|
+
description: Route game work to the smallest useful JGengine guidance set.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# JGengine intake and router
|
|
7
|
+
|
|
8
|
+
Read the repository [README](../../../README.md) for package truth, commands, layering, and license. This skill decides what to load; it is not an engine manual.
|
|
9
|
+
|
|
10
|
+
## 1. Establish the target
|
|
11
|
+
|
|
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
|
+
|
|
14
|
+
- player point of view and controls
|
|
15
|
+
- world shape and authored content
|
|
16
|
+
- core loop, interaction, combat, and progression
|
|
17
|
+
- solo, local, hosted, or persistent multiplayer
|
|
18
|
+
- HUD/menu states and **custom** UI art direction (every game owns its presentation — no stock drop-in face)
|
|
19
|
+
- one observable completion scenario
|
|
20
|
+
|
|
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
|
+
|
|
23
|
+
## 2. Select domains
|
|
24
|
+
|
|
25
|
+
Load only rows the target needs. Do not preload every domain.
|
|
26
|
+
|
|
27
|
+
| Need | Skill | Owner |
|
|
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 |
|
|
31
|
+
| scene placement, terrain painting, paths, asset authoring | `jgengine-editor` | editor document and authoring tools |
|
|
32
|
+
| runtime world, movement, input, interaction, AI, navigation, audio | `jgengine-world` | spatial simulation and world runtime |
|
|
33
|
+
| game state, systems, inventory, crafting, quests, economy, turns | `jgengine-gameplay` | non-combat rules and state |
|
|
34
|
+
| targeting, stats, damage, effects, abilities, projectiles, loot | `jgengine-combat` | combat resolution |
|
|
35
|
+
| HUD, menus, layout, accessibility, touch presentation | `jgengine-ui` | player-facing interface |
|
|
36
|
+
| models, sprites, materials, audio files, source licensing | `jgengine-assets` | asset discovery and catalogs |
|
|
37
|
+
| authority, transport, replication, sessions, host persistence | `jgengine-multiplayer` | network topology and persistence adapters |
|
|
38
|
+
|
|
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.
|
|
40
|
+
|
|
41
|
+
## 3. Discover before designing
|
|
42
|
+
|
|
43
|
+
Within each selected domain:
|
|
44
|
+
|
|
45
|
+
1. Search `capabilities.md` by intent; it maps needs to imports.
|
|
46
|
+
2. Open `api.md` only when exact signatures or export inventory are needed.
|
|
47
|
+
3. Open the linked reference only for a deeper recipe or trap.
|
|
48
|
+
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.
|
|
49
|
+
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
## 4. Build and finish
|
|
53
|
+
|
|
54
|
+
Keep scene coordinates in the editor document, state serializable, randomness injected, and hot-path work bounded. Use `jgengine-verify` for evidence appropriate to the change. Inside this monorepo, `workflow` owns issue/PR completion. Visual polish work also uses the `jgengine-ui` quality reference.
|