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; } }