@wasm-gaming/engine-specs 0.2.5 → 0.2.7
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 +19 -20
- package/demo/demo.js +22 -7
- package/dist/engine.d.ts +421 -72
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +80 -5
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -5
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/engine.ts +506 -78
- package/src/index.ts +30 -7
package/src/engine.ts
CHANGED
|
@@ -1,88 +1,516 @@
|
|
|
1
1
|
// Layer B — the imperative contract.
|
|
2
2
|
//
|
|
3
|
-
// Every engine subproject
|
|
4
|
-
//
|
|
5
|
-
// The
|
|
6
|
-
//
|
|
3
|
+
// Every engine subproject exports a class that extends `EngineSDK`: a factory the
|
|
4
|
+
// host configures by chaining, and whose `boot()`/`start()` hand back an `EnginePlay`
|
|
5
|
+
// — the live engine. The chain is written once, here, so twelve engines don't each
|
|
6
|
+
// reinvent it; a concrete engine supplies only `create()`.
|
|
7
|
+
//
|
|
8
|
+
// Per-engine differences are absorbed by `manifest` (Layer A) and by closing the
|
|
9
|
+
// `Config` / `Options` generics.
|
|
10
|
+
//
|
|
11
|
+
// Status: shapes only. Every member is `abstract`, so the classes exist at runtime but
|
|
12
|
+
// implement nothing yet — the chain, the freeze, the payload merge and the idempotent
|
|
13
|
+
// `boot()` land with the contract 1.0 release.
|
|
7
14
|
|
|
8
15
|
import type { EngineManifest, KeyMap, InputPreset } from './manifest.js';
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Setup — what the host hands the factory
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where a runtime asset comes from.
|
|
23
|
+
*
|
|
24
|
+
* The host owns its files; the SDK only ever reads them. The SDK picks the
|
|
25
|
+
* strategy: an OPFS handle can be read on demand, a `showOpenFilePicker()` handle
|
|
26
|
+
* has no sync access and gets copied. The host does not need to know which.
|
|
27
|
+
*
|
|
28
|
+
* A thunk is resolved by `boot()`, and only if the asset is actually needed.
|
|
29
|
+
*
|
|
30
|
+
* @category Setup
|
|
31
|
+
*/
|
|
32
|
+
export type AssetSource =
|
|
33
|
+
| Uint8Array
|
|
34
|
+
| ArrayBuffer
|
|
35
|
+
| Blob
|
|
36
|
+
/** A door to a file, read on demand. */
|
|
37
|
+
| FileSystemFileHandle
|
|
38
|
+
/** A URL the SDK downloads. The only source that makes the SDK touch the network. */
|
|
39
|
+
| string
|
|
40
|
+
/** Resolved lazily, by `boot()`, and only if the asset is needed. */
|
|
41
|
+
| (() => AssetSource | Promise<AssetSource>);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The SDK's own folder: saved games, plus whatever internal state the engine
|
|
45
|
+
* persists. Never assets, never player preferences.
|
|
46
|
+
*
|
|
47
|
+
* There is no `required` flag: if the engine needs persistence and cannot have it,
|
|
48
|
+
* boot fails on its own with a typed {@link EngineError} that says *what* went
|
|
49
|
+
* wrong. Without `storage()` nothing is persisted at all.
|
|
50
|
+
*
|
|
51
|
+
* @category Setup
|
|
52
|
+
*/
|
|
53
|
+
export interface StorageSetup {
|
|
54
|
+
/** Folder the SDK owns, e.g. `"rsdkv4/sonic-1"`. Chosen by the host. */
|
|
55
|
+
namespace: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Live storage state, once the engine is up. @category Setup */
|
|
59
|
+
export interface StorageInfo extends StorageSetup {
|
|
60
|
+
/** `false` when the browser granted only best-effort storage — warn the player. */
|
|
61
|
+
persistent: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The JSON-safe half of {@link EngineSetup}.
|
|
66
|
+
*
|
|
67
|
+
* `structuredClone` (and therefore IndexedDB) round-trips the full setup, but
|
|
68
|
+
* `JSON.stringify` silently turns a `FileSystemFileHandle`, an `ArrayBuffer` or a
|
|
69
|
+
* `Blob` into `{}` and drops functions. Only this half survives JSON.
|
|
70
|
+
*
|
|
71
|
+
* @category Setup
|
|
72
|
+
*/
|
|
73
|
+
export interface EngineSetupData<Config, Options> {
|
|
74
|
+
/** Engine settings, applied at boot. */
|
|
75
|
+
config?: Partial<Config>;
|
|
76
|
+
/** Default session payload — see {@link EngineSDK.options}. */
|
|
77
|
+
options?: Partial<Options>;
|
|
78
|
+
storage?: StorageSetup;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Everything the factory can be configured with, in one object.
|
|
83
|
+
*
|
|
84
|
+
* Accepted by the constructor, which is what makes "restore the last session"
|
|
85
|
+
* a single call. Anything settable here is settable by chaining, and vice versa.
|
|
86
|
+
*
|
|
87
|
+
* @category Setup
|
|
88
|
+
*/
|
|
89
|
+
export interface EngineSetup<Config, Options> extends EngineSetupData<Config, Options> {
|
|
90
|
+
/** Runtime files keyed by `AssetSpec.key`. */
|
|
91
|
+
assets?: Record<string, AssetSource>;
|
|
92
|
+
/** Where the engine is seen. A container is scaled by the SDK; a canvas is yours. */
|
|
93
|
+
mount?: HTMLElement;
|
|
94
|
+
on?: Partial<EngineEventHandlers>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Resolved properties — the one shape all four getters return
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One setting, resolved: what is in effect, plus the JSON Schema vocabulary the
|
|
103
|
+
* manifest declared for it. Enough to paint a settings screen without asking the
|
|
104
|
+
* engine anything.
|
|
105
|
+
*
|
|
106
|
+
* There is no `source` field: the method you called *is* the source —
|
|
107
|
+
* {@link EngineSDK.config} are engine keys, {@link EngineSDK.options} are session keys.
|
|
108
|
+
*
|
|
109
|
+
* @category Config & options
|
|
110
|
+
*/
|
|
111
|
+
export interface ResolvedProperty<Value = unknown> {
|
|
112
|
+
/** What is in effect — or what will be used, if there is no engine yet. */
|
|
113
|
+
value: Value;
|
|
114
|
+
/** What the manifest declares, when it declares it. */
|
|
115
|
+
default?: Value;
|
|
116
|
+
/**
|
|
117
|
+
* Changing this needs a restart. It does **not** mean immutable: `player` is
|
|
118
|
+
* `readOnly` in `options()` and changes fine through {@link EnginePlay.restart}.
|
|
119
|
+
* A settings screen can grey it out instead of hiding it.
|
|
120
|
+
*/
|
|
121
|
+
readOnly?: true;
|
|
122
|
+
type?: string;
|
|
123
|
+
enum?: readonly unknown[];
|
|
124
|
+
enumNames?: readonly string[];
|
|
125
|
+
minimum?: number;
|
|
126
|
+
maximum?: number;
|
|
127
|
+
title?: string;
|
|
128
|
+
description?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Every key of `T`, resolved. The return shape of all four getters. @category Config & options */
|
|
132
|
+
export type ResolvedProperties<T> = { [K in keyof T]-?: ResolvedProperty<T[K]> };
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Events and errors
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* `ready` fires when `boot()` completes, `start` when a session opens. A
|
|
140
|
+
* `sdk.start()` emits both, in that order; a `restart()` does not re-emit `ready`.
|
|
141
|
+
*
|
|
142
|
+
* @category Events
|
|
143
|
+
*/
|
|
144
|
+
export interface EngineEventMap {
|
|
145
|
+
/** Engine up, no session. */
|
|
146
|
+
ready: void;
|
|
147
|
+
/** A session opened, with the payload it opened with. */
|
|
148
|
+
start: { payload: unknown };
|
|
149
|
+
/** `total` may be absent — guard before dividing. */
|
|
150
|
+
progress: { key: string; loaded: number; total?: number };
|
|
151
|
+
frame: { fps: number };
|
|
152
|
+
pause: { owner: string };
|
|
153
|
+
resume: { owner: string };
|
|
154
|
+
error: { error: EngineError };
|
|
155
|
+
exit: void;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** @category Events */
|
|
159
|
+
export type EngineEventHandlers = {
|
|
160
|
+
[K in keyof EngineEventMap]: (event: EngineEventMap[K]) => void;
|
|
33
161
|
};
|
|
34
162
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
|
54
|
-
|
|
55
|
-
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
163
|
+
/**
|
|
164
|
+
* Why boot or a call failed.
|
|
165
|
+
*
|
|
166
|
+
* Storage checks (OPFS availability, `crossOriginIsolated`, sync access, quota) run
|
|
167
|
+
* *before* the wasm is instantiated, so the failure arrives as a typed exception and
|
|
168
|
+
* not as a console warning halfway through loading.
|
|
169
|
+
*
|
|
170
|
+
* @category Errors
|
|
171
|
+
*/
|
|
172
|
+
export type EngineErrorCode =
|
|
173
|
+
/** No storage backend at all. */
|
|
174
|
+
| 'storage-unavailable'
|
|
175
|
+
/** The page needs COOP/COEP. */
|
|
176
|
+
| 'not-isolated'
|
|
177
|
+
/** Another tab holds the namespace. */
|
|
178
|
+
| 'storage-locked'
|
|
179
|
+
| 'quota-exceeded'
|
|
180
|
+
/** A `required` asset was never handed over — see {@link EngineSDK.missingAssets}. */
|
|
181
|
+
| 'asset-missing'
|
|
182
|
+
/** The engine could not parse the file. Integrity checks against a catalogue are the host's job. */
|
|
183
|
+
| 'asset-invalid'
|
|
184
|
+
| 'unsupported-browser'
|
|
185
|
+
/** A `readOnly` key was written on the live engine — use {@link EnginePlay.restart}. */
|
|
186
|
+
| 'read-only'
|
|
187
|
+
/** A setup method was called while an instance was alive. */
|
|
188
|
+
| 'frozen'
|
|
189
|
+
/** A method was called on a torn-down {@link EnginePlay}. */
|
|
190
|
+
| 'destroyed';
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Errors reach the host **both ways**: the promise rejects *and* `error` is
|
|
194
|
+
* emitted, so a host that does not `await` is not left blind.
|
|
195
|
+
*
|
|
196
|
+
* @category Errors
|
|
197
|
+
*/
|
|
198
|
+
export interface EngineError extends Error {
|
|
199
|
+
code: EngineErrorCode;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Saves
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/** One save slot, as a launcher would paint it. @category Saves */
|
|
207
|
+
export interface SaveSlot {
|
|
208
|
+
id: number;
|
|
209
|
+
empty: boolean;
|
|
210
|
+
/** Ready to show, e.g. `"Zone 1 · A · 3 lives"`. */
|
|
211
|
+
label: string;
|
|
212
|
+
/** Engine-specific extras the host may render. */
|
|
213
|
+
meta: Record<string, unknown>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// The chainable promise
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A promise of the live engine, hydrated with the engine's own methods.
|
|
222
|
+
*
|
|
223
|
+
* Every method of {@link EnginePlay} already returns a promise, so hydrating is
|
|
224
|
+
* enough: the actions come back as another `PlayPromise` and keep chaining, the
|
|
225
|
+
* queries come back as a plain promise and close.
|
|
226
|
+
*
|
|
227
|
+
* ```js
|
|
228
|
+
* await play.pause(OWNER.menu).options({ spindash: true }).resume(OWNER.menu)
|
|
229
|
+
* const slots = await play.pause(OWNER.menu).saves() // closes
|
|
230
|
+
* ```
|
|
231
|
+
*
|
|
232
|
+
* Two normative rules:
|
|
233
|
+
*
|
|
234
|
+
* - A rejection aborts the rest of the chain — nothing downstream runs.
|
|
235
|
+
* - It is a `Promise` subclass whose `Symbol.species` is `Promise`, so `then()`,
|
|
236
|
+
* `catch()` and `finally()` return a **plain** promise. Calling `then()` closes
|
|
237
|
+
* the chain, like a getter does.
|
|
238
|
+
*
|
|
239
|
+
* @category Lifecycle
|
|
240
|
+
*/
|
|
241
|
+
export type PlayPromise<Play> = Promise<Play> & Play;
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// EnginePlay — the live engine
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The live engine. Handed to the host by {@link EngineSDK.boot} or
|
|
249
|
+
* {@link EngineSDK.start}; never constructed by the host.
|
|
250
|
+
*
|
|
251
|
+
* Sessions come and go on the same live engine: `boot()` gives you one with no
|
|
252
|
+
* session at all, and each {@link EnginePlay.restart} opens another.
|
|
253
|
+
*
|
|
254
|
+
* The capability-gated members exist **if and only if** `manifest.capabilities`
|
|
255
|
+
* says so, so `if (play.saveState)` and reading the manifest always agree.
|
|
256
|
+
*
|
|
257
|
+
* @category Lifecycle
|
|
258
|
+
*/
|
|
259
|
+
export abstract class EnginePlay<
|
|
260
|
+
Options = Record<string, unknown>,
|
|
261
|
+
Config = Record<string, unknown>,
|
|
262
|
+
> {
|
|
263
|
+
/**
|
|
264
|
+
* Open a session, or reopen it.
|
|
265
|
+
*
|
|
266
|
+
* The payload is cumulative: there is one current payload, it starts as the
|
|
267
|
+
* factory's, and every `start()`/`restart()` merges its own on top (flat merge,
|
|
268
|
+
* one level). So `restart({ slot: 3 })` keeps the `player` you opened with, and
|
|
269
|
+
* `restart()` with no argument is a power cycle that repeats the current payload.
|
|
270
|
+
*
|
|
271
|
+
* `null` is an explicit value, not "leave unchanged".
|
|
272
|
+
*/
|
|
273
|
+
abstract restart(options?: Partial<Options>): PlayPromise<this>;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Freeze, on behalf of `owner`. Only the owner can resume, so an overlay cannot
|
|
277
|
+
* resume what another one froze.
|
|
278
|
+
*/
|
|
279
|
+
abstract pause(owner: string): PlayPromise<this>;
|
|
280
|
+
/** Resume. Ignored when the pause belongs to somebody else. */
|
|
281
|
+
abstract resume(owner: string): PlayPromise<this>;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Move the picture — reparents and refits. Throws on a different canvas: the GL
|
|
285
|
+
* context was bound to a surface at boot.
|
|
286
|
+
*/
|
|
287
|
+
abstract mount(target: HTMLElement): PlayPromise<this>;
|
|
288
|
+
abstract setInput(map: InputPreset | KeyMap): PlayPromise<this>;
|
|
289
|
+
|
|
290
|
+
/** Read the live engine settings: same keys as `sdk.config()`, current values. */
|
|
291
|
+
abstract config(): Promise<ResolvedProperties<Config>>;
|
|
292
|
+
/**
|
|
293
|
+
* Apply engine settings to *this session only*. Never persisted — where
|
|
294
|
+
* preferences live is the UI's call. Throws `read-only` on a `readOnly` key.
|
|
295
|
+
*/
|
|
296
|
+
abstract config(values: Partial<Config>): PlayPromise<this>;
|
|
297
|
+
|
|
298
|
+
/** Read the session options. Runtime discovery: their keys come from the loaded content. */
|
|
299
|
+
abstract options(): Promise<ResolvedProperties<Options>>;
|
|
300
|
+
/**
|
|
301
|
+
* Write session options, as flat values. Throws `read-only` on a `readOnly` key —
|
|
302
|
+
* those change through {@link EnginePlay.restart}.
|
|
303
|
+
*
|
|
304
|
+
* The getter shape (`{ volume: { value: 0.8 } }`) is not accepted: it would be
|
|
305
|
+
* ambiguous the day a property is itself an object.
|
|
306
|
+
*/
|
|
307
|
+
abstract options(values: Partial<Options>): PlayPromise<this>;
|
|
308
|
+
|
|
309
|
+
/** Where saves are going, and whether the browser will really keep them. */
|
|
310
|
+
abstract storage(): Promise<StorageInfo>;
|
|
311
|
+
|
|
312
|
+
abstract on<K extends keyof EngineEventMap>(
|
|
313
|
+
type: K,
|
|
314
|
+
handler: (event: EngineEventMap[K]) => void,
|
|
315
|
+
): PlayPromise<this>;
|
|
316
|
+
abstract off<K extends keyof EngineEventMap>(
|
|
317
|
+
type: K,
|
|
318
|
+
handler: (event: EngineEventMap[K]) => void,
|
|
319
|
+
): PlayPromise<this>;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Stop the loop, silence the audio, flush saves, release the canvas.
|
|
323
|
+
*
|
|
324
|
+
* **Closes the chain** — the factory stays frozen until this settles, which is
|
|
325
|
+
* what stops a host from repointing `storage()` while the old engine is still
|
|
326
|
+
* flushing into the old namespace.
|
|
327
|
+
*
|
|
328
|
+
* Idempotent: calling it again resolves and does nothing. The freeze is released
|
|
329
|
+
* when it *settles*, not when it fulfils — a failed flush must not brick the
|
|
330
|
+
* factory. Afterwards every method here throws `destroyed`, in-flight chains
|
|
331
|
+
* included.
|
|
332
|
+
*/
|
|
333
|
+
abstract destroy(): Promise<void>;
|
|
334
|
+
|
|
335
|
+
/** Present when `capabilities.saves !== false`. */
|
|
336
|
+
abstract saves?(): Promise<SaveSlot[]>;
|
|
337
|
+
/** Present when `capabilities.saves !== false`. */
|
|
338
|
+
abstract deleteSave?(id: number): PlayPromise<this>;
|
|
339
|
+
/** Present when `capabilities.saveStates`. */
|
|
340
|
+
abstract saveState?(): Promise<Uint8Array>;
|
|
341
|
+
/** Present when `capabilities.saveStates`. */
|
|
342
|
+
abstract loadState?(data: Uint8Array): PlayPromise<this>;
|
|
343
|
+
/** Present when `capabilities.screenshots`. */
|
|
344
|
+
abstract screenshot?(): Promise<Blob>;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// EngineSDK — the factory
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The factory every engine package exports a subclass of.
|
|
353
|
+
*
|
|
354
|
+
* Configured by chaining — all of it synchronous, because none of it crosses to
|
|
355
|
+
* the engine; these methods record, they do not resolve. Then `boot()` or
|
|
356
|
+
* `start()` produce the live {@link EnginePlay}.
|
|
357
|
+
*
|
|
358
|
+
* ```js
|
|
359
|
+
* const play = await new MotorSDK()
|
|
360
|
+
* .assets({ data: packHandle })
|
|
361
|
+
* .config({ language: 4 })
|
|
362
|
+
* .storage({ namespace: 'motor/game-1' })
|
|
363
|
+
* .on('error', report)
|
|
364
|
+
* .mount(document.querySelector('#stage'))
|
|
365
|
+
* .start({ slot: 0, player: 1 })
|
|
366
|
+
* ```
|
|
367
|
+
*
|
|
368
|
+
* Anything settable by chaining is settable in the constructor, and vice versa.
|
|
369
|
+
*
|
|
370
|
+
* Three boot verbs, two of them sugar for the third — and the equivalence is
|
|
371
|
+
* literal, it is how the base class implements them:
|
|
372
|
+
*
|
|
373
|
+
* | | what it does | equals |
|
|
374
|
+
* | --- | --- | --- |
|
|
375
|
+
* | `boot()` | engine alive, no session | — |
|
|
376
|
+
* | `play.restart(p)` | opens or reopens a session | — |
|
|
377
|
+
* | `start(p)` | engine alive **and** session running | `boot().restart(p)` |
|
|
378
|
+
*
|
|
379
|
+
* `boot()` is idempotent, so a factory governs at most one live engine, and
|
|
380
|
+
* `start(p)` on a live one is just `restart(p)`. Neither throws.
|
|
381
|
+
*
|
|
382
|
+
* @typeParam Config - the engine's own settings. Close it per engine; an open
|
|
383
|
+
* `Record<string, unknown>` gives up the compile-time check that is the point.
|
|
384
|
+
* @typeParam Options - the session payload, i.e. the writable keys of
|
|
385
|
+
* {@link EnginePlay.options}.
|
|
386
|
+
* @typeParam Play - the engine's own {@link EnginePlay} subclass, when it adds methods.
|
|
387
|
+
*
|
|
388
|
+
* @category Factory
|
|
389
|
+
*/
|
|
390
|
+
export abstract class EngineSDK<
|
|
391
|
+
Config = Record<string, unknown>,
|
|
392
|
+
Options = Record<string, unknown>,
|
|
393
|
+
Play extends EnginePlay<Options, Config> = EnginePlay<Options, Config>,
|
|
394
|
+
> {
|
|
395
|
+
/**
|
|
396
|
+
* Accepts the whole setup at once, which is what makes "restore the last session"
|
|
397
|
+
* a single call. Only {@link EngineSetupData} survives `JSON` — use
|
|
398
|
+
* `structuredClone`/IndexedDB for the rest.
|
|
399
|
+
*/
|
|
400
|
+
constructor(setup?: EngineSetup<Config, Options>) {
|
|
401
|
+
void setup; // shape only: the base class does not run yet
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Register asset sources. **Always synchronous** — it records sources, it does
|
|
406
|
+
* not resolve them, which is why a value may be a thunk.
|
|
407
|
+
*
|
|
408
|
+
* Throws `frozen` while an engine is alive.
|
|
409
|
+
*/
|
|
410
|
+
abstract assets(sources: Record<string, AssetSource>): this;
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Read the boot settings, without booting anything — enough to paint a
|
|
414
|
+
* preferences screen. Synchronous: there is no engine to ask, only
|
|
415
|
+
* `manifest.config` and whatever the chain put on top.
|
|
416
|
+
*
|
|
417
|
+
* With a live engine this still reads *what it booted with*;
|
|
418
|
+
* {@link EnginePlay.config} reads how it is now. Both are correct at once.
|
|
419
|
+
*/
|
|
420
|
+
abstract config(): ResolvedProperties<Config>;
|
|
421
|
+
/** Set the boot settings. Throws `frozen` while an engine is alive. */
|
|
422
|
+
abstract config(values: Partial<Config>): this;
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Read the current session payload. Synchronous, and thinner than the other
|
|
426
|
+
* getters — session keys are declared by the loaded content, so with no engine
|
|
427
|
+
* there is no `enum` or `title` to give, only `value`.
|
|
428
|
+
*/
|
|
429
|
+
abstract options(): ResolvedProperties<Options>;
|
|
430
|
+
/**
|
|
431
|
+
* Set the payload the next session will open with. Exactly the same as passing it
|
|
432
|
+
* to `start()`.
|
|
433
|
+
*
|
|
434
|
+
* Unlike `assets()`, `config()` and `storage()` this does **not** freeze with a
|
|
435
|
+
* live engine: it does not describe the engine, it describes the next session,
|
|
436
|
+
* and opening another session is legal at any time. It changes nothing hot —
|
|
437
|
+
* that is {@link EnginePlay.options}.
|
|
438
|
+
*/
|
|
439
|
+
abstract options(payload: Partial<Options>): this;
|
|
440
|
+
|
|
441
|
+
/** Point the SDK at its folder. Throws `frozen` while an engine is alive. */
|
|
442
|
+
abstract storage(setup: StorageSetup): this;
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Where the engine will be seen. A container is scaled by the SDK; pass your own
|
|
446
|
+
* canvas and the scaling is yours — and only before booting.
|
|
447
|
+
*/
|
|
448
|
+
abstract mount(target: HTMLElement): this;
|
|
449
|
+
|
|
450
|
+
abstract on<K extends keyof EngineEventMap>(
|
|
451
|
+
type: K,
|
|
452
|
+
handler: (event: EngineEventMap[K]) => void,
|
|
453
|
+
): this;
|
|
454
|
+
abstract off<K extends keyof EngineEventMap>(
|
|
455
|
+
type: K,
|
|
456
|
+
handler: (event: EngineEventMap[K]) => void,
|
|
457
|
+
): this;
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The `required` manifest keys that have not been handed over, **or whose value
|
|
461
|
+
* is not a valid {@link AssetSource}** — a `{}` left by a lost JSON round-trip
|
|
462
|
+
* counts as absent, not as present.
|
|
463
|
+
*
|
|
464
|
+
* Synchronous: it reads the chain, not the disk. Whether the file exists in the
|
|
465
|
+
* host's library is the host's question.
|
|
466
|
+
*/
|
|
467
|
+
abstract missingAssets(): string[];
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Engine alive, no session — where a launcher reads the saves and paints its
|
|
471
|
+
* cards before committing to a game.
|
|
472
|
+
*
|
|
473
|
+
* Idempotent: with a live engine it returns *that* engine.
|
|
474
|
+
*/
|
|
475
|
+
abstract boot(): PlayPromise<Play>;
|
|
476
|
+
/** `boot()` and open a session. Literally `boot().restart(payload)`. */
|
|
477
|
+
abstract start(payload?: Partial<Options>): PlayPromise<Play>;
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Read the saves without a session.
|
|
481
|
+
*
|
|
482
|
+
* Present when `capabilities.saves !== false`. With `'storage'` it reads the
|
|
483
|
+
* store in JS and does **not** boot; with `'engine'` it is sugar for
|
|
484
|
+
* `boot().saves()` and does boot. Same rows either way.
|
|
485
|
+
*
|
|
486
|
+
* Every factory method that needs a live engine is sugar for
|
|
487
|
+
* `boot().<method>()`; the capability only says whether that boot can be skipped.
|
|
488
|
+
*/
|
|
489
|
+
abstract saves?(): Promise<SaveSlot[]>;
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The only thing a concrete engine implements. Everything above is written once,
|
|
493
|
+
* in the base class.
|
|
494
|
+
*/
|
|
495
|
+
protected abstract create(setup: EngineSetup<Config, Options>): Promise<Play>;
|
|
82
496
|
}
|
|
83
497
|
|
|
84
|
-
/**
|
|
85
|
-
export
|
|
86
|
-
|
|
87
|
-
|
|
498
|
+
/**
|
|
499
|
+
* The static side of an engine package's export — the half `implements` cannot
|
|
500
|
+
* check, since TypeScript has no abstract statics.
|
|
501
|
+
*
|
|
502
|
+
* ```ts
|
|
503
|
+
* const _contract: EngineSDKClass<MotorConfig, MotorOptions, MotorPlay> = MotorSDK
|
|
504
|
+
* ```
|
|
505
|
+
*
|
|
506
|
+
* @category Factory
|
|
507
|
+
*/
|
|
508
|
+
export interface EngineSDKClass<
|
|
509
|
+
Config = Record<string, unknown>,
|
|
510
|
+
Options = Record<string, unknown>,
|
|
511
|
+
Play extends EnginePlay<Options, Config> = EnginePlay<Options, Config>,
|
|
512
|
+
> {
|
|
513
|
+
new (setup?: EngineSetup<Config, Options>): EngineSDK<Config, Options, Play>;
|
|
514
|
+
/** What the engine knows about itself, with nothing instantiated. */
|
|
515
|
+
readonly manifest: EngineManifest;
|
|
88
516
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* @document ../docs/CONTRACT-MAP.md
|
|
4
|
+
* @document ../docs/USAGE.md
|
|
5
|
+
*/
|
|
6
|
+
|
|
1
7
|
// @wasm-gaming/engine-specs — the engine contract.
|
|
2
8
|
//
|
|
3
9
|
// Layer A (declarative): EngineManifest & friends — what an engine ships/needs.
|
|
4
|
-
// Layer B (imperative): EngineSDK /
|
|
10
|
+
// Layer B (imperative): EngineSDK / EnginePlay — how the host drives it.
|
|
5
11
|
// Plus a runtime validator for the manifest.
|
|
6
12
|
|
|
7
13
|
export type {
|
|
@@ -16,13 +22,30 @@ export type {
|
|
|
16
22
|
JSONSchema,
|
|
17
23
|
} from './manifest.js';
|
|
18
24
|
|
|
25
|
+
// The two base classes. Every member is abstract for now: the shapes are the 1.0
|
|
26
|
+
// contract, the implementation lands with it.
|
|
27
|
+
export { EngineSDK, EnginePlay } from './engine.js';
|
|
28
|
+
|
|
19
29
|
export type {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
EngineSDKClass,
|
|
31
|
+
// Setup
|
|
32
|
+
AssetSource,
|
|
33
|
+
EngineSetup,
|
|
34
|
+
EngineSetupData,
|
|
35
|
+
StorageSetup,
|
|
36
|
+
StorageInfo,
|
|
37
|
+
// Config & options
|
|
38
|
+
ResolvedProperty,
|
|
39
|
+
ResolvedProperties,
|
|
40
|
+
// Lifecycle
|
|
41
|
+
PlayPromise,
|
|
42
|
+
// Events & errors
|
|
43
|
+
EngineEventMap,
|
|
44
|
+
EngineEventHandlers,
|
|
45
|
+
EngineError,
|
|
46
|
+
EngineErrorCode,
|
|
47
|
+
// Saves
|
|
48
|
+
SaveSlot,
|
|
26
49
|
} from './engine.js';
|
|
27
50
|
|
|
28
51
|
export { validateManifest, assertManifest } from './validate.js';
|