| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472 |
- 'use strict';
- /* ===== Утилиты ===== */
- const $ = (s, r = document) => r.querySelector(s);
- const rnd = (a, b) => a + Math.random() * (b - a);
- const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
- const el = (tag, attrs = {}, ...kids) => {
- const e = document.createElement(tag);
- for (const [k, v] of Object.entries(attrs)) {
- if (v == null || v === false) continue;
- if (k === 'class') e.className = v;
- else if (k === 'html') e.innerHTML = v;
- else if (k.startsWith('on')) e.addEventListener(k.slice(2), v);
- else e.setAttribute(k, v);
- }
- kids.flat().forEach(c => { if (c != null && c !== false) e.append(c.nodeType ? c : document.createTextNode(c)); });
- return e;
- };
- const store = {
- get(k, d) { try { const v = localStorage.getItem(k); return v == null ? d : JSON.parse(v); } catch (e) { return d; } },
- set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* нет доступа к хранилищу */ } }
- };
- /* ===== Звук (Web Audio + синтез речи) ===== */
- const Snd = {
- ctx: null, on: store.get('sound', true), sirenTimer: null, sirenOsc: null,
- init() {
- if (!this.ctx) { const A = window.AudioContext || window.webkitAudioContext; if (A) this.ctx = new A(); }
- if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
- return this.ctx;
- },
- tone(f = 600, d = .12, type = 'sine', v = .07, when = 0) {
- if (!this.on) return; const c = this.init(); if (!c) return;
- const t = c.currentTime + when, o = c.createOscillator(), g = c.createGain();
- o.type = type; o.frequency.setValueAtTime(f, t);
- g.gain.setValueAtTime(v, t); g.gain.exponentialRampToValueAtTime(.0001, t + d);
- o.connect(g); g.connect(c.destination); o.start(t); o.stop(t + d + .02);
- },
- click() { this.tone(520, .06, 'triangle', .05); },
- ok() { this.tone(660, .12, 'sine', .08); this.tone(990, .18, 'sine', .08, .1); },
- bad() { this.tone(180, .25, 'sawtooth', .06); },
- beep() { this.tone(2900, .12, 'square', .035); },
- siren(start) {
- if (!start) {
- clearInterval(this.sirenTimer); this.sirenTimer = null;
- if (this.sirenOsc) { try { this.sirenOsc.stop(); } catch (e) { } this.sirenOsc = null; }
- return;
- }
- if (!this.on || this.sirenTimer) return;
- const c = this.init(); if (!c) return;
- const o = c.createOscillator(), g = c.createGain();
- o.type = 'sawtooth'; g.gain.value = .05; o.connect(g); g.connect(c.destination); o.start();
- this.sirenOsc = o; let hi = false;
- const tick = () => { o.frequency.setTargetAtTime(hi ? 980 : 720, c.currentTime, .03); hi = !hi; };
- tick(); this.sirenTimer = setInterval(tick, 420);
- },
- speak(text) {
- if (!this.on || !('speechSynthesis' in window)) return;
- try { speechSynthesis.cancel(); const u = new SpeechSynthesisUtterance(text); u.lang = 'ru-RU'; u.rate = .95; speechSynthesis.speak(u); } catch (e) { }
- },
- stopAll() { this.siren(false); try { speechSynthesis && speechSynthesis.cancel(); } catch (e) { } }
- };
- /* ===== Мини-тест внутри сцены ===== */
- function miniQuiz(q, onRight) {
- const box = el('div', { class: 'card quiz' });
- const fb = el('div', { class: 'msg' }, 'Проверь себя: выбери ответ.');
- box.append(el('h3', {}, '❓ ' + q.t));
- const btns = q.o.map((txt, i) => {
- const b = el('button', { class: 'opt' }, txt);
- b.onclick = () => {
- if (i === q.a) {
- Snd.ok(); b.classList.add('right'); btns.forEach(x => x.disabled = true);
- fb.className = 'msg good'; fb.textContent = '✔ Верно. ' + q.e; onRight && onRight();
- } else { Snd.bad(); b.classList.add('wrong'); b.disabled = true; fb.className = 'msg bad'; fb.textContent = '✖ Не совсем. Подумай ещё и попробуй другой вариант.'; }
- };
- return b;
- });
- box.append(...btns, fb);
- return box;
- }
- /* ===== Сцена 1: Старт ===== */
- function sceneStart(root, ctx) {
- const cv = el('canvas', { width: 560, height: 280 });
- const hero = el('div', { class: 'hero' },
- cv,
- el('span', { class: 'pill' }, 'Технические средства защиты информации и объектов · ТЗИ'),
- el('h1', { html: 'Как «видит» пожар <span class="grad">пожарная сигнализация</span>' }),
- el('p', { class: 'lead' }, 'Интерактивный курс на 5–10 минут: соберёшь в голове принцип работы дымового и теплового извещателей, подберёшь прибор для помещения, запустишь пожарный пост и пройдёшь итоговое тестирование.'),
- el('button', { class: 'btn primary big', onclick: () => { Snd.init(); Snd.ok(); ctx.next(); } }, '▶ Начать курс'),
- el('p', { class: 'hint' }, 'Включи звук: в курсе есть сирена и голосовое оповещение 🔊')
- );
- root.append(hero); ctx.unlock();
- return fireLoop(cv, () => 78, true);
- }
- /* ===== Анимация «видео» пожара на canvas ===== */
- function fireLoop(canvas, getLevel, ambient) {
- const c = canvas.getContext('2d'); let ps = [], raf, run = true;
- function frame() {
- if (!run) return;
- const W = canvas.width, H = canvas.height, L = getLevel();
- c.globalCompositeOperation = 'source-over';
- const g = c.createLinearGradient(0, 0, 0, H); g.addColorStop(0, '#0b0f1a'); g.addColorStop(1, '#1b1f33');
- c.fillStyle = g; c.fillRect(0, 0, W, H);
- c.fillStyle = '#242b45'; c.fillRect(0, H - 34, W, 34);
- c.fillStyle = '#394260'; c.fillRect(W / 2 - 60, H - 64, 120, 30); // «мебель»
- if (L > 8 && Math.random() < L / 40) for (let i = 0; i < Math.ceil(L / 25); i++) ps.push({ t: 's', x: W / 2 + rnd(-40, 40), y: H - 70, vx: rnd(-.35, .35), vy: -rnd(.6, 1.7), r: rnd(6, 12), a: .32, l: 1 });
- if (L > 40) for (let i = 0; i < Math.ceil((L - 35) / 12); i++) ps.push({ t: 'f', x: W / 2 + rnd(-30 - L / 3, 30 + L / 3), y: H - 66, vx: rnd(-.5, .5), vy: -rnd(1.5, 3.2), r: rnd(6, 14), a: 1, l: 1 });
- if (L > 8) { const ca = clamp(L / 140, 0, .55); const sg = c.createLinearGradient(0, 0, 0, H * .55); sg.addColorStop(0, `rgba(160,170,190,${ca})`); sg.addColorStop(1, 'rgba(160,170,190,0)'); c.fillStyle = sg; c.fillRect(0, 0, W, H * .55); }
- for (const p of ps) {
- p.x += p.vx; p.y += p.vy; p.l -= p.t === 'f' ? .028 : .006; p.r += p.t === 'f' ? -.15 : .13;
- if (p.t === 's') { c.fillStyle = `rgba(150,158,176,${p.a * p.l})`; c.beginPath(); c.arc(p.x, p.y, Math.max(p.r, 0), 0, 7); c.fill(); }
- }
- c.globalCompositeOperation = 'lighter';
- for (const p of ps) if (p.t === 'f') { const rr = Math.max(p.r, .1); const gr = c.createRadialGradient(p.x, p.y, 0, p.x, p.y, rr * 2); gr.addColorStop(0, `rgba(255,210,90,${.7 * p.l})`); gr.addColorStop(.5, `rgba(255,90,30,${.45 * p.l})`); gr.addColorStop(1, 'rgba(255,40,0,0)'); c.fillStyle = gr; c.beginPath(); c.arc(p.x, p.y, rr * 2, 0, 7); c.fill(); }
- c.globalCompositeOperation = 'source-over';
- ps = ps.filter(p => p.l > 0 && p.r > 0 && p.y > -20); if (ps.length > 700) ps.splice(0, 100);
- if (!ambient) { // потолочные извещатели
- [[W * .2, 'Д'], [W * .5, 'Т'], [W * .8, 'П']].forEach(([x, s]) => { c.fillStyle = '#d7dcef'; c.beginPath(); c.arc(x, 16, 13, 0, 7); c.fill(); c.fillStyle = '#0b1020'; c.font = 'bold 13px sans-serif'; c.textAlign = 'center'; c.fillText(s, x, 21); });
- }
- raf = requestAnimationFrame(frame);
- }
- frame();
- return () => { run = false; cancelAnimationFrame(raf); };
- }
- /* ===== Сцена 2: Стадии пожара ===== */
- function sceneStages(root, ctx) {
- let level = 0;
- const cv = el('canvas', { class: 'cv', width: 640, height: 320 });
- const stageName = el('div', { class: 'msg' });
- const lamps = { smoke: el('span', { class: 'lamp' }, el('i'), 'Дымовой «Д»'), heat: el('span', { class: 'lamp' }, el('i'), 'Тепловой «Т»'), flame: el('span', { class: 'lamp' }, el('i'), 'Пламени «П»') };
- const th = { smoke: 18, heat: 55, flame: 70 }; const fired = {}; let maxL = 0;
- const slider = el('input', { type: 'range', min: 0, max: 100, value: 0 });
- const info = el('div', { class: 'msg' }, 'Двигай ползунок и наблюдай, какой извещатель сработает раньше.');
- const upd = () => {
- level = +slider.value; maxL = Math.max(maxL, level);
- const n = level < 8 ? 'Норма' : level < 35 ? 'Тление: появляются невидимые аэрозоли и первый дым' : level < 60 ? 'Задымление, растёт температура под потолком' : level < 80 ? 'Открытое горение, видно пламя' : 'Развитый пожар';
- stageName.textContent = `Стадия: ${n} (${level}%)`;
- for (const k in lamps) {
- const on = level >= th[k]; lamps[k].classList.toggle('on', on);
- if (on && !fired[k]) { fired[k] = 1; Snd.beep(); Snd.beep(); info.className = 'msg good'; info.textContent = { smoke: '🌫 Дымовой извещатель сработал первым — он ловит самые ранние признаки пожара (тление).', heat: '🌡 Тепловой извещатель сработал позже — когда температура уже заметно выросла.', flame: '🔥 Извещатель пламени сработал на излучение факела — им защищают помещения, где дыма мало, а огонь возникает быстро.' }[k]; }
- }
- };
- slider.oninput = upd;
- root.append(
- el('h2', {}, 'Как развивается пожар'),
- el('p', { class: 'lead' }, 'У огня есть «лестница» признаков: сначала дым, потом тепло, потом пламя. Каждый тип извещателя реагирует на свой признак. Подними ползунок «Развитие пожара» до конца.'),
- el('div', { class: 'grid2' },
- el('div', { class: 'card' }, cv, stageName),
- el('div', { class: 'card' }, el('h3', {}, 'Развитие пожара'), slider, el('button', { class: 'btn sm', onclick: () => { Snd.click(); slider.value = 0; upd(); for (const k in fired) delete fired[k]; info.className = 'msg'; info.textContent = 'Ползунок сброшен. Смотри заново.'; } }, '↺ Сбросить ползунок'),
- el('div', { class: 'row' }, lamps.smoke, lamps.heat, lamps.flame), info,
- el('ul', { class: 'steps', html: '<li><b>Дымовой</b> — раннее обнаружение тления.</li><li><b>Тепловой</b> — для запылённых, парящих помещений.</li><li><b>Пламени</b> — ИК/УФ датчик на излучение факела.</li>' })))
- );
- const stop = fireLoop(cv, () => level);
- const q = miniQuiz({ t: 'Какой тип извещателя обычно срабатывает самым первым при тлении кабеля?', o: ['Тепловой', 'Дымовой', 'Пламени', 'Ручной'], a: 1, e: 'При тлении много дыма, но мало тепла и нет пламени.' });
- root.append(q);
- ctx.gate(() => maxL >= 75 && q.querySelector('.right'));
- return stop;
- }
- /* ===== Сцена 3: Оптический дымовой извещатель ===== */
- function sceneSmoke(root, ctx) {
- const svgBox = el('div', { class: 'card' });
- svgBox.innerHTML = `<svg viewBox="0 0 440 280" class="svg" aria-label="Модель дымовой камеры">
- <circle cx="220" cy="140" r="112" fill="#0e1424" stroke="#3b4a6b" stroke-width="4"/>
- <circle cx="220" cy="140" r="100" fill="none" stroke="#26314f" stroke-dasharray="4 6"/>
- <rect x="84" y="128" width="24" height="24" rx="4" fill="#ffcf33"/><text x="96" y="120" text-anchor="middle">ИК-светодиод</text>
- <line id="beam" x1="108" y1="140" x2="326" y2="140" stroke="#ffd166" stroke-width="3" opacity=".85"/>
- <rect x="326" y="130" width="10" height="20" fill="#444"/><text x="332" y="170" text-anchor="middle">Поглотитель</text>
- <rect x="250" y="206" width="28" height="14" rx="3" fill="#5ec8ff"/><text x="264" y="240" text-anchor="middle">Фотодиод</text>
- <g id="sc"></g><g id="ps" fill="#c3cbe0"></g>
- <circle id="alarmLed" cx="390" cy="36" r="9" fill="#394260"/><text x="390" y="64" text-anchor="middle">Индикатор</text>
- <text x="220" y="272" text-anchor="middle">Дымовая камера (вид сверху)</text></svg>`;
- const svg = svgBox.firstElementChild, ps = svg.querySelector('#ps'), sc = svg.querySelector('#sc'), led = svg.querySelector('#alarmLed');
- const slider = el('input', { type: 'range', min: 0, max: 100, value: 0 });
- const bar = el('div', { class: 'bar' }, el('div'), el('span', { class: 'thr', style: 'left:40%' }));
- const msg = el('div', { class: 'msg' }, 'Выбери источник частиц или двигай ползунок плотности.');
- let source = 'manual', density = 0, sig = 0, hold = 0, alarm = false, lastBeep = 0, gotTrue = false, gotFalse = false, quizShown = false;
- const sourceBtns = [['Чистый воздух', 0, 'air'], ['Тление (дым)', 58, 'smoke'], ['Пар из ванной', 66, 'steam'], ['Строительная пыль', 52, 'dust']].map(([n, v, s]) =>
- el('button', { class: 'btn sm', onclick: () => { Snd.click(); slider.value = v; source = s; density = v; } }, n));
- slider.oninput = () => { source = 'manual'; density = +slider.value; };
- const parts = []; let raf, last = performance.now();
- const NS = 'http://www.w3.org/2000/svg';
- const quizHolder = el('div');
- function loop(now) {
- const dt = (now - last) / 1000; last = now;
- const target = Math.round(density * .7);
- while (parts.length < target) { const a = rnd(0, 6.28), r = Math.sqrt(Math.random()) * 92; const e = document.createElementNS(NS, 'circle'); e.setAttribute('r', rnd(1.5, 3.2)); ps.append(e); parts.push({ x: 220 + Math.cos(a) * r, y: 140 + Math.sin(a) * r, vx: rnd(-.5, .5), vy: rnd(-.5, .5), e }); }
- while (parts.length > target) parts.pop().e.remove();
- let rays = '', n = 0;
- for (const p of parts) {
- p.x += p.vx; p.y += p.vy; if (Math.hypot(p.x - 220, p.y - 140) > 98) { p.vx *= -1; p.vy *= -1; p.x += p.vx * 3; p.y += p.vy * 3; }
- p.e.setAttribute('cx', p.x.toFixed(1)); p.e.setAttribute('cy', p.y.toFixed(1));
- const inb = Math.abs(p.y - 140) < 12 && p.x > 110 && p.x < 326; p.e.setAttribute('fill', inb ? '#ffe9a8' : '#c3cbe0');
- if (inb && n < 14) { n++; rays += `<line x1="${p.x.toFixed(1)}" y1="${p.y.toFixed(1)}" x2="264" y2="206" stroke="#5ec8ff" stroke-width="1.4" opacity=".6"/>`; }
- }
- sc.innerHTML = rays;
- const tgt = clamp(density / 100 * 1.02 + rnd(-.015, .015), 0, 1); sig += (tgt - sig) * .12;
- bar.firstChild.style.width = (sig * 100).toFixed(0) + '%';
- if (sig >= .4) { hold += dt; if (hold > .7 && !alarm) { alarm = true; onAlarm(); } } else if (sig < .35) { hold = 0; if (alarm) { alarm = false; led.setAttribute('fill', '#394260'); } }
- if (alarm) { led.setAttribute('fill', Math.floor(now / 250) % 2 ? '#ff5470' : '#5a1f2c'); if (now - lastBeep > 520) { Snd.beep(); Snd.beep(); lastBeep = now; } }
- raf = requestAnimationFrame(loop);
- }
- function onAlarm() {
- if (source === 'steam' || source === 'dust') { gotFalse = true; msg.className = 'msg bad'; msg.textContent = '⚠ ЛОЖНАЯ ТРЕВОГА! Частицы пара/пыли рассеивают свет так же, как дым — оптический датчик их не различает. Поэтому такие помещения защищают тепловыми или комбинированными извещателями.'; }
- else { gotTrue = true; msg.className = 'msg good'; msg.textContent = '🔔 Тревога! Частицы дыма рассеяли луч на фотодиод — сигнал превысил порог (красная метка) и держался дольше 0,7 с.'; }
- if (gotTrue && gotFalse && !quizShown) {
- quizShown = true;
- quizHolder.append(miniQuiz({ t: 'Почему в чистой камере фотодиод «молчит», хотя светодиод светит?', o: ['Фотодиод выключен до пожара', 'Он стоит вне прямого луча — свет попадает на него только после рассеяния на частицах', 'Светодиод включается только при пожаре', 'Луч поглощается стенками камеры полностью'], a: 1, e: 'Это эффект рассеяния (Тиндаля): нет частиц — нет рассеянного света — нет сигнала.' }));
- }
- }
- raf = requestAnimationFrame(loop);
- root.append(el('h2', {}, 'Оптический дымовой извещатель'),
- el('p', { class: 'lead' }, 'Внутри — тёмная камера, светодиод и фотодиод, расположенный в стороне от луча. Дым рассеивает свет, часть его попадает на фотодиод. Вызови настоящую и ложную тревогу.'),
- el('div', { class: 'grid2' }, svgBox,
- el('div', { class: 'card' }, el('h3', {}, 'Источник частиц'), el('div', { class: 'row' }, sourceBtns),
- el('p', {}, 'Плотность частиц:'), slider, el('p', {}, 'Сигнал фотодиода / порог:'), bar, msg)),
- quizHolder);
- ctx.gate(() => gotTrue && gotFalse && quizHolder.querySelector('.right'));
- return () => cancelAnimationFrame(raf);
- }
- /* ===== Сцена 4: Тепловой извещатель ===== */
- function sceneHeat(root, ctx) {
- const cv = el('canvas', { class: 'cv', width: 640, height: 280 });
- const g = cv.getContext('2d');
- const lampMax = el('span', { class: 'lamp' }, el('i'), 'Максимальный (≥ 70 °C)');
- const lampDif = el('span', { class: 'lamp' }, el('i'), 'Дифференциальный (≥ 8 °C/мин)');
- const info = el('div', { class: 'msg' }, 'Запусти оба сценария по очереди.');
- const readout = el('div', { class: 'lamp' }, 'T = — · скорость = —');
- const ran = { sun: false, fire: false }; let timer, quizDone = false; const quizHolder = el('div');
- function draw(T, tmax) {
- const W = cv.width, H = cv.height; g.clearRect(0, 0, W, H); g.fillStyle = '#0b0f1a'; g.fillRect(0, 0, W, H);
- g.strokeStyle = '#26314f'; g.fillStyle = '#7f8db0'; g.font = '12px sans-serif';
- for (let t = 20; t <= 140; t += 20) { const y = H - 20 - (t - 20) / 120 * (H - 40); g.beginPath(); g.moveTo(40, y); g.lineTo(W, y); g.stroke(); g.fillText(t + '°', 6, y + 4); }
- const y70 = H - 20 - 50 / 120 * (H - 40); g.strokeStyle = '#ff5470'; g.setLineDash([6, 5]); g.beginPath(); g.moveTo(40, y70); g.lineTo(W, y70); g.stroke(); g.setLineDash([]);
- g.strokeStyle = '#ffd166'; g.lineWidth = 3; g.beginPath();
- T.forEach((v, i) => { const x = 40 + i / (tmax * 10) * (W - 50), y = H - 20 - (clamp(v, 20, 140) - 20) / 120 * (H - 40); i ? g.lineTo(x, y) : g.moveTo(x, y); }); g.stroke(); g.lineWidth = 1;
- }
- function run(kind) {
- clearInterval(timer); Snd.click(); lampMax.classList.remove('on'); lampDif.classList.remove('on');
- const f = kind === 'sun' ? t => 20 + 2 * t : t => 22 + 14 * t, tmax = kind === 'sun' ? 30 : 6;
- const T = [f(0)]; let n = 0, mA = null, dA = null; draw(T, tmax);
- info.className = 'msg'; info.textContent = kind === 'sun' ? '☀ Солнце нагревает помещение медленно: ~2 °C в минуту…' : '🔥 Загорелась мебель: температура растёт ~14 °C в минуту…';
- timer = setInterval(() => {
- n++; const t = n / 10; T.push(f(t)); draw(T, tmax);
- const k = Math.max(0, n - 10), rate = (T[n] - T[k]) / ((n - k) / 10);
- readout.textContent = `T = ${T[n].toFixed(0)} °C · скорость = ${rate.toFixed(1)} °C/мин · t = ${t.toFixed(1)} мин`;
- if (T[n] >= 70 && mA == null) { mA = t; lampMax.classList.add('on'); Snd.beep(); Snd.beep(); }
- if (rate >= 8 && dA == null) { dA = t; lampDif.classList.add('on'); Snd.beep(); Snd.beep(); }
- if (n >= tmax * 10) {
- clearInterval(timer); ran[kind] = true;
- if (kind === 'sun') { info.className = 'msg bad'; info.textContent = `Максимальный извещатель сработал на ${mA.toFixed(1)}-й минуте — ЛОЖНАЯ тревога от нагрева солнцем. Дифференциальный промолчал: скорость роста мала.`; }
- else { info.className = 'msg good'; info.textContent = `Дифференциальный сработал уже на ${dA.toFixed(1)} мин, максимальный — только на ${mA.toFixed(1)} мин: по скорости роста пожар виден раньше.`; }
- if (ran.sun && ran.fire && !quizDone) { quizDone = true; quizHolder.append(miniQuiz({ t: 'Какой извещатель сработает при быстром росте температуры, даже если порог 70 °C ещё не достигнут?', o: ['Максимальный', 'Дифференциальный', 'Дымовой оптический', 'Ни один'], a: 1, e: 'Дифференциальный измеряет скорость нарастания температуры (°C/мин).' })); }
- }
- }, 40);
- }
- draw([20], 30);
- root.append(el('h2', {}, 'Тепловые извещатели'),
- el('p', { class: 'lead' }, 'Термистор в корпусе измеряет температуру воздуха под потолком. Максимальный реагирует на порог, дифференциальный — на скорость роста. Сравни их.'),
- el('div', { class: 'grid2' }, el('div', { class: 'card' }, cv, readout),
- el('div', { class: 'card' }, el('h3', {}, 'Сценарии'),
- el('div', { class: 'row' }, el('button', { class: 'btn', onclick: () => run('sun') }, '☀ Солнечный день'), el('button', { class: 'btn', onclick: () => run('fire') }, '🔥 Пожар')),
- el('div', { class: 'row', style: 'margin-top:12px' }, lampMax, lampDif), info)),
- quizHolder);
- ctx.gate(() => ran.sun && ran.fire && quizHolder.querySelector('.right'));
- return () => clearInterval(timer);
- }
- /* ===== Сцена 5: Подбор извещателя ===== */
- function scenePick(root, ctx) {
- const cases = [
- ['🖥 Серверная', 'Кабели могут тлеть, дыма много, пламени нет. Чистый воздух.', 0, 'Дымовой: тление кабелей даёт дым задолго до тепла.'],
- ['🍳 Кухня ресторана', 'Пар, жир, дым от жарки.', 1, 'Тепловой: дым и пар вызывали бы ложные тревоги.'],
- ['⛽ Склад ЛВЖ (бензин, спирты)', 'Горит быстро, почти без дыма, высокие потолки.', 2, 'Пламени: реагирует на излучение факела мгновенно.'],
- ['🚪 Коридор общежития', 'Чистое помещение, люди спят, нужно раннее оповещение.', 0, 'Дымовой: самое раннее обнаружение — время на эвакуацию.'],
- ['🧖 Сауна / парная', 'Постоянный пар и высокая температура.', 1, 'Тепловой (с повышенным порогом): пар «обманул» бы оптический.']
- ];
- const names = ['Дымовой', 'Тепловой', 'Пламени']; let done = 0, ok = 0;
- const score = el('div', { class: 'pill' }, 'Верно: 0 из 5');
- const list = cases.map(([t, d, a, e]) => {
- const fb = el('div', { class: 'msg' }, 'Выбери извещатель'); const row = el('div', { class: 'row' });
- const b = names.map((n, i) => el('button', { class: 'btn sm', onclick: () => {
- b.forEach(x => x.disabled = true); done++;
- if (i === a) { ok++; Snd.ok(); fb.className = 'msg good'; fb.textContent = '✔ ' + e; } else { Snd.bad(); fb.className = 'msg bad'; fb.textContent = `✖ Лучше — «${names[a]}». ${e}`; }
- score.textContent = `Верно: ${ok} из 5`; if (done === cases.length) ctx.unlock();
- } }, n)); row.append(...b);
- return el('div', { class: 'card' }, el('h3', {}, t), el('small', { class: 'lead' }, d), row, fb);
- });
- root.append(el('h2', {}, 'Подбери извещатель'), el('p', { class: 'lead' }, 'Для каждого помещения выбери тип извещателя. Ошибки не страшны — после ответа будет объяснение.'), el('div', { class: 'row' }, score, el('button', { class: 'btn sm', onclick: () => ctx.reset() }, '↺ Пройти заново')), el('div', { class: 'grid3', style: 'margin-top:10px' }, list));
- ctx.gate(() => done === cases.length);
- }
- /* ===== Сцена 6: ППКП и здание ===== */
- function scenePanel(root, ctx) {
- const rooms = [{ n: 'Кухня', d: 'Тепловой', t: 2400, ic: '🍳' }, { n: 'Серверная', d: 'Дымовой', t: 1300, ic: '🖥' }, { n: 'Склад ЛВЖ', d: 'Пламени', t: 700, ic: '⛽' }, { n: 'Коридор', d: 'Дымовой', t: 1500, ic: '🚪' }];
- const lcd = el('div', { class: 'lcd' }, 'ДЕЖУРНЫЙ РЕЖИМ\nВсе шлейфы в норме');
- const log = el('div', { class: 'log' }); const zoneLamps = rooms.map((r, i) => el('span', { class: 'lamp' }, el('i'), 'ЗОНА ' + (i + 1)));
- const alarms = new Set(); let acked = false, everAcked = false, timers = [];
- const L = m => { const d = new Date(); log.prepend(el('div', {}, d.toLocaleTimeString('ru-RU') + ' — ' + m)); };
- const refresh = () => {
- if (!alarms.size) { lcd.className = 'lcd'; lcd.textContent = 'ДЕЖУРНЫЙ РЕЖИМ\nВсе шлейфы в норме'; return; }
- lcd.className = 'lcd alert'; lcd.textContent = (acked ? 'ПОЖАР (квитировано)\n' : '*** ПОЖАР ***\n') + [...alarms].map(i => `Зона ${i + 1}: ${rooms[i].n}`).join('\n');
- };
- const cards = rooms.map((r, i) => {
- const c = el('div', { class: 'card room' }, el('h4', {}, `${r.ic} ${r.n}`), el('small', {}, 'Извещатель: ' + r.d), el('div', {}, el('button', { class: 'btn sm', style: 'margin-top:8px', onclick: () => ignite(i) }, '🔥 Поджечь')), el('div', { class: 'flame' }, '🔥'));
- return c;
- });
- function ignite(i, manual) {
- if (alarms.has(i) || cards[i].classList.contains('burn')) return; Snd.click(); cards[i].classList.add('burn'); L(`Возгорание в «${rooms[i].n}»`);
- timers.push(setTimeout(() => trigger(i, manual ? 'ручной извещатель' : rooms[i].d.toLowerCase() + ' извещатель'), manual ? 0 : rooms[i].t));
- }
- function trigger(i, by) {
- alarms.add(i); acked = false; cards[i].classList.add('alarm'); zoneLamps[i].classList.add('on'); L(`Сработал ${by}, зона ${i + 1}`); refresh();
- Snd.siren(true); Snd.speak(`Внимание! Пожар! Зона ${i + 1}. ${rooms[i].n}. Покиньте помещение.`);
- check();
- }
- const ackBtn = el('button', { class: 'btn', onclick: () => { if (!alarms.size) return; Snd.click(); acked = true; everAcked = true; Snd.stopAll(); L('Дежурный квитировал сигнал'); refresh(); check(); } }, '🔕 Квитировать');
- const resetBtn = el('button', { class: 'btn', onclick: () => { Snd.click(); timers.forEach(clearTimeout); timers = []; alarms.clear(); acked = false; Snd.stopAll(); cards.forEach(c => c.classList.remove('burn', 'alarm')); zoneLamps.forEach(z => z.classList.remove('on')); L('Сброс ППКП: пожар ликвидирован'); refresh(); } }, '♻ Сброс');
- const manualBtn = el('button', { class: 'btn', onclick: () => { ignite(3, true); } }, '🧯 Ручной извещатель (коридор)');
- function check() { if (alarms.size >= 2 && everAcked) { ctx.unlock(); } }
- root.append(el('h2', {}, 'Пожарный пост: ППКП'),
- el('p', { class: 'lead' }, 'Прибор приёмно-контрольный пожарный (ППКП) опрашивает шлейфы с извещателями, показывает зону пожара, включает сирену и голосовое оповещение. Подожги два разных помещения, затем квитируй сигнал, а потом сделай сброс.'),
- el('div', { class: 'grid2' }, el('div', { class: 'grid3', style: 'grid-template-columns:1fr 1fr' }, cards),
- el('div', { class: 'panel' }, lcd, el('div', { class: 'row', style: 'margin:10px 0' }, zoneLamps), el('div', { class: 'row' }, ackBtn, resetBtn, manualBtn), log)));
- ctx.gate(() => alarms.size >= 2 && everAcked || ctx.isUnlocked());
- return () => { timers.forEach(clearTimeout); Snd.stopAll(); };
- }
- /* ===== Сцена 7: Алгоритм ===== */
- function sceneAlgo(root, ctx) {
- const correct = ['Извещатель обнаруживает признак пожара и передаёт сигнал на ППКП', 'ППКП определяет зону и включает сирену и оповещение', 'Дежурный квитирует сигнал и проверяет зону (камеры, обход)', 'Пожар подтверждён: вызов 101/112 и эвакуация людей', 'Запуск пожаротушения и дымоудаления, отключение вентиляции', 'После ликвидации — сброс ППКП и восстановление шлейфа'];
- const pool = correct.map((t, i) => ({ t, i })).sort(() => Math.random() - .5); let next = 0;
- const target = el('div', { class: 'chips' }), poolBox = el('div', { class: 'chips' }); const msg = el('div', { class: 'msg' }, 'Нажимай шаги в правильной последовательности.');
- pool.forEach(p => { const b = el('button', { class: 'chip' }, p.t); b.onclick = () => {
- if (p.i === next) { Snd.ok(); next++; b.classList.add('ok'); b.disabled = true; target.append(el('div', { class: 'chip ok' }, `${next}. ${p.t}`)); b.remove(); msg.className = 'msg good'; msg.textContent = 'Верно!'; if (next === correct.length) { msg.textContent = '🎉 Алгоритм собран правильно!'; ctx.unlock(); } }
- else { Snd.bad(); b.classList.add('no'); setTimeout(() => b.classList.remove('no'), 400); msg.className = 'msg bad'; msg.textContent = 'Это ещё рано — подумай, что должно произойти раньше.'; }
- }; poolBox.append(b); });
- root.append(el('h2', {}, 'Порядок действий при тревоге'), el('p', { class: 'lead' }, 'Расставь этапы от срабатывания до возврата в дежурный режим.'), el('h3', {}, 'Перемешанные шаги'), poolBox, el('h3', {}, 'Твоя последовательность'), target, msg, el('button', { class: 'btn sm', onclick: () => ctx.reset() }, '↺ Перемешать и начать заново'));
- ctx.gate(() => next === correct.length);
- }
- /* ===== Вопросы итогового теста ===== */
- const TEST = [
- { q: 'На каком физическом явлении основан оптико-электронный дымовой извещатель?', o: ['Ионизация воздуха альфа-излучением', 'Рассеяние света частицами дыма (эффект Тиндаля)', 'Изменение сопротивления при нагреве', 'Приём ультрафиолета пламени'], a: 1, e: 'Свет светодиода рассеивается частицами дыма на фотодиод.' },
- { q: 'Почему фотодиод в дымовой камере не стоит на линии луча светодиода?', o: ['Чтобы он не перегревался', 'Чтобы в чистом воздухе сигнал был близок к нулю', 'Чтобы увеличить ток светодиода', 'Из-за требований дизайна корпуса'], a: 1, e: 'Фотодиод должен «видеть» только рассеянный свет.' },
- { q: 'Максимальный тепловой извещатель срабатывает, когда…', o: ['температура растёт быстрее 8 °C/мин', 'температура достигла порогового значения', 'в воздухе появился дым', 'нажата кнопка'], a: 1, e: 'Он сравнивает температуру с фиксированным порогом.' },
- { q: 'На что реагирует дифференциальный тепловой извещатель?', o: ['На абсолютную температуру', 'На скорость роста температуры', 'На влажность воздуха', 'На яркость света'], a: 1, e: 'Он оценивает градиент °C/мин, поэтому быстрее видит пожар.' },
- { q: 'Какой извещатель лучше для кухни с паром и жиром?', o: ['Дымовой оптический', 'Тепловой', 'Любой, разницы нет', 'Только звуковой'], a: 1, e: 'Пар и дым вызвали бы ложные срабатывания оптического датчика.' },
- { q: 'Что выбрать для склада ЛВЖ, где огонь распространяется быстро и почти без дыма?', o: ['Извещатель пламени', 'Максимальный тепловой с порогом 90 °C', 'Дымовой оптический', 'Ионизационный'], a: 0, e: 'ИК/УФ датчик реагирует на излучение факела.' },
- { q: 'Что такое ППКП?', o: ['Прибор приёмно-контрольный пожарный', 'Пульт пожарного координатора', 'Преобразователь питания', 'Плата предварительной калибровки'], a: 0, e: 'Это «мозг» системы: опрашивает шлейфы и управляет оповещением.' },
- { q: 'Что вызовет ложную тревогу оптического дымового извещателя?', o: ['Плотный пар или пыль', 'Выключенный свет', 'Низкая температура', 'Тишина'], a: 0, e: 'Частицы пара и пыли тоже рассеивают свет.' },
- { q: 'Что означает «квитировать» сигнал?', o: ['Полностью устранить пожар', 'Подтвердить приём сигнала дежурным и заглушить звук', 'Отключить шлейф навсегда', 'Сбросить прибор в заводские настройки'], a: 1, e: 'Квитирование — подтверждение приёма. Пожар при этом остаётся в памяти ППКП до сброса.' }
- ];
- /* ===== Сцена 8: Финал ===== */
- function sceneFinal(root, ctx) {
- const best = store.get('best', null);
- root.append(el('div', { class: 'hero' }, el('div', { style: 'font-size:72px' }, '🎓'), el('h1', { html: 'Материал <span class="grad">пройден</span>' }),
- el('p', { class: 'lead' }, 'Ты познакомился с принципами работы дымового, теплового извещателей и ППКП. Осталось проверить, как усвоился материал: 9 вопросов с мгновенной проверкой.'),
- el('button', { class: 'btn primary big', onclick: () => { Snd.ok(); ctx.go(8); } }, '📝 Итоговое Тестирование'),
- best ? el('p', { class: 'hint' }, `Лучший результат: ${best.s} из ${best.t} (оценка ${best.g})`) : null));
- ctx.unlock();
- }
- /* ===== Сцена 9: Тест ===== */
- function sceneTest(root, ctx) {
- let i = 0, score = 0; const wrongs = [];
- const box = el('div'); root.append(box);
- function show() {
- box.innerHTML = ''; const q = TEST[i];
- const fb = el('div', { class: 'msg' }, 'Выбери ответ'); const nxt = el('button', { class: 'btn primary', style: 'display:none', onclick: () => { Snd.click(); i++; i < TEST.length ? show() : finish(); } }, i === TEST.length - 1 ? 'Показать результат' : 'Следующий вопрос →');
- const opts = q.o.map((t, k) => el('button', { class: 'opt', onclick: () => {
- opts.forEach(o => o.disabled = true); opts[q.a].classList.add('right');
- if (k === q.a) { score++; Snd.ok(); fb.className = 'msg good'; fb.textContent = '✔ Верно. ' + q.e; } else { opts[k].classList.add('wrong'); Snd.bad(); wrongs.push({ q: q.q, you: t, right: q.o[q.a], e: q.e }); fb.className = 'msg bad'; fb.textContent = '✖ Неверно. ' + q.e; }
- nxt.style.display = '';
- } }, t));
- box.append(el('div', { class: 'pill' }, `Вопрос ${i + 1} из ${TEST.length}`), el('div', { class: 'prog' }, el('div', { style: `width:${i / TEST.length * 100}%` })), el('div', { class: 'card' }, el('h2', {}, q.q), opts, fb, el('div', { class: 'row', style: 'margin-top:10px' }, nxt)));
- }
- function finish() {
- const p = score / TEST.length, g = p >= .9 ? 5 : p >= .75 ? 4 : p >= .55 ? 3 : 2;
- const prev = store.get('best', null); if (!prev || score > prev.s) store.set('best', { s: score, t: TEST.length, g });
- box.innerHTML = ''; (g >= 4 ? (Snd.ok(), confetti()) : Snd.bad());
- box.append(el('div', { class: 'hero' }, el('div', { class: 'score grad' }, `${score} / ${TEST.length}`), el('h2', {}, `Оценка: ${g}`),
- el('p', { class: 'lead' }, g === 5 ? 'Отлично! Материал усвоен.' : g === 4 ? 'Хорошо! Загляни в разбор ошибок.' : g === 3 ? 'Удовлетворительно. Рекомендуется повторить сцены с моделями.' : 'Нужно повторить материал и пересдать.'),
- el('div', { class: 'row' }, el('button', { class: 'btn primary', onclick: () => { i = 0; score = 0; wrongs.length = 0; show(); } }, '↻ Пересдать'), el('button', { class: 'btn', onclick: () => ctx.go(1) }, '📚 Повторить курс'))),
- wrongs.length ? el('div', {}, el('h3', {}, 'Разбор ошибок'), wrongs.map(w => el('div', { class: 'rev' }, el('b', {}, w.q), el('div', {}, 'Твой ответ: ' + w.you), el('div', {}, 'Верно: ' + w.right), el('small', {}, w.e)))) : el('div', { class: 'rev g' }, 'Ошибок нет — отличная работа!'));
- }
- show(); ctx.gate(() => false);
- }
- /* ===== Тосты, модальное меню, конфетти ===== */
- let toastT;
- function toast(msg) { const t = $('#toast'); t.textContent = msg; t.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove('show'), 2600); }
- function modal(title, body, actions = []) {
- const m = $('#modal'); m.innerHTML = ''; m.hidden = false;
- const close = () => { m.hidden = true; m.innerHTML = ''; };
- m.append(el('div', { class: 'sheet card' }, el('div', { class: 'sheet-h' }, el('h3', {}, title), el('button', { class: 'icon-btn', onclick: close, 'aria-label': 'Закрыть' }, '✕')), body, el('div', { class: 'row', style: 'margin-top:14px' }, actions.map(([t, f, cls]) => el('button', { class: 'btn ' + (cls || ''), onclick: () => { f(); close(); } }, t)))));
- m.onclick = e => { if (e.target === m) close(); };
- return close;
- }
- function confetti() {
- const cv = $('#confetti'), c = cv.getContext('2d'); cv.width = innerWidth; cv.height = innerHeight; cv.style.display = 'block';
- const cols = ['#ff7a3d', '#ffd166', '#5ec8ff', '#42d392', '#ff5470'];
- const ps = Array.from({ length: 140 }, () => ({ x: rnd(0, cv.width), y: rnd(-cv.height, 0), s: rnd(5, 10), v: rnd(2, 5), r: rnd(0, 6), c: cols[Math.floor(rnd(0, 5))] }));
- let f = 0; (function tick() { c.clearRect(0, 0, cv.width, cv.height); ps.forEach(p => { p.y += p.v; p.x += Math.sin(p.y / 30); p.r += .1; c.save(); c.translate(p.x, p.y); c.rotate(p.r); c.fillStyle = p.c; c.fillRect(-p.s / 2, -p.s / 4, p.s, p.s / 2); c.restore(); }); if (++f < 220) requestAnimationFrame(tick); else { c.clearRect(0, 0, cv.width, cv.height); cv.style.display = 'none'; } })();
- }
- const HINTS = ['Нажми «Начать курс». Звук можно выключить кнопкой 🔊.',
- 'Двигай ползунок до конца (≥ 75 %) и ответь на вопрос внизу: что видно первым при тлении — дым.',
- 'Нажми «Тление (дым)» и дождись тревоги, затем «Пар из ванной» — это ложная тревога. После этого ответь на вопрос.',
- 'Запусти оба сценария: «Солнечный день» и «Пожар» (ждать до конца графика), затем ответь на вопрос.',
- 'Ответь на все 5 карточек. Дым — тление и чистые помещения, тепло — пар и жир, пламя — ЛВЖ.',
- 'Подожги два разных помещения, дождись сирены, нажми «Квитировать». Потом можно сделать «Сброс».',
- 'Порядок: сигнал → ППКП/оповещение → проверка дежурным → вызов 101/112 → тушение и дымоудаление → сброс.',
- 'Нажми «Итоговое Тестирование». В тесте 9 вопросов, на каждый сразу даётся пояснение.',
- 'Выбирай один вариант. Верный ответ подсвечивается сразу после выбора.'];
- function showMenu() {
- const list = el('div', { class: 'menu-list' });
- const furthest = SCENES.reduce((m, s, k) => S.done.has(k) ? Math.max(m, k + 1) : m, 0);
- SCENES.forEach((s, k) => { const open = k <= Math.max(furthest, S.i) || S.done.has(k); const b = el('button', { class: 'menu-item' + (k === S.i ? ' cur' : ''), disabled: open ? null : '', onclick: () => { $('#modal').hidden = true; Snd.click(); go(k); } }, el('span', { class: 'num' }, S.done.has(k) ? '✔' : String(k + 1)), el('span', {}, s.hidden ? 'Итоговое тестирование' : s.n)); list.append(b); });
- const best = store.get('best', null);
- modal('Сцены курса', el('div', {}, list, el('p', { class: 'hint', style: 'text-align:left' }, best ? `Лучший результат теста: ${best.s} из ${best.t} (оценка ${best.g})` : 'Тест ещё не пройден')),
- [['↺ Сбросить весь прогресс', () => { S.done.clear(); toast('Прогресс сброшен'); go(0); }], ['🗑 Стереть результат теста', () => { store.set('best', null); toast('Результат теста стёрт'); }, 'ghost']]);
- }
- function resetScene() { Snd.click(); S.done.delete(S.i); go(S.i); toast('Сцена сброшена'); }
- function showTip() { Snd.click(); modal('💡 Подсказка', el('p', { class: 'lead', style: 'margin:0' }, HINTS[S.i])); }
- /* ===== Каркас приложения ===== */
- const SCENES = [
- { n: 'Старт', f: sceneStart }, { n: 'Стадии пожара', f: sceneStages }, { n: 'Дымовой', f: sceneSmoke }, { n: 'Тепловой', f: sceneHeat },
- { n: 'Подбор', f: scenePick }, { n: 'ППКП', f: scenePanel }, { n: 'Алгоритм', f: sceneAlgo }, { n: 'Финал', f: sceneFinal }, { n: 'Тест', f: sceneTest, hidden: true }
- ];
- const S = { i: 0, cleanup: null, done: new Set(), gate: null };
- const stage = $('#stage'), prev = $('#prev'), next = $('#next'), hint = $('#hint'), dots = $('#dots');
- function updateNav() {
- const sc = SCENES[S.i], ok = S.done.has(S.i) || (S.gate && S.gate());
- if (ok && !S.done.has(S.i) && S.i > 0 && S.i < 8) toast('✔ Задание выполнено — можно идти дальше');
- if (ok) S.done.add(S.i);
- next.disabled = !ok || S.i >= 7; prev.disabled = S.i === 0 || S.i === 8;
- next.style.visibility = S.i >= 7 ? 'hidden' : '';
- hint.className = 'hint' + (ok ? ' ok' : ''); hint.textContent = S.i === 8 ? '' : ok ? '✔ Задание выполнено — можно идти дальше' : (S.i === 0 ? '' : 'Выполни задание сцены, чтобы продолжить');
- $('#step').textContent = S.i === 8 ? 'Итоговое тестирование' : `Сцена ${S.i + 1} из 8 · ${sc.n}`;
- dots.innerHTML = ''; SCENES.filter(s => !s.hidden).forEach((s, k) => dots.append(el('button', { class: 'dot' + (S.done.has(k) ? ' done' : '') + (k === S.i ? ' cur' : ''), title: s.n, 'aria-label': s.n, disabled: !(S.done.has(k) || k < S.i) || S.i === 8 ? '' : null, onclick: () => { if (k !== S.i) { Snd.click(); go(k); } } })));
- }
- function go(i) {
- if (S.cleanup) { try { S.cleanup(); } catch (e) { } S.cleanup = null; } Snd.stopAll();
- S.i = i; S.gate = null; $('#app').classList.toggle('first', i === 0); stage.innerHTML = ''; if (i >= 1 && i <= 7) stage.append(el('div', { class: 'kicker' }, `СЦЕНА ${i + 1} · ${SCENES[i].n.toUpperCase()}`)); stage.style.animation = 'none'; void stage.offsetWidth; stage.style.animation = '';
- const ctx = { next: () => go(S.i + 1), go, reset: () => { S.done.delete(S.i); go(S.i); }, unlock() { S.done.add(S.i); updateNav(); }, isUnlocked: () => S.done.has(S.i), gate(fn) { S.gate = fn; updateNav(); } };
- S.cleanup = SCENES[i].f(stage, ctx) || null; updateNav(); window.scrollTo(0, 0);
- }
- setInterval(updateNav, 150);
- prev.onclick = () => { Snd.click(); go(S.i - 1); };
- next.onclick = () => { Snd.click(); go(S.i + 1); };
- const sb = $('#sound'); sb.textContent = Snd.on ? '🔊' : '🔇';
- sb.onclick = () => { Snd.on = !Snd.on; store.set('sound', Snd.on); sb.textContent = Snd.on ? '🔊' : '🔇'; if (!Snd.on) Snd.stopAll(); else Snd.click(); };
- if ('serviceWorker' in navigator && location.protocol.startsWith('http')) navigator.serviceWorker.register('sw.js').catch(() => { });
- $('#menu').onclick = showMenu; $('#tip').onclick = showTip; $('#reset').onclick = resetScene;
- $('#full').onclick = () => { try { document.fullscreenElement ? document.exitFullscreen() : document.documentElement.requestFullscreen(); } catch (e) { toast('Полноэкранный режим недоступен'); } };
- document.addEventListener('keydown', e => {
- if (e.target.matches('input,textarea') || e.ctrlKey || e.metaKey || e.altKey) return;
- if (!$('#modal').hidden) { if (e.key === 'Escape') $('#modal').hidden = true; return; }
- const k = e.key.toLowerCase();
- if (e.key === 'ArrowRight' && !next.disabled && next.style.visibility !== 'hidden') next.click();
- else if (e.key === 'ArrowLeft' && !prev.disabled) prev.click();
- else if (k === 'm' || k === 'ь') showMenu(); else if (k === 'h' || k === 'р') showTip(); else if (k === 'r' || k === 'к') resetScene(); else if (k === 'f' || k === 'а') $('#full').click();
- });
- window.__go = go; // для отладки/автотестов
- go(0);
|