feat(timeline): 交互式分支演出 + 从任意节点开始 + 时间轴分面板/自适应

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

View File

@ -1,7 +1,7 @@
{ {
"id": "QY_TLDEMO", "id": "QY_TLDEMO",
"title": "演出预览功能演示", "title": "演出预览功能演示",
"theme": "P1 白模演出测试(走位/对话打字机/动画/镜头/多角色", "theme": "P1 白模演出测试(走位/对话/动画/镜头/选择/随机/战斗",
"scale": "演示", "scale": "演示",
"roles": [ "roles": [
{ "slot": "NP1", "name": "神秘剑客", "archetype": "负伤外门高手", "camp": 0 }, { "slot": "NP1", "name": "神秘剑客", "archetype": "负伤外门高手", "camp": 0 },
@ -13,18 +13,48 @@
{ "id": "n_p1walk", "kind": "move", "actor": "P1", "to": "PT_DOOR", "next": "n_p1say" }, { "id": "n_p1walk", "kind": "move", "actor": "P1", "to": "PT_DOOR", "next": "n_p1say" },
{ "id": "n_p1say", "kind": "dialogue", "speaker": "P1", "text": "(这客栈看着冷清,却处处透着古怪……)", "next": "n_npenter" }, { "id": "n_p1say", "kind": "dialogue", "speaker": "P1", "text": "(这客栈看着冷清,却处处透着古怪……)", "next": "n_npenter" },
{ "id": "n_npenter", "kind": "move", "actor": "NP1", "to": "PT_CENTER", "next": "n_npsay" }, { "id": "n_npenter", "kind": "move", "actor": "NP1", "to": "PT_CENTER", "next": "n_npsay" },
{ "id": "n_npsay", "kind": "dialogue", "speaker": "NP1", "camera": "NP1", "text": "阁下,留步。在下身负旧伤,想借贵宝地歇一晚,天明即走。", "next": "n_draw" }, { "id": "n_npsay", "kind": "dialogue", "speaker": "NP1", "text": "阁下,留步。在下身负旧伤,想借贵宝地歇一晚,天明即走。", "next": "n_draw" },
{ "id": "n_draw", "kind": "anim", "actor": "NP1", "ani": "draw_sword", "next": "n_p1ask" }, { "id": "n_draw", "kind": "anim", "actor": "NP1", "ani": "draw_sword", "next": "n_p1ask" },
{ "id": "n_p1ask", "kind": "dialogue", "speaker": "P1", "text": "来者何人?这般夜半叩门,又突然按剑,意欲何为?", "next": "n_xiaoer" }, { "id": "n_p1ask", "kind": "dialogue", "speaker": "P1", "text": "来者何人?这般夜半叩门,又突然按剑,意欲何为?", "next": "n_xiaoer" },
{ "id": "n_xiaoer", "kind": "move", "actor": "NP2", "to": "PT_SIDE", "next": "n_xsay" }, { "id": "n_xiaoer", "kind": "move", "actor": "NP2", "to": "PT_SIDE", "next": "n_xsay" },
{ "id": "n_xsay", "kind": "dialogue", "speaker": "NP2", "text": "二位客官息怒!有话好好说,小店实在经不起折腾啊……", "next": "n_choice" }, { "id": "n_xsay", "kind": "dialogue", "speaker": "NP2", "text": "二位客官息怒!有话好好说,小店实在经不起折腾啊……", "next": "n_choice" },
{ "id": "n_choice", "kind": "choice", "options": [ { "id": "n_choice", "kind": "choice", "options": [
{ "text": "收剑,请他进来歇脚", "goto": "end_peace" }, { "text": "收剑,请他进来歇脚", "goto": "sc_peace1" },
{ "text": "拔剑相向,喝令他离开", "goto": "end_fight" } { "text": "拔剑相向,喝令他离开", "goto": "sc_fight1" }
] } ] },
{ "id": "sc_peace1", "kind": "dialogue", "speaker": "P1", "text": "(罢了,他确实带着伤,眼神也不像歹人。)", "next": "sc_peace2" },
{ "id": "sc_peace2", "kind": "anim", "actor": "P1", "ani": "sheath_sword", "next": "sc_peace3" },
{ "id": "sc_peace3", "kind": "dialogue", "speaker": "NP1", "text": "多谢。萍水相逢,承蒙阁下不弃,这份情,在下记下了。", "next": "sc_peace4" },
{ "id": "sc_peace4", "kind": "move", "actor": "NP2", "to": "PT_CENTER", "next": "sc_peace5" },
{ "id": "sc_peace5", "kind": "dialogue", "speaker": "NP2", "text": "好嘞!二位里边请,小的这就去沏一壶热茶,再添两副碗筷!", "next": "sc_peace6" },
{ "id": "sc_peace6", "kind": "move", "actor": "NP1", "to": "PT_SIDE", "next": "sc_peace_rand" },
{ "id": "sc_peace_rand", "kind": "random", "branches": [
{ "weight": 60, "goto": "sc_peace_calm" },
{ "weight": 40, "goto": "sc_peace_night" }
] },
{ "id": "sc_peace_calm", "kind": "narration", "speaker": "P1", "text": "这一夜风停雨歇,客栈里难得安宁。剑客和衣而眠,天没亮便悄然上路。", "next": "sc_peace_calm2" },
{ "id": "sc_peace_calm2", "kind": "dialogue", "speaker": "NP1", "text": "后会有期。他日江湖再见,必当厚报。", "next": "end_peace" },
{ "id": "sc_peace_night", "kind": "narration", "speaker": "P1", "text": "三更天,窗外忽有黑影一闪而过。剑客猛然睁眼,按住了腰间的剑。", "next": "sc_peace_night2" },
{ "id": "sc_peace_night2", "kind": "move", "actor": "NP1", "to": "PT_DOOR", "next": "sc_peace_night3" },
{ "id": "sc_peace_night3", "kind": "dialogue", "speaker": "NP1", "text": "阁下,麻烦没完——他们找上门了。今夜怕是要承你一把剑。", "next": "end_peace_twist" },
{ "id": "sc_fight1", "kind": "dialogue", "speaker": "P1", "text": "想进这道门,先问过我手里的剑!", "next": "sc_fight2" },
{ "id": "sc_fight2", "kind": "anim", "actor": "P1", "ani": "draw_sword", "next": "sc_fight3" },
{ "id": "sc_fight3", "kind": "dialogue", "speaker": "NP1", "text": "既然阁下不肯通融……那就别怪在下。得罪了!", "next": "sc_fight4" },
{ "id": "sc_fight4", "kind": "move", "actor": "NP1", "to": "PT_DOOR", "next": "sc_fight_battle" },
{ "id": "sc_fight_battle", "kind": "fight", "fight_type": 1, "camp2": [ "NP1" ], "win": "sc_fight_win", "lose": "sc_fight_lose" },
{ "id": "sc_fight_win", "kind": "dialogue", "speaker": "P1", "text": "承让。你伤成这样还要硬闯,又是何苦。", "next": "sc_fight_win2" },
{ "id": "sc_fight_win2", "kind": "move", "actor": "NP1", "to": "PT_SIDE", "next": "sc_fight_win3" },
{ "id": "sc_fight_win3", "kind": "dialogue", "speaker": "NP1", "text": "好剑法……是在下唐突了。告辞。", "next": "end_fight_win" },
{ "id": "sc_fight_lose", "kind": "dialogue", "speaker": "NP1", "text": "阁下手下留情。这一晚的账,日后江湖上再算。", "next": "sc_fight_lose2" },
{ "id": "sc_fight_lose2", "kind": "move", "actor": "NP1", "to": "PT_CENTER", "next": "end_fight_lose" }
], ],
"endings": [ "endings": [
{ "id": "end_peace", "summary": "化干戈为玉帛,与剑客结为知己", "grants": [], "result": "success" }, { "id": "end_peace", "summary": "结义同盟,平安一夜", "grants": [], "result": "success" },
{ "id": "end_fight", "summary": "不欢而散,剑客消失在茫茫雨夜中", "grants": [], "result": "end" } { "id": "end_peace_twist", "summary": "夜半生变,新的麻烦找上门", "grants": [], "result": "end" },
{ "id": "end_fight_win", "summary": "技高一筹,剑客败退消失在雨夜", "grants": [], "result": "success" },
{ "id": "end_fight_lose", "summary": "技不如人,被剑客闯入客栈", "grants": [], "result": "fail" }
] ]
} }

