@wasm-gaming/rsdkv3-wasm 0.1.4 → 0.2.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/README.md CHANGED
@@ -1,89 +1,98 @@
1
1
  # @wasm-gaming/rsdkv3-wasm
2
2
 
3
- RSDKv3 (Retro Software Development Kit v3, Sonic CD 2011 decompilation) compiled
4
- to WebAssembly, wrapped in a JS SDK conforming to the
5
- [wasm-gaming engine contract](https://github.com/wasm-gaming/engine-specs).
3
+ RSDKv3 (Retro Software Development Kit v3, the Sonic CD 2011 decompilation) compiled to
4
+ WebAssembly, wrapped in a self-contained SDK that implements the
5
+ [wasm-gaming engine contract](https://github.com/wasm-gaming/engine-specs) 1.0.
6
6
 
7
- The engine does not bake game data with `--preload-file`. Hosts provide
8
- `Data.rsdk` at runtime, so the same `rsdkv3.wasm` can be reused across installs.
7
+ The engine bakes no game data: the host hands over `Data.rsdk` at runtime, so one `rsdkv3.wasm`
8
+ serves any install.
9
9
 
10
- ## Contract surface
10
+ ## Install
11
11
 
12
- ```js
13
- import { manifest, load } from '@wasm-gaming/rsdkv3-wasm';
14
-
15
- const engine = await load({
16
- canvas,
17
- assets: { data: dataRsdkBytes /*, settings: iniBytes */ },
18
- onEvent: (e) => { /* 'ready' | 'error' */ },
19
- // Optional override when artifacts are hosted externally:
20
- // jsUrl, wasmUrl,
21
- });
22
-
23
- engine.pause();
24
- engine.resume();
25
- engine.setInput('rsdkv4');
26
- engine.destroy();
12
+ ```bash
13
+ npm install @wasm-gaming/rsdkv3-wasm
27
14
  ```
28
15
 
29
- - `manifest` defines required `data` (`/data/Data.rsdk`) and optional `settings`
30
- (`/data/settings.ini`) assets.
31
- - `load(config)` returns an `EngineInstance` and exposes `devMenu` bridge helpers:
32
- `getStageList()`, `loadStage(category, stage)`, `setPaused(bool)`.
16
+ The SDK carries the contract's runtime beside it (`dist/rsdkv3/engine-specs.js`) and imports it
17
+ relatively: it loads from any static server, with no bundler and no import map. The wasm and its
18
+ glue are built in CI and shipped in the GitHub Release (`rsdkv3.tar.gz`, layout `rsdkv3/rsdkv3/…`).
33
19
 
34
- ## Options
20
+ ## Use
35
21
 
36
- Engine-specific options are declared in
37
- [src/rsdkv3.options.ts](src/rsdkv3.options.ts) and mirrored into `manifest.options`:
22
+ ```js
23
+ import Rsdkv3SDK from './rsdkv3/rsdkv3.sdk.js';
24
+
25
+ const play = await new Rsdkv3SDK({ config: { soundtrack: 1 } }) // US soundtrack
26
+ .assets({ data: () => readDataRsdk() }) // bytes, or a thunk
27
+ .storage({ namespace: 'sonic-cd' }) // → OPFS rsdkv3/sonic-cd/
28
+ .on('saves', ({ detail }) => console.log('saved', detail.files))
29
+ .mount(document.querySelector('#stage'))
30
+ .start();
31
+
32
+ play.pause('menu'); play.resume('menu'); // owner-aware; sound stops too
33
+ play.devMenu.getStageList(); // draw your own stage select…
34
+ play.devMenu.loadStage(0, 2); // …and warp
35
+ await play.destroy(); // save flushed, loop stopped, audio closed
36
+ ```
38
37
 
39
- - `devMenu`
40
- - `engineDebugMode`
41
- - `vsync`
42
- - `soundtrack` (`0` JP/EU, `1` US)
43
- - `language`
44
- - `disableTouchControls`
38
+ | payload | keys |
39
+ | --- | --- |
40
+ | `config` | `devMenu`, `engineDebugMode`, `vsync`, `soundtrack` (0 JP/EU, 1 US), `language`, `disableTouchControls` (→ generated `settings.ini`), `jsUrl`, `wasmUrl` |
41
+ | `options` | — |
42
+ | `assets` | `data` (`Data.rsdk`, required), `settings` (`settings.ini`, optional) |
43
+ | `storage` | `namespace` (default `default`), `persist` (`'opfs'` \| `null`) |
45
44
 
46
- If `assets.settings` is omitted, the SDK generates `settings.ini` from these
47
- options.
45
+ Events: the contract's `start`, `error`, `exit`, `pause`, `resume`, plus `saves`.
46
+ RSDKv3 cannot restart in process: after `destroy()`, a new session needs a new page (or iframe).
48
47
 
49
- ## Build
48
+ ## The engine's own app
50
49
 
51
- All build logic is in [Makefile](Makefile):
50
+ A host that would rather not draw menus mounts the app shipped next to the SDK
51
+ ([engine-specs](https://github.com/wasm-gaming/engine-specs) `EngineApp`, `UX.md`):
52
52
 
53
- ```bash
54
- make build # SDK (TypeScript) + WASM
55
- make build-sdk # SDK only
56
- make build-wasm # WASM via Docker wrapper
57
- make preview # serves dist/ at http://localhost:8080
53
+ ```js
54
+ const app = await import('@wasm-gaming/rsdkv3-wasm/app'); // dist/rsdkv3/app/app.js
55
+ const handle = await app.mount(element, { game, storage, assets, input, exit });
56
+ await handle.unmount();
58
57
  ```
59
58
 
60
- - `scripts/build.sh` clones `Rubberduckycooly/Sonic-CD-11-Decompilation`, applies
61
- known Emscripten patches, vendors `libtheora`, and outputs:
62
- - `dist/rsdkv3/rsdkv3.js`
63
- - `dist/rsdkv3/rsdkv3.wasm`
64
- - `scripts/emit-manifest.mjs` writes `dist/manifest.json` from typed manifest.
65
- - `scripts/seed-settings.mjs` seeds `dist/settings.ini` from
66
- `src/settings.default.ini` when absent.
59
+ It shows a boot screen, then Sonic CD with its own title and save select. Select + Start or
60
+ Escape opens the pause menu: *Continuar*, the game's own screens found in the stage list (title
61
+ screen, main menu, level select — only those the data has) and *Volver al lanzador* (asks first).
62
+ There is no load-game screen like rsdkv4's yet: that needs a native bridge (see `RECORD/`).
63
+
64
+ ## Saves live in the engine's own folder
65
+
66
+ The working dir is `/data/rsdkv3/<namespace>` (in memory). The SDK seeds `SData.bin` from OPFS
67
+ `rsdkv3/<namespace>/` before boot and mirrors it back when it changes — every 10 s, on pause, when
68
+ the page is hidden and on `destroy()` — so saves survive reloads. `play.storagePath` gives the
69
+ folder; `storagePath(engineId, namespace)` is exported.
67
70
 
68
- ## Try locally
71
+ ## Build
69
72
 
70
73
  ```bash
71
- make build
72
- cp /path/to/Data.rsdk dist/
73
- make preview
74
- # open http://localhost:8080/
74
+ make build-sdk # TypeScript → dist/rsdkv3/ (fast, no Docker)
75
+ make test # type-check + tests against the built package
76
+ make build-wasm # RSDKv3 → WebAssembly in emscripten/emsdk:6.0.2 (Docker)
77
+ make demo # dist/index.html: a no-build demo (pick or drop Data.rsdk)
78
+ make preview # serve dist/ on :8023
75
79
  ```
76
80
 
77
- The demo attempts `./Data.rsdk`; if missing, it shows a file picker and supports
78
- drag-and-drop.
81
+ The upstream decompilation is pinned (`d1bbec59`, 2026-07-04) together with its submodules, and so
82
+ is the emsdk image, because the build's source patches are written against that exact revision.
83
+
84
+ ## Project Specs
85
+
86
+ - Contract 1.0 class (`Rsdkv3SDK` / `Rsdkv3Play`), self-contained ESM, engine-specs as the one
87
+ dependency; conformance line `RSDKV3_ENGINE` checked by `make test`.
88
+ - Engine-owned storage `rsdkv3/<namespace>/` with an `SData.bin` mirror.
89
+ - Records of decisions in [RECORD/](RECORD/).
79
90
 
80
- ## Status
91
+ ## Todos
81
92
 
82
- - ✅ TypeScript SDK + manifest build
83
- - ✅ CI workflow scaffold for SDK/WASM/release/pages
84
- - ⏳ Browser runtime verification with a real `Data.rsdk`
85
- - ⏳ Full WASM build verification (`make build-wasm`) on this new subproject
93
+ - [ ] Make the build's source patches assert that they applied.
94
+ - [ ] Verify with a real `Data.rsdk` (save written by the game → mirrored → restored).
86
95
 
87
96
  ## License
88
97
 
89
- MIT for this wrapper. Upstream engine and game data keep their own licenses.
98
+ MIT for this wrapper. The upstream engine and the game data keep their own licenses.
@@ -0,0 +1,77 @@
1
+ /* rsdkv3 app — boot screen and pause menu (same look as rsdkv4's app).
2
+ Tokens come from engine-specs (UI_TOKENS, `--wg-*`) when the host sets them; the
3
+ fallbacks are the same values, so the app looks right on a bare page too. */
4
+
5
+ .rsdk-app {
6
+ --ring: var(--wg-focus-ring, 0 0 0 3px #fff);
7
+ --surface: var(--wg-surface, rgb(18 20 28 / 0.78));
8
+ --line: var(--wg-line, rgb(255 255 255 / 0.12));
9
+ --text: var(--wg-text, #f4f6fb);
10
+ --muted: var(--wg-muted, #8e97a9);
11
+ --danger: var(--wg-danger, #f87171);
12
+ --ui: var(--wg-font-ui, 'Inter Variable', 'Inter', system-ui, sans-serif);
13
+ --display: var(--wg-font-display, 'Unbounded Variable', 'Unbounded', system-ui, sans-serif);
14
+ --ease: var(--wg-ease, cubic-bezier(0.2, 0.8, 0.2, 1));
15
+ position: absolute;
16
+ inset: 0;
17
+ overflow: hidden;
18
+ background: #000;
19
+ color: var(--text);
20
+ font-family: var(--ui);
21
+ user-select: none;
22
+ -webkit-tap-highlight-color: transparent;
23
+ }
24
+ .rsdk-app * { box-sizing: border-box; }
25
+ .rsdk-stage { position: absolute; inset: 0; display: grid; place-items: center; }
26
+ .rsdk-stage canvas { outline: none; }
27
+ .rsdk-overlay { position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none; }
28
+ .rsdk-overlay > * { pointer-events: auto; }
29
+ .rsdk-app[data-screen='game'] .rsdk-overlay { display: none; }
30
+
31
+ /* Everything but the game sits on a backdrop that keeps the game visible underneath. */
32
+ .rsdk-app:not([data-screen='game']) .rsdk-overlay {
33
+ background:
34
+ radial-gradient(120% 80% at 50% 0%, color-mix(in srgb, var(--accent) 38%, transparent), transparent 70%),
35
+ rgb(4 6 12 / 0.72);
36
+ backdrop-filter: blur(14px) saturate(1.2);
37
+ -webkit-backdrop-filter: blur(14px) saturate(1.2);
38
+ }
39
+ .rsdk-app[data-screen='boot'] .rsdk-overlay { background-color: rgb(4 6 12 / 0.9); }
40
+
41
+ .kicker { margin: 0; font: 600 0.72rem var(--ui); letter-spacing: 0.22em; text-transform: uppercase; color: var(--muted); }
42
+ .rsdk-app h2 { margin: 0; font: 700 clamp(1.4rem, 3vw, 2.1rem) var(--display); letter-spacing: -0.02em; }
43
+
44
+ /* ---- boot ---- */
45
+ .rsdk-boot { display: grid; justify-items: center; gap: 22px; }
46
+ .rsdk-boot img { width: min(260px, 50vw); border-radius: 22px; box-shadow: 0 30px 80px -30px #000; animation: rsdk-breathe 2.4s var(--ease) infinite alternate; }
47
+ .rsdk-boot p { margin: 0; color: var(--muted); font-weight: 500; }
48
+ @keyframes rsdk-breathe { from { opacity: 0.55; transform: scale(0.98); } to { opacity: 1; transform: none; } }
49
+
50
+ /* ---- pause & confirm ---- */
51
+ .rsdk-panel {
52
+ width: min(440px, 92vw); display: grid; gap: 10px; padding: 26px; border-radius: 28px;
53
+ background: rgb(20 22 31 / 0.9); border: 1px solid var(--line); box-shadow: 0 40px 100px -30px #000;
54
+ }
55
+ .rsdk-panel h2 { margin-bottom: 8px; }
56
+ .rsdk-panel .text { margin: 0 0 8px; color: var(--muted); line-height: 1.5; }
57
+ .item {
58
+ all: unset; box-sizing: border-box; display: grid; gap: 2px; padding: 13px 16px; border-radius: 14px;
59
+ background: rgb(255 255 255 / 0.05); font-weight: 600; cursor: pointer; transition: background 0.15s, box-shadow 0.15s;
60
+ }
61
+ .item small { font-weight: 500; font-size: 0.78rem; color: var(--muted); }
62
+ .item.is-focused { background: #fff; color: #0b0d12; box-shadow: var(--ring); }
63
+ .item.is-focused small { color: #4b5563; }
64
+ .item.is-danger.is-focused { background: var(--danger); color: #1a0505; box-shadow: 0 0 0 3px var(--danger); }
65
+ .rsdk-panel .row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
66
+ .rsdk-panel .row .item { text-align: center; }
67
+ .rsdk-confirm { position: absolute; inset: 0; display: grid; place-items: center; background: rgb(0 0 0 / 0.55); }
68
+
69
+ /* ---- hints ---- */
70
+ .rsdk-hints { display: flex; flex-wrap: wrap; gap: 8px 18px; justify-content: center; color: var(--muted); font-size: 0.78rem; font-weight: 500; }
71
+ .rsdk-hints span { display: inline-flex; align-items: center; gap: 7px; }
72
+ .rsdk-hints kbd { display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 6px; border-radius: 11px; background: rgb(255 255 255 / 0.12); color: var(--text); font: 700 0.68rem var(--ui); }
73
+ .rsdk-panel .rsdk-hints { justify-content: flex-start; margin-top: 6px; }
74
+
75
+ @media (prefers-reduced-motion: reduce) {
76
+ .rsdk-boot img { transition: none; animation: none; }
77
+ }
@@ -0,0 +1,319 @@
1
+ // rsdkv3's own app: what a host gets when it mounts this engine instead of driving
2
+ // the SDK itself — a boot screen, then the game with its OWN title and save select,
3
+ // and a pause menu on the wasm-gaming usability scheme (engine-specs UX.md).
4
+ //
5
+ // import * as app from '<engines>/rsdkv3/app/app.js'
6
+ // const handle = await app.mount(target, host) // EngineApp.mount
7
+ // await handle.unmount()
8
+ //
9
+ // `host` is engine-specs' EngineAppHost: { game, storage, assets, input, exit }. The
10
+ // input is the host's createInput(): resumed it delivers menu actions; suspended (in
11
+ // game) only Select+Start and Escape come through, as `menu`.
12
+ //
13
+ // Unlike rsdkv4's app there is no load-game screen: Sonic CD's menus are scripts in
14
+ // Data.rsdk, with no native bridge to list or start a save (see RECORD/).
15
+ //
16
+ // Plain JS and DOM on purpose: the app ships inside the engine's package next to the
17
+ // SDK and loads from a static server, so it imports nothing but the SDK.
18
+
19
+ import Rsdkv3SDK from '../rsdkv3.sdk.js';
20
+
21
+ const ENGINE = 'rsdkv3';
22
+ const OWNER = 'app';
23
+
24
+ /**
25
+ * The game's own screens worth jumping to from the pause menu, looked up by scene
26
+ * name in the live stage list. Only the ones the loaded Data.rsdk has are offered.
27
+ */
28
+ export const GAME_SCREENS = [
29
+ { match: /^TITLE( SCREEN)?$/i, label: 'Pantalla de título' },
30
+ { match: /^(MAIN MENU|MENU|SAVE SELECT|STAGE MENU)$/i, label: 'Menú del juego' },
31
+ { match: /^(LEVEL|STAGE) SELECT$/i, label: 'Level select' }, // Sonic CD: STAGE SELECT
32
+ ];
33
+
34
+ /** `[{ label, list, scene }]` for the GAME_SCREENS found in a devMenu stage list. */
35
+ export const findGameScreens = (lists) => {
36
+ const found = [];
37
+ for (const screen of GAME_SCREENS) {
38
+ for (let list = 0; list < (lists?.length ?? 0); list++) {
39
+ const scene = (lists[list]?.stages ?? []).findIndex(({ name } = {}) => screen.match.test(String(name ?? '').trim()));
40
+ if (scene >= 0) {
41
+ found.push({ label: screen.label, list, scene });
42
+ break;
43
+ }
44
+ }
45
+ }
46
+ return found;
47
+ };
48
+
49
+ const esc = (text) => String(text ?? '').replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
50
+
51
+ let cssLoaded = null;
52
+ const loadCss = () =>
53
+ (cssLoaded ??= new Promise((resolve) => {
54
+ const link = document.createElement('link');
55
+ link.rel = 'stylesheet';
56
+ link.href = new URL('./app.css', import.meta.url).href;
57
+ link.onload = link.onerror = () => resolve();
58
+ document.head.append(link);
59
+ }));
60
+
61
+ export async function mount(target, host) {
62
+ await loadCss();
63
+
64
+ const root = document.createElement('div');
65
+ root.className = 'rsdk-app';
66
+ root.style.setProperty('--accent', host.game?.accent ?? '#1d4ed8');
67
+ const stage = document.createElement('div');
68
+ stage.className = 'rsdk-stage';
69
+ const overlay = document.createElement('div');
70
+ overlay.className = 'rsdk-overlay';
71
+ root.append(stage, overlay);
72
+ target.append(root);
73
+
74
+ /** screen: 'boot' | 'game' | 'pause' | 'confirm' | 'error' */
75
+ const state = {
76
+ screen: 'boot',
77
+ status: 'Arrancando el motor…',
78
+ error: null,
79
+ pause: { items: [], focus: 0 },
80
+ confirm: null, // { title, text, yes, no, onYes, onNo, focus, under }
81
+ };
82
+
83
+ let play = null;
84
+ let leaving = false;
85
+ let destroyed = false;
86
+
87
+ const canvasFocus = () => stage.querySelector('canvas')?.focus({ preventScroll: true });
88
+ const title = () => host.game?.title ?? 'Sonic CD';
89
+
90
+ // ---- screens ------------------------------------------------------------------
91
+
92
+ const hints = (pairs) =>
93
+ `<footer class="rsdk-hints">${pairs.map(([k, t]) => `<span><kbd>${k}</kbd>${t}</span>`).join('')}</footer>`;
94
+
95
+ const render = () => {
96
+ if (destroyed) return;
97
+ root.dataset.screen = state.screen;
98
+ const s = state.screen;
99
+ if (s === 'game') {
100
+ overlay.innerHTML = '';
101
+ return;
102
+ }
103
+ if (s === 'boot') {
104
+ overlay.innerHTML = `<section class="rsdk-boot">
105
+ ${host.game?.art ? `<img src="${esc(host.game.art)}" alt="">` : ''}
106
+ <p>${esc(state.status)}</p>
107
+ </section>`;
108
+ return;
109
+ }
110
+ if (s === 'error') {
111
+ overlay.innerHTML = `<section class="rsdk-panel">
112
+ <p class="kicker">${esc(title())}</p>
113
+ <h2>No se pudo arrancar</h2>
114
+ <p class="text">${esc(state.error)}</p>
115
+ <button class="item is-focused" data-exit>Volver</button>
116
+ </section>`;
117
+ return;
118
+ }
119
+ let html = '';
120
+ const base = s === 'confirm' ? state.confirm.under : s;
121
+ if (base === 'pause') {
122
+ html += `<section class="rsdk-panel">
123
+ <p class="kicker">${esc(title())}</p>
124
+ <h2>Pausa</h2>
125
+ ${state.pause.items.map((item, i) => `<button class="item${i === state.pause.focus && s !== 'confirm' ? ' is-focused' : ''}" data-item="${i}">${esc(item.label)}${item.hint ? `<small>${esc(item.hint)}</small>` : ''}</button>`).join('')}
126
+ ${hints([['A', 'elegir'], ['B', 'continuar']])}
127
+ </section>`;
128
+ }
129
+ if (s === 'confirm') {
130
+ const c = state.confirm;
131
+ html += `<div class="rsdk-confirm"><section class="rsdk-panel">
132
+ <h2>${esc(c.title)}</h2>
133
+ ${c.text ? `<p class="text">${esc(c.text)}</p>` : ''}
134
+ <div class="row">
135
+ <button class="item${c.focus === 0 ? ' is-focused' : ''}" data-confirm="0">${esc(c.no)}</button>
136
+ <button class="item is-danger${c.focus === 1 ? ' is-focused' : ''}" data-confirm="1">${esc(c.yes)}</button>
137
+ </div>
138
+ </section></div>`;
139
+ }
140
+ overlay.innerHTML = html;
141
+ };
142
+
143
+ // ---- transitions --------------------------------------------------------------
144
+
145
+ const enterGame = () => {
146
+ state.screen = 'game';
147
+ render();
148
+ host.input.suspend();
149
+ canvasFocus();
150
+ };
151
+
152
+ const gameScreens = () => {
153
+ try {
154
+ return findGameScreens(play.devMenu.getStageList());
155
+ } catch {
156
+ return [];
157
+ }
158
+ };
159
+
160
+ const openPause = () => {
161
+ play?.pause(OWNER);
162
+ state.pause = {
163
+ focus: 0,
164
+ items: [
165
+ { label: 'Continuar', run: closePause },
166
+ ...gameScreens().map((screen) => ({
167
+ label: screen.label,
168
+ hint: 'pantalla del propio juego',
169
+ run: () => {
170
+ closePause();
171
+ play.devMenu.loadStage(screen.list, screen.scene);
172
+ },
173
+ })),
174
+ { label: 'Volver al lanzador', run: askExit },
175
+ ],
176
+ };
177
+ state.screen = 'pause';
178
+ host.input.resume();
179
+ render();
180
+ };
181
+
182
+ const closePause = () => {
183
+ enterGame();
184
+ play?.resume(OWNER);
185
+ };
186
+
187
+ // A confirm sits on top of the screen it was asked from and goes back to it.
188
+ const ask = (options) => {
189
+ state.confirm = { focus: 0, under: state.screen, ...options };
190
+ state.screen = 'confirm';
191
+ render();
192
+ };
193
+ const answer = (yes) => {
194
+ const c = state.confirm;
195
+ state.screen = c.under;
196
+ state.confirm = null;
197
+ render();
198
+ (yes ? c.onYes : c.onNo)?.();
199
+ };
200
+
201
+ const exit = async () => {
202
+ if (leaving) return;
203
+ leaving = true;
204
+ try {
205
+ await play?.destroy();
206
+ } catch (error) {
207
+ console.warn(`[${ENGINE} app] destroy failed`, error);
208
+ }
209
+ play = null;
210
+ host.exit();
211
+ };
212
+
213
+ const askExit = () =>
214
+ ask({ title: '¿Volver al lanzador?', text: 'La partida se guarda donde la guarde el juego.', yes: 'Salir', no: 'Seguir', onYes: exit });
215
+
216
+ // ---- input ----------------------------------------------------------------------
217
+
218
+ const onPause = (action) => {
219
+ const p = state.pause;
220
+ if (action === 'up') p.focus = (p.focus + p.items.length - 1) % p.items.length;
221
+ else if (action === 'down') p.focus = (p.focus + 1) % p.items.length;
222
+ else if (action === 'accept') return p.items[p.focus].run();
223
+ else if (action === 'back' || action === 'menu') return closePause();
224
+ else return;
225
+ render();
226
+ };
227
+
228
+ const onConfirm = (action) => {
229
+ const c = state.confirm;
230
+ if (action === 'left' || action === 'up') c.focus = 0;
231
+ else if (action === 'right' || action === 'down') c.focus = 1;
232
+ else if (action === 'accept') return answer(c.focus === 1);
233
+ else if (action === 'back') return answer(false);
234
+ else return;
235
+ render();
236
+ };
237
+
238
+ const off = host.input.on((action) => {
239
+ if (leaving) return;
240
+ const s = state.screen;
241
+ if (s === 'game') {
242
+ if (action === 'menu') openPause();
243
+ } else if (s === 'pause') onPause(action);
244
+ else if (s === 'confirm') onConfirm(action);
245
+ else if (s === 'error' && (action === 'accept' || action === 'back')) exit();
246
+ else if (s === 'boot' && action === 'back') exit();
247
+ });
248
+
249
+ // Pointer: hover focuses, click activates — the same paths as the pad. Hover is read
250
+ // from real movement only: every render replaces the buttons, and the browser fires
251
+ // a fresh pointerover for whatever lands under a resting cursor.
252
+ let lastPointer = '';
253
+ const onPointer = (event) => {
254
+ if (event.type === 'pointermove') {
255
+ const at = `${event.clientX},${event.clientY}`;
256
+ if (at === lastPointer) return;
257
+ lastPointer = at;
258
+ }
259
+ const el = event.target.closest('[data-item],[data-confirm],[data-exit]');
260
+ if (!el || leaving) return;
261
+ if (event.type === 'pointermove') {
262
+ if (el.dataset.item != null && state.screen === 'pause' && state.pause.focus !== +el.dataset.item) {
263
+ state.pause.focus = +el.dataset.item;
264
+ render();
265
+ }
266
+ return;
267
+ }
268
+ if (el.dataset.item != null) state.pause.items[+el.dataset.item].run();
269
+ else if (el.dataset.confirm != null) answer(el.dataset.confirm === '1');
270
+ else if (el.dataset.exit != null) exit();
271
+ };
272
+ overlay.addEventListener('click', onPointer);
273
+ overlay.addEventListener('pointermove', onPointer);
274
+
275
+ const handle = {
276
+ async unmount() {
277
+ destroyed = true;
278
+ leaving = true;
279
+ off();
280
+ overlay.removeEventListener('click', onPointer);
281
+ overlay.removeEventListener('pointermove', onPointer);
282
+ try {
283
+ await play?.destroy();
284
+ } catch {
285
+ /* already gone */
286
+ }
287
+ play = null;
288
+ root.remove();
289
+ },
290
+ };
291
+
292
+ // ---- boot -----------------------------------------------------------------------
293
+
294
+ render();
295
+ host.input.resume();
296
+ try {
297
+ play = await new Rsdkv3SDK()
298
+ .mount(stage)
299
+ .assets(host.assets)
300
+ .storage(host.storage)
301
+ .on('error', ({ detail }) => console.error(`[${ENGINE} app]`, detail))
302
+ .on('exit', () => exit())
303
+ .start();
304
+ if (destroyed) {
305
+ // Unmounted while booting: nothing to show any more.
306
+ await play.destroy().catch(() => {});
307
+ play = null;
308
+ return handle;
309
+ }
310
+ enterGame();
311
+ } catch (error) {
312
+ console.error(`[${ENGINE} app]`, error);
313
+ state.error = error?.message ?? String(error);
314
+ state.screen = 'error';
315
+ render();
316
+ }
317
+
318
+ return handle;
319
+ }