@rydr/game-sdk 6.1.0 → 7.0.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.
Files changed (48) hide show
  1. package/README.md +7 -0
  2. package/dist/client/PlatformClient.d.ts +25 -2
  3. package/dist/client/PlatformClient.d.ts.map +1 -1
  4. package/dist/client/PlatformClient.js +47 -17
  5. package/dist/client/PlatformClient.js.map +1 -1
  6. package/dist/host/PlatformHost.d.ts +0 -4
  7. package/dist/host/PlatformHost.d.ts.map +1 -1
  8. package/dist/host/PlatformHost.js +0 -3
  9. package/dist/host/PlatformHost.js.map +1 -1
  10. package/dist/protocol/boards.d.ts +13 -5
  11. package/dist/protocol/boards.d.ts.map +1 -1
  12. package/dist/protocol/boards.js.map +1 -1
  13. package/dist/protocol/buttons.d.ts +10 -8
  14. package/dist/protocol/buttons.d.ts.map +1 -1
  15. package/dist/protocol/buttons.js +10 -8
  16. package/dist/protocol/buttons.js.map +1 -1
  17. package/dist/protocol/glyphs.d.ts.map +1 -1
  18. package/dist/protocol/glyphs.js +22 -1
  19. package/dist/protocol/glyphs.js.map +1 -1
  20. package/dist/protocol/messages.d.ts +1 -15
  21. package/dist/protocol/messages.d.ts.map +1 -1
  22. package/dist/protocol/version.d.ts +2 -2
  23. package/dist/protocol/version.d.ts.map +1 -1
  24. package/dist/protocol/version.js +15 -2
  25. package/dist/protocol/version.js.map +1 -1
  26. package/dist/three/perf-overlay.d.ts +52 -2
  27. package/dist/three/perf-overlay.d.ts.map +1 -1
  28. package/dist/three/perf-overlay.js +188 -3
  29. package/dist/three/perf-overlay.js.map +1 -1
  30. package/dist/ui/README.md +45 -1
  31. package/dist/ui/index.d.ts +1 -0
  32. package/dist/ui/index.d.ts.map +1 -1
  33. package/dist/ui/index.js +1 -0
  34. package/dist/ui/index.js.map +1 -1
  35. package/dist/ui/keycap.d.ts +23 -0
  36. package/dist/ui/keycap.d.ts.map +1 -1
  37. package/dist/ui/keycap.js +42 -0
  38. package/dist/ui/keycap.js.map +1 -1
  39. package/dist/ui/pause-menu.d.ts +144 -0
  40. package/dist/ui/pause-menu.d.ts.map +1 -0
  41. package/dist/ui/pause-menu.js +500 -0
  42. package/dist/ui/pause-menu.js.map +1 -0
  43. package/dist/ui/showcase/index.d.ts.map +1 -1
  44. package/dist/ui/showcase/index.js +35 -1
  45. package/dist/ui/showcase/index.js.map +1 -1
  46. package/dist/ui/styles.js +21 -0
  47. package/dist/ui/styles.js.map +1 -1
  48. package/package.json +1 -1
