@jgengine/react 0.10.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 (53) hide show
  1. package/CHANGELOG.md +13 -2
  2. package/LICENSE +203 -661
  3. package/README.md +1 -1
  4. package/dist/actionHud.d.ts +174 -0
  5. package/dist/actionHud.js +292 -0
  6. package/dist/cards.d.ts +59 -0
  7. package/dist/cards.js +116 -0
  8. package/dist/chat.d.ts +3 -1
  9. package/dist/chat.js +2 -2
  10. package/dist/components.d.ts +20 -0
  11. package/dist/components.js +28 -0
  12. package/dist/display.d.ts +1 -0
  13. package/dist/display.js +1 -0
  14. package/dist/engineStore.d.ts +3 -0
  15. package/dist/engineStore.js +3 -0
  16. package/dist/fogOverlay.d.ts +3 -0
  17. package/dist/fogOverlay.js +3 -0
  18. package/dist/gameViewport.js +16 -0
  19. package/dist/hooks.d.ts +20 -0
  20. package/dist/hooks.js +31 -1
  21. package/dist/hud.d.ts +87 -0
  22. package/dist/hud.js +144 -0
  23. package/dist/hudLayout.d.ts +19 -2
  24. package/dist/hudLayout.js +121 -10
  25. package/dist/index.d.ts +8 -0
  26. package/dist/index.js +8 -0
  27. package/dist/keyHint.d.ts +30 -0
  28. package/dist/keyHint.js +31 -0
  29. package/dist/map.d.ts +69 -1
  30. package/dist/map.js +54 -1
  31. package/dist/save.d.ts +25 -0
  32. package/dist/save.js +37 -0
  33. package/dist/selectSnapshot.d.ts +2 -0
  34. package/dist/selectSnapshot.js +2 -0
  35. package/dist/selectionHud.d.ts +133 -0
  36. package/dist/selectionHud.js +182 -0
  37. package/dist/skillCheckPaint.d.ts +2 -0
  38. package/dist/skillCheckPaint.js +2 -0
  39. package/dist/startScreen.d.ts +62 -0
  40. package/dist/startScreen.js +62 -0
  41. package/dist/store.d.ts +24 -0
  42. package/dist/store.js +9 -0
  43. package/package.json +4 -4
  44. package/skills/jgengine/SKILL.md +51 -0
  45. package/skills/jgengine/api.md +434 -0
  46. package/skills/jgengine/capabilities.md +29 -0
  47. package/skills/jgengine/reference-cartridge.md +72 -0
  48. package/skills/jgengine-ui/SKILL.md +36 -0
  49. package/skills/jgengine-ui/api.md +1852 -0
  50. package/skills/jgengine-ui/capabilities.md +142 -0
  51. package/skills/jgengine-ui/reference.md +500 -0
  52. package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
  53. package/llms.txt +0 -1724
@@ -1,5 +1,6 @@
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";
3
4
  import { type HudPriority, type MobileHudBehavior } from "@jgengine/core/ui/gameLayout";
4
5
  import type { GamePhase } from "@jgengine/core/game/gamePhase";
5
6
  /** Whether a HUD element opted into `showDuring` is visible in the current phase; `undefined` = always visible (default). */
@@ -18,6 +19,13 @@ export declare function useHudLayout(options?: {
18
19
  storageKey?: string;
19
20
  snap?: number;
20
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;
21
29
  }): HudLayoutStore;
22
30
  /**
23
31
  * Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine
@@ -44,7 +52,7 @@ export declare function HudCanvas({ layout, editChord, compactScale, showDuring,
44
52
  * leaves the flow and keeps its custom placement. On compact displays custom
45
53
  * placements are ignored and the `compact` behavior applies.
46
54
  */
