284 lines
11 KiB
Markdown
284 lines
11 KiB
Markdown
|
|
# 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 剧情文件:
|
|||
|
|
|
|||
|
|
```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)。
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
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)** 并且**必须有返回值**。
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
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:直接访问变量存储进行简单同步 (以逗号分隔的字符串形式)
|
|||
|
|
```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<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)
|
|||
|
|
|
|||
|
|
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 捕获的内存泄露。
|