Files
birthday-chat/app.js
Freestyle-Play 60991875c0 init
2026-08-25 18:22:36 +03:00

1081 lines
41 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
ДЕЛО № 260826 — движок v2
Состояния экранов, журнал фактов, подсказки, звук.
============================================================ */
(function () {
"use strict";
const STORAGE_KEY = "papka_v2_state";
const MUTE_KEY = "papka_v2_muted";
/* Экраны без верхней панели и дока */
const BARE = new Set(["intro", "loading", "twist", "congrats", "orderScreen"]);
/* Экраны-загадки: на них доступна подсказка */
const PUZZLES = {
caseNumber: CONTENT.caseNumber,
scene: CONTENT.scene,
witnesses: CONTENT.witnesses,
philology: CONTENT.philology,
audio: CONTENT.audio,
falseTrail: CONTENT.falseTrail,
orderForm: CONTENT.order,
};
const NEXT = {
caseNumber: "caseOpened",
caseOpened: "scene",
scene: "witnesses",
witnesses: "philology",
philology: "audio",
audio: "falseTrail",
falseTrail: "orderForm",
orderForm: "twist",
twist: "congrats",
congrats: "orderScreen",
};
/* ---------------- состояние ---------------- */
const defaultState = () => ({
stage: "intro",
solved: {}, // stageId -> true
journal: [], // накопленные факты
mistakes: {},
hintRequested: {},
tellsFound: [], // найденные признаки подделки в рапорте
orderFilled: {}, // подтверждённые графы ордера
easter: {},
muted: localStorage.getItem(MUTE_KEY) === "1",
});
let state = load();
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? Object.assign(defaultState(), JSON.parse(raw)) : defaultState();
} catch (e) {
return defaultState();
}
}
function save() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {}
}
function resetAll() {
localStorage.removeItem(STORAGE_KEY);
state = defaultState();
render();
}
/* ---------------- звук ---------------- */
const Sound = (function () {
let ctx = null;
function ensure() {
if (!ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (AC) ctx = new AC();
}
if (ctx && ctx.state === "suspended") ctx.resume();
return ctx;
}
function tone(freq, dur, type, peak, delay) {
if (state.muted) return;
const c = ensure();
if (!c) return;
const t0 = c.currentTime + (delay || 0);
const osc = c.createOscillator();
const g = c.createGain();
osc.type = type || "sine";
osc.frequency.setValueAtTime(freq, t0);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(peak || 0.07, t0 + 0.015);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g); g.connect(c.destination);
osc.start(t0); osc.stop(t0 + dur + 0.02);
}
return {
click() { tone(620, 0.045, "square", 0.025); },
success() { tone(523, 0.09, "sine", 0.06); tone(784, 0.14, "sine", 0.05, 0.09); },
error() { tone(150, 0.15, "sawtooth", 0.04); },
stamp() { tone(90, 0.09, "square", 0.085); tone(68, 0.13, "square", 0.06, 0.03); },
warm() { tone(392, 0.3, "sine", 0.045); tone(523, 0.35, "sine", 0.035, 0.12); tone(659, 0.5, "sine", 0.035, 0.24); },
};
})();
/* ---------------- утилиты ---------------- */
const digits = (s) => ((s || "").match(/\d+/g) || []).join("");
const word = (s) => (s || "").trim().toLowerCase().replace(/ё/g, "е");
const bare = (s) => (s || "").toUpperCase().replace(/[\s\-_.·№]/g, "").replace(/Ё/g, "Е");
/* Время приводим к чч:мм, чтобы «9:08», «09.08» и «0908» считались
тем же ответом, что и «09:08». */
function normTime(s) {
const m = (s || "").trim().match(/^(\d{1,2})\s*[:.\-\s]?\s*(\d{2})$/);
if (!m) return null;
const h = +m[1], min = +m[2];
if (h > 23 || min > 59) return null;
return String(h).padStart(2, "0") + ":" + m[2];
}
/* Сравнение ответа для конкретной графы ордера. */
function matchesFact(fieldId, value) {
if (fieldId === "exitTime") {
const t = normTime(value);
return t !== null && t === FACTS.exitTime;
}
return bare(value) === bare(FACTS[fieldId]);
}
function mistakesFor(id) { return state.mistakes[id] || 0; }
function addMistake(id) { state.mistakes[id] = mistakesFor(id) + 1; save(); }
function autoHint(id) {
const m = mistakesFor(id);
return m >= 8 ? 3 : m >= 5 ? 2 : m >= 3 ? 1 : 0;
}
function hintLevel(id) { return Math.max(autoHint(id), state.hintRequested[id] || 0); }
function bumpHint(id, max) {
const cur = state.hintRequested[id] || 0;
if (cur < max) state.hintRequested[id] = cur + 1;
save();
}
function canReveal(id) { return mistakesFor(id) >= 8; }
function pick(arr, i) { return arr[i % arr.length]; }
function addFact(ev) {
if (state.journal.some((j) => j.name === ev.name)) return;
state.journal.push({ name: ev.name, fact: ev.fact, factLabel: ev.factLabel, desc: ev.desc });
save();
}
function markSolved(id) { state.solved[id] = true; save(); }
function go(stage) {
state.stage = stage;
save();
render();
window.scrollTo({ top: 0, behavior: "smooth" });
}
function esc(s) {
const d = document.createElement("div");
d.textContent = s == null ? "" : String(s);
return d.innerHTML;
}
function paras(lines) {
return (Array.isArray(lines) ? lines : [lines])
.map((l) => (l === "" ? '<div class="vspace"></div>' : `<p>${esc(l)}</p>`))
.join("");
}
/* Фидбек переживает перерисовку: render() пересобирает DOM целиком,
поэтому текст ошибки кладём сюда и забираем на следующем рендере. */
let pending = {};
/* То же для граф ордера: у каждой свой текст ошибки и своё введённое
значение, которые обязаны пережить перерисовку. */
let pendingOrder = {};
function setFeedback(id, text, cls, value) { pending[id] = { text, cls, value }; }
function takeFeedback(fbEl, id, inputEl) {
const f = pending[id];
if (!f) return;
delete pending[id];
fbEl.innerHTML = `<span class="fb-mark"></span><span>${esc(f.text)}</span>`;
fbEl.className = "feedback " + f.cls;
if (inputEl && f.value != null) {
inputEl.value = f.value;
inputEl.focus();
try { inputEl.setSelectionRange(f.value.length, f.value.length); } catch (e) {}
}
}
/* ---------------- каркас ---------------- */
const app = document.getElementById("app");
function render() {
app.innerHTML = "";
document.body.className = ["twist", "congrats", "orderScreen"].includes(state.stage) ? "warm" : "";
const wrap = document.createElement("div");
wrap.className = "shell";
if (!BARE.has(state.stage)) {
wrap.appendChild(topbar());
wrap.appendChild(progressRail());
}
const main = document.createElement("main");
main.className = "stage";
main.appendChild(stageView(state.stage));
wrap.appendChild(main);
if (!BARE.has(state.stage)) {
wrap.appendChild(dock());
wrap.appendChild(footer());
}
app.appendChild(wrap);
}
function topbar() {
const el = document.createElement("header");
el.className = "topbar";
const hintable = !!PUZZLES[state.stage] && !state.solved[state.stage];
/* Номер дела — ответ первой загадки, до её решения его не показываем. */
const caseLabel = state.solved.caseNumber ? "дело № " + CONFIG.caseNumber : "дело № ——————";
el.innerHTML = `
<div class="tb-left">
<span class="tb-logo">ГИС «ПАПКА»</span>
<span class="tb-case">${esc(caseLabel)}</span>
</div>
<div class="tb-right">
<button class="chip" data-a="journal">Журнал<span class="chip-n">${state.journal.length}</span></button>
<button class="chip" data-a="hint"${hintable ? "" : " disabled"}>Подсказка</button>
<button class="chip chip-i" data-a="sound" title="звук">${state.muted ? "🔇" : "🔊"}</button>
</div>`;
el.querySelector('[data-a="journal"]').onclick = openJournal;
el.querySelector('[data-a="hint"]').onclick = () => openHint(state.stage);
el.querySelector('[data-a="sound"]').onclick = () => {
state.muted = !state.muted;
localStorage.setItem(MUTE_KEY, state.muted ? "1" : "0");
render();
};
return el;
}
function progressRail() {
const el = document.createElement("div");
el.className = "rail";
const curIdx = CHAPTERS.findIndex((c) => c.stage === state.stage);
el.innerHTML = CHAPTERS.map((c, i) => {
const done = state.solved[c.stage];
const cur = i === curIdx;
return `<div class="rail-step${done ? " done" : ""}${cur ? " cur" : ""}">
<span class="rail-dot">${done ? "✓" : i + 1}</span>
<span class="rail-label">${esc(c.label)}</span>
</div>`;
}).join("");
return el;
}
function dock() {
const e = CONTENT.easter;
const el = document.createElement("div");
el.className = "dock";
el.innerHTML = `
<button class="dk" data-a="speed">${esc(e.speedButton)}</button>
<button class="dk" data-a="confused">${esc(e.confusedButton)}</button>
<button class="dk" data-a="complaint">${esc(e.complaintButton)}</button>
<button class="dk" data-a="reset">${esc(e.resetButton)}</button>`;
el.querySelector('[data-a="speed"]').onclick = () => {
const n = (state.easter.speed = (state.easter.speed || 0) + 1);
Sound.click(); save(); toast(n === 1 ? e.speedFirst : e.speedSecond);
};
el.querySelector('[data-a="confused"]').onclick = () => {
const n = (state.easter.confused = (state.easter.confused || 0) + 1);
Sound.click(); save(); toast(n === 1 ? e.confusedFirst : e.confusedSecond);
};
el.querySelector('[data-a="complaint"]').onclick = () => { Sound.click(); toast(e.complaintResult); };
el.querySelector('[data-a="reset"]').onclick = () => {
const now = Date.now();
if (resetArmed && now - resetArmed < 4000) { Sound.stamp(); resetArmed = 0; resetAll(); }
else { resetArmed = now; Sound.click(); toast(e.resetArm); }
};
return el;
}
let resetArmed = 0;
function footer() {
const f = document.createElement("div");
f.className = "footer";
f.textContent = CONTENT.easter.footer;
return f;
}
/* Clipboard API недоступен при открытии страницы файлом (file://) и
в ряде мобильных браузеров, а writeText умеет отклоняться. Поэтому
запасной путь через execCommand и честное сообщение, если не вышло. */
function copyText(text) {
const fallback = () => {
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.cssText = "position:fixed;top:0;left:0;opacity:0";
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand("copy");
document.body.removeChild(ta);
toast(ok ? "Скопировано." : "Скопировать не удалось — выделите текст выше вручную.");
} catch (e) {
toast("Скопировать не удалось — выделите текст выше вручную.");
}
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => toast("Скопировано."), fallback);
} else {
fallback();
}
}
let toastT = null;
function toast(text) {
let t = document.getElementById("toast");
if (!t) { t = document.createElement("div"); t.id = "toast"; document.body.appendChild(t); }
t.textContent = text;
t.classList.add("on");
clearTimeout(toastT);
toastT = setTimeout(() => t.classList.remove("on"), 3800);
}
/* ---------------- панели ---------------- */
function closePanels() { document.querySelectorAll(".backdrop").forEach((n) => n.remove()); }
function panel(title, bodyHtml, cls) {
closePanels();
const b = document.createElement("div");
b.className = "backdrop";
b.innerHTML = `
<aside class="panel ${cls || ""}">
<div class="panel-head"><h3>${esc(title)}</h3><button class="panel-x" data-a="x">×</button></div>
<div class="panel-body">${bodyHtml}</div>
</aside>`;
b.onclick = (e) => { if (e.target === b) closePanels(); };
b.querySelector('[data-a="x"]').onclick = closePanels;
document.body.appendChild(b);
return b;
}
function openJournal() {
const body = state.journal.length === 0
? '<p class="dim">Журнал пуст. Следствие в самом начале.</p>'
: `<p class="dim panel-lead">Установленные факты. В финале понадобятся все.</p>` +
state.journal.map((j) => `
<div class="jcard">
<div class="jcard-name">${esc(j.name)}</div>
<div class="jcard-fact"><span class="jcard-label">${esc(j.factLabel)}</span><b>${esc(j.fact)}</b></div>
<div class="jcard-desc">${esc(j.desc)}</div>
</div>`).join("");
panel("Журнал дела", body);
}
function openHint(stageId) {
const cfg = PUZZLES[stageId];
if (!cfg || !cfg.hints) return;
bumpHint(stageId, cfg.hints.length);
const lvl = hintLevel(stageId);
const body = `
<p class="panel-lead dim">${esc(pick(COMMON.hintIntro, lvl))}</p>
${cfg.hints.slice(0, lvl).map((h, i) => `<p class="hint"><b>${i + 1}.</b> ${esc(h)}</p>`).join("")}
${lvl === 0 ? '<p class="dim">Подсказка ещё не раскрыта. Нажмите ещё раз.</p>' : ""}`;
panel("Подсказка", body, "panel-hint");
}
/* ---------------- строительные блоки ---------------- */
function sheet(html, cls) {
const d = document.createElement("section");
d.className = "sheet" + (cls ? " " + cls : "");
d.innerHTML = html;
return d;
}
function btn(label, cls) {
const b = document.createElement("button");
b.className = "btn " + (cls || "primary");
b.textContent = label;
return b;
}
function row(nodes) {
const r = document.createElement("div");
r.className = "row";
nodes.forEach((n) => r.appendChild(n));
return r;
}
function head(chapter, title) {
return `<div class="sheet-head">
<div class="chapter">${esc(chapter)}</div>
<h2>${esc(title)}</h2>
</div>`;
}
/* Блок успеха с карточкой добытого факта */
function successBlock(title, text, ev) {
return `<div class="success">
<div class="success-head"><span class="tick">✓</span>${esc(title)}</div>
<p>${esc(text)}</p>
${ev ? `<div class="jcard inline">
<div class="jcard-name">${esc(ev.name)}</div>
<div class="jcard-fact"><span class="jcard-label">${esc(ev.factLabel)}</span><b>${esc(ev.fact)}</b></div>
</div>` : ""}
</div>`;
}
function revealLink(stageId, onClick) {
if (!canReveal(stageId)) return null;
const b = document.createElement("button");
b.className = "linkbtn";
b.textContent = "Следствие настаивает на правильном ответе";
b.onclick = onClick;
return b;
}
/* ============================================================
ЭКРАНЫ
============================================================ */
function stageView(s) {
switch (s) {
case "intro": return vIntro();
case "loading": return vLoading();
case "caseNumber": return vCaseNumber();
case "caseOpened": return vCaseOpened();
case "scene": return vScene();
case "witnesses": return vWitnesses();
case "philology": return vPhilology();
case "audio": return vAudio();
case "falseTrail": return vFalseTrail();
case "orderForm": return vOrderForm();
case "twist": return vTwist();
case "congrats": return vCongrats();
case "orderScreen": return vOrderScreen();
default: return sheet("<p>Раздел ушёл в отпуск по сюжетным обстоятельствам.</p>");
}
}
/* ---- интро ---- */
function vIntro() {
const c = CONTENT.intro;
const el = document.createElement("div");
el.className = "center-wrap";
el.innerHTML = `
<div class="sheet intro">
<div class="chapter red">${esc(c.tag)}</div>
<h1>${esc(c.title)}</h1>
${paras(c.body)}
<p class="note">${esc(c.footnote)}</p>
</div>`;
const b = btn(c.button);
b.onclick = () => { Sound.click(); go("loading"); };
el.querySelector(".intro").insertBefore(row([b]), el.querySelector(".note"));
return el;
}
/* ---- загрузка ---- */
function vLoading() {
const c = CONTENT.loadingScreen;
const el = document.createElement("div");
el.className = "boot";
el.innerHTML = `
<h2 class="boot-title">${esc(c.system)}</h2>
<p class="boot-sub">${esc(c.subtitle)}</p>
<div class="boot-lines"></div>`;
const box = el.querySelector(".boot-lines");
c.lines.forEach((l, i) => {
const p = document.createElement("p");
p.textContent = l;
p.style.animationDelay = i * 0.5 + "s";
box.appendChild(p);
});
setTimeout(() => {
const b = btn(c.button);
b.onclick = () => { Sound.click(); go("caseNumber"); };
const r = row([b]);
r.classList.add("center");
el.appendChild(r);
}, c.lines.length * 500 + 400);
return el;
}
/* ---- глава 0: номер дела ---- */
function vCaseNumber() {
const c = CONTENT.caseNumber;
const id = "caseNumber";
const el = sheet(`
${head(c.chapter, c.title)}
${paras(c.body)}
<div class="excerpt">
<div class="excerpt-h">${esc(c.ruleTitle)}</div>
<p>${esc(c.ruleText)}</p>
</div>
<div class="record">
<div class="record-h">${esc(c.registryTitle)}</div>
${c.registryRows.map(([k, v]) => `<div class="rec-row"><span>${esc(k)}</span><b>${esc(v)}</b></div>`).join("")}
</div>
<div class="inrow">
<input id="in" type="text" placeholder="${esc(c.placeholder)}" autocomplete="off" inputmode="numeric" />
<button class="btn primary" id="go">${esc(c.button)}</button>
</div>
<div class="feedback" id="fb"></div>`);
const input = el.querySelector("#in");
const fb = el.querySelector("#fb");
takeFeedback(fb, id, input);
const submit = () => {
const raw = input.value;
if (digits(raw) === CONFIG.caseNumber) {
Sound.success(); markSolved(id); go("caseOpened");
} else {
Sound.error(); addMistake(id);
setFeedback(id, pick(c.wrong, mistakesFor(id) - 1), "bad shake", raw);
render();
}
};
el.querySelector("#go").onclick = submit;
input.onkeydown = (e) => { if (e.key === "Enter") submit(); };
const rv = revealLink(id, () => { input.value = CONFIG.caseNumber; input.focus(); });
if (rv) el.appendChild(rv);
return el;
}
/* ---- дело открыто ---- */
function vCaseOpened() {
const c = CONTENT.caseOpened;
const el = sheet(`${head("Допуск получен", c.title)}${paras(c.body)}`);
const b = btn(c.button);
b.onclick = () => { Sound.click(); go("scene"); };
el.appendChild(row([b]));
return el;
}
/* ---- глава 1: опись ---- */
function vScene() {
const c = CONTENT.scene;
const id = "scene";
const done = !!state.solved[id];
const el = sheet(`
${head(c.chapter, c.title)}
${paras(c.body)}
<div class="two-col">
<div class="record">
<div class="record-h">${esc(c.protocolTitle)}</div>
${c.inventory.map(([n, d]) => `<div class="inv-row"><span class="inv-n">${esc(n)}</span><span>${esc(d)}</span></div>`).join("")}
<div class="record-note">${esc(c.protocolNote)}</div>
</div>
<div class="scene-box">${desk()}</div>
</div>
<div class="feedback" id="fb"></div>
<div id="after"></div>`, "wide");
const fb = el.querySelector("#fb");
const after = el.querySelector("#after");
if (done) {
after.innerHTML = successBlock(c.successTitle, c.successBody, c.evidence);
const b = btn(c.button);
b.onclick = () => { Sound.click(); go("witnesses"); };
after.appendChild(row([b]));
el.querySelector('[data-id="box"]').classList.add("found");
} else {
takeFeedback(fb, id);
el.querySelectorAll(".hit").forEach((n) => {
n.onclick = () => {
const hid = n.getAttribute("data-id");
if (hid === c.correctId) {
Sound.success(); addFact(c.evidence); markSolved(id); render();
} else {
Sound.error(); addMistake(id);
setFeedback(id, c.reactions[hid] || "Предмет осмотрен. Предмет молчит.", "bad");
render();
}
};
});
const rv = revealLink(id, () => el.querySelector('[data-id="box"]').classList.add("pulse"));
if (rv) el.appendChild(rv);
}
return el;
}
/* Стол с предметами и читаемыми бирками */
function desk() {
const item = (id, x, y, w, h, tag, inner) => `
<g class="hit" data-id="${id}" transform="translate(${x},${y})">
${inner}
<g class="tag" transform="translate(0,${h + 6})">
<rect x="0" y="0" width="${Math.max(46, tag.length * 7.2)}" height="15" rx="2"/>
<text x="4" y="11">${tag}</text>
</g>
</g>`;
return `
<svg viewBox="0 0 560 420" class="desk" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Стол с предметами">
<defs>
<linearGradient id="wood" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#5b4632"/><stop offset="1" stop-color="#43331f"/>
</linearGradient>
</defs>
<rect width="560" height="420" fill="url(#wood)"/>
<g opacity=".08" stroke="#000">
${Array.from({ length: 14 }, (_, i) => `<path d="M0 ${i * 30} H560" stroke-width="1"/>`).join("")}
</g>
${item("folder", 26, 26, 128, 82, "БКТО-14",
`<rect width="128" height="82" rx="3" fill="#cbb389" stroke="#8b7550"/>
<rect x="0" y="0" width="128" height="16" rx="3" fill="#b89f74"/>
<text x="9" y="42" class="lbl">ДЕЛО №</text>
<text x="9" y="60" class="lbl">${esc(CONFIG.caseNumber)}</text>`)}
${item("mug", 196, 40, 54, 54, "БКТО-12",
`<rect width="54" height="54" rx="8" fill="#e9dcc2" stroke="#b4a17c"/>
<path d="M54 16 q14 11 0 24" fill="none" stroke="#b4a17c" stroke-width="4"/>
<text x="6" y="31" class="lbl-s">НЕ</text>
<text x="6" y="44" class="lbl-s">ТРОГАТЬ</text>`)}
${item("calendar", 292, 30, 84, 68, "БКТО-13",
`<rect width="84" height="68" rx="3" fill="#eee3cb" stroke="#b4a17c"/>
<rect width="84" height="15" rx="3" fill="#b33a3a"/>
<text x="26" y="52" class="num">26</text>
<circle cx="41" cy="45" r="18" fill="none" stroke="#b33a3a" stroke-width="2.5"/>`)}
${item("lamp", 424, 22, 76, 96, "БКТО-11",
`<ellipse cx="38" cy="26" rx="38" ry="24" fill="#3f7a52"/>
<ellipse cx="38" cy="26" rx="26" ry="15" fill="#d9a441" opacity=".85"/>
<rect x="33" y="46" width="10" height="42" fill="#7d8892"/>
<rect x="18" y="88" width="40" height="8" rx="3" fill="#7d8892"/>`)}
${item("pencil", 34, 190, 116, 12, "БКТО-15",
`<rect y="2" width="100" height="9" rx="3" fill="#d9a441"/>
<path d="M100 2 l16 5.5 l-16 5.5z" fill="#e9dcc2"/>`)}
${item("paper", 176, 168, 170, 92, "БКТО-17",
`<rect width="170" height="92" fill="#f4ecd8" stroke="#b33a3a" stroke-width="1.5"/>
<text x="12" y="34" class="lbl-s">ПЕЧАТЬ</text>
<text x="12" y="52" class="lbl-s">НЕ ТРОГАТЬ</text>
<text x="12" y="76" class="lbl-xs">(печать отсутствует)</text>`)}
${item("cactus", 424, 176, 54, 84, "БКТО-16",
`<rect x="12" y="62" width="30" height="22" rx="3" fill="#a6714a"/>
<path d="M27 62 V16 q0-10 5-10 t5 10 v10" fill="none" stroke="#5c8a5c" stroke-width="9" stroke-linecap="round"/>
<path d="M27 44 q-14 0 -14 -14" fill="none" stroke="#5c8a5c" stroke-width="8" stroke-linecap="round"/>`)}
${item("box", 246, 306, 104, 66, "ДС-04",
`<rect width="104" height="66" rx="2" fill="#c19a6b" stroke="#8a6a44"/>
<path d="M0 22 H104" stroke="#8a6a44" stroke-width="1.5"/>
<rect x="40" y="0" width="24" height="66" fill="#b08a5e" opacity=".7"/>
<text x="8" y="46" class="lbl-xs">хрупкое. сюжет внутри</text>`)}
</svg>`;
}
/* ---- глава 2: журнал и показания ---- */
function vWitnesses() {
const c = CONTENT.witnesses;
const id = "witnesses";
const done = !!state.solved[id];
const el = sheet(`
${head(c.chapter, c.title)}
${paras(c.body)}
<div class="record log">
<div class="record-h">${esc(c.logTitle)}</div>
<div class="log-grid">
${c.logRows.map(([t, n, d]) => `
<div class="log-row">
<span class="log-t">${esc(t)}</span>
<span class="log-n">${esc(n)}</span>
<span class="log-d ${d === "выход" ? "out" : "in"}">${esc(d)}</span>
</div>`).join("")}
</div>
</div>
<div class="cards" id="cards"></div>
<div class="feedback" id="fb"></div>
<div id="after"></div>`);
const cards = el.querySelector("#cards");
c.cards.forEach((w) => {
const b = document.createElement("button");
b.className = "pcard";
b.disabled = done;
if (w.id === c.correct) b.dataset.correct = "1";
if (done && w.id === c.correct) b.classList.add("found");
b.innerHTML = `
<div class="pcard-top"><span class="pcard-name">${esc(w.name)}</span><span class="pcard-role">${esc(w.role)}</span></div>
<div class="pcard-text">${esc(w.text)}</div>`;
b.onclick = () => {
if (w.id === c.correct) { Sound.success(); addFact(c.evidence); markSolved(id); render(); }
else {
Sound.error(); addMistake(id);
setFeedback(id, c.wrongReactions[w.id], "bad");
render();
}
};
cards.appendChild(b);
});
const after = el.querySelector("#after");
if (done) {
after.innerHTML = successBlock(c.successTitle, c.successBody, c.evidence);
const b = btn(c.button);
b.onclick = () => { Sound.click(); go("philology"); };
after.appendChild(row([b]));
} else {
takeFeedback(el.querySelector("#fb"), id);
const rv = revealLink(id, () => el.querySelector('[data-correct="1"]').classList.add("pulse"));
if (rv) el.appendChild(rv);
}
return el;
}
/* ---- глава 3: филология ---- */
function vPhilology() {
const c = CONTENT.philology;
const id = "philology";
const done = !!state.solved[id];
const el = sheet(`
${head(c.chapter, c.title)}
${paras(c.body)}
<div class="memo">
<div class="memo-h">${esc(c.memoTitle)}</div>
<p>${esc(c.memoText)}</p>
</div>
<div class="docs" id="docs"></div>
<div class="feedback" id="fb"></div>
<div id="after"></div>`);
const docs = el.querySelector("#docs");
c.docs.forEach((d) => {
const b = document.createElement("button");
b.className = "doc";
b.disabled = done;
if (d.id === c.correct) b.dataset.correct = "1";
if (done && d.id === c.correct) b.classList.add("found");
b.innerHTML = `
<div class="doc-h"><span class="doc-n">Документ № ${d.id}</span><span class="doc-title">${esc(d.head)}</span></div>
<div class="doc-t">${esc(d.text)}</div>
<div class="doc-sign">${esc(d.sign)}</div>`;
b.onclick = () => {
if (d.id === c.correct) { Sound.success(); addFact(c.evidence); markSolved(id); render(); }
else {
Sound.error(); addMistake(id);
setFeedback(id, c.wrongReactions[d.id], "bad");
render();
}
};
docs.appendChild(b);
});
const after = el.querySelector("#after");
if (done) {
after.innerHTML = successBlock(c.successTitle, c.successBody, c.evidence);
const b = btn(c.button);
b.onclick = () => { Sound.click(); go("audio"); };
after.appendChild(row([b]));
} else {
takeFeedback(el.querySelector("#fb"), id);
const rv = revealLink(id, () => el.querySelector('[data-correct="1"]').classList.add("pulse"));
if (rv) el.appendChild(rv);
}
return el;
}
/* ---- глава 4: аудио ---- */
function vAudio() {
const c = CONTENT.audio;
const id = "audio";
const done = !!state.solved[id];
const el = sheet(`
${head(c.chapter, c.title)}
${paras(c.body)}
<div class="song">
<div class="song-h">
<div><div class="song-t">${esc(c.songTitle)}</div><div class="song-a">${esc(c.songAuthor)}</div></div>
</div>
<audio id="player" controls preload="none" src="${esc(CONFIG.songSrc)}"></audio>
<p class="song-fb" id="sfb" hidden>Аудиозапись технически недоступна. Но текст перед вами — это тоже документ.</p>
<div class="lyrics" id="lyrics"></div>
</div>
<div class="inrow">
<input id="in" type="text" placeholder="${esc(c.placeholder)}" autocomplete="off"${done ? " disabled" : ""} />
<button class="btn primary" id="go"${done ? " disabled" : ""}>${esc(c.button)}</button>
</div>
<div class="feedback" id="fb"></div>
<div id="after"></div>`);
const ly = el.querySelector("#lyrics");
c.lyrics.forEach((bl) => {
const d = document.createElement("div");
d.className = "lblock " + bl.type;
const isBridge = bl.type === "bridge";
/* Первые буквы бриджа намеренно НЕ подсвечиваем: найти акростих —
и есть загадка. Блок выделен визуально («куда смотреть»),
а «как читать» объясняют подсказки. */
d.innerHTML = `<div class="lblock-h">${esc(bl.label)}${isBridge ? ` <span class="badge">${esc(c.bridgeLabel)}</span>` : ""}</div>` +
bl.lines.map((l) => `<p>${esc(l)}</p>`).join("");
ly.appendChild(d);
});
const player = el.querySelector("#player");
player.addEventListener("error", () => {
player.hidden = true;
el.querySelector("#sfb").hidden = false;
});
const after = el.querySelector("#after");
if (done) {
after.innerHTML = successBlock(c.successTitle, c.successBody, c.evidence);
const b = btn(c.button);
b.onclick = () => { Sound.click(); go("falseTrail"); };
after.appendChild(row([b]));
} else {
const input = el.querySelector("#in");
const fb = el.querySelector("#fb");
takeFeedback(fb, id, input);
const submit = () => {
const v = word(input.value);
if (v === word(c.correct)) {
Sound.success(); addFact(c.evidence); markSolved(id); render(); return;
}
Sound.error(); addMistake(id);
let msg;
if (v === "ожидание" || v === "ожиданье") msg = c.wrongHints.ozhidanie;
else if (v === "сюжет") msg = c.wrongHints.syuzhet;
else msg = pick(c.wrong, mistakesFor(id) - 1);
setFeedback(id, msg, "bad shake", input.value);
render();
};
el.querySelector("#go").onclick = submit;
input.onkeydown = (e) => { if (e.key === "Enter") submit(); };
const rv = revealLink(id, () => { input.value = c.correct; input.focus(); });
if (rv) el.appendChild(rv);
}
return el;
}
/* ---- глава 5: ложный след ---- */
function vFalseTrail() {
const c = CONTENT.falseTrail;
const id = "falseTrail";
const done = !!state.solved[id];
const total = c.reportParts.filter((p) => p.tell).length;
const el = sheet(`
${head(c.chapter, c.title)}
<div class="alert">${paras(c.alertBody)}</div>
<p>${esc(c.intro)}</p>
<div class="report">
<div class="report-h">${esc(c.reportTitle)}</div>
<p class="report-body" id="rbody"></p>
<div class="report-seal">печать имеется</div>
</div>
${done ? "" : `<div class="task"><p>${esc(c.task)}</p>
<div class="counter">${esc(c.counter)}: <b id="cnt">${state.tellsFound.length}</b> / ${total}</div></div>`}
<div class="feedback" id="fb"></div>
<div id="after"></div>`);
const rb = el.querySelector("#rbody");
c.reportParts.forEach((p) => {
if (p.tell) {
const sp = document.createElement("button");
sp.className = "tell" + (state.tellsFound.includes(p.id) ? " got" : "");
sp.textContent = p.t;
sp.disabled = done || state.tellsFound.includes(p.id);
sp.onclick = () => {
if (state.tellsFound.includes(p.id)) return;
state.tellsFound.push(p.id);
save();
const r = c.tellReactions[p.id];
Sound.success();
if (state.tellsFound.length >= total) {
addFact(c.evidence); markSolved(id);
}
setFeedback(id, r.title + " — " + r.text, "good");
render();
};
rb.appendChild(sp);
} else {
const sp = document.createElement("span");
sp.className = "plain";
sp.textContent = p.t;
if (!done) {
sp.onclick = () => {
Sound.error(); addMistake(id);
setFeedback(id, c.plainReactions[p.id] || "Ничего примечательного.", "bad");
render();
};
}
rb.appendChild(sp);
}
});
const after = el.querySelector("#after");
if (done) {
after.innerHTML = successBlock(c.successTitle, c.successBody, c.evidence) +
`<p class="quote">${esc(c.kaverzaQuote)}</p>`;
const b = btn(c.button);
b.onclick = () => { Sound.click(); go("orderForm"); };
after.appendChild(row([b]));
} else {
takeFeedback(el.querySelector("#fb"), id);
const rv = revealLink(id, () => el.querySelectorAll(".tell").forEach((t) => t.classList.add("pulse")));
if (rv) el.appendChild(rv);
}
return el;
}
/* ---- глава 6: форма ордера ---- */
function vOrderForm() {
const c = CONTENT.order;
const id = "orderForm";
const el = sheet(`
${head(c.chapter, c.title)}
${paras(c.body)}
<div class="form" id="form"></div>
<div class="feedback" id="fb"></div>`);
const form = el.querySelector("#form");
c.fields.forEach((f) => {
const ok = !!state.orderFilled[f.id];
const prev = pendingOrder[f.id] || {};
const shown = ok ? FACTS[f.id] : (prev.value || "");
const g = document.createElement("div");
g.className = "fgroup" + (ok ? " ok" : "");
g.innerHTML = `
<label for="f_${f.id}">${esc(f.label)}</label>
<div class="fline">
<input id="f_${f.id}" type="text" placeholder="${esc(f.placeholder)}" autocomplete="off"
value="${esc(shown)}"${ok ? " disabled" : ""} />
<span class="fmark">${ok ? "✓" : ""}</span>
</div>
<div class="fsrc">источник: ${esc(f.hintSource)}</div>
<div class="ferr" id="e_${f.id}">${ok ? "" : esc(prev.error || "")}</div>`;
form.appendChild(g);
});
pendingOrder = {};
const submitAll = () => {
let anyWrong = false;
const carry = {};
c.fields.forEach((f) => {
if (state.orderFilled[f.id]) return;
const inp = el.querySelector("#f_" + f.id);
const v = inp.value.trim();
if (v === "") return;
if (matchesFact(f.id, v)) {
state.orderFilled[f.id] = true;
save();
} else {
anyWrong = true;
carry[f.id] = { value: inp.value, error: f.wrong };
}
});
const filled = c.fields.every((f) => state.orderFilled[f.id]);
if (filled) {
Sound.stamp(); markSolved(id); go("twist"); return;
}
pendingOrder = carry;
if (anyWrong) { Sound.error(); addMistake(id); }
else { Sound.click(); }
render();
if (!anyWrong) toast(c.partial);
};
const b = btn(c.button);
b.onclick = submitAll;
el.appendChild(row([b]));
el.querySelectorAll(".fgroup input").forEach((i) => {
i.onkeydown = (e) => { if (e.key === "Enter") submitAll(); };
});
const rv = revealLink(id, () => {
c.fields.forEach((f) => {
const inp = el.querySelector("#f_" + f.id);
if (inp && !inp.disabled) inp.value = FACTS[f.id];
});
});
if (rv) el.appendChild(rv);
return el;
}
/* ---- твист ---- */
function vTwist() {
const c = CONTENT.twist;
const el = document.createElement("div");
el.className = "twist";
const box = document.createElement("div");
box.className = "twist-lines";
el.appendChild(box);
c.lines.forEach((l, i) => {
const p = document.createElement("p");
p.textContent = l;
p.style.animationDelay = i * 1.05 + "s";
box.appendChild(p);
});
const t = c.lines.length * 1050;
setTimeout(() => Sound.stamp(), t + 200);
setTimeout(() => {
const st = document.createElement("div");
st.className = "bigstamp";
st.textContent = c.stamp;
el.appendChild(st);
const b = btn(c.button);
b.onclick = () => { Sound.warm(); go("congrats"); };
const r = row([b]); r.classList.add("center");
el.appendChild(r);
}, t + 450);
return el;
}
/* ---- поздравление ---- */
function vCongrats() {
const c = CONTENT.congrats;
const el = document.createElement("div");
el.className = "center-wrap";
el.innerHTML = `<div class="sheet warm-sheet"><h1>${esc(c.title)}</h1>${paras(c.body)}</div>`;
const b = btn(c.button);
b.onclick = () => { Sound.click(); go("orderScreen"); };
const r = row([b]); r.classList.add("center");
el.querySelector(".sheet").appendChild(r);
return el;
}
/* ---- ордер и QR ---- */
function vOrderScreen() {
const c = CONTENT.orderScreen;
const el = document.createElement("div");
el.className = "center-wrap";
el.innerHTML = `
<div class="sheet warm-sheet order-final">
<h1>${esc(c.title)}</h1>
<div class="record">
${c.rows.map(([k, v]) => `<div class="rec-row"><span>${esc(k)}</span><b>${esc(v)}</b></div>`).join("")}
</div>
<p class="center-t">${esc(c.qrNote)}</p>
<div class="qr" id="qr"></div>
<div class="record pickup">
<div class="rec-row"><span>Адрес выдачи</span><b>${esc(CONFIG.pvzAddress)}</b></div>
<div class="rec-row"><span>Кодовое слово</span><b>${esc(CONFIG.pickupCode)}</b></div>
<div class="rec-row"><span>Дело</span><b>№ ${esc(CONFIG.caseNumber)}</b></div>
</div>
<div class="claim">
<div class="claim-h">${esc(c.claimTitle)}</div>
<p class="claim-lead">${esc(c.claimLead)}</p>
<ol class="claim-steps">
${c.claimSteps.map((s) => `<li>${esc(s)}</li>`).join("")}
</ol>
<p class="claim-note">${esc(c.claimNote)}</p>
</div>
<p class="quote">${esc(c.joke)}</p>
<p class="note">${esc(c.reserveNote)}</p>
</div>`;
try {
/* Держим текст QR коротким: чем он длиннее, тем плотнее код и тем
хуже он ловится камерой. Подробная инструкция — на экране рядом. */
const text = CONFIG.pvzPageUrl ||
`${CONFIG.pvzAddress}. Кодовое слово: ${CONFIG.pickupCode}. Дело №${CONFIG.caseNumber}.`;
const q = qrcode(0, "M");
q.addData(text);
q.make();
el.querySelector("#qr").innerHTML = q.createSvgTag(6, 8);
} catch (e) {
el.querySelector("#qr").innerHTML = '<p class="dim">QR не сформирован. Используйте данные ниже.</p>';
}
const cp = btn(c.copyButton, "ghost");
cp.onclick = () => {
const t = `Адрес выдачи: ${CONFIG.pvzAddress}\n`
+ `Кодовое слово: ${CONFIG.pickupCode} (напишите его мне в ЛС — пришлю штрихкод)\n`
+ `Дело № ${CONFIG.caseNumber}`;
copyText(t);
};
const r = row([cp]); r.classList.add("center");
el.querySelector(".order-final").appendChild(r);
return el;
}
render();
})();