Compare commits

..

67 Commits

Author SHA1 Message Date
gewuyou
a9ac8a927c
Merge pull request #134 from GeWuYou/feat/state-history-undo-redo
feat(state): 扩展状态管理功能支持历史记录撤销重做和批处理
2026-03-24 19:52:01 +08:00
GeWuYou
e5da5aa801 refactor(state): 将历史快照属性改为方法并优化批处理逻辑
- 将 HistoryEntries 属性替换为 GetHistoryEntriesSnapshot() 方法,明确表达会返回集合副本
- 在批处理清理逻辑中添加 else 条件避免无效操作
- 更新测试代码使用新的快照获取方法
- 添加 System.Diagnostics 命名空间引用
- 修复批处理深度为零时的异常处理逻辑
2026-03-24 19:45:59 +08:00
GeWuYou
bd29475748 refactor(store): 添加调试断言确保线程安全
- 在 CreateMiddlewareSnapshotCore 方法中添加锁持有检查
- 在 CreateReducerSnapshotCore 方法中添加锁持有检查
- 确保并发安全性避免竞态条件
- 导入 System.Diagnostics 命名空间支持断言功能
2026-03-24 19:39:55 +08:00
GeWuYou
91246ff482 feat(state): 扩展状态管理功能支持历史记录撤销重做和批处理
- 新增 RunInBatch() 方法支持批处理通知折叠
- 添加 Undo()/Redo() 基于历史缓冲区的状态回退前进功能
- 实现 TimeTravelTo() 跳转到指定历史索引的能力
- 提供 ClearHistory() 以当前状态重置历史锚点的功能
- 支持可选历史缓冲区、撤销/重做和时间旅行功能
- 添加可选批处理通知折叠机制
- 实现多态 action 匹配(基类/接口)支持
- 在诊断信息中增加历史游标和批处理状态
- StoreBuilder 新增 WithHistoryCapacity() 和 WithActionMatching() 配置方法
- 优化 reducer 注册支持全局序号以实现稳定排序
- 实现多态匹配时的类型继承距离计算
- 添加批处理嵌套支持和状态通知延迟机制
2026-03-24 19:08:03 +08:00
gewuyou
b912e6aa4d
Merge pull request #133 from GeWuYou/feat/state-dynamic-registration
feat(state): 添加运行时临时注册与注销功能
2026-03-23 21:18:43 +08:00
GeWuYou
1f1aff5335 refactor(state): 优化状态管理存储的中间件和reducer快照创建
- 为CreateMiddlewareSnapshot方法添加状态锁保护
- 为CreateReducerSnapshot方法添加状态锁保护
- 更新方法注释说明锁的安全性保障机制
- 避免调用方需要了解锁顺序的隐式依赖关系
2026-03-23 20:58:03 +08:00
GeWuYou
14849d6761 refactor(GFramework.Core.Tests): 更新全局using引用
- 添加GFramework.Core.Abstractions.Property命名空间引用
- 重新排序using语句以优化代码结构
- 保持现有功能不变的同时改进代码组织方式
2026-03-23 20:41:36 +08:00
GeWuYou
2c00070bb1 feat(state): 添加运行时临时注册与注销功能
- 实现 RegisterReducerHandle 和 RegisterMiddleware 方法,支持获取注销句柄
- 添加 IUnRegister 接口和 DefaultUnRegister 实现,提供精确注销能力
- 修改内部数据结构,使用 Registration 包装对象确保注销时的身份稳定性
- 实现中间件和 reducer 的快照机制,确保运行中注销不影响当前 dispatch
- 添加相关单元测试验证运行时注册注销的正确性
- 更新文档说明运行时临时注册与注销的使用方式和约束条件
2026-03-23 20:38:46 +08:00
gewuyou
b6ef6278c0
Merge pull request #132 from GeWuYou/feat/state-management-core
Feat/state management core
2026-03-23 20:19:53 +08:00
GeWuYou
3d212716d6 refactor(tests): 更新状态管理测试的命名空间引用
- 添加了属性抽象层的命名空间引用
- 添加了状态管理抽象层的命名空间引用
- 保持了原有的核心扩展、属性和状态管理命名空间引用
2026-03-23 20:14:23 +08:00
GeWuYou
b7c54743fa refactor(state): 优化 Store 状态分发的并发控制机制
- 将 Store 类标记为 sealed 以防止继承
- 引入独立的 dispatch 门闩锁,将状态锁的保护范围缩小为仅保护临界区访问
- 实现 dispatch 过程中的快照机制,确保中间件和 reducer 在锁外执行稳定的不可变序列
- 重构 ExecuteDispatchPipeline 方法,接受快照参数并改为静态方法
- 添加 CreateReducerSnapshot 方法为每次分发创建 reducer 快照
- 更新 StoreBuilder 和 StoreSelection 类为 sealed
- 新增测试用例验证长时间运行的 middleware 不会阻塞状态读取和订阅操作
- 修复 dispatch 过程中状态锁占用时间过长的问题,提升并发性能
2026-03-23 20:11:10 +08:00
GeWuYou
79cebb95b5 feat(state): 添加 StoreBuilder 配置功能并优化状态管理
- 引入 StoreBuilder<TState> 支持模块化配置 reducer 和中间件
- 实现状态选择视图缓存机制提升性能
- 重构订阅管理使用精确订阅对象替代委托链
- 增强 SubscribeWithInitValue 方法防止状态变化遗漏
- 添加完整的状态管理文档示例和测试用例
- 更新接口定义支持新的构建器功能
2026-03-23 19:59:23 +08:00
GeWuYou
79f1240e1d docs(core): 添加状态管理文档并完善属性绑定指南
- 新增 state-management 文档,介绍集中式状态容器方案
- 在 property 文档中补充与 Store 的使用边界说明
- 更新核心功能表格,添加状态管理条目链接
- 在 README 中增加 StateManagement 功能描述
- 添加状态管理相关接口到抽象层文档
- 提供 Store 与 BindableProperty 的选择指导原则
2026-03-23 19:35:01 +08:00
GeWuYou
2b4b87baba feat(state): 添加状态管理框架核心功能
- 实现 Store 类作为集中式状态容器,默认支持状态归约和订阅通知
- 添加 IReadonlyStore、IStore、IReducer 等状态管理相关抽象接口
- 实现 StoreExtensions 扩展方法,提供 Select 和 ToBindableProperty 选择器功能
- 添加 StoreSelection 类,支持从完整状态树中投影局部状态视图
- 实现 StoreDispatchContext 和 StoreDispatchRecord 用于分发过程诊断
- 添加 IStoreMiddleware 中间件接口,支持在分发过程中插入日志和审计逻辑
- 实现完整的状态选择器和绑定属性桥接功能,便于现有 UI 代码复用
- 添加 Store 相关单元测试,覆盖状态归约、订阅通知和选择器桥接场景
2026-03-23 19:34:28 +08:00
deepsource-autofix[bot]
c70728b64e refactor: simplify lambda
This PR refactors a lambda expression that contained unnecessary braces around a single-statement body, converting it into a more concise expression-bodied form to improve readability and maintainability.

- Consider simplifying lambda when its body has a single statement: The original lambda used a block body with braces and a single call to Execute, which is verbose for a one-line operation. We removed the braces and semicolon, converting it into an expression-bodied lambda (`static (spc, pair) => Execute(spc, pair.Left, pair.Right)`) to simplify the code and follow best practices.

> This Autofix was generated by AI. Please review the change before merging.
2026-03-23 08:28:07 +08:00
gewuyou
cf486cbeff
Merge pull request #128 from GeWuYou/feat/get-node-generator
feat(godot): 添加 GetNode 源代码生成器功能
2026-03-22 15:34:00 +08:00
gewuyou
8d656b90a7
Merge pull request #129 from GeWuYou/deepsource-autofix-b7cf8394
refactor: simplify single-statement getter
2026-03-22 15:30:07 +08:00
GeWuYou
fc386fb4bc refactor(generator): 调整项目文件夹结构
- 移除 logging 文件夹引用
- 将 diagnostics 文件夹重命名为 Diagnostics
- 更新项目文件中的文件夹路径配置
2026-03-22 15:28:39 +08:00
deepsource-autofix[bot]
bbf1dc8d0c
refactor: simplify single-statement getter
This PR refactors properties that contain only a single return statement by converting them to expression-bodied members, reducing boilerplate and improving readability.

- Getters and setters with a single statement in their bodies can be simplified: The `IsDone` property originally used a full getter block to evaluate whether all coroutine handles are inactive. We replaced it with an expression-bodied property (`public bool IsDone => _handles.All(handle => !_scheduler.IsCoroutineAlive(handle));`) and relocated the explanatory comment above it, streamlining the code.

> This Autofix was generated by AI. Please review the change before merging.
2026-03-22 07:24:25 +00:00
GeWuYou
b95c65a30e refactor(generator): 优化GetNodeGenerator代码结构
- 使用语法树遍历替代字符串匹配来检测注入方法调用
- 添加IsGeneratedInjectionInvocation辅助方法提高代码可读性
- 将字段分组逻辑从列表查找改为字典映射提升性能
- 优化GroupByContainingType方法的时间复杂度
2026-03-22 15:23:51 +08:00
GeWuYou
9ab09cf47b feat(godot): 添加 GetNode 源代码生成器功能
- 实现了 [GetNode] 属性用于标记 Godot 节点字段
- 创建了 GetNodeGenerator 源代码生成器自动注入节点获取逻辑
- 添加了节点路径推导和多种查找模式支持
- 集成了生成器到 Godot 脚手架模板中
- 添加了完整的诊断规则和错误提示
- 创建了单元测试验证生成器功能
- 更新了解决方案配置以包含新的测试项目
- 在 README 中添加了详细的使用文档和示例代码
2026-03-22 15:16:24 +08:00
gewuyou
63b1d71a0e
Merge pull request #127 from GeWuYou/feat/async-log-appender-error-handler
feat(logging): 添加异步日志输出器的错误处理回调功能
2026-03-21 22:09:06 +08:00
GeWuYou
cdc49c319a feat(logging): 添加异步日志输出器功能
- 使用 Channel 实现异步日志处理机制
- 解耦调用线程与慢速日志目标
- 添加全局 Channels 命名空间引用
- 完善日志组件的异步处理能力
2026-03-21 22:04:09 +08:00
GeWuYou
d94d8deb29 fix(logging): 修复异步日志追加器中的操作取消异常处理
- 添加对 OperationCanceledException 的特殊处理,避免将其报告为后台处理错误
- 在 ReportProcessingError 方法中检查并过滤掉操作取消异常
- 添加单元测试验证当内部追加器抛出 OperationCanceledException 时不报告错误
- 创建 CancellationAppender 测试辅助类来模拟取消异常场景
- 确保取消相关的异常不会触发错误处理逻辑
2026-03-21 21:59:51 +08:00
GeWuYou
49609d3821 feat(logging): 添加异步日志输出器的错误处理回调功能
- 在 AsyncLogAppender 构造函数中添加 processingErrorHandler 参数用于处理后台异常
- 实现 ReportProcessingError 方法安全上报后台处理异常而不影响处理循环
- 更新文档注释说明异常处理机制和错误回调用途
- 修改测试用例验证异常处理回调功能的正确性
- 确保错误观察者异常不会终止日志处理线程
- 移除直接写入控制台错误输出的逻辑改为统一回调处理
2026-03-21 21:54:24 +08:00
gewuyou
003fe42ad8
Merge pull request #126 from GeWuYou/refactor/generators-downgrade-to-netstandard20
refactor(generators): 将源代码生成器项目目标框架降级至 netstandard2.0
2026-03-21 21:40:43 +08:00
GeWuYou
a42ec0c282 fix(generator): 修复优先级生成器中的部分关键字检查逻辑
- 将语法节点的部分关键字检查从 Any 操作改为 All 操作
- 修正了对非部分类的诊断报告条件判断
- 确保只有当所有修饰符都不是部分关键字时才报告错误
2026-03-21 21:31:52 +08:00
GeWuYou
884249649d chore(build): 配置项目构建属性以支持源代码生成器
- 为 GFramework.Godot 项目添加 GodotProjectDir 属性默认值
- 在 GFramework 元包中添加 NoPackageAnalysis 属性配置
- 为不同 .NET 版本添加占位符文件到包输出中
- 确保源代码生成器在标准 SDK 构建中正常运行
2026-03-21 21:30:29 +08:00
GeWuYou
d582dffe40 refactor(generators): 将源代码生成器项目目标框架降级至 netstandard2.0
- 将 GFramework.Godot.SourceGenerators 项目的目标框架从 netstandard2.1 更改为 netstandard2.0
- 将 GFramework.SourceGenerators.Abstractions 项目的目标框架从 netstandard2.1 更改为 netstandard2.0
- 将 GFramework.SourceGenerators 项目的目标框架从 netstandard2.1 更改为 netstandard2.0
- 将 GFramework.SourceGenerators.Common 项目的目标框架从 netstandard2.1 更改为 netstandard2.0
- 从 GFramework.SourceGenerators 项目中移除对 GFramework.Core.Abstractions 的引用
- 从 GFramework.SourceGenerators.Abstractions 项目中移除对 GFramework.Core.Abstractions 的引用
- 更新 PriorityGenerator 中的语法检查逻辑,使用 Any 替代 All 进行 partial 关键字检查
- 更新 PriorityAttribute 文档注释中的 cref 格式为 c 标签
2026-03-21 21:20:32 +08:00
GeWuYou
63a6c2e6f0 refactor(abstractions): 移除重复的Key属性声明
- 从LockInfo结构体移除StructLayout特性
- 从ISceneBehavior接口移除重复的Key属性声明
- 从IUiPageBehavior接口移除重复的Key属性声明
- 简化LockInfo类定义减少不必要的using引用
2026-03-21 21:13:53 +08:00
GeWuYou
f3d45169cd refactor(pause): 将暂停状态变化事件改为标准事件模式
- 将 OnPauseStateChanged 事件从 Action<PauseGroup, bool> 类型改为 EventHandler<PauseStateChangedEventArgs>
- 添加 PauseStateChangedEventArgs 类来封装事件数据
- 更新所有事件处理方法的签名以匹配新的事件参数
- 修改文档中相关的事件处理代码示例
- 在 PauseStackManager 中添加 RaisePauseStateChanged 方法统一处理事件触发
- 更新测试代码以适应新的事件处理方式
2026-03-21 21:13:53 +08:00
GeWuYou
86645d34cb fix(generator): 修复诊断消息中的多余句号
- 移除了 Priority 特性描述中的多余句号
- 移除了 IPrioritized 接口实现跳过警告中的多余句号
- 移除了 partial 类要求错误中的多余句号
- 移除了 Priority 特性值验证错误中的多余句号
- 移除了嵌套类限制错误中的多余句号
- 移除了服务获取建议信息中的多余句号
2026-03-21 20:51:03 +08:00
GeWuYou
ab04f0ace7 docs(diagnostic): 更新诊断消息描述文本
- 为 Priority 特性错误消息添加句号结尾
- 为接口实现警告消息添加句号结尾
- 为 partial 类要求错误消息添加句号结尾
- 为整数值验证错误消息添加句号结尾
- 为嵌套类限制错误消息添加句号结尾
- 为服务获取建议消息添加句号结尾
- 在测试项目配置中添加警告级别设置
2026-03-21 20:51:03 +08:00
gewuyou
51492b1dcd fix(docs): remove dead ADR references 2026-03-21 15:56:18 +08:00
gewuyou
4caa3c0d71
Merge pull request #122 from GeWuYou/feat/localization-compact-number-formatter
Feat/localization compact number formatter
2026-03-21 15:07:02 +08:00
GeWuYou
fca3808657 fix(localization): 修复紧凑数字格式化器处理未知选项的行为
- 修改 CompactNumberLocalizationFormatter 中 TryApplyOption 方法的返回逻辑
- 当键名未知时现在返回 true 而不是 false,允许忽略未知选项
- 更新 NumericDisplayFormatter 使用 ResolveRule 方法来确定格式化规则
- 添加对不同数值显示风格的支持和验证
- 在集成测试中添加未知选项的测试用例以验证正确行为
- 改进 NumericSuffixFormatRule 的文档注释以更清楚地描述功能
2026-03-21 15:01:47 +08:00
GeWuYou
5996ecf5f3 docs(core): 添加内置数值显示工具文档
- 新增内置数值显示工具使用说明
- 提供 FormatCompact 和 ToCompactString 示例代码
- 展示 NumericFormatOptions 配置选项用法
- 介绍本地化文本中的数值格式化功能
- 更新相关链接列表移除多余逗号
2026-03-21 14:43:55 +08:00
GeWuYou
53edd13f8f feat(localization): 添加本地化格式化器和数值显示功能
- 在LocalizationManager中注册内置格式化器包括条件、复数和紧凑数值格式化器
- 实现CompactNumberLocalizationFormatter支持{value:compact}格式化语法
- 添加数值显示扩展方法ToDisplayString和ToCompactString
- 实现NumericDisplayFormatter和NumericSuffixFormatRule数值格式化核心逻辑
- 添加数值格式化选项配置包括小数位数、四舍五入策略等参数
- 为紧凑数值格式化功能添加完整的单元测试覆盖各种数值类型和边界情况
2026-03-21 14:43:42 +08:00
gewuyou
0442fec2d1
Merge pull request #121 from GeWuYou/docs/refactor-guidelines-comprehensive
Docs/refactor guidelines comprehensive
2026-03-21 13:47:39 +08:00
GeWuYou
2001eddbff refactor(scripts): 重构AI环境生成脚本以优化工具选择逻辑
- 移除shell脚本中的冗余命令执行逻辑
- 添加select_tool函数统一处理工具偏好和回退逻辑
- 使用新函数重构搜索、JSON处理、脚本编写等工具选择
- 更新时间戳以反映代码变更后的重新生成
- 简化构建工具配置的回退策略
2026-03-21 12:49:51 +08:00
GeWuYou
3130a3bab2 feat(docs): 添加开发环境能力清单及相关工具脚本
- 在贡献指南中添加开发环境能力清单链接和说明
- 创建详细的开发环境能力清单文档,记录项目所需的运行时和工具
- 添加 .ai/environment/tools.ai.yaml 文件,为 AI 提供精简的环境能力信息
- 添加 .ai/environment/tools.raw.yaml 文件,存储完整的环境检测数据
- 创建 collect-dev-environment.sh 脚本,用于收集和输出环境信息
- 创建 generate-ai-environment.py 脚本,从原始数据生成 AI 友好的环境清单
- 在 .gitignore 中添加 .venv/ 工具目录忽略规则
2026-03-21 12:37:56 +08:00
GeWuYou
ba4ee24ce7 docs(guidelines): 更新 AGENTS.md 和 CLAUDE.md 文档内容
- 重写 AGENTS.md 为 AI 代理和贡献者提供完整的编码行为准则
- 新增详细的注释规则,包括 XML 文档、内联注释和架构级注释要求
- 完善代码风格指南,涵盖命名约定、格式化和 C# 最佳实践
- 扩展测试要求,明确覆盖率、组织结构和执行期望
- 添加安全规则和文档规范,确保代码质量和安全性
- 重构 CLAUDE.md 为 AI 代理提供项目理解指导
- 更新模块依赖图和架构模式说明,澄清各层职责
- 补充源码生成器、测试框架和文档结构的详细说明
- 优化项目概述和设计意图描述,提升 AI 理解准确性
2026-03-21 11:36:39 +08:00
gewuyou
dfae4ba207
Merge pull request #120 from GeWuYou/feat/docs-add-repository-guide
docs(guide): 添加仓库开发指南文档
2026-03-20 08:36:02 +08:00
GeWuYou
ccb51791a3 docs(guide): 更新提交和拉取请求指南中的拼写错误
- 修复了 "Conventional Commit" 到 "Conventional Commits" 的拼写
- 确保文档中使用的术语与业界标准保持一致
2026-03-20 08:24:17 +08:00
GeWuYou
55234c4d70 docs(guide): 添加仓库开发指南文档
- 创建了项目结构和模块组织说明
- 添加了构建测试和开发命令指南
- 定义了代码风格和命名约定规范
- 提供了测试指南和最佳实践
- 包含了提交和拉取请求准则
- 记录了解决方案文件和目录布局说明
2026-03-20 08:18:00 +08:00
dependabot[bot]
108bcbf27e Bump Meziantou.Analyzer from 3.0.19 to 3.0.25
---
updated-dependencies:
- dependency-name: Meziantou.Analyzer
  dependency-version: 3.0.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Meziantou.Analyzer
  dependency-version: 3.0.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Meziantou.Analyzer
  dependency-version: 3.0.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Meziantou.Analyzer
  dependency-version: 3.0.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Meziantou.Analyzer
  dependency-version: 3.0.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 08:12:51 +08:00
dependabot[bot]
51393c30ee Bump Microsoft.Extensions.DependencyInjection from 10.0.4 to 10.0.5
---
updated-dependencies:
- dependency-name: Microsoft.Extensions.DependencyInjection
  dependency-version: 10.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 08:12:35 +08:00
dependabot[bot]
589f9f7d63 Bump the nuget group with 1 update
Bumps Scriban from 6.2.0 to 6.6.0

---
updated-dependencies:
- dependency-name: Scriban
  dependency-version: 6.6.0
  dependency-type: direct:production
  dependency-group: nuget
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 08:12:18 +08:00
gewuyou
ba8369c8b3
Merge pull request #115 from GeWuYou/feat/localization-system
feat(core): 添加本地化系统支持多语言功能
2026-03-19 13:21:22 +08:00
GeWuYou
9ca28a44d8 refactor(localization): 优化回退表检查逻辑
- 将 Fallback.ContainsKey 检查替换为更安全的模式匹配语法
- 避免了潜在的空引用异常风险
- 提高了代码的可读性和健壮性
- 保持了原有的功能逻辑不变
2026-03-19 13:15:39 +08:00
GeWuYou
0c5c9dceae test(localization): 添加本地化集成测试的临时文件管理和测试数据创建
- 添加 System.IO 命名空间引用以支持文件操作
- 实现 CreateTestLocalizationFiles 方法创建测试用的多语言文件
- 使用 GUID 生成唯一的临时目录路径避免冲突
- 添加 TearDown 方法清理测试过程中创建的临时文件
- 在 Setup 方法中调用文件创建方法初始化测试环境
- 将目标框架配置改为可配置的条件变量方式
2026-03-19 12:52:11 +08:00
GeWuYou
d7e7d3cc7f feat(events): 为优先级事件系统添加线程安全支持
- 添加同步锁对象以保护处理器集合的并发访问
- 在注册和注销操作中加入线程安全锁机制
- 实现快照创建方法以避免迭代期间的并发修改
- 重构触发器逻辑以使用线程安全的快照创建
- 在计数器方法中添加同步保护
- 确保所有集合操作都在安全锁内执行
2026-03-19 12:51:59 +08:00
GeWuYou
b5c67850ce docs(localization): 更新本地化字符串类的XML文档注释
- 为构造函数添加更详细的参数描述和异常说明
- 为WithVariable方法添加完整的XML文档注释
- 为WithVariables方法添加完整的XML文档注释
- 为Format方法添加详细的返回值和备注信息
- 为GetRaw方法添加完整的XML文档注释
- 为Exists方法添加完整的XML文档注释
- 为私有FormatString方法添加参数和返回值说明
- 为私有FormatMatch方法添加详细的处理逻辑描述
- 为私有TryFormatValue方法添加格式化器相关参数说明
- 为私有FormatValue方法添加默认格式化逻辑说明
- 为私有GetOptionalGroupValue方法添加功能说明
- 为私有GetFormatter方法添加获取格式化器的详细描述
2026-03-19 09:20:43 +08:00
GeWuYou
1f680d2822 refactor(localization): 重构本地化字符串格式化逻辑
- 将 FormatVariablePattern 从 readonly 字段改为 const 常量
- 提取复杂的正则替换逻辑到独立的 FormatMatch 方法中
- 新增 GetOptionalGroupValue 辅助方法处理可选组值提取
- 分离格式化值和尝试格式化的逻辑到独立方法
- 简化条件判断并提高代码可读性
- 优化错误处理流程,保持原有功能不变
2026-03-19 09:19:34 +08:00
GeWuYou
075d397a4c refactor(localization): 更新集成测试中的命名空间引用
- 添加对 GFramework.Core.Abstractions.Localization 的引用
- 确保测试文件能够访问本地化相关的抽象接口
- 为后续的本地化功能扩展做好准备
- 保持代码结构的一致性与可维护性
2026-03-18 23:25:55 +08:00
GeWuYou
5fb96761a3 docs(localization): 更新本地化文档并完善功能实现
- 添加了配置项暂不支持的说明信息
- 扩展了语言变化监听的使用方式,增加命名方法订阅示例
- 完善了覆盖机制的文档说明
- 优化了异常处理逻辑,精确捕获本地化相关异常
- 实现了正则表达式的预编译以提升性能
- 添加了必要的命名空间引用
2026-03-18 23:25:09 +08:00
GeWuYou
e49713a842 feat(core): 添加本地化系统支持多语言功能
- 实现 ILocalizationManager 接口及 LocalizationManager 管理器
- 添加 ILocalizationTable 和 ILocalizationString 接口及其实现
- 创建 LocalizationConfig 配置类用于管理本地化行为
- 实现 ConditionalFormatter 和 PluralFormatter 内置格式化器
- 添加本地化文档包括 API 参考和使用指南
- 集成本地化系统到核心框架架构中
2026-03-18 22:58:07 +08:00
gewuyou
aee13c3c1d
Merge pull request #114 from GeWuYou/docs/add-claude-md-guide
docs(guide): 添加 CLAUDE.md 文件提供项目开发指导
2026-03-18 10:00:28 +08:00
GeWuYou
05c4f06717 docs(guide): 添加 CLAUDE.md 文件提供项目开发指导
- 新增项目概述说明,介绍 GFramework 框架的核心特性
- 添加构建和测试命令指南,包括 dotnet 构建和测试脚本
- 提供模块依赖关系图,说明各模块之间的依赖结构
- 详细描述架构模式,包括四层结构和关键设计模式
- 添加代码约定说明,涵盖命名空间、隐式引用等规范
- 补充测试框架信息,说明测试结构和基类使用方式
- 介绍源码生成器功能,列出四个主要生成器的作用
- 提供文档站点信息,说明 VitePress 文档的本地预览方法
2026-03-18 09:44:06 +08:00
gewuyou
2accbf4bdf
Merge pull request #113 from GeWuYou/refactor/router-base-ci-concurrency
refactor(game): 重构路由系统并优化CI测试流程
2026-03-17 16:45:05 +08:00
GeWuYou
60068aff4f refactor(ci): 重构工作流配置以分离代码质量和构建测试任务
- 将原有的 test job 重命名为 code-quality,专注于代码质量与安全检查
- 添加构建和测试独立的 build-and-test job,实现并行执行
- 更新 MegaLinter 配置,优化缓存和报告上传流程
- 重新组织 CI 工作流结构,提升执行效率和可维护性
- 调整作业名称和描述,明确职责分工
2026-03-17 16:34:09 +08:00
GeWuYou
9c69c4ec00 refactor(ci): 优化CI工作流中的测试执行策略
- 将多个独立的测试步骤合并为单个并发执行步骤
- 移除重复的测试配置以简化工作流定义
- 保留后台测试等待机制确保执行完整性
- 统一测试项目名称提高可读性
2026-03-17 16:20:55 +08:00
GeWuYou
65b949b62f perf(scene): 优化路由守卫异步方法性能
- 将 ISceneRouteGuard 中的 Task 返回类型改为 ValueTask
- 将 IUiRouteGuard 中的 Task 返回类型改为 ValueTask
- 移除注释中的多余缩进空格
- 提升异步操作的性能表现
2026-03-17 16:10:24 +08:00
GeWuYou
4afa856fdc refactor(game): 重构路由系统并优化CI测试流程
- 将SceneRouterBase和UiRouterBase继承自新的RouterBase基类
- 移除原有的守卫管理相关代码,统一使用基类实现
- 更新路由栈操作使用基类提供的Stack属性
- 重写Current、Contains等方法以使用基类实现
- 在CI工作流中启用并发测试执行以提升性能
- 添加等待步骤确保并发测试完成
- 更新项目文件排除测试项目的编译
- 在解决方案文件中添加GFramework.Game.Tests项目引用
- 新增RouterBase基类提供通用路由管理功能
2026-03-17 15:01:55 +08:00
gewuyou
27858df94e
Merge pull request #112 from GeWuYou/refactor/architecture-modular-safety
refactor(architecture): 重构架构类为模块化设计提升代码安全性
2026-03-17 12:56:34 +08:00
GeWuYou
1c2e68fc5a style(docs): 统一文档中IoC容器术语格式并优化架构生命周期代码
- 将文档中的"IOC"统一更正为"IoC"格式
- 重构ArchitectureLifecycle类构造函数使用主构造函数语法
- 移除私有字段前缀下划线,直接使用参数名称
- 修复配置访问权限问题,移除字段访问前缀下划线
- 调整组件注册方法泛型约束,提升类型安全
- 优化日志记录器访问方式,移除字段访问前缀下划线
- 重构销毁逻辑,分离组件清理和服务模块销毁流程
- 提取清理组件逻辑到独立方法,提升代码可读性
- 更新阶段转换和钩子通知中的对象引用方式
2026-03-17 12:50:43 +08:00
GeWuYou
3d8e19b5e2 refactor(architecture): 重构架构类为模块化设计提升代码安全性
- 将单一 Architecture 类拆分为 ArchitectureLifecycle、ArchitectureComponentRegistry 和 ArchitectureModules
- 消除 3 处 null! 强制断言,在构造函数中初始化管理器提高代码安全性
- 添加 PhaseChanged 事件支持架构阶段监听
- 所有公共 API 保持不变确保向后兼容
- 实现单一职责原则使代码更易维护和测试
- 组件注册、生命周期管理和模块管理职责分离到专门的管理器中
2026-03-17 11:08:48 +08:00
138 changed files with 12625 additions and 2134 deletions

View File

@ -0,0 +1,62 @@
schema_version: 1
generated_at_utc: "2026-03-21T04:47:58Z"
generated_from: ".ai/environment/tools.raw.yaml"
generator: "scripts/generate-ai-environment.py"
platform:
family: "wsl-linux"
os: "Linux"
distro: "Ubuntu 24.04.4 LTS"
shell: "bash"
capabilities:
dotnet: true
python: true
node: true
bun: true
docker: true
fast_search: true
json_cli: true
tool_selection:
search:
preferred: "rg"
fallback: "grep"
use_for: "Repository text search."
json:
preferred: "jq"
fallback: "python3"
use_for: "Inspecting or transforming JSON command output."
shell:
preferred: "bash"
fallback: "sh"
use_for: "Repository shell scripts and command execution."
scripting:
preferred: "python3"
fallback: "bash"
use_for: "Non-trivial local automation and helper scripts."
docs_package_manager:
preferred: "bun"
fallback: "npm"
use_for: "Installing and previewing the docs site."
build_and_test:
preferred: "dotnet"
fallback: "unavailable"
use_for: "Build, test, restore, and solution validation."
python:
available: true
helper_packages:
requests: true
rich: true
openai: false
tiktoken: false
pydantic: false
pytest: false
preferences:
prefer_project_listed_tools: true
prefer_python_for_non_trivial_automation: true
avoid_unlisted_system_tools: true
rules:
- "Use rg instead of grep for repository search when rg is available."
- "Use jq for JSON inspection; fall back to python3 if jq is unavailable."
- "Prefer python3 over complex bash for non-trivial scripting when python3 is available."
- "Use bun for docs preview workflows when bun is available; otherwise fall back to npm."
- "Use dotnet for repository build and test workflows."
- "Do not assume unrelated system tools are part of the supported project environment."

View File

@ -0,0 +1,89 @@
schema_version: 1
generated_at_utc: "2026-03-21T04:47:28Z"
generator: "scripts/collect-dev-environment.sh"
platform:
os: "Linux"
distro: "Ubuntu 24.04.4 LTS"
version: "24.04"
kernel: "5.15.167.4-microsoft-standard-WSL2"
wsl: true
wsl_version: "2.4.13"
shell: "bash"
required_runtimes:
dotnet:
installed: true
version: "10.0.104"
path: "/usr/bin/dotnet"
purpose: "Builds and tests the GFramework solution."
python3:
installed: true
version: "Python 3.12.3"
path: "/usr/bin/python3"
purpose: "Runs local automation and environment collection scripts."
node:
installed: true
version: "v20.20.1"
path: "/usr/bin/node"
purpose: "Provides the JavaScript runtime used by docs tooling."
bun:
installed: true
version: "1.3.10"
path: "/root/.bun/bin/bun"
purpose: "Installs and previews the VitePress documentation site."
required_tools:
git:
installed: true
version: "git version 2.43.0"
path: "/usr/bin/git"
purpose: "Source control and patch review."
bash:
installed: true
version: "GNU bash, version 5.2.21(1)-release (x86_64-pc-linux-gnu)"
path: "/usr/bin/bash"
purpose: "Executes repository scripts and shell automation."
rg:
installed: true
version: "ripgrep 15.1.0 (rev af60c2de9d)"
path: "/root/.bun/install/global/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/path/rg"
purpose: "Fast text search across the repository."
jq:
installed: true
version: "jq-1.7"
path: "/usr/bin/jq"
purpose: "Inspecting and transforming JSON outputs."
project_tools:
docker:
installed: true
version: "Docker version 29.2.1, build a5c7197"
path: "/usr/bin/docker"
purpose: "Runs MegaLinter and other containerized validation tools."
python_packages:
requests:
installed: true
version: "2.31.0"
purpose: "Simple HTTP calls in local helper scripts."
rich:
installed: true
version: "13.7.1"
purpose: "Readable CLI output for local Python helpers."
openai:
installed: false
version: "not-installed"
purpose: "Optional scripted access to OpenAI APIs."
tiktoken:
installed: false
version: "not-installed"
purpose: "Optional token counting for prompt and context inspection."
pydantic:
installed: false
version: "not-installed"
purpose: "Optional typed config and schema validation for helper scripts."
pytest:
installed: false
version: "not-installed"
purpose: "Optional lightweight testing for Python helper scripts."

View File

@ -13,8 +13,9 @@ permissions:
security-events: write security-events: write
jobs: jobs:
test: # 代码质量检查 job并行执行不阻塞构建
name: Build and Test code-quality:
name: Code Quality & Security
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -23,9 +24,11 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
# 校验C#命名空间与源码目录是否符合命名规范 # 校验C#命名空间与源码目录是否符合命名规范
- name: Validate C# naming - name: Validate C# naming
run: bash scripts/validate-csharp-naming.sh run: bash scripts/validate-csharp-naming.sh
# 缓存MegaLinter # 缓存MegaLinter
- name: Cache MegaLinter - name: Cache MegaLinter
uses: actions/cache@v5 uses: actions/cache@v5
@ -35,7 +38,6 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-megalinter- ${{ runner.os }}-megalinter-
# MegaLinter扫描步骤 # MegaLinter扫描步骤
# 执行代码质量检查和安全扫描生成SARIF格式报告 # 执行代码质量检查和安全扫描生成SARIF格式报告
- name: MegaLinter - name: MegaLinter
@ -44,11 +46,13 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FAIL_ON_ERROR: ${{ github.ref == 'refs/heads/main' }} FAIL_ON_ERROR: ${{ github.ref == 'refs/heads/main' }}
# 上传SARIF格式的安全和代码质量问题报告到GitHub安全中心 # 上传SARIF格式的安全和代码质量问题报告到GitHub安全中心
- name: Upload SARIF - name: Upload SARIF
uses: github/codeql-action/upload-sarif@v4 uses: github/codeql-action/upload-sarif@v4
with: with:
sarif_file: megalinter-reports/sarif sarif_file: megalinter-reports/sarif
# 缓存TruffleHog # 缓存TruffleHog
- name: Cache TruffleHog - name: Cache TruffleHog
uses: actions/cache@v5 uses: actions/cache@v5
@ -69,6 +73,18 @@ jobs:
# 当前提交哈希,作为扫描的目标版本 # 当前提交哈希,作为扫描的目标版本
head: ${{ github.sha }} head: ${{ github.sha }}
# 构建和测试 job并行执行
build-and-test:
name: Build and Test
runs-on: ubuntu-latest
steps:
# 检出源代码
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
# 安装和配置.NET SDK版本 # 安装和配置.NET SDK版本
- name: Setup .NET 8 - name: Setup .NET 8
uses: actions/setup-dotnet@v5 uses: actions/setup-dotnet@v5
@ -113,29 +129,35 @@ jobs:
run: dotnet build -c Release --no-restore run: dotnet build -c Release --no-restore
# 运行单元测试输出TRX格式结果到TestResults目录 # 运行单元测试输出TRX格式结果到TestResults目录
- name: Test - Core # 在同一个 step 中并发执行所有测试以加快速度
- name: Test All Projects
run: | run: |
dotnet test GFramework.Core.Tests \ dotnet test GFramework.Core.Tests \
-c Release \ -c Release \
--no-build \ --no-build \
--logger "trx;LogFileName=core-$RANDOM.trx" \ --logger "trx;LogFileName=core-$RANDOM.trx" \
--results-directory TestResults --results-directory TestResults &
dotnet test GFramework.Game.Tests \
-c Release \
--no-build \
--logger "trx;LogFileName=game-$RANDOM.trx" \
--results-directory TestResults &
- name: Test - SourceGenerators
run: |
dotnet test GFramework.SourceGenerators.Tests \ dotnet test GFramework.SourceGenerators.Tests \
-c Release \ -c Release \
--no-build \ --no-build \
--logger "trx;LogFileName=sg-$RANDOM.trx" \ --logger "trx;LogFileName=sg-$RANDOM.trx" \
--results-directory TestResults --results-directory TestResults &
- name: Test - GFramework.Ecs.Arch.Tests
run: |
dotnet test GFramework.Ecs.Arch.Tests \ dotnet test GFramework.Ecs.Arch.Tests \
-c Release \ -c Release \
--no-build \ --no-build \
--logger "trx;LogFileName=ecs-arch-$RANDOM.trx" \ --logger "trx;LogFileName=ecs-arch-$RANDOM.trx" \
--results-directory TestResults --results-directory TestResults &
# 等待所有后台测试完成
wait
- name: Generate CTRF report - name: Generate CTRF report
run: | run: |

