feat(core): 添加本地化系统支持多语言功能

- 实现 ILocalizationManager 接口及 LocalizationManager 管理器
- 添加 ILocalizationTable 和 ILocalizationString 接口及其实现
- 创建 LocalizationConfig 配置类用于管理本地化行为
- 实现 ConditionalFormatter 和 PluralFormatter 内置格式化器
- 添加本地化文档包括 API 参考和使用指南
- 集成本地化系统到核心框架架构中
This commit is contained in:
GeWuYou 2026-03-18 22:58:07 +08:00
parent aee13c3c1d
commit e49713a842
18 changed files with 1817 additions and 0 deletions

View File

@ -0,0 +1,22 @@
namespace GFramework.Core.Abstractions.Localization;
/// <summary>
/// 本地化格式化器接口
/// </summary>
public interface ILocalizationFormatter
{
/// <summary>
/// 格式化器名称
/// </summary>
string Name { get; }
/// <summary>
/// 尝试格式化值
/// </summary>
/// <param name="format">格式字符串</param>
/// <param name="value">要格式化的值</param>
/// <param name="provider">格式提供者</param>
/// <param name="result">格式化结果</param>
/// <returns>是否成功格式化</returns>
bool TryFormat(string format, object value, IFormatProvider? provider, out string result);
}

View File

@ -0,0 +1,89 @@
using System.Globalization;
using GFramework.Core.Abstractions.Systems;
namespace GFramework.Core.Abstractions.Localization;
/// <summary>
/// 本地化管理器接口
/// </summary>
public interface ILocalizationManager : ISystem
{
/// <summary>
/// 当前语言代码
/// </summary>
string CurrentLanguage { get; }
/// <summary>
/// 当前文化信息
/// </summary>
CultureInfo CurrentCulture { get; }
/// <summary>
/// 可用语言列表
/// </summary>
IReadOnlyList<string> AvailableLanguages { get; }
/// <summary>
/// 设置当前语言
/// </summary>
/// <param name="languageCode">语言代码</param>
void SetLanguage(string languageCode);
/// <summary>
/// 获取本地化表
/// </summary>
/// <param name="tableName">表名</param>
/// <returns>本地化表</returns>
ILocalizationTable GetTable(string tableName);
/// <summary>
/// 获取本地化文本
/// </summary>
/// <param name="table">表名</param>
/// <param name="key">键名</param>
/// <returns>本地化文本</returns>
string GetText(string table, string key);
/// <summary>
/// 获取本地化字符串(支持变量和格式化)
/// </summary>
/// <param name="table">表名</param>
/// <param name="key">键名</param>
/// <returns>本地化字符串</returns>
ILocalizationString GetString(string table, string key);
/// <summary>
/// 尝试获取本地化文本
/// </summary>
/// <param name="table">表名</param>
/// <param name="key">键名</param>
/// <param name="text">输出文本</param>
/// <returns>是否成功获取</returns>
bool TryGetText(string table, string key, out string text);
/// <summary>
/// 注册格式化器
/// </summary>
/// <param name="name">格式化器名称</param>
/// <param name="formatter">格式化器实例</param>
void RegisterFormatter(string name, ILocalizationFormatter formatter);
/// <summary>
/// 获取格式化器
/// </summary>
/// <param name="name">格式化器名称</param>
/// <returns>格式化器实例,如果不存在则返回 null</returns>
ILocalizationFormatter? GetFormatter(string name);
/// <summary>
/// 订阅语言变化事件
/// </summary>
/// <param name="callback">回调函数</param>
void SubscribeToLanguageChange(Action<string> callback);
/// <summary>
/// 取消订阅语言变化事件
/// </summary>
/// <param name="callback">回调函数</param>
void UnsubscribeFromLanguageChange(Action<string> callback);
}

View File

