From 021080dd56adc4fe9598e24d2a4b8d35201453f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=93=E9=9B=A8=E9=B9=8F?= <846149189@qq.com> Date: Sat, 13 Jun 2026 22:34:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(timeline):=20P2=20=E5=B9=B6=E8=A1=8C?= =?UTF-8?q?=E7=BC=96=E6=8E=92=E2=80=94=E2=80=94scene=20=E5=A4=9A=E8=BD=A8?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=20+=20=E7=99=BD=E6=A8=A1=E9=87=8D?= =?UTF-8?q?=E5=8F=A0=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 剧情 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 + 白模重叠演出。 --- .gitignore | 2 + IR_SCHEMA.md | 52 ++++++- ir_core/compile.py | 10 ++ ir_core/validate.py | 114 ++++++++++++++- samples/scene_demo.ir.json | 45 ++++++ test_scene.js | 62 ++++++++ web/app.py | 14 +- web/static/app.js | 2 + web/static/form.js | 69 ++++++++- web/static/graph.js | 24 +++- web/static/index.html | 1 + web/static/scene_edit.js | 283 +++++++++++++++++++++++++++++++++++++ web/static/style.css | 59 ++++++++ web/static/timeline.js | 120 ++++++++++++++-- 14 files changed, 841 insertions(+), 16 deletions(-) create mode 100644 samples/scene_demo.ir.json create mode 100644 test_scene.js create mode 100644 web/static/scene_edit.js diff --git a/.gitignore b/.gitignore index 60c1865..d5b4655 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ __pycache__/ *.pyc web/data/ web/story_events.db +web/_localdemo.db +web/_e2e*.db diff --git a/IR_SCHEMA.md b/IR_SCHEMA.md index 1f60c02..188db3a 100644 --- a/IR_SCHEMA.md +++ b/IR_SCHEMA.md @@ -1,5 +1,8 @@ -# Story IR Schema(v0.2,M4 扩展) +# Story IR Schema(v0.3,P2 扩展) +> v0.3 变更(剧情 Timeline P2):新增 `scene` 节点(多轨时间线容器,承载并行+时间偏移演出)+ track + clip 模型(见 §4.2)。 +> scene 与现有线性节点**并存**(D1);scene 内只放演出、不含分支,有单一 `next` 出口(D2)。 +> **导出 gate(D3)**:含 scene 的事件暂不可编译导出(需 P3 引擎支持),`compile_ir` 遇 scene 报错拦下;校验/白模预览不受限。 > v0.2 变更(M4):新增 `sequences`/`out_ref`(子序列复用)、Option `skip`(押注跳过)、 > `stage.point_set`(点位集引用 + 坐标校验);条件/奖励词典外置到 `ir_dictionary.json`。 > 校验+编译内核收敛进 `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] | | `reward` | `grants`:[Grant], `next` | type0;`resultRewardIds` 或 `roleActionCode` | | `out_ref` | `ref`(子序列 id), `next` | 编译前预展开成普通节点(见 §4.1);本节点不直接产行 | +| `scene` | `tracks`:[Track], `duration?`, `next` | **P2 多轨演出段**(见 §4.2);并行+时间偏移容器。导出 gate(D3):编译期报错,暂不产行(需 P3 引擎) | ### 4.1 Sequence / out_ref(子序列复用,对应 `OutRefStoryNodeData`) @@ -81,6 +85,52 @@ Story IR 是"讲故事"与"编译成配置"两段之间的唯一交接棒。它 - **多处复用不撞 id**:不同 `out_ref` 节点前缀不同 → 各自一份独立行。 - **限制**:M4 仅支持**一层**(子序列内不得再含 `out_ref`),违者校验报错;子序列内跳转目标须落在子序列内或共享结局。 +### 4.2 scene / Track / Clip(多轨演出段,P2 新增) + +`scene` 是一段被分支夹着的**连续并行演出**(对应影视「一镜到底的演出段」):多条轨道同时推进, +每个 clip 有场景内绝对起始秒 `start`,因此「角色 A 走到点 1,1.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=角色 slot(P1/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.0s(P3 由引擎动画时长表回填) | +| `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(选项) ```jsonc { "text": "收留他", "condition": Condition?, "reward": {"grants":[Grant]}?, diff --git a/ir_core/compile.py b/ir_core/compile.py index 1bdb681..d613705 100644 --- a/ir_core/compile.py +++ b/ir_core/compile.py @@ -236,6 +236,11 @@ def compile_ir(ir, dic): r["resultRewardIds"] = rid_str if rac: r["roleActionCode"] = rac + elif kind == "scene": + # 导出 gate(P2 决策 D3):scene 多轨演出段需 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", []): diff --git a/ir_core/validate.py b/ir_core/validate.py index 9f75c65..dd060ce 100644 --- a/ir_core/validate.py +++ b/ir_core/validate.py @@ -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 %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"] diff --git a/samples/scene_demo.ir.json b/samples/scene_demo.ir.json new file mode 100644 index 0000000..c5da1de --- /dev/null +++ b/samples/scene_demo.ir.json @@ -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" } + ] +} diff --git a/test_scene.js b/test_scene.js new file mode 100644 index 0000000..78ca0d4 --- /dev/null +++ b/test_scene.js @@ -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 各有 move,NP2 有 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 之后能续演到 choice(extendSegment 流过 scene→n_choice) +const s = T._prepare(ir, anchors); +let r = T._extend(s, T._firstNode(ir)); +ok(r && (r.kind === "choice"), "scene 后停在 choice:kind=" + (r && r.kind)); + +// 6) NP2 对话比 P1 走位晚 4.5s(scene-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); diff --git a/web/app.py b/web/app.py index ac6b70d..b962135 100644 --- a/web/app.py +++ b/web/app.py @@ -266,22 +266,30 @@ async def export_zip(): if not confirmed: return JSONResponse({"error": "没有 confirmed 事件可导出"}, status_code=422) - # 校验门:任一 confirmed 有 error 即整体拒绝 + # 校验门:任一 confirmed 有 error 即整体拒绝。 + # 同步做预编译探测——捕获 CompileError(含 P2 scene 导出 gate D3:含 scene 的事件暂不可导出), + # 把编译失败也并入 report,避免 compile 抛异常变成 500。编译成功的结果缓存复用,不重复编译。 report = {} + compiled = {} blocked = False for group, ir in confirmed: 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} if errs: blocked = True if blocked: - return JSONResponse({"error": "存在校验失败的 confirmed 事件,已拒绝导出", + return JSONResponse({"error": "存在校验/编译失败的 confirmed 事件,已拒绝导出", "report": report}, status_code=422) buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: for group, ir in confirmed: - rows = ir_core.compile_ir(ir, dic) + rows = compiled[group] z.writestr(group + ".events.json", json.dumps(rows, ensure_ascii=False, indent=2)) texts = ir_core.extract_texts(ir) diff --git a/web/static/app.js b/web/static/app.js index 671afca..bdb57eb 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -100,6 +100,7 @@ }, selectNode: id => { App.selectedNode = 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()); } @@ -402,6 +403,7 @@ 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(); }, onDeleteSelected: id => deleteNode(id), + onEditScene: id => { App.selectedNode = id; GraphUI.select(id); SceneEdit.open(id, App.ir, ctx(), App.pointsets, App.dict); }, }); (async function () { try { const r = await fetch("/api/events?status=all"); if (r.status === 401) { showLogin(); return; } hideLogin(); init(); } diff --git a/web/static/form.js b/web/static/form.js index e807b91..b9234f1 100644 --- a/web/static/form.js +++ b/web/static/form.js @@ -5,7 +5,8 @@ (function () { 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 小工具 ---- function el(tag, attrs, kids) { @@ -252,6 +253,8 @@ 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("出口接回 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) { host.appendChild(field("结局摘要 summary", txt(e.summary, v => { e.summary = v; ctx.onChange(false); }))); host.appendChild(field("结果 result", sel(e.result || "success", [ diff --git a/web/static/graph.js b/web/static/graph.js index 662af3b..01438ff 100644 --- a/web/static/graph.js +++ b/web/static/graph.js @@ -31,6 +31,10 @@ if (n.kind === "anim") return ["动画 · " + nameOf(ir, names, n.actor), n.ani || ""]; if (n.kind === "reward") return ["奖励结算", ""]; 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 || ""]; return [n.kind, ""]; } @@ -61,7 +65,7 @@ } // ---------- 节点 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) { const names = roleNames(ir), end = isEnding(node); const kind = end ? "ending" : node.kind; @@ -85,6 +89,16 @@ } 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 '