本文应该是关于《终面/OneLastInterview》的最后一篇复盘系列文章了,在这篇文章里我想总结一下《终面》的多语言功能实现。在浏览 Steam 商城中的一些没有官方中文的游戏时,经常能刷到类似下图的评论:

《尼尔·机械纪元》下的评论

现代电子游戏早已跨越了国与国之间的界限,只要手头有资金,任何游戏都能轻易购入库中吃灰。作为游戏开发者,我们自然也希望自己费心费力制作出来的作品能被世界各地的玩家所喜爱。对玩家们来说,他们也必然倾向于购买一款带有母语官方翻译的游戏,否则想玩一个游戏还得找语言补丁,无疑增加了游戏门槛。

因此在开发《终面》的时候,我们也注意到了多语言的重要性。由于实际开发的时间比较紧凑,《终面》的多语言系统最终是基于 Unity Asset Store 上的免费插件 Simple Localization with Google Sheets 实现的。

Simple Localization with Google Sheets 是一款专注于通过 Google Sheets(谷歌表格)实现 Unity 项目本地化的工具插件。其核心设计思想是通过云端表格管理多语言文本,并实现动态同步与运行时切换语言的功能。

这个插件很简单易用,使用步骤如下:

  1. 环境配置
    • 安装插件:从 Unity Asset Store 导入插件,确保依赖项(如 Google Sheets API 权限)已配置。
    • 创建 Google Sheets 文档:按分类设计工作表。
  2. 获取数据
    • 设置表格 ID:在 Unity 编辑器中填写 Google Sheets 的表格 ID 和工作表名称。
    • 同步数据:点击插件的 Download 按钮,自动下载并解析表格数据到 Resources/Localization 目录。
  3. UI绑定
    • 挂载组件:为需要本地化的 Text 组件添加 LocalizeText,并指定对应的键。
    • 实现多语言:通过代码切换语言,验证文本更新效果。

该插件的文件目录如下,在下文中我将结合文件目录剖析一下它的源码实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
SimpleLocalization
|- Resources
|- LocalizationSettings.asset
|- Localization
|- 工作表1.csv
|- Scripts
|- Constants.cs
|- LocalizationManager.cs
|- LocalizationSettings.cs
|- LocalizationDropdown.cs
|- LocalizedText.cs
|- Sheet.cs
|- Editor
|- DropdownEditor.cs
|- EditorActions.cs
|- LocalizationEditor.cs
|- LocalizationEditorWindw.cs
|- LocalizationSettingsEditor.cs
|- LocalizationSettingsWindow.cs
|- LocalizationUtils.cs
|- LocalizedTextEditor.cs
|- LocalizedTranslate.cs
|- TextEditor.cs

LocalizationSettings.asset

在 Unity 中,.asset 文件是一种资源文件,用于存储项目中的自定义数据或配置信息。它是 Unity 序列化系统的一部分,允许开发者将非场景对象(如配置数据、ScriptableObject 等)保存为独立的资源文件,便于管理和复用。

Localization

本地化谷歌表格 下载并解析后的.csv文件

这个文件夹里存放的是从谷歌文档上下载下来的 .csv 文件。

Scripts

Constants.cs

Constants 是一个静态类,用于存储插件中的常量值(包括用于解析谷歌文档工作表的 Google Apps Script 地址、URL 模板等)和示例配置(Examples),在此不过多叙述。

1
2
3
4
5
public static class Constants {
public const string LocalizationEditorUrl = "";
public const string SheetResolverUrl = "https://script.google.com/macros/s/AKfycbycW2dsGZhc2xJh2Fs8yu9KUEqdM-ssOiK1AlES3crLqQa1lkDrI4mZgP7sJhmFlGAD/exec";
public const string TableUrlPattern = "https://docs.google.com/spreadsheets/d/{0}";
}

LocalizationManager.cs

