diff --git a/web/app.py b/web/app.py index 5c40fa9..c14b4ff 100644 --- a/web/app.py +++ b/web/app.py @@ -163,9 +163,21 @@ async def pointsets(): try: with open(os.path.join(_POINTSETS_DIR, fn), encoding="utf-8") as f: ps = json.load(f) + pts = ps.get("points", []) out[name] = { "mapId": ps.get("mapId", ""), - "points": [p.get("name") for p in ps.get("points", [])], + "points": [p.get("name") for p in pts], # 名字数组:兼容现有表单下拉 + # 含坐标的锚点:白模演出预览用(map-local pos[x,y,z] + rot) + "anchors": [ + { + "name": p.get("name"), + "pos": p.get("pos") or [0, 0, 0], + "rot": p.get("rot", 0), + "kind": p.get("kind", ""), + "npc": p.get("npc", ""), + } + for p in pts + ], } except Exception as e: out[name] = {"error": str(e)} diff --git a/web/static/app.js b/web/static/app.js index 1d1dd9b..d95cd83 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -80,7 +80,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-confirm", "btn-discard", "btn-addnode", "btn-autolayout", "btn-addsucc"].forEach(b => $(b).disabled = false); + ["btn-save", "btn-validate", "btn-playtest", "btn-timeline", "btn-confirm", "btn-discard", "btn-addnode", "btn-autolayout", "btn-addsucc"].forEach(b => $(b).disabled = false); renderAll(true); GraphUI.focusStart(App.ir); // 定位到开头节点 snapReset(); // 初始化撤销栈 @@ -194,6 +194,9 @@ // ---------- 试走 ---------- $("btn-playtest").onclick = () => Playtest.open(App.ir, App.dict); + // ---------- 演出预览(白模时间线)---------- + $("btn-timeline").onclick = () => Timeline.open(App.ir, App.dict, App.pointsets); + // ---------- 导入 ---------- let importFiles = []; // 当前已选文件 function renderImportFiles() { diff --git a/web/static/index.html b/web/static/index.html index 22bcc1c..c519fe4 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -26,6 +26,7 @@ + @@ -115,10 +116,28 @@ + + + + diff --git a/web/static/style.css b/web/static/style.css index 07659ca..2791309 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -222,3 +222,36 @@ button.mini { padding:2px 8px; font-size:12px; } .pt-choices button { display:block; width:100%; text-align:left; margin:5px 0; } .pt-choices button.locked { opacity:.55; } .pt-q { color:#b89a5a; font-size:12px; margin:8px 0 4px; } + +/* ---- 演出预览(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-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; + 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; } +.tl-tick span { font-size:9px; color:#6a6256; padding-left:3px; } +.tl-lane { position:relative; border-bottom:1px solid #211c15; } +.tl-lane-label { position:sticky; left:0; z-index:3; display:inline-block; min-width:54px; + padding:2px 6px; font-size:11px; color:#b89a5a; background:#211c15; + 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; + 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-clip.k-dialogue { background:#7ec8e3; } +.tl-clip.k-move { background:#e6c878; } +.tl-clip.k-anim { background:#9ee37e; } +.tl-clip.k-camera { background:#c89ee3; } +.tl-clip.k-branch { background:#9ec0f0; } +.tl-clip.k-fight { background:#e38f7e; } +.tl-clip.k-reward { background:#e0c060; } +.tl-clip.k-ending { background:#e0a850; } +.tl-clip.k-stop { background:#d87878; color:#fff; } +.tl-playhead { position:absolute; top:0; bottom:0; width:2px; background:#ff5a4a; + pointer-events:none; z-index:4; box-shadow:0 0 4px rgba(255,90,74,.7); } diff --git a/web/static/timeline.js b/web/static/timeline.js new file mode 100644 index 0000000..9d6c2e3 --- /dev/null +++ b/web/static/timeline.js @@ -0,0 +1,362 @@ +// 演出预览:把事件的演出节点铺成时间线(每节点按时长占一段),并在 2D 俯视白模舞台上随 +// playhead 播放——走位插值、对话打字机、镜头俯视框示意。战斗/选择/随机只在「剧情」轨标点,不模拟。 +// P1:消费现有 IR(线性顺序铺轴,无并行偏移);沿首出口走一条主路径预览。 +// 暴露 window.Timeline = { open(ir, dict, pointsets) }。 + +(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; // 轨道行高 + + 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; + } + + // ---- 线性化:从入度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; + } + 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; + } + + // ---- 演员名单(出现在演出里的 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; + + function open(IR, DICT, POINTSETS) { + 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 + ")" : "") + + (anchors.length ? "" : " ⚠ 无坐标,走位/俯视图将退化为示意"); + stageCv = document.getElementById("tl-stage"); + stageCtx = stageCv.getContext("2d"); + buildTracks(); + seek(0); stop(); + } + + function buildTracks() { + const host = document.getElementById("tl-tracks"); + host.innerHTML = ""; + 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 = '' + s + 's'; 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"; + 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 => { + 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); + }); + + // playhead + const ph = document.createElement("div"); ph.className = "tl-playhead"; ph.id = "tl-playhead"; + host.appendChild(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 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, + }; + } + + 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 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 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() { + 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); + }); + // 自动横向滚动跟随 playhead + const host = document.getElementById("tl-tracks"); + const 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 (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"); }; + } + + window.Timeline = { + open(ir, dict, pointsets) { if (!Timeline._wired) { wire(); Timeline._wired = true; } open(ir, dict, pointsets); }, + _wired: false, + // 仅供离线测试(node)调用的纯逻辑,无 DOM 依赖: + _buildModel: buildModel, _linearize: linearize, _dlgDur: dlgDur, + }; +})();