feat(frameAnima): 动画系统前四个阶段
This commit is contained in:
@@ -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:
|
||||
Reference in New Issue
Block a user