@jgengine/react 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +34 -3
  2. package/LICENSE +203 -661
  3. package/README.md +1 -1
  4. package/dist/actionHud.d.ts +174 -0
  5. package/dist/actionHud.js +292 -0
  6. package/dist/cards.d.ts +59 -0
  7. package/dist/cards.js +116 -0
  8. package/dist/chat.d.ts +6 -4
  9. package/dist/chat.js +16 -7
  10. package/dist/chatBubbles.js +2 -1
  11. package/dist/components.d.ts +20 -0
  12. package/dist/components.js +29 -1
  13. package/dist/display.d.ts +1 -0
  14. package/dist/display.js +1 -0
  15. package/dist/engineStore.d.ts +3 -0
  16. package/dist/engineStore.js +3 -0
  17. package/dist/fogOverlay.d.ts +3 -0
  18. package/dist/fogOverlay.js +3 -0
  19. package/dist/gameViewport.d.ts +54 -0
  20. package/dist/gameViewport.js +356 -0
  21. package/dist/hooks.d.ts +22 -0
  22. package/dist/hooks.js +58 -12
  23. package/dist/hud.d.ts +87 -0
  24. package/dist/hud.js +144 -0
  25. package/dist/hudLayout.d.ts +39 -4
  26. package/dist/hudLayout.js +152 -11
  27. package/dist/index.d.ts +10 -0
  28. package/dist/index.js +10 -0
  29. package/dist/keyHint.d.ts +30 -0
  30. package/dist/keyHint.js +31 -0
  31. package/dist/map.d.ts +69 -1
  32. package/dist/map.js +54 -1
  33. package/dist/provider.d.ts +2 -0
  34. package/dist/provider.js +4 -0
  35. package/dist/rotateDevice.d.ts +24 -0
  36. package/dist/rotateDevice.js +83 -0
  37. package/dist/save.d.ts +25 -0
  38. package/dist/save.js +37 -0
  39. package/dist/selectSnapshot.d.ts +2 -0
  40. package/dist/selectSnapshot.js +2 -0
  41. package/dist/selectionHud.d.ts +133 -0
  42. package/dist/selectionHud.js +182 -0
  43. package/dist/settings.d.ts +3 -0
  44. package/dist/skillCheckPaint.d.ts +2 -0
  45. package/dist/skillCheckPaint.js +2 -0
  46. package/dist/social.d.ts +3 -3
  47. package/dist/social.js +31 -10
  48. package/dist/startScreen.d.ts +62 -0
  49. package/dist/startScreen.js +62 -0
  50. package/dist/store.d.ts +24 -0
  51. package/dist/store.js +9 -0
  52. package/package.json +4 -4
  53. package/skills/jgengine/SKILL.md +51 -0
  54. package/skills/jgengine/api.md +434 -0
  55. package/skills/jgengine/capabilities.md +29 -0
  56. package/skills/jgengine/reference-cartridge.md +72 -0
  57. package/skills/jgengine-ui/SKILL.md +36 -0
  58. package/skills/jgengine-ui/api.md +1852 -0
  59. package/skills/jgengine-ui/capabilities.md +142 -0
  60. package/skills/jgengine-ui/reference.md +500 -0
  61. package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
  62. package/llms.txt +0 -1633
