Compare commits

...
5 Commits
Author SHA1 Message Date
dingyuntian 75ece85309 Merge branch 'bugfix/帧动画调整' into 'develop'
chore: 版本号升级

See merge request aibis-dream/aibis-dream!841
2026-08-05 13:56:42 +00:00
dingyuntian 278543cb8b chore: 版本号升级 2026-08-05 21:56:13 +08:00
dingyuntian 9fdef73926 Merge branch 'bugfix/帧动画调整' into 'develop'
Bugfix/帧动画调整

See merge request aibis-dream/aibis-dream!840
2026-08-05 13:53:03 +00:00
dingyuntian 121e7f398c feat: FlowAsync调整 2026-08-05 21:14:06 +08:00
dingyuntian 9c3c467046 fix: 静音功能 2026-08-05 21:13:43 +08:00
15 changed files with 526 additions and 66 deletions
@@ -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)
{
@@ -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]
@@ -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<FrameAnimationPlaybackResult> completionSource =
@@ -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,
@@ -138,18 +138,22 @@ namespace AibisDream.FrameAnimation
return PlayInternal(playableId, options);
}
/// <summary>
/// 等待当前播放请求完成首轮。非循环请求等待自然结束;终点为 Loop 的请求在
/// 第一次回绕后结束等待,但播放请求本身保持活动并继续循环。
/// </summary>
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<Sprite> 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)
@@ -28,12 +28,19 @@ namespace AibisDream.FrameAnimation
public string PlayableId { get; }
public bool IsFlow { 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;
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;
}
@@ -20,6 +20,12 @@ namespace AibisDream.FrameAnimation
Always
}
public enum AnimationFlowAsyncCompletionMode
{
CompleteOnTerminalLoopStart = 0,
WaitForTerminalLoopFirstCycle = 1
}
public enum FrameAnimationPlaybackState
{
Stopped,
@@ -468,13 +468,19 @@ namespace AibisDream.Kit
public void SetVolume(float normalizedVolume)
{
// 将 0~1 映射到 minDecibels ~ maxDecibels,再转换为线性幅度
// 使用对数曲线匹配人耳听觉特性
float db = Mathf.Lerp(minDecibels, maxDecibels, normalizedVolume);
float linearVolume = Mathf.Pow(10f, db / 20f);
normalizedVolume = Mathf.Clamp01(normalizedVolume);
var masterVca = RuntimeManager.GetBus("bus:/");
masterVca.setVolume(linearVolume);
// 最低档需要真正静音;有限的最小分贝值仍会保留可听的线性幅度。
float linearVolume = 0f;
if (normalizedVolume > 0f)
{
// 将非零音量映射到 minDecibels ~ maxDecibels,保留原有的对数听感曲线。
float db = Mathf.Lerp(minDecibels, maxDecibels, normalizedVolume);
linearVolume = Mathf.Pow(10f, db / 20f);
}
var masterBus = RuntimeManager.GetBus("bus:/");
masterBus.setVolume(linearVolume);
}
#endregion
@@ -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;
}
@@ -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]
@@ -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<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]
public void RestoreTerminalState_RebuildsSavedVisualState()
{
@@ -154,16 +299,60 @@ namespace AibisDream.FrameAnimation.Tests.PlayMode
gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
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<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(
string id,
FrameClipEndBehavior endBehavior,
@@ -186,15 +375,30 @@ namespace AibisDream.FrameAnimation.Tests.PlayMode
private FrameAnimationGraph CreateGraph(params FrameClip[] clips)
{
var graph = Track(ScriptableObject.CreateInstance<FrameAnimationGraph>());
graph.Configure(
"Graph",
"Graph",
return CreateGraph(
clips,
Array.Empty<AnimationNode>(),
Array.Empty<AnimationEdge>(),
Array.Empty<AnimationFlow>(),
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;
}
+2 -2
View File
@@ -25,13 +25,13 @@ Graph 必须通过 `FrameAnimationGraphValidator` 校验。帧动画角色不会
<<change_actor_state Idle 测试角色>>
```
等待一次性动画播放完成,或等待 Loop playable 完成首轮
等待一次性动画播放完成,或按 Flow 配置等待 Loop
```yarn
<<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 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<FrameAnimationPlaybackResult> 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 和回调必须观察到同一个结果。
@@ -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
<<change_actor_state_async 摘帽到伸手_Flow 火山>>
// 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 | 不等待 | `<<change_actor_state ClipID 角色名>>` |
| 单个 Clip | 等完整动画;Loop 等第一轮 | `<<change_actor_state_async ClipID 角色名>>` |
| 一整条 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 不能重名。
@@ -212,7 +221,7 @@ Clip ID、Flow ID、角色名或槽位名中包含空格时,必须加英文双
hs: 医——生——救——我——!
// 调用 Flow:等“摘帽”播完,再等终点“伸手表情idle”完成第一轮
// 调用 Flow默认等“摘帽”播完,并显示终点“伸手表情idle”首帧
<<change_actor_state_async 摘帽到伸手_Flow 火山>>
// 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 中途停住**
+1 -1
View File
@@ -133,7 +133,7 @@ PlayerSettings:
vulkanEnableLateAcquireNextImage: 0
vulkanEnableCommandBufferRecycling: 1
loadStoreDebugModeEnabled: 0
bundleVersion: 0.6.2.4
bundleVersion: 0.6.2.6
preloadedAssets:
- {fileID: 11400000, guid: 0fb2bc2476953fc43a74f0ab8879077c, type: 2}
metroInputSource: 0