@wasm-gaming/engine-specs 0.3.1 → 0.4.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 CHANGED
@@ -4,8 +4,9 @@ The **engine contract** for the wasm-gaming ecosystem: one shared JS API that ev
4
4
  WASM game/emulator engine implements and every host drives. Define it once here,
5
5
  depend on it everywhere, and engines can't drift.
6
6
 
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.
7
+ The types, plus the contract implemented once so twelve engines inherit it instead of
8
+ re-deriving it — [src/engine-specs.ts](src/engine-specs.ts) is the whole package. No
9
+ runtime dependencies, because twelve engines depend on this.
9
10
 
10
11
  ## The shape
11
12
 
@@ -55,6 +56,104 @@ without asking the engine anything, and without the host knowing a single key na
55
56
  `restart()`. The descriptor shape is never accepted on write: that asymmetry is what
56
57
  keeps a write unambiguous the day a value is itself an object.
57
58
 
59
+ ## Listening
60
+
61
+ `on` and `off` are on the same fluent surface, so they chain wherever the setters do —
62
+ and **registration is synchronous on every carrier**, including the promise `start()`
63
+ returns. The chain is what the return value continues, never what the subscription
64
+ waits for; otherwise `start().on('error', …)` would bind after the session opened and
65
+ miss an error thrown while it was opening.
66
+
67
+ A listener receives an **event**, not the payload: what it carries is `detail`, as
68
+ `CustomEvent` does it. The envelope is what lets an event grow a field without every
69
+ listener signature changing — and what survives `postMessage` intact when the engine
70
+ lives in a worker, which is where this ecosystem is going.
71
+
72
+ ```js
73
+ const play = await new Rsdkv4SDK({ on: { error: report } }) // in the constructor
74
+ .on('start', (event) => console.log('session', event.detail))
75
+ .assets({ pack })
76
+ .start();
77
+ ```
78
+
79
+ It is deliberately **not** a DOM event: two members, frozen, no bubbling, no
80
+ `preventDefault()`, no `target`. Half-implementing a familiar interface is worse than
81
+ offering a different one.
82
+
83
+ The factory and the engine it opens are **one channel**, so subscribing before there is
84
+ an engine is the normal way to do it. Every engine emits `start`, `error`, `exit`,
85
+ `pause` and `resume`; an engine that has more of its own declares them in its payload
86
+ bundle and they type-check alongside the core set:
87
+
88
+ ```ts
89
+ interface Rsdkv4Payloads extends EnginePayloads {
90
+ // …the four payloads
91
+ events: { levelLoaded: { zone: string } }; // optional; omit it for the core set alone
92
+ }
93
+ ```
94
+
95
+ `error` is emitted **and** the promise rejects — both, always — so a host that does not
96
+ `await` is not left blind, and one that does is not forced to subscribe.
97
+
98
+ ## Writing an engine
99
+
100
+ None of the above has to be implemented by hand. `EnginePlayBase` and `EngineSDKBase`
101
+ carry the state, the descriptors, the cumulative merge, the chainable promise and the
102
+ channel; an engine declares its properties as data and fills in what only it knows.
103
+
104
+ ```js
105
+ import { EnginePlayBase, EngineSDKBase } from '@wasm-gaming/engine-specs';
106
+
107
+ const spec = {
108
+ config: { region: { default: 'us', enum: ['us', 'eu', 'jp'], live: false } },
109
+ options: { slot: { default: 0, live: true } },
110
+ assets: { pack: { allowedTypes: ['.bin'], live: false } },
111
+ storage: { namespace: { default: 'rsdkv4', live: false } },
112
+ };
113
+
114
+ class Rsdkv4Play extends EnginePlayBase {
115
+ constructor(init, events) { super(spec, init, events); }
116
+ async open(options) { /* start or reopen a session */ }
117
+ async close() { /* stop and release */ }
118
+ mounted(target) { /* the picture goes here */ }
119
+ }
120
+
121
+ export class Rsdkv4SDK extends EngineSDKBase {
122
+ static id = 'rsdkv4';
123
+ static version = '1.0.0';
124
+ createPlay(init, events) { return new Rsdkv4Play(init, events); }
125
+ }
126
+ ```
127
+
128
+ `default` is declared in the spec and **nowhere else** — the base seeds its starting
129
+ state from it, so the initial value and the advertised default cannot drift apart.
130
+ `start`, `exit` and `error` are emitted for you, including the rule that a failing hook
131
+ both rejects the chain and emits `error`; `this.emit(…)` is for the engine's own
132
+ events.
133
+
134
+ Adoption is optional: the contract is structural, so an engine with its own emitter and
135
+ its own state satisfies `EngineSDK` without extending anything. `EventEmitter` and
136
+ `hydratePlay` are exported for engines that want the pieces rather than the whole.
137
+
138
+ ## Menus: one usability scheme
139
+
140
+ Engines also ship their own screens (start / load game, pause, options) as an **engine app**, and
141
+ all of them — and the launcher — follow one scheme, described in [UX.md](UX.md). The package carries
142
+ its runtime, so no engine needs another dependency:
143
+
144
+ ```js
145
+ import { createInput, nextFocus, uiTokensCss } from '@wasm-gaming/engine-specs';
146
+
147
+ const input = createInput(); // keyboard + gamepads → actions, with repeat
148
+ input.on((action) => { // 'up' | 'down' | … | 'accept' | 'back' | 'menu' | 'more'
149
+ if (action === 'down') focus(nextFocus(current, buttons, 'down'));
150
+ });
151
+ input.suspend(); // the game has the controls now
152
+ ```
153
+
154
+ An app implements `EngineApp` — `mount(target, host)` — and receives `game`, `storage`, `assets`,
155
+ the shared `input` and `exit()` from the host.
156
+
58
157
  ## Closing the types