package/dist/hud.js ADDED
@@ -0,0 +1,144 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo } from "react";
3
+ import { groundSpeed } from "@jgengine/core/scene/entityStore";
4
+ import { useCurrency, useEntityStat, useGameClock, useGameStore, useInventory, localPlayerEntity } from "./hooks.js";
5
+ /**
6
+ * Drop-in HUD components — good-looking with zero styling, and fully opt-in: a game imports only the
7
+ * pieces it wants (`StatBar`, `Hotbar`, `Speedometer`, `Clock`, `WaveBanner`, `Coins`, `Crosshair`)
8
+ * and places them itself. The engine imposes none of these; there is no forced HUD. Styling is
9
+ * self-contained inline CSS (no Tailwind `@source` needed) with a shared dark-glass look; pass
10
+ * `style`/`className` to override. Anything reading the player defaults to the local player entity.
11
+ *
12
+ * @capability hud-components opt-in drop-in health/hotbar/speed/clock/wave/currency HUD widgets
13
+ */
14
+ const PANEL = {
15
+ background: "rgba(10,12,16,0.62)",
16
+ backdropFilter: "blur(6px)",
17
+ WebkitBackdropFilter: "blur(6px)",
18
+ border: "1px solid rgba(255,255,255,0.10)",
19
+ borderRadius: 10,
20
+ color: "#f4f6fb",
21
+ fontFamily: "ui-sans-serif, system-ui, -apple-system, sans-serif",
22
+ boxShadow: "0 4px 16px rgba(0,0,0,0.35)",
23
+ };
24
+ /** Named color ramps for {@link StatBar} tones. `fn` maps fill fraction (0..1) → gradient stops. */
25
+ const TONES = {
26
+ health: (t) => (t > 0.5 ? ["#22c55e", "#4ade80"] : t > 0.25 ? ["#f59e0b", "#fbbf24"] : ["#dc2626", "#f87171"]),
27
+ mana: () => ["#2563eb", "#60a5fa"],
28
+ stamina: () => ["#65a30d", "#a3e635"],
29
+ shield: () => ["#0891b2", "#22d3ee"],
30
+ xp: () => ["#7c3aed", "#a78bfa"],
31
+ neutral: () => ["#475569", "#94a3b8"],
32
+ };
33
+ function useLocalPlayerId(entityId) {
34
+ return useGameStore((ctx) => entityId ?? localPlayerEntity(ctx)?.id ?? null);
35
+ }
36
+ /**
37
+ * A polished stat bar (health/mana/stamina/shield/xp) — a rounded, glassy meter with a tone-colored
38
+ * fill and an optional value readout. Reads `statId` off `entityId` (defaults to the local player).
39
+ * Renders nothing until the stat exists.
40
+ */
41
+ export function StatBar({ statId = "health", entityId, tone = "health", label, showValue = true, width = 200, icon, style, className, }) {
42
+ const id = useLocalPlayerId(entityId);
43
+ const stat = useEntityStat(id ?? "", statId);
44
+ if (id === null || stat === null)
45
+ return null;
46
+ const range = stat.max - stat.min;
47
+ const fraction = range <= 0 ? 0 : Math.max(0, Math.min(1, (stat.current - stat.min) / range));
48
+ const [from, to] = (TONES[tone] ?? TONES.neutral)(fraction);
49
+ return (_jsxs("div", { className: className, style: { ...PANEL, padding: "6px 8px", width, ...style }, "data-stat": statId, children: [(label !== undefined || icon !== undefined || showValue) && (_jsxs("div", { style: { display: "flex", alignItems: "center", gap: 6, marginBottom: 4, fontSize: 11, fontWeight: 600, letterSpacing: 0.3 }, children: [icon, _jsx("span", { style: { textTransform: "uppercase", opacity: 0.75 }, children: label ?? statId }), showValue ? (_jsxs("span", { style: { marginLeft: "auto", fontVariantNumeric: "tabular-nums", opacity: 0.9 }, children: [Math.round(stat.current), _jsxs("span", { style: { opacity: 0.5 }, children: [" / ", Math.round(stat.max)] })] })) : null] })), _jsx("div", { style: { height: 8, borderRadius: 999, background: "rgba(255,255,255,0.10)", overflow: "hidden" }, children: _jsx("div", { style: { width: `${fraction * 100}%`, height: "100%", borderRadius: 999, background: `linear-gradient(90deg, ${from}, ${to})`, transition: "width 160ms ease" } }) })] }));
50
+ }
51
+ /**
52
+ * A numbered hotbar bound to an inventory — glassy slots, an active-slot highlight, item id + count,
53
+ * and a keycap per slot. Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one.
54
+ */
55
+ export function Hotbar({ inventoryId, activeSlot, keys, slotSize = 46, style, className, }) {
56
+ const slots = useInventory(inventoryId);
57
+ return (_jsx("div", { className: className, style: { display: "flex", gap: 6, ...style }, "data-inventory": inventoryId, children: slots.map((slot, index) => {
58
+ const active = index === activeSlot;
59
+ return (_jsxs("div", { "data-slot": index, style: {
60
+ ...PANEL,
61
+ width: slotSize,
62
+ height: slotSize,
63
+ position: "relative",
64
+ display: "flex",
65
+ alignItems: "center",
66
+ justifyContent: "center",
67
+ borderColor: active ? "rgba(56,189,248,0.9)" : "rgba(255,255,255,0.10)",
68
+ boxShadow: active ? "0 0 0 1px rgba(56,189,248,0.7), 0 4px 16px rgba(0,0,0,0.35)" : PANEL.boxShadow,
69
+ }, children: [_jsx("span", { style: { position: "absolute", top: 2, left: 4, fontSize: 9, fontWeight: 700, opacity: 0.55 }, children: keys?.[index] ?? index + 1 }), slot !== null ? (_jsxs("span", { style: { fontSize: 10, textAlign: "center", lineHeight: 1.1, padding: "0 2px", wordBreak: "break-word" }, children: [slot.itemId, slot.count > 1 ? _jsxs("span", { style: { display: "block", opacity: 0.6 }, children: ["\u00D7", slot.count] }) : null] })) : null] }, index));
70
+ }) }));
71
+ }
72
+ /**
73
+ * A speedometer for an entity (defaults to the local player) — an SVG arc gauge + a digital readout.
74
+ * `scale` converts world units/second to your display unit (3.6 → km/h, 2.237 → mph); `max` sets the
75
+ * gauge's top of scale.
76
+ */
77
+ export function Speedometer({ entityId, scale = 3.6, unit = "km/h", max = 60, size = 96, style, className, }) {
78
+ const speed = useGameStore((ctx) => {
79
+ const entity = entityId === undefined ? localPlayerEntity(ctx) : ctx.scene.entity.get(entityId);
80
+ return entity === null ? 0 : groundSpeed(entity) * scale;
81
+ });
82
+ const fraction = Math.max(0, Math.min(1, speed / max));
83
+ // 240° sweep from -210° to +30°.
84
+ const startDeg = -210;
85
+ const sweep = 240;
86
+ const radius = size / 2 - 8;
87
+ const cx = size / 2;
88
+ const cy = size / 2;
89
+ const arc = (fromFrac, toFrac) => {
90
+ const a0 = ((startDeg + sweep * fromFrac) * Math.PI) / 180;
91
+ const a1 = ((startDeg + sweep * toFrac) * Math.PI) / 180;
92
+ const large = sweep * (toFrac - fromFrac) > 180 ? 1 : 0;
93
+ return `M ${cx + radius * Math.cos(a0)} ${cy + radius * Math.sin(a0)} A ${radius} ${radius} 0 ${large} 1 ${cx + radius * Math.cos(a1)} ${cy + radius * Math.sin(a1)}`;
94
+ };
95
+ return (_jsxs("div", { className: className, style: { ...PANEL, width: size, padding: 6, textAlign: "center", ...style }, children: [_jsxs("svg", { width: size - 12, height: (size - 12) * 0.72, viewBox: `0 0 ${size} ${size * 0.72}`, style: { display: "block", margin: "0 auto" }, children: [_jsx("path", { d: arc(0, 1), fill: "none", stroke: "rgba(255,255,255,0.12)", strokeWidth: 7, strokeLinecap: "round" }), _jsx("path", { d: arc(0, fraction), fill: "none", stroke: fraction > 0.85 ? "#f87171" : "#38bdf8", strokeWidth: 7, strokeLinecap: "round" })] }), _jsx("div", { style: { marginTop: -8, fontVariantNumeric: "tabular-nums", fontWeight: 700, fontSize: 18, lineHeight: 1 }, children: Math.round(speed) }), _jsx("div", { style: { fontSize: 9, opacity: 0.6, letterSpacing: 0.4, textTransform: "uppercase" }, children: unit })] }));
96
+ }
97
+ /**
98
+ * A time-of-day clock reading the sim calendar — `Day N · HH:MM`, 24h or 12h. `controls` adds
99
+ * pause + the game's speed multipliers as clickable pills (the "fast-forward" bar), off by default so
100
+ * a game opts into letting the player scrub time.
101
+ */
102
+ export function Clock({ format = "24h", showDay = true, controls = false, style, className, }) {
103
+ const clock = useGameClock();
104
+ const { hour, minute, day } = clock.calendar;
105
+ const label = useMemo(() => {
106
+ const mm = minute.toString().padStart(2, "0");
107
+ if (format === "12h") {
108
+ const h12 = hour % 12 === 0 ? 12 : hour % 12;
109
+ return `${h12}:${mm} ${hour < 12 ? "AM" : "PM"}`;
110
+ }
111
+ return `${hour.toString().padStart(2, "0")}:${mm}`;
112
+ }, [hour, minute, format]);
113
+ return (_jsxs("div", { className: className, style: { ...PANEL, padding: "5px 10px", display: "inline-flex", alignItems: "center", gap: 8, fontVariantNumeric: "tabular-nums", ...style }, children: [showDay ? _jsxs("span", { style: { fontSize: 11, opacity: 0.6 }, children: ["Day ", day + 1] }) : null, _jsx("span", { style: { fontWeight: 700, fontSize: 14 }, children: label }), controls ? (_jsxs("span", { style: { display: "inline-flex", gap: 3, marginLeft: 4 }, children: [_jsx(ClockPill, { label: clock.paused ? "▶" : "❚❚", active: false, onClick: () => (clock.paused ? clock.controls.play() : clock.controls.pause()) }), clock.speeds.map((s) => (_jsx(ClockPill, { label: `${s}×`, active: !clock.paused && clock.speed === s, onClick: () => clock.controls.setSpeed(s) }, s)))] })) : null] }));
114
+ }
115
+ function ClockPill({ label, active, onClick }) {
116
+ return (_jsx("button", { type: "button", onClick: onClick, style: {
117
+ pointerEvents: "auto",
118
+ cursor: "pointer",
119
+ fontSize: 10,
120
+ fontWeight: 600,
121
+ padding: "1px 6px",
122
+ borderRadius: 6,
123
+ border: "none",
124
+ color: active ? "#0a0c10" : "#cbd5e1",
125
+ background: active ? "#38bdf8" : "rgba(255,255,255,0.08)",
126
+ }, children: label }));
127
+ }
128
+ /**
129
+ * A wave / round banner — a bold centered pill for "WAVE 3" style callouts, with an optional subtitle
130
+ * (enemies remaining, timer). Pure display: pass the current `wave` and whatever subtitle you track.
131
+ */
132
+ export function WaveBanner({ wave, label = "Wave", subtitle, style, className, }) {
133
+ return (_jsxs("div", { className: className, style: { ...PANEL, padding: "6px 16px", textAlign: "center", display: "inline-block", ...style }, children: [_jsx("div", { style: { fontSize: 10, letterSpacing: 2, textTransform: "uppercase", opacity: 0.65 }, children: label }), _jsx("div", { style: { fontSize: 24, fontWeight: 800, lineHeight: 1.1 }, children: wave }), subtitle !== undefined ? _jsx("div", { style: { fontSize: 11, opacity: 0.7, marginTop: 2 }, children: subtitle }) : null] }));
134
+ }
135
+ /** A currency counter — an icon (emoji/char, default a coin) plus the live amount for `currencyId`. */
136
+ export function Coins({ currencyId, icon = "🪙", style, className, }) {
137
+ const amount = useCurrency(currencyId);
138
+ return (_jsxs("span", { className: className, style: { ...PANEL, padding: "3px 10px", display: "inline-flex", alignItems: "center", gap: 6, fontWeight: 700, fontVariantNumeric: "tabular-nums", ...style }, "data-currency": currencyId, children: [_jsx("span", { "aria-hidden": true, children: icon }), amount] }));
139
+ }
140
+ /** A minimal center crosshair reticle — four ticks around a gap. Purely presentational. */
141
+ export function Crosshair({ size = 18, gap = 5, thickness = 2, color = "rgba(255,255,255,0.85)", style, className, }) {
142
+ const tick = (extra) => ({ position: "absolute", background: color, borderRadius: thickness, ...extra });
143
+ return (_jsx("div", { className: className, style: { position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "none", ...style }, children: _jsxs("div", { style: { position: "relative", width: size, height: size }, children: [_jsx("div", { style: tick({ left: "50%", top: 0, width: thickness, height: size / 2 - gap, transform: "translateX(-50%)" }) }), _jsx("div", { style: tick({ left: "50%", bottom: 0, width: thickness, height: size / 2 - gap, transform: "translateX(-50%)" }) }), _jsx("div", { style: tick({ top: "50%", left: 0, height: thickness, width: size / 2 - gap, transform: "translateY(-50%)" }) }), _jsx("div", { style: tick({ top: "50%", right: 0, height: thickness, width: size / 2 - gap, transform: "translateY(-50%)" }) })] }) }));
144
+ }
@@ -1,5 +1,10 @@
1
1
  import { type CSSProperties, type ReactNode } from "react";
