Files
story-edit-web/web/static/timeline.js
邓雨鹏 676df30c67 @
feat(web): 演出配置 Timeline 加「场景底图」选择器(venue 特写)

- 后端 /api/sceneshots:列 SceneShots 全部俯视底图(venue 特写) name->{url,bounds}
- timeline.js:底图优先级 ir.stage.backdrop(venue) > 点位集默认 shot;
  顶栏加底图下拉 renderMapInfo + applyBackdrop(换底+改投影范围+重画+回调)
- app.js:拉 /api/sceneshots;performSelect 传入;saveBackdrop 写 ir.stage.backdrop 并 PUT
- venue 特写与点位集同 map-local → 换底图后锚点自动落对位(无头实拍擂台验证)
- ir.stage.backdrop 是编辑器元数据:validate 不读、compile 不碰
@
2026-06-15 12:01:14 +08:00

725 lines
40 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 () {
// ---- 时长模型(常量与 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, CAMERA_DUR = 2.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);
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;
}
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);
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");
(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 }; } };
// 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 === "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);
}
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;
}
}
// 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 生成可视 clipstart 偏移 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;
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 === "scene") { appendScene(S, n); id = n.next; continue; }
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) {
// 真机=45°俯视跟随玩家的相机默认锁玩家不会因换说话人而推近仅显式镜头点(camera clip)才移焦。
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; }
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, lastDecisionId = null;
let shotBg = null, shotReady = false, shotBounds = null; // 真实场景正交俯视底图
// 场景底图选择器SHOTS=所有可选 venue 特写({name:{url,bounds}})curBackdrop=当前选中底图名(""=点位集默认)
// onPick=切换底图回调(app.js 写 ir.stage.backdrop 并持久化)baseBounds=无底图时回退的投影范围。
let SHOTS = {}, curPsName = "", curPs = {}, curBackdrop = "", onPick = null, baseBounds = null;
const TEMPLATE =
'<div class="tl-stagepanel">' +
' <div class="tl-mapinfo"></div>' +
' <div class="tl-stagewrap"><canvas class="tl-stage" width="960" height="470"></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-resizer" title="拖动调整时间轴高度"></div>' +
'<div class="tl-timelinepanel"><div class="tl-tracks"></div></div>';
function show(host, IR, DICT, POINTSETS, opts) {
stopPlay();
opts = opts || {};
const startId = opts.startId;
SHOTS = opts.sceneshots || {};
onPick = opts.onPickBackdrop || null;
const psName = (IR.stage || {}).point_set || IR.id;
const ps = (POINTSETS || {})[psName] || {};
curPsName = psName; curPs = ps;
curBackdrop = (IR.stage || {}).backdrop || "";
S = prepare(IR, ps.anchors || []); model = S;
baseBounds = Object.assign({}, S.bounds); // 无底图时回退用
// 底图优先级:事件指定的 venue 特写(ir.stage.backdrop) > 点位集默认底图
const shot = (curBackdrop && SHOTS[curBackdrop]) ? SHOTS[curBackdrop] : ps.shot;
setupShot(shot); // 有真实场景俯视底图 → 覆盖投影范围并异步加载drawStage 当舞台底
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"),
resizer: host.querySelector(".tl-resizer"),
timelinepanel: host.querySelector(".tl-timelinepanel"),
playhead: null,
};
renderMapInfo();
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(); };
// 拖拽分隔条调时间轴高度(向上拖=时间轴变高/舞台变小,反之)
els.resizer.onmousedown = e => {
e.preventDefault();
const startY = e.clientY, startH = els.timelinepanel.offsetHeight;
document.body.style.cursor = "row-resize";
const mv = ev => {
const h = Math.max(80, Math.min((els.host.clientHeight - 150), startH - (ev.clientY - startY)));
els.timelinepanel.style.height = h + "px";
if (fitMode) { refreshTimeline(); renderFrame(); }
};
const up = () => { document.removeEventListener("mousemove", mv); document.removeEventListener("mouseup", up); document.body.style.cursor = ""; };
document.addEventListener("mousemove", mv); document.addEventListener("mouseup", up);
};
startFrom(startId && S.nodes[startId] ? startId : 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;
if (n.kind === "scene") return "演出段·" + (n.tracks || []).length + "轨";
return n.kind;
}
// 从指定节点开始:先重放途中走位算进入位置,再从该节点构建演出。
function startFrom(targetId, autoplay) {
stopPlay(); hideChoices(); clearSelection(); lastDecisionId = null;
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; }
// 真实场景俯视底图shot={url,bounds:[minX,maxX,minZ,maxZ]}Unity 抓拍产出)。
// 有底图时把投影范围覆盖成底图覆盖的 map-local 范围 → actor/锚点像素级落在真实场景上。
function setupShot(shot) {
shotBg = null; shotReady = false; shotBounds = null;
if (!shot || !Array.isArray(shot.bounds) || shot.bounds.length !== 4) return;
const b = shot.bounds;
shotBounds = { minX: b[0], maxX: b[1], minZ: b[2], maxZ: b[3] };
S.bounds = shotBounds; // 与底图对齐(口径同 pointview屏幕右=+X、上=+Z
shotBg = new Image();
shotBg.onload = () => { shotReady = true; if (stageCv) renderFrame(); };
shotBg.onerror = () => { shotReady = false; };
shotBg.src = shot.url;
}
// 顶部信息栏 + 场景底图选择器。底图列表来自 /api/sceneshotsvenue 特写等)。
function renderMapInfo() {
const mi = els.mapinfo; if (!mi) return;
mi.innerHTML = "";
const span = document.createElement("span");
span.textContent = "点位集:" + curPsName + (curPs.mapId ? "(地图 " + curPs.mapId + "" : "") +
(S.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)"
: (shotBounds ? " · 真实坐标 · 已叠场景底图" : " · 真实坐标"));
mi.appendChild(span);
const names = Object.keys(SHOTS).sort();
if (names.length) {
const lab = document.createElement("label");
lab.className = "tl-bd-label"; lab.textContent = "场景底图:";
const sel = document.createElement("select");
sel.className = "tl-bd-select";
const def = document.createElement("option");
def.value = ""; def.textContent = curPs.shot ? "(点位集默认)" : "(无)";
sel.appendChild(def);
names.forEach(n => {
const o = document.createElement("option");
o.value = n; o.textContent = n;
if (n === curBackdrop) o.selected = true;
sel.appendChild(o);
});
sel.onchange = () => applyBackdrop(sel.value);
mi.appendChild(lab); mi.appendChild(sel);
}
}
// 切换 Timeline 底图:换底 + 改投影范围 + 重画 + 回调持久化(写 ir.stage.backdrop)。
// 锚点(点位集坐标)不变,因 venue 特写与点位集同为 map-local事件点会落在特写图正确位置上。
function applyBackdrop(name) {
curBackdrop = name;
const shot = (name && SHOTS[name]) ? SHOTS[name] : curPs.shot;
if (shot && Array.isArray(shot.bounds) && shot.bounds.length === 4) {
setupShot(shot);
} else { // 选「无/默认」且无点位集底图 → 回退基础范围
shotBg = null; shotReady = false; shotBounds = null;
if (baseBounds) S.bounds = Object.assign({}, baseBounds);
}
renderMapInfo();
renderFrame();
if (onPick) onPick(name); // app.js: ir.stage.backdrop = name 并 PUT 持久化
}
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(); } };
// 鼠标按住拖拽 → 平移时间轴(拖动超过阈值则不算点击,避免误选)
let dragging = false, dragX = 0, dragScroll = 0, moved = false;
host.onmousedown = e => { if (e.button !== 0) return; dragging = true; moved = false; dragX = e.clientX; dragScroll = host.scrollLeft; };
host.onmousemove = e => { if (!dragging) return; const dx = e.clientX - dragX; if (Math.abs(dx) > 3) { moved = true; host.style.cursor = "grabbing"; } host.scrollLeft = dragScroll - dx; e.preventDefault(); };
const endDrag = () => { dragging = false; host.style.cursor = ""; };
host.onmouseup = endDrag; host.onmouseleave = endDrag;
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(); if (moved) return; 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 => {
if (moved) return; // 刚才是拖拽,不当作跳转
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);
// 真实场景俯视底图:画进 bounds 投影出的矩形(纵横比=bounds=图,故像素级贴合)
if (shotBg && shotReady && shotBounds) {
const tl = worldToStage({ x: shotBounds.minX, z: shotBounds.maxZ });
const br = worldToStage({ x: shotBounds.maxX, z: shotBounds.minZ });
ctx.drawImage(shotBg, tl.x, tl.y, br.x - tl.x, br.y - tl.y);
}
(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();
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) {
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) {
lastDecisionId = node.id; // 记录最近分支点,供结局「回到上一个选择」
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) return;
if (pending.kind === "choice" || pending.kind === "choice_once" || pending.kind === "fight" || pending.kind === "random")
showChoices(pending.node, pending.kind);
else if (pending.kind === "ending")
showEnding(pending.node);
}
function grantText(g) {
if (g.kind === "银两") return "银两" + (g.value > 0 ? "+" : "") + g.value;
if (g.kind === "道具") return "道具" + g.item + "×" + g.value;
if (g.kind === "友好度") return S.nm(g.target) + " 友好+" + g.value;
if (g.kind === "入门") return S.nm(g.target) + " 入门";
return g.kind;
}
function showEnding(node) {
const box = els.choices; box.innerHTML = "";
const res = { success: "成功", fail: "失败", end: "中性" }[node.result || "success"];
const grants = (node.grants && node.grants.length) ? " 奖励:" + node.grants.map(grantText).join("") : "";
box.appendChild(Object.assign(document.createElement("div"), { className: "tl-choices-q tl-ending-q", textContent: "★ 结局:" + (node.summary || node.id) + "" + res + "" + grants }));
const mk = (txt, fn) => { const b = document.createElement("button"); b.className = "tl-choice-btn tl-ending-btn"; b.textContent = txt; b.onclick = fn; box.appendChild(b); };
mk("⟲ 重新观看", () => startFrom(firstNode(S.IR), true));
if (lastDecisionId && S.nodes[lastDecisionId]) mk("↶ 回到上一个选择", () => startFrom(lastDecisionId, true));
box.classList.remove("hidden");
}
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;
}
// 纯函数版 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,
_pathTo: pathTo, _replayPositions: replayPositions,
};
})();