@jgengine/react 0.37.0 → 0.38.1
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 +82 -0
- package/dist/engineStyles.js +5 -0
- package/dist/gameMenu.d.ts +120 -0
- package/dist/gameMenu.js +400 -0
- package/dist/gameMenuSaves.d.ts +159 -0
- package/dist/gameMenuSaves.js +480 -0
- package/dist/gameMenuSound.d.ts +41 -0
- package/dist/gameMenuSound.js +82 -0
- package/dist/hudTheme.js +8 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/saveThumbnail.d.ts +22 -0
- package/dist/saveThumbnail.js +43 -0
- package/dist/styles.css +5 -0
- package/package.json +2 -2
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-ui/SKILL.md +1 -1
- package/skills/jgengine-ui/api.md +68 -0
- package/skills/jgengine-ui/capabilities.md +40 -0
- package/skills/jgengine-ui/reference.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -26,6 +26,88 @@ At publish, rename this heading to the new version and mirror the entries into
|
|
|
26
26
|
`packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
|
|
27
27
|
-->
|
|
28
28
|
|
|
29
|
+
## 0.38.1
|
|
30
|
+
|
|
31
|
+
### Migrate
|
|
32
|
+
|
|
33
|
+
- **Bump lockstep SDK packages to `^0.38.1`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.35.1`; `@jgengine/github` is `0.25.1`.
|
|
34
|
+
- Nothing to migrate. `variantLevels()` was renamed to `ModelVariantConfig.shadows` before any release shipped it, so no published version ever exported it and no consumer can be holding one. A level that wants to stop paying the near level's shadow cost writes `{ url, shadows: "none" }`; leaving `shadows` out keeps the model's own answer.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- `ModelVariantConfig.shadows` (`@jgengine/core/world/modelLod`): a model variant level declares its own shadow participation as a plain field, `"inherit" | "cast" | "receive" | "both" | "none"`. `"inherit"`, the default, keeps the model's own `shadows`, so every existing model draws as before; a far level set to `"none"` leaves the shadow pass while still drawing its mesh.
|
|
39
|
+
- `ModelShadowMode` (`@jgengine/core/world/modelLod`) names the four-value shadow vocabulary, and `ModelConfig.shadows` uses it.
|
|
40
|
+
- `variantLevelModel` (`@jgengine/shell/render/singleVariantLod`) is the one place that resolves what a level draws and how it shadows; the single prop, batched prop and scatter paths all ask it, so a level's asset and its shadow answer cannot disagree.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- `SaveSlotsPane` cards are one box each: equal tracks, `grid-auto-rows: 1fr`, a card edge that reads over a bright scene, and a name that ellipsises instead of widening the card. A save with a long name and one with none produce the same card box.
|
|
45
|
+
- `GameMenu` puts the primary verbs on their own scrim, so their labels stay legible over whatever the live scene is showing instead of relying on a text shadow.
|
|
46
|
+
- The theme emits three menu tokens the slot card and the verb surface read: `--jg-menu-card-height` (`clamp(244px, 27vh, 300px)`), `--jg-menu-card-thumb` (`clamp(88px, 14vh, 160px)`) and `--jg-menu-surface-radius` (`8px`). A game overrides them in its own `HudTheme` instead of patching the menu components.
|
|
47
|
+
|
|
48
|
+
## 0.38.0
|
|
49
|
+
|
|
50
|
+
### Migrate
|
|
51
|
+
|
|
52
|
+
- **Bump lockstep SDK packages to `^0.38.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.35.0`; `@jgengine/github` is `0.25.0`.
|
|
53
|
+
- Composing a session loop directly: pass your rule as `settle`, `carries: false` when nothing is carried, and your authored options as `offers`. `createRunLoop` is removed one minor release after Beacon Bastion and Drift Foundry stop using it.
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- `GameMenu` and `GamePauseMenu` (`@jgengine/react/gameMenu`): a full-bleed menu surface over the live scene or drifting key art, with a token-sized logo, an animated navigable verb list, a generated selection tick and a detail pane per verb. The document scroll is locked while it is open, so the surface is exactly the viewport at any size.
|
|
58
|
+
- Panes for the detail slot: `ContinuePane`, `NewCharacterPane`, `SaveSlotsPane` (slot cards with a `meta.thumbnail`, playtime, progress, timestamp and a two-press delete) and `CreditsPane` (`@jgengine/react/gameMenuSaves`).
|
|
59
|
+
- `shrinkCapture` (`@jgengine/react/saveThumbnail`) turns a `captureCanvas` frame into a card-sized thumbnail data URL.
|
|
60
|
+
- `createSessionLoop` (`@jgengine/core/run/sessionLoop`) is one session loop for every game: a session opens, may carry a quantity, closes at its boundary, and the game's own `settle` callback decides what happens to what it carried. `carries: false` for a session with nothing at stake, and an optional `offers` table (with `present`/`choose`) for a game whose next session opens with an authored change; without a table nothing is drawn. `installSessionProbe` reports the live session on `window.__jgengineRun`.
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- `switchSlot(slot, { flush: false })` (`@jgengine/core/game/gameSave`) loads a slot without first writing the live state over it. Opening a save from a menu uses it so Continue cannot overwrite the save it resumes.
|
|
65
|
+
- The dev runner's registry and environment modules import outside Vite, so a test or tool can read them.
|
|
66
|
+
- `createRunLoop` is now a thin composition of `createSessionLoop` and is deprecated: bank-or-lose and a per-run change table are one game type's rules, so they live in the composition instead of the base. Its API, state shape and tests are unchanged. One difference: the option draw is seeded under `:offer:` rather than `:run:` (`seededRng(\`${seed}:offer:${index}\`)`), so a career that keeps its seed draws a different change on a given session than 0.37.0 did for the same seed. Ids, payloads and saves stay valid: a saved `changeId` still restores the same change.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- `shoot` fails instead of writing a PNG when the whole frame is one flat colour, and when a scene is still behind its loading cover. Both used to write a plausible screenshot that read as evidence of a rendered frame.
|
|
71
|
+
- The editor's play-mode pixel test waits for the warmup cover to clear before screenshotting. It shot the cover, so it read `0.9988` one-colour locally and passed in CI where the race fell the other way.
|
|
72
|
+
- `shoot` prints the WebGL2 renderer it draws with and fails the capture outright when the launched browser has none.
|
|
73
|
+
|
|
74
|
+
Root cause: capture readiness was trusted to mean "the scene has rendered", but a loading cover and a canvas that never committed both photograph as a flat frame, and nothing measured the frame after the fact. Headless Chrome on this PC was never the problem: it renders with the GPU (`ANGLE (NVIDIA GeForce RTX 3070, D3D11)`) and with SwiftShader when asked.
|
|
75
|
+
|
|
76
|
+
Before: `bun run shoot --url <flat canvas page>` exited 0 and wrote a one-colour PNG, and `packages/editor/src/CreatorApp.test.ts` failed locally at `dominantColorShare 0.9988`.
|
|
77
|
+
After: the same shoot exits 1 with `the whole frame is one flat color (100.0%) - no scene reached the capture - nothing was written`, and the editor test passes in 12s with sky and ground in the PNG.
|
|
78
|
+
|
|
79
|
+
Cost: forcing SwiftShader on the same game shot costs 2.4x the capture (16.0s vs 6.7s warm, beacon-bastion play at half size), so the launch keeps the native GPU. The two checks add about 10ms to a shot.
|
|
80
|
+
|
|
81
|
+
Verification:
|
|
82
|
+
- `bun test scripts/shot-metrics.test.ts` - pass
|
|
83
|
+
- `bun test packages/editor/src/CreatorApp.test.ts` - pass
|
|
84
|
+
- `bun run shoot beacon-bastion --mode play --size half` - exit 0, scene renders
|
|
85
|
+
- `bun run shoot --url <flat canvas page>` - exit 1, no PNG written
|
|
86
|
+
- `bun run verify:fast` - pass
|
|
87
|
+
|
|
88
|
+
The CI reliability fix this lane also carried (a browser setup hook shorter than Playwright's launch default) is #2335, so either change can be reverted without the other.
|
|
89
|
+
- The publish workflow's last step no longer reports a whole release as unpublished because it ran seconds after `npm publish`. `check:release-artifacts --wait <seconds>` re-checks while the only failures are absent packages (no metadata, version missing, tarball 404) and gives up when the budget runs out; a wrong version in the tarball, a missing subpath or a main entry that will not import still fails on the first check. The 0.37.0 publish run failed this step at 04:48 listing all 13 packages, then npm served every one of them. The step now passes `-- --wait 300`.
|
|
90
|
+
- A Playwright test file's `beforeAll` now outlives the Chrome launch it waits on, and `browser-test-budget` rejects a file that leaves the hook at or under Playwright's 30 s launch default. On `test (g)` in #2326 the hook's 30 s budget and that default expired together, so CI reported only `(unnamed) hook timed out` beside `killed 1 dangling process` and nothing named the launch.
|
|
91
|
+
- `packages/react/src/dialogBehavior.test.ts` gives its browser navigation its own 20 s deadline and one retry inside a 60 s hook.
|
|
92
|
+
|
|
93
|
+
Root cause: Bun prints a `beforeAll` timeout as an unnamed hook timeout, so a setup budget equal to Playwright's own 30 s launch default reports the anonymous timer instead of the launch that stalled.
|
|
94
|
+
|
|
95
|
+
Before: `test (g)` on #2326 reported `(fail) (unnamed) [30001.51ms] ^ a beforeEach/afterEach hook timed out` and `killed 1 dangling process`.
|
|
96
|
+
After: a stall now surfaces as Playwright's own `Timeout ... exceeded`, and `bun scripts/browser-test-budget.ts` names any file whose setup hook sits at or under the launch default.
|
|
97
|
+
|
|
98
|
+
Verification:
|
|
99
|
+
- `bun scripts/browser-test-budget.ts` - pass, and the new rule names the unraised files on the previous tip
|
|
100
|
+
- `bun test scripts/browser-test-budget.test.ts` - pass
|
|
101
|
+
- `bun test packages/react/src/dialogBehavior.test.ts packages/react/src/scrollRail.test.ts packages/shell/src/settingsBoundary.test.ts` - pass
|
|
102
|
+
- `bun run verify:fast` - pass
|
|
103
|
+
- A prop placed at runtime honours its model's `variants` levels. `StaticObjectModels` applied the level switch only inside its batched branch, so a prop placed with `scene.object.place` — Harbor Heat's palm line — drew its full asset at every camera distance: 15 palms at 9,134 triangles each, and pointing the far level at a dispatch booth changed nothing on screen. `IsolatedEntityModel` now mounts `SingleVariantModel`, which asks the same `createModelVariantSelector` and `lodBandIndex` authority the batched path uses, at the prop's own camera distance, and keeps only the active level mounted so both levels never draw at once. The switch carries the selector's hysteresis, warms every level at mount, measures only the near level, and reads the placement from the scene store through scratch vectors, so a frame allocates nothing. One palm past 60 m draws 4,110 triangles instead of 9,134 (5,024 saved, 45% of the full level).
|
|
104
|
+
|
|
105
|
+
- `singleVariantLevel` and `singleVariantModel` (`@jgengine/shell` internal, `render/singleVariantLod`) are the isolated path's level decision and the model it draws, so a fixture pins the same switch the component runs.
|
|
106
|
+
|
|
107
|
+
`bun test packages/shell/src/render/singleVariantLod.test.ts` — 6 pass. With the old render decision restored, the wiring test fails (`Expected: SingleVariantModel, Received: EntityModel`).
|
|
108
|
+
`bun --cwd=packages/shell run test` — 1,229 pass, 0 fail.
|
|
109
|
+
`bun --cwd=packages/shell run check-types` — clean.
|
|
110
|
+
|
|
29
111
|
## 0.37.0
|
|
30
112
|
|
|
31
113
|
### Migrate
|
package/dist/engineStyles.js
CHANGED
|
@@ -52,6 +52,11 @@ export const ENGINE_STYLESHEET = `/* @jgengine/react engine stylesheet */
|
|
|
52
52
|
@keyframes jg-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
53
53
|
@keyframes jg-delta-up { 0% { color: var(--jg-success, #3fa34d); text-shadow: 0 0 10px var(--jg-success, #3fa34d); scale: 1.12; } 100% { scale: 1; } }
|
|
54
54
|
@keyframes jg-delta-down { 0% { color: var(--jg-danger, #e5484d); text-shadow: 0 0 10px var(--jg-danger, #e5484d); scale: 0.94; } 100% { scale: 1; } }
|
|
55
|
+
@keyframes jg-menu-drift { from { transform: scale(1.06) translate3d(-1.4%, -1%, 0); } to { transform: scale(1.12) translate3d(1.6%, 1.2%, 0); } }
|
|
56
|
+
@keyframes jg-menu-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
|
|
57
|
+
@keyframes jg-menu-pane-in { from { opacity: 0; transform: translateX(14px); } to { opacity: 1; transform: translateX(0); } }
|
|
58
|
+
[data-jg-game-menu] * { scrollbar-width: none; }
|
|
59
|
+
[data-jg-game-menu] *::-webkit-scrollbar { width: 0; height: 0; }
|
|
55
60
|
|
|
56
61
|
[data-jg-hud] { font-family: var(--jg-font-body, ${HUD_FONT_STACK}); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; }
|
|
57
62
|
:where([data-jg-hud]) :where(button, [role="button"], a[href], summary, [data-jg-interactive]):focus-visible { outline: 2px solid var(--jg-accent, #38bdf8); outline-offset: 2px; }
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { type GameMenuSound } from "./gameMenuSound.js";
|
|
3
|
+
/**
|
|
4
|
+
* A game's front-end menu: a full-bleed surface over a living background, a logo, primary verbs on
|
|
5
|
+
* the left that animate as the selection moves, and a detail pane on the right that changes with the
|
|
6
|
+
* highlighted verb. It is the block a main menu, a pause menu and their sub-screens are built from,
|
|
7
|
+
* so a game composes one menu system instead of a stack of modal panels.
|
|
8
|
+
*
|
|
9
|
+
* The surface is exactly the viewport: `position: fixed` with `overflow: hidden`, and the document
|
|
10
|
+
* scroll is locked while it is open, so a tall menu can never grow a page scrollbar. Content that
|
|
11
|
+
* genuinely scrolls (credits) scrolls inside its own pane.
|
|
12
|
+
*/
|
|
13
|
+
/** One primary verb in a {@link GameMenu}. */
|
|
14
|
+
export interface GameMenuItem {
|
|
15
|
+
/** Stable id the game routes on. */
|
|
16
|
+
id: string;
|
|
17
|
+
/** Player-facing label. */
|
|
18
|
+
label: string;
|
|
19
|
+
/** One short line under the label — a save summary, a state note. Omit for a clean row. */
|
|
20
|
+
hint?: string;
|
|
21
|
+
/** Show the row but skip it in navigation and clicks. */
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
/** Small marker at the end of the row (a badge, a count, a key glyph). */
|
|
24
|
+
badge?: ReactNode;
|
|
25
|
+
}
|
|
26
|
+
/** The logo slot. Give it `image` for art, or `text` for a styled wordmark. */
|
|
27
|
+
export interface GameMenuLogo {
|
|
28
|
+
/** Image URL for the game's logo. Sized by the `--jg-menu-logo` token, never by a heading tag. */
|
|
29
|
+
image?: string;
|
|
30
|
+
/** Alt text for the logo image. Defaults to `title` when that is a string. */
|
|
31
|
+
alt?: string;
|
|
32
|
+
/** Styled type instead of an image. */
|
|
33
|
+
text?: ReactNode;
|
|
34
|
+
/** Plain title, used for the accessible name and as the alt fallback. */
|
|
35
|
+
title?: string;
|
|
36
|
+
}
|
|
37
|
+
/** How a {@link GameMenu} is used. `pause` is the same system at a smaller scale. */
|
|
38
|
+
export type GameMenuVariant = "title" | "pause";
|
|
39
|
+
/** Props for {@link GameMenu}. */
|
|
40
|
+
export interface GameMenuProps {
|
|
41
|
+
/** Primary verbs, in focus order. */
|
|
42
|
+
items: readonly GameMenuItem[];
|
|
43
|
+
/** The verb focused on mount. Default the first enabled one. */
|
|
44
|
+
initialId?: string;
|
|
45
|
+
/** A verb was confirmed (Enter, Space, A, or a click). */
|
|
46
|
+
onSelect: (id: string) => void;
|
|
47
|
+
/** Back (Escape, Backspace, B, Start). On a pause menu this is the resume action. */
|
|
48
|
+
onBack?: () => void;
|
|
49
|
+
/** The highlighted verb changed — use it to load the detail for `id`. */
|
|
50
|
+
onHighlight?: (id: string | null) => void;
|
|
51
|
+
/** The right-hand pane's content for the highlighted verb. */
|
|
52
|
+
detail?: ReactNode | ((id: string | null) => ReactNode);
|
|
53
|
+
/** Logo slot: an image or styled type, sized by the logo token. */
|
|
54
|
+
logo?: GameMenuLogo | ReactNode;
|
|
55
|
+
/** Small line above the logo (studio, chapter, mode). */
|
|
56
|
+
eyebrow?: ReactNode;
|
|
57
|
+
/** One line under the logo. */
|
|
58
|
+
tagline?: ReactNode;
|
|
59
|
+
/**
|
|
60
|
+
* Layer painted across the whole viewport behind the menu: a live scene component, key art, a
|
|
61
|
+
* video. Leave it out when the engine's own 3D canvas sits behind the menu — the menu stays
|
|
62
|
+
* transparent and only tints and vignettes it, so play keeps rendering underneath.
|
|
63
|
+
*/
|
|
64
|
+
background?: ReactNode;
|
|
65
|
+
/**
|
|
66
|
+
* Key-art URL for the drifting background layer. Omit when the live scene behind the menu is the
|
|
67
|
+
* background. With neither, the menu still tints and vignettes whatever is behind it.
|
|
68
|
+
*/
|
|
69
|
+
art?: string;
|
|
70
|
+
/** Secondary verbs at the bottom-left (quit, back). Same navigation, quieter styling. */
|
|
71
|
+
secondary?: readonly GameMenuItem[];
|
|
72
|
+
/** Footer left slot: build version, studio. */
|
|
73
|
+
footer?: ReactNode;
|
|
74
|
+
/** Footer right slot: replaces the default control hint. */
|
|
75
|
+
controlHint?: ReactNode;
|
|
76
|
+
/** Selection sound. Default on. Pass `false` for silence, or a player from `createGameMenuSound`. */
|
|
77
|
+
sound?: boolean | GameMenuSound;
|
|
78
|
+
/** Title menu or the smaller pause menu. Default `"title"`. */
|
|
79
|
+
variant?: GameMenuVariant;
|
|
80
|
+
/** Open state the game owns. A closed menu renders nothing and releases the document scroll. */
|
|
81
|
+
open?: boolean;
|
|
82
|
+
/** Force motion off. Reduced motion is also read from the accessibility preference and the OS. */
|
|
83
|
+
reducedMotion?: boolean;
|
|
84
|
+
/** Accessible name of the surface. Default `"Main menu"` (`"Paused"` for the pause variant). */
|
|
85
|
+
label?: string;
|
|
86
|
+
className?: string;
|
|
87
|
+
style?: CSSProperties;
|
|
88
|
+
detailClassName?: string;
|
|
89
|
+
detailStyle?: CSSProperties;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Lock the page scroll while `active`, so a fixed full-bleed surface is exactly the viewport and the
|
|
93
|
+
* document behind it never gains a scrollbar. Nested and repeated locks are counted, and the previous
|
|
94
|
+
* inline styles are restored when the last one releases.
|
|
95
|
+
*
|
|
96
|
+
* @capability use-viewport-scroll-lock count-aware document scroll lock for full-bleed surfaces
|
|
97
|
+
*/
|
|
98
|
+
export declare function useViewportScrollLock(active: boolean): void;
|
|
99
|
+
/**
|
|
100
|
+
* A game front-end menu: full-bleed over a living background, with a logo, an animated primary list
|
|
101
|
+
* and a detail pane that follows the highlighted verb. One system covers the main menu and the pause
|
|
102
|
+
* menu (`variant="pause"`), keyboard and gamepad navigation come from `useMenuNav`, confirmation
|
|
103
|
+
* ticks are generated (no audio asset), and the document never scrolls while it is open.
|
|
104
|
+
*
|
|
105
|
+
* The engine owns the surface, layout, motion, focus order, sound and the save/character panes it
|
|
106
|
+
* exports next to this component. The game owns the art, the logo, the wording, the verbs and what
|
|
107
|
+
* each detail pane shows.
|
|
108
|
+
*
|
|
109
|
+
* @capability game-menu full-bleed game menu — living background, token-sized logo, animated navigable verbs, detail pane, selection sound, pause variant, no page scrollbar
|
|
110
|
+
*/
|
|
111
|
+
export declare function GameMenu({ items, initialId, onSelect, onBack, onHighlight, detail, logo, eyebrow, tagline, background, art, secondary, footer, controlHint, sound, variant, open, reducedMotion, label, className, style, detailClassName, detailStyle, }: GameMenuProps): ReactNode;
|
|
112
|
+
/**
|
|
113
|
+
* A pause menu built from {@link GameMenu}: the same surface and navigation at a smaller scale, so a
|
|
114
|
+
* game composes one menu system rather than a second one. Escape (or B/Start) resumes.
|
|
115
|
+
*
|
|
116
|
+
* @capability game-pause-menu pause menu from the shared game-menu system — same navigation and surface, sized down
|
|
117
|
+
*/
|
|
118
|
+
export declare function GamePauseMenu(props: Omit<GameMenuProps, "variant"> & {
|
|
119
|
+
onResume?: () => void;
|
|
120
|
+
}): ReactNode;
|
package/dist/gameMenu.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, } from "react";
|
|
3
|
+
import { useReducedMotion } from "./accessibility.js";
|
|
4
|
+
import { createGameMenuSound } from "./gameMenuSound.js";
|
|
5
|
+
import { hudDuration, hudEase, hudFont, hudType } from "./hudTokens.js";
|
|
6
|
+
import { useMenuNav } from "./menuNav.js";
|
|
7
|
+
let scrollLocks = 0;
|
|
8
|
+
let savedDocumentOverflow = "";
|
|
9
|
+
let savedBodyOverflow = "";
|
|
10
|
+
function lockDocumentScroll() {
|
|
11
|
+
if (typeof document === "undefined")
|
|
12
|
+
return () => { };
|
|
13
|
+
if (scrollLocks === 0) {
|
|
14
|
+
savedDocumentOverflow = document.documentElement.style.overflow;
|
|
15
|
+
savedBodyOverflow = document.body.style.overflow;
|
|
16
|
+
document.documentElement.style.overflow = "hidden";
|
|
17
|
+
document.body.style.overflow = "hidden";
|
|
18
|
+
}
|
|
19
|
+
scrollLocks += 1;
|
|
20
|
+
let released = false;
|
|
21
|
+
return () => {
|
|
22
|
+
if (released)
|
|
23
|
+
return;
|
|
24
|
+
released = true;
|
|
25
|
+
scrollLocks -= 1;
|
|
26
|
+
if (scrollLocks > 0 || typeof document === "undefined")
|
|
27
|
+
return;
|
|
28
|
+
document.documentElement.style.overflow = savedDocumentOverflow;
|
|
29
|
+
document.body.style.overflow = savedBodyOverflow;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Lock the page scroll while `active`, so a fixed full-bleed surface is exactly the viewport and the
|
|
34
|
+
* document behind it never gains a scrollbar. Nested and repeated locks are counted, and the previous
|
|
35
|
+
* inline styles are restored when the last one releases.
|
|
36
|
+
*
|
|
37
|
+
* @capability use-viewport-scroll-lock count-aware document scroll lock for full-bleed surfaces
|
|
38
|
+
*/
|
|
39
|
+
export function useViewportScrollLock(active) {
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!active)
|
|
42
|
+
return;
|
|
43
|
+
return lockDocumentScroll();
|
|
44
|
+
}, [active]);
|
|
45
|
+
}
|
|
46
|
+
const ROW_HEIGHT = { title: 58, pause: 46 };
|
|
47
|
+
const VIGNETTE = "radial-gradient(125% 110% at 26% 42%, rgba(4,7,12,0.02) 0%, rgba(4,7,12,0.34) 66%, rgba(2,4,7,0.8) 100%)";
|
|
48
|
+
const TINT = "linear-gradient(100deg, rgba(6,10,16,0.9) 0%, rgba(6,10,16,0.74) 34%, rgba(6,10,16,0.34) 56%, rgba(6,10,16,0.08) 78%, rgba(6,10,16,0.28) 100%)";
|
|
49
|
+
function logoNode(logo, title, reduced) {
|
|
50
|
+
if (logo === undefined || logo === null || logo === false)
|
|
51
|
+
return null;
|
|
52
|
+
if (typeof logo === "object" && !Array.isArray(logo) && !("type" in logo)) {
|
|
53
|
+
const spec = logo;
|
|
54
|
+
const size = "var(--jg-menu-logo-size, clamp(30px, 4.6vw, 66px))";
|
|
55
|
+
if (spec.image !== undefined) {
|
|
56
|
+
return (_jsx("img", { "data-jg-game-menu-logo": "", src: spec.image, alt: spec.alt ?? spec.title ?? title ?? "", style: {
|
|
57
|
+
display: "block",
|
|
58
|
+
width: "auto",
|
|
59
|
+
height: `calc(${size} * 1.15)`,
|
|
60
|
+
maxWidth: "min(420px, 46vw)",
|
|
61
|
+
objectFit: "contain",
|
|
62
|
+
filter: "drop-shadow(0 8px 30px rgba(0,0,0,0.6))",
|
|
63
|
+
} }));
|
|
64
|
+
}
|
|
65
|
+
return (_jsx("div", { "data-jg-game-menu-logo": "", style: {
|
|
66
|
+
fontFamily: hudFont.display,
|
|
67
|
+
fontSize: size,
|
|
68
|
+
fontWeight: 800,
|
|
69
|
+
lineHeight: 0.98,
|
|
70
|
+
letterSpacing: "-0.02em",
|
|
71
|
+
color: "var(--jg-text, #f7f9fc)",
|
|
72
|
+
textShadow: "0 2px 24px rgba(0,0,0,0.65), 0 0 60px var(--jg-accent-glow, rgba(88,196,255,0.25))",
|
|
73
|
+
animation: reduced ? undefined : `jg-menu-rise 620ms ${hudEase("easeOut")} both`,
|
|
74
|
+
}, children: spec.text ?? spec.title ?? title }));
|
|
75
|
+
}
|
|
76
|
+
return (_jsx("div", { "data-jg-game-menu-logo": "", style: {
|
|
77
|
+
fontFamily: hudFont.display,
|
|
78
|
+
fontSize: "var(--jg-menu-logo-size, clamp(30px, 4.6vw, 66px))",
|
|
79
|
+
fontWeight: 800,
|
|
80
|
+
lineHeight: 0.98,
|
|
81
|
+
letterSpacing: "-0.02em",
|
|
82
|
+
color: "var(--jg-text, #f7f9fc)",
|
|
83
|
+
textShadow: "0 2px 24px rgba(0,0,0,0.65), 0 0 60px var(--jg-accent-glow, rgba(88,196,255,0.25))",
|
|
84
|
+
animation: reduced ? undefined : `jg-menu-rise 620ms ${hudEase("easeOut")} both`,
|
|
85
|
+
}, children: logo }));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A game front-end menu: full-bleed over a living background, with a logo, an animated primary list
|
|
89
|
+
* and a detail pane that follows the highlighted verb. One system covers the main menu and the pause
|
|
90
|
+
* menu (`variant="pause"`), keyboard and gamepad navigation come from `useMenuNav`, confirmation
|
|
91
|
+
* ticks are generated (no audio asset), and the document never scrolls while it is open.
|
|
92
|
+
*
|
|
93
|
+
* The engine owns the surface, layout, motion, focus order, sound and the save/character panes it
|
|
94
|
+
* exports next to this component. The game owns the art, the logo, the wording, the verbs and what
|
|
95
|
+
* each detail pane shows.
|
|
96
|
+
*
|
|
97
|
+
* @capability game-menu full-bleed game menu — living background, token-sized logo, animated navigable verbs, detail pane, selection sound, pause variant, no page scrollbar
|
|
98
|
+
*/
|
|
99
|
+
export function GameMenu({ items, initialId, onSelect, onBack, onHighlight, detail, logo, eyebrow, tagline, background, art, secondary, footer, controlHint, sound = true, variant = "title", open = true, reducedMotion, label, className, style, detailClassName, detailStyle, }) {
|
|
100
|
+
const prefersReduced = useReducedMotion();
|
|
101
|
+
const reduced = reducedMotion === undefined ? prefersReduced : reducedMotion || prefersReduced;
|
|
102
|
+
useViewportScrollLock(open);
|
|
103
|
+
const allItems = useMemo(() => [...items, ...(secondary ?? [])], [items, secondary]);
|
|
104
|
+
const disabled = useMemo(() => allItems.filter((item) => item.disabled === true).map((item) => item.id), [allItems]);
|
|
105
|
+
const soundRef = useRef(null);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (sound === false)
|
|
108
|
+
return;
|
|
109
|
+
const player = typeof sound === "object" ? sound : createGameMenuSound();
|
|
110
|
+
soundRef.current = player;
|
|
111
|
+
return () => {
|
|
112
|
+
if (typeof sound !== "object")
|
|
113
|
+
player.dispose();
|
|
114
|
+
soundRef.current = null;
|
|
115
|
+
};
|
|
116
|
+
}, [sound]);
|
|
117
|
+
const onHighlightRef = useRef(onHighlight);
|
|
118
|
+
onHighlightRef.current = onHighlight;
|
|
119
|
+
const nav = useMenuNav({
|
|
120
|
+
ids: allItems.map((item) => item.id),
|
|
121
|
+
disabled,
|
|
122
|
+
enabled: open,
|
|
123
|
+
...(initialId === undefined ? {} : { initialId }),
|
|
124
|
+
onConfirm: (id) => {
|
|
125
|
+
const item = allItems.find((candidate) => candidate.id === id);
|
|
126
|
+
if (item === undefined || item.disabled === true)
|
|
127
|
+
return;
|
|
128
|
+
soundRef.current?.play("confirm");
|
|
129
|
+
onSelect(id);
|
|
130
|
+
},
|
|
131
|
+
...(onBack === undefined
|
|
132
|
+
? {}
|
|
133
|
+
: {
|
|
134
|
+
onBack: () => {
|
|
135
|
+
soundRef.current?.play("back");
|
|
136
|
+
onBack();
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
});
|
|
140
|
+
// A cursor that happens to rest over a row when the menu opens must not select it: hover only
|
|
141
|
+
// counts once the pointer has actually moved in this session.
|
|
142
|
+
const pointerMoved = useRef(false);
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (typeof window === "undefined")
|
|
145
|
+
return;
|
|
146
|
+
const mark = () => { pointerMoved.current = true; };
|
|
147
|
+
window.addEventListener("pointermove", mark, { passive: true });
|
|
148
|
+
return () => window.removeEventListener("pointermove", mark);
|
|
149
|
+
}, []);
|
|
150
|
+
const hoverProps = (id) => {
|
|
151
|
+
const props = nav.itemProps(id);
|
|
152
|
+
return { ...props, onPointerEnter: () => { if (pointerMoved.current)
|
|
153
|
+
props.onPointerEnter(); } };
|
|
154
|
+
};
|
|
155
|
+
const activeId = nav.activeId;
|
|
156
|
+
const activeIndex = allItems.findIndex((item) => item.id === activeId);
|
|
157
|
+
const mounted = useRef(false);
|
|
158
|
+
const appliedInitial = useRef(initialId);
|
|
159
|
+
// `initialId` is the verb the game wants focused (Continue when a save exists). When that changes
|
|
160
|
+
// after mount — a save finished loading, a screen swapped — move the selection rather than
|
|
161
|
+
// remounting the whole menu, which would replay the entry animation and drop focus.
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (initialId === undefined || appliedInitial.current === initialId || disabled.includes(initialId))
|
|
164
|
+
return;
|
|
165
|
+
appliedInitial.current = initialId;
|
|
166
|
+
nav.setActive(initialId);
|
|
167
|
+
}, [initialId, disabled, nav.activeId, nav.setActive]);
|
|
168
|
+
// The first pass can run before item refs exist, so focus the selection once after paint when the
|
|
169
|
+
// menu owns an idle document. Arrow keys then land on the menu itself instead of the page.
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!open)
|
|
172
|
+
return;
|
|
173
|
+
if (typeof requestAnimationFrame !== "function")
|
|
174
|
+
return;
|
|
175
|
+
const frame = requestAnimationFrame(() => {
|
|
176
|
+
const active = typeof document === "undefined" ? null : document.activeElement;
|
|
177
|
+
const insideMenu = active !== null && typeof active.closest === "function" && active.closest("[data-jg-game-menu]") !== null;
|
|
178
|
+
if (insideMenu && active !== document.body)
|
|
179
|
+
return;
|
|
180
|
+
const target = document.querySelector("[data-jg-game-menu] [data-jg-menu-item][aria-current='true']");
|
|
181
|
+
target?.focus({ preventScroll: true });
|
|
182
|
+
});
|
|
183
|
+
return () => cancelAnimationFrame(frame);
|
|
184
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
185
|
+
}, [open, activeId]);
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (!mounted.current) {
|
|
188
|
+
mounted.current = true;
|
|
189
|
+
}
|
|
190
|
+
else if (activeId !== null) {
|
|
191
|
+
soundRef.current?.play("move");
|
|
192
|
+
}
|
|
193
|
+
onHighlightRef.current?.(activeId);
|
|
194
|
+
}, [activeId]);
|
|
195
|
+
if (!open)
|
|
196
|
+
return null;
|
|
197
|
+
const isPause = variant === "pause";
|
|
198
|
+
const rowHeight = ROW_HEIGHT[variant];
|
|
199
|
+
const primaryCount = items.length;
|
|
200
|
+
const detailNode = typeof detail === "function" ? detail(activeId) : detail;
|
|
201
|
+
const title = typeof logo === "object" && logo !== null && !Array.isArray(logo) ? logo.title : undefined;
|
|
202
|
+
const surfaceLabel = label ?? (isPause ? "Paused" : "Main menu");
|
|
203
|
+
return (_jsxs("div", { "data-jg-game-menu": "", "data-variant": variant, "data-open": "", role: "dialog", "aria-modal": "true", "aria-label": surfaceLabel, className: className, style: {
|
|
204
|
+
position: "fixed",
|
|
205
|
+
inset: 0,
|
|
206
|
+
width: "100vw",
|
|
207
|
+
height: "100dvh",
|
|
208
|
+
maxWidth: "100vw",
|
|
209
|
+
maxHeight: "100dvh",
|
|
210
|
+
overflow: "hidden",
|
|
211
|
+
overscrollBehavior: "contain",
|
|
212
|
+
display: "grid",
|
|
213
|
+
gridTemplateRows: "auto minmax(0, 1fr) auto",
|
|
214
|
+
gridTemplateColumns: "minmax(232px, clamp(232px, 27vw, 360px)) minmax(0, 1fr)",
|
|
215
|
+
columnGap: "clamp(16px, 3vw, 56px)",
|
|
216
|
+
padding: isPause
|
|
217
|
+
? "clamp(16px, 2.4vw, 34px) clamp(20px, 3.2vw, 56px)"
|
|
218
|
+
: "clamp(18px, 3.2vw, 56px) clamp(20px, 3.6vw, 64px)",
|
|
219
|
+
boxSizing: "border-box",
|
|
220
|
+
color: "var(--jg-text, #f5f7fa)",
|
|
221
|
+
fontFamily: hudFont.body,
|
|
222
|
+
animation: reduced ? undefined : `jg-fade-in ${hudDuration("slow")} ${hudEase("easeOut")} both`,
|
|
223
|
+
...style,
|
|
224
|
+
}, children: [_jsxs("div", { "data-jg-game-menu-backdrop": "", "aria-hidden": "true", style: { position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none", zIndex: 0 }, children: [background === undefined ? null : (_jsx("div", { "data-jg-game-menu-live": "", style: { position: "absolute", inset: 0 }, children: background })), art === undefined ? null : (_jsx("div", { "data-jg-game-menu-art": "", style: {
|
|
225
|
+
position: "absolute",
|
|
226
|
+
inset: "-8%",
|
|
227
|
+
backgroundImage: `url(${art})`,
|
|
228
|
+
backgroundSize: "cover",
|
|
229
|
+
backgroundPosition: "center",
|
|
230
|
+
transform: "scale(1.06)",
|
|
231
|
+
animation: reduced ? undefined : "jg-menu-drift 46s ease-in-out infinite alternate",
|
|
232
|
+
willChange: "transform",
|
|
233
|
+
} })), _jsx("div", { "data-jg-game-menu-tint": "", style: { position: "absolute", inset: 0, background: TINT, opacity: isPause ? 1 : 0.94 } }), _jsx("div", { "data-jg-game-menu-vignette": "", style: { position: "absolute", inset: 0, background: VIGNETTE } }), _jsx("div", { "data-jg-game-menu-grain": "", style: {
|
|
234
|
+
position: "absolute",
|
|
235
|
+
inset: 0,
|
|
236
|
+
opacity: 0.06,
|
|
237
|
+
backgroundImage: "repeating-linear-gradient(0deg, rgba(255,255,255,0.5) 0px, rgba(255,255,255,0.5) 1px, transparent 1px, transparent 3px)",
|
|
238
|
+
mixBlendMode: "overlay",
|
|
239
|
+
} })] }), _jsxs("header", { "data-jg-game-menu-header": "", style: {
|
|
240
|
+
position: "relative",
|
|
241
|
+
zIndex: 1,
|
|
242
|
+
gridColumn: "1 / -1",
|
|
243
|
+
display: "flex",
|
|
244
|
+
flexDirection: "column",
|
|
245
|
+
gap: 6,
|
|
246
|
+
minWidth: 0,
|
|
247
|
+
}, children: [eyebrow === undefined ? null : (_jsx("div", { "data-jg-game-menu-eyebrow": "", style: {
|
|
248
|
+
fontSize: hudType("xs"),
|
|
249
|
+
letterSpacing: "0.3em",
|
|
250
|
+
textTransform: "uppercase",
|
|
251
|
+
color: "var(--jg-accent, #7dd3fc)",
|
|
252
|
+
fontWeight: 700,
|
|
253
|
+
}, children: eyebrow })), logoNode(logo, title, reduced), tagline === undefined ? null : (_jsx("div", { "data-jg-game-menu-tagline": "", style: {
|
|
254
|
+
fontSize: hudType(isPause ? "sm" : "md"),
|
|
255
|
+
color: "var(--jg-text-dim, rgba(226,235,245,0.68))",
|
|
256
|
+
maxWidth: "46ch",
|
|
257
|
+
}, children: tagline }))] }), _jsx("div", { "data-jg-game-menu-nav-wrap": "", style: {
|
|
258
|
+
position: "relative",
|
|
259
|
+
zIndex: 1,
|
|
260
|
+
display: "flex",
|
|
261
|
+
flexDirection: "column",
|
|
262
|
+
justifyContent: isPause ? "center" : "flex-start",
|
|
263
|
+
minHeight: 0,
|
|
264
|
+
paddingTop: isPause ? 0 : "clamp(8px, 2vh, 28px)",
|
|
265
|
+
}, children: _jsxs("nav", { "data-jg-game-menu-items": "", ...nav.containerProps, style: { position: "relative", display: "flex", flexDirection: "column", gap: 2 }, onKeyDown: (event) => {
|
|
266
|
+
nav.containerProps.onKeyDown(event);
|
|
267
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp")
|
|
268
|
+
event.stopPropagation();
|
|
269
|
+
}, children: [_jsx("div", { "data-jg-game-menu-nav-surface": "", "aria-hidden": "true", style: {
|
|
270
|
+
position: "absolute",
|
|
271
|
+
inset: "-16px -22px",
|
|
272
|
+
borderRadius: "var(--jg-menu-surface-radius, 8px)",
|
|
273
|
+
background: "linear-gradient(96deg, rgba(4,7,11,0.6) 0%, rgba(4,7,11,0.34) 56%, rgba(4,7,11,0) 100%)",
|
|
274
|
+
maskImage: "linear-gradient(180deg, transparent 0, #000 9%, #000 91%, transparent 100%)",
|
|
275
|
+
WebkitMaskImage: "linear-gradient(180deg, transparent 0, #000 9%, #000 91%, transparent 100%)",
|
|
276
|
+
backdropFilter: "blur(2px)",
|
|
277
|
+
WebkitBackdropFilter: "blur(2px)",
|
|
278
|
+
pointerEvents: "none",
|
|
279
|
+
} }), _jsx("div", { "data-jg-game-menu-rail": "", "aria-hidden": "true", style: {
|
|
280
|
+
position: "absolute",
|
|
281
|
+
left: 0,
|
|
282
|
+
right: 8,
|
|
283
|
+
top: 0,
|
|
284
|
+
height: rowHeight,
|
|
285
|
+
borderRadius: 10,
|
|
286
|
+
opacity: activeIndex < 0 ? 0 : 1,
|
|
287
|
+
transform: `translate3d(0, ${Math.max(0, activeIndex) * (rowHeight + 2)}px, 0) scale(${activeIndex >= primaryCount ? 0.98 : 1})`,
|
|
288
|
+
transition: reduced
|
|
289
|
+
? undefined
|
|
290
|
+
: `transform ${hudDuration("base")} ${hudEase("spring")}, opacity ${hudDuration("fast")} ${hudEase("easeOut")}`,
|
|
291
|
+
background: "linear-gradient(90deg, var(--jg-accent-glow, rgba(94,196,255,0.22)) 0%, rgba(255,255,255,0.05) 62%, transparent 100%)",
|
|
292
|
+
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.14), 0 10px 30px -14px rgba(0,0,0,0.9)",
|
|
293
|
+
borderLeft: "2px solid var(--jg-accent, #7dd3fc)",
|
|
294
|
+
} }), items.map((item) => {
|
|
295
|
+
const active = item.id === activeId;
|
|
296
|
+
return (_jsxs("button", { type: "button", ...hoverProps(item.id), "data-jg-game-menu-item": item.id, "data-active": active ? "" : undefined, disabled: item.disabled === true, onClick: () => {
|
|
297
|
+
if (item.disabled === true)
|
|
298
|
+
return;
|
|
299
|
+
soundRef.current?.play("confirm");
|
|
300
|
+
onSelect(item.id);
|
|
301
|
+
}, style: {
|
|
302
|
+
position: "relative",
|
|
303
|
+
display: "flex",
|
|
304
|
+
alignItems: "center",
|
|
305
|
+
justifyContent: "space-between",
|
|
306
|
+
gap: 10,
|
|
307
|
+
height: rowHeight,
|
|
308
|
+
paddingLeft: active ? 18 : 12,
|
|
309
|
+
paddingRight: 12,
|
|
310
|
+
border: "none",
|
|
311
|
+
background: "transparent",
|
|
312
|
+
color: item.disabled === true ? "rgba(226,235,245,0.32)" : active ? "var(--jg-text, #ffffff)" : "rgba(226,235,245,0.78)",
|
|
313
|
+
fontFamily: hudFont.display,
|
|
314
|
+
fontSize: hudType(isPause ? "md" : "xl"),
|
|
315
|
+
fontWeight: active ? 700 : 600,
|
|
316
|
+
letterSpacing: "0.01em",
|
|
317
|
+
textAlign: "left",
|
|
318
|
+
cursor: item.disabled === true ? "not-allowed" : "pointer",
|
|
319
|
+
transition: reduced ? undefined : `padding ${hudDuration("base")} ${hudEase("spring")}, color ${hudDuration("fast")} linear`,
|
|
320
|
+
textShadow: active
|
|
321
|
+
? "0 1px 12px rgba(0,0,0,0.8), 0 0 22px var(--jg-accent-glow, rgba(94,196,255,0.4))"
|
|
322
|
+
: "0 1px 10px rgba(0,0,0,0.72)",
|
|
323
|
+
outline: "none",
|
|
324
|
+
}, children: [_jsxs("span", { style: { display: "flex", flexDirection: "column", gap: 1, minWidth: 0 }, children: [_jsx("span", { style: { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }, children: item.label }), item.hint === undefined ? null : (_jsx("span", { style: {
|
|
325
|
+
fontSize: hudType("xs"),
|
|
326
|
+
fontWeight: 500,
|
|
327
|
+
letterSpacing: 0,
|
|
328
|
+
color: "var(--jg-text-dim, rgba(203,213,225,0.66))",
|
|
329
|
+
whiteSpace: "nowrap",
|
|
330
|
+
overflow: "hidden",
|
|
331
|
+
textOverflow: "ellipsis",
|
|
332
|
+
opacity: active ? 1 : 0.72,
|
|
333
|
+
}, children: item.hint }))] }), item.badge === undefined ? null : (_jsx("span", { style: { fontSize: hudType("xs"), color: "var(--jg-accent, #7dd3fc)", fontWeight: 700 }, children: item.badge }))] }, item.id));
|
|
334
|
+
}), secondary === undefined || secondary.length === 0 ? null : (_jsx("div", { style: { height: 1, margin: "10px 12px", background: "rgba(255,255,255,0.12)" }, "aria-hidden": "true" })), (secondary ?? []).map((item) => {
|
|
335
|
+
const active = item.id === activeId;
|
|
336
|
+
return (_jsx("button", { type: "button", ...hoverProps(item.id), "data-jg-game-menu-item": item.id, "data-secondary": "", "data-active": active ? "" : undefined, disabled: item.disabled === true, onClick: () => {
|
|
337
|
+
if (item.disabled === true)
|
|
338
|
+
return;
|
|
339
|
+
soundRef.current?.play("confirm");
|
|
340
|
+
onSelect(item.id);
|
|
341
|
+
}, style: {
|
|
342
|
+
position: "relative",
|
|
343
|
+
display: "flex",
|
|
344
|
+
alignItems: "center",
|
|
345
|
+
height: rowHeight,
|
|
346
|
+
paddingLeft: active ? 18 : 12,
|
|
347
|
+
border: "none",
|
|
348
|
+
background: "transparent",
|
|
349
|
+
color: active ? "var(--jg-text, #ffffff)" : "rgba(203,213,225,0.6)",
|
|
350
|
+
fontFamily: hudFont.body,
|
|
351
|
+
fontSize: hudType("md"),
|
|
352
|
+
fontWeight: 600,
|
|
353
|
+
textAlign: "left",
|
|
354
|
+
cursor: item.disabled === true ? "not-allowed" : "pointer",
|
|
355
|
+
transition: reduced ? undefined : `padding ${hudDuration("base")} ${hudEase("spring")}`,
|
|
356
|
+
outline: "none",
|
|
357
|
+
}, children: item.label }, item.id));
|
|
358
|
+
})] }) }), _jsx("div", { "data-jg-game-menu-detail": "", "data-detail-id": activeId ?? "", className: detailClassName, style: {
|
|
359
|
+
position: "relative",
|
|
360
|
+
zIndex: 1,
|
|
361
|
+
minWidth: 0,
|
|
362
|
+
minHeight: 0,
|
|
363
|
+
maxHeight: "100%",
|
|
364
|
+
overflow: "hidden",
|
|
365
|
+
display: "flex",
|
|
366
|
+
flexDirection: "column",
|
|
367
|
+
paddingTop: isPause ? 0 : "clamp(6px, 1.6vh, 20px)",
|
|
368
|
+
...detailStyle,
|
|
369
|
+
}, children: detailNode === undefined || detailNode === null ? null : (_jsx("div", { "data-jg-game-menu-pane": "", style: {
|
|
370
|
+
minHeight: 0,
|
|
371
|
+
flex: 1,
|
|
372
|
+
display: "flex",
|
|
373
|
+
flexDirection: "column",
|
|
374
|
+
animation: reduced ? undefined : `jg-menu-pane-in ${hudDuration("slow")} ${hudEase("easeOut")} both`,
|
|
375
|
+
}, children: detailNode }, activeId ?? "none")) }), _jsxs("footer", { "data-jg-game-menu-footer": "", style: {
|
|
376
|
+
position: "relative",
|
|
377
|
+
zIndex: 1,
|
|
378
|
+
gridColumn: "1 / -1",
|
|
379
|
+
display: "flex",
|
|
380
|
+
alignItems: "center",
|
|
381
|
+
justifyContent: "space-between",
|
|
382
|
+
gap: 16,
|
|
383
|
+
paddingTop: 8,
|
|
384
|
+
fontSize: hudType("xs"),
|
|
385
|
+
letterSpacing: "0.08em",
|
|
386
|
+
textTransform: "uppercase",
|
|
387
|
+
color: "rgba(203,213,225,0.45)",
|
|
388
|
+
}, children: [_jsx("span", { "data-jg-game-menu-footer-left": "", children: footer }), _jsx("span", { "data-jg-kbd-hint": "", "data-jg-game-menu-footer-hint": "", children: controlHint ??
|
|
389
|
+
(isPause ? "Esc resume · Enter select" : "↑↓ move · Enter select · Esc back") })] })] }));
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* A pause menu built from {@link GameMenu}: the same surface and navigation at a smaller scale, so a
|
|
393
|
+
* game composes one menu system rather than a second one. Escape (or B/Start) resumes.
|
|
394
|
+
*
|
|
395
|
+
* @capability game-pause-menu pause menu from the shared game-menu system — same navigation and surface, sized down
|
|
396
|
+
*/
|
|
397
|
+
export function GamePauseMenu(props) {
|
|
398
|
+
const { onResume, onBack, ...rest } = props;
|
|
399
|
+
return _jsx(GameMenu, { ...rest, variant: "pause", onBack: onBack ?? onResume, label: rest.label ?? "Paused" });
|
|
400
|
+
}
|