Files
ichni_Official/docs/story-writer-yarn-guide.md
SoulliesOfficial 810d019619 剧情+对话完善
2026-07-21 15:24:42 -04:00

460 lines
18 KiB
Markdown
Raw 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.
# ichni Official 剧本编写与 Yarn Script 制作手册
> 适用对象:剧本编写者、叙事设计师、负责初步拆分剧情 Block 的策划
> 适用项目:`ichni Official`
> 剧情工具Yarn Spinner `3.2.1` + Unity Localization
> 最后更新2026-07-21
本文说明如何把一段剧情写成可交付给程序员的 Yarn Scripts。本文只描述当前项目已经存在的语法和运行规则所有玩家可见文本使用 Unity Localization也不要使用 Yarn 原生临时变量保存本项目的剧情进度。
## 1. 先理解游戏中的剧情结构
一个章节由一份 `StoryData` 定义。`StoryData` 中预先排好完整剧情路线,运行时根据玩家进度把每个 Block 显示为以下状态之一:
- `Locked`:前置流程或额外条件尚未满足,不能点击。
- `Current`:当前可进入。
- `Completed`:已经完成,仍可点击回顾。
- `Forbidden`玩家的分支选择已经排除了这条路线Block 保留显示,但不能点击。
Block 有三种类型:
| 类型 | 用途 | 剧本编写者需要提供的内容 |
|---|---|---|
| `TextBlock` | 播放 Yarn 对话 | Yarn 文件、入口 Node、Block 标题、重要性、变量与分支说明 |
| `SongBlock` | 进入指定歌曲的选曲界面 | 目标歌曲及它在叙事中的意义;不需要单独写 Yarn |
| `TutorialBlock` | 弹出“游玩/跳过教程”的选择 | 教程前后文案与目标教程;不需要单独写 Yarn |
`TextBlock` 又分为:
- `Important`:主线转折、重要选择、适合作为 Timeline 回滚点的内容。
- `Secondary`:补充信息、支线或非关键叙事。
剧情图不是运行时自动排版。策划和程序员会把所有可能路线预先放入 `StoryData`;玩家的选择通过章节变量、`Unlock Condition``Forbidden Condition` 动态开放或禁用对应 Block。
## 2. 存档与对话事务规则
每次进入一个 `TextBlock`,系统会建立一份“本次对话事务”:
1. 进入前复制当前章节剧情存档。
2. 对话中的 `set_*`、选项记录和 Block 进度先只修改内存。
3. 对话正常走到结束时,一次性提交本次变化并把 TextBlock 标记为 `Completed`
4. 玩家中途按 `Back`,或 Yarn 启动失败时,恢复进入前的章节快照。
因此:
- 没有正常结束的对话不会保存其中的选项和剧情变量。
- 再次进入已完成的 TextBlock 时,已经记忆的选项会自动采用,不再显示选择页面。
- Timeline 回滚到某个 Marker 时,会恢复该列之前的 Block、选项和章节变量。
- Timeline 回滚不会撤销歌曲成绩、`isTried`、设置、Offline 解锁 Key 或其它章节。
- `grant_unlock``unlock_song``show_message` 会等到对话正常提交后才执行;中途退出不会留下全局解锁或孤立弹窗。
## 3. 命名规范
稳定 ID 一旦进入测试存档就不要随意重命名。玩家可见文本必须进入 Unity Localization稳定 ID 不翻译。
| 对象 | 规范 | 示例 |
|---|---|---|
| Chapter Index | 必须与 `ChapterSelectionUnit``StoryData``StoryManager` 完全一致 | `Chapter0` |
| StoryData | `StoryData_Chapter{n}` | `StoryData_Chapter0` |
| Yarn Project | `Chapter{n}.yarnproject` | `Chapter0.yarnproject` |
| Yarn 文件 | `C{n}_{主题}.yarn` | `C0_SignalCheckpoint.yarn` |
| Yarn 入口 Node | `C{n}_{Block用途}`,在整个 Yarn Project 内唯一 | `C0_SignalCheckpoint` |
| Yarn 内部 Node | 在入口名后增加语义后缀 | `C0_SignalCheckpoint_Record` |
| Block ID | 小写英文、数字、下划线;建议包含章节、类型和用途 | `c0_text_signal_checkpoint` |
| 章节变量 | `c{n}_{范围}_{事实}`,使用小写英文、数字和下划线 | `c0_signal_route` |
| Timeline Marker ID | `c{n}_marker_{时间或事件}` | `c0_marker_day_01` |
| Block 标题本地化 Key | `story_c{n}_block_{用途}_title` | `story_c0_block_signal_title` |
| Timeline 文本 Key | `story_c{n}_timeline_{时间或事件}` | `story_c0_timeline_day_01` |
| Character ID | 与 Yarn 台词冒号前的名字完全一致,区分大小写 | `Asahi` |
| Character Name Key | `character_{角色}_name` | `character_asahi_name` |
| Unlock Key | 遵守 Offline Key 规范:小写英文、数字、下划线,以字母开头 | `story_ch0_prologue_completed` |
不要使用 `Record``Start``End` 这类过短的 Yarn Node 名。Node 名在同一 Yarn Project 内不是文件局部名称,多个文件出现同名 Node 会冲突。
变量应描述已经发生的事实,而不是按钮动作:
- 推荐:`c0_signal_observed``c0_signal_route``c0_intro_read`
- 不推荐:`clicked_left``do_next``temp``choice1`
Bool 变量尽量使用正向语义,例如 `c0_tutorial_resolved`,避免 `not_skipped` 这类双重否定。Int 路线编码必须在交付说明中列出每个数字的含义,例如 `0 = 未选择1 = 观测2 = 保护`
## 4. Yarn 文件的基本结构
最小可运行 Node
```yarn
title: C0_Intro
---
SLS: 记录开始。
Asahi: 我已经准备好了。
===
```
规则:
- `title:` 后是 Node 名,必须在当前 Yarn Project 内唯一。
- `---` 表示正文开始。
- `===` 表示 Node 结束。
- `//` 是只给作者和程序员看的注释。
- `Character: 台词` 中冒号前的名称会成为 `CharacterName`
- 没有角色名前缀的文本按旁白处理。
- 不要手工删除或复用 `#line:xxxxxxx`。这些 Line ID 由 Yarn 工具添加,是本地化和选项记忆的稳定身份。
一个文件可以包含多个 Node但建议“一份入口剧情 + 它的内部小分段”放在同一文件;跨 Block 的入口 Node 则分文件保存。
## 5. 流程、跳转与选项
### 5.1 跳转
```yarn
title: C0_Intro
---
SLS: 先从这里开始。
<<jump C0_Intro_Continue>>
===
title: C0_Intro_Continue
---
Asahi: 我们继续吧。
===
```
`<<jump NodeName>>` 会转到另一个 Node。目标必须存在名称必须完全一致。
`<<stop>>` 会立即结束当前 Yarn 对话。只有确认这就是本 TextBlock 的正常终点时才使用;正常结束会提交事务并完成 Block。
### 5.2 玩家选项
```yarn
SLS: 你打算怎样处理信号?
-> 记录信号的变化
<<set_int "c0_signal_route" 1>>
<<jump C0_SignalCheckpoint_Record>>
-> 优先保护观测装置
<<set_int "c0_signal_route" 2>>
<<jump C0_SignalCheckpoint_Protect>>
```
注意:
- 选项正文必须缩进。
- 当前 `DialogUIPage` 只有 4 个 `ChoiceButton`,一个选择组最多写 4 个可显示选项;需要更多时先与程序员确认 UI 扩容。
- 选项的 Line ID 会被保存。重新排序选项不会改变记忆;增加/删除选项或更换 Line ID 会形成新的选项组,玩家可能需要重新选择。
- 已保存的选项如果在新版台本中消失或变为不可用,系统会删除旧记录并重新显示选择页面。
### 5.3 条件选项
```yarn
-> 询问朝日的过去 <<if get_bool("c0_asahi_origin_known")>>
Asahi: 我现在可以谈起那段记忆了。
```
条件不满足时,该选项由 Yarn 标记为不可用。不要用 Yarn 原生 `$variable` 作为条件。
## 6. 条件语法
```yarn
<<if get_int("c0_signal_route") == 1>>
SLS: 你选择了观测路线。
<<elseif get_int("c0_signal_route") == 2>>
SLS: 你选择了保护路线。
<<else>>
SLS: 目前还没有选择路线。
<<endif>>
```
常用运算:
- 相等/不等:`==``!=`
- 数值比较:`>``>=``<``<=`
- 逻辑组合:`and``or``not`
- Bool 函数可直接作为条件:`<<if get_bool("c0_intro_read")>>`
复杂条件应优先拆成可读的事实变量。不要把大量业务规则全部塞在一行 Yarn 表达式中;对应 Block 的开放和禁用仍应由程序员在 `StoryData` 条件树中复核。
## 7. 永久章节变量:全部命令与函数
新台本禁止使用:
```yarn
<<declare $temporary = false>>
<<set $temporary = true>>
<<if $temporary>>
```
这些变量不受 ichni Official 的章节存档、对话事务和 Timeline 回滚管理。
### 7.1 Bool
```yarn
<<set_bool "c0_intro_read" true>>
<<if get_bool("c0_intro_read")>>
SLS: 你已经读过开场。
<<endif>>
```
| 接口 | 用途 |
|---|---|
| `<<set_bool "key" true>>` | 写入 Bool |
| `get_bool("key")` | 读取 Bool用于 `if` 或表达式 |
### 7.2 Int
```yarn
<<set_int "c0_signal_route" 1>>
<<add_int "c0_resonance" 2>>
<<if get_int("c0_resonance") >= 3>>
SLS: 共鸣已经足够强烈。
<<endif>>
```
| 接口 | 用途 |
|---|---|
| `<<set_int "key" 1>>` | 写入整数语义的数值 |
| `<<add_int "key" 1>>` | 在当前值上增加整数 |
| `get_int("key")` | 读取整数 |
### 7.3 Float
```yarn
<<set_float "c0_signal_strength" 0.5>>
<<add_float "c0_signal_strength" 0.25>>
<<if get_float("c0_signal_strength") >= 0.75>>
SLS: 信号强度已达到阈值。
<<endif>>
```
| 接口 | 用途 |
|---|---|
| `<<set_float "key" 0.5>>` | 写入小数 |
| `<<add_float "key" 0.25>>` | 在当前值上增加小数 |
| `get_float("key")` | 读取小数 |
### 7.4 String
```yarn
<<set_string "c0_signal_source" "moon">>
<<if get_string("c0_signal_source") == "moon">>
Asahi: 信号来自月面。
<<endif>>
```
| 接口 | 用途 |
|---|---|
| `<<set_string "key" "value">>` | 写入字符串 |
| `get_string("key")` | 读取字符串 |
### 7.5 通用变量操作
| 接口 | 用途 |
|---|---|
| `has_variable("key")` | 存档中有该变量,或 `StoryData.Initial Variables` 声明了该 Key 时返回 `true` |
| `<<remove_variable "key">>` | 删除存档覆盖值;若 `Initial Variables` 有同名定义,之后读取会重新得到默认值 |
所有变量只属于当前章节。不要依赖 Chapter 0 的变量直接解锁 Chapter 1跨章节内容开放应使用 `Unlock Key`
## 8. 立绘命令
立绘坐标采用推荐范围 `-100``100``(0, 0)` 为舞台中心;角色自己的 `CharacterData.positionOffset` 会额外叠加。当前代码不会强制 Clamp超出范围可能让立绘离开画面。
| 命令 | 作用 |
|---|---|
| `<<set_portrait "Asahi" 30 0 "normal">>` | 角色不在场时创建;已在场时设置位置和表情 |
| `<<set_portrait_current 30 0 "happy">>` | 操作当前说话者;必须已经显示过一行带角色名的台词 |
| `<<set_portrait_position "Asahi" -30 0>>` | 只改位置;角色不在场时以 `normal` 创建 |
| `<<set_portrait_emotion "Asahi" "angry">>` | 只改表情;角色必须已在场 |
| `<<hide_portrait "Asahi">>` | 隐藏并移除指定角色 |
| `<<hide_all_portraits>>` | 清空全部立绘 |
| `<<move_portrait "Asahi" 0 0 0.5>>` | 在指定秒数内平滑移动 |
| `<<jump_portrait "Asahi" 5 2 0.8>>` | 跳跃;参数为高度、次数、总时长 |
| `<<shake_portrait "Asahi" 0.5 2 10>>` | 横向震动;参数为时长、强度、频率 |
动画命令启动 DOTween 后会立即让 Yarn 继续执行,不会自动等待动画完成。若下一句必须等动画结束,可以使用 Yarn Spinner 内置命令:
```yarn
<<move_portrait "Asahi" 0 0 0.5>>
<<wait 0.5>>
Asahi: 我到了。
```
建议每个独立 TextBlock 开头明确建立所需立绘,结束前按设计调用 `hide_portrait``hide_all_portraits`,不要隐式依赖上一次对话留下的舞台状态。
## 9. 解锁、提示和调试命令
| 命令 | 作用 | 是否等待对话正常提交 |
|---|---|---|
| `<<grant_unlock "story_ch0_prologue_completed">>` | 授予通用 Offline 内容 Key不自动弹提示 | 是 |
| `<<unlock_song "story_ch0_song_space_rain_available">>` | 兼容旧流程:授予 Key并在首次授予时显示歌曲提示 | 是 |
| `<<show_message "title_key" "content_key" "Message">>` | 从指定 Unity String Table 解析并显示 MessageBox | 是 |
| `<<log "checkpoint reached" "info">>` | 输出 `info``warning``error` 日志 | 否;只影响 Console |
推荐把通用解锁和玩家提示明确拆开:
```yarn
<<grant_unlock "story_ch0_prologue_completed">>
<<show_message "story_c0_unlock_title" "story_c0_unlock_space_rain" "Message">>
```
`unlock_song` 主要用于兼容现有内容。它当前的专用提示仍可能显示内部 Key正式文本优先使用 `grant_unlock + show_message`
## 10. 完整分支案例
### 10.1 第一段主线
```yarn
title: C0_SignalIntro
---
<<set_portrait "Asahi" 30 -5 "normal">>
SLS: 记录开始。异常信号将在一分钟后抵达。
Asahi: 我们要观察它,还是先保护装置?
<<set_bool "c0_signal_intro_read" true>>
<<add_int "c0_signal_resonance" 1>>
===
```
### 10.2 带 Timeline Marker 的重要选择
```yarn
title: C0_SignalCheckpoint
---
<<if get_bool("c0_signal_intro_read")>>
SLS: 前置记录已经同步。现在决定路线。
<<else>>
SLS: 前置记录缺失,请程序员检查 StoryData 连接。
<<endif>>
-> 继续观测
<<set_int "c0_signal_route" 1>>
<<jump C0_SignalCheckpoint_Observe>>
-> 保护装置
<<set_int "c0_signal_route" 2>>
<<jump C0_SignalCheckpoint_Protect>>
===
title: C0_SignalCheckpoint_Observe
---
Asahi: 我会记录每一次波形变化。
===
title: C0_SignalCheckpoint_Protect
---
Asahi: 我先切断装置与外界的连接。
===
```
程序员会把这个 Important TextBlock 配置为 Timeline Marker 的 Anchor。第一次完成后再次进入会自动采用第一次保存的选项回滚到该 Marker 后,选项和 `c0_signal_route` 都恢复到该列之前。
### 10.3 两条互斥分支
观测分支:
```yarn
title: C0_SignalObserve
---
<<if get_int("c0_signal_route") == 1>>
SLS: 观测结果已经归档。
<<set_bool "c0_signal_observed" true>>
<<add_int "c0_signal_resonance" 2>>
<<endif>>
===
```
保护分支:
```yarn
title: C0_SignalProtect
---
<<if get_int("c0_signal_route") == 2>>
SLS: 保护程序已经完成。
<<set_bool "c0_signal_protected" true>>
<<add_int "c0_signal_resonance" 1>>
<<endif>>
===
```
程序员应同时配置:
- 观测 Block`Unlock c0_signal_route == 1``Forbidden c0_signal_route == 2`
- 保护 Block`Unlock c0_signal_route == 2``Forbidden c0_signal_route == 1`
### 10.4 汇合段与内容解锁
```yarn
title: C0_SignalConvergence
---
<<if get_bool("c0_signal_observed")>>
SLS: 你从时间的回声中取得了答案。
<<elseif get_bool("c0_signal_protected")>>
SLS: 你为下一次观测保留了机会。
<<else>>
SLS: 没有有效路线记录,请检查剧情配置。
<<endif>>
<<set_bool "c0_signal_convergence_read" true>>
<<grant_unlock "story_ch0_signal_convergence_completed">>
<<show_message "story_c0_signal_complete_title" "story_c0_signal_complete_body" "Message">>
<<hide_all_portraits>>
===
```
仓库中还有一组可直接参考的 `1-1-2-1` 示例:
- `Assets/Story/Chapter0/C0_RollbackDemo_Intro.yarn`
- `Assets/Story/Chapter0/C0_RollbackDemo_Checkpoint.yarn`
- `Assets/Story/Chapter0/C0_RollbackDemo_Observe.yarn`
- `Assets/Story/Chapter0/C0_RollbackDemo_Protect.yarn`
- `Assets/Story/Chapter0/C0_RollbackDemo_Convergence.yarn`
## 11. 本地化交付规则
Yarn 台词使用 Unity Localization。当前 `Chapter0.yarnproject``baseLanguage``en`,因此正式生产有两种选择:
1. Yarn 源文件直接写英文,翻译到 `zh-CN``ja``ko``vi-VN`
2. 如果团队以中文为原稿,程序员必须在批量生产前把 Yarn Project 的 Base Language 一次性改为 `zh-CN`,再建立其它语言翻译。
不要在已经大量翻译后频繁切换 Base Language也不要让“配置为 English 的项目”长期保存中文源文本。
交付给程序员时,每个 Yarn 文件还应附带:
- 所属 Chapter 和目标 Block ID。
- 入口 Node 名。
- Important / Secondary。
- 新增变量清单、类型、默认值和语义。
- 路线编号含义。
- 哪些 Block 应被开放或 Forbidden。
- 是否需要 Timeline Marker以及显示的时间文本。
- 需要授予的 Unlock Key 和 Message 文案 Key。
- 新角色、表情和立绘资源需求。
翻译人员只修改 String Table 中的玩家可见文本,不修改 Line ID、Block ID、Node 名、变量 Key、Character ID 或 Unlock Key。
## 12. 剧本自检清单
提交前逐项确认:
1. 每个文件和 Node 都遵守命名规范Node 在当前 Yarn Project 内唯一。
2. 每个入口 Node 都有 `---``===`,所有 `if` 都有 `endif`
3. 选项缩进正确,单组选项不超过当前 UI 的 4 个按钮。
4. 没有 `<<declare>>``<<set $...>>``$variable`
5. 所有剧情变量使用 `set_*` / `get_*`,并附带类型与默认值说明。
6. 互斥路线同时说明 Unlock 和 Forbidden 规则。
7. 重要选择说明是否需要 Timeline Marker。
8. 立绘 Character ID 与 `CharacterData.characterId` 完全一致。
9. 需要等待的立绘动画显式添加 `<<wait>>`
10. 玩家可见标题、提示和时间文本提供 Unity Localization Key。
11. Line ID 由 Yarn 工具生成后不再手工改动。
12. 对话正常结束路径、Back 中断路径和已记忆选项回顾路径都能成立。
## 13. 常见错误
| 现象 | 常见原因 |
|---|---|
| TextBlock 点击后没有对话 | `Yarn Node` 为空、Node 名拼错、Yarn Project 编译失败 |
| 变量在重开后没有恢复 | 使用了 `$variable``declare` 或原生 `set` |
| 第二次进入仍显示选项 | Option Line ID 变化、选项组结构变化、上次对话中途退出 |
| 某条分支仍可点击 | 只写了 Yarn `if`,没有在 StoryData 配置 Forbidden Condition |
| 立绘命令找不到角色 | Character ID 大小写不一致,或 CharacterRegistry 未登记 |
| `set_portrait_current` 无效果 | 命令执行前还没有显示任何带角色名的台词 |
| Marker 回滚按钮不可用 | 该 Marker 的“列之前快照”尚未创建,或 Marker 配置错误 |
| 翻译后选项记忆错乱 | 不应发生;系统按 Line ID 记忆。若发生,优先检查 Line ID 是否被重建 |