2PlayerGame/detective-game/public/client.js

867 lines
43 KiB
JavaScript
Raw Normal View History

2026-07-23 17:03:26 +03:00
// Спільне ядро клієнта: мережа, стан, бічна дошка, звинувачення, модалки.
"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; // згорнута секція «Приховані» на дні дошки
2026-08-15 15:37:54 +03:00
let evFilter = ""; // текстовий фільтр по картках доказів
let evTag = null; // активний чип-тег (null = всі)
2026-07-23 17:03:26 +03:00
// Дошка перемальовується на кожен стан — тож недодруковані нотатки треба зберігати,
// інакше broadcast напарника стирає те, що ти зараз друкуєш. Тримаємо чернетку в мапі
// (ключ = data-draft-key поля) і відновлюємо фокус/курсор після перемальовки.
const noteDraft = {};
2026-08-15 15:37:54 +03:00
// Але фокус повертаємо, ЛИШЕ якщо гравець справді щойно друкував. Раніше було
// «завжди, якщо колись друкував»: варто польовому один раз клацнути в нотатку,
// і кожен наступний 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);
}
2026-07-23 17:03:26 +03:00
function wireDraft(input, key) {
input.dataset.draftKey = key;
if (noteDraft[key]) input.value = noteDraft[key];
input.addEventListener("input", () => {
noteDraft[key] = input.value;
});
}
2026-08-15 18:04:59 +03:00
// Які нотатки зараз відкриті на правку. Дошка перемальовується на кожен снапшот
// стану, тож цей прапорець мусить жити ПОЗА рендером — інакше будь-яка дія
// напарника згортала б поле просто під пальцями.
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;
}
2026-07-23 17:03:26 +03:00
$("#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();
};
2026-08-15 15:37:54 +03:00
// Мітки. На картках доказів лишається ✓/✗/…, а на людях трьох станів замало —
// треба вести кілька версій одночасно. Тому для ключів 'c:' окрема палітра
// кольорів. Що саме означає кожен колір — вирішують гравці: жодних підписів,
// легенд і тултипів ми не нав'язуємо. «Сховати» лишається в обох: на ньому
// тримається секція «Приховані».
const EV_MARKS = [
["tick", "відмітити", null],
["cross", "викреслити", null],
["hide", "сховати", null],
];
2026-08-15 18:04:59 +03:00
// Порядок кольорів — це порядок кнопок. Замість синього тут фіолетовий: синій
// (#5b8dbf) і зелений (#5aa870) майже збігалися за яскравістю, і на кольоровій
// лівій грані рядка — смужці 4px — розрізнити їх було майже неможливо.
2026-08-15 15:37:54 +03:00
const PEOPLE_MARKS = [
["green", null, "var(--mk-green)"],
["red", null, "var(--mk-red)"],
2026-08-15 18:04:59 +03:00
["violet", null, "var(--mk-violet)"],
2026-08-15 15:37:54 +03:00
["amber", null, "var(--mk-amber)"],
["hide", "сховати", null],
];
const MARK_GLYPH = { tick: "✓", cross: "✗", hide: "…" };
2026-08-15 18:04:59 +03:00
// Сейви зі старих версій тримають на людях ✓/✗ — читаємо їх як два перші кольори,
// а колишній синій показуємо як фіолетовий, що прийшов йому на зміну.
const LEGACY_PERSON_MARK = { tick: "green", cross: "red", blue: "violet" };
2026-08-15 15:37:54 +03:00
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;
}
2026-07-23 17:03:26 +03:00
function markButtons(key) {
2026-08-15 15:37:54 +03:00
const cur = markOf(key);
2026-07-23 17:03:26 +03:00
const mk = el("div", "mk");
2026-08-15 15:37:54 +03:00
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;
2026-07-23 17:03:26 +03:00
b.onclick = (ev) => {
ev.stopPropagation();
App.send({ t: "mark", key, kind });
};
mk.append(b);
}
return mk;
}
function cardClasses(key) {
2026-08-15 15:37:54 +03:00
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>`;
2026-07-23 17:03:26 +03:00
}
2026-08-15 14:07:39 +03:00
// Прокрутити дошку до доказу за 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);
}
2026-07-23 17:03:26 +03:00
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;
2026-08-15 15:37:54 +03:00
const found = App.state.found;
const tagOf = (e) => e.tag || "запит";
2026-07-23 17:03:26 +03:00
body.append(
el(
"div",
"dim small",
2026-08-15 15:37:54 +03:00
"Закріплене 📌 піде в справу для прокурора. Знайдено: " + found.length,
2026-07-23 17:03:26 +03:00
),
);
2026-08-15 15:37:54 +03:00
// Пошук + чипи-теги. До фіналу на дошці 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);
2026-07-23 17:03:26 +03:00
// Закріплені — зверху, від останнього запіненого до першого; далі решта доказів,
// від найновішого знайденого до найстарішого. (pins — у порядку закріплення.)
const pinnedCards = [...pins]
.reverse()
2026-08-15 15:37:54 +03:00
.map((id) => shown.find((e) => e.id === id))
2026-07-23 17:03:26 +03:00
.filter(Boolean);
2026-08-15 15:37:54 +03:00
const rest = [...shown].reverse().filter((e) => !pins.includes(e.id));
2026-07-23 17:03:26 +03:00
const buildCard = (e) => {
const key = "e:" + e.id;
2026-08-15 15:37:54 +03:00
const isPinned = pins.includes(e.id);
const c = el("div", "card" + cardClasses(key) + (isPinned ? " pinned" : ""));
2026-08-15 14:07:39 +03:00
c.dataset.ev = e.id; // якір для прокрутки-до-доказу
2026-07-23 17:03:26 +03:00
const t = el("div", "t");
2026-08-15 15:37:54 +03:00
t.append(el("b", "", esc(e.title)), el("span", "tag", esc(tagOf(e))));
const pin = el("button", "pinbtn" + (isPinned ? " on" : ""), "📌");
pin.title = isPinned ? "відкріпити" : "закріпити для прокурора";
2026-07-23 17:03:26 +03:00
pin.onclick = () => App.send({ t: "pin", evId: e.id });
t.append(pin);
2026-08-15 15:37:54 +03:00
c.append(t);
// Звідки знахідка — лише для фізичних: решта прийшла з термінала чи розмови.
if (e.from) c.append(el("div", "small dim orig", esc(e.from)));
c.append(el("div", "small", esc(e.detail)));
2026-08-15 14:07:39 +03:00
// Експертиза називає доказ, який розбирала, і веде до нього (прокрутка + підсвітка).
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));
2026-07-23 17:03:26 +03:00
const notes = (App.state.board.enotes || {})[e.id] || [];
2026-08-15 18:04:59 +03:00
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 }),
}),
2026-07-23 17:03:26 +03:00
);
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 = [];
2026-08-15 15:37:54 +03:00
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
? "За цим фільтром нічого немає."
: "Дошка порожня — знахідки з'являться тут.",
),
);
2026-07-23 17:03:26 +03:00
// Приховані — окрема згорнута секція на дні, кожен доказ окремо (не «одна кнопка = все»)
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);
2026-08-15 18:04:59 +03:00
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 }),
}),
2026-07-23 17:03:26 +03:00
);
}
2026-08-15 15:37:54 +03:00
// Повернути фокус і курсор у поле, де друкували до перемальовки, — але лише
// якщо друкували щойно (див. FOCUS_GRACE_MS): інакше поле перехоплює клавіатуру
// польового назавжди.
if (activeKey && Date.now() - lastTypedAt < FOCUS_GRACE_MS) {
2026-07-23 17:03:26 +03:00
const again = body.querySelector(`[data-draft-key="${activeKey}"]`);
if (again) {
2026-08-15 15:37:54 +03:00
restoringFocus = true;
2026-07-23 17:03:26 +03:00
again.focus();
if (caret != null)
try {
again.setSelectionRange(caret, caret);
} catch {}
2026-08-15 15:37:54 +03:00
restoringFocus = false;
2026-07-23 17:03:26 +03:00
}
}
}
// ---------- Досьє ----------
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>
2026-08-15 18:04:59 +03:00
</table>`,
// Ключ із id: досьє іншої людини — це інша модалка, а не дубль цієї.
{ key: "dossier:" + c.id },
);
2026-07-23 17:03:26 +03:00
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];
2026-08-15 15:37:54 +03:00
const row = el("div", "small logrow");
2026-07-23 17:03:26 +03:00
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;
}
2026-08-15 15:37:54 +03:00
// Пошук за іменем — ОДИН на обидві ролі. Кожне слово запиту має бути початком
// якогось слова в імені; регістр не важить. Раніше це були дві копії (база
// аналітика і база польового), і аналітикова загубила .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)));
});
}
2026-08-15 18:04:59 +03:00
// Enter у полі = натиснути кнопку поруч. Поля пошуку лежать у <div class="filters">,
// а не у <form>, тож неявного сабміту браузер не дає — доводиться вішати руками.
// Хелпер спільний, бо це вже четверте місце з тим самим рядком, а база мешканців
// існує у двох копіях (аналітик і польовий), які одного разу вже розійшлися.
const submitOn = (input, button) => {
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
button.click();
}
});
};
2026-07-23 17:03:26 +03:00
// ---------- Модалки ----------
2026-08-15 15:37:54 +03:00
// Кроки більше не заміщають один одного: батьківська модалка лишається під
// поточною, тож скасування picker'а повертає до розмови, а не в порожнечу.
// opts.back — явне «← Назад» там, де попередній крок треба перезібрати
// (порожня тека ордера, шортліст лабораторії); opts.onClose — прибирання за собою.
2026-08-15 18:04:59 +03:00
//
// opts.key робить відкриття ІДЕМПОТЕНТНИМ: повторний клік по тій самій кнопці
// замінює наявний шар, а не кладе ще один зверху. Раніше N кліків по «Справа»
// давали N однакових модалок стосом — і оскільки .modal це fixed/inset:0, вони
// накладались ідеально й читались як панель, що не закривається.
// Ключ — це КОНКРЕТНА ціль ('unit:C8', 'talk:c23'), а не тип панелі: інакше
// перехід «квартира A2 → квартира A3» затирав би сам себе. Кроки одного ланцюга
// (відділок → ордер → вибір підстави) ключів не мають зовсім — там стос
// навмисний, на ньому тримається «← Назад».
2026-07-23 17:03:26 +03:00
function openModal(html, opts = {}) {
2026-08-15 18:04:59 +03:00
// Заміна, а не закриття: onClose тут не кличемо свідомо — у деяких місцях він
// прибирає БАТЬКІВСЬКУ модалку (шортліст лабораторії), і повторний клік
// рознiс би ланцюг, який гравець не чіпав.
if (opts.key) {
const old = document.querySelector(`.modal[data-key="${CSS.escape(opts.key)}"]`);
if (old) old.remove();
}
2026-07-23 17:03:26 +03:00
const wrap = el("div", "modal");
2026-08-15 18:04:59 +03:00
if (opts.key) wrap.dataset.key = opts.key;
// Позначка для Esc: locked-модалки (мінігра вилучення) мають власний вихід
// «Відкласти» — клавіатурою їх не обходять.
if (opts.locked) wrap.dataset.locked = "1";
2026-07-23 17:03:26 +03:00
const inner = el("div", "", html);
wrap.append(inner);
2026-08-15 15:37:54 +03:00
const close = () => {
wrap.remove();
if (opts.onClose) opts.onClose();
};
2026-08-15 18:04:59 +03:00
const goBack = opts.back
? () => {
wrap.remove();
opts.back();
}
: null;
2026-07-23 17:03:26 +03:00
if (!opts.locked) {
wrap.onclick = (e) => {
2026-08-15 15:37:54 +03:00
if (e.target === wrap) close();
2026-07-23 17:03:26 +03:00
};
2026-08-15 15:37:54 +03:00
const foot = el("div", "modalfoot");
2026-08-15 18:04:59 +03:00
if (goBack) {
2026-08-15 15:37:54 +03:00
const bk = el("button", "small", "← Назад");
2026-08-15 18:04:59 +03:00
bk.onclick = goBack;
2026-08-15 15:37:54 +03:00
foot.append(bk);
}
2026-07-23 17:03:26 +03:00
const x = el("button", "small", "Закрити");
2026-08-15 15:37:54 +03:00
x.onclick = close;
foot.append(x);
inner.append(foot);
// Автори модалок дописують вміст уже після openModal (журнал допитів, підказки).
// Мікрозадача переносить кнопки в кінець, коли всі синхронні append'и минули.
queueMicrotask(() => inner.append(foot));
2026-07-23 17:03:26 +03:00
}
2026-08-15 18:04:59 +03:00
// Хендли на самому вузлі: глобальний Esc знаходить модалку в DOM і мусить
// закрити її тим самим шляхом, що й кнопка, — інакше загубиться onClose.
wrap.__close = close;
wrap.__back = goBack;
2026-07-23 17:03:26 +03:00
document.body.append(wrap);
2026-08-15 15:37:54 +03:00
return { wrap, inner, close };
2026-07-23 17:03:26 +03:00
}
2026-08-15 18:04:59 +03:00
// Прибрати модалку за ключем ззовні: стан прийшов — чернетку звинувачення
// відкликано, справу закрито. Заміняє ручні змінні-сингтони, які раніше
// тримали посилання на вузол лише заради цього.
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)();
});
2026-07-23 17:03:26 +03:00
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">
2026-08-15 15:37:54 +03:00
<li><b>База мешканців:</b> фільтри вводите руками значення беріть із доказів. Мітьте людей кольорами чотири кольори плюс «» (сховати); що означає кожен, вирішуєте ви з напарником, гра не підказує. Реєстр місцями застарілий: одяг/взуття надійніше звіряти очним оглядом польового.</li>
2026-07-23 17:03:26 +03:00
<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>`;
2026-08-15 18:04:59 +03:00
openModal(
`<h3>${esc(i.title)}: ${esc(i.scene)}, ~${esc(i.time)}</h3><p>${esc(i.text)}</p>
2026-07-23 17:03:26 +03:00
<p class="dim small">Роль: ${App.role === "field" ? "польовий детектив — район, обшуки, розмови" : "аналітик — бази, камери, лабораторія"}.</p>
${App.role === "field" ? fieldHow : analystHow}
<div class="syshead">Фінал</div>
2026-08-15 18:04:59 +03:00
<p class="small">Одне звинувачення на двох: ХТО (ім'я з клавіатури) / ЯК чи ДЕ / ЧОМУ, підписи обох. Прокурор дивиться лише на закріплені 📌 докази: потрібне підтвердження особи (ДНК, відбиток або зламане алібі), мотив і спосіб. Нотатки спільні: загальні у вкладці, точкові прямо на картці доказу.</p>`,
{ key: "intro" },
);
2026-07-23 17:03:26 +03:00
}
$("#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>
2026-08-15 18:04:59 +03:00
<button class="danger" id="ac-go">Подати на підпис напарнику</button>`,
// Спільний ключ із модалкою статусу: щойно з'явиться чернетка, форма
// сама поступиться їй місцем, а не ляже під нею другим шаром.
{ key: "accuse" },
);
2026-07-23 17:03:26 +03:00
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;
2026-08-15 18:04:59 +03:00
const m = openModal(`
2026-07-23 17:03:26 +03:00
<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>
2026-08-15 18:04:59 +03:00
<button id="ac-cancel">Відкликати</button>`,
{ key: "accuse" },
);
m.inner.querySelector("#ac-sign").onclick = () =>
2026-07-23 17:03:26 +03:00
App.send({ t: "accuse_confirm" });
2026-08-15 18:04:59 +03:00
m.inner.querySelector("#ac-cancel").onclick = () =>
2026-07-23 17:03:26 +03:00
App.send({ t: "accuse_cancel" });
}
2026-08-15 18:04:59 +03:00
// Статус перезбирається на кожен снапшот (змінилася кількість підписів), тож
// стару модалку прибираємо явно: ключ сам по собі замінив би її лише в момент
// відкриття нової, а тут буває й відкликання — коли нову не відкривають зовсім.
2026-07-23 17:03:26 +03:00
App.on("state", () => {
2026-08-15 18:04:59 +03:00
closeModalByKey("accuse");
2026-07-23 17:03:26 +03:00
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);
2026-08-15 18:04:59 +03:00
closeModalByKey("accuse");
// endShown лишається: він гасить не подвійний клік, а повторний показ фіналу
// на кожен наступний снапшот — ключ від цього не рятує, бо модалку щоразу
// відкривали б наново.
2026-07-23 17:03:26 +03:00
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>`,
2026-08-15 18:04:59 +03:00
{ key: "end" },
2026-07-23 17:03:26 +03:00
);
}