@wasm-gaming/engine-specs 0.2.3 → 0.2.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/demo/README.md +75 -22
- package/demo/components/drop-area.html +54 -7
- package/demo/components/esc-menu.html +540 -0
- package/demo/components/launcher.html +46 -24
- package/demo/components/sdk-info.html +14 -2
- package/demo/demo.css +6 -1
- package/demo/demo.js +579 -121
- package/demo/demo.sdk.js +4 -1
- package/demo/index.html +58 -1
- package/package.json +1 -1
- package/demo/components/file-info.html +0 -66
package/demo/demo.js
CHANGED
|
@@ -1,55 +1,57 @@
|
|
|
1
|
-
import { getSdk } from "./sdk.js";
|
|
2
1
|
import { checksumAlgorithms, computeChecksums } from "./checksums.js";
|
|
3
|
-
import { loadStoredAsset, saveStoredAsset } from "./storage.js";
|
|
4
|
-
import { $, $create, Component79 } from "https://jgermade.github.io/jq79/jq79.js";
|
|
5
|
-
|
|
6
|
-
// Safe URL parameter resolution from script URL & page URL
|
|
7
|
-
const pageUrl = new URL(location.href);
|
|
8
|
-
const scriptUrl = new URL(import.meta.url, location.href);
|
|
9
|
-
const biosParam = scriptUrl.searchParams.get("bios") ?? pageUrl.searchParams.get("bios");
|
|
10
|
-
|
|
11
|
-
// Fetch component HTML templates using import.meta.url for resilient subpath resolution
|
|
12
|
-
const fetchComponent = (path) => Component79.fetch(new URL(path, import.meta.url).href);
|
|
13
|
-
|
|
14
|
-
// Fetch all components at once to leverage http2 request multiplexing.
|
|
15
|
-
const [CLauncher, CDropArea, CSdkInfo, CFileInfo] = await Promise.all([
|
|
16
|
-
fetchComponent("./components/launcher.html"),
|
|
17
|
-
fetchComponent("./components/drop-area.html"),
|
|
18
|
-
fetchComponent("./components/sdk-info.html"),
|
|
19
|
-
fetchComponent("./components/file-info.html"),
|
|
20
|
-
]);
|
|
21
|
-
|
|
22
|
-
const mainEl = $("main");
|
|
23
|
-
if (!(mainEl instanceof HTMLElement)) {
|
|
24
|
-
throw new Error("Demo page boot failed: missing required elements.");
|
|
25
|
-
}
|
|
2
|
+
import { loadStoredAsset, saveStoredAsset, clearStoredAsset } from "./storage.js";
|
|
26
3
|
|
|
27
|
-
|
|
4
|
+
let isInitialized = false;
|
|
5
|
+
let currentConfig = {};
|
|
6
|
+
let currentInstance = null;
|
|
28
7
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
8
|
+
const listeners = new Map();
|
|
9
|
+
const loadedFiles = {
|
|
10
|
+
rom: null,
|
|
11
|
+
bios: null,
|
|
12
|
+
};
|
|
32
13
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
14
|
+
const DEFAULT_MESSAGES = {
|
|
15
|
+
// Page / Document
|
|
16
|
+
title: "WASM Gaming Engine Specs Demo",
|
|
36
17
|
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
18
|
+
// Launcher & Status
|
|
19
|
+
launchButton: "Launch Engine",
|
|
20
|
+
launchingStatus: "Launching with",
|
|
21
|
+
statusReady: "Ready to launch!",
|
|
22
|
+
statusSelectRom: "Ready - Please select a ROM file to launch.",
|
|
23
|
+
statusSelectBoth: "Ready - Please select ROM and BIOS files to launch.",
|
|
24
|
+
statusBiosRequired: "BIOS file is required to launch.",
|
|
41
25
|
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
26
|
+
// Asset Drop Zones
|
|
27
|
+
dropRomPrompt: "Drop ROM File",
|
|
28
|
+
dropBiosPromptRequired: "Drop BIOS File (Required)",
|
|
29
|
+
dropBiosPromptOptional: "Drop BIOS File (Optional)",
|
|
30
|
+
romAssetHint: "or choose ROM file from computer",
|
|
31
|
+
biosAssetHintOptional: "optional BIOS firmware file",
|
|
32
|
+
biosAssetHintRequired: "required BIOS firmware file",
|
|
33
|
+
pickRomButton: "Pick ROM",
|
|
34
|
+
pickBiosButton: "Pick BIOS",
|
|
35
|
+
changeFileButton: "Change File",
|
|
36
|
+
removeFileButton: "Remove",
|
|
37
|
+
selectedPrefix: "Selected: ",
|
|
47
38
|
|
|
48
|
-
|
|
49
|
-
|
|
39
|
+
// Metadata
|
|
40
|
+
versionPrefix: "Version ",
|
|
41
|
+
assetKeyPrefix: "Asset key: ",
|
|
50
42
|
|
|
51
|
-
|
|
52
|
-
|
|
43
|
+
// ESC Menu
|
|
44
|
+
escMenuTitle: "Settings",
|
|
45
|
+
escMenuBadge: "PAUSED",
|
|
46
|
+
escMenuResume: "Resume",
|
|
47
|
+
escMenuRestoreDefaults: "Restore defaults",
|
|
48
|
+
escMenuReset: "Reset game",
|
|
49
|
+
escMenuSaveState: "Save State",
|
|
50
|
+
escMenuLoadState: "Load State",
|
|
51
|
+
escMenuExit: "Exit to Launcher",
|
|
52
|
+
escMenuNeedsResetTag: "needs reset",
|
|
53
|
+
escMenuKeysHint: "↑ ↓ select · ← → change · Enter apply · Esc close",
|
|
54
|
+
};
|
|
53
55
|
|
|
54
56
|
async function toArrayBuffer(input) {
|
|
55
57
|
if (!input) return null;
|
|
@@ -60,90 +62,546 @@ async function toArrayBuffer(input) {
|
|
|
60
62
|
throw new TypeError("Provided input is not a File, Blob, or ArrayBuffer.");
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
mainEl.classList.add("running");
|
|
68
|
-
|
|
69
|
-
const fileInfoEl = $create("section", { className: "file-info" });
|
|
70
|
-
const runtimeEl = $create("div", {
|
|
71
|
-
className: "runtime",
|
|
72
|
-
children: [fileInfoEl],
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
mainEl.replaceChildren(runtimeEl);
|
|
76
|
-
|
|
77
|
-
const primaryFile = rom || bios;
|
|
78
|
-
const fileInfoProps = {
|
|
79
|
-
fileName: primaryFile.name,
|
|
80
|
-
size: `${primaryFile.size.toLocaleString()} bytes`,
|
|
81
|
-
};
|
|
82
|
-
const fileInfoPlaceholder = CFileInfo
|
|
83
|
-
.renderShadow({
|
|
84
|
-
...fileInfoProps,
|
|
85
|
-
checksums: checksumAlgorithms.map((label) => ({ label, value: "computing…" })),
|
|
86
|
-
})
|
|
87
|
-
.mount(fileInfoEl);
|
|
88
|
-
|
|
89
|
-
const assets = {};
|
|
90
|
-
if (romBytes && romSpec) {
|
|
91
|
-
assets[romSpec.key] = romBytes;
|
|
92
|
-
}
|
|
93
|
-
if (biosBytes && biosSpec) {
|
|
94
|
-
assets[biosSpec.key] = biosBytes;
|
|
65
|
+
function resolveBiosConfig(biosOption) {
|
|
66
|
+
if (typeof location === "undefined") {
|
|
67
|
+
return { showBIOS: false, requiresBIOS: false };
|
|
95
68
|
}
|
|
96
|
-
|
|
97
|
-
|
|
69
|
+
const pageUrl = new URL(location.href);
|
|
70
|
+
const scriptUrl = new URL(import.meta.url, location.href);
|
|
71
|
+
const biosParam = scriptUrl.searchParams.get("bios") ?? pageUrl.searchParams.get("bios");
|
|
72
|
+
|
|
73
|
+
if (biosOption !== undefined) {
|
|
74
|
+
if (biosOption === "required") {
|
|
75
|
+
return { showBIOS: true, requiresBIOS: true };
|
|
76
|
+
}
|
|
77
|
+
if (biosOption === "optional" || biosOption === true || biosOption === "true") {
|
|
78
|
+
return { showBIOS: true, requiresBIOS: false };
|
|
79
|
+
}
|
|
80
|
+
return { showBIOS: false, requiresBIOS: false };
|
|
98
81
|
}
|
|
99
82
|
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
83
|
+
const show = biosParam === "true" || biosParam === "optional" || biosParam === "required";
|
|
84
|
+
const req = biosParam === "required";
|
|
85
|
+
return { showBIOS: show, requiresBIOS: req };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getDefaultOptionGroups() {
|
|
89
|
+
return [
|
|
90
|
+
{
|
|
91
|
+
id: "video",
|
|
92
|
+
label: "Video",
|
|
93
|
+
options: [
|
|
94
|
+
{
|
|
95
|
+
key: "filterMode",
|
|
96
|
+
label: "Image filtering",
|
|
97
|
+
description: "Texture sampling used when scaling emulated output. Nearest keeps pixel art crisp; linear softens it.",
|
|
98
|
+
type: "enum",
|
|
99
|
+
value: "Nearest",
|
|
100
|
+
values: [
|
|
101
|
+
{ value: "Nearest", label: "Nearest" },
|
|
102
|
+
{ value: "Linear", label: "Linear" },
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
key: "aspectRatio",
|
|
107
|
+
label: "Display aspect ratio",
|
|
108
|
+
description: "Output display aspect ratio for emulated console screen.",
|
|
109
|
+
type: "enum",
|
|
110
|
+
value: "4:3",
|
|
111
|
+
values: [
|
|
112
|
+
{ value: "4:3", label: "4:3" },
|
|
113
|
+
{ value: "8:7", label: "8:7" },
|
|
114
|
+
{ value: "1:1", label: "1:1" },
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
key: "autoPrescale",
|
|
119
|
+
label: "Prescaling",
|
|
120
|
+
description: "Integer-prescale the frame before filtering to sharpen upscaled output.",
|
|
121
|
+
type: "boolean",
|
|
122
|
+
value: true,
|
|
123
|
+
requiresReset: false,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
106
126
|
},
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
127
|
+
{
|
|
128
|
+
id: "audio",
|
|
129
|
+
label: "Audio",
|
|
130
|
+
options: [
|
|
131
|
+
{
|
|
132
|
+
key: "lowPassFilter",
|
|
133
|
+
label: "Audio low-pass filter",
|
|
134
|
+
description: "Emulate the console's hardware audio low-pass filter.",
|
|
135
|
+
type: "boolean",
|
|
136
|
+
value: false,
|
|
137
|
+
},
|
|
138
|
+
],
|
|
111
139
|
},
|
|
112
|
-
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const demo = {
|
|
144
|
+
on(event, fn) {
|
|
145
|
+
if (!listeners.has(event)) {
|
|
146
|
+
listeners.set(event, []);
|
|
147
|
+
}
|
|
148
|
+
listeners.get(event).push(fn);
|
|
149
|
+
return demo;
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
off(event, fn) {
|
|
153
|
+
if (!listeners.has(event)) return demo;
|
|
154
|
+
const list = listeners.get(event).filter((cb) => cb !== fn);
|
|
155
|
+
listeners.set(event, list);
|
|
156
|
+
return demo;
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async emit(event, payload) {
|
|
160
|
+
if (!listeners.has(event)) return [];
|
|
161
|
+
const callbacks = listeners.get(event);
|
|
162
|
+
const results = [];
|
|
163
|
+
for (const cb of callbacks) {
|
|
164
|
+
try {
|
|
165
|
+
const res = await cb(payload);
|
|
166
|
+
results.push(res);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.error(`[Demo] Event '${event}' handler error:`, err);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return results;
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async loadToMEMFS(key, target) {
|
|
175
|
+
if (!key) {
|
|
176
|
+
const assets = {};
|
|
177
|
+
for (const [k, file] of Object.entries(loadedFiles)) {
|
|
178
|
+
if (file) {
|
|
179
|
+
assets[k] = await toArrayBuffer(file);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return assets;
|
|
183
|
+
}
|
|
184
|
+
const file = loadedFiles[key];
|
|
185
|
+
if (!file) return null;
|
|
186
|
+
const buffer = await toArrayBuffer(file);
|
|
187
|
+
if (target && typeof target === "object") {
|
|
188
|
+
if (typeof target.set === "function") {
|
|
189
|
+
target.set(key, buffer);
|
|
190
|
+
} else if (typeof target.FS?.writeFile === "function") {
|
|
191
|
+
target.FS.writeFile(key, new Uint8Array(buffer));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return buffer;
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
getFile(key) {
|
|
198
|
+
if (!key) return loadedFiles;
|
|
199
|
+
return loadedFiles[key] || null;
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
hasFile(key) {
|
|
203
|
+
if (!key) return Boolean(loadedFiles.rom || loadedFiles.bios);
|
|
204
|
+
return Boolean(loadedFiles[key]);
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
isLoaded(key) {
|
|
208
|
+
return demo.hasFile(key);
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
bindInstance(instance) {
|
|
212
|
+
currentInstance = instance;
|
|
213
|
+
return demo;
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
setInstance(instance) {
|
|
217
|
+
return demo.bindInstance(instance);
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
init(config = {}) {
|
|
221
|
+
isInitialized = true;
|
|
222
|
+
currentConfig = config;
|
|
223
|
+
|
|
224
|
+
const boot = async () => {
|
|
225
|
+
if (typeof window === "undefined" && !config.target) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
let jq;
|
|
229
|
+
if (config.jq79) {
|
|
230
|
+
jq = config.jq79;
|
|
231
|
+
} else {
|
|
232
|
+
jq = await import("https://jgermade.github.io/jq79/jq79.js");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const { $, $create, Component79 } = jq;
|
|
236
|
+
const fetchComponent = (path) => Component79.fetch(new URL(path, import.meta.url).href);
|
|
237
|
+
|
|
238
|
+
// Fetch components
|
|
239
|
+
const [CLauncher, CDropArea, CSdkInfo, CEscMenu] = await Promise.all([
|
|
240
|
+
fetchComponent("./components/launcher.html"),
|
|
241
|
+
fetchComponent("./components/drop-area.html"),
|
|
242
|
+
fetchComponent("./components/sdk-info.html"),
|
|
243
|
+
fetchComponent("./components/esc-menu.html"),
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
let mainEl;
|
|
247
|
+
if (config.target instanceof HTMLElement) {
|
|
248
|
+
mainEl = config.target;
|
|
249
|
+
} else if (typeof config.target === "string") {
|
|
250
|
+
mainEl = document.querySelector(config.target);
|
|
251
|
+
} else {
|
|
252
|
+
mainEl = $("main") || document.body;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!mainEl) {
|
|
256
|
+
throw new Error("Demo page boot failed: missing target element.");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const messages = { ...DEFAULT_MESSAGES, ...config.messages };
|
|
260
|
+
const escMenuConfig = config.escMenu || {};
|
|
261
|
+
|
|
262
|
+
if (typeof document !== "undefined") {
|
|
263
|
+
if (config.messages?.title) {
|
|
264
|
+
document.title = config.messages.title;
|
|
265
|
+
} else if (config.manifest?.name) {
|
|
266
|
+
document.title = `${config.manifest.name} - Demo`;
|
|
267
|
+
} else if (config.sdkInfo?.name) {
|
|
268
|
+
document.title = `${config.sdkInfo.name} - Demo`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const { showBIOS, requiresBIOS } = resolveBiosConfig(config.bios);
|
|
113
273
|
|
|
114
|
-
|
|
274
|
+
const [storedRom, storedBios] = await Promise.all([
|
|
275
|
+
loadStoredAsset("rom"),
|
|
276
|
+
showBIOS ? loadStoredAsset("bios") : null,
|
|
277
|
+
]);
|
|
115
278
|
|
|
116
|
-
|
|
117
|
-
|
|
279
|
+
loadedFiles.rom = storedRom;
|
|
280
|
+
loadedFiles.bios = storedBios;
|
|
118
281
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
282
|
+
const manifestAssets = config.manifest?.assets || config.sdkInfo?.assets;
|
|
283
|
+
const romSpec = manifestAssets?.find((a) => a.key === "rom") ?? manifestAssets?.[0];
|
|
284
|
+
const biosSpec = manifestAssets?.find((a) => a.key === "bios") ?? manifestAssets?.[1];
|
|
285
|
+
|
|
286
|
+
const romAccept = config.romAccept ?? config.accept?.rom ?? (romSpec?.accept ? romSpec.accept.join(",") : ".rom,.bin,.zip,.dat");
|
|
287
|
+
const romAssetHint = config.messages?.romAssetHint ?? (romSpec?.accept ? `Supported: ${romSpec.accept.join(", ")}` : messages.romAssetHint);
|
|
288
|
+
|
|
289
|
+
const biosAccept = config.biosAccept ?? config.accept?.bios ?? (biosSpec?.accept ? biosSpec.accept.join(",") : ".bin,.rom,.sys");
|
|
290
|
+
const biosAssetHint = config.messages?.biosAssetHint ?? (biosSpec?.description ?? (biosSpec?.accept ? `Supported: ${biosSpec.accept.join(", ")}` : (requiresBIOS ? messages.biosAssetHintRequired : messages.biosAssetHintOptional)));
|
|
291
|
+
|
|
292
|
+
const sdkInfo = config.sdkInfo || (config.manifest ? {
|
|
293
|
+
name: config.manifest.name,
|
|
294
|
+
id: config.manifest.id,
|
|
295
|
+
description: config.manifest.description,
|
|
296
|
+
version: config.manifest.version,
|
|
297
|
+
baseWidth: config.manifest.video?.baseWidth ?? 320,
|
|
298
|
+
baseHeight: config.manifest.video?.baseHeight ?? 240,
|
|
299
|
+
assetKey: romSpec?.key ?? "rom",
|
|
300
|
+
} : {
|
|
301
|
+
name: messages.title || "WASM Gaming Engine Demo",
|
|
302
|
+
id: "engine-demo",
|
|
303
|
+
description: "Standalone Web Interface",
|
|
304
|
+
version: "1.0.0",
|
|
305
|
+
baseWidth: 320,
|
|
306
|
+
baseHeight: 240,
|
|
307
|
+
assetKey: "rom",
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const handleSaveRom = (file) => {
|
|
311
|
+
loadedFiles.rom = file;
|
|
312
|
+
saveStoredAsset("rom", file);
|
|
313
|
+
const payload = { file, key: "rom", type: "rom", action: "save" };
|
|
314
|
+
demo.emit("rom:save", payload);
|
|
315
|
+
demo.emit("save:rom", payload);
|
|
316
|
+
demo.emit("rom:select", payload);
|
|
317
|
+
demo.emit("file", payload);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const handleRemoveRom = () => {
|
|
321
|
+
loadedFiles.rom = null;
|
|
322
|
+
clearStoredAsset("rom");
|
|
323
|
+
const payload = { file: null, key: "rom", type: "rom", action: "remove" };
|
|
324
|
+
demo.emit("rom:remove", payload);
|
|
325
|
+
demo.emit("remove:rom", payload);
|
|
326
|
+
demo.emit("rom:clear", payload);
|
|
327
|
+
demo.emit("file", payload);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const handleSaveBios = (file) => {
|
|
331
|
+
loadedFiles.bios = file;
|
|
332
|
+
saveStoredAsset("bios", file);
|
|
333
|
+
const payload = { file, key: "bios", type: "bios", action: "save" };
|
|
334
|
+
demo.emit("bios:save", payload);
|
|
335
|
+
demo.emit("save:bios", payload);
|
|
336
|
+
demo.emit("bios:select", payload);
|
|
337
|
+
demo.emit("file", payload);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const handleRemoveBios = () => {
|
|
341
|
+
loadedFiles.bios = null;
|
|
342
|
+
clearStoredAsset("bios");
|
|
343
|
+
const payload = { file: null, key: "bios", type: "bios", action: "remove" };
|
|
344
|
+
demo.emit("bios:remove", payload);
|
|
345
|
+
demo.emit("remove:bios", payload);
|
|
346
|
+
demo.emit("bios:clear", payload);
|
|
347
|
+
demo.emit("file", payload);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
let currentLauncherMount = null;
|
|
351
|
+
|
|
352
|
+
const renderLauncher = () => {
|
|
353
|
+
currentInstance = null;
|
|
354
|
+
if (mainEl.classList?.remove) {
|
|
355
|
+
mainEl.classList.remove("running");
|
|
356
|
+
}
|
|
357
|
+
if (mainEl.replaceChildren) {
|
|
358
|
+
mainEl.replaceChildren();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
currentLauncherMount = CLauncher
|
|
362
|
+
.render({
|
|
363
|
+
CSdkInfo,
|
|
364
|
+
CDropArea,
|
|
365
|
+
sdkInfo,
|
|
366
|
+
initialRomFile: loadedFiles.rom,
|
|
367
|
+
initialBiosFile: loadedFiles.bios,
|
|
368
|
+
romAccept,
|
|
369
|
+
romAssetHint,
|
|
370
|
+
biosAccept,
|
|
371
|
+
biosAssetHint,
|
|
372
|
+
requiresBIOS,
|
|
373
|
+
showBIOS,
|
|
374
|
+
messages,
|
|
375
|
+
})
|
|
376
|
+
.on("save:rom", handleSaveRom)
|
|
377
|
+
.on("remove:rom", handleRemoveRom)
|
|
378
|
+
.on("save:bios", handleSaveBios)
|
|
379
|
+
.on("remove:bios", handleRemoveBios)
|
|
380
|
+
.on("launch", (payload) => bootWithFiles(payload))
|
|
381
|
+
.mount(mainEl);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const bootWithFiles = async ({ rom, bios }) => {
|
|
385
|
+
loadedFiles.rom = rom;
|
|
386
|
+
loadedFiles.bios = bios;
|
|
387
|
+
|
|
388
|
+
mainEl.classList?.add("running");
|
|
389
|
+
|
|
390
|
+
const runtimeEl = $create("div", {
|
|
391
|
+
className: "runtime",
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
mainEl.replaceChildren(runtimeEl);
|
|
395
|
+
|
|
396
|
+
const primaryFile = rom || bios;
|
|
397
|
+
console.log(`[Demo] Loaded file: ${primaryFile?.name} (${primaryFile?.size?.toLocaleString()} bytes)`);
|
|
398
|
+
|
|
399
|
+
if (primaryFile) {
|
|
400
|
+
toArrayBuffer(primaryFile).then((bytes) => {
|
|
401
|
+
if (bytes) {
|
|
402
|
+
computeChecksums(bytes).then((checksums) => {
|
|
403
|
+
const checksumMap = checksums.reduce((acc, c) => ({ ...acc, [c.label]: c.value }), {});
|
|
404
|
+
console.log(`[Demo] Checksums for ${primaryFile.name}:`, checksumMap);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const eventPayload = {
|
|
411
|
+
attachTo: runtimeEl,
|
|
412
|
+
container: runtimeEl,
|
|
413
|
+
target: runtimeEl,
|
|
414
|
+
rom,
|
|
415
|
+
bios,
|
|
416
|
+
fileName: primaryFile?.name,
|
|
417
|
+
byteLength: primaryFile?.size,
|
|
418
|
+
files: { rom, bios },
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const results = await demo.emit("launch", eventPayload);
|
|
422
|
+
const fileResults = await demo.emit("file", { action: "launch", ...eventPayload });
|
|
423
|
+
|
|
424
|
+
const allResults = [...results, ...fileResults];
|
|
425
|
+
for (const res of allResults) {
|
|
426
|
+
if (res && typeof res === "object" && (typeof res.start === "function" || typeof res.pause === "function" || typeof res.destroy === "function")) {
|
|
427
|
+
currentInstance = res;
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (escMenuConfig.enabled !== false) {
|
|
433
|
+
let isMenuOpen = false;
|
|
434
|
+
let escMenuMount = null;
|
|
435
|
+
|
|
436
|
+
const escContainer = $create("div", { className: "esc-container" });
|
|
437
|
+
runtimeEl.appendChild(escContainer);
|
|
438
|
+
|
|
439
|
+
const optionGroups = escMenuConfig.optionGroups ||
|
|
440
|
+
(escMenuConfig.options ? [{ id: "settings", label: "Settings", options: escMenuConfig.options }] : getDefaultOptionGroups());
|
|
441
|
+
|
|
442
|
+
const exitToLauncher = () => {
|
|
443
|
+
if (typeof window !== "undefined") {
|
|
444
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
445
|
+
}
|
|
446
|
+
if (escMenuMount) {
|
|
447
|
+
escMenuMount.destroy();
|
|
448
|
+
escMenuMount = null;
|
|
449
|
+
}
|
|
450
|
+
if (currentInstance && typeof currentInstance.destroy === "function") {
|
|
451
|
+
currentInstance.destroy();
|
|
452
|
+
}
|
|
453
|
+
currentInstance = null;
|
|
454
|
+
renderLauncher();
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const updateEscMenu = () => {
|
|
458
|
+
if (escMenuMount) {
|
|
459
|
+
escMenuMount.destroy();
|
|
460
|
+
}
|
|
461
|
+
escMenuMount = CEscMenu
|
|
462
|
+
.renderShadow({
|
|
463
|
+
open: isMenuOpen,
|
|
464
|
+
title: escMenuConfig.title || config.messages?.escMenuTitle || messages.escMenuTitle,
|
|
465
|
+
subtitle: primaryFile?.name || "Game",
|
|
466
|
+
hasSaveStates: Boolean(config.manifest?.capabilities?.saveStates || escMenuConfig.onSaveState || currentInstance?.saveState),
|
|
467
|
+
optionGroups,
|
|
468
|
+
customItems: escMenuConfig.items || [],
|
|
469
|
+
messages,
|
|
470
|
+
})
|
|
471
|
+
.on("close", () => closeMenu())
|
|
472
|
+
.on("reset", () => {
|
|
473
|
+
closeMenu();
|
|
474
|
+
demo.emit("reset", { instance: currentInstance });
|
|
475
|
+
if (typeof escMenuConfig.onReset === "function") {
|
|
476
|
+
escMenuConfig.onReset(currentInstance);
|
|
477
|
+
} else if (currentInstance && typeof currentInstance.reset === "function") {
|
|
478
|
+
currentInstance.reset();
|
|
479
|
+
}
|
|
480
|
+
})
|
|
481
|
+
.on("restore-defaults", () => {
|
|
482
|
+
const defaults = getDefaultOptionGroups();
|
|
483
|
+
for (const group of optionGroups) {
|
|
484
|
+
const defaultGroup = defaults.find((g) => g.id === group.id);
|
|
485
|
+
if (!defaultGroup) continue;
|
|
486
|
+
for (const opt of group.options) {
|
|
487
|
+
const defaultOpt = defaultGroup.options.find((o) => o.key === opt.key);
|
|
488
|
+
if (defaultOpt) {
|
|
489
|
+
opt.value = defaultOpt.value;
|
|
490
|
+
if (currentInstance && typeof currentInstance.setOption === "function") {
|
|
491
|
+
currentInstance.setOption(opt.key, opt.value);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
updateEscMenu();
|
|
497
|
+
demo.emit("restoreDefaults", { instance: currentInstance });
|
|
498
|
+
if (typeof escMenuConfig.onRestoreDefaults === "function") {
|
|
499
|
+
escMenuConfig.onRestoreDefaults(currentInstance);
|
|
500
|
+
}
|
|
501
|
+
})
|
|
502
|
+
.on("option-change", ({ key, value, option }) => {
|
|
503
|
+
if (currentInstance && typeof currentInstance.setOption === "function") {
|
|
504
|
+
currentInstance.setOption(key, value);
|
|
505
|
+
}
|
|
506
|
+
demo.emit("option", { key, value, option, instance: currentInstance });
|
|
507
|
+
if (typeof escMenuConfig.onOptionChange === "function") {
|
|
508
|
+
escMenuConfig.onOptionChange(key, value, option, currentInstance);
|
|
509
|
+
}
|
|
510
|
+
})
|
|
511
|
+
.on("save-state", () => {
|
|
512
|
+
closeMenu();
|
|
513
|
+
demo.emit("saveState", { instance: currentInstance });
|
|
514
|
+
if (typeof escMenuConfig.onSaveState === "function") {
|
|
515
|
+
escMenuConfig.onSaveState(currentInstance);
|
|
516
|
+
} else if (currentInstance && typeof currentInstance.saveState === "function") {
|
|
517
|
+
currentInstance.saveState();
|
|
518
|
+
}
|
|
519
|
+
})
|
|
520
|
+
.on("load-state", () => {
|
|
521
|
+
closeMenu();
|
|
522
|
+
demo.emit("loadState", { instance: currentInstance });
|
|
523
|
+
if (typeof escMenuConfig.onLoadState === "function") {
|
|
524
|
+
escMenuConfig.onLoadState(currentInstance);
|
|
525
|
+
} else if (currentInstance && typeof currentInstance.loadState === "function") {
|
|
526
|
+
currentInstance.loadState();
|
|
527
|
+
}
|
|
528
|
+
})
|
|
529
|
+
.on("exit", () => {
|
|
530
|
+
demo.emit("exit", { instance: currentInstance });
|
|
531
|
+
if (typeof escMenuConfig.onExit === "function") {
|
|
532
|
+
escMenuConfig.onExit(currentInstance);
|
|
533
|
+
}
|
|
534
|
+
exitToLauncher();
|
|
535
|
+
})
|
|
536
|
+
.on("custom-action", (item) => {
|
|
537
|
+
closeMenu();
|
|
538
|
+
demo.emit("customAction", { item, instance: currentInstance });
|
|
539
|
+
if (typeof item?.action === "function") {
|
|
540
|
+
item.action(currentInstance);
|
|
541
|
+
}
|
|
542
|
+
})
|
|
543
|
+
.mount(escContainer);
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const openMenu = () => {
|
|
547
|
+
if (isMenuOpen) return;
|
|
548
|
+
isMenuOpen = true;
|
|
549
|
+
demo.emit("pause", { instance: currentInstance });
|
|
550
|
+
demo.emit("esc", { open: true, instance: currentInstance });
|
|
551
|
+
if (currentInstance && typeof currentInstance.pause === "function") {
|
|
552
|
+
currentInstance.pause();
|
|
553
|
+
}
|
|
554
|
+
updateEscMenu();
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const closeMenu = () => {
|
|
558
|
+
if (!isMenuOpen) return;
|
|
559
|
+
isMenuOpen = false;
|
|
560
|
+
demo.emit("resume", { instance: currentInstance });
|
|
561
|
+
demo.emit("esc", { open: false, instance: currentInstance });
|
|
562
|
+
if (currentInstance && typeof currentInstance.resume === "function") {
|
|
563
|
+
currentInstance.resume();
|
|
564
|
+
}
|
|
565
|
+
updateEscMenu();
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const toggleMenu = () => {
|
|
569
|
+
if (isMenuOpen) closeMenu();
|
|
570
|
+
else openMenu();
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const shortcutKey = escMenuConfig.shortcutKey || "Escape";
|
|
574
|
+
const handleKeyDown = (event) => {
|
|
575
|
+
if (event.key === shortcutKey || event.code === shortcutKey) {
|
|
576
|
+
event.preventDefault();
|
|
577
|
+
toggleMenu();
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
if (typeof window !== "undefined") {
|
|
582
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
583
|
+
}
|
|
584
|
+
updateEscMenu();
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
renderLauncher();
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
boot().catch((err) => {
|
|
592
|
+
console.error("[Demo] Boot failed:", err);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
return demo;
|
|
596
|
+
},
|
|
122
597
|
};
|
|
123
598
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
baseWidth: sdk.manifest.video?.baseWidth ?? 320,
|
|
134
|
-
baseHeight: sdk.manifest.video?.baseHeight ?? 240,
|
|
135
|
-
assetKey: romSpec?.key ?? "rom",
|
|
136
|
-
},
|
|
137
|
-
initialRomFile: storedRom,
|
|
138
|
-
initialBiosFile: storedBios,
|
|
139
|
-
onSaveRom: (file) => saveStoredAsset("rom", file),
|
|
140
|
-
onSaveBios: (file) => saveStoredAsset("bios", file),
|
|
141
|
-
onLaunchFiles: bootWithFiles,
|
|
142
|
-
romAccept,
|
|
143
|
-
romAssetHint,
|
|
144
|
-
biosAccept,
|
|
145
|
-
biosAssetHint,
|
|
146
|
-
requiresBIOS,
|
|
147
|
-
showBIOS,
|
|
148
|
-
})
|
|
149
|
-
.mount(mainEl);
|
|
599
|
+
// Automatic initialization fallback for legacy script tags (<script type="module" src="./demo.js"></script>)
|
|
600
|
+
setTimeout(() => {
|
|
601
|
+
if (!isInitialized && typeof document !== "undefined" && typeof window !== "undefined") {
|
|
602
|
+
demo.init();
|
|
603
|
+
}
|
|
604
|
+
}, 0);
|
|
605
|
+
|
|
606
|
+
export default demo;
|
|
607
|
+
export { demo };
|