app.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. /* app.js — interaction layer: code validation, visual state, sound, dialogs and local persistence. */
  2. const $ = (selector) => document.querySelector(selector);
  3. let activeCode = localStorage.getItem('zke-code') || '1234';
  4. if (!/^\d{4}$/.test(activeCode) || new Set(activeCode).size !== 4) activeCode = '1234';
  5. let exitOverride = false;
  6. let entered = [];
  7. let faultId;
  8. let inputPulseId;
  9. let circuitState = 'idle';
  10. let soundEnabled = localStorage.getItem('zke-sound') === 'true';
  11. const quiz = [
  12. ['Что происходит при неверной цифре?', ['Цепь продолжает работу', 'Цепь сбрасывается', 'Дверь открывается'], 1],
  13. ['Какой элемент физически убирает защёлку?', ['Электромагнит', 'Тиристор', 'Клавиша'], 0],
  14. ['Что подаёт питание на электромагнит?', ['Транзисторный выходной каскад', 'Микроконтроллер DD1', 'Резистор R1'], 0],
  15. ['Какую функцию выполняют VD5–VD8?', ['Хранят последовательность цифр', 'Выпрямляют сетевое напряжение', 'Подсвечивают лампу'], 0],
  16. ['Какой элемент формирует выдержку после открытия?', ['C1', 'FU1', 'S1'], 0],
  17. ['Для чего нужен VD9?', ['Для подавления выброса самоиндукции YA1', 'Для выбора кода', 'Для усиления звука'], 0],
  18. ['Что происходит при нажатии S2?', ['Нагрузка включается напрямую', 'Код стирается навсегда', 'Схема переходит в режим настройки'], 0],
  19. ['Какую роль играет HL1?', ['Сигнализирует об открытии замка', 'Запоминает первую цифру', 'Понижает напряжение'], 0],
  20. ['Что соединяет наборное поле XP/XS?', ['Выбранные клавиши с каскадами кода', 'Сеть с трансформатором', 'Лампу с корпусом'], 0],
  21. ['Как закрывается замок в учебном стенде?', ['Кнопкой «Закрыть замок» или сбросом', 'Автоматически через 1 секунду', 'Только после выключения браузера'], 0]
  22. ];
  23. function tone(type = 'click') { if (!soundEnabled || !window.AudioContext) return; const ctx = new AudioContext(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); const now = ctx.currentTime; osc.type = type === 'error' ? 'sawtooth' : 'sine'; osc.frequency.setValueAtTime(type === 'success' ? 780 : type === 'error' ? 140 : 350, now); if (type === 'success') osc.frequency.exponentialRampToValueAtTime(1180, now + .12); gain.gain.setValueAtTime(.06, now); gain.gain.exponentialRampToValueAtTime(.001, now + (type === 'error' ? .3 : .12)); osc.start(now); osc.stop(now + .35); }
  24. function setLog(text) { $('#eventLog').textContent = text; }
  25. function updateCircuit(state = circuitState) {
  26. circuitState = state;
  27. const powered = state === 'open';
  28. window.lockDiagram.render(state, entered, activeCode, exitOverride);
  29. $('#closeLockButton').disabled = !powered;
  30. $('#finishPulse').disabled = !powered;
  31. $('#lockReadout').textContent = powered ? (exitOverride ? 'S2 УДЕРЖИВАЕТСЯ' : 'ДО ЗАКРЫТИЯ КНОПКОЙ') : 'ОЖИДАНИЕ КОДА';
  32. ['#backspaceButton', '#codeInput', '#applyCode', '#nextStep'].forEach(selector => {
  33. $(selector).disabled = powered || state === 'error';
  34. });
  35. $('#resetButton').disabled = false;
  36. $('#keypadReset').disabled = false;
  37. $('#exitButton').disabled = state === 'error' || (powered && !exitOverride);
  38. $('#exitButton').textContent = exitOverride ? 'S2 · Отпустить кнопку' : 'S2 · Выход изнутри';
  39. document.querySelectorAll('[data-key]').forEach(b => b.disabled = powered || state === 'error');
  40. document.querySelectorAll('[data-stage-digit]').forEach(p => p.textContent = activeCode[Number(p.dataset.stageDigit)]);
  41. $('#driverReading').textContent = `${entered.length} / 4`;
  42. $('#relayReading').textContent = powered ? (exitOverride ? 'S2 нажата' : 'Вручную') : 'Нет';
  43. $('#coilReading').textContent = powered ? 'Включён' : 'Выключен';
  44. $('#circuitStatus').textContent = powered
  45. ? (exitOverride ? 'S2 удерживается · YA1 включён напрямую' : 'Код принят · замок открыт до закрытия кнопкой')
  46. : state === 'error' ? 'Неверная цифра · тиристорная память сброшена'
  47. : entered.length ? `Проводят ${entered.length} из 4 тиристоров · ожидание следующей цифры`
  48. : 'Ожидание кода · VD5–VD8 закрыты';
  49. $('#logicText').textContent = powered
  50. ? (exitOverride ? 'S2 замкнула цепь нагрузки в обход составного ключа VT5 / VT2. YA1 включён, HL1 горит. Нажмите S2 повторно, чтобы отпустить кнопку и закрыть замок.' : 'VD5–VD8 проводят. Составной ключ VT5 / VT2 включил YA1; HL1 горит. Замок и схема остаются в открытом состоянии до нажатия «Закрыть замок» или «Сбросить».')
  51. : state === 'error' ? 'Сработала цепь сброса R19 / C3 и управляющий каскад: ток удержания цепочки прерван, VD5–VD8 закрыты. Начните набор сначала.'
  52. : entered.length ? `Цифра ${entered[entered.length-1]} принята: VD${entered.length+4} проводит. Ток удержания сохраняет состояние. ${entered.length < 4 ? `Следующая ступень — VD${entered.length+5}.` : ''} Электромагнит пока выключен.`
  53. : 'VD5–VD8 закрыты. Выпрямители питают схему, но импульс открытия отсутствует. YA1 обесточен; пружина удерживает защёлку.';
  54. }
  55. function clearInputPulse() {
  56. clearTimeout(inputPulseId);
  57. inputPulseId = undefined;
  58. window.lockDiagram.clearPulse();
  59. }
  60. function pulseInput(key) {
  61. clearInputPulse();
  62. window.lockDiagram.pulse(key);
  63. inputPulseId = setTimeout(clearInputPulse, 650);
  64. }
  65. function updateScreen() {
  66. $('#codeDisplay').textContent = [...entered, ...Array(4 - entered.length).fill('—')].join(' ');
  67. $('#progressNumber').textContent = `${entered.length}/4`;
  68. updateCircuit();
  69. }
  70. function reset(message = 'Система сброшена. Ожидание нового ввода.') {
  71. exitOverride = false;
  72. clearTimeout(faultId);
  73. faultId = undefined;
  74. clearInputPulse();
  75. entered = [];
  76. circuitState = 'idle';
  77. $('#lockStage').classList.remove('is-open');
  78. $('#lockPill').classList.remove('is-open');
  79. $('#lockPill').textContent = 'ЗАКРЫТ';
  80. $('#lamp').className = 'lamp';
  81. $('#powerDot').classList.remove('is-on');
  82. updateScreen();
  83. setLog(message);
  84. }
  85. function openLock(fromExit = false) {
  86. exitOverride = fromExit;
  87. updateCircuit('open');
  88. $('#lockStage').classList.add('is-open');
  89. $('#lockPill').classList.add('is-open');
  90. $('#lockPill').textContent = 'ОТКРЫТ';
  91. $('#lamp').className = 'lamp is-success';
  92. $('#powerDot').classList.add('is-on');
  93. setLog(fromExit ? 'S2 удерживается. Прямое питание YA1; нажмите S2 повторно, чтобы отпустить.' : 'Код принят. VT5 / VT2 включили YA1. Замок открыт до нажатия «Закрыть замок» или «Сбросить».');
  94. tone('success');
  95. }
  96. function incorrect() {
  97. entered = [];
  98. circuitState = 'error';
  99. updateScreen();
  100. $('#lamp').className = 'lamp is-error';
  101. setLog('Неверная цифра — ввод сброшен. Тиристорная память сброшена, YA1 выключен.');
  102. tone('error');
  103. faultId = setTimeout(() => reset(), 700);
  104. }
  105. function input(key) {
  106. if (circuitState === 'open' || circuitState === 'error' || entered.length >= 4) return;
  107. tone();
  108. pulseInput(key);
  109. const candidate = entered.join('') + key;
  110. if (!activeCode.startsWith(candidate)) return incorrect();
  111. entered.push(key);
  112. updateScreen();
  113. if (candidate === activeCode) return openLock();
  114. setLog(`Введено ${entered.length} из 4 цифр. Очередной тиристор проводит; набор ещё не завершён.`);
  115. }
  116. function applyCode() {
  117. const value = $('#codeInput').value.trim();
  118. if (!/^\d{4}$/.test(value) || new Set(value).size !== 4) {
  119. $('#codeHint').textContent = 'Нужны четыре разные цифры: у каждой клавиши оригинала один провод к наборному полю.';
  120. $('#codeHint').style.color = 'var(--danger)'; return;
  121. }
  122. activeCode = value; localStorage.setItem('zke-code', value);
  123. $('#codeInput').value = value;
  124. $('#codeHint').textContent = 'Соединения XP/XS обновлены в модели. Код сохранён в этом браузере.';
  125. $('#codeHint').style.color = ''; $('#saveNote').textContent = 'сохранено сейчас';
  126. reset('Наборное поле перенастроено. Можно вводить новый код.');
  127. }
  128. function renderQuiz(index = 0, score = 0) { const host = $('#quizContent'); if (index === quiz.length) { host.innerHTML = `<h2>Готово</h2><p class="quiz-result">${score} из ${quiz.length} — ${score === quiz.length ? 'идеально!' : 'ещё один заход?'}</p><button class="button button--primary" id="restartQuiz">Пройти ещё раз</button>`; $('#restartQuiz').onclick = () => renderQuiz(); return; } const [question, options, answer] = quiz[index]; host.innerHTML = `<p class="quiz-progress">ВОПРОС ${index + 1} / ${quiz.length}</p><h2>${question}</h2><div class="quiz-options">${options.map((option, i) => `<button data-answer="${i}">${option}</button>`).join('')}</div>`; host.querySelectorAll('button').forEach(button => button.onclick = () => renderQuiz(index + 1, score + (+button.dataset.answer === answer))); }
  129. $('#codeInput').value = activeCode; updateScreen();
  130. $('#keypad').addEventListener('click', e => { const key = e.target.dataset.key; if (key) input(key); });
  131. $('#closeLockButton').onclick = () => reset('Замок закрыт кнопкой. YA1 выключен; пружина вернула защёлку.');
  132. $('#finishPulse').onclick = $('#closeLockButton').onclick;
  133. $('#nextStep').onclick = () => input(activeCode[entered.length]);
  134. $('#exitButton').onclick = () => { if (exitOverride) return reset('S2 отпущена. YA1 выключен; пружина вернула защёлку.'); clearInputPulse(); entered = []; updateScreen(); openLock(true); };
  135. $('#keypadReset').onclick = () => reset(); $('#resetButton').onclick = () => reset(); $('#backspaceButton').onclick = () => reset('Тиристорная память сброшена целиком. Начните с первой цифры.');
  136. $('#applyCode').onclick = applyCode; $('#codeInput').addEventListener('keydown', e => { if (e.key === 'Enter') applyCode(); });
  137. document.addEventListener('keydown', e => {
  138. if (document.querySelector('dialog[open]:not(#circuitModal)') || e.target.closest('input, select, textarea, [contenteditable="true"]') || e.ctrlKey || e.altKey || e.metaKey) return;
  139. if (/^[0-9]$/.test(e.key)) { e.preventDefault(); input(e.key); }
  140. });
  141. const extrasModal = $('#extrasModal');
  142. extrasModal.addEventListener('close', () => $('#extrasButton').setAttribute('aria-expanded', 'false'));
  143. extrasModal.addEventListener('click', event => {
  144. const bounds = extrasModal.getBoundingClientRect();
  145. if (event.target === extrasModal && (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom)) extrasModal.close();
  146. });
  147. function updateSoundButton() {
  148. $('#soundButton').classList.toggle('is-active', soundEnabled);
  149. $('#soundButton').setAttribute('aria-pressed', String(soundEnabled));
  150. $('#soundButton').setAttribute('aria-label', soundEnabled ? 'Выключить звук' : 'Включить звук');
  151. }
  152. updateSoundButton();
  153. $('#soundButton').onclick = () => {
  154. soundEnabled = !soundEnabled;
  155. localStorage.setItem('zke-sound', soundEnabled);
  156. updateSoundButton();
  157. tone();
  158. };
  159. document.querySelectorAll('[data-open-modal]').forEach(button => button.onclick = () => { const modal = $(`#${button.dataset.openModal}`); if (modal.id === 'quizModal') renderQuiz(); modal.showModal(); if (modal.id === 'extrasModal') $('#extrasButton').setAttribute('aria-expanded', 'true'); }); document.querySelectorAll('.modal-close').forEach(button => button.onclick = () => button.closest('dialog').close());
  160. // Compact minesweeper: mines are placed after the first move, so it is always safe.
  161. const mineBoard = $('#minesweeperBoard');
  162. const mineMessage = $('#minesweeperMessage');
  163. const mineCounter = $('#minesweeperMines');
  164. const mineFlagToggle = $('#minesweeperFlagToggle');
  165. const mineSize = 9;
  166. const mineTotal = 10;
  167. let mineCells = [];
  168. let mineGameOver = false;
  169. let mineFlagMode = false;
  170. const mineIndex = (row, column) => row * mineSize + column;
  171. function mineNeighbours(index) { const row = Math.floor(index / mineSize); const column = index % mineSize; const result = []; for (let r = row - 1; r <= row + 1; r++) for (let c = column - 1; c <= column + 1; c++) if (r >= 0 && r < mineSize && c >= 0 && c < mineSize && (r !== row || c !== column)) result.push(mineIndex(r, c)); return result; }
  172. function renderMineCell(index) { const cell = mineCells[index]; const button = mineBoard.children[index]; button.className = 'mine-cell'; button.textContent = ''; if (cell.open) { button.classList.add('is-open'); if (cell.mine) { button.classList.add('is-mine'); button.textContent = '✹'; } else if (cell.count) { button.classList.add(`number-${Math.min(cell.count, 4)}`); button.textContent = cell.count; } } else if (cell.flagged) { button.classList.add('is-flagged'); button.textContent = '⚑'; } }
  173. function updateMineCounter() { mineCounter.textContent = `МИНЫ: ${mineTotal - mineCells.filter(cell => cell.flagged).length}`; }
  174. function prepareMines(safeIndex) { const forbidden = new Set([safeIndex, ...mineNeighbours(safeIndex)]); const candidates = Array.from({ length: mineSize * mineSize }, (_, i) => i).filter(i => !forbidden.has(i)); for (let i = candidates.length - 1; i > 0; i--) { const swap = Math.floor(Math.random() * (i + 1)); [candidates[i], candidates[swap]] = [candidates[swap], candidates[i]]; } candidates.slice(0, mineTotal).forEach(index => mineCells[index].mine = true); mineCells.forEach((cell, index) => cell.count = mineNeighbours(index).filter(neighbour => mineCells[neighbour].mine).length); }
  175. function openMineCell(index) { const cell = mineCells[index]; if (mineGameOver || cell.open || cell.flagged) return; if (!mineCells.some(item => item.mine)) prepareMines(index); cell.open = true; if (cell.mine) { mineGameOver = true; mineMessage.textContent = 'Мина. Попробуйте ещё раз.'; mineCells.forEach((item, i) => { if (item.mine) { item.open = true; renderMineCell(i); } }); tone('error'); return; } renderMineCell(index); if (!cell.count) mineNeighbours(index).forEach(openMineCell); if (!mineCells.some(item => !item.mine && !item.open)) { mineGameOver = true; mineMessage.textContent = 'Поле очищено — отлично!'; tone('success'); } }
  176. function flagMineCell(index) { const cell = mineCells[index]; if (mineGameOver || cell.open) return; cell.flagged = !cell.flagged; renderMineCell(index); updateMineCounter(); }
  177. function newMinesweeper() { mineGameOver = false; mineFlagMode = false; mineCells = Array.from({ length: mineSize * mineSize }, () => ({ mine: false, open: false, flagged: false, count: 0 })); mineBoard.innerHTML = ''; mineCells.forEach((_, index) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'mine-cell'; button.setAttribute('role', 'gridcell'); button.setAttribute('aria-label', `Клетка ${index + 1}`); button.onclick = () => mineFlagMode ? flagMineCell(index) : openMineCell(index); button.oncontextmenu = event => { event.preventDefault(); flagMineCell(index); }; mineBoard.append(button); }); mineMessage.textContent = 'Поле готово'; mineFlagToggle.textContent = 'Режим: открыть клетки'; mineFlagToggle.setAttribute('aria-pressed', 'false'); updateMineCounter(); }
  178. $('#minesweeperReset').onclick = newMinesweeper;
  179. mineFlagToggle.onclick = () => { mineFlagMode = !mineFlagMode; mineFlagToggle.textContent = mineFlagMode ? 'Режим: ставить флажки' : 'Режим: открыть клетки'; mineFlagToggle.setAttribute('aria-pressed', String(mineFlagMode)); };
  180. newMinesweeper();
  181. // Radio uses the selected public stream directly, so the volume slider is a native draggable control.
  182. const radioAudio = $('#radioAudio');
  183. const radioStation = $('#radioStation');
  184. const radioPlay = $('#radioPlay');
  185. const radioVolume = $('#radioVolume');
  186. const radioVolumeValue = $('#radioVolumeValue');
  187. const radioStations = $('#radioStations');
  188. const radioOptions = [...radioStation.options];
  189. function setRadioVolume() { radioAudio.volume = Number(radioVolume.value) / 100; radioVolumeValue.textContent = `${radioVolume.value}%`; }
  190. function updateRadioButton() { const isPlaying = !radioAudio.paused; radioAudio.dataset.playing = String(isPlaying); radioPlay.textContent = isPlaying ? '❚❚' : '▶'; radioPlay.setAttribute('aria-label', isPlaying ? 'Поставить радио на паузу' : 'Включить радио'); }
  191. function updateRadioCards() { [...radioStations.children].forEach(card => card.classList.toggle('is-selected', card.dataset.value === radioStation.value)); }
  192. function selectRadioStation(value) { radioStation.value = value; radioStation.dispatchEvent(new Event('change')); }
  193. radioOptions.forEach(option => { const card = document.createElement('button'); card.type = 'button'; card.className = 'radio-station-card'; card.dataset.value = option.value; card.title = option.textContent; card.setAttribute('aria-label', `Включить ${option.textContent}`); card.innerHTML = `<img src="https://radiobells.com/stations/${option.dataset.logo}_70.webp" alt="" loading="lazy"><span>${option.textContent}</span>`; card.onclick = () => selectRadioStation(option.value); radioStations.append(card); });
  194. radioAudio.src = radioStation.value;
  195. setRadioVolume();
  196. radioVolume.addEventListener('input', setRadioVolume);
  197. radioPlay.onclick = async () => { if (radioAudio.paused) { try { await radioAudio.play(); } catch { radioPlay.title = 'Не удалось включить этот поток. Выберите другую станцию.'; } } else radioAudio.pause(); updateRadioButton(); };
  198. radioStation.onchange = async () => { const shouldResume = !radioAudio.paused; radioAudio.src = radioStation.value; if (shouldResume) { try { await radioAudio.play(); } catch { /* The play button keeps a retry available. */ } } updateRadioButton(); updateRadioCards(); };
  199. radioAudio.onplay = updateRadioButton;
  200. radioAudio.onpause = updateRadioButton;
  201. updateRadioCards();
  202. // Hidden story sequence: code 1984 opens a short archive and reaction test.
  203. const archiveModal = $('#archiveModal');
  204. const archiveReader = $('#archiveReader');
  205. let archiveInterval;
  206. let archiveTimeout;
  207. function clearArchiveTest() { clearInterval(archiveInterval); clearTimeout(archiveTimeout); archiveInterval = undefined; archiveTimeout = undefined; }
  208. function openArchive() { reset('Обнаружен архив №1984. Доступ предоставлен на один сеанс.'); archiveModal.showModal(); }
  209. function showArchiveFile(index) { document.querySelectorAll('[data-archive-file]').forEach(file => file.classList.toggle('is-open', +file.dataset.archiveFile === index)); clearArchiveTest(); if (index === 0) archiveReader.innerHTML = '<p><b>ШИФР СМЕНЫ</b><br>«Если лампа горит слишком ровно — проверьте не лампу, а того, кто на неё смотрит».</p>'; if (index === 1) archiveReader.innerHTML = '<p><b>ЗАПИСКА ОПЕРАТОРА</b><br>03:14. После четвёртого сигнала дверь открылась сама. В журнале этого нет. Не обсуждать по внутренней связи.</p>'; if (index === 2) archiveReader.innerHTML = '<p><b>ПРОВЕРКА ЛИНИИ</b><br>Найдите светящуюся лампу 6 раз за 10 секунд.</p><button class="button button--primary" id="startSignalTest">Запустить проверку</button>'; const startButton = $('#startSignalTest'); if (startButton) startButton.onclick = startSignalTest; }
  210. function startSignalTest() { clearArchiveTest(); let score = 0; let current = -1; let finished = false; archiveReader.innerHTML = '<div class="signal-test"><div class="signal-test__top"><span id="signalTestScore">СИГНАЛЫ: 0 / 6</span><span>10.0 c</span></div><div class="signal-lamps">' + Array.from({ length: 5 }, (_, index) => `<button class="signal-lamp" data-signal-lamp="${index}" aria-label="Сигнальная лампа ${index + 1}"></button>`).join('') + '</div></div>'; const scoreLabel = $('#signalTestScore'); const lamps = [...archiveReader.querySelectorAll('[data-signal-lamp]')]; const nextLamp = () => { lamps[current]?.classList.remove('is-target'); current = Math.floor(Math.random() * lamps.length); lamps[current].classList.add('is-target'); }; lamps.forEach((lamp, index) => lamp.onclick = () => { if (finished || index !== current) return; score++; scoreLabel.textContent = `СИГНАЛЫ: ${score} / 6`; if (score === 6) { finished = true; clearArchiveTest(); archiveReader.innerHTML = '<p><b>ЛИНИЯ ЧИСТА.</b><br>Наблюдатель отключён. Запись автоматически удалится после закрытия архива.</p>'; tone('success'); } else nextLamp(); }); nextLamp(); archiveInterval = setInterval(nextLamp, 850); archiveTimeout = setTimeout(() => { if (finished) return; finished = true; clearArchiveTest(); archiveReader.innerHTML = `<p><b>ПРОВЕРКА ЗАВЕРШЕНА.</b><br>Зафиксировано сигналов: ${score} из 6. Архив остаётся доступен.</p>`; tone('error'); }, 10000); }
  211. document.querySelectorAll('[data-archive-file]').forEach(file => file.onclick = () => showArchiveFile(+file.dataset.archiveFile));
  212. archiveModal.addEventListener('close', clearArchiveTest);