节点编辑器改用 Drawflow 拖拽连线版
- 中间画布换成 Drawflow:拖动节点摆位、从出口圆点拉线到目标=建跳转 - 出口端口动态映射 IR:线性next/choice选项/random分支/fight胜败 - 连线/拖动实时写回 IR;节点坐标持久化到 ir._layout(编译忽略) - 右栏表单保留并双向联动;改跳转目标触发画布重渲 - 工具栏:自动整理、加后继;防误删(右栏输入时 Del 不删节点) - 移除旧 tree.js
This commit is contained in:
@ -79,7 +79,7 @@
|
|||||||
App.current = group; App.ir = JSON.parse(JSON.stringify(d.ir));
|
App.current = group; App.ir = JSON.parse(JSON.stringify(d.ir));
|
||||||
App.status = d.status; App.selectedNode = null; App.dirty = false;
|
App.status = d.status; App.selectedNode = null; App.dirty = false;
|
||||||
$("graph-empty").style.display = "none";
|
$("graph-empty").style.display = "none";
|
||||||
["btn-save", "btn-validate", "btn-playtest", "btn-confirm", "btn-discard", "btn-addnode"].forEach(b => $(b).disabled = false);
|
["btn-save", "btn-validate", "btn-playtest", "btn-confirm", "btn-discard", "btn-addnode", "btn-autolayout", "btn-addsucc"].forEach(b => $(b).disabled = false);
|
||||||
renderAll(true);
|
renderAll(true);
|
||||||
renderList();
|
renderList();
|
||||||
updateDirty();
|
updateDirty();
|
||||||
@ -88,35 +88,33 @@
|
|||||||
const ctx = () => ({
|
const ctx = () => ({
|
||||||
dict: App.dict,
|
dict: App.dict,
|
||||||
pointNames: (App.pointsets[(App.ir.stage || {}).point_set || App.ir.id] || {}).points || [],
|
pointNames: (App.pointsets[(App.ir.stage || {}).point_set || App.ir.id] || {}).points || [],
|
||||||
onChange: structural => { App.dirty = true; updateDirty(); drawTree(); if (structural) { FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, App.selectedNode, ctx()); } },
|
onChange: structural => {
|
||||||
|
App.dirty = true; updateDirty();
|
||||||
|
if (structural) { GraphUI.render(App.ir, App.selectedNode); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, App.selectedNode, ctx()); }
|
||||||
|
else { GraphUI.updateLabel(App.ir, App.selectedNode); }
|
||||||
|
},
|
||||||
selectNode: id => { App.selectedNode = id; },
|
selectNode: id => { App.selectedNode = id; },
|
||||||
});
|
});
|
||||||
|
|
||||||
function drawTree() {
|
function selectNode(id) { App.selectedNode = id; GraphUI.select(id); FormUI.renderNode(App.ir, id, ctx()); }
|
||||||
if (App.ir) renderTree(App.ir, {
|
|
||||||
selected: App.selectedNode, onSelect: selectNode,
|
|
||||||
onAddNext: addSuccessor, onDelete: deleteNode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function selectNode(id) { App.selectedNode = id; drawTree(); FormUI.renderNode(App.ir, id, ctx()); }
|
|
||||||
|
|
||||||
// 节点快捷按钮:加后继 / 删除
|
// 工具栏「加后继」:给当前选中节点建一个后继并自动接线(无选中则忽略)
|
||||||
function addSuccessor(id) {
|
function addSuccessor(id) {
|
||||||
const r = FormUI.addSuccessor(App.ir, id);
|
const r = FormUI.addSuccessor(App.ir, id);
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
if (!r.linked) alert("该战斗节点的「胜→win」「败→lose」出口都已占用,\n新节点已创建但未自动接线——请在右栏把胜或败指向它(id: " + r.id + ")。");
|
if (!r.linked) alert("该战斗节点的「胜→win」「败→lose」出口都已占用,\n新节点已创建但未自动接线——请在右栏把胜或败指向它(id: " + r.id + ")。");
|
||||||
App.dirty = true; selectNode(r.id); FormUI.renderMeta(App.ir, ctx()); updateDirty();
|
App.dirty = true; App.selectedNode = r.id;
|
||||||
|
GraphUI.render(App.ir, r.id); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, r.id, ctx()); updateDirty();
|
||||||
}
|
}
|
||||||
function deleteNode(id) {
|
// 纯数据删除节点/结局(供右栏删除按钮与画布 Del 键共用)
|
||||||
const isEnding = (App.ir.endings || []).some(e => e.id === id);
|
function removeFromIr(id) {
|
||||||
if (!confirm("删除" + (isEnding ? "结局" : "节点") + " " + id + "?指向它的跳转需手动修复(校验会提示)。")) return;
|
App.ir.nodes = (App.ir.nodes || []).filter(n => n.id !== id);
|
||||||
if (isEnding) App.ir.endings = (App.ir.endings || []).filter(e => e.id !== id);
|
App.ir.endings = (App.ir.endings || []).filter(e => e.id !== id);
|
||||||
else App.ir.nodes = (App.ir.nodes || []).filter(n => n.id !== id);
|
if (App.ir._layout) delete App.ir._layout[id];
|
||||||
if (App.selectedNode === id) App.selectedNode = null;
|
if (App.selectedNode === id) App.selectedNode = null;
|
||||||
App.dirty = true; drawTree(); FormUI.renderMeta(App.ir, ctx());
|
App.dirty = true; updateDirty();
|
||||||
FormUI.renderNode(App.ir, App.selectedNode, ctx()); updateDirty();
|
|
||||||
}
|
}
|
||||||
function renderAll() { drawTree(); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, App.selectedNode, ctx()); }
|
function renderAll() { GraphUI.render(App.ir, App.selectedNode); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, App.selectedNode, ctx()); }
|
||||||
|
|
||||||
function updateDirty() {
|
function updateDirty() {
|
||||||
$("btn-save").textContent = App.dirty ? "保存 *" : "保存";
|
$("btn-save").textContent = App.dirty ? "保存 *" : "保存";
|
||||||
@ -127,7 +125,8 @@
|
|||||||
$("btn-addnode").onclick = () => {
|
$("btn-addnode").onclick = () => {
|
||||||
if (!App.ir) return;
|
if (!App.ir) return;
|
||||||
const id = FormUI.newNode(App.ir);
|
const id = FormUI.newNode(App.ir);
|
||||||
App.dirty = true; selectNode(id); FormUI.renderMeta(App.ir, ctx()); updateDirty();
|
App.dirty = true; App.selectedNode = id;
|
||||||
|
GraphUI.render(App.ir, id); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, id, ctx()); updateDirty();
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- 保存 ----------
|
// ---------- 保存 ----------
|
||||||
@ -242,9 +241,27 @@
|
|||||||
|
|
||||||
// ---------- 工具 ----------
|
// ---------- 工具 ----------
|
||||||
function esc(s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
function esc(s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||||||
window.addEventListener("resize", drawTree);
|
|
||||||
|
// ---------- 画布工具栏 ----------
|
||||||
|
$("btn-autolayout").onclick = () => {
|
||||||
|
if (!App.ir) return;
|
||||||
|
App.ir._layout = null; // 清坐标 → render 时按自动布局重排
|
||||||
|
GraphUI.render(App.ir, App.selectedNode);
|
||||||
|
App.dirty = true; updateDirty();
|
||||||
|
};
|
||||||
|
$("btn-addsucc").onclick = () => {
|
||||||
|
if (!App.ir || !App.selectedNode) { alert("先在画布上点选一个节点,再点「加后继」。"); return; }
|
||||||
|
addSuccessor(App.selectedNode);
|
||||||
|
};
|
||||||
|
|
||||||
// ---------- 启动 ----------
|
// ---------- 启动 ----------
|
||||||
|
GraphUI.init("drawflow", {
|
||||||
|
onSelect: id => selectNode(id),
|
||||||
|
onMove: (id, x, y) => { if (!App.ir) return; (App.ir._layout = App.ir._layout || {})[id] = { x: x, y: y }; App.dirty = true; updateDirty(); },
|
||||||
|
onConnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); },
|
||||||
|
onDisconnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); },
|
||||||
|
onRemove: id => { removeFromIr(id); FormUI.renderNode(App.ir, App.selectedNode, ctx()); },
|
||||||
|
});
|
||||||
(async function () {
|
(async function () {
|
||||||
try { const r = await fetch("/api/events?status=all"); if (r.status === 401) { showLogin(); return; } hideLogin(); init(); }
|
try { const r = await fetch("/api/events?status=all"); if (r.status === 401) { showLogin(); return; } hideLogin(); init(); }
|
||||||
catch (e) { showLogin(); }
|
catch (e) { showLogin(); }
|
||||||
|
|||||||
@ -169,12 +169,12 @@
|
|||||||
if (node.kind === "narration") {
|
if (node.kind === "narration") {
|
||||||
host.appendChild(field("说话者(可选)", sel(node.speaker || "P1", [{ value: "P1", label: "P1 玩家" }].concat(slots(ir)), v => { node.speaker = v; mut(1); })));
|
host.appendChild(field("说话者(可选)", sel(node.speaker || "P1", [{ value: "P1", label: "P1 玩家" }].concat(slots(ir)), v => { node.speaker = v; mut(1); })));
|
||||||
host.appendChild(field("文本", area(node.text, v => { node.text = v; mut(1); })));
|
host.appendChild(field("文本", area(node.text, v => { node.text = v; mut(1); })));
|
||||||
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
|
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); })));
|
||||||
} else if (node.kind === "dialogue") {
|
} else if (node.kind === "dialogue") {
|
||||||
host.appendChild(field("说话者 speaker", sel(node.speaker, [{ value: "P1", label: "P1 玩家" }].concat(slots(ir)), v => { node.speaker = v; mut(1); })));
|
host.appendChild(field("说话者 speaker", sel(node.speaker, [{ value: "P1", label: "P1 玩家" }].concat(slots(ir)), v => { node.speaker = v; mut(1); })));
|
||||||
host.appendChild(field("镜头 camera(点位,可选)", sel(node.camera, pointOpts(ir, ctx, node.camera), v => { node.camera = v || undefined; mut(1); })));
|
host.appendChild(field("镜头 camera(点位,可选)", sel(node.camera, pointOpts(ir, ctx, node.camera), v => { node.camera = v || undefined; mut(1); })));
|
||||||
host.appendChild(field("文本", area(node.text, v => { node.text = v; mut(1); })));
|
host.appendChild(field("文本", area(node.text, v => { node.text = v; mut(1); })));
|
||||||
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
|
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); })));
|
||||||
} else if (node.kind === "choice" || node.kind === "choice_once") {
|
} else if (node.kind === "choice" || node.kind === "choice_once") {
|
||||||
const box = el("div", { class: "subbox" });
|
const box = el("div", { class: "subbox" });
|
||||||
box.appendChild(el("div", { class: "hd" }, [
|
box.appendChild(el("div", { class: "hd" }, [
|
||||||
@ -188,7 +188,7 @@
|
|||||||
el("button", { class: "mini", onclick: () => { node.options.splice(i, 1); ctx.onChange(true); } }, ["删"]),
|
el("button", { class: "mini", onclick: () => { node.options.splice(i, 1); ctx.onChange(true); } }, ["删"]),
|
||||||
]));
|
]));
|
||||||
ob.appendChild(field("文本", txt(o.text, v => { o.text = v; ctx.onChange(false); })));
|
ob.appendChild(field("文本", txt(o.text, v => { o.text = v; ctx.onChange(false); })));
|
||||||
ob.appendChild(field("跳转 goto", sel(o.goto, tgt, v => { o.goto = v; ctx.onChange(false); })));
|
ob.appendChild(field("跳转 goto", sel(o.goto, tgt, v => { o.goto = v; ctx.onChange(true); })));
|
||||||
ob.appendChild(condEditor(ir, ctx, o.condition, (c, valOnly) => { if (c) o.condition = c; else delete o.condition; ctx.onChange(!valOnly); }));
|
ob.appendChild(condEditor(ir, ctx, o.condition, (c, valOnly) => { if (c) o.condition = c; else delete o.condition; ctx.onChange(!valOnly); }));
|
||||||
ob.appendChild(grantsEditor(ir, ctx, (o.reward || {}).grants, (gr, valOnly) => { o.reward = { grants: gr }; ctx.onChange(!valOnly); }));
|
ob.appendChild(grantsEditor(ir, ctx, (o.reward || {}).grants, (gr, valOnly) => { o.reward = { grants: gr }; ctx.onChange(!valOnly); }));
|
||||||
// skip
|
// skip
|
||||||
@ -214,7 +214,7 @@
|
|||||||
(node.branches || []).forEach((b, i) => {
|
(node.branches || []).forEach((b, i) => {
|
||||||
box.appendChild(el("div", { class: "row2" }, [
|
box.appendChild(el("div", { class: "row2" }, [
|
||||||
field("权重", num(b.weight, v => { b.weight = v; ctx.onChange(false); })),
|
field("权重", num(b.weight, v => { b.weight = v; ctx.onChange(false); })),
|
||||||
field("跳转", sel(b.goto, tgt, v => { b.goto = v; ctx.onChange(false); })),
|
field("跳转", sel(b.goto, tgt, v => { b.goto = v; ctx.onChange(true); })),
|
||||||
el("button", { class: "mini", onclick: () => { node.branches.splice(i, 1); ctx.onChange(true); } }, ["删"]),
|
el("button", { class: "mini", onclick: () => { node.branches.splice(i, 1); ctx.onChange(true); } }, ["删"]),
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
@ -224,8 +224,8 @@
|
|||||||
host.appendChild(campPicker("我方 camp1", ir, node, "camp1", ctx, true));
|
host.appendChild(campPicker("我方 camp1", ir, node, "camp1", ctx, true));
|
||||||
host.appendChild(campPicker("敌方 camp2", ir, node, "camp2", ctx, false));
|
host.appendChild(campPicker("敌方 camp2", ir, node, "camp2", ctx, false));
|
||||||
host.appendChild(el("div", { class: "row2" }, [
|
host.appendChild(el("div", { class: "row2" }, [
|
||||||
field("胜 → win", sel(node.win, tgt, v => { node.win = v; ctx.onChange(false); })),
|
field("胜 → win", sel(node.win, tgt, v => { node.win = v; ctx.onChange(true); })),
|
||||||
field("败 → lose", sel(node.lose, tgt, v => { node.lose = v; ctx.onChange(false); })),
|
field("败 → lose", sel(node.lose, tgt, v => { node.lose = v; ctx.onChange(true); })),
|
||||||
]));
|
]));
|
||||||
} else if (node.kind === "move") {
|
} else if (node.kind === "move") {
|
||||||
host.appendChild(field("移动者 actor", sel(node.actor, [{ value: "P1", label: "P1 玩家" }].concat(slots(ir)), v => { node.actor = v; mut(1); })));
|
host.appendChild(field("移动者 actor", sel(node.actor, [{ value: "P1", label: "P1 玩家" }].concat(slots(ir)), v => { node.actor = v; mut(1); })));
|
||||||
@ -237,21 +237,21 @@
|
|||||||
field("动作 ani", txt(node.ani, v => { node.ani = v; ctx.onChange(false); })),
|
field("动作 ani", txt(node.ani, v => { node.ani = v; ctx.onChange(false); })),
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
|
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); })));
|
||||||
} else if (node.kind === "anim") {
|
} else if (node.kind === "anim") {
|
||||||
host.appendChild(field("角色 actor", sel(node.actor, [{ value: "P1", label: "P1 玩家" }].concat(slots(ir)), v => { node.actor = v; mut(1); })));
|
host.appendChild(field("角色 actor", sel(node.actor, [{ value: "P1", label: "P1 玩家" }].concat(slots(ir)), v => { node.actor = v; mut(1); })));
|
||||||
host.appendChild(el("div", { class: "row2" }, [
|
host.appendChild(el("div", { class: "row2" }, [
|
||||||
field("动画 ani", txt(node.ani, v => { node.ani = v; ctx.onChange(false); })),
|
field("动画 ani", txt(node.ani, v => { node.ani = v; ctx.onChange(false); })),
|
||||||
field("朝向 angle(可选)", num(node.angle, v => { if (v == null) delete node.angle; else node.angle = v; ctx.onChange(false); })),
|
field("朝向 angle(可选)", num(node.angle, v => { if (v == null) delete node.angle; else node.angle = v; ctx.onChange(false); })),
|
||||||
]));
|
]));
|
||||||
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
|
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); })));
|
||||||
} else if (node.kind === "reward") {
|
} else if (node.kind === "reward") {
|
||||||
host.appendChild(grantsEditor(ir, ctx, node.grants, (gr, valOnly) => { node.grants = gr; ctx.onChange(!valOnly); }));
|
host.appendChild(grantsEditor(ir, ctx, node.grants, (gr, valOnly) => { node.grants = gr; ctx.onChange(!valOnly); }));
|
||||||
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
|
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); })));
|
||||||
} else if (node.kind === "out_ref") {
|
} else if (node.kind === "out_ref") {
|
||||||
const seqs = (ir.sequences || []).map(s => ({ value: s.id, label: s.id }));
|
const seqs = (ir.sequences || []).map(s => ({ value: s.id, label: s.id }));
|
||||||
host.appendChild(field("引用子序列 ref", sel(node.ref, [{ value: "", label: "(无)" }].concat(seqs), v => { node.ref = v; ctx.onChange(false); })));
|
host.appendChild(field("引用子序列 ref", sel(node.ref, [{ value: "", label: "(无)" }].concat(seqs), v => { node.ref = v; ctx.onChange(false); })));
|
||||||
host.appendChild(field("出口接回 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
|
host.appendChild(field("出口接回 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); })));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除节点
|
// 删除节点
|
||||||
|
|||||||
208
web/static/graph.js
Normal file
208
web/static/graph.js
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
// 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); },
|
||||||
|
};
|
||||||
|
})();
|
||||||
@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Story 协作编辑器 · M5</title>
|
<title>Story 协作编辑器 · M5</title>
|
||||||
|
<link rel="stylesheet" href="vendor/drawflow.min.css">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -51,9 +52,14 @@
|
|||||||
<div id="event-list"></div>
|
<div id="event-list"></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- 中:分支树 -->
|
<!-- 中:分支图(Drawflow 可拖拽连线) -->
|
||||||
<main id="graph-pane">
|
<main id="graph-pane">
|
||||||
<div id="graph"><svg id="svg"></svg><div id="layers"></div></div>
|
<div id="graph-tools">
|
||||||
|
<button id="btn-autolayout" class="mini" disabled>自动整理</button>
|
||||||
|
<button id="btn-addsucc" class="mini" disabled>加后继</button>
|
||||||
|
<span class="tip">拖出口圆点→目标节点 = 连跳转 · 拖动摆位 · 选中按 Del 删除</span>
|
||||||
|
</div>
|
||||||
|
<div id="drawflow"></div>
|
||||||
<div id="graph-empty" class="empty-center">从左侧选择一个事件</div>
|
<div id="graph-empty" class="empty-center">从左侧选择一个事件</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@ -107,7 +113,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="tree.js"></script>
|
<script src="vendor/drawflow.min.js"></script>
|
||||||
|
<script src="graph.js"></script>
|
||||||
<script src="form.js"></script>
|
<script src="form.js"></script>
|
||||||
<script src="playtest.js"></script>
|
<script src="playtest.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
|||||||
@ -39,12 +39,15 @@ button.mini { padding:2px 8px; font-size:12px; }
|
|||||||
.b-confirmed{ background:#1f3a24; color:#7ad88a; }
|
.b-confirmed{ background:#1f3a24; color:#7ad88a; }
|
||||||
.b-discarded{ background:#3a2020; color:#d88; }
|
.b-discarded{ background:#3a2020; color:#d88; }
|
||||||
|
|
||||||
#graph-pane { flex:1; position:relative; min-width:0; }
|
#graph-pane { flex:1; min-width:0; display:flex; flex-direction:column; position:relative; }
|
||||||
#graph { position:absolute; inset:0; overflow:auto; padding:30px; }
|
#graph-tools { flex:none; display:flex; align-items:center; gap:8px; padding:6px 10px;
|
||||||
#svg { position:absolute; top:0; left:0; pointer-events:none; }
|
background:#19150f; border-bottom:1px solid #3a322a; }
|
||||||
#layers { position:relative; z-index:2; }
|
#graph-tools .tip { font-size:11.5px; color:#7a7264; }
|
||||||
|
#drawflow { position:relative; flex:1; min-height:0; background:#161310;
|
||||||
|
background-image:radial-gradient(#2a2419 1.1px, transparent 1.1px);
|
||||||
|
background-size:22px 22px; }
|
||||||
.empty-center { position:absolute; inset:0; display:flex; align-items:center;
|
.empty-center { position:absolute; inset:0; display:flex; align-items:center;
|
||||||
justify-content:center; color:#6a6256; font-size:15px; }
|
justify-content:center; color:#6a6256; font-size:15px; pointer-events:none; }
|
||||||
|
|
||||||
#edit-pane { width:370px; background:#1c1813; border-left:1px solid #3a322a;
|
#edit-pane { width:370px; background:#1c1813; border-left:1px solid #3a322a;
|
||||||
overflow:auto; flex:none; }
|
overflow:auto; flex:none; }
|
||||||
@ -82,6 +85,35 @@ button.mini { padding:2px 8px; font-size:12px; }
|
|||||||
clip-path: polygon(16px 0, calc(100% - 16px) 0, 100% 50%, calc(100% - 16px) 100%, 16px 100%, 0 50%); }
|
clip-path: polygon(16px 0, calc(100% - 16px) 0, 100% 50%, calc(100% - 16px) 100%, 16px 100%, 0 50%); }
|
||||||
.node.kind-choice .k, .node.kind-choice_once .k { color:#9ec0f0; }
|
.node.kind-choice .k, .node.kind-choice_once .k { color:#9ec0f0; }
|
||||||
|
|
||||||
|
/* ---- Drawflow 节点(拖拽连线版) ---- */
|
||||||
|
#drawflow .drawflow-node { background:#262019; border:1.5px solid #4a4030; border-radius:9px;
|
||||||
|
padding:8px 12px; width:192px; color:#e8e0d4; box-shadow:0 2px 6px rgba(0,0,0,.4); }
|
||||||
|
#drawflow .drawflow-node:hover { border-color:#e6c878; }
|
||||||
|
#drawflow .drawflow-node.selected { border-color:#e6c878; box-shadow:0 0 0 2px rgba(230,200,120,.45); }
|
||||||
|
#drawflow .drawflow_content_node { width:100%; }
|
||||||
|
.drawflow-node .nid { font-size:10px; color:#6a6256; }
|
||||||
|
.drawflow-node .k { font-size:11px; color:#b89a5a; font-weight:bold; }
|
||||||
|
.drawflow-node .t { font-size:12.5px; margin-top:2px; line-height:1.35; color:#ddd3c2; word-break:break-all; }
|
||||||
|
.drawflow-node .outs { margin-top:5px; display:flex; flex-direction:column; gap:2px; }
|
||||||
|
.drawflow-node .outs span { font-size:10.5px; color:#9ec0f0; }
|
||||||
|
.drawflow-node .rw { font-size:11px; color:#c9a86a; margin-top:4px;
|
||||||
|
border-top:1px dashed #6a5630; padding-top:4px; }
|
||||||
|
#drawflow .kind-ending { background:#3a2a17; border-color:#e0a850; }
|
||||||
|
#drawflow .kind-ending .k { color:#f2c463; }
|
||||||
|
#drawflow .kind-fight { border-color:#7a4a4a; background:#2a1c1c; }
|
||||||
|
#drawflow .kind-fight .k { color:#d87878; }
|
||||||
|
#drawflow .kind-out_ref { border-style:dashed; border-color:#7a7ad8; background:#1d1d2a; }
|
||||||
|
#drawflow .kind-out_ref .k { color:#9e9ef0; }
|
||||||
|
#drawflow .kind-choice, #drawflow .kind-choice_once { background:#1d2840; border-color:#3a527a; }
|
||||||
|
#drawflow .kind-choice .k, #drawflow .kind-choice_once .k { color:#9ec0f0; }
|
||||||
|
/* 端口圆点 */
|
||||||
|
#drawflow .drawflow-node .input, #drawflow .drawflow-node .output {
|
||||||
|
background:#e6c878; border:2px solid #161310; width:15px; height:15px; }
|
||||||
|
#drawflow .drawflow-node .input:hover, #drawflow .drawflow-node .output:hover { background:#f0d68a; }
|
||||||
|
/* 连线 */
|
||||||
|
#drawflow .connection .main-path { stroke:#7a96c8; stroke-width:2.4px; }
|
||||||
|
#drawflow .connection .main-path:hover { stroke:#e6c878; }
|
||||||
|
|
||||||
/* ---- form ---- */
|
/* ---- form ---- */
|
||||||
.fld { margin:9px 0; }
|
.fld { margin:9px 0; }
|
||||||
.fld > label { display:block; font-size:12px; color:#9a8f7e; margin-bottom:3px; }
|
.fld > label { display:block; font-size:12px; color:#9a8f7e; margin-bottom:3px; }
|
||||||
|
|||||||
@ -1,157 +0,0 @@
|
|||||||
// 分支树渲染(从 ir_to_html.py 的 TEMPLATE 抽出,加 onSelect 回调供编辑)。
|
|
||||||
// 用法: renderTree(ir, { onSelect:id=>{}, selected:'n1' })
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
const ROW = 150, SP = 232, NW = 188;
|
|
||||||
const COLOR = { next:"#6a6256", option:"#7aa0d8", random:"#a07ad8",
|
|
||||||
win:"#7ac88a", lose:"#d87878", ref:"#9e9ef0" };
|
|
||||||
|
|
||||||
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, 28)];
|
|
||||||
if (n.kind === "dialogue") return ["对话 · " + nameOf(ir, names, n.speaker), (n.text || "").slice(0, 24)];
|
|
||||||
if (n.kind === "choice") return ["选择 (" + (n.options || []).length + "项)", (n.options || []).map(o => o.text).join(" / ").slice(0, 30)];
|
|
||||||
if (n.kind === "choice_once") return ["一次性选择", (n.options || []).map(o => o.text).join(" / ").slice(0, 30)];
|
|
||||||
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, ""];
|
|
||||||
}
|
|
||||||
|
|
||||||
window.renderTree = function (ir, opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
const names = roleNames(ir);
|
|
||||||
const layersDiv = document.getElementById("layers");
|
|
||||||
const svg = document.getElementById("svg");
|
|
||||||
layersDiv.innerHTML = ""; svg.innerHTML = "";
|
|
||||||
|
|
||||||
// 节点 (含结局)
|
|
||||||
const nodes = {};
|
|
||||||
(ir.nodes || []).forEach(n => nodes[n.id] = Object.assign({ _end: false }, n));
|
|
||||||
(ir.endings || []).forEach(e => nodes[e.id] = Object.assign({ _end: true, kind: "ending" }, e));
|
|
||||||
|
|
||||||
// 边
|
|
||||||
const edges = [];
|
|
||||||
const add = (u, v, type, label) => { if (v && nodes[v]) edges.push({ u, v, type, label: label || "" }); };
|
|
||||||
(ir.nodes || []).forEach(n => {
|
|
||||||
if (n.next) add(n.id, n.next, n.kind === "out_ref" ? "ref" : "next");
|
|
||||||
(n.options || []).forEach(o => add(n.id, o.goto, "option", o.text));
|
|
||||||
(n.branches || []).forEach(b => add(n.id, b.goto, "random", "权重" + (b.weight != null ? b.weight : "")));
|
|
||||||
if (n.kind === "fight") { add(n.id, n.win, "win", "胜"); add(n.id, n.lose, "lose", "败"); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// 最长路径分层
|
|
||||||
const layer = {}; Object.keys(nodes).forEach(id => layer[id] = 0);
|
|
||||||
let changed = true, guard = 0;
|
|
||||||
while (changed && guard++ < 999) {
|
|
||||||
changed = false;
|
|
||||||
edges.forEach(e => { if (layer[e.v] < layer[e.u] + 1) { layer[e.v] = layer[e.u] + 1; changed = true; } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 子树居中布局
|
|
||||||
const childMap = {}; Object.keys(nodes).forEach(id => childMap[id] = []);
|
|
||||||
const indeg = {}; Object.keys(nodes).forEach(id => indeg[id] = 0);
|
|
||||||
const seenE = new Set();
|
|
||||||
edges.forEach(e => { const k = e.u + ">" + e.v; if (!seenE.has(k)) { seenE.add(k); childMap[e.u].push(e.v); indeg[e.v]++; } });
|
|
||||||
let roots = Object.keys(nodes).filter(id => indeg[id] === 0);
|
|
||||||
if (!roots.length) roots = [Object.keys(nodes)[0]];
|
|
||||||
const xpos = {}; let nextX = 0; const vis = new Set();
|
|
||||||
function assignX(id) {
|
|
||||||
if (!id || vis.has(id)) return; vis.add(id);
|
|
||||||
if (childMap[id].length === 0) { xpos[id] = nextX; nextX += SP; return; }
|
|
||||||
childMap[id].forEach(assignX);
|
|
||||||
const placed = childMap[id].map(c => xpos[c]).filter(v => v !== undefined);
|
|
||||||
xpos[id] = placed.length ? (Math.min(...placed) + Math.max(...placed)) / 2 : (nextX += SP, nextX - SP);
|
|
||||||
}
|
|
||||||
roots.forEach(assignX);
|
|
||||||
Object.keys(nodes).forEach(id => { if (xpos[id] === undefined) { xpos[id] = nextX; nextX += SP; } });
|
|
||||||
|
|
||||||
let maxX = 0, maxL = 0;
|
|
||||||
Object.keys(nodes).forEach(id => {
|
|
||||||
const n = nodes[id], [k, t] = summary(ir, names, n);
|
|
||||||
const d = document.createElement("div");
|
|
||||||
d.className = "node kind-" + n.kind + (id === opts.selected ? " sel" : "");
|
|
||||||
d.id = "node-" + id;
|
|
||||||
let inner = '<div class="k">' + k + '</div><div class="t">' + esc(t || id) + '</div>';
|
|
||||||
if (n.kind === "ending") {
|
|
||||||
const g = (n.grants && n.grants.length) ? n.grants.map(gr => grantStr(ir, names, gr)).join(",") : "无奖励";
|
|
||||||
inner += '<div class="rw">' + esc(g) + '</div>';
|
|
||||||
}
|
|
||||||
d.innerHTML = inner;
|
|
||||||
d.style.left = xpos[id] + "px"; d.style.top = (layer[id] * ROW) + "px";
|
|
||||||
d.onclick = () => opts.onSelect && opts.onSelect(id);
|
|
||||||
layersDiv.appendChild(d);
|
|
||||||
// 选中节点:浮出快捷按钮。作为画布独立元素按坐标定位,避免被 choice 等节点的 clip-path 裁切。
|
|
||||||
if (id === opts.selected) {
|
|
||||||
const bar = document.createElement("div");
|
|
||||||
bar.className = "node-acts";
|
|
||||||
bar.style.left = (xpos[id] + NW + 2) + "px"; // 右缘对齐节点右上角
|
|
||||||
bar.style.top = (layer[id] * ROW - 13) + "px";
|
|
||||||
const mk = (cls, label, title, fn) => {
|
|
||||||
const b = document.createElement("button");
|
|
||||||
b.className = "nact " + cls; b.textContent = label; b.title = title;
|
|
||||||
b.onclick = e => { e.stopPropagation(); fn(id); };
|
|
||||||
return b;
|
|
||||||
};
|
|
||||||
if (opts.onAddNext && !n._end) bar.appendChild(mk("add", "+后继", "新建一个节点并自动接到这里", opts.onAddNext));
|
|
||||||
if (opts.onDelete) bar.appendChild(mk("del", "✕", n._end ? "删除此结局" : "删除此节点", opts.onDelete));
|
|
||||||
layersDiv.appendChild(bar);
|
|
||||||
}
|
|
||||||
maxX = Math.max(maxX, xpos[id]); maxL = Math.max(maxL, layer[id]);
|
|
||||||
});
|
|
||||||
layersDiv.style.width = (maxX + NW + 40) + "px";
|
|
||||||
layersDiv.style.height = (maxL * ROW + 180) + "px";
|
|
||||||
|
|
||||||
drawEdges(edges);
|
|
||||||
};
|
|
||||||
|
|
||||||
function drawEdges(edges) {
|
|
||||||
const g = document.getElementById("graph"), svg = document.getElementById("svg");
|
|
||||||
const gb = g.getBoundingClientRect();
|
|
||||||
svg.setAttribute("width", g.scrollWidth); svg.setAttribute("height", g.scrollHeight);
|
|
||||||
let h = '<defs>';
|
|
||||||
Object.entries(COLOR).forEach(([k, c]) => {
|
|
||||||
h += '<marker id="ar-' + k + '" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="' + c + '"/></marker>';
|
|
||||||
});
|
|
||||||
h += '</defs>';
|
|
||||||
edges.forEach(e => {
|
|
||||||
const a = document.getElementById("node-" + e.u), b = document.getElementById("node-" + e.v);
|
|
||||||
if (!a || !b) return;
|
|
||||||
const ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
|
|
||||||
const x1 = ra.left - gb.left + g.scrollLeft + ra.width / 2, y1 = ra.bottom - gb.top + g.scrollTop;
|
|
||||||
const x2 = rb.left - gb.left + g.scrollLeft + rb.width / 2, y2 = rb.top - gb.top + g.scrollTop;
|
|
||||||
const c = COLOR[e.type] || "#6a6256", my = (y1 + y2) / 2;
|
|
||||||
h += '<path d="M' + x1 + ',' + y1 + ' C' + x1 + ',' + my + ' ' + x2 + ',' + my + ' ' + x2 + ',' + y2 + '" stroke="' + c + '" stroke-width="2.2" fill="none" opacity="0.92" marker-end="url(#ar-' + e.type + ')"' + (e.type === "option" ? ' stroke-dasharray="7,4"' : '') + '/>';
|
|
||||||
if (e.label) {
|
|
||||||
const t = 0.8, mt = 1 - t;
|
|
||||||
const lx = mt * mt * mt * x1 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t * x2;
|
|
||||||
const ly = mt * mt * mt * y1 + 3 * mt * mt * t * my + 3 * mt * t * t * my + t * t * t * y2;
|
|
||||||
h += '<text x="' + lx + '" y="' + ly + '" fill="' + c + '" font-size="11.5" text-anchor="middle" stroke="#161310" stroke-width="3.5" paint-order="stroke" style="font-weight:bold">' + esc(e.label.slice(0, 12)) + '</text>';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
svg.innerHTML = h;
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
|
||||||
window._treeGrantStr = grantStr;
|
|
||||||
window._treeNameOf = nameOf;
|
|
||||||
window._treeRoleNames = roleNames;
|
|
||||||
})();
|
|
||||||
1
web/static/vendor/drawflow.min.css
vendored
Normal file
1
web/static/vendor/drawflow.min.css
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
.drawflow,.drawflow .parent-node{position:relative}.parent-drawflow{display:flex;overflow:hidden;touch-action:none;outline:0}.drawflow{width:100%;height:100%;user-select:none;perspective:0}.drawflow .drawflow-node{display:flex;align-items:center;position:absolute;background:#0ff;width:160px;min-height:40px;border-radius:4px;border:2px solid #000;color:#000;z-index:2;padding:15px}.drawflow .drawflow-node.selected{background:red}.drawflow .drawflow-node:hover{cursor:move}.drawflow .drawflow-node .inputs,.drawflow .drawflow-node .outputs{width:0}.drawflow .drawflow-node .drawflow_content_node{width:100%;display:block}.drawflow .drawflow-node .input,.drawflow .drawflow-node .output{position:relative;width:20px;height:20px;background:#fff;border-radius:50%;border:2px solid #000;cursor:crosshair;z-index:1;margin-bottom:5px}.drawflow .drawflow-node .input{left:-27px;top:2px;background:#ff0}.drawflow .drawflow-node .output{right:-3px;top:2px}.drawflow svg{z-index:0;position:absolute;overflow:visible!important}.drawflow .connection{position:absolute;pointer-events:none;aspect-ratio:1/1}.drawflow .connection .main-path{fill:none;stroke-width:5px;stroke:#4682b4;pointer-events:all}.drawflow .connection .main-path:hover{stroke:#1266ab;cursor:pointer}.drawflow .connection .main-path.selected{stroke:#43b993}.drawflow .connection .point{cursor:move;stroke:#000;stroke-width:2;fill:#fff;pointer-events:all}.drawflow .connection .point.selected,.drawflow .connection .point:hover{fill:#1266ab}.drawflow .main-path{fill:none;stroke-width:5px;stroke:#4682b4}.drawflow-delete{position:absolute;display:block;width:30px;height:30px;background:#000;color:#fff;z-index:4;border:2px solid #fff;line-height:30px;font-weight:700;text-align:center;border-radius:50%;font-family:monospace;cursor:pointer}.drawflow>.drawflow-delete{margin-left:-15px;margin-top:15px}.parent-node .drawflow-delete{right:-15px;top:-15px}
|
||||||
1
web/static/vendor/drawflow.min.js
vendored
Normal file
1
web/static/vendor/drawflow.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user