Upload nes_browser_compat_fixed.html
Browse files- 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> </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>
|