@jgengine/react 0.11.0 → 0.13.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 +52 -0
  2. package/dist/actionHud.d.ts +5 -5
  3. package/dist/actionHud.js +14 -12
  4. package/dist/bars.d.ts +103 -0
  5. package/dist/bars.js +180 -0
  6. package/dist/barsPreview.d.ts +4 -0
  7. package/dist/barsPreview.js +47 -0
  8. package/dist/characterSheet.d.ts +120 -0
  9. package/dist/characterSheet.js +134 -0
  10. package/dist/components.d.ts +8 -1
  11. package/dist/components.js +8 -1
  12. package/dist/hooks.d.ts +31 -0
  13. package/dist/hooks.js +53 -0
  14. package/dist/hud.d.ts +21 -4
  15. package/dist/hud.js +38 -27
  16. package/dist/hudFrame.d.ts +47 -0
  17. package/dist/hudFrame.js +80 -0
  18. package/dist/hudLayout.d.ts +13 -1
  19. package/dist/hudLayout.js +18 -12
  20. package/dist/hudLayoutPersist.d.ts +23 -0
  21. package/dist/hudLayoutPersist.js +19 -0
  22. package/dist/hudTheme.d.ts +76 -0
  23. package/dist/hudTheme.js +156 -0
  24. package/dist/hudThemePreview.d.ts +4 -0
  25. package/dist/hudThemePreview.js +37 -0
  26. package/dist/iconTreatment.d.ts +50 -0
  27. package/dist/iconTreatment.js +84 -0
  28. package/dist/iconsPreview.d.ts +4 -0
  29. package/dist/iconsPreview.js +29 -0
  30. package/dist/index.d.ts +12 -0
  31. package/dist/index.js +12 -0
  32. package/dist/inventoryGrid.d.ts +20 -0
  33. package/dist/inventoryGrid.js +135 -0
  34. package/dist/map.d.ts +72 -10
  35. package/dist/map.js +84 -15
  36. package/dist/panels.d.ts +107 -0
  37. package/dist/panels.js +215 -0
  38. package/dist/selectionHud.d.ts +11 -5
  39. package/dist/selectionHud.js +11 -5
  40. package/dist/slots.d.ts +47 -0
  41. package/dist/slots.js +37 -0
  42. package/package.json +2 -2
  43. package/skills/jgengine/SKILL.md +8 -4
  44. package/skills/jgengine/api.md +182 -81
  45. package/skills/jgengine/capabilities.md +104 -2
  46. package/skills/jgengine/recipes/minimal-game.md +88 -0
  47. package/skills/jgengine-ui/SKILL.md +17 -10
  48. package/skills/jgengine-ui/api.md +316 -51
  49. package/skills/jgengine-ui/capabilities.md +135 -2
  50. package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
  51. package/skills/jgengine-ui/reference.md +59 -9
  52. package/skills/jgengine-ui/references/visual-scorecard.md +1 -1
  53. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -0,0 +1,134 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import {} from "react";
