diff --git a/samples/timeline_demo.ir.json b/samples/timeline_demo.ir.json index c7d4763..d09084f 100644 --- a/samples/timeline_demo.ir.json +++ b/samples/timeline_demo.ir.json @@ -1,7 +1,7 @@ { "id": "QY_TLDEMO", "title": "演出预览功能演示", - "theme": "P1 白模演出测试(走位/对话打字机/动画/镜头/多角色)", + "theme": "P1 白模演出测试(走位/对话/动画/镜头/选择/随机/战斗)", "scale": "演示", "roles": [ { "slot": "NP1", "name": "神秘剑客", "archetype": "负伤外门高手", "camp": 0 }, @@ -13,18 +13,48 @@ { "id": "n_p1walk", "kind": "move", "actor": "P1", "to": "PT_DOOR", "next": "n_p1say" }, { "id": "n_p1say", "kind": "dialogue", "speaker": "P1", "text": "(这客栈看着冷清,却处处透着古怪……)", "next": "n_npenter" }, { "id": "n_npenter", "kind": "move", "actor": "NP1", "to": "PT_CENTER", "next": "n_npsay" }, - { "id": "n_npsay", "kind": "dialogue", "speaker": "NP1", "camera": "NP1", "text": "阁下,留步。在下身负旧伤,想借贵宝地歇一晚,天明即走。", "next": "n_draw" }, + { "id": "n_npsay", "kind": "dialogue", "speaker": "NP1", "text": "阁下,留步。在下身负旧伤,想借贵宝地歇一晚,天明即走。", "next": "n_draw" }, { "id": "n_draw", "kind": "anim", "actor": "NP1", "ani": "draw_sword", "next": "n_p1ask" }, { "id": "n_p1ask", "kind": "dialogue", "speaker": "P1", "text": "来者何人?这般夜半叩门,又突然按剑,意欲何为?", "next": "n_xiaoer" }, { "id": "n_xiaoer", "kind": "move", "actor": "NP2", "to": "PT_SIDE", "next": "n_xsay" }, { "id": "n_xsay", "kind": "dialogue", "speaker": "NP2", "text": "二位客官息怒!有话好好说,小店实在经不起折腾啊……", "next": "n_choice" }, + { "id": "n_choice", "kind": "choice", "options": [ - { "text": "收剑,请他进来歇脚", "goto": "end_peace" }, - { "text": "拔剑相向,喝令他离开", "goto": "end_fight" } - ] } + { "text": "收剑,请他进来歇脚", "goto": "sc_peace1" }, + { "text": "拔剑相向,喝令他离开", "goto": "sc_fight1" } + ] }, + + { "id": "sc_peace1", "kind": "dialogue", "speaker": "P1", "text": "(罢了,他确实带着伤,眼神也不像歹人。)", "next": "sc_peace2" }, + { "id": "sc_peace2", "kind": "anim", "actor": "P1", "ani": "sheath_sword", "next": "sc_peace3" }, + { "id": "sc_peace3", "kind": "dialogue", "speaker": "NP1", "text": "多谢。萍水相逢,承蒙阁下不弃,这份情,在下记下了。", "next": "sc_peace4" }, + { "id": "sc_peace4", "kind": "move", "actor": "NP2", "to": "PT_CENTER", "next": "sc_peace5" }, + { "id": "sc_peace5", "kind": "dialogue", "speaker": "NP2", "text": "好嘞!二位里边请,小的这就去沏一壶热茶,再添两副碗筷!", "next": "sc_peace6" }, + { "id": "sc_peace6", "kind": "move", "actor": "NP1", "to": "PT_SIDE", "next": "sc_peace_rand" }, + { "id": "sc_peace_rand", "kind": "random", "branches": [ + { "weight": 60, "goto": "sc_peace_calm" }, + { "weight": 40, "goto": "sc_peace_night" } + ] }, + { "id": "sc_peace_calm", "kind": "narration", "speaker": "P1", "text": "这一夜风停雨歇,客栈里难得安宁。剑客和衣而眠,天没亮便悄然上路。", "next": "sc_peace_calm2" }, + { "id": "sc_peace_calm2", "kind": "dialogue", "speaker": "NP1", "text": "后会有期。他日江湖再见,必当厚报。", "next": "end_peace" }, + { "id": "sc_peace_night", "kind": "narration", "speaker": "P1", "text": "三更天,窗外忽有黑影一闪而过。剑客猛然睁眼,按住了腰间的剑。", "next": "sc_peace_night2" }, + { "id": "sc_peace_night2", "kind": "move", "actor": "NP1", "to": "PT_DOOR", "next": "sc_peace_night3" }, + { "id": "sc_peace_night3", "kind": "dialogue", "speaker": "NP1", "text": "阁下,麻烦没完——他们找上门了。今夜怕是要承你一把剑。", "next": "end_peace_twist" }, + + { "id": "sc_fight1", "kind": "dialogue", "speaker": "P1", "text": "想进这道门,先问过我手里的剑!", "next": "sc_fight2" }, + { "id": "sc_fight2", "kind": "anim", "actor": "P1", "ani": "draw_sword", "next": "sc_fight3" }, + { "id": "sc_fight3", "kind": "dialogue", "speaker": "NP1", "text": "既然阁下不肯通融……那就别怪在下。得罪了!", "next": "sc_fight4" }, + { "id": "sc_fight4", "kind": "move", "actor": "NP1", "to": "PT_DOOR", "next": "sc_fight_battle" }, + { "id": "sc_fight_battle", "kind": "fight", "fight_type": 1, "camp2": [ "NP1" ], "win": "sc_fight_win", "lose": "sc_fight_lose" }, + { "id": "sc_fight_win", "kind": "dialogue", "speaker": "P1", "text": "承让。你伤成这样还要硬闯,又是何苦。", "next": "sc_fight_win2" }, + { "id": "sc_fight_win2", "kind": "move", "actor": "NP1", "to": "PT_SIDE", "next": "sc_fight_win3" }, + { "id": "sc_fight_win3", "kind": "dialogue", "speaker": "NP1", "text": "好剑法……是在下唐突了。告辞。", "next": "end_fight_win" }, + { "id": "sc_fight_lose", "kind": "dialogue", "speaker": "NP1", "text": "阁下手下留情。这一晚的账,日后江湖上再算。", "next": "sc_fight_lose2" }, + { "id": "sc_fight_lose2", "kind": "move", "actor": "NP1", "to": "PT_CENTER", "next": "end_fight_lose" } ], "endings": [ - { "id": "end_peace", "summary": "化干戈为玉帛,与剑客结为知己", "grants": [], "result": "success" }, - { "id": "end_fight", "summary": "不欢而散,剑客消失在茫茫雨夜中", "grants": [], "result": "end" } + { "id": "end_peace", "summary": "结义同盟,平安一夜", "grants": [], "result": "success" }, + { "id": "end_peace_twist", "summary": "夜半生变,新的麻烦找上门", "grants": [], "result": "end" }, + { "id": "end_fight_win", "summary": "技高一筹,剑客败退消失在雨夜", "grants": [], "result": "success" }, + { "id": "end_fight_lose", "summary": "技不如人,被剑客闯入客栈", "grants": [], "result": "fail" } ] } diff --git a/web/static/style.css b/web/static/style.css index 6cb5a7b..b7a3338 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -238,18 +238,34 @@ header .who { margin-left:auto; font-size:12px; color:#9a8f7e; } .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 { flex:1; min-width:0; display:flex; flex-direction:column; padding:14px 16px; min-height:0; } #perform-main .empty-center { position:static; inset:auto; min-height:240px; } -/* ---- 演出预览(timeline + 白模舞台)---- */ +/* ---- 演出预览:上=舞台面板 / 下=时间轴面板(分开两块)---- */ +.tl-stagepanel { flex:none; } .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-stagewrap { position:relative; background:#15130d; border:1px solid #3a322a; border-radius:6px; + overflow:hidden; line-height:0; max-width:640px; } .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 { display:flex; align-items:center; gap:10px; margin:10px 0; flex-wrap:wrap; } .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:auto; max-height:32vh; +.tl-startbtn { max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.tl-fitbtn.on { background:#5a4a26; border-color:#8a7038; color:#f3dca0; } +.tl-tracks .tl-clip.startable { cursor:pointer; } +.tl-tracks .tl-clip.sel { box-shadow:0 0 0 2px #ff5a4a, 0 1px 3px rgba(0,0,0,.5); z-index:3; } +/* 选项浮层:镜头下方(舞台底部居中),模拟玩家看到的位置 */ +.tl-choices { position:absolute; left:0; right:0; bottom:12px; display:flex; flex-direction:column; + align-items:center; gap:6px; padding:0 12px; line-height:normal; z-index:5; } +.tl-choices.hidden { display:none; } +.tl-choices-q { font-size:12px; color:#e6c878; background:rgba(20,18,12,.72); padding:2px 10px; border-radius:10px; } +.tl-choice-btn { min-width:240px; max-width:92%; background:rgba(36,31,24,.96); color:#f0e6c8; + border:1px solid #8a7038; border-radius:6px; padding:8px 14px; font-size:13px; + cursor:pointer; box-shadow:0 2px 8px rgba(0,0,0,.55); } +.tl-choice-btn:hover { background:#5a4a26; border-color:#e6c878; } +/* 时间轴面板:独立、占满剩余高度、自己横向滚动 */ +.tl-timelinepanel { flex:1; min-height:120px; margin-top:8px; display:flex; } +.tl-tracks { position:relative; flex:1; overflow-x:auto; overflow-y:auto; 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; } @@ -260,7 +276,8 @@ header .who { margin-left:auto; font-size:12px; color:#9a8f7e; } 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); } + text-overflow:ellipsis; cursor:pointer; box-sizing:border-box; box-shadow:0 1px 2px rgba(0,0,0,.4); + user-select:none; -webkit-user-select:none; } .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; } diff --git a/web/static/timeline.js b/web/static/timeline.js index 858f944..98769e8 100644 --- a/web/static/timeline.js +++ b/web/static/timeline.js @@ -1,161 +1,205 @@ -// 演出预览/配置:把事件的演出节点铺成时间线(每节点按时长占一段),并在 2D 俯视白模舞台上随 -// playhead 播放——走位插值、对话打字机、镜头可视区域框。战斗/选择/随机只在「剧情」轨标点,不模拟。 -// P1:消费现有 IR(线性顺序铺轴,无并行偏移);沿首出口走一条主路径预览。 -// 渲染挂载到任意 host 容器(演出配置页内嵌),不再用弹窗。 +// 演出预览/配置:2D 俯视白模舞台 + 时间轴 playhead 播放。 +// 走位插值 / 对话打字机 / 镜头可视框 / 动画标记;战斗·随机仅标点。 +// 交互式分支:播到「选择/战斗」节点暂停,在舞台底部(镜头下方)弹出选项浮层,点击后接对应分支续演。 +// 布局:上=演出舞台面板(含选项浮层),下=独立时间轴面板(横向滚、playhead 居中)。 // 暴露 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; // 镜头可视区域(世界单位,俯视示意) + // ---- 时长模型 ---- + const CHAR_TIME = 0.07, TAIL_PAUSE = 0.9, MIN_DLG = 1.2; + const MOVE_SPEED = 3.0, ANIM_DUR = 1.0; + const PXMAX = 80, ROW_H = 30; // PXMAX=每秒最大像素;实际用动态 PX(可适应宽度) + 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; - } + 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]++; }); + [n.next].concat((n.options || []).map(o => o.goto), (n.branches || []).map(b => b.goto), n.kind === "fight" ? [n.win, n.lose] : []) + .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; + // ===================== 会话准备(静态:位置/范围/映射)===================== + function collectAllRefs(IR) { + const refs = [], add = n => { if (n && !refs.includes(n)) refs.push(n); }; + add("P1"); (IR.roles || []).forEach(r => add(r.slot)); + const scan = arr => (arr || []).forEach(n => { add(n.speaker); add(n.actor); if (n.kind === "move") add(n.to); if (n.camera) add(n.camera); }); + scan(IR.nodes); (IR.sequences || []).forEach(s => scan(s.nodes)); + return refs; } - // ---- 构建演出模型:clips + rows + total ---- - function buildModel(IR, anchors) { - const seq = linearize(IR); + function prepare(IR, anchors) { 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 refs = collectAllRefs(IR); 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; + 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); // 真实坐标覆盖合成 + 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); } }); + Object.values(posMap).forEach(p => { xs.push(p.x); zs.push(p.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 }; + const initPos = {}; refs.forEach(r => { if (posMap[r]) initPos[r] = posMap[r]; }); + + const S = { + IR, nm, roleName, posMap, anchors: displayAnchors, bounds, synthetic, initPos, + nodes: {}, endings: {}, seqMap: {}, + clips: [], rows: [], total: 0.1, + _t: 0, curPos: {}, entryPos: {}, visited: new Set(), + }; + (IR.nodes || []).forEach(n => S.nodes[n.id] = n); + (IR.endings || []).forEach(e => S.endings[e.id] = e); + (IR.sequences || []).forEach(s => S.seqMap[s.id] = s); + + // 在场角色:全事件里当过 speaker/actor 的 slot(含 P1)。舞台始终按位置画出他们, + // 这样从中途节点开始也能看到各角色"在该处应有的位置"。 + const used = [], addU = s => { if (s && !used.includes(s)) used.push(s); }; + const scanU = arr => (arr || []).forEach(n => { addU(n.speaker); addU(n.actor); }); + scanU(IR.nodes); (IR.sequences || []).forEach(s => scanU(s.nodes)); + const ordU = []; + if (used.includes("P1")) ordU.push("P1"); + (IR.roles || []).forEach(r => { if (r.slot !== "P1" && used.includes(r.slot)) ordU.push(r.slot); }); + used.forEach(u => { if (!ordU.includes(u)) ordU.push(u); }); + S.usedActors = ordU; + return S; } - // ---- 播放期查询 ---- - 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); + function resetAccum(S) { S.clips = []; S.rows = []; S._t = 0; S.curPos = Object.assign({}, S.entryPos || {}); S.visited = new Set(); S.total = 0.1; } + + // 找一条从开头到 targetId 的路径(BFS),用于重放途中走位算"进入位置"。 + function pathTo(S, targetId) { + const start = firstNode(S.IR); + if (!targetId || targetId === start) return []; + const adj = id => { + const n = S.nodes[id]; if (!n) return []; + if (n.kind === "choice" || n.kind === "choice_once") return (n.options || []).map(o => o.goto); + if (n.kind === "random") return (n.branches || []).map(b => b.goto); + if (n.kind === "fight") return [n.win, n.lose]; + return [n.next]; + }; + const prev = {}, q = [start], seen = new Set([start]); + while (q.length) { + const u = q.shift(); + for (const v of adj(u)) { + if (v && !seen.has(v) && S.nodes[v]) { + seen.add(v); prev[v] = u; + if (v === targetId) { const path = []; let c = v; while (c !== undefined) { path.unshift(c); c = prev[c]; } return path; } + q.push(v); + } + } + } + return null; + } + // 沿路径(不含目标节点本身)重放所有走位,得到进入目标节点时各角色的位置。 + function replayPositions(S, path) { + const cur = {}; + const applyMove = n => { if (n.kind === "move") { const to = S.posMap[n.to]; if (to) cur[n.actor || "P1"] = { x: to.x, z: to.z }; } }; + for (let i = 0; i < path.length - 1; i++) { + const n = S.nodes[path[i]]; if (!n) continue; + if (n.kind === "out_ref") { + const sq = S.seqMap[n.ref]; + if (sq) { const sm = {}; (sq.nodes || []).forEach(x => sm[x.id] = x); let sid = (sq.nodes[0] || {}).id, g = 0; while (sid && g++ < 100) { const sn = sm[sid]; if (!sn) break; applyMove(sn); sid = sn.next; } } + } else applyMove(n); + } + return cur; + } + + // ===================== 分段构建(增量追加)===================== + function useRow(S, r) { if (!S.rows.includes(r)) S.rows.push(r); } + function posOf(S, a) { return S.curPos[a] || S.initPos[a] || { x: 0, z: 0 }; } + + function appendPlainNode(S, n) { + const k = n.kind, nid = (S.nodes[n.id] === n) ? n.id : null; // 仅顶层节点可作起点(子序列节点不可) + if (k === "dialogue" || k === "narration") { + const sp = n.speaker || "P1", dur = dlgDur(n.text); + S.clips.push({ row: "演员:" + sp, kind: "dialogue", start: S._t, dur, label: esc(n.text || ""), actor: sp, text: n.text || "", nodeId: nid }); + useRow(S, "演员:" + sp); + if (n.camera) { S.clips.push({ row: "镜头", kind: "camera", start: S._t, dur, label: "对焦 " + n.camera, focus: n.camera, nodeId: nid }); useRow(S, "镜头"); } + S._t += dur; + } else if (k === "move") { + const sp = n.actor || "P1", from = posOf(S, sp), to = S.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); + S.clips.push({ row: "演员:" + sp, kind: "move", start: S._t, dur, label: "→ " + (n.to || ""), actor: sp, from, to, nodeId: nid }); + useRow(S, "演员:" + sp); S.curPos[sp] = { x: to.x, z: to.z }; S._t += dur; + } else if (k === "anim") { + const sp = n.actor || "P1"; + S.clips.push({ row: "演员:" + sp, kind: "anim", start: S._t, dur: ANIM_DUR, label: "动画 " + (n.ani || ""), actor: sp, nodeId: nid }); + useRow(S, "演员:" + sp); S._t += ANIM_DUR; + } else if (k === "reward") { + S.clips.push({ row: "剧情", kind: "reward", start: S._t, dur: 0.4, label: "奖励结算", nodeId: nid }); useRow(S, "剧情"); S._t += 0.4; + } + } + + // 从 startId 走一段线性演出,遇分支/结局/断头停下。返回 {kind, node}。 + function extendSegment(S, startId) { + let id = startId, guard = 0; + while (id && guard++ < 500) { + if (S.visited.has(id)) { S.clips.push({ row: "剧情", kind: "stop", start: S._t, dur: 0.4, label: "循环中断" }); useRow(S, "剧情"); S._t += 0.4; return { kind: "end" }; } + S.visited.add(id); + const end = S.endings[id]; + if (end) { S.clips.push({ row: "剧情", kind: "ending", start: S._t, dur: 0.8, label: "★ " + (end.summary || end.id) }); useRow(S, "剧情"); S._t += 0.8; return { kind: "ending", node: end }; } + const n = S.nodes[id]; + if (!n) return { kind: "end" }; + const k = n.kind; + if (k === "choice" || k === "choice_once" || k === "random" || k === "fight") { + const lbl = (k === "fight") ? "战斗 vs " + (n.camp2 || []).map(S.nm).join("、") + : (k === "random") ? "随机分支" : "选择"; + S.clips.push({ row: "剧情", kind: (k === "fight" ? "fight" : "branch"), start: S._t, dur: 0.6, label: lbl, nodeId: id }); useRow(S, "剧情"); S._t += 0.6; + return { kind: k, node: n }; + } + if (k === "out_ref") { + const sq = S.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; appendPlainNode(S, sn); sid = sn.next; } + } + id = n.next; continue; + } + appendPlainNode(S, n); id = n.next; + } + return { kind: "end" }; + } + + // 走一段,停在下一个需要选的点(choice/fight/random)或结束。 + function runUntilPause(S, startId) { + const r = extendSegment(S, startId); + S.total = Math.max(S._t, 0.1); + return r; + } + + function orderRows(S) { + const order = []; + if (S.rows.includes("演员:P1")) order.push("演员:P1"); + (S.IR.roles || []).forEach(r => { const k = "演员:" + r.slot; if (k !== "演员:P1" && S.rows.includes(k)) order.push(k); }); + S.rows.forEach(r => { if (r.startsWith("演员:") && !order.includes(r)) order.push(r); }); + ["镜头", "剧情"].forEach(r => { if (S.rows.includes(r)) order.push(r); }); + S.rows = order; + } + + // ===================== 播放期查询 ===================== + function actorsIn(M) { return M.usedActors || []; } + function actorPosAt(M, actor, tau) { + let pos = (M.entryPos && M.entryPos[actor]) || M.initPos[actor] || { x: 0, z: 0 }; + const moves = M.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 }; } @@ -163,82 +207,121 @@ } 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) { + function activeDialogue(M, tau) { return M.clips.find(c => c.kind === "dialogue" && tau >= c.start && tau < c.start + c.dur) || null; } + function activeAnim(M, actor, tau) { return M.clips.find(c => c.kind === "anim" && c.actor === actor && tau >= c.start && tau < c.start + c.dur) || null; } + function focusWorldAt(M, 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; + M.clips.filter(c => c.kind === "camera").forEach(c => { if (tau >= c.start) fp = c.focus; }); + if (fp) { const p = anchorXZ(M.anchors, fp); if (p) return p; } + const dlg = activeDialogue(M, tau); + if (dlg && dlg.actor) return actorPosAt(M, dlg.actor, tau); + if (actorsIn(M).includes("P1")) return actorPosAt(M, "P1", tau); + const b = M.bounds; return { x: (b.minX + b.maxX) / 2, z: (b.minZ + b.maxZ) / 2 }; } // ===================== 状态 & 挂载 ===================== - let model, playT = 0, playing = false, rafId = 0, lastTs = 0, stageCv, stageCtx, els = {}; + let S, model, playT = 0, playing = false, rafId = 0, lastTs = 0, stageCv, stageCtx, els = {}, pending = null, selNode = null, PX = 80, fitMode = false; const TEMPLATE = - '
' + - '' + - '