// Польовий детектив: топ-даун район на канвасі, обшуки, розмови. "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( `
Ваша база: дошка справи і термінал аналітика синхронізовані. Тут-таки сидить прокурор — ордер на обшук житла подають особисто, з доказом, який прямо вказує на людину.
`, { 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( `Тека порожня. Спершу закріпіть 📌 на дошці доказ, який прямо вказує на цю особу, — з усім підряд до прокурора не ходять.
`, { back: warrantFlow }, ); const mm = openModal( `Прокурор підпише, лише якщо доказ прямо вказує на цю особу. У теці — закріплене 📌.
Термінал аналітика, тільки перегляд. Кольорові мітки ставить аналітик — тут вони лише видно; що означає кожен колір, домовляєтесь ви двоє. Реєстр місцями застарілий: одяг і взуття надійніше звіряти очним оглядом за ордером.
Задайте пошук або фільтр.
Збігів: ${rows.length}
`; const t = el( "table", "", "— Введіть ім\'я або прізвище.
'; return; } const hits = matchName(App.init.citizens, q); // спільний пошук, див. client.js if (!hits.length) { out.innerHTML = '— Записів не знайдено. Спробуйте інший початок.
'; 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(`${home.length ? "Мешканці: " + home.map((c) => esc(c.name)).join(", ") : "За реєстром тут ніхто не живе."}
`, { 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( `Вдома: ' + inside.map((c) => ``).join(" ") + "
" : ""}💬 «${esc(l)}»
`) .join("") : ""; const topicsHtml = m.topics.length ? 'Сірі теми вже в доказах на дошці.
' : ""; const mm = openModal( `Відповіді можуть бути правдою, брехнею або «не знаю» — перевіряйте камерами і чужими словами. Модалка лишається відкритою: питайте далі, «Закрити» завершує розмову.
` + 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( '${esc(m.body)}
`, { 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( `Зафіксуйте пінцет у зеленій зоні (кнопка або пробіл). Промахи розширюють зону — руки звикають.