This commit is contained in:
UndecimoDia 2026-08-15 18:04:59 +03:00
parent 2c887ca451
commit cb93544793
5 changed files with 336 additions and 78 deletions

View file

@ -48,11 +48,20 @@
const p = panels.db;
p.innerHTML = `
<h3>База мешканців району</h3>
<p class="dim small">Пошук за іменем: досить початку «шев» знайде «Шевченко», регістр не важить. Кілька слів звужують (ім'я + прізвище). Значення для фільтрів беріть зі знайдених слідів і вводьте самі. Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).</p>
<p class="dim small">Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).</p>
<!-- Два незалежні запити, а не один із додатковими полями: пошук за іменем
і пошук за прикметами не поєднуються й перебивають один одного. Тому
вони рознесені підписами і рискою раніше два рядки полів підряд
читались як одна форма з кнопкою «Знайти» вгорі. -->
<div class="dbsec">Пошук за іменем</div>
<p class="dim small">Досить початку «шев» знайде «Шевченко», регістр не важить. Кілька слів звужують (ім'я + прізвище).</p>
<div class="filters">
<input id="q-name" placeholder="Ім'я Прізвище" style="min-width:220px">
<button id="q-go" class="primary">Знайти</button>
</div>
<div class="dbsplit"></div>
<div class="dbsec">Пошук за прикметами</div>
<p class="dim small">Значення беріть зі знайдених слідів і вводьте самі. Порожні поля не враховуються.</p>
<div class="filters">
<select id="f-sex"><option value="">стать: будь-яка</option><option value="ч">чол.</option><option value="ж">жін.</option></select>
<input id="f-smin" type="number" placeholder="взуття від" min="35" max="46">
@ -66,6 +75,12 @@
</div>
<div id="db-out"></div>`;
dbOut = p.querySelector('#db-out');
// Enter шукає / фільтрує — щоб не тягнутись мишею до кнопки на кожен із
// десятків пошуків за гру. Числові поля фільтрів теж: набрав розмір взуття,
// натиснув Enter. У <select> потреби немає — вони й так змінюються з клавіатури.
submitOn(p.querySelector('#q-name'), p.querySelector('#q-go'));
for (const id of ['#f-smin', '#f-smax', '#f-hmin', '#f-hmax'])
submitOn(p.querySelector(id), p.querySelector('#f-go'));
p.querySelector('#q-go').onclick = () => {
const q = p.querySelector('#q-name').value.trim();
if (!q) { lastRows = null; dbOut.innerHTML = '<p class="dim">— Введіть ім\'я або прізвище.</p>'; return; }
@ -161,7 +176,7 @@
if (res.length) html += '<div class="syshead">Мешканці</div>' + res.map(c => `<div class="small">${c.home}: ${esc(c.name)}</div>`).join('');
if (work.length) html += '<div class="syshead">Працюють</div>' + work.map(c => `<div class="small">${esc(c.name)}${esc(c.job)}</div>`).join('');
html += `<div class="syshead">Зараз тут</div><div class="small dim">${here.length ? here.map(c => esc(c.name)).join(', ') : 'нікого не зафіксовано'}</div>`;
openModal(html);
openModal(html, { key: 'building:' + b.id });
}
// ---------- Системи-форми ----------
@ -456,7 +471,10 @@
const mm = openModal(`<h3>Тріангуляція номера ${esc(data.number)}</h3>
<p class="dim small">Три вежі, три відстані до апарата станом на зараз. Клацніть будинок, що лежить на перетині всіх трьох дуг.</p>
<canvas id="trcv"></canvas>
<div id="trreads" class="small" style="margin-top:6px"></div>`);
<div id="trreads" class="small" style="margin-top:6px"></div>`,
// Прилітає у відповідь на запит до оператора: два натискання «Тріангуляція»
// дали б дві мапи одна на одній.
{ key: 'triangulate' });
const cv = mm.inner.querySelector('#trcv'); cv.id = 'cv';
fitCanvas(cv);
const ctx = cv.getContext('2d');

View file

@ -162,6 +162,88 @@ function wireDraft(input, key) {
noteDraft[key] = input.value;
});
}
// Які нотатки зараз відкриті на правку. Дошка перемальовується на кожен снапшот
// стану, тож цей прапорець мусить жити ПОЗА рендером — інакше будь-яка дія
// напарника згортала б поле просто під пальцями.
const noteEditing = new Set();
// Рядок нотатки на дошці: текст, автор, ✎ і видалення. Один рендер на два місця —
// спільні нотатки у вкладці й точкові на картці доказу, — щоб вони не розійшлися,
// як свого часу розійшлися дві копії пошуку по базі.
// compact — вигляд для картки доказу (рядок) проти вкладки «Нотатки» (картка);
// draftKey — власний ключ чернетки, НЕ спільний із полем нової нотатки;
// onSave(txt) / onDel() — хто саме шле повідомлення, вирішує викличне місце.
// Правка йде на місці: сервер міняє лише txt, тож нотатка лишається там, де була.
function noteRow(n, { compact, draftKey, onSave, onDel }) {
const author = n.by === "field" ? "польовий" : "аналітик";
const row = el("div", compact ? "small dim" : "card");
if (noteEditing.has(n.id)) {
const inp = el(compact ? "input" : "textarea", "small");
if (!compact) inp.rows = 2;
inp.style.cssText = "width:100%";
inp.value = noteDraft[draftKey] ?? n.txt;
wireDraft(inp, draftKey);
const finish = () => {
noteEditing.delete(n.id);
delete noteDraft[draftKey];
};
const save = () => {
const txt = inp.value.trim();
const changed = txt && txt !== n.txt;
finish();
if (changed) onSave(txt);
renderSide();
};
const cancel = () => {
finish();
renderSide();
};
inp.onkeydown = (e) => {
// У textarea Shift+Enter лишається переносом рядка — зберігає лише чистий Enter.
if (e.key === "Enter" && (compact || !e.shiftKey)) {
e.preventDefault();
save();
}
// Esc скасовує правку і далі не йде: глобальний обробник інакше просто
// зняв би фокус, і поле лишилось би відкритим без причини.
if (e.key === "Escape") {
e.stopPropagation();
cancel();
}
};
const ok = el("button", "small", "Зберегти");
ok.onclick = save;
const no = el("button", "small", "Скасувати");
no.onclick = cancel;
row.append(inp, ok, no);
return row;
}
if (compact)
row.append(el("span", "", `${esc(n.txt)} `), el("span", "tag", author));
else row.append(el("div", "", esc(n.txt)), el("div", "small dim", author));
const ed = el("button", "small", "✎");
ed.title = "редагувати нотатку";
ed.onclick = () => {
noteEditing.add(n.id);
renderSide();
// Перемальовка щойно замінила вузол — фокус ставимо на новий, інакше
// гравцеві довелося б клацати ще раз у поле, яке він сам щойно відкрив.
const f = $(`#sidebody [data-draft-key="${CSS.escape(draftKey)}"]`);
if (f) {
f.focus();
f.setSelectionRange(f.value.length, f.value.length);
}
};
const del = el("button", "small", compact ? "×" : "видалити");
del.title = "видалити нотатку";
del.onclick = onDel;
row.append(ed, del);
return row;
}
$("#sidetabs").onclick = (e) => {
const b = e.target.closest("button");
if (!b) return;
@ -180,16 +262,20 @@ const EV_MARKS = [
["cross", "викреслити", null],
["hide", "сховати", null],
];
// Порядок кольорів — це порядок кнопок. Замість синього тут фіолетовий: синій
// (#5b8dbf) і зелений (#5aa870) майже збігалися за яскравістю, і на кольоровій
// лівій грані рядка — смужці 4px — розрізнити їх було майже неможливо.
const PEOPLE_MARKS = [
["green", null, "var(--mk-green)"],
["red", null, "var(--mk-red)"],
["blue", null, "var(--mk-blue)"],
["violet", null, "var(--mk-violet)"],
["amber", null, "var(--mk-amber)"],
["hide", "сховати", null],
];
const MARK_GLYPH = { tick: "✓", cross: "✗", hide: "…" };
// Сейви зі старих версій тримають на людях ✓/✗ — читаємо їх як два перші кольори.
const LEGACY_PERSON_MARK = { tick: "green", cross: "red" };
// Сейви зі старих версій тримають на людях ✓/✗ — читаємо їх як два перші кольори,
// а колишній синій показуємо як фіолетовий, що прийшов йому на зміну.
const LEGACY_PERSON_MARK = { tick: "green", cross: "red", blue: "violet" };
const isPersonKey = (key) => String(key).startsWith("c:");
function markOf(key) {
@ -343,18 +429,16 @@ function renderSide() {
}
c.append(markButtons(key));
const notes = (App.state.board.enotes || {})[e.id] || [];
for (const n of notes) {
const row = el(
"div",
"small dim",
`${esc(n.txt)} <span class="tag">${n.by === "field" ? "польовий" : "аналітик"}</span>`,
for (const n of notes)
c.append(
noteRow(n, {
compact: true,
draftKey: "edit:" + n.id,
onSave: (txt) =>
App.send({ t: "enote_edit", evId: e.id, id: n.id, txt }),
onDel: () => App.send({ t: "enote_del", evId: e.id, id: n.id }),
}),
);
const d = el("button", "small", "×");
d.title = "видалити нотатку";
d.onclick = () => App.send({ t: "enote_del", evId: e.id, id: n.id });
row.append(d);
c.append(row);
}
const ni = el("input", "small");
ni.placeholder = "нотатка до доказу…";
ni.style.cssText = "width:100%;margin-top:4px";
@ -428,17 +512,15 @@ function renderSide() {
}
};
body.append(ta, add);
for (const n of [...App.state.board.notes].reverse()) {
const c = el("div", "card");
c.append(
el("div", "", esc(n.txt)),
el("div", "small dim", n.by === "field" ? "польовий" : "аналітик"),
for (const n of [...App.state.board.notes].reverse())
body.append(
noteRow(n, {
compact: false,
draftKey: "edit:" + n.id,
onSave: (txt) => App.send({ t: "note_edit", id: n.id, txt }),
onDel: () => App.send({ t: "note_del", id: n.id }),
}),
);
const d = el("button", "small", "видалити");
d.onclick = () => App.send({ t: "note_del", id: n.id });
c.append(d);
body.append(c);
}
}
// Повернути фокус і курсор у поле, де друкували до перемальовки, — але лише
@ -478,7 +560,10 @@ function showDossier(c) {
<tr><th>Верхній одяг</th><td>${esc(c.coat)}</td></tr>
<tr><th>Звички</th><td>${c.smoker ? "курить; " : ""}${c.dog ? "собака " + esc(c.dog) + "; " : ""}${c.tattoo ? "тату: " + esc(c.tattoo) : ""}</td></tr>
<tr><th>ДНК-профіль</th><td>${dnaCells}</td></tr>
</table>`);
</table>`,
// Ключ із id: досьє іншої людини — це інша модалка, а не дубль цієї.
{ key: "dossier:" + c.id },
);
if (App.state && App.state.warrants.includes(c.id))
m.inner.append(el("div", "small", "📜 Ордер на обшук житла — отримано"));
m.inner.append(logSection(c.id));
@ -549,30 +634,66 @@ function matchName(citizens, query) {
});
}
// Enter у полі = натиснути кнопку поруч. Поля пошуку лежать у <div class="filters">,
// а не у <form>, тож неявного сабміту браузер не дає — доводиться вішати руками.
// Хелпер спільний, бо це вже четверте місце з тим самим рядком, а база мешканців
// існує у двох копіях (аналітик і польовий), які одного разу вже розійшлися.
const submitOn = (input, button) => {
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
button.click();
}
});
};
// ---------- Модалки ----------
// Кроки більше не заміщають один одного: батьківська модалка лишається під
// поточною, тож скасування picker'а повертає до розмови, а не в порожнечу.
// opts.back — явне «← Назад» там, де попередній крок треба перезібрати
// (порожня тека ордера, шортліст лабораторії); opts.onClose — прибирання за собою.
//
// opts.key робить відкриття ІДЕМПОТЕНТНИМ: повторний клік по тій самій кнопці
// замінює наявний шар, а не кладе ще один зверху. Раніше N кліків по «Справа»
// давали N однакових модалок стосом — і оскільки .modal це fixed/inset:0, вони
// накладались ідеально й читались як панель, що не закривається.
// Ключ — це КОНКРЕТНА ціль ('unit:C8', 'talk:c23'), а не тип панелі: інакше
// перехід «квартира A2 → квартира A3» затирав би сам себе. Кроки одного ланцюга
// (відділок → ордер → вибір підстави) ключів не мають зовсім — там стос
// навмисний, на ньому тримається «← Назад».
function openModal(html, opts = {}) {
// Заміна, а не закриття: onClose тут не кличемо свідомо — у деяких місцях він
// прибирає БАТЬКІВСЬКУ модалку (шортліст лабораторії), і повторний клік
// рознiс би ланцюг, який гравець не чіпав.
if (opts.key) {
const old = document.querySelector(`.modal[data-key="${CSS.escape(opts.key)}"]`);
if (old) old.remove();
}
const wrap = el("div", "modal");
if (opts.key) wrap.dataset.key = opts.key;
// Позначка для Esc: locked-модалки (мінігра вилучення) мають власний вихід
// «Відкласти» — клавіатурою їх не обходять.
if (opts.locked) wrap.dataset.locked = "1";
const inner = el("div", "", html);
wrap.append(inner);
const close = () => {
wrap.remove();
if (opts.onClose) opts.onClose();
};
const goBack = opts.back
? () => {
wrap.remove();
opts.back();
}
: null;
if (!opts.locked) {
wrap.onclick = (e) => {
if (e.target === wrap) close();
};
const foot = el("div", "modalfoot");
if (opts.back) {
if (goBack) {
const bk = el("button", "small", "← Назад");
bk.onclick = () => {
wrap.remove();
opts.back();
};
bk.onclick = goBack;
foot.append(bk);
}
const x = el("button", "small", "Закрити");
@ -583,10 +704,44 @@ function openModal(html, opts = {}) {
// Мікрозадача переносить кнопки в кінець, коли всі синхронні append'и минули.
queueMicrotask(() => inner.append(foot));
}
// Хендли на самому вузлі: глобальний Esc знаходить модалку в DOM і мусить
// закрити її тим самим шляхом, що й кнопка, — інакше загубиться onClose.
wrap.__close = close;
wrap.__back = goBack;
document.body.append(wrap);
return { wrap, inner, close };
}
// Прибрати модалку за ключем ззовні: стан прийшов — чернетку звинувачення
// відкликано, справу закрито. Заміняє ручні змінні-сингтони, які раніше
// тримали посилання на вузол лише заради цього.
const closeModalByKey = (key) => {
const m = document.querySelector(`.modal[data-key="${CSS.escape(key)}"]`);
if (m) m.remove();
};
// Esc закриває ВЕРХНЮ модалку — саме верхню, бо стос навмисний: жест, який
// здуває весь ланцюг, зруйнував би навігацію «← Назад» так само, як її зруйнував
// би глобальний сингтон замість ключів.
addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
// Перший Esc із поля вводу лише звільняє поле — клавіатура дорожча за модалку:
// у польового це аварійне повернення керування району (той самий жест у
// field.js), а для аналітика, де того обробника немає, знімаємо фокус тут.
// Модалку закриє наступний Esc — уже «чистий».
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) {
t.blur();
return;
}
const open = document.querySelectorAll(".modal:not([data-locked])");
const top = open[open.length - 1];
if (!top) return;
e.preventDefault();
// Є «← Назад» — Esc робить крок назад, а не викидає з усього ланцюга.
(top.__back || top.__close)();
});
function showIntro() {
const i = App.init.intro;
const fieldHow = `
@ -610,11 +765,14 @@ function showIntro() {
<li><b>Лабораторія:</b> ДНК і відбитки порівнюєте вручну до 6 профілів за раз. Шифри теж тут. Увага: ДНК каже лише, ЧИЯ вона, не коли й чому лишена. На місці буває ДНК людей, що не мають стосунку до злочину: збіг привід для допиту, а не вирок.</li>
<li>Клік по порожньому місцю мапи мітка для напарника.</li>
</ul>`;
openModal(`<h3>${esc(i.title)}: ${esc(i.scene)}, ~${esc(i.time)}</h3><p>${esc(i.text)}</p>
openModal(
`<h3>${esc(i.title)}: ${esc(i.scene)}, ~${esc(i.time)}</h3><p>${esc(i.text)}</p>
<p class="dim small">Роль: ${App.role === "field" ? "польовий детектив — район, обшуки, розмови" : "аналітик — бази, камери, лабораторія"}.</p>
${App.role === "field" ? fieldHow : analystHow}
<div class="syshead">Фінал</div>
<p class="small">Одне звинувачення на двох: ХТО (ім'я з клавіатури) / ЯК чи ДЕ / ЧОМУ, підписи обох. Прокурор дивиться лише на закріплені 📌 докази: потрібне підтвердження особи (ДНК, відбиток або зламане алібі), мотив і спосіб. Нотатки спільні: загальні у вкладці, точкові прямо на картці доказу.</p>`);
<p class="small">Одне звинувачення на двох: ХТО (ім'я з клавіатури) / ЯК чи ДЕ / ЧОМУ, підписи обох. Прокурор дивиться лише на закріплені 📌 докази: потрібне підтвердження особи (ДНК, відбиток або зламане алібі), мотив і спосіб. Нотатки спільні: загальні у вкладці, точкові прямо на картці доказу.</p>`,
{ key: "intro" },
);
}
$("#introbtn").onclick = showIntro;
@ -638,7 +796,11 @@ $("#accusebtn").onclick = () => {
<p>${isK ? "ДЕ утримують:" : "ЯК / ЧИМ:"}<br><select id="ac-how" style="width:100%">${o.methods.map((x) => `<option>${esc(x)}</option>`).join("")}</select></p>
<p>ЧОМУ:<br><select id="ac-why" style="width:100%">${o.motives.map((x) => `<option>${esc(x)}</option>`).join("")}</select></p>
<p>Закріплено доказів: <b>${st.board.pins.length}</b></p>
<button class="danger" id="ac-go">Подати на підпис напарнику</button>`);
<button class="danger" id="ac-go">Подати на підпис напарнику</button>`,
// Спільний ключ із модалкою статусу: щойно з'явиться чернетка, форма
// сама поступиться їй місцем, а не ляже під нею другим шаром.
{ key: "accuse" },
);
m.inner.querySelector("#ac-go").onclick = () => {
const who = m.inner.querySelector("#ac-who").value.trim();
if (!who) return toast("Впишіть ім'я.");
@ -652,11 +814,9 @@ $("#accusebtn").onclick = () => {
};
};
let accuseModal = null;
function showAccuseStatus() {
const d = App.state.accuseDraft;
if (accuseModal) accuseModal.wrap.remove();
accuseModal = openModal(`
const m = openModal(`
<h3>${App.init.caseType === "kidnap" ? "Рапорт на штурм — на підписі" : "Звинувачення на підписі"}</h3>
<table>
<tr><th>ХТО</th><td>${esc(d.who)}</td></tr>
@ -666,17 +826,19 @@ function showAccuseStatus() {
<p class="small">Підписано: ${d.signed} з ${d.need}</p>
<p class="dim small">Потрібен підпис КОЖНОГО гравця і вироку вже не відкликати.</p>
<button class="danger" id="ac-sign">Підписати</button>
<button id="ac-cancel">Відкликати</button>`);
accuseModal.inner.querySelector("#ac-sign").onclick = () =>
<button id="ac-cancel">Відкликати</button>`,
{ key: "accuse" },
);
m.inner.querySelector("#ac-sign").onclick = () =>
App.send({ t: "accuse_confirm" });
accuseModal.inner.querySelector("#ac-cancel").onclick = () =>
m.inner.querySelector("#ac-cancel").onclick = () =>
App.send({ t: "accuse_cancel" });
}
// Статус перезбирається на кожен снапшот (змінилася кількість підписів), тож
// стару модалку прибираємо явно: ключ сам по собі замінив би її лише в момент
// відкриття нової, а тут буває й відкликання — коли нову не відкривають зовсім.
App.on("state", () => {
if (accuseModal) {
accuseModal.wrap.remove();
accuseModal = null;
}
closeModalByKey("accuse");
if (App.state.accuseDraft && !App.state.ended) showAccuseStatus();
});
@ -687,10 +849,10 @@ function renderEnd() {
if (!e || endShown) return;
endShown = true;
if (window.Snd) Snd.sting(!!e.solved);
if (accuseModal) {
accuseModal.wrap.remove();
accuseModal = null;
}
closeModalByKey("accuse");
// endShown лишається: він гасить не подвійний клік, а повторний показ фіналу
// на кожен наступний снапшот — ключ від цього не рятує, бо модалку щоразу
// відкривали б наново.
openModal(
`
<div id="endscreen">
@ -699,6 +861,6 @@ function renderEnd() {
<p>${esc(e.recap)}</p>
<p class="dim small">${e.solved ? "Новий код сесії — цілком нова справа." : "Справжню картину відновив ваш наступник. Новий код сесії — нова спроба в новій справі."}</p>
</div>`,
{ locked: false },
{ key: "end" },
);
}

View file

@ -302,10 +302,13 @@
function stationModal() {
// Відділок лишається під усім, що з нього відкривають: і база, і ордер —
// це кроки, з яких треба вміти повернутись, а не заміщення екрана.
const m = openModal(`<h3>Відділок поліції</h3>
const m = openModal(
`<h3>Відділок поліції</h3>
<p class="dim small">Ваша база: дошка справи і термінал аналітика синхронізовані. Тут-таки сидить прокурор ордер на обшук житла подають особисто, з доказом, який прямо вказує на людину.</p>
<button class="primary" id="warr">📜 Подати на ордер</button>
<button id="db">🗂 База мешканців</button>`);
<button id="db">🗂 База мешканців</button>`,
{ key: "station" },
);
m.inner.querySelector("#db").onclick = residentsModal;
m.inner.querySelector("#warr").onclick = warrantFlow;
}
@ -350,9 +353,14 @@
const coats = [...new Set(App.init.citizens.map((c) => c.coat))];
const m = openModal(`<h3>База мешканців району</h3>
<p class="dim small">Термінал аналітика, тільки перегляд. Кольорові мітки ставить аналітик тут вони лише видно; що означає кожен колір, домовляєтесь ви двоє. Реєстр місцями застарілий: одяг і взуття надійніше звіряти очним оглядом за ордером.</p>
<!-- Той самий поділ, що й у базі аналітика: два незалежні запити, які
перебивають один одного, а не одна форма. Два екрани один вигляд. -->
<div class="dbsec">Пошук за іменем</div>
<div class="filters">
<input id="rq" placeholder="Ім'я Прізвище" style="min-width:200px"><button id="rqgo">Знайти</button>
</div>
<div class="dbsplit"></div>
<div class="dbsec">Пошук за прикметами</div>
<div class="filters">
<select id="rf-sex"><option value="">стать: будь-яка</option><option value="ч">чол.</option><option value="ж">жін.</option></select>
<input id="rf-smin" type="number" placeholder="взуття від" min="35" max="46">
@ -364,7 +372,9 @@
<label class="small dim"><input type="checkbox" id="rf-smoke"> курить</label>
<button id="rfgo">Фільтрувати</button>
</div>
<div id="rout"><p class="dim small">Задайте пошук або фільтр.</p></div>`);
<div id="rout"><p class="dim small">Задайте пошук або фільтр.</p></div>`,
{ key: "db" },
);
const out = m.inner.querySelector("#rout");
const draw = (rows) => {
out.innerHTML = `<p class="dim small">Збігів: ${rows.length}</p>`;
@ -430,6 +440,10 @@
};
m.inner.querySelector("#rqgo").onclick = byName;
m.inner.querySelector("#rfgo").onclick = byFilter;
// Той самий Enter, що й у базі аналітика: два екрани — одна поведінка.
submitOn(m.inner.querySelector("#rq"), m.inner.querySelector("#rqgo"));
for (const id of ["#rf-smin", "#rf-smax", "#rf-hmin", "#rf-hmax"])
submitOn(m.inner.querySelector(id), m.inner.querySelector("#rfgo"));
}
// одиночний вибір мешканця з фільтром за іменем.
@ -494,7 +508,8 @@
.join("") +
"</div>";
}
const m = openModal(html);
// Ключ із id будівлі: зайшов у сусідній під'їзд — це інша модалка, не дубль.
const m = openModal(html, { key: "building:" + b.id });
m.inner.onclick = (e) => {
const t = e.target;
if (t.dataset.spot != null)
@ -518,7 +533,9 @@
const m = openModal(`<h3>${esc(b.name)} — кв. ${unit}</h3>
<p class="dim small">${home.length ? "Мешканці: " + home.map((c) => esc(c.name)).join(", ") : "За реєстром тут ніхто не живе."}</p>
<button id="knock">Постукати</button>
<button id="enter">Увійти${App.state.keys.includes(unit) ? " 🔑" : ""}</button>`);
<button id="enter">Увійти${App.state.keys.includes(unit) ? " 🔑" : ""}</button>`,
{ key: "door:" + unit },
);
// Двері лишаються на екрані: «Замкнено» тепер не викидає в порожнечу —
// тост прилітає, а спробувати постукати чи піти назад можна одразу.
m.inner.querySelector("#knock").onclick = () => {
@ -537,9 +554,15 @@
App.state.locs[c.id] === unitB(m.unit) &&
c.id !== App.state.victim,
);
const mm = openModal(`<h3>Кв. ${m.unit} — обшук</h3>
// Тут подвоєння приходить не з рендера, а з мережі: два кліки «Увійти» —
// два повідомлення — дві відповіді. Гард на кнопці цього не покрив би,
// тому ключ стоїть саме в місці створення модалки.
const mm = openModal(
`<h3>Кв. ${m.unit} — обшук</h3>
${inside.length ? '<p class="dim small">Вдома: ' + inside.map((c) => `<button data-cid="${c.id}" class="small">${esc(c.name)}</button>`).join(" ") + "</p>" : ""}
<div class="grid">${m.slots.map((s, i) => `<button data-slot="${i}">${esc(s)}</button>`).join("")}</div>`);
<div class="grid">${m.slots.map((s, i) => `<button data-slot="${i}">${esc(s)}</button>`).join("")}</div>`,
{ key: "unit:" + m.unit },
);
mm.inner.onclick = (e) => {
if (e.target.dataset.slot != null)
App.send({
@ -577,8 +600,9 @@
// Розмова — це цикл «питання → відповідь → наступне питання», а не одноразовий
// крок. Модалка розмови завжди одна: сервер віддає теми у відповідь на `talk`,
// і кожна нова відповідь просто перемальовує її разом із журналом.
let talkModal = null;
// і кожна нова відповідь просто перемальовує її разом із журналом. Одиничність
// тримає ключ 'talk' — без id, бо перехід до іншої людини теж має замінювати
// вікно, а не класти друге поверх.
let flashCid = null; // до кого прилетіла свіжа відповідь — підсвітити останній запис
App.on("topics", (m) => {
@ -600,11 +624,6 @@
.join("") +
'</div><p class="dim small">Сірі теми вже в доказах на дошці.</p>'
: "";
if (talkModal) {
const old = talkModal;
talkModal = null; // прибираємо самі — onClose не має скидати нову модалку
old.wrap.remove();
}
const mm = openModal(
`<h3>${esc(c.name)}</h3>` +
topicsHtml +
@ -621,9 +640,8 @@
</div>
<p class="dim small">Відповіді можуть бути правдою, брехнею або «не знаю» перевіряйте камерами і чужими словами. Модалка лишається відкритою: питайте далі, «Закрити» завершує розмову.</p>` +
gossip,
{ onClose: () => { if (talkModal === mm) talkModal = null; } },
{ key: "talk" },
);
talkModal = mm;
mm.inner.append(logSection(m.cid)); // журнал із мітками (спільний із досьє аналітика)
// Свіжа відповідь лежить останнім записом журналу — прокручуємо й підсвічуємо,
// інакше її не видно за темами й плітками.
@ -684,7 +702,9 @@
// Відкривається поверх розмови, «Закрити» повертає до співрозмовника.
App.on("inspect", (m) => {
const c = App.byId(m.cid);
openModal(`<h3>Очний огляд: ${esc(c.name)}</h3><p>${esc(m.body)}</p>`);
openModal(`<h3>Очний огляд: ${esc(c.name)}</h3><p>${esc(m.body)}</p>`, {
key: "inspect:" + m.cid,
});
});
// Відповідь допитуваного. Сироту-модалку не відкриваємо: просимо теми ще раз —
@ -708,7 +728,10 @@
<button class="primary" id="mgfix">Зафіксувати</button>
<button id="mgoff">Відкласти</button>
<span class="dim small" id="mgmsg"></span>`,
{ locked: true },
// locked — власний вихід «Відкласти», Esc сюди не лізе. Ключ на випадок,
// якщо сервер надішле вилучення двічі: друга спроба замінить першу,
// а не запустить другий таймер поверх.
{ locked: true, key: "mg" },
);
const q = (sel) => mm.inner.querySelector(sel);
const zone = q("#mgzone"),

View file

@ -10,7 +10,9 @@
--txt:#cfd6df; --dim:#7d8794; --acc:#e8b04b; --red:#c94f4f; --green:#5aa870; --blue:#5b8dbf;
--paper:#e9ddc0; --paper2:#dcCfae; --ink:#282218; --inkdim:#6f6450; --paperline:#c3b28b;
--amber:#ffb454; --amberdim:#8a6a34; --crt:#0e0903;
--mk-green:#5aa870; --mk-red:#c94f4f; --mk-blue:#5b8dbf; --mk-amber:#e8b04b;
/* Фіолетовий замість синього: #5b8dbf і зелений #5aa870 мали майже однакову
яскравість, і на 4px смужці лівої грані рядка різниця не читалася зовсім. */
--mk-green:#5aa870; --mk-red:#c94f4f; --mk-violet:#a25ce0; --mk-amber:#e8b04b;
--noise:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .05 0'/></filter><rect width='140' height='140' filter='url(%23n)'/></svg>");
}
* { box-sizing:border-box; margin:0; }
@ -168,7 +170,7 @@
tr[class*="mk-"] > td:first-child { border-left:4px solid transparent; padding-left:6px; }
tr.mk-green > td:first-child { border-left-color:var(--mk-green); }
tr.mk-red > td:first-child { border-left-color:var(--mk-red); }
tr.mk-blue > td:first-child { border-left-color:var(--mk-blue); }
tr.mk-violet> td:first-child { border-left-color:var(--mk-violet); }
tr.mk-amber > td:first-child { border-left-color:var(--mk-amber); }
.dot { display:inline-block; width:10px; height:10px; border-radius:50%; vertical-align:-1px;
box-shadow:0 0 0 1px rgba(0,0,0,.45); }
@ -183,6 +185,12 @@
#cc-cam { max-width:min(100%, 460px); }
.syshead { color:var(--dim); text-transform:uppercase; font-size:12px; letter-spacing:2px; margin:14px 0 6px; }
.syshead::before { content:'— '; opacity:.5; }
/* База мешканців: пошук за іменем і пошук за прикметами — два незалежні запити,
які перебивають один одного, а не одна форма з двома рядами полів. Риска й
підписи розводять їх; стилі спільні для обох ролей, бо екран той самий. */
.dbsec { color:var(--dim); text-transform:uppercase; font-size:11px; letter-spacing:1.6px; margin:12px 0 4px; }
.dbsplit { height:1px; background:var(--line); margin:16px 0 2px; }
.role-analyst #stage .dbsec { color:var(--amber); opacity:.7; }
/* ---------- Модалки: паперовий протокол ---------- */
.modal { position:fixed; inset:0; background:rgba(2,4,8,.72); display:flex; align-items:center; justify-content:center; z-index:40;
@ -198,6 +206,9 @@
text-transform:uppercase; letter-spacing:1.5px; font-size:14px; }
.modal .dim, .modal .small.dim { color:var(--inkdim); }
.modal .syshead { color:#7a1f1f; opacity:.8; }
/* База польового живе в модалці — на папері, а не на панелі: свої чорнило й лінія. */
.modal .dbsec { color:#7a1f1f; opacity:.75; }
.modal .dbsplit { background:var(--paperline); }
.modal button { background:#d9cba6; color:var(--ink); border:1px solid #b3a077; border-radius:2px; }
.modal button:hover { border-color:#7a1f1f; }
.modal button.primary { background:#7a1f1f; color:var(--paper); }

View file

@ -18,7 +18,9 @@ let clientSeq = 0; // стабільний id на кожне з'єднання
// Допустимі мітки на дошці. Клієнт малює їх із того ж поділу (public/client.js).
const EV_MARKS = ['tick', 'cross', 'hide']; // докази і записи журналу
const PERSON_MARKS = ['green', 'red', 'blue', 'amber', 'hide']; // люди: кольори без нав'язаного значення
// Люди: кольори без нав'язаного значення. 'blue' лишається в списку лише заради
// старих сейвів — нові клієнти шлють 'violet' (синій надто злипався з зеленим).
const PERSON_MARKS = ['green', 'red', 'violet', 'amber', 'hide', 'blue'];
// Присутність зараз (не плутати з evening — правдою вечора злочину): вдома або на роботі.
// Перетасовується, коли польовий заходить у будівлю, — крім будівлі, куди він зайшов.
@ -54,6 +56,12 @@ function getSession(code) {
board: { pins: [], marks: {}, notes: [], enotes: {} }, // marks: {'e:id'|'c:id': 'tick'|'cross'|'hide'}
spawn: { x: stn.pos.x + stn.pos.w / 2, y: stn.pos.y + stn.pos.h + 15 }, // точка появи у відділку
extra: [], xn: 0, // динамічні картки (результати запитів, зізнання)
// Наскрізний порядок дошки: id карток обох видів у тій послідовності, в якій
// вони на дошці з'явились. Раніше view() зшивав два незалежні масиви
// (st.found + extra), а клієнт розвертав шов як одну хронологію — через це
// будь-який результат запиту назавжди опинявся вище будь-якої фізичної
// знахідки, і нова знахідка після пачки камер лягала десь усередині списку.
order: [],
log: {}, // журнал допитів: cid → [{id, q, a}]
logN: 0, // лічильник id записів журналу (для міток на них)
alibiClaims: {}, // cid → building: що людина заявила про вечір злочину
@ -83,7 +91,7 @@ function getSession(code) {
} catch {}
}
if (!loaded) {
for (const id of cs.startingFacts) RT.applyFound(cs, s.st, cs.evidence[id]);
for (const id of cs.startingFacts) { RT.applyFound(cs, s.st, cs.evidence[id]); noteOrder(s, id); }
saveNow(s); // синхронно: файл має існувати одразу, до першого throttle-запису
}
return sessions[code] = s;
@ -96,8 +104,8 @@ function getSession(code) {
// непарсабельного) JSON.
const SAVE_MS = 300;
function saveData(s) {
const { st, board, accuseDraft, ended, extra, xn, log, logN, markers, alibiClaims, cracked, mgOffered } = s;
return JSON.stringify({ st, board, accuseDraft, ended, extra, xn, log, logN, markers, alibiClaims, cracked, mgOffered });
const { st, board, accuseDraft, ended, extra, xn, order, log, logN, markers, alibiClaims, cracked, mgOffered } = s;
return JSON.stringify({ st, board, accuseDraft, ended, extra, xn, order, log, logN, markers, alibiClaims, cracked, mgOffered });
}
// Синхронний запис на диск — для першого сейву щойно створеної сесії. Інакше файл
// з'явиться лише через SAVE_MS, і другий гравець, що зайшов раніше, тригерне
@ -156,6 +164,15 @@ function view(s) {
// Динамічні картки (результати запитів) тега не мають — даємо дефолтний,
// інакше вони випадали б із будь-якого зрізу по чипах.
}).concat(s.extra.map(x => (x.tag ? x : { ...x, tag: 'запит' })));
// Зшиті два списки розкладаємо в справжню хронологію дошки. Клієнт розвертає
// цей масив (найновіше зверху), тож без сортування результати запитів, які
// йдуть у хвості конкатенації, назавжди перекривали фізичні знахідки.
// Картка без місця в порядку (сейв старішої версії) лишається найстарішою —
// тобто на дні дошки, у тому ж відносному порядку, що й досі.
// Ранг мапою, а не indexOf у компараторі: view() ганяється на кожен broadcast,
// тобто на кожну дію кожного гравця.
const rank = new Map((s.order || []).map((id, i) => [id, i]));
found.sort((a, b) => (rank.get(a.id) ?? -1) - (rank.get(b.id) ?? -1));
const killerC = cs.citizens.find(c => c.id === cs.finale.killer);
const dnaOwner = cs.citizens.find(c => c.id === (cs.accomplice || cs.finale.killer));
return {
@ -237,7 +254,14 @@ function validFieldPos(p, cs) {
return { x: p.x | 0, y: p.y | 0 };
}
function reveal(s, ev) { RT.applyFound(s.cs, s.st, ev); }
// Позначити картку в наскрізному порядку дошки. Ідемпотентно: те, що вже лежить
// на дошці, повторний виклик не пересуває.
function noteOrder(s, id) {
if (!s.order) s.order = []; // сейв старішої версії — порядку в ньому немає
if (!s.order.includes(id)) s.order.push(id);
}
function reveal(s, ev) { RT.applyFound(s.cs, s.st, ev); noteOrder(s, ev.id); }
// головоломка розв'язана / нові вимоги виконані → носії відкриваються ланцюжком
function autoUnlockCarriers(s) {
@ -256,7 +280,9 @@ function autoUnlockCarriers(s) {
// pointsTo (опційно) — на кого картка вказує, щоб з неї теж можна було взяти ордер.
function extraCard(s, key, title, detail, pointsTo) {
if (s.extra.some(x => x.key === key)) return;
s.extra.push({ id: 'x' + (++s.xn), key, title, detail, side: 'analyst', ...(pointsTo ? { pointsTo } : {}) });
const id = 'x' + (++s.xn);
s.extra.push({ id, key, title, detail, side: 'analyst', ...(pointsTo ? { pointsTo } : {}) });
noteOrder(s, id); // в один потік із доказами, а не окремим списком у хвіст
}
// На кого вказує доказ (для ордера) — і зі статичних доказів, і з динамічних карток.
@ -391,7 +417,10 @@ function relationOf(z, x) {
function handle(s, ws, m) {
const { cs, st } = s;
const done = () => { autoUnlockCarriers(s); save(s); broadcast(s); };
if (s.ended && !['note', 'note_del', 'enote', 'enote_del', 'mark'].includes(m.t)) return; // справу закрито
// Після вироку дошка лишається доступною «на почитати»: нотатки й мітки живуть далі.
// Правки нотаток — у тому ж списку, що й додавання: інакше виходило б безглуздя —
// дописати після фіналу можна, а виправити в дописаному одрук уже ні.
if (s.ended && !['note', 'note_edit', 'note_del', 'enote', 'enote_edit', 'enote_del', 'mark'].includes(m.t)) return; // справу закрито
switch (m.t) {
case 'search': { // публічна точка: {building, spot}
@ -775,12 +804,27 @@ function handle(s, ws, m) {
s.board.notes.push({ id: Date.now() + Math.random(), txt: m.txt.slice(0, 500), by: ws.role });
return done();
}
// Правка НА МІСЦІ: міняємо лише txt, лишаючи id, автора й позицію в масиві.
// Через це виправлена нотатка не стрибає на верх списку (клієнт малює спільні
// нотатки найновішими зверху) і не ламає хронологію міркувань. Автора теж не
// переписуємо: дошка спільна, і «хто це записав» важить більше, ніж «хто
// виправив одрук у чужому записі».
case 'note_edit': { // {id, txt}
const n = s.board.notes.find(x => x.id === m.id);
if (n && typeof m.txt === 'string' && m.txt.trim()) n.txt = m.txt.slice(0, 500);
return done();
}
case 'note_del': { s.board.notes = s.board.notes.filter(n => n.id !== m.id); return done(); }
case 'enote': { // нотатка на конкретному доказі
if (typeof m.txt !== 'string' || !m.txt.trim()) return;
(s.board.enotes[m.evId] ??= []).push({ id: Date.now() + Math.random(), txt: m.txt.slice(0, 300), by: ws.role });
return done();
}
case 'enote_edit': { // {evId, id, txt}
const n = (s.board.enotes[m.evId] || []).find(x => x.id === m.id);
if (n && typeof m.txt === 'string' && m.txt.trim()) n.txt = m.txt.slice(0, 300);
return done();
}
case 'enote_del': {
const l = s.board.enotes[m.evId];
if (l) s.board.enotes[m.evId] = l.filter(n => n.id !== m.id);