diff --git a/Assets/AddressableAssetsData/AddressableAssetSettings.asset b/Assets/AddressableAssetsData/AddressableAssetSettings.asset index 1c7d4afc1..8840dbc9b 100644 --- a/Assets/AddressableAssetsData/AddressableAssetSettings.asset +++ b/Assets/AddressableAssetsData/AddressableAssetSettings.asset @@ -15,7 +15,7 @@ MonoBehaviour: m_DefaultGroup: 09546aca93a801441859af2a811fe53b m_currentHash: serializedVersion: 2 - Hash: 00000000000000000000000000000000 + Hash: cf6c8ca853b0b2e0e22d46c14944816d m_OptimizeCatalogSize: 0 m_BuildRemoteCatalog: 0 m_BundleLocalCatalog: 0 @@ -59,7 +59,6 @@ MonoBehaviour: - {fileID: 11400000, guid: 6d259d339c6615248b893837398c1438, type: 2} - {fileID: 11400000, guid: 8718a71c99923e247a960cbf4e4795f5, type: 2} - {fileID: 11400000, guid: 1e521cfc13b314642b1e682e3be7ea17, type: 2} - - {fileID: 11400000, guid: 7e4ab0de168458b43a21dc2f9e811ff5, type: 2} - {fileID: 11400000, guid: 724eed66b2d8eef4faf77a4bee4d2845, type: 2} - {fileID: 11400000, guid: 62a92c74b9bd5bd439690727c1831500, type: 2} - {fileID: 11400000, guid: b593dc98ea11a484995123de77d9bf1a, type: 2} @@ -68,6 +67,7 @@ MonoBehaviour: - {fileID: 11400000, guid: dc8d31ed80ea35945812ff6552efbbe5, type: 2} - {fileID: 11400000, guid: af94af36950b08d4f9a5037546bbe040, type: 2} - {fileID: 11400000, guid: 60fd2fd8c7094b242b01c63de65de9ea, type: 2} + - {fileID: 11400000, guid: 7e4ab0de168458b43a21dc2f9e811ff5, type: 2} m_BuildSettings: m_CompileScriptsInVirtualMode: 0 m_CleanupStreamingAssetsAfterBuilds: 1 diff --git a/Assets/AddressableAssetsData/AssetGroups/Scene_HuoshanFix.asset b/Assets/AddressableAssetsData/AssetGroups/Scene_HuoshanFix.asset index da965cca0..0ffb63f42 100644 --- a/Assets/AddressableAssetsData/AssetGroups/Scene_HuoshanFix.asset +++ b/Assets/AddressableAssetsData/AssetGroups/Scene_HuoshanFix.asset @@ -92,6 +92,11 @@ MonoBehaviour: m_ReadOnly: 0 m_SerializedLabels: [] FlaggedDuringContentUpdateRestriction: 0 + - m_GUID: efa335dcb6e706347bd4acf3222331b9 + m_Address: "FrameAnimation/\u706B\u5C71" + m_ReadOnly: 0 + m_SerializedLabels: [] + FlaggedDuringContentUpdateRestriction: 0 m_ReadOnly: 0 m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2} m_SchemaSet: diff --git a/Assets/AddressableAssetsData/AssetGroups/Scene_PeipeiFix.asset b/Assets/AddressableAssetsData/AssetGroups/Scene_PeipeiFix.asset index 83f1ee634..0cd945a05 100644 --- a/Assets/AddressableAssetsData/AssetGroups/Scene_PeipeiFix.asset +++ b/Assets/AddressableAssetsData/AssetGroups/Scene_PeipeiFix.asset @@ -37,6 +37,11 @@ MonoBehaviour: m_ReadOnly: 0 m_SerializedLabels: [] FlaggedDuringContentUpdateRestriction: 0 + - m_GUID: 26105571c9644aa4194f2b981ee33a58 + m_Address: "FrameAnimation/\u4F69\u4F69" + m_ReadOnly: 0 + m_SerializedLabels: [] + FlaggedDuringContentUpdateRestriction: 0 m_ReadOnly: 0 m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2} m_SchemaSet: diff --git a/Assets/AddressableAssetsData/AssetGroups/Shared_PerformanceTools.asset b/Assets/AddressableAssetsData/AssetGroups/Shared_PerformanceTools.asset index 0ee4e73f5..b4cf4a804 100644 --- a/Assets/AddressableAssetsData/AssetGroups/Shared_PerformanceTools.asset +++ b/Assets/AddressableAssetsData/AssetGroups/Shared_PerformanceTools.asset @@ -32,6 +32,11 @@ MonoBehaviour: m_ReadOnly: 0 m_SerializedLabels: [] FlaggedDuringContentUpdateRestriction: 0 + - m_GUID: b9e730c7e5824c5ca2858a253fe93c98 + m_Address: Prefab/FrameAnimationActor + m_ReadOnly: 0 + m_SerializedLabels: [] + FlaggedDuringContentUpdateRestriction: 0 - m_GUID: cd996317bdc6ae8478acb2de956f25d9 m_Address: Prefab/AnimaMobileObject m_ReadOnly: 0 diff --git a/Assets/CameraData/Main Camera Blends.asset b/Assets/CameraData/Main Camera Blends.asset index af8cc3079..79429ade6 100644 --- a/Assets/CameraData/Main Camera Blends.asset +++ b/Assets/CameraData/Main Camera Blends.asset @@ -244,3 +244,14 @@ MonoBehaviour: m_PreInfinity: 0 m_PostInfinity: 0 m_RotationOrder: 0 + - m_From: '**ANY CAMERA**' + m_To: Dream Camera + m_Blend: + m_Style: 0 + m_Time: 2 + m_CustomCurve: + serializedVersion: 2 + m_Curve: [] + m_PreInfinity: 0 + m_PostInfinity: 0 + m_RotationOrder: 0 diff --git a/Assets/Editor/ChapterGraph/ChapterGraphEditorWindow.cs b/Assets/Editor/ChapterGraph/ChapterGraphEditorWindow.cs index c265e4f6c..0ae2735be 100644 --- a/Assets/Editor/ChapterGraph/ChapterGraphEditorWindow.cs +++ b/Assets/Editor/ChapterGraph/ChapterGraphEditorWindow.cs @@ -148,9 +148,19 @@ namespace AibisDream.SystemEditor }); // 右侧 GraphView + // 套一层容器:GraphView 与左侧 Inspector 并列放在 Flex Row 中时, + // 内置 RectangleSelector 绘制的框选矩形会出现坐标偏移(已知 Unity 问题)。 + // 将 GraphView 放在独立的 Flex 子容器中可使其局部原点与父容器对齐,规避该偏移。 + var graphViewWrapper = new VisualElement(); + graphViewWrapper.style.flexGrow = 1; + graphViewWrapper.style.flexDirection = FlexDirection.Column; + graphViewWrapper.style.overflow = Overflow.Hidden; + _graphView = new ChapterGraphView(); _graphView.style.flexGrow = 1; - mainContainer.Add(_graphView); + graphViewWrapper.Add(_graphView); + + mainContainer.Add(graphViewWrapper); root.Add(mainContainer); diff --git a/Assets/Editor/TerminalUI.meta b/Assets/Editor/FrameAnimation.meta similarity index 77% rename from Assets/Editor/TerminalUI.meta rename to Assets/Editor/FrameAnimation.meta index e5fe22dc2..ea98cbebf 100644 --- a/Assets/Editor/TerminalUI.meta +++ b/Assets/Editor/FrameAnimation.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 33e48b17003f9a340a079e8b5993c786 +guid: 9e244d3d62dec3f4f881f7e681da8c03 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Assets/Editor/FrameAnimation/AibisDream.FrameAnimation.Editor.asmdef b/Assets/Editor/FrameAnimation/AibisDream.FrameAnimation.Editor.asmdef new file mode 100644 index 000000000..8a5bae64f --- /dev/null +++ b/Assets/Editor/FrameAnimation/AibisDream.FrameAnimation.Editor.asmdef @@ -0,0 +1,19 @@ +{ + "name": "AibisDream.FrameAnimation.Editor", + "rootNamespace": "AibisDream.FrameAnimation.Editor", + "references": [ + "AibisDream.FrameAnimation.Runtime", + "Unity.2D.Sprite.Editor" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/Editor/FrameAnimation/AibisDream.FrameAnimation.Editor.asmdef.meta b/Assets/Editor/FrameAnimation/AibisDream.FrameAnimation.Editor.asmdef.meta new file mode 100644 index 000000000..692deda26 --- /dev/null +++ b/Assets/Editor/FrameAnimation/AibisDream.FrameAnimation.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d1ffe139ca2417941a09835467d1e442 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/FrameAnimation/AsepriteJsonParser.cs b/Assets/Editor/FrameAnimation/AsepriteJsonParser.cs new file mode 100644 index 000000000..32803a79b --- /dev/null +++ b/Assets/Editor/FrameAnimation/AsepriteJsonParser.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace AibisDream.FrameAnimation.Editor +{ + internal static class AsepriteJsonParser + { + public static bool TryParse( + string json, + string sourceId, + out AsepriteSourceDocument document, + out FrameAnimationImportIssue issue) + { + document = null; + issue = null; + try + { + if (string.IsNullOrWhiteSpace(json)) + { + issue = Error(FrameAnimationImportIssueCode.JsonInvalid, sourceId, "Aseprite JSON 为空。"); + return false; + } + + var root = JObject.Parse(json); + var framesToken = root["frames"]; + if (framesToken == null || + (framesToken.Type != JTokenType.Object && framesToken.Type != JTokenType.Array)) + { + issue = Error(FrameAnimationImportIssueCode.FramesMissing, sourceId, "JSON 缺少 Object 或 Array 格式的 frames。"); + return false; + } + + var frames = ParseFrames(framesToken); + var meta = root["meta"] as JObject; + var size = ParseSize(meta?["size"]); + var imageName = meta?.Value("image") ?? string.Empty; + var tags = ParseTags(meta?["frameTags"]); + document = new AsepriteSourceDocument(frames, tags, size, imageName); + return true; + } + catch (Exception exception) + { + issue = Error( + FrameAnimationImportIssueCode.JsonInvalid, + sourceId, + $"Aseprite JSON 解析失败:{exception.Message}"); + return false; + } + } + + public static bool TryExpandTag( + AsepriteSourceTag tag, + int frameCount, + string sourceId, + out IReadOnlyList indices, + out FrameAnimationImportIssue issue) + { + indices = Array.Empty(); + issue = null; + if (tag == null || tag.From < 0 || tag.To < tag.From || tag.To >= frameCount) + { + issue = Error( + FrameAnimationImportIssueCode.TagRangeInvalid, + sourceId, + $"Tag '{tag?.Name}' 的范围 {tag?.From}..{tag?.To} 无效。"); + return false; + } + + var result = new List(); + switch ((tag.Direction ?? string.Empty).ToLowerInvariant()) + { + case "forward": + AddAscending(result, tag.From, tag.To); + break; + case "reverse": + AddDescending(result, tag.To, tag.From); + break; + case "pingpong": + AddAscending(result, tag.From, tag.To); + AddDescending(result, tag.To - 1, tag.From + 1); + break; + case "pingpong_reverse": + AddDescending(result, tag.To, tag.From); + AddAscending(result, tag.From + 1, tag.To - 1); + break; + default: + issue = Error( + FrameAnimationImportIssueCode.TagDirectionInvalid, + sourceId, + $"Tag '{tag.Name}' 使用了不支持的 direction '{tag.Direction}'。"); + return false; + } + + indices = result; + return true; + } + + private static List ParseFrames(JToken token) + { + var frames = new List(); + if (token is JObject objectFrames) + { + foreach (var property in objectFrames.Properties()) + { + frames.Add(ParseFrame(property.Name, property.Value)); + } + } + else + { + foreach (var item in (JArray)token) + { + frames.Add(ParseFrame(item.Value("filename") ?? string.Empty, item)); + } + } + return frames; + } + + private static AsepriteSourceFrame ParseFrame(string frameName, JToken token) + { + return new AsepriteSourceFrame( + frameName, + ParseRect(token["frame"]), + token.Value("duration") ?? 0, + token.Value("rotated") ?? false, + token.Value("trimmed") ?? false, + ParseRect(token["spriteSourceSize"]), + ParseSize(token["sourceSize"])); + } + + private static List ParseTags(JToken token) + { + var tags = new List(); + if (!(token is JArray array)) + { + return tags; + } + + foreach (var item in array) + { + tags.Add(new AsepriteSourceTag( + item.Value("name") ?? string.Empty, + item.Value("from") ?? -1, + item.Value("to") ?? -1, + item.Value("direction") ?? string.Empty)); + } + return tags; + } + + private static RectInt ParseRect(JToken token) + { + return token == null + ? default + : new RectInt( + token.Value("x") ?? 0, + token.Value("y") ?? 0, + token.Value("w") ?? 0, + token.Value("h") ?? 0); + } + + private static Vector2Int ParseSize(JToken token) + { + return token == null + ? default + : new Vector2Int(token.Value("w") ?? 0, token.Value("h") ?? 0); + } + + private static void AddAscending(List target, int from, int to) + { + for (var index = from; index <= to; index++) + { + target.Add(index); + } + } + + private static void AddDescending(List target, int from, int to) + { + for (var index = from; index >= to; index--) + { + target.Add(index); + } + } + + private static FrameAnimationImportIssue Error( + FrameAnimationImportIssueCode code, + string sourceId, + string message) + { + return new FrameAnimationImportIssue( + FrameAnimationValidationSeverity.Error, + code, + sourceId, + sourceId, + message); + } + } +} diff --git a/Assets/Scripts/UI/SaveFileUIController.cs.meta b/Assets/Editor/FrameAnimation/AsepriteJsonParser.cs.meta similarity index 83% rename from Assets/Scripts/UI/SaveFileUIController.cs.meta rename to Assets/Editor/FrameAnimation/AsepriteJsonParser.cs.meta index 76217cb44..998c3ac8b 100644 --- a/Assets/Scripts/UI/SaveFileUIController.cs.meta +++ b/Assets/Editor/FrameAnimation/AsepriteJsonParser.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: a351e509c8dd5944f9e6406f59ea5c0b +guid: a21095473b1495440acab244f9e470c6 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/Editor/FrameAnimation/AssemblyInfo.cs b/Assets/Editor/FrameAnimation/AssemblyInfo.cs new file mode 100644 index 000000000..9b37c4ef1 --- /dev/null +++ b/Assets/Editor/FrameAnimation/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("AibisDream.FrameAnimation.Tests.EditMode")] diff --git a/Assets/Scripts/UI/Form/AbsFormItem.cs.meta b/Assets/Editor/FrameAnimation/AssemblyInfo.cs.meta similarity index 83% rename from Assets/Scripts/UI/Form/AbsFormItem.cs.meta rename to Assets/Editor/FrameAnimation/AssemblyInfo.cs.meta index 64b2b2294..f1b416002 100644 --- a/Assets/Scripts/UI/Form/AbsFormItem.cs.meta +++ b/Assets/Editor/FrameAnimation/AssemblyInfo.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: dd06484c1f627d249ad5a16558c9cdb9 +guid: 8edeae42e1831084cbf05db9d9e5d1e2 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/Editor/FrameAnimation/FrameAnimationEditorDialogs.cs b/Assets/Editor/FrameAnimation/FrameAnimationEditorDialogs.cs new file mode 100644 index 000000000..fab2e06b7 --- /dev/null +++ b/Assets/Editor/FrameAnimation/FrameAnimationEditorDialogs.cs @@ -0,0 +1,307 @@ +using System; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace AibisDream.FrameAnimation.Editor +{ + internal sealed class FrameAnimationTextInputWindow : EditorWindow + { + private string label; + private string value; + private string warning; + private Func validate; + private Action confirm; + private Vector2 scroll; + + public static void Show( + string title, + string label, + string initialValue, + string warning, + Func validate, + Action confirm) + { + var window = CreateInstance(); + window.titleContent = new GUIContent(title); + window.label = label; + window.value = initialValue ?? string.Empty; + window.warning = warning ?? string.Empty; + window.validate = validate; + window.confirm = confirm; + window.minSize = new Vector2(460f, 220f); + window.maxSize = new Vector2(700f, 420f); + window.ShowUtility(); + } + + private void OnGUI() + { + scroll = EditorGUILayout.BeginScrollView(scroll); + EditorGUILayout.LabelField(label, EditorStyles.boldLabel); + GUI.SetNextControlName("FrameAnimationTextInput"); + value = EditorGUILayout.TextField(value); + if (!string.IsNullOrEmpty(warning)) + { + EditorGUILayout.HelpBox(warning, MessageType.Warning); + } + var error = validate?.Invoke(value) ?? string.Empty; + if (!string.IsNullOrEmpty(error)) + { + EditorGUILayout.HelpBox(error, MessageType.Error); + } + GUILayout.FlexibleSpace(); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("取消", GUILayout.Width(90f))) + { + Close(); + } + using (new EditorGUI.DisabledScope(!string.IsNullOrEmpty(error))) + { + if (GUILayout.Button("确认", GUILayout.Width(90f))) + { + var callback = confirm; + var result = value; + Close(); + callback?.Invoke(result); + } + } + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndScrollView(); + } + + private void OnEnable() + { + EditorApplication.delayCall += FocusInput; + } + + private void FocusInput() + { + if (this != null) + { + Focus(); + EditorGUI.FocusTextInControl("FrameAnimationTextInput"); + } + } + } + + internal sealed class FrameAnimationManualClipWindow : EditorWindow + { + private FrameAnimationGraph graph; + private FrameClip source; + private string id = "NewClip"; + private string displayName = "New Clip"; + private bool external; + private Action completed; + + public static void Show( + FrameAnimationGraph graph, + FrameClip source, + Action completed) + { + var window = CreateInstance(); + window.titleContent = new GUIContent(source == null ? "Create Manual Clip" : "Copy As Manual Clip"); + window.graph = graph; + window.source = source; + window.completed = completed; + var baseId = source != null ? source.Id + "_Manual" : "NewClip"; + window.id = MakeUniqueId(graph, baseId); + window.displayName = source != null ? source.DisplayName + " Manual" : window.id; + window.minSize = new Vector2(460f, 260f); + window.maxSize = new Vector2(700f, 420f); + window.ShowUtility(); + } + + private void OnGUI() + { + EditorGUILayout.LabelField(source == null ? "创建 Manual Clip" : "复制为 Manual Clip", EditorStyles.boldLabel); + id = EditorGUILayout.TextField("ID", id); + displayName = EditorGUILayout.TextField("Display Name", displayName); + external = EditorGUILayout.ToggleLeft("保存为外部独立 .asset(默认保存为 Graph sub-asset)", external); + var error = FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, id) + ? string.Empty + : "ID 为空或与当前 Graph 的 Clip / Flow 冲突。"; + if (!string.IsNullOrEmpty(error)) + { + EditorGUILayout.HelpBox(error, MessageType.Error); + } + if (source != null) + { + EditorGUILayout.HelpBox( + "帧表将深拷贝,但 Sprite 仍复用原资源;ImportSource 关联会被清除。", + MessageType.Info); + } + GUILayout.FlexibleSpace(); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("取消", GUILayout.Width(90f))) + { + Close(); + } + using (new EditorGUI.DisabledScope(!string.IsNullOrEmpty(error))) + { + if (GUILayout.Button("创建", GUILayout.Width(90f))) + { + Create(); + } + } + EditorGUILayout.EndHorizontal(); + } + + private void Create() + { + var path = string.Empty; + if (external) + { + path = EditorUtility.SaveFilePanelInProject( + "保存外部 Manual Clip", + id, + "asset", + "请选择 Assets 下的保存位置。"); + if (string.IsNullOrEmpty(path)) + { + return; + } + } + + var succeeded = source == null + ? FrameAnimationAssetOperations.CreateManualClip(graph, id, displayName, path, out var result, out var error) + : FrameAnimationAssetOperations.CopyToManual(graph, source, id, displayName, path, out result, out error); + if (!succeeded) + { + EditorUtility.DisplayDialog("操作失败", error, "确定"); + return; + } + var callback = completed; + Close(); + callback?.Invoke(result); + } + + private static string MakeUniqueId(FrameAnimationGraph graph, string baseId) + { + var candidate = baseId; + var suffix = 2; + while (!FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, candidate)) + { + candidate = baseId + suffix++; + } + return candidate; + } + } + + internal sealed class FrameAnimationExistingClipWindow : EditorWindow + { + private FrameAnimationGraph graph; + private FrameClip clip; + private Action completed; + + public static void Show(FrameAnimationGraph graph, Action completed) + { + var window = CreateInstance(); + window.titleContent = new GUIContent("Add Existing Manual Clip"); + window.graph = graph; + window.completed = completed; + window.minSize = new Vector2(460f, 180f); + window.maxSize = new Vector2(640f, 260f); + window.ShowUtility(); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("添加外部 Manual Clip", EditorStyles.boldLabel); + clip = (FrameClip)EditorGUILayout.ObjectField("FrameClip", clip, typeof(FrameClip), false); + GUILayout.FlexibleSpace(); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("取消", GUILayout.Width(90f))) + { + Close(); + } + using (new EditorGUI.DisabledScope(clip == null)) + { + if (GUILayout.Button("添加", GUILayout.Width(90f))) + { + if (!FrameAnimationAssetOperations.AddExistingManualClip(graph, clip, out var error)) + { + EditorUtility.DisplayDialog("添加失败", error, "确定"); + return; + } + var callback = completed; + var result = clip; + Close(); + callback?.Invoke(result); + } + } + EditorGUILayout.EndHorizontal(); + } + } + + internal sealed class FrameAnimationFlowWindow : EditorWindow + { + private FrameAnimationGraph graph; + private AnimationNode entry; + private string id; + private string displayName; + private Action completed; + + internal static void Show( + FrameAnimationGraph graph, + AnimationNode entry, + Action completed) + { + var window = CreateInstance(); + window.titleContent = new GUIContent("Create Animation Flow"); + window.graph = graph; + window.entry = entry; + window.id = FrameAnimationGraphMutationService.MakeUniqueFlowId(graph, entry); + window.displayName = window.id; + window.completed = completed; + window.minSize = new Vector2(460f, 230f); + window.maxSize = new Vector2(700f, 360f); + window.ShowUtility(); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("从选中节点创建 Flow", EditorStyles.boldLabel); + using (new EditorGUI.DisabledScope(true)) + { + EditorGUILayout.TextField("Entry Node", entry?.DisplayName ?? string.Empty); + } + id = EditorGUILayout.TextField("ID", id); + displayName = EditorGUILayout.TextField("Display Name", displayName); + var idError = FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, id) + ? string.Empty + : "ID 为空或与当前 Graph 中的 Clip / Flow 冲突。"; + var owner = graph?.Flows.FirstOrDefault(flow => flow != null && flow.EntryNodeId == entry?.InternalId); + if (owner != null) + { + idError = $"该节点已是 Flow '{owner.Id}' 的入口。"; + } + if (!string.IsNullOrEmpty(idError)) + { + EditorGUILayout.HelpBox(idError, MessageType.Error); + } + GUILayout.FlexibleSpace(); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("取消", GUILayout.Width(90f))) Close(); + using (new EditorGUI.DisabledScope(!string.IsNullOrEmpty(idError))) + { + if (GUILayout.Button("创建", GUILayout.Width(90f))) + { + if (!FrameAnimationGraphMutationService.CreateFlow( + graph, entry, id, displayName, out var flow, out var error)) + { + EditorUtility.DisplayDialog("创建 Flow 失败", error, "确定"); + return; + } + var callback = completed; + Close(); + callback?.Invoke(flow); + } + } + EditorGUILayout.EndHorizontal(); + } + } +} diff --git a/Assets/Editor/FrameAnimation/FrameAnimationEditorDialogs.cs.meta b/Assets/Editor/FrameAnimation/FrameAnimationEditorDialogs.cs.meta new file mode 100644 index 000000000..a0aec5932 --- /dev/null +++ b/Assets/Editor/FrameAnimation/FrameAnimationEditorDialogs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c8f58e7b80263c24087a98874796e91d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/FrameAnimation/FrameAnimationEditorServices.cs b/Assets/Editor/FrameAnimation/FrameAnimationEditorServices.cs new file mode 100644 index 000000000..53ca236e8 --- /dev/null +++ b/Assets/Editor/FrameAnimation/FrameAnimationEditorServices.cs @@ -0,0 +1,744 @@ +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 "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 形式的 Manual 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); + } +} diff --git a/Assets/Editor/FrameAnimation/FrameAnimationEditorServices.cs.meta b/Assets/Editor/FrameAnimation/FrameAnimationEditorServices.cs.meta new file mode 100644 index 000000000..3f10e911a --- /dev/null +++ b/Assets/Editor/FrameAnimation/FrameAnimationEditorServices.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2643ad174ef237e43b98400d60c4a201 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/FrameAnimation/FrameAnimationGraphAuthoringServices.cs b/Assets/Editor/FrameAnimation/FrameAnimationGraphAuthoringServices.cs new file mode 100644 index 000000000..aa2216769 --- /dev/null +++ b/Assets/Editor/FrameAnimation/FrameAnimationGraphAuthoringServices.cs @@ -0,0 +1,603 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace AibisDream.FrameAnimation.Editor +{ + internal static class FrameAnimationFlowFocusPolicy + { + public static AnimationFlow FindFocusedFlow(FrameAnimationGraph graph, string focusedFlowId) + { + return graph?.Flows.FirstOrDefault(flow => flow != null && flow.Id == focusedFlowId); + } + + public static bool Contains( + FrameAnimationGraph graph, + string focusedFlowId, + FrameAnimationEditorSelection selection) + { + var flow = FindFocusedFlow(graph, focusedFlowId); + if (flow == null) + { + return false; + } + var reachable = new FrameAnimationGraphTopology(graph).GetReachable(flow.EntryNodeId); + return selection.Value switch + { + AnimationNode node => reachable.Nodes.Contains(node), + AnimationEdge edge => reachable.Edges.Contains(edge), + AnimationFlow selectedFlow => selectedFlow == flow, + _ => false + }; + } + + public static bool ShouldExitForSelection( + FrameAnimationGraph graph, + string focusedFlowId, + FrameAnimationEditorSelection selection) + { + if (string.IsNullOrEmpty(focusedFlowId)) + { + return false; + } + return selection.Kind switch + { + FrameAnimationEditorSelectionKind.None => false, + FrameAnimationEditorSelectionKind.Flow => false, + FrameAnimationEditorSelectionKind.Node => !Contains(graph, focusedFlowId, selection), + FrameAnimationEditorSelectionKind.Edge => !Contains(graph, focusedFlowId, selection), + _ => true + }; + } + + public static bool ShouldExitForSelectionSet( + FrameAnimationGraph graph, + string focusedFlowId, + IEnumerable selections) + { + if (string.IsNullOrEmpty(focusedFlowId)) + { + return false; + } + var items = (selections ?? Array.Empty()).ToArray(); + return items.Length > 0 && items.Any(item => + (item.Kind == FrameAnimationEditorSelectionKind.Node || + item.Kind == FrameAnimationEditorSelectionKind.Edge) + ? !Contains(graph, focusedFlowId, item) + : ShouldExitForSelection(graph, focusedFlowId, item)); + } + } + + internal sealed class FrameAnimationGraphImpact + { + public IReadOnlyList Flows { get; } + public int LostNodeCount { get; } + + internal FrameAnimationGraphImpact(IReadOnlyList flows, int lostNodeCount) + { + Flows = flows ?? Array.Empty(); + LostNodeCount = lostNodeCount; + } + } + + internal static class FrameAnimationGraphImpactAnalyzer + { + public static FrameAnimationGraphImpact AnalyzeEdgeRemoval( + FrameAnimationGraph graph, + IEnumerable edges) + { + var removed = new HashSet((edges ?? Array.Empty()).Where(edge => edge != null)); + return Analyze(graph, Array.Empty(), removed); + } + + public static FrameAnimationGraphImpact AnalyzeNodeRemoval( + FrameAnimationGraph graph, + IEnumerable nodes) + { + var removedNodes = new HashSet((nodes ?? Array.Empty()).Where(node => node != null)); + var removedIds = new HashSet(removedNodes.Select(node => node.InternalId)); + var removedEdges = new HashSet(graph.Edges.Where(edge => edge != null && + (removedIds.Contains(edge.FromNodeId) || removedIds.Contains(edge.ToNodeId)))); + return Analyze(graph, removedNodes, removedEdges); + } + + public static FrameAnimationGraphImpact AnalyzeRemoval( + FrameAnimationGraph graph, + IEnumerable nodes, + IEnumerable edges) + { + if (graph == null) + { + return new FrameAnimationGraphImpact(Array.Empty(), 0); + } + var removedNodes = new HashSet((nodes ?? Array.Empty()) + .Where(node => node != null)); + var removedIds = new HashSet(removedNodes.Select(node => node.InternalId)); + var removedEdges = new HashSet((edges ?? Array.Empty()) + .Where(edge => edge != null)); + removedEdges.UnionWith(graph.Edges.Where(edge => edge != null && + (removedIds.Contains(edge.FromNodeId) || removedIds.Contains(edge.ToNodeId)))); + return Analyze(graph, removedNodes, removedEdges); + } + + private static FrameAnimationGraphImpact Analyze( + FrameAnimationGraph graph, + IReadOnlyCollection removedNodes, + IReadOnlyCollection removedEdges) + { + if (graph == null) + { + return new FrameAnimationGraphImpact(Array.Empty(), 0); + } + var topology = new FrameAnimationGraphTopology(graph); + var removedNodeIds = new HashSet(removedNodes.Select(node => node.InternalId)); + var affected = new List(); + var lost = new HashSet(); + foreach (var flow in graph.Flows.Where(flow => flow != null)) + { + var before = topology.GetReachable(flow.EntryNodeId).Nodes + .Where(node => node != null).Select(node => node.InternalId).ToHashSet(); + var after = ReachableWithout(graph, flow.EntryNodeId, removedNodeIds, removedEdges); + var flowLost = before.Where(nodeId => !after.Contains(nodeId)).ToArray(); + if (flowLost.Length > 0) + { + affected.Add(flow); + lost.UnionWith(flowLost); + } + } + return new FrameAnimationGraphImpact( + affected.OrderBy(flow => flow.Id, StringComparer.Ordinal).ToArray(), + lost.Count); + } + + private static HashSet ReachableWithout( + FrameAnimationGraph graph, + string entry, + HashSet removedNodeIds, + IReadOnlyCollection removedEdges) + { + var result = new HashSet(); + if (removedNodeIds.Contains(entry ?? string.Empty)) + { + return result; + } + var outgoing = graph.Edges.Where(edge => edge != null && !removedEdges.Contains(edge)) + .GroupBy(edge => edge.FromNodeId ?? string.Empty) + .ToDictionary(group => group.Key, group => group.ToArray()); + var queue = new Queue(); + queue.Enqueue(entry ?? string.Empty); + while (queue.Count > 0) + { + var id = queue.Dequeue(); + if (removedNodeIds.Contains(id) || !result.Add(id)) + { + continue; + } + if (outgoing.TryGetValue(id, out var edges)) + { + foreach (var edge in edges) + { + queue.Enqueue(edge.ToNodeId ?? string.Empty); + } + } + } + return result; + } + } + + internal static class FrameAnimationGraphMutationService + { + private static readonly Color[] FlowColors = + { + new Color(0.24f, 0.65f, 1f), new Color(0.42f, 0.8f, 0.42f), + new Color(1f, 0.62f, 0.24f), new Color(0.82f, 0.42f, 0.9f), + new Color(1f, 0.38f, 0.52f), new Color(0.25f, 0.82f, 0.78f) + }; + + public static bool CreateNode( + FrameAnimationGraph graph, + FrameClip clip, + Vector2 position, + out AnimationNode node, + out string error) + { + node = null; + error = string.Empty; + if (graph == null || clip == null || !graph.Clips.Contains(clip)) + { + error = "只能为当前 Graph 中的 FrameClip 创建节点。"; + return false; + } + Undo.RecordObject(graph, "Create Frame Animation Node"); + node = new AnimationNode(clip.Id, + string.IsNullOrWhiteSpace(clip.DisplayName) ? clip.Id : clip.DisplayName); + graph.AddNode(node); + graph.EditorData.GetOrCreateNodeData(node.InternalId, position).SetPosition(position); + Dirty(graph); + return true; + } + + public static bool TryConnect( + FrameAnimationGraph graph, + AnimationNode from, + AnimationNode to, + out AnimationEdge edge, + out string error) + { + edge = null; + if (!CanConnect(graph, from, to, out error)) + { + return false; + } + Undo.RecordObject(graph, "Connect Frame Animation Nodes"); + edge = new AnimationEdge(from.InternalId, to.InternalId); + graph.AddEdge(edge); + Dirty(graph); + return true; + } + + public static bool CanConnect( + FrameAnimationGraph graph, + AnimationNode from, + AnimationNode to, + out string error) + { + error = string.Empty; + if (graph == null || from == null || to == null || + graph.Nodes.Count(node => node == from) != 1 || graph.Nodes.Count(node => node == to) != 1) + { + error = "连接两端必须唯一属于当前 Graph。"; + return false; + } + if (from == to || from.InternalId == to.InternalId) + { + error = "节点不允许连接到自身。"; + return false; + } + var topology = new FrameAnimationGraphTopology(graph); + if (topology.GetOutgoing(from.InternalId).Count > 0) + { + error = "第一版每个节点最多只能有一个后继。"; + return false; + } + if (from.EndBehaviorOverride.HasValue) + { + error = "该节点已设置终点结束行为,请先清除覆盖。"; + return false; + } + if (graph.Edges.Any(existing => existing != null && + existing.FromNodeId == from.InternalId && existing.ToNodeId == to.InternalId)) + { + error = "该连接已经存在。"; + return false; + } + if (topology.WouldCreateCycle(from.InternalId, to.InternalId)) + { + error = "该连接会形成自连接或多节点环路。"; + return false; + } + return true; + } + + public static void SetNodeDisplayName(FrameAnimationGraph graph, AnimationNode node, string value) + { + Undo.RecordObject(graph, "Rename Frame Animation Node"); + node.SetDisplayName(value); + Dirty(graph); + } + + public static void SetNodeClip(FrameAnimationGraph graph, AnimationNode node, FrameClip clip) + { + Undo.RecordObject(graph, "Set Frame Animation Node Clip"); + node.SetClipId(clip != null ? clip.Id : string.Empty); + Dirty(graph); + } + + public static void SetNodeSpeed(FrameAnimationGraph graph, AnimationNode node, float? value) + { + Undo.RecordObject(graph, "Set Frame Animation Node Speed"); + node.SetSpeedOverride(value); + Dirty(graph); + } + + public static bool SetNodeEndBehavior( + FrameAnimationGraph graph, + AnimationNode node, + FrameClipEndBehavior? value, + bool removeOutgoing, + out string error) + { + error = string.Empty; + var outgoing = graph.Edges.Where(edge => edge != null && edge.FromNodeId == node.InternalId).ToArray(); + if (value.HasValue && outgoing.Length > 0 && !removeOutgoing) + { + error = "节点仍有后继 Edge。"; + return false; + } + Undo.RecordObject(graph, "Set Frame Animation Node End Behavior"); + if (value.HasValue) + { + foreach (var edge in outgoing) + { + graph.RemoveEdge(edge); + } + } + node.SetEndBehaviorOverride(value); + Dirty(graph); + return true; + } + + public static void RemoveEdges(FrameAnimationGraph graph, IEnumerable edges) + { + var items = (edges ?? Array.Empty()).Where(edge => edge != null).Distinct().ToArray(); + if (graph == null || items.Length == 0) + { + return; + } + Undo.RecordObject(graph, "Disconnect Frame Animation Nodes"); + foreach (var edge in items) + { + graph.RemoveEdge(edge); + } + Dirty(graph); + } + + public static bool RemoveNodes( + FrameAnimationGraph graph, + IEnumerable nodes, + bool deleteEntryFlows, + out string error) + { + return RemoveNodesAndEdges( + graph, nodes, Array.Empty(), deleteEntryFlows, out error); + } + + public static bool RemoveNodesAndEdges( + FrameAnimationGraph graph, + IEnumerable nodes, + IEnumerable additionalEdges, + bool deleteEntryFlows, + out string error) + { + error = string.Empty; + if (graph == null) + { + error = "Graph 已失效。"; + return false; + } + var items = (nodes ?? Array.Empty()).Where(node => node != null).Distinct().ToArray(); + var extraEdges = (additionalEdges ?? Array.Empty()) + .Where(edge => edge != null).Distinct().ToArray(); + var ids = new HashSet(items.Select(node => node.InternalId)); + var entryFlows = graph.Flows.Where(flow => flow != null && ids.Contains(flow.EntryNodeId)).ToArray(); + if (entryFlows.Length > 0 && !deleteEntryFlows) + { + error = "删除项包含 Flow 入口节点。"; + return false; + } + Undo.RecordObject(graph, "Delete Frame Animation Graph Elements"); + foreach (var edge in graph.Edges.Where(edge => edge != null && + (ids.Contains(edge.FromNodeId) || ids.Contains(edge.ToNodeId) || extraEdges.Contains(edge))).ToArray()) + { + graph.RemoveEdge(edge); + } + foreach (var flow in entryFlows) + { + graph.RemoveFlow(flow); + graph.EditorData.RemoveFlowData(flow.Id); + if (graph.Settings.DefaultPlayableId == flow.Id) + { + graph.Settings.SetDefaultPlayableId(string.Empty); + } + } + foreach (var node in items) + { + graph.RemoveNode(node); + graph.EditorData.RemoveNodeData(node.InternalId); + } + Dirty(graph); + return true; + } + + public static string MakeUniqueFlowId(FrameAnimationGraph graph, AnimationNode node) + { + var root = (string.IsNullOrWhiteSpace(node?.DisplayName) ? "Animation" : node.DisplayName) + "Flow"; + var candidate = root; + var suffix = 2; + while (!FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, candidate)) + { + candidate = root + suffix++; + } + return candidate; + } + + public static bool CreateFlow( + FrameAnimationGraph graph, + AnimationNode entry, + string id, + string displayName, + out AnimationFlow flow, + out string error) + { + flow = null; + error = string.Empty; + if (graph == null || entry == null || !graph.Nodes.Contains(entry)) + { + error = "Flow 入口必须属于当前 Graph。"; + return false; + } + var owner = graph.Flows.FirstOrDefault(existing => existing != null && existing.EntryNodeId == entry.InternalId); + if (owner != null) + { + error = $"该节点已是 Flow '{owner.Id}' 的入口。"; + return false; + } + if (!FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, id)) + { + error = "Flow id 为空或与 Clip / Flow 冲突。"; + return false; + } + Undo.RecordObject(graph, "Create Animation Flow"); + flow = new AnimationFlow(id, string.IsNullOrWhiteSpace(displayName) ? id : displayName, entry.InternalId); + graph.AddFlow(flow); + graph.EditorData.GetOrCreateFlowData(flow.Id, FlowColors[(graph.Flows.Count - 1) % FlowColors.Length]); + Dirty(graph); + return true; + } + + public static bool SetFlowEntry( + FrameAnimationGraph graph, + AnimationFlow flow, + AnimationNode entry, + out string error) + { + error = string.Empty; + if (graph == null || flow == null || entry == null || + !graph.Flows.Contains(flow) || !graph.Nodes.Contains(entry)) + { + error = "Flow 或入口节点不属于当前 Graph。"; + return false; + } + var owner = graph.Flows.FirstOrDefault(existing => existing != null && existing != flow && + existing.EntryNodeId == entry.InternalId); + if (owner != null) + { + error = $"该节点已是 Flow '{owner.Id}' 的入口。"; + return false; + } + Undo.RecordObject(graph, "Set Animation Flow Entry"); + flow.SetEntryNodeId(entry.InternalId); + Dirty(graph); + return true; + } + + public static void SetFlowColor(FrameAnimationGraph graph, AnimationFlow flow, Color value) + { + if (graph == null || flow == null) + { + return; + } + Undo.RecordObject(graph, "Set Animation Flow Color"); + graph.EditorData.GetOrCreateFlowData(flow.Id, value).SetColor(value); + Dirty(graph); + } + + public static bool EnsureEditorData(FrameAnimationGraph graph) + { + if (graph == null) + { + return false; + } + var missingNodes = graph.Nodes.Where(node => node != null && + graph.EditorData.NodeEditorData.All(data => data == null || data.NodeId != node.InternalId)).ToArray(); + var missingFlows = graph.Flows.Where(flow => flow != null && + graph.EditorData.FlowEditorData.All(data => data == null || data.FlowId != flow.Id)).ToArray(); + if (missingNodes.Length == 0 && missingFlows.Length == 0) + { + return false; + } + Undo.RecordObject(graph, "Initialize Frame Animation Graph Layout"); + for (var index = 0; index < missingNodes.Length; index++) + { + var position = new Vector2((index % 4) * 300f, (index / 4) * 180f); + graph.EditorData.GetOrCreateNodeData(missingNodes[index].InternalId, position); + } + for (var index = 0; index < missingFlows.Length; index++) + { + graph.EditorData.GetOrCreateFlowData(missingFlows[index].Id, + FlowColors[(graph.Flows.ToList().IndexOf(missingFlows[index]) + FlowColors.Length) % FlowColors.Length]); + } + Dirty(graph); + return true; + } + + public static Vector2 GetNodePosition(FrameAnimationGraph graph, AnimationNode node) + { + return graph?.EditorData.NodeEditorData.FirstOrDefault(data => + data != null && data.NodeId == node?.InternalId)?.Position ?? Vector2.zero; + } + + public static void SetNodePositions( + FrameAnimationGraph graph, + IReadOnlyDictionary positions, + string undoName) + { + if (graph == null || positions == null || positions.Count == 0) + { + return; + } + Undo.RecordObject(graph, undoName); + foreach (var pair in positions) + { + graph.EditorData.GetOrCreateNodeData(pair.Key.InternalId, pair.Value).SetPosition(pair.Value); + } + Dirty(graph); + } + + private static void Dirty(FrameAnimationGraph graph) + { + EditorUtility.SetDirty(graph); + } + } + + internal static class FrameAnimationGraphLayoutService + { + public static IReadOnlyDictionary Calculate( + FrameAnimationGraph graph, + IEnumerable scope) + { + var source = scope ?? (graph != null + ? graph.Nodes + : (IEnumerable)Array.Empty()); + var nodes = source.Where(node => node != null).Distinct() + .GroupBy(node => node.InternalId ?? string.Empty) + .Select(group => group.First()).ToArray(); + if (graph == null || nodes.Length == 0) + { + return new Dictionary(); + } + var ids = new HashSet(nodes.Select(node => node.InternalId)); + var edges = graph.Edges.Where(edge => edge != null && ids.Contains(edge.FromNodeId) && ids.Contains(edge.ToNodeId)).ToArray(); + var incoming = nodes.ToDictionary(node => node.InternalId, _ => 0); + foreach (var edge in edges) + { + incoming[edge.ToNodeId]++; + } + var ranks = nodes.ToDictionary(node => node.InternalId, _ => 0); + var queue = new Queue(nodes.Where(node => incoming[node.InternalId] == 0) + .OrderBy(node => node.InternalId, StringComparer.Ordinal)); + var processed = new HashSet(); + while (queue.Count > 0) + { + var node = queue.Dequeue(); + if (!processed.Add(node.InternalId)) continue; + foreach (var edge in edges.Where(edge => edge.FromNodeId == node.InternalId)) + { + ranks[edge.ToNodeId] = Math.Max(ranks[edge.ToNodeId], ranks[node.InternalId] + 1); + incoming[edge.ToNodeId]--; + if (incoming[edge.ToNodeId] == 0) + { + queue.Enqueue(nodes.First(item => item.InternalId == edge.ToNodeId)); + } + } + } + var oldPositions = nodes.ToDictionary(node => node, + node => FrameAnimationGraphMutationService.GetNodePosition(graph, node)); + var originX = oldPositions.Values.Min(position => position.x); + var originY = oldPositions.Values.Min(position => position.y); + var result = new Dictionary(); + foreach (var group in nodes.GroupBy(node => ranks[node.InternalId]).OrderBy(group => group.Key)) + { + var ordered = group.OrderBy(node => oldPositions[node].y) + .ThenBy(node => node.InternalId, StringComparer.Ordinal).ToArray(); + for (var index = 0; index < ordered.Length; index++) + { + result[ordered[index]] = new Vector2(originX + group.Key * 300f, originY + index * 180f); + } + } + return result; + } + } +} diff --git a/Assets/Editor/FrameAnimation/FrameAnimationGraphAuthoringServices.cs.meta b/Assets/Editor/FrameAnimation/FrameAnimationGraphAuthoringServices.cs.meta new file mode 100644 index 000000000..1add36e3d --- /dev/null +++ b/Assets/Editor/FrameAnimation/FrameAnimationGraphAuthoringServices.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b397ed96c5b44df89626f866c32e03dd diff --git a/Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.cs b/Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.cs new file mode 100644 index 000000000..a9a4c99fe --- /dev/null +++ b/Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.cs @@ -0,0 +1,33 @@ +using UnityEditor; +using UnityEngine; + +namespace AibisDream.FrameAnimation.Editor +{ + [CustomEditor(typeof(FrameAnimationGraph))] + public sealed class FrameAnimationGraphEditor : UnityEditor.Editor + { + public override void OnInspectorGUI() + { + var graph = (FrameAnimationGraph)target; + EditorGUILayout.LabelField("FrameAnimationGraph", EditorStyles.boldLabel); + using (new EditorGUI.DisabledScope(true)) + { + EditorGUILayout.TextField("ID", graph.Id); + EditorGUILayout.TextField("Display Name", graph.DisplayName); + EditorGUILayout.IntField("Clips", graph.Clips.Count); + EditorGUILayout.IntField("Nodes", graph.Nodes.Count); + EditorGUILayout.IntField("Edges", graph.Edges.Count); + EditorGUILayout.IntField("Flows", graph.Flows.Count); + EditorGUILayout.IntField("Import Sources", graph.ImportSources.Count); + EditorGUILayout.TextField("Default Playable", graph.Settings?.DefaultPlayableId ?? string.Empty); + } + EditorGUILayout.HelpBox( + "Graph 的资源、来源、重命名和删除操作只能在主工作台中执行。", + MessageType.Info); + if (GUILayout.Button("Open Frame Animation Graph Editor")) + { + FrameAnimationGraphEditorWindow.Open(graph); + } + } + } +} diff --git a/Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.cs.meta b/Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.cs.meta new file mode 100644 index 000000000..13479ff4f --- /dev/null +++ b/Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 74f3b50c2650607499ea1c82a282f00f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.uss b/Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.uss new file mode 100644 index 000000000..0bfb16281 --- /dev/null +++ b/Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.uss @@ -0,0 +1,72 @@ +.fa-preview { + position: relative; + overflow: hidden; + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-left-color: rgb(25, 25, 25); + border-right-color: rgb(25, 25, 25); + border-top-color: rgb(25, 25, 25); + border-bottom-color: rgb(25, 25, 25); +} + +.fa-preview__image { + position: absolute; +} + +.fa-preview__empty { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + -unity-text-align: middle-center; + color: rgb(190, 190, 190); +} + +.fa-node__preview { + height: 82px; + margin-top: 5px; +} + +.fa-node--preview-current { + border-left-color: rgb(55, 174, 240); + border-right-color: rgb(55, 174, 240); + border-top-color: rgb(55, 174, 240); + border-bottom-color: rgb(55, 174, 240); +} + +.fa-node--preview-passed { + border-left-color: rgb(83, 154, 113); +} + +.fa-node--preview-upcoming { + opacity: 0.72; +} + +.fa-clip-preview-panel { + flex-shrink: 0; + padding-left: 6px; + padding-right: 6px; + padding-top: 4px; + padding-bottom: 5px; + border-bottom-width: 1px; + border-bottom-color: rgb(28, 28, 28); +} + +.fa-clip-preview-panel .fa-preview { + height: 190px; + margin-bottom: 4px; +} + +.fa-preview-controls { + flex-direction: row; + align-items: center; +} + +.fa-preview-status { + font-size: 10px; + color: rgb(185, 185, 185); + margin-top: 2px; +} diff --git a/Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.uss.meta b/Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.uss.meta new file mode 100644 index 000000000..37c3f50ed --- /dev/null +++ b/Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.uss.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: a38dd39c699e42348667cc1264ea9076 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/FrameAnimation/FrameAnimationGraphEditorWindow.cs b/Assets/Editor/FrameAnimation/FrameAnimationGraphEditorWindow.cs new file mode 100644 index 000000000..e9244f119 --- /dev/null +++ b/Assets/Editor/FrameAnimation/FrameAnimationGraphEditorWindow.cs @@ -0,0 +1,2485 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEditor.Callbacks; +using UnityEditor.UIElements; +using UnityEditorInternal; +using UnityEngine; +using UnityEngine.UIElements; + +namespace AibisDream.FrameAnimation.Editor +{ + public sealed class FrameAnimationGraphEditorWindow : EditorWindow + { + private enum ResourceTab { Clips, Flows, Sources } + private enum BottomTab { ImportDiff, Validation } + private enum ValidationKindFilter { All, Graph, Clip, Flow, Source, Node, Edge } + + private sealed class BrowserEntry + { + public bool IsHeader; + public string Label; + public FrameAnimationEditorSelection Selection; + } + + private const string LastGraphGuidKey = "AibisDream.FrameAnimation.Workspace.LastGraphGuid"; + + [SerializeField] private FrameAnimationGraph graph; + private ObjectField graphField; + private ToolbarButton graphPropertiesButton; + private ToolbarButton graphBreadcrumbButton; + private Label dirtyLabel; + private Label canvasLabel; + private FrameAnimationGraphView graphView; + private PopupField flowFocusField; + private Label flowFocusStatusLabel; + private ToolbarButton exitFlowFocusButton; + private string focusedFlowId = string.Empty; + private IReadOnlyList multiSelectedNodes = Array.Empty(); + private AnimationNode lastSelectedNode; + private Vector2 browserDragStart; + private bool browserDragPending; + private ListView resourceList; + private readonly List browserEntries = new List(); + private ToolbarSearchField searchField; + private EnumField filterField; + private EnumField sortField; + private IMGUIContainer propertyContainer; + private IMGUIContainer bottomContainer; + private VisualElement leftPanel; + private VisualElement rightPanel; + private VisualElement bottomPanel; + private ResourceTab resourceTab; + private BottomTab bottomTab; + private FrameAnimationClipFilter clipFilter; + private FrameAnimationClipSort clipSort; + private FrameAnimationEditorSelection selection; + private FrameAnimationImportPreview importPreview; + private IReadOnlyList validationIssues = Array.Empty(); + private bool bottomCollapsed; + private bool showErrors = true; + private bool showWarnings = true; + private bool showInfo = true; + private ValidationKindFilter validationKindFilter; + private double validateAt = -1d; + private SerializedObject clipSerializedObject; + private FrameClip frameListClip; + private ReorderableList frameList; + private readonly FrameAnimationPreviewCoordinator previewCoordinator = new FrameAnimationPreviewCoordinator(); + private readonly List previewTimelineSliders = new List(); + private readonly List