using System; using System.Collections.Generic; using System.IO; using System.Linq; using UnityEditor; using UnityEngine; namespace AibisDream.FrameAnimation.Editor { internal enum FrameAnimationEditorSelectionKind { None, Graph, Clip, Flow, Source, Node, Edge } internal readonly struct FrameAnimationEditorSelection { public FrameAnimationEditorSelectionKind Kind { get; } public object Value { get; } public FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind kind, object value) { Kind = kind; Value = value; } } internal enum FrameAnimationClipFilter { All, Manual, Imported, Normal, Missing, Referenced, Unused } internal enum FrameAnimationClipSort { Name, Source, FrameCount, Duration, Missing, ReferenceCount } internal static class FrameAnimationAssetReferenceIndex { public static IReadOnlyList FindGraphsReferencing(FrameClip clip) { if (clip == null) { return Array.Empty(); } return AssetDatabase.FindAssets("t:FrameAnimationGraph") .Select(guid => AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid))) .Where(graph => graph != null && graph.Clips.Any(item => item == clip)) .Distinct() .OrderBy(graph => AssetDatabase.GetAssetPath(graph), StringComparer.Ordinal) .ToArray(); } } internal static class FrameAnimationResourceQuery { public static IReadOnlyList QueryClips( FrameAnimationGraph graph, string search, FrameAnimationClipFilter filter, FrameAnimationClipSort sort) { if (graph == null) { return Array.Empty(); } var sourceNames = graph.ImportSources.Where(source => source != null) .GroupBy(source => source.InternalId ?? string.Empty) .ToDictionary(group => group.Key, group => group.First().DisplayName ?? string.Empty); var query = graph.Clips.Where(clip => clip != null && MatchesSearch(clip, sourceNames, search)); query = filter switch { FrameAnimationClipFilter.Manual => query.Where(clip => !clip.IsImported), FrameAnimationClipFilter.Imported => query.Where(clip => clip.IsImported), FrameAnimationClipFilter.Normal => query.Where(clip => !clip.IsImported || !clip.ImportInfo.IsMissingFromSource), FrameAnimationClipFilter.Missing => query.Where(clip => clip.IsImported && clip.ImportInfo.IsMissingFromSource), FrameAnimationClipFilter.Referenced => query.Where(clip => ReferenceCount(graph, clip) > 0), FrameAnimationClipFilter.Unused => query.Where(clip => ReferenceCount(graph, clip) == 0), _ => query }; IOrderedEnumerable ordered = sort switch { FrameAnimationClipSort.Source => query.OrderBy(clip => SourceName(clip, sourceNames), StringComparer.Ordinal), FrameAnimationClipSort.FrameCount => query.OrderBy(clip => clip.FrameCount), FrameAnimationClipSort.Duration => query.OrderBy(clip => clip.TotalDurationMs), FrameAnimationClipSort.Missing => query.OrderBy(clip => clip.IsImported && clip.ImportInfo.IsMissingFromSource ? 1 : 0), FrameAnimationClipSort.ReferenceCount => query.OrderBy(clip => ReferenceCount(graph, clip)), _ => query.OrderBy(clip => clip.DisplayName ?? clip.Id, StringComparer.Ordinal) }; return ordered.ThenBy(clip => clip.Id, StringComparer.Ordinal) .ThenBy(clip => AssetDatabase.GetAssetPath(clip), StringComparer.Ordinal) .ToArray(); } public static int ReferenceCount(FrameAnimationGraph graph, FrameClip clip) { return graph?.Nodes.Count(node => node != null && node.ClipId == clip?.Id) ?? 0; } public static string SourceName( FrameClip clip, IReadOnlyDictionary sourceNames = null) { if (clip == null || !clip.IsImported) { return clip != null && clip.HasStandaloneImportSource ? "Standalone Source" : "Manual"; } var sourceId = clip.ImportInfo?.ImportSourceId; if (sourceNames != null && !string.IsNullOrEmpty(sourceId) && sourceNames.TryGetValue(sourceId, out var name)) { return name; } return sourceId ?? string.Empty; } private static bool MatchesSearch( FrameClip clip, IReadOnlyDictionary sourceNames, string search) { if (string.IsNullOrWhiteSpace(search)) { return true; } return Contains(clip.Id, search) || Contains(clip.DisplayName, search) || Contains(clip.ImportInfo?.SourceTagName, search) || Contains(SourceName(clip, sourceNames), search); } private static bool Contains(string value, string search) { return !string.IsNullOrEmpty(value) && value.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0; } } internal static class FrameAnimationAssetOperations { public static bool IsPlayableIdAvailable( FrameAnimationGraph graph, string id, FrameClip exceptClip = null, AnimationFlow exceptFlow = null) { return graph != null && !string.IsNullOrWhiteSpace(id) && graph.Clips.All(clip => clip == null || clip == exceptClip || clip.Id != id) && graph.Flows.All(flow => flow == null || flow == exceptFlow || flow.Id != id); } public static bool CreateManualClip( FrameAnimationGraph graph, string id, string displayName, string externalPath, out FrameClip clip, out string error) { clip = null; error = string.Empty; if (!IsPlayableIdAvailable(graph, id)) { error = "Clip id 为空或与现有 Clip / Flow 冲突。"; return false; } Undo.IncrementCurrentGroup(); var group = Undo.GetCurrentGroup(); Undo.SetCurrentGroupName("Create Manual Frame Clip"); try { Undo.RecordObject(graph, "Add Manual Frame Clip"); clip = ScriptableObject.CreateInstance(); clip.name = id; clip.Configure(id, string.IsNullOrWhiteSpace(displayName) ? id : displayName, Array.Empty(), 1f, graph.Settings.NewManualClipDefaultEndBehavior); if (string.IsNullOrWhiteSpace(externalPath)) { AssetDatabase.AddObjectToAsset(clip, graph); } else { AssetDatabase.CreateAsset(clip, externalPath); } Undo.RegisterCreatedObjectUndo(clip, "Create Manual Frame Clip"); graph.AddClip(clip); EditorUtility.SetDirty(graph); EditorUtility.SetDirty(clip); AssetDatabase.SaveAssets(); Undo.CollapseUndoOperations(group); return true; } catch (Exception exception) { Undo.RevertAllDownToGroup(group); clip = null; error = exception.Message; return false; } } public static bool AddExistingManualClip( FrameAnimationGraph graph, FrameClip clip, out string error) { error = string.Empty; if (graph == null || clip == null) { error = "Graph 或 Clip 为空。"; return false; } if (clip.IsImported || AssetDatabase.IsSubAsset(clip) || string.IsNullOrEmpty(AssetDatabase.GetAssetPath(clip))) { error = "只能添加独立 .asset 形式、且不由 Graph ImportSource 管理的 Clip。"; return false; } if (graph.Clips.Contains(clip)) { error = "当前 Graph 已引用该 Clip。"; return false; } if (!IsPlayableIdAvailable(graph, clip.Id)) { error = $"playable id '{clip.Id}' 与当前 Graph 中的 Clip / Flow 冲突。"; return false; } Undo.RecordObject(graph, "Add Existing Manual Frame Clip"); graph.AddClip(clip); EditorUtility.SetDirty(graph); return true; } public static bool CopyToManual( FrameAnimationGraph graph, FrameClip source, string id, string displayName, string externalPath, out FrameClip copy, out string error) { copy = null; error = string.Empty; if (source == null || !IsPlayableIdAvailable(graph, id)) { error = "来源为空,或新 id 与现有 Clip / Flow 冲突。"; return false; } Undo.IncrementCurrentGroup(); var group = Undo.GetCurrentGroup(); Undo.SetCurrentGroupName("Copy Frame Clip As Manual"); try { Undo.RecordObject(graph, "Add Manual Frame Clip Copy"); copy = ScriptableObject.CreateInstance(); copy.name = id; copy.Configure(id, string.IsNullOrWhiteSpace(displayName) ? source.DisplayName : displayName, source.Frames.Select(frame => frame == null ? null : new FrameAnimationFrame(frame.Sprite, frame.DurationMs, frame.FrameName, frame.SourceIndex)), source.Speed, source.DefaultEndBehavior); if (string.IsNullOrWhiteSpace(externalPath)) { AssetDatabase.AddObjectToAsset(copy, graph); } else { AssetDatabase.CreateAsset(copy, externalPath); } Undo.RegisterCreatedObjectUndo(copy, "Create Manual Frame Clip Copy"); graph.AddClip(copy); EditorUtility.SetDirty(graph); EditorUtility.SetDirty(copy); AssetDatabase.SaveAssets(); Undo.CollapseUndoOperations(group); return true; } catch (Exception exception) { Undo.RevertAllDownToGroup(group); copy = null; error = exception.Message; return false; } } public static bool RenameClip( FrameAnimationGraph graph, FrameClip clip, string newId, out string error) { error = string.Empty; if (graph == null || clip == null || !graph.Clips.Contains(clip)) { error = "Clip 不属于当前 Graph。"; return false; } var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip); if (!AssetDatabase.IsSubAsset(clip) && owners.Count > 1) { error = "外部 Manual Clip 被多个 Graph 共享,禁止重命名:" + string.Join(", ", owners.Select(owner => owner.name)); return false; } if (!IsPlayableIdAvailable(graph, newId, clip)) { error = "新 id 为空或与现有 Clip / Flow 冲突。"; return false; } var oldId = clip.Id; Undo.IncrementCurrentGroup(); var group = Undo.GetCurrentGroup(); Undo.SetCurrentGroupName("Rename Frame Clip"); Undo.RecordObject(graph, "Update Frame Clip References"); Undo.RecordObject(clip, "Rename Frame Clip"); clip.SetId(newId); foreach (var node in graph.Nodes.Where(node => node != null && node.ClipId == oldId)) { node.SetClipId(newId); } if (graph.Settings.DefaultPlayableId == oldId) { graph.Settings.SetDefaultPlayableId(newId); } EditorUtility.SetDirty(graph); EditorUtility.SetDirty(clip); Undo.CollapseUndoOperations(group); return true; } public static bool RenameFlow( FrameAnimationGraph graph, AnimationFlow flow, string newId, out string error) { error = string.Empty; if (graph == null || flow == null || !graph.Flows.Contains(flow) || !IsPlayableIdAvailable(graph, newId, exceptFlow: flow)) { error = "Flow 不属于当前 Graph,或新 id 与现有 Clip / Flow 冲突。"; return false; } var oldId = flow.Id; Undo.RecordObject(graph, "Rename Animation Flow"); flow.SetId(newId); if (graph.Settings.DefaultPlayableId == oldId) { graph.Settings.SetDefaultPlayableId(newId); } foreach (var data in graph.EditorData.FlowEditorData.Where(data => data != null && data.FlowId == oldId)) { data.SetFlowId(newId); } EditorUtility.SetDirty(graph); return true; } public static bool RenameGraph(FrameAnimationGraph graph, string newId, out string error) { error = string.Empty; if (graph == null || string.IsNullOrWhiteSpace(newId)) { error = "Graph id 不能为空。"; return false; } Undo.RecordObject(graph, "Rename Frame Animation Graph"); graph.SetId(newId); EditorUtility.SetDirty(graph); return true; } public static bool RemoveClip(FrameAnimationGraph graph, FrameClip clip, out string error) { if (!CanRemoveClip(graph, clip, out error)) { return false; } Undo.IncrementCurrentGroup(); var group = Undo.GetCurrentGroup(); Undo.SetCurrentGroupName("Remove Frame Clip"); Undo.RecordObject(graph, "Remove Frame Clip Reference"); graph.RemoveClip(clip); if (AssetDatabase.IsSubAsset(clip) && AssetDatabase.GetAssetPath(clip) == AssetDatabase.GetAssetPath(graph)) { Undo.DestroyObjectImmediate(clip); } EditorUtility.SetDirty(graph); AssetDatabase.SaveAssets(); Undo.CollapseUndoOperations(group); return true; } public static bool CanRemoveClip(FrameAnimationGraph graph, FrameClip clip, out string error) { error = string.Empty; if (graph == null || clip == null || !graph.Clips.Contains(clip)) { error = "Clip 不属于当前 Graph。"; return false; } var nodeCount = graph.Nodes.Count(node => node != null && node.ClipId == clip.Id); if (nodeCount > 0) { error = $"Clip 仍被 {nodeCount} 个 AnimationNode 引用。"; return false; } if (graph.Settings.DefaultPlayableId == clip.Id) { error = "Clip 仍被 defaultPlayableId 引用。"; return false; } return true; } public static bool RemoveFlow(FrameAnimationGraph graph, AnimationFlow flow, out string error) { if (!CanRemoveFlow(graph, flow, out error)) { return false; } Undo.RecordObject(graph, "Remove Animation Flow"); graph.RemoveFlow(flow); graph.EditorData.RemoveFlowData(flow.Id); EditorUtility.SetDirty(graph); return true; } public static bool CanRemoveFlow(FrameAnimationGraph graph, AnimationFlow flow, out string error) { error = string.Empty; if (graph == null || flow == null || !graph.Flows.Contains(flow)) { error = "Flow 不属于当前 Graph。"; return false; } if (graph.Settings.DefaultPlayableId == flow.Id) { error = "Flow 仍被 defaultPlayableId 引用。"; return false; } return true; } public static bool RemoveSource( FrameAnimationGraph graph, FrameAnimationImportSource source, out string error) { error = string.Empty; if (graph == null || source == null || !graph.ImportSources.Contains(source)) { error = "ImportSource 不属于当前 Graph。"; return false; } var clips = graph.Clips.Where(clip => clip != null && clip.ImportInfo?.ImportSourceId == source.InternalId).ToArray(); if (clips.Length > 0) { error = "ImportSource 仍关联 Imported Clip:" + string.Join(", ", clips.Select(clip => clip.Id)); return false; } Undo.RecordObject(graph, "Remove Frame Animation ImportSource"); graph.RemoveImportSource(source); EditorUtility.SetDirty(graph); return true; } } internal sealed class FrameAnimationEditorIssue { public FrameAnimationValidationSeverity Severity { get; } public string Code { get; } public FrameAnimationEditorSelection Selection { get; } public string Message { get; } public string Suggestion { get; } public FrameAnimationEditorIssue( FrameAnimationValidationSeverity severity, string code, FrameAnimationEditorSelection selection, string message, string suggestion) { Severity = severity; Code = code ?? string.Empty; Selection = selection; Message = message ?? string.Empty; Suggestion = suggestion ?? string.Empty; } } internal static class FrameAnimationEditorValidationService { public static IReadOnlyList Validate( FrameAnimationGraph graph, bool includeImports, out FrameAnimationImportPreview importPreview) { importPreview = null; if (graph == null) { return Array.Empty(); } var issues = FrameAnimationGraphValidator.Validate(graph).Issues .Select(issue => new FrameAnimationEditorIssue( issue.Severity, issue.Code.ToString(), Locate(graph, issue.TargetType, issue.TargetId), issue.Message, Suggestion(issue.Code))) .ToList(); foreach (var duplicate in graph.Clips.Where(clip => clip != null) .GroupBy(clip => clip).Where(group => group.Count() > 1)) { issues.Add(new FrameAnimationEditorIssue( FrameAnimationValidationSeverity.Error, "DuplicateClipReference", new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, duplicate.Key), $"Graph 重复引用 Clip '{duplicate.Key.Id}'。", "移除重复的 Clip 引用。")); } var graphPath = AssetDatabase.GetAssetPath(graph); foreach (var clip in graph.Clips.Where(clip => clip != null)) { if (AssetDatabase.IsSubAsset(clip) && AssetDatabase.GetAssetPath(clip) != graphPath) { issues.Add(new FrameAnimationEditorIssue( FrameAnimationValidationSeverity.Error, "ForeignSubAssetClip", new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, clip), $"Clip '{clip.Id}' 是其他资产的 sub-asset。", "移除该引用,并复制为当前 Graph 的 Manual Clip。")); } if (clip.IsImported && (!AssetDatabase.IsSubAsset(clip) || AssetDatabase.GetAssetPath(clip) != graphPath)) { issues.Add(new FrameAnimationEditorIssue( FrameAnimationValidationSeverity.Error, "ImportedClipOwnership", new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, clip), $"Imported Clip '{clip.Id}' 不是当前 Graph 的 sub-asset。", "删除非法引用并从对应 ImportSource 重新刷新。")); } } foreach (var node in graph.Nodes.Where(node => node != null)) { var clip = graph.Clips.SingleOrDefault(item => item != null && item.Id == node.ClipId); if (clip?.ImportInfo?.IsMissingFromSource == true) { issues.Add(new FrameAnimationEditorIssue( FrameAnimationValidationSeverity.Error, "NodeReferencesMissingClip", new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, node), $"Node '{node.InternalId}' 引用了 Missing Clip '{clip.Id}'。", "恢复源 Tag、修改节点引用或删除该节点。")); } } var nodeIds = new HashSet(graph.Nodes.Where(node => node != null) .Select(node => node.InternalId)); foreach (var node in graph.Nodes.Where(node => node != null && graph.EditorData.NodeEditorData.All(data => data == null || data.NodeId != node.InternalId))) { issues.Add(new FrameAnimationEditorIssue( FrameAnimationValidationSeverity.Warning, "EditorDataMissing", new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, node), $"Node '{node.InternalId}' 缺少画布位置数据。", "打开 Graph Editor 初始化位置,或执行自动布局。")); } foreach (var data in graph.EditorData.NodeEditorData.Where(data => data != null && !nodeIds.Contains(data.NodeId))) { issues.Add(new FrameAnimationEditorIssue( FrameAnimationValidationSeverity.Warning, "EditorDataOrphan", new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph), $"存在孤立的 NodeEditorData '{data.NodeId}'。", "该数据不会影响运行时;确认无引用后可由后续维护工具清理。")); } foreach (var duplicate in graph.EditorData.NodeEditorData.Where(data => data != null) .GroupBy(data => data.NodeId).Where(group => group.Count() > 1)) { issues.Add(new FrameAnimationEditorIssue( FrameAnimationValidationSeverity.Error, "EditorDataDuplicate", new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph), $"NodeEditorData '{duplicate.Key}' 重复。", "保留一条对应位置数据并移除重复项。")); } var flowIds = new HashSet(graph.Flows.Where(flow => flow != null).Select(flow => flow.Id)); foreach (var flow in graph.Flows.Where(flow => flow != null && graph.EditorData.FlowEditorData.All(data => data == null || data.FlowId != flow.Id))) { issues.Add(new FrameAnimationEditorIssue( FrameAnimationValidationSeverity.Warning, "FlowEditorDataMissing", new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Flow, flow), $"Flow '{flow.Id}' 缺少画布颜色数据。", "打开 Graph Editor 初始化 Flow 颜色。")); } foreach (var data in graph.EditorData.FlowEditorData.Where(data => data != null && !flowIds.Contains(data.FlowId))) { issues.Add(new FrameAnimationEditorIssue( FrameAnimationValidationSeverity.Warning, "FlowEditorDataOrphan", new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph), $"存在孤立的 FlowEditorData '{data.FlowId}'。", "确认 Flow 已删除后可由后续维护工具清理。")); } foreach (var duplicate in graph.EditorData.FlowEditorData.Where(data => data != null) .GroupBy(data => data.FlowId).Where(group => group.Count() > 1)) { issues.Add(new FrameAnimationEditorIssue( FrameAnimationValidationSeverity.Error, "FlowEditorDataDuplicate", new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph), $"FlowEditorData '{duplicate.Key}' 重复。", "保留一条对应颜色数据并移除重复项。")); } if (!includeImports) { return issues; } importPreview = FrameAnimationImportService.PreviewAll(graph); foreach (var sourcePreview in importPreview.Sources) { foreach (var importIssue in sourcePreview.Issues) { issues.Add(new FrameAnimationEditorIssue( importIssue.Severity, importIssue.Code.ToString(), new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Source, sourcePreview.Source), importIssue.Message, "打开对应 ImportSource,修复来源设置后重新 Preview。")); } } return issues; } private static FrameAnimationEditorSelection Locate( FrameAnimationGraph graph, FrameAnimationValidationTargetType type, string id) { return type switch { FrameAnimationValidationTargetType.Clip => new FrameAnimationEditorSelection( FrameAnimationEditorSelectionKind.Clip, graph.Clips.FirstOrDefault(clip => clip != null && clip.Id == id)), FrameAnimationValidationTargetType.Flow => new FrameAnimationEditorSelection( FrameAnimationEditorSelectionKind.Flow, graph.Flows.FirstOrDefault(flow => flow != null && flow.Id == id)), FrameAnimationValidationTargetType.Node => new FrameAnimationEditorSelection( FrameAnimationEditorSelectionKind.Node, graph.Nodes.FirstOrDefault(node => node != null && node.InternalId == id)), FrameAnimationValidationTargetType.Edge => new FrameAnimationEditorSelection( FrameAnimationEditorSelectionKind.Edge, graph.Edges.FirstOrDefault(edge => edge != null && edge.InternalId == id)), FrameAnimationValidationTargetType.ImportSource => new FrameAnimationEditorSelection( FrameAnimationEditorSelectionKind.Source, graph.ImportSources.FirstOrDefault(source => source != null && source.InternalId == id)), _ => new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph) }; } private static string Suggestion(FrameAnimationValidationCode code) { return code switch { FrameAnimationValidationCode.PlayableIdEmpty => "使用正式 Rename 命令设置唯一 playable id。", FrameAnimationValidationCode.PlayableIdDuplicate => "重命名冲突的 Clip 或 Flow。", FrameAnimationValidationCode.ClipFramesEmpty => "为 Manual Clip 添加帧,或刷新 Imported Clip 来源。", FrameAnimationValidationCode.FrameDurationInvalid => "将 durationMs 修改为大于 0 的整数。", FrameAnimationValidationCode.DefaultPlayableInvalid => "在 Graph 属性中选择有效的默认 Clip 或 Flow。", FrameAnimationValidationCode.ImportedClipMissingFromSource => "恢复源 Tag,或确认没有引用后删除 Missing Clip。", _ => "打开问题对象并修复对应字段或引用。" }; } } internal static class FrameAnimationWorkspaceState { private const string Prefix = "AibisDream.FrameAnimation.Workspace."; public static string Key(FrameAnimationGraph graph, string name) { var graphPath = graph != null ? AssetDatabase.GetAssetPath(graph) : string.Empty; var graphGuid = string.IsNullOrEmpty(graphPath) ? "none" : AssetDatabase.AssetPathToGUID(graphPath); var project = Hash128.Compute(Application.dataPath).ToString(); return Prefix + project + "." + graphGuid + "." + name; } public static float GetFloat(FrameAnimationGraph graph, string name, float fallback) => EditorPrefs.GetFloat(Key(graph, name), fallback); public static void SetFloat(FrameAnimationGraph graph, string name, float value) => EditorPrefs.SetFloat(Key(graph, name), value); public static string GetString(FrameAnimationGraph graph, string name, string fallback) => EditorPrefs.GetString(Key(graph, name), fallback); public static void SetString(FrameAnimationGraph graph, string name, string value) => EditorPrefs.SetString(Key(graph, name), value ?? string.Empty); public static bool GetBool(FrameAnimationGraph graph, string name, bool fallback) => EditorPrefs.GetBool(Key(graph, name), fallback); public static void SetBool(FrameAnimationGraph graph, string name, bool value) => EditorPrefs.SetBool(Key(graph, name), value); } }