@wasm-gaming/rsdkv4-wasm 0.2.1 → 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 +8 -5
- package/dist/rsdkv4/rsdkv4.options.d.ts.map +1 -1
- package/dist/rsdkv4/rsdkv4.options.js +1 -1
- package/dist/rsdkv4/rsdkv4.options.js.map +1 -1
- package/dist/rsdkv4/rsdkv4.sdk.d.ts +24 -2
- package/dist/rsdkv4/rsdkv4.sdk.d.ts.map +1 -1
- package/dist/rsdkv4/rsdkv4.sdk.js +104 -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 +9 -6
- package/src/rsdkv4.sdk.ts +107 -26
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
// rsdkv4's own app: the screens a host gets when it mounts this engine instead of
|
|
2
|
+
// driving the SDK itself — craft's load-game screen (NO SAVE + four save slots) and a
|
|
3
|
+
// pause menu, on the wasm-gaming usability scheme (engine-specs UX.md).
|
|
4
|
+
//
|
|
5
|
+
// import * as app from '<engines>/rsdkv4/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
|
+
// Plain JS and DOM on purpose: the app ships inside the engine's package next to the
|
|
14
|
+
// SDK and loads from a static server, so it imports nothing but the SDK.
|
|
15
|
+
|
|
16
|
+
import Rsdkv4SDK from '../rsdkv4.sdk.js';
|
|
17
|
+
|
|
18
|
+
const OWNER = 'app';
|
|
19
|
+
const asset = (path) => new URL(`./assets/${path}`, import.meta.url).href;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Each pack's zones in its own order (GameConfig.bin, regular list). `scene` is the
|
|
23
|
+
* FIRST scene of the zone: a save points at an act, not a zone (Scrap Brain 3 is
|
|
24
|
+
* scene 17). `art` is how many zones have a portrait. Same table as craft's
|
|
25
|
+
* game.service.js.
|
|
26
|
+
*/
|
|
27
|
+
export const STAGES = {
|
|
28
|
+
1: {
|
|
29
|
+
folder: 'stages-sonic1',
|
|
30
|
+
scenes: 19,
|
|
31
|
+
art: 7,
|
|
32
|
+
zones: [
|
|
33
|
+
['GREEN HILL', 0], ['MARBLE', 3], ['SPRING YARD', 6], ['LABYRINTH', 9],
|
|
34
|
+
['STARLIGHT', 12], ['SCRAP BRAIN', 15], ['FINAL', 18],
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
2: {
|
|
38
|
+
folder: 'stages-sonic2',
|
|
39
|
+
scenes: 21,
|
|
40
|
+
art: 10,
|
|
41
|
+
zones: [
|
|
42
|
+
['EMERALD HILL', 0], ['CHEMICAL PLANT', 2], ['AQUATIC RUIN', 4], ['CASINO NIGHT', 6],
|
|
43
|
+
['HILL TOP', 8], ['MYSTIC CAVE', 10], ['OIL OCEAN', 12], ['METROPOLIS', 14],
|
|
44
|
+
['SKY CHASE', 17], ['WING FORTRESS', 18], ['DEATH EGG', 19], ['HIDDEN PALACE', 20],
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The pack's own menu scenes, looked up by name in the live stage list. */
|
|
50
|
+
const PACK_SCREENS = [
|
|
51
|
+
{ list: 0, match: 'LEVEL SELECT', label: 'Level select' },
|
|
52
|
+
{ list: 1, match: 'STAGE MENU', label: 'Stage menu' },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const PLAYER_ART = ['sonic', 'tails', 'knuckles', 'sonictails'];
|
|
56
|
+
const PLAYER_NAMES = ['Sonic', 'Tails', 'Knuckles', 'Sonic y Tails'];
|
|
57
|
+
const GEMS = ['#58e058', '#f0a038', '#f078d8', '#9878f0', '#f0f0f0', '#f07878', '#78c0f8'];
|
|
58
|
+
|
|
59
|
+
/** RSDKv4 stores how many emeralds, not which; an out-of-range value is counted by bits. */
|
|
60
|
+
export const emeraldCount = (value) => {
|
|
61
|
+
if (value >= 0 && value <= 7) return value;
|
|
62
|
+
let bits = 0;
|
|
63
|
+
for (let v = value; v > 0; v >>= 1) bits += v & 1;
|
|
64
|
+
return Math.min(bits, 7);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Zone name, tag and portrait of a save slot, or null for an empty one. */
|
|
68
|
+
export const describeSlot = (slot, type) => {
|
|
69
|
+
if (!slot || slot.empty) return null;
|
|
70
|
+
const pack = STAGES[type] ?? STAGES[1];
|
|
71
|
+
if (slot.list === 3) {
|
|
72
|
+
return { name: 'SPECIAL STAGE', tag: `STAGE ${slot.zone + 1}`, act: null, img: asset(`${pack.folder}/special.png`) };
|
|
73
|
+
}
|
|
74
|
+
if (slot.zone < 0 || slot.zone >= pack.scenes) return { name: 'UNKNOWN ZONE', tag: '', act: null, img: '' };
|
|
75
|
+
const index = pack.zones.findLastIndex(([, scene]) => slot.zone >= scene);
|
|
76
|
+
const [name, scene] = pack.zones[index];
|
|
77
|
+
return {
|
|
78
|
+
name,
|
|
79
|
+
tag: `ZONE ${index + 1}`,
|
|
80
|
+
act: slot.zone - scene + 1,
|
|
81
|
+
img: index < pack.art ? asset(`${pack.folder}/zone-${index + 1}.png`) : '',
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const esc = (text) => String(text ?? '').replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
86
|
+
|
|
87
|
+
let cssLoaded = null;
|
|
88
|
+
const loadCss = () =>
|
|
89
|
+
(cssLoaded ??= new Promise((resolve) => {
|
|
90
|
+
const link = document.createElement('link');
|
|
91
|
+
link.rel = 'stylesheet';
|
|
92
|
+
link.href = new URL('./app.css', import.meta.url).href;
|
|
93
|
+
link.onload = link.onerror = () => resolve();
|
|
94
|
+
document.head.append(link);
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
export async function mount(target, host) {
|
|
98
|
+
await loadCss();
|
|
99
|
+
|
|
100
|
+
const root = document.createElement('div');
|
|
101
|
+
root.className = 'rsdk-app';
|
|
102
|
+
root.style.setProperty('--accent', host.game?.accent ?? '#1d4ed8');
|
|
103
|
+
const stage = document.createElement('div');
|
|
104
|
+
stage.className = 'rsdk-stage';
|
|
105
|
+
const overlay = document.createElement('div');
|
|
106
|
+
overlay.className = 'rsdk-overlay';
|
|
107
|
+
root.append(stage, overlay);
|
|
108
|
+
target.append(root);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* screen: 'boot' | 'slots' | 'game' | 'pause' | 'confirm' | 'error'
|
|
112
|
+
* The slot screen is the top level before playing; from the pause menu it is one
|
|
113
|
+
* level down (Back returns to the pause menu, not out of the game).
|
|
114
|
+
*/
|
|
115
|
+
const state = {
|
|
116
|
+
screen: 'boot',
|
|
117
|
+
status: 'Arrancando el motor…',
|
|
118
|
+
error: null,
|
|
119
|
+
type: 0,
|
|
120
|
+
players: [],
|
|
121
|
+
slots: [],
|
|
122
|
+
focus: 1, // 0 = NO SAVE, 1-4 = slots
|
|
123
|
+
pick: [0, 0, 0, 0, 0], // character per card (NO SAVE + four slots)
|
|
124
|
+
playing: false, // a game has been started at least once
|
|
125
|
+
from: null, // what the slot screen / a confirm returns to
|
|
126
|
+
pause: { items: [], focus: 0 },
|
|
127
|
+
confirm: null, // { title, text, yes, no, onYes, onNo, focus }
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
let play = null;
|
|
131
|
+
let leaving = false;
|
|
132
|
+
let destroyed = false;
|
|
133
|
+
|
|
134
|
+
const canvasFocus = () => stage.querySelector('canvas')?.focus({ preventScroll: true });
|
|
135
|
+
|
|
136
|
+
// ---- screens ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
// craft's slot cards (src/craft/views/GameLauncher.html), kept as they were: a short
|
|
139
|
+
// NO SAVE card, then the four slots — a saved one shows where it stopped, an empty
|
|
140
|
+
// one is the character picker (click, or ↑/↓, cycles it) over PLAY.
|
|
141
|
+
const slotCard = (i) => {
|
|
142
|
+
const focused = state.focus === i ? ' is-focused' : '';
|
|
143
|
+
if (i === 0) {
|
|
144
|
+
return `<div class="slot-card is-nosave${focused}" data-card="0" role="button">NO SAVE</div>`;
|
|
145
|
+
}
|
|
146
|
+
const slot = state.slots[i - 1];
|
|
147
|
+
const info = describeSlot(slot, state.type);
|
|
148
|
+
if (!info) {
|
|
149
|
+
const art = PLAYER_ART[state.pick[i]];
|
|
150
|
+
return `<div class="slot-card is-new${focused}" data-focus="${i}">
|
|
151
|
+
<button type="button" class="player-choose" data-player="${art}" data-pick="${i}" data-dir="1"
|
|
152
|
+
title="${esc(PLAYER_NAMES[state.pick[i]] ?? '')}">
|
|
153
|
+
<img src="${asset(`player-${art}.svg`)}" alt="">
|
|
154
|
+
</button>
|
|
155
|
+
<button type="button" class="slot-play" data-card="${i}">PLAY</button>
|
|
156
|
+
</div>`;
|
|
157
|
+
}
|
|
158
|
+
const gems = emeraldCount(slot.emeralds);
|
|
159
|
+
const char = PLAYER_ART[slot.character] ?? 'sonic';
|
|
160
|
+
return `<div class="slot-card is-saved${focused}" data-focus="${i}">
|
|
161
|
+
<div class="stage-shot">
|
|
162
|
+
<div class="stage-badge">${info.img ? `<img src="${info.img}" alt="${esc(info.name)}">` : ''}</div>
|
|
163
|
+
<div class="zone-tag">${esc(info.tag)}</div>
|
|
164
|
+
<div class="player-icon"><img src="${asset(`player-${char}.svg`)}" alt=""></div>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="zone-title" title="${esc(info.name)}${info.act ? ` · act ${info.act}` : ''}">${esc(info.name)}</div>
|
|
167
|
+
<div class="emeralds" title="${gems} of 7 chaos emeralds">${GEMS.map((c, g) => `<i style="--gem:${c}"${g < gems ? ' class="on"' : ''}></i>`).join('')}</div>
|
|
168
|
+
<div class="stats-grid">
|
|
169
|
+
<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>
|
|
170
|
+
<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>
|
|
171
|
+
</div>
|
|
172
|
+
<button type="button" class="slot-play" data-card="${i}">CONTINUE</button>
|
|
173
|
+
</div>`;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const hints = (pairs) =>
|
|
177
|
+
`<footer class="rsdk-hints">${pairs.map(([k, t]) => `<span><kbd>${k}</kbd>${t}</span>`).join('')}</footer>`;
|
|
178
|
+
|
|
179
|
+
const render = () => {
|
|
180
|
+
if (destroyed) return;
|
|
181
|
+
root.dataset.screen = state.screen;
|
|
182
|
+
const s = state.screen;
|
|
183
|
+
if (s === 'game') {
|
|
184
|
+
overlay.innerHTML = '';
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (s === 'boot') {
|
|
188
|
+
overlay.innerHTML = `<section class="rsdk-boot">
|
|
189
|
+
${host.game?.art ? `<img src="${esc(host.game.art)}" alt="">` : ''}
|
|
190
|
+
<p>${esc(state.status)}</p>
|
|
191
|
+
</section>`;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (s === 'error') {
|
|
195
|
+
overlay.innerHTML = `<section class="rsdk-panel">
|
|
196
|
+
<p class="kicker">${esc(host.game?.title ?? 'RSDKv4')}</p>
|
|
197
|
+
<h2>No se pudo arrancar</h2>
|
|
198
|
+
<p class="text">${esc(state.error)}</p>
|
|
199
|
+
<button class="item is-focused" data-exit>Volver</button>
|
|
200
|
+
</section>`;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const base = s === 'confirm' ? state.confirm.under : s;
|
|
204
|
+
let html = '';
|
|
205
|
+
if (base === 'slots') {
|
|
206
|
+
const selected = state.focus;
|
|
207
|
+
const slot = selected > 0 ? state.slots[selected - 1] : null;
|
|
208
|
+
const saved = slot && !slot.empty;
|
|
209
|
+
html += `<section class="rsdk-slots">
|
|
210
|
+
<header><p class="kicker">${esc(host.game?.title ?? '')}</p><h2>Elige partida</h2></header>
|
|
211
|
+
<div class="slot-cards">${[0, 1, 2, 3, 4].map(slotCard).join('')}</div>
|
|
212
|
+
${hints([
|
|
213
|
+
['←→', 'partida'],
|
|
214
|
+
...(saved ? [] : [['↑↓', 'personaje']]),
|
|
215
|
+
['A', saved ? 'continuar' : 'jugar'],
|
|
216
|
+
...(saved ? [['Y', 'borrar']] : []),
|
|
217
|
+
['B', state.playing ? 'volver' : 'salir'],
|
|
218
|
+
])}
|
|
219
|
+
</section>`;
|
|
220
|
+
} else if (base === 'pause') {
|
|
221
|
+
html += `<section class="rsdk-panel">
|
|
222
|
+
<p class="kicker">${esc(host.game?.title ?? '')}</p>
|
|
223
|
+
<h2>Pausa</h2>
|
|
224
|
+
${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('')}
|
|
225
|
+
${hints([['A', 'elegir'], ['B', 'continuar']])}
|
|
226
|
+
</section>`;
|
|
227
|
+
}
|
|
228
|
+
if (s === 'confirm') {
|
|
229
|
+
const c = state.confirm;
|
|
230
|
+
html += `<div class="rsdk-confirm"><section class="rsdk-panel">
|
|
231
|
+
<h2>${esc(c.title)}</h2>
|
|
232
|
+
${c.text ? `<p class="text">${esc(c.text)}</p>` : ''}
|
|
233
|
+
<div class="row">
|
|
234
|
+
<button class="item${c.focus === 0 ? ' is-focused' : ''}" data-confirm="0">${esc(c.no)}</button>
|
|
235
|
+
<button class="item is-danger${c.focus === 1 ? ' is-focused' : ''}" data-confirm="1">${esc(c.yes)}</button>
|
|
236
|
+
</div>
|
|
237
|
+
</section></div>`;
|
|
238
|
+
}
|
|
239
|
+
overlay.innerHTML = html;
|
|
240
|
+
// On a narrow screen the cards scroll sideways: keep the focused one in view
|
|
241
|
+
// (by hand — scrollIntoView would also scroll the host's page).
|
|
242
|
+
const cards = overlay.querySelector('.slot-cards');
|
|
243
|
+
const focused = cards?.querySelector('.slot-card.is-focused');
|
|
244
|
+
if (cards && focused && cards.scrollWidth > cards.clientWidth) {
|
|
245
|
+
cards.scrollLeft = focused.offsetLeft - (cards.clientWidth - focused.offsetWidth) / 2;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// ---- transitions --------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
const refreshSlots = (slots) => {
|
|
252
|
+
try {
|
|
253
|
+
state.slots = slots ?? play?.game.saveSlots() ?? [];
|
|
254
|
+
} catch {
|
|
255
|
+
state.slots = [];
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const showSlots = (from = null) => {
|
|
260
|
+
refreshSlots();
|
|
261
|
+
state.from = from;
|
|
262
|
+
state.screen = 'slots';
|
|
263
|
+
play?.pause(OWNER);
|
|
264
|
+
host.input.resume();
|
|
265
|
+
render();
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const startGame = () => {
|
|
269
|
+
const card = state.focus;
|
|
270
|
+
const slot = card === 0 ? null : card - 1;
|
|
271
|
+
const saved = slot !== null && state.slots[slot] && !state.slots[slot].empty;
|
|
272
|
+
const player = saved ? state.slots[slot].character : state.pick[card];
|
|
273
|
+
state.screen = 'game';
|
|
274
|
+
state.playing = true;
|
|
275
|
+
render();
|
|
276
|
+
host.input.suspend();
|
|
277
|
+
play.game.start(slot, player); // also lifts the pause
|
|
278
|
+
canvasFocus();
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const packScreens = () => {
|
|
282
|
+
try {
|
|
283
|
+
const lists = play.devMenu.getStageList();
|
|
284
|
+
return PACK_SCREENS.map((screen) => {
|
|
285
|
+
const scene = (lists[screen.list]?.stages ?? []).findIndex(({ name }) => name?.trim() === screen.match);
|
|
286
|
+
return scene < 0 ? null : { ...screen, scene };
|
|
287
|
+
}).filter(Boolean);
|
|
288
|
+
} catch {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const openPause = () => {
|
|
294
|
+
play?.pause(OWNER);
|
|
295
|
+
state.pause = {
|
|
296
|
+
focus: 0,
|
|
297
|
+
items: [
|
|
298
|
+
{ label: 'Continuar', run: closePause },
|
|
299
|
+
{ label: 'Cambiar de partida', hint: 'vuelve a las partidas guardadas', run: () => showSlots('pause') },
|
|
300
|
+
...packScreens().map((screen) => ({
|
|
301
|
+
label: screen.label,
|
|
302
|
+
hint: 'pantalla del propio juego',
|
|
303
|
+
run: () => {
|
|
304
|
+
closePause();
|
|
305
|
+
play.devMenu.loadStage(screen.list, screen.scene);
|
|
306
|
+
},
|
|
307
|
+
})),
|
|
308
|
+
{ label: 'Volver al lanzador', run: askExit },
|
|
309
|
+
],
|
|
310
|
+
};
|
|
311
|
+
state.screen = 'pause';
|
|
312
|
+
host.input.resume();
|
|
313
|
+
render();
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const closePause = () => {
|
|
317
|
+
state.screen = 'game';
|
|
318
|
+
render();
|
|
319
|
+
host.input.suspend();
|
|
320
|
+
play?.resume(OWNER);
|
|
321
|
+
canvasFocus();
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// A confirm sits on top of the screen it was asked from and goes back to it;
|
|
325
|
+
// `state.from` (where the slot screen returns to) is left alone.
|
|
326
|
+
const ask = (options) => {
|
|
327
|
+
state.confirm = { focus: 0, under: state.screen, ...options };
|
|
328
|
+
state.screen = 'confirm';
|
|
329
|
+
render();
|
|
330
|
+
};
|
|
331
|
+
const answer = (yes) => {
|
|
332
|
+
const c = state.confirm;
|
|
333
|
+
state.screen = c.under;
|
|
334
|
+
state.confirm = null;
|
|
335
|
+
render();
|
|
336
|
+
(yes ? c.onYes : c.onNo)?.();
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const exit = async () => {
|
|
340
|
+
if (leaving) return;
|
|
341
|
+
leaving = true;
|
|
342
|
+
try {
|
|
343
|
+
await play?.destroy();
|
|
344
|
+
} catch (error) {
|
|
345
|
+
console.warn('[rsdkv4 app] destroy failed', error);
|
|
346
|
+
}
|
|
347
|
+
play = null;
|
|
348
|
+
host.exit();
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const askExit = () =>
|
|
352
|
+
ask({ title: '¿Volver al lanzador?', text: 'La partida se guarda donde la guarde el juego.', yes: 'Salir', no: 'Seguir', onYes: exit });
|
|
353
|
+
|
|
354
|
+
const askDelete = (slot) =>
|
|
355
|
+
ask({
|
|
356
|
+
title: `¿Borrar la partida ${slot + 1}?`,
|
|
357
|
+
text: 'No se puede deshacer.',
|
|
358
|
+
yes: 'Borrar',
|
|
359
|
+
no: 'Cancelar',
|
|
360
|
+
onYes: () => {
|
|
361
|
+
play.game.deleteSave(slot);
|
|
362
|
+
refreshSlots();
|
|
363
|
+
render();
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const cyclePlayer = (card, dir) => {
|
|
368
|
+
const count = Math.max(1, Math.min(PLAYER_ART.length, state.players.length || 1));
|
|
369
|
+
state.pick[card] = (state.pick[card] + dir + count) % count;
|
|
370
|
+
render();
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// ---- input ----------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
const onSlots = (action) => {
|
|
376
|
+
const card = state.focus;
|
|
377
|
+
const slot = card > 0 ? state.slots[card - 1] : null;
|
|
378
|
+
const saved = slot && !slot.empty;
|
|
379
|
+
if (action === 'left' || action === 'prev') state.focus = Math.max(0, card - 1);
|
|
380
|
+
else if (action === 'right' || action === 'next') state.focus = Math.min(4, card + 1);
|
|
381
|
+
else if ((action === 'up' || action === 'down') && !saved) return cyclePlayer(card, action === 'up' ? -1 : 1);
|
|
382
|
+
else if (action === 'accept') return startGame();
|
|
383
|
+
else if (action === 'more' && saved) return askDelete(card - 1);
|
|
384
|
+
else if (action === 'back' || action === 'menu') {
|
|
385
|
+
if (state.from === 'pause') return openPause();
|
|
386
|
+
return askExit();
|
|
387
|
+
} else return;
|
|
388
|
+
render();
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const onPause = (action) => {
|
|
392
|
+
const p = state.pause;
|
|
393
|
+
if (action === 'up') p.focus = (p.focus + p.items.length - 1) % p.items.length;
|
|
394
|
+
else if (action === 'down') p.focus = (p.focus + 1) % p.items.length;
|
|
395
|
+
else if (action === 'accept') return p.items[p.focus].run();
|
|
396
|
+
else if (action === 'back' || action === 'menu') return closePause();
|
|
397
|
+
else return;
|
|
398
|
+
render();
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const onConfirm = (action) => {
|
|
402
|
+
const c = state.confirm;
|
|
403
|
+
if (action === 'left' || action === 'up') c.focus = 0;
|
|
404
|
+
else if (action === 'right' || action === 'down') c.focus = 1;
|
|
405
|
+
else if (action === 'accept') return answer(c.focus === 1);
|
|
406
|
+
else if (action === 'back') return answer(false);
|
|
407
|
+
else return;
|
|
408
|
+
render();
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const off = host.input.on((action) => {
|
|
412
|
+
if (leaving) return;
|
|
413
|
+
const s = state.screen;
|
|
414
|
+
if (s === 'game') {
|
|
415
|
+
if (action === 'menu') openPause();
|
|
416
|
+
} else if (s === 'slots') onSlots(action);
|
|
417
|
+
else if (s === 'pause') onPause(action);
|
|
418
|
+
else if (s === 'confirm') onConfirm(action);
|
|
419
|
+
else if (s === 'error' && (action === 'accept' || action === 'back')) exit();
|
|
420
|
+
else if (s === 'boot' && action === 'back') exit();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Pointer: hover focuses, click activates — the same paths as the pad. Hover is read
|
|
424
|
+
// from real movement only: every render replaces the cards, and the browser fires a
|
|
425
|
+
// fresh pointerover for whatever lands under a resting cursor, which would steal
|
|
426
|
+
// the focus back from the pad on every key.
|
|
427
|
+
let lastPointer = '';
|
|
428
|
+
const onPointer = (event) => {
|
|
429
|
+
if (event.type === 'pointermove') {
|
|
430
|
+
const at = `${event.clientX},${event.clientY}`;
|
|
431
|
+
if (at === lastPointer) return;
|
|
432
|
+
lastPointer = at;
|
|
433
|
+
}
|
|
434
|
+
const el = event.target.closest('[data-card],[data-item],[data-confirm],[data-exit],[data-pick],[data-focus]');
|
|
435
|
+
if (!el || leaving) return;
|
|
436
|
+
if (event.type === 'pointermove') {
|
|
437
|
+
const card = el.closest('[data-focus]')?.dataset.focus ?? el.dataset.card ?? el.dataset.pick;
|
|
438
|
+
if (card != null && state.screen === 'slots' && state.focus !== +card) {
|
|
439
|
+
state.focus = +card;
|
|
440
|
+
render();
|
|
441
|
+
} else if (el.dataset.item != null && state.pause.focus !== +el.dataset.item) {
|
|
442
|
+
state.pause.focus = +el.dataset.item;
|
|
443
|
+
render();
|
|
444
|
+
}
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (el.dataset.pick != null) {
|
|
448
|
+
event.stopPropagation();
|
|
449
|
+
state.focus = +el.dataset.pick;
|
|
450
|
+
return cyclePlayer(+el.dataset.pick, +el.dataset.dir);
|
|
451
|
+
}
|
|
452
|
+
if (el.dataset.card != null) {
|
|
453
|
+
state.focus = +el.dataset.card;
|
|
454
|
+
startGame();
|
|
455
|
+
} else if (el.dataset.item != null) state.pause.items[+el.dataset.item].run();
|
|
456
|
+
else if (el.dataset.confirm != null) answer(el.dataset.confirm === '1');
|
|
457
|
+
else if (el.dataset.exit != null) exit();
|
|
458
|
+
};
|
|
459
|
+
overlay.addEventListener('click', onPointer);
|
|
460
|
+
overlay.addEventListener('pointermove', onPointer);
|
|
461
|
+
|
|
462
|
+
const handle = {
|
|
463
|
+
async unmount() {
|
|
464
|
+
destroyed = true;
|
|
465
|
+
leaving = true;
|
|
466
|
+
off();
|
|
467
|
+
overlay.removeEventListener('click', onPointer);
|
|
468
|
+
overlay.removeEventListener('pointermove', onPointer);
|
|
469
|
+
try {
|
|
470
|
+
await play?.destroy();
|
|
471
|
+
} catch {
|
|
472
|
+
/* already gone */
|
|
473
|
+
}
|
|
474
|
+
play = null;
|
|
475
|
+
root.remove();
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
// ---- boot -----------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
render();
|
|
482
|
+
host.input.resume();
|
|
483
|
+
try {
|
|
484
|
+
play = await new Rsdkv4SDK()
|
|
485
|
+
.mount(stage)
|
|
486
|
+
.assets(host.assets)
|
|
487
|
+
.storage(host.storage)
|
|
488
|
+
.config({ startMenu: 'host' })
|
|
489
|
+
.on('saves', ({ detail }) => {
|
|
490
|
+
refreshSlots(detail);
|
|
491
|
+
if (state.screen === 'slots' || state.from === 'slots') render();
|
|
492
|
+
})
|
|
493
|
+
.on('error', ({ detail }) => console.error('[rsdkv4 app]', detail))
|
|
494
|
+
.on('exit', () => exit())
|
|
495
|
+
.start();
|
|
496
|
+
if (destroyed) {
|
|
497
|
+
// Unmounted while booting: nothing to show any more.
|
|
498
|
+
await play.destroy().catch(() => {});
|
|
499
|
+
play = null;
|
|
500
|
+
return handle;
|
|
501
|
+
}
|
|
502
|
+
state.type = play.game.type();
|
|
503
|
+
try {
|
|
504
|
+
state.players = play.game.players();
|
|
505
|
+
} catch {
|
|
506
|
+
state.players = [];
|
|
507
|
+
}
|
|
508
|
+
// The first slot with a game in it is where most people want to be.
|
|
509
|
+
refreshSlots();
|
|
510
|
+
const firstSaved = state.slots.findIndex((slot) => slot && !slot.empty);
|
|
511
|
+
state.focus = firstSaved >= 0 ? firstSaved + 1 : state.slots.findIndex((slot) => slot?.empty) + 1 || 1;
|
|
512
|
+
showSlots();
|
|
513
|
+
} catch (error) {
|
|
514
|
+
console.error('[rsdkv4 app]', error);
|
|
515
|
+
state.error = error?.message ?? String(error);
|
|
516
|
+
state.screen = 'error';
|
|
517
|
+
render();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return handle;
|
|
521
|
+
}
|