@ -0,0 +1,50 @@
namespace GFramework.Core.Abstractions.Localization;
/// <summary>
/// 本地化字符串接口(支持变量和格式化)
/// </summary>
public interface ILocalizationString
{
/// <summary>
/// 表名
/// </summary>
string Table { get; }
/// <summary>
/// 键名
/// </summary>
string Key { get; }
/// <summary>
/// 添加变量
/// </summary>
/// <param name="name">变量名</param>
/// <param name="value">变量值</param>
/// <returns>当前实例(支持链式调用)</returns>
ILocalizationString WithVariable(string name, object value);
/// <summary>
/// 批量添加变量
/// </summary>
/// <param name="variables">变量数组</param>
/// <returns>当前实例(支持链式调用)</returns>
ILocalizationString WithVariables(params (string name, object value)[] variables);
/// <summary>
/// 格式化并返回最终文本
/// </summary>
/// <returns>格式化后的文本</returns>
string Format();
/// <summary>
/// 获取原始文本(不进行格式化)
/// </summary>
/// <returns>原始文本</returns>
string GetRaw();
/// <summary>
/// 检查键是否存在
/// </summary>
/// <returns>是否存在</returns>
bool Exists();
}

View File

@ -0,0 +1,48 @@
namespace GFramework.Core.Abstractions.Localization;
/// <summary>
/// 本地化表接口
/// </summary>
public interface ILocalizationTable
{
/// <summary>
/// 表名
/// </summary>
string Name { get; }
/// <summary>
/// 语言代码
/// </summary>
string Language { get; }
/// <summary>
/// 回退表(当前表中找不到键时使用)
/// </summary>
ILocalizationTable? Fallback { get; }
/// <summary>
/// 获取原始文本(不进行格式化)
/// </summary>
/// <param name="key">键名</param>
/// <returns>原始文本</returns>
string GetRawText(string key);
/// <summary>
/// 检查是否包含指定键
/// </summary>
/// <param name="key">键名</param>
/// <returns>是否包含</returns>
bool ContainsKey(string key);
/// <summary>
/// 获取所有键
/// </summary>
/// <returns>键集合</returns>
IEnumerable<string> GetKeys();
/// <summary>
/// 合并覆盖数据
/// </summary>
/// <param name="overrides">覆盖数据</param>
void Merge(IReadOnlyDictionary<string, string> overrides);
}

View File

@ -0,0 +1,37 @@
namespace GFramework.Core.Abstractions.Localization;
/// <summary>
/// 本地化配置
/// </summary>
public class LocalizationConfig
{
/// <summary>
/// 默认语言代码
/// </summary>
public string DefaultLanguage { get; set; } = "eng";
/// <summary>
/// 回退语言代码(当目标语言缺少键时使用)
/// </summary>
public string FallbackLanguage { get; set; } = "eng";
/// <summary>
/// 本地化文件路径Godot 资源路径)
/// </summary>
public string LocalizationPath { get; set; } = "res://localization";
/// <summary>
/// 用户覆盖文件路径(用于热更新和自定义翻译)
/// </summary>
public string OverridePath { get; set; } = "user://localization_override";
/// <summary>
/// 是否启用热重载(监视覆盖文件变化)
/// </summary>
public bool EnableHotReload { get; set; } = true;
/// <summary>
/// 是否在加载时验证本地化文件
/// </summary>
public bool ValidateOnLoad { get; set; } = true;
}

View File

