@jgengine/react 0.10.0 → 0.12.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 (72) hide show
  1. package/CHANGELOG.md +39 -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 +294 -0
  6. package/dist/bars.d.ts +103 -0
  7. package/dist/bars.js +180 -0
  8. package/dist/barsPreview.d.ts +4 -0
  9. package/dist/barsPreview.js +47 -0
  10. package/dist/cards.d.ts +59 -0
  11. package/dist/cards.js +116 -0
  12. package/dist/chat.d.ts +3 -1
  13. package/dist/chat.js +2 -2
  14. package/dist/components.d.ts +28 -1
  15. package/dist/components.js +36 -1
  16. package/dist/display.d.ts +1 -0
  17. package/dist/display.js +1 -0
  18. package/dist/engineStore.d.ts +3 -0
  19. package/dist/engineStore.js +3 -0
  20. package/dist/fogOverlay.d.ts +3 -0
  21. package/dist/fogOverlay.js +3 -0
  22. package/dist/gameViewport.js +16 -0
  23. package/dist/hooks.d.ts +40 -0
  24. package/dist/hooks.js +69 -1
  25. package/dist/hud.d.ts +104 -0
  26. package/dist/hud.js +155 -0
  27. package/dist/hudFrame.d.ts +47 -0
  28. package/dist/hudFrame.js +80 -0
  29. package/dist/hudLayout.d.ts +31 -2
  30. package/dist/hudLayout.js +127 -10
  31. package/dist/hudLayoutPersist.d.ts +23 -0
  32. package/dist/hudLayoutPersist.js +19 -0
  33. package/dist/hudTheme.d.ts +76 -0
  34. package/dist/hudTheme.js +156 -0
  35. package/dist/hudThemePreview.d.ts +4 -0
  36. package/dist/hudThemePreview.js +37 -0
  37. package/dist/iconTreatment.d.ts +50 -0
  38. package/dist/iconTreatment.js +84 -0
  39. package/dist/iconsPreview.d.ts +4 -0
  40. package/dist/iconsPreview.js +29 -0
  41. package/dist/index.d.ts +17 -0
  42. package/dist/index.js +17 -0
  43. package/dist/keyHint.d.ts +30 -0
  44. package/dist/keyHint.js +31 -0
  45. package/dist/map.d.ts +141 -11
  46. package/dist/map.js +138 -16
  47. package/dist/save.d.ts +25 -0
  48. package/dist/save.js +37 -0
  49. package/dist/selectSnapshot.d.ts +2 -0
  50. package/dist/selectSnapshot.js +2 -0
  51. package/dist/selectionHud.d.ts +139 -0
  52. package/dist/selectionHud.js +188 -0
  53. package/dist/skillCheckPaint.d.ts +2 -0
  54. package/dist/skillCheckPaint.js +2 -0
  55. package/dist/slots.d.ts +44 -0
  56. package/dist/slots.js +37 -0
  57. package/dist/startScreen.d.ts +62 -0
  58. package/dist/startScreen.js +62 -0
  59. package/dist/store.d.ts +24 -0
  60. package/dist/store.js +9 -0
  61. package/package.json +4 -4
  62. package/skills/jgengine/SKILL.md +54 -0
  63. package/skills/jgengine/api.md +527 -0
  64. package/skills/jgengine/capabilities.md +131 -0
  65. package/skills/jgengine/recipes/minimal-game.md +88 -0
  66. package/skills/jgengine-ui/SKILL.md +41 -0
  67. package/skills/jgengine-ui/api.md +2007 -0
  68. package/skills/jgengine-ui/capabilities.md +211 -0
  69. package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
  70. package/skills/jgengine-ui/reference.md +513 -0
  71. package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
  72. package/llms.txt +0 -1724
@@ -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
@@ -1,4 +1,6 @@
1
+ import { type DependencyList } from "react";
1
2
  import { AxisChannel, type AxisChannelConfig } from "@jgengine/core/input/axisInput";
3
+ import { type MarkerSet } from "@jgengine/core/world/markers";
2
4
  import type { GameContext } from "@jgengine/core/runtime/gameContext";
3
5
  import type { AbilityKit, AbilitySlotSnapshot } from "@jgengine/core/combat/abilityKit";
4
6
  import type { EventMeter } from "@jgengine/core/stats/eventMeter";
@@ -19,6 +21,8 @@ import type { ClockSnapshot, SimClock } from "@jgengine/core/time/simClock";
19
21
  import { type PositionedPrompt } from "@jgengine/core/interaction/proximityPrompt";
20
22
  import { type GamePhase } from "@jgengine/core/game/gamePhase";
21
23
  export declare function useGameStore<T>(selector: (ctx: GameContext) => T, isEqual?: (previous: T, next: T) => boolean): T;
