| <!doctype html> |
| <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> |
|
|
| |
| <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)!important; } |
| .bad { color:var(--bad)!important; } |
| |
| .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) { |
| |
| |
| 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(); |
| |
| 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> |
|
|