@jgengine/react 0.7.0 → 0.9.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 +63 -0
  2. package/dist/chat.d.ts +52 -0
  3. package/dist/chat.js +68 -0
  4. package/dist/chatBubbles.d.ts +15 -0
  5. package/dist/chatBubbles.js +45 -0
  6. package/dist/components.d.ts +1 -0
  7. package/dist/components.js +35 -22
  8. package/dist/display.d.ts +14 -0
  9. package/dist/display.js +45 -0
  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/gameIcons.d.ts +12 -0
  17. package/dist/gameIcons.js +282 -0
  18. package/dist/hooks.d.ts +59 -2
  19. package/dist/hooks.js +170 -12
  20. package/dist/hudLayout.d.ts +61 -0
  21. package/dist/hudLayout.js +488 -0
  22. package/dist/hudViewport.d.ts +21 -0
  23. package/dist/hudViewport.js +17 -0
  24. package/dist/identity.d.ts +65 -0
  25. package/dist/identity.js +94 -0
  26. package/dist/index.d.ts +10 -0
  27. package/dist/index.js +10 -0
  28. package/dist/liveBind.d.ts +4 -10
  29. package/dist/liveBind.js +28 -16
  30. package/dist/map.d.ts +27 -7
  31. package/dist/map.js +152 -43
  32. package/dist/preview.d.ts +6 -0
  33. package/dist/preview.js +1 -0
  34. package/dist/selectSnapshot.d.ts +7 -0
  35. package/dist/selectSnapshot.js +16 -0
  36. package/dist/settings.d.ts +75 -0
  37. package/dist/settings.js +61 -0
  38. package/dist/skillCheckPaint.d.ts +4 -0
  39. package/dist/skillCheckPaint.js +28 -0
  40. package/dist/social.d.ts +108 -0
  41. package/dist/social.js +119 -0
  42. package/dist/voice.d.ts +58 -0
  43. package/dist/voice.js +130 -0
  44. package/llms.txt +1633 -0
  45. package/package.json +11 -6
package/dist/hooks.js CHANGED
@@ -1,10 +1,19 @@
1
- import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ import { AxisChannel } from "@jgengine/core/input/axisInput";
3
+ import { browseSessions, } from "@jgengine/core/multiplayer/matchmaking";
2
4
  import { resolveActivePrompt, } from "@jgengine/core/interaction/proximityPrompt";
5
+ import { gamePhase, setGamePhase } from "@jgengine/core/game/gamePhase";
3
6
  import { useGameContext } from "./provider.js";