24
+ /** Subscribe to a single keyed slice of the game store, returning `fallback` until the key is set. */
25
+ export declare function useGameStoreValue<T>(key: string, fallback: T): T;
22
26
  export declare function useGame(): {
23
27
  commands: GameContext["game"]["commands"];
24
28
  events: GameEvents;
@@ -36,6 +40,15 @@ export declare function useGamePhase(): {
36
40
  export declare function useOptionalGamePhase(): GamePhase;
37
41
  export declare function useSceneEntities(): readonly SceneEntity[];
38
42
  export declare function useSceneObjects(): readonly SceneObject[];
43
+ /**
44
+ * Membership-only entity id list: the returned array keeps a stable identity across per-frame pose
45
+ * writes and only changes when an entity spawns, despawns, or the store is hydrated (#625). A marker
46
+ * mapped from these ids reads its own live pose imperatively (useFrame), so the actor tree no longer
47
+ * re-reconciles every frame. Prefer this over {@link useSceneEntities} for large scenes.
48
+ */
49
+ export declare function useSceneEntityIds(): readonly string[];
50
+ /** Membership-only object id list — the object counterpart of {@link useSceneEntityIds}; stable across move/rotate/setVisual, changes only on place/remove. */
51
+ export declare function useSceneObjectIds(): readonly string[];
39
52
  export declare function useWorldItems(): readonly WorldItemRecord[];
40
53
  /** Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight. */
41
54
  export declare function useNearestWorldItem(radius: number): WorldItemRecord | null;
@@ -90,19 +103,46 @@ export declare function useGameClock(): ClockSnapshot & {
90
103
  };
91
104
  export declare function useActivePrompt<T extends PositionedPrompt>(prompts?: readonly T[]): T | null;
92
105
  export declare function abilityKitNeedsHeartbeat(kit: AbilityKit, resourceAvailable?: number): boolean;
106
+ /** True when `meter`'s value/fraction/tier/ready diverge from `previous` — the re-render check `useEventMeter` polls on its heartbeat. */
93
107
  export declare function eventMeterNeedsHeartbeat(meter: EventMeter, previous: EventMeterView | null): boolean;
94
108
  export interface AbilitySlotBindingOptions {
95
109
  intervalMs?: number;
96
110
  }
97
111
  export declare function useAbilitySlots(kit: AbilityKit, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot[];
98
112
  export declare function useAbilitySlot(kit: AbilityKit, slotId: string, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot | null;
113
+ /** A rendered snapshot of an {@link EventMeter}: current value, fill fraction, active tier, and ready-to-consume flag. */
99
114
  export interface EventMeterView {
100
115
  value: number;
101
116
  fraction: number;
102
117
  tier: string | null;
103
118
  ready: boolean;
104
119
  }
120
+ /**
121
+ * Bind a `createEventMeter` (`@jgengine/core/stats/eventMeter`) heat/streak gauge to a component — the react-render
122
+ * half of the ult/adrenaline and streak/combo meters (`event-meter` capability) that lets a HUD gauge re-render
123
+ * on tick without the game hand-rolling a `useEffect`/`setInterval` heartbeat around `meter.value()`.
124
+ *
125
+ * @capability event-meter-hud render a core event/heat meter's live value, fraction, tier, and ready state in a HUD gauge
126
+ */
105
127
  export declare function useEventMeter(meter: EventMeter, options?: AbilitySlotBindingOptions): EventMeterView;
128
+ /**
129
+ * A self-ticking {@link MarkerSet} kept in sync with the live scene: on a
130
+ * heartbeat (default 100ms, and once immediately) it clears the set and calls
131
+ * `rebuild(markers, ctx)` to repopulate it from the current scene, so a HUD map
132
+ * never hand-rolls a `setInterval` + `markers.clear()` + rescan. Each heartbeat
133
+ * also re-renders the calling component, so player-centered props derived from
134
+ * the live scene each tick (`center`, `facingYaw`, zone name) stay fresh — this
135
+ * fully subsumes the hand-rolled ticker, not just the marker rebuild. The set is
136
+ * stable across renders and re-seeded whenever `ctx`, `intervalMs`, or the
137
+ * supplied `deps` change. SSR-safe: the heartbeat only arms when `window` is
138
+ * available.
139
+ *
140
+ * @capability live-markers a self-ticking MarkerSet kept in sync from the live scene each HUD tick — pass a rebuild(markers, ctx) that clears+repopulates, no hand-rolled setInterval
141
+ */
142
+ export declare function useLiveMarkers(rebuild: (markers: MarkerSet, ctx: GameContext) => void, options?: {
143
+ intervalMs?: number;
144
+ deps?: DependencyList;
145
+ }): MarkerSet;
106
146
  type HeldKeyEventTarget = Pick<Window, "addEventListener" | "removeEventListener">;
107
147
  export declare function createHeldKeyTracker(target: HeldKeyEventTarget): {
108
148
  isDown: (code: string) => boolean;
package/dist/hooks.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
2
  import { AxisChannel } from "@jgengine/core/input/axisInput";
3
+ import { createMarkerSet } from "@jgengine/core/world/markers";
3
4
  import { browseSessions, } from "@jgengine/core/multiplayer/matchmaking";
4
5
  import { resolveActivePrompt, } from "@jgengine/core/interaction/proximityPrompt";
5
6
  import { gamePhase, setGamePhase } from "@jgengine/core/game/gamePhase";
@@ -16,6 +17,10 @@ export function useGameStore(selector, isEqual = Object.is) {
16
17
  const getSnapshot = useCallback(() => readSelectSnapshot(cacheRef.current, ctx.version(), () => selectorRef.current(ctx), (previous, next) => isEqualRef.current(previous, next)), [ctx]);
17
18
  return useSyncExternalStore(ctx.subscribe, getSnapshot, getSnapshot);
18
19
  }
20
+ /** Subscribe to a single keyed slice of the game store, returning `fallback` until the key is set. */
21
+ export function useGameStoreValue(key, fallback) {
22
+ return useGameStore((ctx) => ctx.game.store.get(key) ?? fallback);
23
+ }
19
24
  export function useGame() {
20
25
  const ctx = useGameContext();
21
26
  return useMemo(() => ({ commands: ctx.game.commands, events: ctx.game.events }), [ctx]);
@@ -43,6 +48,23 @@ export function useSceneEntities() {
43
48
  export function useSceneObjects() {
44
49
  return useGameStore((ctx) => ctx.scene.object.list());
45
50
  }
51
+ /**
52
+ * Membership-only entity id list: the returned array keeps a stable identity across per-frame pose
53
+ * writes and only changes when an entity spawns, despawns, or the store is hydrated (#625). A marker
54
+ * mapped from these ids reads its own live pose imperatively (useFrame), so the actor tree no longer
55
+ * re-reconciles every frame. Prefer this over {@link useSceneEntities} for large scenes.
56
+ */
57
+ export function useSceneEntityIds() {
58
+ const ctx = useGameContext();
59
+ const entity = ctx.scene.entity;
60
+ return useSyncExternalStore(entity.subscribeMembership, entity.ids, entity.ids);
61
+ }
62
+ /** Membership-only object id list — the object counterpart of {@link useSceneEntityIds}; stable across move/rotate/setVisual, changes only on place/remove. */
63
+ export function useSceneObjectIds() {
64
+ const ctx = useGameContext();
65
+ const object = ctx.scene.object;
66
+ return useSyncExternalStore(object.subscribeMembership, object.ids, object.ids);
67
+ }
46
68
  export function useWorldItems() {
47
69
  return useGameStore((ctx) => ctx.scene.worldItem.list());
48
70
  }
@@ -71,8 +93,9 @@ export function useCurrency(currencyId) {
71
93
  export function useFeed({ action, limit }) {
72
94
  return useGameStore((ctx) => ctx.game.feed.recent(action, limit === undefined ? undefined : { limit }));
73
95
  }
96
+ const EMPTY_QUESTS = [];
74
97
  export function useQuestJournal() {
75
- return useGameStore((ctx) => ctx.game.quest.list(ctx.player.userId));
98
+ return useGameStore((ctx) => ctx.game.quest?.list(ctx.player.userId) ?? EMPTY_QUESTS);
76
99
  }
77
100
  const EMPTY_FRIENDS = [];
78
101
  const EMPTY_PARTY = [];
@@ -191,6 +214,7 @@ export function abilityKitNeedsHeartbeat(kit, resourceAvailable) {
191
214
  }
192
215
  return false;
193
216
  }
217
+ /** True when `meter`'s value/fraction/tier/ready diverge from `previous` — the re-render check `useEventMeter` polls on its heartbeat. */
194
218
  export function eventMeterNeedsHeartbeat(meter, previous) {
195
219
  const next = {
196
220
  value: meter.value(),
@@ -227,6 +251,13 @@ export function useAbilitySlot(kit, slotId, resourceAvailable, options) {
227
251
  useEngineHeartbeat(options?.intervalMs ?? 80, () => abilityKitNeedsHeartbeat(kit, resourceAvailable));
228
252
  return kit.state(slotId, resourceAvailable);
229
253
  }
254
+ /**
255
+ * Bind a `createEventMeter` (`@jgengine/core/stats/eventMeter`) heat/streak gauge to a component — the react-render
256
+ * half of the ult/adrenaline and streak/combo meters (`event-meter` capability) that lets a HUD gauge re-render
257
+ * on tick without the game hand-rolling a `useEffect`/`setInterval` heartbeat around `meter.value()`.
258
+ *
259
+ * @capability event-meter-hud render a core event/heat meter's live value, fraction, tier, and ready state in a HUD gauge
260
+ */
230
261
  export function useEventMeter(meter, options) {
231
262
  const previous = useRef(null);
232
263
  useEngineHeartbeat(options?.intervalMs ?? 80, () => {
@@ -245,6 +276,43 @@ export function useEventMeter(meter, options) {
245
276
  previous.current = view;
246
277
  return view;
247
278
  }
279
+ /**
280
+ * A self-ticking {@link MarkerSet} kept in sync with the live scene: on a
281
+ * heartbeat (default 100ms, and once immediately) it clears the set and calls
282
+ * `rebuild(markers, ctx)` to repopulate it from the current scene, so a HUD map
283
+ * never hand-rolls a `setInterval` + `markers.clear()` + rescan. Each heartbeat
284
+ * also re-renders the calling component, so player-centered props derived from
285
+ * the live scene each tick (`center`, `facingYaw`, zone name) stay fresh — this
286
+ * fully subsumes the hand-rolled ticker, not just the marker rebuild. The set is
287
+ * stable across renders and re-seeded whenever `ctx`, `intervalMs`, or the
288
+ * supplied `deps` change. SSR-safe: the heartbeat only arms when `window` is
289
+ * available.
290
+ *
291
+ * @capability live-markers a self-ticking MarkerSet kept in sync from the live scene each HUD tick — pass a rebuild(markers, ctx) that clears+repopulates, no hand-rolled setInterval
292
+ */
293
+ export function useLiveMarkers(rebuild, options) {
294
+ const ctx = useGameContext();
295
+ const markers = useMemo(() => createMarkerSet(() => ctx.time.now()), [ctx]);
296
+ const rebuildRef = useRef(rebuild);
297
+ rebuildRef.current = rebuild;
298
+ const intervalMs = options?.intervalMs ?? 100;
299
+ const deps = options?.deps ?? [];
300
+ const [, forceTick] = useState(0);
301
+ useEffect(() => {
302
+ const run = () => {
303
+ markers.clear();
304
+ rebuildRef.current(markers, ctx);
305
+ forceTick((value) => (value + 1) % 1_000_000);
306
+ };
307
+ run();
308
+ if (intervalMs <= 0 || typeof window === "undefined")
309
+ return undefined;
310
+ const id = window.setInterval(run, intervalMs);
311
+ return () => window.clearInterval(id);
312
+ // deps is spread so callers can re-seed on their own inputs.
313
+ }, [markers, ctx, intervalMs, ...deps]);
314
+ return markers;
315
+ }
248
316
  export function createHeldKeyTracker(target) {
249
317
  const held = new Set();
250
318
  const onKeyDown = (event) => held.add(event.code);
package/dist/hud.d.ts ADDED
@@ -0,0 +1,104 @@
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
+ * @deprecated The `tone`-switched umbrella is being retired (#1033). Use the atomic, purpose-named,
12
+ * token-themed bars from `@jgengine/react/bars` instead — `HealthBar`, `ShieldBar`, `ManaBar`,
13
+ * `StaminaBar`, `ExperienceBar`, `SoulBar`, `BossBar`, `AmmoCounter` — each does one readout and is
14
+ * restyled globally via the shared `--jg-*` tokens (`barTokens`). Kept as a thin shim during migration.
15
+ *
16
+ * @capability hud-stat-bar optional stat-bound bar building block (health, mana, stamina) — prefer game-owned chrome
17
+ */
18
+ export declare function StatBar({ statId, entityId, tone, label, showValue, width, icon, style, className, }: {
19
+ statId?: string;
20
+ entityId?: string;
21
+ tone?: StatTone;
22
+ label?: string;
23
+ showValue?: boolean;
24
+ width?: number;
25
+ icon?: ReactNode;
26
+ style?: CSSProperties;
27
+ className?: string;
28
+ }): import("react").JSX.Element | null;
29
+ /**
30
+ * A numbered hotbar bound to an inventory — painted iconed slots (a `GameIcon` glyph over a
31
+ * school-keyed gradient with a count badge, #1035), an active-slot highlight, and a keycap per slot.
32
+ * Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one. Supply `itemIcon` to
33
+ * map your item ids to your own glyphs — the default resolves a `GameIcon` from the item id.
34
+ *
35
+ * @capability hud-hotbar optional inventory hotbar building block — games own slot chrome
36
+ */
37
+ export declare function Hotbar({ inventoryId, activeSlot, keys, slotSize, itemIcon, style, className, }: {
38
+ inventoryId: string;
39
+ activeSlot?: number;
40
+ keys?: readonly string[];
41
+ slotSize?: number;
42
+ /** Caller-supplied item id → icon registry; return null/undefined to fall back to the default glyph. */
43
+ itemIcon?: (itemId: string) => ReactNode;
44
+ style?: CSSProperties;
45
+ className?: string;
46
+ }): import("react").JSX.Element;
47
+ /**
48
+ * A speedometer for an entity (defaults to the local player) — an SVG arc gauge + a digital readout.
49
+ * `scale` converts world units/second to your display unit (3.6 → km/h, 2.237 → mph); `max` sets the
50
+ * gauge's top of scale.
51
+ */
52
+ export declare function Speedometer({ entityId, scale, unit, max, size, style, className, }: {
53
+ entityId?: string;
54
+ scale?: number;
55
+ unit?: string;
56
+ max?: number;
57
+ size?: number;
58
+ style?: CSSProperties;
59
+ className?: string;
60
+ }): import("react").JSX.Element;
61
+ /**
62
+ * A time-of-day clock reading the sim calendar — `Day N · HH:MM`, 24h or 12h. `controls` adds
63
+ * pause + the game's speed multipliers as clickable pills (the "fast-forward" bar), off by default so
64
+ * a game opts into letting the player scrub time.
65
+ *
66
+ * @capability hud-clock in-game time-of-day readout bound to the sim clock
67
+ */
68
+ export declare function Clock({ format, showDay, controls, style, className, }: {
69
+ format?: "24h" | "12h";
70
+ showDay?: boolean;
71
+ controls?: boolean;
72
+ style?: CSSProperties;
73
+ className?: string;
74
+ }): import("react").JSX.Element;
75
+ /**
76
+ * A wave / round banner — a bold centered pill for "WAVE 3" style callouts, with an optional subtitle
77
+ * (enemies remaining, timer). Pure display: pass the current `wave` and whatever subtitle you track.
78
+ */
79
+ export declare function WaveBanner({ wave, label, subtitle, style, className, }: {
80
+ wave: number | string;
81
+ label?: string;
82
+ subtitle?: ReactNode;
83
+ style?: CSSProperties;
84
+ className?: string;
85
+ }): import("react").JSX.Element;
86
+ /** A currency counter — an icon (emoji/char, default a coin) plus the live amount for `currencyId`. *
87
+ * @capability hud-coins currency counter widget bound to a currency id
88
+ */
89
+ export declare function Coins({ currencyId, icon, style, className, }: {
90
+ currencyId: string;
91
+ icon?: ReactNode;
92
+ style?: CSSProperties;
93
+ className?: string;
94
+ }): import("react").JSX.Element;
95
+ /** A minimal center crosshair reticle — four ticks around a gap. Purely presentational. */
96
+ export declare function Crosshair({ size, gap, thickness, color, style, className, }: {
97
+ size?: number;
98
+ gap?: number;
99
+ thickness?: number;
100
+ color?: string;
101
+ style?: CSSProperties;
102
+ className?: string;
103
+ }): import("react").JSX.Element;
104
+ export {};
package/dist/hud.js ADDED
@@ -0,0 +1,155 @@
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 { iconForItemId } from "./gameIcons.js";
5
+ import { hudFrameStyle } from "./hudFrame.js";
6
+ import { IconTreatment, schoolForItem } from "./iconTreatment.js";
7
+ import { useCurrency, useEntityStat, useGameClock, useGameStore, useInventory, localPlayerEntity } from "./hooks.js";
8
+ /**
9
+ * Optional HUD building blocks bound to live game state — not a finished game face. Every game
10
+ * owns custom presentation (see AGENTS.md); import these only as temporary scaffolding, previews,
11
+ * or under game-owned chrome. Prefer headless hooks + game markup for shipped UI. The engine
12
+ * mounts none of these by default. Styling is self-contained dark-glass for demos; pass
13
+ * `style`/`className` to override. Anything reading the player defaults to the local player entity.
14
+ *
15
+ * @capability hud-components optional health/hotbar/speed/clock/wave/currency building blocks — games own custom chrome
16
+ */
17
+ const PANEL = hudFrameStyle("glass");
18
+ /** Named color ramps for {@link StatBar} tones. `fn` maps fill fraction (0..1) → gradient stops. */
19
+ const TONES = {
20
+ health: (t) => (t > 0.5 ? ["#22c55e", "#4ade80"] : t > 0.25 ? ["#f59e0b", "#fbbf24"] : ["#dc2626", "#f87171"]),
21
+ mana: () => ["#2563eb", "#60a5fa"],
22
+ stamina: () => ["#65a30d", "#a3e635"],
23
+ shield: () => ["#0891b2", "#22d3ee"],
24
+ xp: () => ["#7c3aed", "#a78bfa"],
25
+ neutral: () => ["#475569", "#94a3b8"],
26
+ };
27
+ function useLocalPlayerId(entityId) {
28
+ return useGameStore((ctx) => entityId ?? localPlayerEntity(ctx)?.id ?? null);
29
+ }
30
+ /**
31
+ * A polished stat bar (health/mana/stamina/shield/xp) — a rounded, glassy meter with a tone-colored
32
+ * fill and an optional value readout. Reads `statId` off `entityId` (defaults to the local player).
33
+ * Renders nothing until the stat exists.
34
+ *
35
+ * @deprecated The `tone`-switched umbrella is being retired (#1033). Use the atomic, purpose-named,
36
+ * token-themed bars from `@jgengine/react/bars` instead — `HealthBar`, `ShieldBar`, `ManaBar`,
37
+ * `StaminaBar`, `ExperienceBar`, `SoulBar`, `BossBar`, `AmmoCounter` — each does one readout and is
38
+ * restyled globally via the shared `--jg-*` tokens (`barTokens`). Kept as a thin shim during migration.
39
+ *
40
+ * @capability hud-stat-bar optional stat-bound bar building block (health, mana, stamina) — prefer game-owned chrome
41
+ */
42
+ export function StatBar({ statId = "health", entityId, tone = "health", label, showValue = true, width = 200, icon, style, className, }) {
43
+ const id = useLocalPlayerId(entityId);
44
+ const stat = useEntityStat(id ?? "", statId);
45
+ if (id === null || stat === null)
46
+ return null;
47
+ const range = stat.max - stat.min;
48
+ const fraction = range <= 0 ? 0 : Math.max(0, Math.min(1, (stat.current - stat.min) / range));
49
+ const [from, to] = (TONES[tone] ?? TONES.neutral)(fraction);
50
+ 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" } }) })] }));
51
+ }
52
+ /**
53
+ * A numbered hotbar bound to an inventory — painted iconed slots (a `GameIcon` glyph over a
54
+ * school-keyed gradient with a count badge, #1035), an active-slot highlight, and a keycap per slot.
55
+ * Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one. Supply `itemIcon` to
56
+ * map your item ids to your own glyphs — the default resolves a `GameIcon` from the item id.
57
+ *
58
+ * @capability hud-hotbar optional inventory hotbar building block — games own slot chrome
59
+ */
60
+ export function Hotbar({ inventoryId, activeSlot, keys, slotSize = 46, itemIcon, style, className, }) {
61
+ const slots = useInventory(inventoryId);
62
+ return (_jsx("div", { className: className, style: { display: "flex", gap: 6, ...style }, "data-inventory": inventoryId, children: slots.map((slot, index) => {
63
+ const active = index === activeSlot;
64
+ const keycap = keys?.[index] ?? String(index + 1);
65
+ const custom = slot !== null ? itemIcon?.(slot.itemId) : undefined;
66
+ return (_jsx("div", { "data-slot": index, style: {
67
+ width: slotSize,
68
+ height: slotSize,
69
+ position: "relative",
70
+ }, children: slot !== null ? (custom !== undefined && custom !== null ? (_jsx(IconTreatment, { glyph: custom, size: slotSize, count: slot.count, keycap: keycap, active: active })) : (_jsx(IconTreatment, { ...(iconForItemId(slot.itemId) === null ? {} : { icon: iconForItemId(slot.itemId) }), school: schoolForItem(slot.itemId), size: slotSize, count: slot.count, keycap: keycap, active: active }))) : (_jsx("div", { style: {
71
+ ...PANEL,
72
+ width: slotSize,
73
+ height: slotSize,
74
+ position: "relative",
75
+ borderColor: active ? "rgba(56,189,248,0.9)" : "rgba(255,255,255,0.10)",
76
+ }, children: _jsx("span", { style: { position: "absolute", top: 2, left: 4, fontSize: 9, fontWeight: 700, opacity: 0.55 }, children: keycap }) })) }, index));
77
+ }) }));
78
+ }
79
+ /**
80
+ * A speedometer for an entity (defaults to the local player) — an SVG arc gauge + a digital readout.
81
+ * `scale` converts world units/second to your display unit (3.6 → km/h, 2.237 → mph); `max` sets the
82
+ * gauge's top of scale.
83
+ */
84
+ export function Speedometer({ entityId, scale = 3.6, unit = "km/h", max = 60, size = 96, style, className, }) {
85
+ const speed = useGameStore((ctx) => {
86
+ const entity = entityId === undefined ? localPlayerEntity(ctx) : ctx.scene.entity.get(entityId);
87
+ return entity === null ? 0 : groundSpeed(entity) * scale;
88
+ });
89
+ const fraction = Math.max(0, Math.min(1, speed / max));
90
+ // 240° sweep from -210° to +30°.
91
+ const startDeg = -210;
92
+ const sweep = 240;
93
+ const radius = size / 2 - 8;
94
+ const cx = size / 2;
95
+ const cy = size / 2;
96
+ const arc = (fromFrac, toFrac) => {
97
+ const a0 = ((startDeg + sweep * fromFrac) * Math.PI) / 180;
98
+ const a1 = ((startDeg + sweep * toFrac) * Math.PI) / 180;
99
+ const large = sweep * (toFrac - fromFrac) > 180 ? 1 : 0;
100
+ 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)}`;
101
+ };
102
+ 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 })] }));
103
+ }
104
+ /**
105
+ * A time-of-day clock reading the sim calendar — `Day N · HH:MM`, 24h or 12h. `controls` adds
106
+ * pause + the game's speed multipliers as clickable pills (the "fast-forward" bar), off by default so
107
+ * a game opts into letting the player scrub time.
108
+ *
109
+ * @capability hud-clock in-game time-of-day readout bound to the sim clock
110
+ */
111
+ export function Clock({ format = "24h", showDay = true, controls = false, style, className, }) {
112
+ const clock = useGameClock();
113
+ const { hour, minute, day } = clock.calendar;
114
+ const label = useMemo(() => {
115
+ const mm = minute.toString().padStart(2, "0");
116
+ if (format === "12h") {
117
+ const h12 = hour % 12 === 0 ? 12 : hour % 12;
118
+ return `${h12}:${mm} ${hour < 12 ? "AM" : "PM"}`;
119
+ }
120
+ return `${hour.toString().padStart(2, "0")}:${mm}`;
121
+ }, [hour, minute, format]);
122
+ 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] }));
123
+ }
124
+ function ClockPill({ label, active, onClick }) {
125
+ return (_jsx("button", { type: "button", onClick: onClick, style: {
126
+ pointerEvents: "auto",
127
+ cursor: "pointer",
128
+ fontSize: 10,
129
+ fontWeight: 600,
130
+ padding: "1px 6px",
131
+ borderRadius: 6,
132
+ border: "none",
133
+ color: active ? "#0a0c10" : "#cbd5e1",
134
+ background: active ? "#38bdf8" : "rgba(255,255,255,0.08)",
135
+ }, children: label }));
136
+ }
137
+ /**
138
+ * A wave / round banner — a bold centered pill for "WAVE 3" style callouts, with an optional subtitle
139
+ * (enemies remaining, timer). Pure display: pass the current `wave` and whatever subtitle you track.
140
+ */
141
+ export function WaveBanner({ wave, label = "Wave", subtitle, style, className, }) {
142
+ 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] }));
143
+ }
144
+ /** A currency counter — an icon (emoji/char, default a coin) plus the live amount for `currencyId`. *
145
+ * @capability hud-coins currency counter widget bound to a currency id
146
+ */
147
+ export function Coins({ currencyId, icon = "🪙", style, className, }) {
148
+ const amount = useCurrency(currencyId);
149
+ 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] }));
150
+ }
151
+ /** A minimal center crosshair reticle — four ticks around a gap. Purely presentational. */
152
+ export function Crosshair({ size = 18, gap = 5, thickness = 2, color = "rgba(255,255,255,0.85)", style, className, }) {
153
+ const tick = (extra) => ({ position: "absolute", background: color, borderRadius: thickness, ...extra });
154
+ 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%)" }) })] }) }));
155
+ }
@@ -0,0 +1,47 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ /** Frame skin — `glass` (the shared dark-glass panel), `plate` (a heavier opaque plate), `retro` (a hard black-outlined frame), or `themed` (driven by the `--jg-frame-*` `HudTheme` tokens). */
3
+ export type HudFrameVariation = "glass" | "plate" | "retro" | "themed";
4
+ /** Frame corner shape — `rounded` (the variation's default radius), `circle` (fully round), or `square` (no radius). */
5
+ export type HudFrameShape = "rounded" | "circle" | "square";
6
+ /**
7
+ * Bare style object for a HUD frame's look (no wrapper element) — spread it onto
8
+ * a widget that already owns its element when it just needs the chrome CSS. The
9
+ * `glass` variation is byte-for-byte the shared dark-glass panel used across the
10
+ * built-in HUD widgets; `plate` is a heavier opaque plate; `retro` is a hard
11
+ * black-outlined frame. `shape` overrides the corner radius.
12
+ *
13
+ * @internal helper behind {@link HudFrame} (and the built-in HUD `PANEL`); widgets normally use `HudFrame`.
14
+ */
15
+ export declare function hudFrameStyle(variation?: HudFrameVariation, shape?: HudFrameShape): CSSProperties;
16
+ /** Props for {@link HudFrame}. */
17
+ export interface HudFrameProps {
18
+ /** Frame skin (default `glass`). */
19
+ variation?: HudFrameVariation;
20
+ /** Corner shape (default `rounded`). */
21
+ shape?: HudFrameShape;
22
+ /** Small uppercase header label rendered at the top-left of the frame. */
23
+ title?: ReactNode;
24
+ /** Header content rendered at the top-right, opposite `title` (e.g. a readout or cardinal). */
25
+ aside?: ReactNode;
26
+ /** Inner padding of the frame. */
27
+ padding?: number | string;
28
+ /** Fixed frame width. */
29
+ width?: number | string;
30
+ /** Accept pointer events (default off, so the frame never eats clicks meant for the game). */
31
+ interactive?: boolean;
32
+ /** Extra class on the frame element. */
33
+ className?: string;
34
+ /** Style overrides merged last, so callers can pin exact gradients/colors. */
35
+ style?: CSSProperties;
36
+ /** Frame body. */
37
+ children?: ReactNode;
38
+ }
39
+ /**
40
+ * Optional framed panel primitive — a single `<div data-hud-frame>` with a `glass`,
41
+ * `plate`, `retro`, or `themed` skin, optional `title`/`aside` header, and caller
42
+ * `style` merged last. Shared building block for demos and token-themed widgets;
43
+ * games own product chrome and art direction (see AGENTS.md).
44
+ *
45
+ * @capability hud-frame optional framed panel skins (glass/plate/retro/themed) — games own product chrome
46
+ */
47
+ export declare function HudFrame({ variation, shape, title, aside, padding, width, interactive, className, style, children, }: HudFrameProps): ReactNode;
@@ -0,0 +1,80 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Bare style object for a HUD frame's look (no wrapper element) — spread it onto
4
+ * a widget that already owns its element when it just needs the chrome CSS. The
5
+ * `glass` variation is byte-for-byte the shared dark-glass panel used across the
6
+ * built-in HUD widgets; `plate` is a heavier opaque plate; `retro` is a hard
7
+ * black-outlined frame. `shape` overrides the corner radius.
8
+ *
9
+ * @internal helper behind {@link HudFrame} (and the built-in HUD `PANEL`); widgets normally use `HudFrame`.
10
+ */
11
+ export function hudFrameStyle(variation = "glass", shape = "rounded") {
12
+ const base = variation === "themed"
13
+ ? {
14
+ background: "var(--jg-frame-bg, rgba(10,12,16,0.62))",
15
+ border: "var(--jg-frame-border, 1px solid rgba(255,255,255,0.10))",
16
+ borderRadius: "var(--jg-frame-radius, 10px)",
17
+ boxShadow: "var(--jg-frame-glow, 0 4px 16px rgba(0,0,0,0.35))",
18
+ color: "var(--jg-bar-text, #f4f6fb)",
19
+ fontFamily: "ui-sans-serif, system-ui, -apple-system, sans-serif",
20
+ }
21
+ : variation === "retro"
22
+ ? {
23
+ border: "2px solid #000",
24
+ borderRadius: 4,
25
+ boxShadow: "4px 4px 0 #000",
26
+ color: "#fff",
27
+ }
28
+ : variation === "plate"
29
+ ? {
30
+ background: "rgba(16,18,24,0.92)",
31
+ border: "1px solid rgba(255,255,255,0.10)",
32
+ borderRadius: 10,
33
+ color: "#f4f6fb",
34
+ fontFamily: "ui-sans-serif, system-ui, -apple-system, sans-serif",
35
+ boxShadow: "0 6px 20px rgba(0,0,0,0.45)",
36
+ }
37
+ : {
38
+ background: "rgba(10,12,16,0.62)",
39
+ backdropFilter: "blur(6px)",
40
+ WebkitBackdropFilter: "blur(6px)",
41
+ border: "1px solid rgba(255,255,255,0.10)",
42
+ borderRadius: 10,
43
+ color: "#f4f6fb",
44
+ fontFamily: "ui-sans-serif, system-ui, -apple-system, sans-serif",
45
+ boxShadow: "0 4px 16px rgba(0,0,0,0.35)",
46
+ };
47
+ if (shape === "circle")
48
+ return { ...base, borderRadius: 9999 };
49
+ if (shape === "square")
50
+ return { ...base, borderRadius: 0 };
51
+ return base;
52
+ }
53
+ /**
54
+ * Optional framed panel primitive — a single `<div data-hud-frame>` with a `glass`,
55
+ * `plate`, `retro`, or `themed` skin, optional `title`/`aside` header, and caller
56
+ * `style` merged last. Shared building block for demos and token-themed widgets;
57
+ * games own product chrome and art direction (see AGENTS.md).
58
+ *
59
+ * @capability hud-frame optional framed panel skins (glass/plate/retro/themed) — games own product chrome
60
+ */
61
+ export function HudFrame({ variation = "glass", shape = "rounded", title, aside, padding, width, interactive, className, style, children, }) {
62
+ const hasHeader = title !== undefined || aside !== undefined;
63
+ return (_jsxs("div", { className: className, "data-hud-frame": true, style: {
64
+ ...hudFrameStyle(variation, shape),
65
+ padding,
66
+ width,
67
+ pointerEvents: interactive === true ? "auto" : undefined,
68
+ ...style,
69
+ }, children: [hasHeader ? (_jsxs("div", { "data-hud-frame-header": true, style: {
70
+ display: "flex",
71
+ justifyContent: "space-between",
72
+ alignItems: "center",
73
+ gap: 8,
74
+ marginBottom: 6,
75
+ fontSize: 10,
76
+ letterSpacing: 1.4,
77
+ textTransform: "uppercase",
78
+ opacity: 0.75,
79
+ }, children: [_jsx("span", { children: title }), aside !== undefined ? _jsx("span", { children: aside }) : null] })) : null, children] }));
80
+ }