2PlayerGame/chapter1/index.html
2026-07-23 17:03:26 +03:00

360 lines
18 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nightshade Protocol</title>
<style>
:root { --bg:#0b0e14; --panel:#131824; --panel2:#1a2130; --line:#2a3347; --txt:#c9d2e3; --dim:#7a8699; --amber:#e3b341; --cyan:#4dd0e1; --red:#e06c75; --green:#7ec87e; }
* { box-sizing:border-box; margin:0; }
body { background:var(--bg); color:var(--txt); font:16px/1.5 system-ui,sans-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; }
button:hover { border-color:var(--amber); }
button.primary { background:var(--amber); color:#0b0e14; 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); }
select { background:var(--panel2); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:8px; font-size:14px; }
.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); }
.stack { display:flex; flex-direction:column; gap:8px; }
.row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
.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); }
.back { color:var(--cyan); background:none; border:none; padding:4px 0; font-size:14px; }
#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; color:var(--txt); }
.overlay { position:fixed; inset:0; background:rgba(5,7,12,.93); 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); }
.pill.field { color:var(--amber); }
</style>
</head>
<body>
<div id="app"></div>
<div id="tabs" style="display:none"></div>
<script src="/case.js"></script>
<script>
const $ = s => document.getElementById(s);
const esc = t => t.replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
let session = localStorage.getItem('np_session') || '';
let role = localStorage.getItem('np_role') || '';
let state = null;
let ui = { tab: 'invest', loc: null, person: null, briefed: false, accEv: [], detail: '' };
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); render(); };
}
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 suspectName = id => id === 'general' ? 'Загальне' : CASE.suspects.find(s => s.id === id).name;
// ---------- екрани ----------
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();
const t = { invest: role === 'field' ? renderField : renderAnalyst, notes: renderNotes, board: renderBoard, accuse: renderAccuse }[ui.tab];
t();
}
function renderJoin() {
$('tabs').style.display = 'none';
$('app').innerHTML = `
<div style="margin-top:12vh;text-align:center">
<h1>NIGHTSHADE PROTOCOL</h1>
<p class="dim" style="margin:8px 0 24px">Детектив на двох · Меридіан, 2087</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('np_session', session);
localStorage.setItem('np_role', role);
send({ t: 'join', role });
connect();
}
function renderBriefing() {
$('app').innerHTML = `
<div class="overlay"><div class="inner">
<h1>СПРАВА: NIGHTSHADE PROTOCOL</h1>
<p style="margin:14px 0">${CASE.intro}</p>
<h2>Твоя роль: ${CASE.roles[role].name}</h2>
<p>${CASE.roles[role].desc}</p>
<h2>Як грати</h2>
<p>${CASE.howto}</p>
<p style="margin-top:10px" class="dim">Мета: разом висунути звинувачення — хто, яким способом, і які докази це доводять.</p>
<button class="primary" style="margin-top:20px;width:100%" onclick="ui.briefed=true;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">NIGHTSHADE</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 => `<button onclick="ui.loc='${l.id}';ui.detail='';render()"><b>${l.name}</b><br><span class="dim">${l.desc}</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>${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(); }
ui.detail = evCard(sp.ev, CASE.evidence[sp.ev], true);
send({ t: 'find', id: sp.ev, by: role });
render();
}
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><h2>${p.name}</h2><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 (!unlocked(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); }
ui.detail = evCard(q.ev, CASE.evidence[q.ev], true);
send({ t: 'find', id: q.ev, by: role });
render();
window.scrollTo(0, 1e6);
}
function showQ(qid) {
const q = findQ(qid);
ui.detail = evCard(q.ev, CASE.evidence[q.ev], false);
render();
window.scrollTo(0, 1e6);
}
// ---------- записник ----------
function renderNotes() {
const mine = Object.keys(state.found).filter(id => CASE.evidence[id].role === role);
let html = window._header + `<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}">
<h3>${ev.title} <span class="tag">${ev.tag}</span></h3>
<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>${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></div>
<h2>Підозрювані</h2>` + CASE.suspects.map(s => `<div class="card"><b>${s.name}</b> <span class="tag">${s.role}</span><p class="dim">${s.desc}</p></div>`).join('');
$('app').innerHTML = html;
}
// ---------- дошка ----------
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 (!pins.length && g === 'general') continue;
if (g !== 'general') {
const s = CASE.suspects.find(x => x.id === g);
html += `<h3>${s.name} <span class="tag">${s.role}</span></h3>`;
if (!pins.length) { html += `<p class="dim">— нічого не закріплено</p>`; continue; }
} else html += `<h3>Загальне</h3>`;
for (const p of pins) {
const ev = CASE.evidence[p.id];
html += `<div class="card ev ${ev.role}">
<h3>${ev.title} <span class="tag">${ev.tag}</span> <span class="pill ${ev.role}">${ev.role === 'field' ? 'польовий' : 'аналітик'}</span></h3>
<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 (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}</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></span></label>`;
}
html += `</div><button class="primary" style="width:100%;margin-top:16px" 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 (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"><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('np_session');location.reload()">Нова гра</button>
</div></div>`;
}
// ---------- старт ----------
if (session && role) connect(); else render();
</script>
</body>
</html>