feat(coroutine): 添加协程系统核心组件与Godot集成

- 实现CoroutineMetadata类存储协程元数据信息
- 创建CoroutineScheduler协程调度器管理协程生命周期
- 添加CoroutineSlot类管理单个协程执行状态
- 实现GodotTimeSource时间源支持缩放和真实时间
- 添加Timing类提供Godot协程管理功能
- 实现CoroutineNodeExtensions扩展方法支持节点生命周期管理
- 支持协程分组、标签、优先级等功能
- 提供协程暂停、恢复、终止等控制接口
- 实现协程统计和快照功能
- 添加等待指令处理机制支持多种等待类型
This commit is contained in:
GeWuYou 2026-04-05 15:06:35 +08:00
parent a22e522cf9
commit 1c41c57d72
14 changed files with 1409 additions and 342 deletions

View File

@ -0,0 +1,28 @@
namespace GFramework.Core.Abstractions.Coroutine;
/// <summary>
/// 表示协程的最终完成结果。
/// </summary>
public enum CoroutineCompletionStatus
{
/// <summary>
/// 调度器无法确认该句柄的最终结果。
/// 这通常意味着句柄无效,或者句柄对应的历史结果已经不可用。
/// </summary>
Unknown,
/// <summary>
/// 协程自然执行结束。
/// </summary>
Completed,
/// <summary>
/// 协程被外部终止、清空或取消令牌中断。
/// </summary>
Cancelled,
/// <summary>
/// 协程在推进过程中抛出了异常。
/// </summary>
Faulted
}

View File

@ -0,0 +1,29 @@
namespace GFramework.Core.Abstractions.Coroutine;
/// <summary>
/// 表示协程调度器当前所处的执行阶段。
/// </summary>
/// <remarks>
/// 某些等待指令具有阶段语义,例如 <c>WaitForFixedUpdate</c> 和 <c>WaitForEndOfFrame</c>。
/// 宿主应为这些语义提供匹配的调度器阶段,否则这类等待不会自然完成。
/// </remarks>
public enum CoroutineExecutionStage
{
/// <summary>
/// 默认更新阶段。
/// 普通时间等待、下一帧等待以及大多数条件等待都会在该阶段推进。
/// </summary>
Update,
/// <summary>
/// 固定更新阶段。
/// 仅与固定步相关的等待指令会在该阶段完成。
/// </summary>
FixedUpdate,
/// <summary>
/// 帧结束阶段。
/// 仅与帧尾或延迟执行相关的等待指令会在该阶段完成。
/// </summary>
EndOfFrame
}

View File

