mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-08 01:24:31 +08:00
Merge pull request #251 from GeWuYou/feat/rich-text-effects-and-color-markers
feat(text): 添加富文本效果系统和颜色标记功能
This commit is contained in:
commit
22f271e709
83
.github/workflows/ci.yml
vendored
83
.github/workflows/ci.yml
vendored
@ -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<<EOF"
|
||||
if [ "${#failed_projects[@]}" -gt 0 ]; then
|
||||
printf '%s\n' "${failed_projects[@]}"
|
||||
fi
|
||||
echo "EOF"
|
||||
echo "failed_log_paths<<EOF"
|
||||
if [ "${#failed_log_paths[@]}" -gt 0 ]; then
|
||||
printf '%s\n' "${failed_log_paths[@]}"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$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
|
||||
|
||||
@ -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;
|
||||
|
||||
29
GFramework.Godot.Tests/Text/RichTextEffectPlanTests.cs
Normal file
29
GFramework.Godot.Tests/Text/RichTextEffectPlanTests.cs
Normal file
@ -0,0 +1,29 @@
|
||||
namespace GFramework.Godot.Tests.Text;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="RichTextEffectPlan" /> 的纯托管测试。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class RichTextEffectPlanTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证默认内置计划会暴露完整的第一阶段效果键集合。
|
||||
/// </summary>
|
||||
[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"
|
||||
}));
|
||||
}
|
||||
}
|
||||
129
GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
Normal file
129
GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
Normal file
@ -0,0 +1,129 @@
|
||||
namespace GFramework.Godot.Tests.Text;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="RichTextEffectsController" /> 的纯托管行为测试。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class RichTextEffectsControllerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证启用框架效果时会开启宿主 BBCode,并在 Profile 为空时回退到内置默认配置。
|
||||
/// </summary>
|
||||
[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"
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证关闭框架效果时不会触发新的效果安装,并会清空宿主的自定义效果集合。
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证控制器会在每次刷新时读取最新的配置访问器结果,避免缓存旧配置。
|
||||
/// </summary>
|
||||
[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<RichTextEffectPlan> CapturedProfiles { get; } = [];
|
||||
|
||||
public List<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
65
GFramework.Godot.Tests/Text/RichTextMarkupTests.cs
Normal file
65
GFramework.Godot.Tests/Text/RichTextMarkupTests.cs
Normal file
@ -0,0 +1,65 @@
|
||||
using GFramework.Godot.Text;
|
||||
|
||||
namespace GFramework.Godot.Tests.Text;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="RichTextMarkup" /> 的测试。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class RichTextMarkupTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证颜色快捷方法会输出预期标签。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Green_Should_Wrap_Text_With_Green_Tag()
|
||||
{
|
||||
var result = RichTextMarkup.Green("Ready");
|
||||
|
||||
Assert.That(result, Is.EqualTo("[green]Ready[/green]"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证效果方法会按稳定顺序拼接环境参数。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Effect_Should_Sort_Environment_Parameters_By_Key()
|
||||
{
|
||||
var env = new Dictionary<string, object?>
|
||||
{
|
||||
["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]"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证非法标签 token 会被拒绝,避免生成损坏的 BBCode。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Effect_Should_Reject_Invalid_Tag_Tokens()
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentException>(() => RichTextMarkup.Effect("Hello", "fade=in"));
|
||||
|
||||
Assert.That(exception!.ParamName, Is.EqualTo("tag"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证非法环境参数键会被拒绝,避免注入无效的 BBCode token。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Effect_Should_Reject_Invalid_Environment_Key_Tokens()
|
||||
{
|
||||
var env = new Dictionary<string, object?>
|
||||
{
|
||||
["bad key"] = 1
|
||||
};
|
||||
|
||||
var exception = Assert.Throws<ArgumentException>(() => RichTextMarkup.Effect("Hello", "fade_in", env));
|
||||
|
||||
Assert.That(exception!.ParamName, Is.EqualTo("env"));
|
||||
}
|
||||
}
|
||||
68
GFramework.Godot/Text/DefaultRichTextEffectRegistry.cs
Normal file
68
GFramework.Godot/Text/DefaultRichTextEffectRegistry.cs
Normal file
@ -0,0 +1,68 @@
|
||||
using GFramework.Godot.Text.Effects;
|
||||
|
||||
namespace GFramework.Godot.Text;
|
||||
|
||||
/// <summary>
|
||||
/// 默认的富文本效果注册表。
|
||||
/// 该实现仅负责内置效果键的解析,不处理业务层文本构建或配置持久化。
|
||||
/// </summary>
|
||||
public sealed class DefaultRichTextEffectRegistry : IRichTextEffectRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建当前配置对应的全部效果实例。
|
||||
/// </summary>
|
||||
/// <param name="profile">效果组合配置。</param>
|
||||
/// <param name="animatedEffectsEnabled">当前是否允许字符级动态效果生效。</param>
|
||||
/// <returns>内置效果实例集合。</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// 当 <paramref name="profile" /> 为 <see langword="null" /> 时抛出。
|
||||
/// </exception>
|
||||
public IReadOnlyList<RichTextEffect> CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var effects = new List<RichTextEffect>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据效果键创建单个效果实例。
|
||||
/// </summary>
|
||||
/// <param name="key">效果键。</param>
|
||||
/// <param name="animatedEffectsEnabled">当前是否允许字符级动态效果生效。</param>
|
||||
/// <returns>解析成功时返回效果实例;否则返回 <see langword="null" />。</returns>
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
27
GFramework.Godot/Text/Effects/RichTextBlueEffect.cs
Normal file
27
GFramework.Godot/Text/Effects/RichTextBlueEffect.cs
Normal file
@ -0,0 +1,27 @@
|
||||
namespace GFramework.Godot.Text.Effects;
|
||||
|
||||
/// <summary>
|
||||
/// 为文本应用蓝色语义高亮。
|
||||
/// </summary>
|
||||
[GlobalClass]
|
||||
[Tool]
|
||||
public partial class RichTextBlueEffect : RichTextEffectBase
|
||||
{
|
||||
private static readonly Color BlueColor = new(0.44f, 0.72f, 0.98f, 1.0f);
|
||||
|
||||
/// <summary>
|
||||
/// 获取标签名。
|
||||
/// </summary>
|
||||
protected override string TagName => "blue";
|
||||
|
||||
/// <summary>
|
||||
/// 应用蓝色颜色效果。
|
||||
/// </summary>
|
||||
/// <param name="charFx">当前字符上下文。</param>
|
||||
/// <returns>始终返回 <see langword="true" />。</returns>
|
||||
public override bool _ProcessCustomFX(CharFXTransform charFx)
|
||||
{
|
||||
charFx.Color = BlueColor;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
87
GFramework.Godot/Text/Effects/RichTextEffectBase.cs
Normal file
87
GFramework.Godot/Text/Effects/RichTextEffectBase.cs
Normal file
@ -0,0 +1,87 @@
|
||||
namespace GFramework.Godot.Text.Effects;
|
||||
|
||||
/// <summary>
|
||||
/// 富文本效果基类,提供统一的标签命名和环境参数读取辅助逻辑。
|
||||
/// 该基类只负责 Godot 适配细节,不承载业务语义分层。
|
||||
/// </summary>
|
||||
[Tool]
|
||||
public abstract partial class RichTextEffectBase : RichTextEffect
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取当前效果对应的 BBCode 标签名。
|
||||
/// </summary>
|
||||
protected abstract string TagName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取 Godot 识别当前效果所需的 `bbcode` 属性。
|
||||
/// 属性名使用小写是 Godot `RichTextEffect` 的约定,不是框架对公共成员命名的放宽。
|
||||
/// </summary>
|
||||
public string bbcode => TagName;
|
||||
|
||||
/// <summary>
|
||||
/// 尝试从字符环境参数中读取布尔值。
|
||||
/// </summary>
|
||||
/// <param name="transform">当前字符变换上下文。</param>
|
||||
/// <param name="key">参数键。</param>
|
||||
/// <param name="defaultValue">读取失败时使用的默认值。</param>
|
||||
/// <returns>最终布尔值;当环境参数不存在或类型不是 <see cref="Variant.Type.Bool" /> 时返回默认值。</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试从字符环境参数中读取浮点值。
|
||||
/// </summary>
|
||||
/// <param name="transform">当前字符变换上下文。</param>
|
||||
/// <param name="key">参数键。</param>
|
||||
/// <param name="defaultValue">读取失败时使用的默认值。</param>
|
||||
/// <returns>
|
||||
/// 最终浮点值;当环境参数不存在,或类型既不是 <see cref="Variant.Type.Float" /> 也不是
|
||||
/// <see cref="Variant.Type.Int" /> 时返回默认值。
|
||||
/// </returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试从字符环境参数中读取颜色值。
|
||||
/// </summary>
|
||||
/// <param name="transform">当前字符变换上下文。</param>
|
||||
/// <param name="key">参数键。</param>
|
||||
/// <param name="defaultValue">读取失败时使用的默认值。</param>
|
||||
/// <returns>最终颜色值。</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从字符环境参数中应用可见性开关。
|
||||
/// </summary>
|
||||
/// <param name="transform">当前字符变换上下文。</param>
|
||||
/// <param name="defaultValue">默认可见性。</param>
|
||||
protected void ApplyVisibility(CharFXTransform transform, bool defaultValue = true)
|
||||
{
|
||||
transform.Visible = GetBool(transform, "visible", defaultValue);
|
||||
}
|
||||
}
|
||||
47
GFramework.Godot/Text/Effects/RichTextFadeInEffect.cs
Normal file
47
GFramework.Godot/Text/Effects/RichTextFadeInEffect.cs
Normal file
@ -0,0 +1,47 @@
|
||||
namespace GFramework.Godot.Text.Effects;
|
||||
|
||||
/// <summary>
|
||||
/// 为文本提供逐字符淡入效果。
|
||||
/// </summary>
|
||||
[GlobalClass]
|
||||
[Tool]
|
||||
public partial class RichTextFadeInEffect : RichTextEffectBase
|
||||
{
|
||||
private readonly bool _animatedEffectsEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化淡入效果。
|
||||
/// </summary>
|
||||
/// <param name="animatedEffectsEnabled">是否允许动态效果实际生效。</param>
|
||||
public RichTextFadeInEffect(bool animatedEffectsEnabled = true)
|
||||
{
|
||||
_animatedEffectsEnabled = animatedEffectsEnabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取标签名。
|
||||
/// </summary>
|
||||
protected override string TagName => "fade_in";
|
||||
|
||||
/// <summary>
|
||||
/// 应用淡入动画。
|
||||
/// </summary>
|
||||
/// <param name="charFx">当前字符上下文。</param>
|
||||
/// <returns>始终返回 <see langword="true" />。</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
64
GFramework.Godot/Text/Effects/RichTextFlyInEffect.cs
Normal file
64
GFramework.Godot/Text/Effects/RichTextFlyInEffect.cs
Normal file
@ -0,0 +1,64 @@
|
||||
namespace GFramework.Godot.Text.Effects;
|
||||
|
||||
/// <summary>
|
||||
/// 为文本提供逐字符飞入效果。
|
||||
/// </summary>
|
||||
[GlobalClass]
|
||||
[Tool]
|
||||
public partial class RichTextFlyInEffect : RichTextEffectBase
|
||||
{
|
||||
private readonly bool _animatedEffectsEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化飞入效果。
|
||||
/// </summary>
|
||||
/// <param name="animatedEffectsEnabled">是否允许动态效果实际生效。</param>
|
||||
public RichTextFlyInEffect(bool animatedEffectsEnabled = true)
|
||||
{
|
||||
_animatedEffectsEnabled = animatedEffectsEnabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取标签名。
|
||||
/// </summary>
|
||||
protected override string TagName => "fly_in";
|
||||
|
||||
/// <summary>
|
||||
/// 应用飞入动画。
|
||||
/// </summary>
|
||||
/// <param name="charFx">当前字符上下文。</param>
|
||||
/// <returns>始终返回 <see langword="true" />。</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算二次缓出值。
|
||||
/// </summary>
|
||||
/// <param name="value">归一化进度。</param>
|
||||
/// <returns>缓出后的进度。</returns>
|
||||
private static float EaseOutQuad(float value)
|
||||
{
|
||||
return 1f - (1f - value) * (1f - value);
|
||||
}
|
||||
}
|
||||
27
GFramework.Godot/Text/Effects/RichTextGoldEffect.cs
Normal file
27
GFramework.Godot/Text/Effects/RichTextGoldEffect.cs
Normal file
@ -0,0 +1,27 @@
|
||||
namespace GFramework.Godot.Text.Effects;
|
||||
|
||||
/// <summary>
|
||||
/// 为文本应用金色语义高亮。
|
||||
/// </summary>
|
||||
[GlobalClass]
|
||||
[Tool]
|
||||
public partial class RichTextGoldEffect : RichTextEffectBase
|
||||
{
|
||||
private static readonly Color GoldColor = new(0.96f, 0.79f, 0.34f, 1.0f);
|
||||
|
||||
/// <summary>
|
||||
/// 获取标签名。
|
||||
/// </summary>
|
||||
protected override string TagName => "gold";
|
||||
|
||||
/// <summary>
|
||||
/// 应用金色颜色效果。
|
||||
/// </summary>
|
||||
/// <param name="charFx">当前字符上下文。</param>
|
||||
/// <returns>始终返回 <see langword="true" />。</returns>
|
||||
public override bool _ProcessCustomFX(CharFXTransform charFx)
|
||||
{
|
||||
charFx.Color = GoldColor;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
27
GFramework.Godot/Text/Effects/RichTextGreenEffect.cs
Normal file
27
GFramework.Godot/Text/Effects/RichTextGreenEffect.cs
Normal file
@ -0,0 +1,27 @@
|
||||
namespace GFramework.Godot.Text.Effects;
|
||||
|
||||
/// <summary>
|
||||
/// 为文本应用绿色语义高亮。
|
||||
/// </summary>
|
||||
[GlobalClass]
|
||||
[Tool]
|
||||
public partial class RichTextGreenEffect : RichTextEffectBase
|
||||
{
|
||||
private static readonly Color GreenColor = new(0.46f, 0.91f, 0.49f, 1.0f);
|
||||
|
||||
/// <summary>
|
||||
/// 获取标签名。
|
||||
/// </summary>
|
||||
protected override string TagName => "green";
|
||||
|
||||
/// <summary>
|
||||
/// 应用绿色颜色效果。
|
||||
/// </summary>
|
||||
/// <param name="charFx">当前字符上下文。</param>
|
||||
/// <returns>始终返回 <see langword="true" />。</returns>
|
||||
public override bool _ProcessCustomFX(CharFXTransform charFx)
|
||||
{
|
||||
charFx.Color = GreenColor;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
62
GFramework.Godot/Text/Effects/RichTextJitterEffect.cs
Normal file
62
GFramework.Godot/Text/Effects/RichTextJitterEffect.cs
Normal file
@ -0,0 +1,62 @@
|
||||
namespace GFramework.Godot.Text.Effects;
|
||||
|
||||
/// <summary>
|
||||
/// 为文本提供抖动效果。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 默认注册表会在每次宿主刷新时为该效果创建独立实例。
|
||||
/// <see cref="_ProcessCustomFX" /> 内部会复用并修改 <see cref="_noise" /> 的 Seed,因此该类型假定 Godot 在主线程顺序
|
||||
/// 执行字符效果,不支持跨多个 <see cref="RichTextLabel" /> 共享同一实例,也不保证并发调用下的线程安全。
|
||||
/// </remarks>
|
||||
[GlobalClass]
|
||||
[Tool]
|
||||
public partial class RichTextJitterEffect : RichTextEffectBase
|
||||
{
|
||||
private readonly bool _animatedEffectsEnabled;
|
||||
private readonly FastNoiseLite _noise;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化抖动效果。
|
||||
/// </summary>
|
||||
/// <param name="animatedEffectsEnabled">是否允许动态效果实际生效。</param>
|
||||
public RichTextJitterEffect(bool animatedEffectsEnabled = true)
|
||||
{
|
||||
_animatedEffectsEnabled = animatedEffectsEnabled;
|
||||
_noise = new FastNoiseLite
|
||||
{
|
||||
NoiseType = FastNoiseLite.NoiseTypeEnum.Perlin,
|
||||
FractalOctaves = 8,
|
||||
FractalGain = 0.8f
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取标签名。
|
||||
/// </summary>
|
||||
protected override string TagName => "jitter";
|
||||
|
||||
/// <summary>
|
||||
/// 应用抖动位移。
|
||||
/// </summary>
|
||||
/// <param name="charFx">当前字符上下文。</param>
|
||||
/// <returns>始终返回 <see langword="true" />。</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
27
GFramework.Godot/Text/Effects/RichTextRedEffect.cs
Normal file
27
GFramework.Godot/Text/Effects/RichTextRedEffect.cs
Normal file
@ -0,0 +1,27 @@
|
||||
namespace GFramework.Godot.Text.Effects;
|
||||
|
||||
/// <summary>
|
||||
/// 为文本应用红色语义高亮。
|
||||
/// </summary>
|
||||
[GlobalClass]
|
||||
[Tool]
|
||||
public partial class RichTextRedEffect : RichTextEffectBase
|
||||
{
|
||||
private static readonly Color RedColor = new(0.96f, 0.35f, 0.35f, 1.0f);
|
||||
|
||||
/// <summary>
|
||||
/// 获取标签名。
|
||||
/// </summary>
|
||||
protected override string TagName => "red";
|
||||
|
||||
/// <summary>
|
||||
/// 应用红色颜色效果。
|
||||
/// </summary>
|
||||
/// <param name="charFx">当前字符上下文。</param>
|
||||
/// <returns>始终返回 <see langword="true" />。</returns>
|
||||
public override bool _ProcessCustomFX(CharFXTransform charFx)
|
||||
{
|
||||
charFx.Color = RedColor;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
47
GFramework.Godot/Text/Effects/RichTextSineEffect.cs
Normal file
47
GFramework.Godot/Text/Effects/RichTextSineEffect.cs
Normal file
@ -0,0 +1,47 @@
|
||||
namespace GFramework.Godot.Text.Effects;
|
||||
|
||||
/// <summary>
|
||||
/// 为文本提供正弦波形的上下漂浮效果。
|
||||
/// </summary>
|
||||
[GlobalClass]
|
||||
[Tool]
|
||||
public partial class RichTextSineEffect : RichTextEffectBase
|
||||
{
|
||||
private readonly bool _animatedEffectsEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化正弦效果。
|
||||
/// </summary>
|
||||
/// <param name="animatedEffectsEnabled">是否允许动态效果实际生效。</param>
|
||||
public RichTextSineEffect(bool animatedEffectsEnabled = true)
|
||||
{
|
||||
_animatedEffectsEnabled = animatedEffectsEnabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取标签名。
|
||||
/// </summary>
|
||||
protected override string TagName => "sine";
|
||||
|
||||
/// <summary>
|
||||
/// 应用正弦位移。
|
||||
/// </summary>
|
||||
/// <param name="charFx">当前字符上下文。</param>
|
||||
/// <returns>始终返回 <see langword="true" />。</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
106
GFramework.Godot/Text/GfRichTextLabel.cs
Normal file
106
GFramework.Godot/Text/GfRichTextLabel.cs
Normal file
@ -0,0 +1,106 @@
|
||||
namespace GFramework.Godot.Text;
|
||||
|
||||
/// <summary>
|
||||
/// GFramework 提供的组合式富文本标签宿主。
|
||||
/// 该类型只负责桥接 Godot 的 <see cref="RichTextLabel" /> 与框架的效果装配逻辑,不承载具体效果实现。
|
||||
/// </summary>
|
||||
[GlobalClass]
|
||||
[Tool]
|
||||
public partial class GfRichTextLabel : RichTextLabel, IRichTextEffectHost
|
||||
{
|
||||
private IRichTextEffectRegistry? _effectRegistry;
|
||||
private RichTextEffectsController? _effectsController;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置当前标签使用的效果配置。
|
||||
/// 为空时将回退到内置默认配置。
|
||||
/// </summary>
|
||||
[Export]
|
||||
public RichTextProfile? Profile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置是否启用框架管理的富文本效果装配。
|
||||
/// 关闭后只会停止框架效果安装,不会覆盖调用方手动维护的其他 BBCode 解析状态。
|
||||
/// </summary>
|
||||
[Export]
|
||||
public bool EnableFrameworkEffects { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置是否允许字符级动态效果实际生效。
|
||||
/// 关闭后仍然会安装对应标签,使富文本内容保持可解析。
|
||||
/// </summary>
|
||||
[Export]
|
||||
public bool AnimatedEffectsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前使用的效果注册表。
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// 当设置值为 <see langword="null" /> 时抛出。
|
||||
/// </exception>
|
||||
internal IRichTextEffectRegistry EffectRegistry
|
||||
{
|
||||
get => _effectRegistry ??= new DefaultRichTextEffectRegistry();
|
||||
set => _effectRegistry = value ?? throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据控制器提供的配置参数在适配层实例化 Godot 原生效果,并写回标签宿主。
|
||||
/// 这样控制器与测试替身不需要直接触碰 <see cref="RichTextEffect" /> 或
|
||||
/// <see cref="global::Godot.Collections.Array" />,而真正依赖 Godot runtime 的工作只发生在节点边界上。
|
||||
/// </summary>
|
||||
/// <param name="profile">需要安装的纯托管效果计划。</param>
|
||||
/// <param name="animatedEffectsEnabled">当前是否允许字符级动态效果生效。</param>
|
||||
void IRichTextEffectHost.ApplyEffects(RichTextEffectPlan profile, bool animatedEffectsEnabled)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var registry = EffectRegistry;
|
||||
var effects = registry.CreateEffects(RichTextProfile.FromPlan(profile), animatedEffectsEnabled);
|
||||
var customEffects = new global::Godot.Collections.Array();
|
||||
foreach (var effect in effects)
|
||||
{
|
||||
customEffects.Add(effect);
|
||||
}
|
||||
|
||||
CustomEffects = customEffects;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清空标签当前持有的自定义效果集合。
|
||||
/// </summary>
|
||||
void IRichTextEffectHost.ClearCustomEffects()
|
||||
{
|
||||
CustomEffects = new global::Godot.Collections.Array();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节点就绪时初始化控制器并安装效果集合。
|
||||
/// </summary>
|
||||
public override void _Ready()
|
||||
{
|
||||
EnsureController().Initialize();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动刷新框架效果集合。
|
||||
/// 当调用方在运行时替换配置或切换动画开关时,可通过该方法同步宿主状态。
|
||||
/// </summary>
|
||||
public void RefreshFrameworkEffects()
|
||||
{
|
||||
EnsureController().RefreshEffects();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取或创建控制器实例。
|
||||
/// </summary>
|
||||
/// <returns>组合式装配控制器。</returns>
|
||||
private RichTextEffectsController EnsureController()
|
||||
{
|
||||
return _effectsController ??= new RichTextEffectsController(
|
||||
this,
|
||||
() => Profile is null ? null : RichTextEffectPlan.FromProfile(Profile),
|
||||
() => EnableFrameworkEffects,
|
||||
() => AnimatedEffectsEnabled);
|
||||
}
|
||||
}
|
||||
28
GFramework.Godot/Text/IRichTextEffectHost.cs
Normal file
28
GFramework.Godot/Text/IRichTextEffectHost.cs
Normal file
@ -0,0 +1,28 @@
|
||||
namespace GFramework.Godot.Text;
|
||||
|
||||
/// <summary>
|
||||
/// 抽象可被富文本效果控制器驱动的宿主。
|
||||
/// 该接口把装配决策从 Godot 原生 <see cref="RichTextLabel" /> 生命周期中解耦出来,便于在纯托管测试中验证开关、
|
||||
/// 配置回退和注册表替换行为。
|
||||
/// </summary>
|
||||
internal interface IRichTextEffectHost
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置宿主是否启用 BBCode 解析。
|
||||
/// </summary>
|
||||
bool BbcodeEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用给定的配置和动画开关重建宿主上的自定义富文本效果。
|
||||
/// 纯托管控制器只负责组合刷新参数,适配层负责在真正需要时解析注册表、实例化 Godot 效果对象并写回宿主。
|
||||
/// </summary>
|
||||
/// <param name="profile">需要安装的纯托管效果计划。</param>
|
||||
/// <param name="animatedEffectsEnabled">当前是否允许字符级动态效果生效。</param>
|
||||
void ApplyEffects(RichTextEffectPlan profile, bool animatedEffectsEnabled);
|
||||
|
||||
/// <summary>
|
||||
/// 清空当前安装到宿主上的自定义富文本效果集合。
|
||||
/// 关闭框架效果时,控制器会通过该方法显式撤销之前安装的效果。
|
||||
/// </summary>
|
||||
void ClearCustomEffects();
|
||||
}
|
||||
36
GFramework.Godot/Text/IRichTextEffectRegistry.cs
Normal file
36
GFramework.Godot/Text/IRichTextEffectRegistry.cs
Normal file
@ -0,0 +1,36 @@
|
||||
namespace GFramework.Godot.Text;
|
||||
|
||||
/// <summary>
|
||||
/// 富文本效果注册表,负责把配置中的效果键解析为可安装的 <see cref="RichTextEffect" /> 实例。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="RichTextEffectsController" /> 会在 <see cref="GfRichTextLabel" /> 就绪或显式刷新时调用该注册表,重建
|
||||
/// <see cref="RichTextLabel.CustomEffects" />。
|
||||
/// 该抽象存在的目的,是把“配置里声明了哪些效果”与“这些效果如何实例化”解耦,使宿主节点、配置资源和测试替身都不必
|
||||
/// 直接依赖具体内置效果类型。
|
||||
/// 当项目只需要组合现有标签时,应优先使用 <see cref="RichTextProfile" />;当项目需要替换内置映射、注入自定义
|
||||
/// <see cref="RichTextEffect" />,或按“动态效果是否启用”的边界切换效果实现时,应实现该接口。
|
||||
/// </remarks>
|
||||
public interface IRichTextEffectRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据指定配置创建完整的效果实例集合。
|
||||
/// 该方法会在每次宿主刷新时执行,因此实现应保持可重复、确定且与宿主生命周期兼容。
|
||||
/// </summary>
|
||||
/// <param name="profile">效果组合配置。</param>
|
||||
/// <param name="animatedEffectsEnabled">当前是否允许字符级动态效果生效。</param>
|
||||
/// <returns>可直接写入 <see cref="RichTextLabel.CustomEffects" /> 的效果实例集合。</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// 当 <paramref name="profile" /> 为 <see langword="null" /> 且实现不接受空配置时抛出。
|
||||
/// </exception>
|
||||
IReadOnlyList<RichTextEffect> CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled);
|
||||
|
||||
/// <summary>
|
||||
/// 根据单个效果键创建对应效果实例。
|
||||
/// 该方法主要用于将配置声明的 key 映射为宿主可安装的单个效果对象。
|
||||
/// </summary>
|
||||
/// <param name="key">效果键。</param>
|
||||
/// <param name="animatedEffectsEnabled">当前是否允许字符级动态效果生效。</param>
|
||||
/// <returns>解析成功时返回效果实例;否则返回 <see langword="null" />。</returns>
|
||||
RichTextEffect? CreateEffect(string key, bool animatedEffectsEnabled);
|
||||
}
|
||||
22
GFramework.Godot/Text/RichTextEffectEntry.cs
Normal file
22
GFramework.Godot/Text/RichTextEffectEntry.cs
Normal file
@ -0,0 +1,22 @@
|
||||
namespace GFramework.Godot.Text;
|
||||
|
||||
/// <summary>
|
||||
/// 描述一条富文本效果配置项。
|
||||
/// 该资源只负责声明需要启用的效果键与开关状态,不承担实例创建逻辑。
|
||||
/// </summary>
|
||||
[GlobalClass]
|
||||
public partial class RichTextEffectEntry : Resource
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置效果键。
|
||||
/// 键值由 <see cref="IRichTextEffectRegistry" /> 解析为具体的 <see cref="RichTextEffect" /> 实例。
|
||||
/// </summary>
|
||||
[Export]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置该配置项是否启用。
|
||||
/// </summary>
|
||||
[Export]
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
70
GFramework.Godot/Text/RichTextEffectPlan.cs
Normal file
70
GFramework.Godot/Text/RichTextEffectPlan.cs
Normal file
@ -0,0 +1,70 @@
|
||||
namespace GFramework.Godot.Text;
|
||||
|
||||
/// <summary>
|
||||
/// 描述一次富文本效果安装所需的纯托管计划。
|
||||
/// 该类型用于把控制器与测试替身隔离在 Godot runtime 之外,使刷新决策可以在普通 .NET 测试进程中验证。
|
||||
/// </summary>
|
||||
internal sealed class RichTextEffectPlan
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一个富文本效果计划。
|
||||
/// </summary>
|
||||
/// <param name="effects">计划中声明的效果条目集合。</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// 当 <paramref name="effects" /> 为 <see langword="null" /> 时抛出。
|
||||
/// </exception>
|
||||
public RichTextEffectPlan(IReadOnlyList<RichTextEffectPlanEntry> effects)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(effects);
|
||||
|
||||
Effects = effects.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前计划启用的效果条目集合。
|
||||
/// </summary>
|
||||
public IReadOnlyList<RichTextEffectPlanEntry> Effects { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 从 Godot 资源配置转换为纯托管计划。
|
||||
/// </summary>
|
||||
/// <param name="profile">待转换的资源配置。</param>
|
||||
/// <returns>与资源配置等价的纯托管计划。</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// 当 <paramref name="profile" /> 为 <see langword="null" /> 时抛出。
|
||||
/// </exception>
|
||||
public static RichTextEffectPlan FromProfile(RichTextProfile profile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var effects = new RichTextEffectPlanEntry[profile.Effects.Length];
|
||||
for (var index = 0; index < profile.Effects.Length; index++)
|
||||
{
|
||||
var entry = profile.Effects[index];
|
||||
effects[index] = entry is null
|
||||
? default
|
||||
: new RichTextEffectPlanEntry(entry.Key, entry.Enabled);
|
||||
}
|
||||
|
||||
return new RichTextEffectPlan(effects);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建包含全部内置效果的默认计划。
|
||||
/// </summary>
|
||||
/// <returns>包含第一阶段全部内置效果键的默认计划。</returns>
|
||||
public static RichTextEffectPlan CreateBuiltInDefault()
|
||||
{
|
||||
return new RichTextEffectPlan(
|
||||
[
|
||||
new RichTextEffectPlanEntry("green"),
|
||||
new RichTextEffectPlanEntry("red"),
|
||||
new RichTextEffectPlanEntry("gold"),
|
||||
new RichTextEffectPlanEntry("blue"),
|
||||
new RichTextEffectPlanEntry("fade_in"),
|
||||
new RichTextEffectPlanEntry("sine"),
|
||||
new RichTextEffectPlanEntry("jitter"),
|
||||
new RichTextEffectPlanEntry("fly_in")
|
||||
]);
|
||||
}
|
||||
}
|
||||
9
GFramework.Godot/Text/RichTextEffectPlanEntry.cs
Normal file
9
GFramework.Godot/Text/RichTextEffectPlanEntry.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace GFramework.Godot.Text;
|
||||
|
||||
/// <summary>
|
||||
/// 描述一条纯托管的富文本效果计划项。
|
||||
/// 控制器与测试替身只关心效果键和启用状态,不需要依赖 Godot 资源对象本身。
|
||||
/// </summary>
|
||||
/// <param name="Key">效果键。</param>
|
||||
/// <param name="Enabled">该效果项是否启用。</param>
|
||||
internal readonly record struct RichTextEffectPlanEntry(string Key, bool Enabled = true);
|
||||
68
GFramework.Godot/Text/RichTextEffectsController.cs
Normal file
68
GFramework.Godot/Text/RichTextEffectsController.cs
Normal file
@ -0,0 +1,68 @@
|
||||
namespace GFramework.Godot.Text;
|
||||
|
||||
/// <summary>
|
||||
/// 负责把纯托管效果计划和开关装配为宿主标签的实际效果集合。
|
||||
/// 该控制器是组合式扩展的装配中心,使 <see cref="GfRichTextLabel" /> 保持轻量。
|
||||
/// </summary>
|
||||
internal sealed class RichTextEffectsController
|
||||
{
|
||||
private readonly Func<bool> _animatedEffectsEnabledAccessor;
|
||||
private readonly Func<bool> _frameworkEffectsEnabledAccessor;
|
||||
private readonly IRichTextEffectHost _host;
|
||||
private readonly Func<RichTextEffectPlan?> _profileAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化控制器实例。
|
||||
/// </summary>
|
||||
/// <param name="host">目标富文本标签。</param>
|
||||
/// <param name="profileAccessor">当前纯托管效果计划访问器。</param>
|
||||
/// <param name="frameworkEffectsEnabledAccessor">框架效果总开关访问器。</param>
|
||||
/// <param name="animatedEffectsEnabledAccessor">字符动画开关访问器。</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// 当 <paramref name="host" />、<paramref name="profileAccessor" />、
|
||||
/// <paramref name="frameworkEffectsEnabledAccessor" /> 或 <paramref name="animatedEffectsEnabledAccessor" />
|
||||
/// 为 <see langword="null" /> 时抛出。
|
||||
/// </exception>
|
||||
public RichTextEffectsController(
|
||||
IRichTextEffectHost host,
|
||||
Func<RichTextEffectPlan?> profileAccessor,
|
||||
Func<bool> frameworkEffectsEnabledAccessor,
|
||||
Func<bool> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化并立即刷新宿主标签的效果集合。
|
||||
/// </summary>
|
||||
public void Initialize()
|
||||
{
|
||||
RefreshEffects();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据当前配置和开关重建宿主标签上的 <see cref="RichTextLabel.CustomEffects" />。
|
||||
/// </summary>
|
||||
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());
|
||||
}
|
||||
}
|
||||
182
GFramework.Godot/Text/RichTextMarkup.cs
Normal file
182
GFramework.Godot/Text/RichTextMarkup.cs
Normal file
@ -0,0 +1,182 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace GFramework.Godot.Text;
|
||||
|
||||
/// <summary>
|
||||
/// 提供语义化的富文本标签构建辅助方法。
|
||||
/// 该工具层用于减少业务代码直接手写原始 BBCode 字符串的重复工作。
|
||||
/// </summary>
|
||||
public static class RichTextMarkup
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用指定标签包裹文本。
|
||||
/// </summary>
|
||||
/// <param name="text">原始文本。</param>
|
||||
/// <param name="tag">标签名。</param>
|
||||
/// <returns>包裹后的 BBCode 文本。</returns>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// 当 <paramref name="tag" /> 为空、仅包含空白字符,或包含 BBCode token 不允许的控制字符时抛出。
|
||||
/// </exception>
|
||||
public static string Color(string text, string tag)
|
||||
{
|
||||
return Wrap(text, tag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用 `green` 标签包裹文本。
|
||||
/// </summary>
|
||||
/// <param name="text">原始文本。</param>
|
||||
/// <returns>包裹后的 BBCode 文本。</returns>
|
||||
public static string Green(string text)
|
||||
{
|
||||
return Wrap(text, "green");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用 `red` 标签包裹文本。
|
||||
/// </summary>
|
||||
/// <param name="text">原始文本。</param>
|
||||
/// <returns>包裹后的 BBCode 文本。</returns>
|
||||
public static string Red(string text)
|
||||
{
|
||||
return Wrap(text, "red");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用 `gold` 标签包裹文本。
|
||||
/// </summary>
|
||||
/// <param name="text">原始文本。</param>
|
||||
/// <returns>包裹后的 BBCode 文本。</returns>
|
||||
public static string Gold(string text)
|
||||
{
|
||||
return Wrap(text, "gold");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用 `blue` 标签包裹文本。
|
||||
/// </summary>
|
||||
/// <param name="text">原始文本。</param>
|
||||
/// <returns>包裹后的 BBCode 文本。</returns>
|
||||
public static string Blue(string text)
|
||||
{
|
||||
return Wrap(text, "blue");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定效果标签包裹文本,并可附带参数环境。
|
||||
/// 环境参数会按键名进行稳定排序,避免不同字典实现导致输出顺序漂移。
|
||||
/// </summary>
|
||||
/// <param name="text">原始文本。</param>
|
||||
/// <param name="tag">标签名。</param>
|
||||
/// <param name="env">可选的标签参数集合。</param>
|
||||
/// <returns>包裹后的 BBCode 文本。</returns>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// 当 <paramref name="tag" /> 为空、仅包含空白字符,包含 BBCode token 不允许的控制字符,
|
||||
/// 或 <paramref name="env" /> 中存在包含非法控制字符的参数键时抛出。
|
||||
/// </exception>
|
||||
public static string Effect(string text, string tag, IReadOnlyDictionary<string, object?>? 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定标签包裹文本。
|
||||
/// </summary>
|
||||
/// <param name="text">原始文本。</param>
|
||||
/// <param name="tag">标签名。</param>
|
||||
/// <returns>包裹后的 BBCode 文本。</returns>
|
||||
private static string Wrap(string text, string tag)
|
||||
{
|
||||
ValidateToken(tag, nameof(tag));
|
||||
return $"[{tag}]{text ?? string.Empty}[/{tag}]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收集并排序可写入 BBCode 的环境参数。
|
||||
/// </summary>
|
||||
/// <param name="env">原始环境参数。</param>
|
||||
/// <returns>按键名稳定排序后的参数集合。</returns>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// 当参数键包含 BBCode token 不允许的控制字符时抛出。
|
||||
/// </exception>
|
||||
private static IReadOnlyList<KeyValuePair<string, object>> CollectEnvironmentPairs(
|
||||
IReadOnlyDictionary<string, object?> env)
|
||||
{
|
||||
var pairs = new List<KeyValuePair<string, object>>(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<string, object>(pair.Key, pair.Value));
|
||||
}
|
||||
|
||||
pairs.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.Key, right.Key));
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 BBCode 标签或参数键是否满足 token 约束。
|
||||
/// </summary>
|
||||
/// <param name="token">待验证的 token。</param>
|
||||
/// <param name="paramName">异常参数名。</param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// 当 token 为空、仅包含空白字符,或包含 BBCode token 不允许的控制字符时抛出。
|
||||
/// </exception>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将标签参数值格式化为稳定的 BBCode 字符串表示。
|
||||
/// </summary>
|
||||
/// <param name="value">待格式化的值。</param>
|
||||
/// <returns>适用于 BBCode 参数的字符串。</returns>
|
||||
private static string FormatValue(object value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
string text => text,
|
||||
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
|
||||
_ => value.ToString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
50
GFramework.Godot/Text/RichTextProfile.cs
Normal file
50
GFramework.Godot/Text/RichTextProfile.cs
Normal file
@ -0,0 +1,50 @@
|
||||
namespace GFramework.Godot.Text;
|
||||
|
||||
/// <summary>
|
||||
/// 描述一个富文本效果组合配置。
|
||||
/// 该资源是 Godot 编辑器与场景系统使用的配置载体;运行时控制器会先把它转换为
|
||||
/// <see cref="RichTextEffectPlan" />,再在纯托管边界内完成刷新决策。
|
||||
/// </summary>
|
||||
[GlobalClass]
|
||||
public partial class RichTextProfile : Resource
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置当前配置启用的效果条目集合。
|
||||
/// </summary>
|
||||
[Export]
|
||||
public RichTextEffectEntry[] Effects { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 创建包含全部内置效果的默认配置。
|
||||
/// 该方法为第一阶段提供零配置可用的回退组合。
|
||||
/// </summary>
|
||||
/// <returns>包含全部内置效果键的默认配置。</returns>
|
||||
public static RichTextProfile CreateBuiltInDefault()
|
||||
{
|
||||
return FromPlan(RichTextEffectPlan.CreateBuiltInDefault());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从纯托管效果计划创建对应的 Godot 资源配置。
|
||||
/// 该转换只应发生在真正需要与 Godot 宿主或公开注册表交互的适配层边界上。
|
||||
/// </summary>
|
||||
/// <param name="plan">待转换的纯托管效果计划。</param>
|
||||
/// <returns>与计划等价的 Godot 资源配置。</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// 当 <paramref name="plan" /> 为 <see langword="null" /> 时抛出。
|
||||
/// </exception>
|
||||
internal static RichTextProfile FromPlan(RichTextEffectPlan plan)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
|
||||
var profile = new RichTextProfile();
|
||||
profile.Effects = plan.Effects
|
||||
.Select(static entry => new RichTextEffectEntry
|
||||
{
|
||||
Key = entry.Key,
|
||||
Enabled = entry.Enabled
|
||||
})
|
||||
.ToArray();
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user