@jgengine/react 0.13.0 → 0.15.0-next.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 (101) hide show
  1. package/CHANGELOG.md +529 -1
  2. package/dist/accessibility.d.ts +39 -0
  3. package/dist/accessibility.js +61 -0
  4. package/dist/achievements.d.ts +50 -0
  5. package/dist/achievements.js +94 -0
  6. package/dist/cameraShake.d.ts +52 -0
  7. package/dist/cameraShake.js +53 -0
  8. package/dist/coachMarks.d.ts +72 -0
  9. package/dist/coachMarks.js +136 -0
  10. package/dist/codex.d.ts +25 -0
  11. package/dist/codex.js +62 -0
  12. package/dist/comboMeter.d.ts +63 -0
  13. package/dist/comboMeter.js +93 -0
  14. package/dist/components.js +4 -3
  15. package/dist/creditsScreen.d.ts +19 -0
  16. package/dist/creditsScreen.js +31 -0
  17. package/dist/cutscene.d.ts +66 -0
  18. package/dist/cutscene.js +132 -0
  19. package/dist/damageDirection.d.ts +51 -0
  20. package/dist/damageDirection.js +103 -0
  21. package/dist/dialogueView.d.ts +69 -0
  22. package/dist/dialogueView.js +111 -0
  23. package/dist/entityFrames.d.ts +114 -0
  24. package/dist/entityFrames.js +55 -0
  25. package/dist/fastTravel.d.ts +36 -0
  26. package/dist/fastTravel.js +103 -0
  27. package/dist/floatingText.d.ts +84 -0
  28. package/dist/floatingText.js +61 -0
  29. package/dist/gameViewport.js +8 -12
  30. package/dist/hooks.d.ts +42 -0
  31. package/dist/hooks.js +85 -0
  32. package/dist/hudLayout.js +138 -9
  33. package/dist/hudTheme.d.ts +104 -7
  34. package/dist/hudTheme.js +300 -2
  35. package/dist/i18n.d.ts +29 -0
  36. package/dist/i18n.js +44 -0
  37. package/dist/index.d.ts +34 -0
  38. package/dist/index.js +34 -0
  39. package/dist/interactionPrompt.d.ts +80 -0
  40. package/dist/interactionPrompt.js +112 -0
  41. package/dist/keybindingMenu.d.ts +59 -0
  42. package/dist/keybindingMenu.js +146 -0
  43. package/dist/killFeed.d.ts +63 -0
  44. package/dist/killFeed.js +88 -0
  45. package/dist/map.d.ts +123 -2
  46. package/dist/map.js +239 -36
  47. package/dist/menuRouter.d.ts +51 -0
  48. package/dist/menuRouter.js +49 -0
  49. package/dist/modals.d.ts +131 -0
  50. package/dist/modals.js +168 -0
  51. package/dist/notifications.d.ts +39 -0
  52. package/dist/notifications.js +111 -0
  53. package/dist/objectiveBanner.d.ts +62 -0
  54. package/dist/objectiveBanner.js +129 -0
  55. package/dist/panels.d.ts +10 -2
  56. package/dist/panels.js +9 -3
  57. package/dist/photoMode.d.ts +21 -0
  58. package/dist/photoMode.js +39 -0
  59. package/dist/previewFixtures.d.ts +30 -0
  60. package/dist/previewFixtures.js +46 -0
  61. package/dist/provider.d.ts +15 -0
  62. package/dist/provider.js +17 -1
  63. package/dist/questTracker.d.ts +24 -0
  64. package/dist/questTracker.js +31 -0
  65. package/dist/quickMenu.d.ts +39 -0
  66. package/dist/quickMenu.js +79 -0
  67. package/dist/radialMenu.d.ts +50 -0
  68. package/dist/radialMenu.js +69 -0
  69. package/dist/saveSlots.d.ts +81 -0
  70. package/dist/saveSlots.js +138 -0
  71. package/dist/scoreboard.d.ts +67 -0
  72. package/dist/scoreboard.js +71 -0
  73. package/dist/settings.d.ts +9 -3
  74. package/dist/settings.js +10 -4
  75. package/dist/shopGrid.d.ts +70 -0
  76. package/dist/shopGrid.js +129 -0
  77. package/dist/statusEffectBar.d.ts +52 -0
  78. package/dist/statusEffectBar.js +57 -0
  79. package/dist/streetGeometryPreview.d.ts +7 -0
  80. package/dist/streetGeometryPreview.js +119 -0
  81. package/dist/talentTree.d.ts +60 -0
  82. package/dist/talentTree.js +217 -0
  83. package/dist/timerReadout.d.ts +90 -0
  84. package/dist/timerReadout.js +111 -0
  85. package/dist/useDebouncedCommit.d.ts +54 -0
  86. package/dist/useDebouncedCommit.js +81 -0
  87. package/dist/waveHud.d.ts +49 -0
  88. package/dist/waveHud.js +53 -0
  89. package/dist/waypointMarkers.d.ts +80 -0
  90. package/dist/waypointMarkers.js +116 -0
  91. package/package.json +3 -3
  92. package/skills/ce-handoff/SKILL.md +149 -0
  93. package/skills/jgengine/SKILL.md +7 -3
  94. package/skills/jgengine/api.md +17 -7
  95. package/skills/jgengine/capabilities.md +12 -0
  96. package/skills/jgengine/recipes/minimal-game.md +7 -4
  97. package/skills/jgengine-ui/SKILL.md +8 -3
  98. package/skills/jgengine-ui/api.md +732 -23
  99. package/skills/jgengine-ui/capabilities.md +371 -2
  100. package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
  101. package/skills/jgengine-ui/reference.md +7 -4
