@wasm-gaming/rsdkv4-wasm 0.2.0 → 0.3.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 +159 -213
- package/dist/rsdkv4/app/app.css +160 -0
- package/dist/rsdkv4/app/app.js +521 -0
- package/dist/rsdkv4/app/assets/player-knuckles.svg +757 -0
- package/dist/rsdkv4/app/assets/player-sonic.svg +720 -0
- package/dist/rsdkv4/app/assets/player-sonictails.svg +1414 -0
- package/dist/rsdkv4/app/assets/player-tails.svg +979 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/special.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-1.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-2.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-3.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-4.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-5.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-6.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-7.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/special.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-1.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-10.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-2.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-3.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-4.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-5.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-6.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-7.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-8.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-9.png +0 -0
- package/dist/rsdkv4/engine-specs.js +382 -0
- package/dist/rsdkv4/rsdkv4.options.d.ts +13 -5
- package/dist/rsdkv4/rsdkv4.options.d.ts.map +1 -1
- package/dist/rsdkv4/rsdkv4.options.js +6 -1
- package/dist/rsdkv4/rsdkv4.options.js.map +1 -1
- package/dist/rsdkv4/rsdkv4.sdk.d.ts +82 -2
- package/dist/rsdkv4/rsdkv4.sdk.d.ts.map +1 -1
- package/dist/rsdkv4/rsdkv4.sdk.js +217 -26
- package/dist/rsdkv4/rsdkv4.sdk.js.map +1 -1
- package/package.json +7 -4
- package/src/app/app.css +160 -0
- package/src/app/app.js +521 -0
- package/src/rsdkv4.options.ts +14 -6
- package/src/rsdkv4.sdk.ts +283 -26
package/src/rsdkv4.options.ts
CHANGED
|
@@ -185,6 +185,11 @@ export interface Rsdkv4InputDefaults {
|
|
|
185
185
|
/**
|
|
186
186
|
* The engine's own default bindings. See {@link Rsdkv4InputDefaults}.
|
|
187
187
|
*
|
|
188
|
+
* Directions are the arrow keys. This build's `ProcessInput()` also reads `KeyW`,
|
|
189
|
+
* `KeyA`, `KeyS` and `KeyD` as a second set of them, and takes those three scancodes
|
|
190
|
+
* off `x`, `y` and `z` to do it — so the table below is what a *claiming* host should
|
|
191
|
+
* bind, and the engine under SDL polling answers to both.
|
|
192
|
+
*
|
|
188
193
|
* This replaces the old `RSDKV4_KEYMAP`, which nothing read and which was wrong in
|
|
189
194
|
* three ways for want of ever being checked against the engine: it listed 9 of the
|
|
190
195
|
* 14 buttons, and bound `select` to `ShiftLeft` where the engine's default is `Tab`.
|
|
@@ -344,12 +349,15 @@ export interface Rsdkv4Assets {
|
|
|
344
349
|
export interface Rsdkv4Storage {
|
|
345
350
|
/**
|
|
346
351
|
* Per-game folder for the pack, the settings and the save file, so two games do not
|
|
347
|
-
* overwrite each other. Examples: `"
|
|
352
|
+
* overwrite each other. Examples: `"sonic-1"`, `"sonic-2/profile-a"`.
|
|
348
353
|
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
354
|
+
* Relative to the engine's own folder: the SDK always prefixes `rsdkv4/`, so the
|
|
355
|
+
* files land in `rsdkv4/<namespace>/` — in WASMFS (`/data/rsdkv4/<namespace>`) and in
|
|
356
|
+
* OPFS alike — and no namespace can reach another engine's files. A host reads the
|
|
357
|
+
* resolved path from `play.storagePath`. Segments are cleaned (`.`/`..` dropped,
|
|
358
|
+
* odd characters → `_`); anything unusable normalises to `"default"`, including the
|
|
359
|
+
* object you get from interpolating a game record by mistake. A leading `rsdkv4/`
|
|
360
|
+
* (the old convention) is stripped with a warning.
|
|
353
361
|
*
|
|
354
362
|
* @defaultValue `"default"`
|
|
355
363
|
*/
|
|
@@ -491,7 +499,7 @@ export const RSDKV4_SPEC: EngineSpec<Rsdkv4Payloads> = {
|
|
|
491
499
|
namespace: {
|
|
492
500
|
default: 'default',
|
|
493
501
|
live: false,
|
|
494
|
-
description: "Folder for this game's pack, settings and save file.",
|
|
502
|
+
description: "Folder for this game's pack, settings and save file, inside the engine's own rsdkv4/ folder (OPFS rsdkv4/<namespace>/).",
|
|
495
503
|
},
|
|
496
504
|
persist: {
|
|
497
505
|
enum: ['opfs', null],
|
package/src/rsdkv4.sdk.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* const play = await new Rsdkv4SDK()
|
|
8
8
|
* .mount(document.querySelector('#stage'))
|
|
9
9
|
* .assets({ data: () => library.readBytes('Sonic2') })
|
|
10
|
-
* .storage({ namespace: '
|
|
10
|
+
* .storage({ namespace: 'sonic-2' }) // → OPFS rsdkv4/sonic-2/
|
|
11
11
|
* .config({ startMenu: 'host' })
|
|
12
12
|
* .on('saves', ({ detail }) => paintSlots(detail))
|
|
13
13
|
* .start({ slot: 0 });
|
|
@@ -85,6 +85,16 @@ export type Rsdkv4Events_ = EventsOf<Rsdkv4Payloads>;
|
|
|
85
85
|
const WORK_ROOT = '/data';
|
|
86
86
|
const DEFAULT_STORAGE_NAMESPACE = 'default';
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* This engine's folder in WASMFS / OPFS. Every rsdkv4 file lives under it, whatever
|
|
90
|
+
* namespace a host picks, so two engines can never share a working dir — the host
|
|
91
|
+
* chooses the sub-folder, never the root. Same value as {@link Rsdkv4SDK.id}.
|
|
92
|
+
*/
|
|
93
|
+
const ENGINE_ID = 'rsdkv4';
|
|
94
|
+
|
|
95
|
+
/** The files this engine keeps in its namespace — and the only ones it ever migrates. */
|
|
96
|
+
const ENGINE_FILES = ['Data.rsdk', 'settings.ini', 'SData.bin'] as const;
|
|
97
|
+
|
|
88
98
|
/** RSDKv4's save file, written next to the game data in the working dir. */
|
|
89
99
|
const SAVE_FILE = 'SData.bin';
|
|
90
100
|
|
|
@@ -116,6 +126,7 @@ const ENGINE_BRIDGES = [
|
|
|
116
126
|
'web_start_game',
|
|
117
127
|
'web_audio_stop',
|
|
118
128
|
'web_devmenu_get_stage_list',
|
|
129
|
+
'web_devmenu_open',
|
|
119
130
|
'web_input_set_source',
|
|
120
131
|
] as const;
|
|
121
132
|
|
|
@@ -129,6 +140,34 @@ const INPUT_PULL_HOOK = '__rsdkv4_input_pull';
|
|
|
129
140
|
/** How often a pull that keeps throwing is allowed to say so. See {@link Rsdkv4Play}. */
|
|
130
141
|
const INPUT_ERROR_INTERVAL_MS = 1000;
|
|
131
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Keys whose browser default action is swallowed while the canvas has focus — the
|
|
145
|
+
* arrows and Space scroll the page under the game, Tab takes the focus off the canvas
|
|
146
|
+
* and with it every key the engine was reading.
|
|
147
|
+
*
|
|
148
|
+
* Both `KeyboardEvent.code` and `.key` spellings are in here because the set is
|
|
149
|
+
* matched against each: `code` is the physical key (layout-independent, which is what
|
|
150
|
+
* the engine binds), `key` is what it produced, and a browser or a remapping tool that
|
|
151
|
+
* reports only one of the two still lands.
|
|
152
|
+
*
|
|
153
|
+
* WASD is included because this build's `ProcessInput()` reads those scancodes as a
|
|
154
|
+
* second set of directions. They have no default action to prevent today, so this
|
|
155
|
+
* costs nothing and stops mattering the day the page grows a shortcut on one of them.
|
|
156
|
+
*/
|
|
157
|
+
const ENGINE_KEYS = new Set([
|
|
158
|
+
'ArrowUp',
|
|
159
|
+
'ArrowDown',
|
|
160
|
+
'ArrowLeft',
|
|
161
|
+
'ArrowRight',
|
|
162
|
+
'KeyW',
|
|
163
|
+
'KeyA',
|
|
164
|
+
'KeyS',
|
|
165
|
+
'KeyD',
|
|
166
|
+
'Space',
|
|
167
|
+
' ',
|
|
168
|
+
'Tab',
|
|
169
|
+
]);
|
|
170
|
+
|
|
132
171
|
/** The Emscripten module. Untyped on purpose: it is generated glue, not our surface. */
|
|
133
172
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
134
173
|
type EmscriptenModule = any;
|
|
@@ -150,21 +189,37 @@ async function resolveAsset(asset: Rsdkv4Asset | undefined): Promise<Uint8Array
|
|
|
150
189
|
return toUint8(typeof asset === 'function' ? await asset() : asset);
|
|
151
190
|
}
|
|
152
191
|
|
|
153
|
-
/**
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Where an engine keeps one namespace: `<engineId>/<namespace>`, relative to the OPFS
|
|
194
|
+
* root (which is WASMFS `/data`). Written with nothing rsdkv4-specific in it, so it can
|
|
195
|
+
* move verbatim into engine-specs.
|
|
196
|
+
*
|
|
197
|
+
* The host's namespace is cleaned segment by segment: blanks dropped, `.` and `..`
|
|
198
|
+
* dropped (they used to survive, and `'../x'` walked out of the work root), anything
|
|
199
|
+
* outside `[A-Za-z0-9._-]` turned into `_`. Nothing usable left means `'default'`.
|
|
200
|
+
* A namespace that already starts with the engine id has it stripped once, so a host
|
|
201
|
+
* written against the old convention (`'rsdkv4/Sonic1'`) lands where it always did.
|
|
202
|
+
*/
|
|
203
|
+
export function storagePath(
|
|
204
|
+
engineId: string,
|
|
205
|
+
namespace: unknown,
|
|
206
|
+
): { namespace: string; path: string; prefixed: boolean } {
|
|
207
|
+
const segments =
|
|
208
|
+
typeof namespace === 'string'
|
|
209
|
+
? namespace
|
|
210
|
+
.split('/')
|
|
211
|
+
.map((segment) => segment.trim())
|
|
212
|
+
.filter((segment) => segment && segment !== '.' && segment !== '..')
|
|
213
|
+
.map((segment) => segment.replace(/[^A-Za-z0-9._-]/g, '_'))
|
|
214
|
+
: [];
|
|
215
|
+
const prefixed = segments.length > 1 && segments[0] === engineId;
|
|
216
|
+
if (prefixed) segments.shift();
|
|
217
|
+
const cleaned = segments.join('/') || DEFAULT_STORAGE_NAMESPACE;
|
|
218
|
+
return { namespace: cleaned, path: `${engineId}/${cleaned}`, prefixed };
|
|
166
219
|
}
|
|
167
220
|
|
|
221
|
+
let warnedPrefixed = false;
|
|
222
|
+
|
|
168
223
|
/** The plain values behind a descriptor table — what the engine actually needs. */
|
|
169
224
|
function valuesOf<T>(described: PropertiesDefinition<T>): T {
|
|
170
225
|
const out: Record<string, unknown> = {};
|
|
@@ -289,18 +344,18 @@ function mountWorkingDir(
|
|
|
289
344
|
}
|
|
290
345
|
|
|
291
346
|
/**
|
|
292
|
-
* OPFS handle for `<namespace
|
|
347
|
+
* OPFS handle for `<engineId>/<namespace>/` (a {@link storagePath}), reached with the plain (async) OPFS API from JS —
|
|
293
348
|
* which, unlike the WASM side's sync access handles, works on the main thread of any
|
|
294
349
|
* page. Returns null when OPFS isn't available at all.
|
|
295
350
|
*/
|
|
296
351
|
async function opfsNamespaceDir(
|
|
297
|
-
|
|
352
|
+
path: string,
|
|
298
353
|
create: boolean,
|
|
299
354
|
): Promise<FileSystemDirectoryHandle | null> {
|
|
300
355
|
if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return null;
|
|
301
356
|
try {
|
|
302
357
|
let dir = await navigator.storage.getDirectory();
|
|
303
|
-
for (const segment of
|
|
358
|
+
for (const segment of path.split('/').filter(Boolean)) {
|
|
304
359
|
dir = await dir.getDirectoryHandle(segment, { create });
|
|
305
360
|
}
|
|
306
361
|
return dir;
|
|
@@ -309,6 +364,42 @@ async function opfsNamespaceDir(
|
|
|
309
364
|
}
|
|
310
365
|
}
|
|
311
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Before the namespace lived under `rsdkv4/`, the default one was a top-level
|
|
369
|
+
* `default/` — a folder snes9x, mGBA and geolith also fall back to. Copy rsdkv4's own
|
|
370
|
+
* files out of it into `rsdkv4/default/` when the new folder lacks them. Copy, never
|
|
371
|
+
* move or delete: whatever else is in `default/` is not ours to touch.
|
|
372
|
+
*/
|
|
373
|
+
async function migrateLegacyDefault(): Promise<void> {
|
|
374
|
+
const legacy = await opfsNamespaceDir(DEFAULT_STORAGE_NAMESPACE, false);
|
|
375
|
+
if (!legacy) return;
|
|
376
|
+
let target: FileSystemDirectoryHandle | null = null;
|
|
377
|
+
for (const name of ENGINE_FILES) {
|
|
378
|
+
let source: File;
|
|
379
|
+
try {
|
|
380
|
+
source = await (await legacy.getFileHandle(name)).getFile();
|
|
381
|
+
} catch {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
target ??= await opfsNamespaceDir(`${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}`, true);
|
|
385
|
+
if (!target) return;
|
|
386
|
+
try {
|
|
387
|
+
await target.getFileHandle(name);
|
|
388
|
+
continue; // already there: the new layout wins
|
|
389
|
+
} catch {
|
|
390
|
+
/* missing — copy it */
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
const writable = await (await target.getFileHandle(name, { create: true })).createWritable();
|
|
394
|
+
await writable.write(await source.arrayBuffer());
|
|
395
|
+
await writable.close();
|
|
396
|
+
console.info(`[rsdkv4] copied ${DEFAULT_STORAGE_NAMESPACE}/${name} → ${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}/${name}`);
|
|
397
|
+
} catch (e) {
|
|
398
|
+
console.warn(`[rsdkv4] could not migrate ${DEFAULT_STORAGE_NAMESPACE}/${name}`, e);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
312
403
|
/**
|
|
313
404
|
* Wait for `Engine::Init`, which runs on the engine's first frame (main.cpp's
|
|
314
405
|
* main_loop), not inside `callMain`. Gives up after ~4s rather than hanging the host:
|
|
@@ -331,11 +422,49 @@ function engineBridgeComplete(Module: EmscriptenModule): boolean {
|
|
|
331
422
|
|
|
332
423
|
// ---------------------------------------------------------------- this engine's own surface
|
|
333
424
|
|
|
425
|
+
/**
|
|
426
|
+
* A screen of RSDKv4's own in-canvas Dev Menu (Debug.cpp), as {@link
|
|
427
|
+
* RsdkDevMenuBridge.open} names them.
|
|
428
|
+
*
|
|
429
|
+
* `'categories'` is the engine's `SELECT A STAGE LIST` — PRESENTATION / REGULAR /
|
|
430
|
+
* SPECIAL / BONUS — and `'stages'` the scene list under one of them. Those two are the
|
|
431
|
+
* only native way to reach the BONUS list: the packs' own menu scenes (Sonic 1 and 2's
|
|
432
|
+
* `LEVEL SELECT` and `STAGE MENU`) can load a regular stage and nothing else.
|
|
433
|
+
*/
|
|
434
|
+
export type RsdkDevMenuScreen = 'main' | 'player' | 'categories' | 'stages';
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The order `web_devmenu_open`'s first argument counts in — the index into this array
|
|
438
|
+
* is what crosses to C++, so it has to stay in step with WebDevMenu.cpp (written by
|
|
439
|
+
* scripts/build.sh). Names on this side, a number on the wire.
|
|
440
|
+
*/
|
|
441
|
+
const DEV_MENU_SCREENS: readonly RsdkDevMenuScreen[] = ['main', 'player', 'categories', 'stages'];
|
|
442
|
+
|
|
334
443
|
/** RSDKv4-specific bridge for a host's debug/stage-select UI. */
|
|
335
444
|
export interface RsdkDevMenuBridge {
|
|
336
445
|
getStageList(): Array<{ name: string; stages: Array<{ name: string }> }>;
|
|
337
446
|
loadStage(categoryIdx: number, stageIdx: number): void;
|
|
338
447
|
setPaused(paused: boolean): void;
|
|
448
|
+
/**
|
|
449
|
+
* Whether the loaded wasm carries {@link open}. False means an engine built before
|
|
450
|
+
* the bridge existed — a host should hide the button rather than offer a dead one.
|
|
451
|
+
*/
|
|
452
|
+
readonly canOpen: boolean;
|
|
453
|
+
/**
|
|
454
|
+
* Open the engine's own Dev Menu at `screen`, with `category` selecting the stage
|
|
455
|
+
* list for `'categories'` (preselected) and `'stages'` (listed). Categories are the
|
|
456
|
+
* engine's own indices, the ones {@link getStageList} is ordered by — 2 is BONUS.
|
|
457
|
+
*
|
|
458
|
+
* This is what Escape does natively: the request is handed to the engine thread
|
|
459
|
+
* (`ENGINE_INITDEVMENU`) and the menu comes up on its next frame, so **the running
|
|
460
|
+
* stage is torn down** — `initDevMenu()` clears graphics and animation data. Save
|
|
461
|
+
* files are untouched; the current run is not.
|
|
462
|
+
*
|
|
463
|
+
* The menu is driven by the engine's own input and does not run while the SDK holds
|
|
464
|
+
* a pause, so resume before calling. Returns false when the engine cannot be reached
|
|
465
|
+
* or the wasm predates the bridge ({@link canOpen}).
|
|
466
|
+
*/
|
|
467
|
+
open(screen?: RsdkDevMenuScreen, category?: number): boolean;
|
|
339
468
|
}
|
|
340
469
|
|
|
341
470
|
/**
|
|
@@ -403,6 +532,34 @@ export interface RsdkInputBridge {
|
|
|
403
532
|
release(): PromisePlay<Rsdkv4Payloads>;
|
|
404
533
|
/** Whether a claim is held. The `options()` descriptor deliberately will not say. */
|
|
405
534
|
readonly claimed: boolean;
|
|
535
|
+
/**
|
|
536
|
+
* What the engine has held **right now**, as the bitmask a claim writes: bit 0 is
|
|
537
|
+
* `up`, bit 13 is `select`, in {@link RSDKV4_BUTTONS} order.
|
|
538
|
+
*
|
|
539
|
+
* This is the read-back to the claim's write, and it answers the one question no
|
|
540
|
+
* amount of listening on the page can: *did the button reach the engine?* A key
|
|
541
|
+
* that fires `keydown` in the browser and never lights a bit here died between SDL
|
|
542
|
+
* and `ProcessInput()`; one that lights the bit and changes nothing on screen was
|
|
543
|
+
* received and the game chose not to act on it. Those two are otherwise the same
|
|
544
|
+
* silence, and telling them apart is the difference between a real input bug and
|
|
545
|
+
* Sonic simply refusing to roll while a direction is held.
|
|
546
|
+
*
|
|
547
|
+
* Works whether or not a claim is held — under SDL polling it is the keyboard and
|
|
548
|
+
* the pad as the engine read them. `0` when the engine is not up.
|
|
549
|
+
*
|
|
550
|
+
* Two things it is not. It is `hold`, not `press`: a button reads the same on the
|
|
551
|
+
* frame it goes down and on the hundredth frame it stays down. And it is the
|
|
552
|
+
* engine's view *after* the B→A fold, so in-stage the jump key `x` lights `a` and
|
|
553
|
+
* `b` never lights at all — which is the fold working, not a lost button.
|
|
554
|
+
*/
|
|
555
|
+
readonly mask: number;
|
|
556
|
+
/**
|
|
557
|
+
* {@link mask}, spelled as the fourteen named booleans — the same shape a claim
|
|
558
|
+
* hands over, so a host can diff what it sent against what arrived.
|
|
559
|
+
*
|
|
560
|
+
* A fresh object on every read; it is a snapshot, not a live view.
|
|
561
|
+
*/
|
|
562
|
+
readonly held: Rsdkv4Buttons;
|
|
406
563
|
}
|
|
407
564
|
|
|
408
565
|
/**
|
|
@@ -446,7 +603,8 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
|
|
|
446
603
|
|
|
447
604
|
#persistent = false;
|
|
448
605
|
#namespace = DEFAULT_STORAGE_NAMESPACE;
|
|
449
|
-
#
|
|
606
|
+
#storagePath = `${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}`;
|
|
607
|
+
#workDir = `${WORK_ROOT}/${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}`;
|
|
450
608
|
#mirrorSaves = true;
|
|
451
609
|
#saveTimer: ReturnType<typeof setInterval> | null = null;
|
|
452
610
|
/** Last bytes mirrored out, so the `saves` event means "changed" and not "checked". */
|
|
@@ -602,6 +760,7 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
|
|
|
602
760
|
|
|
603
761
|
canvas.addEventListener('contextmenu', this.#swallowContextMenu);
|
|
604
762
|
canvas.addEventListener('pointerdown', this.#onPointerDown);
|
|
763
|
+
canvas.addEventListener('keydown', this.#onKeyDown);
|
|
605
764
|
window.addEventListener('resize', this.#onResize);
|
|
606
765
|
// Fullscreen transitions need a refit too.
|
|
607
766
|
document.addEventListener('fullscreenchange', this.#onFullscreenChange);
|
|
@@ -754,6 +913,15 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
|
|
|
754
913
|
return this.#namespace;
|
|
755
914
|
}
|
|
756
915
|
|
|
916
|
+
/**
|
|
917
|
+
* Where this session's files live, relative to the OPFS root: `rsdkv4/<namespace>`.
|
|
918
|
+
* The path a host walks with `navigator.storage.getDirectory()` to stage a
|
|
919
|
+
* `Data.rsdk` before booting, or to read `SData.bin` after.
|
|
920
|
+
*/
|
|
921
|
+
get storagePath(): string {
|
|
922
|
+
return this.#storagePath;
|
|
923
|
+
}
|
|
924
|
+
|
|
757
925
|
/**
|
|
758
926
|
* Input ownership, named. `play.input.claim(buttons)` and `play.input.release()`
|
|
759
927
|
* are exactly `options({ buttons })` and `options({ buttons: null })` — same state,
|
|
@@ -769,9 +937,34 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
|
|
|
769
937
|
get claimed(): boolean {
|
|
770
938
|
return play.#buttons !== null;
|
|
771
939
|
},
|
|
940
|
+
get mask(): number {
|
|
941
|
+
return play.#engineMask();
|
|
942
|
+
},
|
|
943
|
+
get held(): Rsdkv4Buttons {
|
|
944
|
+
const mask = play.#engineMask();
|
|
945
|
+
const held = {} as Rsdkv4Buttons;
|
|
946
|
+
for (let bit = 0; bit < RSDKV4_BUTTONS.length; bit++) {
|
|
947
|
+
held[RSDKV4_BUTTONS[bit]] = (mask & (1 << bit)) !== 0;
|
|
948
|
+
}
|
|
949
|
+
return held;
|
|
950
|
+
},
|
|
772
951
|
};
|
|
773
952
|
}
|
|
774
953
|
|
|
954
|
+
/**
|
|
955
|
+
* The engine's own `web_input_get_mask()`, or 0 when there is no engine to ask.
|
|
956
|
+
*
|
|
957
|
+
* Deliberately quiet about a module that does not have the bridge: unlike a claim,
|
|
958
|
+
* which silently does nothing and deserves the console error `#applyButtons` gives
|
|
959
|
+
* it, this is a read — a host polling it once a frame for an overlay would turn one
|
|
960
|
+
* old artifact into sixty messages a second.
|
|
961
|
+
*/
|
|
962
|
+
#engineMask(): number {
|
|
963
|
+
const Module = this.#module;
|
|
964
|
+
if (!Module || typeof Module.web_input_get_mask !== 'function') return 0;
|
|
965
|
+
return Module.web_input_get_mask() | 0;
|
|
966
|
+
}
|
|
967
|
+
|
|
775
968
|
// ---- Host-driven input ----
|
|
776
969
|
|
|
777
970
|
/**
|
|
@@ -928,6 +1121,24 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
|
|
|
928
1121
|
if (paused) this.pause('host');
|
|
929
1122
|
else this.resume(this.#pauseOwner ?? 'host');
|
|
930
1123
|
},
|
|
1124
|
+
canOpen: typeof Module.web_devmenu_open === 'function',
|
|
1125
|
+
open: (screen = 'main', category = 0) => {
|
|
1126
|
+
if (typeof Module.web_devmenu_open !== 'function') {
|
|
1127
|
+
console.warn(
|
|
1128
|
+
'[rsdkv4] this rsdkv4.wasm has no web_devmenu_open — rebuild it (`make build-wasm`), or read devMenu.canOpen before offering the button',
|
|
1129
|
+
);
|
|
1130
|
+
return false;
|
|
1131
|
+
}
|
|
1132
|
+
const index = DEV_MENU_SCREENS.indexOf(screen);
|
|
1133
|
+
if (index < 0) {
|
|
1134
|
+
console.warn(
|
|
1135
|
+
`[rsdkv4] "${screen}" is not a dev menu screen — one of ${DEV_MENU_SCREENS.join(', ')}`,
|
|
1136
|
+
);
|
|
1137
|
+
return false;
|
|
1138
|
+
}
|
|
1139
|
+
Module.web_devmenu_open(index, category | 0);
|
|
1140
|
+
return true;
|
|
1141
|
+
},
|
|
931
1142
|
};
|
|
932
1143
|
}
|
|
933
1144
|
|
|
@@ -973,8 +1184,17 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
|
|
|
973
1184
|
// Mount the working dir first, so we can see what is already persisted (OPFS)
|
|
974
1185
|
// before deciding whether to pull assets.
|
|
975
1186
|
this.#persistent = mountWorkingDir(Module, storage.persist).persistent;
|
|
976
|
-
|
|
977
|
-
|
|
1187
|
+
const resolved = storagePath(ENGINE_ID, storage.namespace);
|
|
1188
|
+
if (resolved.prefixed && !warnedPrefixed) {
|
|
1189
|
+
warnedPrefixed = true;
|
|
1190
|
+
console.warn(
|
|
1191
|
+
`[rsdkv4] storage.namespace no longer needs the "${ENGINE_ID}/" prefix — the engine adds it. Pass "${resolved.namespace}".`,
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
this.#namespace = resolved.namespace;
|
|
1195
|
+
this.#storagePath = resolved.path;
|
|
1196
|
+
this.#workDir = `${WORK_ROOT}/${resolved.path}`;
|
|
1197
|
+
if (resolved.namespace === DEFAULT_STORAGE_NAMESPACE) await migrateLegacyDefault();
|
|
978
1198
|
this.#mirrorSaves = storage.persist !== null;
|
|
979
1199
|
ensureDir(Module, this.#workDir);
|
|
980
1200
|
|
|
@@ -999,8 +1219,24 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
|
|
|
999
1219
|
|
|
1000
1220
|
// The engine writes SData.bin at its own pace (new game, checkpoint, options
|
|
1001
1221
|
// change), so copy it out on a slow timer as well as on the way down.
|
|
1222
|
+
//
|
|
1223
|
+
// The timer alone loses saves, and by a wide margin: `destroy()` is the only
|
|
1224
|
+
// other flush, and a browser tab that is closed or reloaded never calls it. A
|
|
1225
|
+
// player who finished an act and hit reload inside the next ten seconds had the
|
|
1226
|
+
// save written by the engine, held in the wasm heap, and dropped on the floor —
|
|
1227
|
+
// which reads as "my progress did not stick" and looks exactly like a save
|
|
1228
|
+
// system that does not work.
|
|
1229
|
+
//
|
|
1230
|
+
// `visibilitychange` → hidden is the flush that actually fires: it covers the
|
|
1231
|
+
// tab being switched away from, the window being minimised, the phone going to
|
|
1232
|
+
// the home screen, and — unlike `beforeunload`, which browsers increasingly
|
|
1233
|
+
// ignore — navigating away and closing. `pagehide` backs it up for the bfcache
|
|
1234
|
+
// path. Cheap to over-fire: the mirror compares bytes and returns without
|
|
1235
|
+
// touching OPFS when nothing moved.
|
|
1002
1236
|
if (this.#mirrorSaves) {
|
|
1003
1237
|
this.#saveTimer = setInterval(() => void this.#mirrorSaveData(), 10_000);
|
|
1238
|
+
document.addEventListener('visibilitychange', this.#onVisibilityChange);
|
|
1239
|
+
window.addEventListener('pagehide', this.#onPageHide);
|
|
1004
1240
|
}
|
|
1005
1241
|
|
|
1006
1242
|
return Module;
|
|
@@ -1071,7 +1307,7 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
|
|
|
1071
1307
|
if (explicit) Module.FS.writeFile(dataPath, explicit);
|
|
1072
1308
|
else {
|
|
1073
1309
|
console.log(
|
|
1074
|
-
`[rsdkv4] Data.rsdk already in persistent storage (${this.#
|
|
1310
|
+
`[rsdkv4] Data.rsdk already in persistent storage (${this.#storagePath}) — skipping fetch`,
|
|
1075
1311
|
);
|
|
1076
1312
|
}
|
|
1077
1313
|
} else {
|
|
@@ -1103,7 +1339,7 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
|
|
|
1103
1339
|
* it changes.
|
|
1104
1340
|
*/
|
|
1105
1341
|
async #restoreSaveData(Module: EmscriptenModule): Promise<void> {
|
|
1106
|
-
const dir = await opfsNamespaceDir(this.#
|
|
1342
|
+
const dir = await opfsNamespaceDir(this.#storagePath, false);
|
|
1107
1343
|
if (!dir) return;
|
|
1108
1344
|
try {
|
|
1109
1345
|
const file = await (await dir.getFileHandle(SAVE_FILE)).getFile();
|
|
@@ -1118,7 +1354,7 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
|
|
|
1118
1354
|
}
|
|
1119
1355
|
|
|
1120
1356
|
/**
|
|
1121
|
-
* Copy SData.bin out of the wasm into
|
|
1357
|
+
* Copy SData.bin out of the wasm into `rsdkv4/<namespace>/`, and announce it if it moved.
|
|
1122
1358
|
*
|
|
1123
1359
|
* The division of labour the session settled on: the SDK owns getting the bytes out
|
|
1124
1360
|
* of the wasm and into the namespace both sides already agree on; a host owns
|
|
@@ -1140,7 +1376,7 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
|
|
|
1140
1376
|
}
|
|
1141
1377
|
if (sameBytes(bytes, this.#lastSave)) return;
|
|
1142
1378
|
|
|
1143
|
-
const dir = await opfsNamespaceDir(this.#
|
|
1379
|
+
const dir = await opfsNamespaceDir(this.#storagePath, true);
|
|
1144
1380
|
if (dir) {
|
|
1145
1381
|
try {
|
|
1146
1382
|
const handle = await dir.getFileHandle(SAVE_FILE, { create: true });
|
|
@@ -1159,8 +1395,26 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
|
|
|
1159
1395
|
|
|
1160
1396
|
// ---- The render target ----
|
|
1161
1397
|
|
|
1398
|
+
/**
|
|
1399
|
+
* Flush the save when the page is being put away. See where these are registered
|
|
1400
|
+
* for why the ten-second timer is not enough on its own.
|
|
1401
|
+
*/
|
|
1402
|
+
#onVisibilityChange = (): void => {
|
|
1403
|
+
if (document.visibilityState === 'hidden' && this.#mirrorSaves) void this.#mirrorSaveData();
|
|
1404
|
+
};
|
|
1405
|
+
#onPageHide = (): void => {
|
|
1406
|
+
if (this.#mirrorSaves) void this.#mirrorSaveData();
|
|
1407
|
+
};
|
|
1408
|
+
|
|
1162
1409
|
#swallowContextMenu = (event: Event): void => event.preventDefault();
|
|
1163
1410
|
#onPointerDown = (): void => this.#focusCanvas();
|
|
1411
|
+
#onKeyDown = (event: KeyboardEvent): void => {
|
|
1412
|
+
// Only the browser's own default action is suppressed — the arrows and Space
|
|
1413
|
+
// scroll the page, Tab walks the focus out of the canvas. The event is left to
|
|
1414
|
+
// propagate: SDL's own handler sits on `window` in the bubble phase, so calling
|
|
1415
|
+
// stopPropagation() here would be the engine's whole keyboard.
|
|
1416
|
+
if (ENGINE_KEYS.has(event.code) || ENGINE_KEYS.has(event.key)) event.preventDefault();
|
|
1417
|
+
};
|
|
1164
1418
|
#onResize = (): void => this.#fitPicture();
|
|
1165
1419
|
#onFullscreenChange = (): void => {
|
|
1166
1420
|
this.#fitPicture();
|
|
@@ -1214,9 +1468,12 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
|
|
|
1214
1468
|
if (canvas) {
|
|
1215
1469
|
canvas.removeEventListener('contextmenu', this.#swallowContextMenu);
|
|
1216
1470
|
canvas.removeEventListener('pointerdown', this.#onPointerDown);
|
|
1471
|
+
canvas.removeEventListener('keydown', this.#onKeyDown);
|
|
1217
1472
|
if (this.#ownsCanvas) canvas.remove();
|
|
1218
1473
|
}
|
|
1219
1474
|
document.removeEventListener('fullscreenchange', this.#onFullscreenChange);
|
|
1475
|
+
document.removeEventListener('visibilitychange', this.#onVisibilityChange);
|
|
1476
|
+
window.removeEventListener('pagehide', this.#onPageHide);
|
|
1220
1477
|
window.removeEventListener('resize', this.#onResize);
|
|
1221
1478
|
this.#boxObserver?.disconnect();
|
|
1222
1479
|
this.#boxObserver = null;
|
|
@@ -1256,9 +1513,9 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
|
|
|
1256
1513
|
* Everything on it is the contract's — see the module doc for the shape of a call.
|
|
1257
1514
|
*/
|
|
1258
1515
|
export class Rsdkv4SDK extends EngineSDKBase<Rsdkv4Payloads> {
|
|
1259
|
-
static readonly id =
|
|
1516
|
+
static readonly id = ENGINE_ID;
|
|
1260
1517
|
/** Kept in step with package.json by `node scripts/sync-version.mjs`. */
|
|
1261
|
-
static readonly version = '0.
|
|
1518
|
+
static readonly version = '0.3.0';
|
|
1262
1519
|
static readonly name = 'Retro Software Development Kit v4';
|
|
1263
1520
|
static readonly description =
|
|
1264
1521
|
'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.';
|