@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/chat.d.ts +3 -3
  3. package/dist/chat.js +14 -5
  4. package/dist/chatBubbles.d.ts +15 -0
  5. package/dist/chatBubbles.js +46 -0
  6. package/dist/components.d.ts +1 -0
  7. package/dist/components.js +36 -23
  8. package/dist/display.d.ts +2 -1
  9. package/dist/display.js +3 -2
  10. package/dist/dragLayer.d.ts +6 -1
  11. package/dist/dragLayer.js +64 -31
  12. package/dist/engineStore.d.ts +1 -1
  13. package/dist/engineStore.js +10 -2
  14. package/dist/fogOverlay.d.ts +21 -0
  15. package/dist/fogOverlay.js +34 -0
  16. package/dist/gameViewport.d.ts +54 -0
  17. package/dist/gameViewport.js +340 -0
  18. package/dist/hooks.d.ts +11 -1
  19. package/dist/hooks.js +89 -21
  20. package/dist/hudLayout.d.ts +79 -0
  21. package/dist/hudLayout.js +518 -0
  22. package/dist/hudViewport.d.ts +21 -0
  23. package/dist/hudViewport.js +17 -0
  24. package/dist/index.d.ts +7 -0
  25. package/dist/index.js +7 -0
  26. package/dist/liveBind.d.ts +4 -10
  27. package/dist/liveBind.js +28 -16
  28. package/dist/map.d.ts +27 -7
  29. package/dist/map.js +152 -43
  30. package/dist/preview.d.ts +6 -0
  31. package/dist/preview.js +1 -0
  32. package/dist/provider.d.ts +2 -0
  33. package/dist/provider.js +4 -0
  34. package/dist/rotateDevice.d.ts +24 -0
  35. package/dist/rotateDevice.js +83 -0
  36. package/dist/selectSnapshot.d.ts +7 -0
  37. package/dist/selectSnapshot.js +16 -0
  38. package/dist/settings.d.ts +78 -0
  39. package/dist/settings.js +61 -0
  40. package/dist/skillCheckPaint.d.ts +4 -0
  41. package/dist/skillCheckPaint.js +28 -0
  42. package/dist/social.d.ts +3 -3
  43. package/dist/social.js +31 -10
  44. package/llms.txt +858 -860
  45. 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
- export declare function useGameStore<T>(selector: (ctx: GameContext) => T): T;
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 { useGameContext } from "./provider.js";
6
- export function useGameStore(selector) {
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
- useSyncExternalStore(ctx.subscribe, ctx.version, ctx.version);
9
- return selector(ctx);
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.friends.list(ctx.player.userId));
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.party.list(ctx.player.userId));
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.presence.get(userId));
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.worldInvites.listFor(ctx.player.userId));
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.history(channelId, { limit, viewerUserId: ctx.player.userId }));
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.friends.requestsFor(ctx.player.userId));
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.party.invitesFor(ctx.player.userId));
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.list(userId ?? ctx.player.userId));
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.getTop(stat, options));
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 useEngineHeartbeat(intervalMs) {
158
- const ctx = useGameContext();
159
- useSyncExternalStore(ctx.subscribe, ctx.version, ctx.version);
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(() => setTick((current) => current + 1), intervalMs);
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
- useEngineHeartbeat(options?.intervalMs ?? 80);
178
- return { value: meter.value(), fraction: meter.fraction(), tier: meter.tier(), ready: meter.ready() };
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();