View File

@ -238,18 +238,34 @@ header .who { margin-left:auto; font-size:12px; color:#9a8f7e; }
.perform-listhead { padding:10px 12px; font-size:12px; color:#9a8f7e; letter-spacing:1px; .perform-listhead { padding:10px 12px; font-size:12px; color:#9a8f7e; letter-spacing:1px;
border-bottom:1px solid #3a322a; background:#1f1a15; } border-bottom:1px solid #3a322a; background:#1f1a15; }
#perform-list { overflow:auto; flex:1; } #perform-list { overflow:auto; flex:1; }
#perform-main { flex:1; min-width:0; overflow:auto; padding:16px 18px; } #perform-main { flex:1; min-width:0; display:flex; flex-direction:column; padding:14px 16px; min-height:0; }
#perform-main .empty-center { position:static; inset:auto; min-height:240px; } #perform-main .empty-center { position:static; inset:auto; min-height:240px; }
/* ---- 演出预览timeline + 白模舞台---- */ /* ---- 演出预览:上=舞台面板 / 下=时间轴面板(分开两块---- */
.tl-stagepanel { flex:none; }
.tl-mapinfo { font-size:12px; color:#9a8f7e; margin-bottom:8px; } .tl-mapinfo { font-size:12px; color:#9a8f7e; margin-bottom:8px; }
.tl-stagewrap { background:#15130d; border:1px solid #3a322a; border-radius:6px; overflow:hidden; .tl-stagewrap { position:relative; background:#15130d; border:1px solid #3a322a; border-radius:6px;
line-height:0; max-width:820px; } overflow:hidden; line-height:0; max-width:640px; }
.tl-stage { width:100%; height:auto; display:block; } .tl-stage { width:100%; height:auto; display:block; }
.tl-controls { display:flex; align-items:center; gap:10px; margin:10px 0 6px; max-width:820px; } .tl-controls { display:flex; align-items:center; gap:10px; margin:10px 0; flex-wrap:wrap; }
.tl-controls .tip { font-size:11.5px; color:#7a7264; } .tl-controls .tip { font-size:11.5px; color:#7a7264; }
.tl-time { font-size:13px; color:#e6c878; font-variant-numeric:tabular-nums; min-width:90px; } .tl-time { font-size:13px; color:#e6c878; font-variant-numeric:tabular-nums; min-width:90px; }
.tl-tracks { position:relative; overflow-x:auto; overflow-y:auto; max-height:32vh; .tl-startbtn { max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.tl-fitbtn.on { background:#5a4a26; border-color:#8a7038; color:#f3dca0; }
.tl-tracks .tl-clip.startable { cursor:pointer; }
.tl-tracks .tl-clip.sel { box-shadow:0 0 0 2px #ff5a4a, 0 1px 3px rgba(0,0,0,.5); z-index:3; }
/* 选项浮层:镜头下方(舞台底部居中),模拟玩家看到的位置 */
.tl-choices { position:absolute; left:0; right:0; bottom:12px; display:flex; flex-direction:column;
align-items:center; gap:6px; padding:0 12px; line-height:normal; z-index:5; }
.tl-choices.hidden { display:none; }
.tl-choices-q { font-size:12px; color:#e6c878; background:rgba(20,18,12,.72); padding:2px 10px; border-radius:10px; }
.tl-choice-btn { min-width:240px; max-width:92%; background:rgba(36,31,24,.96); color:#f0e6c8;
border:1px solid #8a7038; border-radius:6px; padding:8px 14px; font-size:13px;
cursor:pointer; box-shadow:0 2px 8px rgba(0,0,0,.55); }
.tl-choice-btn:hover { background:#5a4a26; border-color:#e6c878; }
/* 时间轴面板:独立、占满剩余高度、自己横向滚动 */
.tl-timelinepanel { flex:1; min-height:120px; margin-top:8px; display:flex; }
.tl-tracks { position:relative; flex:1; overflow-x:auto; overflow-y:auto;
background:#19150f; border:1px solid #3a322a; border-radius:6px; padding-top:20px; } background:#19150f; border:1px solid #3a322a; border-radius:6px; padding-top:20px; }
.tl-ruler { position:relative; height:16px; border-bottom:1px solid #2a2419; } .tl-ruler { position:relative; height:16px; border-bottom:1px solid #2a2419; }
.tl-tick { position:absolute; top:0; height:16px; border-left:1px solid #2a2419; } .tl-tick { position:absolute; top:0; height:16px; border-left:1px solid #2a2419; }
@ -260,7 +276,8 @@ header .who { margin-left:auto; font-size:12px; color:#9a8f7e; }
border-right:1px solid #2a2419; height:100%; box-sizing:border-box; line-height:26px; } border-right:1px solid #2a2419; height:100%; box-sizing:border-box; line-height:26px; }
.tl-clip { position:absolute; top:3px; height:22px; line-height:22px; padding:0 5px; .tl-clip { position:absolute; top:3px; height:22px; line-height:22px; padding:0 5px;
font-size:11px; color:#1a1710; border-radius:3px; overflow:hidden; white-space:nowrap; font-size:11px; color:#1a1710; border-radius:3px; overflow:hidden; white-space:nowrap;
text-overflow:ellipsis; cursor:pointer; box-sizing:border-box; box-shadow:0 1px 2px rgba(0,0,0,.4); } text-overflow:ellipsis; cursor:pointer; box-sizing:border-box; box-shadow:0 1px 2px rgba(0,0,0,.4);
user-select:none; -webkit-user-select:none; }
.tl-tracks .tl-clip.active { outline:2px solid #fff; outline-offset:-1px; z-index:2; } .tl-tracks .tl-clip.active { outline:2px solid #fff; outline-offset:-1px; z-index:2; }
.tl-clip.k-dialogue { background:#7ec8e3; } .tl-clip.k-dialogue { background:#7ec8e3; }
.tl-clip.k-move { background:#e6c878; } .tl-clip.k-move { background:#e6c878; }

View File

@ -1,161 +1,205 @@
// 演出预览/配置:把事件的演出节点铺成时间线(每节点按时长占一段),并在 2D 俯视白模舞台上随 // 演出预览/配置:2D 俯视白模舞台 + 时间轴 playhead 播放。
// playhead 播放——走位插值对话打字机镜头可视区域框。战斗/选择/随机只在「剧情」轨标点,不模拟 // 走位插值 / 对话打字机 / 镜头可视框 / 动画标记;战斗·随机仅标点
// P1消费现有 IR线性顺序铺轴无并行偏移沿首出口走一条主路径预览 // 交互式分支:播到「选择/战斗」节点暂停,在舞台底部(镜头下方)弹出选项浮层,点击后接对应分支续演
// 渲染挂载到任意 host 容器(演出配置页内嵌),不再用弹窗 // 布局:上=演出舞台面板(含选项浮层),下=独立时间轴面板(横向滚、playhead 居中)
// 暴露 window.Timeline = { show(host, ir, dict, pointsets), stop() }。 // 暴露 window.Timeline = { show(host, ir, dict, pointsets), stop() }。
(function () { (function () {
// ---- 时长模型(前端估算;与未来编译器口径对齐时再统一)---- // ---- 时长模型 ----
const CHAR_TIME = 0.07; // 打字机:秒/字 const CHAR_TIME = 0.07, TAIL_PAUSE = 0.9, MIN_DLG = 1.2;
const TAIL_PAUSE = 0.9; // 对话读完停顿 const MOVE_SPEED = 3.0, ANIM_DUR = 1.0;
const MIN_DLG = 1.2; // 对话最短时长 const PXMAX = 80, ROW_H = 30; // PXMAX=每秒最大像素;实际用动态 PX可适应宽度
const MOVE_SPEED = 3.0; // 默认走位速度(点位单位/秒) const CAM_W = 14, CAM_H = 9;
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 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 esc(s) { return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function anchorXZ(anchors, name) { function anchorXZ(anchors, name) { const a = (anchors || []).find(x => x.name === name); return a ? { x: a.pos[0], z: a.pos[2] } : null; }
const a = (anchors || []).find(x => x.name === name);
return a ? { x: a.pos[0], z: a.pos[2] } : null;
}
// ---- 线性化从入度0起步沿首出口走一条主路径分支/结局标点不展开 ----
function firstNode(IR) { function firstNode(IR) {
const indeg = {}; (IR.nodes || []).forEach(n => indeg[n.id] = 0); const indeg = {}; (IR.nodes || []).forEach(n => indeg[n.id] = 0);
(IR.nodes || []).forEach(n => { (IR.nodes || []).forEach(n => {
const outs = [n.next].concat((n.options || []).map(o => o.goto), (n.branches || []).map(b => b.goto), [n.next].concat((n.options || []).map(o => o.goto), (n.branches || []).map(b => b.goto), n.kind === "fight" ? [n.win, n.lose] : [])
n.kind === "fight" ? [n.win, n.lose] : []); .forEach(t => { if (t in indeg) indeg[t]++; });
outs.forEach(t => { if (t in indeg) indeg[t]++; });
}); });
const roots = (IR.nodes || []).filter(n => indeg[n.id] === 0); const roots = (IR.nodes || []).filter(n => indeg[n.id] === 0);
return (roots[0] || (IR.nodes || [])[0] || {}).id; return (roots[0] || (IR.nodes || [])[0] || {}).id;
} }
function linearize(IR) { // ===================== 会话准备(静态:位置/范围/映射)=====================
const nodes = {}; (IR.nodes || []).forEach(n => nodes[n.id] = n); function collectAllRefs(IR) {
const endings = {}; (IR.endings || []).forEach(e => endings[e.id] = e); const refs = [], add = n => { if (n && !refs.includes(n)) refs.push(n); };
const seqMap = {}; (IR.sequences || []).forEach(s => seqMap[s.id] = s); add("P1"); (IR.roles || []).forEach(r => add(r.slot));
const out = []; 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); });
let id = firstNode(IR), guard = 0; const seen = new Set(); scan(IR.nodes); (IR.sequences || []).forEach(s => scan(s.nodes));
while (id && guard++ < 500) { return refs;
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 prepare(IR, anchors) {
function buildModel(IR, anchors) {
const seq = linearize(IR);
const roleName = {}; (IR.roles || []).forEach(r => roleName[r.slot] = r.name); const roleName = {}; (IR.roles || []).forEach(r => roleName[r.slot] = r.name);
const nm = s => s === "P1" ? "玩家" : (roleName[s] || s); const nm = s => s === "P1" ? "玩家" : (roleName[s] || s);
// 位置来源:真实锚点优先;点位集没挂/缺坐标时,把引用到的点位在圆周上**合成布局** const refs = collectAllRefs(IR);
// 保证走位预览不空(线上未挂点位集卷的常见情形 + 尚未取点的事件)。
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 realPos = {}; (anchors || []).forEach(a => realPos[a.name] = { x: a.pos[0], z: a.pos[2] });
const hasReal = Object.keys(realPos).length > 0; const hasReal = Object.keys(realPos).length > 0;
const posMap = {}; const posMap = {}; const missing = refs.filter(r => !realPos[r]); const R = 6;
const missing = refs.filter(r => !realPos[r]);
const R = 6;
missing.forEach((name, i) => { missing.forEach((name, i) => {
const ang = (i / Math.max(1, missing.length)) * Math.PI * 2 - Math.PI / 2; 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) }; posMap[name] = { x: +(Math.cos(ang) * R).toFixed(2), z: +(Math.sin(ang) * R).toFixed(2) };
}); });
Object.assign(posMap, realPos); // 真实坐标覆盖合成 Object.assign(posMap, realPos);
const synthetic = !hasReal; 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 || []) const displayAnchors = hasReal ? (anchors || [])
: refs.filter(r => posMap[r]).map(r => ({ name: r, pos: [posMap[r].x, 0, posMap[r].z] })); : refs.filter(r => posMap[r]).map(r => ({ name: r, pos: [posMap[r].x, 0, posMap[r].z] }));
// 世界坐标范围(含锚点 + 所有走位终点),用于俯视舞台适配
const xs = [], zs = []; const xs = [], zs = [];
displayAnchors.forEach(a => { xs.push(a.pos[0]); zs.push(a.pos[2]); }); displayAnchors.forEach(a => { xs.push(a.pos[0]); zs.push(a.pos[2]); });
clips.forEach(c => { if (c.from) { xs.push(c.from.x); zs.push(c.from.z); } if (c.to) { xs.push(c.to.x); zs.push(c.to.z); } }); 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) } 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 }; : { 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 }; 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; }
function actorPosAt(model, actor, tau) {
let pos = model.initPos[actor] || { x: 0, z: 0 }; // 找一条从开头到 targetId 的路径BFS用于重放途中走位算"进入位置"。
const moves = model.clips.filter(c => c.actor === actor && c.kind === "move").sort((a, b) => a.start - b.start); 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) { for (const m of moves) {
if (tau >= m.start + m.dur) pos = m.to; 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 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 }; }
@ -163,82 +207,121 @@
} }
return pos; return pos;
} }
function activeDialogue(model, tau) { function activeDialogue(M, tau) { return M.clips.find(c => c.kind === "dialogue" && tau >= c.start && tau < c.start + c.dur) || null; }
return model.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) {
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; let fp = null;
model.clips.filter(c => c.kind === "camera").forEach(c => { if (tau >= c.start) fp = c.focus; }); M.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; } if (fp) { const p = anchorXZ(M.anchors, fp); if (p) return p; }
const dlg = activeDialogue(model, tau); const dlg = activeDialogue(M, tau);
if (dlg && dlg.actor) return actorPosAt(model, dlg.actor, tau); if (dlg && dlg.actor) return actorPosAt(M, dlg.actor, tau);
if (actorsIn(model).includes("P1")) return actorPosAt(model, "P1", tau); if (actorsIn(M).includes("P1")) return actorPosAt(M, "P1", tau);
const b = model.bounds; return { x: (b.minX + b.maxX) / 2, z: (b.minZ + b.maxZ) / 2 }; const b = M.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 = {}; let S, model, playT = 0, playing = false, rafId = 0, lastTs = 0, stageCv, stageCtx, els = {}, pending = null, selNode = null, PX = 80, fitMode = false;
const TEMPLATE = const TEMPLATE =
'<div class="tl-mapinfo"></div>' + '<div class="tl-stagepanel">' +
'<div class="tl-stagewrap"><canvas class="tl-stage" width="780" height="380"></canvas></div>' + ' <div class="tl-mapinfo"></div>' +
'<div class="tl-controls">' + ' <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-play primary">▶ 播放</button>' +
' <button class="tl-restart mini">⏮ 重头</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="tl-time">0.0 / 0.0s</span>' +
' <span class="tip">点时间轴任意处跳转 · 战斗/选择/随机仅标点不模拟 · 预览沿首出口主路径</span>' + ' <span class="tip">单击节点选中→「从选中处开始」,或双击节点直接开始(位置按途中走位重放) · 遇选择/战斗/随机弹选项</span>' +
' </div>' +
'</div>' + '</div>' +
'<div class="tl-tracks"></div>'; '<div class="tl-timelinepanel"><div class="tl-tracks"></div></div>';
function show(host, IR, DICT, POINTSETS) { function show(host, IR, DICT, POINTSETS) {
stopPlay(); stopPlay();
const psName = (IR.stage || {}).point_set || IR.id; const psName = (IR.stage || {}).point_set || IR.id;
const ps = (POINTSETS || {})[psName] || {}; const ps = (POINTSETS || {})[psName] || {};
const anchors = ps.anchors || []; S = prepare(IR, ps.anchors || []); model = S;
model = buildModel(IR, anchors);
host.innerHTML = TEMPLATE; host.innerHTML = TEMPLATE;
els = { els = {
host, host,
mapinfo: host.querySelector(".tl-mapinfo"), mapinfo: host.querySelector(".tl-mapinfo"),
stage: host.querySelector(".tl-stage"), stage: host.querySelector(".tl-stage"),
choices: host.querySelector(".tl-choices"),
tracks: host.querySelector(".tl-tracks"), tracks: host.querySelector(".tl-tracks"),
play: host.querySelector(".tl-play"), play: host.querySelector(".tl-play"),
restart: host.querySelector(".tl-restart"), restart: host.querySelector(".tl-restart"),
startbtn: host.querySelector(".tl-startbtn"),
fitbtn: host.querySelector(".tl-fitbtn"),
time: host.querySelector(".tl-time"), time: host.querySelector(".tl-time"),
playhead: null, playhead: null,
}; };
els.mapinfo.textContent = "点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + "" : "") + els.mapinfo.textContent = "点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + "" : "") +
(model.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)" : " · 真实坐标"); (S.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)" : " · 真实坐标");
stageCv = els.stage; stageCtx = stageCv.getContext("2d"); stageCv = els.stage; stageCtx = stageCv.getContext("2d");
buildTracks();
els.play.onclick = () => playing ? stopPlay() : play(); els.play.onclick = () => playing ? stopPlay() : play();
els.restart.onclick = () => { stopPlay(); seek(0); }; els.restart.onclick = () => restart();
seek(0); 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 clear() { stopPlay(); if (els.host) els.host.innerHTML = ""; els = {}; model = null; } 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() { function buildTracks() {
const host = els.tracks; host.innerHTML = ""; host.style.position = "relative"; const host = els.tracks; host.innerHTML = ""; host.style.position = "relative";
const W = model.total * PXPSEC; // 适应宽度:把整条时间轴压进可视宽度(留标签位);否则用固定 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"; const ruler = document.createElement("div"); ruler.className = "tl-ruler"; ruler.style.width = W + "px";
for (let s = 0; s <= Math.ceil(model.total); s++) { 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"; const tick = document.createElement("div"); tick.className = "tl-tick"; tick.style.left = (s * PX) + "px";
tick.innerHTML = '<span>' + s + 's</span>'; ruler.appendChild(tick); tick.innerHTML = '<span>' + s + 's</span>'; ruler.appendChild(tick);
} }
host.appendChild(ruler); host.appendChild(ruler);
model.rows.forEach(r => { 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 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; const label = r.startsWith("演员:") ? model.nm(r.slice(3)) : r;
@ -246,69 +329,53 @@
model.clips.filter(c => c.row === r).forEach(c => { model.clips.filter(c => c.row === r).forEach(c => {
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "tl-clip k-" + c.kind; el.className = "tl-clip k-" + c.kind;
el.style.left = (c.start * PXPSEC) + "px"; el.style.left = (c.start * PX) + "px";
el.style.width = Math.max(8, c.dur * PXPSEC - 2) + "px"; el.style.width = Math.max(8, c.dur * PX - 2) + "px";
el.title = c.label + "" + c.dur.toFixed(1) + "s"; el.title = c.label + "" + c.dur.toFixed(1) + "s";
el.textContent = c.label; el.textContent = c.label;
el.dataset.start = c.start; el.dataset.end = c.start + c.dur; el.dataset.start = c.start; el.dataset.end = c.start + c.dur;
el.onclick = e => { e.stopPropagation(); seek(c.start + 0.001); }; 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); lane.appendChild(el);
}); });
host.appendChild(lane); host.appendChild(lane);
}); });
const ph = document.createElement("div"); ph.className = "tl-playhead"; host.appendChild(ph); els.playhead = ph;
const ph = document.createElement("div"); ph.className = "tl-playhead"; host.appendChild(ph);
els.playhead = ph;
host.onclick = e => { host.onclick = e => {
const rect = host.getBoundingClientRect(); const rect = host.getBoundingClientRect();
const x = e.clientX - rect.left + host.scrollLeft; seek(Math.max(0, Math.min(model.total, (e.clientX - rect.left + host.scrollLeft) / PX)));
seek(Math.max(0, Math.min(model.total, x / PXPSEC)));
}; };
} }
function worldToStage(p) { function worldToStage(p) {
const b = model.bounds, pad = 40; const b = model.bounds, pad = 40, w = stageCv.width, h = stageCv.height;
const w = stageCv.width, h = stageCv.height;
const dx = (b.maxX - b.minX) || 1, dz = (b.maxZ - b.minZ) || 1; 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 sc = Math.min((w - pad * 2) / dx, (h - pad * 2) / dz);
const cx = (b.minX + b.maxX) / 2, cz = (b.minZ + b.maxZ) / 2; 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 }; 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"]; const ACTOR_COLORS = ["#e6c878", "#7ec8e3", "#e38f7e", "#9ee37e", "#c89ee3", "#e3c87e", "#7ee3c8"];
function colorOf(model, actor) { function colorOf(M, actor) {
if (actor === "P1") return "#f0d890"; if (actor === "P1") return "#f0d890";
const list = actorsIn(model).filter(a => a !== "P1"); const list = actorsIn(M).filter(a => a !== "P1"); const i = list.indexOf(actor);
const i = list.indexOf(actor);
return ACTOR_COLORS[(i + 1) % ACTOR_COLORS.length]; return ACTOR_COLORS[(i + 1) % ACTOR_COLORS.length];
} }
function drawStage(tau) { function drawStage(tau) {
const ctx = stageCtx, w = stageCv.width, h = stageCv.height; const ctx = stageCtx, w = stageCv.width, h = stageCv.height;
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h); ctx.fillStyle = "#15130d"; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = "#15130d"; ctx.fillRect(0, 0, w, h);
// 锚点(淡)
(model.anchors || []).forEach(a => { (model.anchors || []).forEach(a => {
const p = worldToStage({ x: a.pos[0], z: a.pos[2] }); const p = worldToStage({ x: a.pos[0], z: a.pos[2] });
ctx.strokeStyle = "rgba(180,170,140,.35)"; ctx.lineWidth = 1; 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.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);
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;
// 镜头可视区域框(始终可见,跟随焦点)
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.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.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.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.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();
// 焦点十字
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); const dlg = activeDialogue(model, tau);
actorsIn(model).forEach(actor => { actorsIn(model).forEach(actor => {
const wp = actorPosAt(model, actor, tau), p = worldToStage(wp), col = colorOf(model, actor); const wp = actorPosAt(model, actor, tau), p = worldToStage(wp), col = colorOf(model, actor);
@ -316,10 +383,8 @@
const anim = activeAnim(model, actor, tau); 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.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.strokeStyle = "#000"; ctx.lineWidth = 1; ctx.stroke();
ctx.fillStyle = "#1a1710"; ctx.font = "bold 9px sans-serif"; ctx.textAlign = "center"; ctx.fillStyle = "#1a1710"; ctx.font = "bold 9px sans-serif"; ctx.textAlign = "center"; ctx.fillText(actor, p.x, p.y + 3);
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);
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 (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 (anim) { ctx.fillStyle = col; ctx.font = "9px sans-serif"; ctx.fillText("♪" + (anim.label || ""), p.x, p.y - 12); }
if (dlg && dlg.actor === actor) { if (dlg && dlg.actor === actor) {
@ -328,63 +393,109 @@
} }
}); });
} }
function drawBubble(ctx, cx, cy, text) { function drawBubble(ctx, cx, cy, text) {
ctx.font = "12px sans-serif"; ctx.textAlign = "left"; ctx.font = "12px sans-serif"; ctx.textAlign = "left";
const maxW = 260, lines = wrap(ctx, text, maxW), lh = 16; 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 tw = Math.min(maxW, Math.max(...lines.map(l => ctx.measureText(l).width))) + 14, th = lines.length * lh + 10;
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);
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; ctx.fillStyle = "rgba(20,18,12,.92)"; ctx.strokeStyle = "#e6c878"; ctx.lineWidth = 1;
roundRect(ctx, x, y, tw, th, 5); ctx.fill(); ctx.stroke(); roundRect(ctx, x, y, tw, th, 5); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#f0e6c8"; ctx.fillStyle = "#f0e6c8"; lines.forEach((l, i) => ctx.fillText(l, x + 7, y + 16 + i * lh));
lines.forEach((l, i) => ctx.fillText(l, x + 7, y + 16 + i * lh));
} }
function wrap(ctx, text, maxW) { function wrap(ctx, text, maxW) {
const out = []; let cur = ""; const out = []; let cur = "";
for (const ch of String(text)) { 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 (ctx.measureText(cur + ch).width > maxW || ch === "\n") { out.push(cur); cur = ch === "\n" ? "" : ch; } if (cur) out.push(cur); return out.length ? out : [""];
else cur += ch;
}
if (cur) out.push(cur);
return out.length ? out : [""];
} }
function roundRect(ctx, x, y, w, h, r) { function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath(); ctx.moveTo(x + r, y); 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 + 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(); 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() { function renderFrame() {
if (!model) return; if (!model) return;
drawStage(playT); drawStage(playT);
if (els.playhead) els.playhead.style.left = (playT * PXPSEC) + "px"; if (els.playhead) els.playhead.style.left = (playT * PX) + "px";
if (els.time) els.time.textContent = playT.toFixed(1) + " / " + model.total.toFixed(1) + "s"; if (els.time) els.time.textContent = playT.toFixed(1) + " / " + model.total.toFixed(1) + "s";
els.tracks.querySelectorAll(".tl-clip").forEach(el => { els.tracks.querySelectorAll(".tl-clip").forEach(el => el.classList.toggle("active", playT >= +el.dataset.start && playT < +el.dataset.end));
el.classList.toggle("active", playT >= +el.dataset.start && playT < +el.dataset.end); // 仅播放时把 playhead 顶在中间;暂停时不动滚动条,让用户自由横向滚动找节点。
}); if (playing) {
const host = els.tracks, phx = playT * PXPSEC; const host = els.tracks, phx = playT * PX, maxScroll = host.scrollWidth - host.clientWidth;
if (phx < host.scrollLeft + 60 || phx > host.scrollLeft + host.clientWidth - 60) host.scrollLeft = Math.max(0, Math.min(Math.max(0, maxScroll), phx - host.clientWidth / 2));
host.scrollLeft = 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) { function tick(ts) {
if (!playing) return; if (!playing) return;
if (!lastTs) lastTs = ts; if (!lastTs) lastTs = ts;
playT += (ts - lastTs) / 1000; lastTs = ts; playT += (ts - lastTs) / 1000; lastTs = ts;
if (playT >= model.total) { playT = model.total; playing = false; updateBtn(); } if (playT >= model.total) { playT = model.total; playing = false; updateBtn(); renderFrame(); onReachEnd(); return; }
renderFrame(); renderFrame();
if (playing) rafId = requestAnimationFrame(tick); 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 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 stopPlay() { playing = false; if (rafId) cancelAnimationFrame(rafId); rafId = 0; updateBtn(); }
function seek(t) { playT = t; lastTs = 0; renderFrame(); } 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 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 = { window.Timeline = {
show, stop: stopPlay, clear, show, stop: stopPlay, clear,
// 仅供离线测试node调用的纯逻辑无 DOM 依赖 // 离线测试
_buildModel: buildModel, _linearize: linearize, _dlgDur: dlgDur, _buildModel: buildModelAuto, _dlgDur: dlgDur,
_prepare: prepare, _extend: extendSegment, _runUntilPause: runUntilPause, _firstNode: firstNode, _orderRows: orderRows,
_pathTo: pathTo, _replayPositions: replayPositions,
}; };
})(); })();