// Спільне ядро клієнта: мережа, стан, бічна дошка, звинувачення, модалки. "use strict"; const App = (window.App = { ws: null, role: null, session: null, init: null, // {district, citizens, intro, options...} state: null, // останній снапшот від сервера handlers: {}, on(t, fn) { (this.handlers[t] ??= []).push(fn); }, send(m) { if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify(m)); }, byId(cid) { return this.init.citizens.find((c) => c.id === cid); }, }); const $ = (sel) => document.querySelector(sel); const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }; const esc = (s) => String(s).replace( /[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], ); function toast(txt) { const t = el("div", "toast", esc(txt)); $("#toasts").append(t); setTimeout(() => t.remove(), 4200); } // Підказки приховані за замовчуванням — спершу думаємо самі. function hintBlock(text) { const wrap = el("div", "hintwrap"); if (!text) return wrap; const btn = el("button", "small", "💡 Підказка"); const body = el("div", "small dim"); body.style.cssText = "display:none;margin-top:4px"; body.textContent = text; btn.onclick = () => { body.style.display = body.style.display === "none" ? "block" : "none"; }; wrap.append(btn, body); return wrap; } // ---------- Мережа ---------- function connect(code, role) { App.noReconnect = false; const ws = (App.ws = new WebSocket(`ws://${location.host}`)); ws.onopen = () => ws.send( JSON.stringify({ t: "join", session: code, role, pos: App.lastFieldPos }), ); ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.t === "reset") { toast("Сейв видалено — справа починається з нуля."); setTimeout(() => location.reload(), 1400); return; } if (m.t === "joinerr") { // сесія заповнена по цій ролі — лишаємось у лобі, без автоперепідключення App.noReconnect = true; toast(m.txt); ws.close(); return; } if (m.t === "pos") App.fieldPositions = m.field || []; if (m.t === "toast") { if (m.txt.includes("підписав ордер") && window.Snd) Snd.stamp(); return toast(m.txt); } if (m.t === "init") { App.init = m; App.role = m.role; App.myNum = m.num; document.body.classList.add("role-" + m.role); $("#lobby").style.display = "none"; $("#app").classList.add("on"); $("#b-session").textContent = "справа: " + App.session; for (const fn of App.handlers.init) fn(m); showIntro(); return; } if (m.t === "state") { App.state = m; $("#b-field").classList.toggle("on", m.online.field > 0); $("#b-analyst").classList.toggle("on", m.online.analyst > 0); $("#b-field").textContent = "поле" + (m.online.field > 1 ? " ×" + m.online.field : ""); $("#b-analyst").textContent = "аналітик" + (m.online.analyst > 1 ? " ×" + m.online.analyst : ""); renderSide(); renderEnd(); for (const fn of App.handlers.state) fn(m); return; } for (const fn of App.handlers[m.t] || []) fn(m); }; ws.onclose = () => { if (App.noReconnect) return; toast("Зв'язок втрачено, перепідключення…"); setTimeout(() => connect(code, role), 1500); }; } // ---------- Лобі ---------- for (const b of document.querySelectorAll(".rolebtn")) { b.onclick = () => { const code = ($("#code").value.trim() || "DEFAULT").toUpperCase(); App.session = code; localStorage.setItem("ch3", JSON.stringify({ code, role: b.dataset.role })); if (window.Snd) Snd.start(b.dataset.role); connect(code, b.dataset.role); }; } try { const prev = JSON.parse(localStorage.getItem("ch3")); if (prev && prev.code) $("#code").value = prev.code; } catch {} // ---------- Бічна дошка ---------- let sideTab = "ev"; let showHidden = false; // згорнута секція «Приховані» на дні дошки // Дошка перемальовується на кожен стан — тож недодруковані нотатки треба зберігати, // інакше broadcast напарника стирає те, що ти зараз друкуєш. Тримаємо чернетку в мапі // (ключ = data-draft-key поля) і відновлюємо фокус/курсор після перемальовки. const noteDraft = {}; function wireDraft(input, key) { input.dataset.draftKey = key; if (noteDraft[key]) input.value = noteDraft[key]; input.addEventListener("input", () => { noteDraft[key] = input.value; }); } $("#sidetabs").onclick = (e) => { const b = e.target.closest("button"); if (!b) return; sideTab = b.dataset.tab; for (const x of $("#sidetabs").children) x.classList.toggle("on", x === b); renderSide(); }; function markButtons(key) { const marks = App.state.board.marks; const mk = el("div", "mk"); for (const [kind, label] of [ ["tick", "✓"], ["cross", "✗"], ["hide", "…"], ]) { const b = el("button", marks[key] === kind ? "on" : "", label); b.title = { tick: "відмітити", cross: "викреслити", hide: "сховати", }[kind]; b.onclick = (ev) => { ev.stopPropagation(); App.send({ t: "mark", key, kind }); }; mk.append(b); } return mk; } function cardClasses(key) { const m = App.state.board.marks[key]; return m && m !== "hide" ? " " + m : ""; } // Прокрутити дошку до доказу за id і коротко підсвітити. Якщо він у згорнутій // секції «Приховані» — спершу розгортаємо її й перемальовуємо. function jumpToEvidence(id) { const find = () => document.querySelector(`#sidebody [data-ev="${CSS.escape(id)}"]`); let card = find(); if (!card && !showHidden) { showHidden = true; renderSide(); card = find(); } if (!card) return; card.scrollIntoView({ behavior: "smooth", block: "center" }); card.classList.add("flash"); setTimeout(() => card.classList.remove("flash"), 1300); } function renderSide() { if (!App.state) return; const body = $("#sidebody"); // запам'ятати, яке поле нотатки в фокусі, щоб повернути його після перемальовки const act = document.activeElement; const activeKey = act && act.dataset ? act.dataset.draftKey : null; const caret = activeKey ? act.selectionStart : null; body.innerHTML = ""; const marks = App.state.board.marks; if (sideTab === "ev") { const pins = App.state.board.pins; body.append( el( "div", "dim small", "Закріплене 📌 піде в справу для прокурора. Знайдено: " + App.state.found.length, ), ); const found = App.state.found; // Закріплені — зверху, від останнього запіненого до першого; далі решта доказів, // від найновішого знайденого до найстарішого. (pins — у порядку закріплення.) const pinnedCards = [...pins] .reverse() .map((id) => found.find((e) => e.id === id)) .filter(Boolean); const rest = [...found].reverse().filter((e) => !pins.includes(e.id)); const ordered = [...pinnedCards, ...rest]; const buildCard = (e) => { const key = "e:" + e.id; const c = el("div", "card" + cardClasses(key)); c.dataset.ev = e.id; // якір для прокрутки-до-доказу const t = el("div", "t"); t.append(el("b", "", esc(e.title))); const pin = el( "button", "pinbtn" + (pins.includes(e.id) ? " on" : ""), "📌", ); pin.onclick = () => App.send({ t: "pin", evId: e.id }); t.append(pin); c.append(t, el("div", "small", esc(e.detail))); // Експертиза називає доказ, який розбирала, і веде до нього (прокрутка + підсвітка). if (e.ref) { const src = found.find((x) => x.id === e.ref); if (src) { const link = el("button", "evref small", `↳ аналіз доказу: «${esc(src.title)}»`); link.onclick = () => jumpToEvidence(e.ref); c.append(link); } } c.append(markButtons(key)); const notes = (App.state.board.enotes || {})[e.id] || []; for (const n of notes) { const row = el( "div", "small dim", `✎ ${esc(n.txt)} ${n.by === "field" ? "польовий" : "аналітик"}`, ); const d = el("button", "small", "×"); d.title = "видалити нотатку"; d.onclick = () => App.send({ t: "enote_del", evId: e.id, id: n.id }); row.append(d); c.append(row); } const ni = el("input", "small"); ni.placeholder = "нотатка до доказу…"; ni.style.cssText = "width:100%;margin-top:4px"; wireDraft(ni, "e:" + e.id); ni.onkeydown = (ev) => { if (ev.key === "Enter" && ni.value.trim()) { App.send({ t: "enote", evId: e.id, txt: ni.value }); ni.value = ""; delete noteDraft["e:" + e.id]; } }; c.append(ni); return c; }; const hidden = []; for (const e of ordered) { if (marks["e:" + e.id] === "hide") { hidden.push(e); continue; } body.append(buildCard(e)); } // Приховані — окрема згорнута секція на дні, кожен доказ окремо (не «одна кнопка = все») if (hidden.length) { const head = el( "button", "small", `${showHidden ? "▾" : "▸"} Приховані (${hidden.length})`, ); head.style.cssText = "width:100%;margin-top:10px;text-align:left"; head.onclick = () => { showHidden = !showHidden; renderSide(); }; body.append(head); if (showHidden) for (const e of hidden) body.append(buildCard(e)); } } if (sideTab === "notes") { const ta = el("textarea", "", ""); ta.rows = 2; ta.style.width = "100%"; ta.placeholder = "спільна нотатка…"; wireDraft(ta, "shared"); const add = el("button", "primary", "Додати"); add.onclick = () => { if (ta.value.trim()) { App.send({ t: "note", txt: ta.value }); ta.value = ""; delete noteDraft["shared"]; } }; body.append(ta, add); for (const n of [...App.state.board.notes].reverse()) { const c = el("div", "card"); c.append( el("div", "", esc(n.txt)), el("div", "small dim", n.by === "field" ? "польовий" : "аналітик"), ); const d = el("button", "small", "видалити"); d.onclick = () => App.send({ t: "note_del", id: n.id }); c.append(d); body.append(c); } } // повернути фокус і курсор у поле, де друкували до перемальовки if (activeKey) { const again = body.querySelector(`[data-draft-key="${activeKey}"]`); if (again) { again.focus(); if (caret != null) try { again.setSelectionRange(caret, caret); } catch {} } } } // ---------- Досьє ---------- function showDossier(c) { const dnaCells = c.dna .map( (v) => ``, ) .join(""); const m = openModal(`
| Вік / стать | ${c.age} / ${c.sex === "ч" ? "чол." : "жін."} (${c.birthYear} р.н.) |
|---|---|
| Адреса | ${esc(c.home)} |
| Робота | ${esc(c.job)}${c.workplace ? " — " + esc(bName(c.workplace)) : ""} |
| Телефон | ${esc(c.phone)} |
| Зріст / взуття | ${c.height} см / ${c.shoe} розмір |
| Кров / відбитки | ${esc(c.blood)} / ${esc(c.printType)} |
| Верхній одяг | ${esc(c.coat)} |
| Звички | ${c.smoker ? "курить; " : ""}${c.dog ? "собака " + esc(c.dog) + "; " : ""}${c.tattoo ? "тату: " + esc(c.tattoo) : ""} |
| ДНК-профіль | ${dnaCells} |
${esc(i.text)}
Роль: ${App.role === "field" ? "польовий детектив — район, обшуки, розмови" : "аналітик — бази, камери, лабораторія"}.
${App.role === "field" ? fieldHow : analystHow}Одне звинувачення на двох: ХТО (ім'я з клавіатури) / ЯК чи ДЕ / ЧОМУ, підписи обох. Прокурор дивиться лише на закріплені 📌 докази: потрібне підтвердження особи (ДНК, відбиток або зламане алібі), мотив і спосіб. Нотатки — спільні: загальні у вкладці, точкові — прямо на картці доказу.
`); } $("#introbtn").onclick = showIntro; const sndBtn = $("#sndbtn"); if (sndBtn && window.Snd) { sndBtn.textContent = Snd.muted ? "🔇" : "🔊"; sndBtn.onclick = () => (sndBtn.textContent = Snd.toggle() ? "🔇" : "🔊"); } // ---------- Звинувачення ---------- $("#accusebtn").onclick = () => { const st = App.state; if (!st || st.ended) return; if (st.accuseDraft) return showAccuseStatus(); const o = App.init.options; const isK = App.init.caseType === "kidnap"; const m = openModal(`Помилка в будь-якому полі — і ${isK ? "штурм піде не туди" : "вас звільнять"}. Прокурор дивиться лише на закріплені 📌 докази.
ХТО (повне ім'я, з клавіатури):
${isK ? "ДЕ утримують:" : "ЯК / ЧИМ:"}
ЧОМУ:
Закріплено доказів: ${st.board.pins.length}
`); m.inner.querySelector("#ac-go").onclick = () => { const who = m.inner.querySelector("#ac-who").value.trim(); if (!who) return toast("Впишіть ім'я."); App.send({ t: "accuse_draft", who, how: m.inner.querySelector("#ac-how").value, why: m.inner.querySelector("#ac-why").value, }); m.wrap.remove(); }; }; let accuseModal = null; function showAccuseStatus() { const d = App.state.accuseDraft; if (accuseModal) accuseModal.wrap.remove(); accuseModal = openModal(`| ХТО | ${esc(d.who)} |
|---|---|
| ${App.init.caseType === "kidnap" ? "ДЕ" : "ЯК"} | ${esc(d.how)} |
| ЧОМУ | ${esc(d.why)} |
Підписано: ${d.signed} з ${d.need}
Потрібен підпис КОЖНОГО гравця — і вироку вже не відкликати.
`); accuseModal.inner.querySelector("#ac-sign").onclick = () => App.send({ t: "accuse_confirm" }); accuseModal.inner.querySelector("#ac-cancel").onclick = () => App.send({ t: "accuse_cancel" }); } App.on("state", () => { if (accuseModal) { accuseModal.wrap.remove(); accuseModal = null; } if (App.state.accuseDraft && !App.state.ended) showAccuseStatus(); }); // ---------- Фінал ---------- let endShown = false; function renderEnd() { const e = App.state.ended; if (!e || endShown) return; endShown = true; if (window.Snd) Snd.sting(!!e.solved); if (accuseModal) { accuseModal.wrap.remove(); accuseModal = null; } openModal( `Де ви схибили: ${esc(e.hint)}
` : ""}${esc(e.recap)}
${e.solved ? "Новий код сесії — цілком нова справа." : "Справжню картину відновив ваш наступник. Новий код сесії — нова спроба в новій справі."}