feat(timeline): 无点位集时合成圆周布局兜底 + 演示事件

- 线上 NAS 多半没挂点位集卷(/api/pointsets 空),白模会无坐标。改为:真实锚点优先,
  缺坐标时把引用到的点位(演员位/走位目标/镜头点)在圆周自动铺开,走位预览不空。
  真实坐标存在时一律覆盖合成。mapinfo 标注当前是真实坐标还是示意布局。
- samples/timeline_demo.ir.json:QY_TLDEMO 演示事件,覆盖 P1 全部功能
  (旁白/对话打字机/3角色走位/动画标记/镜头对焦/选择分支/双结局),离线测试合成布局走位全有位移
This commit is contained in:
2026-06-13 11:22:07 +08:00
parent 2a3cf2c66b
commit fb95937236
2 changed files with 63 additions and 5 deletions

View File

@ -69,7 +69,31 @@
const seq = linearize(IR);
const roleName = {}; (IR.roles || []).forEach(r => roleName[r.slot] = r.name);
const nm = s => s === "P1" ? "玩家" : (roleName[s] || s);
const initPos = {}; (anchors || []).forEach(a => initPos[a.name] = { x: a.pos[0], z: a.pos[2] });
// 位置来源:真实锚点优先;点位集没挂/缺坐标时,把引用到的点位在圆周上**合成布局**
// 保证走位预览不空(线上未挂点位集卷的常见情形 + 尚未取点的事件)。
const refs = [];
const addRef = n => { if (n && !refs.includes(n)) refs.push(n); };
addRef("P1"); (IR.roles || []).forEach(r => addRef(r.slot));
seq.forEach(item => {
const n = item.node; if (!n) return;
addRef(n.speaker); addRef(n.actor);
if (n.kind === "move") addRef(n.to);
if (n.camera) addRef(n.camera);
});
const realPos = {}; (anchors || []).forEach(a => realPos[a.name] = { x: a.pos[0], z: a.pos[2] });
const hasReal = Object.keys(realPos).length > 0;
const posMap = {};
const missing = refs.filter(r => !realPos[r]);
const R = 6;
missing.forEach((name, i) => {
const ang = (i / Math.max(1, missing.length)) * Math.PI * 2 - Math.PI / 2;
posMap[name] = { x: +(Math.cos(ang) * R).toFixed(2), z: +(Math.sin(ang) * R).toFixed(2) };
});
Object.assign(posMap, realPos); // 真实坐标覆盖合成
const synthetic = !hasReal;
const initPos = {}; refs.forEach(r => { if (posMap[r]) initPos[r] = posMap[r]; });
const curPos = {};
const posOf = a => curPos[a] || initPos[a] || { x: 0, z: 0 };
@ -94,7 +118,7 @@
if (n.camera) { clips.push({ row: "镜头", kind: "camera", start: t, dur, label: "对焦 " + n.camera, focus: n.camera }); useRow("镜头"); }
t += dur;
} else if (k === "move") {
const sp = n.actor || "P1", from = posOf(sp), to = anchorXZ(anchors, n.to) || from;
const sp = n.actor || "P1", from = posOf(sp), to = posMap[n.to] || from;
const speed = n.speed || MOVE_SPEED, dist = Math.hypot(to.x - from.x, to.z - from.z), dur = Math.max(0.3, dist / speed);
clips.push({ row: "演员:" + sp, kind: "move", start: t, dur, label: "→ " + (n.to || ""), actor: sp, from, to });
useRow("演员:" + sp); curPos[sp] = { x: to.x, z: to.z }; t += dur;
@ -114,14 +138,18 @@
rowSet.forEach(r => { if (r.startsWith("演员:") && !order.includes(r)) order.push(r); });
["镜头", "剧情"].forEach(r => { if (rowSet.includes(r)) order.push(r); });
// 舞台要画的锚点:有真实坐标用真实;否则用合成布局(含点位名,作背景参照)
const displayAnchors = hasReal ? (anchors || [])
: refs.filter(r => posMap[r]).map(r => ({ name: r, pos: [posMap[r].x, 0, posMap[r].z] }));
// 世界坐标范围(含锚点 + 所有走位终点),用于俯视舞台适配
const xs = [], zs = [];
(anchors || []).forEach(a => { xs.push(a.pos[0]); zs.push(a.pos[2]); });
displayAnchors.forEach(a => { xs.push(a.pos[0]); zs.push(a.pos[2]); });
clips.forEach(c => { if (c.from) { xs.push(c.from.x); zs.push(c.from.z); } if (c.to) { xs.push(c.to.x); zs.push(c.to.z); } });
const bounds = xs.length ? { minX: Math.min(...xs), maxX: Math.max(...xs), minZ: Math.min(...zs), maxZ: Math.max(...zs) }
: { minX: 0, maxX: 1, minZ: 0, maxZ: 1 };
return { clips, rows: order, total: Math.max(t, 0.1), anchors: anchors || [], initPos, nm, roleName, bounds };
return { clips, rows: order, total: Math.max(t, 0.1), anchors: displayAnchors, initPos, nm, roleName, bounds, synthetic };
}
// ---- 播放期查询 ----
@ -190,7 +218,7 @@
playhead: null,
};
els.mapinfo.textContent = "点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + "" : "") +
(anchors.length ? "" : " ⚠ 无坐标,走位/俯视图将退化为示意");
(model.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)" : " · 真实坐标");
stageCv = els.stage; stageCtx = stageCv.getContext("2d");
buildTracks();