From e49713a84237a5f98c4359c465c1551413f830f6 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:58:07 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(core):=20=E6=B7=BB=E5=8A=A0=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=8C=96=E7=B3=BB=E7=BB=9F=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 ILocalizationManager 接口及 LocalizationManager 管理器 - 添加 ILocalizationTable 和 ILocalizationString 接口及其实现 - 创建 LocalizationConfig 配置类用于管理本地化行为 - 实现 ConditionalFormatter 和 PluralFormatter 内置格式化器 - 添加本地化文档包括 API 参考和使用指南 - 集成本地化系统到核心框架架构中 --- .../Localization/ILocalizationFormatter.cs | 22 + .../Localization/ILocalizationManager.cs | 89 ++++ .../Localization/ILocalizationString.cs | 50 ++ .../Localization/ILocalizationTable.cs | 48 ++ .../Localization/LocalizationConfig.cs | 37 ++ .../Localization/LocalizationException.cs | 31 ++ .../LocalizationKeyNotFoundException.cs | 29 ++ .../LocalizationTableNotFoundException.cs | 22 + .../LocalizationIntegrationTests.cs | 105 ++++ .../Localization/LocalizationTableTests.cs | 84 +++ .../Formatters/ConditionalFormatter.cs | 38 ++ .../Formatters/PluralFormatter.cs | 43 ++ .../Localization/LocalizationManager.cs | 307 +++++++++++ .../Localization/LocalizationString.cs | 148 ++++++ .../Localization/LocalizationTable.cs | 136 +++++ docs/zh-CN/api-reference/index.md | 140 +++++ docs/zh-CN/core/index.md | 1 + docs/zh-CN/core/localization.md | 487 ++++++++++++++++++ 18 files changed, 1817 insertions(+) create mode 100644 GFramework.Core.Abstractions/Localization/ILocalizationFormatter.cs create mode 100644 GFramework.Core.Abstractions/Localization/ILocalizationManager.cs create mode 100644 GFramework.Core.Abstractions/Localization/ILocalizationString.cs create mode 100644 GFramework.Core.Abstractions/Localization/ILocalizationTable.cs create mode 100644 GFramework.Core.Abstractions/Localization/LocalizationConfig.cs create mode 100644 GFramework.Core.Abstractions/Localization/LocalizationException.cs create mode 100644 GFramework.Core.Abstractions/Localization/LocalizationKeyNotFoundException.cs create mode 100644 GFramework.Core.Abstractions/Localization/LocalizationTableNotFoundException.cs create mode 100644 GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs create mode 100644 GFramework.Core.Tests/Localization/LocalizationTableTests.cs create mode 100644 GFramework.Core/Localization/Formatters/ConditionalFormatter.cs create mode 100644 GFramework.Core/Localization/Formatters/PluralFormatter.cs create mode 100644 GFramework.Core/Localization/LocalizationManager.cs create mode 100644 GFramework.Core/Localization/LocalizationString.cs create mode 100644 GFramework.Core/Localization/LocalizationTable.cs create mode 100644 docs/zh-CN/core/localization.md diff --git a/GFramework.Core.Abstractions/Localization/ILocalizationFormatter.cs b/GFramework.Core.Abstractions/Localization/ILocalizationFormatter.cs new file mode 100644 index 0000000..65faef8 --- /dev/null +++ b/GFramework.Core.Abstractions/Localization/ILocalizationFormatter.cs @@ -0,0 +1,22 @@ +namespace GFramework.Core.Abstractions.Localization; + +/// +/// 本地化格式化器接口 +/// +public interface ILocalizationFormatter +{ + /// + /// 格式化器名称 + /// + string Name { get; } + + /// + /// 尝试格式化值 + /// + /// 格式字符串 + /// 要格式化的值 + /// 格式提供者 + /// 格式化结果 + /// 是否成功格式化 + bool TryFormat(string format, object value, IFormatProvider? provider, out string result); +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/Localization/ILocalizationManager.cs b/GFramework.Core.Abstractions/Localization/ILocalizationManager.cs new file mode 100644 index 0000000..835fd6d --- /dev/null +++ b/GFramework.Core.Abstractions/Localization/ILocalizationManager.cs @@ -0,0 +1,89 @@ +using System.Globalization; +using GFramework.Core.Abstractions.Systems; + +namespace GFramework.Core.Abstractions.Localization; + +/// +/// 本地化管理器接口 +/// +public interface ILocalizationManager : ISystem +{ + /// + /// 当前语言代码 + /// + string CurrentLanguage { get; } + + /// + /// 当前文化信息 + /// + CultureInfo CurrentCulture { get; } + + /// + /// 可用语言列表 + /// + IReadOnlyList AvailableLanguages { get; } + + /// + /// 设置当前语言 + /// + /// 语言代码 + void SetLanguage(string languageCode); + + /// + /// 获取本地化表 + /// + /// 表名 + /// 本地化表 + ILocalizationTable GetTable(string tableName); + + /// + /// 获取本地化文本 + /// + /// 表名 + /// 键名 + /// 本地化文本 + string GetText(string table, string key); + + /// + /// 获取本地化字符串(支持变量和格式化) + /// + /// 表名 + /// 键名 + /// 本地化字符串 + ILocalizationString GetString(string table, string key); + + /// + /// 尝试获取本地化文本 + /// + /// 表名 + /// 键名 + /// 输出文本 + /// 是否成功获取 + bool TryGetText(string table, string key, out string text); + + /// + /// 注册格式化器 + /// + /// 格式化器名称 + /// 格式化器实例 + void RegisterFormatter(string name, ILocalizationFormatter formatter); + + /// + /// 获取格式化器 + /// + /// 格式化器名称 + /// 格式化器实例,如果不存在则返回 null + ILocalizationFormatter? GetFormatter(string name); + + /// + /// 订阅语言变化事件 + /// + /// 回调函数 + void SubscribeToLanguageChange(Action callback); + + /// + /// 取消订阅语言变化事件 + /// + /// 回调函数 + void UnsubscribeFromLanguageChange(Action callback); +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/Localization/ILocalizationString.cs b/GFramework.Core.Abstractions/Localization/ILocalizationString.cs new file mode 100644 index 0000000..34146c4 --- /dev/null +++ b/GFramework.Core.Abstractions/Localization/ILocalizationString.cs @@ -0,0 +1,50 @@ +namespace GFramework.Core.Abstractions.Localization; + +/// +/// 本地化字符串接口(支持变量和格式化) +/// +public interface ILocalizationString +{ + /// + /// 表名 + /// + string Table { get; } + + /// + /// 键名 + /// + string Key { get; } + + /// + /// 添加变量 + /// + /// 变量名 + /// 变量值 + /// 当前实例(支持链式调用) + ILocalizationString WithVariable(string name, object value); + + /// + /// 批量添加变量 + /// + /// 变量数组 + /// 当前实例(支持链式调用) + ILocalizationString WithVariables(params (string name, object value)[] variables); + + /// + /// 格式化并返回最终文本 + /// + /// 格式化后的文本 + string Format(); + + /// + /// 获取原始文本(不进行格式化) + /// + /// 原始文本 + string GetRaw(); + + /// + /// 检查键是否存在 + /// + /// 是否存在 + bool Exists(); +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/Localization/ILocalizationTable.cs b/GFramework.Core.Abstractions/Localization/ILocalizationTable.cs new file mode 100644 index 0000000..29db256 --- /dev/null +++ b/GFramework.Core.Abstractions/Localization/ILocalizationTable.cs @@ -0,0 +1,48 @@ +namespace GFramework.Core.Abstractions.Localization; + +/// +/// 本地化表接口 +/// +public interface ILocalizationTable +{ + /// + /// 表名 + /// + string Name { get; } + + /// + /// 语言代码 + /// + string Language { get; } + + /// + /// 回退表(当前表中找不到键时使用) + /// + ILocalizationTable? Fallback { get; } + + /// + /// 获取原始文本(不进行格式化) + /// + /// 键名 + /// 原始文本 + string GetRawText(string key); + + /// + /// 检查是否包含指定键 + /// + /// 键名 + /// 是否包含 + bool ContainsKey(string key); + + /// + /// 获取所有键 + /// + /// 键集合 + IEnumerable GetKeys(); + + /// + /// 合并覆盖数据 + /// + /// 覆盖数据 + void Merge(IReadOnlyDictionary overrides); +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/Localization/LocalizationConfig.cs b/GFramework.Core.Abstractions/Localization/LocalizationConfig.cs new file mode 100644 index 0000000..26a3e1c --- /dev/null +++ b/GFramework.Core.Abstractions/Localization/LocalizationConfig.cs @@ -0,0 +1,37 @@ +namespace GFramework.Core.Abstractions.Localization; + +/// +/// 本地化配置 +/// +public class LocalizationConfig +{ + /// + /// 默认语言代码 + /// + public string DefaultLanguage { get; set; } = "eng"; + + /// + /// 回退语言代码(当目标语言缺少键时使用) + /// + public string FallbackLanguage { get; set; } = "eng"; + + /// + /// 本地化文件路径(Godot 资源路径) + /// + public string LocalizationPath { get; set; } = "res://localization"; + + /// + /// 用户覆盖文件路径(用于热更新和自定义翻译) + /// + public string OverridePath { get; set; } = "user://localization_override"; + + /// + /// 是否启用热重载(监视覆盖文件变化) + /// + public bool EnableHotReload { get; set; } = true; + + /// + /// 是否在加载时验证本地化文件 + /// + public bool ValidateOnLoad { get; set; } = true; +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/Localization/LocalizationException.cs b/GFramework.Core.Abstractions/Localization/LocalizationException.cs new file mode 100644 index 0000000..5f1345f --- /dev/null +++ b/GFramework.Core.Abstractions/Localization/LocalizationException.cs @@ -0,0 +1,31 @@ +namespace GFramework.Core.Abstractions.Localization; + +/// +/// 本地化异常基类 +/// +public class LocalizationException : Exception +{ + /// + /// 初始化本地化异常 + /// + public LocalizationException() + { + } + + /// + /// 初始化本地化异常 + /// + /// 异常消息 + public LocalizationException(string message) : base(message) + { + } + + /// + /// 初始化本地化异常 + /// + /// 异常消息 + /// 内部异常 + public LocalizationException(string message, Exception innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/Localization/LocalizationKeyNotFoundException.cs b/GFramework.Core.Abstractions/Localization/LocalizationKeyNotFoundException.cs new file mode 100644 index 0000000..6611962 --- /dev/null +++ b/GFramework.Core.Abstractions/Localization/LocalizationKeyNotFoundException.cs @@ -0,0 +1,29 @@ +namespace GFramework.Core.Abstractions.Localization; + +/// +/// 本地化键未找到异常 +/// +public class LocalizationKeyNotFoundException : LocalizationException +{ + /// + /// 初始化键未找到异常 + /// + /// 表名 + /// 键名 + public LocalizationKeyNotFoundException(string tableName, string key) + : base($"Localization key '{key}' not found in table '{tableName}'") + { + TableName = tableName; + Key = key; + } + + /// + /// 表名 + /// + public string TableName { get; } + + /// + /// 键名 + /// + public string Key { get; } +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/Localization/LocalizationTableNotFoundException.cs b/GFramework.Core.Abstractions/Localization/LocalizationTableNotFoundException.cs new file mode 100644 index 0000000..c6df214 --- /dev/null +++ b/GFramework.Core.Abstractions/Localization/LocalizationTableNotFoundException.cs @@ -0,0 +1,22 @@ +namespace GFramework.Core.Abstractions.Localization; + +/// +/// 本地化表未找到异常 +/// +public class LocalizationTableNotFoundException : LocalizationException +{ + /// + /// 初始化表未找到异常 + /// + /// 表名 + public LocalizationTableNotFoundException(string tableName) + : base($"Localization table '{tableName}' not found") + { + TableName = tableName; + } + + /// + /// 表名 + /// + public string TableName { get; } +} \ No newline at end of file diff --git a/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs b/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs new file mode 100644 index 0000000..afce6a2 --- /dev/null +++ b/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs @@ -0,0 +1,105 @@ +using GFramework.Core.Localization; + +namespace GFramework.Core.Tests.Localization; + +[TestFixture] +public class LocalizationIntegrationTests +{ + private LocalizationManager? _manager; + private string _testDataPath = null!; + + [SetUp] + public void Setup() + { + _testDataPath = "/tmp/localization_example"; + var config = new LocalizationConfig + { + DefaultLanguage = "eng", + FallbackLanguage = "eng", + LocalizationPath = _testDataPath, + EnableHotReload = false, + ValidateOnLoad = false + }; + + _manager = new LocalizationManager(config); + _manager.Initialize(); + } + + [Test] + public void GetText_ShouldReturnEnglishText() + { + // Act + var title = _manager!.GetText("common", "game.title"); + + // Assert + Assert.That(title, Is.EqualTo("My Game")); + } + + [Test] + public void GetString_WithVariable_ShouldFormatCorrectly() + { + // Act + var message = _manager!.GetString("common", "ui.message.welcome") + .WithVariable("playerName", "Alice") + .Format(); + + // Assert + Assert.That(message, Is.EqualTo("Welcome, Alice!")); + } + + [Test] + public void SetLanguage_ShouldSwitchToChineseText() + { + // Act + _manager!.SetLanguage("zhs"); + var title = _manager.GetText("common", "game.title"); + + // Assert + Assert.That(title, Is.EqualTo("我的游戏")); + } + + [Test] + public void GetString_WithMultipleVariables_ShouldFormatCorrectly() + { + // Act + var health = _manager!.GetString("common", "status.health") + .WithVariable("current", 80) + .WithVariable("max", 100) + .Format(); + + // Assert + Assert.That(health, Is.EqualTo("Health: 80/100")); + } + + [Test] + public void LanguageChange_ShouldTriggerCallback() + { + // Arrange + var callbackTriggered = false; + var newLanguage = string.Empty; + + _manager!.SubscribeToLanguageChange(lang => + { + callbackTriggered = true; + newLanguage = lang; + }); + + // Act + _manager.SetLanguage("zhs"); + + // Assert + Assert.That(callbackTriggered, Is.True); + Assert.That(newLanguage, Is.EqualTo("zhs")); + } + + [Test] + public void AvailableLanguages_ShouldContainBothLanguages() + { + // Act + var languages = _manager!.AvailableLanguages; + + // Assert + Assert.That(languages, Contains.Item("eng")); + Assert.That(languages, Contains.Item("zhs")); + } +} \ No newline at end of file diff --git a/GFramework.Core.Tests/Localization/LocalizationTableTests.cs b/GFramework.Core.Tests/Localization/LocalizationTableTests.cs new file mode 100644 index 0000000..d86b775 --- /dev/null +++ b/GFramework.Core.Tests/Localization/LocalizationTableTests.cs @@ -0,0 +1,84 @@ +using GFramework.Core.Localization; + +namespace GFramework.Core.Tests.Localization; + +[TestFixture] +public class LocalizationTableTests +{ + [Test] + public void GetRawText_ShouldReturnCorrectText() + { + // Arrange + var data = new Dictionary + { + ["test.key"] = "Test Value" + }; + var table = new LocalizationTable("test", "eng", data); + + // Act + var result = table.GetRawText("test.key"); + + // Assert + Assert.That(result, Is.EqualTo("Test Value")); + } + + [Test] + public void GetRawText_WithFallback_ShouldReturnFallbackValue() + { + // Arrange + var fallbackData = new Dictionary + { + ["test.key"] = "Fallback Value" + }; + var fallbackTable = new LocalizationTable("test", "eng", fallbackData); + + var data = new Dictionary(); + var table = new LocalizationTable("test", "zhs", data, fallbackTable); + + // Act + var result = table.GetRawText("test.key"); + + // Assert + Assert.That(result, Is.EqualTo("Fallback Value")); + } + + [Test] + public void ContainsKey_ShouldReturnTrue_WhenKeyExists() + { + // Arrange + var data = new Dictionary + { + ["test.key"] = "Test Value" + }; + var table = new LocalizationTable("test", "eng", data); + + // Act + var result = table.ContainsKey("test.key"); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void Merge_ShouldOverrideExistingValues() + { + // Arrange + var data = new Dictionary + { + ["test.key"] = "Original Value" + }; + var table = new LocalizationTable("test", "eng", data); + + var overrides = new Dictionary + { + ["test.key"] = "Override Value" + }; + + // Act + table.Merge(overrides); + var result = table.GetRawText("test.key"); + + // Assert + Assert.That(result, Is.EqualTo("Override Value")); + } +} \ No newline at end of file diff --git a/GFramework.Core/Localization/Formatters/ConditionalFormatter.cs b/GFramework.Core/Localization/Formatters/ConditionalFormatter.cs new file mode 100644 index 0000000..03172aa --- /dev/null +++ b/GFramework.Core/Localization/Formatters/ConditionalFormatter.cs @@ -0,0 +1,38 @@ +using GFramework.Core.Abstractions.Localization; + +namespace GFramework.Core.Localization.Formatters; + +/// +/// 条件格式化器 +/// 格式: {condition:if:trueText|falseText} +/// 示例: {upgraded:if:Upgraded|Normal} +/// +public class ConditionalFormatter : ILocalizationFormatter +{ + /// + public string Name => "if"; + + /// + public bool TryFormat(string format, object value, IFormatProvider? provider, out string result) + { + result = string.Empty; + + try + { + var parts = format.Split('|'); + + if (parts.Length != 2) + { + return false; + } + + var condition = value is bool b ? b : Convert.ToBoolean(value); + result = condition ? parts[0] : parts[1]; + return true; + } + catch + { + return false; + } + } +} \ No newline at end of file diff --git a/GFramework.Core/Localization/Formatters/PluralFormatter.cs b/GFramework.Core/Localization/Formatters/PluralFormatter.cs new file mode 100644 index 0000000..e1ee7da --- /dev/null +++ b/GFramework.Core/Localization/Formatters/PluralFormatter.cs @@ -0,0 +1,43 @@ +using GFramework.Core.Abstractions.Localization; + +namespace GFramework.Core.Localization.Formatters; + +/// +/// 复数格式化器 +/// 格式: {count:plural:singular|plural} +/// 示例: {count:plural:item|items} +/// +public class PluralFormatter : ILocalizationFormatter +{ + /// + public string Name => "plural"; + + /// + public bool TryFormat(string format, object value, IFormatProvider? provider, out string result) + { + result = string.Empty; + + if (value is not IConvertible convertible) + { + return false; + } + + try + { + var number = convertible.ToDecimal(provider); + var parts = format.Split('|'); + + if (parts.Length != 2) + { + return false; + } + + result = Math.Abs(number) == 1 ? parts[0] : parts[1]; + return true; + } + catch + { + return false; + } + } +} \ No newline at end of file diff --git a/GFramework.Core/Localization/LocalizationManager.cs b/GFramework.Core/Localization/LocalizationManager.cs new file mode 100644 index 0000000..202bbcd --- /dev/null +++ b/GFramework.Core/Localization/LocalizationManager.cs @@ -0,0 +1,307 @@ +using System.Globalization; +using System.IO; +using System.Text.Json; +using GFramework.Core.Abstractions.Localization; +using GFramework.Core.Systems; + +namespace GFramework.Core.Localization; + +/// +/// 本地化管理器实现 +/// +public class LocalizationManager : AbstractSystem, ILocalizationManager +{ + private readonly LocalizationConfig _config; + private readonly Dictionary _formatters; + private readonly List> _languageChangeCallbacks; + private readonly Dictionary> _tables; + private List _availableLanguages; + private CultureInfo _currentCulture; + private string _currentLanguage; + + /// + /// 初始化本地化管理器 + /// + /// 配置 + public LocalizationManager(LocalizationConfig? config = null) + { + _config = config ?? new LocalizationConfig(); + _tables = new Dictionary>(); + _formatters = new Dictionary(); + _languageChangeCallbacks = new List>(); + _currentLanguage = _config.DefaultLanguage; + _currentCulture = GetCultureInfo(_currentLanguage); + _availableLanguages = new List(); + } + + /// + public string CurrentLanguage => _currentLanguage; + + /// + public CultureInfo CurrentCulture => _currentCulture; + + /// + public IReadOnlyList AvailableLanguages => _availableLanguages; + + /// + public void SetLanguage(string languageCode) + { + if (string.IsNullOrEmpty(languageCode)) + { + throw new ArgumentNullException(nameof(languageCode)); + } + + if (_currentLanguage == languageCode) + { + return; + } + + LoadLanguage(languageCode); + _currentLanguage = languageCode; + _currentCulture = GetCultureInfo(languageCode); + + // 触发语言变化回调 + TriggerLanguageChange(); + } + + /// + public ILocalizationTable GetTable(string tableName) + { + if (string.IsNullOrEmpty(tableName)) + { + throw new ArgumentNullException(nameof(tableName)); + } + + if (!_tables.TryGetValue(_currentLanguage, out var languageTables)) + { + throw new LocalizationTableNotFoundException(tableName); + } + + if (!languageTables.TryGetValue(tableName, out var table)) + { + throw new LocalizationTableNotFoundException(tableName); + } + + return table; + } + + /// + public string GetText(string table, string key) + { + return GetTable(table).GetRawText(key); + } + + /// + public ILocalizationString GetString(string table, string key) + { + return new LocalizationString(this, table, key); + } + + /// + public bool TryGetText(string table, string key, out string text) + { + try + { + text = GetText(table, key); + return true; + } + catch + { + text = string.Empty; + return false; + } + } + + /// + public void RegisterFormatter(string name, ILocalizationFormatter formatter) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + _formatters[name] = formatter ?? throw new ArgumentNullException(nameof(formatter)); + } + + /// + public ILocalizationFormatter? GetFormatter(string name) + { + if (string.IsNullOrEmpty(name)) + { + return null; + } + + return _formatters.TryGetValue(name, out var formatter) ? formatter : null; + } + + /// + public void SubscribeToLanguageChange(Action callback) + { + if (callback == null) + { + throw new ArgumentNullException(nameof(callback)); + } + + if (!_languageChangeCallbacks.Contains(callback)) + { + _languageChangeCallbacks.Add(callback); + } + } + + /// + public void UnsubscribeFromLanguageChange(Action callback) + { + if (callback == null) + { + throw new ArgumentNullException(nameof(callback)); + } + + _languageChangeCallbacks.Remove(callback); + } + + /// + protected override void OnInit() + { + // 扫描可用语言 + ScanAvailableLanguages(); + + // 加载默认语言 + LoadLanguage(_config.DefaultLanguage); + } + + /// + protected override void OnDestroy() + { + _tables.Clear(); + _formatters.Clear(); + _languageChangeCallbacks.Clear(); + } + + /// + /// 扫描可用语言 + /// + private void ScanAvailableLanguages() + { + _availableLanguages.Clear(); + + var localizationPath = _config.LocalizationPath; + if (!Directory.Exists(localizationPath)) + { + _availableLanguages.Add(_config.DefaultLanguage); + return; + } + + var directories = Directory.GetDirectories(localizationPath); + foreach (var dir in directories) + { + var languageCode = Path.GetFileName(dir); + if (!string.IsNullOrEmpty(languageCode)) + { + _availableLanguages.Add(languageCode); + } + } + + if (_availableLanguages.Count == 0) + { + _availableLanguages.Add(_config.DefaultLanguage); + } + } + + /// + /// 加载语言 + /// + private void LoadLanguage(string languageCode) + { + if (_tables.ContainsKey(languageCode)) + { + return; // 已加载 + } + + var languageTables = new Dictionary(); + + // 加载回退语言(如果不是默认语言) + Dictionary? fallbackTables = null; + if (languageCode != _config.FallbackLanguage) + { + LoadLanguage(_config.FallbackLanguage); + _tables.TryGetValue(_config.FallbackLanguage, out fallbackTables); + } + + // 加载目标语言 + var languagePath = Path.Combine(_config.LocalizationPath, languageCode); + if (Directory.Exists(languagePath)) + { + var jsonFiles = Directory.GetFiles(languagePath, "*.json"); + foreach (var file in jsonFiles) + { + var tableName = Path.GetFileNameWithoutExtension(file); + var data = LoadJsonFile(file); + + ILocalizationTable? fallback = null; + fallbackTables?.TryGetValue(tableName, out fallback); + + languageTables[tableName] = new LocalizationTable(tableName, languageCode, data, fallback); + } + } + + _tables[languageCode] = languageTables; + } + + /// + /// 加载 JSON 文件 + /// + private static Dictionary LoadJsonFile(string filePath) + { + var json = File.ReadAllText(filePath); + var data = JsonSerializer.Deserialize>(json); + return data ?? new Dictionary(); + } + + /// + /// 获取文化信息 + /// + private static CultureInfo GetCultureInfo(string languageCode) + { + try + { + // 尝试映射常见的语言代码 + var cultureCode = languageCode switch + { + "eng" => "en-US", + "zhs" => "zh-CN", + "zht" => "zh-TW", + "jpn" => "ja-JP", + "kor" => "ko-KR", + "fra" => "fr-FR", + "deu" => "de-DE", + "spa" => "es-ES", + "rus" => "ru-RU", + _ => languageCode + }; + + return new CultureInfo(cultureCode); + } + catch + { + return CultureInfo.InvariantCulture; + } + } + + /// + /// 触发语言变化事件 + /// + private void TriggerLanguageChange() + { + foreach (var callback in _languageChangeCallbacks.ToList()) + { + try + { + callback(_currentLanguage); + } + catch + { + // 忽略回调异常 + } + } + } +} \ No newline at end of file diff --git a/GFramework.Core/Localization/LocalizationString.cs b/GFramework.Core/Localization/LocalizationString.cs new file mode 100644 index 0000000..e15a5c1 --- /dev/null +++ b/GFramework.Core/Localization/LocalizationString.cs @@ -0,0 +1,148 @@ +using System.Text.RegularExpressions; +using GFramework.Core.Abstractions.Localization; + +namespace GFramework.Core.Localization; + +/// +/// 本地化字符串实现 +/// +public class LocalizationString : ILocalizationString +{ + private readonly ILocalizationManager _manager; + private readonly Dictionary _variables; + + /// + /// 初始化本地化字符串 + /// + /// 本地化管理器 + /// 表名 + /// 键名 + public LocalizationString(ILocalizationManager manager, string table, string key) + { + _manager = manager ?? throw new ArgumentNullException(nameof(manager)); + Table = table ?? throw new ArgumentNullException(nameof(table)); + Key = key ?? throw new ArgumentNullException(nameof(key)); + _variables = new Dictionary(); + } + + /// + public string Table { get; } + + /// + public string Key { get; } + + /// + public ILocalizationString WithVariable(string name, object value) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + _variables[name] = value; + return this; + } + + /// + public ILocalizationString WithVariables(params (string name, object value)[] variables) + { + if (variables == null) + { + throw new ArgumentNullException(nameof(variables)); + } + + foreach (var (name, value) in variables) + { + WithVariable(name, value); + } + + return this; + } + + /// + public string Format() + { + var rawText = GetRaw(); + return FormatString(rawText, _variables, _manager); + } + + /// + public string GetRaw() + { + if (!_manager.TryGetText(Table, Key, out var text)) + { + return $"[{Table}.{Key}]"; + } + + return text; + } + + /// + public bool Exists() + { + return _manager.TryGetText(Table, Key, out _); + } + + /// + /// 格式化字符串(支持变量替换和格式化器) + /// + private static string FormatString( + string template, + Dictionary variables, + ILocalizationManager manager) + { + if (string.IsNullOrEmpty(template)) + { + return template; + } + + // 匹配 {variableName} 或 {variableName:formatter:args} + var pattern = @"\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([a-zA-Z_][a-zA-Z0-9_]*)(?::([^}]+))?)?\}"; + var regex = new Regex(pattern); + + return regex.Replace(template, match => + { + var variableName = match.Groups[1].Value; + var formatterName = match.Groups[2].Success ? match.Groups[2].Value : null; + var formatterArgs = match.Groups[3].Success ? match.Groups[3].Value : null; + + if (!variables.TryGetValue(variableName, out var value)) + { + return match.Value; // 保持原样 + } + + // 如果没有格式化器,直接转换为字符串 + if (string.IsNullOrEmpty(formatterName)) + { + return value switch + { + IFormattable formattable => formattable.ToString(null, manager.CurrentCulture), + _ => value?.ToString() ?? string.Empty + }; + } + + // 尝试使用注册的格式化器 + var formatter = GetFormatter(manager, formatterName); + if (formatter != null && formatter.TryFormat(formatterArgs ?? string.Empty, value, manager.CurrentCulture, + out var result)) + { + return result; + } + + // 格式化失败,返回基本格式化 + return value switch + { + IFormattable formattable => formattable.ToString(null, manager.CurrentCulture), + _ => value?.ToString() ?? string.Empty + }; + }); + } + + /// + /// 获取格式化器 + /// + private static ILocalizationFormatter? GetFormatter(ILocalizationManager manager, string name) + { + return manager.GetFormatter(name); + } +} \ No newline at end of file diff --git a/GFramework.Core/Localization/LocalizationTable.cs b/GFramework.Core/Localization/LocalizationTable.cs new file mode 100644 index 0000000..2a063f2 --- /dev/null +++ b/GFramework.Core/Localization/LocalizationTable.cs @@ -0,0 +1,136 @@ +using GFramework.Core.Abstractions.Localization; + +namespace GFramework.Core.Localization; + +/// +/// 本地化表实现 +/// +public class LocalizationTable : ILocalizationTable +{ + /// + /// 存储原始本地化数据的字典 + /// + private readonly Dictionary _data; + + /// + /// 存储覆盖数据的字典,优先级高于原始数据 + /// + private readonly Dictionary _overrides; + + /// + /// 初始化本地化表 + /// + /// 表名 + /// 语言代码 + /// 数据字典 + /// 回退表 + public LocalizationTable( + string name, + string language, + IReadOnlyDictionary data, + ILocalizationTable? fallback = null) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Language = language ?? throw new ArgumentNullException(nameof(language)); + _data = new Dictionary(data); + _overrides = new Dictionary(); + Fallback = fallback; + } + + /// + /// 获取本地化表的名称 + /// + public string Name { get; } + + /// + /// 获取语言代码 + /// + public string Language { get; } + + /// + /// 获取回退表,当当前表找不到键时用于查找 + /// + public ILocalizationTable? Fallback { get; } + + /// + /// 获取指定键的原始文本内容 + /// + /// 要查找的本地化键 + /// 找到的本地化文本值 + /// 当 key 为 null 时抛出 + /// 当键在表中不存在且无回退表时抛出 + public string GetRawText(string key) + { + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + // 优先使用覆盖数据 + if (_overrides.TryGetValue(key, out var overrideValue)) + { + return overrideValue; + } + + // 然后使用原始数据 + if (_data.TryGetValue(key, out var value)) + { + return value; + } + + // 最后尝试回退表 + if (Fallback != null && Fallback.ContainsKey(key)) + { + return Fallback.GetRawText(key); + } + + throw new LocalizationKeyNotFoundException(Name, key); + } + + /// + /// 检查是否包含指定的键 + /// + /// 要检查的本地化键 + /// 如果存在则返回 true,否则返回 false + public bool ContainsKey(string key) + { + return _overrides.ContainsKey(key) + || _data.ContainsKey(key) + || (Fallback?.ContainsKey(key) ?? false); + } + + /// + /// 获取所有可用的本地化键集合 + /// + /// 包含所有键的可枚举集合 + public IEnumerable GetKeys() + { + var keys = new HashSet(_data.Keys); + keys.UnionWith(_overrides.Keys); + + if (Fallback != null) + { + keys.UnionWith(Fallback.GetKeys()); + } + + return keys; + } + + /// + /// 合并覆盖数据到当前表 + /// + /// 要合并的覆盖数据字典 + /// 当 overrides 为 null 时抛出 + public void Merge(IReadOnlyDictionary overrides) + { + if (overrides == null) + { + throw new ArgumentNullException(nameof(overrides)); + } + + foreach (var (key, value) in overrides) + { + _overrides[key] = value; + } + } +} \ No newline at end of file diff --git a/docs/zh-CN/api-reference/index.md b/docs/zh-CN/api-reference/index.md index d3a9748..18b3ec3 100644 --- a/docs/zh-CN/api-reference/index.md +++ b/docs/zh-CN/api-reference/index.md @@ -66,6 +66,23 @@ IoC 容器命名空间。 | `IObjectPool` | 对象池接口 | | `ObjectPool` | 对象池实现 | +### GFramework.Core.Localization + +本地化系统命名空间。 + +#### 主要类型 + +| 类型 | 说明 | +|--------------------------|----------| +| `ILocalizationManager` | 本地化管理器接口 | +| `ILocalizationTable` | 本地化表接口 | +| `ILocalizationString` | 本地化字符串接口 | +| `ILocalizationFormatter` | 格式化器接口 | +| `LocalizationConfig` | 本地化配置类 | +| `LocalizationManager` | 本地化管理器实现 | +| `LocalizationTable` | 本地化表实现 | +| `LocalizationString` | 本地化字符串实现 | + ## 常用 API ### Architecture @@ -247,6 +264,99 @@ public class BindableProperty } ``` +### ILocalizationManager + +```csharp +public interface ILocalizationManager : ISystem +{ + // 获取当前语言代码 + string CurrentLanguage { get; } + + // 获取当前文化信息 + CultureInfo CurrentCulture { get; } + + // 获取可用语言列表 + IReadOnlyList AvailableLanguages { get; } + + // 设置当前语言 + void SetLanguage(string languageCode); + + // 获取本地化表 + ILocalizationTable GetTable(string tableName); + + // 获取本地化文本 + string GetText(string table, string key); + + // 获取本地化字符串(支持变量) + ILocalizationString GetString(string table, string key); + + // 尝试获取本地化文本 + bool TryGetText(string table, string key, out string text); + + // 注册格式化器 + void RegisterFormatter(string name, ILocalizationFormatter formatter); + + // 订阅语言变化事件 + void SubscribeToLanguageChange(Action callback); + + // 取消订阅语言变化事件 + void UnsubscribeFromLanguageChange(Action callback); +} +``` + +### ILocalizationString + +```csharp +public interface ILocalizationString +{ + // 获取表名 + string Table { get; } + + // 获取键名 + string Key { get; } + + // 添加变量 + ILocalizationString WithVariable(string name, object value); + + // 批量添加变量 + ILocalizationString WithVariables(params (string name, object value)[] variables); + + // 格式化并返回文本 + string Format(); + + // 获取原始文本 + string GetRaw(); + + // 检查键是否存在 + bool Exists(); +} +``` + +### LocalizationConfig + +```csharp +public class LocalizationConfig +{ + // 默认语言代码 + public string DefaultLanguage { get; set; } = "eng"; + + // 回退语言代码 + public string FallbackLanguage { get; set; } = "eng"; + + // 本地化文件路径 + public string LocalizationPath { get; set; } = "res://localization"; + + // 用户覆盖路径 + public string OverridePath { get; set; } = "user://localization_override"; + + // 是否启用热重载 + public bool EnableHotReload { get; set; } = true; + + // 是否在加载时验证 + public bool ValidateOnLoad { get; set; } = true; +} +``` + ## 扩展方法 ### 架构扩展 @@ -406,6 +516,36 @@ public class PlayerSystem : AbstractSystem } ``` +### 使用本地化 + +```csharp +// 初始化本地化管理器 +var config = new LocalizationConfig +{ + DefaultLanguage = "eng", + LocalizationPath = "res://localization" +}; +var locManager = new LocalizationManager(config); +locManager.Initialize(); + +// 获取简单文本 +string title = locManager.GetText("common", "game.title"); + +// 使用变量 +var message = locManager.GetString("common", "ui.message.welcome") + .WithVariable("playerName", "Alice") + .Format(); + +// 切换语言 +locManager.SetLanguage("zhs"); + +// 监听语言变化 +locManager.SubscribeToLanguageChange(language => +{ + Console.WriteLine($"Language changed to: {language}"); +}); +``` + --- 更多详情请查看各模块的详细文档。 diff --git a/docs/zh-CN/core/index.md b/docs/zh-CN/core/index.md index 189f0a6..fb9602b 100644 --- a/docs/zh-CN/core/index.md +++ b/docs/zh-CN/core/index.md @@ -421,6 +421,7 @@ public class PlayerController : IController | **extensions** | 扩展方法,简化 API 调用 | [查看](./extensions) | | **logging** | 日志系统,记录运行日志 | [查看](./logging) | | **environment** | 环境接口,提供运行环境信息 | [查看](./environment) | +| **localization** | 本地化系统,多语言支持 | [查看](./localization) | ## 组件联动 diff --git a/docs/zh-CN/core/localization.md b/docs/zh-CN/core/localization.md new file mode 100644 index 0000000..440e051 --- /dev/null +++ b/docs/zh-CN/core/localization.md @@ -0,0 +1,487 @@ +# Localization 本地化系统 + +## 概述 + +Localization 包提供了完整的多语言本地化支持,实现了游戏文本的国际化管理。通过本地化系统,可以轻松实现多语言切换、动态变量替换、回退机制等功能。 + +本地化系统是 GFramework 架构中的 System 层组件,与其他系统无缝集成,支持类型安全的 API 和流畅的使用体验。 + +## 核心接口 + +### ILocalizationManager + +本地化管理器接口,继承自 `ISystem`,提供本地化的核心功能。 + +**核心属性:** + +```csharp +string CurrentLanguage { get; } // 当前语言代码 +CultureInfo CurrentCulture { get; } // 当前文化信息 +IReadOnlyList AvailableLanguages { get; } // 可用语言列表 +``` + +**核心方法:** + +```csharp +void SetLanguage(string languageCode); // 设置当前语言 +ILocalizationTable GetTable(string tableName); // 获取本地化表 +string GetText(string table, string key); // 获取本地化文本 +ILocalizationString GetString(string table, string key); // 获取本地化字符串(支持变量) +bool TryGetText(string table, string key, out string text); // 尝试获取文本 +void RegisterFormatter(string name, ILocalizationFormatter formatter); // 注册格式化器 +void SubscribeToLanguageChange(Action callback); // 订阅语言变化 +void UnsubscribeFromLanguageChange(Action callback); // 取消订阅 +``` + +### ILocalizationTable + +本地化表接口,表示单个语言的本地化数据表。 + +**核心属性:** + +```csharp +string Name { get; } // 表名 +string Language { get; } // 语言代码 +ILocalizationTable? Fallback { get; } // 回退表 +``` + +**核心方法:** + +```csharp +string GetRawText(string key); // 获取原始文本 +bool ContainsKey(string key); // 检查键是否存在 +IEnumerable GetKeys(); // 获取所有键 +void Merge(IReadOnlyDictionary overrides); // 合并覆盖数据 +``` + +### ILocalizationString + +本地化字符串接口,支持变量替换和格式化。 + +**核心属性:** + +```csharp +string Table { get; } // 表名 +string Key { get; } // 键名 +``` + +**核心方法:** + +```csharp +ILocalizationString WithVariable(string name, object value); // 添加变量 +ILocalizationString WithVariables(params (string name, object value)[] variables); // 批量添加变量 +string Format(); // 格式化并返回文本 +string GetRaw(); // 获取原始文本 +bool Exists(); // 检查键是否存在 +``` + +### ILocalizationFormatter + +格式化器接口,用于自定义变量格式化逻辑。 + +**核心属性:** + +```csharp +string Name { get; } // 格式化器名称 +``` + +**核心方法:** + +```csharp +bool TryFormat(string format, object value, IFormatProvider? provider, out string result); +``` + +## 配置类 + +### LocalizationConfig + +本地化配置类,用于配置本地化系统的行为。 + +**配置属性:** + +```csharp +string DefaultLanguage { get; set; } // 默认语言代码,默认 "eng" +string FallbackLanguage { get; set; } // 回退语言代码,默认 "eng" +string LocalizationPath { get; set; } // 本地化文件路径,默认 "res://localization" +string OverridePath { get; set; } // 用户覆盖路径,默认 "user://localization_override" +bool EnableHotReload { get; set; } // 是否启用热重载,默认 true +bool ValidateOnLoad { get; set; } // 是否在加载时验证,默认 true +``` + +## 文件组织 + +### 目录结构 + +``` +res://localization/ +├── eng/ # 英文 +│ ├── common.json # 通用文本 +│ ├── ui.json # UI 文本 +│ ├── cards.json # 卡牌文本 +│ └── ... +├── zhs/ # 简体中文 +│ ├── common.json +│ ├── ui.json +│ └── ... +└── ... + +user://localization_override/ # 用户覆盖(可选) +├── eng/ +└── zhs/ +``` + +### JSON 文件格式 + +```json +{ + "game.title": "My Game", + "game.version": "Version {version}", + "ui.button.start": "Start Game", + "ui.message.welcome": "Welcome, {playerName}!", + "combat.damage": "Deal {damage} damage", + "status.health": "Health: {current}/{max}" +} +``` + +**命名约定:** + +- 使用点号分隔的层级结构(如 `ui.button.start`) +- 变量使用花括号包裹(如 `{playerName}`) +- 键名使用小写字母和点号 + +## 基本使用 + +### 初始化本地化管理器 + +```csharp +using GFramework.Core.Abstractions.Localization; +using GFramework.Core.Localization; + +// 创建配置 +var config = new LocalizationConfig +{ + DefaultLanguage = "eng", + FallbackLanguage = "eng", + LocalizationPath = "res://localization" +}; + +// 创建管理器 +var locManager = new LocalizationManager(config); +locManager.Initialize(); +``` + +### 在 Architecture 中注册 + +```csharp +public class GameArchitecture : Architecture +{ + protected override void OnInit() + { + // 注册本地化管理器 + this.RegisterSystem(new LocalizationManager()); + } +} +``` + +### 获取本地化文本 + +```csharp +// 获取管理器 +var locManager = this.GetSystem(); + +// 简单文本 +string title = locManager.GetText("common", "game.title"); +// 结果: "My Game" + +// 安全获取 +if (locManager.TryGetText("common", "game.title", out var text)) +{ + Debug.Log(text); +} +``` + +### 使用变量 + +```csharp +// 单个变量 +var message = locManager.GetString("common", "ui.message.welcome") + .WithVariable("playerName", "Alice") + .Format(); +// 结果: "Welcome, Alice!" + +// 多个变量 +var health = locManager.GetString("common", "status.health") + .WithVariable("current", 80) + .WithVariable("max", 100) + .Format(); +// 结果: "Health: 80/100" + +// 链式调用 +var text = locManager.GetString("common", "game.version") + .WithVariable("version", "1.0.0") + .Format(); +// 结果: "Version 1.0.0" +``` + +### 切换语言 + +```csharp +// 切换到简体中文 +locManager.SetLanguage("zhs"); + +// 获取文本(自动使用新语言) +string title = locManager.GetText("common", "game.title"); +// 结果: "我的游戏" + +// 获取当前语言 +string currentLang = locManager.CurrentLanguage; // "zhs" + +// 获取可用语言列表 +var languages = locManager.AvailableLanguages; +foreach (var lang in languages) +{ + Debug.Log(lang); // "eng", "zhs", ... +} +``` + +### 监听语言变化 + +```csharp +// 订阅语言变化事件 +locManager.SubscribeToLanguageChange(language => +{ + Debug.Log($"Language changed to: {language}"); + // 更新 UI、重新加载资源等 +}); + +// 取消订阅 +locManager.UnsubscribeFromLanguageChange(callback); +``` + +## 高级功能 + +### 回退机制 + +当目标语言缺少某个键时,系统会自动回退到默认语言: + +```csharp +// 假设 zhs/common.json 中缺少 "new.feature" 键 +locManager.SetLanguage("zhs"); +var text = locManager.GetText("common", "new.feature"); +// 自动从 eng/common.json 获取 + +// 回退顺序: +// 1. 当前语言的覆盖数据 +// 2. 当前语言的原始数据 +// 3. 回退语言的数据 +``` + +### 覆盖机制 + +用户可以在 `user://localization_override/` 目录下放置覆盖文件: + +```json +// user://localization_override/eng/common.json +{ + "game.title": "My Custom Game Title" +} +``` + +覆盖文件会自动合并到主本地化表中,优先级最高。 + +### 自定义格式化器 + +```csharp +// 实现自定义格式化器 +public class UpperCaseFormatter : ILocalizationFormatter +{ + public string Name => "upper"; + + public bool TryFormat(string format, object value, IFormatProvider? provider, out string result) + { + result = value?.ToString()?.ToUpper() ?? string.Empty; + return true; + } +} + +// 注册格式化器 +locManager.RegisterFormatter("upper", new UpperCaseFormatter()); + +// 使用格式化器(需要在 LocalizationString 中实现格式化器支持) +// 格式: {variableName:formatterName:args} +``` + +### 内置格式化器 + +#### ConditionalFormatter + +条件格式化器,根据布尔值选择不同文本。 + +```csharp +// 格式: {condition:if:trueText|falseText} +// JSON: "status": "{upgraded:if:Upgraded|Normal}" + +var text = locManager.GetString("common", "status") + .WithVariable("upgraded", true) + .Format(); +// 结果: "Upgraded" +``` + +#### PluralFormatter + +复数格式化器,根据数量选择单复数形式。 + +```csharp +// 格式: {count:plural:singular|plural} +// JSON: "items": "{count:plural:item|items}" + +var text = locManager.GetString("common", "items") + .WithVariable("count", 1) + .Format(); +// 结果: "item" + +var text2 = locManager.GetString("common", "items") + .WithVariable("count", 3) + .Format(); +// 结果: "items" +``` + +## 异常处理 + +### LocalizationException + +本地化异常基类。 + +### LocalizationKeyNotFoundException + +当请求的键不存在时抛出。 + +```csharp +try +{ + var text = locManager.GetText("common", "nonexistent.key"); +} +catch (LocalizationKeyNotFoundException ex) +{ + Debug.LogError($"Key not found: {ex.TableName}.{ex.Key}"); +} +``` + +### LocalizationTableNotFoundException + +当请求的表不存在时抛出。 + +```csharp +try +{ + var table = locManager.GetTable("nonexistent_table"); +} +catch (LocalizationTableNotFoundException ex) +{ + Debug.LogError($"Table not found: {ex.TableName}"); +} +``` + +## 最佳实践 + +### 1. 键名组织 + +```csharp +// 推荐:使用层级结构 +"ui.button.start" +"ui.button.quit" +"combat.damage.physical" +"combat.damage.magical" + +// 不推荐:扁平结构 +"start_button" +"quit_button" +``` + +### 2. 变量命名 + +```csharp +// 推荐:使用驼峰命名 +"{playerName}" +"{maxHealth}" + +// 不推荐:使用下划线或大写 +"{player_name}" +"{MAX_HEALTH}" +``` + +### 3. 表的划分 + +```csharp +// 按功能模块划分表 +common.json // 通用文本 +ui.json // UI 文本 +combat.json // 战斗文本 +items.json // 物品文本 +``` + +### 4. 安全获取 + +```csharp +// 推荐:使用 TryGetText 避免异常 +if (locManager.TryGetText("common", "key", out var text)) +{ + // 使用 text +} + +// 或者提供默认值 +var text = locManager.TryGetText("common", "key", out var result) + ? result + : "Default Text"; +``` + +### 5. 语言变化处理 + +```csharp +// 在组件初始化时订阅 +public override void OnInit() +{ + var locManager = this.GetSystem(); + locManager.SubscribeToLanguageChange(OnLanguageChanged); +} + +// 在组件销毁时取消订阅 +public override void OnDestroy() +{ + var locManager = this.GetSystem(); + locManager.UnsubscribeFromLanguageChange(OnLanguageChanged); +} + +private void OnLanguageChanged(string language) +{ + // 更新 UI + UpdateUI(); +} +``` + +## 性能考虑 + +### 缓存策略 + +- 本地化表在加载后会缓存在内存中 +- 语言切换时只加载新语言的表 +- 建议在游戏启动时预加载常用语言 + +### 内存优化 + +```csharp +// 只加载当前语言,不预加载所有语言 +var config = new LocalizationConfig +{ + DefaultLanguage = "eng" +}; + +// 按需切换语言 +locManager.SetLanguage(userSelectedLanguage); +``` + +## 相关资源 + +- [Architecture 架构系统](./architecture.md) +- [System 系统层](./system.md) +- [Configuration 配置管理](./configuration.md) From 5fb96761a305acda0eb4d7d888c5a32f96117b3c Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:25:09 +0800 Subject: [PATCH 2/8] =?UTF-8?q?docs(localization):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=8C=96=E6=96=87=E6=A1=A3=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E5=8A=9F=E8=83=BD=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加了配置项暂不支持的说明信息 - 扩展了语言变化监听的使用方式,增加命名方法订阅示例 - 完善了覆盖机制的文档说明 - 优化了异常处理逻辑,精确捕获本地化相关异常 - 实现了正则表达式的预编译以提升性能 - 添加了必要的命名空间引用 --- .../Localization/LocalizationManager.cs | 3 +- .../Localization/LocalizationString.cs | 19 +++++++++---- docs/zh-CN/core/localization.md | 28 ++++++++++++++----- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/GFramework.Core/Localization/LocalizationManager.cs b/GFramework.Core/Localization/LocalizationManager.cs index 202bbcd..5ff4d88 100644 --- a/GFramework.Core/Localization/LocalizationManager.cs +++ b/GFramework.Core/Localization/LocalizationManager.cs @@ -105,8 +105,9 @@ public class LocalizationManager : AbstractSystem, ILocalizationManager text = GetText(table, key); return true; } - catch + catch (LocalizationException) { + // 只捕获本地化相关的异常(键不存在、表不存在等) text = string.Empty; return false; } diff --git a/GFramework.Core/Localization/LocalizationString.cs b/GFramework.Core/Localization/LocalizationString.cs index e15a5c1..0beb7b0 100644 --- a/GFramework.Core/Localization/LocalizationString.cs +++ b/GFramework.Core/Localization/LocalizationString.cs @@ -8,6 +8,18 @@ namespace GFramework.Core.Localization; /// public class LocalizationString : ILocalizationString { + /// + /// 匹配 {variableName} 或 {variableName:formatter:args} 的正则表达式模式 + /// + private static readonly string FormatVariablePattern = + @"\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([a-zA-Z_][a-zA-Z0-9_]*)(?::([^}]+))?)?\}"; + + /// + /// 预编译的静态正则表达式,用于格式化字符串中的变量替换 + /// + private static readonly Regex FormatVariableRegex = + new(FormatVariablePattern, RegexOptions.Compiled | RegexOptions.CultureInvariant); + private readonly ILocalizationManager _manager; private readonly Dictionary _variables; @@ -96,11 +108,8 @@ public class LocalizationString : ILocalizationString return template; } - // 匹配 {variableName} 或 {variableName:formatter:args} - var pattern = @"\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([a-zA-Z_][a-zA-Z0-9_]*)(?::([^}]+))?)?\}"; - var regex = new Regex(pattern); - - return regex.Replace(template, match => + // 使用预编译的静态正则表达式匹配 {variableName} 或 {variableName:formatter:args} + return FormatVariableRegex.Replace(template, match => { var variableName = match.Groups[1].Value; var formatterName = match.Groups[2].Success ? match.Groups[2].Value : null; diff --git a/docs/zh-CN/core/localization.md b/docs/zh-CN/core/localization.md index 440e051..03cb112 100644 --- a/docs/zh-CN/core/localization.md +++ b/docs/zh-CN/core/localization.md @@ -103,11 +103,13 @@ bool TryFormat(string format, object value, IFormatProvider? provider, out strin string DefaultLanguage { get; set; } // 默认语言代码,默认 "eng" string FallbackLanguage { get; set; } // 回退语言代码,默认 "eng" string LocalizationPath { get; set; } // 本地化文件路径,默认 "res://localization" -string OverridePath { get; set; } // 用户覆盖路径,默认 "user://localization_override" -bool EnableHotReload { get; set; } // 是否启用热重载,默认 true -bool ValidateOnLoad { get; set; } // 是否在加载时验证,默认 true +string OverridePath { get; set; } // 用户覆盖路径,默认 "user://localization_override" (暂不支持) +bool EnableHotReload { get; set; } // 是否启用热重载,默认 true (暂不支持) +bool ValidateOnLoad { get; set; } // 是否在加载时验证,默认 true (暂不支持) ``` +**注意:** `OverridePath`、`EnableHotReload` 和 `ValidateOnLoad` 配置项已定义但当前版本暂不支持,将在后续版本中实现。 + ## 文件组织 ### 目录结构 @@ -247,15 +249,25 @@ foreach (var lang in languages) ### 监听语言变化 ```csharp -// 订阅语言变化事件 +// 方式 1: 使用 lambda 表达式(无法取消订阅) locManager.SubscribeToLanguageChange(language => { Debug.Log($"Language changed to: {language}"); // 更新 UI、重新加载资源等 }); -// 取消订阅 -locManager.UnsubscribeFromLanguageChange(callback); +// 方式 2: 使用命名方法(推荐,可以取消订阅) +void OnLanguageChanged(string language) +{ + Debug.Log($"Language changed to: {language}"); + // 更新 UI、重新加载资源等 +} + +// 订阅 +locManager.SubscribeToLanguageChange(OnLanguageChanged); + +// 取消订阅(使用相同的方法引用) +locManager.UnsubscribeFromLanguageChange(OnLanguageChanged); ``` ## 高级功能 @@ -278,7 +290,9 @@ var text = locManager.GetText("common", "new.feature"); ### 覆盖机制 -用户可以在 `user://localization_override/` 目录下放置覆盖文件: +**注意:** 覆盖机制功能已规划但当前版本暂不支持,将在后续版本中实现。 + +未来版本中,用户可以在 `user://localization_override/` 目录下放置覆盖文件: ```json // user://localization_override/eng/common.json From 075d397a4c9e151ceb8b728f5ed65cc14cbf0852 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:25:55 +0800 Subject: [PATCH 3/8] =?UTF-8?q?refactor(localization):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E7=A9=BA=E9=97=B4=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加对 GFramework.Core.Abstractions.Localization 的引用 - 确保测试文件能够访问本地化相关的抽象接口 - 为后续的本地化功能扩展做好准备 - 保持代码结构的一致性与可维护性 --- .../Localization/LocalizationIntegrationTests.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs b/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs index afce6a2..60d95e0 100644 --- a/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs +++ b/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs @@ -1,3 +1,4 @@ +using GFramework.Core.Abstractions.Localization; using GFramework.Core.Localization; namespace GFramework.Core.Tests.Localization; @@ -5,9 +6,6 @@ namespace GFramework.Core.Tests.Localization; [TestFixture] public class LocalizationIntegrationTests { - private LocalizationManager? _manager; - private string _testDataPath = null!; - [SetUp] public void Setup() { @@ -25,6 +23,9 @@ public class LocalizationIntegrationTests _manager.Initialize(); } + private LocalizationManager? _manager; + private string _testDataPath = null!; + [Test] public void GetText_ShouldReturnEnglishText() { From 1f680d28223b45df572056e2307e8a4cf6c6162b Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:19:34 +0800 Subject: [PATCH 4/8] =?UTF-8?q?refactor(localization):=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E6=9C=AC=E5=9C=B0=E5=8C=96=E5=AD=97=E7=AC=A6=E4=B8=B2?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 FormatVariablePattern 从 readonly 字段改为 const 常量 - 提取复杂的正则替换逻辑到独立的 FormatMatch 方法中 - 新增 GetOptionalGroupValue 辅助方法处理可选组值提取 - 分离格式化值和尝试格式化的逻辑到独立方法 - 简化条件判断并提高代码可读性 - 优化错误处理流程,保持原有功能不变 --- .../Localization/LocalizationString.cs | 83 ++++++++++++------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/GFramework.Core/Localization/LocalizationString.cs b/GFramework.Core/Localization/LocalizationString.cs index 0beb7b0..48eba3a 100644 --- a/GFramework.Core/Localization/LocalizationString.cs +++ b/GFramework.Core/Localization/LocalizationString.cs @@ -11,7 +11,7 @@ public class LocalizationString : ILocalizationString /// /// 匹配 {variableName} 或 {variableName:formatter:args} 的正则表达式模式 /// - private static readonly string FormatVariablePattern = + private const string FormatVariablePattern = @"\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([a-zA-Z_][a-zA-Z0-9_]*)(?::([^}]+))?)?\}"; /// @@ -109,42 +109,61 @@ public class LocalizationString : ILocalizationString } // 使用预编译的静态正则表达式匹配 {variableName} 或 {variableName:formatter:args} - return FormatVariableRegex.Replace(template, match => + return FormatVariableRegex.Replace(template, match => FormatMatch(match, variables, manager)); + } + + private static string FormatMatch( + Match match, + Dictionary variables, + ILocalizationManager manager) + { + var variableName = match.Groups[1].Value; + if (!variables.TryGetValue(variableName, out var value)) { - var variableName = match.Groups[1].Value; - var formatterName = match.Groups[2].Success ? match.Groups[2].Value : null; - var formatterArgs = match.Groups[3].Success ? match.Groups[3].Value : null; + return match.Value; + } - if (!variables.TryGetValue(variableName, out var value)) - { - return match.Value; // 保持原样 - } + var formatterName = GetOptionalGroupValue(match, 2); + if (string.IsNullOrEmpty(formatterName)) + { + return FormatValue(value, manager); + } - // 如果没有格式化器,直接转换为字符串 - if (string.IsNullOrEmpty(formatterName)) - { - return value switch - { - IFormattable formattable => formattable.ToString(null, manager.CurrentCulture), - _ => value?.ToString() ?? string.Empty - }; - } + return TryFormatValue(match, value, formatterName, manager, out var result) + ? result + : FormatValue(value, manager); + } - // 尝试使用注册的格式化器 - var formatter = GetFormatter(manager, formatterName); - if (formatter != null && formatter.TryFormat(formatterArgs ?? string.Empty, value, manager.CurrentCulture, - out var result)) - { - return result; - } + private static bool TryFormatValue( + Match match, + object value, + string formatterName, + ILocalizationManager manager, + out string result) + { + var formatterArgs = GetOptionalGroupValue(match, 3) ?? string.Empty; + if (GetFormatter(manager, formatterName) is { } formatter && + formatter.TryFormat(formatterArgs, value, manager.CurrentCulture, out result)) + { + return true; + } - // 格式化失败,返回基本格式化 - return value switch - { - IFormattable formattable => formattable.ToString(null, manager.CurrentCulture), - _ => value?.ToString() ?? string.Empty - }; - }); + result = string.Empty; + return false; + } + + private static string FormatValue(object value, ILocalizationManager manager) + { + return value switch + { + IFormattable formattable => formattable.ToString(null, manager.CurrentCulture), + _ => value.ToString() ?? string.Empty + }; + } + + private static string? GetOptionalGroupValue(Match match, int groupIndex) + { + return match.Groups[groupIndex].Success ? match.Groups[groupIndex].Value : null; } /// From b5c67850ce039311fcd6d9130397a9370cfb7cbf Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:20:43 +0800 Subject: [PATCH 5/8] =?UTF-8?q?docs(localization):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=8C=96=E5=AD=97=E7=AC=A6=E4=B8=B2=E7=B1=BB?= =?UTF-8?q?=E7=9A=84XML=E6=96=87=E6=A1=A3=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为构造函数添加更详细的参数描述和异常说明 - 为WithVariable方法添加完整的XML文档注释 - 为WithVariables方法添加完整的XML文档注释 - 为Format方法添加详细的返回值和备注信息 - 为GetRaw方法添加完整的XML文档注释 - 为Exists方法添加完整的XML文档注释 - 为私有FormatString方法添加参数和返回值说明 - 为私有FormatMatch方法添加详细的处理逻辑描述 - 为私有TryFormatValue方法添加格式化器相关参数说明 - 为私有FormatValue方法添加默认格式化逻辑说明 - 为私有GetOptionalGroupValue方法添加功能说明 - 为私有GetFormatter方法添加获取格式化器的详细描述 --- .../Localization/LocalizationString.cs | 79 ++++++++++++++++--- 1 file changed, 70 insertions(+), 9 deletions(-) diff --git a/GFramework.Core/Localization/LocalizationString.cs b/GFramework.Core/Localization/LocalizationString.cs index 48eba3a..15832ab 100644 --- a/GFramework.Core/Localization/LocalizationString.cs +++ b/GFramework.Core/Localization/LocalizationString.cs @@ -26,9 +26,10 @@ public class LocalizationString : ILocalizationString /// /// 初始化本地化字符串 /// - /// 本地化管理器 - /// 表名 - /// 键名 + /// 本地化管理器实例 + /// 本地化表名,用于定位本地化资源表 + /// 本地化键名,用于在表中定位具体的本地化文本 + /// 当 manager、table 或 key 为 null 时抛出 public LocalizationString(ILocalizationManager manager, string table, string key) { _manager = manager ?? throw new ArgumentNullException(nameof(manager)); @@ -43,7 +44,13 @@ public class LocalizationString : ILocalizationString /// public string Key { get; } - /// + /// + /// 添加单个变量到本地化字符串中 + /// + /// 变量名称,用于在模板中匹配对应的占位符 + /// 变量值,将被转换为字符串并替换到对应位置 + /// 返回当前的 LocalizationString 实例,支持链式调用 + /// 当 name 为 null 时抛出 public ILocalizationString WithVariable(string name, object value) { if (name == null) @@ -55,7 +62,12 @@ public class LocalizationString : ILocalizationString return this; } - /// + /// + /// 批量添加多个变量到本地化字符串中 + /// + /// 变量元组数组,每个元组包含变量名称和对应的值 + /// 返回当前的 LocalizationString 实例,支持链式调用 + /// 当 variables 为 null 时抛出 public ILocalizationString WithVariables(params (string name, object value)[] variables) { if (variables == null) @@ -71,14 +83,25 @@ public class LocalizationString : ILocalizationString return this; } - /// + /// + /// 格式化本地化字符串,将模板中的变量占位符替换为实际值 + /// + /// 格式化后的完整字符串。如果本地化文本不存在,则返回 "[Table.Key]" 格式的占位符 + /// + /// 支持两种格式: + /// 1. {variableName} - 简单变量替换 + /// 2. {variableName:formatter:args} - 使用格式化器进行格式化 + /// public string Format() { var rawText = GetRaw(); return FormatString(rawText, _variables, _manager); } - /// + /// + /// 获取原始的本地化文本,不进行任何变量替换 + /// + /// 本地化文本。如果在本地化管理器中未找到对应的文本,则返回 "[Table.Key]" 格式的占位符 public string GetRaw() { if (!_manager.TryGetText(Table, Key, out var text)) @@ -89,7 +112,10 @@ public class LocalizationString : ILocalizationString return text; } - /// + /// + /// 检查当前本地化键是否存在于本地化管理器中 + /// + /// 如果存在返回 true;否则返回 false public bool Exists() { return _manager.TryGetText(Table, Key, out _); @@ -98,6 +124,10 @@ public class LocalizationString : ILocalizationString /// /// 格式化字符串(支持变量替换和格式化器) /// + /// 包含占位符的模板字符串 + /// 包含变量名称和值的字典 + /// 本地化管理器实例,用于获取格式化器 + /// 格式化后的字符串。如果模板为空或 null,则直接返回原模板 private static string FormatString( string template, Dictionary variables, @@ -112,6 +142,13 @@ public class LocalizationString : ILocalizationString return FormatVariableRegex.Replace(template, match => FormatMatch(match, variables, manager)); } + /// + /// 处理单个正则表达式匹配项,根据是否有格式化器决定如何处理变量值 + /// + /// 正则表达式匹配结果 + /// 变量字典 + /// 本地化管理器实例 + /// 替换后的字符串。如果变量不存在则返回原始匹配值;如果有格式化器则尝试格式化,失败则使用默认格式化 private static string FormatMatch( Match match, Dictionary variables, @@ -134,6 +171,15 @@ public class LocalizationString : ILocalizationString : FormatValue(value, manager); } + /// + /// 尝试使用指定的格式化器格式化变量值 + /// + /// 正则表达式匹配结果,用于获取格式化参数 + /// 要格式化的变量值 + /// 格式化器名称 + /// 本地化管理器实例 + /// 格式化后的结果字符串 + /// 如果格式化成功返回 true;否则返回 false,此时 result 为空字符串 private static bool TryFormatValue( Match match, object value, @@ -152,6 +198,12 @@ public class LocalizationString : ILocalizationString return false; } + /// + /// 对变量值进行默认格式化,不使用自定义格式化器 + /// + /// 要格式化的值 + /// 本地化管理器实例,提供当前文化信息 + /// 格式化后的字符串。如果值实现 IFormattable 接口则使用其 ToString 方法,否则调用默认的 ToString 方法 private static string FormatValue(object value, ILocalizationManager manager) { return value switch @@ -161,14 +213,23 @@ public class LocalizationString : ILocalizationString }; } + /// + /// 获取正则表达式匹配组中的可选值 + /// + /// 正则表达式匹配结果 + /// 要获取的组索引 + /// 如果该组匹配成功则返回其值;否则返回 null private static string? GetOptionalGroupValue(Match match, int groupIndex) { return match.Groups[groupIndex].Success ? match.Groups[groupIndex].Value : null; } /// - /// 获取格式化器 + /// 从本地化管理器获取指定名称的格式化器 /// + /// 本地化管理器实例 + /// 格式化器名称 + /// 如果找到对应的格式化器则返回;否则返回 null private static ILocalizationFormatter? GetFormatter(ILocalizationManager manager, string name) { return manager.GetFormatter(name); From d7e7d3cc7ffd1ed90d9a724f1320b2d2b2c35a83 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:51:59 +0800 Subject: [PATCH 6/8] =?UTF-8?q?feat(events):=20=E4=B8=BA=E4=BC=98=E5=85=88?= =?UTF-8?q?=E7=BA=A7=E4=BA=8B=E4=BB=B6=E7=B3=BB=E7=BB=9F=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E5=AE=89=E5=85=A8=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加同步锁对象以保护处理器集合的并发访问 - 在注册和注销操作中加入线程安全锁机制 - 实现快照创建方法以避免迭代期间的并发修改 - 重构触发器逻辑以使用线程安全的快照创建 - 在计数器方法中添加同步保护 - 确保所有集合操作都在安全锁内执行 --- GFramework.Core/Events/PriorityEvent.cs | 52 ++++++++++++++++++------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/GFramework.Core/Events/PriorityEvent.cs b/GFramework.Core/Events/PriorityEvent.cs index 7abcc1f..8704267 100644 --- a/GFramework.Core/Events/PriorityEvent.cs +++ b/GFramework.Core/Events/PriorityEvent.cs @@ -18,6 +18,11 @@ public class PriorityEvent : IEvent /// private readonly List _handlers = new(); + /// + /// 保护处理器集合的并发访问 + /// + private readonly object _syncRoot = new(); + /// /// 标记事件是否已被处理(用于 UntilHandled 传播模式) /// @@ -52,10 +57,13 @@ public class PriorityEvent : IEvent public IUnRegister Register(Action onEvent, int priority) { var handler = new EventHandler(onEvent, priority); - _handlers.Add(handler); + lock (_syncRoot) + { + _handlers.Add(handler); - // 按优先级降序排序(高优先级在前) - _handlers.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + // 按优先级降序排序(高优先级在前) + _handlers.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + } return new DefaultUnRegister(() => UnRegister(onEvent)); } @@ -66,7 +74,10 @@ public class PriorityEvent : IEvent /// 需要被注销的事件处理方法 public void UnRegister(Action onEvent) { - _handlers.RemoveAll(h => h.Handler == onEvent); + lock (_syncRoot) + { + _handlers.RemoveAll(h => h.Handler == onEvent); + } } /// @@ -78,10 +89,13 @@ public class PriorityEvent : IEvent public IUnRegister RegisterWithContext(Action> onEvent, int priority = 0) { var handler = new ContextEventHandler(onEvent, priority); - _contextHandlers.Add(handler); + lock (_syncRoot) + { + _contextHandlers.Add(handler); - // 按优先级降序排序(高优先级在前) - _contextHandlers.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + // 按优先级降序排序(高优先级在前) + _contextHandlers.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + } return new DefaultUnRegister(() => UnRegisterContext(onEvent)); } @@ -92,7 +106,10 @@ public class PriorityEvent : IEvent /// 需要被注销的事件处理方法 public void UnRegisterContext(Action> onEvent) { - _contextHandlers.RemoveAll(h => h.Handler == onEvent); + lock (_syncRoot) + { + _contextHandlers.RemoveAll(h => h.Handler == onEvent); + } } /// @@ -172,8 +189,7 @@ public class PriorityEvent : IEvent /// 事件参数 private void TriggerHighest(T t) { - var normalSnapshot = _handlers.ToArray(); - var contextSnapshot = _contextHandlers.ToArray(); + var (normalSnapshot, contextSnapshot) = CreateSnapshots(); var highestPriority = GetHighestPriority(normalSnapshot, contextSnapshot); if (highestPriority != int.MinValue) @@ -191,8 +207,7 @@ public class PriorityEvent : IEvent private List<(int Priority, Action? Handler, Action>? ContextHandler, bool IsContext)> MergeAndSortHandlers(T t) { - var normalSnapshot = _handlers.ToArray(); - var contextSnapshot = _contextHandlers.ToArray(); + var (normalSnapshot, contextSnapshot) = CreateSnapshots(); // 使用快照避免迭代期间修改 return normalSnapshot .Select(h => (h.Priority, Handler: (Action?)(() => h.Handler.Invoke(t)), @@ -260,7 +275,18 @@ public class PriorityEvent : IEvent /// 监听器总数量 public int GetListenerCount() { - return _handlers.Count + _contextHandlers.Count; + lock (_syncRoot) + { + return _handlers.Count + _contextHandlers.Count; + } + } + + private (EventHandler[] NormalHandlers, ContextEventHandler[] ContextHandlers) CreateSnapshots() + { + lock (_syncRoot) + { + return (_handlers.ToArray(), _contextHandlers.ToArray()); + } } /// From 0c5c9dceae63e57f118907c73a47c967c38e5f5d Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:52:11 +0800 Subject: [PATCH 7/8] =?UTF-8?q?test(localization):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=8C=96=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=9A=84=E4=B8=B4=E6=97=B6=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=92=8C=E6=B5=8B=E8=AF=95=E6=95=B0=E6=8D=AE=E5=88=9B=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 System.IO 命名空间引用以支持文件操作 - 实现 CreateTestLocalizationFiles 方法创建测试用的多语言文件 - 使用 GUID 生成唯一的临时目录路径避免冲突 - 添加 TearDown 方法清理测试过程中创建的临时文件 - 在 Setup 方法中调用文件创建方法初始化测试环境 - 将目标框架配置改为可配置的条件变量方式 --- .../GFramework.Core.Tests.csproj | 3 +- .../LocalizationIntegrationTests.cs | 38 ++++++++++++++++++- .../GFramework.Ecs.Arch.Tests.csproj | 3 +- .../GFramework.Game.Tests.csproj | 3 +- .../GFramework.SourceGenerators.Tests.csproj | 3 +- 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/GFramework.Core.Tests/GFramework.Core.Tests.csproj b/GFramework.Core.Tests/GFramework.Core.Tests.csproj index b71bb9f..b8f7997 100644 --- a/GFramework.Core.Tests/GFramework.Core.Tests.csproj +++ b/GFramework.Core.Tests/GFramework.Core.Tests.csproj @@ -1,7 +1,8 @@  - net8.0;net10.0 + net10.0 + $(TestTargetFrameworks) disable enable false diff --git a/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs b/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs index 60d95e0..bfcf15e 100644 --- a/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs +++ b/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs @@ -1,3 +1,4 @@ +using System.IO; using GFramework.Core.Abstractions.Localization; using GFramework.Core.Localization; @@ -9,7 +10,9 @@ public class LocalizationIntegrationTests [SetUp] public void Setup() { - _testDataPath = "/tmp/localization_example"; + _testDataPath = Path.Combine(Path.GetTempPath(), $"gframework_localization_{Guid.NewGuid():N}"); + CreateTestLocalizationFiles(_testDataPath); + var config = new LocalizationConfig { DefaultLanguage = "eng", @@ -23,9 +26,42 @@ public class LocalizationIntegrationTests _manager.Initialize(); } + [TearDown] + public void TearDown() + { + if (Directory.Exists(_testDataPath)) + { + Directory.Delete(_testDataPath, recursive: true); + } + } + private LocalizationManager? _manager; private string _testDataPath = null!; + private static void CreateTestLocalizationFiles(string rootPath) + { + var engPath = Path.Combine(rootPath, "eng"); + var zhsPath = Path.Combine(rootPath, "zhs"); + Directory.CreateDirectory(engPath); + Directory.CreateDirectory(zhsPath); + + File.WriteAllText(Path.Combine(engPath, "common.json"), """ + { + "game.title": "My Game", + "ui.message.welcome": "Welcome, {playerName}!", + "status.health": "Health: {current}/{max}" + } + """); + + File.WriteAllText(Path.Combine(zhsPath, "common.json"), """ + { + "game.title": "我的游戏", + "ui.message.welcome": "欢迎, {playerName}!", + "status.health": "生命值: {current}/{max}" + } + """); + } + [Test] public void GetText_ShouldReturnEnglishText() { diff --git a/GFramework.Ecs.Arch.Tests/GFramework.Ecs.Arch.Tests.csproj b/GFramework.Ecs.Arch.Tests/GFramework.Ecs.Arch.Tests.csproj index 5fac8dd..b4eb273 100644 --- a/GFramework.Ecs.Arch.Tests/GFramework.Ecs.Arch.Tests.csproj +++ b/GFramework.Ecs.Arch.Tests/GFramework.Ecs.Arch.Tests.csproj @@ -1,7 +1,8 @@ - net8.0;net10.0 + net10.0 + $(TestTargetFrameworks) disable enable false diff --git a/GFramework.Game.Tests/GFramework.Game.Tests.csproj b/GFramework.Game.Tests/GFramework.Game.Tests.csproj index 56fa021..f210018 100644 --- a/GFramework.Game.Tests/GFramework.Game.Tests.csproj +++ b/GFramework.Game.Tests/GFramework.Game.Tests.csproj @@ -1,7 +1,8 @@ - net8.0;net10.0 + net10.0 + $(TestTargetFrameworks) disable enable false diff --git a/GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj b/GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj index 733f870..48c8406 100644 --- a/GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj +++ b/GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj @@ -1,7 +1,8 @@  - net8.0;net10.0 + net10.0 + $(TestTargetFrameworks) disable enable false From 9ca28a44d8544db980df4354f07641422fb79e11 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:15:39 +0800 Subject: [PATCH 8/8] =?UTF-8?q?refactor(localization):=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=9B=9E=E9=80=80=E8=A1=A8=E6=A3=80=E6=9F=A5=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 Fallback.ContainsKey 检查替换为更安全的模式匹配语法 - 避免了潜在的空引用异常风险 - 提高了代码的可读性和健壮性 - 保持了原有的功能逻辑不变 --- GFramework.Core/Localization/LocalizationTable.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/GFramework.Core/Localization/LocalizationTable.cs b/GFramework.Core/Localization/LocalizationTable.cs index 2a063f2..5109089 100644 --- a/GFramework.Core/Localization/LocalizationTable.cs +++ b/GFramework.Core/Localization/LocalizationTable.cs @@ -79,9 +79,9 @@ public class LocalizationTable : ILocalizationTable } // 最后尝试回退表 - if (Fallback != null && Fallback.ContainsKey(key)) + if (Fallback is { } fb && fb.ContainsKey(key)) { - return Fallback.GetRawText(key); + return fb.GetRawText(key); } throw new LocalizationKeyNotFoundException(Name, key); @@ -96,7 +96,7 @@ public class LocalizationTable : ILocalizationTable { return _overrides.ContainsKey(key) || _data.ContainsKey(key) - || (Fallback?.ContainsKey(key) ?? false); + || (Fallback is { } fb && fb.ContainsKey(key)); } ///