2
2
  import { type HudAnchor, type HudLayoutStore } from "@jgengine/core/ui/hudLayout";
3
+ import { type EditorUiDocument } from "@jgengine/core/ui/hudDocument";
4
+ import { type HudPriority, type MobileHudBehavior } from "@jgengine/core/ui/gameLayout";
5
+ import type { GamePhase } from "@jgengine/core/game/gamePhase";
6
+ /** Whether a HUD element opted into `showDuring` is visible in the current phase; `undefined` = always visible (default). */
7
+ export declare function hudVisibleInPhase(showDuring: readonly GamePhase[] | undefined, phase: GamePhase): boolean;
3
8
  /**
4
9
  * How a panel behaves on compact (phone-scale) displays. `keep` stays visible
5
10
  * at the global compact scale, `chip` collapses to a small tap-to-expand
@@ -14,6 +19,13 @@ export declare function useHudLayout(options?: {
14
19
  storageKey?: string;
15
20
  snap?: number;
16
21
  locked?: boolean;
22
+ /**
23
+ * Scene-document `ui` section — source of truth for panel placement/size.
24
+ * When provided, hydrates the layout store (and wins over legacy localStorage).
25
+ */
26
+ documentUi?: EditorUiDocument;
27
+ /** When true (default), canvas moves/resizes write undoable `setUiPanel` patches to a live editor host. */
28
+ documentPatches?: boolean;
17
29
  }): HudLayoutStore;
