@jgengine/react 0.9.0 → 0.11.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +34 -3
  2. package/LICENSE +203 -661
  3. package/README.md +1 -1
  4. package/dist/actionHud.d.ts +174 -0
  5. package/dist/actionHud.js +292 -0
  6. package/dist/cards.d.ts +59 -0
  7. package/dist/cards.js +116 -0
  8. package/dist/chat.d.ts +6 -4
  9. package/dist/chat.js +16 -7
  10. package/dist/chatBubbles.js +2 -1
  11. package/dist/components.d.ts +20 -0
  12. package/dist/components.js +29 -1
  13. package/dist/display.d.ts +1 -0
  14. package/dist/display.js +1 -0
  15. package/dist/engineStore.d.ts +3 -0
  16. package/dist/engineStore.js +3 -0
  17. package/dist/fogOverlay.d.ts +3 -0
  18. package/dist/fogOverlay.js +3 -0
  19. package/dist/gameViewport.d.ts +54 -0
  20. package/dist/gameViewport.js +356 -0
  21. package/dist/hooks.d.ts +22 -0
  22. package/dist/hooks.js +58 -12
  23. package/dist/hud.d.ts +87 -0
  24. package/dist/hud.js +144 -0
  25. package/dist/hudLayout.d.ts +39 -4
  26. package/dist/hudLayout.js +152 -11
  27. package/dist/index.d.ts +10 -0
  28. package/dist/index.js +10 -0
  29. package/dist/keyHint.d.ts +30 -0
  30. package/dist/keyHint.js +31 -0
  31. package/dist/map.d.ts +69 -1
  32. package/dist/map.js +54 -1
  33. package/dist/provider.d.ts +2 -0
  34. package/dist/provider.js +4 -0
  35. package/dist/rotateDevice.d.ts +24 -0
  36. package/dist/rotateDevice.js +83 -0
  37. package/dist/save.d.ts +25 -0
  38. package/dist/save.js +37 -0
  39. package/dist/selectSnapshot.d.ts +2 -0
  40. package/dist/selectSnapshot.js +2 -0
  41. package/dist/selectionHud.d.ts +133 -0
  42. package/dist/selectionHud.js +182 -0
  43. package/dist/settings.d.ts +3 -0
  44. package/dist/skillCheckPaint.d.ts +2 -0
  45. package/dist/skillCheckPaint.js +2 -0
  46. package/dist/social.d.ts +3 -3
  47. package/dist/social.js +31 -10
  48. package/dist/startScreen.d.ts +62 -0
  49. package/dist/startScreen.js +62 -0
  50. package/dist/store.d.ts +24 -0
  51. package/dist/store.js +9 -0
  52. package/package.json +4 -4
  53. package/skills/jgengine/SKILL.md +51 -0
  54. package/skills/jgengine/api.md +434 -0
  55. package/skills/jgengine/capabilities.md +29 -0
  56. package/skills/jgengine/reference-cartridge.md +72 -0
  57. package/skills/jgengine-ui/SKILL.md +36 -0
  58. package/skills/jgengine-ui/api.md +1852 -0
  59. package/skills/jgengine-ui/capabilities.md +142 -0
  60. package/skills/jgengine-ui/reference.md +500 -0
  61. package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
  62. package/llms.txt +0 -1633
