@wasm-gaming/engine-specs 0.2.4 → 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/demo.js CHANGED
@@ -1,54 +1,57 @@
1
- import { getSdk } from "./sdk.js";
2
1
  import { checksumAlgorithms, computeChecksums } from "./checksums.js";
3
2
  import { loadStoredAsset, saveStoredAsset, clearStoredAsset } 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
- }
26
3
 
27
- const sdk = await getSdk();
4
+ let isInitialized = false;
5
+ let currentConfig = {};
6
+ let currentInstance = null;
28
7
 
29
- if (sdk.manifest?.name) {
30
- document.title = `${sdk.manifest.name} - Demo`;
31
- }
8
+ const listeners = new Map();
9
+ const loadedFiles = {
10
+ rom: null,
11
+ bios: null,
12
+ };
32
13
 
33
- // Compute asset accept filters and hints from manifest
34
- const romSpec = sdk.manifest?.assets?.find((a) => a.key === "rom") ?? sdk.manifest?.assets?.[0];
35
- const biosSpec = sdk.manifest?.assets?.find((a) => a.key === "bios") ?? sdk.manifest?.assets?.[1];
14
+ const DEFAULT_MESSAGES = {
15
+ // Page / Document
16
+ title: "WASM Gaming Engine Specs Demo",
36
17
 
37
- // Determine BIOS visibility and requirement rules (Default: showBIOS = false)
38
- const showBIOS = biosParam === "true" || biosParam === "optional" || biosParam === "required";
39
- const requiresBIOS = biosParam === "required";
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.",
40
25
 
41
- // Load previously stored ROM & BIOS from OPFS if available
42
- const [storedRom, storedBios] = await Promise.all([
43
- loadStoredAsset("rom"),
44
- showBIOS ? loadStoredAsset("bios") : null,
45
- ]);
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: ",
46
38
 
47
- const romAccept = romSpec?.accept?.join(",");
48
- const romAssetHint = romSpec?.accept ? `Supported: ${romSpec.accept.join(", ")}` : "or choose ROM file from computer";
39
+ // Metadata
40
+ versionPrefix: "Version ",
41
+ assetKeyPrefix: "Asset key: ",
49
42
 
50
- const biosAccept = biosSpec?.accept?.join(",");
51
- const biosAssetHint = biosSpec?.description ?? (biosSpec?.accept ? `Supported: ${biosSpec.accept.join(", ")}` : (requiresBIOS ? "required BIOS firmware file" : "optional BIOS firmware file"));
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
+ };
52
55
 
53
56
  async function toArrayBuffer(input) {
54
57
  if (!input) return null;
@@ -59,92 +62,546 @@ async function toArrayBuffer(input) {
59
62
  throw new TypeError("Provided input is not a File, Blob, or ArrayBuffer.");
60
63
  }
61
64
 
62
- const bootWithFiles = async ({ rom, bios }) => {
63
- const romBytes = await toArrayBuffer(rom);
64
- const biosBytes = await toArrayBuffer(bios);
65
-
66
- mainEl.classList.add("running");
67
-
68
- const fileInfoEl = $create("section", { className: "file-info" });
69
- const runtimeEl = $create("div", {
70
- className: "runtime",
71
- children: [fileInfoEl],
72
- });
73
-
74
- mainEl.replaceChildren(runtimeEl);
75
-
76
- const primaryFile = rom || bios;
77
- const fileInfoProps = {
78
- fileName: primaryFile.name,
79
- size: `${primaryFile.size.toLocaleString()} bytes`,
80
- };
81
- const fileInfoPlaceholder = CFileInfo
82
- .renderShadow({
83
- ...fileInfoProps,
84
- checksums: checksumAlgorithms.map((label) => ({ label, value: "computing…" })),
85
- })
86
- .mount(fileInfoEl);
87
-
88
- const assets = {};
89
- if (romBytes && romSpec) {
90
- assets[romSpec.key] = romBytes;
91
- }
92
- if (biosBytes && biosSpec) {
93
- assets[biosSpec.key] = biosBytes;
65
+ function resolveBiosConfig(biosOption) {
66
+ if (typeof location === "undefined") {
67
+ return { showBIOS: false, requiresBIOS: false };
94
68
  }
95
- if (Object.keys(assets).length === 0 && romBytes) {
96
- assets.rom = romBytes;
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 };
97
81
  }
98
82
 
99
- const instance = await sdk.load({
100
- attachTo: runtimeEl,
101
- assets,
102
- options: {
103
- fileName: primaryFile.name,
104
- byteLength: primaryFile.size,
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
+ ],
105
126
  },
106
- onEvent(event) {
107
- if (event.type === "error") {
108
- console.error(event.error);
109
- }
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
+ ],
110
139
  },
111
- });
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);
112
273
 
113
- instance.start();
274
+ const [storedRom, storedBios] = await Promise.all([
275
+ loadStoredAsset("rom"),
276
+ showBIOS ? loadStoredAsset("bios") : null,
277
+ ]);
114
278
 
115
- const checksums = await computeChecksums(romBytes || biosBytes);
116
- fileInfoPlaceholder.destroy();
279
+ loadedFiles.rom = storedRom;
280
+ loadedFiles.bios = storedBios;
117
281
 
118
- CFileInfo
119
- .renderShadow({ ...fileInfoProps, checksums })
120
- .mount(fileInfoEl);
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
+ },
121
597
  };
122
598
 
123
- CLauncher
124
- .render({
125
- CSdkInfo,
126
- CDropArea,
127
- sdkInfo: {
128
- name: sdk.manifest.name,
129
- id: sdk.manifest.id,
130
- description: sdk.manifest.description,
131
- version: sdk.manifest.version,
132
- baseWidth: sdk.manifest.video?.baseWidth ?? 320,
133
- baseHeight: sdk.manifest.video?.baseHeight ?? 240,
134
- assetKey: romSpec?.key ?? "rom",
135
- },
136
- initialRomFile: storedRom,
137
- initialBiosFile: storedBios,
138
- onSaveRom: (file) => saveStoredAsset("rom", file),
139
- onSaveBios: (file) => saveStoredAsset("bios", file),
140
- onRemoveRom: () => clearStoredAsset("rom"),
141
- onRemoveBios: () => clearStoredAsset("bios"),
142
- onLaunchFiles: bootWithFiles,
143
- romAccept,
144
- romAssetHint,
145
- biosAccept,
146
- biosAssetHint,
147
- requiresBIOS,
148
- showBIOS,
149
- })
150
- .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 };