18 KiB
ichni Official 剧本编写与 Yarn Script 制作手册
适用对象:剧本编写者、叙事设计师、负责初步拆分剧情 Block 的策划
适用项目:ichni Official
剧情工具:Yarn Spinner3.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,系统会建立一份“本次对话事务”:
- 进入前复制当前章节剧情存档。
- 对话中的
set_*、选项记录和 Block 进度先只修改内存。 - 对话正常走到结束时,一次性提交本次变化并把 TextBlock 标记为
Completed。 - 玩家中途按
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:
title: C0_Intro
---
SLS: 记录开始。
Asahi: 我已经准备好了。
===
规则:
title:后是 Node 名,必须在当前 Yarn Project 内唯一。---表示正文开始。===表示 Node 结束。//是只给作者和程序员看的注释。Character: 台词中冒号前的名称会成为CharacterName。- 没有角色名前缀的文本按旁白处理。
- 不要手工删除或复用
#line:xxxxxxx。这些 Line ID 由 Yarn 工具添加,是本地化和选项记忆的稳定身份。
一个文件可以包含多个 Node,但建议“一份入口剧情 + 它的内部小分段”放在同一文件;跨 Block 的入口 Node 则分文件保存。
5. 流程、跳转与选项
5.1 跳转
title: C0_Intro
---
SLS: 先从这里开始。
<<jump C0_Intro_Continue>>
===
title: C0_Intro_Continue
---
Asahi: 我们继续吧。
===
<<jump NodeName>> 会转到另一个 Node。目标必须存在,名称必须完全一致。
<<stop>> 会立即结束当前 Yarn 对话。只有确认这就是本 TextBlock 的正常终点时才使用;正常结束会提交事务并完成 Block。
5.2 玩家选项
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 条件选项
-> 询问朝日的过去 <<if get_bool("c0_asahi_origin_known")>>
Asahi: 我现在可以谈起那段记忆了。
条件不满足时,该选项由 Yarn 标记为不可用。不要用 Yarn 原生 $variable 作为条件。
6. 条件语法
<<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. 永久章节变量:全部命令与函数
新台本禁止使用:
<<declare $temporary = false>>
<<set $temporary = true>>
<<if $temporary>>
这些变量不受 ichni Official 的章节存档、对话事务和 Timeline 回滚管理。
7.1 Bool
<<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
<<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
<<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
<<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 内置命令:
<<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 |
推荐把通用解锁和玩家提示明确拆开:
<<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 第一段主线
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 的重要选择
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 两条互斥分支
观测分支:
title: C0_SignalObserve
---
<<if get_int("c0_signal_route") == 1>>
SLS: 观测结果已经归档。
<<set_bool "c0_signal_observed" true>>
<<add_int "c0_signal_resonance" 2>>
<<endif>>
===
保护分支:
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 汇合段与内容解锁
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.yarnAssets/Story/Chapter0/C0_RollbackDemo_Checkpoint.yarnAssets/Story/Chapter0/C0_RollbackDemo_Observe.yarnAssets/Story/Chapter0/C0_RollbackDemo_Protect.yarnAssets/Story/Chapter0/C0_RollbackDemo_Convergence.yarn
11. 本地化交付规则
Yarn 台词使用 Unity Localization。当前 Chapter0.yarnproject 的 baseLanguage 是 en,因此正式生产有两种选择:
- Yarn 源文件直接写英文,翻译到
zh-CN、ja、ko、vi-VN。 - 如果团队以中文为原稿,程序员必须在批量生产前把 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. 剧本自检清单
提交前逐项确认:
- 每个文件和 Node 都遵守命名规范,Node 在当前 Yarn Project 内唯一。
- 每个入口 Node 都有
---和===,所有if都有endif。 - 选项缩进正确,单组选项不超过当前 UI 的 4 个按钮。
- 没有
<<declare>>、<<set $...>>或$variable。 - 所有剧情变量使用
set_*/get_*,并附带类型与默认值说明。 - 互斥路线同时说明 Unlock 和 Forbidden 规则。
- 重要选择说明是否需要 Timeline Marker。
- 立绘 Character ID 与
CharacterData.characterId完全一致。 - 需要等待的立绘动画显式添加
<<wait>>。 - 玩家可见标题、提示和时间文本提供 Unity Localization Key。
- Line ID 由 Yarn 工具生成后不再手工改动。
- 对话正常结束路径、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 是否被重建 |