@@ -0,0 +1,182 @@
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
+ * @capability vital-bar compact current/max vital meter for an entity summary
69
+ */
70
+ export function VitalBar({ vital, width = 120, showLabel = true, className, style, }) {
71
+ const denom = vital.max <= 0 ? 1 : vital.max;
72
+ const fraction = Math.max(0, Math.min(1, vital.current / denom));
73
+ const [from, to] = VITAL_TONES[vital.tone ?? "neutral"] ?? VITAL_TONES.neutral;
74
+ 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})` } }) })] }));
75
+ }
76
+ const PORTRAIT_BASE = {
77
+ position: "relative",
78
+ boxSizing: "border-box",
79
+ display: "flex",
80
+ flexDirection: "column",
81
+ alignItems: "center",
82
+ justifyContent: "center",
83
+ border: "1px solid rgba(255,255,255,0.14)",
84
+ borderRadius: 8,
85
+ background: "rgba(14,17,22,0.72)",
86
+ color: "#eef2f8",
87
+ cursor: "pointer",
88
+ userSelect: "none",
89
+ padding: 2,
90
+ };
91
+ /**
92
+ * A selectable entity portrait tile — icon/initials, an optional thin health strip, and complete
93
+ * selected/hover/focus states as a real `<button>` (`aria-pressed`). The unit-of-a-selection chrome;
94
+ * swap the face via `renderIcon`. Accessible and keyboard-focusable.
95
+ *
96
+ * @capability entity-portrait selectable entity portrait tile with vitals and selected/focus states
97
+ */
98
+ export function EntityPortrait({ entity, selected = false, size = 44, vitalId = "health", tabIndex, onSelect, renderIcon, className, style, }) {
99
+ const vital = entity.vitals?.find((v) => v.id === vitalId) ?? entity.vitals?.[0];
100
+ 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: {
101
+ ...PORTRAIT_BASE,
102
+ width: size,
103
+ height: size,
104
+ borderColor: selected ? "rgba(56,189,248,0.95)" : "rgba(255,255,255,0.14)",
105
+ boxShadow: selected ? "0 0 0 2px rgba(56,189,248,0.6)" : "none",
106
+ ...style,
107
+ }, 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: {
108
+ width: `${Math.max(0, Math.min(1, vital.max <= 0 ? 0 : vital.current / vital.max)) * 100}%`,
109
+ height: "100%",
110
+ background: (VITAL_TONES[vital.tone ?? "health"] ?? VITAL_TONES.neutral)[0],
111
+ } }) })) : null] }));
112
+ }
113
+ /**
114
+ * The primary-entity detail chrome — name, tags, and full vital bars for the focused member. The
115
+ * "who is selected" summary a single-selection or the head of a multi-selection shows. Swap the vital
116
+ * rows via `renderVital`.
117
+ *
118
+ * @capability entity-summary primary selected-entity detail panel (name, tags, vitals)
119
+ */
120
+ export function EntitySummary({ entity, renderVital, className, style, }) {
121
+ 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)))] }));
122
+ }
123
+ /**
124
+ * A same-kind selection chip — icon plus count — for the grouped large-selection view. The default
125
+ * chrome {@link SelectionCollectionChrome} renders per group; swap it via `renderGroup`.
126
+ *
127
+ * @capability selection-group-chip same-kind selection chip (icon + count) for a grouped selection
128
+ */
129
+ export function SelectionGroupChip({ group, onSelect, renderIcon, className, style, }) {
130
+ 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 })] }));
131
+ }
132
+ /**
133
+ * The composable RENDERER for the member collection: a portrait strip for a small selection, or the
134
+ * same-kind group chips once the model is `grouped`. Roving tabindex + arrow-key focus. Swap
135
+ * `renderMember`/`renderGroup` for custom chrome, or read the model and lay it out yourself. No panel
136
+ * skin — a behavior/accessibility layer only.
137
+ *
138
+ * @capability selection-collection accessible portrait-strip / group-chip renderer over a selection model
139
+ */
140
+ export function SelectionCollectionChrome({ model, itemSize = 44, gap = 4, windowSize, scroll = 0, ariaLabel = "Selection", renderMember, renderGroup, onGroupSelect, className, style, }) {
141
+ const { view } = model;
142
+ const rovingId = model.focusId ?? view.primary?.id ?? null;
143
+ const containerStyle = { display: "flex", flexWrap: "wrap", gap };
144
+ if (view.grouped) {
145
+ 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))) }));
146
+ }
147
+ const win = windowSize !== undefined ? model.window(scroll, windowSize) : { start: 0, end: view.count, items: view.members };
148
+ 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) => {
149
+ const selected = view.primary?.id === entity.id;
150
+ if (renderMember !== undefined) {
151
+ return (_jsx("div", { role: "option", "aria-selected": selected, children: renderMember(entity, selected) }, entity.id));
152
+ }
153
+ 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));
154
+ }) }));
155
+ }
156
+ const PANEL = {
157
+ padding: 8,
158
+ borderRadius: 12,
159
+ background: "rgba(10,12,16,0.6)",
160
+ backdropFilter: "blur(6px)",
161
+ WebkitBackdropFilter: "blur(6px)",
162
+ border: "1px solid rgba(255,255,255,0.1)",
163
+ boxShadow: "0 4px 16px rgba(0,0,0,0.35)",
164
+ color: "#eef2f8",
165
+ font: "500 12px/1.2 ui-sans-serif, system-ui, sans-serif",
166
+ };
167
+ /**
168
+ * The batteries-included default: a skinned glass panel composing {@link EntitySummary} for the
169
+ * primary member beside {@link SelectionCollectionChrome} for the rest. Pass `members` (or a prebuilt
170
+ * `model`) and it renders a working RTS-style selection panel; every sub-renderer stays swappable, and
171
+ * a game that wants a different frame composes the hook and chrome directly.
172
+ *
173
+ * @capability selection-panel drop-in selection panel: primary summary beside a portrait strip / group chips
174
+ */
175
+ export function SelectionPanel({ members, model: providedModel, groupThreshold, labelOf, windowSize, renderPrimary, renderMember, renderVital, onFocusChange, className, style, }) {
176
+ const ownModel = useSelectionView(members ?? [], { groupThreshold, labelOf, onFocusChange });
177
+ const model = providedModel ?? ownModel;
178
+ const { view } = model;
179
+ if (view.count === 0)
180
+ return null;
181
+ 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] }));
182
+ }
@@ -8,6 +8,7 @@ export declare function SettingsProvider({ store, children }: {
8
8
  export declare function useSettingsStore(): SettingsStore;
9
9
  /** Read + write one persisted setting; re-renders when the value changes anywhere. */
10
10
  export declare function useSetting<T extends SettingValue>(id: string, fallback: T): readonly [T, (value: SettingValue) => void];
11
+ /** One editable setting rendered in a settings menu category. */
11
12
  export interface SettingsRow {
12
13
  id: string;
13
14
  label: string;
@@ -20,6 +21,7 @@ export interface SettingsRow {
20
21
  format?: (value: number) => string;
21
22
  set: (value: SettingValue) => void;
22
23
  }
24
+ /** One rebindable action row rendered in the controls settings category. */
23
25
  export interface SettingsKeybindRow {
24
26
  action: string;
25
27
  label: string;
@@ -28,6 +30,7 @@ export interface SettingsKeybindRow {
28
30
  rebind: (code: string) => void;
29
31
  reset: () => void;
30
32
  }
33
+ /** A settings menu category with its rows and keybinds, ready to render. */
31
34
  export interface SettingsCategoryView {
32
35
  id: SettingCategory;
33
36
  label: string;
@@ -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;
@@ -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/social.d.ts CHANGED
@@ -24,7 +24,7 @@ export declare function AddFriendButton({ toUserId, className, children, onReque
24
24
  children?: ReactNode;
25
25
  onRequested?: (requestId: string) => void;
26
26
  onRejected?: (reason: string) => void;
27
- }): import("react").JSX.Element;
27
+ }): import("react").JSX.Element | null;
28
28
  export declare function FriendRequestsList({ className, rowClassName, acceptClassName, declineClassName, emptyState, renderRequest, }: {
29
29
  className?: string;
30
30
  rowClassName?: string;
@@ -32,7 +32,7 @@ export declare function FriendRequestsList({ className, rowClassName, acceptClas
32
32
  declineClassName?: string;
33
33
  emptyState?: ReactNode;
34
34
  renderRequest?: (request: FriendRequestEntry) => ReactNode;
35
- }): import("react").JSX.Element;
35
+ }): import("react").JSX.Element | null;
36
36
  export declare function PartyMemberRow({ member, className, dotClassName, children, }: {
37
37
  member: PartyMemberEntry;
38
38
  className?: string;
@@ -70,7 +70,7 @@ export declare function InviteToWorldButton({ toUserId, target, className, child
70
70
  children?: ReactNode;
71
71
  onInvited?: (inviteId: string) => void;
72
72
  onRejected?: (reason: string) => void;
73
- }): import("react").JSX.Element;
73
+ }): import("react").JSX.Element | null;
74
74
  export declare function WorldBrowser({ listings, onJoin, className, rowClassName, joinClassName, emptyState, renderListing, }: {
75
75
  listings: readonly SessionListing[];
76
76
  onJoin: (listing: SessionListing) => void;
package/dist/social.js CHANGED
@@ -18,9 +18,12 @@ export function FriendsList({ className, rowClassName, dotClassName, emptyState,
18
18
  }
19
19
  export function AddFriendButton({ toUserId, className, children, onRequested, onRejected, }) {
20
20
  const ctx = useGameContext();
21
- const denied = ctx.game.social.friends.canRequest(ctx.player.userId, toUserId);
21
+ const social = ctx.game.social;
22
+ if (social === undefined)
23
+ return null;
24
+ const denied = social.friends.canRequest(ctx.player.userId, toUserId);
22
25
  return (_jsx("button", { type: "button", className: className, "data-add-friend": toUserId, disabled: denied !== null, title: denied?.reason, onClick: () => {
23
- const result = ctx.game.social.friends.request(ctx.player.userId, toUserId);
26
+ const result = social.friends.request(ctx.player.userId, toUserId);
24
27
  if ("reason" in result)
25
28
  onRejected?.(result.reason);
26
29
  else
@@ -30,9 +33,12 @@ export function AddFriendButton({ toUserId, className, children, onRequested, on
30
33
  export function FriendRequestsList({ className, rowClassName, acceptClassName, declineClassName, emptyState, renderRequest, }) {
31
34
  const ctx = useGameContext();
32
35
  const requests = useFriendRequests();
36
+ const social = ctx.game.social;
37
+ if (social === undefined)
38
+ return null;
33
39
  return (_jsx("div", { className: className, "data-friend-requests": true, children: requests.length === 0
34
40
  ? emptyState ?? null
35
- : requests.map((request) => renderRequest !== undefined ? (renderRequest(request)) : (_jsxs("div", { className: rowClassName, "data-friend-request": request.requestId, children: [_jsx("span", { "data-request-from": true, children: request.fromUserId }), _jsx("button", { type: "button", className: acceptClassName, "data-accept": true, onClick: () => ctx.game.social.friends.accept(ctx.player.userId, request.requestId), children: "Accept" }), _jsx("button", { type: "button", className: declineClassName, "data-decline": true, onClick: () => ctx.game.social.friends.decline(ctx.player.userId, request.requestId), children: "Decline" })] }, request.requestId))) }));
41
+ : requests.map((request) => renderRequest !== undefined ? (renderRequest(request)) : (_jsxs("div", { className: rowClassName, "data-friend-request": request.requestId, children: [_jsx("span", { "data-request-from": true, children: request.fromUserId }), _jsx("button", { type: "button", className: acceptClassName, "data-accept": true, onClick: () => social.friends.accept(ctx.player.userId, request.requestId), children: "Accept" }), _jsx("button", { type: "button", className: declineClassName, "data-decline": true, onClick: () => social.friends.decline(ctx.player.userId, request.requestId), children: "Decline" })] }, request.requestId))) }));
36
42
  }
37
43
  export function PartyMemberRow({ member, className, dotClassName, children, }) {
38
44
  return (_jsxs("div", { className: className, "data-party-member": member.userId, "data-role": member.role, children: [_jsx(PresenceDot, { userId: member.userId, className: dotClassName }), _jsx("span", { "data-member-name": true, children: member.userId }), children] }));
@@ -46,33 +52,45 @@ export function PartyFrame({ className, rowClassName, dotClassName, emptyState,
46
52
  export function PartyInviteToast({ className, acceptClassName, declineClassName, renderInvite, }) {
47
53
  const ctx = useGameContext();
48
54
  const invites = usePartyInvites();
55
+ const social = ctx.game.social;
56
+ if (social === undefined)
57
+ return null;
49
58
  if (invites.length === 0)
50
59
  return null;
51
- return (_jsx("div", { className: className, "data-party-invites": true, children: invites.map((invite) => renderInvite !== undefined ? (renderInvite(invite)) : (_jsxs("div", { "data-party-invite": invite.inviteId, children: [_jsx("span", { "data-invite-from": true, children: invite.fromUserId }), _jsx("button", { type: "button", className: acceptClassName, "data-accept": true, onClick: () => ctx.game.social.party.accept(ctx.player.userId, invite.inviteId), children: "Join" }), _jsx("button", { type: "button", className: declineClassName, "data-decline": true, onClick: () => ctx.game.social.party.decline(ctx.player.userId, invite.inviteId), children: "Decline" })] }, invite.inviteId))) }));
60
+ return (_jsx("div", { className: className, "data-party-invites": true, children: invites.map((invite) => renderInvite !== undefined ? (renderInvite(invite)) : (_jsxs("div", { "data-party-invite": invite.inviteId, children: [_jsx("span", { "data-invite-from": true, children: invite.fromUserId }), _jsx("button", { type: "button", className: acceptClassName, "data-accept": true, onClick: () => social.party.accept(ctx.player.userId, invite.inviteId), children: "Join" }), _jsx("button", { type: "button", className: declineClassName, "data-decline": true, onClick: () => social.party.decline(ctx.player.userId, invite.inviteId), children: "Decline" })] }, invite.inviteId))) }));
52
61
  }
53
62
  export function LeavePartyButton({ className, children, }) {
54
63
  const ctx = useGameContext();
55
64
  const members = useParty();
65
+ const social = ctx.game.social;
66
+ if (social === undefined)
67
+ return null;
56
68
  if (members.length === 0)
57
69
  return null;
58
- return (_jsx("button", { type: "button", className: className, "data-leave-party": true, onClick: () => ctx.game.social.party.leave(ctx.player.userId), children: children ?? "Leave party" }));
70
+ return (_jsx("button", { type: "button", className: className, "data-leave-party": true, onClick: () => social.party.leave(ctx.player.userId), children: children ?? "Leave party" }));
59
71
  }
60
72
  export function WorldInviteToast({ className, acceptClassName, declineClassName, onAccepted, renderInvite, }) {
61
73
  const ctx = useGameContext();
62
74
  const invites = useWorldInvites();
75
+ const social = ctx.game.social;
76
+ if (social === undefined)
77
+ return null;
63
78
  if (invites.length === 0)
64
79
  return null;
65
80
  return (_jsx("div", { className: className, "data-world-invites": true, children: invites.map((invite) => renderInvite !== undefined ? (renderInvite(invite)) : (_jsxs("div", { "data-world-invite": invite.id, children: [_jsx("span", { "data-invite-from": true, children: invite.fromUserId }), _jsx("span", { "data-invite-server": true, children: invite.serverId }), _jsx("button", { type: "button", className: acceptClassName, "data-accept": true, onClick: () => {
66
- const result = ctx.game.social.worldInvites.accept(ctx.player.userId, invite.id);
81
+ const result = social.worldInvites.accept(ctx.player.userId, invite.id);
67
82
  if ("target" in result)
68
83
  onAccepted(result.target);
69
- }, children: "Join" }), _jsx("button", { type: "button", className: declineClassName, "data-decline": true, onClick: () => ctx.game.social.worldInvites.decline(ctx.player.userId, invite.id), children: "Decline" })] }, invite.id))) }));
84
+ }, children: "Join" }), _jsx("button", { type: "button", className: declineClassName, "data-decline": true, onClick: () => social.worldInvites.decline(ctx.player.userId, invite.id), children: "Decline" })] }, invite.id))) }));
70
85
  }
71
86
  export function InviteToWorldButton({ toUserId, target, className, children, onInvited, onRejected, }) {
72
87
  const ctx = useGameContext();
73
- const denied = ctx.game.social.worldInvites.canInvite(ctx.player.userId, toUserId);
88
+ const social = ctx.game.social;
89
+ if (social === undefined)
90
+ return null;
91
+ const denied = social.worldInvites.canInvite(ctx.player.userId, toUserId);
74
92
  return (_jsx("button", { type: "button", className: className, "data-invite-to-world": toUserId, disabled: denied !== null, title: denied?.reason, onClick: () => {
75
- const result = ctx.game.social.worldInvites.invite(ctx.player.userId, toUserId, target);
93
+ const result = social.worldInvites.invite(ctx.player.userId, toUserId, target);
76
94
  if ("reason" in result)
77
95
  onRejected?.(result.reason);
78
96
  else
@@ -107,10 +125,13 @@ export function QuickMatchButton({ listings, onJoin, onNoMatch, filter, classNam
107
125
  }
108
126
  export function EmoteWheel({ emotes, radius, open = true, className, emoteClassName, onPlayed, onRejected, renderEmote, }) {
109
127
  const ctx = useGameContext();
128
+ const social = ctx.game.social;
129
+ if (social === undefined)
130
+ return null;
110
131
  if (!open)
111
132
  return null;
112
133
  return (_jsx("div", { className: className, role: "menu", "data-emote-wheel": true, "data-emote-count": emotes.length, children: emotes.map((emoteId, index) => (_jsx("button", { type: "button", role: "menuitem", className: emoteClassName, "data-emote": emoteId, "data-emote-index": index, onClick: () => {
113
- const result = ctx.game.social.emotes.play(ctx.player.userId, emoteId, radius);
134
+ const result = social.emotes.play(ctx.player.userId, emoteId, radius);
114
135
  if ("reason" in result)
115
136
  onRejected?.(result.reason);
116
137
  else
@@ -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
+ }
@@ -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.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "React UI layer for JGengine: GameProvider, hooks, and headless primitives over @jgengine/core.",
5
- "license": "AGPL-3.0-only",
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
- "llms.txt"
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.9.0"
34
+ "@jgengine/core": "^0.11.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "react": "^19.0.0",
@@ -0,0 +1,51 @@
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
+ 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 art direction
19
+ - one observable completion scenario
20
+
21
+ For a new in-repo game, scaffold with `bun run new:game <id> --name "Title"`. 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
+ | scene placement, terrain painting, paths, asset authoring | `jgengine-editor` | editor document and authoring tools |
30
+ | runtime world, movement, input, interaction, AI, navigation, audio | `jgengine-world` | spatial simulation and world runtime |
31
+ | game state, systems, inventory, crafting, quests, economy, turns | `jgengine-gameplay` | non-combat rules and state |
32
+ | targeting, stats, damage, effects, abilities, projectiles, loot | `jgengine-combat` | combat resolution |
33
+ | HUD, menus, layout, accessibility, touch presentation | `jgengine-ui` | player-facing interface |
34
+ | models, sprites, materials, audio files, source licensing | `jgengine-assets` | asset discovery and catalogs |
35
+ | authority, transport, replication, sessions, host persistence | `jgengine-multiplayer` | network topology and persistence adapters |
36
+
37
+ Scenes normally require both `jgengine-editor` for authoring and `jgengine-world` for runtime consumption. Combat and multiplayer are opt-in, not default intake.
38
+
39
+ ## 3. Discover before designing
40
+
41
+ Within each selected domain:
42
+
43
+ 1. Search `capabilities.md` by intent; it maps needs to imports.
44
+ 2. Open `api.md` only when exact signatures or export inventory are needed.
45
+ 3. Open the linked reference only for a deeper recipe or trap.
46
+
47
+ 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
+
49
+ ## 4. Build and finish
50
+
51
+ 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.