@jgengine/react 0.8.0 → 0.9.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.
@@ -0,0 +1,61 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type HudAnchor, type HudLayoutStore } from "@jgengine/core/ui/hudLayout";
3
+ /**
4
+ * How a panel behaves on compact (phone-scale) displays. `keep` stays visible
5
+ * at the global compact scale, `chip` collapses to a small tap-to-expand
6
+ * pill, `hide` unmounts entirely.
7
+ */
8
+ export type HudCompactMode = "keep" | "chip" | "hide";
9
+ export interface HudEditChord {
10
+ hold: string;
11
+ press: string;
12
+ }
13
+ export declare function useHudLayout(options?: {
14
+ storageKey?: string;
15
+ snap?: number;
16
+ locked?: boolean;
17
+ }): HudLayoutStore;
18
+ /**
19
+ * Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine
20
+ * anchor regions and stack automatically with a gap — no per-panel pixel
21
+ * offsets, no manual clearance for sibling panels, the touch-control dock
22
+ * (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the
23
+ * whole surface scales down and each panel applies its `compact` behavior.
24
+ */
25
+ export declare function HudCanvas({ layout, editChord, compactScale, className, style, children, }: {
26
+ layout: HudLayoutStore;
27
+ editChord?: HudEditChord | false;
28
+ /** Zoom applied to the whole HUD on compact displays. Default 0.85. */
29
+ compactScale?: number;
30
+ className?: string;
31
+ style?: CSSProperties;
32
+ children?: ReactNode;
33
+ }): import("react").JSX.Element;
34
+ /**
35
+ * A HUD block that lives in one of the nine anchor regions. Panels sharing a
36
+ * region stack outward from the screen edge in ascending `order`. On fine
37
+ * pointers panels stay draggable through the edit chord; a dragged panel
38
+ * leaves the flow and keeps its custom placement. On compact displays custom
39
+ * placements are ignored and the `compact` behavior applies.
40
+ */
41
+ export declare function HudPanel({ id, anchor, order, compact: compactMode, chip, interactive, inset, locked, className, style, children, }: {
42
+ id: string;
43
+ anchor?: HudAnchor;
44
+ /** Stack position within the region, ascending outward from the screen edge. Default 0. */
45
+ order?: number;
46
+ /** Behavior on compact displays. Default `"keep"`. */
47
+ compact?: HudCompactMode;
48
+ /** Chip label when `compact="chip"`. Defaults to the panel id. */
49
+ chip?: string;
50
+ /** `false` lets pointer events pass through to the game (read-only panels). Default true. */
51
+ interactive?: boolean;
52
+ /** Legacy pixel inset from the anchor; only used as the reset placement for dragged panels. */
53
+ inset?: {
54
+ x: number;
55
+ y: number;
56
+ };
57
+ locked?: boolean;
58
+ className?: string;
59
+ style?: CSSProperties;
60
+ children?: ReactNode;
61
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,488 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { HUD_ANCHOR_FRACTIONS, anchoredPlacement, createHudLayout, isPanelDraggable, } from "@jgengine/core/ui/hudLayout";
5
+ import { hudScaleForViewport, overflowingPanels, resolveHudFit } from "@jgengine/core/ui/hudScale";
6
+ import { useDisplayProfile } from "./display.js";
7
+ import { useEngineState } from "./engineStore.js";
8
+ import { useHudViewport } from "./hudViewport.js";
9
+ const STORAGE_PREFIX = "jg:hud:";
10
+ const DRAG_THRESHOLD_PX = 4;
11
+ const PERSIST_DELAY_MS = 200;
12
+ const EDIT_BAR_Z = 100000;
13
+ const REGION_GAP = 10;
14
+ const COMPACT_HUD_SCALE = 0.85;
15
+ const HUD_ANCHORS = Object.keys(HUD_ANCHOR_FRACTIONS);
16
+ export function useHudLayout(options) {
17
+ const storageKey = options?.storageKey;
18
+ const snap = options?.snap;
19
+ const locked = options?.locked;
20
+ const layout = useMemo(() => {
21
+ const store = createHudLayout({ snap, locked });
22
+ if (storageKey !== undefined && typeof localStorage !== "undefined") {
23
+ store.hydrate(localStorage.getItem(STORAGE_PREFIX + storageKey));
24
+ }
25
+ return store;
26
+ }, [storageKey, snap, locked]);
27
+ useEffect(() => {
28
+ if (storageKey === undefined || typeof localStorage === "undefined")
29
+ return;
30
+ let timer = null;
31
+ const unsubscribe = layout.subscribe(() => {
32
+ if (timer !== null)
33
+ clearTimeout(timer);
34
+ timer = setTimeout(() => {
35
+ timer = null;
36
+ localStorage.setItem(STORAGE_PREFIX + storageKey, layout.serialize());
37
+ }, PERSIST_DELAY_MS);
38
+ });
39
+ return () => {
40
+ unsubscribe();
41
+ if (timer !== null) {
42
+ clearTimeout(timer);
43
+ localStorage.setItem(STORAGE_PREFIX + storageKey, layout.serialize());
44
+ }
45
+ };
46
+ }, [layout, storageKey]);
47
+ return layout;
48
+ }
49
+ const HudCanvasContext = createContext(null);
50
+ function HudEditBar({ layout }) {
51
+ return (_jsxs("div", { "data-hud-edit-bar": "", style: {
52
+ position: "absolute",
53
+ top: 12,
54
+ left: "50%",
55
+ transform: "translateX(-50%)",
56
+ zIndex: EDIT_BAR_Z,
57
+ pointerEvents: "auto",
58
+ display: "flex",
59
+ alignItems: "center",
60
+ gap: 10,
61
+ padding: "6px 12px",
62
+ borderRadius: 999,
63
+ background: "rgba(10, 10, 14, 0.92)",
64
+ border: "1px solid rgba(255, 255, 255, 0.25)",
65
+ color: "rgba(255, 255, 255, 0.92)",
66
+ font: "12px/1.4 system-ui, sans-serif",
67
+ whiteSpace: "nowrap",
68
+ }, children: [_jsx("span", { children: "HUD layout \u2014 drag panels to rearrange" }), _jsx("button", { type: "button", onClick: () => layout.reset(), style: {
69
+ padding: "2px 10px",
70
+ borderRadius: 999,
71
+ border: "1px solid rgba(255, 255, 255, 0.3)",
72
+ background: "transparent",
73
+ color: "inherit",
74
+ font: "inherit",
75
+ cursor: "pointer",
76
+ }, children: "Reset" }), _jsx("button", { type: "button", onClick: () => layout.setEditing(false), style: {
77
+ padding: "2px 10px",
78
+ borderRadius: 999,
79
+ border: "1px solid transparent",
80
+ background: "rgba(255, 255, 255, 0.92)",
81
+ color: "rgb(10, 10, 14)",
82
+ font: "inherit",
83
+ cursor: "pointer",
84
+ }, children: "Done" })] }));
85
+ }
86
+ const PAD_TOP = "var(--jg-hud-pad-top)";
87
+ const PAD_BOTTOM = "var(--jg-hud-pad-bottom)";
88
+ const PAD_LEFT = "var(--jg-hud-pad-left)";
89
+ const PAD_RIGHT = "var(--jg-hud-pad-right)";
90
+ const REGION_LAYOUT = {
91
+ "top-left": { top: PAD_TOP, left: PAD_LEFT, alignItems: "flex-start" },
92
+ top: { top: PAD_TOP, left: "50%", transform: "translateX(-50%)", alignItems: "center" },
93
+ "top-right": { top: PAD_TOP, right: PAD_RIGHT, alignItems: "flex-end" },
94
+ left: { left: PAD_LEFT, top: "50%", transform: "translateY(-50%)", alignItems: "flex-start" },
95
+ center: { left: "50%", top: "50%", transform: "translate(-50%, -50%)", alignItems: "center" },
96
+ right: { right: PAD_RIGHT, top: "50%", transform: "translateY(-50%)", alignItems: "flex-end" },
97
+ "bottom-left": {
98
+ bottom: PAD_BOTTOM,
99
+ left: PAD_LEFT,
100
+ flexDirection: "column-reverse",
101
+ alignItems: "flex-start",
102
+ },
103
+ bottom: {
104
+ bottom: PAD_BOTTOM,
105
+ left: "50%",
106
+ transform: "translateX(-50%)",
107
+ flexDirection: "column-reverse",
108
+ alignItems: "center",
109
+ },
110
+ "bottom-right": {
111
+ bottom: PAD_BOTTOM,
112
+ right: PAD_RIGHT,
113
+ flexDirection: "column-reverse",
114
+ alignItems: "flex-end",
115
+ },
116
+ };
117
+ function edgePad(envInset, extra, basePx, scale) {
118
+ const outer = extra === null ? envInset : `${envInset} + ${extra}`;
119
+ return scale === 1 ? `calc(${outer} + ${basePx}px)` : `calc((${outer}) / ${scale} + ${basePx}px)`;
120
+ }
121
+ /**
122
+ * Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine
123
+ * anchor regions and stack automatically with a gap — no per-panel pixel
124
+ * offsets, no manual clearance for sibling panels, the touch-control dock
125
+ * (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the
126
+ * whole surface scales down and each panel applies its `compact` behavior.
127
+ */
128
+ export function HudCanvas({ layout, editChord, compactScale, className, style, children, }) {
129
+ const canvasRef = useRef(null);
130
+ const { compact } = useDisplayProfile();
131
+ const hudViewport = useHudViewport();
132
+ const fitEnabled = hudViewport?.fitEnabled === true;
133
+ const [viewport, setViewport] = useState(null);
134
+ const [regions, setRegions] = useState({});
135
+ const regionRefs = useMemo(() => {
136
+ const refs = {};
137
+ for (const anchor of HUD_ANCHORS) {
138
+ refs[anchor] = (el) => {
139
+ setRegions((prev) => {
140
+ if (el === null) {
141
+ if (prev[anchor] === undefined)
142
+ return prev;
143
+ const next = { ...prev };
144
+ delete next[anchor];
145
+ return next;
146
+ }
147
+ return prev[anchor] === el ? prev : { ...prev, [anchor]: el };
148
+ });
149
+ };
150
+ }
151
+ return refs;
152
+ }, []);
153
+ const value = useMemo(() => ({ layout, canvasRef, regions, compact }), [layout, regions, compact]);
154
+ const editing = useEngineState(layout).editing;
155
+ const chordEnabled = editChord !== false;
156
+ const hold = editChord === false ? undefined : (editChord?.hold ?? "F2");
157
+ const press = editChord === false ? undefined : (editChord?.press ?? "KeyC");
158
+ useEffect(() => {
159
+ if (!chordEnabled || hold === undefined || press === undefined)
160
+ return;
161
+ if (typeof window === "undefined")
162
+ return;
163
+ let holding = false;
164
+ const onKeyDown = (event) => {
165
+ if (event.code === hold) {
166
+ holding = true;
167
+ return;
168
+ }
169
+ if (event.code === press && holding) {
170
+ event.preventDefault();
171
+ layout.setEditing(!layout.getState().editing);
172
+ return;
173
+ }
174
+ if (event.code === "Escape" && layout.getState().editing) {
175
+ layout.setEditing(false);
176
+ }
177
+ };
178
+ const onKeyUp = (event) => {
179
+ if (event.code === hold)
180
+ holding = false;
181
+ };
182
+ const onBlur = () => {
183
+ holding = false;
184
+ };
185
+ window.addEventListener("keydown", onKeyDown);
186
+ window.addEventListener("keyup", onKeyUp);
187
+ window.addEventListener("blur", onBlur);
188
+ return () => {
189
+ window.removeEventListener("keydown", onKeyDown);
190
+ window.removeEventListener("keyup", onKeyUp);
191
+ window.removeEventListener("blur", onBlur);
192
+ };
193
+ }, [layout, chordEnabled, hold, press]);
194
+ useEffect(() => {
195
+ if (!fitEnabled)
196
+ return;
197
+ const host = canvasRef.current?.parentElement;
198
+ if (host === undefined || host === null || typeof ResizeObserver === "undefined")
199
+ return;
200
+ const measure = () => {
201
+ const rect = host.getBoundingClientRect();
202
+ const next = { width: Math.round(rect.width), height: Math.round(rect.height) };
203
+ setViewport((prev) => prev !== null && prev.width === next.width && prev.height === next.height ? prev : next);
204
+ };
205
+ measure();
206
+ const observer = new ResizeObserver(measure);
207
+ observer.observe(host);
208
+ return () => observer.disconnect();
209
+ }, [fitEnabled]);
210
+ useEffect(() => {
211
+ const canvas = canvasRef.current;
212
+ const host = canvas?.parentElement;
213
+ if (canvas === null || canvas === undefined || host === undefined || host === null)
214
+ return;
215
+ if (typeof requestAnimationFrame !== "function")
216
+ return;
217
+ let frame = 0;
218
+ let lastReport = "";
219
+ const check = () => {
220
+ frame = 0;
221
+ const hostRect = host.getBoundingClientRect();
222
+ if (hostRect.width <= 0 || hostRect.height <= 0)
223
+ return;
224
+ const panels = [];
225
+ for (const el of canvas.querySelectorAll("[data-hud-panel]")) {
226
+ const rect = el.getBoundingClientRect();
227
+ if (rect.width === 0 && rect.height === 0)
228
+ continue;
229
+ panels.push({
230
+ id: el.getAttribute("data-hud-panel") ?? "?",
231
+ rect: {
232
+ x: rect.left - hostRect.left,
233
+ y: rect.top - hostRect.top,
234
+ width: rect.width,
235
+ height: rect.height,
236
+ },
237
+ });
238
+ }
239
+ const offenders = overflowingPanels(panels, { width: hostRect.width, height: hostRect.height });
240
+ const report = offenders.length === 0 ? "" : JSON.stringify(offenders);
241
+ if (report === lastReport)
242
+ return;
243
+ lastReport = report;
244
+ if (report === "") {
245
+ canvas.removeAttribute("data-hud-overflow");
246
+ }
247
+ else {
248
+ canvas.setAttribute("data-hud-overflow", report);
249
+ console.warn(`[jgengine] HUD panels overflow the ${Math.round(hostRect.width)}x${Math.round(hostRect.height)} viewport: ${report}`);
250
+ }
251
+ };
252
+ const schedule = () => {
253
+ if (frame !== 0)
254
+ return;
255
+ frame = requestAnimationFrame(check);
256
+ };
257
+ const settle = setTimeout(schedule, 400);
258
+ const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule);
259
+ resizeObserver?.observe(host);
260
+ const mutationObserver = typeof MutationObserver === "undefined" ? null : new MutationObserver(schedule);
261
+ mutationObserver?.observe(canvas, { childList: true, subtree: true });
262
+ return () => {
263
+ clearTimeout(settle);
264
+ if (frame !== 0)
265
+ cancelAnimationFrame(frame);
266
+ resizeObserver?.disconnect();
267
+ mutationObserver?.disconnect();
268
+ };
269
+ }, []);
270
+ const fit = fitEnabled ? resolveHudFit(hudViewport?.config, compact) : null;
271
+ const baseScale = fit !== null && viewport !== null
272
+ ? hudScaleForViewport(fit, viewport)
273
+ : compact
274
+ ? (compactScale ?? COMPACT_HUD_SCALE)
275
+ : 1;
276
+ const scale = baseScale * (hudViewport?.userScale ?? 1);
277
+ const basePad = compact ? 10 : 16;
278
+ const canvasStyle = {
279
+ position: "absolute",
280
+ inset: 0,
281
+ pointerEvents: "none",
282
+ zoom: scale === 1 ? undefined : scale,
283
+ "--jg-hud-pad-top": edgePad("env(safe-area-inset-top, 0px)", null, basePad, scale),
284
+ "--jg-hud-pad-bottom": edgePad("env(safe-area-inset-bottom, 0px)", "var(--jg-hud-dock-clearance, 0px)", basePad, scale),
285
+ "--jg-hud-pad-left": edgePad("env(safe-area-inset-left, 0px)", null, basePad, scale),
286
+ "--jg-hud-pad-right": edgePad("env(safe-area-inset-right, 0px)", null, basePad, scale),
287
+ ...style,
288
+ };
289
+ 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
+ position: "absolute",
291
+ display: "flex",
292
+ flexDirection: "column",
293
+ gap: REGION_GAP,
294
+ pointerEvents: "none",
295
+ zIndex: 1,
296
+ ...REGION_LAYOUT[anchor],
297
+ } }, anchor))), children, editing ? _jsx(HudEditBar, { layout: layout }) : null] }) }));
298
+ }
299
+ function chipAlignment(anchor) {
300
+ if (anchor.endsWith("right"))
301
+ return "flex-end";
302
+ if (anchor.endsWith("left"))
303
+ return "flex-start";
304
+ return "center";
305
+ }
306
+ function HudChip({ label, anchor, children, }) {
307
+ const [open, setOpen] = useState(false);
308
+ return (_jsxs("div", { style: {
309
+ display: "flex",
310
+ flexDirection: "column",
311
+ gap: 6,
312
+ alignItems: chipAlignment(anchor),
313
+ }, children: [_jsxs("button", { type: "button", "data-hud-chip": "", onClick: () => setOpen((current) => !current), style: {
314
+ pointerEvents: "auto",
315
+ display: "inline-flex",
316
+ alignItems: "center",
317
+ gap: 6,
318
+ padding: "4px 10px",
319
+ borderRadius: 999,
320
+ background: "rgba(10, 10, 14, 0.72)",
321
+ border: "1px solid rgba(255, 255, 255, 0.22)",
322
+ color: "rgba(255, 255, 255, 0.92)",
323
+ font: "600 11px/1.2 system-ui, sans-serif",
324
+ letterSpacing: "0.04em",
325
+ textTransform: "uppercase",
326
+ cursor: "pointer",
327
+ whiteSpace: "nowrap",
328
+ }, children: [_jsx("span", { children: label }), _jsx("span", { "aria-hidden": true, children: open ? "▾" : "▸" })] }), open ? children : null] }));
329
+ }
330
+ /**
331
+ * A HUD block that lives in one of the nine anchor regions. Panels sharing a
332
+ * region stack outward from the screen edge in ascending `order`. On fine
333
+ * pointers panels stay draggable through the edit chord; a dragged panel
334
+ * leaves the flow and keeps its custom placement. On compact displays custom
335
+ * placements are ignored and the `compact` behavior applies.
336
+ */
337
+ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, className, style, children, }) {
338
+ const ctx = useContext(HudCanvasContext);
339
+ if (ctx === null)
340
+ throw new Error("HudPanel must be rendered inside a HudCanvas");
341
+ const { layout, canvasRef, regions, compact } = ctx;
342
+ const registeredOnRef = useRef(null);
343
+ if (registeredOnRef.current !== layout) {
344
+ registeredOnRef.current = layout;
345
+ layout.register(id, anchoredPlacement(anchor, inset ?? { x: 16, y: 16 }));
346
+ }
347
+ const layoutState = useEngineState(layout);
348
+ const panel = layoutState.panels[id];
349
+ const draggable = !compact && locked !== true && isPanelDraggable(layoutState, id);
350
+ const editing = layoutState.editing && draggable;
351
+ const rootRef = useRef(null);
352
+ const dragRef = useRef(null);
353
+ const suppressClickRef = useRef(false);
354
+ const detachRef = useRef(null);
355
+ const [dragging, setDragging] = useState(false);
356
+ useEffect(() => () => detachRef.current?.(), []);
357
+ const onPointerDown = (event) => {
358
+ if (!draggable || event.button !== 0)
359
+ return;
360
+ const root = rootRef.current;
361
+ const canvas = canvasRef.current;
362
+ if (root === null || canvas === null)
363
+ return;
364
+ const canvasRect = canvas.getBoundingClientRect();
365
+ const rect = root.getBoundingClientRect();
366
+ suppressClickRef.current = false;
367
+ const drag = {
368
+ pointerId: event.pointerId,
369
+ startX: event.clientX,
370
+ startY: event.clientY,
371
+ x: rect.left - canvasRect.left,
372
+ y: rect.top - canvasRect.top,
373
+ width: rect.width,
374
+ height: rect.height,
375
+ active: false,
376
+ };
377
+ dragRef.current = drag;
378
+ const onMove = (moveEvent) => {
379
+ if (moveEvent.pointerId !== drag.pointerId)
380
+ return;
381
+ const dx = moveEvent.clientX - drag.startX;
382
+ const dy = moveEvent.clientY - drag.startY;
383
+ if (!drag.active) {
384
+ if (dx * dx + dy * dy < DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX)
385
+ return;
386
+ drag.active = true;
387
+ suppressClickRef.current = true;
388
+ layout.bringToFront(id);
389
+ setDragging(true);
390
+ }
391
+ const liveCanvas = canvasRef.current;
392
+ if (liveCanvas === null)
393
+ return;
394
+ const liveRect = liveCanvas.getBoundingClientRect();
395
+ layout.move(id, { x: drag.x + dx, y: drag.y + dy, width: drag.width, height: drag.height }, { width: liveRect.width, height: liveRect.height });
396
+ };
397
+ const onEnd = (endEvent) => {
398
+ if (endEvent.pointerId !== drag.pointerId)
399
+ return;
400
+ detachRef.current?.();
401
+ dragRef.current = null;
402
+ setDragging(false);
403
+ };
404
+ detachRef.current?.();
405
+ const detach = () => {
406
+ window.removeEventListener("pointermove", onMove);
407
+ window.removeEventListener("pointerup", onEnd);
408
+ window.removeEventListener("pointercancel", onEnd);
409
+ detachRef.current = null;
410
+ };
411
+ detachRef.current = detach;
412
+ window.addEventListener("pointermove", onMove);
413
+ window.addEventListener("pointerup", onEnd);
414
+ window.addEventListener("pointercancel", onEnd);
415
+ };
416
+ const onClickCapture = (event) => {
417
+ if (!suppressClickRef.current)
418
+ return;
419
+ suppressClickRef.current = false;
420
+ event.preventDefault();
421
+ event.stopPropagation();
422
+ };
423
+ const editingChrome = editing ? (_jsxs(_Fragment, { children: [_jsx("div", { "data-hud-panel-shield": "", style: { position: "absolute", inset: 0, zIndex: 1, cursor: "inherit" } }), _jsx("div", { "data-hud-panel-label": "", style: {
424
+ position: "absolute",
425
+ top: -20,
426
+ left: 0,
427
+ zIndex: 2,
428
+ pointerEvents: "none",
429
+ padding: "1px 6px",
430
+ borderRadius: 4,
431
+ background: "rgba(10, 10, 14, 0.85)",
432
+ color: "rgba(255, 255, 255, 0.9)",
433
+ font: "10px/1.5 system-ui, sans-serif",
434
+ whiteSpace: "nowrap",
435
+ }, children: id })] })) : null;
436
+ if (panel === undefined)
437
+ return null;
438
+ if (compact && compactMode === "hide")
439
+ return null;
440
+ const flow = compact || !panel.moved;
441
+ if (flow) {
442
+ const regionEl = regions[compact ? anchor : panel.placement.anchor];
443
+ if (regionEl === undefined)
444
+ return null;
445
+ const chipped = compact && compactMode === "chip";
446
+ 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: {
448
+ position: "relative",
449
+ order: order ?? 0,
450
+ pointerEvents: passThrough ? "none" : "auto",
451
+ touchAction: draggable ? "none" : undefined,
452
+ userSelect: editing || dragging ? "none" : undefined,
453
+ cursor: dragging ? "grabbing" : editing ? "grab" : undefined,
454
+ outline: editing ? "1px dashed rgba(255, 255, 255, 0.6)" : undefined,
455
+ outlineOffset: editing ? 2 : undefined,
456
+ ...style,
457
+ }, children: [chipped ? (_jsx(HudChip, { label: chip ?? id, anchor: anchor, children: children })) : (children), editingChrome] }), regionEl);
458
+ }
459
+ const { fx, fy } = HUD_ANCHOR_FRACTIONS[panel.placement.anchor];
460
+ const position = {};
461
+ if (fx === 0)
462
+ position.left = panel.placement.dx;
463
+ else if (fx === 1)
464
+ position.right = -panel.placement.dx;
465
+ else
466
+ position.left = `calc(50% + ${panel.placement.dx}px)`;
467
+ if (fy === 0)
468
+ position.top = panel.placement.dy;
469
+ else if (fy === 1)
470
+ position.bottom = -panel.placement.dy;
471
+ else
472
+ position.top = `calc(50% + ${panel.placement.dy}px)`;
473
+ if (fx === 0.5 || fy === 0.5) {
474
+ position.transform = `translate(${fx === 0.5 ? "-50%" : "0"}, ${fy === 0.5 ? "-50%" : "0"})`;
475
+ }
476
+ return (_jsxs("div", { ref: rootRef, "data-hud-panel": id, "data-dragging": dragging ? "" : undefined, className: className, onPointerDown: onPointerDown, onClickCapture: onClickCapture, style: {
477
+ position: "absolute",
478
+ ...position,
479
+ zIndex: panel.z,
480
+ pointerEvents: "auto",
481
+ touchAction: draggable ? "none" : undefined,
482
+ userSelect: editing || dragging ? "none" : undefined,
483
+ cursor: dragging ? "grabbing" : editing ? "grab" : undefined,
484
+ outline: editing ? "1px dashed rgba(255, 255, 255, 0.6)" : undefined,
485
+ outlineOffset: editing ? 2 : undefined,
486
+ ...style,
487
+ }, children: [children, editingChrome] }));
488
+ }
@@ -0,0 +1,21 @@
1
+ import { type ReactNode } from "react";
2
+ import type { HudPlatform, HudViewportConfig } from "@jgengine/core/ui/hudScale";
3
+ export interface HudViewportContextValue {
4
+ /** True unless the game's `platforms` explicitly excludes `"mobile"` — the HUD auto-fits the design resolution to the live viewport. */
5
+ fitEnabled: boolean;
6
+ config: HudViewportConfig | undefined;
7
+ /** Player's UI-scale setting; multiplies the computed fit scale everywhere. */
8
+ userScale: number;
9
+ }
10
+ /**
11
+ * Mounted by the shell around `GameUI` so every `HudCanvas` inside the game
12
+ * picks up the game's `platforms`/`hudFit` declaration and the player's UI
13
+ * scale setting without any game-side wiring.
14
+ */
15
+ export declare function HudViewportProvider({ platforms, config, userScale, children, }: {
16
+ platforms: readonly HudPlatform[] | undefined;
17
+ config: HudViewportConfig | undefined;
18
+ userScale?: number;
19
+ children?: ReactNode;
20
+ }): import("react").JSX.Element;
21
+ export declare function useHudViewport(): HudViewportContextValue | null;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useMemo } from "react";
3
+ const HudViewportContext = createContext(null);
4
+ /**
5
+ * Mounted by the shell around `GameUI` so every `HudCanvas` inside the game
6
+ * picks up the game's `platforms`/`hudFit` declaration and the player's UI
7
+ * scale setting without any game-side wiring.
8
+ */
9
+ export function HudViewportProvider({ platforms, config, userScale, children, }) {
10
+ const fitEnabled = platforms?.includes("mobile") ?? true;
11
+ const scale = userScale ?? 1;
12
+ const value = useMemo(() => ({ fitEnabled, config, userScale: scale }), [fitEnabled, config, scale]);
13
+ return _jsx(HudViewportContext.Provider, { value: value, children: children });
14
+ }
15
+ export function useHudViewport() {
16
+ return useContext(HudViewportContext);
17
+ }
package/dist/index.d.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  export * from "./provider.js";
2
2
  export * from "./identity.js";
