feat: 帧动画系统Timeline兼容

This commit is contained in:
2026-07-22 02:32:04 +08:00
parent 9b2caf64c9
commit c6c76666ef
48 changed files with 2947 additions and 31 deletions
@@ -1,5 +1,6 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("AibisDream.FrameAnimation.Editor")]
[assembly: InternalsVisibleTo("AibisDream.FrameAnimation.Timeline")]
[assembly: InternalsVisibleTo("AibisDream.FrameAnimation.Tests.EditMode")]
[assembly: InternalsVisibleTo("AibisDream.FrameAnimation.Tests.PlayMode")]
@@ -56,6 +56,34 @@ namespace AibisDream.FrameAnimation
}
}
/// <summary>
/// Editor-time source settings owned by an external FrameClip asset.
/// This is intentionally separate from graph-owned FrameClipImportInfo.
/// </summary>
[Serializable]
public sealed class FrameClipImportSource
{
[SerializeField] private Texture2D texture;
[SerializeField] private TextAsset asepriteJson;
[SerializeField] private Vector2 pivot = new Vector2(0.5f, 0.5f);
[SerializeField] private bool manageSpriteSlicing;
[SerializeField] private string lastSourceHash = string.Empty;
[SerializeField] private string lastImportedTagName = string.Empty;
public Texture2D Texture => texture;
public TextAsset AsepriteJson => asepriteJson;
public Vector2 Pivot => pivot;
public bool ManageSpriteSlicing => manageSpriteSlicing;
public string LastSourceHash => lastSourceHash;
public string LastImportedTagName => lastImportedTagName;
internal void SetLastImport(string sourceHash, string tagName)
{
lastSourceHash = sourceHash ?? string.Empty;
lastImportedTagName = tagName ?? string.Empty;
}
}
[Serializable]
public sealed class AnimationFlow
{
@@ -407,6 +407,15 @@ namespace AibisDream.FrameAnimation
out IFrameAnimationTarget resolvedTarget,
out FrameAnimationPlaybackError error)
{
if (GetComponent<FrameClipPlayer>() != null)
{
resolvedTarget = null;
error = new FrameAnimationPlaybackError(
FrameAnimationPlaybackErrorCode.TargetConflict,
"同一 GameObject 不能同时包含 FrameAnimationPlayer 和 FrameClipPlayer。");
return false;
}
var spriteRenderer = GetComponent<SpriteRenderer>();
var image = GetComponent<Image>();
if (spriteRenderer != null && image != null)
@@ -135,15 +135,14 @@ namespace AibisDream.FrameAnimation
if (clipMatches.Count == 1)
{
return TryResolveClip(clipMatches[0], playableId, options, out plan, out error);
return TryResolveClip(clipMatches[0], options, out plan, out error);
}
return TryResolveFlow(graph, flowMatches[0], options, out plan, out error);
}
private static bool TryResolveClip(
internal static bool TryResolveClip(
FrameClip clip,
string playableId,
FrameAnimationPlayOptions options,
out ResolvedPlaybackPlan plan,
out FrameAnimationPlaybackError error)
@@ -156,7 +155,7 @@ namespace AibisDream.FrameAnimation
var endBehavior = options.EndBehaviorOverride ?? clip.DefaultEndBehavior;
plan = new ResolvedPlaybackPlan(
playableId,
clip != null ? clip.Id : string.Empty,
false,
new[] { new ResolvedPlaybackStep(clip, string.Empty, clip.Speed, endBehavior) });
return true;
@@ -13,6 +13,8 @@ namespace AibisDream.FrameAnimation
[SerializeField] private FrameClipEndBehavior defaultEndBehavior = FrameClipEndBehavior.HoldLastFrame;
[SerializeField] private bool hasImportInfo;
[SerializeField] private FrameClipImportInfo importInfo;
[SerializeField] private bool hasStandaloneImportSource;
[SerializeField] private FrameClipImportSource standaloneImportSource;
public string Id => id;
public string DisplayName => displayName;
@@ -21,6 +23,9 @@ namespace AibisDream.FrameAnimation
public FrameClipEndBehavior DefaultEndBehavior => defaultEndBehavior;
public FrameClipImportInfo ImportInfo => hasImportInfo ? importInfo : null;
public bool IsImported => ImportInfo != null;
public FrameClipImportSource StandaloneImportSource =>
hasStandaloneImportSource ? standaloneImportSource : null;
public bool HasStandaloneImportSource => StandaloneImportSource != null;
public int FrameCount => frames?.Count ?? 0;
@@ -115,5 +120,18 @@ namespace AibisDream.FrameAnimation
? new List<FrameAnimationFrame>(value)
: new List<FrameAnimationFrame>();
}
internal FrameClipImportSource GetOrCreateStandaloneImportSource()
{
standaloneImportSource ??= new FrameClipImportSource();
hasStandaloneImportSource = true;
return standaloneImportSource;
}
internal void ClearStandaloneImportSource()
{
hasStandaloneImportSource = false;
standaloneImportSource = null;
}
}
}
@@ -0,0 +1,583 @@
using System;
using System.Threading;
using UnityEngine;
using UnityEngine.UI;
namespace AibisDream.FrameAnimation
{
[DisallowMultipleComponent]
public sealed class FrameClipPlayer : MonoBehaviour
{
[SerializeField] private FrameClip clip;
[SerializeField] private bool playOnEnable;
[SerializeField] private float speed = 1f;
private static long nextRequestId;
private IFrameAnimationTarget target;
private FrameAnimationPlaybackSession session;
private FrameAnimationPlaybackHandle activeHandle;
private FrameAnimationPlaybackState state = FrameAnimationPlaybackState.Stopped;
private bool skipInitialPlayOnEnable;
private bool timelineControlled;
private FrameClip timelineClip;
private Sprite timelineBaselineSprite;
private bool timelineBaselineEnabled;
public FrameClip Clip => clip;
public bool PlayOnEnable => playOnEnable;
public FrameAnimationPlaybackState State => state;
public FrameClip CurrentClip => session?.CurrentClip;
public string CurrentClipId => session?.CurrentClipId ?? string.Empty;
public int CurrentFrameIndex => session?.CurrentFrameIndex ?? -1;
public float Speed => speed;
private void Awake()
{
skipInitialPlayOnEnable = playOnEnable;
if (TryCreateTarget(out var resolvedTarget, out _))
{
target = resolvedTarget;
}
}
private void OnEnable()
{
if (!playOnEnable) return;
if (skipInitialPlayOnEnable)
{
skipInitialPlayOnEnable = false;
return;
}
TryAutoPlay();
}
private void Start()
{
if (playOnEnable) TryAutoPlay();
}
private void TryAutoPlay()
{
var handle = Play();
if (handle.IsCompleted && handle.Result.Reason == FrameAnimationCompletionReason.Failed)
{
Debug.LogError($"FrameClipPlayer 自动播放失败:{handle.Result.Error}", this);
}
}
private void Update()
{
if (timelineControlled) return;
Evaluate(Time.deltaTime);
}
private void OnDisable()
{
ReleaseTimelineControl(null);
TerminateActive(FrameAnimationCompletionReason.Stopped, FrameAnimationPlaybackError.None);
}
private void OnDestroy()
{
ReleaseTimelineControl(null);
TerminateActive(FrameAnimationCompletionReason.Stopped, FrameAnimationPlaybackError.None);
}
public FrameAnimationPlaybackHandle Play()
{
return PlayInternal(clip, default);
}
public FrameAnimationPlaybackHandle Play(FrameAnimationPlayOptions options)
{
return PlayInternal(clip, options);
}
public FrameAnimationPlaybackHandle Play(FrameClip value)
{
return PlayInternal(value, default);
}
public FrameAnimationPlaybackHandle Play(FrameClip value, FrameAnimationPlayOptions options)
{
return PlayInternal(value, options);
}
public FrameAnimationPlaybackHandle RestoreTerminalState()
{
return RestoreTerminalState(clip);
}
public FrameAnimationPlaybackHandle RestoreTerminalState(FrameClip value)
{
ReleaseTimelineControl(null);
var requestId = Interlocked.Increment(ref nextRequestId);
var handle = new FrameAnimationPlaybackHandle(requestId);
var playableId = value != null ? value.Id : string.Empty;
if (!TryPrepare(value, default, handle, out var plan, out var resolvedTarget)) return handle;
if (activeHandle != null && !activeHandle.IsCompleted)
{
CompleteHandle(activeHandle, CurrentClipId, FrameAnimationCompletionReason.Replaced,
FrameAnimationPlaybackError.None);
}
target = resolvedTarget;
target.Enabled = true;
var terminalStep = plan.Steps[plan.Steps.Count - 1];
if (terminalStep.TerminalEndBehavior == FrameClipEndBehavior.Loop)
{
session = new FrameAnimationPlaybackSession(plan);
activeHandle = handle;
state = FrameAnimationPlaybackState.Playing;
session.Start(ApplySprite);
return handle;
}
ApplySprite(terminalStep.Clip.Frames[terminalStep.Clip.FrameCount - 1]?.Sprite);
ApplyEndBehavior(terminalStep.TerminalEndBehavior);
session = null;
activeHandle = null;
state = FrameAnimationPlaybackState.Stopped;
CompleteHandle(handle, playableId, FrameAnimationCompletionReason.Completed,
FrameAnimationPlaybackError.None);
return handle;
}
public CustomYieldInstruction WaitForFirstPass(FrameAnimationPlaybackHandle playbackHandle)
{
if (playbackHandle == null) throw new ArgumentNullException(nameof(playbackHandle));
return new FirstPassYieldInstruction(this, playbackHandle);
}
public void Stop(FrameAnimationStopMode mode = FrameAnimationStopMode.HoldCurrentFrame)
{
ReleaseTimelineControl(null);
TerminateActive(FrameAnimationCompletionReason.Stopped, FrameAnimationPlaybackError.None);
if (target == null || !target.IsValid)
{
if (!TryCreateTarget(out var resolvedTarget, out _)) return;
target = resolvedTarget;
}
ApplyStopMode(mode);
}
public void Pause()
{
if (state == FrameAnimationPlaybackState.Playing && session != null)
state = FrameAnimationPlaybackState.Paused;
}
public void Resume()
{
if (state == FrameAnimationPlaybackState.Paused && session != null)
state = FrameAnimationPlaybackState.Playing;
}
public void SetSpeed(float value)
{
if (!FrameAnimationValueUtility.IsValidSpeed(value))
{
Debug.LogError($"FrameClipPlayer speed 必须是有限且不小于 0 的数值,收到:{value}", this);
return;
}
speed = value;
}
internal void ConfigureForAuthoring(FrameClip value, bool shouldPlayOnEnable, float playerSpeed)
{
clip = value;
playOnEnable = shouldPlayOnEnable;
speed = playerSpeed;
}
internal void EvaluateForTests(double deltaTimeSeconds)
{
Evaluate(deltaTimeSeconds);
}
internal FrameAnimationPlaybackError BeginTimelineControl()
{
if (timelineControlled)
{
return FrameAnimationPlaybackError.None;
}
if (!TryCreateTarget(out var resolvedTarget, out var targetError))
{
return targetError;
}
TerminateActive(
FrameAnimationCompletionReason.Replaced,
FrameAnimationPlaybackError.None);
target = resolvedTarget;
timelineBaselineSprite = target.Sprite;
timelineBaselineEnabled = target.Enabled;
timelineClip = null;
timelineControlled = true;
state = FrameAnimationPlaybackState.Stopped;
return FrameAnimationPlaybackError.None;
}
internal FrameAnimationPlaybackError SampleTimeline(FrameClip value, double timeSeconds)
{
var beginError = BeginTimelineControl();
if (beginError.Code != FrameAnimationPlaybackErrorCode.None)
{
return beginError;
}
if (value == null)
{
return new FrameAnimationPlaybackError(
FrameAnimationPlaybackErrorCode.PlayableNotFound,
"Timeline Clip 没有绑定 FrameClip。");
}
if (timelineClip != value || session == null)
{
if (!FrameAnimationResolver.TryResolveClip(
value,
default,
out var plan,
out var resolveError))
{
session = null;
timelineClip = null;
state = FrameAnimationPlaybackState.Stopped;
return resolveError;
}
timelineClip = value;
session = new FrameAnimationPlaybackSession(plan);
session.Start(ApplySprite);
}
target.Enabled = true;
state = FrameAnimationPlaybackState.Playing;
var evaluation = session.Seek(Math.Max(0d, timeSeconds), ApplySprite);
if (evaluation.IsFailed)
{
session = null;
timelineClip = null;
state = FrameAnimationPlaybackState.Stopped;
return evaluation.Error;
}
if (evaluation.IsCompleted)
{
ApplyEndBehavior(evaluation.EndBehavior);
}
return FrameAnimationPlaybackError.None;
}
internal FrameAnimationPlaybackError ApplyTimelineEndBehavior(FrameClip value)
{
var beginError = BeginTimelineControl();
if (beginError.Code != FrameAnimationPlaybackErrorCode.None)
{
return beginError;
}
if (value == null)
{
return new FrameAnimationPlaybackError(
FrameAnimationPlaybackErrorCode.PlayableNotFound,
"Timeline Clip 没有绑定 FrameClip。");
}
if (value.DefaultEndBehavior == FrameClipEndBehavior.Loop)
{
if (timelineClip == value && session != null)
{
return FrameAnimationPlaybackError.None;
}
var duration = GetEffectiveClipDurationSeconds(value);
return SampleTimeline(value, Math.Max(0d, duration - 0.000001d));
}
var sampleError = SampleTimeline(value, GetEffectiveClipDurationSeconds(value));
if (sampleError.Code == FrameAnimationPlaybackErrorCode.None)
{
ApplyEndBehavior(value.DefaultEndBehavior);
}
return sampleError;
}
internal void RestoreTimelineBaseline()
{
if (!timelineControlled || target == null || !target.IsValid) return;
target.Sprite = timelineBaselineSprite;
target.Enabled = timelineBaselineEnabled;
session = null;
timelineClip = null;
state = FrameAnimationPlaybackState.Stopped;
}
internal void ReleaseTimelineControl(FrameClip terminalClip)
{
if (!timelineControlled) return;
if (terminalClip != null)
{
ApplyTimelineEndBehavior(terminalClip);
}
else
{
RestoreTimelineBaseline();
}
timelineControlled = false;
timelineClip = null;
session = null;
state = FrameAnimationPlaybackState.Stopped;
}
private static double GetEffectiveClipDurationSeconds(FrameClip value)
{
if (value == null || value.TotalDurationMs <= 0 || value.Speed <= 0f)
{
return 0d;
}
return value.TotalDurationMs / 1000d / value.Speed;
}
private void Evaluate(double deltaTimeSeconds)
{
if (state != FrameAnimationPlaybackState.Playing || session == null) return;
if (target == null || !target.IsValid)
{
FailActive(FrameAnimationPlaybackErrorCode.TargetMissing, "播放期间显示目标失效。");
return;
}
if (session.CurrentClip == null)
{
FailActive(FrameAnimationPlaybackErrorCode.InvalidPlayableData, "播放期间 Clip 数据失效。");
return;
}
if (!FrameAnimationValueUtility.IsValidSpeed(speed))
{
FailActive(FrameAnimationPlaybackErrorCode.InvalidSpeed, "播放期间 Player speed 变为无效值。");
return;
}
var evaluation = session.Evaluate(deltaTimeSeconds, speed, ApplySprite);
if (evaluation.IsFailed) FailActive(evaluation.Error.Code, evaluation.Error.Message);
else if (evaluation.IsCompleted) CompleteNaturally(evaluation.EndBehavior);
}
private FrameAnimationPlaybackHandle PlayInternal(FrameClip value, FrameAnimationPlayOptions options)
{
ReleaseTimelineControl(null);
var handle = new FrameAnimationPlaybackHandle(Interlocked.Increment(ref nextRequestId));
if (!TryPrepare(value, options, handle, out var plan, out var resolvedTarget)) return handle;
if (activeHandle != null && !activeHandle.IsCompleted)
{
CompleteHandle(activeHandle, CurrentClipId, FrameAnimationCompletionReason.Replaced,
FrameAnimationPlaybackError.None);
}
session = new FrameAnimationPlaybackSession(plan);
activeHandle = handle;
state = FrameAnimationPlaybackState.Playing;
target = resolvedTarget;
target.Enabled = true;
session.Start(ApplySprite);
return handle;
}
private bool TryPrepare(
FrameClip value,
FrameAnimationPlayOptions options,
FrameAnimationPlaybackHandle handle,
out ResolvedPlaybackPlan plan,
out IFrameAnimationTarget resolvedTarget)
{
plan = null;
resolvedTarget = null;
var playableId = value != null ? value.Id : string.Empty;
if (!isActiveAndEnabled)
{
CompleteFailed(handle, playableId, FrameAnimationPlaybackErrorCode.PlayerNotReady,
"FrameClipPlayer 未启用或 GameObject 未激活。");
return false;
}
if (value == null)
{
CompleteFailed(handle, playableId, FrameAnimationPlaybackErrorCode.PlayableNotFound,
"FrameClipPlayer 没有可播放的 FrameClip。");
return false;
}
if (!FrameAnimationValueUtility.IsValidSpeed(speed))
{
CompleteFailed(handle, playableId, FrameAnimationPlaybackErrorCode.InvalidSpeed,
"FrameClipPlayer speed 无效。");
return false;
}
if (!TryCreateTarget(out resolvedTarget, out var targetError))
{
CompleteFailed(handle, playableId, targetError.Code, targetError.Message);
return false;
}
if (!FrameAnimationResolver.TryResolveClip(value, options, out plan, out var resolveError))
{
CompleteFailed(handle, playableId, resolveError.Code, resolveError.Message);
return false;
}
return true;
}
private bool TryCreateTarget(out IFrameAnimationTarget resolvedTarget, out FrameAnimationPlaybackError error)
{
if (GetComponent<FrameAnimationPlayer>() != null)
{
resolvedTarget = null;
error = new FrameAnimationPlaybackError(FrameAnimationPlaybackErrorCode.TargetConflict,
"同一 GameObject 不能同时包含 FrameAnimationPlayer 和 FrameClipPlayer。");
return false;
}
var spriteRenderer = GetComponent<SpriteRenderer>();
var image = GetComponent<Image>();
if (spriteRenderer != null && image != null)
{
resolvedTarget = null;
error = new FrameAnimationPlaybackError(FrameAnimationPlaybackErrorCode.TargetConflict,
"FrameClipPlayer 所在对象不能同时包含 SpriteRenderer 和 Image。");
return false;
}
if (spriteRenderer == null && image == null)
{
resolvedTarget = null;
error = new FrameAnimationPlaybackError(FrameAnimationPlaybackErrorCode.TargetMissing,
"FrameClipPlayer 所在对象缺少 SpriteRenderer 或 Image。");
return false;
}
resolvedTarget = spriteRenderer != null
? new SpriteRendererFrameAnimationTarget(spriteRenderer)
: new ImageFrameAnimationTarget(image);
error = FrameAnimationPlaybackError.None;
return true;
}
private static FrameAnimationPlaybackHandle CompleteFailed(
FrameAnimationPlaybackHandle handle,
string playableId,
FrameAnimationPlaybackErrorCode code,
string message)
{
CompleteHandle(handle, playableId, FrameAnimationCompletionReason.Failed,
new FrameAnimationPlaybackError(code, message));
return handle;
}
private void CompleteNaturally(FrameClipEndBehavior endBehavior)
{
var handle = activeHandle;
var playableId = CurrentClipId;
ApplyEndBehavior(endBehavior);
session = null;
activeHandle = null;
state = FrameAnimationPlaybackState.Stopped;
CompleteHandle(handle, playableId, FrameAnimationCompletionReason.Completed,
FrameAnimationPlaybackError.None);
}
private void FailActive(FrameAnimationPlaybackErrorCode code, string message)
{
var handle = activeHandle;
var playableId = CurrentClipId;
session = null;
activeHandle = null;
state = FrameAnimationPlaybackState.Stopped;
CompleteHandle(handle, playableId, FrameAnimationCompletionReason.Failed,
new FrameAnimationPlaybackError(code, message));
}
private void TerminateActive(FrameAnimationCompletionReason reason, FrameAnimationPlaybackError error)
{
if (activeHandle == null || activeHandle.IsCompleted)
{
session = null;
activeHandle = null;
state = FrameAnimationPlaybackState.Stopped;
return;
}
var handle = activeHandle;
var playableId = CurrentClipId;
session = null;
activeHandle = null;
state = FrameAnimationPlaybackState.Stopped;
CompleteHandle(handle, playableId, reason, error);
}
private static void CompleteHandle(
FrameAnimationPlaybackHandle handle,
string playableId,
FrameAnimationCompletionReason reason,
FrameAnimationPlaybackError error)
{
handle?.TryComplete(new FrameAnimationPlaybackResult(handle.RequestId, playableId, reason, error));
}
private void ApplySprite(Sprite sprite)
{
if (target != null && target.IsValid) target.Sprite = sprite;
}
private bool ShouldWaitForFirstPass(FrameAnimationPlaybackHandle playbackHandle)
{
return !playbackHandle.IsCompleted && activeHandle == playbackHandle && session != null &&
!session.HasCompletedFirstTerminalLoopCycle;
}
private sealed class FirstPassYieldInstruction : CustomYieldInstruction
{
private readonly FrameClipPlayer player;
private readonly FrameAnimationPlaybackHandle playbackHandle;
public FirstPassYieldInstruction(FrameClipPlayer 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)
{
case FrameClipEndBehavior.Clear:
if (target != null && target.IsValid)
{
target.Enabled = true;
target.Sprite = null;
}
break;
case FrameClipEndBehavior.HideTarget:
if (target != null && target.IsValid) target.Enabled = false;
break;
}
}
private void ApplyStopMode(FrameAnimationStopMode mode)
{
switch (mode)
{
case FrameAnimationStopMode.Clear:
target.Enabled = true;
target.Sprite = null;
break;
case FrameAnimationStopMode.HideTarget:
target.Enabled = false;
break;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 192967735fd44d642bee1a6c5ba29717
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 66e7d3aa7a5d4bf3838316bbebed8117
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,18 @@
{
"name": "AibisDream.FrameAnimation.Timeline",
"rootNamespace": "AibisDream.FrameAnimation.Timeline",
"references": [
"AibisDream.FrameAnimation.Runtime",
"Unity.Timeline",
"UnityEngine.UI"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 2a8d75ec8acb4b18ae787ed85e8401b8
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,5 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("AibisDream.FrameAnimation.Tests.EditMode")]
[assembly: InternalsVisibleTo("AibisDream.FrameAnimation.Tests.PlayMode")]
[assembly: InternalsVisibleTo("AibisDream.FrameAnimation.Timeline.Editor")]
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: df7035ef1c2d40218ccf85f294e34735
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b32641aa64c6449994fe91525598e451
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,21 @@
{
"name": "AibisDream.FrameAnimation.Timeline.Editor",
"rootNamespace": "AibisDream.FrameAnimation.Timeline.Editor",
"references": [
"AibisDream.FrameAnimation.Runtime",
"AibisDream.FrameAnimation.Timeline",
"Unity.Timeline",
"Unity.Timeline.Editor"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 7dcd49d5096747bda63e1b94c699b67b
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,51 @@
using UnityEditor.Timeline;
using UnityEngine.Timeline;
namespace AibisDream.FrameAnimation.Timeline.Editor
{
[CustomTimelineEditor(typeof(FrameAnimationTimelineClip))]
internal sealed class FrameAnimationTimelineClipEditor : ClipEditor
{
public override void OnCreate(TimelineClip clip, TrackAsset track, TimelineClip clonedFrom)
{
base.OnCreate(clip, track, clonedFrom);
SynchronizeSource(clip);
}
public override void OnClipChanged(TimelineClip clip)
{
base.OnClipChanged(clip);
SynchronizeSource(clip);
}
public override ClipDrawOptions GetClipOptions(TimelineClip clip)
{
var options = base.GetClipOptions(clip);
if (clip?.asset is not FrameAnimationTimelineClip asset)
{
return options;
}
if (asset.FrameClip == null)
{
options.errorText = "未指定 FrameClip";
}
return options;
}
private static void SynchronizeSource(TimelineClip clip)
{
if (clip?.asset is not FrameAnimationTimelineClip asset ||
!asset.TrySynchronizeSourceDuration(out var duration))
{
return;
}
clip.duration = duration;
clip.displayName = asset.FrameClip != null &&
!string.IsNullOrEmpty(asset.FrameClip.DisplayName)
? asset.FrameClip.DisplayName
: "Frame Animation Clip";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d9f814325d9449478d6688741c38204f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,76 @@
using System;
using System.ComponentModel;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
namespace AibisDream.FrameAnimation.Timeline
{
[Serializable]
[DisplayName("Frame Animation Clip")]
public sealed class FrameAnimationTimelineClip : PlayableAsset, ITimelineClipAsset
{
private const double FallbackDurationSeconds = 1d;
[SerializeField, Tooltip("要由 Timeline 播放的独立 FrameClip 资产。")]
private FrameClip frameClip;
[SerializeField, HideInInspector] private FrameClip synchronizedDurationSource;
[SerializeField, HideInInspector] private bool hasSynchronizedDuration;
public FrameClip FrameClip => frameClip;
public override double duration => GetEffectiveDuration(frameClip);
public ClipCaps clipCaps
{
get
{
var caps = ClipCaps.ClipIn | ClipCaps.SpeedMultiplier;
if (frameClip != null && frameClip.DefaultEndBehavior == FrameClipEndBehavior.Loop)
{
caps |= ClipCaps.Looping;
}
return caps;
}
}
public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
{
var playable = ScriptPlayable<FrameAnimationTimelineBehaviour>.Create(graph);
playable.GetBehaviour().FrameClip = frameClip;
return playable;
}
internal void SetFrameClipForTests(FrameClip value)
{
frameClip = value;
}
internal bool TrySynchronizeSourceDuration(out double sourceDuration)
{
sourceDuration = duration;
if (hasSynchronizedDuration && synchronizedDurationSource == frameClip)
{
return false;
}
synchronizedDurationSource = frameClip;
hasSynchronizedDuration = true;
return true;
}
internal static double GetEffectiveDuration(FrameClip value)
{
if (value == null || value.TotalDurationMs <= 0 || value.Speed <= 0f)
{
return FallbackDurationSeconds;
}
return value.TotalDurationMs / 1000d / value.Speed;
}
}
internal sealed class FrameAnimationTimelineBehaviour : PlayableBehaviour
{
internal FrameClip FrameClip { get; set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c908631f9d7446ac86914dce3f1927af
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
using UnityEngine.UI;
namespace AibisDream.FrameAnimation.Timeline
{
[DisplayName("Frame Animation Track")]
[TrackColor(0.35f, 0.72f, 0.95f)]
[TrackBindingType(typeof(FrameClipPlayer))]
[TrackClipType(typeof(FrameAnimationTimelineClip))]
public sealed class FrameAnimationTrack : TrackAsset
{
public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount)
{
var playable = ScriptPlayable<FrameAnimationTrackMixerBehaviour>.Create(graph, inputCount);
playable.GetBehaviour().SetClips(
GetClips()
.Select((clip, index) => new FrameAnimationTimelineClipInfo(clip, index))
.ToArray());
return playable;
}
public override void GatherProperties(PlayableDirector director, IPropertyCollector driver)
{
var player = director != null ? director.GetGenericBinding(this) as FrameClipPlayer : null;
if (player != null)
{
var spriteRenderer = player.GetComponent<SpriteRenderer>();
if (spriteRenderer != null)
{
driver.AddFromName<SpriteRenderer>(player.gameObject, "m_Sprite");
driver.AddFromName<SpriteRenderer>(player.gameObject, "m_Enabled");
}
var image = player.GetComponent<Image>();
if (image != null)
{
driver.AddFromName<Image>(player.gameObject, "m_Sprite");
driver.AddFromName<Image>(player.gameObject, "m_Enabled");
}
}
base.GatherProperties(director, driver);
}
}
internal readonly struct FrameAnimationTimelineClipInfo
{
internal TimelineClip TimelineClip { get; }
internal FrameAnimationTimelineClip Asset { get; }
internal double Start { get; }
internal double End { get; }
internal int Order { get; }
internal FrameAnimationTimelineClipInfo(TimelineClip timelineClip, int order)
{
TimelineClip = timelineClip;
Asset = timelineClip?.asset as FrameAnimationTimelineClip;
Start = timelineClip?.start ?? 0d;
End = timelineClip?.end ?? 0d;
Order = order;
}
}
internal sealed class FrameAnimationTrackMixerBehaviour : PlayableBehaviour
{
private const double TimeEpsilon = 0.0000001d;
private IReadOnlyList<FrameAnimationTimelineClipInfo> clips =
Array.Empty<FrameAnimationTimelineClipInfo>();
private FrameClipPlayer currentPlayer;
private FrameClip terminalClip;
private string lastErrorKey = string.Empty;
internal void SetClips(IReadOnlyList<FrameAnimationTimelineClipInfo> value)
{
clips = value ?? Array.Empty<FrameAnimationTimelineClipInfo>();
}
public override void ProcessFrame(Playable playable, FrameData info, object playerData)
{
var player = playerData as FrameClipPlayer;
if (player == null)
{
ReleaseCurrentPlayer();
return;
}
if (currentPlayer != player)
{
ReleaseCurrentPlayer();
currentPlayer = player;
ReportError(currentPlayer.BeginTimelineControl(), currentPlayer);
}
var timelineTime = playable.GetTime();
var activeIndex = FindActiveClipIndex(playable, timelineTime);
if (activeIndex >= 0)
{
var clipInfo = clips[activeIndex];
terminalClip = clipInfo.Asset?.FrameClip;
var localTime = playable.GetInput(activeIndex).GetTime();
localTime = NormalizeLoopTime(terminalClip, localTime);
ReportError(currentPlayer.SampleTimeline(terminalClip, localTime), currentPlayer);
return;
}
var previous = FindPreviousClip(timelineTime);
if (previous.HasValue)
{
terminalClip = previous.Value.Asset?.FrameClip;
ReportError(currentPlayer.ApplyTimelineEndBehavior(terminalClip), currentPlayer);
}
else
{
terminalClip = null;
currentPlayer.RestoreTimelineBaseline();
lastErrorKey = string.Empty;
}
}
public override void OnGraphStop(Playable playable)
{
ReleaseCurrentPlayer();
}
public override void OnPlayableDestroy(Playable playable)
{
ReleaseCurrentPlayer();
}
private int FindActiveClipIndex(Playable playable, double timelineTime)
{
var selected = -1;
var selectedStart = double.NegativeInfinity;
var count = Math.Min(playable.GetInputCount(), clips.Count);
for (var index = 0; index < count; index++)
{
var clip = clips[index];
var isInside = timelineTime + TimeEpsilon >= clip.Start &&
timelineTime < clip.End - TimeEpsilon;
if (!isInside || playable.GetInputWeight(index) <= 0f)
{
continue;
}
if (clip.Start > selectedStart ||
(Math.Abs(clip.Start - selectedStart) <= TimeEpsilon && index > selected))
{
selected = index;
selectedStart = clip.Start;
}
}
return selected;
}
private FrameAnimationTimelineClipInfo? FindPreviousClip(double timelineTime)
{
FrameAnimationTimelineClipInfo? selected = null;
foreach (var clip in clips)
{
if (clip.End > timelineTime + TimeEpsilon)
{
continue;
}
if (!selected.HasValue || clip.End > selected.Value.End ||
(Math.Abs(clip.End - selected.Value.End) <= TimeEpsilon &&
clip.Order > selected.Value.Order))
{
selected = clip;
}
}
return selected;
}
private static double NormalizeLoopTime(FrameClip clip, double localTime)
{
if (clip == null || clip.DefaultEndBehavior != FrameClipEndBehavior.Loop)
{
return Math.Max(0d, localTime);
}
var duration = FrameAnimationTimelineClip.GetEffectiveDuration(clip);
if (duration <= 0d)
{
return 0d;
}
return Math.Max(0d, localTime) % duration;
}
private void ReleaseCurrentPlayer()
{
if (currentPlayer != null)
{
currentPlayer.ReleaseTimelineControl(terminalClip);
}
currentPlayer = null;
terminalClip = null;
lastErrorKey = string.Empty;
}
private void ReportError(FrameAnimationPlaybackError error, UnityEngine.Object context)
{
if (error.Code == FrameAnimationPlaybackErrorCode.None)
{
lastErrorKey = string.Empty;
return;
}
var key = $"{error.Code}:{error.Message}";
if (lastErrorKey == key)
{
return;
}
lastErrorKey = key;
Debug.LogError($"Frame Animation Timeline 播放失败:{error}", context);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6d1afb0ade1f46879f33b8173a66abbf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: