@jgengine/react 0.13.0 → 0.15.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +529 -1
  2. package/dist/accessibility.d.ts +39 -0
  3. package/dist/accessibility.js +61 -0
  4. package/dist/achievements.d.ts +50 -0
  5. package/dist/achievements.js +94 -0
  6. package/dist/cameraShake.d.ts +52 -0
  7. package/dist/cameraShake.js +53 -0
  8. package/dist/coachMarks.d.ts +72 -0
  9. package/dist/coachMarks.js +136 -0
  10. package/dist/codex.d.ts +25 -0
  11. package/dist/codex.js +62 -0
  12. package/dist/comboMeter.d.ts +63 -0
  13. package/dist/comboMeter.js +93 -0
  14. package/dist/components.js +4 -3
  15. package/dist/creditsScreen.d.ts +19 -0
  16. package/dist/creditsScreen.js +31 -0
  17. package/dist/cutscene.d.ts +66 -0
  18. package/dist/cutscene.js +132 -0
  19. package/dist/damageDirection.d.ts +51 -0
  20. package/dist/damageDirection.js +103 -0
  21. package/dist/dialogueView.d.ts +69 -0
  22. package/dist/dialogueView.js +111 -0
  23. package/dist/entityFrames.d.ts +114 -0
  24. package/dist/entityFrames.js +55 -0
  25. package/dist/fastTravel.d.ts +36 -0
  26. package/dist/fastTravel.js +103 -0
  27. package/dist/floatingText.d.ts +84 -0
  28. package/dist/floatingText.js +61 -0
  29. package/dist/gameViewport.js +8 -12
  30. package/dist/hooks.d.ts +42 -0
  31. package/dist/hooks.js +85 -0
  32. package/dist/hudLayout.js +138 -9
  33. package/dist/hudTheme.d.ts +104 -7
  34. package/dist/hudTheme.js +300 -2
  35. package/dist/i18n.d.ts +29 -0
  36. package/dist/i18n.js +44 -0
  37. package/dist/index.d.ts +34 -0
  38. package/dist/index.js +34 -0
  39. package/dist/interactionPrompt.d.ts +80 -0
  40. package/dist/interactionPrompt.js +112 -0
  41. package/dist/keybindingMenu.d.ts +59 -0
  42. package/dist/keybindingMenu.js +146 -0
  43. package/dist/killFeed.d.ts +63 -0
  44. package/dist/killFeed.js +88 -0
  45. package/dist/map.d.ts +123 -2
  46. package/dist/map.js +239 -36
  47. package/dist/menuRouter.d.ts +51 -0
  48. package/dist/menuRouter.js +49 -0
  49. package/dist/modals.d.ts +131 -0
  50. package/dist/modals.js +168 -0
  51. package/dist/notifications.d.ts +39 -0
  52. package/dist/notifications.js +111 -0
  53. package/dist/objectiveBanner.d.ts +62 -0
  54. package/dist/objectiveBanner.js +129 -0
  55. package/dist/panels.d.ts +10 -2
  56. package/dist/panels.js +9 -3
  57. package/dist/photoMode.d.ts +21 -0
  58. package/dist/photoMode.js +39 -0
  59. package/dist/previewFixtures.d.ts +30 -0
  60. package/dist/previewFixtures.js +46 -0
  61. package/dist/provider.d.ts +15 -0
  62. package/dist/provider.js +17 -1
  63. package/dist/questTracker.d.ts +24 -0
  64. package/dist/questTracker.js +31 -0
  65. package/dist/quickMenu.d.ts +39 -0
  66. package/dist/quickMenu.js +79 -0
  67. package/dist/radialMenu.d.ts +50 -0
  68. package/dist/radialMenu.js +69 -0
  69. package/dist/saveSlots.d.ts +81 -0
  70. package/dist/saveSlots.js +138 -0
  71. package/dist/scoreboard.d.ts +67 -0
  72. package/dist/scoreboard.js +71 -0
  73. package/dist/settings.d.ts +9 -3
  74. package/dist/settings.js +10 -4
  75. package/dist/shopGrid.d.ts +70 -0
  76. package/dist/shopGrid.js +129 -0
  77. package/dist/statusEffectBar.d.ts +52 -0
  78. package/dist/statusEffectBar.js +57 -0
  79. package/dist/streetGeometryPreview.d.ts +7 -0
  80. package/dist/streetGeometryPreview.js +119 -0
  81. package/dist/talentTree.d.ts +60 -0
  82. package/dist/talentTree.js +217 -0
  83. package/dist/timerReadout.d.ts +90 -0
  84. package/dist/timerReadout.js +111 -0
  85. package/dist/useDebouncedCommit.d.ts +54 -0
  86. package/dist/useDebouncedCommit.js +81 -0
  87. package/dist/waveHud.d.ts +49 -0
  88. package/dist/waveHud.js +53 -0
  89. package/dist/waypointMarkers.d.ts +80 -0
  90. package/dist/waypointMarkers.js +116 -0
  91. package/package.json +3 -3
  92. package/skills/ce-handoff/SKILL.md +149 -0
  93. package/skills/jgengine/SKILL.md +7 -3
  94. package/skills/jgengine/api.md +17 -7
  95. package/skills/jgengine/capabilities.md +12 -0
  96. package/skills/jgengine/recipes/minimal-game.md +7 -4
  97. package/skills/jgengine-ui/SKILL.md +8 -3
  98. package/skills/jgengine-ui/api.md +732 -23
  99. package/skills/jgengine-ui/capabilities.md +371 -2
  100. package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
  101. package/skills/jgengine-ui/reference.md +7 -4
