UI系统优化
This commit is contained in:
@@ -1,17 +1,19 @@
|
||||
{
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"precompiledReferences": [],
|
||||
"name": "BaseGames.Localization",
|
||||
"defineConstraints": [],
|
||||
"noEngineReferences": false,
|
||||
"versionDefines": [],
|
||||
"rootNamespace": "BaseGames.Localization",
|
||||
"references": [
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Core.Save"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"overrideReferences": false,
|
||||
"includePlatforms": []
|
||||
}
|
||||
"name": "BaseGames.Localization",
|
||||
"rootNamespace": "BaseGames.Localization",
|
||||
"references": [
|
||||
"BaseGames.Core",
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Core.Save",
|
||||
"Unity.TextMeshPro"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
42
Assets/_Game/Scripts/Localization/ILocalizableAsset.cs
Normal file
42
Assets/_Game/Scripts/Localization/ILocalizableAsset.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BaseGames.Localization
|
||||
{
|
||||
/// <summary>
|
||||
/// 标记一个 ScriptableObject 持有可本地化字段。
|
||||
/// 实现此接口后,<see cref="BaseGames.Editor.Modules.LocalizationAuditModule"/>
|
||||
/// 将自动发现并检查该 SO 的所有 Key,无需在审计模块中硬编码扫描逻辑。
|
||||
///
|
||||
/// 新增 SO 类型时:实现此接口即可自动纳入本地化审计,不需要修改审计模块。
|
||||
/// </summary>
|
||||
public interface ILocalizableAsset
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回该资产中所有本地化 Key 的引用列表。
|
||||
/// 实现时跳过空 key(<c>string.IsNullOrEmpty</c> 检查)。
|
||||
/// </summary>
|
||||
IEnumerable<LocalizationKeyRef> GetLocalizationKeys();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 对一个本地化 Key 引用的描述,供审计工具使用。
|
||||
/// </summary>
|
||||
public readonly struct LocalizationKeyRef
|
||||
{
|
||||
/// <summary>本地化 Key 字符串。</summary>
|
||||
public readonly string Key;
|
||||
|
||||
/// <summary>所属表名(使用 <see cref="LocalizationTable"/> 常量)。</summary>
|
||||
public readonly string Table;
|
||||
|
||||
/// <summary>该 Key 来自的字段名称,用于审计报告中精确定位。</summary>
|
||||
public readonly string FieldName;
|
||||
|
||||
public LocalizationKeyRef(string key, string table, string fieldName)
|
||||
{
|
||||
Key = key;
|
||||
Table = table;
|
||||
FieldName = fieldName;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Localization/ILocalizableAsset.cs.meta
Normal file
11
Assets/_Game/Scripts/Localization/ILocalizableAsset.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b4ea4cfc00373e14aadc6750c579aae7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -12,12 +12,49 @@ namespace BaseGames.Localization
|
||||
|
||||
/// <summary>
|
||||
/// 获取本地化字符串。查找顺序:当前语言 → 回退语言(English)→ 直接返回 key。
|
||||
/// 表名建议使用 <see cref="LocalizationTable"/> 中的常量。
|
||||
/// </summary>
|
||||
string Get(string key, string table = "UI");
|
||||
string Get(string key, string table = LocalizationTable.UI);
|
||||
|
||||
/// <summary>
|
||||
/// 尝试获取本地化字符串。返回 false 表示 key 在所有语言(含回退)中均不存在。
|
||||
/// 与 <see cref="Get"/> 不同:key 不存在时不会将 key 本身作为值返回。
|
||||
/// 适用于需要区分"key 存在但值为空"和"key 完全不存在"的场景。
|
||||
/// </summary>
|
||||
bool TryGet(string key, out string value, string table = LocalizationTable.UI);
|
||||
|
||||
/// <summary>
|
||||
/// 获取带格式化参数的本地化字符串(<c>string.Format</c> 风格)。
|
||||
/// 例:<c>GetFormat("REWARD_GOLD", LocalizationTable.UI, amount)</c> → "获得 100 灵珠"。
|
||||
/// 格式化失败时静默返回原始模板字符串,不抛出异常。
|
||||
/// </summary>
|
||||
string GetFormat(string key, string table, params object[] args);
|
||||
|
||||
/// <summary>切换游戏语言并通知所有订阅者刷新文本。</summary>
|
||||
void SetLanguage(Language language);
|
||||
|
||||
/// <summary>
|
||||
/// 同步预热指定语言的所有已知本地化表,避免首次访问时产生帧卡顿。
|
||||
/// 在主线程阻塞执行,建议改用 <see cref="PreloadTablesAsync"/> 分帧加载。
|
||||
/// </summary>
|
||||
void PreloadTables(Language language);
|
||||
|
||||
/// <summary>
|
||||
/// 异步分帧预热指定语言的所有本地化表(每帧加载一个表,不阻塞主线程)。
|
||||
/// 建议在 Loading Screen 的协程中调用。
|
||||
/// </summary>
|
||||
/// <param name="language">要预热的语言。</param>
|
||||
/// <param name="onComplete">全部表加载完成后的回调(可为 null)。</param>
|
||||
void PreloadTablesAsync(Language language, Action onComplete = null);
|
||||
|
||||
/// <summary>
|
||||
/// 获取带数量的复数形式本地化字符串。
|
||||
/// 规则:先查找 "{key}_one"(count==1)或 "{key}_other"(count≠1),找不到则回退到基础 key。
|
||||
/// 查找到的模板以 <c>string.Format(template, count)</c> 展开,{0} 代入 count。
|
||||
/// 示例:key="ITEM_COUNT",表中配置 "ITEM_COUNT_other"="获得 {0} 个物品" → "获得 5 个物品"。
|
||||
/// </summary>
|
||||
string GetPlural(string key, int count, string table = LocalizationTable.UI);
|
||||
|
||||
/// <summary>语言切换时触发。</summary>
|
||||
event Action<Language> OnLanguageChanged;
|
||||
}
|
||||
|
||||
59
Assets/_Game/Scripts/Localization/LanguageFontConfigSO.cs
Normal file
59
Assets/_Game/Scripts/Localization/LanguageFontConfigSO.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Localization
|
||||
{
|
||||
/// <summary>
|
||||
/// 每种语言对应的 TMP 字体配置。
|
||||
/// 创建资产:Assets/Data/Localization/FontConfig.asset
|
||||
///
|
||||
/// 用法:将此资产拖入 <see cref="BaseGames.Localization.LocalizedText"/> 的
|
||||
/// <c>Font Config</c> 字段,切换语言时 LocalizedText 将自动替换字体和材质。
|
||||
///
|
||||
/// CJK 语言通常需要单独的字体资产,无需为每个文本节点逐一指定,
|
||||
/// 只需在此 SO 中统一配置一次即可全局生效。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(
|
||||
menuName = "BaseGames/Localization/Language Font Config",
|
||||
fileName = "FontConfig")]
|
||||
public class LanguageFontConfigSO : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public class FontEntry
|
||||
{
|
||||
[Tooltip("对应的语言。")]
|
||||
public Language language;
|
||||
|
||||
[Tooltip("该语言使用的 TMP 字体资产(留空表示沿用默认字体)。")]
|
||||
public TMP_FontAsset fontAsset;
|
||||
|
||||
[Tooltip("该语言字体的材质预设(留空表示使用字体默认材质)。")]
|
||||
public Material fontMaterial;
|
||||
}
|
||||
|
||||
[Tooltip("每种语言的字体映射。未列出的语言保持默认字体不变。")]
|
||||
public FontEntry[] entries = Array.Empty<FontEntry>();
|
||||
|
||||
/// <summary>
|
||||
/// 尝试获取指定语言的字体配置。
|
||||
/// 返回 false 表示该语言未配置,调用方应保持现有字体不变。
|
||||
/// </summary>
|
||||
public bool TryGetFont(Language language, out TMP_FontAsset font, out Material material)
|
||||
{
|
||||
if (entries != null)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry.language != language) continue;
|
||||
font = entry.fontAsset;
|
||||
material = entry.fontMaterial;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
font = null;
|
||||
material = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b31f31378d72a8e42b454d4af01e51ab
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,4 +1,4 @@
|
||||
// Assets/Scripts/Localization/LocalizationManager.cs
|
||||
// Assets/Scripts/Localization/LocalizationManager.cs
|
||||
// 本地化管理器(运行时 JSON 文件驱动)。
|
||||
//
|
||||
// 数据格式(放在 Resources/Localization/{Language}/{TableName}.json):
|
||||
@@ -15,9 +15,11 @@
|
||||
//
|
||||
// 便捷静态方法(内部仍走 ServiceLocator,推荐在热路径之外使用):
|
||||
// LocalizationManager.Get("ui_start")
|
||||
// LocalizationManager.Get("dlg_hero", "Dialogue")
|
||||
// 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;
|
||||
@@ -36,10 +38,17 @@ namespace BaseGames.Localization
|
||||
private Language _currentLanguage = Language.ChineseSimplified;
|
||||
private readonly Language _fallbackLanguage = Language.English;
|
||||
|
||||
// 双层缓存:languageKey("ChineseSimplified/UI") → (key → value)
|
||||
private readonly Dictionary<string, Dictionary<string, string>> _cache = new();
|
||||
// 双层缓存:(Language, tableName) 结构体键 → (key → value)
|
||||
// 使用值类型 CacheKey 代替字符串插值,消除每次 Get 调用的 string 堆分配。
|
||||
private readonly Dictionary<CacheKey, Dictionary<string, string>> _cache = new();
|
||||
|
||||
// ILocalizationService 实例事件
|
||||
// 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
|
||||
{
|
||||
@@ -78,8 +87,22 @@ namespace BaseGames.Localization
|
||||
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。
|
||||
@@ -101,6 +124,118 @@ namespace BaseGames.Localization
|
||||
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} 未找到," +
|
||||
$"请确认 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)
|
||||
{
|
||||
@@ -118,75 +253,91 @@ namespace BaseGames.Localization
|
||||
// ── 静态便捷方法 ─────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// 静态快捷获取本地化字符串。委托给 ILocalizationService 实例;服务未注册时直接返回 key。
|
||||
/// 表名建议使用 <see cref="LocalizationTable"/> 中的常量。
|
||||
/// </summary>
|
||||
public static string Get(string key, string table = "UI")
|
||||
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 System.Collections.Generic.Dictionary<
|
||||
string,
|
||||
System.Collections.Generic.Dictionary<string, string>> s_editorPreviewCache = new();
|
||||
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 = "UI",
|
||||
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); // 中文缺失时英文回退
|
||||
?? GetEditorTable(Language.English, table);
|
||||
if (dict == null) return null;
|
||||
|
||||
dict.TryGetValue(key, out var value);
|
||||
return value; // 找不到 key 时返回 null
|
||||
return value;
|
||||
}
|
||||
|
||||
private static System.Collections.Generic.Dictionary<string, string> GetEditorTable(
|
||||
Language language, string table)
|
||||
public static Dictionary<string, string> GetEditorTable(Language language, string table)
|
||||
{
|
||||
string cacheKey = $"{language}/{table}";
|
||||
if (s_editorPreviewCache.TryGetValue(cacheKey, out var cached))
|
||||
return cached; // 已缓存(可能是 null 占位,表示文件不存在)
|
||||
return cached;
|
||||
|
||||
string path = $"Localization/{language}/{table}";
|
||||
var asset = Resources.Load<TextAsset>(path);
|
||||
if (asset == null)
|
||||
{
|
||||
s_editorPreviewCache[cacheKey] = null; // 记录"不存在",避免重复尝试
|
||||
return null;
|
||||
}
|
||||
|
||||
var parsed = JsonUtility.FromJson<StringTableJson>(asset.text);
|
||||
if (parsed?.entries == null)
|
||||
{
|
||||
s_editorPreviewCache[cacheKey] = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
var dict = new System.Collections.Generic.Dictionary<string, string>(
|
||||
parsed.entries.Count, System.StringComparer.Ordinal);
|
||||
foreach (var entry in parsed.entries)
|
||||
if (!string.IsNullOrEmpty(entry.key))
|
||||
dict[entry.key] = entry.value ?? string.Empty;
|
||||
|
||||
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 cacheKey = $"{language}/{table}";
|
||||
if (!_cache.TryGetValue(cacheKey, out var dict))
|
||||
var ck = new CacheKey(language, table);
|
||||
if (!_cache.TryGetValue(ck, out var dict))
|
||||
{
|
||||
dict = LoadTable(language, table);
|
||||
_cache[cacheKey] = dict; // 即使加载失败也存入空字典,避免每帧重试
|
||||
_cache[ck] = dict; // 即使加载失败也存入空字典,避免每帧重试
|
||||
}
|
||||
if (dict != null && dict.TryGetValue(key, out value)) return true;
|
||||
value = null;
|
||||
@@ -201,9 +352,16 @@ namespace BaseGames.Localization
|
||||
{
|
||||
string path = $"Localization/{language}/{table}";
|
||||
var asset = Resources.Load<TextAsset>(path);
|
||||
if (asset == null) return null;
|
||||
return asset == null ? null : ParseTableText(asset.text);
|
||||
}
|
||||
|
||||
var parsed = JsonUtility.FromJson<StringTableJson>(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);
|
||||
@@ -214,6 +372,29 @@ namespace BaseGames.Localization
|
||||
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]
|
||||
@@ -230,4 +411,3 @@ namespace BaseGames.Localization
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
39
Assets/_Game/Scripts/Localization/LocalizationTable.cs
Normal file
39
Assets/_Game/Scripts/Localization/LocalizationTable.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
namespace BaseGames.Localization
|
||||
{
|
||||
/// <summary>
|
||||
/// 本地化表名常量。
|
||||
/// 所有调用 <see cref="ILocalizationService.Get"/> 或 <see cref="LocalizationManager.Get"/> 时
|
||||
/// 必须引用此类的常量,禁止直接硬编码表名字符串。
|
||||
///
|
||||
/// 新增表时:在此追加常量,并在 Resources/Localization/{Language}/ 下创建同名 JSON 文件。
|
||||
/// </summary>
|
||||
public static class LocalizationTable
|
||||
{
|
||||
/// <summary>通用 UI 文本(按钮、标题、菜单、HUD、提示等)。</summary>
|
||||
public const string UI = "UI";
|
||||
|
||||
/// <summary>NPC 对话行与对话选项文本。</summary>
|
||||
public const string Dialogue = "Dialogue";
|
||||
|
||||
/// <summary>任务名称与描述文本。</summary>
|
||||
public const string Quest = "Quest";
|
||||
|
||||
/// <summary>法术名称与描述文本。</summary>
|
||||
public const string Spells = "Spells";
|
||||
|
||||
/// <summary>角色名称(NPC、玩家角色等)。</summary>
|
||||
public const string Character = "Character";
|
||||
|
||||
/// <summary>物品名称与描述(护符、收集品等)。</summary>
|
||||
public const string Items = "Items";
|
||||
|
||||
/// <summary>技能名称与描述。</summary>
|
||||
public const string Skills = "Skills";
|
||||
|
||||
/// <summary>教程与上下文提示文本。</summary>
|
||||
public const string Tutorial = "Tutorial";
|
||||
|
||||
/// <summary>所有已定义的表名数组,供预热和审计遍历使用。</summary>
|
||||
public static readonly string[] All = { UI, Dialogue, Quest, Spells, Character, Items, Skills, Tutorial };
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Localization/LocalizationTable.cs.meta
Normal file
11
Assets/_Game/Scripts/Localization/LocalizationTable.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 231300f4bcb6aa34991ea6b1b145204d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
144
Assets/_Game/Scripts/Localization/LocalizedText.cs
Normal file
144
Assets/_Game/Scripts/Localization/LocalizedText.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Localization
|
||||
{
|
||||
/// <summary>
|
||||
/// UI 文本本地化自动绑定组件。
|
||||
/// 挂载在含 <see cref="TMP_Text"/> 的 GameObject 上,语言切换时自动刷新文本内容。
|
||||
///
|
||||
/// 用法:
|
||||
/// 1. 挂上此组件,填写 <see cref="key"/> 和 <see cref="table"/>(默认 UI 表)。
|
||||
/// 2. 运行时 <see cref="ILocalizationService.OnLanguageChanged"/> 触发时自动刷新。
|
||||
/// 3. 格式化参数在运行时通过 <see cref="SetFormatArgs"/> 传入后即时更新显示。
|
||||
/// 4. (可选)绑定 <see cref="LanguageFontConfigSO"/>,语言切换时自动替换 TMP 字体。
|
||||
///
|
||||
/// 编辑器预览:Inspector 内实时显示当前 key 对应的本地化文本(使用简体中文表)。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(TMP_Text))]
|
||||
[AddComponentMenu("BaseGames/Localization/Localized Text")]
|
||||
public class LocalizedText : MonoBehaviour
|
||||
{
|
||||
[Tooltip("本地化 Key,如 \"BTN_START\"、\"HUD_HP\"。")]
|
||||
[SerializeField] private string _key;
|
||||
|
||||
[Tooltip("所属本地化表。使用 LocalizationTable 中的常量,默认 \"UI\"。")]
|
||||
[SerializeField] private string _table = LocalizationTable.UI;
|
||||
|
||||
[Tooltip("(可选)语言→字体映射表。填写后语言切换时自动替换 TMP 字体,用于 CJK 等需要独立字体的语言。")]
|
||||
[SerializeField] private LanguageFontConfigSO _fontConfig;
|
||||
|
||||
// 格式化参数(运行时由 SetFormatArgs 设置,空数组 = 无格式化)
|
||||
private object[] _formatArgs;
|
||||
|
||||
private TMP_Text _label;
|
||||
private ILocalizationService _svc;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_label = GetComponent<TMP_Text>();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_svc = ServiceLocator.GetOrDefault<ILocalizationService>();
|
||||
if (_svc != null)
|
||||
_svc.OnLanguageChanged += OnLanguageChanged;
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
else
|
||||
Debug.LogWarning(
|
||||
$"[LocalizedText] '{name}' OnEnable:ILocalizationService 尚未注册," +
|
||||
$"文本将不会随语言切换自动刷新。请确认 LocalizationManager 在此对象激活前已完成 Awake。", this);
|
||||
#endif
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_svc != null)
|
||||
_svc.OnLanguageChanged -= OnLanguageChanged;
|
||||
_svc = null;
|
||||
}
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 动态更改本地化 Key 并立即刷新文本。
|
||||
/// 适用于同一 UI 控件在不同状态下显示不同字段的情况。
|
||||
/// </summary>
|
||||
public void SetKey(string key, string table = null)
|
||||
{
|
||||
_key = key;
|
||||
if (table != null) _table = table;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置格式化参数并立即刷新文本。
|
||||
/// 本地化字符串中使用标准 {0}、{1}…占位符,例如:
|
||||
/// "获得 {0} 灵珠" → <c>SetFormatArgs(amount)</c>
|
||||
/// </summary>
|
||||
public void SetFormatArgs(params object[] args)
|
||||
{
|
||||
_formatArgs = args;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
/// <summary>强制立即刷新文本(语言切换后由组件自动调用,通常无需手动调用)。</summary>
|
||||
public void Refresh()
|
||||
{
|
||||
if (_label == null || string.IsNullOrEmpty(_key)) return;
|
||||
ApplyFont();
|
||||
_label.text = ResolveText();
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnLanguageChanged(Language _) => Refresh();
|
||||
|
||||
private string ResolveText()
|
||||
{
|
||||
// 直接使用缓存的 _svc 实例,避免每次调用 ServiceLocator 字典查找(热路径优化)
|
||||
if (_svc != null)
|
||||
{
|
||||
return (_formatArgs != null && _formatArgs.Length > 0)
|
||||
? _svc.GetFormat(_key, _table, _formatArgs)
|
||||
: _svc.Get(_key, _table);
|
||||
}
|
||||
// 服务未注册时使用静态方法兜底(保证不崩溃)
|
||||
return (_formatArgs != null && _formatArgs.Length > 0)
|
||||
? LocalizationManager.GetFormat(_key, _table, _formatArgs)
|
||||
: LocalizationManager.Get(_key, _table);
|
||||
}
|
||||
|
||||
private void ApplyFont()
|
||||
{
|
||||
if (_fontConfig == null || _label == null) return;
|
||||
var lang = _svc?.CurrentLanguage ?? Language.ChineseSimplified;
|
||||
if (!_fontConfig.TryGetFont(lang, out var font, out var mat)) return;
|
||||
if (font != null) _label.font = font;
|
||||
if (mat != null) _label.fontSharedMaterial = mat;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// 编辑器下 key / table 变化时立即预览(无需进入 Play Mode)
|
||||
private void OnValidate()
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
UpdateEditorPreview();
|
||||
}
|
||||
|
||||
public void UpdateEditorPreview()
|
||||
{
|
||||
if (_label == null) _label = GetComponent<TMP_Text>();
|
||||
if (_label == null || string.IsNullOrEmpty(_key)) return;
|
||||
string preview = LocalizationManager.GetEditorPreview(_key, _table);
|
||||
// 未找到时显示 key 本身,方便策划确认是否拼写正确
|
||||
_label.text = preview ?? $"[{_key}]";
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Localization/LocalizedText.cs.meta
Normal file
11
Assets/_Game/Scripts/Localization/LocalizedText.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 964e3a3ab86805244bbde47d5f54950b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user