18
30
  /**
19
31
  * Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine
@@ -22,15 +34,17 @@ export declare function useHudLayout(options?: {
22
34
  * (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the
23
35
  * whole surface scales down and each panel applies its `compact` behavior.
24
36
  */
25
- export declare function HudCanvas({ layout, editChord, compactScale, className, style, children, }: {
37
+ export declare function HudCanvas({ layout, editChord, compactScale, showDuring, className, style, children, }: {
26
38
  layout: HudLayoutStore;
27
39
  editChord?: HudEditChord | false;
28
40
  /** Zoom applied to the whole HUD on compact displays. Default 0.85. */
29
41
  compactScale?: number;
42
+ /** Opt-in play-phase gate: render the HUD only during these phases (e.g. `["playing"]`). Omit for always-visible (default). */
43
+ showDuring?: readonly GamePhase[];
30
44
  className?: string;
31
45
  style?: CSSProperties;
32
46
  children?: ReactNode;
33
- }): import("react").JSX.Element;
47
+ }): import("react").JSX.Element | null;
34
48
  /**
35
49
  * A HUD block that lives in one of the nine anchor regions. Panels sharing a
36
50
  * region stack outward from the screen edge in ascending `order`. On fine
@@ -38,7 +52,7 @@ export declare function HudCanvas({ layout, editChord, compactScale, className,
38
52
  * leaves the flow and keeps its custom placement. On compact displays custom
39
53
  * placements are ignored and the `compact` behavior applies.
40
54
  */
