From e5ad29314e7ba1ebbfc08055f536805c9a9ac894 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Sat, 18 Apr 2026 11:58:40 +0800
Subject: [PATCH 1/8] =?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?=E9=A2=9C=E8=89=B2=E6=A0=87=E8=AE=B0=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现 RichTextEffectBase 基类提供统一的标签命名和参数读取逻辑
- 添加 RichTextBlueEffect、RichTextGoldEffect、RichTextGreenEffect 和 RichTextRedEffect 颜色标记效果
- 添加 RichTextFadeInEffect、RichTextFlyInEffect、RichTextJitterEffect 和 RichTextSineEffect 动画效果
- 实现 DefaultRichTextEffectRegistry 默认效果注册表
- 创建 GfRichTextLabel 富文本标签宿主组件
- 添加 IRichTextEffectRegistry 效果注册表接口
- 实现 RichTextEffectEntry、RichTextEffectsController 和 RichTextProfile 配置管理类
- 添加 RichTextMarkup 语义化标签构建辅助方法
- 创建 RichTextMarkupTests 和 RichTextProfileTests 单元测试
---
.../Text/RichTextMarkupTests.cs | 38 ++++++
.../Text/RichTextProfileTests.cs | 31 +++++
.../Text/DefaultRichTextEffectRegistry.cs | 65 +++++++++
.../Text/Effects/RichTextBlueEffect.cs | 27 ++++
.../Text/Effects/RichTextEffectBase.cs | 81 +++++++++++
.../Text/Effects/RichTextFadeInEffect.cs | 47 +++++++
.../Text/Effects/RichTextFlyInEffect.cs | 64 +++++++++
.../Text/Effects/RichTextGoldEffect.cs | 27 ++++
.../Text/Effects/RichTextGreenEffect.cs | 27 ++++
.../Text/Effects/RichTextJitterEffect.cs | 57 ++++++++
.../Text/Effects/RichTextRedEffect.cs | 27 ++++
.../Text/Effects/RichTextSineEffect.cs | 47 +++++++
GFramework.Godot/Text/GfRichTextLabel.cs | 83 ++++++++++++
.../Text/IRichTextEffectRegistry.cs | 23 ++++
GFramework.Godot/Text/RichTextEffectEntry.cs | 22 +++
.../Text/RichTextEffectsController.cs | 70 ++++++++++
GFramework.Godot/Text/RichTextMarkup.cs | 128 ++++++++++++++++++
GFramework.Godot/Text/RichTextProfile.cs | 37 +++++
18 files changed, 901 insertions(+)
create mode 100644 GFramework.Godot.Tests/Text/RichTextMarkupTests.cs
create mode 100644 GFramework.Godot.Tests/Text/RichTextProfileTests.cs
create mode 100644 GFramework.Godot/Text/DefaultRichTextEffectRegistry.cs
create mode 100644 GFramework.Godot/Text/Effects/RichTextBlueEffect.cs
create mode 100644 GFramework.Godot/Text/Effects/RichTextEffectBase.cs
create mode 100644 GFramework.Godot/Text/Effects/RichTextFadeInEffect.cs
create mode 100644 GFramework.Godot/Text/Effects/RichTextFlyInEffect.cs
create mode 100644 GFramework.Godot/Text/Effects/RichTextGoldEffect.cs
create mode 100644 GFramework.Godot/Text/Effects/RichTextGreenEffect.cs
create mode 100644 GFramework.Godot/Text/Effects/RichTextJitterEffect.cs
create mode 100644 GFramework.Godot/Text/Effects/RichTextRedEffect.cs
create mode 100644 GFramework.Godot/Text/Effects/RichTextSineEffect.cs
create mode 100644 GFramework.Godot/Text/GfRichTextLabel.cs
create mode 100644 GFramework.Godot/Text/IRichTextEffectRegistry.cs
create mode 100644 GFramework.Godot/Text/RichTextEffectEntry.cs
create mode 100644 GFramework.Godot/Text/RichTextEffectsController.cs
create mode 100644 GFramework.Godot/Text/RichTextMarkup.cs
create mode 100644 GFramework.Godot/Text/RichTextProfile.cs
diff --git a/GFramework.Godot.Tests/Text/RichTextMarkupTests.cs b/GFramework.Godot.Tests/Text/RichTextMarkupTests.cs
new file mode 100644
index 00000000..72009309
--- /dev/null
+++ b/GFramework.Godot.Tests/Text/RichTextMarkupTests.cs
@@ -0,0 +1,38 @@
+using GFramework.Godot.Text;
+
+namespace GFramework.Godot.Tests.Text;
+
+///
+/// 的测试。
+///
+[TestFixture]
+public sealed class RichTextMarkupTests
+{
+ ///
+ /// 验证颜色快捷方法会输出预期标签。
+ ///
+ [Test]
+ public void Green_Should_Wrap_Text_With_Green_Tag()
+ {
+ var result = RichTextMarkup.Green("Ready");
+
+ Assert.That(result, Is.EqualTo("[green]Ready[/green]"));
+ }
+
+ ///
+ /// 验证效果方法会按稳定顺序拼接环境参数。
+ ///
+ [Test]
+ public void Effect_Should_Append_Environment_Parameters()
+ {
+ var env = new Dictionary
+ {
+ ["speed"] = 4,
+ ["tick"] = 0.1f
+ };
+
+ var result = RichTextMarkup.Effect("Hello", "fade_in", env);
+
+ Assert.That(result, Is.EqualTo("[fade_in speed=4 tick=0.1]Hello[/fade_in]"));
+ }
+}
diff --git a/GFramework.Godot.Tests/Text/RichTextProfileTests.cs b/GFramework.Godot.Tests/Text/RichTextProfileTests.cs
new file mode 100644
index 00000000..666289b5
--- /dev/null
+++ b/GFramework.Godot.Tests/Text/RichTextProfileTests.cs
@@ -0,0 +1,31 @@
+using GFramework.Godot.Text;
+
+namespace GFramework.Godot.Tests.Text;
+
+///
+/// 的测试。
+///
+[TestFixture]
+public sealed class RichTextProfileTests
+{
+ ///
+ /// 验证默认内置配置会暴露完整的第一阶段效果键集合。
+ ///
+ [Test]
+ public void CreateBuiltInDefault_Should_Contain_The_First_Phase_Effect_Keys()
+ {
+ var profile = RichTextProfile.CreateBuiltInDefault();
+
+ Assert.That(profile.Effects.Select(static entry => entry.Key), Is.EqualTo(new[]
+ {
+ "green",
+ "red",
+ "gold",
+ "blue",
+ "fade_in",
+ "sine",
+ "jitter",
+ "fly_in"
+ }));
+ }
+}
diff --git a/GFramework.Godot/Text/DefaultRichTextEffectRegistry.cs b/GFramework.Godot/Text/DefaultRichTextEffectRegistry.cs
new file mode 100644
index 00000000..590e36b3
--- /dev/null
+++ b/GFramework.Godot/Text/DefaultRichTextEffectRegistry.cs
@@ -0,0 +1,65 @@
+using GFramework.Godot.Text.Effects;
+
+namespace GFramework.Godot.Text;
+
+///
+/// 默认的富文本效果注册表。
+/// 该实现仅负责内置效果键的解析,不处理业务层文本构建或配置持久化。
+///
+public sealed class DefaultRichTextEffectRegistry : IRichTextEffectRegistry
+{
+ ///
+ /// 创建当前配置对应的全部效果实例。
+ ///
+ /// 效果组合配置。
+ /// 当前是否允许字符级动态效果生效。
+ /// 内置效果实例集合。
+ public IReadOnlyList CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled)
+ {
+ ArgumentNullException.ThrowIfNull(profile);
+
+ var effects = new List(profile.Effects.Length);
+ foreach (var entry in profile.Effects)
+ {
+ if (entry is null || !entry.Enabled || string.IsNullOrWhiteSpace(entry.Key))
+ {
+ continue;
+ }
+
+ var effect = CreateEffect(entry.Key, animatedEffectsEnabled);
+ if (effect is not null)
+ {
+ effects.Add(effect);
+ }
+ }
+
+ return effects;
+ }
+
+ ///
+ /// 根据效果键创建单个效果实例。
+ ///
+ /// 效果键。
+ /// 当前是否允许字符级动态效果生效。
+ /// 解析成功时返回效果实例;否则返回 。
+ public RichTextEffect? CreateEffect(string key, bool animatedEffectsEnabled)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ return null;
+ }
+
+ return key.Trim().ToLowerInvariant() switch
+ {
+ "green" => new RichTextGreenEffect(),
+ "red" => new RichTextRedEffect(),
+ "gold" => new RichTextGoldEffect(),
+ "blue" => new RichTextBlueEffect(),
+ "fade_in" => new RichTextFadeInEffect(animatedEffectsEnabled),
+ "sine" => new RichTextSineEffect(animatedEffectsEnabled),
+ "jitter" => new RichTextJitterEffect(animatedEffectsEnabled),
+ "fly_in" => new RichTextFlyInEffect(animatedEffectsEnabled),
+ _ => null
+ };
+ }
+}
diff --git a/GFramework.Godot/Text/Effects/RichTextBlueEffect.cs b/GFramework.Godot/Text/Effects/RichTextBlueEffect.cs
new file mode 100644
index 00000000..c7319616
--- /dev/null
+++ b/GFramework.Godot/Text/Effects/RichTextBlueEffect.cs
@@ -0,0 +1,27 @@
+namespace GFramework.Godot.Text.Effects;
+
+///
+/// 为文本应用蓝色语义高亮。
+///
+[GlobalClass]
+[Tool]
+public partial class RichTextBlueEffect : RichTextEffectBase
+{
+ private static readonly Color BlueColor = new(0.44f, 0.72f, 0.98f, 1.0f);
+
+ ///
+ /// 获取标签名。
+ ///
+ protected override string TagName => "blue";
+
+ ///
+ /// 应用蓝色颜色效果。
+ ///
+ /// 当前字符上下文。
+ /// 始终返回 。
+ public override bool _ProcessCustomFX(CharFXTransform charFx)
+ {
+ charFx.Color = BlueColor;
+ return true;
+ }
+}
diff --git a/GFramework.Godot/Text/Effects/RichTextEffectBase.cs b/GFramework.Godot/Text/Effects/RichTextEffectBase.cs
new file mode 100644
index 00000000..41d7ca8e
--- /dev/null
+++ b/GFramework.Godot/Text/Effects/RichTextEffectBase.cs
@@ -0,0 +1,81 @@
+namespace GFramework.Godot.Text.Effects;
+
+///
+/// 富文本效果基类,提供统一的标签命名和环境参数读取辅助逻辑。
+/// 该基类只负责 Godot 适配细节,不承载业务语义分层。
+///
+[Tool]
+public abstract partial class RichTextEffectBase : RichTextEffect
+{
+ ///
+ /// 获取当前效果对应的 BBCode 标签名。
+ ///
+ protected abstract string TagName { get; }
+
+ ///
+ /// 获取 Godot 识别当前效果所需的 `bbcode` 属性。
+ ///
+ public string bbcode => TagName;
+
+ ///
+ /// 尝试从字符环境参数中读取布尔值。
+ ///
+ /// 当前字符变换上下文。
+ /// 参数键。
+ /// 读取失败时使用的默认值。
+ /// 最终布尔值。
+ protected bool GetBool(CharFXTransform transform, string key, bool defaultValue = false)
+ {
+ if (transform.Env.TryGetValue(Variant.From(key), out var value))
+ {
+ return value.AsBool();
+ }
+
+ return defaultValue;
+ }
+
+ ///
+ /// 尝试从字符环境参数中读取浮点值。
+ ///
+ /// 当前字符变换上下文。
+ /// 参数键。
+ /// 读取失败时使用的默认值。
+ /// 最终浮点值。
+ protected float GetFloat(CharFXTransform transform, string key, float defaultValue)
+ {
+ if (transform.Env.TryGetValue(Variant.From(key), out var value))
+ {
+ return (float)value.AsDouble();
+ }
+
+ return defaultValue;
+ }
+
+ ///
+ /// 尝试从字符环境参数中读取颜色值。
+ ///
+ /// 当前字符变换上下文。
+ /// 参数键。
+ /// 读取失败时使用的默认值。
+ /// 最终颜色值。
+ protected Color GetColor(CharFXTransform transform, string key, Color defaultValue)
+ {
+ if (transform.Env.TryGetValue(Variant.From(key), out var value) &&
+ value.VariantType == Variant.Type.Color)
+ {
+ return (Color)value;
+ }
+
+ return defaultValue;
+ }
+
+ ///
+ /// 从字符环境参数中应用可见性开关。
+ ///
+ /// 当前字符变换上下文。
+ /// 默认可见性。
+ protected void ApplyVisibility(CharFXTransform transform, bool defaultValue = true)
+ {
+ transform.Visible = GetBool(transform, "visible", defaultValue);
+ }
+}
diff --git a/GFramework.Godot/Text/Effects/RichTextFadeInEffect.cs b/GFramework.Godot/Text/Effects/RichTextFadeInEffect.cs
new file mode 100644
index 00000000..3af25719
--- /dev/null
+++ b/GFramework.Godot/Text/Effects/RichTextFadeInEffect.cs
@@ -0,0 +1,47 @@
+namespace GFramework.Godot.Text.Effects;
+
+///
+/// 为文本提供逐字符淡入效果。
+///
+[GlobalClass]
+[Tool]
+public partial class RichTextFadeInEffect : RichTextEffectBase
+{
+ private readonly bool _animatedEffectsEnabled;
+
+ ///
+ /// 初始化淡入效果。
+ ///
+ /// 是否允许动态效果实际生效。
+ public RichTextFadeInEffect(bool animatedEffectsEnabled = true)
+ {
+ _animatedEffectsEnabled = animatedEffectsEnabled;
+ }
+
+ ///
+ /// 获取标签名。
+ ///
+ protected override string TagName => "fade_in";
+
+ ///
+ /// 应用淡入动画。
+ ///
+ /// 当前字符上下文。
+ /// 始终返回 。
+ public override bool _ProcessCustomFX(CharFXTransform charFx)
+ {
+ if (!_animatedEffectsEnabled)
+ {
+ return true;
+ }
+
+ var speed = GetFloat(charFx, "speed", 4.0f);
+ var tick = GetFloat(charFx, "tick", 0.01f);
+ var progress = (float)(charFx.ElapsedTime * speed - charFx.RelativeIndex * tick);
+ var color = charFx.Color;
+ color.A = Mathf.Clamp(progress, 0f, 1f);
+ charFx.Color = color;
+ ApplyVisibility(charFx);
+ return true;
+ }
+}
diff --git a/GFramework.Godot/Text/Effects/RichTextFlyInEffect.cs b/GFramework.Godot/Text/Effects/RichTextFlyInEffect.cs
new file mode 100644
index 00000000..efb6f893
--- /dev/null
+++ b/GFramework.Godot/Text/Effects/RichTextFlyInEffect.cs
@@ -0,0 +1,64 @@
+namespace GFramework.Godot.Text.Effects;
+
+///
+/// 为文本提供逐字符飞入效果。
+///
+[GlobalClass]
+[Tool]
+public partial class RichTextFlyInEffect : RichTextEffectBase
+{
+ private readonly bool _animatedEffectsEnabled;
+
+ ///
+ /// 初始化飞入效果。
+ ///
+ /// 是否允许动态效果实际生效。
+ public RichTextFlyInEffect(bool animatedEffectsEnabled = true)
+ {
+ _animatedEffectsEnabled = animatedEffectsEnabled;
+ }
+
+ ///
+ /// 获取标签名。
+ ///
+ protected override string TagName => "fly_in";
+
+ ///
+ /// 应用飞入动画。
+ ///
+ /// 当前字符上下文。
+ /// 始终返回 。
+ public override bool _ProcessCustomFX(CharFXTransform charFx)
+ {
+ if (!_animatedEffectsEnabled)
+ {
+ return true;
+ }
+
+ var startOffset = new Vector2(
+ GetFloat(charFx, "offset_x", 12f),
+ GetFloat(charFx, "offset_y", 0f));
+ var speed = GetFloat(charFx, "speed", 3.0f);
+ var tick = GetFloat(charFx, "tick", 0.015f);
+ var progress = Mathf.Clamp((float)(charFx.ElapsedTime * speed - charFx.RelativeIndex * tick), 0f, 1f);
+ var eased = EaseOutQuad(progress);
+
+ charFx.Offset += startOffset * (1f - eased);
+
+ var color = charFx.Color;
+ color.A = eased;
+ charFx.Color = color;
+ ApplyVisibility(charFx);
+ return true;
+ }
+
+ ///
+ /// 计算二次缓出值。
+ ///
+ /// 归一化进度。
+ /// 缓出后的进度。
+ private static float EaseOutQuad(float value)
+ {
+ return 1f - (1f - value) * (1f - value);
+ }
+}
diff --git a/GFramework.Godot/Text/Effects/RichTextGoldEffect.cs b/GFramework.Godot/Text/Effects/RichTextGoldEffect.cs
new file mode 100644
index 00000000..68ee952d
--- /dev/null
+++ b/GFramework.Godot/Text/Effects/RichTextGoldEffect.cs
@@ -0,0 +1,27 @@
+namespace GFramework.Godot.Text.Effects;
+
+///
+/// 为文本应用金色语义高亮。
+///
+[GlobalClass]
+[Tool]
+public partial class RichTextGoldEffect : RichTextEffectBase
+{
+ private static readonly Color GoldColor = new(0.96f, 0.79f, 0.34f, 1.0f);
+
+ ///
+ /// 获取标签名。
+ ///
+ protected override string TagName => "gold";
+
+ ///
+ /// 应用金色颜色效果。
+ ///
+ /// 当前字符上下文。
+ /// 始终返回 。
+ public override bool _ProcessCustomFX(CharFXTransform charFx)
+ {
+ charFx.Color = GoldColor;
+ return true;
+ }
+}
diff --git a/GFramework.Godot/Text/Effects/RichTextGreenEffect.cs b/GFramework.Godot/Text/Effects/RichTextGreenEffect.cs
new file mode 100644
index 00000000..f4f3db0e
--- /dev/null
+++ b/GFramework.Godot/Text/Effects/RichTextGreenEffect.cs
@@ -0,0 +1,27 @@
+namespace GFramework.Godot.Text.Effects;
+
+///
+/// 为文本应用绿色语义高亮。
+///
+[GlobalClass]
+[Tool]
+public partial class RichTextGreenEffect : RichTextEffectBase
+{
+ private static readonly Color GreenColor = new(0.46f, 0.91f, 0.49f, 1.0f);
+
+ ///
+ /// 获取标签名。
+ ///
+ protected override string TagName => "green";
+
+ ///
+ /// 应用绿色颜色效果。
+ ///
+ /// 当前字符上下文。
+ /// 始终返回 。
+ public override bool _ProcessCustomFX(CharFXTransform charFx)
+ {
+ charFx.Color = GreenColor;
+ return true;
+ }
+}
diff --git a/GFramework.Godot/Text/Effects/RichTextJitterEffect.cs b/GFramework.Godot/Text/Effects/RichTextJitterEffect.cs
new file mode 100644
index 00000000..b5b3fa16
--- /dev/null
+++ b/GFramework.Godot/Text/Effects/RichTextJitterEffect.cs
@@ -0,0 +1,57 @@
+namespace GFramework.Godot.Text.Effects;
+
+///
+/// 为文本提供抖动效果。
+///
+[GlobalClass]
+[Tool]
+public partial class RichTextJitterEffect : RichTextEffectBase
+{
+ private readonly bool _animatedEffectsEnabled;
+ private readonly FastNoiseLite _noise;
+
+ ///
+ /// 初始化抖动效果。
+ ///
+ /// 是否允许动态效果实际生效。
+ public RichTextJitterEffect(bool animatedEffectsEnabled = true)
+ {
+ _animatedEffectsEnabled = animatedEffectsEnabled;
+ _noise = new FastNoiseLite
+ {
+ NoiseType = FastNoiseLite.NoiseTypeEnum.Perlin,
+ FractalOctaves = 8,
+ FractalGain = 0.8f
+ };
+ }
+
+ ///
+ /// 获取标签名。
+ ///
+ protected override string TagName => "jitter";
+
+ ///
+ /// 应用抖动位移。
+ ///
+ /// 当前字符上下文。
+ /// 始终返回 。
+ public override bool _ProcessCustomFX(CharFXTransform charFx)
+ {
+ if (!_animatedEffectsEnabled)
+ {
+ return true;
+ }
+
+ var amplitude = GetFloat(charFx, "amplitude", 3.0f);
+ var speed = GetFloat(charFx, "speed", 600.0f);
+
+ _noise.Seed = (charFx.RelativeIndex + 1) * 131;
+ var x = _noise.GetNoise1D((float)charFx.ElapsedTime * speed);
+ _noise.Seed = (charFx.RelativeIndex + 1) * 737;
+ var y = _noise.GetNoise1D((float)charFx.ElapsedTime * speed);
+
+ charFx.Offset += new Vector2(x, y) * amplitude;
+ ApplyVisibility(charFx);
+ return true;
+ }
+}
diff --git a/GFramework.Godot/Text/Effects/RichTextRedEffect.cs b/GFramework.Godot/Text/Effects/RichTextRedEffect.cs
new file mode 100644
index 00000000..af4afa4e
--- /dev/null
+++ b/GFramework.Godot/Text/Effects/RichTextRedEffect.cs
@@ -0,0 +1,27 @@
+namespace GFramework.Godot.Text.Effects;
+
+///
+/// 为文本应用红色语义高亮。
+///
+[GlobalClass]
+[Tool]
+public partial class RichTextRedEffect : RichTextEffectBase
+{
+ private static readonly Color RedColor = new(0.96f, 0.35f, 0.35f, 1.0f);
+
+ ///
+ /// 获取标签名。
+ ///
+ protected override string TagName => "red";
+
+ ///
+ /// 应用红色颜色效果。
+ ///
+ /// 当前字符上下文。
+ /// 始终返回 。
+ public override bool _ProcessCustomFX(CharFXTransform charFx)
+ {
+ charFx.Color = RedColor;
+ return true;
+ }
+}
diff --git a/GFramework.Godot/Text/Effects/RichTextSineEffect.cs b/GFramework.Godot/Text/Effects/RichTextSineEffect.cs
new file mode 100644
index 00000000..10530bf9
--- /dev/null
+++ b/GFramework.Godot/Text/Effects/RichTextSineEffect.cs
@@ -0,0 +1,47 @@
+namespace GFramework.Godot.Text.Effects;
+
+///
+/// 为文本提供正弦波形的上下漂浮效果。
+///
+[GlobalClass]
+[Tool]
+public partial class RichTextSineEffect : RichTextEffectBase
+{
+ private readonly bool _animatedEffectsEnabled;
+
+ ///
+ /// 初始化正弦效果。
+ ///
+ /// 是否允许动态效果实际生效。
+ public RichTextSineEffect(bool animatedEffectsEnabled = true)
+ {
+ _animatedEffectsEnabled = animatedEffectsEnabled;
+ }
+
+ ///
+ /// 获取标签名。
+ ///
+ protected override string TagName => "sine";
+
+ ///
+ /// 应用正弦位移。
+ ///
+ /// 当前字符上下文。
+ /// 始终返回 。
+ public override bool _ProcessCustomFX(CharFXTransform charFx)
+ {
+ if (!_animatedEffectsEnabled)
+ {
+ return true;
+ }
+
+ var amplitude = GetFloat(charFx, "amplitude", 0.8f);
+ var frequency = GetFloat(charFx, "frequency", 0.5f);
+ var speed = GetFloat(charFx, "speed", 1.5f);
+ var phase = (float)(charFx.ElapsedTime * speed + charFx.RelativeIndex * 0.1f);
+ var offsetY = amplitude * Mathf.Sin(phase * Mathf.Pi * 2f * frequency);
+ charFx.Offset += new Vector2(0f, offsetY);
+ ApplyVisibility(charFx);
+ return true;
+ }
+}
diff --git a/GFramework.Godot/Text/GfRichTextLabel.cs b/GFramework.Godot/Text/GfRichTextLabel.cs
new file mode 100644
index 00000000..6715ec45
--- /dev/null
+++ b/GFramework.Godot/Text/GfRichTextLabel.cs
@@ -0,0 +1,83 @@
+namespace GFramework.Godot.Text;
+
+///
+/// GFramework 提供的组合式富文本标签宿主。
+/// 该类型只负责桥接 Godot 的 与框架的效果装配逻辑,不承载具体效果实现。
+///
+[GlobalClass]
+[Tool]
+public partial class GfRichTextLabel : RichTextLabel
+{
+ private IRichTextEffectRegistry? _effectRegistry;
+ private RichTextEffectsController? _effectsController;
+
+ ///
+ /// 获取或设置当前标签使用的效果配置。
+ /// 为空时将回退到内置默认配置。
+ ///
+ [Export]
+ public RichTextProfile? Profile { get; set; }
+
+ ///
+ /// 获取或设置是否启用框架管理的富文本效果装配。
+ ///
+ [Export]
+ public bool EnableFrameworkEffects { get; set; } = true;
+
+ ///
+ /// 获取或设置是否允许字符级动态效果实际生效。
+ /// 关闭后仍然会安装对应标签,使富文本内容保持可解析。
+ ///
+ [Export]
+ public bool AnimatedEffectsEnabled { get; set; } = true;
+
+ ///
+ /// 获取当前使用的效果注册表。
+ ///
+ internal IRichTextEffectRegistry EffectRegistry
+ {
+ get => _effectRegistry ??= new DefaultRichTextEffectRegistry();
+ set => _effectRegistry = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ ///
+ /// 节点就绪时初始化控制器并安装效果集合。
+ ///
+ public override void _Ready()
+ {
+ if (EnableFrameworkEffects && !BbcodeEnabled)
+ {
+ BbcodeEnabled = true;
+ }
+
+ EnsureController().Initialize();
+ }
+
+ ///
+ /// 手动刷新框架效果集合。
+ /// 当调用方在运行时替换配置或切换动画开关时,可通过该方法同步宿主状态。
+ ///
+ public void RefreshFrameworkEffects()
+ {
+ if (EnableFrameworkEffects && !BbcodeEnabled)
+ {
+ BbcodeEnabled = true;
+ }
+
+ EnsureController().RefreshEffects();
+ }
+
+ ///
+ /// 获取或创建控制器实例。
+ ///
+ /// 组合式装配控制器。
+ private RichTextEffectsController EnsureController()
+ {
+ return _effectsController ??= new RichTextEffectsController(
+ this,
+ EffectRegistry,
+ () => Profile,
+ () => EnableFrameworkEffects,
+ () => AnimatedEffectsEnabled);
+ }
+}
diff --git a/GFramework.Godot/Text/IRichTextEffectRegistry.cs b/GFramework.Godot/Text/IRichTextEffectRegistry.cs
new file mode 100644
index 00000000..8d5a29c1
--- /dev/null
+++ b/GFramework.Godot/Text/IRichTextEffectRegistry.cs
@@ -0,0 +1,23 @@
+namespace GFramework.Godot.Text;
+
+///
+/// 富文本效果注册表,负责把配置中的效果键解析为可安装的 实例。
+///
+public interface IRichTextEffectRegistry
+{
+ ///
+ /// 根据指定配置创建完整的效果实例集合。
+ ///
+ /// 效果组合配置。
+ /// 当前是否允许字符级动态效果生效。
+ /// 可直接写入 的效果实例集合。
+ IReadOnlyList CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled);
+
+ ///
+ /// 根据单个效果键创建对应效果实例。
+ ///
+ /// 效果键。
+ /// 当前是否允许字符级动态效果生效。
+ /// 解析成功时返回效果实例;否则返回 。
+ RichTextEffect? CreateEffect(string key, bool animatedEffectsEnabled);
+}
diff --git a/GFramework.Godot/Text/RichTextEffectEntry.cs b/GFramework.Godot/Text/RichTextEffectEntry.cs
new file mode 100644
index 00000000..63e30d3d
--- /dev/null
+++ b/GFramework.Godot/Text/RichTextEffectEntry.cs
@@ -0,0 +1,22 @@
+namespace GFramework.Godot.Text;
+
+///
+/// 描述一条富文本效果配置项。
+/// 该资源只负责声明需要启用的效果键与开关状态,不承担实例创建逻辑。
+///
+[GlobalClass]
+public partial class RichTextEffectEntry : Resource
+{
+ ///
+ /// 获取或设置效果键。
+ /// 键值由 解析为具体的 实例。
+ ///
+ [Export]
+ public string Key { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置该配置项是否启用。
+ ///
+ [Export]
+ public bool Enabled { get; set; } = true;
+}
diff --git a/GFramework.Godot/Text/RichTextEffectsController.cs b/GFramework.Godot/Text/RichTextEffectsController.cs
new file mode 100644
index 00000000..2a9eeb78
--- /dev/null
+++ b/GFramework.Godot/Text/RichTextEffectsController.cs
@@ -0,0 +1,70 @@
+using Array = Godot.Collections.Array;
+
+namespace GFramework.Godot.Text;
+
+///
+/// 负责把配置、开关和注册表装配为宿主标签的实际效果集合。
+/// 该控制器是组合式扩展的装配中心,使 保持轻量。
+///
+internal sealed class RichTextEffectsController
+{
+ private readonly Func _animatedEffectsEnabledAccessor;
+ private readonly Func _frameworkEffectsEnabledAccessor;
+ private readonly RichTextLabel _host;
+ private readonly Func _profileAccessor;
+ private readonly IRichTextEffectRegistry _registry;
+
+ ///
+ /// 初始化控制器实例。
+ ///
+ /// 目标富文本标签。
+ /// 效果注册表。
+ /// 当前配置访问器。
+ /// 框架效果总开关访问器。
+ /// 字符动画开关访问器。
+ public RichTextEffectsController(
+ RichTextLabel host,
+ IRichTextEffectRegistry registry,
+ Func profileAccessor,
+ Func frameworkEffectsEnabledAccessor,
+ Func animatedEffectsEnabledAccessor)
+ {
+ _host = host ?? throw new ArgumentNullException(nameof(host));
+ _registry = registry ?? throw new ArgumentNullException(nameof(registry));
+ _profileAccessor = profileAccessor ?? throw new ArgumentNullException(nameof(profileAccessor));
+ _frameworkEffectsEnabledAccessor = frameworkEffectsEnabledAccessor
+ ?? throw new ArgumentNullException(nameof(frameworkEffectsEnabledAccessor));
+ _animatedEffectsEnabledAccessor = animatedEffectsEnabledAccessor
+ ?? throw new ArgumentNullException(nameof(animatedEffectsEnabledAccessor));
+ }
+
+ ///
+ /// 初始化并立即刷新宿主标签的效果集合。
+ ///
+ public void Initialize()
+ {
+ RefreshEffects();
+ }
+
+ ///
+ /// 根据当前配置和开关重建宿主标签上的 。
+ ///
+ public void RefreshEffects()
+ {
+ if (!_frameworkEffectsEnabledAccessor())
+ {
+ _host.CustomEffects = new Array();
+ return;
+ }
+
+ var profile = _profileAccessor() ?? RichTextProfile.CreateBuiltInDefault();
+ var effects = _registry.CreateEffects(profile, _animatedEffectsEnabledAccessor());
+ var customEffects = new Array();
+ foreach (var effect in effects)
+ {
+ customEffects.Add(effect);
+ }
+
+ _host.CustomEffects = customEffects;
+ }
+}
diff --git a/GFramework.Godot/Text/RichTextMarkup.cs b/GFramework.Godot/Text/RichTextMarkup.cs
new file mode 100644
index 00000000..e08ec667
--- /dev/null
+++ b/GFramework.Godot/Text/RichTextMarkup.cs
@@ -0,0 +1,128 @@
+using System.Globalization;
+using System.Text;
+
+namespace GFramework.Godot.Text;
+
+///
+/// 提供语义化的富文本标签构建辅助方法。
+/// 该工具层用于减少业务代码直接手写原始 BBCode 字符串的重复工作。
+///
+public static class RichTextMarkup
+{
+ ///
+ /// 使用指定标签包裹文本。
+ ///
+ /// 原始文本。
+ /// 标签名。
+ /// 包裹后的 BBCode 文本。
+ public static string Color(string text, string tag)
+ {
+ return Wrap(text, tag);
+ }
+
+ ///
+ /// 使用 `green` 标签包裹文本。
+ ///
+ /// 原始文本。
+ /// 包裹后的 BBCode 文本。
+ public static string Green(string text)
+ {
+ return Wrap(text, "green");
+ }
+
+ ///
+ /// 使用 `red` 标签包裹文本。
+ ///
+ /// 原始文本。
+ /// 包裹后的 BBCode 文本。
+ public static string Red(string text)
+ {
+ return Wrap(text, "red");
+ }
+
+ ///
+ /// 使用 `gold` 标签包裹文本。
+ ///
+ /// 原始文本。
+ /// 包裹后的 BBCode 文本。
+ public static string Gold(string text)
+ {
+ return Wrap(text, "gold");
+ }
+
+ ///
+ /// 使用 `blue` 标签包裹文本。
+ ///
+ /// 原始文本。
+ /// 包裹后的 BBCode 文本。
+ public static string Blue(string text)
+ {
+ return Wrap(text, "blue");
+ }
+
+ ///
+ /// 使用指定效果标签包裹文本,并可附带参数环境。
+ ///
+ /// 原始文本。
+ /// 标签名。
+ /// 可选的标签参数集合。
+ /// 包裹后的 BBCode 文本。
+ public static string Effect(string text, string tag, IReadOnlyDictionary? env = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tag);
+
+ var builder = new StringBuilder();
+ builder.Append('[');
+ builder.Append(tag);
+
+ if (env is not null)
+ {
+ foreach (var pair in env)
+ {
+ if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
+ {
+ continue;
+ }
+
+ builder.Append(' ');
+ builder.Append(pair.Key);
+ builder.Append('=');
+ builder.Append(FormatValue(pair.Value));
+ }
+ }
+
+ builder.Append(']');
+ builder.Append(text ?? string.Empty);
+ builder.Append("[/");
+ builder.Append(tag);
+ builder.Append(']');
+ return builder.ToString();
+ }
+
+ ///
+ /// 使用指定标签包裹文本。
+ ///
+ /// 原始文本。
+ /// 标签名。
+ /// 包裹后的 BBCode 文本。
+ private static string Wrap(string text, string tag)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tag);
+ return $"[{tag}]{text ?? string.Empty}[/{tag}]";
+ }
+
+ ///
+ /// 将标签参数值格式化为稳定的 BBCode 字符串表示。
+ ///
+ /// 待格式化的值。
+ /// 适用于 BBCode 参数的字符串。
+ private static string FormatValue(object value)
+ {
+ return value switch
+ {
+ string text => text,
+ IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
+ _ => value.ToString() ?? string.Empty
+ };
+ }
+}
diff --git a/GFramework.Godot/Text/RichTextProfile.cs b/GFramework.Godot/Text/RichTextProfile.cs
new file mode 100644
index 00000000..d61d3f56
--- /dev/null
+++ b/GFramework.Godot/Text/RichTextProfile.cs
@@ -0,0 +1,37 @@
+namespace GFramework.Godot.Text;
+
+///
+/// 描述一个富文本效果组合配置。
+/// 该资源是组合式扩展的核心载体,用于声明宿主标签需要安装的效果集合。
+///
+[GlobalClass]
+public partial class RichTextProfile : Resource
+{
+ ///
+ /// 获取或设置当前配置启用的效果条目集合。
+ ///
+ [Export]
+ public RichTextEffectEntry[] Effects { get; set; } = [];
+
+ ///
+ /// 创建包含全部内置效果的默认配置。
+ /// 该方法为第一阶段提供零配置可用的回退组合。
+ ///
+ /// 包含全部内置效果键的默认配置。
+ public static RichTextProfile CreateBuiltInDefault()
+ {
+ 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" }
+ ];
+ return profile;
+ }
+}
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 2/8] =?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 字符串表示。
///
From 1c2a813a5268373f9a3843833dc606ff01cda725 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Sat, 18 Apr 2026 14:55:31 +0800
Subject: [PATCH 3/8] =?UTF-8?q?feat(ci):=20=E6=B7=BB=E5=8A=A0CI/CD?=
=?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=85=8D=E7=BD=AE=E5=92=8CGodot?=
=?UTF-8?q?=E6=96=87=E6=9C=AC=E6=95=88=E6=9E=9C=E6=8E=A7=E5=88=B6=E5=99=A8?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 配置GitHub Actions工作流用于PR构建和测试
- 实现代码质量检查和安全扫描功能
- 添加.NET多版本SDK支持和依赖缓存
- 集成MegaLinter和TruffleHog安全工具
- 创建RichTextEffectsController的单元测试覆盖各种场景
- 实现测试报告生成和发布功能
---
.github/workflows/ci.yml | 29 +++++++++++--------
.../Text/RichTextEffectsControllerTests.cs | 10 +++----
2 files changed, 22 insertions(+), 17 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3ceb98e8..e14bb0c4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -145,11 +145,12 @@ jobs:
run: dotnet build GFramework.sln -c Release --no-restore
# 运行单元测试,输出TRX格式结果到TestResults目录
- # 在同一个 step 中并发执行所有测试以加快速度
+ # 顺序执行各测试项目,避免并发 dotnet test 进程导致“TRX 全绿但 step 仍返回失败”的假红状态
- name: Test All Projects
id: test_all_projects
run: |
set -euo pipefail
+ mkdir -p TestResults
test_projects=(
"GFramework.Core.Tests/GFramework.Core.Tests.csproj:core"
@@ -161,27 +162,31 @@ jobs:
"GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj:godot-sg"
)
- pids=()
+ failed=0
+ failed_projects=()
+
for entry in "${test_projects[@]}"; do
project="${entry%%:*}"
name="${entry##*:}"
- dotnet test "$project" \
+ echo "::group::dotnet test $project"
+ if ! dotnet test "$project" \
-c Release \
--no-build \
- --logger "trx;LogFileName=${name}-$RANDOM.trx" \
- --results-directory TestResults &
-
- pids+=("$!")
- done
-
- failed=0
- for pid in "${pids[@]}"; do
- if ! wait "$pid"; then
+ --logger "trx;LogFileName=${name}.trx" \
+ --results-directory TestResults; then
failed=1
+ failed_projects+=("$project")
+ echo "::error title=Test project failed::$project returned a non-zero exit code."
fi
+ echo "::endgroup::"
done
+ if [ "$failed" -eq 1 ]; then
+ printf 'Failed test projects:\n'
+ printf ' %s\n' "${failed_projects[@]}"
+ fi
+
echo "failed=$failed" >> "$GITHUB_OUTPUT"
- name: Generate CTRF report
diff --git a/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs b/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
index 4d1a1c30..c028e178 100644
--- a/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
+++ b/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
@@ -1,5 +1,4 @@
using GFramework.Godot.Text;
-using Godot;
using Array = Godot.Collections.Array;
namespace GFramework.Godot.Tests.Text;
@@ -28,7 +27,8 @@ public sealed class RichTextEffectsControllerTests
controller.RefreshEffects();
Assert.That(host.BbcodeEnabled, Is.True);
- Assert.That(registry.CapturedAnimatedEffectsEnabled, Is.False);
+ 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[]
{
@@ -109,13 +109,13 @@ public sealed class RichTextEffectsControllerTests
{
public List CapturedProfiles { get; } = [];
- public bool CapturedAnimatedEffectsEnabled { get; private set; }
+ public List CapturedAnimatedEffectsEnabled { get; } = [];
public IReadOnlyList CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled)
{
CapturedProfiles.Add(profile);
- CapturedAnimatedEffectsEnabled = animatedEffectsEnabled;
- return System.Array.Empty();
+ CapturedAnimatedEffectsEnabled.Add(animatedEffectsEnabled);
+ return Array.Empty();
}
public RichTextEffect? CreateEffect(string key, bool animatedEffectsEnabled)
From f3d50c63613715f2fed739fc49aef50980cae9fc Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Sat, 18 Apr 2026 14:59:21 +0800
Subject: [PATCH 4/8] =?UTF-8?q?refactor(tests):=20=E6=9B=B4=E6=96=B0?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=BB=A3=E7=A0=81=E4=B8=AD=E7=9A=84=E5=91=BD?=
=?UTF-8?q?=E5=90=8D=E7=A9=BA=E9=97=B4=E5=BC=95=E7=94=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 将 RichTextEffectsControllerTests 中的 GFramework.Godot.Text 引用替换为 Godot 相关命名空间
- 使用 Godot.Collections.Array 替代系统数组类型
- 在 GlobalUsings 中添加 GFramework.Godot.Text 的全局引用
- 修复返回类型以使用新的 Array 结构
---
GFramework.Godot.Tests/GlobalUsings.cs | 1 +
.../Text/RichTextEffectsControllerTests.cs | 5 +++--
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/GFramework.Godot.Tests/GlobalUsings.cs b/GFramework.Godot.Tests/GlobalUsings.cs
index 70989071..622488b8 100644
--- a/GFramework.Godot.Tests/GlobalUsings.cs
+++ b/GFramework.Godot.Tests/GlobalUsings.cs
@@ -22,3 +22,4 @@ global using System.Globalization;
global using System.IO;
global using System.Text;
global using System.Text.Json;
+global using GFramework.Godot.Text;
diff --git a/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs b/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
index c028e178..0280d8c9 100644
--- a/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
+++ b/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
@@ -1,4 +1,5 @@
-using GFramework.Godot.Text;
+using Godot;
+using Godot.Collections;
using Array = Godot.Collections.Array;
namespace GFramework.Godot.Tests.Text;
@@ -115,7 +116,7 @@ public sealed class RichTextEffectsControllerTests
{
CapturedProfiles.Add(profile);
CapturedAnimatedEffectsEnabled.Add(animatedEffectsEnabled);
- return Array.Empty();
+ return new Array();
}
public RichTextEffect? CreateEffect(string key, bool animatedEffectsEnabled)
From 1665e721152fb320e91e60b8ec2519766504c6cc Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Sat, 18 Apr 2026 15:05:09 +0800
Subject: [PATCH 5/8] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8DPR=E6=8A=A5?=
=?UTF-8?q?=E5=91=8A=E6=9D=83=E9=99=90=E9=85=8D=E7=BD=AE=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 添加pull-requests写入权限以支持PR评论功能
- 修改pull-request-report条件避免跨仓库触发错误
---
.github/workflows/ci.yml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e14bb0c4..ae1e2f42 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,6 +8,7 @@ on:
permissions:
contents: read
+ pull-requests: write
security-events: write
jobs:
@@ -218,7 +219,7 @@ jobs:
with:
report-path: './ctrf/*.json'
github-report: true
- pull-request-report: true
+ pull-request-report: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
summary-delta-report: true
insights-report: true
flaky-rate-report: true
From 5cb5a2270b1b1cea91c71953bbfb8a477ad58e88 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Sat, 18 Apr 2026 15:08:53 +0800
Subject: [PATCH 6/8] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=E6=B5=8B?=
=?UTF-8?q?=E8=AF=95=E9=A1=B9=E7=9B=AE=E5=A4=B1=E8=B4=A5=E6=97=B6=E7=9A=84?=
=?UTF-8?q?=E8=BE=93=E5=87=BA=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 使用here document格式正确输出失败项目列表到GITHUB_OUTPUT
- 添加EOF分隔符确保多行内容正确传递
- 在失败步骤中读取并显示具体的失败项目名称
- 保持原有的退出码设置确保工作流正确失败
---
.github/workflows/ci.yml | 16 ++++++++++++++--
1 file changed, 14 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ae1e2f42..c62726a5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -188,7 +188,14 @@ jobs:
printf ' %s\n' "${failed_projects[@]}"
fi
- echo "failed=$failed" >> "$GITHUB_OUTPUT"
+ {
+ echo "failed=$failed"
+ echo "failed_projects<> "$GITHUB_OUTPUT"
- name: Generate CTRF report
run: |
@@ -233,4 +240,9 @@ jobs:
- name: Fail if any test project failed
if: always() && steps.test_all_projects.outputs.failed == '1'
- run: exit 1
+ env:
+ FAILED_PROJECTS: ${{ steps.test_all_projects.outputs.failed_projects }}
+ run: |
+ echo "The following test projects returned non-zero exit codes:"
+ printf '%s\n' "$FAILED_PROJECTS"
+ exit 1
From 1145f455f335ebed664e50f9f34c53312d6c5018 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Sat, 18 Apr 2026 15:18:37 +0800
Subject: [PATCH 7/8] =?UTF-8?q?feat(ci):=20=E6=B7=BB=E5=8A=A0CI/CD?=
=?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 配置pull request触发的构建和测试流程
- 集成代码质量检查和安全扫描功能
- 设置.NET多版本SDK环境支持
- 配置NuGet包和dotnet工具缓存优化
- 实现Node.js和Bun运行时环境搭建
- 添加配置工具依赖安装和测试执行
- 配置项目构建和单元测试执行流程
- 集成测试报告生成和发布功能
- 实现失败测试项目的错误处理机制
---
.github/workflows/ci.yml | 38 +++++++++++++++++++++++++++++++++++---
1 file changed, 35 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c62726a5..963c3e52 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -159,25 +159,28 @@ 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"
)
failed=0
failed_projects=()
+ failed_log_paths=()
for entry in "${test_projects[@]}"; do
project="${entry%%:*}"
name="${entry##*:}"
+ log_path="TestResults/${name}.console.log"
echo "::group::dotnet test $project"
if ! dotnet test "$project" \
-c Release \
--no-build \
--logger "trx;LogFileName=${name}.trx" \
- --results-directory TestResults; then
+ --results-directory TestResults \
+ 2>&1 | tee "$log_path"; then
failed=1
failed_projects+=("$project")
+ failed_log_paths+=("$log_path")
echo "::error title=Test project failed::$project returned a non-zero exit code."
fi
echo "::endgroup::"
@@ -195,8 +198,13 @@ jobs:
printf '%s\n' "${failed_projects[@]}"
fi
echo "EOF"
+ echo "failed_log_paths<> "$GITHUB_OUTPUT"
-
+
- name: Generate CTRF report
run: |
mkdir -p ctrf
@@ -211,6 +219,20 @@ jobs:
-d ctrf \
-f "$name.json"
done
+
+ - name: Run GFramework.Godot.Tests Diagnostics
+ if: always()
+ continue-on-error: true
+ run: |
+ mkdir -p TestResults
+ dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj \
+ -c Release \
+ --no-build \
+ --blame-crash \
+ --diag TestResults/godot-testhost-diag.log \
+ --logger "trx;LogFileName=godot-diagnostic.trx" \
+ --results-directory TestResults \
+ 2>&1 | tee TestResults/godot-diagnostic.console.log
# 生成并发布测试报告,无论测试成功或失败都会执行
@@ -242,7 +264,17 @@ jobs:
if: always() && steps.test_all_projects.outputs.failed == '1'
env:
FAILED_PROJECTS: ${{ steps.test_all_projects.outputs.failed_projects }}
+ FAILED_LOG_PATHS: ${{ steps.test_all_projects.outputs.failed_log_paths }}
run: |
echo "The following test projects returned non-zero exit codes:"
printf '%s\n' "$FAILED_PROJECTS"
+ echo
+ echo "Captured dotnet test output:"
+ while IFS= read -r log_path; do
+ if [ -n "$log_path" ] && [ -f "$log_path" ]; then
+ echo "--- BEGIN $log_path ---"
+ cat "$log_path"
+ echo "--- END $log_path ---"
+ fi
+ done <<< "$FAILED_LOG_PATHS"
exit 1
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 8/8] =?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;
}
}