@jgengine/react 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -3
- package/LICENSE +203 -661
- package/README.md +1 -1
- package/dist/actionHud.d.ts +174 -0
- package/dist/actionHud.js +292 -0
- package/dist/cards.d.ts +59 -0
- package/dist/cards.js +116 -0
- package/dist/chat.d.ts +6 -4
- package/dist/chat.js +16 -7
- package/dist/chatBubbles.js +2 -1
- package/dist/components.d.ts +20 -0
- package/dist/components.js +29 -1
- package/dist/display.d.ts +1 -0
- package/dist/display.js +1 -0
- package/dist/engineStore.d.ts +3 -0
- package/dist/engineStore.js +3 -0
- package/dist/fogOverlay.d.ts +3 -0
- package/dist/fogOverlay.js +3 -0
- package/dist/gameViewport.d.ts +54 -0
- package/dist/gameViewport.js +356 -0
- package/dist/hooks.d.ts +22 -0
- package/dist/hooks.js +58 -12
- package/dist/hud.d.ts +87 -0
- package/dist/hud.js +144 -0
- package/dist/hudLayout.d.ts +39 -4
- package/dist/hudLayout.js +152 -11
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/keyHint.d.ts +30 -0
- package/dist/keyHint.js +31 -0
- package/dist/map.d.ts +69 -1
- package/dist/map.js +54 -1
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +4 -0
- package/dist/rotateDevice.d.ts +24 -0
- package/dist/rotateDevice.js +83 -0
- package/dist/save.d.ts +25 -0
- package/dist/save.js +37 -0
- package/dist/selectSnapshot.d.ts +2 -0
- package/dist/selectSnapshot.js +2 -0
- package/dist/selectionHud.d.ts +133 -0
- package/dist/selectionHud.js +182 -0
- package/dist/settings.d.ts +3 -0
- package/dist/skillCheckPaint.d.ts +2 -0
- package/dist/skillCheckPaint.js +2 -0
- package/dist/social.d.ts +3 -3
- package/dist/social.js +31 -10
- package/dist/startScreen.d.ts +62 -0
- package/dist/startScreen.js +62 -0
- package/dist/store.d.ts +24 -0
- package/dist/store.js +9 -0
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +51 -0
- package/skills/jgengine/api.md +434 -0
- package/skills/jgengine/capabilities.md +29 -0
- package/skills/jgengine/reference-cartridge.md +72 -0
- package/skills/jgengine-ui/SKILL.md +36 -0
- package/skills/jgengine-ui/api.md +1852 -0
- package/skills/jgengine-ui/capabilities.md +142 -0
- package/skills/jgengine-ui/reference.md +500 -0
- package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
- package/llms.txt +0 -1633
package/dist/map.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import type { FogField } from "@jgengine/core/world/fog";
|
|
3
3
|
import { type MapMarker, type MarkerKindStyle, type MarkerSet } from "@jgengine/core/world/markers";
|
|
4
|
-
import { type WorldXZ } from "@jgengine/core/world/minimap";
|
|
4
|
+
import { type MinimapView, type WorldXZ } from "@jgengine/core/world/minimap";
|
|
5
5
|
import { type MapCellStates, type MapRoute, type MapZone } from "@jgengine/core/world/mapLayers";
|
|
6
6
|
export declare function useMarkers(markers: MarkerSet): readonly MapMarker[];
|
|
7
7
|
export declare function useFog(fog: FogField): ReturnType<FogField["cells"]>;
|
|
@@ -40,8 +40,53 @@ export interface MapBounds {
|
|
|
40
40
|
* Framed circular minimap: optional baked terrain background, reveal-on-event
|
|
41
41
|
* fog overlay, categorized marker icons, and a facing arrow. Reads a core
|
|
42
42
|
* `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
|
|
43
|
+
*
|
|
44
|
+
* @capability minimap framed circular minimap with terrain bake, fog, markers, and facing arrow
|
|
43
45
|
*/
|
|
44
46
|
export declare function Minimap({ markers, center, worldRadius, fog, size, facingYaw, rotate, kindStyles, background, mapBounds, routes, zones, cellStates, onWorldClick, className, title, children, }: MinimapProps): ReactNode;
|
|
47
|
+
/** One dot (or, with `heading`, a rotated arrow) drawn by `MinimapChrome`. */
|
|
48
|
+
export interface MinimapChromeMarker {
|
|
49
|
+
id: string;
|
|
50
|
+
position: WorldXZ | readonly [number, number, number];
|
|
51
|
+
/** Bearing (radians) to draw an arrow instead of a dot — pass `headingToBearing(yaw)` for the player/other facing blips. */
|
|
52
|
+
heading?: number;
|
|
53
|
+
color?: string;
|
|
54
|
+
radius?: number;
|
|
55
|
+
glyph?: string;
|
|
56
|
+
glyphColor?: string;
|
|
57
|
+
strokeColor?: string;
|
|
58
|
+
strokeWidth?: number;
|
|
59
|
+
/** Clamp to the minimap edge when outside `view.worldRadius` (off-screen indicator); `false` hides it instead. Default `true`. */
|
|
60
|
+
clampToEdge?: boolean;
|
|
61
|
+
}
|
|
62
|
+
/** Props for `MinimapChrome`. */
|
|
63
|
+
export interface MinimapChromeProps {
|
|
64
|
+
/** Shared with `projectToMinimap`/`clampToMinimapEdge` — pass `view.rotate` to keep a rotating map's arrows pointed correctly. */
|
|
65
|
+
view: MinimapView;
|
|
66
|
+
/** Ring outline (+ `cardinalLabel`). Default `false` — most games draw their own frame/panel around the chrome. */
|
|
67
|
+
frame?: boolean;
|
|
68
|
+
frameColor?: string;
|
|
69
|
+
frameStrokeWidth?: number;
|
|
70
|
+
/** Label drawn at the top of the ring when `frame` is on; `false` omits it. */
|
|
71
|
+
cardinalLabel?: string | false;
|
|
72
|
+
markers?: readonly MinimapChromeMarker[];
|
|
73
|
+
markerColor?: string;
|
|
74
|
+
markerRadius?: number;
|
|
75
|
+
className?: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Headless minimap chrome — nests inside a game's own `<svg>`: an optional
|
|
79
|
+
* ring frame, and edge-clamped marker dots that draw as directional arrows
|
|
80
|
+
* when a marker carries a `heading` (the compass-arrow / player-blip layer
|
|
81
|
+
* ~8 games were re-drawing by hand over `projectToMinimap`/`clampToMinimapEdge`).
|
|
82
|
+
* Renders a bare `<g>` — no background, panel, or title; compose your own
|
|
83
|
+
* content layer (routes, zones, terrain) as sibling SVG nodes for full control
|
|
84
|
+
* of z-order, or call `MinimapChrome` twice (once for `frame`, again for
|
|
85
|
+
* `markers`) to sandwich custom content between the ring and the blips.
|
|
86
|
+
*
|
|
87
|
+
* @capability minimap-chrome ring frame plus edge-clamped, heading-aware marker dots to nest inside a game's own `<svg>`
|
|
88
|
+
*/
|
|
89
|
+
export declare function MinimapChrome({ view, frame, frameColor, frameStrokeWidth, cardinalLabel, markers, markerColor, markerRadius, className, }: MinimapChromeProps): ReactNode;
|
|
45
90
|
export interface CompassProps {
|
|
46
91
|
/** Facing yaw (`rotationY`, forward = `(sin yaw, cos yaw)`) of the tracked entity — pass it raw, never pre-converted to a bearing. */
|
|
47
92
|
facingYaw: number;
|
|
@@ -57,6 +102,29 @@ export interface CompassProps {
|
|
|
57
102
|
* eight cardinals and optional marker pips (bearing to each `MarkerSet` entry).
|
|
58
103
|
*/
|
|
59
104
|
export declare function Compass({ facingYaw, center, markers, width, fov, kindStyles, className, }: CompassProps): ReactNode;
|
|
105
|
+
/** Props for `MinimapPanel` — all `MinimapProps` plus the zone-label/clock header and compass slots. */
|
|
106
|
+
export interface MinimapPanelProps extends MinimapProps {
|
|
107
|
+
/** Zone/area name slot rendered in the chrome header — the game supplies its own zone lookup. */
|
|
108
|
+
zoneLabel?: ReactNode;
|
|
109
|
+
/** Clock readout slot rendered in the chrome header — pair with `useGameClock()` + `calendar()`. */
|
|
110
|
+
clock?: ReactNode;
|
|
111
|
+
/** Compass strip beneath the circle. Default true; pass `false` to omit it entirely. */
|
|
112
|
+
showCompass?: boolean;
|
|
113
|
+
compassProps?: Partial<Omit<CompassProps, "className">>;
|
|
114
|
+
headerClassName?: string;
|
|
115
|
+
zoneLabelClassName?: string;
|
|
116
|
+
clockClassName?: string;
|
|
117
|
+
compassClassName?: string;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Composed circular-minimap chrome: optional zone-label + clock header above
|
|
121
|
+
* the `Minimap`, optional `Compass` strip below. Purely a wiring layer over
|
|
122
|
+
* the existing primitives — the game supplies the zone name and clock text,
|
|
123
|
+
* this only places them; omit either slot and the header disappears.
|
|
124
|
+
*
|
|
125
|
+
* @capability minimap-panel circular minimap with zone label, clock, and compass composed together
|
|
126
|
+
*/
|
|
127
|
+
export declare function MinimapPanel({ zoneLabel, clock, showCompass, compassProps, headerClassName, zoneLabelClassName, clockClassName, compassClassName, children, ...minimapProps }: MinimapPanelProps): ReactNode;
|
|
60
128
|
export interface WorldMapProps {
|
|
61
129
|
markers: MarkerSet;
|
|
62
130
|
bounds: MapBounds;
|
package/dist/map.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useMemo, useSyncExternalStore } from "react";
|
|
3
3
|
import { DEFAULT_MARKER_KINDS, markerKindStyle, } from "@jgengine/core/world/markers";
|
|
4
4
|
import { bearingToCardinal, clampToMinimapEdge, compassBearing, headingToBearing, projectToMinimap, relativeBearing, unprojectFromMinimap, } from "@jgengine/core/world/minimap";
|
|
@@ -78,6 +78,8 @@ export function useFog(fog) {
|
|
|
78
78
|
* Framed circular minimap: optional baked terrain background, reveal-on-event
|
|
79
79
|
* fog overlay, categorized marker icons, and a facing arrow. Reads a core
|
|
80
80
|
* `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
|
|
81
|
+
*
|
|
82
|
+
* @capability minimap framed circular minimap with terrain bake, fog, markers, and facing arrow
|
|
81
83
|
*/
|
|
82
84
|
export function Minimap({ markers, center, worldRadius, fog, size = 176, facingYaw = 0, rotate = false, kindStyles = DEFAULT_MARKER_KINDS, background, mapBounds, routes, zones, cellStates, onWorldClick, className, title = "Map", children, }) {
|
|
83
85
|
const markerList = useMarkers(markers);
|
|
@@ -162,6 +164,36 @@ export function Minimap({ markers, center, worldRadius, fog, size = 176, facingY
|
|
|
162
164
|
return (_jsxs("g", { "data-marker-kind": marker.kind, children: [_jsx("circle", { cx: at.x, cy: at.y, r: 7, fill: "rgba(2,6,12,0.55)" }), _jsx("text", { x: at.x, y: at.y + 3.5, textAnchor: "middle", fontSize: 10, fill: style.color, style: { fontWeight: 700 }, children: style.glyph })] }, marker.id));
|
|
163
165
|
}), _jsx("g", { transform: `translate(${half} ${half}) rotate(${(rotate ? 0 : bearing) * (180 / Math.PI)})`, children: _jsx("path", { d: "M0,-9 L6,7 L0,3 L-6,7 Z", fill: "#4ade80", stroke: "#052e16", strokeWidth: 0.75 }) })] }), _jsx("circle", { cx: half, cy: half, r: half - 1, fill: "none", stroke: "rgba(148,163,184,0.4)", strokeWidth: 1.5 }), _jsx("text", { x: half, y: 13, textAnchor: "middle", fontSize: 11, fill: "rgba(226,232,240,0.9)", style: { fontWeight: 700 }, children: "N" })] }), children] }));
|
|
164
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Headless minimap chrome — nests inside a game's own `<svg>`: an optional
|
|
169
|
+
* ring frame, and edge-clamped marker dots that draw as directional arrows
|
|
170
|
+
* when a marker carries a `heading` (the compass-arrow / player-blip layer
|
|
171
|
+
* ~8 games were re-drawing by hand over `projectToMinimap`/`clampToMinimapEdge`).
|
|
172
|
+
* Renders a bare `<g>` — no background, panel, or title; compose your own
|
|
173
|
+
* content layer (routes, zones, terrain) as sibling SVG nodes for full control
|
|
174
|
+
* of z-order, or call `MinimapChrome` twice (once for `frame`, again for
|
|
175
|
+
* `markers`) to sandwich custom content between the ring and the blips.
|
|
176
|
+
*
|
|
177
|
+
* @capability minimap-chrome ring frame plus edge-clamped, heading-aware marker dots to nest inside a game's own `<svg>`
|
|
178
|
+
*/
|
|
179
|
+
export function MinimapChrome({ view, frame = false, frameColor = "rgba(148,163,184,0.4)", frameStrokeWidth = 1.5, cardinalLabel = "N", markers = [], markerColor = "#e2e8f0", markerRadius = 5, className, }) {
|
|
180
|
+
const half = view.size / 2;
|
|
181
|
+
const rotate = view.rotate ?? 0;
|
|
182
|
+
return (_jsxs("g", { className: className, "data-minimap-chrome": true, children: [frame ? (_jsxs(_Fragment, { children: [_jsx("circle", { cx: half, cy: half, r: half - 1, fill: "none", stroke: frameColor, strokeWidth: frameStrokeWidth }), cardinalLabel !== false ? (_jsx("text", { x: half, y: 13, textAnchor: "middle", fontSize: 11, fill: frameColor, style: { fontWeight: 700 }, children: cardinalLabel })) : null] })) : null, markers.map((marker) => {
|
|
183
|
+
const projected = projectToMinimap(marker.position, view);
|
|
184
|
+
const clamp = marker.clampToEdge ?? true;
|
|
185
|
+
if (!projected.inside && !clamp)
|
|
186
|
+
return null;
|
|
187
|
+
const at = projected.inside ? projected : clampToMinimapEdge(projected, view.size);
|
|
188
|
+
const color = marker.color ?? markerColor;
|
|
189
|
+
const radius = marker.radius ?? markerRadius;
|
|
190
|
+
if (marker.heading !== undefined) {
|
|
191
|
+
const deg = (marker.heading - rotate) * (180 / Math.PI);
|
|
192
|
+
return (_jsx("g", { "data-minimap-marker": marker.id, transform: `translate(${at.x} ${at.y}) rotate(${deg})`, children: _jsx("path", { d: `M0,${-radius} L${radius * 0.7},${radius * 0.8} L0,${radius * 0.35} L${-radius * 0.7},${radius * 0.8} Z`, fill: color, stroke: marker.strokeColor ?? "rgba(0,0,0,0.6)", strokeWidth: marker.strokeWidth ?? 0.75 }) }, marker.id));
|
|
193
|
+
}
|
|
194
|
+
return (_jsxs("g", { "data-minimap-marker": marker.id, children: [_jsx("circle", { cx: at.x, cy: at.y, r: radius, fill: color, stroke: marker.strokeColor, strokeWidth: marker.strokeWidth }), marker.glyph !== undefined ? (_jsx("text", { x: at.x, y: at.y + 3.5, textAnchor: "middle", fontSize: 10, fill: marker.glyphColor ?? "#0f172a", style: { fontWeight: 700 }, children: marker.glyph })) : null] }, marker.id));
|
|
195
|
+
})] }));
|
|
196
|
+
}
|
|
165
197
|
const NO_SUBSCRIBE = () => () => undefined;
|
|
166
198
|
const NULL_CELLS = () => null;
|
|
167
199
|
const EMPTY_MARKERS_VALUE = [];
|
|
@@ -240,6 +272,27 @@ export function Compass({ facingYaw, center, markers, width = 340, fov = (Math.P
|
|
|
240
272
|
})
|
|
241
273
|
: null] }));
|
|
242
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* Composed circular-minimap chrome: optional zone-label + clock header above
|
|
277
|
+
* the `Minimap`, optional `Compass` strip below. Purely a wiring layer over
|
|
278
|
+
* the existing primitives — the game supplies the zone name and clock text,
|
|
279
|
+
* this only places them; omit either slot and the header disappears.
|
|
280
|
+
*
|
|
281
|
+
* @capability minimap-panel circular minimap with zone label, clock, and compass composed together
|
|
282
|
+
*/
|
|
283
|
+
export function MinimapPanel({ zoneLabel, clock, showCompass = true, compassProps, headerClassName, zoneLabelClassName, clockClassName, compassClassName, children, ...minimapProps }) {
|
|
284
|
+
const hasHeader = zoneLabel !== undefined || clock !== undefined;
|
|
285
|
+
return (_jsxs("div", { "data-minimap-chrome": true, children: [hasHeader ? (_jsxs("div", { className: headerClassName, "data-minimap-chrome-header": true, style: {
|
|
286
|
+
display: "flex",
|
|
287
|
+
justifyContent: "space-between",
|
|
288
|
+
alignItems: "baseline",
|
|
289
|
+
gap: 8,
|
|
290
|
+
marginBottom: 4,
|
|
291
|
+
width: minimapProps.size ?? 176,
|
|
292
|
+
color: "#e2e8f0",
|
|
293
|
+
fontFamily: "ui-sans-serif, system-ui, sans-serif",
|
|
294
|
+
}, children: [zoneLabel !== undefined ? (_jsx("span", { className: zoneLabelClassName, "data-minimap-chrome-zone": true, style: { fontSize: 12, fontWeight: 700 }, children: zoneLabel })) : (_jsx("span", {})), clock !== undefined ? (_jsx("span", { className: clockClassName, "data-minimap-chrome-clock": true, style: { fontSize: 11, color: "rgba(226,232,240,0.75)" }, children: clock })) : null] })) : null, _jsx(Minimap, { ...minimapProps, children: children }), showCompass ? (_jsx("div", { className: compassClassName, "data-minimap-chrome-compass": true, style: { marginTop: 6 }, children: _jsx(Compass, { facingYaw: minimapProps.facingYaw ?? 0, center: minimapProps.center, markers: minimapProps.markers, width: minimapProps.size ?? 176, ...compassProps }) })) : null] }));
|
|
295
|
+
}
|
|
243
296
|
function WorldMapFogImage({ fogCells, project, worldW, worldD, width, mapH, }) {
|
|
244
297
|
const fogImage = useMemo(() => {
|
|
245
298
|
if (fogCells === null)
|
package/dist/provider.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/save.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SaveStatus, SaveStore } from "@jgengine/core/game/saveStore";
|
|
2
|
+
/** The reactive slice of a {@link SaveStore} returned by {@link useSaveStore} — the live value plus bound save actions, all re-rendering on change. */
|
|
3
|
+
export interface SaveStoreView<T> {
|
|
4
|
+
value: T;
|
|
5
|
+
status: SaveStatus;
|
|
6
|
+
slot: string;
|
|
7
|
+
/** True while the initial `load()` is in flight — gate a loading screen on it. */
|
|
8
|
+
loading: boolean;
|
|
9
|
+
/** True while a write is in flight — show a "Saving…" indicator. */
|
|
10
|
+
saving: boolean;
|
|
11
|
+
set: (value: T) => void;
|
|
12
|
+
patch: (mutate: (previous: T) => T) => void;
|
|
13
|
+
save: () => Promise<void>;
|
|
14
|
+
clear: () => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Bind a {@link SaveStore} to React: returns the live `value` and `status` and
|
|
18
|
+
* re-renders whenever either changes. By default it calls `load()` once on
|
|
19
|
+
* mount (pass `{ load: false }` to hydrate yourself). The same hook works for
|
|
20
|
+
* offline (localStorage) and cloud (Convex/DB) saves — only the store's backend
|
|
21
|
+
* differs.
|
|
22
|
+
*/
|
|
23
|
+
export declare function useSaveStore<T>(store: SaveStore<T>, options?: {
|
|
24
|
+
load?: boolean;
|
|
25
|
+
}): SaveStoreView<T>;
|
package/dist/save.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Bind a {@link SaveStore} to React: returns the live `value` and `status` and
|
|
4
|
+
* re-renders whenever either changes. By default it calls `load()` once on
|
|
5
|
+
* mount (pass `{ load: false }` to hydrate yourself). The same hook works for
|
|
6
|
+
* offline (localStorage) and cloud (Convex/DB) saves — only the store's backend
|
|
7
|
+
* differs.
|
|
8
|
+
*/
|
|
9
|
+
export function useSaveStore(store, options) {
|
|
10
|
+
useSyncExternalStore(store.subscribe, store.revision, store.revision);
|
|
11
|
+
const shouldLoad = options?.load !== false;
|
|
12
|
+
const loadedFor = useRef(null);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!shouldLoad)
|
|
15
|
+
return;
|
|
16
|
+
if (loadedFor.current === store)
|
|
17
|
+
return;
|
|
18
|
+
loadedFor.current = store;
|
|
19
|
+
void store.load();
|
|
20
|
+
}, [store, shouldLoad]);
|
|
21
|
+
const set = useCallback((value) => store.set(value), [store]);
|
|
22
|
+
const patch = useCallback((mutate) => void store.patch(mutate), [store]);
|
|
23
|
+
const save = useCallback(() => store.save(), [store]);
|
|
24
|
+
const clear = useCallback(() => store.clear(), [store]);
|
|
25
|
+
const status = store.status();
|
|
26
|
+
return {
|
|
27
|
+
value: store.value(),
|
|
28
|
+
status,
|
|
29
|
+
slot: store.slot(),
|
|
30
|
+
loading: status === "loading",
|
|
31
|
+
saving: status === "saving",
|
|
32
|
+
set,
|
|
33
|
+
patch,
|
|
34
|
+
save,
|
|
35
|
+
clear,
|
|
36
|
+
};
|
|
37
|
+
}
|
package/dist/selectSnapshot.d.ts
CHANGED
|
@@ -3,5 +3,7 @@ export type SelectCache<TSnapshot, TSelected> = {
|
|
|
3
3
|
snapshot: TSnapshot;
|
|
4
4
|
value: TSelected;
|
|
5
5
|
};
|
|
6
|
+
/** @internal */
|
|
6
7
|
export declare function createSelectCache<TSnapshot, TSelected>(): SelectCache<TSnapshot, TSelected>;
|
|
8
|
+
/** @internal */
|
|
7
9
|
export declare function readSelectSnapshot<TSnapshot, TSelected>(cache: SelectCache<TSnapshot, TSelected>, snapshot: TSnapshot, select: (snapshot: TSnapshot) => TSelected, isEqual?: (previous: TSelected, next: TSelected) => boolean): TSelected;
|
package/dist/selectSnapshot.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
/** @internal */
|
|
1
2
|
export function createSelectCache() {
|
|
2
3
|
return { ready: false, snapshot: undefined, value: undefined };
|
|
3
4
|
}
|
|
5
|
+
/** @internal */
|
|
4
6
|
export function readSelectSnapshot(cache, snapshot, select, isEqual = Object.is) {
|
|
5
7
|
if (cache.ready && Object.is(cache.snapshot, snapshot))
|
|
6
8
|
return cache.value;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { type EntitySummaryDef, type EntityVital, type SelectionGroup, type SelectionView, type SelectionWindow } from "@jgengine/core/ui/selectionModel";
|
|
3
|
+
/** A minimal keyboard-event shape — a real `KeyboardEvent`/React synthetic satisfies it. */
|
|
4
|
+
export interface SelectionKeyEvent {
|
|
5
|
+
key: string;
|
|
6
|
+
preventDefault(): void;
|
|
7
|
+
}
|
|
8
|
+
/** Options for {@link useSelectionView}. */
|
|
9
|
+
export interface UseSelectionViewOptions {
|
|
10
|
+
/** Member count above which `grouped` flips true (default 12). */
|
|
11
|
+
groupThreshold?: number;
|
|
12
|
+
/** Map a member `kind` to a display label for its group. */
|
|
13
|
+
labelOf?: (kind: string) => string;
|
|
14
|
+
/** Fired when the focused (primary) member changes. */
|
|
15
|
+
onFocusChange?: (id: string) => void;
|
|
16
|
+
}
|
|
17
|
+
/** The live selection model returned by {@link useSelectionView}. */
|
|
18
|
+
export interface SelectionModel {
|
|
19
|
+
view: SelectionView;
|
|
20
|
+
focusId: string | null;
|
|
21
|
+
setFocus: (id: string) => void;
|
|
22
|
+
onKeyDown: (event: SelectionKeyEvent) => void;
|
|
23
|
+
/** A virtualization window over the members, for a large selection strip. */
|
|
24
|
+
window: (scroll: number, size: number) => SelectionWindow;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The DATA/HOOK layer: summarize a selected-entity list into a live model with a focused primary,
|
|
28
|
+
* ordered members, same-kind groups, keyboard focus stepping, and a virtualization window.
|
|
29
|
+
* Rendering-agnostic — feed it to {@link SelectionPanel} or read the `view` directly.
|
|
30
|
+
*
|
|
31
|
+
* @capability selection-model headless multi-entity selection model with primary focus, grouping, and virtualization
|
|
32
|
+
*/
|
|
33
|
+
export declare function useSelectionView(members: readonly EntitySummaryDef[], options?: UseSelectionViewOptions): SelectionModel;
|
|
34
|
+
/**
|
|
35
|
+
* A compact current/max vital meter for an entity summary; tone maps to a colour ramp. Reuse it as
|
|
36
|
+
* the vital row inside {@link EntitySummary} or any custom entity panel.
|
|
37
|
+
*
|
|
38
|
+
* @capability vital-bar compact current/max vital meter for an entity summary
|
|
39
|
+
*/
|
|
40
|
+
export declare function VitalBar({ vital, width, showLabel, className, style, }: {
|
|
41
|
+
vital: EntityVital;
|
|
42
|
+
width?: number;
|
|
43
|
+
showLabel?: boolean;
|
|
44
|
+
className?: string;
|
|
45
|
+
style?: CSSProperties;
|
|
46
|
+
}): import("react").JSX.Element;
|
|
47
|
+
/**
|
|
48
|
+
* A selectable entity portrait tile — icon/initials, an optional thin health strip, and complete
|
|
49
|
+
* selected/hover/focus states as a real `<button>` (`aria-pressed`). The unit-of-a-selection chrome;
|
|
50
|
+
* swap the face via `renderIcon`. Accessible and keyboard-focusable.
|
|
51
|
+
*
|
|
52
|
+
* @capability entity-portrait selectable entity portrait tile with vitals and selected/focus states
|
|
53
|
+
*/
|
|
54
|
+
export declare function EntityPortrait({ entity, selected, size, vitalId, tabIndex, onSelect, renderIcon, className, style, }: {
|
|
55
|
+
entity: EntitySummaryDef;
|
|
56
|
+
selected?: boolean;
|
|
57
|
+
size?: number;
|
|
58
|
+
vitalId?: string;
|
|
59
|
+
tabIndex?: number;
|
|
60
|
+
onSelect?: (id: string) => void;
|
|
61
|
+
renderIcon?: (entity: EntitySummaryDef) => ReactNode;
|
|
62
|
+
className?: string;
|
|
63
|
+
style?: CSSProperties;
|
|
64
|
+
}): import("react").JSX.Element;
|
|
65
|
+
/**
|
|
66
|
+
* The primary-entity detail chrome — name, tags, and full vital bars for the focused member. The
|
|
67
|
+
* "who is selected" summary a single-selection or the head of a multi-selection shows. Swap the vital
|
|
68
|
+
* rows via `renderVital`.
|
|
69
|
+
*
|
|
70
|
+
* @capability entity-summary primary selected-entity detail panel (name, tags, vitals)
|
|
71
|
+
*/
|
|
72
|
+
export declare function EntitySummary({ entity, renderVital, className, style, }: {
|
|
73
|
+
entity: EntitySummaryDef;
|
|
74
|
+
renderVital?: (vital: EntityVital) => ReactNode;
|
|
75
|
+
className?: string;
|
|
76
|
+
style?: CSSProperties;
|
|
77
|
+
}): import("react").JSX.Element;
|
|
78
|
+
/**
|
|
79
|
+
* A same-kind selection chip — icon plus count — for the grouped large-selection view. The default
|
|
80
|
+
* chrome {@link SelectionCollectionChrome} renders per group; swap it via `renderGroup`.
|
|
81
|
+
*
|
|
82
|
+
* @capability selection-group-chip same-kind selection chip (icon + count) for a grouped selection
|
|
83
|
+
*/
|
|
84
|
+
export declare function SelectionGroupChip({ group, onSelect, renderIcon, className, style, }: {
|
|
85
|
+
group: SelectionGroup;
|
|
86
|
+
onSelect?: (group: SelectionGroup) => void;
|
|
87
|
+
renderIcon?: (group: SelectionGroup) => ReactNode;
|
|
88
|
+
className?: string;
|
|
89
|
+
style?: CSSProperties;
|
|
90
|
+
}): import("react").JSX.Element;
|
|
91
|
+
/**
|
|
92
|
+
* The composable RENDERER for the member collection: a portrait strip for a small selection, or the
|
|
93
|
+
* same-kind group chips once the model is `grouped`. Roving tabindex + arrow-key focus. Swap
|
|
94
|
+
* `renderMember`/`renderGroup` for custom chrome, or read the model and lay it out yourself. No panel
|
|
95
|
+
* skin — a behavior/accessibility layer only.
|
|
96
|
+
*
|
|
97
|
+
* @capability selection-collection accessible portrait-strip / group-chip renderer over a selection model
|
|
98
|
+
*/
|
|
99
|
+
export declare function SelectionCollectionChrome({ model, itemSize, gap, windowSize, scroll, ariaLabel, renderMember, renderGroup, onGroupSelect, className, style, }: {
|
|
100
|
+
model: SelectionModel;
|
|
101
|
+
itemSize?: number;
|
|
102
|
+
gap?: number;
|
|
103
|
+
/** Cap the mounted portraits; the model virtualizes past this many members. */
|
|
104
|
+
windowSize?: number;
|
|
105
|
+
scroll?: number;
|
|
106
|
+
ariaLabel?: string;
|
|
107
|
+
renderMember?: (entity: EntitySummaryDef, selected: boolean) => ReactNode;
|
|
108
|
+
renderGroup?: (group: SelectionGroup) => ReactNode;
|
|
109
|
+
onGroupSelect?: (group: SelectionGroup) => void;
|
|
110
|
+
className?: string;
|
|
111
|
+
style?: CSSProperties;
|
|
112
|
+
}): import("react").JSX.Element;
|
|
113
|
+
/**
|
|
114
|
+
* The batteries-included default: a skinned glass panel composing {@link EntitySummary} for the
|
|
115
|
+
* primary member beside {@link SelectionCollectionChrome} for the rest. Pass `members` (or a prebuilt
|
|
116
|
+
* `model`) and it renders a working RTS-style selection panel; every sub-renderer stays swappable, and
|
|
117
|
+
* a game that wants a different frame composes the hook and chrome directly.
|
|
118
|
+
*
|
|
119
|
+
* @capability selection-panel drop-in selection panel: primary summary beside a portrait strip / group chips
|
|
120
|
+
*/
|
|
121
|
+
export declare function SelectionPanel({ members, model: providedModel, groupThreshold, labelOf, windowSize, renderPrimary, renderMember, renderVital, onFocusChange, className, style, }: {
|
|
122
|
+
members?: readonly EntitySummaryDef[];
|
|
123
|
+
model?: SelectionModel;
|
|
124
|
+
groupThreshold?: number;
|
|
125
|
+
labelOf?: (kind: string) => string;
|
|
126
|
+
windowSize?: number;
|
|
127
|
+
renderPrimary?: (entity: EntitySummaryDef) => ReactNode;
|
|
128
|
+
renderMember?: (entity: EntitySummaryDef, selected: boolean) => ReactNode;
|
|
129
|
+
renderVital?: (vital: EntityVital) => ReactNode;
|
|
130
|
+
onFocusChange?: (id: string) => void;
|
|
131
|
+
className?: string;
|
|
132
|
+
style?: CSSProperties;
|
|
133
|
+
}): import("react").JSX.Element | null;
|