47
- export declare function HudPanel({ id, anchor, order, compact: compactMode, chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region, 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, }: {
48
56
  id: string;
49
57
  anchor?: HudAnchor;
50
58
  /** Stack position within the region, ascending outward from the screen edge. Default 0. */
@@ -55,7 +63,10 @@ export declare function HudPanel({ id, anchor, order, compact: compactMode, chip
55
63
  chip?: string;
56
64
  /** `false` lets pointer events pass through to the game (read-only panels). Default true. */
57
65
  interactive?: boolean;
58
- /** 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
+ */
59
70
  inset?: {
60
71
  x: number;
61
72
  y: number;
@@ -73,6 +84,12 @@ export declare function HudPanel({ id, anchor, order, compact: compactMode, chip
73
84
  collisionGroup?: string;
74
85
  /** Register this panel as a collision-tracked layout region. Default true. */
75
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;
76
93
  className?: string;
77
94
  style?: CSSProperties;
78
95
  children?: ReactNode;
package/dist/hudLayout.js CHANGED
@@ -1,7 +1,8 @@
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";
6
7
  import { isMobileMode, } from "@jgengine/core/ui/gameLayout";
7
8
  import { useDisplayProfile } from "./display.js";
@@ -20,18 +21,73 @@ const EDIT_BAR_Z = 100000;
20
21
  const REGION_GAP = 10;
21
22
  const COMPACT_HUD_SCALE = 0.85;
22
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
+ }
23
63
  export function useHudLayout(options) {
24
64
  const storageKey = options?.storageKey;
25
65
  const snap = options?.snap;
26
66
  const locked = options?.locked;
67
+ const documentUi = options?.documentUi;
68
+ const documentPatches = options?.documentPatches !== false;
27
69
  const layout = useMemo(() => {
28
- const store = createHudLayout({ snap, locked });
29
- 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") {
30
79
  store.hydrate(localStorage.getItem(STORAGE_PREFIX + storageKey));
31
80
  }
32
81
  return store;
33
- }, [storageKey, snap, locked]);
82
+ }, [storageKey, snap, locked, documentPatches]);
83
+ useEffect(() => {
84
+ if (documentUi !== undefined) {
85
+ layout.applyDocumentUi(documentUi);
86
+ }
87
+ }, [layout, documentUi]);
34
88
  useEffect(() => {
89
+ if (documentUi !== undefined)
90
+ return;
35
91
  if (storageKey === undefined || typeof localStorage === "undefined")
36
92
  return;
37
93
  let timer = null;
@@ -50,7 +106,7 @@ export function useHudLayout(options) {
50
106
  localStorage.setItem(STORAGE_PREFIX + storageKey, layout.serialize());
51
107
  }
52
108
  };
53
- }, [layout, storageKey]);
109
+ }, [layout, storageKey, documentUi]);
54
110
  return layout;
55
111
  }
56
112
  const HudCanvasContext = createContext(null);
@@ -160,6 +216,7 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
160
216
  }, []);
161
217
  const value = useMemo(() => ({ layout, canvasRef, regions, compact }), [layout, regions, compact]);
162
218
  const editing = useEngineState(layout).editing;
219
+ useEffect(() => registerActiveHudLayout(layout), [layout]);
163
220
  const chordEnabled = editChord !== false;
164
221
  const hold = editChord === false ? undefined : (editChord?.hold ?? "F2");
165
222
  const press = editChord === false ? undefined : (editChord?.press ?? "KeyC");
@@ -344,7 +401,7 @@ function HudChip({ label, anchor, children, }) {
344
401
  * leaves the flow and keeps its custom placement. On compact displays custom
345
402
  * placements are ignored and the `compact` behavior applies.
346
403
  */
347
- export function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, 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, }) {
348
405
  const ctx = useContext(HudCanvasContext);
349
406
  const phase = useOptionalGamePhase();
350
407
  const layoutMode = useGameLayoutMode();
@@ -355,12 +412,18 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
355
412
  const registeredOnRef = useRef(null);
356
413
  if (registeredOnRef.current !== layout) {
357
414
  registeredOnRef.current = layout;
358
- 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
+ });
359
420
  }
360
421
  const layoutState = useEngineState(layout);
361
422
  const panel = layoutState.panels[id];
362
423
  const draggable = !compact && locked !== true && isPanelDraggable(layoutState, id);
363
424
  const editing = layoutState.editing && draggable;
425
+ const resizeSpec = resolvePanelResize(panel?.type ?? type);
426
+ const resizable = editing && resizeSpec.axes !== "none";
364
427
  const rootRef = useRef(null);
365
428
  const dragRef = useRef(null);
366
429
  const suppressClickRef = useRef(false);
@@ -368,6 +431,7 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
368
431
  const [dragging, setDragging] = useState(false);
369
432
  useEffect(() => () => detachRef.current?.(), []);
370
433
  const regionHidden = panel === undefined ||
434
+ panel.visible === false ||
371
435
  (compact && compactMode === "hide") ||
372
436
  (onMobile && mobileBehavior === "hidden") ||
373
437
  !hudVisibleInPhase(showDuring, phase);