59
158
 
60
159
  One type parameter carries all four payloads, so there is no order to get wrong:
@@ -85,10 +184,15 @@ make preview # serve site/ on :8020 (PORT= to change it)
85
184
  Two engines implement the contract in this repo, and both are type-checked in CI:
86
185
  [tests/engine.conformance.ts](tests/engine.conformance.ts), the reference engine, and
87
186
  [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
187
+ page](https://wasm-gaming.github.io/engine-specs/demo/). Between them they cover both
188
+ branches of the event map — the reference engine declares one of its own, the demo
189
+ omits `events` and gets the core set alone. The negative cases live in
89
190
  [tests/types.test-d.ts](tests/types.test-d.ts), guarded by `@ts-expect-error` — which
90
191
  fails if a rejection ever stops happening.
91
192
 
193
+ The emitter is the one part with behaviour to run rather than compile, so it has
194
+ tests that run: [tests/event-emitter.test.mjs](tests/event-emitter.test.mjs).
195
+
92
196
  ## License
93
197
 
94
198
  MIT.
package/demo/app.html CHANGED
@@ -4,7 +4,9 @@
4
4
  It drives the engine only through the contract, and every panel is painted from
5
5
  something the contract handed back:
6
6
 
7
- - the header is the class's static identity, read before any engine exists;
7
+ - the toolbar is the reference's own, so the two pages are one site;
8
+ - the footer under it all is the class's static identity, read before any engine
9
+ exists — and the document title is the same reading;
8
10
  - the pickers get their file filter from each asset descriptor's `allowedTypes`;
9
11
  - the tables are `config()`, `options()`, `assets()` and `storage()` rendered as
10
12
  they arrive — one component, and not one key name written on this side.
@@ -13,22 +15,16 @@
13
15
  there is no mount bookkeeping here.
14
16
  -->
15
17
 
16
- <EngineHeader :name="name" :id="id" :version="version" :description="description" />
18
+ <SiteToolbar :version="packageVersion" />
17
19
 
18
20
  <div class="layout">
19
21
  <aside>
20
- <section>
22
+ <section class="panel">
21
23
  <h2>assets</h2>
22
24
  <AssetPicker :assets="assets" @select="onSelect" @remove="onRemove" />
23
25
  </section>
24
26
 
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>
27
+ <section class="panel">
32
28
  <h2>calls</h2>
33
29
  <CallLog :entries="calls" />
34
30
  </section>
@@ -37,11 +33,22 @@
37
33
  <div>
38
34
  <!-- The engine is handed this element and appends its own canvas to it. -->
39
35
  <div id="stage"></div>
36
+
37
+ <!-- Under the picture and as wide as it: the verbs act on what is above them. -->
38
+ <section class="panel">
39
+ <h2>lifecycle</h2>
40
+ <LifecycleBar :running="running" @start="onStart" @restart="onRestart"
41
+ @pause="onPause" @resume="onResume" @destroy="onDestroy" />
42
+ </section>
43
+
40
44
  <PropertyTable :tables="tables" />
41
45
  </div>
42
46
  </div>
43
47
 
48
+ <EngineHeader :version="version" :description="description" />
49
+
44
50
  <script :setup="{}">
51
+ const SiteToolbar = await import('./components/site-toolbar.html');
45
52
  const EngineHeader = await import('./components/engine-header.html');
46
53
  const AssetPicker = await import('./components/asset-picker.html');
47
54
  const PropertyTable = await import('./components/property-table.html');
@@ -50,9 +57,19 @@
50
57
 
51
58
  const { DemoEngineSDK } = await import('./demo.sdk.js');
52
59
 
60
+ // The *package* version, for the toolbar — not the engine's. `make site` writes this
61
+ // file next to the demo from package.json, which is the same source TypeDoc reads, so
62
+ // the two bars cannot disagree. A bare `demo/` checkout has no such file, and the bar
63
+ // then shows the package name alone.
64
+ let packageVersion = '';
65
+ try {
66
+ ({ version: packageVersion } = await import('./package-version.js'));
67
+ } catch {
68
+ packageVersion = '';
69
+ }
70
+
53
71
  // Identity, off the class — no engine constructed to read it.
54
72
  const name = DemoEngineSDK.name;
55
- const id = DemoEngineSDK.id;
56
73
  const version = DemoEngineSDK.version;
57
74
  const description = DemoEngineSDK.description;
58
75
  document.title = `${name} — engine-specs demo`;
@@ -71,6 +88,27 @@
71
88
  calls = [line, ...calls].slice(0, 40);
72
89
  };