2
.gitignore vendored
View File

@ -13,3 +13,5 @@ opencode.json
docs/.omc/ docs/.omc/
docs/.vitepress/cache/ docs/.vitepress/cache/
local-plan/ local-plan/
# tool
.venv/

220
AGENTS.md Normal file
View File

@ -0,0 +1,220 @@
# AGENTS.md
This document is the single source of truth for coding behavior in this repository.
All AI agents and contributors must follow these rules when writing, reviewing, or modifying code in `GFramework`.
## Environment Capability Inventory
- Before choosing runtimes or CLI tools, read `@.ai/environment/tools.ai.yaml`.
- Use `@.ai/environment/tools.raw.yaml` only when you need the full collected facts behind the AI-facing hints.
- Prefer the project-relevant tools listed there instead of assuming every installed system tool is fair game.
- If the real environment differs from the inventory, use the project-relevant installed tool and report the mismatch.
## Commenting Rules (MUST)
All generated or modified code MUST include clear and meaningful comments where required by the rules below.
### XML Documentation (Required)
- All public, protected, and internal types and members MUST include XML documentation comments (`///`).
- Use `<summary>`, `<param>`, `<returns>`, `<exception>`, and `<remarks>` where applicable.
- Comments must explain intent, contract, and usage constraints instead of restating syntax.
- If a member participates in lifecycle, threading, registration, or disposal behavior, document that behavior
explicitly.
### Inline Comments
- Add inline comments for:
- Non-trivial logic
- Concurrency or threading behavior
- Performance-sensitive paths
- Workarounds, compatibility constraints, or edge cases
- Registration order, lifecycle sequencing, or generated code assumptions
- Avoid obvious comments such as `// increment i`.
### Architecture-Level Comments
- Core framework components such as Architecture, Module, System, Context, Registry, Service Module, and Lifecycle types
MUST include high-level explanations of:
- Responsibilities
- Lifecycle
- Interaction with other components
- Why the abstraction exists
- When to use it instead of alternatives
### Source Generator Comments
- Generated logic and generator pipelines MUST explain:
- What is generated
- Why it is generated
- The semantic assumptions the generator relies on
- Any diagnostics or fallback behavior
### Complex Logic Requirement
- Methods with non-trivial logic MUST document:
- The core idea
- Key decisions
- Edge case handling, if any
### Quality Rules
- Comments MUST NOT be trivial, redundant, or misleading.
- Prefer explaining `why` and `when`, not just `what`.
- Code should remain understandable without requiring external context.
- Prefer slightly more explanation over too little for framework code.
### Enforcement
- Missing required documentation is a coding standards violation.
- Code that does not meet the documentation rules is considered incomplete.
## Code Style
### Language and Project Settings
- Follow the repository defaults:
- `ImplicitUsings` disabled
- `Nullable` enabled
- `GenerateDocumentationFile` enabled for shipped libraries
- `LangVersion` is generally `preview` in the main libraries and abstractions
- Do not rely on implicit imports. Declare every required `using` explicitly.
- Write null-safe code that respects nullable annotations instead of suppressing warnings by default.
### Naming and Structure
- Use the namespace pattern `GFramework.{Module}.{Feature}` with PascalCase segments.
- Follow standard C# naming:
- Types, methods, properties, events, and constants: PascalCase
- Interfaces: `I` prefix
- Parameters and locals: camelCase
- Private fields: `_camelCase`
- Keep abstractions projects free of implementation details and engine-specific dependencies.
- Preserve existing module boundaries. Do not introduce new cross-module dependencies without clear architectural need.
### Formatting
- Use 4 spaces for indentation. Do not use tabs.
- Use Allman braces.
- Keep `using` directives at the top of the file and sort them consistently.
- Separate logical blocks with blank lines when it improves readability.
- Prefer one primary type per file unless the surrounding project already uses a different local pattern.
- Keep line length readable. Around 120 characters is the preferred upper bound.
### C# Conventions
- Prefer explicit, readable code over clever shorthand in framework internals.
- Match existing async patterns and naming conventions (`Async` suffix for asynchronous methods).
- Avoid hidden side effects in property getters, constructors, and registration helpers.
- Preserve deterministic behavior in registries, lifecycle orchestration, and generated outputs.
- When adding analyzers or suppressions, keep them minimal and justify them in code comments if the reason is not
obvious.
### Analyzer and Validation Expectations
- The repository uses `Meziantou.Analyzer`; treat analyzer feedback as part of the coding standard.
- Naming must remain compatible with `scripts/validate-csharp-naming.sh`.
## Testing Requirements
### Required Coverage
- Every non-trivial feature, bug fix, or behavior change MUST include tests or an explicit justification for why a test
is not practical.
- Public API changes must be covered by unit or integration tests.
- Regression fixes should include a test that fails before the fix and passes after it.
### Test Organization
- Mirror the source structure in test projects whenever practical.
- Reuse existing architecture test infrastructure when relevant:
- `ArchitectureTestsBase<T>`
- `SyncTestArchitecture`
- `AsyncTestArchitecture`
- Keep tests focused on observable behavior, not implementation trivia.
### Source Generator Tests
- Source generator changes MUST be covered by generator tests.
- Preserve snapshot-based verification patterns already used in the repository.
- When generator behavior changes intentionally, update snapshots together with the implementation.
### Validation Commands
Use the smallest command set that proves the change, then expand if the change is cross-cutting.
```bash
# Build the full solution
dotnet build GFramework.sln -c Release
# Run all tests
dotnet test GFramework.sln -c Release
# Run a single test project
dotnet test GFramework.Core.Tests -c Release
dotnet test GFramework.Game.Tests -c Release
dotnet test GFramework.SourceGenerators.Tests -c Release
dotnet test GFramework.Ecs.Arch.Tests -c Release
# Run a single NUnit test or test group
dotnet test GFramework.Core.Tests -c Release --filter "FullyQualifiedName~CommandExecutorTests.Execute"
# Validate naming rules used by CI
bash scripts/validate-csharp-naming.sh
```
### Test Execution Expectations
- Run targeted tests for the code you changed whenever possible.
- Run broader solution-level validation for changes that touch shared abstractions, lifecycle behavior, source
generators, or dependency wiring.
- Do not claim completion if required tests were skipped; state what was not run and why.
## Security Rules
- Validate external or user-controlled input before it reaches file system, serialization, reflection, code generation,
or process boundaries.
- Do not build command strings, file paths, type names, or generated code from untrusted input without strict validation
or allow-listing.
- Avoid logging secrets, tokens, credentials, or machine-specific sensitive data.
- Keep source generators deterministic and free of hidden environment or network dependencies.
- Prefer least-privilege behavior for file, process, and environment access.
- Do not introduce unsafe deserialization, broad reflection-based activation, or dynamic code execution unless it is
explicitly required and tightly constrained.
- When adding caching, pooling, or shared mutable state, document thread-safety assumptions and failure modes.
- Minimize new package dependencies. Add them only when necessary and keep scope narrow.
## Documentation Rules
### Code Documentation
- Any change to public API, lifecycle semantics, module behavior, or extension points MUST update the related XML docs.
- If a framework abstraction changes meaning or intended usage, update the explanatory comments in code as part of the
same change.
### Repository Documentation
- Update the relevant `README.md` or `docs/` page when behavior, setup steps, architecture guidance, or user-facing
examples change.
- The main documentation site lives under `docs/`, with Chinese content under `docs/zh-CN/`.
- Keep code samples, package names, and command examples aligned with the current repository state.
- Prefer documenting behavior and design intent, not only API surface.
### Documentation Preview
When documentation changes need local preview, use:
```bash
cd docs && bun install && bun run dev
```
## Review Standard
Before considering work complete, confirm:
- Required comments and XML docs are present
- Code follows repository style and naming rules
- Relevant tests were added or updated
- Sensitive or unsafe behavior was not introduced
- User-facing documentation is updated when needed

134
CLAUDE.md Normal file
View File

@ -0,0 +1,134 @@
# CLAUDE.md
This file provides project understanding for AI agents working in this repository.
## Project Overview
GFramework 是面向游戏开发的模块化 C# 框架,核心能力与引擎解耦。项目灵感参考 QFramework并在模块边界、工程组织和可扩展性方面持续重构。
## AI Agent Instructions
All coding rules are defined in:
@AGENTS.md
Follow them strictly.
## Module Dependency Graph
```text
GFramework (meta package) ─→ Core + Game
GFramework.Core ─→ Core.Abstractions
GFramework.Game ─→ Game.Abstractions, Core, Core.Abstractions
GFramework.Godot ─→ Core, Game, Core.Abstractions, Game.Abstractions
GFramework.Ecs.Arch ─→ Ecs.Arch.Abstractions, Core, Core.Abstractions
GFramework.SourceGenerators ─→ SourceGenerators.Common, SourceGenerators.Abstractions
```
- **Abstractions projects** (`netstandard2.1`): 只包含接口和契约定义,不承载运行时实现逻辑。
- **Core / Game / Ecs.Arch** (`net8.0;net9.0;net10.0`): 平台无关的核心实现层。
- **Godot**: Godot 引擎集成层,负责与节点、场景和引擎生命周期对接。
- **SourceGenerators** (`netstandard2.1`): Roslyn 增量源码生成器及其公共基础设施。
## Architecture Pattern
框架核心采用 `Architecture / Model / System / Utility` 四层结构:
- **IArchitecture**: 顶层容器,负责生命周期管理、组件注册、模块安装和统一服务访问。
- **IContextAware**: 统一上下文访问接口,组件通过 `SetContext(IArchitectureContext)` 获取架构上下文。
- **IModel**: 数据与状态层,负责长期状态和业务数据建模。
- **ISystem**: 业务逻辑层,负责命令执行、流程编排和规则落地。
- **IUtility**: 通用无状态工具层,供其他层复用。
关键实现位于 `GFramework.Core/Architectures/Architecture.cs`,其职责是作为总协调器串联生命周期、组件注册和模块系统。
## Architecture Details
### Lifecycle
Architecture 负责统一生命周期编排,核心阶段包括:
- `Init`
- `Ready`
- `Destroy`
在实现层中,生命周期被拆分为更细粒度的初始化与销毁阶段,用于保证 Utility、Model、System、服务模块和钩子的顺序一致性。
### Component Coordination
框架通过独立组件协作完成架构编排:
- `ArchitectureLifecycle`: 管理生命周期阶段、阶段转换和生命周期钩子。
- `ArchitectureComponentRegistry`: 管理 Model、System、Utility 的注册与解析。
- `ArchitectureModules`: 管理模块安装、服务模块接入和扩展点注册。
这组拆分的目标是降低单个核心类的职责密度,同时保持对外 API 稳定。
### Context Propagation
`IArchitectureContext` 和相关 Provider 类型负责在组件之间传播上下文能力,使 Model、System
和外部扩展都能通过统一入口访问架构服务,而不直接耦合具体实现细节。
## Key Patterns
### CQRS
命令与查询分离支持同步与异步执行。Mediator 模式通过源码生成器集成,以减少模板代码并保持调用路径清晰。
### EventBus
类型安全事件总线支持事件发布、订阅、优先级、过滤器和弱引用订阅。它是模块之间松耦合通信的核心基础设施之一。
### BindableProperty
响应式属性模型通过值变化通知驱动界面或业务层更新,适合表达轻量级状态同步。
### Coroutine
帧驱动协程系统基于 `IYieldInstruction` 和调度器抽象,支持等待时间、事件和任务完成等常见模式。
### IoC
依赖注入通过 `MicrosoftDiContainer``Microsoft.Extensions.DependencyInjection` 进行封装,用于统一组件注册和服务解析体验。
### Service Modules
`IServiceModule` 模式用于向 Architecture 注册内置服务,例如 EventBus、CommandExecutor、QueryExecutor 等。这一模式承担“基础设施能力装配”的职责。
## Source Generators
当前仓库包含多类 Roslyn 增量源码生成器:
- `LoggerGenerator` (`[Log]`): 自动生成日志字段和日志辅助方法。
- `PriorityGenerator` (`[Priority]`): 生成优先级比较相关实现。
- `EnumExtensionsGenerator` (`[GenerateEnumExtensions]`): 生成枚举扩展能力。
- `ContextAwareGenerator` (`[ContextAware]`): 自动实现 `IContextAware` 相关样板逻辑。
这些生成器的目标是减少重复代码,同时保持框架层 API 的一致性与可维护性。
## Module Structure
仓库以“抽象层 + 实现层 + 集成层 + 生成器层”的方式组织:
- `GFramework.Core.Abstractions` / `GFramework.Game.Abstractions`: 约束接口和公共契约。
- `GFramework.Core` / `GFramework.Game`: 提供平台无关实现。
- `GFramework.Godot`: 提供与 Godot 运行时集成的适配实现。
- `GFramework.Ecs.Arch`: 提供 ECS Architecture 相关扩展。
- `GFramework.SourceGenerators` 及相关 Abstractions/Common: 提供代码生成能力。
这种结构的核心设计目标是让抽象稳定、实现可替换、引擎集成隔离、生成器能力可独立演进。
## Documentation Structure
项目文档位于 `docs/`,中文内容位于 `docs/zh-CN/`。文档内容覆盖:
- 入门与安装
- Core / Game / Godot / ECS 各模块能力
- Source Generator 使用说明
- 教程、最佳实践与故障排查
阅读顺序通常建议先看根目录 `README.md` 和各子模块 `README.md`,再进入 `docs/` 查阅专题说明。
## Design Intent
GFramework 的设计重点不是把所有能力堆进单一核心类,而是通过清晰的模块边界、可组合的服务注册方式、稳定的抽象契约以及适度自动化的源码生成,构建一个适合长期演进的游戏开发基础框架。

View File

@ -0,0 +1,43 @@
// Copyright (c) 2025 GeWuYou
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
namespace GFramework.Core.Abstractions.Concurrency;
/// <summary>
/// 锁信息(用于调试)
/// </summary>
public readonly struct LockInfo
{
/// <summary>
/// 锁的键。
/// </summary>
public string Key { get; init; }
/// <summary>
/// 当前引用计数。
/// </summary>
public int ReferenceCount { get; init; }
/// <summary>
/// 最后访问时间戳Environment.TickCount64
/// </summary>
public long LastAccessTicks { get; init; }
/// <summary>
/// 等待队列长度(近似值)。
/// 注意:这是一个基于 SemaphoreSlim.CurrentCount 的近似指示器,
/// 当 CurrentCount == 0 时表示锁被持有且可能有等待者,返回 1
/// 否则返回 0。这不是精确的等待者数量仅用于调试参考。
/// </summary>
public int WaitingCount { get; init; }
}

View File

@ -11,11 +11,14 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
using System.Runtime.InteropServices;
namespace GFramework.Core.Abstractions.Concurrency; namespace GFramework.Core.Abstractions.Concurrency;
/// <summary> /// <summary>
/// 锁统计信息 /// 锁统计信息
/// </summary> /// </summary>
[StructLayout(LayoutKind.Auto)]
public readonly struct LockStatistics public readonly struct LockStatistics
{ {
/// <summary> /// <summary>
@ -38,32 +41,3 @@ public readonly struct LockStatistics
/// </summary> /// </summary>
public int TotalCleaned { get; init; } public int TotalCleaned { get; init; }
} }
/// <summary>
/// 锁信息(用于调试)
/// </summary>
public readonly struct LockInfo
{
/// <summary>
/// 锁的键
/// </summary>
public string Key { get; init; }
/// <summary>
/// 当前引用计数
/// </summary>
public int ReferenceCount { get; init; }
/// <summary>
/// 最后访问时间戳Environment.TickCount64
/// </summary>
public long LastAccessTicks { get; init; }
/// <summary>
/// 等待队列长度(近似值)
/// 注意:这是一个基于 SemaphoreSlim.CurrentCount 的近似指示器,
/// 当 CurrentCount == 0 时表示锁被持有且可能有等待者,返回 1
/// 否则返回 0。这不是精确的等待者数量仅用于调试参考。
/// </summary>
public int WaitingCount { get; init; }
}

View File

@ -17,7 +17,7 @@
<Using Include="GFramework.Core.Abstractions"/> <Using Include="GFramework.Core.Abstractions"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Update="Meziantou.Analyzer" Version="3.0.19"> <PackageReference Update="Meziantou.Analyzer" Version="3.0.25">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference> </PackageReference>

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

@ -75,7 +75,9 @@ public interface IPauseStackManager : IContextUtility
void UnregisterHandler(IPauseHandler handler); void UnregisterHandler(IPauseHandler handler);
/// <summary> /// <summary>
/// 暂停状态变化事件 /// 暂停状态变化事件。
/// 事件遵循标准 .NET 事件模式,事件源为触发通知的暂停管理器实例,
/// 事件数据由 <see cref="PauseStateChangedEventArgs"/> 提供。
/// </summary> /// </summary>
event Action<PauseGroup, bool>? OnPauseStateChanged; event EventHandler<PauseStateChangedEventArgs>? OnPauseStateChanged;
} }

View File

@ -0,0 +1,30 @@
namespace GFramework.Core.Abstractions.Pause;
/// <summary>
/// 表示暂停状态变化事件的数据。
/// 该类型用于向事件订阅者传递暂停组以及该组变化后的暂停状态。
/// </summary>
public sealed class PauseStateChangedEventArgs : EventArgs
{
/// <summary>
/// 初始化 <see cref="PauseStateChangedEventArgs"/> 的新实例。
/// </summary>
/// <param name="group">发生状态变化的暂停组。</param>
/// <param name="isPaused">暂停组变化后的新状态。</param>
public PauseStateChangedEventArgs(PauseGroup group, bool isPaused)
{
Group = group;
IsPaused = isPaused;
}
/// <summary>
/// 获取发生状态变化的暂停组。
/// </summary>
public PauseGroup Group { get; }
/// <summary>
/// 获取暂停组变化后的新状态。
/// 为 <see langword="true"/> 表示进入暂停,为 <see langword="false"/> 表示恢复运行。
/// </summary>
public bool IsPaused { get; }
}

View File

@ -12,6 +12,7 @@ GFramework 框架的抽象层定义模块,包含所有核心组件的接口定
- 事件系统接口 (IEvent, IEventBus) - 事件系统接口 (IEvent, IEventBus)
- 依赖注入容器接口 (IIocContainer) - 依赖注入容器接口 (IIocContainer)
- 可绑定属性接口 (IBindableProperty) - 可绑定属性接口 (IBindableProperty)
- 状态管理接口 (IStore, IReducer, IStateSelector, IStoreBuilder)
- 日志系统接口 (ILogger) - 日志系统接口 (ILogger)
## 设计原则 ## 设计原则

View File

@ -0,0 +1,40 @@
using GFramework.Core.Abstractions.Events;
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 只读状态容器接口,用于暴露应用状态快照和订阅能力。
/// 该抽象适用于 Controller、Query、ViewModel 等只需要观察状态的调用方,
/// 使其无需依赖写入能力即可响应复杂状态树的变化。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public interface IReadonlyStore<out TState>
{
/// <summary>
/// 获取当前状态快照。
/// Store 负责保证返回值与最近一次成功分发后的状态一致。
/// </summary>
TState State { get; }
/// <summary>
/// 订阅状态变化通知。
/// 仅当 Store 判断状态发生有效变化时,才会调用该监听器。
/// </summary>
/// <param name="listener">状态变化时的监听器,参数为新的状态快照。</param>
/// <returns>用于取消订阅的句柄。</returns>
IUnRegister Subscribe(Action<TState> listener);
/// <summary>
/// 订阅状态变化通知,并立即以当前状态调用一次监听器。
/// 该方法适合在 UI 初始化或 ViewModel 首次绑定时建立同步视图。
/// </summary>
/// <param name="listener">状态变化时的监听器,参数为新的状态快照。</param>
/// <returns>用于取消订阅的句柄。</returns>
IUnRegister SubscribeWithInitValue(Action<TState> listener);
/// <summary>
/// 取消订阅指定的状态监听器。
/// </summary>
/// <param name="listener">需要移除的监听器。</param>
void UnSubscribe(Action<TState> listener);
}

View File

@ -0,0 +1,19 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 定义状态归约器接口。
/// Reducer 应保持纯函数风格:根据当前状态和 action 计算下一状态,
/// 不直接产生副作用,也不依赖外部可变环境。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
/// <typeparam name="TAction">当前 reducer 处理的 action 类型。</typeparam>
public interface IReducer<TState, in TAction>
{
/// <summary>
/// 根据当前状态和 action 计算下一状态。
/// </summary>
/// <param name="currentState">当前状态快照。</param>
/// <param name="action">触发本次归约的 action。</param>
/// <returns>归约后的下一状态。</returns>
TState Reduce(TState currentState, TAction action);
}

View File

@ -0,0 +1,17 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 定义状态选择器接口,用于从整棵状态树中投影出局部状态视图。
/// 该抽象适用于复用复杂选择逻辑,避免在 UI 或 Controller 中重复编写投影代码。
/// </summary>
/// <typeparam name="TState">源状态类型。</typeparam>
/// <typeparam name="TSelected">投影后的局部状态类型。</typeparam>
public interface IStateSelector<in TState, out TSelected>
{
/// <summary>
/// 从给定状态中选择目标片段。
/// </summary>
/// <param name="state">当前完整状态。</param>
/// <returns>投影后的局部状态。</returns>
TSelected Select(TState state);
}

View File

@ -0,0 +1,63 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 可写状态容器接口,提供统一的状态分发入口。
/// 所有状态变更都应通过分发 action 触发,以保持单向数据流和可测试性。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public interface IStore<out TState> : IReadonlyStore<TState>
{
/// <summary>
/// 获取当前是否可以撤销到更早的历史状态。
/// 当未启用历史缓冲区,或当前已经位于最早历史点时,返回 <see langword="false"/>。
/// </summary>
bool CanUndo { get; }
/// <summary>
/// 获取当前是否可以重做到更晚的历史状态。
/// 当未启用历史缓冲区,或当前已经位于最新历史点时,返回 <see langword="false"/>。
/// </summary>
bool CanRedo { get; }
/// <summary>
/// 分发一个 action 以触发状态演进。
/// Store 会按注册顺序执行与该 action 类型匹配的 reducer并在状态变化后通知订阅者。
/// </summary>
/// <typeparam name="TAction">action 的具体类型。</typeparam>
/// <param name="action">要分发的 action 实例。</param>
void Dispatch<TAction>(TAction action);
/// <summary>
/// 将多个状态操作合并到一个批处理中执行。
/// 批处理内部的每次分发仍会立即更新 Store 状态和历史,但订阅通知会延迟到最外层批处理结束后再统一触发一次。
/// </summary>
/// <param name="batchAction">批处理主体;调用方应在其中执行若干次 <see cref="Dispatch{TAction}(TAction)"/>、<see cref="Undo"/> 或 <see cref="Redo"/>。</param>
void RunInBatch(Action batchAction);
/// <summary>
/// 将当前状态回退到上一个历史点。
/// </summary>
/// <exception cref="InvalidOperationException">当历史缓冲区未启用,或当前已经没有可撤销的历史点时抛出。</exception>
void Undo();
/// <summary>
/// 将当前状态前进到下一个历史点。
/// </summary>
/// <exception cref="InvalidOperationException">当历史缓冲区未启用,或当前已经没有可重做的历史点时抛出。</exception>
void Redo();
/// <summary>
/// 跳转到指定索引的历史点。
/// 该能力适合调试面板或开发工具实现时间旅行查看。
/// </summary>
/// <param name="historyIndex">目标历史索引,从 0 开始。</param>
/// <exception cref="InvalidOperationException">当历史缓冲区未启用时抛出。</exception>
/// <exception cref="ArgumentOutOfRangeException">当 <paramref name="historyIndex"/> 超出当前历史范围时抛出。</exception>
void TimeTravelTo(int historyIndex);
/// <summary>
/// 清空当前撤销/重做历史,并以当前状态作为新的历史锚点。
/// 该操作不会修改当前状态,也不会触发额外通知。
/// </summary>
void ClearHistory();
}

View File

@ -0,0 +1,62 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 定义 Store 构建器接口,用于在创建 Store 之前完成 reducer、中间件和比较器配置。
/// 该抽象适用于模块化注册、依赖注入装配和测试工厂,避免调用方必须依赖具体 Store 类型进行配置。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public interface IStoreBuilder<TState>
{
/// <summary>
/// 配置用于判断状态是否真正变化的比较器。
/// </summary>
/// <param name="comparer">状态比较器。</param>
/// <returns>当前构建器实例。</returns>
IStoreBuilder<TState> WithComparer(IEqualityComparer<TState> comparer);
/// <summary>
/// 配置历史缓冲区容量。
/// 传入 0 表示禁用历史记录;大于 0 时会保留最近若干个状态快照,用于撤销、重做和时间旅行调试。
/// </summary>
/// <param name="historyCapacity">历史缓冲区容量。</param>
/// <returns>当前构建器实例。</returns>
IStoreBuilder<TState> WithHistoryCapacity(int historyCapacity);
/// <summary>
/// 配置 reducer 的 action 匹配策略。
/// 默认使用 <see cref="StoreActionMatchingMode.ExactTypeOnly"/>,仅在需要复用基类或接口 action 层次时再启用多态匹配。
/// </summary>
/// <param name="actionMatchingMode">要使用的匹配策略。</param>
/// <returns>当前构建器实例。</returns>
IStoreBuilder<TState> WithActionMatching(StoreActionMatchingMode actionMatchingMode);
/// <summary>
/// 添加一个强类型 reducer。
/// </summary>
/// <typeparam name="TAction">当前 reducer 处理的 action 类型。</typeparam>
/// <param name="reducer">要添加的 reducer。</param>
/// <returns>当前构建器实例。</returns>
IStoreBuilder<TState> AddReducer<TAction>(IReducer<TState, TAction> reducer);
/// <summary>
/// 使用委托快速添加一个 reducer。
/// </summary>
/// <typeparam name="TAction">当前 reducer 处理的 action 类型。</typeparam>
/// <param name="reducer">执行归约的委托。</param>
/// <returns>当前构建器实例。</returns>
IStoreBuilder<TState> AddReducer<TAction>(Func<TState, TAction, TState> reducer);
/// <summary>
/// 添加一个 Store 中间件。
/// </summary>
/// <param name="middleware">要添加的中间件。</param>
/// <returns>当前构建器实例。</returns>
IStoreBuilder<TState> UseMiddleware(IStoreMiddleware<TState> middleware);
/// <summary>
/// 基于给定初始状态创建一个新的 Store。
/// </summary>
/// <param name="initialState">Store 的初始状态。</param>
/// <returns>已应用当前构建器配置的 Store 实例。</returns>
IStore<TState> Build(TState initialState);
}

View File

@ -0,0 +1,67 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 暴露 Store 的诊断信息。
/// 该接口用于调试、监控和后续时间旅行能力的扩展,不参与状态写入流程。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public interface IStoreDiagnostics<TState>
{
/// <summary>
/// 获取当前已注册的订阅者数量。
/// </summary>
int SubscriberCount { get; }
/// <summary>
/// 获取最近一次分发的 action 类型。
/// 即使该次分发未引起状态变化,该值也会更新。
/// </summary>
Type? LastActionType { get; }
/// <summary>
/// 获取最近一次真正改变状态的时间戳。
/// 若尚未发生状态变化,则返回 <see langword="null"/>。
/// </summary>
DateTimeOffset? LastStateChangedAt { get; }
/// <summary>
/// 获取最近一次分发记录。
/// </summary>
StoreDispatchRecord<TState>? LastDispatchRecord { get; }
/// <summary>
/// 获取当前 Store 使用的 action 匹配策略。
/// </summary>
StoreActionMatchingMode ActionMatchingMode { get; }
/// <summary>
/// 获取历史缓冲区容量。
/// 返回 0 表示当前 Store 未启用历史记录能力。
/// </summary>
int HistoryCapacity { get; }
/// <summary>
/// 获取当前可见历史记录数量。
/// 当历史记录启用时,该值至少为 1因为当前状态会作为历史锚点存在。
/// </summary>
int HistoryCount { get; }
/// <summary>
/// 获取当前状态在历史缓冲区中的索引。
/// 当未启用历史记录时返回 -1。
/// </summary>
int HistoryIndex { get; }
/// <summary>
/// 获取当前是否处于批处理阶段。
/// 该值为 <see langword="true"/> 时,状态变更通知会延迟到最外层批处理结束后再统一发送。
/// </summary>
bool IsBatching { get; }
/// <summary>
/// 获取当前历史快照列表的只读快照。
/// 该方法会返回一份独立快照,供调试工具渲染时间旅行面板,而不暴露 Store 的内部可变集合。
/// </summary>
/// <returns>当前历史快照列表;若未启用历史记录或当前没有历史,则返回空数组。</returns>
IReadOnlyList<StoreHistoryEntry<TState>> GetHistoryEntriesSnapshot();
}

View File

@ -0,0 +1,19 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 定义 Store 分发中间件接口。
/// 中间件用于在 action 分发前后插入日志、诊断、审计或拦截逻辑,
/// 同时保持核心 Store 实现专注于状态归约与订阅通知。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public interface IStoreMiddleware<TState>
{
/// <summary>
/// 执行一次分发管线节点。
/// 实现通常应调用 <paramref name="next"/> 继续后续处理;若选择短路,
/// 需要自行保证上下文状态对调用方仍然是可解释的。
/// </summary>
/// <param name="context">当前分发上下文。</param>
/// <param name="next">继续执行后续中间件或 reducer 的委托。</param>
void Invoke(StoreDispatchContext<TState> context, Action next);
}

View File

@ -0,0 +1,20 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 定义 Store 在分发 action 时的 reducer 匹配策略。
/// 默认使用精确类型匹配,以保持执行结果和顺序的确定性;仅在确有需要时再启用多态匹配。
/// </summary>
public enum StoreActionMatchingMode
{
/// <summary>
/// 仅匹配与 action 运行时类型完全相同的 reducer。
/// 该模式不会命中基类或接口注册,适合作为默认的稳定行为。
/// </summary>
ExactTypeOnly = 0,
/// <summary>
/// 在精确类型匹配之外,额外匹配可赋值的基类和接口 reducer。
/// Store 会保持确定性的执行顺序:精确类型优先,其次是最近的基类,最后是接口注册。
/// </summary>
IncludeAssignableTypes = 1
}

View File

@ -0,0 +1,55 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 表示一次 Store 分发流程中的上下文数据。
/// 中间件和 Store 实现通过该对象共享当前 action、分发时间以及归约结果。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public sealed class StoreDispatchContext<TState>
{
/// <summary>
/// 初始化一个新的分发上下文。
/// </summary>
/// <param name="action">当前分发的 action。</param>
/// <param name="previousState">分发前的状态快照。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="action"/> 为 <see langword="null"/> 时抛出。</exception>
public StoreDispatchContext(object action, TState previousState)
{
Action = action ?? throw new ArgumentNullException(nameof(action));
PreviousState = previousState;
NextState = previousState;
DispatchedAt = DateTimeOffset.UtcNow;
}
/// <summary>
/// 获取当前分发的 action 实例。
/// </summary>
public object Action { get; }
/// <summary>
/// 获取当前分发的 action 运行时类型。
/// </summary>
public Type ActionType => Action.GetType();
/// <summary>
/// 获取分发前的状态快照。
/// </summary>
public TState PreviousState { get; }
/// <summary>
/// 获取或设置归约后的下一状态。
/// Store 会在 reducer 执行完成后使用该值更新内部状态。
/// </summary>
public TState NextState { get; set; }
/// <summary>
/// 获取或设置本次分发是否导致状态发生变化。
/// 中间件可读取该值进行日志和诊断,但通常应由 Store 负责最终判定。
/// </summary>
public bool HasStateChanged { get; set; }
/// <summary>
/// 获取本次分发创建时的时间戳。
/// </summary>
public DateTimeOffset DispatchedAt { get; }
}

View File

@ -0,0 +1,62 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 记录最近一次 Store 分发的结果。
/// 该结构为调试和诊断提供稳定的只读视图,避免调用方直接依赖 Store 的内部状态。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public sealed class StoreDispatchRecord<TState>
{
/// <summary>
/// 初始化一条分发记录。
/// </summary>
/// <param name="action">本次分发的 action。</param>
/// <param name="previousState">分发前状态。</param>
/// <param name="nextState">分发后状态。</param>
/// <param name="hasStateChanged">是否发生了有效状态变化。</param>
/// <param name="dispatchedAt">分发时间。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="action"/> 为 <see langword="null"/> 时抛出。</exception>
public StoreDispatchRecord(
object action,
TState previousState,
TState nextState,
bool hasStateChanged,
DateTimeOffset dispatchedAt)
{
Action = action ?? throw new ArgumentNullException(nameof(action));
PreviousState = previousState;
NextState = nextState;
HasStateChanged = hasStateChanged;
DispatchedAt = dispatchedAt;
}
/// <summary>
/// 获取本次分发的 action 实例。
/// </summary>
public object Action { get; }
/// <summary>
/// 获取本次分发的 action 运行时类型。
/// </summary>
public Type ActionType => Action.GetType();
/// <summary>
/// 获取分发前状态。
/// </summary>
public TState PreviousState { get; }
/// <summary>
/// 获取分发后状态。
/// </summary>
public TState NextState { get; }
/// <summary>
/// 获取本次分发是否产生了有效状态变化。
/// </summary>
public bool HasStateChanged { get; }
/// <summary>
/// 获取分发时间。
/// </summary>
public DateTimeOffset DispatchedAt { get; }
}

View File

@ -0,0 +1,44 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 表示一条 Store 历史快照记录。
/// 该记录用于撤销/重做和调试面板查看历史状态,不会暴露 Store 的内部可变结构。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public sealed class StoreHistoryEntry<TState>
{
/// <summary>
/// 初始化一条历史记录。
/// </summary>
/// <param name="state">该历史点对应的状态快照。</param>
/// <param name="recordedAt">该历史点被记录的时间。</param>
/// <param name="action">触发该状态的 action若为初始状态或已清空历史后的锚点则为 <see langword="null"/>。</param>
public StoreHistoryEntry(TState state, DateTimeOffset recordedAt, object? action = null)
{
State = state;
RecordedAt = recordedAt;
Action = action;
}
/// <summary>
/// 获取该历史点对应的状态快照。
/// </summary>
public TState State { get; }
/// <summary>
/// 获取该历史点被记录的时间。
/// </summary>
public DateTimeOffset RecordedAt { get; }
/// <summary>
/// 获取触发该历史点的 action 实例。
/// 对于初始状态或调用 <c>ClearHistory()</c> 后的新锚点,该值为 <see langword="null"/>。
/// </summary>
public object? Action { get; }
/// <summary>
/// 获取触发该历史点的 action 运行时类型。
/// 若该历史点没有关联 action则返回 <see langword="null"/>。
/// </summary>
public Type? ActionType => Action?.GetType();
}

View File

@ -0,0 +1,16 @@
namespace GFramework.Core.Abstractions.Utility.Numeric;
/// <summary>
/// 数值显示格式化器接口。
/// </summary>
public interface INumericDisplayFormatter
{
/// <summary>
/// 将数值格式化为展示字符串。
/// </summary>
/// <typeparam name="T">数值类型。</typeparam>
/// <param name="value">待格式化的值。</param>
/// <param name="options">格式化选项。</param>
/// <returns>格式化后的字符串。</returns>
string Format<T>(T value, NumericFormatOptions? options = null);
}

View File

@ -0,0 +1,22 @@
namespace GFramework.Core.Abstractions.Utility.Numeric;
/// <summary>
/// 数值显示规则接口。
/// </summary>
public interface INumericFormatRule
{
/// <summary>
/// 规则名称。
/// </summary>
string Name { get; }
/// <summary>
/// 尝试按当前规则格式化数值。
/// </summary>
/// <typeparam name="T">数值类型。</typeparam>
/// <param name="value">待格式化的值。</param>
/// <param name="options">格式化选项。</param>
/// <param name="result">输出结果。</param>
/// <returns>格式化是否成功。</returns>
bool TryFormat<T>(T value, NumericFormatOptions options, out string result);
}

View File

@ -0,0 +1,12 @@
namespace GFramework.Core.Abstractions.Utility.Numeric;
/// <summary>
/// 数值显示风格。
/// </summary>
public enum NumericDisplayStyle
{
/// <summary>
/// 紧凑缩写风格,例如 1.2K / 3.4M。
/// </summary>
Compact = 0
}

View File

@ -0,0 +1,52 @@
namespace GFramework.Core.Abstractions.Utility.Numeric;
/// <summary>
/// 数值格式化选项。
/// </summary>
public sealed record NumericFormatOptions
{
/// <summary>
/// 显示风格。
/// </summary>
public NumericDisplayStyle Style { get; init; } = NumericDisplayStyle.Compact;
/// <summary>
/// 最大保留小数位数。
/// </summary>
public int MaxDecimalPlaces { get; init; } = 1;
/// <summary>
/// 最少保留小数位数。
/// </summary>
public int MinDecimalPlaces { get; init; } = 0;
/// <summary>
/// 四舍五入策略。
/// </summary>
public MidpointRounding MidpointRounding { get; init; } = MidpointRounding.AwayFromZero;
/// <summary>
/// 是否裁剪小数末尾的 0。
/// </summary>
public bool TrimTrailingZeros { get; init; } = true;
/// <summary>
/// 小于缩写阈值时是否启用千分位分组。
/// </summary>
public bool UseGroupingBelowThreshold { get; init; }
/// <summary>
/// 进入缩写显示的阈值。
/// </summary>
public decimal CompactThreshold { get; init; } = 1000m;
/// <summary>
/// 格式提供者。
/// </summary>
public IFormatProvider? FormatProvider { get; init; }
/// <summary>
/// 自定义格式规则。
/// </summary>
public INumericFormatRule? Rule { get; init; }
}

View File

@ -0,0 +1,8 @@
namespace GFramework.Core.Abstractions.Utility.Numeric;
/// <summary>
/// 数值缩写阈值定义。
/// </summary>
/// <param name="Divisor">缩写除数,例如 1000、1000000。</param>
/// <param name="Suffix">缩写后缀,例如 K、M。</param>
public readonly record struct NumericSuffixThreshold(decimal Divisor, string Suffix);

