168 lines
6.3 KiB
Markdown
168 lines
6.3 KiB
Markdown
# Yarn Spinner 大型叙事与本地化系统架构(3A 工程规范)
|
||
|
||
在大规模、高复杂度且需支持多国语言文本/音频本地化的互动叙事项目中,传统的纯 Yarn 内部变量存储会成为架构瓶颈。本文件沉淀了面向 3A 叙事系统的工程设计方案、SSOT(单一事实来源)数据存储架构、Addressables 异步音频加载,以及 Unity 官方本地化包与 Wwise 音频集成规范。
|
||
|
||
---
|
||
|
||
## 核心架构原则 (Core Architectural Principles)
|
||
|
||
1. **导演与管家解耦 (Director & Butler Decoupling)**
|
||
Yarn 脚本纯粹充当“导演”(控制剧情流向与台词显示),C# 全局数据中心充当“管家”(掌控玩家属性、道具、标志位等事实数据)。Yarn 脚本通过 C# 特性接口进行只读查询与写入。
|
||
|
||
2. **单一事实来源 (Single Source of Truth, SSOT)**
|
||
剧情相关的全局变量在 C# 侧通过强类型(如 ScriptableObject 或 GameManager)集中保存,与游戏的存读档(Save/Load)系统一键对接,不依赖 Yarn 的 InMemory 字典。
|
||
|
||
3. **异步按需载入 (Asynchronous On-Demand Loading)**
|
||
数以万计的配音音频资源(VO)严禁显式挂载,必须基于 Yarn Project 自动分配的 **Line ID (Line Tag)** 通过 `Addressables` 寻址异步载入,播放完成后立即释放,防止爆内存。
|
||
|
||
---
|
||
|
||
## 核心类与接口设计
|
||
|
||
### 1. `PlayerStoryStateSO` (ScriptableObject)
|
||
- **职责**:保存全局剧情进度、完成的任务列表、解锁的地图标志位。
|
||
- **最佳实践**:内部使用高检索效率的 `HashSet<string>`,提供强类型的增删查 API,同时支持一键 JSON 序列化持久化。
|
||
|
||
### 2. `AudioLinePresenter` (DialoguePresenterBase)
|
||
- **职责**:v3.x 异步核心 UI 展示。根据台词的 `line.LineID` 从 Addressables 动态加载并播放对应语种的配音,并将文字打字机渲染时间与音频物理长度精确匹配。
|
||
|
||
---
|
||
|
||
## 3A 本地化与音频同步黄金代码模板
|
||
|
||
### 1. C# 剧情全局状态中心 (SO 模式)
|
||
|
||
```csharp
|
||
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
using Yarn.Unity;
|
||
|
||
[CreateAssetMenu(fileName = "StoryState", menuName = "Narrative/Story State")]
|
||
public class StoryStateSO : ScriptableObject
|
||
{
|
||
public static StoryStateSO Instance;
|
||
|
||
public int gold = 100;
|
||
[SerializeField] private List<string> unlockedFlags = new List<string>();
|
||
|
||
private HashSet<string> _flagSet = new HashSet<string>();
|
||
|
||
public void Initialize()
|
||
{
|
||
Instance = this;
|
||
_flagSet = new HashSet<string>(unlockedFlags);
|
||
}
|
||
|
||
public bool HasFlag(string flagId) => _flagSet.Contains(flagId);
|
||
|
||
public void SetFlag(string flagId)
|
||
{
|
||
if (_flagSet.Add(flagId))
|
||
{
|
||
unlockedFlags.Add(flagId);
|
||
}
|
||
}
|
||
}
|
||
|
||
public static class YarnNarrativeBridge
|
||
{
|
||
[YarnFunction("check_flag")]
|
||
public static bool CheckFlag(string flagId)
|
||
{
|
||
return StoryStateSO.Instance != null && StoryStateSO.Instance.HasFlag(flagId);
|
||
}
|
||
|
||
[YarnCommand("set_flag")]
|
||
public static void SetFlag(string flagId)
|
||
{
|
||
StoryStateSO.Instance?.SetFlag(flagId);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 2. 基于 Addressables 异步多语言音频控制台词组件 (v3.x Presenter)
|
||
|
||
```csharp
|
||
using System.Threading;
|
||
using UnityEngine;
|
||
using UnityEngine.AddressableAssets;
|
||
using UnityEngine.Localization.Settings;
|
||
using Yarn.Unity;
|
||
|
||
public class AddressableAudioPresenter : DialoguePresenterBase
|
||
{
|
||
[SerializeField] private TMPro.TextMeshProUGUI subtitleText;
|
||
[SerializeField] private AudioSource voSource;
|
||
|
||
public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token)
|
||
{
|
||
// 1. 获取当前玩家设置的语言代码 (如 "zh-CN", "en-US")
|
||
string langCode = LocalizationSettings.SelectedLocale.Identifier.Code;
|
||
string addressablePath = $"VO/{langCode}/{line.LineID}";
|
||
|
||
AudioClip loadedVo = null;
|
||
try
|
||
{
|
||
var opHandle = Addressables.LoadAssetAsync<AudioClip>(addressablePath);
|
||
loadedVo = await opHandle.Task;
|
||
}
|
||
catch (System.Exception)
|
||
{
|
||
Debug.LogWarning($"[VO] 寻址地址不存在: {addressablePath}");
|
||
}
|
||
|
||
// 2. 音频驱动打字机渲染
|
||
if (loadedVo != null)
|
||
{
|
||
voSource.clip = loadedVo;
|
||
voSource.Play();
|
||
await SyncTextWithTypewriter(line.TextWithoutCharacterName.Text, loadedVo.length, token.Token);
|
||
Addressables.Release(loadedVo); // 精准释放,内存无忧
|
||
}
|
||
else
|
||
{
|
||
float speedBasedDuration = line.TextWithoutCharacterName.Text.Length * 0.08f;
|
||
await SyncTextWithTypewriter(line.TextWithoutCharacterName.Text, speedBasedDuration, token.Token);
|
||
}
|
||
|
||
// 3. 等待确认
|
||
while (!Input.GetKeyDown(KeyCode.Space) && !token.Token.IsCancellationRequested)
|
||
{
|
||
await YarnTask.Yield();
|
||
}
|
||
}
|
||
|
||
private async YarnTask SyncTextWithTypewriter(string text, float targetDuration, CancellationToken ct)
|
||
{
|
||
subtitleText.text = "";
|
||
float delayPerChar = targetDuration / text.Length;
|
||
for (int i = 0; i < text.Length; i++)
|
||
{
|
||
if (ct.IsCancellationRequested)
|
||
{
|
||
subtitleText.text = text;
|
||
voSource.Stop();
|
||
break;
|
||
}
|
||
subtitleText.text += text[i];
|
||
await YarnTask.Delay((int)(delayPerChar * 1000));
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 性能避坑与工程优化红线 (Performance & Engine Optimization)
|
||
|
||
1. **禁止热加载 Yarn Project**
|
||
在加载新的 Yarn 脚本时,尽量避免在运行时频繁销毁和重建 `DialogueRunner`,而是仅在使用完后清空 `VariableStorage`。采用 Addressables 加载 `YarnProject` 资产,以场景级别进行卸载。
|
||
|
||
2. **Odin Inspector 可视化优化**
|
||
为 ScriptableObject 添加 Odin 标注特性(如 `[Searchable]`),在剧情标志位堆积至成百上千个时,能支持运行时高检索,方便策划进行精准断点调试。
|
||
|
||
3. **文本本地化包 (Localization Tables) 的 GC 问题**
|
||
Unity 官方多语言表格底层存在查询开销。在长台词切换高频场景下,建议使用内存缓存(Cache)层,避免每一帧都去底层查询翻译 String,造成大面积 GC Alloc。
|