@wasm-gaming/rsdkv4-wasm 0.2.0 → 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 +159 -213
- package/dist/rsdkv4/app/app.css +160 -0
- package/dist/rsdkv4/app/app.js +521 -0
- package/dist/rsdkv4/app/assets/player-knuckles.svg +757 -0
- package/dist/rsdkv4/app/assets/player-sonic.svg +720 -0
- package/dist/rsdkv4/app/assets/player-sonictails.svg +1414 -0
- package/dist/rsdkv4/app/assets/player-tails.svg +979 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/special.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-1.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-2.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-3.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-4.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-5.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-6.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-7.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/special.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-1.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-10.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-2.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-3.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-4.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-5.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-6.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-7.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-8.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-9.png +0 -0
- package/dist/rsdkv4/engine-specs.js +382 -0
- package/dist/rsdkv4/rsdkv4.options.d.ts +13 -5
- package/dist/rsdkv4/rsdkv4.options.d.ts.map +1 -1
- package/dist/rsdkv4/rsdkv4.options.js +6 -1
- package/dist/rsdkv4/rsdkv4.options.js.map +1 -1
- package/dist/rsdkv4/rsdkv4.sdk.d.ts +82 -2
- package/dist/rsdkv4/rsdkv4.sdk.d.ts.map +1 -1
- package/dist/rsdkv4/rsdkv4.sdk.js +217 -26
- package/dist/rsdkv4/rsdkv4.sdk.js.map +1 -1
- package/package.json +7 -4
- package/src/app/app.css +160 -0
- package/src/app/app.js +521 -0
- package/src/rsdkv4.options.ts +14 -6
- package/src/rsdkv4.sdk.ts +283 -26
package/README.md
CHANGED
|
@@ -10,228 +10,170 @@ into the Emscripten filesystem *at runtime*, so the same `rsdkv4.wasm` runs both
|
|
|
10
10
|
is what picks the game. (The old build baked `Data.rsdk` in with `--preload-file`
|
|
11
11
|
and had to be recompiled per game; this doesn't.)
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## Install
|
|
14
14
|
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const engine = await load({
|
|
19
|
-
canvas, // an <canvas id="canvas">
|
|
20
|
-
assets: { data: dataRsdkBytes /*, settings: iniBytes */ },
|
|
21
|
-
storageNamespace: 'sonic1', // carpeta por juego bajo /data (OPFS/WASMFS)
|
|
22
|
-
onEvent: (e) => { /* 'ready' | 'error' | 'exit' */ },
|
|
23
|
-
// Where the app placed the CI-built artifacts (defaults to package-relative):
|
|
24
|
-
// jsUrl, wasmUrl,
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
engine.pause();
|
|
28
|
-
engine.resume();
|
|
29
|
-
engine.setInput('rsdkv4');
|
|
30
|
-
engine.purgeStorage(); // borra Data.rsdk/settings.ini de este namespace
|
|
31
|
-
engine.destroy();
|
|
15
|
+
```bash
|
|
16
|
+
npm install @wasm-gaming/rsdkv4-wasm
|
|
32
17
|
```
|
|
33
18
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
19
|
+
One dependency comes with it: `@wasm-gaming/engine-specs`, the engine contract. The
|
|
20
|
+
built SDK carries the contract's runtime beside it (`dist/rsdkv4/engine-specs.js`) and
|
|
21
|
+
imports it relatively, so the package loads from any static server — **no bundler and
|
|
22
|
+
no import map needed**. Serve `dist/rsdkv4/` as is and import `rsdkv4.sdk.js`.
|
|
23
|
+
|
|
24
|
+
## Contract surface
|
|
39
25
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
The engine only exposes the raw bridge as `instance.devMenu` (backed by the
|
|
43
|
-
`web_devmenu_*` embind functions compiled into the WASM):
|
|
26
|
+
The SDK implements the [wasm-gaming engine contract](https://github.com/wasm-gaming/engine-specs)
|
|
27
|
+
1.0: a class with a static identity, configured by chaining, that opens a live session.
|
|
44
28
|
|
|
45
29
|
```js
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
30
|
+
import Rsdkv4SDK from '@wasm-gaming/rsdkv4-wasm';
|
|
31
|
+
|
|
32
|
+
Rsdkv4SDK.id; // 'rsdkv4' — also this engine's folder in OPFS
|
|
33
|
+
Rsdkv4SDK.version; // '0.3.0'
|
|
34
|
+
|
|
35
|
+
const play = await new Rsdkv4SDK()
|
|
36
|
+
.config({ startMenu: 'host' }) // engine: who draws the start screens
|
|
37
|
+
.assets({ data: () => fetchDataRsdk() }) // Data.rsdk — bytes, or a thunk called only on a miss
|
|
38
|
+
.storage({ namespace: 'sonic-1' }) // → OPFS rsdkv4/sonic-1/
|
|
39
|
+
.on('saves', ({ detail }) => paintSlots(detail))
|
|
40
|
+
.on('error', ({ detail }) => report(detail))
|
|
41
|
+
.mount(document.querySelector('#stage')) // a <canvas> is used as is; anything else gets one inside
|
|
42
|
+
.start({ slot: 0, player: 1 }); // session: slot 0 as Tails
|
|
43
|
+
|
|
44
|
+
await play.restart({ slot: 2 });
|
|
45
|
+
await play.destroy(); // stops audio, flushes the save, releases the canvas
|
|
51
46
|
```
|
|
52
47
|
|
|
53
|
-
The
|
|
54
|
-
|
|
55
|
-
ships with `DevMenu=false`, so the native in-canvas menu stays suppressed).
|
|
56
|
-
|
|
57
|
-
> ⚠️ A capture-phase listener on `window` that calls `stopPropagation()` also
|
|
58
|
-
> stops the **bubble**-phase listener SDL registers there — i.e. it takes the
|
|
59
|
-
> keyboard away from the game. Swallow only the keys you actually handle, and
|
|
60
|
-
> only while your overlay is open. (This is exactly how the shared demo
|
|
61
|
-
> template's pause menu used to kill the d-pad; see
|
|
62
|
-
> [src/demo/components/esc-menu.html](src/demo/components/esc-menu.html).)
|
|
63
|
-
|
|
64
|
-
### Start menu — the host can own that too
|
|
65
|
-
`instance.game` exposes RSDKv4's Start Menu as data (the `web_*` embind functions
|
|
66
|
-
in `WebGame.cpp`), so a host can draw its own save / character / options screens
|
|
67
|
-
instead of the engine's in-canvas text menus. The logic mirrors
|
|
68
|
-
`Debug.cpp` move for move — same globals, same `saveRAM`, same
|
|
69
|
-
`InitStartingStage()` — so a game started this way lands in the state the native
|
|
70
|
-
menu would have produced.
|
|
48
|
+
The four payloads, each read with `await sdk.config()` (a descriptor per key: value,
|
|
49
|
+
default, enum, description, `live`) and written with `sdk.config({ … })`:
|
|
71
50
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
engine.game.players(); // ["SONIC", "TAILS", "KNUCKLES", "SONIC AND TAILS"]
|
|
79
|
-
engine.game.saveSlots(); // [{ slot, empty, character, lives, score, emeralds, list, zone }]
|
|
80
|
-
engine.game.start(0, 1); // slot 0 as Tails — a slot with data resumes it
|
|
81
|
-
engine.game.start(null, 0); // no-save mode
|
|
82
|
-
engine.game.deleteSave(2);
|
|
83
|
-
|
|
84
|
-
engine.game.options(); // the engine's GAME OPTIONS screen, as data
|
|
85
|
-
engine.game.setOption('spindash', 1); // written through to the game's save file
|
|
86
|
-
```
|
|
51
|
+
| payload | keys |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `config` (engine) | `startMenu` (`'native'` \| `'host'`), `devMenu`, `engineDebugMode`, `vsync`, `jsUrl`, `wasmUrl` |
|
|
54
|
+
| `options` (session) | `slot`, `player`, `stage`, `buttons` |
|
|
55
|
+
| `assets` | `data` (Data.rsdk, required), `settings` (settings.ini, optional — generated from `config` otherwise) |
|
|
56
|
+
| `storage` | `namespace`, `persist` (`'opfs'` \| `null`) |
|
|
87
57
|
|
|
88
|
-
`
|
|
89
|
-
|
|
90
|
-
|
|
58
|
+
Types: `Rsdkv4Config`, `Rsdkv4Options`, `Rsdkv4Assets`, `Rsdkv4Storage` and the full
|
|
59
|
+
spec as data in `RSDKV4_SPEC` ([src/rsdkv4.options.ts](src/rsdkv4.options.ts)). CI holds
|
|
60
|
+
the class to the contract with one line: `RSDKV4_ENGINE: EngineSDKConstructor<Rsdkv4Payloads>`.
|
|
91
61
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
62
|
+
Events: the contract's `start`, `error`, `exit`, `pause`, `resume`, plus `saves`
|
|
63
|
+
(the slots, emitted when `SData.bin` changed and was persisted).
|
|
64
|
+
|
|
65
|
+
`jsUrl` / `wasmUrl` default to `rsdkv4.js` / `rsdkv4.wasm` next to `rsdkv4.sdk.js`; a
|
|
66
|
+
host that serves them elsewhere sets them in `config`.
|
|
67
|
+
|
|
68
|
+
### Start menu — the host can own it
|
|
69
|
+
|
|
70
|
+
With `config({ startMenu: 'host' })` the engine's own save / character / options screens
|
|
71
|
+
are skipped and `play.game` exposes them as data (the `web_*` embind functions in
|
|
72
|
+
`WebGame.cpp`, mirroring `Debug.cpp` move for move, so a game started this way lands in
|
|
73
|
+
the state the native menu would have produced):
|
|
96
74
|
|
|
97
75
|
```js
|
|
98
|
-
|
|
99
|
-
|
|
76
|
+
play.game.type(); // 1 = Sonic 1, 2 = Sonic 2
|
|
77
|
+
play.game.players(); // ["SONIC", "TAILS", "KNUCKLES", "SONIC AND TAILS"]
|
|
78
|
+
play.game.saveSlots(); // [{ slot, empty, character, lives, score, emeralds, list, zone }]
|
|
79
|
+
play.game.start(0, 1); // slot 0 as Tails — a slot with data resumes it
|
|
80
|
+
play.game.start(null, 0); // no-save mode
|
|
81
|
+
play.game.deleteSave(2);
|
|
82
|
+
play.game.options(); // the GAME OPTIONS screen, as data
|
|
83
|
+
play.game.setOption('spindash', 1);
|
|
100
84
|
```
|
|
101
85
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
86
|
+
`start()` resolves only once `Engine::Init` has run, so all of the above is safe to call
|
|
87
|
+
the moment it does.
|
|
88
|
+
|
|
89
|
+
### Dev menu — the host owns the UI
|
|
105
90
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
[src/rsdkv4.manifest.ts](src/rsdkv4.manifest.ts)'s `options`). When the host doesn't pass an explicit
|
|
110
|
-
`settings` asset, the SDK **generates `settings.ini` from `config.options`**
|
|
111
|
-
(`devMenu`, `engineDebugMode`, `vsync`, `skipStartMenu`,
|
|
112
|
-
`startingCategory/Scene/Player`).
|
|
91
|
+
The stage-select **UI is the host's**; the engine exposes the bridge as `play.devMenu`
|
|
92
|
+
(`getStageList()`, `loadStage(category, stage)`, `setPaused()`, `open()`). The engine
|
|
93
|
+
ships with `devMenu: false`, so the native in-canvas menu stays suppressed.
|
|
113
94
|
|
|
114
|
-
>
|
|
115
|
-
>
|
|
116
|
-
>
|
|
95
|
+
> ⚠️ A capture-phase listener on `window` that calls `stopPropagation()` also stops the
|
|
96
|
+
> bubble-phase listener SDL registers there — i.e. it takes the keyboard away from the
|
|
97
|
+
> game. Swallow only the keys you handle, and only while your overlay is open.
|
|
98
|
+
|
|
99
|
+
### Pausing has an owner
|
|
100
|
+
|
|
101
|
+
`play.pause(owner?)` / `play.resume(owner?)` — the **first** caller to pause owns it,
|
|
102
|
+
and only that owner's `resume()` lifts it, so a pause overlay can't resume a game a
|
|
103
|
+
start screen had already frozen. Pausing stops the audio too.
|
|
104
|
+
|
|
105
|
+
### Input
|
|
106
|
+
|
|
107
|
+
`play.input.claim(buttons)` / `play.input.release()` (same as `options({ buttons })`)
|
|
108
|
+
let a host drive the fourteen buttons instead of the engine reading the keyboard.
|
|
117
109
|
|
|
118
110
|
### Other niceties
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
characters, an empty pause menu. So `load()` checks the module for the bridges
|
|
126
|
-
it needs and, if any are missing, refetches once with a cache-busting query and
|
|
127
|
-
says so in the console. A healthy build loads the engine exactly once.
|
|
128
|
-
|
|
129
|
-
> Audio autoplay-unlock and the gamepad→keyboard translator are cross-cutting host
|
|
130
|
-
> concerns (the app's shared input script); the SDK only exposes the `input`
|
|
131
|
-
> preset name via the manifest and `setInput()`.
|
|
111
|
+
|
|
112
|
+
- **Canvas id guard** — Emscripten's SDL2 port finds the canvas with
|
|
113
|
+
`querySelector('#canvas')`, so the SDK gives the canvas that id.
|
|
114
|
+
- **Stale-engine self-heal** — fresh SDK against a browser-cached older wasm would make
|
|
115
|
+
every getter answer "empty". The SDK checks the module for the bridges it needs and,
|
|
116
|
+
if any are missing, refetches once past the HTTP cache and says so in the console.
|
|
132
117
|
|
|
133
118
|
## Filesystem (WASMFS / OPFS)
|
|
134
119
|
|
|
135
|
-
Built with **`-sWASMFS
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
120
|
+
Built with **`-sWASMFS`**. WASMFS `/data` is the OPFS root, mounted when the page is
|
|
121
|
+
cross-origin isolated *and* the build can create the OPFS backend from the main thread;
|
|
122
|
+
otherwise it falls back to the in-memory backend so the engine still boots.
|
|
123
|
+
|
|
124
|
+
### Every file lives in the engine's own folder
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
OPFS root (= WASMFS /data)
|
|
128
|
+
└── rsdkv4/ ← Rsdkv4SDK.id, added by the SDK
|
|
129
|
+
└── <namespace>/ ← storage({ namespace }), default "default"
|
|
130
|
+
├── Data.rsdk
|
|
131
|
+
├── settings.ini
|
|
132
|
+
└── SData.bin
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The host picks the namespace; **the engine owns the root**. No namespace can reach
|
|
136
|
+
another engine's files or climb out of `rsdkv4/`: segments are cleaned (`.` and `..`
|
|
137
|
+
dropped, characters outside `[A-Za-z0-9._-]` → `_`), and anything unusable becomes
|
|
138
|
+
`default`. `play.storagePath` gives the resolved path (`'rsdkv4/sonic-1'`), which is
|
|
139
|
+
also the OPFS path a host walks with `navigator.storage.getDirectory()`:
|
|
140
|
+
|
|
141
|
+
| host, from JS (OPFS API) | engine, inside WASMFS |
|
|
142
|
+
| ------------------------------- | ---------------------------------- |
|
|
143
|
+
| `rsdkv4/sonic-1/Data.rsdk` | `/data/rsdkv4/sonic-1/Data.rsdk` |
|
|
144
|
+
|
|
145
|
+
So a launcher can **install** a game by writing `Data.rsdk` into
|
|
146
|
+
`rsdkv4/<namespace>/` and later start it with `storage({ namespace })` and no
|
|
147
|
+
`assets.data` at all — the SDK finds the persisted copy and skips the copy. One ~40 MB
|
|
148
|
+
pack, not two, and the save lands next to it. `storagePath(engineId, namespace)` is
|
|
149
|
+
exported for hosts that need the path before a session exists.
|
|
150
|
+
|
|
151
|
+
**Upgrading from 0.2.x:** a namespace starting with `rsdkv4/` (the old convention,
|
|
152
|
+
e.g. `'rsdkv4/Sonic1'`) has the prefix stripped once, with a console warning, so its
|
|
153
|
+
files stay where they were — pass `'Sonic1'`. Files from the old top-level `default/`
|
|
154
|
+
folder (`Data.rsdk`, `settings.ini`, `SData.bin`) are **copied** into
|
|
155
|
+
`rsdkv4/default/` the first time the default namespace boots; the old folder is left
|
|
156
|
+
untouched, since other engines used `default/` too.
|
|
157
|
+
|
|
158
|
+
### Backend selection — `storage.persist`
|
|
143
159
|
|
|
144
|
-
### Backend selection — `config.persist`
|
|
145
160
|
- `undefined` (default) — **auto**: OPFS when cross-origin isolated *and* the build
|
|
146
161
|
supports the mount, else in-memory.
|
|
147
|
-
- `'opfs'` — **ask for** OPFS; warn
|
|
148
|
-
|
|
149
|
-
SDK checks first and falls back rather than trying.)
|
|
150
|
-
- `null` — **force the in-memory** WASMFS backend (the MEMFS-equivalent fallback;
|
|
151
|
-
non-persistent).
|
|
152
|
-
|
|
153
|
-
(`'idbfs'` isn't supported under WASMFS and is treated as in-memory.)
|
|
154
|
-
|
|
155
|
-
### Per-game storage namespace — `config.storageNamespace`
|
|
156
|
-
- `undefined` (default) — uses `/data/default`.
|
|
157
|
-
- `'sonic1'` / `'sonic2'` (recommended) — keeps each game in its own folder,
|
|
158
|
-
e.g. `/data/sonic1` and `/data/sonic2`.
|
|
159
|
-
- Supports nested paths like `'sonic1/profile-a'`.
|
|
160
|
-
|
|
161
|
-
This prevents cross-game reuse collisions and allows purging one game's files
|
|
162
|
-
without touching the others.
|
|
163
|
-
|
|
164
|
-
### `/data` *is* the OPFS root
|
|
165
|
-
`web_mount_opfs` mounts the backend returned by `wasmfs_create_opfs_backend()`,
|
|
166
|
-
whose root is `navigator.storage.getDirectory()`. So the WASM path `/data/<ns>/…`
|
|
167
|
-
and the OPFS path `<ns>/…` a host reaches from JS are **the same file**:
|
|
168
|
-
|
|
169
|
-
| host, from JS (OPFS API) | engine, inside WASMFS |
|
|
170
|
-
| ---------------------------- | ----------------------------- |
|
|
171
|
-
| `rsdkv4/Sonic1/Data.rsdk` | `/data/rsdkv4/Sonic1/Data.rsdk` |
|
|
172
|
-
|
|
173
|
-
A launcher can therefore write a player's `Data.rsdk` straight into the folder the
|
|
174
|
-
engine will use as its working dir and then load with
|
|
175
|
-
`storageNamespace: 'rsdkv4/Sonic1'` and **no `assets.data` at all** — the SDK finds
|
|
176
|
-
the file already persisted and skips the copy. That is what this repo's demo does
|
|
177
|
-
(see [Try it locally](#try-it-locally)); it keeps one copy of a ~40 MB pack instead
|
|
178
|
-
of two, and the game's save data lands next to it in the same folder.
|
|
162
|
+
- `'opfs'` — **ask for** OPFS; warn and fall back to in-memory if unavailable.
|
|
163
|
+
- `null` — force in-memory, and switch off the save mirror: nothing is kept.
|
|
179
164
|
|
|
180
165
|
### Save data survives even without the mount
|
|
181
|
-
The engine writes its save file (`SData.bin`) into the working dir, which is
|
|
182
|
-
in-memory on every build that can't mount OPFS — so game saves would die with the
|
|
183
|
-
page and every slot would read "NEW GAME" forever. The *host* side of OPFS has no
|
|
184
|
-
such limitation (the plain async API works on any main thread), so the SDK
|
|
185
|
-
mirrors that one file itself: it copies any stored `SData.bin` into the working
|
|
186
|
-
dir before the engine boots, and copies it back out on pause, on `destroy()`, and
|
|
187
|
-
every 10s in between. Saves therefore persist per `storageNamespace` even while
|
|
188
|
-
the working dir does not. `persist: null` turns the mirror off with everything else.
|
|
189
166
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
`dataProvider()` (called only on a miss). So a host can pass a `dataProvider` that
|
|
195
|
-
fetches the ROM and it will only run on first load / non-isolated pages:
|
|
196
|
-
|
|
197
|
-
```js
|
|
198
|
-
await load({
|
|
199
|
-
canvas,
|
|
200
|
-
assets: {}, // no eager 40 MB download
|
|
201
|
-
storageNamespace: 'sonic1', // namespace-specific cache
|
|
202
|
-
dataProvider: () => fetch('/Data.rsdk').then(r => r.arrayBuffer()),
|
|
203
|
-
});
|
|
204
|
-
```
|
|
167
|
+
On every build that can't mount OPFS the working dir is in-memory, so `SData.bin` would
|
|
168
|
+
die with the page. The host-side OPFS API has no such limitation, so the SDK mirrors that
|
|
169
|
+
one file itself: in before boot, out on pause, on `destroy()`, when the page is hidden,
|
|
170
|
+
and every 10 s — always to `rsdkv4/<namespace>/SData.bin`, and `saves` fires when it moved.
|
|
205
171
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
```js
|
|
209
|
-
const sonic2 = await load({
|
|
210
|
-
canvas,
|
|
211
|
-
assets: {},
|
|
212
|
-
storageNamespace: 'sonic2',
|
|
213
|
-
dataProvider: () => fetch('/sonic2/Data.rsdk').then(r => r.arrayBuffer()),
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
// Removes /data/sonic2/Data.rsdk and /data/sonic2/settings.ini only.
|
|
217
|
-
const purged = sonic2.purgeStorage();
|
|
218
|
-
console.log(purged); // { data: true|false, settings: true|false }
|
|
219
|
-
```
|
|
172
|
+
### Skip re-fetch when already persisted
|
|
220
173
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
> ⚠️ **OPFS caveat:** OPFS sync-access handles only exist in Workers, so WASMFS's
|
|
225
|
-
> OPFS backend spawns a proxy worker — and it **refuses to do that from the main
|
|
226
|
-
> browser thread** unless the build has **`-sASYNCIFY`** or **`-sJSPI`**. It
|
|
227
|
-
> doesn't fail softly either: `wasmfs_create_opfs_backend()` asserts, and the
|
|
228
|
-
> failed assert `abort()`s the module, so there is nothing left to fall back with.
|
|
229
|
-
> The SDK mounts by calling into the module from the page (main thread) and this
|
|
230
|
-
> build has neither flag, so `web_opfs_supported()` (WebFS.cpp) reports 0 and the
|
|
231
|
-
> SDK never attempts the mount — COOP/COEP alone changes nothing. Making it real
|
|
232
|
-
> means either building with Asyncify/JSPI, or `-pthread` + performing the mount
|
|
233
|
-
> from the engine thread; both interact with SDL2 + `emscripten_set_main_loop` and
|
|
234
|
-
> must be validated with a real build.
|
|
174
|
+
Precedence for `Data.rsdk`: explicit bytes in `assets.data` → a copy already in
|
|
175
|
+
`rsdkv4/<namespace>/` → the `assets.data` thunk (called only on a miss). So a host can
|
|
176
|
+
hand over a thunk that downloads the pack and it only runs the first time.
|
|
235
177
|
|
|
236
178
|
## Build
|
|
237
179
|
|
|
@@ -244,8 +186,9 @@ make build-sdk # TypeScript only → dist/ (fast; no Docker)
|
|
|
244
186
|
make build-wasm # local: runs scripts/build.sh inside emscripten/emsdk (Docker)
|
|
245
187
|
```
|
|
246
188
|
|
|
247
|
-
- `build-lib` compiles the SDK
|
|
248
|
-
- `build-
|
|
189
|
+
- `build-lib` compiles the SDK and options (`.js` + `.d.ts`) → `dist/rsdkv4/`.
|
|
190
|
+
- `build-specs` copies the contract's runtime to `dist/rsdkv4/engine-specs.js` and rewrites
|
|
191
|
+
the SDK's import to it (`scripts/localize-specs.mjs`), so the package is self-contained.
|
|
249
192
|
- `build-demo` compiles `src/demo/{demo,library}.ts` → `dist/`, copies the shared
|
|
250
193
|
demo template from `@wasm-gaming/engine-specs` → `dist/demo/`, overwrites its
|
|
251
194
|
launcher with `src/demo/components/launcher.html` (the two-game one), copies
|
|
@@ -260,8 +203,9 @@ make build-wasm # local: runs scripts/build.sh inside emscripten/emsdk (Docker
|
|
|
260
203
|
`-sWASMFS -sINVOKE_RUN=0 -sMODULARIZE=1 -sEXPORT_ES6=1 -sEXPORT_NAME=createRSDKv4`
|
|
261
204
|
+ `FS`/`callMain`/`ccall` exported, **without** `--preload-file`.
|
|
262
205
|
|
|
263
|
-
|
|
264
|
-
|
|
206
|
+
The contract is the one dependency: `@wasm-gaming/engine-specs` (types from the package,
|
|
207
|
+
runtime copied into `dist/rsdkv4/`). `make test` type-checks the SDK and runs `tests/`
|
|
208
|
+
against the built package.
|
|
265
209
|
|
|
266
210
|
### dist/ layout
|
|
267
211
|
|
|
@@ -272,8 +216,7 @@ dist/
|
|
|
272
216
|
│ ├── rsdkv4.wasm
|
|
273
217
|
│ ├── rsdkv4.sdk.js (+ .d.ts)
|
|
274
218
|
│ ├── rsdkv4.options.js (+ .d.ts)
|
|
275
|
-
│ └──
|
|
276
|
-
├── manifest.json # declarative manifest (artifacts → rsdkv4/rsdkv4.*)
|
|
219
|
+
│ └── engine-specs.js # the contract's runtime, imported relatively by the SDK
|
|
277
220
|
├── index.html # demo shell (import map → ./rsdkv4/rsdkv4.sdk.js)
|
|
278
221
|
├── demo.js # window.SDK + window.rsdkv4Library
|
|
279
222
|
├── library.js # the two-game OPFS library (src/demo/library.ts)
|
|
@@ -310,8 +253,8 @@ OPFS root
|
|
|
310
253
|
└── Sonic2/…
|
|
311
254
|
```
|
|
312
255
|
|
|
313
|
-
Because [
|
|
314
|
-
engine's working dir: launching
|
|
256
|
+
Because [every file lives in the engine's own folder](#every-file-lives-in-the-engines-own-folder),
|
|
257
|
+
that folder *is* the engine's working dir: launching starts with `storage({ namespace: 'Sonic1' })` and
|
|
315
258
|
no assets, so nothing is copied or re-read. **Replace** swaps the pack; **Remove**
|
|
316
259
|
deletes only `Data.rsdk`, leaving settings and saves for when it comes back.
|
|
317
260
|
|
|
@@ -370,31 +313,34 @@ What this project is, and the constraints that shape it.
|
|
|
370
313
|
|
|
371
314
|
- **A contract implementation, not a game.** The deliverable is one game-agnostic
|
|
372
315
|
`rsdkv4.wasm` plus an SDK conforming to
|
|
373
|
-
[@wasm-gaming/engine-specs](https://github.com/wasm-gaming/engine-specs) —
|
|
374
|
-
|
|
375
|
-
|
|
316
|
+
[@wasm-gaming/engine-specs](https://github.com/wasm-gaming/engine-specs) 1.0 —
|
|
317
|
+
`Rsdkv4SDK extends EngineSDKBase`. A host drives this engine exactly as it drives the
|
|
318
|
+
others.
|
|
319
|
+
- **Works on its own.** Clone, `npm ci`, `make build-sdk test`: no other wasm-gaming
|
|
320
|
+
repo needed. engine-specs is the one dependency, and its runtime ships inside
|
|
321
|
+
`dist/rsdkv4/`, so the package needs no bundler or import map.
|
|
376
322
|
- **Ships none of the game.** The player provides `Data.rsdk`; it never leaves the
|
|
377
323
|
browser and is never committed. Which pack the host hands over is what selects Sonic 1
|
|
378
324
|
or Sonic 2.
|
|
379
325
|
- **The engine's own values are the SDK's only private vocabulary.** They live in
|
|
380
|
-
[src/rsdkv4.options.ts](src/rsdkv4.options.ts) —
|
|
381
|
-
|
|
326
|
+
[src/rsdkv4.options.ts](src/rsdkv4.options.ts) — `RSDKV4_SPEC`, the four payloads as
|
|
327
|
+
data. Everything else is
|
|
382
328
|
contract, documented once at the [contract
|
|
383
329
|
reference](https://wasm-gaming.github.io/engine-specs/api-docs/).
|
|
384
|
-
- **The host owns the files; the SDK owns its folder.**
|
|
385
|
-
|
|
386
|
-
|
|
330
|
+
- **The host owns the files; the SDK owns its folder.** Everything lives in
|
|
331
|
+
`rsdkv4/<namespace>/` (WASMFS and OPFS alike): the engine adds `rsdkv4/`, the host only
|
|
332
|
+
picks `<namespace>`. The SDK reads the pack and never writes it; what it does write —
|
|
333
|
+
save data, `settings.ini` — lands in the same folder.
|
|
387
334
|
- **Persistence needs cross-origin isolation.** OPFS sync access exists only in workers,
|
|
388
335
|
so the working dir falls back to in-memory WASMFS when the page isn't isolated (or when
|
|
389
336
|
the build can't create the backend from the main thread — see the filesystem caveat).
|
|
390
337
|
- **Everything builds from the Makefile**, `package.json` has no scripts. `dist/` is both
|
|
391
338
|
the npm artifact and the Pages site: demo at the root, `/craft`, `/vanilla`, and the API
|
|
392
339
|
reference at `/api-docs`.
|
|
393
|
-
- **
|
|
394
|
-
- **
|
|
395
|
-
|
|
396
|
-
engine
|
|
397
|
-
[SESSIONS/2026-08-14_12h35.sdk-migration-to-contract-1.0.session.md](SESSIONS/2026-08-14_12h35.sdk-migration-to-contract-1.0.session.md).
|
|
340
|
+
- **RECORD/ is the record.** Specs and decisions live in [RECORD/](RECORD/): `.WIP.md` append-only, `.completed.md` read-only.
|
|
341
|
+
- **The reference engine.** The other wasm-gaming engines migrate to this layout, and the
|
|
342
|
+
[play.germade](https://github.com/jgermade/play.germade) launcher consumes it first — see
|
|
343
|
+
[RECORD/2026-11-12.standalone-package-and-wasmfs-engine-namespace.WIP.md](RECORD/2026-11-12.standalone-package-and-wasmfs-engine-namespace.WIP.md).
|
|
398
344
|
|
|
399
345
|
## Todos
|
|
400
346
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/* rsdkv4 app — the load-game screen (craft's slot cards) and the pause menu.
|
|
2
|
+
Tokens come from engine-specs (UI_TOKENS, `--wg-*`) when the host sets them; the
|
|
3
|
+
fallbacks are the same values, so the app looks right on a bare page too. */
|
|
4
|
+
|
|
5
|
+
.rsdk-app {
|
|
6
|
+
--ring: var(--wg-focus-ring, 0 0 0 3px #fff);
|
|
7
|
+
--surface: var(--wg-surface, rgb(18 20 28 / 0.78));
|
|
8
|
+
--line: var(--wg-line, rgb(255 255 255 / 0.12));
|
|
9
|
+
--text: var(--wg-text, #f4f6fb);
|
|
10
|
+
--muted: var(--wg-muted, #8e97a9);
|
|
11
|
+
--danger: var(--wg-danger, #f87171);
|
|
12
|
+
--ui: var(--wg-font-ui, 'Inter Variable', 'Inter', system-ui, sans-serif);
|
|
13
|
+
--display: var(--wg-font-display, 'Unbounded Variable', 'Unbounded', system-ui, sans-serif);
|
|
14
|
+
--ease: var(--wg-ease, cubic-bezier(0.2, 0.8, 0.2, 1));
|
|
15
|
+
position: absolute;
|
|
16
|
+
inset: 0;
|
|
17
|
+
overflow: hidden;
|
|
18
|
+
background: #000;
|
|
19
|
+
color: var(--text);
|
|
20
|
+
font-family: var(--ui);
|
|
21
|
+
user-select: none;
|
|
22
|
+
-webkit-tap-highlight-color: transparent;
|
|
23
|
+
}
|
|
24
|
+
.rsdk-app * { box-sizing: border-box; }
|
|
25
|
+
.rsdk-stage { position: absolute; inset: 0; display: grid; place-items: center; }
|
|
26
|
+
.rsdk-stage canvas { outline: none; }
|
|
27
|
+
.rsdk-overlay { position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none; }
|
|
28
|
+
.rsdk-overlay > * { pointer-events: auto; }
|
|
29
|
+
.rsdk-app[data-screen='game'] .rsdk-overlay { display: none; }
|
|
30
|
+
|
|
31
|
+
/* Everything but the game sits on a backdrop that keeps the game visible underneath. */
|
|
32
|
+
.rsdk-app:not([data-screen='game']) .rsdk-overlay {
|
|
33
|
+
background:
|
|
34
|
+
radial-gradient(120% 80% at 50% 0%, color-mix(in srgb, var(--accent) 38%, transparent), transparent 70%),
|
|
35
|
+
rgb(4 6 12 / 0.72);
|
|
36
|
+
backdrop-filter: blur(14px) saturate(1.2);
|
|
37
|
+
-webkit-backdrop-filter: blur(14px) saturate(1.2);
|
|
38
|
+
}
|
|
39
|
+
.rsdk-app[data-screen='boot'] .rsdk-overlay,
|
|
40
|
+
.rsdk-app[data-screen='slots'] .rsdk-overlay { background-color: rgb(4 6 12 / 0.9); }
|
|
41
|
+
|
|
42
|
+
.kicker { margin: 0; font: 600 0.72rem var(--ui); letter-spacing: 0.22em; text-transform: uppercase; color: var(--muted); }
|
|
43
|
+
.rsdk-app h2 { margin: 0; font: 700 clamp(1.4rem, 3vw, 2.1rem) var(--display); letter-spacing: -0.02em; }
|
|
44
|
+
|
|
45
|
+
/* ---- boot ---- */
|
|
46
|
+
.rsdk-boot { display: grid; justify-items: center; gap: 22px; }
|
|
47
|
+
.rsdk-boot img { width: min(260px, 50vw); border-radius: 22px; box-shadow: 0 30px 80px -30px #000; animation: rsdk-breathe 2.4s var(--ease) infinite alternate; }
|
|
48
|
+
.rsdk-boot p { margin: 0; color: var(--muted); font-weight: 500; }
|
|
49
|
+
@keyframes rsdk-breathe { from { opacity: 0.55; transform: scale(0.98); } to { opacity: 1; transform: none; } }
|
|
50
|
+
|
|
51
|
+
/* ---- load game: craft's slot cards (src/craft/views/GameLauncher.html) ---- */
|
|
52
|
+
.rsdk-slots { width: min(1120px, 100%); padding: 0 clamp(16px, 4vw, 48px); display: grid; gap: clamp(22px, 4vh, 40px); }
|
|
53
|
+
.rsdk-slots header { display: grid; gap: 6px; }
|
|
54
|
+
.slot-cards {
|
|
55
|
+
--w: clamp(150px, 13vw, 190px);
|
|
56
|
+
display: flex; justify-content: center; align-items: flex-start; gap: clamp(12px, 1.6vw, 24px);
|
|
57
|
+
}
|
|
58
|
+
.slot-card {
|
|
59
|
+
flex: none; width: var(--w); height: calc(var(--w) * 220 / 170);
|
|
60
|
+
display: flex; flex-direction: column; justify-content: space-between; gap: 0.4rem;
|
|
61
|
+
padding: 0.75rem; border-radius: 0.5rem; border: 2px solid #000; background: #333; color: #fff;
|
|
62
|
+
font-family: system-ui, sans-serif;
|
|
63
|
+
transition: transform 0.2s var(--ease), box-shadow 0.2s var(--ease);
|
|
64
|
+
}
|
|
65
|
+
.slot-card.is-focused { box-shadow: var(--ring), 0 24px 50px -20px #000; transform: translateY(-4px); }
|
|
66
|
+
.slot-card.is-nosave {
|
|
67
|
+
height: calc(var(--w) * 130 / 170); justify-content: center; align-items: center;
|
|
68
|
+
font-weight: 700; cursor: pointer;
|
|
69
|
+
}
|
|
70
|
+
.slot-card.is-nosave:hover:not(:active) { background: #222; }
|
|
71
|
+
.slot-card img { display: block; }
|
|
72
|
+
|
|
73
|
+
.slot-play {
|
|
74
|
+
all: unset; box-sizing: border-box; flex-shrink: 0; width: 100%; height: 34px; text-align: center;
|
|
75
|
+
border-radius: 4px; background: royalblue; color: #fff; font-weight: 700; text-shadow: 0 0 2px #000; cursor: pointer;
|
|
76
|
+
}
|
|
77
|
+
.slot-play:hover:not(:active), .slot-card.is-focused .slot-play { background: blue; }
|
|
78
|
+
|
|
79
|
+
/* the character picker: white square, the character over two drifting colour bands */
|
|
80
|
+
.player-choose {
|
|
81
|
+
all: unset; box-sizing: border-box; position: relative; width: 100%; aspect-ratio: 1; padding: 1rem 0.5rem 0;
|
|
82
|
+
display: flex; align-items: center; justify-content: center; overflow: hidden;
|
|
83
|
+
background: #fff; cursor: pointer; transition: padding 0.05s linear;
|
|
84
|
+
}
|
|
85
|
+
.player-choose::before, .player-choose::after { content: ''; position: absolute; inset: 0 1rem 0 3rem; background: rgb(180 180 180 / 0.2); }
|
|
86
|
+
.player-choose[data-player='sonic']::after { background: rgb(13 122 233 / 0.2); left: 30%; right: 20%; }
|
|
87
|
+
.player-choose[data-player='tails']::after { background: rgb(241 176 0 / 0.2); left: 40%; right: 10%; animation-delay: -5s; }
|
|
88
|
+
.player-choose[data-player='knuckles']::after { background: rgb(255 20 0 / 0.2); left: 20%; right: 30%; animation-delay: -10s; }
|
|
89
|
+
.player-choose[data-player='sonictails']::after { background: rgb(160 32 240 / 0.2); left: 20%; right: 70%; animation-delay: -15s; }
|
|
90
|
+
.slot-card:hover .player-choose, .slot-card.is-focused .player-choose { padding: 0; }
|
|
91
|
+
.player-choose:hover::before, .slot-card.is-focused .player-choose::before { animation: rsdk-player-bg 20s linear infinite; }
|
|
92
|
+
.player-choose:hover::after, .slot-card.is-focused .player-choose::after { animation: rsdk-player-bg 30s linear reverse infinite; }
|
|
93
|
+
.player-choose img { position: relative; z-index: 1; max-width: 100%; max-height: 100%; }
|
|
94
|
+
@keyframes rsdk-player-bg {
|
|
95
|
+
0% { left: 30%; right: 20%; } 10% { left: 50%; right: 50%; } 20% { left: 10%; right: 20%; }
|
|
96
|
+
30% { left: 60%; right: 40%; } 40% { left: 70%; right: 10%; } 50% { left: 20%; right: 80%; }
|
|
97
|
+
60% { left: 0; right: 60%; } 70% { left: 40%; right: 10%; } 80% { left: 10%; right: 70%; }
|
|
98
|
+
90% { left: 80%; right: 0; } 100% { left: 30%; right: 20%; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* a saved slot */
|
|
102
|
+
.slot-card.is-saved { gap: 0.25rem; }
|
|
103
|
+
.stage-shot { position: relative; width: 100%; flex-shrink: 0; }
|
|
104
|
+
.stage-badge {
|
|
105
|
+
width: 100%; aspect-ratio: 16 / 9; border-radius: 4px; overflow: hidden;
|
|
106
|
+
border: 1px solid rgb(255 255 255 / 0.2); background: linear-gradient(160deg, #1b2a4a, #080d1c);
|
|
107
|
+
}
|
|
108
|
+
.stage-badge img { width: 100%; height: 100%; object-fit: cover; filter: brightness(0.9); image-rendering: pixelated; }
|
|
109
|
+
.zone-tag {
|
|
110
|
+
position: absolute; top: 4px; left: 4px; padding: 1px 4px; border-radius: 3px; background: rgb(0 0 0 / 0.72);
|
|
111
|
+
color: #fbbf24; font-size: 0.5rem; font-weight: 800; letter-spacing: 0.06em; line-height: 1.4;
|
|
112
|
+
}
|
|
113
|
+
.player-icon { position: absolute; right: 0; bottom: -1rem; pointer-events: none; }
|
|
114
|
+
.player-icon img { height: calc(var(--w) * 64 / 170); width: auto; filter: drop-shadow(0 2px 4px rgb(0 0 0 / 0.85)); }
|
|
115
|
+
.zone-title {
|
|
116
|
+
height: 2.3em; padding-right: 26%; font-weight: 800; font-size: 0.72rem; color: #fbbf24; text-transform: uppercase;
|
|
117
|
+
letter-spacing: 0.4px; text-shadow: 0 1px 3px rgb(0 0 0 / 0.9); line-height: 1.15;
|
|
118
|
+
}
|
|
119
|
+
.emeralds { display: flex; justify-content: center; gap: 3px; flex-shrink: 0; }
|
|
120
|
+
.emeralds i { width: 11px; height: 11px; background: var(--gem); clip-path: polygon(50% 0, 100% 38%, 50% 100%, 0 38%); opacity: 0.2; }
|
|
121
|
+
.emeralds i.on { opacity: 1; filter: drop-shadow(0 0 3px rgb(255 255 255 / 0.4)); }
|
|
122
|
+
.stats-grid { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.2rem 0.4rem; font-size: 0.68rem; color: #e2e8f0; }
|
|
123
|
+
.stats-grid span {
|
|
124
|
+
display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.1rem 0.3rem; border-radius: 3px;
|
|
125
|
+
background: rgb(0 0 0 / 0.4); border: 1px solid rgb(255 255 255 / 0.1); font-variant-numeric: tabular-nums;
|
|
126
|
+
}
|
|
127
|
+
.stats-grid svg { width: 10px; height: 10px; }
|
|
128
|
+
|
|
129
|
+
/* ---- pause & confirm ---- */
|
|
130
|
+
.rsdk-panel {
|
|
131
|
+
width: min(440px, 92vw); display: grid; gap: 10px; padding: 26px; border-radius: 28px;
|
|
132
|
+
background: rgb(20 22 31 / 0.9); border: 1px solid var(--line); box-shadow: 0 40px 100px -30px #000;
|
|
133
|
+
}
|
|
134
|
+
.rsdk-panel h2 { margin-bottom: 8px; }
|
|
135
|
+
.rsdk-panel .text { margin: 0 0 8px; color: var(--muted); line-height: 1.5; }
|
|
136
|
+
.item {
|
|
137
|
+
all: unset; box-sizing: border-box; display: grid; gap: 2px; padding: 13px 16px; border-radius: 14px;
|
|
138
|
+
background: rgb(255 255 255 / 0.05); font-weight: 600; cursor: pointer; transition: background 0.15s, box-shadow 0.15s;
|
|
139
|
+
}
|
|
140
|
+
.item small { font-weight: 500; font-size: 0.78rem; color: var(--muted); }
|
|
141
|
+
.item.is-focused { background: #fff; color: #0b0d12; box-shadow: var(--ring); }
|
|
142
|
+
.item.is-focused small { color: #4b5563; }
|
|
143
|
+
.item.is-danger.is-focused { background: var(--danger); color: #1a0505; box-shadow: 0 0 0 3px var(--danger); }
|
|
144
|
+
.rsdk-panel .row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
|
145
|
+
.rsdk-panel .row .item { text-align: center; }
|
|
146
|
+
.rsdk-confirm { position: absolute; inset: 0; display: grid; place-items: center; background: rgb(0 0 0 / 0.55); }
|
|
147
|
+
|
|
148
|
+
/* ---- hints ---- */
|
|
149
|
+
.rsdk-hints { display: flex; flex-wrap: wrap; gap: 8px 18px; justify-content: center; color: var(--muted); font-size: 0.78rem; font-weight: 500; }
|
|
150
|
+
.rsdk-hints span { display: inline-flex; align-items: center; gap: 7px; }
|
|
151
|
+
.rsdk-hints kbd { display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 6px; border-radius: 11px; background: rgb(255 255 255 / 0.12); color: var(--text); font: 700 0.68rem var(--ui); }
|
|
152
|
+
.rsdk-panel .rsdk-hints { justify-content: flex-start; margin-top: 6px; }
|
|
153
|
+
|
|
154
|
+
@media (max-width: 960px) {
|
|
155
|
+
.slot-cards { justify-content: flex-start; overflow-x: auto; padding: 16px 6px 18px; margin: 0 -6px; scrollbar-width: none; scroll-snap-type: x mandatory; }
|
|
156
|
+
.slot-card { scroll-snap-align: center; }
|
|
157
|
+
}
|
|
158
|
+
@media (prefers-reduced-motion: reduce) {
|
|
159
|
+
.slot-card, .player-choose, .player-choose::before, .player-choose::after, .rsdk-boot img { transition: none; animation: none; }
|
|
160
|
+
}
|