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)