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.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj:sg"
"GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj:cqrs" "GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj:cqrs"
"GFramework.Ecs.Arch.Tests/GFramework.Ecs.Arch.Tests.csproj:ecs-arch" "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" "GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj:godot-sg"
) )
@ -221,7 +222,7 @@ jobs:
done done
- name: Run GFramework.Godot.Tests Diagnostics - 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 continue-on-error: true
run: | run: |
mkdir -p TestResults mkdir -p TestResults

View File

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

View File

@ -1,7 +1,3 @@
using Godot;
using Godot.Collections;
using Array = Godot.Collections.Array;
namespace GFramework.Godot.Tests.Text; namespace GFramework.Godot.Tests.Text;
/// <summary> /// <summary>
@ -17,10 +13,8 @@ public sealed class RichTextEffectsControllerTests
public void RefreshEffects_Should_Enable_Bbcode_And_Use_BuiltIn_Default_Profile() public void RefreshEffects_Should_Enable_Bbcode_And_Use_BuiltIn_Default_Profile()
{ {
var host = new FakeRichTextEffectHost(); var host = new FakeRichTextEffectHost();
var registry = new RecordingRegistry();
var controller = new RichTextEffectsController( var controller = new RichTextEffectsController(
host, host,
() => registry,
() => null, () => null,
() => true, () => true,
() => false); () => false);
@ -28,10 +22,10 @@ public sealed class RichTextEffectsControllerTests
controller.RefreshEffects(); controller.RefreshEffects();
Assert.That(host.BbcodeEnabled, Is.True); Assert.That(host.BbcodeEnabled, Is.True);
Assert.That(registry.CapturedAnimatedEffectsEnabled, Has.Count.EqualTo(1)); Assert.That(host.CapturedAnimatedEffectsEnabled, Has.Count.EqualTo(1));
Assert.That(registry.CapturedAnimatedEffectsEnabled[0], Is.False); Assert.That(host.CapturedAnimatedEffectsEnabled[0], Is.False);
Assert.That(registry.CapturedProfiles, Has.Count.EqualTo(1)); Assert.That(host.CapturedProfiles, Has.Count.EqualTo(1));
Assert.That(registry.CapturedProfiles[0].Effects.Select(static entry => entry.Key), Is.EqualTo(new[] Assert.That(host.CapturedProfiles[0].Effects.Select(static entry => entry.Key), Is.EqualTo(new[]
{ {
"green", "green",
"red", "red",
@ -45,83 +39,91 @@ public sealed class RichTextEffectsControllerTests
} }
/// <summary> /// <summary>
/// 验证关闭框架效果时不会调用注册表,并会清空宿主的自定义效果集合。 /// 验证关闭框架效果时不会触发新的效果安装,并会清空宿主的自定义效果集合。
/// </summary> /// </summary>
[Test] [Test]
public void RefreshEffects_Should_Clear_CustomEffects_When_Framework_Effects_Are_Disabled() public void RefreshEffects_Should_Clear_CustomEffects_When_Framework_Effects_Are_Disabled()
{ {
var existingEffects = new Array();
existingEffects.Add("placeholder");
var host = new FakeRichTextEffectHost var host = new FakeRichTextEffectHost
{ {
BbcodeEnabled = true, BbcodeEnabled = true
CustomEffects = existingEffects
}; };
var registry = new RecordingRegistry(); host.SimulateInstalledEffects();
var controller = new RichTextEffectsController( var controller = new RichTextEffectsController(
host, host,
() => registry, () => RichTextEffectPlan.CreateBuiltInDefault(),
() => RichTextProfile.CreateBuiltInDefault(),
() => false, () => false,
() => true); () => true);
controller.RefreshEffects(); controller.RefreshEffects();
Assert.That(host.BbcodeEnabled, Is.True); Assert.That(host.BbcodeEnabled, Is.True);
Assert.That(host.CustomEffects.Count, Is.EqualTo(0)); Assert.That(host.CustomEffectsInstalled, Is.False);
Assert.That(registry.CapturedProfiles, Is.Empty); Assert.That(host.ClearCustomEffectsCallCount, Is.EqualTo(1));
Assert.That(host.CapturedProfiles, Is.Empty);
} }
/// <summary> /// <summary>
/// 验证控制器会在每次刷新时读取最新的注册表访问器结果,避免缓存旧注册表 /// 验证控制器会在每次刷新时读取最新的配置访问器结果,避免缓存旧配置
/// </summary> /// </summary>
[Test] [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 host = new FakeRichTextEffectHost();
var firstRegistry = new RecordingRegistry(); var firstProfile = new RichTextEffectPlan(
var secondRegistry = new RecordingRegistry(); [
IRichTextEffectRegistry currentRegistry = firstRegistry; new RichTextEffectPlanEntry("green")
]);
var secondProfile = new RichTextEffectPlan(
[
new RichTextEffectPlanEntry("gold")
]);
RichTextEffectPlan? currentProfile = firstProfile;
var controller = new RichTextEffectsController( var controller = new RichTextEffectsController(
host, host,
() => currentRegistry, () => currentProfile,
() => RichTextProfile.CreateBuiltInDefault(),
() => true, () => true,
() => true); () => true);
controller.RefreshEffects(); controller.RefreshEffects();
currentRegistry = secondRegistry; currentProfile = secondProfile;
controller.RefreshEffects(); controller.RefreshEffects();
Assert.That(firstRegistry.CapturedProfiles, Has.Count.EqualTo(1)); Assert.That(host.CapturedProfiles, Has.Count.EqualTo(2));
Assert.That(secondRegistry.CapturedProfiles, Has.Count.EqualTo(1)); Assert.That(host.CapturedProfiles[0], Is.SameAs(firstProfile));
Assert.That(host.CapturedProfiles[1], Is.SameAs(secondProfile));
} }
private sealed class FakeRichTextEffectHost : IRichTextEffectHost private sealed class FakeRichTextEffectHost : IRichTextEffectHost
{ {
public bool BbcodeEnabled { get; set; } public List<RichTextEffectPlan> CapturedProfiles { get; } = [];
public Array CustomEffects { get; set; } = new();
}
private sealed class RecordingRegistry : IRichTextEffectRegistry
{
public List<RichTextProfile> CapturedProfiles { get; } = [];
public List<bool> CapturedAnimatedEffectsEnabled { 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); CapturedProfiles.Add(profile);
CapturedAnimatedEffectsEnabled.Add(animatedEffectsEnabled); 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)); 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>
/// 节点就绪时初始化控制器并安装效果集合。 /// 节点就绪时初始化控制器并安装效果集合。
/// </summary> /// </summary>
@ -69,8 +99,7 @@ public partial class GfRichTextLabel : RichTextLabel, IRichTextEffectHost
{ {
return _effectsController ??= new RichTextEffectsController( return _effectsController ??= new RichTextEffectsController(
this, this,
() => EffectRegistry, () => Profile is null ? null : RichTextEffectPlan.FromProfile(Profile),
() => Profile,
() => EnableFrameworkEffects, () => EnableFrameworkEffects,
() => AnimatedEffectsEnabled); () => AnimatedEffectsEnabled);
} }

View File

@ -1,5 +1,3 @@
using Array = Godot.Collections.Array;
namespace GFramework.Godot.Text; namespace GFramework.Godot.Text;
/// <summary> /// <summary>
@ -15,7 +13,16 @@ internal interface IRichTextEffectHost
bool BbcodeEnabled { get; set; } bool BbcodeEnabled { get; set; }
/// <summary> /// <summary>
/// 获取或设置当前安装到宿主上的自定义富文本效果集合。 /// 使用给定的配置和动画开关重建宿主上的自定义富文本效果。
/// 纯托管控制器只负责组合刷新参数,适配层负责在真正需要时解析注册表、实例化 Godot 效果对象并写回宿主。
/// </summary> /// </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; namespace GFramework.Godot.Text;
/// <summary> /// <summary>
/// 负责把配置、开关和注册表装配为宿主标签的实际效果集合。 /// 负责把纯托管效果计划和开关装配为宿主标签的实际效果集合。
/// 该控制器是组合式扩展的装配中心,使 <see cref="GfRichTextLabel" /> 保持轻量。 /// 该控制器是组合式扩展的装配中心,使 <see cref="GfRichTextLabel" /> 保持轻量。
/// </summary> /// </summary>
internal sealed class RichTextEffectsController internal sealed class RichTextEffectsController
@ -11,31 +9,27 @@ internal sealed class RichTextEffectsController
private readonly Func<bool> _animatedEffectsEnabledAccessor; private readonly Func<bool> _animatedEffectsEnabledAccessor;
private readonly Func<bool> _frameworkEffectsEnabledAccessor; private readonly Func<bool> _frameworkEffectsEnabledAccessor;
private readonly IRichTextEffectHost _host; private readonly IRichTextEffectHost _host;
private readonly Func<RichTextProfile?> _profileAccessor; private readonly Func<RichTextEffectPlan?> _profileAccessor;
private readonly Func<IRichTextEffectRegistry> _registryAccessor;
/// <summary> /// <summary>
/// 初始化控制器实例。 /// 初始化控制器实例。
/// </summary> /// </summary>
/// <param name="host">目标富文本标签。</param> /// <param name="host">目标富文本标签。</param>
/// <param name="registryAccessor">当前效果注册表访问器。</param> /// <param name="profileAccessor">当前纯托管效果计划访问器。</param>
/// <param name="profileAccessor">当前配置访问器。</param>
/// <param name="frameworkEffectsEnabledAccessor">框架效果总开关访问器。</param> /// <param name="frameworkEffectsEnabledAccessor">框架效果总开关访问器。</param>
/// <param name="animatedEffectsEnabledAccessor">字符动画开关访问器。</param> /// <param name="animatedEffectsEnabledAccessor">字符动画开关访问器。</param>
/// <exception cref="ArgumentNullException"> /// <exception cref="ArgumentNullException">
/// 当 <paramref name="host" />、<paramref name="registryAccessor" />、<paramref name="profileAccessor" />、 /// 当 <paramref name="host" />、<paramref name="profileAccessor" />、
/// <paramref name="frameworkEffectsEnabledAccessor" /> 或 <paramref name="animatedEffectsEnabledAccessor" /> /// <paramref name="frameworkEffectsEnabledAccessor" /> 或 <paramref name="animatedEffectsEnabledAccessor" />
/// 为 <see langword="null" /> 时抛出。 /// 为 <see langword="null" /> 时抛出。
/// </exception> /// </exception>
public RichTextEffectsController( public RichTextEffectsController(
IRichTextEffectHost host, IRichTextEffectHost host,
Func<IRichTextEffectRegistry> registryAccessor, Func<RichTextEffectPlan?> profileAccessor,
Func<RichTextProfile?> profileAccessor,
Func<bool> frameworkEffectsEnabledAccessor, Func<bool> frameworkEffectsEnabledAccessor,
Func<bool> animatedEffectsEnabledAccessor) Func<bool> animatedEffectsEnabledAccessor)
{ {
_host = host ?? throw new ArgumentNullException(nameof(host)); _host = host ?? throw new ArgumentNullException(nameof(host));
_registryAccessor = registryAccessor ?? throw new ArgumentNullException(nameof(registryAccessor));
_profileAccessor = profileAccessor ?? throw new ArgumentNullException(nameof(profileAccessor)); _profileAccessor = profileAccessor ?? throw new ArgumentNullException(nameof(profileAccessor));
_frameworkEffectsEnabledAccessor = frameworkEffectsEnabledAccessor _frameworkEffectsEnabledAccessor = frameworkEffectsEnabledAccessor
?? throw new ArgumentNullException(nameof(frameworkEffectsEnabledAccessor)); ?? throw new ArgumentNullException(nameof(frameworkEffectsEnabledAccessor));
@ -64,21 +58,11 @@ internal sealed class RichTextEffectsController
if (!frameworkEffectsEnabled) if (!frameworkEffectsEnabled)
{ {
_host.CustomEffects = new Array(); _host.ClearCustomEffects();
return; return;
} }
var profile = _profileAccessor() ?? RichTextProfile.CreateBuiltInDefault(); var profile = _profileAccessor() ?? RichTextEffectPlan.CreateBuiltInDefault();
var registry = _registryAccessor() _host.ApplyEffects(profile, _animatedEffectsEnabledAccessor());
?? 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;
} }
} }

View File

@ -2,7 +2,8 @@ namespace GFramework.Godot.Text;
/// <summary> /// <summary>
/// 描述一个富文本效果组合配置。 /// 描述一个富文本效果组合配置。
/// 该资源是组合式扩展的核心载体,用于声明宿主标签需要安装的效果集合。 /// 该资源是 Godot 编辑器与场景系统使用的配置载体;运行时控制器会先把它转换为
/// <see cref="RichTextEffectPlan" />,再在纯托管边界内完成刷新决策。
/// </summary> /// </summary>
[GlobalClass] [GlobalClass]
public partial class RichTextProfile : Resource public partial class RichTextProfile : Resource
@ -20,18 +21,30 @@ public partial class RichTextProfile : Resource
/// <returns>包含全部内置效果键的默认配置。</returns> /// <returns>包含全部内置效果键的默认配置。</returns>
public static RichTextProfile CreateBuiltInDefault() 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(); var profile = new RichTextProfile();
profile.Effects = profile.Effects = plan.Effects
[ .Select(static entry => new RichTextEffectEntry
new RichTextEffectEntry { Key = "green" }, {
new RichTextEffectEntry { Key = "red" }, Key = entry.Key,
new RichTextEffectEntry { Key = "gold" }, Enabled = entry.Enabled
new RichTextEffectEntry { Key = "blue" }, })
new RichTextEffectEntry { Key = "fade_in" }, .ToArray();
new RichTextEffectEntry { Key = "sine" },
new RichTextEffectEntry { Key = "jitter" },
new RichTextEffectEntry { Key = "fly_in" }
];
return profile; return profile;
} }
} }