Download nes_browser_compat_fixed.html from 4tyzap/NES: direct link, hf CLI and curl.
- Browser
- Download file 29.2 kB
-
https://huggingface.co/4tyzap/NES/resolve/main/nes_browser_compat_fixed.html
- Command line
-
hf download hf://4tyzap/NES/nes_browser_compat_fixed.html
-
curl -L -o nes_browser_compat_fixed.html https://huggingface.co/4tyzap/NES/resolve/main/nes_browser_compat_fixed.html
29.2 kB
| <html lang="ru"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1" /> | |
| <title>NES Browser — 50 FPS</title> | |
| <!-- Версия закреплена намеренно: не заменять на URL без номера версии. --> | |
| <script src="https://unpkg.com/jsnes@1.2.1/dist/jsnes.min.js" crossorigin="anonymous"></script> | |
| <style> | |
| :root { | |
| --bg:#0b0f14; --panel:#121925; --panel2:#0c121b; --text:#e6edf3; | |
| --muted:#91a3b5; --line:#24344a; --accent:#4cc9f0; --ok:#7cffb2; --bad:#ff7474; | |
| } | |
| * { box-sizing:border-box; } | |
| html, body { width:100%; height:100%; } | |
| body { | |
| margin:0; height:100vh; overflow:hidden; background:var(--bg); color:var(--text); | |
| font:13px/1.35 system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif; | |
| } | |
| button, input, select { font:inherit; } | |
| header { | |
| height:48px; padding:0 14px; border-bottom:1px solid #1d2a3b; | |
| display:flex; align-items:center; gap:12px; white-space:nowrap; overflow:hidden; | |
| } | |
| header h1 { margin:0; font-size:15px; } | |
| header span { color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; } | |
| code { color:#cfe7f3; } | |
| .layout { | |
| height:calc(100vh - 48px); padding:10px; display:grid; | |
| grid-template-columns:370px minmax(0,1fr); gap:10px; overflow:hidden; | |
| } | |
| .panel { background:var(--panel); border:1px solid #1d2a3b; border-radius:13px; } | |
| .controls { padding:11px; overflow:auto; min-height:0; } | |
| .game { padding:11px; min-width:0; min-height:0; overflow:hidden; display:flex; flex-direction:column; gap:9px; } | |
| .section { margin-top:13px; } | |
| .section:first-child { margin-top:0; } | |
| .title { | |
| margin:0 0 7px; color:var(--muted); font-size:11px; font-weight:800; | |
| text-transform:uppercase; letter-spacing:.75px; | |
| } | |
| label { display:block; color:var(--muted); font-size:12px; margin:7px 0 5px; } | |
| select, input[type="text"], input[type="file"], button { | |
| width:100%; min-height:36px; padding:7px 9px; color:var(--text); background:#0e1520; | |
| border:1px solid var(--line); border-radius:9px; outline:none; | |
| } | |
| select:focus, input:focus { border-color:#387697; box-shadow:0 0 0 3px rgba(76,201,240,.10); } | |
| button { cursor:pointer; background:#10202d; } | |
| button:hover { border-color:#3a6788; } | |
| button:disabled { opacity:.5; cursor:not-allowed; } | |
| button.primary { background:rgba(76,201,240,.13); border-color:rgba(76,201,240,.48); } | |
| button.danger { background:rgba(255,116,116,.10); border-color:rgba(255,116,116,.45); } | |
| .row { display:flex; gap:8px; align-items:center; } | |
| .row > * { flex:1; min-width:0; } | |
| .small { color:var(--muted); font-size:11px; } | |
| .inline { display:flex; align-items:center; gap:9px; } | |
| .inline input[type="range"] { flex:1; min-width:0; } | |
| .value { min-width:48px; text-align:right; color:#cfe7f3; font-variant-numeric:tabular-nums; } | |
| .keys { display:grid; grid-template-columns:1fr 1fr; gap:7px; } | |
| .key { | |
| padding:7px; border:1px solid var(--line); border-radius:10px; background:var(--panel2); | |
| display:grid; grid-template-columns:minmax(50px,1fr) auto; gap:6px; align-items:center; | |
| } | |
| .key strong { font-size:11px; } | |
| .key button { width:auto; min-height:29px; padding:4px 7px; font-size:11px; } | |
| .keycode { | |
| margin-top:3px; color:#cfe7f3; font-size:10px; white-space:nowrap; | |
| overflow:hidden; text-overflow:ellipsis; | |
| } | |
| .waiting { color:var(--accent); } | |
| .hud { display:flex; align-items:center; gap:13px; flex-wrap:wrap; min-height:20px; } | |
| .hud span { color:var(--muted); font-size:11px; } | |
| .hud b { color:var(--text); } | |
| .ok { color:var(--ok); } | |
| .bad { color:var(--bad); } | |
| .stage { | |
| flex:1; min-height:0; min-width:0; overflow:auto; background:#070a0e; | |
| border:1px solid var(--line); border-radius:12px; display:flex; align-items:flex-start; | |
| justify-content:flex-start; padding:10px; | |
| } | |
| canvas { | |
| flex:0 0 auto; display:block; background:#000; image-rendering:pixelated; | |
| image-rendering:crisp-edges; border:1px solid #26384e; border-radius:5px; | |
| } | |
| .log { | |
| height:91px; flex:0 0 91px; overflow:auto; padding:8px; background:var(--panel2); | |
| border:1px solid var(--line); border-radius:10px; white-space:pre-wrap; | |
| font:10px/1.35 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; | |
| } | |
| .hint { | |
| padding:7px 8px; margin-top:7px; border-left:3px solid rgba(76,201,240,.55); | |
| border-radius:8px; background:rgba(76,201,240,.06); color:#cfe7f3; font-size:11px; | |
| } | |
| @media (max-width:900px) { | |
| body { overflow:auto; height:auto; min-height:100%; } | |
| .layout { height:auto; min-height:calc(100vh - 48px); grid-template-columns:1fr; overflow:visible; } | |
| .controls { overflow:visible; } | |
| .game { min-height:620px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>NES в браузере</h1> | |
| <span>репозиторий <code>4tyzap/NES</code> · JSNES 1.2.1 (compatibility core) · фиксированные 50 FPS</span> | |
| </header> | |
| <div class="layout"> | |
| <aside class="panel controls"> | |
| <section class="section"> | |
| <h2 class="title">Игра</h2> | |
| <select id="gameSelect" disabled> | |
| <option value="">Загрузка списка ROM…</option> | |
| </select> | |
| <div class="row" style="margin-top:8px"> | |
| <button id="startBtn" class="primary" disabled>Старт</button> | |
| <button id="stopBtn" class="danger">Стоп</button> | |
| </div> | |
| </section> | |
| <section class="section"> | |
| <h2 class="title">Масштаб видео</h2> | |
| <div class="inline"> | |
| <input id="scaleInput" type="range" min="1" max="5" step="0.25" value="2.5" /> | |
| <span id="scaleValue" class="value">2.50×</span> | |
| </div> | |
| </section> | |
| <section class="section"> | |
| <h2 class="title">Сохранение</h2> | |
| <div class="row"> | |
| <div> | |
| <label for="slotInput">Индекс файла</label> | |
| <input id="slotInput" type="text" inputmode="numeric" maxlength="3" value="001" /> | |
| </div> | |
| <div> | |
| <label> </label> | |
| <button id="nextSlotBtn">Следующий</button> | |
| </div> | |
| </div> | |
| <div id="saveName" class="small" style="margin-top:5px">Game001.sav</div> | |
| <div class="row" style="margin-top:8px"> | |
| <button id="saveBtn" class="primary">Сохранить .sav</button> | |
| <label style="margin:0"> | |
| <input id="loadInput" type="file" accept=".sav,application/json" /> | |
| </label> | |
| </div> | |
| <div class="hint">Новый save содержит состояние, настройки и копию ROM. При загрузке сначала используется ROM из файла, затем — репозиторий.</div> | |
| </section> | |
| <section class="section"> | |
| <h2 class="title">Звук</h2> | |
| <div class="row"> | |
| <button id="audioBtn">Включить аудио</button> | |
| <button id="muteBtn">🔊 ON</button> | |
| </div> | |
| <div class="inline" style="margin-top:7px"> | |
| <input id="volumeInput" type="range" min="0" max="1" step="0.01" value="0.60" /> | |
| <span id="volumeValue" class="value">0.60</span> | |
| </div> | |
| </section> | |
| <section class="section"> | |
| <h2 class="title">Управление</h2> | |
| <div id="keys" class="keys"></div> | |
| <div class="hint">Turbo A и Turbo B автоматически повторяют A/B, пока назначенная клавиша удерживается. По умолчанию: X и Y.</div> | |
| </section> | |
| </aside> | |
| <main class="panel game"> | |
| <div class="hud"> | |
| <span>Статус: <b id="status">ожидание</b></span> | |
| <span>FPS: <b id="fps">0</b></span> | |
| <span>ROM: <b id="romName">—</b></span> | |
| <span>Core: <b>1.2.1</b></span> | |
| </div> | |
| <div class="stage"><canvas id="screen" width="256" height="240"></canvas></div> | |
| <div id="log" class="log"></div> | |
| </main> | |
| </div> | |
| <script> | |
| (() => { | |
| "use strict"; | |
| const CORE_VERSION = "1.2.1"; | |
| const FIXED_FPS = 50; | |
| const FRAME_MS = 1000 / FIXED_FPS; | |
| const HF_REPO_ID = "4tyzap/NES"; | |
| const HF_REVISION = "main"; | |
| const REPO_TYPES = ["models", "datasets", "spaces"]; | |
| const STORAGE_KEYS = "nes-keymap-v5"; | |
| const $ = (id) => document.getElementById(id); | |
| const logBox = $("log"); | |
| function log(message, type = "info") { | |
| const mark = type === "error" ? "✖" : type === "ok" ? "✔" : "•"; | |
| const time = new Date().toLocaleTimeString("ru-RU", { hour12:false }); | |
| logBox.textContent = `${mark} [${time}] ${message}\n` + logBox.textContent; | |
| } | |
| function setStatus(text, type = "") { | |
| const element = $("status"); | |
| element.textContent = text; | |
| element.className = type; | |
| } | |
| window.addEventListener("error", (event) => { | |
| if (!window.jsnes) { | |
| setStatus("ядро не загружено", "bad"); | |
| log("Не удалось загрузить JSNES 1.2.1 (compatibility core). Проверьте доступ к unpkg.com.", "error"); | |
| } else if (event.error) { | |
| log(`Ошибка страницы: ${event.error.message || event.message}`, "error"); | |
| } | |
| }); | |
| if (!window.jsnes) { | |
| setStatus("ядро не загружено", "bad"); | |
| log("JSNES не найден. Для первого запуска требуется доступ к CDN.", "error"); | |
| return; | |
| } | |
| const DEFAULT_KEYMAP = { | |
| UP:"ArrowUp", DOWN:"ArrowDown", LEFT:"ArrowLeft", RIGHT:"ArrowRight", | |
| A:"KeyK", B:"KeyJ", TURBO_A:"KeyX", TURBO_B:"KeyY", | |
| START:"Enter", SELECT:"ShiftRight" | |
| }; | |
| const CONTROL_ORDER = ["UP","DOWN","LEFT","RIGHT","A","B","TURBO_A","TURBO_B","START","SELECT"]; | |
| const CONTROL_LABELS = { | |
| UP:"Вверх", DOWN:"Вниз", LEFT:"Влево", RIGHT:"Вправо", A:"A", B:"B", | |
| TURBO_A:"Turbo A", TURBO_B:"Turbo B", START:"Start", SELECT:"Select" | |
| }; | |
| function loadKeymap() { | |
| try { return { ...DEFAULT_KEYMAP, ...JSON.parse(localStorage.getItem(STORAGE_KEYS) || "{}") }; } | |
| catch { return { ...DEFAULT_KEYMAP }; } | |
| } | |
| let keymap = loadKeymap(); | |
| let waitingControl = null; | |
| const canvas = $("screen"); | |
| const context = canvas.getContext("2d", { alpha:false, desynchronized:true }); | |
| context.imageSmoothingEnabled = false; | |
| const image = context.createImageData(256, 240); | |
| const rgba = image.data; | |
| let nes = null; | |
| let currentRom = null; | |
| let currentRomBytes = null; | |
| let detectedRepoType = null; | |
| let running = false; | |
| let loopToken = 0; | |
| let measuredFrames = 0; | |
| let fpsStartedAt = performance.now(); | |
| const romCache = new Map(); | |
| const pressedCodes = new Set(); | |
| let audioContext = null; | |
| let audioNode = null; | |
| let gainNode = null; | |
| let muted = false; | |
| let audioLeft = null; | |
| let audioRight = null; | |
| let audioSize = 0; | |
| let audioRead = 0; | |
| let audioWrite = 0; | |
| let audioCount = 0; | |
| function resetAudioBuffer(sampleRate = 48000) { | |
| audioSize = Math.max(8192, Math.floor(sampleRate * 1.2)); | |
| audioLeft = new Float32Array(audioSize); | |
| audioRight = new Float32Array(audioSize); | |
| audioRead = audioWrite = audioCount = 0; | |
| } | |
| function pushAudio(left, right) { | |
| if (!audioLeft) return; | |
| if (audioCount === audioSize) { | |
| audioRead = (audioRead + 1) % audioSize; | |
| audioCount--; | |
| } | |
| audioLeft[audioWrite] = left; | |
| audioRight[audioWrite] = right; | |
| audioWrite = (audioWrite + 1) % audioSize; | |
| audioCount++; | |
| } | |
| async function enableAudio() { | |
| const AudioContextClass = window.AudioContext || window.webkitAudioContext; | |
| if (!AudioContextClass) { | |
| log("Web Audio API не поддерживается браузером.", "error"); | |
| return false; | |
| } | |
| if (!audioContext) { | |
| audioContext = new AudioContextClass(); | |
| resetAudioBuffer(audioContext.sampleRate); | |
| gainNode = audioContext.createGain(); | |
| gainNode.gain.value = Number($("volumeInput").value); | |
| audioNode = audioContext.createScriptProcessor(2048, 0, 2); | |
| audioNode.onaudioprocess = (event) => { | |
| const left = event.outputBuffer.getChannelData(0); | |
| const right = event.outputBuffer.getChannelData(1); | |
| for (let i = 0; i < left.length; i++) { | |
| if (muted || audioCount === 0) { | |
| left[i] = 0; right[i] = 0; | |
| } else { | |
| left[i] = audioLeft[audioRead]; | |
| right[i] = audioRight[audioRead]; | |
| audioRead = (audioRead + 1) % audioSize; | |
| audioCount--; | |
| } | |
| } | |
| }; | |
| audioNode.connect(gainNode); | |
| gainNode.connect(audioContext.destination); | |
| } | |
| if (audioContext.state === "suspended") await audioContext.resume(); | |
| $("audioBtn").textContent = "Аудио готово"; | |
| log(`Аудио включено: ${audioContext.sampleRate} Hz.`, "ok"); | |
| return true; | |
| } | |
| function renderFrame(frameBuffer) { | |
| // JSNES 1.x возвращает 24-битный цвет в формате 0xBBGGRR. | |
| // ImageData ожидает последовательность RGBA, поэтому младший байт — красный. | |
| for (let i = 0, p = 0; i < frameBuffer.length; i++, p += 4) { | |
| const bgr = frameBuffer[i] >>> 0; | |
| rgba[p] = bgr & 0xff; | |
| rgba[p + 1] = (bgr >>> 8) & 0xff; | |
| rgba[p + 2] = (bgr >>> 16) & 0xff; | |
| rgba[p + 3] = 0xff; | |
| } | |
| context.putImageData(image, 0, 0); | |
| measuredFrames++; | |
| const now = performance.now(); | |
| if (now - fpsStartedAt >= 1000) { | |
| $("fps").textContent = String(Math.round(measuredFrames * 1000 / (now - fpsStartedAt))); | |
| measuredFrames = 0; | |
| fpsStartedAt = now; | |
| } | |
| } | |
| function createNES() { | |
| return new jsnes.NES({ | |
| emulateSound:true, | |
| preferredFrameRate:FIXED_FPS, | |
| sampleRate:audioContext ? audioContext.sampleRate : 48000, | |
| onFrame:renderFrame, | |
| onAudioSample:pushAudio, | |
| onStatusUpdate:(status) => status && log(status) | |
| }); | |
| } | |
| const turboHeld = { TURBO_A:false, TURBO_B:false }; | |
| let turboFrame = 0; | |
| const TURBO_HALF_PERIOD_FRAMES = 2; | |
| function releaseAllButtons() { | |
| turboHeld.TURBO_A = false; | |
| turboHeld.TURBO_B = false; | |
| turboFrame = 0; | |
| if (nes) { | |
| const C = jsnes.Controller; | |
| [C.BUTTON_A,C.BUTTON_B,C.BUTTON_SELECT,C.BUTTON_START,C.BUTTON_UP,C.BUTTON_DOWN, | |
| C.BUTTON_LEFT,C.BUTTON_RIGHT].forEach((button) => nes.buttonUp(1, button)); | |
| } | |
| pressedCodes.clear(); | |
| } | |
| function turboBaseAction(action) { | |
| return action === "TURBO_A" ? "A" : "B"; | |
| } | |
| function isTurboActiveFor(action) { | |
| return action === "A" ? turboHeld.TURBO_A : action === "B" ? turboHeld.TURBO_B : false; | |
| } | |
| function updateTurboButtons() { | |
| if (!nes) return; | |
| turboFrame++; | |
| const pulseDown = Math.floor(turboFrame / TURBO_HALF_PERIOD_FRAMES) % 2 === 0; | |
| for (const turboAction of ["TURBO_A", "TURBO_B"]) { | |
| if (!turboHeld[turboAction]) continue; | |
| const baseAction = turboBaseAction(turboAction); | |
| const button = controllerButton(baseAction); | |
| if (pulseDown) nes.buttonDown(1, button); | |
| else nes.buttonUp(1, button); | |
| } | |
| } | |
| function stopLoop() { | |
| running = false; | |
| loopToken++; | |
| releaseAllButtons(); | |
| setStatus("остановлено"); | |
| } | |
| function startLoop() { | |
| if (!nes) return; | |
| stopLoop(); | |
| running = true; | |
| const token = ++loopToken; | |
| setStatus("игра", "ok"); | |
| measuredFrames = 0; | |
| fpsStartedAt = performance.now(); | |
| let previous = performance.now(); | |
| let accumulator = 0; | |
| const tick = (now) => { | |
| if (!running || token !== loopToken || !nes) return; | |
| const elapsed = Math.min(100, now - previous); | |
| previous = now; | |
| accumulator += elapsed; | |
| let frames = 0; | |
| while (accumulator >= FRAME_MS && frames < 4) { | |
| try { | |
| updateTurboButtons(); | |
| nes.frame(); | |
| } | |
| catch (error) { | |
| running = false; | |
| setStatus("ошибка", "bad"); | |
| log(`Ошибка эмуляции: ${error.message || error}`, "error"); | |
| return; | |
| } | |
| accumulator -= FRAME_MS; | |
| frames++; | |
| } | |
| requestAnimationFrame(tick); | |
| }; | |
| requestAnimationFrame(tick); | |
| } | |
| function controllerButton(action) { | |
| const C = jsnes.Controller; | |
| return { | |
| A:C.BUTTON_A, B:C.BUTTON_B, SELECT:C.BUTTON_SELECT, START:C.BUTTON_START, | |
| UP:C.BUTTON_UP, DOWN:C.BUTTON_DOWN, LEFT:C.BUTTON_LEFT, RIGHT:C.BUTTON_RIGHT, | |
| }[action]; | |
| } | |
| function actionForCode(code) { | |
| return CONTROL_ORDER.find((action) => keymap[action] === code) || null; | |
| } | |
| window.addEventListener("keydown", (event) => { | |
| if (waitingControl) { | |
| event.preventDefault(); | |
| keymap[waitingControl] = event.code; | |
| localStorage.setItem(STORAGE_KEYS, JSON.stringify(keymap)); | |
| log(`${CONTROL_LABELS[waitingControl]} назначено на ${event.code}.`, "ok"); | |
| waitingControl = null; | |
| renderKeys(); | |
| return; | |
| } | |
| const action = actionForCode(event.code); | |
| if (!action || !nes || !running) return; | |
| event.preventDefault(); | |
| if (event.repeat || pressedCodes.has(event.code)) return; | |
| pressedCodes.add(event.code); | |
| if (action === "TURBO_A" || action === "TURBO_B") { | |
| turboHeld[action] = true; | |
| turboFrame = 0; | |
| nes.buttonDown(1, controllerButton(turboBaseAction(action))); | |
| return; | |
| } | |
| if (isTurboActiveFor(action)) return; | |
| const button = controllerButton(action); | |
| if (typeof button === "number") nes.buttonDown(1, button); | |
| }, { passive:false }); | |
| window.addEventListener("keyup", (event) => { | |
| const action = actionForCode(event.code); | |
| if (!action) return; | |
| event.preventDefault(); | |
| pressedCodes.delete(event.code); | |
| if (!nes) return; | |
| if (action === "TURBO_A" || action === "TURBO_B") { | |
| turboHeld[action] = false; | |
| const baseAction = turboBaseAction(action); | |
| const button = controllerButton(baseAction); | |
| if (pressedCodes.has(keymap[baseAction])) nes.buttonDown(1, button); | |
| else nes.buttonUp(1, button); | |
| return; | |
| } | |
| if (isTurboActiveFor(action)) return; | |
| const button = controllerButton(action); | |
| if (typeof button === "number") nes.buttonUp(1, button); | |
| }, { passive:false }); | |
| window.addEventListener("blur", releaseAllButtons); | |
| document.addEventListener("visibilitychange", () => { if (document.hidden) releaseAllButtons(); }); | |
| function renderKeys() { | |
| const holder = $("keys"); | |
| holder.replaceChildren(); | |
| for (const action of CONTROL_ORDER) { | |
| const item = document.createElement("div"); | |
| item.className = "key"; | |
| const info = document.createElement("div"); | |
| const title = document.createElement("strong"); | |
| title.textContent = CONTROL_LABELS[action]; | |
| const code = document.createElement("div"); | |
| code.className = "keycode" + (waitingControl === action ? " waiting" : ""); | |
| code.textContent = waitingControl === action ? "нажмите клавишу…" : keymap[action]; | |
| info.append(title, code); | |
| const button = document.createElement("button"); | |
| button.type = "button"; | |
| button.textContent = "Изменить"; | |
| button.onclick = () => { waitingControl = action; renderKeys(); }; | |
| item.append(info, button); | |
| holder.append(item); | |
| } | |
| } | |
| function encodePath(path) { | |
| return String(path).split("/").filter(Boolean).map(encodeURIComponent).join("/"); | |
| } | |
| function treeURL(repoType) { | |
| return `https://huggingface.co/api/${repoType}/${HF_REPO_ID}/tree/${HF_REVISION}?recursive=true&expand=false`; | |
| } | |
| function fileURL(rom) { | |
| const prefix = rom.repoType === "datasets" ? "datasets/" : rom.repoType === "spaces" ? "spaces/" : ""; | |
| return `https://huggingface.co/${prefix}${rom.repoId}/resolve/${encodeURIComponent(rom.revision)}/${encodePath(rom.path)}?download=true`; | |
| } | |
| async function listRoms() { | |
| const select = $("gameSelect"); | |
| setStatus("список ROM…"); | |
| for (const repoType of REPO_TYPES) { | |
| try { | |
| const response = await fetch(treeURL(repoType), { mode:"cors", cache:"no-store" }); | |
| if (!response.ok) continue; | |
| const tree = await response.json(); | |
| if (!Array.isArray(tree)) continue; | |
| const files = tree | |
| .filter((item) => item && item.type === "file" && /\.nes$/i.test(item.path || "")) | |
| .map((item) => ({ | |
| repoType, repoId:HF_REPO_ID, revision:HF_REVISION, | |
| path:item.path, name:item.path.split("/").pop() | |
| })) | |
| .sort((a,b) => a.name.localeCompare(b.name, "ru", { numeric:true })); | |
| if (!files.length) continue; | |
| detectedRepoType = repoType; | |
| select.replaceChildren(new Option("— выберите ROM —", "")); | |
| files.forEach((rom) => select.add(new Option(rom.name, JSON.stringify(rom)))); | |
| select.disabled = false; | |
| $("startBtn").disabled = false; | |
| setStatus("готово", "ok"); | |
| log(`Найдено ROM: ${files.length}. Тип репозитория: ${repoType}.`, "ok"); | |
| return; | |
| } catch (error) { | |
| log(`Проверка ${repoType}: ${error.message}`, "error"); | |
| } | |
| } | |
| select.replaceChildren(new Option("Список ROM недоступен", "")); | |
| setStatus("репозиторий недоступен", "bad"); | |
| log("Не удалось получить список ROM из 4tyzap/NES.", "error"); | |
| } | |
| async function fetchRomBytes(rom) { | |
| const url = fileURL(rom); | |
| if (romCache.has(url)) return romCache.get(url).slice(); | |
| log(`Загрузка ROM: ${rom.name}`); | |
| const response = await fetch(url, { mode:"cors", cache:"no-store", redirect:"follow" }); | |
| if (!response.ok) throw new Error(`HTTP ${response.status}`); | |
| const bytes = new Uint8Array(await response.arrayBuffer()); | |
| if (bytes.length < 16 || bytes[0] !== 0x4e || bytes[1] !== 0x45 || bytes[2] !== 0x53 || bytes[3] !== 0x1a) { | |
| throw new Error("файл не содержит корректный заголовок iNES"); | |
| } | |
| romCache.set(url, bytes.slice()); | |
| return bytes; | |
| } | |
| async function runRom(rom, state = null, embeddedBytes = null) { | |
| stopLoop(); | |
| setStatus("загрузка…"); | |
| await enableAudio().catch(() => false); | |
| resetAudioBuffer(audioContext ? audioContext.sampleRate : 48000); | |
| try { | |
| const bytes = embeddedBytes || await fetchRomBytes(rom); | |
| nes = createNES(); | |
| // Ядро 1.2.1 наиболее надёжно принимает ROM как binary string. | |
| nes.loadROM(bytesToBinaryString(bytes)); | |
| if (state) nes.fromJSON(state); | |
| currentRom = rom; | |
| currentRomBytes = bytes.slice(); | |
| $("romName").textContent = rom.name; | |
| updateSaveName(); | |
| startLoop(); | |
| log(`${state ? "Состояние восстановлено" : "ROM запущен"}: ${rom.name}.`, "ok"); | |
| } catch (error) { | |
| nes = null; | |
| currentRom = null; | |
| currentRomBytes = null; | |
| setStatus("ошибка", "bad"); | |
| log(`Не удалось запустить ROM: ${error.message || error}`, "error"); | |
| } | |
| } | |
| function bytesToBase64(bytes) { | |
| const chunk = 0x8000; | |
| let binary = ""; | |
| for (let i = 0; i < bytes.length; i += chunk) { | |
| binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); | |
| } | |
| return btoa(binary); | |
| } | |
| function base64ToBytes(base64) { | |
| const binary = atob(base64); | |
| const bytes = new Uint8Array(binary.length); | |
| for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); | |
| return bytes; | |
| } | |
| function bytesToBinaryString(bytes) { | |
| const chunk = 0x8000; | |
| let binary = ""; | |
| for (let i = 0; i < bytes.length; i += chunk) { | |
| binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); | |
| } | |
| return binary; | |
| } | |
| function safeBaseName(name) { | |
| const base = String(name || "Game").replace(/\.nes$/i, ""); | |
| const cleaned = base.replace(/[<>:"/\\|?*\x00-\x1f]/g, "").trim(); | |
| return (cleaned || "Game").slice(0, 80); | |
| } | |
| function normalizedSlot() { | |
| const numeric = Math.max(0, Math.min(999, parseInt($("slotInput").value, 10) || 1)); | |
| return String(numeric).padStart(3, "0"); | |
| } | |
| function updateSaveName() { | |
| $("slotInput").value = normalizedSlot(); | |
| $("saveName").textContent = `${safeBaseName(currentRom?.name)}${normalizedSlot()}.sav`; | |
| } | |
| function download(name, text) { | |
| const url = URL.createObjectURL(new Blob([text], { type:"application/json;charset=utf-8" })); | |
| const link = document.createElement("a"); | |
| link.href = url; | |
| link.download = name; | |
| document.body.append(link); | |
| link.click(); | |
| link.remove(); | |
| setTimeout(() => URL.revokeObjectURL(url), 1000); | |
| } | |
| function saveState() { | |
| if (!nes || !currentRom || !currentRomBytes) { | |
| log("Сначала запустите игру.", "error"); | |
| return; | |
| } | |
| releaseAllButtons(); | |
| const filename = `${safeBaseName(currentRom.name)}${normalizedSlot()}.sav`; | |
| const save = { | |
| format:"nes-browser-save", | |
| version:5, | |
| createdAt:new Date().toISOString(), | |
| emulator:{ name:"jsnes", version:CORE_VERSION, fps:FIXED_FPS }, | |
| rom:{ ...currentRom, dataBase64:bytesToBase64(currentRomBytes) }, | |
| keymap:{ ...keymap }, | |
| state:nes.toJSON() | |
| }; | |
| download(filename, JSON.stringify(save)); | |
| log(`Сохранён файл ${filename}.`, "ok"); | |
| } | |
| async function loadStateFile(file) { | |
| setStatus("чтение save…"); | |
| try { | |
| const save = JSON.parse(await file.text()); | |
| const romInfo = save.rom || save.game; | |
| if (!romInfo || !save.state) throw new Error("в файле отсутствуют rom/state"); | |
| keymap = { ...DEFAULT_KEYMAP, ...(save.keymap || {}) }; | |
| localStorage.setItem(STORAGE_KEYS, JSON.stringify(keymap)); | |
| renderKeys(); | |
| const rom = { | |
| repoType:romInfo.repoType || detectedRepoType || "models", | |
| repoId:romInfo.repoId || HF_REPO_ID, | |
| revision:romInfo.revision || HF_REVISION, | |
| path:romInfo.path, | |
| name:romInfo.name || String(romInfo.path || "Game.nes").split("/").pop() | |
| }; | |
| if (!rom.path) throw new Error("не указан путь ROM"); | |
| const embedded = romInfo.dataBase64 ? base64ToBytes(romInfo.dataBase64) : null; | |
| const match = file.name.match(/(\d{3})\.sav$/i); | |
| if (match) $("slotInput").value = match[1]; | |
| const savedVersion = save.emulator?.version; | |
| if (savedVersion && savedVersion !== CORE_VERSION) { | |
| log(`Save создан JSNES ${savedVersion}; состояние разных major-версий может быть несовместимо.`); | |
| } | |
| selectRomByPath(rom.path); | |
| await runRom(rom, save.state, embedded); | |
| } catch (error) { | |
| setStatus("ошибка save", "bad"); | |
| log(`Save не загружен: ${error.message || error}`, "error"); | |
| } | |
| } | |
| function selectRomByPath(path) { | |
| const select = $("gameSelect"); | |
| for (const option of select.options) { | |
| if (!option.value) continue; | |
| try { | |
| if (JSON.parse(option.value).path === path) { | |
| select.value = option.value; | |
| return; | |
| } | |
| } catch {} | |
| } | |
| } | |
| $("startBtn").onclick = async () => { | |
| const value = $("gameSelect").value; | |
| if (!value) { log("Выберите ROM.", "error"); return; } | |
| await runRom(JSON.parse(value)); | |
| }; | |
| $("stopBtn").onclick = () => { | |
| stopLoop(); nes = null; currentRom = null; currentRomBytes = null; | |
| $("romName").textContent = "—"; $("fps").textContent = "0"; updateSaveName(); | |
| }; | |
| $("saveBtn").onclick = saveState; | |
| $("loadInput").onchange = async (event) => { | |
| const file = event.target.files?.[0]; | |
| if (file) await loadStateFile(file); | |
| event.target.value = ""; | |
| }; | |
| $("nextSlotBtn").onclick = () => { | |
| $("slotInput").value = String((parseInt(normalizedSlot(),10) + 1) % 1000).padStart(3,"0"); | |
| updateSaveName(); | |
| }; | |
| $("slotInput").oninput = () => { | |
| $("slotInput").value = $("slotInput").value.replace(/\D/g, "").slice(0,3); | |
| updateSaveName(); | |
| }; | |
| $("scaleInput").oninput = () => { | |
| const scale = Number($("scaleInput").value); | |
| canvas.style.width = `${Math.round(256 * scale)}px`; | |
| canvas.style.height = `${Math.round(240 * scale)}px`; | |
| $("scaleValue").textContent = `${scale.toFixed(2)}×`; | |
| }; | |
| $("audioBtn").onclick = enableAudio; | |
| $("muteBtn").onclick = () => { | |
| muted = !muted; | |
| $("muteBtn").textContent = muted ? "🔇 OFF" : "🔊 ON"; | |
| }; | |
| $("volumeInput").oninput = () => { | |
| const value = Number($("volumeInput").value); | |
| $("volumeValue").textContent = value.toFixed(2); | |
| if (gainNode) gainNode.gain.value = value; | |
| }; | |
| renderKeys(); | |
| $("scaleInput").dispatchEvent(new Event("input")); | |
| updateSaveName(); | |
| log("Compatibility core 1.2.1: BGR→RGBA, binary ROM loading, frame-based turbo.", "ok"); | |
| listRoms(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |