Ver código fonte

Загрузить файлы ''

u23-27moroz 3 semanas atrás
pai
commit
c8e9c754d0
12 arquivos alterados com 936 adições e 0 exclusões
  1. 56 0
      CHANGELOG.md
  2. 53 0
      README.md
  3. 81 0
      SCENARIY.md
  4. 472 0
      app.js
  5. BIN
      icon-192.png
  6. BIN
      icon-512.png
  7. 8 0
      icon.svg
  8. 38 0
      index.html
  9. 24 0
      issues.md
  10. 17 0
      manifest.webmanifest
  11. 177 0
      style.css
  12. 10 0
      sw.js

+ 56 - 0
CHANGELOG.md

@@ -0,0 +1,56 @@
+# Отчёт об изменениях — «Пожарная сигнализация»
+
+Формат: дата → версия → что изменено. Время указано по Москве (UTC+3).
+
+---
+
+## v4.0 — 20.10.2026, 15:24 — Центрирование интерфейса
+- Основной контент сцен выровнен по центру: плашка сцены, заголовок (с симметричными акцентными чертами по бокам), вводный текст, счётчики, ряды кнопок и блоки шагов.
+- Сцена вертикально центрируется на широких экранах (минимальная высота = высота окна минус шапка и нижняя панель).
+- Максимальная ширина приложения уменьшена с 1100 до 1000 px — меньше пустоты по краям.
+- Нижняя панель навигации выровнена по центру; тексты внутри карточек, ЖК-экран ППКП и журнал остались выровненными по левому краю для читаемости.
+- На телефоне вертикальное центрирование отключено, чтобы не было лишних отступов.
+- Версия кэша Service Worker: `aps-course-v4`.
+
+---
+
+## v3.0 — 20.10.2026, 00:18 — Новые кнопки и дизайн
+**Новые кнопки (панель в шапке и внутри сцен):**
+- ☰ **Меню сцен** — список всех сцен с отметкой ✔, переход к пройденным; внутри — «↺ Сбросить весь прогресс» и «🗑 Стереть результат теста».
+- 💡 **Подсказка** — своя для каждой сцены, в модальном окне.
+- ↺ **Сбросить сцену** — перезапуск текущей сцены с нуля (для теста — перезапуск теста).
+- ⛶ **Полный экран**.
+- «↺ Сбросить ползунок» (сцена 2), «↺ Пройти заново» (сцена 5), «↺ Перемешать и начать заново» (сцена 7).
+- Горячие клавиши: ← / → навигация, **M** меню, **H** подсказка, **R** сброс, **F** полный экран, **Esc** закрыть окно.
+
+**Дизайн:**
+- Плашка-«kicker» над заголовком сцены («СЦЕНА 3 · ДЫМОВОЙ»).
+- Всплывающее уведомление «✔ Задание выполнено» при разблокировке перехода.
+- Модальные окна с размытием фона, конфетти при оценке 4–5.
+- Панель инструментов с круглыми кнопками, анимация поворота у «Сбросить».
+- Доработка адаптива для телефона (компактная панель инструментов).
+
+**Технически:** версия кэша Service Worker `aps-course-v3`.
+
+---
+
+## v2.0 — 20.10.2026, 00:11 — Переименование и редизайн интерфейса
+- Название «АПС · Интерактивный курс» заменено на **«Пожарная сигнализация»** (шапка, `<title>`, `manifest.webmanifest`, README, SCENARIO).
+- Новая шапка: логотип-иконка, подпись «Сцена N из 8 · название», кликабельные сегменты прогресса.
+- «Стеклянные» карточки, акцентная полоска у заголовков, пульсирующая кнопка старта, новые ползунки, анимированный фон, фокус-рамки, поддержка `prefers-reduced-motion`.
+- Исправлена шапка на телефоне (прогресс — отдельной строкой).
+- Исправлена работа атрибутов в хелпере `el()` (пустые значения больше не превращаются в строку «null»).
+
+---
+
+## v1.1 — 19.10.2026 — Исправления после первой проверки
+- Исправлено: варианты ответов мини-вопросов не отображались (массив кнопок в `append`).
+- Нижняя панель больше не перекрывает контент; на стартовом экране скрыта.
+- Разблокировка перехода со сцены 2 теперь только после выполнения всех условий.
+- Ускорен опрос условий перехода (150 мс).
+
+## v1.0 — 19.10.2026 — Первая версия
+- PWA на 8 сцен + итоговое тестирование (9 вопросов).
+- Модели: оптический дымовой извещатель, тепловые (максимальный и дифференциальный), пульт ППКП.
+- Аудио (сирена, сигналы, голосовое оповещение), canvas-анимация пожара.
+- Файлы: `SCENARIO.md`, `README.md`, `issues.md`, манифест, Service Worker, иконки.

+ 53 - 0
README.md

