@jgengine/react 0.8.0 → 0.10.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 (45) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/chat.d.ts +3 -3
  3. package/dist/chat.js +14 -5
  4. package/dist/chatBubbles.d.ts +15 -0
  5. package/dist/chatBubbles.js +46 -0
  6. package/dist/components.d.ts +1 -0
  7. package/dist/components.js +36 -23
  8. package/dist/display.d.ts +2 -1
  9. package/dist/display.js +3 -2
  10. package/dist/dragLayer.d.ts +6 -1
  11. package/dist/dragLayer.js +64 -31
  12. package/dist/engineStore.d.ts +1 -1
  13. package/dist/engineStore.js +10 -2
  14. package/dist/fogOverlay.d.ts +21 -0
  15. package/dist/fogOverlay.js +34 -0
  16. package/dist/gameViewport.d.ts +54 -0
  17. package/dist/gameViewport.js +340 -0
  18. package/dist/hooks.d.ts +11 -1
  19. package/dist/hooks.js +89 -21
  20. package/dist/hudLayout.d.ts +79 -0
  21. package/dist/hudLayout.js +518 -0
  22. package/dist/hudViewport.d.ts +21 -0
  23. package/dist/hudViewport.js +17 -0
  24. package/dist/index.d.ts +7 -0
  25. package/dist/index.js +7 -0
  26. package/dist/liveBind.d.ts +4 -10
  27. package/dist/liveBind.js +28 -16
  28. package/dist/map.d.ts +27 -7
  29. package/dist/map.js +152 -43
  30. package/dist/preview.d.ts +6 -0
  31. package/dist/preview.js +1 -0
  32. package/dist/provider.d.ts +2 -0
  33. package/dist/provider.js +4 -0
  34. package/dist/rotateDevice.d.ts +24 -0
  35. package/dist/rotateDevice.js +83 -0
  36. package/dist/selectSnapshot.d.ts +7 -0
  37. package/dist/selectSnapshot.js +16 -0
  38. package/dist/settings.d.ts +78 -0
  39. package/dist/settings.js +61 -0
  40. package/dist/skillCheckPaint.d.ts +4 -0
  41. package/dist/skillCheckPaint.js +28 -0
  42. package/dist/social.d.ts +3 -3
  43. package/dist/social.js +31 -10
  44. package/llms.txt +858 -860
  45. package/package.json +9 -5
@@ -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 {};
package/dist/liveBind.js CHANGED
@@ -1,32 +1,44 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useRef } from "react";
3
- /**
4
- * Drive a DOM/SVG element from a per-frame value without re-rendering React.
5
- * Runs one requestAnimationFrame loop, reads `get()` each frame, and calls
6
- * `apply(value, element)` so HUDs bound to live engine state (speed, pose)
7
- * never re-render and never lag. `get`/`apply` may change without restarting.
8
- */
3
+ const subscribers = new Set();
4
+ let sharedRaf = 0;
5
+ function sharedTick() {
6
+ for (const subscriber of subscribers)
7
+ subscriber();
8
+ if (subscribers.size > 0)
9
+ sharedRaf = requestAnimationFrame(sharedTick);
10
+ else
11
+ sharedRaf = 0;
12
+ }
13
+ export function subscribeFrameBind(subscriber) {
14
+ subscribers.add(subscriber);
15
+ if (sharedRaf === 0 && typeof requestAnimationFrame !== "undefined") {
16
+ sharedRaf = requestAnimationFrame(sharedTick);
17
+ }
18
+ return () => {
19
+ subscribers.delete(subscriber);
20
+ if (subscribers.size === 0 && sharedRaf !== 0 && typeof cancelAnimationFrame !== "undefined") {
21
+ cancelAnimationFrame(sharedRaf);
22
+ sharedRaf = 0;
23
+ }
24
+ };
25
+ }
26
+ export function frameBindSubscriberCount() {
27
+ return subscribers.size;
28
+ }
9
29
  export function useFrameBind(ref, get, apply) {
10
30
  const getRef = useRef(get);
11
31
  getRef.current = get;
12
32
  const applyRef = useRef(apply);
13
33
  applyRef.current = apply;
14
34
  useEffect(() => {
15
- let raf = 0;
16
- const tick = () => {
35
+ return subscribeFrameBind(() => {
17
36
  const element = ref.current;
18
37
  if (element !== null)
19
38
  applyRef.current(getRef.current(), element);
20
- raf = requestAnimationFrame(tick);
21
- };
22
- raf = requestAnimationFrame(tick);
23
- return () => cancelAnimationFrame(raf);
39
+ });
24
40
  }, [ref]);
25
41
  }
