Files
Cielonos/.agent/skills-Cielonos/unity-technician/knowledge/YarnSpinner.md
SoulliesOfficial 8186f54e90 新场景,剧情
2026-06-02 12:55:39 -04:00

11 KiB
Raw Blame History

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 仅支持 stringfloatbool 基础类型,对于“玩家已访问的地点列表 (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)

  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 捕获的内存泄露。