View File

@ -41,16 +41,8 @@ public abstract class TestArchitectureBase : Architecture
{ {
InitCalled = true; InitCalled = true;
_postRegistrationHook?.Invoke(this); _postRegistrationHook?.Invoke(this);
}
/// <summary> // 订阅阶段变更事件以记录历史
/// 进入指定架构阶段时的处理方法,记录阶段历史 PhaseChanged += phase => PhaseHistory.Add(phase);
/// </summary>
/// <param name="next">要进入的下一个架构阶段</param>
protected override void EnterPhase(ArchitecturePhase next)
{
base.EnterPhase(next);
// 记录进入的架构阶段到历史列表中
PhaseHistory.Add(next);
} }
} }

View File

@ -1,11 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks> <TestTargetFrameworks Condition="'$(TestTargetFrameworks)' == ''">net10.0</TestTargetFrameworks>
<TargetFrameworks>$(TestTargetFrameworks)</TargetFrameworks>
<ImplicitUsings>disable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
<WarningLevel>0</WarningLevel>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Mediator.Abstractions" Version="3.0.1"/> <PackageReference Include="Mediator.Abstractions" Version="3.0.1"/>
@ -20,6 +22,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Scriban" Version="6.6.0" />
<ProjectReference Include="..\GFramework.Core.Abstractions\GFramework.Core.Abstractions.csproj"/>
<ProjectReference Include="..\GFramework.Core\GFramework.Core.csproj"/> <ProjectReference Include="..\GFramework.Core\GFramework.Core.csproj"/>
<ProjectReference Include="..\GFramework.SourceGenerators.Abstractions\GFramework.SourceGenerators.Abstractions.csproj"/> <ProjectReference Include="..\GFramework.SourceGenerators.Abstractions\GFramework.SourceGenerators.Abstractions.csproj"/>
<ProjectReference Include="..\GFramework.SourceGenerators.Common\GFramework.SourceGenerators.Common.csproj"/> <ProjectReference Include="..\GFramework.SourceGenerators.Common\GFramework.SourceGenerators.Common.csproj"/>

View File

@ -19,3 +19,8 @@ global using System.Threading.Tasks;
global using NUnit.Framework; global using NUnit.Framework;
global using NUnit.Compatibility; global using NUnit.Compatibility;
global using GFramework.Core.Systems; global using GFramework.Core.Systems;
global using GFramework.Core.Abstractions.StateManagement;
global using GFramework.Core.Extensions;
global using GFramework.Core.Property;
global using GFramework.Core.StateManagement;
global using GFramework.Core.Abstractions.Property;

View File

