// 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()?.Get("ui_start") // ServiceLocator.GetOrDefault()?.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 { /// /// 本地化管理器(MonoBehaviour,挂在 Persistent 场景)。 /// 实现 ILocalizationService + ISaveable,通过 ServiceLocator 注册。 /// 语言偏好持久化到 SaveData.Settings.Language,不使用 PlayerPrefs。 /// 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> _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 _onLanguageChanged; event Action ILocalizationService.OnLanguageChanged { add => _onLanguageChanged += value; remove => _onLanguageChanged -= value; } // ── 生命周期 ────────────────────────────────────────────────────────── private void Awake() { if (ServiceLocator.GetOrDefault() != null) { Destroy(gameObject); return; } ServiceLocator.Register(this); } private void OnEnable() { ServiceLocator.GetOrDefault()?.Register(this); } private void OnDisable() { ServiceLocator.GetOrDefault()?.Unregister(this); } private void OnDestroy() { ServiceLocator.Unregister(this); } // ── ILocalizationService ────────────────────────────────────────────── public Language CurrentLanguage => _currentLanguage; /// 切换游戏语言并通知所有订阅者刷新文本。 public void SetLanguage(Language language) { if (_currentLanguage == language) return; _currentLanguage = language; _onLanguageChanged?.Invoke(language); _languageEventChannel?.Raise(language); // 同步到设置文件(ISettingsService 存储 locale code 字符串) ServiceLocator.GetOrDefault()?.SetLanguage(LanguageToLocaleCode(language)); } /// 将 Language 枚举转换为标准 locale code 字符串。 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", }; /// /// 获取本地化字符串(显式接口实现)。 /// 查找顺序:当前语言 → 回退语言(English)→ 直接返回 key。 /// 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; } /// /// 尝试获取本地化字符串(显式接口实现)。 /// 返回 false 时 value 为 null,key 不会作为 value 返回。 /// 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; } /// /// 获取带格式化参数的本地化字符串(显式接口实现)。 /// 格式化失败时静默返回原始模板字符串。 /// 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; } } /// /// 获取带数量的复数形式本地化字符串(显式接口实现)。 /// 先查找 "{key}_one"(count==1)或 "{key}_other"(count≠1),找不到则回退到基础 key。 /// 模板以 string.Format(template, count) 展开。 /// 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; } } /// /// 同步预热指定语言的所有已知本地化表,不阻塞检测已缓存表(显式接口实现)。 /// 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; } } /// /// 异步分帧预热:每帧加载一个表,不阻塞主线程(显式接口实现)。 /// 建议在 Loading Screen 的协程中调用。 /// 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(data.Settings.Language, out var lang)) SetLanguage(lang); } // ── 静态便捷方法 ───────────────────────────────────────────────────────── /// /// 静态快捷获取本地化字符串。委托给 ILocalizationService 实例;服务未注册时直接返回 key。 /// 表名建议使用 中的常量。 /// public static string Get(string key, string table = LocalizationTable.UI) => ServiceLocator.GetOrDefault()?.Get(key, table) ?? key; /// /// 静态快捷尝试获取本地化字符串。服务未注册时返回 false。 /// public static bool TryGet(string key, out string value, string table = LocalizationTable.UI) { var svc = ServiceLocator.GetOrDefault(); if (svc != null) return svc.TryGet(key, out value, table); value = null; return false; } /// /// 静态快捷获取带格式化参数的本地化字符串。 /// 服务未注册时以 key 作为模板直接格式化后返回。 /// public static string GetFormat(string key, string table, params object[] args) { var svc = ServiceLocator.GetOrDefault(); 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; } } /// /// 静态快捷获取带数量的复数形式本地化字符串。 /// 服务未注册时直接返回 key。 /// public static string GetPlural(string key, int count, string table = LocalizationTable.UI) => ServiceLocator.GetOrDefault()?.GetPlural(key, count, table) ?? key; // ── 编辑器预览(不依赖 ServiceLocator 实例)──────────────────────────── #if UNITY_EDITOR // 编辑器预览缓存:"{language}/{table}" → (key → value) // 生命周期与编辑器进程相同;域重载时自动清空(static 字段随域重载重置)。 private static readonly Dictionary> s_editorPreviewCache = new(); /// /// 编辑器工具专用:不依赖运行时服务实例,直接用 AssetDatabase 按路径读取本地化文本 /// (编辑器期无需初始化 Addressables)。结果缓存在静态字典中,同一编辑器会话内同一表只加载一次。 /// 找不到时返回 null(区别于运行时的 key 回退,便于调用方判断是否显示 key)。 /// 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 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(path); var dict = asset == null ? null : ParseTableText(asset.text); s_editorPreviewCache[cacheKey] = dict; return dict; } /// 编辑器工具:清除编辑器预览缓存(修改 JSON 文件后手动刷新时调用)。 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; } /// /// 通过 Addressables 同步加载字符串表(地址 "Localization/{language}/{table}")。 /// 项目统一用 Addressables 管理资源,不使用 Resources。 /// 表不存在时返回 null(先用 LoadResourceLocations 判存在,避免缺表时的错误日志刷屏)。 /// private static Dictionary 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(address); var dict = asset == null ? null : ParseTableText(asset.text); AssetLoader.Release(handle); // 已解析为字典,释放资源句柄 return dict; } /// /// 将 JSON 文本解析为 key→value 字典。 /// 委托给 ,与编辑器写盘共用同一格式逻辑。 /// 返回 null 表示格式无效。 /// private static Dictionary ParseTableText(string jsonText) => LocalizationSerializer.Parse(jsonText); // ── 缓存键(值类型,消除字符串插值 GC)────────────────────────────── private readonly struct CacheKey : IEquatable { 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); } } }