LocalizationManager(下文简称 LM)是 Simple Localization with Google Sheets 插件的核心部分,负责管理多语言本地化功能。它可以读取 .csv 格式的本地化数据,并在运行时动态切换和获取翻译文本。从代码层面看,它主要有以下四个功能:

  1. 多语言数据管理
    LM 通过双层字典存储多语言文本数据,其中外层字典的键是语言名称(如 “English”、“Chinese”),内层字典的键是文本的唯一标识(如 “UI_StartButton”),值是翻译后的文本内容

    1
    Dictionary<string, Dictionary<string, string>> Dictionary = new();
  2. 数据加载与解析

    负责管理多语言条目的字典 Dictionary 通过 Read 方法从 .csv 文件读取数据、通过 GetLinesGetColumns 方法从表中读取行或列。LM 支持多表(Sheet)加载,并自动检测重复键和语言。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    // 从 `.csv` 文件读取数据
    public static void Read() {
    // 已经有数据了
    if (Dictionary.Count > 0) return;

    var keys = new List<string>();
    // 获取表格
    foreach (var sheet in LocalizationSettings.Instance.Sheets) {
    // 获取.csv表格的文本内容
    var textAsset = sheet.TextAsset;
    // 获取.csv的所有行
    var lines = GetLines(textAsset.text);
    // 逗号分隔第一行,即语言类型(见上图)
    var languages = lines[0].Split(',').Select(i => i.Trim()).ToList();

    // 检查语言是否存在重复
    if (languages.Count != languages.Distinct().Count()) {
    Debug.LogError($"Duplicated languages found in `{sheet.Name}`. This sheet is not loaded.");
    continue;
    }

    // 初始化语言字典
    for (var i = 1; i < languages.Count; i++) {
    if (!Dictionary.ContainsKey(languages[i])) {
    Dictionary.Add(languages[i], new Dictionary<string, string>());
    }
    }

    // 解析每行数据
    for (var i = 1; i < lines.Count; i++) {
    // 获取该行的所有列
    var columns = GetColumns(lines[i]);
    // 文本的唯一标识
    var key = columns[0];
    // 不存在文本的唯一标识则直接跳过
    if (key == "") continue;

    // 检查键是否重复
    if (keys.Contains(key)) {
    Debug.LogError($"Duplicated key `{key}` found in `{sheet.Name}`. This key is not loaded.");
    continue;
    }

    keys.Add(key);

    // 存储翻译文本
    for (var j = 1; j < languages.Count; j++) {
    if (Dictionary[languages[j]].ContainsKey(key)) {
    Debug.LogError($"Duplicated key `{key}` in `{sheet.Name}`.");
    }
    else {
    Dictionary[languages[j]].Add(key, columns[j]);
    }
    }
    }
    }
    // 设置默认语言(指定Language字符串的值)
    AutoLanguage();
    }

    很显然这里的 LocalizationSettings 是一个单例,表格就存放在其中。我们会在稍后介绍它。

  3. 语言切换与事件通知
    LM 通过 Language 字符串设置当前语言,并触发 OnLocalizationChanged 事件通知订阅了该事件的 UI 组件更新。具体的更新逻辑会在稍后介绍到。

    1
    2
    3
    4
    5
    6
    7
    public static event Action OnLocalizationChanged = () => { };

    public static string Language {
    get => _language;
    // 修改 Language 时触发 OnLocalizationChanged 回调
    set { _language = value; OnLocalizationChanged(); }
    }
  4. 文本翻译
    这也是 LM 实现本地化的核心方法。LM 提供了 Localize 方法,根据键获取当前语言的翻译文本:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
     public static string Localize(string localizationKey) {
    // 初始化字典
    if (Dictionary.Count == 0)
    Read();
    // 没找到该语言
    if (!Dictionary.ContainsKey(Language)) throw new KeyNotFoundException("Language not found: " + Language);
    // 检查键是否缺失,或者该键所对应的文本是否缺失
    var missed = !Dictionary[Language].ContainsKey(localizationKey) || Dictionary[Language][localizationKey] == "";

    if (missed) {
    Debug.LogWarning($"Translation not found: {localizationKey} ({Language}).");

    return Dictionary["English"].ContainsKey(localizationKey) ? Dictionary["English"][localizationKey] : localizationKey;
    }
    // 返回翻译的文本
    return Dictionary[Language][localizationKey];
    }

    LM 同时支持动态参数替换(如 “Welcome, {0}!”)这是通过 Localize 方法的重载实现的。

或许还有进一步优化的空间?

我们从上面的代码中可以注意到,在首次启动游戏时,LM 都会创建一个新的 Dictionary 用于记录多语言信息。如果游戏的文本量非常庞大,那么每次打开游戏时都需要花费一定的时间进行读取,这其实是没必要的。
我的想法是,或许我们可以将字典数据缓存到本地,在文本发生变更时再重新获取,这样可以减少重复加载的次数,节约性能。

LocalizationSettings.cs

LocalizationSettings(以下简称 LS)是一个全局唯一的单例,负责管理与谷歌表格的交互本地化数据的下载与解析,以及在 Unity 编辑器中的 UI 操作。我们可以通过 LocalizationSettings.asset 配置相关数据,并利用编辑器按钮帮助开发者执行相关操作。