@ -0,0 +1,31 @@
namespace GFramework.Core.Abstractions.Localization;
/// <summary>
/// 本地化异常基类
/// </summary>
public class LocalizationException : Exception
{
/// <summary>
/// 初始化本地化异常
/// </summary>
public LocalizationException()
{
}
/// <summary>
/// 初始化本地化异常
/// </summary>
/// <param name="message">异常消息</param>
public LocalizationException(string message) : base(message)
{
}
/// <summary>
/// 初始化本地化异常
/// </summary>
/// <param name="message">异常消息</param>
/// <param name="innerException">内部异常</param>
public LocalizationException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@ -0,0 +1,29 @@
namespace GFramework.Core.Abstractions.Localization;
/// <summary>
/// 本地化键未找到异常
/// </summary>
public class LocalizationKeyNotFoundException : LocalizationException
{
/// <summary>
/// 初始化键未找到异常
/// </summary>
/// <param name="tableName">表名</param>
/// <param name="key">键名</param>
public LocalizationKeyNotFoundException(string tableName, string key)
: base($"Localization key '{key}' not found in table '{tableName}'")
{
TableName = tableName;
Key = key;
}
/// <summary>
/// 表名
/// </summary>
public string TableName { get; }
/// <summary>
/// 键名
/// </summary>
public string Key { get; }
}

View File

@ -0,0 +1,22 @@
namespace GFramework.Core.Abstractions.Localization;
/// <summary>
/// 本地化表未找到异常
/// </summary>
public class LocalizationTableNotFoundException : LocalizationException
{
/// <summary>
/// 初始化表未找到异常
/// </summary>
/// <param name="tableName">表名</param>
public LocalizationTableNotFoundException(string tableName)
: base($"Localization table '{tableName}' not found")
{
TableName = tableName;
}
/// <summary>
/// 表名
/// </summary>
public string TableName { get; }
}

View File

@ -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"));
}
}

View File

@ -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<string, string>
{
["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<string, string>
{
["test.key"] = "Fallback Value"
};
var fallbackTable = new LocalizationTable("test", "eng", fallbackData);
var data = new Dictionary<string, string>();
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<string, string>
{
["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<string, string>
{
["test.key"] = "Original Value"
};
var table = new LocalizationTable("test", "eng", data);
var overrides = new Dictionary<string, string>
{
["test.key"] = "Override Value"
};
// Act
table.Merge(overrides);
var result = table.GetRawText("test.key");
// Assert
Assert.That(result, Is.EqualTo("Override Value"));
}
}

View File

@ -0,0 +1,38 @@
using GFramework.Core.Abstractions.Localization;
namespace GFramework.Core.Localization.Formatters;
/// <summary>
/// 条件格式化器
/// 格式: {condition:if:trueText|falseText}
/// 示例: {upgraded:if:Upgraded|Normal}
/// </summary>
public class ConditionalFormatter : ILocalizationFormatter
{
/// <inheritdoc/>
public string Name => "if";
/// <inheritdoc/>
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;
}
}
}

View File

@ -0,0 +1,43 @@
using GFramework.Core.Abstractions.Localization;
namespace GFramework.Core.Localization.Formatters;
/// <summary>
/// 复数格式化器
/// 格式: {count:plural:singular|plural}
/// 示例: {count:plural:item|items}
/// </summary>
public class PluralFormatter : ILocalizationFormatter
{
/// <inheritdoc/>
public string Name => "plural";
/// <inheritdoc/>
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;
}
}
}

View File