package/dist/map.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useSyncExternalStore } from "react";
2
+ import { useMemo, useRef, useState, 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";
5
5
  import { mapLayerColor, } from "@jgengine/core/world/mapLayers";
@@ -376,21 +376,66 @@ function WorldMapFogImage({ fogCells, project, worldW, worldD, width, mapH, }) {
376
376
  return null;
377
377
  return _jsx("image", { href: fogImage, x: 0, y: 0, width: width, height: mapH, preserveAspectRatio: "none" });
378
378
  }
379
+ const IDENTITY_VIEWPORT = { scale: 1, tx: 0, ty: 0 };
379
380
  /**
380
- * Full-bounds top-down world map (the "press M" overlay): baked terrain
381
- * background, reveal-on-event fog, all markers with labels, and the player.
382
- * Rectangular linear projection over the supplied world `bounds`.
381
+ * The bare top-down map `<svg>` shared by {@link WorldMap} (framed panel) and
382
+ * {@link FullscreenMap} (pan/zoom overlay): baked terrain background, fog, map
383
+ * layers, markers with labels, and the player arrow, drawn under an optional
384
+ * viewport transform. Rectangular linear projection over the world `bounds`.
383
385
  */
384
- export function WorldMap({ markers, bounds, player, facingYaw = 0, fog, background, width = 520, height, kindStyles = DEFAULT_MARKER_KINDS, routes, zones, cellStates, onWorldClick, className, title = "World Map", onClose, }) {
386
+ export function WorldMapSurface({ markers, bounds, player, facingYaw = 0, fog, background, width = 520, height, kindStyles = DEFAULT_MARKER_KINDS, routes, zones, cellStates, onWorldClick, canvasWidth, canvasHeight, viewport = IDENTITY_VIEWPORT, style, }) {
385
387
  const markerList = useMarkers(markers);
386
388
  const fogCells = useSyncExternalStore(fog?.subscribe ?? NO_SUBSCRIBE, fog?.cells ?? NULL_CELLS, fog?.cells ?? NULL_CELLS);
387
389
  const worldW = bounds.maxX - bounds.minX;
388
390
  const worldD = bounds.maxZ - bounds.minZ;
389
391
  const mapH = height ?? Math.round((width * worldD) / worldW);
392
+ const svgW = canvasWidth ?? width;
393
+ const svgH = canvasHeight ?? mapH;
390
394
  const project = (x, z) => ({
391
395
  x: ((x - bounds.minX) / worldW) * width,
392
396
  y: ((z - bounds.minZ) / worldD) * mapH,
393
397
  });
398
+ return (_jsx("svg", { width: svgW, height: svgH, viewBox: `0 0 ${svgW} ${svgH}`, "data-world-map-canvas": true, style: { borderRadius: 10, ...(onWorldClick === undefined ? {} : { cursor: "crosshair" }), ...style }, ...(onWorldClick === undefined
399
+ ? {}
400
+ : {
401
+ onClick: (event) => {
402
+ const rect = event.currentTarget.getBoundingClientRect();
403
+ const sx = ((event.clientX - rect.left) / rect.width) * svgW;
404
+ const sy = ((event.clientY - rect.top) / rect.height) * svgH;
405
+ const px = (sx - viewport.tx) / viewport.scale;
406
+ const py = (sy - viewport.ty) / viewport.scale;
407
+ onWorldClick([bounds.minX + (px / width) * worldW, bounds.minZ + (py / mapH) * worldD]);
408
+ },
409
+ }), children: _jsxs("g", { transform: `translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`, "data-world-map-content": true, children: [_jsx("rect", { x: 0, y: 0, width: width, height: mapH, fill: "#16202b" }), background !== undefined ? (_jsx("image", { href: background, x: 0, y: 0, width: width, height: mapH, preserveAspectRatio: "none", opacity: 0.95 })) : null, _jsx(WorldMapFogImage, { fogCells: fogCells, project: project, worldW: worldW, worldD: worldD, width: width, mapH: mapH }), (() => {
410
+ const projectXZ = (x, z) => project(x, z);
411
+ const layerScale = {
412
+ x: (units) => (units / worldW) * width,
413
+ y: (units) => (units / worldD) * mapH,
414
+ };
415
+ return [
416
+ ...cellStateNodes(cellStates, projectXZ, layerScale, "wm"),
417
+ ...zoneNodes(zones, projectXZ, layerScale, "wm"),
418
+ ...routeNodes(routes, projectXZ, "wm"),
419
+ ];
420
+ })(), markerList.map((marker) => {
421
+ const at = project(marker.position[0], marker.position[2]);
422
+ const kind = marker.kind ?? "marker";
423
+ const markerStyle = markerKindStyle(kind, kindStyles);
424
+ return (_jsxs("g", { "data-world-marker": kind, children: [_jsx("circle", { cx: at.x, cy: at.y, r: 9, fill: "rgba(2,6,12,0.6)", stroke: markerStyle.color, strokeWidth: 1.2 }), _jsx("text", { x: at.x, y: at.y + 4, textAnchor: "middle", fontSize: 12, fill: markerStyle.color, style: { fontWeight: 700 }, children: markerStyle.glyph }), marker.label !== undefined ? (_jsx("text", { x: at.x + 12, y: at.y + 4, fontSize: 11, fill: "rgba(226,232,240,0.85)", children: marker.label })) : null] }, marker.id));
425
+ }), player !== undefined
426
+ ? (() => {
427
+ const at = project(player[0], player[1]);
428
+ return (_jsx("g", { transform: `translate(${at.x} ${at.y}) rotate(${headingToBearing(facingYaw) * (180 / Math.PI)})`, children: _jsx("path", { d: "M0,-11 L7,9 L0,4 L-7,9 Z", fill: "#4ade80", stroke: "#052e16", strokeWidth: 1 }) }));
429
+ })()
430
+ : null] }) }));
431
+ }
432
+ /**
433
+ * Full-bounds top-down world map (the "press M" overlay): baked terrain
434
+ * background, reveal-on-event fog, all markers with labels, and the player.
435
+ * Rectangular linear projection over the supplied world `bounds`. Framed panel
436
+ * wrapper over {@link WorldMapSurface}.
437
+ */
438
+ export function WorldMap({ markers, bounds, player, facingYaw = 0, fog, background, width = 520, height, kindStyles = DEFAULT_MARKER_KINDS, routes, zones, cellStates, onWorldClick, className, title = "World Map", onClose, }) {
394
439
  return (_jsxs("div", { className: className, "data-world-map": true, style: {
395
440
  borderRadius: 16,
396
441
  padding: 14,
@@ -407,35 +452,193 @@ export function WorldMap({ markers, bounds, player, facingYaw = 0, fog, backgrou
407
452
  fontSize: 11,
408
453
  padding: "3px 9px",
409
454
  cursor: "pointer",
410
- }, children: "Close" })) : null] }), _jsxs("svg", { width: width, height: mapH, viewBox: `0 0 ${width} ${mapH}`, "data-world-map-canvas": true, style: { borderRadius: 10, ...(onWorldClick === undefined ? {} : { cursor: "crosshair" }) }, ...(onWorldClick === undefined
411
- ? {}
412
- : {
413
- onClick: (event) => {
414
- const rect = event.currentTarget.getBoundingClientRect();
415
- const px = ((event.clientX - rect.left) / rect.width) * width;
416
- const py = ((event.clientY - rect.top) / rect.height) * mapH;
417
- onWorldClick([bounds.minX + (px / width) * worldW, bounds.minZ + (py / mapH) * worldD]);
418
- },
419
- }), children: [_jsx("rect", { x: 0, y: 0, width: width, height: mapH, fill: "#16202b" }), background !== undefined ? (_jsx("image", { href: background, x: 0, y: 0, width: width, height: mapH, preserveAspectRatio: "none", opacity: 0.95 })) : null, _jsx(WorldMapFogImage, { fogCells: fogCells, project: project, worldW: worldW, worldD: worldD, width: width, mapH: mapH }), (() => {
420
- const projectXZ = (x, z) => project(x, z);
421
- const layerScale = {
422
- x: (units) => (units / worldW) * width,
423
- y: (units) => (units / worldD) * mapH,
424
- };
425
- return [
426
- ...cellStateNodes(cellStates, projectXZ, layerScale, "wm"),
427
- ...zoneNodes(zones, projectXZ, layerScale, "wm"),
428
- ...routeNodes(routes, projectXZ, "wm"),
429
- ];
430
- })(), markerList.map((marker) => {
431
- const at = project(marker.position[0], marker.position[2]);
432
- const kind = marker.kind ?? "marker";
433
- const style = markerKindStyle(kind, kindStyles);
434
- return (_jsxs("g", { "data-world-marker": kind, children: [_jsx("circle", { cx: at.x, cy: at.y, r: 9, fill: "rgba(2,6,12,0.6)", stroke: style.color, strokeWidth: 1.2 }), _jsx("text", { x: at.x, y: at.y + 4, textAnchor: "middle", fontSize: 12, fill: style.color, style: { fontWeight: 700 }, children: style.glyph }), marker.label !== undefined ? (_jsx("text", { x: at.x + 12, y: at.y + 4, fontSize: 11, fill: "rgba(226,232,240,0.85)", children: marker.label })) : null] }, marker.id));
435
- }), player !== undefined
436
- ? (() => {
437
- const at = project(player[0], player[1]);
438
- return (_jsx("g", { transform: `translate(${at.x} ${at.y}) rotate(${headingToBearing(facingYaw) * (180 / Math.PI)})`, children: _jsx("path", { d: "M0,-11 L7,9 L0,4 L-7,9 Z", fill: "#4ade80", stroke: "#052e16", strokeWidth: 1 }) }));
439
- })()
440
- : null] })] }));
455
+ }, children: "Close" })) : null] }), _jsx(WorldMapSurface, { markers: markers, bounds: bounds, player: player, facingYaw: facingYaw, fog: fog, background: background, width: width, height: height, kindStyles: kindStyles, routes: routes, zones: zones, cellStates: cellStates, onWorldClick: onWorldClick })] }));
456
+ }
457
+ function clampScale(value, min, max) {
458
+ return Math.max(min, Math.min(max, value));
459
+ }
460
+ /**
461
+ * Fullscreen pan/zoom world-map overlay — the enlarged "press M" map. Wheel to
462
+ * zoom (toward the cursor), drag to pan, and click to place (wire `onWorldClick`
463
+ * to a waypoint/annotation store). Switch `tool` to `"draw"` to freehand-draw
464
+ * strokes committed to `onStrokeComplete` (wire to `createAnnotationLayer`).
465
+ * Reuses {@link WorldMapSurface} for rendering, so terrain bake, fog, routes,
466
+ * zones, markers, and the player arrow all appear; a drag never fires
467
+ * `onWorldClick`. Compose a {@link MapLegend} or tool palette via `children`.
468
+ *
469
+ * @capability fullscreen-map fullscreen pan/zoom world-map overlay over WorldMapSurface — wheel-zoom, drag-pan, click-to-place, and a freehand draw tool (onStrokeComplete)
470
+ */
471
+ export function FullscreenMap({ open = true, bounds, contentWidth = 900, minScale = 0.5, maxScale = 8, title = "Map", onClose, onWorldClick, tool = "pan", onStrokeComplete, drawTone = "info", drawWidth = 3, children, overlayClassName, overlayStyle, ...surfaceProps }) {
472
+ const worldW = bounds.maxX - bounds.minX;
473
+ const worldD = bounds.maxZ - bounds.minZ;
474
+ const contentHeight = Math.round((contentWidth * worldD) / worldW);
475
+ const [viewport, setViewport] = useState(IDENTITY_VIEWPORT);
476
+ const drag = useRef(null);
477
+ const moved = useRef(false);
478
+ const draftRef = useRef(null);
479
+ const [draft, setDraft] = useState(null);
480
+ const drawing = tool === "draw";
481
+ const minSpacing = (worldW + worldD) / 400;
482
+ if (!open)
483
+ return null;
484
+ const zoomBy = (factor, sx, sy) => {
485
+ setViewport((prev) => {
486
+ const next = clampScale(prev.scale * factor, minScale, maxScale);
487
+ const k = next / prev.scale;
488
+ return { scale: next, tx: sx - k * (sx - prev.tx), ty: sy - k * (sy - prev.ty) };
489
+ });
490
+ };
491
+ const canvasPoint = (event) => {
492
+ const rect = event.currentTarget.getBoundingClientRect();
493
+ const kx = contentWidth / rect.width;
494
+ const ky = contentHeight / rect.height;
495
+ return { x: (event.clientX - rect.left) * kx, y: (event.clientY - rect.top) * ky, kx, ky };
496
+ };
497
+ const screenToWorld = (event) => {
498
+ const at = canvasPoint(event);
499
+ const cx = (at.x - viewport.tx) / viewport.scale;
500
+ const cy = (at.y - viewport.ty) / viewport.scale;
501
+ return [bounds.minX + (cx / contentWidth) * worldW, bounds.minZ + (cy / contentHeight) * worldD];
502
+ };
503
+ const pushDraftPoint = (point) => {
504
+ const points = draftRef.current ?? [];
505
+ const last = points[points.length - 1];
506
+ if (last !== undefined && Math.hypot(last[0] - point[0], last[1] - point[1]) < minSpacing)
507
+ return;
508
+ draftRef.current = [...points, point];
509
+ setDraft(draftRef.current);
510
+ };
511
+ const commitDraft = () => {
512
+ const points = draftRef.current;
513
+ draftRef.current = null;
514
+ setDraft(null);
515
+ if (points !== null && points.length >= 2)
516
+ onStrokeComplete?.(points);
517
+ };
518
+ const draftRoute = draft !== null && draft.length >= 2 ? { id: "__draft-stroke", points: draft, tone: drawTone, width: drawWidth } : null;
519
+ const forwardedRoutes = draftRoute === null ? surfaceProps.routes : [...(surfaceProps.routes ?? []), draftRoute];
520
+ const controlButton = {
521
+ width: 30,
522
+ height: 30,
523
+ borderRadius: 8,
524
+ border: "1px solid rgba(148,163,184,0.4)",
525
+ background: "rgba(17,22,30,0.85)",
526
+ color: "#e2e8f0",
527
+ fontSize: 16,
528
+ lineHeight: "1",
529
+ cursor: "pointer",
530
+ };
531
+ return (_jsxs("div", { className: overlayClassName, "data-fullscreen-map": true, style: {
532
+ position: "fixed",
533
+ inset: 0,
534
+ zIndex: 40,
535
+ display: "flex",
536
+ flexDirection: "column",
537
+ background: "rgba(6,9,14,0.82)",
538
+ backdropFilter: "blur(2px)",
539
+ color: "#e2e8f0",
540
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
541
+ ...overlayStyle,
542
+ }, children: [_jsxs("div", { style: {
543
+ display: "flex",
544
+ alignItems: "center",
545
+ justifyContent: "space-between",
546
+ padding: "10px 16px",
547
+ borderBottom: "1px solid rgba(148,163,184,0.2)",
548
+ }, children: [_jsx("span", { style: { fontSize: 13, fontWeight: 700, letterSpacing: 1.6, textTransform: "uppercase" }, children: title }), _jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center" }, children: [_jsx("button", { type: "button", "aria-label": "Zoom out", style: controlButton, onClick: () => zoomBy(1 / 1.2, contentWidth / 2, contentHeight / 2), children: "\u2212" }), _jsx("button", { type: "button", "aria-label": "Zoom in", style: controlButton, onClick: () => zoomBy(1.2, contentWidth / 2, contentHeight / 2), children: "+" }), _jsx("button", { type: "button", "aria-label": "Reset view", style: { ...controlButton, width: "auto", padding: "0 10px", fontSize: 11 }, onClick: () => setViewport(IDENTITY_VIEWPORT), children: "Reset" }), onClose !== undefined ? (_jsx("button", { type: "button", "aria-label": "Close map", style: { ...controlButton, width: "auto", padding: "0 12px", fontSize: 11 }, onClick: onClose, children: "Close" })) : null] })] }), _jsxs("div", { "data-fullscreen-map-viewport": true, "data-map-tool": tool, style: {
549
+ position: "relative",
550
+ flex: 1,
551
+ minHeight: 0,
552
+ overflow: "hidden",
553
+ touchAction: "none",
554
+ cursor: drawing ? "crosshair" : drag.current !== null ? "grabbing" : "grab",
555
+ }, onWheel: (event) => {
556
+ const at = canvasPoint(event);
557
+ zoomBy(event.deltaY < 0 ? 1.15 : 1 / 1.15, at.x, at.y);
558
+ }, onPointerDown: (event) => {
559
+ if (event.button !== 0)
560
+ return;
561
+ event.currentTarget.setPointerCapture(event.pointerId);
562
+ if (drawing) {
563
+ draftRef.current = [screenToWorld(event)];
564
+ setDraft(draftRef.current);
565
+ return;
566
+ }
567
+ drag.current = { x: event.clientX, y: event.clientY, vp: viewport };
568
+ moved.current = false;
569
+ }, onPointerMove: (event) => {
570
+ if (drawing) {
571
+ if (draftRef.current !== null)
572
+ pushDraftPoint(screenToWorld(event));
573
+ return;
574
+ }
575
+ const state = drag.current;
576
+ if (state === null)
577
+ return;
578
+ const rect = event.currentTarget.getBoundingClientRect();
579
+ const kx = contentWidth / rect.width;
580
+ const ky = contentHeight / rect.height;
581
+ const dx = (event.clientX - state.x) * kx;
582
+ const dy = (event.clientY - state.y) * ky;
583
+ if (Math.abs(dx) + Math.abs(dy) > 3)
584
+ moved.current = true;
585
+ setViewport({ scale: state.vp.scale, tx: state.vp.tx + dx, ty: state.vp.ty + dy });
586
+ }, onPointerUp: (event) => {
587
+ if (event.currentTarget.hasPointerCapture(event.pointerId))
588
+ event.currentTarget.releasePointerCapture(event.pointerId);
589
+ if (drawing) {
590
+ commitDraft();
591
+ return;
592
+ }
593
+ drag.current = null;
594
+ }, children: [_jsx(WorldMapSurface, { ...surfaceProps, routes: forwardedRoutes, bounds: bounds, width: contentWidth, height: contentHeight, canvasWidth: contentWidth, canvasHeight: contentHeight, viewport: viewport, style: { width: "100%", height: "100%", borderRadius: 0, display: "block" }, onWorldClick: onWorldClick === undefined || drawing
595
+ ? undefined
596
+ : (world) => {
597
+ if (moved.current) {
598
+ moved.current = false;
599
+ return;
600
+ }
601
+ onWorldClick(world);
602
+ } }), children] })] }));
603
+ }
604
+ /**
605
+ * Marker-kind key for a map/minimap — glyph + color swatch per kind, labelled.
606
+ * Reads the same `kindStyles` the map renders with, so the legend can never
607
+ * drift from the pins.
608
+ *
609
+ * @capability map-legend marker-kind key (glyph + color + label per kind) sharing the map's kindStyles
610
+ */
611
+ export function MapLegend({ kinds, labels, kindStyles = DEFAULT_MARKER_KINDS, title = "Legend", className, style, }) {
612
+ return (_jsxs("div", { className: className, "data-map-legend": true, style: {
613
+ borderRadius: 10,
614
+ padding: "8px 10px",
615
+ background: "rgba(12,16,22,0.86)",
616
+ border: "1px solid rgba(148,163,184,0.28)",
617
+ color: "#e2e8f0",
618
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
619
+ fontSize: 11,
620
+ ...style,
621
+ }, children: [title !== "" ? (_jsx("div", { style: { fontSize: 9, letterSpacing: 1.4, textTransform: "uppercase", color: "rgba(203,213,225,0.7)", marginBottom: 5 }, children: title })) : null, _jsx("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 3 }, children: kinds.map((kind) => {
622
+ const kindStyle = markerKindStyle(kind, kindStyles);
623
+ return (_jsxs("li", { "data-legend-kind": kind, style: { display: "flex", alignItems: "center", gap: 7 }, children: [_jsx("span", { "aria-hidden": true, style: { width: 14, textAlign: "center", color: kindStyle.color, fontWeight: 700 }, children: kindStyle.glyph }), _jsx("span", { children: labels?.[kind] ?? kind })] }, kind));
624
+ }) })] }));
625
+ }
626
+ /**
627
+ * On-screen guide arrow to the tracked waypoint — a HUD compass needle rotated
628
+ * by the facing-relative bearing, with an optional label and distance readout.
629
+ * Pair with `WaypointStore.guidance(playerXZ, facingYaw)`.
630
+ *
631
+ * @capability waypoint-arrow on-screen HUD guide arrow to a tracked waypoint, rotated by facing-relative bearing with a distance readout
632
+ */
633
+ export function WaypointArrow({ relative, distance, label, formatDistance = (value) => `${Math.round(value)}m`, size = 44, color = "#f59e0b", className, style, }) {
634
+ return (_jsxs("div", { className: className, "data-waypoint-arrow": true, style: {
635
+ display: "inline-flex",
636
+ flexDirection: "column",
637
+ alignItems: "center",
638
+ gap: 2,
639
+ color,
640
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
641
+ textShadow: "0 1px 2px rgba(0,0,0,0.9)",
642
+ ...style,
643
+ }, children: [_jsx("svg", { width: size, height: size, viewBox: "0 0 44 44", "aria-hidden": true, "data-waypoint-arrow-needle": true, children: _jsx("g", { transform: `rotate(${(relative * 180) / Math.PI} 22 22)`, children: _jsx("path", { d: "M22 6 L32 34 L22 27 L12 34 Z", fill: color, stroke: "rgba(0,0,0,0.7)", strokeWidth: 1.2, strokeLinejoin: "round" }) }) }), label !== undefined ? (_jsx("span", { style: { fontSize: 10, fontWeight: 700, letterSpacing: 0.6, textTransform: "uppercase" }, children: label })) : null, distance !== undefined ? (_jsx("span", { style: { fontSize: 11, fontVariantNumeric: "tabular-nums", color: "rgba(226,232,240,0.9)" }, children: formatDistance(distance) })) : null] }));
441
644
  }
