// graph.js — Drawflow 版分支图编辑器。 // 单一数据源 = IR:拖线连接=改出口跳转,拖动节点=存坐标(ir._layout),结构变=整体重渲。 // 暴露 window.GraphUI = { init, render, updateLabel, select, autoLayout }。 (function () { const ROW = 135, COL = 290; // 自动布局间距:COL=层间(横向) ROW=同层(纵向) let editor = null, cb = {}, _ir = null; let dfId2ir = {}, ir2dfId = {}, building = false; // building: 重渲期间抑制事件 // ---------- 显示辅助(移植自旧 tree.js) ---------- function roleNames(ir) { const m = {}; (ir.roles || []).forEach(r => m[r.slot] = r.name + (r.archetype ? ("〔" + r.archetype + "〕") : "")); return m; } function nameOf(ir, names, s) { return (s === "P1" ? "玩家" : "") || names[s] || s; } function grantStr(ir, names, gr) { if (gr.kind === "银两") return "银两 " + (gr.value > 0 ? "+" : "") + gr.value; if (gr.kind === "道具") return "道具 " + gr.item + " ×" + gr.value; if (gr.kind === "友好度") return nameOf(ir, names, gr.target) + " 友好度+" + gr.value; if (gr.kind === "入门") return nameOf(ir, names, gr.target) + " 加入门派"; return JSON.stringify(gr); } function summary(ir, names, n) { if (n.kind === "narration") return ["旁白", (n.text || "").slice(0, 22)]; if (n.kind === "dialogue") return ["对话 · " + nameOf(ir, names, n.speaker), (n.text || "").slice(0, 20)]; if (n.kind === "choice") return ["选择 (" + (n.options || []).length + "项)", (n.options || []).map(o => o.text).join(" / ").slice(0, 24)]; if (n.kind === "choice_once") return ["一次性选择", (n.options || []).map(o => o.text).join(" / ").slice(0, 24)]; if (n.kind === "random") return ["随机分支", (n.branches || []).length + " 路"]; if (n.kind === "fight") return ["战斗", "vs " + (n.camp2 || []).map(s => nameOf(ir, names, s)).join("、")]; if (n.kind === "move") return ["走位 · " + nameOf(ir, names, n.actor), "→ " + (n.to || "")]; if (n.kind === "anim") return ["动画 · " + nameOf(ir, names, n.actor), n.ani || ""]; if (n.kind === "reward") return ["奖励结算", ""]; if (n.kind === "out_ref") return ["引用子序列", "→ " + (n.ref || "")]; if (n.kind === "ending") return ["★ 结局", n.summary || ""]; return [n.kind, ""]; } function esc(s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">"); } // ---------- 出口模型:output_(i+1) ←→ IR 出口 ---------- function isEnding(node) { return (_ir.endings || []).some(e => e.id === node.id); } function getOutlets(node) { const k = node.kind; if (k === "choice" || k === "choice_once") return (node.options || []).map((o, i) => ({ label: (i + 1) + ". " + (o.text ? o.text.slice(0, 14) : "(空选项)"), target: o.goto || "" })); if (k === "random") return (node.branches || []).map((b, i) => ({ label: (i + 1) + ". 权重 " + (b.weight != null ? b.weight : "?"), target: b.goto || "" })); if (k === "fight") return [{ label: "① 胜", target: node.win || "" }, { label: "② 败", target: node.lose || "" }]; if (isEnding(node)) return []; return [{ label: "next", target: node.next || "" }]; // 线性 } function setOutlet(node, idx, target) { const k = node.kind; if (k === "choice" || k === "choice_once") { if (node.options && node.options[idx]) node.options[idx].goto = target; } else if (k === "random") { if (node.branches && node.branches[idx]) node.branches[idx].goto = target; } else if (k === "fight") { if (idx === 0) node.win = target; else node.lose = target; } else { node.next = target; } } function findNode(irId) { return (_ir.nodes || []).find(n => n.id === irId) || (_ir.endings || []).find(e => e.id === irId); } // ---------- 节点 HTML ---------- function nodeInner(ir, node, isStart) { const names = roleNames(ir), end = isEnding(node); const startTag = isStart ? '▶ 开头 ' : ''; if (end) { const sm = summary(ir, names, Object.assign({ kind: "ending" }, node)); const g = (node.grants && node.grants.length) ? node.grants.map(gr => grantStr(ir, names, gr)).join(",") : "无奖励"; return '
' + startTag + '#' + esc(node.id) + '
' + '
' + esc(sm[0]) + '
' + esc(sm[1] || "") + '
' + '
' + esc(g) + '
'; } const outs = getOutlets(node); // 多出口节点(选择/随机/战斗):顶部角标 + 每出口一行(右对齐,行高匹配端口间距 → 与黄点平齐) if (outs.length > 1) { const head = summary(ir, names, node)[0]; return '
' + (isStart ? ' ' : '') + '#' + esc(node.id) + ' ' + esc(head) + '
' + '
' + outs.map(o => '
' + esc(o.label) + '
').join("") + '
'; } // 线性 / 单出口节点 const sm = summary(ir, names, node); return '
' + startTag + '#' + esc(node.id) + '
' + '
' + esc(sm[0]) + '
' + esc(sm[1] || "") + '
'; } // ---------- 自动布局:最长路径分层 + 每层顺序铺开 ---------- function rawTargets(n) { const k = n.kind; if (k === "choice" || k === "choice_once") return (n.options || []).map(o => o.goto); if (k === "random") return (n.branches || []).map(b => b.goto); if (k === "fight") return [n.win, n.lose]; return [n.next]; } function autoCoords(ir) { const nodes = {}; (ir.nodes || []).forEach(n => nodes[n.id] = n); (ir.endings || []).forEach(e => nodes[e.id] = e); const ids = Object.keys(nodes); // 层级:最长路径(决定横向 x,左→右) const edges = []; ids.forEach(id => rawTargets(nodes[id]).forEach(t => { if (t && nodes[t]) edges.push([id, t]); })); const layer = {}; ids.forEach(id => layer[id] = 0); let changed = true, guard = 0; while (changed && guard++ < 9999) { changed = false; edges.forEach(([u, v]) => { if (layer[v] < layer[u] + 1) { layer[v] = layer[u] + 1; changed = true; } }); } // 有序子表(按出口顺序,去重)+ 入度求根 const childMap = {}, indeg = {}; ids.forEach(id => { childMap[id] = []; indeg[id] = 0; }); const seen = new Set(); ids.forEach(id => rawTargets(nodes[id]).forEach(t => { if (t && nodes[t]) { const k = id + ">" + t; if (!seen.has(k)) { seen.add(k); childMap[id].push(t); indeg[t]++; } } })); let roots = ids.filter(id => indeg[id] === 0); if (!roots.length && ids.length) roots = [ids[0]]; // 子树居中:按出口顺序递归分配纵向 y,父节点居子范围中点 → 选项1上/2中/3下且对齐 const ypos = {}; let nextY = 0; const vis = new Set(); function assignY(id) { if (!id || vis.has(id)) return; vis.add(id); if (!childMap[id].length) { ypos[id] = nextY; nextY += ROW; return; } childMap[id].forEach(assignY); const placed = childMap[id].map(c => ypos[c]).filter(v => v !== undefined); ypos[id] = placed.length ? (Math.min(...placed) + Math.max(...placed)) / 2 : (nextY += ROW, nextY - ROW); } roots.forEach(assignY); ids.forEach(id => { if (ypos[id] === undefined) { ypos[id] = nextY; nextY += ROW; } }); const pos = {}; ids.forEach(id => { pos[id] = { x: 40 + layer[id] * COL, y: 30 + ypos[id] }; }); return pos; } function ensureLayout(ir) { const layout = ir._layout || (ir._layout = {}); const all = (ir.nodes || []).map(n => n.id).concat((ir.endings || []).map(e => e.id)); if (all.some(id => !layout[id])) { const auto = autoCoords(ir); all.forEach(id => { if (!layout[id]) layout[id] = auto[id] || { x: 40, y: 30 }; }); } return layout; } // ---------- 连线事件 ---------- function handleConnect(info) { const srcIr = dfId2ir[info.output_id], dstIr = dfId2ir[info.input_id]; if (!srcIr || !dstIr) return; const idx = parseInt(String(info.output_class).split("_")[1], 10) - 1; const node = findNode(srcIr); if (!node) return; const cur = getOutlets(node)[idx]; const old = cur && cur.target; if (old && old !== dstIr && ir2dfId[old]) { // 单出口单目标:先拆旧线 building = true; try { editor.removeSingleConnection(info.output_id, ir2dfId[old], info.output_class, "input_1"); } catch (e) {} building = false; } setOutlet(node, idx, dstIr); if (cb.onConnect) cb.onConnect(); } function handleDisconnect(info) { const srcIr = dfId2ir[info.output_id], dstIr = dfId2ir[info.input_id]; if (!srcIr) return; const idx = parseInt(String(info.output_class).split("_")[1], 10) - 1; const node = findNode(srcIr); if (!node) return; const cur = getOutlets(node)[idx]; if (cur && cur.target === dstIr) { setOutlet(node, idx, ""); if (cb.onDisconnect) cb.onDisconnect(); } } // ---------- 公开接口 ---------- window.GraphUI = { init(containerId, callbacks) { cb = callbacks || {}; editor = new Drawflow(document.getElementById(containerId)); editor.start(); editor.on("nodeSelected", id => { if (building) return; const ir = dfId2ir[id]; if (ir && cb.onSelect) cb.onSelect(ir); }); editor.on("nodeMoved", id => { if (building) return; const n = editor.getNodeFromId(id), ir = dfId2ir[id]; if (ir && cb.onMove) cb.onMove(ir, Math.round(n.pos_x), Math.round(n.pos_y)); }); editor.on("connectionCreated", info => { if (building) return; handleConnect(info); }); editor.on("connectionRemoved", info => { if (building) return; handleDisconnect(info); }); editor.on("nodeRemoved", id => { if (building) return; const ir = dfId2ir[id]; if (ir && cb.onRemove) cb.onRemove(ir); }); // 键盘 Del:删画布选中节点 → 交给 app 做「缝合删除」;焦点在输入框时放行文本编辑;选中连线时交给 Drawflow document.addEventListener("keydown", e => { if (e.key !== "Delete" && e.key !== "Backspace") return; const a = document.activeElement; if (a && (a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.isContentEditable)) return; const sel = document.querySelector("#drawflow .drawflow-node.selected"); if (sel) { const irId = dfId2ir[sel.id.replace("node-", "")]; if (irId && cb.onDeleteSelected) { e.stopPropagation(); e.preventDefault(); cb.onDeleteSelected(irId); } } }, true); }, render(ir, selectedIrId) { _ir = ir; building = true; editor.clear(); dfId2ir = {}; ir2dfId = {}; const layout = ensureLayout(ir); const startId = (ir.nodes && ir.nodes[0]) ? ir.nodes[0].id : null; const all = (ir.nodes || []).map(n => ({ node: n, end: false })) .concat((ir.endings || []).map(e => ({ node: e, end: true }))); all.forEach(({ node, end }) => { const outN = end ? 0 : getOutlets(node).length; const pos = layout[node.id] || { x: 40, y: 30 }; const cls = "irnode kind-" + (end ? "ending" : node.kind) + (node.id === startId ? " isstart" : ""); const dfId = editor.addNode(node.id, 1, outN, pos.x, pos.y, cls, { irId: node.id }, nodeInner(ir, node, node.id === startId)); dfId2ir[dfId] = node.id; ir2dfId[node.id] = dfId; }); all.forEach(({ node, end }) => { if (end) return; getOutlets(node).forEach((o, i) => { if (o.target && ir2dfId[o.target]) { try { editor.addConnection(ir2dfId[node.id], ir2dfId[o.target], "output_" + (i + 1), "input_1"); } catch (e) {} } }); }); building = false; if (selectedIrId) this.select(selectedIrId); }, updateLabel(ir, irId) { _ir = ir; const dfId = ir2dfId[irId]; if (!dfId) return; const node = findNode(irId); if (!node) return; const startId = (ir.nodes && ir.nodes[0]) ? ir.nodes[0].id : null; const box = document.querySelector("#node-" + dfId + " .drawflow_content_node"); if (box) box.innerHTML = nodeInner(ir, node, irId === startId); }, // 把画布平移到开头节点(ir.nodes[0])附近 focusStart(ir) { const startId = (ir.nodes && ir.nodes[0]) ? ir.nodes[0].id : null; if (!startId) return; const p = (ir._layout || {})[startId]; if (!p) return; const z = editor.zoom || 1; const tx = 70 - p.x * z, ty = 90 - p.y * z; try { editor.canvas_x = tx; editor.canvas_y = ty; const pc = editor.precanvas || document.querySelector("#drawflow .drawflow"); if (pc) pc.style.transform = "translate(" + tx + "px, " + ty + "px) scale(" + z + ")"; } catch (e) {} }, select(irId) { document.querySelectorAll("#drawflow .drawflow-node.selected").forEach(n => n.classList.remove("selected")); const dfId = ir2dfId[irId]; if (dfId) { const el = document.getElementById("node-" + dfId); if (el) el.classList.add("selected"); } }, autoLayout(ir) { ir._layout = autoCoords(ir); this.render(ir, null); }, }; })();