41
- export declare function HudPanel({ id, anchor, order, compact: compactMode, chip, interactive, inset, locked, className, style, children, }: {
55
+ 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, }: {
42
56
  id: string;
43
57
  anchor?: HudAnchor;
44
58
  /** Stack position within the region, ascending outward from the screen edge. Default 0. */
@@ -49,12 +63,33 @@ export declare function HudPanel({ id, anchor, order, compact: compactMode, chip
49
63
  chip?: string;
50
64
  /** `false` lets pointer events pass through to the game (read-only panels). Default true. */
51
65
  interactive?: boolean;
52
- /** Legacy pixel inset from the anchor; only used as the reset placement for dragged panels. */
66
+ /**
67
+ * Fallback pixel inset from the anchor when the scene document has no `ui.panels[id]`.
68
+ * Document layout is the source of truth once authored; TSX props are fallback-only.
69
+ */
53
70
  inset?: {
54
71
  x: number;
55
72
  y: number;
56
73
  };
57
74
  locked?: boolean;
75
+ /** Opt-in play-phase gate: render this panel only during these phases. Omit for always-visible (default). */
76
+ showDuring?: readonly GamePhase[];
77
+ /** Gameplay-importance tier, surfaced to layout tooling. `critical` stays visible; `tertiary` is the first to fold away. */
78
+ priority?: HudPriority;
79
+ /** How the panel adapts on mobile. `"hidden"` unmounts on phones; `"transient"` softens its collision policy; others tag the element for game CSS. */
80
+ mobileBehavior?: MobileHudBehavior;
81
+ /** Region ids this panel may overlap without a forbidden-collision report. */
82
+ allowOverlapWith?: readonly string[];
83
+ /** Regions sharing a non-empty group never collide with each other. */
84
+ collisionGroup?: string;
85
+ /** Register this panel as a collision-tracked layout region. Default true. */
86
+ region?: boolean;
87
+ /** Fallback width when the document has not authored a size. */
88
+ width?: number;
89
+ /** Fallback height when the document has not authored a size. */
90
+ height?: number;
91
+ /** Panel type id ({@link registerHudPanelType}) — drives resize axes + ParamSchema. */
92
+ type?: string;
58
93
  className?: string;
59
94
  style?: CSSProperties;
60
95
  children?: ReactNode;
package/dist/hudLayout.js CHANGED
@@ -1,11 +1,19 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react";
3
3
  import { createPortal } from "react-dom";
4
- import { HUD_ANCHOR_FRACTIONS, anchoredPlacement, createHudLayout, isPanelDraggable, } from "@jgengine/core/ui/hudLayout";
4
+ import { HUD_ANCHOR_FRACTIONS, anchoredPlacement, createHudLayout, isPanelDraggable, registerActiveHudLayout, } from "@jgengine/core/ui/hudLayout";
5
+ import { resizePanelSize, resolvePanelResize, } from "@jgengine/core/ui/hudDocument";
5
6
  import { hudScaleForViewport, overflowingPanels, resolveHudFit } from "@jgengine/core/ui/hudScale";
7
+ import { isMobileMode, } from "@jgengine/core/ui/gameLayout";
6
8
  import { useDisplayProfile } from "./display.js";
7
9
  import { useEngineState } from "./engineStore.js";
10
+ import { useGameLayoutMode, useRegisterLayoutRegion } from "./gameViewport.js";
11
+ import { useOptionalGamePhase } from "./hooks.js";
8
12
  import { useHudViewport } from "./hudViewport.js";
13
+ /** Whether a HUD element opted into `showDuring` is visible in the current phase; `undefined` = always visible (default). */
14
+ export function hudVisibleInPhase(showDuring, phase) {
15
+ return showDuring === undefined || showDuring.includes(phase);
16
+ }
9
17
  const STORAGE_PREFIX = "jg:hud:";
10
18
  const DRAG_THRESHOLD_PX = 4;
11
19
  const PERSIST_DELAY_MS = 200;
@@ -13,18 +21,73 @@ const EDIT_BAR_Z = 100000;
13
21
  const REGION_GAP = 10;
14
22
  const COMPACT_HUD_SCALE = 0.85;
15
23
  const HUD_ANCHORS = Object.keys(HUD_ANCHOR_FRACTIONS);
24
+ function patchDocumentUi(id, panel) {
25
+ if (typeof window === "undefined")
26
+ return;
27
+ const host = window.__jgengineEditorHost;
28
+ if (host === undefined)
29
+ return;
30
+ host.handle({
31
+ method: "dispatch",
32
+ command: { type: "setUiPanel", id, patch: panel },
33
+ });
34
+ }
35
+ function sizeStyle(panel) {
36
+ if (panel === undefined)
37
+ return {};
38
+ return {
39
+ ...(panel.width === undefined ? {} : { width: panel.width }),
40
+ ...(panel.height === undefined ? {} : { height: panel.height }),
41
+ };
42
+ }
43
+ function ResizeHandles({ axes, onResizeStart, }) {
44
+ if (axes === "none")
45
+ return null;
46
+ const handle = (edge, cursor, style) => (_jsx("div", { "data-hud-resize": edge, onPointerDown: (event) => {
47
+ event.stopPropagation();
48
+ onResizeStart(edge, event);
49
+ }, style: {
50
+ position: "absolute",
51
+ zIndex: 3,
52
+ pointerEvents: "auto",
53
+ background: "rgba(255, 255, 255, 0.85)",
54
+ cursor,
55
+ ...style,
56
+ } }, edge));
57
+ return (_jsxs(_Fragment, { children: [axes === "x" || axes === "both"
58
+ ? handle("e", "ew-resize", { top: 4, bottom: 4, right: -3, width: 6 })
59
+ : null, axes === "y" || axes === "both"
60
+ ? handle("s", "ns-resize", { left: 4, right: 4, bottom: -3, height: 6 })
61
+ : null, axes === "both" ? handle("se", "nwse-resize", { right: -4, bottom: -4, width: 10, height: 10 }) : null] }));
62
+ }
16
63
  export function useHudLayout(options) {
17
64
  const storageKey = options?.storageKey;
18
65
  const snap = options?.snap;
19
66
  const locked = options?.locked;
67
+ const documentUi = options?.documentUi;
68
+ const documentPatches = options?.documentPatches !== false;
20
69
  const layout = useMemo(() => {
21
- const store = createHudLayout({ snap, locked });
22
- if (storageKey !== undefined && typeof localStorage !== "undefined") {
70
+ const store = createHudLayout({
71
+ snap,
72
+ locked,
73
+ onDocumentPatch: documentPatches ? patchDocumentUi : undefined,
74
+ });
75
+ if (documentUi !== undefined) {
76
+ store.applyDocumentUi(documentUi);
77
+ }
78
+ else if (storageKey !== undefined && typeof localStorage !== "undefined") {
23
79
  store.hydrate(localStorage.getItem(STORAGE_PREFIX + storageKey));
24
80
  }
25
81
  return store;
26
- }, [storageKey, snap, locked]);
82
+ }, [storageKey, snap, locked, documentPatches]);
83
+ useEffect(() => {
84
+ if (documentUi !== undefined) {
85
+ layout.applyDocumentUi(documentUi);
86
+ }
87
+ }, [layout, documentUi]);
27
88
  useEffect(() => {
89
+ if (documentUi !== undefined)
90
+ return;
28
91
  if (storageKey === undefined || typeof localStorage === "undefined")
29
92
  return;
30
93
  let timer = null;
@@ -43,7 +106,7 @@ export function useHudLayout(options) {
43
106
  localStorage.setItem(STORAGE_PREFIX + storageKey, layout.serialize());
44
107
  }
45
108
  };
46
- }, [layout, storageKey]);
109
+ }, [layout, storageKey, documentUi]);
47
110
  return layout;
48
111
  }
