diff --git a/Assets/GameContent/Huoshan/Actor/火山Graph.asset b/Assets/GameContent/Huoshan/Actor/火山Graph.asset index b987c1c7f..3692a9315 100644 --- a/Assets/GameContent/Huoshan/Actor/火山Graph.asset +++ b/Assets/GameContent/Huoshan/Actor/火山Graph.asset @@ -290,7 +290,7 @@ MonoBehaviour: frameName: "\u706B\u5C71\u50CF\u7D20\u7248 29.aseprite" sourceIndex: 29 speed: 1 - defaultEndBehavior: 1 + defaultEndBehavior: 0 hasImportInfo: 1 importInfo: importSourceId: d329feaf92a945f885e419c05824cd56 diff --git a/Assets/Scenes/Persistence.unity b/Assets/Scenes/Persistence.unity index 0b6faeaa4..c037d9b20 100644 --- a/Assets/Scenes/Persistence.unity +++ b/Assets/Scenes/Persistence.unity @@ -8477,13 +8477,13 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: - {fileID: 834154072} + - {fileID: 3100101002} - {fileID: 300534481} - {fileID: 2135943277} - {fileID: 1546742660} - {fileID: 923998313} - {fileID: 1867622714} - {fileID: 3100100002} - - {fileID: 3100101002} m_Father: {fileID: 1110722291} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} diff --git a/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlaybackSession.cs b/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlaybackSession.cs index bf095ea26..84881e0f1 100644 --- a/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlaybackSession.cs +++ b/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlaybackSession.cs @@ -88,12 +88,14 @@ namespace AibisDream.FrameAnimation private double frameElapsedSeconds; private bool hasStarted; private bool hasCompleted; + private bool hasCompletedFirstTerminalLoopCycle; public string PlayableId => plan.PlayableId; public string CurrentClipId => CurrentStep?.Clip != null ? CurrentStep.Clip.Id : string.Empty; public string CurrentNodeId => CurrentStep?.NodeId ?? string.Empty; public int CurrentFrameIndex => hasStarted && !hasCompleted ? frameIndex : -1; public FrameClip CurrentClip => CurrentStep?.Clip; + internal bool HasCompletedFirstTerminalLoopCycle => hasCompletedFirstTerminalLoopCycle; internal FrameAnimationPlaybackSnapshot Snapshot => new FrameAnimationPlaybackSnapshot( hasStarted, @@ -129,6 +131,7 @@ namespace AibisDream.FrameAnimation { hasStarted = true; hasCompleted = false; + hasCompletedFirstTerminalLoopCycle = false; stepIndex = 0; frameIndex = 0; frameElapsedSeconds = 0d; @@ -186,6 +189,7 @@ namespace AibisDream.FrameAnimation var cycleRealDuration = GetClipDurationSeconds(step.Clip) / effectiveSpeed; if (cycleRealDuration > Epsilon && remainingRealSeconds >= cycleRealDuration) { + hasCompletedFirstTerminalLoopCycle = true; remainingRealSeconds %= cycleRealDuration; if (remainingRealSeconds <= Epsilon) { @@ -247,6 +251,7 @@ namespace AibisDream.FrameAnimation if (step.TerminalEndBehavior == FrameClipEndBehavior.Loop) { + hasCompletedFirstTerminalLoopCycle = true; frameIndex = 0; ApplyCurrentFrame(applySprite); return FrameAnimationSessionEvaluation.Running; diff --git a/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlayer.cs b/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlayer.cs index c8964f1bd..698a55b46 100644 --- a/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlayer.cs +++ b/Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlayer.cs @@ -1,3 +1,4 @@ +using System; using System.Threading; using UnityEngine; using UnityEngine.UI; @@ -136,6 +137,20 @@ namespace AibisDream.FrameAnimation return PlayInternal(playableId, options); } + /// + /// 等待当前播放请求完成首轮。非循环请求等待自然结束;终点为 Loop 的请求在 + /// 第一次回绕后结束等待,但播放请求本身保持活动并继续循环。 + /// + public CustomYieldInstruction WaitForFirstPass(FrameAnimationPlaybackHandle playbackHandle) + { + if (playbackHandle == null) + { + throw new ArgumentNullException(nameof(playbackHandle)); + } + + return new FirstPassYieldInstruction(this, playbackHandle); + } + public bool TryBindGraph( FrameAnimationGraph value, out FrameAnimationPlaybackError error) @@ -499,6 +514,33 @@ namespace AibisDream.FrameAnimation } } + private bool ShouldWaitForFirstPass(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); + } + private void ApplyEndBehavior(FrameClipEndBehavior endBehavior) { switch (endBehavior) diff --git a/Assets/Scripts/SceneManagement/ActorKit/FrameAnimationActor.cs b/Assets/Scripts/SceneManagement/ActorKit/FrameAnimationActor.cs index f9d737207..70ae952e6 100644 --- a/Assets/Scripts/SceneManagement/ActorKit/FrameAnimationActor.cs +++ b/Assets/Scripts/SceneManagement/ActorKit/FrameAnimationActor.cs @@ -100,8 +100,11 @@ namespace AibisDream yield break; } - yield return handle; - LogFailedResult(stateName, handle.Result); + yield return _player.WaitForFirstPass(handle); + if (handle.IsCompleted) + { + LogFailedResult(stateName, handle.Result); + } } public override ActorEntrySnapshotDto CaptureEntry() diff --git a/Assets/Tests/FrameAnimation/PlayMode/FrameAnimationPlayerTests.cs b/Assets/Tests/FrameAnimation/PlayMode/FrameAnimationPlayerTests.cs index e1987d3cb..ec366733b 100644 --- a/Assets/Tests/FrameAnimation/PlayMode/FrameAnimationPlayerTests.cs +++ b/Assets/Tests/FrameAnimation/PlayMode/FrameAnimationPlayerTests.cs @@ -114,6 +114,223 @@ namespace AibisDream.FrameAnimation.Tests.PlayMode Assert.That(player.CurrentPlayableId, Is.EqualTo(second.Id)); } + [Test] + public void WaitForFirstPass_DirectLoopReleasesAfterFirstWrapAndKeepsPlaying() + { + var first = CreateSprite(Color.red); + var second = CreateSprite(Color.blue); + var clip = CreateClip("Loop", FrameClipEndBehavior.Loop, first, second); + var graph = CreateDirectGraph(clip); + var gameObject = Track(new GameObject("Wait Direct Loop")); + var renderer = gameObject.AddComponent(); + var player = gameObject.AddComponent(); + player.ConfigureForAuthoring(graph, false, 1f); + + var handle = player.Play(clip.Id); + var wait = player.WaitForFirstPass(handle); + + Assert.That(wait.keepWaiting, Is.True); + player.EvaluateForTests(0.1d); + Assert.That(wait.keepWaiting, Is.True); + + player.EvaluateForTests(0.1d); + + Assert.That(wait.keepWaiting, Is.False); + Assert.That(handle.IsCompleted, Is.False); + Assert.That(player.State, Is.EqualTo(FrameAnimationPlaybackState.Playing)); + Assert.That(player.CurrentFrameIndex, Is.EqualTo(0)); + Assert.That(renderer.sprite, Is.SameAs(first)); + } + + [Test] + public void WaitForFirstPass_FlowWaitsForIntroAndOneTerminalLoopCycle() + { + var introClip = CreateClip( + "Intro", + FrameClipEndBehavior.HoldLastFrame, + CreateSprite(Color.red)); + var loopFirst = CreateSprite(Color.green); + var loopClip = CreateClip( + "Loop", + FrameClipEndBehavior.Loop, + loopFirst, + CreateSprite(Color.blue)); + var introNode = new AnimationNode(introClip.Id); + var loopNode = new AnimationNode(loopClip.Id); + var edge = new AnimationEdge(introNode.InternalId, loopNode.InternalId); + var flow = new AnimationFlow("Flow", "Flow", introNode.InternalId); + var graph = Track(ScriptableObject.CreateInstance()); + graph.Configure( + "Graph", + "Graph", + new[] { introClip, loopClip }, + new[] { introNode, loopNode }, + new[] { edge }, + new[] { flow }, + flow.Id); + var gameObject = Track(new GameObject("Wait Flow Loop")); + var renderer = gameObject.AddComponent(); + var player = gameObject.AddComponent(); + player.ConfigureForAuthoring(graph, false, 1f); + + var handle = player.Play(flow.Id); + var wait = player.WaitForFirstPass(handle); + + player.EvaluateForTests(0.1d); + Assert.That(player.CurrentClipId, Is.EqualTo(loopClip.Id)); + Assert.That(wait.keepWaiting, Is.True); + + player.EvaluateForTests(0.2d); + + Assert.That(wait.keepWaiting, Is.False); + Assert.That(handle.IsCompleted, Is.False); + Assert.That(player.State, Is.EqualTo(FrameAnimationPlaybackState.Playing)); + Assert.That(player.CurrentFrameIndex, Is.EqualTo(0)); + Assert.That(renderer.sprite, Is.SameAs(loopFirst)); + } + + [Test] + public void WaitForFirstPass_LargeDeltaCrossesIntroAndTerminalLoopCycle() + { + var introClip = CreateClip( + "Intro", + FrameClipEndBehavior.HoldLastFrame, + CreateSprite(Color.red)); + var loopClip = CreateClip( + "Loop", + FrameClipEndBehavior.Loop, + CreateSprite(Color.green), + CreateSprite(Color.blue)); + var introNode = new AnimationNode(introClip.Id); + var loopNode = new AnimationNode(loopClip.Id); + var graph = Track(ScriptableObject.CreateInstance()); + graph.Configure( + "Graph", + "Graph", + new[] { introClip, loopClip }, + new[] { introNode, loopNode }, + new[] { new AnimationEdge(introNode.InternalId, loopNode.InternalId) }, + new[] { new AnimationFlow("Flow", "Flow", introNode.InternalId) }, + "Flow"); + var gameObject = Track(new GameObject("Wait Large Delta")); + gameObject.AddComponent(); + var player = gameObject.AddComponent(); + player.ConfigureForAuthoring(graph, false, 1f); + + var handle = player.Play("Flow"); + var wait = player.WaitForFirstPass(handle); + player.EvaluateForTests(1.35d); + + Assert.That(wait.keepWaiting, Is.False); + Assert.That(handle.IsCompleted, Is.False); + Assert.That(player.State, Is.EqualTo(FrameAnimationPlaybackState.Playing)); + } + + [Test] + public void WaitForFirstPass_NonLoopMirrorsNaturalCompletion() + { + var clip = CreateClip( + "Once", + FrameClipEndBehavior.HoldLastFrame, + CreateSprite(Color.red), + CreateSprite(Color.blue)); + var graph = CreateDirectGraph(clip); + var gameObject = Track(new GameObject("Wait Once")); + gameObject.AddComponent(); + var player = gameObject.AddComponent(); + player.ConfigureForAuthoring(graph, false, 1f); + + var handle = player.Play(clip.Id); + var wait = player.WaitForFirstPass(handle); + player.EvaluateForTests(0.2d); + + Assert.That(wait.keepWaiting, Is.False); + Assert.That(handle.Result.Reason, Is.EqualTo(FrameAnimationCompletionReason.Completed)); + Assert.That(player.State, Is.EqualTo(FrameAnimationPlaybackState.Stopped)); + } + + [Test] + public void WaitForFirstPass_ReleasesWhenRequestIsReplacedOrStopped() + { + var first = CreateClip("First", FrameClipEndBehavior.Loop, CreateSprite(Color.red)); + var second = CreateClip("Second", FrameClipEndBehavior.Loop, CreateSprite(Color.blue)); + var graph = Track(ScriptableObject.CreateInstance()); + graph.Configure( + "Graph", + "Graph", + new[] { first, second }, + Array.Empty(), + Array.Empty(), + Array.Empty(), + first.Id); + var gameObject = Track(new GameObject("Wait Termination")); + gameObject.AddComponent(); + var player = gameObject.AddComponent(); + player.ConfigureForAuthoring(graph, false, 1f); + + var replacedHandle = player.Play(first.Id); + var replacedWait = player.WaitForFirstPass(replacedHandle); + var stoppedHandle = player.Play(second.Id); + var stoppedWait = player.WaitForFirstPass(stoppedHandle); + + Assert.That(replacedWait.keepWaiting, Is.False); + Assert.That(replacedHandle.Result.Reason, Is.EqualTo(FrameAnimationCompletionReason.Replaced)); + Assert.That(stoppedWait.keepWaiting, Is.True); + + player.Stop(); + + Assert.That(stoppedWait.keepWaiting, Is.False); + Assert.That(stoppedHandle.Result.Reason, Is.EqualTo(FrameAnimationCompletionReason.Stopped)); + } + + [Test] + public void WaitForFirstPass_PauseAndZeroSpeedDoNotReleaseEarly() + { + var clip = CreateClip( + "Loop", + FrameClipEndBehavior.Loop, + CreateSprite(Color.red), + CreateSprite(Color.blue)); + var graph = CreateDirectGraph(clip); + var gameObject = Track(new GameObject("Wait Paused")); + gameObject.AddComponent(); + var player = gameObject.AddComponent(); + player.ConfigureForAuthoring(graph, false, 0f); + + var handle = player.Play(clip.Id); + var wait = player.WaitForFirstPass(handle); + player.EvaluateForTests(1d); + Assert.That(wait.keepWaiting, Is.True); + + player.SetSpeed(1f); + player.Pause(); + player.EvaluateForTests(1d); + Assert.That(wait.keepWaiting, Is.True); + + player.Resume(); + player.EvaluateForTests(0.2d); + Assert.That(wait.keepWaiting, Is.False); + Assert.That(handle.IsCompleted, Is.False); + } + + [Test] + public void WaitForFirstPass_FailedRequestDoesNotWait() + { + var clip = CreateClip("Loop", FrameClipEndBehavior.Loop, CreateSprite(Color.white)); + var graph = CreateDirectGraph(clip); + var gameObject = Track(new GameObject("Wait Failed")); + gameObject.AddComponent(); + var player = gameObject.AddComponent(); + player.ConfigureForAuthoring(graph, false, 1f); + + var handle = player.Play("Missing"); + var wait = player.WaitForFirstPass(handle); + + Assert.That(wait.keepWaiting, Is.False); + Assert.That(handle.Result.Reason, Is.EqualTo(FrameAnimationCompletionReason.Failed)); + Assert.That(handle.Result.Error.Code, Is.EqualTo(FrameAnimationPlaybackErrorCode.PlayableNotFound)); + } + [Test] public void FailedTargetProbeDoesNotDiscardCurrentTargetOrRequest() { diff --git a/Assets/Yarn/FP/FP_Huoshan1/Stage1.yarn b/Assets/Yarn/FP/FP_Huoshan1/Stage1.yarn index 11aefd0e3..28fea428f 100644 --- a/Assets/Yarn/FP/FP_Huoshan1/Stage1.yarn +++ b/Assets/Yarn/FP/FP_Huoshan1/Stage1.yarn @@ -125,8 +125,7 @@ hs: 嘿嘿… #line:066c9f4 hs: 真的不想打扰你…可是可惜…我可能等不到晚上了。 #line:005bb59 me: 怎…怎么了?年审的原因吗? #line:092cb78 -<> -<> +<> <> //俩人一时都没说话。 #line:0755544 <> @@ -177,7 +176,7 @@ me: 你能哪怕一秒钟不要再说这些笑话了么…你今天是怎么回 <> // 尴尬的笑声 <> -<> +<> <> <> hs: 啊…哈哈哈。冷笑话已经不时*帽*了吗。看来我确实真是土老 *帽* 了。 #line:0aacf26 diff --git a/Docs/FrameAnimationActor接入说明.md b/Docs/FrameAnimationActor接入说明.md index 7893d93da..50d809d33 100644 --- a/Docs/FrameAnimationActor接入说明.md +++ b/Docs/FrameAnimationActor接入说明.md @@ -25,13 +25,13 @@ Graph 必须通过 `FrameAnimationGraphValidator` 校验。帧动画角色不会 <> ``` -等待一次性动画播放完成: +等待一次性动画播放完成,或等待 Loop playable 完成首轮: ```yarn <> ``` -`init_actor` 会等待 Prefab 和 Graph 加载完成,因此紧随其后的状态切换可以安全执行。Loop playable 的 Handle 不会自然完成,Loop 必须使用 `change_actor_state`,不要使用异步命令。 +`init_actor` 会等待 Prefab 和 Graph 加载完成,因此紧随其后的状态切换可以安全执行。`change_actor_state_async` 播放 Loop playable 时,会在直接 Loop Clip 完成第一轮后继续执行 Yarn;对于“前置节点 → 终点 Loop”的 Flow,则等待前置节点和终点 Loop 第一轮全部完成。命令返回后动画仍会继续循环,直到被新状态替换或主动停止。无需等待首轮时仍可使用 `change_actor_state` 立即继续 Yarn。 ## 存档行为