init: 剧情事件协作 Web 编辑器独立仓(从 SGame/tools/event_authoring 拆出)

This commit is contained in:
bia
2026-06-08 16:50:27 +08:00
commit f5669dc01d
32 changed files with 3497 additions and 0 deletions

157
web/static/tree.js Normal file
View File

@ -0,0 +1,157 @@
// 分支树渲染(从 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
window._treeGrantStr = grantStr;
window._treeNameOf = nameOf;
window._treeRoleNames = roleNames;
})();