feat(timeline): P2 并行编排——scene 多轨编辑器 + 白模重叠预览

剧情 Timeline P2 前端 + 共享内核(与 SGame 源真同步):
- ir_core/IR_SCHEMA/样张:scene v0.3 + scene 校验 + 导出 gate(D3),与 SGame 仓逐字一致
- timeline.js:appendScene 按 authored start 铺多轨 clip(自然重叠预览),move from 同 actor 跨轨续连(D4);
  drawStage 改逐 actor 查对话→多人气泡同时计时;导出 _clipDur 纯函数;show() 加 startId 参;常量加 CAMERA_DUR
- scene_edit.js(新):演出段编辑模态——拖 clip 改 start(吸附 0.1s)、拖右缘改 dur、增删 clip/轨道、
  选中属性条精确编辑、客户端轻量 lint(镜像 validate.py)、▶ 预览此段(复用播放核)
- graph.js:scene 节点(KIND_CN/summary/nodeInner 列轨道)+双击进编辑模态
- form.js:右栏 renderScene 精确数值编辑(轨道/clip 的 start/dur/kind/目标)+打开编辑器按钮
- app.py export:捕获 CompileError 并入 report(scene 被拦时不再 500)
- test_scene.js:离线 10 断言全过(重叠确凿/晚 1.5s 起步/from 续连);gitignore 忽略本地 _localdemo.db

待浏览器目测拖拽编辑落 IR + 白模重叠演出。
This commit is contained in:
2026-06-13 22:34:29 +08:00
parent 06e639f0df
commit 021080dd56
14 changed files with 841 additions and 16 deletions

2
.gitignore vendored
View File

@ -2,3 +2,5 @@ __pycache__/
*.pyc *.pyc
web/data/ web/data/
web/story_events.db web/story_events.db
web/_localdemo.db
web/_e2e*.db

View File

