// Аналітик: база мешканців, мапа з мітками, системи-форми і лабораторія. 'use strict'; (() => { let stage, panels = {}, mapCtx, mapCv; const UKR = 'абвгґдеєжзиіїйклмнопрстуфхцчшщьюя'; const caesarDec = (text, s) => [...text].map(ch => { const low = ch.toLowerCase(), i = UKR.indexOf(low); if (i < 0) return ch; const out = UKR[(i - s % UKR.length + UKR.length) % UKR.length]; return ch === low ? out : out.toUpperCase(); }).join(''); const b64dec = s => { try { return decodeURIComponent(escape(atob(s.trim()))); } catch { return '⚠ не схоже на base64'; } }; App.on('init', m => { if (App.role !== 'analyst') return; stage = document.getElementById('stage'); stage.innerHTML = ''; const tabs = el('div', 'atabs'); const names = { db: 'База мешканців', map: 'Мапа району', sys: 'Системи й лабораторія' }; for (const id of Object.keys(names)) { const b = el('button', id === 'db' ? 'on' : '', names[id]); b.onclick = () => { for (const x of tabs.children) x.classList.toggle('on', x === b); for (const p of Object.values(panels)) p.classList.remove('on'); panels[id].classList.add('on'); }; tabs.append(b); panels[id] = el('div', 'apanel' + (id === 'db' ? ' on' : '')); } stage.append(tabs, panels.db, panels.map, panels.sys); buildDb(); buildMap(); buildSys(); setInterval(() => { if (!panels.map.classList.contains('on')) return; if (!mapCv._k) fitCanvas(mapCv); drawDistrict(mapCtx, { partner: true }); }, 400); addEventListener('resize', () => { if (mapCv) { fitCanvas(mapCv); drawDistrict(mapCtx, { partner: true }); } }); }); App.on('pos', () => { if (mapCtx) drawDistrict(mapCtx, { partner: true }); }); App.on('state', () => { if (App.role !== 'analyst' || !stage) return; renderPuzzles(); renderRows(); renderLab(); renderAlibi(); }); // ---------- База мешканців ---------- // Сліди в базу не підставляються самі: читайте докази і вводьте значення руками. let dbOut = null, lastRows = null; let dbShowHidden = false; // згорнута секція прихованих у базі function buildDb() { const p = panels.db; p.innerHTML = `
Пошук за точним ім'ям — помилка в написанні дасть порожній результат. Значення для фільтрів беріть зі знайдених слідів і вводьте самі. Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).
— Записів не знайдено. Перевірте написання.
'; return; } lastRows = [hit]; renderRows(); }; p.querySelector('#f-go').onclick = () => { const v = id => p.querySelector(id).value; lastRows = App.init.citizens.filter(c => (!v('#f-sex') || c.sex === v('#f-sex')) && (!v('#f-smin') || c.shoe >= +v('#f-smin')) && (!v('#f-smax') || c.shoe <= +v('#f-smax')) && (!v('#f-hmin') || c.height >= +v('#f-hmin')) && (!v('#f-hmax') || c.height <= +v('#f-hmax')) && (!v('#f-print') || c.printType === v('#f-print')) && (!v('#f-coat') || c.coat === v('#f-coat')) && (!p.querySelector('#f-smoke').checked || c.smoker)); renderRows(); }; } // рядки з позначками ✓/✗/…, як картки доказів: викреслюйте людей по одному function renderRows() { if (!dbOut || !lastRows || !App.state) return; dbOut.innerHTML = `Збігів: ${lastRows.length}
`; const t = el('table', '', 'Клік по будівлі — адреси й люди. Клік по порожньому місцю — мітка «перевір тут» для напарника (повторний клік — зняти). Золота точка — напарник.
'; mapCv = el('canvas'); mapCv.id = 'cv'; mapCv.width = App.init.district.world.w; mapCv.height = App.init.district.world.h; p.append(mapCv); mapCtx = mapCv.getContext('2d'); drawDistrict(mapCtx, { partner: true }); mapCv.onclick = e => { const r = mapCv.getBoundingClientRect(), D = App.init.district.world; const x = (e.clientX - r.left) * D.w / r.width, y = (e.clientY - r.top) * D.h / r.height; const b = App.init.district.buildings.find(b => x > b.pos.x && x < b.pos.x + b.pos.w && y > b.pos.y && y < b.pos.y + b.pos.h); if (b) buildingInfo(b); else App.send({ t: 'marker', x, y }); }; } function buildingInfo(b) { const res = App.init.citizens.filter(c => b.units && b.units.includes(c.home)); const work = App.init.citizens.filter(c => c.workplace === b.id); const here = App.init.citizens.filter(c => App.state && App.state.locs[c.id] === b.id && c.id !== App.state.victim); let html = `Дані для запитів — номери, імена, прикмети, роки — вичитуйте з доказів і вводьте самі. Кожен результат лягає на дошку: що з нього стосується справи — вирішуєте ви.
${esc(z.payload)}`));
const out = el('div', 'small', '');
const sl = el('input'); sl.type = 'range'; sl.min = 1; sl.max = 32; sl.value = 1; sl.style.width = '100%';
const upd = () => out.innerHTML = `зсув ${sl.value}: ${esc(caesarDec(z.payload, +sl.value))}`;
sl.oninput = upd; upd();
const b = el('button', 'primary', 'Зафіксувати зсув');
b.onclick = () => App.send({ t: 'puzzle_try', id: z.id, shift: +sl.value });
c.append(sl, out, b);
} else if (z.kind === 'b64') {
c.append(el('div', 'small', `${esc(z.payload)}`));
const inp = el('input'); inp.placeholder = 'розкодований текст (інструменти нижче)'; inp.style.width = '100%';
const b = el('button', 'primary', 'Надіслати');
b.onclick = () => App.send({ t: 'puzzle_try', id: z.id, answer: inp.value.trim() });
c.append(inp, b);
} else { // pin / md5
const inp = el('input'); inp.placeholder = z.kind === 'pin' ? 'PIN (4 цифри)' : 'пароль';
const b = el('button', 'primary', 'Спробувати');
b.onclick = () => App.send({ t: 'puzzle_try', id: z.id, answer: inp.value });
inp.onkeydown = e => { if (e.key === 'Enter') b.click(); };
c.append(inp, b);
}
c.append(hintBlock(z.hint));
pzList.append(c);
}
}
// ---------- Лабораторні міні-ігри ----------
const rng32 = seed => { let a = seed | 0; return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; };
function drawPrint(ctx, seed, rot) {
const rr = rng32(seed), W = ctx.canvas.width, c = W / 2;
ctx.fillStyle = '#0d1116'; ctx.fillRect(0, 0, W, W);
ctx.save(); ctx.translate(c, c); ctx.rotate(rot || 0);
ctx.strokeStyle = '#9db4c8'; ctx.lineWidth = 1.4;
for (let i = 0; i < 11; i++) {
const rad = 4 + i * (c - 8) / 11 + rr() * 3, a0 = rr() * 6.28, len = 1.2 + rr() * 3.6;
ctx.beginPath(); ctx.arc(0, 0, rad, a0, a0 + len); ctx.stroke();
if (rr() < 0.4) { ctx.beginPath(); ctx.arc(0, 0, rad * 0.72, a0 + 2, a0 + 2.7); ctx.stroke(); }
}
ctx.restore();
}
const laneHtml = (dna, name) =>
`Оберіть до 6 профілів для порівняння зі зразком.
`); const sel = new Set(); const list = mm.inner.querySelector('#pk-list'), q = mm.inner.querySelector('#pk-q'), n = mm.inner.querySelector('#pk-n'); const draw = () => { list.innerHTML = ''; const f = q.value.trim().toLowerCase(); for (const c of App.init.citizens) { if (c.id === App.state.victim) continue; if (f && !c.name.toLowerCase().includes(f)) continue; const lb = el('label', 'small'); lb.style.display = 'block'; const cb = el('input'); cb.type = 'checkbox'; cb.checked = sel.has(c.id); cb.onchange = () => { if (cb.checked && sel.size >= 6) { cb.checked = false; return; } if (cb.checked) sel.add(c.id); else sel.delete(c.id); n.textContent = `обрано: ${sel.size}/6`; }; lb.append(cb, ` ${c.name} — ${c.home}, ${c.job}`); list.append(lb); } }; q.oninput = draw; draw(); mm.inner.querySelector('#pk-go').onclick = () => { if (!sel.size) return; mm.wrap.remove(); go([...sel]); }; } function dnaModal(ids, sampleEv, sample) { if (!sample || !ids.length) return; const mm = openModal(`Зразок із місця злочину — ліворуч. Клацніть по профілю, який вважаєте збігом. Памʼятайте: збіг скаже лише, ЧИЯ це ДНК, — не коли і не чому вона там.
Зразок ліворуч. Клацніть по власнику відбитка.
← зразок `); drawPrint(mm.inner.querySelector('#psample').getContext('2d'), App.state.printSample, 0.7); const box = mm.inner.querySelector('#pcands'); for (const id of ids) { const c = App.byId(id); const w = el('div', '', ''); w.style.cursor = 'pointer'; w.style.textAlign = 'center'; const cv = el('canvas'); cv.width = cv.height = 110; cv.style.border = '1px solid var(--line)'; cv.style.borderRadius = '6px'; w.append(cv, el('div', 'small dim', esc(c.name))); drawPrint(cv.getContext('2d'), c.printSeed, 0); w.onclick = () => { App.send({ t: 'print_pick', cid: id }); mm.wrap.remove(); }; box.append(w); } mm.inner.append(hintBlock('Зразок знято під кутом — подумки поверніть його. Порівнюйте будову: кількість і хід дуг, а не поворот.')); } function triangulateModal(data) { const mm = openModal(`Три вежі, три відстані до апарата — станом на зараз. Клацніть будинок, що лежить на перетині всіх трьох дуг.
`); const cv = mm.inner.querySelector('#trcv'); cv.id = 'cv'; fitCanvas(cv); const ctx = cv.getContext('2d'); drawDistrict(ctx); // лишає трансформацію у світових координатах for (const t of data.towers) { ctx.strokeStyle = 'rgba(232,176,75,.55)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(t.x, t.y, t.dist, 0, 7); ctx.stroke(); ctx.fillStyle = '#e8b04b'; ctx.beginPath(); ctx.arc(t.x, t.y, 6, 0, 7); ctx.fill(); ctx.fillStyle = '#e8b04b'; ctx.font = '12px Georgia'; ctx.fillText('▲ ' + t.name, t.x + 8, t.y + 4); } mm.inner.querySelector('#trreads').innerHTML = data.towers.map(t => `${esc(t.name)}: ~${t.dist} м`).join(' · '); mm.inner.append(hintBlock('Кожна дуга — коло можливих точок навколо вежі. Апарат там, де всі три кола накладаються на один будинок. Клацніть саме той будинок.')); cv.onclick = e => { const r = cv.getBoundingClientRect(), D = App.init.district.world; const x = (e.clientX - r.left) * D.w / r.width, y = (e.clientY - r.top) * D.h / r.height; const b = App.init.district.buildings.find(b => x > b.pos.x && x < b.pos.x + b.pos.w && y > b.pos.y && y < b.pos.y + b.pos.h); if (b) { App.send({ t: 'triangulate_pick', building: b.id }); mm.wrap.remove(); } }; } App.on('mg', m => { if (App.role !== 'analyst') return; if (m.kind === 'triangulate') triangulateModal(m); }); })();