@wasm-gaming/fbneo-wasm 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +103 -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 +66 -19
  7. package/dist/fbneo/fbneo.options.d.ts.map +1 -1
  8. package/dist/fbneo/fbneo.options.js +39 -44
  9. package/dist/fbneo/fbneo.options.js.map +1 -1
  10. package/dist/fbneo/fbneo.sdk.d.ts +137 -8
  11. package/dist/fbneo/fbneo.sdk.d.ts.map +1 -1
  12. package/dist/fbneo/fbneo.sdk.js +588 -184
  13. package/dist/fbneo/fbneo.sdk.js.map +1 -1
  14. package/dist/fbneo/fbneo.wasm +4 -0
  15. package/package.json +10 -15
  16. package/src/app/app.css +112 -0
  17. package/src/app/app.js +646 -0
  18. package/src/fbneo.options.ts +107 -63
  19. package/src/fbneo.sdk.ts +644 -212
  20. package/dist/fbneo/fbneo.manifest.d.ts +0 -4
  21. package/dist/fbneo/fbneo.manifest.d.ts.map +0 -1
  22. package/dist/fbneo/fbneo.manifest.js +0 -33
  23. package/dist/fbneo/fbneo.manifest.js.map +0 -1
  24. package/dist/manifest.json +0 -91
  25. package/src/fbneo.manifest.ts +0 -38
  26. /package/{data → dist/fbneo}/romsets/atari.json +0 -0
  27. /package/{data → dist/fbneo}/romsets/capcom.json +0 -0
  28. /package/{data → dist/fbneo}/romsets/cave.json +0 -0
  29. /package/{data → dist/fbneo}/romsets/channelf.json +0 -0
  30. /package/{data → dist/fbneo}/romsets/coleco.json +0 -0
  31. /package/{data → dist/fbneo}/romsets/cps3.json +0 -0
  32. /package/{data → dist/fbneo}/romsets/dataeast.json +0 -0
  33. /package/{data → dist/fbneo}/romsets/galaxian.json +0 -0
  34. /package/{data → dist/fbneo}/romsets/index.json +0 -0
  35. /package/{data → dist/fbneo}/romsets/irem.json +0 -0
  36. /package/{data → dist/fbneo}/romsets/konami.json +0 -0
  37. /package/{data → dist/fbneo}/romsets/megadrive.json +0 -0
  38. /package/{data → dist/fbneo}/romsets/midway.json +0 -0
  39. /package/{data → dist/fbneo}/romsets/msx.json +0 -0
  40. /package/{data → dist/fbneo}/romsets/neogeo.json +0 -0
  41. /package/{data → dist/fbneo}/romsets/nes.json +0 -0
  42. /package/{data → dist/fbneo}/romsets/pce.json +0 -0
  43. /package/{data → dist/fbneo}/romsets/pgm.json +0 -0
  44. /package/{data → dist/fbneo}/romsets/pgm2.json +0 -0
  45. /package/{data → dist/fbneo}/romsets/pre90s.json +0 -0
  46. /package/{data → dist/fbneo}/romsets/psikyo.json +0 -0
  47. /package/{data → dist/fbneo}/romsets/pst90s.json +0 -0
  48. /package/{data → dist/fbneo}/romsets/sega.json +0 -0
  49. /package/{data → dist/fbneo}/romsets/sg1000.json +0 -0
  50. /package/{data → dist/fbneo}/romsets/sms.json +0 -0
  51. /package/{data → dist/fbneo}/romsets/snes.json +0 -0
  52. /package/{data → dist/fbneo}/romsets/spectrum.json +0 -0
  53. /package/{data → dist/fbneo}/romsets/taito.json +0 -0
  54. /package/{data → dist/fbneo}/romsets/toaplan.json +0 -0
package/README.md CHANGED
@@ -1,44 +1,82 @@
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) |
45
+ | `assets` | `rom` (`.zip`/`.7z`, required), `bios` (`.zip`, e.g. `neogeo.zip`) |
46
+ | `storage` | `namespace` (default `default`), `persist` (`'opfs'` \| `null`) |
14
47
 
15
- It conforms to the [`@wasm-gaming/engine-specs`](https://github.com/wasm-gaming/engine-specs)
16
- contract (`EngineSDK` = `{ manifest, load }`).
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.
17
51
 
18
- ## Contract surface
52
+ Events: the contract's `start`, `error`, `exit`, `pause`, `resume`, plus `saves`.
53
+
54
+ ## The engine's own app
55
+
56
+ A host that would rather not draw menus mounts the app shipped next to the SDK
57
+ (engine-specs `EngineApp`, `UX.md`):
19
58
 
20
59
  ```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();
60
+ const app = await import('@wasm-gaming/fbneo-wasm/app'); // dist/fbneo/app/app.js
61
+ const handle = await app.mount(element, { game, storage, assets, input, exit });
62
+ await handle.unmount();
33
63
  ```
34
64
 
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.)
65
+ The same app as blastem-wasm's: suspend points the way the Nintendo Classic Mini does them
66
+ (leaving keeps an unsaved point with its frame; coming back offers it, four slots and "from
67
+ the beginning"), and a pause menu on Select + Start or Escape (*Continuar*, *Puntos de
68
+ suspensión*, *Reiniciar*, *Volver al lanzador*). The host hands over bytes, not a driver
69
+ name, so the app identifies the set by its contents (the datasets below, Neo Geo and Capcom
70
+ first), rebuilds a `.neo` image, and falls back to `game.slug`.
71
+
72
+ ## Saves live in the engine's own folder
73
+
74
+ FBNeo's NVRAM/EEPROM (`<driver>.fs`), hiscores (`<driver>.hi`) and save states
75
+ (`slot_N.fs`, with an optional `slot_N.png` frame and `slot_N.lock`) are written to MEMFS
76
+ `/save`, which the SDK seeds from OPFS `fbneo/<namespace>/` before boot and mirrors back
77
+ every 10 s, on pause, when the page is hidden and on destroy. `persist: null` keeps nothing.
40
78
 