@ -1,5 +1,8 @@
# Story IR Schemav0.2M4 扩展) # Story IR Schemav0.3P2 扩展)
> v0.3 变更(剧情 Timeline P2新增 `scene` 节点(多轨时间线容器,承载并行+时间偏移演出)+ track + clip 模型(见 §4.2)。
> scene 与现有线性节点**并存**D1scene 内只放演出、不含分支,有单一 `next` 出口D2
> **导出 gateD3**:含 scene 的事件暂不可编译导出(需 P3 引擎支持),`compile_ir` 遇 scene 报错拦下;校验/白模预览不受限。
> v0.2 变更M4新增 `sequences`/`out_ref`子序列复用、Option `skip`(押注跳过)、 > v0.2 变更M4新增 `sequences`/`out_ref`子序列复用、Option `skip`(押注跳过)、
> `stage.point_set`(点位集引用 + 坐标校验);条件/奖励词典外置到 `ir_dictionary.json`。 > `stage.point_set`(点位集引用 + 坐标校验);条件/奖励词典外置到 `ir_dictionary.json`。
> 校验+编译内核收敛进 `ir_core/` 包CLI 与 M5 Web 后端共用)。 > 校验+编译内核收敛进 `ir_core/` 包CLI 与 M5 Web 后端共用)。
@ -65,6 +68,7 @@ Story IR 是"讲故事"与"编译成配置"两段之间的唯一交接棒。它
| `fight` | `fight_type`(1 击倒/2 死斗), `camp2`:[slot], `camp1?`:[slot], `win`, `lose` | type0`camp2Fighters`,`fightStatus`=[type,win,lose] | | `fight` | `fight_type`(1 击倒/2 死斗), `camp2`:[slot], `camp1?`:[slot], `win`, `lose` | type0`camp2Fighters`,`fightStatus`=[type,win,lose] |
| `reward` | `grants`:[Grant], `next` | type0`resultRewardIds``roleActionCode` | | `reward` | `grants`:[Grant], `next` | type0`resultRewardIds``roleActionCode` |
| `out_ref` | `ref`(子序列 id), `next` | 编译前预展开成普通节点(见 §4.1);本节点不直接产行 | | `out_ref` | `ref`(子序列 id), `next` | 编译前预展开成普通节点(见 §4.1);本节点不直接产行 |
| `scene` | `tracks`:[Track], `duration?`, `next` | **P2 多轨演出段**(见 §4.2);并行+时间偏移容器。导出 gateD3编译期报错暂不产行需 P3 引擎) |
### 4.1 Sequence / out_ref子序列复用对应 `OutRefStoryNodeData` ### 4.1 Sequence / out_ref子序列复用对应 `OutRefStoryNodeData`
@ -81,6 +85,52 @@ Story IR 是"讲故事"与"编译成配置"两段之间的唯一交接棒。它
- **多处复用不撞 id**:不同 `out_ref` 节点前缀不同 → 各自一份独立行。 - **多处复用不撞 id**:不同 `out_ref` 节点前缀不同 → 各自一份独立行。
- **限制**M4 仅支持**一层**(子序列内不得再含 `out_ref`),违者校验报错;子序列内跳转目标须落在子序列内或共享结局。 - **限制**M4 仅支持**一层**(子序列内不得再含 `out_ref`),违者校验报错;子序列内跳转目标须落在子序列内或共享结局。
### 4.2 scene / Track / Clip多轨演出段P2 新增)
`scene` 是一段被分支夹着的**连续并行演出**(对应影视「一镜到底的演出段」):多条轨道同时推进,
每个 clip 有场景内绝对起始秒 `start`,因此「角色 A 走到点 11.5 秒后角色 B 才开始走、两者时间重叠」
这类语义成为一等公民。scene 内**不含分支**D2有单一 `next` 出口;需要选择/战斗/随机仍用图级节点。
```jsonc
{
"id": "sc_intro",
"kind": "scene",
"next": "choice_1", // 单一出口D2
"duration": 8.5, // 可选;缺省 = 所有 clip end 的最大值
"tracks": [ Track, ... ]
}
```
**Track轨道**
```jsonc
{ "id": "tk_p1", "actor": "P1", "clips": [ Clip, ... ] } // 演员轨actor=角色 slotP1/NP1…
{ "id": "tk_cam", "role": "camera", "clips": [ Clip, ... ] } // 镜头轨role="camera"
```
- 演员轨绑定一个 slot承载该角色的 move/anim/dialogue/narration。
- 镜头轨 `role:"camera"`,承载 camera clip不校验 actor
- 推荐单 actor 单轨;一个 actor 多轨亦可(校验不强制,但**同 actor 的 move 跨轨也不得时间重叠**)。
**Clip片段**:通用字段 `id`scene 内唯一)、`kind``start`场景内绝对秒≥0`dur`(可选,按 kind 派生)。
| kind | 字段 | dur 来源 |
|---|---|---|
| `move` | `to`(点位名), `speed?`, `dur?` | `dur` > `dist/speed` > `dist/默认速度` |
| `dialogue` | `text`, `dur?` | `dur` > `字数×打字速度+尾停` |
| `narration` | `text`, `dur?` | 同 dialogue |
| `anim` | `ani`, `angle?`, `dur?` | `dur` > 缺省 1.0sP3 由引擎动画时长表回填) |
| `camera` | `focus`(点位名), `dur?` | `dur` > 缺省 2.0s |
| `wait` | (仅占位), `dur` | 显式 `dur` |
- **D4 move 时长派生、位置隐式**clip 的 `start` 由作者编排move 的 `dur` 默认由 `dist(from→to)/speed` 派生(可显式 `dur` 覆盖)。
move 的 `from` **不存**,运行期由「同 actor 轨上一段 move 的 `to`」推出(无则取点位集初始锚点)。
- 校验约束:**同一 actor 的 move 之间时间上不得重叠**一个人不能同时走两处dialogue/anim 可与本人 move 重叠(边走边说)。
- 时长/打字速度常量与白模预览 `timeline.js` 共享CHAR_TIME/TAIL_PAUSE/MIN_DLG/MOVE_SPEED/ANIM_DUR/CAMERA_DUR
- 样张:`samples/scene_demo.ir.json`A 走、1.5s 后 B 走,两者重叠)。
> **P3 衔接契约**scene/track/clip 即 P3 `StoryTimelineData` 的序列化源。引擎进入 scene 时对每 clip 按 `start` 排程
> `UniTask.Delay`move 用现有多 agent 并发寻路dialogue 走打字机camera 设 `otherTarget`wait 纯延时;
> 在 `max(clip.end)` 或显式 `duration` 后推进 `next`。故 IR **不写** move 的 `from`/绝对坐标(引擎自点位集+轨道续连推)。
### Option选项 ### Option选项
```jsonc ```jsonc
{ "text": "收留他", "condition": Condition?, "reward": {"grants":[Grant]}?, { "text": "收留他", "condition": Condition?, "reward": {"grants":[Grant]}?,

View File

@ -236,6 +236,11 @@ def compile_ir(ir, dic):
r["resultRewardIds"] = rid_str r["resultRewardIds"] = rid_str
if rac: if rac:
r["roleActionCode"] = rac r["roleActionCode"] = rac
elif kind == "scene":
# 导出 gateP2 决策 D3scene 多轨演出段需 P3 引擎(StoryTimelineData/Director)支持,
# P2 阶段只编排+白模预览,不做会被 P3 替换的临时降级器。拦在 zip 导出前。
raise CompileError("节点 %s 是 scene 演出段scene 暂需 P3 引擎支持,未上线前勿用于导出"
"(可继续编排+白模预览,但含 scene 的事件无法编译成 game_event_data" % tid)
else: else:
raise CompileError("未知节点 kind: %r (节点 %s)" % (kind, tid)) raise CompileError("未知节点 kind: %r (节点 %s)" % (kind, tid))
@ -260,6 +265,11 @@ def extract_texts(ir):
add(n.get("text")) add(n.get("text"))
for o in n.get("options", []): for o in n.get("options", []):
add(o.get("text")) add(o.get("text"))
if n.get("kind") == "scene": # P2补抽 scene 内 dialogue/narration 文本
for tk in n.get("tracks", []) or []:
for clip in tk.get("clips", []) or []:
if clip.get("kind") in ("dialogue", "narration"):
add(clip.get("text"))
walk_nodes(ir.get("nodes", [])) walk_nodes(ir.get("nodes", []))
for s in ir.get("sequences", []): for s in ir.get("sequences", []):

View File

@ -12,6 +12,12 @@ from .dictionary import CompileError
ITEM_RE = re.compile(r"^[VP]\d+$") ITEM_RE = re.compile(r"^[VP]\d+$")
# 时长/打字速度常量(与白模预览 timeline.js 顶部共享避免漂移。P2 scene move 重叠校验用。
MOVE_SPEED = 3.0
CHAR_TIME, TAIL_PAUSE, MIN_DLG = 0.07, 0.9, 1.2
ANIM_DUR, CAMERA_DUR = 1.0, 2.0
_SCENE_CLIP_KINDS = {"move", "dialogue", "narration", "anim", "camera", "wait"}
# 点位集位置:本文件在 SGame/tools/event_authoring/ir_core/ -> 上溯 4 级到项目根 # 点位集位置:本文件在 SGame/tools/event_authoring/ir_core/ -> 上溯 4 级到项目根
_POINTS_DIR = os.path.join( _POINTS_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))), os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))),
@ -47,13 +53,15 @@ def validate(ir, dic, points_dir=None, strict_points=False):
endings = {e["id"]: e for e in ir.get("endings", [])} endings = {e["id"]: e for e in ir.get("endings", [])}
all_ids = set(nodes) | set(endings) all_ids = set(nodes) | set(endings)
slots = {r["slot"] for r in ir.get("roles", [])} slots = {r["slot"] for r in ir.get("roles", [])}
anchors = _load_anchors(ir, points_dir) # {点位名:(x,z)}无点位集时为空move 重叠检跳过)
def check_target(src, tid, field): def check_target(src, tid, field):
if tid not in all_ids: if tid not in all_ids:
errs.append("[跳转失效] 节点 %s%s 指向不存在的目标 %r" % (src, field, tid)) errs.append("[跳转失效] 节点 %s%s 指向不存在的目标 %r" % (src, field, tid))
def check_slot(src, s, field): def check_slot(src, s, field):
if s and s not in slots: # P1 是固定玩家 slot系统隐式合法与 collect_point_refs/编译器/前端表单一致),不需在 roles 声明
if s and s != "P1" and s not in slots:
errs.append("[未声明角色] 节点 %s%s 引用了未在 roles 声明的 slot %r" % (src, field, s)) errs.append("[未声明角色] 节点 %s%s 引用了未在 roles 声明的 slot %r" % (src, field, s))
def check_grants(src, grants): def check_grants(src, grants):
@ -125,6 +133,8 @@ def validate(ir, dic, points_dir=None, strict_points=False):
check_slot(nid, n.get("actor"), "move.actor") check_slot(nid, n.get("actor"), "move.actor")
if kind == "reward": if kind == "reward":
check_grants(nid, n.get("grants")) check_grants(nid, n.get("grants"))
if kind == "scene":
_check_scene(nid, n, slots, anchors, errs)
for e in ir.get("endings", []): for e in ir.get("endings", []):
check_grants(e["id"], e.get("grants")) check_grants(e["id"], e.get("grants"))
@ -157,9 +167,111 @@ def collect_point_refs(ir):
for s in (n.get("camp1") or []) + (n.get("camp2") or []): for s in (n.get("camp1") or []) + (n.get("camp2") or []):
if s and s != "P1": if s and s != "P1":
refs.add(s) refs.add(s)
if n.get("kind") == "scene":
for tk in n.get("tracks", []) or []:
a = tk.get("actor")
if a and a != "P1":
refs.add(a)
for clip in tk.get("clips", []) or []:
if clip.get("kind") == "move" and clip.get("to") and clip["to"] != "P1":
refs.add(clip["to"])
if clip.get("kind") == "camera" and clip.get("focus"):
refs.add(clip["focus"])
return refs return refs
def _load_anchors(ir, points_dir):
"""加载点位集坐标 {点位名:(x,z)};缺失/损坏返回 {}scene move 重叠检会因此跳过)。"""
import json
name = (ir.get("stage") or {}).get("point_set") or ir["id"]
base = points_dir or _POINTS_DIR
path = os.path.join(base, name + ".points.json")
if not os.path.exists(path):
return {}
try:
with open(path, encoding="utf-8") as f:
ps = json.load(f)
except Exception:
return {}
out = {}
for p in ps.get("points", []):
pos = p.get("pos") or [0, 0, 0]
if p.get("name"):
out[p["name"]] = (pos[0], pos[2]) # 俯视用 x,z
return out
def _check_scene(nid, node, slots, anchors, errs):
"""P2 scene 节点校验:轨道/clip 合法性 + 同 actor move 时间不重叠D4
点位存在性由 collect_point_refs + _check_point_set 统一校验,此处只查字段齐备与时序。
"""
tracks = node.get("tracks")
if not isinstance(tracks, list) or not tracks:
errs.append("[空演出段] scene 节点 %s 没有任何 track" % nid); return
seen_clip = set()
moves_by_actor = {} # actor -> [clip](含跨轨;同 actor move 不得重叠)
for ti, tk in enumerate(tracks):
is_cam = tk.get("role") == "camera"
actor = tk.get("actor")
if not is_cam:
if not actor:
errs.append("[轨道缺actor] scene %s%d条轨道既非镜头轨(role:camera)也未指定 actor" % (nid, ti + 1))
elif actor != "P1" and actor not in slots:
errs.append("[未声明角色] scene %s 轨道 actor %r 未在 roles 声明" % (nid, actor))
for clip in tk.get("clips", []) or []:
cid = clip.get("id")
if cid:
if cid in seen_clip:
errs.append("[clip重复id] scene %s 内 clip id %r 重复scene 内须唯一)" % (nid, cid))
seen_clip.add(cid)
k = clip.get("kind")
if k not in _SCENE_CLIP_KINDS:
errs.append("[非法clip] scene %s 的 clip %r 的 kind %r 不在允许集合 %s"
% (nid, cid, k, sorted(_SCENE_CLIP_KINDS))); continue
st = clip.get("start")
if not isinstance(st, (int, float)) or st < 0:
errs.append("[非法start] scene %s 的 clip %r 的 start %r 必须是 ≥0 的数字" % (nid, cid, st))
d = clip.get("dur")
if d is not None and (not isinstance(d, (int, float)) or d <= 0):
errs.append("[非法dur] scene %s 的 clip %r 的 dur %r 必须为正数" % (nid, cid, d))
if k in ("dialogue", "narration") and not str(clip.get("text") or "").strip():
errs.append("[空文本] scene %s%s clip %r 文本为空" % (nid, k, cid))
if k == "move" and not clip.get("to"):
errs.append("[move缺目标] scene %s 的 move clip %r 缺 to(目标点)" % (nid, cid))
if k == "camera" and not clip.get("focus"):
errs.append("[camera缺焦点] scene %s 的 camera clip %r 缺 focus(点位)" % (nid, cid))
if k == "wait" and not isinstance(d, (int, float)):
errs.append("[wait缺时长] scene %s 的 wait clip %r 必须显式 dur" % (nid, cid))
if k == "move" and not is_cam and actor:
moves_by_actor.setdefault(actor, []).append(clip)
# 同 actor 的 move 时间不得重叠(一个人不能同时走两处);无坐标无法估时长则跳过该 actor
for actor, mvs in moves_by_actor.items():
prev_pos = anchors.get(actor)
mvs_sorted = sorted(mvs, key=lambda c: c.get("start") or 0)
timed, ok = [], True
for c in mvs_sorted:
st = c.get("start") or 0
d = c.get("dur")
if isinstance(d, (int, float)) and d > 0:
prev_pos = anchors.get(c.get("to"), prev_pos)
else:
to = anchors.get(c.get("to"))
if to is None or prev_pos is None:
ok = False; break
dist = ((to[0] - prev_pos[0]) ** 2 + (to[1] - prev_pos[1]) ** 2) ** 0.5
d = max(0.3, dist / (c.get("speed") or MOVE_SPEED))
prev_pos = to
timed.append((st, st + d, c.get("id")))
if not ok:
continue
timed.sort()
for i in range(1, len(timed)):
if timed[i - 1][1] > timed[i][0] + 1e-6:
errs.append("[move重叠] scene %s 的 actor %smove %r 尚未走完move %r 又起步"
"(同一人不能同时走两处)" % (nid, actor, timed[i - 1][2], timed[i][2]))
def _check_point_set(ir, errs, warns, points_dir, strict_points): def _check_point_set(ir, errs, warns, points_dir, strict_points):
import json import json
name = (ir.get("stage") or {}).get("point_set") or ir["id"] name = (ir.get("stage") or {}).get("point_set") or ir["id"]

View File

