- 中间画布换成 Drawflow:拖动节点摆位、从出口圆点拉线到目标=建跳转 - 出口端口动态映射 IR:线性next/choice选项/random分支/fight胜败 - 连线/拖动实时写回 IR;节点坐标持久化到 ir._layout(编译忽略) - 右栏表单保留并双向联动;改跳转目标触发画布重渲 - 工具栏:自动整理、加后继;防误删(右栏输入时 Del 不删节点) - 移除旧 tree.js
209 lines
10 KiB
JavaScript
209 lines
10 KiB
JavaScript
// 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, "<").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, 6) : ""), target: o.goto || "" }));
|
||
if (k === "random")
|
||
return (node.branches || []).map((b) => ({ label: "权" + (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) {
|
||
const names = roleNames(ir), end = isEnding(node);
|
||
const sm = summary(ir, names, end ? Object.assign({ kind: "ending" }, node) : node);
|
||
let h = '<div class="nid">#' + esc(node.id) + '</div>'
|
||
+ '<div class="k">' + esc(sm[0]) + '</div>'
|
||
+ '<div class="t">' + esc(sm[1] || "") + '</div>';
|
||
if (end) {
|
||
const g = (node.grants && node.grants.length) ? node.grants.map(gr => grantStr(ir, names, gr)).join(",") : "无奖励";
|
||
h += '<div class="rw">' + esc(g) + '</div>';
|
||
} else {
|
||
const outs = getOutlets(node);
|
||
if (outs.length > 1)
|
||
h += '<div class="outs">' + outs.map((o, i) => '<span>' + (i + 1) + "·" + esc(o.label) + '</span>').join("") + '</div>';
|
||
}
|
||
return h;
|
||
}
|
||
|
||
// ---------- 自动布局:最长路径分层 + 每层顺序铺开 ----------
|
||
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);
|
||
const edges = [];
|
||
(ir.nodes || []).forEach(n => rawTargets(n).forEach(t => { if (t && nodes[t]) edges.push([n.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 perLayer = {}, pos = {};
|
||
ids.forEach(id => {
|
||
const l = layer[id]; perLayer[l] = perLayer[l] || 0;
|
||
// 横向分层(左→右),契合 Drawflow 端口左右朝向
|
||
pos[id] = { x: 40 + l * COL, y: 30 + perLayer[l] * ROW };
|
||
perLayer[l]++;
|
||
});
|
||
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/Backspace 不应删画布选中节点
|
||
document.addEventListener("keydown", e => {
|
||
if (e.key === "Delete" || e.key === "Backspace") {
|
||
const a = document.activeElement;
|
||
if (a && (a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.isContentEditable)) e.stopPropagation();
|
||
}
|
||
}, true);
|
||
},
|
||
render(ir, selectedIrId) {
|
||
_ir = ir; building = true;
|
||
editor.clear();
|
||
dfId2ir = {}; ir2dfId = {};
|
||
const layout = ensureLayout(ir);
|
||
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 dfId = editor.addNode(node.id, 1, outN, pos.x, pos.y,
|
||
"irnode kind-" + (end ? "ending" : node.kind), { irId: node.id }, nodeInner(ir, node));
|
||
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 box = document.querySelector("#node-" + dfId + " .drawflow_content_node");
|
||
if (box) box.innerHTML = nodeInner(ir, node);
|
||
},
|
||
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); },
|
||
};
|
||
})();
|