feat(frameAnima): 动画系统前四个阶段

This commit is contained in:
2026-07-15 15:13:56 +08:00
parent 19b6b9c4e2
commit 85a3fc9e14
108 changed files with 15448 additions and 35 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 24d07c530798ed44589b2fdc0f05f173
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,16 @@
{
"name": "AibisDream.FrameAnimation.Runtime",
"rootNamespace": "AibisDream.FrameAnimation",
"references": [
"UnityEngine.UI"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 0c054cc61ebe5194dbb3adbb7a88b52a
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,5 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("AibisDream.FrameAnimation.Editor")]
[assembly: InternalsVisibleTo("AibisDream.FrameAnimation.Tests.EditMode")]
[assembly: InternalsVisibleTo("AibisDream.FrameAnimation.Tests.PlayMode")]
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f0fe6a145765bb64f8a88c56a8a3436b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,401 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace AibisDream.FrameAnimation
{
[Serializable]
public sealed class FrameAnimationFrame
{
[SerializeField] private Sprite sprite;
[SerializeField] private int durationMs = 100;
[SerializeField] private string frameName = string.Empty;
[SerializeField] private int sourceIndex = -1;
public Sprite Sprite => sprite;
public int DurationMs => durationMs;
public string FrameName => frameName;
public int SourceIndex => sourceIndex;
public FrameAnimationFrame()
{
}
internal FrameAnimationFrame(Sprite sprite, int durationMs, string frameName, int sourceIndex)
{
this.sprite = sprite;
this.durationMs = durationMs;
this.frameName = frameName ?? string.Empty;
this.sourceIndex = sourceIndex;
}
}
[Serializable]
public sealed class FrameClipImportInfo
{
[SerializeField] private string importSourceId = string.Empty;
[SerializeField] private string sourceTagName = string.Empty;
[SerializeField] private bool isMissingFromSource;
public string ImportSourceId => importSourceId;
public string SourceTagName => sourceTagName;
public bool IsMissingFromSource => isMissingFromSource;
internal FrameClipImportInfo(string importSourceId, string sourceTagName, bool isMissingFromSource)
{
this.importSourceId = importSourceId ?? string.Empty;
this.sourceTagName = sourceTagName ?? string.Empty;
this.isMissingFromSource = isMissingFromSource;
}
internal void Update(string sourceId, string tagName, bool missingFromSource)
{
importSourceId = sourceId ?? string.Empty;
sourceTagName = tagName ?? string.Empty;
isMissingFromSource = missingFromSource;
}
}
[Serializable]
public sealed class AnimationFlow
{
[SerializeField] private string id = string.Empty;
[SerializeField] private string displayName = string.Empty;
[SerializeField] private string entryNodeId = string.Empty;
[SerializeField] private bool hasEndBehaviorOverride;
[SerializeField] private FrameClipEndBehavior endBehaviorOverride;
public string Id => id;
public string DisplayName => displayName;
public string EntryNodeId => entryNodeId;
public FrameClipEndBehavior? EndBehaviorOverride =>
hasEndBehaviorOverride ? endBehaviorOverride : (FrameClipEndBehavior?)null;
public AnimationFlow()
{
}
internal AnimationFlow(
string id,
string displayName,
string entryNodeId,
FrameClipEndBehavior? endBehaviorOverride = null)
{
this.id = id ?? string.Empty;
this.displayName = displayName ?? this.id;
this.entryNodeId = entryNodeId ?? string.Empty;
SetEndBehaviorOverride(endBehaviorOverride);
}
internal void SetEndBehaviorOverride(FrameClipEndBehavior? value)
{
hasEndBehaviorOverride = value.HasValue;
endBehaviorOverride = value.GetValueOrDefault();
}
internal void SetId(string value)
{
id = value ?? string.Empty;
}
internal void SetDisplayName(string value)
{
displayName = value ?? string.Empty;
}
internal void SetEntryNodeId(string value)
{
entryNodeId = value ?? string.Empty;
}
}
[Serializable]
public sealed class AnimationNode
{
[SerializeField] private string internalId = string.Empty;
[SerializeField] private string displayName = string.Empty;
[SerializeField] private AnimationNodeType type = AnimationNodeType.Clip;
[SerializeField] private string clipId = string.Empty;
[SerializeField] private bool hasEndBehaviorOverride;
[SerializeField] private FrameClipEndBehavior endBehaviorOverride;
[SerializeField] private bool hasSpeedOverride;
[SerializeField] private float speedOverride = 1f;
public string InternalId => internalId;
public string DisplayName => displayName;
public AnimationNodeType Type => type;
public string ClipId => clipId;
public FrameClipEndBehavior? EndBehaviorOverride =>
hasEndBehaviorOverride ? endBehaviorOverride : (FrameClipEndBehavior?)null;
public float? SpeedOverride => hasSpeedOverride ? speedOverride : (float?)null;
public AnimationNode()
{
}
internal AnimationNode(
string clipId,
string displayName = null,
FrameClipEndBehavior? endBehaviorOverride = null,
float? speedOverride = null,
string internalId = null)
{
this.internalId = string.IsNullOrEmpty(internalId)
? FrameAnimationValueUtility.NewInternalId()
: internalId;
this.displayName = displayName ?? clipId ?? string.Empty;
type = AnimationNodeType.Clip;
this.clipId = clipId ?? string.Empty;
SetEndBehaviorOverride(endBehaviorOverride);
SetSpeedOverride(speedOverride);
}
internal void EnsureInternalId()
{
if (string.IsNullOrWhiteSpace(internalId))
{
internalId = FrameAnimationValueUtility.NewInternalId();
}
}
internal void SetEndBehaviorOverride(FrameClipEndBehavior? value)
{
hasEndBehaviorOverride = value.HasValue;
endBehaviorOverride = value.GetValueOrDefault();
}
internal void SetSpeedOverride(float? value)
{
hasSpeedOverride = value.HasValue;
speedOverride = value.GetValueOrDefault(1f);
}
internal void SetClipId(string value)
{
clipId = value ?? string.Empty;
}
internal void SetDisplayName(string value)
{
displayName = value ?? string.Empty;
}
}
[Serializable]
public sealed class AnimationEdge
{
[SerializeField] private string internalId = string.Empty;
[SerializeField] private string fromNodeId = string.Empty;
[SerializeField] private string toNodeId = string.Empty;
[SerializeField] private string exitName = "default";
[SerializeField] private AnimationEdgeCondition condition = AnimationEdgeCondition.Always;
public string InternalId => internalId;
public string FromNodeId => fromNodeId;
public string ToNodeId => toNodeId;
public string ExitName => exitName;
public AnimationEdgeCondition Condition => condition;
public AnimationEdge()
{
}
internal AnimationEdge(string fromNodeId, string toNodeId, string internalId = null)
{
this.internalId = string.IsNullOrEmpty(internalId)
? FrameAnimationValueUtility.NewInternalId()
: internalId;
this.fromNodeId = fromNodeId ?? string.Empty;
this.toNodeId = toNodeId ?? string.Empty;
exitName = "default";
condition = AnimationEdgeCondition.Always;
}
internal void EnsureInternalId()
{
if (string.IsNullOrWhiteSpace(internalId))
{
internalId = FrameAnimationValueUtility.NewInternalId();
}
}
}
[Serializable]
public sealed class FrameAnimationImportSource
{
[SerializeField] private string internalId = string.Empty;
[SerializeField] private string displayName = string.Empty;
[SerializeField] private bool isEnabled = true;
[SerializeField] private Texture2D texture;
[SerializeField] private TextAsset asepriteJson;
[SerializeField] private Vector2 pivot = new Vector2(0.5f, 0.5f);
[SerializeField] private bool manageSpriteSlicing;
[SerializeField] private FrameClipEndBehavior defaultNewClipEndBehavior =
FrameClipEndBehavior.HoldLastFrame;
[SerializeField] private string lastSourceHash = string.Empty;
public string InternalId => internalId;
public string DisplayName => displayName;
public bool IsEnabled => isEnabled;
public Texture2D Texture => texture;
public TextAsset AsepriteJson => asepriteJson;
public Vector2 Pivot => pivot;
public bool ManageSpriteSlicing => manageSpriteSlicing;
public FrameClipEndBehavior DefaultNewClipEndBehavior => defaultNewClipEndBehavior;
public string LastSourceHash => lastSourceHash;
public FrameAnimationImportSource()
{
}
internal FrameAnimationImportSource(
string sourceDisplayName,
Texture2D sourceTexture,
TextAsset sourceJson,
Vector2 sourcePivot,
bool ownsSpriteSlicing,
FrameClipEndBehavior newClipEndBehavior = FrameClipEndBehavior.HoldLastFrame,
string sourceInternalId = null)
{
internalId = string.IsNullOrWhiteSpace(sourceInternalId)
? FrameAnimationValueUtility.NewInternalId()
: sourceInternalId;
displayName = sourceDisplayName ?? string.Empty;
isEnabled = true;
texture = sourceTexture;
asepriteJson = sourceJson;
pivot = sourcePivot;
manageSpriteSlicing = ownsSpriteSlicing;
defaultNewClipEndBehavior = newClipEndBehavior;
}
internal void EnsureInternalId()
{
if (string.IsNullOrWhiteSpace(internalId))
{
internalId = FrameAnimationValueUtility.NewInternalId();
}
}
internal void SetLastSourceHash(string value)
{
lastSourceHash = value ?? string.Empty;
}
}
[Serializable]
public sealed class FrameAnimationGraphSettings
{
[SerializeField] private string defaultPlayableId = string.Empty;
[SerializeField] private FrameClipEndBehavior newManualClipDefaultEndBehavior =
FrameClipEndBehavior.HoldLastFrame;
public string DefaultPlayableId => defaultPlayableId;
public FrameClipEndBehavior NewManualClipDefaultEndBehavior => newManualClipDefaultEndBehavior;
internal void SetDefaultPlayableId(string value)
{
defaultPlayableId = value ?? string.Empty;
}
internal void SetNewManualClipDefaultEndBehavior(FrameClipEndBehavior value)
{
newManualClipDefaultEndBehavior = value;
}
}
[Serializable]
public sealed class AnimationNodeEditorData
{
[SerializeField] private string nodeId = string.Empty;
[SerializeField] private Vector2 position;
public string NodeId => nodeId;
public Vector2 Position => position;
internal AnimationNodeEditorData(string nodeId, Vector2 position)
{
this.nodeId = nodeId ?? string.Empty;
this.position = position;
}
internal void SetPosition(Vector2 value)
{
position = value;
}
}
[Serializable]
public sealed class AnimationFlowEditorData
{
[SerializeField] private string flowId = string.Empty;
[SerializeField] private Color color = Color.white;
public string FlowId => flowId;
public Color Color => color;
internal AnimationFlowEditorData(string flowId, Color color)
{
this.flowId = flowId ?? string.Empty;
this.color = color;
}
internal void SetFlowId(string value)
{
flowId = value ?? string.Empty;
}
internal void SetColor(Color value)
{
color = value;
}
}
[Serializable]
public sealed class FrameAnimationGraphEditorData
{
[SerializeField] private List<AnimationNodeEditorData> nodeEditorData = new List<AnimationNodeEditorData>();
[SerializeField] private List<AnimationFlowEditorData> flowEditorData = new List<AnimationFlowEditorData>();
public IReadOnlyList<AnimationNodeEditorData> NodeEditorData => nodeEditorData;
public IReadOnlyList<AnimationFlowEditorData> FlowEditorData => flowEditorData;
internal AnimationNodeEditorData GetOrCreateNodeData(string nodeId, Vector2 position)
{
nodeEditorData ??= new List<AnimationNodeEditorData>();
var existing = nodeEditorData.Find(data => data != null && data.NodeId == nodeId);
if (existing != null)
{
return existing;
}
var created = new AnimationNodeEditorData(nodeId, position);
nodeEditorData.Add(created);
return created;
}
internal bool RemoveNodeData(string nodeId)
{
nodeEditorData ??= new List<AnimationNodeEditorData>();
return nodeEditorData.RemoveAll(data => data != null && data.NodeId == nodeId) > 0;
}
internal AnimationFlowEditorData GetOrCreateFlowData(string flowId, Color color)
{
flowEditorData ??= new List<AnimationFlowEditorData>();
var existing = flowEditorData.Find(data => data != null && data.FlowId == flowId);
if (existing != null)
{
return existing;
}
var created = new AnimationFlowEditorData(flowId, color);
flowEditorData.Add(created);
return created;
}
internal bool RemoveFlowData(string flowId)
{
flowEditorData ??= new List<AnimationFlowEditorData>();
return flowEditorData.RemoveAll(data => data != null && data.FlowId == flowId) > 0;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d9b4ce08f4054bb4fa63773ab102abe7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,196 @@
using System.Collections.Generic;
using UnityEngine;
namespace AibisDream.FrameAnimation
{
[CreateAssetMenu(fileName = "FrameAnimationGraph", menuName = "AibisDream/Frame Animation/Frame Animation Graph")]
public sealed class FrameAnimationGraph : ScriptableObject
{
[SerializeField] private string id = string.Empty;
[SerializeField] private string displayName = string.Empty;
[SerializeField] private List<FrameClip> clips = new List<FrameClip>();
[SerializeField] private List<AnimationNode> nodes = new List<AnimationNode>();
[SerializeField] private List<AnimationEdge> edges = new List<AnimationEdge>();
[SerializeField] private List<AnimationFlow> flows = new List<AnimationFlow>();
[SerializeField] private List<FrameAnimationImportSource> importSources =
new List<FrameAnimationImportSource>();
[SerializeField] private FrameAnimationGraphSettings settings = new FrameAnimationGraphSettings();
[SerializeField] private FrameAnimationGraphEditorData editorData = new FrameAnimationGraphEditorData();
public string Id => id;
public string DisplayName => displayName;
public IReadOnlyList<FrameClip> Clips => clips;
public IReadOnlyList<AnimationNode> Nodes => nodes;
public IReadOnlyList<AnimationEdge> Edges => edges;
public IReadOnlyList<AnimationFlow> Flows => flows;
public IReadOnlyList<FrameAnimationImportSource> ImportSources => importSources;
public FrameAnimationGraphSettings Settings => settings;
public FrameAnimationGraphEditorData EditorData => editorData;
private void OnEnable()
{
EnsureCollections();
}
#if UNITY_EDITOR
private void OnValidate()
{
EnsureCollections();
EnsureMissingInternalIds();
}
#endif
internal void Configure(
string graphId,
string graphDisplayName,
IEnumerable<FrameClip> graphClips,
IEnumerable<AnimationNode> graphNodes,
IEnumerable<AnimationEdge> graphEdges,
IEnumerable<AnimationFlow> graphFlows,
string defaultPlayableId)
{
id = graphId ?? string.Empty;
displayName = graphDisplayName ?? id;
clips = graphClips != null ? new List<FrameClip>(graphClips) : new List<FrameClip>();
nodes = graphNodes != null ? new List<AnimationNode>(graphNodes) : new List<AnimationNode>();
edges = graphEdges != null ? new List<AnimationEdge>(graphEdges) : new List<AnimationEdge>();
flows = graphFlows != null ? new List<AnimationFlow>(graphFlows) : new List<AnimationFlow>();
importSources ??= new List<FrameAnimationImportSource>();
settings ??= new FrameAnimationGraphSettings();
settings.SetDefaultPlayableId(defaultPlayableId);
editorData ??= new FrameAnimationGraphEditorData();
EnsureMissingInternalIds();
}
internal void EnsureMissingInternalIds()
{
if (nodes != null)
{
foreach (var node in nodes)
{
node?.EnsureInternalId();
}
}
if (edges != null)
{
foreach (var edge in edges)
{
edge?.EnsureInternalId();
}
}
if (importSources != null)
{
foreach (var source in importSources)
{
source?.EnsureInternalId();
}
}
}
internal void AddImportedClip(FrameClip clip)
{
EnsureCollections();
if (clip != null && !clips.Contains(clip))
{
clips.Add(clip);
}
}
internal void AddClip(FrameClip clip)
{
AddImportedClip(clip);
}
internal bool RemoveClip(FrameClip clip)
{
EnsureCollections();
return clip != null && clips.Remove(clip);
}
internal void AddFlow(AnimationFlow flow)
{
EnsureCollections();
if (flow != null && !flows.Contains(flow))
{
flows.Add(flow);
}
}
internal bool RemoveFlow(AnimationFlow flow)
{
EnsureCollections();
return flow != null && flows.Remove(flow);
}
internal void AddNode(AnimationNode node)
{
EnsureCollections();
if (node != null && !nodes.Contains(node))
{
node.EnsureInternalId();
nodes.Add(node);
}
}
internal bool RemoveNode(AnimationNode node)
{
EnsureCollections();
return node != null && nodes.Remove(node);
}
internal void AddEdge(AnimationEdge edge)
{
EnsureCollections();
if (edge != null && !edges.Contains(edge))
{
edge.EnsureInternalId();
edges.Add(edge);
}
}
internal bool RemoveEdge(AnimationEdge edge)
{
EnsureCollections();
return edge != null && edges.Remove(edge);
}
internal void AddImportSource(FrameAnimationImportSource source)
{
EnsureCollections();
if (source != null && !importSources.Contains(source))
{
source.EnsureInternalId();
importSources.Add(source);
}
}
internal bool RemoveImportSource(FrameAnimationImportSource source)
{
EnsureCollections();
return source != null && importSources.Remove(source);
}
internal void SetId(string value)
{
id = value ?? string.Empty;
}
internal void SetDisplayName(string value)
{
displayName = value ?? string.Empty;
}
private void EnsureCollections()
{
clips ??= new List<FrameClip>();
nodes ??= new List<AnimationNode>();
edges ??= new List<AnimationEdge>();
flows ??= new List<AnimationFlow>();
importSources ??= new List<FrameAnimationImportSource>();
settings ??= new FrameAnimationGraphSettings();
editorData ??= new FrameAnimationGraphEditorData();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4c9e0458642034545b44454bbadcf059
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AibisDream.FrameAnimation
{
internal sealed class FrameAnimationReachability
{
public IReadOnlyList<AnimationNode> Nodes { get; }
public IReadOnlyList<AnimationEdge> Edges { get; }
internal FrameAnimationReachability(
IReadOnlyList<AnimationNode> nodes,
IReadOnlyList<AnimationEdge> edges)
{
Nodes = nodes ?? Array.Empty<AnimationNode>();
Edges = edges ?? Array.Empty<AnimationEdge>();
}
}
internal sealed class FrameAnimationGraphTopology
{
private readonly FrameAnimationGraph graph;
private readonly Dictionary<string, List<AnimationNode>> nodesById;
private readonly Dictionary<string, List<AnimationEdge>> outgoingByNodeId;
internal FrameAnimationGraphTopology(FrameAnimationGraph graph)
{
this.graph = graph;
nodesById = (graph?.Nodes ?? Array.Empty<AnimationNode>())
.Where(node => node != null)
.GroupBy(node => node.InternalId ?? string.Empty)
.ToDictionary(group => group.Key, group => group.ToList());
outgoingByNodeId = (graph?.Edges ?? Array.Empty<AnimationEdge>())
.Where(edge => edge != null)
.GroupBy(edge => edge.FromNodeId ?? string.Empty)
.ToDictionary(group => group.Key, group => group.ToList());
}
internal IReadOnlyList<AnimationNode> FindNodes(string internalId)
{
return nodesById.TryGetValue(internalId ?? string.Empty, out var nodes)
? nodes
: Array.Empty<AnimationNode>();
}
internal IReadOnlyList<AnimationEdge> GetOutgoing(string nodeId)
{
return outgoingByNodeId.TryGetValue(nodeId ?? string.Empty, out var edges)
? edges
: Array.Empty<AnimationEdge>();
}
internal FrameAnimationReachability GetReachable(string entryNodeId)
{
var nodes = new List<AnimationNode>();
var edges = new List<AnimationEdge>();
var visitedNodes = new HashSet<string>();
var visitedEdges = new HashSet<string>();
var queue = new Queue<string>();
queue.Enqueue(entryNodeId ?? string.Empty);
while (queue.Count > 0)
{
var nodeId = queue.Dequeue();
if (!visitedNodes.Add(nodeId))
{
continue;
}
if (nodesById.TryGetValue(nodeId, out var matches))
{
nodes.AddRange(matches);
}
if (!outgoingByNodeId.TryGetValue(nodeId, out var outgoing))
{
continue;
}
foreach (var edge in outgoing)
{
if (visitedEdges.Add(edge.InternalId ?? string.Empty))
{
edges.Add(edge);
}
queue.Enqueue(edge.ToNodeId ?? string.Empty);
}
}
return new FrameAnimationReachability(nodes, edges);
}
internal bool WouldCreateCycle(string fromNodeId, string toNodeId)
{
if (string.IsNullOrEmpty(fromNodeId) || string.IsNullOrEmpty(toNodeId) || fromNodeId == toNodeId)
{
return true;
}
var visited = new HashSet<string>();
var stack = new Stack<string>();
stack.Push(toNodeId);
while (stack.Count > 0)
{
var current = stack.Pop();
if (!visited.Add(current))
{
continue;
}
if (current == fromNodeId)
{
return true;
}
foreach (var edge in GetOutgoing(current))
{
stack.Push(edge.ToNodeId ?? string.Empty);
}
}
return false;
}
internal IReadOnlyList<AnimationFlow> FindFlowsUsingNode(string nodeId)
{
if (graph == null)
{
return Array.Empty<AnimationFlow>();
}
return graph.Flows.Where(flow => flow != null &&
GetReachable(flow.EntryNodeId).Nodes.Any(node => node != null && node.InternalId == nodeId))
.OrderBy(flow => flow.Id, StringComparer.Ordinal)
.ToArray();
}
internal IReadOnlyList<AnimationFlow> FindFlowsUsingEdge(string edgeId)
{
if (graph == null)
{
return Array.Empty<AnimationFlow>();
}
return graph.Flows.Where(flow => flow != null &&
GetReachable(flow.EntryNodeId).Edges.Any(edge => edge != null && edge.InternalId == edgeId))
.OrderBy(flow => flow.Id, StringComparer.Ordinal)
.ToArray();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 87a6d13f70dd4f9c8da82dbb48cf4931
@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
namespace AibisDream.FrameAnimation
{
public sealed class FrameAnimationPlaybackHandle : CustomYieldInstruction
{
private readonly TaskCompletionSource<FrameAnimationPlaybackResult> completionSource =
new TaskCompletionSource<FrameAnimationPlaybackResult>(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly List<Action<FrameAnimationPlaybackResult>> callbacks =
new List<Action<FrameAnimationPlaybackResult>>();
private bool isCompleted;
private FrameAnimationPlaybackResult result;
public long RequestId { get; }
public bool IsCompleted => isCompleted;
public override bool keepWaiting => !isCompleted;
public FrameAnimationPlaybackResult Result
{
get
{
if (!isCompleted)
{
throw new InvalidOperationException("播放请求尚未完成,不能读取 Result。");
}
return result;
}
}
internal FrameAnimationPlaybackHandle(long requestId)
{
RequestId = requestId;
}
public Task<FrameAnimationPlaybackResult> WaitAsync()
{
return completionSource.Task;
}
public void RegisterCompleted(Action<FrameAnimationPlaybackResult> callback)
{
if (callback == null)
{
throw new ArgumentNullException(nameof(callback));
}
if (isCompleted)
{
InvokeCallback(callback, result);
return;
}
callbacks.Add(callback);
}
internal bool TryComplete(FrameAnimationPlaybackResult completionResult)
{
if (isCompleted)
{
return false;
}
isCompleted = true;
result = completionResult;
completionSource.TrySetResult(completionResult);
var callbacksToInvoke = callbacks.ToArray();
callbacks.Clear();
foreach (var callback in callbacksToInvoke)
{
InvokeCallback(callback, completionResult);
}
return true;
}
private static void InvokeCallback(
Action<FrameAnimationPlaybackResult> callback,
FrameAnimationPlaybackResult completionResult)
{
try
{
callback(completionResult);
}
catch (Exception exception)
{
Debug.LogException(exception);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4495b4d2175155c45bdf6d51857fb9f3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,220 @@
using System;
using UnityEngine;
namespace AibisDream.FrameAnimation
{
internal readonly struct FrameAnimationSessionEvaluation
{
public static FrameAnimationSessionEvaluation Running =>
new FrameAnimationSessionEvaluation(
false,
FrameClipEndBehavior.HoldLastFrame,
FrameAnimationPlaybackError.None);
public bool IsCompleted { get; }
public FrameClipEndBehavior EndBehavior { get; }
public FrameAnimationPlaybackError Error { get; }
public bool IsFailed => Error.Code != FrameAnimationPlaybackErrorCode.None;
private FrameAnimationSessionEvaluation(
bool isCompleted,
FrameClipEndBehavior endBehavior,
FrameAnimationPlaybackError error)
{
IsCompleted = isCompleted;
EndBehavior = endBehavior;
Error = error;
}
public static FrameAnimationSessionEvaluation Completed(FrameClipEndBehavior endBehavior)
{
return new FrameAnimationSessionEvaluation(true, endBehavior, FrameAnimationPlaybackError.None);
}
public static FrameAnimationSessionEvaluation Failed(
FrameAnimationPlaybackErrorCode code,
string message)
{
return new FrameAnimationSessionEvaluation(
false,
FrameClipEndBehavior.HoldLastFrame,
new FrameAnimationPlaybackError(code, message));
}
}
internal sealed class FrameAnimationPlaybackSession
{
private const double Epsilon = 0.000000001d;
private readonly ResolvedPlaybackPlan plan;
private int stepIndex;
private int frameIndex;
private double frameElapsedSeconds;
private bool hasStarted;
private bool hasCompleted;
public string PlayableId => plan.PlayableId;
public string CurrentClipId => CurrentStep?.Clip != null ? CurrentStep.Clip.Id : string.Empty;
public string CurrentNodeId => plan.IsFlow && CurrentStep != null ? CurrentStep.NodeId : string.Empty;
public int CurrentFrameIndex => hasStarted && !hasCompleted ? frameIndex : -1;
public FrameClip CurrentClip => CurrentStep?.Clip;
private ResolvedPlaybackStep CurrentStep =>
stepIndex >= 0 && stepIndex < plan.Steps.Count ? plan.Steps[stepIndex] : null;
internal FrameAnimationPlaybackSession(ResolvedPlaybackPlan plan)
{
this.plan = plan ?? throw new ArgumentNullException(nameof(plan));
}
public void Start(Action<Sprite> applySprite)
{
if (hasStarted)
{
return;
}
hasStarted = true;
stepIndex = 0;
frameIndex = 0;
frameElapsedSeconds = 0d;
ApplyCurrentFrame(applySprite);
}
public FrameAnimationSessionEvaluation Evaluate(
double deltaTimeSeconds,
float playerSpeed,
Action<Sprite> applySprite)
{
if (!hasStarted || hasCompleted || deltaTimeSeconds <= 0d)
{
return FrameAnimationSessionEvaluation.Running;
}
var remainingRealSeconds = deltaTimeSeconds;
while (remainingRealSeconds > Epsilon)
{
var step = CurrentStep;
if (step?.Clip == null ||
frameIndex < 0 ||
frameIndex >= step.Clip.FrameCount)
{
hasCompleted = true;
return FrameAnimationSessionEvaluation.Failed(
FrameAnimationPlaybackErrorCode.InvalidPlayableData,
"播放期间 Clip 或 Frame 数据失效。");
}
var effectiveSpeed = playerSpeed * step.PlaybackSpeed;
if (!FrameAnimationValueUtility.IsValidSpeed(effectiveSpeed))
{
hasCompleted = true;
return FrameAnimationSessionEvaluation.Failed(
FrameAnimationPlaybackErrorCode.InvalidSpeed,
"播放期间有效速度变为无效值。");
}
if (effectiveSpeed == 0f)
{
return FrameAnimationSessionEvaluation.Running;
}
if (IsTerminalLoop(step) && frameIndex == 0 && frameElapsedSeconds <= Epsilon)
{
var cycleRealDuration = GetClipDurationSeconds(step.Clip) / effectiveSpeed;
if (cycleRealDuration > Epsilon && remainingRealSeconds >= cycleRealDuration)
{
remainingRealSeconds %= cycleRealDuration;
if (remainingRealSeconds <= Epsilon)
{
return FrameAnimationSessionEvaluation.Running;
}
}
}
var frame = step.Clip.Frames[frameIndex];
if (frame == null || frame.DurationMs <= 0)
{
hasCompleted = true;
return FrameAnimationSessionEvaluation.Failed(
FrameAnimationPlaybackErrorCode.InvalidPlayableData,
"播放期间 Frame 数据失效或 durationMs 不再有效。");
}
var frameDurationSeconds = frame.DurationMs / 1000d;
var remainingAnimationSeconds = Math.Max(0d, frameDurationSeconds - frameElapsedSeconds);
var realSecondsToBoundary = remainingAnimationSeconds / effectiveSpeed;
if (remainingRealSeconds + Epsilon < realSecondsToBoundary)
{
frameElapsedSeconds += remainingRealSeconds * effectiveSpeed;
return FrameAnimationSessionEvaluation.Running;
}
remainingRealSeconds = Math.Max(0d, remainingRealSeconds - realSecondsToBoundary);
frameElapsedSeconds = 0d;
var completion = AdvanceFrameOrStep(applySprite);
if (completion.IsCompleted)
{
return completion;
}
}
return FrameAnimationSessionEvaluation.Running;
}
private FrameAnimationSessionEvaluation AdvanceFrameOrStep(Action<Sprite> applySprite)
{
var step = CurrentStep;
frameIndex++;
if (frameIndex < step.Clip.FrameCount)
{
ApplyCurrentFrame(applySprite);
return FrameAnimationSessionEvaluation.Running;
}
var isTerminalStep = stepIndex == plan.Steps.Count - 1;
if (!isTerminalStep)
{
stepIndex++;
frameIndex = 0;
ApplyCurrentFrame(applySprite);
return FrameAnimationSessionEvaluation.Running;
}
if (step.TerminalEndBehavior == FrameClipEndBehavior.Loop)
{
frameIndex = 0;
ApplyCurrentFrame(applySprite);
return FrameAnimationSessionEvaluation.Running;
}
frameIndex = step.Clip.FrameCount - 1;
hasCompleted = true;
return FrameAnimationSessionEvaluation.Completed(step.TerminalEndBehavior);
}
private bool IsTerminalLoop(ResolvedPlaybackStep step)
{
return stepIndex == plan.Steps.Count - 1 &&
step.TerminalEndBehavior == FrameClipEndBehavior.Loop;
}
private static double GetClipDurationSeconds(FrameClip clip)
{
return clip.TotalDurationMs / 1000d;
}
private void ApplyCurrentFrame(Action<Sprite> applySprite)
{
var step = CurrentStep;
if (step?.Clip == null || frameIndex < 0 || frameIndex >= step.Clip.FrameCount)
{
return;
}
applySprite?.Invoke(step.Clip.Frames[frameIndex]?.Sprite);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 315bd2832bfe093459fab9f2358a7832
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,414 @@
using System.Threading;
using UnityEngine;
using UnityEngine.UI;
namespace AibisDream.FrameAnimation
{
[DisallowMultipleComponent]
public sealed class FrameAnimationPlayer : MonoBehaviour
{
[SerializeField] private FrameAnimationGraph graph;
[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;
public FrameAnimationGraph Graph => graph;
public bool PlayOnEnable => playOnEnable;
public FrameAnimationPlaybackState State => state;
public string CurrentPlayableId => session?.PlayableId ?? string.Empty;
public string CurrentClipId => session?.CurrentClipId ?? string.Empty;
public string CurrentNodeId => session?.CurrentNodeId ?? 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)
{
return;
}
TryAutoPlay();
}
private void TryAutoPlay()
{
var handle = Play();
if (handle.IsCompleted && handle.Result.Reason == FrameAnimationCompletionReason.Failed)
{
Debug.LogError($"FrameAnimationPlayer 自动播放失败:{handle.Result.Error}", this);
}
}
private void Update()
{
if (state != FrameAnimationPlaybackState.Playing || session == null)
{
return;
}
if (target == null || !target.IsValid)
{
FailActive(FrameAnimationPlaybackErrorCode.TargetMissing, "播放期间显示目标失效。");
return;
}
if (graph == null || session.CurrentClip == null)
{
FailActive(FrameAnimationPlaybackErrorCode.InvalidPlayableData, "播放期间 Graph 或 Clip 数据失效。");
return;
}
if (!FrameAnimationValueUtility.IsValidSpeed(speed))
{
FailActive(FrameAnimationPlaybackErrorCode.InvalidSpeed, "播放期间 Player speed 变为无效值。");
return;
}
var evaluation = session.Evaluate(Time.deltaTime, speed, ApplySprite);
if (evaluation.IsFailed)
{
FailActive(evaluation.Error.Code, evaluation.Error.Message);
}
else if (evaluation.IsCompleted)
{
CompleteNaturally(evaluation.EndBehavior);
}
}
private void OnDisable()
{
TerminateActive(FrameAnimationCompletionReason.Stopped, FrameAnimationPlaybackError.None);
}
private void OnDestroy()
{
TerminateActive(FrameAnimationCompletionReason.Stopped, FrameAnimationPlaybackError.None);
}
public FrameAnimationPlaybackHandle Play()
{
var playableId = graph?.Settings?.DefaultPlayableId ?? string.Empty;
return PlayInternal(playableId, default);
}
public FrameAnimationPlaybackHandle Play(string playableId)
{
return PlayInternal(playableId, default);
}
public FrameAnimationPlaybackHandle Play(string playableId, FrameAnimationPlayOptions options)
{
return PlayInternal(playableId, options);
}
public void Stop(FrameAnimationStopMode mode = FrameAnimationStopMode.HoldCurrentFrame)
{
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($"FrameAnimationPlayer speed 必须是有限且不小于 0 的数值,收到:{value}", this);
return;
}
speed = value;
}
internal void ConfigureForAuthoring(FrameAnimationGraph value, bool shouldPlayOnEnable, float playerSpeed)
{
graph = value;
playOnEnable = shouldPlayOnEnable;
speed = playerSpeed;
}
internal void EvaluateForTests(double deltaTimeSeconds)
{
if (state != FrameAnimationPlaybackState.Playing || session == null)
{
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(
string playableId,
FrameAnimationPlayOptions options)
{
var requestId = Interlocked.Increment(ref nextRequestId);
var handle = new FrameAnimationPlaybackHandle(requestId);
if (!isActiveAndEnabled)
{
return CompleteFailed(
handle,
playableId,
FrameAnimationPlaybackErrorCode.PlayerNotReady,
"FrameAnimationPlayer 未启用或 GameObject 未激活。");
}
if (graph == null)
{
return CompleteFailed(
handle,
playableId,
FrameAnimationPlaybackErrorCode.GraphMissing,
"FrameAnimationPlayer 没有绑定 FrameAnimationGraph。");
}
if (!FrameAnimationValueUtility.IsValidSpeed(speed))
{
return CompleteFailed(
handle,
playableId,
FrameAnimationPlaybackErrorCode.InvalidSpeed,
"FrameAnimationPlayer speed 无效。");
}
if (!TryCreateTarget(out var resolvedTarget, out var targetError))
{
return CompleteFailed(handle, playableId, targetError.Code, targetError.Message);
}
if (!FrameAnimationResolver.TryResolve(graph, playableId, options, out var plan, out var resolveError))
{
return CompleteFailed(handle, playableId, resolveError.Code, resolveError.Message);
}
if (activeHandle != null && !activeHandle.IsCompleted)
{
CompleteHandle(activeHandle, CurrentPlayableId, 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 TryCreateTarget(
out IFrameAnimationTarget resolvedTarget,
out FrameAnimationPlaybackError error)
{
var spriteRenderer = GetComponent<SpriteRenderer>();
var image = GetComponent<Image>();
if (spriteRenderer != null && image != null)
{
resolvedTarget = null;
error = new FrameAnimationPlaybackError(
FrameAnimationPlaybackErrorCode.TargetConflict,
"FrameAnimationPlayer 所在对象不能同时包含 SpriteRenderer 和 Image。");
return false;
}
if (spriteRenderer == null && image == null)
{
resolvedTarget = null;
error = new FrameAnimationPlaybackError(
FrameAnimationPlaybackErrorCode.TargetMissing,
"FrameAnimationPlayer 所在对象缺少 SpriteRenderer 或 Image。");
return false;
}
resolvedTarget = spriteRenderer != null
? new SpriteRendererFrameAnimationTarget(spriteRenderer)
: new ImageFrameAnimationTarget(image);
error = FrameAnimationPlaybackError.None;
return true;
}
private 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 = CurrentPlayableId;
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 = CurrentPlayableId;
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 = CurrentPlayableId;
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 void ApplyEndBehavior(FrameClipEndBehavior endBehavior)
{
switch (endBehavior)
{
case FrameClipEndBehavior.HoldLastFrame:
break;
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.HoldCurrentFrame:
break;
case FrameAnimationStopMode.Clear:
target.Enabled = true;
target.Sprite = null;
break;
case FrameAnimationStopMode.HideTarget:
target.Enabled = false;
break;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a8c5a5233fb69c042a7e6345f4ab3c54
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,274 @@
using System.Collections.Generic;
using System.Linq;
namespace AibisDream.FrameAnimation
{
internal sealed class ResolvedPlaybackStep
{
public FrameClip Clip { get; }
public string NodeId { get; }
public float PlaybackSpeed { get; }
public FrameClipEndBehavior TerminalEndBehavior { get; }
public ResolvedPlaybackStep(
FrameClip clip,
string nodeId,
float playbackSpeed,
FrameClipEndBehavior terminalEndBehavior)
{
Clip = clip;
NodeId = nodeId ?? string.Empty;
PlaybackSpeed = playbackSpeed;
TerminalEndBehavior = terminalEndBehavior;
}
}
internal sealed class ResolvedPlaybackPlan
{
public string PlayableId { get; }
public bool IsFlow { get; }
public IReadOnlyList<ResolvedPlaybackStep> Steps { get; }
public ResolvedPlaybackPlan(string playableId, bool isFlow, IReadOnlyList<ResolvedPlaybackStep> steps)
{
PlayableId = playableId;
IsFlow = isFlow;
Steps = steps;
}
}
internal static class FrameAnimationResolver
{
public static bool TryResolve(
FrameAnimationGraph graph,
string playableId,
FrameAnimationPlayOptions options,
out ResolvedPlaybackPlan plan,
out FrameAnimationPlaybackError error)
{
plan = null;
error = FrameAnimationPlaybackError.None;
if (graph == null)
{
error = Error(FrameAnimationPlaybackErrorCode.GraphMissing, "FrameAnimationGraph 为空。");
return false;
}
if (string.IsNullOrWhiteSpace(playableId))
{
error = Error(FrameAnimationPlaybackErrorCode.PlayableIdEmpty, "playableId 不能为空。");
return false;
}
var clipMatches = graph.Clips.Where(clip => clip != null && clip.Id == playableId).ToList();
var flowMatches = graph.Flows.Where(flow => flow != null && flow.Id == playableId).ToList();
var matchCount = clipMatches.Count + flowMatches.Count;
if (matchCount == 0)
{
error = Error(
FrameAnimationPlaybackErrorCode.PlayableNotFound,
$"Graph '{graph.Id}' 中不存在 playable '{playableId}'。");
return false;
}
if (matchCount > 1)
{
error = Error(
FrameAnimationPlaybackErrorCode.InvalidPlayableData,
$"playableId '{playableId}' 在 Clip / Flow 统一命名空间中不唯一。");
return false;
}
if (clipMatches.Count == 1)
{
return TryResolveClip(clipMatches[0], playableId, options, out plan, out error);
}
return TryResolveFlow(graph, flowMatches[0], options, out plan, out error);
}
private static bool TryResolveClip(
FrameClip clip,
string playableId,
FrameAnimationPlayOptions options,
out ResolvedPlaybackPlan plan,
out FrameAnimationPlaybackError error)
{
plan = null;
if (!TryValidateClip(clip, out error))
{
return false;
}
var endBehavior = options.EndBehaviorOverride ?? clip.DefaultEndBehavior;
plan = new ResolvedPlaybackPlan(
playableId,
false,
new[] { new ResolvedPlaybackStep(clip, string.Empty, clip.Speed, endBehavior) });
return true;
}
private static bool TryResolveFlow(
FrameAnimationGraph graph,
AnimationFlow flow,
FrameAnimationPlayOptions options,
out ResolvedPlaybackPlan plan,
out FrameAnimationPlaybackError error)
{
plan = null;
error = FrameAnimationPlaybackError.None;
var steps = new List<ResolvedPlaybackStep>();
var visited = new HashSet<string>();
var currentNodeId = flow.EntryNodeId;
var topology = new FrameAnimationGraphTopology(graph);
while (true)
{
if (!FrameAnimationValueUtility.IsValidInternalId(currentNodeId))
{
error = Error(
FrameAnimationPlaybackErrorCode.InvalidPlayableData,
$"Flow '{flow.Id}' 的入口或后继 Node internalId 无效。");
return false;
}
if (!visited.Add(currentNodeId))
{
error = Error(
FrameAnimationPlaybackErrorCode.InvalidPlayableData,
$"Flow '{flow.Id}' 的可达路径形成了环路。");
return false;
}
var nodeMatches = topology.FindNodes(currentNodeId);
if (nodeMatches.Count != 1)
{
error = Error(
FrameAnimationPlaybackErrorCode.InvalidPlayableData,
$"Flow '{flow.Id}' 的 Node '{currentNodeId}' 不存在或不唯一。");
return false;
}
var node = nodeMatches[0];
if (node.Type != AnimationNodeType.Clip)
{
error = Error(
FrameAnimationPlaybackErrorCode.InvalidPlayableData,
$"Node '{node.InternalId}' 使用了第一版不支持的节点类型。");
return false;
}
var clipMatches = graph.Clips.Where(clip => clip != null && clip.Id == node.ClipId).ToList();
if (clipMatches.Count != 1)
{
error = Error(
FrameAnimationPlaybackErrorCode.InvalidPlayableData,
$"Node '{node.InternalId}' 的 clipId '{node.ClipId}' 不存在或不唯一。");
return false;
}
var clip = clipMatches[0];
if (!TryValidateClip(clip, out error))
{
return false;
}
var nodeSpeed = node.SpeedOverride ?? clip.Speed;
if (!FrameAnimationValueUtility.IsValidSpeed(nodeSpeed))
{
error = Error(
FrameAnimationPlaybackErrorCode.InvalidSpeed,
$"Node '{node.InternalId}' 的有效播放速度无效。");
return false;
}
var outgoing = topology.GetOutgoing(currentNodeId);
if (outgoing.Count > 1)
{
error = Error(
FrameAnimationPlaybackErrorCode.InvalidPlayableData,
$"Node '{node.InternalId}' 第一版最多只能有一个后继 Edge。");
return false;
}
if (node.EndBehaviorOverride.HasValue && outgoing.Count > 0)
{
error = Error(
FrameAnimationPlaybackErrorCode.InvalidPlayableData,
$"Node '{node.InternalId}' 已设置结束行为,不能同时拥有后继 Edge。");
return false;
}
var isTerminal = outgoing.Count == 0;
var terminalBehavior = node.EndBehaviorOverride ??
options.EndBehaviorOverride ??
flow.EndBehaviorOverride ??
clip.DefaultEndBehavior;
steps.Add(new ResolvedPlaybackStep(clip, node.InternalId, nodeSpeed, terminalBehavior));
if (isTerminal)
{
break;
}
var edge = outgoing[0];
if (!FrameAnimationValueUtility.IsValidInternalId(edge.InternalId) ||
edge.Condition != AnimationEdgeCondition.Always ||
edge.FromNodeId == edge.ToNodeId)
{
error = Error(
FrameAnimationPlaybackErrorCode.InvalidPlayableData,
$"Node '{node.InternalId}' 的后继 Edge 无效。");
return false;
}
currentNodeId = edge.ToNodeId;
}
plan = new ResolvedPlaybackPlan(flow.Id, true, steps);
return true;
}
private static bool TryValidateClip(FrameClip clip, out FrameAnimationPlaybackError error)
{
if (clip == null || string.IsNullOrWhiteSpace(clip.Id) || clip.FrameCount == 0)
{
error = Error(
FrameAnimationPlaybackErrorCode.InvalidPlayableData,
"FrameClip 为空、id 为空或没有 Frame。");
return false;
}
if (!FrameAnimationValueUtility.IsValidSpeed(clip.Speed))
{
error = Error(
FrameAnimationPlaybackErrorCode.InvalidSpeed,
$"Clip '{clip.Id}' 的 speed 无效。");
return false;
}
for (var index = 0; index < clip.Frames.Count; index++)
{
var frame = clip.Frames[index];
if (frame == null || frame.DurationMs <= 0)
{
error = Error(
FrameAnimationPlaybackErrorCode.InvalidPlayableData,
$"Clip '{clip.Id}' 的第 {index} 帧为空或 durationMs 无效。");
return false;
}
}
error = FrameAnimationPlaybackError.None;
return true;
}
private static FrameAnimationPlaybackError Error(
FrameAnimationPlaybackErrorCode code,
string message)
{
return new FrameAnimationPlaybackError(code, message);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 66463245ce9eb9e41a61b53fb2302410
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,80 @@
using UnityEngine;
using UnityEngine.UI;
namespace AibisDream.FrameAnimation
{
internal interface IFrameAnimationTarget
{
bool IsValid { get; }
bool Enabled { get; set; }
Sprite Sprite { get; set; }
}
internal sealed class SpriteRendererFrameAnimationTarget : IFrameAnimationTarget
{
private readonly SpriteRenderer target;
public bool IsValid => target != null;
public bool Enabled
{
get => target != null && target.enabled;
set
{
if (target != null)
{
target.enabled = value;
}
}
}
public Sprite Sprite
{
get => target != null ? target.sprite : null;
set
{
if (target != null)
{
target.sprite = value;
}
}
}
public SpriteRendererFrameAnimationTarget(SpriteRenderer target)
{
this.target = target;
}
}
internal sealed class ImageFrameAnimationTarget : IFrameAnimationTarget
{
private readonly Image target;
public bool IsValid => target != null;
public bool Enabled
{
get => target != null && target.enabled;
set
{
if (target != null)
{
target.enabled = value;
}
}
}
public Sprite Sprite
{
get => target != null ? target.sprite : null;
set
{
if (target != null)
{
target.sprite = value;
}
}
}
public ImageFrameAnimationTarget(Image target)
{
this.target = target;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 41f47bb181c662f4a8c1140f976821f0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,127 @@
using System;
namespace AibisDream.FrameAnimation
{
public enum FrameClipEndBehavior
{
HoldLastFrame,
Loop,
Clear,
HideTarget
}
public enum AnimationNodeType
{
Clip
}
public enum AnimationEdgeCondition
{
Always
}
public enum FrameAnimationPlaybackState
{
Stopped,
Playing,
Paused
}
public enum FrameAnimationStopMode
{
HoldCurrentFrame,
Clear,
HideTarget
}
public enum FrameAnimationCompletionReason
{
Completed,
Replaced,
Stopped,
Failed
}
public enum FrameAnimationPlaybackErrorCode
{
None,
PlayerNotReady,
GraphMissing,
TargetMissing,
TargetConflict,
PlayableIdEmpty,
PlayableNotFound,
InvalidPlayableData,
InvalidSpeed
}
public readonly struct FrameAnimationPlayOptions
{
public FrameClipEndBehavior? EndBehaviorOverride { get; }
public FrameAnimationPlayOptions(FrameClipEndBehavior? endBehaviorOverride)
{
EndBehaviorOverride = endBehaviorOverride;
}
}
public readonly struct FrameAnimationPlaybackError
{
public static FrameAnimationPlaybackError None =>
new FrameAnimationPlaybackError(FrameAnimationPlaybackErrorCode.None, string.Empty);
public FrameAnimationPlaybackErrorCode Code { get; }
public string Message { get; }
public FrameAnimationPlaybackError(FrameAnimationPlaybackErrorCode code, string message)
{
Code = code;
Message = message ?? string.Empty;
}
public override string ToString()
{
return Code == FrameAnimationPlaybackErrorCode.None
? nameof(FrameAnimationPlaybackErrorCode.None)
: $"{Code}: {Message}";
}
}
public readonly struct FrameAnimationPlaybackResult
{
public long RequestId { get; }
public string PlayableId { get; }
public FrameAnimationCompletionReason Reason { get; }
public FrameAnimationPlaybackError Error { get; }
public FrameAnimationPlaybackResult(
long requestId,
string playableId,
FrameAnimationCompletionReason reason,
FrameAnimationPlaybackError error)
{
RequestId = requestId;
PlayableId = playableId ?? string.Empty;
Reason = reason;
Error = error;
}
}
internal static class FrameAnimationValueUtility
{
public static bool IsValidSpeed(float value)
{
return value >= 0f && !float.IsNaN(value) && !float.IsInfinity(value);
}
public static bool IsValidInternalId(string value)
{
return !string.IsNullOrWhiteSpace(value) && Guid.TryParseExact(value, "N", out _);
}
public static string NewInternalId()
{
return Guid.NewGuid().ToString("N");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ed7202be469ab25458e1dbf38806f65d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,541 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AibisDream.FrameAnimation
{
public enum FrameAnimationValidationSeverity
{
Info,
Warning,
Error
}
public enum FrameAnimationValidationTargetType
{
Graph,
Clip,
Node,
Edge,
Flow,
ImportSource
}
public enum FrameAnimationValidationCode
{
GraphMissing,
PlayableIdEmpty,
PlayableIdDuplicate,
ClipMissing,
ClipFramesEmpty,
FrameMissing,
FrameDurationInvalid,
SpeedInvalid,
InternalIdInvalid,
InternalIdDuplicate,
NodeClipMissing,
EdgeNodeMissing,
EdgeConditionInvalid,
EdgeMultiplicityInvalid,
EdgeSelfReference,
EdgeCycle,
NodeTerminalHasSuccessor,
FlowEntryMissing,
FlowEntryDuplicate,
DefaultPlayableInvalid,
NodeUnused,
ImportSourceReferenceMissing,
ImportedClipMissingFromSource
}
public sealed class FrameAnimationValidationIssue
{
public FrameAnimationValidationSeverity Severity { get; }
public FrameAnimationValidationCode Code { get; }
public FrameAnimationValidationTargetType TargetType { get; }
public string TargetId { get; }
public string Message { get; }
internal FrameAnimationValidationIssue(
FrameAnimationValidationSeverity severity,
FrameAnimationValidationCode code,
FrameAnimationValidationTargetType targetType,
string targetId,
string message)
{
Severity = severity;
Code = code;
TargetType = targetType;
TargetId = targetId ?? string.Empty;
Message = message ?? string.Empty;
}
}
public sealed class FrameAnimationValidationReport
{
private readonly List<FrameAnimationValidationIssue> issues = new List<FrameAnimationValidationIssue>();
public IReadOnlyList<FrameAnimationValidationIssue> Issues => issues;
public bool HasErrors => issues.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error);
internal void Add(
FrameAnimationValidationSeverity severity,
FrameAnimationValidationCode code,
FrameAnimationValidationTargetType targetType,
string targetId,
string message)
{
issues.Add(new FrameAnimationValidationIssue(severity, code, targetType, targetId, message));
}
}
public static class FrameAnimationGraphValidator
{
public static FrameAnimationValidationReport Validate(FrameAnimationGraph graph)
{
var report = new FrameAnimationValidationReport();
if (graph == null)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.GraphMissing,
FrameAnimationValidationTargetType.Graph,
string.Empty,
"FrameAnimationGraph 为空。");
return report;
}
ValidatePlayableIds(graph, report);
ValidateClips(graph, report);
ValidateImportAssociations(graph, report);
ValidateInternalIds(graph.Nodes, node => node?.InternalId, FrameAnimationValidationTargetType.Node, report);
ValidateInternalIds(graph.Edges, edge => edge?.InternalId, FrameAnimationValidationTargetType.Edge, report);
ValidateInternalIds(
graph.ImportSources,
source => source?.InternalId,
FrameAnimationValidationTargetType.ImportSource,
report);
ValidateNodesAndEdges(graph, report);
ValidateFlows(graph, report);
ValidateDefaultPlayable(graph, report);
ValidateUnusedNodes(graph, report);
return report;
}
private static void ValidateImportAssociations(
FrameAnimationGraph graph,
FrameAnimationValidationReport report)
{
var sourceIds = new HashSet<string>(
graph.ImportSources
.Where(source => source != null)
.Select(source => source.InternalId));
foreach (var clip in graph.Clips.Where(clip => clip != null && clip.IsImported))
{
var importInfo = clip.ImportInfo;
if (importInfo == null ||
string.IsNullOrWhiteSpace(importInfo.ImportSourceId) ||
!sourceIds.Contains(importInfo.ImportSourceId))
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.ImportSourceReferenceMissing,
FrameAnimationValidationTargetType.Clip,
clip.Id,
$"Imported Clip '{clip.Id}' 没有指向本 Graph 中有效的 ImportSource。");
continue;
}
if (importInfo.IsMissingFromSource)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.ImportedClipMissingFromSource,
FrameAnimationValidationTargetType.Clip,
clip.Id,
$"Imported Clip '{clip.Id}' 对应的源 Tag '{importInfo.SourceTagName}' 已消失。");
}
}
}
private static void ValidatePlayableIds(FrameAnimationGraph graph, FrameAnimationValidationReport report)
{
var entries = new List<(string id, FrameAnimationValidationTargetType type)>();
foreach (var clip in graph.Clips)
{
if (clip == null)
{
continue;
}
if (string.IsNullOrWhiteSpace(clip.Id))
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.PlayableIdEmpty,
FrameAnimationValidationTargetType.Clip,
string.Empty,
"FrameClip playable id 不能为空。");
}
else
{
entries.Add((clip.Id, FrameAnimationValidationTargetType.Clip));
}
}
foreach (var flow in graph.Flows)
{
if (flow == null)
{
continue;
}
if (string.IsNullOrWhiteSpace(flow.Id))
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.PlayableIdEmpty,
FrameAnimationValidationTargetType.Flow,
string.Empty,
"AnimationFlow playable id 不能为空。");
}
else
{
entries.Add((flow.Id, FrameAnimationValidationTargetType.Flow));
}
}
foreach (var group in entries.GroupBy(entry => entry.id).Where(group => group.Count() > 1))
{
foreach (var entry in group)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.PlayableIdDuplicate,
entry.type,
group.Key,
$"playable id '{group.Key}' 在 Clip / Flow 统一命名空间中重复。");
}
}
}
private static void ValidateClips(FrameAnimationGraph graph, FrameAnimationValidationReport report)
{
for (var clipIndex = 0; clipIndex < graph.Clips.Count; clipIndex++)
{
var clip = graph.Clips[clipIndex];
if (clip == null)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.ClipMissing,
FrameAnimationValidationTargetType.Graph,
graph.Id,
$"Graph clips[{clipIndex}] 引用了空 Clip。");
continue;
}
if (!FrameAnimationValueUtility.IsValidSpeed(clip.Speed))
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.SpeedInvalid,
FrameAnimationValidationTargetType.Clip,
clip.Id,
$"Clip '{clip.Id}' 的 speed 必须是有限且不小于 0 的数值。");
}
if (clip.FrameCount == 0)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.ClipFramesEmpty,
FrameAnimationValidationTargetType.Clip,
clip.Id,
$"Clip '{clip.Id}' 没有任何 Frame。");
continue;
}
for (var frameIndex = 0; frameIndex < clip.Frames.Count; frameIndex++)
{
var frame = clip.Frames[frameIndex];
if (frame == null)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.FrameMissing,
FrameAnimationValidationTargetType.Clip,
clip.Id,
$"Clip '{clip.Id}' 的第 {frameIndex} 帧为空数据对象。");
}
else if (frame.DurationMs <= 0)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.FrameDurationInvalid,
FrameAnimationValidationTargetType.Clip,
clip.Id,
$"Clip '{clip.Id}' 的第 {frameIndex} 帧 durationMs 必须大于 0。");
}
}
}
}
private static void ValidateInternalIds<T>(
IReadOnlyList<T> items,
Func<T, string> idSelector,
FrameAnimationValidationTargetType targetType,
FrameAnimationValidationReport report)
{
var validIds = new List<string>();
foreach (var item in items)
{
var id = idSelector(item);
if (!FrameAnimationValueUtility.IsValidInternalId(id))
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.InternalIdInvalid,
targetType,
id,
$"{targetType} internalId 必须是不可变的 N 格式 GUID。");
}
else
{
validIds.Add(id);
}
}
foreach (var duplicate in validIds.GroupBy(id => id).Where(group => group.Count() > 1))
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.InternalIdDuplicate,
targetType,
duplicate.Key,
$"{targetType} internalId '{duplicate.Key}' 重复。");
}
}
private static void ValidateNodesAndEdges(FrameAnimationGraph graph, FrameAnimationValidationReport report)
{
var nodeCounts = graph.Nodes
.Where(node => node != null)
.GroupBy(node => node.InternalId ?? string.Empty)
.ToDictionary(group => group.Key, group => group.Count());
var outgoing = graph.Edges
.Where(edge => edge != null)
.GroupBy(edge => edge.FromNodeId ?? string.Empty)
.ToDictionary(group => group.Key, group => group.ToList());
foreach (var node in graph.Nodes)
{
if (node == null)
{
continue;
}
if (node.Type != AnimationNodeType.Clip)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.NodeClipMissing,
FrameAnimationValidationTargetType.Node,
node.InternalId,
$"Node '{node.InternalId}' 使用了第一版不支持的节点类型。");
}
var clipMatches = graph.Clips.Count(clip => clip != null && clip.Id == node.ClipId);
if (clipMatches != 1)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.NodeClipMissing,
FrameAnimationValidationTargetType.Node,
node.InternalId,
$"Node '{node.InternalId}' 的 clipId '{node.ClipId}' 必须唯一指向一个 Clip。");
}
if (node.SpeedOverride.HasValue &&
!FrameAnimationValueUtility.IsValidSpeed(node.SpeedOverride.Value))
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.SpeedInvalid,
FrameAnimationValidationTargetType.Node,
node.InternalId,
$"Node '{node.InternalId}' 的 speedOverride 必须是有限且不小于 0 的数值。");
}
var outgoingCount = outgoing.TryGetValue(node.InternalId ?? string.Empty, out var edges)
? edges.Count
: 0;
if (outgoingCount > 1)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.EdgeMultiplicityInvalid,
FrameAnimationValidationTargetType.Node,
node.InternalId,
$"Node '{node.InternalId}' 第一版最多只能有一个后继 Edge。");
}
if (node.EndBehaviorOverride.HasValue && outgoingCount > 0)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.NodeTerminalHasSuccessor,
FrameAnimationValidationTargetType.Node,
node.InternalId,
$"Node '{node.InternalId}' 已设置终点结束行为,不能同时连接后继 Edge。");
}
}
foreach (var edge in graph.Edges)
{
if (edge == null)
{
continue;
}
var fromValid = nodeCounts.TryGetValue(edge.FromNodeId ?? string.Empty, out var fromCount) && fromCount == 1;
var toValid = nodeCounts.TryGetValue(edge.ToNodeId ?? string.Empty, out var toCount) && toCount == 1;
if (!fromValid || !toValid)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.EdgeNodeMissing,
FrameAnimationValidationTargetType.Edge,
edge.InternalId,
$"Edge '{edge.InternalId}' 的起点或终点没有唯一对应的 Node。");
}
if (edge.Condition != AnimationEdgeCondition.Always)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.EdgeConditionInvalid,
FrameAnimationValidationTargetType.Edge,
edge.InternalId,
$"Edge '{edge.InternalId}' 第一版只支持 Always 条件。");
}
if (!string.IsNullOrEmpty(edge.FromNodeId) && edge.FromNodeId == edge.ToNodeId)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.EdgeSelfReference,
FrameAnimationValidationTargetType.Edge,
edge.InternalId,
$"Edge '{edge.InternalId}' 不允许自连接。");
}
}
ValidateCycles(graph, report);
}
private static void ValidateCycles(FrameAnimationGraph graph, FrameAnimationValidationReport report)
{
var topology = new FrameAnimationGraphTopology(graph);
var reported = new HashSet<string>();
foreach (var edge in graph.Edges.Where(edge => edge != null && edge.FromNodeId != edge.ToNodeId))
{
if (topology.WouldCreateCycle(edge.FromNodeId, edge.ToNodeId) &&
reported.Add(edge.FromNodeId ?? string.Empty))
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.EdgeCycle,
FrameAnimationValidationTargetType.Node,
edge.FromNodeId,
$"从 Node '{edge.FromNodeId}' 可达的 Edge 形成了环路。");
}
}
}
private static void ValidateFlows(FrameAnimationGraph graph, FrameAnimationValidationReport report)
{
var entryOwners = new Dictionary<string, List<string>>();
foreach (var flow in graph.Flows)
{
if (flow == null)
{
continue;
}
var matches = graph.Nodes.Count(node => node != null && node.InternalId == flow.EntryNodeId);
if (matches != 1)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.FlowEntryMissing,
FrameAnimationValidationTargetType.Flow,
flow.Id,
$"Flow '{flow.Id}' 的入口没有唯一对应的 Node。");
}
var entryNodeId = flow.EntryNodeId ?? string.Empty;
if (!entryOwners.TryGetValue(entryNodeId, out var owners))
{
owners = new List<string>();
entryOwners.Add(entryNodeId, owners);
}
owners.Add(flow.Id);
}
foreach (var pair in entryOwners.Where(pair => pair.Value.Count > 1))
{
foreach (var flowId in pair.Value)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.FlowEntryDuplicate,
FrameAnimationValidationTargetType.Flow,
flowId,
$"Node '{pair.Key}' 不能同时作为多个 Flow 的入口。");
}
}
}
private static void ValidateDefaultPlayable(FrameAnimationGraph graph, FrameAnimationValidationReport report)
{
var defaultId = graph.Settings?.DefaultPlayableId;
var matches = graph.Clips.Count(clip => clip != null && clip.Id == defaultId) +
graph.Flows.Count(flow => flow != null && flow.Id == defaultId);
if (string.IsNullOrWhiteSpace(defaultId) || matches != 1)
{
report.Add(
FrameAnimationValidationSeverity.Error,
FrameAnimationValidationCode.DefaultPlayableInvalid,
FrameAnimationValidationTargetType.Graph,
graph.Id,
"defaultPlayableId 必须唯一指向一个 Clip 或 Flow。");
}
}
private static void ValidateUnusedNodes(FrameAnimationGraph graph, FrameAnimationValidationReport report)
{
var reachable = new HashSet<string>();
var topology = new FrameAnimationGraphTopology(graph);
foreach (var flow in graph.Flows.Where(flow => flow != null))
{
foreach (var node in topology.GetReachable(flow.EntryNodeId).Nodes)
{
reachable.Add(node.InternalId ?? string.Empty);
}
}
foreach (var node in graph.Nodes.Where(node => node != null && !reachable.Contains(node.InternalId)))
{
report.Add(
FrameAnimationValidationSeverity.Warning,
FrameAnimationValidationCode.NodeUnused,
FrameAnimationValidationTargetType.Node,
node.InternalId,
$"Node '{node.InternalId}' 未被任何 Flow 使用。");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b086b5224a546b744bcac481c12b58ae
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,119 @@
using System.Collections.Generic;
using UnityEngine;
namespace AibisDream.FrameAnimation
{
[CreateAssetMenu(fileName = "FrameClip", menuName = "AibisDream/Frame Animation/Frame Clip")]
public sealed class FrameClip : ScriptableObject
{
[SerializeField] private string id = string.Empty;
[SerializeField] private string displayName = string.Empty;
[SerializeField] private List<FrameAnimationFrame> frames = new List<FrameAnimationFrame>();
[SerializeField] private float speed = 1f;
[SerializeField] private FrameClipEndBehavior defaultEndBehavior = FrameClipEndBehavior.HoldLastFrame;
[SerializeField] private bool hasImportInfo;
[SerializeField] private FrameClipImportInfo importInfo;
public string Id => id;
public string DisplayName => displayName;
public IReadOnlyList<FrameAnimationFrame> Frames => frames;
public float Speed => speed;
public FrameClipEndBehavior DefaultEndBehavior => defaultEndBehavior;
public FrameClipImportInfo ImportInfo => hasImportInfo ? importInfo : null;
public bool IsImported => ImportInfo != null;
public int FrameCount => frames?.Count ?? 0;
public long TotalDurationMs
{
get
{
long total = 0;
if (frames == null)
{
return total;
}
foreach (var frame in frames)
{
if (frame != null)
{
total += frame.DurationMs;
}
}
return total;
}
}
private void OnEnable()
{
frames ??= new List<FrameAnimationFrame>();
if (!hasImportInfo && importInfo != null &&
(!string.IsNullOrEmpty(importInfo.ImportSourceId) ||
!string.IsNullOrEmpty(importInfo.SourceTagName) ||
importInfo.IsMissingFromSource))
{
// Compatibility for phase-two assets created before the explicit presence flag existed.
hasImportInfo = true;
}
}
internal void Configure(
string clipId,
string clipDisplayName,
IEnumerable<FrameAnimationFrame> clipFrames,
float clipSpeed = 1f,
FrameClipEndBehavior endBehavior = FrameClipEndBehavior.HoldLastFrame,
FrameClipImportInfo clipImportInfo = null)
{
id = clipId ?? string.Empty;
displayName = clipDisplayName ?? id;
frames = clipFrames != null
? new List<FrameAnimationFrame>(clipFrames)
: new List<FrameAnimationFrame>();
speed = clipSpeed;
defaultEndBehavior = endBehavior;
importInfo = clipImportInfo;
hasImportInfo = clipImportInfo != null;
}
internal void ReplaceImportedFrames(
IEnumerable<FrameAnimationFrame> importedFrames,
string importSourceId,
string sourceTagName)
{
frames = importedFrames != null
? new List<FrameAnimationFrame>(importedFrames)
: new List<FrameAnimationFrame>();
importInfo ??= new FrameClipImportInfo(importSourceId, sourceTagName, false);
importInfo.Update(importSourceId, sourceTagName, false);
hasImportInfo = true;
}
internal void SetImportMissingState(bool isMissing)
{
if (hasImportInfo && importInfo != null)
{
importInfo.Update(importInfo.ImportSourceId, importInfo.SourceTagName, isMissing);
}
}
internal void SetId(string value)
{
id = value ?? string.Empty;
}
internal void SetDisplayName(string value)
{
displayName = value ?? string.Empty;
}
internal void SetFrames(IEnumerable<FrameAnimationFrame> value)
{
frames = value != null
? new List<FrameAnimationFrame>(value)
: new List<FrameAnimationFrame>();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 408fe45af4d321848a6c97f0356e8e5c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: