# Yarn Spinner in Unity 深度技术参考与最佳实践 Yarn Spinner 是一款轻量、易扩展且功能强大的互动叙事脚本插件,专门为游戏对话、多分支剧情开发设计。本章程作为项目核心 C# 技术专家的专属知识库,沉淀了 Yarn Spinner v3.x(兼容 v2.x 核心概念)与 Unity 深度整合的技术架构、核心 API、工程规范及性能优化避坑指南。 --- ## 核心类与接口 (Core Classes & Interfaces) Yarn Spinner 的底层核心主要由编译器(Compiler)、虚拟机(Virtual Machine)、外部接口和 Unity 表现层组件构成。在 Unity 整合中,需要掌握以下核心类: ### 1. `YarnProject` (ScriptableObject) - **定位**:Yarn Spinner 剧情工程的“编译核心”。它是一个 ScriptableObject,用于在 Unity 编辑器中追踪并收集特定的 `.yarn` 脚本文件,将其集中编译为二进制的 Program,并管理多语言本地化(Localization)。 - **关键机制**: - 自动编译:当绑定的 `.yarn` 文件发生修改时,YarnProject 会在后台自动重新生成数据。 - 元数据注入:通过 `Line Tags` 追踪每一行文本,并可注入自定义标签(如 `#character:Alice`)。 ### 2. `DialogueRunner` (MonoBehaviour) - **定位**:Unity 整合层的主控单元(Orchestrator),负责驱动 Yarn 虚拟机并将对话流转发给具体的表现组件。 - **关键 API**: - `YarnTask StartDialogue(string nodeName)`: 从指定节点异步开始执行对话(v3 为异步,v2 为 void 并通过回调通知)。 - `void Stop()`: 立即终止对话的执行。 - `void AddCommandHandler(string name, Delegate handler)`: 动态添加命令处理器。 - `void AddFunction(string name, Delegate handler)`: 动态添加计算函数。 - `VariableStorageBehaviour VariableStorage`: 对话中用于存取变量的后端组件。 ### 3. `DialoguePresenterBase` / `DialoguePresenter` (v3.x 核心抽象) - **定位**:用于向玩家展现对话文本和提供选择分支的基类。在 v2 中此基类为 `DialogueViewBase`。 - **核心生命周期方法 (v3.x)**: - `YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token)`: 显示一行台词。通过 cancellation token 处理玩家中断/快速跳过。 - `YarnTask RunOptionsAsync(DialogueOption[] options, LineCancellationToken token)`: 呈递分支选项,等待玩家选择并返回所选的分支对象。 - `YarnTask OnDialogueStartedAsync()` / `YarnTask OnDialogueCompleteAsync()`: 对话开始和结束时的切入/切出生命周期。 ### 4. `VariableStorageBehaviour` (Abstract Class) - **定位**:持久化/非持久化变量读写的核心抽象层。Yarn 脚本中的 `$variableName` 最终通过此类与 Unity 通信。 - **继承实现**: - `InMemoryVariableStorage`: 默认自带的基于内存字典的临时存储,游戏退出即清空。 - 自定义存储(如对接 Save System)必须继承自此基类并实现 `TryGetValue`、`SetValue` 等方法。 --- ## 最佳实践代码片段 (Best Practice Code Snippets) ### 1. 剧情脚本最佳实践 (.yarn 规范) 编写高可读性、高复用性的 Yarn 剧情文件: ```yarn <> <> <> title: Start --- Narrator: 你来到了古老的石门前。 <> Narrator: (这已经是你第 {visited_count("Start")} 次来到这里了。) <> <> === title: Gatekeeper_Conversation --- Gatekeeper: 站住!凡人。想要通过这里,你需要付出代价。 -> 我想买通过的许可。<= 50>> Gatekeeper: 很好,拿去你的钥匙。 <> <> <> -> 我能用别的方式过去吗? <> Gatekeeper: 除非你有守卫长的推荐信,但看起来你没有。 <> -> 直接离开。 Gatekeeper: 哼,慢走不送。 <> Gatekeeper: 还有别的事吗? <> === title: Open_Gate --- Narrator: 咔哒,石门缓缓开启... <> // 调用自定义命令 <> <> === ``` --- ### 2. 编写自定义命令 `[YarnCommand]` YarnCommand 用于执行“动作”。支持无缝的异步等待(Async/Await)。 ```csharp using System.Threading.Tasks; using UnityEngine; using Yarn.Unity; public class GameActionsManager : MonoBehaviour { private static GameActionsManager _instance; private void Awake() { _instance = this; } /// /// 静态命令:不需要场景中 GameObject 名字即可调用。 /// Yarn 调用:<> /// [YarnCommand("play_sfx")] public static async YarnTask PlaySFXCommand(string sfxName) { Debug.Log($"[SFX] 播放音效: {sfxName}"); // 模拟等待音频播放完毕 (例如等待 1.5 秒) // 强烈建议使用 YarnTask.Delay 避免产生不必要的 Thread 阻塞,它完全适配 WebGL/Unity 主线程 await YarnTask.Delay(1500); Debug.Log($"[SFX] 音效播放完毕: {sfxName}"); } /// /// 实例命令:需要通过物体名进行关联寻址。 /// 假设挂载此组件的物体在场景中命名为 "Player" /// Yarn 调用:<> /// [YarnCommand("move_to")] public async YarnTask MoveToPosition(float x, float y, float z) { Vector3 targetPos = new Vector3(x, y, z); Debug.Log($"{gameObject.name} 开始移动向 target: {targetPos}"); // 简单平滑移动模拟 while (Vector3.Distance(transform.position, targetPos) > 0.1f) { transform.position = Vector3.MoveTowards(transform.position, targetPos, Time.deltaTime * 5f); await YarnTask.Yield(); // 每一帧进行等待,防卡死 } transform.position = targetPos; Debug.Log($"{gameObject.name} 移动完毕!"); } } ``` --- ### 3. 编写自定义表达式函数 `[YarnFunction]` YarnFunction 用于“计算与返回状态”。必须是**静态 (static)** 并且**必须有返回值**。 ```csharp using UnityEngine; using Yarn.Unity; public static class GameYarnFunctions { private static int _playerLevel = 5; private static string _playerClass = "Warrior"; /// /// 计算两个数值相加。 /// Yarn 调用:< 7>> 或 {$result = add_numbers($a, $b)} /// [YarnFunction("add_numbers")] public static int AddNumbers(int a, int b) { return a + b; } /// /// 判断玩家是否满足特定等级与职业。 /// Yarn 调用:<> /// [YarnFunction("check_requirement")] public static bool CheckRequirement(int requiredLevel, string requiredClass) { bool meetsLevel = _playerLevel >= requiredLevel; bool meetsClass = _playerClass.Equals(requiredClass, System.StringComparison.OrdinalIgnoreCase); Debug.Log($"[YarnFunction] 检查玩家条件: 需要等级 {requiredLevel} (当前 {_playerLevel}), 需要职业 {requiredClass} (当前 {_playerClass}) -> 结果: {meetsLevel && meetsClass}"); return meetsLevel && meetsClass; } } ``` --- ### 4. 非对话状态下读写剧情变量与复杂状态同步 (Non-Dialogue State Sync) 在大地图选择、背包或成就面板等非对话运行流程中,经常需要读写剧情状态。因 Yarn 仅支持 `string`、`float`、`bool` 基础类型,对于“玩家已访问的地点列表 (List/Set)”等复杂集合,首选以下解耦设计: #### 方案 A:直接访问变量存储进行简单同步 (以逗号分隔的字符串形式) ```csharp using UnityEngine; using Yarn.Unity; using System.Linq; public class GameMapController : MonoBehaviour { [SerializeField] private DialogueRunner dialogueRunner; public void VisitLocationDirectly(string locId) { var storage = dialogueRunner.VariableStorage; string varKey = "$visited_locations"; string rawVal = ""; if (storage.TryGetValue(varKey, out string currentVal)) { rawVal = currentVal; } var list = rawVal.Split(new[] { ',' }, System.StringSplitOptions.RemoveEmptyEntries).ToList(); if (!list.Contains(locId)) { list.Add(locId); } storage.SetValue(varKey, string.Join(",", list)); } } ``` #### 方案 B:解耦代理模式 (C# 强类型数据中心 + Yarn 特性接口 —— 🌟 强烈推荐) 把复杂的数据维护完全保留在 C# 管理器中,避免在 Yarn 脚本中做冗长无谓的变量声明。 ```csharp using System.Collections.Generic; using UnityEngine; using Yarn.Unity; public class NarrativeLocationManager : MonoBehaviour { public static NarrativeLocationManager Instance { get; private set; } private HashSet _visitedLocs = new HashSet(); private void Awake() { Instance = this; } public void RecordVisit(string locId) { _visitedLocs.Add(locId); } [YarnCommand("visit_loc")] public static void VisitLocCommand(string locId) { Instance?.RecordVisit(locId); } [YarnFunction("has_visited_loc")] public static bool HasVisitedLocFunction(string locId) { return Instance != null && Instance._visitedLocs.Contains(locId); } } ``` --- ## 性能避坑指南 (Performance & Best Practices Pitfalls) 1. **热路径不要使用同步线程阻塞** - **错误示范**:在 `[YarnCommand]` 中使用 `System.Threading.Thread.Sleep(1000)`。这会直接卡死 Unity 主线程,导致整个游戏卡顿甚至崩溃。 - **解决方案**:在 v3 中,优先使用返回 `YarnTask` 的异步方法,并搭配 `await YarnTask.Delay(milliseconds)`。 2. **避免字符串装箱 (Boxing) 与分配** - 尽管叙事系统不可避免地会频繁处理文本,但要避免在 `[YarnFunction]` 或 `[YarnCommand]` 的热更新检测逻辑里进行频繁的字符串格式化 `string.Format` 或字符串拼接。 - 对话中的数据计算尽量依靠强类型的变量交互。 3. **动态注册 vs 静态声明特性特性** - 在新版 Yarn Spinner 中,通过在 Project Settings 开启 `Automatic Registration`,挂载 `[YarnCommand]` 与 `[YarnFunction]` 的方法会自动在编译期注册,无需在代码里显式写 `dialogueRunner.AddCommandHandler`。 - **性能避坑**:如果是在大项目中,请确保只将专门管理剧情交互的类打上这些标签,避免全局漫天反射查找,提升项目构建/冷启动效率。 4. **对话界面渲染垃圾回收 (GC Layout Garbage)** - 使用 `LineView` 自带的打字机特效时,频繁更新 UI 上的文本网格(TextMeshPro)会造成一定程度的 GC。可以在不频繁触发对话的场景下放宽,但对于极高频率的文字泡泡(如动作战斗中的头顶气泡),建议通过自定义的高效打字机(避免富文本标记逐字符重新解析)来规避 GC 问题。 5. **退出对话时的垃圾释放** - 在切换场景或停止对话时,应主动调用 `dialogueRunner.Stop()` 并对监听的事件做移除处理,避免产生未被 GC 捕获的内存泄露。