// 分支树渲染(从 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 = '
' + k + '
' + esc(t || id) + '
'; if (n.kind === "ending") { const g = (n.grants && n.grants.length) ? n.grants.map(gr => grantStr(ir, names, gr)).join(",") : "无奖励"; inner += '
' + esc(g) + '
'; } 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 = ''; Object.entries(COLOR).forEach(([k, c]) => { h += ''; }); h += ''; 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 += ''; 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 += '' + esc(e.label.slice(0, 12)) + ''; } }); svg.innerHTML = h; } function esc(s) { return String(s).replace(/&/g, "&").replace(//g, ">"); } window._treeGrantStr = grantStr; window._treeNameOf = nameOf; window._treeRoleNames = roleNames; })();