533 lines
28 KiB
HTML
533 lines
28 KiB
HTML
|
|
<!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 => ({'&':'&','<':'<','>':'>','"':'"'}[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>
|