剧情 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 + 白模重叠演出。
391 lines
22 KiB
JavaScript
391 lines
22 KiB
JavaScript
// 右栏表单编辑:元信息 + 角色表 + 按 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 };
|
||
};
|
||
})();
|