@@ -383,6 +447,8 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
383
447
  const onPointerDown = (event) => {
384
448
  if (!draggable || event.button !== 0)
385
449
  return;
450
+ if (event.target?.closest?.("[data-hud-resize]") != null)
451
+ return;
386
452
  const root = rootRef.current;
387
453
  const canvas = canvasRef.current;
388
454
  if (root === null || canvas === null)
@@ -439,6 +505,44 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
439
505
  window.addEventListener("pointerup", onEnd);
440
506
  window.addEventListener("pointercancel", onEnd);
441
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
+ };
442
546
  const onClickCapture = (event) => {
443
547
  if (!suppressClickRef.current)
444
548
  return;
@@ -458,15 +562,18 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
458
562
  color: "rgba(255, 255, 255, 0.9)",
459
563
  font: "10px/1.5 system-ui, sans-serif",
460
564
  whiteSpace: "nowrap",
461
- }, children: id })] })) : null;
565
+ }, children: id }), resizable ? _jsx(ResizeHandles, { axes: resizeSpec.axes, onResizeStart: onResizeStart }) : null] })) : null;
462
566
  if (panel === undefined)
463
567
  return null;
568
+ if (panel.visible === false)
569
+ return null;
464
570
  if (compact && compactMode === "hide")
465
571
  return null;
466
572
  if (onMobile && mobileBehavior === "hidden")
467
573
  return null;
468
574
  if (!hudVisibleInPhase(showDuring, phase))
469
575
  return null;
576
+ const authoredSize = sizeStyle(panel);
470
577
  const flow = compact || !panel.moved;
471
578
  if (flow) {
472
579
  const regionEl = regions[compact ? anchor : panel.placement.anchor];
@@ -474,7 +581,7 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
474
581
  return null;
475
582
  const chipped = compact && compactMode === "chip";
476
583
  const passThrough = interactive === false && !editing && !chipped;
477
- return createPortal(_jsxs("div", { ref: rootRef, "data-hud-panel": id, "data-dragging": dragging ? "" : undefined, "data-hud-priority": priority, "data-hud-mobile-behavior": mobileBehavior, 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: {
478
585
  position: "relative",
479
586
  order: order ?? 0,
480
587
  pointerEvents: passThrough ? "none" : "auto",
@@ -483,6 +590,8 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
483
590
  cursor: dragging ? "grabbing" : editing ? "grab" : undefined,
484
591
  outline: editing ? "1px dashed rgba(255, 255, 255, 0.6)" : undefined,
485
592
  outlineOffset: editing ? 2 : undefined,
593
+ boxSizing: "border-box",
594
+ ...authoredSize,
486
595
  ...style,
487
596
  }, children: [chipped ? (_jsx(HudChip, { label: chip ?? id, anchor: anchor, children: children })) : (children), editingChrome] }), regionEl);
488
597
  }
@@ -503,7 +612,7 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
503
612
  if (fx === 0.5 || fy === 0.5) {
504
613
  position.transform = `translate(${fx === 0.5 ? "-50%" : "0"}, ${fy === 0.5 ? "-50%" : "0"})`;
505
614
  }
506
- 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: {
507
616
  position: "absolute",
508
617
  ...position,
509
618
  zIndex: panel.z,
@@ -513,6 +622,8 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
513
622
  cursor: dragging ? "grabbing" : editing ? "grab" : undefined,
514
623
  outline: editing ? "1px dashed rgba(255, 255, 255, 0.6)" : undefined,
515
624
  outlineOffset: editing ? 2 : undefined,
625
+ boxSizing: "border-box",
626
+ ...authoredSize,
516
627
  ...style,
517
628
  }, children: [children, editingChrome] }));
518
629
  }
package/dist/index.d.ts CHANGED
@@ -5,10 +5,18 @@ 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";
14
22
  export * from "./gameViewport.js";
package/dist/index.js CHANGED
@@ -5,10 +5,18 @@ 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";
14
22
  export * from "./gameViewport.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
+ }
package/dist/map.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { FogField } from "@jgengine/core/world/fog";
3
3
  import { type MapMarker, type MarkerKindStyle, type MarkerSet } from "@jgengine/core/world/markers";
4
- import { type WorldXZ } from "@jgengine/core/world/minimap";
4
+ import { type MinimapView, type WorldXZ } from "@jgengine/core/world/minimap";
5
5
  import { type MapCellStates, type MapRoute, type MapZone } from "@jgengine/core/world/mapLayers";
6
6
  export declare function useMarkers(markers: MarkerSet): readonly MapMarker[];
7
7
  export declare function useFog(fog: FogField): ReturnType<FogField["cells"]>;
@@ -40,8 +40,53 @@ export interface MapBounds {
40
40
  * Framed circular minimap: optional baked terrain background, reveal-on-event
41
41
  * fog overlay, categorized marker icons, and a facing arrow. Reads a core
42
42
  * `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
43
+ *
44
+ * @capability minimap framed circular minimap with terrain bake, fog, markers, and facing arrow
43
45
  */
44
46
  export declare function Minimap({ markers, center, worldRadius, fog, size, facingYaw, rotate, kindStyles, background, mapBounds, routes, zones, cellStates, onWorldClick, className, title, children, }: MinimapProps): ReactNode;
47
+ /** One dot (or, with `heading`, a rotated arrow) drawn by `MinimapChrome`. */
48
+ export interface MinimapChromeMarker {
49
+ id: string;
50
+ position: WorldXZ | readonly [number, number, number];
51
+ /** Bearing (radians) to draw an arrow instead of a dot — pass `headingToBearing(yaw)` for the player/other facing blips. */
52
+ heading?: number;
53
+ color?: string;
54
+ radius?: number;
55
+ glyph?: string;
56
+ glyphColor?: string;
57
+ strokeColor?: string;
58
+ strokeWidth?: number;
59
+ /** Clamp to the minimap edge when outside `view.worldRadius` (off-screen indicator); `false` hides it instead. Default `true`. */
60
+ clampToEdge?: boolean;
61
+ }
62
+ /** Props for `MinimapChrome`. */
63
+ export interface MinimapChromeProps {
64
+ /** Shared with `projectToMinimap`/`clampToMinimapEdge` — pass `view.rotate` to keep a rotating map's arrows pointed correctly. */
65
+ view: MinimapView;
66
+ /** Ring outline (+ `cardinalLabel`). Default `false` — most games draw their own frame/panel around the chrome. */
67
+ frame?: boolean;
68
+ frameColor?: string;
69
+ frameStrokeWidth?: number;
70
+ /** Label drawn at the top of the ring when `frame` is on; `false` omits it. */
71
+ cardinalLabel?: string | false;
72
+ markers?: readonly MinimapChromeMarker[];
73
+ markerColor?: string;
74
+ markerRadius?: number;
75
+ className?: string;
76
+ }
77
+ /**
78
+ * Headless minimap chrome — nests inside a game's own `<svg>`: an optional
79
+ * ring frame, and edge-clamped marker dots that draw as directional arrows
80
+ * when a marker carries a `heading` (the compass-arrow / player-blip layer
81
+ * ~8 games were re-drawing by hand over `projectToMinimap`/`clampToMinimapEdge`).
82
+ * Renders a bare `<g>` — no background, panel, or title; compose your own
83
+ * content layer (routes, zones, terrain) as sibling SVG nodes for full control
84
+ * of z-order, or call `MinimapChrome` twice (once for `frame`, again for
85
+ * `markers`) to sandwich custom content between the ring and the blips.
86
+ *
87
+ * @capability minimap-chrome ring frame plus edge-clamped, heading-aware marker dots to nest inside a game's own `<svg>`
88
+ */
89
+ export declare function MinimapChrome({ view, frame, frameColor, frameStrokeWidth, cardinalLabel, markers, markerColor, markerRadius, className, }: MinimapChromeProps): ReactNode;
45
90
  export interface CompassProps {
46
91
  /** Facing yaw (`rotationY`, forward = `(sin yaw, cos yaw)`) of the tracked entity — pass it raw, never pre-converted to a bearing. */
47
92
  facingYaw: number;
@@ -57,6 +102,29 @@ export interface CompassProps {
57
102
  * eight cardinals and optional marker pips (bearing to each `MarkerSet` entry).
58
103
  */
59
104
  export declare function Compass({ facingYaw, center, markers, width, fov, kindStyles, className, }: CompassProps): ReactNode;
105
+ /** Props for `MinimapPanel` — all `MinimapProps` plus the zone-label/clock header and compass slots. */
106
+ export interface MinimapPanelProps extends MinimapProps {
107
+ /** Zone/area name slot rendered in the chrome header — the game supplies its own zone lookup. */
108
+ zoneLabel?: ReactNode;
109
+ /** Clock readout slot rendered in the chrome header — pair with `useGameClock()` + `calendar()`. */
110
+ clock?: ReactNode;
111
+ /** Compass strip beneath the circle. Default true; pass `false` to omit it entirely. */
112
+ showCompass?: boolean;
113
+ compassProps?: Partial<Omit<CompassProps, "className">>;
114
+ headerClassName?: string;
115
+ zoneLabelClassName?: string;
116
+ clockClassName?: string;
117
+ compassClassName?: string;
118
+ }
119
+ /**
120
+ * Composed circular-minimap chrome: optional zone-label + clock header above
121
+ * the `Minimap`, optional `Compass` strip below. Purely a wiring layer over
122
+ * the existing primitives — the game supplies the zone name and clock text,
123
+ * this only places them; omit either slot and the header disappears.
124
+ *
125
+ * @capability minimap-panel circular minimap with zone label, clock, and compass composed together
126
+ */
127
+ export declare function MinimapPanel({ zoneLabel, clock, showCompass, compassProps, headerClassName, zoneLabelClassName, clockClassName, compassClassName, children, ...minimapProps }: MinimapPanelProps): ReactNode;
60
128
  export interface WorldMapProps {
61
129
  markers: MarkerSet;
62
130
  bounds: MapBounds;
package/dist/map.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo, useSyncExternalStore } from "react";
3
3
  import { DEFAULT_MARKER_KINDS, markerKindStyle, } from "@jgengine/core/world/markers";
4
4
  import { bearingToCardinal, clampToMinimapEdge, compassBearing, headingToBearing, projectToMinimap, relativeBearing, unprojectFromMinimap, } from "@jgengine/core/world/minimap";
@@ -78,6 +78,8 @@ export function useFog(fog) {
78
78
  * Framed circular minimap: optional baked terrain background, reveal-on-event
79
79
  * fog overlay, categorized marker icons, and a facing arrow. Reads a core
80
80
  * `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
81
+ *
82
+ * @capability minimap framed circular minimap with terrain bake, fog, markers, and facing arrow
81
83
  */
82
84
  export function Minimap({ markers, center, worldRadius, fog, size = 176, facingYaw = 0, rotate = false, kindStyles = DEFAULT_MARKER_KINDS, background, mapBounds, routes, zones, cellStates, onWorldClick, className, title = "Map", children, }) {
83
85
  const markerList = useMarkers(markers);
@@ -162,6 +164,36 @@ export function Minimap({ markers, center, worldRadius, fog, size = 176, facingY
162
164
  return (_jsxs("g", { "data-marker-kind": marker.kind, children: [_jsx("circle", { cx: at.x, cy: at.y, r: 7, fill: "rgba(2,6,12,0.55)" }), _jsx("text", { x: at.x, y: at.y + 3.5, textAnchor: "middle", fontSize: 10, fill: style.color, style: { fontWeight: 700 }, children: style.glyph })] }, marker.id));
163
165
  }), _jsx("g", { transform: `translate(${half} ${half}) rotate(${(rotate ? 0 : bearing) * (180 / Math.PI)})`, children: _jsx("path", { d: "M0,-9 L6,7 L0,3 L-6,7 Z", fill: "#4ade80", stroke: "#052e16", strokeWidth: 0.75 }) })] }), _jsx("circle", { cx: half, cy: half, r: half - 1, fill: "none", stroke: "rgba(148,163,184,0.4)", strokeWidth: 1.5 }), _jsx("text", { x: half, y: 13, textAnchor: "middle", fontSize: 11, fill: "rgba(226,232,240,0.9)", style: { fontWeight: 700 }, children: "N" })] }), children] }));
