Files
story-edit-web/web/static/form.js
邓雨鹏 65424a4dfb feat(web): 海选按场景分组 + 删场景点位页签 + 演出真实底图 + 破缓存
- 海选审核左侧改两列:场景列(按新字段 ir.scene 手动归类聚合,含全部/未分类) + 该场景事件列
- 删独立「场景/点位」页签(pointview.js 保留未引用)
- 演出配置 Timeline 接真实场景俯视底图(setupShot 覆盖投影范围 + drawStage 叠图,复用 /api/pointsets 的 shot)
- 事件 meta 加「所属场景」归类输入框(datalist 提示已有场景名)
- db: events 加 scene 列 + 旧库 ALTER 迁移;upsert 镜像 ir.scene;list 返回
- app.py: 首页按文件 mtime 给 js/css 注入 ?v= 破浏览器缓存(根治新html配旧缓存js崩溃→弹口令)
2026-06-15 11:46:59 +08:00

402 lines
24 KiB
JavaScript
Raw Permalink 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; }
// 带下拉建议的文本框datalist用于场景名等可复用又可自由输入的字段
function txtDatalist(val, listId, options, oninput) {
const i = el("input", { type: "text", value: val == null ? "" : val, list: listId, placeholder: "输入或选择场景名" });
i.oninput = () => oninput(i.value);
const dl = el("datalist", { id: listId });
(options || []).forEach(o => dl.appendChild(el("option", { value: o })));
return el("span", { class: "with-datalist" }, [i, dl]);
}
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); })));
// 所属场景:海选审核第一列的分组维度,手动归类(可复用已有场景名)。改后保存即生效。
const scenes = [...new Set((window.App && window.App.events ? window.App.events.map(e => e.scene) : []).filter(Boolean))].sort();
host.appendChild(field("所属场景(海选分组)", txtDatalist(ir.scene, "scene-datalist", scenes, v => { ir.scene = 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 };
};
})();