From 22882f68c4ff9947ce41aedd8a7391a79bb14bf9 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Sat, 18 Apr 2026 14:17:09 +0800
Subject: [PATCH] =?UTF-8?q?feat(text):=20=E6=B7=BB=E5=8A=A0=E5=AF=8C?=
=?UTF-8?q?=E6=96=87=E6=9C=AC=E6=95=88=E6=9E=9C=E7=B3=BB=E7=BB=9F=E5=92=8C?=
=?UTF-8?q?=E6=8A=96=E5=8A=A8=E6=95=88=E6=9E=9C=E5=AE=9E=E7=8E=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 创建 RichTextEffectBase 基类提供统一的标签命名和环境参数读取逻辑
- 实现 RichTextJitterEffect 抖动效果类,支持振幅和速度参数调节
- 添加 DefaultRichTextEffectRegistry 默认效果注册表管理内置效果映射
- 创建 GfRichTextLabel 组合式富文本标签宿主,集成效果装配逻辑
- 定义 IRichTextEffectRegistry 接口实现效果注册表抽象
- 开发 RichTextEffectsController 装配控制器负责效果集合管理
- 实现 RichTextMarkup 工具类提供语义化富文本标签构建辅助方法
- 添加相关单元测试验证效果控制器和标记工具的功能正确性
---
.../Text/RichTextEffectsControllerTests.cs | 126 ++++++++++++++++++
.../Text/RichTextMarkupTests.cs | 33 ++++-
.../Text/DefaultRichTextEffectRegistry.cs | 3 +
.../Text/Effects/RichTextEffectBase.cs | 14 +-
.../Text/Effects/RichTextJitterEffect.cs | 5 +
GFramework.Godot/Text/GfRichTextLabel.cs | 18 +--
GFramework.Godot/Text/IRichTextEffectHost.cs | 21 +++
.../Text/IRichTextEffectRegistry.cs | 13 ++
.../Text/RichTextEffectsController.cs | 30 +++--
GFramework.Godot/Text/RichTextMarkup.cs | 70 ++++++++--
10 files changed, 298 insertions(+), 35 deletions(-)
create mode 100644 GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
create mode 100644 GFramework.Godot/Text/IRichTextEffectHost.cs
diff --git a/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs b/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
new file mode 100644
index 00000000..4d1a1c30
--- /dev/null
+++ b/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
@@ -0,0 +1,126 @@
+using GFramework.Godot.Text;
+using Godot;
+using Array = Godot.Collections.Array;
+
+namespace GFramework.Godot.Tests.Text;
+
+///
+/// 的纯托管行为测试。
+///
+[TestFixture]
+public sealed class RichTextEffectsControllerTests
+{
+ ///
+ /// 验证启用框架效果时会开启宿主 BBCode,并在 Profile 为空时回退到内置默认配置。
+ ///
+ [Test]
+ 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);
+
+ controller.RefreshEffects();
+
+ Assert.That(host.BbcodeEnabled, Is.True);
+ Assert.That(registry.CapturedAnimatedEffectsEnabled, Is.False);
+ Assert.That(registry.CapturedProfiles, Has.Count.EqualTo(1));
+ Assert.That(registry.CapturedProfiles[0].Effects.Select(static entry => entry.Key), Is.EqualTo(new[]
+ {
+ "green",
+ "red",
+ "gold",
+ "blue",
+ "fade_in",
+ "sine",
+ "jitter",
+ "fly_in"
+ }));
+ }
+
+ ///
+ /// 验证关闭框架效果时不会调用注册表,并会清空宿主的自定义效果集合。
+ ///
+ [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
+ };
+ var registry = new RecordingRegistry();
+ var controller = new RichTextEffectsController(
+ host,
+ () => registry,
+ () => RichTextProfile.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);
+ }
+
+ ///
+ /// 验证控制器会在每次刷新时读取最新的注册表访问器结果,避免缓存旧注册表。
+ ///
+ [Test]
+ public void RefreshEffects_Should_Use_The_Current_Registry_From_Accessor()
+ {
+ var host = new FakeRichTextEffectHost();
+ var firstRegistry = new RecordingRegistry();
+ var secondRegistry = new RecordingRegistry();
+ IRichTextEffectRegistry currentRegistry = firstRegistry;
+
+ var controller = new RichTextEffectsController(
+ host,
+ () => currentRegistry,
+ () => RichTextProfile.CreateBuiltInDefault(),
+ () => true,
+ () => true);
+
+ controller.RefreshEffects();
+ currentRegistry = secondRegistry;
+ controller.RefreshEffects();
+
+ Assert.That(firstRegistry.CapturedProfiles, Has.Count.EqualTo(1));
+ Assert.That(secondRegistry.CapturedProfiles, Has.Count.EqualTo(1));
+ }
+
+ 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 bool CapturedAnimatedEffectsEnabled { get; private set; }
+
+ public IReadOnlyList CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled)
+ {
+ CapturedProfiles.Add(profile);
+ CapturedAnimatedEffectsEnabled = animatedEffectsEnabled;
+ return System.Array.Empty();
+ }
+
+ public RichTextEffect? CreateEffect(string key, bool animatedEffectsEnabled)
+ {
+ return null;
+ }
+ }
+}
diff --git a/GFramework.Godot.Tests/Text/RichTextMarkupTests.cs b/GFramework.Godot.Tests/Text/RichTextMarkupTests.cs
index 72009309..127d783c 100644
--- a/GFramework.Godot.Tests/Text/RichTextMarkupTests.cs
+++ b/GFramework.Godot.Tests/Text/RichTextMarkupTests.cs
@@ -23,16 +23,43 @@ public sealed class RichTextMarkupTests
/// 验证效果方法会按稳定顺序拼接环境参数。
///
[Test]
- public void Effect_Should_Append_Environment_Parameters()
+ public void Effect_Should_Sort_Environment_Parameters_By_Key()
{
var env = new Dictionary
{
- ["speed"] = 4,
- ["tick"] = 0.1f
+ ["tick"] = 0.1f,
+ ["speed"] = 4
};
var result = RichTextMarkup.Effect("Hello", "fade_in", env);
Assert.That(result, Is.EqualTo("[fade_in speed=4 tick=0.1]Hello[/fade_in]"));
}
+
+ ///
+ /// 验证非法标签 token 会被拒绝,避免生成损坏的 BBCode。
+ ///
+ [Test]
+ public void Effect_Should_Reject_Invalid_Tag_Tokens()
+ {
+ var exception = Assert.Throws(() => RichTextMarkup.Effect("Hello", "fade=in"));
+
+ Assert.That(exception!.ParamName, Is.EqualTo("tag"));
+ }
+
+ ///
+ /// 验证非法环境参数键会被拒绝,避免注入无效的 BBCode token。
+ ///
+ [Test]
+ public void Effect_Should_Reject_Invalid_Environment_Key_Tokens()
+ {
+ var env = new Dictionary
+ {
+ ["bad key"] = 1
+ };
+
+ var exception = Assert.Throws(() => RichTextMarkup.Effect("Hello", "fade_in", env));
+
+ Assert.That(exception!.ParamName, Is.EqualTo("env"));
+ }
}
diff --git a/GFramework.Godot/Text/DefaultRichTextEffectRegistry.cs b/GFramework.Godot/Text/DefaultRichTextEffectRegistry.cs
index 590e36b3..e4fd4780 100644
--- a/GFramework.Godot/Text/DefaultRichTextEffectRegistry.cs
+++ b/GFramework.Godot/Text/DefaultRichTextEffectRegistry.cs
@@ -14,6 +14,9 @@ public sealed class DefaultRichTextEffectRegistry : IRichTextEffectRegistry
/// 效果组合配置。
/// 当前是否允许字符级动态效果生效。
/// 内置效果实例集合。
+ ///
+ /// 当 为 时抛出。
+ ///
public IReadOnlyList CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled)
{
ArgumentNullException.ThrowIfNull(profile);
diff --git a/GFramework.Godot/Text/Effects/RichTextEffectBase.cs b/GFramework.Godot/Text/Effects/RichTextEffectBase.cs
index 41d7ca8e..55b89560 100644
--- a/GFramework.Godot/Text/Effects/RichTextEffectBase.cs
+++ b/GFramework.Godot/Text/Effects/RichTextEffectBase.cs
@@ -14,6 +14,7 @@ public abstract partial class RichTextEffectBase : RichTextEffect
///
/// 获取 Godot 识别当前效果所需的 `bbcode` 属性。
+ /// 属性名使用小写是 Godot `RichTextEffect` 的约定,不是框架对公共成员命名的放宽。
///
public string bbcode => TagName;
@@ -23,10 +24,11 @@ public abstract partial class RichTextEffectBase : RichTextEffect
/// 当前字符变换上下文。
/// 参数键。
/// 读取失败时使用的默认值。
- /// 最终布尔值。
+ /// 最终布尔值;当环境参数不存在或类型不是 时返回默认值。
protected bool GetBool(CharFXTransform transform, string key, bool defaultValue = false)
{
- if (transform.Env.TryGetValue(Variant.From(key), out var value))
+ if (transform.Env.TryGetValue(Variant.From(key), out var value) &&
+ value.VariantType == Variant.Type.Bool)
{
return value.AsBool();
}
@@ -40,10 +42,14 @@ public abstract partial class RichTextEffectBase : RichTextEffect
/// 当前字符变换上下文。
/// 参数键。
/// 读取失败时使用的默认值。
- /// 最终浮点值。
+ ///
+ /// 最终浮点值;当环境参数不存在,或类型既不是 也不是
+ /// 时返回默认值。
+ ///
protected float GetFloat(CharFXTransform transform, string key, float defaultValue)
{
- if (transform.Env.TryGetValue(Variant.From(key), out var value))
+ if (transform.Env.TryGetValue(Variant.From(key), out var value) &&
+ (value.VariantType == Variant.Type.Float || value.VariantType == Variant.Type.Int))
{
return (float)value.AsDouble();
}
diff --git a/GFramework.Godot/Text/Effects/RichTextJitterEffect.cs b/GFramework.Godot/Text/Effects/RichTextJitterEffect.cs
index b5b3fa16..1c8a8c76 100644
--- a/GFramework.Godot/Text/Effects/RichTextJitterEffect.cs
+++ b/GFramework.Godot/Text/Effects/RichTextJitterEffect.cs
@@ -3,6 +3,11 @@ namespace GFramework.Godot.Text.Effects;
///
/// 为文本提供抖动效果。
///
+///
+/// 默认注册表会在每次宿主刷新时为该效果创建独立实例。
+/// 内部会复用并修改 的 Seed,因此该类型假定 Godot 在主线程顺序
+/// 执行字符效果,不支持跨多个 共享同一实例,也不保证并发调用下的线程安全。
+///
[GlobalClass]
[Tool]
public partial class RichTextJitterEffect : RichTextEffectBase
diff --git a/GFramework.Godot/Text/GfRichTextLabel.cs b/GFramework.Godot/Text/GfRichTextLabel.cs
index 6715ec45..78440196 100644
--- a/GFramework.Godot/Text/GfRichTextLabel.cs
+++ b/GFramework.Godot/Text/GfRichTextLabel.cs
@@ -6,7 +6,7 @@ namespace GFramework.Godot.Text;
///
[GlobalClass]
[Tool]
-public partial class GfRichTextLabel : RichTextLabel
+public partial class GfRichTextLabel : RichTextLabel, IRichTextEffectHost
{
private IRichTextEffectRegistry? _effectRegistry;
private RichTextEffectsController? _effectsController;
@@ -20,6 +20,7 @@ public partial class GfRichTextLabel : RichTextLabel
///
/// 获取或设置是否启用框架管理的富文本效果装配。
+ /// 关闭后只会停止框架效果安装,不会覆盖调用方手动维护的其他 BBCode 解析状态。
///
[Export]
public bool EnableFrameworkEffects { get; set; } = true;
@@ -34,6 +35,9 @@ public partial class GfRichTextLabel : RichTextLabel
///
/// 获取当前使用的效果注册表。
///
+ ///
+ /// 当设置值为 时抛出。
+ ///
internal IRichTextEffectRegistry EffectRegistry
{
get => _effectRegistry ??= new DefaultRichTextEffectRegistry();
@@ -45,11 +49,6 @@ public partial class GfRichTextLabel : RichTextLabel
///
public override void _Ready()
{
- if (EnableFrameworkEffects && !BbcodeEnabled)
- {
- BbcodeEnabled = true;
- }
-
EnsureController().Initialize();
}
@@ -59,11 +58,6 @@ public partial class GfRichTextLabel : RichTextLabel
///
public void RefreshFrameworkEffects()
{
- if (EnableFrameworkEffects && !BbcodeEnabled)
- {
- BbcodeEnabled = true;
- }
-
EnsureController().RefreshEffects();
}
@@ -75,7 +69,7 @@ public partial class GfRichTextLabel : RichTextLabel
{
return _effectsController ??= new RichTextEffectsController(
this,
- EffectRegistry,
+ () => EffectRegistry,
() => Profile,
() => EnableFrameworkEffects,
() => AnimatedEffectsEnabled);
diff --git a/GFramework.Godot/Text/IRichTextEffectHost.cs b/GFramework.Godot/Text/IRichTextEffectHost.cs
new file mode 100644
index 00000000..b78efeb6
--- /dev/null
+++ b/GFramework.Godot/Text/IRichTextEffectHost.cs
@@ -0,0 +1,21 @@
+using Array = Godot.Collections.Array;
+
+namespace GFramework.Godot.Text;
+
+///
+/// 抽象可被富文本效果控制器驱动的宿主。
+/// 该接口把装配决策从 Godot 原生 生命周期中解耦出来,便于在纯托管测试中验证开关、
+/// 配置回退和注册表替换行为。
+///
+internal interface IRichTextEffectHost
+{
+ ///
+ /// 获取或设置宿主是否启用 BBCode 解析。
+ ///
+ bool BbcodeEnabled { get; set; }
+
+ ///
+ /// 获取或设置当前安装到宿主上的自定义富文本效果集合。
+ ///
+ Array CustomEffects { get; set; }
+}
diff --git a/GFramework.Godot/Text/IRichTextEffectRegistry.cs b/GFramework.Godot/Text/IRichTextEffectRegistry.cs
index 8d5a29c1..d43c0838 100644
--- a/GFramework.Godot/Text/IRichTextEffectRegistry.cs
+++ b/GFramework.Godot/Text/IRichTextEffectRegistry.cs
@@ -3,18 +3,31 @@ namespace GFramework.Godot.Text;
///
/// 富文本效果注册表,负责把配置中的效果键解析为可安装的 实例。
///
+///
+/// 会在 就绪或显式刷新时调用该注册表,重建
+/// 。
+/// 该抽象存在的目的,是把“配置里声明了哪些效果”与“这些效果如何实例化”解耦,使宿主节点、配置资源和测试替身都不必
+/// 直接依赖具体内置效果类型。
+/// 当项目只需要组合现有标签时,应优先使用 ;当项目需要替换内置映射、注入自定义
+/// ,或按“动态效果是否启用”的边界切换效果实现时,应实现该接口。
+///
public interface IRichTextEffectRegistry
{
///
/// 根据指定配置创建完整的效果实例集合。
+ /// 该方法会在每次宿主刷新时执行,因此实现应保持可重复、确定且与宿主生命周期兼容。
///
/// 效果组合配置。
/// 当前是否允许字符级动态效果生效。
/// 可直接写入 的效果实例集合。
+ ///
+ /// 当 为 且实现不接受空配置时抛出。
+ ///
IReadOnlyList CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled);
///
/// 根据单个效果键创建对应效果实例。
+ /// 该方法主要用于将配置声明的 key 映射为宿主可安装的单个效果对象。
///
/// 效果键。
/// 当前是否允许字符级动态效果生效。
diff --git a/GFramework.Godot/Text/RichTextEffectsController.cs b/GFramework.Godot/Text/RichTextEffectsController.cs
index 2a9eeb78..dabced40 100644
--- a/GFramework.Godot/Text/RichTextEffectsController.cs
+++ b/GFramework.Godot/Text/RichTextEffectsController.cs
@@ -10,27 +10,32 @@ internal sealed class RichTextEffectsController
{
private readonly Func _animatedEffectsEnabledAccessor;
private readonly Func _frameworkEffectsEnabledAccessor;
- private readonly RichTextLabel _host;
+ private readonly IRichTextEffectHost _host;
private readonly Func _profileAccessor;
- private readonly IRichTextEffectRegistry _registry;
+ private readonly Func _registryAccessor;
///
/// 初始化控制器实例。
///
/// 目标富文本标签。
- /// 效果注册表。
+ /// 当前效果注册表访问器。
/// 当前配置访问器。
/// 框架效果总开关访问器。
/// 字符动画开关访问器。
+ ///
+ /// 当 、、、
+ /// 或
+ /// 为 时抛出。
+ ///
public RichTextEffectsController(
- RichTextLabel host,
- IRichTextEffectRegistry registry,
+ IRichTextEffectHost host,
+ Func registryAccessor,
Func profileAccessor,
Func frameworkEffectsEnabledAccessor,
Func animatedEffectsEnabledAccessor)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
- _registry = registry ?? throw new ArgumentNullException(nameof(registry));
+ _registryAccessor = registryAccessor ?? throw new ArgumentNullException(nameof(registryAccessor));
_profileAccessor = profileAccessor ?? throw new ArgumentNullException(nameof(profileAccessor));
_frameworkEffectsEnabledAccessor = frameworkEffectsEnabledAccessor
?? throw new ArgumentNullException(nameof(frameworkEffectsEnabledAccessor));
@@ -51,14 +56,23 @@ internal sealed class RichTextEffectsController
///
public void RefreshEffects()
{
- if (!_frameworkEffectsEnabledAccessor())
+ var frameworkEffectsEnabled = _frameworkEffectsEnabledAccessor();
+ if (frameworkEffectsEnabled && !_host.BbcodeEnabled)
+ {
+ _host.BbcodeEnabled = true;
+ }
+
+ if (!frameworkEffectsEnabled)
{
_host.CustomEffects = new Array();
return;
}
var profile = _profileAccessor() ?? RichTextProfile.CreateBuiltInDefault();
- var effects = _registry.CreateEffects(profile, _animatedEffectsEnabledAccessor());
+ 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)
{
diff --git a/GFramework.Godot/Text/RichTextMarkup.cs b/GFramework.Godot/Text/RichTextMarkup.cs
index e08ec667..685e8408 100644
--- a/GFramework.Godot/Text/RichTextMarkup.cs
+++ b/GFramework.Godot/Text/RichTextMarkup.cs
@@ -15,6 +15,9 @@ public static class RichTextMarkup
/// 原始文本。
/// 标签名。
/// 包裹后的 BBCode 文本。
+ ///
+ /// 当 为空、仅包含空白字符,或包含 BBCode token 不允许的控制字符时抛出。
+ ///
public static string Color(string text, string tag)
{
return Wrap(text, tag);
@@ -62,14 +65,19 @@ public static class RichTextMarkup
///
/// 使用指定效果标签包裹文本,并可附带参数环境。
+ /// 环境参数会按键名进行稳定排序,避免不同字典实现导致输出顺序漂移。
///
/// 原始文本。
/// 标签名。
/// 可选的标签参数集合。
/// 包裹后的 BBCode 文本。
+ ///
+ /// 当 为空、仅包含空白字符,包含 BBCode token 不允许的控制字符,
+ /// 或 中存在包含非法控制字符的参数键时抛出。
+ ///
public static string Effect(string text, string tag, IReadOnlyDictionary? env = null)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(tag);
+ ValidateToken(tag, nameof(tag));
var builder = new StringBuilder();
builder.Append('[');
@@ -77,13 +85,8 @@ public static class RichTextMarkup
if (env is not null)
{
- foreach (var pair in env)
+ foreach (var pair in CollectEnvironmentPairs(env))
{
- if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
- {
- continue;
- }
-
builder.Append(' ');
builder.Append(pair.Key);
builder.Append('=');
@@ -107,10 +110,61 @@ public static class RichTextMarkup
/// 包裹后的 BBCode 文本。
private static string Wrap(string text, string tag)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(tag);
+ ValidateToken(tag, nameof(tag));
return $"[{tag}]{text ?? string.Empty}[/{tag}]";
}
+ ///
+ /// 收集并排序可写入 BBCode 的环境参数。
+ ///
+ /// 原始环境参数。
+ /// 按键名稳定排序后的参数集合。
+ ///
+ /// 当参数键包含 BBCode token 不允许的控制字符时抛出。
+ ///
+ private static IReadOnlyList> CollectEnvironmentPairs(
+ IReadOnlyDictionary env)
+ {
+ var pairs = new List>(env.Count);
+ foreach (var pair in env)
+ {
+ if (pair.Value is null || string.IsNullOrWhiteSpace(pair.Key))
+ {
+ continue;
+ }
+
+ ValidateToken(pair.Key, nameof(env));
+ pairs.Add(new KeyValuePair(pair.Key, pair.Value));
+ }
+
+ pairs.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.Key, right.Key));
+ return pairs;
+ }
+
+ ///
+ /// 验证 BBCode 标签或参数键是否满足 token 约束。
+ ///
+ /// 待验证的 token。
+ /// 异常参数名。
+ ///
+ /// 当 token 为空、仅包含空白字符,或包含 BBCode token 不允许的控制字符时抛出。
+ ///
+ private static void ValidateToken(string token, string paramName)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new ArgumentException("BBCode token cannot be null, empty, or whitespace.", paramName);
+ }
+
+ foreach (var character in token)
+ {
+ if (char.IsWhiteSpace(character) || character is '[' or ']' or '=')
+ {
+ throw new ArgumentException("BBCode token contains invalid control characters.", paramName);
+ }
+ }
+ }
+
///
/// 将标签参数值格式化为稳定的 BBCode 字符串表示。
///