@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.
- package/CHANGELOG.md +34 -3
- package/LICENSE +203 -661
- package/README.md +1 -1
- package/dist/actionHud.d.ts +174 -0
- package/dist/actionHud.js +292 -0
- package/dist/cards.d.ts +59 -0
- package/dist/cards.js +116 -0
- package/dist/chat.d.ts +6 -4
- package/dist/chat.js +16 -7
- package/dist/chatBubbles.js +2 -1
- package/dist/components.d.ts +20 -0
- package/dist/components.js +29 -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.d.ts +54 -0
- package/dist/gameViewport.js +356 -0
- package/dist/hooks.d.ts +22 -0
- package/dist/hooks.js +58 -12
- package/dist/hud.d.ts +87 -0
- package/dist/hud.js +144 -0
- package/dist/hudLayout.d.ts +39 -4
- package/dist/hudLayout.js +152 -11
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/keyHint.d.ts +30 -0
- package/dist/keyHint.js +31 -0
- package/dist/map.d.ts +69 -1
- package/dist/map.js +54 -1
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +4 -0
- package/dist/rotateDevice.d.ts +24 -0
- package/dist/rotateDevice.js +83 -0
- 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 +133 -0
- package/dist/selectionHud.js +182 -0
- package/dist/settings.d.ts +3 -0
- package/dist/skillCheckPaint.d.ts +2 -0
- package/dist/skillCheckPaint.js +2 -0
- package/dist/social.d.ts +3 -3
- package/dist/social.js +31 -10
- 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 +51 -0
- package/skills/jgengine/api.md +434 -0
- package/skills/jgengine/capabilities.md +29 -0
- package/skills/jgengine/reference-cartridge.md +72 -0
- package/skills/jgengine-ui/SKILL.md +36 -0
- package/skills/jgengine-ui/api.md +1852 -0
- package/skills/jgengine-ui/capabilities.md +142 -0
- package/skills/jgengine-ui/reference.md +500 -0
- package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
- package/llms.txt +0 -1633
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Shared mobile-composition runtime. `GameViewportProvider` allocates the live
|
|
4
|
+
* game viewport once — tracking `visualViewport`, safe-area insets, orientation
|
|
5
|
+
* and layout mode — and hosts a registry every UI subsystem publishes its
|
|
6
|
+
* occupied rectangle to. HUD panels, touch-control zones, and system chrome all
|
|
7
|
+
* register here so the engine can detect forbidden overlaps instead of each
|
|
8
|
+
* subsystem independently claiming an edge.
|
|
9
|
+
*/
|
|
10
|
+
import { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
|
|
11
|
+
import { computeGameplayRect, detectLayoutCollisions, formatLayoutCollisions, resolveLayoutMode, orientationOf, } from "@jgengine/core/ui/gameLayout";
|
|
12
|
+
import { useDisplayProfile } from "./display.js";
|
|
13
|
+
function sameRect(a, b) {
|
|
14
|
+
return a.left === b.left && a.top === b.top && a.right === b.right && a.bottom === b.bottom;
|
|
15
|
+
}
|
|
16
|
+
function sameRegion(a, b) {
|
|
17
|
+
return (a.kind === b.kind &&
|
|
18
|
+
a.collisionPolicy === b.collisionPolicy &&
|
|
19
|
+
a.priority === b.priority &&
|
|
20
|
+
a.collisionGroup === b.collisionGroup &&
|
|
21
|
+
a.element === b.element &&
|
|
22
|
+
(a.allowOverlapWith ?? []).join("+") === (b.allowOverlapWith ?? []).join("+") &&
|
|
23
|
+
sameRect(a.rect, b.rect));
|
|
24
|
+
}
|
|
25
|
+
const EMPTY_REGIONS = [];
|
|
26
|
+
function createRegionRegistry() {
|
|
27
|
+
const records = new Map();
|
|
28
|
+
const listeners = new Set();
|
|
29
|
+
let snapshot = EMPTY_REGIONS;
|
|
30
|
+
let frame = 0;
|
|
31
|
+
const flush = () => {
|
|
32
|
+
frame = 0;
|
|
33
|
+
for (const listener of [...listeners])
|
|
34
|
+
listener();
|
|
35
|
+
};
|
|
36
|
+
const emit = () => {
|
|
37
|
+
snapshot = [...records.values()];
|
|
38
|
+
if (typeof requestAnimationFrame !== "function") {
|
|
39
|
+
for (const listener of [...listeners])
|
|
40
|
+
listener();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (frame !== 0)
|
|
44
|
+
return;
|
|
45
|
+
frame = requestAnimationFrame(flush);
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
set(region) {
|
|
49
|
+
const prev = records.get(region.id);
|
|
50
|
+
if (prev !== undefined && sameRegion(prev, region))
|
|
51
|
+
return;
|
|
52
|
+
records.set(region.id, region);
|
|
53
|
+
emit();
|
|
54
|
+
},
|
|
55
|
+
remove(id) {
|
|
56
|
+
if (records.delete(id))
|
|
57
|
+
emit();
|
|
58
|
+
},
|
|
59
|
+
list: () => snapshot,
|
|
60
|
+
subscribe(listener) {
|
|
61
|
+
listeners.add(listener);
|
|
62
|
+
return () => {
|
|
63
|
+
listeners.delete(listener);
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const RegionRegistryContext = createContext(null);
|
|
69
|
+
const GameLayoutContext = createContext(null);
|
|
70
|
+
const INPUT_HINT_STYLE_ID = "jg-input-hints";
|
|
71
|
+
const INPUT_HINT_CSS = "@media (pointer: coarse){[data-jg-kbd-hint]{display:none!important}}";
|
|
72
|
+
/** Install the backstop rule that hides keyboard/mouse hints on touch, once per document. */
|
|
73
|
+
function ensureInputHintStylesheet() {
|
|
74
|
+
if (typeof document === "undefined")
|
|
75
|
+
return;
|
|
76
|
+
if (document.getElementById(INPUT_HINT_STYLE_ID) !== null)
|
|
77
|
+
return;
|
|
78
|
+
const style = document.createElement("style");
|
|
79
|
+
style.id = INPUT_HINT_STYLE_ID;
|
|
80
|
+
style.textContent = INPUT_HINT_CSS;
|
|
81
|
+
document.head.appendChild(style);
|
|
82
|
+
}
|
|
83
|
+
const SERVER_METRICS = {
|
|
84
|
+
layout: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
85
|
+
visual: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
86
|
+
};
|
|
87
|
+
function readViewportMetrics() {
|
|
88
|
+
if (typeof window === "undefined")
|
|
89
|
+
return SERVER_METRICS;
|
|
90
|
+
const layoutWidth = window.innerWidth;
|
|
91
|
+
const layoutHeight = window.innerHeight;
|
|
92
|
+
const vv = window.visualViewport;
|
|
93
|
+
const visual = vv
|
|
94
|
+
? {
|
|
95
|
+
left: vv.offsetLeft,
|
|
96
|
+
top: vv.offsetTop,
|
|
97
|
+
right: vv.offsetLeft + vv.width,
|
|
98
|
+
bottom: vv.offsetTop + vv.height,
|
|
99
|
+
}
|
|
100
|
+
: { left: 0, top: 0, right: layoutWidth, bottom: layoutHeight };
|
|
101
|
+
return { layout: { left: 0, top: 0, right: layoutWidth, bottom: layoutHeight }, visual };
|
|
102
|
+
}
|
|
103
|
+
let cachedMetrics = SERVER_METRICS;
|
|
104
|
+
function metricsSnapshot() {
|
|
105
|
+
const next = readViewportMetrics();
|
|
106
|
+
if (!sameRect(next.layout, cachedMetrics.layout) || !sameRect(next.visual, cachedMetrics.visual)) {
|
|
107
|
+
cachedMetrics = next;
|
|
108
|
+
}
|
|
109
|
+
return cachedMetrics;
|
|
110
|
+
}
|
|
111
|
+
function subscribeViewport(onChange) {
|
|
112
|
+
if (typeof window === "undefined")
|
|
113
|
+
return () => undefined;
|
|
114
|
+
window.addEventListener("resize", onChange);
|
|
115
|
+
window.addEventListener("orientationchange", onChange);
|
|
116
|
+
const vv = window.visualViewport;
|
|
117
|
+
vv?.addEventListener("resize", onChange);
|
|
118
|
+
vv?.addEventListener("scroll", onChange);
|
|
119
|
+
return () => {
|
|
120
|
+
window.removeEventListener("resize", onChange);
|
|
121
|
+
window.removeEventListener("orientationchange", onChange);
|
|
122
|
+
vv?.removeEventListener("resize", onChange);
|
|
123
|
+
vv?.removeEventListener("scroll", onChange);
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** Live visible viewport, tracking `window.visualViewport` (mobile browser chrome, pinch-zoom) with a layout-viewport fallback. */
|
|
127
|
+
export function useViewportMetrics() {
|
|
128
|
+
return useSyncExternalStore(subscribeViewport, metricsSnapshot, () => SERVER_METRICS);
|
|
129
|
+
}
|
|
130
|
+
function measureSafeArea(probe) {
|
|
131
|
+
if (probe === null || typeof getComputedStyle !== "function")
|
|
132
|
+
return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
133
|
+
const style = getComputedStyle(probe);
|
|
134
|
+
const px = (value) => {
|
|
135
|
+
const n = Number.parseFloat(value);
|
|
136
|
+
return Number.isFinite(n) ? n : 0;
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
top: px(style.paddingTop),
|
|
140
|
+
right: px(style.paddingRight),
|
|
141
|
+
bottom: px(style.paddingBottom),
|
|
142
|
+
left: px(style.paddingLeft),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const SAFE_AREA_PROBE_STYLE = {
|
|
146
|
+
position: "fixed",
|
|
147
|
+
top: 0,
|
|
148
|
+
left: 0,
|
|
149
|
+
width: 0,
|
|
150
|
+
height: 0,
|
|
151
|
+
visibility: "hidden",
|
|
152
|
+
pointerEvents: "none",
|
|
153
|
+
paddingTop: "env(safe-area-inset-top, 0px)",
|
|
154
|
+
paddingRight: "env(safe-area-inset-right, 0px)",
|
|
155
|
+
paddingBottom: "env(safe-area-inset-bottom, 0px)",
|
|
156
|
+
paddingLeft: "env(safe-area-inset-left, 0px)",
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* Provides the shared game viewport layout to everything it wraps. Mount it
|
|
160
|
+
* once around the whole game presentation (world, HUD, controls, system UI) so
|
|
161
|
+
* every subsystem reads one coordinated geometry and registers its rect for
|
|
162
|
+
* collision detection. Publishes live `--jg-viewport-*` / `--jg-visual-viewport-*`
|
|
163
|
+
* / `--jg-safe-*` CSS variables on its root.
|
|
164
|
+
*/
|
|
165
|
+
export function GameViewportProvider({ platforms, className, style, children, }) {
|
|
166
|
+
const registry = useMemo(createRegionRegistry, []);
|
|
167
|
+
const metrics = useViewportMetrics();
|
|
168
|
+
const { coarsePointer } = useDisplayProfile();
|
|
169
|
+
const probeRef = useRef(null);
|
|
170
|
+
const [safeArea, setSafeArea] = useState({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
171
|
+
const rootRef = useRef(null);
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
ensureInputHintStylesheet();
|
|
174
|
+
}, []);
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
const update = () => {
|
|
177
|
+
const next = measureSafeArea(probeRef.current);
|
|
178
|
+
setSafeArea((prev) => prev.top === next.top && prev.right === next.right && prev.bottom === next.bottom && prev.left === next.left
|
|
179
|
+
? prev
|
|
180
|
+
: next);
|
|
181
|
+
};
|
|
182
|
+
update();
|
|
183
|
+
if (typeof window === "undefined")
|
|
184
|
+
return;
|
|
185
|
+
window.addEventListener("resize", update);
|
|
186
|
+
window.addEventListener("orientationchange", update);
|
|
187
|
+
return () => {
|
|
188
|
+
window.removeEventListener("resize", update);
|
|
189
|
+
window.removeEventListener("orientationchange", update);
|
|
190
|
+
};
|
|
191
|
+
}, [metrics]);
|
|
192
|
+
const regions = useSyncExternalStore(registry.subscribe, registry.list, () => EMPTY_REGIONS);
|
|
193
|
+
const mobileSupported = platforms?.includes("mobile") ?? true;
|
|
194
|
+
const visual = metrics.visual;
|
|
195
|
+
const vw = Math.max(0, visual.right - visual.left);
|
|
196
|
+
const vh = Math.max(0, visual.bottom - visual.top);
|
|
197
|
+
const layout = useMemo(() => {
|
|
198
|
+
const orientation = vw > 0 || vh > 0 ? orientationOf(vw, vh) : orientationOf(metrics.layout.right, metrics.layout.bottom);
|
|
199
|
+
const mode = resolveLayoutMode({
|
|
200
|
+
width: vw > 0 ? vw : metrics.layout.right,
|
|
201
|
+
height: vh > 0 ? vh : metrics.layout.bottom,
|
|
202
|
+
coarsePointer,
|
|
203
|
+
mobileSupported,
|
|
204
|
+
});
|
|
205
|
+
const reserved = regions
|
|
206
|
+
.filter((region) => region.kind === "control" || region.kind === "system")
|
|
207
|
+
.map((region) => region.rect);
|
|
208
|
+
const gameplayRect = computeGameplayRect(metrics.layout, safeArea, reserved);
|
|
209
|
+
return {
|
|
210
|
+
viewport: metrics.layout,
|
|
211
|
+
visualViewport: visual,
|
|
212
|
+
safeArea,
|
|
213
|
+
mode,
|
|
214
|
+
orientation,
|
|
215
|
+
regions,
|
|
216
|
+
controlZones: reserved,
|
|
217
|
+
gameplayRect,
|
|
218
|
+
};
|
|
219
|
+
}, [regions, metrics.layout, visual, vw, vh, coarsePointer, mobileSupported, safeArea]);
|
|
220
|
+
const collisions = useMemo(() => detectLayoutCollisions(regions), [regions]);
|
|
221
|
+
const forbidden = useMemo(() => collisions.filter((c) => c.severity === "forbid"), [collisions]);
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
const root = rootRef.current;
|
|
224
|
+
if (root === null)
|
|
225
|
+
return;
|
|
226
|
+
if (forbidden.length === 0) {
|
|
227
|
+
root.removeAttribute("data-jg-layout-collision");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const report = JSON.stringify(forbidden);
|
|
231
|
+
root.setAttribute("data-jg-layout-collision", report);
|
|
232
|
+
console.warn(`[jgengine] ${formatLayoutCollisions(forbidden)}\n mode=${layout.mode} viewport=${Math.round(vw)}x${Math.round(vh)} safe=${safeArea.top}/${safeArea.right}/${safeArea.bottom}/${safeArea.left}`);
|
|
233
|
+
}, [forbidden, layout.mode, vw, vh, safeArea]);
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
const ids = new Set();
|
|
236
|
+
for (const collision of forbidden) {
|
|
237
|
+
ids.add(collision.a);
|
|
238
|
+
ids.add(collision.b);
|
|
239
|
+
}
|
|
240
|
+
const marked = [];
|
|
241
|
+
for (const region of regions) {
|
|
242
|
+
if (region.element != null && ids.has(region.id)) {
|
|
243
|
+
region.element.setAttribute("data-jg-collision", "");
|
|
244
|
+
marked.push(region.element);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return () => {
|
|
248
|
+
for (const element of marked)
|
|
249
|
+
element.removeAttribute("data-jg-collision");
|
|
250
|
+
};
|
|
251
|
+
}, [forbidden, regions]);
|
|
252
|
+
const rootStyle = {
|
|
253
|
+
position: "relative",
|
|
254
|
+
width: "100%",
|
|
255
|
+
height: "100%",
|
|
256
|
+
"--jg-viewport-width": `${metrics.layout.right}px`,
|
|
257
|
+
"--jg-viewport-height": `${metrics.layout.bottom}px`,
|
|
258
|
+
"--jg-visual-viewport-width": `${Math.round(vw)}px`,
|
|
259
|
+
"--jg-visual-viewport-height": `${Math.round(vh)}px`,
|
|
260
|
+
"--jg-visual-viewport-offset-top": `${Math.round(visual.top)}px`,
|
|
261
|
+
"--jg-visual-viewport-offset-left": `${Math.round(visual.left)}px`,
|
|
262
|
+
"--jg-safe-top": `${safeArea.top}px`,
|
|
263
|
+
"--jg-safe-right": `${safeArea.right}px`,
|
|
264
|
+
"--jg-safe-bottom": `${safeArea.bottom}px`,
|
|
265
|
+
"--jg-safe-left": `${safeArea.left}px`,
|
|
266
|
+
...style,
|
|
267
|
+
};
|
|
268
|
+
return (_jsx(RegionRegistryContext.Provider, { value: registry, children: _jsx(GameLayoutContext.Provider, { value: layout, children: _jsxs("div", { ref: rootRef, "data-jg-viewport": "", "data-jg-layout-mode": layout.mode, "data-jg-orientation": layout.orientation, className: className, style: rootStyle, children: [_jsx("div", { ref: probeRef, "aria-hidden": true, style: SAFE_AREA_PROBE_STYLE }), children] }) }) }));
|
|
269
|
+
}
|
|
270
|
+
/** The live shared viewport layout. Returns a neutral default outside a `GameViewportProvider` so it never throws in previews. */
|
|
271
|
+
export function useGameViewportLayout() {
|
|
272
|
+
const layout = useContext(GameLayoutContext);
|
|
273
|
+
const metrics = useViewportMetrics();
|
|
274
|
+
return (layout ?? {
|
|
275
|
+
viewport: metrics.layout,
|
|
276
|
+
visualViewport: metrics.visual,
|
|
277
|
+
safeArea: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
278
|
+
mode: resolveLayoutMode({ width: metrics.layout.right, height: metrics.layout.bottom, coarsePointer: false }),
|
|
279
|
+
orientation: orientationOf(metrics.layout.right, metrics.layout.bottom),
|
|
280
|
+
regions: [],
|
|
281
|
+
controlZones: [],
|
|
282
|
+
gameplayRect: metrics.layout,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
/** The resolved explicit composition mode (`desktop-wide` … `mobile-portrait`). */
|
|
286
|
+
export function useGameLayoutMode() {
|
|
287
|
+
return useGameViewportLayout().mode;
|
|
288
|
+
}
|
|
289
|
+
/** The live device orientation. */
|
|
290
|
+
export function useGameOrientation() {
|
|
291
|
+
return useGameViewportLayout().orientation;
|
|
292
|
+
}
|
|
293
|
+
/** Rectangles reserved by touch controls and system UI — HUD placement should avoid these. */
|
|
294
|
+
export function useReservedControlZones() {
|
|
295
|
+
return useGameViewportLayout().controlZones;
|
|
296
|
+
}
|
|
297
|
+
/** Live forbidden/warned region collisions (empty outside a provider). */
|
|
298
|
+
export function useLayoutCollisions() {
|
|
299
|
+
const layout = useContext(GameLayoutContext);
|
|
300
|
+
return useMemo(() => (layout === null ? [] : detectLayoutCollisions(layout.regions)), [layout]);
|
|
301
|
+
}
|
|
302
|
+
function specKey(spec) {
|
|
303
|
+
return [
|
|
304
|
+
spec.id,
|
|
305
|
+
spec.kind,
|
|
306
|
+
spec.collisionPolicy,
|
|
307
|
+
spec.priority ?? "",
|
|
308
|
+
spec.collisionGroup ?? "",
|
|
309
|
+
(spec.allowOverlapWith ?? []).join("+"),
|
|
310
|
+
].join("|");
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Register the element behind `ref` as a layout region and keep its measured
|
|
314
|
+
* rectangle live (ResizeObserver + viewport changes). No-op outside a
|
|
315
|
+
* `GameViewportProvider`, so a component using it still works in isolation.
|
|
316
|
+
*/
|
|
317
|
+
export function useRegisterLayoutRegion(spec, ref, enabled = true) {
|
|
318
|
+
const registry = useContext(RegionRegistryContext);
|
|
319
|
+
const specRef = useRef(spec);
|
|
320
|
+
specRef.current = spec;
|
|
321
|
+
const key = specKey(spec);
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
const element = ref.current;
|
|
324
|
+
if (registry === null || element === null || !enabled) {
|
|
325
|
+
registry?.remove(specRef.current.id);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const measure = () => {
|
|
329
|
+
const rect = element.getBoundingClientRect();
|
|
330
|
+
registry.set({
|
|
331
|
+
...specRef.current,
|
|
332
|
+
element,
|
|
333
|
+
rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
|
|
334
|
+
});
|
|
335
|
+
};
|
|
336
|
+
measure();
|
|
337
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
|
|
338
|
+
observer?.observe(element);
|
|
339
|
+
if (typeof window !== "undefined") {
|
|
340
|
+
window.addEventListener("resize", measure);
|
|
341
|
+
window.addEventListener("orientationchange", measure);
|
|
342
|
+
window.visualViewport?.addEventListener("resize", measure);
|
|
343
|
+
window.visualViewport?.addEventListener("scroll", measure);
|
|
344
|
+
}
|
|
345
|
+
return () => {
|
|
346
|
+
observer?.disconnect();
|
|
347
|
+
if (typeof window !== "undefined") {
|
|
348
|
+
window.removeEventListener("resize", measure);
|
|
349
|
+
window.removeEventListener("orientationchange", measure);
|
|
350
|
+
window.visualViewport?.removeEventListener("resize", measure);
|
|
351
|
+
window.visualViewport?.removeEventListener("scroll", measure);
|
|
352
|
+
}
|
|
353
|
+
registry.remove(specRef.current.id);
|
|
354
|
+
};
|
|
355
|
+
}, [registry, ref, key, enabled]);
|
|
356
|
+
}
|
package/dist/hooks.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ import type { ClockSnapshot, SimClock } from "@jgengine/core/time/simClock";
|
|
|
19
19
|
import { type PositionedPrompt } from "@jgengine/core/interaction/proximityPrompt";
|
|
20
20
|
import { type GamePhase } from "@jgengine/core/game/gamePhase";
|
|
21
21
|
export declare function useGameStore<T>(selector: (ctx: GameContext) => T, isEqual?: (previous: T, next: T) => boolean): T;
|
|
22
|
+
/** Subscribe to a single keyed slice of the game store, returning `fallback` until the key is set. */
|
|
23
|
+
export declare function useGameStoreValue<T>(key: string, fallback: T): T;
|
|
22
24
|
export declare function useGame(): {
|
|
23
25
|
commands: GameContext["game"]["commands"];
|
|
24
26
|
events: GameEvents;
|
|
@@ -32,8 +34,19 @@ export declare function useGamePhase(): {
|
|
|
32
34
|
phase: GamePhase;
|
|
33
35
|
setPhase: (phase: GamePhase) => void;
|
|
34
36
|
};
|
|
37
|
+
/** Live run phase that degrades to `"playing"` when rendered outside a `GameProvider` (component showcases, previews), so phase-gated chrome never throws. */
|
|
38
|
+
export declare function useOptionalGamePhase(): GamePhase;
|
|
35
39
|
export declare function useSceneEntities(): readonly SceneEntity[];
|
|
36
40
|
export declare function useSceneObjects(): readonly SceneObject[];
|
|
41
|
+
/**
|
|
42
|
+
* Membership-only entity id list: the returned array keeps a stable identity across per-frame pose
|
|
43
|
+
* writes and only changes when an entity spawns, despawns, or the store is hydrated (#625). A marker
|
|
44
|
+
* mapped from these ids reads its own live pose imperatively (useFrame), so the actor tree no longer
|
|
45
|
+
* re-reconciles every frame. Prefer this over {@link useSceneEntities} for large scenes.
|
|
46
|
+
*/
|
|
47
|
+
export declare function useSceneEntityIds(): readonly string[];
|
|
48
|
+
/** Membership-only object id list — the object counterpart of {@link useSceneEntityIds}; stable across move/rotate/setVisual, changes only on place/remove. */
|
|
49
|
+
export declare function useSceneObjectIds(): readonly string[];
|
|
37
50
|
export declare function useWorldItems(): readonly WorldItemRecord[];
|
|
38
51
|
/** Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight. */
|
|
39
52
|
export declare function useNearestWorldItem(radius: number): WorldItemRecord | null;
|
|
@@ -88,18 +101,27 @@ export declare function useGameClock(): ClockSnapshot & {
|
|
|
88
101
|
};
|
|
89
102
|
export declare function useActivePrompt<T extends PositionedPrompt>(prompts?: readonly T[]): T | null;
|
|
90
103
|
export declare function abilityKitNeedsHeartbeat(kit: AbilityKit, resourceAvailable?: number): boolean;
|
|
104
|
+
/** True when `meter`'s value/fraction/tier/ready diverge from `previous` — the re-render check `useEventMeter` polls on its heartbeat. */
|
|
91
105
|
export declare function eventMeterNeedsHeartbeat(meter: EventMeter, previous: EventMeterView | null): boolean;
|
|
92
106
|
export interface AbilitySlotBindingOptions {
|
|
93
107
|
intervalMs?: number;
|
|
94
108
|
}
|
|
95
109
|
export declare function useAbilitySlots(kit: AbilityKit, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot[];
|
|
96
110
|
export declare function useAbilitySlot(kit: AbilityKit, slotId: string, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot | null;
|
|
111
|
+
/** A rendered snapshot of an {@link EventMeter}: current value, fill fraction, active tier, and ready-to-consume flag. */
|
|
97
112
|
export interface EventMeterView {
|
|
98
113
|
value: number;
|
|
99
114
|
fraction: number;
|
|
100
115
|
tier: string | null;
|
|
101
116
|
ready: boolean;
|
|
102
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Bind a `createEventMeter` (`@jgengine/core/stats/eventMeter`) heat/streak gauge to a component — the react-render
|
|
120
|
+
* half of the ult/adrenaline and streak/combo meters (`event-meter` capability) that lets a HUD gauge re-render
|
|
121
|
+
* on tick without the game hand-rolling a `useEffect`/`setInterval` heartbeat around `meter.value()`.
|
|
122
|
+
*
|
|
123
|
+
* @capability event-meter-hud render a core event/heat meter's live value, fraction, tier, and ready state in a HUD gauge
|
|
124
|
+
*/
|
|
103
125
|
export declare function useEventMeter(meter: EventMeter, options?: AbilitySlotBindingOptions): EventMeterView;
|
|
104
126
|
type HeldKeyEventTarget = Pick<Window, "addEventListener" | "removeEventListener">;
|
|
105
127
|
export declare function createHeldKeyTracker(target: HeldKeyEventTarget): {
|
package/dist/hooks.js
CHANGED
|
@@ -3,7 +3,8 @@ import { AxisChannel } from "@jgengine/core/input/axisInput";
|
|
|
3
3
|
import { browseSessions, } from "@jgengine/core/multiplayer/matchmaking";
|
|
4
4
|
import { resolveActivePrompt, } from "@jgengine/core/interaction/proximityPrompt";
|
|
5
5
|
import { gamePhase, setGamePhase } from "@jgengine/core/game/gamePhase";
|
|
6
|
-
import { useGameContext } from "./provider.js";
|
|
6
|
+
import { useGameContext, useOptionalGameContext } from "./provider.js";
|
|
7
|
+
const noopSubscribe = () => () => undefined;
|
|
7
8
|
import { createSelectCache, readSelectSnapshot } from "./selectSnapshot.js";
|
|
8
9
|
export function useGameStore(selector, isEqual = Object.is) {
|
|
9
10
|
const ctx = useGameContext();
|
|
@@ -15,6 +16,10 @@ export function useGameStore(selector, isEqual = Object.is) {
|
|
|
15
16
|
const getSnapshot = useCallback(() => readSelectSnapshot(cacheRef.current, ctx.version(), () => selectorRef.current(ctx), (previous, next) => isEqualRef.current(previous, next)), [ctx]);
|
|
16
17
|
return useSyncExternalStore(ctx.subscribe, getSnapshot, getSnapshot);
|
|
17
18
|
}
|
|
19
|
+
/** Subscribe to a single keyed slice of the game store, returning `fallback` until the key is set. */
|
|
20
|
+
export function useGameStoreValue(key, fallback) {
|
|
21
|
+
return useGameStore((ctx) => ctx.game.store.get(key) ?? fallback);
|
|
22
|
+
}
|
|
18
23
|
export function useGame() {
|
|
19
24
|
const ctx = useGameContext();
|
|
20
25
|
return useMemo(() => ({ commands: ctx.game.commands, events: ctx.game.events }), [ctx]);
|
|
@@ -30,12 +35,35 @@ export function useGamePhase() {
|
|
|
30
35
|
const setPhase = useCallback((next) => setGamePhase(ctx, next), [ctx]);
|
|
31
36
|
return { phase, setPhase };
|
|
32
37
|
}
|
|
38
|
+
/** Live run phase that degrades to `"playing"` when rendered outside a `GameProvider` (component showcases, previews), so phase-gated chrome never throws. */
|
|
39
|
+
export function useOptionalGamePhase() {
|
|
40
|
+
const ctx = useOptionalGameContext();
|
|
41
|
+
const getSnapshot = useCallback(() => (ctx === null ? "playing" : gamePhase(ctx)), [ctx]);
|
|
42
|
+
return useSyncExternalStore(ctx?.subscribe ?? noopSubscribe, getSnapshot, getSnapshot);
|
|
43
|
+
}
|
|
33
44
|
export function useSceneEntities() {
|
|
34
45
|
return useGameStore((ctx) => ctx.scene.entity.list());
|
|
35
46
|
}
|
|
36
47
|
export function useSceneObjects() {
|
|
37
48
|
return useGameStore((ctx) => ctx.scene.object.list());
|
|
38
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Membership-only entity id list: the returned array keeps a stable identity across per-frame pose
|
|
52
|
+
* writes and only changes when an entity spawns, despawns, or the store is hydrated (#625). A marker
|
|
53
|
+
* mapped from these ids reads its own live pose imperatively (useFrame), so the actor tree no longer
|
|
54
|
+
* re-reconciles every frame. Prefer this over {@link useSceneEntities} for large scenes.
|
|
55
|
+
*/
|
|
56
|
+
export function useSceneEntityIds() {
|
|
57
|
+
const ctx = useGameContext();
|
|
58
|
+
const entity = ctx.scene.entity;
|
|
59
|
+
return useSyncExternalStore(entity.subscribeMembership, entity.ids, entity.ids);
|
|
60
|
+
}
|
|
61
|
+
/** Membership-only object id list — the object counterpart of {@link useSceneEntityIds}; stable across move/rotate/setVisual, changes only on place/remove. */
|
|
62
|
+
export function useSceneObjectIds() {
|
|
63
|
+
const ctx = useGameContext();
|
|
64
|
+
const object = ctx.scene.object;
|
|
65
|
+
return useSyncExternalStore(object.subscribeMembership, object.ids, object.ids);
|
|
66
|
+
}
|
|
39
67
|
export function useWorldItems() {
|
|
40
68
|
return useGameStore((ctx) => ctx.scene.worldItem.list());
|
|
41
69
|
}
|
|
@@ -64,30 +92,38 @@ export function useCurrency(currencyId) {
|
|
|
64
92
|
export function useFeed({ action, limit }) {
|
|
65
93
|
return useGameStore((ctx) => ctx.game.feed.recent(action, limit === undefined ? undefined : { limit }));
|
|
66
94
|
}
|
|
95
|
+
const EMPTY_QUESTS = [];
|
|
67
96
|
export function useQuestJournal() {
|
|
68
|
-
return useGameStore((ctx) => ctx.game.quest
|
|
69
|
-
}
|
|
97
|
+
return useGameStore((ctx) => ctx.game.quest?.list(ctx.player.userId) ?? EMPTY_QUESTS);
|
|
98
|
+
}
|
|
99
|
+
const EMPTY_FRIENDS = [];
|
|
100
|
+
const EMPTY_PARTY = [];
|
|
101
|
+
const EMPTY_WORLD_INVITES = [];
|
|
102
|
+
const EMPTY_CHAT = [];
|
|
103
|
+
const EMPTY_FRIEND_REQUESTS = [];
|
|
104
|
+
const EMPTY_PARTY_INVITES = [];
|
|
105
|
+
const OFFLINE_PRESENCE = { online: false };
|
|
70
106
|
export function useFriends() {
|
|
71
|
-
return useGameStore((ctx) => ctx.game.social
|
|
107
|
+
return useGameStore((ctx) => ctx.game.social?.friends.list(ctx.player.userId) ?? EMPTY_FRIENDS);
|
|
72
108
|
}
|
|
73
109
|
export function useParty() {
|
|
74
|
-
return useGameStore((ctx) => ctx.game.social
|
|
110
|
+
return useGameStore((ctx) => ctx.game.social?.party.list(ctx.player.userId) ?? EMPTY_PARTY);
|
|
75
111
|
}
|
|
76
112
|
export function usePresence(userId) {
|
|
77
|
-
return useGameStore((ctx) => ctx.game.social
|
|
113
|
+
return useGameStore((ctx) => ctx.game.social?.presence.get(userId) ?? OFFLINE_PRESENCE);
|
|
78
114
|
}
|
|
79
115
|
export function useWorldInvites() {
|
|
80
|
-
return useGameStore((ctx) => ctx.game.social
|
|
116
|
+
return useGameStore((ctx) => ctx.game.social?.worldInvites.listFor(ctx.player.userId) ?? EMPTY_WORLD_INVITES);
|
|
81
117
|
}
|
|
82
118
|
export function useChat(channelId, options) {
|
|
83
119
|
const limit = options?.limit ?? 50;
|
|
84
|
-
return useGameStore((ctx) => ctx.game.chat
|
|
120
|
+
return useGameStore((ctx) => ctx.game.chat?.history(channelId, { limit, viewerUserId: ctx.player.userId }) ?? EMPTY_CHAT);
|
|
85
121
|
}
|
|
86
122
|
export function useFriendRequests() {
|
|
87
|
-
return useGameStore((ctx) => ctx.game.social
|
|
123
|
+
return useGameStore((ctx) => ctx.game.social?.friends.requestsFor(ctx.player.userId) ?? EMPTY_FRIEND_REQUESTS);
|
|
88
124
|
}
|
|
89
125
|
export function usePartyInvites() {
|
|
90
|
-
return useGameStore((ctx) => ctx.game.social
|
|
126
|
+
return useGameStore((ctx) => ctx.game.social?.party.invitesFor(ctx.player.userId) ?? EMPTY_PARTY_INVITES);
|
|
91
127
|
}
|
|
92
128
|
/**
|
|
93
129
|
* Polls a host-supplied session fetcher (e.g. createWsBackend().browse) and
|
|
@@ -134,11 +170,13 @@ export function useWorldBrowser(options) {
|
|
|
134
170
|
return useMemo(() => ({ listings, loading, error, refresh: () => setRefreshTick((tick) => tick + 1) }), [listings, loading, error]);
|
|
135
171
|
}
|
|
136
172
|
export function useRoster(userId) {
|
|
137
|
-
return useGameStore((ctx) => ctx.game.roster
|
|
173
|
+
return useGameStore((ctx) => ctx.game.roster?.list(userId ?? ctx.player.userId) ?? EMPTY_ROSTER);
|
|
138
174
|
}
|
|
175
|
+
const EMPTY_ROSTER = [];
|
|
139
176
|
export function useLeaderboard(stat, options) {
|
|
140
|
-
return useGameStore((ctx) => ctx.game.leaderboard
|
|
177
|
+
return useGameStore((ctx) => ctx.game.leaderboard?.getTop(stat, options) ?? EMPTY_LEADERBOARD);
|
|
141
178
|
}
|
|
179
|
+
const EMPTY_LEADERBOARD = [];
|
|
142
180
|
export function useLocalPlayerDead(healthStatId = "health") {
|
|
143
181
|
return useGameStore((ctx) => {
|
|
144
182
|
const player = localPlayerEntity(ctx);
|
|
@@ -175,6 +213,7 @@ export function abilityKitNeedsHeartbeat(kit, resourceAvailable) {
|
|
|
175
213
|
}
|
|
176
214
|
return false;
|
|
177
215
|
}
|
|
216
|
+
/** True when `meter`'s value/fraction/tier/ready diverge from `previous` — the re-render check `useEventMeter` polls on its heartbeat. */
|
|
178
217
|
export function eventMeterNeedsHeartbeat(meter, previous) {
|
|
179
218
|
const next = {
|
|
180
219
|
value: meter.value(),
|
|
@@ -211,6 +250,13 @@ export function useAbilitySlot(kit, slotId, resourceAvailable, options) {
|
|
|
211
250
|
useEngineHeartbeat(options?.intervalMs ?? 80, () => abilityKitNeedsHeartbeat(kit, resourceAvailable));
|
|
212
251
|
return kit.state(slotId, resourceAvailable);
|
|
213
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Bind a `createEventMeter` (`@jgengine/core/stats/eventMeter`) heat/streak gauge to a component — the react-render
|
|
255
|
+
* half of the ult/adrenaline and streak/combo meters (`event-meter` capability) that lets a HUD gauge re-render
|
|
256
|
+
* on tick without the game hand-rolling a `useEffect`/`setInterval` heartbeat around `meter.value()`.
|
|
257
|
+
*
|
|
258
|
+
* @capability event-meter-hud render a core event/heat meter's live value, fraction, tier, and ready state in a HUD gauge
|
|
259
|
+
*/
|
|
214
260
|
export function useEventMeter(meter, options) {
|
|
215
261
|
const previous = useRef(null);
|
|
216
262
|
useEngineHeartbeat(options?.intervalMs ?? 80, () => {
|
package/dist/hud.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
/** Named color ramps for {@link StatBar} tones. `fn` maps fill fraction (0..1) → gradient stops. */
|
|
3
|
+
declare const TONES: Record<string, (t: number) => [string, string]>;
|
|
4
|
+
/** A stat tone — picks the bar's color ramp. */
|
|
5
|
+
export type StatTone = keyof typeof TONES;
|
|
6
|
+
/**
|
|
7
|
+
* A polished stat bar (health/mana/stamina/shield/xp) — a rounded, glassy meter with a tone-colored
|
|
8
|
+
* fill and an optional value readout. Reads `statId` off `entityId` (defaults to the local player).
|
|
9
|
+
* Renders nothing until the stat exists.
|
|
10
|
+
*/
|
|
11
|
+
export declare function StatBar({ statId, entityId, tone, label, showValue, width, icon, style, className, }: {
|
|
12
|
+
statId?: string;
|
|
13
|
+
entityId?: string;
|
|
14
|
+
tone?: StatTone;
|
|
15
|
+
label?: string;
|
|
16
|
+
showValue?: boolean;
|
|
17
|
+
width?: number;
|
|
18
|
+
icon?: ReactNode;
|
|
19
|
+
style?: CSSProperties;
|
|
20
|
+
className?: string;
|
|
21
|
+
}): import("react").JSX.Element | null;
|
|
22
|
+
/**
|
|
23
|
+
* A numbered hotbar bound to an inventory — glassy slots, an active-slot highlight, item id + count,
|
|
24
|
+
* and a keycap per slot. Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one.
|
|
25
|
+
*/
|
|
26
|
+
export declare function Hotbar({ inventoryId, activeSlot, keys, slotSize, style, className, }: {
|
|
27
|
+
inventoryId: string;
|
|
28
|
+
activeSlot?: number;
|
|
29
|
+
keys?: readonly string[];
|
|
30
|
+
slotSize?: number;
|
|
31
|
+
style?: CSSProperties;
|
|
32
|
+
className?: string;
|
|
33
|
+
}): import("react").JSX.Element;
|
|
34
|
+
/**
|
|
35
|
+
* A speedometer for an entity (defaults to the local player) — an SVG arc gauge + a digital readout.
|
|
36
|
+
* `scale` converts world units/second to your display unit (3.6 → km/h, 2.237 → mph); `max` sets the
|
|
37
|
+
* gauge's top of scale.
|
|
38
|
+
*/
|
|
39
|
+
export declare function Speedometer({ entityId, scale, unit, max, size, style, className, }: {
|
|
40
|
+
entityId?: string;
|
|
41
|
+
scale?: number;
|
|
42
|
+
unit?: string;
|
|
43
|
+
max?: number;
|
|
44
|
+
size?: number;
|
|
45
|
+
style?: CSSProperties;
|
|
46
|
+
className?: string;
|
|
47
|
+
}): import("react").JSX.Element;
|
|
48
|
+
/**
|
|
49
|
+
* A time-of-day clock reading the sim calendar — `Day N · HH:MM`, 24h or 12h. `controls` adds
|
|
50
|
+
* pause + the game's speed multipliers as clickable pills (the "fast-forward" bar), off by default so
|
|
51
|
+
* a game opts into letting the player scrub time.
|
|
52
|
+
*/
|
|
53
|
+
export declare function Clock({ format, showDay, controls, style, className, }: {
|
|
54
|
+
format?: "24h" | "12h";
|
|
55
|
+
showDay?: boolean;
|
|
56
|
+
controls?: boolean;
|
|
57
|
+
style?: CSSProperties;
|
|
58
|
+
className?: string;
|
|
59
|
+
}): import("react").JSX.Element;
|
|
60
|
+
/**
|
|
61
|
+
* A wave / round banner — a bold centered pill for "WAVE 3" style callouts, with an optional subtitle
|
|
62
|
+
* (enemies remaining, timer). Pure display: pass the current `wave` and whatever subtitle you track.
|
|
63
|
+
*/
|
|
64
|
+
export declare function WaveBanner({ wave, label, subtitle, style, className, }: {
|
|
65
|
+
wave: number | string;
|
|
66
|
+
label?: string;
|
|
67
|
+
subtitle?: ReactNode;
|
|
68
|
+
style?: CSSProperties;
|
|
69
|
+
className?: string;
|
|
70
|
+
}): import("react").JSX.Element;
|
|
71
|
+
/** A currency counter — an icon (emoji/char, default a coin) plus the live amount for `currencyId`. */
|
|
72
|
+
export declare function Coins({ currencyId, icon, style, className, }: {
|
|
73
|
+
currencyId: string;
|
|
74
|
+
icon?: ReactNode;
|
|
75
|
+
style?: CSSProperties;
|
|
76
|
+
className?: string;
|
|
77
|
+
}): import("react").JSX.Element;
|
|
78
|
+
/** A minimal center crosshair reticle — four ticks around a gap. Purely presentational. */
|
|
79
|
+
export declare function Crosshair({ size, gap, thickness, color, style, className, }: {
|
|
80
|
+
size?: number;
|
|
81
|
+
gap?: number;
|
|
82
|
+
thickness?: number;
|
|
83
|
+
color?: string;
|
|
84
|
+
style?: CSSProperties;
|
|
85
|
+
className?: string;
|
|
86
|
+
}): import("react").JSX.Element;
|
|
87
|
+
export {};
|