2PlayerGame/chapter1/index.html

361 lines
18 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>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>