@ -0,0 +1,45 @@
{
"id": "QY_SCENE",
"title": "并行演出段演示",
"theme": "P2 多轨时间线A 走、1.5s 后 B 才走,两者时间重叠",
"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": "sc_demo" },
{
"id": "sc_demo",
"kind": "scene",
"next": "n_choice",
"tracks": [
{ "id": "tk_p1", "actor": "P1", "clips": [
{ "id": "c_p1move", "kind": "move", "start": 0, "to": "PT_CENTER" },
{ "id": "c_p1say", "kind": "dialogue", "start": 0.5, "text": "(这客栈看着冷清,却处处透着古怪……我先到中堂看看。)" }
] },
{ "id": "tk_np1", "actor": "NP1", "clips": [
{ "id": "c_np1move", "kind": "move", "start": 1.5, "to": "PT_DOOR" },
{ "id": "c_np1say", "kind": "dialogue", "start": 3.0, "text": "阁下留步。在下身负旧伤,想借贵宝地歇一晚,天明即走。" }
] },
{ "id": "tk_np2", "actor": "NP2", "clips": [
{ "id": "c_np2say", "kind": "dialogue", "start": 4.5, "text": "二位客官,里边请,小的这就去沏壶热茶!" }
] },
{ "id": "tk_cam", "role": "camera", "clips": [
{ "id": "c_cam0", "kind": "camera", "start": 0, "focus": "PT_CENTER", "dur": 6.0 }
] }
]
},
{ "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": "fail" }
]
}

62
test_scene.js Normal file
View File

@ -0,0 +1,62 @@
// 离线回归scene 多轨数据源 + 重叠演出无浏览器stub window 加载 timeline.js
// 跑法node test_scene.js
const fs = require("fs"), path = require("path");
global.window = {};
require("./web/static/timeline.js"); // 设置 window.Timeline加载期不碰 DOM
const T = global.window.Timeline;
function loadAnchors(psName) {
// 复刻 app.py /api/pointsets把点位集转成 anchors [{name,pos:[x,y,z]}]
const p = path.join("E:/Library/SGame/Assets/StreamingAssets/Story/PointSets", psName + ".points.json");
const ps = JSON.parse(fs.readFileSync(p, "utf-8"));
return (ps.points || []).map(pt => ({ name: pt.name, pos: pt.pos || [0, 0, 0], rot: pt.rot || 0 }));
}
let fails = 0;
function ok(cond, msg) { console.log((cond ? "PASS " : "FAIL ") + msg); if (!cond) fails++; }
function overlap(a, b) { return a.start < b.start + b.dur && b.start < a.start + a.dur; }
const ir = JSON.parse(fs.readFileSync("samples/scene_demo.ir.json", "utf-8"));
const anchors = loadAnchors("QY_TLDEMO");
const M = T._buildModel(ir, anchors);
// 1) scene clips 被铺出来P1/NP1 各有 moveNP2 有 dialogue镜头有 camera
const p1move = M.clips.find(c => c.actor === "P1" && c.kind === "move");
const np1move = M.clips.find(c => c.actor === "NP1" && c.kind === "move");
const np1say = M.clips.find(c => c.actor === "NP1" && c.kind === "dialogue");
const cam = M.clips.find(c => c.kind === "camera");
ok(!!p1move, "P1 走位 clip 存在");
ok(!!np1move, "NP1 走位 clip 存在");
ok(!!np1say, "NP1 对话 clip 存在");
ok(!!cam && cam.focus === "PT_CENTER", "镜头 clip 对焦 PT_CENTER");
// 2) 核心A 走与 1.5s 后 B 走在时间上重叠(两点同时移动)
ok(overlap(p1move, np1move),
`P1/NP1 走位重叠P1[${p1move.start.toFixed(2)},${(p1move.start + p1move.dur).toFixed(2)}] NP1[${np1move.start.toFixed(2)},${(np1move.start + np1move.dur).toFixed(2)}]`);
// 3) NP1 比 P1 晚 1.5s 起步authored start 偏移被尊重scene 整体在前置 narration 之后铺轴)
ok(Math.abs((np1move.start - p1move.start) - 1.5) < 1e-6, `NP1 比 P1 晚起步 ${(np1move.start - p1move.start).toFixed(2)}s 应=1.5`);
// 4) move from 续连P1 从初始锚点(-6) 起步、终点 PT_CENTER(+1.5)
ok(Math.abs(p1move.from.z - (-6)) < 1e-6 && Math.abs(p1move.to.z - 1.5) < 1e-6,
`P1 move from.z=${p1move.from.z}→to.z=${p1move.to.z}`);
// 5) scene 之后能续演到 choiceextendSegment 流过 scene→n_choice
const s = T._prepare(ir, anchors);
let r = T._extend(s, T._firstNode(ir));
ok(r && (r.kind === "choice"), "scene 后停在 choicekind=" + (r && r.kind));
// 6) NP2 对话比 P1 走位晚 4.5sscene-local start 偏移)
const np2say = M.clips.find(c => c.actor === "NP2" && c.kind === "dialogue");
ok(np2say && Math.abs((np2say.start - p1move.start) - 4.5) < 1e-6, "NP2 对话比 P1 晚 " + (np2say && (np2say.start - p1move.start).toFixed(2)) + "s 应=4.5");
// 7) 重叠时刻两人都在走(数据层:取两 move 区间交集中点)
const lo = Math.max(p1move.start, np1move.start), hi = Math.min(p1move.start + p1move.dur, np1move.start + np1move.dur);
const tau = (lo + hi) / 2;
const p1moving = tau >= p1move.start && tau < p1move.start + p1move.dur;
const np1moving = tau >= np1move.start && tau < np1move.start + np1move.dur;
ok(p1moving && np1moving, `tau=${tau.toFixed(2)}(重叠区间中点)时 P1 走=${p1moving} NP1 走=${np1moving}(应同时为真)`);
console.log("\n" + (fails ? (fails + " 个断言失败") : "全部通过"));
process.exit(fails ? 1 : 0);

View File

@ -266,22 +266,30 @@ async def export_zip():
if not confirmed: if not confirmed:
return JSONResponse({"error": "没有 confirmed 事件可导出"}, status_code=422) return JSONResponse({"error": "没有 confirmed 事件可导出"}, status_code=422)
# 校验门:任一 confirmed 有 error 即整体拒绝 # 校验门:任一 confirmed 有 error 即整体拒绝
# 同步做预编译探测——捕获 CompileError含 P2 scene 导出 gate D3含 scene 的事件暂不可导出),
# 把编译失败也并入 report避免 compile 抛异常变成 500。编译成功的结果缓存复用不重复编译。
report = {} report = {}
compiled = {}
blocked = False blocked = False
for group, ir in confirmed: for group, ir in confirmed:
errs, warns = ir_core.validate(ir, dic, points_dir=_POINTSETS_DIR) errs, warns = ir_core.validate(ir, dic, points_dir=_POINTSETS_DIR)
if not errs:
try:
compiled[group] = ir_core.compile_ir(ir, dic)
except ir_core.CompileError as e:
errs = errs + ["[编译失败] %s" % e]
report[group] = {"errors": errs, "warnings": warns} report[group] = {"errors": errs, "warnings": warns}
if errs: if errs:
blocked = True blocked = True
if blocked: if blocked:
return JSONResponse({"error": "存在校验失败的 confirmed 事件,已拒绝导出", return JSONResponse({"error": "存在校验/编译失败的 confirmed 事件,已拒绝导出",
"report": report}, status_code=422) "report": report}, status_code=422)
buf = io.BytesIO() buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for group, ir in confirmed: for group, ir in confirmed:
rows = ir_core.compile_ir(ir, dic) rows = compiled[group]
z.writestr(group + ".events.json", z.writestr(group + ".events.json",
json.dumps(rows, ensure_ascii=False, indent=2)) json.dumps(rows, ensure_ascii=False, indent=2))
texts = ir_core.extract_texts(ir) texts = ir_core.extract_texts(ir)

View File

