@wasm-gaming/rsdkv3-wasm 0.1.4 → 0.2.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 +72 -63
- package/dist/rsdkv3/app/app.css +77 -0
- package/dist/rsdkv3/app/app.js +319 -0
- package/dist/rsdkv3/engine-specs.js +382 -0
- package/dist/rsdkv3/rsdkv3.options.d.ts +63 -15
- package/dist/rsdkv3/rsdkv3.options.d.ts.map +1 -1
- package/dist/rsdkv3/rsdkv3.options.js +36 -37
- package/dist/rsdkv3/rsdkv3.options.js.map +1 -1
- package/dist/rsdkv3/rsdkv3.sdk.d.ts +74 -33
- package/dist/rsdkv3/rsdkv3.sdk.d.ts.map +1 -1
- package/dist/rsdkv3/rsdkv3.sdk.js +355 -184
- package/dist/rsdkv3/rsdkv3.sdk.js.map +1 -1
- package/dist/rsdkv3/rsdkv3.wasm +0 -0
- package/package.json +9 -14
- package/src/app/app.css +77 -0
- package/src/app/app.js +319 -0
- package/src/rsdkv3.options.ts +99 -51
- package/src/rsdkv3.sdk.ts +407 -200
- package/dist/manifest.json +0 -76
- package/dist/rsdkv3/rsdkv3.manifest.d.ts +0 -4
- package/dist/rsdkv3/rsdkv3.manifest.d.ts.map +0 -1
- package/dist/rsdkv3/rsdkv3.manifest.js +0 -39
- package/dist/rsdkv3/rsdkv3.manifest.js.map +0 -1
- package/dist/rsdkv3/wasm-specs.d.ts +0 -71
- package/dist/rsdkv3/wasm-specs.d.ts.map +0 -1
- package/dist/rsdkv3/wasm-specs.js +0 -2
- package/dist/rsdkv3/wasm-specs.js.map +0 -1
- package/src/rsdkv3.manifest.ts +0 -44
- package/src/wasm-specs.ts +0 -72
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wasm-gaming/rsdkv3-wasm",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "RSDKv3 (
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "RSDKv3 (Sonic CD decompilation) compiled to WebAssembly, with a self-contained JS SDK implementing the wasm-gaming engine contract. Data.rsdk is loaded at runtime.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/wasm-gaming/rsdkv3-wasm.git"
|
|
@@ -22,29 +22,24 @@
|
|
|
22
22
|
"types": "./dist/rsdkv3/rsdkv3.options.d.ts",
|
|
23
23
|
"import": "./dist/rsdkv3/rsdkv3.options.js"
|
|
24
24
|
},
|
|
25
|
-
"./
|
|
25
|
+
"./app": {
|
|
26
|
+
"import": "./dist/rsdkv3/app/app.js"
|
|
27
|
+
}
|
|
26
28
|
},
|
|
27
29
|
"files": [
|
|
28
30
|
"dist/rsdkv3/",
|
|
29
|
-
"
|
|
30
|
-
"src/rsdkv3.manifest.ts",
|
|
31
|
+
"src/app/",
|
|
31
32
|
"src/rsdkv3.options.ts",
|
|
32
33
|
"src/rsdkv3.sdk.ts",
|
|
33
|
-
"src/wasm-specs.ts",
|
|
34
34
|
"README.md"
|
|
35
35
|
],
|
|
36
36
|
"comment:scripts": "Build logic lives in the Makefile, not here (make build / build-sdk / build-wasm / preview).",
|
|
37
37
|
"scripts": {},
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"peerDependenciesMeta": {
|
|
42
|
-
"@wasm-gaming/engine-specs": {
|
|
43
|
-
"optional": true
|
|
44
|
-
}
|
|
38
|
+
"comment:dependencies": "engine-specs is the one wasm-gaming package an engine depends on. Its runtime is also copied into dist/rsdkv3/ and imported relatively, so the built SDK loads without a bundler or an import map; the dependency carries the types.",
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@wasm-gaming/engine-specs": "^0.3.2"
|
|
45
41
|
},
|
|
46
42
|
"devDependencies": {
|
|
47
|
-
"@wasm-gaming/engine-specs": "^0.1.0",
|
|
48
43
|
"typescript": "^5.5.0"
|
|
49
44
|
},
|
|
50
45
|
"engineContract": "https://github.com/wasm-gaming/engine-specs",
|
package/src/app/app.css
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/* rsdkv3 app — boot screen and pause menu (same look as rsdkv4's app).
|
|
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 { background-color: rgb(4 6 12 / 0.9); }
|
|
40
|
+
|
|
41
|
+
.kicker { margin: 0; font: 600 0.72rem var(--ui); letter-spacing: 0.22em; text-transform: uppercase; color: var(--muted); }
|
|
42
|
+
.rsdk-app h2 { margin: 0; font: 700 clamp(1.4rem, 3vw, 2.1rem) var(--display); letter-spacing: -0.02em; }
|
|
43
|
+
|
|
44
|
+
/* ---- boot ---- */
|
|
45
|
+
.rsdk-boot { display: grid; justify-items: center; gap: 22px; }
|
|
46
|
+
.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; }
|
|
47
|
+
.rsdk-boot p { margin: 0; color: var(--muted); font-weight: 500; }
|
|
48
|
+
@keyframes rsdk-breathe { from { opacity: 0.55; transform: scale(0.98); } to { opacity: 1; transform: none; } }
|
|
49
|
+
|
|
50
|
+
/* ---- pause & confirm ---- */
|
|
51
|
+
.rsdk-panel {
|
|
52
|
+
width: min(440px, 92vw); display: grid; gap: 10px; padding: 26px; border-radius: 28px;
|
|
53
|
+
background: rgb(20 22 31 / 0.9); border: 1px solid var(--line); box-shadow: 0 40px 100px -30px #000;
|
|
54
|
+
}
|
|
55
|
+
.rsdk-panel h2 { margin-bottom: 8px; }
|
|
56
|
+
.rsdk-panel .text { margin: 0 0 8px; color: var(--muted); line-height: 1.5; }
|
|
57
|
+
.item {
|
|
58
|
+
all: unset; box-sizing: border-box; display: grid; gap: 2px; padding: 13px 16px; border-radius: 14px;
|
|
59
|
+
background: rgb(255 255 255 / 0.05); font-weight: 600; cursor: pointer; transition: background 0.15s, box-shadow 0.15s;
|
|
60
|
+
}
|
|
61
|
+
.item small { font-weight: 500; font-size: 0.78rem; color: var(--muted); }
|
|
62
|
+
.item.is-focused { background: #fff; color: #0b0d12; box-shadow: var(--ring); }
|
|
63
|
+
.item.is-focused small { color: #4b5563; }
|
|
64
|
+
.item.is-danger.is-focused { background: var(--danger); color: #1a0505; box-shadow: 0 0 0 3px var(--danger); }
|
|
65
|
+
.rsdk-panel .row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
|
66
|
+
.rsdk-panel .row .item { text-align: center; }
|
|
67
|
+
.rsdk-confirm { position: absolute; inset: 0; display: grid; place-items: center; background: rgb(0 0 0 / 0.55); }
|
|
68
|
+
|
|
69
|
+
/* ---- hints ---- */
|
|
70
|
+
.rsdk-hints { display: flex; flex-wrap: wrap; gap: 8px 18px; justify-content: center; color: var(--muted); font-size: 0.78rem; font-weight: 500; }
|
|
71
|
+
.rsdk-hints span { display: inline-flex; align-items: center; gap: 7px; }
|
|
72
|
+
.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); }
|
|
73
|
+
.rsdk-panel .rsdk-hints { justify-content: flex-start; margin-top: 6px; }
|
|
74
|
+
|
|
75
|
+
@media (prefers-reduced-motion: reduce) {
|
|
76
|
+
.rsdk-boot img { transition: none; animation: none; }
|
|
77
|
+
}
|
package/src/app/app.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// rsdkv3's own app: what a host gets when it mounts this engine instead of driving
|
|
2
|
+
// the SDK itself — a boot screen, then the game with its OWN title and save select,
|
|
3
|
+
// and a pause menu on the wasm-gaming usability scheme (engine-specs UX.md).
|
|
4
|
+
//
|
|
5
|
+
// import * as app from '<engines>/rsdkv3/app/app.js'
|
|
6
|
+
// const handle = await app.mount(target, host) // EngineApp.mount
|
|
7
|
+
// await handle.unmount()
|
|
8
|
+
//
|
|
9
|
+
// `host` is engine-specs' EngineAppHost: { game, storage, assets, input, exit }. The
|
|
10
|
+
// input is the host's createInput(): resumed it delivers menu actions; suspended (in
|
|
11
|
+
// game) only Select+Start and Escape come through, as `menu`.
|
|
12
|
+
//
|
|
13
|
+
// Unlike rsdkv4's app there is no load-game screen: Sonic CD's menus are scripts in
|
|
14
|
+
// Data.rsdk, with no native bridge to list or start a save (see RECORD/).
|
|
15
|
+
//
|
|
16
|
+
// Plain JS and DOM on purpose: the app ships inside the engine's package next to the
|
|
17
|
+
// SDK and loads from a static server, so it imports nothing but the SDK.
|
|
18
|
+
|
|
19
|
+
import Rsdkv3SDK from '../rsdkv3.sdk.js';
|
|
20
|
+
|
|
21
|
+
const ENGINE = 'rsdkv3';
|
|
22
|
+
const OWNER = 'app';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The game's own screens worth jumping to from the pause menu, looked up by scene
|
|
26
|
+
* name in the live stage list. Only the ones the loaded Data.rsdk has are offered.
|
|
27
|
+
*/
|
|
28
|
+
export const GAME_SCREENS = [
|
|
29
|
+
{ match: /^TITLE( SCREEN)?$/i, label: 'Pantalla de título' },
|
|
30
|
+
{ match: /^(MAIN MENU|MENU|SAVE SELECT|STAGE MENU)$/i, label: 'Menú del juego' },
|
|
31
|
+
{ match: /^(LEVEL|STAGE) SELECT$/i, label: 'Level select' }, // Sonic CD: STAGE SELECT
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** `[{ label, list, scene }]` for the GAME_SCREENS found in a devMenu stage list. */
|
|
35
|
+
export const findGameScreens = (lists) => {
|
|
36
|
+
const found = [];
|
|
37
|
+
for (const screen of GAME_SCREENS) {
|
|
38
|
+
for (let list = 0; list < (lists?.length ?? 0); list++) {
|
|
39
|
+
const scene = (lists[list]?.stages ?? []).findIndex(({ name } = {}) => screen.match.test(String(name ?? '').trim()));
|
|
40
|
+
if (scene >= 0) {
|
|
41
|
+
found.push({ label: screen.label, list, scene });
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return found;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const esc = (text) => String(text ?? '').replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
50
|
+
|
|
51
|
+
let cssLoaded = null;
|
|
52
|
+
const loadCss = () =>
|
|
53
|
+
(cssLoaded ??= new Promise((resolve) => {
|
|
54
|
+
const link = document.createElement('link');
|
|
55
|
+
link.rel = 'stylesheet';
|
|
56
|
+
link.href = new URL('./app.css', import.meta.url).href;
|
|
57
|
+
link.onload = link.onerror = () => resolve();
|
|
58
|
+
document.head.append(link);
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
export async function mount(target, host) {
|
|
62
|
+
await loadCss();
|
|
63
|
+
|
|
64
|
+
const root = document.createElement('div');
|
|
65
|
+
root.className = 'rsdk-app';
|
|
66
|
+
root.style.setProperty('--accent', host.game?.accent ?? '#1d4ed8');
|
|
67
|
+
const stage = document.createElement('div');
|
|
68
|
+
stage.className = 'rsdk-stage';
|
|
69
|
+
const overlay = document.createElement('div');
|
|
70
|
+
overlay.className = 'rsdk-overlay';
|
|
71
|
+
root.append(stage, overlay);
|
|
72
|
+
target.append(root);
|
|
73
|
+
|
|
74
|
+
/** screen: 'boot' | 'game' | 'pause' | 'confirm' | 'error' */
|
|
75
|
+
const state = {
|
|
76
|
+
screen: 'boot',
|
|
77
|
+
status: 'Arrancando el motor…',
|
|
78
|
+
error: null,
|
|
79
|
+
pause: { items: [], focus: 0 },
|
|
80
|
+
confirm: null, // { title, text, yes, no, onYes, onNo, focus, under }
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
let play = null;
|
|
84
|
+
let leaving = false;
|
|
85
|
+
let destroyed = false;
|
|
86
|
+
|
|
87
|
+
const canvasFocus = () => stage.querySelector('canvas')?.focus({ preventScroll: true });
|
|
88
|
+
const title = () => host.game?.title ?? 'Sonic CD';
|
|
89
|
+
|
|
90
|
+
// ---- screens ------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
const hints = (pairs) =>
|
|
93
|
+
`<footer class="rsdk-hints">${pairs.map(([k, t]) => `<span><kbd>${k}</kbd>${t}</span>`).join('')}</footer>`;
|
|
94
|
+
|
|
95
|
+
const render = () => {
|
|
96
|
+
if (destroyed) return;
|
|
97
|
+
root.dataset.screen = state.screen;
|
|
98
|
+
const s = state.screen;
|
|
99
|
+
if (s === 'game') {
|
|
100
|
+
overlay.innerHTML = '';
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (s === 'boot') {
|
|
104
|
+
overlay.innerHTML = `<section class="rsdk-boot">
|
|
105
|
+
${host.game?.art ? `<img src="${esc(host.game.art)}" alt="">` : ''}
|
|
106
|
+
<p>${esc(state.status)}</p>
|
|
107
|
+
</section>`;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (s === 'error') {
|
|
111
|
+
overlay.innerHTML = `<section class="rsdk-panel">
|
|
112
|
+
<p class="kicker">${esc(title())}</p>
|
|
113
|
+
<h2>No se pudo arrancar</h2>
|
|
114
|
+
<p class="text">${esc(state.error)}</p>
|
|
115
|
+
<button class="item is-focused" data-exit>Volver</button>
|
|
116
|
+
</section>`;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
let html = '';
|
|
120
|
+
const base = s === 'confirm' ? state.confirm.under : s;
|
|
121
|
+
if (base === 'pause') {
|
|
122
|
+
html += `<section class="rsdk-panel">
|
|
123
|
+
<p class="kicker">${esc(title())}</p>
|
|
124
|
+
<h2>Pausa</h2>
|
|
125
|
+
${state.pause.items.map((item, i) => `<button class="item${i === state.pause.focus && s !== 'confirm' ? ' is-focused' : ''}" data-item="${i}">${esc(item.label)}${item.hint ? `<small>${esc(item.hint)}</small>` : ''}</button>`).join('')}
|
|
126
|
+
${hints([['A', 'elegir'], ['B', 'continuar']])}
|
|
127
|
+
</section>`;
|
|
128
|
+
}
|
|
129
|
+
if (s === 'confirm') {
|
|
130
|
+
const c = state.confirm;
|
|
131
|
+
html += `<div class="rsdk-confirm"><section class="rsdk-panel">
|
|
132
|
+
<h2>${esc(c.title)}</h2>
|
|
133
|
+
${c.text ? `<p class="text">${esc(c.text)}</p>` : ''}
|
|
134
|
+
<div class="row">
|
|
135
|
+
<button class="item${c.focus === 0 ? ' is-focused' : ''}" data-confirm="0">${esc(c.no)}</button>
|
|
136
|
+
<button class="item is-danger${c.focus === 1 ? ' is-focused' : ''}" data-confirm="1">${esc(c.yes)}</button>
|
|
137
|
+
</div>
|
|
138
|
+
</section></div>`;
|
|
139
|
+
}
|
|
140
|
+
overlay.innerHTML = html;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// ---- transitions --------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
const enterGame = () => {
|
|
146
|
+
state.screen = 'game';
|
|
147
|
+
render();
|
|
148
|
+
host.input.suspend();
|
|
149
|
+
canvasFocus();
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const gameScreens = () => {
|
|
153
|
+
try {
|
|
154
|
+
return findGameScreens(play.devMenu.getStageList());
|
|
155
|
+
} catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const openPause = () => {
|
|
161
|
+
play?.pause(OWNER);
|
|
162
|
+
state.pause = {
|
|
163
|
+
focus: 0,
|
|
164
|
+
items: [
|
|
165
|
+
{ label: 'Continuar', run: closePause },
|
|
166
|
+
...gameScreens().map((screen) => ({
|
|
167
|
+
label: screen.label,
|
|
168
|
+
hint: 'pantalla del propio juego',
|
|
169
|
+
run: () => {
|
|
170
|
+
closePause();
|
|
171
|
+
play.devMenu.loadStage(screen.list, screen.scene);
|
|
172
|
+
},
|
|
173
|
+
})),
|
|
174
|
+
{ label: 'Volver al lanzador', run: askExit },
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
state.screen = 'pause';
|
|
178
|
+
host.input.resume();
|
|
179
|
+
render();
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const closePause = () => {
|
|
183
|
+
enterGame();
|
|
184
|
+
play?.resume(OWNER);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// A confirm sits on top of the screen it was asked from and goes back to it.
|
|
188
|
+
const ask = (options) => {
|
|
189
|
+
state.confirm = { focus: 0, under: state.screen, ...options };
|
|
190
|
+
state.screen = 'confirm';
|
|
191
|
+
render();
|
|
192
|
+
};
|
|
193
|
+
const answer = (yes) => {
|
|
194
|
+
const c = state.confirm;
|
|
195
|
+
state.screen = c.under;
|
|
196
|
+
state.confirm = null;
|
|
197
|
+
render();
|
|
198
|
+
(yes ? c.onYes : c.onNo)?.();
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const exit = async () => {
|
|
202
|
+
if (leaving) return;
|
|
203
|
+
leaving = true;
|
|
204
|
+
try {
|
|
205
|
+
await play?.destroy();
|
|
206
|
+
} catch (error) {
|
|
207
|
+
console.warn(`[${ENGINE} app] destroy failed`, error);
|
|
208
|
+
}
|
|
209
|
+
play = null;
|
|
210
|
+
host.exit();
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const askExit = () =>
|
|
214
|
+
ask({ title: '¿Volver al lanzador?', text: 'La partida se guarda donde la guarde el juego.', yes: 'Salir', no: 'Seguir', onYes: exit });
|
|
215
|
+
|
|
216
|
+
// ---- input ----------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
const onPause = (action) => {
|
|
219
|
+
const p = state.pause;
|
|
220
|
+
if (action === 'up') p.focus = (p.focus + p.items.length - 1) % p.items.length;
|
|
221
|
+
else if (action === 'down') p.focus = (p.focus + 1) % p.items.length;
|
|
222
|
+
else if (action === 'accept') return p.items[p.focus].run();
|
|
223
|
+
else if (action === 'back' || action === 'menu') return closePause();
|
|
224
|
+
else return;
|
|
225
|
+
render();
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const onConfirm = (action) => {
|
|
229
|
+
const c = state.confirm;
|
|
230
|
+
if (action === 'left' || action === 'up') c.focus = 0;
|
|
231
|
+
else if (action === 'right' || action === 'down') c.focus = 1;
|
|
232
|
+
else if (action === 'accept') return answer(c.focus === 1);
|
|
233
|
+
else if (action === 'back') return answer(false);
|
|
234
|
+
else return;
|
|
235
|
+
render();
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const off = host.input.on((action) => {
|
|
239
|
+
if (leaving) return;
|
|
240
|
+
const s = state.screen;
|
|
241
|
+
if (s === 'game') {
|
|
242
|
+
if (action === 'menu') openPause();
|
|
243
|
+
} else if (s === 'pause') onPause(action);
|
|
244
|
+
else if (s === 'confirm') onConfirm(action);
|
|
245
|
+
else if (s === 'error' && (action === 'accept' || action === 'back')) exit();
|
|
246
|
+
else if (s === 'boot' && action === 'back') exit();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Pointer: hover focuses, click activates — the same paths as the pad. Hover is read
|
|
250
|
+
// from real movement only: every render replaces the buttons, and the browser fires
|
|
251
|
+
// a fresh pointerover for whatever lands under a resting cursor.
|
|
252
|
+
let lastPointer = '';
|
|
253
|
+
const onPointer = (event) => {
|
|
254
|
+
if (event.type === 'pointermove') {
|
|
255
|
+
const at = `${event.clientX},${event.clientY}`;
|
|
256
|
+
if (at === lastPointer) return;
|
|
257
|
+
lastPointer = at;
|
|
258
|
+
}
|
|
259
|
+
const el = event.target.closest('[data-item],[data-confirm],[data-exit]');
|
|
260
|
+
if (!el || leaving) return;
|
|
261
|
+
if (event.type === 'pointermove') {
|
|
262
|
+
if (el.dataset.item != null && state.screen === 'pause' && state.pause.focus !== +el.dataset.item) {
|
|
263
|
+
state.pause.focus = +el.dataset.item;
|
|
264
|
+
render();
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (el.dataset.item != null) state.pause.items[+el.dataset.item].run();
|
|
269
|
+
else if (el.dataset.confirm != null) answer(el.dataset.confirm === '1');
|
|
270
|
+
else if (el.dataset.exit != null) exit();
|
|
271
|
+
};
|
|
272
|
+
overlay.addEventListener('click', onPointer);
|
|
273
|
+
overlay.addEventListener('pointermove', onPointer);
|
|
274
|
+
|
|
275
|
+
const handle = {
|
|
276
|
+
async unmount() {
|
|
277
|
+
destroyed = true;
|
|
278
|
+
leaving = true;
|
|
279
|
+
off();
|
|
280
|
+
overlay.removeEventListener('click', onPointer);
|
|
281
|
+
overlay.removeEventListener('pointermove', onPointer);
|
|
282
|
+
try {
|
|
283
|
+
await play?.destroy();
|
|
284
|
+
} catch {
|
|
285
|
+
/* already gone */
|
|
286
|
+
}
|
|
287
|
+
play = null;
|
|
288
|
+
root.remove();
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// ---- boot -----------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
render();
|
|
295
|
+
host.input.resume();
|
|
296
|
+
try {
|
|
297
|
+
play = await new Rsdkv3SDK()
|
|
298
|
+
.mount(stage)
|
|
299
|
+
.assets(host.assets)
|
|
300
|
+
.storage(host.storage)
|
|
301
|
+
.on('error', ({ detail }) => console.error(`[${ENGINE} app]`, detail))
|
|
302
|
+
.on('exit', () => exit())
|
|
303
|
+
.start();
|
|
304
|
+
if (destroyed) {
|
|
305
|
+
// Unmounted while booting: nothing to show any more.
|
|
306
|
+
await play.destroy().catch(() => {});
|
|
307
|
+
play = null;
|
|
308
|
+
return handle;
|
|
309
|
+
}
|
|
310
|
+
enterGame();
|
|
311
|
+
} catch (error) {
|
|
312
|
+
console.error(`[${ENGINE} app]`, error);
|
|
313
|
+
state.error = error?.message ?? String(error);
|
|
314
|
+
state.screen = 'error';
|
|
315
|
+
render();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return handle;
|
|
319
|
+
}
|
package/src/rsdkv3.options.ts
CHANGED
|
@@ -1,68 +1,116 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// At runtime these options are serialized into Sonic CD's `settings.ini` format
|
|
10
|
-
// when the host does not pass an explicit `settings` asset.
|
|
1
|
+
/**
|
|
2
|
+
* What RSDKv3 accepts, as types and as data.
|
|
3
|
+
*
|
|
4
|
+
* The four payloads of the wasm-gaming engine contract, closed for this engine, and
|
|
5
|
+
* {@link RSDKV3_SPEC} — the same thing as data the contract's base class reads:
|
|
6
|
+
* every default lives there and nowhere else.
|
|
7
|
+
*/
|
|
8
|
+
import type { EnginePayloads, EngineSpec } from '@wasm-gaming/engine-specs';
|
|
11
9
|
|
|
12
|
-
|
|
10
|
+
/** Bytes, or a thunk that produces them only when the engine actually needs them. */
|
|
11
|
+
export type Rsdkv3Asset =
|
|
12
|
+
| Uint8Array
|
|
13
|
+
| ArrayBuffer
|
|
14
|
+
| null
|
|
15
|
+
| (() => Uint8Array | ArrayBuffer | null | Promise<Uint8Array | ArrayBuffer | null>);
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
* the debug/stage-select UI (via `instance.devMenu`), and leaving this on has
|
|
18
|
-
* historically produced a native menu-like screen at boot.
|
|
19
|
-
*/
|
|
17
|
+
/** The engine, written into the generated `settings.ini`. Fixed for the life of the wasm. */
|
|
18
|
+
export interface Rsdkv3Config {
|
|
19
|
+
/** RSDKv3's native in-canvas Dev Menu. A host draws its own from `play.devMenu`. */
|
|
20
20
|
devMenu?: boolean;
|
|
21
|
-
/** RSDKv3 EngineDebugMode
|
|
21
|
+
/** RSDKv3 `EngineDebugMode`. */
|
|
22
22
|
engineDebugMode?: boolean;
|
|
23
|
-
/** VSync setting in the generated settings.ini. */
|
|
24
23
|
vsync?: boolean;
|
|
25
|
-
/** 0 = JP/EU
|
|
26
|
-
soundtrack?:
|
|
27
|
-
/** 0 = English.
|
|
24
|
+
/** Sonic CD soundtrack: 0 = JP/EU, 1 = US. */
|
|
25
|
+
soundtrack?: 0 | 1;
|
|
26
|
+
/** RSDKv3 language index (0 = English). */
|
|
28
27
|
language?: number;
|
|
29
|
-
/** Toggle touch controls in the generated settings.ini. */
|
|
30
28
|
disableTouchControls?: boolean;
|
|
29
|
+
/** Emscripten glue URL. Defaults to `./rsdkv3.js` next to the SDK. */
|
|
30
|
+
jsUrl?: string;
|
|
31
|
+
/** WASM URL. Defaults to `./rsdkv3.wasm` next to the SDK. */
|
|
32
|
+
wasmUrl?: string;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
/** The session. Nothing per-session yet. */
|
|
36
|
+
export type Rsdkv3Options = Record<string, never>;
|
|
37
|
+
|
|
38
|
+
export interface Rsdkv3Assets {
|
|
39
|
+
/** The game's `Data.rsdk` (Sonic CD). Required to start. */
|
|
40
|
+
data?: Rsdkv3Asset;
|
|
41
|
+
/** A ready-made `settings.ini`; omitted, one is generated from `config`. */
|
|
42
|
+
settings?: Rsdkv3Asset;
|
|
43
|
+
}
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
/** Where this engine's files live, and whether they outlive the page. */
|
|
46
|
+
export interface Rsdkv3Storage {
|
|
47
|
+
/**
|
|
48
|
+
* Per-game folder, relative to the engine's own: files land in `rsdkv3/<namespace>/`
|
|
49
|
+
* (WASMFS `/data/rsdkv3/<namespace>`, and the OPFS folder the save is mirrored to).
|
|
50
|
+
* Segments are cleaned (`.`/`..` dropped, odd characters → `_`); a leading
|
|
51
|
+
* `rsdkv3/` is stripped once. `play.storagePath` gives the result.
|
|
52
|
+
*
|
|
53
|
+
* @defaultValue `"default"`
|
|
54
|
+
*/
|
|
55
|
+
namespace?: string;
|
|
56
|
+
/** `null` keeps nothing; otherwise `SData.bin` is mirrored to OPFS. */
|
|
57
|
+
persist?: 'opfs' | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface Rsdkv3Events {
|
|
61
|
+
/** `SData.bin` changed and was written to `rsdkv3/<namespace>/`. */
|
|
62
|
+
saves: { files: string[] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Rsdkv3Payloads extends EnginePayloads {
|
|
66
|
+
config: Rsdkv3Config;
|
|
67
|
+
options: Rsdkv3Options;
|
|
68
|
+
assets: Rsdkv3Assets;
|
|
69
|
+
storage: Rsdkv3Storage;
|
|
70
|
+
events: Rsdkv3Events;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The native picture, which the SDK scales to fit its mount. */
|
|
74
|
+
export const RSDKV3_VIDEO = { width: 426, height: 240, aspect: '16:9' } as const;
|
|
75
|
+
|
|
76
|
+
export const RSDKV3_SPEC: EngineSpec<Rsdkv3Payloads> = {
|
|
77
|
+
config: {
|
|
46
78
|
devMenu: {
|
|
47
|
-
type: 'boolean',
|
|
48
79
|
default: false,
|
|
49
|
-
|
|
50
|
-
|
|
80
|
+
live: false,
|
|
81
|
+
description: "RSDKv3's native Dev Menu; keep it off and draw your own from play.devMenu.",
|
|
51
82
|
},
|
|
52
|
-
engineDebugMode: {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
83
|
+
engineDebugMode: { default: false, live: false, description: 'RSDKv3 EngineDebugMode.' },
|
|
84
|
+
vsync: { default: false, live: false, description: 'VSync in the generated settings.ini.' },
|
|
85
|
+
soundtrack: { default: 0, enum: [0, 1], live: false, description: 'Sonic CD soundtrack: 0 = JP/EU, 1 = US.' },
|
|
86
|
+
language: { default: 0, live: false, description: 'RSDKv3 language index (0 = English).' },
|
|
87
|
+
disableTouchControls: { default: true, live: false, description: 'Hide the touch controls.' },
|
|
88
|
+
jsUrl: { live: false, description: 'Emscripten glue URL. Defaults to ./rsdkv3.js.' },
|
|
89
|
+
wasmUrl: { live: false, description: 'WASM URL. Defaults to ./rsdkv3.wasm.' },
|
|
90
|
+
},
|
|
91
|
+
options: {},
|
|
92
|
+
assets: {
|
|
93
|
+
data: {
|
|
94
|
+
allowedTypes: ['.rsdk'],
|
|
95
|
+
live: false,
|
|
96
|
+
description: 'The RSDKv3 game data pack (Sonic CD). Required to start.',
|
|
97
|
+
},
|
|
98
|
+
settings: {
|
|
99
|
+
allowedTypes: ['.ini'],
|
|
100
|
+
live: false,
|
|
101
|
+
description: 'A ready-made settings.ini. Omitted, one is generated from config.',
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
storage: {
|
|
105
|
+
namespace: {
|
|
106
|
+
default: 'default',
|
|
107
|
+
live: false,
|
|
108
|
+
description: "Folder for this game's save, inside the engine's own rsdkv3/ folder (OPFS rsdkv3/<namespace>/).",
|
|
56
109
|
},
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
maximum: 1,
|
|
62
|
-
default: 0,
|
|
63
|
-
description: 'Sonic CD soundtrack selector: 0=JP/EU, 1=US.',
|
|
110
|
+
persist: {
|
|
111
|
+
enum: ['opfs', null],
|
|
112
|
+
live: false,
|
|
113
|
+
description: 'null keeps nothing; otherwise SData.bin is mirrored to OPFS.',
|
|
64
114
|
},
|
|
65
|
-
language: { type: 'integer', minimum: 0, default: 0 },
|
|
66
|
-
disableTouchControls: { type: 'boolean', default: true },
|
|
67
115
|
},
|
|
68
116
|
};
|