@jgengine/react 0.10.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 (53) hide show
  1. package/CHANGELOG.md +13 -2
  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 +3 -1
  9. package/dist/chat.js +2 -2
  10. package/dist/components.d.ts +20 -0
  11. package/dist/components.js +28 -0
  12. package/dist/display.d.ts +1 -0
  13. package/dist/display.js +1 -0
  14. package/dist/engineStore.d.ts +3 -0
  15. package/dist/engineStore.js +3 -0
  16. package/dist/fogOverlay.d.ts +3 -0
  17. package/dist/fogOverlay.js +3 -0
  18. package/dist/gameViewport.js +16 -0
  19. package/dist/hooks.d.ts +20 -0
  20. package/dist/hooks.js +31 -1
  21. package/dist/hud.d.ts +87 -0
  22. package/dist/hud.js +144 -0
  23. package/dist/hudLayout.d.ts +19 -2
  24. package/dist/hudLayout.js +121 -10
  25. package/dist/index.d.ts +8 -0
  26. package/dist/index.js +8 -0
  27. package/dist/keyHint.d.ts +30 -0
  28. package/dist/keyHint.js +31 -0
  29. package/dist/map.d.ts +69 -1
  30. package/dist/map.js +54 -1
  31. package/dist/save.d.ts +25 -0
  32. package/dist/save.js +37 -0
  33. package/dist/selectSnapshot.d.ts +2 -0
  34. package/dist/selectSnapshot.js +2 -0
  35. package/dist/selectionHud.d.ts +133 -0
  36. package/dist/selectionHud.js +182 -0
  37. package/dist/skillCheckPaint.d.ts +2 -0
  38. package/dist/skillCheckPaint.js +2 -0
  39. package/dist/startScreen.d.ts +62 -0
  40. package/dist/startScreen.js +62 -0
  41. package/dist/store.d.ts +24 -0
  42. package/dist/store.js +9 -0
  43. package/package.json +4 -4
  44. package/skills/jgengine/SKILL.md +51 -0
  45. package/skills/jgengine/api.md +434 -0
  46. package/skills/jgengine/capabilities.md +29 -0
  47. package/skills/jgengine/reference-cartridge.md +72 -0
  48. package/skills/jgengine-ui/SKILL.md +36 -0
  49. package/skills/jgengine-ui/api.md +1852 -0
  50. package/skills/jgengine-ui/capabilities.md +142 -0
  51. package/skills/jgengine-ui/reference.md +500 -0
  52. package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
  53. package/llms.txt +0 -1724
package/dist/chat.d.ts CHANGED
@@ -33,7 +33,7 @@ export declare function ChannelTabs({ channels, active, onSelect, className, tab
33
33
  activeTabClassName?: string;
34
34
  renderTab?: (channelId: string, isActive: boolean) => ReactNode;
35
35
  }): import("react").JSX.Element | null;
36
- export declare function ChatPanel({ channels, initialChannel, limit, className, tabsClassName, tabClassName, activeTabClassName, logClassName, messageClassName, inputClassName, inputFieldClassName, sendButtonClassName, placeholder, renderMessage, onRejected, }: {
36
+ export declare function ChatPanel({ channels, initialChannel, limit, className, tabsClassName, tabClassName, activeTabClassName, logClassName, messageClassName, inputClassName, inputFieldClassName, sendButtonClassName, placeholder, renderMessage, renderTab, onRejected, }: {
37
37
  channels?: readonly string[];
38
38
  initialChannel?: string;
39
39
  limit?: number;
@@ -48,5 +48,7 @@ export declare function ChatPanel({ channels, initialChannel, limit, className,
48
48
  sendButtonClassName?: string;
49
49
  placeholder?: string;
50
50
  renderMessage?: (message: ChatMessage) => ReactNode;
51
+ /** Overrides a tab's label — e.g. render channel id `"proximity"` as "Nearby". */
52
+ renderTab?: (channelId: string, isActive: boolean) => ReactNode;
51
53
  onRejected?: (reason: string) => void;
52
54
  }): import("react").JSX.Element | null;
package/dist/chat.js CHANGED
@@ -66,12 +66,12 @@ export function ChannelTabs({ channels, active, onSelect, className, tabClassNam
66
66
  return (_jsx("button", { type: "button", role: "tab", "aria-selected": isActive, className: classes.length > 0 ? classes : undefined, "data-channel": channelId, onClick: () => onSelect(channelId), children: renderTab !== undefined ? renderTab(channelId, isActive) : channelId }, channelId));
67
67
  }) }));
68
68
  }
