@wasm-gaming/fbneo-wasm 0.1.2 → 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.
Files changed (59) hide show
  1. package/README.md +112 -69
  2. package/dist/fbneo/app/app.css +112 -0
  3. package/dist/fbneo/app/app.js +646 -0
  4. package/dist/fbneo/engine-specs.js +646 -0
  5. package/dist/fbneo/fbneo.js +2 -0
  6. package/dist/fbneo/fbneo.options.d.ts +72 -19
  7. package/dist/fbneo/fbneo.options.d.ts.map +1 -1
  8. package/dist/fbneo/fbneo.options.js +49 -48
  9. package/dist/fbneo/fbneo.options.js.map +1 -1
  10. package/dist/fbneo/fbneo.sdk.d.ts +157 -8
  11. package/dist/fbneo/fbneo.sdk.d.ts.map +1 -1
  12. package/dist/fbneo/fbneo.sdk.js +641 -181
  13. package/dist/fbneo/fbneo.sdk.js.map +1 -1
  14. package/dist/fbneo/fbneo.wasm +4 -0
  15. package/dist/fbneo/romset.d.ts +0 -0
  16. package/dist/fbneo/romset.d.ts.map +1 -1
  17. package/dist/fbneo/romset.js.map +1 -1
  18. package/{data → dist/fbneo}/romsets/atari.json +5036 -118
  19. package/{data → dist/fbneo}/romsets/capcom.json +3392 -848
  20. package/{data → dist/fbneo}/romsets/cave.json +460 -115
  21. package/{data → dist/fbneo}/romsets/channelf.json +200 -50
  22. package/{data → dist/fbneo}/romsets/coleco.json +2650 -658
  23. package/{data → dist/fbneo}/romsets/cps3.json +240 -60
  24. package/{data → dist/fbneo}/romsets/dataeast.json +1352 -338
  25. package/{data → dist/fbneo}/romsets/galaxian.json +1192 -298
  26. package/dist/fbneo/romsets/gba.json +197 -0
  27. package/{data → dist/fbneo}/romsets/index.json +16 -12
  28. package/{data → dist/fbneo}/romsets/irem.json +796 -199
  29. package/{data → dist/fbneo}/romsets/konami.json +1644 -411
  30. package/{data → dist/fbneo}/romsets/megadrive.json +11899 -2926
  31. package/{data → dist/fbneo}/romsets/midway.json +560 -140
  32. package/{data → dist/fbneo}/romsets/msx.json +7204 -1798
  33. package/{data → dist/fbneo}/romsets/neogeo.json +2704 -614
  34. package/{data → dist/fbneo}/romsets/nes.json +12815 -3155
  35. package/{data → dist/fbneo}/romsets/pce.json +1952 -488
  36. package/{data → dist/fbneo}/romsets/pgm.json +796 -199
  37. package/{data → dist/fbneo}/romsets/pgm2.json +32 -8
  38. package/{data → dist/fbneo}/romsets/pre90s.json +10002 -2084
  39. package/{data → dist/fbneo}/romsets/psikyo.json +160 -40
  40. package/{data → dist/fbneo}/romsets/pst90s.json +6400 -1597
  41. package/{data → dist/fbneo}/romsets/sega.json +2756 -658
  42. package/{data → dist/fbneo}/romsets/sg1000.json +1032 -255
  43. package/{data → dist/fbneo}/romsets/sms.json +6012 -1503
  44. package/{data → dist/fbneo}/romsets/snes.json +10991 -2816
  45. package/{data → dist/fbneo}/romsets/spectrum.json +13579 -3361
  46. package/{data → dist/fbneo}/romsets/taito.json +2496 -624
  47. package/{data → dist/fbneo}/romsets/toaplan.json +576 -144
  48. package/package.json +10 -15
  49. package/src/app/app.css +112 -0
  50. package/src/app/app.js +646 -0
  51. package/src/fbneo.options.ts +119 -63
  52. package/src/fbneo.sdk.ts +712 -211
  53. package/src/romset.ts +0 -0
  54. package/dist/fbneo/fbneo.manifest.d.ts +0 -4
  55. package/dist/fbneo/fbneo.manifest.d.ts.map +0 -1
  56. package/dist/fbneo/fbneo.manifest.js +0 -33
  57. package/dist/fbneo/fbneo.manifest.js.map +0 -1
  58. package/dist/manifest.json +0 -91
  59. package/src/fbneo.manifest.ts +0 -38