@@ -0,0 +1,53 @@
+# Пожарная сигнализация — интерактивный курс
+
+Одностраничное локальное PWA (HTML + CSS + JS, без внешних библиотек и сети).
+Тематика: **ТЗИ, п. 1.2 — принцип работы прибора, интерактивная модель** (оптический дымовой и тепловые извещатели, ППКП).
+
+## Запуск
+Service Worker и установка PWA работают только по `http(s)://` или `localhost`.
+
+```bash
+cd pwa-fire
+python3 -m http.server 8080
+# открыть http://localhost:8080
+```
+В Chrome/Edge: значок «Установить» в адресной строке → приложение работает офлайн.
+Без сервера `index.html` тоже можно открыть двойным щелчком — курс будет работать, но без установки и офлайн-кэша.
+
+## Требования ТЗ и как они выполнены
+| Требование | Реализация |
+|---|---|
+| Красивый дизайн | Тёмная «glass»-тема, градиенты, анимации, адаптивная вёрстка (телефон/ПК) |
+| Интерактивность, аудио, видео | Модели приборов (SVG-камера дымового извещателя, график теплового, пульт ППКП); Web Audio — сирена и сигналы; синтез речи «Внимание! Пожар!»; canvas-«видео» развития пожара |
+| Объём ≥ 5 минут | 8 сцен с обязательными заданиями + тест; ориентировочно 6–10 минут |
+| Образовательность + итоговый тест | Кнопка «Итоговое Тестирование» на финальной сцене, 9 вопросов, оценка и разбор ошибок |
+| Сценарий в отдельном файле | [`SCENARIO.md`](SCENARIO.md): сцены, элементы, действия, условия перехода |
+
+## Структура
+```
+index.html            каркас страницы
+style.css             оформление
+app.js                движок сцен, модели приборов, тест, звук
+manifest.webmanifest  манифест PWA
+sw.js                 Service Worker (офлайн-кэш)
+icons/                иконки приложения
+SCENARIO.md           сценарий
+CHANGELOG.md          отчёт об изменениях с датами
+issues.md             известные проблемы и планы
+```
+
+## Что показать при защите (содержание)
+1. Стадии пожара и признаки: дым → тепло → пламя.
+2. Оптический дымовой извещатель: светодиод, фотодиод вне луча, рассеяние на частицах дыма, порог и задержка срабатывания, причины ложных тревог.
+3. Тепловые: максимальный (порог) и дифференциальный (скорость роста).
+4. Выбор извещателя по помещению.
+5. ППКП: зоны, оповещение, квитирование, сброс, ручной извещатель.
+6. Алгоритм действий при тревоге.
+
+## Технические замечания
+- Звук включается после первого клика (ограничение браузеров).
+- Голосовое оповещение зависит от наличия русского голоса в системе.
+- Лучший результат теста хранится в `localStorage`.
+
+## Управление
+Кнопки в шапке: ☰ меню сцен, 💡 подсказка, ↺ сброс сцены, ⛶ полный экран, 🔊 звук. Клавиши: ←/→, M, H, R, F, Esc.

+ 81 - 0
SCENARIY.md