4
- export function useGameStore(selector) {
7
+ import { createSelectCache, readSelectSnapshot } from "./selectSnapshot.js";
8
+ export function useGameStore(selector, isEqual = Object.is) {
5
9
  const ctx = useGameContext();
6
- useSyncExternalStore(ctx.subscribe, ctx.version, ctx.version);
7
- return selector(ctx);
10
+ const selectorRef = useRef(selector);
11
+ selectorRef.current = selector;
12
+ const isEqualRef = useRef(isEqual);
13
+ isEqualRef.current = isEqual;
14
+ const cacheRef = useRef(createSelectCache());
15
+ const getSnapshot = useCallback(() => readSelectSnapshot(cacheRef.current, ctx.version(), () => selectorRef.current(ctx), (previous, next) => isEqualRef.current(previous, next)), [ctx]);
16
+ return useSyncExternalStore(ctx.subscribe, getSnapshot, getSnapshot);
8
17
  }
9
18
  export function useGame() {
10
19
  const ctx = useGameContext();
@@ -14,6 +23,13 @@ export function usePlayer() {
14
23
  const ctx = useGameContext();
15
24
  return useMemo(() => ({ userId: ctx.player.userId, isNew: ctx.player.isNew }), [ctx]);
16
25
  }
26
+ /** Live run phase + a setter that also gates the shell's touch controls. `menu`/`paused`/`ended` hide the touch dock; `playing` shows it. */
27
+ export function useGamePhase() {
28
+ const ctx = useGameContext();
29
+ const phase = useGameStore((c) => gamePhase(c));
30
+ const setPhase = useCallback((next) => setGamePhase(ctx, next), [ctx]);
31
+ return { phase, setPhase };
32
+ }
17
33
  export function useSceneEntities() {
18
34
  return useGameStore((ctx) => ctx.scene.entity.list());
19
35
  }
@@ -60,6 +76,63 @@ export function useParty() {
60
76
  export function usePresence(userId) {
61
77
  return useGameStore((ctx) => ctx.game.social.presence.get(userId));
62
78
  }
79
+ export function useWorldInvites() {
80
+ return useGameStore((ctx) => ctx.game.social.worldInvites.listFor(ctx.player.userId));
81
+ }
82
+ export function useChat(channelId, options) {
83
+ const limit = options?.limit ?? 50;
84
+ return useGameStore((ctx) => ctx.game.chat.history(channelId, { limit, viewerUserId: ctx.player.userId }));
85
+ }
86
+ export function useFriendRequests() {
87
+ return useGameStore((ctx) => ctx.game.social.friends.requestsFor(ctx.player.userId));
88
+ }
89
+ export function usePartyInvites() {
90
+ return useGameStore((ctx) => ctx.game.social.party.invitesFor(ctx.player.userId));
91
+ }
92
+ /**
93
+ * Polls a host-supplied session fetcher (e.g. createWsBackend().browse) and
94
+ * filters through matchmaking's browseSessions. fetchSessions must be
95
+ * identity-stable (wrap in useCallback at the call site) or every render
96
+ * refetches.
97
+ */
98
+ export function useWorldBrowser(options) {
99
+ const { fetchSessions, refreshMs } = options;
100
+ const [raw, setRaw] = useState([]);
101
+ const [loading, setLoading] = useState(true);
102
+ const [error, setError] = useState(null);
103
+ const [refreshTick, setRefreshTick] = useState(0);
104
+ useEffect(() => {
105
+ let cancelled = false;
106
+ setLoading(true);
107
+ fetchSessions()
108
+ .then((listings) => {
109
+ if (cancelled)
110
+ return;
111
+ setRaw(listings);
112
+ setError(null);
113
+ setLoading(false);
114
+ })
115
+ .catch((cause) => {
116
+ if (cancelled)
117
+ return;
118
+ setError(cause instanceof Error ? cause.message : "failed to browse sessions");
119
+ setLoading(false);
120
+ });
121
+ return () => {
122
+ cancelled = true;
123
+ };
124
+ }, [fetchSessions, refreshTick]);
125
+ useEffect(() => {
126
+ if (refreshMs === undefined || refreshMs <= 0)
127
+ return undefined;
128
+ const id = setInterval(() => setRefreshTick((tick) => tick + 1), refreshMs);
129
+ return () => clearInterval(id);
130
+ }, [refreshMs]);
131
+ const filter = options.filter;
132
+ const limit = options.limit;
133
+ const listings = useMemo(() => browseSessions(raw, filter, limit === undefined ? {} : { limit }), [raw, filter, limit]);
134
+ return useMemo(() => ({ listings, loading, error, refresh: () => setRefreshTick((tick) => tick + 1) }), [listings, loading, error]);
135
+ }
63
136
  export function useRoster(userId) {
64
137
  return useGameStore((ctx) => ctx.game.roster.list(userId ?? ctx.player.userId));
65
138
  }
@@ -95,26 +168,111 @@ export function useActivePrompt(prompts) {
95
168
  return resolveActivePrompt({ x: player.position[0], z: player.position[2] }, prompts);
96
169
  });
97
170
  }
98
- function useEngineHeartbeat(intervalMs) {
99
- const ctx = useGameContext();
100
- useSyncExternalStore(ctx.subscribe, ctx.version, ctx.version);
171
+ export function abilityKitNeedsHeartbeat(kit, resourceAvailable) {
172
+ for (const slot of kit.snapshot(resourceAvailable)) {
173
+ if (slot.cooldownRemainingMs > 0 || slot.justCast || slot.groupRemainingMs > 0)
174
+ return true;
175
+ }
176
+ return false;
177
+ }
178
+ export function eventMeterNeedsHeartbeat(meter, previous) {
179
+ const next = {
180
+ value: meter.value(),
181
+ fraction: meter.fraction(),
182
+ tier: meter.tier(),
183
+ ready: meter.ready(),
184
+ };
185
+ if (previous === null)
186
+ return true;
187
+ return (previous.value !== next.value ||
188
+ previous.fraction !== next.fraction ||
189
+ previous.tier !== next.tier ||
190
+ previous.ready !== next.ready);
191
+ }
192
+ function useEngineHeartbeat(intervalMs, shouldTick) {
101
193
  const [, setTick] = useState(0);
194
+ const shouldTickRef = useRef(shouldTick);
195
+ shouldTickRef.current = shouldTick;
102
196
  useEffect(() => {
103
197
  if (intervalMs <= 0 || typeof window === "undefined")
104
198
  return undefined;
105
- const id = window.setInterval(() => setTick((current) => current + 1), intervalMs);
199
+ const id = window.setInterval(() => {
200
+ if (shouldTickRef.current())
201
+ setTick((current) => current + 1);
202
+ }, intervalMs);
106
203
  return () => window.clearInterval(id);
107
204
  }, [intervalMs]);
108
205
  }
109
206
  export function useAbilitySlots(kit, resourceAvailable, options) {
110
- useEngineHeartbeat(options?.intervalMs ?? 80);
207
+ useEngineHeartbeat(options?.intervalMs ?? 80, () => abilityKitNeedsHeartbeat(kit, resourceAvailable));
111
208
  return kit.snapshot(resourceAvailable);
112
209
  }
113
210
  export function useAbilitySlot(kit, slotId, resourceAvailable, options) {
114
- useEngineHeartbeat(options?.intervalMs ?? 80);
211
+ useEngineHeartbeat(options?.intervalMs ?? 80, () => abilityKitNeedsHeartbeat(kit, resourceAvailable));
115
212
  return kit.state(slotId, resourceAvailable);
116
213
  }
117
214
  export function useEventMeter(meter, options) {
118
- useEngineHeartbeat(options?.intervalMs ?? 80);
119
- return { value: meter.value(), fraction: meter.fraction(), tier: meter.tier(), ready: meter.ready() };
215
+ const previous = useRef(null);
216
+ useEngineHeartbeat(options?.intervalMs ?? 80, () => {
217
+ const changed = eventMeterNeedsHeartbeat(meter, previous.current);
218
+ if (changed) {
219
+ previous.current = {
220
+ value: meter.value(),
221
+ fraction: meter.fraction(),
222
+ tier: meter.tier(),
223
+ ready: meter.ready(),
224
+ };
225
+ }
226
+ return changed;
227
+ });
228
+ const view = { value: meter.value(), fraction: meter.fraction(), tier: meter.tier(), ready: meter.ready() };
229
+ previous.current = view;
230
+ return view;
231
+ }
232
+ export function createHeldKeyTracker(target) {
233
+ const held = new Set();
234
+ const onKeyDown = (event) => held.add(event.code);
235
+ const onKeyUp = (event) => held.delete(event.code);
236
+ const onBlur = () => held.clear();
237
+ target.addEventListener("keydown", onKeyDown);
238
+ target.addEventListener("keyup", onKeyUp);
239
+ target.addEventListener("blur", onBlur);
240
+ return {
241
+ isDown: (code) => held.has(code),
242
+ dispose: () => {
243
+ target.removeEventListener("keydown", onKeyDown);
244
+ target.removeEventListener("keyup", onKeyUp);
245
+ target.removeEventListener("blur", onBlur);
246
+ held.clear();
247
+ },
248
+ };
249
+ }
250
+ /**
251
+ * Held-key predicate backed by window keydown/keyup/blur listeners (blur clears held state so a
252
+ * released-off-window key doesn't stick). SSR-safe: listeners attach in an effect, never at module
253
+ * scope. The returned predicate is stable across renders.
254
+ */
255
+ export function useHeldKeys() {
256
+ const trackerRef = useRef(null);
257
+ useEffect(() => {
258
+ if (typeof window === "undefined")
259
+ return undefined;
260
+ const tracker = createHeldKeyTracker(window);
261
+ trackerRef.current = tracker;
262
+ return () => {
263
+ tracker.dispose();
264
+ trackerRef.current = null;
265
+ };
266
+ }, []);
267
+ return useCallback((code) => trackerRef.current?.isDown(code) ?? false, []);
268
+ }
269
+ /**
270
+ * Wires useHeldKeys into a fresh AxisChannel, ready for a per-frame `channel.sample(dt, isDown)`.
271
+ * The channel is recreated when `config` identity changes, so pass a stable config (useMemo/module
272
+ * constant at the call site) unless a rebind is intended.
273
+ */
274
+ export function useAxisChannel(config) {
275
+ const isDown = useHeldKeys();
276
+ const channel = useMemo(() => new AxisChannel(config), [config]);
277
+ return { channel, isDown };
120
278
  }
@@ -0,0 +1,61 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type HudAnchor, type HudLayoutStore } from "@jgengine/core/ui/hudLayout";
3
+ /**
4
+ * How a panel behaves on compact (phone-scale) displays. `keep` stays visible
5
+ * at the global compact scale, `chip` collapses to a small tap-to-expand
6
+ * pill, `hide` unmounts entirely.
7
+ */
8
+ export type HudCompactMode = "keep" | "chip" | "hide";
9
+ export interface HudEditChord {
10
+ hold: string;
11
+ press: string;
12
+ }
13
+ export declare function useHudLayout(options?: {
14
+ storageKey?: string;
15
+ snap?: number;
16
+ locked?: boolean;
17
+ }): HudLayoutStore;
18
+ /**
19
+ * Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine
20
+ * anchor regions and stack automatically with a gap — no per-panel pixel
21
+ * offsets, no manual clearance for sibling panels, the touch-control dock
22
+ * (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the
23
+ * whole surface scales down and each panel applies its `compact` behavior.
24
+ */
25
+ export declare function HudCanvas({ layout, editChord, compactScale, className, style, children, }: {
26
+ layout: HudLayoutStore;
27
+ editChord?: HudEditChord | false;
28
+ /** Zoom applied to the whole HUD on compact displays. Default 0.85. */
29
+ compactScale?: number;
30
+ className?: string;
31
+ style?: CSSProperties;
32
+ children?: ReactNode;
33
+ }): import("react").JSX.Element;
34
+ /**
35
+ * A HUD block that lives in one of the nine anchor regions. Panels sharing a
36
+ * region stack outward from the screen edge in ascending `order`. On fine
37
+ * pointers panels stay draggable through the edit chord; a dragged panel
38
+ * leaves the flow and keeps its custom placement. On compact displays custom
39
+ * placements are ignored and the `compact` behavior applies.
40
+ */
41
+ export declare function HudPanel({ id, anchor, order, compact: compactMode, chip, interactive, inset, locked, className, style, children, }: {
42
+ id: string;
43
+ anchor?: HudAnchor;
44
+ /** Stack position within the region, ascending outward from the screen edge. Default 0. */
45
+ order?: number;
46
+ /** Behavior on compact displays. Default `"keep"`. */
47
+ compact?: HudCompactMode;
48
+ /** Chip label when `compact="chip"`. Defaults to the panel id. */
49
+ chip?: string;
50
+ /** `false` lets pointer events pass through to the game (read-only panels). Default true. */
51
+ interactive?: boolean;
52
+ /** Legacy pixel inset from the anchor; only used as the reset placement for dragged panels. */
53
+ inset?: {
54
+ x: number;
55
+ y: number;
56
+ };
57
+ locked?: boolean;
58
+ className?: string;
59
+ style?: CSSProperties;
60
+ children?: ReactNode;
61
+ }): import("react").JSX.Element | null;