From 2a3cf2c66bd81aa9c9a86bd67cd1a6c9ce6f5049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=93=E9=9B=A8=E9=B9=8F?= <846149189@qq.com> Date: Sat, 13 Jun 2026 11:15:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(perform):=20=E6=BC=94=E5=87=BA=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5(=E6=A8=A1=E5=BC=8F=E5=88=87=E6=8D=A2)=20+=20?= =?UTF-8?q?=E9=95=9C=E5=A4=B4=E5=8F=AF=E8=A7=86=E6=A1=86=E5=A7=8B=E7=BB=88?= =?UTF-8?q?=E5=8F=AF=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 反馈两处修改: - 顶部加「海选审核 / 演出配置」模式切换;演出配置=独立页,左列仅已确认事件, 选中即内嵌白模预览(弃用原弹窗),为 P2 在此配置演出细节打底 - 修镜头框只在 dialogue 显式带 camera 时才画的 bug:改为镜头可视区域框始终可见, 显式镜头点优先→跟随说话人→跟玩家→场景中心,框尺寸按世界单位随舞台缩放+焦点十字 - timeline.js 从弹窗固定ID重构为挂载到任意容器 Timeline.show(host,...); 离线模型测试复跑两样张全过,重构未破坏逻辑 --- web/static/app.js | 41 ++++++++++- web/static/index.html | 37 +++++----- web/static/style.css | 34 ++++++--- web/static/timeline.js | 152 ++++++++++++++++++++--------------------- 4 files changed, 157 insertions(+), 107 deletions(-) diff --git a/web/static/app.js b/web/static/app.js index d95cd83..a5eba07 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -9,6 +9,7 @@ status: null, selectedNode: null, dirty: false, + mode: "review", // review=海选审核 / perform=演出配置 by: localStorage.getItem("story_by") || "匿名", }; window.App = App; @@ -80,7 +81,7 @@ App.current = group; App.ir = JSON.parse(JSON.stringify(d.ir)); App.status = d.status; App.selectedNode = null; App.dirty = false; $("graph-empty").style.display = "none"; - ["btn-save", "btn-validate", "btn-playtest", "btn-timeline", "btn-confirm", "btn-discard", "btn-addnode", "btn-autolayout", "btn-addsucc"].forEach(b => $(b).disabled = false); + ["btn-save", "btn-validate", "btn-playtest", "btn-confirm", "btn-discard", "btn-addnode", "btn-autolayout", "btn-addsucc"].forEach(b => $(b).disabled = false); renderAll(true); GraphUI.focusStart(App.ir); // 定位到开头节点 snapReset(); // 初始化撤销栈 @@ -194,8 +195,42 @@ // ---------- 试走 ---------- $("btn-playtest").onclick = () => Playtest.open(App.ir, App.dict); - // ---------- 演出预览(白模时间线)---------- - $("btn-timeline").onclick = () => Timeline.open(App.ir, App.dict, App.pointsets); + // ---------- 模式切换:海选审核 / 演出配置 ---------- + function setMode(m) { + App.mode = m; + $("mode-review").classList.toggle("active", m === "review"); + $("mode-perform").classList.toggle("active", m === "perform"); + $("wrap").classList.toggle("hidden", m !== "review"); + $("perform-wrap").classList.toggle("hidden", m !== "perform"); + $("review-toolbar").style.display = m === "review" ? "" : "none"; + Timeline.stop(); + if (m === "perform") performLoadList(); + } + $("mode-review").onclick = () => setMode("review"); + $("mode-perform").onclick = () => setMode("perform"); + + // ---------- 演出配置页:已确认事件列表 + 内嵌白模预览 ---------- + let performCurrent = null; + async function performLoadList() { + let list; + try { list = await (await api("/api/events?status=confirmed")).json(); } catch (e) { return; } + const host = $("perform-list"); host.innerHTML = ""; + if (!list.length) { host.innerHTML = '
还没有已确认的事件。去「海选审核」确认事件后再来配置演出。
'; return; } + list.forEach(e => { + const d = document.createElement("div"); + d.className = "ev" + (e.group === performCurrent ? " sel" : ""); + d.innerHTML = '
' + esc(e.title || e.group) + '
' + esc(e.group) + ' · ' + esc(e.updated_by || "") + '
'; + d.onclick = () => performSelect(e.group); + host.appendChild(d); + }); + } + async function performSelect(group) { + let d; + try { d = await (await api("/api/events/" + encodeURIComponent(group))).json(); } catch (e) { return; } + performCurrent = group; + performLoadList(); + Timeline.show($("perform-main"), d.ir, App.dict, App.pointsets); + } // ---------- 导入 ---------- let importFiles = []; // 当前已选文件 diff --git a/web/static/index.html b/web/static/index.html index c519fe4..fc23c30 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -22,19 +22,22 @@

剧情事件协作编辑器 M5

-
+
+ + +
+
- -
+
@@ -74,6 +77,17 @@
+ + + - - - 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, };