4tyzap commited on
Commit
dba5db8
·
verified ·
1 Parent(s): 7089ad0

Upload nes_browser_compat_fixed.html

Browse files
Files changed (1) hide show
  1. nes_browser_compat_fixed.html +773 -0
nes_browser_compat_fixed.html ADDED
@@ -0,0 +1,773 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="ru">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1" />
6
+ <title>NES Browser — 50 FPS</title>
7
+
8
+ <!-- Версия закреплена намеренно: не заменять на URL без номера версии. -->
9
+ <script src="https://unpkg.com/jsnes@1.2.1/dist/jsnes.min.js" crossorigin="anonymous"></script>
10
+
11
+ <style>
12
+ :root {
13
+ --bg:#0b0f14; --panel:#121925; --panel2:#0c121b; --text:#e6edf3;
14
+ --muted:#91a3b5; --line:#24344a; --accent:#4cc9f0; --ok:#7cffb2; --bad:#ff7474;
15
+ }
16
+ * { box-sizing:border-box; }
17
+ html, body { width:100%; height:100%; }
18
+ body {
19
+ margin:0; height:100vh; overflow:hidden; background:var(--bg); color:var(--text);
20
+ font:13px/1.35 system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;
21
+ }
22
+ button, input, select { font:inherit; }
23
+ header {
24
+ height:48px; padding:0 14px; border-bottom:1px solid #1d2a3b;
25
+ display:flex; align-items:center; gap:12px; white-space:nowrap; overflow:hidden;
26
+ }
27
+ header h1 { margin:0; font-size:15px; }
28
+ header span { color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; }
29
+ code { color:#cfe7f3; }
30
+
31
+ .layout {
32
+ height:calc(100vh - 48px); padding:10px; display:grid;
33
+ grid-template-columns:370px minmax(0,1fr); gap:10px; overflow:hidden;
34
+ }
35
+ .panel { background:var(--panel); border:1px solid #1d2a3b; border-radius:13px; }
36
+ .controls { padding:11px; overflow:auto; min-height:0; }
37
+ .game { padding:11px; min-width:0; min-height:0; overflow:hidden; display:flex; flex-direction:column; gap:9px; }
38
+ .section { margin-top:13px; }
39
+ .section:first-child { margin-top:0; }
40
+ .title {
41
+ margin:0 0 7px; color:var(--muted); font-size:11px; font-weight:800;
42
+ text-transform:uppercase; letter-spacing:.75px;
43
+ }
44
+ label { display:block; color:var(--muted); font-size:12px; margin:7px 0 5px; }
45
+ select, input[type="text"], input[type="file"], button {
46
+ width:100%; min-height:36px; padding:7px 9px; color:var(--text); background:#0e1520;
47
+ border:1px solid var(--line); border-radius:9px; outline:none;
48
+ }
49
+ select:focus, input:focus { border-color:#387697; box-shadow:0 0 0 3px rgba(76,201,240,.10); }
50
+ button { cursor:pointer; background:#10202d; }
51
+ button:hover { border-color:#3a6788; }
52
+ button:disabled { opacity:.5; cursor:not-allowed; }
53
+ button.primary { background:rgba(76,201,240,.13); border-color:rgba(76,201,240,.48); }
54
+ button.danger { background:rgba(255,116,116,.10); border-color:rgba(255,116,116,.45); }
55
+ .row { display:flex; gap:8px; align-items:center; }
56
+ .row > * { flex:1; min-width:0; }
57
+ .small { color:var(--muted); font-size:11px; }
58
+ .inline { display:flex; align-items:center; gap:9px; }
59
+ .inline input[type="range"] { flex:1; min-width:0; }
60
+ .value { min-width:48px; text-align:right; color:#cfe7f3; font-variant-numeric:tabular-nums; }
61
+
62
+ .keys { display:grid; grid-template-columns:1fr 1fr; gap:7px; }
63
+ .key {
64
+ padding:7px; border:1px solid var(--line); border-radius:10px; background:var(--panel2);
65
+ display:grid; grid-template-columns:minmax(50px,1fr) auto; gap:6px; align-items:center;
66
+ }
67
+ .key strong { font-size:11px; }
68
+ .key button { width:auto; min-height:29px; padding:4px 7px; font-size:11px; }
69
+ .keycode {
70
+ margin-top:3px; color:#cfe7f3; font-size:10px; white-space:nowrap;
71
+ overflow:hidden; text-overflow:ellipsis;
72
+ }
73
+ .waiting { color:var(--accent); }
74
+
75
+ .hud { display:flex; align-items:center; gap:13px; flex-wrap:wrap; min-height:20px; }
76
+ .hud span { color:var(--muted); font-size:11px; }
77
+ .hud b { color:var(--text); }
78
+ .ok { color:var(--ok)!important; }
79
+ .bad { color:var(--bad)!important; }
80
+
81
+ .stage {
82
+ flex:1; min-height:0; min-width:0; overflow:auto; background:#070a0e;
83
+ border:1px solid var(--line); border-radius:12px; display:flex; align-items:flex-start;
84
+ justify-content:flex-start; padding:10px;
85
+ }
86
+ canvas {
87
+ flex:0 0 auto; display:block; background:#000; image-rendering:pixelated;
88
+ image-rendering:crisp-edges; border:1px solid #26384e; border-radius:5px;
89
+ }
90
+ .log {
91
+ height:91px; flex:0 0 91px; overflow:auto; padding:8px; background:var(--panel2);
92
+ border:1px solid var(--line); border-radius:10px; white-space:pre-wrap;
93
+ font:10px/1.35 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
94
+ }
95
+ .hint {
96
+ padding:7px 8px; margin-top:7px; border-left:3px solid rgba(76,201,240,.55);
97
+ border-radius:8px; background:rgba(76,201,240,.06); color:#cfe7f3; font-size:11px;
98
+ }
99
+
100
+ @media (max-width:900px) {
101
+ body { overflow:auto; height:auto; min-height:100%; }
102
+ .layout { height:auto; min-height:calc(100vh - 48px); grid-template-columns:1fr; overflow:visible; }
103
+ .controls { overflow:visible; }
104
+ .game { min-height:620px; }
105
+ }
106
+ </style>
107
+ </head>
108
+ <body>
109
+ <header>
110
+ <h1>NES в браузере</h1>
111
+ <span>репозиторий <code>4tyzap/NES</code> · JSNES 1.2.1 (compatibility core) · фиксированные 50 FPS</span>
112
+ </header>
113
+
114
+ <div class="layout">
115
+ <aside class="panel controls">
116
+ <section class="section">
117
+ <h2 class="title">Игра</h2>
118
+ <select id="gameSelect" disabled>
119
+ <option value="">Загрузка списка ROM…</option>
120
+ </select>
121
+ <div class="row" style="margin-top:8px">
122
+ <button id="startBtn" class="primary" disabled>Старт</button>
123
+ <button id="stopBtn" class="danger">Стоп</button>
124
+ </div>
125
+ </section>
126
+
127
+ <section class="section">
128
+ <h2 class="title">Масштаб видео</h2>
129
+ <div class="inline">
130
+ <input id="scaleInput" type="range" min="1" max="5" step="0.25" value="2.5" />
131
+ <span id="scaleValue" class="value">2.50×</span>
132
+ </div>
133
+ </section>
134
+
135
+ <section class="section">
136
+ <h2 class="title">Сохранение</h2>
137
+ <div class="row">
138
+ <div>
139
+ <label for="slotInput">Индекс файла</label>
140
+ <input id="slotInput" type="text" inputmode="numeric" maxlength="3" value="001" />
141
+ </div>
142
+ <div>
143
+ <label>&nbsp;</label>
144
+ <button id="nextSlotBtn">Следующий</button>
145
+ </div>
146
+ </div>
147
+ <div id="saveName" class="small" style="margin-top:5px">Game001.sav</div>
148
+ <div class="row" style="margin-top:8px">
149
+ <button id="saveBtn" class="primary">Сохранить .sav</button>
150
+ <label style="margin:0">
151
+ <input id="loadInput" type="file" accept=".sav,application/json" />
152
+ </label>
153
+ </div>
154
+ <div class="hint">Новый save содержит состояние, настройки и копию ROM. При загрузке сначала используется ROM из файла, затем — репозиторий.</div>
155
+ </section>
156
+
157
+ <section class="section">
158
+ <h2 class="title">Звук</h2>
159
+ <div class="row">
160
+ <button id="audioBtn">Включить аудио</button>
161
+ <button id="muteBtn">🔊 ON</button>
162
+ </div>
163
+ <div class="inline" style="margin-top:7px">
164
+ <input id="volumeInput" type="range" min="0" max="1" step="0.01" value="0.60" />
165
+ <span id="volumeValue" class="value">0.60</span>
166
+ </div>
167
+ </section>
168
+
169
+ <section class="section">
170
+ <h2 class="title">Управление</h2>
171
+ <div id="keys" class="keys"></div>
172
+ <div class="hint">Turbo A и Turbo B автоматически повторяют A/B, пока назначенная клавиша удерживается. По умолчанию: X и Y.</div>
173
+ </section>
174
+ </aside>
175
+
176
+ <main class="panel game">
177
+ <div class="hud">
178
+ <span>Статус: <b id="status">ожидание</b></span>
179
+ <span>FPS: <b id="fps">0</b></span>
180
+ <span>ROM: <b id="romName">—</b></span>
181
+ <span>Core: <b>1.2.1</b></span>
182
+ </div>
183
+ <div class="stage"><canvas id="screen" width="256" height="240"></canvas></div>
184
+ <div id="log" class="log"></div>
185
+ </main>
186
+ </div>
187
+
188
+ <script>
189
+ (() => {
190
+ "use strict";
191
+
192
+ const CORE_VERSION = "1.2.1";
193
+ const FIXED_FPS = 50;
194
+ const FRAME_MS = 1000 / FIXED_FPS;
195
+ const HF_REPO_ID = "4tyzap/NES";
196
+ const HF_REVISION = "main";
197
+ const REPO_TYPES = ["models", "datasets", "spaces"];
198
+ const STORAGE_KEYS = "nes-keymap-v5";
199
+
200
+ const $ = (id) => document.getElementById(id);
201
+ const logBox = $("log");
202
+
203
+ function log(message, type = "info") {
204
+ const mark = type === "error" ? "✖" : type === "ok" ? "✔" : "•";
205
+ const time = new Date().toLocaleTimeString("ru-RU", { hour12:false });
206
+ logBox.textContent = `${mark} [${time}] ${message}\n` + logBox.textContent;
207
+ }
208
+
209
+ function setStatus(text, type = "") {
210
+ const element = $("status");
211
+ element.textContent = text;
212
+ element.className = type;
213
+ }
214
+
215
+ window.addEventListener("error", (event) => {
216
+ if (!window.jsnes) {
217
+ setStatus("ядро не загружено", "bad");
218
+ log("Не удалось загрузить JSNES 1.2.1 (compatibility core). Проверьте доступ к unpkg.com.", "error");
219
+ } else if (event.error) {
220
+ log(`Ошибка страницы: ${event.error.message || event.message}`, "error");
221
+ }
222
+ });
223
+
224
+ if (!window.jsnes) {
225
+ setStatus("ядро не загружено", "bad");
226
+ log("JSNES не найден. Для первого запуска требуется доступ к CDN.", "error");
227
+ return;
228
+ }
229
+
230
+ const DEFAULT_KEYMAP = {
231
+ UP:"ArrowUp", DOWN:"ArrowDown", LEFT:"ArrowLeft", RIGHT:"ArrowRight",
232
+ A:"KeyK", B:"KeyJ", TURBO_A:"KeyX", TURBO_B:"KeyY",
233
+ START:"Enter", SELECT:"ShiftRight"
234
+ };
235
+ const CONTROL_ORDER = ["UP","DOWN","LEFT","RIGHT","A","B","TURBO_A","TURBO_B","START","SELECT"];
236
+ const CONTROL_LABELS = {
237
+ UP:"Вверх", DOWN:"Вниз", LEFT:"Влево", RIGHT:"В��раво", A:"A", B:"B",
238
+ TURBO_A:"Turbo A", TURBO_B:"Turbo B", START:"Start", SELECT:"Select"
239
+ };
240
+
241
+ function loadKeymap() {
242
+ try { return { ...DEFAULT_KEYMAP, ...JSON.parse(localStorage.getItem(STORAGE_KEYS) || "{}") }; }
243
+ catch { return { ...DEFAULT_KEYMAP }; }
244
+ }
245
+ let keymap = loadKeymap();
246
+ let waitingControl = null;
247
+
248
+ const canvas = $("screen");
249
+ const context = canvas.getContext("2d", { alpha:false, desynchronized:true });
250
+ context.imageSmoothingEnabled = false;
251
+ const image = context.createImageData(256, 240);
252
+ const rgba = image.data;
253
+
254
+ let nes = null;
255
+ let currentRom = null;
256
+ let currentRomBytes = null;
257
+ let detectedRepoType = null;
258
+ let running = false;
259
+ let loopToken = 0;
260
+ let measuredFrames = 0;
261
+ let fpsStartedAt = performance.now();
262
+ const romCache = new Map();
263
+ const pressedCodes = new Set();
264
+
265
+ let audioContext = null;
266
+ let audioNode = null;
267
+ let gainNode = null;
268
+ let muted = false;
269
+ let audioLeft = null;
270
+ let audioRight = null;
271
+ let audioSize = 0;
272
+ let audioRead = 0;
273
+ let audioWrite = 0;
274
+ let audioCount = 0;
275
+
276
+ function resetAudioBuffer(sampleRate = 48000) {
277
+ audioSize = Math.max(8192, Math.floor(sampleRate * 1.2));
278
+ audioLeft = new Float32Array(audioSize);
279
+ audioRight = new Float32Array(audioSize);
280
+ audioRead = audioWrite = audioCount = 0;
281
+ }
282
+
283
+ function pushAudio(left, right) {
284
+ if (!audioLeft) return;
285
+ if (audioCount === audioSize) {
286
+ audioRead = (audioRead + 1) % audioSize;
287
+ audioCount--;
288
+ }
289
+ audioLeft[audioWrite] = left;
290
+ audioRight[audioWrite] = right;
291
+ audioWrite = (audioWrite + 1) % audioSize;
292
+ audioCount++;
293
+ }
294
+
295
+ async function enableAudio() {
296
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext;
297
+ if (!AudioContextClass) {
298
+ log("Web Audio API не поддерживается браузером.", "error");
299
+ return false;
300
+ }
301
+ if (!audioContext) {
302
+ audioContext = new AudioContextClass();
303
+ resetAudioBuffer(audioContext.sampleRate);
304
+ gainNode = audioContext.createGain();
305
+ gainNode.gain.value = Number($("volumeInput").value);
306
+ audioNode = audioContext.createScriptProcessor(2048, 0, 2);
307
+ audioNode.onaudioprocess = (event) => {
308
+ const left = event.outputBuffer.getChannelData(0);
309
+ const right = event.outputBuffer.getChannelData(1);
310
+ for (let i = 0; i < left.length; i++) {
311
+ if (muted || audioCount === 0) {
312
+ left[i] = 0; right[i] = 0;
313
+ } else {
314
+ left[i] = audioLeft[audioRead];
315
+ right[i] = audioRight[audioRead];
316
+ audioRead = (audioRead + 1) % audioSize;
317
+ audioCount--;
318
+ }
319
+ }
320
+ };
321
+ audioNode.connect(gainNode);
322
+ gainNode.connect(audioContext.destination);
323
+ }
324
+ if (audioContext.state === "suspended") await audioContext.resume();
325
+ $("audioBtn").textContent = "Аудио готово";
326
+ log(`Аудио включено: ${audioContext.sampleRate} Hz.`, "ok");
327
+ return true;
328
+ }
329
+
330
+ function renderFrame(frameBuffer) {
331
+ // JSNES 1.x возвращает 24-битный цвет в формате 0xBBGGRR.
332
+ // ImageData ожидает последовательность RGBA, поэтому младший байт — красный.
333
+ for (let i = 0, p = 0; i < frameBuffer.length; i++, p += 4) {
334
+ const bgr = frameBuffer[i] >>> 0;
335
+ rgba[p] = bgr & 0xff;
336
+ rgba[p + 1] = (bgr >>> 8) & 0xff;
337
+ rgba[p + 2] = (bgr >>> 16) & 0xff;
338
+ rgba[p + 3] = 0xff;
339
+ }
340
+ context.putImageData(image, 0, 0);
341
+ measuredFrames++;
342
+ const now = performance.now();
343
+ if (now - fpsStartedAt >= 1000) {
344
+ $("fps").textContent = String(Math.round(measuredFrames * 1000 / (now - fpsStartedAt)));
345
+ measuredFrames = 0;
346
+ fpsStartedAt = now;
347
+ }
348
+ }
349
+
350
+ function createNES() {
351
+ return new jsnes.NES({
352
+ emulateSound:true,
353
+ preferredFrameRate:FIXED_FPS,
354
+ sampleRate:audioContext ? audioContext.sampleRate : 48000,
355
+ onFrame:renderFrame,
356
+ onAudioSample:pushAudio,
357
+ onStatusUpdate:(status) => status && log(status)
358
+ });
359
+ }
360
+
361
+ const turboHeld = { TURBO_A:false, TURBO_B:false };
362
+ let turboFrame = 0;
363
+ const TURBO_HALF_PERIOD_FRAMES = 2;
364
+
365
+ function releaseAllButtons() {
366
+ turboHeld.TURBO_A = false;
367
+ turboHeld.TURBO_B = false;
368
+ turboFrame = 0;
369
+ if (nes) {
370
+ const C = jsnes.Controller;
371
+ [C.BUTTON_A,C.BUTTON_B,C.BUTTON_SELECT,C.BUTTON_START,C.BUTTON_UP,C.BUTTON_DOWN,
372
+ C.BUTTON_LEFT,C.BUTTON_RIGHT].forEach((button) => nes.buttonUp(1, button));
373
+ }
374
+ pressedCodes.clear();
375
+ }
376
+
377
+ function turboBaseAction(action) {
378
+ return action === "TURBO_A" ? "A" : "B";
379
+ }
380
+
381
+ function isTurboActiveFor(action) {
382
+ return action === "A" ? turboHeld.TURBO_A : action === "B" ? turboHeld.TURBO_B : false;
383
+ }
384
+
385
+ function updateTurboButtons() {
386
+ if (!nes) return;
387
+ turboFrame++;
388
+ const pulseDown = Math.floor(turboFrame / TURBO_HALF_PERIOD_FRAMES) % 2 === 0;
389
+ for (const turboAction of ["TURBO_A", "TURBO_B"]) {
390
+ if (!turboHeld[turboAction]) continue;
391
+ const baseAction = turboBaseAction(turboAction);
392
+ const button = controllerButton(baseAction);
393
+ if (pulseDown) nes.buttonDown(1, button);
394
+ else nes.buttonUp(1, button);
395
+ }
396
+ }
397
+
398
+ function stopLoop() {
399
+ running = false;
400
+ loopToken++;
401
+ releaseAllButtons();
402
+ setStatus("остановлено");
403
+ }
404
+
405
+ function startLoop() {
406
+ if (!nes) return;
407
+ stopLoop();
408
+ running = true;
409
+ const token = ++loopToken;
410
+ setStatus("игра", "ok");
411
+ measuredFrames = 0;
412
+ fpsStartedAt = performance.now();
413
+
414
+ let previous = performance.now();
415
+ let accumulator = 0;
416
+ const tick = (now) => {
417
+ if (!running || token !== loopToken || !nes) return;
418
+ const elapsed = Math.min(100, now - previous);
419
+ previous = now;
420
+ accumulator += elapsed;
421
+ let frames = 0;
422
+ while (accumulator >= FRAME_MS && frames < 4) {
423
+ try {
424
+ updateTurboButtons();
425
+ nes.frame();
426
+ }
427
+ catch (error) {
428
+ running = false;
429
+ setStatus("ошибка", "bad");
430
+ log(`Ошибка эмуляции: ${error.message || error}`, "error");
431
+ return;
432
+ }
433
+ accumulator -= FRAME_MS;
434
+ frames++;
435
+ }
436
+ requestAnimationFrame(tick);
437
+ };
438
+ requestAnimationFrame(tick);
439
+ }
440
+
441
+ function controllerButton(action) {
442
+ const C = jsnes.Controller;
443
+ return {
444
+ A:C.BUTTON_A, B:C.BUTTON_B, SELECT:C.BUTTON_SELECT, START:C.BUTTON_START,
445
+ UP:C.BUTTON_UP, DOWN:C.BUTTON_DOWN, LEFT:C.BUTTON_LEFT, RIGHT:C.BUTTON_RIGHT,
446
+ }[action];
447
+ }
448
+
449
+ function actionForCode(code) {
450
+ return CONTROL_ORDER.find((action) => keymap[action] === code) || null;
451
+ }
452
+
453
+ window.addEventListener("keydown", (event) => {
454
+ if (waitingControl) {
455
+ event.preventDefault();
456
+ keymap[waitingControl] = event.code;
457
+ localStorage.setItem(STORAGE_KEYS, JSON.stringify(keymap));
458
+ log(`${CONTROL_LABELS[waitingControl]} назначено на ${event.code}.`, "ok");
459
+ waitingControl = null;
460
+ renderKeys();
461
+ return;
462
+ }
463
+ const action = actionForCode(event.code);
464
+ if (!action || !nes || !running) return;
465
+ event.preventDefault();
466
+ if (event.repeat || pressedCodes.has(event.code)) return;
467
+ pressedCodes.add(event.code);
468
+ if (action === "TURBO_A" || action === "TURBO_B") {
469
+ turboHeld[action] = true;
470
+ turboFrame = 0;
471
+ nes.buttonDown(1, controllerButton(turboBaseAction(action)));
472
+ return;
473
+ }
474
+ if (isTurboActiveFor(action)) return;
475
+ const button = controllerButton(action);
476
+ if (typeof button === "number") nes.buttonDown(1, button);
477
+ }, { passive:false });
478
+
479
+ window.addEventListener("keyup", (event) => {
480
+ const action = actionForCode(event.code);
481
+ if (!action) return;
482
+ event.preventDefault();
483
+ pressedCodes.delete(event.code);
484
+ if (!nes) return;
485
+ if (action === "TURBO_A" || action === "TURBO_B") {
486
+ turboHeld[action] = false;
487
+ const baseAction = turboBaseAction(action);
488
+ const button = controllerButton(baseAction);
489
+ if (pressedCodes.has(keymap[baseAction])) nes.buttonDown(1, button);
490
+ else nes.buttonUp(1, button);
491
+ return;
492
+ }
493
+ if (isTurboActiveFor(action)) return;
494
+ const button = controllerButton(action);
495
+ if (typeof button === "number") nes.buttonUp(1, button);
496
+ }, { passive:false });
497
+
498
+ window.addEventListener("blur", releaseAllButtons);
499
+ document.addEventListener("visibilitychange", () => { if (document.hidden) releaseAllButtons(); });
500
+
501
+ function renderKeys() {
502
+ const holder = $("keys");
503
+ holder.replaceChildren();
504
+ for (const action of CONTROL_ORDER) {
505
+ const item = document.createElement("div");
506
+ item.className = "key";
507
+ const info = document.createElement("div");
508
+ const title = document.createElement("strong");
509
+ title.textContent = CONTROL_LABELS[action];
510
+ const code = document.createElement("div");
511
+ code.className = "keycode" + (waitingControl === action ? " waiting" : "");
512
+ code.textContent = waitingControl === action ? "нажмите клавишу…" : keymap[action];
513
+ info.append(title, code);
514
+ const button = document.createElement("button");
515
+ button.type = "button";
516
+ button.textContent = "Изменить";
517
+ button.onclick = () => { waitingControl = action; renderKeys(); };
518
+ item.append(info, button);
519
+ holder.append(item);
520
+ }
521
+ }
522
+
523
+ function encodePath(path) {
524
+ return String(path).split("/").filter(Boolean).map(encodeURIComponent).join("/");
525
+ }
526
+ function treeURL(repoType) {
527
+ return `https://huggingface.co/api/${repoType}/${HF_REPO_ID}/tree/${HF_REVISION}?recursive=true&expand=false`;
528
+ }
529
+ function fileURL(rom) {
530
+ const prefix = rom.repoType === "datasets" ? "datasets/" : rom.repoType === "spaces" ? "spaces/" : "";
531
+ return `https://huggingface.co/${prefix}${rom.repoId}/resolve/${encodeURIComponent(rom.revision)}/${encodePath(rom.path)}?download=true`;
532
+ }
533
+
534
+ async function listRoms() {
535
+ const select = $("gameSelect");
536
+ setStatus("список ROM…");
537
+ for (const repoType of REPO_TYPES) {
538
+ try {
539
+ const response = await fetch(treeURL(repoType), { mode:"cors", cache:"no-store" });
540
+ if (!response.ok) continue;
541
+ const tree = await response.json();
542
+ if (!Array.isArray(tree)) continue;
543
+ const files = tree
544
+ .filter((item) => item && item.type === "file" && /\.nes$/i.test(item.path || ""))
545
+ .map((item) => ({
546
+ repoType, repoId:HF_REPO_ID, revision:HF_REVISION,
547
+ path:item.path, name:item.path.split("/").pop()
548
+ }))
549
+ .sort((a,b) => a.name.localeCompare(b.name, "ru", { numeric:true }));
550
+ if (!files.length) continue;
551
+ detectedRepoType = repoType;
552
+ select.replaceChildren(new Option("— выберите ROM —", ""));
553
+ files.forEach((rom) => select.add(new Option(rom.name, JSON.stringify(rom))));
554
+ select.disabled = false;
555
+ $("startBtn").disabled = false;
556
+ setStatus("готово", "ok");
557
+ log(`Найдено ROM: ${files.length}. Тип репозитория: ${repoType}.`, "ok");
558
+ return;
559
+ } catch (error) {
560
+ log(`Проверка ${repoType}: ${error.message}`, "error");
561
+ }
562
+ }
563
+ select.replaceChildren(new Option("Список ROM недоступен", ""));
564
+ setStatus("репозиторий недоступен", "bad");
565
+ log("Не удалось получить список ROM из 4tyzap/NES.", "error");
566
+ }
567
+
568
+ async function fetchRomBytes(rom) {
569
+ const url = fileURL(rom);
570
+ if (romCache.has(url)) return romCache.get(url).slice();
571
+ log(`Загрузка ROM: ${rom.name}`);
572
+ const response = await fetch(url, { mode:"cors", cache:"no-store", redirect:"follow" });
573
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
574
+ const bytes = new Uint8Array(await response.arrayBuffer());
575
+ if (bytes.length < 16 || bytes[0] !== 0x4e || bytes[1] !== 0x45 || bytes[2] !== 0x53 || bytes[3] !== 0x1a) {
576
+ throw new Error("файл не содержит корректный заголовок iNES");
577
+ }
578
+ romCache.set(url, bytes.slice());
579
+ return bytes;
580
+ }
581
+
582
+ async function runRom(rom, state = null, embeddedBytes = null) {
583
+ stopLoop();
584
+ setStatus("загрузка…");
585
+ await enableAudio().catch(() => false);
586
+ resetAudioBuffer(audioContext ? audioContext.sampleRate : 48000);
587
+
588
+ try {
589
+ const bytes = embeddedBytes || await fetchRomBytes(rom);
590
+ nes = createNES();
591
+ // Ядро 1.2.1 наиболее надёжно принимает ROM как binary string.
592
+ nes.loadROM(bytesToBinaryString(bytes));
593
+ if (state) nes.fromJSON(state);
594
+ currentRom = rom;
595
+ currentRomBytes = bytes.slice();
596
+ $("romName").textContent = rom.name;
597
+ updateSaveName();
598
+ startLoop();
599
+ log(`${state ? "Состояние восстановлено" : "ROM запущен"}: ${rom.name}.`, "ok");
600
+ } catch (error) {
601
+ nes = null;
602
+ currentRom = null;
603
+ currentRomBytes = null;
604
+ setStatus("ошибка", "bad");
605
+ log(`Не удалось запустить ROM: ${error.message || error}`, "error");
606
+ }
607
+ }
608
+
609
+ function bytesToBase64(bytes) {
610
+ const chunk = 0x8000;
611
+ let binary = "";
612
+ for (let i = 0; i < bytes.length; i += chunk) {
613
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
614
+ }
615
+ return btoa(binary);
616
+ }
617
+ function base64ToBytes(base64) {
618
+ const binary = atob(base64);
619
+ const bytes = new Uint8Array(binary.length);
620
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
621
+ return bytes;
622
+ }
623
+
624
+ function bytesToBinaryString(bytes) {
625
+ const chunk = 0x8000;
626
+ let binary = "";
627
+ for (let i = 0; i < bytes.length; i += chunk) {
628
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
629
+ }
630
+ return binary;
631
+ }
632
+
633
+ function safeBaseName(name) {
634
+ const base = String(name || "Game").replace(/\.nes$/i, "");
635
+ const cleaned = base.replace(/[<>:"/\\|?*\x00-\x1f]/g, "").trim();
636
+ return (cleaned || "Game").slice(0, 80);
637
+ }
638
+ function normalizedSlot() {
639
+ const numeric = Math.max(0, Math.min(999, parseInt($("slotInput").value, 10) || 1));
640
+ return String(numeric).padStart(3, "0");
641
+ }
642
+ function updateSaveName() {
643
+ $("slotInput").value = normalizedSlot();
644
+ $("saveName").textContent = `${safeBaseName(currentRom?.name)}${normalizedSlot()}.sav`;
645
+ }
646
+
647
+ function download(name, text) {
648
+ const url = URL.createObjectURL(new Blob([text], { type:"application/json;charset=utf-8" }));
649
+ const link = document.createElement("a");
650
+ link.href = url;
651
+ link.download = name;
652
+ document.body.append(link);
653
+ link.click();
654
+ link.remove();
655
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
656
+ }
657
+
658
+ function saveState() {
659
+ if (!nes || !currentRom || !currentRomBytes) {
660
+ log("Сначала запустите игру.", "error");
661
+ return;
662
+ }
663
+ releaseAllButtons();
664
+ const filename = `${safeBaseName(currentRom.name)}${normalizedSlot()}.sav`;
665
+ const save = {
666
+ format:"nes-browser-save",
667
+ version:5,
668
+ createdAt:new Date().toISOString(),
669
+ emulator:{ name:"jsnes", version:CORE_VERSION, fps:FIXED_FPS },
670
+ rom:{ ...currentRom, dataBase64:bytesToBase64(currentRomBytes) },
671
+ keymap:{ ...keymap },
672
+ state:nes.toJSON()
673
+ };
674
+ download(filename, JSON.stringify(save));
675
+ log(`Сохранён файл ${filename}.`, "ok");
676
+ }
677
+
678
+ async function loadStateFile(file) {
679
+ setStatus("чтение save…");
680
+ try {
681
+ const save = JSON.parse(await file.text());
682
+ const romInfo = save.rom || save.game;
683
+ if (!romInfo || !save.state) throw new Error("в файле отсутствуют rom/state");
684
+ keymap = { ...DEFAULT_KEYMAP, ...(save.keymap || {}) };
685
+ localStorage.setItem(STORAGE_KEYS, JSON.stringify(keymap));
686
+ renderKeys();
687
+
688
+ const rom = {
689
+ repoType:romInfo.repoType || detectedRepoType || "models",
690
+ repoId:romInfo.repoId || HF_REPO_ID,
691
+ revision:romInfo.revision || HF_REVISION,
692
+ path:romInfo.path,
693
+ name:romInfo.name || String(romInfo.path || "Game.nes").split("/").pop()
694
+ };
695
+ if (!rom.path) throw new Error("не указан путь ROM");
696
+ const embedded = romInfo.dataBase64 ? base64ToBytes(romInfo.dataBase64) : null;
697
+ const match = file.name.match(/(\d{3})\.sav$/i);
698
+ if (match) $("slotInput").value = match[1];
699
+
700
+ const savedVersion = save.emulator?.version;
701
+ if (savedVersion && savedVersion !== CORE_VERSION) {
702
+ log(`Save создан JSNES ${savedVersion}; состояние разных major-версий может быть несовместимо.`);
703
+ }
704
+ selectRomByPath(rom.path);
705
+ await runRom(rom, save.state, embedded);
706
+ } catch (error) {
707
+ setStatus("ошибка save", "bad");
708
+ log(`Save не загружен: ${error.message || error}`, "error");
709
+ }
710
+ }
711
+
712
+ function selectRomByPath(path) {
713
+ const select = $("gameSelect");
714
+ for (const option of select.options) {
715
+ if (!option.value) continue;
716
+ try {
717
+ if (JSON.parse(option.value).path === path) {
718
+ select.value = option.value;
719
+ return;
720
+ }
721
+ } catch {}
722
+ }
723
+ }
724
+
725
+ $("startBtn").onclick = async () => {
726
+ const value = $("gameSelect").value;
727
+ if (!value) { log("Выберите ROM.", "error"); return; }
728
+ await runRom(JSON.parse(value));
729
+ };
730
+ $("stopBtn").onclick = () => {
731
+ stopLoop(); nes = null; currentRom = null; currentRomBytes = null;
732
+ $("romName").textContent = "—"; $("fps").textContent = "0"; updateSaveName();
733
+ };
734
+ $("saveBtn").onclick = saveState;
735
+ $("loadInput").onchange = async (event) => {
736
+ const file = event.target.files?.[0];
737
+ if (file) await loadStateFile(file);
738
+ event.target.value = "";
739
+ };
740
+ $("nextSlotBtn").onclick = () => {
741
+ $("slotInput").value = String((parseInt(normalizedSlot(),10) + 1) % 1000).padStart(3,"0");
742
+ updateSaveName();
743
+ };
744
+ $("slotInput").oninput = () => {
745
+ $("slotInput").value = $("slotInput").value.replace(/\D/g, "").slice(0,3);
746
+ updateSaveName();
747
+ };
748
+ $("scaleInput").oninput = () => {
749
+ const scale = Number($("scaleInput").value);
750
+ canvas.style.width = `${Math.round(256 * scale)}px`;
751
+ canvas.style.height = `${Math.round(240 * scale)}px`;
752
+ $("scaleValue").textContent = `${scale.toFixed(2)}×`;
753
+ };
754
+ $("audioBtn").onclick = enableAudio;
755
+ $("muteBtn").onclick = () => {
756
+ muted = !muted;
757
+ $("muteBtn").textContent = muted ? "🔇 OFF" : "🔊 ON";
758
+ };
759
+ $("volumeInput").oninput = () => {
760
+ const value = Number($("volumeInput").value);
761
+ $("volumeValue").textContent = value.toFixed(2);
762
+ if (gainNode) gainNode.gain.value = value;
763
+ };
764
+
765
+ renderKeys();
766
+ $("scaleInput").dispatchEvent(new Event("input"));
767
+ updateSaveName();
768
+ log("Compatibility core 1.2.1: BGR→RGBA, binary ROM loading, frame-based turbo.", "ok");
769
+ listRoms();
770
+ })();
771
+ </script>
772
+ </body>
773
+ </html>