3
+ import { EquipmentSlots } from "./slots.js";
4
+ import { HudFrame } from "./hudFrame.js";
5
+ const DEFAULT_ARRANGEMENT = [
6
+ { id: "head", label: "Head", position: "left" },
7
+ { id: "chest", label: "Chest", position: "left" },
8
+ { id: "hands", label: "Hands", position: "left" },
9
+ { id: "legs", label: "Legs", position: "left" },
10
+ { id: "feet", label: "Feet", position: "left" },
11
+ { id: "mainHand", label: "Main Hand", position: "right" },
12
+ { id: "offHand", label: "Off Hand", position: "right" },
13
+ { id: "ring1", label: "Ring", position: "right" },
14
+ { id: "ring2", label: "Ring", position: "right" },
15
+ { id: "trinket", label: "Trinket", position: "right" },
16
+ ];
17
+ /**
18
+ * A sensible default paperdoll arrangement — worn armor down the left flank, held gear and jewelry
19
+ * down the right — that the game fills by passing worn items keyed by slot id. Every id is optional;
20
+ * unfilled slots render empty. Games that need a different slot set pass their own {@link EquipSlotLayout}.
21
+ */
22
+ export function defaultEquipLayout(items = {}) {
23
+ return DEFAULT_ARRANGEMENT.map((slot) => ({
24
+ id: slot.id,
25
+ label: slot.label,
26
+ item: items[slot.id] ?? null,
27
+ position: slot.position,
28
+ }));
29
+ }
30
+ function SlotLabel({ children }) {
31
+ return (_jsx("span", { style: {
32
+ fontSize: 9,
33
+ letterSpacing: 0.6,
34
+ fontVariant: "small-caps",
35
+ textTransform: "lowercase",
36
+ color: "var(--jg-bar-text, #f4f6fb)",
37
+ opacity: 0.62,
38
+ whiteSpace: "nowrap",
39
+ }, children: children }));
40
+ }
41
+ function EquipSlotCell({ slot, slotSize, align, onSlotActivate, }) {
42
+ const interactive = onSlotActivate !== undefined;
43
+ const activate = interactive ? () => onSlotActivate(slot.id) : undefined;
44
+ return (_jsxs("div", { "data-equip-slot": slot.id, role: "group", "aria-label": slot.label, tabIndex: interactive ? 0 : undefined, ...(activate === undefined ? {} : { onClick: activate }), ...(activate === undefined
45
+ ? {}
46
+ : {
47
+ onKeyDown: (event) => {
48
+ if (event.key === "Enter" || event.key === " ") {
49
+ event.preventDefault();
50
+ activate();
51
+ }
52
+ },
53
+ }), style: {
54
+ display: "flex",
55
+ flexDirection: "column",
56
+ alignItems: align === "center" ? "center" : align === "right" ? "flex-end" : "flex-start",
57
+ gap: 3,
58
+ cursor: interactive ? "pointer" : undefined,
59
+ }, children: [_jsx(EquipmentSlots, { items: [slot.item], size: slotSize }), _jsx(SlotLabel, { children: slot.label })] }));
60
+ }
61
+ function Silhouette({ size }) {
62
+ return (_jsx("div", { "data-paperdoll-silhouette": "", "aria-hidden": true, style: {
63
+ width: size,
64
+ height: Math.round(size * 2.6),
65
+ borderRadius: "var(--jg-slot-radius, 9px)",
66
+ background: "var(--jg-slot-bg, rgba(12,14,20,0.5))",
67
+ border: "var(--jg-slot-border, 1px solid rgba(255,255,255,0.1))",
68
+ boxShadow: "inset 0 2px 10px rgba(0,0,0,0.5)",
69
+ display: "flex",
70
+ alignItems: "center",
71
+ justifyContent: "center",
72
+ color: "var(--jg-bar-text, #f4f6fb)",
73
+ opacity: 0.28,
74
+ fontSize: Math.round(size * 0.9),
75
+ boxSizing: "border-box",
76
+ }, children: _jsx("span", { "aria-hidden": true, children: "\u265F" }) }));
77
+ }
78
+ /**
79
+ * Arranges caller-supplied equip slots as a paperdoll — worn gear in two flanking columns around a
80
+ * center `portrait` (or a silhouette placeholder), with any `bottom` slots in a row beneath it. Each
81
+ * slot reuses the atomic {@link EquipmentSlots} tile (filled icon or themed empty placeholder), carries
82
+ * a label, and is a focusable `role="group"` with an `aria-label`; `onSlotActivate` fires on
83
+ * click/Enter/Space so games can wire drag or an equip picker. Composition and skin stay the game's.
84
+ *
85
+ * @capability character-paperdoll equip-slot paperdoll — flanking columns around a portrait, activatable slots
86
+ */
87
+ export function Paperdoll({ slots, portrait, slotSize = 44, onSlotActivate, className, style }) {
88
+ const left = slots.filter((s) => (s.position ?? "left") === "left");
89
+ const right = slots.filter((s) => s.position === "right");
90
+ const bottom = slots.filter((s) => s.position === "bottom");
91
+ const column = (cells, align) => (_jsx("div", { style: { display: "flex", flexDirection: "column", gap: 10, alignItems: align === "right" ? "flex-end" : "flex-start" }, children: cells.map((slot) => (_jsx(EquipSlotCell, { slot: slot, slotSize: slotSize, align: align, ...(onSlotActivate === undefined ? {} : { onSlotActivate }) }, slot.id))) }));
92
+ return (_jsxs("div", { "data-paperdoll": "", className: className, style: { display: "flex", flexDirection: "column", alignItems: "center", gap: 12, ...style }, children: [_jsxs("div", { style: { display: "flex", gap: 16, alignItems: "flex-start", justifyContent: "center" }, children: [column(left, "right"), _jsx("div", { "data-paperdoll-portrait": "", style: { display: "flex", alignItems: "center", justifyContent: "center", alignSelf: "stretch" }, children: portrait ?? _jsx(Silhouette, { size: slotSize * 1.4 }) }), column(right, "left")] }), bottom.length > 0 ? (_jsx("div", { style: { display: "flex", gap: 12, justifyContent: "center" }, children: bottom.map((slot) => (_jsx(EquipSlotCell, { slot: slot, slotSize: slotSize, align: "center", ...(onSlotActivate === undefined ? {} : { onSlotActivate }) }, slot.id))) })) : null] }));
93
+ }
94
+ function StatListRow({ row }) {
95
+ return (_jsxs("div", { "data-stat-row": row.id, style: { display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 12, padding: "2px 0" }, children: [_jsx("span", { style: { fontSize: 11, fontVariant: "small-caps", letterSpacing: 0.5, color: "var(--jg-bar-text, #f4f6fb)", opacity: 0.72 }, children: row.label }), _jsxs("span", { style: { fontSize: 12, fontWeight: 700, fontVariantNumeric: "tabular-nums", color: "var(--jg-accent, #38bdf8)" }, children: [row.value, row.hint !== undefined ? (_jsx("span", { style: { marginLeft: 5, fontWeight: 500, color: "var(--jg-bar-text, #f4f6fb)", opacity: 0.5 }, children: row.hint })) : null] })] }));
96
+ }
97
+ /**
98
+ * Renders derived character stats as clean small-caps label→value rows, token-themed. Rows carrying a
99
+ * `group` field are bucketed under a small uppercase header (consecutive same-group rows share one).
100
+ * Pure and prop-driven — the game supplies the rows (from `useEntityStat`/derived values); the engine
101
+ * ships no stat list of its own.
102
+ *
103
+ * @capability character-statlist derived-stat readout — grouped small-caps label→value rows, token-themed
104
+ */
105
+ export function StatList({ stats, className, style }) {
106
+ const groups = [];
107
+ for (const row of stats) {
108
+ const last = groups[groups.length - 1];
109
+ if (last !== undefined && last.name === row.group)
110
+ last.rows.push(row);
111
+ else
112
+ groups.push({ name: row.group, rows: [row] });
113
+ }
114
+ return (_jsx("div", { "data-stat-list": "", className: className, style: { display: "flex", flexDirection: "column", gap: 4, ...style }, children: groups.map((group, index) => (_jsxs("div", { "data-stat-group": group.name ?? "", children: [group.name !== undefined ? (_jsx("div", { style: {
115
+ fontSize: 9,
116
+ letterSpacing: 1.4,
117
+ textTransform: "uppercase",
118
+ opacity: 0.55,
119
+ margin: "6px 0 2px",
120
+ color: "var(--jg-bar-text, #f4f6fb)",
121
+ }, children: group.name })) : null, group.rows.map((row) => (_jsx(StatListRow, { row: row }, row.id)))] }, index))) }));
122
+ }
123
+ /**
124
+ * Drop-in character sheet: a {@link HudFrame} wrapping an optional name/subtitle/portrait header, the
125
+ * equip-slot {@link Paperdoll}, and the derived-stat {@link StatList}. The game supplies slots, stats,
126
+ * header content, and the activation callback, and owns the window that toggles it (this component does
127
+ * not manage its own open/close). Pure composition over existing primitives and HudTheme tokens.
128
+ *
129
+ * @capability character-sheet drop-in paperdoll + derived-stat sheet in a HudFrame — game supplies slots/stats
130
+ */
131
+ export function CharacterSheet({ slots, stats, name, subtitle, portrait, header, title = "Character", variation = "themed", slotSize, onSlotActivate, className, style, }) {
132
+ const hasHeader = header !== undefined || name !== undefined || subtitle !== undefined;
133
+ return (_jsx(HudFrame, { variation: variation, title: title, interactive: true, padding: 14, ...(className === undefined ? {} : { className }), ...(style === undefined ? {} : { style }), children: _jsxs("div", { "data-character-sheet": "", style: { display: "flex", flexDirection: "column", gap: 12, minWidth: 260 }, children: [hasHeader ? (_jsx("div", { "data-character-header": "", style: { display: "flex", flexDirection: "column", gap: 1 }, children: header ?? (_jsxs(_Fragment, { children: [name !== undefined ? _jsx("div", { style: { fontSize: 16, fontWeight: 800, letterSpacing: 0.2 }, children: name }) : null, subtitle !== undefined ? (_jsx("div", { style: { fontSize: 11, opacity: 0.65, fontVariant: "small-caps", letterSpacing: 0.4 }, children: subtitle })) : null] })) })) : null, _jsxs("div", { style: { display: "flex", gap: 18, alignItems: "flex-start", flexWrap: "wrap" }, children: [_jsx(Paperdoll, { slots: slots, ...(portrait === undefined ? {} : { portrait }), ...(slotSize === undefined ? {} : { slotSize }), ...(onSlotActivate === undefined ? {} : { onSlotActivate }) }), _jsx(StatList, { stats: stats, style: { flex: "1 1 140px", minWidth: 140 } })] })] }) }));
134
+ }
@@ -12,7 +12,14 @@ export declare function SlotGrid({ inventoryId, className, renderSlot, }: {
12
12
  className?: string;
13
13
  renderSlot?: (slot: InventorySlot, index: number) => ReactNode;
14
14
  }): import("react").JSX.Element;
