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,9 +5,9 @@
|
||||
// 暴露 window.Timeline = { show(host, ir, dict, pointsets), stop() }。
|
||||
|
||||
(function () {
|
||||
// ---- 时长模型 ----
|
||||
// ---- 时长模型(常量与 ir_core/validate.py 顶部共享口径,避免预览与校验/真机漂移)----
|
||||
const CHAR_TIME = 0.07, TAIL_PAUSE = 0.9, MIN_DLG = 1.2;
|
||||
const MOVE_SPEED = 3.0, ANIM_DUR = 1.0;
|
||||
const MOVE_SPEED = 3.0, ANIM_DUR = 1.0, CAMERA_DUR = 2.0;
|
||||
const PXMAX = 80, ROW_H = 30; // PXMAX=每秒最大像素;实际用动态 PX(可适应宽度)
|
||||
const CAM_W = 14, CAM_H = 9;
|
||||
|
||||
@ -29,7 +29,13 @@
|
||||
function collectAllRefs(IR) {
|
||||
const refs = [], add = n => { if (n && !refs.includes(n)) refs.push(n); };
|
||||
add("P1"); (IR.roles || []).forEach(r => add(r.slot));
|
||||
const scan = arr => (arr || []).forEach(n => { add(n.speaker); add(n.actor); if (n.kind === "move") add(n.to); if (n.camera) add(n.camera); });
|
||||
const scan = arr => (arr || []).forEach(n => {
|
||||
add(n.speaker); add(n.actor); if (n.kind === "move") add(n.to); if (n.camera) add(n.camera);
|
||||
if (n.kind === "scene") (n.tracks || []).forEach(tk => {
|
||||
add(tk.actor);
|
||||
(tk.clips || []).forEach(c => { if (c.kind === "move") add(c.to); if (c.kind === "camera") add(c.focus); });
|
||||
});
|
||||
});
|
||||
scan(IR.nodes); (IR.sequences || []).forEach(s => scan(s.nodes));
|
||||
return refs;
|
||||
}
|
||||
@ -72,7 +78,10 @@
|
||||
// 在场角色:全事件里当过 speaker/actor 的 slot(含 P1)。舞台始终按位置画出他们,
|
||||
// 这样从中途节点开始也能看到各角色"在该处应有的位置"。
|
||||
const used = [], addU = s => { if (s && !used.includes(s)) used.push(s); };
|
||||
const scanU = arr => (arr || []).forEach(n => { addU(n.speaker); addU(n.actor); });
|
||||
const scanU = arr => (arr || []).forEach(n => {
|
||||
addU(n.speaker); addU(n.actor);
|
||||
if (n.kind === "scene") (n.tracks || []).forEach(tk => { if (tk.role !== "camera") addU(tk.actor || "P1"); });
|
||||
});
|
||||
scanU(IR.nodes); (IR.sequences || []).forEach(s => scanU(s.nodes));
|
||||
const ordU = [];
|
||||
if (used.includes("P1")) ordU.push("P1");
|
||||
@ -112,9 +121,21 @@
|
||||
function replayPositions(S, path) {
|
||||
const cur = {};
|
||||
const applyMove = n => { if (n.kind === "move") { const to = S.posMap[n.to]; if (to) cur[n.actor || "P1"] = { x: to.x, z: to.z }; } };
|
||||
// scene:各 actor 取其(跨轨)最后一段 move 的目标点作为离场位置
|
||||
const applyScene = n => {
|
||||
const mv = {};
|
||||
(n.tracks || []).forEach(tk => { if (tk.role === "camera") return; const a = tk.actor || "P1";
|
||||
(tk.clips || []).forEach(c => { if (c.kind === "move") (mv[a] = mv[a] || []).push(c); }); });
|
||||
Object.keys(mv).forEach(a => {
|
||||
mv[a].sort((x, y) => (x.start || 0) - (y.start || 0));
|
||||
const last = mv[a][mv[a].length - 1], to = last && S.posMap[last.to];
|
||||
if (to) cur[a] = { x: to.x, z: to.z };
|
||||
});
|
||||
};
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const n = S.nodes[path[i]]; if (!n) continue;
|
||||
if (n.kind === "out_ref") {
|
||||
if (n.kind === "scene") applyScene(n);
|
||||
else if (n.kind === "out_ref") {
|
||||
const sq = S.seqMap[n.ref];
|
||||
if (sq) { const sm = {}; (sq.nodes || []).forEach(x => sm[x.id] = x); let sid = (sq.nodes[0] || {}).id, g = 0; while (sid && g++ < 100) { const sn = sm[sid]; if (!sn) break; applyMove(sn); sid = sn.next; } }
|
||||
} else applyMove(n);
|
||||
@ -148,6 +169,68 @@
|
||||
}
|
||||
}
|
||||
|
||||
// clip 时长派生(与 ir_core/validate.py 口径一致):显式 dur > 按 kind 派生。
|
||||
function clipDur(S, c, actor, fromPos) {
|
||||
if (typeof c.dur === "number" && c.dur > 0) return c.dur;
|
||||
const k = c.kind;
|
||||
if (k === "dialogue" || k === "narration") return dlgDur(c.text);
|
||||
if (k === "anim") return ANIM_DUR;
|
||||
if (k === "camera") return CAMERA_DUR;
|
||||
if (k === "move") {
|
||||
const from = fromPos || posOf(S, actor), to = S.posMap[c.to] || from;
|
||||
const dist = Math.hypot(to.x - from.x, to.z - from.z);
|
||||
return Math.max(0.3, dist / (c.speed || MOVE_SPEED));
|
||||
}
|
||||
if (k === "wait") return 0.5;
|
||||
return 0.4;
|
||||
}
|
||||
|
||||
// scene 演出段:把多轨 clips 按 authored start(相对 S._t 偏移)铺进 S.clips,自然支持重叠。
|
||||
// move 的 from = 同 actor(跨轨)上一段 move 的 to,无则取进入 scene 时的位置/初始锚点(D4)。
|
||||
function appendScene(S, n) {
|
||||
const base = S._t, tracks = n.tracks || [];
|
||||
// 1) 先按 actor 续连各 move 的 from(跨轨合并、按 start 排序)
|
||||
const movesByActor = {};
|
||||
tracks.forEach(tk => { if (tk.role === "camera") return; const actor = tk.actor || "P1";
|
||||
(tk.clips || []).forEach(c => { if (c.kind === "move") (movesByActor[actor] = movesByActor[actor] || []).push(c); }); });
|
||||
const moveFrom = new Map();
|
||||
Object.keys(movesByActor).forEach(actor => {
|
||||
let prev = posOf(S, actor);
|
||||
movesByActor[actor].slice().sort((a, b) => (a.start || 0) - (b.start || 0)).forEach(c => {
|
||||
moveFrom.set(c, prev);
|
||||
const to = S.posMap[c.to] || prev; prev = { x: to.x, z: to.z };
|
||||
});
|
||||
S.curPos[actor] = prev; // scene 结束后该 actor 的位置
|
||||
});
|
||||
// 2) 逐轨逐 clip 生成可视 clip(start 偏移 base)
|
||||
let sceneEnd = 0;
|
||||
tracks.forEach(tk => {
|
||||
const isCam = tk.role === "camera", actor = isCam ? null : (tk.actor || "P1");
|
||||
(tk.clips || []).forEach(c => {
|
||||
const start = base + (c.start || 0), dur = clipDur(S, c, actor, moveFrom.get(c));
|
||||
sceneEnd = Math.max(sceneEnd, (c.start || 0) + dur);
|
||||
const k = c.kind;
|
||||
if (k === "dialogue" || k === "narration") {
|
||||
S.clips.push({ row: "演员:" + actor, kind: "dialogue", start, dur, label: esc(c.text || ""), actor, text: c.text || "", nodeId: n.id });
|
||||
useRow(S, "演员:" + actor);
|
||||
} else if (k === "move") {
|
||||
const from = moveFrom.get(c) || posOf(S, actor), to = S.posMap[c.to] || from;
|
||||
S.clips.push({ row: "演员:" + actor, kind: "move", start, dur, label: "→ " + (c.to || ""), actor, from, to, nodeId: n.id });
|
||||
useRow(S, "演员:" + actor);
|
||||
} else if (k === "anim") {
|
||||
S.clips.push({ row: "演员:" + actor, kind: "anim", start, dur, label: "动画 " + (c.ani || ""), actor, nodeId: n.id });
|
||||
useRow(S, "演员:" + actor);
|
||||
} else if (k === "camera") {
|
||||
S.clips.push({ row: "镜头", kind: "camera", start, dur, label: "对焦 " + (c.focus || ""), focus: c.focus, nodeId: n.id });
|
||||
useRow(S, "镜头");
|
||||
}
|
||||
// wait:仅占位,只计入时长,不产可视 clip
|
||||
});
|
||||
});
|
||||
const total = (typeof n.duration === "number" && n.duration > 0) ? Math.max(n.duration, sceneEnd) : sceneEnd;
|
||||
S._t = base + Math.max(total, 0.1);
|
||||
}
|
||||
|
||||
// 从 startId 走一段线性演出,遇分支/结局/断头停下。返回 {kind, node}。
|
||||
function extendSegment(S, startId) {
|
||||
let id = startId, guard = 0;
|
||||
@ -165,6 +248,7 @@
|
||||
S.clips.push({ row: "剧情", kind: (k === "fight" ? "fight" : "branch"), start: S._t, dur: 0.6, label: lbl, nodeId: id }); useRow(S, "剧情"); S._t += 0.6;
|
||||
return { kind: k, node: n };
|
||||
}
|
||||
if (k === "scene") { appendScene(S, n); id = n.next; continue; }
|
||||
if (k === "out_ref") {
|
||||
const sq = S.seqMap[n.ref];
|
||||
if (sq && (sq.nodes || []).length) {
|
||||
@ -237,7 +321,7 @@
|
||||
'<div class="tl-resizer" title="拖动调整时间轴高度"></div>' +
|
||||
'<div class="tl-timelinepanel"><div class="tl-tracks"></div></div>';
|
||||
|
||||
function show(host, IR, DICT, POINTSETS) {
|
||||
function show(host, IR, DICT, POINTSETS, startId) {
|
||||
stopPlay();
|
||||
const psName = (IR.stage || {}).point_set || IR.id;
|
||||
const ps = (POINTSETS || {})[psName] || {};
|
||||
@ -281,7 +365,7 @@
|
||||
document.addEventListener("mousemove", mv); document.addEventListener("mouseup", up);
|
||||
};
|
||||
|
||||
startFrom(firstNode(IR));
|
||||
startFrom(startId && S.nodes[startId] ? startId : firstNode(IR));
|
||||
}
|
||||
|
||||
function selectClip(c, el) {
|
||||
@ -305,6 +389,7 @@
|
||||
if (n.kind === "fight") return "【战斗】vs " + (n.camp2 || []).map(nm).join("、");
|
||||
if (n.kind === "reward") return "奖励结算";
|
||||
if (n.kind === "out_ref") return "引用·" + n.ref;
|
||||
if (n.kind === "scene") return "演出段·" + (n.tracks || []).length + "轨";
|
||||
return n.kind;
|
||||
}
|
||||
// 从指定节点开始:先重放途中走位算进入位置,再从该节点构建演出。
|
||||
@ -398,18 +483,19 @@
|
||||
ctx.fillStyle = "rgba(230,200,120,.9)"; ctx.font = "10px sans-serif"; ctx.textAlign = "left"; ctx.fillText("镜头", fp.x - bw / 2 + 4, fp.y - bh / 2 + 13);
|
||||
ctx.strokeStyle = "rgba(230,200,120,.5)"; ctx.beginPath(); ctx.moveTo(fp.x - 6, fp.y); ctx.lineTo(fp.x + 6, fp.y); ctx.moveTo(fp.x, fp.y - 6); ctx.lineTo(fp.x, fp.y + 6); ctx.stroke();
|
||||
|
||||
const dlg = activeDialogue(model, tau);
|
||||
actorsIn(model).forEach(actor => {
|
||||
const wp = actorPosAt(model, actor, tau), p = worldToStage(wp), col = colorOf(model, actor);
|
||||
const moving = model.clips.some(c => c.actor === actor && c.kind === "move" && tau >= c.start && tau < c.start + c.dur);
|
||||
const anim = activeAnim(model, actor, tau);
|
||||
// 每个 actor 独立查自己的对话 clip → 重叠演出时多人气泡同时计时呈现
|
||||
const dlg = model.clips.find(c => c.kind === "dialogue" && c.actor === actor && tau >= c.start && tau < c.start + c.dur);
|
||||
ctx.fillStyle = col; ctx.beginPath(); ctx.arc(p.x, p.y, moving ? 9 : 8, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = "#000"; ctx.lineWidth = 1; ctx.stroke();
|
||||
ctx.fillStyle = "#1a1710"; ctx.font = "bold 9px sans-serif"; ctx.textAlign = "center"; ctx.fillText(actor, p.x, p.y + 3);
|
||||
ctx.fillStyle = "#d8cda0"; ctx.font = "11px sans-serif"; ctx.fillText(model.nm(actor), p.x, p.y + 22);
|
||||
if (moving) { ctx.fillStyle = col; ctx.font = "9px sans-serif"; ctx.fillText("…走位", p.x, p.y - 12); }
|
||||
if (anim) { ctx.fillStyle = col; ctx.font = "9px sans-serif"; ctx.fillText("♪" + (anim.label || ""), p.x, p.y - 12); }
|
||||
if (dlg && dlg.actor === actor) {
|
||||
if (dlg) {
|
||||
const typed = (dlg.text || "").slice(0, Math.floor((tau - dlg.start) / CHAR_TIME));
|
||||
drawBubble(ctx, p.x, p.y - 26, model.nm(actor) + ":" + typed);
|
||||
}
|
||||
@ -534,8 +620,24 @@
|
||||
return s;
|
||||
}
|
||||
|
||||
// 纯函数版 clip 时长(scene 编辑器复用,单一口径):posMap={点位名:{x,z}},fromPos=move 起点。
|
||||
function clipDurPure(c, posMap, fromPos) {
|
||||
if (typeof c.dur === "number" && c.dur > 0) return c.dur;
|
||||
const k = c.kind;
|
||||
if (k === "dialogue" || k === "narration") return dlgDur(c.text);
|
||||
if (k === "anim") return ANIM_DUR;
|
||||
if (k === "camera") return CAMERA_DUR;
|
||||
if (k === "move") {
|
||||
const from = fromPos || { x: 0, z: 0 }, to = (posMap && posMap[c.to]) || from;
|
||||
return Math.max(0.3, Math.hypot(to.x - from.x, to.z - from.z) / (c.speed || MOVE_SPEED));
|
||||
}
|
||||
if (k === "wait") return 0.5;
|
||||
return 0.4;
|
||||
}
|
||||
|
||||
window.Timeline = {
|
||||
show, stop: stopPlay, clear,
|
||||
_clipDur: clipDurPure, // scene 编辑器复用
|
||||
// 离线测试用:
|
||||
_buildModel: buildModelAuto, _dlgDur: dlgDur,
|
||||
_prepare: prepare, _extend: extendSegment, _runUntilPause: runUntilPause, _firstNode: firstNode, _orderRows: orderRows,
|
||||
|
||||
Reference in New Issue
Block a user