@manybitsbyte/nesplayer-svelte 0.8.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/LICENSE +201 -0
- package/README.md +185 -0
- package/dist/components/ControllerPanel.svelte +709 -0
- package/dist/components/ControllerPanel.svelte.d.ts +42 -0
- package/dist/components/Screen.svelte +1532 -0
- package/dist/components/Screen.svelte.d.ts +10 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/dist/wasm/audio-worklet.d.ts +1 -0
- package/dist/wasm/audio-worklet.js +172 -0
- package/dist/wasm/nes-player.d.ts +2 -0
- package/dist/wasm/nes-player.js +2365 -0
- package/dist/wasm/nes-player.wasm +0 -0
- package/package.json +52 -0
|
@@ -0,0 +1,1532 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
4
|
+
import createNESPlayerModule from '../wasm/nes-player.js';
|
|
5
|
+
import nesPlayerWasmUrl from '../wasm/nes-player.wasm?url';
|
|
6
|
+
import audioWorkletSrc from '../wasm/audio-worklet.js?raw';
|
|
7
|
+
import ControllerPanel from './ControllerPanel.svelte';
|
|
8
|
+
import { version } from '../version.js';
|
|
9
|
+
|
|
10
|
+
interface NESModule {
|
|
11
|
+
HEAPU8: Uint8Array;
|
|
12
|
+
_malloc: (size: number) => number;
|
|
13
|
+
_free: (ptr: number) => void;
|
|
14
|
+
_init: () => void;
|
|
15
|
+
_powerOn: () => void;
|
|
16
|
+
_powerOff: () => void;
|
|
17
|
+
_reset: () => void;
|
|
18
|
+
_pause: () => void;
|
|
19
|
+
_resume: () => void;
|
|
20
|
+
_isPaused: () => number;
|
|
21
|
+
_tickFrame: (maxCycles: number) => number;
|
|
22
|
+
_loadNESROM: (ptr: number, size: number) => number;
|
|
23
|
+
_getFramebufferPtr: () => number;
|
|
24
|
+
_getPPUFrame: () => number;
|
|
25
|
+
_setButtons: (controller: number, mask: number) => void;
|
|
26
|
+
_getSRAMPtr: () => number;
|
|
27
|
+
_getSRAMSize: () => number;
|
|
28
|
+
_hasBattery: () => number;
|
|
29
|
+
_getSRAMDirty: () => number;
|
|
30
|
+
_clearSRAMDirty: () => void;
|
|
31
|
+
_getFlashDirty: () => number;
|
|
32
|
+
_clearFlashDirty: () => void;
|
|
33
|
+
_getPRGRomPtr: () => number;
|
|
34
|
+
_getPRGRomSize: () => number;
|
|
35
|
+
_setAudioSampleRate: (rate: number) => void;
|
|
36
|
+
_getAudioBufferPtr: () => number;
|
|
37
|
+
_getAudioSampleCount: () => number;
|
|
38
|
+
_clearAudioSamples: () => void;
|
|
39
|
+
_getCPUHz: () => number;
|
|
40
|
+
_getCyclesPerFrame: () => number;
|
|
41
|
+
_getRegion: () => number;
|
|
42
|
+
_setRegion: (region: number) => void;
|
|
43
|
+
_saveCPUState: () => number;
|
|
44
|
+
_getStateBufPtr: () => number;
|
|
45
|
+
_restoreCPUState: (ptr: number, size: number) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Props {
|
|
49
|
+
rom?: Uint8Array;
|
|
50
|
+
romName?: string;
|
|
51
|
+
volume?: number;
|
|
52
|
+
muted?: boolean;
|
|
53
|
+
sampleRate?: 48000 | 44100;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let {
|
|
57
|
+
rom,
|
|
58
|
+
romName,
|
|
59
|
+
volume = $bindable(1),
|
|
60
|
+
muted = $bindable(false),
|
|
61
|
+
sampleRate = $bindable<48000 | 44100>(48000),
|
|
62
|
+
}: Props = $props();
|
|
63
|
+
|
|
64
|
+
const SCREEN_W = 256;
|
|
65
|
+
const SCREEN_H = 240;
|
|
66
|
+
const ASPECT = (SCREEN_W * 8 / 7) / SCREEN_H;
|
|
67
|
+
const RING_SIZE = 4096;
|
|
68
|
+
|
|
69
|
+
const VERT_SRC = `
|
|
70
|
+
attribute vec2 a_position;
|
|
71
|
+
attribute vec2 a_texCoord;
|
|
72
|
+
varying vec2 v_texCoord;
|
|
73
|
+
void main() {
|
|
74
|
+
gl_Position = vec4(a_position, 0.0, 1.0);
|
|
75
|
+
v_texCoord = a_texCoord;
|
|
76
|
+
}
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
const FRAG_SRC = `
|
|
80
|
+
precision mediump float;
|
|
81
|
+
uniform sampler2D u_texture;
|
|
82
|
+
varying vec2 v_texCoord;
|
|
83
|
+
void main() {
|
|
84
|
+
gl_FragColor = texture2D(u_texture, v_texCoord);
|
|
85
|
+
}
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
const DEFAULT_GAMEPAD_BUTTON_MAP: Record<number, number> = {
|
|
89
|
+
0: 0x01, 1: 0x02, 8: 0x04, 9: 0x08,
|
|
90
|
+
12: 0x10, 13: 0x20, 14: 0x40, 15: 0x80,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const DEFAULT_KEYMAP: Record<string, number> = {
|
|
94
|
+
ArrowUp: 0x10,
|
|
95
|
+
ArrowDown: 0x20,
|
|
96
|
+
ArrowLeft: 0x40,
|
|
97
|
+
ArrowRight: 0x80,
|
|
98
|
+
ShiftRight: 0x04,
|
|
99
|
+
Enter: 0x08,
|
|
100
|
+
KeyZ: 0x02,
|
|
101
|
+
KeyX: 0x01,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const INPUT_CONFIG_KEY = 'nesplayer-input-config';
|
|
105
|
+
|
|
106
|
+
type InputConfig = {
|
|
107
|
+
player1Input: PlayerInput;
|
|
108
|
+
player2Input: PlayerInput;
|
|
109
|
+
player1KeyMap: Record<string, number>;
|
|
110
|
+
player2KeyMap: Record<string, number>;
|
|
111
|
+
gamepadProfiles: GamepadProfile[];
|
|
112
|
+
quickSaveKey: string;
|
|
113
|
+
quickLoadKey: string;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
let container = $state<HTMLDivElement | undefined>();
|
|
117
|
+
let canvas = $state<HTMLCanvasElement | undefined>();
|
|
118
|
+
let fileInput = $state<HTMLInputElement | undefined>();
|
|
119
|
+
let overlayTop = $state(0);
|
|
120
|
+
let overlayH = $state(0);
|
|
121
|
+
let overlayLeft = $state(0);
|
|
122
|
+
let overlayW = $state(0);
|
|
123
|
+
let nes = $state.raw<NESModule | null>(null);
|
|
124
|
+
|
|
125
|
+
let currentRomName = '';
|
|
126
|
+
let currentRomData: Uint8Array | null = null;
|
|
127
|
+
let autoPaused = $state(false);
|
|
128
|
+
|
|
129
|
+
let gl: WebGLRenderingContext | null = null;
|
|
130
|
+
let texture: WebGLTexture | null = null;
|
|
131
|
+
let program: WebGLProgram | null = null;
|
|
132
|
+
let posBuf: WebGLBuffer | null = null;
|
|
133
|
+
let texBuf: WebGLBuffer | null = null;
|
|
134
|
+
let animId: number = 0;
|
|
135
|
+
let lastTime: number = 0;
|
|
136
|
+
let cycleAccum: number = 0;
|
|
137
|
+
|
|
138
|
+
let audioCtx: AudioContext | null = null;
|
|
139
|
+
let gainNode: GainNode | null = null;
|
|
140
|
+
let headerView: Int32Array | null = null;
|
|
141
|
+
let ringView: Float32Array | null = null;
|
|
142
|
+
|
|
143
|
+
let held = new SvelteSet<string>();
|
|
144
|
+
let showOverlay = $state(false);
|
|
145
|
+
let powered = $state(false);
|
|
146
|
+
let paused = $state(false);
|
|
147
|
+
let userRegion = $state<'auto' | 'ntsc' | 'pal'>('auto');
|
|
148
|
+
let autoRegion = 0;
|
|
149
|
+
let isFullscreen = $state(false);
|
|
150
|
+
let showControllerPanel = $state(false);
|
|
151
|
+
|
|
152
|
+
type GamepadInfo = { id: string; index: number; name: string };
|
|
153
|
+
type GamepadProfile = { id: string; buttonMap: Record<number, number>; quickSaveBtn?: number | null; quickLoadBtn?: number | null };
|
|
154
|
+
type PlayerInput = { type: 'keyboard' } | { type: 'gamepad'; id: string } | { type: 'none' };
|
|
155
|
+
|
|
156
|
+
let detectedGamepads = $state<GamepadInfo[]>([]);
|
|
157
|
+
let gamepadProfiles = $state<GamepadProfile[]>([]);
|
|
158
|
+
let player1Input = $state<PlayerInput>({ type: 'keyboard' });
|
|
159
|
+
let player2Input = $state<PlayerInput>({ type: 'none' });
|
|
160
|
+
let player1KeyMap = $state<Record<string, number>>({ ...DEFAULT_KEYMAP });
|
|
161
|
+
let player2KeyMap = $state<Record<string, number>>({});
|
|
162
|
+
let quickSaveKey = $state('F5');
|
|
163
|
+
let quickLoadKey = $state('F7');
|
|
164
|
+
|
|
165
|
+
let quickSaveState = $state<Uint8Array | null>(null);
|
|
166
|
+
let stateMessage = $state<string | null>(null);
|
|
167
|
+
let stateMessageTimer = 0;
|
|
168
|
+
let gpQsSavePrev: Record<string, boolean> = {};
|
|
169
|
+
let gpQsLoadPrev: Record<string, boolean> = {};
|
|
170
|
+
|
|
171
|
+
type DisconnectWarning = { padId: string; padName: string; player: 1 | 2 };
|
|
172
|
+
let disconnectWarning = $state<DisconnectWarning | null>(null);
|
|
173
|
+
let waitingReconnect = $state<{ padId: string; player: 1 | 2 } | null>(null);
|
|
174
|
+
let disconnectAutoPaused = false;
|
|
175
|
+
let controllerPanelAutoPaused = false;
|
|
176
|
+
let configLoaded = $state(false);
|
|
177
|
+
let savedP1GamepadId = '';
|
|
178
|
+
let savedP2GamepadId = '';
|
|
179
|
+
|
|
180
|
+
function padName(id: string): string {
|
|
181
|
+
const m = id.match(/^([^(]+)/);
|
|
182
|
+
return m ? m[1].trim() : id;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function padInfo(gp: Gamepad): GamepadInfo {
|
|
186
|
+
return { id: gp.id, index: gp.index, name: padName(gp.id) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function loadInputConfig() {
|
|
190
|
+
try {
|
|
191
|
+
const raw = localStorage.getItem(INPUT_CONFIG_KEY);
|
|
192
|
+
if (!raw) { return; }
|
|
193
|
+
const cfg = JSON.parse(raw) as InputConfig;
|
|
194
|
+
if (cfg.player1Input) {
|
|
195
|
+
player1Input = cfg.player1Input;
|
|
196
|
+
if (cfg.player1Input.type === 'gamepad') { savedP1GamepadId = cfg.player1Input.id; }
|
|
197
|
+
}
|
|
198
|
+
if (cfg.player2Input) {
|
|
199
|
+
player2Input = cfg.player2Input;
|
|
200
|
+
if (cfg.player2Input.type === 'gamepad') { savedP2GamepadId = cfg.player2Input.id; }
|
|
201
|
+
}
|
|
202
|
+
if (cfg.player1KeyMap) { player1KeyMap = cfg.player1KeyMap; }
|
|
203
|
+
if (cfg.player2KeyMap) { player2KeyMap = cfg.player2KeyMap; }
|
|
204
|
+
if (cfg.gamepadProfiles) { gamepadProfiles = cfg.gamepadProfiles; }
|
|
205
|
+
if (cfg.quickSaveKey) { quickSaveKey = cfg.quickSaveKey; }
|
|
206
|
+
if (cfg.quickLoadKey) { quickLoadKey = cfg.quickLoadKey; }
|
|
207
|
+
} catch { }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function onDisconnectSwitchKeyboard() {
|
|
211
|
+
if (!disconnectWarning) { return; }
|
|
212
|
+
const { player } = disconnectWarning;
|
|
213
|
+
if (player === 1) { player1Input = { type: 'keyboard' }; }
|
|
214
|
+
else { player2Input = { type: 'keyboard' }; }
|
|
215
|
+
if (disconnectAutoPaused) {
|
|
216
|
+
nes?._resume();
|
|
217
|
+
autoPaused = false;
|
|
218
|
+
disconnectAutoPaused = false;
|
|
219
|
+
}
|
|
220
|
+
disconnectWarning = null;
|
|
221
|
+
waitingReconnect = null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function onDisconnectWait() {
|
|
225
|
+
if (!disconnectWarning) { return; }
|
|
226
|
+
waitingReconnect = { padId: disconnectWarning.padId, player: disconnectWarning.player };
|
|
227
|
+
disconnectWarning = null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function onDisconnectPowerOff() {
|
|
231
|
+
disconnectWarning = null;
|
|
232
|
+
waitingReconnect = null;
|
|
233
|
+
disconnectAutoPaused = false;
|
|
234
|
+
onPower();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function showStateMessage(msg: string) {
|
|
238
|
+
stateMessage = msg;
|
|
239
|
+
clearTimeout(stateMessageTimer);
|
|
240
|
+
stateMessageTimer = window.setTimeout(() => { stateMessage = null; }, 1500);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function quickSave() {
|
|
244
|
+
if (!nes || !powered) { return; }
|
|
245
|
+
const size = nes._saveCPUState();
|
|
246
|
+
const ptr = nes._getStateBufPtr();
|
|
247
|
+
quickSaveState = nes.HEAPU8.slice(ptr, ptr + size);
|
|
248
|
+
if (currentRomName) {
|
|
249
|
+
localStorage.setItem(`nesplayer-${currentRomName}.state`, bytesToBase64(quickSaveState));
|
|
250
|
+
}
|
|
251
|
+
showStateMessage('State saved');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function quickLoad() {
|
|
255
|
+
if (!nes || !powered || !quickSaveState) { return; }
|
|
256
|
+
const ptr = nes._malloc(quickSaveState.length);
|
|
257
|
+
nes.HEAPU8.set(quickSaveState, ptr);
|
|
258
|
+
nes._restoreCPUState(ptr, quickSaveState.length);
|
|
259
|
+
nes._free(ptr);
|
|
260
|
+
nes._resume();
|
|
261
|
+
paused = false;
|
|
262
|
+
autoPaused = false;
|
|
263
|
+
lastTime = 0;
|
|
264
|
+
cycleAccum = 0;
|
|
265
|
+
showStateMessage('State loaded');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function onContainerFocusIn() {
|
|
269
|
+
if (!nes || !autoPaused) { return; }
|
|
270
|
+
nes._resume();
|
|
271
|
+
autoPaused = false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function onContainerFocusOut(e: FocusEvent) {
|
|
275
|
+
if (container?.contains(e.relatedTarget as Node)) { return; }
|
|
276
|
+
if (!nes || paused || !powered) { return; }
|
|
277
|
+
nes._pause();
|
|
278
|
+
autoPaused = true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function onContainerDblClick() {
|
|
282
|
+
if (!nes || !powered) { return; }
|
|
283
|
+
if (paused || autoPaused) {
|
|
284
|
+
nes._resume();
|
|
285
|
+
paused = false;
|
|
286
|
+
autoPaused = false;
|
|
287
|
+
} else {
|
|
288
|
+
nes._pause();
|
|
289
|
+
paused = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function onContainerPointerDown() {
|
|
294
|
+
container?.focus({ preventScroll: true });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function onContainerMouseMove(e: MouseEvent) {
|
|
298
|
+
if (!container) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const rect = container.getBoundingClientRect();
|
|
302
|
+
const canvasRight = rect.right - overlayLeft;
|
|
303
|
+
showOverlay = canvasRight - e.clientX <= 52;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function onContainerMouseLeave() {
|
|
307
|
+
showOverlay = false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function pollPlayer(input: PlayerInput, kmap: Record<string, number>): number {
|
|
311
|
+
let mask = 0;
|
|
312
|
+
if (input.type === 'keyboard') {
|
|
313
|
+
for (const [code, bit] of Object.entries(kmap)) {
|
|
314
|
+
if (held.has(code)) { mask |= bit; }
|
|
315
|
+
}
|
|
316
|
+
} else if (input.type === 'gamepad') {
|
|
317
|
+
const pad = [...navigator.getGamepads()].find(p => p?.connected && p.id === input.id) ?? null;
|
|
318
|
+
if (pad) {
|
|
319
|
+
const profile = gamepadProfiles.find(p => p.id === input.id);
|
|
320
|
+
const bmap = profile?.buttonMap ?? DEFAULT_GAMEPAD_BUTTON_MAP;
|
|
321
|
+
for (const [idx, bit] of Object.entries(bmap)) {
|
|
322
|
+
if (pad.buttons[Number(idx)]?.pressed) { mask |= bit; }
|
|
323
|
+
}
|
|
324
|
+
if (pad.axes[0] < -0.5) { mask |= 0x40; }
|
|
325
|
+
if (pad.axes[0] > 0.5) { mask |= 0x80; }
|
|
326
|
+
if (pad.axes[1] < -0.5) { mask |= 0x10; }
|
|
327
|
+
if (pad.axes[1] > 0.5) { mask |= 0x20; }
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return mask;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function pushSamples(samples: Float32Array) {
|
|
334
|
+
if (!headerView || !ringView) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const writePos = Atomics.load(headerView, 0);
|
|
338
|
+
const readPos = Atomics.load(headerView, 1);
|
|
339
|
+
const available = RING_SIZE - ((writePos - readPos + RING_SIZE) % RING_SIZE) - 1;
|
|
340
|
+
const count = Math.min(samples.length, available);
|
|
341
|
+
for (let i = 0; i < count; i++) {
|
|
342
|
+
ringView[(writePos + i) % RING_SIZE] = samples[i];
|
|
343
|
+
}
|
|
344
|
+
Atomics.store(headerView, 0, (writePos + count) % RING_SIZE);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function getAudioSamples(module: NESModule): Float32Array {
|
|
348
|
+
const count = module._getAudioSampleCount();
|
|
349
|
+
if (count === 0) {
|
|
350
|
+
return new Float32Array(0);
|
|
351
|
+
}
|
|
352
|
+
const ptr = module._getAudioBufferPtr();
|
|
353
|
+
const samples = new Float32Array(module.HEAPU8.buffer, ptr, count);
|
|
354
|
+
const copy = new Float32Array(count);
|
|
355
|
+
copy.set(samples);
|
|
356
|
+
module._clearAudioSamples();
|
|
357
|
+
return copy;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function bytesToBase64(data: Uint8Array): string {
|
|
361
|
+
let str = '';
|
|
362
|
+
const CHUNK = 0x8000;
|
|
363
|
+
for (let i = 0; i < data.length; i += CHUNK) {
|
|
364
|
+
str += String.fromCharCode(...data.subarray(i, i + CHUNK));
|
|
365
|
+
}
|
|
366
|
+
return btoa(str);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function restoreSaves(module: NESModule) {
|
|
370
|
+
const sramSave = localStorage.getItem(`nesplayer-${currentRomName}.sram`);
|
|
371
|
+
if (sramSave && module._hasBattery()) {
|
|
372
|
+
const bytes = Uint8Array.from(atob(sramSave), c => c.charCodeAt(0));
|
|
373
|
+
module.HEAPU8.set(bytes, module._getSRAMPtr());
|
|
374
|
+
}
|
|
375
|
+
const flashSave = localStorage.getItem(`nesplayer-${currentRomName}.flash`);
|
|
376
|
+
if (flashSave) {
|
|
377
|
+
const bytes = Uint8Array.from(atob(flashSave), c => c.charCodeAt(0));
|
|
378
|
+
const ptr = module._getPRGRomPtr();
|
|
379
|
+
if (ptr && bytes.length <= module._getPRGRomSize()) {
|
|
380
|
+
module.HEAPU8.set(bytes, ptr);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const stateSave = localStorage.getItem(`nesplayer-${currentRomName}.state`);
|
|
384
|
+
quickSaveState = stateSave ? Uint8Array.from(atob(stateSave), c => c.charCodeAt(0)) : null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function flushSRAM(module: NESModule) {
|
|
388
|
+
if (!module._hasBattery() || !module._getSRAMDirty()) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const ptr = module._getSRAMPtr();
|
|
392
|
+
const size = module._getSRAMSize();
|
|
393
|
+
const data = module.HEAPU8.slice(ptr, ptr + size);
|
|
394
|
+
localStorage.setItem(`nesplayer-${currentRomName}.sram`, btoa(String.fromCharCode(...data)));
|
|
395
|
+
module._clearSRAMDirty();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function flushFlash(module: NESModule) {
|
|
399
|
+
if (!module._getFlashDirty()) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const ptr = module._getPRGRomPtr();
|
|
403
|
+
const size = module._getPRGRomSize();
|
|
404
|
+
const data = module.HEAPU8.slice(ptr, ptr + size);
|
|
405
|
+
localStorage.setItem(`nesplayer-${currentRomName}.flash`, btoa(String.fromCharCode(...data)));
|
|
406
|
+
module._clearFlashDirty();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function loadROM(module: NESModule, data: Uint8Array) {
|
|
410
|
+
const ptr = module._malloc(data.length);
|
|
411
|
+
module.HEAPU8.set(data, ptr);
|
|
412
|
+
const result = module._loadNESROM(ptr, data.length);
|
|
413
|
+
module._free(ptr);
|
|
414
|
+
if (result !== 0) {
|
|
415
|
+
throw new Error(`loadNESROM failed (${result})`);
|
|
416
|
+
}
|
|
417
|
+
restoreSaves(module);
|
|
418
|
+
autoRegion = module._getRegion();
|
|
419
|
+
if (userRegion === 'ntsc') { module._setRegion(0); }
|
|
420
|
+
else if (userRegion === 'pal') { module._setRegion(1); }
|
|
421
|
+
module._setAudioSampleRate(sampleRate);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function createShader(g: WebGLRenderingContext, type: number, src: string): WebGLShader | null {
|
|
425
|
+
const shader = g.createShader(type);
|
|
426
|
+
if (!shader) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
g.shaderSource(shader, src);
|
|
430
|
+
g.compileShader(shader);
|
|
431
|
+
if (!g.getShaderParameter(shader, g.COMPILE_STATUS)) {
|
|
432
|
+
console.error('[NESScreen] Shader error:', g.getShaderInfoLog(shader));
|
|
433
|
+
g.deleteShader(shader);
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
return shader;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function initWebGL(c: HTMLCanvasElement): boolean {
|
|
440
|
+
gl = c.getContext('webgl', { alpha: false, antialias: false });
|
|
441
|
+
if (!gl) {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const vs = createShader(gl, gl.VERTEX_SHADER, VERT_SRC);
|
|
446
|
+
const fs = createShader(gl, gl.FRAGMENT_SHADER, FRAG_SRC);
|
|
447
|
+
if (!vs || !fs) {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const prog = gl.createProgram();
|
|
452
|
+
if (!prog) {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
gl.attachShader(prog, vs);
|
|
456
|
+
gl.attachShader(prog, fs);
|
|
457
|
+
gl.linkProgram(prog);
|
|
458
|
+
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
|
459
|
+
console.error('[NESScreen] Program link error:', gl.getProgramInfoLog(prog));
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
program = prog;
|
|
463
|
+
|
|
464
|
+
posBuf = gl.createBuffer();
|
|
465
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
|
|
466
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
|
|
467
|
+
|
|
468
|
+
texBuf = gl.createBuffer();
|
|
469
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, texBuf);
|
|
470
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0,1, 1,1, 0,0, 1,0]), gl.STATIC_DRAW);
|
|
471
|
+
|
|
472
|
+
texture = gl.createTexture();
|
|
473
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
474
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
475
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
476
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
477
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
478
|
+
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function initAudio(module: NESModule) {
|
|
483
|
+
if (typeof SharedArrayBuffer === 'undefined') {
|
|
484
|
+
console.warn('[NESScreen] SharedArrayBuffer unavailable — audio disabled. Serve with COOP/COEP headers to enable.');
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (!audioCtx) {
|
|
488
|
+
audioCtx = new AudioContext({ sampleRate });
|
|
489
|
+
}
|
|
490
|
+
console.log('[NESScreen] AudioContext state:', audioCtx.state, 'sampleRate:', audioCtx.sampleRate);
|
|
491
|
+
void audioCtx.resume();
|
|
492
|
+
|
|
493
|
+
const workletBlob = new Blob([audioWorkletSrc], { type: 'text/javascript' });
|
|
494
|
+
const workletUrl = URL.createObjectURL(workletBlob);
|
|
495
|
+
await audioCtx.audioWorklet.addModule(workletUrl);
|
|
496
|
+
URL.revokeObjectURL(workletUrl);
|
|
497
|
+
|
|
498
|
+
const workletNode = new AudioWorkletNode(audioCtx, 'nes-audio-processor', {
|
|
499
|
+
numberOfInputs: 0,
|
|
500
|
+
numberOfOutputs: 1,
|
|
501
|
+
outputChannelCount: [2],
|
|
502
|
+
});
|
|
503
|
+
gainNode = audioCtx.createGain();
|
|
504
|
+
gainNode.gain.value = muted ? 0 : volume;
|
|
505
|
+
workletNode.connect(gainNode);
|
|
506
|
+
gainNode.connect(audioCtx.destination);
|
|
507
|
+
|
|
508
|
+
const sab = new SharedArrayBuffer(8 + RING_SIZE * 4);
|
|
509
|
+
headerView = new Int32Array(sab, 0, 2);
|
|
510
|
+
ringView = new Float32Array(sab, 8, RING_SIZE);
|
|
511
|
+
workletNode.port.postMessage({ type: 'init', sharedBuffer: sab });
|
|
512
|
+
|
|
513
|
+
module._setAudioSampleRate(sampleRate);
|
|
514
|
+
console.log('[NESScreen] Audio init complete. sampleCount after rate set:', module._getAudioSampleCount());
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function fitCanvas() {
|
|
518
|
+
if (!container || !canvas) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const cw = container.clientWidth;
|
|
522
|
+
const ch = container.clientHeight;
|
|
523
|
+
const ar = cw / ch;
|
|
524
|
+
let w: number, h: number;
|
|
525
|
+
if (ar > ASPECT) {
|
|
526
|
+
h = ch;
|
|
527
|
+
w = Math.round(ch * ASPECT);
|
|
528
|
+
} else {
|
|
529
|
+
w = cw;
|
|
530
|
+
h = Math.round(cw / ASPECT);
|
|
531
|
+
}
|
|
532
|
+
canvas.style.width = `${w}px`;
|
|
533
|
+
canvas.style.height = `${h}px`;
|
|
534
|
+
const dpr = window.devicePixelRatio || 1;
|
|
535
|
+
canvas.width = Math.round(w * dpr);
|
|
536
|
+
canvas.height = Math.round(h * dpr);
|
|
537
|
+
overlayTop = Math.round((ch - h) / 2);
|
|
538
|
+
overlayH = h;
|
|
539
|
+
overlayLeft = Math.round((cw - w) / 2);
|
|
540
|
+
overlayW = w;
|
|
541
|
+
|
|
542
|
+
if (nes && powered) {
|
|
543
|
+
drawFrame(nes);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function drawFrame(module: NESModule) {
|
|
548
|
+
if (!gl || !texture || !program || !posBuf || !texBuf) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const ptr = module._getFramebufferPtr();
|
|
552
|
+
const framebuf = module.HEAPU8.subarray(ptr, ptr + SCREEN_W * SCREEN_H * 4);
|
|
553
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
554
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, SCREEN_W, SCREEN_H, 0, gl.RGBA, gl.UNSIGNED_BYTE, framebuf);
|
|
555
|
+
|
|
556
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
557
|
+
gl.viewport(0, 0, canvas!.width, canvas!.height);
|
|
558
|
+
gl.useProgram(program);
|
|
559
|
+
|
|
560
|
+
const posLoc = gl.getAttribLocation(program, 'a_position');
|
|
561
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
|
|
562
|
+
gl.enableVertexAttribArray(posLoc);
|
|
563
|
+
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
|
|
564
|
+
|
|
565
|
+
const texLoc = gl.getAttribLocation(program, 'a_texCoord');
|
|
566
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, texBuf);
|
|
567
|
+
gl.enableVertexAttribArray(texLoc);
|
|
568
|
+
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
|
|
569
|
+
|
|
570
|
+
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function runFrame(currentTime: number) {
|
|
574
|
+
animId = requestAnimationFrame(runFrame);
|
|
575
|
+
|
|
576
|
+
const module = nes;
|
|
577
|
+
if (!module) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const livePads = [...navigator.getGamepads()];
|
|
582
|
+
|
|
583
|
+
if (!disconnectWarning && powered && !paused && !autoPaused) {
|
|
584
|
+
for (const player of [1, 2] as const) {
|
|
585
|
+
const inp = player === 1 ? player1Input : player2Input;
|
|
586
|
+
if (inp.type !== 'gamepad') { continue; }
|
|
587
|
+
if (!detectedGamepads.some(p => p.id === inp.id)) { continue; }
|
|
588
|
+
if (!livePads.some(p => p?.connected && p.id === inp.id)) {
|
|
589
|
+
detectedGamepads = detectedGamepads.filter(p => p.id !== inp.id);
|
|
590
|
+
module._pause();
|
|
591
|
+
disconnectAutoPaused = true;
|
|
592
|
+
autoPaused = true;
|
|
593
|
+
disconnectWarning = { padId: inp.id, padName: padName(inp.id), player };
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const pendingReconnectId = waitingReconnect?.padId ?? disconnectWarning?.padId;
|
|
600
|
+
if (pendingReconnectId) {
|
|
601
|
+
const rejoined = livePads.find(p => p?.connected && p.id === pendingReconnectId);
|
|
602
|
+
if (rejoined) {
|
|
603
|
+
if (!detectedGamepads.some(p => p.id === rejoined.id)) {
|
|
604
|
+
detectedGamepads = [...detectedGamepads, padInfo(rejoined)];
|
|
605
|
+
}
|
|
606
|
+
module._resume();
|
|
607
|
+
autoPaused = false;
|
|
608
|
+
disconnectAutoPaused = false;
|
|
609
|
+
waitingReconnect = null;
|
|
610
|
+
disconnectWarning = null;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
module._setButtons(0, pollPlayer(player1Input, player1KeyMap));
|
|
615
|
+
module._setButtons(1, pollPlayer(player2Input, player2KeyMap));
|
|
616
|
+
|
|
617
|
+
for (const inp of [player1Input, player2Input]) {
|
|
618
|
+
if (inp.type !== 'gamepad') { continue; }
|
|
619
|
+
const pad = livePads.find(p => p?.connected && p.id === inp.id) ?? null;
|
|
620
|
+
if (!pad) { continue; }
|
|
621
|
+
const prof = gamepadProfiles.find(p => p.id === inp.id);
|
|
622
|
+
const saveBtn = prof?.quickSaveBtn ?? null;
|
|
623
|
+
const loadBtn = prof?.quickLoadBtn ?? null;
|
|
624
|
+
if (saveBtn !== null && saveBtn !== undefined) {
|
|
625
|
+
const now = pad.buttons[saveBtn]?.pressed ?? false;
|
|
626
|
+
if (now && !gpQsSavePrev[inp.id]) { quickSave(); }
|
|
627
|
+
gpQsSavePrev[inp.id] = now;
|
|
628
|
+
}
|
|
629
|
+
if (loadBtn !== null && loadBtn !== undefined) {
|
|
630
|
+
const now = pad.buttons[loadBtn]?.pressed ?? false;
|
|
631
|
+
if (now && !gpQsLoadPrev[inp.id]) { quickLoad(); }
|
|
632
|
+
gpQsLoadPrev[inp.id] = now;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (module._isPaused()) {
|
|
637
|
+
lastTime = 0;
|
|
638
|
+
cycleAccum = 0;
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (lastTime === 0) {
|
|
643
|
+
lastTime = currentTime;
|
|
644
|
+
}
|
|
645
|
+
const elapsed = (currentTime - lastTime) / 1000;
|
|
646
|
+
lastTime = currentTime;
|
|
647
|
+
cycleAccum += Math.min(elapsed, 1 / 15) * module._getCPUHz();
|
|
648
|
+
|
|
649
|
+
const cpf = module._getCyclesPerFrame();
|
|
650
|
+
if (cycleAccum < cpf) {
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
let framesRun = 0;
|
|
655
|
+
while (cycleAccum >= cpf && framesRun < 4) {
|
|
656
|
+
const executed = module._tickFrame(cpf);
|
|
657
|
+
cycleAccum -= executed;
|
|
658
|
+
framesRun++;
|
|
659
|
+
if (headerView) {
|
|
660
|
+
const samples = getAudioSamples(module);
|
|
661
|
+
if (samples.length > 0) {
|
|
662
|
+
pushSamples(samples);
|
|
663
|
+
}
|
|
664
|
+
} else {
|
|
665
|
+
module._clearAudioSamples();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (module._getSRAMDirty()) {
|
|
670
|
+
flushSRAM(module);
|
|
671
|
+
}
|
|
672
|
+
if (module._getFlashDirty()) {
|
|
673
|
+
flushFlash(module);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
drawFrame(module);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
$effect(() => {
|
|
680
|
+
if (!nes || !rom) {
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
currentRomName = romName ?? '';
|
|
684
|
+
currentRomData = rom;
|
|
685
|
+
lastTime = 0;
|
|
686
|
+
cycleAccum = 0;
|
|
687
|
+
paused = false;
|
|
688
|
+
autoPaused = false;
|
|
689
|
+
powered = true;
|
|
690
|
+
nes._resume();
|
|
691
|
+
loadROM(nes, rom);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
$effect(() => {
|
|
695
|
+
const gain = muted ? 0 : volume;
|
|
696
|
+
if (!gainNode) {
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
gainNode.gain.value = gain;
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
$effect(() => {
|
|
703
|
+
if (!nes || !audioCtx || audioCtx.sampleRate === sampleRate) {
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
headerView = null;
|
|
707
|
+
ringView = null;
|
|
708
|
+
gainNode = null;
|
|
709
|
+
const old = audioCtx;
|
|
710
|
+
audioCtx = null;
|
|
711
|
+
void (async () => {
|
|
712
|
+
await old.close();
|
|
713
|
+
await initAudio(nes!);
|
|
714
|
+
})();
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
$effect(() => {
|
|
718
|
+
if (!configLoaded) { return; }
|
|
719
|
+
localStorage.setItem(INPUT_CONFIG_KEY, JSON.stringify({
|
|
720
|
+
player1Input, player2Input, player1KeyMap, player2KeyMap, gamepadProfiles,
|
|
721
|
+
quickSaveKey, quickLoadKey,
|
|
722
|
+
}));
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
$effect(() => {
|
|
726
|
+
if (!nes || !powered) { return; }
|
|
727
|
+
if (showControllerPanel) {
|
|
728
|
+
if (!paused && !autoPaused) {
|
|
729
|
+
nes._pause();
|
|
730
|
+
autoPaused = true;
|
|
731
|
+
controllerPanelAutoPaused = true;
|
|
732
|
+
}
|
|
733
|
+
} else if (controllerPanelAutoPaused) {
|
|
734
|
+
nes._resume();
|
|
735
|
+
autoPaused = false;
|
|
736
|
+
controllerPanelAutoPaused = false;
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
function clearScreen() {
|
|
741
|
+
if (!gl) {
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
gl.clearColor(0, 0, 0, 1);
|
|
745
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function onPower() {
|
|
749
|
+
if (!nes) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (powered) {
|
|
753
|
+
flushSRAM(nes);
|
|
754
|
+
flushFlash(nes);
|
|
755
|
+
nes._powerOff();
|
|
756
|
+
nes._pause();
|
|
757
|
+
nes._clearAudioSamples();
|
|
758
|
+
lastTime = 0;
|
|
759
|
+
cycleAccum = 0;
|
|
760
|
+
clearScreen();
|
|
761
|
+
powered = false;
|
|
762
|
+
paused = false;
|
|
763
|
+
} else {
|
|
764
|
+
nes._powerOn();
|
|
765
|
+
nes._resume();
|
|
766
|
+
lastTime = 0;
|
|
767
|
+
cycleAccum = 0;
|
|
768
|
+
if (currentRomData) {
|
|
769
|
+
loadROM(nes, currentRomData);
|
|
770
|
+
}
|
|
771
|
+
powered = true;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function onPauseToggle() {
|
|
776
|
+
if (!nes) { return; }
|
|
777
|
+
if (paused || autoPaused) {
|
|
778
|
+
nes._resume();
|
|
779
|
+
paused = false;
|
|
780
|
+
autoPaused = false;
|
|
781
|
+
} else {
|
|
782
|
+
nes._pause();
|
|
783
|
+
paused = true;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function onReset() {
|
|
788
|
+
if (!nes) { return; }
|
|
789
|
+
nes._reset();
|
|
790
|
+
nes._resume();
|
|
791
|
+
lastTime = 0;
|
|
792
|
+
cycleAccum = 0;
|
|
793
|
+
paused = false;
|
|
794
|
+
autoPaused = false;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function onRegionToggle() {
|
|
798
|
+
userRegion = userRegion === 'auto' ? 'ntsc' : userRegion === 'ntsc' ? 'pal' : 'auto';
|
|
799
|
+
if (!nes || !powered) { return; }
|
|
800
|
+
const r = userRegion === 'pal' ? 1 : userRegion === 'ntsc' ? 0 : autoRegion;
|
|
801
|
+
nes._reset();
|
|
802
|
+
nes._setRegion(r);
|
|
803
|
+
nes._setAudioSampleRate(sampleRate);
|
|
804
|
+
lastTime = 0;
|
|
805
|
+
cycleAccum = 0;
|
|
806
|
+
paused = false;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function onFullscreenToggle() {
|
|
810
|
+
if (!document.fullscreenElement) {
|
|
811
|
+
container?.requestFullscreen();
|
|
812
|
+
} else {
|
|
813
|
+
document.exitFullscreen();
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function onVolumeInput(e: Event) {
|
|
818
|
+
const val = parseFloat((e.target as HTMLInputElement).value);
|
|
819
|
+
volume = val;
|
|
820
|
+
if (val > 0) {
|
|
821
|
+
muted = false;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function onMuteToggle() {
|
|
826
|
+
muted = !muted;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function onInsertRomClick(e: MouseEvent) {
|
|
830
|
+
(e.currentTarget as HTMLButtonElement).blur();
|
|
831
|
+
fileInput?.click();
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function onFileChange(e: Event) {
|
|
835
|
+
const file = (e.target as HTMLInputElement).files?.[0];
|
|
836
|
+
if (!file || !nes) {
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
flushSRAM(nes);
|
|
841
|
+
flushFlash(nes);
|
|
842
|
+
nes._reset();
|
|
843
|
+
nes._clearAudioSamples();
|
|
844
|
+
|
|
845
|
+
lastTime = 0;
|
|
846
|
+
cycleAccum = 0;
|
|
847
|
+
paused = false;
|
|
848
|
+
autoPaused = false;
|
|
849
|
+
|
|
850
|
+
currentRomName = file.name.replace(/\.nes$/i, '');
|
|
851
|
+
const data = new Uint8Array(await file.arrayBuffer());
|
|
852
|
+
currentRomData = data;
|
|
853
|
+
powered = true;
|
|
854
|
+
nes._resume();
|
|
855
|
+
loadROM(nes, data);
|
|
856
|
+
|
|
857
|
+
(e.target as HTMLInputElement).value = '';
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
onMount(() => {
|
|
861
|
+
loadInputConfig();
|
|
862
|
+
configLoaded = true;
|
|
863
|
+
|
|
864
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
865
|
+
const tag = (e.target as HTMLElement)?.tagName;
|
|
866
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA') { return; }
|
|
867
|
+
if (e.code === quickSaveKey) { e.preventDefault(); quickSave(); return; }
|
|
868
|
+
if (e.code === quickLoadKey) { e.preventDefault(); quickLoad(); return; }
|
|
869
|
+
if (showControllerPanel) { return; }
|
|
870
|
+
held.add(e.code);
|
|
871
|
+
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) {
|
|
872
|
+
e.preventDefault();
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
const onKeyUp = (e: KeyboardEvent) => {
|
|
876
|
+
held.delete(e.code);
|
|
877
|
+
};
|
|
878
|
+
window.addEventListener('keydown', onKeyDown);
|
|
879
|
+
window.addEventListener('keyup', onKeyUp);
|
|
880
|
+
|
|
881
|
+
const ro = new ResizeObserver(() => fitCanvas());
|
|
882
|
+
if (container) {
|
|
883
|
+
ro.observe(container);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const onUnload = () => {
|
|
887
|
+
if (nes) {
|
|
888
|
+
flushSRAM(nes);
|
|
889
|
+
flushFlash(nes);
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
window.addEventListener('beforeunload', onUnload);
|
|
893
|
+
|
|
894
|
+
if (typeof SharedArrayBuffer !== 'undefined') {
|
|
895
|
+
audioCtx = new AudioContext({ sampleRate });
|
|
896
|
+
void audioCtx.resume();
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const resumeCtx = () => {
|
|
900
|
+
if (audioCtx?.state === 'suspended') {
|
|
901
|
+
void audioCtx.resume();
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
document.addEventListener('click', resumeCtx);
|
|
905
|
+
document.addEventListener('keydown', resumeCtx);
|
|
906
|
+
document.addEventListener('pointerdown', resumeCtx);
|
|
907
|
+
|
|
908
|
+
const onFullscreenChange = () => { isFullscreen = !!document.fullscreenElement; };
|
|
909
|
+
document.addEventListener('fullscreenchange', onFullscreenChange);
|
|
910
|
+
|
|
911
|
+
function applyAutoAssign(id: string) {
|
|
912
|
+
if (id === savedP1GamepadId && player1Input.type !== 'gamepad') {
|
|
913
|
+
player1Input = { type: 'gamepad', id };
|
|
914
|
+
} else if (id === savedP2GamepadId && player2Input.type !== 'gamepad') {
|
|
915
|
+
player2Input = { type: 'gamepad', id };
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function handlePadConnect(gp: Gamepad) {
|
|
920
|
+
if (detectedGamepads.some(p => p.id === gp.id)) { return; }
|
|
921
|
+
detectedGamepads = [...detectedGamepads, padInfo(gp)];
|
|
922
|
+
applyAutoAssign(gp.id);
|
|
923
|
+
const pendingId = waitingReconnect?.padId ?? disconnectWarning?.padId;
|
|
924
|
+
if (pendingId === gp.id) {
|
|
925
|
+
if (disconnectAutoPaused) {
|
|
926
|
+
nes?._resume();
|
|
927
|
+
autoPaused = false;
|
|
928
|
+
disconnectAutoPaused = false;
|
|
929
|
+
}
|
|
930
|
+
waitingReconnect = null;
|
|
931
|
+
disconnectWarning = null;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function handlePadDisconnect(id: string) {
|
|
936
|
+
if (!detectedGamepads.some(p => p.id === id)) { return; }
|
|
937
|
+
if (disconnectWarning?.padId === id) { return; }
|
|
938
|
+
detectedGamepads = detectedGamepads.filter(p => p.id !== id);
|
|
939
|
+
const player =
|
|
940
|
+
player1Input.type === 'gamepad' && player1Input.id === id ? 1 as const :
|
|
941
|
+
player2Input.type === 'gamepad' && player2Input.id === id ? 2 as const : null;
|
|
942
|
+
if (player !== null && powered && !paused && !autoPaused) {
|
|
943
|
+
nes?._pause();
|
|
944
|
+
disconnectAutoPaused = true;
|
|
945
|
+
autoPaused = true;
|
|
946
|
+
disconnectWarning = { padId: id, padName: padName(id), player };
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function syncGamepads() {
|
|
951
|
+
const current = [...navigator.getGamepads()].filter(Boolean) as Gamepad[];
|
|
952
|
+
for (const gp of current) {
|
|
953
|
+
handlePadConnect(gp);
|
|
954
|
+
}
|
|
955
|
+
for (const known of [...detectedGamepads]) {
|
|
956
|
+
if (!current.some(gp => gp.id === known.id)) {
|
|
957
|
+
handlePadDisconnect(known.id);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
syncGamepads();
|
|
962
|
+
const padPollInterval = setInterval(syncGamepads, 500);
|
|
963
|
+
|
|
964
|
+
const onGamepadConnected = () => syncGamepads();
|
|
965
|
+
const onGamepadDisconnected = () => syncGamepads();
|
|
966
|
+
window.addEventListener('gamepadconnected', onGamepadConnected);
|
|
967
|
+
window.addEventListener('gamepaddisconnected', onGamepadDisconnected);
|
|
968
|
+
|
|
969
|
+
void (async () => {
|
|
970
|
+
const module = await createNESPlayerModule({
|
|
971
|
+
locateFile: (f: string) => f.endsWith('.wasm') ? nesPlayerWasmUrl : f,
|
|
972
|
+
}) as NESModule;
|
|
973
|
+
module._init();
|
|
974
|
+
module._powerOn();
|
|
975
|
+
await initAudio(module);
|
|
976
|
+
nes = module;
|
|
977
|
+
if (canvas && initWebGL(canvas)) {
|
|
978
|
+
fitCanvas();
|
|
979
|
+
animId = requestAnimationFrame(runFrame);
|
|
980
|
+
}
|
|
981
|
+
})();
|
|
982
|
+
|
|
983
|
+
return () => {
|
|
984
|
+
cancelAnimationFrame(animId);
|
|
985
|
+
if (nes) {
|
|
986
|
+
flushSRAM(nes);
|
|
987
|
+
flushFlash(nes);
|
|
988
|
+
nes._powerOff();
|
|
989
|
+
}
|
|
990
|
+
void audioCtx?.close();
|
|
991
|
+
ro.disconnect();
|
|
992
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
993
|
+
window.removeEventListener('keyup', onKeyUp);
|
|
994
|
+
window.removeEventListener('beforeunload', onUnload);
|
|
995
|
+
held.clear();
|
|
996
|
+
document.removeEventListener('click', resumeCtx);
|
|
997
|
+
document.removeEventListener('keydown', resumeCtx);
|
|
998
|
+
document.removeEventListener('pointerdown', resumeCtx);
|
|
999
|
+
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
|
1000
|
+
clearInterval(padPollInterval);
|
|
1001
|
+
window.removeEventListener('gamepadconnected', onGamepadConnected);
|
|
1002
|
+
window.removeEventListener('gamepaddisconnected', onGamepadDisconnected);
|
|
1003
|
+
};
|
|
1004
|
+
});
|
|
1005
|
+
</script>
|
|
1006
|
+
|
|
1007
|
+
<div
|
|
1008
|
+
bind:this={container}
|
|
1009
|
+
class="nes-screen"
|
|
1010
|
+
role="application"
|
|
1011
|
+
aria-label="NES emulator"
|
|
1012
|
+
tabindex="-1"
|
|
1013
|
+
onpointerdown={onContainerPointerDown}
|
|
1014
|
+
ondblclick={onContainerDblClick}
|
|
1015
|
+
onmousemove={onContainerMouseMove}
|
|
1016
|
+
onmouseleave={onContainerMouseLeave}
|
|
1017
|
+
onfocusin={onContainerFocusIn}
|
|
1018
|
+
onfocusout={onContainerFocusOut}
|
|
1019
|
+
>
|
|
1020
|
+
<canvas bind:this={canvas}></canvas>
|
|
1021
|
+
|
|
1022
|
+
{#if (paused || autoPaused) && powered}
|
|
1023
|
+
<div
|
|
1024
|
+
class="pause-overlay"
|
|
1025
|
+
style="top: {overlayTop}px; left: {overlayLeft}px; width: {overlayW}px; height: {overlayH}px;"
|
|
1026
|
+
>
|
|
1027
|
+
Paused
|
|
1028
|
+
</div>
|
|
1029
|
+
{/if}
|
|
1030
|
+
|
|
1031
|
+
<div class="overlay-zone" class:visible={showOverlay} style="top: {overlayTop}px; right: {overlayLeft}px; height: {overlayH}px;">
|
|
1032
|
+
<div
|
|
1033
|
+
class="overlay-panel"
|
|
1034
|
+
role="toolbar"
|
|
1035
|
+
aria-label="Emulator controls"
|
|
1036
|
+
onclick={(e) => e.stopPropagation()}
|
|
1037
|
+
ondblclick={(e) => e.stopPropagation()}
|
|
1038
|
+
onkeydown={(e) => e.stopPropagation()}
|
|
1039
|
+
>
|
|
1040
|
+
|
|
1041
|
+
<button class="overlay-btn" class:powered data-tip={powered ? 'Power off' : 'Power on'} onclick={onPower}>
|
|
1042
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1043
|
+
<path d="M12 2v6"/>
|
|
1044
|
+
<path d="M6.4 5.4a8 8 0 1 0 11.2 0"/>
|
|
1045
|
+
</svg>
|
|
1046
|
+
</button>
|
|
1047
|
+
|
|
1048
|
+
<button class="overlay-btn" class:active={paused || autoPaused} data-tip={(paused || autoPaused) ? 'Resume' : 'Pause'} onclick={onPauseToggle}>
|
|
1049
|
+
{#if paused || autoPaused}
|
|
1050
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1051
|
+
<polygon points="5 3 19 12 5 21 5 3"/>
|
|
1052
|
+
</svg>
|
|
1053
|
+
{:else}
|
|
1054
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1055
|
+
<rect x="6" y="4" width="4" height="16"/>
|
|
1056
|
+
<rect x="14" y="4" width="4" height="16"/>
|
|
1057
|
+
</svg>
|
|
1058
|
+
{/if}
|
|
1059
|
+
</button>
|
|
1060
|
+
|
|
1061
|
+
<button class="overlay-btn" data-tip="Reset" onclick={onReset}>
|
|
1062
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1063
|
+
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
|
1064
|
+
<path d="M3 3v5h5"/>
|
|
1065
|
+
</svg>
|
|
1066
|
+
</button>
|
|
1067
|
+
|
|
1068
|
+
<div class="slider-wrap">
|
|
1069
|
+
<input
|
|
1070
|
+
type="range"
|
|
1071
|
+
class="vol-slider"
|
|
1072
|
+
min="0" max="1" step="0.01"
|
|
1073
|
+
value={muted ? 0 : volume}
|
|
1074
|
+
oninput={onVolumeInput}
|
|
1075
|
+
/>
|
|
1076
|
+
</div>
|
|
1077
|
+
|
|
1078
|
+
<button class="overlay-btn" class:active={muted} data-tip={muted ? 'Unmute' : 'Mute'} onclick={onMuteToggle}>
|
|
1079
|
+
{#if muted}
|
|
1080
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1081
|
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
|
1082
|
+
<line x1="23" y1="9" x2="17" y2="15"/>
|
|
1083
|
+
<line x1="17" y1="9" x2="23" y2="15"/>
|
|
1084
|
+
</svg>
|
|
1085
|
+
{:else}
|
|
1086
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1087
|
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
|
1088
|
+
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
|
1089
|
+
</svg>
|
|
1090
|
+
{/if}
|
|
1091
|
+
</button>
|
|
1092
|
+
|
|
1093
|
+
<button class="overlay-btn" data-tip="Insert ROM" onclick={onInsertRomClick}>
|
|
1094
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1095
|
+
<path d="M4 3H20V19H18V21H6V19H4Z"/>
|
|
1096
|
+
<path d="M6 3V12H18V3"/>
|
|
1097
|
+
<line x1="6" y1="6" x2="18" y2="6"/>
|
|
1098
|
+
</svg>
|
|
1099
|
+
</button>
|
|
1100
|
+
<input bind:this={fileInput} type="file" accept=".nes" style="display:none" onchange={onFileChange} />
|
|
1101
|
+
|
|
1102
|
+
<button class="overlay-btn" class:active={showControllerPanel} data-tip="Controllers" onclick={() => { showControllerPanel = !showControllerPanel; }}>
|
|
1103
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1104
|
+
<line x1="6" y1="12" x2="10" y2="12"/>
|
|
1105
|
+
<line x1="8" y1="10" x2="8" y2="14"/>
|
|
1106
|
+
<circle cx="15" cy="13" r="0.5" fill="currentColor"/>
|
|
1107
|
+
<circle cx="18" cy="11" r="0.5" fill="currentColor"/>
|
|
1108
|
+
<rect x="2" y="6" width="20" height="12" rx="2"/>
|
|
1109
|
+
</svg>
|
|
1110
|
+
</button>
|
|
1111
|
+
|
|
1112
|
+
<button class="overlay-btn overlay-btn--region" data-tip="Toggle region (Auto / NTSC / PAL)" onclick={onRegionToggle}>
|
|
1113
|
+
{userRegion === 'ntsc' ? 'NTSC' : userRegion === 'pal' ? 'PAL' : 'Auto'}
|
|
1114
|
+
</button>
|
|
1115
|
+
|
|
1116
|
+
<button class="overlay-btn" data-tip={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'} onclick={onFullscreenToggle}>
|
|
1117
|
+
{#if isFullscreen}
|
|
1118
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1119
|
+
<path d="M8 3v3a2 2 0 0 1-2 2H3"/>
|
|
1120
|
+
<path d="M21 8h-3a2 2 0 0 1-2-2V3"/>
|
|
1121
|
+
<path d="M3 16h3a2 2 0 0 1 2 2v3"/>
|
|
1122
|
+
<path d="M16 21v-3a2 2 0 0 1 2-2h3"/>
|
|
1123
|
+
</svg>
|
|
1124
|
+
{:else}
|
|
1125
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1126
|
+
<path d="M3 7V5a2 2 0 0 1 2-2h2"/>
|
|
1127
|
+
<path d="M17 3h2a2 2 0 0 1 2 2v2"/>
|
|
1128
|
+
<path d="M21 17v2a2 2 0 0 1-2 2h-2"/>
|
|
1129
|
+
<path d="M7 21H5a2 2 0 0 1-2-2v-2"/>
|
|
1130
|
+
</svg>
|
|
1131
|
+
{/if}
|
|
1132
|
+
</button>
|
|
1133
|
+
|
|
1134
|
+
</div>
|
|
1135
|
+
</div>
|
|
1136
|
+
|
|
1137
|
+
{#if disconnectWarning}
|
|
1138
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
1139
|
+
<div class="dc-backdrop"></div>
|
|
1140
|
+
<div
|
|
1141
|
+
class="dc-dialog"
|
|
1142
|
+
role="alertdialog"
|
|
1143
|
+
aria-label="Controller disconnected"
|
|
1144
|
+
onclick={(e) => e.stopPropagation()}
|
|
1145
|
+
ondblclick={(e) => e.stopPropagation()}
|
|
1146
|
+
>
|
|
1147
|
+
<svg class="dc-icon" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1148
|
+
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
|
1149
|
+
<line x1="12" y1="9" x2="12" y2="13"/>
|
|
1150
|
+
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
1151
|
+
</svg>
|
|
1152
|
+
<p class="dc-msg">Player {disconnectWarning.player} controller disconnected</p>
|
|
1153
|
+
<span class="dc-name">{disconnectWarning.padName}</span>
|
|
1154
|
+
<div class="dc-btns">
|
|
1155
|
+
<button class="dc-btn" onclick={onDisconnectSwitchKeyboard}>Switch to Keyboard</button>
|
|
1156
|
+
<button class="dc-btn dc-btn--wait" onclick={onDisconnectWait}>Wait for Reconnect</button>
|
|
1157
|
+
<button class="dc-btn dc-btn--off" onclick={onDisconnectPowerOff}>Power Off</button>
|
|
1158
|
+
</div>
|
|
1159
|
+
</div>
|
|
1160
|
+
{/if}
|
|
1161
|
+
|
|
1162
|
+
{#if waitingReconnect}
|
|
1163
|
+
<div class="dc-waiting" onclick={(e) => e.stopPropagation()} ondblclick={(e) => e.stopPropagation()}>
|
|
1164
|
+
<span class="dc-waiting-dot"></span>
|
|
1165
|
+
<span>P{waitingReconnect.player} — waiting for controller…</span>
|
|
1166
|
+
<button onclick={() => {
|
|
1167
|
+
if (disconnectAutoPaused) { nes?._resume(); autoPaused = false; disconnectAutoPaused = false; }
|
|
1168
|
+
waitingReconnect = null;
|
|
1169
|
+
}}>Dismiss</button>
|
|
1170
|
+
</div>
|
|
1171
|
+
{/if}
|
|
1172
|
+
|
|
1173
|
+
{#if stateMessage}
|
|
1174
|
+
<div
|
|
1175
|
+
class="state-toast"
|
|
1176
|
+
style="top: {overlayTop + overlayH - 40}px; left: {overlayLeft}px; width: {overlayW}px;"
|
|
1177
|
+
>
|
|
1178
|
+
{stateMessage}
|
|
1179
|
+
</div>
|
|
1180
|
+
{/if}
|
|
1181
|
+
|
|
1182
|
+
<div
|
|
1183
|
+
class="version-badge"
|
|
1184
|
+
class:visible={showOverlay}
|
|
1185
|
+
style="top: {overlayTop + overlayH - 24}px; left: {overlayLeft + 8}px;"
|
|
1186
|
+
>
|
|
1187
|
+
NES Player (Svelte) v{version}
|
|
1188
|
+
</div>
|
|
1189
|
+
|
|
1190
|
+
{#if showControllerPanel}
|
|
1191
|
+
<ControllerPanel
|
|
1192
|
+
defaultKeyMap={DEFAULT_KEYMAP}
|
|
1193
|
+
defaultGamepadButtonMap={DEFAULT_GAMEPAD_BUTTON_MAP}
|
|
1194
|
+
{detectedGamepads}
|
|
1195
|
+
{gamepadProfiles}
|
|
1196
|
+
{player1Input}
|
|
1197
|
+
{player2Input}
|
|
1198
|
+
{player1KeyMap}
|
|
1199
|
+
{player2KeyMap}
|
|
1200
|
+
{quickSaveKey}
|
|
1201
|
+
{quickLoadKey}
|
|
1202
|
+
onquicksavekeychange={(k) => { quickSaveKey = k; }}
|
|
1203
|
+
onquickloadkeychange={(k) => { quickLoadKey = k; }}
|
|
1204
|
+
onplayer1inputchange={(v) => { player1Input = v; }}
|
|
1205
|
+
onplayer2inputchange={(v) => { player2Input = v; }}
|
|
1206
|
+
onplayer1keymapchange={(m) => { player1KeyMap = m; }}
|
|
1207
|
+
onplayer2keymapchange={(m) => { player2KeyMap = m; }}
|
|
1208
|
+
ongpprofilechange={(p) => {
|
|
1209
|
+
gamepadProfiles = [...gamepadProfiles.filter(x => x.id !== p.id), p];
|
|
1210
|
+
}}
|
|
1211
|
+
onclose={() => { showControllerPanel = false; }}
|
|
1212
|
+
/>
|
|
1213
|
+
{/if}
|
|
1214
|
+
</div>
|
|
1215
|
+
|
|
1216
|
+
<style>
|
|
1217
|
+
.nes-screen {
|
|
1218
|
+
width: 100%;
|
|
1219
|
+
height: 100%;
|
|
1220
|
+
display: flex;
|
|
1221
|
+
align-items: center;
|
|
1222
|
+
justify-content: center;
|
|
1223
|
+
background: black;
|
|
1224
|
+
position: relative;
|
|
1225
|
+
outline: none;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
.pause-overlay {
|
|
1229
|
+
position: absolute;
|
|
1230
|
+
display: flex;
|
|
1231
|
+
align-items: center;
|
|
1232
|
+
justify-content: center;
|
|
1233
|
+
background: rgba(0, 0, 0, 0.45);
|
|
1234
|
+
color: rgba(255, 255, 255, 0.9);
|
|
1235
|
+
font-size: 1.5rem;
|
|
1236
|
+
font-weight: 600;
|
|
1237
|
+
letter-spacing: 0.15em;
|
|
1238
|
+
text-transform: uppercase;
|
|
1239
|
+
pointer-events: none;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
.overlay-zone {
|
|
1243
|
+
position: absolute;
|
|
1244
|
+
width: 52px;
|
|
1245
|
+
pointer-events: none;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
.overlay-zone.visible {
|
|
1249
|
+
pointer-events: auto;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
.overlay-panel {
|
|
1253
|
+
position: absolute;
|
|
1254
|
+
inset: 0;
|
|
1255
|
+
box-sizing: border-box;
|
|
1256
|
+
background: rgba(0, 0, 0, 0.6);
|
|
1257
|
+
display: flex;
|
|
1258
|
+
flex-direction: column;
|
|
1259
|
+
align-items: center;
|
|
1260
|
+
padding: 6px 0;
|
|
1261
|
+
gap: 4px;
|
|
1262
|
+
opacity: 0;
|
|
1263
|
+
transition: opacity 0.15s ease;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
.overlay-zone.visible .overlay-panel {
|
|
1267
|
+
opacity: 1;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
.overlay-btn {
|
|
1271
|
+
width: 36px;
|
|
1272
|
+
height: 36px;
|
|
1273
|
+
min-height: 0;
|
|
1274
|
+
border: none;
|
|
1275
|
+
border-radius: 4px;
|
|
1276
|
+
background: rgba(255, 255, 255, 0.1);
|
|
1277
|
+
color: rgba(255, 255, 255, 0.85);
|
|
1278
|
+
cursor: pointer;
|
|
1279
|
+
display: flex;
|
|
1280
|
+
align-items: center;
|
|
1281
|
+
justify-content: center;
|
|
1282
|
+
padding: 0;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
.overlay-btn:hover {
|
|
1286
|
+
background: rgba(255, 255, 255, 0.25);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
.overlay-btn.active {
|
|
1290
|
+
color: #f87171;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
.overlay-btn.powered {
|
|
1294
|
+
color: #4ade80;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
.overlay-btn--region {
|
|
1298
|
+
font-size: 0.6rem;
|
|
1299
|
+
font-weight: 700;
|
|
1300
|
+
letter-spacing: 0.05em;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
.overlay-btn[data-tip] {
|
|
1304
|
+
position: relative;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
.overlay-btn[data-tip]::after {
|
|
1308
|
+
content: attr(data-tip);
|
|
1309
|
+
position: absolute;
|
|
1310
|
+
right: calc(100% + 8px);
|
|
1311
|
+
top: 50%;
|
|
1312
|
+
transform: translateY(-50%);
|
|
1313
|
+
background: rgba(0, 0, 0, 0.85);
|
|
1314
|
+
color: rgba(255, 255, 255, 0.9);
|
|
1315
|
+
font-size: 0.68rem;
|
|
1316
|
+
font-weight: 500;
|
|
1317
|
+
padding: 3px 8px;
|
|
1318
|
+
border-radius: 4px;
|
|
1319
|
+
white-space: nowrap;
|
|
1320
|
+
pointer-events: none;
|
|
1321
|
+
opacity: 0;
|
|
1322
|
+
transition: opacity 0.12s ease 0s;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
.overlay-btn[data-tip]:hover::after {
|
|
1326
|
+
opacity: 1;
|
|
1327
|
+
transition-delay: 0.5s;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
.slider-wrap {
|
|
1332
|
+
flex: 1;
|
|
1333
|
+
width: 100%;
|
|
1334
|
+
display: flex;
|
|
1335
|
+
align-items: center;
|
|
1336
|
+
justify-content: center;
|
|
1337
|
+
overflow: hidden;
|
|
1338
|
+
min-height: 0;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
.vol-slider {
|
|
1342
|
+
writing-mode: vertical-lr;
|
|
1343
|
+
direction: rtl;
|
|
1344
|
+
width: 32px;
|
|
1345
|
+
height: 100%;
|
|
1346
|
+
cursor: pointer;
|
|
1347
|
+
accent-color: rgba(255, 255, 255, 0.85);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
.state-toast {
|
|
1351
|
+
position: absolute;
|
|
1352
|
+
display: flex;
|
|
1353
|
+
align-items: center;
|
|
1354
|
+
justify-content: center;
|
|
1355
|
+
height: 32px;
|
|
1356
|
+
background: rgba(0, 0, 0, 0.72);
|
|
1357
|
+
color: rgba(255, 255, 255, 0.88);
|
|
1358
|
+
font-size: 0.72rem;
|
|
1359
|
+
font-weight: 600;
|
|
1360
|
+
letter-spacing: 0.08em;
|
|
1361
|
+
pointer-events: none;
|
|
1362
|
+
z-index: 10;
|
|
1363
|
+
animation: toast-fade 1.5s ease forwards;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
@keyframes toast-fade {
|
|
1367
|
+
0% { opacity: 1; }
|
|
1368
|
+
60% { opacity: 1; }
|
|
1369
|
+
100% { opacity: 0; }
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
.version-badge {
|
|
1373
|
+
position: absolute;
|
|
1374
|
+
height: 20px;
|
|
1375
|
+
padding: 0 8px;
|
|
1376
|
+
display: flex;
|
|
1377
|
+
align-items: center;
|
|
1378
|
+
background: rgba(0, 0, 0, 0.5);
|
|
1379
|
+
color: rgba(255, 255, 255, 0.38);
|
|
1380
|
+
font-size: 0.6rem;
|
|
1381
|
+
font-weight: 600;
|
|
1382
|
+
letter-spacing: 0.07em;
|
|
1383
|
+
border-radius: 4px;
|
|
1384
|
+
pointer-events: none;
|
|
1385
|
+
z-index: 10;
|
|
1386
|
+
opacity: 0;
|
|
1387
|
+
transition: opacity 0.18s ease;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
.version-badge.visible {
|
|
1391
|
+
opacity: 1;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
.dc-backdrop {
|
|
1395
|
+
position: absolute;
|
|
1396
|
+
inset: 0;
|
|
1397
|
+
background: rgba(0, 0, 0, 0.65);
|
|
1398
|
+
z-index: 30;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
.dc-dialog {
|
|
1402
|
+
position: absolute;
|
|
1403
|
+
inset: 0;
|
|
1404
|
+
margin: auto;
|
|
1405
|
+
width: min(260px, calc(100% - 48px));
|
|
1406
|
+
height: fit-content;
|
|
1407
|
+
background: #0d0d1c;
|
|
1408
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
1409
|
+
border-radius: 10px;
|
|
1410
|
+
z-index: 31;
|
|
1411
|
+
padding: 20px 18px 16px;
|
|
1412
|
+
display: flex;
|
|
1413
|
+
flex-direction: column;
|
|
1414
|
+
align-items: center;
|
|
1415
|
+
gap: 6px;
|
|
1416
|
+
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
.dc-icon {
|
|
1420
|
+
margin-bottom: 2px;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
.dc-msg {
|
|
1424
|
+
font-size: 0.82rem;
|
|
1425
|
+
color: rgba(255, 255, 255, 0.85);
|
|
1426
|
+
text-align: center;
|
|
1427
|
+
margin: 0;
|
|
1428
|
+
font-weight: 600;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
.dc-name {
|
|
1432
|
+
font-size: 0.63rem;
|
|
1433
|
+
color: rgba(255, 255, 255, 0.35);
|
|
1434
|
+
text-align: center;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
.dc-btns {
|
|
1438
|
+
display: flex;
|
|
1439
|
+
flex-direction: column;
|
|
1440
|
+
gap: 6px;
|
|
1441
|
+
width: 100%;
|
|
1442
|
+
margin-top: 8px;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
.dc-btn {
|
|
1446
|
+
width: 100%;
|
|
1447
|
+
height: 30px;
|
|
1448
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
1449
|
+
border-radius: 5px;
|
|
1450
|
+
background: rgba(255, 255, 255, 0.06);
|
|
1451
|
+
color: rgba(255, 255, 255, 0.75);
|
|
1452
|
+
font-size: 0.72rem;
|
|
1453
|
+
font-weight: 600;
|
|
1454
|
+
cursor: pointer;
|
|
1455
|
+
transition: background 0.1s, border-color 0.1s, color 0.1s;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
.dc-btn:hover {
|
|
1459
|
+
background: rgba(255, 255, 255, 0.12);
|
|
1460
|
+
border-color: rgba(255, 255, 255, 0.24);
|
|
1461
|
+
color: rgba(255, 255, 255, 0.95);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
.dc-btn--wait {
|
|
1465
|
+
border-color: rgba(74, 222, 128, 0.25);
|
|
1466
|
+
color: #4ade80;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
.dc-btn--wait:hover {
|
|
1470
|
+
background: rgba(74, 222, 128, 0.1);
|
|
1471
|
+
border-color: rgba(74, 222, 128, 0.45);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
.dc-btn--off {
|
|
1475
|
+
border-color: rgba(248, 113, 113, 0.25);
|
|
1476
|
+
color: #f87171;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
.dc-btn--off:hover {
|
|
1480
|
+
background: rgba(248, 113, 113, 0.1);
|
|
1481
|
+
border-color: rgba(248, 113, 113, 0.45);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
.dc-waiting {
|
|
1485
|
+
position: absolute;
|
|
1486
|
+
bottom: 10px;
|
|
1487
|
+
left: 50%;
|
|
1488
|
+
transform: translateX(-50%);
|
|
1489
|
+
background: rgba(10, 10, 20, 0.88);
|
|
1490
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
1491
|
+
border-radius: 20px;
|
|
1492
|
+
padding: 5px 14px 5px 10px;
|
|
1493
|
+
display: flex;
|
|
1494
|
+
align-items: center;
|
|
1495
|
+
gap: 8px;
|
|
1496
|
+
z-index: 20;
|
|
1497
|
+
white-space: nowrap;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
.dc-waiting-dot {
|
|
1501
|
+
width: 6px;
|
|
1502
|
+
height: 6px;
|
|
1503
|
+
border-radius: 50%;
|
|
1504
|
+
background: #f59e0b;
|
|
1505
|
+
flex-shrink: 0;
|
|
1506
|
+
animation: dc-blink 1.2s ease-in-out infinite;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
@keyframes dc-blink {
|
|
1510
|
+
0%, 100% { opacity: 1; }
|
|
1511
|
+
50% { opacity: 0.2; }
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
.dc-waiting span {
|
|
1515
|
+
font-size: 0.68rem;
|
|
1516
|
+
color: rgba(255, 255, 255, 0.6);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
.dc-waiting button {
|
|
1520
|
+
font-size: 0.62rem;
|
|
1521
|
+
color: rgba(255, 255, 255, 0.3);
|
|
1522
|
+
background: none;
|
|
1523
|
+
border: none;
|
|
1524
|
+
cursor: pointer;
|
|
1525
|
+
padding: 0;
|
|
1526
|
+
margin-left: 2px;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
.dc-waiting button:hover {
|
|
1530
|
+
color: rgba(255, 255, 255, 0.65);
|
|
1531
|
+
}
|
|
1532
|
+
</style>
|