Files
story-edit-web/web/static/form.js
邓雨鹏 021080dd56 feat(timeline): P2 并行编排——scene 多轨编辑器 + 白模重叠预览
剧情 Timeline P2 前端 + 共享内核(与 SGame 源真同步):
- ir_core/IR_SCHEMA/样张:scene v0.3 + scene 校验 + 导出 gate(D3),与 SGame 仓逐字一致
- timeline.js:appendScene 按 authored start 铺多轨 clip(自然重叠预览),move from 同 actor 跨轨续连(D4);
  drawStage 改逐 actor 查对话→多人气泡同时计时;导出 _clipDur 纯函数;show() 加 startId 参;常量加 CAMERA_DUR
- scene_edit.js(新):演出段编辑模态——拖 clip 改 start(吸附 0.1s)、拖右缘改 dur、增删 clip/轨道、
  选中属性条精确编辑、客户端轻量 lint(镜像 validate.py)、▶ 预览此段(复用播放核)
- graph.js:scene 节点(KIND_CN/summary/nodeInner 列轨道)+双击进编辑模态
- form.js:右栏 renderScene 精确数值编辑(轨道/clip 的 start/dur/kind/目标)+打开编辑器按钮
- app.py export:捕获 CompileError 并入 report(scene 被拦时不再 500)
- test_scene.js:离线 10 断言全过(重叠确凿/晚 1.5s 起步/from 续连);gitignore 忽略本地 _localdemo.db

待浏览器目测拖拽编辑落 IR + 白模重叠演出。
2026-06-13 22:34:29 +08:00

391 lines
22 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.