69
- export function ChatPanel({ channels, initialChannel, limit, className, tabsClassName, tabClassName, activeTabClassName, logClassName, messageClassName, inputClassName, inputFieldClassName, sendButtonClassName, placeholder, renderMessage, onRejected, }) {
69
+ export function ChatPanel({ channels, initialChannel, limit, className, tabsClassName, tabClassName, activeTabClassName, logClassName, messageClassName, inputClassName, inputFieldClassName, sendButtonClassName, placeholder, renderMessage, renderTab, onRejected, }) {
70
70
  const ctx = useGameContext();
71
71
  const chat = ctx.game.chat;
72
72
  const ids = channels ?? chat?.channels().map((def) => def.id) ?? [];
73
73
  const [active, setActive] = useState(initialChannel ?? ids[0] ?? "global");
74
74
  if (chat === undefined)
75
75
  return null;
76
- return (_jsxs("section", { className: className, "data-chat-panel": true, children: [_jsx(ChannelTabs, { channels: ids, active: active, onSelect: setActive, className: tabsClassName, tabClassName: tabClassName, activeTabClassName: activeTabClassName }), _jsx(ChatLog, { channelId: active, limit: limit, className: logClassName, messageClassName: messageClassName, renderMessage: renderMessage }), _jsx(ChatInput, { channelId: active, className: inputClassName, inputClassName: inputFieldClassName, buttonClassName: sendButtonClassName, placeholder: placeholder, onRejected: onRejected })] }));
76
+ return (_jsxs("section", { className: className, "data-chat-panel": true, children: [_jsx(ChannelTabs, { channels: ids, active: active, onSelect: setActive, className: tabsClassName, tabClassName: tabClassName, activeTabClassName: activeTabClassName, renderTab: renderTab }), _jsx(ChatLog, { channelId: active, limit: limit, className: logClassName, messageClassName: messageClassName, renderMessage: renderMessage }), _jsx(ChatInput, { channelId: active, className: inputClassName, inputClassName: inputFieldClassName, buttonClassName: sendButtonClassName, placeholder: placeholder, onRejected: onRejected })] }));
77
77
  }
@@ -84,6 +84,20 @@ export declare function DialogueBox({ dialogue, onChoice, rng, className, lineCl
84
84
  choiceClassName?: string;
85
85
  checkClassName?: string;
86
86
  }): import("react").JSX.Element;
87
+ /**
88
+ * The id of the dialogue `ctx.game.dialogue` (or a `talkable(id)` prompt) currently has open, or `null`.
89
+ * The read side of the `features.dialogue` bridge — a panel looks the id up in its own dialogue catalog and
90
+ * renders {@link DialogueBox}, with no per-game open/close store.
91
+ */
92
+ export declare function useOpenDialogueId(): string | null;
93
+ /**
94
+ * Route a {@link DialogueBox} choice through the `features.dialogue` bridge: resolve the choice's invoke
95
+ * (honoring a skill-check `result`), run that command, and otherwise close the dialogue — the write side that
96
+ * replaces a per-game `onChoice` that hand-rolls `resolveDialogueInvoke` + `dialogue.close`.
97
+ */
98
+ export declare function runDialogueChoice(commands: {
99
+ run(name: string, input?: unknown): unknown;
100
+ }, choice: DialogueChoice, result: CheckResult | null): void;
87
101
  export declare function SkillCheckBar({ config, startedAt, className, trackClassName, zoneClassName, markerClassName, renderStatus, }: {
88
102
  config: SkillCheckConfig;
89
103
  startedAt: number;
@@ -106,6 +120,12 @@ export declare function CaptureOdds({ chance, className, fillClassName, }: {
106
120
  className?: string;
107
121
  fillClassName?: string;
108
122
  }): import("react").JSX.Element;
123
+ /**
124
+ * Render `ctx.game.feed`'s entries for `action` as a newest-first toast stack — the feed-backed sibling of
125
+ * `@jgengine/core/game/toasts`' `createToastQueue`. Reach for this when the message source is already an engine
126
+ * event bound onto `ctx.game.feed` (kill feed, quest updates, loot log); reach for a `createToastQueue` when the
127
+ * game raises ad-hoc messages that need their own TTL independent of the feed's ring buffer.
128
+ */
109
129
  export declare function ToastStack({ action, limit, className, renderToast, }: {
110
130
  action: string;
111
131
  limit?: number;
@@ -3,8 +3,10 @@ import { useEffect, useRef, useState } from "react";
3
3
  import { evaluateSkillCheck, } from "@jgengine/core/interaction/skillCheck";
4
4
  import { pendingQteStep } from "@jgengine/core/interaction/qte";
5
5
  import { rollCheck } from "@jgengine/core/stats/rollCheck";
6
+ import { dialogueSlot } from "@jgengine/core/game/dialogue";
6
7
  import { useGameContext } from "./provider.js";
7
8
  import { useCurrency, useEntityStat, useFeed, useInventory, useLocalPlayerDead } from "./hooks.js";
9
+ import { useStore } from "./store.js";
8
10
  import { paintQteStepDom, paintSkillCheckDom } from "./skillCheckPaint.js";
9
11
  export { paintQteStepDom, paintSkillCheckDom } from "./skillCheckPaint.js";
10
12
  export function SlotGrid({ inventoryId, className, renderSlot, }) {
@@ -55,6 +57,26 @@ export function DialogueBox({ dialogue, onChoice, rng, className, lineClassName,
55
57
  ? ` (${choice.check.advantage})`
56
58
  : ""] }))] }, choice.label))) }, index)) : (_jsxs("p", { className: lineClassName, children: [_jsx("span", { className: speakerClassName, "data-speaker": true, children: line.speaker }), _jsx("span", { children: line.text })] }, index))) }));
