剧情 Timeline P2 前端 + 共享内核(与 SGame 源真同步): - ir_core/IR_SCHEMA/样张:scene v0.3 + scene 校验 + 导出 gate(D3),与 SGame 仓逐字一致 - timeline.js:appendScene 按 authored start 铺多轨 clip(自然重叠预览),move from 同 actor 跨轨续连(D4); drawStage 改逐 actor 查对话→多人气泡同时计时;导出 _clipDur 纯函数;show() 加 startId 参;常量加 CAMERA_DUR - scene_edit.js(新):演出段编辑模态——拖 clip 改 start(吸附 0.1s)、拖右缘改 dur、增删 clip/轨道、 选中属性条精确编辑、客户端轻量 lint(镜像 validate.py)、▶ 预览此段(复用播放核) - graph.js:scene 节点(KIND_CN/summary/nodeInner 列轨道)+双击进编辑模态 - form.js:右栏 renderScene 精确数值编辑(轨道/clip 的 start/dur/kind/目标)+打开编辑器按钮 - app.py export:捕获 CompileError 并入 report(scene 被拦时不再 500) - test_scene.js:离线 10 断言全过(重叠确凿/晚 1.5s 起步/from 续连);gitignore 忽略本地 _localdemo.db 待浏览器目测拖拽编辑落 IR + 白模重叠演出。
647 lines
36 KiB
JavaScript
647 lines
36 KiB
JavaScript
// 演出预览/配置: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, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||
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 生成可视 clip(start 偏移 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;
|
||
|
||
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, startId) {
|
||
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"),
|
||
resizer: host.querySelector(".tl-resizer"),
|
||
timelinepanel: host.querySelector(".tl-timelinepanel"),
|
||
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(); };
|
||
// 拖拽分隔条调时间轴高度(向上拖=时间轴变高/舞台变小,反之)
|
||
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; }
|
||
|
||
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);
|
||
(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,
|
||
};
|
||
})();
|