@@ -0,0 +1,30 @@
1
+ import type { GamePreviewComponent } from "./preview.js";
2
+ /**
3
+ * One entry in the engine preview-fixture registry: a deterministic, engine-level
4
+ * preview component keyed by a stable id.
5
+ */
6
+ export interface PreviewFixture {
7
+ /** Stable id used on the `?fixture=` capture route and `bun run shoot --fixture <name>`. */
8
+ readonly name: string;
9
+ /** One-line description of what the deterministic fixture renders. */
10
+ readonly description: string;
11
+ /** The real exported preview component, rendered from static values (screenshots identically). */
12
+ readonly component: GamePreviewComponent;
13
+ }
14
+ /**
15
+ * Registry of deterministic, engine-level preview fixtures — the *real* exported
16
+ * `@jgengine/react` components (`HudThemePreview`, `BarsPreview`, `IconsPreview`, …) that render
17
+ * identically every time from static values, so they can be screenshotted as regression evidence
18
+ * without booting a game.
19
+ *
20
+ * The dev runner's fixtures route (`?fixture=<name>`) and `bun run shoot --fixture <name>` mount
21
+ * these by name; `bun run shoot --list` (or `--fixture` with no name) prints the registered set.
22
+ * Register any additional deterministic preview component here — the route and `shoot` pick it up
23
+ * with no further wiring. Genre-agnostic: there is no hardcoded fixture set beyond what this record
24
+ * declares.
25
+ */
26
+ export declare const PREVIEW_FIXTURES: Record<string, PreviewFixture>;
27
+ /** Sorted list of registered fixture names, for discovery/listing. */
28
+ export declare function previewFixtureNames(): string[];
29
+ /** Resolve a fixture by name, or `undefined` when it is not registered. */
30
+ export declare function resolvePreviewFixture(name: string): PreviewFixture | undefined;
@@ -0,0 +1,46 @@
1
+ import { BarsPreview } from "./barsPreview.js";
2
+ import { HudThemePreview } from "./hudThemePreview.js";
3
+ import { IconsPreview } from "./iconsPreview.js";
4
+ import { StreetGeometryPreview } from "./streetGeometryPreview.js";
5
+ /**
6
+ * Registry of deterministic, engine-level preview fixtures — the *real* exported
7
+ * `@jgengine/react` components (`HudThemePreview`, `BarsPreview`, `IconsPreview`, …) that render
8
+ * identically every time from static values, so they can be screenshotted as regression evidence
9
+ * without booting a game.
10
+ *
11
+ * The dev runner's fixtures route (`?fixture=<name>`) and `bun run shoot --fixture <name>` mount
12
+ * these by name; `bun run shoot --list` (or `--fixture` with no name) prints the registered set.
13
+ * Register any additional deterministic preview component here — the route and `shoot` pick it up
14
+ * with no further wiring. Genre-agnostic: there is no hardcoded fixture set beyond what this record
15
+ * declares.
16
+ */
17
+ export const PREVIEW_FIXTURES = {
18
+ HudThemePreview: {
19
+ name: "HudThemePreview",
20
+ description: "Every HudTheme genre preset × atomic bars + a themed frame + a slot row.",
21
+ component: HudThemePreview,
22
+ },
23
+ BarsPreview: {
24
+ name: "BarsPreview",
25
+ description: "Every atomic vitals bar rendered twice under different BarTokens blocks.",
26
+ component: BarsPreview,
27
+ },
28
+ IconsPreview: {
29
+ name: "IconsPreview",
30
+ description: "IconTreatment school-gradient row plus a treated-icon hotbar with badges.",
31
+ component: IconsPreview,
32
+ },
33
+ StreetGeometryPreview: {
34
+ name: "StreetGeometryPreview",
35
+ description: "Close-up welded road meshes for turns, unequal crossings, and five-way junctions.",
36
+ component: StreetGeometryPreview,
37
+ },
38
+ };
39
+ /** Sorted list of registered fixture names, for discovery/listing. */
40
+ export function previewFixtureNames() {
41
+ return Object.keys(PREVIEW_FIXTURES).sort();
42
+ }
43
+ /** Resolve a fixture by name, or `undefined` when it is not registered. */
44
+ export function resolvePreviewFixture(name) {
45
+ return PREVIEW_FIXTURES[name];
46
+ }
@@ -4,6 +4,21 @@ export declare function GameProvider({ context, children }: {
4
4
  context: GameContext;
5
5
  children?: ReactNode;
6
6
  }): import("react").JSX.Element;
7
+ /**
8
+ * Re-provide a captured {@link GameContext} across a nested React reconciler boundary. The `<Canvas>`
9
+ * of `@react-three/fiber` renders its children in a *separate* React root, so a `GameProvider` in the
10
+ * outer tree does NOT reach inside it — hooks like `useGameContext` / `useEntityRenderCues` throw. The
11
+ * fix the shell already uses internally: capture the context outside the boundary (with
12
+ * {@link useOptionalGameContext}) and wrap the inner tree in `GameContextBridge`. A `null` context
13
+ * renders children unchanged, so the same code works in showcases with no running game. This is the
14
+ * seam behind `EntityPreview` — reach for it directly when building a bespoke nested canvas.
15
+ *
16
+ * @capability game-context-bridge re-provide the GameContext across a nested Canvas/reconciler boundary
17
+ */
18
+ export declare function GameContextBridge({ context, children, }: {
19
+ context: GameContext | null;
20
+ children?: ReactNode;
21
+ }): import("react").JSX.Element;
7
22
  export declare function useGameContext(): GameContext;
