diff --git a/CLAUDE.md b/CLAUDE.md index 3078c6e..5fab270 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ npm run art:lint # линтер арта: палитра/слоты/«мыло» по apps/game/assets npm run aiart # пайплайн AI-атласов: gen/build/promote (apps/game/tools/aiart) node apps/game/tools/aiart/tryon.mjs # примерка AI-сборки в игре до promote (скриншот /tmp/rpg_tryon.png) +npm run dialogues # визуальный редактор графов диалогов (порт 5199) npm run audio # перегенерация WAV (apps/game/tools/audio/gen.mjs) npm run maps # перегенерация карт-файлов (apps/game/tools/maps + encodeMap) npm run agent:check # полный прогон проверок через агентный мост (JSON) diff --git a/apps/game/tools/dialogue-editor/editor.css b/apps/game/tools/dialogue-editor/editor.css new file mode 100644 index 0000000..50498b9 --- /dev/null +++ b/apps/game/tools/dialogue-editor/editor.css @@ -0,0 +1,137 @@ +/* Редактор диалогов: тёмная тема, без внешних ресурсов. */ + +:root { + --bg: #14161c; + --panel: #1c1f27; + --panel2: #232733; + --border: #3a3f4f; + --text: #d6d9e0; + --muted: #8a8fa0; + --accent: #f0d878; + --edge: #6a7185; + --edge-choice: #d8b04a; + --danger: #d88a8a; + --ok: #8ad88f; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 14px/1.45 system-ui, sans-serif; + height: 100vh; + display: flex; + flex-direction: column; +} + +.muted { color: var(--muted); } +.spacer { flex: 1; } +.accent { border-color: var(--accent); color: var(--accent); } +.danger { border-color: var(--danger); color: var(--danger); } + +button, select, input, textarea { + background: var(--panel2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 8px; + font: inherit; +} +button { cursor: pointer; } +button:hover { border-color: var(--accent); } +input, textarea { width: 100%; } + +#topbar { + display: flex; + gap: 10px; + align-items: center; + padding: 8px 12px; + background: var(--panel); + border-bottom: 1px solid var(--border); +} +#topbar h1 { font-size: 15px; margin: 0; } + +#layout { + flex: 1; + display: grid; + grid-template-columns: 170px 1fr 340px; + min-height: 0; +} + +#graph-list { + background: var(--panel); + border-right: 1px solid var(--border); + padding: 8px; + overflow-y: auto; +} +#graph-list button { + display: block; + width: 100%; + text-align: left; + margin-bottom: 4px; + border: 1px solid transparent; + background: none; +} +#graph-list button:hover { border-color: var(--border); } +#graph-list button.active { border-color: var(--accent); color: var(--accent); } + +#canvas-wrap { position: relative; overflow: auto; min-width: 0; } +#canvas { width: 100%; height: 100%; min-height: 400px; } + +/* Узел-карточка. */ +.node-card rect { + fill: var(--panel); + stroke: var(--border); + rx: 6; +} +.node-card.selected rect { stroke: var(--accent); stroke-width: 2; } +.node-card.ghost rect { stroke-dasharray: 4 3; stroke: var(--danger); } +.node-card text { fill: var(--text); font: 12px system-ui, sans-serif; } +.node-card .speaker { fill: var(--accent); font-weight: 600; } +.node-card .body { fill: var(--muted); } +.node-card .badge { fill: #4a5164; font: 10px monospace; } + +#run-output, #problems { + position: absolute; + left: 12px; + right: 12px; + bottom: 12px; + max-height: 40%; + overflow-y: auto; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 12px; + white-space: pre-wrap; +} +#problems .err { color: var(--danger); } +#problems .warn { color: var(--accent); } + +#inspector { + background: var(--panel); + border-left: 1px solid var(--border); + padding: 10px; + overflow-y: auto; +} +#inspector .row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; } +#inspector label { display: flex; flex-direction: column; gap: 2px; font-size: 12px; color: var(--muted); flex: 1; } +#inspector label.grow { flex: 2; } +#inspector details { margin-bottom: 8px; } +#inspector summary { cursor: pointer; color: var(--muted); margin-bottom: 6px; } +#inspector .buttons { justify-content: space-between; } +#inspector .buttons button { flex: 1; white-space: nowrap; } + +.choice-edit { + border: 1px solid var(--border); + border-radius: 4px; + padding: 6px; + margin-bottom: 6px; + background: var(--panel2); +} +.choice-edit .row { margin-bottom: 4px; } +.choice-edit button { margin-top: 4px; } + +#status.ok { color: var(--ok); } +#status.err { color: var(--danger); } \ No newline at end of file diff --git a/apps/game/tools/dialogue-editor/editor.js b/apps/game/tools/dialogue-editor/editor.js new file mode 100644 index 0000000..2a89708 --- /dev/null +++ b/apps/game/tools/dialogue-editor/editor.js @@ -0,0 +1,578 @@ +/** + * Клиент редактора диалогов (D8): список графов, SVG-канва с карточками и + * рёбрами, инспектор узла. Без фреймворков и CDN; авторитет валидации — + * сервер (probe.ts), клиент лишь мгновенно подсвечивает сирот и неизвестные id. + */ + +const $ = (id) => document.getElementById(id); +const SVGNS = 'http://www.w3.org/2000/svg'; +const CARD_W = 190; +const CARD_H = 74; +const GAP_X = 90; +const GAP_Y = 24; + +let graphIds = []; +let currentId = null; +let mtime = null; +let graph = null; +let selected = null; +let dirty = false; + +/** Позиции узлов последнего рендера (для рёбер). */ +let posMap = new Map(); + +async function jget(url) { + const r = await fetch(url); + const body = await r.json(); + if (!r.ok) throw new Error(body.message ?? `${r.status}`); + return body; +} + +async function jsend(url, method, body) { + const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + return { ok: r.ok, status: r.status, body: await r.json() }; +} + +function setStatus(text, cls = '') { + $('status').textContent = text; + $('status').className = `muted ${cls}`; +} + +function markDirty() { + dirty = true; + setStatus('не сохранено'); +} + +/** Все исходящие рёбра узла: [{to, kind}] — next и next выборов. */ +function edgesOf(id) { + const n = graph.nodes[id]; + if (!n) return []; + const out = []; + if (n.next) out.push({ to: n.next, kind: 'next' }); + (n.choices ?? []).forEach((c) => { + if (c.next) out.push({ to: c.next, kind: 'choice' }); + }); + return out; +} + +/** Слоёная раскладка: BFS от start (уровень → колонка), сироты — в последнюю. */ +function computeLayout() { + const pos = new Map(); + const depth = new Map(); + if (!graph.nodes[graph.start]) return pos; + depth.set(graph.start, 0); + const queue = [graph.start]; + while (queue.length > 0) { + const id = queue.shift(); + for (const e of edgesOf(id)) { + if (graph.nodes[e.to] && !depth.has(e.to)) { + depth.set(e.to, depth.get(id) + 1); + queue.push(e.to); + } + } + } + const rows = new Map(); + const lastCol = Math.max(-1, ...depth.values()) + 1; + for (const id of Object.keys(graph.nodes)) { + const d = depth.get(id) ?? lastCol; + const row = rows.get(d) ?? 0; + rows.set(d, row + 1); + pos.set(id, { x: d * (CARD_W + GAP_X) + 16, y: row * (CARD_H + GAP_Y) + 16, orphan: !depth.has(id) }); + } + return pos; +} + +/** Эллипсис для карточки. */ +function preview(s, n) { + const s2 = (s ?? '').replace(/\s+/g, ' ').trim(); + return s2.length > n ? `${s2.slice(0, n - 1)}…` : s2; +} + +function svgText(x, y, str, cls) { + const t = document.createElementNS(SVGNS, 'text'); + t.setAttribute('x', x); + t.setAttribute('y', y); + if (cls) t.setAttribute('class', cls); + t.textContent = str; + return t; +} + +/** Реплика ли узел: есть текст (inline или ключ). */ +function isReplica(n) { + return n.text !== undefined || n.textKey !== undefined; +} + +/** Краткие бейджи условий и эффектов (внизу карточки). */ +function badgesOf(n) { + const parts = []; + if (n.mood) parts.push(`mood:${n.mood}`); + if (n.when?.length) parts.push(`when:${n.when.join(',')}`); + if (n.whenNot?.length) parts.push(`!${n.whenNot.join(',')}`); + if (n.whenVar) parts.push(`${n.whenVar.key}${n.whenVar.op}${n.whenVar.value}`); + if (n.whenVars?.length) parts.push(`${n.whenVars.length}×var`); + if (n.hasItem?.length) parts.push(`item:${n.hasItem.join(',')}`); + if (n.setFlags?.length) parts.push(`+${n.setFlags.join(',')}`); + if (n.setVars && Object.keys(n.setVars).length) parts.push(`v=${Object.keys(n.setVars).join(',')}`); + (n.do ?? []).forEach((o) => parts.push(`${o.kind}${o.id ? ':' + o.id : ''}`)); + return parts.join(' '); +} + +/** Карточка узла (rect + 4 строки текста). */ +function drawNodeCard(id) { + const n = graph.nodes[id]; + const p = posMap.get(id); + const g = document.createElementNS(SVGNS, 'g'); + g.setAttribute('class', `node-card${selected === id ? ' selected' : ''}${p.orphan ? ' ghost' : ''}`); + g.setAttribute('transform', `translate(${p.x},${p.y})`); + const rect = document.createElementNS(SVGNS, 'rect'); + rect.setAttribute('width', CARD_W); + rect.setAttribute('height', CARD_H); + rect.setAttribute('rx', 6); + g.appendChild(rect); + g.appendChild(svgText(10, 16, n.speaker ?? (isReplica(n) ? '—' : '(действие)'), 'speaker')); + g.appendChild(svgText(10, 30, id, '')); + g.appendChild(svgText(10, 46, preview(n.text ?? n.textKey ?? '', 27), 'body')); + const badges = badgesOf(n); + if (badges) g.appendChild(svgText(10, 62, preview(badges, 30), 'badge')); + g.addEventListener('click', () => select(id)); + $('nodes').appendChild(g); +} + +/** Ребро между карточками: кубическая кривая от правой грани к левой. */ +function drawEdge(from, to, kind) { + const a = posMap.get(from); + const b = posMap.get(to); + if (!a || !b) return; + const x1 = a.x + CARD_W; + const y1 = a.y + CARD_H / 2; + const x2 = b.x; + const y2 = b.y + CARD_H / 2; + const mx = (x1 + x2) / 2; + const path = document.createElementNS(SVGNS, 'path'); + path.setAttribute('d', `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke', kind === 'choice' ? 'var(--edge-choice)' : 'var(--edge)'); + if (kind === 'choice') path.setAttribute('stroke-dasharray', '5 4'); + path.setAttribute('stroke-width', '1.5'); + $('edges').appendChild(path); +} + +function renderCanvas() { + posMap = computeLayout(); + $('edges').replaceChildren(); + $('nodes').replaceChildren(); + for (const id of Object.keys(graph.nodes)) { + for (const e of edgesOf(id)) { + if (graph.nodes[e.to]) drawEdge(id, e.to, e.kind); + } + } + for (const id of Object.keys(graph.nodes)) { + drawNodeCard(id); + } + const xs = [...posMap.values()].map((p) => p.x + CARD_W + GAP_X); + const ys = [...posMap.values()].map((p) => p.y + CARD_H + GAP_Y); + $('canvas').setAttribute('viewBox', `0 0 ${Math.max(480, ...xs)} ${Math.max(400, ...ys)}`); +} + +function select(id) { + selected = id; + renderCanvas(); + renderInspector(); +} + +/* ---------- инспектор ---------- */ + +function setField(el, value) { + el.value = value ?? ''; +} + +/** Собрать whenVar из трёх полей (пустой ключ — условия нет). */ +function whenVarFromInputs() { + const key = $('f-whenvar-key').value.trim(); + if (!key) return undefined; + return { key, op: $('f-whenvar-op').value, value: Number($('f-whenvar-value').value || 0) }; +} + +/** do[] из строки «kind:id:count, kind:id». */ +function doFromString(s) { + const ops = []; + for (const part of s.split(',')) { + const [kind, id, count] = part.trim().split(':').map((x) => x.trim()); + if (!kind) continue; + const op = { kind }; + if (id) op.id = id; + if (count) op.count = Number(count); + ops.push(op); + } + return ops.length > 0 ? ops : undefined; +} + +function doToString(ops) { + return (ops ?? []).map((o) => [o.kind, o.id, o.count].filter((x) => x !== undefined).join(':')).join(', '); +} + +/** setVars из строки «ключ=число, ...». */ +function setVarsFromString(s) { + const out = {}; + for (const part of s.split(',')) { + const [k, v] = part.split('=').map((x) => x.trim()); + if (k && v !== undefined && v !== '') out[k] = Number(v); + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function setVarsToString(vars) { + return Object.entries(vars ?? {}).map(([k, v]) => `${k}=${v}`).join(', '); +} + +/** Заполнить поля инспектора из узла. */ +function fillInspector(id) { + const n = graph.nodes[id]; + setField($('f-id'), id); + setField($('f-speaker'), n.speaker); + setField($('f-mood'), n.mood); + setField($('f-text'), n.text); + setField($('f-textkey'), n.textKey); + setField($('f-tags'), (n.tags ?? []).join(', ')); + setField($('f-next'), n.next ?? ''); + setField($('f-when'), (n.when ?? []).join(', ')); + setField($('f-whennot'), (n.whenNot ?? []).join(', ')); + const wv = n.whenVar ?? n.whenVars?.[0]; + setField($('f-whenvar-key'), wv?.key); + setField($('f-whenvar-op'), wv?.op ?? 'ge'); + setField($('f-whenvar-value'), wv?.value); + setField($('f-setflags'), (n.setFlags ?? []).join(', ')); + setField($('f-setvars'), setVarsToString(n.setVars)); + setField($('f-do'), doToString(n.do)); + fillNextOptions(n.next ?? ''); + renderChoicesEditor(id); +} + +/** Варианты next: все узлы + пустой конец. */ +function fillNextOptions(current) { + const sel = $('f-next'); + sel.replaceChildren(new Option('— конец —', '')); + for (const id of Object.keys(graph.nodes)) sel.appendChild(new Option(id, id)); + sel.value = current; +} + +/** Обёртка label>control (строки инспектора). */ +function wrapLabel(text, control, grow) { + const label = document.createElement('label'); + if (grow) label.className = 'grow'; + label.append(text, control); + return label; +} + +/** Редактор одного выбора: text / next / when / удалить. */ +function choiceRow(id, index, ch) { + const div = document.createElement('div'); + div.className = 'choice-edit'; + const text = document.createElement('input'); + text.value = ch.text ?? ''; + text.placeholder = 'текст варианта'; + text.addEventListener('input', () => { + ch.text = text.value; + markDirty(); + renderCanvas(); + }); + const next = document.createElement('select'); + next.appendChild(new Option('— конец —', '')); + for (const nid of Object.keys(graph.nodes)) next.appendChild(new Option(nid, nid)); + next.value = ch.next ?? ''; + next.addEventListener('change', () => { + if (next.value) ch.next = next.value; + else delete ch.next; + markDirty(); + renderCanvas(); + }); + const when = document.createElement('input'); + when.value = (ch.when ?? []).join(', '); + when.placeholder = 'when (флаги, через запятую)'; + when.addEventListener('input', () => { + const flags = listOf(when.value); + if (flags.length > 0) ch.when = flags; + else delete ch.when; + markDirty(); + }); + const del = document.createElement('button'); + del.textContent = '× удалить'; + del.className = 'danger'; + del.addEventListener('click', () => { + graph.nodes[id].choices.splice(index, 1); + if (graph.nodes[id].choices.length === 0) delete graph.nodes[id].choices; + markDirty(); + renderAll(); + }); + const row1 = document.createElement('div'); + row1.className = 'row'; + row1.appendChild(wrapLabel('text', text, true)); + const row2 = document.createElement('div'); + row2.className = 'row'; + row2.appendChild(wrapLabel('next', next, true)); + const row3 = document.createElement('div'); + row3.className = 'row'; + row3.appendChild(wrapLabel('when', when, true)); + div.append(row1, row2, row3, del); + return div; +} + +function renderChoicesEditor(id) { + const n = graph.nodes[id]; + const wrap = $('choices'); + wrap.replaceChildren(); + (n.choices ?? []).forEach((ch, i) => { + wrap.appendChild(choiceRow(id, i, ch)); + }); +} + +/* ---------- запись полей обратно в узел ---------- */ + +/** Поля с comma-списками: пустое поле → пустой список. */ +function listOf(s) { + return s.split(',').map((x) => x.trim()).filter(Boolean); +} + +function bindField(elId, apply) { + $(elId).addEventListener('input', () => { + if (!selected) return; + apply(graph.nodes[selected]); + markDirty(); + renderCanvas(); + }); +} + +/** whenVar-поле: единая запись (редактируем одно условие; whenVars[1+] не трогаем). */ +function bindWhenVar() { + const apply = (n) => { + const wv = whenVarFromInputs(); + if (wv) { + n.whenVar = wv; + delete n.whenVars; + } else { + delete n.whenVar; + delete n.whenVars; + } + }; + for (const id of ['f-whenvar-key', 'f-whenvar-op', 'f-whenvar-value']) bindField(id, apply); +} + +function bindInspector() { + bindField('f-speaker', (n) => { + if ($('f-speaker').value) n.speaker = $('f-speaker').value; + else delete n.speaker; + }); + bindField('f-mood', (n) => { + if ($('f-mood').value) n.mood = $('f-mood').value; + else delete n.mood; + }); + bindField('f-text', (n) => { + if ($('f-text').value) n.text = $('f-text').value; + else delete n.text; + }); + bindField('f-textkey', (n) => { + if ($('f-textkey').value) n.textKey = $('f-textkey').value; + else delete n.textKey; + }); + bindField('f-tags', (n) => { + const tags = listOf($('f-tags').value); + if (tags.length > 0) n.tags = tags; + else delete n.tags; + }); + bindField('f-when', (n) => { + const flags = listOf($('f-when').value); + if (flags.length > 0) n.when = flags; + else delete n.when; + }); + bindField('f-whennot', (n) => { + const flags = listOf($('f-whennot').value); + if (flags.length > 0) n.whenNot = flags; + else delete n.whenNot; + }); + bindWhenVar(); + bindField('f-setflags', (n) => { + const flags = listOf($('f-setflags').value); + if (flags.length > 0) n.setFlags = flags; + else delete n.setFlags; + }); + bindField('f-setvars', (n) => { + const vars = setVarsFromString($('f-setvars').value); + if (vars) n.setVars = vars; + else delete n.setVars; + }); + bindField('f-do', (n) => { + const ops = doFromString($('f-do').value); + if (ops) n.do = ops; + else delete n.do; + }); + $('f-next').addEventListener('change', () => { + if (!selected) return; + if ($('f-next').value) graph.nodes[selected].next = $('f-next').value; + else delete graph.nodes[selected].next; + markDirty(); + renderCanvas(); + }); + $('btn-add-choice').addEventListener('click', () => { + if (!selected) return; + (graph.nodes[selected].choices ??= []).push({ text: 'Новый вариант' }); + markDirty(); + renderAll(); + }); + $('btn-add-node').addEventListener('click', addNode); + $('btn-del-node').addEventListener('click', deleteNode); +} + +/** Свободный id узла: node_N. */ +function freeNodeId() { + let i = 1; + while (graph.nodes[`node_${i}`]) i++; + return `node_${i}`; +} + +function addNode() { + const id = freeNodeId(); + graph.nodes[id] = { text: '' }; + markDirty(); + select(id); +} + +function deleteNode() { + if (!selected) return; + if (selected === graph.start) { + setStatus('start удалять нельзя — выбери другой узел', 'err'); + return; + } + delete graph.nodes[selected]; + selected = null; + markDirty(); + renderAll(); +} + +/* ---------- список графов, открытие, сохранение, dry-run ---------- */ + +async function loadGraphList() { + graphIds = await jget('/api/graphs'); + const nav = $('graph-list'); + nav.replaceChildren(); + for (const g of graphIds) { + const b = document.createElement('button'); + b.textContent = g.id; + b.className = g.id === currentId ? 'active' : ''; + b.addEventListener('click', () => switchGraph(g.id)); + nav.appendChild(b); + } +} + +async function switchGraph(id) { + if (dirty && !confirm('Есть несохранённые правки. Перейти и потерять их?')) return; + await openGraph(id); +} + +async function openGraph(id) { + const data = await jget(`/api/graph/${id}`); + currentId = id; + mtime = data.mtime; + graph = data.graph; + selected = null; + dirty = false; + $('graph-title').textContent = `— ${id}`; + $('run-output').hidden = true; + $('problems').hidden = true; + setStatus('загружено с диска'); + await loadGraphList(); + renderAll(); +} + +function renderInspector() { + if (selected && graph.nodes[selected]) { + $('inspector-empty').hidden = true; + $('inspector-form').hidden = false; + fillInspector(selected); + } else { + $('inspector-empty').hidden = false; + $('inspector-form').hidden = true; + } +} + +function renderAll() { + renderCanvas(); + renderInspector(); +} + +async function save() { + if (!graph) return; + setStatus('валидация…'); + const r = await jsend(`/api/graph/${currentId}`, 'PUT', { mtime, graph }); + if (r.status === 409) { + if (confirm(`${r.body.message}. Перечитать с диска и потерять локальные правки?`)) { + await openGraph(currentId); + } + return; + } + if (r.status === 422) { + showProblems(r.body); + setStatus('ошибки валидации — не сохранено', 'err'); + return; + } + if (!r.ok) { + setStatus(`ошибка: ${r.body.message ?? r.status}`, 'err'); + return; + } + mtime = r.body.mtime; + dirty = false; + $('problems').hidden = true; + setStatus('сохранено', 'ok'); +} + +/** Проблемы (валидация/анализ) в панель над канвой. */ +function showProblems(out) { + const div = $('problems'); + div.replaceChildren(); + const line = (cls, text) => { + const p = document.createElement('div'); + p.className = cls; + p.textContent = text; + div.appendChild(p); + }; + for (const inv of out.errors ?? []) line('err', `[ошибка] ${inv.message}`); + for (const inv of out.warnings ?? []) line('warn', `[предупреждение] ${inv.message}`); + for (const o of out.orphans ?? []) line('warn', `[сирота] узел «${o}» недостижим`); + for (const cy of out.cycles ?? []) line('err', `[цикл без текста] ${cy.join(' → ')}`); + for (const e of out.silentEnds ?? []) line('warn', `[тихий конец] «${e}»`); + div.hidden = div.childElementCount === 0; +} + +async function dryRun() { + if (!graph) return; + setStatus('прогон…'); + const r = await jsend('/api/dry-run', 'POST', { graph, preset: $('preset').value }); + if (!r.ok) { + setStatus(`ошибка: ${r.body.message ?? r.status}`, 'err'); + return; + } + const div = $('run-output'); + div.textContent = r.body.run ?? ''; + div.hidden = false; + showProblems(r.body); + setStatus('прогон готов', 'ok'); +} + +async function init() { + const list = await jget('/api/graphs'); + if (list.length === 0) { + setStatus('в data/dialogues нет графов', 'err'); + return; + } + bindInspector(); + $('btn-save').addEventListener('click', save); + $('btn-reload').addEventListener('click', async () => { + if (!dirty || confirm('Потерять локальные правки?')) await openGraph(currentId); + }); + $('btn-dry').addEventListener('click', dryRun); + window.addEventListener('beforeunload', (e) => { + if (dirty) e.preventDefault(); + }); + await openGraph(list[0].id); +} + +init().catch((e) => setStatus(String(e), 'err')); \ No newline at end of file diff --git a/apps/game/tools/dialogue-editor/index.html b/apps/game/tools/dialogue-editor/index.html new file mode 100644 index 0000000..f2d3c19 --- /dev/null +++ b/apps/game/tools/dialogue-editor/index.html @@ -0,0 +1,104 @@ + + + + + + Редактор диалогов — Пепельные луга + + + +
+

