394 lines
18 KiB
C#
394 lines
18 KiB
C#
// Assets/Scripts/Localization/LocalizationManager.cs
|
||
// 本地化管理器(运行时 JSON 文件驱动)。
|
||
//
|
||
// 数据格式(放在 Assets/_Game/Data/Localization/{Language}/{TableName}.json):
|
||
// {
|
||
// "entries": [
|
||
// { "key": "ui_start", "value": "开始游戏" },
|
||
// { "key": "ui_settings", "value": "设置" }
|
||
// ]
|
||
// }
|
||
//
|
||
// 推荐用法(通过 ServiceLocator 获取 ILocalizationService 实例):
|
||
// ServiceLocator.GetOrDefault<ILocalizationService>()?.Get("ui_start")
|
||
// ServiceLocator.GetOrDefault<ILocalizationService>()?.SetLanguage(Language.English)
|
||
//
|
||
// 便捷静态方法(内部仍走 ServiceLocator,推荐在热路径之外使用):
|
||
// LocalizationManager.Get("ui_start")
|
||
// LocalizationManager.Get("dlg_hero", LocalizationTable.Dialogue)
|
||
// LocalizationManager.GetFormat("REWARD_GOLD", LocalizationTable.UI, 100)
|
||
|
||
using System;
|
||
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
using BaseGames.Core;
|
||
using BaseGames.Core.Assets;
|
||
using BaseGames.Core.Save;
|
||
|
||
namespace BaseGames.Localization
|
||
{
|
||
/// <summary>
|
||
/// 本地化管理器(MonoBehaviour,挂在 Persistent 场景)。
|
||
/// 实现 ILocalizationService + ISaveable,通过 ServiceLocator 注册。
|
||
/// 语言偏好持久化到 SaveData.Settings.Language,不使用 PlayerPrefs。
|
||
/// </summary>
|
||
public class LocalizationManager : MonoBehaviour, ILocalizationService, ISaveable
|
||
{
|
||
// 默认语言:回退链:当前语言 → 英语 → 直接返回 key
|
||
private Language _currentLanguage = Language.ChineseSimplified;
|
||
private readonly Language _fallbackLanguage = Language.English;
|
||
|
||
// 双层缓存:(Language, tableName) 结构体键 → (key → value)
|
||
// 使用值类型 CacheKey 代替字符串插值,消除每次 Get 调用的 string 堆分配。
|
||
private readonly Dictionary<CacheKey, Dictionary<string, string>> _cache = new();
|
||
|
||
// LanguageEventChannelSO:语言切换时向 SO 驱动的 UI 组件广播。
|
||
// 在 Persistent 场景预制体的 Inspector 中拖入 EVT_LanguageChanged.asset。
|
||
[SerializeField]
|
||
[Tooltip("语言切换事件频道(EVT_LanguageChanged.asset)。切换语言时广播,订阅此频道的 UI 组件自动刷新文本。")]
|
||
private LanguageEventChannelSO _languageEventChannel;
|
||
|
||
// ILocalizationService 实例事件(C# 订阅,供不方便引用 SO 的组件使用)
|
||
private event Action<Language> _onLanguageChanged;
|
||
event Action<Language> ILocalizationService.OnLanguageChanged
|
||
{
|
||
add => _onLanguageChanged += value;
|
||
remove => _onLanguageChanged -= value;
|
||
}
|
||
|
||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||
private void Awake()
|
||
{
|
||
if (ServiceLocator.GetOrDefault<ILocalizationService>() != null) { Destroy(gameObject); return; }
|
||
ServiceLocator.Register<ILocalizationService>(this);
|
||
}
|
||
|
||
private void OnEnable()
|
||
{
|
||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
|
||
}
|
||
|
||
private void OnDisable()
|
||
{
|
||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
|
||
}
|
||
|
||
private void OnDestroy()
|
||
{
|
||
ServiceLocator.Unregister<ILocalizationService>(this);
|
||
}
|
||
|
||
// ── ILocalizationService ──────────────────────────────────────────────
|
||
public Language CurrentLanguage => _currentLanguage;
|
||
|
||
/// <summary>切换游戏语言并通知所有订阅者刷新文本。</summary>
|
||
public void SetLanguage(Language language)
|
||
{
|
||
if (_currentLanguage == language) return;
|
||
_currentLanguage = language;
|
||
_onLanguageChanged?.Invoke(language);
|
||
_languageEventChannel?.Raise(language);
|
||
|
||
// 同步到设置文件(ISettingsService 存储 locale code 字符串)
|
||
ServiceLocator.GetOrDefault<ISettingsService>()?.SetLanguage(LanguageToLocaleCode(language));
|
||
}
|
||
|
||
/// <summary>将 Language 枚举转换为标准 locale code 字符串。</summary>
|
||
public static string LanguageToLocaleCode(Language language) => language switch
|
||
{
|
||
Language.ChineseSimplified => "zh-CN",
|
||
Language.Japanese => "ja-JP",
|
||
Language.Korean => "ko-KR",
|
||
Language.English => "en-US",
|
||
_ => "en-US",
|
||
};
|
||
|
||
/// <summary>
|
||
/// 获取本地化字符串(显式接口实现)。
|
||
/// 查找顺序:当前语言 → 回退语言(English)→ 直接返回 key。
|
||
/// </summary>
|
||
string ILocalizationService.Get(string key, string table)
|
||
{
|
||
if (string.IsNullOrEmpty(key)) return string.Empty;
|
||
|
||
// 1. 尝试当前语言
|
||
if (TryGetFromTable(_currentLanguage, table, key, out string text))
|
||
return text;
|
||
|
||
// 2. 回退到 English
|
||
if (_currentLanguage != _fallbackLanguage &&
|
||
TryGetFromTable(_fallbackLanguage, table, key, out text))
|
||
return text;
|
||
|
||
// 3. 最终回退:原始 key
|
||
return key;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 尝试获取本地化字符串(显式接口实现)。
|
||
/// 返回 false 时 value 为 null,key 不会作为 value 返回。
|
||
/// </summary>
|
||
bool ILocalizationService.TryGet(string key, out string value, string table)
|
||
{
|
||
if (string.IsNullOrEmpty(key)) { value = null; return false; }
|
||
|
||
if (TryGetFromTable(_currentLanguage, table, key, out value)) return true;
|
||
if (_currentLanguage != _fallbackLanguage &&
|
||
TryGetFromTable(_fallbackLanguage, table, key, out value)) return true;
|
||
|
||
value = null;
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取带格式化参数的本地化字符串(显式接口实现)。
|
||
/// 格式化失败时静默返回原始模板字符串。
|
||
/// </summary>
|
||
string ILocalizationService.GetFormat(string key, string table, params object[] args)
|
||
{
|
||
string template = ((ILocalizationService)this).Get(key, table);
|
||
if (args == null || args.Length == 0) return template;
|
||
try { return string.Format(template, args); }
|
||
catch (Exception e)
|
||
{
|
||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||
Debug.LogWarning($"[LocalizationManager] GetFormat '{key}' 格式化失败: {e.Message}");
|
||
#else
|
||
_ = e;
|
||
#endif
|
||
return template;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取带数量的复数形式本地化字符串(显式接口实现)。
|
||
/// 先查找 "{key}_one"(count==1)或 "{key}_other"(count≠1),找不到则回退到基础 key。
|
||
/// 模板以 string.Format(template, count) 展开。
|
||
/// </summary>
|
||
string ILocalizationService.GetPlural(string key, int count, string table)
|
||
{
|
||
string pluralKey = count == 1 ? $"{key}_one" : $"{key}_other";
|
||
if (TryGetFromTable(_currentLanguage, table, pluralKey, out string text) ||
|
||
(_currentLanguage != _fallbackLanguage &&
|
||
TryGetFromTable(_fallbackLanguage, table, pluralKey, out text)))
|
||
{
|
||
try { return string.Format(text, count); }
|
||
catch { return text; }
|
||
}
|
||
|
||
// 回退到基础 key
|
||
string baseText = ((ILocalizationService)this).Get(key, table);
|
||
try { return string.Format(baseText, count); }
|
||
catch { return baseText; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// 同步预热指定语言的所有已知本地化表,不阻塞检测已缓存表(显式接口实现)。
|
||
/// </summary>
|
||
void ILocalizationService.PreloadTables(Language language)
|
||
{
|
||
foreach (var table in LocalizationTable.All)
|
||
{
|
||
var ck = new CacheKey(language, table);
|
||
if (_cache.ContainsKey(ck)) continue;
|
||
|
||
var dict = LoadTable(language, table);
|
||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||
if (dict == null)
|
||
Debug.LogWarning(
|
||
$"[LocalizationManager] PreloadTables:{language}/{table} 未找到," +
|
||
$"请确认 Addressable 地址 'Localization/{language}/{table}' 已配置。");
|
||
#endif
|
||
_cache[ck] = dict;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 异步分帧预热:每帧加载一个表,不阻塞主线程(显式接口实现)。
|
||
/// 建议在 Loading Screen 的协程中调用。
|
||
/// </summary>
|
||
void ILocalizationService.PreloadTablesAsync(Language language, Action onComplete)
|
||
=> StartCoroutine(PreloadTablesRoutine(language, onComplete));
|
||
|
||
private IEnumerator PreloadTablesRoutine(Language language, Action onComplete)
|
||
{
|
||
foreach (var table in LocalizationTable.All)
|
||
{
|
||
var ck = new CacheKey(language, table);
|
||
if (_cache.ContainsKey(ck)) { yield return null; continue; }
|
||
|
||
// 经 AssetLoader 门面加载(本地小 JSON,同步开销可忽略;分帧 yield 避免一次性卡顿)
|
||
var dict = LoadTable(language, table);
|
||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||
if (dict == null)
|
||
Debug.LogWarning(
|
||
$"[LocalizationManager] PreloadTablesAsync:{language}/{table} 未找到," +
|
||
$"请确认 Addressable 地址 'Localization/{language}/{table}' 已配置。");
|
||
#endif
|
||
_cache[ck] = dict;
|
||
yield return null; // 分帧:每帧加载一个表,避免一次性卡顿
|
||
}
|
||
|
||
onComplete?.Invoke();
|
||
}
|
||
|
||
// ── ISaveable ─────────────────────────────────────────────────────────
|
||
public void OnSave(SaveData data)
|
||
{
|
||
if (data?.Settings == null) return;
|
||
data.Settings.Language = _currentLanguage.ToString();
|
||
}
|
||
|
||
public void OnLoad(SaveData data)
|
||
{
|
||
if (data?.Settings == null || string.IsNullOrEmpty(data.Settings.Language)) return;
|
||
if (Enum.TryParse<Language>(data.Settings.Language, out var lang))
|
||
SetLanguage(lang);
|
||
}
|
||
|
||
// ── 静态便捷方法 ─────────────────────────────────────────────────────────
|
||
/// <summary>
|
||
/// 静态快捷获取本地化字符串。委托给 ILocalizationService 实例;服务未注册时直接返回 key。
|
||
/// 表名建议使用 <see cref="LocalizationTable"/> 中的常量。
|
||
/// </summary>
|
||
public static string Get(string key, string table = LocalizationTable.UI)
|
||
=> ServiceLocator.GetOrDefault<ILocalizationService>()?.Get(key, table) ?? key;
|
||
|
||
/// <summary>
|
||
/// 静态快捷尝试获取本地化字符串。服务未注册时返回 false。
|
||
/// </summary>
|
||
public static bool TryGet(string key, out string value, string table = LocalizationTable.UI)
|
||
{
|
||
var svc = ServiceLocator.GetOrDefault<ILocalizationService>();
|
||
if (svc != null) return svc.TryGet(key, out value, table);
|
||
value = null;
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 静态快捷获取带格式化参数的本地化字符串。
|
||
/// 服务未注册时以 key 作为模板直接格式化后返回。
|
||
/// </summary>
|
||
public static string GetFormat(string key, string table, params object[] args)
|
||
{
|
||
var svc = ServiceLocator.GetOrDefault<ILocalizationService>();
|
||
if (svc != null) return svc.GetFormat(key, table, args);
|
||
if (args == null || args.Length == 0) return key;
|
||
try { return string.Format(key, args); }
|
||
catch { return key; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// 静态快捷获取带数量的复数形式本地化字符串。
|
||
/// 服务未注册时直接返回 key。
|
||
/// </summary>
|
||
public static string GetPlural(string key, int count, string table = LocalizationTable.UI)
|
||
=> ServiceLocator.GetOrDefault<ILocalizationService>()?.GetPlural(key, count, table) ?? key;
|
||
|
||
// ── 编辑器预览(不依赖 ServiceLocator 实例)────────────────────────────
|
||
#if UNITY_EDITOR
|
||
// 编辑器预览缓存:"{language}/{table}" → (key → value)
|
||
// 生命周期与编辑器进程相同;域重载时自动清空(static 字段随域重载重置)。
|
||
private static readonly Dictionary<string, Dictionary<string, string>> s_editorPreviewCache = new();
|
||
|
||
/// <summary>
|
||
/// 编辑器工具专用:不依赖运行时服务实例,直接用 AssetDatabase 按路径读取本地化文本
|
||
/// (编辑器期无需初始化 Addressables)。结果缓存在静态字典中,同一编辑器会话内同一表只加载一次。
|
||
/// 找不到时返回 null(区别于运行时的 key 回退,便于调用方判断是否显示 key)。
|
||
/// </summary>
|
||
public static string GetEditorPreview(string key, string table = LocalizationTable.UI,
|
||
Language language = Language.ChineseSimplified)
|
||
{
|
||
if (string.IsNullOrEmpty(key)) return null;
|
||
|
||
var dict = GetEditorTable(language, table)
|
||
?? GetEditorTable(Language.English, table);
|
||
if (dict == null) return null;
|
||
|
||
dict.TryGetValue(key, out var value);
|
||
return value;
|
||
}
|
||
|
||
public static Dictionary<string, string> GetEditorTable(Language language, string table)
|
||
{
|
||
string cacheKey = $"{language}/{table}";
|
||
if (s_editorPreviewCache.TryGetValue(cacheKey, out var cached))
|
||
return cached;
|
||
|
||
// 编辑器按资产路径读取(与运行时 Addressable 地址对应的物理位置)
|
||
string path = LocalizationPaths.AssetPath(language, table);
|
||
var asset = UnityEditor.AssetDatabase.LoadAssetAtPath<TextAsset>(path);
|
||
var dict = asset == null ? null : ParseTableText(asset.text);
|
||
s_editorPreviewCache[cacheKey] = dict;
|
||
return dict;
|
||
}
|
||
|
||
/// <summary>编辑器工具:清除编辑器预览缓存(修改 JSON 文件后手动刷新时调用)。</summary>
|
||
public static void ClearEditorPreviewCache() => s_editorPreviewCache.Clear();
|
||
#endif
|
||
|
||
// ── 内部缓存查找 ──────────────────────────────────────────────────────
|
||
private bool TryGetFromTable(Language language, string table, string key, out string value)
|
||
{
|
||
var ck = new CacheKey(language, table);
|
||
if (!_cache.TryGetValue(ck, out var dict))
|
||
{
|
||
dict = LoadTable(language, table);
|
||
_cache[ck] = dict; // 即使加载失败也存入空字典,避免每帧重试
|
||
}
|
||
if (dict != null && dict.TryGetValue(key, out value)) return true;
|
||
value = null;
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 通过 Addressables 同步加载字符串表(地址 "Localization/{language}/{table}")。
|
||
/// 项目统一用 Addressables 管理资源,不使用 Resources。
|
||
/// 表不存在时返回 null(先用 LoadResourceLocations 判存在,避免缺表时的错误日志刷屏)。
|
||
/// </summary>
|
||
private static Dictionary<string, string> LoadTable(Language language, string table)
|
||
{
|
||
string address = LocalizationPaths.Address(language, table);
|
||
// 统一经 AssetLoader 门面:缺键安全检查 + 同步加载 + 释放
|
||
if (!AssetLoader.Exists(address, typeof(TextAsset))) return null;
|
||
|
||
var (asset, handle) = AssetLoader.LoadSync<TextAsset>(address);
|
||
var dict = asset == null ? null : ParseTableText(asset.text);
|
||
AssetLoader.Release(handle); // 已解析为字典,释放资源句柄
|
||
return dict;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将 JSON 文本解析为 key→value 字典。
|
||
/// 委托给 <see cref="LocalizationSerializer.Parse"/>,与编辑器写盘共用同一格式逻辑。
|
||
/// 返回 null 表示格式无效。
|
||
/// </summary>
|
||
private static Dictionary<string, string> ParseTableText(string jsonText)
|
||
=> LocalizationSerializer.Parse(jsonText);
|
||
|
||
// ── 缓存键(值类型,消除字符串插值 GC)──────────────────────────────
|
||
private readonly struct CacheKey : IEquatable<CacheKey>
|
||
{
|
||
private readonly Language _language;
|
||
private readonly string _table;
|
||
|
||
public CacheKey(Language language, string table)
|
||
{
|
||
_language = language;
|
||
_table = table;
|
||
}
|
||
|
||
public bool Equals(CacheKey other)
|
||
=> _language == other._language &&
|
||
string.Equals(_table, other._table, StringComparison.Ordinal);
|
||
|
||
public override bool Equals(object obj)
|
||
=> obj is CacheKey other && Equals(other);
|
||
|
||
public override int GetHashCode()
|
||
=> HashCode.Combine((int)_language, _table);
|
||
}
|
||
}
|
||
}
|