15
- export declare function HealthBar({ instanceId, statId, className, fillClassName, }: {
15
+ /**
16
+ * A headless, unstyled stat meter — an accessible `role="progressbar"` you paint entirely through
17
+ * `className`/`fillClassName`. This is the class-hook primitive for teams supplying their own CSS;
18
+ * for a batteries-included themed bar use the atomic, token-styled components in
19
+ * `@jgengine/react/bars` (`HealthBar`, `ShieldBar`, …). Renamed from `HealthBar` in #1033 so the
20
+ * purpose-named atomic bar owns that name and no two components share it.
21
+ */
22
+ export declare function StatMeter({ instanceId, statId, className, fillClassName, }: {
16
23
  instanceId: string;
17
24
  statId: string;
18
25
  className?: string;
@@ -17,7 +17,14 @@ export function SlotGrid({ inventoryId, className, renderSlot, }) {
17
17
  ? `${slot.itemId} x${slot.count}`
18
18
  : null }, index))) }));
19
19
  }
20
- export function HealthBar({ instanceId, statId, className, fillClassName, }) {
20
+ /**
21
+ * A headless, unstyled stat meter — an accessible `role="progressbar"` you paint entirely through
22
+ * `className`/`fillClassName`. This is the class-hook primitive for teams supplying their own CSS;
23
+ * for a batteries-included themed bar use the atomic, token-styled components in
24
+ * `@jgengine/react/bars` (`HealthBar`, `ShieldBar`, …). Renamed from `HealthBar` in #1033 so the
25
+ * purpose-named atomic bar owns that name and no two components share it.
26
+ */
27
+ export function StatMeter({ instanceId, statId, className, fillClassName, }) {
21
28
  const stat = useEntityStat(instanceId, statId);
22
29
  if (stat === null)
23
30
  return null;
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";
@@ -53,6 +55,17 @@ export declare function useNearestWorldItem(radius: number): WorldItemRecord | n
53
55
  export declare function useEntityStat(instanceId: string, statId: string): StatValue | null;
54
56
  export declare function useTarget(fromInstanceId: string): string | null;
55
57
  export declare function useInventory(inventoryId: string): readonly InventorySlot[];
58
+ /** Live slots plus `move`/`split` actions bound to `inventoryId`, routed through the notifying `inventory.move`/`inventory.split` commands so React re-renders. */
59
+ export interface InventoryGridBinding {
60
+ slots: readonly InventorySlot[];
61
+ move(from: number, to: number): void;
62
+ split(slot: number, amount: number, toSlot?: number): void;
63
+ }
64
+ /**
65
+ * Binds a live inventory to a HUD grid: `slots` from {@link useInventory}, and `move`/`split` that run
66
+ * the built-in `inventory.move`/`inventory.split` commands (which notify, so the grid re-renders).
67
+ */
68
+ export declare function useInventoryGrid(inventoryId: string): InventoryGridBinding;
56
69
  export declare function useCurrency(currencyId: string): number;
57
70
  export declare function useFeed({ action, limit }: {
58
71
  action: string;
@@ -123,6 +136,24 @@ export interface EventMeterView {
123
136
  * @capability event-meter-hud render a core event/heat meter's live value, fraction, tier, and ready state in a HUD gauge
124
137
  */
125
138
  export declare function useEventMeter(meter: EventMeter, options?: AbilitySlotBindingOptions): EventMeterView;
139
+ /**
140
+ * A self-ticking {@link MarkerSet} kept in sync with the live scene: on a
141
+ * heartbeat (default 100ms, and once immediately) it clears the set and calls
142
+ * `rebuild(markers, ctx)` to repopulate it from the current scene, so a HUD map
143
+ * never hand-rolls a `setInterval` + `markers.clear()` + rescan. Each heartbeat
144
+ * also re-renders the calling component, so player-centered props derived from
145
+ * the live scene each tick (`center`, `facingYaw`, zone name) stay fresh — this
146
+ * fully subsumes the hand-rolled ticker, not just the marker rebuild. The set is
147
+ * stable across renders and re-seeded whenever `ctx`, `intervalMs`, or the
148
+ * supplied `deps` change. SSR-safe: the heartbeat only arms when `window` is
149
+ * available.
150
+ *
151
+ * @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
152
+ */
153
+ export declare function useLiveMarkers(rebuild: (markers: MarkerSet, ctx: GameContext) => void, options?: {
154
+ intervalMs?: number;
155
+ deps?: DependencyList;
156
+ }): MarkerSet;
126
157
  type HeldKeyEventTarget = Pick<Window, "addEventListener" | "removeEventListener">;
127
158
  export declare function createHeldKeyTracker(target: HeldKeyEventTarget): {
128
159
  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";
@@ -86,6 +87,21 @@ export function useTarget(fromInstanceId) {
86
87
  export function useInventory(inventoryId) {
87
88
  return useGameStore((ctx) => ctx.player.inventory.state(inventoryId).slots);
88
89
  }
90
+ /**
91
+ * Binds a live inventory to a HUD grid: `slots` from {@link useInventory}, and `move`/`split` that run
92
+ * the built-in `inventory.move`/`inventory.split` commands (which notify, so the grid re-renders).
93
+ */
94
+ export function useInventoryGrid(inventoryId) {
95
+ const slots = useInventory(inventoryId);
96
+ const { commands } = useGame();
97
+ const move = useCallback((from, to) => {
98
+ commands.run("inventory.move", { inventoryId, from, to });
99
+ }, [commands, inventoryId]);
100
+ const split = useCallback((slot, amount, toSlot) => {
101
+ commands.run("inventory.split", toSlot === undefined ? { inventoryId, slot, amount } : { inventoryId, slot, amount, toSlot });
102
+ }, [commands, inventoryId]);
103
+ return useMemo(() => ({ slots, move, split }), [slots, move, split]);
104
+ }
89
105
  export function useCurrency(currencyId) {
90
106
  return useGameStore((ctx) => ctx.game.economy.balance(ctx.player.userId, currencyId));
91
107
  }
@@ -275,6 +291,43 @@ export function useEventMeter(meter, options) {
275
291
  previous.current = view;
276
292
  return view;
277
293
  }
294
+ /**
295
+ * A self-ticking {@link MarkerSet} kept in sync with the live scene: on a
296
+ * heartbeat (default 100ms, and once immediately) it clears the set and calls
297
+ * `rebuild(markers, ctx)` to repopulate it from the current scene, so a HUD map
298
+ * never hand-rolls a `setInterval` + `markers.clear()` + rescan. Each heartbeat
299
+ * also re-renders the calling component, so player-centered props derived from
300
+ * the live scene each tick (`center`, `facingYaw`, zone name) stay fresh — this
301
+ * fully subsumes the hand-rolled ticker, not just the marker rebuild. The set is
302
+ * stable across renders and re-seeded whenever `ctx`, `intervalMs`, or the
303
+ * supplied `deps` change. SSR-safe: the heartbeat only arms when `window` is
304
+ * available.
305
+ *
306
+ * @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
307
+ */
308
+ export function useLiveMarkers(rebuild, options) {
309
+ const ctx = useGameContext();
310
+ const markers = useMemo(() => createMarkerSet(() => ctx.time.now()), [ctx]);
311
+ const rebuildRef = useRef(rebuild);
312
+ rebuildRef.current = rebuild;
313
+ const intervalMs = options?.intervalMs ?? 100;
314
+ const deps = options?.deps ?? [];
315
+ const [, forceTick] = useState(0);
316
+ useEffect(() => {
317
+ const run = () => {
318
+ markers.clear();
319
+ rebuildRef.current(markers, ctx);
320
+ forceTick((value) => (value + 1) % 1_000_000);
321
+ };
322
+ run();
323
+ if (intervalMs <= 0 || typeof window === "undefined")
324
+ return undefined;
325
+ const id = window.setInterval(run, intervalMs);
326
+ return () => window.clearInterval(id);
327
+ // deps is spread so callers can re-seed on their own inputs.
328
+ }, [markers, ctx, intervalMs, ...deps]);
329
+ return markers;
330
+ }
278
331
  export function createHeldKeyTracker(target) {
279
332
  const held = new Set();
280
333
  const onKeyDown = (event) => held.add(event.code);
package/dist/hud.d.ts CHANGED
@@ -7,6 +7,13 @@ export type StatTone = keyof typeof TONES;
7
7
  * A polished stat bar (health/mana/stamina/shield/xp) — a rounded, glassy meter with a tone-colored
8
8
  * fill and an optional value readout. Reads `statId` off `entityId` (defaults to the local player).
9
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
10
17
  */
11
18
  export declare function StatBar({ statId, entityId, tone, label, showValue, width, icon, style, className, }: {
12
19
  statId?: string;
@@ -20,14 +27,20 @@ export declare function StatBar({ statId, entityId, tone, label, showValue, widt
20
27
  className?: string;
21
28
  }): import("react").JSX.Element | null;
22
29
  /**
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.
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
25
36
  */
26
- export declare function Hotbar({ inventoryId, activeSlot, keys, slotSize, style, className, }: {
37
+ export declare function Hotbar({ inventoryId, activeSlot, keys, slotSize, itemIcon, style, className, }: {
27
38
  inventoryId: string;
28
39
  activeSlot?: number;
29
40
  keys?: readonly string[];
30
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;
31
44
  style?: CSSProperties;
32
45
  className?: string;
33
46
  }): import("react").JSX.Element;
@@ -49,6 +62,8 @@ export declare function Speedometer({ entityId, scale, unit, max, size, style, c
49
62
  * A time-of-day clock reading the sim calendar — `Day N · HH:MM`, 24h or 12h. `controls` adds
50
63
  * pause + the game's speed multipliers as clickable pills (the "fast-forward" bar), off by default so
51
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
52
67
  */
53
68
  export declare function Clock({ format, showDay, controls, style, className, }: {
54
69
  format?: "24h" | "12h";
@@ -68,7 +83,9 @@ export declare function WaveBanner({ wave, label, subtitle, style, className, }:
68
83
  style?: CSSProperties;
69
84
  className?: string;
70
85
  }): import("react").JSX.Element;
71
- /** A currency counter — an icon (emoji/char, default a coin) plus the live amount for `currencyId`. */
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
+ */
72
89
  export declare function Coins({ currencyId, icon, style, className, }: {
73
90
  currencyId: string;
74
91
  icon?: ReactNode;
package/dist/hud.js CHANGED
@@ -1,26 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo } from "react";
3
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";
4
7
  import { useCurrency, useEntityStat, useGameClock, useGameStore, useInventory, localPlayerEntity } from "./hooks.js";
5
8
  /**
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
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
10
13
  * `style`/`className` to override. Anything reading the player defaults to the local player entity.
11
14
  *
12
- * @capability hud-components opt-in drop-in health/hotbar/speed/clock/wave/currency HUD widgets
15
+ * @capability hud-components optional health/hotbar/speed/clock/wave/currency building blocks — games own custom chrome
13
16
  */
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
- };
17
+ const PANEL = hudFrameStyle("glass");
24
18
  /** Named color ramps for {@link StatBar} tones. `fn` maps fill fraction (0..1) → gradient stops. */
25
19
  const TONES = {
26
20
  health: (t) => (t > 0.5 ? ["#22c55e", "#4ade80"] : t > 0.25 ? ["#f59e0b", "#fbbf24"] : ["#dc2626", "#f87171"]),
@@ -37,6 +31,13 @@ function useLocalPlayerId(entityId) {
37
31
  * A polished stat bar (health/mana/stamina/shield/xp) — a rounded, glassy meter with a tone-colored
38
32
  * fill and an optional value readout. Reads `statId` off `entityId` (defaults to the local player).
39
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
40
41
  */
41
42
  export function StatBar({ statId = "health", entityId, tone = "health", label, showValue = true, width = 200, icon, style, className, }) {
42
43
  const id = useLocalPlayerId(entityId);
@@ -49,24 +50,30 @@ export function StatBar({ statId = "health", entityId, tone = "health", label, s
49
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" } }) })] }));
50
51
  }
51
52
  /**
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.
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
54
59
  */
55
- export function Hotbar({ inventoryId, activeSlot, keys, slotSize = 46, style, className, }) {
60
+ export function Hotbar({ inventoryId, activeSlot, keys, slotSize = 46, itemIcon, style, className, }) {
56
61
  const slots = useInventory(inventoryId);
57
62
  return (_jsx("div", { className: className, style: { display: "flex", gap: 6, ...style }, "data-inventory": inventoryId, children: slots.map((slot, index) => {
58
63
  const active = index === activeSlot;
59
- return (_jsxs("div", { "data-slot": index, style: {
60
- ...PANEL,
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: {
61
67
  width: slotSize,
62
68
  height: slotSize,
63
69
  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
+ }, 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));
70
77
  }) }));
71
78
  }
72
79
  /**
@@ -98,6 +105,8 @@ export function Speedometer({ entityId, scale = 3.6, unit = "km/h", max = 60, si
98
105
  * A time-of-day clock reading the sim calendar — `Day N · HH:MM`, 24h or 12h. `controls` adds
99
106
  * pause + the game's speed multipliers as clickable pills (the "fast-forward" bar), off by default so
100
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
101
110
  */
102
111
  export function Clock({ format = "24h", showDay = true, controls = false, style, className, }) {
103
112
  const clock = useGameClock();
@@ -132,7 +141,9 @@ function ClockPill({ label, active, onClick }) {
132
141
  export function WaveBanner({ wave, label = "Wave", subtitle, style, className, }) {
133
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] }));
134
143
  }
135
- /** A currency counter — an icon (emoji/char, default a coin) plus the live amount for `currencyId`. */
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
+ */
136
147
  export function Coins({ currencyId, icon = "🪙", style, className, }) {
137
148
  const amount = useCurrency(currencyId);
138
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] }));
@@ -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
+ }
@@ -15,6 +15,11 @@ export interface HudEditChord {
15
15
  hold: string;
16
16
  press: string;
17
17
  }
18
+ /**
19
+ * Layout state for `HudCanvas` — panel placements, edit-mode drag/resize, and per-game persistence.
20
+ *
21
+ * @capability hud-layout persistent, live-editable HUD panel layout state for `HudCanvas`
22
+ */
18
23
  export declare function useHudLayout(options?: {
19
24
  storageKey?: string;
20
25
  snap?: number;
@@ -24,7 +29,10 @@ export declare function useHudLayout(options?: {
24
29
  * When provided, hydrates the layout store (and wins over legacy localStorage).
25
30
  */
26
31
  documentUi?: EditorUiDocument;
27
- /** When true (default), canvas moves/resizes write undoable `setUiPanel` patches to a live editor host. */
32
+ /**
33
+ * When true (default), canvas moves/resizes invoke the injected {@link HudLayoutPersist}
34
+ * port (`HudLayoutPersistProvider` / `onPanelCommit`). No-op when no host has provided one.
35
+ */
28
36
  documentPatches?: boolean;
29
37
  }): HudLayoutStore;
30
38
  /**
@@ -33,6 +41,8 @@ export declare function useHudLayout(options?: {
33
41
  * offsets, no manual clearance for sibling panels, the touch-control dock
34
42
  * (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the
35
43
  * whole surface scales down and each panel applies its `compact` behavior.
44
+ *
45
+ * @capability hud-canvas the HUD root surface — design-resolution scaling plus live panel-placement editing (F2+C)
36
46
  */
37
47
  export declare function HudCanvas({ layout, editChord, compactScale, showDuring, className, style, children, }: {
38
48
  layout: HudLayoutStore;
@@ -51,6 +61,8 @@ export declare function HudCanvas({ layout, editChord, compactScale, showDuring,
51
61
  * pointers panels stay draggable through the edit chord; a dragged panel
52
62
  * leaves the flow and keeps its custom placement. On compact displays custom
53
63
  * placements are ignored and the `compact` behavior applies.
64
+ *
65
+ * @capability hud-panel anchored HUD slot — drop any widget in a named, player-movable panel
54
66
  */
55
67
  export declare function HudPanel({ id, anchor, order, compact: compactMode, chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region, width, height, type, className, style, children, }: {
56
68
  id: string;