@ -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;
/// <summary>
/// 本地化管理器实现
/// </summary>
public class LocalizationManager : AbstractSystem, ILocalizationManager
{
private readonly LocalizationConfig _config;
private readonly Dictionary<string, ILocalizationFormatter> _formatters;
private readonly List<Action<string>> _languageChangeCallbacks;
private readonly Dictionary<string, Dictionary<string, ILocalizationTable>> _tables;
private List<string> _availableLanguages;
private CultureInfo _currentCulture;
private string _currentLanguage;
/// <summary>
/// 初始化本地化管理器
/// </summary>
/// <param name="config">配置</param>
public LocalizationManager(LocalizationConfig? config = null)
{
_config = config ?? new LocalizationConfig();
_tables = new Dictionary<string, Dictionary<string, ILocalizationTable>>();
_formatters = new Dictionary<string, ILocalizationFormatter>();
_languageChangeCallbacks = new List<Action<string>>();
_currentLanguage = _config.DefaultLanguage;
_currentCulture = GetCultureInfo(_currentLanguage);
_availableLanguages = new List<string>();
}
/// <inheritdoc/>
public string CurrentLanguage => _currentLanguage;
/// <inheritdoc/>
public CultureInfo CurrentCulture => _currentCulture;
/// <inheritdoc/>
public IReadOnlyList<string> AvailableLanguages => _availableLanguages;
/// <inheritdoc/>
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();
}
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
public string GetText(string table, string key)
{
return GetTable(table).GetRawText(key);
}
/// <inheritdoc/>
public ILocalizationString GetString(string table, string key)
{
return new LocalizationString(this, table, key);
}
/// <inheritdoc/>
public bool TryGetText(string table, string key, out string text)
{
try
{
text = GetText(table, key);
return true;
}
catch
{
text = string.Empty;
return false;
}
}
/// <inheritdoc/>
public void RegisterFormatter(string name, ILocalizationFormatter formatter)
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentNullException(nameof(name));
}
_formatters[name] = formatter ?? throw new ArgumentNullException(nameof(formatter));
}
/// <inheritdoc/>
public ILocalizationFormatter? GetFormatter(string name)
{
if (string.IsNullOrEmpty(name))
{
return null;
}
return _formatters.TryGetValue(name, out var formatter) ? formatter : null;
}
/// <inheritdoc/>
public void SubscribeToLanguageChange(Action<string> callback)
{
if (callback == null)
{
throw new ArgumentNullException(nameof(callback));
}
if (!_languageChangeCallbacks.Contains(callback))
{
_languageChangeCallbacks.Add(callback);
}
}
/// <inheritdoc/>
public void UnsubscribeFromLanguageChange(Action<string> callback)
{
if (callback == null)
{
throw new ArgumentNullException(nameof(callback));
}
_languageChangeCallbacks.Remove(callback);
}
/// <inheritdoc/>
protected override void OnInit()
{
// 扫描可用语言
ScanAvailableLanguages();
// 加载默认语言
LoadLanguage(_config.DefaultLanguage);
}
/// <inheritdoc/>
protected override void OnDestroy()
{
_tables.Clear();
_formatters.Clear();
_languageChangeCallbacks.Clear();
}
/// <summary>
/// 扫描可用语言
/// </summary>
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);
}
}
/// <summary>
/// 加载语言
/// </summary>
private void LoadLanguage(string languageCode)
{
if (_tables.ContainsKey(languageCode))
{
return; // 已加载
}
var languageTables = new Dictionary<string, ILocalizationTable>();
// 加载回退语言(如果不是默认语言)
Dictionary<string, ILocalizationTable>? 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;
}
/// <summary>
/// 加载 JSON 文件
/// </summary>
private static Dictionary<string, string> LoadJsonFile(string filePath)
{
var json = File.ReadAllText(filePath);
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
return data ?? new Dictionary<string, string>();
}
/// <summary>
/// 获取文化信息
/// </summary>
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;
}
}
/// <summary>
/// 触发语言变化事件
/// </summary>
private void TriggerLanguageChange()
{
foreach (var callback in _languageChangeCallbacks.ToList())
{
try
{
callback(_currentLanguage);
}
catch
{
// 忽略回调异常
}
}
}
}

View File

