Files
story-edit-web/web/static/tree.js

158 lines
8.4 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.

// 分支树渲染(从 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;
})();