11 KiB
11 KiB
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<DialogueOption?> RunOptionsAsync(DialogueOption[] options, LineCancellationToken token): 呈递分支选项,等待玩家选择并返回所选的分支对象。YarnTask OnDialogueStartedAsync()/YarnTask OnDialogueCompleteAsync(): 对话开始和结束时的切入/切出生命周期。
4. VariableStorageBehaviour (Abstract Class)
- 定位:持久化/非持久化变量读写的核心抽象层。Yarn 脚本中的
$variableName最终通过此类与 Unity 通信。 - 继承实现:
InMemoryVariableStorage: 默认自带的基于内存字典的临时存储,游戏退出即清空。- 自定义存储(如对接 Save System)必须继承自此基类并实现
TryGetValue<T>、SetValue等方法。
最佳实践代码片段 (Best Practice Code Snippets)
1. 剧情脚本最佳实践 (.yarn 规范)
编写高可读性、高复用性的 Yarn 剧情文件:
<<declare $player_gold = 100 as number>>
<<declare $has_key = false as bool>>
<<declare $npc_relationship = 0 as number>>
title: Start
---
Narrator: 你来到了古老的石门前。
<<if visited("Start")>>
Narrator: (这已经是你第 {visited_count("Start")} 次来到这里了。)
<<endif>>
<<jump Gatekeeper_Conversation>>
===
title: Gatekeeper_Conversation
---
Gatekeeper: 站住!凡人。想要通过这里,你需要付出代价。
-> 我想买通过的许可。<<if $player_gold >= 50>>
Gatekeeper: 很好,拿去你的钥匙。
<<set $player_gold -= 50>>
<<set $has_key to true>>
<<detour Open_Gate>>
-> 我能用别的方式过去吗? <<once>>
Gatekeeper: 除非你有守卫长的推荐信,但看起来你没有。
<<set $npc_relationship -= 5>>
-> 直接离开。
Gatekeeper: 哼,慢走不送。
<<stop>>
Gatekeeper: 还有别的事吗?
<<jump Gatekeeper_Conversation>>
===
title: Open_Gate
---
Narrator: 咔哒,石门缓缓开启...
<<play_sfx "gate_open">> // 调用自定义命令
<<wait 1.5>>
<<return>>
===
2. 编写自定义命令 [YarnCommand]
YarnCommand 用于执行“动作”。支持无缝的异步等待(Async/Await)。
using System.Threading.Tasks;
using UnityEngine;
using Yarn.Unity;
public class GameActionsManager : MonoBehaviour
{
private static GameActionsManager _instance;
private void Awake()
{
_instance = this;
}
/// <summary>
/// 静态命令:不需要场景中 GameObject 名字即可调用。
/// Yarn 调用:<<play_sfx gate_open>>
/// </summary>
[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}");
}
/// <summary>
/// 实例命令:需要通过物体名进行关联寻址。
/// 假设挂载此组件的物体在场景中命名为 "Player"
/// Yarn 调用:<<move_to Player 10 0 5>>
/// </summary>
[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) 并且必须有返回值。
using UnityEngine;
using Yarn.Unity;
public static class GameYarnFunctions
{
private static int _playerLevel = 5;
private static string _playerClass = "Warrior";
/// <summary>
/// 计算两个数值相加。
/// Yarn 调用:<<if add_numbers(3, 5) > 7>> 或 {$result = add_numbers($a, $b)}
/// </summary>
[YarnFunction("add_numbers")]
public static int AddNumbers(int a, int b)
{
return a + b;
}
/// <summary>
/// 判断玩家是否满足特定等级与职业。
/// Yarn 调用:<<if check_requirement(4, "Warrior")>>
/// </summary>
[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:直接访问变量存储进行简单同步 (以逗号分隔的字符串形式)
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 脚本中做冗长无谓的变量声明。
using System.Collections.Generic;
using UnityEngine;
using Yarn.Unity;
public class NarrativeLocationManager : MonoBehaviour
{
public static NarrativeLocationManager Instance { get; private set; }
private HashSet<string> _visitedLocs = new HashSet<string>();
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)
-
热路径不要使用同步线程阻塞
- 错误示范:在
[YarnCommand]中使用System.Threading.Thread.Sleep(1000)。这会直接卡死 Unity 主线程,导致整个游戏卡顿甚至崩溃。 - 解决方案:在 v3 中,优先使用返回
YarnTask的异步方法,并搭配await YarnTask.Delay(milliseconds)。
- 错误示范:在
-
避免字符串装箱 (Boxing) 与分配
- 尽管叙事系统不可避免地会频繁处理文本,但要避免在
[YarnFunction]或[YarnCommand]的热更新检测逻辑里进行频繁的字符串格式化string.Format或字符串拼接。 - 对话中的数据计算尽量依靠强类型的变量交互。
- 尽管叙事系统不可避免地会频繁处理文本,但要避免在
-
动态注册 vs 静态声明特性特性
- 在新版 Yarn Spinner 中,通过在 Project Settings 开启
Automatic Registration,挂载[YarnCommand]与[YarnFunction]的方法会自动在编译期注册,无需在代码里显式写dialogueRunner.AddCommandHandler。 - 性能避坑:如果是在大项目中,请确保只将专门管理剧情交互的类打上这些标签,避免全局漫天反射查找,提升项目构建/冷启动效率。
- 在新版 Yarn Spinner 中,通过在 Project Settings 开启
-
对话界面渲染垃圾回收 (GC Layout Garbage)
- 使用
LineView自带的打字机特效时,频繁更新 UI 上的文本网格(TextMeshPro)会造成一定程度的 GC。可以在不频繁触发对话的场景下放宽,但对于极高频率的文字泡泡(如动作战斗中的头顶气泡),建议通过自定义的高效打字机(避免富文本标记逐字符重新解析)来规避 GC 问题。
- 使用
-
退出对话时的垃圾释放
- 在切换场景或停止对话时,应主动调用
dialogueRunner.Stop()并对监听的事件做移除处理,避免产生未被 GC 捕获的内存泄露。
- 在切换场景或停止对话时,应主动调用