@jgengine/react 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +34 -3
  2. package/LICENSE +203 -661
  3. package/README.md +1 -1
  4. package/dist/actionHud.d.ts +174 -0
  5. package/dist/actionHud.js +292 -0
  6. package/dist/cards.d.ts +59 -0
  7. package/dist/cards.js +116 -0
  8. package/dist/chat.d.ts +6 -4
  9. package/dist/chat.js +16 -7
  10. package/dist/chatBubbles.js +2 -1
  11. package/dist/components.d.ts +20 -0
  12. package/dist/components.js +29 -1
  13. package/dist/display.d.ts +1 -0
  14. package/dist/display.js +1 -0
  15. package/dist/engineStore.d.ts +3 -0
  16. package/dist/engineStore.js +3 -0
  17. package/dist/fogOverlay.d.ts +3 -0
  18. package/dist/fogOverlay.js +3 -0
  19. package/dist/gameViewport.d.ts +54 -0
  20. package/dist/gameViewport.js +356 -0
  21. package/dist/hooks.d.ts +22 -0
  22. package/dist/hooks.js +58 -12
  23. package/dist/hud.d.ts +87 -0
  24. package/dist/hud.js +144 -0
  25. package/dist/hudLayout.d.ts +39 -4
  26. package/dist/hudLayout.js +152 -11
  27. package/dist/index.d.ts +10 -0
  28. package/dist/index.js +10 -0
  29. package/dist/keyHint.d.ts +30 -0
  30. package/dist/keyHint.js +31 -0
  31. package/dist/map.d.ts +69 -1
  32. package/dist/map.js +54 -1
  33. package/dist/provider.d.ts +2 -0
  34. package/dist/provider.js +4 -0
  35. package/dist/rotateDevice.d.ts +24 -0
  36. package/dist/rotateDevice.js +83 -0
  37. package/dist/save.d.ts +25 -0
  38. package/dist/save.js +37 -0
  39. package/dist/selectSnapshot.d.ts +2 -0
  40. package/dist/selectSnapshot.js +2 -0
  41. package/dist/selectionHud.d.ts +133 -0
  42. package/dist/selectionHud.js +182 -0
  43. package/dist/settings.d.ts +3 -0
  44. package/dist/skillCheckPaint.d.ts +2 -0
  45. package/dist/skillCheckPaint.js +2 -0
  46. package/dist/social.d.ts +3 -3
  47. package/dist/social.js +31 -10
  48. package/dist/startScreen.d.ts +62 -0
  49. package/dist/startScreen.js +62 -0
  50. package/dist/store.d.ts +24 -0
  51. package/dist/store.js +9 -0
  52. package/package.json +4 -4
  53. package/skills/jgengine/SKILL.md +51 -0
  54. package/skills/jgengine/api.md +434 -0
  55. package/skills/jgengine/capabilities.md +29 -0
  56. package/skills/jgengine/reference-cartridge.md +72 -0
  57. package/skills/jgengine-ui/SKILL.md +36 -0
  58. package/skills/jgengine-ui/api.md +1852 -0
  59. package/skills/jgengine-ui/capabilities.md +142 -0
  60. package/skills/jgengine-ui/reference.md +500 -0
  61. package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
  62. package/llms.txt +0 -1633
@@ -0,0 +1,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.list(ctx.player.userId));
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.friends.list(ctx.player.userId));
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.party.list(ctx.player.userId));
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.presence.get(userId));
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.worldInvites.listFor(ctx.player.userId));
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.history(channelId, { limit, viewerUserId: ctx.player.userId }));
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.friends.requestsFor(ctx.player.userId));
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.party.invitesFor(ctx.player.userId));
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.list(userId ?? ctx.player.userId));
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.getTop(stat, options));
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 {};