| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218 |
- /* app.js — interaction layer: code validation, visual state, sound, dialogs and local persistence. */
- const $ = (selector) => document.querySelector(selector);
- let activeCode = localStorage.getItem('zke-code') || '1234';
- if (!/^\d{4}$/.test(activeCode) || new Set(activeCode).size !== 4) activeCode = '1234';
- let exitOverride = false;
- let entered = [];
- let faultId;
- let inputPulseId;
- let circuitState = 'idle';
- let soundEnabled = localStorage.getItem('zke-sound') === 'true';
- const quiz = [
- ['Что происходит при неверной цифре?', ['Цепь продолжает работу', 'Цепь сбрасывается', 'Дверь открывается'], 1],
- ['Какой элемент физически убирает защёлку?', ['Электромагнит', 'Тиристор', 'Клавиша'], 0],
- ['Что подаёт питание на электромагнит?', ['Транзисторный выходной каскад', 'Микроконтроллер DD1', 'Резистор R1'], 0],
- ['Какую функцию выполняют VD5–VD8?', ['Хранят последовательность цифр', 'Выпрямляют сетевое напряжение', 'Подсвечивают лампу'], 0],
- ['Какой элемент формирует выдержку после открытия?', ['C1', 'FU1', 'S1'], 0],
- ['Для чего нужен VD9?', ['Для подавления выброса самоиндукции YA1', 'Для выбора кода', 'Для усиления звука'], 0],
- ['Что происходит при нажатии S2?', ['Нагрузка включается напрямую', 'Код стирается навсегда', 'Схема переходит в режим настройки'], 0],
- ['Какую роль играет HL1?', ['Сигнализирует об открытии замка', 'Запоминает первую цифру', 'Понижает напряжение'], 0],
- ['Что соединяет наборное поле XP/XS?', ['Выбранные клавиши с каскадами кода', 'Сеть с трансформатором', 'Лампу с корпусом'], 0],
- ['Как закрывается замок в учебном стенде?', ['Кнопкой «Закрыть замок» или сбросом', 'Автоматически через 1 секунду', 'Только после выключения браузера'], 0]
- ];
- 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); }
- function setLog(text) { $('#eventLog').textContent = text; }
- function updateCircuit(state = circuitState) {
- circuitState = state;
- const powered = state === 'open';
- window.lockDiagram.render(state, entered, activeCode, exitOverride);
- $('#closeLockButton').disabled = !powered;
- $('#finishPulse').disabled = !powered;
- $('#lockReadout').textContent = powered ? (exitOverride ? 'S2 УДЕРЖИВАЕТСЯ' : 'ДО ЗАКРЫТИЯ КНОПКОЙ') : 'ОЖИДАНИЕ КОДА';
- ['#backspaceButton', '#codeInput', '#applyCode', '#nextStep'].forEach(selector => {
- $(selector).disabled = powered || state === 'error';
- });
- $('#resetButton').disabled = false;
- $('#keypadReset').disabled = false;
- $('#exitButton').disabled = state === 'error' || (powered && !exitOverride);
- $('#exitButton').textContent = exitOverride ? 'S2 · Отпустить кнопку' : 'S2 · Выход изнутри';
- document.querySelectorAll('[data-key]').forEach(b => b.disabled = powered || state === 'error');
- document.querySelectorAll('[data-stage-digit]').forEach(p => p.textContent = activeCode[Number(p.dataset.stageDigit)]);
- $('#driverReading').textContent = `${entered.length} / 4`;
- $('#relayReading').textContent = powered ? (exitOverride ? 'S2 нажата' : 'Вручную') : 'Нет';
- $('#coilReading').textContent = powered ? 'Включён' : 'Выключен';
- $('#circuitStatus').textContent = powered
- ? (exitOverride ? 'S2 удерживается · YA1 включён напрямую' : 'Код принят · замок открыт до закрытия кнопкой')
- : state === 'error' ? 'Неверная цифра · тиристорная память сброшена'
- : entered.length ? `Проводят ${entered.length} из 4 тиристоров · ожидание следующей цифры`
- : 'Ожидание кода · VD5–VD8 закрыты';
- $('#logicText').textContent = powered
- ? (exitOverride ? 'S2 замкнула цепь нагрузки в обход составного ключа VT5 / VT2. YA1 включён, HL1 горит. Нажмите S2 повторно, чтобы отпустить кнопку и закрыть замок.' : 'VD5–VD8 проводят. Составной ключ VT5 / VT2 включил YA1; HL1 горит. Замок и схема остаются в открытом состоянии до нажатия «Закрыть замок» или «Сбросить».')
- : state === 'error' ? 'Сработала цепь сброса R19 / C3 и управляющий каскад: ток удержания цепочки прерван, VD5–VD8 закрыты. Начните набор сначала.'
- : entered.length ? `Цифра ${entered[entered.length-1]} принята: VD${entered.length+4} проводит. Ток удержания сохраняет состояние. ${entered.length < 4 ? `Следующая ступень — VD${entered.length+5}.` : ''} Электромагнит пока выключен.`
- : 'VD5–VD8 закрыты. Выпрямители питают схему, но импульс открытия отсутствует. YA1 обесточен; пружина удерживает защёлку.';
- }
- function clearInputPulse() {
- clearTimeout(inputPulseId);
- inputPulseId = undefined;
- window.lockDiagram.clearPulse();
- }
- function pulseInput(key) {
- clearInputPulse();
- window.lockDiagram.pulse(key);
- inputPulseId = setTimeout(clearInputPulse, 650);
- }
- function updateScreen() {
- $('#codeDisplay').textContent = [...entered, ...Array(4 - entered.length).fill('—')].join(' ');
- $('#progressNumber').textContent = `${entered.length}/4`;
- updateCircuit();
- }
- function reset(message = 'Система сброшена. Ожидание нового ввода.') {
- exitOverride = false;
- clearTimeout(faultId);
- faultId = undefined;
- clearInputPulse();
- entered = [];
- circuitState = 'idle';
- $('#lockStage').classList.remove('is-open');
- $('#lockPill').classList.remove('is-open');
- $('#lockPill').textContent = 'ЗАКРЫТ';
- $('#lamp').className = 'lamp';
- $('#powerDot').classList.remove('is-on');
- updateScreen();
- setLog(message);
- }
- function openLock(fromExit = false) {
- exitOverride = fromExit;
- updateCircuit('open');
- $('#lockStage').classList.add('is-open');
- $('#lockPill').classList.add('is-open');
- $('#lockPill').textContent = 'ОТКРЫТ';
- $('#lamp').className = 'lamp is-success';
- $('#powerDot').classList.add('is-on');
- setLog(fromExit ? 'S2 удерживается. Прямое питание YA1; нажмите S2 повторно, чтобы отпустить.' : 'Код принят. VT5 / VT2 включили YA1. Замок открыт до нажатия «Закрыть замок» или «Сбросить».');
- tone('success');
- }
- function incorrect() {
- entered = [];
- circuitState = 'error';
- updateScreen();
- $('#lamp').className = 'lamp is-error';
- setLog('Неверная цифра — ввод сброшен. Тиристорная память сброшена, YA1 выключен.');
- tone('error');
- faultId = setTimeout(() => reset(), 700);
- }
- function input(key) {
- if (circuitState === 'open' || circuitState === 'error' || entered.length >= 4) return;
- tone();
- pulseInput(key);
- const candidate = entered.join('') + key;
- if (!activeCode.startsWith(candidate)) return incorrect();
- entered.push(key);
- updateScreen();
- if (candidate === activeCode) return openLock();
- setLog(`Введено ${entered.length} из 4 цифр. Очередной тиристор проводит; набор ещё не завершён.`);
- }
- function applyCode() {
- const value = $('#codeInput').value.trim();
- if (!/^\d{4}$/.test(value) || new Set(value).size !== 4) {
- $('#codeHint').textContent = 'Нужны четыре разные цифры: у каждой клавиши оригинала один провод к наборному полю.';
- $('#codeHint').style.color = 'var(--danger)'; return;
- }
- activeCode = value; localStorage.setItem('zke-code', value);
- $('#codeInput').value = value;
- $('#codeHint').textContent = 'Соединения XP/XS обновлены в модели. Код сохранён в этом браузере.';
- $('#codeHint').style.color = ''; $('#saveNote').textContent = 'сохранено сейчас';
- reset('Наборное поле перенастроено. Можно вводить новый код.');
- }
- 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))); }
- $('#codeInput').value = activeCode; updateScreen();
- $('#keypad').addEventListener('click', e => { const key = e.target.dataset.key; if (key) input(key); });
- $('#closeLockButton').onclick = () => reset('Замок закрыт кнопкой. YA1 выключен; пружина вернула защёлку.');
- $('#finishPulse').onclick = $('#closeLockButton').onclick;
- $('#nextStep').onclick = () => input(activeCode[entered.length]);
- $('#exitButton').onclick = () => { if (exitOverride) return reset('S2 отпущена. YA1 выключен; пружина вернула защёлку.'); clearInputPulse(); entered = []; updateScreen(); openLock(true); };
- $('#keypadReset').onclick = () => reset(); $('#resetButton').onclick = () => reset(); $('#backspaceButton').onclick = () => reset('Тиристорная память сброшена целиком. Начните с первой цифры.');
- $('#applyCode').onclick = applyCode; $('#codeInput').addEventListener('keydown', e => { if (e.key === 'Enter') applyCode(); });
- document.addEventListener('keydown', e => {
- if (document.querySelector('dialog[open]:not(#circuitModal)') || e.target.closest('input, select, textarea, [contenteditable="true"]') || e.ctrlKey || e.altKey || e.metaKey) return;
- if (/^[0-9]$/.test(e.key)) { e.preventDefault(); input(e.key); }
- });
- const extrasModal = $('#extrasModal');
- extrasModal.addEventListener('close', () => $('#extrasButton').setAttribute('aria-expanded', 'false'));
- extrasModal.addEventListener('click', event => {
- const bounds = extrasModal.getBoundingClientRect();
- if (event.target === extrasModal && (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom)) extrasModal.close();
- });
- function updateSoundButton() {
- $('#soundButton').classList.toggle('is-active', soundEnabled);
- $('#soundButton').setAttribute('aria-pressed', String(soundEnabled));
- $('#soundButton').setAttribute('aria-label', soundEnabled ? 'Выключить звук' : 'Включить звук');
- }
- updateSoundButton();
- $('#soundButton').onclick = () => {
- soundEnabled = !soundEnabled;
- localStorage.setItem('zke-sound', soundEnabled);
- updateSoundButton();
- tone();
- };
- 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());
- // Compact minesweeper: mines are placed after the first move, so it is always safe.
- const mineBoard = $('#minesweeperBoard');
- const mineMessage = $('#minesweeperMessage');
- const mineCounter = $('#minesweeperMines');
- const mineFlagToggle = $('#minesweeperFlagToggle');
- const mineSize = 9;
- const mineTotal = 10;
- let mineCells = [];
- let mineGameOver = false;
- let mineFlagMode = false;
- const mineIndex = (row, column) => row * mineSize + column;
- 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; }
- 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 = '⚑'; } }
- function updateMineCounter() { mineCounter.textContent = `МИНЫ: ${mineTotal - mineCells.filter(cell => cell.flagged).length}`; }
- 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); }
- 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'); } }
- function flagMineCell(index) { const cell = mineCells[index]; if (mineGameOver || cell.open) return; cell.flagged = !cell.flagged; renderMineCell(index); updateMineCounter(); }
- 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(); }
- $('#minesweeperReset').onclick = newMinesweeper;
- mineFlagToggle.onclick = () => { mineFlagMode = !mineFlagMode; mineFlagToggle.textContent = mineFlagMode ? 'Режим: ставить флажки' : 'Режим: открыть клетки'; mineFlagToggle.setAttribute('aria-pressed', String(mineFlagMode)); };
- newMinesweeper();
- // Radio uses the selected public stream directly, so the volume slider is a native draggable control.
- const radioAudio = $('#radioAudio');
- const radioStation = $('#radioStation');
- const radioPlay = $('#radioPlay');
- const radioVolume = $('#radioVolume');
- const radioVolumeValue = $('#radioVolumeValue');
- const radioStations = $('#radioStations');
- const radioOptions = [...radioStation.options];
- function setRadioVolume() { radioAudio.volume = Number(radioVolume.value) / 100; radioVolumeValue.textContent = `${radioVolume.value}%`; }
- function updateRadioButton() { const isPlaying = !radioAudio.paused; radioAudio.dataset.playing = String(isPlaying); radioPlay.textContent = isPlaying ? '❚❚' : '▶'; radioPlay.setAttribute('aria-label', isPlaying ? 'Поставить радио на паузу' : 'Включить радио'); }
- function updateRadioCards() { [...radioStations.children].forEach(card => card.classList.toggle('is-selected', card.dataset.value === radioStation.value)); }
- function selectRadioStation(value) { radioStation.value = value; radioStation.dispatchEvent(new Event('change')); }
- 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); });
- radioAudio.src = radioStation.value;
- setRadioVolume();
- radioVolume.addEventListener('input', setRadioVolume);
- radioPlay.onclick = async () => { if (radioAudio.paused) { try { await radioAudio.play(); } catch { radioPlay.title = 'Не удалось включить этот поток. Выберите другую станцию.'; } } else radioAudio.pause(); updateRadioButton(); };
- 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(); };
- radioAudio.onplay = updateRadioButton;
- radioAudio.onpause = updateRadioButton;
- updateRadioCards();
- // Hidden story sequence: code 1984 opens a short archive and reaction test.
- const archiveModal = $('#archiveModal');
- const archiveReader = $('#archiveReader');
- let archiveInterval;
- let archiveTimeout;
- function clearArchiveTest() { clearInterval(archiveInterval); clearTimeout(archiveTimeout); archiveInterval = undefined; archiveTimeout = undefined; }
- function openArchive() { reset('Обнаружен архив №1984. Доступ предоставлен на один сеанс.'); archiveModal.showModal(); }
- 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; }
- 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); }
- document.querySelectorAll('[data-archive-file]').forEach(file => file.onclick = () => showArchiveFile(+file.dataset.archiveFile));
- archiveModal.addEventListener('close', clearArchiveTest);
|