diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ceb98e8..db07fe4f 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: @@ -145,11 +146,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,29 +163,49 @@ jobs: "GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj:godot-sg" ) - pids=() + failed=0 + failed_projects=() + failed_log_paths=() + for entry in "${test_projects[@]}"; do project="${entry%%:*}" name="${entry##*:}" + log_path="TestResults/${name}.console.log" - 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 \ + 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::" done - echo "failed=$failed" >> "$GITHUB_OUTPUT" - + if [ "$failed" -eq 1 ]; then + printf 'Failed test projects:\n' + printf ' %s\n' "${failed_projects[@]}" + fi + + { + echo "failed=$failed" + echo "failed_projects<> "$GITHUB_OUTPUT" + - name: Generate CTRF report run: | mkdir -p ctrf @@ -198,6 +220,20 @@ jobs: -d ctrf \ -f "$name.json" done + + - name: Run GFramework.Godot.Tests Diagnostics + 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 + 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 # 生成并发布测试报告,无论测试成功或失败都会执行 @@ -213,7 +249,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 @@ -227,4 +263,19 @@ 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 }} + 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 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/RichTextEffectPlanTests.cs b/GFramework.Godot.Tests/Text/RichTextEffectPlanTests.cs new file mode 100644 index 00000000..1bc3994b --- /dev/null +++ b/GFramework.Godot.Tests/Text/RichTextEffectPlanTests.cs @@ -0,0 +1,29 @@ +namespace GFramework.Godot.Tests.Text; + +/// +/// 的纯托管测试。 +/// +[TestFixture] +public sealed class RichTextEffectPlanTests +{ + /// + /// 验证默认内置计划会暴露完整的第一阶段效果键集合。 + /// + [Test] + public void CreateBuiltInDefault_Should_Contain_The_First_Phase_Effect_Keys() + { + var plan = RichTextEffectPlan.CreateBuiltInDefault(); + + Assert.That(plan.Effects.Select(static entry => entry.Key), Is.EqualTo(new[] + { + "green", + "red", + "gold", + "blue", + "fade_in", + "sine", + "jitter", + "fly_in" + })); + } +} diff --git a/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs b/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs new file mode 100644 index 00000000..93193ff2 --- /dev/null +++ b/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs @@ -0,0 +1,129 @@ +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 controller = new RichTextEffectsController( + host, + () => null, + () => true, + () => false); + + controller.RefreshEffects(); + + Assert.That(host.BbcodeEnabled, Is.True); + 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", + "gold", + "blue", + "fade_in", + "sine", + "jitter", + "fly_in" + })); + } + + /// + /// 验证关闭框架效果时不会触发新的效果安装,并会清空宿主的自定义效果集合。 + /// + [Test] + public void RefreshEffects_Should_Clear_CustomEffects_When_Framework_Effects_Are_Disabled() + { + var host = new FakeRichTextEffectHost + { + BbcodeEnabled = true + }; + host.SimulateInstalledEffects(); + var controller = new RichTextEffectsController( + host, + () => RichTextEffectPlan.CreateBuiltInDefault(), + () => false, + () => true); + + controller.RefreshEffects(); + + Assert.That(host.BbcodeEnabled, Is.True); + 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_Profile_From_Accessor() + { + var host = new FakeRichTextEffectHost(); + var firstProfile = new RichTextEffectPlan( + [ + new RichTextEffectPlanEntry("green") + ]); + var secondProfile = new RichTextEffectPlan( + [ + new RichTextEffectPlanEntry("gold") + ]); + RichTextEffectPlan? currentProfile = firstProfile; + + var controller = new RichTextEffectsController( + host, + () => currentProfile, + () => true, + () => true); + + controller.RefreshEffects(); + currentProfile = secondProfile; + controller.RefreshEffects(); + + 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 List CapturedProfiles { get; } = []; + + public List CapturedAnimatedEffectsEnabled { get; } = []; + + 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); + CustomEffectsInstalled = true; + } + + public void ClearCustomEffects() + { + ClearCustomEffectsCallCount++; + CustomEffectsInstalled = false; + } + + public void SimulateInstalledEffects() + { + CustomEffectsInstalled = true; + } + } +} diff --git a/GFramework.Godot.Tests/Text/RichTextMarkupTests.cs b/GFramework.Godot.Tests/Text/RichTextMarkupTests.cs new file mode 100644 index 00000000..127d783c --- /dev/null +++ b/GFramework.Godot.Tests/Text/RichTextMarkupTests.cs @@ -0,0 +1,65 @@ +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_Sort_Environment_Parameters_By_Key() + { + var env = new Dictionary + { + ["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 new file mode 100644 index 00000000..e4fd4780 --- /dev/null +++ b/GFramework.Godot/Text/DefaultRichTextEffectRegistry.cs @@ -0,0 +1,68 @@ +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..55b89560 --- /dev/null +++ b/GFramework.Godot/Text/Effects/RichTextEffectBase.cs @@ -0,0 +1,87 @@ +namespace GFramework.Godot.Text.Effects; + +/// +/// 富文本效果基类,提供统一的标签命名和环境参数读取辅助逻辑。 +/// 该基类只负责 Godot 适配细节,不承载业务语义分层。 +/// +[Tool] +public abstract partial class RichTextEffectBase : RichTextEffect +{ + /// + /// 获取当前效果对应的 BBCode 标签名。 + /// + protected abstract string TagName { get; } + + /// + /// 获取 Godot 识别当前效果所需的 `bbcode` 属性。 + /// 属性名使用小写是 Godot `RichTextEffect` 的约定,不是框架对公共成员命名的放宽。 + /// + public string bbcode => TagName; + + /// + /// 尝试从字符环境参数中读取布尔值。 + /// + /// 当前字符变换上下文。 + /// 参数键。 + /// 读取失败时使用的默认值。 + /// 最终布尔值;当环境参数不存在或类型不是 时返回默认值。 + protected bool GetBool(CharFXTransform transform, string key, bool defaultValue = false) + { + if (transform.Env.TryGetValue(Variant.From(key), out var value) && + value.VariantType == Variant.Type.Bool) + { + return value.AsBool(); + } + + return defaultValue; + } + + /// + /// 尝试从字符环境参数中读取浮点值。 + /// + /// 当前字符变换上下文。 + /// 参数键。 + /// 读取失败时使用的默认值。 + /// + /// 最终浮点值;当环境参数不存在,或类型既不是 也不是 + /// 时返回默认值。 + /// + protected float GetFloat(CharFXTransform transform, string key, float defaultValue) + { + if (transform.Env.TryGetValue(Variant.From(key), out var value) && + (value.VariantType == Variant.Type.Float || value.VariantType == Variant.Type.Int)) + { + 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..1c8a8c76 --- /dev/null +++ b/GFramework.Godot/Text/Effects/RichTextJitterEffect.cs @@ -0,0 +1,62 @@ +namespace GFramework.Godot.Text.Effects; + +/// +/// 为文本提供抖动效果。 +/// +/// +/// 默认注册表会在每次宿主刷新时为该效果创建独立实例。 +/// 内部会复用并修改 的 Seed,因此该类型假定 Godot 在主线程顺序 +/// 执行字符效果,不支持跨多个 共享同一实例,也不保证并发调用下的线程安全。 +/// +[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..3ee2d828 --- /dev/null +++ b/GFramework.Godot/Text/GfRichTextLabel.cs @@ -0,0 +1,106 @@ +namespace GFramework.Godot.Text; + +/// +/// GFramework 提供的组合式富文本标签宿主。 +/// 该类型只负责桥接 Godot 的 与框架的效果装配逻辑,不承载具体效果实现。 +/// +[GlobalClass] +[Tool] +public partial class GfRichTextLabel : RichTextLabel, IRichTextEffectHost +{ + private IRichTextEffectRegistry? _effectRegistry; + private RichTextEffectsController? _effectsController; + + /// + /// 获取或设置当前标签使用的效果配置。 + /// 为空时将回退到内置默认配置。 + /// + [Export] + public RichTextProfile? Profile { get; set; } + + /// + /// 获取或设置是否启用框架管理的富文本效果装配。 + /// 关闭后只会停止框架效果安装,不会覆盖调用方手动维护的其他 BBCode 解析状态。 + /// + [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)); + } + + /// + /// 根据控制器提供的配置参数在适配层实例化 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(); + } + + /// + /// 节点就绪时初始化控制器并安装效果集合。 + /// + public override void _Ready() + { + EnsureController().Initialize(); + } + + /// + /// 手动刷新框架效果集合。 + /// 当调用方在运行时替换配置或切换动画开关时,可通过该方法同步宿主状态。 + /// + public void RefreshFrameworkEffects() + { + EnsureController().RefreshEffects(); + } + + /// + /// 获取或创建控制器实例。 + /// + /// 组合式装配控制器。 + private RichTextEffectsController EnsureController() + { + return _effectsController ??= new RichTextEffectsController( + this, + () => Profile is null ? null : RichTextEffectPlan.FromProfile(Profile), + () => EnableFrameworkEffects, + () => AnimatedEffectsEnabled); + } +} diff --git a/GFramework.Godot/Text/IRichTextEffectHost.cs b/GFramework.Godot/Text/IRichTextEffectHost.cs new file mode 100644 index 00000000..b01ebe3f --- /dev/null +++ b/GFramework.Godot/Text/IRichTextEffectHost.cs @@ -0,0 +1,28 @@ +namespace GFramework.Godot.Text; + +/// +/// 抽象可被富文本效果控制器驱动的宿主。 +/// 该接口把装配决策从 Godot 原生 生命周期中解耦出来,便于在纯托管测试中验证开关、 +/// 配置回退和注册表替换行为。 +/// +internal interface IRichTextEffectHost +{ + /// + /// 获取或设置宿主是否启用 BBCode 解析。 + /// + bool BbcodeEnabled { get; set; } + + /// + /// 使用给定的配置和动画开关重建宿主上的自定义富文本效果。 + /// 纯托管控制器只负责组合刷新参数,适配层负责在真正需要时解析注册表、实例化 Godot 效果对象并写回宿主。 + /// + /// 需要安装的纯托管效果计划。 + /// 当前是否允许字符级动态效果生效。 + void ApplyEffects(RichTextEffectPlan profile, bool animatedEffectsEnabled); + + /// + /// 清空当前安装到宿主上的自定义富文本效果集合。 + /// 关闭框架效果时,控制器会通过该方法显式撤销之前安装的效果。 + /// + void ClearCustomEffects(); +} diff --git a/GFramework.Godot/Text/IRichTextEffectRegistry.cs b/GFramework.Godot/Text/IRichTextEffectRegistry.cs new file mode 100644 index 00000000..d43c0838 --- /dev/null +++ b/GFramework.Godot/Text/IRichTextEffectRegistry.cs @@ -0,0 +1,36 @@ +namespace GFramework.Godot.Text; + +/// +/// 富文本效果注册表,负责把配置中的效果键解析为可安装的 实例。 +/// +/// +/// 会在 就绪或显式刷新时调用该注册表,重建 +/// 。 +/// 该抽象存在的目的,是把“配置里声明了哪些效果”与“这些效果如何实例化”解耦,使宿主节点、配置资源和测试替身都不必 +/// 直接依赖具体内置效果类型。 +/// 当项目只需要组合现有标签时,应优先使用 ;当项目需要替换内置映射、注入自定义 +/// ,或按“动态效果是否启用”的边界切换效果实现时,应实现该接口。 +/// +public interface IRichTextEffectRegistry +{ + /// + /// 根据指定配置创建完整的效果实例集合。 + /// 该方法会在每次宿主刷新时执行,因此实现应保持可重复、确定且与宿主生命周期兼容。 + /// + /// 效果组合配置。 + /// 当前是否允许字符级动态效果生效。 + /// 可直接写入 的效果实例集合。 + /// + /// 当 且实现不接受空配置时抛出。 + /// + IReadOnlyList CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled); + + /// + /// 根据单个效果键创建对应效果实例。 + /// 该方法主要用于将配置声明的 key 映射为宿主可安装的单个效果对象。 + /// + /// 效果键。 + /// 当前是否允许字符级动态效果生效。 + /// 解析成功时返回效果实例;否则返回 + 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/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 new file mode 100644 index 00000000..29074953 --- /dev/null +++ b/GFramework.Godot/Text/RichTextEffectsController.cs @@ -0,0 +1,68 @@ +namespace GFramework.Godot.Text; + +/// +/// 负责把纯托管效果计划和开关装配为宿主标签的实际效果集合。 +/// 该控制器是组合式扩展的装配中心,使 保持轻量。 +/// +internal sealed class RichTextEffectsController +{ + private readonly Func _animatedEffectsEnabledAccessor; + private readonly Func _frameworkEffectsEnabledAccessor; + private readonly IRichTextEffectHost _host; + private readonly Func _profileAccessor; + + /// + /// 初始化控制器实例。 + /// + /// 目标富文本标签。 + /// 当前纯托管效果计划访问器。 + /// 框架效果总开关访问器。 + /// 字符动画开关访问器。 + /// + /// 当 、 + /// + /// 为 时抛出。 + /// + public RichTextEffectsController( + IRichTextEffectHost host, + Func profileAccessor, + Func frameworkEffectsEnabledAccessor, + Func animatedEffectsEnabledAccessor) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _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() + { + var frameworkEffectsEnabled = _frameworkEffectsEnabledAccessor(); + if (frameworkEffectsEnabled && !_host.BbcodeEnabled) + { + _host.BbcodeEnabled = true; + } + + if (!frameworkEffectsEnabled) + { + _host.ClearCustomEffects(); + return; + } + + var profile = _profileAccessor() ?? RichTextEffectPlan.CreateBuiltInDefault(); + _host.ApplyEffects(profile, _animatedEffectsEnabledAccessor()); + } +} diff --git a/GFramework.Godot/Text/RichTextMarkup.cs b/GFramework.Godot/Text/RichTextMarkup.cs new file mode 100644 index 00000000..685e8408 --- /dev/null +++ b/GFramework.Godot/Text/RichTextMarkup.cs @@ -0,0 +1,182 @@ +using System.Globalization; +using System.Text; + +namespace GFramework.Godot.Text; + +/// +/// 提供语义化的富文本标签构建辅助方法。 +/// 该工具层用于减少业务代码直接手写原始 BBCode 字符串的重复工作。 +/// +public static class RichTextMarkup +{ + /// + /// 使用指定标签包裹文本。 + /// + /// 原始文本。 + /// 标签名。 + /// 包裹后的 BBCode 文本。 + /// + /// 当 为空、仅包含空白字符,或包含 BBCode token 不允许的控制字符时抛出。 + /// + 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 文本。 + /// + /// 当 为空、仅包含空白字符,包含 BBCode token 不允许的控制字符, + /// 或 中存在包含非法控制字符的参数键时抛出。 + /// + public static string Effect(string text, string tag, IReadOnlyDictionary? env = null) + { + ValidateToken(tag, nameof(tag)); + + var builder = new StringBuilder(); + builder.Append('['); + builder.Append(tag); + + if (env is not null) + { + foreach (var pair in CollectEnvironmentPairs(env)) + { + 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) + { + 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 字符串表示。 + /// + /// 待格式化的值。 + /// 适用于 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..9759787e --- /dev/null +++ b/GFramework.Godot/Text/RichTextProfile.cs @@ -0,0 +1,50 @@ +namespace GFramework.Godot.Text; + +/// +/// 描述一个富文本效果组合配置。 +/// 该资源是 Godot 编辑器与场景系统使用的配置载体;运行时控制器会先把它转换为 +/// ,再在纯托管边界内完成刷新决策。 +/// +[GlobalClass] +public partial class RichTextProfile : Resource +{ + /// + /// 获取或设置当前配置启用的效果条目集合。 + /// + [Export] + public RichTextEffectEntry[] Effects { get; set; } = []; + + /// + /// 创建包含全部内置效果的默认配置。 + /// 该方法为第一阶段提供零配置可用的回退组合。 + /// + /// 包含全部内置效果键的默认配置。 + 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 = plan.Effects + .Select(static entry => new RichTextEffectEntry + { + Key = entry.Key, + Enabled = entry.Enabled + }) + .ToArray(); + return profile; + } +}