Files
story-edit-web/web/static/timeline.js
邓雨鹏 2a3cf2c66b feat(perform): 演出配置页(模式切换) + 镜头可视框始终可见
P1 反馈两处修改:
- 顶部加「海选审核 / 演出配置」模式切换;演出配置=独立页,左列仅已确认事件,
  选中即内嵌白模预览(弃用原弹窗),为 P2 在此配置演出细节打底
- 修镜头框只在 dialogue 显式带 camera 时才画的 bug:改为镜头可视区域框始终可见,
  显式镜头点优先→跟随说话人→跟玩家→场景中心,框尺寸按世界单位随舞台缩放+焦点十字
- timeline.js 从弹窗固定ID重构为挂载到任意容器 Timeline.show(host,...);
  离线模型测试复跑两样张全过,重构未破坏逻辑
2026-06-13 11:15:20 +08:00

363 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 演出预览/配置:把事件的演出节点铺成时间线(每节点按时长占一段),并在 2D 俯视白模舞台上随
// playhead 播放——走位插值、对话打字机、镜头可视区域框。战斗/选择/随机只在「剧情」轨标点,不模拟。
// P1消费现有 IR线性顺序铺轴无并行偏移沿首出口走一条主路径预览。
// 渲染挂载到任意 host 容器(演出配置页内嵌),不再用弹窗。
// 暴露 window.Timeline = { show(host, ir, dict, pointsets), stop() }。
(function () {
// ---- 时长模型(前端估算;与未来编译器口径对齐时再统一)----
const CHAR_TIME = 0.07; // 打字机:秒/字
const TAIL_PAUSE = 0.9; // 对话读完停顿
const MIN_DLG = 1.2; // 对话最短时长
const MOVE_SPEED = 3.0; // 默认走位速度(点位单位/秒)
const ANIM_DUR = 1.0; // 动画缺省时长Web 不知 clip 真长P3 引擎回填)
const PXPSEC = 80; // 时间轴每秒像素
const ROW_H = 30; // 轨道行高
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;
}
// ---- 线性化从入度0起步沿首出口走一条主路径分支/结局标点不展开 ----
function firstNode(IR) {
const indeg = {}; (IR.nodes || []).forEach(n => indeg[n.id] = 0);
(IR.nodes || []).forEach(n => {
const outs = [n.next].concat((n.options || []).map(o => o.goto), (n.branches || []).map(b => b.goto),
n.kind === "fight" ? [n.win, n.lose] : []);
outs.forEach(t => { if (t in indeg) indeg[t]++; });
});
const roots = (IR.nodes || []).filter(n => indeg[n.id] === 0);
return (roots[0] || (IR.nodes || [])[0] || {}).id;
}
function linearize(IR) {
const nodes = {}; (IR.nodes || []).forEach(n => nodes[n.id] = n);
const endings = {}; (IR.endings || []).forEach(e => endings[e.id] = e);
const seqMap = {}; (IR.sequences || []).forEach(s => seqMap[s.id] = s);
const out = [];
let id = firstNode(IR), guard = 0; const seen = new Set();
while (id && guard++ < 500) {
if (seen.has(id)) { out.push({ stop: "循环中断" }); break; }
seen.add(id);
const n = nodes[id];
if (!n) { if (endings[id]) out.push({ ending: endings[id] }); break; }
const k = n.kind;
if (k === "choice" || k === "choice_once") { out.push({ branch: "choice", node: n }); const o = n.options || []; id = o.length ? o[0].goto : null; }
else if (k === "random") { out.push({ branch: "random", node: n }); const b = n.branches || []; id = b.length ? b[0].goto : null; }
else if (k === "fight") { out.push({ branch: "fight", node: n }); id = n.win || n.lose || null; }
else if (k === "out_ref") {
const sq = seqMap[n.ref];
if (sq && (sq.nodes || []).length) {
const sm = {}; sq.nodes.forEach(x => sm[x.id] = x);
let sid = sq.nodes[0].id, sg = 0;
while (sid && sg++ < 100) { const sn = sm[sid]; if (!sn) break; out.push({ node: sn, kind: sn.kind }); sid = sn.next; }
}
id = n.next;
}
else { out.push({ node: n, kind: k }); id = n.next; }
}
// 注:结局已在循环顶部 `!n` 分支里 push 过endings 不在 nodes 里),此处不再重复推。
return out;
}
// ---- 构建演出模型clips + rows + total ----
function buildModel(IR, anchors) {
const seq = linearize(IR);
const roleName = {}; (IR.roles || []).forEach(r => roleName[r.slot] = r.name);
const nm = s => s === "P1" ? "玩家" : (roleName[s] || s);
const initPos = {}; (anchors || []).forEach(a => initPos[a.name] = { x: a.pos[0], z: a.pos[2] });
const curPos = {};
const posOf = a => curPos[a] || initPos[a] || { x: 0, z: 0 };
let t = 0; const clips = []; const rowSet = [];
const useRow = r => { if (!rowSet.includes(r)) rowSet.push(r); };
seq.forEach(item => {
if (item.stop) { clips.push({ row: "剧情", kind: "stop", start: t, dur: 0.4, label: item.stop }); useRow("剧情"); t += 0.4; return; }
if (item.branch) {
const n = item.node;
const lbl = item.branch === "choice" ? "选择:" + (n.options || []).map(o => o.text).join(" / ")
: item.branch === "random" ? "随机分支(预览取首路)"
: "战斗 vs " + (n.camp2 || []).map(nm).join("、") + "(预览取胜路)";
clips.push({ row: "剧情", kind: "branch", start: t, dur: 0.6, label: lbl }); useRow("剧情"); t += 0.6; return;
}
if (item.ending) { clips.push({ row: "剧情", kind: "ending", start: t, dur: 0.8, label: "★ " + (item.ending.summary || item.ending.id) }); useRow("剧情"); t += 0.8; return; }
const n = item.node, k = item.kind;
if (k === "dialogue" || k === "narration") {
const sp = n.speaker || "P1", dur = dlgDur(n.text);
clips.push({ row: "演员:" + sp, kind: "dialogue", start: t, dur, label: esc(n.text || ""), actor: sp, text: n.text || "" });
useRow("演员:" + sp);
if (n.camera) { clips.push({ row: "镜头", kind: "camera", start: t, dur, label: "对焦 " + n.camera, focus: n.camera }); useRow("镜头"); }
t += dur;
} else if (k === "move") {
const sp = n.actor || "P1", from = posOf(sp), to = anchorXZ(anchors, n.to) || from;
const speed = n.speed || MOVE_SPEED, dist = Math.hypot(to.x - from.x, to.z - from.z), dur = Math.max(0.3, dist / speed);
clips.push({ row: "演员:" + sp, kind: "move", start: t, dur, label: "→ " + (n.to || ""), actor: sp, from, to });
useRow("演员:" + sp); curPos[sp] = { x: to.x, z: to.z }; t += dur;
} else if (k === "anim") {
const sp = n.actor || "P1";
clips.push({ row: "演员:" + sp, kind: "anim", start: t, dur: ANIM_DUR, label: "动画 " + (n.ani || ""), actor: sp });
useRow("演员:" + sp); t += ANIM_DUR;
} else if (k === "reward") {
clips.push({ row: "剧情", kind: "reward", start: t, dur: 0.4, label: "奖励结算" }); useRow("剧情"); t += 0.4;
}
});
// 行排序:演员(按 roles 顺序P1 优先)→ 镜头 → 剧情
const order = [];
if (rowSet.includes("演员:P1")) order.push("演员:P1");
(IR.roles || []).forEach(r => { const k = "演员:" + r.slot; if (k !== "演员:P1" && rowSet.includes(k)) order.push(k); });
rowSet.forEach(r => { if (r.startsWith("演员:") && !order.includes(r)) order.push(r); });
["镜头", "剧情"].forEach(r => { if (rowSet.includes(r)) order.push(r); });
// 世界坐标范围(含锚点 + 所有走位终点),用于俯视舞台适配
const xs = [], zs = [];
(anchors || []).forEach(a => { xs.push(a.pos[0]); zs.push(a.pos[2]); });
clips.forEach(c => { if (c.from) { xs.push(c.from.x); zs.push(c.from.z); } if (c.to) { xs.push(c.to.x); zs.push(c.to.z); } });
const bounds = xs.length ? { minX: Math.min(...xs), maxX: Math.max(...xs), minZ: Math.min(...zs), maxZ: Math.max(...zs) }
: { minX: 0, maxX: 1, minZ: 0, maxZ: 1 };
return { clips, rows: order, total: Math.max(t, 0.1), anchors: anchors || [], initPos, nm, roleName, bounds };
}
// ---- 播放期查询 ----
function actorPosAt(model, actor, tau) {
let pos = model.initPos[actor] || { x: 0, z: 0 };
const moves = model.clips.filter(c => c.actor === actor && c.kind === "move").sort((a, b) => a.start - b.start);
for (const m of moves) {
if (tau >= m.start + m.dur) pos = m.to;
else if (tau >= m.start) { const f = (tau - m.start) / m.dur; return { x: m.from.x + (m.to.x - m.from.x) * f, z: m.from.z + (m.to.z - m.from.z) * f }; }
else break;
}
return pos;
}
function activeDialogue(model, tau) {
return model.clips.find(c => c.kind === "dialogue" && tau >= c.start && tau < c.start + c.dur) || null;
}
function activeAnim(model, actor, tau) {
return model.clips.find(c => c.kind === "anim" && c.actor === actor && tau >= c.start && tau < c.start + c.dur) || null;
}
// 镜头世界焦点:显式 camera clip 优先 → 否则跟随当前说话人 → 否则玩家 → 否则场景中心。
// 这样镜头可视框「始终可见」并跟着戏走,而不是只在配了 camera 的对话上才出现。
function focusWorldAt(model, tau) {
let fp = null;
model.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; }
const dlg = activeDialogue(model, tau);
if (dlg && dlg.actor) return actorPosAt(model, dlg.actor, tau);
if (actorsIn(model).includes("P1")) return actorPosAt(model, "P1", tau);
const b = model.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 = {};
const TEMPLATE =
'<div class="tl-mapinfo"></div>' +
'<div class="tl-stagewrap"><canvas class="tl-stage" width="780" height="380"></canvas></div>' +
'<div class="tl-controls">' +
' <button class="tl-play primary">▶ 播放</button>' +
' <button class="tl-restart mini">⏮ 重头</button>' +
' <span class="tl-time">0.0 / 0.0s</span>' +
' <span class="tip">点时间轴任意处跳转 · 战斗/选择/随机仅标点不模拟 · 预览沿首出口主路径</span>' +
'</div>' +
'<div class="tl-tracks"></div>';
function show(host, IR, DICT, POINTSETS) {
stopPlay();
const psName = (IR.stage || {}).point_set || IR.id;
const ps = (POINTSETS || {})[psName] || {};
const anchors = ps.anchors || [];
model = buildModel(IR, anchors);
host.innerHTML = TEMPLATE;
els = {
host,
mapinfo: host.querySelector(".tl-mapinfo"),
stage: host.querySelector(".tl-stage"),
tracks: host.querySelector(".tl-tracks"),
play: host.querySelector(".tl-play"),
restart: host.querySelector(".tl-restart"),
time: host.querySelector(".tl-time"),
playhead: null,
};
els.mapinfo.textContent = "点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + "" : "") +
(anchors.length ? "" : " ⚠ 无坐标,走位/俯视图将退化为示意");
stageCv = els.stage; stageCtx = stageCv.getContext("2d");
buildTracks();
els.play.onclick = () => playing ? stopPlay() : play();
els.restart.onclick = () => { stopPlay(); seek(0); };
seek(0);
}
function clear() { stopPlay(); if (els.host) els.host.innerHTML = ""; els = {}; model = null; }
function buildTracks() {
const host = els.tracks; host.innerHTML = ""; host.style.position = "relative";
const W = model.total * PXPSEC;
const ruler = document.createElement("div"); ruler.className = "tl-ruler"; ruler.style.width = W + "px";
for (let s = 0; s <= Math.ceil(model.total); s++) {
const tick = document.createElement("div"); tick.className = "tl-tick"; tick.style.left = (s * PXPSEC) + "px";
tick.innerHTML = '<span>' + s + 's</span>'; ruler.appendChild(tick);
}
host.appendChild(ruler);
model.rows.forEach(r => {
const lane = document.createElement("div"); lane.className = "tl-lane"; lane.style.width = W + "px"; lane.style.height = ROW_H + "px";
const label = r.startsWith("演员:") ? model.nm(r.slice(3)) : r;
lane.appendChild(Object.assign(document.createElement("div"), { className: "tl-lane-label", textContent: label }));
model.clips.filter(c => c.row === r).forEach(c => {
const el = document.createElement("div");
el.className = "tl-clip k-" + c.kind;
el.style.left = (c.start * PXPSEC) + "px";
el.style.width = Math.max(8, c.dur * PXPSEC - 2) + "px";
el.title = c.label + "" + c.dur.toFixed(1) + "s";
el.textContent = c.label;
el.dataset.start = c.start; el.dataset.end = c.start + c.dur;
el.onclick = e => { e.stopPropagation(); seek(c.start + 0.001); };
lane.appendChild(el);
});
host.appendChild(lane);
});
const ph = document.createElement("div"); ph.className = "tl-playhead"; host.appendChild(ph);
els.playhead = ph;
host.onclick = e => {
const rect = host.getBoundingClientRect();
const x = e.clientX - rect.left + host.scrollLeft;
seek(Math.max(0, Math.min(model.total, x / PXPSEC)));
};
}
function worldToStage(p) {
const b = model.bounds, pad = 40;
const w = stageCv.width, h = stageCv.height;
const dx = (b.maxX - b.minX) || 1, dz = (b.maxZ - b.minZ) || 1;
const sc = Math.min((w - pad * 2) / dx, (h - pad * 2) / dz);
const cx = (b.minX + b.maxX) / 2, cz = (b.minZ + b.maxZ) / 2;
return { x: w / 2 + (p.x - cx) * sc, y: h / 2 - (p.z - cz) * sc, sc };
}
const ACTOR_COLORS = ["#e6c878", "#7ec8e3", "#e38f7e", "#9ee37e", "#c89ee3", "#e3c87e", "#7ee3c8"];
function colorOf(model, actor) {
if (actor === "P1") return "#f0d890";
const list = actorsIn(model).filter(a => a !== "P1");
const i = list.indexOf(actor);
return ACTOR_COLORS[(i + 1) % ACTOR_COLORS.length];
}
function drawStage(tau) {
const ctx = stageCtx, w = stageCv.width, h = stageCv.height;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#15130d"; ctx.fillRect(0, 0, w, h);
// 锚点(淡)
(model.anchors || []).forEach(a => {
const p = worldToStage({ x: a.pos[0], z: a.pos[2] });
ctx.strokeStyle = "rgba(180,170,140,.35)"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = "rgba(180,170,140,.45)"; ctx.font = "10px sans-serif"; ctx.textAlign = "center";
ctx.fillText(a.name, p.x, p.y - 8);
});
// 镜头可视区域框(始终可见,跟随焦点)
const 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.strokeRect(fp.x - bw / 2, fp.y - bh / 2, bw, bh); ctx.setLineDash([]);
ctx.fillStyle = "rgba(230,200,120,.9)"; ctx.font = "10px sans-serif"; ctx.textAlign = "left";
ctx.fillText("镜头", fp.x - bw / 2 + 4, fp.y - bh / 2 + 13);
// 焦点十字
ctx.strokeStyle = "rgba(230,200,120,.5)"; ctx.beginPath();
ctx.moveTo(fp.x - 6, fp.y); ctx.lineTo(fp.x + 6, fp.y); ctx.moveTo(fp.x, fp.y - 6); ctx.lineTo(fp.x, fp.y + 6); ctx.stroke();
// 演员
const dlg = activeDialogue(model, tau);
actorsIn(model).forEach(actor => {
const wp = actorPosAt(model, actor, tau), p = worldToStage(wp), col = colorOf(model, actor);
const moving = model.clips.some(c => c.actor === actor && c.kind === "move" && tau >= c.start && tau < c.start + c.dur);
const anim = activeAnim(model, actor, tau);
ctx.fillStyle = col; ctx.beginPath(); ctx.arc(p.x, p.y, moving ? 9 : 8, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = "#000"; ctx.lineWidth = 1; ctx.stroke();
ctx.fillStyle = "#1a1710"; ctx.font = "bold 9px sans-serif"; ctx.textAlign = "center";
ctx.fillText(actor, p.x, p.y + 3);
ctx.fillStyle = "#d8cda0"; ctx.font = "11px sans-serif";
ctx.fillText(model.nm(actor), p.x, p.y + 22);
if (moving) { ctx.fillStyle = col; ctx.font = "9px sans-serif"; ctx.fillText("…走位", p.x, p.y - 12); }
if (anim) { ctx.fillStyle = col; ctx.font = "9px sans-serif"; ctx.fillText("♪" + (anim.label || ""), p.x, p.y - 12); }
if (dlg && dlg.actor === actor) {
const typed = (dlg.text || "").slice(0, Math.floor((tau - dlg.start) / CHAR_TIME));
drawBubble(ctx, p.x, p.y - 26, model.nm(actor) + "" + typed);
}
});
}
function drawBubble(ctx, cx, cy, text) {
ctx.font = "12px sans-serif"; ctx.textAlign = "left";
const maxW = 260, lines = wrap(ctx, text, maxW), lh = 16;
const tw = Math.min(maxW, Math.max(...lines.map(l => ctx.measureText(l).width))) + 14;
const th = lines.length * lh + 10;
let x = cx - tw / 2, y = cy - th;
x = Math.max(4, Math.min(stageCv.width - tw - 4, x)); y = Math.max(4, y);
ctx.fillStyle = "rgba(20,18,12,.92)"; ctx.strokeStyle = "#e6c878"; ctx.lineWidth = 1;
roundRect(ctx, x, y, tw, th, 5); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#f0e6c8";
lines.forEach((l, i) => ctx.fillText(l, x + 7, y + 16 + i * lh));
}
function wrap(ctx, text, maxW) {
const out = []; let cur = "";
for (const ch of String(text)) {
if (ctx.measureText(cur + ch).width > maxW || ch === "\n") { out.push(cur); cur = ch === "\n" ? "" : ch; }
else cur += ch;
}
if (cur) out.push(cur);
return out.length ? out : [""];
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath(); ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath();
}
// ---- 帧/控制 ----
function renderFrame() {
if (!model) return;
drawStage(playT);
if (els.playhead) els.playhead.style.left = (playT * PXPSEC) + "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);
});
const host = els.tracks, phx = playT * PXPSEC;
if (phx < host.scrollLeft + 60 || phx > host.scrollLeft + host.clientWidth - 60)
host.scrollLeft = phx - host.clientWidth / 2;
}
function tick(ts) {
if (!playing) return;
if (!lastTs) lastTs = ts;
playT += (ts - lastTs) / 1000; lastTs = ts;
if (playT >= model.total) { playT = model.total; playing = false; updateBtn(); }
renderFrame();
if (playing) rafId = requestAnimationFrame(tick);
}
function play() { if (!model) return; if (playT >= model.total) playT = 0; playing = true; lastTs = 0; updateBtn(); rafId = requestAnimationFrame(tick); }
function stopPlay() { playing = false; if (rafId) cancelAnimationFrame(rafId); rafId = 0; updateBtn(); }
function seek(t) { playT = t; lastTs = 0; renderFrame(); }
function updateBtn() { if (els.play) els.play.textContent = playing ? "⏸ 暂停" : "▶ 播放"; }
window.Timeline = {
show, stop: stopPlay, clear,
// 仅供离线测试node调用的纯逻辑无 DOM 依赖:
_buildModel: buildModel, _linearize: linearize, _dlgDur: dlgDur,
};
})();