@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
package/README.md CHANGED
@@ -1,44 +1,91 @@
1
1
  # @wasm-gaming/fbneo-wasm
2
2
 
3
- FinalBurn Neo (FBNeo) — the multi-system arcade emulator (Capcom CPS-1/2/3, SNK
4
- Neo Geo, Sega System 16, Cave, PGM, and many more) — compiled to WebAssembly via
5
- Emscripten and packaged as a wasm-gaming engine SDK.
3
+ FinalBurn Neo (FBNeo), the multi-system arcade emulator (Capcom CPS-1/2/3, SNK Neo Geo,
4
+ Sega System 16, Cave, PGM and many more), compiled to WebAssembly and wrapped in a small JS
5
+ SDK that implements the [wasm-gaming engine contract](https://github.com/wasm-gaming/engine-specs)
6
+ 1.0. It ships no games: the player brings the ROM sets, and they never leave the browser.
6
7
 
7
- This subproject follows the same engine-package approach used by jgenesis-wasm,
8
- blastem-wasm, rsdkv3, and rsdkv4:
8
+ ## Install
9
9
 
10
- - typed `manifest`
11
- - typed `options`
12
- - `load(config)` engine SDK surface
13
- - Makefile-driven build (`build-sdk`, `build-wasm`, `preview`)
10
+ ```sh
11
+ npm install @wasm-gaming/fbneo-wasm
12
+ ```
13
+
14
+ The SDK is self-contained: the contract's runtime ships beside it in `dist/fbneo/` and is
15
+ imported relatively, so it loads from any static server with no bundler and no import map.
16
+ The wasm and its glue sit next to it (`fbneo.js`, `fbneo.wasm`).
17
+
18
+ ## Use
19
+
20
+ ```js
21
+ import FbneoSDK from '@wasm-gaming/fbneo-wasm';
22
+
23
+ FbneoSDK.id; // 'fbneo' — also this engine's folder in OPFS
24
+
25
+ const play = await new FbneoSDK()
26
+ .options({ driver: 'mslug' }) // or romFileName: 'mslug.zip'
27
+ .assets({ rom: () => zip.arrayBuffer(), bios: () => neogeo.arrayBuffer() })
28
+ .storage({ namespace: 'mslug' }) // → OPFS fbneo/mslug/
29
+ .on('saves', ({ detail }) => console.log('saved', detail.files))
30
+ .mount(document.querySelector('#stage')) // a <canvas> is used as is; anything else gets one
31
+ .start();
32
+
33
+ play.pause('menu'); // the first caller owns the pause…
34
+ play.resume('menu'); // …and only it lifts it; sound stops too
35
+ await play.restart(); // soft reset
36
+ await play.states.save(0); // save states: slots 0-3, plus 9 (the suspend point)
37
+ play.states.load(0);
38
+ await play.destroy(); // saves flushed, loop stopped, audio closed
39
+ ```
40
+
41
+ | payload | keys |
42
+ | --- | --- |
43
+ | `config` | `renderFilter` (`pixelated` \| `smooth`, live), `jsUrl`, `wasmUrl` |
44
+ | `options` | `driver` (FBNeo short name), `romFileName` (its stem is the driver when `driver` is empty), `neogeo` (`mvs` \| `aes`) |
45
+ | `assets` | `rom` (`.zip`/`.7z`, required), `bios` (`.zip`, e.g. `neogeo.zip`) |
46
+ | `storage` | `namespace` (default `default`), `persist` (`'opfs'` \| `null`) |
47
+
48
+ FBNeo boots a game by its ROM-set short name (the *driver*, `mslug`) and finds `<driver>.zip`
49
+ in its ROM path; the SDK writes the bytes there under that name, so the player's file name
50
+ never matters once the driver is known.
14
51
 
15
- It conforms to the [`@wasm-gaming/engine-specs`](https://github.com/wasm-gaming/engine-specs)
16
- contract (`EngineSDK` = `{ manifest, load }`).
52
+ Events: the contract's `start`, `error`, `exit`, `pause`, `resume`, plus `saves`.
17
53
 
18
- ## Contract surface
54
+ ## Neo Geo as the AES
55
+
56
+ FBNeo boots Neo Geo sets as the **MVS** (the arcade board: coins, credits). `options({ neogeo: 'aes' })`
57
+ boots them as the **AES**, the home console: once the set runs, the SDK picks the BIOS from the
58
+ `bios` zip — the real AES World BIOS (`neo-epo.bin`) when it is there, else Universe BIOS 4.0 in its
59
+ AES mode (MAME's `neogeo.zip` has it) — sets FBNeo's BIOS settings, inserts the game's **memory
60
+ card** (`<driver>.card` in the save folder, so in OPFS) and resets. `play.neogeo` says what was used
61
+ (`{ mode: 'aes', bios: 'aes' | 'unibios', card }`). The app takes the option from `host.options`.
62
+
63
+ ## The engine's own app
64
+
65
+ A host that would rather not draw menus mounts the app shipped next to the SDK
66
+ (engine-specs `EngineApp`, `UX.md`):
19
67
 
20
68
  ```js
21
- import { manifest, load } from '@wasm-gaming/fbneo-wasm';
22
-
23
- const engine = await load({
24
- canvas, // per engine-specs EngineConfig
25
- assets: {
26
- rom: romZipBytes, // arcade ROM set (zip)
27
- bios: neogeoZipBytes, // optional: e.g. neogeo.zip for Neo Geo sets
28
- },
29
- options: { driver: 'mslug', renderFilter: 'pixelated' },
30
- onEvent: (e) => console.log(e),
31
- });
32
- engine.start();
69
+ const app = await import('@wasm-gaming/fbneo-wasm/app'); // dist/fbneo/app/app.js
70
+ const handle = await app.mount(element, { game, storage, assets, input, exit });
71
+ await handle.unmount();
33
72
  ```
34
73
 
35
- FBNeo identifies a game by the ROM zip **filename** (without extension). Pass the
36
- game short name explicitly via `options.driver` (e.g. `"mslug"`, `"sf2"`,
37
- `"kof98"`), or let it be inferred from `options.romFileName` / the picked file's
38
- name. (The bytes are always written to MEMFS as `<driver>.zip`, so the source
39
- file itself never needs renaming — only its *name* has to resolve to a driver.)
74
+ The same app as blastem-wasm's: suspend points the way the Nintendo Classic Mini does them
75
+ (leaving keeps an unsaved point with its frame; coming back offers it, four slots and "from
76
+ the beginning"), and a pause menu on Select + Start or Escape (*Continuar*, *Puntos de
77
+ suspensión*, *Reiniciar*, *Volver al lanzador*). The host hands over bytes, not a driver
78
+ name, so the app identifies the set by its contents (the datasets below, Neo Geo and Capcom
79
+ first), rebuilds a `.neo` image, and falls back to `game.slug`.
80
+
81
+ ## Saves live in the engine's own folder
82
+
83
+ FBNeo's NVRAM/EEPROM (`<driver>.fs`), hiscores (`<driver>.hi`) and save states
84
+ (`slot_N.fs`, with an optional `slot_N.png` frame and `slot_N.lock`) are written to MEMFS
85
+ `/save`, which the SDK seeds from OPFS `fbneo/<namespace>/` before boot and mirrors back
86
+ every 10 s, on pause, when the page is hidden and on destroy. `persist: null` keeps nothing.
40
87
 
41
- ### Identifying a ROM set from its contents
88
+ ## Identifying a ROM set from its contents
42
89
 
43
90
  When the filename is unreliable (a browser-mangled `mslug (1).zip`) but the ROM
44
91
  files inside must keep their canonical names, recover the driver from the zip's
@@ -55,16 +102,16 @@ const neogeo = await fetch('/romsets/neogeo.json').then((r) => r.json());
55
102
  const index = new RomsetIndex([neogeo]);
56
103
 
57
104
  const match = resolveDriver(romZipBytes, index); // { driver: 'mslug', coverage: 1, ... } | null
58
- const engine = await load({
59
- canvas,
60
- assets: { rom: romZipBytes },
61
- options: { driver: match?.driver }, // fall back to your own logic when null
62
- });
105
+ const play = await new FbneoSDK()
106
+ .options({ driver: match?.driver ?? '' }) // fall back to your own logic when null
107
+ .assets({ rom: romZipBytes })
108
+ .mount(stage)
109
+ .start();
63
110
  ```
64
111
 
65
- The dataset ships as `data/romsets/<system>.json` (also served at `/romsets/…`
66
- from the built `dist/`); `data/romsets/index.json` lists the systems and game
67
- counts. Regenerate it from a pinned FBNeo checkout with `make romsets`.
112
+ The dataset is `data/romsets/<system>.json` in the repo and ships next to the SDK as
113
+ `fbneo/romsets/<system>.json` (package export `./romsets/*`); `index.json` lists the
114
+ systems and game counts. Regenerate it from a pinned FBNeo checkout with `make romsets`.
68
115
 
69
116
  Each ROM entry carries its internal name (`n`) and CRC-32 (`c`). `neogeo.json`
70
117
  additionally carries each chip's size (`s`) and hardware region (`r`) — the chip
@@ -77,7 +124,7 @@ extra files, subdirectories, and non-canonical chip filenames inside the zip do
77
124
  not matter. Only the zip's *name* has to resolve to a driver, which is exactly
78
125
  what this helper supplies.
79
126
 
80
- ### Neo Geo `.neo` files (NeoSD / Terraonion)
127
+ ## Neo Geo `.neo` files (NeoSD / Terraonion)
81
128
 
82
129
  FBNeo reads `.zip` and `.7z` ROM sets only. A `.neo` — the single-file format for
83
130
  NeoSD/Terraonion flash carts, and how most Neo Geo homebrew is distributed — is a
@@ -93,7 +140,7 @@ const neogeo = await fetch('/romsets/neogeo.json').then((r) => r.json());
93
140
 
94
141
  if (isNeoFile(bytes)) {
95
142
  const { driver, zip } = neoToRomset(bytes, neogeo); // throws if it cannot be rebuilt
96
- await load({ canvas, assets: { rom: zip }, options: { driver } });
143
+ await new FbneoSDK().options({ driver }).assets({ rom: zip }).mount(stage).start();
97
144
  }
98
145
  ```
99
146
 
@@ -114,41 +161,37 @@ node scripts/neo2romset.mjs mygame.neo --out ./roms # needs `make build-lib`
114
161
  Sets whose program ROM lives inside the SMA custom chip cannot be reproduced by a
115
162
  straight carve; those fail CRC verification and are reported rather than written.
116
163
 
117
- ### Options
118
-
119
- | Option | Default | Description |
120
- |--------|---------|-------------|
121
- | `romFileName` | `game.zip` | Filename used when writing ROM bytes to MEMFS. |
122
- | `driver` | *(inferred)* | FBNeo ROM set short name to boot. |
123
- | `renderFilter` | `pixelated` | `pixelated` (crisp) or `smooth` (linear). |
124
- | `audioSampleRate` | `44100` | Target audio sample rate in Hz. |
125
- | `vsync` | `true` | Enable vertical sync. |
126
- | `integerScale` | `false` | Integer scaling only. |
127
- | `windowScale` | `2` | Internal render-resolution multiplier. |
128
-
129
164
  ## Build
130
165
 
131
166
  ```sh
132
- make build # Full build: WASM (Docker/Emscripten) + TypeScript SDK
133
- make build-sdk # TypeScript only (SDK + manifest + demo shell)
134
- make build-wasm # FBNeo WASM only (via Docker)
135
- make preview # Serve dist/ with COOP/COEP headers
167
+ make build-sdk # TypeScript → dist/fbneo/ (no WASM), with the app and the datasets
168
+ make build-wasm # FBNeo → WebAssembly in the pinned emscripten/emsdk:6.0.2 (Docker)
169
+ make demo # the no-build demo page → dist/index.html
170
+ make test # type-check, then the tests against the built package
171
+ make preview # serve dist/ with COOP/COEP
172
+ make romsets # regenerate data/romsets/*.json from the pinned FBNeo checkout
136
173
  ```
137
174
 
138
- WebAssembly threads/SharedArrayBuffer require the COOP/COEP headers that
139
- `make preview` sets — serve the built `dist/` with those headers in production too.
175
+ `scripts/build-fbneo.sh` fetches FBNeo at a pinned commit and ports its SDL2 burner to
176
+ Emscripten: every source patch checks it applied, so a moved upstream fails the build
177
+ instead of producing a broken engine. FBNeo's own blocking loop runs under ASYNCIFY and
178
+ paces itself to the driver's frame rate on the real-time clock (not the monitor's refresh);
179
+ `scripts/web_bridge.cpp` adds the SDK's handles (pause, reset, NVRAM flush, save states).
180
+
181
+ ## Project Specs
182
+
183
+ - Contract: engine-specs 1.0 (`FbneoSDK` / `FbneoPlay`), self-contained ESM.
184
+ - Engine: FBNeo pinned (see `FBNEO_REV` in `scripts/build-fbneo.sh`), Emscripten 6.0.2, ASYNCIFY.
185
+ - Storage: OPFS `fbneo/<namespace>/` (NVRAM, hiscores, save states).
186
+ - App: `dist/fbneo/app/app.js` (EngineApp), driver identified by content.
187
+ - Records: [RECORD/](RECORD/).
140
188
 
141
- ## WASM artifacts
189
+ ## Todos
142
190
 
143
- | File | Description |
144
- |------|-------------|
145
- | `fbneo.js` | Emscripten-generated module loader (`createFbneoModule`). |
146
- | `fbneo.wasm` | Compiled FBNeo runtime. |
191
+ - Verify game-level behaviour with real ROM sets (pause, reset, states, NVRAM round trip).
192
+ - Move the FBNeo pin forward (later upstream changed the audio code the build patches).
147
193
 
148
- FBNeo uses Emscripten SDL2. The JS loader expects a `<canvas id="canvas">` in the
149
- DOM (the SDK sets this id automatically). ROM zips are written to the in-memory
150
- MEMFS under `/roms/` before the emulator boots, and FBNeo is launched with the
151
- driver short name.
194
+ ## License
152
195
 
153
- See [CORE.md](CORE.md) for the mapping between upstream FBNeo capabilities and the
154
- options currently exposed by this wrapper.
196
+ MIT for this wrapper. FBNeo has its own non-commercial license; ROM sets and BIOS images
197
+ belong to their owners and are never shipped.
@@ -0,0 +1,112 @@
1
+ /* FBNeo app — suspend points (Classic Mini style) and the pause menu.
2
+ engine-specs `--wg-*` tokens when the host sets them; same values as fallbacks. */
3
+
4
+ .fbn-app {
5
+ --ring: var(--wg-focus-ring, 0 0 0 3px #fff);
6
+ --surface: var(--wg-surface, rgb(18 20 28 / 0.78));
7
+ --line: var(--wg-line, rgb(255 255 255 / 0.12));
8
+ --text: var(--wg-text, #f4f6fb);
9
+ --muted: var(--wg-muted, #8e97a9);
10
+ --danger: var(--wg-danger, #f87171);
11
+ --ui: var(--wg-font-ui, 'Inter Variable', 'Inter', system-ui, sans-serif);
12
+ --display: var(--wg-font-display, 'Unbounded Variable', 'Unbounded', system-ui, sans-serif);
13
+ --ease: var(--wg-ease, cubic-bezier(0.2, 0.8, 0.2, 1));
14
+ position: absolute; inset: 0; overflow: hidden; background: #000; color: var(--text);
15
+ font-family: var(--ui); user-select: none; -webkit-tap-highlight-color: transparent;
16
+ }
17
+ .fbn-app * { box-sizing: border-box; }
18
+ .fbn-stage { position: absolute; inset: 0; display: grid; place-items: center; }
19
+ .fbn-stage canvas { outline: none; }
20
+ .fbn-overlay { position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none; }
21
+ .fbn-overlay > * { pointer-events: auto; }
22
+ .fbn-app:not([data-screen='game']) .fbn-overlay {
23
+ background:
24
+ radial-gradient(120% 80% at 50% 0%, color-mix(in srgb, var(--accent) 34%, transparent), transparent 70%),
25
+ rgb(4 6 12 / 0.74);
26
+ backdrop-filter: blur(14px) saturate(1.2);
27
+ -webkit-backdrop-filter: blur(14px) saturate(1.2);
28
+ }
29
+ .fbn-app[data-screen='boot'] .fbn-overlay,
30
+ .fbn-app[data-screen='points'] .fbn-overlay { background-color: rgb(4 6 12 / 0.9); }
31
+
32
+ .kicker { margin: 0; font: 600 0.72rem var(--ui); letter-spacing: 0.22em; text-transform: uppercase; color: var(--muted); }
33
+ .fbn-app h2 { margin: 0; font: 700 clamp(1.3rem, 3vw, 2rem) var(--display); letter-spacing: -0.02em; }
34
+
35
+ /* boot */
36
+ .fbn-boot { display: grid; justify-items: center; gap: 22px; }
37
+ .fbn-boot img { width: min(240px, 46vw); border-radius: 22px; box-shadow: 0 30px 80px -30px #000; animation: fbn-breathe 2.4s var(--ease) infinite alternate; }
38
+ .fbn-boot p { margin: 0; color: var(--muted); font-weight: 500; }
39
+ @keyframes fbn-breathe { from { opacity: 0.55; transform: scale(0.98); } to { opacity: 1; transform: none; } }
40
+
41
+ /* suspend points: a row of frames, like the Classic Mini's */
42
+ .fbn-points { width: min(1180px, 100%); padding: 0 clamp(16px, 4vw, 48px); display: grid; gap: clamp(20px, 4vh, 36px); }
43
+ .fbn-points header { display: grid; gap: 6px; }
44
+ .points { display: flex; justify-content: center; gap: clamp(10px, 1.4vw, 18px); padding: 12px 4px; }
45
+ .point {
46
+ all: unset; box-sizing: border-box; flex: 0 1 196px; min-width: 0; display: grid; gap: 8px; cursor: pointer;
47
+ transition: transform 0.22s var(--ease), opacity 0.2s;
48
+ }
49
+ .point-frame {
50
+ position: relative; display: grid; place-items: center; aspect-ratio: 320 / 224; border-radius: 10px; overflow: hidden;
51
+ background: rgb(255 255 255 / 0.04); border: 2px solid rgb(255 255 255 / 0.16);
52
+ transition: box-shadow 0.22s var(--ease), border-color 0.2s;
53
+ }
54
+ .point-frame img { width: 100%; height: 100%; object-fit: cover; image-rendering: pixelated; }
55
+ .point-frame svg { width: 30%; color: var(--muted); }
56
+ .point.is-empty .point-frame { border-style: dashed; background: transparent; }
57
+ .point.is-auto .point-frame { border-color: color-mix(in srgb, var(--accent) 70%, #fff); }
58
+ .point-label { font: 700 0.9rem var(--ui); text-align: center; }
59
+ .point.is-auto .point-label { color: #fda4af; }
60
+ .point-date { font: 500 0.74rem var(--ui); color: var(--muted); text-align: center; font-variant-numeric: tabular-nums; }
61
+ .point-lock {
62
+ position: absolute; top: 6px; right: 6px; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%;
63
+ background: rgb(0 0 0 / 0.72); color: #fbbf24;
64
+ }
65
+ .point-lock svg { width: 13px; color: inherit; }
66
+ .point.is-start .point-frame { border-style: solid; }
67
+ .point.is-focused { transform: translateY(-6px) scale(1.04); }
68
+ .point.is-focused .point-frame { box-shadow: var(--ring), 0 26px 60px -24px #000; border-color: transparent; }
69
+ .point.is-dim { opacity: 0.32; }
70
+ .point.is-target.is-focused .point-frame { box-shadow: 0 0 0 3px #fbbf24, 0 26px 60px -24px #000; }
71
+
72
+ /* pause, sheets, confirms */
73
+ .fbn-panel {
74
+ width: min(440px, 92vw); display: grid; gap: 10px; padding: 26px; border-radius: 28px;
75
+ background: rgb(20 22 31 / 0.92); border: 1px solid var(--line); box-shadow: 0 40px 100px -30px #000;
76
+ }
77
+ .fbn-panel.is-small { width: min(380px, 92vw); }
78
+ .fbn-panel h2 { margin-bottom: 8px; }
79
+ .fbn-panel .text { margin: 0 0 8px; color: var(--muted); line-height: 1.5; }
80
+ .item {
81
+ all: unset; box-sizing: border-box; display: grid; gap: 2px; padding: 13px 16px; border-radius: 14px;
82
+ background: rgb(255 255 255 / 0.05); font-weight: 600; cursor: pointer; transition: background 0.15s, box-shadow 0.15s;
83
+ }
84
+ .item small { font-weight: 500; font-size: 0.78rem; color: var(--muted); }
85
+ .item[disabled] { opacity: 0.45; cursor: default; }
86
+ .item.is-focused { background: #fff; color: #0b0d12; box-shadow: var(--ring); }
87
+ .item.is-focused small { color: #4b5563; }
88
+ .item.is-danger.is-focused { background: var(--danger); color: #1a0505; box-shadow: 0 0 0 3px var(--danger); }
89
+ .item.is-danger.is-focused small { color: #450a0a; }
90
+ .fbn-panel .row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
91
+ .fbn-panel .row .item { text-align: center; }
92
+ .fbn-layer { position: absolute; inset: 0; display: grid; place-items: center; background: rgb(0 0 0 / 0.55); }
93
+
94
+ .fbn-hints { display: flex; flex-wrap: wrap; gap: 8px 18px; justify-content: center; color: var(--muted); font-size: 0.78rem; font-weight: 500; }
95
+ .fbn-hints span { display: inline-flex; align-items: center; gap: 7px; }
96
+ .fbn-hints kbd { display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 6px; border-radius: 11px; background: rgb(255 255 255 / 0.12); color: var(--text); font: 700 0.68rem var(--ui); }
97
+ .fbn-panel .fbn-hints { justify-content: flex-start; margin-top: 6px; }
98
+
99
+ .fbn-toast {
100
+ position: absolute; left: 50%; bottom: calc(28px + var(--wg-safe-bottom, 0px)); transform: translateX(-50%); margin: 0;
101
+ padding: 10px 18px; border-radius: 999px; background: rgb(20 22 31 / 0.94); border: 1px solid var(--line);
102
+ font-weight: 600; font-size: 0.85rem; white-space: nowrap; animation: fbn-toast 0.3s var(--ease);
103
+ }
104
+ @keyframes fbn-toast { from { opacity: 0; transform: translate(-50%, 8px); } }
105
+
106
+ @media (max-width: 960px) {
107
+ .points { justify-content: flex-start; overflow-x: auto; scrollbar-width: none; padding: 14px 6px 18px; }
108
+ .point { flex: 0 0 min(42vw, 180px); }
109
+ }
110
+ @media (prefers-reduced-motion: reduce) {
111
+ .point, .point-frame, .fbn-boot img, .fbn-toast { transition: none; animation: none; }
112
+ }