164
166
  }
167
+ /**
168
+ * Headless minimap chrome — nests inside a game's own `<svg>`: an optional
169
+ * ring frame, and edge-clamped marker dots that draw as directional arrows
170
+ * when a marker carries a `heading` (the compass-arrow / player-blip layer
171
+ * ~8 games were re-drawing by hand over `projectToMinimap`/`clampToMinimapEdge`).
172
+ * Renders a bare `<g>` — no background, panel, or title; compose your own
173
+ * content layer (routes, zones, terrain) as sibling SVG nodes for full control
174
+ * of z-order, or call `MinimapChrome` twice (once for `frame`, again for
175
+ * `markers`) to sandwich custom content between the ring and the blips.
176
+ *
177
+ * @capability minimap-chrome ring frame plus edge-clamped, heading-aware marker dots to nest inside a game's own `<svg>`
178
+ */
179
+ export function MinimapChrome({ view, frame = false, frameColor = "rgba(148,163,184,0.4)", frameStrokeWidth = 1.5, cardinalLabel = "N", markers = [], markerColor = "#e2e8f0", markerRadius = 5, className, }) {
180
+ const half = view.size / 2;
181
+ const rotate = view.rotate ?? 0;
182
+ return (_jsxs("g", { className: className, "data-minimap-chrome": true, children: [frame ? (_jsxs(_Fragment, { children: [_jsx("circle", { cx: half, cy: half, r: half - 1, fill: "none", stroke: frameColor, strokeWidth: frameStrokeWidth }), cardinalLabel !== false ? (_jsx("text", { x: half, y: 13, textAnchor: "middle", fontSize: 11, fill: frameColor, style: { fontWeight: 700 }, children: cardinalLabel })) : null] })) : null, markers.map((marker) => {
183
+ const projected = projectToMinimap(marker.position, view);
184
+ const clamp = marker.clampToEdge ?? true;
185
+ if (!projected.inside && !clamp)
186
+ return null;
187
+ const at = projected.inside ? projected : clampToMinimapEdge(projected, view.size);
188
+ const color = marker.color ?? markerColor;
189
+ const radius = marker.radius ?? markerRadius;
190
+ if (marker.heading !== undefined) {
191
+ const deg = (marker.heading - rotate) * (180 / Math.PI);
192
+ return (_jsx("g", { "data-minimap-marker": marker.id, transform: `translate(${at.x} ${at.y}) rotate(${deg})`, children: _jsx("path", { d: `M0,${-radius} L${radius * 0.7},${radius * 0.8} L0,${radius * 0.35} L${-radius * 0.7},${radius * 0.8} Z`, fill: color, stroke: marker.strokeColor ?? "rgba(0,0,0,0.6)", strokeWidth: marker.strokeWidth ?? 0.75 }) }, marker.id));
193
+ }
194
+ return (_jsxs("g", { "data-minimap-marker": marker.id, children: [_jsx("circle", { cx: at.x, cy: at.y, r: radius, fill: color, stroke: marker.strokeColor, strokeWidth: marker.strokeWidth }), marker.glyph !== undefined ? (_jsx("text", { x: at.x, y: at.y + 3.5, textAnchor: "middle", fontSize: 10, fill: marker.glyphColor ?? "#0f172a", style: { fontWeight: 700 }, children: marker.glyph })) : null] }, marker.id));
195
+ })] }));
196
+ }
165
197
  const NO_SUBSCRIBE = () => () => undefined;