26
- /**
27
- * A `<span>` whose text tracks a live value every frame (the 90% case of
28
- * useFrameBind). `<LiveText get={() => groundSpeed(car) * KMH} format={Math.round} />`.
29
- */
30
42
  export function LiveText({ get, format, className, style, }) {
31
43
  const ref = useRef(null);
32
44
  useFrameBind(ref, get, (value, element) => {
package/dist/map.d.ts CHANGED
@@ -2,6 +2,7 @@ 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
4
  import { type WorldXZ } from "@jgengine/core/world/minimap";
5
+ import { type MapCellStates, type MapRoute, type MapZone } from "@jgengine/core/world/mapLayers";
5
6
  export declare function useMarkers(markers: MarkerSet): readonly MapMarker[];
6
7
  export declare function useFog(fog: FogField): ReturnType<FogField["cells"]>;
7
8
  export interface MinimapProps {
@@ -10,12 +11,21 @@ export interface MinimapProps {
10
11
  worldRadius: number;
11
12
  fog?: FogField;
12
13
  size?: number;
13
- heading?: number;
14
+ /** Facing yaw (`rotationY`, forward = `(sin yaw, cos yaw)`) of the tracked entity — pass it raw, never pre-converted to a bearing. */
15
+ facingYaw?: number;
14
16
  rotate?: boolean;
15
17
  kindStyles?: Record<string, MarkerKindStyle>;
16
18
  background?: string;
17
19
  /** World bounds the `background` image spans, so it pans correctly under a player-centered map. */
18
20
  mapBounds?: MapBounds;
21
+ /** Route/corridor polylines drawn under the markers (#285.2); `forecast` routes dash. */
22
+ routes?: readonly MapRoute[];
23
+ /** Zone/hazard overlays with live fills and dashed forecast outlines (#285.1). */
24
+ zones?: readonly MapZone[];
25
+ /** Per-cell status heatmaps (#285.1) — pair with `world/cellStates`. */
26
+ cellStates?: readonly MapCellStates[];
27
+ /** Click-to-pin seam (#285.6): receives the clicked world XZ via `unprojectFromMinimap`. */
28
+ onWorldClick?: (world: WorldXZ) => void;
19
29
  className?: string;
20
30
  title?: string;
21
31
  children?: ReactNode;
@@ -31,9 +41,10 @@ export interface MapBounds {
31
41
  * fog overlay, categorized marker icons, and a facing arrow. Reads a core
32
42
  * `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
33
43
  */
34
- export declare function Minimap({ markers, center, worldRadius, fog, size, heading, rotate, kindStyles, background, mapBounds, className, title, children, }: MinimapProps): ReactNode;
44
+ export declare function Minimap({ markers, center, worldRadius, fog, size, facingYaw, rotate, kindStyles, background, mapBounds, routes, zones, cellStates, onWorldClick, className, title, children, }: MinimapProps): ReactNode;
35
45
  export interface CompassProps {
36
- heading: number;
46
+ /** Facing yaw (`rotationY`, forward = `(sin yaw, cos yaw)`) of the tracked entity — pass it raw, never pre-converted to a bearing. */
47
+ facingYaw: number;
37
48
  center?: WorldXZ;
38
49
  markers?: MarkerSet;
39
50
  width?: number;
@@ -42,20 +53,29 @@ export interface CompassProps {
42
53
  className?: string;
43
54
  }
44
55
  /**
45
- * Horizontal compass strip centered on the player's facing bearing, with the
56
+ * Horizontal compass strip centered on the player's facing direction, with the
46
57
  * eight cardinals and optional marker pips (bearing to each `MarkerSet` entry).
47
58
  */
48
- export declare function Compass({ heading, center, markers, width, fov, kindStyles, className, }: CompassProps): ReactNode;
59
+ export declare function Compass({ facingYaw, center, markers, width, fov, kindStyles, className, }: CompassProps): ReactNode;
49
60
  export interface WorldMapProps {
50
61
  markers: MarkerSet;
51
62
  bounds: MapBounds;
52
63
  player?: WorldXZ;
53
- heading?: number;
64
+ /** Facing yaw (`rotationY`, forward = `(sin yaw, cos yaw)`) of the player — pass it raw, never pre-converted to a bearing. */
65
+ facingYaw?: number;
54
66
  fog?: FogField;
55
67
  background?: string;
56
68
  width?: number;
57
69
  height?: number;
58
70
  kindStyles?: Record<string, MarkerKindStyle>;
71
+ /** Route/corridor polylines drawn under the markers (#285.2); `forecast` routes dash. */
72
+ routes?: readonly MapRoute[];
73
+ /** Zone/hazard overlays with live fills and dashed forecast outlines (#285.1). */
74
+ zones?: readonly MapZone[];
75
+ /** Per-cell status heatmaps (#285.1) — pair with `world/cellStates`. */
76
+ cellStates?: readonly MapCellStates[];
77
+ /** Click-to-pin seam (#285.6): receives the clicked world XZ. */
78
+ onWorldClick?: (world: WorldXZ) => void;
59
79
  className?: string;
60
80
  title?: string;
61
81
  onClose?: () => void;
@@ -65,4 +85,4 @@ export interface WorldMapProps {
65
85
  * background, reveal-on-event fog, all markers with labels, and the player.
66
86
  * Rectangular linear projection over the supplied world `bounds`.
67
87
  */
68
- export declare function WorldMap({ markers, bounds, player, heading, fog, background, width, height, kindStyles, className, title, onClose, }: WorldMapProps): ReactNode;
88
+ export declare function WorldMap({ markers, bounds, player, facingYaw, fog, background, width, height, kindStyles, routes, zones, cellStates, onWorldClick, className, title, onClose, }: WorldMapProps): ReactNode;
package/dist/map.js CHANGED
@@ -1,7 +1,73 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useSyncExternalStore } from "react";
2
+ import { useMemo, useSyncExternalStore } from "react";
3
3
  import { DEFAULT_MARKER_KINDS, markerKindStyle, } from "@jgengine/core/world/markers";
4
- import { bearingToCardinal, clampToMinimapEdge, compassBearing, projectToMinimap, relativeBearing, } from "@jgengine/core/world/minimap";
4
+ import { bearingToCardinal, clampToMinimapEdge, compassBearing, headingToBearing, projectToMinimap, relativeBearing, unprojectFromMinimap, } from "@jgengine/core/world/minimap";
5
+ import { mapLayerColor, } from "@jgengine/core/world/mapLayers";
6
+ import { createFogDataUrl } from "./fogOverlay.js";
7
+ function routeNodes(routes, project, keyPrefix) {
8
+ if (routes === undefined)
9
+ return [];
10
+ return routes.map((route) => {
11
+ const points = route.closed === true && route.points.length > 1 ? [...route.points, route.points[0]] : route.points;
12
+ const path = points.map(([x, z]) => {
13
+ const at = project(x, z);
14
+ return `${at.x},${at.y}`;
15
+ });
16
+ return (_jsx("polyline", { "data-map-route": route.id, points: path.join(" "), fill: "none", stroke: mapLayerColor(route.tone), strokeWidth: route.width ?? 2, strokeLinejoin: "round", strokeLinecap: "round", opacity: 0.9, ...(route.forecast === true ? { strokeDasharray: "6 4" } : {}) }, `${keyPrefix}-route-${route.id}`));
17
+ });
18
+ }
19
+ function zoneNodes(zones, project, scale, keyPrefix) {
20
+ if (zones === undefined)
21
+ return [];
22
+ return zones.map((zone) => {
23
+ const color = mapLayerColor(zone.tone);
24
+ const forecast = zone.forecast === true;
25
+ const fill = forecast ? "none" : color;
26
+ const fillOpacity = forecast ? 0 : zone.opacity ?? 0.25;
27
+ const stroke = { stroke: color, strokeWidth: 1.5, ...(forecast ? { strokeDasharray: "6 4" } : {}) };
28
+ const shape = zone.shape;
29
+ let node = null;
30
+ let labelAt = null;
31
+ if (shape.kind === "circle") {
32
+ const at = project(shape.center[0], shape.center[1]);
33
+ labelAt = at;
34
+ node = (_jsx("ellipse", { cx: at.x, cy: at.y, rx: scale.x(shape.radius), ry: scale.y(shape.radius), fill: fill, fillOpacity: fillOpacity, ...stroke }));
35
+ }
36
+ else {
37
+ const corners = shape.kind === "rect"
38
+ ? rectCorners(shape).map(([x, z]) => project(x, z))
39
+ : shape.points.map(([x, z]) => project(x, z));
40
+ labelAt = corners[0] ?? null;
41
+ node = (_jsx("polygon", { points: corners.map((at) => `${at.x},${at.y}`).join(" "), fill: fill, fillOpacity: fillOpacity, ...stroke }));
42
+ }
43
+ return (_jsxs("g", { "data-map-zone": zone.id, opacity: 0.9, children: [node, zone.label !== undefined && labelAt !== null ? (_jsx("text", { x: labelAt.x, y: labelAt.y - 4, textAnchor: "middle", fontSize: 9, fill: color, style: { fontWeight: 700 }, children: zone.label })) : null] }, `${keyPrefix}-zone-${zone.id}`));
44
+ });
45
+ }
46
+ function rectCorners(shape) {
47
+ const halfW = shape.w / 2;
48
+ const halfD = shape.d / 2;
49
+ const rotate = shape.rotate ?? 0;
50
+ const cos = Math.cos(rotate);
51
+ const sin = Math.sin(rotate);
52
+ return [[-halfW, -halfD], [halfW, -halfD], [halfW, halfD], [-halfW, halfD]].map(([dx, dz]) => [
53
+ shape.center[0] + dx * cos - dz * sin,
54
+ shape.center[1] + dx * sin + dz * cos,
55
+ ]);
56
+ }
57
+ function cellStateNodes(layers, project, scale, keyPrefix) {
58
+ if (layers === undefined)
59
+ return [];
60
+ const nodes = [];
61
+ for (const layer of layers) {
62
+ const w = scale.x(layer.cellSize);
63
+ const h = scale.y(layer.cellSize);
64
+ for (const cell of layer.cells) {
65
+ const at = project(layer.origin[0] + cell.col * layer.cellSize, layer.origin[1] + cell.row * layer.cellSize);
66
+ nodes.push(_jsx("rect", { x: at.x - w / 2, y: at.y - h / 2, width: w, height: h, fill: mapLayerColor(cell.tone), opacity: cell.opacity ?? 0.35 }, `${keyPrefix}-${layer.id}-${cell.col}-${cell.row}`));
67
+ }
68
+ }
69
+ return nodes;
70
+ }
5
71
  export function useMarkers(markers) {
6
72
  return useSyncExternalStore(markers.subscribe, markers.snapshot, markers.snapshot);
7
73
  }
@@ -13,34 +79,37 @@ export function useFog(fog) {
13
79
  * fog overlay, categorized marker icons, and a facing arrow. Reads a core
14
80
  * `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
15
81
  */
16
- export function Minimap({ markers, center, worldRadius, fog, size = 176, heading = 0, rotate = false, kindStyles = DEFAULT_MARKER_KINDS, background, mapBounds, className, title = "Map", children, }) {
82
+ 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, }) {
17
83
  const markerList = useMarkers(markers);
18
84
  const fogCells = useSyncExternalStore(fog?.subscribe ?? NO_SUBSCRIBE, fog?.cells ?? NULL_CELLS, fog?.cells ?? NULL_CELLS);
85
+ const bearing = headingToBearing(facingYaw);
19
86
  const view = {
20
87
  center,
21
88
  worldRadius,
22
89
  size,
23
- ...(rotate ? { rotate: -heading } : {}),
90
+ ...(rotate ? { rotate: bearing } : {}),
24
91
  };
25
92
  const half = size / 2;
26
93
  const clipId = `mm-clip-${size}`;
27
94
  const sorted = [...markerList].sort((a, b) => markerKindStyle(a.kind, kindStyles).priority - markerKindStyle(b.kind, kindStyles).priority);
28
- const fogRects = [];
29
- if (fogCells !== null) {
95
+ const fogImage = useMemo(() => {
96
+ if (fogCells === null)
97
+ return null;
30
98
  const cellPx = (fogCells.cellSize / worldRadius) * half;
31
- for (let row = 0; row < fogCells.rows; row += 1) {
32
- for (let col = 0; col < fogCells.cols; col += 1) {
33
- if (fogCells.revealed[row * fogCells.cols + col])
34
- continue;
35
- const worldX = fogCells.minX + (col + 0.5) * fogCells.cellSize;
36
- const worldZ = fogCells.minZ + (row + 0.5) * fogCells.cellSize;
37
- const projected = projectToMinimap([worldX, worldZ], view);
38
- if (projected.distance > worldRadius + fogCells.cellSize)
39
- continue;
40
- fogRects.push(_jsx("rect", { x: projected.x - cellPx / 2 - 0.5, y: projected.y - cellPx / 2 - 0.5, width: cellPx + 1, height: cellPx + 1, fill: "#0b0f14", opacity: 0.82 }, `fog-${col}-${row}`));
41
- }
42
- }
43
- }
99
+ return createFogDataUrl(fogCells, { width: size, height: size }, (col, row) => {
100
+ const worldX = fogCells.minX + (col + 0.5) * fogCells.cellSize;
101
+ const worldZ = fogCells.minZ + (row + 0.5) * fogCells.cellSize;
102
+ const projected = projectToMinimap([worldX, worldZ], view);
103
+ if (projected.distance > worldRadius + fogCells.cellSize)
104
+ return null;
105
+ return {
106
+ x: projected.x - cellPx / 2 - 0.5,
107
+ y: projected.y - cellPx / 2 - 0.5,
108
+ width: cellPx + 1,
109
+ height: cellPx + 1,
110
+ };
111
+ });
112
+ }, [fogCells, center[0], center[1], worldRadius, size, rotate, bearing]);
44
113
  return (_jsxs("div", { className: className, "data-minimap": true, style: {
45
114
  width: size,
46
115
  borderRadius: 14,
@@ -59,7 +128,17 @@ export function Minimap({ markers, center, worldRadius, fog, size = 176, heading
59
128
  letterSpacing: 1.4,
60
129
  textTransform: "uppercase",
61
130
  color: "rgba(203,213,225,0.75)",
62
- }, children: [_jsx("span", { children: title }), _jsx("span", { style: { color: "rgba(148,163,184,0.6)" }, children: bearingToCardinal(heading) })] }), _jsxs("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, "data-minimap-canvas": true, children: [_jsx("defs", { children: _jsx("clipPath", { id: clipId, children: _jsx("circle", { cx: half, cy: half, r: half - 1 }) }) }), _jsxs("g", { clipPath: `url(#${clipId})`, children: [_jsx("circle", { cx: half, cy: half, r: half - 1, fill: "#1c2733" }), background !== undefined
131
+ }, children: [_jsx("span", { children: title }), _jsx("span", { style: { color: "rgba(148,163,184,0.6)" }, children: bearingToCardinal(bearing) })] }), _jsxs("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, "data-minimap-canvas": true, ...(onWorldClick === undefined
132
+ ? {}
133
+ : {
134
+ onClick: (event) => {
135
+ const rect = event.currentTarget.getBoundingClientRect();
136
+ const px = ((event.clientX - rect.left) / rect.width) * size;
137
+ const py = ((event.clientY - rect.top) / rect.height) * size;
138
+ onWorldClick(unprojectFromMinimap({ x: px, y: py }, view));
139
+ },
140
+ style: { cursor: "crosshair" },
141
+ }), children: [_jsx("defs", { children: _jsx("clipPath", { id: clipId, children: _jsx("circle", { cx: half, cy: half, r: half - 1 }) }) }), _jsxs("g", { clipPath: `url(#${clipId})`, children: [_jsx("circle", { cx: half, cy: half, r: half - 1, fill: "#1c2733" }), background !== undefined
63
142
  ? (() => {
64
143
  if (mapBounds === undefined) {
65
144
  return (_jsx("image", { href: background, x: 0, y: 0, width: size, height: size, preserveAspectRatio: "xMidYMid slice", opacity: 0.9 }));
@@ -67,12 +146,21 @@ export function Minimap({ markers, center, worldRadius, fog, size = 176, heading
67
146
  const pxPerWorld = half / worldRadius;
68
147
  return (_jsx("image", { href: background, x: half + (mapBounds.minX - center[0]) * pxPerWorld, y: half + (mapBounds.minZ - center[1]) * pxPerWorld, width: (mapBounds.maxX - mapBounds.minX) * pxPerWorld, height: (mapBounds.maxZ - mapBounds.minZ) * pxPerWorld, preserveAspectRatio: "none", opacity: 0.92 }));
69
148
  })()
70
- : null, fogRects, _jsx("circle", { cx: half, cy: half, r: half - 1, fill: "none", stroke: "rgba(148,163,184,0.12)" }), sorted.map((marker) => {
149
+ : null, fogImage !== null ? (_jsx("image", { href: fogImage, x: 0, y: 0, width: size, height: size, preserveAspectRatio: "none" })) : null, (() => {
150
+ const projectXZ = (x, z) => projectToMinimap([x, z], view);
151
+ const pxPerWorld = worldRadius === 0 ? 0 : half / worldRadius;
152
+ const layerScale = { x: (units) => units * pxPerWorld, y: (units) => units * pxPerWorld };
153
+ return [
154
+ ...cellStateNodes(cellStates, projectXZ, layerScale, "mm"),
155
+ ...zoneNodes(zones, projectXZ, layerScale, "mm"),
156
+ ...routeNodes(routes, projectXZ, "mm"),
157
+ ];
158
+ })(), _jsx("circle", { cx: half, cy: half, r: half - 1, fill: "none", stroke: "rgba(148,163,184,0.12)" }), sorted.map((marker) => {
71
159
  const style = markerKindStyle(marker.kind, kindStyles);
72
160
  const projected = projectToMinimap(marker.position, view);
73
161
  const at = projected.inside ? projected : clampToMinimapEdge(projected, size);
74
162
  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));
75
- }), _jsx("g", { transform: `translate(${half} ${half}) rotate(${(rotate ? 0 : heading) * (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] }));
163
+ }), _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] }));
76
164
  }
77
165
  const NO_SUBSCRIBE = () => () => undefined;
78
166
  const NULL_CELLS = () => null;
@@ -89,14 +177,15 @@ const COMPASS_TICKS = [
89
177
  { cardinal: "NW", bearing: (7 * Math.PI) / 4 },
90
178
  ];
91
179
  /**
92
- * Horizontal compass strip centered on the player's facing bearing, with the
180
+ * Horizontal compass strip centered on the player's facing direction, with the
93
181
  * eight cardinals and optional marker pips (bearing to each `MarkerSet` entry).
94
182
  */
95
- export function Compass({ heading, center, markers, width = 340, fov = (Math.PI * 2) / 3, kindStyles = DEFAULT_MARKER_KINDS, className, }) {
183
+ export function Compass({ facingYaw, center, markers, width = 340, fov = (Math.PI * 2) / 3, kindStyles = DEFAULT_MARKER_KINDS, className, }) {
96
184
  const markerList = useSyncExternalStore(markers?.subscribe ?? NO_SUBSCRIBE, markers?.snapshot ?? EMPTY_MARKERS, markers?.snapshot ?? EMPTY_MARKERS);
185
+ const facingBearing = headingToBearing(facingYaw);
97
186
  const half = fov / 2;
98
187
  const toX = (bearing) => {
99
- const delta = relativeBearing(bearing, heading);
188
+ const delta = relativeBearing(bearing, facingBearing);
100
189
  if (Math.abs(delta) > half)
101
190
  return null;
102
191
  return width / 2 + (delta / half) * (width / 2);
@@ -151,12 +240,27 @@ export function Compass({ heading, center, markers, width = 340, fov = (Math.PI
151
240
  })
152
241
  : null] }));
153
242
  }
243
+ function WorldMapFogImage({ fogCells, project, worldW, worldD, width, mapH, }) {
244
+ const fogImage = useMemo(() => {
245
+ if (fogCells === null)
246
+ return null;
247
+ const cellW = (fogCells.cellSize / worldW) * width;
248
+ const cellH = (fogCells.cellSize / worldD) * mapH;
249
+ return createFogDataUrl(fogCells, { width, height: mapH }, (col, row) => {
250
+ const at = project(fogCells.minX + col * fogCells.cellSize, fogCells.minZ + row * fogCells.cellSize);
251
+ return { x: at.x, y: at.y, width: cellW + 0.5, height: cellH + 0.5 };
252
+ }, "rgba(11, 15, 20, 0.86)");
253
+ }, [fogCells, project, worldW, worldD, width, mapH]);
254
+ if (fogImage === null)
255
+ return null;
256
+ return _jsx("image", { href: fogImage, x: 0, y: 0, width: width, height: mapH, preserveAspectRatio: "none" });
257
+ }
154
258
  /**
155
259
  * Full-bounds top-down world map (the "press M" overlay): baked terrain
156
260
  * background, reveal-on-event fog, all markers with labels, and the player.
157
261
  * Rectangular linear projection over the supplied world `bounds`.
158
262
  */
159
- export function WorldMap({ markers, bounds, player, heading = 0, fog, background, width = 520, height, kindStyles = DEFAULT_MARKER_KINDS, className, title = "World Map", onClose, }) {
263
+ 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, }) {
160
264
  const markerList = useMarkers(markers);
161
265
  const fogCells = useSyncExternalStore(fog?.subscribe ?? NO_SUBSCRIBE, fog?.cells ?? NULL_CELLS, fog?.cells ?? NULL_CELLS);
162
266
  const worldW = bounds.maxX - bounds.minX;
@@ -182,29 +286,34 @@ export function WorldMap({ markers, bounds, player, heading = 0, fog, background
182
286
  fontSize: 11,
183
287
  padding: "3px 9px",
184
288
  cursor: "pointer",
185
- }, children: "Close" })) : null] }), _jsxs("svg", { width: width, height: mapH, viewBox: `0 0 ${width} ${mapH}`, "data-world-map-canvas": true, style: { borderRadius: 10 }, 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, fogCells !== null
186
- ? (() => {
187
- const cellW = (fogCells.cellSize / worldW) * width;
188
- const cellH = (fogCells.cellSize / worldD) * mapH;
189
- const rects = [];
190
- for (let row = 0; row < fogCells.rows; row += 1) {
191
- for (let col = 0; col < fogCells.cols; col += 1) {
192
- if (fogCells.revealed[row * fogCells.cols + col])
193
- continue;
194
- const at = project(fogCells.minX + col * fogCells.cellSize, fogCells.minZ + row * fogCells.cellSize);
195
- rects.push(_jsx("rect", { x: at.x, y: at.y, width: cellW + 0.5, height: cellH + 0.5, fill: "#0b0f14", opacity: 0.86 }, `wfog-${col}-${row}`));
196
- }
197
- }
198
- return rects;
199
- })()
200
- : null, markerList.map((marker) => {
289
+ }, 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
290
+ ? {}
291
+ : {
292
+ onClick: (event) => {
293
+ const rect = event.currentTarget.getBoundingClientRect();
294
+ const px = ((event.clientX - rect.left) / rect.width) * width;
295
+ const py = ((event.clientY - rect.top) / rect.height) * mapH;
296
+ onWorldClick([bounds.minX + (px / width) * worldW, bounds.minZ + (py / mapH) * worldD]);
297
+ },
298
+ }), 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 }), (() => {
299
+ const projectXZ = (x, z) => project(x, z);
300
+ const layerScale = {
301
+ x: (units) => (units / worldW) * width,
302
+ y: (units) => (units / worldD) * mapH,
303
+ };
304
+ return [
305
+ ...cellStateNodes(cellStates, projectXZ, layerScale, "wm"),
306
+ ...zoneNodes(zones, projectXZ, layerScale, "wm"),
307
+ ...routeNodes(routes, projectXZ, "wm"),
308
+ ];
309
+ })(), markerList.map((marker) => {
201
310
  const at = project(marker.position[0], marker.position[2]);
202
311
  const style = markerKindStyle(marker.kind, kindStyles);
203
312
  return (_jsxs("g", { "data-world-marker": 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));
204
313
  }), player !== undefined
205
314
  ? (() => {
206
315
  const at = project(player[0], player[1]);
207
- return (_jsx("g", { transform: `translate(${at.x} ${at.y}) rotate(${heading * (180 / Math.PI)})`, children: _jsx("path", { d: "M0,-11 L7,9 L0,4 L-7,9 Z", fill: "#4ade80", stroke: "#052e16", strokeWidth: 1 }) }));
316
+ 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 }) }));
208
317
  })()
209
318
  : null] })] }));
210
319
  }
@@ -0,0 +1,6 @@
1
+ import type { ComponentType } from "react";
2
+ export type GamePreviewProps = {
3
+ className?: string;
4
+ };
5
+ export type GamePreviewComponent = ComponentType<GamePreviewProps>;
6
+ export type GamePreviewStates = Record<string, GamePreviewComponent>;
@@ -0,0 +1 @@
1
+ export {};
@@ -5,3 +5,5 @@ export declare function GameProvider({ context, children }: {
5
5
  children?: ReactNode;
6
6
  }): import("react").JSX.Element;
7
7
  export declare function useGameContext(): GameContext;
8
+ /** The game context if a `GameProvider` is present, otherwise `null` — for chrome that may render outside a running game (showcases, previews). */
9
+ export declare function useOptionalGameContext(): GameContext | null;
package/dist/provider.js CHANGED
@@ -10,3 +10,7 @@ export function useGameContext() {
10
10
  throw new Error("useGameContext must be used within <GameProvider>");
11
11
  return ctx;
12
12
  }
13
+ /** The game context if a `GameProvider` is present, otherwise `null` — for chrome that may render outside a running game (showcases, previews). */
14
+ export function useOptionalGameContext() {
15
+ return useContext(GameReactContext);
16
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Engine-owned rotate-device screen. A polished, non-dismissible full-viewport
3
+ * gate shown when a game requires an orientation the device isn't in. It owns
4
+ * the visible viewport (visualViewport-sized), respects safe areas, blocks all
5
+ * pointer/touch input to the game beneath, and is themeable through `--jg-*`
6
+ * tokens. Reduced-motion respected. This is game UI, not a website toast.
7
+ */
8
+ import type { CSSProperties, ReactNode } from "react";
9
+ import type { LayoutOrientation } from "@jgengine/core/ui/orientation";
10
+ /** Full-viewport, non-dismissible rotate-device gate shown when a game requires an orientation the device isn't in. */
11
+ export declare function RotateDeviceScreen({ title, description, requiredOrientation, accent, icon, className, style, }: {
12
+ /** Short headline. */
13
+ title?: string;
14
+ /** One concise explanatory line. Defaults from `requiredOrientation`. */
15
+ description?: string;
16
+ /** Orientation the game needs. Drives the rotation animation direction. */
17
+ requiredOrientation?: LayoutOrientation;
18
+ /** Illustration/token color. */
19
+ accent?: string;
20
+ /** Custom illustration; defaults to an animated phone glyph. */
21
+ icon?: ReactNode;
22
+ className?: string;
23
+ style?: CSSProperties;
24
+ }): import("react").JSX.Element;
@@ -0,0 +1,83 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ const STYLE_ID = "jg-rotate-device-style";
3
+ const ROTATE_CSS = `
4
+ .jg-rotate-root {
5
+ position: fixed;
6
+ inset: 0;
7
+ z-index: 2147483000;
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: center;
11
+ width: var(--jg-visual-viewport-width, 100vw);
12
+ height: var(--jg-visual-viewport-height, 100dvh);
13
+ min-height: var(--jg-visual-viewport-height, 100vh);
14
+ padding: calc(env(safe-area-inset-top, 0px) + 24px) calc(env(safe-area-inset-right, 0px) + 24px)
15
+ calc(env(safe-area-inset-bottom, 0px) + 24px) calc(env(safe-area-inset-left, 0px) + 24px);
16
+ color: var(--jg-text, #f4f4f6);
17
+ background:
18
+ radial-gradient(120% 120% at 50% 0%, var(--jg-accent-dim, rgba(120, 130, 160, 0.18)), transparent 60%),
19
+ var(--jg-surface, #0b0d13);
20
+ touch-action: none;
21
+ user-select: none;
22
+ -webkit-user-select: none;
23
+ overflow: hidden;
24
+ }
25
+ .jg-rotate-inner {
26
+ display: flex;
27
+ flex-direction: column;
28
+ align-items: center;
29
+ gap: 22px;
30
+ text-align: center;
31
+ max-width: 22rem;
32
+ }
33
+ .jg-rotate-phone {
34
+ transform-origin: 50% 50%;
35
+ }
36
+ .jg-rotate-title {
37
+ margin: 0;
38
+ font: 700 clamp(1.25rem, 5vw, 1.75rem) / 1.15 system-ui, -apple-system, "Segoe UI", sans-serif;
39
+ letter-spacing: -0.01em;
40
+ }
41
+ .jg-rotate-desc {
42
+ margin: 0;
43
+ font: 400 clamp(0.9rem, 3.4vw, 1.05rem) / 1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
44
+ color: var(--jg-text-dim, rgba(244, 244, 246, 0.66));
45
+ }
46
+ @media (prefers-reduced-motion: no-preference) {
47
+ .jg-rotate-phone--to-landscape { animation: jg-rotate-to-landscape 2.6s cubic-bezier(0.65, 0, 0.35, 1) infinite; }
48
+ .jg-rotate-phone--to-portrait { animation: jg-rotate-to-portrait 2.6s cubic-bezier(0.65, 0, 0.35, 1) infinite; }
49
+ }
50
+ @keyframes jg-rotate-to-landscape {
51
+ 0%, 22% { transform: rotate(0deg); }
52
+ 55%, 78% { transform: rotate(-90deg); }
53
+ 100% { transform: rotate(-90deg); }
54
+ }
55
+ @keyframes jg-rotate-to-portrait {
56
+ 0%, 22% { transform: rotate(-90deg); }
57
+ 55%, 78% { transform: rotate(0deg); }
58
+ 100% { transform: rotate(0deg); }
59
+ }
60
+ `;
61
+ function ensureStyle() {
62
+ if (typeof document === "undefined")
63
+ return;
64
+ if (document.getElementById(STYLE_ID) !== null)
65
+ return;
66
+ const el = document.createElement("style");
67
+ el.id = STYLE_ID;
68
+ el.textContent = ROTATE_CSS;
69
+ document.head.appendChild(el);
70
+ }
71
+ function PhoneGlyph({ accent }) {
72
+ return (_jsxs("svg", { width: "96", height: "96", viewBox: "0 0 96 96", fill: "none", "aria-hidden": true, focusable: "false", children: [_jsx("rect", { x: "34", y: "10", width: "28", height: "52", rx: "6", stroke: accent, strokeWidth: "3", fill: "none" }), _jsx("rect", { x: "39", y: "18", width: "18", height: "34", rx: "2", fill: accent, opacity: "0.22" }), _jsx("circle", { cx: "48", cy: "57.5", r: "1.8", fill: accent }), _jsx("path", { d: "M22 74a30 30 0 0 1 52 0", stroke: accent, strokeWidth: "3", strokeLinecap: "round", fill: "none", opacity: "0.5" }), _jsx("path", { d: "M70 68l6 8-10 1z", fill: accent, opacity: "0.5" })] }));
73
+ }
74
+ /** Full-viewport, non-dismissible rotate-device gate shown when a game requires an orientation the device isn't in. */
75
+ export function RotateDeviceScreen({ title = "Turn your device", description, requiredOrientation = "landscape", accent = "var(--jg-accent, #8ea2ff)", icon, className, style, }) {
76
+ ensureStyle();
77
+ const line = description ??
78
+ (requiredOrientation === "landscape"
79
+ ? "Rotate to landscape to play."
80
+ : "Rotate to portrait to play.");
81
+ const spin = requiredOrientation === "landscape" ? "jg-rotate-phone--to-landscape" : "jg-rotate-phone--to-portrait";
82
+ return (_jsx("div", { className: className === undefined ? "jg-rotate-root" : `jg-rotate-root ${className}`, style: style, "data-jg-orientation-gate": requiredOrientation, role: "dialog", "aria-modal": "true", "aria-label": title, onPointerDownCapture: (e) => e.stopPropagation(), onTouchStartCapture: (e) => e.stopPropagation(), children: _jsxs("div", { className: "jg-rotate-inner", children: [_jsx("div", { className: `jg-rotate-phone ${spin}`, children: icon ?? _jsx(PhoneGlyph, { accent: accent }) }), _jsx("h2", { className: "jg-rotate-title", children: title }), _jsx("p", { className: "jg-rotate-desc", children: line })] }) }));
83
+ }
@@ -0,0 +1,7 @@
1
+ export type SelectCache<TSnapshot, TSelected> = {
2
+ ready: boolean;
3
+ snapshot: TSnapshot;
4
+ value: TSelected;
5
+ };
6
+ export declare function createSelectCache<TSnapshot, TSelected>(): SelectCache<TSnapshot, TSelected>;
7
+ export declare function readSelectSnapshot<TSnapshot, TSelected>(cache: SelectCache<TSnapshot, TSelected>, snapshot: TSnapshot, select: (snapshot: TSnapshot) => TSelected, isEqual?: (previous: TSelected, next: TSelected) => boolean): TSelected;
@@ -0,0 +1,16 @@
1
+ export function createSelectCache() {
2
+ return { ready: false, snapshot: undefined, value: undefined };
3
+ }
4
+ export function readSelectSnapshot(cache, snapshot, select, isEqual = Object.is) {
5
+ if (cache.ready && Object.is(cache.snapshot, snapshot))
6
+ return cache.value;
7
+ const next = select(snapshot);
8
+ if (cache.ready && isEqual(cache.value, next)) {
9
+ cache.snapshot = snapshot;
10
+ return cache.value;
11
+ }
12
+ cache.ready = true;
13
+ cache.snapshot = snapshot;
14
+ cache.value = next;
15
+ return cache.value;
16
+ }