@ -0,0 +1,148 @@
using System.Text.RegularExpressions;
using GFramework.Core.Abstractions.Localization;
namespace GFramework.Core.Localization;
/// <summary>
/// 本地化字符串实现
/// </summary>
public class LocalizationString : ILocalizationString
{
private readonly ILocalizationManager _manager;
private readonly Dictionary<string, object> _variables;
/// <summary>
/// 初始化本地化字符串
/// </summary>
/// <param name="manager">本地化管理器</param>
/// <param name="table">表名</param>
/// <param name="key">键名</param>
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<string, object>();
}
/// <inheritdoc/>
public string Table { get; }
/// <inheritdoc/>
public string Key { get; }
/// <inheritdoc/>
public ILocalizationString WithVariable(string name, object value)
{
if (name == null)
{
throw new ArgumentNullException(nameof(name));
}
_variables[name] = value;
return this;
}
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
public string Format()
{
var rawText = GetRaw();
return FormatString(rawText, _variables, _manager);
}
/// <inheritdoc/>
public string GetRaw()
{
if (!_manager.TryGetText(Table, Key, out var text))
{
return $"[{Table}.{Key}]";
}
return text;
}
/// <inheritdoc/>
public bool Exists()
{
return _manager.TryGetText(Table, Key, out _);
}
/// <summary>
/// 格式化字符串(支持变量替换和格式化器)
/// </summary>
private static string FormatString(
string template,
Dictionary<string, object> 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
};
});
}
/// <summary>
/// 获取格式化器
/// </summary>
private static ILocalizationFormatter? GetFormatter(ILocalizationManager manager, string name)
{
return manager.GetFormatter(name);
}
}

View File

@ -0,0 +1,136 @@
using GFramework.Core.Abstractions.Localization;
namespace GFramework.Core.Localization;
/// <summary>
/// 本地化表实现
/// </summary>
public class LocalizationTable : ILocalizationTable
{
/// <summary>
/// 存储原始本地化数据的字典
/// </summary>
private readonly Dictionary<string, string> _data;
/// <summary>
/// 存储覆盖数据的字典,优先级高于原始数据
/// </summary>
private readonly Dictionary<string, string> _overrides;
/// <summary>
/// 初始化本地化表
/// </summary>
/// <param name="name">表名</param>
/// <param name="language">语言代码</param>
/// <param name="data">数据字典</param>
/// <param name="fallback">回退表</param>
public LocalizationTable(
string name,
string language,
IReadOnlyDictionary<string, string> data,
ILocalizationTable? fallback = null)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Language = language ?? throw new ArgumentNullException(nameof(language));
_data = new Dictionary<string, string>(data);
_overrides = new Dictionary<string, string>();
Fallback = fallback;
}
/// <summary>
/// 获取本地化表的名称
/// </summary>
public string Name { get; }
/// <summary>
/// 获取语言代码
/// </summary>
public string Language { get; }
/// <summary>
/// 获取回退表,当当前表找不到键时用于查找
/// </summary>
public ILocalizationTable? Fallback { get; }
/// <summary>
/// 获取指定键的原始文本内容
/// </summary>
/// <param name="key">要查找的本地化键</param>
/// <returns>找到的本地化文本值</returns>
/// <exception cref="ArgumentNullException">当 key 为 null 时抛出</exception>
/// <exception cref="LocalizationKeyNotFoundException">当键在表中不存在且无回退表时抛出</exception>
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);
}
/// <summary>
/// 检查是否包含指定的键
/// </summary>
/// <param name="key">要检查的本地化键</param>
/// <returns>如果存在则返回 true否则返回 false</returns>
public bool ContainsKey(string key)
{
return _overrides.ContainsKey(key)
|| _data.ContainsKey(key)
|| (Fallback?.ContainsKey(key) ?? false);
}
/// <summary>
/// 获取所有可用的本地化键集合
/// </summary>
/// <returns>包含所有键的可枚举集合</returns>
public IEnumerable<string> GetKeys()
{
var keys = new HashSet<string>(_data.Keys);
keys.UnionWith(_overrides.Keys);
if (Fallback != null)
{
keys.UnionWith(Fallback.GetKeys());
}
return keys;
}
/// <summary>
/// 合并覆盖数据到当前表
/// </summary>
/// <param name="overrides">要合并的覆盖数据字典</param>
/// <exception cref="ArgumentNullException">当 overrides 为 null 时抛出</exception>
public void Merge(IReadOnlyDictionary<string, string> overrides)
{
if (overrides == null)
{
throw new ArgumentNullException(nameof(overrides));
}
foreach (var (key, value) in overrides)
{
_overrides[key] = value;
}
}
}

