原描述挤在顶部一行被截断、左下大块留白。改为两栏:左栏战斗描述占满左侧、 可换行、垂直居中;右栏胜/败出口行。.fbody align-items:flex-end + 出口列 justify-content:flex-end 保持黄点与胜/败行对齐。
263 lines
14 KiB
JavaScript
263 lines
14 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, 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 ----------
|
||
const KIND_CN = { narration: "旁白", dialogue: "对话", choice: "选择", choice_once: "一次性选择", random: "随机", fight: "战斗", move: "走位", anim: "动画", reward: "奖励", out_ref: "引用", ending: "结局" };
|
||
function nodeInner(ir, node) {
|
||
const names = roleNames(ir), end = isEnding(node);
|
||
const kind = end ? "ending" : node.kind;
|
||
const label = '<div class="nlabel">' + esc(KIND_CN[kind] || kind) + '</div>';
|
||
if (end) {
|
||
const g = (node.grants && node.grants.length) ? node.grants.map(gr => grantStr(ir, names, gr)).join(",") : "无奖励";
|
||
return label + '<div class="nbody"><div class="t">' + esc(node.summary || "") + '</div><div class="rw">' + esc(g) + '</div></div>';
|
||
}
|
||
const outs = getOutlets(node);
|
||
// 多出口(选择/随机/战斗):每出口一行(右对齐)。战斗节点额外在出口行上方加一行战斗描述,
|
||
// 出口黄点靠 .kind-fight .outputs{justify-content:flex-end} 压到底部两行(胜/败)保持逐行平齐。
|
||
if (outs.length > 1) {
|
||
const chOpts = '<div class="ch-opts">' + outs.map(o => '<div class="ch-opt" title="' + esc(o.label) + '">' + esc(o.label) + '</div>').join("") + '</div>';
|
||
// 战斗:左栏战斗描述(占满左侧、可换行)+ 右栏胜/败出口行
|
||
if (!end && node.kind === "fight") {
|
||
const ft = node.fight_type === 2 ? "死斗" : "击倒";
|
||
const foes = (node.camp2 || []).map(s => nameOf(ir, names, s)).join("、") || "未设敌方";
|
||
const allies = (node.camp1 || []).map(s => nameOf(ir, names, s)).join("、");
|
||
const full = ft + " · " + (allies ? (allies + " vs ") : "vs ") + foes;
|
||
return label + '<div class="fbody"><div class="fdesc" title="' + esc(full) + '">' + esc(full) + '</div>' + chOpts + '</div>';
|
||
}
|
||
return label + chOpts;
|
||
}
|
||
// 线性 / 单出口:角色(可选)+ 文本
|
||
const sm = summary(ir, names, node);
|
||
const actor = node.speaker || node.actor;
|
||
let body = actor ? ('<div class="who">' + esc(nameOf(ir, names, actor)) + '</div>') : "";
|
||
body += '<div class="t">' + esc(sm[1] || "") + '</div>';
|
||
return label + '<div class="nbody">' + body + '</div>';
|
||
}
|
||
|
||
// ---------- 自动布局:最长路径分层 + 每层顺序铺开 ----------
|
||
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));
|
||
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);
|
||
},
|
||
// 把画布平移到开头节点(ir.nodes[0]):水平靠左、垂直居中
|
||
focusStart(ir) {
|
||
const startId = (ir.nodes && ir.nodes[0]) ? ir.nodes[0].id : null;
|
||
if (!startId) return;
|
||
const apply = () => {
|
||
const p = (ir._layout || {})[startId]; if (!p) return;
|
||
const z = editor.zoom || 1;
|
||
const host = document.getElementById("drawflow");
|
||
const H = (host && host.clientHeight) || 500;
|
||
const tx = 70 - p.x * z, ty = H / 2 - p.y * z - 45; // 左侧 + 垂直居中(-45≈半节点高)
|
||
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) {}
|
||
};
|
||
apply();
|
||
if (typeof requestAnimationFrame === "function") requestAnimationFrame(apply);
|
||
},
|
||
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); },
|
||
};
|
||
})();
|