@wasm-gaming/engine-specs 0.3.0 → 0.3.2

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.sdk.js CHANGED
@@ -2,54 +2,91 @@
2
2
  /**
3
3
  * The demo engine, written against the contract in `src/engine-specs.ts`.
4
4
  *
5
- * It is a real implementation, not a mock: the four payloads are closed with named
6
- * types, every getter returns descriptors, every setter chains, and `boot()` is
7
- * idempotent. `demo/tsconfig.json` checks it against the contract, so "the demo
8
- * conforms" is verified rather than claimed.
5
+ * It is a real implementation, not a mock, and it is now written the way an engine is
6
+ * meant to be written: `EnginePlayBase` and `EngineSDKBase` carry the state, the
7
+ * descriptors, the cumulative merge, the chainable promise and the channel, and what
8
+ * is left here is the part that is actually about drawing on a canvas.
9
9
  *
10
- * What used to live in a manifest lives here now:
11
- * `id`/`version`/`name`/`description` are static on the class, the video geometry is
12
- * `config`, and what an asset accepts is its descriptor's `allowedTypes`.
10
+ * `demo/tsconfig.json` checks it against the contract with `checkJs`, so "the demo
11
+ * conforms" is verified rather than claimed — and it is also the check that the base
12
+ * classes are usable from plain JS with JSDoc, not only from TypeScript.
13
+ *
14
+ * What used to live in a manifest lives here now: `id`/`version`/`name`/`description`
15
+ * are static on the class, the video geometry is `config`, and what an asset accepts
16
+ * is its descriptor's `allowedTypes`.
13
17
  */
14
18
 
19
+ import { EnginePlayBase, EngineSDKBase } from '../src/engine-specs.js'
15
20
  import { createCanvasRenderer } from './canvas-renderer.js'
16
21
 
17
22
  /**
23
+ * This engine omits `events` from its payload bundle, unlike the reference engine in
24
+ * `tests/engine.conformance.ts` which declares one of its own. Both are legal, and
25
+ * between the two they cover both branches of `EventsOf`: here it resolves to the
26
+ * core set alone.
27
+ *
18
28
  * @typedef {{ baseWidth: number, baseHeight: number }} DemoConfig
19
29
  * @typedef {{ fileName?: string, byteLength?: number }} DemoOptions
20
30
  * @typedef {{ rom?: Uint8Array, bios?: Uint8Array }} DemoAssets
21
31
  * @typedef {{ namespace: string }} DemoStorage
22
32
  * @typedef {{ config: DemoConfig, options: DemoOptions, assets: DemoAssets, storage: DemoStorage }} DemoPayloads
23
33
  *
34
+ * @typedef {import('../src/engine-specs.js').EventsOf<DemoPayloads>} DemoEvents
24
35
  * @typedef {import('../src/engine-specs.js').EngineInit<DemoPayloads>} DemoInit
25
- * @typedef {import('../src/engine-specs.js').EnginePlay<DemoPayloads>} DemoPlayContract
26
- * @typedef {import('../src/engine-specs.js').PromisePlay<DemoPayloads>} DemoPromisePlay
27
- * @typedef {import('../src/engine-specs.js').EngineSDK<DemoPayloads>} DemoSDKContract
36
+ * @typedef {import('../src/engine-specs.js').EngineSpec<DemoPayloads>} DemoSpec
37
+ * @typedef {import('../src/engine-specs.js').EventEmitter<DemoEvents>} DemoChannel
28
38
  * @typedef {import('../src/engine-specs.js').EngineSDKConstructor<DemoPayloads>} DemoSDKConstructor
29
39
  */
30
40
 