View File

@ -66,6 +66,23 @@ IoC 容器命名空间。
| `IObjectPool<T>` | 对象池接口 |
| `ObjectPool<T>` | 对象池实现 |
### GFramework.Core.Localization
本地化系统命名空间。
#### 主要类型
| 类型 | 说明 |
|--------------------------|----------|
| `ILocalizationManager` | 本地化管理器接口 |
| `ILocalizationTable` | 本地化表接口 |
| `ILocalizationString` | 本地化字符串接口 |
| `ILocalizationFormatter` | 格式化器接口 |
| `LocalizationConfig` | 本地化配置类 |
| `LocalizationManager` | 本地化管理器实现 |
| `LocalizationTable` | 本地化表实现 |
| `LocalizationString` | 本地化字符串实现 |
## 常用 API
### Architecture
@ -247,6 +264,99 @@ public class BindableProperty<T>
}
```
### ILocalizationManager
```csharp
public interface ILocalizationManager : ISystem
{
// 获取当前语言代码
string CurrentLanguage { get; }
// 获取当前文化信息
CultureInfo CurrentCulture { get; }
// 获取可用语言列表
IReadOnlyList<string> 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<string> callback);
// 取消订阅语言变化事件
void UnsubscribeFromLanguageChange(Action<string> 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}");
});
```
---
更多详情请查看各模块的详细文档。

View File

@ -421,6 +421,7 @@ public class PlayerController : IController
| **extensions** | 扩展方法,简化 API 调用 | [查看](./extensions) |
| **logging** | 日志系统,记录运行日志 | [查看](./logging) |
| **environment** | 环境接口,提供运行环境信息 | [查看](./environment) |
| **localization** | 本地化系统,多语言支持 | [查看](./localization) |
## 组件联动

View File

@ -0,0 +1,487 @@
# Localization 本地化系统
## 概述
Localization 包提供了完整的多语言本地化支持,实现了游戏文本的国际化管理。通过本地化系统,可以轻松实现多语言切换、动态变量替换、回退机制等功能。
本地化系统是 GFramework 架构中的 System 层组件,与其他系统无缝集成,支持类型安全的 API 和流畅的使用体验。
## 核心接口
### ILocalizationManager
本地化管理器接口,继承自 `ISystem`,提供本地化的核心功能。
**核心属性:**
```csharp
string CurrentLanguage { get; } // 当前语言代码
CultureInfo CurrentCulture { get; } // 当前文化信息
IReadOnlyList<string> 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<string> callback); // 订阅语言变化
void UnsubscribeFromLanguageChange(Action<string> callback); // 取消订阅
```
### ILocalizationTable
本地化表接口,表示单个语言的本地化数据表。
**核心属性:**
```csharp
string Name { get; } // 表名
string Language { get; } // 语言代码
ILocalizationTable? Fallback { get; } // 回退表
```
**核心方法:**
```csharp
string GetRawText(string key); // 获取原始文本
bool ContainsKey(string key); // 检查键是否存在
IEnumerable<string> GetKeys(); // 获取所有键
void Merge(IReadOnlyDictionary<string, string> 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<GameArchitecture>
{
protected override void OnInit()
{
// 注册本地化管理器
this.RegisterSystem<ILocalizationManager>(new LocalizationManager());
}
}
```
### 获取本地化文本
```csharp
// 获取管理器
var locManager = this.GetSystem<ILocalizationManager>();
// 简单文本
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<ILocalizationManager>();
locManager.SubscribeToLanguageChange(OnLanguageChanged);
}
// 在组件销毁时取消订阅
public override void OnDestroy()
{
var locManager = this.GetSystem<ILocalizationManager>();
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)