@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.
- package/README.md +112 -69
- 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 +72 -19
- package/dist/fbneo/fbneo.options.d.ts.map +1 -1
- package/dist/fbneo/fbneo.options.js +49 -48
- package/dist/fbneo/fbneo.options.js.map +1 -1
- package/dist/fbneo/fbneo.sdk.d.ts +157 -8
- package/dist/fbneo/fbneo.sdk.d.ts.map +1 -1
- package/dist/fbneo/fbneo.sdk.js +641 -181
- package/dist/fbneo/fbneo.sdk.js.map +1 -1
- package/dist/fbneo/fbneo.wasm +4 -0
- package/dist/fbneo/romset.d.ts +0 -0
- package/dist/fbneo/romset.d.ts.map +1 -1
- package/dist/fbneo/romset.js.map +1 -1
- package/{data → dist/fbneo}/romsets/atari.json +5036 -118
- package/{data → dist/fbneo}/romsets/capcom.json +3392 -848
- package/{data → dist/fbneo}/romsets/cave.json +460 -115
- package/{data → dist/fbneo}/romsets/channelf.json +200 -50
- package/{data → dist/fbneo}/romsets/coleco.json +2650 -658
- package/{data → dist/fbneo}/romsets/cps3.json +240 -60
- package/{data → dist/fbneo}/romsets/dataeast.json +1352 -338
- package/{data → dist/fbneo}/romsets/galaxian.json +1192 -298
- package/dist/fbneo/romsets/gba.json +197 -0
- package/{data → dist/fbneo}/romsets/index.json +16 -12
- package/{data → dist/fbneo}/romsets/irem.json +796 -199
- package/{data → dist/fbneo}/romsets/konami.json +1644 -411
- package/{data → dist/fbneo}/romsets/megadrive.json +11899 -2926
- package/{data → dist/fbneo}/romsets/midway.json +560 -140
- package/{data → dist/fbneo}/romsets/msx.json +7204 -1798
- package/{data → dist/fbneo}/romsets/neogeo.json +2704 -614
- package/{data → dist/fbneo}/romsets/nes.json +12815 -3155
- package/{data → dist/fbneo}/romsets/pce.json +1952 -488
- package/{data → dist/fbneo}/romsets/pgm.json +796 -199
- package/{data → dist/fbneo}/romsets/pgm2.json +32 -8
- package/{data → dist/fbneo}/romsets/pre90s.json +10002 -2084
- package/{data → dist/fbneo}/romsets/psikyo.json +160 -40
- package/{data → dist/fbneo}/romsets/pst90s.json +6400 -1597
- package/{data → dist/fbneo}/romsets/sega.json +2756 -658
- package/{data → dist/fbneo}/romsets/sg1000.json +1032 -255
- package/{data → dist/fbneo}/romsets/sms.json +6012 -1503
- package/{data → dist/fbneo}/romsets/snes.json +10991 -2816
- package/{data → dist/fbneo}/romsets/spectrum.json +13579 -3361
- package/{data → dist/fbneo}/romsets/taito.json +2496 -624
- package/{data → dist/fbneo}/romsets/toaplan.json +576 -144
- package/package.json +10 -15
- package/src/app/app.css +112 -0
- package/src/app/app.js +646 -0
- package/src/fbneo.options.ts +119 -63
- package/src/fbneo.sdk.ts +712 -211
- package/src/romset.ts +0 -0
- 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/README.md
CHANGED
|
@@ -1,44 +1,91 @@
|
|
|
1
1
|
# @wasm-gaming/fbneo-wasm
|
|
2
2
|
|
|
3
|
-
FinalBurn Neo (FBNeo)
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
blastem-wasm, rsdkv3, and rsdkv4:
|
|
8
|
+
## Install
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
contract (`EngineSDK` = `{ manifest, load }`).
|
|
52
|
+
Events: the contract's `start`, `error`, `exit`, `pause`, `resume`, plus `saves`.
|
|
17
53
|
|
|
18
|
-
##
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
assets
|
|
61
|
-
|
|
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
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
133
|
-
make build-
|
|
134
|
-
make
|
|
135
|
-
make
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
##
|
|
189
|
+
## Todos
|
|
142
190
|
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
+
}
|