31
- /** @template T @typedef {import('../src/engine-specs.js').PropertiesDefinition<T>} Props */
41
+ /**
42
+ * What this engine declares about its properties. `default` appears here and nowhere
43
+ * else — the base class seeds its starting state from it, so the initial value and
44
+ * the advertised default cannot drift apart.
45
+ *
46
+ * @type {DemoSpec}
47
+ */
48
+ const spec = {
49
+ config: {
50
+ baseWidth: { default: 320, description: 'Native render width, in pixels.', live: false },
51
+ baseHeight: { default: 256, description: 'Native render height, in pixels.', live: false },
52
+ },
53
+ options: {
54
+ fileName: { description: 'Name of the loaded ROM.', live: true },
55
+ byteLength: { description: 'Size of the loaded ROM, in bytes.', live: true },
56
+ },
57
+ assets: {
58
+ // `allowedTypes` is what a host file picker needs: it replaces the manifest's
59
+ // `AssetSpec.accept`, and now travels with the value it describes.
60
+ rom: {
61
+ description: 'Any game ROM file works in this demo.',
62
+ allowedTypes: ['.rom', '.bin', '.zip', '.dat'],
63
+ live: false,
64
+ },
65
+ bios: {
66
+ description: 'System BIOS firmware file.',
67
+ allowedTypes: ['.bin', '.rom', '.sys'],
68
+ live: false,
69
+ },
70
+ },
71
+ storage: {
72
+ namespace: { default: 'engine-specs/demo', description: 'Folder the engine owns.', live: false },
73
+ },
74
+ }
32
75
 
33
76
  /**
34
77
  * The live engine.
35
78
  *
79
+ * Everything the contract prescribes is inherited. What is below is this engine and
80
+ * nothing else: a canvas, a renderer, and a pretend file system.
81
+ *
36
82
  * `pause()` and `resume()` are **not** part of the contract — they are this engine's
37
- * own additions, which the demo's ESC menu drives. Anything the contract declares is
38
- * below them.
83
+ * own methods, which the demo's ESC menu drives. What *is* in the contract is what
84
+ * they emit: `pause` and `resume` are core events, so a host can react to the ESC
85
+ * menu without knowing this class has those methods.
39
86
  *
40
- * @implements {DemoPlayContract}
87
+ * @extends {EnginePlayBase<DemoPayloads>}
41
88
  */