// 右栏表单编辑:元信息 + 角色表 + 按 kind 的节点表单 + grant/condition 编辑器。
// ctx = { dict, pointNames:[], onChange(structural), selectNode(id) }
// onChange(true) -> 结构变化(增删节点/选项、改 kind调用方重画树 + 重渲表单
// onChange(false) -> 仅字段值变化:调用方重画树(更新标签)
(function () {
const NODE_KINDS = ["narration", "dialogue", "choice", "choice_once", "random",
"fight", "move", "anim", "reward", "out_ref", "scene"];
const SCENE_CLIP_KINDS = ["move", "dialogue", "narration", "anim", "camera", "wait"];
// ---- DOM 小工具 ----
function el(tag, attrs, kids) {
const e = document.createElement(tag);
if (attrs) for (const k in attrs) {
if (k === "class") e.className = attrs[k];
else if (k.startsWith("on")) e[k] = attrs[k];
else if (attrs[k] != null) e.setAttribute(k, attrs[k]);
}
(kids || []).forEach(c => e.appendChild(typeof c === "string" ? document.createTextNode(c) : c));
return e;
}
function field(label, input) { return el("div", { class: "fld" }, [el("label", {}, [label]), input]); }
function txt(val, oninput) { const i = el("input", { type: "text", value: val == null ? "" : val }); i.oninput = () => oninput(i.value); return i; }
function area(val, oninput) { const t = el("textarea", {}, [val || ""]); t.oninput = () => oninput(t.value); return t; }
function num(val, oninput) { const i = el("input", { type: "number", value: val == null ? "" : val }); i.oninput = () => oninput(i.value === "" ? null : Number(i.value)); return i; }
function sel(val, opts, onchange) {
const s = el("select");
opts.forEach(o => { const op = el("option", { value: o.value }, [o.label]); if (String(o.value) === String(val)) op.selected = true; s.appendChild(op); });
s.onchange = () => onchange(s.value);
return s;
}
// ---- 选项来源 ----
function targets(ir) {
const ids = (ir.nodes || []).map(n => n.id).concat((ir.endings || []).map(e => e.id));
return [{ value: "", label: "(无 / 留空)" }].concat(ids.map(i => ({ value: i, label: i })));
}
function slots(ir, withPlayer) {
const list = (ir.roles || []).map(r => ({ value: r.slot, label: r.slot + " " + r.name }));
return (withPlayer ? [] : []).concat(list);
}
function pointOpts(ir, ctx, cur) {
const set = new Set((ctx.pointNames || []).concat((ir.roles || []).map(r => r.slot)));
if (cur) set.add(cur);
return [{ value: "", label: "(无)" }].concat([...set].map(p => ({ value: p, label: p })));
}
function grantKinds(ctx) { return Object.keys((ctx.dict || {}).grants || {}); }
function condKinds(ctx) { return Object.keys((ctx.dict || {}).conditions || {}); }
// ---- grant 编辑器 ----
function grantsEditor(ir, ctx, grants, onMut) {
grants = grants || [];
const box = el("div", { class: "subbox" });
box.appendChild(el("div", { class: "hd" }, [
el("span", {}, ["奖励 grants"]),
el("button", { class: "mini", onclick: () => { grants.push({ kind: grantKinds(ctx)[0], value: 0 }); onMut(grants); } }, [""]),
]));
grants.forEach((g, i) => {
const row = el("div", { class: "fld" });
const head = el("div", { class: "row2" }, [
sel(g.kind, grantKinds(ctx).map(k => ({ value: k, label: k })), v => { grants[i] = { kind: v, value: 0 }; onMut(grants); }),
el("button", { class: "mini", onclick: () => { grants.splice(i, 1); onMut(grants); } }, ["删"]),
]);
row.appendChild(head);
const form = ((ctx.dict.grants[g.kind]) || {}).form;
const fields = el("div", { class: "row2" });
if (form === "money") fields.appendChild(field("数值(±)", num(g.value, v => { g.value = v; onMut(grants, true); })));
else if (form === "item") {
fields.appendChild(field("道具ID", txt(g.item, v => { g.item = v; onMut(grants, true); })));
fields.appendChild(field("数量", num(g.value, v => { g.value = v; onMut(grants, true); })));
} else if (form === "friend") {
fields.appendChild(field("对象", sel(g.target, slots(ir), v => { g.target = v; onMut(grants, true); })));
fields.appendChild(field("数值", num(g.value, v => { g.value = v; onMut(grants, true); })));
} else if (form === "join") {
fields.appendChild(field("门派(对象)", sel(g.target, slots(ir), v => { g.target = v; onMut(grants, true); })));
}
if (fields.children.length) row.appendChild(fields);
box.appendChild(row);
});
if (!grants.length) box.appendChild(el("div", { class: "empty" }, ["无"]));
return box;
}
// ---- condition 编辑器 ----
function condEditor(ir, ctx, cond, setCond) {
const box = el("div", { class: "subbox" });
box.appendChild(el("div", { class: "hd" }, [
el("span", {}, ["条件 condition"]),
el("button", { class: "mini", onclick: () => setCond(cond ? null : { kind: condKinds(ctx)[0], op: ">=", value: 0 }) }, [cond ? "移除" : ""]),
]));
if (cond) {
const ops = Object.keys((ctx.dict.conditions[cond.kind] || {}).ops || { ">=": 1 });
box.appendChild(el("div", { class: "row2" }, [
sel(cond.kind, condKinds(ctx).map(k => ({ value: k, label: k })), v => { cond.kind = v; setCond(cond, true); }),
sel(cond.op, ops.map(o => ({ value: o, label: o })), v => { cond.op = v; setCond(cond, true); }),
num(cond.value, v => { cond.value = v; setCond(cond, true); }),
]));
}
return box;
}
// ========== 元信息 + 角色表 ==========
window.FormUI = window.FormUI || {};
FormUI.renderMeta = function (ir, ctx) {
const host = document.getElementById("meta-edit");
host.innerHTML = "";
const psHint = (ctx.pointNames && ctx.pointNames.length)
? ("点位集: " + ctx.pointNames.length + " 点") : "(无点位集,坐标校验降级警告)";
host.appendChild(field("标题", txt(ir.title, v => { ir.title = v; ctx.onChange(false); })));
host.appendChild(el("div", { class: "row2" }, [
field("主题", txt(ir.theme, v => { ir.theme = v; ctx.onChange(false); })),
field("规模", txt(ir.scale, v => { ir.scale = v; ctx.onChange(false); })),
]));
const stage = ir.stage || (ir.stage = {});
host.appendChild(field("舞台 / " + psHint, txt(stage.type, v => { stage.type = v; ctx.onChange(false); })));
// 角色表
const box = el("div", { class: "subbox" });
box.appendChild(el("div", { class: "hd" }, [
el("span", {}, ["角色表 roles"]),
el("button", { class: "mini", onclick: () => {
const n = (ir.roles || []).length;
(ir.roles = ir.roles || []).push({ slot: "NP" + n, name: "新角色", archetype: "", camp: 0 });
ctx.onChange(true);
} }, ["+角色"]),
]));
(ir.roles || []).forEach((r, i) => {
const row = el("div", { class: "fld" }, [
el("div", { class: "row2" }, [
txt(r.slot, v => { r.slot = v; ctx.onChange(false); }),
txt(r.name, v => { r.name = v; ctx.onChange(false); }),
]),
el("div", { class: "row2" }, [
txt(r.archetype, v => { r.archetype = v; ctx.onChange(false); }),
sel(r.camp, [0, 1, 2].map(c => ({ value: c, label: "阵营" + c })), v => { r.camp = Number(v); ctx.onChange(false); }),
el("button", { class: "mini", onclick: () => { ir.roles.splice(i, 1); ctx.onChange(true); } }, ["删"]),
]),
]);
box.appendChild(row);
});
host.appendChild(box);
};
// ========== 节点表单 ==========
FormUI.renderNode = function (ir, id, ctx) {
const host = document.getElementById("node-edit");
host.innerHTML = "";
if (!id) { host.appendChild(el("div", { class: "empty" }, ["点击中间任意节点进行编辑"])); return; }
const isEnding = (ir.endings || []).some(e => e.id === id);
const node = isEnding ? (ir.endings.find(e => e.id === id))
: (ir.nodes.find(n => n.id === id));
if (!node) { host.appendChild(el("div", { class: "empty" }, ["节点已删除"])); return; }
const head = el("div", { class: "fld inline" }, [
el("span", { class: "node-id" }, ["#" + id]),
]);
host.appendChild(head);
if (isEnding) { renderEnding(host, ir, node, ctx); return; }
// kind 切换
host.appendChild(field("类型 kind", sel(node.kind, NODE_KINDS.map(k => ({ value: k, label: k })), v => {
node.kind = v; ctx.onChange(true);
})));
const mut = (s) => ctx.onChange(!!s ? false : true); // mut(true)=值改, 默认结构改
const tgt = targets(ir);
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("文本", area(node.text, v => { node.text = v; mut(1); })));
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); })));
} 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("镜头 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("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); })));
} else if (node.kind === "choice" || node.kind === "choice_once") {
const box = el("div", { class: "subbox" });
box.appendChild(el("div", { class: "hd" }, [
el("span", {}, ["选项 options"]),
el("button", { class: "mini", onclick: () => { (node.options = node.options || []).push({ text: "新选项", goto: "" }); ctx.onChange(true); } }, ["+选项"]),
]));
(node.options || []).forEach((o, i) => {
const det = el("details", { class: "optdet" });
det.appendChild(el("summary", {}, ["选项 " + (i + 1) + "" + (o.text || "(空)")]));
const ob = el("div", { class: "optbody" });
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(true); })));
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); }));
// skip
const skBox = el("div", { class: "subbox" });
skBox.appendChild(el("div", { class: "hd" }, [
el("span", {}, ["押注跳过 skip"]),
el("button", { class: "mini", onclick: () => { if (o.skip) delete o.skip; else o.skip = { node: "", reward: { grants: [] } }; ctx.onChange(true); } }, [o.skip ? "移除" : ""]),
]));
if (o.skip) {
skBox.appendChild(field("结算目标节点", sel(o.skip.node, tgt, v => { o.skip.node = v; ctx.onChange(false); })));
skBox.appendChild(grantsEditor(ir, ctx, (o.skip.reward || {}).grants, (gr, valOnly) => { o.skip.reward = { grants: gr }; ctx.onChange(!valOnly); }));
}
ob.appendChild(skBox);
ob.appendChild(el("div", { class: "fld" }, [el("button", { class: "mini", onclick: () => { node.options.splice(i, 1); ctx.onChange(true); } }, ["删除此选项"])]));
det.appendChild(ob);
box.appendChild(det);
});
host.appendChild(box);
} else if (node.kind === "random") {
const box = el("div", { class: "subbox" });
box.appendChild(el("div", { class: "hd" }, [
el("span", {}, ["随机分支 branches"]),
el("button", { class: "mini", onclick: () => { (node.branches = node.branches || []).push({ weight: 1, goto: "" }); ctx.onChange(true); } }, ["+分支"]),
]));
(node.branches || []).forEach((b, i) => {
box.appendChild(el("div", { class: "row2" }, [
field("权重", num(b.weight, v => { b.weight = 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); } }, ["删"]),
]));
});
host.appendChild(box);
} else if (node.kind === "fight") {
host.appendChild(field("战斗类型", sel(node.fight_type, [{ value: 1, label: "1 击倒" }, { value: 2, label: "2 死斗" }], v => { node.fight_type = Number(v); ctx.onChange(false); })));
host.appendChild(campPicker("我方 camp1", ir, node, "camp1", ctx, true));
host.appendChild(campPicker("敌方 camp2", ir, node, "camp2", ctx, false));
host.appendChild(el("div", { class: "row2" }, [
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(true); })),
]));
} 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("目标点 to", sel(node.to, pointOpts(ir, ctx, node.to), v => { node.to = v; ctx.onChange(false); })));
host.appendChild(field("模式 mode", sel(node.mode || "walk", [{ value: "walk", label: "walk 行走" }, { value: "teleport", label: "teleport 瞬移" }, { value: "remove", label: "remove 移除" }], v => { node.mode = v; ctx.onChange(true); })));
if ((node.mode || "walk") === "walk") {
host.appendChild(el("div", { class: "row2" }, [
field("速度 speed", num(node.speed == null ? 6 : node.speed, v => { node.speed = 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(true); })));
} 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(el("div", { class: "row2" }, [
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); })),
]));
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); })));
} else if (node.kind === "reward") {
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(true); })));
} else if (node.kind === "out_ref") {
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("出口接回 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); })));
} else if (node.kind === "scene") {
renderScene(host, ir, node, ctx, tgt);
}
// 删除节点
host.appendChild(el("div", { class: "fld" }, [
el("button", { class: "mini", onclick: () => {
if (!confirm("删除节点 " + id + "?(若是直线中间节点,会自动把前后接上)")) return;
ctx.deleteNode(id);
} }, ["删除此节点"]),
]));
};
// scene 内 clip 唯一 id
function newClipId(node) {
let i = 1, id; const has = x => (node.tracks || []).some(tk => (tk.clips || []).some(c => c.id === x));
do { id = "c" + i++; } while (has(id));
return id;
}
// ========== 演出段 scene 右栏(精确数值编辑,与画布拖拽互补)==========
function renderScene(host, ir, node, ctx, tgt) {
if (!node.tracks) node.tracks = [];
const actorSlots = [{ value: "P1", label: "P1 玩家" }].concat((ir.roles || []).map(r => ({ value: r.slot, label: r.slot + " " + r.name })));
host.appendChild(el("div", { class: "fld" }, [
el("button", { class: "mini primary", onclick: () => ctx.editScene && ctx.editScene(node.id) }, ["🎬 打开时间线编辑器(拖拽编排)"]),
]));
host.appendChild(el("div", { class: "row2" }, [
field("时长 duration(可选,缺省=自动)", num(node.duration, v => { if (v == null) delete node.duration; else node.duration = v; ctx.onChange(false); })),
field("出口 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); })),
]));
const box = el("div", { class: "subbox" });
box.appendChild(el("div", { class: "hd" }, [
el("span", {}, ["轨道 tracks"]),
el("span", {}, [
el("button", { class: "mini", onclick: () => { node.tracks.push({ id: "tk" + node.tracks.length, actor: "P1", clips: [] }); ctx.onChange(true); } }, ["+演员轨"]),
el("button", { class: "mini", onclick: () => { node.tracks.push({ id: "tkcam" + node.tracks.length, role: "camera", clips: [] }); ctx.onChange(true); } }, ["+镜头轨"]),
]),
]));
(node.tracks || []).forEach((tk, ti) => {
const isCam = tk.role === "camera";
const tbox = el("div", { class: "fld optdet-like" });
tbox.appendChild(el("div", { class: "row2" }, [
isCam ? el("span", { class: "node-id" }, ["🎬 镜头轨"])
: sel(tk.actor || "P1", actorSlots, v => { tk.actor = v; ctx.onChange(false); }),
el("span", {}, [
el("button", { class: "mini", onclick: () => {
const c = { id: newClipId(node), kind: isCam ? "camera" : "move", start: 0 };
if (isCam) c.focus = (ctx.pointNames || [])[0] || ""; else c.to = (ctx.pointNames || [])[0] || "";
(tk.clips = tk.clips || []).push(c); ctx.onChange(true);
} }, ["clip"]),
el("button", { class: "mini", onclick: () => { if (confirm("删除该轨道?")) { node.tracks.splice(ti, 1); ctx.onChange(true); } } }, ["删轨"]),
]),
]));
(tk.clips || []).forEach((c, ci) => {
const kinds = isCam ? ["camera"] : SCENE_CLIP_KINDS.filter(k => k !== "camera");
const row = el("div", { class: "se-cliprow" }, [
sel(c.kind, kinds.map(k => ({ value: k, label: k })), v => { c.kind = v; ctx.onChange(true); }),
field("start", num(c.start, v => { c.start = v == null ? 0 : Math.max(0, v); ctx.onChange(false); })),
field("dur", num(c.dur, v => { if (v == null) delete c.dur; else c.dur = v; ctx.onChange(false); })),
]);
if (c.kind === "move") row.appendChild(field("to", sel(c.to, pointOpts(ir, ctx, c.to), v => { c.to = v; ctx.onChange(false); })));
else if (c.kind === "camera") row.appendChild(field("focus", sel(c.focus, pointOpts(ir, ctx, c.focus), v => { c.focus = v; ctx.onChange(false); })));
else if (c.kind === "dialogue" || c.kind === "narration") row.appendChild(field("文本", txt(c.text, v => { c.text = v; ctx.onChange(false); })));
else if (c.kind === "anim") row.appendChild(field("ani", txt(c.ani, v => { c.ani = v; ctx.onChange(false); })));
row.appendChild(el("button", { class: "mini", onclick: () => { tk.clips.splice(ci, 1); ctx.onChange(true); } }, ["✕"]));
tbox.appendChild(row);
});
if (!(tk.clips || []).length) tbox.appendChild(el("div", { class: "empty" }, ["无 clip"]));
box.appendChild(tbox);
});
if (!node.tracks.length) box.appendChild(el("div", { class: "empty" }, ["无轨道——点「+演员轨/+镜头轨」"]));
host.appendChild(box);
}
function renderEnding(host, ir, e, ctx) {
host.appendChild(field("结局摘要 summary", txt(e.summary, v => { e.summary = v; ctx.onChange(false); })));
host.appendChild(field("结果 result", sel(e.result || "success", [
{ value: "success", label: "success 成功" }, { value: "fail", label: "fail 失败" }, { value: "end", label: "end 中性" }], v => { e.result = v; ctx.onChange(false); })));
host.appendChild(grantsEditor(ir, ctx, e.grants, (gr, valOnly) => { e.grants = gr; ctx.onChange(!valOnly); }));
}
function campPicker(label, ir, node, key, ctx, withPlayer) {
const cur = new Set(node[key] || []);
const wrap = el("div", { class: "fld" }, [el("label", {}, [label])]);
const pick = el("div", { class: "tag-pick" });
const all = (withPlayer ? [{ slot: "P1", name: "玩家" }] : []).concat(ir.roles || []);
all.forEach(r => {
const cb = el("input", { type: "checkbox" }); cb.checked = cur.has(r.slot);
cb.onchange = () => {
const arr = new Set(node[key] || []);
if (cb.checked) arr.add(r.slot); else arr.delete(r.slot);
node[key] = [...arr]; ctx.onChange(false);
};
pick.appendChild(el("label", {}, [cb, r.slot]));
});
wrap.appendChild(pick);
return wrap;
}
// 新建节点:分配唯一 id
FormUI.newNode = function (ir) {
let i = (ir.nodes || []).length + 1, id;
do { id = "n" + i++; } while ((ir.nodes || []).some(n => n.id === id) || (ir.endings || []).some(e => e.id === id));
(ir.nodes = ir.nodes || []).push({ id, kind: "dialogue", speaker: "P1", text: "新对话", next: "" });
return id;
};
// 在指定节点后追加一个新节点,并按其类型自动接线;返回新节点 id结局/不存在返回 null
// 线性类型(next)插入到当前与原后继之间choice/random追加一个选项/分支fight填空缺的胜/败出口。
FormUI.addSuccessor = function (ir, id) {
const node = (ir.nodes || []).find(n => n.id === id);
if (!node) return null; // 结局节点等无后继
const nid = FormUI.newNode(ir);
const kind = node.kind;
let linked = true;
if (kind === "choice" || kind === "choice_once") {
(node.options = node.options || []).push({ text: "新选项", goto: nid });
} else if (kind === "random") {
(node.branches = node.branches || []).push({ weight: 1, goto: nid });
} else if (kind === "fight") {
if (!node.win) node.win = nid;
else if (!node.lose) node.lose = nid;
else linked = false; // 胜败出口都已占用:新节点孤立
} else {
// 线性 next 类型:插入到当前与原后继之间,不破坏原有链路
const fresh = ir.nodes.find(n => n.id === nid);
fresh.next = node.next || "";
node.next = nid;
}
return { id: nid, linked: linked };
};
})();