2PlayerGame/chapter3/public/field.js

724 lines
30 KiB
JavaScript
Raw 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 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);
addEventListener("resize", () => 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));
// 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);
};
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;
});
})();