166
198
  const NULL_CELLS = () => null;
167
199
  const EMPTY_MARKERS_VALUE = [];
@@ -240,6 +272,27 @@ export function Compass({ facingYaw, center, markers, width = 340, fov = (Math.P
240
272
  })
241
273
  : null] }));
242
274
  }
275
+ /**
276
+ * Composed circular-minimap chrome: optional zone-label + clock header above
277
+ * the `Minimap`, optional `Compass` strip below. Purely a wiring layer over
278
+ * the existing primitives — the game supplies the zone name and clock text,
279
+ * this only places them; omit either slot and the header disappears.
280
+ *
281
+ * @capability minimap-panel circular minimap with zone label, clock, and compass composed together
282
+ */
283
+ export function MinimapPanel({ zoneLabel, clock, showCompass = true, compassProps, headerClassName, zoneLabelClassName, clockClassName, compassClassName, children, ...minimapProps }) {
284
+ const hasHeader = zoneLabel !== undefined || clock !== undefined;
285
+ return (_jsxs("div", { "data-minimap-chrome": true, children: [hasHeader ? (_jsxs("div", { className: headerClassName, "data-minimap-chrome-header": true, style: {
286
+ display: "flex",
287
+ justifyContent: "space-between",
288
+ alignItems: "baseline",
289
+ gap: 8,
290
+ marginBottom: 4,
291
+ width: minimapProps.size ?? 176,
292
+ color: "#e2e8f0",
293
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
294
+ }, children: [zoneLabel !== undefined ? (_jsx("span", { className: zoneLabelClassName, "data-minimap-chrome-zone": true, style: { fontSize: 12, fontWeight: 700 }, children: zoneLabel })) : (_jsx("span", {})), clock !== undefined ? (_jsx("span", { className: clockClassName, "data-minimap-chrome-clock": true, style: { fontSize: 11, color: "rgba(226,232,240,0.75)" }, children: clock })) : null] })) : null, _jsx(Minimap, { ...minimapProps, children: children }), showCompass ? (_jsx("div", { className: compassClassName, "data-minimap-chrome-compass": true, style: { marginTop: 6 }, children: _jsx(Compass, { facingYaw: minimapProps.facingYaw ?? 0, center: minimapProps.center, markers: minimapProps.markers, width: minimapProps.size ?? 176, ...compassProps }) })) : null] }));
295
+ }
243
296
  function WorldMapFogImage({ fogCells, project, worldW, worldD, width, mapH, }) {
244
297
  const fogImage = useMemo(() => {
245
298
  if (fogCells === null)
package/dist/save.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { SaveStatus, SaveStore } from "@jgengine/core/game/saveStore";
2
+ /** The reactive slice of a {@link SaveStore} returned by {@link useSaveStore} — the live value plus bound save actions, all re-rendering on change. */
3
+ export interface SaveStoreView<T> {
4
+ value: T;
5
+ status: SaveStatus;
6
+ slot: string;
7
+ /** True while the initial `load()` is in flight — gate a loading screen on it. */
8
+ loading: boolean;
9
+ /** True while a write is in flight — show a "Saving…" indicator. */
10
+ saving: boolean;
11
+ set: (value: T) => void;
12
+ patch: (mutate: (previous: T) => T) => void;
13
+ save: () => Promise<void>;
14
+ clear: () => Promise<void>;
15
+ }
16
+ /**
17
+ * Bind a {@link SaveStore} to React: returns the live `value` and `status` and
18
+ * re-renders whenever either changes. By default it calls `load()` once on
19
+ * mount (pass `{ load: false }` to hydrate yourself). The same hook works for
20
+ * offline (localStorage) and cloud (Convex/DB) saves — only the store's backend
21
+ * differs.
22
+ */
23
+ export declare function useSaveStore<T>(store: SaveStore<T>, options?: {
24
+ load?: boolean;
25
+ }): SaveStoreView<T>;
package/dist/save.js ADDED
@@ -0,0 +1,37 @@
1
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
2
+ /**
3
+ * Bind a {@link SaveStore} to React: returns the live `value` and `status` and
4
+ * re-renders whenever either changes. By default it calls `load()` once on
5
+ * mount (pass `{ load: false }` to hydrate yourself). The same hook works for
6
+ * offline (localStorage) and cloud (Convex/DB) saves — only the store's backend
7
+ * differs.
8
+ */
9
+ export function useSaveStore(store, options) {
10
+ useSyncExternalStore(store.subscribe, store.revision, store.revision);
11
+ const shouldLoad = options?.load !== false;
12
+ const loadedFor = useRef(null);
13
+ useEffect(() => {
14
+ if (!shouldLoad)
15
+ return;
16
+ if (loadedFor.current === store)
17
+ return;
18
+ loadedFor.current = store;
19
+ void store.load();
20
+ }, [store, shouldLoad]);
21
+ const set = useCallback((value) => store.set(value), [store]);
22
+ const patch = useCallback((mutate) => void store.patch(mutate), [store]);
23
+ const save = useCallback(() => store.save(), [store]);
24
+ const clear = useCallback(() => store.clear(), [store]);
25
+ const status = store.status();
26
+ return {
27
+ value: store.value(),
28
+ status,
29
+ slot: store.slot(),
30
+ loading: status === "loading",
31
+ saving: status === "saving",
32
+ set,
33
+ patch,
34
+ save,
35
+ clear,
36
+ };
37
+ }
@@ -3,5 +3,7 @@ export type SelectCache<TSnapshot, TSelected> = {
3
3
  snapshot: TSnapshot;
4
4
  value: TSelected;
5
5
  };
6
+ /** @internal */
6
7
  export declare function createSelectCache<TSnapshot, TSelected>(): SelectCache<TSnapshot, TSelected>;
8
+ /** @internal */
7
9
  export declare function readSelectSnapshot<TSnapshot, TSelected>(cache: SelectCache<TSnapshot, TSelected>, snapshot: TSnapshot, select: (snapshot: TSnapshot) => TSelected, isEqual?: (previous: TSelected, next: TSelected) => boolean): TSelected;