8
23
  /** The game context if a `GameProvider` is present, otherwise `null` — for chrome that may render outside a running game (showcases, previews). */
9
24
  export declare function useOptionalGameContext(): GameContext | null;
package/dist/provider.js CHANGED
@@ -1,9 +1,25 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useContext } from "react";
3
3
  const GameReactContext = createContext(null);
4
4
  export function GameProvider({ context, children }) {
5
5
  return _jsx(GameReactContext.Provider, { value: context, children: children });
6
6
  }
7
+ /**
8
+ * Re-provide a captured {@link GameContext} across a nested React reconciler boundary. The `<Canvas>`
9
+ * of `@react-three/fiber` renders its children in a *separate* React root, so a `GameProvider` in the
10
+ * outer tree does NOT reach inside it — hooks like `useGameContext` / `useEntityRenderCues` throw. The
11
+ * fix the shell already uses internally: capture the context outside the boundary (with
12
+ * {@link useOptionalGameContext}) and wrap the inner tree in `GameContextBridge`. A `null` context
13
+ * renders children unchanged, so the same code works in showcases with no running game. This is the
14
+ * seam behind `EntityPreview` — reach for it directly when building a bespoke nested canvas.
15
+ *
16
+ * @capability game-context-bridge re-provide the GameContext across a nested Canvas/reconciler boundary
17
+ */
18
+ export function GameContextBridge({ context, children, }) {
19
+ if (context === null)
20
+ return _jsx(_Fragment, { children: children });
21
+ return _jsx(GameReactContext.Provider, { value: context, children: children });
22
+ }
7
23
  export function useGameContext() {
8
24
  const ctx = useContext(GameReactContext);
9
25
  if (ctx === null)
@@ -0,0 +1,24 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import type { TrackedQuestView } from "@jgengine/core/game/quest";
3
+ /** Props for {@link QuestTracker}. */
4
+ export interface QuestTrackerProps {
5
+ /** Quests to show, e.g. `journal.list(userId).map((q) => describeTrackedQuest(defs[q.questId], q))`. */
6
+ quests: readonly TrackedQuestView[];
7
+ title?: string;
8
+ emptyLabel?: string;
9
+ /** Max objectives shown per quest before a "+N more" line. Default: all. */
10
+ maxObjectives?: number;
11
+ /** Accent color for the completion tick / progress fill. Reads a HudTheme token by default. */
12
+ accent?: string;
13
+ className?: string;
14
+ style?: CSSProperties;
15
+ }
16
+ /**
17
+ * Quest / objective tracker HUD — a compact panel of active quests with a title
18
+ * and labelled, progress-barred objectives (checked + struck when complete).
19
+ * Presentation-only over `describeTrackedQuest(def, instance)` views, so it stays
20
+ * decoupled from the `QuestJournal` runtime.
21
+ *
22
+ * @capability quest-tracker compact quest/objective HUD tracker — titles + labelled objective progress from describeTrackedQuest views
23
+ */
24
+ export declare function QuestTracker({ quests, title, emptyLabel, maxObjectives, accent, className, style, }: QuestTrackerProps): ReactNode;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ function ObjectiveRow({ label, progress, count, complete, accent }) {
3
+ const fraction = count > 0 ? Math.min(1, progress / count) : complete ? 1 : 0;
4
+ return (_jsxs("li", { "data-quest-objective": true, "data-complete": complete, style: { display: "flex", flexDirection: "column", gap: 3, listStyle: "none" }, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 8 }, children: [_jsxs("span", { style: { display: "flex", alignItems: "baseline", gap: 6, opacity: complete ? 0.65 : 1 }, children: [_jsx("span", { "aria-hidden": true, style: { color: complete ? accent : "rgba(148,163,184,0.7)" }, children: complete ? "✓" : "○" }), _jsx("span", { style: { textDecoration: complete ? "line-through" : "none" }, children: label })] }), count > 1 ? (_jsxs("span", { style: { fontVariantNumeric: "tabular-nums", color: "rgba(203,213,225,0.7)", fontSize: "0.9em" }, children: [Math.min(progress, count), "/", count] })) : null] }), count > 1 && !complete ? (_jsx("div", { style: { height: 3, borderRadius: 9999, background: "rgba(148,163,184,0.2)", overflow: "hidden" }, children: _jsx("div", { style: { width: `${Math.round(fraction * 100)}%`, height: "100%", background: accent } }) })) : null] }));
5
+ }
6
+ /**
7
+ * Quest / objective tracker HUD — a compact panel of active quests with a title
8
+ * and labelled, progress-barred objectives (checked + struck when complete).
9
+ * Presentation-only over `describeTrackedQuest(def, instance)` views, so it stays
10
+ * decoupled from the `QuestJournal` runtime.
11
+ *
12
+ * @capability quest-tracker compact quest/objective HUD tracker — titles + labelled objective progress from describeTrackedQuest views
13
+ */
14
+ export function QuestTracker({ quests, title = "Quests", emptyLabel = "No active quests.", maxObjectives, accent = "var(--jg-accent, #facc15)", className, style, }) {
15
+ return (_jsxs("div", { className: className, "data-quest-tracker": true, style: {
16
+ width: 260,
17
+ borderRadius: 12,
18
+ padding: 12,
19
+ background: "linear-gradient(160deg, rgba(20,24,32,0.92), rgba(11,14,19,0.92))",
20
+ border: "1px solid var(--jg-ring, rgba(148,163,184,0.28))",
21
+ color: "#e2e8f0",
22
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
23
+ fontSize: 12,
24
+ ...style,
25
+ }, children: [title !== "" ? (_jsx("div", { style: { fontSize: 10, letterSpacing: 1.4, textTransform: "uppercase", color: "rgba(203,213,225,0.7)", marginBottom: 8 }, children: title })) : null, quests.length === 0 ? (_jsx("p", { style: { margin: 0, color: "rgba(148,163,184,0.8)" }, children: emptyLabel })) : (_jsx("ul", { style: { margin: 0, padding: 0, listStyle: "none", display: "flex", flexDirection: "column", gap: 10 }, children: quests.map((quest) => {
26
+ const shown = maxObjectives === undefined ? quest.objectives : quest.objectives.slice(0, maxObjectives);
27
+ const hidden = quest.objectives.length - shown.length;
28
+ const done = quest.status === "completed";
29
+ return (_jsxs("li", { "data-quest": quest.id, "data-status": quest.status, style: { listStyle: "none" }, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: 6, marginBottom: 4 }, children: [_jsx("span", { style: { fontWeight: 700, color: done ? accent : "#f8fafc" }, children: quest.title }), done ? _jsx("span", { style: { fontSize: 10, color: accent }, children: "DONE" }) : null] }), _jsx("ul", { style: { margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 5 }, children: shown.map((objective) => (_jsx(ObjectiveRow, { label: objective.label, progress: objective.progress, count: objective.count, complete: objective.complete, accent: accent }, objective.id))) }), hidden > 0 ? (_jsxs("div", { style: { marginTop: 4, fontSize: 11, color: "rgba(148,163,184,0.75)" }, children: ["+", hidden, " more"] })) : null] }, quest.id));
30
+ }) }))] }));
31
+ }
@@ -0,0 +1,39 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { RadialArc } from "@jgengine/core/ui/radialMenu";
3
+ import { type RadialMenuOption } from "./radialMenu.js";
4
+ /** An entry on a {@link QuickMenu}: a leaf action, or (with `children`) a submenu. */
5
+ export interface QuickMenuItem extends RadialMenuOption {
6
+ /** Nested items; selecting this entry drills into them instead of firing `onSelect`. */
7
+ children?: readonly QuickMenuItem[];
8
+ /** Optional group heading (list/grid layouts). */
9
+ section?: string;
10
+ }
11
+ /** The forms a {@link QuickMenu} can take. */
12
+ export type QuickMenuLayout = "radial" | "arc" | "list" | "grid";
13
+ /** Props for {@link QuickMenu}. */
14
+ export interface QuickMenuProps {
15
+ items: readonly QuickMenuItem[];
16
+ layout?: QuickMenuLayout;
17
+ onSelect: (id: string) => void;
18
+ onClose?: () => void;
19
+ title?: string;
20
+ /** Grid columns. Default 3. */
21
+ columns?: number;
22
+ /** Arc span for `layout="arc"`. Default a bottom half. */
23
+ arc?: RadialArc;
24
+ /** Diameter for radial/arc forms. Default 300. */
25
+ radialSize?: number;
26
+ accent?: string;
27
+ className?: string;
28
+ style?: CSSProperties;
29
+ }
30
+ /**
31
+ * Versatile quick menu that takes many forms — a `radial` wheel, a partial
32
+ * `arc`, a vertical `list`, or a `grid` — over one item model with icons,
33
+ * labels, hotkeys, count badges, cooldowns, section headers, and nested
34
+ * submenus (drill in / back). Radial and arc forms reuse {@link RadialMenu};
35
+ * list and grid render sectioned rows/tiles. The game supplies content and skin.
36
+ *
37
+ * @capability quick-menu multi-form quick menu (radial / arc / list / grid) over one item model with hotkeys, badges, cooldowns, sections, and nested submenus
38
+ */
39
+ export declare function QuickMenu({ items, layout, onSelect, onClose, title, columns, arc, radialSize, accent, className, style, }: QuickMenuProps): ReactNode;
@@ -0,0 +1,79 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from "react";
3
+ import { RadialMenu } from "./radialMenu.js";
4
+ const PANEL = {
5
+ borderRadius: 14,
6
+ padding: 12,
7
+ background: "linear-gradient(160deg, rgba(20,24,32,0.94), rgba(11,14,19,0.94))",
8
+ border: "1px solid var(--jg-ring, rgba(148,163,184,0.3))",
9
+ color: "#e2e8f0",
10
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
11
+ };
12
+ function groupBySection(items) {
13
+ const groups = [];
14
+ for (const item of items) {
15
+ const last = groups[groups.length - 1];
16
+ if (last !== undefined && last.section === item.section)
17
+ last.items.push(item);
18
+ else
19
+ groups.push({ section: item.section, items: [item] });
20
+ }
21
+ return groups;
22
+ }
23
+ function Badges({ item, accent }) {
24
+ return (_jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 6 }, children: [item.cooldown !== undefined && item.cooldown > 0 ? (_jsxs("span", { style: { fontSize: 10, color: "rgba(148,163,184,0.85)" }, children: [Math.ceil(item.cooldown * 100), "%"] })) : null, item.badge !== undefined ? (_jsx("span", { style: { fontSize: 11, fontWeight: 700, color: accent, fontVariantNumeric: "tabular-nums" }, children: item.badge })) : null, item.hotkey !== undefined ? (_jsx("kbd", { style: { fontSize: 10, padding: "1px 5px", borderRadius: 4, border: "1px solid rgba(148,163,184,0.4)", color: "rgba(226,232,240,0.85)" }, children: item.hotkey })) : null, item.children !== undefined ? _jsx("span", { style: { color: "rgba(226,232,240,0.7)" }, children: "\u25B8" }) : null] }));
25
+ }
26
+ /**
27
+ * Versatile quick menu that takes many forms — a `radial` wheel, a partial
28
+ * `arc`, a vertical `list`, or a `grid` — over one item model with icons,
29
+ * labels, hotkeys, count badges, cooldowns, section headers, and nested
30
+ * submenus (drill in / back). Radial and arc forms reuse {@link RadialMenu};
31
+ * list and grid render sectioned rows/tiles. The game supplies content and skin.
32
+ *
33
+ * @capability quick-menu multi-form quick menu (radial / arc / list / grid) over one item model with hotkeys, badges, cooldowns, sections, and nested submenus
34
+ */
35
+ export function QuickMenu({ items, layout = "list", onSelect, onClose, title, columns = 3, arc = { startAngle: Math.PI / 2, sweep: Math.PI }, radialSize = 300, accent = "var(--jg-accent, #38bdf8)", className, style, }) {
36
+ const [path, setPath] = useState([]);
37
+ const current = useMemo(() => {
38
+ let level = items;
39
+ for (const id of path) {
40
+ const next = level.find((item) => item.id === id)?.children;
41
+ if (next === undefined)
42
+ return level;
43
+ level = next;
44
+ }
45
+ return level;
46
+ }, [items, path]);
47
+ const activate = (item) => {
48
+ if (item.disabled === true)
49
+ return;
50
+ if (item.children !== undefined && item.children.length > 0)
51
+ setPath((p) => [...p, item.id]);
52
+ else
53
+ onSelect(item.id);
54
+ };
55
+ const back = () => setPath((p) => p.slice(0, -1));
56
+ const header = title !== undefined || path.length > 0 ? (_jsxs("div", { "data-quick-header": true, style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }, children: [path.length > 0 ? (_jsx("button", { type: "button", "data-quick-back": true, onClick: back, style: { border: "1px solid rgba(148,163,184,0.4)", borderRadius: 6, background: "transparent", color: "#e2e8f0", fontSize: 11, padding: "2px 8px", cursor: "pointer" }, children: "\u2039 Back" })) : null, title !== undefined ? (_jsx("span", { style: { fontSize: 11, letterSpacing: 1.2, textTransform: "uppercase", color: "rgba(203,213,225,0.75)" }, children: title })) : null] })) : null;
57
+ if (layout === "radial" || layout === "arc") {
58
+ const options = current.map((item) => ({ ...item, hasSubmenu: item.children !== undefined && item.children.length > 0 }));
59
+ return (_jsxs("div", { className: className, "data-quick-menu": true, "data-layout": layout, style: { position: "relative", ...style }, children: [header, _jsx(RadialMenu, { options: options, size: radialSize, accent: accent, ...(layout === "arc" ? { arc } : {}), onSelect: (id) => {
60
+ const item = current.find((entry) => entry.id === id);
61
+ if (item !== undefined)
62
+ activate(item);
63
+ }, onClose: onClose })] }));
64
+ }
65
+ const groups = groupBySection(current);
66
+ if (layout === "grid") {
67
+ return (_jsxs("div", { className: className, "data-quick-menu": true, "data-layout": "grid", style: { ...PANEL, width: columns * 92 + 24, ...style }, children: [header, groups.map((group, gi) => (_jsxs("div", { "data-section": group.section, children: [group.section !== undefined ? (_jsx("div", { style: { fontSize: 10, textTransform: "uppercase", letterSpacing: 1.2, color: "rgba(148,163,184,0.75)", margin: "6px 0 4px" }, children: group.section })) : null, _jsx("div", { style: { display: "grid", gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 6 }, children: group.items.map((item) => (_jsxs("button", { type: "button", "data-quick-item": item.id, "data-disabled": item.disabled === true, onClick: () => activate(item), disabled: item.disabled === true, style: {
68
+ position: "relative", display: "flex", flexDirection: "column", alignItems: "center", gap: 3, padding: "10px 4px",
69
+ borderRadius: 10, border: "1px solid rgba(148,163,184,0.25)", background: "rgba(17,22,30,0.7)", color: "#e2e8f0",
70
+ cursor: item.disabled === true ? "default" : "pointer", opacity: item.disabled === true ? 0.4 : 1,
71
+ }, children: [_jsx("span", { style: { fontSize: 22 }, children: item.icon }), item.label !== undefined ? _jsx("span", { style: { fontSize: 10 }, children: item.label }) : null, item.badge !== undefined ? (_jsx("span", { style: { position: "absolute", top: 3, right: 5, fontSize: 10, fontWeight: 700, color: accent }, children: item.badge })) : null, item.children !== undefined ? _jsx("span", { style: { position: "absolute", bottom: 3, right: 5, fontSize: 10 }, children: "\u25B8" }) : null] }, item.id))) })] }, group.section ?? gi)))] }));
72
+ }
73
+ // list
74
+ return (_jsxs("div", { className: className, "data-quick-menu": true, "data-layout": "list", style: { ...PANEL, width: 240, ...style }, children: [header, groups.map((group, gi) => (_jsxs("div", { "data-section": group.section, children: [group.section !== undefined ? (_jsx("div", { style: { fontSize: 10, textTransform: "uppercase", letterSpacing: 1.2, color: "rgba(148,163,184,0.75)", margin: "6px 0 4px" }, children: group.section })) : null, _jsx("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 2 }, children: group.items.map((item) => (_jsx("li", { children: _jsxs("button", { type: "button", "data-quick-item": item.id, "data-disabled": item.disabled === true, onClick: () => activate(item), disabled: item.disabled === true, style: {
75
+ width: "100%", display: "flex", alignItems: "center", gap: 8, padding: "7px 9px", borderRadius: 8,
76
+ border: "1px solid transparent", background: "transparent", color: "#e2e8f0", fontSize: 13, textAlign: "left",
77
+ cursor: item.disabled === true ? "default" : "pointer", opacity: item.disabled === true ? 0.45 : 1,
78
+ }, children: [item.icon !== undefined ? _jsx("span", { style: { width: 22, textAlign: "center", fontSize: 17 }, children: item.icon }) : null, _jsx("span", { style: { flex: 1 }, children: item.label ?? item.id }), _jsx(Badges, { item: item, accent: accent })] }) }, item.id))) })] }, group.section ?? gi)))] }));
79
+ }
@@ -0,0 +1,50 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type RadialArc } from "@jgengine/core/ui/radialMenu";
3
+ /** One selectable entry on a radial/quick menu (weapon or emote wheel). */
4
+ export interface RadialMenuOption {
5
+ id: string;
6
+ label?: string;
7
+ /** Glyph/icon node the game supplies. */
8
+ icon?: ReactNode;
9
+ disabled?: boolean;
10
+ /** Per-slice accent; falls back to the menu `accent`. */
11
+ color?: string;
12
+ /** Keybind hint drawn on the slice (e.g. "1", "Q"). */
13
+ hotkey?: string;
14
+ /** Count/quantity badge (ammo, stack size). */
15
+ badge?: string | number;
16
+ /** Cooldown remaining as `0..1` — dims the slice and shows the fraction. */
17
+ cooldown?: number;
18
+ /** Draw a ► marker to signal the slice opens a submenu. */
19
+ hasSubmenu?: boolean;
20
+ }
21
+ /** Props for {@link RadialMenu}. */
22
+ export interface RadialMenuProps {
23
+ options: readonly RadialMenuOption[];
24
+ /** Render nothing when false. Default true. */
25
+ open?: boolean;
26
+ onSelect: (id: string) => void;
27
+ onClose?: () => void;
28
+ /** Diameter in px. Default 300. */
29
+ size?: number;
30
+ /** Neutral inner-hub radius (dead zone) in px. Default 52. */
31
+ innerRadius?: number;
32
+ /** Partial arc instead of a full wheel (e.g. a bottom half or quarter). */
33
+ arc?: RadialArc;
34
+ /** Force a highlighted slice (also the initial hover) — for controllers/tests. */
35
+ highlightIndex?: number | null;
36
+ accent?: string;
37
+ className?: string;
38
+ style?: CSSProperties;
39
+ }
40
+ /**
41
+ * Radial / quick menu (weapon or emote wheel) — a ring or partial `arc` of
42
+ * selectable wedges the player points at with the mouse (or a stick). Pointer
43
+ * angle drives the highlight via core `radialIndexFromVector`; click/confirm
44
+ * fires `onSelect`, the neutral hub closes. Options carry optional hotkeys,
45
+ * count badges, cooldowns, and submenu markers. The game supplies icons/labels
46
+ * and skins it.
47
+ *
48
+ * @capability radial-menu pointer/stick-driven radial or arc quick menu (weapon/emote wheel) — angular slice selection with hotkeys, badges, cooldowns, and a neutral hub
49
+ */
50
+ export declare function RadialMenu({ options, open, onSelect, onClose, size, innerRadius, arc, highlightIndex, accent, className, style, }: RadialMenuProps): ReactNode;
@@ -0,0 +1,69 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from "react";
3
+ import { radialIndexFromVector, radialSlicePosition, radialSlices, } from "@jgengine/core/ui/radialMenu";
4
+ const TWO_PI = Math.PI * 2;
5
+ function wedgePath(cx, cy, innerR, outerR, center, half) {
6
+ const point = (r, a) => [cx + Math.sin(a) * r, cy - Math.cos(a) * r];
7
+ const start = center - half;
8
+ const end = center + half;
9
+ const [x0, y0] = point(outerR, start);
10
+ const [x1, y1] = point(outerR, end);
11
+ const [x2, y2] = point(innerR, end);
12
+ const [x3, y3] = point(innerR, start);
13
+ const large = half * 2 > Math.PI ? 1 : 0;
14
+ return `M${x0},${y0} A${outerR},${outerR} 0 ${large} 1 ${x1},${y1} L${x2},${y2} A${innerR},${innerR} 0 ${large} 0 ${x3},${y3} Z`;
15
+ }
16
+ /**
17
+ * Radial / quick menu (weapon or emote wheel) — a ring or partial `arc` of
18
+ * selectable wedges the player points at with the mouse (or a stick). Pointer
19
+ * angle drives the highlight via core `radialIndexFromVector`; click/confirm
20
+ * fires `onSelect`, the neutral hub closes. Options carry optional hotkeys,
21
+ * count badges, cooldowns, and submenu markers. The game supplies icons/labels
22
+ * and skins it.
23
+ *
24
+ * @capability radial-menu pointer/stick-driven radial or arc quick menu (weapon/emote wheel) — angular slice selection with hotkeys, badges, cooldowns, and a neutral hub
25
+ */
26
+ export function RadialMenu({ options, open = true, onSelect, onClose, size = 300, innerRadius = 52, arc, highlightIndex, accent = "var(--jg-accent, #38bdf8)", className, style, }) {
27
+ const count = options.length;
28
+ const [hovered, setHovered] = useState(highlightIndex ?? null);
29
+ const active = highlightIndex !== undefined ? highlightIndex : hovered;
30
+ const cx = size / 2;
31
+ const cy = size / 2;
32
+ const outerR = size / 2 - 3;
33
+ const labelRadius = (innerRadius + outerR) / 2;
34
+ const sweep = arc?.sweep ?? TWO_PI;
35
+ const half = count > 0 ? (sweep >= TWO_PI - 1e-6 ? Math.PI / count : sweep / count / 2) : 0;
36
+ const centers = useMemo(() => radialSlices(count, arc).map((slice) => slice.centerAngle), [count, arc]);
37
+ if (!open || count === 0)
38
+ return null;
39
+ const commit = () => {
40
+ if (active === null) {
41
+ onClose?.();
42
+ return;
43
+ }
44
+ const option = options[active];
45
+ if (option !== undefined && option.disabled !== true)
46
+ onSelect(option.id);
47
+ };
48
+ return (_jsx("div", { className: className, "data-radial-menu": true, style: { position: "relative", width: size, height: size, userSelect: "none", ...style }, children: _jsxs("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, style: { display: "block", cursor: "pointer" }, onPointerMove: (event) => {
49
+ if (highlightIndex !== undefined)
50
+ return;
51
+ const rect = event.currentTarget.getBoundingClientRect();
52
+ const px = ((event.clientX - rect.left) / rect.width) * size;
53
+ const py = ((event.clientY - rect.top) / rect.height) * size;
54
+ setHovered(radialIndexFromVector(px - cx, py - cy, count, { deadZone: innerRadius, ...arc }));
55
+ }, onClick: commit, children: [centers.map((center, index) => {
56
+ const option = options[index];
57
+ const isActive = index === active;
58
+ const color = option.color ?? accent;
59
+ const onCooldown = option.cooldown !== undefined && option.cooldown > 0;
60
+ return (_jsx("path", { "data-radial-option": option.id, "data-highlighted": isActive, "data-disabled": option.disabled === true, d: wedgePath(cx, cy, innerRadius, outerR, center, half * 0.92), fill: isActive ? color : "rgba(17,22,30,0.82)", fillOpacity: option.disabled === true ? 0.3 : onCooldown ? 0.5 : isActive ? 0.9 : 0.82, stroke: "rgba(148,163,184,0.35)", strokeWidth: 1 }, option.id));
61
+ }), _jsx("circle", { cx: cx, cy: cy, r: innerRadius - 6, fill: "rgba(8,11,16,0.9)", stroke: "rgba(148,163,184,0.3)" }), centers.map((_, index) => {
62
+ const option = options[index];
63
+ const at = radialSlicePosition(index, count, labelRadius, arc);
64
+ const isActive = index === active;
65
+ const x = cx + at.x;
66
+ const y = cy + at.y;
67
+ return (_jsxs("g", { transform: `translate(${x} ${y})`, style: { pointerEvents: "none" }, children: [option.icon !== undefined ? (_jsx("foreignObject", { x: -18, y: -26, width: 36, height: 30, style: { overflow: "visible" }, children: _jsx("div", { style: { display: "flex", justifyContent: "center", fontSize: 20 }, children: option.icon }) })) : null, option.label !== undefined ? (_jsx("text", { y: option.icon !== undefined ? 14 : 4, textAnchor: "middle", fontSize: 11, fontWeight: 700, fill: isActive ? "#f8fafc" : "rgba(226,232,240,0.8)", fontFamily: "ui-sans-serif, system-ui, sans-serif", children: option.label })) : null, option.hotkey !== undefined ? (_jsxs("text", { y: -20, textAnchor: "middle", fontSize: 9, fontWeight: 700, fill: "rgba(148,163,184,0.9)", children: ["[", option.hotkey, "]"] })) : null, option.badge !== undefined ? (_jsx("text", { x: 16, y: -8, textAnchor: "middle", fontSize: 10, fontWeight: 700, fill: accent, children: option.badge })) : null, option.cooldown !== undefined && option.cooldown > 0 ? (_jsxs("text", { y: 28, textAnchor: "middle", fontSize: 9, fill: "rgba(148,163,184,0.85)", children: [Math.ceil(option.cooldown * 100), "%"] })) : null, option.hasSubmenu === true ? (_jsx("text", { x: 22, y: 4, textAnchor: "middle", fontSize: 11, fill: "rgba(226,232,240,0.75)", children: "\u25B8" })) : null] }, option.id));
68
+ }), _jsx("text", { x: cx, y: cy + 4, textAnchor: "middle", fontSize: 12, fontWeight: 700, fill: "rgba(226,232,240,0.9)", fontFamily: "ui-sans-serif, system-ui, sans-serif", style: { pointerEvents: "none" }, children: active !== null && options[active] !== undefined ? options[active].label ?? "" : "" })] }) }));
69
+ }
@@ -0,0 +1,81 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { SaveSlotMeta, SaveSlots } from "@jgengine/core/game/saveSlots";
3
+ import { type GameIconName } from "./gameIcons.js";
4
+ /**
5
+ * Subscribe to a {@link SaveSlots} index and re-render on any change (write,
6
+ * clear, rename, restore). Returns the live list of slots in menu order. Wire it
7
+ * into a save-select screen and mutate the model in event handlers — the menu
8
+ * re-renders itself.
9
+ *
10
+ * @capability use-save-slots React hook binding a save-slots metadata index — re-renders on change and returns the ordered slot list
11
+ */
12
+ export declare function useSaveSlots(model: SaveSlots): SaveSlotMeta[];
13
+ /** Reskin tokens for {@link SaveSlotMenu}. All optional — each falls back to a HudTheme var then a neutral default. */
14
+ export interface SaveSlotMenuTheme {
15
+ /** Accent for the Continue action, savedAt time, and focus glow. Default reads `--jg-accent`. */
16
+ accent?: string;
17
+ /** Card background. */
18
+ cardBg?: string;
19
+ /** Card border. */
20
+ cardBorder?: string;
21
+ /** Primary text color (slot name). */
22
+ text?: string;
23
+ /** Muted text color (meta chips, empty label). */
24
+ muted?: string;
25
+ /** Meta-chip background. */
26
+ chipBg?: string;
27
+ /** Font family. */
28
+ fontFamily?: string;
29
+ /** Card corner radius (CSS length). */
30
+ radius?: string;
31
+ }
32
+ /**
33
+ * Turn a `savedAt` timestamp into a compact relative label ("just now", "4m ago",
34
+ * "2d ago"). Presentation-only helper — the model stores raw ms.
35
+ */
36
+ export declare function relativeSavedAt(savedAt: number, nowMs?: number): string;
37
+ /** Props for {@link SaveSlotMenu}. */
38
+ export interface SaveSlotMenuProps {
39
+ /** The metadata index to render. */
40
+ model: SaveSlots;
41
+ /**
42
+ * Load an existing save. Given the filled slot the player chose. Wire this to
43
+ * your `createSaveStore.switchSlot(id)` + scene load.
44
+ */
45
+ onLoad?: (slot: SaveSlotMeta) => void;
46
+ /** Start a new game in an empty slot. Given the empty slot chosen. */
47
+ onNew?: (slot: SaveSlotMeta) => void;
48
+ /**
49
+ * Delete a save. Given the filled slot. A game typically clears both this index
50
+ * (`model.clear(id)`) and the matching `createSaveStore` slot.
51
+ */
52
+ onDelete?: (slot: SaveSlotMeta) => void;
53
+ /**
54
+ * Continue the most recent save (the Continue button targets `model.mostRecent()`).
55
+ * Omit to hide the Continue button.
56
+ */
57
+ onContinue?: (slot: SaveSlotMeta) => void;
58
+ /** Menu heading. Default `"Select Save"`. */
59
+ title?: ReactNode;
60
+ /** Reskin tokens. */
61
+ theme?: SaveSlotMenuTheme;
62
+ /** Wall clock (ms) for relative times — inject in tests/screenshots. Default `Date.now`. */
63
+ now?: () => number;
64
+ /** Optional per-`meta`-key icon, so a game can prefix chips (e.g. `{ level: "star", playtime: "hourglass" }`). */
65
+ metaIcons?: Record<string, GameIconName>;
66
+ className?: string;
67
+ style?: CSSProperties;
68
+ }
69
+ /**
70
+ * A drop-in save-select / profile menu that renders a {@link SaveSlots} index as
71
+ * a column of cards. A filled slot shows its name, the free-string `meta` the
72
+ * game supplied as chips, and a relative "saved" time, with Load and Delete
73
+ * actions; an empty slot shows a "New Game" affordance. A top-level Continue
74
+ * button targets the newest save (`model.mostRecent()`). This is a starting-point
75
+ * game-front-end building block — the game owns final art, wording, and layout;
76
+ * reskin it with {@link SaveSlotMenuTheme} or HudTheme CSS vars. Presentation
77
+ * only: all state lives in the core model, and `meta` keys are never interpreted.
78
+ *
79
+ * @capability save-slot-menu drop-in save-select / profile menu that renders a core save-slots index as New / Continue / Load / Delete cards with free-string meta chips and relative save times, theme-skinnable
80
+ */
81
+ export declare function SaveSlotMenu({ model, onLoad, onNew, onDelete, onContinue, title, theme, now, metaIcons, className, style, }: SaveSlotMenuProps): ReactNode;