Files
story-edit-web/web/static/graph.js
邓雨鹏 f2ebbbd87a feat(graph): 战斗节点显示战斗描述(类型+敌我)
战斗节点原本只显示胜/败两个出口、看不出打谁。现在在胜/败行上方加一行
'击倒/死斗 · [我方 vs] 敌方角色名'。描述使内容变高,故 .kind-fight .outputs
用 align-self:stretch + justify-content:flex-end 把胜/败两个黄点压到底部两行,
维持端口与出口行逐行对齐。
2026-06-09 10:58:31 +08:00

262 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
// ---------- 出口模型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) {
let desc = "";
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;
desc = '<div class="fdesc" title="' + esc(full) + '">' + esc(full) + '</div>';
}
return label + desc + '<div class="ch-opts">' + outs.map(o => '<div class="ch-opt" title="' + esc(o.label) + '">' + esc(o.label) + '</div>').join("") + '</div>';
}
// 线性 / 单出口:角色(可选)+ 文本
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); },
};
})();