// 演出预览/配置:把事件的演出节点铺成时间线(每节点按时长占一段),并在 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, "&").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; } // ---- 线性化:从入度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 refs = []; const addRef = n => { if (n && !refs.includes(n)) refs.push(n); }; addRef("P1"); (IR.roles || []).forEach(r => addRef(r.slot)); seq.forEach(item => { const n = item.node; if (!n) return; addRef(n.speaker); addRef(n.actor); if (n.kind === "move") addRef(n.to); if (n.camera) addRef(n.camera); }); 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 initPos = {}; refs.forEach(r => { if (posMap[r]) initPos[r] = posMap[r]; }); 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 = 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); 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 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]); }); 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: displayAnchors, initPos, nm, roleName, bounds, synthetic }; } // ---- 播放期查询 ---- 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 = '
' + '
' + '
' + ' ' + ' ' + ' 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); 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 + ")" : "") + (model.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)" : " · 真实坐标"); 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 = '' + 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"; 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, }; })();