@jgengine/react 0.14.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.
- package/CHANGELOG.md +483 -1
- package/dist/accessibility.d.ts +39 -0
- package/dist/accessibility.js +61 -0
- package/dist/achievements.d.ts +50 -0
- package/dist/achievements.js +94 -0
- package/dist/cameraShake.d.ts +52 -0
- package/dist/cameraShake.js +53 -0
- package/dist/coachMarks.d.ts +72 -0
- package/dist/coachMarks.js +136 -0
- package/dist/codex.d.ts +25 -0
- package/dist/codex.js +62 -0
- package/dist/comboMeter.d.ts +63 -0
- package/dist/comboMeter.js +93 -0
- package/dist/components.js +4 -3
- package/dist/creditsScreen.d.ts +19 -0
- package/dist/creditsScreen.js +31 -0
- package/dist/cutscene.d.ts +66 -0
- package/dist/cutscene.js +132 -0
- package/dist/damageDirection.d.ts +51 -0
- package/dist/damageDirection.js +103 -0
- package/dist/dialogueView.d.ts +69 -0
- package/dist/dialogueView.js +111 -0
- package/dist/entityFrames.d.ts +114 -0
- package/dist/entityFrames.js +55 -0
- package/dist/fastTravel.d.ts +36 -0
- package/dist/fastTravel.js +103 -0
- package/dist/floatingText.d.ts +84 -0
- package/dist/floatingText.js +61 -0
- package/dist/gameViewport.js +8 -12
- package/dist/hooks.d.ts +42 -0
- package/dist/hooks.js +85 -0
- package/dist/hudTheme.d.ts +104 -7
- package/dist/hudTheme.js +300 -2
- package/dist/i18n.d.ts +29 -0
- package/dist/i18n.js +44 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +34 -0
- package/dist/interactionPrompt.d.ts +80 -0
- package/dist/interactionPrompt.js +112 -0
- package/dist/keybindingMenu.d.ts +59 -0
- package/dist/keybindingMenu.js +146 -0
- package/dist/killFeed.d.ts +63 -0
- package/dist/killFeed.js +88 -0
- package/dist/map.d.ts +123 -2
- package/dist/map.js +239 -36
- package/dist/menuRouter.d.ts +51 -0
- package/dist/menuRouter.js +49 -0
- package/dist/modals.d.ts +131 -0
- package/dist/modals.js +168 -0
- package/dist/notifications.d.ts +39 -0
- package/dist/notifications.js +111 -0
- package/dist/objectiveBanner.d.ts +62 -0
- package/dist/objectiveBanner.js +129 -0
- package/dist/photoMode.d.ts +21 -0
- package/dist/photoMode.js +39 -0
- package/dist/previewFixtures.d.ts +30 -0
- package/dist/previewFixtures.js +46 -0
- package/dist/questTracker.d.ts +24 -0
- package/dist/questTracker.js +31 -0
- package/dist/quickMenu.d.ts +39 -0
- package/dist/quickMenu.js +79 -0
- package/dist/radialMenu.d.ts +50 -0
- package/dist/radialMenu.js +69 -0
- package/dist/saveSlots.d.ts +81 -0
- package/dist/saveSlots.js +138 -0
- package/dist/scoreboard.d.ts +67 -0
- package/dist/scoreboard.js +71 -0
- package/dist/settings.d.ts +9 -3
- package/dist/settings.js +10 -4
- package/dist/shopGrid.d.ts +70 -0
- package/dist/shopGrid.js +129 -0
- package/dist/statusEffectBar.d.ts +52 -0
- package/dist/statusEffectBar.js +57 -0
- package/dist/streetGeometryPreview.d.ts +7 -0
- package/dist/streetGeometryPreview.js +119 -0
- package/dist/talentTree.d.ts +60 -0
- package/dist/talentTree.js +217 -0
- package/dist/timerReadout.d.ts +90 -0
- package/dist/timerReadout.js +111 -0
- package/dist/useDebouncedCommit.d.ts +54 -0
- package/dist/useDebouncedCommit.js +81 -0
- package/dist/waveHud.d.ts +49 -0
- package/dist/waveHud.js +53 -0
- package/dist/waypointMarkers.d.ts +80 -0
- package/dist/waypointMarkers.js +116 -0
- package/package.json +3 -3
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/jgengine/SKILL.md +3 -1
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-ui/SKILL.md +5 -3
- package/skills/jgengine-ui/api.md +708 -19
- package/skills/jgengine-ui/capabilities.md +362 -1
- package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
- package/skills/jgengine-ui/reference.md +7 -4
package/dist/killFeed.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useReducer, useRef } from "react";
|
|
3
|
+
import { GameIcon, isGameIconName } from "./gameIcons.js";
|
|
4
|
+
/**
|
|
5
|
+
* Subscribe to an event ticker and re-render it on an animation frame (unless the game
|
|
6
|
+
* already ticks it), returning the live entries newest-first with `fade` applied. Driving
|
|
7
|
+
* the frame loop is what makes entries visibly fade and drop as they age out.
|
|
8
|
+
*
|
|
9
|
+
* @capability use-event-ticker React hook binding an event-ticker — re-renders per frame so entries fade and drop live, returning the current newest-first views
|
|
10
|
+
*/
|
|
11
|
+
export function useEventTicker(ticker, animate = true) {
|
|
12
|
+
const [, bump] = useReducer((n) => n + 1, 0);
|
|
13
|
+
const rafRef = useRef(0);
|
|
14
|
+
useEffect(() => ticker.subscribe(bump), [ticker]);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!animate)
|
|
17
|
+
return;
|
|
18
|
+
let running = true;
|
|
19
|
+
const loop = () => {
|
|
20
|
+
if (!running)
|
|
21
|
+
return;
|
|
22
|
+
bump();
|
|
23
|
+
rafRef.current = requestAnimationFrame(loop);
|
|
24
|
+
};
|
|
25
|
+
rafRef.current = requestAnimationFrame(loop);
|
|
26
|
+
return () => {
|
|
27
|
+
running = false;
|
|
28
|
+
cancelAnimationFrame(rafRef.current);
|
|
29
|
+
};
|
|
30
|
+
}, [ticker, animate]);
|
|
31
|
+
return ticker.recent();
|
|
32
|
+
}
|
|
33
|
+
function resolveTheme(theme) {
|
|
34
|
+
return {
|
|
35
|
+
rowBg: theme?.rowBg ?? "var(--jg-frame-bg, rgba(12,16,22,0.82))",
|
|
36
|
+
text: theme?.text ?? "#f1f5f9",
|
|
37
|
+
accent: theme?.accent ?? "var(--jg-accent, #38bdf8)",
|
|
38
|
+
fontFamily: theme?.fontFamily ?? "ui-sans-serif, system-ui, sans-serif",
|
|
39
|
+
radius: theme?.radius ?? "var(--jg-frame-radius, 8px)",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A vertical event/kill-feed ticker: renders an {@link EventTicker}'s recent entries as a
|
|
44
|
+
* stack of rows, newest on top, each with its `fade` `0..1` mapped to opacity so entries dim
|
|
45
|
+
* and drop as they age out. Each row shows an optional per-kind (or per-entry) {@link GameIcon}
|
|
46
|
+
* and a per-kind accent color from `kindStyles`, styled with HudTheme tokens. Presentation
|
|
47
|
+
* only: all capping/timing lives in the core ticker, and the free-string `kind`/`text`/`icon`
|
|
48
|
+
* are never interpreted here — a game colors and icons each variant via `kindStyles`.
|
|
49
|
+
*
|
|
50
|
+
* @capability kill-feed vertical event/kill-feed ticker rendering a core event-ticker as a fading, newest-on-top stack of per-kind iconned, accent-colored rows over HudTheme tokens
|
|
51
|
+
*/
|
|
52
|
+
export function KillFeed({ ticker, kindStyles, theme, iconSize = 18, animate = true, zIndex = 40, className, style, }) {
|
|
53
|
+
const entries = useEventTicker(ticker, animate);
|
|
54
|
+
const t = resolveTheme(theme);
|
|
55
|
+
return (_jsx("div", { className: className, "data-kill-feed": "", style: {
|
|
56
|
+
display: "flex",
|
|
57
|
+
flexDirection: "column",
|
|
58
|
+
alignItems: "stretch",
|
|
59
|
+
gap: 6,
|
|
60
|
+
pointerEvents: "none",
|
|
61
|
+
fontFamily: t.fontFamily,
|
|
62
|
+
zIndex,
|
|
63
|
+
...style,
|
|
64
|
+
}, children: entries.map((entry) => {
|
|
65
|
+
const kindStyle = kindStyles?.[entry.kind];
|
|
66
|
+
const accent = kindStyle?.accent ?? t.accent;
|
|
67
|
+
const iconName = entry.icon ?? kindStyle?.icon;
|
|
68
|
+
const opacity = 1 - entry.fade * 0.85;
|
|
69
|
+
return (_jsxs("div", { "data-kill-feed-row": entry.id, "data-kind": entry.kind, style: {
|
|
70
|
+
display: "flex",
|
|
71
|
+
alignItems: "center",
|
|
72
|
+
gap: 8,
|
|
73
|
+
padding: "5px 10px",
|
|
74
|
+
background: t.rowBg,
|
|
75
|
+
borderLeft: `3px solid ${accent}`,
|
|
76
|
+
borderRadius: t.radius,
|
|
77
|
+
color: kindStyle?.color ?? t.text,
|
|
78
|
+
fontSize: 13,
|
|
79
|
+
fontWeight: 600,
|
|
80
|
+
lineHeight: 1.2,
|
|
81
|
+
whiteSpace: "nowrap",
|
|
82
|
+
opacity,
|
|
83
|
+
boxShadow: "0 2px 8px rgba(0,0,0,0.4)",
|
|
84
|
+
transform: `translateX(${entry.fade * 8}px)`,
|
|
85
|
+
willChange: "opacity, transform",
|
|
86
|
+
}, children: [iconName !== undefined && isGameIconName(iconName) ? (_jsx("span", { style: { color: accent, display: "flex", flexShrink: 0 }, children: _jsx(GameIcon, { name: iconName, size: iconSize }) })) : null, _jsx("span", { "data-kill-feed-text": "", children: entry.text })] }, entry.id));
|
|
87
|
+
}) }));
|
|
88
|
+
}
|
package/dist/map.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type CSSProperties, type ReactNode } from "react";
|
|
|
2
2
|
import type { FogField } from "@jgengine/core/world/fog";
|
|
3
3
|
import { type MapMarker, type MarkerCollection, type MarkerKindStyle, type MarkerSource, type MarkerSet, type MarkerView } from "@jgengine/core/world/markers";
|
|
4
4
|
import { type MinimapView, type WorldXZ } from "@jgengine/core/world/minimap";
|
|
5
|
-
import { type MapCellStates, type MapRoute, type MapZone } from "@jgengine/core/world/mapLayers";
|
|
5
|
+
import { type MapCellStates, type MapLayerTone, type MapRoute, type MapZone } from "@jgengine/core/world/mapLayers";
|
|
6
6
|
/** Subscribe to a native marker set or external marker source, or read a static marker array. */
|
|
7
7
|
export declare function useMarkers<TMeta = unknown>(markers: MarkerSet<TMeta>): readonly MapMarker<TMeta>[];
|
|
8
8
|
export declare function useMarkers<TMarker extends MarkerView>(markers: readonly TMarker[] | MarkerSource<TMarker>): readonly TMarker[];
|
|
@@ -210,9 +210,130 @@ export interface WorldMapProps {
|
|
|
210
210
|
title?: string;
|
|
211
211
|
onClose?: () => void;
|
|
212
212
|
}
|
|
213
|
+
/** Pan/zoom transform applied to a {@link WorldMapSurface}'s content group. */
|
|
214
|
+
export interface MapViewport {
|
|
215
|
+
/** Uniform zoom factor; 1 = fit. */
|
|
216
|
+
scale: number;
|
|
217
|
+
/** Content-group translation in canvas px. */
|
|
218
|
+
tx: number;
|
|
219
|
+
ty: number;
|
|
220
|
+
}
|
|
221
|
+
/** Props for {@link WorldMapSurface}. */
|
|
222
|
+
export interface WorldMapSurfaceProps {
|
|
223
|
+
markers: MarkerCollection;
|
|
224
|
+
bounds: MapBounds;
|
|
225
|
+
player?: WorldXZ;
|
|
226
|
+
facingYaw?: number;
|
|
227
|
+
fog?: FogField;
|
|
228
|
+
background?: string;
|
|
229
|
+
/** Content layout width in px (world bounds map into this). */
|
|
230
|
+
width?: number;
|
|
231
|
+
/** Content layout height; defaults to the bounds aspect ratio. */
|
|
232
|
+
height?: number;
|
|
233
|
+
kindStyles?: Record<string, MarkerKindStyle>;
|
|
234
|
+
routes?: readonly MapRoute[];
|
|
235
|
+
zones?: readonly MapZone[];
|
|
236
|
+
cellStates?: readonly MapCellStates[];
|
|
237
|
+
onWorldClick?: (world: WorldXZ) => void;
|
|
238
|
+
/** Outer `<svg>` pixel size; defaults to the content `width`/`height` (no viewport). */
|
|
239
|
+
canvasWidth?: number;
|
|
240
|
+
canvasHeight?: number;
|
|
241
|
+
/** Pan/zoom transform; identity when omitted. Click math inverts it before unprojecting. */
|
|
242
|
+
viewport?: MapViewport;
|
|
243
|
+
style?: CSSProperties;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The bare top-down map `<svg>` shared by {@link WorldMap} (framed panel) and
|
|
247
|
+
* {@link FullscreenMap} (pan/zoom overlay): baked terrain background, fog, map
|
|
248
|
+
* layers, markers with labels, and the player arrow, drawn under an optional
|
|
249
|
+
* viewport transform. Rectangular linear projection over the world `bounds`.
|
|
250
|
+
*/
|
|
251
|
+
export declare function WorldMapSurface({ markers, bounds, player, facingYaw, fog, background, width, height, kindStyles, routes, zones, cellStates, onWorldClick, canvasWidth, canvasHeight, viewport, style, }: WorldMapSurfaceProps): ReactNode;
|
|
213
252
|
/**
|
|
214
253
|
* Full-bounds top-down world map (the "press M" overlay): baked terrain
|
|
215
254
|
* background, reveal-on-event fog, all markers with labels, and the player.
|
|
216
|
-
* Rectangular linear projection over the supplied world `bounds`.
|
|
255
|
+
* Rectangular linear projection over the supplied world `bounds`. Framed panel
|
|
256
|
+
* wrapper over {@link WorldMapSurface}.
|
|
217
257
|
*/
|
|
218
258
|
export declare function WorldMap({ markers, bounds, player, facingYaw, fog, background, width, height, kindStyles, routes, zones, cellStates, onWorldClick, className, title, onClose, }: WorldMapProps): ReactNode;
|
|
259
|
+
/** Props for {@link FullscreenMap}. */
|
|
260
|
+
export interface FullscreenMapProps extends Omit<WorldMapSurfaceProps, "canvasWidth" | "canvasHeight" | "viewport" | "width" | "height" | "style"> {
|
|
261
|
+
/** Render nothing when false. Default true. */
|
|
262
|
+
open?: boolean;
|
|
263
|
+
/** Content layout width in px before zoom; height derives from the bounds aspect. Default 900. */
|
|
264
|
+
contentWidth?: number;
|
|
265
|
+
/** Zoom bounds. Default 0.5 / 8. */
|
|
266
|
+
minScale?: number;
|
|
267
|
+
maxScale?: number;
|
|
268
|
+
title?: string;
|
|
269
|
+
onClose?: () => void;
|
|
270
|
+
/**
|
|
271
|
+
* Interaction mode. `"pan"` (default) drags to pan and click-to-place fires
|
|
272
|
+
* `onWorldClick`; `"draw"` drags to paint a freehand stroke, committed to
|
|
273
|
+
* `onStrokeComplete` (panning and click-to-place are suppressed).
|
|
274
|
+
*/
|
|
275
|
+
tool?: "pan" | "draw";
|
|
276
|
+
/** Called with a finished freehand stroke (world-XZ points) in `"draw"` mode — wire to an annotation layer's `addStroke`. */
|
|
277
|
+
onStrokeComplete?: (points: readonly WorldXZ[]) => void;
|
|
278
|
+
/** Tone of the in-progress stroke while drawing. Default `"info"`. */
|
|
279
|
+
drawTone?: MapLayerTone;
|
|
280
|
+
/** Width (map px) of the in-progress stroke. Default 3. */
|
|
281
|
+
drawWidth?: number;
|
|
282
|
+
/** Overlay chrome drawn above the map (legend, tool palette, hints). */
|
|
283
|
+
children?: ReactNode;
|
|
284
|
+
overlayClassName?: string;
|
|
285
|
+
overlayStyle?: CSSProperties;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Fullscreen pan/zoom world-map overlay — the enlarged "press M" map. Wheel to
|
|
289
|
+
* zoom (toward the cursor), drag to pan, and click to place (wire `onWorldClick`
|
|
290
|
+
* to a waypoint/annotation store). Switch `tool` to `"draw"` to freehand-draw
|
|
291
|
+
* strokes committed to `onStrokeComplete` (wire to `createAnnotationLayer`).
|
|
292
|
+
* Reuses {@link WorldMapSurface} for rendering, so terrain bake, fog, routes,
|
|
293
|
+
* zones, markers, and the player arrow all appear; a drag never fires
|
|
294
|
+
* `onWorldClick`. Compose a {@link MapLegend} or tool palette via `children`.
|
|
295
|
+
*
|
|
296
|
+
* @capability fullscreen-map fullscreen pan/zoom world-map overlay over WorldMapSurface — wheel-zoom, drag-pan, click-to-place, and a freehand draw tool (onStrokeComplete)
|
|
297
|
+
*/
|
|
298
|
+
export declare function FullscreenMap({ open, bounds, contentWidth, minScale, maxScale, title, onClose, onWorldClick, tool, onStrokeComplete, drawTone, drawWidth, children, overlayClassName, overlayStyle, ...surfaceProps }: FullscreenMapProps): ReactNode;
|
|
299
|
+
/** Props for {@link MapLegend}. */
|
|
300
|
+
export interface MapLegendProps {
|
|
301
|
+
/** Marker kinds to key, in order. */
|
|
302
|
+
kinds: readonly string[];
|
|
303
|
+
/** Human labels per kind; falls back to the kind id. */
|
|
304
|
+
labels?: Record<string, string>;
|
|
305
|
+
kindStyles?: Record<string, MarkerKindStyle>;
|
|
306
|
+
title?: string;
|
|
307
|
+
className?: string;
|
|
308
|
+
style?: CSSProperties;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Marker-kind key for a map/minimap — glyph + color swatch per kind, labelled.
|
|
312
|
+
* Reads the same `kindStyles` the map renders with, so the legend can never
|
|
313
|
+
* drift from the pins.
|
|
314
|
+
*
|
|
315
|
+
* @capability map-legend marker-kind key (glyph + color + label per kind) sharing the map's kindStyles
|
|
316
|
+
*/
|
|
317
|
+
export declare function MapLegend({ kinds, labels, kindStyles, title, className, style, }: MapLegendProps): ReactNode;
|
|
318
|
+
/** Props for {@link WaypointArrow}. */
|
|
319
|
+
export interface WaypointArrowProps {
|
|
320
|
+
/** Bearing to the target relative to facing (radians); 0 = dead ahead. From `WaypointStore.guidance().relative`. */
|
|
321
|
+
relative: number;
|
|
322
|
+
/** XZ distance in world units; renders a readout when supplied. */
|
|
323
|
+
distance?: number;
|
|
324
|
+
label?: string;
|
|
325
|
+
/** Format the distance readout; defaults to whole units + "m". */
|
|
326
|
+
formatDistance?: (distance: number) => string;
|
|
327
|
+
size?: number;
|
|
328
|
+
color?: string;
|
|
329
|
+
className?: string;
|
|
330
|
+
style?: CSSProperties;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* On-screen guide arrow to the tracked waypoint — a HUD compass needle rotated
|
|
334
|
+
* by the facing-relative bearing, with an optional label and distance readout.
|
|
335
|
+
* Pair with `WaypointStore.guidance(playerXZ, facingYaw)`.
|
|
336
|
+
*
|
|
337
|
+
* @capability waypoint-arrow on-screen HUD guide arrow to a tracked waypoint, rotated by facing-relative bearing with a distance readout
|
|
338
|
+
*/
|
|
339
|
+
export declare function WaypointArrow({ relative, distance, label, formatDistance, size, color, className, style, }: WaypointArrowProps): ReactNode;
|
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
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
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
|
|
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] }),
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
+
}
|