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/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 new file mode 100644 index 0000000..bfcf15e --- /dev/null +++ b/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs @@ -0,0 +1,142 @@ +using System.IO; +using GFramework.Core.Abstractions.Localization; +using GFramework.Core.Localization; + +namespace GFramework.Core.Tests.Localization; + +[TestFixture] +public class LocalizationIntegrationTests +{ + [SetUp] + public void Setup() + { + _testDataPath = Path.Combine(Path.GetTempPath(), $"gframework_localization_{Guid.NewGuid():N}"); + CreateTestLocalizationFiles(_testDataPath); + + var config = new LocalizationConfig + { + DefaultLanguage = "eng", + FallbackLanguage = "eng", + LocalizationPath = _testDataPath, + EnableHotReload = false, + ValidateOnLoad = false + }; + + _manager = new LocalizationManager(config); + _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() + { + // 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/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()); + } } /// 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..5ff4d88 --- /dev/null +++ b/GFramework.Core/Localization/LocalizationManager.cs @@ -0,0 +1,308 @@ +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 (LocalizationException) + { + // 只捕获本地化相关的异常(键不存在、表不存在等) + 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..15832ab --- /dev/null +++ b/GFramework.Core/Localization/LocalizationString.cs @@ -0,0 +1,237 @@ +using System.Text.RegularExpressions; +using GFramework.Core.Abstractions.Localization; + +namespace GFramework.Core.Localization; + +/// +/// 本地化字符串实现 +/// +public class LocalizationString : ILocalizationString +{ + /// + /// 匹配 {variableName} 或 {variableName:formatter:args} 的正则表达式模式 + /// + private const 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; + + /// + /// 初始化本地化字符串 + /// + /// 本地化管理器实例 + /// 本地化表名,用于定位本地化资源表 + /// 本地化键名,用于在表中定位具体的本地化文本 + /// 当 manager、table 或 key 为 null 时抛出 + 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; } + + /// + /// 添加单个变量到本地化字符串中 + /// + /// 变量名称,用于在模板中匹配对应的占位符 + /// 变量值,将被转换为字符串并替换到对应位置 + /// 返回当前的 LocalizationString 实例,支持链式调用 + /// 当 name 为 null 时抛出 + public ILocalizationString WithVariable(string name, object value) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + _variables[name] = value; + return this; + } + + /// + /// 批量添加多个变量到本地化字符串中 + /// + /// 变量元组数组,每个元组包含变量名称和对应的值 + /// 返回当前的 LocalizationString 实例,支持链式调用 + /// 当 variables 为 null 时抛出 + 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; + } + + /// + /// 格式化本地化字符串,将模板中的变量占位符替换为实际值 + /// + /// 格式化后的完整字符串。如果本地化文本不存在,则返回 "[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)) + { + return $"[{Table}.{Key}]"; + } + + return text; + } + + /// + /// 检查当前本地化键是否存在于本地化管理器中 + /// + /// 如果存在返回 true;否则返回 false + public bool Exists() + { + return _manager.TryGetText(Table, Key, out _); + } + + /// + /// 格式化字符串(支持变量替换和格式化器) + /// + /// 包含占位符的模板字符串 + /// 包含变量名称和值的字典 + /// 本地化管理器实例,用于获取格式化器 + /// 格式化后的字符串。如果模板为空或 null,则直接返回原模板 + private static string FormatString( + string template, + Dictionary variables, + ILocalizationManager manager) + { + if (string.IsNullOrEmpty(template)) + { + return template; + } + + // 使用预编译的静态正则表达式匹配 {variableName} 或 {variableName:formatter:args} + 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)) + { + return match.Value; + } + + var formatterName = GetOptionalGroupValue(match, 2); + if (string.IsNullOrEmpty(formatterName)) + { + return FormatValue(value, manager); + } + + return TryFormatValue(match, value, formatterName, manager, out var result) + ? result + : FormatValue(value, manager); + } + + /// + /// 尝试使用指定的格式化器格式化变量值 + /// + /// 正则表达式匹配结果,用于获取格式化参数 + /// 要格式化的变量值 + /// 格式化器名称 + /// 本地化管理器实例 + /// 格式化后的结果字符串 + /// 如果格式化成功返回 true;否则返回 false,此时 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; + } + + result = string.Empty; + return false; + } + + /// + /// 对变量值进行默认格式化,不使用自定义格式化器 + /// + /// 要格式化的值 + /// 本地化管理器实例,提供当前文化信息 + /// 格式化后的字符串。如果值实现 IFormattable 接口则使用其 ToString 方法,否则调用默认的 ToString 方法 + private static string FormatValue(object value, ILocalizationManager manager) + { + return value switch + { + IFormattable formattable => formattable.ToString(null, manager.CurrentCulture), + _ => value.ToString() ?? string.Empty + }; + } + + /// + /// 获取正则表达式匹配组中的可选值 + /// + /// 正则表达式匹配结果 + /// 要获取的组索引 + /// 如果该组匹配成功则返回其值;否则返回 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); + } +} \ 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..5109089 --- /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 is { } fb && fb.ContainsKey(key)) + { + return fb.GetRawText(key); + } + + throw new LocalizationKeyNotFoundException(Name, key); + } + + /// + /// 检查是否包含指定的键 + /// + /// 要检查的本地化键 + /// 如果存在则返回 true,否则返回 false + public bool ContainsKey(string key) + { + return _overrides.ContainsKey(key) + || _data.ContainsKey(key) + || (Fallback is { } fb && fb.ContainsKey(key)); + } + + /// + /// 获取所有可用的本地化键集合 + /// + /// 包含所有键的可枚举集合 + 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/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 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..03cb112 --- /dev/null +++ b/docs/zh-CN/core/localization.md @@ -0,0 +1,501 @@ +# 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 (暂不支持) +``` + +**注意:** `OverridePath`、`EnableHotReload` 和 `ValidateOnLoad` 配置项已定义但当前版本暂不支持,将在后续版本中实现。 + +## 文件组织 + +### 目录结构 + +``` +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 +// 方式 1: 使用 lambda 表达式(无法取消订阅) +locManager.SubscribeToLanguageChange(language => +{ + Debug.Log($"Language changed to: {language}"); + // 更新 UI、重新加载资源等 +}); + +// 方式 2: 使用命名方法(推荐,可以取消订阅) +void OnLanguageChanged(string language) +{ + Debug.Log($"Language changed to: {language}"); + // 更新 UI、重新加载资源等 +} + +// 订阅 +locManager.SubscribeToLanguageChange(OnLanguageChanged); + +// 取消订阅(使用相同的方法引用) +locManager.UnsubscribeFromLanguageChange(OnLanguageChanged); +``` + +## 高级功能 + +### 回退机制 + +当目标语言缺少某个键时,系统会自动回退到默认语言: + +```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)