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

553 lines
25 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; // згорнута секція «Приховані» на дні дошки
// Дошка перемальовується на кожен стан — тож недодруковані нотатки треба зберігати,
// інакше 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 : "";
}
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;
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));
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");
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);
2026-08-15 14:07:39 +03:00
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));
2026-07-23 17:03:26 +03:00
const notes = (App.state.board.enotes || {})[e.id] || [];
for (const n of notes) {
const row = el(
"div",
"small dim",
`${esc(n.txt)} <span class="tag">${n.by === "field" ? "польовий" : "аналітик"}</span>`,
);
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) =>
`<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>`);
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");
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;
}
// ---------- Модалки ----------
function openModal(html, opts = {}) {
const wrap = el("div", "modal");
const inner = el("div", "", html);
wrap.append(inner);
if (!opts.locked) {
wrap.onclick = (e) => {
if (e.target === wrap) wrap.remove();
};
const x = el("button", "small", "Закрити");
x.style.marginTop = "12px";
x.onclick = () => wrap.remove();
inner.append(x);
}
document.body.append(wrap);
return { wrap, inner };
}
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>`);
}
$("#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>`);
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(`
<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>`);
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(
`
<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>`,
{ locked: false },
);
}