@ -100,6 +100,7 @@
}, },
selectNode: id => { App.selectedNode = id; }, selectNode: id => { App.selectedNode = id; },
deleteNode: id => deleteNode(id), deleteNode: id => deleteNode(id),
editScene: id => { App.selectedNode = id; SceneEdit.open(id, App.ir, ctx(), App.pointsets, App.dict); },
}); });
function selectNode(id) { App.selectedNode = id; GraphUI.select(id); FormUI.renderNode(App.ir, id, ctx()); } function selectNode(id) { App.selectedNode = id; GraphUI.select(id); FormUI.renderNode(App.ir, id, ctx()); }
@ -402,6 +403,7 @@
onConnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); scheduleSnapshot(); }, onConnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); scheduleSnapshot(); },
onDisconnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); scheduleSnapshot(); }, onDisconnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); scheduleSnapshot(); },
onDeleteSelected: id => deleteNode(id), onDeleteSelected: id => deleteNode(id),
onEditScene: id => { App.selectedNode = id; GraphUI.select(id); SceneEdit.open(id, App.ir, ctx(), App.pointsets, App.dict); },
}); });
(async function () { (async function () {
try { const r = await fetch("/api/events?status=all"); if (r.status === 401) { showLogin(); return; } hideLogin(); init(); } try { const r = await fetch("/api/events?status=all"); if (r.status === 401) { showLogin(); return; } hideLogin(); init(); }

View File

@ -5,7 +5,8 @@
(function () { (function () {
const NODE_KINDS = ["narration", "dialogue", "choice", "choice_once", "random", const NODE_KINDS = ["narration", "dialogue", "choice", "choice_once", "random",
"fight", "move", "anim", "reward", "out_ref"]; "fight", "move", "anim", "reward", "out_ref", "scene"];
const SCENE_CLIP_KINDS = ["move", "dialogue", "narration", "anim", "camera", "wait"];
// ---- DOM 小工具 ---- // ---- DOM 小工具 ----
function el(tag, attrs, kids) { function el(tag, attrs, kids) {
@ -252,6 +253,8 @@
const seqs = (ir.sequences || []).map(s => ({ value: s.id, label: s.id })); const seqs = (ir.sequences || []).map(s => ({ value: s.id, label: s.id }));
host.appendChild(field("引用子序列 ref", sel(node.ref, [{ value: "", label: "(无)" }].concat(seqs), v => { node.ref = v; ctx.onChange(false); }))); host.appendChild(field("引用子序列 ref", sel(node.ref, [{ value: "", label: "(无)" }].concat(seqs), v => { node.ref = v; ctx.onChange(false); })));
host.appendChild(field("出口接回 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); }))); host.appendChild(field("出口接回 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); })));
} else if (node.kind === "scene") {
renderScene(host, ir, node, ctx, tgt);
} }
// 删除节点 // 删除节点
@ -263,6 +266,70 @@
])); ]));
}; };
// scene 内 clip 唯一 id
function newClipId(node) {
let i = 1, id; const has = x => (node.tracks || []).some(tk => (tk.clips || []).some(c => c.id === x));
do { id = "c" + i++; } while (has(id));
return id;
}
// ========== 演出段 scene 右栏(精确数值编辑,与画布拖拽互补)==========
function renderScene(host, ir, node, ctx, tgt) {
if (!node.tracks) node.tracks = [];
const actorSlots = [{ value: "P1", label: "P1 玩家" }].concat((ir.roles || []).map(r => ({ value: r.slot, label: r.slot + " " + r.name })));
host.appendChild(el("div", { class: "fld" }, [
el("button", { class: "mini primary", onclick: () => ctx.editScene && ctx.editScene(node.id) }, ["🎬 打开时间线编辑器(拖拽编排)"]),
]));
host.appendChild(el("div", { class: "row2" }, [
field("时长 duration(可选,缺省=自动)", num(node.duration, v => { if (v == null) delete node.duration; else node.duration = v; ctx.onChange(false); })),
field("出口 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); })),
]));
const box = el("div", { class: "subbox" });
box.appendChild(el("div", { class: "hd" }, [
el("span", {}, ["轨道 tracks"]),
el("span", {}, [
el("button", { class: "mini", onclick: () => { node.tracks.push({ id: "tk" + node.tracks.length, actor: "P1", clips: [] }); ctx.onChange(true); } }, ["+演员轨"]),
el("button", { class: "mini", onclick: () => { node.tracks.push({ id: "tkcam" + node.tracks.length, role: "camera", clips: [] }); ctx.onChange(true); } }, ["+镜头轨"]),
]),
]));
(node.tracks || []).forEach((tk, ti) => {
const isCam = tk.role === "camera";
const tbox = el("div", { class: "fld optdet-like" });
tbox.appendChild(el("div", { class: "row2" }, [
isCam ? el("span", { class: "node-id" }, ["🎬 镜头轨"])
: sel(tk.actor || "P1", actorSlots, v => { tk.actor = v; ctx.onChange(false); }),
el("span", {}, [
el("button", { class: "mini", onclick: () => {
const c = { id: newClipId(node), kind: isCam ? "camera" : "move", start: 0 };
if (isCam) c.focus = (ctx.pointNames || [])[0] || ""; else c.to = (ctx.pointNames || [])[0] || "";
(tk.clips = tk.clips || []).push(c); ctx.onChange(true);
} }, ["clip"]),
el("button", { class: "mini", onclick: () => { if (confirm("删除该轨道?")) { node.tracks.splice(ti, 1); ctx.onChange(true); } } }, ["删轨"]),
]),
]));
(tk.clips || []).forEach((c, ci) => {
const kinds = isCam ? ["camera"] : SCENE_CLIP_KINDS.filter(k => k !== "camera");
const row = el("div", { class: "se-cliprow" }, [
sel(c.kind, kinds.map(k => ({ value: k, label: k })), v => { c.kind = v; ctx.onChange(true); }),
field("start", num(c.start, v => { c.start = v == null ? 0 : Math.max(0, v); ctx.onChange(false); })),
field("dur", num(c.dur, v => { if (v == null) delete c.dur; else c.dur = v; ctx.onChange(false); })),
]);
if (c.kind === "move") row.appendChild(field("to", sel(c.to, pointOpts(ir, ctx, c.to), v => { c.to = v; ctx.onChange(false); })));
else if (c.kind === "camera") row.appendChild(field("focus", sel(c.focus, pointOpts(ir, ctx, c.focus), v => { c.focus = v; ctx.onChange(false); })));
else if (c.kind === "dialogue" || c.kind === "narration") row.appendChild(field("文本", txt(c.text, v => { c.text = v; ctx.onChange(false); })));
else if (c.kind === "anim") row.appendChild(field("ani", txt(c.ani, v => { c.ani = v; ctx.onChange(false); })));
row.appendChild(el("button", { class: "mini", onclick: () => { tk.clips.splice(ci, 1); ctx.onChange(true); } }, ["✕"]));
tbox.appendChild(row);
});
if (!(tk.clips || []).length) tbox.appendChild(el("div", { class: "empty" }, ["无 clip"]));
box.appendChild(tbox);
});
if (!node.tracks.length) box.appendChild(el("div", { class: "empty" }, ["无轨道——点「+演员轨/+镜头轨」"]));
host.appendChild(box);
}
function renderEnding(host, ir, e, ctx) { function renderEnding(host, ir, e, ctx) {
host.appendChild(field("结局摘要 summary", txt(e.summary, v => { e.summary = v; ctx.onChange(false); }))); host.appendChild(field("结局摘要 summary", txt(e.summary, v => { e.summary = v; ctx.onChange(false); })));
host.appendChild(field("结果 result", sel(e.result || "success", [ host.appendChild(field("结果 result", sel(e.result || "success", [

View File

@ -31,6 +31,10 @@
if (n.kind === "anim") return ["动画 · " + nameOf(ir, names, n.actor), n.ani || ""]; if (n.kind === "anim") return ["动画 · " + nameOf(ir, names, n.actor), n.ani || ""];
if (n.kind === "reward") return ["奖励结算", ""]; if (n.kind === "reward") return ["奖励结算", ""];
if (n.kind === "out_ref") return ["引用子序列", "→ " + (n.ref || "")]; if (n.kind === "out_ref") return ["引用子序列", "→ " + (n.ref || "")];
if (n.kind === "scene") {
const tks = n.tracks || [], nclip = tks.reduce((a, t) => a + ((t.clips || []).length), 0);
return ["演出段 (" + tks.length + "轨)", nclip + " clip" + (n.duration ? " · " + n.duration + "s" : "")];
}
if (n.kind === "ending") return ["★ 结局", n.summary || ""]; if (n.kind === "ending") return ["★ 结局", n.summary || ""];
return [n.kind, ""]; return [n.kind, ""];
} }
@ -61,7 +65,7 @@
} }
// ---------- 节点 HTML ---------- // ---------- 节点 HTML ----------
const KIND_CN = { narration: "旁白", dialogue: "对话", choice: "选择", choice_once: "一次性选择", random: "随机", fight: "战斗", move: "走位", anim: "动画", reward: "奖励", out_ref: "引用", ending: "结局" }; const KIND_CN = { narration: "旁白", dialogue: "对话", choice: "选择", choice_once: "一次性选择", random: "随机", fight: "战斗", move: "走位", anim: "动画", reward: "奖励", out_ref: "引用", scene: "演出段", ending: "结局" };
function nodeInner(ir, node) { function nodeInner(ir, node) {
const names = roleNames(ir), end = isEnding(node); const names = roleNames(ir), end = isEnding(node);
const kind = end ? "ending" : node.kind; const kind = end ? "ending" : node.kind;
@ -85,6 +89,16 @@
} }
return label + chOpts; return label + chOpts;
} }
// 演出段 scene列出各轨演员/镜头)+ clip 数 + 双击编辑提示
if (!end && node.kind === "scene") {
const tks = node.tracks || [];
const lines = tks.slice(0, 4).map(tk => {
const who = tk.role === "camera" ? "🎬 镜头" : nameOf(ir, names, tk.actor || "P1");
return '<div class="screl">' + esc(who) + ' · ' + ((tk.clips || []).length) + ' clip</div>';
}).join("");
const more = tks.length > 4 ? '<div class="screl more">…+' + (tks.length - 4) + ' 轨</div>' : "";
return label + '<div class="nbody scenebody">' + (lines || '<div class="screl more">(空演出段)</div>') + more + '<div class="schint">双击编辑时间线</div></div>';
}
// 线性 / 单出口:角色(可选)+ 文本 // 线性 / 单出口:角色(可选)+ 文本
const sm = summary(ir, names, node); const sm = summary(ir, names, node);
const actor = node.speaker || node.actor; const actor = node.speaker || node.actor;
@ -188,6 +202,14 @@
editor.on("connectionCreated", info => { if (building) return; handleConnect(info); }); editor.on("connectionCreated", info => { if (building) return; handleConnect(info); });
editor.on("connectionRemoved", info => { if (building) return; handleDisconnect(info); }); editor.on("connectionRemoved", info => { if (building) return; handleDisconnect(info); });
editor.on("nodeRemoved", id => { if (building) return; const ir = dfId2ir[id]; if (ir && cb.onRemove) cb.onRemove(ir); }); editor.on("nodeRemoved", id => { if (building) return; const ir = dfId2ir[id]; if (ir && cb.onRemove) cb.onRemove(ir); });
// 双击 scene 节点 → 打开时间线编辑模态(演出段编排入口)
document.getElementById(containerId).addEventListener("dblclick", e => {
const nodeEl = e.target.closest && e.target.closest(".drawflow-node");
if (!nodeEl) return;
const irId = dfId2ir[nodeEl.id.replace("node-", "")];
const n = irId && findNode(irId);
if (n && n.kind === "scene" && cb.onEditScene) { e.stopPropagation(); e.preventDefault(); cb.onEditScene(irId); }
});
// 键盘 Del删画布选中节点 → 交给 app 做「缝合删除」;焦点在输入框时放行文本编辑;选中连线时交给 Drawflow // 键盘 Del删画布选中节点 → 交给 app 做「缝合删除」;焦点在输入框时放行文本编辑;选中连线时交给 Drawflow
document.addEventListener("keydown", e => { document.addEventListener("keydown", e => {
if (e.key !== "Delete" && e.key !== "Backspace") return; if (e.key !== "Delete" && e.key !== "Backspace") return;

View File

@ -135,6 +135,7 @@
<script src="form.js"></script> <script src="form.js"></script>
<script src="playtest.js"></script> <script src="playtest.js"></script>
<script src="timeline.js"></script> <script src="timeline.js"></script>
<script src="scene_edit.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

283
web/static/scene_edit.js Normal file
View File

@ -0,0 +1,283 @@
// scene_edit.js — 演出段scene多轨时间线编辑模态。
// 直接编辑 App.ir 里某个 scene 节点的 tracks/clips拖 clip 改 start、拖右缘改 dur、
// 增删 clip/轨道、设目标点/文本,实时重画 + 客户端校验 + 复用白模播放核预览。
// 时长口径复用 window.Timeline._clipDur与播放/校验单一来源)。
// 暴露 window.SceneEdit = { open(sceneId, ir, ctx, pointsets, dict) }。
(function () {
const PX = 90, LANE_H = 34, GRID = 0.1, MIN_DUR = 0.2;
let ir, ctx, dict, pointsets, sceneId, posMap = {}, sel = null;
let root = null, lanesEl = null, propEl = null, lintEl = null, previewEl = null, rulerWrapEl = null;
function esc(s) { return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function snap(v) { return Math.max(0, Math.round(v / GRID) * GRID); }
function round1(v) { return Math.round(v * 10) / 10; }
function scene() { return (ir.nodes || []).find(n => n.id === sceneId); }
function pointNames() { return Object.keys(posMap); }
function actorSlots() { return ["P1"].concat((ir.roles || []).map(r => r.slot)); }
function actorName(s) { if (s === "P1") return "玩家"; const r = (ir.roles || []).find(x => x.slot === s); return r ? r.name : s; }
// 每个 actor 的 move from跨轨续连按 start 排序)→ Map(clip->{x,z})
function moveFroms(sc) {
const byActor = {};
(sc.tracks || []).forEach(tk => { if (tk.role === "camera") return; const a = tk.actor || "P1";
(tk.clips || []).forEach(c => { if (c.kind === "move") (byActor[a] = byActor[a] || []).push(c); }); });
const m = new Map();
Object.keys(byActor).forEach(a => { let prev = posMap[a] || { x: 0, z: 0 };
byActor[a].slice().sort((x, y) => (x.start || 0) - (y.start || 0)).forEach(c => {
m.set(c, prev); const to = posMap[c.to] || prev; prev = { x: to.x, z: to.z };
});
});
return m;
}
function durOf(c, froms) { return window.Timeline._clipDur(c, posMap, froms.get(c)); }
function sceneEnd(sc, froms) { let e = 0.1; (sc.tracks || []).forEach(tk => (tk.clips || []).forEach(c => { e = Math.max(e, (c.start || 0) + durOf(c, froms)); })); return e; }
function newClipId(sc) {
let i = 1, id; const has = id => (sc.tracks || []).some(tk => (tk.clips || []).some(c => c.id === id));
do { id = "c" + i++; } while (has(id));
return id;
}
// ---------- 打开 / 关闭 ----------
function open(_sceneId, _ir, _ctx, _pointsets, _dict) {
sceneId = _sceneId; ir = _ir; ctx = _ctx; pointsets = _pointsets || {}; dict = _dict || {}; sel = null;
const psName = (ir.stage || {}).point_set || ir.id;
const ps = pointsets[psName] || {};
posMap = {}; (ps.anchors || []).forEach(a => posMap[a.name] = { x: (a.pos || [0, 0, 0])[0], z: (a.pos || [0, 0, 0])[2] });
buildModal(psName, ps);
render();
}
function close() { try { window.Timeline.stop(); } catch (e) {} if (root) root.remove(); root = null; }
function buildModal(psName, ps) {
if (root) root.remove();
root = document.createElement("div");
root.className = "overlay se-overlay";
root.innerHTML =
'<div class="modal scene-edit">' +
' <h3>演出段编排 · <span class="se-id"></span>' +
' <span class="se-psinfo"></span>' +
' <button class="modal-close se-x" style="float:right">关闭</button>' +
' </h3>' +
' <div class="se-toolbar">' +
' <button class="mini se-addtrack"> 添加轨道</button>' +
' <button class="mini se-preview-btn primary">▶ 预览此段</button>' +
' <span class="se-tip">拖 clip 改起始 · 拖右缘改时长 · 单击选中编辑 · 网格吸附 0.1s</span>' +
' <span class="se-total"></span>' +
' </div>' +
' <div class="se-trackswrap"><div class="se-ruler"></div><div class="se-lanes"></div></div>' +
' <div class="se-lint"></div>' +
' <div class="se-prop"></div>' +
' <div class="se-preview"></div>' +
'</div>';
document.body.appendChild(root);
root.querySelector(".se-id").textContent = sceneId;
root.querySelector(".se-psinfo").textContent = " 点位集:" + psName + (ps.anchors && ps.anchors.length ? "" + ps.anchors.length + " 点)" : "无坐标move 时长按 0 估)");
root.querySelector(".se-x").onclick = close;
root.querySelector(".se-addtrack").onclick = addTrack;
root.querySelector(".se-preview-btn").onclick = doPreview;
root.addEventListener("mousedown", e => { if (e.target === root) close(); }); // 点遮罩空白关闭
lanesEl = root.querySelector(".se-lanes");
rulerWrapEl = root.querySelector(".se-ruler");
propEl = root.querySelector(".se-prop");
lintEl = root.querySelector(".se-lint");
previewEl = root.querySelector(".se-preview");
}
// ---------- 渲染 ----------
function render() {
const sc = scene(); if (!sc) { close(); return; }
if (!sc.tracks) sc.tracks = [];
const froms = moveFroms(sc), total = sceneEnd(sc, froms);
const W = Math.max(total, 4) * PX + 60;
root.querySelector(".se-total").textContent = "总时长 ≈ " + round1(total) + "s" + (sc.duration ? "(显式 " + sc.duration + "s" : "");
// 标尺
rulerWrapEl.innerHTML = ""; rulerWrapEl.style.width = W + "px";
for (let s = 0; s <= Math.ceil(total) + 1; s++) {
const t = document.createElement("div"); t.className = "se-tick"; t.style.left = (s * PX + 56) + "px"; t.innerHTML = "<span>" + s + "s</span>";
rulerWrapEl.appendChild(t);
}
// 轨道
lanesEl.innerHTML = ""; lanesEl.style.width = W + "px";
sc.tracks.forEach((tk, ti) => {
const lane = document.createElement("div"); lane.className = "se-lane"; lane.style.height = LANE_H + "px";
const isCam = tk.role === "camera";
const label = document.createElement("div"); label.className = "se-lane-label";
label.innerHTML = (isCam ? "🎬 镜头" : esc(actorName(tk.actor || "P1"))) +
'<button class="se-addclip" title="加 clip"></button>' +
'<button class="se-deltrack" title="删轨道">✕</button>';
label.querySelector(".se-addclip").onclick = e => { e.stopPropagation(); addClip(ti); };
label.querySelector(".se-deltrack").onclick = e => { e.stopPropagation(); delTrack(ti); };
lane.appendChild(label);
(tk.clips || []).forEach((c, ci) => lane.appendChild(clipEl(tk, ti, c, ci, froms)));
lanesEl.appendChild(lane);
});
if (!sc.tracks.length) lanesEl.innerHTML = '<div class="se-empty">空演出段——点「+ 添加轨道」开始编排</div>';
renderProp(); renderLint();
}
function clipEl(tk, ti, c, ci, froms) {
const dur = durOf(c, froms), el = document.createElement("div");
el.className = "se-clip k-" + (c.kind === "narration" ? "dialogue" : c.kind) + (sel && sel.ti === ti && sel.ci === ci ? " sel" : "");
el.style.left = ((c.start || 0) * PX + 56) + "px";
el.style.width = Math.max(14, dur * PX - 2) + "px";
el.textContent = clipLabel(c);
el.title = clipLabel(c) + "start " + round1(c.start || 0) + "s · " + round1(dur) + "s" + (c.dur ? " 显式" : " 自动") + "";
const handle = document.createElement("div"); handle.className = "se-resize"; el.appendChild(handle);
el.onmousedown = e => startDrag(e, tk, ti, c, ci, false);
handle.onmousedown = e => { e.stopPropagation(); startDrag(e, tk, ti, c, ci, true); };
return el;
}
function clipLabel(c) {
if (c.kind === "move") return "→ " + (c.to || "?");
if (c.kind === "dialogue" || c.kind === "narration") return (c.kind === "narration" ? "旁:" : "") + (c.text || "").slice(0, 12);
if (c.kind === "anim") return "♪ " + (c.ani || "?");
if (c.kind === "camera") return "🎬 " + (c.focus || "?");
if (c.kind === "wait") return "⏸ wait";
return c.kind;
}
// ---------- 拖拽:移动 start / 拉伸 dur ----------
function startDrag(e, tk, ti, c, ci, resize) {
if (e.button !== 0) return;
e.preventDefault();
const sc = scene(), froms = moveFroms(sc);
const startX = e.clientX, origStart = c.start || 0, origDur = durOf(c, froms);
let moved = false;
const onMove = ev => {
const dx = (ev.clientX - startX) / PX;
if (Math.abs(ev.clientX - startX) > 3) moved = true;
if (resize) { c.dur = Math.max(MIN_DUR, snap(origDur + dx)); }
else { c.start = snap(origStart + dx); }
render(); // 实时重画(含依赖时长/重叠 lint
};
const onUp = () => {
document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp);
if (moved) commit();
else { sel = { ti, ci }; render(); } // 没拖动=选中
};
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
}
// ---------- 增删 ----------
function addTrack() {
const sc = scene();
const choices = actorSlots().map(s => actorName(s) + "(" + s + ")").concat(["镜头轨"]);
const pick = prompt("添加轨道——输入序号:\n" + choices.map((c, i) => (i + 1) + ". " + c).join("\n"), "1");
if (pick == null) return;
const idx = parseInt(pick, 10) - 1;
if (isNaN(idx) || idx < 0 || idx >= choices.length) return;
if (idx === choices.length - 1) sc.tracks.push({ id: "tk_cam" + sc.tracks.length, role: "camera", clips: [] });
else { const slot = actorSlots()[idx]; sc.tracks.push({ id: "tk_" + slot.toLowerCase() + sc.tracks.length, actor: slot, clips: [] }); }
commit();
}
function delTrack(ti) {
const sc = scene();
if (!confirm("删除该轨道及其所有 clip")) return;
sc.tracks.splice(ti, 1); sel = null; commit();
}
function addClip(ti) {
const sc = scene(), tk = sc.tracks[ti], isCam = tk.role === "camera";
const kinds = isCam ? ["camera"] : ["move", "dialogue", "narration", "anim", "wait"];
let kind = kinds[0];
if (kinds.length > 1) {
const pick = prompt("加 clip 类型——输入序号:\n" + kinds.map((k, i) => (i + 1) + ". " + k).join("\n"), "1");
if (pick == null) return; const ki = parseInt(pick, 10) - 1;
if (isNaN(ki) || ki < 0 || ki >= kinds.length) return; kind = kinds[ki];
}
const froms = moveFroms(sc);
let start = 0; (tk.clips || []).forEach(c => { start = Math.max(start, (c.start || 0) + durOf(c, froms)); });
const c = { id: newClipId(sc), kind, start: round1(start) };
if (kind === "move") c.to = pointNames()[0] || "";
else if (kind === "dialogue" || kind === "narration") c.text = "新台词";
else if (kind === "anim") c.ani = "idle";
else if (kind === "camera") c.focus = pointNames()[0] || "";
else if (kind === "wait") c.dur = 1.0;
(tk.clips = tk.clips || []).push(c);
sel = { ti, ci: tk.clips.length - 1 };
commit();
}
function delClip() {
if (!sel) return; const sc = scene(); const tk = sc.tracks[sel.ti]; if (!tk) return;
tk.clips.splice(sel.ci, 1); sel = null; commit();
}
// ---------- 选中 clip 的属性条 ----------
function renderProp() {
propEl.innerHTML = "";
if (!sel) { propEl.innerHTML = '<div class="se-prop-empty">单击一个 clip 编辑其属性</div>'; return; }
const sc = scene(), tk = sc.tracks[sel.ti]; if (!tk || !tk.clips[sel.ci]) { sel = null; return renderProp(); }
const c = tk.clips[sel.ci];
const wrap = document.createElement("div"); wrap.className = "se-prop-row";
const add = (label, node) => { const f = document.createElement("label"); f.className = "se-f"; f.appendChild(document.createTextNode(label)); f.appendChild(node); wrap.appendChild(f); };
const numIn = (val, on) => { const i = document.createElement("input"); i.type = "number"; i.step = "0.1"; i.value = val == null ? "" : val; i.oninput = () => on(i.value === "" ? null : Number(i.value)); return i; };
const txtIn = (val, on) => { const i = document.createElement("input"); i.type = "text"; i.value = val == null ? "" : val; i.oninput = () => on(i.value); return i; };
const selIn = (val, opts, on) => { const s = document.createElement("select"); opts.forEach(o => { const op = document.createElement("option"); op.value = o; op.textContent = o; if (o === val) op.selected = true; s.appendChild(op); }); s.onchange = () => on(s.value); return s; };
wrap.appendChild(Object.assign(document.createElement("span"), { className: "se-prop-kind", textContent: "#" + c.id + " · " + c.kind }));
add("start(s)", numIn(round1(c.start || 0), v => { c.start = Math.max(0, v || 0); commit(); }));
const froms = moveFroms(sc), autoDur = durOf(c, froms);
add("dur(s)", numIn(c.dur != null ? c.dur : "", v => { if (v == null) delete c.dur; else c.dur = Math.max(MIN_DUR, v); commit(); }));
if (c.dur != null) { const b = document.createElement("button"); b.className = "mini"; b.textContent = "恢复自动(" + round1(autoDur) + "s)"; b.onclick = () => { delete c.dur; commit(); }; wrap.appendChild(b); }
if (c.kind === "move") {
add("目标点 to", selIn(c.to, pointNames(), v => { c.to = v; commit(); }));
add("速度 speed", numIn(c.speed != null ? c.speed : "", v => { if (v == null) delete c.speed; else c.speed = v; commit(); }));
} else if (c.kind === "dialogue" || c.kind === "narration") {
add("文本", txtIn(c.text, v => { c.text = v; commitLight(); }));
} else if (c.kind === "anim") {
add("动画 ani", txtIn(c.ani, v => { c.ani = v; commitLight(); }));
add("朝向 angle", numIn(c.angle != null ? c.angle : "", v => { if (v == null) delete c.angle; else c.angle = v; commit(); }));
} else if (c.kind === "camera") {
add("对焦 focus", selIn(c.focus, pointNames(), v => { c.focus = v; commit(); }));
}
const del = document.createElement("button"); del.className = "mini se-delclip"; del.textContent = "删除此 clip"; del.onclick = delClip;
wrap.appendChild(del);
propEl.appendChild(wrap);
}
// ---------- 客户端轻量校验(镜像 validate.py scene 规则,即时反馈)----------
function renderLint() {
const sc = scene(), froms = moveFroms(sc), issues = [];
const movesByActor = {};
(sc.tracks || []).forEach((tk, ti) => {
const isCam = tk.role === "camera", actor = tk.actor;
if (!isCam && !actor) issues.push("第" + (ti + 1) + "条轨道未指定 actor");
(tk.clips || []).forEach(c => {
if ((c.start || 0) < 0) issues.push("clip " + c.id + " start<0");
if ((c.kind === "dialogue" || c.kind === "narration") && !String(c.text || "").trim()) issues.push("clip " + c.id + " 文本为空");
if (c.kind === "move" && !c.to) issues.push("clip " + c.id + " 缺目标点");
if (c.kind === "camera" && !c.focus) issues.push("clip " + c.id + " 缺对焦点");
if (c.kind === "move" && !isCam && actor) (movesByActor[actor] = movesByActor[actor] || []).push(c);
});
});
Object.keys(movesByActor).forEach(a => {
const arr = movesByActor[a].slice().sort((x, y) => (x.start || 0) - (y.start || 0));
for (let i = 1; i < arr.length; i++) {
if ((arr[i - 1].start || 0) + durOf(arr[i - 1], froms) > (arr[i].start || 0) + 1e-6)
issues.push(actorName(a) + " 的 move " + arr[i - 1].id + " 与 " + arr[i].id + " 时间重叠");
}
});
if (!issues.length) { lintEl.className = "se-lint ok"; lintEl.textContent = "✓ 本段无明显问题"; }
else { lintEl.className = "se-lint bad"; lintEl.innerHTML = "⚠ " + issues.map(esc).join(" · "); }
}
// ---------- 提交 / 预览 ----------
function commit() { if (ctx && ctx.onChange) ctx.onChange(false); render(); }
function commitLight() { if (ctx && ctx.onChange) ctx.onChange(false); renderLint();
// 文本编辑时不整体 render避免输入框失焦仅刷新本 clip 标签宽度与 lint
const sc = scene(), froms = moveFroms(sc);
const lane = lanesEl.children[sel.ti]; if (lane) { const cl = lane.querySelectorAll(".se-clip")[sel.ci]; if (cl) cl.firstChild.textContent = clipLabel(sc.tracks[sel.ti].clips[sel.ci]); }
}
function doPreview() {
previewEl.innerHTML = '<div class="se-prev-head">▶ 白模预览(从本演出段开始)</div><div class="se-prev-host"></div>';
const host = previewEl.querySelector(".se-prev-host");
try { window.Timeline.show(host, ir, dict, pointsets, sceneId); }
catch (e) { host.textContent = "预览失败:" + e.message; }
}
window.SceneEdit = { open, close };
})();

View File

@ -325,3 +325,62 @@ body.perform-mode .mode-switch { border-color:#2f7a60; }
.tl-clip.k-stop { background:#d87878; color:#fff; } .tl-clip.k-stop { background:#d87878; color:#fff; }
.tl-playhead { position:absolute; top:0; bottom:0; width:2px; background:#ff5a4a; .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); } pointer-events:none; z-index:4; box-shadow:0 0 4px rgba(255,90,74,.7); }
/* ===== 演出段 scene 节点(分支图)===== */
#drawflow .kind-scene { background:#16241f; border-color:#3a7a64; }
#drawflow .kind-scene .nlabel { color:#7ee3c8; }
.drawflow-node .scenebody { padding-top:2px; }
.drawflow-node .scenebody .screl { font-size:11px; color:#bfe6d8; line-height:1.5; }
.drawflow-node .scenebody .screl.more { color:#7a8a82; }
.drawflow-node .scenebody .schint { font-size:10px; color:#5a8a7a; margin-top:3px; font-style:italic; }
/* ===== 演出段编辑模态 ===== */
.scene-edit { width:92vw; max-width:1280px; max-height:92vh; }
.se-psinfo { font-size:12px; color:#9a907e; font-weight:normal; }
.se-toolbar { display:flex; align-items:center; gap:10px; margin:4px 0 8px; flex-wrap:wrap; }
.se-toolbar .se-tip { font-size:11px; color:#8a8275; }
.se-toolbar .se-total { margin-left:auto; font-size:12px; color:#e6c878; }
.se-trackswrap { overflow-x:auto; background:#15130d; border:1px solid #2a2419; border-radius:6px; padding-bottom:4px; }
.se-ruler { position:relative; height:16px; border-bottom:1px solid #2a2419; min-width:100%; }
.se-tick { position:absolute; top:0; height:16px; border-left:1px solid #2a2419; }
.se-tick span { font-size:9px; color:#6a6256; padding-left:3px; }
.se-lanes { position:relative; min-width:100%; }
.se-lane { position:relative; border-bottom:1px solid #211c15; }
.se-lane-label { position:sticky; left:0; z-index:5; display:inline-flex; align-items:center; gap:3px;
width:54px; height:100%; background:#1d1810; color:#d8cda0; font-size:11px; padding:0 3px;
border-right:1px solid #2a2419; box-sizing:border-box; }
.se-lane-label button { background:none; border:none; color:#9a8f78; cursor:pointer; font-size:11px; padding:0 1px; }
.se-lane-label .se-addclip:hover { color:#7ee3c8; }
.se-lane-label .se-deltrack:hover { color:#e38f7e; }
.se-clip { position:absolute; top:5px; height:24px; line-height:24px; padding:0 6px; border-radius:4px;
font-size:11px; color:#1a1710; white-space:nowrap; overflow:hidden; cursor:grab; user-select:none;
box-shadow:0 1px 3px rgba(0,0,0,.5); }
.se-clip.sel { box-shadow:0 0 0 2px #ff5a4a, 0 1px 3px rgba(0,0,0,.5); z-index:3; }
.se-clip.k-dialogue { background:#7ec8e3; }
.se-clip.k-move { background:#e6c878; }
.se-clip.k-anim { background:#9ee37e; }
.se-clip.k-camera { background:#c89ee3; }
.se-clip.k-wait { background:#9a9488; color:#fff; }
.se-resize { position:absolute; top:0; right:0; width:7px; height:100%; cursor:ew-resize; background:rgba(0,0,0,.18); border-radius:0 4px 4px 0; }
.se-empty, .se-prop-empty { padding:14px; color:#8a8275; font-size:12px; text-align:center; }
.se-lint { margin:8px 0 4px; font-size:11.5px; padding:5px 8px; border-radius:5px; }
.se-lint.ok { color:#7ee3a0; background:rgba(60,120,80,.12); }
.se-lint.bad { color:#f0b070; background:rgba(160,90,40,.14); }
.se-prop { background:#19150f; border:1px solid #2a2419; border-radius:6px; padding:8px; min-height:40px; }
.se-prop-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.se-prop-kind { font-size:12px; color:#e6c878; font-weight:bold; }
.se-f { font-size:11px; color:#a89e88; display:flex; align-items:center; gap:4px; }
.se-f input, .se-f select { width:auto; min-width:64px; background:#221d16; color:#e8e0d4; border:1px solid #3a3326; border-radius:4px; padding:2px 4px; font-size:12px; }
.se-f input[type=number] { width:64px; }
.se-delclip { margin-left:auto; color:#e38f7e !important; }
.se-preview { margin-top:10px; }
.se-prev-head { font-size:12px; color:#7ee3c8; margin-bottom:4px; }
.se-prev-host { height:560px; background:#100e09; border:1px solid #2a2419; border-radius:6px; overflow:hidden; position:relative; }
/* scene 右栏 clip 行form.js 精确数值编辑) */
.se-cliprow { display:flex; align-items:flex-end; gap:5px; flex-wrap:wrap; padding:5px 0; border-top:1px dashed #2a2419; }
.se-cliprow .fld { margin:0; }
.se-cliprow .fld label { font-size:10px; }
.se-cliprow input[type=number] { width:52px; }
.se-cliprow select { min-width:60px; }
#edit-pane .optdet-like { border:1px solid #2a2419; border-radius:5px; padding:6px; margin-bottom:6px; }

View File

@ -5,9 +5,9 @@
// 暴露 window.Timeline = { show(host, ir, dict, pointsets), stop() }。 // 暴露 window.Timeline = { show(host, ir, dict, pointsets), stop() }。
(function () { (function () {
// ---- 时长模型 ---- // ---- 时长模型(常量与 ir_core/validate.py 顶部共享口径,避免预览与校验/真机漂移)----
const CHAR_TIME = 0.07, TAIL_PAUSE = 0.9, MIN_DLG = 1.2; const CHAR_TIME = 0.07, TAIL_PAUSE = 0.9, MIN_DLG = 1.2;
const MOVE_SPEED = 3.0, ANIM_DUR = 1.0; const MOVE_SPEED = 3.0, ANIM_DUR = 1.0, CAMERA_DUR = 2.0;
const PXMAX = 80, ROW_H = 30; // PXMAX=每秒最大像素;实际用动态 PX可适应宽度 const PXMAX = 80, ROW_H = 30; // PXMAX=每秒最大像素;实际用动态 PX可适应宽度
const CAM_W = 14, CAM_H = 9; const CAM_W = 14, CAM_H = 9;
@ -29,7 +29,13 @@
function collectAllRefs(IR) { function collectAllRefs(IR) {
const refs = [], add = n => { if (n && !refs.includes(n)) refs.push(n); }; const refs = [], add = n => { if (n && !refs.includes(n)) refs.push(n); };
add("P1"); (IR.roles || []).forEach(r => add(r.slot)); 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); }); 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);
if (n.kind === "scene") (n.tracks || []).forEach(tk => {
add(tk.actor);
(tk.clips || []).forEach(c => { if (c.kind === "move") add(c.to); if (c.kind === "camera") add(c.focus); });
});
});
scan(IR.nodes); (IR.sequences || []).forEach(s => scan(s.nodes)); scan(IR.nodes); (IR.sequences || []).forEach(s => scan(s.nodes));
return refs; return refs;
} }
@ -72,7 +78,10 @@
// 在场角色:全事件里当过 speaker/actor 的 slot含 P1。舞台始终按位置画出他们 // 在场角色:全事件里当过 speaker/actor 的 slot含 P1。舞台始终按位置画出他们
// 这样从中途节点开始也能看到各角色"在该处应有的位置"。 // 这样从中途节点开始也能看到各角色"在该处应有的位置"。
const used = [], addU = s => { if (s && !used.includes(s)) used.push(s); }; const used = [], addU = s => { if (s && !used.includes(s)) used.push(s); };
const scanU = arr => (arr || []).forEach(n => { addU(n.speaker); addU(n.actor); }); const scanU = arr => (arr || []).forEach(n => {
addU(n.speaker); addU(n.actor);
if (n.kind === "scene") (n.tracks || []).forEach(tk => { if (tk.role !== "camera") addU(tk.actor || "P1"); });
});
scanU(IR.nodes); (IR.sequences || []).forEach(s => scanU(s.nodes)); scanU(IR.nodes); (IR.sequences || []).forEach(s => scanU(s.nodes));
const ordU = []; const ordU = [];
if (used.includes("P1")) ordU.push("P1"); if (used.includes("P1")) ordU.push("P1");
@ -112,9 +121,21 @@
function replayPositions(S, path) { function replayPositions(S, path) {
const cur = {}; 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 }; } }; 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 }; } };
// scene各 actor 取其(跨轨)最后一段 move 的目标点作为离场位置
const applyScene = n => {
const mv = {};
(n.tracks || []).forEach(tk => { if (tk.role === "camera") return; const a = tk.actor || "P1";
(tk.clips || []).forEach(c => { if (c.kind === "move") (mv[a] = mv[a] || []).push(c); }); });
Object.keys(mv).forEach(a => {
mv[a].sort((x, y) => (x.start || 0) - (y.start || 0));
const last = mv[a][mv[a].length - 1], to = last && S.posMap[last.to];
if (to) cur[a] = { x: to.x, z: to.z };
});
};
for (let i = 0; i < path.length - 1; i++) { for (let i = 0; i < path.length - 1; i++) {
const n = S.nodes[path[i]]; if (!n) continue; const n = S.nodes[path[i]]; if (!n) continue;
if (n.kind === "out_ref") { if (n.kind === "scene") applyScene(n);
else if (n.kind === "out_ref") {
const sq = S.seqMap[n.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; } } 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); } else applyMove(n);
@ -148,6 +169,68 @@
} }
} }
// clip 时长派生(与 ir_core/validate.py 口径一致):显式 dur > 按 kind 派生。
function clipDur(S, c, actor, fromPos) {
if (typeof c.dur === "number" && c.dur > 0) return c.dur;
const k = c.kind;
if (k === "dialogue" || k === "narration") return dlgDur(c.text);
if (k === "anim") return ANIM_DUR;
if (k === "camera") return CAMERA_DUR;
if (k === "move") {
const from = fromPos || posOf(S, actor), to = S.posMap[c.to] || from;
const dist = Math.hypot(to.x - from.x, to.z - from.z);
return Math.max(0.3, dist / (c.speed || MOVE_SPEED));
}
if (k === "wait") return 0.5;
return 0.4;
}
// scene 演出段:把多轨 clips 按 authored start相对 S._t 偏移)铺进 S.clips自然支持重叠。
// move 的 from = 同 actor跨轨上一段 move 的 to无则取进入 scene 时的位置/初始锚点D4
function appendScene(S, n) {
const base = S._t, tracks = n.tracks || [];
// 1) 先按 actor 续连各 move 的 from跨轨合并、按 start 排序)
const movesByActor = {};
tracks.forEach(tk => { if (tk.role === "camera") return; const actor = tk.actor || "P1";
(tk.clips || []).forEach(c => { if (c.kind === "move") (movesByActor[actor] = movesByActor[actor] || []).push(c); }); });
const moveFrom = new Map();
Object.keys(movesByActor).forEach(actor => {
let prev = posOf(S, actor);
movesByActor[actor].slice().sort((a, b) => (a.start || 0) - (b.start || 0)).forEach(c => {
moveFrom.set(c, prev);
const to = S.posMap[c.to] || prev; prev = { x: to.x, z: to.z };
});
S.curPos[actor] = prev; // scene 结束后该 actor 的位置
});
// 2) 逐轨逐 clip 生成可视 clipstart 偏移 base
let sceneEnd = 0;
tracks.forEach(tk => {
const isCam = tk.role === "camera", actor = isCam ? null : (tk.actor || "P1");
(tk.clips || []).forEach(c => {
const start = base + (c.start || 0), dur = clipDur(S, c, actor, moveFrom.get(c));
sceneEnd = Math.max(sceneEnd, (c.start || 0) + dur);
const k = c.kind;
if (k === "dialogue" || k === "narration") {
S.clips.push({ row: "演员:" + actor, kind: "dialogue", start, dur, label: esc(c.text || ""), actor, text: c.text || "", nodeId: n.id });
useRow(S, "演员:" + actor);
} else if (k === "move") {
const from = moveFrom.get(c) || posOf(S, actor), to = S.posMap[c.to] || from;
S.clips.push({ row: "演员:" + actor, kind: "move", start, dur, label: "→ " + (c.to || ""), actor, from, to, nodeId: n.id });
useRow(S, "演员:" + actor);
} else if (k === "anim") {
S.clips.push({ row: "演员:" + actor, kind: "anim", start, dur, label: "动画 " + (c.ani || ""), actor, nodeId: n.id });
useRow(S, "演员:" + actor);
} else if (k === "camera") {
S.clips.push({ row: "镜头", kind: "camera", start, dur, label: "对焦 " + (c.focus || ""), focus: c.focus, nodeId: n.id });
useRow(S, "镜头");
}
// wait仅占位只计入时长不产可视 clip
});
});
const total = (typeof n.duration === "number" && n.duration > 0) ? Math.max(n.duration, sceneEnd) : sceneEnd;
S._t = base + Math.max(total, 0.1);
}
// 从 startId 走一段线性演出,遇分支/结局/断头停下。返回 {kind, node}。 // 从 startId 走一段线性演出,遇分支/结局/断头停下。返回 {kind, node}。
function extendSegment(S, startId) { function extendSegment(S, startId) {
let id = startId, guard = 0; let id = startId, guard = 0;
@ -165,6 +248,7 @@
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; 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 }; return { kind: k, node: n };
} }
if (k === "scene") { appendScene(S, n); id = n.next; continue; }
if (k === "out_ref") { if (k === "out_ref") {
const sq = S.seqMap[n.ref]; const sq = S.seqMap[n.ref];
if (sq && (sq.nodes || []).length) { if (sq && (sq.nodes || []).length) {
@ -237,7 +321,7 @@
'<div class="tl-resizer" title="拖动调整时间轴高度"></div>' + '<div class="tl-resizer" title="拖动调整时间轴高度"></div>' +
'<div class="tl-timelinepanel"><div class="tl-tracks"></div></div>'; '<div class="tl-timelinepanel"><div class="tl-tracks"></div></div>';
function show(host, IR, DICT, POINTSETS) { function show(host, IR, DICT, POINTSETS, startId) {
stopPlay(); stopPlay();
const psName = (IR.stage || {}).point_set || IR.id; const psName = (IR.stage || {}).point_set || IR.id;
const ps = (POINTSETS || {})[psName] || {}; const ps = (POINTSETS || {})[psName] || {};
@ -281,7 +365,7 @@
document.addEventListener("mousemove", mv); document.addEventListener("mouseup", up); document.addEventListener("mousemove", mv); document.addEventListener("mouseup", up);
}; };
startFrom(firstNode(IR)); startFrom(startId && S.nodes[startId] ? startId : firstNode(IR));
} }
function selectClip(c, el) { function selectClip(c, el) {
@ -305,6 +389,7 @@
if (n.kind === "fight") return "【战斗】vs " + (n.camp2 || []).map(nm).join("、"); if (n.kind === "fight") return "【战斗】vs " + (n.camp2 || []).map(nm).join("、");
if (n.kind === "reward") return "奖励结算"; if (n.kind === "reward") return "奖励结算";
if (n.kind === "out_ref") return "引用·" + n.ref; if (n.kind === "out_ref") return "引用·" + n.ref;
if (n.kind === "scene") return "演出段·" + (n.tracks || []).length + "轨";
return n.kind; return n.kind;
} }
// 从指定节点开始:先重放途中走位算进入位置,再从该节点构建演出。 // 从指定节点开始:先重放途中走位算进入位置,再从该节点构建演出。
@ -398,18 +483,19 @@
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.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(); 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 => { actorsIn(model).forEach(actor => {
const wp = actorPosAt(model, actor, tau), p = worldToStage(wp), col = colorOf(model, 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 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); const anim = activeAnim(model, actor, tau);
// 每个 actor 独立查自己的对话 clip → 重叠演出时多人气泡同时计时呈现
const dlg = model.clips.find(c => c.kind === "dialogue" && c.actor === actor && tau >= c.start && tau < c.start + c.dur);
ctx.fillStyle = col; ctx.beginPath(); ctx.arc(p.x, p.y, moving ? 9 : 8, 0, Math.PI * 2); ctx.fill(); 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.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 = "#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); 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 (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 (anim) { ctx.fillStyle = col; ctx.font = "9px sans-serif"; ctx.fillText("♪" + (anim.label || ""), p.x, p.y - 12); }
if (dlg && dlg.actor === actor) { if (dlg) {
const typed = (dlg.text || "").slice(0, Math.floor((tau - dlg.start) / CHAR_TIME)); const typed = (dlg.text || "").slice(0, Math.floor((tau - dlg.start) / CHAR_TIME));
drawBubble(ctx, p.x, p.y - 26, model.nm(actor) + "" + typed); drawBubble(ctx, p.x, p.y - 26, model.nm(actor) + "" + typed);
} }
@ -534,8 +620,24 @@
return s; return s;
} }
// 纯函数版 clip 时长scene 编辑器复用单一口径posMap={点位名:{x,z}}fromPos=move 起点。
function clipDurPure(c, posMap, fromPos) {
if (typeof c.dur === "number" && c.dur > 0) return c.dur;
const k = c.kind;
if (k === "dialogue" || k === "narration") return dlgDur(c.text);
if (k === "anim") return ANIM_DUR;
if (k === "camera") return CAMERA_DUR;
if (k === "move") {
const from = fromPos || { x: 0, z: 0 }, to = (posMap && posMap[c.to]) || from;
return Math.max(0.3, Math.hypot(to.x - from.x, to.z - from.z) / (c.speed || MOVE_SPEED));
}
if (k === "wait") return 0.5;
return 0.4;
}
window.Timeline = { window.Timeline = {
show, stop: stopPlay, clear, show, stop: stopPlay, clear,
_clipDur: clipDurPure, // scene 编辑器复用
// 离线测试用: // 离线测试用:
_buildModel: buildModelAuto, _dlgDur: dlgDur, _buildModel: buildModelAuto, _dlgDur: dlgDur,
_prepare: prepare, _extend: extendSegment, _runUntilPause: runUntilPause, _firstNode: firstNode, _orderRows: orderRows, _prepare: prepare, _extend: extendSegment, _runUntilPause: runUntilPause, _firstNode: firstNode, _orderRows: orderRows,