@wasm-gaming/rsdkv4-wasm 0.1.5 → 0.2.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/README.md +54 -0
- package/dist/rsdkv4/rsdkv4.options.d.ts +355 -23
- package/dist/rsdkv4/rsdkv4.options.d.ts.map +1 -1
- package/dist/rsdkv4/rsdkv4.options.js +169 -52
- package/dist/rsdkv4/rsdkv4.options.js.map +1 -1
- package/dist/rsdkv4/rsdkv4.sdk.d.ts +259 -64
- package/dist/rsdkv4/rsdkv4.sdk.d.ts.map +1 -1
- package/dist/rsdkv4/rsdkv4.sdk.js +1063 -434
- package/dist/rsdkv4/rsdkv4.sdk.js.map +1 -1
- package/package.json +7 -12
- package/src/rsdkv4.options.ts +481 -73
- package/src/rsdkv4.sdk.ts +1338 -506
- package/dist/manifest.json +0 -94
- package/dist/rsdkv4/rsdkv4.manifest.d.ts +0 -4
- package/dist/rsdkv4/rsdkv4.manifest.d.ts.map +0 -1
- package/dist/rsdkv4/rsdkv4.manifest.js +0 -51
- package/dist/rsdkv4/rsdkv4.manifest.js.map +0 -1
- package/src/rsdkv4.manifest.ts +0 -57
package/src/rsdkv4.sdk.ts
CHANGED
|
@@ -1,30 +1,93 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The engine package's entry point: an `EngineSDK` for RSDKv4.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import Rsdkv4SDK from '@wasm-gaming/rsdkv4-wasm';
|
|
6
|
+
*
|
|
7
|
+
* const play = await new Rsdkv4SDK()
|
|
8
|
+
* .mount(document.querySelector('#stage'))
|
|
9
|
+
* .assets({ data: () => library.readBytes('Sonic2') })
|
|
10
|
+
* .storage({ namespace: 'rsdkv4/Sonic2' })
|
|
11
|
+
* .config({ startMenu: 'host' })
|
|
12
|
+
* .on('saves', ({ detail }) => paintSlots(detail))
|
|
13
|
+
* .start({ slot: 0 });
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Everything a host drives is the contract's, and documented there: the four payloads
|
|
17
|
+
* with their descriptors, `mount`, `on`/`off`, `start`, `restart`, `destroy`. What is
|
|
18
|
+
* this engine's own lives in two places — {@link Rsdkv4Config} and friends, which say
|
|
19
|
+
* what the payloads accept *here*, and the extra members of {@link Rsdkv4Play} below:
|
|
20
|
+
* {@link Rsdkv4Play.game} and {@link Rsdkv4Play.devMenu}, for a host that draws
|
|
21
|
+
* RSDKv4's start screens itself, owner-aware {@link Rsdkv4Play.pause}, and
|
|
22
|
+
* {@link Rsdkv4Play.input}, for a host that would rather drive the buttons than let
|
|
23
|
+
* the engine read the keyboard.
|
|
24
|
+
*
|
|
25
|
+
* One game-agnostic `rsdkv4.wasm` runs both Sonic 1 and Sonic 2 — the difference is
|
|
26
|
+
* only which `Data.rsdk` the host hands over in `assets.data`, written into the
|
|
27
|
+
* filesystem at runtime (not baked with `--preload-file`).
|
|
28
|
+
*
|
|
29
|
+
* Filesystem: built with `-sWASMFS`. The working dir is mounted on **OPFS** when the
|
|
30
|
+
* page is cross-origin isolated *and* the build can create the OPFS backend from the
|
|
31
|
+
* main thread (an Asyncify/JSPI build — today's is not, see `WebFS.cpp`); otherwise it
|
|
32
|
+
* falls back to the WASMFS in-memory backend, which boots fine but forgets everything.
|
|
33
|
+
* That fallback is why the SDK mirrors `SData.bin` into OPFS itself: see
|
|
34
|
+
* {@link Rsdkv4Storage.persist}.
|
|
35
|
+
*
|
|
36
|
+
* @module
|
|
37
|
+
* @see [The engine contract](https://wasm-gaming.github.io/engine-specs/api-docs/)
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { EnginePlayBase, EngineSDKBase, EventEmitter } from '@wasm-gaming/engine-specs';
|
|
41
|
+
import type {
|
|
42
|
+
EngineInit,
|
|
43
|
+
EngineSDKConstructor,
|
|
44
|
+
EventsOf,
|
|
45
|
+
PayloadKind,
|
|
46
|
+
PromisePlay,
|
|
47
|
+
PropertiesDefinition,
|
|
48
|
+
PropertyDefinition,
|
|
49
|
+
} from '@wasm-gaming/engine-specs';
|
|
50
|
+
import {
|
|
51
|
+
RSDKV4_BUTTONS,
|
|
52
|
+
RSDKV4_SPEC,
|
|
53
|
+
type Rsdkv4Asset,
|
|
54
|
+
type Rsdkv4AssetData,
|
|
55
|
+
type Rsdkv4Assets,
|
|
56
|
+
type Rsdkv4Buttons,
|
|
57
|
+
type Rsdkv4Config,
|
|
58
|
+
type Rsdkv4Options,
|
|
59
|
+
type Rsdkv4Payloads,
|
|
60
|
+
type Rsdkv4Stage,
|
|
61
|
+
type Rsdkv4Storage,
|
|
62
|
+
type RsdkSaveSlot,
|
|
63
|
+
} from './rsdkv4.options.js';
|
|
64
|
+
|
|
65
|
+
export type {
|
|
66
|
+
Rsdkv4Asset,
|
|
67
|
+
Rsdkv4AssetData,
|
|
68
|
+
Rsdkv4Assets,
|
|
69
|
+
Rsdkv4Button,
|
|
70
|
+
Rsdkv4Buttons,
|
|
71
|
+
Rsdkv4Config,
|
|
72
|
+
Rsdkv4Events,
|
|
73
|
+
Rsdkv4InputDefaults,
|
|
74
|
+
Rsdkv4Options,
|
|
75
|
+
Rsdkv4Payloads,
|
|
76
|
+
Rsdkv4Stage,
|
|
77
|
+
Rsdkv4Storage,
|
|
78
|
+
RsdkSaveSlot,
|
|
79
|
+
} from './rsdkv4.options.js';
|
|
80
|
+
export { RSDKV4_BUTTONS, RSDKV4_INPUT, RSDKV4_SPEC } from './rsdkv4.options.js';
|
|
81
|
+
|
|
82
|
+
/** The core events unioned with this engine's own — what `on`/`off` accept. */
|
|
83
|
+
export type Rsdkv4Events_ = EventsOf<Rsdkv4Payloads>;
|
|
24
84
|
|
|
25
85
|
const WORK_ROOT = '/data';
|
|
26
86
|
const DEFAULT_STORAGE_NAMESPACE = 'default';
|
|
27
87
|
|
|
88
|
+
/** RSDKv4's save file, written next to the game data in the working dir. */
|
|
89
|
+
const SAVE_FILE = 'SData.bin';
|
|
90
|
+
|
|
28
91
|
/**
|
|
29
92
|
* Emscripten's SDL2 port locates the canvas via `document.querySelector('#canvas')`,
|
|
30
93
|
* so the element the game draws into has to carry this id.
|
|
@@ -32,30 +95,74 @@ const DEFAULT_STORAGE_NAMESPACE = 'default';
|
|
|
32
95
|
const CANVAS_ID = 'canvas';
|
|
33
96
|
|
|
34
97
|
/**
|
|
35
|
-
*
|
|
98
|
+
* The picture's native size, which is what the SDK scales to fit its mount.
|
|
36
99
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* DEFAULT_RSDKV4_OPTIONS.
|
|
100
|
+
* A module constant rather than a manifest field: `EngineManifest` left the contract
|
|
101
|
+
* in 0.3.0, and this was one of only two things the SDK ever read back out of it.
|
|
40
102
|
*/
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
103
|
+
export const RSDKV4_VIDEO = { baseWidth: 424, baseHeight: 240, aspect: '16:9' } as const;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The embind bridges this SDK drives the engine through. All of them ship in the same
|
|
107
|
+
* rsdkv4.wasm, so a module missing any one of them is an artifact older than this SDK
|
|
108
|
+
* rather than a build variant.
|
|
109
|
+
*/
|
|
110
|
+
const ENGINE_BRIDGES = [
|
|
111
|
+
'web_engine_ready',
|
|
112
|
+
'web_game_type',
|
|
113
|
+
'web_get_players',
|
|
114
|
+
'web_get_save_slots',
|
|
115
|
+
'web_get_game_options',
|
|
116
|
+
'web_start_game',
|
|
117
|
+
'web_audio_stop',
|
|
118
|
+
'web_devmenu_get_stage_list',
|
|
119
|
+
'web_devmenu_open',
|
|
120
|
+
'web_input_set_source',
|
|
121
|
+
] as const;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The name the engine's `web_input_pull()` looks the reader up under, on the module
|
|
125
|
+
* object itself rather than on a global: two engines on one page have two modules,
|
|
126
|
+
* and a global would have them fighting over one claim.
|
|
127
|
+
*/
|
|
128
|
+
const INPUT_PULL_HOOK = '__rsdkv4_input_pull';
|
|
129
|
+
|
|
130
|
+
/** How often a pull that keeps throwing is allowed to say so. See {@link Rsdkv4Play}. */
|
|
131
|
+
const INPUT_ERROR_INTERVAL_MS = 1000;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Keys whose browser default action is swallowed while the canvas has focus — the
|
|
135
|
+
* arrows and Space scroll the page under the game, Tab takes the focus off the canvas
|
|
136
|
+
* and with it every key the engine was reading.
|
|
137
|
+
*
|
|
138
|
+
* Both `KeyboardEvent.code` and `.key` spellings are in here because the set is
|
|
139
|
+
* matched against each: `code` is the physical key (layout-independent, which is what
|
|
140
|
+
* the engine binds), `key` is what it produced, and a browser or a remapping tool that
|
|
141
|
+
* reports only one of the two still lands.
|
|
142
|
+
*
|
|
143
|
+
* WASD is included because this build's `ProcessInput()` reads those scancodes as a
|
|
144
|
+
* second set of directions. They have no default action to prevent today, so this
|
|
145
|
+
* costs nothing and stops mattering the day the page grows a shortcut on one of them.
|
|
146
|
+
*/
|
|
147
|
+
const ENGINE_KEYS = new Set([
|
|
148
|
+
'ArrowUp',
|
|
149
|
+
'ArrowDown',
|
|
150
|
+
'ArrowLeft',
|
|
151
|
+
'ArrowRight',
|
|
152
|
+
'KeyW',
|
|
153
|
+
'KeyA',
|
|
154
|
+
'KeyS',
|
|
155
|
+
'KeyD',
|
|
156
|
+
'Space',
|
|
157
|
+
' ',
|
|
158
|
+
'Tab',
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
/** The Emscripten module. Untyped on purpose: it is generated glue, not our surface. */
|
|
162
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
163
|
+
type EmscriptenModule = any;
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------- small helpers
|
|
59
166
|
|
|
60
167
|
function toUint8(x: unknown): Uint8Array | null {
|
|
61
168
|
if (x == null) return null;
|
|
@@ -63,10 +170,16 @@ function toUint8(x: unknown): Uint8Array | null {
|
|
|
63
170
|
if (x instanceof Uint8Array) return x;
|
|
64
171
|
if (x instanceof ArrayBuffer) return new Uint8Array(x);
|
|
65
172
|
if (ArrayBuffer.isView(x)) return new Uint8Array(x.buffer, x.byteOffset, x.byteLength);
|
|
66
|
-
throw new TypeError('asset must be Uint8Array | ArrayBuffer | string');
|
|
173
|
+
throw new TypeError('rsdkv4: an asset must be Uint8Array | ArrayBuffer | string');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Bytes for an asset, calling the thunk form only when we get this far. */
|
|
177
|
+
async function resolveAsset(asset: Rsdkv4Asset | undefined): Promise<Uint8Array | null> {
|
|
178
|
+
if (asset == null) return null;
|
|
179
|
+
return toUint8(typeof asset === 'function' ? await asset() : asset);
|
|
67
180
|
}
|
|
68
181
|
|
|
69
|
-
/** Normalize a
|
|
182
|
+
/** Normalize a host-provided storage namespace into a safe relative path. */
|
|
70
183
|
function normalizeStorageNamespace(namespace: unknown): string {
|
|
71
184
|
if (typeof namespace !== 'string' || !namespace.trim()) return DEFAULT_STORAGE_NAMESPACE;
|
|
72
185
|
|
|
@@ -81,8 +194,55 @@ function normalizeStorageNamespace(namespace: unknown): string {
|
|
|
81
194
|
return cleaned || DEFAULT_STORAGE_NAMESPACE;
|
|
82
195
|
}
|
|
83
196
|
|
|
197
|
+
/** The plain values behind a descriptor table — what the engine actually needs. */
|
|
198
|
+
function valuesOf<T>(described: PropertiesDefinition<T>): T {
|
|
199
|
+
const out: Record<string, unknown> = {};
|
|
200
|
+
for (const [key, property] of Object.entries(
|
|
201
|
+
described as Record<string, PropertyDefinition<unknown>>,
|
|
202
|
+
)) {
|
|
203
|
+
out[key] = property.value;
|
|
204
|
+
}
|
|
205
|
+
return out as T;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Serialize {@link Rsdkv4Config} into RSDKv4's settings.ini format.
|
|
210
|
+
*
|
|
211
|
+
* The `Starting*` trio is written as 255 — the engine's "unset" — and never anything
|
|
212
|
+
* else. That is not a leftover: anything else there, **including 0**, makes
|
|
213
|
+
* `Engine::Init` skip its normal boot flow and force a stage (see Userdata.cpp). The
|
|
214
|
+
* session's own `stage` is applied after boot instead, through the engine's stage
|
|
215
|
+
* loader, which is why it survives a `restart()`.
|
|
216
|
+
*/
|
|
217
|
+
function buildSettingsIni(config: Rsdkv4Config): string {
|
|
218
|
+
return [
|
|
219
|
+
'[Dev]',
|
|
220
|
+
`EngineDebugMode=${config.engineDebugMode ? 'true' : 'false'}`,
|
|
221
|
+
`DevMenu=${config.devMenu ? 'true' : 'false'}`,
|
|
222
|
+
'StartingCategory=255',
|
|
223
|
+
'StartingScene=255',
|
|
224
|
+
'StartingPlayer=255',
|
|
225
|
+
'',
|
|
226
|
+
'[Game]',
|
|
227
|
+
`SkipStartMenu=${config.startMenu === 'host' ? 'true' : 'false'}`,
|
|
228
|
+
'',
|
|
229
|
+
'[Window]',
|
|
230
|
+
`VSync=${config.vsync ? 'true' : 'false'}`,
|
|
231
|
+
'',
|
|
232
|
+
].join('\n');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function sameBytes(a: Uint8Array | null, b: Uint8Array | null): boolean {
|
|
236
|
+
if (a === null || b === null) return a === b;
|
|
237
|
+
if (a.length !== b.length) return false;
|
|
238
|
+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------- the wasm filesystem
|
|
243
|
+
|
|
84
244
|
/** Best-effort mkdir -p for the Emscripten FS layer. */
|
|
85
|
-
function ensureDir(Module:
|
|
245
|
+
function ensureDir(Module: EmscriptenModule, path: string): void {
|
|
86
246
|
const parts = path.split('/').filter(Boolean);
|
|
87
247
|
let current = '';
|
|
88
248
|
for (const part of parts) {
|
|
@@ -95,17 +255,27 @@ function ensureDir(Module: any, path: string): void {
|
|
|
95
255
|
}
|
|
96
256
|
}
|
|
97
257
|
|
|
258
|
+
/** True if `path` exists in the (mounted) filesystem. */
|
|
259
|
+
function fileExists(Module: EmscriptenModule, path: string): boolean {
|
|
260
|
+
try {
|
|
261
|
+
Module.FS.stat(path);
|
|
262
|
+
return true;
|
|
263
|
+
} catch {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
98
268
|
/**
|
|
99
269
|
* Whether this build can mount OPFS at all, asked of the wasm itself.
|
|
100
270
|
*
|
|
101
|
-
* WASMFS's `wasmfs_create_opfs_backend()` spawns a proxy worker synchronously,
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
271
|
+
* WASMFS's `wasmfs_create_opfs_backend()` spawns a proxy worker synchronously, which it
|
|
272
|
+
* refuses to do on the main browser thread without Asyncify/JSPI — it *asserts*, and a
|
|
273
|
+
* failed assert calls `abort()`, which tears the module down for good. That is past the
|
|
274
|
+
* point where a try/catch around the call can save us, so we have to know before
|
|
275
|
+
* calling. `web_opfs_supported` (WebFS.cpp) mirrors the same condition; builds predating
|
|
276
|
+
* that helper simply answer "no".
|
|
107
277
|
*/
|
|
108
|
-
function opfsMountSupported(Module:
|
|
278
|
+
function opfsMountSupported(Module: EmscriptenModule): boolean {
|
|
109
279
|
const probe = Module._web_opfs_supported;
|
|
110
280
|
if (typeof probe !== 'function') return false;
|
|
111
281
|
try {
|
|
@@ -116,18 +286,17 @@ function opfsMountSupported(Module: any): boolean {
|
|
|
116
286
|
}
|
|
117
287
|
|
|
118
288
|
/**
|
|
119
|
-
* Mount the game working dir,
|
|
120
|
-
*
|
|
121
|
-
* null → force the in-memory WASMFS backend (the MEMFS-equivalent fallback).
|
|
122
|
-
* undefined → auto: OPFS when the page is cross-origin isolated, else in-memory.
|
|
123
|
-
* ('idbfs' isn't supported under WASMFS and is treated as in-memory.)
|
|
289
|
+
* Mount the game working dir, honouring {@link Rsdkv4Storage.persist}. Returns whether
|
|
290
|
+
* the resulting mount is persistent.
|
|
124
291
|
*
|
|
125
|
-
* Cross-origin isolation is necessary but not sufficient: the mount also needs a
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* Returns whether the resulting mount is persistent.
|
|
292
|
+
* Cross-origin isolation is necessary but not sufficient: the mount also needs a build
|
|
293
|
+
* whose OPFS backend can run from where we call it (see {@link opfsMountSupported}), so
|
|
294
|
+
* both gates apply before we touch `web_mount_opfs`.
|
|
129
295
|
*/
|
|
130
|
-
function mountWorkingDir(
|
|
296
|
+
function mountWorkingDir(
|
|
297
|
+
Module: EmscriptenModule,
|
|
298
|
+
persist: Rsdkv4Storage['persist'],
|
|
299
|
+
): { persistent: boolean } {
|
|
131
300
|
const isolated = typeof crossOriginIsolated !== 'undefined' && crossOriginIsolated;
|
|
132
301
|
const wantsOpfs = persist === 'opfs' || (persist === undefined && isolated);
|
|
133
302
|
|
|
@@ -148,22 +317,33 @@ function mountWorkingDir(Module: any, persist: EngineConfig['persist']): { persi
|
|
|
148
317
|
return { persistent: false };
|
|
149
318
|
}
|
|
150
319
|
|
|
151
|
-
/**
|
|
152
|
-
|
|
320
|
+
/**
|
|
321
|
+
* OPFS handle for `<namespace>/`, reached with the plain (async) OPFS API from JS —
|
|
322
|
+
* which, unlike the WASM side's sync access handles, works on the main thread of any
|
|
323
|
+
* page. Returns null when OPFS isn't available at all.
|
|
324
|
+
*/
|
|
325
|
+
async function opfsNamespaceDir(
|
|
326
|
+
namespace: string,
|
|
327
|
+
create: boolean,
|
|
328
|
+
): Promise<FileSystemDirectoryHandle | null> {
|
|
329
|
+
if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return null;
|
|
153
330
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
331
|
+
let dir = await navigator.storage.getDirectory();
|
|
332
|
+
for (const segment of namespace.split('/').filter(Boolean)) {
|
|
333
|
+
dir = await dir.getDirectoryHandle(segment, { create });
|
|
334
|
+
}
|
|
335
|
+
return dir;
|
|
156
336
|
} catch {
|
|
157
|
-
return false
|
|
337
|
+
return null; // missing (create: false), or blocked
|
|
158
338
|
}
|
|
159
339
|
}
|
|
160
340
|
|
|
161
341
|
/**
|
|
162
|
-
* Wait for Engine::Init
|
|
163
|
-
* main_loop), not inside callMain
|
|
164
|
-
*
|
|
342
|
+
* Wait for `Engine::Init`, which runs on the engine's first frame (main.cpp's
|
|
343
|
+
* main_loop), not inside `callMain`. Gives up after ~4s rather than hanging the host:
|
|
344
|
+
* everything still works, the first read just comes back empty.
|
|
165
345
|
*/
|
|
166
|
-
async function waitForEngine(Module:
|
|
346
|
+
async function waitForEngine(Module: EmscriptenModule, timeoutMs = 4000): Promise<boolean> {
|
|
167
347
|
if (typeof Module.web_engine_ready !== 'function') return false;
|
|
168
348
|
const deadline = Date.now() + timeoutMs;
|
|
169
349
|
while (Date.now() < deadline) {
|
|
@@ -174,127 +354,65 @@ async function waitForEngine(Module: any, timeoutMs = 4000): Promise<boolean> {
|
|
|
174
354
|
return false;
|
|
175
355
|
}
|
|
176
356
|
|
|
177
|
-
|
|
178
|
-
* The embind bridges this SDK drives the engine through. All of them ship in the
|
|
179
|
-
* same rsdkv4.wasm, so a module missing any one of them is an artifact older
|
|
180
|
-
* than this SDK rather than a build variant.
|
|
181
|
-
*/
|
|
182
|
-
const ENGINE_BRIDGES = [
|
|
183
|
-
'web_engine_ready',
|
|
184
|
-
'web_game_type',
|
|
185
|
-
'web_get_players',
|
|
186
|
-
'web_get_save_slots',
|
|
187
|
-
'web_get_game_options',
|
|
188
|
-
'web_start_game',
|
|
189
|
-
'web_audio_stop',
|
|
190
|
-
'web_devmenu_get_stage_list',
|
|
191
|
-
] as const;
|
|
192
|
-
|
|
193
|
-
function engineBridgeComplete(Module: any): boolean {
|
|
357
|
+
function engineBridgeComplete(Module: EmscriptenModule): boolean {
|
|
194
358
|
return ENGINE_BRIDGES.every((name) => typeof Module?.[name] === 'function');
|
|
195
359
|
}
|
|
196
360
|
|
|
197
|
-
|
|
198
|
-
const SAVE_FILE = 'SData.bin';
|
|
361
|
+
// ---------------------------------------------------------------- this engine's own surface
|
|
199
362
|
|
|
200
363
|
/**
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
364
|
+
* A screen of RSDKv4's own in-canvas Dev Menu (Debug.cpp), as {@link
|
|
365
|
+
* RsdkDevMenuBridge.open} names them.
|
|
366
|
+
*
|
|
367
|
+
* `'categories'` is the engine's `SELECT A STAGE LIST` — PRESENTATION / REGULAR /
|
|
368
|
+
* SPECIAL / BONUS — and `'stages'` the scene list under one of them. Those two are the
|
|
369
|
+
* only native way to reach the BONUS list: the packs' own menu scenes (Sonic 1 and 2's
|
|
370
|
+
* `LEVEL SELECT` and `STAGE MENU`) can load a regular stage and nothing else.
|
|
204
371
|
*/
|
|
205
|
-
|
|
206
|
-
namespace: string,
|
|
207
|
-
create: boolean,
|
|
208
|
-
): Promise<FileSystemDirectoryHandle | null> {
|
|
209
|
-
if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return null;
|
|
210
|
-
try {
|
|
211
|
-
let dir = await navigator.storage.getDirectory();
|
|
212
|
-
for (const segment of namespace.split('/').filter(Boolean)) {
|
|
213
|
-
dir = await dir.getDirectoryHandle(segment, { create });
|
|
214
|
-
}
|
|
215
|
-
return dir;
|
|
216
|
-
} catch {
|
|
217
|
-
return null; // missing (create: false), or blocked
|
|
218
|
-
}
|
|
219
|
-
}
|
|
372
|
+
export type RsdkDevMenuScreen = 'main' | 'player' | 'categories' | 'stages';
|
|
220
373
|
|
|
221
374
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* is every build without Asyncify/JSPI — see mountWorkingDir), so SData.bin
|
|
226
|
-
* would die with the page and every save slot would read "NEW GAME" forever.
|
|
227
|
-
* The host's own OPFS access has no such limitation, so the SDK copies the file
|
|
228
|
-
* in before the engine boots and back out as it changes. Saves therefore
|
|
229
|
-
* survive reloads even while the mounted working dir does not.
|
|
375
|
+
* The order `web_devmenu_open`'s first argument counts in — the index into this array
|
|
376
|
+
* is what crosses to C++, so it has to stay in step with WebDevMenu.cpp (written by
|
|
377
|
+
* scripts/build.sh). Names on this side, a number on the wire.
|
|
230
378
|
*/
|
|
231
|
-
|
|
232
|
-
const dir = await opfsNamespaceDir(namespace, false);
|
|
233
|
-
if (!dir) return;
|
|
234
|
-
try {
|
|
235
|
-
const file = await (await dir.getFileHandle(SAVE_FILE)).getFile();
|
|
236
|
-
if (file.size > 0) {
|
|
237
|
-
Module.FS.writeFile(`${workDir}/${SAVE_FILE}`, new Uint8Array(await file.arrayBuffer()));
|
|
238
|
-
}
|
|
239
|
-
} catch {
|
|
240
|
-
/* no save yet */
|
|
241
|
-
}
|
|
242
|
-
}
|
|
379
|
+
const DEV_MENU_SCREENS: readonly RsdkDevMenuScreen[] = ['main', 'player', 'categories', 'stages'];
|
|
243
380
|
|
|
244
|
-
|
|
245
|
-
let bytes: Uint8Array<ArrayBuffer>;
|
|
246
|
-
try {
|
|
247
|
-
bytes = Module.FS.readFile(`${workDir}/${SAVE_FILE}`);
|
|
248
|
-
} catch {
|
|
249
|
-
return; // the engine hasn't written one
|
|
250
|
-
}
|
|
251
|
-
const dir = await opfsNamespaceDir(namespace, true);
|
|
252
|
-
if (!dir) return;
|
|
253
|
-
try {
|
|
254
|
-
const writable = await (await dir.getFileHandle(SAVE_FILE, { create: true })).createWritable();
|
|
255
|
-
await writable.write(bytes);
|
|
256
|
-
await writable.close();
|
|
257
|
-
} catch (e) {
|
|
258
|
-
console.warn('[rsdkv4] could not persist save data', e);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/** Delete a file if present; returns true when something was removed. */
|
|
263
|
-
function deleteFileIfExists(Module: any, path: string): boolean {
|
|
264
|
-
if (!fileExists(Module, path)) return false;
|
|
265
|
-
try {
|
|
266
|
-
Module.FS.unlink(path);
|
|
267
|
-
return true;
|
|
268
|
-
} catch {
|
|
269
|
-
return false;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/** RSDKv4-specific bridge for the launcher's debug/stage-select UI. */
|
|
381
|
+
/** RSDKv4-specific bridge for a host's debug/stage-select UI. */
|
|
274
382
|
export interface RsdkDevMenuBridge {
|
|
275
383
|
getStageList(): Array<{ name: string; stages: Array<{ name: string }> }>;
|
|
276
384
|
loadStage(categoryIdx: number, stageIdx: number): void;
|
|
277
385
|
setPaused(paused: boolean): void;
|
|
386
|
+
/**
|
|
387
|
+
* Whether the loaded wasm carries {@link open}. False means an engine built before
|
|
388
|
+
* the bridge existed — a host should hide the button rather than offer a dead one.
|
|
389
|
+
*/
|
|
390
|
+
readonly canOpen: boolean;
|
|
391
|
+
/**
|
|
392
|
+
* Open the engine's own Dev Menu at `screen`, with `category` selecting the stage
|
|
393
|
+
* list for `'categories'` (preselected) and `'stages'` (listed). Categories are the
|
|
394
|
+
* engine's own indices, the ones {@link getStageList} is ordered by — 2 is BONUS.
|
|
395
|
+
*
|
|
396
|
+
* This is what Escape does natively: the request is handed to the engine thread
|
|
397
|
+
* (`ENGINE_INITDEVMENU`) and the menu comes up on its next frame, so **the running
|
|
398
|
+
* stage is torn down** — `initDevMenu()` clears graphics and animation data. Save
|
|
399
|
+
* files are untouched; the current run is not.
|
|
400
|
+
*
|
|
401
|
+
* The menu is driven by the engine's own input and does not run while the SDK holds
|
|
402
|
+
* a pause, so resume before calling. Returns false when the engine cannot be reached
|
|
403
|
+
* or the wasm predates the bridge ({@link canOpen}).
|
|
404
|
+
*/
|
|
405
|
+
open(screen?: RsdkDevMenuScreen, category?: number): boolean;
|
|
278
406
|
}
|
|
279
407
|
|
|
280
|
-
/**
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
lives: number;
|
|
289
|
-
score: number;
|
|
290
|
-
emeralds: number;
|
|
291
|
-
/** Stage list the save resumes into (1 regular, 3 special), or -1 when empty. */
|
|
292
|
-
list: number;
|
|
293
|
-
/** 0-based scene within `list` — index it against `devMenu.getStageList()`. */
|
|
294
|
-
zone: number;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/** A row of the engine's GAME OPTIONS screen (differs between Sonic 1 and 2). */
|
|
408
|
+
/**
|
|
409
|
+
* A row of the engine's GAME OPTIONS screen (differs between Sonic 1 and 2), as the
|
|
410
|
+
* engine reports it — with `value` as the raw number it stores.
|
|
411
|
+
*
|
|
412
|
+
* The same rows also come back through `play.options()` as descriptors, where a
|
|
413
|
+
* boolean row reads as a boolean and an enum row as one of its choice labels. This is
|
|
414
|
+
* the untranslated view, for a host that wants it.
|
|
415
|
+
*/
|
|
298
416
|
export interface RsdkGameOption {
|
|
299
417
|
key: string;
|
|
300
418
|
label: string;
|
|
@@ -306,8 +424,8 @@ export interface RsdkGameOption {
|
|
|
306
424
|
|
|
307
425
|
/**
|
|
308
426
|
* The engine's Start Menu as data, so a host can render save-slot / character /
|
|
309
|
-
* game-option screens itself. Everything here mirrors what RSDKv4's own in-canvas
|
|
310
|
-
*
|
|
427
|
+
* game-option screens itself. Everything here mirrors what RSDKv4's own in-canvas menu
|
|
428
|
+
* does — see WebGame.cpp.
|
|
311
429
|
*/
|
|
312
430
|
export interface RsdkGameBridge {
|
|
313
431
|
/** 1 = Sonic 1, 2 = Sonic 2, 0 = unrecognised pack. */
|
|
@@ -316,9 +434,12 @@ export interface RsdkGameBridge {
|
|
|
316
434
|
players(): string[];
|
|
317
435
|
saveSlots(): RsdkSaveSlot[];
|
|
318
436
|
/**
|
|
319
|
-
* Start a game the way the native menu would: a slot with data continues it,
|
|
320
|
-
*
|
|
321
|
-
*
|
|
437
|
+
* Start a game the way the native menu would: a slot with data continues it, an
|
|
438
|
+
* empty slot starts a new game as `player`, and `slot: null` plays without saving.
|
|
439
|
+
* Resumes the engine if the SDK had it paused.
|
|
440
|
+
*
|
|
441
|
+
* This is the index-taking form. `play.restart({ slot, player })` is the same thing
|
|
442
|
+
* through the contract, with the character named rather than numbered.
|
|
322
443
|
*/
|
|
323
444
|
start(slot: number | null, player?: number): void;
|
|
324
445
|
/** Wipe a slot back to "NEW GAME". */
|
|
@@ -328,401 +449,1112 @@ export interface RsdkGameBridge {
|
|
|
328
449
|
setOption(key: string, value: number): void;
|
|
329
450
|
}
|
|
330
451
|
|
|
331
|
-
|
|
452
|
+
/**
|
|
453
|
+
* Input ownership, named — the same thing as `options({ buttons })` and
|
|
454
|
+
* `options({ buttons: null })`, for a host that would rather say what it means.
|
|
455
|
+
*
|
|
456
|
+
* See {@link Rsdkv4Options.buttons} for what a claim costs the host: keyup on focus
|
|
457
|
+
* loss, the gamepad, and remembering to release.
|
|
458
|
+
*/
|
|
459
|
+
export interface RsdkInputBridge {
|
|
460
|
+
/**
|
|
461
|
+
* Take input over. The object is kept by reference and read once per tick — write
|
|
462
|
+
* to it, do not replace it.
|
|
463
|
+
*
|
|
464
|
+
* All fourteen keys of {@link Rsdkv4Buttons} must be present and nothing else may
|
|
465
|
+
* be: a missing key would read `false` for ever, and a misspelled one would be
|
|
466
|
+
* written to and never read. Both throw here instead.
|
|
467
|
+
*/
|
|
468
|
+
claim(buttons: Rsdkv4Buttons): PromisePlay<Rsdkv4Payloads>;
|
|
469
|
+
/** Hand input back; the engine polls SDL again from the next tick. */
|
|
470
|
+
release(): PromisePlay<Rsdkv4Payloads>;
|
|
471
|
+
/** Whether a claim is held. The `options()` descriptor deliberately will not say. */
|
|
472
|
+
readonly claimed: boolean;
|
|
332
473
|
/**
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
474
|
+
* What the engine has held **right now**, as the bitmask a claim writes: bit 0 is
|
|
475
|
+
* `up`, bit 13 is `select`, in {@link RSDKV4_BUTTONS} order.
|
|
476
|
+
*
|
|
477
|
+
* This is the read-back to the claim's write, and it answers the one question no
|
|
478
|
+
* amount of listening on the page can: *did the button reach the engine?* A key
|
|
479
|
+
* that fires `keydown` in the browser and never lights a bit here died between SDL
|
|
480
|
+
* and `ProcessInput()`; one that lights the bit and changes nothing on screen was
|
|
481
|
+
* received and the game chose not to act on it. Those two are otherwise the same
|
|
482
|
+
* silence, and telling them apart is the difference between a real input bug and
|
|
483
|
+
* Sonic simply refusing to roll while a direction is held.
|
|
484
|
+
*
|
|
485
|
+
* Works whether or not a claim is held — under SDL polling it is the keyboard and
|
|
486
|
+
* the pad as the engine read them. `0` when the engine is not up.
|
|
487
|
+
*
|
|
488
|
+
* Two things it is not. It is `hold`, not `press`: a button reads the same on the
|
|
489
|
+
* frame it goes down and on the hundredth frame it stays down. And it is the
|
|
490
|
+
* engine's view *after* the B→A fold, so in-stage the jump key `x` lights `a` and
|
|
491
|
+
* `b` never lights at all — which is the fold working, not a lost button.
|
|
337
492
|
*/
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
purgeStorage(): { data: boolean; settings: boolean };
|
|
348
|
-
};
|
|
493
|
+
readonly mask: number;
|
|
494
|
+
/**
|
|
495
|
+
* {@link mask}, spelled as the fourteen named booleans — the same shape a claim
|
|
496
|
+
* hands over, so a host can diff what it sent against what arrived.
|
|
497
|
+
*
|
|
498
|
+
* A fresh object on every read; it is a snapshot, not a live view.
|
|
499
|
+
*/
|
|
500
|
+
readonly held: Rsdkv4Buttons;
|
|
501
|
+
}
|
|
349
502
|
|
|
350
503
|
/**
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
504
|
+
* What `start()` and `restart()` hand back: the contract's chainable promise, narrowed
|
|
505
|
+
* so that awaiting it gives this engine's {@link Rsdkv4Play} rather than the contract's
|
|
506
|
+
* `EnginePlay` — which is what puts {@link Rsdkv4Play.game} within reach without a cast.
|
|
507
|
+
*
|
|
508
|
+
* Chaining (`.mount(…)`, `.options(…)`) returns the contract's own `PromisePlay`, so
|
|
509
|
+
* put the `await` at the end of the chain to keep the narrowing.
|
|
510
|
+
*/
|
|
511
|
+
export interface Rsdkv4Session extends PromisePlay<Rsdkv4Payloads> {
|
|
512
|
+
then<TResult1 = Rsdkv4Play, TResult2 = never>(
|
|
513
|
+
onfulfilled?: ((value: Rsdkv4Play) => TResult1 | PromiseLike<TResult1>) | null,
|
|
514
|
+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
515
|
+
): Promise<TResult1 | TResult2>;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ---------------------------------------------------------------- the live engine
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The running engine.
|
|
522
|
+
*
|
|
523
|
+
* Most of it is the contract's, inherited: the four getter/setter pairs, the cumulative
|
|
524
|
+
* merge, the chainable promise, the event channel, and `destroy()`. What this class
|
|
525
|
+
* adds is the four hooks the base leaves to an engine — {@link open}, {@link close},
|
|
526
|
+
* {@link mounted}, {@link patched} — plus the members below, which have no contract
|
|
527
|
+
* equivalent and exist because RSDKv4 has a start menu worth drawing from HTML.
|
|
354
528
|
*/
|
|
355
|
-
export
|
|
356
|
-
|
|
357
|
-
|
|
529
|
+
export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
|
|
530
|
+
#module: EmscriptenModule | null = null;
|
|
531
|
+
/** In flight, so two concurrent `restart()`s do not boot two engines. */
|
|
532
|
+
#booting: Promise<EmscriptenModule> | null = null;
|
|
533
|
+
#destroyed = false;
|
|
534
|
+
|
|
535
|
+
#canvas: HTMLCanvasElement | null = null;
|
|
536
|
+
/** False when the host handed us its own canvas: then we neither size nor remove it. */
|
|
537
|
+
#ownsCanvas = false;
|
|
538
|
+
/** The element the picture has to fit inside, in CSS pixels. */
|
|
539
|
+
#box: HTMLElement | null = null;
|
|
540
|
+
#boxObserver: ResizeObserver | null = null;
|
|
541
|
+
|
|
542
|
+
#persistent = false;
|
|
543
|
+
#namespace = DEFAULT_STORAGE_NAMESPACE;
|
|
544
|
+
#workDir = `${WORK_ROOT}/${DEFAULT_STORAGE_NAMESPACE}`;
|
|
545
|
+
#mirrorSaves = true;
|
|
546
|
+
#saveTimer: ReturnType<typeof setInterval> | null = null;
|
|
547
|
+
/** Last bytes mirrored out, so the `saves` event means "changed" and not "checked". */
|
|
548
|
+
#lastSave: Uint8Array | null = null;
|
|
549
|
+
|
|
358
550
|
/**
|
|
359
|
-
*
|
|
360
|
-
*
|
|
551
|
+
* Pause state, with an owner.
|
|
552
|
+
*
|
|
553
|
+
* `masterPaused` only freezes the logic loop — the audio callback keeps mixing, so a
|
|
554
|
+
* paused engine went on playing its music behind whatever the host put on top.
|
|
555
|
+
* Pausing therefore has to stop the sound too.
|
|
556
|
+
*
|
|
557
|
+
* The owner matters because pause/resume has several callers: a pause overlay, a
|
|
558
|
+
* start screen, the host itself. Closing an overlay must not resume a game something
|
|
559
|
+
* else had already frozen, so the *first* pause claims ownership and only that
|
|
560
|
+
* owner's `resume()` lifts it.
|
|
361
561
|
*/
|
|
362
|
-
|
|
363
|
-
/** Deprecated alias for `canvasEl`, kept for hosts written against 0.0.x. */
|
|
364
|
-
canvas?: HTMLCanvasElement;
|
|
365
|
-
};
|
|
562
|
+
#pauseOwner: string | null = null;
|
|
366
563
|
|
|
367
|
-
/**
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
564
|
+
/**
|
|
565
|
+
* The host's button object while it holds a claim, by reference — read on the
|
|
566
|
+
* engine's callback, never copied.
|
|
567
|
+
*/
|
|
568
|
+
#buttons: Rsdkv4Buttons | null = null;
|
|
569
|
+
/** The reader handed to the engine, kept so the same function can be uninstalled. */
|
|
570
|
+
#pull: (() => number) | null = null;
|
|
571
|
+
/** Throttle state for a pull that throws. See {@link Rsdkv4Play.#readButtons}. */
|
|
572
|
+
#pullErrorKey: string | null = null;
|
|
573
|
+
#pullErrorAt = 0;
|
|
574
|
+
#pullErrors = 0;
|
|
575
|
+
|
|
576
|
+
constructor(init?: EngineInit<Rsdkv4Payloads>, events?: EventEmitter<Rsdkv4Events_>) {
|
|
577
|
+
// The spec goes up rather than being declared as a field: a subclass field
|
|
578
|
+
// initialiser runs after `super()`, which would be too late for the base to seed
|
|
579
|
+
// state from it.
|
|
580
|
+
super(RSDKV4_SPEC, init, events);
|
|
581
|
+
}
|
|
371
582
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
583
|
+
// ---- The hooks the contract's base class leaves to an engine ----
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Open or reopen a session. The first call brings the wasm up; every call after that
|
|
587
|
+
* reuses it, because RSDKv4 cannot power-cycle in process and does not need to — a
|
|
588
|
+
* session is a save slot, a character and a stage, and all three are reachable on a
|
|
589
|
+
* running engine.
|
|
590
|
+
*/
|
|
591
|
+
protected override async open(options: Rsdkv4Options): Promise<void> {
|
|
592
|
+
await this.#ensureBooted();
|
|
593
|
+
|
|
594
|
+
// Re-applied from the merged state rather than from a patch, which is what makes a
|
|
595
|
+
// claim survive `restart()`: the engine's own input source was just reset by the
|
|
596
|
+
// boot (or never set, on a reopen), and the host's object is still the one it was.
|
|
597
|
+
if (options.buttons !== undefined) this.#applyButtons(options.buttons);
|
|
598
|
+
|
|
599
|
+
// Absence is "boot the way the engine normally would", so an empty options bag
|
|
600
|
+
// leaves the engine wherever it came up — its own start menu, usually.
|
|
601
|
+
if (options.player !== undefined || options.slot !== undefined) {
|
|
602
|
+
this.game.start(options.slot ?? null, this.#playerIndex(options.player));
|
|
377
603
|
}
|
|
378
|
-
|
|
604
|
+
if (options.stage) {
|
|
605
|
+
this.devMenu.loadStage(options.stage.category | 0, options.stage.scene | 0);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
379
608
|
|
|
380
|
-
|
|
609
|
+
/**
|
|
610
|
+
* Stop and release. The base emits `exit` once this resolves.
|
|
611
|
+
*
|
|
612
|
+
* There is no coming back: RSDKv4 cannot be restarted in process, so a later
|
|
613
|
+
* `start()` on the same factory reports that rather than quietly doing nothing.
|
|
614
|
+
*/
|
|
615
|
+
protected override async close(): Promise<void> {
|
|
616
|
+
this.#destroyed = true;
|
|
381
617
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
618
|
+
if (this.#saveTimer !== null) {
|
|
619
|
+
clearInterval(this.#saveTimer);
|
|
620
|
+
this.#saveTimer = null;
|
|
621
|
+
}
|
|
386
622
|
|
|
387
|
-
|
|
388
|
-
|
|
623
|
+
const Module = this.#module;
|
|
624
|
+
if (Module) {
|
|
625
|
+
// Before anything else: the engine is about to be pumped a few more times on its
|
|
626
|
+
// way down, and there is no reason for those ticks to reach into a host object
|
|
627
|
+
// whose owner has already been told the session is over.
|
|
628
|
+
this.#applyButtons(null);
|
|
389
629
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
630
|
+
// Last chance to keep whatever the player earned this session.
|
|
631
|
+
if (this.#mirrorSaves) await this.#mirrorSaveData();
|
|
632
|
+
|
|
633
|
+
try {
|
|
634
|
+
Module.pauseMainLoop?.();
|
|
635
|
+
} catch {
|
|
636
|
+
/* engine already down */
|
|
637
|
+
}
|
|
638
|
+
this.#applyPaused(true);
|
|
639
|
+
// Freezing the loop does not silence the audio callback — without this the music
|
|
640
|
+
// kept playing after the host went back to its launcher.
|
|
641
|
+
try {
|
|
642
|
+
Module.web_audio_stop?.();
|
|
643
|
+
} catch {
|
|
644
|
+
/* engine already down */
|
|
645
|
+
}
|
|
396
646
|
}
|
|
397
|
-
|
|
647
|
+
|
|
648
|
+
this.#teardownCanvas();
|
|
649
|
+
this.#module = null;
|
|
650
|
+
this.#booting = null;
|
|
398
651
|
}
|
|
399
652
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
653
|
+
/**
|
|
654
|
+
* Where the picture goes. Called by the base before the first `open()`, which is what
|
|
655
|
+
* guarantees the canvas exists before Emscripten's SDL2 port goes looking for it.
|
|
656
|
+
*
|
|
657
|
+
* Hand over a `<canvas>` and it is used as-is, untouched apart from the id SDL needs;
|
|
658
|
+
* hand over any other element and the SDK creates a canvas inside it and keeps it
|
|
659
|
+
* scaled to fit. Reparenting a live engine is legal — the same canvas moves.
|
|
660
|
+
*/
|
|
661
|
+
protected override async mounted(target: HTMLElement): Promise<void> {
|
|
662
|
+
if (this.#canvas) {
|
|
663
|
+
this.#reparent(target);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
406
666
|
|
|
407
|
-
|
|
408
|
-
|
|
667
|
+
const provided = target instanceof HTMLCanvasElement ? target : null;
|
|
668
|
+
const canvas = provided ?? document.createElement('canvas');
|
|
669
|
+
this.#ownsCanvas = provided === null;
|
|
670
|
+
this.#canvas = canvas;
|
|
671
|
+
this.#box = this.#ownsCanvas ? target : canvas;
|
|
672
|
+
|
|
673
|
+
// Emscripten's SDL2 port locates the canvas via document.querySelector('#canvas').
|
|
674
|
+
if (canvas.id !== CANVAS_ID) {
|
|
675
|
+
if (canvas.id) {
|
|
676
|
+
console.warn(
|
|
677
|
+
`[rsdkv4] renaming the render target's id from "${canvas.id}" to "${CANVAS_ID}" — SDL looks the canvas up by that id.`,
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
canvas.id = CANVAS_ID;
|
|
681
|
+
}
|
|
409
682
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
683
|
+
if (this.#ownsCanvas) {
|
|
684
|
+
canvas.style.display = 'block';
|
|
685
|
+
canvas.style.maxWidth = '100%';
|
|
686
|
+
canvas.style.maxHeight = '100%';
|
|
687
|
+
canvas.style.height = 'auto';
|
|
688
|
+
canvas.classList.add('emscripten');
|
|
689
|
+
target.appendChild(canvas);
|
|
690
|
+
}
|
|
415
691
|
|
|
416
|
-
|
|
417
|
-
|
|
692
|
+
// A canvas is not in the tab order and `focus()` on one is a silent no-op until it
|
|
693
|
+
// has a tabindex. -1 makes it focusable without adding a tab stop.
|
|
694
|
+
if (!canvas.hasAttribute('tabindex')) canvas.setAttribute('tabindex', '-1');
|
|
695
|
+
// …and the focus ring would draw a browser-blue box around the picture.
|
|
696
|
+
canvas.style.outline = 'none';
|
|
697
|
+
|
|
698
|
+
canvas.addEventListener('contextmenu', this.#swallowContextMenu);
|
|
699
|
+
canvas.addEventListener('pointerdown', this.#onPointerDown);
|
|
700
|
+
canvas.addEventListener('keydown', this.#onKeyDown);
|
|
701
|
+
window.addEventListener('resize', this.#onResize);
|
|
702
|
+
// Fullscreen transitions need a refit too.
|
|
703
|
+
document.addEventListener('fullscreenchange', this.#onFullscreenChange);
|
|
704
|
+
if (typeof ResizeObserver !== 'undefined' && this.#box) {
|
|
705
|
+
this.#boxObserver = new ResizeObserver(this.#onResize);
|
|
706
|
+
this.#boxObserver.observe(this.#box);
|
|
707
|
+
}
|
|
418
708
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
canvas.focus({ preventScroll: true });
|
|
422
|
-
};
|
|
709
|
+
this.#fitPicture();
|
|
710
|
+
}
|
|
423
711
|
|
|
424
|
-
|
|
425
|
-
|
|
712
|
+
/**
|
|
713
|
+
* A payload changed on a running engine. Only the GAME OPTIONS rows are `live` here —
|
|
714
|
+
* `config` is `settings.ini`, read once at boot, and `player`/`slot`/`stage` are what
|
|
715
|
+
* a `restart()` is for.
|
|
716
|
+
*/
|
|
717
|
+
protected override async patched(kind: PayloadKind, patch: object): Promise<void> {
|
|
718
|
+
if (kind !== 'options') return;
|
|
719
|
+
|
|
720
|
+
// Ahead of the engine check, unlike everything below it: a claim written onto an
|
|
721
|
+
// engine that has not booted yet is still a claim, and a malformed one should be
|
|
722
|
+
// rejected by the call that made it rather than by the boot three lines later.
|
|
723
|
+
//
|
|
724
|
+
// `undefined` and `null` are not the same key here, and must not be collapsed:
|
|
725
|
+
// absence means "leave the claim as it is" — which is what keeps a later
|
|
726
|
+
// `options({ stage })` from dropping a claim on its way past — and only `null`
|
|
727
|
+
// releases.
|
|
728
|
+
const buttons = (patch as Rsdkv4Options).buttons;
|
|
729
|
+
if (buttons !== undefined) this.#applyButtons(buttons);
|
|
730
|
+
|
|
731
|
+
if (!this.#module) return;
|
|
732
|
+
|
|
733
|
+
const rows = this.game.options();
|
|
734
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
735
|
+
if (key === 'player' || key === 'slot' || key === 'stage' || key === 'buttons') continue;
|
|
736
|
+
const row = rows.find((candidate) => candidate.key === key);
|
|
737
|
+
if (!row) {
|
|
738
|
+
console.warn(
|
|
739
|
+
`[rsdkv4] "${key}" is not a game option this pack declares — ignoring. Read options() for the ones it does.`,
|
|
740
|
+
);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
this.game.setOption(row.key, toEngineOption(row, value));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
426
746
|
|
|
427
|
-
//
|
|
747
|
+
// ---- The one place this engine does not simply inherit ----
|
|
428
748
|
|
|
429
|
-
/**
|
|
430
|
-
|
|
749
|
+
/**
|
|
750
|
+
* The session's descriptors, enriched with what only a loaded pack can say: the
|
|
751
|
+
* playable characters, the stage list, and the engine's own GAME OPTIONS rows.
|
|
752
|
+
*
|
|
753
|
+
* The base builds descriptors from a spec fixed at construction, which is right for
|
|
754
|
+
* every other payload and cannot work for this one — Sonic 1 and Sonic 2 do not offer
|
|
755
|
+
* the same characters or the same options, and neither is knowable before
|
|
756
|
+
* `Data.rsdk` is read. Before the engine is up this answers the static spec, which is
|
|
757
|
+
* still enough for a host to know the keys exist.
|
|
758
|
+
*/
|
|
759
|
+
override options(): Promise<PropertiesDefinition<Rsdkv4Options>>;
|
|
760
|
+
override options(patch: Partial<Rsdkv4Options>): PromisePlay<Rsdkv4Payloads>;
|
|
761
|
+
override options(
|
|
762
|
+
patch?: Partial<Rsdkv4Options>,
|
|
763
|
+
): Promise<PropertiesDefinition<Rsdkv4Options>> | PromisePlay<Rsdkv4Payloads> {
|
|
764
|
+
// `arguments.length`, not `patch === undefined`: `options(undefined)` is a write of
|
|
765
|
+
// nothing, not a read.
|
|
766
|
+
if (arguments.length === 0) return this.#describeOptions();
|
|
767
|
+
return super.options(patch as Partial<Rsdkv4Options>);
|
|
768
|
+
}
|
|
431
769
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
770
|
+
async #describeOptions(): Promise<PropertiesDefinition<Rsdkv4Options>> {
|
|
771
|
+
const described = (await super.options()) as Record<string, PropertyDefinition<unknown>>;
|
|
772
|
+
// The claim never reads back, engine or no engine. The base would have written the
|
|
773
|
+
// host's live object into `value` here, and this getter is what a host serialises
|
|
774
|
+
// to save its settings and what it reads to paint a settings screen — neither of
|
|
775
|
+
// which fourteen booleans that change sixty times a second belong in.
|
|
776
|
+
// `play.input.claimed` is the honest question, and it is a different one.
|
|
777
|
+
described.buttons = { ...described.buttons, value: null };
|
|
778
|
+
if (!this.#module) return described as PropertiesDefinition<Rsdkv4Options>;
|
|
779
|
+
|
|
780
|
+
const out: Record<string, PropertyDefinition<unknown>> = { ...described };
|
|
781
|
+
|
|
782
|
+
const players = this.game.players();
|
|
783
|
+
if (players.length) out.player = { ...out.player, enum: players };
|
|
784
|
+
|
|
785
|
+
const stages = flattenStages(this.devMenu.getStageList());
|
|
786
|
+
if (stages.length) out.stage = { ...out.stage, enum: stages };
|
|
787
|
+
|
|
788
|
+
for (const row of this.game.options()) {
|
|
789
|
+
if (
|
|
790
|
+
row.key === 'player' ||
|
|
791
|
+
row.key === 'slot' ||
|
|
792
|
+
row.key === 'stage' ||
|
|
793
|
+
row.key === 'buttons'
|
|
794
|
+
) {
|
|
795
|
+
console.warn(`[rsdkv4] the pack declares a game option named "${row.key}" — shadowed`);
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
out[row.key] = {
|
|
799
|
+
value: fromEngineOption(row),
|
|
800
|
+
description: row.label,
|
|
801
|
+
live: true,
|
|
802
|
+
enum: row.type === 'boolean' ? [false, true] : (row.values ?? []),
|
|
803
|
+
};
|
|
804
|
+
}
|
|
436
805
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
const scale = Math.min(box.width / bw, box.height / bh);
|
|
440
|
-
canvas.style.width = `${Math.round(bw * scale)}px`;
|
|
441
|
-
canvas.style.height = `${Math.round(bh * scale)}px`;
|
|
442
|
-
};
|
|
806
|
+
return out as PropertiesDefinition<Rsdkv4Options>;
|
|
807
|
+
}
|
|
443
808
|
|
|
444
|
-
|
|
809
|
+
override restart(options?: Partial<Rsdkv4Options>): Rsdkv4Session {
|
|
810
|
+
return super.restart(options) as Rsdkv4Session;
|
|
811
|
+
}
|
|
445
812
|
|
|
446
|
-
|
|
447
|
-
window.addEventListener('resize', onResize);
|
|
448
|
-
const boxObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize);
|
|
449
|
-
boxObserver?.observe(boxElement);
|
|
813
|
+
// ---- Members with no contract equivalent ----
|
|
450
814
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
815
|
+
/**
|
|
816
|
+
* Pause, on behalf of `owner`. The first caller to pause owns it and only that
|
|
817
|
+
* owner's `resume()` lifts it, so a pause overlay cannot resume a game a start
|
|
818
|
+
* screen — or the host — had already frozen. Audio stops with the engine.
|
|
819
|
+
*
|
|
820
|
+
* Emits the contract's `pause` event, whose detail carries the owner.
|
|
821
|
+
*/
|
|
822
|
+
pause(owner: string = 'host'): void {
|
|
823
|
+
if (this.#pauseOwner !== null) return; // whoever got there first keeps the claim
|
|
824
|
+
this.#pauseOwner = owner;
|
|
825
|
+
this.#applyPaused(true);
|
|
826
|
+
// A pause is a natural checkpoint for the save mirror.
|
|
827
|
+
if (this.#mirrorSaves) void this.#mirrorSaveData();
|
|
828
|
+
this.emit('pause', { owner });
|
|
829
|
+
}
|
|
457
830
|
|
|
458
|
-
|
|
831
|
+
resume(owner: string = 'host'): void {
|
|
832
|
+
if (this.#pauseOwner !== owner) return; // someone else's pause — leave it alone
|
|
833
|
+
this.#pauseOwner = null;
|
|
834
|
+
this.#applyPaused(false);
|
|
835
|
+
this.emit('resume', { owner });
|
|
836
|
+
}
|
|
459
837
|
|
|
460
|
-
|
|
461
|
-
|
|
838
|
+
/** Who is holding the engine paused, or null. */
|
|
839
|
+
get pausedBy(): string | null {
|
|
840
|
+
return this.#pauseOwner;
|
|
841
|
+
}
|
|
462
842
|
|
|
463
|
-
|
|
464
|
-
|
|
843
|
+
/** True when the working dir is OPFS-backed rather than in-memory. */
|
|
844
|
+
get persistent(): boolean {
|
|
845
|
+
return this.#persistent;
|
|
846
|
+
}
|
|
465
847
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
848
|
+
/** The normalised {@link Rsdkv4Storage.namespace} this session actually used. */
|
|
849
|
+
get namespace(): string {
|
|
850
|
+
return this.#namespace;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Input ownership, named. `play.input.claim(buttons)` and `play.input.release()`
|
|
855
|
+
* are exactly `options({ buttons })` and `options({ buttons: null })` — same state,
|
|
856
|
+
* same survival across `restart()`, same everything.
|
|
857
|
+
*/
|
|
858
|
+
get input(): RsdkInputBridge {
|
|
859
|
+
// Captured, because `this` inside an object literal's getter is the literal. A
|
|
860
|
+
// host that holds on to `play.input` and asks later still gets today's answer.
|
|
861
|
+
const play = this;
|
|
862
|
+
return {
|
|
863
|
+
claim: (buttons: Rsdkv4Buttons) => this.options({ buttons }),
|
|
864
|
+
release: () => this.options({ buttons: null }),
|
|
865
|
+
get claimed(): boolean {
|
|
866
|
+
return play.#buttons !== null;
|
|
867
|
+
},
|
|
868
|
+
get mask(): number {
|
|
869
|
+
return play.#engineMask();
|
|
870
|
+
},
|
|
871
|
+
get held(): Rsdkv4Buttons {
|
|
872
|
+
const mask = play.#engineMask();
|
|
873
|
+
const held = {} as Rsdkv4Buttons;
|
|
874
|
+
for (let bit = 0; bit < RSDKV4_BUTTONS.length; bit++) {
|
|
875
|
+
held[RSDKV4_BUTTONS[bit]] = (mask & (1 << bit)) !== 0;
|
|
876
|
+
}
|
|
877
|
+
return held;
|
|
878
|
+
},
|
|
472
879
|
};
|
|
880
|
+
}
|
|
473
881
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
882
|
+
/**
|
|
883
|
+
* The engine's own `web_input_get_mask()`, or 0 when there is no engine to ask.
|
|
884
|
+
*
|
|
885
|
+
* Deliberately quiet about a module that does not have the bridge: unlike a claim,
|
|
886
|
+
* which silently does nothing and deserves the console error `#applyButtons` gives
|
|
887
|
+
* it, this is a read — a host polling it once a frame for an overlay would turn one
|
|
888
|
+
* old artifact into sixty messages a second.
|
|
889
|
+
*/
|
|
890
|
+
#engineMask(): number {
|
|
891
|
+
const Module = this.#module;
|
|
892
|
+
if (!Module || typeof Module.web_input_get_mask !== 'function') return 0;
|
|
893
|
+
return Module.web_input_get_mask() | 0;
|
|
894
|
+
}
|
|
485
895
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
896
|
+
// ---- Host-driven input ----
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Claim or release, on the engine and here. The only writer of {@link #buttons}.
|
|
900
|
+
*
|
|
901
|
+
* Order matters in both directions: the reader is installed before the engine is
|
|
902
|
+
* told to use it, and the engine is told to stop before it is taken away — so there
|
|
903
|
+
* is never a tick where the engine is pulling at something that is not there.
|
|
904
|
+
*/
|
|
905
|
+
#applyButtons(buttons: Rsdkv4Buttons | null): void {
|
|
906
|
+
if (buttons !== null) validateButtons(buttons);
|
|
907
|
+
|
|
908
|
+
const Module = this.#module;
|
|
909
|
+
this.#buttons = buttons;
|
|
910
|
+
|
|
911
|
+
// Before a boot there is nothing to tell; `open()` re-applies from the merged
|
|
912
|
+
// state once the engine is up, which is the same path a `restart()` takes.
|
|
913
|
+
if (!Module) return;
|
|
914
|
+
|
|
915
|
+
if (typeof Module.web_input_set_source !== 'function') {
|
|
502
916
|
console.error(
|
|
503
|
-
'[rsdkv4]
|
|
917
|
+
'[rsdkv4] web_input_set_source is missing from this build of rsdkv4.wasm — host-driven input needs an engine built from this SDK\'s build.sh. Rebuild it (`make build-wasm`); the engine keeps polling the keyboard until then.',
|
|
504
918
|
);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (buttons === null) {
|
|
923
|
+
Module.web_input_set_source(0);
|
|
924
|
+
delete Module[INPUT_PULL_HOOK];
|
|
925
|
+
this.#pull = null;
|
|
926
|
+
this.#resetPullErrors();
|
|
927
|
+
return;
|
|
505
928
|
}
|
|
929
|
+
|
|
930
|
+
this.#pull ??= () => this.#readButtons();
|
|
931
|
+
Module[INPUT_PULL_HOOK] = this.#pull;
|
|
932
|
+
Module.web_input_set_source(1);
|
|
506
933
|
}
|
|
507
934
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
935
|
+
/**
|
|
936
|
+
* The per-tick read. Called from the engine, at the top of its input pass, through
|
|
937
|
+
* `web_input_pull()` — never from a timer and never from anywhere else.
|
|
938
|
+
*
|
|
939
|
+
* **Keep it on the engine's callback.** A read inside a reactive effect would make
|
|
940
|
+
* all fourteen buttons dependencies of that effect (jq79 attributes every read
|
|
941
|
+
* through a store's `get` trap to whichever effect is running), and every keypress
|
|
942
|
+
* would wake it. The call stack here is always rAF → wasm → EM_JS → this, where no
|
|
943
|
+
* effect can be running, and that is an invariant to preserve rather than a
|
|
944
|
+
* coincidence to rely on.
|
|
945
|
+
*/
|
|
946
|
+
#readButtons(): number {
|
|
947
|
+
const buttons = this.#buttons;
|
|
948
|
+
if (!buttons) return 0;
|
|
514
949
|
|
|
515
|
-
|
|
516
|
-
|
|
950
|
+
try {
|
|
951
|
+
let mask = 0;
|
|
952
|
+
// Indexed, not `Object.entries`: the array's order is the engine's bit order,
|
|
953
|
+
// and a host object's key order is its own business.
|
|
954
|
+
for (let bit = 0; bit < RSDKV4_BUTTONS.length; bit++) {
|
|
955
|
+
if (buttons[RSDKV4_BUTTONS[bit]]) mask |= 1 << bit;
|
|
956
|
+
}
|
|
957
|
+
this.#resetPullErrors();
|
|
958
|
+
return mask;
|
|
959
|
+
} catch (error) {
|
|
960
|
+
this.#reportPullError(error);
|
|
961
|
+
// Everything released, never the last good mask: a repeated mask with a
|
|
962
|
+
// direction held leaves the character running into a pit on its own.
|
|
963
|
+
return 0;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
517
966
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
967
|
+
/**
|
|
968
|
+
* Announce a failing pull at most once a second, with a count.
|
|
969
|
+
*
|
|
970
|
+
* This runs sixty times a second, so an unthrottled `emit` would bury the page. It
|
|
971
|
+
* does **not** release the claim: a transient fault recovers on its own, whereas
|
|
972
|
+
* releasing would change input ownership behind the host's back.
|
|
973
|
+
*
|
|
974
|
+
* With a plain object nothing here can fire. It exists because the API accepts any
|
|
975
|
+
* object and a Proxy runs host code on every read — a store nulled by a component
|
|
976
|
+
* that unmounted without releasing, a revoked `Proxy.revocable`, a `get` trap whose
|
|
977
|
+
* effect scope has been torn down. Claim-time validation covers none of those: it
|
|
978
|
+
* validated once, over a reference that was live at the time.
|
|
979
|
+
*/
|
|
980
|
+
#reportPullError(error: unknown): void {
|
|
981
|
+
const key = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
982
|
+
// performance.now() rather than a timer: nothing to schedule, nothing to clear,
|
|
983
|
+
// and the comparison is already in the hand.
|
|
984
|
+
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
985
|
+
this.#pullErrors++;
|
|
986
|
+
|
|
987
|
+
if (key !== this.#pullErrorKey || now - this.#pullErrorAt >= INPUT_ERROR_INTERVAL_MS) {
|
|
988
|
+
const windowMs = this.#pullErrorKey === null ? 0 : now - this.#pullErrorAt;
|
|
989
|
+
this.emit(
|
|
990
|
+
'error',
|
|
991
|
+
Object.assign(
|
|
992
|
+
new Error(
|
|
993
|
+
`rsdkv4: reading the host's buttons failed (${this.#pullErrors}× in ${Math.round(windowMs)}ms) — input is dead until it recovers, and the claim is still held. Release it with options({ buttons: null }) if the host is gone. Cause: ${key}`,
|
|
994
|
+
),
|
|
995
|
+
{ cause: error },
|
|
996
|
+
),
|
|
524
997
|
);
|
|
525
|
-
|
|
526
|
-
|
|
998
|
+
this.#pullErrorKey = key;
|
|
999
|
+
this.#pullErrorAt = now;
|
|
1000
|
+
this.#pullErrors = 0;
|
|
527
1001
|
}
|
|
528
1002
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
1003
|
+
|
|
1004
|
+
/** Forget the streak, so a relapse announces itself immediately rather than in a second. */
|
|
1005
|
+
#resetPullErrors(): void {
|
|
1006
|
+
if (this.#pullErrorKey === null) return;
|
|
1007
|
+
this.#pullErrorKey = null;
|
|
1008
|
+
this.#pullErrorAt = 0;
|
|
1009
|
+
this.#pullErrors = 0;
|
|
535
1010
|
}
|
|
536
1011
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
1012
|
+
/** RSDKv4's Start Menu as data. Available once the engine is up. */
|
|
1013
|
+
get game(): RsdkGameBridge {
|
|
1014
|
+
const Module = this.#requireModule();
|
|
1015
|
+
const parse = <T>(fn: unknown, fallback: T, what: string): T => parseJson(fn, fallback, what);
|
|
1016
|
+
return {
|
|
1017
|
+
type: () => (typeof Module.web_game_type === 'function' ? Module.web_game_type() : 0),
|
|
1018
|
+
players: () => parse(Module.web_get_players, [] as string[], 'web_get_players'),
|
|
1019
|
+
saveSlots: () =>
|
|
1020
|
+
parse(Module.web_get_save_slots, [] as RsdkSaveSlot[], 'web_get_save_slots'),
|
|
1021
|
+
options: () =>
|
|
1022
|
+
parse(Module.web_get_game_options, [] as RsdkGameOption[], 'web_get_game_options'),
|
|
1023
|
+
setOption: (key, value) => {
|
|
1024
|
+
Module.web_set_game_option?.(String(key), value | 0);
|
|
1025
|
+
},
|
|
1026
|
+
deleteSave: (slot) => {
|
|
1027
|
+
Module.web_delete_save?.(slot | 0);
|
|
1028
|
+
if (this.#mirrorSaves) void this.#mirrorSaveData();
|
|
1029
|
+
},
|
|
1030
|
+
start: (slot, player = 0) => {
|
|
1031
|
+
Module.web_start_game?.(slot == null ? -1 : slot | 0, player | 0);
|
|
1032
|
+
// Whatever drove this is done with the engine — hand it back.
|
|
1033
|
+
this.#pauseOwner = null;
|
|
1034
|
+
this.#applyPaused(false);
|
|
1035
|
+
},
|
|
1036
|
+
};
|
|
541
1037
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
Module
|
|
1038
|
+
|
|
1039
|
+
/** The engine's stage list and loader. Available once the engine is up. */
|
|
1040
|
+
get devMenu(): RsdkDevMenuBridge {
|
|
1041
|
+
const Module = this.#requireModule();
|
|
1042
|
+
return {
|
|
1043
|
+
getStageList: () =>
|
|
1044
|
+
parseJson(Module.web_devmenu_get_stage_list, [], 'web_devmenu_get_stage_list'),
|
|
1045
|
+
loadStage: (categoryIdx, stageIdx) => {
|
|
1046
|
+
Module.web_devmenu_load_stage?.(categoryIdx | 0, stageIdx | 0);
|
|
1047
|
+
},
|
|
1048
|
+
setPaused: (paused) => {
|
|
1049
|
+
if (paused) this.pause('host');
|
|
1050
|
+
else this.resume(this.#pauseOwner ?? 'host');
|
|
1051
|
+
},
|
|
1052
|
+
canOpen: typeof Module.web_devmenu_open === 'function',
|
|
1053
|
+
open: (screen = 'main', category = 0) => {
|
|
1054
|
+
if (typeof Module.web_devmenu_open !== 'function') {
|
|
1055
|
+
console.warn(
|
|
1056
|
+
'[rsdkv4] this rsdkv4.wasm has no web_devmenu_open — rebuild it (`make build-wasm`), or read devMenu.canOpen before offering the button',
|
|
1057
|
+
);
|
|
1058
|
+
return false;
|
|
1059
|
+
}
|
|
1060
|
+
const index = DEV_MENU_SCREENS.indexOf(screen);
|
|
1061
|
+
if (index < 0) {
|
|
1062
|
+
console.warn(
|
|
1063
|
+
`[rsdkv4] "${screen}" is not a dev menu screen — one of ${DEV_MENU_SCREENS.join(', ')}`,
|
|
1064
|
+
);
|
|
1065
|
+
return false;
|
|
1066
|
+
}
|
|
1067
|
+
Module.web_devmenu_open(index, category | 0);
|
|
1068
|
+
return true;
|
|
1069
|
+
},
|
|
1070
|
+
};
|
|
546
1071
|
}
|
|
547
|
-
// else: keep the persisted settings.ini
|
|
548
1072
|
|
|
549
|
-
//
|
|
550
|
-
// otherwise pull any previous SData.bin in before the engine reads it.
|
|
551
|
-
const mirrorSaves = config.persist !== null;
|
|
552
|
-
if (mirrorSaves) await restoreSaveData(Module, storageNamespace, workDir);
|
|
1073
|
+
// ---- Bringing the engine up ----
|
|
553
1074
|
|
|
554
|
-
|
|
1075
|
+
#requireModule(): EmscriptenModule {
|
|
1076
|
+
if (!this.#module) {
|
|
1077
|
+
throw new Error('rsdkv4: the engine is not running — await start() before reaching for it');
|
|
1078
|
+
}
|
|
1079
|
+
return this.#module;
|
|
1080
|
+
}
|
|
555
1081
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
1082
|
+
#ensureBooted(): Promise<EmscriptenModule> {
|
|
1083
|
+
if (this.#destroyed) {
|
|
1084
|
+
throw new Error(
|
|
1085
|
+
'rsdkv4: this engine has been destroyed and cannot be restarted in process — build a new Rsdkv4SDK',
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
// A boot that failed must not poison the next attempt. The common failure is a
|
|
1089
|
+
// host that started before handing over `assets.data` — it fixes that and calls
|
|
1090
|
+
// `start()` again, and caching the rejection would tell it "no Data.rsdk" forever.
|
|
1091
|
+
this.#booting ??= this.#boot().catch((error: unknown) => {
|
|
1092
|
+
this.#booting = null;
|
|
1093
|
+
this.#module = null;
|
|
1094
|
+
throw error;
|
|
1095
|
+
});
|
|
1096
|
+
return this.#booting;
|
|
1097
|
+
}
|
|
571
1098
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
if (
|
|
575
|
-
|
|
1099
|
+
async #boot(): Promise<EmscriptenModule> {
|
|
1100
|
+
const canvas = this.#canvas;
|
|
1101
|
+
if (!canvas) {
|
|
1102
|
+
throw new Error('rsdkv4: mount(target) before start() — the engine draws into a canvas');
|
|
1103
|
+
}
|
|
576
1104
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
1105
|
+
const config = valuesOf(await this.config());
|
|
1106
|
+
const assets = valuesOf(await this.assets());
|
|
1107
|
+
const storage = valuesOf(await this.storage());
|
|
1108
|
+
|
|
1109
|
+
const Module = await this.#instantiate(canvas, config);
|
|
1110
|
+
this.#module = Module;
|
|
1111
|
+
|
|
1112
|
+
// Mount the working dir first, so we can see what is already persisted (OPFS)
|
|
1113
|
+
// before deciding whether to pull assets.
|
|
1114
|
+
this.#persistent = mountWorkingDir(Module, storage.persist).persistent;
|
|
1115
|
+
this.#namespace = normalizeStorageNamespace(storage.namespace);
|
|
1116
|
+
this.#workDir = `${WORK_ROOT}/${this.#namespace}`;
|
|
1117
|
+
this.#mirrorSaves = storage.persist !== null;
|
|
1118
|
+
ensureDir(Module, this.#workDir);
|
|
1119
|
+
|
|
1120
|
+
await this.#writeAssets(Module, assets, config);
|
|
1121
|
+
|
|
1122
|
+
// `persist: null` means the host wants nothing kept, so honour it; otherwise pull
|
|
1123
|
+
// any previous SData.bin in before the engine reads it.
|
|
1124
|
+
if (this.#mirrorSaves) await this.#restoreSaveData(Module);
|
|
1125
|
+
|
|
1126
|
+
Module.FS.chdir(this.#workDir);
|
|
1127
|
+
|
|
1128
|
+
// Run main() from the working dir. simulate_infinite_loop schedules the rAF loop
|
|
1129
|
+
// and returns via a benign unwind Emscripten swallows.
|
|
1130
|
+
Module.callMain(['UsingCWD']);
|
|
1131
|
+
|
|
1132
|
+
// callMain only *schedules* the loop: Engine::Init runs on the first frame, and
|
|
1133
|
+
// until it has, there is no game config, stage list or saveRAM to read. Waiting
|
|
1134
|
+
// here means the bridges answer the moment start() resolves.
|
|
1135
|
+
await waitForEngine(Module);
|
|
1136
|
+
|
|
1137
|
+
this.#focusCanvas();
|
|
1138
|
+
|
|
1139
|
+
// The engine writes SData.bin at its own pace (new game, checkpoint, options
|
|
1140
|
+
// change), so copy it out on a slow timer as well as on the way down.
|
|
1141
|
+
//
|
|
1142
|
+
// The timer alone loses saves, and by a wide margin: `destroy()` is the only
|
|
1143
|
+
// other flush, and a browser tab that is closed or reloaded never calls it. A
|
|
1144
|
+
// player who finished an act and hit reload inside the next ten seconds had the
|
|
1145
|
+
// save written by the engine, held in the wasm heap, and dropped on the floor —
|
|
1146
|
+
// which reads as "my progress did not stick" and looks exactly like a save
|
|
1147
|
+
// system that does not work.
|
|
1148
|
+
//
|
|
1149
|
+
// `visibilitychange` → hidden is the flush that actually fires: it covers the
|
|
1150
|
+
// tab being switched away from, the window being minimised, the phone going to
|
|
1151
|
+
// the home screen, and — unlike `beforeunload`, which browsers increasingly
|
|
1152
|
+
// ignore — navigating away and closing. `pagehide` backs it up for the bfcache
|
|
1153
|
+
// path. Cheap to over-fire: the mirror compares bytes and returns without
|
|
1154
|
+
// touching OPFS when nothing moved.
|
|
1155
|
+
if (this.#mirrorSaves) {
|
|
1156
|
+
this.#saveTimer = setInterval(() => void this.#mirrorSaveData(), 10_000);
|
|
1157
|
+
document.addEventListener('visibilitychange', this.#onVisibilityChange);
|
|
1158
|
+
window.addEventListener('pagehide', this.#onPageHide);
|
|
1159
|
+
}
|
|
584
1160
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
pauseOwner = null;
|
|
588
|
-
applyPaused(false);
|
|
589
|
-
};
|
|
1161
|
+
return Module;
|
|
1162
|
+
}
|
|
590
1163
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
1164
|
+
async #instantiate(canvas: HTMLCanvasElement, config: Rsdkv4Config): Promise<EmscriptenModule> {
|
|
1165
|
+
const jsUrl = config.jsUrl ?? new URL('./rsdkv4.js', import.meta.url).href;
|
|
1166
|
+
const wasmUrl = config.wasmUrl ?? new URL('./rsdkv4.wasm', import.meta.url).href;
|
|
1167
|
+
|
|
1168
|
+
const load = async (bust?: string): Promise<EmscriptenModule> => {
|
|
1169
|
+
const withBust = (url: string): string => {
|
|
1170
|
+
if (!bust) return url;
|
|
1171
|
+
const parsed = new URL(url, location.href);
|
|
1172
|
+
parsed.searchParams.set('_', bust);
|
|
1173
|
+
return parsed.href;
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
const mod = await import(/* @vite-ignore */ withBust(jsUrl));
|
|
1177
|
+
return mod.default({
|
|
1178
|
+
canvas,
|
|
1179
|
+
noInitialRun: true, // built with -sINVOKE_RUN=0; we mount data before main()
|
|
1180
|
+
locateFile: (path: string) => (path.endsWith('.wasm') ? withBust(wasmUrl) : path),
|
|
1181
|
+
print: (...a: unknown[]) => console.log('[rsdkv4]', ...a),
|
|
1182
|
+
printErr: (...a: unknown[]) => console.error('[rsdkv4]', ...a),
|
|
1183
|
+
onAbort: (reason: unknown) => {
|
|
1184
|
+
this.emit('error', new Error(`rsdkv4 aborted: ${reason}`));
|
|
1185
|
+
},
|
|
1186
|
+
});
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
let Module = await load();
|
|
1190
|
+
|
|
1191
|
+
// Self-heal a stale engine.
|
|
1192
|
+
//
|
|
1193
|
+
// rsdkv4.js/.wasm are big and served by most static hosts with nothing but
|
|
1194
|
+
// Last-Modified, so a browser will happily keep a build for hours. The symptom is
|
|
1195
|
+
// nasty: fresh SDK code against an old engine, where the bridges this SDK calls
|
|
1196
|
+
// simply don't exist and every getter quietly answers "empty" — no save slots, no
|
|
1197
|
+
// characters, an empty pause menu. Rather than leave that to the reader's cache
|
|
1198
|
+
// hygiene, notice it and fetch once past the cache.
|
|
1199
|
+
if (!engineBridgeComplete(Module)) {
|
|
1200
|
+
console.warn(
|
|
1201
|
+
'[rsdkv4] the engine that loaded is missing bridges this SDK needs — refetching past the HTTP cache',
|
|
1202
|
+
);
|
|
1203
|
+
Module = await load(String(Date.now()));
|
|
1204
|
+
if (!engineBridgeComplete(Module)) {
|
|
1205
|
+
console.error(
|
|
1206
|
+
'[rsdkv4] still missing engine bridges after a cache-busting reload — rsdkv4.wasm is genuinely older than this SDK; rebuild it (`make build-wasm`)',
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
return Module;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** Data.rsdk and settings.ini into the working dir. See {@link Rsdkv4Assets}. */
|
|
1215
|
+
async #writeAssets(
|
|
1216
|
+
Module: EmscriptenModule,
|
|
1217
|
+
assets: Rsdkv4Assets,
|
|
1218
|
+
config: Rsdkv4Config,
|
|
1219
|
+
): Promise<void> {
|
|
1220
|
+
const dataPath = `${this.#workDir}/Data.rsdk`;
|
|
1221
|
+
const settingsPath = `${this.#workDir}/settings.ini`;
|
|
1222
|
+
|
|
1223
|
+
// The pack — the expensive one, so a persisted copy short-circuits the thunk.
|
|
1224
|
+
if (fileExists(Module, dataPath)) {
|
|
1225
|
+
const explicit = typeof assets.data === 'function' ? null : await resolveAsset(assets.data);
|
|
1226
|
+
if (explicit) Module.FS.writeFile(dataPath, explicit);
|
|
1227
|
+
else {
|
|
1228
|
+
console.log(
|
|
1229
|
+
`[rsdkv4] Data.rsdk already in persistent storage (${this.#namespace}) — skipping fetch`,
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
} else {
|
|
1233
|
+
const bytes = await resolveAsset(assets.data);
|
|
1234
|
+
if (!bytes) {
|
|
1235
|
+
throw new Error(
|
|
1236
|
+
'rsdkv4: no Data.rsdk — hand one over as assets.data (bytes or a thunk), or point storage.namespace at a persisted copy',
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
Module.FS.writeFile(dataPath, bytes);
|
|
1240
|
+
}
|
|
596
1241
|
|
|
597
|
-
|
|
1242
|
+
// The settings — cheap, and regenerated every boot so `config` is never shadowed
|
|
1243
|
+
// by a file an earlier session left behind. The engine never writes it back (its
|
|
1244
|
+
// writeSettings() is #ifndef __EMSCRIPTEN__), so there is nothing to lose.
|
|
1245
|
+
const settings = await resolveAsset(assets.settings);
|
|
1246
|
+
Module.FS.writeFile(settingsPath, settings ?? new TextEncoder().encode(buildSettingsIni(config)));
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// ---- The save mirror ----
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Pull a previously mirrored SData.bin into the wasm before the engine reads it.
|
|
1253
|
+
*
|
|
1254
|
+
* The working dir is in-memory whenever the OPFS *mount* is unavailable (which is
|
|
1255
|
+
* every build without Asyncify/JSPI — see {@link mountWorkingDir}), so SData.bin
|
|
1256
|
+
* would die with the page and every slot would read "NEW GAME" forever. The host-side
|
|
1257
|
+
* OPFS API has no such limitation, so the SDK copies the file in here and back out as
|
|
1258
|
+
* it changes.
|
|
1259
|
+
*/
|
|
1260
|
+
async #restoreSaveData(Module: EmscriptenModule): Promise<void> {
|
|
1261
|
+
const dir = await opfsNamespaceDir(this.#namespace, false);
|
|
1262
|
+
if (!dir) return;
|
|
598
1263
|
try {
|
|
599
|
-
|
|
1264
|
+
const file = await (await dir.getFileHandle(SAVE_FILE)).getFile();
|
|
1265
|
+
if (file.size > 0) {
|
|
1266
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
1267
|
+
Module.FS.writeFile(`${this.#workDir}/${SAVE_FILE}`, bytes);
|
|
1268
|
+
this.#lastSave = bytes;
|
|
1269
|
+
}
|
|
600
1270
|
} catch {
|
|
601
|
-
/*
|
|
1271
|
+
/* no save yet */
|
|
602
1272
|
}
|
|
603
|
-
}
|
|
1273
|
+
}
|
|
604
1274
|
|
|
605
1275
|
/**
|
|
606
|
-
*
|
|
1276
|
+
* Copy SData.bin out of the wasm into `<namespace>/`, and announce it if it moved.
|
|
607
1277
|
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
1278
|
+
* The division of labour the session settled on: the SDK owns getting the bytes out
|
|
1279
|
+
* of the wasm and into the namespace both sides already agree on; a host owns
|
|
1280
|
+
* reading, parsing and painting them. `saves` is what tells it to look again — which
|
|
1281
|
+
* is why it fires on a change and not on every check.
|
|
612
1282
|
*/
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
1283
|
+
async #mirrorSaveData(): Promise<void> {
|
|
1284
|
+
const Module = this.#module;
|
|
1285
|
+
if (!Module) return;
|
|
1286
|
+
|
|
1287
|
+
// Explicitly over an ArrayBuffer, not the default ArrayBufferLike: OPFS's
|
|
1288
|
+
// `write()` will not take a view that might be backed by a SharedArrayBuffer,
|
|
1289
|
+
// and on a cross-origin isolated page that is not a hypothetical distinction.
|
|
1290
|
+
let bytes: Uint8Array<ArrayBuffer>;
|
|
620
1291
|
try {
|
|
621
|
-
|
|
622
|
-
} catch
|
|
623
|
-
|
|
624
|
-
return fallback;
|
|
1292
|
+
bytes = Module.FS.readFile(`${this.#workDir}/${SAVE_FILE}`);
|
|
1293
|
+
} catch {
|
|
1294
|
+
return; // the engine hasn't written one
|
|
625
1295
|
}
|
|
626
|
-
|
|
1296
|
+
if (sameBytes(bytes, this.#lastSave)) return;
|
|
627
1297
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
players: () => parseJson(Module.web_get_players, [] as string[], 'web_get_players'),
|
|
631
|
-
saveSlots: () => parseJson(Module.web_get_save_slots, [] as RsdkSaveSlot[], 'web_get_save_slots'),
|
|
632
|
-
options: () => parseJson(Module.web_get_game_options, [] as RsdkGameOption[], 'web_get_game_options'),
|
|
633
|
-
setOption(key, value) {
|
|
634
|
-
Module.web_set_game_option?.(String(key), value | 0);
|
|
635
|
-
},
|
|
636
|
-
deleteSave(slot) {
|
|
637
|
-
Module.web_delete_save?.(slot | 0);
|
|
638
|
-
},
|
|
639
|
-
start(slot, player = 0) {
|
|
640
|
-
Module.web_start_game?.(slot == null ? -1 : slot | 0, player | 0);
|
|
641
|
-
// The overlay that drove this is done with the engine — hand it back.
|
|
642
|
-
pauseOwner = null;
|
|
643
|
-
applyPaused(false);
|
|
644
|
-
},
|
|
645
|
-
};
|
|
646
|
-
|
|
647
|
-
const devMenu: RsdkDevMenuBridge = {
|
|
648
|
-
getStageList() {
|
|
649
|
-
if (typeof Module.web_devmenu_get_stage_list !== 'function') return [];
|
|
1298
|
+
const dir = await opfsNamespaceDir(this.#namespace, true);
|
|
1299
|
+
if (dir) {
|
|
650
1300
|
try {
|
|
651
|
-
|
|
1301
|
+
const handle = await dir.getFileHandle(SAVE_FILE, { create: true });
|
|
1302
|
+
const writable = await handle.createWritable();
|
|
1303
|
+
await writable.write(bytes);
|
|
1304
|
+
await writable.close();
|
|
652
1305
|
} catch (e) {
|
|
653
|
-
console.
|
|
654
|
-
return
|
|
655
|
-
}
|
|
656
|
-
},
|
|
657
|
-
loadStage(categoryIdx, stageIdx) {
|
|
658
|
-
if (typeof Module.web_devmenu_load_stage === 'function') {
|
|
659
|
-
Module.web_devmenu_load_stage(categoryIdx | 0, stageIdx | 0);
|
|
1306
|
+
console.warn('[rsdkv4] could not persist save data', e);
|
|
1307
|
+
return;
|
|
660
1308
|
}
|
|
661
|
-
}
|
|
662
|
-
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
this.#lastSave = bytes;
|
|
1312
|
+
this.emit('saves', this.game.saveSlots());
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// ---- The render target ----
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* Flush the save when the page is being put away. See where these are registered
|
|
1319
|
+
* for why the ten-second timer is not enough on its own.
|
|
1320
|
+
*/
|
|
1321
|
+
#onVisibilityChange = (): void => {
|
|
1322
|
+
if (document.visibilityState === 'hidden' && this.#mirrorSaves) void this.#mirrorSaveData();
|
|
1323
|
+
};
|
|
1324
|
+
#onPageHide = (): void => {
|
|
1325
|
+
if (this.#mirrorSaves) void this.#mirrorSaveData();
|
|
663
1326
|
};
|
|
664
1327
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
focusCanvas();
|
|
675
|
-
|
|
676
|
-
// The engine writes SData.bin at its own pace (new game, checkpoint, options
|
|
677
|
-
// change), so copy it out on a slow timer as well as on the way down.
|
|
678
|
-
const saveTimer = mirrorSaves
|
|
679
|
-
? setInterval(() => void persistSaveData(Module, storageNamespace, workDir), 10_000)
|
|
680
|
-
: null;
|
|
681
|
-
|
|
682
|
-
emit({ type: 'ready' });
|
|
683
|
-
|
|
684
|
-
return {
|
|
685
|
-
start() {},
|
|
686
|
-
/** `owner` lets a pause overlay avoid resuming a game something else paused. */
|
|
687
|
-
pause(owner: string = 'host') { pauseWith(owner); },
|
|
688
|
-
resume(owner: string = 'host') { resumeFrom(owner); },
|
|
689
|
-
reset() {
|
|
690
|
-
throw new Error('rsdkv4: reset() is not supported — destroy() and load() again');
|
|
691
|
-
},
|
|
692
|
-
setInput(preset: InputPreset | KeyMap) {
|
|
693
|
-
if (typeof window !== 'undefined') {
|
|
694
|
-
(window as any).__gamepadKeyMap = preset ?? manifest.input;
|
|
695
|
-
}
|
|
696
|
-
},
|
|
697
|
-
destroy() {
|
|
698
|
-
if (saveTimer !== null) clearInterval(saveTimer);
|
|
699
|
-
// Last chance to keep whatever the player earned this session.
|
|
700
|
-
if (mirrorSaves) void persistSaveData(Module, storageNamespace, workDir);
|
|
701
|
-
|
|
702
|
-
try { Module.pauseMainLoop?.(); } catch { /* noop */ }
|
|
703
|
-
try { setPaused(true); } catch { /* noop */ }
|
|
704
|
-
// Freezing the loop does not silence the audio callback — without this the
|
|
705
|
-
// music kept playing after the host went back to its launcher.
|
|
706
|
-
stopAudio();
|
|
707
|
-
|
|
708
|
-
canvas.removeEventListener('contextmenu', swallowContextMenu);
|
|
709
|
-
canvas.removeEventListener('pointerdown', onPointerDown);
|
|
710
|
-
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
|
711
|
-
window.removeEventListener('resize', onResize);
|
|
712
|
-
boxObserver?.disconnect();
|
|
713
|
-
if (ownsCanvas) canvas.remove();
|
|
714
|
-
},
|
|
715
|
-
devMenu,
|
|
716
|
-
game,
|
|
717
|
-
persistent,
|
|
718
|
-
storageNamespace,
|
|
719
|
-
purgeStorage() {
|
|
720
|
-
return {
|
|
721
|
-
data: deleteFileIfExists(Module, dataPath),
|
|
722
|
-
settings: deleteFileIfExists(Module, settingsPath),
|
|
723
|
-
};
|
|
724
|
-
},
|
|
1328
|
+
#swallowContextMenu = (event: Event): void => event.preventDefault();
|
|
1329
|
+
#onPointerDown = (): void => this.#focusCanvas();
|
|
1330
|
+
#onKeyDown = (event: KeyboardEvent): void => {
|
|
1331
|
+
// Only the browser's own default action is suppressed — the arrows and Space
|
|
1332
|
+
// scroll the page, Tab walks the focus out of the canvas. The event is left to
|
|
1333
|
+
// propagate: SDL's own handler sits on `window` in the bubble phase, so calling
|
|
1334
|
+
// stopPropagation() here would be the engine's whole keyboard.
|
|
1335
|
+
if (ENGINE_KEYS.has(event.code) || ENGINE_KEYS.has(event.key)) event.preventDefault();
|
|
725
1336
|
};
|
|
1337
|
+
#onResize = (): void => this.#fitPicture();
|
|
1338
|
+
#onFullscreenChange = (): void => {
|
|
1339
|
+
this.#fitPicture();
|
|
1340
|
+
requestAnimationFrame(() => this.#fitPicture());
|
|
1341
|
+
};
|
|
1342
|
+
|
|
1343
|
+
#focusCanvas(): void {
|
|
1344
|
+
if (!this.#canvas) return;
|
|
1345
|
+
if (!document.hasFocus()) window.focus();
|
|
1346
|
+
this.#canvas.focus({ preventScroll: true });
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
#fitPicture(): void {
|
|
1350
|
+
if (!this.#ownsCanvas || !this.#canvas || !this.#box) return;
|
|
1351
|
+
const box = this.#box.getBoundingClientRect();
|
|
1352
|
+
if (box.width < 1 || box.height < 1) return;
|
|
1353
|
+
|
|
1354
|
+
const { baseWidth, baseHeight } = RSDKV4_VIDEO;
|
|
1355
|
+
const scale = Math.min(box.width / baseWidth, box.height / baseHeight);
|
|
1356
|
+
this.#canvas.style.width = `${Math.round(baseWidth * scale)}px`;
|
|
1357
|
+
this.#canvas.style.height = `${Math.round(baseHeight * scale)}px`;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Move the picture we already have. SDL keeps its canvas; only the parent changes.
|
|
1362
|
+
*
|
|
1363
|
+
* Pointing a second `mount()` at a *different* `<canvas>` is the one case this
|
|
1364
|
+
* refuses: SDL2 resolved `#canvas` once, at instantiation, so swapping the element
|
|
1365
|
+
* would leave the engine drawing into an orphan while the host watched a blank one.
|
|
1366
|
+
*/
|
|
1367
|
+
#reparent(target: HTMLElement): void {
|
|
1368
|
+
const canvas = this.#canvas;
|
|
1369
|
+
if (!canvas || target === canvas || canvas.parentElement === target) return;
|
|
1370
|
+
|
|
1371
|
+
if (target instanceof HTMLCanvasElement) {
|
|
1372
|
+
console.warn(
|
|
1373
|
+
'[rsdkv4] the canvas SDL draws into cannot be swapped after mounting — ignoring this mount()',
|
|
1374
|
+
);
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
target.appendChild(canvas);
|
|
1379
|
+
if (this.#box) this.#boxObserver?.unobserve(this.#box);
|
|
1380
|
+
this.#box = this.#ownsCanvas ? target : canvas;
|
|
1381
|
+
if (this.#box) this.#boxObserver?.observe(this.#box);
|
|
1382
|
+
this.#fitPicture();
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
#teardownCanvas(): void {
|
|
1386
|
+
const canvas = this.#canvas;
|
|
1387
|
+
if (canvas) {
|
|
1388
|
+
canvas.removeEventListener('contextmenu', this.#swallowContextMenu);
|
|
1389
|
+
canvas.removeEventListener('pointerdown', this.#onPointerDown);
|
|
1390
|
+
canvas.removeEventListener('keydown', this.#onKeyDown);
|
|
1391
|
+
if (this.#ownsCanvas) canvas.remove();
|
|
1392
|
+
}
|
|
1393
|
+
document.removeEventListener('fullscreenchange', this.#onFullscreenChange);
|
|
1394
|
+
document.removeEventListener('visibilitychange', this.#onVisibilityChange);
|
|
1395
|
+
window.removeEventListener('pagehide', this.#onPageHide);
|
|
1396
|
+
window.removeEventListener('resize', this.#onResize);
|
|
1397
|
+
this.#boxObserver?.disconnect();
|
|
1398
|
+
this.#boxObserver = null;
|
|
1399
|
+
this.#canvas = null;
|
|
1400
|
+
this.#box = null;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// ---- Misc ----
|
|
1404
|
+
|
|
1405
|
+
#applyPaused(paused: boolean): void {
|
|
1406
|
+
const Module = this.#module;
|
|
1407
|
+
if (!Module) return;
|
|
1408
|
+
if (typeof Module.web_devmenu_set_paused === 'function') Module.web_devmenu_set_paused(paused);
|
|
1409
|
+
if (typeof Module.web_audio_set_paused === 'function') Module.web_audio_set_paused(paused);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/** A character name, as the pack spells it, to the index the engine wants. */
|
|
1413
|
+
#playerIndex(player: string | undefined): number {
|
|
1414
|
+
if (player === undefined) return 0;
|
|
1415
|
+
const players = this.game.players();
|
|
1416
|
+
const index = players.findIndex((name) => name.toLowerCase() === player.toLowerCase());
|
|
1417
|
+
if (index < 0) {
|
|
1418
|
+
throw new Error(
|
|
1419
|
+
`rsdkv4: "${player}" is not a playable character in this pack — try one of ${players.join(', ') || '(none reported)'}`,
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
return index;
|
|
1423
|
+
}
|
|
726
1424
|
}
|
|
727
1425
|
|
|
728
|
-
|
|
1426
|
+
// ---------------------------------------------------------------- the factory
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* The factory a host imports: identity on the constructor, one live engine per
|
|
1430
|
+
* instance, and a `start()` that brings the wasm up and opens a session.
|
|
1431
|
+
*
|
|
1432
|
+
* Everything on it is the contract's — see the module doc for the shape of a call.
|
|
1433
|
+
*/
|
|
1434
|
+
export class Rsdkv4SDK extends EngineSDKBase<Rsdkv4Payloads> {
|
|
1435
|
+
static readonly id = 'rsdkv4';
|
|
1436
|
+
/** Kept in step with package.json by `node scripts/sync-version.mjs`. */
|
|
1437
|
+
static readonly version = '0.2.1';
|
|
1438
|
+
static readonly name = 'Retro Software Development Kit v4';
|
|
1439
|
+
static readonly description =
|
|
1440
|
+
'RSDKv4 compiled to WebAssembly via Emscripten. One game-agnostic WASM binary runs both Sonic 1 and Sonic 2 — the difference is only which Data.rsdk the host supplies at runtime. Ships none of the game: the player provides their own Data.rsdk, and it never leaves their browser.';
|
|
1441
|
+
|
|
1442
|
+
protected override createPlay(
|
|
1443
|
+
init: EngineInit<Rsdkv4Payloads>,
|
|
1444
|
+
events: EventEmitter<Rsdkv4Events_>,
|
|
1445
|
+
): Rsdkv4Play {
|
|
1446
|
+
return new Rsdkv4Play(init, events);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
override start(options?: Partial<Rsdkv4Options>): Rsdkv4Session {
|
|
1450
|
+
return super.start(options) as Rsdkv4Session;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* The line that puts the contract in CI: if this file stops satisfying
|
|
1456
|
+
* `EngineSDKConstructor`, `make typecheck` says so.
|
|
1457
|
+
*/
|
|
1458
|
+
export const RSDKV4_ENGINE: EngineSDKConstructor<Rsdkv4Payloads> = Rsdkv4SDK;
|
|
1459
|
+
|
|
1460
|
+
export default Rsdkv4SDK;
|
|
1461
|
+
|
|
1462
|
+
// ---------------------------------------------------------------- module-private helpers
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* Call a `web_*` embind getter and parse its JSON.
|
|
1466
|
+
*
|
|
1467
|
+
* A missing function means the loaded rsdkv4.wasm predates that bridge — the usual
|
|
1468
|
+
* cause is a browser serving a cached build. Say so out loud: silently returning an
|
|
1469
|
+
* empty list makes a stale artifact look like an empty save file or a game with no
|
|
1470
|
+
* playable characters.
|
|
1471
|
+
*/
|
|
1472
|
+
function parseJson<T>(fn: unknown, fallback: T, what: string): T {
|
|
1473
|
+
if (typeof fn !== 'function') {
|
|
1474
|
+
console.error(
|
|
1475
|
+
`[rsdkv4] ${what} is missing from this build of rsdkv4.wasm — the page is probably running a cached copy. Hard-reload (or clear the site's storage) after rebuilding.`,
|
|
1476
|
+
);
|
|
1477
|
+
return fallback;
|
|
1478
|
+
}
|
|
1479
|
+
try {
|
|
1480
|
+
return JSON.parse((fn as () => string)());
|
|
1481
|
+
} catch (e) {
|
|
1482
|
+
console.error(`[rsdkv4] ${what} failed`, e);
|
|
1483
|
+
return fallback;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/**
|
|
1488
|
+
* A claim, checked once — exactly the fourteen buttons, with missing and unknown both
|
|
1489
|
+
* fatal.
|
|
1490
|
+
*
|
|
1491
|
+
* Strict because the two failure modes are silent and asymmetric. A **missing** key
|
|
1492
|
+
* reads `false` on every tick: a dead button, no error anywhere, and easy to blame on
|
|
1493
|
+
* the engine. An **unknown** key is a button the host writes to that nothing ever
|
|
1494
|
+
* reads. The first sketch of this API lost `right` to a duplicated `up` — a duplicate
|
|
1495
|
+
* key in an object literal is legal JavaScript and surfaces only as an absent one.
|
|
1496
|
+
*
|
|
1497
|
+
* `Object.keys`, not `in`: a reactive store's `has` trap can answer `true` for a key
|
|
1498
|
+
* that has been deleted (jq79 does exactly that, deliberately, so a template
|
|
1499
|
+
* expression reading a deleted name takes its else branch instead of dying), and
|
|
1500
|
+
* `ownKeys` has no such licence.
|
|
1501
|
+
*/
|
|
1502
|
+
function validateButtons(buttons: Rsdkv4Buttons): void {
|
|
1503
|
+
if (typeof buttons !== 'object' || buttons === null) {
|
|
1504
|
+
throw new Error(
|
|
1505
|
+
`rsdkv4: options({ buttons }) takes an object of the 14 buttons or null to release, not ${typeof buttons}`,
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
const keys = new Set(Object.keys(buttons));
|
|
1510
|
+
const missing = RSDKV4_BUTTONS.filter((button) => !keys.has(button));
|
|
1511
|
+
const unknown = [...keys].filter(
|
|
1512
|
+
(key) => !(RSDKV4_BUTTONS as readonly string[]).includes(key),
|
|
1513
|
+
);
|
|
1514
|
+
|
|
1515
|
+
if (missing.length || unknown.length) {
|
|
1516
|
+
const faults = [
|
|
1517
|
+
missing.length && `missing ${missing.join(', ')}`,
|
|
1518
|
+
unknown.length && `unknown ${unknown.join(', ')}`,
|
|
1519
|
+
].filter(Boolean);
|
|
1520
|
+
throw new Error(
|
|
1521
|
+
`rsdkv4: the buttons object must carry exactly the 14 buttons (${RSDKV4_BUTTONS.join(', ')}) — ${faults.join('; ')}. A missing button is dead, not neutral, so this is an error rather than a default.`,
|
|
1522
|
+
);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/** The engine's two-level stage list as flat, writable {@link Rsdkv4Stage} values. */
|
|
1527
|
+
function flattenStages(
|
|
1528
|
+
categories: Array<{ name: string; stages: Array<{ name: string }> }>,
|
|
1529
|
+
): Rsdkv4Stage[] {
|
|
1530
|
+
const out: Rsdkv4Stage[] = [];
|
|
1531
|
+
categories.forEach((category, categoryIdx) => {
|
|
1532
|
+
category.stages.forEach((stage, sceneIdx) => {
|
|
1533
|
+
out.push({ category: categoryIdx, scene: sceneIdx, name: `${category.name} · ${stage.name}` });
|
|
1534
|
+
});
|
|
1535
|
+
});
|
|
1536
|
+
return out;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/** A GAME OPTIONS row as a host reads it: a boolean, or the label of the chosen entry. */
|
|
1540
|
+
function fromEngineOption(row: RsdkGameOption): boolean | string | number {
|
|
1541
|
+
if (row.type === 'boolean') return row.value !== 0;
|
|
1542
|
+
return row.values?.[row.value] ?? row.value;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/** …and back to the number the engine stores. */
|
|
1546
|
+
function toEngineOption(row: RsdkGameOption, value: unknown): number {
|
|
1547
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
1548
|
+
if (typeof value === 'number') return value | 0;
|
|
1549
|
+
if (typeof value === 'string') {
|
|
1550
|
+
const index = (row.values ?? []).findIndex(
|
|
1551
|
+
(choice) => choice.toLowerCase() === value.toLowerCase(),
|
|
1552
|
+
);
|
|
1553
|
+
if (index >= 0) return index;
|
|
1554
|
+
}
|
|
1555
|
+
throw new Error(
|
|
1556
|
+
`rsdkv4: ${JSON.stringify(value)} is not a value "${row.key}" accepts — try one of ${
|
|
1557
|
+
row.type === 'boolean' ? 'true, false' : (row.values ?? []).join(', ')
|
|
1558
|
+
}`,
|
|
1559
|
+
);
|
|
1560
|
+
}
|