@@ -0,0 +1,81 @@
+# Сценарий PWA «Пожарная сигнализация»
+
+**Тематика:** п. 1 — ТЗИ; п. 1.2 — принцип работы прибора и интерактивная модель (пожарная сигнализация).
+**Формат:** одностраничное локальное PWA; навигация по «Сценам»; каждая сцена — один экран.
+**Время прохождения:** 6–10 минут. **Тест:** 9 вопросов.
+
+**Общие элементы на всех сценах (шапка):** ☰ меню сцен (переход к пройденным, сброс прогресса), 💡 подсказка по сцене, ↺ сброс сцены, ⛶ полный экран, 🔊 звук.
+
+Правило навигации: кнопка «Далее →» активна только после выполнения задания сцены (условие указано в поле «Условие перехода»).
+
+---
+
+## Сцена 1. Старт
+**Элементы:** анимированная заставка (canvas — «видео» горящей комнаты), заголовок, описание курса, кнопка «▶ Начать курс», подсказка про звук, кнопка звука 🔊 в шапке.
+**Действия:**
+- «Начать курс» — включает аудио-контекст, звук подтверждения, переход к Сцене 2.
+- 🔊 — включить/выключить весь звук (состояние запоминается).
+**Условие перехода:** нет (одна кнопка).
+
+## Сцена 2. Как развивается пожар
+**Элементы:** canvas-анимация комнаты с дымом и пламенем; ползунок «Развитие пожара» 0–100 %; индикаторы «Дымовой Д», «Тепловой Т», «Пламени П»; строка стадии; пояснение; мини-вопрос.
+**Действия:**
+- Двигать ползунок: растут дым, потом пламя; индикаторы загораются на порогах 18 % (дым), 55 % (тепло), 70 % (пламя), звучит сигнал, в пояснении — какой извещатель и почему сработал.
+- Ответить на мини-вопрос о первом сработавшем извещателе (верный ответ — «Дымовой»).
+**Условие перехода:** ползунок ≥ 75 % **и** верный ответ на мини-вопрос.
+
+## Сцена 3. Оптический дымовой извещатель (модель прибора)
+**Элементы:** SVG-схема дымовой камеры (ИК-светодиод, луч, поглотитель, фотодиод, частицы, индикатор тревоги); кнопки источников «Чистый воздух», «Тление (дым)», «Пар из ванной», «Строительная пыль»; ползунок плотности частиц; шкала сигнала фотодиода с красной меткой порога; окно пояснений; мини-вопрос.
+**Действия:**
+- Выбор источника / ползунок — меняется число частиц; частицы в луче рассеивают свет на фотодиод (рисуются лучи), растёт сигнал.
+- Сигнал ≥ порога дольше 0,7 с — тревога: мигает индикатор, звучат писки.
+- Тление → «верная тревога»; пар/пыль → «ложная тревога» с объяснением.
+- После обеих тревог появляется мини-вопрос об устройстве камеры.
+**Условие перехода:** получены верная и ложная тревоги + верный ответ.
+
+## Сцена 4. Тепловые извещатели (модель прибора)
+**Элементы:** график температуры во времени (canvas) с порогом 70 °C; кнопки «☀ Солнечный день» и «🔥 Пожар»; лампы «Максимальный (≥ 70 °C)» и «Дифференциальный (≥ 8 °C/мин)»; табло T / скорость / время; пояснение; мини-вопрос.
+**Действия:**
+- «Солнечный день» — медленный нагрев (2 °C/мин): максимальный даёт ложную тревогу, дифференциальный молчит.
+- «Пожар» — быстрый нагрев (14 °C/мин): дифференциальный срабатывает почти сразу, максимальный — позже.
+- После обоих запусков — мини-вопрос.
+**Условие перехода:** запущены оба сценария + верный ответ.
+
+## Сцена 5. Подбери извещатель
+**Элементы:** 5 карточек помещений (серверная, кухня, склад ЛВЖ, коридор общежития, сауна); у каждой кнопки «Дымовой / Тепловой / Пламени»; счётчик верных ответов; пояснения.
+**Действия:** выбрать тип извещателя — мгновенная обратная связь (✔/✖ и объяснение), карточка блокируется.
+**Условие перехода:** отвечены все 5 карточек (оценка не влияет на допуск).
+
+## Сцена 6. Пожарный пост: ППКП (интерактивная модель прибора)
+**Элементы:** 4 помещения (Кухня, Серверная, Склад ЛВЖ, Коридор) с кнопкой «🔥 Поджечь»; ЖК-экран ППКП; лампы зон 1–4; кнопки «🔕 Квитировать», «♻ Сброс», «🧯 Ручной извещатель»; журнал событий.
+**Действия:**
+- «Поджечь» — в помещении появляется пламя; через задержку, зависящую от типа извещателя (пламени 0,7 с, дымовой 1,3–1,5 с, тепловой 2,4 с), срабатывает извещатель: красная рамка, лампа зоны, надпись на ЖК, сирена и голос «Внимание! Пожар!».
+- «Квитировать» — заглушить сирену, статус «квитировано».
+- «Сброс» — вернуть ППКП в дежурный режим.
+- «Ручной извещатель» — мгновенная тревога в зоне «Коридор».
+**Условие перехода:** тревога минимум в двух зонах + выполнено квитирование.
+
+## Сцена 7. Порядок действий при тревоге
+**Элементы:** перемешанные 6 шагов; поле «Твоя последовательность»; строка статуса.
+**Действия:** нажимать шаги по порядку; верный — переходит в список; неверный — встряхивается с подсказкой.
+**Условие перехода:** все 6 шагов расставлены верно.
+
+## Сцена 8. Финал
+**Элементы:** заголовок, текст, лучший прошлый результат, кнопка «📝 Итоговое Тестирование».
+**Действия:** «Итоговое Тестирование» — переход к Сцене 9.
+
+## Сцена 9. Итоговое тестирование (9 вопросов)
+**Элементы:** счётчик вопросов, полоса прогресса, вопрос с 4 вариантами, пояснение, кнопка «Следующий вопрос».
+**Действия:** выбрать вариант — сразу показывается верный ответ и пояснение; перейти к следующему.
+**Итог:** результат «N / 9», оценка (≥ 90 % — 5; ≥ 75 % — 4; ≥ 55 % — 3; иначе 2), разбор ошибок, кнопки «Пересдать» и «Повторить курс». Лучший результат сохраняется в localStorage.
+
+### Ключевые знания, проверяемые тестом
+1. Принцип оптического дымового извещателя — рассеяние света (эффект Тиндаля).
+2. Почему фотодиод вынесен из линии луча.
+3. Максимальный тепловой извещатель — по порогу температуры.
+4. Дифференциальный — по скорости роста температуры.
+5. Выбор извещателя для кухни (тепловой).
+6. Выбор для склада ЛВЖ (пламени).
+7. Назначение ППКП.
+8. Причины ложных тревог оптического извещателя (пар, пыль).
+9. Смысл «квитирования».

+ 472 - 0
app.js

@@ -0,0 +1,472 @@
+'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);

BIN
icon-192.png


BIN
icon-512.png


+ 8 - 0
icon.svg

@@ -0,0 +1,8 @@
+<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1b2550"/><stop offset="1" stop-color="#0b1020"/></linearGradient>
+<linearGradient id="f" x1="0" y1="1" x2="0" y2="0"><stop offset="0" stop-color="#ff4d2e"/><stop offset="1" stop-color="#ffd166"/></linearGradient></defs>
+<rect width="512" height="512" fill="url(#g)"/>
+<circle cx="256" cy="256" r="190" fill="none" stroke="#5ec8ff" stroke-width="14" opacity=".6"/>
+<circle cx="256" cy="256" r="130" fill="none" stroke="#5ec8ff" stroke-width="8" opacity=".35"/>
+<path d="M256 120c20 52 82 82 82 150a82 82 0 0 1-164 0c0-34 18-52 32-70 6 22 18 32 30 34-10-50 0-84 20-114z" fill="url(#f)"/>
+</svg>

+ 38 - 0
index.html

@@ -0,0 +1,38 @@
+<!doctype html>
+<html lang="ru">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
+<meta name="theme-color" content="#0b1020">
+<title>Пожарная сигнализация</title>
+<link rel="manifest" href="manifest.webmanifest">
+<link rel="icon" href="icons/icon.svg" type="image/svg+xml">
+<link rel="apple-touch-icon" href="icons/icon-192.png">
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+<div class="app" id="app">
+  <header class="top">
+    <div class="brand"><img class="logo" src="icons/icon.svg" alt="" width="34" height="34"><div class="bt"><b>Пожарная сигнализация</b><small id="step">Сцена 1 из 8</small></div></div>
+    <nav class="dots" id="dots" aria-label="Прогресс"></nav>
+    <div class="tools">
+      <button class="icon-btn" id="menu" title="Меню сцен (M)" aria-label="Меню сцен">☰</button>
+      <button class="icon-btn" id="tip" title="Подсказка (H)" aria-label="Подсказка">💡</button>
+      <button class="icon-btn" id="reset" title="Сбросить сцену (R)" aria-label="Сбросить сцену">↺</button>
+      <button class="icon-btn" id="full" title="Полный экран (F)" aria-label="Полный экран">⛶</button>
+      <button class="icon-btn" id="sound" title="Звук вкл/выкл" aria-label="Звук">🔊</button>
+    </div>
+  </header>
+  <main class="stage" id="stage" aria-live="polite"></main>
+  <footer class="bottom" id="bottom">
+    <button class="btn ghost" id="prev">← Назад</button>
+    <div class="hint" id="hint"></div>
+    <button class="btn primary" id="next">Далее →</button>
+  </footer>
+</div>
+<div id="toast" class="toast" role="status"></div>
+<div id="modal" class="modal" hidden></div>
+<canvas id="confetti"></canvas>
+<script src="app.js"></script>
+</body>
+</html>

+ 24 - 0
issues.md

@@ -0,0 +1,24 @@
+# issues.md — известные проблемы и планы
+
+## Известные ограничения
+| № | Описание | Статус / обходной путь |
+|---|---|---|
+| 1 | Service Worker и установка PWA не работают при открытии по `file://` | Запускать через `python3 -m http.server` |
+| 2 | Голосовое оповещение (SpeechSynthesis) зависит от установленных в ОС голосов; без русского голоса фраза может не прозвучать | Сирена и писки работают независимо |
+| 3 | Звук начинается только после первого клика (политика автозапуска браузеров) | Первое действие — кнопка «Начать курс» |
+| 4 | Физические модели упрощены: фиксированные пороги (дым 40 % сигнала, 70 °C, 8 °C/мин), без учёта инерционности реальных приборов | Значения условные, цель — наглядность |
+| 5 | Иконки PWA сгенерированы автоматически из SVG | Заменить на фирменные при необходимости |
+| 6 | Прогресс сцен не сохраняется при перезагрузке (сохраняется только лучший результат теста и настройка звука); «Сбросить весь прогресс» очищает его только в текущей сессии | Планируется |
+| 7 | Вопросы теста идут в фиксированном порядке, варианты не перемешиваются | Планируется |
+
+## Планы развития
+- [ ] Модель ионизационного извещателя и сравнение с оптическим.
+- [ ] Модель охранной сигнализации (магнитоконтактный, ИК-датчик движения).
+- [ ] Перемешивание вопросов, банк вопросов > 9.
+- [ ] Сохранение прогресса и режим «только тест».
+- [ ] Тёмная/светлая тема, доступность (клавиатурная навигация, `aria`-подписи для SVG-моделей).
+
+## Исправлено в процессе разработки
+- Мини-вопросы не отображали варианты ответа (некорректная передача массива кнопок в `append`) — исправлено.
+- Нижняя панель навигации перекрывала контент и кнопку старта — добавлены отступ и фон, на стартовом экране панель скрыта.
+- Переход со сцены 2 разблокировался раньше времени — условие вынесено в единый «gate» сцены.

+ 17 - 0
manifest.webmanifest

@@ -0,0 +1,17 @@
+{
+  "name": "Пожарная сигнализация",
+  "short_name": "Сигнализация",
+  "description": "Интерактивный образовательный курс по принципам работы пожарных извещателей и ППКП",
+  "lang": "ru",
+  "start_url": "./index.html",
+  "scope": "./",
+  "display": "standalone",
+  "orientation": "any",
+  "background_color": "#0b1020",
+  "theme_color": "#0b1020",
+  "icons": [
+    {"src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png"},
+    {"src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png"},
+    {"src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable"}
+  ]
+}

+ 177 - 0
style.css

@@ -0,0 +1,177 @@
+:root{--bg:#0b1020;--bg2:#121a33;--card:rgba(255,255,255,.055);--line:rgba(255,255,255,.12);--txt:#e8eefc;--mut:#98a6c8;--acc:#ff7a3d;--acc2:#ffd166;--cy:#5ec8ff;--ok:#42d392;--bad:#ff5470;--r:18px}
+*{box-sizing:border-box}
+html,body{margin:0;height:100%;background:var(--bg);color:var(--txt);font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
+body{background:radial-gradient(1200px 700px at 85% -10%,#2a1d3f 0,transparent 60%),radial-gradient(900px 600px at -10% 110%,#10325a 0,transparent 55%),var(--bg);background-attachment:fixed}
+.app{max-width:1100px;margin:0 auto;min-height:100%;display:flex;flex-direction:column;padding:0 16px}
+.top{display:flex;align-items:center;gap:12px;padding:14px 0;position:sticky;top:0;z-index:5;backdrop-filter:blur(10px)}
+.brand{display:flex;gap:8px;align-items:center;white-space:nowrap;font-size:15px}.logo{font-size:22px}
+.dots{flex:1;display:flex;gap:6px;justify-content:center}
+.dot{width:100%;max-width:44px;height:6px;border-radius:6px;background:var(--line);transition:.3s}
+.dot.done{background:var(--ok)}.dot.cur{background:linear-gradient(90deg,var(--acc),var(--acc2));box-shadow:0 0 12px var(--acc)}
+.icon-btn{background:var(--card);border:1px solid var(--line);color:var(--txt);width:40px;height:40px;border-radius:50%;font-size:18px;cursor:pointer}
+.stage{flex:1;padding:8px 0 20px;animation:in .45s ease}
+@keyframes in{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
+h1{font-size:clamp(30px,6vw,56px);line-height:1.05;margin:.2em 0}
+h2{font-size:clamp(22px,3.6vw,32px);margin:.2em 0}
+h3{margin:.2em 0 .4em}
+.lead{color:var(--mut);max-width:70ch;margin:.3em 0 1em}
+.grad{background:linear-gradient(90deg,var(--acc),var(--acc2));-webkit-background-clip:text;background-clip:text;color:transparent}
+.card{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:16px;backdrop-filter:blur(6px)}
+.grid2{display:grid;grid-template-columns:1.3fr 1fr;gap:16px}
+.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
+@media(max-width:820px){.grid2,.grid3{grid-template-columns:1fr}.brand b{display:none}}
+.btn{font:inherit;border:1px solid var(--line);background:var(--card);color:var(--txt);padding:10px 18px;border-radius:999px;cursor:pointer;transition:.2s}
+.btn:hover:not(:disabled){transform:translateY(-1px);border-color:var(--cy)}
+.btn:disabled{opacity:.4;cursor:not-allowed}
+.btn.primary{background:linear-gradient(135deg,var(--acc),#ff4d6d);border:0;font-weight:600;box-shadow:0 6px 22px rgba(255,90,60,.35)}
+.btn.big{font-size:20px;padding:16px 34px}
+.btn.ghost{background:transparent}
+.btn.sm{padding:6px 12px;font-size:14px}
+.btn.on{border-color:var(--acc);background:rgba(255,122,61,.18)}
+.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
+.bottom{display:flex;align-items:center;gap:12px;padding:12px 0 18px;position:sticky;bottom:0;backdrop-filter:blur(10px)}
+.hint{flex:1;text-align:center;color:var(--mut);font-size:14px}.hint.ok{color:var(--ok)}
+.hero{display:grid;place-items:center;text-align:center;padding:30px 0}
+.hero canvas{width:100%;max-width:560px;border-radius:var(--r);border:1px solid var(--line);margin-bottom:16px}
+.pill{display:inline-block;padding:3px 10px;border-radius:99px;background:rgba(94,200,255,.14);color:var(--cy);font-size:13px}
+canvas.cv{width:100%;border-radius:14px;border:1px solid var(--line);background:#0b0f1a;display:block}
+.svg{width:100%;height:auto;display:block}
+.svg text{fill:#9fb0d0;font-size:12px;font-family:inherit}
+input[type=range]{width:100%;accent-color:var(--acc);height:28px}
+.lamp{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:12px;background:rgba(0,0,0,.25);border:1px solid var(--line);font-size:14px}
+.lamp i{width:12px;height:12px;border-radius:50%;background:#394260;display:inline-block;transition:.2s}
+.lamp.on i{background:var(--bad);box-shadow:0 0 12px var(--bad)}
+.lamp.on{border-color:var(--bad)}
+.lamp.green.on i{background:var(--ok);box-shadow:0 0 12px var(--ok)}
+.bar{height:14px;border-radius:8px;background:rgba(255,255,255,.1);position:relative;overflow:hidden}
+.bar>div{height:100%;background:linear-gradient(90deg,var(--cy),var(--acc2));width:0}
+.bar .thr{position:absolute;top:0;bottom:0;width:2px;background:var(--bad)}
+.msg{padding:10px 14px;border-radius:12px;background:rgba(255,255,255,.06);border-left:4px solid var(--cy);margin:10px 0;min-height:24px}
+.msg.good{border-color:var(--ok)}.msg.bad{border-color:var(--bad)}
+.quiz{margin-top:14px}
+.opt{display:block;width:100%;text-align:left;margin:6px 0;padding:11px 14px;border-radius:12px;border:1px solid var(--line);background:rgba(255,255,255,.04);color:var(--txt);font:inherit;cursor:pointer;transition:.15s}
+.opt:hover:not(:disabled){border-color:var(--cy);background:rgba(94,200,255,.08)}
+.opt.right{background:rgba(66,211,146,.2);border-color:var(--ok)}
+.opt.wrong{background:rgba(255,84,112,.2);border-color:var(--bad);animation:shake .35s}
+@keyframes shake{25%{transform:translateX(-6px)}75%{transform:translateX(6px)}}
+.room{position:relative;overflow:hidden;min-height:130px;text-align:left}
+.room h4{margin:0}.room small{color:var(--mut)}
+.room .flame{position:absolute;right:14px;bottom:8px;font-size:46px;opacity:0;transform:scale(.2);transition:.4s;filter:drop-shadow(0 0 14px #ff6a2d)}
+.room.burn .flame{opacity:1;transform:scale(1);animation:flick .5s infinite alternate}
+.room.burn{border-color:var(--acc)}
+.room.alarm{box-shadow:0 0 0 2px var(--bad),0 0 24px rgba(255,84,112,.5)}
+@keyframes flick{to{transform:scale(1.12) rotate(4deg)}}
+.panel{background:#060912;border:2px solid #2a3558;border-radius:16px;padding:14px;font-family:ui-monospace,Menlo,Consolas,monospace}
+.lcd{background:#0d2a1c;color:#6dffb0;border-radius:8px;padding:10px;min-height:68px;text-shadow:0 0 6px #2f9;white-space:pre-line}
+.lcd.alert{background:#2a0d12;color:#ff8da0;text-shadow:0 0 6px #f45;animation:blink 1s infinite}
+@keyframes blink{50%{opacity:.65}}
+.log{max-height:130px;overflow:auto;font-size:13px;color:var(--mut);margin-top:10px}
+.log div{padding:2px 0;border-bottom:1px dashed var(--line)}
+.chips{display:flex;flex-wrap:wrap;gap:8px;min-height:46px;padding:10px;border:1px dashed var(--line);border-radius:12px;margin:8px 0}
+.chip{padding:8px 12px;border-radius:12px;background:rgba(255,255,255,.07);border:1px solid var(--line);color:var(--txt);font:inherit;cursor:pointer;text-align:left}
+.chip.ok{background:rgba(66,211,146,.18);border-color:var(--ok);cursor:default}
+.chip.no{animation:shake .35s;border-color:var(--bad)}
+.steps li{margin:6px 0}
+.score{font-size:64px;font-weight:800}
+.prog{height:8px;background:var(--line);border-radius:8px;overflow:hidden;margin:6px 0 14px}.prog>div{height:100%;background:linear-gradient(90deg,var(--acc),var(--acc2));transition:.4s}
+.rev{border-left:4px solid var(--bad);padding:6px 12px;margin:8px 0;background:rgba(255,255,255,.04);border-radius:8px}
+.rev.g{border-color:var(--ok)}
+.hero canvas{max-width:400px}
+.bottom{background:linear-gradient(transparent,var(--bg) 40%)}
+.stage{padding-bottom:60px}
+.app.first .bottom{display:none}
+
+/* ===== Редизайн интерфейса v2 ===== */
+:root{--glass:rgba(20,28,56,.55);--shadow:0 10px 40px rgba(0,0,0,.35)}
+html,body{font-family:"Inter","Segoe UI",system-ui,-apple-system,Roboto,sans-serif;letter-spacing:.005em}
+body::before{content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;background:
+ radial-gradient(500px 500px at 15% 20%,rgba(255,122,61,.12),transparent 70%),
+ radial-gradient(600px 600px at 85% 75%,rgba(94,200,255,.12),transparent 70%);animation:drift 18s ease-in-out infinite alternate}
+@keyframes drift{to{transform:translate(30px,-20px) scale(1.08)}}
+.top{background:linear-gradient(var(--bg) 55%,transparent);padding:12px 0 16px}
+.brand{gap:10px}.logo{border-radius:10px;box-shadow:0 0 18px rgba(255,122,61,.45)}
+.bt{display:flex;flex-direction:column;line-height:1.15}.bt b{font-size:16px}.bt small{color:var(--mut);font-size:12px}
+.dots{gap:8px}
+.dot{border:0;padding:0;height:8px;cursor:default;opacity:.9}
+.dot:not(:disabled){cursor:pointer}.dot:not(:disabled):hover{transform:scaleY(1.7)}
+.dot.cur{width:140%;max-width:64px}
+.card{background:var(--glass);box-shadow:var(--shadow);border-color:rgba(255,255,255,.1);transition:border-color .25s,transform .25s}
+.grid3>.card:hover{border-color:rgba(94,200,255,.45);transform:translateY(-2px)}
+h2{display:flex;align-items:center;gap:12px}
+h2::before{content:"";width:6px;height:1.1em;border-radius:4px;background:linear-gradient(var(--acc2),var(--acc));flex:none}
+.hero h2::before,.card h2::before{display:none}
+.btn{font-weight:500;min-height:42px}
+.btn.primary{background:linear-gradient(135deg,#ff8a3d,#ff4d6d);position:relative;overflow:hidden}
+.btn.primary:hover:not(:disabled){box-shadow:0 8px 30px rgba(255,90,60,.55);transform:translateY(-2px)}
+.btn.big{animation:pulse 2.4s infinite}
+@keyframes pulse{50%{box-shadow:0 6px 40px rgba(255,90,60,.65)}}
+.icon-btn{transition:.2s}.icon-btn:hover{border-color:var(--cy);transform:rotate(-8deg) scale(1.06)}
+:focus-visible{outline:2px solid var(--cy);outline-offset:2px}
+.msg{background:rgba(255,255,255,.05);border-radius:12px;animation:in .3s}
+.opt{border-radius:14px;padding:13px 16px}
+input[type=range]{-webkit-appearance:none;appearance:none;background:transparent;height:30px}
+input[type=range]::-webkit-slider-runnable-track{height:8px;border-radius:8px;background:linear-gradient(90deg,#2a3a66,var(--acc))}
+input[type=range]::-moz-range-track{height:8px;border-radius:8px;background:linear-gradient(90deg,#2a3a66,var(--acc))}
+input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:24px;height:24px;margin-top:-8px;border-radius:50%;background:#fff;border:4px solid var(--acc);box-shadow:0 0 14px rgba(255,122,61,.7)}
+input[type=range]::-moz-range-thumb{width:18px;height:18px;border-radius:50%;background:#fff;border:4px solid var(--acc)}
+.bottom{padding:14px 0 18px}
+.hint{font-size:13px}
+.hero{padding:8px 0 20px;gap:4px}
+.hero canvas{box-shadow:0 0 60px rgba(255,100,40,.25)}
+.hero h1{margin:.3em 0}
+.panel{box-shadow:var(--shadow),inset 0 0 40px rgba(0,255,150,.04)}
+.lamp{background:rgba(0,0,0,.3)}
+@media(max-width:820px){
+ .app{padding:0 12px}.dot.cur{width:100%}.bt small{display:none}.bt b{font-size:15px}.brand b{display:inline}
+ .btn.big{width:100%}.hero h1{font-size:30px}.bottom .btn{padding:10px 14px}
+}
+@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+@media(max-width:820px){.top{flex-wrap:wrap;gap:8px 10px}.brand{flex:1}.dots{order:3;flex:1 1 100%}.dot{max-width:none}}
+
+/* ===== Дизайн v3: панель инструментов, меню, тосты, конфетти ===== */
+.tools{display:flex;gap:6px}
+.icon-btn{width:38px;height:38px;font-size:16px;display:grid;place-items:center;padding:0}
+.icon-btn:hover{background:rgba(94,200,255,.12)}
+#reset:hover{transform:rotate(-200deg)}
+.kicker{display:inline-block;font-size:12px;letter-spacing:.14em;font-weight:700;color:var(--acc2);margin:2px 0 4px;padding:4px 12px;border-radius:99px;border:1px solid rgba(255,209,102,.35);background:rgba(255,209,102,.08)}
+.toast{position:fixed;left:50%;bottom:86px;transform:translate(-50%,30px);background:rgba(20,32,60,.96);border:1px solid var(--ok);color:var(--txt);padding:10px 18px;border-radius:99px;box-shadow:0 10px 40px rgba(0,0,0,.5);opacity:0;pointer-events:none;transition:.35s;z-index:30;white-space:nowrap;max-width:92vw;overflow:hidden;text-overflow:ellipsis}
+.toast.show{opacity:1;transform:translate(-50%,0)}
+.modal{position:fixed;inset:0;z-index:40;background:rgba(4,7,16,.7);backdrop-filter:blur(6px);display:grid;place-items:center;padding:16px;animation:in .25s}
+.modal[hidden]{display:none}
+.sheet{width:min(520px,100%);background:#10172e;max-height:90vh;overflow:auto}
+.sheet-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
+.menu-list{display:grid;gap:8px}
+.menu-item{display:flex;align-items:center;gap:12px;padding:10px 14px;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.04);color:var(--txt);font:inherit;text-align:left;cursor:pointer;transition:.15s}
+.menu-item:hover:not(:disabled){border-color:var(--cy);background:rgba(94,200,255,.08)}
+.menu-item:disabled{opacity:.4;cursor:not-allowed}
+.menu-item.cur{border-color:var(--acc);background:rgba(255,122,61,.14)}
+.menu-item .num{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;background:rgba(255,255,255,.1);font-size:13px;font-weight:700;flex:none}
+.menu-item:has(.num:where(:not(:empty))){}
+#confetti{position:fixed;inset:0;pointer-events:none;z-index:50;display:none}
+.card h4{font-size:17px}
+.room{transition:.3s}.room:hover{transform:translateY(-2px)}
+.btn.sm:active,.btn:active{transform:scale(.97)}
+@media(max-width:820px){.tools{gap:4px}.icon-btn{width:34px;height:34px}.brand{min-width:0}.bt b{font-size:14px}}
+@media(max-width:420px){#full{display:none}}
+
+/* ===== Дизайн v4: центрирование интерфейса ===== */
+.app{max-width:1000px}
+.stage{display:flex;flex-direction:column;justify-content:center;align-items:stretch;min-height:calc(100vh - 170px)}
+.stage>.kicker,.stage>.pill{align-self:center}
+.stage>h2{justify-content:center;text-align:center}
+.stage>h2::before{content:"";width:34px;height:4px;background:linear-gradient(90deg,transparent,var(--acc))}
+.stage>h2::after{content:"";width:34px;height:4px;border-radius:4px;background:linear-gradient(90deg,var(--acc),transparent);flex:none}
+.stage>h3{text-align:center}
+.stage>.lead{margin-left:auto;margin-right:auto;text-align:center}
+.stage>.row{justify-content:center}
+.stage>.chips{justify-content:center}
+.stage>.msg{text-align:center}
+.card .lead{text-align:left;margin-left:0}
+.card h3,.card .msg,.panel{text-align:left}
+.quiz{width:100%;max-width:760px;align-self:center}
+.hero{width:100%}
+.stage>.grid2,.stage>.grid3{width:100%;margin-inline:auto}
+.stage>.rev,.stage>div>.rev{max-width:760px;margin-inline:auto}
+.bottom{justify-content:center}
+.top{justify-content:space-between}
+@media(max-width:820px){.stage{min-height:0;justify-content:flex-start}.stage>h2{font-size:22px}.stage>h2::before,.stage>h2::after{width:16px}}

+ 10 - 0
sw.js

@@ -0,0 +1,10 @@
+const CACHE = 'aps-course-v4';
+const FILES = ['./','index.html','style.css','app.js','manifest.webmanifest','icons/icon.svg','icons/icon-192.png','icons/icon-512.png'];
+self.addEventListener('install', e => { e.waitUntil(caches.open(CACHE).then(c => c.addAll(FILES)).then(() => self.skipWaiting())); });
+self.addEventListener('activate', e => { e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k)))).then(() => self.clients.claim())); });
+self.addEventListener('fetch', e => {
+  if (e.request.method !== 'GET') return;
+  e.respondWith(caches.match(e.request).then(r => r || fetch(e.request).then(res => {
+    const copy = res.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); return res;
+  }).catch(() => caches.match('index.html'))));
+});