@wasm-gaming/engine-specs 0.2.1 → 0.2.3
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 +102 -0
- package/demo/checksums.js +101 -0
- package/demo/components/drop-area.html +124 -0
- package/demo/components/file-info.html +66 -0
- package/demo/components/launcher.html +198 -0
- package/demo/components/sdk-info.html +42 -0
- package/demo/demo.css +137 -0
- package/demo/demo.js +149 -0
- package/demo/demo.sdk.js +208 -0
- package/demo/index.html +14 -0
- package/demo/sdk.js +19 -0
- package/demo/storage.js +88 -0
- package/package.json +2 -1
package/demo/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Themable Engine Demo Template
|
|
2
|
+
|
|
3
|
+
This directory contains a standalone, themable web interface template for hosting interactive WASM gaming engine demos.
|
|
4
|
+
|
|
5
|
+
## 🚀 How to use in another engine repository
|
|
6
|
+
|
|
7
|
+
1. **Copy the `demo/` folder** into your engine repository.
|
|
8
|
+
|
|
9
|
+
2. **Connect your engine SDK in `demo/sdk.js`**:
|
|
10
|
+
Open `demo/sdk.js` and point it to your engine SDK build:
|
|
11
|
+
```javascript
|
|
12
|
+
import sdk from "../src/index.js"; // or "../dist/index.js"
|
|
13
|
+
|
|
14
|
+
export async function getSdk() {
|
|
15
|
+
return sdk;
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
*Alternatively*, if embedding in HTML without modifying `sdk.js`, set `window.SDK = myEngineSdk;` before `demo.js` runs.
|
|
19
|
+
|
|
20
|
+
3. **Serve the demo**:
|
|
21
|
+
Serve `demo/index.html` using any local development server (e.g. `npx serve demo` or Vite).
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 🎨 Customizing & Theming
|
|
26
|
+
|
|
27
|
+
The demo visual design system is fully controlled via **CSS Custom Properties** defined in `demo.css`.
|
|
28
|
+
|
|
29
|
+
### Overriding CSS Variables
|
|
30
|
+
|
|
31
|
+
To apply your custom branding, override any of the `:root` variables in `demo.css` or in your own custom stylesheet:
|
|
32
|
+
|
|
33
|
+
```css
|
|
34
|
+
:root {
|
|
35
|
+
/* Brand colors */
|
|
36
|
+
--demo-bg-a: #1e1b4b;
|
|
37
|
+
--demo-bg-b: #0f172a;
|
|
38
|
+
--demo-text: #f8fafc;
|
|
39
|
+
--demo-muted: #94a3b8;
|
|
40
|
+
|
|
41
|
+
/* Accent highlights */
|
|
42
|
+
--demo-accent: #00ffcc;
|
|
43
|
+
--demo-accent-rgb: 0, 255, 204;
|
|
44
|
+
--demo-accent2: #38bdf8;
|
|
45
|
+
--demo-accent2-rgb: 56, 189, 248;
|
|
46
|
+
|
|
47
|
+
/* Glassmorphism panels */
|
|
48
|
+
--demo-panel-bg: rgba(15, 23, 42, 0.75);
|
|
49
|
+
--demo-panel-border: rgba(255, 255, 255, 0.1);
|
|
50
|
+
--demo-subpanel-bg: rgba(30, 41, 59, 0.6);
|
|
51
|
+
--demo-subpanel-border: rgba(255, 255, 255, 0.1);
|
|
52
|
+
|
|
53
|
+
/* Buttons & Dropzone */
|
|
54
|
+
--demo-drop-bg: rgba(30, 41, 59, 0.4);
|
|
55
|
+
--demo-picker-bg: rgba(255, 255, 255, 0.1);
|
|
56
|
+
--demo-picker-hover-bg: rgba(255, 255, 255, 0.2);
|
|
57
|
+
--demo-picker-text: #ffffff;
|
|
58
|
+
|
|
59
|
+
/* Launch button */
|
|
60
|
+
--demo-launch-bg: var(--demo-accent);
|
|
61
|
+
--demo-launch-text: #ffffff;
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Full Token Reference
|
|
66
|
+
|
|
67
|
+
| Variable | Description | Default |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `--demo-bg-a` | Gradient background stop 1 | `#d8b4fe` |
|
|
70
|
+
| `--demo-bg-b` | Gradient background stop 2 | `#7dd3fc` |
|
|
71
|
+
| `--demo-text` | Primary text color | `#0f172a` |
|
|
72
|
+
| `--demo-muted` | Secondary/muted text color | `#475569` |
|
|
73
|
+
| `--demo-accent` | Primary accent color | `#ff007f` |
|
|
74
|
+
| `--demo-accent-rgb` | Primary accent RGB channels | `255, 0, 127` |
|
|
75
|
+
| `--demo-accent2` | Secondary accent color | `#0284c7` |
|
|
76
|
+
| `--demo-accent2-rgb` | Secondary accent RGB channels | `2, 132, 199` |
|
|
77
|
+
| `--demo-panel-bg` | Main card/launcher background | `rgba(255, 255, 255, 0.25)` |
|
|
78
|
+
| `--demo-panel-border` | Main card border color | `rgba(255, 255, 255, 0.4)` |
|
|
79
|
+
| `--demo-panel-radius` | Main card corner rounding | `12px` |
|
|
80
|
+
| `--demo-panel-blur` | Backdrop glass blur effect | `24px` |
|
|
81
|
+
| `--demo-subpanel-bg` | Inner info section background | `rgba(255, 255, 255, 0.35)` |
|
|
82
|
+
| `--demo-drop-bg` | Dropzone background | `rgba(255, 255, 255, 0.15)` |
|
|
83
|
+
| `--demo-drop-active-bg` | Drag-over active background | `rgba(255, 255, 255, 0.4)` |
|
|
84
|
+
| `--demo-picker-bg` | Pick File button background | `rgba(255, 255, 255, 0.5)` |
|
|
85
|
+
| `--demo-pill-bg` | Metadata badge background | `rgba(var(--demo-accent-rgb), 0.1)` |
|
|
86
|
+
| `--demo-file-info-bg` | Canvas overlay panel background | `rgba(255, 255, 255, 0.7)` |
|
|
87
|
+
| `--demo-launch-bg` | Launch button background color | `var(--demo-accent)` |
|
|
88
|
+
| `--demo-launch-text` | Launch button text color | `#ffffff` |
|
|
89
|
+
| `--demo-font-sans` | Primary sans-serif font family | `"Outfit", system-ui, sans-serif` |
|
|
90
|
+
| `--demo-font-mono` | Monospace code font family | `"Space Mono", monospace` |
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 🛠 Features
|
|
95
|
+
|
|
96
|
+
- **OPFS Asset Persistence**: Automatically stores selected ROM and BIOS files in the Origin Private File System (`navigator.storage.getDirectory()`), restoring them automatically across page reloads.
|
|
97
|
+
- **Split Asset Drop Zones**: Independent drop/picker areas for optional **BIOS** firmware and game **ROM** assets.
|
|
98
|
+
- **Dedicated Launch Trigger**: Prominent launch button that activates once required game assets are selected.
|
|
99
|
+
- **Automated Manifest Integration**: Page title, SDK info, asset key mapping, and file type (`accept`) filters are automatically loaded from `sdk.manifest`.
|
|
100
|
+
- **Checksum Calculation**: Computes MD5, SHA-1, and SHA-256 hashes of loaded files asynchronously in the background.
|
|
101
|
+
- **Shadow DOM Isolation**: UI components are isolated using Shadow DOM to prevent style leakage.
|
|
102
|
+
- **Subpath Hosting Ready**: Uses `import.meta.url` for component templates to work seamlessly on subpaths (e.g. GitHub Pages).
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, n) => {
|
|
2
|
+
let c = n;
|
|
3
|
+
for (let k = 0; k < 8; k += 1) {
|
|
4
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
5
|
+
}
|
|
6
|
+
return c >>> 0;
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const crc32 = (bytes) => {
|
|
10
|
+
let crc = 0xffffffff;
|
|
11
|
+
for (const byte of bytes) {
|
|
12
|
+
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
13
|
+
}
|
|
14
|
+
return ((crc ^ 0xffffffff) >>> 0).toString(16).padStart(8, "0");
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const toHex = (buffer) =>
|
|
18
|
+
Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
19
|
+
|
|
20
|
+
// RFC 1321. Hand-rolled because crypto.subtle does not offer MD5.
|
|
21
|
+
const MD5_K = Uint32Array.from({ length: 64 }, (_, i) =>
|
|
22
|
+
Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32));
|
|
23
|
+
|
|
24
|
+
const MD5_S = [
|
|
25
|
+
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
|
|
26
|
+
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
|
|
27
|
+
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
|
|
28
|
+
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const md5 = (bytes) => {
|
|
32
|
+
const paddedLength = (((bytes.length + 8) >> 6) + 1) << 6;
|
|
33
|
+
const padded = new Uint8Array(paddedLength);
|
|
34
|
+
padded.set(bytes);
|
|
35
|
+
padded[bytes.length] = 0x80;
|
|
36
|
+
const view = new DataView(padded.buffer);
|
|
37
|
+
view.setUint32(paddedLength - 8, (bytes.length * 8) >>> 0, true);
|
|
38
|
+
view.setUint32(paddedLength - 4, Math.floor(bytes.length / 0x20000000), true);
|
|
39
|
+
|
|
40
|
+
let [h0, h1, h2, h3] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476];
|
|
41
|
+
const words = new Uint32Array(16);
|
|
42
|
+
|
|
43
|
+
for (let offset = 0; offset < paddedLength; offset += 64) {
|
|
44
|
+
for (let j = 0; j < 16; j += 1) {
|
|
45
|
+
words[j] = view.getUint32(offset + j * 4, true);
|
|
46
|
+
}
|
|
47
|
+
let [a, b, c, d] = [h0, h1, h2, h3];
|
|
48
|
+
for (let i = 0; i < 64; i += 1) {
|
|
49
|
+
let f;
|
|
50
|
+
let g;
|
|
51
|
+
if (i < 16) {
|
|
52
|
+
f = (b & c) | (~b & d);
|
|
53
|
+
g = i;
|
|
54
|
+
} else if (i < 32) {
|
|
55
|
+
f = (d & b) | (~d & c);
|
|
56
|
+
g = (5 * i + 1) % 16;
|
|
57
|
+
} else if (i < 48) {
|
|
58
|
+
f = b ^ c ^ d;
|
|
59
|
+
g = (3 * i + 5) % 16;
|
|
60
|
+
} else {
|
|
61
|
+
f = c ^ (b | ~d);
|
|
62
|
+
g = (7 * i) % 16;
|
|
63
|
+
}
|
|
64
|
+
const sum = (a + f + MD5_K[i] + words[g]) >>> 0;
|
|
65
|
+
const rotated = (sum << MD5_S[i]) | (sum >>> (32 - MD5_S[i]));
|
|
66
|
+
[a, b, c, d] = [d, (b + rotated) >>> 0, b, c];
|
|
67
|
+
}
|
|
68
|
+
h0 = (h0 + a) >>> 0;
|
|
69
|
+
h1 = (h1 + b) >>> 0;
|
|
70
|
+
h2 = (h2 + c) >>> 0;
|
|
71
|
+
h3 = (h3 + d) >>> 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const digest = new DataView(new ArrayBuffer(16));
|
|
75
|
+
digest.setUint32(0, h0, true);
|
|
76
|
+
digest.setUint32(4, h1, true);
|
|
77
|
+
digest.setUint32(8, h2, true);
|
|
78
|
+
digest.setUint32(12, h3, true);
|
|
79
|
+
return toHex(digest.buffer);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const sha = async (algorithm, buffer) => {
|
|
83
|
+
// crypto.subtle only exists in secure contexts (https or localhost).
|
|
84
|
+
if (!globalThis.crypto?.subtle) {
|
|
85
|
+
return "unavailable (requires a secure context)";
|
|
86
|
+
}
|
|
87
|
+
return toHex(await crypto.subtle.digest(algorithm, buffer));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const checksumAlgorithms = ["CRC32", "MD5", "SHA-1", "SHA-256"];
|
|
91
|
+
|
|
92
|
+
export async function computeChecksums(buffer) {
|
|
93
|
+
const bytes = new Uint8Array(buffer);
|
|
94
|
+
const [sha1, sha256] = await Promise.all([sha("SHA-1", buffer), sha("SHA-256", buffer)]);
|
|
95
|
+
return [
|
|
96
|
+
{ label: "CRC32", value: crc32(bytes) },
|
|
97
|
+
{ label: "MD5", value: md5(bytes) },
|
|
98
|
+
{ label: "SHA-1", value: sha1 },
|
|
99
|
+
{ label: "SHA-256", value: sha256 },
|
|
100
|
+
];
|
|
101
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<section class="drop" :class.active="dragging" :class.selected="selectedName"
|
|
2
|
+
@dragover.prevent="dragging = true"
|
|
3
|
+
@dragleave="dragging = false"
|
|
4
|
+
@drop.prevent="onDrop">
|
|
5
|
+
<strong>{{ prompt }}</strong>
|
|
6
|
+
<p :class.file-selected="selectedName">{{ selectedName ? 'Selected: ' + selectedName : assetHint }}</p>
|
|
7
|
+
<label class="picker">
|
|
8
|
+
{{ selectedName ? 'Change File' : buttonText }}
|
|
9
|
+
<input type="file" :accept="accept" @change="onPick" />
|
|
10
|
+
</label>
|
|
11
|
+
</section>
|
|
12
|
+
|
|
13
|
+
<script :setup="{
|
|
14
|
+
prompt,
|
|
15
|
+
assetHint = 'or choose one from your computer',
|
|
16
|
+
accept,
|
|
17
|
+
buttonText = 'Pick file',
|
|
18
|
+
fileName = '',
|
|
19
|
+
}">
|
|
20
|
+
let dragging = false;
|
|
21
|
+
let selectedName = fileName || "";
|
|
22
|
+
|
|
23
|
+
const extractFile = (arg) => {
|
|
24
|
+
if (!arg) return null;
|
|
25
|
+
if (arg instanceof File) return arg;
|
|
26
|
+
if (arg.detail instanceof File) return arg.detail;
|
|
27
|
+
if (arg.target?.files?.item(0)) return arg.target.files.item(0);
|
|
28
|
+
if (arg.dataTransfer?.files?.item(0)) return arg.dataTransfer.files.item(0);
|
|
29
|
+
return null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const emitFile = (file) => {
|
|
33
|
+
if (!file) return;
|
|
34
|
+
selectedName = file.name;
|
|
35
|
+
$emit("select", file);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const onDrop = (event) => {
|
|
39
|
+
dragging = false;
|
|
40
|
+
const file = extractFile(event);
|
|
41
|
+
if (file) emitFile(file);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const onPick = (event) => {
|
|
45
|
+
const file = extractFile(event);
|
|
46
|
+
if (file) emitFile(file);
|
|
47
|
+
};
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<style>
|
|
51
|
+
.drop {
|
|
52
|
+
border: 2px dashed var(--demo-drop-border, rgba(255, 255, 255, 0.6));
|
|
53
|
+
border-radius: var(--demo-subpanel-radius, 8px);
|
|
54
|
+
padding: 2rem 1.25rem;
|
|
55
|
+
text-align: center;
|
|
56
|
+
transition: all 250ms ease;
|
|
57
|
+
background: var(--demo-drop-bg, rgba(255, 255, 255, 0.15));
|
|
58
|
+
backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
|
|
59
|
+
-webkit-backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.drop.active {
|
|
63
|
+
border-color: var(--demo-drop-active-border, var(--demo-accent2, #0284c7));
|
|
64
|
+
background: var(--demo-drop-active-bg, rgba(255, 255, 255, 0.4));
|
|
65
|
+
box-shadow: var(--demo-drop-active-shadow, inset 0 0 0 1px rgba(255, 255, 255, 0.8));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.drop.selected {
|
|
69
|
+
border-style: solid;
|
|
70
|
+
border-color: var(--demo-accent2, #0284c7);
|
|
71
|
+
background: var(--demo-subpanel-bg, rgba(255, 255, 255, 0.35));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.drop strong {
|
|
75
|
+
display: block;
|
|
76
|
+
margin-bottom: 0.5rem;
|
|
77
|
+
font-size: 1.1rem;
|
|
78
|
+
font-weight: 600;
|
|
79
|
+
text-transform: uppercase;
|
|
80
|
+
letter-spacing: 0.05em;
|
|
81
|
+
color: var(--demo-text, #0f172a);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.drop p {
|
|
85
|
+
margin: 0.2rem 0;
|
|
86
|
+
color: var(--demo-muted, #475569);
|
|
87
|
+
font-family: var(--demo-font-mono, monospace);
|
|
88
|
+
font-size: 0.85rem;
|
|
89
|
+
word-break: break-all;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.drop p.file-selected {
|
|
93
|
+
color: var(--demo-text, #0f172a);
|
|
94
|
+
font-weight: 600;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.picker {
|
|
98
|
+
display: inline-flex;
|
|
99
|
+
margin-top: 1rem;
|
|
100
|
+
background: var(--demo-picker-bg, rgba(255, 255, 255, 0.5));
|
|
101
|
+
color: var(--demo-picker-text, var(--demo-text, #0f172a));
|
|
102
|
+
border: 1px solid var(--demo-picker-border, rgba(255, 255, 255, 0.6));
|
|
103
|
+
border-radius: var(--demo-subpanel-radius, 8px);
|
|
104
|
+
padding: 0.5rem 1rem;
|
|
105
|
+
cursor: pointer;
|
|
106
|
+
text-transform: uppercase;
|
|
107
|
+
font-weight: 600;
|
|
108
|
+
font-size: 0.85rem;
|
|
109
|
+
letter-spacing: 0.05em;
|
|
110
|
+
transition: all 0.2s;
|
|
111
|
+
backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
|
|
112
|
+
-webkit-backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.picker:hover {
|
|
116
|
+
background: var(--demo-picker-hover-bg, rgba(255, 255, 255, 0.8));
|
|
117
|
+
border-color: var(--demo-picker-hover-border, rgba(255, 255, 255, 0.9));
|
|
118
|
+
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.picker input {
|
|
122
|
+
display: none;
|
|
123
|
+
}
|
|
124
|
+
</style>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<h2>{{ fileName }}</h2>
|
|
2
|
+
<dl>
|
|
3
|
+
<div class="row">
|
|
4
|
+
<dt>Size</dt>
|
|
5
|
+
<dd>{{ size }}</dd>
|
|
6
|
+
</div>
|
|
7
|
+
<div :each="checksum in checksums" class="row">
|
|
8
|
+
<dt>{{ checksum.label }}</dt>
|
|
9
|
+
<dd>{{ checksum.value }}</dd>
|
|
10
|
+
</div>
|
|
11
|
+
</dl>
|
|
12
|
+
|
|
13
|
+
<style>
|
|
14
|
+
:host {
|
|
15
|
+
position: absolute;
|
|
16
|
+
top: 4rem;
|
|
17
|
+
left: 2rem;
|
|
18
|
+
max-width: min(600px, calc(100vw - 2rem));
|
|
19
|
+
border: 1px solid var(--demo-file-info-border, rgba(255, 255, 255, 0.8));
|
|
20
|
+
border-radius: var(--demo-panel-radius, 12px);
|
|
21
|
+
background: var(--demo-file-info-bg, rgba(255, 255, 255, 0.7));
|
|
22
|
+
backdrop-filter: blur(var(--demo-file-info-blur, 10px));
|
|
23
|
+
padding: 1.2rem;
|
|
24
|
+
font-size: 0.9rem;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
h2 {
|
|
28
|
+
margin: 0 0 0.8rem;
|
|
29
|
+
font-size: 1.1rem;
|
|
30
|
+
font-weight: 600;
|
|
31
|
+
word-break: break-all;
|
|
32
|
+
color: var(--demo-text, var(--text, #0f172a));
|
|
33
|
+
letter-spacing: 0.05em;
|
|
34
|
+
border-bottom: 1px solid var(--demo-panel-border, rgba(255, 255, 255, 0.6));
|
|
35
|
+
padding-bottom: 0.4rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
dl {
|
|
39
|
+
margin: 0;
|
|
40
|
+
display: grid;
|
|
41
|
+
gap: 0.5rem;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.row {
|
|
45
|
+
display: grid;
|
|
46
|
+
grid-template-columns: 4.4rem 1fr;
|
|
47
|
+
gap: 0.8rem;
|
|
48
|
+
align-items: baseline;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
dt {
|
|
52
|
+
color: var(--demo-text, var(--text, #0f172a));
|
|
53
|
+
opacity: 0.7;
|
|
54
|
+
font-size: 0.8rem;
|
|
55
|
+
text-transform: uppercase;
|
|
56
|
+
letter-spacing: 0.05em;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
dd {
|
|
60
|
+
margin: 0;
|
|
61
|
+
font-family: var(--demo-font-mono, var(--font-mono, monospace));
|
|
62
|
+
word-break: break-all;
|
|
63
|
+
user-select: all;
|
|
64
|
+
color: var(--demo-text, var(--text, #0f172a));
|
|
65
|
+
}
|
|
66
|
+
</style>
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
<form class="launcher" @submit.prevent="onLaunch">
|
|
2
|
+
<section class="sdk">
|
|
3
|
+
<CSdkInfo ...sdkInfo />
|
|
4
|
+
</section>
|
|
5
|
+
|
|
6
|
+
<div class="drop-grid" :class.single="!showBIOS">
|
|
7
|
+
<CDropArea
|
|
8
|
+
:if="showBIOS"
|
|
9
|
+
:prompt="requiresBIOS ? 'Drop BIOS File (Required)' : 'Drop BIOS File (Optional)'"
|
|
10
|
+
:asset-hint="biosAssetHint"
|
|
11
|
+
:accept="biosAccept"
|
|
12
|
+
:button-text="'Pick BIOS'"
|
|
13
|
+
:file-name="biosFile?.name"
|
|
14
|
+
@select="onBiosSelect"
|
|
15
|
+
/>
|
|
16
|
+
<CDropArea
|
|
17
|
+
:prompt="'Drop ROM File'"
|
|
18
|
+
:asset-hint="romAssetHint"
|
|
19
|
+
:accept="romAccept"
|
|
20
|
+
:button-text="'Pick ROM'"
|
|
21
|
+
:file-name="romFile?.name"
|
|
22
|
+
@select="onRomSelect"
|
|
23
|
+
/>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<button type="submit" class="launch-btn" :disabled="!canLaunch">
|
|
27
|
+
Launch Engine
|
|
28
|
+
</button>
|
|
29
|
+
|
|
30
|
+
<div class="status">{{ status }}</div>
|
|
31
|
+
</form>
|
|
32
|
+
|
|
33
|
+
<script :setup="{
|
|
34
|
+
CSdkInfo,
|
|
35
|
+
CDropArea,
|
|
36
|
+
sdkInfo,
|
|
37
|
+
onLaunchFiles,
|
|
38
|
+
onSaveBios,
|
|
39
|
+
onSaveRom,
|
|
40
|
+
initialRomFile = null,
|
|
41
|
+
initialBiosFile = null,
|
|
42
|
+
romAccept,
|
|
43
|
+
romAssetHint = 'or choose ROM file from computer',
|
|
44
|
+
biosAccept,
|
|
45
|
+
biosAssetHint = 'optional BIOS firmware file',
|
|
46
|
+
requiresBIOS = false,
|
|
47
|
+
showBIOS = true,
|
|
48
|
+
}">
|
|
49
|
+
let romFile = initialRomFile;
|
|
50
|
+
let biosFile = initialBiosFile;
|
|
51
|
+
|
|
52
|
+
let canLaunch = Boolean(romFile && (!requiresBIOS || biosFile));
|
|
53
|
+
let status = "";
|
|
54
|
+
|
|
55
|
+
const updateStatus = () => {
|
|
56
|
+
canLaunch = Boolean(romFile && (!requiresBIOS || biosFile));
|
|
57
|
+
if (requiresBIOS && !biosFile && !romFile) {
|
|
58
|
+
status = "Ready - Please select ROM and BIOS files to launch.";
|
|
59
|
+
} else if (requiresBIOS && !biosFile) {
|
|
60
|
+
status = "BIOS file is required to launch.";
|
|
61
|
+
} else if (!romFile) {
|
|
62
|
+
status = "Ready - Please select a ROM file to launch.";
|
|
63
|
+
} else {
|
|
64
|
+
status = `Ready to launch! (${romFile.name}${biosFile ? ' + BIOS' : ''})`;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
updateStatus();
|
|
69
|
+
|
|
70
|
+
const extractFile = (arg) => {
|
|
71
|
+
if (!arg) return null;
|
|
72
|
+
if (arg instanceof File) return arg;
|
|
73
|
+
if (arg.detail instanceof File) return arg.detail;
|
|
74
|
+
if (arg.detail) return extractFile(arg.detail);
|
|
75
|
+
return null;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const onRomSelect = (arg) => {
|
|
79
|
+
const file = extractFile(arg);
|
|
80
|
+
if (!file) return;
|
|
81
|
+
romFile = file;
|
|
82
|
+
updateStatus();
|
|
83
|
+
onSaveRom?.(file);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const onBiosSelect = (arg) => {
|
|
87
|
+
const file = extractFile(arg);
|
|
88
|
+
if (!file) return;
|
|
89
|
+
biosFile = file;
|
|
90
|
+
updateStatus();
|
|
91
|
+
onSaveBios?.(file);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const onLaunch = async () => {
|
|
95
|
+
if (!canLaunch) {
|
|
96
|
+
updateStatus();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
status = `Launching with ${romFile.name}...`;
|
|
101
|
+
await onLaunchFiles({ rom: romFile, bios: biosFile });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
104
|
+
status = `Failed to load engine: ${message}`;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<style>
|
|
110
|
+
form.launcher {
|
|
111
|
+
width: min(740px, 100%);
|
|
112
|
+
border: 1px solid var(--demo-panel-border, rgba(255, 255, 255, 0.4));
|
|
113
|
+
border-radius: var(--demo-panel-radius, 12px);
|
|
114
|
+
background: var(--demo-panel-bg, rgba(255, 255, 255, 0.25));
|
|
115
|
+
box-shadow: var(--demo-panel-shadow, inset 0 0 0 1px rgba(255, 255, 255, 0.5));
|
|
116
|
+
backdrop-filter: blur(var(--demo-panel-blur, 24px));
|
|
117
|
+
-webkit-backdrop-filter: blur(var(--demo-panel-blur, 24px));
|
|
118
|
+
padding: 1.5rem;
|
|
119
|
+
display: grid;
|
|
120
|
+
gap: 1.25rem;
|
|
121
|
+
position: relative;
|
|
122
|
+
overflow: hidden;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
form.launcher::before {
|
|
126
|
+
content: "";
|
|
127
|
+
position: absolute;
|
|
128
|
+
top: 0;
|
|
129
|
+
right: 0;
|
|
130
|
+
width: 60px;
|
|
131
|
+
height: 60px;
|
|
132
|
+
background: linear-gradient(135deg, transparent 50%, var(--demo-accent, #ff007f) 50%);
|
|
133
|
+
opacity: 0.8;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.sdk {
|
|
137
|
+
border: 1px solid var(--demo-subpanel-border, rgba(255, 255, 255, 0.3));
|
|
138
|
+
border-radius: var(--demo-subpanel-radius, 8px);
|
|
139
|
+
padding: 1.25rem;
|
|
140
|
+
background: var(--demo-subpanel-bg, rgba(255, 255, 255, 0.35));
|
|
141
|
+
backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
|
|
142
|
+
-webkit-backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
|
|
143
|
+
box-shadow: var(--demo-subpanel-shadow, inset 0 0 0 1px rgba(255, 255, 255, 0.6));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.drop-grid {
|
|
147
|
+
display: grid;
|
|
148
|
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
149
|
+
gap: 1rem;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.drop-grid.single {
|
|
153
|
+
grid-template-columns: 1fr;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.launch-btn {
|
|
157
|
+
width: 100%;
|
|
158
|
+
padding: 0.9rem 1.5rem;
|
|
159
|
+
font-size: 1.1rem;
|
|
160
|
+
font-weight: 700;
|
|
161
|
+
font-family: var(--demo-font-sans, sans-serif);
|
|
162
|
+
text-transform: uppercase;
|
|
163
|
+
letter-spacing: 0.08em;
|
|
164
|
+
color: var(--demo-launch-text, #ffffff);
|
|
165
|
+
background: var(--demo-launch-bg, var(--demo-accent, #ff007f));
|
|
166
|
+
border: none;
|
|
167
|
+
border-radius: var(--demo-subpanel-radius, 8px);
|
|
168
|
+
cursor: pointer;
|
|
169
|
+
transition: all 0.2s ease;
|
|
170
|
+
box-shadow: var(--demo-accent-glow, 0 0 15px rgba(255, 0, 127, 0.3));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.launch-btn:hover:not(:disabled) {
|
|
174
|
+
filter: brightness(1.1);
|
|
175
|
+
transform: translateY(-1px);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.launch-btn:active:not(:disabled) {
|
|
179
|
+
transform: translateY(0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.launch-btn:disabled {
|
|
183
|
+
opacity: 0.5;
|
|
184
|
+
cursor: not-allowed;
|
|
185
|
+
box-shadow: none;
|
|
186
|
+
background: var(--demo-muted, #475569);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.status {
|
|
190
|
+
min-height: 1.2rem;
|
|
191
|
+
color: var(--demo-text, #0f172a);
|
|
192
|
+
font-family: var(--demo-font-mono, monospace);
|
|
193
|
+
font-size: 0.85rem;
|
|
194
|
+
text-transform: uppercase;
|
|
195
|
+
letter-spacing: 0.05em;
|
|
196
|
+
opacity: 0.8;
|
|
197
|
+
}
|
|
198
|
+
</style>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<h1>{{ name }} ({{ id }})</h1>
|
|
2
|
+
<p>{{ description }}</p>
|
|
3
|
+
<div class="row">
|
|
4
|
+
<span class="pill">Version {{ version }}</span>
|
|
5
|
+
<span class="pill">{{ baseWidth }}x{{ baseHeight }}</span>
|
|
6
|
+
<span class="pill">Asset key: {{ assetKey }}</span>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<style>
|
|
10
|
+
h1 {
|
|
11
|
+
margin: 0 0 0.4rem;
|
|
12
|
+
font-size: 1.4rem;
|
|
13
|
+
font-weight: 800;
|
|
14
|
+
color: var(--demo-text, var(--text, #0f172a));
|
|
15
|
+
letter-spacing: 0.05em;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
p {
|
|
19
|
+
margin: 0;
|
|
20
|
+
color: var(--demo-muted, var(--muted, #475569));
|
|
21
|
+
font-size: 0.95rem;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.row {
|
|
25
|
+
margin-top: 0.9rem;
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-wrap: wrap;
|
|
28
|
+
gap: 0.6rem;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.pill {
|
|
32
|
+
border: 1px solid var(--demo-pill-border, var(--demo-accent, var(--accent, #ff007f)));
|
|
33
|
+
border-radius: 2px;
|
|
34
|
+
padding: 0.3rem 0.75rem;
|
|
35
|
+
font-family: var(--demo-font-mono, var(--font-mono, monospace));
|
|
36
|
+
font-size: 0.8rem;
|
|
37
|
+
color: var(--demo-pill-text, var(--demo-accent, var(--accent, #ff007f)));
|
|
38
|
+
background: var(--demo-pill-bg, rgba(255, 0, 127, 0.1));
|
|
39
|
+
box-shadow: var(--demo-pill-shadow, inset 0 0 5px rgba(255, 0, 127, 0.3));
|
|
40
|
+
text-transform: uppercase;
|
|
41
|
+
}
|
|
42
|
+
</style>
|
package/demo/demo.css
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap');
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
/* Colors & Palette */
|
|
5
|
+
--demo-bg-a: #d8b4fe;
|
|
6
|
+
--demo-bg-b: #7dd3fc;
|
|
7
|
+
--demo-bg-gradient: linear-gradient(145deg, var(--demo-bg-a) 0%, var(--demo-bg-b) 100%);
|
|
8
|
+
--demo-bg-radial-1: rgba(255, 0, 127, 0.08);
|
|
9
|
+
--demo-bg-radial-2: rgba(2, 132, 199, 0.12);
|
|
10
|
+
|
|
11
|
+
--demo-text: #0f172a;
|
|
12
|
+
--demo-muted: #475569;
|
|
13
|
+
|
|
14
|
+
--demo-accent: #ff007f;
|
|
15
|
+
--demo-accent-rgb: 255, 0, 127;
|
|
16
|
+
--demo-accent-glow: 0 0 15px rgba(var(--demo-accent-rgb), 0.3), 0 0 30px rgba(var(--demo-accent-rgb), 0.15);
|
|
17
|
+
|
|
18
|
+
--demo-accent2: #0284c7;
|
|
19
|
+
--demo-accent2-rgb: 2, 132, 199;
|
|
20
|
+
--demo-accent2-glow: 0 0 15px rgba(var(--demo-accent2-rgb), 0.3), 0 0 30px rgba(var(--demo-accent2-rgb), 0.15);
|
|
21
|
+
|
|
22
|
+
/* Glassmorphism & Panel System */
|
|
23
|
+
--demo-panel-bg: rgba(255, 255, 255, 0.25);
|
|
24
|
+
--demo-panel-border: rgba(255, 255, 255, 0.4);
|
|
25
|
+
--demo-panel-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5);
|
|
26
|
+
--demo-panel-blur: 24px;
|
|
27
|
+
--demo-panel-radius: 12px;
|
|
28
|
+
|
|
29
|
+
--demo-subpanel-bg: rgba(255, 255, 255, 0.35);
|
|
30
|
+
--demo-subpanel-border: rgba(255, 255, 255, 0.3);
|
|
31
|
+
--demo-subpanel-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
|
|
32
|
+
--demo-subpanel-blur: 12px;
|
|
33
|
+
--demo-subpanel-radius: 8px;
|
|
34
|
+
|
|
35
|
+
/* Drop Zone */
|
|
36
|
+
--demo-drop-bg: rgba(255, 255, 255, 0.15);
|
|
37
|
+
--demo-drop-border: rgba(255, 255, 255, 0.6);
|
|
38
|
+
--demo-drop-active-bg: rgba(255, 255, 255, 0.4);
|
|
39
|
+
--demo-drop-active-border: var(--demo-accent2);
|
|
40
|
+
--demo-drop-active-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.8);
|
|
41
|
+
|
|
42
|
+
/* Button / File Picker */
|
|
43
|
+
--demo-picker-bg: rgba(255, 255, 255, 0.5);
|
|
44
|
+
--demo-picker-hover-bg: rgba(255, 255, 255, 0.8);
|
|
45
|
+
--demo-picker-border: rgba(255, 255, 255, 0.6);
|
|
46
|
+
--demo-picker-hover-border: rgba(255, 255, 255, 0.9);
|
|
47
|
+
--demo-picker-text: var(--demo-text);
|
|
48
|
+
|
|
49
|
+
/* Metadata Pills */
|
|
50
|
+
--demo-pill-bg: rgba(var(--demo-accent-rgb), 0.1);
|
|
51
|
+
--demo-pill-border: var(--demo-accent);
|
|
52
|
+
--demo-pill-text: var(--demo-accent);
|
|
53
|
+
--demo-pill-shadow: inset 0 0 5px rgba(var(--demo-accent-rgb), 0.3);
|
|
54
|
+
|
|
55
|
+
/* File Info Overlay */
|
|
56
|
+
--demo-file-info-bg: rgba(255, 255, 255, 0.7);
|
|
57
|
+
--demo-file-info-border: rgba(255, 255, 255, 0.8);
|
|
58
|
+
--demo-file-info-blur: 10px;
|
|
59
|
+
|
|
60
|
+
/* Typography & Fonts */
|
|
61
|
+
--demo-font-sans: "Outfit", system-ui, -apple-system, sans-serif;
|
|
62
|
+
--demo-font-mono: "Space Mono", monospace;
|
|
63
|
+
|
|
64
|
+
/* Legacy Aliases for Backward Compatibility */
|
|
65
|
+
--bg-a: var(--demo-bg-a);
|
|
66
|
+
--bg-b: var(--demo-bg-b);
|
|
67
|
+
--text: var(--demo-text);
|
|
68
|
+
--muted: var(--demo-muted);
|
|
69
|
+
--accent: var(--demo-accent);
|
|
70
|
+
--accent-glow: var(--demo-accent-glow);
|
|
71
|
+
--accent2: var(--demo-accent2);
|
|
72
|
+
--accent2-glow: var(--demo-accent2-glow);
|
|
73
|
+
--font-sans: var(--demo-font-sans);
|
|
74
|
+
--font-mono: var(--demo-font-mono);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
* {
|
|
78
|
+
box-sizing: border-box;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
html,
|
|
82
|
+
body {
|
|
83
|
+
width: 100%;
|
|
84
|
+
height: 100%;
|
|
85
|
+
margin: 0;
|
|
86
|
+
color: var(--demo-text);
|
|
87
|
+
background:
|
|
88
|
+
radial-gradient(circle at 15% 50%, var(--demo-bg-radial-1), transparent 45%),
|
|
89
|
+
radial-gradient(circle at 85% 30%, var(--demo-bg-radial-2), transparent 50%),
|
|
90
|
+
var(--demo-bg-gradient);
|
|
91
|
+
background-attachment: fixed;
|
|
92
|
+
font-family: var(--demo-font-sans);
|
|
93
|
+
letter-spacing: 0.02em;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
body::after {
|
|
97
|
+
content: "";
|
|
98
|
+
position: fixed;
|
|
99
|
+
inset: 0;
|
|
100
|
+
background: linear-gradient(to bottom,
|
|
101
|
+
rgba(0, 0, 0, 0),
|
|
102
|
+
rgba(0, 0, 0, 0) 50%,
|
|
103
|
+
rgba(0, 0, 0, 0.02) 50%,
|
|
104
|
+
rgba(0, 0, 0, 0.02));
|
|
105
|
+
background-size: 100% 4px;
|
|
106
|
+
pointer-events: none;
|
|
107
|
+
z-index: 9999;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
main {
|
|
111
|
+
min-height: 100vh;
|
|
112
|
+
min-height: 100dvh;
|
|
113
|
+
display: grid;
|
|
114
|
+
place-items: center;
|
|
115
|
+
padding: 2rem;
|
|
116
|
+
position: relative;
|
|
117
|
+
z-index: 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
main.running {
|
|
121
|
+
padding: 0;
|
|
122
|
+
background: black;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.runtime {
|
|
126
|
+
position: relative;
|
|
127
|
+
aspect-ratio: 5 / 4;
|
|
128
|
+
overflow: hidden;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.runtime canvas {
|
|
132
|
+
aspect-ratio: 5 / 4;
|
|
133
|
+
width: min(100vw, calc(100vh * 5 / 4));
|
|
134
|
+
height: auto;
|
|
135
|
+
max-height: 100vh;
|
|
136
|
+
display: block;
|
|
137
|
+
}
|
package/demo/demo.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { getSdk } from "./sdk.js";
|
|
2
|
+
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
|
+
}
|
|
26
|
+
|
|
27
|
+
const sdk = await getSdk();
|
|
28
|
+
|
|
29
|
+
if (sdk.manifest?.name) {
|
|
30
|
+
document.title = `${sdk.manifest.name} - Demo`;
|
|
31
|
+
}
|
|
32
|
+
|
|
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];
|
|
36
|
+
|
|
37
|
+
// Determine BIOS visibility and requirement rules
|
|
38
|
+
const hasBiosSpec = Boolean(biosSpec);
|
|
39
|
+
const showBIOS = biosParam !== "false" && (hasBiosSpec || biosParam === "required" || biosParam === "true" || biosParam === "optional");
|
|
40
|
+
const requiresBIOS = biosParam === "required" || (biosParam !== "optional" && Boolean(biosSpec?.required));
|
|
41
|
+
|
|
42
|
+
// Load previously stored ROM & BIOS from OPFS if available
|
|
43
|
+
const [storedRom, storedBios] = await Promise.all([
|
|
44
|
+
loadStoredAsset("rom"),
|
|
45
|
+
showBIOS ? loadStoredAsset("bios") : null,
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const romAccept = romSpec?.accept?.join(",");
|
|
49
|
+
const romAssetHint = romSpec?.accept ? `Supported: ${romSpec.accept.join(", ")}` : "or choose ROM file from computer";
|
|
50
|
+
|
|
51
|
+
const biosAccept = biosSpec?.accept?.join(",");
|
|
52
|
+
const biosAssetHint = biosSpec?.description ?? (biosSpec?.accept ? `Supported: ${biosSpec.accept.join(", ")}` : (requiresBIOS ? "required BIOS firmware file" : "optional BIOS firmware file"));
|
|
53
|
+
|
|
54
|
+
async function toArrayBuffer(input) {
|
|
55
|
+
if (!input) return null;
|
|
56
|
+
if (input instanceof ArrayBuffer) return input;
|
|
57
|
+
if (ArrayBuffer.isView(input)) return input.buffer;
|
|
58
|
+
if (typeof input.arrayBuffer === "function") return await input.arrayBuffer();
|
|
59
|
+
if (input.detail) return await toArrayBuffer(input.detail);
|
|
60
|
+
throw new TypeError("Provided input is not a File, Blob, or ArrayBuffer.");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const bootWithFiles = async ({ rom, bios }) => {
|
|
64
|
+
const romBytes = await toArrayBuffer(rom);
|
|
65
|
+
const biosBytes = await toArrayBuffer(bios);
|
|
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;
|
|
95
|
+
}
|
|
96
|
+
if (Object.keys(assets).length === 0 && romBytes) {
|
|
97
|
+
assets.rom = romBytes;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const instance = await sdk.load({
|
|
101
|
+
attachTo: runtimeEl,
|
|
102
|
+
assets,
|
|
103
|
+
options: {
|
|
104
|
+
fileName: primaryFile.name,
|
|
105
|
+
byteLength: primaryFile.size,
|
|
106
|
+
},
|
|
107
|
+
onEvent(event) {
|
|
108
|
+
if (event.type === "error") {
|
|
109
|
+
console.error(event.error);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
instance.start();
|
|
115
|
+
|
|
116
|
+
const checksums = await computeChecksums(romBytes || biosBytes);
|
|
117
|
+
fileInfoPlaceholder.destroy();
|
|
118
|
+
|
|
119
|
+
CFileInfo
|
|
120
|
+
.renderShadow({ ...fileInfoProps, checksums })
|
|
121
|
+
.mount(fileInfoEl);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
CLauncher
|
|
125
|
+
.render({
|
|
126
|
+
CSdkInfo,
|
|
127
|
+
CDropArea,
|
|
128
|
+
sdkInfo: {
|
|
129
|
+
name: sdk.manifest.name,
|
|
130
|
+
id: sdk.manifest.id,
|
|
131
|
+
description: sdk.manifest.description,
|
|
132
|
+
version: sdk.manifest.version,
|
|
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);
|
package/demo/demo.sdk.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
export function createDummySdk() {
|
|
2
|
+
const manifest = {
|
|
3
|
+
id: "dummy-canvas-sdk",
|
|
4
|
+
version: "0.1.0-demo",
|
|
5
|
+
name: "Dummy Canvas Engine",
|
|
6
|
+
description: "A placeholder SDK that renders animated content on canvas.",
|
|
7
|
+
artifacts: {
|
|
8
|
+
wasm: "./dummy.wasm",
|
|
9
|
+
js: "./dummy.js",
|
|
10
|
+
},
|
|
11
|
+
assets: [
|
|
12
|
+
{
|
|
13
|
+
key: "rom",
|
|
14
|
+
mountPath: "/rom.bin",
|
|
15
|
+
required: true,
|
|
16
|
+
accept: [".rom", ".bin", ".zip", ".dat"],
|
|
17
|
+
description: "Any game ROM file works in this demo.",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
key: "bios",
|
|
21
|
+
mountPath: "/bios.bin",
|
|
22
|
+
required: false,
|
|
23
|
+
accept: [".bin", ".rom", ".sys"],
|
|
24
|
+
description: "Optional system BIOS firmware file.",
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
input: "keyboard-default",
|
|
28
|
+
video: {
|
|
29
|
+
baseWidth: 320,
|
|
30
|
+
baseHeight: 256,
|
|
31
|
+
aspect: "5:4",
|
|
32
|
+
},
|
|
33
|
+
capabilities: {
|
|
34
|
+
saveStates: false,
|
|
35
|
+
sram: false,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
manifest,
|
|
41
|
+
async load(config) {
|
|
42
|
+
const ownsCanvas = !config.canvasEl;
|
|
43
|
+
const canvasEl = config.canvasEl ?? document.createElement("canvas");
|
|
44
|
+
if (ownsCanvas) {
|
|
45
|
+
config.attachTo.appendChild(canvasEl);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ctx = canvasEl.getContext("2d");
|
|
49
|
+
if (!ctx) {
|
|
50
|
+
const error = new Error("Could not create 2D canvas context.");
|
|
51
|
+
config.onEvent?.({ type: "error", error });
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Copy loaded ROM and BIOS assets to MEMFS (In-Memory Virtual File System)
|
|
56
|
+
const MEMFS = new Map();
|
|
57
|
+
if (config.assets) {
|
|
58
|
+
for (const assetSpec of manifest.assets) {
|
|
59
|
+
const data = config.assets[assetSpec.key];
|
|
60
|
+
if (data) {
|
|
61
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
62
|
+
MEMFS.set(assetSpec.mountPath, bytes);
|
|
63
|
+
console.log(`[MEMFS] Copied asset '${assetSpec.key}' (${bytes.byteLength} bytes) to ${assetSpec.mountPath}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let raf = 0;
|
|
69
|
+
let running = false;
|
|
70
|
+
let t0 = performance.now();
|
|
71
|
+
let lastFrame = t0;
|
|
72
|
+
|
|
73
|
+
const fontSize = 30;
|
|
74
|
+
let drops = [];
|
|
75
|
+
|
|
76
|
+
const fitCanvasToWindow = () => {
|
|
77
|
+
const dpr = window.devicePixelRatio || 1;
|
|
78
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
79
|
+
const width = Math.max(1, Math.floor(rect.width * dpr));
|
|
80
|
+
const height = Math.max(1, Math.floor(rect.height * dpr));
|
|
81
|
+
canvasEl.width = width;
|
|
82
|
+
canvasEl.height = height;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const render = (time) => {
|
|
86
|
+
if (!running) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const width = canvasEl.width;
|
|
91
|
+
const height = canvasEl.height;
|
|
92
|
+
const dt = Math.min((time - lastFrame) / 1000, 0.1);
|
|
93
|
+
const fps = Math.max(1, 1000 / Math.max(1, time - lastFrame));
|
|
94
|
+
lastFrame = time;
|
|
95
|
+
|
|
96
|
+
const expectedColumns = Math.floor(width / fontSize);
|
|
97
|
+
const maxRows = Math.floor(height / fontSize) + 30;
|
|
98
|
+
|
|
99
|
+
if (drops.length !== expectedColumns) {
|
|
100
|
+
drops = [];
|
|
101
|
+
for (let x = 0; x < expectedColumns; x++) {
|
|
102
|
+
drops[x] = {
|
|
103
|
+
y: Math.random() * maxRows,
|
|
104
|
+
speed: Math.random() * 1.5 + 0.5,
|
|
105
|
+
opacity: Math.random() * 0.7 + 0.3,
|
|
106
|
+
length: Math.floor(Math.random() * 20 + 5),
|
|
107
|
+
chars: Array.from({ length: maxRows }, () => String.fromCharCode(0x30A0 + Math.random() * 96))
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Dark background inside the canvas to contrast with the light UI components
|
|
113
|
+
ctx.fillStyle = "#060910ff";
|
|
114
|
+
ctx.fillRect(0, 0, width, height);
|
|
115
|
+
|
|
116
|
+
ctx.font = "bold " + fontSize + "px 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Noto Sans CJK JP', sans-serif";
|
|
117
|
+
ctx.textAlign = "center";
|
|
118
|
+
|
|
119
|
+
for (let x = 0; x < drops.length; x++) {
|
|
120
|
+
let drop = drops[x];
|
|
121
|
+
|
|
122
|
+
if (Math.random() > 0.4) {
|
|
123
|
+
let randIdx = Math.floor(Math.random() * drop.chars.length);
|
|
124
|
+
drop.chars[randIdx] = String.fromCharCode(0x30A0 + Math.random() * 96);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
drop.y += drop.speed * (dt * 12);
|
|
128
|
+
|
|
129
|
+
let startY = Math.floor(drop.y);
|
|
130
|
+
|
|
131
|
+
for (let i = 0; i < drop.length; i++) {
|
|
132
|
+
let charY = startY - i;
|
|
133
|
+
if (charY < 0 || charY >= maxRows) continue;
|
|
134
|
+
|
|
135
|
+
let text = drop.chars[charY];
|
|
136
|
+
let alpha = drop.opacity * (1 - (i / drop.length));
|
|
137
|
+
|
|
138
|
+
if (i === 0) {
|
|
139
|
+
ctx.fillStyle = `rgba(220, 255, 255, ${drop.opacity})`;
|
|
140
|
+
} else {
|
|
141
|
+
const hue = ((time / 15) + (x * 10)) % 360;
|
|
142
|
+
ctx.fillStyle = `hsla(${hue}, 80%, 65%, ${alpha})`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
ctx.fillText(text, x * fontSize + fontSize / 2, charY * fontSize);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if ((drop.y - drop.length) * fontSize > height) {
|
|
149
|
+
drop.y = 0;
|
|
150
|
+
drop.speed = Math.random() * 1.5 + 0.5;
|
|
151
|
+
drop.opacity = Math.random() * 0.7 + 0.3;
|
|
152
|
+
drop.length = Math.floor(Math.random() * 20 + 5);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
ctx.fillStyle = "rgba(255,255,255,0.85)";
|
|
157
|
+
ctx.fillRect(width - 210, 20, 180, 64);
|
|
158
|
+
ctx.fillStyle = "#0f172a";
|
|
159
|
+
ctx.font = "600 36px 'Outfit', system-ui, sans-serif";
|
|
160
|
+
ctx.textAlign = "left";
|
|
161
|
+
ctx.fillText(`FPS ${fps.toFixed(0)}`, width - 182, 65);
|
|
162
|
+
|
|
163
|
+
config.onEvent?.({ type: "frame", fps });
|
|
164
|
+
raf = requestAnimationFrame(render);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
fitCanvasToWindow();
|
|
168
|
+
window.addEventListener("resize", fitCanvasToWindow);
|
|
169
|
+
config.onEvent?.({ type: "ready" });
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
start() {
|
|
173
|
+
if (running) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
running = true;
|
|
177
|
+
raf = requestAnimationFrame(render);
|
|
178
|
+
},
|
|
179
|
+
pause() {
|
|
180
|
+
running = false;
|
|
181
|
+
cancelAnimationFrame(raf);
|
|
182
|
+
},
|
|
183
|
+
resume() {
|
|
184
|
+
if (running) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
running = true;
|
|
188
|
+
raf = requestAnimationFrame(render);
|
|
189
|
+
},
|
|
190
|
+
reset() {
|
|
191
|
+
t0 = performance.now();
|
|
192
|
+
lastFrame = t0;
|
|
193
|
+
},
|
|
194
|
+
setInput() {
|
|
195
|
+
return;
|
|
196
|
+
},
|
|
197
|
+
destroy() {
|
|
198
|
+
running = false;
|
|
199
|
+
cancelAnimationFrame(raf);
|
|
200
|
+
window.removeEventListener("resize", fitCanvasToWindow);
|
|
201
|
+
if (ownsCanvas) {
|
|
202
|
+
canvasEl.remove();
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
package/demo/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>WASM Gaming Engine Specs Demo</title>
|
|
7
|
+
<link rel="stylesheet" href="./demo.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<main></main>
|
|
11
|
+
|
|
12
|
+
<script type="module" src="./demo.js"></script>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
package/demo/sdk.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createDummySdk } from "./demo.sdk.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the Engine SDK to use for the demo.
|
|
5
|
+
*
|
|
6
|
+
* To use this template in another repository:
|
|
7
|
+
* 1. Modify this file to import your engine's SDK:
|
|
8
|
+
* import sdk from "../src/index.js"; // or "../dist/index.js"
|
|
9
|
+
* export async function getSdk() { return sdk; }
|
|
10
|
+
*
|
|
11
|
+
* 2. Or attach window.SDK before demo.js boots:
|
|
12
|
+
* window.SDK = myEngineSdk;
|
|
13
|
+
*/
|
|
14
|
+
export async function getSdk() {
|
|
15
|
+
if (typeof window !== "undefined" && window.SDK) {
|
|
16
|
+
return window.SDK;
|
|
17
|
+
}
|
|
18
|
+
return createDummySdk();
|
|
19
|
+
}
|
package/demo/storage.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OPFS Asset Storage Helper for Engine Demo
|
|
3
|
+
* Persists ROM and BIOS files in the Origin Private File System (OPFS) across page reloads.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DIR_NAME = "wasm-gaming-demo";
|
|
7
|
+
|
|
8
|
+
async function getStorageDir() {
|
|
9
|
+
if (typeof navigator === "undefined" || !navigator.storage?.getDirectory) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const root = await navigator.storage.getDirectory();
|
|
14
|
+
return await root.getDirectoryHandle(DIR_NAME, { create: true });
|
|
15
|
+
} catch (err) {
|
|
16
|
+
console.warn("OPFS storage not accessible:", err);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Save an asset File into OPFS.
|
|
23
|
+
*/
|
|
24
|
+
export async function saveStoredAsset(key, file) {
|
|
25
|
+
const dir = await getStorageDir();
|
|
26
|
+
if (!dir || !file) return;
|
|
27
|
+
try {
|
|
28
|
+
const fileHandle = await dir.getFileHandle(`${key}.bin`, { create: true });
|
|
29
|
+
const writable = await fileHandle.createWritable();
|
|
30
|
+
const bytes = await file.arrayBuffer();
|
|
31
|
+
await writable.write(bytes);
|
|
32
|
+
await writable.close();
|
|
33
|
+
|
|
34
|
+
// Store file metadata
|
|
35
|
+
const metaHandle = await dir.getFileHandle(`${key}.json`, { create: true });
|
|
36
|
+
const metaWritable = await metaHandle.createWritable();
|
|
37
|
+
await metaWritable.write(
|
|
38
|
+
JSON.stringify({
|
|
39
|
+
name: file.name,
|
|
40
|
+
size: file.size || bytes.byteLength,
|
|
41
|
+
type: file.type || "application/octet-stream",
|
|
42
|
+
lastModified: file.lastModified || Date.now(),
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
await metaWritable.close();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.warn(`Failed to save ${key} in OPFS:`, err);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Retrieve a stored asset File from OPFS.
|
|
53
|
+
*/
|
|
54
|
+
export async function loadStoredAsset(key) {
|
|
55
|
+
const dir = await getStorageDir();
|
|
56
|
+
if (!dir) return null;
|
|
57
|
+
try {
|
|
58
|
+
const fileHandle = await dir.getFileHandle(`${key}.bin`);
|
|
59
|
+
const metaHandle = await dir.getFileHandle(`${key}.json`);
|
|
60
|
+
|
|
61
|
+
const fileObj = await fileHandle.getFile();
|
|
62
|
+
const metaText = await (await metaHandle.getFile()).text();
|
|
63
|
+
const meta = JSON.parse(metaText);
|
|
64
|
+
|
|
65
|
+
const bytes = await fileObj.arrayBuffer();
|
|
66
|
+
return new File([bytes], meta.name, {
|
|
67
|
+
type: meta.type,
|
|
68
|
+
lastModified: meta.lastModified,
|
|
69
|
+
});
|
|
70
|
+
} catch {
|
|
71
|
+
// File or meta doesn't exist yet
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Clear a stored asset from OPFS.
|
|
78
|
+
*/
|
|
79
|
+
export async function clearStoredAsset(key) {
|
|
80
|
+
const dir = await getStorageDir();
|
|
81
|
+
if (!dir) return;
|
|
82
|
+
try {
|
|
83
|
+
await dir.removeEntry(`${key}.bin`);
|
|
84
|
+
} catch {}
|
|
85
|
+
try {
|
|
86
|
+
await dir.removeEntry(`${key}.json`);
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wasm-gaming/engine-specs",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "The wasm-gaming engine contract: the single shared interface every WASM game/emulator engine and the host app conform to. TypeScript types + JSON Schema + a Zod runtime validator.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"dist/",
|
|
16
|
+
"demo/",
|
|
16
17
|
"src/",
|
|
17
18
|
"schema/"
|
|
18
19
|
],
|