剧情 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 + 白模重叠演出。
208 lines
13 KiB
Markdown
208 lines
13 KiB
Markdown
# 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 后端共用)。
|
||
|
||
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);并行+时间偏移容器。导出 gate(D3):编译期报错,暂不产行(需 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 走到点 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]}?,
|
||
"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`(属游戏侧改造,不在编译器范围)。
|