@wasm-gaming/rsdkv4-wasm 0.1.2 → 0.1.5
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 +188 -35
- package/dist/manifest.json +24 -5
- package/dist/rsdkv4/rsdkv4.manifest.d.ts.map +1 -1
- package/dist/rsdkv4/rsdkv4.manifest.js +14 -2
- package/dist/rsdkv4/rsdkv4.manifest.js.map +1 -1
- package/dist/rsdkv4/rsdkv4.options.d.ts +12 -0
- package/dist/rsdkv4/rsdkv4.options.d.ts.map +1 -1
- package/dist/rsdkv4/rsdkv4.options.js +33 -6
- package/dist/rsdkv4/rsdkv4.options.js.map +1 -1
- package/dist/rsdkv4/rsdkv4.sdk.d.ts +60 -1
- package/dist/rsdkv4/rsdkv4.sdk.d.ts.map +1 -1
- package/dist/rsdkv4/rsdkv4.sdk.js +359 -32
- package/dist/rsdkv4/rsdkv4.sdk.js.map +1 -1
- package/package.json +3 -3
- package/src/rsdkv4.manifest.ts +15 -2
- package/src/rsdkv4.options.ts +41 -6
- package/src/rsdkv4.sdk.ts +466 -32
- package/dist/rsdkv4/rsdkv4.js +0 -9268
- package/dist/rsdkv4/rsdkv4.wasm +0 -0
package/src/rsdkv4.options.ts
CHANGED
|
@@ -24,6 +24,12 @@ export interface Rsdkv4Options {
|
|
|
24
24
|
engineDebugMode?: boolean;
|
|
25
25
|
/** VSync the engine's SDL window. */
|
|
26
26
|
vsync?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Skip RSDKv4's in-canvas Start Menu (save select / character select / game
|
|
29
|
+
* options). Set this when the host draws those screens itself from
|
|
30
|
+
* `instance.game` — otherwise the engine's own menu runs underneath.
|
|
31
|
+
*/
|
|
32
|
+
skipStartMenu?: boolean;
|
|
27
33
|
/** Boot directly into a stage: 0-based stage-list category. */
|
|
28
34
|
startingCategory?: number;
|
|
29
35
|
/** Boot directly into a stage: 0-based scene within the category. */
|
|
@@ -32,13 +38,21 @@ export interface Rsdkv4Options {
|
|
|
32
38
|
startingPlayer?: number;
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
/**
|
|
42
|
+
* RSDKv4 reads 255 as "not set" for the starting stage/player (see Userdata.cpp:
|
|
43
|
+
* anything else — **including 0** — makes Engine::Init skip the start-menu flow
|
|
44
|
+
* and force InitStartingStage). So "boot normally" has to be 255, not 0.
|
|
45
|
+
*/
|
|
46
|
+
export const RSDKV4_UNSET = 255;
|
|
47
|
+
|
|
35
48
|
export const DEFAULT_RSDKV4_OPTIONS: Required<Rsdkv4Options> = {
|
|
36
49
|
devMenu: false,
|
|
37
50
|
engineDebugMode: true,
|
|
38
51
|
vsync: true,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
skipStartMenu: false,
|
|
53
|
+
startingCategory: RSDKV4_UNSET,
|
|
54
|
+
startingScene: RSDKV4_UNSET,
|
|
55
|
+
startingPlayer: RSDKV4_UNSET,
|
|
42
56
|
};
|
|
43
57
|
|
|
44
58
|
export const RSDKV4_OPTIONS_SCHEMA: JSONSchema = {
|
|
@@ -57,8 +71,29 @@ export const RSDKV4_OPTIONS_SCHEMA: JSONSchema = {
|
|
|
57
71
|
description: 'Enables the web devmenu embind bridge (stage list / warp / pause).',
|
|
58
72
|
},
|
|
59
73
|
vsync: { type: 'boolean', default: true },
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
74
|
+
skipStartMenu: {
|
|
75
|
+
type: 'boolean',
|
|
76
|
+
default: false,
|
|
77
|
+
description:
|
|
78
|
+
"Skip the engine's own save/character/options screens — set it when the host renders them from instance.game.",
|
|
79
|
+
},
|
|
80
|
+
startingCategory: {
|
|
81
|
+
type: 'integer',
|
|
82
|
+
minimum: 0,
|
|
83
|
+
default: RSDKV4_UNSET,
|
|
84
|
+
description: '0-based stage-list category; 255 = unset (normal boot flow).',
|
|
85
|
+
},
|
|
86
|
+
startingScene: {
|
|
87
|
+
type: 'integer',
|
|
88
|
+
minimum: 0,
|
|
89
|
+
default: RSDKV4_UNSET,
|
|
90
|
+
description: '0-based scene in the category; 255 = unset (normal boot flow).',
|
|
91
|
+
},
|
|
92
|
+
startingPlayer: {
|
|
93
|
+
type: 'integer',
|
|
94
|
+
minimum: 0,
|
|
95
|
+
default: RSDKV4_UNSET,
|
|
96
|
+
description: 'Player/character index; 255 = unset (normal boot flow).',
|
|
97
|
+
},
|
|
63
98
|
},
|
|
64
99
|
};
|
package/src/rsdkv4.sdk.ts
CHANGED
|
@@ -9,12 +9,14 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Filesystem: built with -sWASMFS (the modern Emscripten filesystem, not MEMFS).
|
|
11
11
|
// The game working dir `/data` is mounted on **OPFS** for persistence when the
|
|
12
|
-
// page is cross-origin isolated
|
|
13
|
-
//
|
|
12
|
+
// page is cross-origin isolated *and* the wasm build can create the OPFS backend
|
|
13
|
+
// from the main thread (an Asyncify/JSPI build — today's is not, see WebFS.cpp);
|
|
14
|
+
// otherwise it falls back to the WASMFS in-memory backend (transient, but the
|
|
15
|
+
// engine still boots). The backend is selectable via
|
|
14
16
|
// `config.persist` (see mountWorkingDir), and when the dir is OPFS-backed the SDK
|
|
15
17
|
// reuses an already-persisted Data.rsdk/settings.ini instead of re-fetching.
|
|
16
18
|
|
|
17
|
-
import type { EngineConfig, EngineInstance, AssetData } from '@wasm-gaming/engine-specs';
|
|
19
|
+
import type { EngineConfig, EngineInstance, EngineEvent, AssetData, InputPreset, KeyMap } from '@wasm-gaming/engine-specs';
|
|
18
20
|
import { manifest } from './rsdkv4.manifest.js';
|
|
19
21
|
import { DEFAULT_RSDKV4_OPTIONS, type Rsdkv4Options } from './rsdkv4.options.js';
|
|
20
22
|
|
|
@@ -23,7 +25,19 @@ export { manifest };
|
|
|
23
25
|
const WORK_ROOT = '/data';
|
|
24
26
|
const DEFAULT_STORAGE_NAMESPACE = 'default';
|
|
25
27
|
|
|
26
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Emscripten's SDL2 port locates the canvas via `document.querySelector('#canvas')`,
|
|
30
|
+
* so the element the game draws into has to carry this id.
|
|
31
|
+
*/
|
|
32
|
+
const CANVAS_ID = 'canvas';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Serialize engine options into RSDKv4's settings.ini format.
|
|
36
|
+
*
|
|
37
|
+
* Note the Starting* trio defaults to 255 ("unset") — writing 0 is not neutral,
|
|
38
|
+
* it tells the engine to skip its start-menu flow and force that stage. See
|
|
39
|
+
* DEFAULT_RSDKV4_OPTIONS.
|
|
40
|
+
*/
|
|
27
41
|
function buildSettingsIni(options: Rsdkv4Options = {}): string {
|
|
28
42
|
const o = { ...DEFAULT_RSDKV4_OPTIONS, ...options };
|
|
29
43
|
return [
|
|
@@ -34,6 +48,9 @@ function buildSettingsIni(options: Rsdkv4Options = {}): string {
|
|
|
34
48
|
`StartingScene=${o.startingScene | 0}`,
|
|
35
49
|
`StartingPlayer=${o.startingPlayer | 0}`,
|
|
36
50
|
'',
|
|
51
|
+
'[Game]',
|
|
52
|
+
`SkipStartMenu=${o.skipStartMenu ? 'true' : 'false'}`,
|
|
53
|
+
'',
|
|
37
54
|
'[Window]',
|
|
38
55
|
`VSync=${o.vsync ? 'true' : 'false'}`,
|
|
39
56
|
'',
|
|
@@ -78,6 +95,26 @@ function ensureDir(Module: any, path: string): void {
|
|
|
78
95
|
}
|
|
79
96
|
}
|
|
80
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Whether this build can mount OPFS at all, asked of the wasm itself.
|
|
100
|
+
*
|
|
101
|
+
* WASMFS's `wasmfs_create_opfs_backend()` spawns a proxy worker synchronously,
|
|
102
|
+
* which it refuses to do on the main browser thread without Asyncify/JSPI — it
|
|
103
|
+
* *asserts*, and a failed assert calls `abort()`, which tears the module down for
|
|
104
|
+
* good. That is past the point where a try/catch around the call can save us, so
|
|
105
|
+
* we have to know before calling. `web_opfs_supported` (WebFS.cpp) mirrors the
|
|
106
|
+
* same condition; builds predating that helper simply answer "no".
|
|
107
|
+
*/
|
|
108
|
+
function opfsMountSupported(Module: any): boolean {
|
|
109
|
+
const probe = Module._web_opfs_supported;
|
|
110
|
+
if (typeof probe !== 'function') return false;
|
|
111
|
+
try {
|
|
112
|
+
return probe() !== 0;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
81
118
|
/**
|
|
82
119
|
* Mount the game working dir, honoring `persist`:
|
|
83
120
|
* 'opfs' → force an OPFS (persistent) mount; warn + fall back if unavailable.
|
|
@@ -85,24 +122,27 @@ function ensureDir(Module: any, path: string): void {
|
|
|
85
122
|
* undefined → auto: OPFS when the page is cross-origin isolated, else in-memory.
|
|
86
123
|
* ('idbfs' isn't supported under WASMFS and is treated as in-memory.)
|
|
87
124
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
125
|
+
* Cross-origin isolation is necessary but not sufficient: the mount also needs a
|
|
126
|
+
* build whose OPFS backend can run from where we call it (see opfsMountSupported),
|
|
127
|
+
* so both gates apply before we touch `web_mount_opfs`.
|
|
90
128
|
* Returns whether the resulting mount is persistent.
|
|
91
129
|
*/
|
|
92
130
|
function mountWorkingDir(Module: any, persist: EngineConfig['persist']): { persistent: boolean } {
|
|
93
131
|
const isolated = typeof crossOriginIsolated !== 'undefined' && crossOriginIsolated;
|
|
94
|
-
const
|
|
132
|
+
const wantsOpfs = persist === 'opfs' || (persist === undefined && isolated);
|
|
95
133
|
|
|
96
|
-
if (
|
|
134
|
+
if (wantsOpfs && typeof Module.ccall === 'function' && opfsMountSupported(Module)) {
|
|
97
135
|
try {
|
|
98
136
|
const rc = Module.ccall('web_mount_opfs', 'number', ['string'], [WORK_ROOT]);
|
|
99
137
|
if (rc === 0) return { persistent: true };
|
|
100
138
|
} catch {
|
|
101
139
|
/* fall through to in-memory */
|
|
102
140
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
141
|
+
}
|
|
142
|
+
if (persist === 'opfs') {
|
|
143
|
+
console.warn(
|
|
144
|
+
'[rsdkv4] OPFS requested but unavailable (needs a cross-origin isolated page and an Asyncify/JSPI build) — using in-memory WASMFS',
|
|
145
|
+
);
|
|
106
146
|
}
|
|
107
147
|
ensureDir(Module, WORK_ROOT);
|
|
108
148
|
return { persistent: false };
|
|
@@ -118,6 +158,107 @@ function fileExists(Module: any, path: string): boolean {
|
|
|
118
158
|
}
|
|
119
159
|
}
|
|
120
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Wait for Engine::Init, which runs on the engine's first frame (main.cpp's
|
|
163
|
+
* main_loop), not inside callMain. Gives up after ~4s rather than hanging the
|
|
164
|
+
* host: everything still works, the first read just comes back empty.
|
|
165
|
+
*/
|
|
166
|
+
async function waitForEngine(Module: any, timeoutMs = 4000): Promise<boolean> {
|
|
167
|
+
if (typeof Module.web_engine_ready !== 'function') return false;
|
|
168
|
+
const deadline = Date.now() + timeoutMs;
|
|
169
|
+
while (Date.now() < deadline) {
|
|
170
|
+
if (Module.web_engine_ready()) return true;
|
|
171
|
+
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
172
|
+
}
|
|
173
|
+
console.warn('[rsdkv4] engine did not report ready within %dms', timeoutMs);
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
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 {
|
|
194
|
+
return ENGINE_BRIDGES.every((name) => typeof Module?.[name] === 'function');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** RSDKv4's save file, written next to the game data in the working dir. */
|
|
198
|
+
const SAVE_FILE = 'SData.bin';
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* OPFS handle for `<namespace>/`, reached with the plain (async) OPFS API from
|
|
202
|
+
* JS — which, unlike the WASM side's sync access handles, works on the main
|
|
203
|
+
* thread of any page. Returns null when OPFS isn't available at all.
|
|
204
|
+
*/
|
|
205
|
+
async function opfsNamespaceDir(
|
|
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
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Mirror for the engine's save file.
|
|
223
|
+
*
|
|
224
|
+
* The working dir is in-memory whenever the OPFS *mount* is unavailable (which
|
|
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.
|
|
230
|
+
*/
|
|
231
|
+
async function restoreSaveData(Module: any, namespace: string, workDir: string): Promise<void> {
|
|
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
|
+
}
|
|
243
|
+
|
|
244
|
+
async function persistSaveData(Module: any, namespace: string, workDir: string): Promise<void> {
|
|
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
|
+
|
|
121
262
|
/** Delete a file if present; returns true when something was removed. */
|
|
122
263
|
function deleteFileIfExists(Module: any, path: string): boolean {
|
|
123
264
|
if (!fileExists(Module, path)) return false;
|
|
@@ -136,8 +277,68 @@ export interface RsdkDevMenuBridge {
|
|
|
136
277
|
setPaused(paused: boolean): void;
|
|
137
278
|
}
|
|
138
279
|
|
|
139
|
-
|
|
280
|
+
/** One of RSDKv4's four save slots, as the engine's own save-select screen sees it. */
|
|
281
|
+
export interface RsdkSaveSlot {
|
|
282
|
+
/** 0-3. */
|
|
283
|
+
slot: number;
|
|
284
|
+
/** No game stored here — starting it means picking a character first. */
|
|
285
|
+
empty: boolean;
|
|
286
|
+
/** Character index into `players()`: 0 Sonic, 1 Tails, 2 Knuckles, 3 Sonic & Tails. */
|
|
287
|
+
character: number;
|
|
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). */
|
|
298
|
+
export interface RsdkGameOption {
|
|
299
|
+
key: string;
|
|
300
|
+
label: string;
|
|
301
|
+
value: number;
|
|
302
|
+
type: 'boolean' | 'enum';
|
|
303
|
+
/** Choice labels, for `type: 'enum'` (item box sets). */
|
|
304
|
+
values?: string[];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 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
|
+
* menu does — see WebGame.cpp.
|
|
311
|
+
*/
|
|
312
|
+
export interface RsdkGameBridge {
|
|
313
|
+
/** 1 = Sonic 1, 2 = Sonic 2, 0 = unrecognised pack. */
|
|
314
|
+
type(): number;
|
|
315
|
+
/** Playable characters from the pack's GameConfig, in engine order. */
|
|
316
|
+
players(): string[];
|
|
317
|
+
saveSlots(): RsdkSaveSlot[];
|
|
318
|
+
/**
|
|
319
|
+
* Start a game the way the native menu would: a slot with data continues it,
|
|
320
|
+
* an empty slot starts a new game as `player`, and `slot: null` plays without
|
|
321
|
+
* saving. Resumes the engine if the SDK had it paused.
|
|
322
|
+
*/
|
|
323
|
+
start(slot: number | null, player?: number): void;
|
|
324
|
+
/** Wipe a slot back to "NEW GAME". */
|
|
325
|
+
deleteSave(slot: number): void;
|
|
326
|
+
options(): RsdkGameOption[];
|
|
327
|
+
/** Set an option; written through to the engine and its save file, as the engine does. */
|
|
328
|
+
setOption(key: string, value: number): void;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export type Rsdkv4Instance = Omit<EngineInstance, 'pause' | 'resume'> & {
|
|
332
|
+
/**
|
|
333
|
+
* Pause, on behalf of `owner` (default 'host'). The first caller to pause owns
|
|
334
|
+
* it and only that owner's `resume()` lifts it, so a pause overlay can't
|
|
335
|
+
* resume a game a start screen — or the host — had already frozen. Audio stops
|
|
336
|
+
* with the engine.
|
|
337
|
+
*/
|
|
338
|
+
pause(owner?: string): void;
|
|
339
|
+
resume(owner?: string): void;
|
|
140
340
|
devMenu: RsdkDevMenuBridge;
|
|
341
|
+
game: RsdkGameBridge;
|
|
141
342
|
/** True when the working dir is OPFS-backed (persistent) rather than in-memory. */
|
|
142
343
|
persistent: boolean;
|
|
143
344
|
/** Relative storage namespace used under /data (e.g. "sonic1", "sonic2"). */
|
|
@@ -159,39 +360,150 @@ export type Rsdkv4LoadConfig = EngineConfig & {
|
|
|
159
360
|
* Examples: "sonic1", "sonic2", "my-pack/v1".
|
|
160
361
|
*/
|
|
161
362
|
storageNamespace?: string;
|
|
363
|
+
/** Deprecated alias for `canvasEl`, kept for hosts written against 0.0.x. */
|
|
364
|
+
canvas?: HTMLCanvasElement;
|
|
162
365
|
};
|
|
163
366
|
|
|
164
367
|
/** Boot the RSDKv4 engine. */
|
|
165
368
|
export async function load(config: Rsdkv4LoadConfig): Promise<Rsdkv4Instance> {
|
|
166
|
-
const {
|
|
369
|
+
const { assets, onEvent, attachTo } = config;
|
|
167
370
|
const options = config.options as Rsdkv4Options | undefined;
|
|
168
|
-
|
|
371
|
+
|
|
372
|
+
const emit = (event: EngineEvent): void => {
|
|
373
|
+
try {
|
|
374
|
+
onEvent?.(event);
|
|
375
|
+
} catch {
|
|
376
|
+
// A host callback must not break the engine runtime.
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// ----------------------------------------------------------------- render target
|
|
381
|
+
|
|
382
|
+
const providedCanvas = config.canvasEl ?? config.canvas ?? null;
|
|
383
|
+
if (!providedCanvas && !attachTo) {
|
|
384
|
+
throw new Error('rsdkv4: config.canvasEl or config.attachTo is required');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const ownsCanvas = providedCanvas === null;
|
|
388
|
+
const canvas = providedCanvas ?? document.createElement('canvas');
|
|
169
389
|
|
|
170
390
|
// Emscripten's SDL2 port locates the canvas via document.querySelector('#canvas').
|
|
171
|
-
if (canvas.id !==
|
|
391
|
+
if (canvas.id !== CANVAS_ID) {
|
|
392
|
+
if (canvas.id) {
|
|
393
|
+
console.warn(
|
|
394
|
+
`rsdkv4: renaming the render target's id from "${canvas.id}" to "${CANVAS_ID}" — SDL looks the canvas up by that id.`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
canvas.id = CANVAS_ID;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (ownsCanvas) {
|
|
401
|
+
canvas.style.display = 'block';
|
|
402
|
+
canvas.style.maxWidth = '100%';
|
|
403
|
+
canvas.style.maxHeight = '100%';
|
|
404
|
+
canvas.style.height = 'auto';
|
|
405
|
+
canvas.classList.add('emscripten');
|
|
406
|
+
|
|
407
|
+
attachTo?.appendChild(canvas);
|
|
408
|
+
}
|
|
172
409
|
|
|
173
|
-
|
|
174
|
-
|
|
410
|
+
// A canvas is not in the tab order and `focus()` on one is a silent no-op
|
|
411
|
+
// until it has a tabindex. -1 makes it focusable without adding a tab stop.
|
|
412
|
+
if (!canvas.hasAttribute('tabindex')) canvas.setAttribute('tabindex', '-1');
|
|
413
|
+
// …and the focus ring would draw a browser-blue box around the picture.
|
|
414
|
+
canvas.style.outline = 'none';
|
|
415
|
+
|
|
416
|
+
const swallowContextMenu = (event: Event): void => event.preventDefault();
|
|
417
|
+
canvas.addEventListener('contextmenu', swallowContextMenu);
|
|
418
|
+
|
|
419
|
+
const focusCanvas = (): void => {
|
|
420
|
+
if (!document.hasFocus()) window.focus();
|
|
421
|
+
canvas.focus({ preventScroll: true });
|
|
175
422
|
};
|
|
176
423
|
|
|
424
|
+
const onPointerDown = (): void => focusCanvas();
|
|
425
|
+
canvas.addEventListener('pointerdown', onPointerDown);
|
|
426
|
+
|
|
427
|
+
// ----------------------------------------------------------------- sizing
|
|
428
|
+
|
|
429
|
+
/** The box the picture has to fit inside, in CSS pixels. */
|
|
430
|
+
const boxElement = ownsCanvas && attachTo ? attachTo : canvas;
|
|
431
|
+
|
|
432
|
+
const fitPicture = (): void => {
|
|
433
|
+
if (!ownsCanvas) return;
|
|
434
|
+
const box = boxElement.getBoundingClientRect();
|
|
435
|
+
if (box.width < 1 || box.height < 1) return;
|
|
436
|
+
|
|
437
|
+
const bw = manifest.video.baseWidth;
|
|
438
|
+
const bh = manifest.video.baseHeight;
|
|
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
|
+
};
|
|
443
|
+
|
|
444
|
+
fitPicture();
|
|
445
|
+
|
|
446
|
+
const onResize = (): void => fitPicture();
|
|
447
|
+
window.addEventListener('resize', onResize);
|
|
448
|
+
const boxObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize);
|
|
449
|
+
boxObserver?.observe(boxElement);
|
|
450
|
+
|
|
451
|
+
// Fullscreen transitions need a refit too.
|
|
452
|
+
const onFullscreenChange = (): void => {
|
|
453
|
+
fitPicture();
|
|
454
|
+
requestAnimationFrame(fitPicture);
|
|
455
|
+
};
|
|
456
|
+
document.addEventListener('fullscreenchange', onFullscreenChange);
|
|
457
|
+
|
|
458
|
+
// ----------------------------------------------------------------- emscripten
|
|
459
|
+
|
|
177
460
|
const jsUrl = config.jsUrl ?? new URL('./rsdkv4.js', import.meta.url).href;
|
|
178
461
|
const wasmUrl = config.wasmUrl ?? new URL('./rsdkv4.wasm', import.meta.url).href;
|
|
179
462
|
|
|
180
463
|
// RSDKv4 keyboard bindings are the shared input script's default preset.
|
|
181
464
|
if (typeof window !== 'undefined') (window as any).__gamepadKeyMap = manifest.input;
|
|
182
465
|
|
|
183
|
-
const
|
|
184
|
-
|
|
466
|
+
const instantiate = async (bust?: string): Promise<any> => {
|
|
467
|
+
const withBust = (url: string): string => {
|
|
468
|
+
if (!bust) return url;
|
|
469
|
+
const parsed = new URL(url, location.href);
|
|
470
|
+
parsed.searchParams.set('_', bust);
|
|
471
|
+
return parsed.href;
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const mod: any = await import(/* @vite-ignore */ withBust(jsUrl));
|
|
475
|
+
return mod.default({
|
|
476
|
+
canvas,
|
|
477
|
+
noInitialRun: true, // built with -sINVOKE_RUN=0; we mount data before main()
|
|
478
|
+
locateFile: (path: string) => (path.endsWith('.wasm') ? withBust(wasmUrl) : path),
|
|
479
|
+
print: (...a: unknown[]) => console.log('[rsdkv4]', ...a),
|
|
480
|
+
printErr: (...a: unknown[]) => console.error('[rsdkv4]', ...a),
|
|
481
|
+
onAbort: (reason: unknown) =>
|
|
482
|
+
emit({ type: 'error', error: new Error(`rsdkv4 aborted: ${reason}`) }),
|
|
483
|
+
});
|
|
484
|
+
};
|
|
185
485
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
486
|
+
let Module: any = await instantiate();
|
|
487
|
+
|
|
488
|
+
// Self-heal a stale engine.
|
|
489
|
+
//
|
|
490
|
+
// rsdkv4.js/.wasm are big and served by most static hosts with nothing but
|
|
491
|
+
// Last-Modified, so a browser will happily keep a build for hours. The
|
|
492
|
+
// symptom is nasty: fresh SDK code against an old engine, where the bridges
|
|
493
|
+
// this SDK calls simply don't exist and every getter quietly answers "empty" —
|
|
494
|
+
// no save slots, no characters, an empty pause menu. Rather than leave that to
|
|
495
|
+
// the reader's cache hygiene, notice it and fetch once past the cache.
|
|
496
|
+
if (!engineBridgeComplete(Module)) {
|
|
497
|
+
console.warn(
|
|
498
|
+
'[rsdkv4] the engine that loaded is missing bridges this SDK needs — refetching past the HTTP cache',
|
|
499
|
+
);
|
|
500
|
+
Module = await instantiate(String(Date.now()));
|
|
501
|
+
if (!engineBridgeComplete(Module)) {
|
|
502
|
+
console.error(
|
|
503
|
+
'[rsdkv4] still missing engine bridges after a cache-busting reload — rsdkv4.wasm is genuinely older than this SDK; rebuild it (`make build-wasm`)',
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
195
507
|
|
|
196
508
|
// Mount the working dir first, so we can see what's already persisted (OPFS)
|
|
197
509
|
// before deciding whether to pull assets.
|
|
@@ -234,10 +546,102 @@ export async function load(config: Rsdkv4LoadConfig): Promise<Rsdkv4Instance> {
|
|
|
234
546
|
}
|
|
235
547
|
// else: keep the persisted settings.ini
|
|
236
548
|
|
|
549
|
+
// Save data. `persist: null` means the host wants nothing kept, so honour it;
|
|
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);
|
|
553
|
+
|
|
237
554
|
Module.FS.chdir(workDir);
|
|
238
555
|
|
|
239
|
-
|
|
556
|
+
/**
|
|
557
|
+
* Pause state, with an owner.
|
|
558
|
+
*
|
|
559
|
+
* `masterPaused` only freezes the logic loop — the audio callback keeps
|
|
560
|
+
* mixing, so a paused engine went on playing its music behind whatever the
|
|
561
|
+
* host put on top. Pausing therefore has to stop the sound too.
|
|
562
|
+
*
|
|
563
|
+
* The owner matters because pause/resume has several callers: a pause overlay,
|
|
564
|
+
* a start screen, the host itself. Closing an overlay must not resume a game
|
|
565
|
+
* something else had already frozen, so the *first* pause claims ownership and
|
|
566
|
+
* only that owner's `resume()` lifts it. Owner strings are the caller's to
|
|
567
|
+
* choose; the SDK's own default is 'host'.
|
|
568
|
+
*/
|
|
569
|
+
type PauseOwner = string;
|
|
570
|
+
let pauseOwner: PauseOwner | null = null;
|
|
571
|
+
|
|
572
|
+
const applyPaused = (paused: boolean) => {
|
|
240
573
|
if (typeof Module.web_devmenu_set_paused === 'function') Module.web_devmenu_set_paused(paused);
|
|
574
|
+
if (typeof Module.web_audio_set_paused === 'function') Module.web_audio_set_paused(paused);
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const pauseWith = (owner: PauseOwner) => {
|
|
578
|
+
if (pauseOwner !== null) return; // already paused — whoever got there first keeps the claim
|
|
579
|
+
pauseOwner = owner;
|
|
580
|
+
applyPaused(true);
|
|
581
|
+
// A pause is a natural checkpoint for the save mirror.
|
|
582
|
+
if (mirrorSaves) void persistSaveData(Module, storageNamespace, workDir);
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const resumeFrom = (owner: PauseOwner) => {
|
|
586
|
+
if (pauseOwner !== owner) return; // someone else's pause — leave it alone
|
|
587
|
+
pauseOwner = null;
|
|
588
|
+
applyPaused(false);
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
/** Legacy shape kept for `devMenu.setPaused` — always acts as the host. */
|
|
592
|
+
const setPaused = (paused: boolean) => {
|
|
593
|
+
if (paused) pauseWith('host');
|
|
594
|
+
else resumeFrom(pauseOwner ?? 'host');
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const stopAudio = () => {
|
|
598
|
+
try {
|
|
599
|
+
Module.web_audio_stop?.();
|
|
600
|
+
} catch {
|
|
601
|
+
/* engine already down */
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Call a `web_*` embind getter and parse its JSON.
|
|
607
|
+
*
|
|
608
|
+
* A missing function means the loaded rsdkv4.wasm predates that bridge — the
|
|
609
|
+
* usual cause is a browser serving a cached build. Say so out loud: silently
|
|
610
|
+
* returning an empty list makes a stale artifact look like an empty save file
|
|
611
|
+
* or a game with no playable characters.
|
|
612
|
+
*/
|
|
613
|
+
const parseJson = <T>(fn: unknown, fallback: T, what: string): T => {
|
|
614
|
+
if (typeof fn !== 'function') {
|
|
615
|
+
console.error(
|
|
616
|
+
`[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.`,
|
|
617
|
+
);
|
|
618
|
+
return fallback;
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
return JSON.parse((fn as () => string)());
|
|
622
|
+
} catch (e) {
|
|
623
|
+
console.error(`[rsdkv4] ${what} failed`, e);
|
|
624
|
+
return fallback;
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
const game: RsdkGameBridge = {
|
|
629
|
+
type: () => (typeof Module.web_game_type === 'function' ? Module.web_game_type() : 0),
|
|
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
|
+
},
|
|
241
645
|
};
|
|
242
646
|
|
|
243
647
|
const devMenu: RsdkDevMenuBridge = {
|
|
@@ -261,25 +665,55 @@ export async function load(config: Rsdkv4LoadConfig): Promise<Rsdkv4Instance> {
|
|
|
261
665
|
// Run main() from the OPFS/WASMFS working dir. simulate_infinite_loop schedules
|
|
262
666
|
// the rAF loop and returns via a benign unwind Emscripten swallows.
|
|
263
667
|
Module.callMain(['UsingCWD']);
|
|
668
|
+
|
|
669
|
+
// callMain only *schedules* the loop: Engine::Init runs on the first frame, and
|
|
670
|
+
// until it has, there is no game config, stage list or saveRAM to read. Waiting
|
|
671
|
+
// here means a host can call devMenu/game getters the moment load() resolves.
|
|
672
|
+
await waitForEngine(Module);
|
|
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
|
+
|
|
264
682
|
emit({ type: 'ready' });
|
|
265
683
|
|
|
266
684
|
return {
|
|
267
685
|
start() {},
|
|
268
|
-
pause
|
|
269
|
-
|
|
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); },
|
|
270
689
|
reset() {
|
|
271
690
|
throw new Error('rsdkv4: reset() is not supported — destroy() and load() again');
|
|
272
691
|
},
|
|
273
|
-
setInput(preset) {
|
|
692
|
+
setInput(preset: InputPreset | KeyMap) {
|
|
274
693
|
if (typeof window !== 'undefined') {
|
|
275
694
|
(window as any).__gamepadKeyMap = preset ?? manifest.input;
|
|
276
695
|
}
|
|
277
696
|
},
|
|
278
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
|
+
|
|
279
702
|
try { Module.pauseMainLoop?.(); } catch { /* noop */ }
|
|
280
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();
|
|
281
714
|
},
|
|
282
715
|
devMenu,
|
|
716
|
+
game,
|
|
283
717
|
persistent,
|
|
284
718
|
storageNamespace,
|
|
285
719
|
purgeStorage() {
|