@@ -0,0 +1,51 @@
1
+ /** A screen-stack transition. `reset` without an id collapses to the router's original root. */
2
+ export type MenuStackAction<Id extends string> = {
3
+ type: "open";
4
+ id: Id;
5
+ } | {
6
+ type: "back";
7
+ } | {
8
+ type: "replace";
9
+ id: Id;
10
+ } | {
11
+ type: "reset";
12
+ id?: Id;
13
+ };
14
+ /**
15
+ * The pure screen-stack transition behind {@link useMenuRouter}. Exported so a game can drive the
16
+ * same navigation from its own store or a replayable command log instead of React state.
17
+ *
18
+ * @capability menu-stack-reducer pure push/back/replace/reset transitions for a menu screen stack
19
+ */
20
+ export declare function menuStackReducer<Id extends string>(path: readonly Id[], action: MenuStackAction<Id>, root: Id): readonly Id[];
21
+ /** A menu screen stack: where you are, how you got there, and how to go back. */
22
+ export interface MenuRouter<Id extends string> {
23
+ /** The screen on top of the stack. */
24
+ current: Id;
25
+ /** The whole stack, root first — render breadcrumbs from it, or check depth. */
26
+ path: readonly Id[];
27
+ /** True when `current` is the root screen, so Escape has nowhere to go. */
28
+ atRoot: boolean;
29
+ /** Push a screen. Pushing the current screen again is a no-op. */
30
+ open(id: Id): void;
31
+ /** Pop one screen. A no-op at the root. */
32
+ back(): void;
33
+ /** Replace the top of the stack without deepening it. */
34
+ replace(id: Id): void;
35
+ /** Collapse to a root — the one passed, or the original. Use it when a run starts or ends. */
36
+ reset(id?: Id): void;
37
+ }
38
+ /**
39
+ * The screen stack behind a game front-end: title → save select → settings → credits, and Escape
40
+ * back out. Every game menu needs this and none of it is game-specific, so it ships here rather
41
+ * than being re-derived per game. Holds no content and imposes no look — the game decides what each
42
+ * screen id renders.
43
+ *
44
+ * `escapeToBack` (default `true`) binds Escape to {@link MenuRouter.back}; pass `false` when the
45
+ * game already owns the Escape key (a pause menu that toggles rather than pops).
46
+ *
47
+ * @capability menu-router screen stack for a game front-end — push/back/replace/reset with optional Escape-to-back
48
+ */
49
+ export declare function useMenuRouter<Id extends string>(root: Id, options?: {
50
+ escapeToBack?: boolean;
51
+ }): MenuRouter<Id>;
@@ -0,0 +1,49 @@
1
+ import { useCallback, useEffect, useMemo, useReducer } from "react";
2
+ /**
3
+ * The pure screen-stack transition behind {@link useMenuRouter}. Exported so a game can drive the
4
+ * same navigation from its own store or a replayable command log instead of React state.
5
+ *
6
+ * @capability menu-stack-reducer pure push/back/replace/reset transitions for a menu screen stack
7
+ */
8
+ export function menuStackReducer(path, action, root) {
9
+ switch (action.type) {
10
+ case "open":
11
+ return path[path.length - 1] === action.id ? path : [...path, action.id];
12
+ case "back":
13
+ return path.length <= 1 ? path : path.slice(0, -1);
14
+ case "replace":
15
+ return [...path.slice(0, -1), action.id];
16
+ case "reset":
17
+ return [action.id ?? root];
18
+ }
19
+ }
20
+ /**
21
+ * The screen stack behind a game front-end: title → save select → settings → credits, and Escape
22
+ * back out. Every game menu needs this and none of it is game-specific, so it ships here rather
23
+ * than being re-derived per game. Holds no content and imposes no look — the game decides what each
24
+ * screen id renders.
25
+ *
26
+ * `escapeToBack` (default `true`) binds Escape to {@link MenuRouter.back}; pass `false` when the
27
+ * game already owns the Escape key (a pause menu that toggles rather than pops).
28
+ *
29
+ * @capability menu-router screen stack for a game front-end — push/back/replace/reset with optional Escape-to-back
30
+ */
31
+ export function useMenuRouter(root, options = {}) {
32
+ const { escapeToBack = true } = options;
33
+ const [path, dispatch] = useReducer((state, action) => menuStackReducer(state, action, root), [root]);
34
+ const open = useCallback((id) => dispatch({ type: "open", id }), []);
35
+ const back = useCallback(() => dispatch({ type: "back" }), []);
36
+ const replace = useCallback((id) => dispatch({ type: "replace", id }), []);
37
+ const reset = useCallback((id) => dispatch({ type: "reset", id }), []);
38
+ useEffect(() => {
39
+ if (!escapeToBack)
40
+ return;
41
+ const onKeyDown = (event) => {
42
+ if (event.key === "Escape")
43
+ dispatch({ type: "back" });
44
+ };
45
+ window.addEventListener("keydown", onKeyDown);
46
+ return () => window.removeEventListener("keydown", onKeyDown);
47
+ }, [escapeToBack]);
48
+ return useMemo(() => ({ current: path[path.length - 1], path, atRoot: path.length <= 1, open, back, replace, reset }), [path, open, back, replace, reset]);
49
+ }
@@ -0,0 +1,131 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type ModalRecord, type ModalStack } from "@jgengine/core/ui/modalStack";
3
+ /**
4
+ * React chrome over the headless modal stack (`@jgengine/core/ui/modalStack`) — the blocking-overlay
5
+ * layer a game raises over its scene: a pause menu, a "quit to menu?" confirm, a reward popup.
6
+ * {@link useModalStack} is the DATA/HOOK layer (subscribe + the active record), {@link ModalHost}
7
+ * renders the top modal with a backdrop, focus trap, and Esc-to-cancel, and {@link ConfirmDialog} /
8
+ * {@link PauseMenu} are ready-to-wire, HudTheme-token-driven building blocks. All unskinned and
9
+ * game-owned: the model never interprets a modal's `kind`, so a game switches on it to pick which of
10
+ * these (or its own) content to show, and reskins everything via `HudTheme` tokens and the frame.
11
+ */
12
+ /** The live modal view returned by {@link useModalStack}: the active record (or `null`) plus the stack depth. */
13
+ export interface ModalStackView {
14
+ /** The topmost open modal, or `null` when nothing is open. */
15
+ top: ModalRecord | null;
16
+ /** Number of stacked modals (for a "2 of 3"-style affordance or a dim-per-layer backdrop). */
17
+ depth: number;
18
+ }
19
+ /**
20
+ * Subscribe to a modal stack and re-render on every change, returning the active modal and depth.
21
+ *
22
+ * @capability use-modal-stack React hook binding a modal stack to a component — re-renders on push/pop/resolve
23
+ */
24
+ export declare function useModalStack(stack: ModalStack): ModalStackView;
25
+ /** Props for {@link ModalHost}. */
26
+ export interface ModalHostProps {
27
+ /** The stack to render the top of. */
28
+ stack: ModalStack;
29
+ /**
30
+ * Render the active modal's content. Receives the record and controls bound to the stack —
31
+ * `resolve(result)` closes it with a free-form outcome, `cancel()` is the Esc/backdrop shortcut
32
+ * (`resolve(MODAL_CANCEL)`). Switch on `record.kind` to pick a {@link ConfirmDialog}, a
33
+ * {@link PauseMenu}, or your own content.
34
+ */
35
+ children: (record: ModalRecord, controls: ModalControls) => ReactNode;
36
+ /** Dim the scene behind the modal. Default `true`. */
37
+ showBackdrop?: boolean;
38
+ /** Backdrop color (default `rgba(4,7,12,0.62)`, or the `--jg-backdrop` token). */
39
+ backdrop?: string;
40
+ /** Close the top modal (cancel) on backdrop click. Default `true`. */
41
+ closeOnBackdrop?: boolean;
42
+ /** Close the top modal (cancel) on Escape. Default `true`. */
43
+ closeOnEsc?: boolean;
44
+ /** z-index of the host's stacking context — sits above the HUD/windows. Default 80. */
45
+ zIndexBase?: number;
46
+ className?: string;
47
+ style?: CSSProperties;
48
+ }
49
+ /** Stack-bound controls handed to a {@link ModalHost} render callback. */
50
+ export interface ModalControls {
51
+ /** Resolve the active modal with a free-form result and close it. */
52
+ resolve: (result: string, payload?: unknown) => void;
53
+ /** Resolve the active modal with {@link MODAL_CONFIRM}. */
54
+ confirm: (payload?: unknown) => void;
55
+ /** Resolve the active modal with {@link MODAL_CANCEL} — the Esc / backdrop behavior. */
56
+ cancel: () => void;
57
+ }
58
+ /**
59
+ * Full-screen modal host: watches a stack, renders only the topmost modal centered over a dimmed
60
+ * backdrop, and manages accessibility — the panel is a `role="dialog" aria-modal`, focus moves into
61
+ * it on open and is trapped with Tab/Shift+Tab cycling, Escape and (optionally) a backdrop click
62
+ * cancel the top modal, and focus returns to the previously focused element on close. It renders
63
+ * nothing when the stack is empty. Skin the panel and backdrop via `HudTheme` tokens.
64
+ *
65
+ * @capability modal-host full-screen modal/dialog host over a modal stack — dimmed backdrop, focus trap, Esc/backdrop-to-cancel, aria-modal dialog
66
+ */
67
+ export declare function ModalHost({ stack, children, showBackdrop, backdrop, closeOnBackdrop, closeOnEsc, zIndexBase, className, style, }: ModalHostProps): ReactNode;
68
+ /** Props for {@link ConfirmDialog}. */
69
+ export interface ConfirmDialogProps {
70
+ title: ReactNode;
71
+ /** Explanatory body copy under the title. */
72
+ body?: ReactNode;
73
+ /** Confirm button label (default `"Confirm"`). */
74
+ confirmLabel?: ReactNode;
75
+ /** Cancel button label (default `"Cancel"`). */
76
+ cancelLabel?: ReactNode;
77
+ onConfirm: () => void;
78
+ onCancel: () => void;
79
+ /** Style the confirm button as destructive (red). Default `false`. */
80
+ danger?: boolean;
81
+ /** Fixed dialog width (default 360). */
82
+ width?: number | string;
83
+ className?: string;
84
+ style?: CSSProperties;
85
+ }
86
+ /**
87
+ * A generic two-button confirmation dialog — title, optional body, and Cancel / Confirm buttons over
88
+ * a `HudTheme`-token-driven {@link HudFrame}. Presentation-only: wire `onConfirm`/`onCancel` to a
89
+ * modal stack's `resolve` (typically inside a {@link ModalHost} render callback) and reskin via
90
+ * `HudTheme` tokens or `danger` for a destructive action. It interprets nothing — the game supplies
91
+ * the copy.
92
+ *
93
+ * @capability confirm-dialog generic themeable confirm/cancel dialog — title, body, two buttons, danger styling
94
+ */
95
+ export declare function ConfirmDialog({ title, body, confirmLabel, cancelLabel, onConfirm, onCancel, danger, width, className, style, }: ConfirmDialogProps): ReactNode;
96
+ /** One selectable row in a {@link PauseMenu}'s slot list (Settings, Save, Quit, …). */
97
+ export interface PauseMenuItem {
98
+ /** Stable id (also the `data-pause-item` value). */
99
+ id: string;
100
+ label: ReactNode;
101
+ onSelect: () => void;
102
+ /** Render as destructive (e.g. Quit). Default `false`. */
103
+ danger?: boolean;
104
+ /** Disable the row. Default `false`. */
105
+ disabled?: boolean;
106
+ }
107
+ /** Props for {@link PauseMenu}. */
108
+ export interface PauseMenuProps {
109
+ /** Menu heading (default `"Paused"`). */
110
+ title?: ReactNode;
111
+ /** Resume/close-the-menu action, wired to the primary Resume button. */
112
+ onResume: () => void;
113
+ /** Resume button label (default `"Resume"`). */
114
+ resumeLabel?: ReactNode;
115
+ /** The game-filled slot list beneath Resume (Settings, Save, Quit, …). */
116
+ items?: readonly PauseMenuItem[];
117
+ /** Fixed menu width (default 300). */
118
+ width?: number | string;
119
+ className?: string;
120
+ style?: CSSProperties;
121
+ }
122
+ /**
123
+ * A reskinnable pause-menu building block: a primary Resume button plus a game-filled list of slot
124
+ * items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudFrame}. It ships
125
+ * the common shape every game's pause screen needs and nothing genre-specific — the game supplies the
126
+ * title, item labels/order, and destructive styling, and reskins with `HudTheme` tokens. Drop it into
127
+ * a {@link ModalHost} keyed on a `"pause"` modal, or render it standalone.
128
+ *
129
+ * @capability pause-menu reskinnable pause-menu building block — Resume plus a game-filled Settings/Quit slot list, HudTheme-driven
130
+ */
131
+ export declare function PauseMenu({ title, onResume, resumeLabel, items, width, className, style, }: PauseMenuProps): ReactNode;