@jgengine/react 0.8.0 → 0.10.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 +36 -0
- package/dist/chat.d.ts +3 -3
- package/dist/chat.js +14 -5
- package/dist/chatBubbles.d.ts +15 -0
- package/dist/chatBubbles.js +46 -0
- package/dist/components.d.ts +1 -0
- package/dist/components.js +36 -23
- package/dist/display.d.ts +2 -1
- package/dist/display.js +3 -2
- package/dist/dragLayer.d.ts +6 -1
- package/dist/dragLayer.js +64 -31
- package/dist/engineStore.d.ts +1 -1
- package/dist/engineStore.js +10 -2
- package/dist/fogOverlay.d.ts +21 -0
- package/dist/fogOverlay.js +34 -0
- package/dist/gameViewport.d.ts +54 -0
- package/dist/gameViewport.js +340 -0
- package/dist/hooks.d.ts +11 -1
- package/dist/hooks.js +89 -21
- package/dist/hudLayout.d.ts +79 -0
- package/dist/hudLayout.js +518 -0
- package/dist/hudViewport.d.ts +21 -0
- package/dist/hudViewport.js +17 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/liveBind.d.ts +4 -10
- package/dist/liveBind.js +28 -16
- package/dist/map.d.ts +27 -7
- package/dist/map.js +152 -43
- package/dist/preview.d.ts +6 -0
- package/dist/preview.js +1 -0
- 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/selectSnapshot.d.ts +7 -0
- package/dist/selectSnapshot.js +16 -0
- package/dist/settings.d.ts +78 -0
- package/dist/settings.js +61 -0
- package/dist/skillCheckPaint.d.ts +4 -0
- package/dist/skillCheckPaint.js +28 -0
- package/dist/social.d.ts +3 -3
- package/dist/social.js +31 -10
- package/llms.txt +858 -860
- package/package.json +9 -5
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared mobile-composition runtime. `GameViewportProvider` allocates the live
|
|
3
|
+
* game viewport once — tracking `visualViewport`, safe-area insets, orientation
|
|
4
|
+
* and layout mode — and hosts a registry every UI subsystem publishes its
|
|
5
|
+
* occupied rectangle to. HUD panels, touch-control zones, and system chrome all
|
|
6
|
+
* register here so the engine can detect forbidden overlaps instead of each
|
|
7
|
+
* subsystem independently claiming an edge.
|
|
8
|
+
*/
|
|
9
|
+
import { type CSSProperties, type ReactNode, type RefObject } from "react";
|
|
10
|
+
import { type GameLayoutMode, type GameViewportLayout, type LayoutCollision, type LayoutRect, type LayoutRegion } from "@jgengine/core/ui/gameLayout";
|
|
11
|
+
import type { LayoutOrientation } from "@jgengine/core/ui/orientation";
|
|
12
|
+
import type { HudPlatform } from "@jgengine/core/ui/hudScale";
|
|
13
|
+
/** A region registration: the full `LayoutRegion` plus the live element (dev outlining), rect measured by the shell/react side. */
|
|
14
|
+
export interface RegionRecord extends LayoutRegion {
|
|
15
|
+
element?: HTMLElement | null;
|
|
16
|
+
}
|
|
17
|
+
/** A region descriptor without its measured rectangle — the caller supplies geometry through `useRegisterLayoutRegion`. */
|
|
18
|
+
export type LayoutRegionSpec = Omit<LayoutRegion, "rect">;
|
|
19
|
+
/** Live viewport rectangles: the layout viewport and the visible `visualViewport`. */
|
|
20
|
+
export interface ViewportMetrics {
|
|
21
|
+
layout: LayoutRect;
|
|
22
|
+
visual: LayoutRect;
|
|
23
|
+
}
|
|
24
|
+
/** Live visible viewport, tracking `window.visualViewport` (mobile browser chrome, pinch-zoom) with a layout-viewport fallback. */
|
|
25
|
+
export declare function useViewportMetrics(): ViewportMetrics;
|
|
26
|
+
/**
|
|
27
|
+
* Provides the shared game viewport layout to everything it wraps. Mount it
|
|
28
|
+
* once around the whole game presentation (world, HUD, controls, system UI) so
|
|
29
|
+
* every subsystem reads one coordinated geometry and registers its rect for
|
|
30
|
+
* collision detection. Publishes live `--jg-viewport-*` / `--jg-visual-viewport-*`
|
|
31
|
+
* / `--jg-safe-*` CSS variables on its root.
|
|
32
|
+
*/
|
|
33
|
+
export declare function GameViewportProvider({ platforms, className, style, children, }: {
|
|
34
|
+
platforms?: readonly HudPlatform[];
|
|
35
|
+
className?: string;
|
|
36
|
+
style?: CSSProperties;
|
|
37
|
+
children?: ReactNode;
|
|
38
|
+
}): import("react").JSX.Element;
|
|
39
|
+
/** The live shared viewport layout. Returns a neutral default outside a `GameViewportProvider` so it never throws in previews. */
|
|
40
|
+
export declare function useGameViewportLayout(): GameViewportLayout;
|
|
41
|
+
/** The resolved explicit composition mode (`desktop-wide` … `mobile-portrait`). */
|
|
42
|
+
export declare function useGameLayoutMode(): GameLayoutMode;
|
|
43
|
+
/** The live device orientation. */
|
|
44
|
+
export declare function useGameOrientation(): LayoutOrientation;
|
|
45
|
+
/** Rectangles reserved by touch controls and system UI — HUD placement should avoid these. */
|
|
46
|
+
export declare function useReservedControlZones(): readonly LayoutRect[];
|
|
47
|
+
/** Live forbidden/warned region collisions (empty outside a provider). */
|
|
48
|
+
export declare function useLayoutCollisions(): readonly LayoutCollision[];
|
|
49
|
+
/**
|
|
50
|
+
* Register the element behind `ref` as a layout region and keep its measured
|
|
51
|
+
* rectangle live (ResizeObserver + viewport changes). No-op outside a
|
|
52
|
+
* `GameViewportProvider`, so a component using it still works in isolation.
|
|
53
|
+
*/
|
|
54
|
+
export declare function useRegisterLayoutRegion(spec: LayoutRegionSpec, ref: RefObject<HTMLElement | null>, enabled?: boolean): void;
|
|
@@ -0,0 +1,340 @@
|
|
|
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 SERVER_METRICS = {
|
|
71
|
+
layout: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
72
|
+
visual: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
73
|
+
};
|
|
74
|
+
function readViewportMetrics() {
|
|
75
|
+
if (typeof window === "undefined")
|
|
76
|
+
return SERVER_METRICS;
|
|
77
|
+
const layoutWidth = window.innerWidth;
|
|
78
|
+
const layoutHeight = window.innerHeight;
|
|
79
|
+
const vv = window.visualViewport;
|
|
80
|
+
const visual = vv
|
|
81
|
+
? {
|
|
82
|
+
left: vv.offsetLeft,
|
|
83
|
+
top: vv.offsetTop,
|
|
84
|
+
right: vv.offsetLeft + vv.width,
|
|
85
|
+
bottom: vv.offsetTop + vv.height,
|
|
86
|
+
}
|
|
87
|
+
: { left: 0, top: 0, right: layoutWidth, bottom: layoutHeight };
|
|
88
|
+
return { layout: { left: 0, top: 0, right: layoutWidth, bottom: layoutHeight }, visual };
|
|
89
|
+
}
|
|
90
|
+
let cachedMetrics = SERVER_METRICS;
|
|
91
|
+
function metricsSnapshot() {
|
|
92
|
+
const next = readViewportMetrics();
|
|
93
|
+
if (!sameRect(next.layout, cachedMetrics.layout) || !sameRect(next.visual, cachedMetrics.visual)) {
|
|
94
|
+
cachedMetrics = next;
|
|
95
|
+
}
|
|
96
|
+
return cachedMetrics;
|
|
97
|
+
}
|
|
98
|
+
function subscribeViewport(onChange) {
|
|
99
|
+
if (typeof window === "undefined")
|
|
100
|
+
return () => undefined;
|
|
101
|
+
window.addEventListener("resize", onChange);
|
|
102
|
+
window.addEventListener("orientationchange", onChange);
|
|
103
|
+
const vv = window.visualViewport;
|
|
104
|
+
vv?.addEventListener("resize", onChange);
|
|
105
|
+
vv?.addEventListener("scroll", onChange);
|
|
106
|
+
return () => {
|
|
107
|
+
window.removeEventListener("resize", onChange);
|
|
108
|
+
window.removeEventListener("orientationchange", onChange);
|
|
109
|
+
vv?.removeEventListener("resize", onChange);
|
|
110
|
+
vv?.removeEventListener("scroll", onChange);
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Live visible viewport, tracking `window.visualViewport` (mobile browser chrome, pinch-zoom) with a layout-viewport fallback. */
|
|
114
|
+
export function useViewportMetrics() {
|
|
115
|
+
return useSyncExternalStore(subscribeViewport, metricsSnapshot, () => SERVER_METRICS);
|
|
116
|
+
}
|
|
117
|
+
function measureSafeArea(probe) {
|
|
118
|
+
if (probe === null || typeof getComputedStyle !== "function")
|
|
119
|
+
return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
120
|
+
const style = getComputedStyle(probe);
|
|
121
|
+
const px = (value) => {
|
|
122
|
+
const n = Number.parseFloat(value);
|
|
123
|
+
return Number.isFinite(n) ? n : 0;
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
top: px(style.paddingTop),
|
|
127
|
+
right: px(style.paddingRight),
|
|
128
|
+
bottom: px(style.paddingBottom),
|
|
129
|
+
left: px(style.paddingLeft),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const SAFE_AREA_PROBE_STYLE = {
|
|
133
|
+
position: "fixed",
|
|
134
|
+
top: 0,
|
|
135
|
+
left: 0,
|
|
136
|
+
width: 0,
|
|
137
|
+
height: 0,
|
|
138
|
+
visibility: "hidden",
|
|
139
|
+
pointerEvents: "none",
|
|
140
|
+
paddingTop: "env(safe-area-inset-top, 0px)",
|
|
141
|
+
paddingRight: "env(safe-area-inset-right, 0px)",
|
|
142
|
+
paddingBottom: "env(safe-area-inset-bottom, 0px)",
|
|
143
|
+
paddingLeft: "env(safe-area-inset-left, 0px)",
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Provides the shared game viewport layout to everything it wraps. Mount it
|
|
147
|
+
* once around the whole game presentation (world, HUD, controls, system UI) so
|
|
148
|
+
* every subsystem reads one coordinated geometry and registers its rect for
|
|
149
|
+
* collision detection. Publishes live `--jg-viewport-*` / `--jg-visual-viewport-*`
|
|
150
|
+
* / `--jg-safe-*` CSS variables on its root.
|
|
151
|
+
*/
|
|
152
|
+
export function GameViewportProvider({ platforms, className, style, children, }) {
|
|
153
|
+
const registry = useMemo(createRegionRegistry, []);
|
|
154
|
+
const metrics = useViewportMetrics();
|
|
155
|
+
const { coarsePointer } = useDisplayProfile();
|
|
156
|
+
const probeRef = useRef(null);
|
|
157
|
+
const [safeArea, setSafeArea] = useState({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
158
|
+
const rootRef = useRef(null);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
const update = () => {
|
|
161
|
+
const next = measureSafeArea(probeRef.current);
|
|
162
|
+
setSafeArea((prev) => prev.top === next.top && prev.right === next.right && prev.bottom === next.bottom && prev.left === next.left
|
|
163
|
+
? prev
|
|
164
|
+
: next);
|
|
165
|
+
};
|
|
166
|
+
update();
|
|
167
|
+
if (typeof window === "undefined")
|
|
168
|
+
return;
|
|
169
|
+
window.addEventListener("resize", update);
|
|
170
|
+
window.addEventListener("orientationchange", update);
|
|
171
|
+
return () => {
|
|
172
|
+
window.removeEventListener("resize", update);
|
|
173
|
+
window.removeEventListener("orientationchange", update);
|
|
174
|
+
};
|
|
175
|
+
}, [metrics]);
|
|
176
|
+
const regions = useSyncExternalStore(registry.subscribe, registry.list, () => EMPTY_REGIONS);
|
|
177
|
+
const mobileSupported = platforms?.includes("mobile") ?? true;
|
|
178
|
+
const visual = metrics.visual;
|
|
179
|
+
const vw = Math.max(0, visual.right - visual.left);
|
|
180
|
+
const vh = Math.max(0, visual.bottom - visual.top);
|
|
181
|
+
const layout = useMemo(() => {
|
|
182
|
+
const orientation = vw > 0 || vh > 0 ? orientationOf(vw, vh) : orientationOf(metrics.layout.right, metrics.layout.bottom);
|
|
183
|
+
const mode = resolveLayoutMode({
|
|
184
|
+
width: vw > 0 ? vw : metrics.layout.right,
|
|
185
|
+
height: vh > 0 ? vh : metrics.layout.bottom,
|
|
186
|
+
coarsePointer,
|
|
187
|
+
mobileSupported,
|
|
188
|
+
});
|
|
189
|
+
const reserved = regions
|
|
190
|
+
.filter((region) => region.kind === "control" || region.kind === "system")
|
|
191
|
+
.map((region) => region.rect);
|
|
192
|
+
const gameplayRect = computeGameplayRect(metrics.layout, safeArea, reserved);
|
|
193
|
+
return {
|
|
194
|
+
viewport: metrics.layout,
|
|
195
|
+
visualViewport: visual,
|
|
196
|
+
safeArea,
|
|
197
|
+
mode,
|
|
198
|
+
orientation,
|
|
199
|
+
regions,
|
|
200
|
+
controlZones: reserved,
|
|
201
|
+
gameplayRect,
|
|
202
|
+
};
|
|
203
|
+
}, [regions, metrics.layout, visual, vw, vh, coarsePointer, mobileSupported, safeArea]);
|
|
204
|
+
const collisions = useMemo(() => detectLayoutCollisions(regions), [regions]);
|
|
205
|
+
const forbidden = useMemo(() => collisions.filter((c) => c.severity === "forbid"), [collisions]);
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
const root = rootRef.current;
|
|
208
|
+
if (root === null)
|
|
209
|
+
return;
|
|
210
|
+
if (forbidden.length === 0) {
|
|
211
|
+
root.removeAttribute("data-jg-layout-collision");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const report = JSON.stringify(forbidden);
|
|
215
|
+
root.setAttribute("data-jg-layout-collision", report);
|
|
216
|
+
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}`);
|
|
217
|
+
}, [forbidden, layout.mode, vw, vh, safeArea]);
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
const ids = new Set();
|
|
220
|
+
for (const collision of forbidden) {
|
|
221
|
+
ids.add(collision.a);
|
|
222
|
+
ids.add(collision.b);
|
|
223
|
+
}
|
|
224
|
+
const marked = [];
|
|
225
|
+
for (const region of regions) {
|
|
226
|
+
if (region.element != null && ids.has(region.id)) {
|
|
227
|
+
region.element.setAttribute("data-jg-collision", "");
|
|
228
|
+
marked.push(region.element);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return () => {
|
|
232
|
+
for (const element of marked)
|
|
233
|
+
element.removeAttribute("data-jg-collision");
|
|
234
|
+
};
|
|
235
|
+
}, [forbidden, regions]);
|
|
236
|
+
const rootStyle = {
|
|
237
|
+
position: "relative",
|
|
238
|
+
width: "100%",
|
|
239
|
+
height: "100%",
|
|
240
|
+
"--jg-viewport-width": `${metrics.layout.right}px`,
|
|
241
|
+
"--jg-viewport-height": `${metrics.layout.bottom}px`,
|
|
242
|
+
"--jg-visual-viewport-width": `${Math.round(vw)}px`,
|
|
243
|
+
"--jg-visual-viewport-height": `${Math.round(vh)}px`,
|
|
244
|
+
"--jg-visual-viewport-offset-top": `${Math.round(visual.top)}px`,
|
|
245
|
+
"--jg-visual-viewport-offset-left": `${Math.round(visual.left)}px`,
|
|
246
|
+
"--jg-safe-top": `${safeArea.top}px`,
|
|
247
|
+
"--jg-safe-right": `${safeArea.right}px`,
|
|
248
|
+
"--jg-safe-bottom": `${safeArea.bottom}px`,
|
|
249
|
+
"--jg-safe-left": `${safeArea.left}px`,
|
|
250
|
+
...style,
|
|
251
|
+
};
|
|
252
|
+
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] }) }) }));
|
|
253
|
+
}
|
|
254
|
+
/** The live shared viewport layout. Returns a neutral default outside a `GameViewportProvider` so it never throws in previews. */
|
|
255
|
+
export function useGameViewportLayout() {
|
|
256
|
+
const layout = useContext(GameLayoutContext);
|
|
257
|
+
const metrics = useViewportMetrics();
|
|
258
|
+
return (layout ?? {
|
|
259
|
+
viewport: metrics.layout,
|
|
260
|
+
visualViewport: metrics.visual,
|
|
261
|
+
safeArea: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
262
|
+
mode: resolveLayoutMode({ width: metrics.layout.right, height: metrics.layout.bottom, coarsePointer: false }),
|
|
263
|
+
orientation: orientationOf(metrics.layout.right, metrics.layout.bottom),
|
|
264
|
+
regions: [],
|
|
265
|
+
controlZones: [],
|
|
266
|
+
gameplayRect: metrics.layout,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/** The resolved explicit composition mode (`desktop-wide` … `mobile-portrait`). */
|
|
270
|
+
export function useGameLayoutMode() {
|
|
271
|
+
return useGameViewportLayout().mode;
|
|
272
|
+
}
|
|
273
|
+
/** The live device orientation. */
|
|
274
|
+
export function useGameOrientation() {
|
|
275
|
+
return useGameViewportLayout().orientation;
|
|
276
|
+
}
|
|
277
|
+
/** Rectangles reserved by touch controls and system UI — HUD placement should avoid these. */
|
|
278
|
+
export function useReservedControlZones() {
|
|
279
|
+
return useGameViewportLayout().controlZones;
|
|
280
|
+
}
|
|
281
|
+
/** Live forbidden/warned region collisions (empty outside a provider). */
|
|
282
|
+
export function useLayoutCollisions() {
|
|
283
|
+
const layout = useContext(GameLayoutContext);
|
|
284
|
+
return useMemo(() => (layout === null ? [] : detectLayoutCollisions(layout.regions)), [layout]);
|
|
285
|
+
}
|
|
286
|
+
function specKey(spec) {
|
|
287
|
+
return [
|
|
288
|
+
spec.id,
|
|
289
|
+
spec.kind,
|
|
290
|
+
spec.collisionPolicy,
|
|
291
|
+
spec.priority ?? "",
|
|
292
|
+
spec.collisionGroup ?? "",
|
|
293
|
+
(spec.allowOverlapWith ?? []).join("+"),
|
|
294
|
+
].join("|");
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Register the element behind `ref` as a layout region and keep its measured
|
|
298
|
+
* rectangle live (ResizeObserver + viewport changes). No-op outside a
|
|
299
|
+
* `GameViewportProvider`, so a component using it still works in isolation.
|
|
300
|
+
*/
|
|
301
|
+
export function useRegisterLayoutRegion(spec, ref, enabled = true) {
|
|
302
|
+
const registry = useContext(RegionRegistryContext);
|
|
303
|
+
const specRef = useRef(spec);
|
|
304
|
+
specRef.current = spec;
|
|
305
|
+
const key = specKey(spec);
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
const element = ref.current;
|
|
308
|
+
if (registry === null || element === null || !enabled) {
|
|
309
|
+
registry?.remove(specRef.current.id);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const measure = () => {
|
|
313
|
+
const rect = element.getBoundingClientRect();
|
|
314
|
+
registry.set({
|
|
315
|
+
...specRef.current,
|
|
316
|
+
element,
|
|
317
|
+
rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
measure();
|
|
321
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
|
|
322
|
+
observer?.observe(element);
|
|
323
|
+
if (typeof window !== "undefined") {
|
|
324
|
+
window.addEventListener("resize", measure);
|
|
325
|
+
window.addEventListener("orientationchange", measure);
|
|
326
|
+
window.visualViewport?.addEventListener("resize", measure);
|
|
327
|
+
window.visualViewport?.addEventListener("scroll", measure);
|
|
328
|
+
}
|
|
329
|
+
return () => {
|
|
330
|
+
observer?.disconnect();
|
|
331
|
+
if (typeof window !== "undefined") {
|
|
332
|
+
window.removeEventListener("resize", measure);
|
|
333
|
+
window.removeEventListener("orientationchange", measure);
|
|
334
|
+
window.visualViewport?.removeEventListener("resize", measure);
|
|
335
|
+
window.visualViewport?.removeEventListener("scroll", measure);
|
|
336
|
+
}
|
|
337
|
+
registry.remove(specRef.current.id);
|
|
338
|
+
};
|
|
339
|
+
}, [registry, ref, key, enabled]);
|
|
340
|
+
}
|
package/dist/hooks.d.ts
CHANGED
|
@@ -17,7 +17,8 @@ import type { RosterEntry } from "@jgengine/core/scene/roster";
|
|
|
17
17
|
import type { WorldItemRecord } from "@jgengine/core/game/worldItem";
|
|
18
18
|
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
|
+
export declare function useGameStore<T>(selector: (ctx: GameContext) => T, isEqual?: (previous: T, next: T) => boolean): T;
|
|
21
22
|
export declare function useGame(): {
|
|
22
23
|
commands: GameContext["game"]["commands"];
|
|
23
24
|
events: GameEvents;
|
|
@@ -26,6 +27,13 @@ export declare function usePlayer(): {
|
|
|
26
27
|
userId: string;
|
|
27
28
|
isNew: boolean;
|
|
28
29
|
};
|
|
30
|
+
/** Live run phase + a setter that also gates the shell's touch controls. `menu`/`paused`/`ended` hide the touch dock; `playing` shows it. */
|
|
31
|
+
export declare function useGamePhase(): {
|
|
32
|
+
phase: GamePhase;
|
|
33
|
+
setPhase: (phase: GamePhase) => void;
|
|
34
|
+
};
|
|
35
|
+
/** Live run phase that degrades to `"playing"` when rendered outside a `GameProvider` (component showcases, previews), so phase-gated chrome never throws. */
|
|
36
|
+
export declare function useOptionalGamePhase(): GamePhase;
|
|
29
37
|
export declare function useSceneEntities(): readonly SceneEntity[];
|
|
30
38
|
export declare function useSceneObjects(): readonly SceneObject[];
|
|
31
39
|
export declare function useWorldItems(): readonly WorldItemRecord[];
|
|
@@ -81,6 +89,8 @@ export declare function useGameClock(): ClockSnapshot & {
|
|
|
81
89
|
controls: SimClock;
|
|
82
90
|
};
|
|
83
91
|
export declare function useActivePrompt<T extends PositionedPrompt>(prompts?: readonly T[]): T | null;
|
|
92
|
+
export declare function abilityKitNeedsHeartbeat(kit: AbilityKit, resourceAvailable?: number): boolean;
|
|
93
|
+
export declare function eventMeterNeedsHeartbeat(meter: EventMeter, previous: EventMeterView | null): boolean;
|
|
84
94
|
export interface AbilitySlotBindingOptions {
|
|
85
95
|
intervalMs?: number;
|
|
86
96
|
}
|
package/dist/hooks.js
CHANGED
|
@@ -2,11 +2,19 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore
|
|
|
2
2
|
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
|
-
import {
|
|
6
|
-
|
|
5
|
+
import { gamePhase, setGamePhase } from "@jgengine/core/game/gamePhase";
|
|
6
|
+
import { useGameContext, useOptionalGameContext } from "./provider.js";
|
|
7
|
+
const noopSubscribe = () => () => undefined;
|
|
8
|
+
import { createSelectCache, readSelectSnapshot } from "./selectSnapshot.js";
|
|
9
|
+
export function useGameStore(selector, isEqual = Object.is) {
|
|
7
10
|
const ctx = useGameContext();
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
const selectorRef = useRef(selector);
|
|
12
|
+
selectorRef.current = selector;
|
|
13
|
+
const isEqualRef = useRef(isEqual);
|
|
14
|
+
isEqualRef.current = isEqual;
|
|
15
|
+
const cacheRef = useRef(createSelectCache());
|
|
16
|
+
const getSnapshot = useCallback(() => readSelectSnapshot(cacheRef.current, ctx.version(), () => selectorRef.current(ctx), (previous, next) => isEqualRef.current(previous, next)), [ctx]);
|
|
17
|
+
return useSyncExternalStore(ctx.subscribe, getSnapshot, getSnapshot);
|
|
10
18
|
}
|
|
11
19
|
export function useGame() {
|
|
12
20
|
const ctx = useGameContext();
|
|
@@ -16,6 +24,19 @@ export function usePlayer() {
|
|
|
16
24
|
const ctx = useGameContext();
|
|
17
25
|
return useMemo(() => ({ userId: ctx.player.userId, isNew: ctx.player.isNew }), [ctx]);
|
|
18
26
|
}
|
|
27
|
+
/** Live run phase + a setter that also gates the shell's touch controls. `menu`/`paused`/`ended` hide the touch dock; `playing` shows it. */
|
|
28
|
+
export function useGamePhase() {
|
|
29
|
+
const ctx = useGameContext();
|
|
30
|
+
const phase = useGameStore((c) => gamePhase(c));
|
|
31
|
+
const setPhase = useCallback((next) => setGamePhase(ctx, next), [ctx]);
|
|
32
|
+
return { phase, setPhase };
|
|
33
|
+
}
|
|
34
|
+
/** Live run phase that degrades to `"playing"` when rendered outside a `GameProvider` (component showcases, previews), so phase-gated chrome never throws. */
|
|
35
|
+
export function useOptionalGamePhase() {
|
|
36
|
+
const ctx = useOptionalGameContext();
|
|
37
|
+
const getSnapshot = useCallback(() => (ctx === null ? "playing" : gamePhase(ctx)), [ctx]);
|
|
38
|
+
return useSyncExternalStore(ctx?.subscribe ?? noopSubscribe, getSnapshot, getSnapshot);
|
|
39
|
+
}
|
|
19
40
|
export function useSceneEntities() {
|
|
20
41
|
return useGameStore((ctx) => ctx.scene.entity.list());
|
|
21
42
|
}
|
|
@@ -53,27 +74,34 @@ export function useFeed({ action, limit }) {
|
|
|
53
74
|
export function useQuestJournal() {
|
|
54
75
|
return useGameStore((ctx) => ctx.game.quest.list(ctx.player.userId));
|
|
55
76
|
}
|
|
77
|
+
const EMPTY_FRIENDS = [];
|
|
78
|
+
const EMPTY_PARTY = [];
|
|
79
|
+
const EMPTY_WORLD_INVITES = [];
|
|
80
|
+
const EMPTY_CHAT = [];
|
|
81
|
+
const EMPTY_FRIEND_REQUESTS = [];
|
|
82
|
+
const EMPTY_PARTY_INVITES = [];
|
|
83
|
+
const OFFLINE_PRESENCE = { online: false };
|
|
56
84
|
export function useFriends() {
|
|
57
|
-
return useGameStore((ctx) => ctx.game.social
|
|
85
|
+
return useGameStore((ctx) => ctx.game.social?.friends.list(ctx.player.userId) ?? EMPTY_FRIENDS);
|
|
58
86
|
}
|
|
59
87
|
export function useParty() {
|
|
60
|
-
return useGameStore((ctx) => ctx.game.social
|
|
88
|
+
return useGameStore((ctx) => ctx.game.social?.party.list(ctx.player.userId) ?? EMPTY_PARTY);
|
|
61
89
|
}
|
|
62
90
|
export function usePresence(userId) {
|
|
63
|
-
return useGameStore((ctx) => ctx.game.social
|
|
91
|
+
return useGameStore((ctx) => ctx.game.social?.presence.get(userId) ?? OFFLINE_PRESENCE);
|
|
64
92
|
}
|
|
65
93
|
export function useWorldInvites() {
|
|
66
|
-
return useGameStore((ctx) => ctx.game.social
|
|
94
|
+
return useGameStore((ctx) => ctx.game.social?.worldInvites.listFor(ctx.player.userId) ?? EMPTY_WORLD_INVITES);
|
|
67
95
|
}
|
|
68
96
|
export function useChat(channelId, options) {
|
|
69
97
|
const limit = options?.limit ?? 50;
|
|
70
|
-
return useGameStore((ctx) => ctx.game.chat
|
|
98
|
+
return useGameStore((ctx) => ctx.game.chat?.history(channelId, { limit, viewerUserId: ctx.player.userId }) ?? EMPTY_CHAT);
|
|
71
99
|
}
|
|
72
100
|
export function useFriendRequests() {
|
|
73
|
-
return useGameStore((ctx) => ctx.game.social
|
|
101
|
+
return useGameStore((ctx) => ctx.game.social?.friends.requestsFor(ctx.player.userId) ?? EMPTY_FRIEND_REQUESTS);
|
|
74
102
|
}
|
|
75
103
|
export function usePartyInvites() {
|
|
76
|
-
return useGameStore((ctx) => ctx.game.social
|
|
104
|
+
return useGameStore((ctx) => ctx.game.social?.party.invitesFor(ctx.player.userId) ?? EMPTY_PARTY_INVITES);
|
|
77
105
|
}
|
|
78
106
|
/**
|
|
79
107
|
* Polls a host-supplied session fetcher (e.g. createWsBackend().browse) and
|
|
@@ -120,11 +148,13 @@ export function useWorldBrowser(options) {
|
|
|
120
148
|
return useMemo(() => ({ listings, loading, error, refresh: () => setRefreshTick((tick) => tick + 1) }), [listings, loading, error]);
|
|
121
149
|
}
|
|
122
150
|
export function useRoster(userId) {
|
|
123
|
-
return useGameStore((ctx) => ctx.game.roster
|
|
151
|
+
return useGameStore((ctx) => ctx.game.roster?.list(userId ?? ctx.player.userId) ?? EMPTY_ROSTER);
|
|
124
152
|
}
|
|
153
|
+
const EMPTY_ROSTER = [];
|
|
125
154
|
export function useLeaderboard(stat, options) {
|
|
126
|
-
return useGameStore((ctx) => ctx.game.leaderboard
|
|
155
|
+
return useGameStore((ctx) => ctx.game.leaderboard?.getTop(stat, options) ?? EMPTY_LEADERBOARD);
|
|
127
156
|
}
|
|
157
|
+
const EMPTY_LEADERBOARD = [];
|
|
128
158
|
export function useLocalPlayerDead(healthStatId = "health") {
|
|
129
159
|
return useGameStore((ctx) => {
|
|
130
160
|
const player = localPlayerEntity(ctx);
|
|
@@ -154,28 +184,66 @@ export function useActivePrompt(prompts) {
|
|
|
154
184
|
return resolveActivePrompt({ x: player.position[0], z: player.position[2] }, prompts);
|
|
155
185
|
});
|
|
156
186
|
}
|
|
157
|
-
function
|
|
158
|
-
const
|
|
159
|
-
|
|
187
|
+
export function abilityKitNeedsHeartbeat(kit, resourceAvailable) {
|
|
188
|
+
for (const slot of kit.snapshot(resourceAvailable)) {
|
|
189
|
+
if (slot.cooldownRemainingMs > 0 || slot.justCast || slot.groupRemainingMs > 0)
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
export function eventMeterNeedsHeartbeat(meter, previous) {
|
|
195
|
+
const next = {
|
|
196
|
+
value: meter.value(),
|
|
197
|
+
fraction: meter.fraction(),
|
|
198
|
+
tier: meter.tier(),
|
|
199
|
+
ready: meter.ready(),
|
|
200
|
+
};
|
|
201
|
+
if (previous === null)
|
|
202
|
+
return true;
|
|
203
|
+
return (previous.value !== next.value ||
|
|
204
|
+
previous.fraction !== next.fraction ||
|
|
205
|
+
previous.tier !== next.tier ||
|
|
206
|
+
previous.ready !== next.ready);
|
|
207
|
+
}
|
|
208
|
+
function useEngineHeartbeat(intervalMs, shouldTick) {
|
|
160
209
|
const [, setTick] = useState(0);
|
|
210
|
+
const shouldTickRef = useRef(shouldTick);
|
|
211
|
+
shouldTickRef.current = shouldTick;
|
|
161
212
|
useEffect(() => {
|
|
162
213
|
if (intervalMs <= 0 || typeof window === "undefined")
|
|
163
214
|
return undefined;
|
|
164
|
-
const id = window.setInterval(() =>
|
|
215
|
+
const id = window.setInterval(() => {
|
|
216
|
+
if (shouldTickRef.current())
|
|
217
|
+
setTick((current) => current + 1);
|
|
218
|
+
}, intervalMs);
|
|
165
219
|
return () => window.clearInterval(id);
|
|
166
220
|
}, [intervalMs]);
|
|
167
221
|
}
|
|
168
222
|
export function useAbilitySlots(kit, resourceAvailable, options) {
|
|
169
|
-
useEngineHeartbeat(options?.intervalMs ?? 80);
|
|
223
|
+
useEngineHeartbeat(options?.intervalMs ?? 80, () => abilityKitNeedsHeartbeat(kit, resourceAvailable));
|
|
170
224
|
return kit.snapshot(resourceAvailable);
|
|
171
225
|
}
|
|
172
226
|
export function useAbilitySlot(kit, slotId, resourceAvailable, options) {
|
|
173
|
-
useEngineHeartbeat(options?.intervalMs ?? 80);
|
|
227
|
+
useEngineHeartbeat(options?.intervalMs ?? 80, () => abilityKitNeedsHeartbeat(kit, resourceAvailable));
|
|
174
228
|
return kit.state(slotId, resourceAvailable);
|
|
175
229
|
}
|
|
176
230
|
export function useEventMeter(meter, options) {
|
|
177
|
-
|
|
178
|
-
|
|
231
|
+
const previous = useRef(null);
|
|
232
|
+
useEngineHeartbeat(options?.intervalMs ?? 80, () => {
|
|
233
|
+
const changed = eventMeterNeedsHeartbeat(meter, previous.current);
|
|
234
|
+
if (changed) {
|
|
235
|
+
previous.current = {
|
|
236
|
+
value: meter.value(),
|
|
237
|
+
fraction: meter.fraction(),
|
|
238
|
+
tier: meter.tier(),
|
|
239
|
+
ready: meter.ready(),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return changed;
|
|
243
|
+
});
|
|
244
|
+
const view = { value: meter.value(), fraction: meter.fraction(), tier: meter.tier(), ready: meter.ready() };
|
|
245
|
+
previous.current = view;
|
|
246
|
+
return view;
|
|
179
247
|
}
|
|
180
248
|
export function createHeldKeyTracker(target) {
|
|
181
249
|
const held = new Set();
|