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 + 白模重叠演出。
This commit is contained in:
@ -5,7 +5,8 @@
|
||||
|
||||
(function () {
|
||||
const NODE_KINDS = ["narration", "dialogue", "choice", "choice_once", "random",
|
||||
"fight", "move", "anim", "reward", "out_ref"];
|
||||
"fight", "move", "anim", "reward", "out_ref", "scene"];
|
||||
const SCENE_CLIP_KINDS = ["move", "dialogue", "narration", "anim", "camera", "wait"];
|
||||
|
||||
// ---- DOM 小工具 ----
|
||||
function el(tag, attrs, kids) {
|
||||
@ -252,6 +253,8 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// 删除节点
|
||||
@ -263,6 +266,70 @@
|
||||
]));
|
||||
};
|
||||
|
||||
// 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", [
|
||||
|
||||
Reference in New Issue
Block a user