41
- ### Identifying a ROM set from its contents
79
+ ## Identifying a ROM set from its contents
42
80
 
43
81
  When the filename is unreliable (a browser-mangled `mslug (1).zip`) but the ROM
44
82
  files inside must keep their canonical names, recover the driver from the zip's
@@ -55,16 +93,16 @@ const neogeo = await fetch('/romsets/neogeo.json').then((r) => r.json());
55
93
  const index = new RomsetIndex([neogeo]);
56
94
 
57
95
  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
- });
96
+ const play = await new FbneoSDK()
97
+ .options({ driver: match?.driver ?? '' }) // fall back to your own logic when null
98
+ .assets({ rom: romZipBytes })
99
+ .mount(stage)
100
+ .start();
63
101
  ```
64
102
 
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`.
103
+ The dataset is `data/romsets/<system>.json` in the repo and ships next to the SDK as
104
+ `fbneo/romsets/<system>.json` (package export `./romsets/*`); `index.json` lists the
105
+ systems and game counts. Regenerate it from a pinned FBNeo checkout with `make romsets`.
68
106
 
69
107
  Each ROM entry carries its internal name (`n`) and CRC-32 (`c`). `neogeo.json`
70
108
  additionally carries each chip's size (`s`) and hardware region (`r`) — the chip
@@ -77,7 +115,7 @@ extra files, subdirectories, and non-canonical chip filenames inside the zip do
77
115
  not matter. Only the zip's *name* has to resolve to a driver, which is exactly
78
116
  what this helper supplies.
79
117
 
80
- ### Neo Geo `.neo` files (NeoSD / Terraonion)
118
+ ## Neo Geo `.neo` files (NeoSD / Terraonion)
81
119
 
82
120
  FBNeo reads `.zip` and `.7z` ROM sets only. A `.neo` — the single-file format for
83
121
  NeoSD/Terraonion flash carts, and how most Neo Geo homebrew is distributed — is a
@@ -93,7 +131,7 @@ const neogeo = await fetch('/romsets/neogeo.json').then((r) => r.json());
93
131
 
94
132
  if (isNeoFile(bytes)) {
95
133
  const { driver, zip } = neoToRomset(bytes, neogeo); // throws if it cannot be rebuilt
96
- await load({ canvas, assets: { rom: zip }, options: { driver } });
134
+ await new FbneoSDK().options({ driver }).assets({ rom: zip }).mount(stage).start();
97
135
  }
98
136
  ```
99
137
 
@@ -114,41 +152,37 @@ node scripts/neo2romset.mjs mygame.neo --out ./roms # needs `make build-lib`
114
152
  Sets whose program ROM lives inside the SMA custom chip cannot be reproduced by a
115
153
  straight carve; those fail CRC verification and are reported rather than written.
116
154
 
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
155
  ## Build
130
156
 
131
157
  ```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
158
+ make build-sdk # TypeScript → dist/fbneo/ (no WASM), with the app and the datasets
159
+ make build-wasm # FBNeo → WebAssembly in the pinned emscripten/emsdk:6.0.2 (Docker)
160
+ make demo # the no-build demo page → dist/index.html
161
+ make test # type-check, then the tests against the built package
162
+ make preview # serve dist/ with COOP/COEP
163
+ make romsets # regenerate data/romsets/*.json from the pinned FBNeo checkout
136
164
  ```
137
165
 
138
- WebAssembly threads/SharedArrayBuffer require the COOP/COEP headers that
139
- `make preview` sets — serve the built `dist/` with those headers in production too.
166
+ `scripts/build-fbneo.sh` fetches FBNeo at a pinned commit and ports its SDL2 burner to
167
+ Emscripten: every source patch checks it applied, so a moved upstream fails the build
168
+ instead of producing a broken engine. FBNeo's own blocking loop runs under ASYNCIFY and
169
+ paces itself to the driver's frame rate on the real-time clock (not the monitor's refresh);
170
+ `scripts/web_bridge.cpp` adds the SDK's handles (pause, reset, NVRAM flush, save states).
171
+
172
+ ## Project Specs
173
+
174
+ - Contract: engine-specs 1.0 (`FbneoSDK` / `FbneoPlay`), self-contained ESM.
175
+ - Engine: FBNeo pinned (see `FBNEO_REV` in `scripts/build-fbneo.sh`), Emscripten 6.0.2, ASYNCIFY.
176
+ - Storage: OPFS `fbneo/<namespace>/` (NVRAM, hiscores, save states).
177
+ - App: `dist/fbneo/app/app.js` (EngineApp), driver identified by content.
178
+ - Records: [RECORD/](RECORD/).
140
179
 
141
- ## WASM artifacts
180
+ ## Todos
142
181
 
143
- | File | Description |
144
- |------|-------------|
145
- | `fbneo.js` | Emscripten-generated module loader (`createFbneoModule`). |
146
- | `fbneo.wasm` | Compiled FBNeo runtime. |
182
+ - Verify game-level behaviour with real ROM sets (pause, reset, states, NVRAM round trip).
183
+ - Move the FBNeo pin forward (later upstream changed the audio code the build patches).
147
184
 
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.
185
+ ## License
152
186
 
153
- See [CORE.md](CORE.md) for the mapping between upstream FBNeo capabilities and the
154
- options currently exposed by this wrapper.
187
+ MIT for this wrapper. FBNeo has its own non-commercial license; ROM sets and BIOS images
188
+ 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
+ }