Files
story-edit-web/ir_core/validate.py
邓雨鹏 021080dd56 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 + 白模重叠演出。
2026-06-13 22:34:29 +08:00

298 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""Story IR 配置期校验。挡掉调研发现的卡流程 bug校验不过不产出任何配置。
返回 (errors, warnings)errors 非空即拒绝编译warnings 仅提示(如点位集缺失)。
M4 新增out_ref 结构校验、选项 skip 校验、点位集坐标引用校验。
"""
import os
import re
from .compile import expand_out_refs
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__))))),
"Assets", "StreamingAssets", "Story", "PointSets")
def validate(ir, dic, points_dir=None, strict_points=False):
errs, warns = [], []
# ---- out_ref 结构预检(在展开前;展开依赖此处通过)----
seqs = {s["id"]: s for s in ir.get("sequences", [])}
for n in ir.get("nodes", []):
if n.get("kind") == "out_ref":
ref = n.get("ref")
if ref not in seqs:
errs.append("[out_ref失效] 节点 %s 引用了不存在的子序列 %r" % (n["id"], ref))
elif not seqs[ref].get("nodes"):
errs.append("[空子序列] 子序列 %r 没有任何节点(被 %s 引用)" % (ref, n["id"]))
for sn in seqs.get(ref, {}).get("nodes", []):
if sn.get("kind") == "out_ref":
errs.append("[嵌套out_ref] 子序列 %r 节点 %s 含嵌套 out_refM4 暂不支持"
% (ref, sn["id"]))
if errs:
return errs, warns # 结构坏了就别展开了,避免 KeyError 噪声
# ---- 展开 out_ref 后做节点级校验goto/win/lose/skip 目标含子序列内 id----
try:
nodes_list = expand_out_refs(ir)
except CompileError as e:
return ["[展开失败] %s" % e], warns
nodes = {n["id"]: n for n in nodes_list}
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):
# 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):
for g in grants or []:
k = g.get("kind")
if not dic.is_known_grant(k):
errs.append("[未知奖励] 节点 %s 出现未登记的 grant kind %r" % (src, k)); continue
if k == "道具" and not ITEM_RE.match(str(g.get("item", ""))):
errs.append("[非法道具ID] 节点 %s 的道具 %r 不是合法 ID" % (src, g.get("item")))
spec = dic.grant_spec(k)
if spec.get("form") in ("money", "item", "friend") and \
not isinstance(g.get("value"), (int, float)):
errs.append("[非法数值] 节点 %s 的奖励数值 %r 不是数字" % (src, g.get("value")))
if dic.grant_needs_target(k):
check_slot(src, g.get("target"), "奖励target")
def check_cond(src, c):
if not c:
return
if not dic.is_known_cond(c.get("kind")):
errs.append("[非法条件] 节点 %s 的 condition %r 无法编译" % (src, c)); return
spec = dic.cond_spec(c.get("kind"))
if c.get("op") not in spec.get("ops", {}):
errs.append("[非法条件] 节点 %s 的 condition %r 比较符不支持" % (src, c))
elif not isinstance(c.get("value"), (int, float)):
errs.append("[非法条件数值] 节点 %s 的 condition 阈值 %r 不是数字" % (src, c.get("value")))
for nid, n in nodes.items():
kind = n.get("kind")
if n.get("next"):
check_target(nid, n["next"], "next")
if n.get("speaker"):
check_slot(nid, n["speaker"], "speaker")
if n.get("actor"):
check_slot(nid, n["actor"], "actor")
if kind in ("choice", "choice_once"):
opts = n.get("options", [])
if not opts:
errs.append("[空选择] 节点 %s 没有任何选项" % nid)
if not any("condition" not in o for o in opts):
errs.append("[选项无兜底] 节点 %s 的所有选项都带条件,可能全部不满足导致空面板卡死" % nid)
for o in opts:
check_target(nid, o.get("goto"), "option.goto")
check_cond(nid, o.get("condition"))
if o.get("reward"):
check_grants(nid, o["reward"].get("grants"))
if o.get("skip"):
sk = o["skip"]
check_target(nid, sk.get("node"), "option.skip.node")
grants = (sk.get("reward") or {}).get("grants")
if not grants:
errs.append("[空skip奖励] 节点 %s 选项 skip 缺 reward.grants押注无彩头" % nid)
check_grants(nid, grants)
if kind == "random":
for b in n.get("branches", []):
check_target(nid, b.get("goto"), "branch.goto")
if not isinstance(b.get("weight"), (int, float)):
errs.append("[非法权重] 节点 %s 的随机权重 %r 不是数字" % (nid, b.get("weight")))
if kind == "fight":
if n.get("fight_type") not in (1, 2):
errs.append("[非法战斗类型] 节点 %s 的 fight_type 必须是 1(击倒)/2(死斗)" % nid)
if not n.get("camp2"):
errs.append("[战斗缺敌方] 节点 %s 的 camp2 为空" % nid)
for s in (n.get("camp2") or []) + (n.get("camp1") or []):
check_slot(nid, s, "fight阵营")
check_target(nid, n.get("win"), "fight.win")
check_target(nid, n.get("lose"), "fight.lose")
if kind == "move":
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"))
# ---- 点位集坐标引用校验 ----
_check_point_set(ir, errs, warns, points_dir, strict_points)
return errs, warns
def collect_point_refs(ir):
"""收集 IR 里**实际需要物理站位**的点位名(角色出生点/移动目标/镜头点/战斗阵营)。
仅声明、从未在节点出现的 role如未登场的旁系角色不计——运行时不为其摆人。
"""
refs = set()
all_nodes = list(ir.get("nodes", []))
for s in ir.get("sequences", []):
all_nodes += s.get("nodes", [])
for n in all_nodes:
for f in ("speaker", "actor"):
v = n.get(f)
if v and v != "P1":
refs.add(v)
if n.get("kind") == "move" and n.get("to") and n["to"] != "P1":
refs.add(n["to"])
if n.get("camera"):
refs.add(n["camera"])
if n.get("kind") == "fight":
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"]
base = points_dir or _POINTS_DIR
path = os.path.join(base, name + ".points.json")
refs = collect_point_refs(ir)
if not os.path.exists(path):
msg = "[点位集缺失] 未找到 %s.points.json%s);坐标校验跳过" % (name, path)
if strict_points:
errs.append(msg + "--strict-points 下视为错)")
else:
warns.append(msg)
return
try:
with open(path, encoding="utf-8") as f:
ps = json.load(f)
except Exception as e:
errs.append("[点位集损坏] %s 解析失败: %s" % (path, e)); return
have = {p.get("name") for p in ps.get("points", [])}
for r in sorted(refs):
if r not in have:
errs.append("[点位缺失] 点位 %r 在点位集 %s.points.json 中不存在(需取点工具补录)"
% (r, name))