Редактор диалогов

+ + + + + + + +
+ +
+ + +
+ + + + + + +
+ + +
+ + + + \ No newline at end of file diff --git a/apps/game/tools/dialogue-editor/server.mjs b/apps/game/tools/dialogue-editor/server.mjs new file mode 100644 index 0000000..3c8f0fa --- /dev/null +++ b/apps/game/tools/dialogue-editor/server.mjs @@ -0,0 +1,169 @@ +/** + * Сервер визуального редактора диалогов (D8): node http без зависимостей. + * Отдаёт статику (index.html/editor.js/editor.css), читает и пишет графы + * в data/dialogues/*.json (атомарно, с mtime-гардом от конкурентной правки), + * валидацию и dry-run делает спавном vite-node tools/dialogues/probe.ts — + * правила одни (dialogueRules.ts), дублирования нет. + * + * Запуск: npm run dialogues [-- --port 5199] + */ +import http from 'node:http'; +import { spawn } from 'node:child_process'; +import { readFile, writeFile, rename, readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Корень монорепо (server.mjs — на 4 уровня ниже корня). */ +const ROOT = fileURLToPath(new URL('../../../..', import.meta.url)); +const DIALOGUES_DIR = path.join(ROOT, 'apps/game/src/data/dialogues'); +const STATIC_DIR = path.join(ROOT, 'apps/game/tools/dialogue-editor'); +const VITE_NODE = path.join(ROOT, 'node_modules/.bin/vite-node'); +const PORT = Number(process.argv[process.argv.indexOf('--port') + 1] ?? 5199); + +const JSON_SPACE = 4; + +/** Спавн vite-node (TS-импорты из src): stdout — последняя строка (JSON). */ +function viteNode(args, input) { + return new Promise((resolve, reject) => { + const p = spawn(VITE_NODE, args, { cwd: ROOT }); + let out = ''; + let err = ''; + if (input !== undefined) { + p.stdin.write(input); + } + p.stdin.end(); + p.stdout.on('data', (c) => (out += c)); + p.stderr.on('data', (c) => (err += c)); + p.on('error', reject); + p.on('close', (code) => { + const line = out.trim().split('\n').filter(Boolean).pop() ?? ''; + try { + resolve(JSON.parse(line)); + } catch { + reject(new Error(`probe упал (code ${code}): ${err.slice(-500) || line.slice(-200)}`)); + } + }); + }); +} + +/** Валидация графа (probe.ts): Invariants + анализ достижимости. */ +function probe(graph) { + return viteNode(['apps/game/tools/dialogues/probe.ts'], JSON.stringify({ op: 'validate', graph })); +} + +/** Dry-run графа на пресете состояния. */ +function dryRun(graph, preset) { + return viteNode(['apps/game/tools/dialogues/probe.ts'], JSON.stringify({ op: 'dry-run', graph, preset })); +} + +/** Список графов: id + mtime (для гарандов конкурентной правки). */ +async function listGraphs() { + const files = (await readdir(DIALOGUES_DIR)).filter((f) => f.endsWith('.json')).sort(); + const out = []; + for (const f of files) { + const id = f.replace(/\.json$/, ''); + out.push({ id, mtime: (await stat(path.join(DIALOGUES_DIR, f))).mtimeMs }); + } + return out; +} + +/** Канонический формат файла: 4 пробела + \\n в конце. */ +function serialize(graph) { + return JSON.stringify(graph, null, 4) + '\n'; +} + +const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }; + +/** Ответ хелпером: JSON / текст / буфер статики / ошибка. */ +function send(res, code, body, type = 'application/json; charset=utf-8') { + res.writeHead(code, { 'Content-Type': type }); + if (Buffer.isBuffer(body) || typeof body === 'string') res.end(body); + else res.end(JSON.stringify(body)); +} + +/** Чтение тела запроса (JSON, лимит 2 МБ — графы маленькие). */ +function readBody(req) { + return new Promise((resolve, reject) => { + let data = ''; + req.on('data', (c) => { + data += c; + if (data.length > 2 * 1024 * 1024) req.destroy(); + }); + req.on('end', () => { + try { + resolve(JSON.parse(data || '{}')); + } catch (e) { + reject(new Error(`тело не JSON: ${e.message}`)); + } + }); + }); +} + +/** PUT графа: mtime-гард -> валидация -> атомарная запись. */ +async function putGraph(id, body) { + const file = path.join(DIALOGUES_DIR, `${id}.json`); + let current; + try { + current = (await stat(file)).mtimeMs; + } catch { + return { code: 404, body: { message: `граф «${id}» не найден` } }; + } + if (typeof body.mtime !== 'number' || body.mtime !== current) { + // Конкурентная правка: отдаём свежий mtime — клиент предложит перечитать. + return { code: 409, body: { message: 'файл изменён на диске', mtime: current } }; + } + const probeOut = await probe(body.graph); + if (probeOut.errors.length > 0) { + return { code: 422, body: probeOut }; + } + const tmp = `${file}.tmp`; + await writeFile(tmp, serialize(body.graph)); + await rename(tmp, file); + return { code: 200, body: { ok: true, mtime: (await stat(file)).mtimeMs } }; +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://localhost:${PORT}`); + try { + if (url.pathname === '/api/graphs' && req.method === 'GET') { + return send(res, 200, await listGraphs()); + } + const graphMatch = url.pathname.match(/^\/api\/graph\/(\w+)$/); + if (graphMatch) { + const id = graphMatch[1]; + const file = path.join(DIALOGUES_DIR, `${id}.json`); + if (req.method === 'GET') { + const data = await readFile(file, 'utf8'); + return send(res, 200, { id, mtime: (await stat(file)).mtimeMs, graph: JSON.parse(data) }); + } + if (req.method === 'PUT') { + const { code, body } = await putGraph(id, await readBody(req)); + return send(res, code, body); + } + } + if (url.pathname === '/api/dry-run' && req.method === 'POST') { + const { graph, preset } = await readBody(req); + return send(res, 200, await dryRun(graph, preset)); + } + if (url.pathname === '/api/registries' && req.method === 'GET') { + return send(res, 200, await viteNode(['apps/game/tools/dialogues/registries.ts'], '')); + } + // Статика редактора (нет файла — 404, не 500). + const name = url.pathname === '/' ? 'index.html' : url.pathname.slice(1); + if (/^[\w.-]+$/.test(name)) { + try { + const data = await readFile(path.join(STATIC_DIR, name)); + return send(res, 200, data, MIME[path.extname(name)] ?? 'application/octet-stream'); + } catch { + return send(res, 404, { message: `нет файла «${name}»` }); + } + } + send(res, 404, { message: 'нет такого пути' }); + } catch (e) { + send(res, 500, { message: String(e?.message ?? e) }); + } +}); + +server.listen(PORT, () => { + console.log(`редактор диалогов: http://localhost:${PORT}`); +}); \ No newline at end of file diff --git a/apps/game/tools/dialogues/probe.ts b/apps/game/tools/dialogues/probe.ts new file mode 100644 index 0000000..396eaf0 --- /dev/null +++ b/apps/game/tools/dialogues/probe.ts @@ -0,0 +1,93 @@ +/** + * Проверка произвольного графа (для сервера редактора, D8). Читает stdin: + * { "op": "validate", "graph": {...} } — Invariants + анализ + * { "op": "dry-run", "graph": {...}, "preset": "fresh" } — ещё и прогон раннера + * Печатает JSON: + * { errors, warnings, orphans, cycles, silentEnds, run? } + * Запуск: vite-node apps/game/tools/dialogues/probe.ts + */ +import { GameState, DialogueRunner } from '@rpg/engine'; +import type { DialogueGraph } from '@rpg/engine'; +import { analyzeGraph, checkGraph, type GraphRefs } from '../../src/data/dialogueRules'; +import { FLAGS, VARS } from '../../src/data/ids'; +import { ITEMS } from '../../src/data/items'; +import { DIALOGUE_CUSTOM } from '../../src/data/effects'; + +/** Реестры игры (те же множества, что в validate.ts и dry-run.ts). */ +function refs(): GraphRefs { + return { + flags: new Set(Object.keys(FLAGS)), + vars: new Set(Object.keys(VARS)), + items: new Set(Object.keys(ITEMS)), + customs: new Set(Object.keys(DIALOGUE_CUSTOM)), + strings: new Set() + }; +} + +/** Пресеты состояния — как в dry-run.ts (общая семантика с CLI). */ +const PRESETS: { name: string; setup: (s: GameState) => void }[] = [ + { name: 'fresh', setup: () => {} }, + { + name: 'quest-taken+flowers', + setup: (s) => { + s.setFlag('quest_bells_taken'); + s.setFlag('met_elder'); + s.setVar('flowers', 3); + } + }, + { + name: 'quest-done', + setup: (s) => { + s.setFlag('quest_bells_taken'); + s.setFlag('met_elder'); + s.setFlag('quest_bells_done'); + } + } +]; + +/** Один прогон раннера: реплики до первого выбора (или до конца). */ +function walk(graph: DialogueGraph, preset: string): string { + const state = new GameState(); + (PRESETS.find((p) => p.name === preset) ?? PRESETS[0]!).setup(state); + const runner = new DialogueRunner(state); + const shown: string[] = []; + runner.setView({ + show: (n) => + void shown.push( + `${n.speaker ? n.speaker + ': ' : ''}${n.text}` + + (n.choices.length > 0 ? ` [${n.choices.map((c) => c.text).join(' | ')}]` : '') + ), + hide: () => {} + }); + runner.start(graph); + while (runner.active && !runner.waitingForChoice) runner.advance(); + return shown.join(' → ') || '(молча)'; +} + +const main = (): void => { + const chunks: string[] = []; + process.stdin.on('data', (c) => chunks.push(c)); + process.stdin.on('end', () => { + const req = JSON.parse(chunks.join('')) as { + op?: string; + graph: DialogueGraph; + preset?: string; + }; + const graph = req.graph; + const a = analyzeGraph(graph); + const inv = checkGraph('editor', graph, refs()); + const out: Record = { + errors: inv.filter((i) => i.severity === 'error'), + warnings: inv.filter((i) => i.severity === 'warn'), + orphans: a.orphans, + cycles: a.textlessCycles, + silentEnds: a.silentEnds + }; + if (req.op === 'dry-run') { + out.run = walk(graph, req.preset ?? 'fresh'); + } + console.log(JSON.stringify(out)); + }); +}; + +main(); \ No newline at end of file diff --git a/apps/game/tools/dialogues/registries.ts b/apps/game/tools/dialogues/registries.ts new file mode 100644 index 0000000..ba8f005 --- /dev/null +++ b/apps/game/tools/dialogues/registries.ts @@ -0,0 +1,24 @@ +/** + * Реестры игры для редактора диалогов (D8): печатает JSON с допустимыми + * id — сервер отдаёт их клиенту для выпадающих списков и подсказок. + * + * Запуск: vite-node apps/game/tools/dialogues/registries.ts + */ +import { FLAGS, VARS } from '../../src/data/ids'; +import { ITEMS } from '../../src/data/items'; +import { DIALOGUE_CUSTOM } from '../../src/data/effects'; +import { DIALOGUES } from '../../src/data/dialogues'; + +const main = (): void => { + const out = { + flags: Object.keys(FLAGS), + vars: Object.keys(VARS), + items: Object.keys(ITEMS), + customs: Object.keys(DIALOGUE_CUSTOM), + dialogues: Object.keys(DIALOGUES), + strings: [] as string[] + }; + console.log(JSON.stringify(out)); +}; + +main(); \ No newline at end of file diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 53fdcdb..9fed86b 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -77,6 +77,11 @@ недостижимые узлы → `warn` (сироты допустимы, но проверь, что это не забытая ветка), цикл без текста → `error`. Прогнать глазами: `npm run dialogues:dry []` — реплики на пресетах состояния + сироты/циклы. + Править граф глазами — визуальный редактор `npm run dialogues` (порт 5199): + карточки/рёбра, инспектор узла, кнопка Save гоняет тот же checkGraph + (422 на ошибках, mtime-гард от конкурентной правки), dry-run прямо в UI. + Правила одни для всех трёх точек (валидатор, CLI, редактор) — они в + `dialogueRules.ts`; сервер редактора спавнит `tools/dialogues/probe.ts`. 3. Проверка через мост: `walkTo` до соседнего тайла → `tapTile(NPC)` → `runDialogue()` → прочитать `flags`/`dialogue.text` из снапшота (`s.dialogue.path` — какие узлы реально показаны). Нюанс typewriter: diff --git a/docs/engine/ui-and-dialogue.md b/docs/engine/ui-and-dialogue.md index 1be9156..ee001e0 100644 --- a/docs/engine/ui-and-dialogue.md +++ b/docs/engine/ui-and-dialogue.md @@ -252,6 +252,16 @@ `string-unknown` (textKey вне реестра строк), `quest-stage-unreachable` (doneFlag стадии не выставляется setFlags в графе стадии). +Визуальный редактор — `npm run dialogues` (`tools/dialogue-editor/`, порт 5199): +слева список графов, в центре SVG-канва (карточки узлов, рёбра `next` и +`choices` разными цветами, сироты пунктиром), справа инспектор узла +(текст/speaker/mood/tags, условия, setFlags/setVars/do[], выборы). +Сохранение (PUT) сперва валидирует граф тем же `checkGraph` (422 с +Invariant[] на ошибках) и защищено mtime-гардом (409 — файл менялся на +диске); запись атомарная (`.tmp` → rename), формат 4 пробела + `\n`. +Сервер — node http без зависимостей; валидация и dry-run — спавн +`vite-node tools/dialogues/probe.ts` (реестры — `registries.ts`). + ## DialogueBox Готовая нижняя панель для реплик (рисует имя, текст, варианты, подсказку diff --git a/docs/llms.txt b/docs/llms.txt index 8da2b19..d2942a9 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -32,6 +32,7 @@ - `apps/game/tools/agent.mjs`: CLI check/run/snapshot/screenshot/dev (JSON по умолчанию). - `packages/engine/tools/`: жанронезависимые тулзы (png/canvas/imaging/wav, agent-lib, boundary) — импорт через `@rpg/engine/tools/*`; тулзы, знающие контент игры, остаются в `apps/game/tools/` как сценарии применения. - `apps/game/tools/checks/*.mjs`: сценарии проверки геймплейных систем через мост. +- `apps/game/tools/dialogues/`: dry-run CLI (`npm run dialogues:dry`) и probe/registries для редактора; визуальный редактор графов — `npm run dialogues` (порт 5199). - `apps/game/tools/guards/boundary.mjs` (`npm run guard`): сценарий запуска гварда; правила — `packages/engine/tools/boundary.mjs`. ## Мир и арт diff --git a/package.json b/package.json index b0c232c..577099d 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "audio": "node apps/game/tools/audio/gen.mjs", "maps": "vitest run apps/game/tools/maps/gen.test.ts", "dialogues:dry": "vite-node apps/game/tools/dialogues/dry-run.ts", + "dialogues": "node apps/game/tools/dialogue-editor/server.mjs", "guard": "node apps/game/tools/guards/boundary.mjs", "test": "vitest run", "test:watch": "vitest",