@wasm-gaming/engine-specs 0.2.7 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -54
- package/demo/app.html +227 -0
- package/demo/canvas-renderer.js +153 -0
- package/demo/components/asset-picker.html +137 -0
- package/demo/components/call-log.html +33 -0
- package/demo/components/engine-header.html +78 -0
- package/demo/components/lifecycle-bar.html +69 -0
- package/demo/components/property-table.html +119 -0
- package/demo/demo.css +26 -130
- package/demo/demo.sdk.js +405 -203
- package/demo/index.html +7 -56
- package/demo/tsconfig.json +17 -0
- package/dist/engine-specs.d.ts +99 -0
- package/dist/engine-specs.d.ts.map +1 -0
- package/dist/engine-specs.js +3 -0
- package/dist/engine-specs.js.map +1 -0
- package/package.json +7 -12
- package/src/engine-specs.ts +110 -0
- package/demo/README.md +0 -155
- package/demo/checksums.js +0 -101
- package/demo/components/drop-area.html +0 -171
- package/demo/components/esc-menu.html +0 -540
- package/demo/components/launcher.html +0 -220
- package/demo/components/sdk-info.html +0 -54
- package/demo/demo.js +0 -622
- package/demo/sdk.js +0 -19
- package/demo/storage.js +0 -88
- package/dist/engine.d.ts +0 -427
- package/dist/engine.d.ts.map +0 -1
- package/dist/engine.js +0 -83
- package/dist/engine.js.map +0 -1
- package/dist/index.d.ts +0 -13
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -12
- package/dist/index.js.map +0 -1
- package/dist/manifest.d.ts +0 -69
- package/dist/manifest.d.ts.map +0 -1
- package/dist/manifest.js +0 -8
- package/dist/manifest.js.map +0 -1
- package/dist/validate.d.ts +0 -10
- package/dist/validate.d.ts.map +0 -1
- package/dist/validate.js +0 -79
- package/dist/validate.js.map +0 -1
- package/schema/engine-manifest.schema.json +0 -74
- package/src/engine.ts +0 -516
- package/src/index.ts +0 -55
- package/src/manifest.ts +0 -82
- package/src/validate.ts +0 -92
package/README.md
CHANGED
|
@@ -1,84 +1,93 @@
|
|
|
1
1
|
# @wasm-gaming/engine-specs
|
|
2
2
|
|
|
3
|
-
The **engine contract** for the wasm-gaming ecosystem:
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
The **engine contract** for the wasm-gaming ecosystem: one shared JS API that every
|
|
4
|
+
WASM game/emulator engine implements and every host drives. Define it once here,
|
|
5
|
+
depend on it everywhere, and engines can't drift.
|
|
6
6
|
|
|
7
|
-
TypeScript
|
|
8
|
-
|
|
9
|
-
the manifest for non-TS consumers; a Zod-based validator lets any CI fail on
|
|
10
|
-
drift.
|
|
7
|
+
TypeScript types and nothing else — [src/engine-specs.ts](src/engine-specs.ts) is the
|
|
8
|
+
whole package. No runtime dependencies, because twelve engines depend on this.
|
|
11
9
|
|
|
12
|
-
##
|
|
10
|
+
## The shape
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
map](https://wasm-gaming.github.io/engine-specs/api-docs/documents/Contract_map.html)
|
|
17
|
-
(how the types fit together) and the [usage
|
|
18
|
-
guide](https://wasm-gaming.github.io/engine-specs/api-docs/documents/Usage.html)
|
|
19
|
-
(host-side wiring, from validating a manifest to forwarding input).
|
|
12
|
+
An engine exports a class. Its **identity is static**, so a launcher can list engines
|
|
13
|
+
without constructing one:
|
|
20
14
|
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
```ts
|
|
16
|
+
import type { EngineSDKConstructor, EnginePayloads } from '@wasm-gaming/engine-specs';
|
|
23
17
|
|
|
24
|
-
|
|
18
|
+
interface Rsdkv4Payloads extends EnginePayloads {
|
|
19
|
+
config: { region: 'us' | 'eu' | 'jp' };
|
|
20
|
+
options: { player: 'sonic' | 'tails'; slot: number };
|
|
21
|
+
assets: { pack: Uint8Array };
|
|
22
|
+
storage: { namespace: string };
|
|
23
|
+
}
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
// The one line that belongs in an engine's CI: does the class satisfy the contract?
|
|
26
|
+
const _contract: EngineSDKConstructor<Rsdkv4Payloads> = Rsdkv4SDK;
|
|
27
|
+
```
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
Every engine package default-exports an `EngineSDK` = `{ manifest, load }`.
|
|
32
|
-
`load(config)` boots the engine and returns an `EngineInstance` the host drives —
|
|
33
|
-
identically for every engine.
|
|
29
|
+
A host configures by chaining, and gets the live engine back:
|
|
34
30
|
|
|
35
|
-
|
|
31
|
+
```js
|
|
32
|
+
const play = await new Rsdkv4SDK({ config: { region: 'eu' } })
|
|
33
|
+
.assets({ pack })
|
|
34
|
+
.mount(document.querySelector('#stage'))
|
|
35
|
+
.start({ player: 'tails', slot: 0 });
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
await play.restart({ slot: 3 });
|
|
38
|
+
await play.destroy();
|
|
39
|
+
```
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
import type { EngineSDK, EngineManifest } from '@wasm-gaming/engine-specs';
|
|
41
|
+
## Read and write are different shapes
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
`config`, `options`, `assets` and `storage` are each one method with two overloads.
|
|
44
|
+
Called with a patch it writes and keeps the chain; called with nothing it reads:
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
```js
|
|
47
|
+
sdk.config({ region: 'eu' }) // writes, chains
|
|
48
|
+
await sdk.config() // reads
|
|
49
|
+
// → { region: { value: 'eu', default: 'us', enum: [...], description: '…', live: false } }
|
|
47
50
|
```
|
|
48
51
|
|
|
49
|
-
|
|
52
|
+
The getter returns a **descriptor per key** — enough to paint a settings screen
|
|
53
|
+
without asking the engine anything, and without the host knowing a single key name.
|
|
54
|
+
`live` says whether a property can be changed on a running engine or needs a
|
|
55
|
+
`restart()`. The descriptor shape is never accepted on write: that asymmetry is what
|
|
56
|
+
keeps a write unambiguous the day a value is itself an object.
|
|
50
57
|
|
|
51
|
-
|
|
52
|
-
import { validateManifest, assertManifest } from '@wasm-gaming/engine-specs';
|
|
58
|
+
## Closing the types
|
|
53
59
|
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
One type parameter carries all four payloads, so there is no order to get wrong:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
type Sdk = EngineSDK<Rsdkv4Payloads>;
|
|
56
64
|
```
|
|
57
65
|
|
|
66
|
+
Leave it off and the payloads stay open (`Record<string, unknown>`), which compiles
|
|
67
|
+
but gives up the checking that is the point.
|
|
68
|
+
|
|
58
69
|
## Build
|
|
59
70
|
|
|
60
71
|
```bash
|
|
61
72
|
npm install
|
|
62
|
-
make build
|
|
63
|
-
make typecheck
|
|
64
|
-
make
|
|
65
|
-
make
|
|
66
|
-
make
|
|
67
|
-
make
|
|
68
|
-
make
|
|
69
|
-
make
|
|
73
|
+
make build # tsc → dist/ (cleans first)
|
|
74
|
+
make typecheck # the contract
|
|
75
|
+
make typecheck-tests # the compile-time conformance suite
|
|
76
|
+
make typecheck-demo # the demo engine, held to the same contract
|
|
77
|
+
make test # all of the above + the package integrity tests
|
|
78
|
+
make docs # typedoc → site/
|
|
79
|
+
make site # reference at /, demo at /demo/
|
|
80
|
+
make preview # serve site/ on :8020 (PORT= to change it)
|
|
70
81
|
```
|
|
71
82
|
|
|
72
|
-
##
|
|
73
|
-
|
|
74
|
-
Depend on it by version (published npm) or by git ref. Engines list it as a
|
|
75
|
-
(dev/peer) dependency and import only `type`s at compile time — the SDK itself
|
|
76
|
-
carries no runtime dependency on this package.
|
|
77
|
-
|
|
78
|
-
## Versioning
|
|
83
|
+
## Conformance
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
Two engines implement the contract in this repo, and both are type-checked in CI:
|
|
86
|
+
[tests/engine.conformance.ts](tests/engine.conformance.ts), the reference engine, and
|
|
87
|
+
[demo/demo.sdk.js](demo/demo.sdk.js), which drives the [demo
|
|
88
|
+
page](https://wasm-gaming.github.io/engine-specs/demo/). The negative cases live in
|
|
89
|
+
[tests/types.test-d.ts](tests/types.test-d.ts), guarded by `@ts-expect-error` — which
|
|
90
|
+
fails if a rejection ever stops happening.
|
|
82
91
|
|
|
83
92
|
## License
|
|
84
93
|
|
package/demo/app.html
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
The demo, as one root component.
|
|
3
|
+
|
|
4
|
+
It drives the engine only through the contract, and every panel is painted from
|
|
5
|
+
something the contract handed back:
|
|
6
|
+
|
|
7
|
+
- the header is the class's static identity, read before any engine exists;
|
|
8
|
+
- the pickers get their file filter from each asset descriptor's `allowedTypes`;
|
|
9
|
+
- the tables are `config()`, `options()`, `assets()` and `storage()` rendered as
|
|
10
|
+
they arrive — one component, and not one key name written on this side.
|
|
11
|
+
|
|
12
|
+
State lives in the `let`s below: reassigning one repaints what depends on it, so
|
|
13
|
+
there is no mount bookkeeping here.
|
|
14
|
+
-->
|
|
15
|
+
|
|
16
|
+
<EngineHeader :name="name" :id="id" :version="version" :description="description" />
|
|
17
|
+
|
|
18
|
+
<div class="layout">
|
|
19
|
+
<aside>
|
|
20
|
+
<section>
|
|
21
|
+
<h2>assets</h2>
|
|
22
|
+
<AssetPicker :assets="assets" @select="onSelect" @remove="onRemove" />
|
|
23
|
+
</section>
|
|
24
|
+
|
|
25
|
+
<section>
|
|
26
|
+
<h2>lifecycle</h2>
|
|
27
|
+
<LifecycleBar :running="running" @start="onStart" @restart="onRestart"
|
|
28
|
+
@pause="onPause" @resume="onResume" @destroy="onDestroy" />
|
|
29
|
+
</section>
|
|
30
|
+
|
|
31
|
+
<section>
|
|
32
|
+
<h2>calls</h2>
|
|
33
|
+
<CallLog :entries="calls" />
|
|
34
|
+
</section>
|
|
35
|
+
</aside>
|
|
36
|
+
|
|
37
|
+
<div>
|
|
38
|
+
<!-- The engine is handed this element and appends its own canvas to it. -->
|
|
39
|
+
<div id="stage"></div>
|
|
40
|
+
<PropertyTable :tables="tables" />
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<script :setup="{}">
|
|
45
|
+
const EngineHeader = await import('./components/engine-header.html');
|
|
46
|
+
const AssetPicker = await import('./components/asset-picker.html');
|
|
47
|
+
const PropertyTable = await import('./components/property-table.html');
|
|
48
|
+
const LifecycleBar = await import('./components/lifecycle-bar.html');
|
|
49
|
+
const CallLog = await import('./components/call-log.html');
|
|
50
|
+
|
|
51
|
+
const { DemoEngineSDK } = await import('./demo.sdk.js');
|
|
52
|
+
|
|
53
|
+
// Identity, off the class — no engine constructed to read it.
|
|
54
|
+
const name = DemoEngineSDK.name;
|
|
55
|
+
const id = DemoEngineSDK.id;
|
|
56
|
+
const version = DemoEngineSDK.version;
|
|
57
|
+
const description = DemoEngineSDK.description;
|
|
58
|
+
document.title = `${name} — engine-specs demo`;
|
|
59
|
+
|
|
60
|
+
let sdk;
|
|
61
|
+
let play;
|
|
62
|
+
let running = false;
|
|
63
|
+
let assets = [];
|
|
64
|
+
let tables = [];
|
|
65
|
+
let calls = [];
|
|
66
|
+
|
|
67
|
+
/** File names by asset key, for the pickers. The bytes live in the engine. */
|
|
68
|
+
const picked = {};
|
|
69
|
+
|
|
70
|
+
const say = (line) => {
|
|
71
|
+
calls = [line, ...calls].slice(0, 40);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const show = (value) => {
|
|
75
|
+
if (value === undefined) return 'unset';
|
|
76
|
+
if (value instanceof Uint8Array) return `${value.byteLength} bytes`;
|
|
77
|
+
return String(value);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** One getter's return value, flattened for the table component. */
|
|
81
|
+
const toRows = (properties) =>
|
|
82
|
+
Object.entries(properties).map(([key, property]) => ({
|
|
83
|
+
key,
|
|
84
|
+
value: show(property.value),
|
|
85
|
+
unset: property.value === undefined,
|
|
86
|
+
description: property.description ?? '',
|
|
87
|
+
default: property.default === undefined ? '' : show(property.default),
|
|
88
|
+
enum: property.enum ? property.enum.join(' | ') : '',
|
|
89
|
+
allowedTypes: property.allowedTypes ? property.allowedTypes.join(' ') : '',
|
|
90
|
+
live: Boolean(property.live),
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
/** Reads the live engine — or the factory, when there is not one yet. */
|
|
94
|
+
const refresh = async () => {
|
|
95
|
+
const source = play ?? (sdk ??= new DemoEngineSDK());
|
|
96
|
+
const [config, options, assetProps, storage] = await Promise.all([
|
|
97
|
+
source.config(),
|
|
98
|
+
source.options(),
|
|
99
|
+
source.assets(),
|
|
100
|
+
source.storage(),
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
assets = Object.entries(assetProps).map(([key, property]) => ({
|
|
104
|
+
key,
|
|
105
|
+
description: property.description ?? '',
|
|
106
|
+
accept: (property.allowedTypes ?? []).join(','),
|
|
107
|
+
accepts: property.allowedTypes ? `accepts ${property.allowedTypes.join(' ')}` : 'any file',
|
|
108
|
+
fileName: picked[key] ?? '',
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
tables = [
|
|
112
|
+
{ title: 'config', rows: toRows(config) },
|
|
113
|
+
{ title: 'options', rows: toRows(options) },
|
|
114
|
+
{ title: 'assets', rows: toRows(assetProps) },
|
|
115
|
+
{ title: 'storage', rows: toRows(storage) },
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
running = Boolean(play);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const onSelect = async ({ key, file }) => {
|
|
122
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
123
|
+
picked[key] = file.name;
|
|
124
|
+
sdk ??= new DemoEngineSDK();
|
|
125
|
+
say(`sdk.assets({ ${key}: Uint8Array(${bytes.byteLength}) })`);
|
|
126
|
+
sdk.assets({ [key]: bytes });
|
|
127
|
+
if (key === 'rom') {
|
|
128
|
+
say(`sdk.options({ fileName: '${file.name}', byteLength: ${file.size} })`);
|
|
129
|
+
sdk.options({ fileName: file.name, byteLength: file.size });
|
|
130
|
+
}
|
|
131
|
+
await refresh();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const onRemove = async ({ key }) => {
|
|
135
|
+
delete picked[key];
|
|
136
|
+
say(`sdk.assets({ ${key}: undefined })`);
|
|
137
|
+
sdk?.assets({ [key]: undefined });
|
|
138
|
+
await refresh();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const onStart = async () => {
|
|
142
|
+
sdk ??= new DemoEngineSDK();
|
|
143
|
+
say('play = await sdk.mount(stage).start()');
|
|
144
|
+
// The whole chain in one expression, exactly as the contract advertises it.
|
|
145
|
+
play = await sdk.mount(document.querySelector('#stage')).start();
|
|
146
|
+
await refresh();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const onRestart = async () => {
|
|
150
|
+
say('await play.restart()');
|
|
151
|
+
await play.restart();
|
|
152
|
+
await refresh();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// `pause` and `resume` are this engine's own members, not the contract's.
|
|
156
|
+
const onPause = async () => {
|
|
157
|
+
say('play.pause() // engine-specific, not in the contract');
|
|
158
|
+
play.pause();
|
|
159
|
+
await refresh();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const onResume = async () => {
|
|
163
|
+
say('play.resume() // engine-specific, not in the contract');
|
|
164
|
+
play.resume();
|
|
165
|
+
await refresh();
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const onDestroy = async () => {
|
|
169
|
+
say('await play.destroy()');
|
|
170
|
+
await play.destroy();
|
|
171
|
+
play = undefined;
|
|
172
|
+
sdk = undefined;
|
|
173
|
+
for (const key of Object.keys(picked)) delete picked[key];
|
|
174
|
+
await refresh();
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
say('ready — identity read from the class, no engine constructed yet');
|
|
178
|
+
await refresh();
|
|
179
|
+
</script>
|
|
180
|
+
|
|
181
|
+
<style>
|
|
182
|
+
.layout {
|
|
183
|
+
display: grid;
|
|
184
|
+
grid-template-columns: minmax(280px, 340px) 1fr;
|
|
185
|
+
gap: 1.5rem;
|
|
186
|
+
padding: 1.5rem;
|
|
187
|
+
align-items: start;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@media (max-width: 900px) {
|
|
191
|
+
.layout {
|
|
192
|
+
grid-template-columns: 1fr;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
aside section {
|
|
197
|
+
background: var(--panel, #fff);
|
|
198
|
+
border: 1px solid var(--border, #e2e8f0);
|
|
199
|
+
border-radius: 4px;
|
|
200
|
+
padding: 1rem;
|
|
201
|
+
margin-bottom: 1rem;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
aside h2 {
|
|
205
|
+
margin: 0 0 0.75rem;
|
|
206
|
+
font-size: 0.78rem;
|
|
207
|
+
font-family: var(--mono, monospace);
|
|
208
|
+
text-transform: uppercase;
|
|
209
|
+
letter-spacing: 0.08em;
|
|
210
|
+
color: var(--muted, #64748b);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#stage {
|
|
214
|
+
height: 380px;
|
|
215
|
+
border: 1px solid var(--border, #e2e8f0);
|
|
216
|
+
border-radius: 4px;
|
|
217
|
+
overflow: hidden;
|
|
218
|
+
background: #060910;
|
|
219
|
+
margin-bottom: 1rem;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#stage canvas {
|
|
223
|
+
display: block;
|
|
224
|
+
width: 100%;
|
|
225
|
+
height: 100%;
|
|
226
|
+
}
|
|
227
|
+
</style>
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The demo's picture, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* Isolated from the SDK on purpose: it knows about a canvas and a frame callback,
|
|
5
|
+
* not about manifests, assets or the engine contract. Whatever shape the demo SDK
|
|
6
|
+
* takes next, this keeps working untouched — it is the part that was never about
|
|
7
|
+
* the contract.
|
|
8
|
+
*
|
|
9
|
+
* @param {HTMLCanvasElement} canvasEl
|
|
10
|
+
* @param {{ onFrame?: (fps: number) => void }} [options]
|
|
11
|
+
*/
|
|
12
|
+
export function createCanvasRenderer(canvasEl, { onFrame } = {}) {
|
|
13
|
+
const ctx = canvasEl.getContext("2d");
|
|
14
|
+
if (!ctx) {
|
|
15
|
+
throw new Error("Could not create 2D canvas context.");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let raf = 0;
|
|
19
|
+
let running = false;
|
|
20
|
+
let lastFrame = performance.now();
|
|
21
|
+
|
|
22
|
+
const fontSize = 30;
|
|
23
|
+
/** @typedef {{ y: number, speed: number, opacity: number, length: number, chars: string[] }} Drop */
|
|
24
|
+
/** @type {Drop[]} */
|
|
25
|
+
let drops = [];
|
|
26
|
+
|
|
27
|
+
const fitCanvasToWindow = () => {
|
|
28
|
+
const dpr = window.devicePixelRatio || 1;
|
|
29
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
30
|
+
canvasEl.width = Math.max(1, Math.floor(rect.width * dpr));
|
|
31
|
+
canvasEl.height = Math.max(1, Math.floor(rect.height * dpr));
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** @param {number} time */
|
|
35
|
+
const render = (time) => {
|
|
36
|
+
if (!running) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const width = canvasEl.width;
|
|
41
|
+
const height = canvasEl.height;
|
|
42
|
+
const dt = Math.min((time - lastFrame) / 1000, 0.1);
|
|
43
|
+
const fps = Math.max(1, 1000 / Math.max(1, time - lastFrame));
|
|
44
|
+
lastFrame = time;
|
|
45
|
+
|
|
46
|
+
const expectedColumns = Math.floor(width / fontSize);
|
|
47
|
+
const maxRows = Math.floor(height / fontSize) + 30;
|
|
48
|
+
|
|
49
|
+
if (drops.length !== expectedColumns) {
|
|
50
|
+
drops = [];
|
|
51
|
+
for (let x = 0; x < expectedColumns; x++) {
|
|
52
|
+
drops[x] = {
|
|
53
|
+
y: Math.random() * maxRows,
|
|
54
|
+
speed: Math.random() * 1.5 + 0.5,
|
|
55
|
+
opacity: Math.random() * 0.7 + 0.3,
|
|
56
|
+
length: Math.floor(Math.random() * 20 + 5),
|
|
57
|
+
chars: Array.from({ length: maxRows }, () => String.fromCharCode(0x30A0 + Math.random() * 96)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Dark background inside the canvas to contrast with the light UI components
|
|
63
|
+
ctx.fillStyle = "#060910ff";
|
|
64
|
+
ctx.fillRect(0, 0, width, height);
|
|
65
|
+
|
|
66
|
+
ctx.font = "bold " + fontSize + "px 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Noto Sans CJK JP', sans-serif";
|
|
67
|
+
ctx.textAlign = "center";
|
|
68
|
+
|
|
69
|
+
for (const [x, drop] of drops.entries()) {
|
|
70
|
+
if (Math.random() > 0.4) {
|
|
71
|
+
let randIdx = Math.floor(Math.random() * drop.chars.length);
|
|
72
|
+
drop.chars[randIdx] = String.fromCharCode(0x30A0 + Math.random() * 96);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
drop.y += drop.speed * (dt * 12);
|
|
76
|
+
|
|
77
|
+
let startY = Math.floor(drop.y);
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < drop.length; i++) {
|
|
80
|
+
let charY = startY - i;
|
|
81
|
+
if (charY < 0 || charY >= maxRows) continue;
|
|
82
|
+
|
|
83
|
+
const text = drop.chars[charY];
|
|
84
|
+
if (text === undefined) continue;
|
|
85
|
+
let alpha = drop.opacity * (1 - (i / drop.length));
|
|
86
|
+
|
|
87
|
+
if (i === 0) {
|
|
88
|
+
ctx.fillStyle = `rgba(220, 255, 255, ${drop.opacity})`;
|
|
89
|
+
} else {
|
|
90
|
+
const hue = ((time / 15) + (x * 10)) % 360;
|
|
91
|
+
ctx.fillStyle = `hsla(${hue}, 80%, 65%, ${alpha})`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
ctx.fillText(text, x * fontSize + fontSize / 2, charY * fontSize);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if ((drop.y - drop.length) * fontSize > height) {
|
|
98
|
+
drop.y = 0;
|
|
99
|
+
drop.speed = Math.random() * 1.5 + 0.5;
|
|
100
|
+
drop.opacity = Math.random() * 0.7 + 0.3;
|
|
101
|
+
drop.length = Math.floor(Math.random() * 20 + 5);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
ctx.fillStyle = "rgba(255,255,255,0.85)";
|
|
106
|
+
ctx.fillRect(width - 210, 20, 180, 64);
|
|
107
|
+
ctx.fillStyle = "#0f172a";
|
|
108
|
+
ctx.font = "600 36px 'Outfit', system-ui, sans-serif";
|
|
109
|
+
ctx.textAlign = "left";
|
|
110
|
+
ctx.fillText(`FPS ${fps.toFixed(0)}`, width - 182, 65);
|
|
111
|
+
|
|
112
|
+
onFrame?.(fps);
|
|
113
|
+
raf = requestAnimationFrame(render);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
fitCanvasToWindow();
|
|
117
|
+
window.addEventListener("resize", fitCanvasToWindow);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
get running() {
|
|
121
|
+
return running;
|
|
122
|
+
},
|
|
123
|
+
start() {
|
|
124
|
+
if (running) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
running = true;
|
|
128
|
+
lastFrame = performance.now();
|
|
129
|
+
raf = requestAnimationFrame(render);
|
|
130
|
+
},
|
|
131
|
+
pause() {
|
|
132
|
+
running = false;
|
|
133
|
+
cancelAnimationFrame(raf);
|
|
134
|
+
},
|
|
135
|
+
resume() {
|
|
136
|
+
if (running) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
running = true;
|
|
140
|
+
lastFrame = performance.now();
|
|
141
|
+
raf = requestAnimationFrame(render);
|
|
142
|
+
},
|
|
143
|
+
reset() {
|
|
144
|
+
lastFrame = performance.now();
|
|
145
|
+
drops = [];
|
|
146
|
+
},
|
|
147
|
+
destroy() {
|
|
148
|
+
running = false;
|
|
149
|
+
cancelAnimationFrame(raf);
|
|
150
|
+
window.removeEventListener("resize", fitCanvasToWindow);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
The engine's assets, each driven by its own descriptor.
|
|
3
|
+
|
|
4
|
+
Everything shown comes from what `assets()` returned: the hint is the descriptor's
|
|
5
|
+
`description`, and the file dialog's filter is its `allowedTypes`. The page
|
|
6
|
+
hardcodes nothing about any asset — add a third one to the engine and it appears
|
|
7
|
+
here on its own.
|
|
8
|
+
-->
|
|
9
|
+
<div class="pickers">
|
|
10
|
+
<section class="drop" :each="(asset, index) in assets"
|
|
11
|
+
:class.selected="asset.fileName"
|
|
12
|
+
@dragover.prevent="dragging = asset.key"
|
|
13
|
+
@dragleave="dragging = null"
|
|
14
|
+
@drop.prevent="onDrop($event, asset)"
|
|
15
|
+
:class.active="dragging === asset.key">
|
|
16
|
+
<strong>{{ asset.key }}</strong>
|
|
17
|
+
<p class="hint">{{ asset.description }}</p>
|
|
18
|
+
<p :class.file-selected="asset.fileName">{{ asset.fileName || asset.accepts }}</p>
|
|
19
|
+
<div class="picker-actions">
|
|
20
|
+
<label class="picker">
|
|
21
|
+
{{ asset.fileName ? 'Change' : 'Pick file' }}
|
|
22
|
+
<input type="file" :accept="asset.accept" @change="onPick($event, asset)" />
|
|
23
|
+
</label>
|
|
24
|
+
<button type="button" :if="asset.fileName" class="remove-btn" @click.stop="$emit('remove', asset)">Remove</button>
|
|
25
|
+
</div>
|
|
26
|
+
</section>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<script :setup="{
|
|
30
|
+
assets = [],
|
|
31
|
+
}">
|
|
32
|
+
let dragging = null;
|
|
33
|
+
|
|
34
|
+
const extractFile = (arg) => {
|
|
35
|
+
if (!arg) return null;
|
|
36
|
+
if (arg instanceof File) return arg;
|
|
37
|
+
if (arg.detail instanceof File) return arg.detail;
|
|
38
|
+
if (arg.target?.files?.item(0)) return arg.target.files.item(0);
|
|
39
|
+
if (arg.dataTransfer?.files?.item(0)) return arg.dataTransfer.files.item(0);
|
|
40
|
+
return null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const onDrop = (event, asset) => {
|
|
44
|
+
dragging = null;
|
|
45
|
+
const file = extractFile(event);
|
|
46
|
+
if (file) $emit('select', { key: asset.key, file });
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const onPick = (event, asset) => {
|
|
50
|
+
const file = extractFile(event);
|
|
51
|
+
if (file) $emit('select', { key: asset.key, file });
|
|
52
|
+
};
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<style>
|
|
56
|
+
.pickers {
|
|
57
|
+
display: grid;
|
|
58
|
+
gap: 0.6rem;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.drop {
|
|
62
|
+
border: 1px dashed var(--border, #e2e8f0);
|
|
63
|
+
border-radius: 4px;
|
|
64
|
+
padding: 1rem;
|
|
65
|
+
text-align: center;
|
|
66
|
+
transition: border-color 200ms ease, background 200ms ease;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.drop.active {
|
|
70
|
+
border-color: var(--accent, #ff007f);
|
|
71
|
+
background: rgb(255 0 127 / 0.06);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.drop.selected {
|
|
75
|
+
border-style: solid;
|
|
76
|
+
border-color: var(--accent, #ff007f);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.drop strong {
|
|
80
|
+
display: block;
|
|
81
|
+
font-family: var(--mono, monospace);
|
|
82
|
+
font-size: 0.9rem;
|
|
83
|
+
font-weight: 600;
|
|
84
|
+
color: var(--text, #0f172a);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.drop p {
|
|
88
|
+
margin: 0.35rem 0 0;
|
|
89
|
+
color: var(--muted, #64748b);
|
|
90
|
+
font-size: 0.78rem;
|
|
91
|
+
word-break: break-all;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.drop p.hint {
|
|
95
|
+
font-size: 0.8rem;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.drop p.file-selected {
|
|
99
|
+
color: var(--accent, #ff007f);
|
|
100
|
+
font-family: var(--mono, monospace);
|
|
101
|
+
font-weight: 600;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.picker-actions {
|
|
105
|
+
display: inline-flex;
|
|
106
|
+
align-items: center;
|
|
107
|
+
gap: 0.4rem;
|
|
108
|
+
margin-top: 0.75rem;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.picker,
|
|
112
|
+
.remove-btn {
|
|
113
|
+
display: inline-flex;
|
|
114
|
+
font-family: var(--mono, monospace);
|
|
115
|
+
font-size: 0.75rem;
|
|
116
|
+
padding: 0.35rem 0.7rem;
|
|
117
|
+
border: 1px solid var(--border, #e2e8f0);
|
|
118
|
+
border-radius: 3px;
|
|
119
|
+
background: transparent;
|
|
120
|
+
color: var(--text, #0f172a);
|
|
121
|
+
cursor: pointer;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.picker:hover {
|
|
125
|
+
border-color: var(--accent, #ff007f);
|
|
126
|
+
color: var(--accent, #ff007f);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.picker input {
|
|
130
|
+
display: none;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.remove-btn:hover {
|
|
134
|
+
border-color: #ef4444;
|
|
135
|
+
color: #ef4444;
|
|
136
|
+
}
|
|
137
|
+
</style>
|