73
90
 
91
+ /**
92
+ * The only place the factory is built, so the page subscribes exactly once — and
93
+ * does it in the constructor, which is the same call as `.on(type, listener)`.
94
+ *
95
+ * The handlers outlive every engine this factory opens: `start` fires again on each
96
+ * `restart()`, `exit` on `destroy()`, and `pause`/`resume` come from methods the
97
+ * contract does not know about. Nothing below re-subscribes.
98
+ *
99
+ * Each one receives an event, not the payload: what it carries is `detail`.
100
+ */
101
+ const engine = () =>
102
+ (sdk ??= new DemoEngineSDK({
103
+ on: {
104
+ start: ({ detail }) => say(`▸ start fileName: ${detail.fileName ?? 'unset'}`),
105
+ pause: ({ detail }) => say(`▸ pause owner: ${detail.owner}`),
106
+ resume: ({ detail }) => say(`▸ resume owner: ${detail.owner}`),
107
+ error: ({ detail }) => say(`▸ error ${detail.message}`),
108
+ exit: ({ type }) => say(`▸ ${type}`),
109
+ },
110
+ }));
111
+
74
112
  const show = (value) => {
75
113
  if (value === undefined) return 'unset';
76
114
  if (value instanceof Uint8Array) return `${value.byteLength} bytes`;
@@ -92,7 +130,7 @@
92
130
 
93
131
  /** Reads the live engine — or the factory, when there is not one yet. */
94
132
  const refresh = async () => {
95
- const source = play ?? (sdk ??= new DemoEngineSDK());
133
+ const source = play ?? engine();
96
134
  const [config, options, assetProps, storage] = await Promise.all([
97
135
  source.config(),
98
136
  source.options(),
@@ -121,7 +159,7 @@
121
159
  const onSelect = async ({ key, file }) => {
122
160
  const bytes = new Uint8Array(await file.arrayBuffer());
123
161
  picked[key] = file.name;
124
- sdk ??= new DemoEngineSDK();
162
+ engine();
125
163
  say(`sdk.assets({ ${key}: Uint8Array(${bytes.byteLength}) })`);
126
164
  sdk.assets({ [key]: bytes });
127
165
  if (key === 'rom') {
@@ -139,7 +177,7 @@
139
177
  };
140
178
 
141
179
  const onStart = async () => {
142
- sdk ??= new DemoEngineSDK();
180
+ engine();
143
181
  say('play = await sdk.mount(stage).start()');
144
182
  // The whole chain in one expression, exactly as the contract advertises it.
145
183
  play = await sdk.mount(document.querySelector('#stage')).start();
@@ -152,15 +190,16 @@
152
190
  await refresh();
153
191
  };
154
192
 
155
- // `pause` and `resume` are this engine's own members, not the contract's.
193
+ // The methods are this engine's own; the `pause`/`resume` they emit are the
194
+ // contract's — which is why the log shows a ▸ line the page never wrote here.
156
195
  const onPause = async () => {
157
- say('play.pause() // engine-specific, not in the contract');
196
+ say('play.pause() // engine-specific method, contract event');
158
197
  play.pause();
159
198
  await refresh();
160
199
  };
161
200
 
162
201
  const onResume = async () => {
163
- say('play.resume() // engine-specific, not in the contract');
202
+ say('play.resume() // engine-specific method, contract event');
164
203
  play.resume();
165
204
  await refresh();
166
205
  };
@@ -193,7 +232,8 @@
193
232
  }
194
233
  }
195
234
 
196
- aside section {
235
+ /* A class, not `aside section`: the lifecycle panel lives in the other column now. */
236
+ .panel {
197
237
  background: var(--panel, #fff);
198
238
  border: 1px solid var(--border, #e2e8f0);
199
239
  border-radius: 4px;
@@ -201,7 +241,7 @@
201
241
  margin-bottom: 1rem;
202
242
  }
203
243
 
204
- aside h2 {
244
+ .panel h2 {
205
245
  margin: 0 0 0.75rem;
206
246
  font-size: 0.78rem;
207
247
  font-family: var(--mono, monospace);
@@ -13,9 +13,11 @@
13
13
  @dragleave="dragging = null"
14
14
  @drop.prevent="onDrop($event, asset)"
15
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>
16
+ <div class="meta">
17
+ <strong>{{ asset.key }}</strong>
18
+ <p class="hint">{{ asset.description }}</p>
19
+ <p :class.file-selected="asset.fileName">{{ asset.fileName || asset.accepts }}</p>
20
+ </div>
19
21
  <div class="picker-actions">
20
22
  <label class="picker">
21
23
  {{ asset.fileName ? 'Change' : 'Pick file' }}
@@ -24,6 +26,7 @@
24
26
  <button type="button" :if="asset.fileName" class="remove-btn" @click.stop="$emit('remove', asset)">Remove</button>
25
27
  </div>
26
28
  </section>
29
+
27
30
  </div>
28
31
 
29
32
  <script :setup="{
@@ -58,14 +61,26 @@
58
61
  gap: 0.6rem;
59
62
  }
60
63
 
64
+ /*
65
+ A row, not a card: the key and its hints on the left, the actions on the right. The
66
+ whole box is still the drop target — it is only shorter, so two assets and the call
67
+ log fit the column without scrolling.
68
+ */
61
69
  .drop {
70
+ display: grid;
71
+ grid-template-columns: minmax(0, 1fr) auto;
72
+ align-items: center;
73
+ gap: 0.6rem;
62
74
  border: 1px dashed var(--border, #e2e8f0);
63
75
  border-radius: 4px;
64
- padding: 1rem;
65
- text-align: center;
76
+ padding: 0.6rem 0.75rem;
66
77
  transition: border-color 200ms ease, background 200ms ease;
67
78
  }
68
79
 
80
+ .meta {
81
+ min-width: 0;
82
+ }
83
+
69
84
  .drop.active {
70
85
  border-color: var(--accent, #ff007f);
71
86
  background: rgb(255 0 127 / 0.06);
@@ -85,14 +100,16 @@
85
100
  }
86
101
 
87
102
  .drop p {
88
- margin: 0.35rem 0 0;
103
+ margin: 0.15rem 0 0;
89
104
  color: var(--muted, #64748b);
90
- font-size: 0.78rem;
91
- word-break: break-all;
105
+ font-size: 0.75rem;
106
+ line-height: 1.35;
107
+ /* `anywhere`, not `break-all`: a long file name still breaks, prose does not. */
108
+ overflow-wrap: anywhere;
92
109
  }
93
110
 
94
111
  .drop p.hint {
95
- font-size: 0.8rem;
112
+ font-size: 0.78rem;
96
113
  }
97
114
 
98
115
  .drop p.file-selected {
@@ -102,18 +119,20 @@
102
119
  }
103
120
 
104
121
  .picker-actions {
105
- display: inline-flex;
106
- align-items: center;
107
- gap: 0.4rem;
108
- margin-top: 0.75rem;
122
+ display: flex;
123
+ flex-direction: column;
124
+ align-items: stretch;
125
+ gap: 0.3rem;
109
126
  }
110
127
 
111
128
  .picker,
112
129
  .remove-btn {
113
130
  display: inline-flex;
131
+ justify-content: center;
132
+ white-space: nowrap;
114
133
  font-family: var(--mono, monospace);
115
134
  font-size: 0.75rem;
116
- padding: 0.35rem 0.7rem;
135
+ padding: 0.3rem 0.6rem;
117
136
  border: 1px solid var(--border, #e2e8f0);
118
137
  border-radius: 3px;
119
138
  background: transparent;
@@ -4,58 +4,51 @@
4
4
  This is what replaced the manifest: `id`, `version`, `name` and `description` are
5
5
  static members of the SDK constructor, so a launcher can list engines without
6
6
  booting one.
7
+
8
+ It sits at the foot of the page and says nothing about the site: the toolbar above
9
+ carries that, which is why neither the link back to the reference nor a page-titling
10
+ `h1` is here any more. The name still reaches the reader — as the document title,
11
+ written from the same static member.
7
12
  -->
8
- <header>
13
+ <footer class="engine-header">
9
14
  <div>
10
- <h1>{{ name }} <code>{{ id }}</code></h1>
11
15
  <p>{{ description }}</p>
12
16
  </div>
13
17
  <div class="right">
14
18
  <span class="pill">v{{ version }}</span>
15
- <a class="pill link" :href="referenceHref">API reference</a>
16
19
  </div>
17
- </header>
20
+ </footer>
18
21
 
19
22
  <script :setup="{
20
- name,
21
- id,
22
23
  version,
23
24
  description = '',
24
- referenceHref = '../',
25
25
  }">
26
26
  </script>
27
27
 
28
28
  <style>
29
- header {
29
+ /*
30
+ Scoped to a class, not to the element. A component's styles land in the page's own
31
+ <style>, unscoped — so a bare `header {}` here reached the site toolbar above and
32
+ made a flex container of it, which centred the whole bar. Class selectors keep a
33
+ component's look to the component.
34
+ */
35
+ .engine-header {
30
36
  display: flex;
31
37
  flex-wrap: wrap;
32
38
  gap: 1rem;
33
39
  align-items: center;
34
40
  justify-content: space-between;
35
41
  padding: 1.25rem 1.5rem;
36
- border-bottom: 1px solid var(--border, #e2e8f0);
42
+ border-top: 1px solid var(--border, #e2e8f0);
37
43
  background: var(--panel, #fff);
38
44
  }
39
45
 
40
- h1 {
46
+ .engine-header p {
41
47
  margin: 0;
42
- font-size: 1.25rem;
43
- letter-spacing: 0.02em;
44
- color: var(--text, #0f172a);
45
- }
46
-
47
- code {
48
- font-family: var(--mono, monospace);
49
- font-size: 0.75em;
50
48
  color: var(--muted, #64748b);
51
49
  }
52
50
 
53
- p {
54
- margin: 0.25rem 0 0;
55
- color: var(--muted, #64748b);
56
- }
57
-
58
- .right {
51
+ .engine-header .right {
59
52
  display: flex;
60
53
  gap: 0.5rem;
61
54
  }
@@ -71,8 +64,4 @@
71
64
  text-transform: uppercase;
72
65
  white-space: nowrap;
73
66
  }
74
-
75
- .link {
76
- text-decoration: none;
77
- }
78
67
  </style>
@@ -0,0 +1,31 @@
1
+ <!--
2
+ The reference's own toolbar, reused rather than imitated: the class names are
3
+ TypeDoc's and so is the stylesheet that paints them, which index.html loads from
4
+ `../assets/style.css`. Two pages, one bar, and nothing here to keep in sync by hand.
5
+
6
+ What is missing is `button#tsd-search-trigger`. This page carries no search index and
7
+ does not load TypeDoc's `main.js`, so the button would open nothing — and a control
8
+ that does nothing is worse than a control that isn't there.
9
+
10
+ The version is *given* to this component, never written into it: TypeDoc reads it from
11
+ package.json, and a copy hard-coded here would drift the first time it changed. With
12
+ nothing to show — a bare `demo/` checkout — the bar shows the name alone rather than a
13
+ wrong number.
14
+ -->
15
+ <header class="tsd-page-toolbar">
16
+ <div class="tsd-toolbar-contents container">
17
+ <a class="title" :href="referenceHref">{{ title }}<span :if="version"> - v{{ version }}</span></a>
18
+ <div id="tsd-toolbar-links">
19
+ <a :href="referenceHref">Reference</a>
20
+ <a :href="repositoryHref">GitHub</a>
21
+ </div>
22
+ </div>
23
+ </header>
24
+
25
+ <script :setup="{
26
+ title = '@wasm-gaming/engine-specs',
27
+ version = '',
28
+ referenceHref = '../',
29
+ repositoryHref = 'https://github.com/wasm-gaming/engine-specs',
30
+ }">
31
+ </script>
package/demo/demo.css CHANGED
@@ -7,22 +7,32 @@
7
7
  */
8
8
 
9
9
  :root {
10
- --bg: #f8fafc;
11
- --panel: #ffffff;
12
- --border: #e2e8f0;
13
- --text: #0f172a;
14
- --muted: #64748b;
10
+ /*
11
+ The reference's palette, read off the variables TypeDoc defines, so the theme
12
+ switch in the docs sidebar drives this page too — there is no second switch here
13
+ to disagree with it, and no `prefers-color-scheme` block either: TypeDoc's `os`
14
+ theme already means "follow the system".
15
+
16
+ `--accent` stays the demo's own. It marks what the page does, not what the site
17
+ is, and the reference has no equivalent to borrow.
18
+ */
19
+ --bg: var(--color-background);
20
+ --panel: var(--color-background-secondary);
21
+ --border: var(--color-accent);
22
+ --text: var(--color-text);
23
+ --muted: var(--color-text-aside);
15
24
  --accent: #ff007f;
16
25
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
17
26
  }
18
27
 
19
- @media (prefers-color-scheme: dark) {
20
- :root {
21
- --bg: #060910;
22
- --panel: #0f172a;
23
- --border: #1e293b;
24
- --text: #e2e8f0;
25
- --muted: #94a3b8;
28
+ /*
29
+ TypeDoc hides the toolbar links below 769px because its menu trigger takes over
30
+ there. This page has no menu, so hiding them would leave no way back to the
31
+ reference. Unlayered, so it wins.
32
+ */
33
+ @media (max-width: 769px) {
34
+ #tsd-toolbar-links {
35
+ display: flex;
26
36
  }
27
37
  }
28
38