@@ -0,0 +1,144 @@
1
+ /**
2
+ * PAUSE MENU — the in-game menu every RYDR game shares, opened by the game's own `OPTIONS` button.
3
+ *
4
+ * This is the GAME's menu, deliberately distinct from the PLATFORM's overlay (which the shell opens on
5
+ * `MENU` / `M` / phone MENU and which never reaches a game — see `ButtonName`). The platform overlay
6
+ * carries the rider's concerns: activity, hardware, volume, exit. This one carries the *game's*:
7
+ * resume, restart, back to your own menu. It deliberately quotes the platform overlay's visual language
8
+ * — dim + blurred backdrop, frosted rows, the staggered "warp" entrance — so pause reads as a RYDR
9
+ * surface one level shallower than the platform's, rather than as a second, unrelated menu.
10
+ *
11
+ * const pause = mountPauseMenu(document.body, {
12
+ * session,
13
+ * title: "Split Racing",
14
+ * canOpen: () => phase === "playing", // the game decides WHEN it may open
15
+ * onOpen: () => { paused = true }, // the game freezes its own loop
16
+ * onClose: () => { paused = false },
17
+ * items: [
18
+ * { label: "Restart", shortcut: "DIAMOND_UP", onSelect: () => restart() },
19
+ * { label: "Back to menu", onSelect: () => go("/") },
20
+ * ],
21
+ * });
22
+ *
23
+ * `Resume` is prepended for free, so the shortest useful menu is no items at all.
24
+ *
25
+ * **Everything the rider reads is in the GAME's font.** The title and every row inherit the host's
26
+ * font-family (override with the `font` option or the `--rydr-pause-font` custom property), because
27
+ * this menu belongs to the game, not to the shell — the one exception being the keycaps, which keep
28
+ * their standard RYDR type because they are pictures of the rider's physical controller.
29
+ *
30
+ * **What it does NOT do: stop your game.** No library can — your loop, timers and physics are yours.
31
+ * The menu tells you when it opens and closes; freeze there. What it *does* guarantee is that a game
32
+ * which keeps running cannot be *driven* while the rider is in the menu: it takes the controller over
33
+ * for the duration (`session.grabInput`), so your `onButton` handlers go quiet, `isDown`/`stick` read
34
+ * resting, and anything held is released first. It also declares `"menu"` to the shell while it's up,
35
+ * so trainer resistance eases like on any other non-racing screen, and restores `"playing"` on resume.
36
+ *
37
+ * A row may advertise a `shortcut` — the button the rider can press *in-game* to do the same thing
38
+ * (`Restart · Shortcut [Y]`). Binding that shortcut during play stays the game's own job; the menu
39
+ * only teaches it, and honours it while the menu itself is open (where it owns input anyway).
40
+ */
41
+ import type { ButtonEdge, ButtonName, GlyphSet } from "../protocol/index.js";
42
+ import { type NavCue } from "../nav/spatial-nav.js";
43
+ /**
44
+ * The slice of a `PlatformSession` the menu uses. A real session satisfies it structurally, and every
45
+ * capability past `onButton` is optional so a stand-in (a showcase double, a test) works too — without
46
+ * `grabInput` the menu simply can't quiet the game's own input, and without `setActivity` the trainer
47
+ * isn't eased.
48
+ */
49
+ export interface PauseMenuSession {
50
+ onButton(cb: (e: {
51
+ name: ButtonName;
52
+ edge: ButtonEdge;
53
+ }) => void): () => void;
54
+ grabInput?(handler: (e: {
55
+ name: ButtonName;
56
+ edge: ButtonEdge;
57
+ }) => void): () => void;
58
+ setActivity?(state: "playing" | "menu"): void;
59
+ }
60
+ /** One row of the menu. */
61
+ export interface PauseMenuItem {
62
+ /** The row's text — keep it short (it's read from a few feet away): "Restart", "Back to menu". */
63
+ label: string;
64
+ /** Run when the rider picks the row (with `A`, a click, or the row's `shortcut`). */
65
+ onSelect: () => void;
66
+ /**
67
+ * The button that does this same thing **directly in-game**, shown on the row as `Shortcut [Y]` so
68
+ * the rider learns it. The game keeps owning that binding during play; the menu honours it too while
69
+ * it's open.
70
+ */
71
+ shortcut?: ButtonName;
72
+ /** Optional second line under the label — a consequence worth spelling out ("You'll lose this lap"). */
73
+ hint?: string;
74
+ /** Unavailable right now: shown inert, skipped by the focus ring. */
75
+ disabled?: boolean;
76
+ /** Destructive tint (leaving, wiping a run) — the same red the platform overlay gives Exit. */
77
+ danger?: boolean;
78
+ /** Keep the menu open after `onSelect` (a toggle, a row that swaps the list via `setItems`). Default
79
+ * is to close first, so the action runs on a resumed game with input already handed back. */
80
+ keepOpen?: boolean;
81
+ }
82
+ export interface PauseMenuOptions {
83
+ /** The platform session — for input, the trainer-easing hint, and the rider's controller lettering. */
84
+ session?: PauseMenuSession;
85
+ /** The game's name, printed big at the top in the game's own font. */
86
+ title: string;
87
+ /** The rows after `Resume`. Replaceable at any time with `setItems`. */
88
+ items?: PauseMenuItem[];
89
+ /** Which button opens (and closes) the menu. Default `"OPTIONS"` — the game's own menu button. */
90
+ openButton?: ButtonName;
91
+ /**
92
+ * Asked before opening: return `false` to refuse. This is how a game keeps the menu to its PLAYING
93
+ * phase (`() => phase === "playing"`) instead of letting it stack on top of its own menus. Default:
94
+ * always allowed.
95
+ */
96
+ canOpen?: () => boolean;
97
+ /** Label of the free first row. Default `"Resume"`. */
98
+ resumeLabel?: string;
99
+ /** Declare `"menu"` to the shell while open and `"playing"` on close (eases trainer resistance).
100
+ * Default `true`. Turn it off only if the game manages activity state itself around the menu. */
101
+ activity?: boolean;
102
+ /** Take the controller over while open via `session.grabInput` (default `true`). Off = the game keeps
103
+ * receiving input behind the menu, which it must then ignore itself. */
104
+ captureInput?: boolean;
105
+ /** Nav sounds — pass a `Soundboard.cue`; omit for silence. */
106
+ cue?: (id: NavCue) => void;
107
+ /** Force the menu's font (any CSS `font-family` value). Default: inherited from the host — i.e. the
108
+ * game's own font, which is what you want. */
109
+ font?: string;
110
+ /** Controller lettering for the keycaps. Default: the session's, else Xbox. */
111
+ glyphSet?: GlyphSet;
112
+ /** The menu opened — freeze your loop here. */
113
+ onOpen?: () => void;
114
+ /** The menu closed (resume, back, the backdrop, or a row that ran) — unfreeze here. */
115
+ onClose?: () => void;
116
+ }
117
+ export interface PauseMenu {
118
+ /** The overlay root (mounted in the host; hidden while closed). */
119
+ root: HTMLElement;
120
+ /** Open it (no-op if already open or `canOpen` refuses). Returns whether it opened. */
121
+ open(): boolean;
122
+ /** Close it, playing the exit animation. */
123
+ close(): void;
124
+ /** Open if closed, close if open. Returns whether it ended up open. */
125
+ toggle(): boolean;
126
+ isOpen(): boolean;
127
+ /** Replace the rows after `Resume` (rebuilt live if the menu is open). */
128
+ setItems(items: PauseMenuItem[]): void;
129
+ /** Change the big title. */
130
+ setTitle(title: string): void;
131
+ /**
132
+ * Feed one canonical button in by hand — for a host that arbitrates input itself instead of letting
133
+ * the menu subscribe (`session` omitted, or a shell-style router). Returns `true` if the menu
134
+ * consumed it. Pass `down` edges.
135
+ */
136
+ handleButton(name: ButtonName): boolean;
137
+ dispose(): void;
138
+ }
139
+ /**
140
+ * Mount the pause menu (hidden until opened). Appended to `host` — normally `document.body`, so the
141
+ * overlay covers the game whatever the DOM under it looks like.
142
+ */
143
+ export declare function mountPauseMenu(host: HTMLElement, opts: PauseMenuOptions): PauseMenu;
144
+ //# sourceMappingURL=pause-menu.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pause-menu.d.ts","sourceRoot":"","sources":["../../src/ui/pause-menu.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,500 @@
1
+ /**
2
+ * PAUSE MENU — the in-game menu every RYDR game shares, opened by the game's own `OPTIONS` button.
3
+ *
4
+ * This is the GAME's menu, deliberately distinct from the PLATFORM's overlay (which the shell opens on
5
+ * `MENU` / `M` / phone MENU and which never reaches a game — see `ButtonName`). The platform overlay
6
+ * carries the rider's concerns: activity, hardware, volume, exit. This one carries the *game's*:
7
+ * resume, restart, back to your own menu. It deliberately quotes the platform overlay's visual language
8
+ * — dim + blurred backdrop, frosted rows, the staggered "warp" entrance — so pause reads as a RYDR
9
+ * surface one level shallower than the platform's, rather than as a second, unrelated menu.
10
+ *
11
+ * const pause = mountPauseMenu(document.body, {
12
+ * session,
13
+ * title: "Split Racing",
14
+ * canOpen: () => phase === "playing", // the game decides WHEN it may open
15
+ * onOpen: () => { paused = true }, // the game freezes its own loop
16
+ * onClose: () => { paused = false },
17
+ * items: [
18
+ * { label: "Restart", shortcut: "DIAMOND_UP", onSelect: () => restart() },
19
+ * { label: "Back to menu", onSelect: () => go("/") },
20
+ * ],
21
+ * });
22
+ *
23
+ * `Resume` is prepended for free, so the shortest useful menu is no items at all.
24
+ *
25
+ * **Everything the rider reads is in the GAME's font.** The title and every row inherit the host's
26
+ * font-family (override with the `font` option or the `--rydr-pause-font` custom property), because
27
+ * this menu belongs to the game, not to the shell — the one exception being the keycaps, which keep
28
+ * their standard RYDR type because they are pictures of the rider's physical controller.
29
+ *
30
+ * **What it does NOT do: stop your game.** No library can — your loop, timers and physics are yours.
31
+ * The menu tells you when it opens and closes; freeze there. What it *does* guarantee is that a game
32
+ * which keeps running cannot be *driven* while the rider is in the menu: it takes the controller over
33
+ * for the duration (`session.grabInput`), so your `onButton` handlers go quiet, `isDown`/`stick` read
34
+ * resting, and anything held is released first. It also declares `"menu"` to the shell while it's up,
35
+ * so trainer resistance eases like on any other non-racing screen, and restores `"playing"` on resume.
36
+ *
37
+ * A row may advertise a `shortcut` — the button the rider can press *in-game* to do the same thing
38
+ * (`Restart · Shortcut [Y]`). Binding that shortcut during play stays the game's own job; the menu
39
+ * only teaches it, and honours it while the menu itself is open (where it owns input anyway).
40
+ */
41
+ import { DEFAULT_GLYPH_SET } from "../protocol/glyphs.js";
42
+ import { createSpatialNav } from "../nav/spatial-nav.js";
43
+ import { createKeycap, createLabelKeycap } from "./keycap.js";
44
+ import { injectCss } from "./styles.js";
45
+ /**
46
+ * The rider's controller lettering: an explicit `glyphSet` wins, else the one a real `PlatformSession`
47
+ * carries, else Xbox. Probed structurally rather than declared on {@link PauseMenuSession} — same
48
+ * reasoning as `createKeycap`'s own probe: that type is the *minimal* slice the menu needs, and
49
+ * demanding a hardware snapshot would break every hand-rolled stub for a value that's optional anyway.
50
+ */
51
+ function resolveGlyphSet(opts) {
52
+ if (opts.glyphSet)
53
+ return opts.glyphSet;
54
+ const probe = opts.session;
55
+ return probe?.hardware?.current?.glyphSet ?? DEFAULT_GLYPH_SET;
56
+ }
57
+ /** Entrance/exit duration + per-row stagger, mirrored in the CSS below. */
58
+ const ANIM_MS = 500;
59
+ const STAGGER_MS = 26;
60
+ /** Rows warp outward this fraction of their distance from the list's centre. */
61
+ const WARP_PUSH = 0.9;
62
+ let pauseCssInjected = false;
63
+ function injectPauseCss() {
64
+ if (pauseCssInjected || document.getElementById("rydr-pause-css")) {
65
+ pauseCssInjected = true;
66
+ return;
67
+ }
68
+ const style = document.createElement("style");
69
+ style.id = "rydr-pause-css";
70
+ style.textContent = CSS;
71
+ document.head.appendChild(style);
72
+ pauseCssInjected = true;
73
+ }
74
+ /**
75
+ * Mount the pause menu (hidden until opened). Appended to `host` — normally `document.body`, so the
76
+ * overlay covers the game whatever the DOM under it looks like.
77
+ */
78
+ export function mountPauseMenu(host, opts) {
79
+ injectCss(); // the keycaps' own .rydr-ui-* styles
80
+ injectPauseCss();
81
+ const { session, openButton = "OPTIONS", resumeLabel = "Resume", activity = true, captureInput = true, cue, } = opts;
82
+ const glyphSet = resolveGlyphSet(opts);
83
+ let items = opts.items ? [...opts.items] : [];
84
+ let open = false;
85
+ let closing = false;
86
+ let closeTimer = null;
87
+ let releaseInput = null;
88
+ let nav = null;
89
+ /** The rows currently in the DOM, index-aligned with {@link rowItems}. */
90
+ let rows = [];
91
+ /** The item each row stands for — `Resume` first, then `items`. */
92
+ let rowItems = [];
93
+ /** Keycaps built for the current rows (disposed on every rebuild). */
94
+ let rowCaps = [];
95
+ // ── DOM ────────────────────────────────────────────────────────────────────────────────────────
96
+ const root = document.createElement("div");
97
+ root.className = "rydr-pause";
98
+ root.tabIndex = -1;
99
+ if (opts.font)
100
+ root.style.setProperty("--rydr-pause-font", opts.font);
101
+ const inner = document.createElement("div");
102
+ inner.className = "rydr-pause-inner";
103
+ const titleEl = document.createElement("div");
104
+ titleEl.className = "rydr-pause-title";
105
+ titleEl.textContent = opts.title;
106
+ const listEl = document.createElement("div");
107
+ listEl.className = "rydr-pause-list";
108
+ inner.append(titleEl, listEl);
109
+ root.appendChild(inner);
110
+ host.appendChild(root);
111
+ // Backdrop click resumes — the mouse equivalent of pressing the open button again.
112
+ root.addEventListener("click", (e) => {
113
+ if (e.target === root)
114
+ close();
115
+ });
116
+ // ── Keycap press feedback ──────────────────────────────────────────────────────────────────────
117
+ // The keycaps can't listen to `session.onButton`: while the menu holds an input grab, the session
118
+ // delivers nothing to ordinary listeners (that's the point of the grab). So they subscribe to this
119
+ // local source, which the menu feeds from whichever path is actually live.
120
+ const capListeners = new Set();
121
+ const capSource = {
122
+ onButton: (cb) => {
123
+ capListeners.add(cb);
124
+ return () => capListeners.delete(cb);
125
+ },
126
+ };
127
+ /**
128
+ * The confirm keycap, which RIDES the focused row: every row carries a fixed-width slot at its end
129
+ * and the cap is moved into the focused one. Deliberately not the platform overlay's fixed-position
130
+ * roving cap — these rows are full-width, so a cap anchored to a row's right edge would land on top
131
+ * of that row's shortcut keycap. Riding a real slot also needs no measuring, so it can't drift
132
+ * mid-animation or on scroll.
133
+ */
134
+ const confirmCap = createKeycap("DIAMOND_DOWN", {
135
+ variant: "solo",
136
+ colored: false,
137
+ animate: true,
138
+ press: capSource,
139
+ glyphSet,
140
+ });
141
+ confirmCap.el.classList.add("rydr-pause-cap");
142
+ // ── Rows ───────────────────────────────────────────────────────────────────────────────────────
143
+ function buildRows() {
144
+ for (const cap of rowCaps)
145
+ cap.dispose();
146
+ rowCaps = [];
147
+ listEl.textContent = "";
148
+ rowItems = [{ label: resumeLabel, onSelect: () => { }, shortcut: openButton }, ...items];
149
+ rows = rowItems.map((item, i) => {
150
+ const row = document.createElement("button");
151
+ row.type = "button";
152
+ row.className = "rydr-pause-row" + (item.danger ? " danger" : "");
153
+ if (item.disabled)
154
+ row.disabled = true;
155
+ row.style.setProperty("--i", String(i));
156
+ row.style.setProperty("--ri", String(rowItems.length - 1 - i));
157
+ const text = document.createElement("span");
158
+ text.className = "rydr-pause-text";
159
+ const label = document.createElement("span");
160
+ label.className = "rydr-pause-label";
161
+ label.textContent = item.label;
162
+ text.appendChild(label);
163
+ if (item.hint) {
164
+ const hint = document.createElement("span");
165
+ hint.className = "rydr-pause-hint";
166
+ hint.textContent = item.hint;
167
+ text.appendChild(hint);
168
+ }
169
+ row.appendChild(text);
170
+ const right = document.createElement("span");
171
+ right.className = "rydr-pause-right";
172
+ if (item.shortcut) {
173
+ const shortcut = document.createElement("span");
174
+ shortcut.className = "rydr-pause-shortcut";
175
+ const tag = document.createElement("span");
176
+ tag.className = "rydr-pause-shortcut-tag";
177
+ tag.textContent = "Shortcut";
178
+ const cap = createLabelKeycap(item.shortcut, { press: capSource, glyphSet });
179
+ rowCaps.push(cap);
180
+ if (item.disabled)
181
+ cap.setDisabled(true);
182
+ shortcut.append(tag, cap.el);
183
+ right.appendChild(shortcut);
184
+ }
185
+ // The slot the confirm keycap moves into when this row takes focus. Always present (and always
186
+ // the same width) so focus moving between rows never reflows the list.
187
+ const slot = document.createElement("span");
188
+ slot.className = "rydr-pause-confirm";
189
+ right.appendChild(slot);
190
+ row.appendChild(right);
191
+ row.addEventListener("click", () => select(i));
192
+ listEl.appendChild(row);
193
+ return row;
194
+ });
195
+ }
196
+ /**
197
+ * Set each row's warp offset from its position in the list, so the entrance flies rows out from (and
198
+ * back to) their own side of the centre. Measured with the transform-invariant layout box, never
199
+ * `getBoundingClientRect()`, which returns the live *transformed* rect mid-animation and would make
200
+ * every replay drift.
201
+ */
202
+ function computeOffsets() {
203
+ const cy = listEl.offsetHeight / 2;
204
+ for (const row of rows) {
205
+ const rc = row.offsetTop + row.offsetHeight / 2;
206
+ row.style.setProperty("--dy", ((rc - cy) * WARP_PUSH).toFixed(0) + "px");
207
+ }
208
+ }
209
+ /** Move the confirm keycap into the focused row's slot (out of the DOM when nothing is focused). */
210
+ function placeCap() {
211
+ const row = open ? nav?.current() ?? null : null;
212
+ const slot = row?.querySelector(".rydr-pause-confirm");
213
+ if (!slot) {
214
+ confirmCap.el.remove();
215
+ return;
216
+ }
217
+ if (confirmCap.el.parentNode !== slot)
218
+ slot.appendChild(confirmCap.el);
219
+ }
220
+ // ── Selection ──────────────────────────────────────────────────────────────────────────────────
221
+ /**
222
+ * Run row `i`. Closes FIRST by default — so the game is already unfrozen and holding its own input
223
+ * again by the time the action runs (a restart shouldn't begin behind a menu that's still up).
224
+ */
225
+ function select(i) {
226
+ const item = rowItems[i];
227
+ if (!item || item.disabled)
228
+ return;
229
+ confirmCap.setPressed(true);
230
+ setTimeout(() => confirmCap.setPressed(false), 160);
231
+ cue?.("nav.confirm");
232
+ if (item.keepOpen) {
233
+ item.onSelect();
234
+ return;
235
+ }
236
+ close();
237
+ item.onSelect();
238
+ }
239
+ // ── Input ──────────────────────────────────────────────────────────────────────────────────────
240
+ /** Route one `down` edge. Returns whether the menu consumed it. */
241
+ function handleButton(name) {
242
+ if (!open) {
243
+ if (name !== openButton)
244
+ return false;
245
+ return openMenu();
246
+ }
247
+ if (name === openButton) {
248
+ close();
249
+ return true;
250
+ }
251
+ // A row's advertised shortcut fires that row directly — the menu owns input here, so honouring it
252
+ // costs nothing and matches what the row promises.
253
+ const shortcutIndex = rowItems.findIndex((it) => it.shortcut === name && !it.disabled);
254
+ if (shortcutIndex > 0) {
255
+ select(shortcutIndex);
256
+ return true;
257
+ }
258
+ nav?.handle(name); // directions, A (→ onActivate), B (→ onBack → close)
259
+ return true;
260
+ }
261
+ /** Feed an edge to the in-menu keycaps, then route `down` edges. Shared by both input paths. */
262
+ function onEdge(e) {
263
+ for (const cb of capListeners)
264
+ cb(e);
265
+ if (e.edge === "down")
266
+ handleButton(e.name);
267
+ }
268
+ // The always-on subscription: it's what hears the open button while the menu is closed. While a grab
269
+ // is active this receives nothing (the grab is the only recipient) — hence the `releaseInput` guard,
270
+ // which keeps a session WITHOUT `grabInput` (a stub) working off this one path.
271
+ const offSession = session?.onButton((e) => {
272
+ if (releaseInput)
273
+ return; // the grab handler already has it
274
+ onEdge(e);
275
+ });
276
+ // ── Open / close ───────────────────────────────────────────────────────────────────────────────
277
+ function openMenu() {
278
+ if (open)
279
+ return true;
280
+ if (opts.canOpen && !opts.canOpen())
281
+ return false;
282
+ if (closeTimer !== null) {
283
+ clearTimeout(closeTimer);
284
+ closeTimer = null;
285
+ }
286
+ open = true;
287
+ closing = false;
288
+ root.classList.remove("closing");
289
+ root.classList.add("open");
290
+ buildRows();
291
+ computeOffsets();
292
+ if (captureInput && session?.grabInput)
293
+ releaseInput = session.grabInput(onEdge);
294
+ if (activity)
295
+ session?.setActivity?.("menu");
296
+ // Pull keyboard focus onto the overlay so a game that also handles DOM focus can't keep it.
297
+ root.focus?.();
298
+ nav = createSpatialNav({
299
+ root: listEl,
300
+ selector: ".rydr-pause-row:not([disabled])",
301
+ ring: false, // the ring is styled below
302
+ wrap: true, // a single-axis list: DOWN past the last returns to the first
303
+ pointer: true, // hover and controller share one focus state
304
+ cue,
305
+ onActivate: (el) => select(rows.indexOf(el)),
306
+ onBack: close,
307
+ onFocusChange: () => placeCap(),
308
+ });
309
+ nav.focusFirst(); // land on Resume — the safe default
310
+ placeCap();
311
+ opts.onOpen?.();
312
+ return true;
313
+ }
314
+ function close() {
315
+ if (!open && !closing)
316
+ return;
317
+ if (closeTimer !== null)
318
+ return; // already closing
319
+ open = false;
320
+ closing = true;
321
+ // Keep the overlay mounted and lit for the exit animation; `.closing` only fades the backdrop.
322
+ root.classList.remove("open");
323
+ root.classList.add("closing");
324
+ releaseInput?.();
325
+ releaseInput = null;
326
+ if (activity)
327
+ session?.setActivity?.("playing");
328
+ nav?.dispose();
329
+ nav = null;
330
+ // The cap leaves with the focus ring, but the rows stay mounted for the warp-out.
331
+ confirmCap.el.remove();
332
+ cue?.("nav.back");
333
+ opts.onClose?.();
334
+ closeTimer = setTimeout(() => {
335
+ closeTimer = null;
336
+ closing = false;
337
+ root.classList.remove("closing");
338
+ listEl.textContent = "";
339
+ for (const cap of rowCaps)
340
+ cap.dispose();
341
+ rowCaps = [];
342
+ rows = [];
343
+ }, ANIM_MS + rows.length * STAGGER_MS + 140);
344
+ }
345
+ return {
346
+ root,
347
+ open: openMenu,
348
+ close,
349
+ toggle: () => {
350
+ if (open) {
351
+ close();
352
+ return false;
353
+ }
354
+ return openMenu();
355
+ },
356
+ isOpen: () => open,
357
+ setItems: (next) => {
358
+ items = [...next];
359
+ if (open) {
360
+ buildRows();
361
+ computeOffsets();
362
+ nav?.revalidate();
363
+ nav?.focusFirst();
364
+ placeCap();
365
+ }
366
+ },
367
+ setTitle: (title) => {
368
+ titleEl.textContent = title;
369
+ },
370
+ handleButton,
371
+ dispose: () => {
372
+ offSession?.();
373
+ releaseInput?.();
374
+ releaseInput = null;
375
+ // Torn down mid-open (a game unmounting its screen): hand the trainer back to gameplay, or the
376
+ // rider would keep riding the menu's eased resistance with no menu on screen.
377
+ if (open && activity)
378
+ session?.setActivity?.("playing");
379
+ open = false;
380
+ if (closeTimer !== null)
381
+ clearTimeout(closeTimer);
382
+ nav?.dispose();
383
+ nav = null;
384
+ for (const cap of rowCaps)
385
+ cap.dispose();
386
+ rowCaps = [];
387
+ confirmCap.dispose();
388
+ capListeners.clear();
389
+ root.remove();
390
+ },
391
+ };
392
+ }
393
+ /**
394
+ * The overlay's own `.rydr-pause-*` CSS — self-contained, injected once, no global styles or CSS
395
+ * variables required. Deliberately quotes the platform menu's numbers (the same dim, blur, radii and
396
+ * warp curve) so the two surfaces read as one family.
397
+ *
398
+ * `font-family: var(--rydr-pause-font, inherit)` on every text rule is load-bearing: the menu must
399
+ * render in the GAME's font, which it gets by inheriting from the host. The keycaps are the exception —
400
+ * they carry their own type from `styles.ts`, because they depict the rider's physical controller.
401
+ */
402
+ const CSS = `
403
+ .rydr-pause { position: fixed; inset: 0; z-index: 1400; display: flex; justify-content: center;
404
+ overflow-x: hidden; overflow-y: auto; padding: clamp(40px, 7vh, 88px) 24px 96px;
405
+ font-family: var(--rydr-pause-font, inherit);
406
+ background: rgba(2, 6, 23, calc(0.82 * var(--bd, 0)));
407
+ -webkit-backdrop-filter: blur(calc(14px * var(--bd, 0)));
408
+ backdrop-filter: blur(calc(14px * var(--bd, 0)));
409
+ opacity: 0; pointer-events: none; visibility: hidden;
410
+ transition: background 0.45s ease, backdrop-filter 0.45s ease, opacity 0.35s ease; }
411
+ .rydr-pause:focus { outline: none; }
412
+ .rydr-pause.open { opacity: 1; pointer-events: auto; visibility: visible; --bd: 1; }
413
+ /* closing: stay visible so the rows can warp back out — only the backdrop fades */
414
+ .rydr-pause.closing { opacity: 1; visibility: visible; pointer-events: none; --bd: 0;
415
+ transition: background 0.45s ease, backdrop-filter 0.45s ease; }
416
+
417
+ .rydr-pause-inner { width: 100%; max-width: 620px; margin: auto 0; display: flex; flex-direction: column; gap: 26px; }
418
+ /* the game's name, in the game's font — the biggest thing on the screen */
419
+ .rydr-pause-title { font-family: var(--rydr-pause-font, inherit); text-align: center;
420
+ font-size: clamp(2.4rem, 8vw, 3.4rem); font-weight: 800; letter-spacing: -0.03em; line-height: 1;
421
+ color: #f2f5fb; text-shadow: 0 6px 30px rgba(0, 0, 0, 0.6); }
422
+ .rydr-pause-list { position: relative; display: flex; flex-direction: column; gap: 12px; }
423
+
424
+ /* a row: frosted glass, label left, shortcut right */
425
+ .rydr-pause-row { appearance: none; cursor: pointer; width: 100%; box-sizing: border-box;
426
+ display: flex; align-items: center; justify-content: space-between; gap: 18px;
427
+ min-height: 74px; padding: 0.9rem 1.1rem 0.9rem 1.4rem; border-radius: 18px;
428
+ font-family: var(--rydr-pause-font, inherit); color: #f2f5fb; text-align: left;
429
+ background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.14);
430
+ -webkit-backdrop-filter: blur(20px) saturate(120%);
431
+ backdrop-filter: blur(20px) saturate(120%);
432
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.08);
433
+ transition: transform 0.14s ease, border-color 0.14s ease, box-shadow 0.14s ease, background 0.14s ease;
434
+ will-change: transform, opacity, filter; }
435
+ .rydr-pause-row:hover:not([disabled]) { background: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.3); }
436
+ .rydr-pause-row:focus-visible { outline: none; }
437
+ .rydr-pause-row[disabled] { cursor: default; opacity: 0.45; }
438
+ .rydr-pause-row.danger .rydr-pause-label { color: #ff8b8b; }
439
+ .rydr-pause-text { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
440
+ .rydr-pause-label { font-family: var(--rydr-pause-font, inherit); font-weight: 700; font-size: 1.6rem;
441
+ letter-spacing: 0.01em; line-height: 1.1; overflow-wrap: anywhere; }
442
+ .rydr-pause-hint { font-family: var(--rydr-pause-font, inherit); font-size: 0.95rem; font-weight: 500;
443
+ color: rgba(238, 244, 255, 0.62); line-height: 1.25; }
444
+ /* right side: the "Shortcut" tag + its keycap, then the slot the confirm cap rides into */
445
+ .rydr-pause-right { display: flex; align-items: center; gap: 18px; flex: 0 0 auto; }
446
+ .rydr-pause-shortcut { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; }
447
+ .rydr-pause-shortcut-tag { font-family: var(--rydr-pause-font, inherit); font-size: 0.7rem; font-weight: 700;
448
+ letter-spacing: 0.14em; text-transform: uppercase; color: rgba(238, 244, 255, 0.5); }
449
+ /* the slot is always there, at a FIXED width (one solo keycap), so focus moving row to row can never
450
+ reflow the list — only the cap inside it appears and disappears */
451
+ .rydr-pause-confirm { flex: 0 0 38px; height: 38px; display: flex; align-items: center; justify-content: center; }
452
+
453
+ /* roving controller focus: white ring + halo, as on the platform overlay */
454
+ .rydr-pause-row[nav-focused] { outline: none; transform: scale(1.02);
455
+ border-color: rgba(255, 255, 255, 0.9);
456
+ box-shadow: 0 0 0 3px #ffffff, 0 0 22px 0 rgba(255, 255, 255, 0.16), 0 22px 50px rgba(0, 0, 0, 0.55);
457
+ z-index: 2; }
458
+
459
+ /* the confirm keycap, sitting in the focused row's slot */
460
+ .rydr-pause-cap { pointer-events: none;
461
+ filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.7)) drop-shadow(0 8px 16px rgba(0, 0, 0, 0.5)); }
462
+
463
+ /* ── entrance / exit: rows warp in from their own side of the list, staggered ──
464
+ fill: backwards (not both) so the resting frame hands control back to plain CSS and the
465
+ [nav-focused] scale can take over without a flash. */
466
+ .rydr-pause.open .rydr-pause-row { animation: rydr-pause-warp-in ${ANIM_MS}ms cubic-bezier(0.1, 0.8, 0.4, 1) backwards;
467
+ animation-delay: calc(var(--ri, 0) * ${STAGGER_MS}ms); }
468
+ .rydr-pause.open .rydr-pause-title { animation: rydr-pause-warp-in-top ${ANIM_MS}ms cubic-bezier(0.1, 0.8, 0.4, 1) backwards; }
469
+ .rydr-pause.closing .rydr-pause-row { animation: rydr-pause-warp-out ${ANIM_MS}ms cubic-bezier(0.6, 0, 0.9, 0.2) both;
470
+ animation-delay: calc(var(--i, 0) * ${STAGGER_MS}ms); }
471
+ .rydr-pause.closing .rydr-pause-title { animation: rydr-pause-warp-out-top ${ANIM_MS}ms cubic-bezier(0.6, 0, 0.9, 0.2) both; }
472
+ @keyframes rydr-pause-warp-in {
473
+ 0% { opacity: 0; transform: translateY(calc(var(--dy, 0px) * 1.5)) scale(2.4); filter: blur(6px); }
474
+ 55% { opacity: 1; filter: blur(0); }
475
+ 100% { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
476
+ }
477
+ @keyframes rydr-pause-warp-out {
478
+ 0% { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
479
+ 45% { opacity: 1; filter: blur(0); }
480
+ 100% { opacity: 0; transform: translateY(calc(var(--dy, 0px) * 1.5)) scale(2.4); filter: blur(6px); }
481
+ }
482
+ @keyframes rydr-pause-warp-in-top {
483
+ 0% { opacity: 0; transform: translateY(-70px) scale(1.9); filter: blur(6px); }
484
+ 55% { opacity: 1; filter: blur(0); }
485
+ 100% { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
486
+ }
487
+ @keyframes rydr-pause-warp-out-top {
488
+ 0% { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
489
+ 45% { opacity: 1; filter: blur(0); }
490
+ 100% { opacity: 0; transform: translateY(-70px) scale(1.9); filter: blur(6px); }
491
+ }
492
+ @media (prefers-reduced-motion: reduce) {
493
+ .rydr-pause.open .rydr-pause-row,
494
+ .rydr-pause.open .rydr-pause-title { animation: rydr-pause-fade 0.3s ease both !important; }
495
+ .rydr-pause.closing .rydr-pause-row,
496
+ .rydr-pause.closing .rydr-pause-title { animation: rydr-pause-fade 0.2s ease reverse both !important; }
497
+ @keyframes rydr-pause-fade { from { opacity: 0; } to { opacity: 1; } }
498
+ }
499
+ `;
500
+ //# sourceMappingURL=pause-menu.js.map