2PlayerGame/chapter2/index.html

533 lines
28 KiB
HTML
Raw Permalink Normal View History

2026-07-23 17:03:26 +03:00
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Тихий аукціон</title>
<style>
:root { --bg:#0c0d10; --panel:#15171c; --panel2:#1c1f26; --line:#2c313b; --txt:#cfd4dc; --dim:#7d8590; --amber:#d4a017; --cyan:#56b6c2; --red:#e06c75; --green:#7ec87e; }
* { box-sizing:border-box; margin:0; }
body { background:var(--bg); color:var(--txt); font:16px/1.5 Georgia,'Times New Roman',serif; min-height:100vh; }
#app { max-width:760px; margin:0 auto; padding:12px; padding-bottom:80px; }
h1,h2,h3 { font-weight:600; }
h1 { color:var(--amber); letter-spacing:2px; font-size:1.4em; }
h2 { font-size:1.15em; margin:14px 0 8px; color:var(--amber); }
h3 { font-size:1em; margin:10px 0 6px; }
button { background:var(--panel2); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:10px 14px; font-size:15px; cursor:pointer; text-align:left; font-family:inherit; }
button:hover { border-color:var(--amber); }
button.primary { background:var(--amber); color:#0c0d10; font-weight:600; text-align:center; }
button.locked { color:var(--dim); border-style:dashed; cursor:default; }
button.locked:hover { border-color:var(--line); }
button.done { border-color:var(--green); }
button.mini { padding:4px 10px; font-size:13px; }
select, textarea { background:var(--panel2); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:8px; font-size:14px; font-family:inherit; }
textarea { width:100%; min-height:60px; resize:vertical; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:12px; margin:8px 0; }
.card.ev { border-left:3px solid var(--cyan); }
.card.ev.field { border-left-color:var(--amber); }
.card.starred { border-color:var(--amber); box-shadow:0 0 0 1px var(--amber); }
.stack { display:flex; flex-direction:column; gap:8px; }
.row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
.spread { display:flex; justify-content:space-between; align-items:flex-start; gap:8px; }
.dim { color:var(--dim); font-size:0.88em; }
.tag { display:inline-block; font-size:0.75em; padding:1px 8px; border-radius:10px; border:1px solid var(--line); color:var(--dim); font-family:system-ui,sans-serif; }
.back { color:var(--cyan); background:none; border:none; padding:4px 0; font-size:14px; }
.crossed { text-decoration:line-through; opacity:.45; }
.act { color:var(--red); font-family:system-ui,sans-serif; font-size:.8em; letter-spacing:1px; }
#tabs { position:fixed; bottom:0; left:0; right:0; display:flex; background:var(--panel); border-top:1px solid var(--line); z-index:5; }
#tabs button { flex:1; border:none; border-radius:0; text-align:center; padding:12px 4px; font-size:13px; background:none; }
#tabs button.active { color:var(--amber); border-top:2px solid var(--amber); }
.banner { border-radius:10px; padding:12px; margin:10px 0; }
.banner.bad { background:#2a1518; border:1px solid var(--red); }
.banner.good { background:#152a18; border:1px solid var(--green); }
.quote { border-left:2px solid var(--line); padding-left:10px; margin:8px 0; }
.overlay { position:fixed; inset:0; background:rgba(6,7,9,.94); z-index:10; overflow-y:auto; padding:20px; }
.overlay .inner { max-width:600px; margin:40px auto; }
input[type=text] { background:var(--panel2); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:12px; font-size:18px; width:100%; text-transform:uppercase; letter-spacing:3px; text-align:center; }
label.chk { display:flex; gap:10px; align-items:flex-start; padding:8px; border:1px solid var(--line); border-radius:8px; cursor:pointer; }
label.chk input { margin-top:4px; }
.pill { font-size:0.8em; color:var(--cyan); font-family:system-ui,sans-serif; }
.pill.field { color:var(--amber); }
.note { background:var(--panel2); border-radius:8px; padding:8px 10px; margin:6px 0; }
/* міні-ігри */
.mgbar { position:relative; height:46px; background:var(--panel2); border:1px solid var(--line); border-radius:8px; margin:16px 0; overflow:hidden; }
.zone { position:absolute; top:0; bottom:0; background:rgba(126,200,126,.30); border-left:1px solid var(--green); border-right:1px solid var(--green); }
.marker { position:absolute; top:0; bottom:0; width:4px; background:var(--amber); }
.symrow { display:flex; gap:8px; justify-content:center; margin-top:14px; flex-wrap:wrap; }
.symrow button { font-size:24px; padding:10px 16px; text-align:center; }
.symshow { font-size:52px; text-align:center; height:80px; line-height:80px; letter-spacing:8px; color:var(--cyan); }
</style>
</head>
<body>
<div id="app"></div>
<div id="mg"></div>
<div id="tabs" style="display:none"></div>
<script src="/case2.js"></script>
<script src="/minigames.js"></script>
<script>
const $ = s => document.getElementById(s);
const esc = t => String(t).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
let session = localStorage.getItem('np2_session') || '';
let role = localStorage.getItem('np2_role') || '';
let state = null;
let ui = { tab: 'invest', loc: null, person: null, briefed: false, accEv: [], detail: '', advSent: false };
let mg = null; // активна міні-гра: {kind:'search'|'code', ...} — живе в #mg, render() її не чіпає
function send(a) {
fetch('/act', { method: 'POST', body: JSON.stringify({ session, ...a }) });
}
function connect() {
const es = new EventSource('/events?s=' + encodeURIComponent(session) + '&role=' + role);
es.onmessage = e => {
state = JSON.parse(e.data);
// авто-перехід в Акт II, коли докази вбивства на дошці
const pins = pinnedIds();
if (state.act === 1 && !ui.advSent && CASE.actGate.every(id => pins.includes(id))) {
ui.advSent = true;
send({ t: 'advance', act: 2 });
}
render();
renderInterstitial();
};
}
const pinnedIds = () => state.pinned.map(p => p.id);
const isFound = id => !!state.found[id];
const unlocked = item => !item.requires || item.requires.every(r => pinnedIds().includes(r));
const actOk = item => (item.act || 1) <= state.act;
const suspectName = id => id === 'general' ? 'Загальне' : CASE.suspects.find(s => s.id === id).name;
const crossedCls = id => state.crossed[id] ? 'crossed' : '';
const roleLabel = r => r === 'field' ? 'польовий' : 'аналітик';
// ---------- екрани ----------
function render() {
if (!session || !role) return renderJoin();
if (!state) return;
if (!ui.briefed) return renderBriefing();
$('tabs').style.display = 'flex';
if (state.solved) return renderVictory();
renderTabs();
({ invest: role === 'field' ? renderField : renderAnalyst, notes: renderNotes, board: renderBoard, accuse: renderAccuse })[ui.tab]();
}
function renderJoin() {
$('tabs').style.display = 'none';
$('app').innerHTML = `
<div style="margin-top:12vh;text-align:center">
<h1>ТИХИЙ АУКЦІОН</h1>
<p class="dim" style="margin:8px 0 24px">Детектив на двох · Розділ 2 · Нуар</p>
<div class="stack" style="max-width:340px;margin:0 auto">
<input type="text" id="code" maxlength="12" placeholder="КОД СЕСІЇ" value="${esc(session)}">
<p class="dim">Придумайте будь-який код і введіть однаковий на обох пристроях.</p>
<button class="primary" onclick="pickRole('field')">Грати як ${CASE.roles.field.name}</button>
<button class="primary" style="background:var(--cyan)" onclick="pickRole('analyst')">Грати як ${CASE.roles.analyst.name}</button>
</div>
</div>`;
}
function pickRole(r) {
const code = $('code').value.trim().toUpperCase();
if (!code) return alert('Введіть код сесії');
session = code; role = r;
localStorage.setItem('np2_session', session);
localStorage.setItem('np2_role', role);
connect();
}
function renderBriefing() {
$('app').innerHTML = `
<div class="overlay"><div class="inner">
<h1>СПРАВА: ТИХИЙ АУКЦІОН</h1>
<p style="margin:14px 0">${CASE.intro}</p>
<p class="act">${CASE.actNote}</p>
<h2>Твоя роль: ${CASE.roles[role].name}</h2>
<p>${CASE.roles[role].desc}</p>
<h2>Як грати</h2>
<p>${CASE.howto}</p>
<button class="primary" style="margin-top:20px;width:100%" onclick="ui.briefed=true;render()">Почати розслідування</button>
</div></div>`;
}
function renderInterstitial() {
if (mg || !state || !ui.briefed) return;
const key = 'np2_act2_' + session;
if (state.act >= 2 && !localStorage.getItem(key)) {
$('mg').innerHTML = `<div class="overlay"><div class="inner" style="text-align:center">
<h1 style="color:var(--red)">${CASE.act2title}</h1>
<p style="margin:16px 0;text-align:left">${CASE.act2text}</p>
<p class="dim" style="text-align:left">Відкрились нові локації, запити з ордером — і нові питання до старих знайомих.</p>
<button class="primary" style="margin-top:20px;width:100%" onclick="localStorage.setItem('${key}','1');document.getElementById('mg').innerHTML='';render()">До справи</button>
</div></div>`;
}
}
function renderTabs() {
const online = state.online || {};
const other = role === 'field' ? 'analyst' : 'field';
const tabs = [
['invest', role === 'field' ? 'Локації' : 'Системи'],
['notes', 'Записник'],
['board', 'Дошка (' + state.pinned.length + ')'],
['accuse', 'Звинувачення']
];
$('tabs').innerHTML = tabs.map(([id, name]) =>
`<button class="${ui.tab === id ? 'active' : ''}" onclick="ui.tab='${id}';ui.loc=null;ui.person=null;ui.detail='';render()">${name}</button>`).join('');
window._header = `
<div class="row" style="justify-content:space-between">
<h1 style="font-size:1.05em">ТИХИЙ АУКЦІОН <span class="act">АКТ ${state.act === 1 ? 'I' : 'II'}</span></h1>
<span class="dim">${session} · ${CASE.roles[role].name} · напарник: ${online[other] ? '<span style="color:var(--green)">онлайн</span>' : 'офлайн'}</span>
</div>`;
}
// ---------- польовий ----------
function renderField() {
if (ui.person) return renderPerson();
if (ui.loc) return renderLocation();
$('app').innerHTML = window._header + `<h2>Локації</h2><div class="stack">` +
CASE.locations.map(l => actOk(l)
? `<button onclick="ui.loc='${l.id}';ui.detail='';render()"><b>${l.name}</b><br><span class="dim">${l.desc}</span></button>`
: `<button class="locked">🔒 <b>${l.name}</b><br><span class="dim">Доступ відкриється в Акті II — спершу доведіть, що це вбивство.</span></button>`
).join('') + `</div>`;
}
function renderLocation() {
const l = CASE.locations.find(x => x.id === ui.loc);
let html = window._header + `<button class="back" onclick="ui.loc=null;ui.detail='';render()">← Локації</button><h2>${l.name}</h2><p class="dim">${l.desc}</p>`;
if (l.spots.length) {
html += `<h3>Огляд</h3><div class="stack">` + l.spots.map(sp => {
if (sp.ev && isFound(sp.ev)) return `<button class="done" onclick="showEv('${sp.ev}')">✓ ${sp.label}</button>`;
return `<button onclick="inspect('${l.id}','${sp.id}')">${sp.label}</button>`;
}).join('') + `</div>`;
}
if (l.people.length) {
html += `<h3>Люди</h3><div class="stack">` + l.people.map(pid => {
const p = CASE.people[pid];
return `<button onclick="ui.person='${pid}';render()"><b class="${crossedCls(pid)}">${p.name}</b>${p.suspect ? ' <span class="tag">підозрюваний</span>' : ''}</button>`;
}).join('') + `</div>`;
}
html += `<div id="detail">${ui.detail}</div>`;
$('app').innerHTML = html;
}
function inspect(locId, spotId) {
const sp = CASE.locations.find(x => x.id === locId).spots.find(x => x.id === spotId);
if (sp.flavor) { ui.detail = `<div class="card"><p>${sp.flavor}</p></div>`; return render(); }
startSearchGame(sp);
}
function showEv(id) {
ui.detail = evCard(id, CASE.evidence[id], false);
render();
}
function evCard(id, ev, isNew) {
return `<div class="card ev ${ev.role}">
${isNew ? '<p class="dim">НОВИЙ ДОКАЗ</p>' : ''}
<h3>${ev.title} <span class="tag">${ev.tag}</span></h3>
<p>${ev.detail}</p>
<p class="dim" style="margin-top:6px">Закріпи на дошці через Записник, щоб напарник побачив.</p>
</div>`;
}
function renderPerson() {
const p = CASE.people[ui.person];
const asked = state.asked[ui.person] || [];
let html = window._header + `<button class="back" onclick="ui.person=null;render()">← Назад</button>
<div class="spread"><h2 class="${crossedCls(ui.person)}">${p.name}</h2>${p.suspect ? crossBtn(ui.person) : ''}</div><div class="stack">`;
for (const t of p.topics) {
if (asked.includes(t.id)) html += `<div class="card"><b>— ${t.q}</b><div class="quote">${t.a}</div></div>`;
else if (unlocked(t)) html += `<button onclick="ask('${ui.person}','${t.id}')">— ${t.q}</button>`;
else html += `<button class="locked">🔒 Нова тема з'явиться, коли потрібні докази будуть на дошці</button>`;
}
$('app').innerHTML = html + `</div>`;
}
function ask(pid, tid) {
const t = CASE.people[pid].topics.find(x => x.id === tid);
send({ t: 'ask', s: pid, q: tid });
if (t.gives) send({ t: 'find', id: t.gives, by: role });
}
// ---------- аналітик ----------
function renderAnalyst() {
let html = window._header + `<h2>Системи</h2>`;
for (const sys of CASE.systems) {
html += `<h3>${sys.name}</h3><div class="stack">`;
for (const q of sys.queries) {
if (q.ev && isFound(q.ev)) html += `<button class="done" onclick="showQ('${q.id}')">✓ ${q.label}</button>`;
else if (!actOk(q)) html += `<button class="locked">🔒 ${q.label} — потрібен ордер (Акт II)</button>`;
else if (!unlocked(q.ev ? CASE.evidence[q.ev] : q)) html += `<button class="locked">🔒 Запит стане доступним, коли потрібні матеріали будуть на дошці</button>`;
else html += `<button onclick="runQ('${q.id}')">▸ ${q.label}</button>`;
}
html += `</div>`;
}
$('app').innerHTML = html + `<div id="detail">${ui.detail}</div>`;
}
function findQ(qid) { for (const s of CASE.systems) for (const q of s.queries) if (q.id === qid) return q; }
function runQ(qid) {
const q = findQ(qid);
if (q.flavor) { ui.detail = `<div class="card"><b>${q.label}</b><p>${q.flavor}</p></div>`; render(); return window.scrollTo(0, 1e6); }
startCodeGame(q);
}
function showQ(qid) {
const q = findQ(qid);
ui.detail = evCard(q.ev, CASE.evidence[q.ev], false);
render();
window.scrollTo(0, 1e6);
}
// ---------- міні-гра польового: сканер (зафіксуй маркер у зоні) ----------
function startSearchGame(sp) {
const hard = state.act >= 2;
mg = { kind: 'search', sp, period: hard ? 1300 : 1600, zoneW: hard ? 12 : 16, fails: 0 };
newZone();
$('mg').innerHTML = `<div class="overlay"><div class="inner">
<h2>Обшук: ${sp.label}</h2>
<p class="dim">Сканер ловить сигнал. Зафіксуй маркер у зеленій зоні.</p>
<div class="mgbar"><div class="zone" id="mgZone"></div><div class="marker" id="mgMarker"></div></div>
<div class="row">
<button class="primary" style="flex:1" onclick="searchFix()">Зафіксувати</button>
<button onclick="closeMg()">Відійти</button>
</div>
<p id="mgMsg" class="dim" style="margin-top:10px"></p>
</div></div>`;
drawZone();
mg.t0 = performance.now();
(function loop(now) {
if (!mg || mg.kind !== 'search') return;
$('mgMarker').style.left = triPos(now - mg.t0, mg.period) + '%';
mg.raf = requestAnimationFrame(loop);
})(performance.now());
}
function newZone() { mg.zoneStart = 8 + Math.random() * (84 - mg.zoneW); }
function drawZone() { const z = $('mgZone'); z.style.left = mg.zoneStart + '%'; z.style.width = mg.zoneW + '%'; }
function searchFix() {
const pos = triPos(performance.now() - mg.t0, mg.period);
if (inZone(pos, mg.zoneStart, mg.zoneW)) {
const sp = mg.sp;
closeMg();
ui.detail = evCard(sp.ev, CASE.evidence[sp.ev], true);
send({ t: 'find', id: sp.ev, by: role });
render();
} else {
mg.fails++;
$('mgMsg').textContent = 'Сигнал втрачено. Спокійно, ще раз.' + (mg.fails >= 3 ? ' (зона стала ширшою)' : '');
if (mg.fails === 3) mg.zoneW += 8; // ponytail: полегшення після 3 промахів, щоб гра не стала стіною
newZone(); drawZone();
}
}
// ---------- міні-гра аналітика: перехоплення коду (повтори послідовність) ----------
function startCodeGame(q) {
const len = state.act >= 2 ? 5 : 4;
mg = { kind: 'code', q, seq: seqGen(Math.floor(Math.random() * 1e9), len), input: [], showing: true, fails: 0 };
$('mg').innerHTML = `<div class="overlay"><div class="inner">
<h2>Код доступу: ${q.label}</h2>
<p class="dim">Система показує код. Запам'ятай і повтори послідовність.</p>
<div class="symshow" id="mgShow"></div>
<div class="symrow" id="mgKeys">${MG_SYMBOLS.map((s, i) => `<button onclick="codePress(${i})">${s}</button>`).join('')}</div>
<div class="row" style="margin-top:14px">
<button onclick="codeReplay()">Показати ще раз</button>
<button onclick="closeMg()">Скасувати</button>
</div>
<p id="mgMsg" class="dim" style="margin-top:10px"></p>
</div></div>`;
codeShow();
}
function codeShow() {
mg.showing = true; mg.input = [];
$('mgMsg').textContent = '';
let i = 0;
(function step() {
if (!mg || mg.kind !== 'code') return;
if (i < mg.seq.length) {
$('mgShow').textContent = MG_SYMBOLS[mg.seq[i]];
i++;
mg.timer = setTimeout(() => { $('mgShow').textContent = '·'; mg.timer = setTimeout(step, 160); }, 520);
} else {
$('mgShow').textContent = '?';
mg.showing = false;
}
})();
}
function codeReplay() { if (mg && mg.kind === 'code' && !mg.showing) codeShow(); }
function codePress(i) {
if (!mg || mg.kind !== 'code' || mg.showing) return;
mg.input.push(i);
if (!seqPrefixOk(mg.seq, mg.input)) {
mg.fails++;
$('mgMsg').textContent = 'Невірний символ. Система повторює код.' + (mg.fails >= 3 ? ' (показ уповільнено)' : '');
codeShow();
return;
}
$('mgShow').textContent = mg.input.map(x => MG_SYMBOLS[x]).join('');
if (mg.input.length === mg.seq.length) {
const q = mg.q;
closeMg();
ui.detail = evCard(q.ev, CASE.evidence[q.ev], true);
send({ t: 'find', id: q.ev, by: role });
render();
window.scrollTo(0, 1e6);
}
}
function closeMg() {
if (mg) { if (mg.raf) cancelAnimationFrame(mg.raf); if (mg.timer) clearTimeout(mg.timer); }
mg = null;
$('mg').innerHTML = '';
renderInterstitial();
}
// ---------- записник ----------
function crossBtn(sid) {
return `<button class="mini" onclick="send({t:'cross',s:'${sid}'})">${state.crossed[sid] ? '↩ Повернути' : '✗ Відкинути'}</button>`;
}
function starBtn(id) {
return `<button class="mini" onclick="send({t:'star',id:'${id}'})">${state.stars[id] ? '★' : '☆'}</button>`;
}
function renderNotes() {
let html = window._header + `<h2>Нотатки <span class="dim">(спільні)</span></h2>`;
for (const n of state.notes) {
html += `<div class="note spread"><span>${esc(n.txt)} <span class="pill ${n.by}">${roleLabel(n.by)}</span></span>
<button class="mini" onclick="send({t:'note_del',id:${n.id}})"></button></div>`;
}
html += `<textarea id="noteTxt" placeholder="Версії, таймлайн, питання напарнику..."></textarea>
<button class="primary" style="margin-top:6px" onclick="addNote()">Додати нотатку</button>`;
const mine = Object.keys(state.found).filter(id => CASE.evidence[id].role === role);
html += `<h2>Мої докази (${mine.length})</h2>`;
if (!mine.length) html += `<p class="dim">Поки нічого. ${role === 'field' ? 'Оглянь локації і поговори з людьми.' : 'Прожени запити по системах.'}</p>`;
for (const id of mine) {
const ev = CASE.evidence[id];
const pin = state.pinned.find(p => p.id === id);
html += `<div class="card ev ${ev.role} ${state.stars[id] ? 'starred' : ''}">
<div class="spread"><h3>${ev.title} <span class="tag">${ev.tag}</span></h3>${starBtn(id)}</div>
<p>${ev.detail}</p>
<div class="row" style="margin-top:8px">` +
(pin ? `<span class="dim">📌 На дошці: ${suspectName(pin.s)}</span>`
: `<select id="pin_${id}"><option value="general">Загальне</option>${CASE.suspects.map(s => `<option value="${s.id}">${s.name}</option>`).join('')}</select>
<button onclick="send({t:'pin',id:'${id}',s:document.getElementById('pin_${id}').value,by:role})">📌 На дошку</button>`) +
`</div></div>`;
}
const askedAny = Object.entries(state.asked).filter(([, v]) => v.length);
if (askedAny.length) {
html += `<h2>Журнал допитів</h2>`;
for (const [pid, tids] of askedAny) {
html += `<div class="card"><h3 class="${crossedCls(pid)}">${CASE.people[pid].name}</h3>` +
tids.map(tid => { const t = CASE.people[pid].topics.find(x => x.id === tid); return `<p style="margin-top:6px"><b>— ${t.q}</b></p><div class="quote">${t.a}</div>`; }).join('') + `</div>`;
}
}
html += `<h2>Фабула</h2><div class="card"><p>${CASE.intro}</p><p class="act" style="margin-top:8px">${CASE.actNote}</p></div>
<h2>Підозрювані</h2>` + CASE.suspects.map(s =>
`<div class="card"><div class="spread"><span><b class="${crossedCls(s.id)}">${s.name}</b> <span class="tag">${s.role}</span></span>${crossBtn(s.id)}</div><p class="dim ${crossedCls(s.id)}">${s.desc}</p></div>`).join('');
$('app').innerHTML = html;
}
function addNote() {
const txt = $('noteTxt').value.trim();
if (!txt) return;
send({ t: 'note', txt, by: role });
$('noteTxt').value = '';
}
// ---------- дошка ----------
function renderBoard() {
let html = window._header + `<h2>Дошка справи</h2><p class="dim">Спільна для обох. Викреслені підозрювані лишаються на дошці — раптом ви помилились.</p>`;
const groups = ['general', ...CASE.suspects.map(s => s.id)];
for (const g of groups) {
const pins = state.pinned.filter(p => p.s === g);
if (g === 'general') { if (!pins.length) continue; html += `<h3>Загальне</h3>`; }
else {
const s = CASE.suspects.find(x => x.id === g);
html += `<div class="spread"><h3 class="${crossedCls(g)}">${s.name} <span class="tag">${s.role}</span></h3>${crossBtn(g)}</div>`;
if (!pins.length) { html += `<p class="dim">— нічого не закріплено</p>`; continue; }
}
for (const p of pins) {
const ev = CASE.evidence[p.id];
html += `<div class="card ev ${ev.role} ${state.stars[p.id] ? 'starred' : ''}">
<div class="spread"><h3>${ev.title} <span class="tag">${ev.tag}</span> <span class="pill ${ev.role}">${roleLabel(ev.role)}</span></h3>${starBtn(p.id)}</div>
<p>${ev.detail}</p>
<div class="row" style="margin-top:8px">
<select onchange="send({t:'repin',id:'${p.id}',s:this.value})">${groups.map(x => `<option value="${x}" ${x === p.s ? 'selected' : ''}>${suspectName(x)}</option>`).join('')}</select>
<button onclick="send({t:'unpin',id:'${p.id}'})">Зняти</button>
</div></div>`;
}
}
if (!state.pinned.length) html += `<p class="dim" style="margin-top:20px">Дошка порожня. Закріплюйте докази із Записника — це відкриває нові питання й запити.</p>`;
$('app').innerHTML = html;
}
// ---------- звинувачення ----------
function renderAccuse() {
const last = state.lastAccusation;
let html = window._header + `<h2>Висунути звинувачення</h2>
<p class="dim">Оберіть разом: хто, яким способом, і 3${CASE.finale.maxEvidence} докази З ДОШКИ. Помилка — не кінець: отримаєте підказку, яка ланка не сходиться.</p>`;
if (state.act === 1) html += `<div class="banner bad"><b>Зарано.</b><p>Прокурор не прийме звинувачення, поки не доведено сам факт вбивства. Завершіть Акт I.</p></div>`;
if (last && !last.result.ok)
html += `<div class="banner bad"><b>Спроба №${last.n} — відхилено.</b><p>${last.result.hint}</p></div>`;
html += `<h3>Вбивця</h3><select id="acc_s">${CASE.suspects.map(s => `<option value="${s.id}">${s.name} — ${s.role}${state.crossed[s.id] ? ' (викреслений)' : ''}</option>`).join('')}</select>
<h3>Спосіб</h3><select id="acc_m" style="max-width:100%">${CASE.methods.map(m => `<option value="${m.id}">${m.label}</option>`).join('')}</select>
<h3>Докази (з дошки, до ${CASE.finale.maxEvidence})</h3><div class="stack">`;
if (!state.pinned.length) html += `<p class="dim">На дошці порожньо — спершу закріпіть докази.</p>`;
for (const p of state.pinned) {
const ev = CASE.evidence[p.id];
html += `<label class="chk"><input type="checkbox" value="${p.id}" ${ui.accEv.includes(p.id) ? 'checked' : ''} onchange="toggleAccEv(this)"><span><b>${ev.title}</b> <span class="tag">${ev.tag}</span>${state.stars[p.id] ? ' ★' : ''}</span></label>`;
}
html += `</div><button class="primary" style="width:100%;margin-top:16px" ${state.act === 1 ? 'disabled style="width:100%;margin-top:16px;opacity:.4"' : ''} onclick="submitAccusation()">⚖ Висунути звинувачення</button>`;
$('app').innerHTML = html;
}
function toggleAccEv(cb) {
if (cb.checked) {
if (ui.accEv.length >= CASE.finale.maxEvidence) { cb.checked = false; return alert('Не більше ' + CASE.finale.maxEvidence + ' доказів. Оберіть найсильніші.'); }
ui.accEv.push(cb.value);
} else ui.accEv = ui.accEv.filter(x => x !== cb.value);
}
function submitAccusation() {
if (state.act === 1) return;
if (ui.accEv.length < 3) return alert('Потрібно щонайменше 3 докази.');
const suspect = $('acc_s').value, method = $('acc_m').value;
const result = checkAccusation(suspect, method, ui.accEv);
send({ t: 'accuse', suspect, method, evidence: ui.accEv, result });
}
function renderVictory() {
$('tabs').style.display = 'none';
const a = state.lastAccusation;
$('app').innerHTML = `<div class="overlay" style="position:static;background:none"><div class="inner">
<h1 style="color:var(--green)">СПРАВУ РОЗКРИТО</h1>
<div class="banner good"><b>${CASE.suspects.find(s => s.id === a.suspect).name}</b> — винен. Спроб: ${a.n}. Доказів знайдено: ${Object.keys(state.found).length} з ${Object.keys(CASE.evidence).length}.</div>
<h2>Як усе було</h2>
<p>${CASE.finale.recap}</p>
<button class="primary" style="margin-top:20px;width:100%" onclick="localStorage.removeItem('np2_session');localStorage.removeItem('np2_act2_'+session);location.reload()">Нова гра</button>
</div></div>`;
}
// ---------- старт ----------
if (session && role) connect(); else render();
</script>
</body>
</html>