// Польовий детектив: топ-даун район на канвасі, обшуки, розмови. "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(`

Відділок поліції

Ваша база: дошка справи і термінал аналітика синхронізовані. Тут-таки сидить прокурор — ордер на обшук житла подають особисто, з доказом, який прямо вказує на людину.

`); 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( `

Підстава для ордера на: ${esc(c.name)}

Тека порожня. Спершу закріпіть 📌 на дошці доказ, який прямо вказує на цю особу, — з усім підряд до прокурора не ходять.

`, ); const mm = openModal( `

Підстава для ордера на: ${esc(c.name)}

Прокурор підпише, лише якщо доказ прямо вказує на цю особу. У теці — закріплене 📌.

` + evs .map((e) => ``) .join("") + "
", ); 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(`

База мешканців району

Термінал аналітика, тільки перегляд. Мітки (✓/✗/…) ставить аналітик — тут вони лише видно. Реєстр місцями застарілий: одяг і взуття надійніше звіряти очним оглядом за ордером.

Задайте пошук або фільтр.

`); const out = m.inner.querySelector("#rout"); const MK = { tick: "✓", cross: "✗", hide: "…" }; const draw = (rows) => { out.innerHTML = `

Збігів: ${rows.length}

`; const t = el( "table", "", "Ім'яВікАдресаРоботаЗрістВзуттяВідбитки", ); 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), `${esc(c.name)}${vic}${c.age}${esc(c.home)}${esc(c.job)}${c.height}${c.shoe}${esc(c.printType)}${mark ? MK[mark] : ""}`, ); tr.onclick = () => showDossier(c); t.append(tr); } out.append(t); }; const byName = () => { const q = m.inner.querySelector("#rq").value.trim().toLowerCase(); const hit = App.init.citizens.find((c) => c.name.toLowerCase() === q); out.innerHTML = hit ? "" : '

— Записів не знайдено. Перевірте написання.

'; if (hit) draw([hit]); }; 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(`

${esc(title)}

`); 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 = `

${esc(b.name)}

`; if (b.spots && b.spots.length) { html += '
Оглянути
' + b.spots .map((s, i) => ``) .join("") + "
"; } if (b.units) { html += '
Квартири
' + b.units .map( (u) => ``, ) .join("") + "
"; } if (ppl.length && !b.units) { html += '
Тут зараз
' + ppl .map((c) => ``) .join("") + "
"; } 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(`

${esc(b.name)} — кв. ${unit}

${home.length ? "Мешканці: " + home.map((c) => esc(c.name)).join(", ") : "За реєстром тут ніхто не живе."}

`); 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(`

Кв. ${m.unit} — обшук

${inside.length ? '

Вдома: ' + inside.map((c) => ``).join(" ") + "

" : ""}
${m.slots.map((s, i) => ``).join("")}
`); 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( '

Хто відчинив?

' + list .map((c) => ``) .join("") + "
", ); 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 ? '
Балачки про район
' + c.gossip .map((l) => `

💬 «${esc(l)}»

`) .join("") : ""; const topicsHtml = m.topics.length ? '
Теми у справі
' + m.topics .map( (t) => ``, ) .join("") + '

Сірі теми вже в доказах на дошці.

' : ""; const mm = openModal( `

${esc(c.name)}

` + topicsHtml + `
Допит
${App.state.warrants.includes(m.cid) ? '' : ''}

Відповіді можуть бути правдою, брехнею або «не знаю» — перевіряйте камерами і чужими словами.

` + 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( '

Показати доказ

' + App.state.found .map((e) => ``) .join("") + "
", ); 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( `

Очний огляд: ${esc(c.name)}

${esc(m.body)}

`, ); }); // відповідь допитуваного App.on("answer", (m) => { const c = App.byId(m.cid); openModal( `

${esc(c.name)}

${esc(m.q)}

${esc(m.a)}

Записано в журнал допитів (досьє в аналітика і картка розмови).

`, ); }); // ---------- міні-гра «вилучення»: тремтячі руки псують доказ ---------- let mgMisses = 0; App.on("mg", (m) => { if (m.kind !== "collect" || App.role !== "field") return; const mm = openModal( `

Вилучення: ${esc(m.title)}

Зафіксуйте пінцет у зеленій зоні (кнопка або пробіл). Промахи розширюють зону — руки звикають.

`, { 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; }); })();