NES / nes_browser_compat_fixed.html
4tyzap's picture
Upload nes_browser_compat_fixed.html
dba5db8 verified
Raw
History Blame Contribute Delete
29.2 kB
<!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>
<!-- Версия закреплена намеренно: не заменять на 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)!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>&nbsp;</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>