@ -0,0 +1,190 @@
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}",
"status.gold": "Gold: {gold:compact}",
"status.damage": "Damage: {damage:compact:maxDecimals=2}",
"status.unknownCompact": "Gold: {gold:compact:maxDecimalss=2}",
"status.invalidCompact": "Gold: {gold:compact:maxDecimals=abc}"
}
""");
File.WriteAllText(Path.Combine(zhsPath, "common.json"), """
{
"game.title": "我的游戏",
"ui.message.welcome": "欢迎, {playerName}!",
"status.health": "生命值: {current}/{max}",
"status.gold": "金币: {gold:compact}",
"status.damage": "伤害: {damage:compact:maxDecimals=2}",
"status.unknownCompact": "金币: {gold:compact:maxDecimalss=2}",
"status.invalidCompact": "金币: {gold:compact:maxDecimals=abc}"
}
""");
}
[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 GetString_WithCompactFormatter_ShouldFormatCorrectly()
{
var gold = _manager!.GetString("common", "status.gold")
.WithVariable("gold", 1_250)
.Format();
Assert.That(gold, Is.EqualTo("Gold: 1.3K"));
}
[Test]
public void GetString_WithCompactFormatterArgs_ShouldApplyOptions()
{
var damage = _manager!.GetString("common", "status.damage")
.WithVariable("damage", 1_234)
.Format();
Assert.That(damage, Is.EqualTo("Damage: 1.23K"));
}
[Test]
public void GetString_WithUnknownCompactFormatterArgs_ShouldIgnoreUnknownOptions()
{
var gold = _manager!.GetString("common", "status.unknownCompact")
.WithVariable("gold", 1_250)
.Format();
Assert.That(gold, Is.EqualTo("Gold: 1.3K"));
}
[Test]
public void GetString_WithInvalidCompactFormatterArgs_ShouldFallbackToDefaultFormatting()
{
var gold = _manager!.GetString("common", "status.invalidCompact")
.WithVariable("gold", 1_250)
.Format();
Assert.That(gold, Is.EqualTo("Gold: 1250"));
}
[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

@ -1,6 +1,5 @@
using GFramework.Core.Abstractions.Logging; using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Logging.Appenders; using GFramework.Core.Logging.Appenders;
using NUnit.Framework;
namespace GFramework.Core.Tests.Logging; namespace GFramework.Core.Tests.Logging;
@ -152,8 +151,12 @@ public class AsyncLogAppenderTests
[Test] [Test]
public void Append_WhenInnerAppenderThrows_ShouldNotCrash() public void Append_WhenInnerAppenderThrows_ShouldNotCrash()
{ {
var reportedExceptions = new List<Exception>();
var innerAppender = new ThrowingAppender(); var innerAppender = new ThrowingAppender();
using var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 1000); using var asyncAppender = new AsyncLogAppender(
innerAppender,
bufferSize: 1000,
processingErrorHandler: reportedExceptions.Add);
// 即使内部 Appender 抛出异常,也不应该影响调用线程 // 即使内部 Appender 抛出异常,也不应该影响调用线程
Assert.DoesNotThrow(() => Assert.DoesNotThrow(() =>
@ -165,7 +168,56 @@ public class AsyncLogAppenderTests
} }
}); });
Thread.Sleep(100); // 等待后台处理 asyncAppender.Flush();
Assert.That(reportedExceptions, Has.Count.EqualTo(10));
Assert.That(reportedExceptions, Has.All.TypeOf<InvalidOperationException>());
Assert.That(reportedExceptions.Select(static exception => exception.Message),
Has.All.EqualTo("Test exception"));
}
[Test]
public void Append_WhenProcessingErrorHandlerThrows_ShouldStillNotCrash()
{
var innerAppender = new ThrowingAppender();
using var asyncAppender = new AsyncLogAppender(
innerAppender,
bufferSize: 1000,
processingErrorHandler: static _ => throw new InvalidOperationException("Observer failure"));
Assert.DoesNotThrow(() =>
{
for (int i = 0; i < 10; i++)
{
var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null);
asyncAppender.Append(entry);
}
});
Assert.That(asyncAppender.Flush(), Is.True);
}
[Test]
public void Append_WhenInnerAppenderThrowsOperationCanceledException_ShouldNotReportError()
{
var reportedExceptions = new List<Exception>();
var innerAppender = new CancellationAppender();
using var asyncAppender = new AsyncLogAppender(
innerAppender,
bufferSize: 1000,
processingErrorHandler: reportedExceptions.Add);
Assert.DoesNotThrow(() =>
{
for (int i = 0; i < 10; i++)
{
var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null);
asyncAppender.Append(entry);
}
});
Assert.That(asyncAppender.Flush(), Is.True);
Assert.That(reportedExceptions, Is.Empty);
} }
// 辅助测试类 // 辅助测试类
@ -228,4 +280,20 @@ public class AsyncLogAppenderTests
{ {
} }
} }
private class CancellationAppender : ILogAppender
{
public void Append(LogEntry entry)
{
throw new OperationCanceledException("Simulated cancellation");
}
public void Flush()
{
}
public void Dispose()
{
}
}
} }

View File

@ -1,6 +1,5 @@
using GFramework.Core.Abstractions.Pause; using GFramework.Core.Abstractions.Pause;
using GFramework.Core.Pause; using GFramework.Core.Pause;
using NUnit.Framework;
namespace GFramework.Core.Tests.Pause; namespace GFramework.Core.Tests.Pause;
@ -220,11 +219,11 @@ public class PauseStackManagerTests
PauseGroup? eventGroup = null; PauseGroup? eventGroup = null;
bool? eventIsPaused = null; bool? eventIsPaused = null;
_manager.OnPauseStateChanged += (group, isPaused) => _manager.OnPauseStateChanged += (_, e) =>
{ {
eventTriggered = true; eventTriggered = true;
eventGroup = group; eventGroup = e.Group;
eventIsPaused = isPaused; eventIsPaused = e.IsPaused;
}; };
_manager.Push("Test", PauseGroup.Gameplay); _manager.Push("Test", PauseGroup.Gameplay);
@ -243,10 +242,10 @@ public class PauseStackManagerTests
var token = _manager.Push("Test"); var token = _manager.Push("Test");
bool eventTriggered = false; bool eventTriggered = false;
_manager.OnPauseStateChanged += (group, isPaused) => _manager.OnPauseStateChanged += (_, e) =>
{ {
eventTriggered = true; eventTriggered = true;
Assert.That(isPaused, Is.False); Assert.That(e.IsPaused, Is.False);
}; };
_manager.Pop(token); _manager.Pop(token);

View File

@ -0,0 +1,92 @@
using GFramework.Core.Events;
namespace GFramework.Core.Tests.StateManagement;
/// <summary>
/// Store 到 EventBus 桥接扩展的单元测试。
/// 这些测试验证旧模块兼容桥接能够正确转发 dispatch 和状态变化事件,并支持运行时拆除。
/// </summary>
[TestFixture]
public class StoreEventBusExtensionsTests
{
/// <summary>
/// 测试桥接会发布每次 dispatch 事件,并对批处理后的状态变化只发送一次最终状态事件。
/// </summary>
[Test]
public void BridgeToEventBus_Should_Publish_Dispatches_And_Collapsed_State_Changes()
{
var eventBus = new EventBus();
var store = CreateStore();
var dispatchedEvents = new List<StoreDispatchedEvent<CounterState>>();
var stateChangedEvents = new List<StoreStateChangedEvent<CounterState>>();
eventBus.Register<StoreDispatchedEvent<CounterState>>(dispatchedEvents.Add);
eventBus.Register<StoreStateChangedEvent<CounterState>>(stateChangedEvents.Add);
store.BridgeToEventBus(eventBus);
store.Dispatch(new IncrementAction(1));
store.RunInBatch(() =>
{
store.Dispatch(new IncrementAction(1));
store.Dispatch(new IncrementAction(1));
});
Assert.That(dispatchedEvents.Count, Is.EqualTo(3));
Assert.That(dispatchedEvents[0].DispatchRecord.NextState.Count, Is.EqualTo(1));
Assert.That(dispatchedEvents[2].DispatchRecord.NextState.Count, Is.EqualTo(3));
Assert.That(stateChangedEvents.Count, Is.EqualTo(2));
Assert.That(stateChangedEvents[0].State.Count, Is.EqualTo(1));
Assert.That(stateChangedEvents[1].State.Count, Is.EqualTo(3));
}
/// <summary>
/// 测试桥接句柄注销后不会再继续向 EventBus 发送事件。
/// </summary>
[Test]
public void BridgeToEventBus_UnRegister_Should_Stop_Future_Publications()
{
var eventBus = new EventBus();
var store = CreateStore();
var dispatchedEvents = new List<StoreDispatchedEvent<CounterState>>();
var stateChangedEvents = new List<StoreStateChangedEvent<CounterState>>();
eventBus.Register<StoreDispatchedEvent<CounterState>>(dispatchedEvents.Add);
eventBus.Register<StoreStateChangedEvent<CounterState>>(stateChangedEvents.Add);
var bridge = store.BridgeToEventBus(eventBus);
store.Dispatch(new IncrementAction(1));
bridge.UnRegister();
store.Dispatch(new IncrementAction(1));
Assert.That(dispatchedEvents.Count, Is.EqualTo(1));
Assert.That(stateChangedEvents.Count, Is.EqualTo(1));
Assert.That(stateChangedEvents[0].State.Count, Is.EqualTo(1));
}
/// <summary>
/// 创建一个带基础 reducer 的测试 Store。
/// </summary>
/// <returns>测试用 Store 实例。</returns>
private static Store<CounterState> CreateStore()
{
var store = new Store<CounterState>(new CounterState(0, "Player"));
store.RegisterReducer<IncrementAction>((state, action) => state with { Count = state.Count + action.Amount });
return store;
}
/// <summary>
/// 用于桥接测试的状态类型。
/// </summary>
/// <param name="Count">当前计数值。</param>
/// <param name="Name">当前名称。</param>
private sealed record CounterState(int Count, string Name);
/// <summary>
/// 用于桥接测试的计数 action。
/// </summary>
/// <param name="Amount">要增加的数量。</param>
private sealed record IncrementAction(int Amount);
}

View File

@ -0,0 +1,879 @@
namespace GFramework.Core.Tests.StateManagement;
/// <summary>
/// Store 状态管理能力的单元测试。
/// 这些测试覆盖集中式状态容器的核心职责:状态归约、订阅通知、选择器桥接和诊断行为。
/// </summary>
[TestFixture]
public class StoreTests
{
/// <summary>
/// 测试 Store 在创建后能够暴露初始状态。
/// </summary>
[Test]
public void State_Should_Return_Initial_State()
{
var store = CreateStore(new CounterState(1, "Player"));
Assert.That(store.State.Count, Is.EqualTo(1));
Assert.That(store.State.Name, Is.EqualTo("Player"));
}
/// <summary>
/// 测试 Dispatch 能够执行 reducer 并向订阅者广播新状态。
/// </summary>
[Test]
public void Dispatch_Should_Update_State_And_Notify_Subscribers()
{
var store = CreateStore();
var receivedStates = new List<CounterState>();
store.Subscribe(receivedStates.Add);
store.Dispatch(new IncrementAction(2));
Assert.That(store.State.Count, Is.EqualTo(2));
Assert.That(receivedStates.Count, Is.EqualTo(1));
Assert.That(receivedStates[0].Count, Is.EqualTo(2));
}
/// <summary>
/// 测试当 reducer 返回逻辑相等状态时不会触发通知。
/// </summary>
[Test]
public void Dispatch_Should_Not_Notify_When_State_Does_Not_Change()
{
var store = CreateStore();
var notifyCount = 0;
store.Subscribe(_ => notifyCount++);
store.Dispatch(new RenameAction("Player"));
Assert.That(store.State.Name, Is.EqualTo("Player"));
Assert.That(notifyCount, Is.EqualTo(0));
}
/// <summary>
/// 测试同一 action 类型的多个 reducer 会按注册顺序执行。
/// </summary>
[Test]
public void Dispatch_Should_Run_Multiple_Reducers_In_Registration_Order()
{
var store = CreateStore();
store.RegisterReducer<IncrementAction>((state, action) =>
state with { Count = state.Count + action.Amount * 10 });
store.Dispatch(new IncrementAction(1));
Assert.That(store.State.Count, Is.EqualTo(11));
}
/// <summary>
/// 测试 SubscribeWithInitValue 会立即回放当前状态并继续接收后续变化。
/// </summary>
[Test]
public void SubscribeWithInitValue_Should_Replay_Current_State_And_Future_Changes()
{
var store = CreateStore(new CounterState(5, "Player"));
var receivedCounts = new List<int>();
store.SubscribeWithInitValue(state => receivedCounts.Add(state.Count));
store.Dispatch(new IncrementAction(3));
Assert.That(receivedCounts, Is.EqualTo(new[] { 5, 8 }));
}
/// <summary>
/// 测试 Store 的 SubscribeWithInitValue 在初始化回放期间不会漏掉后续状态变化。
/// </summary>
[Test]
public void SubscribeWithInitValue_Should_Not_Miss_Changes_During_Init_Callback()
{
var store = CreateStore();
var receivedCounts = new List<int>();
store.SubscribeWithInitValue(state =>
{
receivedCounts.Add(state.Count);
if (receivedCounts.Count == 1)
{
store.Dispatch(new IncrementAction(1));
}
});
Assert.That(receivedCounts, Is.EqualTo(new[] { 0, 1 }));
}
/// <summary>
/// 测试注销订阅后不会再收到后续通知。
/// </summary>
[Test]
public void UnRegister_Handle_Should_Stop_Future_Notifications()
{
var store = CreateStore();
var notifyCount = 0;
var unRegister = store.Subscribe(_ => notifyCount++);
store.Dispatch(new IncrementAction(1));
unRegister.UnRegister();
store.Dispatch(new IncrementAction(1));
Assert.That(notifyCount, Is.EqualTo(1));
}
/// <summary>
/// 测试选择器仅在所选状态片段变化时触发通知。
/// </summary>
[Test]
public void Select_Should_Only_Notify_When_Selected_Slice_Changes()
{
var store = CreateStore();
var selectedCounts = new List<int>();
var selection = store.Select(state => state.Count);
selection.Register(selectedCounts.Add);
store.Dispatch(new RenameAction("Renamed"));
store.Dispatch(new IncrementAction(2));
Assert.That(selectedCounts, Is.EqualTo(new[] { 2 }));
}
/// <summary>
/// 测试选择器支持自定义比较器,从而抑制无意义的局部状态通知。
/// </summary>
[Test]
public void Select_Should_Respect_Custom_Selected_Value_Comparer()
{
var store = CreateStore();
var selectedCounts = new List<int>();
var selection = store.Select(
state => state.Count,
new TensBucketEqualityComparer());
selection.Register(selectedCounts.Add);
store.Dispatch(new IncrementAction(5));
store.Dispatch(new IncrementAction(6));
Assert.That(selectedCounts, Is.EqualTo(new[] { 11 }));
}
/// <summary>
/// 测试 StoreSelection 的 RegisterWithInitValue 在初始化回放期间不会漏掉后续局部状态变化。
/// </summary>
[Test]
public void Selection_RegisterWithInitValue_Should_Not_Miss_Changes_During_Init_Callback()
{
var store = CreateStore();
var selection = store.Select(state => state.Count);
var receivedCounts = new List<int>();
selection.RegisterWithInitValue(value =>
{
receivedCounts.Add(value);
if (receivedCounts.Count == 1)
{
store.Dispatch(new IncrementAction(1));
}
});
Assert.That(receivedCounts, Is.EqualTo(new[] { 0, 1 }));
}
/// <summary>
/// 测试 ToBindableProperty 可桥接到现有 BindableProperty 风格用法,并与旧属性系统共存。
/// </summary>
[Test]
public void ToBindableProperty_Should_Work_With_Existing_BindableProperty_Pattern()
{
var store = CreateStore();
var mirror = new BindableProperty<int>(0);
IReadonlyBindableProperty<int> bindableProperty = store.ToBindableProperty(state => state.Count);
bindableProperty.Register(value => mirror.Value = value);
store.Dispatch(new IncrementAction(3));
Assert.That(mirror.Value, Is.EqualTo(3));
}
/// <summary>
/// 测试 IStateSelector 接口重载能够复用显式选择逻辑。
/// </summary>
[Test]
public void Select_With_IStateSelector_Should_Project_Selected_Value()
{
var store = CreateStore();
var selection = store.Select(new CounterNameSelector());
Assert.That(selection.Value, Is.EqualTo("Player"));
}
/// <summary>
/// 测试 Store 在中间件内部发生同一实例的嵌套分发时会抛出异常。
/// </summary>
[Test]
public void Dispatch_Should_Throw_When_Nested_Dispatch_Happens_On_Same_Store()
{
var store = CreateStore();
store.UseMiddleware(new NestedDispatchMiddleware(store));
Assert.That(
() => store.Dispatch(new IncrementAction(1)),
Throws.InvalidOperationException.With.Message.Contain("Nested dispatch"));
}
/// <summary>
/// 测试中间件链执行顺序和 Store 诊断信息更新。
/// </summary>
[Test]
public void Dispatch_Should_Run_Middlewares_In_Order_And_Update_Diagnostics()
{
var store = CreateStore();
var logs = new List<string>();
store.UseMiddleware(new RecordingMiddleware(logs, "first"));
store.UseMiddleware(new RecordingMiddleware(logs, "second"));
store.Dispatch(new IncrementAction(2));
Assert.That(logs, Is.EqualTo(new[]
{
"first:before",
"second:before",
"second:after",
"first:after"
}));
Assert.That(store.LastActionType, Is.EqualTo(typeof(IncrementAction)));
Assert.That(store.LastStateChangedAt, Is.Not.Null);
Assert.That(store.LastDispatchRecord, Is.Not.Null);
Assert.That(store.LastDispatchRecord!.HasStateChanged, Is.True);
Assert.That(store.LastDispatchRecord.NextState.Count, Is.EqualTo(2));
}
/// <summary>
/// 测试 reducer 句柄注销后,后续同类型 action 不会再命中该 reducer。
/// </summary>
[Test]
public void RegisterReducerHandle_UnRegister_Should_Stop_Future_Reductions()
{
var store = new Store<CounterState>(new CounterState(0, "Player"));
var reducerHandle = store.RegisterReducerHandle<IncrementAction>((state, action) =>
state with { Count = state.Count + action.Amount });
store.Dispatch(new IncrementAction(2));
reducerHandle.UnRegister();
store.Dispatch(new IncrementAction(2));
Assert.That(store.State.Count, Is.EqualTo(2));
}
/// <summary>
/// 测试 middleware 句柄注销后,后续 dispatch 不会再经过该中间件。
/// </summary>
[Test]
public void RegisterMiddleware_UnRegister_Should_Stop_Future_Pipeline_Execution()
{
var store = CreateStore();
var logs = new List<string>();
var middlewareHandle = store.RegisterMiddleware(new RecordingMiddleware(logs, "dynamic"));
store.Dispatch(new IncrementAction(1));
middlewareHandle.UnRegister();
store.Dispatch(new IncrementAction(1));
Assert.That(store.State.Count, Is.EqualTo(2));
Assert.That(logs, Is.EqualTo(new[] { "dynamic:before", "dynamic:after" }));
}
/// <summary>
/// 测试移除同一 action 类型中的某个 reducer 后,其余 reducer 仍保持原有注册顺序。
/// </summary>
[Test]
public void RegisterReducerHandle_UnRegister_Should_Preserve_Remaining_Order()
{
var executionOrder = new List<string>();
var store = new Store<CounterState>(new CounterState(0, "Player"));
store.RegisterReducerHandle<IncrementAction>((state, action) =>
{
executionOrder.Add("first");
return state with { Count = state.Count + action.Amount };
});
var middleReducer = store.RegisterReducerHandle<IncrementAction>((state, action) =>
{
executionOrder.Add("middle");
return state with { Count = state.Count + action.Amount * 10 };
});
store.RegisterReducerHandle<IncrementAction>((state, action) =>
{
executionOrder.Add("last");
return state with { Count = state.Count + action.Amount * 100 };
});
middleReducer.UnRegister();
store.Dispatch(new IncrementAction(1));
Assert.That(executionOrder, Is.EqualTo(new[] { "first", "last" }));
Assert.That(store.State.Count, Is.EqualTo(101));
}
/// <summary>
/// 测试注册句柄的注销操作是幂等的,多次调用不会抛异常或影响其他注册项。
/// </summary>
[Test]
public void RegisterHandles_UnRegister_Should_Be_Idempotent()
{
var logs = new List<string>();
var store = new Store<CounterState>(new CounterState(0, "Player"));
var reducerHandle = store.RegisterReducerHandle<IncrementAction>((state, action) =>
state with { Count = state.Count + action.Amount });
var middlewareHandle = store.RegisterMiddleware(new RecordingMiddleware(logs, "dynamic"));
Assert.That(() =>
{
reducerHandle.UnRegister();
reducerHandle.UnRegister();
middlewareHandle.UnRegister();
middlewareHandle.UnRegister();
}, Throws.Nothing);
store.Dispatch(new IncrementAction(1));
Assert.That(store.State.Count, Is.EqualTo(0));
Assert.That(logs, Is.Empty);
}
/// <summary>
/// 测试 dispatch 进行中注销 reducer 和 middleware 时,
/// 当前 dispatch 仍使用开始时的快照,而后续 dispatch 会看到注销结果。
/// </summary>
[Test]
public void UnRegister_During_Dispatch_Should_Affect_Next_Dispatch_But_Not_Current_One()
{
using var entered = new ManualResetEventSlim(false);
using var release = new ManualResetEventSlim(false);
var logs = new List<string>();
var store = new Store<CounterState>(new CounterState(0, "Player"));
var reducerHandle = store.RegisterReducerHandle<IncrementAction>((state, action) =>
state with { Count = state.Count + action.Amount });
var blockingHandle = store.RegisterMiddleware(new BlockingMiddleware(entered, release));
var recordingHandle = store.RegisterMiddleware(new RecordingMiddleware(logs, "dynamic"));
var dispatchTask = Task.Run(() => store.Dispatch(new IncrementAction(1)));
Assert.That(entered.Wait(TimeSpan.FromSeconds(2)), Is.True, "middleware 未按预期进入阻塞阶段");
reducerHandle.UnRegister();
blockingHandle.UnRegister();
recordingHandle.UnRegister();
release.Set();
Assert.That(dispatchTask.Wait(TimeSpan.FromSeconds(2)), Is.True, "dispatch 未在释放 middleware 后完成");
Assert.That(store.State.Count, Is.EqualTo(1), "当前 dispatch 应继续使用启动时抓取的 reducer 快照");
Assert.That(logs, Is.EqualTo(new[] { "dynamic:before", "dynamic:after" }));
store.Dispatch(new IncrementAction(1));
Assert.That(store.State.Count, Is.EqualTo(1), "后续 dispatch 应看到 reducer 已被注销");
Assert.That(logs, Is.EqualTo(new[] { "dynamic:before", "dynamic:after" }));
}
/// <summary>
/// 测试未命中的 action 仍会记录诊断信息,但不会改变状态。
/// </summary>
[Test]
public void Dispatch_Without_Matching_Reducer_Should_Update_Record_Without_Changing_State()
{
var store = CreateStore();
store.Dispatch(new NoopAction());
Assert.That(store.State.Count, Is.EqualTo(0));
Assert.That(store.LastActionType, Is.EqualTo(typeof(NoopAction)));
Assert.That(store.LastDispatchRecord, Is.Not.Null);
Assert.That(store.LastDispatchRecord!.HasStateChanged, Is.False);
Assert.That(store.LastStateChangedAt, Is.Null);
}
/// <summary>
/// 测试 Store 能够复用同一个缓存选择视图实例。
/// </summary>
[Test]
public void GetOrCreateSelection_Should_Return_Cached_Instance_For_Same_Key()
{
var store = CreateStore();
var first = store.GetOrCreateSelection("count", state => state.Count);
var second = store.GetOrCreateSelection("count", state => state.Count);
Assert.That(second, Is.SameAs(first));
}
/// <summary>
/// 测试 StoreBuilder 能够应用 reducer、中间件和状态比较器配置。
/// </summary>
[Test]
public void StoreBuilder_Should_Apply_Configured_Reducers_Middlewares_And_Comparer()
{
var logs = new List<string>();
var store = (Store<CounterState>)Store<CounterState>
.CreateBuilder()
.WithComparer(new CounterStateNameInsensitiveComparer())
.AddReducer<IncrementAction>((state, action) => state with { Count = state.Count + action.Amount })
.AddReducer<RenameAction>((state, action) => state with { Name = action.Name })
.UseMiddleware(new RecordingMiddleware(logs, "builder"))
.Build(new CounterState(0, "Player"));
var notifyCount = 0;
store.Subscribe(_ => notifyCount++);
store.Dispatch(new RenameAction("player"));
store.Dispatch(new IncrementAction(2));
Assert.That(notifyCount, Is.EqualTo(1));
Assert.That(store.State.Count, Is.EqualTo(2));
Assert.That(logs, Is.EqualTo(new[] { "builder:before", "builder:after", "builder:before", "builder:after" }));
}
/// <summary>
/// 测试批处理会折叠多次状态变化通知,只在最外层结束时发布最终状态。
/// </summary>
[Test]
public void RunInBatch_Should_Collapse_Notifications_To_Final_State()
{
var store = CreateStore();
var receivedCounts = new List<int>();
store.Subscribe(state => receivedCounts.Add(state.Count));
store.RunInBatch(() =>
{
Assert.That(store.IsBatching, Is.True);
store.Dispatch(new IncrementAction(1));
store.Dispatch(new IncrementAction(2));
});
Assert.That(store.IsBatching, Is.False);
Assert.That(store.State.Count, Is.EqualTo(3));
Assert.That(receivedCounts, Is.EqualTo(new[] { 3 }));
}
/// <summary>
/// 测试嵌套批处理只会在最外层结束时发出一次通知。
/// </summary>
[Test]
public void RunInBatch_Should_Support_Nested_Batches()
{
var store = CreateStore();
var receivedCounts = new List<int>();
store.Subscribe(state => receivedCounts.Add(state.Count));
store.RunInBatch(() =>
{
store.Dispatch(new IncrementAction(1));
store.RunInBatch(() =>
{
Assert.That(store.IsBatching, Is.True);
store.Dispatch(new IncrementAction(1));
});
store.Dispatch(new IncrementAction(1));
});
Assert.That(store.State.Count, Is.EqualTo(3));
Assert.That(receivedCounts, Is.EqualTo(new[] { 3 }));
}
/// <summary>
/// 测试启用历史记录后支持撤销、重做、时间旅行和 redo 分支裁剪。
/// </summary>
[Test]
public void History_Should_Support_Undo_Redo_Time_Travel_And_Branch_Reset()
{
var store = new Store<CounterState>(new CounterState(0, "Player"), historyCapacity: 8);
store.RegisterReducer<IncrementAction>((state, action) => state with { Count = state.Count + action.Amount });
store.Dispatch(new IncrementAction(1));
store.Dispatch(new IncrementAction(1));
store.Dispatch(new IncrementAction(1));
Assert.That(store.HistoryCount, Is.EqualTo(4));
Assert.That(store.HistoryIndex, Is.EqualTo(3));
Assert.That(store.CanUndo, Is.True);
Assert.That(store.CanRedo, Is.False);
store.Undo();
Assert.That(store.State.Count, Is.EqualTo(2));
Assert.That(store.HistoryIndex, Is.EqualTo(2));
Assert.That(store.CanRedo, Is.True);
store.Undo();
Assert.That(store.State.Count, Is.EqualTo(1));
Assert.That(store.HistoryIndex, Is.EqualTo(1));
store.Redo();
Assert.That(store.State.Count, Is.EqualTo(2));
Assert.That(store.HistoryIndex, Is.EqualTo(2));
store.TimeTravelTo(0);
Assert.That(store.State.Count, Is.EqualTo(0));
Assert.That(store.HistoryIndex, Is.EqualTo(0));
store.TimeTravelTo(2);
Assert.That(store.State.Count, Is.EqualTo(2));
Assert.That(store.HistoryIndex, Is.EqualTo(2));
store.Dispatch(new IncrementAction(10));
Assert.That(store.State.Count, Is.EqualTo(12));
Assert.That(store.CanRedo, Is.False, "新 dispatch 应清除 redo 分支");
Assert.That(store.GetHistoryEntriesSnapshot().Select(entry => entry.State.Count),
Is.EqualTo(new[] { 0, 1, 2, 12 }));
}
/// <summary>
/// 测试 ClearHistory 会以当前状态重置历史锚点,而不会修改当前状态。
/// </summary>
[Test]
public void ClearHistory_Should_Reset_To_Current_State_Anchor()
{
var store = new Store<CounterState>(new CounterState(0, "Player"), historyCapacity: 4);
store.RegisterReducer<IncrementAction>((state, action) => state with { Count = state.Count + action.Amount });
store.Dispatch(new IncrementAction(1));
store.Dispatch(new IncrementAction(1));
store.ClearHistory();
Assert.That(store.State.Count, Is.EqualTo(2));
Assert.That(store.HistoryCount, Is.EqualTo(1));
Assert.That(store.HistoryIndex, Is.EqualTo(0));
Assert.That(store.CanUndo, Is.False);
Assert.That(store.GetHistoryEntriesSnapshot()[0].State.Count, Is.EqualTo(2));
}
/// <summary>
/// 测试默认 action 匹配策略仍然只命中精确类型 reducer。
/// </summary>
[Test]
public void Dispatch_Should_Remain_Exact_Type_Only_By_Default()
{
var store = new Store<CounterState>(new CounterState(0, "Player"));
store.RegisterReducer<IncrementActionBase>((state, action) =>
state with { Count = state.Count + action.Amount * 10 });
store.RegisterReducer<IIncrementActionMarker>((state, action) =>
state with { Count = state.Count + action.Amount * 100 });
store.Dispatch(new DerivedIncrementAction(1));
Assert.That(store.State.Count, Is.EqualTo(0));
Assert.That(store.ActionMatchingMode, Is.EqualTo(StoreActionMatchingMode.ExactTypeOnly));
}
/// <summary>
/// 测试启用多态匹配后Store 会按“精确类型 -> 基类 -> 接口”的稳定顺序执行 reducer。
/// </summary>
[Test]
public void Dispatch_Should_Use_Polymorphic_Action_Matching_In_Deterministic_Order()
{
var executionOrder = new List<string>();
var store = new Store<CounterState>(
new CounterState(0, "Player"),
actionMatchingMode: StoreActionMatchingMode.IncludeAssignableTypes);
store.RegisterReducer<IncrementActionBase>((state, action) =>
{
executionOrder.Add("base");
return state with { Count = state.Count + action.Amount * 10 };
});
store.RegisterReducer<IIncrementActionMarker>((state, action) =>
{
executionOrder.Add("interface");
return state with { Count = state.Count + action.Amount * 100 };
});
store.RegisterReducer<DerivedIncrementAction>((state, action) =>
{
executionOrder.Add("exact");
return state with { Count = state.Count + action.Amount };
});
store.Dispatch(new DerivedIncrementAction(1));
Assert.That(executionOrder, Is.EqualTo(new[] { "exact", "base", "interface" }));
Assert.That(store.State.Count, Is.EqualTo(111));
}
/// <summary>
/// 测试 StoreBuilder 能够应用历史容量和 action 匹配策略配置。
/// </summary>
[Test]
public void StoreBuilder_Should_Apply_History_And_Action_Matching_Configuration()
{
var store = (Store<CounterState>)Store<CounterState>
.CreateBuilder()
.WithHistoryCapacity(6)
.WithActionMatching(StoreActionMatchingMode.IncludeAssignableTypes)
.AddReducer<IncrementActionBase>((state, action) => state with { Count = state.Count + action.Amount })
.Build(new CounterState(0, "Player"));
store.Dispatch(new DerivedIncrementAction(2));
Assert.That(store.ActionMatchingMode, Is.EqualTo(StoreActionMatchingMode.IncludeAssignableTypes));
Assert.That(store.HistoryCapacity, Is.EqualTo(6));
Assert.That(store.HistoryCount, Is.EqualTo(2));
Assert.That(store.State.Count, Is.EqualTo(2));
}
/// <summary>
/// 测试长时间运行的 middleware 不会长时间占用状态锁,
/// 使读取状态和新增订阅仍能在 dispatch 进行期间完成。
/// </summary>
[Test]
public void Dispatch_Should_Not_Block_State_Read_Or_Subscribe_While_Middleware_Is_Running()
{
using var entered = new ManualResetEventSlim(false);
using var release = new ManualResetEventSlim(false);
var store = CreateStore();
store.UseMiddleware(new BlockingMiddleware(entered, release));
var dispatchTask = Task.Run(() => store.Dispatch(new IncrementAction(1)));
Assert.That(entered.Wait(TimeSpan.FromSeconds(2)), Is.True, "middleware 未按预期进入阻塞阶段");
var stateReadTask = Task.Run(() => store.State.Count);
Assert.That(stateReadTask.Wait(TimeSpan.FromMilliseconds(200)), Is.True, "State 读取被 dispatch 长时间阻塞");
Assert.That(stateReadTask.Result, Is.EqualTo(0), "middleware 执行期间应仍能读取到提交前的状态快照");
var subscribeTask = Task.Run(() =>
{
var unRegister = store.Subscribe(_ => { });
unRegister.UnRegister();
});
Assert.That(subscribeTask.Wait(TimeSpan.FromMilliseconds(200)), Is.True, "Subscribe 被 dispatch 长时间阻塞");
release.Set();
Assert.That(dispatchTask.Wait(TimeSpan.FromSeconds(2)), Is.True, "dispatch 未在释放 middleware 后完成");
Assert.That(store.State.Count, Is.EqualTo(1));
}
/// <summary>
/// 创建一个带有基础 reducer 的测试 Store。
/// </summary>
/// <param name="initialState">可选初始状态。</param>
/// <returns>已配置基础 reducer 的 Store 实例。</returns>
private static Store<CounterState> CreateStore(CounterState? initialState = null)
{
var store = new Store<CounterState>(initialState ?? new CounterState(0, "Player"));
store.RegisterReducer<IncrementAction>((state, action) => state with { Count = state.Count + action.Amount });
store.RegisterReducer<RenameAction>((state, action) => state with { Name = action.Name });
return store;
}
/// <summary>
/// 用于测试的计数器状态。
/// 使用 record 保持逻辑不可变语义,便于 Store 基于状态快照进行比较和断言。
/// </summary>
/// <param name="Count">当前计数值。</param>
/// <param name="Name">当前名称。</param>
private sealed record CounterState(int Count, string Name);
/// <summary>
/// 表示增加计数的 action。
/// </summary>
/// <param name="Amount">要增加的数量。</param>
private sealed record IncrementAction(int Amount);
/// <summary>
/// 表示修改名称的 action。
/// </summary>
/// <param name="Name">新的名称。</param>
private sealed record RenameAction(string Name);
/// <summary>
/// 表示没有匹配 reducer 的 action用于验证无变更分发路径。
/// </summary>
private sealed record NoopAction;
/// <summary>
/// 表示参与多态匹配测试的 action 标记接口。
/// </summary>
private interface IIncrementActionMarker
{
/// <summary>
/// 获取增量值。
/// </summary>
int Amount { get; }
}
/// <summary>
/// 表示多态匹配测试中的基类 action。
/// </summary>
/// <param name="Amount">要增加的数量。</param>
private abstract record IncrementActionBase(int Amount);
/// <summary>
/// 表示多态匹配测试中的派生 action。
/// </summary>
/// <param name="Amount">要增加的数量。</param>
private sealed record DerivedIncrementAction(int Amount)
: IncrementActionBase(Amount), IIncrementActionMarker;
/// <summary>
/// 显式选择器实现,用于验证 IStateSelector 重载。
/// </summary>
private sealed class CounterNameSelector : IStateSelector<CounterState, string>
{
/// <summary>
/// 从状态中选择名称字段。
/// </summary>
/// <param name="state">完整状态。</param>
/// <returns>名称字段。</returns>
public string Select(CounterState state)
{
return state.Name;
}
}
/// <summary>
/// 将计数值按十位分桶比较的测试比较器。
/// 该比较器用于验证选择器只在局部状态“语义变化”时才触发通知。
/// </summary>
private sealed class TensBucketEqualityComparer : IEqualityComparer<int>
{
/// <summary>
/// 判断两个值是否落在同一个十位分桶中。
/// </summary>
/// <param name="x">左侧值。</param>
/// <param name="y">右侧值。</param>
/// <returns>若位于同一分桶则返回 <see langword="true"/>,否则返回 <see langword="false"/>。</returns>
public bool Equals(int x, int y)
{
return x / 10 == y / 10;
}
/// <summary>
/// 返回基于十位分桶的哈希码。
/// </summary>
/// <param name="obj">目标值。</param>
/// <returns>分桶哈希码。</returns>
public int GetHashCode(int obj)
{
return obj / 10;
}
}
/// <summary>
/// 用于测试 StoreBuilder 自定义状态比较器的比较器实现。
/// 该比较器忽略名称字段的大小写差异,并保持计数字段严格比较。
/// </summary>
private sealed class CounterStateNameInsensitiveComparer : IEqualityComparer<CounterState>
{
/// <summary>
/// 判断两个状态是否在业务语义上相等。
/// </summary>
/// <param name="x">左侧状态。</param>
/// <param name="y">右侧状态。</param>
/// <returns>若两个状态在计数相同且名称仅大小写不同,则返回 <see langword="true"/>。</returns>
public bool Equals(CounterState? x, CounterState? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
return x.Count == y.Count &&
string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 返回与业务语义一致的哈希码。
/// </summary>
/// <param name="obj">目标状态。</param>
/// <returns>忽略名称大小写后的哈希码。</returns>
public int GetHashCode(CounterState obj)
{
return HashCode.Combine(obj.Count, StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Name));
}
}
/// <summary>
/// 记录中间件调用顺序的测试中间件。
/// </summary>
private sealed class RecordingMiddleware(List<string> logs, string name) : IStoreMiddleware<CounterState>
{
/// <summary>
/// 记录当前中间件在分发前后的调用顺序。
/// </summary>
/// <param name="context">当前分发上下文。</param>
/// <param name="next">后续处理节点。</param>
public void Invoke(StoreDispatchContext<CounterState> context, Action next)
{
logs.Add($"{name}:before");
next();
logs.Add($"{name}:after");
}
}
/// <summary>
/// 用于验证 dispatch 管线在 middleware 执行期间不会占用状态锁的测试中间件。
/// </summary>
private sealed class BlockingMiddleware(ManualResetEventSlim entered, ManualResetEventSlim release)
: IStoreMiddleware<CounterState>
{
/// <summary>
/// 通知测试线程 middleware 已进入阻塞点,并等待释放信号后继续执行。
/// </summary>
/// <param name="context">当前分发上下文。</param>
/// <param name="next">后续处理节点。</param>
public void Invoke(StoreDispatchContext<CounterState> context, Action next)
{
entered.Set();
release.Wait(TimeSpan.FromSeconds(2));
next();
}
}
/// <summary>
/// 在中间件阶段尝试二次分发的测试中间件,用于验证重入保护。
/// </summary>
private sealed class NestedDispatchMiddleware(Store<CounterState> store) : IStoreMiddleware<CounterState>
{
/// <summary>
/// 标记是否已经触发过一次嵌套分发,避免因测试实现本身导致无限递归。
/// </summary>
private bool _hasTriggered;
/// <summary>
/// 在第一次进入中间件时执行嵌套分发。
/// </summary>
/// <param name="context">当前分发上下文。</param>
/// <param name="next">后续处理节点。</param>
public void Invoke(StoreDispatchContext<CounterState> context, Action next)
{
if (!_hasTriggered)
{
_hasTriggered = true;
store.Dispatch(new IncrementAction(1));
}
next();
}
}
}

View File

@ -0,0 +1,129 @@
using System.Globalization;
using GFramework.Core.Abstractions.Utility.Numeric;
using GFramework.Core.Extensions;
using GFramework.Core.Utility.Numeric;
namespace GFramework.Core.Tests.Utility;
[TestFixture]
public class NumericDisplayFormatterTests
{
[Test]
public void FormatCompact_ShouldReturnPlainText_WhenValueIsBelowThreshold()
{
var result = NumericDisplay.FormatCompact(950);
Assert.That(result, Is.EqualTo("950"));
}
[Test]
public void FormatCompact_ShouldFormatInt_AsCompactText()
{
var result = NumericDisplay.FormatCompact(1_200);
Assert.That(result, Is.EqualTo("1.2K"));
}
[Test]
public void FormatCompact_ShouldFormatLong_AsCompactText()
{
var result = NumericDisplay.FormatCompact(1_000_000L);
Assert.That(result, Is.EqualTo("1M"));
}
[Test]
public void FormatCompact_ShouldFormatDecimal_AsCompactText()
{
var result = NumericDisplay.FormatCompact(1_234.56m);
Assert.That(result, Is.EqualTo("1.2K"));
}
[Test]
public void FormatCompact_ShouldFormatNegativeValues()
{
var result = NumericDisplay.FormatCompact(-1_250);
Assert.That(result, Is.EqualTo("-1.3K"));
}
[Test]
public void FormatCompact_ShouldPromoteRoundedBoundary_ToNextSuffix()
{
var result = NumericDisplay.FormatCompact(999_950);
Assert.That(result, Is.EqualTo("1M"));
}
[Test]
public void Format_ShouldRespectFormatProvider()
{
var result = NumericDisplay.Format(1_234.5m, new NumericFormatOptions
{
CompactThreshold = 10_000m,
FormatProvider = CultureInfo.GetCultureInfo("de-DE")
});
Assert.That(result, Is.EqualTo("1234,5"));
}
[Test]
public void Format_ShouldUseGroupingBelowThreshold_WhenEnabled()
{
var result = NumericDisplay.Format(12_345, new NumericFormatOptions
{
CompactThreshold = 1_000_000m,
UseGroupingBelowThreshold = true,
FormatProvider = CultureInfo.InvariantCulture
});
Assert.That(result, Is.EqualTo("12,345"));
}
[Test]
public void Format_ShouldSupportCustomSuffixRule()
{
var rule = new NumericSuffixFormatRule("custom",
[
new NumericSuffixThreshold(10m, "X"),
new NumericSuffixThreshold(100m, "Y")
]);
var result = NumericDisplay.Format(123, new NumericFormatOptions
{
Rule = rule,
CompactThreshold = 10m,
FormatProvider = CultureInfo.InvariantCulture
});
Assert.That(result, Is.EqualTo("1.2Y"));
}
[Test]
public void Format_ShouldHandlePositiveInfinity()
{
var result = NumericDisplay.Format(double.PositiveInfinity, new NumericFormatOptions
{
FormatProvider = CultureInfo.InvariantCulture
});
Assert.That(result, Is.EqualTo("Infinity"));
}
[Test]
public void Format_ObjectOverload_ShouldDispatchToNumericFormatter()
{
var result = NumericDisplay.Format((object)1_234m);
Assert.That(result, Is.EqualTo("1.2K"));
}
[Test]
public void ToCompactString_ShouldUseNumericExtension()
{
var result = 15_320.ToCompactString();
Assert.That(result, Is.EqualTo("15.3K"));
}
}

View File

@ -1,14 +1,11 @@
using GFramework.Core.Abstractions.Architectures; using GFramework.Core.Abstractions.Architectures;
using GFramework.Core.Abstractions.Enums; using GFramework.Core.Abstractions.Enums;
using GFramework.Core.Abstractions.Environment; using GFramework.Core.Abstractions.Environment;
using GFramework.Core.Abstractions.Ioc;
using GFramework.Core.Abstractions.Lifecycle;
using GFramework.Core.Abstractions.Logging; using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Abstractions.Model; using GFramework.Core.Abstractions.Model;
using GFramework.Core.Abstractions.Systems; using GFramework.Core.Abstractions.Systems;
using GFramework.Core.Abstractions.Utility; using GFramework.Core.Abstractions.Utility;
using GFramework.Core.Environment; using GFramework.Core.Environment;
using GFramework.Core.Extensions;
using GFramework.Core.Logging; using GFramework.Core.Logging;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -17,41 +14,56 @@ namespace GFramework.Core.Architectures;
/// <summary> /// <summary>
/// 架构基类,提供系统、模型、工具等组件的注册与管理功能。 /// 架构基类,提供系统、模型、工具等组件的注册与管理功能。
/// 专注于生命周期管理、初始化流程控制和架构阶段转换。 /// 专注于生命周期管理、初始化流程控制和架构阶段转换。
///
/// 重构说明:此类已重构为协调器模式,将职责委托给专门的管理器:
/// - ArchitectureLifecycle: 生命周期管理
/// - ArchitectureComponentRegistry: 组件注册管理
/// - ArchitectureModules: 模块管理
/// </summary> /// </summary>
public abstract class Architecture( public abstract class Architecture : IArchitecture
{
#region Constructor
/// <summary>
/// 构造函数,初始化架构和管理器
/// </summary>
/// <param name="configuration">架构配置</param>
/// <param name="environment">环境配置</param>
/// <param name="services">服务管理器</param>
/// <param name="context">架构上下文</param>
protected Architecture(
IArchitectureConfiguration? configuration = null, IArchitectureConfiguration? configuration = null,
IEnvironment? environment = null, IEnvironment? environment = null,
IArchitectureServices? services = null, IArchitectureServices? services = null,
IArchitectureContext? context = null IArchitectureContext? context = null)
)
: IArchitecture
{
#region Module Management
/// <summary>
/// 注册中介行为管道
/// 用于配置Mediator框架的行为拦截和处理逻辑
/// </summary>
/// <typeparam name="TBehavior">行为类型,必须是引用类型</typeparam>
public void RegisterMediatorBehavior<TBehavior>() where TBehavior : class
{ {
_logger.Debug($"Registering mediator behavior: {typeof(TBehavior).Name}"); Configuration = configuration ?? new ArchitectureConfiguration();
Container.RegisterMediatorBehavior<TBehavior>(); Environment = environment ?? new DefaultEnvironment();
Services = services ?? new ArchitectureServices();
_context = context;
// 初始化 Logger
LoggerFactoryResolver.Provider = Configuration.LoggerProperties.LoggerFactoryProvider;
_logger = LoggerFactoryResolver.Provider.CreateLogger(GetType().Name);
// 初始化管理器
_lifecycle = new ArchitectureLifecycle(this, Configuration, Services, _logger);
_componentRegistry = new ArchitectureComponentRegistry(this, Configuration, Services, _lifecycle, _logger);
_modules = new ArchitectureModules(this, Services, _logger);
} }
#endregion
#region Lifecycle Hook Management
/// <summary> /// <summary>
/// 安装架构模块 /// 注册生命周期钩子
/// </summary> /// </summary>
/// <param name="module">要安装的模块</param> /// <param name="hook">生命周期钩子实例</param>
/// <returns>安装的模块实例</returns> /// <returns>注册的钩子实例</returns>
public IArchitectureModule InstallModule(IArchitectureModule module) public IArchitectureLifecycleHook RegisterLifecycleHook(IArchitectureLifecycleHook hook)
{ {
var name = module.GetType().Name; return _lifecycle.RegisterLifecycleHook(hook);
var logger = LoggerFactoryResolver.Provider.CreateLogger(name);
logger.Debug($"Installing module: {name}");
module.Install(this);
logger.Info($"Module installed: {name}");
return module;
} }
#endregion #endregion
@ -61,361 +73,99 @@ public abstract class Architecture(
/// <summary> /// <summary>
/// 获取架构配置对象 /// 获取架构配置对象
/// </summary> /// </summary>
private IArchitectureConfiguration Configuration { get; } = configuration ?? new ArchitectureConfiguration(); private IArchitectureConfiguration Configuration { get; }
/// <summary> /// <summary>
/// 获取环境配置对象 /// 获取环境配置对象
/// </summary> /// </summary>
private IEnvironment Environment { get; } = environment ?? new DefaultEnvironment(); private IEnvironment Environment { get; }
private IArchitectureServices Services { get; } = services ?? new ArchitectureServices();
/// <summary> /// <summary>
/// 获取依赖注入容 /// 获取服务管理
/// </summary> /// </summary>
private IIocContainer Container => Services.Container; private IArchitectureServices Services { get; }
/// <summary> /// <summary>
/// 当前架构的阶段 /// 当前架构的阶段
/// </summary> /// </summary>
public ArchitecturePhase CurrentPhase { get; private set; } public ArchitecturePhase CurrentPhase => _lifecycle.CurrentPhase;
/// <summary> /// <summary>
/// 架构上下文 /// 架构上下文
/// </summary> /// </summary>
public IArchitectureContext Context => _context!; public IArchitectureContext Context => _context!;
/// <summary>
/// 获取一个布尔值,指示当前架构是否处于就绪状态
/// </summary>
public bool IsReady => _lifecycle.IsReady;
/// <summary>
/// 获取用于配置服务集合的委托
/// 默认实现返回null子类可以重写此属性以提供自定义配置逻辑
/// </summary>
public virtual Action<IServiceCollection>? Configurator => null;
/// <summary>
/// 阶段变更事件(用于测试和扩展)
/// </summary>
public event Action<ArchitecturePhase>? PhaseChanged
{
add => _lifecycle.PhaseChanged += value;
remove => _lifecycle.PhaseChanged -= value;
}
#endregion #endregion
#region Fields #region Fields
private readonly TaskCompletionSource _readyTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>
/// 获取一个布尔值,指示当前架构是否处于就绪状态。
/// 当前架构的阶段等于 ArchitecturePhase.Ready 时返回 true否则返回 false。
/// </summary>
public bool IsReady => CurrentPhase == ArchitecturePhase.Ready;
/// <summary>
/// 待初始化组件的去重集合。
/// 用于存储需要初始化的组件实例,确保每个组件仅被初始化一次。
/// </summary>
private readonly HashSet<IInitializable> _pendingInitializableSet = [];
/// <summary>
/// 存储所有待初始化的组件(统一管理,保持注册顺序)
/// </summary>
private readonly List<IInitializable> _pendingInitializableList = [];
/// <summary>
/// 可销毁组件的去重集合(支持 IDestroyable 和 IAsyncDestroyable
/// </summary>
private readonly HashSet<object> _disposableSet = [];
/// <summary>
/// 存储所有需要销毁的组件(统一管理,保持注册逆序销毁)
/// </summary>
private readonly List<object> _disposables = [];
/// <summary>
/// 生命周期感知对象列表
/// </summary>
private readonly List<IArchitectureLifecycleHook> _lifecycleHooks = [];
/// <summary>
/// 标记架构是否已初始化完成
/// </summary>
private bool _mInitialized;
/// <summary> /// <summary>
/// 日志记录器实例 /// 日志记录器实例
/// </summary> /// </summary>
private ILogger _logger = null!; private readonly ILogger _logger;
/// <summary> /// <summary>
/// 架构上下文实例 /// 架构上下文实例
/// </summary> /// </summary>
private IArchitectureContext? _context = context; private IArchitectureContext? _context;
/// <summary>
/// 生命周期管理器
/// </summary>
private readonly ArchitectureLifecycle _lifecycle;
/// <summary>
/// 组件注册管理器
/// </summary>
private readonly ArchitectureComponentRegistry _componentRegistry;
/// <summary>
/// 模块管理器
/// </summary>
private readonly ArchitectureModules _modules;
#endregion #endregion
#region Lifecycle Management #region Module Management
/// <summary> /// <summary>
/// 进入指定的架构阶段,并执行相应的生命周期管理操作 /// 注册中介行为管道
/// 用于配置Mediator框架的行为拦截和处理逻辑
/// </summary> /// </summary>
/// <param name="next">要进入的下一个架构阶段</param> /// <typeparam name="TBehavior">行为类型,必须是引用类型</typeparam>
/// <exception cref="InvalidOperationException">当阶段转换不被允许时抛出异常</exception> public void RegisterMediatorBehavior<TBehavior>() where TBehavior : class
protected virtual void EnterPhase(ArchitecturePhase next)
{ {
// 验证阶段转换 _modules.RegisterMediatorBehavior<TBehavior>();
ValidatePhaseTransition(next);
// 执行阶段转换
var previousPhase = CurrentPhase;
CurrentPhase = next;
if (previousPhase != next)
_logger.Info($"Architecture phase changed: {previousPhase} -> {next}");
// 通知阶段变更
NotifyPhase(next);
NotifyPhaseAwareObjects(next);
} }
/// <summary> /// <summary>
/// 验证阶段转换是否合法 /// 安装架构模块
/// </summary> /// </summary>
/// <param name="next">目标阶段</param> /// <param name="module">要安装的模块</param>
/// <exception cref="InvalidOperationException">当阶段转换不合法时抛出</exception> /// <returns>安装的模块实例</returns>
private void ValidatePhaseTransition(ArchitecturePhase next) public IArchitectureModule InstallModule(IArchitectureModule module)
{ {
// 不需要严格验证,直接返回 return _modules.InstallModule(module);
if (!Configuration.ArchitectureProperties.StrictPhaseValidation)
return;
// FailedInitialization 可以从任何阶段转换,直接返回
if (next == ArchitecturePhase.FailedInitialization)
return;
// 检查转换是否在允许列表中
if (ArchitectureConstants.PhaseTransitions.TryGetValue(CurrentPhase, out var allowed) &&
allowed.Contains(next))
return;
// 转换不合法,抛出异常
var errorMsg = $"Invalid phase transition: {CurrentPhase} -> {next}";
_logger.Fatal(errorMsg);
throw new InvalidOperationException(errorMsg);
}
/// <summary>
/// 通知所有架构阶段感知对象阶段变更
/// </summary>
/// <param name="phase">新阶段</param>
private void NotifyPhaseAwareObjects(ArchitecturePhase phase)
{
foreach (var obj in Container.GetAll<IArchitecturePhaseListener>())
{
_logger.Trace($"Notifying phase-aware object {obj.GetType().Name} of phase change to {phase}");
obj.OnArchitecturePhase(phase);
}
}
/// <summary>
/// 通知所有生命周期钩子当前阶段变更
/// </summary>
/// <param name="phase">当前架构阶段</param>
private void NotifyPhase(ArchitecturePhase phase)
{
foreach (var hook in _lifecycleHooks)
{
hook.OnPhase(phase, this);
_logger.Trace($"Notifying lifecycle hook {hook.GetType().Name} of phase {phase}");
}
}
/// <summary>
/// 注册生命周期钩子
/// </summary>
/// <param name="hook">生命周期钩子实例</param>
/// <returns>注册的钩子实例</returns>
public IArchitectureLifecycleHook RegisterLifecycleHook(IArchitectureLifecycleHook hook)
{
if (CurrentPhase >= ArchitecturePhase.Ready && !Configuration.ArchitectureProperties.AllowLateRegistration)
throw new InvalidOperationException(
"Cannot register lifecycle hook after architecture is Ready");
_lifecycleHooks.Add(hook);
return hook;
}
/// <summary>
/// 统一的组件生命周期注册逻辑
/// </summary>
/// <param name="component">要注册的组件</param>
private void RegisterLifecycleComponent<T>(T component)
{
// 处理初始化
if (component is IInitializable initializable)
{
if (!_mInitialized)
{
// 原子去重HashSet.Add 返回 true 表示添加成功(之前不存在)
if (_pendingInitializableSet.Add(initializable))
{
_pendingInitializableList.Add(initializable);
_logger.Trace($"Added {component.GetType().Name} to pending initialization queue");
}
}
else
{
throw new InvalidOperationException(
"Cannot initialize component after Architecture is Ready");
}
}
// 处理销毁(支持 IDestroyable 或 IAsyncDestroyable
if (component is not (IDestroyable or IAsyncDestroyable)) return;
// 原子去重HashSet.Add 返回 true 表示添加成功(之前不存在)
if (!_disposableSet.Add(component)) return;
_disposables.Add(component);
_logger.Trace($"Registered {component.GetType().Name} for destruction");
}
/// <summary>
/// 初始化所有待初始化的组件
/// </summary>
/// <param name="asyncMode">是否使用异步模式</param>
private async Task InitializeAllComponentsAsync(bool asyncMode)
{
_logger.Info($"Initializing {_pendingInitializableList.Count} components");
// 按类型分组初始化(保持原有的阶段划分)
var utilities = _pendingInitializableList.OfType<IContextUtility>().ToList();
var models = _pendingInitializableList.OfType<IModel>().ToList();
var systems = _pendingInitializableList.OfType<ISystem>().ToList();
// 1. 工具初始化阶段(始终进入阶段,仅在有组件时执行初始化)
EnterPhase(ArchitecturePhase.BeforeUtilityInit);
if (utilities.Count != 0)
{
_logger.Info($"Initializing {utilities.Count} context utilities");
foreach (var utility in utilities)
{
_logger.Debug($"Initializing utility: {utility.GetType().Name}");
await InitializeComponentAsync(utility, asyncMode);
}
_logger.Info("All context utilities initialized");
}
EnterPhase(ArchitecturePhase.AfterUtilityInit);
// 2. 模型初始化阶段(始终进入阶段,仅在有组件时执行初始化)
EnterPhase(ArchitecturePhase.BeforeModelInit);
if (models.Count != 0)
{
_logger.Info($"Initializing {models.Count} models");
foreach (var model in models)
{
_logger.Debug($"Initializing model: {model.GetType().Name}");
await InitializeComponentAsync(model, asyncMode);
}
_logger.Info("All models initialized");
}
EnterPhase(ArchitecturePhase.AfterModelInit);
// 3. 系统初始化阶段(始终进入阶段,仅在有组件时执行初始化)
EnterPhase(ArchitecturePhase.BeforeSystemInit);
if (systems.Count != 0)
{
_logger.Info($"Initializing {systems.Count} systems");
foreach (var system in systems)
{
_logger.Debug($"Initializing system: {system.GetType().Name}");
await InitializeComponentAsync(system, asyncMode);
}
_logger.Info("All systems initialized");
}
EnterPhase(ArchitecturePhase.AfterSystemInit);
_pendingInitializableList.Clear();
_pendingInitializableSet.Clear();
_logger.Info("All components initialized");
}
/// <summary>
/// 异步初始化单个组件
/// </summary>
/// <param name="component">要初始化的组件</param>
/// <param name="asyncMode">是否使用异步模式</param>
private static async Task InitializeComponentAsync(IInitializable component, bool asyncMode)
{
if (asyncMode && component is IAsyncInitializable asyncInit)
await asyncInit.InitializeAsync();
else
component.Initialize();
}
/// <summary>
/// 抽象初始化方法,由子类重写以进行自定义初始化操作
/// </summary>
protected abstract void OnInitialize();
/// <summary>
/// 异步销毁架构及所有组件
/// </summary>
public virtual async ValueTask DestroyAsync()
{
// 检查当前阶段,如果已经处于销毁或已销毁状态则直接返回
if (CurrentPhase >= ArchitecturePhase.Destroying)
{
_logger.Warn("Architecture destroy called but already in destroying/destroyed state");
return;
}
// 进入销毁阶段
_logger.Info("Starting architecture destruction");
EnterPhase(ArchitecturePhase.Destroying);
// 销毁所有实现了 IAsyncDestroyable 或 IDestroyable 的组件(按注册逆序销毁)
_logger.Info($"Destroying {_disposables.Count} disposable components");
for (var i = _disposables.Count - 1; i >= 0; i--)
{
var component = _disposables[i];
try
{
_logger.Debug($"Destroying component: {component.GetType().Name}");
// 优先使用异步销毁
if (component is IAsyncDestroyable asyncDestroyable)
{
await asyncDestroyable.DestroyAsync();
}
else if (component is IDestroyable destroyable)
{
destroyable.Destroy();
}
}
catch (Exception ex)
{
_logger.Error($"Error destroying {component.GetType().Name}", ex);
// 继续销毁其他组件,不会因为一个组件失败而中断
}
}
_disposables.Clear();
_disposableSet.Clear();
// 销毁服务模块
await Services.ModuleManager.DestroyAllAsync();
Container.Clear();
// 进入已销毁阶段
EnterPhase(ArchitecturePhase.Destroyed);
_logger.Info("Architecture destruction completed");
}
/// <summary>
/// 销毁架构并清理所有组件资源(同步方法,保留用于向后兼容)
/// </summary>
[Obsolete("建议使用 DestroyAsync() 以支持异步清理")]
public virtual void Destroy()
{
DestroyAsync().AsTask().GetAwaiter().GetResult();
} }
#endregion #endregion
@ -423,177 +173,77 @@ public abstract class Architecture(
#region Component Registration #region Component Registration
/// <summary> /// <summary>
/// 验证是否允许注册组件 /// 注册一个系统到架构中
/// </summary> /// </summary>
/// <param name="componentType">组件类型描述</param> /// <typeparam name="TSystem">要注册的系统类型</typeparam>
/// <exception cref="InvalidOperationException">当不允许注册时抛出</exception>
private void ValidateRegistration(string componentType)
{
if (CurrentPhase < ArchitecturePhase.Ready ||
Configuration.ArchitectureProperties.AllowLateRegistration) return;
var errorMsg = $"Cannot register {componentType} after Architecture is Ready";
_logger.Error(errorMsg);
throw new InvalidOperationException(errorMsg);
}
/// <summary>
/// 注册一个系统到架构中。
/// 若当前未初始化,则暂存至待初始化列表;否则立即初始化该系统。
/// </summary>
/// <typeparam name="TSystem">要注册的系统类型必须实现ISystem接口</typeparam>
/// <param name="system">要注册的系统实例</param> /// <param name="system">要注册的系统实例</param>
/// <returns>注册成功的系统实例</returns> /// <returns>注册成功的系统实例</returns>
public TSystem RegisterSystem<TSystem>(TSystem system) where TSystem : ISystem public TSystem RegisterSystem<TSystem>(TSystem system) where TSystem : ISystem
{ {
ValidateRegistration("system"); return _componentRegistry.RegisterSystem(system);
_logger.Debug($"Registering system: {typeof(TSystem).Name}");
system.SetContext(Context);
Container.RegisterPlurality(system);
// 处理生命周期
RegisterLifecycleComponent(system);
_logger.Info($"System registered: {typeof(TSystem).Name}");
return system;
} }
/// <summary> /// <summary>
/// 注册系统类型,由 DI 容器自动创建实例 /// 注册系统类型,由 DI 容器自动创建实例
/// </summary> /// </summary>
/// <typeparam name="T">系统类型</typeparam> /// <typeparam name="T">系统类型</typeparam>
/// <param name="onCreated">可选的实例创建后回调,用于自定义配置</param> /// <param name="onCreated">可选的实例创建后回调</param>
public void RegisterSystem<T>(Action<T>? onCreated = null) where T : class, ISystem public void RegisterSystem<T>(Action<T>? onCreated = null) where T : class, ISystem
{ {
ValidateRegistration("system"); _componentRegistry.RegisterSystem(onCreated);
_logger.Debug($"Registering system type: {typeof(T).Name}");
Container.RegisterFactory<T>(sp =>
{
// 1. DI 创建实例
var system = ActivatorUtilities.CreateInstance<T>(sp);
// 2. 框架默认处理
system.SetContext(Context);
RegisterLifecycleComponent(system);
// 3. 用户自定义处理(钩子)
onCreated?.Invoke(system);
_logger.Debug($"System created: {typeof(T).Name}");
return system;
});
_logger.Info($"System type registered: {typeof(T).Name}");
} }
/// <summary> /// <summary>
/// 注册一个模型到架构中。 /// 注册一个模型到架构中
/// 若当前未初始化,则暂存至待初始化列表;否则立即初始化该模型。
/// </summary> /// </summary>
/// <typeparam name="TModel">要注册的模型类型必须实现IModel接口</typeparam> /// <typeparam name="TModel">要注册的模型类型</typeparam>
/// <param name="model">要注册的模型实例</param> /// <param name="model">要注册的模型实例</param>
/// <returns>注册成功的模型实例</returns> /// <returns>注册成功的模型实例</returns>
public TModel RegisterModel<TModel>(TModel model) where TModel : IModel public TModel RegisterModel<TModel>(TModel model) where TModel : IModel
{ {
ValidateRegistration("model"); return _componentRegistry.RegisterModel(model);
_logger.Debug($"Registering model: {typeof(TModel).Name}");
model.SetContext(Context);
Container.RegisterPlurality(model);
// 处理生命周期
RegisterLifecycleComponent(model);
_logger.Info($"Model registered: {typeof(TModel).Name}");
return model;
} }
/// <summary> /// <summary>
/// 注册模型类型,由 DI 容器自动创建实例 /// 注册模型类型,由 DI 容器自动创建实例
/// </summary> /// </summary>
/// <typeparam name="T">模型类型</typeparam> /// <typeparam name="T">模型类型</typeparam>
/// <param name="onCreated">可选的实例创建后回调,用于自定义配置</param> /// <param name="onCreated">可选的实例创建后回调</param>
public void RegisterModel<T>(Action<T>? onCreated = null) where T : class, IModel public void RegisterModel<T>(Action<T>? onCreated = null) where T : class, IModel
{ {
ValidateRegistration("model"); _componentRegistry.RegisterModel(onCreated);
_logger.Debug($"Registering model type: {typeof(T).Name}");
Container.RegisterFactory<T>(sp =>
{
var model = ActivatorUtilities.CreateInstance<T>(sp);
model.SetContext(Context);
RegisterLifecycleComponent(model);
// 用户自定义钩子
onCreated?.Invoke(model);
_logger.Debug($"Model created: {typeof(T).Name}");
return model;
});
_logger.Info($"Model type registered: {typeof(T).Name}");
} }
/// <summary> /// <summary>
/// 注册一个工具到架构中 /// 注册一个工具到架构中
/// </summary> /// </summary>
/// <typeparam name="TUtility">要注册的工具类型必须实现IUtility接口</typeparam> /// <typeparam name="TUtility">要注册的工具类型</typeparam>
/// <param name="utility">要注册的工具实例</param> /// <param name="utility">要注册的工具实例</param>
/// <returns>注册成功的工具实例</returns> /// <returns>注册成功的工具实例</returns>
public TUtility RegisterUtility<TUtility>(TUtility utility) where TUtility : IUtility public TUtility RegisterUtility<TUtility>(TUtility utility) where TUtility : IUtility
{ {
_logger.Debug($"Registering utility: {typeof(TUtility).Name}"); return _componentRegistry.RegisterUtility(utility);
// 处理上下文工具类型的设置和生命周期管理
utility.IfType<IContextUtility>(contextUtility =>
{
contextUtility.SetContext(Context);
// 处理生命周期
RegisterLifecycleComponent(contextUtility);
});
Container.RegisterPlurality(utility);
_logger.Info($"Utility registered: {typeof(TUtility).Name}");
return utility;
} }
/// <summary> /// <summary>
/// 注册工具类型,由 DI 容器自动创建实例 /// 注册工具类型,由 DI 容器自动创建实例
/// </summary> /// </summary>
/// <typeparam name="T">工具类型</typeparam> /// <typeparam name="T">工具类型</typeparam>
/// <param name="onCreated">可选的实例创建后回调,用于自定义配置</param> /// <param name="onCreated">可选的实例创建后回调</param>
public void RegisterUtility<T>(Action<T>? onCreated = null) where T : class, IUtility public void RegisterUtility<T>(Action<T>? onCreated = null) where T : class, IUtility
{ {
_logger.Debug($"Registering utility type: {typeof(T).Name}"); _componentRegistry.RegisterUtility(onCreated);
Container.RegisterFactory<T>(sp =>
{
var utility = ActivatorUtilities.CreateInstance<T>(sp);
// 如果是 IContextUtility设置上下文
if (utility is IContextUtility contextUtility)
{
contextUtility.SetContext(Context);
RegisterLifecycleComponent(contextUtility);
}
// 用户自定义钩子
onCreated?.Invoke(utility);
_logger.Debug($"Utility created: {typeof(T).Name}");
return utility;
});
_logger.Info($"Utility type registered: {typeof(T).Name}");
} }
#endregion #endregion
#region Initialization #region Initialization
/// <summary>
/// 抽象初始化方法,由子类重写以进行自定义初始化操作
/// </summary>
protected abstract void OnInitialize();
/// <summary> /// <summary>
/// 同步初始化方法,阻塞当前线程直到初始化完成 /// 同步初始化方法,阻塞当前线程直到初始化完成
/// </summary> /// </summary>
@ -606,7 +256,7 @@ public abstract class Architecture(
catch (Exception e) catch (Exception e)
{ {
_logger.Error("Architecture initialization failed:", e); _logger.Error("Architecture initialization failed:", e);
EnterPhase(ArchitecturePhase.FailedInitialization); _lifecycle.MarkAsFailed(e);
throw; throw;
} }
} }
@ -614,7 +264,6 @@ public abstract class Architecture(
/// <summary> /// <summary>
/// 异步初始化方法返回Task以便调用者可以等待初始化完成 /// 异步初始化方法返回Task以便调用者可以等待初始化完成
/// </summary> /// </summary>
/// <returns>表示异步初始化操作的Task</returns>
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
try try
@ -624,32 +273,29 @@ public abstract class Architecture(
catch (Exception e) catch (Exception e)
{ {
_logger.Error("Architecture initialization failed:", e); _logger.Error("Architecture initialization failed:", e);
EnterPhase(ArchitecturePhase.FailedInitialization); _lifecycle.MarkAsFailed(e);
throw; throw;
} }
} }
/// <summary> /// <summary>
/// 异步初始化架构内部组件,包括上下文、模型和系统的初始化 /// 异步初始化架构内部组件
/// </summary> /// </summary>
/// <param name="asyncMode">是否启用异步模式进行组件初始化</param> /// <param name="asyncMode">是否启用异步模式</param>
/// <returns>异步任务,表示初始化操作的完成</returns>
private async Task InitializeInternalAsync(bool asyncMode) private async Task InitializeInternalAsync(bool asyncMode)
{ {
// === 基础上下文 & Logger === // === 基础环境初始化 ===
LoggerFactoryResolver.Provider = Configuration.LoggerProperties.LoggerFactoryProvider;
_logger = LoggerFactoryResolver.Provider.CreateLogger(GetType().Name);
Environment.Initialize(); Environment.Initialize();
// 注册内置服务模块 // 注册内置服务模块
Services.ModuleManager.RegisterBuiltInModules(Container); Services.ModuleManager.RegisterBuiltInModules(Services.Container);
// 将 Environment 注册到容器(如果尚未注册) // 将 Environment 注册到容器
if (!Container.Contains<IEnvironment>()) if (!Services.Container.Contains<IEnvironment>())
Container.RegisterPlurality(Environment); Services.Container.RegisterPlurality(Environment);
// 初始化架构上下文(如果尚未初始化) // 初始化架构上下文
_context ??= new ArchitectureContext(Container); _context ??= new ArchitectureContext(Services.Container);
GameContext.Bind(GetType(), _context); GameContext.Bind(GetType(), _context);
// 为服务设置上下文 // 为服务设置上下文
@ -660,7 +306,7 @@ public abstract class Architecture(
} }
// 执行服务钩子 // 执行服务钩子
Container.ExecuteServicesHook(Configurator); Services.Container.ExecuteServicesHook(Configurator);
// 初始化服务模块 // 初始化服务模块
await Services.ModuleManager.InitializeAllAsync(asyncMode); await Services.ModuleManager.InitializeAllAsync(asyncMode);
@ -671,39 +317,44 @@ public abstract class Architecture(
_logger.Debug("User OnInitialize() completed"); _logger.Debug("User OnInitialize() completed");
// === 组件初始化阶段 === // === 组件初始化阶段 ===
await InitializeAllComponentsAsync(asyncMode); await _lifecycle.InitializeAllComponentsAsync(asyncMode);
// === 初始化完成阶段 === // === 初始化完成阶段 ===
Container.Freeze(); Services.Container.Freeze();
_logger.Info("IOC container frozen"); _logger.Info("IOC container frozen");
_mInitialized = true; _lifecycle.MarkAsReady();
EnterPhase(ArchitecturePhase.Ready);
// 🔥 释放 Ready await
_readyTcs.TrySetResult();
_logger.Info($"Architecture {GetType().Name} is ready - all components initialized"); _logger.Info($"Architecture {GetType().Name} is ready - all components initialized");
} }
/// <summary> /// <summary>
/// 等待架构初始化完成Ready 阶段) /// 等待架构初始化完成Ready 阶段)
/// 如果架构已经处于就绪状态,则立即返回已完成的任务;
/// 否则返回一个任务,该任务将在架构进入就绪状态时完成。
/// </summary> /// </summary>
/// <returns>表示等待操作的Task对象</returns>
public Task WaitUntilReadyAsync() public Task WaitUntilReadyAsync()
{ {
return IsReady ? Task.CompletedTask : _readyTcs.Task; return _lifecycle.WaitUntilReadyAsync();
}
#endregion
#region Destruction
/// <summary>
/// 异步销毁架构及所有组件
/// </summary>
public virtual async ValueTask DestroyAsync()
{
await _lifecycle.DestroyAsync();
} }
/// <summary> /// <summary>
/// 获取用于配置服务集合的委托 /// 销毁架构并清理所有组件资源(同步方法,保留用于向后兼容)
/// 默认实现返回null子类可以重写此属性以提供自定义配置逻辑
/// </summary> /// </summary>
/// <value> [Obsolete("建议使用 DestroyAsync() 以支持异步清理")]
/// 一个可为空的Action委托用于配置IServiceCollection实例 public virtual void Destroy()
/// </value> {
public virtual Action<IServiceCollection>? Configurator => null; _lifecycle.Destroy();
}
#endregion #endregion
} }

View File

@ -0,0 +1,204 @@
using GFramework.Core.Abstractions.Architectures;
using GFramework.Core.Abstractions.Enums;
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Abstractions.Model;
using GFramework.Core.Abstractions.Systems;
using GFramework.Core.Abstractions.Utility;
using GFramework.Core.Extensions;
using Microsoft.Extensions.DependencyInjection;
namespace GFramework.Core.Architectures;
/// <summary>
/// 架构组件注册管理器
/// 负责管理 System、Model、Utility 的注册
/// </summary>
internal sealed class ArchitectureComponentRegistry(
IArchitecture architecture,
IArchitectureConfiguration configuration,
IArchitectureServices services,
ArchitectureLifecycle lifecycle,
ILogger logger)
{
#region Validation
/// <summary>
/// 验证是否允许注册组件
/// </summary>
/// <param name="componentType">组件类型描述</param>
/// <exception cref="InvalidOperationException">当不允许注册时抛出</exception>
private void ValidateRegistration(string componentType)
{
if (lifecycle.CurrentPhase < ArchitecturePhase.Ready ||
configuration.ArchitectureProperties.AllowLateRegistration) return;
var errorMsg = $"Cannot register {componentType} after Architecture is Ready";
logger.Error(errorMsg);
throw new InvalidOperationException(errorMsg);
}
#endregion
#region System Registration
/// <summary>
/// 注册一个系统到架构中
/// </summary>
/// <typeparam name="TSystem">要注册的系统类型</typeparam>
/// <param name="system">要注册的系统实例</param>
/// <returns>注册成功的系统实例</returns>
public TSystem RegisterSystem<TSystem>(TSystem system) where TSystem : ISystem
{
ValidateRegistration("system");
logger.Debug($"Registering system: {typeof(TSystem).Name}");
system.SetContext(architecture.Context);
services.Container.RegisterPlurality(system);
// 处理生命周期
lifecycle.RegisterLifecycleComponent(system);
logger.Info($"System registered: {typeof(TSystem).Name}");
return system;
}
/// <summary>
/// 注册系统类型,由 DI 容器自动创建实例
/// </summary>
/// <typeparam name="T">系统类型</typeparam>
/// <param name="onCreated">可选的实例创建后回调</param>
public void RegisterSystem<T>(Action<T>? onCreated = null) where T : class, ISystem
{
ValidateRegistration("system");
logger.Debug($"Registering system type: {typeof(T).Name}");
services.Container.RegisterFactory<T>(sp =>
{
// 1. DI 创建实例
var system = ActivatorUtilities.CreateInstance<T>(sp);
// 2. 框架默认处理
system.SetContext(architecture.Context);
lifecycle.RegisterLifecycleComponent(system);
// 3. 用户自定义处理(钩子)
onCreated?.Invoke(system);
logger.Debug($"System created: {typeof(T).Name}");
return system;
});
logger.Info($"System type registered: {typeof(T).Name}");
}
#endregion
#region Model Registration
/// <summary>
/// 注册一个模型到架构中
/// </summary>
/// <typeparam name="TModel">要注册的模型类型</typeparam>
/// <param name="model">要注册的模型实例</param>
/// <returns>注册成功的模型实例</returns>
public TModel RegisterModel<TModel>(TModel model) where TModel : IModel
{
ValidateRegistration("model");
logger.Debug($"Registering model: {typeof(TModel).Name}");
model.SetContext(architecture.Context);
services.Container.RegisterPlurality(model);
// 处理生命周期
lifecycle.RegisterLifecycleComponent(model);
logger.Info($"Model registered: {typeof(TModel).Name}");
return model;
}
/// <summary>
/// 注册模型类型,由 DI 容器自动创建实例
/// </summary>
/// <typeparam name="T">模型类型</typeparam>
/// <param name="onCreated">可选的实例创建后回调</param>
public void RegisterModel<T>(Action<T>? onCreated = null) where T : class, IModel
{
ValidateRegistration("model");
logger.Debug($"Registering model type: {typeof(T).Name}");
services.Container.RegisterFactory<T>(sp =>
{
var model = ActivatorUtilities.CreateInstance<T>(sp);
model.SetContext(architecture.Context);
lifecycle.RegisterLifecycleComponent(model);
// 用户自定义钩子
onCreated?.Invoke(model);
logger.Debug($"Model created: {typeof(T).Name}");
return model;
});
logger.Info($"Model type registered: {typeof(T).Name}");
}
#endregion
#region Utility Registration
/// <summary>
/// 注册一个工具到架构中
/// </summary>
/// <typeparam name="TUtility">要注册的工具类型</typeparam>
/// <param name="utility">要注册的工具实例</param>
/// <returns>注册成功的工具实例</returns>
public TUtility RegisterUtility<TUtility>(TUtility utility) where TUtility : IUtility
{
logger.Debug($"Registering utility: {typeof(TUtility).Name}");
// 处理上下文工具类型的设置和生命周期管理
utility.IfType<IContextUtility>(contextUtility =>
{
contextUtility.SetContext(architecture.Context);
// 处理生命周期
lifecycle.RegisterLifecycleComponent(contextUtility);
});
services.Container.RegisterPlurality(utility);
logger.Info($"Utility registered: {typeof(TUtility).Name}");
return utility;
}
/// <summary>
/// 注册工具类型,由 DI 容器自动创建实例
/// </summary>
/// <typeparam name="T">工具类型</typeparam>
/// <param name="onCreated">可选的实例创建后回调</param>
public void RegisterUtility<T>(Action<T>? onCreated = null) where T : class, IUtility
{
logger.Debug($"Registering utility type: {typeof(T).Name}");
services.Container.RegisterFactory<T>(sp =>
{
var utility = ActivatorUtilities.CreateInstance<T>(sp);
// 如果是 IContextUtility设置上下文
if (utility is IContextUtility contextUtility)
{
contextUtility.SetContext(architecture.Context);
lifecycle.RegisterLifecycleComponent(contextUtility);
}
// 用户自定义钩子
onCreated?.Invoke(utility);
logger.Debug($"Utility created: {typeof(T).Name}");
return utility;
});
logger.Info($"Utility type registered: {typeof(T).Name}");
}
#endregion
}

View File

@ -0,0 +1,420 @@
using GFramework.Core.Abstractions.Architectures;
using GFramework.Core.Abstractions.Enums;
using GFramework.Core.Abstractions.Lifecycle;
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Abstractions.Model;
using GFramework.Core.Abstractions.Systems;
using GFramework.Core.Abstractions.Utility;
namespace GFramework.Core.Architectures;
/// <summary>
/// 架构生命周期管理器
/// 负责管理架构的阶段转换、组件初始化和销毁
/// </summary>
internal sealed class ArchitectureLifecycle(
IArchitecture architecture,
IArchitectureConfiguration configuration,
IArchitectureServices services,
ILogger logger)
{
#region Lifecycle Hook Management
/// <summary>
/// 注册生命周期钩子
/// </summary>
/// <param name="hook">生命周期钩子实例</param>
/// <returns>注册的钩子实例</returns>
public IArchitectureLifecycleHook RegisterLifecycleHook(IArchitectureLifecycleHook hook)
{
if (CurrentPhase >= ArchitecturePhase.Ready && !configuration.ArchitectureProperties.AllowLateRegistration)
throw new InvalidOperationException(
"Cannot register lifecycle hook after architecture is Ready");
_lifecycleHooks.Add(hook);
return hook;
}
#endregion
#region Component Lifecycle Management
/// <summary>
/// 统一的组件生命周期注册逻辑
/// </summary>
/// <param name="component">要注册的组件</param>
public void RegisterLifecycleComponent(object component)
{
// 处理初始化
if (component is IInitializable initializable)
{
if (!_initialized)
{
// 原子去重HashSet.Add 返回 true 表示添加成功(之前不存在)
if (_pendingInitializableSet.Add(initializable))
{
_pendingInitializableList.Add(initializable);
logger.Trace($"Added {component.GetType().Name} to pending initialization queue");
}
}
else
{
throw new InvalidOperationException(
"Cannot initialize component after Architecture is Ready");
}
}
// 处理销毁(支持 IDestroyable 或 IAsyncDestroyable
if (component is not (IDestroyable or IAsyncDestroyable)) return;
// 原子去重HashSet.Add 返回 true 表示添加成功(之前不存在)
if (!_disposableSet.Add(component)) return;
_disposables.Add(component);
logger.Trace($"Registered {component.GetType().Name} for destruction");
}
#endregion
#region Fields
private readonly TaskCompletionSource _readyTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>
/// 待初始化组件的去重集合
/// </summary>
private readonly HashSet<IInitializable> _pendingInitializableSet = [];
/// <summary>
/// 存储所有待初始化的组件(统一管理,保持注册顺序)
/// </summary>
private readonly List<IInitializable> _pendingInitializableList = [];
/// <summary>
/// 可销毁组件的去重集合(支持 IDestroyable 和 IAsyncDestroyable
/// </summary>
private readonly HashSet<object> _disposableSet = [];
/// <summary>
/// 存储所有需要销毁的组件(统一管理,保持注册逆序销毁)
/// </summary>
private readonly List<object> _disposables = [];
/// <summary>
/// 生命周期感知对象列表
/// </summary>
private readonly List<IArchitectureLifecycleHook> _lifecycleHooks = [];
/// <summary>
/// 标记架构是否已初始化完成
/// </summary>
private bool _initialized;
#endregion
#region Properties
/// <summary>
/// 当前架构的阶段
/// </summary>
public ArchitecturePhase CurrentPhase { get; private set; }
/// <summary>
/// 获取一个布尔值,指示当前架构是否处于就绪状态
/// </summary>
public bool IsReady => CurrentPhase == ArchitecturePhase.Ready;
/// <summary>
/// 获取一个布尔值,指示架构是否已初始化
/// </summary>
public bool IsInitialized => _initialized;
/// <summary>
/// 阶段变更事件(用于测试和扩展)
/// </summary>
public event Action<ArchitecturePhase>? PhaseChanged;
#endregion
#region Phase Management
/// <summary>
/// 进入指定的架构阶段,并执行相应的生命周期管理操作
/// </summary>
/// <param name="next">要进入的下一个架构阶段</param>
/// <exception cref="InvalidOperationException">当阶段转换不被允许时抛出异常</exception>
public void EnterPhase(ArchitecturePhase next)
{
// 验证阶段转换
ValidatePhaseTransition(next);
// 执行阶段转换
var previousPhase = CurrentPhase;
CurrentPhase = next;
if (previousPhase != next)
logger.Info($"Architecture phase changed: {previousPhase} -> {next}");
// 通知阶段变更
NotifyPhase(next);
NotifyPhaseAwareObjects(next);
// 触发阶段变更事件(用于测试和扩展)
PhaseChanged?.Invoke(next);
}
/// <summary>
/// 验证阶段转换是否合法
/// </summary>
/// <param name="next">目标阶段</param>
/// <exception cref="InvalidOperationException">当阶段转换不合法时抛出</exception>
private void ValidatePhaseTransition(ArchitecturePhase next)
{
// 不需要严格验证,直接返回
if (!configuration.ArchitectureProperties.StrictPhaseValidation)
return;
// FailedInitialization 可以从任何阶段转换,直接返回
if (next == ArchitecturePhase.FailedInitialization)
return;
// 检查转换是否在允许列表中
if (ArchitectureConstants.PhaseTransitions.TryGetValue(CurrentPhase, out var allowed) &&
allowed.Contains(next))
return;
// 转换不合法,抛出异常
var errorMsg = $"Invalid phase transition: {CurrentPhase} -> {next}";
logger.Fatal(errorMsg);
throw new InvalidOperationException(errorMsg);
}
/// <summary>
/// 通知所有架构阶段感知对象阶段变更
/// </summary>
/// <param name="phase">新阶段</param>
private void NotifyPhaseAwareObjects(ArchitecturePhase phase)
{
foreach (var obj in services.Container.GetAll<IArchitecturePhaseListener>())
{
logger.Trace($"Notifying phase-aware object {obj.GetType().Name} of phase change to {phase}");
obj.OnArchitecturePhase(phase);
}
}
/// <summary>
/// 通知所有生命周期钩子当前阶段变更
/// </summary>
/// <param name="phase">当前架构阶段</param>
private void NotifyPhase(ArchitecturePhase phase)
{
foreach (var hook in _lifecycleHooks)
{
hook.OnPhase(phase, architecture);
logger.Trace($"Notifying lifecycle hook {hook.GetType().Name} of phase {phase}");
}
}
#endregion
#region Initialization
/// <summary>
/// 初始化所有待初始化的组件
/// </summary>
/// <param name="asyncMode">是否使用异步模式</param>
public async Task InitializeAllComponentsAsync(bool asyncMode)
{
logger.Info($"Initializing {_pendingInitializableList.Count} components");
// 按类型分组初始化(保持原有的阶段划分)
var utilities = _pendingInitializableList.OfType<IContextUtility>().ToList();
var models = _pendingInitializableList.OfType<IModel>().ToList();
var systems = _pendingInitializableList.OfType<ISystem>().ToList();
// 1. 工具初始化阶段
EnterPhase(ArchitecturePhase.BeforeUtilityInit);
if (utilities.Count != 0)
{
logger.Info($"Initializing {utilities.Count} context utilities");
foreach (var utility in utilities)
{
logger.Debug($"Initializing utility: {utility.GetType().Name}");
await InitializeComponentAsync(utility, asyncMode);
}
logger.Info("All context utilities initialized");
}
EnterPhase(ArchitecturePhase.AfterUtilityInit);
// 2. 模型初始化阶段
EnterPhase(ArchitecturePhase.BeforeModelInit);
if (models.Count != 0)
{
logger.Info($"Initializing {models.Count} models");
foreach (var model in models)
{
logger.Debug($"Initializing model: {model.GetType().Name}");
await InitializeComponentAsync(model, asyncMode);
}
logger.Info("All models initialized");
}
EnterPhase(ArchitecturePhase.AfterModelInit);
// 3. 系统初始化阶段
EnterPhase(ArchitecturePhase.BeforeSystemInit);
if (systems.Count != 0)
{
logger.Info($"Initializing {systems.Count} systems");
foreach (var system in systems)
{
logger.Debug($"Initializing system: {system.GetType().Name}");
await InitializeComponentAsync(system, asyncMode);
}
logger.Info("All systems initialized");
}
EnterPhase(ArchitecturePhase.AfterSystemInit);
_pendingInitializableList.Clear();
_pendingInitializableSet.Clear();
_initialized = true;
logger.Info("All components initialized");
}
/// <summary>
/// 异步初始化单个组件
/// </summary>
/// <param name="component">要初始化的组件</param>
/// <param name="asyncMode">是否使用异步模式</param>
private static async Task InitializeComponentAsync(IInitializable component, bool asyncMode)
{
if (asyncMode && component is IAsyncInitializable asyncInit)
await asyncInit.InitializeAsync();
else
component.Initialize();
}
#endregion
#region Destruction
/// <summary>
/// 异步销毁架构及所有组件
/// </summary>
public async ValueTask DestroyAsync()
{
// 检查当前阶段,如果已经处于销毁或已销毁状态则直接返回
if (CurrentPhase >= ArchitecturePhase.Destroying)
{
logger.Warn("Architecture destroy called but already in destroying/destroyed state");
return;
}
// 如果从未初始化None 阶段),只清理已注册的组件,不进行阶段转换
if (CurrentPhase == ArchitecturePhase.None)
{
logger.Debug("Architecture destroy called but never initialized, cleaning up registered components");
await CleanupComponentsAsync();
return;
}
// 进入销毁阶段
logger.Info("Starting architecture destruction");
EnterPhase(ArchitecturePhase.Destroying);
// 清理所有组件
await CleanupComponentsAsync();
// 销毁服务模块
await services.ModuleManager.DestroyAllAsync();
services.Container.Clear();
// 进入已销毁阶段
EnterPhase(ArchitecturePhase.Destroyed);
logger.Info("Architecture destruction completed");
}
/// <summary>
/// 清理所有已注册的可销毁组件
/// </summary>
private async ValueTask CleanupComponentsAsync()
{
// 销毁所有实现了 IAsyncDestroyable 或 IDestroyable 的组件(按注册逆序销毁)
logger.Info($"Destroying {_disposables.Count} disposable components");
for (var i = _disposables.Count - 1; i >= 0; i--)
{
var component = _disposables[i];
try
{
logger.Debug($"Destroying component: {component.GetType().Name}");
// 优先使用异步销毁
if (component is IAsyncDestroyable asyncDestroyable)
{
await asyncDestroyable.DestroyAsync();
}
else if (component is IDestroyable destroyable)
{
destroyable.Destroy();
}
}
catch (Exception ex)
{
logger.Error($"Error destroying {component.GetType().Name}", ex);
// 继续销毁其他组件,不会因为一个组件失败而中断
}
}
_disposables.Clear();
_disposableSet.Clear();
}
/// <summary>
/// 销毁架构并清理所有组件资源(同步方法,保留用于向后兼容)
/// </summary>
[Obsolete("建议使用 DestroyAsync() 以支持异步清理")]
public void Destroy()
{
DestroyAsync().AsTask().GetAwaiter().GetResult();
}
#endregion
#region Ready State
/// <summary>
/// 标记架构为就绪状态
/// </summary>
public void MarkAsReady()
{
EnterPhase(ArchitecturePhase.Ready);
_readyTcs.TrySetResult();
}
/// <summary>
/// 标记架构初始化失败
/// </summary>
/// <param name="exception">失败异常</param>
public void MarkAsFailed(Exception exception)
{
EnterPhase(ArchitecturePhase.FailedInitialization);
_readyTcs.TrySetException(exception);
}
/// <summary>
/// 等待架构就绪
/// </summary>
public Task WaitUntilReadyAsync() => _readyTcs.Task;
#endregion
}

View File

@ -0,0 +1,39 @@
using GFramework.Core.Abstractions.Architectures;
using GFramework.Core.Abstractions.Logging;
namespace GFramework.Core.Architectures;
/// <summary>
/// 架构模块管理器
/// 负责管理架构模块的安装和中介行为注册
/// </summary>
internal sealed class ArchitectureModules(
IArchitecture architecture,
IArchitectureServices services,
ILogger logger)
{
/// <summary>
/// 注册中介行为管道
/// 用于配置Mediator框架的行为拦截和处理逻辑
/// </summary>
/// <typeparam name="TBehavior">行为类型,必须是引用类型</typeparam>
public void RegisterMediatorBehavior<TBehavior>() where TBehavior : class
{
logger.Debug($"Registering mediator behavior: {typeof(TBehavior).Name}");
services.Container.RegisterMediatorBehavior<TBehavior>();
}
/// <summary>
/// 安装架构模块
/// </summary>
/// <param name="module">要安装的模块</param>
/// <returns>安装的模块实例</returns>
public IArchitectureModule InstallModule(IArchitectureModule module)
{
var name = module.GetType().Name;
logger.Debug($"Installing module: {name}");
module.Install(architecture);
logger.Info($"Module installed: {name}");
return module;
}
}

View File

@ -17,11 +17,6 @@ internal sealed class CoroutineSlot
/// </summary> /// </summary>
public CoroutineHandle Handle; public CoroutineHandle Handle;
/// <summary>
/// 协程是否已经开始执行
/// </summary>
public bool HasStarted;
/// <summary> /// <summary>
/// 协程的优先级 /// 协程的优先级
/// </summary> /// </summary>

View File

@ -30,12 +30,6 @@ public sealed class WaitForAllCoroutines(
/// 获取一个值,指示所有协程是否已完成执行 /// 获取一个值,指示所有协程是否已完成执行
/// </summary> /// </summary>
/// <returns>当所有协程都已完成时返回true否则返回false</returns> /// <returns>当所有协程都已完成时返回true否则返回false</returns>
public bool IsDone
{
get
{
// 检查所有协程句柄是否都不在调度器中存活 // 检查所有协程句柄是否都不在调度器中存活
return _handles.All(handle => !_scheduler.IsCoroutineAlive(handle)); public bool IsDone => _handles.All(handle => !_scheduler.IsCoroutineAlive(handle));
}
}
} }

View File

@ -18,6 +18,11 @@ public class PriorityEvent<T> : IEvent
/// </summary> /// </summary>
private readonly List<EventHandler> _handlers = new(); private readonly List<EventHandler> _handlers = new();
/// <summary>
/// 保护处理器集合的并发访问
/// </summary>
private readonly object _syncRoot = new();
/// <summary> /// <summary>
/// 标记事件是否已被处理(用于 UntilHandled 传播模式) /// 标记事件是否已被处理(用于 UntilHandled 传播模式)
/// </summary> /// </summary>
@ -52,10 +57,13 @@ public class PriorityEvent<T> : IEvent
public IUnRegister Register(Action<T> onEvent, int priority) public IUnRegister Register(Action<T> onEvent, int priority)
{ {
var handler = new EventHandler(onEvent, priority); var handler = new EventHandler(onEvent, priority);
lock (_syncRoot)
{
_handlers.Add(handler); _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)); return new DefaultUnRegister(() => UnRegister(onEvent));
} }
@ -65,9 +73,12 @@ public class PriorityEvent<T> : IEvent
/// </summary> /// </summary>
/// <param name="onEvent">需要被注销的事件处理方法</param> /// <param name="onEvent">需要被注销的事件处理方法</param>
public void UnRegister(Action<T> onEvent) public void UnRegister(Action<T> onEvent)
{
lock (_syncRoot)
{ {
_handlers.RemoveAll(h => h.Handler == onEvent); _handlers.RemoveAll(h => h.Handler == onEvent);
} }
}
/// <summary> /// <summary>
/// 注册一个上下文事件监听器,并指定优先级 /// 注册一个上下文事件监听器,并指定优先级
@ -78,10 +89,13 @@ public class PriorityEvent<T> : IEvent
public IUnRegister RegisterWithContext(Action<EventContext<T>> onEvent, int priority = 0) public IUnRegister RegisterWithContext(Action<EventContext<T>> onEvent, int priority = 0)
{ {
var handler = new ContextEventHandler(onEvent, priority); var handler = new ContextEventHandler(onEvent, priority);
lock (_syncRoot)
{
_contextHandlers.Add(handler); _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)); return new DefaultUnRegister(() => UnRegisterContext(onEvent));
} }
@ -91,9 +105,12 @@ public class PriorityEvent<T> : IEvent
/// </summary> /// </summary>
/// <param name="onEvent">需要被注销的事件处理方法</param> /// <param name="onEvent">需要被注销的事件处理方法</param>
public void UnRegisterContext(Action<EventContext<T>> onEvent) public void UnRegisterContext(Action<EventContext<T>> onEvent)
{
lock (_syncRoot)
{ {
_contextHandlers.RemoveAll(h => h.Handler == onEvent); _contextHandlers.RemoveAll(h => h.Handler == onEvent);
} }
}
/// <summary> /// <summary>
/// 触发事件处理程序,并指定传播模式 /// 触发事件处理程序,并指定传播模式
@ -172,8 +189,7 @@ public class PriorityEvent<T> : IEvent
/// <param name="t">事件参数</param> /// <param name="t">事件参数</param>
private void TriggerHighest(T t) private void TriggerHighest(T t)
{ {
var normalSnapshot = _handlers.ToArray(); var (normalSnapshot, contextSnapshot) = CreateSnapshots();
var contextSnapshot = _contextHandlers.ToArray();
var highestPriority = GetHighestPriority(normalSnapshot, contextSnapshot); var highestPriority = GetHighestPriority(normalSnapshot, contextSnapshot);
if (highestPriority != int.MinValue) if (highestPriority != int.MinValue)
@ -191,15 +207,11 @@ public class PriorityEvent<T> : IEvent
private List<(int Priority, Action? Handler, Action<EventContext<T>>? ContextHandler, bool IsContext)> private List<(int Priority, Action? Handler, Action<EventContext<T>>? ContextHandler, bool IsContext)>
MergeAndSortHandlers(T t) MergeAndSortHandlers(T t)
{ {
var normalSnapshot = _handlers.ToArray(); var (normalSnapshot, contextSnapshot) = CreateSnapshots();
var contextSnapshot = _contextHandlers.ToArray(); // 使用统一的投影方法显式固定元组的可空标注,避免 LINQ 在 Concat 时推断出不兼容的签名。
// 使用快照避免迭代期间修改
return normalSnapshot return normalSnapshot
.Select(h => (h.Priority, Handler: (Action?)(() => h.Handler.Invoke(t)), .Select(h => CreateNormalHandlerInvocation(h, t))
ContextHandler: (Action<EventContext<T>>?)null, IsContext: false)) .Concat(contextSnapshot.Select(CreateContextHandlerInvocation))
.Concat(contextSnapshot
.Select(h => (h.Priority, Handler: (Action?)null,
ContextHandler: (Action<EventContext<T>>?)h.Handler, IsContext: true)))
.OrderByDescending(h => h.Priority) .OrderByDescending(h => h.Priority)
.ToList(); .ToList();
} }
@ -259,9 +271,43 @@ public class PriorityEvent<T> : IEvent
/// </summary> /// </summary>
/// <returns>监听器总数量</returns> /// <returns>监听器总数量</returns>
public int GetListenerCount() public int GetListenerCount()
{
lock (_syncRoot)
{ {
return _handlers.Count + _contextHandlers.Count; return _handlers.Count + _contextHandlers.Count;
} }
}
private (EventHandler[] NormalHandlers, ContextEventHandler[] ContextHandlers) CreateSnapshots()
{
lock (_syncRoot)
{
return (_handlers.ToArray(), _contextHandlers.ToArray());
}
}
/// <summary>
/// 将普通事件处理器转换为统一的调用描述。
/// </summary>
/// <param name="handler">要包装的普通处理器。</param>
/// <param name="t">当前触发的事件数据。</param>
/// <returns>可与上下文处理器合并排序的统一调用描述。</returns>
private static (int Priority, Action? Handler, Action<EventContext<T>>? ContextHandler, bool IsContext)
CreateNormalHandlerInvocation(EventHandler handler, T t)
{
return (handler.Priority, () => handler.Handler.Invoke(t), null, false);
}
/// <summary>
/// 将上下文事件处理器转换为统一的调用描述。
/// </summary>
/// <param name="handler">要包装的上下文处理器。</param>
/// <returns>可与普通处理器合并排序的统一调用描述。</returns>
private static (int Priority, Action? Handler, Action<EventContext<T>>? ContextHandler, bool IsContext)
CreateContextHandlerInvocation(ContextEventHandler handler)
{
return (handler.Priority, null, handler.Handler, true);
}
/// <summary> /// <summary>
/// 事件处理器包装类,包含处理器和优先级 /// 事件处理器包装类,包含处理器和优先级

View File

@ -0,0 +1,30 @@
using System.Numerics;
using GFramework.Core.Abstractions.Utility.Numeric;
using GFramework.Core.Utility.Numeric;
namespace GFramework.Core.Extensions;
/// <summary>
/// 数值显示扩展方法。
/// </summary>
public static class NumericDisplayExtensions
{
/// <summary>
/// 按指定选项将数值格式化为展示字符串。
/// </summary>
public static string ToDisplayString<T>(this T value, NumericFormatOptions? options = null) where T : INumber<T>
{
return NumericDisplay.Format(value, options);
}
/// <summary>
/// 使用默认紧凑风格将数值格式化为展示字符串。
/// </summary>
public static string ToCompactString<T>(
this T value,
int maxDecimalPlaces = 1,
IFormatProvider? formatProvider = null) where T : INumber<T>
{
return NumericDisplay.FormatCompact(value, maxDecimalPlaces, formatProvider);
}
}

View File

@ -0,0 +1,118 @@
using GFramework.Core.Abstractions.Events;
using GFramework.Core.Abstractions.StateManagement;
using GFramework.Core.Events;
using GFramework.Core.StateManagement;
namespace GFramework.Core.Extensions;
/// <summary>
/// 为 Store 提供到 EventBus 的兼容桥接扩展。
/// 该扩展面向旧模块渐进迁移场景,使现有事件消费者可以继续观察 Store 的 action 分发和状态变化。
/// </summary>
public static class StoreEventBusExtensions
{
/// <summary>
/// 将 Store 的 dispatch 和状态变化同时桥接到 EventBus。
/// dispatch 事件会逐次发布;状态变化事件会复用 Store 自身的通知折叠语义,因此批处理中只发布最终状态。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
/// <param name="store">源 Store。</param>
/// <param name="eventBus">目标事件总线。</param>
/// <param name="publishDispatches">是否发布每次 action 分发事件。</param>
/// <param name="publishStateChanges">是否发布状态变化事件。</param>
/// <returns>用于拆除桥接的句柄。</returns>
public static IUnRegister BridgeToEventBus<TState>(
this Store<TState> store,
IEventBus eventBus,
bool publishDispatches = true,
bool publishStateChanges = true)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(eventBus);
IUnRegister? dispatchBridge = null;
IUnRegister? stateBridge = null;
if (publishDispatches)
{
dispatchBridge = store.BridgeDispatchesToEventBus(eventBus);
}
if (publishStateChanges)
{
stateBridge = store.BridgeStateChangesToEventBus(eventBus);
}
return new DefaultUnRegister(() =>
{
dispatchBridge?.UnRegister();
stateBridge?.UnRegister();
});
}
/// <summary>
/// 将 Store 的每次 dispatch 结果桥接到 EventBus。
/// 该桥接通过中间件实现,因此即使某次分发未改变状态,也会发布对应的 dispatch 事件。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
/// <param name="store">源 Store。</param>
/// <param name="eventBus">目标事件总线。</param>
/// <returns>用于移除 dispatch 桥接中间件的句柄。</returns>
public static IUnRegister BridgeDispatchesToEventBus<TState>(this Store<TState> store, IEventBus eventBus)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(eventBus);
return store.RegisterMiddleware(new DispatchEventBusMiddleware<TState>(eventBus));
}
/// <summary>
/// 将 Store 的状态变化桥接到 EventBus。
/// 该桥接复用 Store 的订阅通知语义,因此只会在状态真正变化时发布事件。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
/// <param name="store">源 Store。</param>
/// <param name="eventBus">目标事件总线。</param>
/// <returns>用于移除状态变化桥接的句柄。</returns>
public static IUnRegister BridgeStateChangesToEventBus<TState>(this IReadonlyStore<TState> store,
IEventBus eventBus)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(eventBus);
return store.Subscribe(state =>
eventBus.Send(new StoreStateChangedEvent<TState>(state, DateTimeOffset.UtcNow)));
}
/// <summary>
/// 用于把 dispatch 结果桥接到 EventBus 的内部中间件。
/// 选择中间件而不是改写 Store 核心提交流程,是为了把兼容层成本保持在可选扩展中。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
private sealed class DispatchEventBusMiddleware<TState>(IEventBus eventBus) : IStoreMiddleware<TState>
{
/// <summary>
/// 目标事件总线。
/// </summary>
private readonly IEventBus _eventBus = eventBus ?? throw new ArgumentNullException(nameof(eventBus));
/// <summary>
/// 执行后续 dispatch 管线,并在结束后把分发结果发送到 EventBus。
/// </summary>
/// <param name="context">当前分发上下文。</param>
/// <param name="next">后续管线。</param>
public void Invoke(StoreDispatchContext<TState> context, Action next)
{
next();
var dispatchRecord = new StoreDispatchRecord<TState>(
context.Action,
context.PreviousState,
context.NextState,
context.HasStateChanged,
context.DispatchedAt);
_eventBus.Send(new StoreDispatchedEvent<TState>(dispatchRecord));
}
}
}

View File

@ -0,0 +1,90 @@
using GFramework.Core.Abstractions.Property;
using GFramework.Core.Abstractions.StateManagement;
using GFramework.Core.StateManagement;
namespace GFramework.Core.Extensions;
/// <summary>
/// 为 Store 提供选择器和 BindableProperty 风格桥接扩展。
/// 这些扩展用于在集中式状态容器和现有 Property/UI 生态之间建立最小侵入的互操作层。
/// </summary>
public static class StoreExtensions
{
/// <summary>
/// 从 Store 中选择一个局部状态视图。
/// </summary>
/// <typeparam name="TState">源状态类型。</typeparam>
/// <typeparam name="TSelected">局部状态类型。</typeparam>
/// <param name="store">源 Store。</param>
/// <param name="selector">状态选择委托。</param>
/// <returns>可用于订阅局部状态变化的只读绑定视图。</returns>
public static StoreSelection<TState, TSelected> Select<TState, TSelected>(
this IReadonlyStore<TState> store,
Func<TState, TSelected> selector)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(selector);
return new StoreSelection<TState, TSelected>(store, selector);
}
/// <summary>
/// 从 Store 中选择一个局部状态视图,并指定局部状态比较器。
/// </summary>
/// <typeparam name="TState">源状态类型。</typeparam>
/// <typeparam name="TSelected">局部状态类型。</typeparam>
/// <param name="store">源 Store。</param>
/// <param name="selector">状态选择委托。</param>
/// <param name="comparer">用于比较局部状态是否变化的比较器。</param>
/// <returns>可用于订阅局部状态变化的只读绑定视图。</returns>
public static StoreSelection<TState, TSelected> Select<TState, TSelected>(
this IReadonlyStore<TState> store,
Func<TState, TSelected> selector,
IEqualityComparer<TSelected>? comparer)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(selector);
return new StoreSelection<TState, TSelected>(store, selector, comparer);
}
/// <summary>
/// 使用显式选择器对象从 Store 中选择一个局部状态视图。
/// </summary>
/// <typeparam name="TState">源状态类型。</typeparam>
/// <typeparam name="TSelected">局部状态类型。</typeparam>
/// <param name="store">源 Store。</param>
/// <param name="selector">状态选择器实例。</param>
/// <param name="comparer">用于比较局部状态是否变化的比较器。</param>
/// <returns>可用于订阅局部状态变化的只读绑定视图。</returns>
public static StoreSelection<TState, TSelected> Select<TState, TSelected>(
this IReadonlyStore<TState> store,
IStateSelector<TState, TSelected> selector,
IEqualityComparer<TSelected>? comparer = null)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(selector);
return new StoreSelection<TState, TSelected>(store, selector.Select, comparer);
}
/// <summary>
/// 将 Store 中选中的局部状态桥接为 IReadonlyBindableProperty 风格接口。
/// </summary>
/// <typeparam name="TState">源状态类型。</typeparam>
/// <typeparam name="TSelected">局部状态类型。</typeparam>
/// <param name="store">源 Store。</param>
/// <param name="selector">状态选择委托。</param>
/// <param name="comparer">用于比较局部状态是否变化的比较器。</param>
/// <returns>只读绑定属性视图。</returns>
public static IReadonlyBindableProperty<TSelected> ToBindableProperty<TState, TSelected>(
this IReadonlyStore<TState> store,
Func<TState, TSelected> selector,
IEqualityComparer<TSelected>? comparer = null)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(selector);
return new StoreSelection<TState, TSelected>(store, selector, comparer);
}
}

View File

@ -11,7 +11,7 @@
<ProjectReference Include="..\$(AssemblyName).Abstractions\$(AssemblyName).Abstractions.csproj"/> <ProjectReference Include="..\$(AssemblyName).Abstractions\$(AssemblyName).Abstractions.csproj"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.4"/> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5"/>
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0"/> <PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -16,3 +16,4 @@ global using System.Collections.Generic;
global using System.Linq; global using System.Linq;
global using System.Threading; global using System.Threading;
global using System.Threading.Tasks; global using System.Threading.Tasks;
global using System.Threading.Channels;

View File

@ -0,0 +1,167 @@
using GFramework.Core.Abstractions.Localization;
using GFramework.Core.Abstractions.Utility.Numeric;
using GFramework.Core.Utility.Numeric;
namespace GFramework.Core.Localization.Formatters;
/// <summary>
/// 紧凑数值格式化器。
/// 格式: {value:compact} 或 {value:compact:maxDecimals=2,trimZeros=false}
/// </summary>
public sealed class CompactNumberLocalizationFormatter : ILocalizationFormatter
{
/// <summary>
/// 获取格式化器的名称
/// </summary>
public string Name => "compact";
/// <summary>
/// 尝试将指定值按照紧凑数值格式进行格式化
/// </summary>
/// <param name="format">格式字符串,可包含以下选项:
/// maxDecimals: 最大小数位数
/// minDecimals: 最小小数位数
/// trimZeros: 是否去除尾随零
/// grouping: 是否在阈值以下使用分组</param>
/// <param name="value">要格式化的数值对象</param>
/// <param name="provider">格式提供程序,用于区域性特定的格式设置</param>
/// <param name="result">格式化后的字符串结果</param>
/// <returns>如果格式化成功则返回true如果格式字符串无效或格式化失败则返回false</returns>
public bool TryFormat(string format, object value, IFormatProvider? provider, out string result)
{
result = string.Empty;
if (!TryParseOptions(format, provider, out var options))
{
return false;
}
try
{
result = NumericDisplay.Format(value, options);
return true;
}
catch (ArgumentNullException)
{
return false;
}
catch (ArgumentException)
{
return false;
}
}
/// <summary>
/// 尝试解析格式字符串中的选项参数
/// </summary>
/// <param name="format">格式字符串,包含以逗号分隔的键值对,如"maxDecimals=2,trimZeros=false"</param>
/// <param name="provider">格式提供程序</param>
/// <param name="options">解析成功的选项输出</param>
/// <returns>如果所有选项都正确解析则返回true如果有任何语法错误或无效值则返回false</returns>
/// <remarks>
/// 支持的选项包括:
/// - maxDecimals: 最大小数位数,必须是有效整数
/// - minDecimals: 最小小数位数,必须是有效整数
/// - trimZeros: 是否去除尾随零,必须是有效布尔值
/// - grouping: 是否在阈值以下使用分组,必须是有效布尔值
/// 选项之间用逗号或分号分隔格式为key=value
/// </remarks>
private static bool TryParseOptions(string format, IFormatProvider? provider, out NumericFormatOptions options)
{
options = new NumericFormatOptions
{
FormatProvider = provider
};
if (string.IsNullOrWhiteSpace(format))
{
return true;
}
var maxDecimalPlaces = options.MaxDecimalPlaces;
var minDecimalPlaces = options.MinDecimalPlaces;
var trimTrailingZeros = options.TrimTrailingZeros;
var useGroupingBelowThreshold = options.UseGroupingBelowThreshold;
foreach (var segment in format.Split([',', ';'],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (!TryParseSegment(segment, out var key, out var value))
{
return false;
}
if (!TryApplyOption(
key,
value,
ref maxDecimalPlaces,
ref minDecimalPlaces,
ref trimTrailingZeros,
ref useGroupingBelowThreshold))
{
return false;
}
}
options = options with
{
MaxDecimalPlaces = maxDecimalPlaces,
MinDecimalPlaces = minDecimalPlaces,
TrimTrailingZeros = trimTrailingZeros,
UseGroupingBelowThreshold = useGroupingBelowThreshold
};
return true;
}
/// <summary>
/// 尝试解析格式字符串中的单个键值对片段。
/// </summary>
/// <param name="segment">包含键值对的字符串片段,格式应为"key=value"</param>
/// <param name="key">解析得到的键名</param>
/// <param name="value">解析得到的值</param>
/// <returns>如果片段格式有效且成功解析则返回true如果格式无效如缺少分隔符、空键等则返回false</returns>
private static bool TryParseSegment(string segment, out string key, out string value)
{
var separatorIndex = segment.IndexOf('=');
if (separatorIndex <= 0 || separatorIndex == segment.Length - 1)
{
key = string.Empty;
value = string.Empty;
return false;
}
key = segment[..separatorIndex].Trim();
value = segment[(separatorIndex + 1)..].Trim();
return true;
}
/// <summary>
/// 尝试将解析得到的键值对应用到相应的选项变量中。
/// </summary>
/// <param name="key">选项名称</param>
/// <param name="value">选项值的字符串表示</param>
/// <param name="maxDecimalPlaces">最大小数位数的引用参数</param>
/// <param name="minDecimalPlaces">最小小数位数的引用参数</param>
/// <param name="trimTrailingZeros">是否去除尾随零的引用参数</param>
/// <param name="useGroupingBelowThreshold">是否在阈值以下使用分组的引用参数</param>
/// <returns>如果值成功解析或键名未知则返回true如果键名已知但值解析失败则返回false</returns>
private static bool TryApplyOption(
string key,
string value,
ref int maxDecimalPlaces,
ref int minDecimalPlaces,
ref bool trimTrailingZeros,
ref bool useGroupingBelowThreshold)
{
return key switch
{
"maxDecimals" => int.TryParse(value, out maxDecimalPlaces),
"minDecimals" => int.TryParse(value, out minDecimalPlaces),
"trimZeros" => bool.TryParse(value, out trimTrailingZeros),
"grouping" => bool.TryParse(value, out useGroupingBelowThreshold),
_ => true
};
}
}

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,317 @@
using System.Globalization;
using System.IO;
using System.Text.Json;
using GFramework.Core.Abstractions.Localization;
using GFramework.Core.Localization.Formatters;
using GFramework.Core.Systems;
namespace GFramework.Core.Localization;
/// <summary>
/// 本地化管理器实现
/// </summary>
public class LocalizationManager : AbstractSystem, ILocalizationManager
{
private readonly List<string> _availableLanguages;
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 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>();
RegisterBuiltInFormatters();
}
/// <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 (LocalizationException)
{
// 只捕获本地化相关的异常(键不存在、表不存在等)
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();
}
private void RegisterBuiltInFormatters()
{
RegisterFormatter("if", new ConditionalFormatter());
RegisterFormatter("plural", new PluralFormatter());
RegisterFormatter("compact", new CompactNumberLocalizationFormatter());
}
/// <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,237 @@
using System.Text.RegularExpressions;
using GFramework.Core.Abstractions.Localization;
namespace GFramework.Core.Localization;
/// <summary>
/// 本地化字符串实现
/// </summary>
public class LocalizationString : ILocalizationString
{
/// <summary>
/// 匹配 {variableName} 或 {variableName:formatter:args} 的正则表达式模式
/// </summary>
private const string FormatVariablePattern =
@"\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([a-zA-Z_][a-zA-Z0-9_]*)(?::([^}]+))?)?\}";
/// <summary>
/// 预编译的静态正则表达式,用于格式化字符串中的变量替换
/// </summary>
private static readonly Regex FormatVariableRegex =
new(FormatVariablePattern, RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly ILocalizationManager _manager;
private readonly Dictionary<string, object> _variables;
/// <summary>
/// 初始化本地化字符串
/// </summary>
/// <param name="manager">本地化管理器实例</param>
/// <param name="table">本地化表名,用于定位本地化资源表</param>
/// <param name="key">本地化键名,用于在表中定位具体的本地化文本</param>
/// <exception cref="ArgumentNullException">当 manager、table 或 key 为 null 时抛出</exception>
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; }
/// <summary>
/// 添加单个变量到本地化字符串中
/// </summary>
/// <param name="name">变量名称,用于在模板中匹配对应的占位符</param>
/// <param name="value">变量值,将被转换为字符串并替换到对应位置</param>
/// <returns>返回当前的 LocalizationString 实例,支持链式调用</returns>
/// <exception cref="ArgumentNullException">当 name 为 null 时抛出</exception>
public ILocalizationString WithVariable(string name, object value)
{
if (name == null)
{
throw new ArgumentNullException(nameof(name));
}
_variables[name] = value;
return this;
}
/// <summary>
/// 批量添加多个变量到本地化字符串中
/// </summary>
/// <param name="variables">变量元组数组,每个元组包含变量名称和对应的值</param>
/// <returns>返回当前的 LocalizationString 实例,支持链式调用</returns>
/// <exception cref="ArgumentNullException">当 variables 为 null 时抛出</exception>
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;
}
/// <summary>
/// 格式化本地化字符串,将模板中的变量占位符替换为实际值
/// </summary>
/// <returns>格式化后的完整字符串。如果本地化文本不存在,则返回 "[Table.Key]" 格式的占位符</returns>
/// <remarks>
/// 支持两种格式:
/// 1. {variableName} - 简单变量替换
/// 2. {variableName:formatter:args} - 使用格式化器进行格式化
/// </remarks>
public string Format()
{
var rawText = GetRaw();
return FormatString(rawText, _variables, _manager);
}
/// <summary>
/// 获取原始的本地化文本,不进行任何变量替换
/// </summary>
/// <returns>本地化文本。如果在本地化管理器中未找到对应的文本,则返回 "[Table.Key]" 格式的占位符</returns>
public string GetRaw()
{
if (!_manager.TryGetText(Table, Key, out var text))
{
return $"[{Table}.{Key}]";
}
return text;
}
/// <summary>
/// 检查当前本地化键是否存在于本地化管理器中
/// </summary>
/// <returns>如果存在返回 true否则返回 false</returns>
public bool Exists()
{
return _manager.TryGetText(Table, Key, out _);
}
/// <summary>
/// 格式化字符串(支持变量替换和格式化器)
/// </summary>
/// <param name="template">包含占位符的模板字符串</param>
/// <param name="variables">包含变量名称和值的字典</param>
/// <param name="manager">本地化管理器实例,用于获取格式化器</param>
/// <returns>格式化后的字符串。如果模板为空或 null则直接返回原模板</returns>
private static string FormatString(
string template,
Dictionary<string, object> variables,
ILocalizationManager manager)
{
if (string.IsNullOrEmpty(template))
{
return template;
}
// 使用预编译的静态正则表达式匹配 {variableName} 或 {variableName:formatter:args}
return FormatVariableRegex.Replace(template, match => FormatMatch(match, variables, manager));
}
/// <summary>
/// 处理单个正则表达式匹配项,根据是否有格式化器决定如何处理变量值
/// </summary>
/// <param name="match">正则表达式匹配结果</param>
/// <param name="variables">变量字典</param>
/// <param name="manager">本地化管理器实例</param>
/// <returns>替换后的字符串。如果变量不存在则返回原始匹配值;如果有格式化器则尝试格式化,失败则使用默认格式化</returns>
private static string FormatMatch(
Match match,
Dictionary<string, object> 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);
}
/// <summary>
/// 尝试使用指定的格式化器格式化变量值
/// </summary>
/// <param name="match">正则表达式匹配结果,用于获取格式化参数</param>
/// <param name="value">要格式化的变量值</param>
/// <param name="formatterName">格式化器名称</param>
/// <param name="manager">本地化管理器实例</param>
/// <param name="result">格式化后的结果字符串</param>
/// <returns>如果格式化成功返回 true否则返回 false此时 result 为空字符串</returns>
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;
}
/// <summary>
/// 对变量值进行默认格式化,不使用自定义格式化器
/// </summary>
/// <param name="value">要格式化的值</param>
/// <param name="manager">本地化管理器实例,提供当前文化信息</param>
/// <returns>格式化后的字符串。如果值实现 IFormattable 接口则使用其 ToString 方法,否则调用默认的 ToString 方法</returns>
private static string FormatValue(object value, ILocalizationManager manager)
{
return value switch
{
IFormattable formattable => formattable.ToString(null, manager.CurrentCulture),
_ => value.ToString() ?? string.Empty
};
}
/// <summary>
/// 获取正则表达式匹配组中的可选值
/// </summary>
/// <param name="match">正则表达式匹配结果</param>
/// <param name="groupIndex">要获取的组索引</param>
/// <returns>如果该组匹配成功则返回其值;否则返回 null</returns>
private static string? GetOptionalGroupValue(Match match, int groupIndex)
{
return match.Groups[groupIndex].Success ? match.Groups[groupIndex].Value : null;
}
/// <summary>
/// 从本地化管理器获取指定名称的格式化器
/// </summary>
/// <param name="manager">本地化管理器实例</param>
/// <param name="name">格式化器名称</param>
/// <returns>如果找到对应的格式化器则返回;否则返回 null</returns>
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 is { } fb && fb.ContainsKey(key))
{
return fb.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 is { } fb && fb.ContainsKey(key));
}
/// <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

@ -1,17 +1,26 @@
using System.Threading.Channels;
using GFramework.Core.Abstractions.Logging; using GFramework.Core.Abstractions.Logging;
namespace GFramework.Core.Logging.Appenders; namespace GFramework.Core.Logging.Appenders;
/// <summary> /// <summary>
/// 异步日志输出器,使用 Channel 实现非阻塞日志写入 /// 异步日志输出器,使用 <see cref="Channel" /> 将调用线程与慢速日志目标解耦。
/// </summary> /// </summary>
public sealed class AsyncLogAppender : ILogAppender, IDisposable /// <remarks>
/// <para>
/// 该输出器在后台线程中顺序消费日志条目,因此调用方不会因为文件 IO 或其他慢速输出目标而阻塞。
/// </para>
/// <para>
/// 内部输出器抛出的异常不会重新抛回调用线程;如需观察后台处理失败,请在构造函数中提供
/// <c>processingErrorHandler</c> 回调。
/// </para>
/// </remarks>
public sealed class AsyncLogAppender : ILogAppender
{ {
private readonly Channel<LogEntry> _channel; private readonly Channel<LogEntry> _channel;
private readonly CancellationTokenSource _cts; private readonly CancellationTokenSource _cts;
private readonly SemaphoreSlim _flushSemaphore = new(0, 1); private readonly SemaphoreSlim _flushSemaphore = new(0, 1);
private readonly ILogAppender _innerAppender; private readonly ILogAppender _innerAppender;
private readonly Action<Exception>? _processingErrorHandler;
private readonly Task _processingTask; private readonly Task _processingTask;
private bool _disposed; private bool _disposed;
private volatile bool _flushRequested; private volatile bool _flushRequested;
@ -21,9 +30,17 @@ public sealed class AsyncLogAppender : ILogAppender, IDisposable
/// </summary> /// </summary>
/// <param name="innerAppender">内部日志输出器</param> /// <param name="innerAppender">内部日志输出器</param>
/// <param name="bufferSize">缓冲区大小(默认 10000</param> /// <param name="bufferSize">缓冲区大小(默认 10000</param>
public AsyncLogAppender(ILogAppender innerAppender, int bufferSize = 10000) /// <param name="processingErrorHandler">
/// 后台处理日志时的错误回调。
/// 默认值为 <see langword="null" />,表示吞掉内部异常以避免污染宿主标准错误输出。
/// </param>
public AsyncLogAppender(
ILogAppender innerAppender,
int bufferSize = 10000,
Action<Exception>? processingErrorHandler = null)
{ {
_innerAppender = innerAppender ?? throw new ArgumentNullException(nameof(innerAppender)); _innerAppender = innerAppender ?? throw new ArgumentNullException(nameof(innerAppender));
_processingErrorHandler = processingErrorHandler;
if (bufferSize <= 0) if (bufferSize <= 0)
throw new ArgumentException("Buffer size must be greater than 0.", nameof(bufferSize)); throw new ArgumentException("Buffer size must be greater than 0.", nameof(bufferSize));
@ -138,7 +155,8 @@ public sealed class AsyncLogAppender : ILogAppender, IDisposable
} }
/// <summary> /// <summary>
/// 后台处理日志的异步方法 /// 后台处理日志的异步方法。
/// 该循环必须始终保持存活,因此所有内部异常都通过回调上报并被吞掉。
/// </summary> /// </summary>
private async Task ProcessLogsAsync(CancellationToken cancellationToken) private async Task ProcessLogsAsync(CancellationToken cancellationToken)
{ {
@ -152,8 +170,8 @@ public sealed class AsyncLogAppender : ILogAppender, IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
// 记录内部错误到控制台(避免递归) // 后台消费失败只通过显式回调暴露,避免测试宿主将 stderr 误判为测试告警。
await Console.Error.WriteLineAsync($"[AsyncLogAppender] Error processing log entry: {ex.Message}"); ReportProcessingError(ex);
} }
// 检查是否有刷新请求且通道已空 // 检查是否有刷新请求且通道已空
@ -175,7 +193,7 @@ public sealed class AsyncLogAppender : ILogAppender, IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
await Console.Error.WriteLineAsync($"[AsyncLogAppender] Fatal error in processing task: {ex}"); ReportProcessingError(ex);
} }
finally finally
{ {
@ -184,10 +202,37 @@ public sealed class AsyncLogAppender : ILogAppender, IDisposable
{ {
_innerAppender.Flush(); _innerAppender.Flush();
} }
catch (Exception ex)
{
ReportProcessingError(ex);
}
}
}
/// <summary>
/// 上报后台处理异常,同时隔离观察者自身抛出的错误,避免终止处理循环。
/// 取消相关异常表示关闭流程中的预期控制流,不应被视为后台处理失败。
/// </summary>
/// <param name="exception">后台处理中捕获到的异常。</param>
private void ReportProcessingError(Exception exception)
{
if (exception is OperationCanceledException)
{
return;
}
if (_processingErrorHandler is null)
{
return;
}
try
{
_processingErrorHandler(exception);
}
catch catch
{ {
// 忽略刷新错误 // 错误观察者只用于诊断,绝不能反向影响日志处理线程的生命周期。
}
} }
} }
} }

View File

@ -80,7 +80,7 @@ public class PauseStackManager : AbstractContextUtility, IPauseStackManager, IAs
// 触发事件 // 触发事件
try try
{ {
OnPauseStateChanged?.Invoke(group, false); RaisePauseStateChanged(group, false);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -97,7 +97,7 @@ public class PauseStackManager : AbstractContextUtility, IPauseStackManager, IAs
/// <summary> /// <summary>
/// 暂停状态变化事件,当暂停状态发生改变时触发。 /// 暂停状态变化事件,当暂停状态发生改变时触发。
/// </summary> /// </summary>
public event Action<PauseGroup, bool>? OnPauseStateChanged; public event EventHandler<PauseStateChangedEventArgs>? OnPauseStateChanged;
/// <summary> /// <summary>
/// 推入一个新的暂停请求到指定的暂停组中。 /// 推入一个新的暂停请求到指定的暂停组中。
@ -488,7 +488,18 @@ public class PauseStackManager : AbstractContextUtility, IPauseStackManager, IAs
} }
// 触发事件 // 触发事件
OnPauseStateChanged?.Invoke(group, isPaused); RaisePauseStateChanged(group, isPaused);
}
/// <summary>
/// 以标准事件模式发布暂停状态变化事件。
/// 所有状态变更路径都通过该方法创建统一的事件参数,避免不同调用点出现不一致的载荷。
/// </summary>
/// <param name="group">发生状态变化的暂停组。</param>
/// <param name="isPaused">暂停组变化后的新状态。</param>
private void RaisePauseStateChanged(PauseGroup group, bool isPaused)
{
OnPauseStateChanged?.Invoke(this, new PauseStateChangedEventArgs(group, isPaused));
} }
/// <summary> /// <summary>

View File

@ -13,6 +13,7 @@ GFramework 框架的核心模块提供MVC架构的基础设施。
- **Events** - 事件系统,实现组件间松耦合通信 - **Events** - 事件系统,实现组件间松耦合通信
- **IoC** - 轻量级依赖注入容器 - **IoC** - 轻量级依赖注入容器
- **Property** - 可绑定属性,支持数据绑定和响应式编程 - **Property** - 可绑定属性,支持数据绑定和响应式编程
- **StateManagement** - 集中式状态容器,支持状态归约、选择器和诊断
- **Utility** - 无状态工具类 - **Utility** - 无状态工具类
- **Pool** - 对象池系统减少GC压力 - **Pool** - 对象池系统减少GC压力
- **Extensions** - 框架扩展方法 - **Extensions** - 框架扩展方法

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,128 @@
using GFramework.Core.Abstractions.StateManagement;
namespace GFramework.Core.StateManagement;
/// <summary>
/// Store 构建器的默认实现。
/// 该类型用于在 Store 创建之前集中配置比较器、reducer 和中间件,适合模块安装和测试工厂场景。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public sealed class StoreBuilder<TState> : IStoreBuilder<TState>
{
/// <summary>
/// 延迟应用到 Store 的配置操作列表。
/// 采用延迟配置而不是直接缓存 reducer 适配器,可复用 Store 自身的注册和验证逻辑。
/// </summary>
private readonly List<Action<Store<TState>>> _configurators = [];
/// <summary>
/// action 匹配策略。
/// 默认使用精确类型匹配,只有在明确需要复用基类/接口 action 层次时才切换为多态匹配。
/// </summary>
private StoreActionMatchingMode _actionMatchingMode = StoreActionMatchingMode.ExactTypeOnly;
/// <summary>
/// 状态比较器。
/// </summary>
private IEqualityComparer<TState>? _comparer;
/// <summary>
/// 历史缓冲区容量。
/// 默认值为 0表示不记录撤销/重做历史,以维持最轻量的运行时开销。
/// </summary>
private int _historyCapacity;
/// <summary>
/// 添加一个 Store 中间件。
/// </summary>
/// <param name="middleware">要添加的中间件。</param>
/// <returns>当前构建器实例。</returns>
public IStoreBuilder<TState> UseMiddleware(IStoreMiddleware<TState> middleware)
{
ArgumentNullException.ThrowIfNull(middleware);
_configurators.Add(store => store.UseMiddleware(middleware));
return this;
}
/// <summary>
/// 基于给定初始状态创建一个新的 Store。
/// </summary>
/// <param name="initialState">Store 的初始状态。</param>
/// <returns>已应用当前构建器配置的 Store 实例。</returns>
public IStore<TState> Build(TState initialState)
{
var store = new Store<TState>(initialState, _comparer, _historyCapacity, _actionMatchingMode);
foreach (var configurator in _configurators)
{
configurator(store);
}
return store;
}
/// <summary>
/// 配置历史缓冲区容量。
/// </summary>
/// <param name="historyCapacity">历史缓冲区容量0 表示禁用历史记录。</param>
/// <returns>当前构建器实例。</returns>
/// <exception cref="ArgumentOutOfRangeException">当 <paramref name="historyCapacity"/> 小于 0 时抛出。</exception>
public IStoreBuilder<TState> WithHistoryCapacity(int historyCapacity)
{
if (historyCapacity < 0)
{
throw new ArgumentOutOfRangeException(nameof(historyCapacity), historyCapacity,
"History capacity cannot be negative.");
}
_historyCapacity = historyCapacity;
return this;
}
/// <summary>
/// 配置 action 匹配策略。
/// </summary>
/// <param name="actionMatchingMode">要使用的匹配策略。</param>
/// <returns>当前构建器实例。</returns>
public IStoreBuilder<TState> WithActionMatching(StoreActionMatchingMode actionMatchingMode)
{
_actionMatchingMode = actionMatchingMode;
return this;
}
/// <summary>
/// 配置状态比较器。
/// </summary>
/// <param name="comparer">状态比较器。</param>
/// <returns>当前构建器实例。</returns>
public IStoreBuilder<TState> WithComparer(IEqualityComparer<TState> comparer)
{
_comparer = comparer ?? throw new ArgumentNullException(nameof(comparer));
return this;
}
/// <summary>
/// 使用委托快速添加一个 reducer。
/// </summary>
/// <typeparam name="TAction">当前 reducer 处理的 action 类型。</typeparam>
/// <param name="reducer">执行归约的委托。</param>
/// <returns>当前构建器实例。</returns>
public IStoreBuilder<TState> AddReducer<TAction>(Func<TState, TAction, TState> reducer)
{
ArgumentNullException.ThrowIfNull(reducer);
_configurators.Add(store => store.RegisterReducer(reducer));
return this;
}
/// <summary>
/// 添加一个强类型 reducer。
/// </summary>
/// <typeparam name="TAction">当前 reducer 处理的 action 类型。</typeparam>
/// <param name="reducer">要添加的 reducer。</param>
/// <returns>当前构建器实例。</returns>
public IStoreBuilder<TState> AddReducer<TAction>(IReducer<TState, TAction> reducer)
{
ArgumentNullException.ThrowIfNull(reducer);
_configurators.Add(store => store.RegisterReducer(reducer));
return this;
}
}

View File

@ -0,0 +1,26 @@
using GFramework.Core.Abstractions.StateManagement;
namespace GFramework.Core.StateManagement;
/// <summary>
/// 表示一条由 Store 分发桥接到 EventBus 的事件。
/// 该事件用于让旧模块在不直接依赖 Store API 的情况下观察 action 分发结果。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public sealed class StoreDispatchedEvent<TState>
{
/// <summary>
/// 初始化一个新的 Store 分发桥接事件。
/// </summary>
/// <param name="dispatchRecord">本次分发记录。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="dispatchRecord"/> 为 <see langword="null"/> 时抛出。</exception>
public StoreDispatchedEvent(StoreDispatchRecord<TState> dispatchRecord)
{
DispatchRecord = dispatchRecord ?? throw new ArgumentNullException(nameof(dispatchRecord));
}
/// <summary>
/// 获取本次桥接对应的 Store 分发记录。
/// </summary>
public StoreDispatchRecord<TState> DispatchRecord { get; }
}

View File

@ -0,0 +1,394 @@
using GFramework.Core.Abstractions.Events;
using GFramework.Core.Abstractions.Property;
using GFramework.Core.Abstractions.StateManagement;
using GFramework.Core.Events;
namespace GFramework.Core.StateManagement;
/// <summary>
/// Store 选择结果的只读绑定视图。
/// 该类型将整棵状态树上的订阅转换为局部状态片段的订阅,
/// 使现有依赖 IReadonlyBindableProperty 的 UI 代码能够平滑复用到 Store 场景中。
/// </summary>
/// <typeparam name="TState">源状态类型。</typeparam>
/// <typeparam name="TSelected">投影后的局部状态类型。</typeparam>
public sealed class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSelected>
{
/// <summary>
/// 用于判断选择结果是否真正变化的比较器。
/// </summary>
private readonly IEqualityComparer<TSelected> _comparer;
/// <summary>
/// 当前监听器列表。
/// </summary>
private readonly List<SelectionListenerSubscription> _listeners = [];
/// <summary>
/// 保护监听器集合和底层 Store 订阅句柄的同步锁。
/// </summary>
private readonly object _lock = new();
/// <summary>
/// 负责从完整状态中投影出局部状态的选择器。
/// </summary>
private readonly Func<TState, TSelected> _selector;
/// <summary>
/// 源 Store。
/// </summary>
private readonly IReadonlyStore<TState> _store;
/// <summary>
/// 当前已缓存的选择结果。
/// 该缓存仅在存在监听器时用于变化比较和事件通知,直接读取 Value 时始终以 Store 当前状态为准。
/// </summary>
private TSelected _currentValue = default!;
/// <summary>
/// 连接到底层 Store 的订阅句柄。
/// 仅当当前存在至少一个监听器时才会建立该订阅,以减少长期闲置对象造成的引用链。
/// </summary>
private IUnRegister? _storeSubscription;
/// <summary>
/// 初始化一个新的 Store 选择视图。
/// </summary>
/// <param name="store">源 Store。</param>
/// <param name="selector">状态选择器。</param>
/// <param name="comparer">选择结果比较器;未提供时使用 <see cref="EqualityComparer{T}.Default"/>。</param>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="store"/> 或 <paramref name="selector"/> 为 <see langword="null"/> 时抛出。
/// </exception>
public StoreSelection(
IReadonlyStore<TState> store,
Func<TState, TSelected> selector,
IEqualityComparer<TSelected>? comparer = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_selector = selector ?? throw new ArgumentNullException(nameof(selector));
_comparer = comparer ?? EqualityComparer<TSelected>.Default;
}
/// <summary>
/// 获取当前选择结果。
/// </summary>
public TSelected Value => _selector(_store.State);
/// <summary>
/// 将无参事件监听适配为带选择结果参数的监听。
/// </summary>
/// <param name="onEvent">无参事件监听器。</param>
/// <returns>用于取消订阅的句柄。</returns>
IUnRegister IEvent.Register(Action onEvent)
{
ArgumentNullException.ThrowIfNull(onEvent);
return Register(_ => onEvent());
}
/// <summary>
/// 注册选择结果变化监听器。
/// </summary>
/// <param name="onValueChanged">选择结果变化时的回调。</param>
/// <returns>用于取消订阅的句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="onValueChanged"/> 为 <see langword="null"/> 时抛出。</exception>
public IUnRegister Register(Action<TSelected> onValueChanged)
{
ArgumentNullException.ThrowIfNull(onValueChanged);
var subscription = new SelectionListenerSubscription(onValueChanged);
var shouldAttach = false;
lock (_lock)
{
if (_listeners.Count == 0)
{
_currentValue = Value;
shouldAttach = true;
}
_listeners.Add(subscription);
}
if (shouldAttach)
{
AttachToStore();
}
return new DefaultUnRegister(() => UnRegister(subscription));
}
/// <summary>
/// 注册选择结果变化监听器,并立即回放当前值。
/// </summary>
/// <param name="action">选择结果变化时的回调。</param>
/// <returns>用于取消订阅的句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="action"/> 为 <see langword="null"/> 时抛出。</exception>
public IUnRegister RegisterWithInitValue(Action<TSelected> action)
{
ArgumentNullException.ThrowIfNull(action);
var subscription = new SelectionListenerSubscription(action)
{
IsActive = false
};
var currentValue = Value;
TSelected? pendingValue = default;
var hasPendingValue = false;
lock (_lock)
{
if (_listeners.Count == 0)
{
_currentValue = currentValue;
}
_listeners.Add(subscription);
}
EnsureAttached();
try
{
action(currentValue);
}
catch
{
UnRegister(subscription);
throw;
}
lock (_lock)
{
if (!subscription.IsSubscribed)
{
return new DefaultUnRegister(() => { });
}
subscription.IsActive = true;
if (subscription.HasPendingValue)
{
pendingValue = subscription.PendingValue;
hasPendingValue = true;
subscription.PendingValue = default!;
subscription.HasPendingValue = false;
}
}
if (hasPendingValue)
{
action(pendingValue!);
}
return new DefaultUnRegister(() => UnRegister(subscription));
}
/// <summary>
/// 取消注册选择结果变化监听器。
/// </summary>
/// <param name="onValueChanged">需要移除的监听器。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="onValueChanged"/> 为 <see langword="null"/> 时抛出。</exception>
public void UnRegister(Action<TSelected> onValueChanged)
{
ArgumentNullException.ThrowIfNull(onValueChanged);
SelectionListenerSubscription? subscriptionToRemove = null;
lock (_lock)
{
var index = _listeners.FindIndex(subscription => subscription.Listener == onValueChanged);
if (index < 0)
{
return;
}
subscriptionToRemove = _listeners[index];
}
if (subscriptionToRemove != null)
{
UnRegister(subscriptionToRemove);
}
}
/// <summary>
/// 确保当前选择视图已连接到底层 Store。
/// </summary>
private void EnsureAttached()
{
var shouldAttach = false;
lock (_lock)
{
shouldAttach = _listeners.Count > 0 && _storeSubscription == null;
}
if (shouldAttach)
{
AttachToStore();
}
}
/// <summary>
/// 取消注册一个精确的选择结果监听器。
/// </summary>
/// <param name="subscriptionToRemove">需要移除的订阅对象。</param>
private void UnRegister(SelectionListenerSubscription subscriptionToRemove)
{
IUnRegister? storeSubscription = null;
lock (_lock)
{
subscriptionToRemove.IsSubscribed = false;
_listeners.Remove(subscriptionToRemove);
if (_listeners.Count == 0 && _storeSubscription != null)
{
storeSubscription = _storeSubscription;
_storeSubscription = null;
}
}
storeSubscription?.UnRegister();
}
/// <summary>
/// 将当前选择视图连接到底层 Store。
/// </summary>
private void AttachToStore()
{
var subscription = _store.Subscribe(OnStoreChanged);
Action<TSelected>[] listenersSnapshot = Array.Empty<Action<TSelected>>();
var latestValue = Value;
var shouldNotify = false;
lock (_lock)
{
// 如果在建立底层订阅期间所有监听器都已被移除,则立即释放刚刚建立的订阅,
// 避免选择视图在无人监听时继续被 Store 保持引用。
if (_listeners.Count == 0)
{
subscription.UnRegister();
return;
}
if (_storeSubscription != null)
{
subscription.UnRegister();
return;
}
_storeSubscription = subscription;
if (!_comparer.Equals(_currentValue, latestValue))
{
_currentValue = latestValue;
foreach (var listener in _listeners)
{
if (!listener.IsSubscribed)
{
continue;
}
if (listener.IsActive)
{
continue;
}
listener.PendingValue = latestValue;
listener.HasPendingValue = true;
}
listenersSnapshot = _listeners
.Where(listener => listener.IsSubscribed && listener.IsActive)
.Select(listener => listener.Listener)
.ToArray();
shouldNotify = listenersSnapshot.Length > 0;
}
}
if (!shouldNotify)
{
return;
}
foreach (var listener in listenersSnapshot)
{
listener(latestValue);
}
}
/// <summary>
/// 响应底层 Store 的状态变化,并在选中片段真正变化时通知监听器。
/// </summary>
/// <param name="state">新的完整状态。</param>
private void OnStoreChanged(TState state)
{
var selectedValue = _selector(state);
Action<TSelected>[] listenersSnapshot = Array.Empty<Action<TSelected>>();
lock (_lock)
{
if (_listeners.Count == 0 || _comparer.Equals(_currentValue, selectedValue))
{
return;
}
_currentValue = selectedValue;
foreach (var listener in _listeners)
{
if (!listener.IsSubscribed)
{
continue;
}
if (listener.IsActive)
{
continue;
}
listener.PendingValue = selectedValue;
listener.HasPendingValue = true;
}
listenersSnapshot = _listeners
.Where(listener => listener.IsSubscribed && listener.IsActive)
.Select(listener => listener.Listener)
.ToArray();
}
foreach (var listener in listenersSnapshot)
{
listener(selectedValue);
}
}
/// <summary>
/// 表示一个选择结果监听订阅。
/// 该对象用于保证 RegisterWithInitValue 在初始化回放与后续状态变化之间不会漏掉最近一次更新。
/// </summary>
private sealed class SelectionListenerSubscription(Action<TSelected> listener)
{
/// <summary>
/// 获取订阅回调。
/// </summary>
public Action<TSelected> Listener { get; } = listener;
/// <summary>
/// 获取或设置订阅是否已激活。
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// 获取或设置订阅是否仍然有效。
/// </summary>
public bool IsSubscribed { get; set; } = true;
/// <summary>
/// 获取或设置是否存在待补发的局部状态值。
/// </summary>
public bool HasPendingValue { get; set; }
/// <summary>
/// 获取或设置初始化阶段积累的最新局部状态值。
/// </summary>
public TSelected PendingValue { get; set; } = default!;
}
}

View File

@ -0,0 +1,30 @@
namespace GFramework.Core.StateManagement;
/// <summary>
/// 表示一条由 Store 状态变更桥接到 EventBus 的事件。
/// 该事件会复用 Store 对订阅通知的折叠语义,因此在批处理中只会发布最终状态。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public sealed class StoreStateChangedEvent<TState>
{
/// <summary>
/// 初始化一个新的 Store 状态变更桥接事件。
/// </summary>
/// <param name="state">最新状态快照。</param>
/// <param name="changedAt">状态变更时间。</param>
public StoreStateChangedEvent(TState state, DateTimeOffset changedAt)
{
State = state;
ChangedAt = changedAt;
}
/// <summary>
/// 获取最新状态快照。
/// </summary>
public TState State { get; }
/// <summary>
/// 获取该状态对外广播的时间。
/// </summary>
public DateTimeOffset ChangedAt { get; }
}

View File

@ -0,0 +1,58 @@
using System.Numerics;
using GFramework.Core.Abstractions.Utility.Numeric;
namespace GFramework.Core.Utility.Numeric;
/// <summary>
/// 数值显示静态入口。
/// </summary>
public static class NumericDisplay
{
private static readonly NumericDisplayFormatter DefaultFormatter = new();
/// <summary>
/// 将数值格式化为展示字符串。
/// </summary>
public static string Format<T>(T value, NumericFormatOptions? options = null) where T : INumber<T>
{
return DefaultFormatter.Format(value, options);
}
/// <summary>
/// 将运行时数值对象格式化为展示字符串。
/// </summary>
public static string Format(object value, NumericFormatOptions? options = null)
{
return DefaultFormatter.Format(value, options);
}
/// <summary>
/// 使用默认紧凑风格格式化数值。
/// </summary>
public static string FormatCompact<T>(
T value,
int maxDecimalPlaces = 1,
IFormatProvider? formatProvider = null) where T : INumber<T>
{
return Format(value, new NumericFormatOptions
{
MaxDecimalPlaces = maxDecimalPlaces,
FormatProvider = formatProvider
});
}
/// <summary>
/// 使用默认紧凑风格格式化运行时数值对象。
/// </summary>
public static string FormatCompact(
object value,
int maxDecimalPlaces = 1,
IFormatProvider? formatProvider = null)
{
return Format(value, new NumericFormatOptions
{
MaxDecimalPlaces = maxDecimalPlaces,
FormatProvider = formatProvider
});
}
}

View File

@ -0,0 +1,140 @@
using System.Globalization;
using System.Numerics;
using GFramework.Core.Abstractions.Utility.Numeric;
namespace GFramework.Core.Utility.Numeric;
/// <summary>
/// 默认数值显示格式化器。
/// </summary>
public sealed class NumericDisplayFormatter : INumericDisplayFormatter
{
private readonly INumericFormatRule _defaultRule;
/// <summary>
/// 初始化默认数值显示格式化器。
/// </summary>
public NumericDisplayFormatter()
: this(NumericSuffixFormatRule.InternationalCompact)
{
}
/// <summary>
/// 初始化数值显示格式化器。
/// </summary>
/// <param name="defaultRule">默认规则。</param>
public NumericDisplayFormatter(INumericFormatRule defaultRule)
{
_defaultRule = defaultRule ?? throw new ArgumentNullException(nameof(defaultRule));
}
/// <inheritdoc/>
public string Format<T>(T value, NumericFormatOptions? options = null)
{
if (value is null)
{
throw new ArgumentNullException(nameof(value));
}
var resolvedOptions = NormalizeOptions(options);
var rule = ResolveRule(resolvedOptions);
if (rule.TryFormat(value, resolvedOptions, out var result))
{
return result;
}
return FormatFallback(value!, resolvedOptions.FormatProvider);
}
/// <summary>
/// 将运行时数值对象格式化为展示字符串。
/// </summary>
/// <param name="value">待格式化的数值对象。</param>
/// <param name="options">格式化选项。</param>
/// <returns>格式化后的字符串。</returns>
public string Format(object value, NumericFormatOptions? options = null)
{
ArgumentNullException.ThrowIfNull(value);
return value switch
{
byte byteValue => Format(byteValue, options),
sbyte sbyteValue => Format(sbyteValue, options),
short shortValue => Format(shortValue, options),
ushort ushortValue => Format(ushortValue, options),
int intValue => Format(intValue, options),
uint uintValue => Format(uintValue, options),
long longValue => Format(longValue, options),
ulong ulongValue => Format(ulongValue, options),
nint nativeIntValue => Format(nativeIntValue, options),
nuint nativeUIntValue => Format(nativeUIntValue, options),
float floatValue => Format(floatValue, options),
double doubleValue => Format(doubleValue, options),
decimal decimalValue => Format(decimalValue, options),
BigInteger bigIntegerValue => Format(bigIntegerValue, options),
_ => FormatFallback(value, options?.FormatProvider)
};
}
internal static NumericFormatOptions NormalizeOptions(NumericFormatOptions? options)
{
var resolved = options ?? new NumericFormatOptions();
if (resolved.MaxDecimalPlaces < 0)
{
throw new ArgumentOutOfRangeException(
nameof(options),
resolved.MaxDecimalPlaces,
"MaxDecimalPlaces 不能小于 0。");
}
if (resolved.MinDecimalPlaces < 0)
{
throw new ArgumentOutOfRangeException(
nameof(options),
resolved.MinDecimalPlaces,
"MinDecimalPlaces 不能小于 0。");
}
if (resolved.MinDecimalPlaces > resolved.MaxDecimalPlaces)
{
throw new ArgumentException("MinDecimalPlaces 不能大于 MaxDecimalPlaces。", nameof(options));
}
if (resolved.CompactThreshold <= 0m)
{
throw new ArgumentOutOfRangeException(
nameof(options),
resolved.CompactThreshold,
"CompactThreshold 必须大于 0。");
}
return resolved;
}
private INumericFormatRule ResolveRule(NumericFormatOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (options.Rule is not null)
{
return options.Rule;
}
return options.Style switch
{
NumericDisplayStyle.Compact => _defaultRule,
_ => throw new ArgumentOutOfRangeException(nameof(options), options.Style, "不支持的数值显示风格。")
};
}
private static string FormatFallback(object value, IFormatProvider? provider)
{
return value switch
{
IFormattable formattable => formattable.ToString(null, provider ?? CultureInfo.CurrentCulture),
_ => value.ToString() ?? string.Empty
};
}
}

View File

@ -0,0 +1,353 @@
using System.Globalization;
using System.Numerics;
using GFramework.Core.Abstractions.Utility.Numeric;
namespace GFramework.Core.Utility.Numeric;
/// <summary>
/// 基于后缀阈值表的数值缩写规则。
/// </summary>
public sealed class NumericSuffixFormatRule : INumericFormatRule
{
private readonly NumericSuffixThreshold[] _thresholds;
/// <summary>
/// 初始化后缀缩写规则。
/// </summary>
/// <param name="name">规则名称。</param>
/// <param name="thresholds">阈值表。</param>
public NumericSuffixFormatRule(string name, IEnumerable<NumericSuffixThreshold> thresholds)
{
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentNullException.ThrowIfNull(thresholds);
Name = name;
_thresholds = thresholds.OrderBy(entry => entry.Divisor).ToArray();
if (_thresholds.Length == 0)
{
throw new ArgumentException("至少需要一个缩写阈值。", nameof(thresholds));
}
ValidateThresholds(_thresholds);
}
/// <summary>
/// 默认国际缩写规则使用标准的K、M、B、T后缀表示千、百万、十亿、万亿。
/// </summary>
public static NumericSuffixFormatRule InternationalCompact { get; } = new(
"compact",
[
new NumericSuffixThreshold(1_000m, "K"),
new NumericSuffixThreshold(1_000_000m, "M"),
new NumericSuffixThreshold(1_000_000_000m, "B"),
new NumericSuffixThreshold(1_000_000_000_000m, "T")
]);
/// <summary>
/// 获取此格式化规则的名称。
/// </summary>
public string Name { get; }
/// <summary>
/// 尝试将指定的数值按照当前规则进行格式化。
/// </summary>
/// <typeparam name="T">数值的类型</typeparam>
/// <param name="value">要格式化的数值</param>
/// <param name="options">格式化选项,包含小数位数、舍入模式等设置</param>
/// <param name="result">格式化后的字符串结果</param>
/// <returns>如果格式化成功则返回true如果输入无效或格式化失败则返回false</returns>
public bool TryFormat<T>(T value, NumericFormatOptions options, out string result)
{
ArgumentNullException.ThrowIfNull(options);
NumericDisplayFormatter.NormalizeOptions(options);
if (TryFormatSpecialFloatingPoint(value, options.FormatProvider, out result))
{
return true;
}
object? boxedValue = value;
if (boxedValue is null)
{
result = string.Empty;
return false;
}
return boxedValue switch
{
byte byteValue => TryFormatDecimal(byteValue, options, out result),
sbyte sbyteValue => TryFormatDecimal(sbyteValue, options, out result),
short shortValue => TryFormatDecimal(shortValue, options, out result),
ushort ushortValue => TryFormatDecimal(ushortValue, options, out result),
int intValue => TryFormatDecimal(intValue, options, out result),
uint uintValue => TryFormatDecimal(uintValue, options, out result),
long longValue => TryFormatDecimal(longValue, options, out result),
ulong ulongValue => TryFormatDecimal(ulongValue, options, out result),
nint nativeIntValue => TryFormatDecimal(nativeIntValue, options, out result),
nuint nativeUIntValue => TryFormatDecimal(nativeUIntValue, options, out result),
decimal decimalValue => TryFormatDecimal(decimalValue, options, out result),
float floatValue => TryFormatDouble(floatValue, options, out result),
double doubleValue => TryFormatDouble(doubleValue, options, out result),
BigInteger bigIntegerValue => TryFormatBigInteger(bigIntegerValue, options, out result),
_ => TryFormatConvertible(boxedValue, options, out result)
};
}
private static void ValidateThresholds(IReadOnlyList<NumericSuffixThreshold> thresholds)
{
decimal? previousDivisor = null;
foreach (var threshold in thresholds)
{
if (threshold.Divisor <= 0m)
{
throw new ArgumentOutOfRangeException(nameof(thresholds), "阈值除数必须大于 0。");
}
if (string.IsNullOrWhiteSpace(threshold.Suffix))
{
throw new ArgumentException("阈值后缀不能为空。", nameof(thresholds));
}
if (previousDivisor.HasValue && threshold.Divisor <= previousDivisor.Value)
{
throw new ArgumentException("阈值除数必须严格递增。", nameof(thresholds));
}
previousDivisor = threshold.Divisor;
}
}
private static bool TryFormatSpecialFloatingPoint<T>(
T value,
IFormatProvider? provider,
out string result)
{
object? boxedValue = value;
if (boxedValue is null)
{
result = string.Empty;
return false;
}
switch (boxedValue)
{
case float floatValue when float.IsNaN(floatValue) || float.IsInfinity(floatValue):
result = floatValue.ToString(null, provider);
return true;
case double doubleValue when double.IsNaN(doubleValue) || double.IsInfinity(doubleValue):
result = doubleValue.ToString(null, provider);
return true;
default:
result = string.Empty;
return false;
}
}
private bool TryFormatConvertible(object value, NumericFormatOptions options, out string result)
{
if (value is not IConvertible convertible)
{
result = string.Empty;
return false;
}
try
{
var decimalValue = convertible.ToDecimal(options.FormatProvider ?? CultureInfo.InvariantCulture);
return TryFormatDecimal(decimalValue, options, out result);
}
catch
{
result = string.Empty;
return false;
}
}
private bool TryFormatBigInteger(BigInteger value, NumericFormatOptions options, out string result)
{
try
{
return TryFormatDecimal((decimal)value, options, out result);
}
catch (OverflowException)
{
var doubleValue = (double)value;
if (TryFormatSpecialFloatingPoint(doubleValue, options.FormatProvider, out result))
{
return true;
}
return TryFormatDouble(doubleValue, options, out result);
}
}
private bool TryFormatDecimal(decimal value, NumericFormatOptions options, out string result)
{
var absoluteValue = Math.Abs(value);
if (absoluteValue < options.CompactThreshold)
{
result = FormatPlainDecimal(value, options);
return true;
}
var suffixIndex = FindThresholdIndex(absoluteValue);
if (suffixIndex < 0)
{
result = FormatPlainDecimal(value, options);
return true;
}
var scaledValue = RoundScaledDecimal(absoluteValue, suffixIndex, options, out suffixIndex);
result = ComposeResult(value < 0m, FormatDecimalCore(scaledValue, options, false), suffixIndex);
return true;
}
private bool TryFormatDouble(double value, NumericFormatOptions options, out string result)
{
var absoluteValue = Math.Abs(value);
if (absoluteValue < (double)options.CompactThreshold)
{
result = FormatPlainDouble(value, options);
return true;
}
var suffixIndex = FindThresholdIndex(absoluteValue);
if (suffixIndex < 0)
{
result = FormatPlainDouble(value, options);
return true;
}
var scaledValue = RoundScaledDouble(absoluteValue, suffixIndex, options, out suffixIndex);
result = ComposeResult(value < 0d, FormatDoubleCore(scaledValue, options, false), suffixIndex);
return true;
}
private string ComposeResult(bool negative, string numericPart, int suffixIndex)
{
return $"{(negative ? "-" : string.Empty)}{numericPart}{_thresholds[suffixIndex].Suffix}";
}
private int FindThresholdIndex(decimal absoluteValue)
{
for (var i = _thresholds.Length - 1; i >= 0; i--)
{
if (absoluteValue >= _thresholds[i].Divisor)
{
return i;
}
}
return -1;
}
private int FindThresholdIndex(double absoluteValue)
{
for (var i = _thresholds.Length - 1; i >= 0; i--)
{
if (absoluteValue >= (double)_thresholds[i].Divisor)
{
return i;
}
}
return -1;
}
private decimal RoundScaledDecimal(decimal absoluteValue, int suffixIndex, NumericFormatOptions options,
out int resolvedIndex)
{
resolvedIndex = suffixIndex;
var roundedValue = RoundDecimal(absoluteValue / _thresholds[resolvedIndex].Divisor, options);
while (resolvedIndex < _thresholds.Length - 1)
{
var promoteThreshold = _thresholds[resolvedIndex + 1].Divisor / _thresholds[resolvedIndex].Divisor;
if (roundedValue < promoteThreshold)
{
break;
}
resolvedIndex++;
roundedValue = RoundDecimal(absoluteValue / _thresholds[resolvedIndex].Divisor, options);
}
return roundedValue;
}
private double RoundScaledDouble(double absoluteValue, int suffixIndex, NumericFormatOptions options,
out int resolvedIndex)
{
resolvedIndex = suffixIndex;
var roundedValue = RoundDouble(absoluteValue / (double)_thresholds[resolvedIndex].Divisor, options);
while (resolvedIndex < _thresholds.Length - 1)
{
var promoteThreshold =
(double)(_thresholds[resolvedIndex + 1].Divisor / _thresholds[resolvedIndex].Divisor);
if (roundedValue < promoteThreshold)
{
break;
}
resolvedIndex++;
roundedValue = RoundDouble(absoluteValue / (double)_thresholds[resolvedIndex].Divisor, options);
}
return roundedValue;
}
private static decimal RoundDecimal(decimal value, NumericFormatOptions options)
{
return Math.Round(value, options.MaxDecimalPlaces, options.MidpointRounding);
}
private static double RoundDouble(double value, NumericFormatOptions options)
{
return Math.Round(value, options.MaxDecimalPlaces, options.MidpointRounding);
}
private static string FormatPlainDecimal(decimal value, NumericFormatOptions options)
{
return FormatDecimalCore(RoundDecimal(value, options), options, options.UseGroupingBelowThreshold);
}
private static string FormatPlainDouble(double value, NumericFormatOptions options)
{
return FormatDoubleCore(RoundDouble(value, options), options, options.UseGroupingBelowThreshold);
}
private static string FormatDecimalCore(decimal value, NumericFormatOptions options, bool useGrouping)
{
return value.ToString(BuildFormatString(options, useGrouping), options.FormatProvider);
}
private static string FormatDoubleCore(double value, NumericFormatOptions options, bool useGrouping)
{
return value.ToString(BuildFormatString(options, useGrouping), options.FormatProvider);
}
private static string BuildFormatString(NumericFormatOptions options, bool useGrouping)
{
var integerPart = useGrouping ? "#,0" : "0";
if (options.MaxDecimalPlaces == 0)
{
return integerPart;
}
if (!options.TrimTrailingZeros)
{
var fixedDigits = Math.Max(options.MaxDecimalPlaces, options.MinDecimalPlaces);
return $"{integerPart}.{new string('0', fixedDigits)}";
}
var requiredDigits = new string('0', options.MinDecimalPlaces);
var optionalDigits = new string('#', options.MaxDecimalPlaces - options.MinDecimalPlaces);
return $"{integerPart}.{requiredDigits}{optionalDigits}";
}
}

View File

@ -1,7 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks> <TestTargetFrameworks Condition="'$(TestTargetFrameworks)' == ''">net10.0</TestTargetFrameworks>
<TargetFrameworks>$(TestTargetFrameworks)</TargetFrameworks>
<ImplicitUsings>disable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>

View File

@ -19,7 +19,7 @@
<Using Include="GFramework.Game.Abstractions"/> <Using Include="GFramework.Game.Abstractions"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Update="Meziantou.Analyzer" Version="3.0.19"> <PackageReference Update="Meziantou.Analyzer" Version="3.0.25">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference> </PackageReference>

View File

@ -0,0 +1,25 @@
// Copyright (c) 2026 GeWuYou
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
namespace GFramework.Game.Abstractions.Routing;
/// <summary>
/// 路由项接口,表示可路由的对象
/// </summary>
public interface IRoute
{
/// <summary>
/// 路由键值,用于唯一标识路由项
/// </summary>
string Key { get; }
}

View File

@ -0,0 +1,26 @@
// Copyright (c) 2026 GeWuYou
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
namespace GFramework.Game.Abstractions.Routing;
/// <summary>
/// 路由上下文接口,表示路由进入时的参数
/// </summary>
/// <remarks>
/// 这是一个标记接口,用于类型约束。
/// 具体的路由上下文类型应该实现此接口。
/// </remarks>
public interface IRouteContext
{
// 标记接口,用于类型约束
}

View File

@ -0,0 +1,54 @@
// Copyright (c) 2026 GeWuYou
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
namespace GFramework.Game.Abstractions.Routing;
/// <summary>
/// 路由守卫接口,用于控制路由的进入和离开
/// </summary>
/// <typeparam name="TRoute">路由项类型</typeparam>
public interface IRouteGuard<TRoute> where TRoute : IRoute
{
/// <summary>
/// 守卫优先级,数值越小优先级越高
/// </summary>
/// <remarks>
/// 守卫按优先级从小到大依次执行。
/// 建议使用 0-100 的范围,默认为 50。
/// </remarks>
int Priority { get; }
/// <summary>
/// 是否可以中断后续守卫的执行
/// </summary>
/// <remarks>
/// 如果为 true,当此守卫返回 true 或抛出异常时,将中断后续守卫的执行。
/// 如果为 false,将继续执行后续守卫。
/// </remarks>
bool CanInterrupt { get; }
/// <summary>
/// 检查是否可以进入指定路由
/// </summary>
/// <param name="routeKey">路由键值</param>
/// <param name="context">路由上下文</param>
/// <returns>如果允许进入返回 true,否则返回 false</returns>
ValueTask<bool> CanEnterAsync(string routeKey, IRouteContext? context);
/// <summary>
/// 检查是否可以离开指定路由
/// </summary>
/// <param name="routeKey">路由键值</param>
/// <returns>如果允许离开返回 true,否则返回 false</returns>
ValueTask<bool> CanLeaveAsync(string routeKey);
}

View File

@ -11,20 +11,16 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
using GFramework.Game.Abstractions.Routing;
namespace GFramework.Game.Abstractions.Scene; namespace GFramework.Game.Abstractions.Scene;
/// <summary> /// <summary>
/// 场景行为接口,定义了场景生命周期管理的标准方法。 /// 场景行为接口,定义了场景生命周期管理的标准方法。
/// 实现此接口的类需要处理场景的加载、激活、暂停、恢复和卸载等核心操作。 /// 实现此接口的类需要处理场景的加载、激活、暂停、恢复和卸载等核心操作。
/// </summary> /// </summary>
public interface ISceneBehavior public interface ISceneBehavior : IRoute
{ {
/// <summary>
/// 获取场景的唯一标识符。
/// 用于区分不同的场景实例。
/// </summary>
string Key { get; }
/// <summary> /// <summary>
/// 获取场景的原始对象。 /// 获取场景的原始对象。
/// </summary> /// </summary>

View File

@ -11,10 +11,12 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
using GFramework.Game.Abstractions.Routing;
namespace GFramework.Game.Abstractions.Scene; namespace GFramework.Game.Abstractions.Scene;
/// <summary> /// <summary>
/// 场景进入参数接口 /// 场景进入参数接口
/// 该接口用于定义场景跳转时传递的参数数据结构 /// 该接口用于定义场景跳转时传递的参数数据结构
/// </summary> /// </summary>
public interface ISceneEnterParam; public interface ISceneEnterParam : IRouteContext;

View File

@ -11,40 +11,29 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
using GFramework.Game.Abstractions.Routing;
namespace GFramework.Game.Abstractions.Scene; namespace GFramework.Game.Abstractions.Scene;
/// <summary> /// <summary>
/// 场景路由守卫接口,用于在场景切换前进行权限检查和条件验证。 /// 场景路由守卫接口,用于在场景切换前进行权限检查和条件验证。
/// 实现此接口可以拦截场景的进入和离开操作。 /// 实现此接口可以拦截场景的进入和离开操作。
/// </summary> /// </summary>
public interface ISceneRouteGuard public interface ISceneRouteGuard : IRouteGuard<ISceneBehavior>
{ {
/// <summary>
/// 获取守卫的执行优先级。
/// 数值越小优先级越高,越先执行。
/// 建议范围:-1000 到 1000。
/// </summary>
int Priority { get; }
/// <summary>
/// 获取守卫是否可以中断后续守卫的执行。
/// true 表示当前守卫通过后,可以跳过后续守卫直接允许操作。
/// false 表示即使当前守卫通过,仍需执行所有后续守卫。
/// </summary>
bool CanInterrupt { get; }
/// <summary> /// <summary>
/// 异步检查是否允许进入指定场景。 /// 异步检查是否允许进入指定场景。
/// </summary> /// </summary>
/// <param name="sceneKey">目标场景的唯一标识符。</param> /// <param name="sceneKey">目标场景的唯一标识符。</param>
/// <param name="param">场景进入参数,可能包含初始化数据或上下文信息。</param> /// <param name="param">场景进入参数,可能包含初始化数据或上下文信息。</param>
/// <returns>如果允许进入则返回 true否则返回 false。</returns> /// <returns>如果允许进入则返回 true否则返回 false。</returns>
Task<bool> CanEnterAsync(string sceneKey, ISceneEnterParam? param); ValueTask<bool> CanEnterAsync(string sceneKey, ISceneEnterParam? param);
/// <summary> /// <summary>
/// 异步检查是否允许离开指定场景。 /// 异步检查是否允许离开指定场景。
/// 该成员显式细化了通用路由守卫的离开检查,使场景守卫在 API 文档中保持场景语义。
/// </summary> /// </summary>
/// <param name="sceneKey">当前场景的唯一标识符。</param> /// <param name="sceneKey">当前场景的唯一标识符。</param>
/// <returns>如果允许离开则返回 true否则返回 false。</returns> /// <returns>如果允许离开则返回 true否则返回 false。</returns>
Task<bool> CanLeaveAsync(string sceneKey); new ValueTask<bool> CanLeaveAsync(string sceneKey);
} }

View File

@ -1,11 +1,12 @@
using GFramework.Game.Abstractions.Enums; using GFramework.Game.Abstractions.Enums;
using GFramework.Game.Abstractions.Routing;
namespace GFramework.Game.Abstractions.UI; namespace GFramework.Game.Abstractions.UI;
/// <summary> /// <summary>
/// UI页面行为接口定义了UI页面的生命周期方法和状态管理 /// UI页面行为接口定义了UI页面的生命周期方法和状态管理
/// </summary> /// </summary>
public interface IUiPageBehavior public interface IUiPageBehavior : IRoute
{ {
/// <summary> /// <summary>
/// 获取或设置当前UI句柄。 /// 获取或设置当前UI句柄。
@ -45,14 +46,6 @@ public interface IUiPageBehavior
/// <returns>页面视图实例。</returns> /// <returns>页面视图实例。</returns>
object View { get; } object View { get; }
/// <summary>
/// 获取键值
/// </summary>
/// <value>返回当前对象的键标识符</value>
string Key { get; }
/// <summary> /// <summary>
/// 获取页面是否处于活动状态 /// 获取页面是否处于活动状态
/// </summary> /// </summary>

View File

@ -1,7 +1,9 @@
namespace GFramework.Game.Abstractions.UI; using GFramework.Game.Abstractions.Routing;
namespace GFramework.Game.Abstractions.UI;
/// <summary> /// <summary>
/// UI页面进入参数接口 /// UI页面进入参数接口
/// 该接口用于定义UI页面跳转时传递的参数数据结构 /// 该接口用于定义UI页面跳转时传递的参数数据结构
/// </summary> /// </summary>
public interface IUiPageEnterParam; public interface IUiPageEnterParam : IRouteContext;

View File

@ -1,34 +1,26 @@
using GFramework.Game.Abstractions.Routing;
namespace GFramework.Game.Abstractions.UI; namespace GFramework.Game.Abstractions.UI;
/// <summary> /// <summary>
/// UI路由守卫接口 /// UI路由守卫接口
/// 用于拦截和处理UI路由切换实现业务逻辑解耦 /// 用于拦截和处理UI路由切换实现业务逻辑解耦
/// </summary> /// </summary>
public interface IUiRouteGuard public interface IUiRouteGuard : IRouteGuard<IUiPageBehavior>
{ {
/// <summary>
/// 守卫优先级,数值越小越先执行
/// </summary>
int Priority { get; }
/// <summary>
/// 是否可中断后续守卫
/// 如果返回 true当该守卫返回 false 时,将停止执行后续守卫
/// </summary>
bool CanInterrupt { get; }
/// <summary> /// <summary>
/// 进入UI前的检查 /// 进入UI前的检查
/// </summary> /// </summary>
/// <param name="uiKey">目标UI标识符</param> /// <param name="uiKey">目标UI标识符</param>
/// <param name="param">进入参数</param> /// <param name="param">进入参数</param>
/// <returns>true表示允许进入false表示拦截</returns> /// <returns>true表示允许进入false表示拦截</returns>
Task<bool> CanEnterAsync(string uiKey, IUiPageEnterParam? param); ValueTask<bool> CanEnterAsync(string uiKey, IUiPageEnterParam? param);
/// <summary> /// <summary>
/// 离开UI前的检查 /// 离开UI前的检查。
/// 该成员显式细化了通用路由守卫的离开检查,使 UI 守卫在 API 文档中保持 UI 语义。
/// </summary> /// </summary>
/// <param name="uiKey">当前UI标识符</param> /// <param name="uiKey">当前UI标识符</param>
/// <returns>true表示允许离开false表示拦截</returns> /// <returns>true表示允许离开false表示拦截</returns>
Task<bool> CanLeaveAsync(string uiKey); new ValueTask<bool> CanLeaveAsync(string uiKey);
} }

View File

@ -113,28 +113,6 @@ public interface IUiRouter : ISystem
/// </summary> /// </summary>
bool Contains(string uiKey); bool Contains(string uiKey);
#region
/// <summary>
/// 注册路由守卫
/// </summary>
/// <param name="guard">守卫实例</param>
void AddGuard(IUiRouteGuard guard);
/// <summary>
/// 注册路由守卫(泛型方法)
/// </summary>
/// <typeparam name="T">守卫类型,必须实现 IUiRouteGuard 且有无参构造函数</typeparam>
void AddGuard<T>() where T : IUiRouteGuard, new();
/// <summary>
/// 移除路由守卫
/// </summary>
/// <param name="guard">守卫实例</param>
void RemoveGuard(IUiRouteGuard guard);
#endregion
#region Layer UI #region Layer UI
/// <summary> /// <summary>

View File

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TestTargetFrameworks Condition="'$(TestTargetFrameworks)' == ''">net10.0</TestTargetFrameworks>
<TargetFrameworks>$(TestTargetFrameworks)</TargetFrameworks>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0"/>
<PackageReference Include="Moq" Version="4.20.72"/>
<PackageReference Include="NUnit" Version="4.5.1"/>
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GFramework.Game\GFramework.Game.csproj"/>
<ProjectReference Include="..\GFramework.Core\GFramework.Core.csproj"/>
</ItemGroup>
</Project>

View File

@ -0,0 +1,19 @@
// Copyright (c) 2026 GeWuYou
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
global using NUnit.Framework;
global using Moq;
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading.Tasks;

View File

@ -0,0 +1,582 @@
// Copyright (c) 2026 GeWuYou
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using GFramework.Game.Abstractions.Routing;
using GFramework.Game.Routing;
namespace GFramework.Game.Tests.Routing;
/// <summary>
/// RouterBase 单元测试
/// </summary>
[TestFixture]
public class RouterBaseTests
{
/// <summary>
/// 测试用路由项
/// </summary>
private class TestRoute : IRoute
{
public string Key { get; set; } = string.Empty;
}
/// <summary>
/// 测试用路由上下文
/// </summary>
private class TestContext : IRouteContext
{
public string? Data { get; set; }
}
/// <summary>
/// 测试用路由守卫
/// </summary>
private class TestGuard : IRouteGuard<TestRoute>
{
public Func<string, IRouteContext?, ValueTask<bool>>? EnterFunc { get; set; }
public Func<string, ValueTask<bool>>? LeaveFunc { get; set; }
public int Priority { get; set; }
public bool CanInterrupt { get; set; }
public ValueTask<bool> CanEnterAsync(string routeKey, IRouteContext? context)
{
return EnterFunc?.Invoke(routeKey, context) ?? ValueTask.FromResult(true);
}
public ValueTask<bool> CanLeaveAsync(string routeKey)
{
return LeaveFunc?.Invoke(routeKey) ?? ValueTask.FromResult(true);
}
}
/// <summary>
/// 测试用路由器实现
/// </summary>
private class TestRouter : RouterBase<TestRoute, TestContext>
{
public bool HandlersRegistered { get; private set; }
// 暴露 Stack 用于测试
public new Stack<TestRoute> Stack => base.Stack;
protected override void OnInit()
{
// 测试用路由器不需要初始化逻辑
}
protected override void RegisterHandlers()
{
HandlersRegistered = true;
}
// 暴露 protected 方法用于测试
public new Task<bool> ExecuteEnterGuardsAsync(string routeKey, TestContext? context)
{
return base.ExecuteEnterGuardsAsync(routeKey, context);
}
public new Task<bool> ExecuteLeaveGuardsAsync(string routeKey)
{
return base.ExecuteLeaveGuardsAsync(routeKey);
}
}
[Test]
public void AddGuard_ShouldAddGuardToList()
{
// Arrange
var router = new TestRouter();
var guard = new TestGuard { Priority = 10 };
// Act
router.AddGuard(guard);
// Assert - 通过尝试添加相同守卫来验证
Assert.DoesNotThrow(() => router.AddGuard(guard));
}
[Test]
public void AddGuard_ShouldSortByPriority()
{
// Arrange
var router = new TestRouter();
var guard1 = new TestGuard { Priority = 20 };
var guard2 = new TestGuard { Priority = 10 };
var guard3 = new TestGuard { Priority = 30 };
// Act
router.AddGuard(guard1);
router.AddGuard(guard2);
router.AddGuard(guard3);
// Assert - 通过执行守卫来验证顺序
var executionOrder = new List<int>();
guard1.EnterFunc = (_, _) =>
{
executionOrder.Add(1);
return ValueTask.FromResult(true);
};
guard2.EnterFunc = (_, _) =>
{
executionOrder.Add(2);
return ValueTask.FromResult(true);
};
guard3.EnterFunc = (_, _) =>
{
executionOrder.Add(3);
return ValueTask.FromResult(true);
};
router.ExecuteEnterGuardsAsync("test", null).Wait();
Assert.That(executionOrder, Is.EqualTo(new[] { 2, 1, 3 }));
}
[Test]
public void AddGuard_WithGeneric_ShouldCreateAndAddGuard()
{
// Arrange
var router = new TestRouter();
// Act & Assert
Assert.DoesNotThrow(() => router.AddGuard<TestGuard>());
}
[Test]
public void AddGuard_WithNull_ShouldThrowArgumentNullException()
{
// Arrange
var router = new TestRouter();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => router.AddGuard(null!));
}
[Test]
public void RemoveGuard_ShouldRemoveGuardFromList()
{
// Arrange
var router = new TestRouter();
var guard = new TestGuard { Priority = 10 };
router.AddGuard(guard);
// Act
router.RemoveGuard(guard);
// Assert - 守卫应该被移除,不会再执行
var executed = false;
guard.EnterFunc = (_, _) =>
{
executed = true;
return ValueTask.FromResult(true);
};
router.ExecuteEnterGuardsAsync("test", null).Wait();
Assert.That(executed, Is.False);
}
[Test]
public void RemoveGuard_WithNull_ShouldThrowArgumentNullException()
{
// Arrange
var router = new TestRouter();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => router.RemoveGuard(null!));
}
[Test]
public async Task ExecuteEnterGuardsAsync_WithNoGuards_ShouldReturnTrue()
{
// Arrange
var router = new TestRouter();
// Act
var result = await router.ExecuteEnterGuardsAsync("test", null);
// Assert
Assert.That(result, Is.True);
}
[Test]
public async Task ExecuteEnterGuardsAsync_WithAllowingGuard_ShouldReturnTrue()
{
// Arrange
var router = new TestRouter();
var guard = new TestGuard
{
Priority = 10,
EnterFunc = (_, _) => ValueTask.FromResult(true)
};
router.AddGuard(guard);
// Act
var result = await router.ExecuteEnterGuardsAsync("test", null);
// Assert
Assert.That(result, Is.True);
}
[Test]
public async Task ExecuteEnterGuardsAsync_WithBlockingGuard_ShouldReturnFalse()
{
// Arrange
var router = new TestRouter();
var guard = new TestGuard
{
Priority = 10,
EnterFunc = (_, _) => ValueTask.FromResult(false)
};
router.AddGuard(guard);
// Act
var result = await router.ExecuteEnterGuardsAsync("test", null);
// Assert
Assert.That(result, Is.False);
}
[Test]
public async Task ExecuteEnterGuardsAsync_WithInterruptingGuard_ShouldStopExecution()
{
// Arrange
var router = new TestRouter();
var guard1 = new TestGuard
{
Priority = 10,
CanInterrupt = true,
EnterFunc = (_, _) => ValueTask.FromResult(true)
};
var guard2Executed = false;
var guard2 = new TestGuard
{
Priority = 20,
EnterFunc = (_, _) =>
{
guard2Executed = true;
return ValueTask.FromResult(true);
}
};
router.AddGuard(guard1);
router.AddGuard(guard2);
// Act
var result = await router.ExecuteEnterGuardsAsync("test", null);
// Assert
Assert.That(result, Is.True);
Assert.That(guard2Executed, Is.False);
}
[Test]
public async Task ExecuteEnterGuardsAsync_WithThrowingGuard_ShouldContinueIfNotInterrupting()
{
// Arrange
var router = new TestRouter();
var guard1 = new TestGuard
{
Priority = 10,
CanInterrupt = false,
EnterFunc = (_, _) => throw new InvalidOperationException("Test exception")
};
var guard2Executed = false;
var guard2 = new TestGuard
{
Priority = 20,
EnterFunc = (_, _) =>
{
guard2Executed = true;
return ValueTask.FromResult(true);
}
};
router.AddGuard(guard1);
router.AddGuard(guard2);
// Act
var result = await router.ExecuteEnterGuardsAsync("test", null);
// Assert
Assert.That(result, Is.True);
Assert.That(guard2Executed, Is.True);
}
[Test]
public async Task ExecuteEnterGuardsAsync_WithThrowingInterruptingGuard_ShouldReturnFalse()
{
// Arrange
var router = new TestRouter();
var guard = new TestGuard
{
Priority = 10,
CanInterrupt = true,
EnterFunc = (_, _) => throw new InvalidOperationException("Test exception")
};
router.AddGuard(guard);
// Act
var result = await router.ExecuteEnterGuardsAsync("test", null);
// Assert
Assert.That(result, Is.False);
}
[Test]
public async Task ExecuteLeaveGuardsAsync_WithNoGuards_ShouldReturnTrue()
{
// Arrange
var router = new TestRouter();
// Act
var result = await router.ExecuteLeaveGuardsAsync("test");
// Assert
Assert.That(result, Is.True);
}
[Test]
public async Task ExecuteLeaveGuardsAsync_WithAllowingGuard_ShouldReturnTrue()
{
// Arrange
var router = new TestRouter();
var guard = new TestGuard
{
Priority = 10,
LeaveFunc = _ => ValueTask.FromResult(true)
};
router.AddGuard(guard);
// Act
var result = await router.ExecuteLeaveGuardsAsync("test");
// Assert
Assert.That(result, Is.True);
}
[Test]
public async Task ExecuteLeaveGuardsAsync_WithBlockingGuard_ShouldReturnFalse()
{
// Arrange
var router = new TestRouter();
var guard = new TestGuard
{
Priority = 10,
LeaveFunc = _ => ValueTask.FromResult(false)
};
router.AddGuard(guard);
// Act
var result = await router.ExecuteLeaveGuardsAsync("test");
// Assert
Assert.That(result, Is.False);
}
[Test]
public void Contains_WithEmptyStack_ShouldReturnFalse()
{
// Arrange
var router = new TestRouter();
// Act
var result = router.Contains("test");
// Assert
Assert.That(result, Is.False);
}
[Test]
public void Contains_WithMatchingRoute_ShouldReturnTrue()
{
// Arrange
var router = new TestRouter();
var route = new TestRoute { Key = "test" };
router.Stack.Push(route);
// Act
var result = router.Contains("test");
// Assert
Assert.That(result, Is.True);
}
[Test]
public void Contains_WithNonMatchingRoute_ShouldReturnFalse()
{
// Arrange
var router = new TestRouter();
var route = new TestRoute { Key = "test1" };
router.Stack.Push(route);
// Act
var result = router.Contains("test2");
// Assert
Assert.That(result, Is.False);
}
[Test]
public void PeekKey_WithEmptyStack_ShouldReturnEmptyString()
{
// Arrange
var router = new TestRouter();
// Act
var result = router.PeekKey();
// Assert
Assert.That(result, Is.EqualTo(string.Empty));
}
[Test]
public void PeekKey_WithRoute_ShouldReturnRouteKey()
{
// Arrange
var router = new TestRouter();
var route = new TestRoute { Key = "test" };
router.Stack.Push(route);
// Act
var result = router.PeekKey();
// Assert
Assert.That(result, Is.EqualTo("test"));
}
[Test]
public void IsTop_WithEmptyStack_ShouldReturnFalse()
{
// Arrange
var router = new TestRouter();
// Act
var result = router.IsTop("test");
// Assert
Assert.That(result, Is.False);
}
[Test]
public void IsTop_WithMatchingRoute_ShouldReturnTrue()
{
// Arrange
var router = new TestRouter();
var route = new TestRoute { Key = "test" };
router.Stack.Push(route);
// Act
var result = router.IsTop("test");
// Assert
Assert.That(result, Is.True);
}
[Test]
public void IsTop_WithNonMatchingRoute_ShouldReturnFalse()
{
// Arrange
var router = new TestRouter();
var route = new TestRoute { Key = "test1" };
router.Stack.Push(route);
// Act
var result = router.IsTop("test2");
// Assert
Assert.That(result, Is.False);
}
[Test]
public void Current_WithEmptyStack_ShouldReturnNull()
{
// Arrange
var router = new TestRouter();
// Act
var result = router.Current;
// Assert
Assert.That(result, Is.Null);
}
[Test]
public void Current_WithRoute_ShouldReturnTopRoute()
{
// Arrange
var router = new TestRouter();
var route = new TestRoute { Key = "test" };
router.Stack.Push(route);
// Act
var result = router.Current;
// Assert
Assert.That(result, Is.EqualTo(route));
}
[Test]
public void CurrentKey_WithEmptyStack_ShouldReturnNull()
{
// Arrange
var router = new TestRouter();
// Act
var result = router.CurrentKey;
// Assert
Assert.That(result, Is.Null);
}
[Test]
public void CurrentKey_WithRoute_ShouldReturnRouteKey()
{
// Arrange
var router = new TestRouter();
var route = new TestRoute { Key = "test" };
router.Stack.Push(route);
// Act
var result = router.CurrentKey;
// Assert
Assert.That(result, Is.EqualTo("test"));
}
[Test]
public void Count_WithEmptyStack_ShouldReturnZero()
{
// Arrange
var router = new TestRouter();
// Act
var result = router.Count;
// Assert
Assert.That(result, Is.EqualTo(0));
}
[Test]
public void Count_WithRoutes_ShouldReturnCorrectCount()
{
// Arrange
var router = new TestRouter();
router.Stack.Push(new TestRoute { Key = "test1" });
router.Stack.Push(new TestRoute { Key = "test2" });
// Act
var result = router.Count;
// Assert
Assert.That(result, Is.EqualTo(2));
}
}

View File

@ -0,0 +1,244 @@
// Copyright (c) 2026 GeWuYou
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Logging;
using GFramework.Core.Systems;
using GFramework.Game.Abstractions.Routing;
namespace GFramework.Game.Routing;
/// <summary>
/// 路由器基类,提供通用的路由管理功能
/// </summary>
/// <typeparam name="TRoute">路由项类型,必须实现 IRoute 接口</typeparam>
/// <typeparam name="TContext">路由上下文类型,必须实现 IRouteContext 接口</typeparam>
/// <remarks>
/// 此基类提供了以下通用功能:
/// - 路由守卫管理 (AddGuard/RemoveGuard)
/// - 守卫执行逻辑 (ExecuteEnterGuardsAsync/ExecuteLeaveGuardsAsync)
/// - 路由栈管理 (Stack/Current/CurrentKey)
/// - 栈操作方法 (Contains/PeekKey/IsTop)
/// </remarks>
public abstract class RouterBase<TRoute, TContext> : AbstractSystem
where TRoute : IRoute
where TContext : IRouteContext
{
private static readonly ILogger Log =
LoggerFactoryResolver.Provider.CreateLogger(nameof(RouterBase<TRoute, TContext>));
/// <summary>
/// 路由守卫列表,按优先级排序
/// </summary>
private readonly List<IRouteGuard<TRoute>> _guards = new();
/// <summary>
/// 路由栈,用于管理路由的显示顺序和导航历史
/// </summary>
protected readonly Stack<TRoute> Stack = new();
/// <summary>
/// 获取当前路由 (栈顶元素)
/// </summary>
public TRoute? Current => Stack.Count > 0 ? Stack.Peek() : default;
/// <summary>
/// 获取当前路由的键值
/// </summary>
public string? CurrentKey => Current?.Key;
/// <summary>
/// 获取栈深度
/// </summary>
public int Count => Stack.Count;
#region Abstract Methods
/// <summary>
/// 注册过渡处理器 (由子类实现)
/// </summary>
/// <remarks>
/// 子类应该在此方法中注册所有需要的过渡处理器。
/// 此方法在 OnInit 中被调用。
/// </remarks>
protected abstract void RegisterHandlers();
#endregion
#region Guard Management
/// <summary>
/// 添加路由守卫
/// </summary>
/// <param name="guard">路由守卫实例</param>
/// <exception cref="ArgumentNullException">当守卫实例为 null 时抛出</exception>
public void AddGuard(IRouteGuard<TRoute> guard)
{
ArgumentNullException.ThrowIfNull(guard);
if (_guards.Contains(guard))
{
Log.Debug("Guard already registered: {0}", guard.GetType().Name);
return;
}
_guards.Add(guard);
_guards.Sort((a, b) => a.Priority.CompareTo(b.Priority));
Log.Debug("Guard registered: {0}, Priority={1}", guard.GetType().Name, guard.Priority);
}
/// <summary>
/// 添加路由守卫 (泛型版本)
/// </summary>
/// <typeparam name="T">守卫类型,必须实现 IRouteGuard 接口且有无参构造函数</typeparam>
public void AddGuard<T>() where T : IRouteGuard<TRoute>, new()
{
AddGuard(new T());
}
/// <summary>
/// 移除路由守卫
/// </summary>
/// <param name="guard">要移除的路由守卫实例</param>
/// <exception cref="ArgumentNullException">当守卫实例为 null 时抛出</exception>
public void RemoveGuard(IRouteGuard<TRoute> guard)
{
ArgumentNullException.ThrowIfNull(guard);
if (_guards.Remove(guard))
Log.Debug("Guard removed: {0}", guard.GetType().Name);
}
#endregion
#region Guard Execution
/// <summary>
/// 执行进入守卫检查
/// </summary>
/// <param name="routeKey">路由键值</param>
/// <param name="context">路由上下文</param>
/// <returns>如果所有守卫都允许进入返回 true,否则返回 false</returns>
/// <remarks>
/// 守卫按优先级从小到大依次执行。
/// 如果某个守卫返回 false 且 CanInterrupt 为 true,则中断后续守卫的执行。
/// 如果某个守卫抛出异常且 CanInterrupt 为 true,则中断后续守卫的执行。
/// </remarks>
protected async Task<bool> ExecuteEnterGuardsAsync(string routeKey, TContext? context)
{
foreach (var guard in _guards)
{
try
{
Log.Debug("Executing enter guard: {0} for {1}", guard.GetType().Name, routeKey);
var canEnter = await guard.CanEnterAsync(routeKey, context);
if (!canEnter)
{
Log.Debug("Enter guard blocked: {0}", guard.GetType().Name);
return false;
}
if (guard.CanInterrupt)
{
Log.Debug("Enter guard {0} passed, can interrupt = true", guard.GetType().Name);
return true;
}
}
catch (Exception ex)
{
Log.Error("Enter guard {0} failed: {1}", guard.GetType().Name, ex.Message);
if (guard.CanInterrupt)
return false;
}
}
return true;
}
/// <summary>
/// 执行离开守卫检查
/// </summary>
/// <param name="routeKey">路由键值</param>
/// <returns>如果所有守卫都允许离开返回 true,否则返回 false</returns>
/// <remarks>
/// 守卫按优先级从小到大依次执行。
/// 如果某个守卫返回 false 且 CanInterrupt 为 true,则中断后续守卫的执行。
/// 如果某个守卫抛出异常且 CanInterrupt 为 true,则中断后续守卫的执行。
/// </remarks>
protected async Task<bool> ExecuteLeaveGuardsAsync(string routeKey)
{
foreach (var guard in _guards)
{
try
{
Log.Debug("Executing leave guard: {0} for {1}", guard.GetType().Name, routeKey);
var canLeave = await guard.CanLeaveAsync(routeKey);
if (!canLeave)
{
Log.Debug("Leave guard blocked: {0}", guard.GetType().Name);
return false;
}
if (guard.CanInterrupt)
{
Log.Debug("Leave guard {0} passed, can interrupt = true", guard.GetType().Name);
return true;
}
}
catch (Exception ex)
{
Log.Error("Leave guard {0} failed: {1}", guard.GetType().Name, ex.Message);
if (guard.CanInterrupt)
return false;
}
}
return true;
}
#endregion
#region Stack Operations
/// <summary>
/// 检查栈中是否包含指定路由
/// </summary>
/// <param name="routeKey">路由键值</param>
/// <returns>如果栈中包含指定路由返回 true,否则返回 false</returns>
public bool Contains(string routeKey)
{
return Stack.Any(r => r.Key == routeKey);
}
/// <summary>
/// 获取栈顶路由的键值
/// </summary>
/// <returns>栈顶路由的键值,如果栈为空则返回空字符串</returns>
public string PeekKey()
{
return Stack.Count == 0 ? string.Empty : Stack.Peek().Key;
}
/// <summary>
/// 判断栈顶是否为指定路由
/// </summary>
/// <param name="routeKey">路由键值</param>
/// <returns>如果栈顶是指定路由返回 true,否则返回 false</returns>
public bool IsTop(string routeKey)
{
return Stack.Count != 0 && Stack.Peek().Key.Equals(routeKey);
}
#endregion
}

View File

@ -14,9 +14,9 @@
using GFramework.Core.Abstractions.Logging; using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Extensions; using GFramework.Core.Extensions;
using GFramework.Core.Logging; using GFramework.Core.Logging;
using GFramework.Core.Systems;
using GFramework.Game.Abstractions.Enums; using GFramework.Game.Abstractions.Enums;
using GFramework.Game.Abstractions.Scene; using GFramework.Game.Abstractions.Scene;
using GFramework.Game.Routing;
namespace GFramework.Game.Scene; namespace GFramework.Game.Scene;
@ -25,15 +25,13 @@ namespace GFramework.Game.Scene;
/// 实现了 <see cref="ISceneRouter"/> 接口,用于管理场景的加载、替换和卸载操作。 /// 实现了 <see cref="ISceneRouter"/> 接口,用于管理场景的加载、替换和卸载操作。
/// </summary> /// </summary>
public abstract class SceneRouterBase public abstract class SceneRouterBase
: AbstractSystem, ISceneRouter : RouterBase<ISceneBehavior, ISceneEnterParam>, ISceneRouter
{ {
private static readonly ILogger Log = private static readonly ILogger Log =
LoggerFactoryResolver.Provider.CreateLogger(nameof(SceneRouterBase)); LoggerFactoryResolver.Provider.CreateLogger(nameof(SceneRouterBase));
private readonly List<ISceneRouteGuard> _guards = new();
private readonly SceneTransitionPipeline _pipeline = new(); private readonly SceneTransitionPipeline _pipeline = new();
private readonly Stack<ISceneBehavior> _stack = new();
private readonly SemaphoreSlim _transitionLock = new(1, 1); private readonly SemaphoreSlim _transitionLock = new(1, 1);
private ISceneFactory _factory = null!; private ISceneFactory _factory = null!;
@ -45,17 +43,17 @@ public abstract class SceneRouterBase
/// <summary> /// <summary>
/// 获取当前场景行为对象。 /// 获取当前场景行为对象。
/// </summary> /// </summary>
public ISceneBehavior? Current => _stack.Count > 0 ? _stack.Peek() : null; public new ISceneBehavior? Current => Stack.Count > 0 ? Stack.Peek() : null;
/// <summary> /// <summary>
/// 获取当前场景的键名。 /// 获取当前场景的键名。
/// </summary> /// </summary>
public string? CurrentKey => Current?.Key; public new string? CurrentKey => Current?.Key;
/// <summary> /// <summary>
/// 获取场景栈的只读视图,按压入顺序排列(从栈底到栈顶)。 /// 获取场景栈的只读视图,按压入顺序排列(从栈底到栈顶)。
/// </summary> /// </summary>
public IEnumerable<ISceneBehavior> Stack => _stack.Reverse(); IEnumerable<ISceneBehavior> ISceneRouter.Stack => base.Stack.Reverse();
/// <summary> /// <summary>
/// 获取是否正在进行场景转换。 /// 获取是否正在进行场景转换。
@ -115,9 +113,9 @@ public abstract class SceneRouterBase
/// </summary> /// </summary>
/// <param name="sceneKey">场景键名。</param> /// <param name="sceneKey">场景键名。</param>
/// <returns>如果场景在栈中返回true否则返回false。</returns> /// <returns>如果场景在栈中返回true否则返回false。</returns>
public bool Contains(string sceneKey) public new bool Contains(string sceneKey)
{ {
return _stack.Any(s => s.Key == sceneKey); return Stack.Any(s => s.Key == sceneKey);
} }
#endregion #endregion
@ -163,46 +161,10 @@ public abstract class SceneRouterBase
_pipeline.UnregisterAroundHandler(handler); _pipeline.UnregisterAroundHandler(handler);
} }
/// <summary>
/// 添加场景路由守卫。
/// </summary>
/// <param name="guard">守卫实例。</param>
public void AddGuard(ISceneRouteGuard guard)
{
ArgumentNullException.ThrowIfNull(guard);
if (!_guards.Contains(guard))
{
_guards.Add(guard);
_guards.Sort((a, b) => a.Priority.CompareTo(b.Priority));
Log.Debug("Guard added: {0}, Priority={1}", guard.GetType().Name, guard.Priority);
}
}
/// <summary>
/// 添加场景路由守卫(泛型版本)。
/// </summary>
/// <typeparam name="T">守卫类型。</typeparam>
public void AddGuard<T>() where T : ISceneRouteGuard, new()
{
AddGuard(new T());
}
/// <summary>
/// 移除场景路由守卫。
/// </summary>
/// <param name="guard">守卫实例。</param>
public void RemoveGuard(ISceneRouteGuard guard)
{
if (_guards.Remove(guard))
{
Log.Debug("Guard removed: {0}", guard.GetType().Name);
}
}
/// <summary> /// <summary>
/// 注册场景过渡处理器的抽象方法,由子类实现。 /// 注册场景过渡处理器的抽象方法,由子类实现。
/// </summary> /// </summary>
protected abstract void RegisterHandlers(); protected override abstract void RegisterHandlers();
/// <summary> /// <summary>
/// 系统初始化方法,获取场景工厂并注册处理器。 /// 系统初始化方法,获取场景工厂并注册处理器。
@ -281,20 +243,20 @@ public abstract class SceneRouterBase
await scene.OnLoadAsync(param); await scene.OnLoadAsync(param);
// 暂停当前场景 // 暂停当前场景
if (_stack.Count > 0) if (Stack.Count > 0)
{ {
var current = _stack.Peek(); var current = Stack.Peek();
await current.OnPauseAsync(); await current.OnPauseAsync();
} }
// 压入栈 // 压入栈
_stack.Push(scene); Stack.Push(scene);
// 进入场景 // 进入场景
await scene.OnEnterAsync(); await scene.OnEnterAsync();
Log.Debug("Push Scene: {0}, stackCount={1}", Log.Debug("Push Scene: {0}, stackCount={1}",
sceneKey, _stack.Count); sceneKey, Stack.Count);
} }
#endregion #endregion
@ -335,10 +297,10 @@ public abstract class SceneRouterBase
/// <returns>异步任务。</returns> /// <returns>异步任务。</returns>
private async ValueTask PopInternalAsync() private async ValueTask PopInternalAsync()
{ {
if (_stack.Count == 0) if (Stack.Count == 0)
return; return;
var top = _stack.Peek(); var top = Stack.Peek();
// 守卫检查 // 守卫检查
if (!await ExecuteLeaveGuardsAsync(top.Key)) if (!await ExecuteLeaveGuardsAsync(top.Key))
@ -347,7 +309,7 @@ public abstract class SceneRouterBase
return; return;
} }
_stack.Pop(); Stack.Pop();
// 退出场景 // 退出场景
await top.OnExitAsync(); await top.OnExitAsync();
@ -359,13 +321,13 @@ public abstract class SceneRouterBase
Root!.RemoveScene(top); Root!.RemoveScene(top);
// 恢复下一个场景 // 恢复下一个场景
if (_stack.Count > 0) if (Stack.Count > 0)
{ {
var next = _stack.Peek(); var next = Stack.Peek();
await next.OnResumeAsync(); await next.OnResumeAsync();
} }
Log.Debug("Pop Scene, stackCount={0}", _stack.Count); Log.Debug("Pop Scene, stackCount={0}", Stack.Count);
} }
#endregion #endregion
@ -406,7 +368,7 @@ public abstract class SceneRouterBase
/// <returns>异步任务。</returns> /// <returns>异步任务。</returns>
private async ValueTask ClearInternalAsync() private async ValueTask ClearInternalAsync()
{ {
while (_stack.Count > 0) while (Stack.Count > 0)
{ {
await PopInternalAsync(); await PopInternalAsync();
} }
@ -460,82 +422,5 @@ public abstract class SceneRouterBase
Log.Debug("AfterChange phases completed: {0}", @event.TransitionType); Log.Debug("AfterChange phases completed: {0}", @event.TransitionType);
} }
/// <summary>
/// 执行进入场景的守卫检查。
/// 按优先级顺序执行所有守卫的CanEnterAsync方法。
/// </summary>
/// <param name="sceneKey">场景键名。</param>
/// <param name="param">进入参数。</param>
/// <returns>如果所有守卫都允许进入返回true否则返回false。</returns>
private async Task<bool> ExecuteEnterGuardsAsync(string sceneKey, ISceneEnterParam? param)
{
foreach (var guard in _guards)
{
try
{
Log.Debug("Executing enter guard: {0} for {1}", guard.GetType().Name, sceneKey);
var canEnter = await guard.CanEnterAsync(sceneKey, param);
if (!canEnter)
{
Log.Debug("Enter guard blocked: {0}", guard.GetType().Name);
return false;
}
if (guard.CanInterrupt)
{
Log.Debug("Enter guard {0} passed, can interrupt = true", guard.GetType().Name);
return true;
}
}
catch (Exception ex)
{
Log.Error("Enter guard {0} failed: {1}", guard.GetType().Name, ex.Message);
if (guard.CanInterrupt)
return false;
}
}
return true;
}
/// <summary>
/// 执行离开场景的守卫检查。
/// 按优先级顺序执行所有守卫的CanLeaveAsync方法。
/// </summary>
/// <param name="sceneKey">场景键名。</param>
/// <returns>如果所有守卫都允许离开返回true否则返回false。</returns>
private async Task<bool> ExecuteLeaveGuardsAsync(string sceneKey)
{
foreach (var guard in _guards)
{
try
{
Log.Debug("Executing leave guard: {0} for {1}", guard.GetType().Name, sceneKey);
var canLeave = await guard.CanLeaveAsync(sceneKey);
if (!canLeave)
{
Log.Debug("Leave guard blocked: {0}", guard.GetType().Name);
return false;
}
if (guard.CanInterrupt)
{
Log.Debug("Leave guard {0} passed, can interrupt = true", guard.GetType().Name);
return true;
}
}
catch (Exception ex)
{
Log.Error("Leave guard {0} failed: {1}", guard.GetType().Name, ex.Message);
if (guard.CanInterrupt)
return false;
}
}
return true;
}
#endregion #endregion
} }

View File

@ -1,9 +1,9 @@
using GFramework.Core.Abstractions.Logging; using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Extensions; using GFramework.Core.Extensions;
using GFramework.Core.Logging; using GFramework.Core.Logging;
using GFramework.Core.Systems;
using GFramework.Game.Abstractions.Enums; using GFramework.Game.Abstractions.Enums;
using GFramework.Game.Abstractions.UI; using GFramework.Game.Abstractions.UI;
using GFramework.Game.Routing;
namespace GFramework.Game.UI; namespace GFramework.Game.UI;
@ -11,15 +11,10 @@ namespace GFramework.Game.UI;
/// UI路由基类提供页面栈管理和层级UI管理功能 /// UI路由基类提供页面栈管理和层级UI管理功能
/// 负责UI页面的导航、显示、隐藏以及生命周期管理 /// 负责UI页面的导航、显示、隐藏以及生命周期管理
/// </summary> /// </summary>
public abstract class UiRouterBase : AbstractSystem, IUiRouter public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterParam>, IUiRouter
{ {
private static readonly ILogger Log = LoggerFactoryResolver.Provider.CreateLogger(nameof(UiRouterBase)); private static readonly ILogger Log = LoggerFactoryResolver.Provider.CreateLogger(nameof(UiRouterBase));
/// <summary>
/// 路由守卫列表用于控制UI页面的进入和离开
/// </summary>
private readonly List<IUiRouteGuard> _guards = new();
/// <summary> /// <summary>
/// 层级管理字典非栈层级用于管理Overlay、Modal、Toast等浮层UI /// 层级管理字典非栈层级用于管理Overlay、Modal、Toast等浮层UI
/// Key: UiLayer枚举值, Value: InstanceId到PageBehavior的映射字典 /// Key: UiLayer枚举值, Value: InstanceId到PageBehavior的映射字典
@ -31,11 +26,6 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
/// </summary> /// </summary>
private readonly UiTransitionPipeline _pipeline = new(); private readonly UiTransitionPipeline _pipeline = new();
/// <summary>
/// 页面栈用于管理UI页面的显示顺序和导航历史
/// </summary>
private readonly Stack<IUiPageBehavior> _stack = new();
/// <summary> /// <summary>
/// UI工厂实例用于创建UI页面和相关对象 /// UI工厂实例用于创建UI页面和相关对象
/// </summary> /// </summary>
@ -98,7 +88,7 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
} }
var @event = CreateEvent(uiKey, UiTransitionType.Push, policy, param); var @event = CreateEvent(uiKey, UiTransitionType.Push, policy, param);
Log.Debug("Push UI Page: key={0}, policy={1}, stackBefore={2}", uiKey, policy, _stack.Count); Log.Debug("Push UI Page: key={0}, policy={1}, stackBefore={2}", uiKey, policy, Stack.Count);
await _pipeline.ExecuteAroundAsync(@event, async () => await _pipeline.ExecuteAroundAsync(@event, async () =>
{ {
@ -126,7 +116,7 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
} }
var @event = CreateEvent(uiKey, UiTransitionType.Push, policy, param); var @event = CreateEvent(uiKey, UiTransitionType.Push, policy, param);
Log.Debug("Push existing UI Page: key={0}, policy={1}, stackBefore={2}", uiKey, policy, _stack.Count); Log.Debug("Push existing UI Page: key={0}, policy={1}, stackBefore={2}", uiKey, policy, Stack.Count);
await _pipeline.ExecuteAroundAsync(@event, async () => await _pipeline.ExecuteAroundAsync(@event, async () =>
{ {
@ -142,13 +132,13 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
/// <param name="policy">页面弹出策略</param> /// <param name="policy">页面弹出策略</param>
public async ValueTask PopAsync(UiPopPolicy policy = UiPopPolicy.Destroy) public async ValueTask PopAsync(UiPopPolicy policy = UiPopPolicy.Destroy)
{ {
if (_stack.Count == 0) if (Stack.Count == 0)
{ {
Log.Debug("Pop ignored: stack is empty"); Log.Debug("Pop ignored: stack is empty");
return; return;
} }
var leavingUiKey = _stack.Peek().Key; var leavingUiKey = Stack.Peek().Key;
if (!await ExecuteLeaveGuardsAsync(leavingUiKey)) if (!await ExecuteLeaveGuardsAsync(leavingUiKey))
{ {
@ -156,7 +146,7 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
return; return;
} }
var nextUiKey = _stack.Count > 1 ? _stack.ElementAt(1).Key : null; var nextUiKey = Stack.Count > 1 ? Stack.ElementAt(1).Key : null;
var @event = CreateEvent(nextUiKey, UiTransitionType.Pop); var @event = CreateEvent(nextUiKey, UiTransitionType.Pop);
await _pipeline.ExecuteAroundAsync(@event, async () => await _pipeline.ExecuteAroundAsync(@event, async () =>
@ -226,7 +216,7 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
public async ValueTask ClearAsync() public async ValueTask ClearAsync()
{ {
var @event = CreateEvent(string.Empty, UiTransitionType.Clear); var @event = CreateEvent(string.Empty, UiTransitionType.Clear);
Log.Debug("Clear UI Stack, stackCount={0}", _stack.Count); Log.Debug("Clear UI Stack, stackCount={0}", Stack.Count);
await _pipeline.ExecuteAroundAsync(@event, async () => await _pipeline.ExecuteAroundAsync(@event, async () =>
{ {
@ -240,9 +230,9 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
/// 获取栈顶元素的键值 /// 获取栈顶元素的键值
/// </summary> /// </summary>
/// <returns>栈顶UI页面的键值如果栈为空则返回空字符串</returns> /// <returns>栈顶UI页面的键值如果栈为空则返回空字符串</returns>
public string PeekKey() public new string PeekKey()
{ {
return _stack.Count == 0 ? string.Empty : _stack.Peek().Key; return Stack.Count == 0 ? string.Empty : Stack.Peek().Key;
} }
/// <summary> /// <summary>
@ -251,7 +241,7 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
/// <returns>栈顶UI页面行为实例如果栈为空则返回null</returns> /// <returns>栈顶UI页面行为实例如果栈为空则返回null</returns>
public IUiPageBehavior? Peek() public IUiPageBehavior? Peek()
{ {
return _stack.Count == 0 ? null : _stack.Peek(); return Stack.Count == 0 ? null : Stack.Peek();
} }
/// <summary> /// <summary>
@ -259,9 +249,9 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
/// </summary> /// </summary>
/// <param name="uiKey">要检查的UI页面键值</param> /// <param name="uiKey">要检查的UI页面键值</param>
/// <returns>如果栈顶是指定UI则返回true否则返回false</returns> /// <returns>如果栈顶是指定UI则返回true否则返回false</returns>
public bool IsTop(string uiKey) public new bool IsTop(string uiKey)
{ {
return _stack.Count != 0 && _stack.Peek().Key.Equals(uiKey); return Stack.Count != 0 && Stack.Peek().Key.Equals(uiKey);
} }
/// <summary> /// <summary>
@ -269,15 +259,15 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
/// </summary> /// </summary>
/// <param name="uiKey">要检查的UI页面键值</param> /// <param name="uiKey">要检查的UI页面键值</param>
/// <returns>如果栈中包含指定UI则返回true否则返回false</returns> /// <returns>如果栈中包含指定UI则返回true否则返回false</returns>
public bool Contains(string uiKey) public new bool Contains(string uiKey)
{ {
return _stack.Any(p => p.Key.Equals(uiKey)); return Stack.Any(p => p.Key.Equals(uiKey));
} }
/// <summary> /// <summary>
/// 获取栈深度 /// 获取栈深度
/// </summary> /// </summary>
public int Count => _stack.Count; public new int Count => Stack.Count;
#endregion #endregion
@ -458,51 +448,6 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
#endregion #endregion
#region Route Guards
/// <summary>
/// 注册路由守卫
/// </summary>
/// <param name="guard">路由守卫实例</param>
/// <exception cref="ArgumentNullException">当守卫实例为null时抛出</exception>
public void AddGuard(IUiRouteGuard guard)
{
ArgumentNullException.ThrowIfNull(guard);
if (_guards.Contains(guard))
{
Log.Debug("Guard already registered: {0}", guard.GetType().Name);
return;
}
_guards.Add(guard);
_guards.Sort((a, b) => a.Priority.CompareTo(b.Priority));
Log.Debug("Guard registered: {0}, Priority={1}", guard.GetType().Name, guard.Priority);
}
/// <summary>
/// 注册路由守卫(泛型)
/// </summary>
/// <typeparam name="T">路由守卫类型必须实现IUiRouteGuard接口且有无参构造函数</typeparam>
public void AddGuard<T>() where T : IUiRouteGuard, new()
{
AddGuard(new T());
}
/// <summary>
/// 移除路由守卫
/// </summary>
/// <param name="guard">要移除的路由守卫实例</param>
/// <exception cref="ArgumentNullException">当守卫实例为null时抛出</exception>
public void RemoveGuard(IUiRouteGuard guard)
{
ArgumentNullException.ThrowIfNull(guard);
if (_guards.Remove(guard))
Log.Debug("Guard removed: {0}", guard.GetType().Name);
}
#endregion
#region Initialization #region Initialization
/// <summary> /// <summary>
@ -525,7 +470,7 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
/// 抽象方法,用于注册具体的处理程序。 /// 抽象方法,用于注册具体的处理程序。
/// 子类必须实现此方法以完成特定的处理逻辑注册。 /// 子类必须实现此方法以完成特定的处理逻辑注册。
/// </summary> /// </summary>
protected abstract void RegisterHandlers(); protected override abstract void RegisterHandlers();
#endregion #endregion
@ -655,9 +600,9 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
/// <param name="policy">过渡策略</param> /// <param name="policy">过渡策略</param>
private void DoPushPageInternal(IUiPageBehavior page, IUiPageEnterParam? param, UiTransitionPolicy policy) private void DoPushPageInternal(IUiPageBehavior page, IUiPageEnterParam? param, UiTransitionPolicy policy)
{ {
if (_stack.Count > 0) if (Stack.Count > 0)
{ {
var current = _stack.Peek(); var current = Stack.Peek();
Log.Debug("Pause current page: {0}", current.View.GetType().Name); Log.Debug("Pause current page: {0}", current.View.GetType().Name);
current.OnPause(); current.OnPause();
@ -671,9 +616,9 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
Log.Debug("Add page to UiRoot: {0}", page.View.GetType().Name); Log.Debug("Add page to UiRoot: {0}", page.View.GetType().Name);
_uiRoot.AddUiPage(page); _uiRoot.AddUiPage(page);
_stack.Push(page); Stack.Push(page);
Log.Debug("Enter & Show page: {0}, stackAfter={1}", page.View.GetType().Name, _stack.Count); Log.Debug("Enter & Show page: {0}, stackAfter={1}", page.View.GetType().Name, Stack.Count);
page.OnEnter(param); page.OnEnter(param);
page.OnShow(); page.OnShow();
} }
@ -684,12 +629,12 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
/// <param name="policy">页面弹出策略</param> /// <param name="policy">页面弹出策略</param>
private void DoPopInternal(UiPopPolicy policy) private void DoPopInternal(UiPopPolicy policy)
{ {
if (_stack.Count == 0) if (Stack.Count == 0)
return; return;
var top = _stack.Pop(); var top = Stack.Pop();
Log.Debug("Pop UI Page internal: {0}, policy={1}, stackAfterPop={2}", Log.Debug("Pop UI Page internal: {0}, policy={1}, stackAfterPop={2}",
top.GetType().Name, policy, _stack.Count); top.GetType().Name, policy, Stack.Count);
if (policy == UiPopPolicy.Destroy) if (policy == UiPopPolicy.Destroy)
{ {
@ -701,9 +646,9 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
top.OnHide(); top.OnHide();
} }
if (_stack.Count > 0) if (Stack.Count > 0)
{ {
var next = _stack.Peek(); var next = Stack.Peek();
next.OnResume(); next.OnResume();
next.OnShow(); next.OnShow();
} }
@ -715,85 +660,10 @@ public abstract class UiRouterBase : AbstractSystem, IUiRouter
/// <param name="policy">页面弹出策略</param> /// <param name="policy">页面弹出策略</param>
private void DoClearInternal(UiPopPolicy policy) private void DoClearInternal(UiPopPolicy policy)
{ {
Log.Debug("Clear UI Stack internal, count={0}", _stack.Count); Log.Debug("Clear UI Stack internal, count={0}", Stack.Count);
while (_stack.Count > 0) while (Stack.Count > 0)
DoPopInternal(policy); DoPopInternal(policy);
} }
/// <summary>
/// 执行进入守卫检查
/// </summary>
/// <param name="uiKey">UI页面键值</param>
/// <param name="param">页面进入参数</param>
/// <returns>如果允许进入则返回true否则返回false</returns>
private async Task<bool> ExecuteEnterGuardsAsync(string uiKey, IUiPageEnterParam? param)
{
foreach (var guard in _guards)
{
try
{
Log.Debug("Executing enter guard: {0} for {1}", guard.GetType().Name, uiKey);
var canEnter = await guard.CanEnterAsync(uiKey, param);
if (!canEnter)
{
Log.Debug("Enter guard blocked: {0}", guard.GetType().Name);
return false;
}
if (guard.CanInterrupt)
{
Log.Debug("Enter guard {0} passed, can interrupt = true", guard.GetType().Name);
return true;
}
}
catch (Exception ex)
{
Log.Error("Enter guard {0} failed: {1}", guard.GetType().Name, ex.Message);
if (guard.CanInterrupt)
return false;
}
}
return true;
}
/// <summary>
/// 执行离开守卫检查
/// </summary>
/// <param name="uiKey">UI页面键值</param>
/// <returns>如果允许离开则返回true否则返回false</returns>
private async Task<bool> ExecuteLeaveGuardsAsync(string uiKey)
{
foreach (var guard in _guards)
{
try
{
Log.Debug("Executing leave guard: {0} for {1}", guard.GetType().Name, uiKey);
var canLeave = await guard.CanLeaveAsync(uiKey);
if (!canLeave)
{
Log.Debug("Leave guard blocked: {0}", guard.GetType().Name);
return false;
}
if (guard.CanInterrupt)
{
Log.Debug("Leave guard {0} passed, can interrupt = true", guard.GetType().Name);
return true;
}
}
catch (Exception ex)
{
Log.Error("Leave guard {0} failed: {1}", guard.GetType().Name, ex.Message);
if (guard.CanInterrupt)
return false;
}
}
return true;
}
#endregion #endregion
} }

View File

@ -1,7 +1,7 @@
<Project> <Project>
<!-- import parent: https://docs.microsoft.com/en-us/visualstudio/msbuild/customize-your-build --> <!-- import parent: https://docs.microsoft.com/en-us/visualstudio/msbuild/customize-your-build -->
<PropertyGroup> <PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework> <TargetFramework>netstandard2.0</TargetFramework>
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild> <ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
<EmbedUntrackedSources>true</EmbedUntrackedSources> <EmbedUntrackedSources>true</EmbedUntrackedSources>
<!-- <!--

View File

@ -19,7 +19,7 @@
<Folder Include="logging\"/> <Folder Include="logging\"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Update="Meziantou.Analyzer" Version="3.0.19"> <PackageReference Update="Meziantou.Analyzer" Version="3.0.25">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference> </PackageReference>

View File

@ -0,0 +1,40 @@
#nullable enable
namespace GFramework.Godot.SourceGenerators.Abstractions;
/// <summary>
/// 标记 Godot 节点字段Source Generator 会为其生成节点获取逻辑。
/// </summary>
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
public sealed class GetNodeAttribute : Attribute
{
/// <summary>
/// 初始化 <see cref="GetNodeAttribute" /> 的新实例。
/// </summary>
public GetNodeAttribute()
{
}
/// <summary>
/// 初始化 <see cref="GetNodeAttribute" /> 的新实例,并指定节点路径。
/// </summary>
/// <param name="path">节点路径。</param>
public GetNodeAttribute(string path)
{
Path = path;
}
/// <summary>
/// 获取或设置节点路径。未设置时将根据字段名推导。
/// </summary>
public string? Path { get; set; }
/// <summary>
/// 获取或设置节点是否必填。默认为 true。
/// </summary>
public bool Required { get; set; } = true;
/// <summary>
/// 获取或设置节点查找模式。默认为 <see cref="NodeLookupMode.Auto" />。
/// </summary>
public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto;
}

View File

@ -0,0 +1,28 @@
#nullable enable
namespace GFramework.Godot.SourceGenerators.Abstractions;
/// <summary>
/// 节点路径的查找模式。
/// </summary>
public enum NodeLookupMode
{
/// <summary>
/// 自动推断。未显式设置路径时默认按唯一名查找。
/// </summary>
Auto = 0,
/// <summary>
/// 按唯一名查找,对应 Godot 的 %Name 语法。
/// </summary>
UniqueName = 1,
/// <summary>
/// 按相对路径查找。
/// </summary>
RelativePath = 2,
/// <summary>
/// 按绝对路径查找。
/// </summary>
AbsolutePath = 3
}

View File

@ -0,0 +1,37 @@
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
namespace GFramework.Godot.SourceGenerators.Tests.Core;
/// <summary>
/// 提供源代码生成器测试的通用功能。
/// </summary>
/// <typeparam name="TGenerator">要测试的源代码生成器类型,必须具有无参构造函数。</typeparam>
public static class GeneratorTest<TGenerator>
where TGenerator : new()
{
/// <summary>
/// 运行源代码生成器测试。
/// </summary>
/// <param name="source">输入源代码。</param>
/// <param name="generatedSources">期望生成的源文件集合。</param>
public static async Task RunAsync(
string source,
params (string filename, string content)[] generatedSources)
{
var test = new CSharpSourceGeneratorTest<TGenerator, DefaultVerifier>
{
TestState =
{
Sources = { source }
},
DisabledDiagnostics = { "GF_Common_Trace_001" }
};
foreach (var (filename, content) in generatedSources)
test.TestState.GeneratedSources.Add(
(typeof(TGenerator), filename, content));
await test.RunAsync();
}
}

View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TestTargetFrameworks Condition="'$(TestTargetFrameworks)' == ''">net10.0</TestTargetFrameworks>
<TargetFrameworks>$(TestTargetFrameworks)</TargetFrameworks>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis" Version="4.14.0"/>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0"/>
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.14.0"/>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0"/>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing" Version="1.1.3"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0"/>
<PackageReference Include="NUnit" Version="4.5.1"/>
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GFramework.Godot.SourceGenerators\GFramework.Godot.SourceGenerators.csproj"/>
</ItemGroup>
</Project>

View File

@ -0,0 +1,243 @@
using GFramework.Godot.SourceGenerators.Tests.Core;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
using NUnit.Framework;
namespace GFramework.Godot.SourceGenerators.Tests.GetNode;
[TestFixture]
public class GetNodeGeneratorTests
{
[Test]
public async Task Generates_InferredUniqueNameBindings_And_ReadyHook_WhenReadyIsMissing()
{
const string source = """
using System;
using GFramework.Godot.SourceGenerators.Abstractions;
using Godot;
namespace GFramework.Godot.SourceGenerators.Abstractions
{
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
public sealed class GetNodeAttribute : Attribute
{
public GetNodeAttribute() {}
public GetNodeAttribute(string path) { Path = path; }
public string? Path { get; set; }
public bool Required { get; set; } = true;
public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto;
}
public enum NodeLookupMode
{
Auto = 0,
UniqueName = 1,
RelativePath = 2,
AbsolutePath = 3
}
}
namespace Godot
{
public class Node
{
public virtual void _Ready() {}
public T GetNode<T>(string path) where T : Node => throw new InvalidOperationException(path);
public T? GetNodeOrNull<T>(string path) where T : Node => default;
}
public class HBoxContainer : Node
{
}
}
namespace TestApp
{
public partial class TopBar : HBoxContainer
{
[GetNode]
private HBoxContainer _leftContainer = null!;
[GetNode]
private HBoxContainer m_rightContainer = null!;
}
}
""";
const string expected = """
// <auto-generated />
#nullable enable
namespace TestApp;
partial class TopBar
{
private void __InjectGetNodes_Generated()
{
_leftContainer = GetNode<global::Godot.HBoxContainer>("%LeftContainer");
m_rightContainer = GetNode<global::Godot.HBoxContainer>("%RightContainer");
}
partial void OnGetNodeReadyGenerated();
public override void _Ready()
{
__InjectGetNodes_Generated();
OnGetNodeReadyGenerated();
}
}
""";
await GeneratorTest<GetNodeGenerator>.RunAsync(
source,
("TestApp_TopBar.GetNode.g.cs", expected));
}
[Test]
public async Task Generates_ManualInjectionOnly_WhenReadyAlreadyExists()
{
const string source = """
using System;
using GFramework.Godot.SourceGenerators.Abstractions;
using Godot;
namespace GFramework.Godot.SourceGenerators.Abstractions
{
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
public sealed class GetNodeAttribute : Attribute
{
public GetNodeAttribute() {}
public GetNodeAttribute(string path) { Path = path; }
public string? Path { get; set; }
public bool Required { get; set; } = true;
public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto;
}
public enum NodeLookupMode
{
Auto = 0,
UniqueName = 1,
RelativePath = 2,
AbsolutePath = 3
}
}
namespace Godot
{
public class Node
{
public virtual void _Ready() {}
public T GetNode<T>(string path) where T : Node => throw new InvalidOperationException(path);
public T? GetNodeOrNull<T>(string path) where T : Node => default;
}
public class HBoxContainer : Node
{
}
}
namespace TestApp
{
public partial class TopBar : HBoxContainer
{
[GetNode("%LeftContainer")]
private HBoxContainer _leftContainer = null!;
[GetNode(Required = false, Lookup = NodeLookupMode.RelativePath)]
private HBoxContainer? _rightContainer;
public override void _Ready()
{
__InjectGetNodes_Generated();
}
}
}
""";
const string expected = """
// <auto-generated />
#nullable enable
namespace TestApp;
partial class TopBar
{
private void __InjectGetNodes_Generated()
{
_leftContainer = GetNode<global::Godot.HBoxContainer>("%LeftContainer");
_rightContainer = GetNodeOrNull<global::Godot.HBoxContainer>("RightContainer");
}
}
""";
await GeneratorTest<GetNodeGenerator>.RunAsync(
source,
("TestApp_TopBar.GetNode.g.cs", expected));
}
[Test]
public async Task Reports_Diagnostic_When_FieldType_IsNotGodotNode()
{
const string source = """
using System;
using GFramework.Godot.SourceGenerators.Abstractions;
using Godot;
namespace GFramework.Godot.SourceGenerators.Abstractions
{
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
public sealed class GetNodeAttribute : Attribute
{
public string? Path { get; set; }
public bool Required { get; set; } = true;
public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto;
}
public enum NodeLookupMode
{
Auto = 0,
UniqueName = 1,
RelativePath = 2,
AbsolutePath = 3
}
}
namespace Godot
{
public class Node
{
public virtual void _Ready() {}
public T GetNode<T>(string path) where T : Node => throw new InvalidOperationException(path);
public T? GetNodeOrNull<T>(string path) where T : Node => default;
}
}
namespace TestApp
{
public partial class TopBar : Node
{
[GetNode]
private string _leftContainer = string.Empty;
}
}
""";
var test = new CSharpSourceGeneratorTest<GetNodeGenerator, DefaultVerifier>
{
TestState =
{
Sources = { source }
},
DisabledDiagnostics = { "GF_Common_Trace_001" }
};
test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_GetNode_004", DiagnosticSeverity.Error)
.WithSpan(39, 24, 39, 38)
.WithArguments("_leftContainer"));
await test.RunAsync();
}
}

Some files were not shown because too many files have changed in this diff Show More