Newer
Older
rpg / apps / game / tools / dialogue-editor / editor.js
/**
 * Клиент редактора диалогов (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'));