@ -0,0 +1,174 @@
using GFramework.Core.Abstractions.Coroutine;
using GFramework.Core.Coroutine;
using GFramework.Core.Coroutine.Instructions;
namespace GFramework.Core.Tests.Coroutine;
/// <summary>
/// 协程调度器增强行为测试。
/// </summary>
[TestFixture]
public sealed class CoroutineSchedulerAdvancedTests
{
/// <summary>
/// 验证 WaitForSecondsRealtime 使用独立的真实时间源推进。
/// </summary>
[Test]
public void WaitForSecondsRealtime_Should_Use_Realtime_TimeSource_When_Provided()
{
var scaledTime = new FakeTimeSource();
var realtimeTime = new FakeTimeSource();
var scheduler = new CoroutineScheduler(
scaledTime,
realtimeTimeSource: realtimeTime);
var completed = false;
IEnumerator<IYieldInstruction> Coroutine()
{
yield return new WaitForSecondsRealtime(1.0);
completed = true;
}
scheduler.Run(Coroutine());
scaledTime.Advance(0.1);
realtimeTime.Advance(0.4);
scheduler.Update();
Assert.That(completed, Is.False);
Assert.That(scheduler.ActiveCoroutineCount, Is.EqualTo(1));
scaledTime.Advance(0.1);
realtimeTime.Advance(0.6);
scheduler.Update();
Assert.That(completed, Is.True);
Assert.That(scheduler.ActiveCoroutineCount, Is.EqualTo(0));
}
/// <summary>
/// 验证固定更新等待指令仅在固定阶段调度器中推进。
/// </summary>
[Test]
public void WaitForFixedUpdate_Should_Only_Advance_On_FixedUpdate_Scheduler()
{
var defaultTime = new FakeTimeSource();
var fixedTime = new FakeTimeSource();
var defaultScheduler = new CoroutineScheduler(defaultTime);
var fixedScheduler = new CoroutineScheduler(
fixedTime,
executionStage: CoroutineExecutionStage.FixedUpdate);
var defaultCompleted = false;
var fixedCompleted = false;
IEnumerator<IYieldInstruction> DefaultCoroutine()
{
yield return new WaitForFixedUpdate();
defaultCompleted = true;
}
IEnumerator<IYieldInstruction> FixedCoroutine()
{
yield return new WaitForFixedUpdate();
fixedCompleted = true;
}
defaultScheduler.Run(DefaultCoroutine());
fixedScheduler.Run(FixedCoroutine());
defaultTime.Advance(0.1);
fixedTime.Advance(0.1);
defaultScheduler.Update();
fixedScheduler.Update();
Assert.That(defaultCompleted, Is.False);
Assert.That(defaultScheduler.ActiveCoroutineCount, Is.EqualTo(1));
Assert.That(fixedCompleted, Is.True);
Assert.That(fixedScheduler.ActiveCoroutineCount, Is.EqualTo(0));
}
/// <summary>
/// 验证取消令牌会在下一次调度循环中终止协程并记录结果。
/// </summary>
[Test]
public async Task CancellationToken_Should_Cancel_Coroutine_On_Next_Update()
{
var timeSource = new FakeTimeSource();
var scheduler = new CoroutineScheduler(timeSource);
using var cancellationTokenSource = new CancellationTokenSource();
IEnumerator<IYieldInstruction> Coroutine()
{
yield return new Delay(10);
}
var handle = scheduler.Run(Coroutine(), cancellationToken: cancellationTokenSource.Token);
cancellationTokenSource.Cancel();
timeSource.Advance(0.1);
scheduler.Update();
var status = await scheduler.WaitForCompletionAsync(handle);
Assert.That(scheduler.IsCoroutineAlive(handle), Is.False);
Assert.That(status, Is.EqualTo(CoroutineCompletionStatus.Cancelled));
}
/// <summary>
/// 验证调度器可以暴露活跃协程快照。
/// </summary>
[Test]
public void TryGetSnapshot_Should_Return_Current_Waiting_Instruction_And_Stage()
{
var timeSource = new FakeTimeSource();
var scheduler = new CoroutineScheduler(
timeSource,
executionStage: CoroutineExecutionStage.EndOfFrame);
IEnumerator<IYieldInstruction> Coroutine()
{
yield return new WaitForEndOfFrame();
}
var handle = scheduler.Run(Coroutine(), tag: "ui", group: "frame-end");
var found = scheduler.TryGetSnapshot(handle, out var snapshot);
Assert.That(found, Is.True);
Assert.That(snapshot.Handle, Is.EqualTo(handle));
Assert.That(snapshot.Tag, Is.EqualTo("ui"));
Assert.That(snapshot.Group, Is.EqualTo("frame-end"));
Assert.That(snapshot.IsWaiting, Is.True);
Assert.That(snapshot.WaitingInstructionType, Is.EqualTo(typeof(WaitForEndOfFrame)));
Assert.That(snapshot.ExecutionStage, Is.EqualTo(CoroutineExecutionStage.EndOfFrame));
}
/// <summary>
/// 验证异常结束的协程会记录为 Faulted。
/// </summary>
[Test]
public async Task WaitForCompletionAsync_Should_Return_Faulted_For_Failing_Coroutine()
{
var timeSource = new FakeTimeSource();
var scheduler = new CoroutineScheduler(timeSource);
IEnumerator<IYieldInstruction> Coroutine()
{
yield return new WaitOneFrame();
throw new InvalidOperationException("boom");
#pragma warning disable CS0162
yield break;
#pragma warning restore CS0162
}
var handle = scheduler.Run(Coroutine());
timeSource.Advance(0.1);
scheduler.Update();
timeSource.Advance(0.1);
scheduler.Update();
var status = await scheduler.WaitForCompletionAsync(handle);
Assert.That(status, Is.EqualTo(CoroutineCompletionStatus.Faulted));
}
}

