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。
## 存档行为