feat(godot): 添加富文本标签效果系统支持

- 新增 GfRichTextLabel 组件作为富文本标签宿主
- 实现 IRichTextEffectHost 接口用于效果控制器驱动
- 创建 RichTextEffectsController 处理效果装配逻辑
- 添加 RichTextProfile 配置资源类型
- 引入 RichTextEffectPlan 和 RichTextEffectPlanEntry 类型
- 在 CI 工作流中添加 GFramework.Godot.Tests 项目
- 优化 Godot 测试诊断条件判断逻辑
- 添加富文本效果控制器相关单元测试
This commit is contained in:
GeWuYou 2026-04-18 15:47:08 +08:00
parent 1145f455f3
commit 11515ff791
9 changed files with 206 additions and 93 deletions

View File

@ -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

View File

@ -1,22 +1,20 @@
using GFramework.Godot.Text;
namespace GFramework.Godot.Tests.Text;
/// <summary>
/// <see cref="RichTextProfile" /> 的测试。
/// <see cref="RichTextEffectPlan" /> 的纯托管测试。
/// </summary>
[TestFixture]
public sealed class RichTextProfileTests
public sealed class RichTextEffectPlanTests
{
/// <summary>
/// 验证默认内置配置会暴露完整的第一阶段效果键集合。
/// 验证默认内置计划会暴露完整的第一阶段效果键集合。
/// </summary>
[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",

View File

@ -1,7 +1,3 @@
using Godot;
using Godot.Collections;
using Array = Godot.Collections.Array;
namespace GFramework.Godot.Tests.Text;
/// <summary>
@ -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
}
/// <summary>
/// 验证关闭框架效果时不会调用注册表,并会清空宿主的自定义效果集合。
/// 验证关闭框架效果时不会触发新的效果安装,并会清空宿主的自定义效果集合。
/// </summary>
[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);
}
/// <summary>
/// 验证控制器会在每次刷新时读取最新的注册表访问器结果,避免缓存旧注册表
/// 验证控制器会在每次刷新时读取最新的配置访问器结果,避免缓存旧配置
/// </summary>
[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<RichTextProfile> CapturedProfiles { get; } = [];
public List<RichTextEffectPlan> CapturedProfiles { get; } = [];
public List<bool> CapturedAnimatedEffectsEnabled { get; } = [];
public IReadOnlyList<RichTextEffect> 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<RichTextEffect>();
CustomEffectsInstalled = true;
}
public RichTextEffect? CreateEffect(string key, bool animatedEffectsEnabled)
public void ClearCustomEffects()
{
return null;
ClearCustomEffectsCallCount++;
CustomEffectsInstalled = false;
}
public void SimulateInstalledEffects()
{
CustomEffectsInstalled = true;
}
}
}

View File

@ -44,6 +44,36 @@ public partial class GfRichTextLabel : RichTextLabel, IRichTextEffectHost
set => _effectRegistry = value ?? throw new ArgumentNullException(nameof(value));
}
/// <summary>
/// 根据控制器提供的配置参数在适配层实例化 Godot 原生效果,并写回标签宿主。
/// 这样控制器与测试替身不需要直接触碰 <see cref="RichTextEffect" /> 或
/// <see cref="global::Godot.Collections.Array" />,而真正依赖 Godot runtime 的工作只发生在节点边界上。
/// </summary>
/// <param name="profile">需要安装的纯托管效果计划。</param>
/// <param name="animatedEffectsEnabled">当前是否允许字符级动态效果生效。</param>
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;
}
/// <summary>
/// 清空标签当前持有的自定义效果集合。
/// </summary>
void IRichTextEffectHost.ClearCustomEffects()
{
CustomEffects = new global::Godot.Collections.Array();
}
/// <summary>
/// 节点就绪时初始化控制器并安装效果集合。
/// </summary>
@ -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);
}

View File

@ -1,5 +1,3 @@
using Array = Godot.Collections.Array;
namespace GFramework.Godot.Text;
/// <summary>
@ -15,7 +13,16 @@ internal interface IRichTextEffectHost
bool BbcodeEnabled { get; set; }
/// <summary>
/// 获取或设置当前安装到宿主上的自定义富文本效果集合。
/// 使用给定的配置和动画开关重建宿主上的自定义富文本效果。
/// 纯托管控制器只负责组合刷新参数,适配层负责在真正需要时解析注册表、实例化 Godot 效果对象并写回宿主。
/// </summary>
Array CustomEffects { get; set; }
/// <param name="profile">需要安装的纯托管效果计划。</param>
/// <param name="animatedEffectsEnabled">当前是否允许字符级动态效果生效。</param>
void ApplyEffects(RichTextEffectPlan profile, bool animatedEffectsEnabled);
/// <summary>
/// 清空当前安装到宿主上的自定义富文本效果集合。
/// 关闭框架效果时,控制器会通过该方法显式撤销之前安装的效果。
/// </summary>
void ClearCustomEffects();
}

View File

