Files
zeling_v2/Assets/_Game/Scripts/Localization/LocalizationManager.cs
2026-05-25 11:54:37 +08:00

414 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Assets/Scripts/Localization/LocalizationManager.cs
// 本地化管理器(运行时 JSON 文件驱动)。
//
// 数据格式(放在 Resources/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.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 为 nullkey 不会作为 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} 未找到," +
$"请确认 Resources/Localization/{language}/{table}.json 存在。");
#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; }
string path = $"Localization/{language}/{table}";
var request = Resources.LoadAsync<TextAsset>(path);
yield return request;
var asset = request.asset as TextAsset;
var dict = asset == null ? null : ParseTableText(asset.text);
#if UNITY_EDITOR || DEVELOPMENT_BUILD
if (dict == null)
Debug.LogWarning(
$"[LocalizationManager] PreloadTablesAsync{language}/{table} 未找到," +
$"请确认 Resources/Localization/{language}/{table}.json 存在。");
#endif
_cache[ck] = dict;
}
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>
/// 编辑器工具专用:不依赖运行时服务实例,直接从 Resources 读取本地化文本。
/// 结果缓存在静态字典中,同一编辑器会话内同一表只加载一次。
/// 找不到时返回 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;
string path = $"Localization/{language}/{table}";
var asset = Resources.Load<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>
/// 从 Resources/Localization/{language}/{table}.json 加载字符串表。
/// 返回 null 表示文件不存在。
/// </summary>
private static Dictionary<string, string> LoadTable(Language language, string table)
{
string path = $"Localization/{language}/{table}";
var asset = Resources.Load<TextAsset>(path);
return asset == null ? null : ParseTableText(asset.text);
}
/// <summary>
/// 将 JSON 文本解析为 key→value 字典(内部共享解析逻辑)。
/// 返回 null 表示格式无效。
/// </summary>
private static Dictionary<string, string> ParseTableText(string jsonText)
{
var parsed = JsonUtility.FromJson<StringTableJson>(jsonText);
if (parsed?.entries == null) return null;
var dict = new Dictionary<string, string>(parsed.entries.Count, StringComparer.Ordinal);
foreach (var entry in parsed.entries)
if (!string.IsNullOrEmpty(entry.key))
dict[entry.key] = entry.value ?? string.Empty;
return dict;
}
// ── 缓存键(值类型,消除字符串插值 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);
}
// ── 序列化辅助类型 ────────────────────────────────────────────────────
[Serializable]
private class StringTableJson
{
public List<StringEntry> entries;
}
[Serializable]
private class StringEntry
{
public string key;
public string value;
}
}
}