From 11515ff791808a374b4a6c77e591e335fa1cf299 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sat, 18 Apr 2026 15:47:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(godot):=20=E6=B7=BB=E5=8A=A0=E5=AF=8C?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E6=A0=87=E7=AD=BE=E6=95=88=E6=9E=9C=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GfRichTextLabel 组件作为富文本标签宿主 - 实现 IRichTextEffectHost 接口用于效果控制器驱动 - 创建 RichTextEffectsController 处理效果装配逻辑 - 添加 RichTextProfile 配置资源类型 - 引入 RichTextEffectPlan 和 RichTextEffectPlanEntry 类型 - 在 CI 工作流中添加 GFramework.Godot.Tests 项目 - 优化 Godot 测试诊断条件判断逻辑 - 添加富文本效果控制器相关单元测试 --- .github/workflows/ci.yml | 3 +- ...ileTests.cs => RichTextEffectPlanTests.cs} | 12 ++- .../Text/RichTextEffectsControllerTests.cs | 88 ++++++++++--------- GFramework.Godot/Text/GfRichTextLabel.cs | 33 ++++++- GFramework.Godot/Text/IRichTextEffectHost.cs | 15 +++- GFramework.Godot/Text/RichTextEffectPlan.cs | 70 +++++++++++++++ .../Text/RichTextEffectPlanEntry.cs | 9 ++ .../Text/RichTextEffectsController.cs | 32 ++----- GFramework.Godot/Text/RichTextProfile.cs | 37 +++++--- 9 files changed, 206 insertions(+), 93 deletions(-) rename GFramework.Godot.Tests/Text/{RichTextProfileTests.cs => RichTextEffectPlanTests.cs} (56%) create mode 100644 GFramework.Godot/Text/RichTextEffectPlan.cs create mode 100644 GFramework.Godot/Text/RichTextEffectPlanEntry.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 963c3e52..db07fe4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,6 +159,7 @@ jobs: "GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj:sg" "GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj:cqrs" "GFramework.Ecs.Arch.Tests/GFramework.Ecs.Arch.Tests.csproj:ecs-arch" + "GFramework.Godot.Tests/GFramework.Godot.Tests.csproj:godot" "GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj:godot-sg" ) @@ -221,7 +222,7 @@ jobs: done - name: Run GFramework.Godot.Tests Diagnostics - if: always() + if: always() && contains(steps.test_all_projects.outputs.failed_projects, 'GFramework.Godot.Tests/GFramework.Godot.Tests.csproj') continue-on-error: true run: | mkdir -p TestResults diff --git a/GFramework.Godot.Tests/Text/RichTextProfileTests.cs b/GFramework.Godot.Tests/Text/RichTextEffectPlanTests.cs similarity index 56% rename from GFramework.Godot.Tests/Text/RichTextProfileTests.cs rename to GFramework.Godot.Tests/Text/RichTextEffectPlanTests.cs index 666289b5..1bc3994b 100644 --- a/GFramework.Godot.Tests/Text/RichTextProfileTests.cs +++ b/GFramework.Godot.Tests/Text/RichTextEffectPlanTests.cs @@ -1,22 +1,20 @@ -using GFramework.Godot.Text; - namespace GFramework.Godot.Tests.Text; /// -/// 的测试。 +/// 的纯托管测试。 /// [TestFixture] -public sealed class RichTextProfileTests +public sealed class RichTextEffectPlanTests { /// - /// 验证默认内置配置会暴露完整的第一阶段效果键集合。 + /// 验证默认内置计划会暴露完整的第一阶段效果键集合。 /// [Test] public void CreateBuiltInDefault_Should_Contain_The_First_Phase_Effect_Keys() { - var profile = RichTextProfile.CreateBuiltInDefault(); + var plan = RichTextEffectPlan.CreateBuiltInDefault(); - Assert.That(profile.Effects.Select(static entry => entry.Key), Is.EqualTo(new[] + Assert.That(plan.Effects.Select(static entry => entry.Key), Is.EqualTo(new[] { "green", "red", diff --git a/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs b/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs index 0280d8c9..93193ff2 100644 --- a/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs +++ b/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs @@ -1,7 +1,3 @@ -using Godot; -using Godot.Collections; -using Array = Godot.Collections.Array; - namespace GFramework.Godot.Tests.Text; /// @@ -17,10 +13,8 @@ public sealed class RichTextEffectsControllerTests public void RefreshEffects_Should_Enable_Bbcode_And_Use_BuiltIn_Default_Profile() { var host = new FakeRichTextEffectHost(); - var registry = new RecordingRegistry(); var controller = new RichTextEffectsController( host, - () => registry, () => null, () => true, () => false); @@ -28,10 +22,10 @@ public sealed class RichTextEffectsControllerTests controller.RefreshEffects(); Assert.That(host.BbcodeEnabled, Is.True); - Assert.That(registry.CapturedAnimatedEffectsEnabled, Has.Count.EqualTo(1)); - Assert.That(registry.CapturedAnimatedEffectsEnabled[0], Is.False); - Assert.That(registry.CapturedProfiles, Has.Count.EqualTo(1)); - Assert.That(registry.CapturedProfiles[0].Effects.Select(static entry => entry.Key), Is.EqualTo(new[] + Assert.That(host.CapturedAnimatedEffectsEnabled, Has.Count.EqualTo(1)); + Assert.That(host.CapturedAnimatedEffectsEnabled[0], Is.False); + Assert.That(host.CapturedProfiles, Has.Count.EqualTo(1)); + Assert.That(host.CapturedProfiles[0].Effects.Select(static entry => entry.Key), Is.EqualTo(new[] { "green", "red", @@ -45,83 +39,91 @@ public sealed class RichTextEffectsControllerTests } /// - /// 验证关闭框架效果时不会调用注册表,并会清空宿主的自定义效果集合。 + /// 验证关闭框架效果时不会触发新的效果安装,并会清空宿主的自定义效果集合。 /// [Test] public void RefreshEffects_Should_Clear_CustomEffects_When_Framework_Effects_Are_Disabled() { - var existingEffects = new Array(); - existingEffects.Add("placeholder"); - var host = new FakeRichTextEffectHost { - BbcodeEnabled = true, - CustomEffects = existingEffects + BbcodeEnabled = true }; - var registry = new RecordingRegistry(); + host.SimulateInstalledEffects(); var controller = new RichTextEffectsController( host, - () => registry, - () => RichTextProfile.CreateBuiltInDefault(), + () => RichTextEffectPlan.CreateBuiltInDefault(), () => false, () => true); controller.RefreshEffects(); Assert.That(host.BbcodeEnabled, Is.True); - Assert.That(host.CustomEffects.Count, Is.EqualTo(0)); - Assert.That(registry.CapturedProfiles, Is.Empty); + Assert.That(host.CustomEffectsInstalled, Is.False); + Assert.That(host.ClearCustomEffectsCallCount, Is.EqualTo(1)); + Assert.That(host.CapturedProfiles, Is.Empty); } /// - /// 验证控制器会在每次刷新时读取最新的注册表访问器结果,避免缓存旧注册表。 + /// 验证控制器会在每次刷新时读取最新的配置访问器结果,避免缓存旧配置。 /// [Test] - public void RefreshEffects_Should_Use_The_Current_Registry_From_Accessor() + public void RefreshEffects_Should_Use_The_Current_Profile_From_Accessor() { var host = new FakeRichTextEffectHost(); - var firstRegistry = new RecordingRegistry(); - var secondRegistry = new RecordingRegistry(); - IRichTextEffectRegistry currentRegistry = firstRegistry; + var firstProfile = new RichTextEffectPlan( + [ + new RichTextEffectPlanEntry("green") + ]); + var secondProfile = new RichTextEffectPlan( + [ + new RichTextEffectPlanEntry("gold") + ]); + RichTextEffectPlan? currentProfile = firstProfile; var controller = new RichTextEffectsController( host, - () => currentRegistry, - () => RichTextProfile.CreateBuiltInDefault(), + () => currentProfile, () => true, () => true); controller.RefreshEffects(); - currentRegistry = secondRegistry; + currentProfile = secondProfile; controller.RefreshEffects(); - Assert.That(firstRegistry.CapturedProfiles, Has.Count.EqualTo(1)); - Assert.That(secondRegistry.CapturedProfiles, Has.Count.EqualTo(1)); + Assert.That(host.CapturedProfiles, Has.Count.EqualTo(2)); + Assert.That(host.CapturedProfiles[0], Is.SameAs(firstProfile)); + Assert.That(host.CapturedProfiles[1], Is.SameAs(secondProfile)); } private sealed class FakeRichTextEffectHost : IRichTextEffectHost { - public bool BbcodeEnabled { get; set; } - - public Array CustomEffects { get; set; } = new(); - } - - private sealed class RecordingRegistry : IRichTextEffectRegistry - { - public List CapturedProfiles { get; } = []; + public List CapturedProfiles { get; } = []; public List CapturedAnimatedEffectsEnabled { get; } = []; - public IReadOnlyList CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled) + public bool CustomEffectsInstalled { get; private set; } + + public int ClearCustomEffectsCallCount { get; private set; } + public bool BbcodeEnabled { get; set; } + + public void ApplyEffects(RichTextEffectPlan profile, bool animatedEffectsEnabled) { + ArgumentNullException.ThrowIfNull(profile); + CapturedProfiles.Add(profile); CapturedAnimatedEffectsEnabled.Add(animatedEffectsEnabled); - return new Array(); + CustomEffectsInstalled = true; } - public RichTextEffect? CreateEffect(string key, bool animatedEffectsEnabled) + public void ClearCustomEffects() { - return null; + ClearCustomEffectsCallCount++; + CustomEffectsInstalled = false; + } + + public void SimulateInstalledEffects() + { + CustomEffectsInstalled = true; } } } diff --git a/GFramework.Godot/Text/GfRichTextLabel.cs b/GFramework.Godot/Text/GfRichTextLabel.cs index 78440196..3ee2d828 100644 --- a/GFramework.Godot/Text/GfRichTextLabel.cs +++ b/GFramework.Godot/Text/GfRichTextLabel.cs @@ -44,6 +44,36 @@ public partial class GfRichTextLabel : RichTextLabel, IRichTextEffectHost set => _effectRegistry = value ?? throw new ArgumentNullException(nameof(value)); } + /// + /// 根据控制器提供的配置参数在适配层实例化 Godot 原生效果,并写回标签宿主。 + /// 这样控制器与测试替身不需要直接触碰 或 + /// ,而真正依赖 Godot runtime 的工作只发生在节点边界上。 + /// + /// 需要安装的纯托管效果计划。 + /// 当前是否允许字符级动态效果生效。 + void IRichTextEffectHost.ApplyEffects(RichTextEffectPlan profile, bool animatedEffectsEnabled) + { + ArgumentNullException.ThrowIfNull(profile); + + var registry = EffectRegistry; + var effects = registry.CreateEffects(RichTextProfile.FromPlan(profile), animatedEffectsEnabled); + var customEffects = new global::Godot.Collections.Array(); + foreach (var effect in effects) + { + customEffects.Add(effect); + } + + CustomEffects = customEffects; + } + + /// + /// 清空标签当前持有的自定义效果集合。 + /// + void IRichTextEffectHost.ClearCustomEffects() + { + CustomEffects = new global::Godot.Collections.Array(); + } + /// /// 节点就绪时初始化控制器并安装效果集合。 /// @@ -69,8 +99,7 @@ public partial class GfRichTextLabel : RichTextLabel, IRichTextEffectHost { return _effectsController ??= new RichTextEffectsController( this, - () => EffectRegistry, - () => Profile, + () => Profile is null ? null : RichTextEffectPlan.FromProfile(Profile), () => EnableFrameworkEffects, () => AnimatedEffectsEnabled); } diff --git a/GFramework.Godot/Text/IRichTextEffectHost.cs b/GFramework.Godot/Text/IRichTextEffectHost.cs index b78efeb6..b01ebe3f 100644 --- a/GFramework.Godot/Text/IRichTextEffectHost.cs +++ b/GFramework.Godot/Text/IRichTextEffectHost.cs @@ -1,5 +1,3 @@ -using Array = Godot.Collections.Array; - namespace GFramework.Godot.Text; /// @@ -15,7 +13,16 @@ internal interface IRichTextEffectHost bool BbcodeEnabled { get; set; } /// - /// 获取或设置当前安装到宿主上的自定义富文本效果集合。 + /// 使用给定的配置和动画开关重建宿主上的自定义富文本效果。 + /// 纯托管控制器只负责组合刷新参数,适配层负责在真正需要时解析注册表、实例化 Godot 效果对象并写回宿主。 /// - Array CustomEffects { get; set; } + /// 需要安装的纯托管效果计划。 + /// 当前是否允许字符级动态效果生效。 + void ApplyEffects(RichTextEffectPlan profile, bool animatedEffectsEnabled); + + /// + /// 清空当前安装到宿主上的自定义富文本效果集合。 + /// 关闭框架效果时,控制器会通过该方法显式撤销之前安装的效果。 + /// + void ClearCustomEffects(); } diff --git a/GFramework.Godot/Text/RichTextEffectPlan.cs b/GFramework.Godot/Text/RichTextEffectPlan.cs new file mode 100644 index 00000000..cd6be232 --- /dev/null +++ b/GFramework.Godot/Text/RichTextEffectPlan.cs @@ -0,0 +1,70 @@ +namespace GFramework.Godot.Text; + +/// +/// 描述一次富文本效果安装所需的纯托管计划。 +/// 该类型用于把控制器与测试替身隔离在 Godot runtime 之外,使刷新决策可以在普通 .NET 测试进程中验证。 +/// +internal sealed class RichTextEffectPlan +{ + /// + /// 初始化一个富文本效果计划。 + /// + /// 计划中声明的效果条目集合。 + /// + /// 当 时抛出。 + /// + public RichTextEffectPlan(IReadOnlyList effects) + { + ArgumentNullException.ThrowIfNull(effects); + + Effects = effects.ToArray(); + } + + /// + /// 获取当前计划启用的效果条目集合。 + /// + public IReadOnlyList Effects { get; } + + /// + /// 从 Godot 资源配置转换为纯托管计划。 + /// + /// 待转换的资源配置。 + /// 与资源配置等价的纯托管计划。 + /// + /// 当 时抛出。 + /// + public static RichTextEffectPlan FromProfile(RichTextProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + + var effects = new RichTextEffectPlanEntry[profile.Effects.Length]; + for (var index = 0; index < profile.Effects.Length; index++) + { + var entry = profile.Effects[index]; + effects[index] = entry is null + ? default + : new RichTextEffectPlanEntry(entry.Key, entry.Enabled); + } + + return new RichTextEffectPlan(effects); + } + + /// + /// 创建包含全部内置效果的默认计划。 + /// + /// 包含第一阶段全部内置效果键的默认计划。 + public static RichTextEffectPlan CreateBuiltInDefault() + { + return new RichTextEffectPlan( + [ + new RichTextEffectPlanEntry("green"), + new RichTextEffectPlanEntry("red"), + new RichTextEffectPlanEntry("gold"), + new RichTextEffectPlanEntry("blue"), + new RichTextEffectPlanEntry("fade_in"), + new RichTextEffectPlanEntry("sine"), + new RichTextEffectPlanEntry("jitter"), + new RichTextEffectPlanEntry("fly_in") + ]); + } +} diff --git a/GFramework.Godot/Text/RichTextEffectPlanEntry.cs b/GFramework.Godot/Text/RichTextEffectPlanEntry.cs new file mode 100644 index 00000000..f5b11b5d --- /dev/null +++ b/GFramework.Godot/Text/RichTextEffectPlanEntry.cs @@ -0,0 +1,9 @@ +namespace GFramework.Godot.Text; + +/// +/// 描述一条纯托管的富文本效果计划项。 +/// 控制器与测试替身只关心效果键和启用状态,不需要依赖 Godot 资源对象本身。 +/// +/// 效果键。 +/// 该效果项是否启用。 +internal readonly record struct RichTextEffectPlanEntry(string Key, bool Enabled = true); diff --git a/GFramework.Godot/Text/RichTextEffectsController.cs b/GFramework.Godot/Text/RichTextEffectsController.cs index dabced40..29074953 100644 --- a/GFramework.Godot/Text/RichTextEffectsController.cs +++ b/GFramework.Godot/Text/RichTextEffectsController.cs @@ -1,9 +1,7 @@ -using Array = Godot.Collections.Array; - namespace GFramework.Godot.Text; /// -/// 负责把配置、开关和注册表装配为宿主标签的实际效果集合。 +/// 负责把纯托管效果计划和开关装配为宿主标签的实际效果集合。 /// 该控制器是组合式扩展的装配中心,使 保持轻量。 /// internal sealed class RichTextEffectsController @@ -11,31 +9,27 @@ internal sealed class RichTextEffectsController private readonly Func _animatedEffectsEnabledAccessor; private readonly Func _frameworkEffectsEnabledAccessor; private readonly IRichTextEffectHost _host; - private readonly Func _profileAccessor; - private readonly Func _registryAccessor; + private readonly Func _profileAccessor; /// /// 初始化控制器实例。 /// /// 目标富文本标签。 - /// 当前效果注册表访问器。 - /// 当前配置访问器。 + /// 当前纯托管效果计划访问器。 /// 框架效果总开关访问器。 /// 字符动画开关访问器。 /// - /// 当 、 + /// 当 、 /// /// 为 时抛出。 /// public RichTextEffectsController( IRichTextEffectHost host, - Func registryAccessor, - Func profileAccessor, + Func profileAccessor, Func frameworkEffectsEnabledAccessor, Func animatedEffectsEnabledAccessor) { _host = host ?? throw new ArgumentNullException(nameof(host)); - _registryAccessor = registryAccessor ?? throw new ArgumentNullException(nameof(registryAccessor)); _profileAccessor = profileAccessor ?? throw new ArgumentNullException(nameof(profileAccessor)); _frameworkEffectsEnabledAccessor = frameworkEffectsEnabledAccessor ?? throw new ArgumentNullException(nameof(frameworkEffectsEnabledAccessor)); @@ -64,21 +58,11 @@ internal sealed class RichTextEffectsController if (!frameworkEffectsEnabled) { - _host.CustomEffects = new Array(); + _host.ClearCustomEffects(); return; } - var profile = _profileAccessor() ?? RichTextProfile.CreateBuiltInDefault(); - var registry = _registryAccessor() - ?? throw new InvalidOperationException("The rich text effect registry accessor returned null."); - - var effects = registry.CreateEffects(profile, _animatedEffectsEnabledAccessor()); - var customEffects = new Array(); - foreach (var effect in effects) - { - customEffects.Add(effect); - } - - _host.CustomEffects = customEffects; + var profile = _profileAccessor() ?? RichTextEffectPlan.CreateBuiltInDefault(); + _host.ApplyEffects(profile, _animatedEffectsEnabledAccessor()); } } diff --git a/GFramework.Godot/Text/RichTextProfile.cs b/GFramework.Godot/Text/RichTextProfile.cs index d61d3f56..9759787e 100644 --- a/GFramework.Godot/Text/RichTextProfile.cs +++ b/GFramework.Godot/Text/RichTextProfile.cs @@ -2,7 +2,8 @@ namespace GFramework.Godot.Text; /// /// 描述一个富文本效果组合配置。 -/// 该资源是组合式扩展的核心载体,用于声明宿主标签需要安装的效果集合。 +/// 该资源是 Godot 编辑器与场景系统使用的配置载体;运行时控制器会先把它转换为 +/// ,再在纯托管边界内完成刷新决策。 /// [GlobalClass] public partial class RichTextProfile : Resource @@ -20,18 +21,30 @@ public partial class RichTextProfile : Resource /// 包含全部内置效果键的默认配置。 public static RichTextProfile CreateBuiltInDefault() { + return FromPlan(RichTextEffectPlan.CreateBuiltInDefault()); + } + + /// + /// 从纯托管效果计划创建对应的 Godot 资源配置。 + /// 该转换只应发生在真正需要与 Godot 宿主或公开注册表交互的适配层边界上。 + /// + /// 待转换的纯托管效果计划。 + /// 与计划等价的 Godot 资源配置。 + /// + /// 当 时抛出。 + /// + internal static RichTextProfile FromPlan(RichTextEffectPlan plan) + { + ArgumentNullException.ThrowIfNull(plan); + var profile = new RichTextProfile(); - profile.Effects = - [ - new RichTextEffectEntry { Key = "green" }, - new RichTextEffectEntry { Key = "red" }, - new RichTextEffectEntry { Key = "gold" }, - new RichTextEffectEntry { Key = "blue" }, - new RichTextEffectEntry { Key = "fade_in" }, - new RichTextEffectEntry { Key = "sine" }, - new RichTextEffectEntry { Key = "jitter" }, - new RichTextEffectEntry { Key = "fly_in" } - ]; + profile.Effects = plan.Effects + .Select(static entry => new RichTextEffectEntry + { + Key = entry.Key, + Enabled = entry.Enabled + }) + .ToArray(); return profile; } }