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

731 lines
30 KiB
JavaScript
Raw Normal View History

2026-07-23 17:03:26 +03:00
// Польовий детектив: топ-даун район на канвасі, обшуки, розмови.
"use strict";
(() => {
let cv, ctx, hint;
2026-08-15 14:07:39 +03:00
let started = false; // слухачі/цикл/розсилку ставимо раз, навіть якщо init прийде знову
2026-07-23 17:03:26 +03:00
let player = { x: 85, y: 290 };
const keys = {};
let solids = [],
walkIn = []; // будівлі-перешкоди та відкриті зони (парк/пірс)
const OPEN_ZONES = ["park", "pier"];
// ---------- рендер мапи (спільний з аналітиком) ----------
// канвас під розмір вікна (без вертикального скролу) і під щільність пікселів (без мила)
window.fitCanvas = function (cv) {
const D = App.init.district.world,
dpr = window.devicePixelRatio || 1;
const rect = cv.parentElement.getBoundingClientRect();
if (rect.width < 50) return; // вкладку ще не видно
const availH = Math.max(
240,
window.innerHeight - cv.getBoundingClientRect().top - 36,
);
const w = Math.min(rect.width - 4, (availH * D.w) / D.h);
cv.style.width = w + "px";
cv.style.height = (w * D.h) / D.w + "px";
cv._k = (w * dpr) / D.w;
cv.width = Math.round(w * dpr);
cv.height = Math.round(((w * D.h) / D.w) * dpr);
};
// ---------- нічний рендер району (спільний з мапою аналітика) ----------
const hash32 = (str) => {
let h = 2166136261 >>> 0;
for (const c of String(str)) { h ^= c.codePointAt(0); h = Math.imul(h, 16777619); }
return h >>> 0;
};
const lcg = (seed) => {
let a = seed | 0;
return () => { a = (Math.imul(a, 1103515245) + 12345) | 0; return ((a >>> 8) & 0xffff) / 0x10000; };
};
window.drawDistrict = function (ctx, opts = {}) {
const D = App.init.district;
const k = ctx.canvas._k || 1;
ctx.setTransform(k, 0, 0, k, 0, 0);
ctx.globalCompositeOperation = "source-over";
// ніч: земля і річка
ctx.fillStyle = "#0b1014";
ctx.fillRect(0, 0, D.world.w, D.world.h);
const rg = ctx.createLinearGradient(0, 600, 0, 700);
rg.addColorStop(0, "#0b1622");
rg.addColorStop(1, "#123049");
ctx.fillStyle = rg;
ctx.fillRect(0, 616, D.world.w, 84);
// дороги: мокрий асфальт з осьовою
for (const rd of D.roads) {
ctx.fillStyle = "#161a20";
ctx.fillRect(rd.x, rd.y, rd.w, rd.h);
ctx.strokeStyle = "rgba(200,210,220,.07)";
ctx.setLineDash([14, 18]);
ctx.lineWidth = 2;
ctx.beginPath();
if (rd.w > rd.h) { ctx.moveTo(rd.x, rd.y + rd.h / 2); ctx.lineTo(rd.x + rd.w, rd.y + rd.h / 2); }
else { ctx.moveTo(rd.x + rd.w / 2, rd.y); ctx.lineTo(rd.x + rd.w / 2, rd.y + rd.h); }
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "#3f4854";
ctx.font = "11px Georgia";
ctx.fillText(rd.name, rd.x + 8 + (rd.w > rd.h ? 0 : -2), rd.y + (rd.w > rd.h ? 14 : 60));
}
// будівлі: тінь, фасад, теплі вікна (детерміновані від id)
for (const b of D.buildings) {
const p = b.pos, open = OPEN_ZONES.includes(b.id);
if (open) {
ctx.fillStyle = b.id === "park" ? "#0e1a10" : "#0d151c";
ctx.fillRect(p.x, p.y, p.w, p.h);
ctx.strokeStyle = "rgba(120,150,120,.25)";
ctx.strokeRect(p.x + 0.5, p.y + 0.5, p.w, p.h);
const rr = lcg(hash32(b.id));
ctx.fillStyle = b.id === "park" ? "#14240f" : "#13202b";
for (let i = 0; i < 14; i++) {
const x = p.x + 10 + rr() * (p.w - 20), y = p.y + 10 + rr() * (p.h - 20);
ctx.beginPath(); ctx.arc(x, y, 3 + rr() * 5, 0, 7); ctx.fill();
}
} else {
ctx.fillStyle = "#05070a";
ctx.fillRect(p.x + 3, p.y + 4, p.w, p.h);
ctx.fillStyle = b.id === "station" ? "#1a2433" : "#171d26";
ctx.fillRect(p.x, p.y, p.w, p.h);
ctx.fillStyle = "rgba(255,255,255,.045)";
ctx.fillRect(p.x, p.y, p.w, 6);
ctx.strokeStyle = "#2c3642";
ctx.strokeRect(p.x + 0.5, p.y + 0.5, p.w, p.h);
const rr = lcg(hash32(b.id));
for (let wy = p.y + 12; wy < p.y + p.h - 12; wy += 16)
for (let wx = p.x + 8; wx < p.x + p.w - 12; wx += 14)
if (rr() < 0.3) {
ctx.fillStyle = rr() < 0.8 ? "rgba(244,201,112,.5)" : "rgba(160,200,255,.28)";
ctx.fillRect(wx, wy, 5, 7);
}
if (b.id === "station") { ctx.fillStyle = "#7fb4ff"; ctx.fillRect(p.x + p.w / 2 - 2, p.y - 6, 4, 6); }
}
ctx.fillStyle = "#93a1b0";
ctx.font = "12px Georgia";
ctx.fillText(b.name.split(",")[0], p.x + 6, p.y + 16, p.w - 12);
if (App.init.sceneBuilding === b.id) {
ctx.strokeStyle = "rgba(208,83,83,.7)";
ctx.setLineDash([6, 5]);
ctx.strokeRect(p.x - 3.5, p.y - 3.5, p.w + 7, p.h + 7);
ctx.setLineDash([]);
ctx.fillStyle = "#d05353";
ctx.font = "bold 15px Georgia";
ctx.fillText(App.init.caseType === "kidnap" ? "✖ місце викрадення" : "✖ місце злочину", p.x + 6, p.y + p.h - 8, p.w - 12);
}
}
// ліхтарі вздовж доріг — тепле світло, адитивно
ctx.globalCompositeOperation = "lighter";
for (const rd of D.roads) {
const horiz = rd.w > rd.h, len = horiz ? rd.w : rd.h;
for (let i = 80; i < len; i += 170) {
const x = horiz ? rd.x + i : rd.x + rd.w / 2;
const y = horiz ? rd.y + rd.h / 2 : rd.y + i;
const gl = ctx.createRadialGradient(x, y, 2, x, y, 55);
gl.addColorStop(0, "rgba(255,196,110,.15)");
gl.addColorStop(1, "rgba(255,196,110,0)");
ctx.fillStyle = gl;
ctx.beginPath(); ctx.arc(x, y, 55, 0, 7); ctx.fill();
ctx.fillStyle = "rgba(255,220,160,.8)";
ctx.fillRect(x - 1, y - 1, 2, 2);
}
}
ctx.globalCompositeOperation = "source-over";
if (opts.partner) {
for (const f of App.fieldPositions || []) {
if (App.role === "field" && f.num === App.myNum) continue; // себе малює loop
ctx.fillStyle = "#e8b04b";
ctx.beginPath();
ctx.arc(f.x, f.y, 7, 0, 7);
ctx.fill();
ctx.font = "11px Georgia";
ctx.fillText("Польовий" + f.num, f.x + 10, f.y + 4);
}
}
// мітки аналітика «перевір тут» — бачать обидві ролі
for (const mk of (App.state && App.state.markers) || []) {
ctx.strokeStyle = "#d05353";
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(mk.x - 8, mk.y - 8);
ctx.lineTo(mk.x + 8, mk.y + 8);
ctx.moveTo(mk.x + 8, mk.y - 8);
ctx.lineTo(mk.x - 8, mk.y + 8);
ctx.stroke();
}
};
App.on("init", (m) => {
if (App.role !== "field") return;
player = { ...m.pos };
App.lastFieldPos = { ...m.pos };
const stage = document.getElementById("stage");
stage.innerHTML = "";
cv = document.createElement("canvas");
cv.id = "cv";
cv.width = m.district.world.w;
cv.height = m.district.world.h;
hint = document.createElement("div");
hint.id = "fieldhint";
stage.append(cv, hint);
ctx = cv.getContext("2d");
fitCanvas(cv);
solids = m.district.buildings
.filter((b) => !OPEN_ZONES.includes(b.id))
.map((b) => b.pos);
walkIn = m.district.buildings.filter((b) => OPEN_ZONES.includes(b.id));
2026-08-15 14:07:39 +03:00
// init прилітає й на реконекті: канвас/стан перебудували вище, а глобальні
// слухачі, цикл рендеру й розсилку позицій ставимо ЛИШЕ раз — інакше після
// кожного реконекту крутився б зайвий loop і дублювались клавіші.
if (started) return;
started = true;
addEventListener("resize", () => fitCanvas(cv));
2026-07-23 17:03:26 +03:00
// e.code — фізична клавіша, працює на будь-якій розкладці (укр/лат)
addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
return;
keys[e.code] = true;
if (e.code === "KeyE") tryEnter();
});
addEventListener("keyup", (e) => {
keys[e.code] = false;
});
addEventListener("blur", () => {
for (const k in keys) keys[k] = false;
});
requestAnimationFrame(loop);
setInterval(() => {
App.lastFieldPos = { x: player.x, y: player.y };
App.send({ t: "move", x: player.x, y: player.y });
}, 150);
});
const hit = (x, y) =>
solids.some(
(p) =>
x > p.x - 9 && x < p.x + p.w + 9 && y > p.y - 9 && y < p.y + p.h + 9,
);
let drops = null;
function loop() {
const sp = 3.2;
let dx =
(keys.KeyD || keys.ArrowRight ? sp : 0) -
(keys.KeyA || keys.ArrowLeft ? sp : 0);
let dy =
(keys.KeyS || keys.ArrowDown ? sp : 0) -
(keys.KeyW || keys.ArrowUp ? sp : 0);
const D = App.init.district.world;
if (dx && !hit(player.x + dx, player.y))
player.x = Math.max(8, Math.min(D.w - 8, player.x + dx));
if (dy && !hit(player.x, player.y + dy))
player.y = Math.max(8, Math.min(D.h - 18, player.y + dy));
drawDistrict(ctx, { partner: true });
// ліхтарик детектива
ctx.globalCompositeOperation = "lighter";
const pg = ctx.createRadialGradient(player.x, player.y, 4, player.x, player.y, 85);
pg.addColorStop(0, "rgba(255,230,170,.17)");
pg.addColorStop(1, "rgba(255,230,170,0)");
ctx.fillStyle = pg;
ctx.beginPath(); ctx.arc(player.x, player.y, 85, 0, 7); ctx.fill();
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "#e8b04b";
ctx.beginPath(); ctx.arc(player.x, player.y, 8, 0, 7); ctx.fill();
ctx.strokeStyle = "#14100a"; ctx.stroke();
// дощ — косі штрихи, що падають крізь світло ліхтарів
if (!drops)
drops = Array.from({ length: 130 }, () => ({
x: Math.random() * D.w, y: Math.random() * D.h,
l: 8 + Math.random() * 10, s: 6 + Math.random() * 5,
}));
ctx.strokeStyle = "rgba(170,190,215,.27)";
ctx.lineWidth = 1;
ctx.beginPath();
for (const d of drops) {
ctx.moveTo(d.x, d.y);
ctx.lineTo(d.x - d.l * 0.35, d.y + d.l);
d.x -= d.s * 0.35; d.y += d.s;
if (d.y > D.h) { d.y = -10; d.x = Math.random() * (D.w + 60); }
}
ctx.stroke();
// віньєтка ночі
const vg = ctx.createRadialGradient(D.w / 2, D.h / 2, D.h / 2.6, D.w / 2, D.h / 2, D.w / 1.35);
vg.addColorStop(0, "rgba(0,0,0,0)");
vg.addColorStop(1, "rgba(0,0,0,.55)");
ctx.fillStyle = vg;
ctx.fillRect(0, 0, D.w, D.h);
const b = nearBuilding();
hint.textContent = b
? `E — ${OPEN_ZONES.includes(b.id) ? "оглянутися: " : "увійти: "}${b.name}`
: "WASD/стрілки — рух, E — увійти/оглянутися";
requestAnimationFrame(loop);
}
function nearBuilding() {
return App.init.district.buildings.find((b) => {
const p = b.pos,
m = 26;
return (
player.x > p.x - m &&
player.x < p.x + p.w + m &&
player.y > p.y - m &&
player.y < p.y + p.h + m
);
});
}
// ---------- інтер'єри ----------
function tryEnter() {
const b = nearBuilding();
if (!b) return;
if (b.id === "station") return stationModal();
openBuilding(b);
}
function stationModal() {
const m = openModal(`<h3>Відділок поліції</h3>
<p class="dim small">Ваша база: дошка справи і термінал аналітика синхронізовані. Тут-таки сидить прокурор ордер на обшук житла подають особисто, з доказом, який прямо вказує на людину.</p>
<button class="primary" id="warr">📜 Подати на ордер</button>
<button id="db">🗂 База мешканців</button>`);
m.inner.querySelector("#db").onclick = () => { m.wrap.remove(); residentsModal(); };
m.inner.querySelector("#warr").onclick = () => {
m.wrap.remove();
pickCitizen("Ордер: на кого?", (cid) => {
const c = App.byId(cid);
// до прокурора йде лише закріплене 📌 — тека справи, а не вся дошка
const evs = App.state.found.filter((e) =>
App.state.board.pins.includes(e.id),
);
if (!evs.length)
return openModal(
`<h3>Підстава для ордера на: ${esc(c.name)}</h3>
<p class="dim">Тека порожня. Спершу закріпіть 📌 на дошці доказ, який прямо вказує на цю особу, з усім підряд до прокурора не ходять.</p>`,
);
const mm = openModal(
`<h3>Підстава для ордера на: ${esc(c.name)}</h3>
<p class="dim small">Прокурор підпише, лише якщо доказ прямо вказує на цю особу. У теці закріплене 📌.</p>
<div class="grid" style="grid-template-columns:1fr">` +
evs
.map((e) => `<button data-ev="${e.id}">${esc(e.title)}</button>`)
.join("") +
"</div>",
);
mm.inner.onclick = (e) => {
if (!e.target.dataset.ev) return;
App.send({ t: "warrant", cid, evId: e.target.dataset.ev });
mm.wrap.remove();
};
});
};
}
// База мешканців для польового: той самий реєстр, що бачить аналітик, з його
// мітками (✓/✗/…) — тільки перегляд, мітки ставить аналітик.
function residentsModal() {
const coats = [...new Set(App.init.citizens.map((c) => c.coat))];
const m = openModal(`<h3>База мешканців району</h3>
<p class="dim small">Термінал аналітика, тільки перегляд. Мітки (//) ставить аналітик тут вони лише видно. Реєстр місцями застарілий: одяг і взуття надійніше звіряти очним оглядом за ордером.</p>
<div class="filters">
<input id="rq" placeholder="Ім'я Прізвище" style="width:200px"><button id="rqgo">Знайти</button>
</div>
<div class="filters">
<select id="rf-sex"><option value="">стать: будь-яка</option><option value="ч">чол.</option><option value="ж">жін.</option></select>
<input id="rf-smin" type="number" placeholder="взуття від" min="35" max="46">
<input id="rf-smax" type="number" placeholder="взуття до" min="35" max="46">
<input id="rf-hmin" type="number" placeholder="зріст від">
<input id="rf-hmax" type="number" placeholder="зріст до">
<select id="rf-print"><option value="">відбитки: всі</option><option>дуга</option><option>петля</option><option>завиток</option></select>
<select id="rf-coat"><option value="">одяг: будь-який</option>${coats.map((x) => `<option>${esc(x)}</option>`).join("")}</select>
<label class="small dim"><input type="checkbox" id="rf-smoke"> курить</label>
<button id="rfgo">Фільтрувати</button>
</div>
<div id="rout"><p class="dim small">Задайте пошук або фільтр.</p></div>`);
const out = m.inner.querySelector("#rout");
const MK = { tick: "✓", cross: "✗", hide: "…" };
const draw = (rows) => {
out.innerHTML = `<p class="dim small">Збігів: ${rows.length}</p>`;
const t = el(
"table",
"",
"<tr><th>Ім'я</th><th>Вік</th><th>Адреса</th><th>Робота</th><th>Зріст</th><th>Взуття</th><th>Відбитки</th><th></th></tr>",
);
for (const c of rows) {
const key = "c:" + c.id;
const mark = App.state.board.marks[key];
const vic =
App.state.victim === c.id
? App.init.caseType === "kidnap"
? " ⚠"
: " †"
: "";
const tr = el(
"tr",
"rowlink" + cardClasses(key),
`<td>${esc(c.name)}${vic}</td><td>${c.age}</td><td>${esc(c.home)}</td><td>${esc(c.job)}</td><td>${c.height}</td><td>${c.shoe}</td><td>${esc(c.printType)}</td><td class="dim">${mark ? MK[mark] : ""}</td>`,
);
tr.onclick = () => showDossier(c);
t.append(tr);
}
out.append(t);
};
const byName = () => {
const q = m.inner.querySelector("#rq").value.trim().toLowerCase();
if (!q) {
out.innerHTML = '<p class="dim small">— Введіть ім\'я або прізвище.</p>';
return;
}
// Розбиваємо введений запит на слова
const words = q.split(/\s+/);
// Фільтруємо: кожне введене слово має збігатися з початком якогось слова в імені громадянина
const hits = App.init.citizens.filter((c) => {
const nameWords = c.name.toLowerCase().split(/\s+/);
return words.every((w) => nameWords.some((nw) => nw.startsWith(w)));
});
if (!hits.length) {
out.innerHTML = '<p class="dim small">— Записів не знайдено. Спробуйте інший початок.</p>';
return;
}
out.innerHTML = "";
draw(hits);
2026-07-23 17:03:26 +03:00
};
const byFilter = () => {
const v = (id) => m.inner.querySelector(id).value;
draw(
App.init.citizens.filter(
(c) =>
(!v("#rf-sex") || c.sex === v("#rf-sex")) &&
(!v("#rf-smin") || c.shoe >= +v("#rf-smin")) &&
(!v("#rf-smax") || c.shoe <= +v("#rf-smax")) &&
(!v("#rf-hmin") || c.height >= +v("#rf-hmin")) &&
(!v("#rf-hmax") || c.height <= +v("#rf-hmax")) &&
(!v("#rf-print") || c.printType === v("#rf-print")) &&
(!v("#rf-coat") || c.coat === v("#rf-coat")) &&
(!m.inner.querySelector("#rf-smoke").checked || c.smoker),
),
);
};
m.inner.querySelector("#rqgo").onclick = byName;
m.inner.querySelector("#rfgo").onclick = byFilter;
}
// одиночний вибір мешканця з фільтром за іменем.
// skip(c) — кого не показувати; за замовчуванням жертву (ордер на неї не беруть),
// але про жертву питати можна й треба — там викликач передає своє.
function pickCitizen(title, cb, skip = (c) => c.id === App.state.victim) {
const mm = openModal(`<h3>${esc(title)}</h3>
<input id="pc-q" placeholder="фільтр за іменем…" style="width:100%">
<div id="pc-list" class="grid" style="grid-template-columns:1fr 1fr;max-height:300px;overflow-y:auto;margin-top:8px"></div>`);
const list = mm.inner.querySelector("#pc-list"),
q = mm.inner.querySelector("#pc-q");
const draw = () => {
list.innerHTML = "";
const f = q.value.trim().toLowerCase();
for (const c of App.init.citizens) {
if (skip(c)) continue;
if (f && !c.name.toLowerCase().includes(f)) continue;
const b = document.createElement("button");
b.textContent = c.name;
b.onclick = () => {
mm.wrap.remove();
cb(c.id);
};
list.append(b);
}
};
q.oninput = draw;
draw();
}
window.pickCitizen = pickCitizen;
function openBuilding(b) {
App.send({ t: "visit", building: b.id }); // тасує присутність в ІНШИХ будівлях
const ppl = App.init.citizens.filter(
(c) => App.state.locs[c.id] === b.id && c.id !== App.state.victim,
);
let html = `<h3>${esc(b.name)}</h3>`;
if (b.spots && b.spots.length) {
html +=
'<div class="syshead">Оглянути</div><div class="grid">' +
b.spots
.map((s, i) => `<button data-spot="${i}">${esc(s)}</button>`)
.join("") +
"</div>";
}
if (b.units) {
html +=
'<div class="syshead">Квартири</div><div class="grid">' +
b.units
.map(
(u) =>
`<button data-unit="${u}">${u}${App.state.keys.includes(u) ? " 🔑" : ""}</button>`,
)
.join("") +
"</div>";
}
if (ppl.length && !b.units) {
html +=
'<div class="syshead">Тут зараз</div><div class="grid">' +
ppl
.map((c) => `<button data-cid="${c.id}">${esc(c.name)}</button>`)
.join("") +
"</div>";
}
const m = openModal(html);
m.inner.onclick = (e) => {
const t = e.target;
if (t.dataset.spot != null)
App.send({
t: "search",
building: b.id,
spot: b.spots[t.dataset.spot],
});
if (t.dataset.cid) talkTo(t.dataset.cid);
if (t.dataset.unit) {
m.wrap.remove();
openUnitDoor(b, t.dataset.unit);
}
};
}
function openUnitDoor(b, unit) {
const home = App.init.citizens.filter(
(c) => c.home === unit && c.id !== App.state.victim,
);
const present = home.filter((c) => App.state.locs[c.id] === b.id);
const m = openModal(`<h3>${esc(b.name)} — кв. ${unit}</h3>
<p class="dim small">${home.length ? "Мешканці: " + home.map((c) => esc(c.name)).join(", ") : "За реєстром тут ніхто не живе."}</p>
<button id="knock">Постукати</button>
<button id="enter">Увійти${App.state.keys.includes(unit) ? " 🔑" : ""}</button>`);
m.inner.querySelector("#knock").onclick = () => {
if (!present.length) return toast("Тиша. Ніхто не відчиняє.");
m.wrap.remove();
pickPerson(present);
};
m.inner.querySelector("#enter").onclick = () => {
m.wrap.remove();
App.send({ t: "enter_unit", unit });
};
}
App.on("unit", (m) => {
if (!m.allowed) return;
const inside = App.init.citizens.filter(
(c) =>
c.home === m.unit &&
App.state.locs[c.id] === unitB(m.unit) &&
c.id !== App.state.victim,
);
const mm = openModal(`<h3>Кв. ${m.unit} — обшук</h3>
${inside.length ? '<p class="dim small">Вдома: ' + inside.map((c) => `<button data-cid="${c.id}" class="small">${esc(c.name)}</button>`).join(" ") + "</p>" : ""}
<div class="grid">${m.slots.map((s, i) => `<button data-slot="${i}">${esc(s)}</button>`).join("")}</div>`);
mm.inner.onclick = (e) => {
if (e.target.dataset.slot != null)
App.send({
t: "search_unit",
unit: m.unit,
slot: m.slots[e.target.dataset.slot],
});
if (e.target.dataset.cid) talkTo(e.target.dataset.cid);
};
});
const unitB = (u) =>
u[0] === "A" ? "apt_a" : u[0] === "B" ? "apt_b" : "apt_c";
function pickPerson(list) {
if (list.length === 1) return talkTo(list[0].id);
const m = openModal(
'<h3>Хто відчинив?</h3><div class="grid">' +
list
.map((c) => `<button data-cid="${c.id}">${esc(c.name)}</button>`)
.join("") +
"</div>",
);
m.inner.onclick = (e) => {
if (e.target.dataset.cid) {
m.wrap.remove();
talkTo(e.target.dataset.cid);
}
};
}
function talkTo(cid) {
App.send({ t: "talk", cid });
}
App.on("topics", (m) => {
const c = App.byId(m.cid);
const gossip =
c.gossip && c.gossip.length
? '<div class="syshead">Балачки про район</div>' +
c.gossip
.map((l) => `<p class="small dim">💬 «${esc(l)}»</p>`)
.join("")
: "";
const topicsHtml = m.topics.length
? '<div class="syshead">Теми у справі</div><div class="grid" style="grid-template-columns:1fr">' +
m.topics
.map(
(t) =>
`<button data-topic="${t.id}" ${t.found ? "disabled" : ""}>${esc(t.title)}${t.found ? " ✓" : ""}</button>`,
)
.join("") +
'</div><p class="dim small">Сірі теми вже в доказах на дошці.</p>'
: "";
const mm = openModal(
`<h3>${esc(c.name)}</h3>` +
topicsHtml +
`<div class="syshead">Допит</div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<input type="range" id="al-t" min="1200" max="1439" value="1330" style="flex:1;min-width:120px">
<span id="al-v" style="font-family:monospace"></span>
<button id="al-go">Де ви були?</button>
</div>
<div style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap">
<button id="ab-go">Про людину</button>
<button id="sh-go">Показати доказ</button>
${App.state.warrants.includes(m.cid) ? '<button id="insp-go">Оглянути особисто 📜</button>' : ''}
</div>
<p class="dim small">Відповіді можуть бути правдою, брехнею або «не знаю» перевіряйте камерами і чужими словами.</p>` +
gossip,
);
mm.inner.append(logSection(m.cid)); // журнал із мітками (спільний із досьє аналітика)
const sl = mm.inner.querySelector("#al-t"),
out = mm.inner.querySelector("#al-v");
const fmt = (v) =>
`${String(Math.floor(v / 60)).padStart(2, "0")}:${String(v % 60).padStart(2, "0")}`;
const upd = () => (out.textContent = fmt(+sl.value));
sl.oninput = upd;
upd();
mm.inner.querySelector("#al-go").onclick = () => {
App.send({ t: "ask_alibi", cid: m.cid, minutes: +sl.value });
mm.wrap.remove();
};
mm.inner.querySelector("#ab-go").onclick = () => {
mm.wrap.remove();
pickCitizen(
`${esc(c.first)}, що скажете про…`,
(about) => App.send({ t: "ask_about", cid: m.cid, about }),
(x) => x.id === m.cid,
);
};
mm.inner.querySelector("#sh-go").onclick = () => {
mm.wrap.remove();
const pm = openModal(
'<h3>Показати доказ</h3><div class="grid" style="grid-template-columns:1fr">' +
App.state.found
.map((e) => `<button data-ev="${e.id}">${esc(e.title)}</button>`)
.join("") +
"</div>",
);
pm.inner.onclick = (e) => {
if (e.target.dataset.ev) {
App.send({ t: "show_ev", cid: m.cid, evId: e.target.dataset.ev });
pm.wrap.remove();
}
};
};
const insp = mm.inner.querySelector("#insp-go");
if (insp)
insp.onclick = () => {
App.send({ t: "inspect", cid: m.cid });
mm.wrap.remove();
};
mm.inner.onclick = (e) => {
if (e.target.dataset.topic) {
App.send({ t: "ask", evId: e.target.dataset.topic });
mm.wrap.remove();
}
};
});
// результат очного огляду: нікуди не пишеться — переносьте в нотатки самі
App.on("inspect", (m) => {
const c = App.byId(m.cid);
openModal(
`<h3>Очний огляд: ${esc(c.name)}</h3><p>${esc(m.body)}</p>`,
);
});
// відповідь допитуваного
App.on("answer", (m) => {
const c = App.byId(m.cid);
openModal(
`<h3>${esc(c.name)}</h3><p class="dim small">${esc(m.q)}</p><p>${esc(m.a)}</p>
<p class="dim small">Записано в журнал допитів (досьє в аналітика і картка розмови).</p>`,
);
});
// ---------- міні-гра «вилучення»: тремтячі руки псують доказ ----------
let mgMisses = 0;
App.on("mg", (m) => {
if (m.kind !== "collect" || App.role !== "field") return;
const mm = openModal(
`<h3>Вилучення: ${esc(m.title)}</h3>
<p class="dim small">Зафіксуйте пінцет у зеленій зоні (кнопка або пробіл). Промахи розширюють зону руки звикають.</p>
<div id="mgtrack" style="position:relative;height:26px;background:#0d1116;border:1px solid var(--line);border-radius:6px;margin:10px 0">
<div id="mgzone" style="position:absolute;top:0;bottom:0;background:rgba(90,168,112,.35);border:1px solid var(--green)"></div>
<div id="mgmark" style="position:absolute;top:2px;bottom:2px;width:4px;background:var(--acc)"></div>
</div>
<button class="primary" id="mgfix">Зафіксувати</button>
<button id="mgoff">Відкласти</button>
<span class="dim small" id="mgmsg"></span>`,
{ locked: true },
);
const q = (sel) => mm.inner.querySelector(sel);
const zone = q("#mgzone"),
mark = q("#mgmark"),
msg = q("#mgmsg");
let zl = 25 + Math.random() * 45,
zw = 13 + mgMisses * 5;
zone.style.left = zl + "%";
zone.style.width = zw + "%";
let alive = true;
const t0 = performance.now(),
speed = 0.075; // % за мс
(function anim(now) {
if (!alive) return;
const p = ((now - t0) * speed) % 192,
x = p < 96 ? p : 192 - p;
mark.style.left = `calc(${x}% - 2px)`;
mark._x = x;
requestAnimationFrame(anim);
})(t0);
const close = () => {
alive = false;
removeEventListener("keydown", keyfix);
mm.wrap.remove();
};
const fix = () => {
const x = mark._x || 0;
if (x >= zl && x <= zl + zw) {
App.send({ t: "mg_done", evId: m.evId });
close();
} else {
mgMisses++;
zw = 13 + mgMisses * 5;
zone.style.width = zw + "%";
msg.textContent = `Зірвалось (${mgMisses}). Обережніше.`;
if (mgMisses >= 4) {
App.send({ t: "mg_done", evId: m.evId });
toast("З четвертої спроби рука не здригнулася.");
close();
}
}
};
const keyfix = (e) => {
if (e.code === "Space") {
e.preventDefault();
fix();
}
};
addEventListener("keydown", keyfix);
q("#mgfix").onclick = fix;
q("#mgoff").onclick = close;
});
})();