49
112
  const HudCanvasContext = createContext(null);
@@ -125,8 +188,9 @@ function edgePad(envInset, extra, basePx, scale) {
125
188
  * (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the
126
189
  * whole surface scales down and each panel applies its `compact` behavior.
127
190
  */
128
- export function HudCanvas({ layout, editChord, compactScale, className, style, children, }) {
191
+ export function HudCanvas({ layout, editChord, compactScale, showDuring, className, style, children, }) {
129
192
  const canvasRef = useRef(null);
193
+ const phase = useOptionalGamePhase();
130
194
  const { compact } = useDisplayProfile();
131
195
  const hudViewport = useHudViewport();
132
196
  const fitEnabled = hudViewport?.fitEnabled === true;
@@ -152,6 +216,7 @@ export function HudCanvas({ layout, editChord, compactScale, className, style, c
152
216
  }, []);
153
217
  const value = useMemo(() => ({ layout, canvasRef, regions, compact }), [layout, regions, compact]);
154
218
  const editing = useEngineState(layout).editing;
219
+ useEffect(() => registerActiveHudLayout(layout), [layout]);
155
220
  const chordEnabled = editChord !== false;
156
221
  const hold = editChord === false ? undefined : (editChord?.hold ?? "F2");
157
222
  const press = editChord === false ? undefined : (editChord?.press ?? "KeyC");
@@ -286,6 +351,8 @@ export function HudCanvas({ layout, editChord, compactScale, className, style, c
286
351
  "--jg-hud-pad-right": edgePad("env(safe-area-inset-right, 0px)", null, basePad, scale),
287
352
  ...style,
288
353
  };
354
+ if (!hudVisibleInPhase(showDuring, phase))
355
+ return null;
289
356
  return (_jsx(HudCanvasContext.Provider, { value: value, children: _jsxs("div", { ref: canvasRef, "data-hud-canvas": "", "data-hud-editing": editing ? "" : undefined, className: className, style: canvasStyle, children: [HUD_ANCHORS.map((anchor) => (_jsx("div", { ref: regionRefs[anchor], "data-hud-region": anchor, style: {
290
357
  position: "absolute",
291
358
  display: "flex",
@@ -334,29 +401,54 @@ function HudChip({ label, anchor, children, }) {
334
401
  * leaves the flow and keeps its custom placement. On compact displays custom
335
402
  * placements are ignored and the `compact` behavior applies.
336
403
  */
337
- export function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, className, style, children, }) {
404
+ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }) {
338
405
  const ctx = useContext(HudCanvasContext);
406
+ const phase = useOptionalGamePhase();
407
+ const layoutMode = useGameLayoutMode();
339
408
  if (ctx === null)
340
409
  throw new Error("HudPanel must be rendered inside a HudCanvas");
341
410
  const { layout, canvasRef, regions, compact } = ctx;
411
+ const onMobile = isMobileMode(layoutMode);
342
412
  const registeredOnRef = useRef(null);
343
413
  if (registeredOnRef.current !== layout) {
344
414
  registeredOnRef.current = layout;
345
- layout.register(id, anchoredPlacement(anchor, inset ?? { x: 16, y: 16 }));
415
+ layout.register(id, anchoredPlacement(anchor, inset ?? { x: 16, y: 16 }), {
416
+ width,
417
+ height,
418
+ type,
419
+ });
346
420
  }
347
421
  const layoutState = useEngineState(layout);
348
422
  const panel = layoutState.panels[id];
349
423
  const draggable = !compact && locked !== true && isPanelDraggable(layoutState, id);
350
424
  const editing = layoutState.editing && draggable;
425
+ const resizeSpec = resolvePanelResize(panel?.type ?? type);
426
+ const resizable = editing && resizeSpec.axes !== "none";
351
427
  const rootRef = useRef(null);
352
428
  const dragRef = useRef(null);
353
429
  const suppressClickRef = useRef(false);
354
430
  const detachRef = useRef(null);
355
431
  const [dragging, setDragging] = useState(false);
356
432
  useEffect(() => () => detachRef.current?.(), []);
433
+ const regionHidden = panel === undefined ||
434
+ panel.visible === false ||
435
+ (compact && compactMode === "hide") ||
436
+ (onMobile && mobileBehavior === "hidden") ||
437
+ !hudVisibleInPhase(showDuring, phase);
438
+ const regionSpec = useMemo(() => ({
439
+ id,
440
+ kind: "hud",
441
+ collisionPolicy: (mobileBehavior === "transient" ? "warn" : "forbid"),
442
+ priority,
443
+ allowOverlapWith,
444
+ collisionGroup,
445
+ }), [id, mobileBehavior, priority, allowOverlapWith, collisionGroup]);
446
+ useRegisterLayoutRegion(regionSpec, rootRef, region && !regionHidden);
357
447
  const onPointerDown = (event) => {
358
448
  if (!draggable || event.button !== 0)
359
449
  return;
450
+ if (event.target?.closest?.("[data-hud-resize]") != null)
451
+ return;
360
452
  const root = rootRef.current;
361
453
  const canvas = canvasRef.current;
362
454
  if (root === null || canvas === null)
@@ -413,6 +505,44 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
413
505
  window.addEventListener("pointerup", onEnd);
414
506
  window.addEventListener("pointercancel", onEnd);
415
507
  };
508
+ const onResizeStart = (edge, event) => {
509
+ if (!resizable || event.button !== 0)
510
+ return;
511
+ const root = rootRef.current;
512
+ if (root === null)
513
+ return;
514
+ const startWidth = panel?.width ?? root.getBoundingClientRect().width;
515
+ const startHeight = panel?.height ?? root.getBoundingClientRect().height;
516
+ const pointerId = event.pointerId;
517
+ const startX = event.clientX;
518
+ const startY = event.clientY;
519
+ suppressClickRef.current = true;
520
+ layout.bringToFront(id);
521
+ const onMove = (moveEvent) => {
522
+ if (moveEvent.pointerId !== pointerId)
523
+ return;
524
+ const dw = edge === "s" ? 0 : moveEvent.clientX - startX;
525
+ const dh = edge === "e" ? 0 : moveEvent.clientY - startY;
526
+ const next = resizePanelSize({ width: startWidth, height: startHeight }, { dw, dh }, resizeSpec.axes, resizeSpec);
527
+ layout.resize(id, next);
528
+ };
529
+ const onEnd = (endEvent) => {
530
+ if (endEvent.pointerId !== pointerId)
531
+ return;
532
+ detachRef.current?.();
533
+ };
534
+ detachRef.current?.();
535
+ const detach = () => {
536
+ window.removeEventListener("pointermove", onMove);
537
+ window.removeEventListener("pointerup", onEnd);
538
+ window.removeEventListener("pointercancel", onEnd);
539
+ detachRef.current = null;
540
+ };
541
+ detachRef.current = detach;
542
+ window.addEventListener("pointermove", onMove);
543
+ window.addEventListener("pointerup", onEnd);
544
+ window.addEventListener("pointercancel", onEnd);
545
+ };
416
546
  const onClickCapture = (event) => {
417
547
  if (!suppressClickRef.current)
418
548
  return;
@@ -432,11 +562,18 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
432
562
  color: "rgba(255, 255, 255, 0.9)",
433
563
  font: "10px/1.5 system-ui, sans-serif",
434
564
  whiteSpace: "nowrap",
435
- }, children: id })] })) : null;
565
+ }, children: id }), resizable ? _jsx(ResizeHandles, { axes: resizeSpec.axes, onResizeStart: onResizeStart }) : null] })) : null;
436
566
  if (panel === undefined)
437
567
  return null;
568
+ if (panel.visible === false)
569
+ return null;
438
570
  if (compact && compactMode === "hide")
439
571
  return null;
572
+ if (onMobile && mobileBehavior === "hidden")
573
+ return null;
574
+ if (!hudVisibleInPhase(showDuring, phase))
575
+ return null;
576
+ const authoredSize = sizeStyle(panel);
440
577
  const flow = compact || !panel.moved;
441
578
  if (flow) {
442
579
  const regionEl = regions[compact ? anchor : panel.placement.anchor];
@@ -444,7 +581,7 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
444
581
  return null;
445
582
  const chipped = compact && compactMode === "chip";
446
583
  const passThrough = interactive === false && !editing && !chipped;
447
- return createPortal(_jsxs("div", { ref: rootRef, "data-hud-panel": id, "data-dragging": dragging ? "" : undefined, className: className, onPointerDown: onPointerDown, onClickCapture: onClickCapture, style: {
584
+ return createPortal(_jsxs("div", { ref: rootRef, "data-hud-panel": id, "data-dragging": dragging ? "" : undefined, "data-hud-priority": priority, "data-hud-mobile-behavior": mobileBehavior, "data-hud-type": panel.type ?? type, className: className, onPointerDown: onPointerDown, onClickCapture: onClickCapture, style: {
448
585
  position: "relative",
449
586
  order: order ?? 0,
450
587
  pointerEvents: passThrough ? "none" : "auto",
@@ -453,6 +590,8 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
453
590
  cursor: dragging ? "grabbing" : editing ? "grab" : undefined,
454
591
  outline: editing ? "1px dashed rgba(255, 255, 255, 0.6)" : undefined,
455
592
  outlineOffset: editing ? 2 : undefined,
593
+ boxSizing: "border-box",
594
+ ...authoredSize,
456
595
  ...style,
457
596
  }, children: [chipped ? (_jsx(HudChip, { label: chip ?? id, anchor: anchor, children: children })) : (children), editingChrome] }), regionEl);
458
597
  }
@@ -473,7 +612,7 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
473
612
  if (fx === 0.5 || fy === 0.5) {
474
613
  position.transform = `translate(${fx === 0.5 ? "-50%" : "0"}, ${fy === 0.5 ? "-50%" : "0"})`;
475
614
  }
476
- return (_jsxs("div", { ref: rootRef, "data-hud-panel": id, "data-dragging": dragging ? "" : undefined, className: className, onPointerDown: onPointerDown, onClickCapture: onClickCapture, style: {
615
+ return (_jsxs("div", { ref: rootRef, "data-hud-panel": id, "data-dragging": dragging ? "" : undefined, "data-hud-type": panel.type ?? type, className: className, onPointerDown: onPointerDown, onClickCapture: onClickCapture, style: {
477
616
  position: "absolute",
478
617
  ...position,
479
618
  zIndex: panel.z,
@@ -483,6 +622,8 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
483
622
  cursor: dragging ? "grabbing" : editing ? "grab" : undefined,
484
623
  outline: editing ? "1px dashed rgba(255, 255, 255, 0.6)" : undefined,
485
624
  outlineOffset: editing ? 2 : undefined,
625
+ boxSizing: "border-box",
626
+ ...authoredSize,
486
627
  ...style,
487
628
  }, children: [children, editingChrome] }));
488
629
  }
package/dist/index.d.ts CHANGED
@@ -5,12 +5,22 @@ export * from "./chatBubbles.js";
5
5
  export * from "./voice.js";
6
6
  export * from "./social.js";
7
7
  export * from "./hooks.js";
8
+ export * from "./store.js";
8
9
  export * from "./settings.js";
10
+ export * from "./save.js";
9
11
  export * from "./components.js";
12
+ export * from "./hud.js";
13
+ export * from "./keyHint.js";
14
+ export * from "./startScreen.js";
10
15
  export * from "./engineStore.js";
11
16
  export * from "./dragLayer.js";
17
+ export * from "./cards.js";
18
+ export * from "./actionHud.js";
19
+ export * from "./selectionHud.js";
12
20
  export * from "./hudLayout.js";
13
21
  export * from "./hudViewport.js";
22
+ export * from "./gameViewport.js";
23
+ export * from "./rotateDevice.js";
14
24
  export * from "./display.js";
15
25
  export * from "./map.js";
16
26
  export * from "./preview.js";
package/dist/index.js CHANGED
@@ -5,12 +5,22 @@ export * from "./chatBubbles.js";
5
5
  export * from "./voice.js";
6
6
  export * from "./social.js";
7
7
  export * from "./hooks.js";
8
+ export * from "./store.js";
8
9
  export * from "./settings.js";
10
+ export * from "./save.js";
9
11
  export * from "./components.js";
12
+ export * from "./hud.js";
13
+ export * from "./keyHint.js";
14
+ export * from "./startScreen.js";
10
15
  export * from "./engineStore.js";
11
16
  export * from "./dragLayer.js";
17
+ export * from "./cards.js";
18
+ export * from "./actionHud.js";
19
+ export * from "./selectionHud.js";
12
20
  export * from "./hudLayout.js";
13
21
  export * from "./hudViewport.js";
22
+ export * from "./gameViewport.js";
23
+ export * from "./rotateDevice.js";
14
24
  export * from "./display.js";
15
25
  export * from "./map.js";
16
26
  export * from "./preview.js";
@@ -0,0 +1,30 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ /**
3
+ * A single keyboard/mouse key rendered as a cap. Tagged `data-jg-kbd` so tooling
4
+ * can find it; on its own it stays visible (hiding a lone cap would strand its
5
+ * label). Wrap keys + their meaning in `KeyHint` to make the whole hint vanish
6
+ * on touch.
7
+ *
8
+ * @capability keycap styled keyboard/mouse key cap for control hints
9
+ */
10
+ export declare function Keycap({ children, className, style, }: {
11
+ children: ReactNode;
12
+ className?: string;
13
+ style?: CSSProperties;
14
+ }): import("react").JSX.Element;
15
+ /**
16
+ * Wraps a keyboard/mouse control hint (a cap plus what it does). Renders nothing
17
+ * on coarse-pointer devices — a touchscreen has no keyboard, so the hint is
18
+ * noise and only fights the on-screen controls for space. The `data-jg-kbd-hint`
19
+ * marker is also hidden by an engine stylesheet as a hydration-safe backstop, so
20
+ * a hint stays hidden on touch even before this component's effect runs. Put the
21
+ * touch equivalent (a `TouchControls` dock, a tappable button) on the coarse
22
+ * branch instead.
23
+ *
24
+ * @capability key-hint keyboard/mouse control hint that hides itself on touch
25
+ */
26
+ export declare function KeyHint({ children, className, style, }: {
27
+ children: ReactNode;
28
+ className?: string;
29
+ style?: CSSProperties;
30
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import {} from "react";
3
+ import { useDisplayProfile } from "./display.js";
4
+ /**
5
+ * A single keyboard/mouse key rendered as a cap. Tagged `data-jg-kbd` so tooling
6
+ * can find it; on its own it stays visible (hiding a lone cap would strand its
7
+ * label). Wrap keys + their meaning in `KeyHint` to make the whole hint vanish
8
+ * on touch.
9
+ *
10
+ * @capability keycap styled keyboard/mouse key cap for control hints
11
+ */
12
+ export function Keycap({ children, className, style, }) {
13
+ return (_jsx("kbd", { "data-jg-kbd": "", className: className, style: style, children: children }));
14
+ }
15
+ /**
16
+ * Wraps a keyboard/mouse control hint (a cap plus what it does). Renders nothing
17
+ * on coarse-pointer devices — a touchscreen has no keyboard, so the hint is
18
+ * noise and only fights the on-screen controls for space. The `data-jg-kbd-hint`
19
+ * marker is also hidden by an engine stylesheet as a hydration-safe backstop, so
20
+ * a hint stays hidden on touch even before this component's effect runs. Put the
21
+ * touch equivalent (a `TouchControls` dock, a tappable button) on the coarse
22
+ * branch instead.
23
+ *
24
+ * @capability key-hint keyboard/mouse control hint that hides itself on touch
25
+ */
26
+ export function KeyHint({ children, className, style, }) {
27
+ const { coarsePointer } = useDisplayProfile();
28
+ if (coarsePointer)
29
+ return null;
30
+ return (_jsx("span", { "data-jg-kbd-hint": "", className: className, style: style, children: children }));
31
+ }