3
3
  export * from "./chat.js";
4
+ export * from "./chatBubbles.js";
4
5
  export * from "./voice.js";
5
6
  export * from "./social.js";
6
7
  export * from "./hooks.js";
8
+ export * from "./settings.js";
7
9
  export * from "./components.js";
8
10
  export * from "./engineStore.js";
9
11
  export * from "./dragLayer.js";
12
+ export * from "./hudLayout.js";
13
+ export * from "./hudViewport.js";
10
14
  export * from "./display.js";
11
15
  export * from "./map.js";
16
+ export * from "./preview.js";
package/dist/index.js CHANGED
@@ -1,11 +1,16 @@
1
1
  export * from "./provider.js";
2
2
  export * from "./identity.js";
3
3
  export * from "./chat.js";
4
+ export * from "./chatBubbles.js";
4
5
  export * from "./voice.js";
5
6
  export * from "./social.js";
6
7
  export * from "./hooks.js";
8
+ export * from "./settings.js";
7
9
  export * from "./components.js";
8
10
  export * from "./engineStore.js";
9
11
  export * from "./dragLayer.js";
12
+ export * from "./hudLayout.js";
13
+ export * from "./hudViewport.js";
10
14
  export * from "./display.js";
11
15
  export * from "./map.js";
16
+ export * from "./preview.js";
@@ -1,20 +1,14 @@
1
1
  import { type CSSProperties } from "react";
2
- /**
3
- * Drive a DOM/SVG element from a per-frame value without re-rendering React.
4
- * Runs one requestAnimationFrame loop, reads `get()` each frame, and calls
5
- * `apply(value, element)` so HUDs bound to live engine state (speed, pose)
6
- * never re-render and never lag. `get`/`apply` may change without restarting.
7
- */
2
+ type FrameSubscriber = () => void;
3
+ export declare function subscribeFrameBind(subscriber: FrameSubscriber): () => void;
4
+ export declare function frameBindSubscriberCount(): number;
8
5
  export declare function useFrameBind<T, E extends Element = Element>(ref: {
9
6
  current: E | null;
10
7
  }, get: () => T, apply: (value: T, element: E) => void): void;
11
- /**
12
- * A `<span>` whose text tracks a live value every frame (the 90% case of
13
- * useFrameBind). `<LiveText get={() => groundSpeed(car) * KMH} format={Math.round} />`.
14
- */
15
8
  export declare function LiveText({ get, format, className, style, }: {
16
9
  get: () => number | string;
17
10
  format?: (value: number | string) => string;
18
11
  className?: string;
19
12
  style?: CSSProperties;
20
13
  }): import("react").JSX.Element;
14
+ export {};