feat: FlowAsync调整

This commit is contained in:
2026-08-05 21:14:06 +08:00
parent 9c3c467046
commit 121e7f398c
13 changed files with 513 additions and 59 deletions
@@ -1803,6 +1803,14 @@ namespace AibisDream.FrameAnimation.Editor
AddReadOnly(body, "ID", flow.Id); AddReadOnly(body, "ID", flow.Id);
AddReadOnly(body, "Entry Node", flow.EntryNodeId); AddReadOnly(body, "Entry Node", flow.EntryNodeId);
AddBoundProperty(body, serialized, property.FindPropertyRelative("displayName"), "Display Name"); AddBoundProperty(body, serialized, property.FindPropertyRelative("displayName"), "Display Name");
AddBoundProperty(
body,
serialized,
property.FindPropertyRelative("asyncCompletionMode"),
"Async Completion");
body.Add(CreateCallout(
"Only affects async playback when the resolved terminal clip loops.",
"fa-callout--info"));
AddBoundProperty(body, serialized, property.FindPropertyRelative("hasEndBehaviorOverride"), "Override End Behavior"); AddBoundProperty(body, serialized, property.FindPropertyRelative("hasEndBehaviorOverride"), "Override End Behavior");
AddBoundProperty(body, serialized, property.FindPropertyRelative("endBehaviorOverride"), "End Behavior"); AddBoundProperty(body, serialized, property.FindPropertyRelative("endBehaviorOverride"), "End Behavior");
@@ -2513,6 +2521,14 @@ namespace AibisDream.FrameAnimation.Editor
EditorGUILayout.PropertyField(property.FindPropertyRelative("entryNodeId")); EditorGUILayout.PropertyField(property.FindPropertyRelative("entryNodeId"));
} }
EditorGUILayout.PropertyField(property.FindPropertyRelative("displayName")); EditorGUILayout.PropertyField(property.FindPropertyRelative("displayName"));
EditorGUILayout.PropertyField(
property.FindPropertyRelative("asyncCompletionMode"),
new GUIContent(
"Async Completion",
"Only affects async playback when the resolved terminal clip loops."));
EditorGUILayout.HelpBox(
"Async Completion only affects a Flow whose resolved terminal clip loops.",
MessageType.Info);
EditorGUILayout.PropertyField(property.FindPropertyRelative("hasEndBehaviorOverride")); EditorGUILayout.PropertyField(property.FindPropertyRelative("hasEndBehaviorOverride"));
if (property.FindPropertyRelative("hasEndBehaviorOverride").boolValue) if (property.FindPropertyRelative("hasEndBehaviorOverride").boolValue)
{ {
@@ -90,12 +90,15 @@ namespace AibisDream.FrameAnimation
[SerializeField] private string id = string.Empty; [SerializeField] private string id = string.Empty;
[SerializeField] private string displayName = string.Empty; [SerializeField] private string displayName = string.Empty;
[SerializeField] private string entryNodeId = string.Empty; [SerializeField] private string entryNodeId = string.Empty;
[SerializeField] private AnimationFlowAsyncCompletionMode asyncCompletionMode =
AnimationFlowAsyncCompletionMode.CompleteOnTerminalLoopStart;
[SerializeField] private bool hasEndBehaviorOverride; [SerializeField] private bool hasEndBehaviorOverride;
[SerializeField] private FrameClipEndBehavior endBehaviorOverride; [SerializeField] private FrameClipEndBehavior endBehaviorOverride;
public string Id => id; public string Id => id;
public string DisplayName => displayName; public string DisplayName => displayName;
public string EntryNodeId => entryNodeId; public string EntryNodeId => entryNodeId;
public AnimationFlowAsyncCompletionMode AsyncCompletionMode => asyncCompletionMode;
public FrameClipEndBehavior? EndBehaviorOverride => public FrameClipEndBehavior? EndBehaviorOverride =>
hasEndBehaviorOverride ? endBehaviorOverride : (FrameClipEndBehavior?)null; hasEndBehaviorOverride ? endBehaviorOverride : (FrameClipEndBehavior?)null;
@@ -107,11 +110,14 @@ namespace AibisDream.FrameAnimation
string id, string id,
string displayName, string displayName,
string entryNodeId, string entryNodeId,
FrameClipEndBehavior? endBehaviorOverride = null) FrameClipEndBehavior? endBehaviorOverride = null,
AnimationFlowAsyncCompletionMode asyncCompletionMode =
AnimationFlowAsyncCompletionMode.CompleteOnTerminalLoopStart)
{ {
this.id = id ?? string.Empty; this.id = id ?? string.Empty;
this.displayName = displayName ?? this.id; this.displayName = displayName ?? this.id;
this.entryNodeId = entryNodeId ?? string.Empty; this.entryNodeId = entryNodeId ?? string.Empty;
this.asyncCompletionMode = asyncCompletionMode;
SetEndBehaviorOverride(endBehaviorOverride); SetEndBehaviorOverride(endBehaviorOverride);
} }
@@ -135,6 +141,11 @@ namespace AibisDream.FrameAnimation
{ {
entryNodeId = value ?? string.Empty; entryNodeId = value ?? string.Empty;
} }
internal void SetAsyncCompletionMode(AnimationFlowAsyncCompletionMode value)
{
asyncCompletionMode = value;
}
} }
[Serializable] [Serializable]
@@ -5,6 +5,25 @@ using UnityEngine;
namespace AibisDream.FrameAnimation namespace AibisDream.FrameAnimation
{ {
public sealed class FrameAnimationPlaybackAwaitable : CustomYieldInstruction
{
private readonly FrameAnimationPlayer player;
public FrameAnimationPlaybackHandle PlaybackHandle { get; }
internal FrameAnimationPlaybackAwaitable(
FrameAnimationPlayer player,
FrameAnimationPlaybackHandle playbackHandle)
{
this.player = player;
PlaybackHandle = playbackHandle ??
throw new ArgumentNullException(nameof(playbackHandle));
}
public override bool keepWaiting =>
player != null && player.ShouldWaitForAsyncCompletion(PlaybackHandle);
}
public sealed class FrameAnimationPlaybackHandle : CustomYieldInstruction public sealed class FrameAnimationPlaybackHandle : CustomYieldInstruction
{ {
private readonly TaskCompletionSource<FrameAnimationPlaybackResult> completionSource = private readonly TaskCompletionSource<FrameAnimationPlaybackResult> completionSource =
@@ -97,6 +97,31 @@ namespace AibisDream.FrameAnimation
public int CurrentFrameIndex => hasStarted && !hasCompleted ? frameIndex : -1; public int CurrentFrameIndex => hasStarted && !hasCompleted ? frameIndex : -1;
public FrameClip CurrentClip => CurrentStep?.Clip; public FrameClip CurrentClip => CurrentStep?.Clip;
internal bool HasCompletedFirstTerminalLoopCycle => hasCompletedFirstTerminalLoopCycle; internal bool HasCompletedFirstTerminalLoopCycle => hasCompletedFirstTerminalLoopCycle;
internal bool HasReachedAsyncCompletionPoint
{
get
{
if (!hasStarted || plan.Steps == null || plan.Steps.Count == 0)
{
return false;
}
var terminalStep = plan.Steps[plan.Steps.Count - 1];
if (terminalStep.TerminalEndBehavior != FrameClipEndBehavior.Loop)
{
return hasCompleted;
}
if (!plan.IsFlow ||
plan.AsyncCompletionMode ==
AnimationFlowAsyncCompletionMode.WaitForTerminalLoopFirstCycle)
{
return hasCompletedFirstTerminalLoopCycle;
}
return stepIndex == plan.Steps.Count - 1;
}
}
internal FrameAnimationPlaybackSnapshot Snapshot => internal FrameAnimationPlaybackSnapshot Snapshot =>
new FrameAnimationPlaybackSnapshot( new FrameAnimationPlaybackSnapshot(
hasStarted, hasStarted,
@@ -138,18 +138,22 @@ namespace AibisDream.FrameAnimation
return PlayInternal(playableId, options); return PlayInternal(playableId, options);
} }
/// <summary> public FrameAnimationPlaybackAwaitable PlayAsync()
/// 等待当前播放请求完成首轮。非循环请求等待自然结束;终点为 Loop 的请求在
/// 第一次回绕后结束等待,但播放请求本身保持活动并继续循环。
/// </summary>
public CustomYieldInstruction WaitForFirstPass(FrameAnimationPlaybackHandle playbackHandle)
{ {
if (playbackHandle == null) var playableId = graph?.Settings?.DefaultPlayableId ?? string.Empty;
{ return PlayAsyncInternal(playableId, default);
throw new ArgumentNullException(nameof(playbackHandle)); }
}
return new FirstPassYieldInstruction(this, playbackHandle); public FrameAnimationPlaybackAwaitable PlayAsync(string playableId)
{
return PlayAsyncInternal(playableId, default);
}
public FrameAnimationPlaybackAwaitable PlayAsync(
string playableId,
FrameAnimationPlayOptions options)
{
return PlayAsyncInternal(playableId, options);
} }
public bool TryBindGraph( public bool TryBindGraph(
@@ -404,6 +408,15 @@ namespace AibisDream.FrameAnimation
return handle; return handle;
} }
private FrameAnimationPlaybackAwaitable PlayAsyncInternal(
string playableId,
FrameAnimationPlayOptions options)
{
return new FrameAnimationPlaybackAwaitable(
this,
PlayInternal(playableId, options));
}
private bool TryCreateTarget( private bool TryCreateTarget(
out IFrameAnimationTarget resolvedTarget, out IFrameAnimationTarget resolvedTarget,
out FrameAnimationPlaybackError error) out FrameAnimationPlaybackError error)
@@ -527,31 +540,15 @@ namespace AibisDream.FrameAnimation
private Action<Sprite> ApplySpriteCallback => private Action<Sprite> ApplySpriteCallback =>
applySpriteCallback ??= ApplySprite; applySpriteCallback ??= ApplySprite;
private bool ShouldWaitForFirstPass(FrameAnimationPlaybackHandle playbackHandle) internal bool ShouldWaitForAsyncCompletion(
FrameAnimationPlaybackHandle playbackHandle)
{ {
if (playbackHandle.IsCompleted || activeHandle != playbackHandle || session == null) if (playbackHandle.IsCompleted || activeHandle != playbackHandle || session == null)
{ {
return false; return false;
} }
return !session.HasCompletedFirstTerminalLoopCycle; return !session.HasReachedAsyncCompletionPoint;
}
private sealed class FirstPassYieldInstruction : CustomYieldInstruction
{
private readonly FrameAnimationPlayer player;
private readonly FrameAnimationPlaybackHandle playbackHandle;
public FirstPassYieldInstruction(
FrameAnimationPlayer player,
FrameAnimationPlaybackHandle playbackHandle)
{
this.player = player;
this.playbackHandle = playbackHandle;
}
public override bool keepWaiting =>
player != null && player.ShouldWaitForFirstPass(playbackHandle);
} }
private void ApplyEndBehavior(FrameClipEndBehavior endBehavior) private void ApplyEndBehavior(FrameClipEndBehavior endBehavior)
@@ -28,12 +28,19 @@ namespace AibisDream.FrameAnimation
public string PlayableId { get; } public string PlayableId { get; }
public bool IsFlow { get; } public bool IsFlow { get; }
public IReadOnlyList<ResolvedPlaybackStep> Steps { get; } public IReadOnlyList<ResolvedPlaybackStep> Steps { get; }
public AnimationFlowAsyncCompletionMode AsyncCompletionMode { get; }
public ResolvedPlaybackPlan(string playableId, bool isFlow, IReadOnlyList<ResolvedPlaybackStep> steps) public ResolvedPlaybackPlan(
string playableId,
bool isFlow,
IReadOnlyList<ResolvedPlaybackStep> steps,
AnimationFlowAsyncCompletionMode asyncCompletionMode =
AnimationFlowAsyncCompletionMode.WaitForTerminalLoopFirstCycle)
{ {
PlayableId = playableId; PlayableId = playableId;
IsFlow = isFlow; IsFlow = isFlow;
Steps = steps; Steps = steps;
AsyncCompletionMode = asyncCompletionMode;
} }
} }
@@ -278,7 +285,11 @@ namespace AibisDream.FrameAnimation
currentNodeId = edge.ToNodeId; currentNodeId = edge.ToNodeId;
} }
plan = new ResolvedPlaybackPlan(flow.Id, true, steps); plan = new ResolvedPlaybackPlan(
flow.Id,
true,
steps,
flow.AsyncCompletionMode);
return true; return true;
} }
@@ -20,6 +20,12 @@ namespace AibisDream.FrameAnimation
Always Always
} }
public enum AnimationFlowAsyncCompletionMode
{
CompleteOnTerminalLoopStart = 0,
WaitForTerminalLoopFirstCycle = 1
}
public enum FrameAnimationPlaybackState public enum FrameAnimationPlaybackState
{ {
Stopped, Stopped,
@@ -95,12 +95,13 @@ namespace AibisDream
public override IEnumerator ChangeStateAsync(string stateName) public override IEnumerator ChangeStateAsync(string stateName)
{ {
if (!TryPlay(stateName, false, out var handle)) if (!TryPlayAsync(stateName, out var awaitable))
{ {
yield break; yield break;
} }
yield return _player.WaitForFirstPass(handle); yield return awaitable;
var handle = awaitable.PlaybackHandle;
if (handle.IsCompleted) if (handle.IsCompleted)
{ {
LogFailedResult(stateName, handle.Result); LogFailedResult(stateName, handle.Result);
@@ -141,6 +142,50 @@ namespace AibisDream
out FrameAnimationPlaybackHandle handle) out FrameAnimationPlaybackHandle handle)
{ {
handle = null; handle = null;
if (!CanPlay(stateName))
{
return false;
}
handle = restoreTerminalState
? _player.RestoreTerminalState(stateName)
: _player.Play(stateName);
if (handle.IsCompleted &&
handle.Result.Reason == FrameAnimationCompletionReason.Failed)
{
LogFailedResult(stateName, handle.Result);
return false;
}
_currentStateName = stateName;
return true;
}
private bool TryPlayAsync(
string stateName,
out FrameAnimationPlaybackAwaitable awaitable)
{
awaitable = null;
if (!CanPlay(stateName))
{
return false;
}
awaitable = _player.PlayAsync(stateName);
var handle = awaitable.PlaybackHandle;
if (handle.IsCompleted &&
handle.Result.Reason == FrameAnimationCompletionReason.Failed)
{
LogFailedResult(stateName, handle.Result);
return false;
}
_currentStateName = stateName;
return true;
}
private bool CanPlay(string stateName)
{
if (!_isReady || _player == null) if (!_isReady || _player == null)
{ {
Debug.LogError( Debug.LogError(
@@ -157,17 +202,6 @@ namespace AibisDream
return false; return false;
} }
handle = restoreTerminalState
? _player.RestoreTerminalState(stateName)
: _player.Play(stateName);
if (handle.IsCompleted &&
handle.Result.Reason == FrameAnimationCompletionReason.Failed)
{
LogFailedResult(stateName, handle.Result);
return false;
}
_currentStateName = stateName;
return true; return true;
} }
@@ -36,7 +36,8 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
"Flow", "Flow",
"Flow", "Flow",
node.InternalId, node.InternalId,
FrameClipEndBehavior.Clear); FrameClipEndBehavior.Clear,
AnimationFlowAsyncCompletionMode.WaitForTerminalLoopFirstCycle);
var graph = CreateGraph( var graph = CreateGraph(
new[] { clip }, new[] { clip },
new[] { node }, new[] { node },
@@ -55,6 +56,9 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
Assert.That( Assert.That(
clone.Flows[0].EndBehaviorOverride, clone.Flows[0].EndBehaviorOverride,
Is.EqualTo(FrameClipEndBehavior.Clear)); Is.EqualTo(FrameClipEndBehavior.Clear));
Assert.That(
clone.Flows[0].AsyncCompletionMode,
Is.EqualTo(AnimationFlowAsyncCompletionMode.WaitForTerminalLoopFirstCycle));
} }
[Test] [Test]
@@ -126,6 +130,107 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
Assert.That(session.CurrentClipId, Is.EqualTo(idle.Id)); Assert.That(session.CurrentClipId, Is.EqualTo(idle.Id));
Assert.That(session.CurrentNodeId, Is.EqualTo(idleNode.InternalId)); Assert.That(session.CurrentNodeId, Is.EqualTo(idleNode.InternalId));
Assert.That(session.CurrentFrameIndex, Is.EqualTo(2)); Assert.That(session.CurrentFrameIndex, Is.EqualTo(2));
Assert.That(
plan.AsyncCompletionMode,
Is.EqualTo(AnimationFlowAsyncCompletionMode.CompleteOnTerminalLoopStart));
Assert.That(session.HasReachedAsyncCompletionPoint, Is.True);
}
[Test]
public void PlaybackSession_FlowAsyncCompletionHonorsTerminalLoopPolicy()
{
var intro = CreateClip("Intro", FrameClipEndBehavior.HoldLastFrame, 1f, 100);
var idle = CreateClip("Idle", FrameClipEndBehavior.Loop, 1f, 100);
var completeOnStartPlan = new ResolvedPlaybackPlan(
"CompleteOnStart",
true,
new[]
{
new ResolvedPlaybackStep(
intro,
"intro-node",
1f,
FrameClipEndBehavior.HoldLastFrame),
new ResolvedPlaybackStep(
idle,
"idle-node",
1f,
FrameClipEndBehavior.Loop)
},
AnimationFlowAsyncCompletionMode.CompleteOnTerminalLoopStart);
var completeOnStartSession = new FrameAnimationPlaybackSession(completeOnStartPlan);
completeOnStartSession.Start(_ => { });
Assert.That(completeOnStartSession.HasReachedAsyncCompletionPoint, Is.False);
completeOnStartSession.Evaluate(0.1d, 1f, _ => { });
Assert.That(completeOnStartSession.CurrentClipId, Is.EqualTo(idle.Id));
Assert.That(completeOnStartSession.CurrentFrameIndex, Is.Zero);
Assert.That(completeOnStartSession.HasReachedAsyncCompletionPoint, Is.True);
var waitForCyclePlan = new ResolvedPlaybackPlan(
"WaitForCycle",
true,
completeOnStartPlan.Steps,
AnimationFlowAsyncCompletionMode.WaitForTerminalLoopFirstCycle);
var waitForCycleSession = new FrameAnimationPlaybackSession(waitForCyclePlan);
waitForCycleSession.Start(_ => { });
waitForCycleSession.Evaluate(0.1d, 1f, _ => { });
Assert.That(waitForCycleSession.CurrentClipId, Is.EqualTo(idle.Id));
Assert.That(waitForCycleSession.HasReachedAsyncCompletionPoint, Is.False);
waitForCycleSession.Evaluate(0.1d, 1f, _ => { });
Assert.That(waitForCycleSession.HasCompletedFirstTerminalLoopCycle, Is.True);
Assert.That(waitForCycleSession.HasReachedAsyncCompletionPoint, Is.True);
}
[Test]
public void PlaybackSession_DirectLoopClipAlwaysWaitsForFirstCycle()
{
var loop = CreateClip("Loop", FrameClipEndBehavior.Loop, 1f, 100);
Assert.That(
FrameAnimationResolver.TryResolveClip(
loop,
default,
out var plan,
out var error),
Is.True,
error.ToString());
var session = new FrameAnimationPlaybackSession(plan);
session.Start(_ => { });
Assert.That(session.HasReachedAsyncCompletionPoint, Is.False);
session.Evaluate(0.1d, 1f, _ => { });
Assert.That(session.HasReachedAsyncCompletionPoint, Is.True);
}
[Test]
public void PlaybackSession_FiniteFlowCompletesNaturallyForEitherPolicy()
{
var clip = CreateClip("Finite", FrameClipEndBehavior.HoldLastFrame, 1f, 100);
foreach (AnimationFlowAsyncCompletionMode mode in Enum.GetValues(
typeof(AnimationFlowAsyncCompletionMode)))
{
var plan = new ResolvedPlaybackPlan(
$"Finite-{mode}",
true,
new[]
{
new ResolvedPlaybackStep(
clip,
"finite-node",
1f,
FrameClipEndBehavior.HoldLastFrame)
},
mode);
var session = new FrameAnimationPlaybackSession(plan);
session.Start(_ => { });
Assert.That(session.HasReachedAsyncCompletionPoint, Is.False);
var evaluation = session.Evaluate(0.1d, 1f, _ => { });
Assert.That(evaluation.IsCompleted, Is.True);
Assert.That(session.HasReachedAsyncCompletionPoint, Is.True);
}
} }
[Test] [Test]
@@ -112,6 +112,151 @@ namespace AibisDream.FrameAnimation.Tests.PlayMode
Assert.That(player.CurrentPlayableId, Is.EqualTo(second.Id)); Assert.That(player.CurrentPlayableId, Is.EqualTo(second.Id));
} }
[Test]
public void PlayAsync_FlowCompletesAtConfiguredTerminalLoopBoundary()
{
var introSprite = CreateSprite(Color.red);
var idleSprite = CreateSprite(Color.green);
var intro = CreateClip("Intro", FrameClipEndBehavior.HoldLastFrame, introSprite);
var idle = CreateClip("Idle", FrameClipEndBehavior.Loop, idleSprite);
var completeOnStartObject = Track(new GameObject("Complete On Loop Start"));
var completeOnStartRenderer = completeOnStartObject.AddComponent<SpriteRenderer>();
var completeOnStartPlayer = completeOnStartObject.AddComponent<FrameAnimationPlayer>();
var completeOnStartGraph = CreateFlowGraph(
intro,
idle,
AnimationFlowAsyncCompletionMode.CompleteOnTerminalLoopStart);
completeOnStartPlayer.ConfigureForAuthoring(completeOnStartGraph, false, 1f);
var completeOnStart = completeOnStartPlayer.PlayAsync("Flow");
Assert.That(completeOnStart.keepWaiting, Is.True);
Assert.That(completeOnStartRenderer.sprite, Is.SameAs(introSprite));
completeOnStartPlayer.EvaluateForTests(0.1d);
Assert.That(completeOnStartRenderer.sprite, Is.SameAs(idleSprite));
Assert.That(completeOnStart.keepWaiting, Is.False);
Assert.That(completeOnStart.PlaybackHandle.IsCompleted, Is.False);
Assert.That(completeOnStartPlayer.State, Is.EqualTo(FrameAnimationPlaybackState.Playing));
var waitForCycleObject = Track(new GameObject("Wait For Loop Cycle"));
waitForCycleObject.AddComponent<SpriteRenderer>();
var waitForCyclePlayer = waitForCycleObject.AddComponent<FrameAnimationPlayer>();
var waitForCycleGraph = CreateFlowGraph(
intro,
idle,
AnimationFlowAsyncCompletionMode.WaitForTerminalLoopFirstCycle);
waitForCyclePlayer.ConfigureForAuthoring(waitForCycleGraph, false, 1f);
var waitForCycle = waitForCyclePlayer.PlayAsync("Flow");
waitForCyclePlayer.EvaluateForTests(0.1d);
Assert.That(waitForCycle.keepWaiting, Is.True);
waitForCyclePlayer.EvaluateForTests(0.1d);
Assert.That(waitForCycle.keepWaiting, Is.False);
Assert.That(waitForCycle.PlaybackHandle.IsCompleted, Is.False);
}
[Test]
public void PlayAsync_SingleNodeLoopFlowCompletesAfterApplyingFirstFrame()
{
var sprite = CreateSprite(Color.yellow);
var loop = CreateClip("Loop", FrameClipEndBehavior.Loop, sprite);
var node = new AnimationNode(loop.Id);
var flow = new AnimationFlow("Flow", "Flow", node.InternalId);
var graph = CreateGraph(
new[] { loop },
new[] { node },
Array.Empty<AnimationEdge>(),
new[] { flow },
flow.Id);
var gameObject = Track(new GameObject("Single Loop Flow"));
var renderer = gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(graph, false, 1f);
var awaitable = player.PlayAsync(flow.Id);
Assert.That(renderer.sprite, Is.SameAs(sprite));
Assert.That(awaitable.keepWaiting, Is.False);
Assert.That(awaitable.PlaybackHandle.IsCompleted, Is.False);
}
[Test]
public void PlayAsync_DirectLoopClipWaitsForFirstCycle()
{
var loop = CreateClip(
"Loop",
FrameClipEndBehavior.Loop,
CreateSprite(Color.white));
var gameObject = Track(new GameObject("Direct Loop Clip"));
gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(CreateGraph(loop), false, 1f);
var awaitable = player.PlayAsync(loop.Id);
Assert.That(awaitable.keepWaiting, Is.True);
player.EvaluateForTests(0.1d);
Assert.That(awaitable.keepWaiting, Is.False);
Assert.That(awaitable.PlaybackHandle.IsCompleted, Is.False);
}
[UnityTest]
public IEnumerator PlayAsync_AwaitableCanBeYieldedDirectly()
{
var clip = CreateClip(
"Finite",
FrameClipEndBehavior.HoldLastFrame,
CreateSprite(Color.cyan));
var gameObject = Track(new GameObject("Yield Async Player"));
gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(CreateGraph(clip), false, 1f);
var awaitable = player.PlayAsync(clip.Id);
yield return awaitable;
Assert.That(awaitable.keepWaiting, Is.False);
Assert.That(
awaitable.PlaybackHandle.Result.Reason,
Is.EqualTo(FrameAnimationCompletionReason.Completed));
}
[Test]
public void PlayAsync_FailureAndReplacementReleaseAwaitable()
{
var first = CreateClip(
"First",
FrameClipEndBehavior.Loop,
CreateSprite(Color.white));
var second = CreateClip(
"Second",
FrameClipEndBehavior.Loop,
CreateSprite(Color.black));
var gameObject = Track(new GameObject("Async Arbitration"));
gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(CreateGraph(first, second), false, 1f);
var failed = player.PlayAsync("Missing");
Assert.That(failed.keepWaiting, Is.False);
Assert.That(
failed.PlaybackHandle.Result.Error.Code,
Is.EqualTo(FrameAnimationPlaybackErrorCode.PlayableNotFound));
var active = player.PlayAsync(first.Id);
Assert.That(active.keepWaiting, Is.True);
player.Play(second.Id);
Assert.That(active.keepWaiting, Is.False);
Assert.That(
active.PlaybackHandle.Result.Reason,
Is.EqualTo(FrameAnimationCompletionReason.Replaced));
}
[Test] [Test]
public void RestoreTerminalState_RebuildsSavedVisualState() public void RestoreTerminalState_RebuildsSavedVisualState()
{ {
@@ -154,16 +299,60 @@ namespace AibisDream.FrameAnimation.Tests.PlayMode
gameObject.AddComponent<SpriteRenderer>(); gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>(); var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(CreateGraph(clip), false, 1f); player.ConfigureForAuthoring(CreateGraph(clip), false, 1f);
var handle = player.Play(clip.Id); var awaitable = player.PlayAsync(clip.Id);
var handle = awaitable.PlaybackHandle;
player.enabled = false; player.enabled = false;
yield return null; yield return null;
Assert.That(handle.Result.Reason, Assert.That(handle.Result.Reason,
Is.EqualTo(FrameAnimationCompletionReason.Stopped)); Is.EqualTo(FrameAnimationCompletionReason.Stopped));
Assert.That(awaitable.keepWaiting, Is.False);
Assert.That(player.State, Is.EqualTo(FrameAnimationPlaybackState.Stopped)); Assert.That(player.State, Is.EqualTo(FrameAnimationPlaybackState.Stopped));
} }
[Test]
public void PlayAsync_StopReleasesAwaitableAsStopped()
{
var clip = CreateClip(
"Loop",
FrameClipEndBehavior.Loop,
CreateSprite(Color.white));
var gameObject = Track(new GameObject("Stop Async Player"));
gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(CreateGraph(clip), false, 1f);
var awaitable = player.PlayAsync(clip.Id);
player.Stop();
Assert.That(awaitable.keepWaiting, Is.False);
Assert.That(
awaitable.PlaybackHandle.Result.Reason,
Is.EqualTo(FrameAnimationCompletionReason.Stopped));
}
private FrameAnimationGraph CreateFlowGraph(
FrameClip intro,
FrameClip terminal,
AnimationFlowAsyncCompletionMode asyncCompletionMode)
{
var introNode = new AnimationNode(intro.Id);
var terminalNode = new AnimationNode(terminal.Id);
var flow = new AnimationFlow(
"Flow",
"Flow",
introNode.InternalId,
null,
asyncCompletionMode);
return CreateGraph(
new[] { intro, terminal },
new[] { introNode, terminalNode },
new[] { new AnimationEdge(introNode.InternalId, terminalNode.InternalId) },
new[] { flow },
flow.Id);
}
private FrameClip CreateClip( private FrameClip CreateClip(
string id, string id,
FrameClipEndBehavior endBehavior, FrameClipEndBehavior endBehavior,
@@ -186,15 +375,30 @@ namespace AibisDream.FrameAnimation.Tests.PlayMode
private FrameAnimationGraph CreateGraph(params FrameClip[] clips) private FrameAnimationGraph CreateGraph(params FrameClip[] clips)
{ {
var graph = Track(ScriptableObject.CreateInstance<FrameAnimationGraph>()); return CreateGraph(
graph.Configure(
"Graph",
"Graph",
clips, clips,
Array.Empty<AnimationNode>(), Array.Empty<AnimationNode>(),
Array.Empty<AnimationEdge>(), Array.Empty<AnimationEdge>(),
Array.Empty<AnimationFlow>(), Array.Empty<AnimationFlow>(),
clips[0].Id); clips[0].Id);
}
private FrameAnimationGraph CreateGraph(
FrameClip[] clips,
AnimationNode[] nodes,
AnimationEdge[] edges,
AnimationFlow[] flows,
string defaultPlayableId)
{
var graph = Track(ScriptableObject.CreateInstance<FrameAnimationGraph>());
graph.Configure(
"Graph",
"Graph",
clips,
nodes,
edges,
flows,
defaultPlayableId);
return graph; return graph;
} }
+2 -2
View File
@@ -25,13 +25,13 @@ Graph 必须通过 `FrameAnimationGraphValidator` 校验。帧动画角色不会
<<change_actor_state Idle 测试角色>> <<change_actor_state Idle 测试角色>>
``` ```
等待一次性动画播放完成,或等待 Loop playable 完成首轮 等待一次性动画播放完成,或按 Flow 配置等待 Loop
```yarn ```yarn
<<change_actor_state_async Intro 测试角色>> <<change_actor_state_async Intro 测试角色>>
``` ```
`init_actor` 会等待 Prefab 和 Graph 加载完成,因此紧随其后的状态切换可以安全执行。`change_actor_state_async` 播放 Loop playable 时,会在直接 Loop Clip 完成第一轮后继续执行 Yarn对于“前置节点 → 终点 Loop”的 Flow,等待前置节点终点 Loop 第一轮全部完成。命令返回后动画仍会继续循环,直到被新状态替换或主动停止。无需等待首轮时仍可使用 `change_actor_state` 立即继续 Yarn。 `init_actor` 会等待 Prefab 和 Graph 加载完成,因此紧随其后的状态切换可以安全执行。直接异步播放 Loop Clip 时,完成第一轮后继续执行 Yarn对于“前置节点 → 终点 Loop”的 Flow,等待时机由 Flow 的 **Async Completion** 决定:默认在前置节点结束、终点 Loop 首帧显示后继续;也可以配置为等待终点 Loop 完整播放第一轮。命令返回后动画仍会继续循环,直到被新状态替换或主动停止。完全无需等待时仍可使用 `change_actor_state` 立即继续 Yarn。
## 存档行为 ## 存档行为
+16
View File
@@ -447,18 +447,21 @@ FrameAnimationGraphEditorData
string id; string id;
string displayName; string displayName;
string entryNodeId; string entryNodeId;
AnimationFlowAsyncCompletionMode asyncCompletionMode;
FrameClipEndBehavior? endBehaviorOverride; FrameClipEndBehavior? endBehaviorOverride;
``` ```
说明: 说明:
- `entryNodeId` 保存入口 AnimationNode 的 `internalId` - `entryNodeId` 保存入口 AnimationNode 的 `internalId`
- `asyncCompletionMode` 只控制终点为 Loop 时的 `PlayAsync` 等待边界:进入终点并显示首帧,或等待终点首轮回绕;零值默认采用前者。
- `endBehaviorOverride` 是 Flow 到达终点时的可选结束行为覆盖。 - `endBehaviorOverride` 是 Flow 到达终点时的可选结束行为覆盖。
- Flow 包含的节点集合从 `entryNodeId` 沿全局边关系遍历得到,不单独保存 `includedNodeIds` - Flow 包含的节点集合从 `entryNodeId` 沿全局边关系遍历得到,不单独保存 `includedNodeIds`
- 编辑器高亮、Flow 聚焦、校验和运行时使用同一套可达关系,避免人工维护的节点集合与实际连线不一致。 - 编辑器高亮、Flow 聚焦、校验和运行时使用同一套可达关系,避免人工维护的节点集合与实际连线不一致。
- 多个 Flow 的可达范围可以重叠,因此可以自然共享 Idle 等公共节点。 - 多个 Flow 的可达范围可以重叠,因此可以自然共享 Idle 等公共节点。
- 同一个 AnimationNode 不能作为多个 Flow 的入口,但不同 Flow 可以在后续路径中共享该节点。 - 同一个 AnimationNode 不能作为多个 Flow 的入口,但不同 Flow 可以在后续路径中共享该节点。
- Flow id 属于统一可播放命名空间,不能与任何 Clip 或其他 Flow id 重名。 - Flow id 属于统一可播放命名空间,不能与任何 Clip 或其他 Flow id 重名。
- 终点为非 Loop 时忽略 `asyncCompletionMode`,异步播放始终等待 Flow 自然结束。
#### 4.3.2 AnimationNode 数据 #### 4.3.2 AnimationNode 数据
@@ -881,6 +884,7 @@ Flow 创建与入口管理:
- Flow 只能通过“选中一个 Clip 节点 -> 从选中节点创建 Flow”建立。 - Flow 只能通过“选中一个 Clip 节点 -> 从选中节点创建 Flow”建立。
- 创建时必须且只能选中一个节点,编辑器自动将该节点写入 `entryNodeId` - 创建时必须且只能选中一个节点,编辑器自动将该节点写入 `entryNodeId`
- Flow id 默认取节点名称,但创建前允许修改;它必须与所有 Clip / Flow playable id 唯一。 - Flow id 默认取节点名称,但创建前允许修改;它必须与所有 Clip / Flow playable id 唯一。
- Flow 属性区提供 `Async Completion` 配置,并说明它只影响终点为 Loop 的异步播放。
- 同一个节点不能作为多个 Flow 的入口;节点已是入口时禁用创建命令并提供定位现有 Flow 的操作。 - 同一个节点不能作为多个 Flow 的入口;节点已是入口时禁用创建命令并提供定位现有 Flow 的操作。
- Flow 入口允许存在前驱节点;从该 Flow 播放时直接从入口开始,入口之前的节点不属于其可达范围。 - Flow 入口允许存在前驱节点;从该 Flow 播放时直接从入口开始,入口之前的节点不属于其可达范围。
- 修改入口时,先选中目标节点并执行“设置为当前 Flow 入口”;若目标已是其他 Flow 入口则阻止修改。 - 修改入口时,先选中目标节点并执行“设置为当前 Flow 入口”;若目标已是其他 Flow 入口则阻止修改。
@@ -1215,6 +1219,15 @@ FrameAnimationPlaybackHandle : CustomYieldInstruction
void RegisterCompleted(Action<FrameAnimationPlaybackResult> callback); void RegisterCompleted(Action<FrameAnimationPlaybackResult> callback);
} }
FrameAnimationPlaybackAwaitable : CustomYieldInstruction
{
FrameAnimationPlaybackHandle PlaybackHandle { get; }
}
FrameAnimationPlaybackAwaitable PlayAsync();
FrameAnimationPlaybackAwaitable PlayAsync(string playableId);
FrameAnimationPlaybackAwaitable PlayAsync(string playableId, FrameAnimationPlayOptions options);
FrameAnimationPlaybackResult FrameAnimationPlaybackResult
{ {
long requestId; long requestId;
@@ -1254,6 +1267,9 @@ FrameAnimationCompletionReason
要求: 要求:
- Handle 可以直接用于 `yield return handle` - Handle 可以直接用于 `yield return handle`
- `PlayAsync()` 调用时立即开始播放,返回值可直接用于 `yield return`;其 `PlaybackHandle` 仍表示底层播放请求的完整生命周期。
- 非循环 playable 的异步等待随播放自然结束。直接 Loop Clip 固定等待第一轮;终点为 Loop 的 Flow 按 `AnimationFlow.AsyncCompletionMode` 在终点 Loop 首帧显示后或第一轮回绕后解除等待。
- Awaitable 解除等待不等于底层 Loop 播放完成;Loop 的 `PlaybackHandle` 继续保持未完成,直到被替换、停止、禁用或销毁。
- `WaitAsync()` 使用标准 `Task`,项目第一版不引入 UniTask 依赖。 - `WaitAsync()` 使用标准 `Task`,项目第一版不引入 UniTask 依赖。
- `RegisterCompleted()` 是正式完成通知接口。请求尚未完成时登记回调;请求已经完成时立即用既有结果调用,避免同步失败或极短动画造成通知丢失。 - `RegisterCompleted()` 是正式完成通知接口。请求尚未完成时登记回调;请求已经完成时立即用既有结果调用,避免同步失败或极短动画造成通知丢失。
- 同一个播放请求只能从未完成状态结算一次;协程、Task 和回调必须观察到同一个结果。 - 同一个播放请求只能从未完成状态结算一次;协程、Task 和回调必须观察到同一个结果。
@@ -86,6 +86,15 @@ Flow ID 就是 Yarn 中填写的动画名。建议使用有明确含义且不易
3. 点击顶部 **Validate**,底部 **Validation** 中不能有 Error。 3. 点击顶部 **Validate**,底部 **Validation** 中不能有 Error。
4. 点击顶部 **Save** 保存。 4. 点击顶部 **Save** 保存。
### Flow 的异步完成配置
选择 Flow 后,可以在右侧 **Animation Flow** 区域设置 **Async Completion**。该配置只在 Flow 的终点为 `Loop` 时生效:
- `Complete On Terminal Loop Start`:默认值。所有前置节点播放完成并显示终点 Loop 第一帧后,异步调用继续执行;终点 Loop 在后台持续循环。
- `Wait For Terminal Loop First Cycle`:等待前置节点和终点 Loop 第一轮全部播放完成后,异步调用继续执行。
终点不是 `Loop` 时,两种配置行为相同,都会等待整条 Flow 自然结束。
## 4. Yarn 调用 ## 4. Yarn 调用
### 初始化角色 ### 初始化角色
@@ -163,17 +172,17 @@ hs: 戴上“实实”牌帽子,给你的头顶添件宝!
Yarn 会立即继续对白,Flow 则在后台按照连线依次播放。适合整段表演与对白同时发生的情况。 Yarn 会立即继续对白,Flow 则在后台按照连线依次播放。适合整段表演与对白同时发生的情况。
#### Flow播放到终点首轮后再继续 Yarn #### Flow按 Flow 配置等待后再继续 Yarn
```yarn ```yarn
<<change_actor_state_async 摘帽到伸手_Flow 火山>> <<change_actor_state_async 摘帽到伸手_Flow 火山>>
// Flow 的前置动作终点 Loop 首轮播放完后,才执行这里 // 默认在前置动作结束、终点 Loop 首帧显示后执行这里
``` ```
`change_actor_state_async` 会从入口开始等待整条 Flow `change_actor_state_async` 会从入口开始等待整条 Flow
- 终点为非循环 Clip:等待所有节点自然播放结束; - 终点为非循环 Clip:等待所有节点自然播放结束;
- `前置动作 → 终点 Loop` 的 Flow等待前置动作和终点 Loop 的第一轮全部结束。 - `前置动作 → 终点 Loop` 的 Flow根据 **Async Completion**,在终点 Loop 开始时完成,或等待其第一轮结束。
如果终点是 `Loop`,命令返回后终点 Clip 仍会继续循环,直到被下一次状态切换替换。 如果终点是 `Loop`,命令返回后终点 Clip 仍会继续循环,直到被下一次状态切换替换。
@@ -184,7 +193,7 @@ Yarn 会立即继续对白,Flow 则在后台按照连线依次播放。适合
| 单个 Clip | 不等待 | `<<change_actor_state ClipID 角色名>>` | | 单个 Clip | 不等待 | `<<change_actor_state ClipID 角色名>>` |
| 单个 Clip | 等完整动画;Loop 等第一轮 | `<<change_actor_state_async ClipID 角色名>>` | | 单个 Clip | 等完整动画;Loop 等第一轮 | `<<change_actor_state_async ClipID 角色名>>` |
| 一整条 Flow | 不等待 | `<<change_actor_state FlowID 角色名>>` | | 一整条 Flow | 不等待 | `<<change_actor_state FlowID 角色名>>` |
| 一整条 Flow | 等到终点首轮完成 | `<<change_actor_state_async FlowID 角色名>>` | | 一整条 Flow | 按 Flow 的 Async Completion 等待 | `<<change_actor_state_async FlowID 角色名>>` |
命令本身不需要标明目标是 Clip 还是 Flow。系统会用 ID 在当前角色的 Graph 中查找;因此 Clip ID 和 Flow ID 不能重名。 命令本身不需要标明目标是 Clip 还是 Flow。系统会用 ID 在当前角色的 Graph 中查找;因此 Clip ID 和 Flow ID 不能重名。
@@ -212,7 +221,7 @@ Clip ID、Flow ID、角色名或槽位名中包含空格时,必须加英文双
hs: 医——生——救——我——! hs: 医——生——救——我——!
// 调用 Flow:等“摘帽”播完,再等终点“伸手表情idle”完成第一轮 // 调用 Flow默认等“摘帽”播完,并显示终点“伸手表情idle”首帧
<<change_actor_state_async 摘帽到伸手_Flow 火山>> <<change_actor_state_async 摘帽到伸手_Flow 火山>>
// Flow 返回后,终点的伸手 idle 仍在循环 // Flow 返回后,终点的伸手 idle 仍在循环
@@ -233,6 +242,7 @@ hs: 我……没有活干了。
- 只有最后一个 Node 设置了结束行为吗? - 只有最后一个 Node 设置了结束行为吗?
- 需要持续显示的 idle 是否设置为 `Loop` - 需要持续显示的 idle 是否设置为 `Loop`
- Flow ID 是否与已有 Clip / Flow 重名? - Flow ID 是否与已有 Clip / Flow 重名?
- Flow 的 **Async Completion** 是否符合剧情节奏?
- Yarn 中的 ID 与 Graph 完全一致,包括空格和大小写吗? - Yarn 中的 ID 与 Graph 完全一致,包括空格和大小写吗?
- 需要等动画时是否用了 `change_actor_state_async` - 需要等动画时是否用了 `change_actor_state_async`
- 顶部 **Validate** 是否无 Error - 顶部 **Validate** 是否无 Error
@@ -254,7 +264,7 @@ Yarn 中传入的是 Clip ID 或 Flow ID,不是资源文件名、Graph 名或
**循环动画导致剧情无法继续** **循环动画导致剧情无法继续**
使用 `change_actor_state_async` 等待 Loop 时,只会等待第一轮,不会无限阻塞。如果仍未继续,先运行 **Validate** 检查 Flow 路径和结尾配置。 直接异步播放 Loop Clip 时只等待第一轮。异步播放 Flow 时,默认进入终点 Loop 即继续,也可以通过 **Async Completion** 配置为等待终点 Loop 第一轮。如果仍未继续,先检查是否还停在前置节点、有效速度是否为 0,并运行 **Validate** 检查 Flow 路径和结尾配置。
**想让 Flow 中途停住** **想让 Flow 中途停住**