Files
story-edit-web/web/static/timeline.js
邓雨鹏 e6ec743564 feat(timeline): 交互式分支演出 + 从任意节点开始 + 时间轴分面板/自适应
- 交互分支:播到选择/战斗/随机节点暂停,在镜头下方弹按钮手选,点完接对应分支续演
- 从任意节点开始:单击节点选中→「从选中处开始」或双击节点,立即从该处播放;
  起始位置按从开头到该节点途中的走位重放算出(角色站在应有位置),已离线验证
- 布局分两块(上舞台/下时间轴),时间轴 playhead 仅播放时居中、暂停可自由滚
- 时间轴自适应宽度按钮(整条压进可视区)+鼠标滚轮横向滚动,解决长时间轴够不着节点
- 演示事件 QY_TLDEMO 扩充两条分支(和解→随机夜晚/拔剑→战斗胜败),去掉对焦NP1
2026-06-13 18:54:32 +08:00

502 lines
27 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 播放。
// 走位插值 / 对话打字机 / 镜头可视框 / 动画标记;战斗·随机仅标点。
// 交互式分支:播到「选择/战斗」节点暂停,在舞台底部(镜头下方)弹出选项浮层,点击后接对应分支续演。
// 布局:上=演出舞台面板(含选项浮层),下=独立时间轴面板(横向滚、playhead 居中)。
// 暴露 window.Timeline = { show(host, ir, dict, pointsets), stop() }。
(function () {
// ---- 时长模型 ----
const CHAR_TIME = 0.07, TAIL_PAUSE = 0.9, MIN_DLG = 1.2;
const MOVE_SPEED = 3.0, ANIM_DUR = 1.0;
const PXMAX = 80, ROW_H = 30; // PXMAX=每秒最大像素;实际用动态 PX可适应宽度
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; }
function firstNode(IR) {
const indeg = {}; (IR.nodes || []).forEach(n => indeg[n.id] = 0);
(IR.nodes || []).forEach(n => {
[n.next].concat((n.options || []).map(o => o.goto), (n.branches || []).map(b => b.goto), n.kind === "fight" ? [n.win, n.lose] : [])
.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 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); });
scan(IR.nodes); (IR.sequences || []).forEach(s => scan(s.nodes));
return refs;
}
function prepare(IR, anchors) {
const roleName = {}; (IR.roles || []).forEach(r => roleName[r.slot] = r.name);
const nm = s => s === "P1" ? "玩家" : (roleName[s] || s);
const refs = collectAllRefs(IR);
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 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]); });
Object.values(posMap).forEach(p => { xs.push(p.x); zs.push(p.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 };
const initPos = {}; refs.forEach(r => { if (posMap[r]) initPos[r] = posMap[r]; });
const S = {
IR, nm, roleName, posMap, anchors: displayAnchors, bounds, synthetic, initPos,
nodes: {}, endings: {}, seqMap: {},
clips: [], rows: [], total: 0.1,
_t: 0, curPos: {}, entryPos: {}, visited: new Set(),
};
(IR.nodes || []).forEach(n => S.nodes[n.id] = n);
(IR.endings || []).forEach(e => S.endings[e.id] = e);
(IR.sequences || []).forEach(s => S.seqMap[s.id] = s);
// 在场角色:全事件里当过 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); });
scanU(IR.nodes); (IR.sequences || []).forEach(s => scanU(s.nodes));
const ordU = [];
if (used.includes("P1")) ordU.push("P1");
(IR.roles || []).forEach(r => { if (r.slot !== "P1" && used.includes(r.slot)) ordU.push(r.slot); });
used.forEach(u => { if (!ordU.includes(u)) ordU.push(u); });
S.usedActors = ordU;
return S;
}
function resetAccum(S) { S.clips = []; S.rows = []; S._t = 0; S.curPos = Object.assign({}, S.entryPos || {}); S.visited = new Set(); S.total = 0.1; }
// 找一条从开头到 targetId 的路径BFS用于重放途中走位算"进入位置"。
function pathTo(S, targetId) {
const start = firstNode(S.IR);
if (!targetId || targetId === start) return [];
const adj = id => {
const n = S.nodes[id]; if (!n) return [];
if (n.kind === "choice" || n.kind === "choice_once") return (n.options || []).map(o => o.goto);
if (n.kind === "random") return (n.branches || []).map(b => b.goto);
if (n.kind === "fight") return [n.win, n.lose];
return [n.next];
};
const prev = {}, q = [start], seen = new Set([start]);
while (q.length) {
const u = q.shift();
for (const v of adj(u)) {
if (v && !seen.has(v) && S.nodes[v]) {
seen.add(v); prev[v] = u;
if (v === targetId) { const path = []; let c = v; while (c !== undefined) { path.unshift(c); c = prev[c]; } return path; }
q.push(v);
}
}
}
return null;
}
// 沿路径(不含目标节点本身)重放所有走位,得到进入目标节点时各角色的位置。
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 }; } };
for (let i = 0; i < path.length - 1; i++) {
const n = S.nodes[path[i]]; if (!n) continue;
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);
}
return cur;
}
// ===================== 分段构建(增量追加)=====================
function useRow(S, r) { if (!S.rows.includes(r)) S.rows.push(r); }
function posOf(S, a) { return S.curPos[a] || S.initPos[a] || { x: 0, z: 0 }; }
function appendPlainNode(S, n) {
const k = n.kind, nid = (S.nodes[n.id] === n) ? n.id : null; // 仅顶层节点可作起点(子序列节点不可)
if (k === "dialogue" || k === "narration") {
const sp = n.speaker || "P1", dur = dlgDur(n.text);
S.clips.push({ row: "演员:" + sp, kind: "dialogue", start: S._t, dur, label: esc(n.text || ""), actor: sp, text: n.text || "", nodeId: nid });
useRow(S, "演员:" + sp);
if (n.camera) { S.clips.push({ row: "镜头", kind: "camera", start: S._t, dur, label: "对焦 " + n.camera, focus: n.camera, nodeId: nid }); useRow(S, "镜头"); }
S._t += dur;
} else if (k === "move") {
const sp = n.actor || "P1", from = posOf(S, sp), to = S.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);
S.clips.push({ row: "演员:" + sp, kind: "move", start: S._t, dur, label: "→ " + (n.to || ""), actor: sp, from, to, nodeId: nid });
useRow(S, "演员:" + sp); S.curPos[sp] = { x: to.x, z: to.z }; S._t += dur;
} else if (k === "anim") {
const sp = n.actor || "P1";
S.clips.push({ row: "演员:" + sp, kind: "anim", start: S._t, dur: ANIM_DUR, label: "动画 " + (n.ani || ""), actor: sp, nodeId: nid });
useRow(S, "演员:" + sp); S._t += ANIM_DUR;
} else if (k === "reward") {
S.clips.push({ row: "剧情", kind: "reward", start: S._t, dur: 0.4, label: "奖励结算", nodeId: nid }); useRow(S, "剧情"); S._t += 0.4;
}
}
// 从 startId 走一段线性演出,遇分支/结局/断头停下。返回 {kind, node}。
function extendSegment(S, startId) {
let id = startId, guard = 0;
while (id && guard++ < 500) {
if (S.visited.has(id)) { S.clips.push({ row: "剧情", kind: "stop", start: S._t, dur: 0.4, label: "循环中断" }); useRow(S, "剧情"); S._t += 0.4; return { kind: "end" }; }
S.visited.add(id);
const end = S.endings[id];
if (end) { S.clips.push({ row: "剧情", kind: "ending", start: S._t, dur: 0.8, label: "★ " + (end.summary || end.id) }); useRow(S, "剧情"); S._t += 0.8; return { kind: "ending", node: end }; }
const n = S.nodes[id];
if (!n) return { kind: "end" };
const k = n.kind;
if (k === "choice" || k === "choice_once" || k === "random" || k === "fight") {
const lbl = (k === "fight") ? "战斗 vs " + (n.camp2 || []).map(S.nm).join("、")
: (k === "random") ? "随机分支" : "选择";
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 === "out_ref") {
const sq = S.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; appendPlainNode(S, sn); sid = sn.next; }
}
id = n.next; continue;
}
appendPlainNode(S, n); id = n.next;
}
return { kind: "end" };
}
// 走一段停在下一个需要选的点choice/fight/random或结束。
function runUntilPause(S, startId) {
const r = extendSegment(S, startId);
S.total = Math.max(S._t, 0.1);
return r;
}
function orderRows(S) {
const order = [];
if (S.rows.includes("演员:P1")) order.push("演员:P1");
(S.IR.roles || []).forEach(r => { const k = "演员:" + r.slot; if (k !== "演员:P1" && S.rows.includes(k)) order.push(k); });
S.rows.forEach(r => { if (r.startsWith("演员:") && !order.includes(r)) order.push(r); });
["镜头", "剧情"].forEach(r => { if (S.rows.includes(r)) order.push(r); });
S.rows = order;
}
// ===================== 播放期查询 =====================
function actorsIn(M) { return M.usedActors || []; }
function actorPosAt(M, actor, tau) {
let pos = (M.entryPos && M.entryPos[actor]) || M.initPos[actor] || { x: 0, z: 0 };
const moves = M.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(M, tau) { return M.clips.find(c => c.kind === "dialogue" && tau >= c.start && tau < c.start + c.dur) || null; }
function activeAnim(M, actor, tau) { return M.clips.find(c => c.kind === "anim" && c.actor === actor && tau >= c.start && tau < c.start + c.dur) || null; }
function focusWorldAt(M, tau) {
let fp = null;
M.clips.filter(c => c.kind === "camera").forEach(c => { if (tau >= c.start) fp = c.focus; });
if (fp) { const p = anchorXZ(M.anchors, fp); if (p) return p; }
const dlg = activeDialogue(M, tau);
if (dlg && dlg.actor) return actorPosAt(M, dlg.actor, tau);
if (actorsIn(M).includes("P1")) return actorPosAt(M, "P1", tau);
const b = M.bounds; return { x: (b.minX + b.maxX) / 2, z: (b.minZ + b.maxZ) / 2 };
}
// ===================== 状态 & 挂载 =====================
let S, model, playT = 0, playing = false, rafId = 0, lastTs = 0, stageCv, stageCtx, els = {}, pending = null, selNode = null, PX = 80, fitMode = false;
const TEMPLATE =
'<div class="tl-stagepanel">' +
' <div class="tl-mapinfo"></div>' +
' <div class="tl-stagewrap"><canvas class="tl-stage" width="780" height="380"></canvas><div class="tl-choices hidden"></div></div>' +
' <div class="tl-controls">' +
' <button class="tl-play primary">▶ 播放</button>' +
' <button class="tl-restart mini">⏮ 重头</button>' +
' <button class="tl-startbtn mini" disabled>▶ 从选中处开始</button>' +
' <button class="tl-fitbtn mini" title="整条时间轴压进可视宽度/恢复">⇔ 适应宽度</button>' +
' <span class="tl-time">0.0 / 0.0s</span>' +
' <span class="tip">单击节点选中→「从选中处开始」,或双击节点直接开始(位置按途中走位重放) · 遇选择/战斗/随机弹选项</span>' +
' </div>' +
'</div>' +
'<div class="tl-timelinepanel"><div class="tl-tracks"></div></div>';
function show(host, IR, DICT, POINTSETS) {
stopPlay();
const psName = (IR.stage || {}).point_set || IR.id;
const ps = (POINTSETS || {})[psName] || {};
S = prepare(IR, ps.anchors || []); model = S;
host.innerHTML = TEMPLATE;
els = {
host,
mapinfo: host.querySelector(".tl-mapinfo"),
stage: host.querySelector(".tl-stage"),
choices: host.querySelector(".tl-choices"),
tracks: host.querySelector(".tl-tracks"),
play: host.querySelector(".tl-play"),
restart: host.querySelector(".tl-restart"),
startbtn: host.querySelector(".tl-startbtn"),
fitbtn: host.querySelector(".tl-fitbtn"),
time: host.querySelector(".tl-time"),
playhead: null,
};
els.mapinfo.textContent = "点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + "" : "") +
(S.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)" : " · 真实坐标");
stageCv = els.stage; stageCtx = stageCv.getContext("2d");
els.play.onclick = () => playing ? stopPlay() : play();
els.restart.onclick = () => restart();
els.startbtn.onclick = () => { if (selNode) startFrom(selNode, true); };
els.fitbtn.onclick = () => { fitMode = !fitMode; els.fitbtn.classList.toggle("on", fitMode); refreshTimeline(); renderFrame(); };
startFrom(firstNode(IR));
}
function selectClip(c, el) {
els.tracks.querySelectorAll(".tl-clip.sel").forEach(x => x.classList.remove("sel"));
const startable = c.nodeId && S.nodes[c.nodeId];
if (el) el.classList.add("sel");
selNode = startable ? c.nodeId : null;
els.startbtn.disabled = !selNode;
els.startbtn.textContent = selNode ? ("▶ 从「" + nodeLabel(S, S.nodes[selNode]).slice(0, 12) + "」开始") : "▶ 从选中处开始";
}
function clearSelection() { selNode = null; if (els.startbtn) { els.startbtn.disabled = true; els.startbtn.textContent = "▶ 从选中处开始"; } }
function nodeLabel(S, n) {
const nm = S.nm, t = (n.text || "").slice(0, 12);
if (n.kind === "narration") return "旁白·" + nm(n.speaker || "P1") + "" + t;
if (n.kind === "dialogue") return "对话·" + nm(n.speaker || "P1") + "" + t;
if (n.kind === "move") return "走位·" + nm(n.actor) + "→" + n.to;
if (n.kind === "anim") return "动画·" + nm(n.actor) + " " + (n.ani || "");
if (n.kind === "choice" || n.kind === "choice_once") return "【选择】" + (n.options || []).map(o => o.text).join(" / ").slice(0, 18);
if (n.kind === "random") return "【随机】" + (n.branches || []).length + " 路";
if (n.kind === "fight") return "【战斗】vs " + (n.camp2 || []).map(nm).join("、");
if (n.kind === "reward") return "奖励结算";
if (n.kind === "out_ref") return "引用·" + n.ref;
return n.kind;
}
// 从指定节点开始:先重放途中走位算进入位置,再从该节点构建演出。
function startFrom(targetId, autoplay) {
stopPlay(); hideChoices(); clearSelection();
const path = pathTo(S, targetId);
S.entryPos = path ? replayPositions(S, path) : {};
resetAccum(S);
pending = runUntilPause(S, targetId);
refreshTimeline();
seek(0);
if (autoplay) play(); // 用户双击/点「从此处开始」=立即播放;初始加载/重头则停在起点
}
function clear() { stopPlay(); if (els.host) els.host.innerHTML = ""; els = {}; model = S = null; }
function restart() { startFrom(firstNode(S.IR)); }
function refreshTimeline() { orderRows(S); buildTracks(); }
function buildTracks() {
const host = els.tracks; host.innerHTML = ""; host.style.position = "relative";
// 适应宽度:把整条时间轴压进可视宽度(留标签位);否则用固定 80px/s。
PX = fitMode ? Math.max(6, ((host.clientWidth || 760) - 60) / Math.max(model.total, 0.1)) : PXMAX;
// 鼠标滚轮 → 横向滚动(横向溢出时)
host.onwheel = e => { if (host.scrollWidth > host.clientWidth + 1) { host.scrollLeft += (e.deltaY || 0) + (e.deltaX || 0); e.preventDefault(); } };
const W = model.total * PX;
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 * PX) + "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 * PX) + "px";
el.style.width = Math.max(8, c.dur * PX - 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;
if (c.nodeId && S.nodes[c.nodeId]) el.classList.add("startable");
el.onclick = e => { e.stopPropagation(); selectClip(c, el); }; // 单击=选中(不跳时间,避免视图乱滚)
el.ondblclick = e => { e.stopPropagation(); e.preventDefault(); if (c.nodeId && S.nodes[c.nodeId]) startFrom(c.nodeId, true); };
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();
seek(Math.max(0, Math.min(model.total, (e.clientX - rect.left + host.scrollLeft) / PX)));
};
}
function worldToStage(p) {
const b = model.bounds, pad = 40, 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(M, actor) {
if (actor === "P1") return "#f0d890";
const list = actorsIn(M).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), 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, 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 showChoices(node, kind) {
const box = els.choices; box.innerHTML = "";
let q, opts;
if (kind === "fight") {
q = "战斗(预览任一结果):";
opts = [{ label: "▶ 胜 → 继续", goto: node.win }, { label: "▷ 败 → 继续", goto: node.lose }];
} else if (kind === "random") {
q = "随机分支(手选预览任一):";
opts = (node.branches || []).map((b, i) => ({ label: "分支 " + (i + 1) + "(权重 " + (b.weight != null ? b.weight : "?") + "", goto: b.goto }));
} else {
q = "请选择:";
opts = (node.options || []).map(o => ({
label: o.text + (o.condition ? "(需 " + o.condition.kind + o.condition.op + o.condition.value + "" : ""),
goto: o.goto,
}));
}
box.appendChild(Object.assign(document.createElement("div"), { className: "tl-choices-q", textContent: q }));
opts.forEach(o => {
const b = document.createElement("button"); b.className = "tl-choice-btn"; b.textContent = o.label;
b.onclick = () => pick(o.goto);
box.appendChild(b);
});
box.classList.remove("hidden");
}
function hideChoices() { if (els.choices) { els.choices.classList.add("hidden"); els.choices.innerHTML = ""; } }
function pick(goto) {
hideChoices();
if (!goto) { pending = { kind: "end" }; return; }
pending = runUntilPause(S, goto);
refreshTimeline();
play(); // 从当前 playT(上一段末尾)继续
}
// ---- 帧/控制 ----
function renderFrame() {
if (!model) return;
drawStage(playT);
if (els.playhead) els.playhead.style.left = (playT * PX) + "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));
// 仅播放时把 playhead 顶在中间;暂停时不动滚动条,让用户自由横向滚动找节点。
if (playing) {
const host = els.tracks, phx = playT * PX, maxScroll = host.scrollWidth - host.clientWidth;
host.scrollLeft = Math.max(0, Math.min(Math.max(0, maxScroll), phx - host.clientWidth / 2));
}
}
function onReachEnd() {
if (pending && (pending.kind === "choice" || pending.kind === "choice_once" || pending.kind === "fight" || pending.kind === "random"))
showChoices(pending.node, pending.kind);
}
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(); onReachEnd(); return; }
renderFrame();
if (playing) rafId = requestAnimationFrame(tick);
}
function play() { if (!model) return; if (playT >= model.total && !(pending && pending.kind !== "end" && pending.kind !== "ending")) playT = 0; hideChoices(); playing = true; lastTs = 0; updateBtn(); rafId = requestAnimationFrame(tick); }
function stopPlay() { playing = false; if (rafId) cancelAnimationFrame(rafId); rafId = 0; updateBtn(); }
function seek(t) { playT = Math.max(0, Math.min(model ? model.total : 0, t)); lastTs = 0; if (playT < model.total) hideChoices(); renderFrame(); if (playT >= model.total) onReachEnd(); }
function updateBtn() { if (els.play) els.play.textContent = playing ? "⏸ 暂停" : "▶ 播放"; }
// ---- 离线测试用:自动取首分支拍平整路径 ----
function buildModelAuto(IR, anchors) {
const s = prepare(IR, anchors);
let r = extendSegment(s, firstNode(IR)), g = 0;
while (r && g++ < 200) {
if (r.kind === "choice" || r.kind === "choice_once") { const o = (r.node.options || [])[0]; if (!o) break; r = extendSegment(s, o.goto); }
else if (r.kind === "random") { const b = (r.node.branches || [])[0]; if (!b) break; r = extendSegment(s, b.goto); }
else if (r.kind === "fight") { r = extendSegment(s, r.node.win || r.node.lose); }
else break;
}
s.total = Math.max(s._t, 0.1); orderRows(s);
return s;
}
window.Timeline = {
show, stop: stopPlay, clear,
// 离线测试用:
_buildModel: buildModelAuto, _dlgDur: dlgDur,
_prepare: prepare, _extend: extendSegment, _runUntilPause: runUntilPause, _firstNode: firstNode, _orderRows: orderRows,
_pathTo: pathTo, _replayPositions: replayPositions,
};
})();