diff --git a/detective-game/public/analyst.js b/detective-game/public/analyst.js index 8e80713..ff82dbd 100644 --- a/detective-game/public/analyst.js +++ b/detective-game/public/analyst.js @@ -48,11 +48,20 @@ const p = panels.db; p.innerHTML = `

База мешканців району

-

Пошук за іменем: досить початку — «шев» знайде «Шевченко», регістр не важить. Кілька слів звужують (ім'я + прізвище). Значення для фільтрів беріть зі знайдених слідів і вводьте самі. Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).

+

Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).

+ +
Пошук за іменем
+

Досить початку — «шев» знайде «Шевченко», регістр не важить. Кілька слів звужують (ім'я + прізвище).

+
+
Пошук за прикметами
+

Значення беріть зі знайдених слідів і вводьте самі. Порожні поля не враховуються.

@@ -66,6 +75,12 @@
`; dbOut = p.querySelector('#db-out'); + // Enter шукає / фільтрує — щоб не тягнутись мишею до кнопки на кожен із + // десятків пошуків за гру. Числові поля фільтрів теж: набрав розмір взуття, + // натиснув Enter. У ${o.methods.map((x) => ``).join("")}

ЧОМУ:

Закріплено доказів: ${st.board.pins.length}

- `); + `, + // Спільний ключ із модалкою статусу: щойно з'явиться чернетка, форма + // сама поступиться їй місцем, а не ляже під нею другим шаром. + { 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(`

${App.init.caseType === "kidnap" ? "Рапорт на штурм — на підписі" : "Звинувачення на підписі"}

@@ -666,17 +826,19 @@ function showAccuseStatus() {

Підписано: ${d.signed} з ${d.need}

Потрібен підпис КОЖНОГО гравця — і вироку вже не відкликати.

- `); - accuseModal.inner.querySelector("#ac-sign").onclick = () => + `, + { 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( `
@@ -699,6 +861,6 @@ function renderEnd() {

${esc(e.recap)}

${e.solved ? "Новий код сесії — цілком нова справа." : "Справжню картину відновив ваш наступник. Новий код сесії — нова спроба в новій справі."}

`, - { locked: false }, + { key: "end" }, ); } diff --git a/detective-game/public/field.js b/detective-game/public/field.js index 4264516..072919f 100644 --- a/detective-game/public/field.js +++ b/detective-game/public/field.js @@ -302,10 +302,13 @@ function stationModal() { // Відділок лишається під усім, що з нього відкривають: і база, і ордер — // це кроки, з яких треба вміти повернутись, а не заміщення екрана. - const m = openModal(`

Відділок поліції

+ const m = openModal( + `

Відділок поліції

Ваша база: дошка справи і термінал аналітика синхронізовані. Тут-таки сидить прокурор — ордер на обшук житла подають особисто, з доказом, який прямо вказує на людину.

- `); + `, + { 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(`

База мешканців району

Термінал аналітика, тільки перегляд. Кольорові мітки ставить аналітик — тут вони лише видно; що означає кожен колір, домовляєтесь ви двоє. Реєстр місцями застарілий: одяг і взуття надійніше звіряти очним оглядом за ордером.

+ +
Пошук за іменем
+
+
Пошук за прикметами
@@ -364,7 +372,9 @@
-

Задайте пошук або фільтр.

`); +

Задайте пошук або фільтр.

`, + { key: "db" }, + ); const out = m.inner.querySelector("#rout"); const draw = (rows) => { out.innerHTML = `

Збігів: ${rows.length}

`; @@ -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("") + ""; } - 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(`

${esc(b.name)} — кв. ${unit}

${home.length ? "Мешканці: " + home.map((c) => esc(c.name)).join(", ") : "За реєстром тут ніхто не живе."}

- `); + `, + { 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(`

Кв. ${m.unit} — обшук

+ // Тут подвоєння приходить не з рендера, а з мережі: два кліки «Увійти» — + // два повідомлення — дві відповіді. Гард на кнопці цього не покрив би, + // тому ключ стоїть саме в місці створення модалки. + const mm = openModal( + `

Кв. ${m.unit} — обшук

${inside.length ? '

Вдома: ' + inside.map((c) => ``).join(" ") + "

" : ""} -
${m.slots.map((s, i) => ``).join("")}
`); +
${m.slots.map((s, i) => ``).join("")}
`, + { 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("") + '

Сірі теми вже в доказах на дошці.

' : ""; - if (talkModal) { - const old = talkModal; - talkModal = null; // прибираємо самі — onClose не має скидати нову модалку - old.wrap.remove(); - } const mm = openModal( `

${esc(c.name)}

` + topicsHtml + @@ -621,9 +640,8 @@

Відповіді можуть бути правдою, брехнею або «не знаю» — перевіряйте камерами і чужими словами. Модалка лишається відкритою: питайте далі, «Закрити» завершує розмову.

` + 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(`

Очний огляд: ${esc(c.name)}

${esc(m.body)}

`); + openModal(`

Очний огляд: ${esc(c.name)}

${esc(m.body)}

`, { + key: "inspect:" + m.cid, + }); }); // Відповідь допитуваного. Сироту-модалку не відкриваємо: просимо теми ще раз — @@ -708,7 +728,10 @@ `, - { locked: true }, + // locked — власний вихід «Відкласти», Esc сюди не лізе. Ключ на випадок, + // якщо сервер надішле вилучення двічі: друга спроба замінить першу, + // а не запустить другий таймер поверх. + { locked: true, key: "mg" }, ); const q = (sel) => mm.inner.querySelector(sel); const zone = q("#mgzone"), diff --git a/detective-game/public/index.html b/detective-game/public/index.html index 58ba195..82a57a0 100644 --- a/detective-game/public/index.html +++ b/detective-game/public/index.html @@ -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,"); } * { 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); } diff --git a/detective-game/server.js b/detective-game/server.js index 46577a4..660acb2 100644 --- a/detective-game/server.js +++ b/detective-game/server.js @@ -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);
ХТО${esc(d.who)}