57
59
  }
60
+ /**
61
+ * The id of the dialogue `ctx.game.dialogue` (or a `talkable(id)` prompt) currently has open, or `null`.
62
+ * The read side of the `features.dialogue` bridge — a panel looks the id up in its own dialogue catalog and
63
+ * renders {@link DialogueBox}, with no per-game open/close store.
64
+ */
65
+ export function useOpenDialogueId() {
66
+ return useStore(dialogueSlot, (value) => value ?? null);
67
+ }
68
+ /**
69
+ * Route a {@link DialogueBox} choice through the `features.dialogue` bridge: resolve the choice's invoke
70
+ * (honoring a skill-check `result`), run that command, and otherwise close the dialogue — the write side that
71
+ * replaces a per-game `onChoice` that hand-rolls `resolveDialogueInvoke` + `dialogue.close`.
72
+ */
73
+ export function runDialogueChoice(commands, choice, result) {
74
+ const invoke = resolveDialogueInvoke(choice, result);
75
+ if (invoke !== null && invoke !== undefined)
76
+ commands.run(invoke.command, invoke.args ?? {});
77
+ else
78
+ commands.run("dialogue.close", {});
79
+ }
58
80
  export function SkillCheckBar({ config, startedAt, className, trackClassName, zoneClassName, markerClassName, renderStatus, }) {
59
81
  const ctx = useGameContext();
60
82
  const rootRef = useRef(null);
@@ -119,6 +141,12 @@ function defaultToast(entry) {
119
141
  }
120
142
  return typeof entry.data === "string" ? entry.data : JSON.stringify(entry.data);
121
143
  }
144
+ /**
145
+ * Render `ctx.game.feed`'s entries for `action` as a newest-first toast stack — the feed-backed sibling of
146
+ * `@jgengine/core/game/toasts`' `createToastQueue`. Reach for this when the message source is already an engine
147
+ * event bound onto `ctx.game.feed` (kill feed, quest updates, loot log); reach for a `createToastQueue` when the
148
+ * game raises ad-hoc messages that need their own TTL independent of the feed's ring buffer.
149
+ */
122
150
  export function ToastStack({ action, limit = 4, className, renderToast, }) {
123
151
  const entries = useFeed({ action, limit });
124
152
  if (entries.length === 0)
package/dist/display.d.ts CHANGED
@@ -11,4 +11,5 @@ export interface DisplayProfile {
11
11
  compact: boolean;
12
12
  portrait: boolean;
13
13
  }
14
+ /** @internal */
14
15
  export declare function useDisplayProfile(): DisplayProfile;
package/dist/display.js CHANGED
@@ -40,6 +40,7 @@ function subscribe(onChange) {
40
40
  list.removeEventListener("change", onChange);
41
41
  };
42
42
  }
43
+ /** @internal */
43
44
  export function useDisplayProfile() {
44
45
  return useSyncExternalStore(subscribe, readProfile, () => SERVER_PROFILE);
45
46
  }