42
- class DemoPlay {
43
- /** @type {DemoConfig} */
44
- #config = { baseWidth: 320, baseHeight: 256 }
45
- /** @type {DemoOptions} */
46
- #options = {}
47
- /** @type {DemoAssets} */
48
- #assets = {}
49
- /** @type {DemoStorage} */
50
- #storage = { namespace: 'engine-specs/demo' }
51
- /** @type {HTMLElement | undefined} */
52
- #mount
89
+ class DemoPlay extends EnginePlayBase {
53
90
  /** @type {HTMLCanvasElement | undefined} */
54
91
  #canvas
55
92
  /** @type {ReturnType<typeof createCanvasRenderer> | undefined} */
@@ -57,173 +94,60 @@ class DemoPlay {
57
94
  /** The engine's own file system, keyed by mount path. */
58
95
  #memfs = new Map()
59
96
 
60
- /** @param {DemoInit} [init] */
61
- constructor(init) {
62
- if (init?.config) Object.assign(this.#config, init.config)
63
- if (init?.options) Object.assign(this.#options, init.options)
64
- if (init?.assets) Object.assign(this.#assets, init.assets)
65
- if (init?.storage) Object.assign(this.#storage, init.storage)
66
- if (init?.mount) this.mount(init.mount)
67
- this.#copyAssetsToMemfs()
68
- }
69
-
70
- #copyAssetsToMemfs() {
71
- for (const [key, data] of Object.entries(this.#assets)) {
72
- if (!data) continue
73
- const bytes = data instanceof Uint8Array ? data : new Uint8Array(data)
74
- this.#memfs.set(`/${key}.bin`, bytes)
75
- console.log(`[MEMFS] ${key} → /${key}.bin (${bytes.byteLength} bytes)`)
76
- }
77
- }
78
-
79
- /**
80
- * @overload
81
- * @returns {Promise<Props<DemoConfig>>}
82
- * @overload
83
- * @param {Partial<DemoConfig>} patch
84
- * @returns {DemoPromisePlay}
85
- * @param {Partial<DemoConfig>} [patch]
86
- * @returns {any}
87
- */
88
- config(patch) {
89
- if (arguments.length === 0) {
90
- return Promise.resolve({
91
- baseWidth: {
92
- value: this.#config.baseWidth,
93
- default: 320,
94
- description: 'Native render width, in pixels.',
95
- live: false,
96
- },
97
- baseHeight: {
98
- value: this.#config.baseHeight,
99
- default: 256,
100
- description: 'Native render height, in pixels.',
101
- live: false,
102
- },
103
- })
104
- }
105
- Object.assign(this.#config, patch)
106
- return hydrate(Promise.resolve(this))
107
- }
108
-
109
97
  /**
110
- * @overload
111
- * @returns {Promise<Props<DemoOptions>>}
112
- * @overload
113
- * @param {Partial<DemoOptions>} patch
114
- * @returns {DemoPromisePlay}
115
- * @param {Partial<DemoOptions>} [patch]
116
- * @returns {any}
117
- */
118
- options(patch) {
119
- if (arguments.length === 0) {
120
- return Promise.resolve({
121
- fileName: {
122
- value: this.#options.fileName,
123
- description: 'Name of the loaded ROM.',
124
- live: true,
125
- },
126
- byteLength: {
127
- value: this.#options.byteLength,
128
- description: 'Size of the loaded ROM, in bytes.',
129
- live: true,
130
- },
131
- })
132
- }
133
- Object.assign(this.#options, patch)
134
- return hydrate(Promise.resolve(this))
135
- }
136
-
137
- /**
138
- * `allowedTypes` is what a host file picker needs: it replaces the manifest's
139
- * `AssetSpec.accept`, and now travels with the value it describes.
98
+ * The spec goes up to the base rather than being a field here: a subclass field
99
+ * initialiser runs after `super()`, which would be too late to seed state from.
140
100
  *
141
- * @overload
142
- * @returns {Promise<Props<DemoAssets>>}
143
- * @overload
144
- * @param {Partial<DemoAssets>} patch
145
- * @returns {DemoPromisePlay}
146
- * @param {Partial<DemoAssets>} [patch]
147
- * @returns {any}
148
- */
149
- assets(patch) {
150
- if (arguments.length === 0) {
151
- return Promise.resolve({
152
- rom: {
153
- value: this.#assets.rom,
154
- description: 'Any game ROM file works in this demo.',
155
- allowedTypes: ['.rom', '.bin', '.zip', '.dat'],
156
- live: false,
157
- },
158
- bios: {
159
- value: this.#assets.bios,
160
- description: 'System BIOS firmware file.',
161
- allowedTypes: ['.bin', '.rom', '.sys'],
162
- live: false,
163
- },
164
- })
165
- }
166
- Object.assign(this.#assets, patch)
167
- this.#copyAssetsToMemfs()
168
- return hydrate(Promise.resolve(this))
169
- }
170
-
171
- /**
172
- * @overload
173
- * @returns {Promise<Props<DemoStorage>>}
174
- * @overload
175
- * @param {Partial<DemoStorage>} patch
176
- * @returns {DemoPromisePlay}
177
- * @param {Partial<DemoStorage>} [patch]
178
- * @returns {any}
101
+ * @param {DemoInit} [init]
102
+ * @param {DemoChannel} [events]
179
103
  */
180
- storage(patch) {
181
- if (arguments.length === 0) {
182
- return Promise.resolve({
183
- namespace: {
184
- value: this.#storage.namespace,
185
- description: 'Folder the engine owns.',
186
- live: false,
187
- },
188
- })
189
- }
190
- Object.assign(this.#storage, patch)
191
- return hydrate(Promise.resolve(this))
104
+ constructor(init, events) {
105
+ super(spec, init, events)
192
106
  }
193
107
 
194
108
  /**
195
109
  * Where the picture goes. The engine owns the canvas; the host owns the container.
110
+ * The element is already recorded by the base when this runs.
111
+ *
112
+ * @protected
196
113
  * @param {HTMLElement} target
197
- * @returns {DemoPromisePlay}
198
114
  */
199
- mount(target) {
200
- this.#mount = target
201
- if (!this.#canvas) {
202
- this.#canvas = document.createElement('canvas')
203
- }
115
+ mounted(target) {
116
+ this.#canvas ??= document.createElement('canvas')
204
117
  target.appendChild(this.#canvas)
205
118
  this.#renderer ??= createCanvasRenderer(this.#canvas)
206
- return hydrate(Promise.resolve(this))
207
119
  }
208
120
 
209
121
  /**
210
- * Opens or reopens a session. The payload is cumulative, merged flat, one level.
211
- * @param {Partial<DemoOptions>} [options]
212
- * @returns {DemoPromisePlay}
122
+ * A session opens — or reopens. The assets are read back through the contract
123
+ * rather than kept in a second field here, which is the point of the base owning
124
+ * the state.
125
+ *
126
+ * @protected
127
+ * @param {DemoOptions} options
213
128
  */
214
- restart(options) {
215
- if (options) Object.assign(this.#options, options)
129
+ async open(options) {
130
+ void options
131
+ const assets = await this.assets()
132
+ for (const [key, property] of Object.entries(assets)) {
133
+ const data = property.value
134
+ if (!data) continue
135
+ this.#memfs.set(`/${key}.bin`, data)
136
+ console.log(`[MEMFS] ${key} → /${key}.bin (${data.byteLength} bytes)`)
137
+ }
216
138
  this.#renderer?.reset()
217
139
  this.#renderer?.start()
218
- return hydrate(Promise.resolve(this))
219
140
  }
220
141
 
221
- async destroy() {
142
+ /**
143
+ * Stop and release. `exit` is emitted by the base once this returns.
144
+ * @protected
145
+ */
146
+ close() {
222
147
  this.#renderer?.destroy()
223
148
  this.#renderer = undefined
224
149
  this.#canvas?.remove()
225
150
  this.#canvas = undefined
226
- this.#mount = undefined
227
151
  this.#memfs.clear()
228
152
  }
229
153
 
@@ -231,178 +155,36 @@ class DemoPlay {
231
155
 
232
156
  pause() {
233
157
  this.#renderer?.pause()
158
+ this.emit('pause', { owner: 'demo-esc-menu' })
234
159
  }
235
160
 
236
161
  resume() {
237
162
  this.#renderer?.resume()
163
+ this.emit('resume', { owner: 'demo-esc-menu' })
238
164
  }
239
165
  }
240
166
 
241
- /**
242
- * Wraps a promise of the engine in the chainable surface.
243
- *
244
- * Structural, as the contract allows: not a `Promise` subclass, a delegation to one
245
- * — plus `[Symbol.toStringTag]`, which is easy to forget and required.
246
- *
247
- * @param {Promise<DemoPlay>} inner
248
- * @returns {DemoPromisePlay}
249
- */
250
- function hydrate(inner) {
251
- /** @param {(play: DemoPlay) => unknown} step */
252
- const chain = (step) => hydrate(inner.then(async (play) => { await step(play); return play }))
253
-
254
- /** @type {any} */
255
- const hydrated = {
256
- /** @param {any} onfulfilled @param {any} onrejected */
257
- then: (onfulfilled, onrejected) => inner.then(onfulfilled, onrejected),
258
- /** @param {any} onrejected */
259
- catch: (onrejected) => inner.catch(onrejected),
260
- /** @param {any} onfinally */
261
- finally: (onfinally) => inner.finally(onfinally),
262
- [Symbol.toStringTag]: 'PromisePlay',
263
-
264
- /** @param {Partial<DemoConfig>} [patch] */
265
- config: (patch) =>
266
- patch === undefined ? inner.then((p) => p.config()) : chain((p) => p.config(patch)),
267
- /** @param {Partial<DemoOptions>} [patch] */
268
- options: (patch) =>
269
- patch === undefined ? inner.then((p) => p.options()) : chain((p) => p.options(patch)),
270
- /** @param {Partial<DemoAssets>} [patch] */
271
- assets: (patch) =>
272
- patch === undefined ? inner.then((p) => p.assets()) : chain((p) => p.assets(patch)),
273
- /** @param {Partial<DemoStorage>} [patch] */
274
- storage: (patch) =>
275
- patch === undefined ? inner.then((p) => p.storage()) : chain((p) => p.storage(patch)),
276
-
277
- /** @param {HTMLElement} target */
278
- mount: (target) => chain((p) => p.mount(target)),
279
- /** @param {Partial<DemoOptions>} [options] */
280
- restart: (options) => chain((p) => p.restart(options)),
281
- destroy: () => inner.then((p) => p.destroy()),
282
- }
283
- return hydrated
284
- }
285
-
286
167
  /**
287
168
  * The factory. Identity is static, so a launcher can list engines without
288
- * constructing one.
169
+ * constructing one — and the only thing it has to say beyond that is which engine to
170
+ * build.
289
171
  *
290
- * @implements {DemoSDKContract}
172
+ * @extends {EngineSDKBase<DemoPayloads>}
291
173
  */
292
- export class DemoEngineSDK {
174
+ export class DemoEngineSDK extends EngineSDKBase {
293
175
  static id = 'dummy-canvas-sdk'
294
176
  static version = '0.1.0-demo'
295
177
  static name = 'Dummy Canvas Engine'
296
178
  static description = 'A placeholder engine that renders animated content on canvas.'
297
179
 
298
- /** @type {DemoInit} */
299
- #init = {}
300
- /** @type {DemoPlay | undefined} */
301
- #play
302
-
303
- /** @param {DemoInit} [init] */
304
- constructor(init) {
305
- if (init) this.#init = { ...init }
306
- }
307
-
308
- /** The engine that answers a read before boot: the settings the next one will use. */
309
- #pending() {
310
- return this.#play ?? new DemoPlay(this.#init)
311
- }
312
-
313
- /**
314
- * @overload
315
- * @returns {Promise<Props<DemoConfig>>}
316
- * @overload
317
- * @param {Partial<DemoConfig>} patch
318
- * @returns {this}
319
- * @param {Partial<DemoConfig>} [patch]
320
- * @returns {any}
321
- */
322
- config(patch) {
323
- if (arguments.length === 0) return this.#pending().config()
324
- this.#init.config = { ...this.#init.config, ...patch }
325
- if (this.#play && patch) this.#play.config(patch)
326
- return this
327
- }
328
-
329
- /**
330
- * @overload
331
- * @returns {Promise<Props<DemoOptions>>}
332
- * @overload
333
- * @param {Partial<DemoOptions>} patch
334
- * @returns {this}
335
- * @param {Partial<DemoOptions>} [patch]
336
- * @returns {any}
337
- */
338
- options(patch) {
339
- if (arguments.length === 0) return this.#pending().options()
340
- this.#init.options = { ...this.#init.options, ...patch }
341
- if (this.#play && patch) this.#play.options(patch)
342
- return this
343
- }
344
-
345
- /**
346
- * @overload
347
- * @returns {Promise<Props<DemoAssets>>}
348
- * @overload
349
- * @param {Partial<DemoAssets>} patch
350
- * @returns {this}
351
- * @param {Partial<DemoAssets>} [patch]
352
- * @returns {any}
353
- */
354
- assets(patch) {
355
- if (arguments.length === 0) return this.#pending().assets()
356
- this.#init.assets = { ...this.#init.assets, ...patch }
357
- if (this.#play && patch) this.#play.assets(patch)
358
- return this
359
- }
360
-
361
- /**
362
- * @overload
363
- * @returns {Promise<Props<DemoStorage>>}
364
- * @overload
365
- * @param {Partial<DemoStorage>} patch
366
- * @returns {this}
367
- * @param {Partial<DemoStorage>} [patch]
368
- * @returns {any}
369
- */
370
- storage(patch) {
371
- if (arguments.length === 0) return this.#pending().storage()
372
- this.#init.storage = { ...this.#init.storage, ...patch }
373
- if (this.#play && patch) this.#play.storage(patch)
374
- return this
375
- }
376
-
377
- /**
378
- * Recorded now, applied when the engine exists — same as `new SDK({ mount })`.
379
- * @param {HTMLElement} target
380
- * @returns {this}
381
- */
382
- mount(target) {
383
- this.#init.mount = target
384
- if (this.#play) this.#play.mount(target)
385
- return this
386
- }
387
-
388
- /**
389
- * Idempotent: a factory governs at most one live engine.
390
- * @param {DemoInit} [init]
391
- * @returns {DemoPromisePlay}
392
- */
393
- boot(init) {
394
- if (init) this.#init = { ...this.#init, ...init }
395
- this.#play ??= new DemoPlay(this.#init)
396
- return hydrate(Promise.resolve(this.#play))
397
- }
398
-
399
180
  /**
400
- * `start(p)` is `boot().restart(p)` — literally, so the equivalence cannot drift.
401
- * @param {Partial<DemoOptions>} [options]
402
- * @returns {DemoPromisePlay}
181
+ * @protected
182
+ * @param {DemoInit} init
183
+ * @param {DemoChannel} events
184
+ * @returns {DemoPlay}
403
185
  */
404
- start(options) {
405
- return this.boot().restart(options)
186
+ createPlay(init, events) {
187
+ return new DemoPlay(init, events)
406
188
  }
407
189
  }
408
190