764 lines
34 KiB
JavaScript
764 lines
34 KiB
JavaScript
// Польовий детектив: топ-даун район на канвасі, обшуки, розмови.
|
||
"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>`);
|
||
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="filters">
|
||
<input id="rq" placeholder="Ім'я Прізвище" style="min-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 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;
|
||
}
|
||
|
||
// одиночний вибір мешканця з фільтром за іменем.
|
||
// 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) 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("Тиша. Ніхто не відчиняє.");
|
||
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>`);
|
||
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`,
|
||
// і кожна нова відповідь просто перемальовує її разом із журналом.
|
||
let talkModal = null;
|
||
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>'
|
||
: "";
|
||
if (talkModal) {
|
||
const old = talkModal;
|
||
talkModal = null; // прибираємо самі — onClose не має скидати нову модалку
|
||
old.wrap.remove();
|
||
}
|
||
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,
|
||
{ onClose: () => { if (talkModal === mm) talkModal = null; } },
|
||
);
|
||
talkModal = mm;
|
||
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>`);
|
||
});
|
||
|
||
// Відповідь допитуваного. Сироту-модалку не відкриваємо: просимо теми ще раз —
|
||
// розмова перемалюється вже з новим записом у журналі, і можна питати далі.
|
||
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: 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;
|
||
});
|
||
})();
|