app.js 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. 'use strict';
  2. /* ===== Утилиты ===== */
  3. const $ = (s, r = document) => r.querySelector(s);
  4. const rnd = (a, b) => a + Math.random() * (b - a);
  5. const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
  6. const el = (tag, attrs = {}, ...kids) => {
  7. const e = document.createElement(tag);
  8. for (const [k, v] of Object.entries(attrs)) {
  9. if (v == null || v === false) continue;
  10. if (k === 'class') e.className = v;
  11. else if (k === 'html') e.innerHTML = v;
  12. else if (k.startsWith('on')) e.addEventListener(k.slice(2), v);
  13. else e.setAttribute(k, v);
  14. }
  15. kids.flat().forEach(c => { if (c != null && c !== false) e.append(c.nodeType ? c : document.createTextNode(c)); });
  16. return e;
  17. };
  18. const store = {
  19. get(k, d) { try { const v = localStorage.getItem(k); return v == null ? d : JSON.parse(v); } catch (e) { return d; } },
  20. set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* нет доступа к хранилищу */ } }
  21. };
  22. /* ===== Звук (Web Audio + синтез речи) ===== */
  23. const Snd = {
  24. ctx: null, on: store.get('sound', true), sirenTimer: null, sirenOsc: null,
  25. init() {
  26. if (!this.ctx) { const A = window.AudioContext || window.webkitAudioContext; if (A) this.ctx = new A(); }
  27. if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
  28. return this.ctx;
  29. },
  30. tone(f = 600, d = .12, type = 'sine', v = .07, when = 0) {
  31. if (!this.on) return; const c = this.init(); if (!c) return;
  32. const t = c.currentTime + when, o = c.createOscillator(), g = c.createGain();
  33. o.type = type; o.frequency.setValueAtTime(f, t);
  34. g.gain.setValueAtTime(v, t); g.gain.exponentialRampToValueAtTime(.0001, t + d);
  35. o.connect(g); g.connect(c.destination); o.start(t); o.stop(t + d + .02);
  36. },
  37. click() { this.tone(520, .06, 'triangle', .05); },
  38. ok() { this.tone(660, .12, 'sine', .08); this.tone(990, .18, 'sine', .08, .1); },
  39. bad() { this.tone(180, .25, 'sawtooth', .06); },
  40. beep() { this.tone(2900, .12, 'square', .035); },
  41. siren(start) {
  42. if (!start) {
  43. clearInterval(this.sirenTimer); this.sirenTimer = null;
  44. if (this.sirenOsc) { try { this.sirenOsc.stop(); } catch (e) { } this.sirenOsc = null; }
  45. return;
  46. }
  47. if (!this.on || this.sirenTimer) return;
  48. const c = this.init(); if (!c) return;
  49. const o = c.createOscillator(), g = c.createGain();
  50. o.type = 'sawtooth'; g.gain.value = .05; o.connect(g); g.connect(c.destination); o.start();
  51. this.sirenOsc = o; let hi = false;
  52. const tick = () => { o.frequency.setTargetAtTime(hi ? 980 : 720, c.currentTime, .03); hi = !hi; };
  53. tick(); this.sirenTimer = setInterval(tick, 420);
  54. },
  55. speak(text) {
  56. if (!this.on || !('speechSynthesis' in window)) return;
  57. try { speechSynthesis.cancel(); const u = new SpeechSynthesisUtterance(text); u.lang = 'ru-RU'; u.rate = .95; speechSynthesis.speak(u); } catch (e) { }
  58. },
  59. stopAll() { this.siren(false); try { speechSynthesis && speechSynthesis.cancel(); } catch (e) { } }
  60. };
  61. /* ===== Мини-тест внутри сцены ===== */
  62. function miniQuiz(q, onRight) {
  63. const box = el('div', { class: 'card quiz' });
  64. const fb = el('div', { class: 'msg' }, 'Проверь себя: выбери ответ.');
  65. box.append(el('h3', {}, '❓ ' + q.t));
  66. const btns = q.o.map((txt, i) => {
  67. const b = el('button', { class: 'opt' }, txt);
  68. b.onclick = () => {
  69. if (i === q.a) {
  70. Snd.ok(); b.classList.add('right'); btns.forEach(x => x.disabled = true);
  71. fb.className = 'msg good'; fb.textContent = '✔ Верно. ' + q.e; onRight && onRight();
  72. } else { Snd.bad(); b.classList.add('wrong'); b.disabled = true; fb.className = 'msg bad'; fb.textContent = '✖ Не совсем. Подумай ещё и попробуй другой вариант.'; }
  73. };
  74. return b;
  75. });
  76. box.append(...btns, fb);
  77. return box;
  78. }
  79. /* ===== Сцена 1: Старт ===== */
  80. function sceneStart(root, ctx) {
  81. const cv = el('canvas', { width: 560, height: 280 });
  82. const hero = el('div', { class: 'hero' },
  83. cv,
  84. el('span', { class: 'pill' }, 'Технические средства защиты информации и объектов · ТЗИ'),
  85. el('h1', { html: 'Как «видит» пожар <span class="grad">пожарная сигнализация</span>' }),
  86. el('p', { class: 'lead' }, 'Интерактивный курс на 5–10 минут: соберёшь в голове принцип работы дымового и теплового извещателей, подберёшь прибор для помещения, запустишь пожарный пост и пройдёшь итоговое тестирование.'),
  87. el('button', { class: 'btn primary big', onclick: () => { Snd.init(); Snd.ok(); ctx.next(); } }, '▶ Начать курс'),
  88. el('p', { class: 'hint' }, 'Включи звук: в курсе есть сирена и голосовое оповещение 🔊')
  89. );
  90. root.append(hero); ctx.unlock();
  91. return fireLoop(cv, () => 78, true);
  92. }
  93. /* ===== Анимация «видео» пожара на canvas ===== */
  94. function fireLoop(canvas, getLevel, ambient) {
  95. const c = canvas.getContext('2d'); let ps = [], raf, run = true;
  96. function frame() {
  97. if (!run) return;
  98. const W = canvas.width, H = canvas.height, L = getLevel();
  99. c.globalCompositeOperation = 'source-over';
  100. const g = c.createLinearGradient(0, 0, 0, H); g.addColorStop(0, '#0b0f1a'); g.addColorStop(1, '#1b1f33');
  101. c.fillStyle = g; c.fillRect(0, 0, W, H);
  102. c.fillStyle = '#242b45'; c.fillRect(0, H - 34, W, 34);
  103. c.fillStyle = '#394260'; c.fillRect(W / 2 - 60, H - 64, 120, 30); // «мебель»
  104. 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 });
  105. 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 });
  106. 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); }
  107. for (const p of ps) {
  108. p.x += p.vx; p.y += p.vy; p.l -= p.t === 'f' ? .028 : .006; p.r += p.t === 'f' ? -.15 : .13;
  109. 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(); }
  110. }
  111. c.globalCompositeOperation = 'lighter';
  112. 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(); }
  113. c.globalCompositeOperation = 'source-over';
  114. ps = ps.filter(p => p.l > 0 && p.r > 0 && p.y > -20); if (ps.length > 700) ps.splice(0, 100);
  115. if (!ambient) { // потолочные извещатели
  116. [[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); });
  117. }
  118. raf = requestAnimationFrame(frame);
  119. }
  120. frame();
  121. return () => { run = false; cancelAnimationFrame(raf); };
  122. }
  123. /* ===== Сцена 2: Стадии пожара ===== */
  124. function sceneStages(root, ctx) {
  125. let level = 0;
  126. const cv = el('canvas', { class: 'cv', width: 640, height: 320 });
  127. const stageName = el('div', { class: 'msg' });
  128. const lamps = { smoke: el('span', { class: 'lamp' }, el('i'), 'Дымовой «Д»'), heat: el('span', { class: 'lamp' }, el('i'), 'Тепловой «Т»'), flame: el('span', { class: 'lamp' }, el('i'), 'Пламени «П»') };
  129. const th = { smoke: 18, heat: 55, flame: 70 }; const fired = {}; let maxL = 0;
  130. const slider = el('input', { type: 'range', min: 0, max: 100, value: 0 });
  131. const info = el('div', { class: 'msg' }, 'Двигай ползунок и наблюдай, какой извещатель сработает раньше.');
  132. const upd = () => {
  133. level = +slider.value; maxL = Math.max(maxL, level);
  134. const n = level < 8 ? 'Норма' : level < 35 ? 'Тление: появляются невидимые аэрозоли и первый дым' : level < 60 ? 'Задымление, растёт температура под потолком' : level < 80 ? 'Открытое горение, видно пламя' : 'Развитый пожар';
  135. stageName.textContent = `Стадия: ${n} (${level}%)`;
  136. for (const k in lamps) {
  137. const on = level >= th[k]; lamps[k].classList.toggle('on', on);
  138. if (on && !fired[k]) { fired[k] = 1; Snd.beep(); Snd.beep(); info.className = 'msg good'; info.textContent = { smoke: '🌫 Дымовой извещатель сработал первым — он ловит самые ранние признаки пожара (тление).', heat: '🌡 Тепловой извещатель сработал позже — когда температура уже заметно выросла.', flame: '🔥 Извещатель пламени сработал на излучение факела — им защищают помещения, где дыма мало, а огонь возникает быстро.' }[k]; }
  139. }
  140. };
  141. slider.oninput = upd;
  142. root.append(
  143. el('h2', {}, 'Как развивается пожар'),
  144. el('p', { class: 'lead' }, 'У огня есть «лестница» признаков: сначала дым, потом тепло, потом пламя. Каждый тип извещателя реагирует на свой признак. Подними ползунок «Развитие пожара» до конца.'),
  145. el('div', { class: 'grid2' },
  146. el('div', { class: 'card' }, cv, stageName),
  147. 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 = 'Ползунок сброшен. Смотри заново.'; } }, '↺ Сбросить ползунок'),
  148. el('div', { class: 'row' }, lamps.smoke, lamps.heat, lamps.flame), info,
  149. el('ul', { class: 'steps', html: '<li><b>Дымовой</b> — раннее обнаружение тления.</li><li><b>Тепловой</b> — для запылённых, парящих помещений.</li><li><b>Пламени</b> — ИК/УФ датчик на излучение факела.</li>' })))
  150. );
  151. const stop = fireLoop(cv, () => level);
  152. const q = miniQuiz({ t: 'Какой тип извещателя обычно срабатывает самым первым при тлении кабеля?', o: ['Тепловой', 'Дымовой', 'Пламени', 'Ручной'], a: 1, e: 'При тлении много дыма, но мало тепла и нет пламени.' });
  153. root.append(q);
  154. ctx.gate(() => maxL >= 75 && q.querySelector('.right'));
  155. return stop;
  156. }
  157. /* ===== Сцена 3: Оптический дымовой извещатель ===== */
  158. function sceneSmoke(root, ctx) {
  159. const svgBox = el('div', { class: 'card' });
  160. svgBox.innerHTML = `<svg viewBox="0 0 440 280" class="svg" aria-label="Модель дымовой камеры">
  161. <circle cx="220" cy="140" r="112" fill="#0e1424" stroke="#3b4a6b" stroke-width="4"/>
  162. <circle cx="220" cy="140" r="100" fill="none" stroke="#26314f" stroke-dasharray="4 6"/>
  163. <rect x="84" y="128" width="24" height="24" rx="4" fill="#ffcf33"/><text x="96" y="120" text-anchor="middle">ИК-светодиод</text>
  164. <line id="beam" x1="108" y1="140" x2="326" y2="140" stroke="#ffd166" stroke-width="3" opacity=".85"/>
  165. <rect x="326" y="130" width="10" height="20" fill="#444"/><text x="332" y="170" text-anchor="middle">Поглотитель</text>
  166. <rect x="250" y="206" width="28" height="14" rx="3" fill="#5ec8ff"/><text x="264" y="240" text-anchor="middle">Фотодиод</text>
  167. <g id="sc"></g><g id="ps" fill="#c3cbe0"></g>
  168. <circle id="alarmLed" cx="390" cy="36" r="9" fill="#394260"/><text x="390" y="64" text-anchor="middle">Индикатор</text>
  169. <text x="220" y="272" text-anchor="middle">Дымовая камера (вид сверху)</text></svg>`;
  170. const svg = svgBox.firstElementChild, ps = svg.querySelector('#ps'), sc = svg.querySelector('#sc'), led = svg.querySelector('#alarmLed');
  171. const slider = el('input', { type: 'range', min: 0, max: 100, value: 0 });
  172. const bar = el('div', { class: 'bar' }, el('div'), el('span', { class: 'thr', style: 'left:40%' }));
  173. const msg = el('div', { class: 'msg' }, 'Выбери источник частиц или двигай ползунок плотности.');
  174. let source = 'manual', density = 0, sig = 0, hold = 0, alarm = false, lastBeep = 0, gotTrue = false, gotFalse = false, quizShown = false;
  175. const sourceBtns = [['Чистый воздух', 0, 'air'], ['Тление (дым)', 58, 'smoke'], ['Пар из ванной', 66, 'steam'], ['Строительная пыль', 52, 'dust']].map(([n, v, s]) =>
  176. el('button', { class: 'btn sm', onclick: () => { Snd.click(); slider.value = v; source = s; density = v; } }, n));
  177. slider.oninput = () => { source = 'manual'; density = +slider.value; };
  178. const parts = []; let raf, last = performance.now();
  179. const NS = 'http://www.w3.org/2000/svg';
  180. const quizHolder = el('div');
  181. function loop(now) {
  182. const dt = (now - last) / 1000; last = now;
  183. const target = Math.round(density * .7);
  184. 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 }); }
  185. while (parts.length > target) parts.pop().e.remove();
  186. let rays = '', n = 0;
  187. for (const p of parts) {
  188. 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; }
  189. p.e.setAttribute('cx', p.x.toFixed(1)); p.e.setAttribute('cy', p.y.toFixed(1));
  190. const inb = Math.abs(p.y - 140) < 12 && p.x > 110 && p.x < 326; p.e.setAttribute('fill', inb ? '#ffe9a8' : '#c3cbe0');
  191. 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"/>`; }
  192. }
  193. sc.innerHTML = rays;
  194. const tgt = clamp(density / 100 * 1.02 + rnd(-.015, .015), 0, 1); sig += (tgt - sig) * .12;
  195. bar.firstChild.style.width = (sig * 100).toFixed(0) + '%';
  196. 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'); } }
  197. if (alarm) { led.setAttribute('fill', Math.floor(now / 250) % 2 ? '#ff5470' : '#5a1f2c'); if (now - lastBeep > 520) { Snd.beep(); Snd.beep(); lastBeep = now; } }
  198. raf = requestAnimationFrame(loop);
  199. }
  200. function onAlarm() {
  201. if (source === 'steam' || source === 'dust') { gotFalse = true; msg.className = 'msg bad'; msg.textContent = '⚠ ЛОЖНАЯ ТРЕВОГА! Частицы пара/пыли рассеивают свет так же, как дым — оптический датчик их не различает. Поэтому такие помещения защищают тепловыми или комбинированными извещателями.'; }
  202. else { gotTrue = true; msg.className = 'msg good'; msg.textContent = '🔔 Тревога! Частицы дыма рассеяли луч на фотодиод — сигнал превысил порог (красная метка) и держался дольше 0,7 с.'; }
  203. if (gotTrue && gotFalse && !quizShown) {
  204. quizShown = true;
  205. quizHolder.append(miniQuiz({ t: 'Почему в чистой камере фотодиод «молчит», хотя светодиод светит?', o: ['Фотодиод выключен до пожара', 'Он стоит вне прямого луча — свет попадает на него только после рассеяния на частицах', 'Светодиод включается только при пожаре', 'Луч поглощается стенками камеры полностью'], a: 1, e: 'Это эффект рассеяния (Тиндаля): нет частиц — нет рассеянного света — нет сигнала.' }));
  206. }
  207. }
  208. raf = requestAnimationFrame(loop);
  209. root.append(el('h2', {}, 'Оптический дымовой извещатель'),
  210. el('p', { class: 'lead' }, 'Внутри — тёмная камера, светодиод и фотодиод, расположенный в стороне от луча. Дым рассеивает свет, часть его попадает на фотодиод. Вызови настоящую и ложную тревогу.'),
  211. el('div', { class: 'grid2' }, svgBox,
  212. el('div', { class: 'card' }, el('h3', {}, 'Источник частиц'), el('div', { class: 'row' }, sourceBtns),
  213. el('p', {}, 'Плотность частиц:'), slider, el('p', {}, 'Сигнал фотодиода / порог:'), bar, msg)),
  214. quizHolder);
  215. ctx.gate(() => gotTrue && gotFalse && quizHolder.querySelector('.right'));
  216. return () => cancelAnimationFrame(raf);
  217. }
  218. /* ===== Сцена 4: Тепловой извещатель ===== */
  219. function sceneHeat(root, ctx) {
  220. const cv = el('canvas', { class: 'cv', width: 640, height: 280 });
  221. const g = cv.getContext('2d');
  222. const lampMax = el('span', { class: 'lamp' }, el('i'), 'Максимальный (≥ 70 °C)');
  223. const lampDif = el('span', { class: 'lamp' }, el('i'), 'Дифференциальный (≥ 8 °C/мин)');
  224. const info = el('div', { class: 'msg' }, 'Запусти оба сценария по очереди.');
  225. const readout = el('div', { class: 'lamp' }, 'T = — · скорость = —');
  226. const ran = { sun: false, fire: false }; let timer, quizDone = false; const quizHolder = el('div');
  227. function draw(T, tmax) {
  228. const W = cv.width, H = cv.height; g.clearRect(0, 0, W, H); g.fillStyle = '#0b0f1a'; g.fillRect(0, 0, W, H);
  229. g.strokeStyle = '#26314f'; g.fillStyle = '#7f8db0'; g.font = '12px sans-serif';
  230. 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); }
  231. 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([]);
  232. g.strokeStyle = '#ffd166'; g.lineWidth = 3; g.beginPath();
  233. 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;
  234. }
  235. function run(kind) {
  236. clearInterval(timer); Snd.click(); lampMax.classList.remove('on'); lampDif.classList.remove('on');
  237. const f = kind === 'sun' ? t => 20 + 2 * t : t => 22 + 14 * t, tmax = kind === 'sun' ? 30 : 6;
  238. const T = [f(0)]; let n = 0, mA = null, dA = null; draw(T, tmax);
  239. info.className = 'msg'; info.textContent = kind === 'sun' ? '☀ Солнце нагревает помещение медленно: ~2 °C в минуту…' : '🔥 Загорелась мебель: температура растёт ~14 °C в минуту…';
  240. timer = setInterval(() => {
  241. n++; const t = n / 10; T.push(f(t)); draw(T, tmax);
  242. const k = Math.max(0, n - 10), rate = (T[n] - T[k]) / ((n - k) / 10);
  243. readout.textContent = `T = ${T[n].toFixed(0)} °C · скорость = ${rate.toFixed(1)} °C/мин · t = ${t.toFixed(1)} мин`;
  244. if (T[n] >= 70 && mA == null) { mA = t; lampMax.classList.add('on'); Snd.beep(); Snd.beep(); }
  245. if (rate >= 8 && dA == null) { dA = t; lampDif.classList.add('on'); Snd.beep(); Snd.beep(); }
  246. if (n >= tmax * 10) {
  247. clearInterval(timer); ran[kind] = true;
  248. if (kind === 'sun') { info.className = 'msg bad'; info.textContent = `Максимальный извещатель сработал на ${mA.toFixed(1)}-й минуте — ЛОЖНАЯ тревога от нагрева солнцем. Дифференциальный промолчал: скорость роста мала.`; }
  249. else { info.className = 'msg good'; info.textContent = `Дифференциальный сработал уже на ${dA.toFixed(1)} мин, максимальный — только на ${mA.toFixed(1)} мин: по скорости роста пожар виден раньше.`; }
  250. if (ran.sun && ran.fire && !quizDone) { quizDone = true; quizHolder.append(miniQuiz({ t: 'Какой извещатель сработает при быстром росте температуры, даже если порог 70 °C ещё не достигнут?', o: ['Максимальный', 'Дифференциальный', 'Дымовой оптический', 'Ни один'], a: 1, e: 'Дифференциальный измеряет скорость нарастания температуры (°C/мин).' })); }
  251. }
  252. }, 40);
  253. }
  254. draw([20], 30);
  255. root.append(el('h2', {}, 'Тепловые извещатели'),
  256. el('p', { class: 'lead' }, 'Термистор в корпусе измеряет температуру воздуха под потолком. Максимальный реагирует на порог, дифференциальный — на скорость роста. Сравни их.'),
  257. el('div', { class: 'grid2' }, el('div', { class: 'card' }, cv, readout),
  258. el('div', { class: 'card' }, el('h3', {}, 'Сценарии'),
  259. el('div', { class: 'row' }, el('button', { class: 'btn', onclick: () => run('sun') }, '☀ Солнечный день'), el('button', { class: 'btn', onclick: () => run('fire') }, '🔥 Пожар')),
  260. el('div', { class: 'row', style: 'margin-top:12px' }, lampMax, lampDif), info)),
  261. quizHolder);
  262. ctx.gate(() => ran.sun && ran.fire && quizHolder.querySelector('.right'));
  263. return () => clearInterval(timer);
  264. }
  265. /* ===== Сцена 5: Подбор извещателя ===== */
  266. function scenePick(root, ctx) {
  267. const cases = [
  268. ['🖥 Серверная', 'Кабели могут тлеть, дыма много, пламени нет. Чистый воздух.', 0, 'Дымовой: тление кабелей даёт дым задолго до тепла.'],
  269. ['🍳 Кухня ресторана', 'Пар, жир, дым от жарки.', 1, 'Тепловой: дым и пар вызывали бы ложные тревоги.'],
  270. ['⛽ Склад ЛВЖ (бензин, спирты)', 'Горит быстро, почти без дыма, высокие потолки.', 2, 'Пламени: реагирует на излучение факела мгновенно.'],
  271. ['🚪 Коридор общежития', 'Чистое помещение, люди спят, нужно раннее оповещение.', 0, 'Дымовой: самое раннее обнаружение — время на эвакуацию.'],
  272. ['🧖 Сауна / парная', 'Постоянный пар и высокая температура.', 1, 'Тепловой (с повышенным порогом): пар «обманул» бы оптический.']
  273. ];
  274. const names = ['Дымовой', 'Тепловой', 'Пламени']; let done = 0, ok = 0;
  275. const score = el('div', { class: 'pill' }, 'Верно: 0 из 5');
  276. const list = cases.map(([t, d, a, e]) => {
  277. const fb = el('div', { class: 'msg' }, 'Выбери извещатель'); const row = el('div', { class: 'row' });
  278. const b = names.map((n, i) => el('button', { class: 'btn sm', onclick: () => {
  279. b.forEach(x => x.disabled = true); done++;
  280. 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}`; }
  281. score.textContent = `Верно: ${ok} из 5`; if (done === cases.length) ctx.unlock();
  282. } }, n)); row.append(...b);
  283. return el('div', { class: 'card' }, el('h3', {}, t), el('small', { class: 'lead' }, d), row, fb);
  284. });
  285. 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));
  286. ctx.gate(() => done === cases.length);
  287. }
  288. /* ===== Сцена 6: ППКП и здание ===== */
  289. function scenePanel(root, ctx) {
  290. const rooms = [{ n: 'Кухня', d: 'Тепловой', t: 2400, ic: '🍳' }, { n: 'Серверная', d: 'Дымовой', t: 1300, ic: '🖥' }, { n: 'Склад ЛВЖ', d: 'Пламени', t: 700, ic: '⛽' }, { n: 'Коридор', d: 'Дымовой', t: 1500, ic: '🚪' }];
  291. const lcd = el('div', { class: 'lcd' }, 'ДЕЖУРНЫЙ РЕЖИМ\nВсе шлейфы в норме');
  292. const log = el('div', { class: 'log' }); const zoneLamps = rooms.map((r, i) => el('span', { class: 'lamp' }, el('i'), 'ЗОНА ' + (i + 1)));
  293. const alarms = new Set(); let acked = false, everAcked = false, timers = [];
  294. const L = m => { const d = new Date(); log.prepend(el('div', {}, d.toLocaleTimeString('ru-RU') + ' — ' + m)); };
  295. const refresh = () => {
  296. if (!alarms.size) { lcd.className = 'lcd'; lcd.textContent = 'ДЕЖУРНЫЙ РЕЖИМ\nВсе шлейфы в норме'; return; }
  297. lcd.className = 'lcd alert'; lcd.textContent = (acked ? 'ПОЖАР (квитировано)\n' : '*** ПОЖАР ***\n') + [...alarms].map(i => `Зона ${i + 1}: ${rooms[i].n}`).join('\n');
  298. };
  299. const cards = rooms.map((r, i) => {
  300. 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' }, '🔥'));
  301. return c;
  302. });
  303. function ignite(i, manual) {
  304. if (alarms.has(i) || cards[i].classList.contains('burn')) return; Snd.click(); cards[i].classList.add('burn'); L(`Возгорание в «${rooms[i].n}»`);
  305. timers.push(setTimeout(() => trigger(i, manual ? 'ручной извещатель' : rooms[i].d.toLowerCase() + ' извещатель'), manual ? 0 : rooms[i].t));
  306. }
  307. function trigger(i, by) {
  308. alarms.add(i); acked = false; cards[i].classList.add('alarm'); zoneLamps[i].classList.add('on'); L(`Сработал ${by}, зона ${i + 1}`); refresh();
  309. Snd.siren(true); Snd.speak(`Внимание! Пожар! Зона ${i + 1}. ${rooms[i].n}. Покиньте помещение.`);
  310. check();
  311. }
  312. const ackBtn = el('button', { class: 'btn', onclick: () => { if (!alarms.size) return; Snd.click(); acked = true; everAcked = true; Snd.stopAll(); L('Дежурный квитировал сигнал'); refresh(); check(); } }, '🔕 Квитировать');
  313. 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(); } }, '♻ Сброс');
  314. const manualBtn = el('button', { class: 'btn', onclick: () => { ignite(3, true); } }, '🧯 Ручной извещатель (коридор)');
  315. function check() { if (alarms.size >= 2 && everAcked) { ctx.unlock(); } }
  316. root.append(el('h2', {}, 'Пожарный пост: ППКП'),
  317. el('p', { class: 'lead' }, 'Прибор приёмно-контрольный пожарный (ППКП) опрашивает шлейфы с извещателями, показывает зону пожара, включает сирену и голосовое оповещение. Подожги два разных помещения, затем квитируй сигнал, а потом сделай сброс.'),
  318. el('div', { class: 'grid2' }, el('div', { class: 'grid3', style: 'grid-template-columns:1fr 1fr' }, cards),
  319. el('div', { class: 'panel' }, lcd, el('div', { class: 'row', style: 'margin:10px 0' }, zoneLamps), el('div', { class: 'row' }, ackBtn, resetBtn, manualBtn), log)));
  320. ctx.gate(() => alarms.size >= 2 && everAcked || ctx.isUnlocked());
  321. return () => { timers.forEach(clearTimeout); Snd.stopAll(); };
  322. }
  323. /* ===== Сцена 7: Алгоритм ===== */
  324. function sceneAlgo(root, ctx) {
  325. const correct = ['Извещатель обнаруживает признак пожара и передаёт сигнал на ППКП', 'ППКП определяет зону и включает сирену и оповещение', 'Дежурный квитирует сигнал и проверяет зону (камеры, обход)', 'Пожар подтверждён: вызов 101/112 и эвакуация людей', 'Запуск пожаротушения и дымоудаления, отключение вентиляции', 'После ликвидации — сброс ППКП и восстановление шлейфа'];
  326. const pool = correct.map((t, i) => ({ t, i })).sort(() => Math.random() - .5); let next = 0;
  327. const target = el('div', { class: 'chips' }), poolBox = el('div', { class: 'chips' }); const msg = el('div', { class: 'msg' }, 'Нажимай шаги в правильной последовательности.');
  328. pool.forEach(p => { const b = el('button', { class: 'chip' }, p.t); b.onclick = () => {
  329. 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(); } }
  330. else { Snd.bad(); b.classList.add('no'); setTimeout(() => b.classList.remove('no'), 400); msg.className = 'msg bad'; msg.textContent = 'Это ещё рано — подумай, что должно произойти раньше.'; }
  331. }; poolBox.append(b); });
  332. root.append(el('h2', {}, 'Порядок действий при тревоге'), el('p', { class: 'lead' }, 'Расставь этапы от срабатывания до возврата в дежурный режим.'), el('h3', {}, 'Перемешанные шаги'), poolBox, el('h3', {}, 'Твоя последовательность'), target, msg, el('button', { class: 'btn sm', onclick: () => ctx.reset() }, '↺ Перемешать и начать заново'));
  333. ctx.gate(() => next === correct.length);
  334. }
  335. /* ===== Вопросы итогового теста ===== */
  336. const TEST = [
  337. { q: 'На каком физическом явлении основан оптико-электронный дымовой извещатель?', o: ['Ионизация воздуха альфа-излучением', 'Рассеяние света частицами дыма (эффект Тиндаля)', 'Изменение сопротивления при нагреве', 'Приём ультрафиолета пламени'], a: 1, e: 'Свет светодиода рассеивается частицами дыма на фотодиод.' },
  338. { q: 'Почему фотодиод в дымовой камере не стоит на линии луча светодиода?', o: ['Чтобы он не перегревался', 'Чтобы в чистом воздухе сигнал был близок к нулю', 'Чтобы увеличить ток светодиода', 'Из-за требований дизайна корпуса'], a: 1, e: 'Фотодиод должен «видеть» только рассеянный свет.' },
  339. { q: 'Максимальный тепловой извещатель срабатывает, когда…', o: ['температура растёт быстрее 8 °C/мин', 'температура достигла порогового значения', 'в воздухе появился дым', 'нажата кнопка'], a: 1, e: 'Он сравнивает температуру с фиксированным порогом.' },
  340. { q: 'На что реагирует дифференциальный тепловой извещатель?', o: ['На абсолютную температуру', 'На скорость роста температуры', 'На влажность воздуха', 'На яркость света'], a: 1, e: 'Он оценивает градиент °C/мин, поэтому быстрее видит пожар.' },
  341. { q: 'Какой извещатель лучше для кухни с паром и жиром?', o: ['Дымовой оптический', 'Тепловой', 'Любой, разницы нет', 'Только звуковой'], a: 1, e: 'Пар и дым вызвали бы ложные срабатывания оптического датчика.' },
  342. { q: 'Что выбрать для склада ЛВЖ, где огонь распространяется быстро и почти без дыма?', o: ['Извещатель пламени', 'Максимальный тепловой с порогом 90 °C', 'Дымовой оптический', 'Ионизационный'], a: 0, e: 'ИК/УФ датчик реагирует на излучение факела.' },
  343. { q: 'Что такое ППКП?', o: ['Прибор приёмно-контрольный пожарный', 'Пульт пожарного координатора', 'Преобразователь питания', 'Плата предварительной калибровки'], a: 0, e: 'Это «мозг» системы: опрашивает шлейфы и управляет оповещением.' },
  344. { q: 'Что вызовет ложную тревогу оптического дымового извещателя?', o: ['Плотный пар или пыль', 'Выключенный свет', 'Низкая температура', 'Тишина'], a: 0, e: 'Частицы пара и пыли тоже рассеивают свет.' },
  345. { q: 'Что означает «квитировать» сигнал?', o: ['Полностью устранить пожар', 'Подтвердить приём сигнала дежурным и заглушить звук', 'Отключить шлейф навсегда', 'Сбросить прибор в заводские настройки'], a: 1, e: 'Квитирование — подтверждение приёма. Пожар при этом остаётся в памяти ППКП до сброса.' }
  346. ];
  347. /* ===== Сцена 8: Финал ===== */
  348. function sceneFinal(root, ctx) {
  349. const best = store.get('best', null);
  350. root.append(el('div', { class: 'hero' }, el('div', { style: 'font-size:72px' }, '🎓'), el('h1', { html: 'Материал <span class="grad">пройден</span>' }),
  351. el('p', { class: 'lead' }, 'Ты познакомился с принципами работы дымового, теплового извещателей и ППКП. Осталось проверить, как усвоился материал: 9 вопросов с мгновенной проверкой.'),
  352. el('button', { class: 'btn primary big', onclick: () => { Snd.ok(); ctx.go(8); } }, '📝 Итоговое Тестирование'),
  353. best ? el('p', { class: 'hint' }, `Лучший результат: ${best.s} из ${best.t} (оценка ${best.g})`) : null));
  354. ctx.unlock();
  355. }
  356. /* ===== Сцена 9: Тест ===== */
  357. function sceneTest(root, ctx) {
  358. let i = 0, score = 0; const wrongs = [];
  359. const box = el('div'); root.append(box);
  360. function show() {
  361. box.innerHTML = ''; const q = TEST[i];
  362. 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 ? 'Показать результат' : 'Следующий вопрос →');
  363. const opts = q.o.map((t, k) => el('button', { class: 'opt', onclick: () => {
  364. opts.forEach(o => o.disabled = true); opts[q.a].classList.add('right');
  365. 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; }
  366. nxt.style.display = '';
  367. } }, t));
  368. 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)));
  369. }
  370. function finish() {
  371. const p = score / TEST.length, g = p >= .9 ? 5 : p >= .75 ? 4 : p >= .55 ? 3 : 2;
  372. const prev = store.get('best', null); if (!prev || score > prev.s) store.set('best', { s: score, t: TEST.length, g });
  373. box.innerHTML = ''; (g >= 4 ? (Snd.ok(), confetti()) : Snd.bad());
  374. box.append(el('div', { class: 'hero' }, el('div', { class: 'score grad' }, `${score} / ${TEST.length}`), el('h2', {}, `Оценка: ${g}`),
  375. el('p', { class: 'lead' }, g === 5 ? 'Отлично! Материал усвоен.' : g === 4 ? 'Хорошо! Загляни в разбор ошибок.' : g === 3 ? 'Удовлетворительно. Рекомендуется повторить сцены с моделями.' : 'Нужно повторить материал и пересдать.'),
  376. 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) }, '📚 Повторить курс'))),
  377. 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' }, 'Ошибок нет — отличная работа!'));
  378. }
  379. show(); ctx.gate(() => false);
  380. }
  381. /* ===== Тосты, модальное меню, конфетти ===== */
  382. let toastT;
  383. function toast(msg) { const t = $('#toast'); t.textContent = msg; t.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove('show'), 2600); }
  384. function modal(title, body, actions = []) {
  385. const m = $('#modal'); m.innerHTML = ''; m.hidden = false;
  386. const close = () => { m.hidden = true; m.innerHTML = ''; };
  387. 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)))));
  388. m.onclick = e => { if (e.target === m) close(); };
  389. return close;
  390. }
  391. function confetti() {
  392. const cv = $('#confetti'), c = cv.getContext('2d'); cv.width = innerWidth; cv.height = innerHeight; cv.style.display = 'block';
  393. const cols = ['#ff7a3d', '#ffd166', '#5ec8ff', '#42d392', '#ff5470'];
  394. 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))] }));
  395. 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'; } })();
  396. }
  397. const HINTS = ['Нажми «Начать курс». Звук можно выключить кнопкой 🔊.',
  398. 'Двигай ползунок до конца (≥ 75 %) и ответь на вопрос внизу: что видно первым при тлении — дым.',
  399. 'Нажми «Тление (дым)» и дождись тревоги, затем «Пар из ванной» — это ложная тревога. После этого ответь на вопрос.',
  400. 'Запусти оба сценария: «Солнечный день» и «Пожар» (ждать до конца графика), затем ответь на вопрос.',
  401. 'Ответь на все 5 карточек. Дым — тление и чистые помещения, тепло — пар и жир, пламя — ЛВЖ.',
  402. 'Подожги два разных помещения, дождись сирены, нажми «Квитировать». Потом можно сделать «Сброс».',
  403. 'Порядок: сигнал → ППКП/оповещение → проверка дежурным → вызов 101/112 → тушение и дымоудаление → сброс.',
  404. 'Нажми «Итоговое Тестирование». В тесте 9 вопросов, на каждый сразу даётся пояснение.',
  405. 'Выбирай один вариант. Верный ответ подсвечивается сразу после выбора.'];
  406. function showMenu() {
  407. const list = el('div', { class: 'menu-list' });
  408. const furthest = SCENES.reduce((m, s, k) => S.done.has(k) ? Math.max(m, k + 1) : m, 0);
  409. 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); });
  410. const best = store.get('best', null);
  411. modal('Сцены курса', el('div', {}, list, el('p', { class: 'hint', style: 'text-align:left' }, best ? `Лучший результат теста: ${best.s} из ${best.t} (оценка ${best.g})` : 'Тест ещё не пройден')),
  412. [['↺ Сбросить весь прогресс', () => { S.done.clear(); toast('Прогресс сброшен'); go(0); }], ['🗑 Стереть результат теста', () => { store.set('best', null); toast('Результат теста стёрт'); }, 'ghost']]);
  413. }
  414. function resetScene() { Snd.click(); S.done.delete(S.i); go(S.i); toast('Сцена сброшена'); }
  415. function showTip() { Snd.click(); modal('💡 Подсказка', el('p', { class: 'lead', style: 'margin:0' }, HINTS[S.i])); }
  416. /* ===== Каркас приложения ===== */
  417. const SCENES = [
  418. { n: 'Старт', f: sceneStart }, { n: 'Стадии пожара', f: sceneStages }, { n: 'Дымовой', f: sceneSmoke }, { n: 'Тепловой', f: sceneHeat },
  419. { n: 'Подбор', f: scenePick }, { n: 'ППКП', f: scenePanel }, { n: 'Алгоритм', f: sceneAlgo }, { n: 'Финал', f: sceneFinal }, { n: 'Тест', f: sceneTest, hidden: true }
  420. ];
  421. const S = { i: 0, cleanup: null, done: new Set(), gate: null };
  422. const stage = $('#stage'), prev = $('#prev'), next = $('#next'), hint = $('#hint'), dots = $('#dots');
  423. function updateNav() {
  424. const sc = SCENES[S.i], ok = S.done.has(S.i) || (S.gate && S.gate());
  425. if (ok && !S.done.has(S.i) && S.i > 0 && S.i < 8) toast('✔ Задание выполнено — можно идти дальше');
  426. if (ok) S.done.add(S.i);
  427. next.disabled = !ok || S.i >= 7; prev.disabled = S.i === 0 || S.i === 8;
  428. next.style.visibility = S.i >= 7 ? 'hidden' : '';
  429. hint.className = 'hint' + (ok ? ' ok' : ''); hint.textContent = S.i === 8 ? '' : ok ? '✔ Задание выполнено — можно идти дальше' : (S.i === 0 ? '' : 'Выполни задание сцены, чтобы продолжить');
  430. $('#step').textContent = S.i === 8 ? 'Итоговое тестирование' : `Сцена ${S.i + 1} из 8 · ${sc.n}`;
  431. 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); } } })));
  432. }
  433. function go(i) {
  434. if (S.cleanup) { try { S.cleanup(); } catch (e) { } S.cleanup = null; } Snd.stopAll();
  435. 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 = '';
  436. 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(); } };
  437. S.cleanup = SCENES[i].f(stage, ctx) || null; updateNav(); window.scrollTo(0, 0);
  438. }
  439. setInterval(updateNav, 150);
  440. prev.onclick = () => { Snd.click(); go(S.i - 1); };
  441. next.onclick = () => { Snd.click(); go(S.i + 1); };
  442. const sb = $('#sound'); sb.textContent = Snd.on ? '🔊' : '🔇';
  443. sb.onclick = () => { Snd.on = !Snd.on; store.set('sound', Snd.on); sb.textContent = Snd.on ? '🔊' : '🔇'; if (!Snd.on) Snd.stopAll(); else Snd.click(); };
  444. if ('serviceWorker' in navigator && location.protocol.startsWith('http')) navigator.serviceWorker.register('sw.js').catch(() => { });
  445. $('#menu').onclick = showMenu; $('#tip').onclick = showTip; $('#reset').onclick = resetScene;
  446. $('#full').onclick = () => { try { document.fullscreenElement ? document.exitFullscreen() : document.documentElement.requestFullscreen(); } catch (e) { toast('Полноэкранный режим недоступен'); } };
  447. document.addEventListener('keydown', e => {
  448. if (e.target.matches('input,textarea') || e.ctrlKey || e.metaKey || e.altKey) return;
  449. if (!$('#modal').hidden) { if (e.key === 'Escape') $('#modal').hidden = true; return; }
  450. const k = e.key.toLowerCase();
  451. if (e.key === 'ArrowRight' && !next.disabled && next.style.visibility !== 'hidden') next.click();
  452. else if (e.key === 'ArrowLeft' && !prev.disabled) prev.click();
  453. 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();
  454. });
  455. window.__go = go; // для отладки/автотестов
  456. go(0);