Files
story-edit-web/ir_core/compile.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

280 lines
11 KiB
Python
Raw Permalink 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 -> 扁平 List[GameEventData] 编译器。
产出 events.json绕过 StorylineMgr.ReplaceStoryEventData自己做 step/前缀/展开)。
M4 新增out_ref 子序列复用(编译前预展开)、选项 skip押注跳过→直接结算
"""
from .dictionary import CompileError
OUT_REF_SEP = "__" # out_ref 展开前缀分隔符(与节点 id 内的下划线区分)
# ============ out_ref 预展开 ============
def expand_out_refs(ir):
"""把 out_ref 节点摊平成普通节点,返回新的 nodes 列表(不改原 ir
对齐 StorylineMgr.ReplaceStoryEventData 的语义:
- 子序列每个节点 id 加 {refId}__ 前缀;内部跳转同步加前缀;
- 子序列出口next 为空的尾节点)的 next 接到 out_ref 节点的 next
- 引用该 out_ref 节点的跳转改写为子序列入口 {refId}__{entry}
多个 out_ref 引用同一子序列 → 前缀不同 → id 天然不撞(复用本意)。
M4 仅支持一层(子序列内不得再含 out_ref
"""
seqs = {s["id"]: s for s in ir.get("sequences", [])}
src_nodes = ir.get("nodes", [])
out_refs = [n for n in src_nodes if n.get("kind") == "out_ref"]
if not out_refs:
return list(src_nodes)
expanded = [] # 展开出来的子序列节点
ref_entry = {} # refId -> 子序列入口节点 id含前缀
for r in out_refs:
rid, ref = r["id"], r.get("ref")
seq = seqs.get(ref)
if seq is None:
raise CompileError("out_ref 节点 %s 引用了不存在的子序列 %r" % (rid, ref))
sub = seq.get("nodes", [])
if not sub:
raise CompileError("子序列 %r 为空(被 %s 引用)" % (ref, rid))
sub_ids = {n["id"] for n in sub}
prefix = rid + OUT_REF_SEP
ref_entry[rid] = prefix + sub[0]["id"]
def remap(t, is_next):
"""重写子序列内部跳转目标。"""
if t in sub_ids:
return prefix + t
if not t: # 空 next = 子序列出口 -> 接回 caller
return r.get("next", "") if is_next else t
return t # 指向子序列外(共享结局等)保持原样
for n in sub:
if n.get("kind") == "out_ref":
raise CompileError("子序列 %r 含嵌套 out_ref节点 %sM4 暂不支持" % (ref, n["id"]))
c = dict(n)
c["id"] = prefix + n["id"]
if "next" in c:
c["next"] = remap(c.get("next"), True)
elif n.get("kind") not in ("choice", "choice_once", "random", "fight"):
# 链尾叶子(无 next 字段)-> 出口接回 caller.next
c["next"] = r.get("next", "")
if "options" in c:
c["options"] = [_remap_opt(o, remap) for o in c["options"]]
if "branches" in c:
c["branches"] = [dict(b, goto=remap(b.get("goto"), False)) for b in c["branches"]]
if c.get("kind") == "fight":
c["win"] = remap(c.get("win"), False)
c["lose"] = remap(c.get("lose"), False)
expanded.append(c)
# 主图节点:去掉 out_ref 占位,并把指向它们的跳转改写到子序列入口
def deref(t):
return ref_entry.get(t, t)
result = []
for n in src_nodes:
if n.get("kind") == "out_ref":
continue
c = dict(n)
if "next" in c:
c["next"] = deref(c.get("next"))
if "options" in c:
c["options"] = [dict(o, goto=deref(o.get("goto"))) for o in c["options"]]
if "branches" in c:
c["branches"] = [dict(b, goto=deref(b.get("goto"))) for b in c["branches"]]
if c.get("kind") == "fight":
c["win"] = deref(c.get("win"))
c["lose"] = deref(c.get("lose"))
result.append(c)
return result + expanded
def _remap_opt(o, remap):
c = dict(o)
c["goto"] = remap(o.get("goto"), False)
if o.get("skip"):
sk = dict(o["skip"])
sk["node"] = remap(o["skip"].get("node"), False)
c["skip"] = sk
return c
# ============ 编译 ============
def compile_ir(ir, dic):
group = ir["id"]
src_nodes = expand_out_refs(ir)
nodes = {n["id"]: n for n in src_nodes}
endings = {e["id"]: e for e in ir.get("endings", [])}
def entry(tid):
"""目标节点的入口行 id含 group 前缀)。"""
n = nodes.get(tid)
if n and n.get("kind") in ("choice", "choice_once", "random"):
return "%s_%s_o0" % (group, tid)
return "%s_%s" % (group, tid)
def children(tid):
n = nodes.get(tid)
if not n:
return []
res = []
if n.get("next"):
res.append(n["next"])
res += [o["goto"] for o in n.get("options", [])]
res += [b["goto"] for b in n.get("branches", [])]
if n.get("kind") == "fight":
res += [n.get("win"), n.get("lose")]
return [r for r in res if r]
indeg = {i: 0 for i in list(nodes) + list(endings)}
for nid in nodes:
for c in children(nid):
if c in indeg:
indeg[c] += 1
roots = [i for i in nodes if indeg[i] == 0] or list(nodes)[:1]
order, seen = [], set()
def dfs(tid):
if tid in seen or tid not in indeg:
return
seen.add(tid)
order.append(tid)
for c in children(tid):
dfs(c)
for r in roots:
dfs(r)
for i in list(nodes) + list(endings):
if i not in seen:
order.append(i); seen.add(i)
rows, step = [], 0
def newrow(**kw):
nonlocal step
step += 1
row = {"group": group, "step": step, "type": 0}
row.update(kw)
rows.append(row)
return row
for tid in order:
if tid in endings:
e = endings[tid]
rid_str, rac = dic.compile_grants(e.get("grants"))
status = {"success": 1, "fail": 2, "end": 3}.get(e.get("result", "success"))
if status is None:
raise CompileError("结局 %s 的 result 非法: %r(仅 success/fail/end"
% (tid, e.get("result")))
end_id = "%s_%s__end" % (group, tid)
r = newrow(id="%s_%s" % (group, tid), missionText=e.get("summary", ""),
nextStepId=end_id)
if rid_str:
r["resultRewardIds"] = rid_str
if rac:
r["roleActionCode"] = rac
newrow(id=end_id, gameTaskStatus=status)
continue
n = nodes[tid]
kind = n["kind"]
if kind == "narration":
spk = n.get("speaker", "P1")
newrow(id="%s_%s" % (group, tid), points=[spk], content=n.get("text", ""),
nextStepId=entry(n["next"]) if n.get("next") else "")
elif kind == "dialogue":
newrow(id="%s_%s" % (group, tid), points=[n["speaker"]],
content=n.get("text", ""),
nextStepId=entry(n["next"]) if n.get("next") else "")
elif kind in ("choice", "choice_once"):
opt_type = 1 if kind == "choice" else 2
for i, o in enumerate(n.get("options", [])):
rid_str, rac = dic.compile_grants((o.get("reward") or {}).get("grants"))
r = newrow(id="%s_%s_o%d" % (group, tid, i), type=opt_type,
choose=o.get("text", ""), points=["P1"],
nextStepId=entry(o["goto"]))
if o.get("condition"):
r["condition"] = dic.compile_cond(o["condition"])
if rid_str:
r["resultRewardIds"] = rid_str
if rac:
r["roleActionCode"] = rac
# skip押注跳过——选该项时把彩头注入 skip.node 行runtime 自拼 {group}_
if o.get("skip"):
sk = o["skip"]
sk_str, _ = dic.compile_grants((sk.get("reward") or {}).get("grants"))
r["skipNodeId"] = sk["node"]
r["skipReward"] = sk_str
elif kind == "random":
for i, b in enumerate(n.get("branches", [])):
newrow(id="%s_%s_o%d" % (group, tid, i), type=3,
weight=b.get("weight", 1), nextStepId=entry(b["goto"]))
elif kind == "fight":
newrow(id="%s_%s" % (group, tid),
points=n.get("camp1") or ["P1"],
camp2Fighters=n.get("camp2", []),
fightStatus=[str(n["fight_type"]), entry(n["win"]), entry(n["lose"])])
elif kind == "move":
if n.get("mode") == "teleport":
ms = ["1"]
elif n.get("mode") == "remove":
ms = ["2"]
else:
ms = [str(n.get("speed", 6)), n.get("ani", "")]
newrow(id="%s_%s" % (group, tid), points=[n["actor"]],
movePoint=[n["to"]], moveStatus=ms,
nextStepId=entry(n["next"]) if n.get("next") else "")
elif kind == "anim":
anis = [n["ani"]] + ([str(n["angle"])] if n.get("angle") is not None else [])
newrow(id="%s_%s" % (group, tid), points=[n["actor"]], anis=anis,
nextStepId=entry(n["next"]) if n.get("next") else "")
elif kind == "reward":
rid_str, rac = dic.compile_grants(n.get("grants"))
r = newrow(id="%s_%s" % (group, tid),
nextStepId=entry(n["next"]) if n.get("next") else "")
if rid_str:
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))
ids = [r["id"] for r in rows]
dup = {x for x in ids if ids.count(x) > 1}
if dup:
raise CompileError("行 id 重复: %s" % dup)
return rows
def extract_texts(ir):
"""抽取所有玩家可见文本(去重保序),供本地化翻译。
含 sequences 子序列内文本。"""
texts, seen = [], set()
def add(t):
if t and t not in seen:
seen.add(t); texts.append(t)
def walk_nodes(node_list):
for n in node_list:
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", []):
walk_nodes(s.get("nodes", []))
for e in ir.get("endings", []):
add(e.get("summary"))
return texts