2PlayerGame/detective-game/public/client.js
UndecimoDia cb93544793 Fixes
2026-08-15 18:04:59 +03:00

866 lines
43 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.

// Спільне ядро клієнта: мережа, стан, бічна дошка, звинувачення, модалки.
"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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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; // згорнута секція «Приховані» на дні дошки
let evFilter = ""; // текстовий фільтр по картках доказів
let evTag = null; // активний чип-тег (null = всі)
// Дошка перемальовується на кожен стан — тож недодруковані нотатки треба зберігати,
// інакше broadcast напарника стирає те, що ти зараз друкуєш. Тримаємо чернетку в мапі
// (ключ = data-draft-key поля) і відновлюємо фокус/курсор після перемальовки.
const noteDraft = {};
// Але фокус повертаємо, ЛИШЕ якщо гравець справді щойно друкував. Раніше було
// «завжди, якщо колись друкував»: варто польовому один раз клацнути в нотатку,
// і кожен наступний broadcast знову садив фокус у той інпут — а обробник клавіш
// у field.js свідомо ігнорує натискання в INPUT/TEXTAREA. Клавіатура мовчала,
// візуально нічого не змінювалось.
const FOCUS_GRACE_MS = 3000;
let lastTypedAt = 0;
let restoringFocus = false; // наш власний .focus() не має продовжувати вікно
{
const side = $("#side");
const touch = () => {
if (!restoringFocus) lastTypedAt = Date.now();
};
// capture: фільтр дошки сам кличе renderSide() зі свого oninput, тож позначку
// часу треба поставити ДО того, як обробник цілі встигне перемалювати панель.
side.addEventListener("input", touch, true);
side.addEventListener("focusin", touch, true);
}
function wireDraft(input, key) {
input.dataset.draftKey = key;
if (noteDraft[key]) input.value = noteDraft[key];
input.addEventListener("input", () => {
noteDraft[key] = input.value;
});
}
// Які нотатки зараз відкриті на правку. Дошка перемальовується на кожен снапшот
// стану, тож цей прапорець мусить жити ПОЗА рендером — інакше будь-яка дія
// напарника згортала б поле просто під пальцями.
const noteEditing = new Set();
// Рядок нотатки на дошці: текст, автор, ✎ і видалення. Один рендер на два місця —
// спільні нотатки у вкладці й точкові на картці доказу, — щоб вони не розійшлися,
// як свого часу розійшлися дві копії пошуку по базі.
// compact — вигляд для картки доказу (рядок) проти вкладки «Нотатки» (картка);
// draftKey — власний ключ чернетки, НЕ спільний із полем нової нотатки;
// onSave(txt) / onDel() — хто саме шле повідомлення, вирішує викличне місце.
// Правка йде на місці: сервер міняє лише txt, тож нотатка лишається там, де була.
function noteRow(n, { compact, draftKey, onSave, onDel }) {
const author = n.by === "field" ? "польовий" : "аналітик";
const row = el("div", compact ? "small dim" : "card");
if (noteEditing.has(n.id)) {
const inp = el(compact ? "input" : "textarea", "small");
if (!compact) inp.rows = 2;
inp.style.cssText = "width:100%";
inp.value = noteDraft[draftKey] ?? n.txt;
wireDraft(inp, draftKey);
const finish = () => {
noteEditing.delete(n.id);
delete noteDraft[draftKey];
};
const save = () => {
const txt = inp.value.trim();
const changed = txt && txt !== n.txt;
finish();
if (changed) onSave(txt);
renderSide();
};
const cancel = () => {
finish();
renderSide();
};
inp.onkeydown = (e) => {
// У textarea Shift+Enter лишається переносом рядка — зберігає лише чистий Enter.
if (e.key === "Enter" && (compact || !e.shiftKey)) {
e.preventDefault();
save();
}
// Esc скасовує правку і далі не йде: глобальний обробник інакше просто
// зняв би фокус, і поле лишилось би відкритим без причини.
if (e.key === "Escape") {
e.stopPropagation();
cancel();
}
};
const ok = el("button", "small", "Зберегти");
ok.onclick = save;
const no = el("button", "small", "Скасувати");
no.onclick = cancel;
row.append(inp, ok, no);
return row;
}
if (compact)
row.append(el("span", "", `${esc(n.txt)} `), el("span", "tag", author));
else row.append(el("div", "", esc(n.txt)), el("div", "small dim", author));
const ed = el("button", "small", "✎");
ed.title = "редагувати нотатку";
ed.onclick = () => {
noteEditing.add(n.id);
renderSide();
// Перемальовка щойно замінила вузол — фокус ставимо на новий, інакше
// гравцеві довелося б клацати ще раз у поле, яке він сам щойно відкрив.
const f = $(`#sidebody [data-draft-key="${CSS.escape(draftKey)}"]`);
if (f) {
f.focus();
f.setSelectionRange(f.value.length, f.value.length);
}
};
const del = el("button", "small", compact ? "×" : "видалити");
del.title = "видалити нотатку";
del.onclick = onDel;
row.append(ed, del);
return row;
}
$("#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();
};
// Мітки. На картках доказів лишається ✓/✗/…, а на людях трьох станів замало —
// треба вести кілька версій одночасно. Тому для ключів 'c:' окрема палітра
// кольорів. Що саме означає кожен колір — вирішують гравці: жодних підписів,
// легенд і тултипів ми не нав'язуємо. «Сховати» лишається в обох: на ньому
// тримається секція «Приховані».
const EV_MARKS = [
["tick", "відмітити", null],
["cross", "викреслити", null],
["hide", "сховати", null],
];
// Порядок кольорів — це порядок кнопок. Замість синього тут фіолетовий: синій
// (#5b8dbf) і зелений (#5aa870) майже збігалися за яскравістю, і на кольоровій
// лівій грані рядка — смужці 4px — розрізнити їх було майже неможливо.
const PEOPLE_MARKS = [
["green", null, "var(--mk-green)"],
["red", null, "var(--mk-red)"],
["violet", null, "var(--mk-violet)"],
["amber", null, "var(--mk-amber)"],
["hide", "сховати", null],
];
const MARK_GLYPH = { tick: "✓", cross: "✗", hide: "…" };
// Сейви зі старих версій тримають на людях ✓/✗ — читаємо їх як два перші кольори,
// а колишній синій показуємо як фіолетовий, що прийшов йому на зміну.
const LEGACY_PERSON_MARK = { tick: "green", cross: "red", blue: "violet" };
const isPersonKey = (key) => String(key).startsWith("c:");
function markOf(key) {
const m = App.state.board.marks[key];
if (!m) return null;
return isPersonKey(key) ? LEGACY_PERSON_MARK[m] || m : m;
}
function markButtons(key) {
const cur = markOf(key);
const mk = el("div", "mk");
for (const [kind, title, colour] of isPersonKey(key)
? PEOPLE_MARKS
: EV_MARKS) {
const b = el(
"button",
(colour ? "swatch " : "") + (cur === kind ? "on" : ""),
colour
? `<span class="dot" style="background:${colour}"></span>`
: MARK_GLYPH[kind],
);
if (title) b.title = title; // кольори лишаємо без підписів — значення за гравцями
if (colour) b.style.color = colour;
b.onclick = (ev) => {
ev.stopPropagation();
App.send({ t: "mark", key, kind });
};
mk.append(b);
}
return mk;
}
function cardClasses(key) {
const m = markOf(key);
if (!m || m === "hide") return "";
return isPersonKey(key) ? " mk-" + m : " " + m;
}
// Свотч кольору для місць, де мітку лише показують (база польового).
function markSwatch(kind) {
const found = PEOPLE_MARKS.find(([k]) => k === kind);
if (!found || !found[2]) return "";
return `<span class="dot" style="background:${found[2]}"></span>`;
}
// Прокрутити дошку до доказу за 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;
const found = App.state.found;
const tagOf = (e) => e.tag || "запит";
body.append(
el(
"div",
"dim small",
"Закріплене 📌 піде в справу для прокурора. Знайдено: " + found.length,
),
);
// Пошук + чипи-теги. До фіналу на дошці 30+ карток одним стовпчиком, і знайти
// потрібну можна лише прокруткою. Чипи будуємо з тегів, що реально є на дошці, —
// так жоден доказ не лишається поза жодним зрізом.
const q = el("input", "small evfilter");
q.placeholder = "🔎 фільтр за текстом…";
q.value = evFilter;
q.dataset.draftKey = "evfilter"; // щоб фокус/курсор пережили перемальовку
q.oninput = () => {
evFilter = q.value;
renderSide();
};
body.append(q);
const chips = el("div", "chips");
const allTags = [...new Set(found.map(tagOf))].sort((a, b) =>
a.localeCompare(b, "uk"),
);
for (const t of [null, ...allTags]) {
const b = el("button", "chip" + (evTag === t ? " on" : ""), t || "всі");
b.onclick = () => {
evTag = evTag === t ? null : t;
renderSide();
};
chips.append(b);
}
body.append(chips);
// Фільтр застосовуємо ДО поділу на закріплені/решту, щоб 📌 не зникали з очей.
const low = evFilter.trim().toLowerCase();
const passes = (e) =>
(!evTag || tagOf(e) === evTag) &&
(!low ||
`${e.title} ${e.detail} ${e.from || ""}`.toLowerCase().includes(low));
const shown = found.filter(passes);
// Закріплені — зверху, від останнього запіненого до першого; далі решта доказів,
// від найновішого знайденого до найстарішого. (pins — у порядку закріплення.)
const pinnedCards = [...pins]
.reverse()
.map((id) => shown.find((e) => e.id === id))
.filter(Boolean);
const rest = [...shown].reverse().filter((e) => !pins.includes(e.id));
const buildCard = (e) => {
const key = "e:" + e.id;
const isPinned = pins.includes(e.id);
const c = el("div", "card" + cardClasses(key) + (isPinned ? " pinned" : ""));
c.dataset.ev = e.id; // якір для прокрутки-до-доказу
const t = el("div", "t");
t.append(el("b", "", esc(e.title)), el("span", "tag", esc(tagOf(e))));
const pin = el("button", "pinbtn" + (isPinned ? " on" : ""), "📌");
pin.title = isPinned ? "відкріпити" : "закріпити для прокурора";
pin.onclick = () => App.send({ t: "pin", evId: e.id });
t.append(pin);
c.append(t);
// Звідки знахідка — лише для фізичних: решта прийшла з термінала чи розмови.
if (e.from) c.append(el("div", "small dim orig", esc(e.from)));
c.append(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)
c.append(
noteRow(n, {
compact: true,
draftKey: "edit:" + n.id,
onSave: (txt) =>
App.send({ t: "enote_edit", evId: e.id, id: n.id, txt }),
onDel: () => App.send({ t: "enote_del", evId: e.id, id: n.id }),
}),
);
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 = [];
const visible = (arr) =>
arr.filter((e) => {
if (marks["e:" + e.id] === "hide") {
hidden.push(e);
return false;
}
return true;
});
const pinnedVisible = visible(pinnedCards);
const restVisible = visible(rest);
for (const e of pinnedVisible) body.append(buildCard(e));
// Межа теки: видно, де закінчується те, що піде прокуророві.
if (pinnedVisible.length && restVisible.length)
body.append(el("div", "divider small dim", "— решта доказів —"));
for (const e of restVisible) body.append(buildCard(e));
if (!pinnedVisible.length && !restVisible.length && !hidden.length)
body.append(
el(
"div",
"dim small",
found.length
? "За цим фільтром нічого немає."
: "Дошка порожня — знахідки з'являться тут.",
),
);
// Приховані — окрема згорнута секція на дні, кожен доказ окремо (не «одна кнопка = все»)
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())
body.append(
noteRow(n, {
compact: false,
draftKey: "edit:" + n.id,
onSave: (txt) => App.send({ t: "note_edit", id: n.id, txt }),
onDel: () => App.send({ t: "note_del", id: n.id }),
}),
);
}
// Повернути фокус і курсор у поле, де друкували до перемальовки, — але лише
// якщо друкували щойно (див. FOCUS_GRACE_MS): інакше поле перехоплює клавіатуру
// польового назавжди.
if (activeKey && Date.now() - lastTypedAt < FOCUS_GRACE_MS) {
const again = body.querySelector(`[data-draft-key="${activeKey}"]`);
if (again) {
restoringFocus = true;
again.focus();
if (caret != null)
try {
again.setSelectionRange(caret, caret);
} catch {}
restoringFocus = false;
}
}
}
// ---------- Досьє ----------
function showDossier(c) {
const dnaCells = c.dna
.map(
(v) =>
`<span style="display:inline-block;width:10px;height:${6 + v * 2}px;background:var(--blue);margin-right:2px;vertical-align:bottom"></span>`,
)
.join("");
const m = openModal(`
<h3>${esc(c.name)}</h3>
<table>
<tr><th>Вік / стать</th><td>${c.age} / ${c.sex === "ч" ? "чол." : "жін."} (${c.birthYear} р.н.)</td></tr>
<tr><th>Адреса</th><td>${esc(c.home)}</td></tr>
<tr><th>Робота</th><td>${esc(c.job)}${c.workplace ? " — " + esc(bName(c.workplace)) : ""}</td></tr>
<tr><th>Телефон</th><td>${esc(c.phone)}</td></tr>
<tr><th>Зріст / взуття</th><td>${c.height} см / ${c.shoe} розмір</td></tr>
<tr><th>Кров / відбитки</th><td>${esc(c.blood)} / ${esc(c.printType)}</td></tr>
<tr><th>Верхній одяг</th><td>${esc(c.coat)}</td></tr>
<tr><th>Звички</th><td>${c.smoker ? "курить; " : ""}${c.dog ? "собака " + esc(c.dog) + "; " : ""}${c.tattoo ? "тату: " + esc(c.tattoo) : ""}</td></tr>
<tr><th>ДНК-профіль</th><td>${dnaCells}</td></tr>
</table>`,
// Ключ із id: досьє іншої людини — це інша модалка, а не дубль цієї.
{ key: "dossier:" + c.id },
);
if (App.state && App.state.warrants.includes(c.id))
m.inner.append(el("div", "small", "📜 Ордер на обшук житла — отримано"));
m.inner.append(logSection(c.id));
}
// Журнал допитів із мітками (✓ виділити / ✗ закреслити) на кожному записі. Мітки
// спільні (board.marks, ключ 'q:'+id) — бачать обидві ролі. Модалка статична, тож
// клік дає оптимістичний відгук одразу; збережений стан підтягнеться при повторному відкритті.
function logSection(cid) {
const wrap = el("div");
const log = (App.state && App.state.log && App.state.log[cid]) || [];
if (!log.length) return wrap;
wrap.append(el("div", "syshead", "Журнал допитів"));
for (const e of log.slice(-12)) {
const key = "q:" + e.id;
let cur = App.state.board.marks[key];
const row = el("div", "small logrow");
row.style.cssText = "padding-left:7px;border-left:2px solid transparent;margin:5px 0";
const apply = (mk) => {
row.style.textDecoration = mk === "cross" ? "line-through" : "none";
row.style.opacity = mk === "cross" ? ".5" : "1";
row.style.borderLeftColor = mk === "tick" ? "var(--green,#5aa870)" : "transparent";
};
row.append(
el("div", "", `<span class="dim">${esc(e.q)}</span><br>${esc(e.a)}`),
);
const ctrl = el("div", "mk");
for (const [kind, label] of [
["tick", "✓"],
["cross", "✗"],
]) {
const b = el("button", cur === kind ? "on" : "", label);
b.title = kind === "tick" ? "виділити" : "закреслити";
b.onclick = () => {
App.send({ t: "mark", key, kind });
cur = cur === kind ? undefined : kind; // сервер toggl-ить так само
apply(cur);
for (const bb of ctrl.children) bb.classList.remove("on");
if (cur) b.classList.add("on");
};
ctrl.append(b);
}
row.append(ctrl);
apply(cur);
wrap.append(row);
}
return wrap;
}
function bName(bid) {
const b = App.init.district.buildings.find((x) => x.id === bid);
return b ? b.name : bid;
}
// Пошук за іменем — ОДИН на обидві ролі. Кожне слово запиту має бути початком
// якогось слова в імені; регістр не важить. Раніше це були дві копії (база
// аналітика і база польового), і аналітикова загубила .toLowerCase() з обох
// боків — «поліна» не знаходило «Поліна». Сервер шукає банк і реєстри так само.
function matchName(citizens, query) {
const words = String(query || "")
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
if (!words.length) return [];
return citizens.filter((c) => {
const nameWords = c.name.toLowerCase().split(/\s+/);
return words.every((w) => nameWords.some((nw) => nw.startsWith(w)));
});
}
// Enter у полі = натиснути кнопку поруч. Поля пошуку лежать у <div class="filters">,
// а не у <form>, тож неявного сабміту браузер не дає — доводиться вішати руками.
// Хелпер спільний, бо це вже четверте місце з тим самим рядком, а база мешканців
// існує у двох копіях (аналітик і польовий), які одного разу вже розійшлися.
const submitOn = (input, button) => {
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
button.click();
}
});
};
// ---------- Модалки ----------
// Кроки більше не заміщають один одного: батьківська модалка лишається під
// поточною, тож скасування picker'а повертає до розмови, а не в порожнечу.
// opts.back — явне «← Назад» там, де попередній крок треба перезібрати
// (порожня тека ордера, шортліст лабораторії); opts.onClose — прибирання за собою.
//
// opts.key робить відкриття ІДЕМПОТЕНТНИМ: повторний клік по тій самій кнопці
// замінює наявний шар, а не кладе ще один зверху. Раніше N кліків по «Справа»
// давали N однакових модалок стосом — і оскільки .modal це fixed/inset:0, вони
// накладались ідеально й читались як панель, що не закривається.
// Ключ — це КОНКРЕТНА ціль ('unit:C8', 'talk:c23'), а не тип панелі: інакше
// перехід «квартира A2 → квартира A3» затирав би сам себе. Кроки одного ланцюга
// (відділок → ордер → вибір підстави) ключів не мають зовсім — там стос
// навмисний, на ньому тримається «← Назад».
function openModal(html, opts = {}) {
// Заміна, а не закриття: onClose тут не кличемо свідомо — у деяких місцях він
// прибирає БАТЬКІВСЬКУ модалку (шортліст лабораторії), і повторний клік
// рознiс би ланцюг, який гравець не чіпав.
if (opts.key) {
const old = document.querySelector(`.modal[data-key="${CSS.escape(opts.key)}"]`);
if (old) old.remove();
}
const wrap = el("div", "modal");
if (opts.key) wrap.dataset.key = opts.key;
// Позначка для Esc: locked-модалки (мінігра вилучення) мають власний вихід
// «Відкласти» — клавіатурою їх не обходять.
if (opts.locked) wrap.dataset.locked = "1";
const inner = el("div", "", html);
wrap.append(inner);
const close = () => {
wrap.remove();
if (opts.onClose) opts.onClose();
};
const goBack = opts.back
? () => {
wrap.remove();
opts.back();
}
: null;
if (!opts.locked) {
wrap.onclick = (e) => {
if (e.target === wrap) close();
};
const foot = el("div", "modalfoot");
if (goBack) {
const bk = el("button", "small", "← Назад");
bk.onclick = goBack;
foot.append(bk);
}
const x = el("button", "small", "Закрити");
x.onclick = close;
foot.append(x);
inner.append(foot);
// Автори модалок дописують вміст уже після openModal (журнал допитів, підказки).
// Мікрозадача переносить кнопки в кінець, коли всі синхронні append'и минули.
queueMicrotask(() => inner.append(foot));
}
// Хендли на самому вузлі: глобальний Esc знаходить модалку в DOM і мусить
// закрити її тим самим шляхом, що й кнопка, — інакше загубиться onClose.
wrap.__close = close;
wrap.__back = goBack;
document.body.append(wrap);
return { wrap, inner, close };
}
// Прибрати модалку за ключем ззовні: стан прийшов — чернетку звинувачення
// відкликано, справу закрито. Заміняє ручні змінні-сингтони, які раніше
// тримали посилання на вузол лише заради цього.
const closeModalByKey = (key) => {
const m = document.querySelector(`.modal[data-key="${CSS.escape(key)}"]`);
if (m) m.remove();
};
// Esc закриває ВЕРХНЮ модалку — саме верхню, бо стос навмисний: жест, який
// здуває весь ланцюг, зруйнував би навігацію «← Назад» так само, як її зруйнував
// би глобальний сингтон замість ключів.
addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
// Перший Esc із поля вводу лише звільняє поле — клавіатура дорожча за модалку:
// у польового це аварійне повернення керування району (той самий жест у
// field.js), а для аналітика, де того обробника немає, знімаємо фокус тут.
// Модалку закриє наступний Esc — уже «чистий».
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) {
t.blur();
return;
}
const open = document.querySelectorAll(".modal:not([data-locked])");
const top = open[open.length - 1];
if (!top) return;
e.preventDefault();
// Є «← Назад» — Esc робить крок назад, а не викидає з усього ланцюга.
(top.__back || top.__close)();
});
function showIntro() {
const i = App.init.intro;
const fieldHow = `
<div class="syshead">Польовий детектив</div>
<ul class="small">
<li>WASD/стрілки — рух, E — увійти. Почніть із <b>місця злочину</b> (позначене ✖): обшукуйте його точку, поки знахідки не скінчаться. Не кожен слід — вбивці: місце публічне.</li>
<li><b>Розмови:</b> у кожної людини — теми у справі, «Де ви були о …?» (повзунок часу), «Про людину…», «Показати доказ…». Люди брешуть і помиляються. Збрехав? Перевірте його місце камерами і <b>спитайте вдруге</b> — розколеться і скаже, чому брехав. Вірити чи ні — вирішуєте ви.</li>
<li><b>Ордер</b> — особисто у відділку: оберіть людину і доказ із закріплених 📌, який прямо на неї вказує.</li>
<li><b>Пристрої:</b> в житлі під ордером трапляються телефони й ноутбуки. Вилучайте — самі ви їх не відкриєте, вони йдуть аналітику в лабораторію на злам.</li>
<li>З ордером можна <b>оглянути людину особисто</b> — реєстр місцями застарілий, очі надійніші. Результат ніде не записується: занотовуйте самі.</li>
<li>Червоні ✖ на мапі — мітки напарника «перевір тут».</li>
</ul>`;
const analystHow = `
<div class="syshead">Аналітик</div>
<ul class="small">
<li><b>База мешканців:</b> фільтри вводите руками — значення беріть із доказів. Мітьте людей кольорами — чотири кольори плюс «…» (сховати); що означає кожен, вирішуєте ви з напарником, гра не підказує. Реєстр місцями застарілий: одяг/взуття надійніше звіряти очним оглядом польового.</li>
<li><b>Системи:</b> оператор (власник, дзвінки, тріангуляція «де зараз»), банк, реєстри, криміналістика (експертиза + архів), камери. Дані для запитів шукайте в доказах. <b>Кожен результат лягає на дошку</b> — навіть дрібниця; що з того справа, вирішуєте ви.</li>
<li><b>Журнал дзвінків</b> — це покази, які не брешуть: хто з ким говорив і о котрій у вечір злочину. Звіряйте з тим, що люди кажуть на допиті. Порожній вечір теж промовистий: апарат могли вимкнути.</li>
<li><b>Вилучені пристрої</b> потрапляють у лабораторію заблокованими: PIN — це зазвичай рік народження власника (є в реєстрі), пароль ноутбука — або щось особисте латиницею, або словниковий перебір. Усередині буває історія браузера й чати — а буває порожньо.</li>
<li><b>Камери:</b> час злочину ±12 хв — головні кадри; будь-який вечірній час — хто взагалі потрапляв у кадри. Звіряйте з тим, що люди <i>кажуть</i> («Зведення алібі»). Брехня вбивці, спіймана камерою, — повноцінний доказ для прокурора.</li>
<li><b>Лабораторія:</b> ДНК і відбитки порівнюєте вручну — до 6 профілів за раз. Шифри теж тут. Увага: ДНК каже лише, ЧИЯ вона, — не коли й чому лишена. На місці буває ДНК людей, що не мають стосунку до злочину: збіг — привід для допиту, а не вирок.</li>
<li>Клік по порожньому місцю мапи — мітка для напарника.</li>
</ul>`;
openModal(
`<h3>${esc(i.title)}: ${esc(i.scene)}, ~${esc(i.time)}</h3><p>${esc(i.text)}</p>
<p class="dim small">Роль: ${App.role === "field" ? "польовий детектив — район, обшуки, розмови" : "аналітик — бази, камери, лабораторія"}.</p>
${App.role === "field" ? fieldHow : analystHow}
<div class="syshead">Фінал</div>
<p class="small">Одне звинувачення на двох: ХТО (ім'я з клавіатури) / ЯК чи ДЕ / ЧОМУ, підписи обох. Прокурор дивиться лише на закріплені 📌 докази: потрібне підтвердження особи (ДНК, відбиток або зламане алібі), мотив і спосіб. Нотатки — спільні: загальні у вкладці, точкові — прямо на картці доказу.</p>`,
{ key: "intro" },
);
}
$("#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(`
<h3>${isK ? "Рапорт на штурм — один шанс" : "Звинувачення — один шанс"}</h3>
<p class="dim small">Помилка в будь-якому полі — і ${isK ? "штурм піде не туди" : "вас звільнять"}. Прокурор дивиться лише на закріплені 📌 докази.</p>
<p>ХТО (повне ім'я, з клавіатури):<br><input id="ac-who" style="width:100%" placeholder="Ім'я Прізвище"></p>
<p>${isK ? "ДЕ утримують:" : "ЯК / ЧИМ:"}<br><select id="ac-how" style="width:100%">${o.methods.map((x) => `<option>${esc(x)}</option>`).join("")}</select></p>
<p>ЧОМУ:<br><select id="ac-why" style="width:100%">${o.motives.map((x) => `<option>${esc(x)}</option>`).join("")}</select></p>
<p>Закріплено доказів: <b>${st.board.pins.length}</b></p>
<button class="danger" id="ac-go">Подати на підпис напарнику</button>`,
// Спільний ключ із модалкою статусу: щойно з'явиться чернетка, форма
// сама поступиться їй місцем, а не ляже під нею другим шаром.
{ key: "accuse" },
);
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();
};
};
function showAccuseStatus() {
const d = App.state.accuseDraft;
const m = openModal(`
<h3>${App.init.caseType === "kidnap" ? "Рапорт на штурм — на підписі" : "Звинувачення на підписі"}</h3>
<table>
<tr><th>ХТО</th><td>${esc(d.who)}</td></tr>
<tr><th>${App.init.caseType === "kidnap" ? "ДЕ" : "ЯК"}</th><td>${esc(d.how)}</td></tr>
<tr><th>ЧОМУ</th><td>${esc(d.why)}</td></tr>
</table>
<p class="small">Підписано: ${d.signed} з ${d.need}</p>
<p class="dim small">Потрібен підпис КОЖНОГО гравця — і вироку вже не відкликати.</p>
<button class="danger" id="ac-sign">Підписати</button>
<button id="ac-cancel">Відкликати</button>`,
{ key: "accuse" },
);
m.inner.querySelector("#ac-sign").onclick = () =>
App.send({ t: "accuse_confirm" });
m.inner.querySelector("#ac-cancel").onclick = () =>
App.send({ t: "accuse_cancel" });
}
// Статус перезбирається на кожен снапшот (змінилася кількість підписів), тож
// стару модалку прибираємо явно: ключ сам по собі замінив би її лише в момент
// відкриття нової, а тут буває й відкликання — коли нову не відкривають зовсім.
App.on("state", () => {
closeModalByKey("accuse");
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);
closeModalByKey("accuse");
// endShown лишається: він гасить не подвійний клік, а повторний показ фіналу
// на кожен наступний снапшот — ключ від цього не рятує, бо модалку щоразу
// відкривали б наново.
openModal(
`
<div id="endscreen">
<h3>${e.solved ? (App.init.caseType === "kidnap" ? "ЛЮДИНУ ВРЯТОВАНО" : "СПРАВУ РОЗКРИТО") : "ВАС ЗВІЛЬНЕНО"}</h3>
${e.hint ? `<p class="dim">Де ви схибили: ${esc(e.hint)}</p>` : ""}
<p>${esc(e.recap)}</p>
<p class="dim small">${e.solved ? "Новий код сесії — цілком нова справа." : "Справжню картину відновив ваш наступник. Новий код сесії — нова спроба в новій справі."}</p>
</div>`,
{ key: "end" },
);
}