Files
story-edit-web/web/static/timeline.js
邓雨鹏 dbf857769e feat(timeline): P1 白模演出预览——时间线+俯视舞台播放
- /api/pointsets 增 anchors(含 pos/rot/kind/npc 坐标),保留 points 名字数组兼容下拉
- 新增 static/timeline.js:线性化主路径→按时长铺多轨时间线 + 2D 俯视白模舞台播放
  (走位插值/对话打字机/镜头俯视框示意/playhead/点击跳转);战斗/选择/随机仅标点不模拟
- 工具栏加「演出预览」按钮 + 预览遮罩 + 暗金主题样式
- 时长模型:对话=字数×打字机速度,走位=坐标距离÷速度,动画/镜头给缺省时长
- node 离线测试两样张(含 out_ref+fight+skip)逻辑全过;修掉结局重复渲染 bug

设计文档见主仓 docs/plans/2026-06-13-story-timeline-editor-and-whitebox-preview-design.md (P1 期)
2026-06-13 10:52:20 +08:00

363 lines
18 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线性顺序铺轴无并行偏移沿首出口走一条主路径预览。
// 暴露 window.Timeline = { open(ir, dict, pointsets) }。
(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; // 轨道行高
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;"); }
// ---- 取点坐标xz 平面y 是高度,俯视图忽略)----
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 initPos = {}; (anchors || []).forEach(a => initPos[a.name] = { x: a.pos[0], z: a.pos[2] });
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 = anchorXZ(anchors, 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 xs = [], zs = [];
(anchors || []).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 };
}
// ---- 播放期查询 ----
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;
}
function focusAt(model, tau) {
let f = null;
model.clips.filter(c => c.kind === "camera").forEach(c => { if (tau >= c.start) f = c.focus; });
return f;
}
// ---- 演员名单(出现在演出里的 actor slot----
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;
function open(IR, DICT, POINTSETS) {
const psName = (IR.stage || {}).point_set || IR.id;
const ps = (POINTSETS || {})[psName] || {};
const anchors = ps.anchors || [];
model = buildModel(IR, anchors);
document.getElementById("tl-modal").classList.remove("hidden");
document.getElementById("tl-mapinfo").textContent =
"点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + "" : "") +
(anchors.length ? "" : " ⚠ 无坐标,走位/俯视图将退化为示意");
stageCv = document.getElementById("tl-stage");
stageCtx = stageCv.getContext("2d");
buildTracks();
seek(0); stop();
}
function buildTracks() {
const host = document.getElementById("tl-tracks");
host.innerHTML = "";
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";
lane.dataset.row = r;
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);
});
// playhead
const ph = document.createElement("div"); ph.className = "tl-playhead"; ph.id = "tl-playhead";
host.appendChild(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)));
};
host.style.position = "relative";
}
function worldToStage(p) {
const b = model.bounds, pad = 36;
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, // z 越大越靠上
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 fc = focusAt(model, tau);
if (fc) {
const fp0 = anchorXZ(model.anchors, fc);
if (fp0) {
const fp = worldToStage(fp0), bw = 150, bh = 100;
ctx.strokeStyle = "rgba(230,200,120,.8)"; ctx.lineWidth = 1.5; ctx.setLineDash([6, 4]);
ctx.strokeRect(fp.x - bw / 2, fp.y - bh / 2, bw, bh); ctx.setLineDash([]);
ctx.fillStyle = "rgba(230,200,120,.85)"; ctx.font = "10px sans-serif"; ctx.textAlign = "left";
ctx.fillText("镜头", fp.x - bw / 2 + 3, fp.y - bh / 2 + 12);
}
}
// 演员
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() {
drawStage(playT);
const ph = document.getElementById("tl-playhead");
if (ph) ph.style.left = (playT * PXPSEC) + "px";
document.getElementById("tl-time").textContent = playT.toFixed(1) + " / " + model.total.toFixed(1) + "s";
// 高亮当前 clip
document.querySelectorAll("#tl-tracks .tl-clip").forEach(el => {
const on = playT >= +el.dataset.start && playT < +el.dataset.end;
el.classList.toggle("active", on);
});
// 自动横向滚动跟随 playhead
const host = document.getElementById("tl-tracks");
const 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 (playT >= model.total) playT = 0; playing = true; lastTs = 0; updateBtn(); rafId = requestAnimationFrame(tick); }
function stop() { playing = false; cancelAnimationFrame(rafId); updateBtn(); }
function seek(t) { playT = t; if (!playing) renderFrame(); else { lastTs = 0; } renderFrame(); }
function updateBtn() { const b = document.getElementById("tl-play"); if (b) b.textContent = playing ? "⏸ 暂停" : "▶ 播放"; }
// ---- 控件接线(一次性)----
function wire() {
document.getElementById("tl-play").onclick = () => playing ? stop() : play();
document.getElementById("tl-restart").onclick = () => { stop(); seek(0); };
document.querySelector("#tl-modal .modal-close").onclick = () => { stop(); document.getElementById("tl-modal").classList.add("hidden"); };
}
window.Timeline = {
open(ir, dict, pointsets) { if (!Timeline._wired) { wire(); Timeline._wired = true; } open(ir, dict, pointsets); },
_wired: false,
// 仅供离线测试node调用的纯逻辑无 DOM 依赖:
_buildModel: buildModel, _linearize: linearize, _dlgDur: dlgDur,
};
})();