View File

@ -7,6 +7,12 @@ namespace GFramework.Core.Coroutine;
/// </summary>
internal class CoroutineMetadata
{
/// <summary>
/// 协程所属调度器的执行阶段。
/// 该值用于诊断等待语义是否与当前宿主阶段匹配。
/// </summary>
public CoroutineExecutionStage ExecutionStage;
/// <summary>
/// 协程的分组标识符,用于批量管理协程
/// </summary>

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,12 @@ namespace GFramework.Core.Coroutine;
/// </summary>
internal sealed class CoroutineSlot
{
/// <summary>
/// 由外部取消令牌创建的注册。
/// 调度器在协程结束时必须释放该注册,避免泄漏取消回调。
/// </summary>
public CancellationTokenRegistration CancellationRegistration;
/// <summary>
/// 协程枚举器,包含协程的执行逻辑
/// </summary>

View File

@ -0,0 +1,29 @@
using GFramework.Core.Abstractions.Coroutine;
namespace GFramework.Core.Coroutine;
/// <summary>
/// 表示某个活跃协程在调度器中的只读运行快照。
/// </summary>
/// <param name="Handle">协程句柄。</param>
/// <param name="State">当前协程状态。</param>
/// <param name="Priority">当前协程优先级。</param>
/// <param name="Tag">可选标签。</param>
/// <param name="Group">可选分组。</param>
/// <param name="StartTimeMs">协程启动时间,单位为毫秒。</param>
/// <param name="IsWaiting">当前是否正被等待指令阻塞。</param>
/// <param name="WaitingInstructionType">
/// 当前等待指令的具体类型。
/// 若协程当前未处于等待状态,则该值为 <see langword="null" />。
/// </param>
/// <param name="ExecutionStage">所属调度器的执行阶段。</param>
public readonly record struct CoroutineSnapshot(
CoroutineHandle Handle,
CoroutineState State,
CoroutinePriority Priority,
string? Tag,
string? Group,
double StartTimeMs,
bool IsWaiting,
Type? WaitingInstructionType,
CoroutineExecutionStage ExecutionStage);

View File

@ -0,0 +1,52 @@
using System.Collections.Generic;
using GFramework.Godot.Coroutine;
using NUnit.Framework;
namespace GFramework.Godot.Tests.Coroutine;
/// <summary>
/// GodotTimeSource 的单元测试。
/// </summary>
[TestFixture]
public sealed class GodotTimeSourceTests
{
/// <summary>
/// 验证增量模式会直接累加传入的 delta。
/// </summary>
[Test]
public void Update_Should_Accumulate_Delta_When_Using_Delta_Mode()
{
var values = new Queue<double>([0.1, 0.2]);
var timeSource = new GodotTimeSource(() => values.Dequeue());
timeSource.Update();
Assert.That(timeSource.DeltaTime, Is.EqualTo(0.1).Within(0.0001));
Assert.That(timeSource.CurrentTime, Is.EqualTo(0.1).Within(0.0001));
timeSource.Update();
Assert.That(timeSource.DeltaTime, Is.EqualTo(0.2).Within(0.0001));
Assert.That(timeSource.CurrentTime, Is.EqualTo(0.3).Within(0.0001));
}
/// <summary>
/// 验证绝对时间模式会根据前后两次采样计算 delta。
/// </summary>
[Test]
public void Update_Should_Calculate_Delta_When_Using_Absolute_Time_Mode()
{
var values = new Queue<double>([1.0, 1.25, 2.0]);
var timeSource = new GodotTimeSource(() => values.Dequeue(), useAbsoluteTime: true);
timeSource.Update();
Assert.That(timeSource.DeltaTime, Is.EqualTo(0).Within(0.0001));
Assert.That(timeSource.CurrentTime, Is.EqualTo(1.0).Within(0.0001));
timeSource.Update();
Assert.That(timeSource.DeltaTime, Is.EqualTo(0.25).Within(0.0001));
Assert.That(timeSource.CurrentTime, Is.EqualTo(1.25).Within(0.0001));
timeSource.Update();
Assert.That(timeSource.DeltaTime, Is.EqualTo(0.75).Within(0.0001));
Assert.That(timeSource.CurrentTime, Is.EqualTo(2.0).Within(0.0001));
}
}

View File

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TestTargetFrameworks Condition="'$(TestTargetFrameworks)' == ''">net10.0</TestTargetFrameworks>
<TargetFrameworks>$(TestTargetFrameworks)</TargetFrameworks>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<WarningLevel>0</WarningLevel>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0"/>
<PackageReference Include="NUnit" Version="4.5.1"/>
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GFramework.Godot\GFramework.Godot.csproj"/>
</ItemGroup>
</Project>

View File

@ -35,6 +35,25 @@ public static class CoroutineNodeExtensions
return Timing.RunCoroutine(coroutine, segment, tag);
}
/// <summary>
/// 以指定节点作为生命周期所有者运行协程。
/// </summary>
/// <param name="owner">拥有该协程生命周期的节点。</param>
/// <param name="coroutine">要启动的协程枚举器。</param>
/// <param name="segment">协程运行的时间段。</param>
/// <param name="tag">协程标签。</param>
/// <param name="cancellationToken">可选取消令牌。</param>
/// <returns>返回协程句柄。</returns>
public static CoroutineHandle RunCoroutine(
this Node owner,
IEnumerator<IYieldInstruction> coroutine,
Segment segment = Segment.Process,
string? tag = null,
CancellationToken cancellationToken = default)
{
return Timing.RunOwnedCoroutine(owner, coroutine, segment, tag, cancellationToken);
}
/// <summary>
/// 让协程在指定节点被销毁时自动取消。
/// </summary>

View File

@ -1,42 +1,81 @@
using GFramework.Core.Abstractions.Coroutine;
using Godot;
namespace GFramework.Godot.Coroutine;
/// <summary>
/// Godot时间源实现用于提供基于Godot引擎的时间信息
/// Godot 时间源实现,用于为协程调度器提供缩放时间或真实时间数据。
/// </summary>
/// <param name="getDeltaFunc">获取增量时间的函数委托</param>
public class GodotTimeSource(Func<double> getDeltaFunc) : ITimeSource
/// <param name="timeProvider">
/// 时间提供函数。
/// 在默认模式下该函数返回“本帧增量”;在绝对时间模式下该函数返回“当前绝对时间(秒)”。
/// </param>
/// <param name="useAbsoluteTime">
/// 是否把 <paramref name="timeProvider" /> 返回值解释为绝对时间。
/// 启用后,<see cref="Update" /> 会通过相邻两次读数计算 <see cref="DeltaTime" />。
/// </param>
public sealed class GodotTimeSource(Func<double> timeProvider, bool useAbsoluteTime = false) : ITimeSource
{
private readonly Func<double> _getDeltaFunc = getDeltaFunc ?? throw new ArgumentNullException(nameof(getDeltaFunc));
private readonly Func<double> _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
private bool _initialized;
private double _lastAbsoluteTime;
/// <summary>
/// 获取当前累计时间
/// 获取当前累计时间
/// </summary>
public double CurrentTime { get; private set; }
/// <summary>
/// 获取上一帧的时间增量
/// 获取上一帧的时间增量
/// </summary>
public double DeltaTime { get; private set; }
/// <summary>
/// 更新时间源,计算新的增量时间和累计时间
/// 更新时间源,计算新的时间增量与累计时间。
/// </summary>
public void Update()
{
// 调用外部提供的函数获取当前帧的时间增量
DeltaTime = _getDeltaFunc();
// 累加到总时间中
var value = _timeProvider();
if (useAbsoluteTime)
{
if (!_initialized)
{
_initialized = true;
_lastAbsoluteTime = value;
CurrentTime = value;
DeltaTime = 0;
return;
}
DeltaTime = Math.Max(0, value - _lastAbsoluteTime);
_lastAbsoluteTime = value;
CurrentTime = value;
return;
}
DeltaTime = value;
CurrentTime += DeltaTime;
}
/// <summary>
/// 重置时间源到初始状态
/// 创建基于 Godot 单调时钟的真实时间源。
/// </summary>
/// <returns>返回一个不受场景暂停与时间缩放影响的时间源实例。</returns>
public static GodotTimeSource CreateRealtime()
{
return new GodotTimeSource(
() => Time.GetTicksUsec() / 1_000_000.0,
useAbsoluteTime: true);
}
/// <summary>
/// 重置时间源到初始状态。
/// </summary>
public void Reset()
{
CurrentTime = 0;
DeltaTime = 0;
_initialized = false;
_lastAbsoluteTime = 0;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -62,6 +62,7 @@
<None Remove="GFramework.SourceGenerators.Attributes\**"/>
<None Remove="Godot\**"/>
<None Remove="GFramework.Game.Tests\**"/>
<None Remove="GFramework.Godot.Tests\**"/>
</ItemGroup>
<!-- 聚合核心模块 -->
<ItemGroup>
@ -102,6 +103,7 @@
<Compile Remove="GFramework.SourceGenerators.Attributes\**"/>
<Compile Remove="Godot\**"/>
<Compile Remove="GFramework.Game.Tests\**"/>
<Compile Remove="GFramework.Godot.Tests\**"/>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Remove="GFramework.Core\**"/>
@ -128,6 +130,7 @@
<EmbeddedResource Remove="GFramework.SourceGenerators.Attributes\**"/>
<EmbeddedResource Remove="Godot\**"/>
<EmbeddedResource Remove="GFramework.Game.Tests\**"/>
<EmbeddedResource Remove="GFramework.Godot.Tests\**"/>
</ItemGroup>
<ItemGroup>
<AdditionalFiles Remove="AnalyzerReleases.Shipped.md"/>

View File

@ -36,6 +36,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Game.Tests", "GF
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Godot.SourceGenerators.Tests", "GFramework.Godot.SourceGenerators.Tests\GFramework.Godot.SourceGenerators.Tests.csproj", "{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Godot.Tests", "GFramework.Godot.Tests\GFramework.Godot.Tests.csproj", "{576119E2-13D0-4ACF-A012-D01C320E8BF3}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -262,6 +264,18 @@ Global
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x64.Build.0 = Release|Any CPU
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x86.ActiveCfg = Release|Any CPU
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x86.Build.0 = Release|Any CPU
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Debug|x64.ActiveCfg = Debug|Any CPU
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Debug|x64.Build.0 = Debug|Any CPU
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Debug|x86.ActiveCfg = Debug|Any CPU
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Debug|x86.Build.0 = Debug|Any CPU
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Release|Any CPU.Build.0 = Release|Any CPU
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Release|x64.ActiveCfg = Release|Any CPU
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Release|x64.Build.0 = Release|Any CPU
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Release|x86.ActiveCfg = Release|Any CPU
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE