+
+
+
+
+
-
-
+
@@ -74,6 +77,17 @@
+
+
+
-
-
-
-
演出预览(白模)
-
-
-
-
-
-
- 0.0 / 0.0s
- 点时间轴任意处跳转 · 战斗/选择/随机仅标点不模拟 · 预览沿首出口主路径
-
-
-
-
-
diff --git a/web/static/style.css b/web/static/style.css
index 2791309..6cb5a7b 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -223,15 +223,33 @@ button.mini { padding:2px 8px; font-size:12px; }
.pt-choices button.locked { opacity:.55; }
.pt-q { color:#b89a5a; font-size:12px; margin:8px 0 4px; }
+/* ---- 模式切换 ---- */
+.mode-switch { display:flex; gap:0; border:1px solid #5a4a32; border-radius:6px; overflow:hidden; }
+.mode-btn { background:#241f18; color:#9a8f7e; border:none; border-radius:0; padding:6px 14px; }
+.mode-btn:hover:not(:disabled) { background:#2e2820; }
+.mode-btn.active { background:#5a4a26; color:#f3dca0; }
+header .who { margin-left:auto; font-size:12px; color:#9a8f7e; }
+
+/* ---- 演出配置页 ---- */
+#perform-wrap { display:flex; flex:1; min-height:0; }
+#wrap.hidden, #perform-wrap.hidden { display:none; }
+#perform-list-pane { width:250px; background:#19150f; border-right:1px solid #3a322a;
+ display:flex; flex-direction:column; flex:none; }
+.perform-listhead { padding:10px 12px; font-size:12px; color:#9a8f7e; letter-spacing:1px;
+ border-bottom:1px solid #3a322a; background:#1f1a15; }
+#perform-list { overflow:auto; flex:1; }
+#perform-main { flex:1; min-width:0; overflow:auto; padding:16px 18px; }
+#perform-main .empty-center { position:static; inset:auto; min-height:240px; }
+
/* ---- 演出预览(timeline + 白模舞台)---- */
-.modal.huge { width:840px; max-width:94vw; }
-.tl-mapinfo { font-size:12px; color:#9a8f7e; margin-left:12px; font-weight:normal; }
-#tl-stagewrap { background:#15130d; border:1px solid #3a322a; border-radius:6px; overflow:hidden; line-height:0; }
-#tl-stage { width:100%; height:auto; display:block; }
-#tl-controls { display:flex; align-items:center; gap:10px; margin:10px 0 6px; }
-#tl-controls .tip { font-size:11.5px; color:#7a7264; }
+.tl-mapinfo { font-size:12px; color:#9a8f7e; margin-bottom:8px; }
+.tl-stagewrap { background:#15130d; border:1px solid #3a322a; border-radius:6px; overflow:hidden;
+ line-height:0; max-width:820px; }
+.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 .tip { font-size:11.5px; color:#7a7264; }
.tl-time { font-size:13px; color:#e6c878; font-variant-numeric:tabular-nums; min-width:90px; }
-#tl-tracks { position:relative; overflow-x:auto; overflow-y:hidden; max-height:34vh;
+.tl-tracks { position:relative; overflow-x:auto; overflow-y:auto; max-height:32vh;
background:#19150f; border:1px solid #3a322a; border-radius:6px; padding-top:20px; }
.tl-ruler { position:relative; height:16px; border-bottom:1px solid #2a2419; }
.tl-tick { position:absolute; top:0; height:16px; border-left:1px solid #2a2419; }
@@ -243,7 +261,7 @@ button.mini { padding:2px 8px; font-size:12px; }
.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;
text-overflow:ellipsis; cursor:pointer; box-sizing:border-box; box-shadow:0 1px 2px rgba(0,0,0,.4); }
-.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-move { background:#e6c878; }
.tl-clip.k-anim { background:#9ee37e; }
diff --git a/web/static/timeline.js b/web/static/timeline.js
index 9d6c2e3..5978734 100644
--- a/web/static/timeline.js
+++ b/web/static/timeline.js
@@ -1,7 +1,8 @@
-// 演出预览:把事件的演出节点铺成时间线(每节点按时长占一段),并在 2D 俯视白模舞台上随
-// playhead 播放——走位插值、对话打字机、镜头俯视框示意。战斗/选择/随机只在「剧情」轨标点,不模拟。
+// 演出预览/配置:把事件的演出节点铺成时间线(每节点按时长占一段),并在 2D 俯视白模舞台上随
+// playhead 播放——走位插值、对话打字机、镜头可视区域框。战斗/选择/随机只在「剧情」轨标点,不模拟。
// P1:消费现有 IR(线性顺序铺轴,无并行偏移);沿首出口走一条主路径预览。
-// 暴露 window.Timeline = { open(ir, dict, pointsets) }。
+// 渲染挂载到任意 host 容器(演出配置页内嵌),不再用弹窗。
+// 暴露 window.Timeline = { show(host, ir, dict, pointsets), stop() }。
(function () {
// ---- 时长模型(前端估算;与未来编译器口径对齐时再统一)----
@@ -12,11 +13,10 @@
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, "&").replace(//g, ">"); }
-
- // ---- 取点坐标(xz 平面;y 是高度,俯视图忽略)----
function anchorXZ(anchors, name) {
const a = (anchors || []).find(x => x.name === name);
return a ? { x: a.pos[0], z: a.pos[2] } : null;
@@ -141,43 +141,69 @@
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;
}
- function focusAt(model, tau) {
- let f = null;
- model.clips.filter(c => c.kind === "camera").forEach(c => { if (tau >= c.start) f = c.focus; });
- return f;
+ // 镜头世界焦点:显式 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 };
}
-
- // ---- 演员名单(出现在演出里的 actor slot)----
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;
+ // ===================== 状态 & 挂载 =====================
+ let model, playT = 0, playing = false, rafId = 0, lastTs = 0, stageCv, stageCtx, els = {};
- function open(IR, DICT, POINTSETS) {
+ const TEMPLATE =
+ '
' +
+ '
' +
+ '
' +
+ ' ' +
+ ' ' +
+ ' 0.0 / 0.0s' +
+ ' 点时间轴任意处跳转 · 战斗/选择/随机仅标点不模拟 · 预览沿首出口主路径' +
+ '
' +
+ '
';
+
+ 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);
- document.getElementById("tl-modal").classList.remove("hidden");
- document.getElementById("tl-mapinfo").textContent =
- "点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + ")" : "") +
+ 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 = document.getElementById("tl-stage");
- stageCtx = stageCv.getContext("2d");
+ stageCv = els.stage; stageCtx = stageCv.getContext("2d");
+
buildTracks();
- seek(0); stop();
+ els.play.onclick = () => playing ? stopPlay() : play();
+ els.restart.onclick = () => { stopPlay(); seek(0); };
+ seek(0);
}
- function buildTracks() {
- const host = document.getElementById("tl-tracks");
- host.innerHTML = "";
- const W = model.total * PXPSEC;
+ 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";
@@ -185,10 +211,8 @@
}
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";
- lane.dataset.row = r;
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 => {
@@ -205,30 +229,22 @@
host.appendChild(lane);
});
- // playhead
- const ph = document.createElement("div"); ph.className = "tl-playhead"; ph.id = "tl-playhead";
- host.appendChild(ph);
-
- // 点击轴体跳转
+ 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)));
};
- host.style.position = "relative";
}
function worldToStage(p) {
- const b = model.bounds, pad = 36;
+ 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, // z 越大越靠上
- 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"];
@@ -253,18 +269,16 @@
ctx.fillText(a.name, p.x, p.y - 8);
});
- // 镜头俯视框(示意)
- const fc = focusAt(model, tau);
- if (fc) {
- const fp0 = anchorXZ(model.anchors, fc);
- if (fp0) {
- const fp = worldToStage(fp0), bw = 150, bh = 100;
- ctx.strokeStyle = "rgba(230,200,120,.8)"; ctx.lineWidth = 1.5; ctx.setLineDash([6, 4]);
- ctx.strokeRect(fp.x - bw / 2, fp.y - bh / 2, bw, bh); ctx.setLineDash([]);
- ctx.fillStyle = "rgba(230,200,120,.85)"; ctx.font = "10px sans-serif"; ctx.textAlign = "left";
- ctx.fillText("镜头", fp.x - bw / 2 + 3, fp.y - bh / 2 + 12);
- }
- }
+ // 镜头可视区域框(始终可见,跟随焦点)
+ 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);
@@ -276,12 +290,10 @@
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);
@@ -318,18 +330,14 @@
// ---- 帧/控制 ----
function renderFrame() {
+ if (!model) return;
drawStage(playT);
- const ph = document.getElementById("tl-playhead");
- if (ph) ph.style.left = (playT * PXPSEC) + "px";
- document.getElementById("tl-time").textContent = playT.toFixed(1) + " / " + model.total.toFixed(1) + "s";
- // 高亮当前 clip
- document.querySelectorAll("#tl-tracks .tl-clip").forEach(el => {
- const on = playT >= +el.dataset.start && playT < +el.dataset.end;
- el.classList.toggle("active", on);
+ 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);
});
- // 自动横向滚动跟随 playhead
- const host = document.getElementById("tl-tracks");
- const phx = playT * PXPSEC;
+ const host = els.tracks, phx = playT * PXPSEC;
if (phx < host.scrollLeft + 60 || phx > host.scrollLeft + host.clientWidth - 60)
host.scrollLeft = phx - host.clientWidth / 2;
}
@@ -341,21 +349,13 @@
renderFrame();
if (playing) rafId = requestAnimationFrame(tick);
}
- function play() { if (playT >= model.total) playT = 0; playing = true; lastTs = 0; updateBtn(); rafId = requestAnimationFrame(tick); }
- function stop() { playing = false; cancelAnimationFrame(rafId); updateBtn(); }
- function seek(t) { playT = t; if (!playing) renderFrame(); else { lastTs = 0; } renderFrame(); }
- function updateBtn() { const b = document.getElementById("tl-play"); if (b) b.textContent = playing ? "⏸ 暂停" : "▶ 播放"; }
-
- // ---- 控件接线(一次性)----
- function wire() {
- document.getElementById("tl-play").onclick = () => playing ? stop() : play();
- document.getElementById("tl-restart").onclick = () => { stop(); seek(0); };
- document.querySelector("#tl-modal .modal-close").onclick = () => { stop(); document.getElementById("tl-modal").classList.add("hidden"); };
- }
+ 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 = {
- open(ir, dict, pointsets) { if (!Timeline._wired) { wire(); Timeline._wired = true; } open(ir, dict, pointsets); },
- _wired: false,
+ show, stop: stopPlay, clear,
// 仅供离线测试(node)调用的纯逻辑,无 DOM 依赖:
_buildModel: buildModel, _linearize: linearize, _dlgDur: dlgDur,
};