@@ -5,6 +5,9 @@ export interface ReadableEngineStore<TState> {
5
5
  export interface EventfulEngineStore<TEventMap extends object> {
6
6
  on<K extends keyof TEventMap>(eventName: K, listener: (payload: TEventMap[K]) => void): () => void;
7
7
  }
8
+ /** @internal */
8
9
  export declare function useEngineState<TState>(store: ReadableEngineStore<TState>): TState;
10
+ /** @internal */
9
11
  export declare function useEngineStore<TState, TSelected>(store: ReadableEngineStore<TState>, selector: (state: TState) => TSelected, isEqual?: (previous: TSelected, next: TSelected) => boolean): TSelected;
12
+ /** @internal */
10
13
  export declare function useEngineEvent<TEventMap extends object, K extends keyof TEventMap>(store: EventfulEngineStore<TEventMap>, eventName: K, handler: (payload: TEventMap[K]) => void): void;
@@ -1,10 +1,12 @@
1
1
  import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
2
2
  import { createSelectCache, readSelectSnapshot } from "./selectSnapshot.js";
3
+ /** @internal */
3
4
  export function useEngineState(store) {
4
5
  const subscribe = useCallback((onChange) => store.subscribe(() => onChange()), [store]);
5
6
  const getSnapshot = useCallback(() => store.getState(), [store]);
6
7
  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
7
8
  }
9
+ /** @internal */
8
10
  export function useEngineStore(store, selector, isEqual = Object.is) {
9
11
  const selectorRef = useRef(selector);
10
12
  selectorRef.current = selector;
@@ -15,6 +17,7 @@ export function useEngineStore(store, selector, isEqual = Object.is) {
15
17
  const getSnapshot = useCallback(() => readSelectSnapshot(cacheRef.current, store.getState(), (state) => selectorRef.current(state), (previous, next) => isEqualRef.current(previous, next)), [store]);
16
18
  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
17
19
  }
20
+ /** @internal */
18
21
  export function useEngineEvent(store, eventName, handler) {
19
22
  const handlerRef = useRef(handler);
20
23
  handlerRef.current = handler;
@@ -10,11 +10,14 @@ export interface FogPaintSurface {
10
10
  fillRect(x: number, y: number, width: number, height: number): void;
11
11
  clearRect(x: number, y: number, width: number, height: number): void;
12
12
  }
13
+ /** @internal */
13
14
  export declare function forEachUnrevealedFogCell(fog: FogCells, visit: (col: number, row: number) => void): number;
15
+ /** @internal */
14
16
  export declare function paintFogOverlay(surface: FogPaintSurface, canvasSize: {
15
17
  width: number;
16
18
  height: number;
17
19
  }, fog: FogCells, cellRect: (col: number, row: number) => FogCellRect | null, fill?: string): number;
20
+ /** @internal */
18
21
  export declare function createFogDataUrl(fog: FogCells, canvasSize: {
19
22
  width: number;
20
23
  height: number;
@@ -1,3 +1,4 @@
1
+ /** @internal */
1
2
  export function forEachUnrevealedFogCell(fog, visit) {
2
3
  let count = 0;
3
4
  for (let row = 0; row < fog.rows; row += 1) {
@@ -10,6 +11,7 @@ export function forEachUnrevealedFogCell(fog, visit) {
10
11
  }
11
12
  return count;
12
13
  }
14
+ /** @internal */
13
15
  export function paintFogOverlay(surface, canvasSize, fog, cellRect, fill = "rgba(11, 15, 20, 0.82)") {
14
16
  surface.clearRect(0, 0, canvasSize.width, canvasSize.height);
15
17
  surface.fillStyle = fill;
@@ -20,6 +22,7 @@ export function paintFogOverlay(surface, canvasSize, fog, cellRect, fill = "rgba
20
22
  surface.fillRect(rect.x, rect.y, rect.width, rect.height);
21
23
  });
22
24
  }
25
+ /** @internal */
23
26
  export function createFogDataUrl(fog, canvasSize, cellRect, fill = "rgba(11, 15, 20, 0.82)") {
24
27
  if (typeof document === "undefined")
25
28
  return null;
@@ -67,6 +67,19 @@ function createRegionRegistry() {
67
67
  }
68
68
  const RegionRegistryContext = createContext(null);
69
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
+ }
70
83
  const SERVER_METRICS = {
71
84
  layout: { left: 0, top: 0, right: 0, bottom: 0 },
72
85
  visual: { left: 0, top: 0, right: 0, bottom: 0 },
@@ -156,6 +169,9 @@ export function GameViewportProvider({ platforms, className, style, children, })
156
169
  const probeRef = useRef(null);
157
170
  const [safeArea, setSafeArea] = useState({ top: 0, right: 0, bottom: 0, left: 0 });
158
171
  const rootRef = useRef(null);
172
+ useEffect(() => {
173
+ ensureInputHintStylesheet();
174
+ }, []);
159
175
  useEffect(() => {
160
176
  const update = () => {
161
177
  const next = measureSafeArea(probeRef.current);
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;
@@ -36,6 +38,15 @@ export declare function useGamePhase(): {
36
38
  export declare function useOptionalGamePhase(): GamePhase;
37
39
  export declare function useSceneEntities(): readonly SceneEntity[];
38
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[];
39
50
  export declare function useWorldItems(): readonly WorldItemRecord[];
40
51
  /** Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight. */
41
52
  export declare function useNearestWorldItem(radius: number): WorldItemRecord | null;
@@ -90,18 +101,27 @@ export declare function useGameClock(): ClockSnapshot & {
90
101
  };
91
102
  export declare function useActivePrompt<T extends PositionedPrompt>(prompts?: readonly T[]): T | null;
92
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. */
93
105
  export declare function eventMeterNeedsHeartbeat(meter: EventMeter, previous: EventMeterView | null): boolean;
94
106
  export interface AbilitySlotBindingOptions {
95
107
  intervalMs?: number;
96
108
  }
97
109
  export declare function useAbilitySlots(kit: AbilityKit, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot[];
98
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. */
99
112
  export interface EventMeterView {
100
113
  value: number;
101
114
  fraction: number;
102
115
  tier: string | null;
103
116
  ready: boolean;
104
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
+ */
105
125
  export declare function useEventMeter(meter: EventMeter, options?: AbilitySlotBindingOptions): EventMeterView;
106
126
  type HeldKeyEventTarget = Pick<Window, "addEventListener" | "removeEventListener">;
107
127
  export declare function createHeldKeyTracker(target: HeldKeyEventTarget): {
package/dist/hooks.js CHANGED
@@ -16,6 +16,10 @@ export function useGameStore(selector, isEqual = Object.is) {
16
16
  const getSnapshot = useCallback(() => readSelectSnapshot(cacheRef.current, ctx.version(), () => selectorRef.current(ctx), (previous, next) => isEqualRef.current(previous, next)), [ctx]);
17
17
  return useSyncExternalStore(ctx.subscribe, getSnapshot, getSnapshot);
18
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
+ }
19
23
  export function useGame() {
20
24
  const ctx = useGameContext();
21
25
  return useMemo(() => ({ commands: ctx.game.commands, events: ctx.game.events }), [ctx]);
@@ -43,6 +47,23 @@ export function useSceneEntities() {
43
47
  export function useSceneObjects() {
44
48
  return useGameStore((ctx) => ctx.scene.object.list());
45
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
+ }
46
67
  export function useWorldItems() {
47
68
  return useGameStore((ctx) => ctx.scene.worldItem.list());
48
69
  }
@@ -71,8 +92,9 @@ export function useCurrency(currencyId) {
71
92
  export function useFeed({ action, limit }) {
72
93
  return useGameStore((ctx) => ctx.game.feed.recent(action, limit === undefined ? undefined : { limit }));
73
94
  }
95
+ const EMPTY_QUESTS = [];
74
96
  export function useQuestJournal() {
75
- return useGameStore((ctx) => ctx.game.quest.list(ctx.player.userId));
97
+ return useGameStore((ctx) => ctx.game.quest?.list(ctx.player.userId) ?? EMPTY_QUESTS);
76
98
  }
77
99
  const EMPTY_FRIENDS = [];
78
100
  const EMPTY_PARTY = [];
@@ -191,6 +213,7 @@ export function abilityKitNeedsHeartbeat(kit, resourceAvailable) {
191
213
  }
192
214
  return false;
193
215
  }
216
+ /** True when `meter`'s value/fraction/tier/ready diverge from `previous` — the re-render check `useEventMeter` polls on its heartbeat. */
194
217
  export function eventMeterNeedsHeartbeat(meter, previous) {
195
218
  const next = {
196
219
  value: meter.value(),
@@ -227,6 +250,13 @@ export function useAbilitySlot(kit, slotId, resourceAvailable, options) {
227
250
  useEngineHeartbeat(options?.intervalMs ?? 80, () => abilityKitNeedsHeartbeat(kit, resourceAvailable));
228
251
  return kit.state(slotId, resourceAvailable);
229
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
+ */
230
260
  export function useEventMeter(meter, options) {
231
261
  const previous = useRef(null);
232
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 {};
package/dist/hud.js ADDED
@@ -0,0 +1,144 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo } from "react";
3
+ import { groundSpeed } from "@jgengine/core/scene/entityStore";
4
+ import { useCurrency, useEntityStat, useGameClock, useGameStore, useInventory, localPlayerEntity } from "./hooks.js";
5
+ /**
6
+ * Drop-in HUD components — good-looking with zero styling, and fully opt-in: a game imports only the
7
+ * pieces it wants (`StatBar`, `Hotbar`, `Speedometer`, `Clock`, `WaveBanner`, `Coins`, `Crosshair`)
8
+ * and places them itself. The engine imposes none of these; there is no forced HUD. Styling is
9
+ * self-contained inline CSS (no Tailwind `@source` needed) with a shared dark-glass look; pass
10
+ * `style`/`className` to override. Anything reading the player defaults to the local player entity.
11
+ *
12
+ * @capability hud-components opt-in drop-in health/hotbar/speed/clock/wave/currency HUD widgets
13
+ */
14
+ const PANEL = {
15
+ background: "rgba(10,12,16,0.62)",
16
+ backdropFilter: "blur(6px)",
17
+ WebkitBackdropFilter: "blur(6px)",
18
+ border: "1px solid rgba(255,255,255,0.10)",
19
+ borderRadius: 10,
20
+ color: "#f4f6fb",
21
+ fontFamily: "ui-sans-serif, system-ui, -apple-system, sans-serif",
22
+ boxShadow: "0 4px 16px rgba(0,0,0,0.35)",
23
+ };
24
+ /** Named color ramps for {@link StatBar} tones. `fn` maps fill fraction (0..1) → gradient stops. */
25
+ const TONES = {
26
+ health: (t) => (t > 0.5 ? ["#22c55e", "#4ade80"] : t > 0.25 ? ["#f59e0b", "#fbbf24"] : ["#dc2626", "#f87171"]),
27
+ mana: () => ["#2563eb", "#60a5fa"],
28
+ stamina: () => ["#65a30d", "#a3e635"],
29
+ shield: () => ["#0891b2", "#22d3ee"],
30
+ xp: () => ["#7c3aed", "#a78bfa"],
31
+ neutral: () => ["#475569", "#94a3b8"],
32
+ };
33
+ function useLocalPlayerId(entityId) {
34
+ return useGameStore((ctx) => entityId ?? localPlayerEntity(ctx)?.id ?? null);
35
+ }
36
+ /**
37
+ * A polished stat bar (health/mana/stamina/shield/xp) — a rounded, glassy meter with a tone-colored
38
+ * fill and an optional value readout. Reads `statId` off `entityId` (defaults to the local player).
39
+ * Renders nothing until the stat exists.
40
+ */
41
+ export function StatBar({ statId = "health", entityId, tone = "health", label, showValue = true, width = 200, icon, style, className, }) {
42
+ const id = useLocalPlayerId(entityId);
43
+ const stat = useEntityStat(id ?? "", statId);
44
+ if (id === null || stat === null)
45
+ return null;
46
+ const range = stat.max - stat.min;
47
+ const fraction = range <= 0 ? 0 : Math.max(0, Math.min(1, (stat.current - stat.min) / range));
48
+ const [from, to] = (TONES[tone] ?? TONES.neutral)(fraction);
49
+ return (_jsxs("div", { className: className, style: { ...PANEL, padding: "6px 8px", width, ...style }, "data-stat": statId, children: [(label !== undefined || icon !== undefined || showValue) && (_jsxs("div", { style: { display: "flex", alignItems: "center", gap: 6, marginBottom: 4, fontSize: 11, fontWeight: 600, letterSpacing: 0.3 }, children: [icon, _jsx("span", { style: { textTransform: "uppercase", opacity: 0.75 }, children: label ?? statId }), showValue ? (_jsxs("span", { style: { marginLeft: "auto", fontVariantNumeric: "tabular-nums", opacity: 0.9 }, children: [Math.round(stat.current), _jsxs("span", { style: { opacity: 0.5 }, children: [" / ", Math.round(stat.max)] })] })) : null] })), _jsx("div", { style: { height: 8, borderRadius: 999, background: "rgba(255,255,255,0.10)", overflow: "hidden" }, children: _jsx("div", { style: { width: `${fraction * 100}%`, height: "100%", borderRadius: 999, background: `linear-gradient(90deg, ${from}, ${to})`, transition: "width 160ms ease" } }) })] }));
50
+ }
51
+ /**
52
+ * A numbered hotbar bound to an inventory — glassy slots, an active-slot highlight, item id + count,
53
+ * and a keycap per slot. Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one.
54
+ */
55
+ export function Hotbar({ inventoryId, activeSlot, keys, slotSize = 46, style, className, }) {
56
+ const slots = useInventory(inventoryId);
57
+ return (_jsx("div", { className: className, style: { display: "flex", gap: 6, ...style }, "data-inventory": inventoryId, children: slots.map((slot, index) => {
58
+ const active = index === activeSlot;
59
+ return (_jsxs("div", { "data-slot": index, style: {
60
+ ...PANEL,
61
+ width: slotSize,
62
+ height: slotSize,
63
+ position: "relative",
64
+ display: "flex",
65
+ alignItems: "center",
66
+ justifyContent: "center",
67
+ borderColor: active ? "rgba(56,189,248,0.9)" : "rgba(255,255,255,0.10)",
68
+ boxShadow: active ? "0 0 0 1px rgba(56,189,248,0.7), 0 4px 16px rgba(0,0,0,0.35)" : PANEL.boxShadow,
69
+ }, children: [_jsx("span", { style: { position: "absolute", top: 2, left: 4, fontSize: 9, fontWeight: 700, opacity: 0.55 }, children: keys?.[index] ?? index + 1 }), slot !== null ? (_jsxs("span", { style: { fontSize: 10, textAlign: "center", lineHeight: 1.1, padding: "0 2px", wordBreak: "break-word" }, children: [slot.itemId, slot.count > 1 ? _jsxs("span", { style: { display: "block", opacity: 0.6 }, children: ["\u00D7", slot.count] }) : null] })) : null] }, index));
70
+ }) }));
71
+ }
72
+ /**
73
+ * A speedometer for an entity (defaults to the local player) — an SVG arc gauge + a digital readout.
74
+ * `scale` converts world units/second to your display unit (3.6 → km/h, 2.237 → mph); `max` sets the
75
+ * gauge's top of scale.
76
+ */
77
+ export function Speedometer({ entityId, scale = 3.6, unit = "km/h", max = 60, size = 96, style, className, }) {
78
+ const speed = useGameStore((ctx) => {
79
+ const entity = entityId === undefined ? localPlayerEntity(ctx) : ctx.scene.entity.get(entityId);
80
+ return entity === null ? 0 : groundSpeed(entity) * scale;
81
+ });
82
+ const fraction = Math.max(0, Math.min(1, speed / max));
83
+ // 240° sweep from -210° to +30°.
84
+ const startDeg = -210;
85
+ const sweep = 240;
86
+ const radius = size / 2 - 8;
87
+ const cx = size / 2;
88
+ const cy = size / 2;
89
+ const arc = (fromFrac, toFrac) => {
90
+ const a0 = ((startDeg + sweep * fromFrac) * Math.PI) / 180;
91
+ const a1 = ((startDeg + sweep * toFrac) * Math.PI) / 180;
92
+ const large = sweep * (toFrac - fromFrac) > 180 ? 1 : 0;
93
+ return `M ${cx + radius * Math.cos(a0)} ${cy + radius * Math.sin(a0)} A ${radius} ${radius} 0 ${large} 1 ${cx + radius * Math.cos(a1)} ${cy + radius * Math.sin(a1)}`;
94
+ };
95
+ return (_jsxs("div", { className: className, style: { ...PANEL, width: size, padding: 6, textAlign: "center", ...style }, children: [_jsxs("svg", { width: size - 12, height: (size - 12) * 0.72, viewBox: `0 0 ${size} ${size * 0.72}`, style: { display: "block", margin: "0 auto" }, children: [_jsx("path", { d: arc(0, 1), fill: "none", stroke: "rgba(255,255,255,0.12)", strokeWidth: 7, strokeLinecap: "round" }), _jsx("path", { d: arc(0, fraction), fill: "none", stroke: fraction > 0.85 ? "#f87171" : "#38bdf8", strokeWidth: 7, strokeLinecap: "round" })] }), _jsx("div", { style: { marginTop: -8, fontVariantNumeric: "tabular-nums", fontWeight: 700, fontSize: 18, lineHeight: 1 }, children: Math.round(speed) }), _jsx("div", { style: { fontSize: 9, opacity: 0.6, letterSpacing: 0.4, textTransform: "uppercase" }, children: unit })] }));
96
+ }
97
+ /**
98
+ * A time-of-day clock reading the sim calendar — `Day N · HH:MM`, 24h or 12h. `controls` adds
99
+ * pause + the game's speed multipliers as clickable pills (the "fast-forward" bar), off by default so
100
+ * a game opts into letting the player scrub time.
101
+ */
102
+ export function Clock({ format = "24h", showDay = true, controls = false, style, className, }) {
103
+ const clock = useGameClock();
104
+ const { hour, minute, day } = clock.calendar;
105
+ const label = useMemo(() => {
106
+ const mm = minute.toString().padStart(2, "0");
107
+ if (format === "12h") {
108
+ const h12 = hour % 12 === 0 ? 12 : hour % 12;
109
+ return `${h12}:${mm} ${hour < 12 ? "AM" : "PM"}`;
110
+ }
111
+ return `${hour.toString().padStart(2, "0")}:${mm}`;
112
+ }, [hour, minute, format]);
113
+ return (_jsxs("div", { className: className, style: { ...PANEL, padding: "5px 10px", display: "inline-flex", alignItems: "center", gap: 8, fontVariantNumeric: "tabular-nums", ...style }, children: [showDay ? _jsxs("span", { style: { fontSize: 11, opacity: 0.6 }, children: ["Day ", day + 1] }) : null, _jsx("span", { style: { fontWeight: 700, fontSize: 14 }, children: label }), controls ? (_jsxs("span", { style: { display: "inline-flex", gap: 3, marginLeft: 4 }, children: [_jsx(ClockPill, { label: clock.paused ? "▶" : "❚❚", active: false, onClick: () => (clock.paused ? clock.controls.play() : clock.controls.pause()) }), clock.speeds.map((s) => (_jsx(ClockPill, { label: `${s}×`, active: !clock.paused && clock.speed === s, onClick: () => clock.controls.setSpeed(s) }, s)))] })) : null] }));
114
+ }
115
+ function ClockPill({ label, active, onClick }) {
116
+ return (_jsx("button", { type: "button", onClick: onClick, style: {
117
+ pointerEvents: "auto",
118
+ cursor: "pointer",
119
+ fontSize: 10,
120
+ fontWeight: 600,
121
+ padding: "1px 6px",
122
+ borderRadius: 6,
123
+ border: "none",
124
+ color: active ? "#0a0c10" : "#cbd5e1",
125
+ background: active ? "#38bdf8" : "rgba(255,255,255,0.08)",
126
+ }, children: label }));
127
+ }
128
+ /**
129
+ * A wave / round banner — a bold centered pill for "WAVE 3" style callouts, with an optional subtitle
130
+ * (enemies remaining, timer). Pure display: pass the current `wave` and whatever subtitle you track.
131
+ */
132
+ export function WaveBanner({ wave, label = "Wave", subtitle, style, className, }) {
133
+ return (_jsxs("div", { className: className, style: { ...PANEL, padding: "6px 16px", textAlign: "center", display: "inline-block", ...style }, children: [_jsx("div", { style: { fontSize: 10, letterSpacing: 2, textTransform: "uppercase", opacity: 0.65 }, children: label }), _jsx("div", { style: { fontSize: 24, fontWeight: 800, lineHeight: 1.1 }, children: wave }), subtitle !== undefined ? _jsx("div", { style: { fontSize: 11, opacity: 0.7, marginTop: 2 }, children: subtitle }) : null] }));
134
+ }
135
+ /** A currency counter — an icon (emoji/char, default a coin) plus the live amount for `currencyId`. */
136
+ export function Coins({ currencyId, icon = "🪙", style, className, }) {
137
+ const amount = useCurrency(currencyId);
138
+ return (_jsxs("span", { className: className, style: { ...PANEL, padding: "3px 10px", display: "inline-flex", alignItems: "center", gap: 6, fontWeight: 700, fontVariantNumeric: "tabular-nums", ...style }, "data-currency": currencyId, children: [_jsx("span", { "aria-hidden": true, children: icon }), amount] }));
139
+ }
140
+ /** A minimal center crosshair reticle — four ticks around a gap. Purely presentational. */
141
+ export function Crosshair({ size = 18, gap = 5, thickness = 2, color = "rgba(255,255,255,0.85)", style, className, }) {
142
+ const tick = (extra) => ({ position: "absolute", background: color, borderRadius: thickness, ...extra });
143
+ return (_jsx("div", { className: className, style: { position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "none", ...style }, children: _jsxs("div", { style: { position: "relative", width: size, height: size }, children: [_jsx("div", { style: tick({ left: "50%", top: 0, width: thickness, height: size / 2 - gap, transform: "translateX(-50%)" }) }), _jsx("div", { style: tick({ left: "50%", bottom: 0, width: thickness, height: size / 2 - gap, transform: "translateX(-50%)" }) }), _jsx("div", { style: tick({ top: "50%", left: 0, height: thickness, width: size / 2 - gap, transform: "translateY(-50%)" }) }), _jsx("div", { style: tick({ top: "50%", right: 0, height: thickness, width: size / 2 - gap, transform: "translateY(-50%)" }) })] }) }));
144
+ }