从代码层面看,它主要有以下四个功能:

  1. 解析工作表(Resolve Sheets)
    这个功能对应了面板上的 “Resolve Sheets” 按钮,可以获取表格中的所有工作表信息。LS 通过 Google Apps Script 解析表格中的工作表信息。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // 存储工作表
    public List<Sheet> Sheets = new();

    // 编辑器方法
    public void ResolveGoogleSheets() {
    EditorCoroutineUtility.StartCoroutineOwnerless(ResolveGoogleSheetsCoroutine());

    // 解析工作表
    IEnumerator ResolveGoogleSheetsCoroutine() {
    // 调用谷歌api解析表格
    using var request = UnityWebRequest.Get($"{Constants.SheetResolverUrl}?tableUrl={TableId}");
    yield return request.SendWebRequest();
    // 解析成功
    if (request.error == null) {
    // 存储表格下各个工作表名称与 ID 的映射关系
    var sheetsDict = JsonConvert.DeserializeObject<Dictionary<string, long>>(request.downloadHandler.text);
    Sheets.Clear();
    foreach (var item in sheetsDict) {
    Sheets.Add(new Sheet { Id = item.Value, Name = item.Key });
    }
    }
    }
    }
  2. 下载数据(Download Sheets)
    这个功能对应了面板上的 “Download Sheets” 按钮,可以将工作表导出为 .csv 文件并保存到 SaveFolder 路径上。LS 通过 DownloadGoogleSheetsCoroutine 协程,从网站上下载工作表并保存为 .csv 文件。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public string TableId; // Google Sheets 表格 ID
    public List<Sheet> Sheets = new(); // 工作表列表
    public UnityEngine.Object SaveFolder; // CSV 文件保存路径

    public IEnumerator DownloadGoogleSheetsCoroutine(Action callback = null, bool silent = false) {
    // 检查配置是否合法
    if (string.IsNullOrEmpty(TableId) || Sheets.Count == 0 || SaveFolder == null)
    yield break;

    // 下载每个工作表
    for (var i = 0; i < Sheets.Count; i++) {
    var sheet = Sheets[i];
    var url = string.Format(UrlPattern, TableId, sheet.Id); // 生成下载 URL
    var request = UnityWebRequest.Get(url); // 发起请求
    yield return request.SendWebRequest();

    // 保存为 CSV 文件
    var path = Path.Combine(AssetDatabase.GetAssetPath(SaveFolder), sheet.Name + ".csv");
    File.WriteAllBytes(path, request.downloadHandler.data);
    Sheets[i].TextAsset = AssetDatabase.LoadAssetAtPath<TextAsset>(path);
    }

    callback?.Invoke(); // 完成回调
    }

    上面代码中的 UrlPattern 的值是 “https://docs.google.com/spreadsheets/d/{0}/export?format=csv&gid={1}”,它是谷歌表格的导出功能的 api url。

  3. 编辑器按钮
    LocalizationSettings.asset 面板上我们可以看到几个按钮,其中和表格操作有关的有以下三种。这些编辑器按钮分别对应了解析、下载和打开表格。

    1
    2
    3
    4
    5
    public void DisplayButtons() {
    if (GUILayout.Button("↺ Resolve Sheets")) ResolveGoogleSheets();
    if (GUILayout.Button("▼ Download Sheets")) DownloadGoogleSheets();
    if (GUILayout.Button("❖ Open Google Sheets")) OpenGoogleSheets();
    }

LocalizationDropdown.cs / LocalizedText.cs

LocalizedTextLocalizedDropdown 很类似,都是用于实现 UI 组件的多语言本地化功能的脚本。它们的核心目标都是根据当前语言设置动态更新 UI 文本内容,但分别针对不同的 UI 组件(顾名思义,Text 和 Dropdown)。我们就以 LocalizedText(下文简称 LT)为例吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 需要挂载在含有 Text 组件的对象上
[RequireComponent(typeof(Text))]
public class LocalizedText : MonoBehaviour {
public string LocalizationKey; // 本地化键(文本唯一标识符)

public void Start() {
Localize(); // 初始化时本地化
LocalizationManager.OnLocalizationChanged += Localize; // 订阅语言切换事件
}

public void OnDestroy() {
LocalizationManager.OnLocalizationChanged -= Localize; // 取消订阅
}

private void Localize() {
GetComponent<Text>().text = LocalizationManager.Localize(LocalizationKey); // 更新文本
}
}

LT 需要挂载在包含 Text 组件的文本对象上才能发挥作用。在检查器中,为 LocalizationKey 指定该文本所对应的唯一标识符即可。LT 订阅了我们在 LM 中声明的语言切换事件 OnLocalizationChanged,在切换时更新文本,实现多语言切换的功能。

在《终面》中,我们使用的是 TMP 而非 Text,适配也很简单,只需要新建一个 LocalizedTMP 脚本,对原本 LT 的内容稍加修改即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[RequireComponent(typeof(TextMeshProUGUI))]
public class LocalizedTMP : MonoBehaviour {
public string LocalizationKey;

public void Start() {
Localize();
LocalizationManager.OnLocalizationChanged += Localize;
}

public void OnDestroy() {
LocalizationManager.OnLocalizationChanged -= Localize;
}

private void Localize() {
GetComponent<TextMeshProUGUI>().text = LocalizationManager.Localize(LocalizationKey);
}
}

Sheet.cs

Sheet 是在 LS 中存储工作表的数据结构,其内容十分简单,包含了工作表名称id内容

1
2
3
4
5
public class Sheet {
public string Name;
public long Id;
public TextAsset TextAsset;
}

Editor

插件面板

Editor文件夹里看似有很多脚本,但其实它们实现的就是在Window选项卡下Simple Localization的相关编辑器功能(如上图所示),本文就不多展开叙述了。

参考资料

https://assetstore.unity.com/packages/tools/gui/simple-localization-with-google-sheets-120113
https://github.com/hippogamesunity/SimpleLocalization/wiki