@@ -0,0 +1,646 @@
1
+ // FBNeo's own app: the game plus suspend points the way the Nintendo Classic Mini
2
+ // does them, on the wasm-gaming usability scheme (engine-specs UX.md). The same app
3
+ // as blastem-wasm's, on FBNeo's SDK.
4
+ //
5
+ // import * as app from '<engines>/fbneo/app/app.js'
6
+ // const handle = await app.mount(target, host) // EngineApp.mount
7
+ //
8
+ // - Leaving writes the *unsaved* suspend point (with its frame): nothing is lost.
9
+ // - Coming back with any suspend point shows them: the unsaved one, four slots, and
10
+ // "from the beginning". Y saves the unsaved point into a slot; Y on a saved one
11
+ // locks / unlocks or deletes it.
12
+ // - In game, Select+Start or Escape opens the pause menu.
13
+ //
14
+ // Which game: FBNeo boots a ROM set by its driver name (`mslug`), and the host hands
15
+ // over bytes, not a name. The app recovers the driver from the CRC-32s inside the zip
16
+ // (the `romset` helper and the datasets in ../romsets/), rebuilds a `.neo` image into
17
+ // a ROM set, and falls back to the host's game slug.
18
+ //
19
+ // Plain JS and DOM: it ships next to the SDK and imports nothing else.
20
+
21
+ import FbneoSDK, { AUTO_STATE_SLOT, STATE_SLOTS } from '../fbneo.sdk.js';
22
+ import { RomsetIndex, resolveDriver } from '../romset.js';
23
+ import { isNeoFile, neoToRomset } from '../neo.js';
24
+
25
+ const OWNER = 'app';
26
+
27
+ /** The systems searched first: most arcade sets a player brings are one of these. */
28
+ export const FIRST_SYSTEMS = ['neogeo', 'capcom', 'cps3', 'cave', 'konami', 'sega'];
29
+
30
+ const romsetsUrl = new URL('../romsets/', import.meta.url);
31
+ const fetchSystem = async (system) => {
32
+ const response = await fetch(new URL(`${system}.json`, romsetsUrl));
33
+ if (!response.ok) throw new Error(`fbneo app: no dataset for ${system}`);
34
+ return response.json();
35
+ };
36
+ const fetchSystems = async () => {
37
+ const response = await fetch(new URL('index.json', romsetsUrl));
38
+ if (!response.ok) return FIRST_SYSTEMS;
39
+ const { systems } = await response.json();
40
+ const all = systems.map((entry) => entry.system);
41
+ return [...FIRST_SYSTEMS.filter((s) => all.includes(s)), ...all.filter((s) => !FIRST_SYSTEMS.includes(s))];
42
+ };
43
+
44
+ /**
45
+ * The driver for a ROM the host handed over, and the bytes to boot.
46
+ *
47
+ * `.neo` images are rebuilt into a ROM set (Neo Geo dataset). Zips are matched by
48
+ * content, system by system, stopping at the first match; nothing matching leaves
49
+ * the host's slug. `load(system)` and `systems()` are injectable for tests.
50
+ */
51
+ export async function identifyDriver(bytes, { slug = '', load = fetchSystem, systems = fetchSystems } = {}) {
52
+ if (isNeoFile(bytes)) {
53
+ const { driver, zip } = neoToRomset(bytes, await load('neogeo'));
54
+ return { rom: zip, driver, how: 'neo' };
55
+ }
56
+ let names = FIRST_SYSTEMS;
57
+ try {
58
+ names = await systems();
59
+ } catch {
60
+ /* the first ones, then */
61
+ }
62
+ for (const system of names) {
63
+ let data;
64
+ try {
65
+ data = await load(system);
66
+ } catch {
67
+ continue;
68
+ }
69
+ const match = resolveDriver(bytes, new RomsetIndex([data]));
70
+ if (match) return { rom: bytes, driver: match.driver, how: 'content' };
71
+ }
72
+ return { rom: bytes, driver: String(slug).trim(), how: 'slug' };
73
+ }
74
+ const esc = (text) => String(text ?? '').replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
75
+
76
+ let cssLoaded = null;
77
+ const loadCss = () =>
78
+ (cssLoaded ??= new Promise((resolve) => {
79
+ const link = document.createElement('link');
80
+ link.rel = 'stylesheet';
81
+ link.href = new URL('./app.css', import.meta.url).href;
82
+ link.onload = link.onerror = () => resolve();
83
+ document.head.append(link);
84
+ }));
85
+
86
+ const when = (date) => {
87
+ if (!date) return '';
88
+ const now = new Date();
89
+ const time = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
90
+ if (date.toDateString() === now.toDateString()) return `Hoy · ${time}`;
91
+ const yesterday = new Date(now);
92
+ yesterday.setDate(now.getDate() - 1);
93
+ if (date.toDateString() === yesterday.toDateString()) return `Ayer · ${time}`;
94
+ return `${date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' })} · ${time}`;
95
+ };
96
+
97
+ const LOCK = '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M4.5 7V5a3.5 3.5 0 0 1 7 0v2h.5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1zm1.6 0h3.8V5a1.9 1.9 0 0 0-3.8 0z"/></svg>';
98
+ const RESTART = '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" d="M4 12a8 8 0 1 0 2.4-5.7M4 4v4.5h4.5"/></svg>';
99
+
100
+ export async function mount(target, host) {
101
+ await loadCss();
102
+
103
+ const root = document.createElement('div');
104
+ root.className = 'fbn-app';
105
+ root.style.setProperty('--accent', host.game?.accent ?? '#9f1239');
106
+ const stage = document.createElement('div');
107
+ stage.className = 'fbn-stage';
108
+ const overlay = document.createElement('div');
109
+ overlay.className = 'fbn-overlay';
110
+ root.append(stage, overlay);
111
+ target.append(root);
112
+
113
+ /**
114
+ * screen: boot | points | game | pause | sheet | confirm | error
115
+ * `points.picking`: choosing the slot the unsaved point goes to.
116
+ * `points.from`: 'boot' (top level) or 'pause' (one level down).
117
+ */
118
+ const state = {
119
+ screen: 'boot',
120
+ status: 'Arrancando el motor…',
121
+ error: null,
122
+ cards: [], // [{ kind: 'start' | 'auto' | 'slot', slot, info }]
123
+ focus: 0,
124
+ from: 'boot',
125
+ picking: false,
126
+ pause: { items: [], focus: 0 },
127
+ sheet: null, // { slot, items, focus }
128
+ confirm: null, // { title, text, yes, no, onYes, under, focus }
129
+ toast: null,
130
+ started: false, // the game has run since boot (so "from the beginning" is a reset)
131
+ };
132
+
133
+ let play = null;
134
+ let leaving = false;
135
+ let destroyed = false;
136
+ let frame = null; // the frame captured as the pause menu opened
137
+ const urls = new Set();
138
+ const blobUrl = (blob) => {
139
+ if (!blob) return '';
140
+ const url = URL.createObjectURL(blob);
141
+ urls.add(url);
142
+ return url;
143
+ };
144
+ const dropUrls = () => {
145
+ for (const url of urls) URL.revokeObjectURL(url);
146
+ urls.clear();
147
+ };
148
+ const canvasFocus = () => stage.querySelector('canvas')?.focus({ preventScroll: true });
149
+
150
+ // ---- data -----------------------------------------------------------------------
151
+
152
+ const readPoints = () => {
153
+ dropUrls();
154
+ const auto = play.states.get(AUTO_STATE_SLOT);
155
+ const slots = play.states.list();
156
+ state.cards = [
157
+ { kind: 'start' },
158
+ ...(auto.savedAt ? [{ kind: 'auto', slot: AUTO_STATE_SLOT, info: auto, img: blobUrl(auto.thumbnail) }] : []),
159
+ ...slots.map((info) => ({ kind: 'slot', slot: info.slot, info, img: blobUrl(info.thumbnail) })),
160
+ ];
161
+ };
162
+ const hasPoints = () => state.cards.some((card) => card.kind !== 'start' && card.info.savedAt);
163
+ const cardAt = (i) => state.cards[i];
164
+
165
+ // ---- render ---------------------------------------------------------------------
166
+
167
+ const hints = (pairs) =>
168
+ `<footer class="fbn-hints">${pairs.map(([k, t]) => `<span><kbd>${k}</kbd>${t}</span>`).join('')}</footer>`;
169
+
170
+ const pointCard = (card, i) => {
171
+ const focused = state.focus === i ? ' is-focused' : '';
172
+ const target = state.picking && card.kind === 'slot' && !card.info.locked ? ' is-target' : '';
173
+ const dim = state.picking && !(card.kind === 'slot' && !card.info.locked) ? ' is-dim' : '';
174
+ if (card.kind === 'start') {
175
+ return `<button class="point is-start${focused}${dim}" data-card="${i}">
176
+ <span class="point-frame">${RESTART}</span>
177
+ <span class="point-label">Desde el principio</span>
178
+ <span class="point-date">&nbsp;</span>
179
+ </button>`;
180
+ }
181
+ const { info } = card;
182
+ const empty = !info.savedAt;
183
+ const label = card.kind === 'auto' ? 'Sin guardar' : `${card.slot + 1}`;
184
+ return `<button class="point${card.kind === 'auto' ? ' is-auto' : ''}${empty ? ' is-empty' : ''}${info.locked ? ' is-locked' : ''}${focused}${target}${dim}" data-card="${i}">
185
+ <span class="point-frame">${card.img ? `<img src="${card.img}" alt="">` : ''}${info.locked ? `<i class="point-lock">${LOCK}</i>` : ''}</span>
186
+ <span class="point-label">${esc(label)}</span>
187
+ <span class="point-date">${empty ? 'Vacío' : esc(when(info.savedAt))}</span>
188
+ </button>`;
189
+ };
190
+
191
+ const pointsHints = () => {
192
+ if (state.picking) return hints([['←→', 'hueco'], ['A', 'guardar aquí'], ['B', 'cancelar']]);
193
+ const card = cardAt(state.focus);
194
+ const pairs = [['←→', 'elegir']];
195
+ if (card?.kind === 'start' || card?.info?.savedAt) pairs.push(['A', 'jugar']);
196
+ if (card?.kind === 'auto') pairs.push(['Y', 'guardar en un hueco']);
197
+ if (card?.kind === 'slot' && card.info.savedAt) pairs.push(['Y', 'opciones']);
198
+ pairs.push(['B', state.from === 'pause' ? 'volver' : 'salir']);
199
+ return hints(pairs);
200
+ };
201
+
202
+ const render = () => {
203
+ if (destroyed) return;
204
+ const s = state.screen;
205
+ const base = s === 'confirm' || s === 'sheet' ? (state.confirm?.under ?? state.sheet?.under) : s;
206
+ root.dataset.screen = base;
207
+ if (s === 'game') {
208
+ overlay.innerHTML = state.toast ? `<p class="fbn-toast">${esc(state.toast)}</p>` : '';
209
+ return;
210
+ }
211
+ if (s === 'boot') {
212
+ overlay.innerHTML = `<section class="fbn-boot">
213
+ ${host.game?.art ? `<img src="${esc(host.game.art)}" alt="">` : ''}
214
+ <p>${esc(state.status)}</p>
215
+ </section>`;
216
+ return;
217
+ }
218
+ if (s === 'error') {
219
+ overlay.innerHTML = `<section class="fbn-panel">
220
+ <p class="kicker">${esc(host.game?.title ?? 'FBNeo')}</p>
221
+ <h2>No se pudo arrancar</h2>
222
+ <p class="text">${esc(state.error)}</p>
223
+ <button class="item is-focused" data-exit>Volver</button>
224
+ </section>`;
225
+ return;
226
+ }
227
+ let html = '';
228
+ if (base === 'points') {
229
+ html += `<section class="fbn-points">
230
+ <header>
231
+ <p class="kicker">${esc(host.game?.title ?? '')}</p>
232
+ <h2>${state.picking ? '¿En qué hueco lo guardas?' : 'Puntos de suspensión'}</h2>
233
+ </header>
234
+ <div class="points">${state.cards.map(pointCard).join('')}</div>
235
+ ${pointsHints()}
236
+ </section>`;
237
+ } else if (base === 'pause') {
238
+ html += `<section class="fbn-panel">
239
+ <p class="kicker">${esc(host.game?.title ?? '')}</p>
240
+ <h2>Pausa</h2>
241
+ ${state.pause.items.map((item, i) => `<button class="item${i === state.pause.focus && s === 'pause' ? ' is-focused' : ''}" data-item="${i}">${esc(item.label)}${item.hint ? `<small>${esc(item.hint)}</small>` : ''}</button>`).join('')}
242
+ ${hints([['A', 'elegir'], ['B', 'continuar']])}
243
+ </section>`;
244
+ }
245
+ if (s === 'sheet') {
246
+ const sh = state.sheet;
247
+ html += `<div class="fbn-layer"><section class="fbn-panel is-small">
248
+ <h2>Punto ${sh.slot + 1}</h2>
249
+ ${sh.items.map((item, i) => `<button class="item${item.danger ? ' is-danger' : ''}${i === sh.focus ? ' is-focused' : ''}" data-sheet="${i}" ${item.disabled ? 'disabled' : ''}>${esc(item.label)}${item.hint ? `<small>${esc(item.hint)}</small>` : ''}</button>`).join('')}
250
+ </section></div>`;
251
+ }
252
+ if (s === 'confirm') {
253
+ const c = state.confirm;
254
+ html += `<div class="fbn-layer"><section class="fbn-panel is-small">
255
+ <h2>${esc(c.title)}</h2>
256
+ ${c.text ? `<p class="text">${esc(c.text)}</p>` : ''}
257
+ <div class="row">
258
+ <button class="item${c.focus === 0 ? ' is-focused' : ''}" data-confirm="0">${esc(c.no)}</button>
259
+ <button class="item is-danger${c.focus === 1 ? ' is-focused' : ''}" data-confirm="1">${esc(c.yes)}</button>
260
+ </div>
261
+ </section></div>`;
262
+ }
263
+ if (state.toast) html += `<p class="fbn-toast">${esc(state.toast)}</p>`;
264
+ overlay.innerHTML = html;
265
+ const row = overlay.querySelector('.points');
266
+ const focused = row?.querySelector('.point.is-focused');
267
+ if (row && focused && row.scrollWidth > row.clientWidth) {
268
+ row.scrollLeft = focused.offsetLeft - (row.clientWidth - focused.offsetWidth) / 2;
269
+ }
270
+ };
271
+
272
+ let toastTimer = null;
273
+ const toast = (text) => {
274
+ state.toast = text;
275
+ render();
276
+ clearTimeout(toastTimer);
277
+ toastTimer = setTimeout(() => {
278
+ state.toast = null;
279
+ render();
280
+ }, 2600);
281
+ };
282
+
283
+ // ---- transitions ----------------------------------------------------------------
284
+
285
+ const enterGame = () => {
286
+ state.screen = 'game';
287
+ state.picking = false;
288
+ state.started = true;
289
+ render();
290
+ host.input.suspend();
291
+ play.resume(OWNER);
292
+ canvasFocus();
293
+ };
294
+
295
+ const showPoints = (from) => {
296
+ readPoints();
297
+ state.from = from;
298
+ state.picking = false;
299
+ // Focus the unsaved point, else the newest saved one, else "from the beginning".
300
+ const auto = state.cards.findIndex((c) => c.kind === 'auto');
301
+ let newest = -1;
302
+ state.cards.forEach((c, i) => {
303
+ if (c.kind === 'slot' && c.info.savedAt && (newest < 0 || c.info.savedAt > state.cards[newest].info.savedAt)) newest = i;
304
+ });
305
+ state.focus = auto >= 0 ? auto : newest >= 0 ? newest : 0;
306
+ state.screen = 'points';
307
+ play.pause(OWNER);
308
+ host.input.resume();
309
+ render();
310
+ };
311
+
312
+ /** Write the unsaved point with the frame taken when the game was last on screen. */
313
+ const suspend = async () => {
314
+ try {
315
+ await play.states.save(AUTO_STATE_SLOT, { thumbnail: frame });
316
+ } catch (error) {
317
+ console.warn('[fbneo app] could not write the suspend point', error);
318
+ }
319
+ };
320
+
321
+ const openPause = async ({ capture = false } = {}) => {
322
+ // The frame first: once paused there is no picture left to read.
323
+ if (capture) frame = await play.snapshot().catch(() => null);
324
+ play.pause(OWNER);
325
+ state.pause = {
326
+ focus: 0,
327
+ items: [
328
+ { label: 'Continuar', run: enterGame },
329
+ {
330
+ label: 'Puntos de suspensión',
331
+ hint: 'guarda este momento y elige dónde dejarlo',
332
+ run: async () => {
333
+ await suspend();
334
+ showPoints('pause');
335
+ },
336
+ },
337
+ { label: 'Reiniciar', hint: 'reset de la máquina', run: restart },
338
+ { label: 'Volver al lanzador', hint: 'se queda un punto de suspensión', run: leave },
339
+ ],
340
+ };
341
+ state.screen = 'pause';
342
+ host.input.resume();
343
+ render();
344
+ };
345
+
346
+ const restart = async () => {
347
+ enterGame();
348
+ await play.restart();
349
+ };
350
+
351
+ const playCard = async (card) => {
352
+ if (card.kind === 'start') {
353
+ if (state.started) return restart();
354
+ return enterGame();
355
+ }
356
+ if (!card.info.savedAt) return;
357
+ if (!play.states.load(card.slot)) return toast('No se pudo cargar ese punto');
358
+ enterGame();
359
+ };
360
+
361
+ const freeSlot = () => state.cards.findIndex((c) => c.kind === 'slot' && !c.info.savedAt && !c.info.locked);
362
+
363
+ const saveAutoInto = async (i) => {
364
+ const card = cardAt(i);
365
+ await play.states.copy(AUTO_STATE_SLOT, card.slot);
366
+ readPoints();
367
+ state.picking = false;
368
+ state.focus = state.cards.findIndex((c) => c.kind === 'slot' && c.slot === card.slot);
369
+ toast(`Guardado en el hueco ${card.slot + 1}`);
370
+ };
371
+
372
+ const saveAuto = async () => {
373
+ const free = freeSlot();
374
+ if (free >= 0) return saveAutoInto(free);
375
+ const target = state.cards.findIndex((c) => c.kind === 'slot' && !c.info.locked);
376
+ if (target < 0) return toast('Los cuatro huecos están bloqueados');
377
+ state.picking = true;
378
+ state.focus = target;
379
+ render();
380
+ };
381
+
382
+ const openSheet = (card) => {
383
+ const locked = card.info.locked;
384
+ state.sheet = {
385
+ under: 'points',
386
+ slot: card.slot,
387
+ focus: 0,
388
+ items: [
389
+ {
390
+ label: locked ? 'Desbloquear' : 'Bloquear',
391
+ hint: locked ? 'se podrá sobrescribir y borrar' : 'que no se sobrescriba ni se borre',
392
+ run: async () => {
393
+ await play.states.lock(card.slot, !locked);
394
+ closeSheet();
395
+ readPoints();
396
+ render();
397
+ },
398
+ },
399
+ {
400
+ label: 'Borrar',
401
+ danger: true,
402
+ disabled: locked,
403
+ hint: locked ? 'desbloquéalo antes' : null,
404
+ run: () => {
405
+ closeSheet();
406
+ ask({
407
+ title: `¿Borrar el punto ${card.slot + 1}?`,
408
+ text: 'No se puede deshacer.',
409
+ yes: 'Borrar',
410
+ no: 'Cancelar',
411
+ onYes: async () => {
412
+ await play.states.remove(card.slot);
413
+ readPoints();
414
+ render();
415
+ },
416
+ });
417
+ },
418
+ },
419
+ { label: 'Cancelar', run: () => closeSheet() },
420
+ ],
421
+ };
422
+ state.screen = 'sheet';
423
+ render();
424
+ };
425
+ const closeSheet = () => {
426
+ state.screen = state.sheet.under;
427
+ state.sheet = null;
428
+ render();
429
+ };
430
+
431
+ const ask = (options) => {
432
+ state.confirm = { focus: 0, under: state.screen, ...options };
433
+ state.screen = 'confirm';
434
+ render();
435
+ };
436
+ const answer = (yes) => {
437
+ const c = state.confirm;
438
+ state.screen = c.under;
439
+ state.confirm = null;
440
+ render();
441
+ if (yes) c.onYes?.();
442
+ };
443
+
444
+ const exit = async () => {
445
+ if (leaving) return;
446
+ leaving = true;
447
+ try {
448
+ await play?.destroy();
449
+ } catch (error) {
450
+ console.warn('[fbneo app] destroy failed', error);
451
+ }
452
+ play = null;
453
+ host.exit();
454
+ };
455
+
456
+ /** Leaving from a running game keeps it as the unsaved suspend point. */
457
+ const leave = async () => {
458
+ if (leaving) return;
459
+ state.status = 'Guardando el punto de suspensión…';
460
+ state.screen = 'boot';
461
+ render();
462
+ await suspend();
463
+ await exit();
464
+ };
465
+
466
+ // ---- input ----------------------------------------------------------------------
467
+
468
+ const onPoints = (action) => {
469
+ const n = state.cards.length;
470
+ const movable = (i) => !state.picking || (cardAt(i)?.kind === 'slot' && !cardAt(i).info.locked);
471
+ const step = (dir) => {
472
+ for (let i = state.focus + dir; i >= 0 && i < n; i += dir) {
473
+ if (movable(i)) return (state.focus = i);
474
+ }
475
+ };
476
+ const card = cardAt(state.focus);
477
+ if (action === 'left' || action === 'prev' || action === 'up') step(-1);
478
+ else if (action === 'right' || action === 'next' || action === 'down') step(1);
479
+ else if (action === 'accept') {
480
+ if (!state.picking) return playCard(card);
481
+ if (!card.info.savedAt) return saveAutoInto(state.focus);
482
+ return ask({
483
+ title: `¿Sobrescribir el punto ${card.slot + 1}?`,
484
+ text: when(card.info.savedAt),
485
+ yes: 'Sobrescribir',
486
+ no: 'Cancelar',
487
+ onYes: () => saveAutoInto(state.focus),
488
+ });
489
+ } else if (action === 'more' && !state.picking) {
490
+ if (card.kind === 'auto') return saveAuto();
491
+ if (card.kind === 'slot' && card.info.savedAt) return openSheet(card);
492
+ return;
493
+ } else if (action === 'back' || action === 'menu') {
494
+ if (state.picking) {
495
+ state.picking = false;
496
+ state.focus = state.cards.findIndex((c) => c.kind === 'auto');
497
+ } else if (state.from === 'pause') return openPause();
498
+ else
499
+ return ask({ title: '¿Volver al lanzador?', yes: 'Salir', no: 'Seguir', onYes: exit });
500
+ } else return;
501
+ render();
502
+ };
503
+
504
+ const onList = (list, action, close) => {
505
+ const n = list.items.length;
506
+ if (action === 'up') list.focus = (list.focus + n - 1) % n;
507
+ else if (action === 'down') list.focus = (list.focus + 1) % n;
508
+ else if (action === 'accept') {
509
+ const item = list.items[list.focus];
510
+ if (!item.disabled) return item.run();
511
+ return;
512
+ } else if (action === 'back' || action === 'menu') return close();
513
+ else return;
514
+ render();
515
+ };
516
+
517
+ const onConfirm = (action) => {
518
+ const c = state.confirm;
519
+ if (action === 'left' || action === 'up') c.focus = 0;
520
+ else if (action === 'right' || action === 'down') c.focus = 1;
521
+ else if (action === 'accept') return answer(c.focus === 1);
522
+ else if (action === 'back') return answer(false);
523
+ else return;
524
+ render();
525
+ };
526
+
527
+ let busy = false;
528
+ const off = host.input.on(async (action) => {
529
+ if (leaving || busy) return;
530
+ busy = true;
531
+ try {
532
+ const s = state.screen;
533
+ if (s === 'game') {
534
+ if (action === 'menu') await openPause({ capture: true });
535
+ } else if (s === 'points') await onPoints(action);
536
+ else if (s === 'pause') await onList(state.pause, action, enterGame);
537
+ else if (s === 'sheet') await onList(state.sheet, action, closeSheet);
538
+ else if (s === 'confirm') await onConfirm(action);
539
+ else if (s === 'error' && (action === 'accept' || action === 'back')) await exit();
540
+ } catch (error) {
541
+ console.error('[fbneo app]', error);
542
+ toast(error?.message ?? String(error));
543
+ } finally {
544
+ busy = false;
545
+ }
546
+ });
547
+
548
+ // Pointer: hover (real movement only — a render replaces what is under a resting
549
+ // cursor) focuses, click activates; the same paths as the pad.
550
+ let lastPointer = '';
551
+ const onPointer = async (event) => {
552
+ const el = event.target.closest('[data-card],[data-item],[data-sheet],[data-confirm],[data-exit]');
553
+ if (!el || leaving || busy) return;
554
+ if (event.type === 'pointermove') {
555
+ const at = `${event.clientX},${event.clientY}`;
556
+ if (at === lastPointer) return;
557
+ lastPointer = at;
558
+ const key = el.dataset.card ?? el.dataset.item ?? el.dataset.sheet;
559
+ if (key == null) return;
560
+ const list = el.dataset.card != null ? state : el.dataset.item != null ? state.pause : state.sheet;
561
+ if (list && list.focus !== +key) {
562
+ list.focus = +key;
563
+ render();
564
+ }
565
+ return;
566
+ }
567
+ busy = true;
568
+ try {
569
+ if (el.dataset.card != null) {
570
+ state.focus = +el.dataset.card;
571
+ busy = false;
572
+ return onPoints('accept');
573
+ }
574
+ if (el.dataset.item != null) await state.pause.items[+el.dataset.item].run();
575
+ else if (el.dataset.sheet != null) {
576
+ const item = state.sheet.items[+el.dataset.sheet];
577
+ if (!item.disabled) await item.run();
578
+ } else if (el.dataset.confirm != null) answer(el.dataset.confirm === '1');
579
+ else if (el.dataset.exit != null) await exit();
580
+ } finally {
581
+ busy = false;
582
+ }
583
+ };
584
+ overlay.addEventListener('click', onPointer);
585
+ overlay.addEventListener('pointermove', onPointer);
586
+
587
+ const handle = {
588
+ async unmount() {
589
+ destroyed = true;
590
+ leaving = true;
591
+ off();
592
+ clearTimeout(toastTimer);
593
+ overlay.removeEventListener('click', onPointer);
594
+ overlay.removeEventListener('pointermove', onPointer);
595
+ try {
596
+ await play?.destroy();
597
+ } catch {
598
+ /* already gone */
599
+ }
600
+ play = null;
601
+ dropUrls();
602
+ root.remove();
603
+ },
604
+ };
605
+
606
+ // ---- boot -----------------------------------------------------------------------
607
+
608
+ render();
609
+ host.input.resume();
610
+ try {
611
+ state.status = 'Identificando el juego…';
612
+ render();
613
+ const bytes = await host.assets.rom?.();
614
+ if (!bytes) throw new Error('No hay ROM para este juego.');
615
+ const { rom, driver, how } = await identifyDriver(bytes, { slug: host.game?.slug });
616
+ if (!driver) throw new Error('No se reconoce el juego: falta el nombre del driver de FBNeo.');
617
+ console.info(`[fbneo app] driver ${driver} (${how})`);
618
+ state.status = 'Arrancando el motor…';
619
+ render();
620
+ play = await new FbneoSDK()
621
+ .mount(stage)
622
+ .options({ driver, ...(host.options?.neogeo ? { neogeo: host.options.neogeo } : {}) })
623
+ .assets({ ...host.assets, rom })
624
+ .storage(host.storage)
625
+ .on('error', ({ detail }) => console.error('[fbneo app]', detail))
626
+ .on('exit', () => exit())
627
+ .start();
628
+ if (destroyed) {
629
+ await play.destroy().catch(() => {});
630
+ play = null;
631
+ return handle;
632
+ }
633
+ readPoints();
634
+ if (hasPoints()) showPoints('boot');
635
+ else enterGame();
636
+ } catch (error) {
637
+ console.error('[fbneo app]', error);
638
+ state.error = error?.message ?? String(error);
639
+ state.screen = 'error';
640
+ render();
641
+ }
642
+
643
+ return handle;
644
+ }
645
+
646
+ export { STATE_SLOTS };