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

View File

@ -236,6 +236,11 @@ def compile_ir(ir, dic):
r["resultRewardIds"] = rid_str
if 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:
raise CompileError("未知节点 kind: %r (节点 %s)" % (kind, tid))
@ -260,6 +265,11 @@ def extract_texts(ir):
add(n.get("text"))
for o in n.get("options", []):
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", []))
for s in ir.get("sequences", []):

View File

@ -12,6 +12,12 @@ from .dictionary import CompileError
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 级到项目根
_POINTS_DIR = os.path.join(
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", [])}
all_ids = set(nodes) | set(endings)
slots = {r["slot"] for r in ir.get("roles", [])}
anchors = _load_anchors(ir, points_dir) # {点位名:(x,z)}无点位集时为空move 重叠检跳过)
def check_target(src, tid, field):
if tid not in all_ids:
errs.append("[跳转失效] 节点 %s%s 指向不存在的目标 %r" % (src, field, tid))
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))
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")
if kind == "reward":
check_grants(nid, n.get("grants"))
if kind == "scene":
_check_scene(nid, n, slots, anchors, errs)
for e in ir.get("endings", []):
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 []):
if s and s != "P1":
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
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):
import json
name = (ir.get("stage") or {}).get("point_set") or ir["id"]