158 lines
8.4 KiB
JavaScript
158 lines
8.4 KiB
JavaScript
// 分支树渲染(从 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;
|
||
})();
|