From fb95937236e474f094e1f2196ba3ed84f1aa58c0 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:22:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(timeline):=20=E6=97=A0=E7=82=B9=E4=BD=8D?= =?UTF-8?q?=E9=9B=86=E6=97=B6=E5=90=88=E6=88=90=E5=9C=86=E5=91=A8=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E5=85=9C=E5=BA=95=20+=20=E6=BC=94=E7=A4=BA=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 线上 NAS 多半没挂点位集卷(/api/pointsets 空),白模会无坐标。改为:真实锚点优先, 缺坐标时把引用到的点位(演员位/走位目标/镜头点)在圆周自动铺开,走位预览不空。 真实坐标存在时一律覆盖合成。mapinfo 标注当前是真实坐标还是示意布局。 - samples/timeline_demo.ir.json:QY_TLDEMO 演示事件,覆盖 P1 全部功能 (旁白/对话打字机/3角色走位/动画标记/镜头对焦/选择分支/双结局),离线测试合成布局走位全有位移 --- samples/timeline_demo.ir.json | 30 +++++++++++++++++++++++++++ web/static/timeline.js | 38 ++++++++++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 samples/timeline_demo.ir.json diff --git a/samples/timeline_demo.ir.json b/samples/timeline_demo.ir.json new file mode 100644 index 0000000..c7d4763 --- /dev/null +++ b/samples/timeline_demo.ir.json @@ -0,0 +1,30 @@ +{ + "id": "QY_TLDEMO", + "title": "演出预览功能演示", + "theme": "P1 白模演出测试(走位/对话打字机/动画/镜头/多角色)", + "scale": "演示", + "roles": [ + { "slot": "NP1", "name": "神秘剑客", "archetype": "负伤外门高手", "camp": 0 }, + { "slot": "NP2", "name": "客栈小二", "archetype": "市井路人", "camp": 0 } + ], + "stage": { "type": "客栈·夜", "point_set": "QY_TLDEMO" }, + "nodes": [ + { "id": "n_open", "kind": "narration", "speaker": "P1", "text": "夜色深沉,雨点敲打着客栈的窗棂。一道人影立在门外,迟迟没有进来。", "next": "n_p1walk" }, + { "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_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" } + ] } + ], + "endings": [ + { "id": "end_peace", "summary": "化干戈为玉帛,与剑客结为知己", "grants": [], "result": "success" }, + { "id": "end_fight", "summary": "不欢而散,剑客消失在茫茫雨夜中", "grants": [], "result": "end" } + ] +} diff --git a/web/static/timeline.js b/web/static/timeline.js index 5978734..858f944 100644 --- a/web/static/timeline.js +++ b/web/static/timeline.js @@ -69,7 +69,31 @@ 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 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 }; @@ -94,7 +118,7 @@ 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 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; @@ -114,14 +138,18 @@ 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 = []; - (anchors || []).forEach(a => { xs.push(a.pos[0]); zs.push(a.pos[2]); }); + 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: anchors || [], initPos, nm, roleName, bounds }; + return { clips, rows: order, total: Math.max(t, 0.1), anchors: displayAnchors, initPos, nm, roleName, bounds, synthetic }; } // ---- 播放期查询 ---- @@ -190,7 +218,7 @@ playhead: null, }; els.mapinfo.textContent = "点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + ")" : "") + - (anchors.length ? "" : " ⚠ 无坐标,走位/俯视图将退化为示意"); + (model.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)" : " · 真实坐标"); stageCv = els.stage; stageCtx = stageCv.getContext("2d"); buildTracks();