Files
story-edit-web/IR_SCHEMA.md
邓雨鹏 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

208 lines
13 KiB
Markdown
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.

# Story IR Schemav0.3P2 扩展)
> v0.3 变更(剧情 Timeline P2新增 `scene` 节点(多轨时间线容器,承载并行+时间偏移演出)+ track + clip 模型(见 §4.2)。
> scene 与现有线性节点**并存**D1scene 内只放演出、不含分支,有单一 `next` 出口D2
> **导出 gateD3**:含 scene 的事件暂不可编译导出(需 P3 引擎支持),`compile_ir` 遇 scene 报错拦下;校验/白模预览不受限。
> v0.2 变更M4新增 `sequences`/`out_ref`子序列复用、Option `skip`(押注跳过)、
> `stage.point_set`(点位集引用 + 坐标校验);条件/奖励词典外置到 `ir_dictionary.json`。
> 校验+编译内核收敛进 `ir_core/` 包CLI 与 M5 Web 后端共用)。
Story IR 是"讲故事"与"编译成配置"两段之间的唯一交接棒。它只描述**语义层**
(语义角色 / 语义场景 / 叙事节点),把真实 ID、prefab 点位、`step` 号、`nextStepId`
前缀拼接全部留给编译器。
配套:
- 样张:`tools/event_authoring/samples/yuye_koumen.ir.json`
- 降级目标:`Assets/Scripts/Base/Config/Data/GameEventData.cs`
- 设计依据:`docs/plans/2026-06-03-event-config-automation-design.md`
---
## 1. 顶层结构
```jsonc
{
"id": "QY_YYKM", // group 名(唯一,不与现有内置 group 冲突)
"title": "雨夜叩门",
"theme": "正统武侠·道德抉择",
"scale": "标准奇遇", // 仅元信息,不参与编译
"roles": [ Role, ... ],
"stage": Stage,
"sequences": [ Sequence, ... ], // 可选;可复用子序列(被 out_ref 引用)
"nodes": [ Node, ... ],
"endings": [ Ending, ... ]
}
```
## 2. Role语义角色
```jsonc
{ "slot": "NP1", "name": "神秘剑客", "archetype": "负伤外门高手", "camp": 0 }
```
- `slot`:编译后写进 `points`/`camp2Fighters` 的语义点位名。`P1`=玩家固定。
- `archetype`:原型标签,编译期→真实 NPC ID匹配 `npc_data`)。
- `camp`0 中立 / 1 玩家方 / 2 敌方(战斗时用,可省略)。
## 3. Stage语义场景
```jsonc
{ "type": "门派入口·夜", "reuse_hint": "K3_A", "point_set": "QY_YYKM" }
```
- `type`:场景语义;编译器据此 + 所需角色数从点位库挑结构匹配的 prefab。
- `reuse_hint`:可选,显式指定复用哪个现有 prefab仅作 BDTree host
- `point_set`:可选,引用的点位集名(默认 = `id`)。对应 `Assets/StreamingAssets/Story/PointSets/{point_set}.points.json`M1 取点工具产出。编译期校验IR 里实际站位的点位名speaker/actor/move.to/fight 阵营/camera必须都在该点位集中存在点位集文件不存在则降级为警告`--strict-points` 下视为错)。
## 4. Node叙事节点
通用字段:`id`(组内唯一)、`kind`。各 `kind` 字段如下:
| kind | 字段 | → GameEventData |
|---|---|---|
| `narration` | `speaker?`, `text`, `next` | type0挂角色(默认 P1)用气泡实景呈现,**不走全屏黑幕**`content`=text |
| `dialogue` | `speaker`, `text`, `camera?`, `next` | type0`points`=[speaker]`content``cameraPoint` |
| `move` | `actor`, `to`, `mode?`(walk/teleport/remove), `speed?`, `ani?`, `next` | type0`points`/`movePoint`/`moveStatus` |
| `anim` | `actor`, `ani`, `angle?`, `next` | type0`anis`=[ani,angle] |
| `choice` | `options`:[Option] | type1每 option 一行 |
| `choice_once` | `options`:[Option] | type2编译器保证有恒满足兜底项 |
| `random` | `branches`:[{`weight`,`goto`}] | type3`weight`+`nextStepId` |
| `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);并行+时间偏移容器。导出 gateD3编译期报错暂不产行需 P3 引擎) |
### 4.1 Sequence / out_ref子序列复用对应 `OutRefStoryNodeData`
```jsonc
// 顶层 sequences可复用子序列
{ "id": "seq_cheer", "nodes": [ {Node}, ... ] }
// 引用它的节点
{ "id": "ref_a", "kind": "out_ref", "ref": "seq_cheer", "next": "end_win" }
```
- **语义**:把一段公共流程(庆功/过场/结算)抽成子序列,多处 `out_ref` 复用,避免重复手写。
- **编译**(对齐 `StorylineMgr.ReplaceStoryEventData` 的展开):编译前把每个 `out_ref` 节点 `r` 摊平——
克隆 `ref` 子序列的节点id 加 `{r.id}__` 前缀,内部跳转同步加前缀;子序列出口(`next` 为空的尾节点)
`next` 接到 `r.next`;指向 `r` 的跳转改写为子序列入口 `{r.id}__{首节点}`
- **多处复用不撞 id**:不同 `out_ref` 节点前缀不同 → 各自一份独立行。
- **限制**M4 仅支持**一层**(子序列内不得再含 `out_ref`),违者校验报错;子序列内跳转目标须落在子序列内或共享结局。
### 4.2 scene / Track / Clip多轨演出段P2 新增)
`scene` 是一段被分支夹着的**连续并行演出**(对应影视「一镜到底的演出段」):多条轨道同时推进,
每个 clip 有场景内绝对起始秒 `start`,因此「角色 A 走到点 11.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=角色 slotP1/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.0sP3 由引擎动画时长表回填) |
| `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]}?,
"skip": { "node": "end_bet", "reward": {"grants":[Grant]} }?, "goto": "end_ally" }
```
- `text``choose``condition``condition``reward``resultRewardIds`(选项被选后自身发的奖励/扣费),`goto``nextStepId`
- **`skip`(押注跳过,对应 `skipNodeId`+`skipReward`**:选该项时**把 `skip.reward` 注入 `skip.node` 那行的奖励**,常配合 `goto` 越过中间战斗直达结算行(押对了→不打了直接领彩头)。
- 编译:选项行写 `skipNodeId = skip.node`(裸节点 id运行时 `ChooseTaskUI` 自动拼 `{group}_` 查行)、`skipReward = compile(skip.reward)`
- 注意:运行时是**覆盖** `skip.node` 行的 `resultRewardIds`(非追加)→ 让 `skip.node` 的自带 grants 为空、彩头全由 `skip.reward` 给。
- 校验:`skip.node` 须可解析;`skip.reward.grants` 非空且合法。
## 5. Ending结局
```jsonc
{ "id": "end_ally", "summary": "结义同盟", "grants": [Grant], "result": "success" }
```
- `result`:可选,`success`(默认,→`gameTaskStatus`1) / `fail`(→2) / `end`(→3)。决定收尾分支。
- 编译为**两行**:① 结算行(`missionText`=summary + `resultRewardIds`/`roleActionCode``gameTaskStatus`=0
`nextStepId`→终结行);② 裸终结行(仅 `gameTaskStatus`)。
- **为何拆两行**`EventRefreshStepAction``RefreshStep()` 返回 false即下一步 `gameTaskStatus>0`)时
返回 `Failure`,会中断该步 Sequence——若把奖励与 `gameTaskStatus` 放同一行,发奖励/入门的 Action 被跳过。
终结行触发 `DirectStoryEnd`(复位镜头 `otherTarget=null` / 恢复玩家控制 / 结束剧情)。与可视化编辑器
及内置奇遇 `Qiyu/QY10_A.json`(末节点 `taskStatus:1`、内容为空、奖励在前置节点)一致。
## 6. Condition / Grant 词典(外置 `ir_dictionary.json`
> M4 起词典外置到 `tools/event_authoring/ir_dictionary.json`,编译器(`ir_core`)与 M5 Web 前端共读。
> 新增**查表类** kind 只改该 JSON、零代码需引擎特例的 kind 标 `"engine": true`。
> 编译器对未登记 kind **报错不静默**。下表为当前登记项(随推进扩充)。
### Condition→ `condition` 字段)
```jsonc
{ "kind": "银两", "op": ">=", "value": 500 } // → "V19,2,500" (op: >=→2)
```
| kind | 真实ID | 说明 |
|---|---|---|
| 银两 | V19 | 门派金钱(`RewardItems` 特判 schoolMoney |
### Grant→ `resultRewardIds` 或行为码)
```jsonc
{ "kind": "银两", "value": 200 } // → "V19,200"(正=给,负=扣)
{ "kind": "道具", "item": "P6", "value": 3 } // → "P6,3"
{ "kind": "友好度", "target": "NP1", "value": 30 } // → "NP1,V15,30"
{ "kind": "入门", "target": "NP1" } // → roleActionCode "JoinToPlayerSch=NP1"
```
| kind | 真实ID/机制 |
|---|---|
| 银两 | V19 |
| 道具 | 由 `item` 指定drop_item_data ID |
| 友好度 | V15`target` NPC slot |
| 入门 | roleActionCode `JoinToPlayerSch`NPC 加入玩家门派) |
> 词典随 MVP 推进扩充;编译器对未登记的 kind 报错,不静默产出。
## 7. 编译器降级不变量(与 StorylineMgr 现有逻辑一致)
- 自动生成 `step`(按节点拓扑序)。
- 自动给 `id`/`nextStepId`/`fightStatus[1..2]``{group}_` 前缀。
- 所有跳转目标(`next`/`goto`/`win`/`lose`/`branches[].goto`)必须可解析到某节点或结局,否则编译失败。
- 每个 `choice`/`choice_once` 至少一个无 `condition`(或恒满足)的兜底项,否则编译失败。
## 8. 多语言 / 本地化
游戏本地化走 `SGameText`(重写 `Text.text` setter。剧情对话气泡 `TaskDialogUI.contentText`
已是 `SGameText`,链路可用。规则:
- **文本一律用简体中文撰写**`content`/`choose`/`summary`),作为 canonical key
- **简体↔繁体**`SGameText` 字符级机械转换,自动覆盖,无需额外工作。
- **韩文(及未来语言)**`SGameText` 用中文整句作 key 查 `StreamingAssets/i18n/ko.tsv`
- 编译器附带产出 `{group}.i18n.tsv`(中文 key 列 + 空韩文列),翻译填好后合并进 `ko.tsv`
新剧情文本即纳入本地化,不会成为漏网。
- **占位符**:需动态插入角色名时用游戏既有约定(如 `{NP1}``{A5,NP1}`),编译器原样保留。
注意:含占位的整句**韩文查表会 miss**(拿到的是替换后的完整串)→ 简繁不受影响,韩文 fallback 中文。
这是项目级已知限制(待 `SGameText.SetFormat` 模板方案解决),故重要台词尽量少用占位。
- **运行时前提**:剧情文本的显示组件必须是 `SGameText`。对话气泡已满足;黑屏旁白/选项 UI
若发现是原生 `Text`,需在游戏侧补挂 `SGameText`(属游戏侧改造,不在编译器范围)。