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

787 lines
36 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";
(() => {
let cv, ctx, hint;
let started = false; // слухачі/цикл/розсилку ставимо раз, навіть якщо init прийде знову
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;
// Канвас — повноцінна ціль фокуса: клік по району забирає клавіатуру назад
// у польового, а Esc із будь-якого поля вводу повертає її сюди (див. keydown).
cv.tabIndex = 0;
cv.style.outline = "none";
cv.addEventListener("mousedown", () => cv.focus());
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));
// init прилітає й на реконекті: канвас/стан перебудували вище, а глобальні
// слухачі, цикл рендеру й розсилку позицій ставимо ЛИШЕ раз — інакше після
// кожного реконекту крутився б зайвий loop і дублювались клавіші.
if (started) return;
started = true;
addEventListener("resize", () => fitCanvas(cv));
// e.code — фізична клавіша, працює на будь-якій розкладці (укр/лат)
addEventListener("keydown", (e) => {
const typing =
e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA";
// Esc — аварійний вихід із поля вводу назад до району. Без нього клавіатура
// лишалася «зайнятою» нотаткою на дошці, і рух мовчки не працював.
if (typing && e.code === "Escape") {
e.target.blur();
cv.focus();
return;
}
if (typing) 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();
const ae = document.activeElement;
const typing =
ae && (ae.tagName === "INPUT" || ae.tagName === "TEXTAREA");
hint.textContent = typing
? "клавіатура зайнята полем вводу — Esc або клік по району, щоб повернути керування"
: 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>`,
{ key: "station" },
);
m.inner.querySelector("#db").onclick = residentsModal;
m.inner.querySelector("#warr").onclick = warrantFlow;
}
// Вибір людини → вибір підстави. «← Назад» на обох кроках повертає до вибору
// людини: раніше порожня тека була глухим кутом, а помилка в імені коштувала
// всього ланцюга заново.
function warrantFlow() {
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>`,
{ back: warrantFlow },
);
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>",
{ back: warrantFlow },
);
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="dbsec">Пошук за іменем</div>
<div class="filters">
<input id="rq" placeholder="Ім'я Прізвище" style="min-width:200px"><button id="rqgo">Знайти</button>
</div>
<div class="dbsplit"></div>
<div class="dbsec">Пошук за прикметами</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>`,
{ key: "db" },
);
const out = m.inner.querySelector("#rout");
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;
// Мітки ставить аналітик — тут вони лише видно: підпис плюс кольорова
// ліва грань рядка (див. cardClasses). На папері дот сам по собі губиться.
const mark = markOf(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">${markSwatch(mark)}</td>`,
);
tr.onclick = () => showDossier(c);
t.append(tr);
}
out.append(t);
};
const byName = () => {
const q = m.inner.querySelector("#rq").value.trim();
if (!q) {
out.innerHTML = '<p class="dim small">— Введіть ім\'я або прізвище.</p>';
return;
}
const hits = matchName(App.init.citizens, q); // спільний пошук, див. client.js
if (!hits.length) {
out.innerHTML = '<p class="dim small">— Записів не знайдено. Спробуйте інший початок.</p>';
return;
}
out.innerHTML = "";
draw(hits);
};
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;
// Той самий Enter, що й у базі аналітика: два екрани — одна поведінка.
submitOn(m.inner.querySelector("#rq"), m.inner.querySelector("#rqgo"));
for (const id of ["#rf-smin", "#rf-smax", "#rf-hmin", "#rf-hmax"])
submitOn(m.inner.querySelector(id), m.inner.querySelector("#rfgo"));
}
// одиночний вибір мешканця з фільтром за іменем.
// 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>";
}
// Ключ із id будівлі: зайшов у сусідній під'їзд — це інша модалка, не дубль.
const m = openModal(html, { key: "building:" + b.id });
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) 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>`,
{ key: "door:" + unit },
);
// Двері лишаються на екрані: «Замкнено» тепер не викидає в порожнечу —
// тост прилітає, а спробувати постукати чи піти назад можна одразу.
m.inner.querySelector("#knock").onclick = () => {
if (!present.length) return toast("Тиша. Ніхто не відчиняє.");
pickPerson(present);
};
m.inner.querySelector("#enter").onclick = () =>
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>`,
{ key: "unit:" + m.unit },
);
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 });
}
// Розмова — це цикл «питання → відповідь → наступне питання», а не одноразовий
// крок. Модалка розмови завжди одна: сервер віддає теми у відповідь на `talk`,
// і кожна нова відповідь просто перемальовує її разом із журналом. Одиничність
// тримає ключ 'talk' — без id, бо перехід до іншої людини теж має замінювати
// вікно, а не класти друге поверх.
let flashCid = null; // до кого прилетіла свіжа відповідь — підсвітити останній запис
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,
{ key: "talk" },
);
mm.inner.append(logSection(m.cid)); // журнал із мітками (спільний із досьє аналітика)
// Свіжа відповідь лежить останнім записом журналу — прокручуємо й підсвічуємо,
// інакше її не видно за темами й плітками.
if (flashCid === m.cid) {
flashCid = null;
const rows = mm.inner.querySelectorAll(".logrow");
const last = rows[rows.length - 1];
if (last) {
last.classList.add("flash");
last.scrollIntoView({ block: "center" });
}
}
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();
// Жоден крок нижче не знімає модалку розмови: picker'и й результати
// відкриваються ПОВЕРХ неї, а скасування повертає до співрозмовника.
mm.inner.querySelector("#al-go").onclick = () =>
App.send({ t: "ask_alibi", cid: m.cid, minutes: +sl.value });
mm.inner.querySelector("#ab-go").onclick = () =>
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 = () => {
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.inner.onclick = (e) => {
if (e.target.dataset.topic) {
App.send({ t: "ask", evId: e.target.dataset.topic });
// Тема пішла на дошку — перезбираємо розмову, щоб вона стала сірою ✓,
// а решта тем лишилась під рукою: їх зазвичай кілька поспіль.
App.send({ t: "talk", cid: m.cid });
}
};
});
// Результат очного огляду: нікуди не пишеться — переносьте в нотатки самі.
// Відкривається поверх розмови, «Закрити» повертає до співрозмовника.
App.on("inspect", (m) => {
const c = App.byId(m.cid);
openModal(`<h3>Очний огляд: ${esc(c.name)}</h3><p>${esc(m.body)}</p>`, {
key: "inspect:" + m.cid,
});
});
// Відповідь допитуваного. Сироту-модалку не відкриваємо: просимо теми ще раз —
// розмова перемалюється вже з новим записом у журналі, і можна питати далі.
App.on("answer", (m) => {
flashCid = m.cid;
App.send({ t: "talk", cid: m.cid });
});
// ---------- міні-гра «вилучення»: тремтячі руки псують доказ ----------
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 — власний вихід «Відкласти», Esc сюди не лізе. Ключ на випадок,
// якщо сервер надішле вилучення двічі: друга спроба замінить першу,
// а не запустить другий таймер поверх.
{ locked: true, key: "mg" },
);
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;
});
})();