@wasm-gaming/fbneo-wasm 0.1.1 → 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 +133 -76
- package/dist/fbneo/app/app.css +112 -0
- package/dist/fbneo/app/app.js +646 -0
- package/dist/fbneo/engine-specs.js +646 -0
- package/dist/fbneo/fbneo.js +2 -0
- package/dist/fbneo/fbneo.options.d.ts +66 -19
- package/dist/fbneo/fbneo.options.d.ts.map +1 -1
- package/dist/fbneo/fbneo.options.js +39 -44
- package/dist/fbneo/fbneo.options.js.map +1 -1
- package/dist/fbneo/fbneo.sdk.d.ts +137 -8
- package/dist/fbneo/fbneo.sdk.d.ts.map +1 -1
- package/dist/fbneo/fbneo.sdk.js +588 -184
- package/dist/fbneo/fbneo.sdk.js.map +1 -1
- package/dist/fbneo/fbneo.wasm +4 -0
- package/dist/fbneo/neo.d.ts +83 -0
- package/dist/fbneo/neo.d.ts.map +1 -0
- package/dist/fbneo/neo.js +357 -0
- package/dist/fbneo/neo.js.map +1 -0
- package/dist/fbneo/romset.d.ts +0 -0
- package/dist/fbneo/romset.d.ts.map +1 -1
- package/dist/fbneo/romset.js +0 -0
- package/dist/fbneo/romset.js.map +1 -1
- package/{data → dist/fbneo}/romsets/capcom.json +0 -144
- package/{data → dist/fbneo}/romsets/index.json +7 -7
- package/{data → dist/fbneo}/romsets/konami.json +0 -8
- package/{data → dist/fbneo}/romsets/megadrive.json +0 -18
- package/dist/fbneo/romsets/neogeo.json +39274 -0
- package/{data → dist/fbneo}/romsets/nes.json +0 -81
- package/{data → dist/fbneo}/romsets/pgm.json +0 -73
- package/{data → dist/fbneo}/romsets/pre90s.json +396 -273
- package/{data → dist/fbneo}/romsets/pst90s.json +0 -152
- package/{data → dist/fbneo}/romsets/sega.json +0 -175
- package/{data → dist/fbneo}/romsets/sms.json +0 -9
- package/{data → dist/fbneo}/romsets/taito.json +0 -24
- package/{data → dist/fbneo}/romsets/toaplan.json +0 -60
- package/package.json +15 -15
- package/src/app/app.css +112 -0
- package/src/app/app.js +646 -0
- package/src/fbneo.options.ts +107 -63
- package/src/fbneo.sdk.ts +644 -212
- package/src/neo.ts +463 -0
- package/src/romset.ts +0 -0
- package/data/romsets/neogeo.json +0 -28260
- package/dist/fbneo/fbneo.manifest.d.ts +0 -4
- package/dist/fbneo/fbneo.manifest.d.ts.map +0 -1
- package/dist/fbneo/fbneo.manifest.js +0 -33
- package/dist/fbneo/fbneo.manifest.js.map +0 -1
- package/dist/manifest.json +0 -91
- package/src/fbneo.manifest.ts +0 -38
- /package/{data → dist/fbneo}/romsets/atari.json +0 -0
- /package/{data → dist/fbneo}/romsets/cave.json +0 -0
- /package/{data → dist/fbneo}/romsets/channelf.json +0 -0
- /package/{data → dist/fbneo}/romsets/coleco.json +0 -0
- /package/{data → dist/fbneo}/romsets/cps3.json +0 -0
- /package/{data → dist/fbneo}/romsets/dataeast.json +0 -0
- /package/{data → dist/fbneo}/romsets/galaxian.json +0 -0
- /package/{data → dist/fbneo}/romsets/irem.json +0 -0
- /package/{data → dist/fbneo}/romsets/midway.json +0 -0
- /package/{data → dist/fbneo}/romsets/msx.json +0 -0
- /package/{data → dist/fbneo}/romsets/pce.json +0 -0
- /package/{data → dist/fbneo}/romsets/pgm2.json +0 -0
- /package/{data → dist/fbneo}/romsets/psikyo.json +0 -0
- /package/{data → dist/fbneo}/romsets/sg1000.json +0 -0
- /package/{data → dist/fbneo}/romsets/snes.json +0 -0
- /package/{data → dist/fbneo}/romsets/spectrum.json +0 -0
package/src/app/app.js
ADDED
|
@@ -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"> </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 })
|
|
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 };
|