剧情 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 + 白模重叠演出。
298 lines
14 KiB
Python
298 lines
14 KiB
Python
# -*- 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_ref,M4 暂不支持"
|
||
% (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 %s:move %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))
|