@ -0,0 +1,70 @@
namespace GFramework.Godot.Text;
/// <summary>
/// 描述一次富文本效果安装所需的纯托管计划。
/// 该类型用于把控制器与测试替身隔离在 Godot runtime 之外,使刷新决策可以在普通 .NET 测试进程中验证。
/// </summary>
internal sealed class RichTextEffectPlan
{
/// <summary>
/// 初始化一个富文本效果计划。
/// </summary>
/// <param name="effects">计划中声明的效果条目集合。</param>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="effects" /> 为 <see langword="null" /> 时抛出。
/// </exception>
public RichTextEffectPlan(IReadOnlyList<RichTextEffectPlanEntry> effects)
{
ArgumentNullException.ThrowIfNull(effects);
Effects = effects.ToArray();
}
/// <summary>
/// 获取当前计划启用的效果条目集合。
/// </summary>
public IReadOnlyList<RichTextEffectPlanEntry> Effects { get; }
/// <summary>
/// 从 Godot 资源配置转换为纯托管计划。
/// </summary>
/// <param name="profile">待转换的资源配置。</param>
/// <returns>与资源配置等价的纯托管计划。</returns>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="profile" /> 为 <see langword="null" /> 时抛出。
/// </exception>
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);
}
/// <summary>
/// 创建包含全部内置效果的默认计划。
/// </summary>
/// <returns>包含第一阶段全部内置效果键的默认计划。</returns>
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")
]);
}
}

View File

@ -0,0 +1,9 @@
namespace GFramework.Godot.Text;
/// <summary>
/// 描述一条纯托管的富文本效果计划项。
/// 控制器与测试替身只关心效果键和启用状态,不需要依赖 Godot 资源对象本身。
/// </summary>
/// <param name="Key">效果键。</param>
/// <param name="Enabled">该效果项是否启用。</param>
internal readonly record struct RichTextEffectPlanEntry(string Key, bool Enabled = true);

View File

@ -1,9 +1,7 @@
using Array = Godot.Collections.Array;
namespace GFramework.Godot.Text;
/// <summary>
/// 负责把配置、开关和注册表装配为宿主标签的实际效果集合。
/// 负责把纯托管效果计划和开关装配为宿主标签的实际效果集合。
/// 该控制器是组合式扩展的装配中心,使 <see cref="GfRichTextLabel" /> 保持轻量。
/// </summary>
internal sealed class RichTextEffectsController
@ -11,31 +9,27 @@ internal sealed class RichTextEffectsController
private readonly Func<bool> _animatedEffectsEnabledAccessor;
private readonly Func<bool> _frameworkEffectsEnabledAccessor;
private readonly IRichTextEffectHost _host;
private readonly Func<RichTextProfile?> _profileAccessor;
private readonly Func<IRichTextEffectRegistry> _registryAccessor;
private readonly Func<RichTextEffectPlan?> _profileAccessor;
/// <summary>
/// 初始化控制器实例。
/// </summary>
/// <param name="host">目标富文本标签。</param>
/// <param name="registryAccessor">当前效果注册表访问器。</param>
/// <param name="profileAccessor">当前配置访问器。</param>
/// <param name="profileAccessor">当前纯托管效果计划访问器。</param>
/// <param name="frameworkEffectsEnabledAccessor">框架效果总开关访问器。</param>
/// <param name="animatedEffectsEnabledAccessor">字符动画开关访问器。</param>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="host" />、<paramref name="registryAccessor" />、<paramref name="profileAccessor" />、
/// 当 <paramref name="host" />、<paramref name="profileAccessor" />、
/// <paramref name="frameworkEffectsEnabledAccessor" /> 或 <paramref name="animatedEffectsEnabledAccessor" />
/// 为 <see langword="null" /> 时抛出。
/// </exception>
public RichTextEffectsController(
IRichTextEffectHost host,
Func<IRichTextEffectRegistry> registryAccessor,
Func<RichTextProfile?> profileAccessor,
Func<RichTextEffectPlan?> profileAccessor,
Func<bool> frameworkEffectsEnabledAccessor,
Func<bool> 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());
}
}

View File

@ -2,7 +2,8 @@ namespace GFramework.Godot.Text;
/// <summary>
/// 描述一个富文本效果组合配置。
/// 该资源是组合式扩展的核心载体,用于声明宿主标签需要安装的效果集合。
/// 该资源是 Godot 编辑器与场景系统使用的配置载体;运行时控制器会先把它转换为
/// <see cref="RichTextEffectPlan" />,再在纯托管边界内完成刷新决策。
/// </summary>
[GlobalClass]
public partial class RichTextProfile : Resource
@ -20,18 +21,30 @@ public partial class RichTextProfile : Resource
/// <returns>包含全部内置效果键的默认配置。</returns>
public static RichTextProfile CreateBuiltInDefault()
{
return FromPlan(RichTextEffectPlan.CreateBuiltInDefault());
}
/// <summary>
/// 从纯托管效果计划创建对应的 Godot 资源配置。
/// 该转换只应发生在真正需要与 Godot 宿主或公开注册表交互的适配层边界上。
/// </summary>
/// <param name="plan">待转换的纯托管效果计划。</param>
/// <returns>与计划等价的 Godot 资源配置。</returns>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="plan" /> 为 <see langword="null" /> 时抛出。
/// </exception>
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;
}
}