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

391 lines
20 KiB
JavaScript
Raw 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.

// 演出预览/配置:把事件的演出节点铺成时间线(每节点按时长占一段),并在 2D 俯视白模舞台上随
// playhead 播放——走位插值、对话打字机、镜头可视区域框。战斗/选择/随机只在「剧情」轨标点,不模拟。
// P1消费现有 IR线性顺序铺轴无并行偏移沿首出口走一条主路径预览。
// 渲染挂载到任意 host 容器(演出配置页内嵌),不再用弹窗。
// 暴露 window.Timeline = { show(host, ir, dict, pointsets), stop() }。
(function () {
// ---- 时长模型(前端估算;与未来编译器口径对齐时再统一)----
const CHAR_TIME = 0.07; // 打字机:秒/字
const TAIL_PAUSE = 0.9; // 对话读完停顿
const MIN_DLG = 1.2; // 对话最短时长
const MOVE_SPEED = 3.0; // 默认走位速度(点位单位/秒)
const ANIM_DUR = 1.0; // 动画缺省时长Web 不知 clip 真长P3 引擎回填)
const PXPSEC = 80; // 时间轴每秒像素
const ROW_H = 30; // 轨道行高
const CAM_W = 14, CAM_H = 9; // 镜头可视区域(世界单位,俯视示意)
function dlgDur(text) { return Math.max(MIN_DLG, (text || "").length * CHAR_TIME + TAIL_PAUSE); }
function esc(s) { return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function anchorXZ(anchors, name) {
const a = (anchors || []).find(x => x.name === name);
return a ? { x: a.pos[0], z: a.pos[2] } : null;
}
// ---- 线性化从入度0起步沿首出口走一条主路径分支/结局标点不展开 ----
function firstNode(IR) {
const indeg = {}; (IR.nodes || []).forEach(n => indeg[n.id] = 0);
(IR.nodes || []).forEach(n => {
const outs = [n.next].concat((n.options || []).map(o => o.goto), (n.branches || []).map(b => b.goto),
n.kind === "fight" ? [n.win, n.lose] : []);
outs.forEach(t => { if (t in indeg) indeg[t]++; });
});
const roots = (IR.nodes || []).filter(n => indeg[n.id] === 0);
return (roots[0] || (IR.nodes || [])[0] || {}).id;
}
function linearize(IR) {
const nodes = {}; (IR.nodes || []).forEach(n => nodes[n.id] = n);
const endings = {}; (IR.endings || []).forEach(e => endings[e.id] = e);
const seqMap = {}; (IR.sequences || []).forEach(s => seqMap[s.id] = s);
const out = [];
let id = firstNode(IR), guard = 0; const seen = new Set();
while (id && guard++ < 500) {
if (seen.has(id)) { out.push({ stop: "循环中断" }); break; }
seen.add(id);
const n = nodes[id];
if (!n) { if (endings[id]) out.push({ ending: endings[id] }); break; }
const k = n.kind;
if (k === "choice" || k === "choice_once") { out.push({ branch: "choice", node: n }); const o = n.options || []; id = o.length ? o[0].goto : null; }
else if (k === "random") { out.push({ branch: "random", node: n }); const b = n.branches || []; id = b.length ? b[0].goto : null; }
else if (k === "fight") { out.push({ branch: "fight", node: n }); id = n.win || n.lose || null; }
else if (k === "out_ref") {
const sq = seqMap[n.ref];
if (sq && (sq.nodes || []).length) {
const sm = {}; sq.nodes.forEach(x => sm[x.id] = x);
let sid = sq.nodes[0].id, sg = 0;
while (sid && sg++ < 100) { const sn = sm[sid]; if (!sn) break; out.push({ node: sn, kind: sn.kind }); sid = sn.next; }
}
id = n.next;
}
else { out.push({ node: n, kind: k }); id = n.next; }
}
// 注:结局已在循环顶部 `!n` 分支里 push 过endings 不在 nodes 里),此处不再重复推。
return out;
}
// ---- 构建演出模型clips + rows + total ----
function buildModel(IR, anchors) {
const seq = linearize(IR);
const roleName = {}; (IR.roles || []).forEach(r => roleName[r.slot] = r.name);
const nm = s => s === "P1" ? "玩家" : (roleName[s] || s);
// 位置来源:真实锚点优先;点位集没挂/缺坐标时,把引用到的点位在圆周上**合成布局**
// 保证走位预览不空(线上未挂点位集卷的常见情形 + 尚未取点的事件)。
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 };
let t = 0; const clips = []; const rowSet = [];
const useRow = r => { if (!rowSet.includes(r)) rowSet.push(r); };
seq.forEach(item => {
if (item.stop) { clips.push({ row: "剧情", kind: "stop", start: t, dur: 0.4, label: item.stop }); useRow("剧情"); t += 0.4; return; }
if (item.branch) {
const n = item.node;
const lbl = item.branch === "choice" ? "选择:" + (n.options || []).map(o => o.text).join(" / ")
: item.branch === "random" ? "随机分支(预览取首路)"
: "战斗 vs " + (n.camp2 || []).map(nm).join("、") + "(预览取胜路)";
clips.push({ row: "剧情", kind: "branch", start: t, dur: 0.6, label: lbl }); useRow("剧情"); t += 0.6; return;
}
if (item.ending) { clips.push({ row: "剧情", kind: "ending", start: t, dur: 0.8, label: "★ " + (item.ending.summary || item.ending.id) }); useRow("剧情"); t += 0.8; return; }
const n = item.node, k = item.kind;
if (k === "dialogue" || k === "narration") {
const sp = n.speaker || "P1", dur = dlgDur(n.text);
clips.push({ row: "演员:" + sp, kind: "dialogue", start: t, dur, label: esc(n.text || ""), actor: sp, text: n.text || "" });
useRow("演员:" + sp);
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 = 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;
} else if (k === "anim") {
const sp = n.actor || "P1";
clips.push({ row: "演员:" + sp, kind: "anim", start: t, dur: ANIM_DUR, label: "动画 " + (n.ani || ""), actor: sp });
useRow("演员:" + sp); t += ANIM_DUR;
} else if (k === "reward") {
clips.push({ row: "剧情", kind: "reward", start: t, dur: 0.4, label: "奖励结算" }); useRow("剧情"); t += 0.4;
}
});
// 行排序:演员(按 roles 顺序P1 优先)→ 镜头 → 剧情
const order = [];
if (rowSet.includes("演员:P1")) order.push("演员:P1");
(IR.roles || []).forEach(r => { const k = "演员:" + r.slot; if (k !== "演员:P1" && rowSet.includes(k)) order.push(k); });
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 = [];
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: displayAnchors, initPos, nm, roleName, bounds, synthetic };
}
// ---- 播放期查询 ----
function actorPosAt(model, actor, tau) {
let pos = model.initPos[actor] || { x: 0, z: 0 };
const moves = model.clips.filter(c => c.actor === actor && c.kind === "move").sort((a, b) => a.start - b.start);
for (const m of moves) {
if (tau >= m.start + m.dur) pos = m.to;
else if (tau >= m.start) { const f = (tau - m.start) / m.dur; return { x: m.from.x + (m.to.x - m.from.x) * f, z: m.from.z + (m.to.z - m.from.z) * f }; }
else break;
}
return pos;
}
function activeDialogue(model, tau) {
return model.clips.find(c => c.kind === "dialogue" && tau >= c.start && tau < c.start + c.dur) || null;
}
function activeAnim(model, actor, tau) {
return model.clips.find(c => c.kind === "anim" && c.actor === actor && tau >= c.start && tau < c.start + c.dur) || null;
}
// 镜头世界焦点:显式 camera clip 优先 → 否则跟随当前说话人 → 否则玩家 → 否则场景中心。
// 这样镜头可视框「始终可见」并跟着戏走,而不是只在配了 camera 的对话上才出现。
function focusWorldAt(model, tau) {
let fp = null;
model.clips.filter(c => c.kind === "camera").forEach(c => { if (tau >= c.start) fp = c.focus; });
if (fp) { const p = anchorXZ(model.anchors, fp); if (p) return p; }
const dlg = activeDialogue(model, tau);
if (dlg && dlg.actor) return actorPosAt(model, dlg.actor, tau);
if (actorsIn(model).includes("P1")) return actorPosAt(model, "P1", tau);
const b = model.bounds; return { x: (b.minX + b.maxX) / 2, z: (b.minZ + b.maxZ) / 2 };
}
function actorsIn(model) {
const s = []; model.rows.forEach(r => { if (r.startsWith("演员:")) s.push(r.slice(3)); });
return s;
}
// ===================== 状态 & 挂载 =====================
let model, playT = 0, playing = false, rafId = 0, lastTs = 0, stageCv, stageCtx, els = {};
const TEMPLATE =
'<div class="tl-mapinfo"></div>' +
'<div class="tl-stagewrap"><canvas class="tl-stage" width="780" height="380"></canvas></div>' +
'<div class="tl-controls">' +
' <button class="tl-play primary">▶ 播放</button>' +
' <button class="tl-restart mini">⏮ 重头</button>' +
' <span class="tl-time">0.0 / 0.0s</span>' +
' <span class="tip">点时间轴任意处跳转 · 战斗/选择/随机仅标点不模拟 · 预览沿首出口主路径</span>' +
'</div>' +
'<div class="tl-tracks"></div>';
function show(host, IR, DICT, POINTSETS) {
stopPlay();
const psName = (IR.stage || {}).point_set || IR.id;
const ps = (POINTSETS || {})[psName] || {};
const anchors = ps.anchors || [];
model = buildModel(IR, anchors);
host.innerHTML = TEMPLATE;
els = {
host,
mapinfo: host.querySelector(".tl-mapinfo"),
stage: host.querySelector(".tl-stage"),
tracks: host.querySelector(".tl-tracks"),
play: host.querySelector(".tl-play"),
restart: host.querySelector(".tl-restart"),
time: host.querySelector(".tl-time"),
playhead: null,
};
els.mapinfo.textContent = "点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + "" : "") +
(model.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)" : " · 真实坐标");
stageCv = els.stage; stageCtx = stageCv.getContext("2d");
buildTracks();
els.play.onclick = () => playing ? stopPlay() : play();
els.restart.onclick = () => { stopPlay(); seek(0); };
seek(0);
}
function clear() { stopPlay(); if (els.host) els.host.innerHTML = ""; els = {}; model = null; }
function buildTracks() {
const host = els.tracks; host.innerHTML = ""; host.style.position = "relative";
const W = model.total * PXPSEC;
const ruler = document.createElement("div"); ruler.className = "tl-ruler"; ruler.style.width = W + "px";
for (let s = 0; s <= Math.ceil(model.total); s++) {
const tick = document.createElement("div"); tick.className = "tl-tick"; tick.style.left = (s * PXPSEC) + "px";
tick.innerHTML = '<span>' + s + 's</span>'; ruler.appendChild(tick);
}
host.appendChild(ruler);
model.rows.forEach(r => {
const lane = document.createElement("div"); lane.className = "tl-lane"; lane.style.width = W + "px"; lane.style.height = ROW_H + "px";
const label = r.startsWith("演员:") ? model.nm(r.slice(3)) : r;
lane.appendChild(Object.assign(document.createElement("div"), { className: "tl-lane-label", textContent: label }));
model.clips.filter(c => c.row === r).forEach(c => {
const el = document.createElement("div");
el.className = "tl-clip k-" + c.kind;
el.style.left = (c.start * PXPSEC) + "px";
el.style.width = Math.max(8, c.dur * PXPSEC - 2) + "px";
el.title = c.label + "" + c.dur.toFixed(1) + "s";
el.textContent = c.label;
el.dataset.start = c.start; el.dataset.end = c.start + c.dur;
el.onclick = e => { e.stopPropagation(); seek(c.start + 0.001); };
lane.appendChild(el);
});
host.appendChild(lane);
});
const ph = document.createElement("div"); ph.className = "tl-playhead"; host.appendChild(ph);
els.playhead = ph;
host.onclick = e => {
const rect = host.getBoundingClientRect();
const x = e.clientX - rect.left + host.scrollLeft;
seek(Math.max(0, Math.min(model.total, x / PXPSEC)));
};
}
function worldToStage(p) {
const b = model.bounds, pad = 40;
const w = stageCv.width, h = stageCv.height;
const dx = (b.maxX - b.minX) || 1, dz = (b.maxZ - b.minZ) || 1;
const sc = Math.min((w - pad * 2) / dx, (h - pad * 2) / dz);
const cx = (b.minX + b.maxX) / 2, cz = (b.minZ + b.maxZ) / 2;
return { x: w / 2 + (p.x - cx) * sc, y: h / 2 - (p.z - cz) * sc, sc };
}
const ACTOR_COLORS = ["#e6c878", "#7ec8e3", "#e38f7e", "#9ee37e", "#c89ee3", "#e3c87e", "#7ee3c8"];
function colorOf(model, actor) {
if (actor === "P1") return "#f0d890";
const list = actorsIn(model).filter(a => a !== "P1");
const i = list.indexOf(actor);
return ACTOR_COLORS[(i + 1) % ACTOR_COLORS.length];
}
function drawStage(tau) {
const ctx = stageCtx, w = stageCv.width, h = stageCv.height;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#15130d"; ctx.fillRect(0, 0, w, h);
// 锚点(淡)
(model.anchors || []).forEach(a => {
const p = worldToStage({ x: a.pos[0], z: a.pos[2] });
ctx.strokeStyle = "rgba(180,170,140,.35)"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = "rgba(180,170,140,.45)"; ctx.font = "10px sans-serif"; ctx.textAlign = "center";
ctx.fillText(a.name, p.x, p.y - 8);
});
// 镜头可视区域框(始终可见,跟随焦点)
const fw = focusWorldAt(model, tau), fp = worldToStage(fw);
const bw = CAM_W * fp.sc, bh = CAM_H * fp.sc;
ctx.strokeStyle = "rgba(230,200,120,.85)"; ctx.lineWidth = 1.5; ctx.setLineDash([7, 5]);
ctx.strokeRect(fp.x - bw / 2, fp.y - bh / 2, bw, bh); ctx.setLineDash([]);
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);
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) {
const typed = (dlg.text || "").slice(0, Math.floor((tau - dlg.start) / CHAR_TIME));
drawBubble(ctx, p.x, p.y - 26, model.nm(actor) + "" + typed);
}
});
}
function drawBubble(ctx, cx, cy, text) {
ctx.font = "12px sans-serif"; ctx.textAlign = "left";
const maxW = 260, lines = wrap(ctx, text, maxW), lh = 16;
const tw = Math.min(maxW, Math.max(...lines.map(l => ctx.measureText(l).width))) + 14;
const th = lines.length * lh + 10;
let x = cx - tw / 2, y = cy - th;
x = Math.max(4, Math.min(stageCv.width - tw - 4, x)); y = Math.max(4, y);
ctx.fillStyle = "rgba(20,18,12,.92)"; ctx.strokeStyle = "#e6c878"; ctx.lineWidth = 1;
roundRect(ctx, x, y, tw, th, 5); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#f0e6c8";
lines.forEach((l, i) => ctx.fillText(l, x + 7, y + 16 + i * lh));
}
function wrap(ctx, text, maxW) {
const out = []; let cur = "";
for (const ch of String(text)) {
if (ctx.measureText(cur + ch).width > maxW || ch === "\n") { out.push(cur); cur = ch === "\n" ? "" : ch; }
else cur += ch;
}
if (cur) out.push(cur);
return out.length ? out : [""];
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath(); ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath();
}
// ---- 帧/控制 ----
function renderFrame() {
if (!model) return;
drawStage(playT);
if (els.playhead) els.playhead.style.left = (playT * PXPSEC) + "px";
if (els.time) els.time.textContent = playT.toFixed(1) + " / " + model.total.toFixed(1) + "s";
els.tracks.querySelectorAll(".tl-clip").forEach(el => {
el.classList.toggle("active", playT >= +el.dataset.start && playT < +el.dataset.end);
});
const host = els.tracks, phx = playT * PXPSEC;
if (phx < host.scrollLeft + 60 || phx > host.scrollLeft + host.clientWidth - 60)
host.scrollLeft = phx - host.clientWidth / 2;
}
function tick(ts) {
if (!playing) return;
if (!lastTs) lastTs = ts;
playT += (ts - lastTs) / 1000; lastTs = ts;
if (playT >= model.total) { playT = model.total; playing = false; updateBtn(); }
renderFrame();
if (playing) rafId = requestAnimationFrame(tick);
}
function play() { if (!model) return; if (playT >= model.total) playT = 0; playing = true; lastTs = 0; updateBtn(); rafId = requestAnimationFrame(tick); }
function stopPlay() { playing = false; if (rafId) cancelAnimationFrame(rafId); rafId = 0; updateBtn(); }
function seek(t) { playT = t; lastTs = 0; renderFrame(); }
function updateBtn() { if (els.play) els.play.textContent = playing ? "⏸ 暂停" : "▶ 播放"; }
window.Timeline = {
show, stop: stopPlay, clear,
// 仅供离线测试node调用的纯逻辑无 DOM 依赖:
_buildModel: buildModel, _linearize: linearize, _dlgDur: dlgDur,
};
})();