@wasm-gaming/rsdkv5-wasm 0.1.2 → 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 +27 -0
- package/dist/rsdkv5/app/app.css +176 -0
- package/dist/rsdkv5/app/app.js +527 -0
- package/dist/rsdkv5/app/assets/player-knuckles.svg +757 -0
- package/dist/rsdkv5/app/assets/player-sonic.svg +720 -0
- package/dist/rsdkv5/app/assets/player-sonictails.svg +1414 -0
- package/dist/rsdkv5/app/assets/player-tails.svg +979 -0
- package/dist/rsdkv5/rsdkv5.js +1 -1
- package/dist/rsdkv5/rsdkv5.sdk.d.ts +58 -1
- package/dist/rsdkv5/rsdkv5.sdk.d.ts.map +1 -1
- package/dist/rsdkv5/rsdkv5.sdk.js +107 -2
- package/dist/rsdkv5/rsdkv5.sdk.js.map +1 -1
- package/dist/rsdkv5/rsdkv5.wasm +0 -0
- package/package.json +5 -1
- package/src/app/app.css +176 -0
- package/src/app/app.js +527 -0
- package/src/app/assets/player-knuckles.svg +757 -0
- package/src/app/assets/player-sonic.svg +720 -0
- package/src/app/assets/player-sonictails.svg +1414 -0
- package/src/app/assets/player-tails.svg +979 -0
- package/src/rsdkv5.sdk.ts +145 -2
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
// rsdkv5's own app: what a host gets when it mounts this engine instead of driving
|
|
2
|
+
// the SDK itself — a load-game screen (craft's cards, as rsdkv4's app, on Mania's
|
|
3
|
+
// eight save slots), the game, and a pause menu, on the wasm-gaming usability scheme
|
|
4
|
+
// (engine-specs UX.md).
|
|
5
|
+
//
|
|
6
|
+
// import * as app from '<engines>/rsdkv5/app/app.js'
|
|
7
|
+
// const handle = await app.mount(target, host) // EngineApp.mount
|
|
8
|
+
// await handle.unmount()
|
|
9
|
+
//
|
|
10
|
+
// `host` is engine-specs' EngineAppHost: { game, storage, assets, input, exit }. The
|
|
11
|
+
// input is the host's createInput(): resumed it delivers menu actions; suspended (in
|
|
12
|
+
// game) only Select+Start and Escape come through, as `menu`.
|
|
13
|
+
//
|
|
14
|
+
// The slots come from the SDK's `saves` (SaveData.bin read directly; a slot started
|
|
15
|
+
// through the build's bridge, which replays Mania's own save-select action, so Mania
|
|
16
|
+
// writes its file itself). Mania only loads its save once a scene with SaveGame is
|
|
17
|
+
// up, so the game keeps running — muted — behind the slot screen until it has, then
|
|
18
|
+
// pauses. A wasm without the bridge gets the phase-A app: the game with its own menus.
|
|
19
|
+
//
|
|
20
|
+
// Plain JS and DOM on purpose: the app ships inside the engine's package next to the
|
|
21
|
+
// SDK and loads from a static server, so it imports nothing but the SDK.
|
|
22
|
+
|
|
23
|
+
import Rsdkv5SDK, { MANIA_CHARACTERS } from '../rsdkv5.sdk.js';
|
|
24
|
+
|
|
25
|
+
const ENGINE = 'rsdkv5';
|
|
26
|
+
const OWNER = 'app';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The game's own screens worth jumping to from the pause menu, looked up by scene
|
|
30
|
+
* name in the live stage list. Only the ones the loaded Data.rsdk has are offered.
|
|
31
|
+
*/
|
|
32
|
+
export const GAME_SCREENS = [
|
|
33
|
+
{ match: /^TITLE( SCREEN)?$/i, label: 'Pantalla de título' },
|
|
34
|
+
{ match: /^(MAIN MENU|MENU|SAVE SELECT|STAGE MENU)$/i, label: 'Menú del juego' },
|
|
35
|
+
{ match: /^(LEVEL|STAGE) SELECT$/i, label: 'Level select' }, // Sonic CD: STAGE SELECT
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** `[{ label, list, scene }]` for the GAME_SCREENS found in a devMenu stage list. */
|
|
39
|
+
export const findGameScreens = (lists) => {
|
|
40
|
+
const found = [];
|
|
41
|
+
for (const screen of GAME_SCREENS) {
|
|
42
|
+
for (let list = 0; list < (lists?.length ?? 0); list++) {
|
|
43
|
+
const scene = (lists[list]?.stages ?? []).findIndex(({ name } = {}) => screen.match.test(String(name ?? '').trim()));
|
|
44
|
+
if (scene >= 0) {
|
|
45
|
+
found.push({ label: screen.label, list, scene });
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return found;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const asset = (path) => new URL(`./assets/${path}`, import.meta.url).href;
|
|
54
|
+
|
|
55
|
+
/** Art per Mania character (`characterID`); Mighty and Ray have none here: a badge. */
|
|
56
|
+
const PLAYER_ART = ['sonictails', 'sonic', 'tails', 'knuckles', null, null];
|
|
57
|
+
const GEMS = ['#58e058', '#f0a038', '#f078d8', '#9878f0', '#f0f0f0', '#f07878', '#78c0f8'];
|
|
58
|
+
const ZONE_COUNT = 12; // Green Hill … Titanic Monarch (Egg Reverie is the 13th, after them)
|
|
59
|
+
/** Mania's own zone abbreviations, by `zoneID`. */
|
|
60
|
+
const ZONE_CODES = ['GHZ', 'CPZ', 'SPZ', 'FBZ', 'PGZ', 'SSZ', 'HCZ', 'MSZ', 'OOZ', 'LRZ', 'MMZ', 'TMZ', 'ERZ'];
|
|
61
|
+
|
|
62
|
+
const playerArt = (character, cls = '') => {
|
|
63
|
+
const art = PLAYER_ART[character];
|
|
64
|
+
if (art) return `<img class="${cls}" src="${asset(`player-${art}.svg`)}" alt="">`;
|
|
65
|
+
const name = MANIA_CHARACTERS[character] ?? '?';
|
|
66
|
+
return `<span class="player-badge ${cls}" data-char="${character}">${name.charAt(0)}</span>`;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const esc = (text) => String(text ?? '').replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
70
|
+
|
|
71
|
+
let cssLoaded = null;
|
|
72
|
+
const loadCss = () =>
|
|
73
|
+
(cssLoaded ??= new Promise((resolve) => {
|
|
74
|
+
const link = document.createElement('link');
|
|
75
|
+
link.rel = 'stylesheet';
|
|
76
|
+
link.href = new URL('./app.css', import.meta.url).href;
|
|
77
|
+
link.onload = link.onerror = () => resolve();
|
|
78
|
+
document.head.append(link);
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
export async function mount(target, host) {
|
|
82
|
+
await loadCss();
|
|
83
|
+
|
|
84
|
+
const root = document.createElement('div');
|
|
85
|
+
root.className = 'rsdk-app';
|
|
86
|
+
root.style.setProperty('--accent', host.game?.accent ?? '#1d4ed8');
|
|
87
|
+
const stage = document.createElement('div');
|
|
88
|
+
stage.className = 'rsdk-stage';
|
|
89
|
+
const overlay = document.createElement('div');
|
|
90
|
+
overlay.className = 'rsdk-overlay';
|
|
91
|
+
root.append(stage, overlay);
|
|
92
|
+
target.append(root);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* screen: 'boot' | 'slots' | 'game' | 'pause' | 'confirm' | 'error'
|
|
96
|
+
* The slot screen is the top level before playing; from the pause menu it is one
|
|
97
|
+
* level down (Back returns to the pause menu).
|
|
98
|
+
*/
|
|
99
|
+
const state = {
|
|
100
|
+
screen: 'boot',
|
|
101
|
+
status: 'Arrancando el motor…',
|
|
102
|
+
error: null,
|
|
103
|
+
slots: [],
|
|
104
|
+
focus: 1, // 0 = Mania's own menus, 1-8 = slots
|
|
105
|
+
pick: Array(9).fill(0), // character per empty card
|
|
106
|
+
characters: 4,
|
|
107
|
+
from: null,
|
|
108
|
+
waiting: false, // a slot was picked before Mania had its save loaded
|
|
109
|
+
playing: false,
|
|
110
|
+
pause: { items: [], focus: 0 },
|
|
111
|
+
confirm: null, // { title, text, yes, no, onYes, onNo, focus, under }
|
|
112
|
+
};
|
|
113
|
+
let saveReady = null; // Promise<boolean>, once asked
|
|
114
|
+
|
|
115
|
+
let play = null;
|
|
116
|
+
let leaving = false;
|
|
117
|
+
let destroyed = false;
|
|
118
|
+
|
|
119
|
+
const canvasFocus = () => stage.querySelector('canvas')?.focus({ preventScroll: true });
|
|
120
|
+
const title = () => host.game?.title ?? 'Sonic Mania';
|
|
121
|
+
|
|
122
|
+
// ---- screens ------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
const hints = (pairs) =>
|
|
125
|
+
`<footer class="rsdk-hints">${pairs.map(([k, t]) => `<span><kbd>${k}</kbd>${t}</span>`).join('')}</footer>`;
|
|
126
|
+
|
|
127
|
+
const render = () => {
|
|
128
|
+
if (destroyed) return;
|
|
129
|
+
root.dataset.screen = state.screen;
|
|
130
|
+
const s = state.screen;
|
|
131
|
+
if (s === 'game') {
|
|
132
|
+
overlay.innerHTML = '';
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (s === 'boot') {
|
|
136
|
+
overlay.innerHTML = `<section class="rsdk-boot">
|
|
137
|
+
${host.game?.art ? `<img src="${esc(host.game.art)}" alt="">` : ''}
|
|
138
|
+
<p>${esc(state.status)}</p>
|
|
139
|
+
</section>`;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (s === 'error') {
|
|
143
|
+
overlay.innerHTML = `<section class="rsdk-panel">
|
|
144
|
+
<p class="kicker">${esc(title())}</p>
|
|
145
|
+
<h2>No se pudo arrancar</h2>
|
|
146
|
+
<p class="text">${esc(state.error)}</p>
|
|
147
|
+
<button class="item is-focused" data-exit>Volver</button>
|
|
148
|
+
</section>`;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
let html = '';
|
|
152
|
+
const base = s === 'confirm' ? state.confirm.under : s;
|
|
153
|
+
if (base === 'slots') {
|
|
154
|
+
const card = state.focus;
|
|
155
|
+
const slot = card > 0 ? state.slots[card - 1] : null;
|
|
156
|
+
const saved = slot && slot.state !== 'empty';
|
|
157
|
+
html += `<section class="rsdk-slots">
|
|
158
|
+
<header><p class="kicker">${esc(title())}</p><h2>${state.waiting ? 'Cargando partidas…' : 'Elige partida'}</h2></header>
|
|
159
|
+
<div class="slot-cards">${[0, 1, 2, 3, 4, 5, 6, 7, 8].map(slotCard).join('')}</div>
|
|
160
|
+
${hints([
|
|
161
|
+
['←→', 'partida'],
|
|
162
|
+
...(card > 0 && !saved ? [['↑↓', 'personaje']] : []),
|
|
163
|
+
['A', card === 0 ? 'menús de Mania' : saved ? 'continuar' : 'jugar'],
|
|
164
|
+
['B', state.from === 'pause' ? 'volver' : 'salir'],
|
|
165
|
+
])}
|
|
166
|
+
</section>`;
|
|
167
|
+
} else if (base === 'pause') {
|
|
168
|
+
html += `<section class="rsdk-panel">
|
|
169
|
+
<p class="kicker">${esc(title())}</p>
|
|
170
|
+
<h2>Pausa</h2>
|
|
171
|
+
${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('')}
|
|
172
|
+
${hints([['A', 'elegir'], ['B', 'continuar']])}
|
|
173
|
+
</section>`;
|
|
174
|
+
}
|
|
175
|
+
if (s === 'confirm') {
|
|
176
|
+
const c = state.confirm;
|
|
177
|
+
html += `<div class="rsdk-confirm"><section class="rsdk-panel">
|
|
178
|
+
<h2>${esc(c.title)}</h2>
|
|
179
|
+
${c.text ? `<p class="text">${esc(c.text)}</p>` : ''}
|
|
180
|
+
<div class="row">
|
|
181
|
+
<button class="item${c.focus === 0 ? ' is-focused' : ''}" data-confirm="0">${esc(c.no)}</button>
|
|
182
|
+
<button class="item is-danger${c.focus === 1 ? ' is-focused' : ''}" data-confirm="1">${esc(c.yes)}</button>
|
|
183
|
+
</div>
|
|
184
|
+
</section></div>`;
|
|
185
|
+
}
|
|
186
|
+
overlay.innerHTML = html;
|
|
187
|
+
// Nine cards don't fit most screens: keep the focused one in view (by hand —
|
|
188
|
+
// scrollIntoView would also scroll the host's page).
|
|
189
|
+
const cards = overlay.querySelector('.slot-cards');
|
|
190
|
+
const focused = cards?.querySelector('.slot-card.is-focused');
|
|
191
|
+
if (cards && focused && cards.scrollWidth > cards.clientWidth) {
|
|
192
|
+
cards.style.scrollBehavior = 'auto';
|
|
193
|
+
cards.scrollLeft = scrollMemo;
|
|
194
|
+
cards.style.scrollBehavior = '';
|
|
195
|
+
cards.scrollLeft = focused.offsetLeft - (cards.clientWidth - focused.offsetWidth) / 2;
|
|
196
|
+
}
|
|
197
|
+
scrollMemo = cards?.scrollLeft ?? 0;
|
|
198
|
+
};
|
|
199
|
+
let scrollMemo = 0;
|
|
200
|
+
|
|
201
|
+
// craft's slot cards (rsdkv4's app), on Mania's data: the first card goes to Mania's
|
|
202
|
+
// own menus (Encore, Time Attack…); a saved slot shows where it stopped, an empty
|
|
203
|
+
// one is the character picker over PLAY.
|
|
204
|
+
function slotCard(i) {
|
|
205
|
+
const focused = state.focus === i ? ' is-focused' : '';
|
|
206
|
+
if (i === 0) {
|
|
207
|
+
return `<div class="slot-card is-nosave${focused}" data-card="0" role="button">MANIA<br>MENUS</div>`;
|
|
208
|
+
}
|
|
209
|
+
const slot = state.slots[i - 1];
|
|
210
|
+
if (!slot || slot.state === 'empty') {
|
|
211
|
+
const character = state.pick[i];
|
|
212
|
+
return `<div class="slot-card is-new${focused}" data-focus="${i}">
|
|
213
|
+
<button type="button" class="player-choose" data-player="${PLAYER_ART[character] ?? 'badge'}" data-pick="${i}" data-dir="1"
|
|
214
|
+
title="${esc(MANIA_CHARACTERS[character])}">${playerArt(character)}</button>
|
|
215
|
+
<button type="button" class="slot-play" data-card="${i}">PLAY</button>
|
|
216
|
+
</div>`;
|
|
217
|
+
}
|
|
218
|
+
const complete = slot.state === 'complete';
|
|
219
|
+
const zoneTag = `${i} · ${complete ? 'CLEAR' : slot.zone < ZONE_COUNT ? `ZONE ${slot.zone + 1}` : 'FINAL'}`;
|
|
220
|
+
const code = complete ? '★' : (ZONE_CODES[slot.zone] ?? '?');
|
|
221
|
+
const name = complete ? 'GAME CLEAR' : slot.zoneName.toUpperCase();
|
|
222
|
+
return `<div class="slot-card is-saved${focused}" data-focus="${i}">
|
|
223
|
+
<div class="stage-shot">
|
|
224
|
+
<div class="stage-badge is-mania"><span>${esc(code)}</span></div>
|
|
225
|
+
<div class="zone-tag">${esc(zoneTag)}</div>
|
|
226
|
+
<div class="player-icon">${playerArt(slot.character)}</div>
|
|
227
|
+
</div>
|
|
228
|
+
<div class="zone-title" title="${esc(name)} · ${esc(MANIA_CHARACTERS[slot.character])}">${esc(name)}</div>
|
|
229
|
+
<div class="emeralds" title="${slot.emeralds} of 7 chaos emeralds">${GEMS.map((c, g) => `<i style="--gem:${c}"${g < slot.emeralds ? ' class="on"' : ''}></i>`).join('')}</div>
|
|
230
|
+
<div class="stats-grid">
|
|
231
|
+
<span title="Lives"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="#f43f5e" d="M8 14.2 1.9 8.4A3.6 3.6 0 0 1 8 3.6a3.6 3.6 0 0 1 6.1 4.8z"/></svg>${slot.lives}</span>
|
|
232
|
+
<span title="Score"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="#fbbf24" d="m8 1.2 2.1 4.3 4.7.7-3.4 3.3.8 4.7L8 12l-4.2 2.2.8-4.7L1.2 6.2l4.7-.7z"/></svg>${slot.score}</span>
|
|
233
|
+
</div>
|
|
234
|
+
<button type="button" class="slot-play" data-card="${i}">CONTINUE</button>
|
|
235
|
+
</div>`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---- transitions --------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
const refreshSlots = () => {
|
|
241
|
+
try {
|
|
242
|
+
state.slots = play?.saves.list() ?? [];
|
|
243
|
+
} catch {
|
|
244
|
+
state.slots = [];
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Mania loads its save once a scene with SaveGame is up (the title screen): until
|
|
250
|
+
* then the game runs muted behind the slot screen, then it is paused.
|
|
251
|
+
*/
|
|
252
|
+
const askSaveReady = () =>
|
|
253
|
+
(saveReady ??= play.saves.ready(30000).then((ready) => {
|
|
254
|
+
if (state.screen === 'slots' || state.screen === 'confirm') play.pause(OWNER);
|
|
255
|
+
return ready;
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
const showSlots = (from = null) => {
|
|
259
|
+
refreshSlots();
|
|
260
|
+
state.from = from;
|
|
261
|
+
state.screen = 'slots';
|
|
262
|
+
host.input.resume();
|
|
263
|
+
if (from === 'pause') play.pause(OWNER);
|
|
264
|
+
render();
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const startSlot = async (card) => {
|
|
268
|
+
if (state.waiting) return;
|
|
269
|
+
if (card === 0) return playManiaMenus();
|
|
270
|
+
const slot = card - 1;
|
|
271
|
+
state.waiting = true;
|
|
272
|
+
render();
|
|
273
|
+
const ready = await askSaveReady();
|
|
274
|
+
state.waiting = false;
|
|
275
|
+
if (destroyed || leaving) return;
|
|
276
|
+
if (!ready) {
|
|
277
|
+
state.error = 'Mania no ha podido cargar sus partidas.';
|
|
278
|
+
render();
|
|
279
|
+
return playManiaMenus();
|
|
280
|
+
}
|
|
281
|
+
state.screen = 'game';
|
|
282
|
+
state.playing = true;
|
|
283
|
+
render();
|
|
284
|
+
host.input.suspend();
|
|
285
|
+
play.mute(false);
|
|
286
|
+
play.saves.start(slot, state.pick[card]); // also lifts the pause
|
|
287
|
+
canvasFocus();
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/** The game as it is, with Mania's own title and menus. */
|
|
291
|
+
const playManiaMenus = () => {
|
|
292
|
+
state.playing = true;
|
|
293
|
+
play.mute(false);
|
|
294
|
+
play.resume(OWNER);
|
|
295
|
+
enterGame();
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const cyclePlayer = (card, dir) => {
|
|
299
|
+
const count = state.characters;
|
|
300
|
+
state.pick[card] = (state.pick[card] + dir + count) % count;
|
|
301
|
+
render();
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const enterGame = () => {
|
|
305
|
+
state.screen = 'game';
|
|
306
|
+
render();
|
|
307
|
+
host.input.suspend();
|
|
308
|
+
canvasFocus();
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const gameScreens = () => {
|
|
312
|
+
try {
|
|
313
|
+
return findGameScreens(play.devMenu.getStageList());
|
|
314
|
+
} catch {
|
|
315
|
+
return [];
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const openPause = () => {
|
|
320
|
+
play?.pause(OWNER);
|
|
321
|
+
state.pause = {
|
|
322
|
+
focus: 0,
|
|
323
|
+
items: [
|
|
324
|
+
{ label: 'Continuar', run: closePause },
|
|
325
|
+
...(play?.saves.available
|
|
326
|
+
? [{ label: 'Cambiar de partida', hint: 'vuelve a las partidas guardadas', run: () => showSlots('pause') }]
|
|
327
|
+
: []),
|
|
328
|
+
...gameScreens().map((screen) => ({
|
|
329
|
+
label: screen.label,
|
|
330
|
+
hint: 'pantalla del propio juego',
|
|
331
|
+
run: () => {
|
|
332
|
+
closePause();
|
|
333
|
+
play.devMenu.loadStage(screen.list, screen.scene);
|
|
334
|
+
},
|
|
335
|
+
})),
|
|
336
|
+
{ label: 'Volver al lanzador', run: askExit },
|
|
337
|
+
],
|
|
338
|
+
};
|
|
339
|
+
state.screen = 'pause';
|
|
340
|
+
host.input.resume();
|
|
341
|
+
render();
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const closePause = () => {
|
|
345
|
+
enterGame();
|
|
346
|
+
play?.resume(OWNER);
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// A confirm sits on top of the screen it was asked from and goes back to it.
|
|
350
|
+
const ask = (options) => {
|
|
351
|
+
state.confirm = { focus: 0, under: state.screen, ...options };
|
|
352
|
+
state.screen = 'confirm';
|
|
353
|
+
render();
|
|
354
|
+
};
|
|
355
|
+
const answer = (yes) => {
|
|
356
|
+
const c = state.confirm;
|
|
357
|
+
state.screen = c.under;
|
|
358
|
+
state.confirm = null;
|
|
359
|
+
render();
|
|
360
|
+
(yes ? c.onYes : c.onNo)?.();
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const exit = async () => {
|
|
364
|
+
if (leaving) return;
|
|
365
|
+
leaving = true;
|
|
366
|
+
try {
|
|
367
|
+
await play?.destroy();
|
|
368
|
+
} catch (error) {
|
|
369
|
+
console.warn(`[${ENGINE} app] destroy failed`, error);
|
|
370
|
+
}
|
|
371
|
+
play = null;
|
|
372
|
+
host.exit();
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const askExit = () =>
|
|
376
|
+
ask({ title: '¿Volver al lanzador?', text: 'La partida se guarda donde la guarde el juego.', yes: 'Salir', no: 'Seguir', onYes: exit });
|
|
377
|
+
|
|
378
|
+
// ---- input ----------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
const onSlots = (action) => {
|
|
381
|
+
const card = state.focus;
|
|
382
|
+
const slot = card > 0 ? state.slots[card - 1] : null;
|
|
383
|
+
const saved = slot && slot.state !== 'empty';
|
|
384
|
+
if (state.waiting) return;
|
|
385
|
+
if (action === 'left' || action === 'prev') state.focus = Math.max(0, card - 1);
|
|
386
|
+
else if (action === 'right' || action === 'next') state.focus = Math.min(8, card + 1);
|
|
387
|
+
else if ((action === 'up' || action === 'down') && card > 0 && !saved) return cyclePlayer(card, action === 'up' ? -1 : 1);
|
|
388
|
+
else if (action === 'accept') return startSlot(card);
|
|
389
|
+
else if (action === 'back' || action === 'menu') {
|
|
390
|
+
if (state.from === 'pause') return openPause();
|
|
391
|
+
return askExit();
|
|
392
|
+
} else return;
|
|
393
|
+
render();
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const onPause = (action) => {
|
|
397
|
+
const p = state.pause;
|
|
398
|
+
if (action === 'up') p.focus = (p.focus + p.items.length - 1) % p.items.length;
|
|
399
|
+
else if (action === 'down') p.focus = (p.focus + 1) % p.items.length;
|
|
400
|
+
else if (action === 'accept') return p.items[p.focus].run();
|
|
401
|
+
else if (action === 'back' || action === 'menu') return closePause();
|
|
402
|
+
else return;
|
|
403
|
+
render();
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const onConfirm = (action) => {
|
|
407
|
+
const c = state.confirm;
|
|
408
|
+
if (action === 'left' || action === 'up') c.focus = 0;
|
|
409
|
+
else if (action === 'right' || action === 'down') c.focus = 1;
|
|
410
|
+
else if (action === 'accept') return answer(c.focus === 1);
|
|
411
|
+
else if (action === 'back') return answer(false);
|
|
412
|
+
else return;
|
|
413
|
+
render();
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const off = host.input.on((action) => {
|
|
417
|
+
if (leaving) return;
|
|
418
|
+
const s = state.screen;
|
|
419
|
+
if (s === 'game') {
|
|
420
|
+
if (action === 'menu') openPause();
|
|
421
|
+
} else if (s === 'slots') onSlots(action);
|
|
422
|
+
else if (s === 'pause') onPause(action);
|
|
423
|
+
else if (s === 'confirm') onConfirm(action);
|
|
424
|
+
else if (s === 'error' && (action === 'accept' || action === 'back')) exit();
|
|
425
|
+
else if (s === 'boot' && action === 'back') exit();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// Pointer: hover focuses, click activates — the same paths as the pad. Hover is read
|
|
429
|
+
// from real movement only: every render replaces the buttons, and the browser fires
|
|
430
|
+
// a fresh pointerover for whatever lands under a resting cursor.
|
|
431
|
+
let lastPointer = '';
|
|
432
|
+
const onPointer = (event) => {
|
|
433
|
+
if (event.type === 'pointermove') {
|
|
434
|
+
const at = `${event.clientX},${event.clientY}`;
|
|
435
|
+
if (at === lastPointer) return;
|
|
436
|
+
lastPointer = at;
|
|
437
|
+
}
|
|
438
|
+
const el = event.target.closest('[data-card],[data-item],[data-confirm],[data-exit],[data-pick],[data-focus]');
|
|
439
|
+
if (!el || leaving) return;
|
|
440
|
+
if (event.type === 'pointermove') {
|
|
441
|
+
const card = el.closest('[data-focus]')?.dataset.focus ?? el.dataset.card ?? el.dataset.pick;
|
|
442
|
+
if (card != null && state.screen === 'slots' && state.focus !== +card) {
|
|
443
|
+
state.focus = +card;
|
|
444
|
+
render();
|
|
445
|
+
} else if (el.dataset.item != null && state.screen === 'pause' && state.pause.focus !== +el.dataset.item) {
|
|
446
|
+
state.pause.focus = +el.dataset.item;
|
|
447
|
+
render();
|
|
448
|
+
}
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (el.dataset.pick != null) {
|
|
452
|
+
event.stopPropagation();
|
|
453
|
+
state.focus = +el.dataset.pick;
|
|
454
|
+
return cyclePlayer(+el.dataset.pick, +el.dataset.dir);
|
|
455
|
+
}
|
|
456
|
+
if (el.dataset.card != null) {
|
|
457
|
+
state.focus = +el.dataset.card;
|
|
458
|
+
startSlot(+el.dataset.card);
|
|
459
|
+
} else if (el.dataset.item != null) state.pause.items[+el.dataset.item].run();
|
|
460
|
+
else if (el.dataset.confirm != null) answer(el.dataset.confirm === '1');
|
|
461
|
+
else if (el.dataset.exit != null) exit();
|
|
462
|
+
};
|
|
463
|
+
overlay.addEventListener('click', onPointer);
|
|
464
|
+
overlay.addEventListener('pointermove', onPointer);
|
|
465
|
+
|
|
466
|
+
const handle = {
|
|
467
|
+
async unmount() {
|
|
468
|
+
destroyed = true;
|
|
469
|
+
leaving = true;
|
|
470
|
+
off();
|
|
471
|
+
overlay.removeEventListener('click', onPointer);
|
|
472
|
+
overlay.removeEventListener('pointermove', onPointer);
|
|
473
|
+
try {
|
|
474
|
+
await play?.destroy();
|
|
475
|
+
} catch {
|
|
476
|
+
/* already gone */
|
|
477
|
+
}
|
|
478
|
+
play = null;
|
|
479
|
+
root.remove();
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
// ---- boot -----------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
render();
|
|
486
|
+
host.input.resume();
|
|
487
|
+
try {
|
|
488
|
+
play = await new Rsdkv5SDK()
|
|
489
|
+
.mount(stage)
|
|
490
|
+
.assets(host.assets)
|
|
491
|
+
.storage(host.storage)
|
|
492
|
+
.on('error', ({ detail }) => console.error(`[${ENGINE} app]`, detail))
|
|
493
|
+
.on('saves', () => {
|
|
494
|
+
if (state.screen === 'slots') {
|
|
495
|
+
refreshSlots();
|
|
496
|
+
render();
|
|
497
|
+
}
|
|
498
|
+
})
|
|
499
|
+
.on('exit', () => exit())
|
|
500
|
+
.start();
|
|
501
|
+
if (destroyed) {
|
|
502
|
+
// Unmounted while booting: nothing to show any more.
|
|
503
|
+
await play.destroy().catch(() => {});
|
|
504
|
+
play = null;
|
|
505
|
+
return handle;
|
|
506
|
+
}
|
|
507
|
+
if (!play.saves.available) {
|
|
508
|
+
enterGame(); // an older wasm: the game with its own menus (phase A)
|
|
509
|
+
return handle;
|
|
510
|
+
}
|
|
511
|
+
state.characters = play.saves.characters;
|
|
512
|
+
play.mute(true);
|
|
513
|
+
void askSaveReady();
|
|
514
|
+
refreshSlots();
|
|
515
|
+
// The first slot with a game in it is where most people want to be.
|
|
516
|
+
const firstSaved = state.slots.findIndex((slot) => slot.state !== 'empty');
|
|
517
|
+
state.focus = firstSaved >= 0 ? firstSaved + 1 : Math.max(1, state.slots.findIndex((slot) => slot.state === 'empty') + 1);
|
|
518
|
+
showSlots();
|
|
519
|
+
} catch (error) {
|
|
520
|
+
console.error(`[${ENGINE} app]`, error);
|
|
521
|
+
state.error = error?.message ?? String(error);
|
|
522
|
+
state.screen = 'error';
|
|
523
|
+
render();
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return handle;
|
|
527
|
+
}
|