From 121e7f398c8c5c206e095a13de7f5ebf5c87058e Mon Sep 17 00:00:00 2001 From: Ding Yuntian <1491671119@qq.com> Date: Wed, 5 Aug 2026 21:14:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20FlowAsync=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FrameAnimationGraphEditorWindow.cs | 16 ++ .../Runtime/FrameAnimationData.cs | 13 +- .../Runtime/FrameAnimationPlaybackHandle.cs | 19 ++ .../Runtime/FrameAnimationPlaybackSession.cs | 25 ++ .../Runtime/FrameAnimationPlayer.cs | 55 +++-- .../Runtime/FrameAnimationResolver.cs | 15 +- .../Runtime/FrameAnimationTypes.cs | 6 + .../ActorKit/FrameAnimationActor.cs | 60 +++-- .../EditMode/FrameAnimationCoreTests.cs | 107 ++++++++- .../PlayMode/FrameAnimationPlayerTests.cs | 214 +++++++++++++++++- Docs/FrameAnimationActor接入说明.md | 4 +- Docs/动画系统需求整理.md | 16 ++ Docs/帧动画角色配置指南(策划版).md | 22 +- 13 files changed, 513 insertions(+), 59 deletions(-) diff --git a/Assets/Editor/FrameAnimation/FrameAnimationGraphEditorWindow.cs b/Assets/Editor/FrameAnimation/FrameAnimationGraphEditorWindow.cs index 1a21517d3..2f3d510df 100644 --- a/Assets/Editor/FrameAnimation/FrameAnimationGraphEditorWindow.cs +++ b/Assets/Editor/FrameAnimation/FrameAnimationGraphEditorWindow.cs @@ -1803,6 +1803,14 @@ namespace AibisDream.FrameAnimation.Editor AddReadOnly(body, "ID", flow.Id); AddReadOnly(body, "Entry Node", flow.EntryNodeId); 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("endBehaviorOverride"), "End Behavior"); @@ -2513,6 +2521,14 @@ namespace AibisDream.FrameAnimation.Editor EditorGUILayout.PropertyField(property.FindPropertyRelative("entryNodeId")); } 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")); if (property.FindPropertyRelative("hasEndBehaviorOverride").boolValue) { diff --git a/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationData.cs b/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationData.cs index 078b49095..88a813889 100644 --- a/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationData.cs +++ b/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationData.cs @@ -90,12 +90,15 @@ namespace AibisDream.FrameAnimation [SerializeField] private string id = string.Empty; [SerializeField] private string displayName = string.Empty; [SerializeField] private string entryNodeId = string.Empty; + [SerializeField] private AnimationFlowAsyncCompletionMode asyncCompletionMode = + AnimationFlowAsyncCompletionMode.CompleteOnTerminalLoopStart; [SerializeField] private bool hasEndBehaviorOverride; [SerializeField] private FrameClipEndBehavior endBehaviorOverride; public string Id => id; public string DisplayName => displayName; public string EntryNodeId => entryNodeId; + public AnimationFlowAsyncCompletionMode AsyncCompletionMode => asyncCompletionMode; public FrameClipEndBehavior? EndBehaviorOverride => hasEndBehaviorOverride ? endBehaviorOverride : (FrameClipEndBehavior?)null; @@ -107,11 +110,14 @@ namespace AibisDream.FrameAnimation string id, string displayName, string entryNodeId, - FrameClipEndBehavior? endBehaviorOverride = null) + FrameClipEndBehavior? endBehaviorOverride = null, + AnimationFlowAsyncCompletionMode asyncCompletionMode = + AnimationFlowAsyncCompletionMode.CompleteOnTerminalLoopStart) { this.id = id ?? string.Empty; this.displayName = displayName ?? this.id; this.entryNodeId = entryNodeId ?? string.Empty; + this.asyncCompletionMode = asyncCompletionMode; SetEndBehaviorOverride(endBehaviorOverride); } @@ -135,6 +141,11 @@ namespace AibisDream.FrameAnimation { entryNodeId = value ?? string.Empty; } + + internal void SetAsyncCompletionMode(AnimationFlowAsyncCompletionMode value) + { + asyncCompletionMode = value; + } } [Serializable] diff --git a/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlaybackHandle.cs b/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlaybackHandle.cs index 285e84f13..dea333f27 100644 --- a/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlaybackHandle.cs +++ b/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlaybackHandle.cs @@ -5,6 +5,25 @@ using UnityEngine; 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 { private readonly TaskCompletionSource completionSource = diff --git a/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlaybackSession.cs b/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlaybackSession.cs index 88a72f854..761edc9f7 100644 --- a/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlaybackSession.cs +++ b/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlaybackSession.cs @@ -97,6 +97,31 @@ namespace AibisDream.FrameAnimation public int CurrentFrameIndex => hasStarted && !hasCompleted ? frameIndex : -1; public FrameClip CurrentClip => CurrentStep?.Clip; 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 => new FrameAnimationPlaybackSnapshot( hasStarted, diff --git a/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlayer.cs b/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlayer.cs index 90936b2ba..7299a3392 100644 --- a/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlayer.cs +++ b/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlayer.cs @@ -138,18 +138,22 @@ namespace AibisDream.FrameAnimation return PlayInternal(playableId, options); } - /// - /// 等待当前播放请求完成首轮。非循环请求等待自然结束;终点为 Loop 的请求在 - /// 第一次回绕后结束等待,但播放请求本身保持活动并继续循环。 - /// - public CustomYieldInstruction WaitForFirstPass(FrameAnimationPlaybackHandle playbackHandle) + public FrameAnimationPlaybackAwaitable PlayAsync() { - if (playbackHandle == null) - { - throw new ArgumentNullException(nameof(playbackHandle)); - } + var playableId = graph?.Settings?.DefaultPlayableId ?? string.Empty; + return PlayAsyncInternal(playableId, default); + } - 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( @@ -404,6 +408,15 @@ namespace AibisDream.FrameAnimation return handle; } + private FrameAnimationPlaybackAwaitable PlayAsyncInternal( + string playableId, + FrameAnimationPlayOptions options) + { + return new FrameAnimationPlaybackAwaitable( + this, + PlayInternal(playableId, options)); + } + private bool TryCreateTarget( out IFrameAnimationTarget resolvedTarget, out FrameAnimationPlaybackError error) @@ -527,31 +540,15 @@ namespace AibisDream.FrameAnimation private Action ApplySpriteCallback => applySpriteCallback ??= ApplySprite; - private bool ShouldWaitForFirstPass(FrameAnimationPlaybackHandle playbackHandle) + internal bool ShouldWaitForAsyncCompletion( + FrameAnimationPlaybackHandle playbackHandle) { if (playbackHandle.IsCompleted || activeHandle != playbackHandle || session == null) { return false; } - return !session.HasCompletedFirstTerminalLoopCycle; - } - - 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); + return !session.HasReachedAsyncCompletionPoint; } private void ApplyEndBehavior(FrameClipEndBehavior endBehavior) diff --git a/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationResolver.cs b/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationResolver.cs index 13d604f4c..1de47cf53 100644 --- a/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationResolver.cs +++ b/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationResolver.cs @@ -28,12 +28,19 @@ namespace AibisDream.FrameAnimation public string PlayableId { get; } public bool IsFlow { get; } public IReadOnlyList Steps { get; } + public AnimationFlowAsyncCompletionMode AsyncCompletionMode { get; } - public ResolvedPlaybackPlan(string playableId, bool isFlow, IReadOnlyList steps) + public ResolvedPlaybackPlan( + string playableId, + bool isFlow, + IReadOnlyList steps, + AnimationFlowAsyncCompletionMode asyncCompletionMode = + AnimationFlowAsyncCompletionMode.WaitForTerminalLoopFirstCycle) { PlayableId = playableId; IsFlow = isFlow; Steps = steps; + AsyncCompletionMode = asyncCompletionMode; } } @@ -278,7 +285,11 @@ namespace AibisDream.FrameAnimation currentNodeId = edge.ToNodeId; } - plan = new ResolvedPlaybackPlan(flow.Id, true, steps); + plan = new ResolvedPlaybackPlan( + flow.Id, + true, + steps, + flow.AsyncCompletionMode); return true; } diff --git a/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationTypes.cs b/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationTypes.cs index bac1fe483..e929bff4d 100644 --- a/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationTypes.cs +++ b/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationTypes.cs @@ -20,6 +20,12 @@ namespace AibisDream.FrameAnimation Always } + public enum AnimationFlowAsyncCompletionMode + { + CompleteOnTerminalLoopStart = 0, + WaitForTerminalLoopFirstCycle = 1 + } + public enum FrameAnimationPlaybackState { Stopped, diff --git a/Assets/Scripts/SceneManagement/ActorKit/FrameAnimationActor.cs b/Assets/Scripts/SceneManagement/ActorKit/FrameAnimationActor.cs index 70ae952e6..40225e5e8 100644 --- a/Assets/Scripts/SceneManagement/ActorKit/FrameAnimationActor.cs +++ b/Assets/Scripts/SceneManagement/ActorKit/FrameAnimationActor.cs @@ -95,12 +95,13 @@ namespace AibisDream public override IEnumerator ChangeStateAsync(string stateName) { - if (!TryPlay(stateName, false, out var handle)) + if (!TryPlayAsync(stateName, out var awaitable)) { yield break; } - yield return _player.WaitForFirstPass(handle); + yield return awaitable; + var handle = awaitable.PlaybackHandle; if (handle.IsCompleted) { LogFailedResult(stateName, handle.Result); @@ -141,6 +142,50 @@ namespace AibisDream out FrameAnimationPlaybackHandle handle) { 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) { Debug.LogError( @@ -157,17 +202,6 @@ namespace AibisDream 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; } diff --git a/Assets/Tests/FrameAnimation/EditMode/FrameAnimationCoreTests.cs b/Assets/Tests/FrameAnimation/EditMode/FrameAnimationCoreTests.cs index f73b1902b..263e5db65 100644 --- a/Assets/Tests/FrameAnimation/EditMode/FrameAnimationCoreTests.cs +++ b/Assets/Tests/FrameAnimation/EditMode/FrameAnimationCoreTests.cs @@ -36,7 +36,8 @@ namespace AibisDream.FrameAnimation.Tests.EditMode "Flow", "Flow", node.InternalId, - FrameClipEndBehavior.Clear); + FrameClipEndBehavior.Clear, + AnimationFlowAsyncCompletionMode.WaitForTerminalLoopFirstCycle); var graph = CreateGraph( new[] { clip }, new[] { node }, @@ -55,6 +56,9 @@ namespace AibisDream.FrameAnimation.Tests.EditMode Assert.That( clone.Flows[0].EndBehaviorOverride, Is.EqualTo(FrameClipEndBehavior.Clear)); + Assert.That( + clone.Flows[0].AsyncCompletionMode, + Is.EqualTo(AnimationFlowAsyncCompletionMode.WaitForTerminalLoopFirstCycle)); } [Test] @@ -126,6 +130,107 @@ namespace AibisDream.FrameAnimation.Tests.EditMode Assert.That(session.CurrentClipId, Is.EqualTo(idle.Id)); Assert.That(session.CurrentNodeId, Is.EqualTo(idleNode.InternalId)); 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] diff --git a/Assets/Tests/FrameAnimation/PlayMode/FrameAnimationPlayerTests.cs b/Assets/Tests/FrameAnimation/PlayMode/FrameAnimationPlayerTests.cs index deff81af9..83a2aeed2 100644 --- a/Assets/Tests/FrameAnimation/PlayMode/FrameAnimationPlayerTests.cs +++ b/Assets/Tests/FrameAnimation/PlayMode/FrameAnimationPlayerTests.cs @@ -112,6 +112,151 @@ namespace AibisDream.FrameAnimation.Tests.PlayMode 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(); + var completeOnStartPlayer = completeOnStartObject.AddComponent(); + 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(); + var waitForCyclePlayer = waitForCycleObject.AddComponent(); + 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(), + new[] { flow }, + flow.Id); + var gameObject = Track(new GameObject("Single Loop Flow")); + var renderer = gameObject.AddComponent(); + var player = gameObject.AddComponent(); + 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(); + var player = gameObject.AddComponent(); + 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(); + var player = gameObject.AddComponent(); + 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(); + var player = gameObject.AddComponent(); + 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] public void RestoreTerminalState_RebuildsSavedVisualState() { @@ -154,16 +299,60 @@ namespace AibisDream.FrameAnimation.Tests.PlayMode gameObject.AddComponent(); var player = gameObject.AddComponent(); 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; yield return null; Assert.That(handle.Result.Reason, Is.EqualTo(FrameAnimationCompletionReason.Stopped)); + Assert.That(awaitable.keepWaiting, Is.False); 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(); + var player = gameObject.AddComponent(); + 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( string id, FrameClipEndBehavior endBehavior, @@ -186,15 +375,30 @@ namespace AibisDream.FrameAnimation.Tests.PlayMode private FrameAnimationGraph CreateGraph(params FrameClip[] clips) { - var graph = Track(ScriptableObject.CreateInstance()); - graph.Configure( - "Graph", - "Graph", + return CreateGraph( clips, Array.Empty(), Array.Empty(), Array.Empty(), clips[0].Id); + } + + private FrameAnimationGraph CreateGraph( + FrameClip[] clips, + AnimationNode[] nodes, + AnimationEdge[] edges, + AnimationFlow[] flows, + string defaultPlayableId) + { + var graph = Track(ScriptableObject.CreateInstance()); + graph.Configure( + "Graph", + "Graph", + clips, + nodes, + edges, + flows, + defaultPlayableId); return graph; } diff --git a/Docs/FrameAnimationActor接入说明.md b/Docs/FrameAnimationActor接入说明.md index 50d809d33..e979c91e6 100644 --- a/Docs/FrameAnimationActor接入说明.md +++ b/Docs/FrameAnimationActor接入说明.md @@ -25,13 +25,13 @@ Graph 必须通过 `FrameAnimationGraphValidator` 校验。帧动画角色不会 <> ``` -等待一次性动画播放完成,或等待 Loop playable 完成首轮: +等待一次性动画播放完成,或按 Flow 配置等待 Loop: ```yarn <> ``` -`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。 ## 存档行为 diff --git a/Docs/动画系统需求整理.md b/Docs/动画系统需求整理.md index 3a5ad36bd..7937e6415 100644 --- a/Docs/动画系统需求整理.md +++ b/Docs/动画系统需求整理.md @@ -447,18 +447,21 @@ FrameAnimationGraphEditorData string id; string displayName; string entryNodeId; +AnimationFlowAsyncCompletionMode asyncCompletionMode; FrameClipEndBehavior? endBehaviorOverride; ``` 说明: - `entryNodeId` 保存入口 AnimationNode 的 `internalId`。 +- `asyncCompletionMode` 只控制终点为 Loop 时的 `PlayAsync` 等待边界:进入终点并显示首帧,或等待终点首轮回绕;零值默认采用前者。 - `endBehaviorOverride` 是 Flow 到达终点时的可选结束行为覆盖。 - Flow 包含的节点集合从 `entryNodeId` 沿全局边关系遍历得到,不单独保存 `includedNodeIds`。 - 编辑器高亮、Flow 聚焦、校验和运行时使用同一套可达关系,避免人工维护的节点集合与实际连线不一致。 - 多个 Flow 的可达范围可以重叠,因此可以自然共享 Idle 等公共节点。 - 同一个 AnimationNode 不能作为多个 Flow 的入口,但不同 Flow 可以在后续路径中共享该节点。 - Flow id 属于统一可播放命名空间,不能与任何 Clip 或其他 Flow id 重名。 +- 终点为非 Loop 时忽略 `asyncCompletionMode`,异步播放始终等待 Flow 自然结束。 #### 4.3.2 AnimationNode 数据 @@ -881,6 +884,7 @@ Flow 创建与入口管理: - Flow 只能通过“选中一个 Clip 节点 -> 从选中节点创建 Flow”建立。 - 创建时必须且只能选中一个节点,编辑器自动将该节点写入 `entryNodeId`。 - Flow id 默认取节点名称,但创建前允许修改;它必须与所有 Clip / Flow playable id 唯一。 +- Flow 属性区提供 `Async Completion` 配置,并说明它只影响终点为 Loop 的异步播放。 - 同一个节点不能作为多个 Flow 的入口;节点已是入口时禁用创建命令并提供定位现有 Flow 的操作。 - Flow 入口允许存在前驱节点;从该 Flow 播放时直接从入口开始,入口之前的节点不属于其可达范围。 - 修改入口时,先选中目标节点并执行“设置为当前 Flow 入口”;若目标已是其他 Flow 入口则阻止修改。 @@ -1215,6 +1219,15 @@ FrameAnimationPlaybackHandle : CustomYieldInstruction void RegisterCompleted(Action callback); } +FrameAnimationPlaybackAwaitable : CustomYieldInstruction +{ + FrameAnimationPlaybackHandle PlaybackHandle { get; } +} + +FrameAnimationPlaybackAwaitable PlayAsync(); +FrameAnimationPlaybackAwaitable PlayAsync(string playableId); +FrameAnimationPlaybackAwaitable PlayAsync(string playableId, FrameAnimationPlayOptions options); + FrameAnimationPlaybackResult { long requestId; @@ -1254,6 +1267,9 @@ FrameAnimationCompletionReason 要求: - Handle 可以直接用于 `yield return handle`。 +- `PlayAsync()` 调用时立即开始播放,返回值可直接用于 `yield return`;其 `PlaybackHandle` 仍表示底层播放请求的完整生命周期。 +- 非循环 playable 的异步等待随播放自然结束。直接 Loop Clip 固定等待第一轮;终点为 Loop 的 Flow 按 `AnimationFlow.AsyncCompletionMode` 在终点 Loop 首帧显示后或第一轮回绕后解除等待。 +- Awaitable 解除等待不等于底层 Loop 播放完成;Loop 的 `PlaybackHandle` 继续保持未完成,直到被替换、停止、禁用或销毁。 - `WaitAsync()` 使用标准 `Task`,项目第一版不引入 UniTask 依赖。 - `RegisterCompleted()` 是正式完成通知接口。请求尚未完成时登记回调;请求已经完成时立即用既有结果调用,避免同步失败或极短动画造成通知丢失。 - 同一个播放请求只能从未完成状态结算一次;协程、Task 和回调必须观察到同一个结果。 diff --git a/Docs/帧动画角色配置指南(策划版).md b/Docs/帧动画角色配置指南(策划版).md index 2830e95a0..3f3769d80 100644 --- a/Docs/帧动画角色配置指南(策划版).md +++ b/Docs/帧动画角色配置指南(策划版).md @@ -86,6 +86,15 @@ Flow ID 就是 Yarn 中填写的动画名。建议使用有明确含义且不易 3. 点击顶部 **Validate**,底部 **Validation** 中不能有 Error。 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 调用 ### 初始化角色 @@ -163,17 +172,17 @@ hs: 戴上“实实”牌帽子,给你的头顶添件宝! Yarn 会立即继续对白,Flow 则在后台按照连线依次播放。适合整段表演与对白同时发生的情况。 -#### Flow:播放到终点首轮后再继续 Yarn +#### Flow:按 Flow 配置等待后再继续 Yarn ```yarn <> -// Flow 的前置动作和终点 Loop 首轮播放完后,才执行这里 +// 默认在前置动作结束、终点 Loop 首帧显示后执行这里 ``` `change_actor_state_async` 会从入口开始等待整条 Flow: - 终点为非循环 Clip:等待所有节点自然播放结束; -- `前置动作 → 终点 Loop` 的 Flow:等待前置动作和终点 Loop 的第一轮全部结束。 +- `前置动作 → 终点 Loop` 的 Flow:根据 **Async Completion**,在终点 Loop 开始时完成,或等待其第一轮结束。 如果终点是 `Loop`,命令返回后终点 Clip 仍会继续循环,直到被下一次状态切换替换。 @@ -184,7 +193,7 @@ Yarn 会立即继续对白,Flow 则在后台按照连线依次播放。适合 | 单个 Clip | 不等待 | `<>` | | 单个 Clip | 等完整动画;Loop 等第一轮 | `<>` | | 一整条 Flow | 不等待 | `<>` | -| 一整条 Flow | 等到终点首轮完成 | `<>` | +| 一整条 Flow | 按 Flow 的 Async Completion 等待 | `<>` | 命令本身不需要标明目标是 Clip 还是 Flow。系统会用 ID 在当前角色的 Graph 中查找;因此 Clip ID 和 Flow ID 不能重名。 @@ -212,7 +221,7 @@ Clip ID、Flow ID、角色名或槽位名中包含空格时,必须加英文双 hs: 医——生——救——我——! -// 调用 Flow:等“摘帽”播完,再等终点“伸手表情idle”完成第一轮 +// 调用 Flow:默认等“摘帽”播完,并显示终点“伸手表情idle”首帧 <> // Flow 返回后,终点的伸手 idle 仍在循环 @@ -233,6 +242,7 @@ hs: 我……没有活干了。 - 只有最后一个 Node 设置了结束行为吗? - 需要持续显示的 idle 是否设置为 `Loop`? - Flow ID 是否与已有 Clip / Flow 重名? +- Flow 的 **Async Completion** 是否符合剧情节奏? - Yarn 中的 ID 与 Graph 完全一致,包括空格和大小写吗? - 需要等动画时是否用了 `change_actor_state_async`? - 顶部 **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 中途停住**