1
0
Просмотр исходного кода

Загрузить файлы 'ПМ.03.01/YPV'

ypv-demo 2 недель назад
Родитель
Сommit
b762ecfa64
1 измененных файлов с 973 добавлено и 1 удалено
  1. 973 1
      ПМ.03.01/YPV/index.html

+ 973 - 1
ПМ.03.01/YPV/index.html

@@ -1 +1,973 @@
-<head>
+<!DOCTYPE html>
+<html lang="ru">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+<meta name="theme-color" content="#1a1a1e">
+<meta name="apple-mobile-web-app-capable" content="yes">
+<title>Detector Master — Resanta DM-100</title>
+<link rel="manifest" href='data:application/manifest+json,{"name":"Detector Master DM-100","short_name":"DM-100","start_url":".","display":"standalone","background_color":"%231a1a1e","theme_color":"%231a1a1e","icons":[]}'>
+<style>
+  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+  :root {
+    --bg: #1a1a1e;
+    --panel: #25252b;
+    --panel2: #2e2e35;
+    --border: #3a3a42;
+    --text: #e5e5e7;
+    --muted: #9a9aa3;
+    --yellow: #ffd23f;
+    --green: #4ade80;
+    --red: #ef4444;
+    --orange: #f97316;
+    --blue: #38bdf8;
+  }
+  html, body { height: 100%; }
+  body {
+    background: var(--bg);
+    color: var(--text);
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+    line-height: 1.5;
+    overflow-x: hidden;
+    -webkit-font-smoothing: antialiased;
+  }
+  #progress-bar {
+    position: fixed; top: 0; left: 0; right: 0; height: 4px;
+    background: #0f0f12; z-index: 100;
+  }
+  #progress-fill {
+    height: 100%; width: 0%;
+    background: linear-gradient(90deg, var(--yellow), var(--green));
+    transition: width .4s ease;
+  }
+  #app { padding: 28px 16px 80px; max-width: 720px; margin: 0 auto; }
+  .screen { display: none; animation: fadeIn .4s ease; }
+  .screen.active { display: block; }
+  @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
+
+  h1 { font-size: 1.7rem; line-height: 1.2; margin-bottom: 12px; }
+  h2 { font-size: 1.35rem; margin-bottom: 10px; color: var(--yellow); }
+  h3 { font-size: 1.05rem; margin: 16px 0 8px; }
+  p { margin-bottom: 12px; color: var(--text); }
+  .muted { color: var(--muted); font-size: .92rem; }
+  .tag {
+    display: inline-block; padding: 2px 10px; border-radius: 999px;
+    background: var(--panel2); font-size: .78rem; color: var(--muted);
+    margin-bottom: 12px; letter-spacing: .04em; text-transform: uppercase;
+  }
+  .tag.yellow { color: var(--yellow); }
+  .card {
+    background: var(--panel); border: 1px solid var(--border);
+    border-radius: 14px; padding: 16px; margin-bottom: 14px;
+  }
+  .card.hint { border-left: 3px solid var(--yellow); background: #2a281a; }
+  .card.warn { border-left: 3px solid var(--red); background: #2a1f1f; }
+  .card.ok { border-left: 3px solid var(--green); background: #1e2a22; }
+
+  button {
+    font-family: inherit; font-size: 1rem; cursor: pointer;
+    border: none; border-radius: 12px; padding: 14px 20px;
+    transition: transform .1s, opacity .2s, background .2s;
+    -webkit-tap-highlight-color: transparent;
+  }
+  button:active { transform: scale(.97); }
+  button:disabled { opacity: .35; cursor: not-allowed; transform: none; }
+  .btn-primary {
+    background: var(--yellow); color: #1a1a1e; font-weight: 700;
+    width: 100%; margin-top: 8px;
+  }
+  .btn-secondary {
+    background: var(--panel2); color: var(--text); border: 1px solid var(--border);
+    width: 100%;
+  }
+
+  /* ===== WELCOME ===== */
+  .hero { text-align: center; padding: 20px 0 10px; }
+  .hero-icon {
+    width: 110px; height: 110px; margin: 0 auto 20px;
+    border-radius: 24px;
+    background: linear-gradient(145deg, #35353d, #1c1c22);
+    border: 2px solid var(--border);
+    display: flex; align-items: center; justify-content: center;
+    font-size: 2.6rem;
+    box-shadow: 0 0 0 0 rgba(255,210,63,.4);
+    animation: pulse 2.2s infinite;
+  }
+  @keyframes pulse {
+    0% { box-shadow: 0 0 0 0 rgba(255,210,63,.4); }
+    70% { box-shadow: 0 0 0 22px rgba(255,210,63,0); }
+    100% { box-shadow: 0 0 0 0 rgba(255,210,63,0); }
+  }
+  .feature-list { list-style: none; margin: 20px 0; }
+  .feature-list li {
+    padding: 10px 0; border-bottom: 1px solid var(--border);
+    display: flex; gap: 10px; align-items: flex-start;
+  }
+  .feature-list li:last-child { border-bottom: none; }
+  .feature-list .ico { color: var(--yellow); flex-shrink: 0; }
+
+  /* ===== WORKBENCH / WALL ===== */
+  .workbench-slot { margin: 16px 0; min-height: 220px; }
+  #workbench { width: 100%; }
+  .wall {
+    position: relative; width: 100%; height: 180px;
+    background:
+      repeating-linear-gradient(90deg, #2c2c33 0 30px, #28282e 30px 60px),
+      linear-gradient(180deg, #2c2c33, #232328);
+    border-radius: 14px;
+    border: 1px solid var(--border);
+    overflow: hidden;
+    touch-action: none;
+    user-select: none;
+    cursor: crosshair;
+    box-shadow: inset 0 4px 20px rgba(0,0,0,.5);
+  }
+  .wall-object {
+    position: absolute; top: 0; bottom: 0; width: 9%;
+    transform: translateX(-50%);
+    border-radius: 6px;
+    opacity: .18;
+    transition: opacity .3s, box-shadow .3s;
+    pointer-events: none;
+  }
+  .wall.show-objects .wall-object { opacity: .35; }
+  .wall-object.found {
+    opacity: .9 !important;
+    box-shadow: 0 0 18px 4px currentColor;
+  }
+  .wall-object[data-id="armature"] { background: var(--orange); color: var(--orange); }
+  .wall-object[data-id="pipe"]     { background: #f59e0b; color: #f59e0b; }
+  .wall-object[data-id="cable"]    { background: var(--red); color: var(--red); }
+  .wall-object[data-id="wood"]     { background: #a16207; color: #a16207; }
+  .wall-object .obj-label {
+    position: absolute; bottom: 6px; left: 50%; transform: translateX(-50%);
+    font-size: .7rem; color: #fff; white-space: nowrap;
+    background: rgba(0,0,0,.7); padding: 2px 6px; border-radius: 4px;
+    opacity: 0; transition: opacity .2s;
+  }
+  .wall.show-objects .wall-object .obj-label,
+  .wall-object.found .obj-label { opacity: 1; }
+
+  /* ===== DETECTOR ===== */
+  .detector {
+    position: absolute; top: 50%; left: 50%;
+    transform: translate(-50%, -50%);
+    width: 130px;
+    background: linear-gradient(160deg, #3a3a44, #1e1e24);
+    border: 2px solid #4a4a55;
+    border-radius: 16px;
+    padding: 10px 8px 8px;
+    box-shadow: 0 8px 20px rgba(0,0,0,.5);
+    z-index: 10;
+    transition: left .06s linear;
+  }
+  .detector-display {
+    background: #0b1a0e;
+    border: 1px solid #1e3a24;
+    border-radius: 8px;
+    color: var(--green);
+    font-family: "SF Mono", Menlo, Consolas, monospace;
+    font-size: .72rem;
+    text-align: center;
+    padding: 6px 4px;
+    min-height: 42px;
+    display: flex; flex-direction: column; justify-content: center; gap: 2px;
+    box-shadow: inset 0 0 8px rgba(0,255,100,.15);
+  }
+  .detector-display .d-mode { font-size: .6rem; color: #6b9c74; letter-spacing: .05em; }
+  .detector-display .d-main { font-weight: 700; letter-spacing: .03em; }
+  .detector-display.alert { color: var(--yellow); border-color: #4a3d1a; background: #1a1608; }
+  .detector-display.danger { color: var(--red); border-color: #4a1e1e; background: #1a0808; }
+  .detector-buttons {
+    display: flex; gap: 6px; margin-top: 8px;
+  }
+  .det-btn {
+    flex: 1; padding: 8px 0; font-size: .78rem; font-weight: 700;
+    border-radius: 8px;
+    background: #2a2a32; color: var(--text);
+    border: 1px solid #444;
+    letter-spacing: .02em;
+  }
+  .det-btn:active { background: #1a1a20; transform: scale(.94); }
+  .det-btn[data-btn="power"] { color: var(--red); }
+  .det-btn[data-btn="cal"] { color: var(--yellow); }
+  .det-btn[data-btn="mode"] { color: var(--blue); }
+  .det-btn.active { box-shadow: 0 0 0 2px currentColor inset; }
+
+  /* ===== TOAST ===== */
+  #toast {
+    position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(120%);
+    background: var(--panel2); color: var(--text);
+    padding: 12px 20px; border-radius: 12px;
+    border: 1px solid var(--border);
+    font-size: .9rem; z-index: 200;
+    box-shadow: 0 8px 24px rgba(0,0,0,.5);
+    transition: transform .35s ease;
+    max-width: 90vw; text-align: center;
+  }
+  #toast.show { transform: translateX(-50%) translateY(0); }
+  #toast.success { border-color: var(--green); color: var(--green); }
+  #toast.error { border-color: var(--red); color: var(--red); }
+  #toast.info { border-color: var(--yellow); color: var(--yellow); }
+
+  /* ===== QUIZ ===== */
+  .question { margin-bottom: 22px; }
+  .question h3 { font-size: 1rem; color: var(--text); margin-bottom: 12px; font-weight: 600; }
+  .options { display: flex; flex-direction: column; gap: 8px; }
+  .option {
+    display: flex; align-items: flex-start; gap: 10px;
+    padding: 12px 14px; border-radius: 10px;
+    background: var(--panel2); border: 1px solid var(--border);
+    cursor: pointer; transition: border-color .2s, background .2s;
+    font-size: .95rem;
+  }
+  .option:hover { border-color: #555; }
+  .option input { margin-top: 3px; accent-color: var(--yellow); flex-shrink: 0; }
+  .option.correct { border-color: var(--green); background: #1e2a22; }
+  .option.wrong { border-color: var(--red); background: #2a1f1f; }
+  .option.reveal { border-color: var(--green); background: #1e2a22; }
+  .explain {
+    margin-top: 8px; font-size: .85rem; color: var(--muted);
+    padding: 8px 12px; background: #1a1a20; border-radius: 8px;
+    border-left: 2px solid var(--yellow);
+    display: none;
+  }
+  .explain.show { display: block; }
+
+  /* ===== RESULTS ===== */
+  .result-score {
+    text-align: center; font-size: 3rem; font-weight: 800;
+    margin: 20px 0 8px;
+  }
+  .result-score.good { color: var(--green); }
+  .result-score.mid { color: var(--yellow); }
+  .result-score.bad { color: var(--red); }
+
+  .nav-btns { margin-top: 8px; display: flex; flex-direction: column; gap: 10px; }
+  .task-indicator {
+    font-size: .85rem; color: var(--muted);
+    text-align: center; margin-top: 10px;
+  }
+  .task-indicator.done { color: var(--green); }
+
+  @media (max-width: 420px) {
+    #app { padding: 24px 12px 80px; }
+    h1 { font-size: 1.4rem; }
+    .detector { width: 116px; }
+  }
+</style>
+</head>
+<body>
+
+<div id="progress-bar"><div id="progress-fill"></div></div>
+
+<main id="app">
+
+  <!-- ============ WELCOME ============ -->
+  <section class="screen active" id="screen-welcome">
+    <div class="hero">
+      <div class="hero-icon">📡</div>
+      <h1>Detector Master</h1>
+      <p class="muted">Resanta DM-100 — интерактивный симулятор</p>
+    </div>
+
+    <div class="card">
+      <p>Вы — мастер. Перед сверлением стены нужно проверить её на скрытые объекты. У вас в руках <b>Resanta DM-100</b> — детектор с тремя кнопками управления.</p>
+      <p class="muted">За 7 минут вы научитесь включать прибор, калибровать его, переключать режимы и находить арматуру, трубы, кабель под напряжением и дерево.</p>
+    </div>
+
+    <ul class="feature-list">
+      <li><span class="ico">⏻</span><span>Кнопка питания — включение и выключение</span></li>
+      <li><span class="ico">🎯</span><span>Кнопка CAL — калибровка под фон стены</span></li>
+      <li><span class="ico">🔄</span><span>Кнопка M — режимы: Металл → Проводка → Дерево</span></li>
+    </ul>
+
+    <div class="nav-btns">
+      <button class="btn-primary" onclick="go('m1')">Начать обучение</button>
+    </div>
+  </section>
+
+  <!-- ============ MODULE 1 ============ -->
+  <section class="screen" id="screen-m1">
+    <span class="tag yellow">Модуль 1 · 1 минута</span>
+    <h2>Включение прибора</h2>
+    <p>Детектор лежит на столе. На корпусе три кнопки. Чтобы начать работу, <b>нажмите кнопку питания ⏻</b>.</p>
+    <div class="card hint">
+      Короткое нажатие включает прибор. Долгое удержание (1,5 с) выключает его.
+    </div>
+
+    <div class="workbench-slot" data-screen="m1"></div>
+
+    <div class="task-indicator" id="task-m1">Задача: включите прибор</div>
+    <div class="nav-btns">
+      <button class="btn-primary next-btn" disabled onclick="go('m2')">Далее</button>
+    </div>
+  </section>
+
+  <!-- ============ MODULE 2 ============ -->
+  <section class="screen" id="screen-m2">
+    <span class="tag yellow">Модуль 2 · 1,5 минуты</span>
+    <h2>Калибровка — обязательный шаг</h2>
+    <p>После включения прибор нужно откалибровать. Прижмите детектор к <b>пустому участку стены</b> (перетащите его мышью или пальцем) и <b>удерживайте кнопку CAL</b> ~1,5 секунды.</p>
+    <div class="card warn">
+      ⚠️ Не калибруйте над объектом — прибор «ослепнет» и перестанет видеть реальные цели. На стене видны подсказки, где спрятаны объекты.
+    </div>
+
+    <div class="workbench-slot" data-screen="m2"></div>
+
+    <div class="task-indicator" id="task-m2">Задача: откалибруйте прибор на пустом участке</div>
+    <div class="nav-btns">
+      <button class="btn-primary next-btn" disabled onclick="go('m3')">Далее</button>
+    </div>
+  </section>
+
+  <!-- ============ MODULE 3 ============ -->
+  <section class="screen" id="screen-m3">
+    <span class="tag yellow">Модуль 3 · 2 минуты</span>
+    <h2>Переключение режимов</h2>
+    <p>Вы собираетесь сверлить стену. Чтобы убедиться, что под точкой нет <b>кабеля под напряжением</b>, переключите режим на <b>«Проводка»</b> кнопкой M.</p>
+    <div class="card hint">
+      🔄 Кнопка M циклически переключает режимы: <b>Металл → Проводка → Дерево</b>. Режим отображается на экране прибора.
+    </div>
+
+    <div class="workbench-slot" data-screen="m3"></div>
+
+    <div class="task-indicator" id="task-m3">Задача: включите режим «Проводка»</div>
+    <div class="nav-btns">
+      <button class="btn-primary next-btn" disabled onclick="go('m4')">Далее</button>
+    </div>
+  </section>
+
+  <!-- ============ MODULE 4 ============ -->
+  <section class="screen" id="screen-m4">
+    <span class="tag yellow">Модуль 4 · 2 минуты</span>
+    <h2>Поиск в действии</h2>
+    <p>Перетаскивайте детектор по стене. Частота звука растёт по мере приближения к объекту. Найдено: <b><span id="found-count">0</span> / 4</b>.</p>
+    <div class="card hint">
+      💡 Не забудьте: арматура и труба ищутся в режиме «Металл», кабель — в «Проводка», дерево — в «Дерево». Переключайтесь кнопкой M.
+    </div>
+
+    <div class="workbench-slot" data-screen="m4"></div>
+
+    <div class="task-indicator" id="task-m4">Задача: найдите все 4 объекта</div>
+    <div class="nav-btns">
+      <button class="btn-primary next-btn" disabled onclick="go('m5')">Далее</button>
+    </div>
+  </section>
+
+  <!-- ============ MODULE 5 ============ -->
+  <section class="screen" id="screen-m5">
+    <span class="tag yellow">Модуль 5 · 30 секунд</span>
+    <h2>Завершение работы</h2>
+    <p>Поиск завершён. Чтобы выключить прибор и сэкономить батарею, <b>удерживайте кнопку питания ⏻</b> около 1,5 секунды.</p>
+    <div class="card hint">
+      ⏻ Прибор автоматически выключится через 5 минут бездействия, но лучше выключать вручную.
+    </div>
+
+    <div class="workbench-slot" data-screen="m5"></div>
+
+    <div class="task-indicator" id="task-m5">Задача: выключите прибор удержанием кнопки питания</div>
+    <div class="nav-btns">
+      <button class="btn-primary next-btn" disabled onclick="go('quiz')">Итоговое тестирование</button>
+    </div>
+  </section>
+
+  <!-- ============ QUIZ ============ -->
+  <section class="screen" id="screen-quiz">
+    <span class="tag yellow">Итоговое тестирование</span>
+    <h2>Проверка знаний</h2>
+    <p class="muted">7 вопросов. Выберите один вариант в каждом. После отправки вы увидите результат и пояснения.</p>
+    <form id="quiz-form"></form>
+    <div class="nav-btns">
+      <button class="btn-primary" id="submit-quiz">Проверить ответы</button>
+    </div>
+  </section>
+
+  <!-- ============ RESULTS ============ -->
+  <section class="screen" id="screen-results">
+    <span class="tag yellow">Результат</span>
+    <h2>Тестирование завершено</h2>
+    <div class="result-score" id="result-score">0/7</div>
+    <p class="muted" id="result-msg" style="text-align:center;"></p>
+    <div class="card" id="result-details"></div>
+    <div class="nav-btns">
+      <button class="btn-primary" onclick="restart()">Пройти заново</button>
+    </div>
+  </section>
+
+</main>
+
+<div id="toast"></div>
+
+<script>
+/* =========================================================
+   STATE
+   ========================================================= */
+const MODES = ['metal', 'ac', 'wood'];
+const MODE_LABEL = { metal: 'МЕТАЛЛ', ac: 'ПРОВОДКА', wood: 'ДЕРЕВО' };
+const MODE_ICON  = { metal: 'Fe/Cu', ac: '⚡AC', wood: '🌳' };
+
+const OBJECTS = [
+  { id: 'armature', x: 18, depth: 100, label: 'Арматура Fe · 100 мм', needMode: 'metal' },
+  { id: 'pipe',     x: 42, depth: 80,  label: 'Труба Cu · 80 мм',     needMode: 'metal' },
+  { id: 'cable',    x: 66, depth: 50,  label: 'Кабель AC · 50 мм',    needMode: 'ac' },
+  { id: 'wood',     x: 88, depth: 20,  label: 'Дерево · 20 мм',       needMode: 'wood' }
+];
+
+const state = {
+  poweredOn: false,
+  calibrated: false,
+  mode: 'metal',
+  found: new Set(),
+  currentScreen: 'welcome',
+  scanningActive: false
+};
+
+/* =========================================================
+   AUDIO (Web Audio API)
+   ========================================================= */
+let audioCtx = null;
+function ensureAudio() {
+  if (!audioCtx) {
+    try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
+    catch(e) { /* no audio */ }
+  }
+  if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
+}
+function beep(freq = 800, dur = 0.05, vol = 0.06) {
+  ensureAudio();
+  if (!audioCtx) return;
+  const osc = audioCtx.createOscillator();
+  const gain = audioCtx.createGain();
+  osc.type = 'square';
+  osc.frequency.value = freq;
+  gain.gain.value = vol;
+  osc.connect(gain).connect(audioCtx.destination);
+  const t = audioCtx.currentTime;
+  osc.start(t);
+  gain.gain.exponentialRampToValueAtTime(0.0001, t + dur);
+  osc.stop(t + dur);
+}
+
+/* =========================================================
+   DOM REFS
+   ========================================================= */
+const workbench = document.createElement('div');
+workbench.id = 'workbench';
+workbench.innerHTML = `
+  <div class="wall" id="wall">
+    ${OBJECTS.map(o => `
+      <div class="wall-object" data-id="${o.id}" style="left:${o.x}%">
+        <span class="obj-label">${o.label}</span>
+      </div>
+    `).join('')}
+    <div class="detector" id="detector">
+      <div class="detector-display" id="det-display">
+        <div class="d-mode" id="d-mode">OFF</div>
+        <div class="d-main" id="d-main">—</div>
+      </div>
+      <div class="detector-buttons">
+        <button class="det-btn" data-btn="power" title="Питание">⏻</button>
+        <button class="det-btn" data-btn="mode" title="Режим">M</button>
+        <button class="det-btn" data-btn="cal" title="Калибровка">CAL</button>
+      </div>
+    </div>
+  </div>
+`;
+document.body.appendChild(workbench);
+
+const wall = workbench.querySelector('#wall');
+const detectorEl = workbench.querySelector('#detector');
+const dMode = workbench.querySelector('#d-mode');
+const dMain = workbench.querySelector('#d-main');
+const dDisplay = workbench.querySelector('#det-display');
+const btnPower = workbench.querySelector('[data-btn="power"]');
+const btnMode = workbench.querySelector('[data-btn="mode"]');
+const btnCal = workbench.querySelector('[data-btn="cal"]');
+
+/* =========================================================
+   HELPERS
+   ========================================================= */
+function showToast(msg, type = 'info', ms = 2200) {
+  const t = document.getElementById('toast');
+  t.textContent = msg;
+  t.className = 'show ' + type;
+  clearTimeout(t._timer);
+  t._timer = setTimeout(() => t.className = '', ms);
+}
+
+function getDetX() {
+  const left = parseFloat(detectorEl.style.left) || 50;
+  return left;
+}
+function setDetX(x) {
+  detectorEl.style.left = Math.max(6, Math.min(94, x)) + '%';
+}
+setDetX(50);
+
+/* =========================================================
+   NAVIGATION
+   ========================================================= */
+const SCREENS = ['welcome','m1','m2','m3','m4','m5','quiz','results'];
+
+function go(id) {
+  document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
+  const scr = document.getElementById('screen-' + id);
+  if (!scr) return;
+  scr.classList.add('active');
+  state.currentScreen = id;
+  window.scrollTo({ top: 0, behavior: 'smooth' });
+
+  // move workbench into active screen if needed
+  const slot = scr.querySelector('.workbench-slot');
+  if (slot) {
+    slot.appendChild(workbench);
+    // toggle object visibility
+    if (id === 'm2') wall.classList.add('show-objects');
+    else wall.classList.remove('show-objects');
+
+    // module-specific setup
+    if (id === 'm4') {
+      state.found.clear();
+      document.querySelectorAll('.wall-object').forEach(el => el.classList.remove('found'));
+      updateFoundCount();
+    }
+  }
+
+  updateProgress();
+  checkAdvance();
+  updateDetectorUI();
+
+  // start/stop scanning loop
+  if (id === 'm4' && state.poweredOn && state.calibrated) startScanLoop();
+  else stopScanLoop();
+}
+
+function updateProgress() {
+  const idx = SCREENS.indexOf(state.currentScreen);
+  const pct = idx <= 0 ? 0 : (idx / (SCREENS.length - 1)) * 100;
+  document.getElementById('progress-fill').style.width = pct + '%';
+}
+
+function checkAdvance() {
+  const scr = document.getElementById('screen-' + state.currentScreen);
+  if (!scr) return;
+  const btn = scr.querySelector('.next-btn');
+  if (!btn) return;
+  let ok = false;
+  switch (state.currentScreen) {
+    case 'm1': ok = state.poweredOn; break;
+    case 'm2': ok = state.calibrated; break;
+    case 'm3': ok = state.mode === 'ac'; break;
+    case 'm4': ok = state.found.size === OBJECTS.length; break;
+    case 'm5': ok = !state.poweredOn; break;
+  }
+  btn.disabled = !ok;
+
+  // task indicator
+  const ind = document.getElementById('task-' + state.currentScreen);
+  if (ind) {
+    if (ok) { ind.classList.add('done'); ind.textContent = '✅ Выполнено'; }
+    else {
+      ind.classList.remove('done');
+      if (state.currentScreen === 'm1') ind.textContent = 'Задача: включите прибор';
+      if (state.currentScreen === 'm2') ind.textContent = 'Задача: откалибруйте прибор на пустом участке';
+      if (state.currentScreen === 'm3') ind.textContent = 'Задача: включите режим «Проводка»';
+      if (state.currentScreen === 'm4') ind.textContent = 'Задача: найдите все 4 объекта';
+      if (state.currentScreen === 'm5') ind.textContent = 'Задача: выключите прибор удержанием кнопки питания';
+    }
+  }
+}
+
+/* =========================================================
+   DETECTOR UI
+   ========================================================= */
+function updateDetectorUI(signal) {
+  if (!state.poweredOn) {
+    dDisplay.className = 'detector-display';
+    dMode.textContent = 'OFF';
+    dMain.textContent = '—';
+    return;
+  }
+  dMode.textContent = MODE_LABEL[state.mode];
+  dDisplay.className = 'detector-display';
+  if (!state.calibrated) {
+    dMain.textContent = 'CAL?';
+    return;
+  }
+  if (signal) {
+    dDisplay.className = 'detector-display alert';
+    if (signal.obj.id === 'cable') dDisplay.className = 'detector-display danger';
+    dMain.textContent = MODE_ICON[state.mode] + ' ' + Math.round(100 - signal.dist * 7) + '%';
+  } else {
+    dMain.textContent = '—';
+  }
+}
+
+/* =========================================================
+   SIGNAL LOGIC
+   ========================================================= */
+function getSignal(detX) {
+  let best = null;
+  for (const obj of OBJECTS) {
+    if (state.mode !== obj.needMode) continue;
+    const dist = Math.abs(detX - obj.x);
+    if (dist < 12) {
+      if (!best || dist < best.dist) best = { obj, dist };
+    }
+  }
+  return best;
+}
+
+/* =========================================================
+   POWER BUTTON
+   ========================================================= */
+let powerHoldTimer = null;
+let powerPressStart = 0;
+
+btnPower.addEventListener('pointerdown', (e) => {
+  e.preventDefault();
+  e.stopPropagation();
+  ensureAudio();
+  powerPressStart = Date.now();
+  if (state.poweredOn) {
+    // hold to turn off
+    clearTimeout(powerHoldTimer);
+    powerHoldTimer = setTimeout(() => {
+      togglePower(false);
+    }, 1500);
+  }
+});
+btnPower.addEventListener('pointerup', (e) => {
+  e.stopPropagation();
+  clearTimeout(powerHoldTimer);
+  if (!state.poweredOn && Date.now() - powerPressStart < 1500) {
+    togglePower(true);
+  }
+});
+btnPower.addEventListener('pointerleave', () => clearTimeout(powerHoldTimer));
+btnPower.addEventListener('pointercancel', () => clearTimeout(powerHoldTimer));
+
+function togglePower(on) {
+  state.poweredOn = on;
+  if (!on) {
+    state.calibrated = false;
+    beep(400, 0.15, 0.08);
+    showToast('Прибор выключен', 'info');
+    stopScanLoop();
+  } else {
+    beep(1000, 0.1, 0.08);
+    showToast('Прибор включён', 'success');
+  }
+  updateDetectorUI();
+  checkAdvance();
+}
+
+/* =========================================================
+   MODE BUTTON
+   ========================================================= */
+btnMode.addEventListener('click', (e) => {
+  e.preventDefault();
+  e.stopPropagation();
+  if (!state.poweredOn) {
+    showToast('Сначала включите прибор', 'error');
+    return;
+  }
+  const idx = MODES.indexOf(state.mode);
+  state.mode = MODES[(idx + 1) % MODES.length];
+  beep(500, 0.04, 0.05);
+  updateDetectorUI();
+  checkAdvance();
+});
+
+/* =========================================================
+   CALIBRATION BUTTON
+   ========================================================= */
+let calHoldTimer = null;
+btnCal.addEventListener('pointerdown', (e) => {
+  e.preventDefault();
+  e.stopPropagation();
+  ensureAudio();
+  if (!state.poweredOn) {
+    showToast('Сначала включите прибор', 'error');
+    return;
+  }
+  clearTimeout(calHoldTimer);
+  dMain.textContent = 'CAL…';
+  calHoldTimer = setTimeout(() => {
+    const x = getDetX();
+    const overObject = OBJECTS.some(o => Math.abs(x - o.x) < 9);
+    if (overObject) {
+      beep(300, 0.25, 0.1);
+      showToast('Ошибка: калибровка над объектом!', 'error');
+      dMain.textContent = 'ERR';
+    } else {
+      state.calibrated = true;
+      beep(900, 0.08, 0.08);
+      setTimeout(() => beep(1200, 0.08, 0.08), 100);
+      showToast('Калибровка успешна', 'success');
+      dMain.textContent = 'OK';
+      checkAdvance();
+      if (state.currentScreen === 'm4') startScanLoop();
+    }
+  }, 1500);
+});
+btnCal.addEventListener('pointerup', (e) => {
+  e.stopPropagation();
+  clearTimeout(calHoldTimer);
+  if (state.poweredOn && !state.calibrated) dMain.textContent = 'CAL?';
+});
+btnCal.addEventListener('pointerleave', () => clearTimeout(calHoldTimer));
+btnCal.addEventListener('pointercancel', () => clearTimeout(calHoldTimer));
+
+/* =========================================================
+   WALL DRAG
+   ========================================================= */
+let dragging = false;
+wall.addEventListener('pointerdown', (e) => {
+  if (e.target.closest('.detector')) return;
+  dragging = true;
+  wall.setPointerCapture(e.pointerId);
+  moveDetectorTo(e);
+});
+wall.addEventListener('pointermove', (e) => {
+  if (!dragging) return;
+  moveDetectorTo(e);
+});
+wall.addEventListener('pointerup', () => dragging = false);
+wall.addEventListener('pointercancel', () => dragging = false);
+
+function moveDetectorTo(e) {
+  const rect = wall.getBoundingClientRect();
+  const x = ((e.clientX - rect.left) / rect.width) * 100;
+  setDetX(x);
+}
+
+/* =========================================================
+   SCAN LOOP (module 4)
+   ========================================================= */
+let scanTimeout = null;
+function startScanLoop() {
+  if (scanTimeout) return;
+  scanLoop();
+}
+function stopScanLoop() {
+  clearTimeout(scanTimeout);
+  scanTimeout = null;
+}
+function scanLoop() {
+  if (state.currentScreen !== 'm4' || !state.poweredOn || !state.calibrated) {
+    scanTimeout = null;
+    return;
+  }
+  const x = getDetX();
+  const sig = getSignal(x);
+  let delay = 400;
+  if (sig) {
+    const rate = 1 - sig.dist / 12;
+    beep(500 + rate * 700, 0.04, 0.03 + rate * 0.08);
+    delay = Math.max(120, 400 - rate * 300);
+    updateDetectorUI(sig);
+    // mark found
+    if (!state.found.has(sig.obj.id)) {
+      state.found.add(sig.obj.id);
+      document.querySelector(`.wall-object[data-id="${sig.obj.id}"]`).classList.add('found');
+      updateFoundCount();
+      showToast('Найдено: ' + sig.obj.label, 'success', 1600);
+      checkAdvance();
+    }
+  } else {
+    updateDetectorUI(null);
+  }
+  scanTimeout = setTimeout(scanLoop, delay);
+}
+
+function updateFoundCount() {
+  const el = document.getElementById('found-count');
+  if (el) el.textContent = state.found.size;
+}
+
+/* =========================================================
+   QUIZ
+   ========================================================= */
+const QUIZ = [
+  {
+    q: "Что нужно сделать в первую очередь после включения детектора, прежде чем искать объекты?",
+    opts: [
+      "Переключить режим на «Проводка»",
+      "Прижать к пустому участку стены и нажать/удержать кнопку калибровки",
+      "Отключить звук"
+    ],
+    correct: 1,
+    explain: "Калибровка настраивает прибор под фон стены (бетон, кирпич) и устраняет ложные срабатывания."
+  },
+  {
+    q: "Вы ищете кабель под напряжением. Какой режим нужно выбрать кнопкой M?",
+    opts: [
+      "«Металл»",
+      "«Проводка» (AC)",
+      "«Дерево»"
+    ],
+    correct: 1,
+    explain: "Режим «Проводка» настроен на электромагнитное поле кабеля под напряжением."
+  },
+  {
+    q: "Прибор в режиме «Металл» пищит над арматурой. Вы собираетесь сверлить. Что нужно сделать?",
+    opts: [
+      "Сразу сверлить — это же арматура",
+      "Переключить режим на «Проводка» и проверить место снова",
+      "Нажать калибровку прямо над арматурой"
+    ],
+    correct: 1,
+    explain: "Чтобы убедиться в отсутствии кабеля под напряжением, нужен режим «Проводка». Калибровка над объектом — ошибка."
+  },
+  {
+    q: "Какова максимальная глубина обнаружения чёрных металлов (арматуры) детектором DM-100?",
+    opts: [
+      "20 мм",
+      "50 мм",
+      "100 мм"
+    ],
+    correct: 2,
+    explain: "Чёрные металлы обнаруживаются на глубине до 100 мм."
+  },
+  {
+    q: "Как отключить звуковой сигнал, если он мешает?",
+    opts: [
+      "Выключить и снова включить прибор",
+      "Использовать комбинацию нижних кнопок (удержание одной + нажатие другой)",
+      "Кнопкой M"
+    ],
+    correct: 1,
+    explain: "В DM-100 предусмотрено отключение звука комбинацией кнопок."
+  },
+  {
+    q: "Через сколько минут бездействия детектор автоматически выключается?",
+    opts: [
+      "1 минута",
+      "5 минут",
+      "30 минут"
+    ],
+    correct: 1,
+    explain: "Автовыключение предусмотрено через 5 минут отсутствия активности."
+  },
+  {
+    q: "Что означает учащающийся звуковой сигнал при поиске?",
+    opts: [
+      "Прибор разряжается",
+      "Детектор приближается к объекту",
+      "Произошла ошибка калибровки"
+    ],
+    correct: 1,
+    explain: "Частота сигнала возрастает по мере приближения к цели — это главный навигационный инструмент."
+  }
+];
+
+function renderQuiz() {
+  const form = document.getElementById('quiz-form');
+  form.innerHTML = QUIZ.map((q, i) => `
+    <div class="question" data-q="${i}">
+      <h3>${i + 1}. ${q.q}</h3>
+      <div class="options">
+        ${q.opts.map((opt, j) => `
+          <label class="option" data-opt="${j}">
+            <input type="radio" name="q${i}" value="${j}">
+            <span>${opt}</span>
+          </label>
+        `).join('')}
+      </div>
+      <div class="explain" id="explain-${i}"></div>
+    </div>
+  `).join('');
+}
+
+document.getElementById('submit-quiz').addEventListener('click', () => {
+  let score = 0;
+  QUIZ.forEach((q, i) => {
+    const selected = document.querySelector(`input[name="q${i}"]:checked`);
+    const qEl = document.querySelector(`.question[data-q="${i}"]`);
+    const explain = document.getElementById(`explain-${i}`);
+    qEl.querySelectorAll('.option').forEach(o => o.classList.remove('correct', 'wrong', 'reveal'));
+
+    if (selected) {
+      const val = parseInt(selected.value, 10);
+      const chosenEl = qEl.querySelector(`.option[data-opt="${val}"]`);
+      if (val === q.correct) {
+        score++;
+        chosenEl.classList.add('correct');
+      } else {
+        chosenEl.classList.add('wrong');
+        qEl.querySelector(`.option[data-opt="${q.correct}"]`).classList.add('reveal');
+      }
+    } else {
+      qEl.querySelector(`.option[data-opt="${q.correct}"]`).classList.add('reveal');
+    }
+    explain.textContent = q.explain;
+    explain.classList.add('show');
+  });
+
+  showResults(score);
+});
+
+function showResults(score) {
+  const total = QUIZ.length;
+  const scoreEl = document.getElementById('result-score');
+  scoreEl.textContent = score + ' / ' + total;
+  scoreEl.className = 'result-score ' + (score >= 6 ? 'good' : score >= 4 ? 'mid' : 'bad');
+
+  const msg = document.getElementById('result-msg');
+  if (score === total) msg.textContent = 'Отлично! Вы полностью освоили прибор.';
+  else if (score >= 6) msg.textContent = 'Хороший результат. Ознакомьтесь с пояснениями к ошибкам.';
+  else if (score >= 4) msg.textContent = 'Есть пробелы. Рекомендуем повторить модули 2 и 4.';
+  else msg.textContent = 'Стоит пройти обучение заново.';
+
+  const details = document.getElementById('result-details');
+  details.innerHTML = QUIZ.map((q, i) => {
+    const sel = document.querySelector(`input[name="q${i}"]:checked`);
+    const chosen = sel ? parseInt(sel.value, 10) : -1;
+    const ok = chosen === q.correct;
+    return `<p style="margin-bottom:8px;font-size:.9rem;">
+      <b style="color:${ok ? 'var(--green)' : 'var(--red)'}">${ok ? '✓' : '✗'} Вопрос ${i + 1}.</b>
+      ${ok ? 'Верно.' : 'Правильный ответ: ' + q.opts[q.correct]}
+    </p>`;
+  }).join('');
+
+  go('results');
+}
+
+/* =========================================================
+   RESTART
+   ========================================================= */
+function restart() {
+  state.poweredOn = false;
+  state.calibrated = false;
+  state.mode = 'metal';
+  state.found.clear();
+  setDetX(50);
+  document.querySelectorAll('.wall-object').forEach(el => el.classList.remove('found'));
+  document.querySelectorAll('.option').forEach(o => o.classList.remove('correct', 'wrong', 'reveal'));
+  document.querySelectorAll('.explain').forEach(e => e.classList.remove('show'));
+  document.getElementById('quiz-form').innerHTML = '';
+  renderQuiz();
+  stopScanLoop();
+  updateDetectorUI();
+  go('welcome');
+}
+
+/* =========================================================
+   INIT
+   ========================================================= */
+renderQuiz();
+updateDetectorUI();
+updateProgress();
+
+// Prevent accidental page zoom on double tap of buttons
+document.addEventListener('dblclick', (e) => {
+  if (e.target.closest('button')) e.preventDefault();
+}, { passive: false });
+
+// Resume audio on first user gesture
+document.addEventListener('pointerdown', ensureAudio, { once: true });
+</script>
+</body>
+</html>