From 85a3fc9e14b7a2a0eab5d7ddda5ee63e3b0a34c3 Mon Sep 17 00:00:00 2001 From: Ding Yuntian <1491671119@qq.com> Date: Wed, 15 Jul 2026 15:13:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(frameAnima):=20=E5=8A=A8=E7=94=BB=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=89=8D=E5=9B=9B=E4=B8=AA=E9=98=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Editor/FrameAnimation.meta | 8 + .../AibisDream.FrameAnimation.Editor.asmdef | 19 + ...bisDream.FrameAnimation.Editor.asmdef.meta | 7 + .../FrameAnimation/AsepriteJsonParser.cs | 198 ++ .../FrameAnimation/AsepriteJsonParser.cs.meta | 11 + Assets/Editor/FrameAnimation/AssemblyInfo.cs | 3 + .../FrameAnimation/AssemblyInfo.cs.meta | 11 + .../FrameAnimationEditorDialogs.cs | 307 +++ .../FrameAnimationEditorDialogs.cs.meta | 11 + .../FrameAnimationEditorServices.cs | 744 +++++++ .../FrameAnimationEditorServices.cs.meta | 11 + .../FrameAnimationGraphAuthoringServices.cs | 539 +++++ ...ameAnimationGraphAuthoringServices.cs.meta | 2 + .../FrameAnimationGraphEditor.cs | 33 + .../FrameAnimationGraphEditor.cs.meta | 11 + .../FrameAnimationGraphEditorWindow.cs | 1948 +++++++++++++++++ .../FrameAnimationGraphEditorWindow.cs.meta | 11 + .../FrameAnimation/FrameAnimationGraphView.cs | 622 ++++++ .../FrameAnimationGraphView.cs.meta | 2 + .../FrameAnimationImportModels.cs | 261 +++ .../FrameAnimationImportModels.cs.meta | 11 + .../FrameAnimationImportSampleBuilder.cs | 335 +++ .../FrameAnimationImportSampleBuilder.cs.meta | 11 + .../FrameAnimationImportService.cs | 605 +++++ .../FrameAnimationImportService.cs.meta | 11 + .../FrameAnimationPlayerEditor.cs | 84 + .../FrameAnimationPlayerEditor.cs.meta | 11 + .../FrameAnimationRuntimeSampleBuilder.cs | 223 ++ ...FrameAnimationRuntimeSampleBuilder.cs.meta | 11 + .../FrameAnimationSpriteUtility.cs | 344 +++ .../FrameAnimationSpriteUtility.cs.meta | 11 + .../Editor/FrameAnimation/FrameClipEditor.cs | 60 + .../FrameAnimation/FrameClipEditor.cs.meta | 11 + Assets/GameContent/Test/FrameAnimation.meta | 8 + .../FrameAnimation/DirectSampleGraph.asset | 29 + .../DirectSampleGraph.asset.meta | 8 + .../Test/FrameAnimation/FlowSampleGraph.asset | 55 + .../FrameAnimation/FlowSampleGraph.asset.meta | 8 + .../Test/FrameAnimation/Idle.asset | 87 + .../Test/FrameAnimation/Idle.asset.meta | 8 + .../Test/FrameAnimation/Import.meta | 8 + .../Import/FrameAnimationGraph.asset | 775 +++++++ .../Import/FrameAnimationGraph.asset.meta | 8 + .../Test/FrameAnimation/Import/中文Array.json | 69 + .../FrameAnimation/Import/中文Array.json.meta | 7 + .../FrameAnimation/Import/中文Object.json | 67 + .../Import/中文Object.json.meta | 7 + .../FrameAnimation/Import/中文自动切图.png | 3 + .../Import/中文自动切图.png.meta | 232 ++ .../Test/FrameAnimation/Import/中文预切图.png | 3 + .../FrameAnimation/Import/中文预切图.png.meta | 232 ++ .../FrameAnimation/Import/火山像素版.json | 639 ++++++ .../Import/火山像素版.json.meta | 7 + .../Test/FrameAnimation/Import/火山像素版.png | 3 + .../FrameAnimation/Import/火山像素版.png.meta | 1772 +++++++++++++++ .../Test/FrameAnimation/Intro.asset | 43 + .../Test/FrameAnimation/Intro.asset.meta | 8 + .../LiberationSans SDF - Fallback.asset | 45 +- Assets/Scenes/FrameAnimationRuntimeTest.unity | 553 +++++ .../FrameAnimationRuntimeTest.unity.meta | 7 + Assets/Scenes/梦境.unity | 1 - Assets/Scripts/FrameAnimation.meta | 8 + Assets/Scripts/FrameAnimation/Runtime.meta | 8 + .../AibisDream.FrameAnimation.Runtime.asmdef | 16 + ...isDream.FrameAnimation.Runtime.asmdef.meta | 7 + .../FrameAnimation/Runtime/AssemblyInfo.cs | 5 + .../Runtime/AssemblyInfo.cs.meta | 11 + .../Runtime/FrameAnimationData.cs | 401 ++++ .../Runtime/FrameAnimationData.cs.meta | 11 + .../Runtime/FrameAnimationGraph.cs | 196 ++ .../Runtime/FrameAnimationGraph.cs.meta | 11 + .../Runtime/FrameAnimationGraphTopology.cs | 142 ++ .../FrameAnimationGraphTopology.cs.meta | 2 + .../Runtime/FrameAnimationPlaybackHandle.cs | 96 + .../FrameAnimationPlaybackHandle.cs.meta | 11 + .../Runtime/FrameAnimationPlaybackSession.cs | 220 ++ .../FrameAnimationPlaybackSession.cs.meta | 11 + .../Runtime/FrameAnimationPlayer.cs | 414 ++++ .../Runtime/FrameAnimationPlayer.cs.meta | 11 + .../Runtime/FrameAnimationResolver.cs | 274 +++ .../Runtime/FrameAnimationResolver.cs.meta | 11 + .../Runtime/FrameAnimationTarget.cs | 80 + .../Runtime/FrameAnimationTarget.cs.meta | 11 + .../Runtime/FrameAnimationTypes.cs | 127 ++ .../Runtime/FrameAnimationTypes.cs.meta | 11 + .../Runtime/FrameAnimationValidation.cs | 541 +++++ .../Runtime/FrameAnimationValidation.cs.meta | 11 + .../FrameAnimation/Runtime/FrameClip.cs | 119 + .../FrameAnimation/Runtime/FrameClip.cs.meta | 11 + .../Scripts/UI/Components/DreamTerminalUI.cs | 185 +- Assets/Tests.meta | 8 + Assets/Tests/FrameAnimation.meta | 8 + Assets/Tests/FrameAnimation/EditMode.meta | 8 + ...Dream.FrameAnimation.Tests.EditMode.asmdef | 24 + ....FrameAnimation.Tests.EditMode.asmdef.meta | 7 + .../EditMode/FrameAnimationCoreTests.cs | 258 +++ .../EditMode/FrameAnimationCoreTests.cs.meta | 11 + .../FrameAnimationEditorWorkspaceTests.cs | 227 ++ ...FrameAnimationEditorWorkspaceTests.cs.meta | 2 + .../FrameAnimationGraphAuthoringTests.cs | 187 ++ .../FrameAnimationGraphAuthoringTests.cs.meta | 2 + .../EditMode/FrameAnimationImportTests.cs | 249 +++ .../FrameAnimationImportTests.cs.meta | 11 + Assets/Tests/FrameAnimation/PlayMode.meta | 8 + ...Dream.FrameAnimation.Tests.PlayMode.asmdef | 21 + ....FrameAnimation.Tests.PlayMode.asmdef.meta | 7 + .../PlayMode/FrameAnimationPlayerTests.cs | 293 +++ .../FrameAnimationPlayerTests.cs.meta | 11 + 108 files changed, 15448 insertions(+), 35 deletions(-) create mode 100644 Assets/Editor/FrameAnimation.meta create mode 100644 Assets/Editor/FrameAnimation/AibisDream.FrameAnimation.Editor.asmdef create mode 100644 Assets/Editor/FrameAnimation/AibisDream.FrameAnimation.Editor.asmdef.meta create mode 100644 Assets/Editor/FrameAnimation/AsepriteJsonParser.cs create mode 100644 Assets/Editor/FrameAnimation/AsepriteJsonParser.cs.meta create mode 100644 Assets/Editor/FrameAnimation/AssemblyInfo.cs create mode 100644 Assets/Editor/FrameAnimation/AssemblyInfo.cs.meta create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationEditorDialogs.cs create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationEditorDialogs.cs.meta create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationEditorServices.cs create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationEditorServices.cs.meta create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationGraphAuthoringServices.cs create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationGraphAuthoringServices.cs.meta create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.cs create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.cs.meta create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationGraphEditorWindow.cs create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationGraphEditorWindow.cs.meta create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationGraphView.cs create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationGraphView.cs.meta create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationImportModels.cs create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationImportModels.cs.meta create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationImportSampleBuilder.cs create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationImportSampleBuilder.cs.meta create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationImportService.cs create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationImportService.cs.meta create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationPlayerEditor.cs create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationPlayerEditor.cs.meta create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationRuntimeSampleBuilder.cs create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationRuntimeSampleBuilder.cs.meta create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationSpriteUtility.cs create mode 100644 Assets/Editor/FrameAnimation/FrameAnimationSpriteUtility.cs.meta create mode 100644 Assets/Editor/FrameAnimation/FrameClipEditor.cs create mode 100644 Assets/Editor/FrameAnimation/FrameClipEditor.cs.meta create mode 100644 Assets/GameContent/Test/FrameAnimation.meta create mode 100644 Assets/GameContent/Test/FrameAnimation/DirectSampleGraph.asset create mode 100644 Assets/GameContent/Test/FrameAnimation/DirectSampleGraph.asset.meta create mode 100644 Assets/GameContent/Test/FrameAnimation/FlowSampleGraph.asset create mode 100644 Assets/GameContent/Test/FrameAnimation/FlowSampleGraph.asset.meta create mode 100644 Assets/GameContent/Test/FrameAnimation/Idle.asset create mode 100644 Assets/GameContent/Test/FrameAnimation/Idle.asset.meta create mode 100644 Assets/GameContent/Test/FrameAnimation/Import.meta create mode 100644 Assets/GameContent/Test/FrameAnimation/Import/FrameAnimationGraph.asset create mode 100644 Assets/GameContent/Test/FrameAnimation/Import/FrameAnimationGraph.asset.meta create mode 100644 Assets/GameContent/Test/FrameAnimation/Import/中文Array.json create mode 100644 Assets/GameContent/Test/FrameAnimation/Import/中文Array.json.meta create mode 100644 Assets/GameContent/Test/FrameAnimation/Import/中文Object.json create mode 100644 Assets/GameContent/Test/FrameAnimation/Import/中文Object.json.meta create mode 100644 Assets/GameContent/Test/FrameAnimation/Import/中文自动切图.png create mode 100644 Assets/GameContent/Test/FrameAnimation/Import/中文自动切图.png.meta create mode 100644 Assets/GameContent/Test/FrameAnimation/Import/中文预切图.png create mode 100644 Assets/GameContent/Test/FrameAnimation/Import/中文预切图.png.meta create mode 100644 Assets/GameContent/Test/FrameAnimation/Import/火山像素版.json create mode 100644 Assets/GameContent/Test/FrameAnimation/Import/火山像素版.json.meta create mode 100644 Assets/GameContent/Test/FrameAnimation/Import/火山像素版.png create mode 100644 Assets/GameContent/Test/FrameAnimation/Import/火山像素版.png.meta create mode 100644 Assets/GameContent/Test/FrameAnimation/Intro.asset create mode 100644 Assets/GameContent/Test/FrameAnimation/Intro.asset.meta create mode 100644 Assets/Scenes/FrameAnimationRuntimeTest.unity create mode 100644 Assets/Scenes/FrameAnimationRuntimeTest.unity.meta create mode 100644 Assets/Scripts/FrameAnimation.meta create mode 100644 Assets/Scripts/FrameAnimation/Runtime.meta create mode 100644 Assets/Scripts/FrameAnimation/Runtime/AibisDream.FrameAnimation.Runtime.asmdef create mode 100644 Assets/Scripts/FrameAnimation/Runtime/AibisDream.FrameAnimation.Runtime.asmdef.meta create mode 100644 Assets/Scripts/FrameAnimation/Runtime/AssemblyInfo.cs create mode 100644 Assets/Scripts/FrameAnimation/Runtime/AssemblyInfo.cs.meta create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationData.cs create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationData.cs.meta create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationGraph.cs create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationGraph.cs.meta create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationGraphTopology.cs create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationGraphTopology.cs.meta create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlaybackHandle.cs create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlaybackHandle.cs.meta create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlaybackSession.cs create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlaybackSession.cs.meta create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlayer.cs create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationPlayer.cs.meta create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationResolver.cs create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationResolver.cs.meta create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationTarget.cs create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationTarget.cs.meta create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationTypes.cs create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationTypes.cs.meta create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationValidation.cs create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameAnimationValidation.cs.meta create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameClip.cs create mode 100644 Assets/Scripts/FrameAnimation/Runtime/FrameClip.cs.meta create mode 100644 Assets/Tests.meta create mode 100644 Assets/Tests/FrameAnimation.meta create mode 100644 Assets/Tests/FrameAnimation/EditMode.meta create mode 100644 Assets/Tests/FrameAnimation/EditMode/AibisDream.FrameAnimation.Tests.EditMode.asmdef create mode 100644 Assets/Tests/FrameAnimation/EditMode/AibisDream.FrameAnimation.Tests.EditMode.asmdef.meta create mode 100644 Assets/Tests/FrameAnimation/EditMode/FrameAnimationCoreTests.cs create mode 100644 Assets/Tests/FrameAnimation/EditMode/FrameAnimationCoreTests.cs.meta create mode 100644 Assets/Tests/FrameAnimation/EditMode/FrameAnimationEditorWorkspaceTests.cs create mode 100644 Assets/Tests/FrameAnimation/EditMode/FrameAnimationEditorWorkspaceTests.cs.meta create mode 100644 Assets/Tests/FrameAnimation/EditMode/FrameAnimationGraphAuthoringTests.cs create mode 100644 Assets/Tests/FrameAnimation/EditMode/FrameAnimationGraphAuthoringTests.cs.meta create mode 100644 Assets/Tests/FrameAnimation/EditMode/FrameAnimationImportTests.cs create mode 100644 Assets/Tests/FrameAnimation/EditMode/FrameAnimationImportTests.cs.meta create mode 100644 Assets/Tests/FrameAnimation/PlayMode.meta create mode 100644 Assets/Tests/FrameAnimation/PlayMode/AibisDream.FrameAnimation.Tests.PlayMode.asmdef create mode 100644 Assets/Tests/FrameAnimation/PlayMode/AibisDream.FrameAnimation.Tests.PlayMode.asmdef.meta create mode 100644 Assets/Tests/FrameAnimation/PlayMode/FrameAnimationPlayerTests.cs create mode 100644 Assets/Tests/FrameAnimation/PlayMode/FrameAnimationPlayerTests.cs.meta diff --git a/Assets/Editor/FrameAnimation.meta b/Assets/Editor/FrameAnimation.meta new file mode 100644 index 000000000..ea98cbebf --- /dev/null +++ b/Assets/Editor/FrameAnimation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9e244d3d62dec3f4f881f7e681da8c03 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: 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/Editor/FrameAnimation/AsepriteJsonParser.cs.meta b/Assets/Editor/FrameAnimation/AsepriteJsonParser.cs.meta new file mode 100644 index 000000000..998c3ac8b --- /dev/null +++ b/Assets/Editor/FrameAnimation/AsepriteJsonParser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a21095473b1495440acab244f9e470c6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: 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/Editor/FrameAnimation/AssemblyInfo.cs.meta b/Assets/Editor/FrameAnimation/AssemblyInfo.cs.meta new file mode 100644 index 000000000..f1b416002 --- /dev/null +++ b/Assets/Editor/FrameAnimation/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8edeae42e1831084cbf05db9d9e5d1e2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: 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..bd562cbe7 --- /dev/null +++ b/Assets/Editor/FrameAnimation/FrameAnimationGraphAuthoringServices.cs @@ -0,0 +1,539 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace AibisDream.FrameAnimation.Editor +{ + 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/FrameAnimationGraphEditorWindow.cs b/Assets/Editor/FrameAnimation/FrameAnimationGraphEditorWindow.cs new file mode 100644 index 000000000..85e254869 --- /dev/null +++ b/Assets/Editor/FrameAnimation/FrameAnimationGraphEditorWindow.cs @@ -0,0 +1,1948 @@ +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 Label dirtyLabel; + private Label canvasLabel; + private FrameAnimationGraphView graphView; + private PopupField flowFocusField; + 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; + + [MenuItem("Window/Aibis Dream/Frame Animation Graph Editor")] + public static void ShowWindow() + { + var window = GetWindow(); + window.titleContent = new GUIContent("Frame Animation Graph"); + window.minSize = new Vector2(1050f, 650f); + if (window.graph == null) + { + window.RestoreLastGraph(); + } + window.Show(); + } + + public static void Open(FrameAnimationGraph targetGraph) + { + ShowWindow(); + var window = GetWindow(); + window.SetGraph(targetGraph); + window.Focus(); + } + + private void CreateGUI() + { + rootVisualElement.Clear(); + rootVisualElement.style.flexDirection = FlexDirection.Column; + BuildToolbar(); + BuildMainArea(); + BuildBottomArea(); + RestoreLastGraph(); + SetGraph(graph); + RestoreWorkspaceState(); + RestoreSelection(); + RefreshBrowser(); + RefreshFlowChoices(); + RefreshGraphView(); + } + + private void OnEnable() + { + Undo.undoRedoPerformed += OnProjectDataChanged; + EditorApplication.projectChanged += OnProjectDataChanged; + } + + private void OnDisable() + { + Undo.undoRedoPerformed -= OnProjectDataChanged; + EditorApplication.projectChanged -= OnProjectDataChanged; + SaveWorkspaceState(); + } + + private void Update() + { + if (validateAt > 0d && EditorApplication.timeSinceStartup >= validateAt) + { + validateAt = -1d; + validationIssues = FrameAnimationEditorValidationService.Validate(graph, false, out _); + bottomContainer?.MarkDirtyRepaint(); + RefreshBrowser(); + RefreshGraphView(); + } + UpdateDirtyLabel(); + } + + private void BuildToolbar() + { + var toolbar = new Toolbar(); + graphField = new ObjectField("Graph") + { + objectType = typeof(FrameAnimationGraph), + allowSceneObjects = false + }; + graphField.style.width = 330f; + graphField.RegisterValueChangedCallback(evt => SetGraph(evt.newValue as FrameAnimationGraph)); + toolbar.Add(graphField); + toolbar.Add(new ToolbarButton(CreateGraph) { text = "Create" }); + toolbar.Add(new ToolbarButton(SaveGraph) { text = "Save" }); + dirtyLabel = new Label(); + dirtyLabel.style.marginLeft = 6f; + toolbar.Add(dirtyLabel); + toolbar.Add(new ToolbarSpacer { style = { flexGrow = 1f } }); + toolbar.Add(new ToolbarButton(PreviewAllImports) { text = "Preview Imports" }); + toolbar.Add(new ToolbarButton(RefreshAllImports) { text = "Refresh All" }); + toolbar.Add(new ToolbarButton(ValidateFull) { text = "Validate" }); + rootVisualElement.Add(toolbar); + } + + private void BuildMainArea() + { + var main = new VisualElement + { + style = + { + flexDirection = FlexDirection.Row, + flexGrow = 1f, + overflow = Overflow.Hidden + } + }; + + leftPanel = BuildLeftPanel(); + main.Add(leftPanel); + main.Add(CreateVerticalResizer(leftPanel, 220f, 520f, "LeftWidth")); + + var canvas = new VisualElement + { + style = + { + flexGrow = 1f, + minWidth = 280f, + flexDirection = FlexDirection.Column, + backgroundColor = new StyleColor(new Color(0.13f, 0.13f, 0.13f)) + } + }; + var canvasToolbar = new Toolbar(); + canvasToolbar.Add(new ToolbarButton(ShowAllNodes) { text = "Show All" }); + flowFocusField = new PopupField(new List { "Show All" }, 0) + { + style = { minWidth = 170f } + }; + flowFocusField.RegisterValueChangedCallback(evt => + { + if (evt.newValue == "Show All") + { + SetFocusedFlow(string.Empty); + } + else + { + var flow = graph?.Flows.FirstOrDefault(item => item != null && + FlowChoiceLabel(item) == evt.newValue); + SetFocusedFlow(flow?.Id ?? string.Empty); + } + }); + canvasToolbar.Add(flowFocusField); + canvasToolbar.Add(new ToolbarButton(() => graphView?.FrameSelection()) { text = "Frame Selection" }); + canvasToolbar.Add(new ToolbarButton(() => graphView?.FrameCurrentFlow(focusedFlowId)) { text = "Frame Flow" }); + canvasToolbar.Add(new ToolbarButton(AutoLayoutCanvas) { text = "Auto Layout" }); + canvasToolbar.Add(new ToolbarButton(CreateFlowFromCanvasSelection) { text = "Create Flow" }); + canvas.Add(canvasToolbar); + graphView = new FrameAnimationGraphView(); + graphView.SelectionRequested += value => SetSelection(value, false); + graphView.MultiSelectionChanged += nodes => + { + multiSelectedNodes = nodes ?? Array.Empty(); + propertyContainer?.MarkDirtyRepaint(); + }; + graphView.CreateFlowRequested += ShowCreateFlow; + graphView.GraphChanged += OnCanvasGraphChanged; + graphView.NotificationRequested += message => ShowNotification(new GUIContent(message)); + canvas.Add(graphView); + canvasLabel = new Label("选择或创建 FrameAnimationGraph") + { + pickingMode = PickingMode.Ignore, + style = + { + position = Position.Absolute, + left = 20f, + right = 20f, + top = 80f, + unityTextAlign = TextAnchor.MiddleCenter, + whiteSpace = WhiteSpace.Normal, + fontSize = 13f + } + }; + canvas.Add(canvasLabel); + main.Add(canvas); + + rightPanel = new VisualElement + { + style = + { + width = 360f, + minWidth = 260f, + maxWidth = 650f, + flexShrink = 0f, + flexDirection = FlexDirection.Column + } + }; + rightPanel.Add(CreatePanelHeader("Properties")); + propertyContainer = new IMGUIContainer(DrawSelectionProperties) { style = { flexGrow = 1f } }; + rightPanel.Add(propertyContainer); + main.Add(CreateVerticalResizer(rightPanel, 260f, 650f, "RightWidth", resizeFromLeft: true)); + main.Add(rightPanel); + rootVisualElement.Add(main); + } + + private VisualElement BuildLeftPanel() + { + var panel = new VisualElement + { + style = + { + width = 330f, + minWidth = 220f, + maxWidth = 520f, + flexShrink = 0f, + flexDirection = FlexDirection.Column + } + }; + var tabs = new Toolbar(); + tabs.Add(new ToolbarButton(() => SetResourceTab(ResourceTab.Clips)) { text = "Clips" }); + tabs.Add(new ToolbarButton(() => SetResourceTab(ResourceTab.Flows)) { text = "Flows" }); + tabs.Add(new ToolbarButton(() => SetResourceTab(ResourceTab.Sources)) { text = "Sources" }); + panel.Add(tabs); + searchField = new ToolbarSearchField(); + searchField.RegisterValueChangedCallback(_ => + { + SaveWorkspaceState(); + RefreshBrowser(); + }); + panel.Add(searchField); + var controls = new VisualElement { style = { flexDirection = FlexDirection.Row } }; + filterField = new EnumField(FrameAnimationClipFilter.All) { style = { flexGrow = 1f } }; + filterField.RegisterValueChangedCallback(evt => + { + clipFilter = (FrameAnimationClipFilter)evt.newValue; + SaveWorkspaceState(); + RefreshBrowser(); + }); + sortField = new EnumField(FrameAnimationClipSort.Name) { style = { flexGrow = 1f } }; + sortField.RegisterValueChangedCallback(evt => + { + clipSort = (FrameAnimationClipSort)evt.newValue; + SaveWorkspaceState(); + RefreshBrowser(); + }); + controls.Add(filterField); + controls.Add(sortField); + panel.Add(controls); + + resourceList = new ListView(browserEntries, 38f, MakeBrowserItem, BindBrowserItem) + { + selectionType = SelectionType.Single, + style = { flexGrow = 1f } + }; + resourceList.selectionChanged += OnBrowserSelectionChanged; + resourceList.itemsChosen += chosen => + { + var entry = chosen.Cast().FirstOrDefault(item => !item.IsHeader); + if (entry != null) + { + SetSelection(entry.Selection); + } + }; + panel.Add(resourceList); + + var actions = new VisualElement { style = { flexDirection = FlexDirection.Row } }; + actions.Add(new Button(CreateManualClip) { text = "New Manual" }); + actions.Add(new Button(AddExistingClip) { text = "Add Existing" }); + actions.Add(new Button(AddSource) { text = "Add Source" }); + panel.Add(actions); + return panel; + } + + private void BuildBottomArea() + { + bottomPanel = new VisualElement + { + style = + { + height = 230f, + minHeight = 28f, + maxHeight = 520f, + flexShrink = 0f, + borderTopWidth = 1f, + borderTopColor = new StyleColor(Color.black) + } + }; + var toolbar = new Toolbar(); + toolbar.Add(new ToolbarButton(() => SetBottomTab(BottomTab.ImportDiff)) { text = "Import Diff" }); + toolbar.Add(new ToolbarButton(() => SetBottomTab(BottomTab.Validation)) { text = "Validation" }); + toolbar.Add(new ToolbarSpacer { style = { flexGrow = 1f } }); + toolbar.Add(new ToolbarButton(ToggleBottom) { text = "Collapse / Expand" }); + bottomPanel.Add(toolbar); + bottomContainer = new IMGUIContainer(DrawBottomPanel) { style = { flexGrow = 1f } }; + bottomPanel.Add(bottomContainer); + var resizer = CreateHorizontalResizer(bottomPanel, 120f, 520f, "BottomHeight"); + rootVisualElement.Add(resizer); + rootVisualElement.Add(bottomPanel); + } + + private static VisualElement CreatePanelHeader(string text) + { + return new Label(text) + { + style = + { + unityFontStyleAndWeight = FontStyle.Bold, + paddingLeft = 8f, + paddingTop = 7f, + paddingBottom = 7f, + borderBottomWidth = 1f, + borderBottomColor = new StyleColor(Color.black) + } + }; + } + + private VisualElement MakeBrowserItem() + { + var label = new Label + { + style = + { + whiteSpace = WhiteSpace.Normal, + paddingLeft = 6f, + paddingTop = 3f, + paddingBottom = 3f + } + }; + label.RegisterCallback(evt => + { + if (evt.button == 0 && label.userData is BrowserEntry entry && + entry.Selection.Kind == FrameAnimationEditorSelectionKind.Clip && !entry.IsHeader) + { + browserDragPending = true; + browserDragStart = evt.position; + } + }); + label.RegisterCallback(evt => + { + if (!browserDragPending || Vector2.Distance(browserDragStart, evt.position) < 5f || + !(label.userData is BrowserEntry entry) || !(entry.Selection.Value is FrameClip clip)) + { + return; + } + browserDragPending = false; + DragAndDrop.PrepareStartDrag(); + DragAndDrop.SetGenericData(FrameAnimationGraphView.ClipDragKey, clip); + DragAndDrop.objectReferences = new UnityEngine.Object[] { clip }; + DragAndDrop.StartDrag($"Create Node: {clip.Id}"); + }); + label.RegisterCallback(_ => browserDragPending = false); + return label; + } + + private void BindBrowserItem(VisualElement element, int index) + { + var label = (Label)element; + if (index < 0 || index >= browserEntries.Count) + { + label.text = string.Empty; + return; + } + var entry = browserEntries[index]; + label.userData = entry; + label.text = entry.Label; + label.style.unityFontStyleAndWeight = entry.IsHeader ? FontStyle.Bold : FontStyle.Normal; + label.style.color = entry.IsHeader ? new Color(0.7f, 0.8f, 1f) : Color.white; + } + + private void OnBrowserSelectionChanged(IEnumerable values) + { + var entry = values.Cast().FirstOrDefault(); + if (entry != null && !entry.IsHeader) + { + SetSelection(entry.Selection); + } + } + + private void SetGraph(FrameAnimationGraph value) + { + if (graph == value && graphField != null) + { + graphField.SetValueWithoutNotify(graph); + } + else + { + SaveGraph(); + SaveWorkspaceState(); + graph = value; + selection = graph != null + ? new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph) + : default; + importPreview = null; + validationIssues = Array.Empty(); + multiSelectedNodes = Array.Empty(); + lastSelectedNode = null; + frameListClip = null; + frameList = null; + clipSerializedObject = null; + RestoreWorkspaceState(); + RestoreSelection(); + if (graph != null && FrameAnimationGraphMutationService.EnsureEditorData(graph)) + { + ShowNotification(new GUIContent("已初始化缺失的节点布局数据,可使用 Undo 撤销。")); + } + } + + graphField?.SetValueWithoutNotify(graph); + if (graph != null) + { + var path = AssetDatabase.GetAssetPath(graph); + EditorPrefs.SetString(LastGraphGuidKey, AssetDatabase.AssetPathToGUID(path)); + } + RefreshBrowser(); + RefreshFlowChoices(); + RefreshGraphView(); + RefreshCanvasSummary(); + propertyContainer?.MarkDirtyRepaint(); + bottomContainer?.MarkDirtyRepaint(); + } + + private void RestoreLastGraph() + { + if (graph != null) + { + return; + } + var guid = EditorPrefs.GetString(LastGraphGuidKey, string.Empty); + if (!string.IsNullOrEmpty(guid)) + { + graph = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid)); + } + } + + private void SetResourceTab(ResourceTab tab) + { + resourceTab = tab; + SaveWorkspaceState(); + RefreshBrowser(); + } + + private void SetBottomTab(BottomTab tab) + { + bottomTab = tab; + bottomCollapsed = false; + ApplyBottomState(); + SaveWorkspaceState(); + } + + private void SetSelection(FrameAnimationEditorSelection value, bool locateOnCanvas = true) + { + selection = value; + if (value.Kind == FrameAnimationEditorSelectionKind.Node && value.Value is AnimationNode node) + { + lastSelectedNode = node; + } + if (value.Kind == FrameAnimationEditorSelectionKind.Flow && value.Value is AnimationFlow flow) + { + SetFocusedFlow(flow.Id); + } + frameListClip = null; + frameList = null; + clipSerializedObject = null; + propertyContainer?.MarkDirtyRepaint(); + RefreshCanvasSummary(); + SaveWorkspaceState(); + if (locateOnCanvas && (value.Kind == FrameAnimationEditorSelectionKind.Node || + value.Kind == FrameAnimationEditorSelectionKind.Edge)) + { + if (!SelectionBelongsToFocusedFlow(value)) + { + SetFocusedFlow(string.Empty); + } + graphView?.SelectAndFrame(value); + } + } + + private void RefreshBrowser() + { + if (resourceList == null) + { + return; + } + browserEntries.Clear(); + if (graph != null) + { + switch (resourceTab) + { + case ResourceTab.Clips: + BuildClipEntries(); + break; + case ResourceTab.Flows: + BuildFlowEntries(); + break; + case ResourceTab.Sources: + BuildSourceEntries(); + break; + } + } + filterField.style.display = resourceTab == ResourceTab.Clips ? DisplayStyle.Flex : DisplayStyle.None; + sortField.style.display = resourceTab == ResourceTab.Clips ? DisplayStyle.Flex : DisplayStyle.None; + resourceList.Rebuild(); + } + + private void BuildClipEntries() + { + var clips = FrameAnimationResourceQuery.QueryClips( + graph, searchField?.value, clipFilter, clipSort); + var grouped = string.IsNullOrWhiteSpace(searchField?.value) && clipSort == FrameAnimationClipSort.Source; + if (!grouped) + { + foreach (var clip in clips) + { + AddClipEntry(clip); + } + return; + } + + var sources = graph.ImportSources.Where(source => source != null) + .GroupBy(source => source.InternalId ?? string.Empty) + .ToDictionary(group => group.Key, group => group.First().DisplayName); + foreach (var group in clips.GroupBy(clip => FrameAnimationResourceQuery.SourceName(clip, sources))) + { + browserEntries.Add(new BrowserEntry { IsHeader = true, Label = group.Key }); + foreach (var clip in group) + { + AddClipEntry(clip); + } + } + } + + private void AddClipEntry(FrameClip clip) + { + var source = clip.IsImported + ? FrameAnimationResourceQuery.SourceName(clip, + graph.ImportSources.Where(item => item != null) + .GroupBy(item => item.InternalId ?? string.Empty) + .ToDictionary(group => group.Key, group => group.First().DisplayName)) + "/" + + clip.ImportInfo.SourceTagName + : "Manual"; + var missing = clip.IsImported && clip.ImportInfo.IsMissingFromSource ? " [MISSING]" : string.Empty; + var refs = FrameAnimationResourceQuery.ReferenceCount(graph, clip); + browserEntries.Add(new BrowserEntry + { + Label = $"{clip.DisplayName} ({clip.Id}){missing}{IssueMarker(FrameAnimationEditorSelectionKind.Clip, clip)}\n" + + $"{clip.FrameCount} frames {clip.TotalDurationMs} ms {source} refs:{refs}", + Selection = new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, clip) + }); + } + + private void BuildFlowEntries() + { + var search = searchField?.value; + foreach (var flow in graph.Flows.Where(flow => flow != null && + (string.IsNullOrWhiteSpace(search) || Contains(flow.Id, search) || Contains(flow.DisplayName, search))) + .OrderBy(flow => flow.DisplayName, StringComparer.Ordinal).ThenBy(flow => flow.Id, StringComparer.Ordinal)) + { + browserEntries.Add(new BrowserEntry + { + Label = $"{flow.DisplayName} ({flow.Id}){IssueMarker(FrameAnimationEditorSelectionKind.Flow, flow)}\nentry: {flow.EntryNodeId}", + Selection = new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Flow, flow) + }); + } + } + + private void BuildSourceEntries() + { + var search = searchField?.value; + foreach (var source in graph.ImportSources.Where(source => source != null && + (string.IsNullOrWhiteSpace(search) || Contains(source.DisplayName, search) || Contains(source.InternalId, search))) + .OrderBy(source => source.DisplayName, StringComparer.Ordinal) + .ThenBy(source => source.InternalId, StringComparer.Ordinal)) + { + var count = graph.Clips.Count(clip => clip != null && clip.ImportInfo?.ImportSourceId == source.InternalId); + browserEntries.Add(new BrowserEntry + { + Label = $"{source.DisplayName}{IssueMarker(FrameAnimationEditorSelectionKind.Source, source)}\n" + + $"clips:{count} {(source.IsEnabled ? "Enabled" : "Disabled")} {(source.ManageSpriteSlicing ? "Writable" : "ReadOnly")}", + Selection = new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Source, source) + }); + } + } + + private static bool Contains(string value, string search) => + !string.IsNullOrEmpty(value) && value.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0; + + private void RefreshGraphView() + { + graphView?.Bind(graph, validationIssues, focusedFlowId); + } + + private void OnCanvasGraphChanged() + { + ScheduleLightValidation(); + RefreshBrowser(); + RefreshFlowChoices(); + RefreshCanvasSummary(); + propertyContainer?.MarkDirtyRepaint(); + EditorApplication.delayCall += () => + { + if (this != null) + { + RefreshGraphView(); + } + }; + } + + private static string FlowChoiceLabel(AnimationFlow flow) => $"{flow.DisplayName} ({flow.Id})"; + + private void RefreshFlowChoices() + { + if (flowFocusField == null) + { + return; + } + var choices = new List { "Show All" }; + if (graph != null) + { + choices.AddRange(graph.Flows.Where(flow => flow != null) + .OrderBy(flow => flow.DisplayName, StringComparer.Ordinal) + .ThenBy(flow => flow.Id, StringComparer.Ordinal) + .Select(FlowChoiceLabel)); + } + flowFocusField.choices = choices; + var focused = graph?.Flows.FirstOrDefault(flow => flow != null && flow.Id == focusedFlowId); + flowFocusField.SetValueWithoutNotify(focused != null ? FlowChoiceLabel(focused) : "Show All"); + } + + private void SetFocusedFlow(string flowId) + { + focusedFlowId = graph?.Flows.Any(flow => flow != null && flow.Id == flowId) == true + ? flowId + : string.Empty; + RefreshFlowChoices(); + graphView?.SetFocusedFlow(focusedFlowId); + SaveWorkspaceState(); + } + + private void ShowAllNodes() + { + SetFocusedFlow(string.Empty); + graphView?.FrameAll(); + } + + private bool SelectionBelongsToFocusedFlow(FrameAnimationEditorSelection value) + { + if (graph == null || string.IsNullOrEmpty(focusedFlowId)) + { + return true; + } + var flow = graph.Flows.FirstOrDefault(item => item != null && item.Id == focusedFlowId); + if (flow == null) + { + return false; + } + var reachable = new FrameAnimationGraphTopology(graph).GetReachable(flow.EntryNodeId); + return value.Value switch + { + AnimationNode node => reachable.Nodes.Contains(node), + AnimationEdge edge => reachable.Edges.Contains(edge), + _ => true + }; + } + + private void CreateFlowFromCanvasSelection() + { + var nodes = graphView?.SelectedNodes ?? Array.Empty(); + if (nodes.Count != 1) + { + ShowNotification(new GUIContent("创建 Flow 必须且只能选中一个节点。")); + return; + } + ShowCreateFlow(nodes[0]); + } + + private void ShowCreateFlow(AnimationNode entry) + { + if (graph == null || entry == null || !graph.Nodes.Contains(entry)) + { + ShowNotification(new GUIContent("Flow 入口节点已失效。")); + return; + } + FrameAnimationFlowWindow.Show(graph, entry, flow => + { + SetFocusedFlow(flow.Id); + SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Flow, flow), false); + OnCanvasGraphChanged(); + }); + } + + private void AutoLayoutCanvas() + { + if (graph == null) + { + return; + } + IReadOnlyList scope; + var selected = graphView?.SelectedNodes ?? Array.Empty(); + if (selected.Count >= 2) + { + scope = selected; + } + else if (!string.IsNullOrEmpty(focusedFlowId)) + { + var flow = graph.Flows.FirstOrDefault(item => item != null && item.Id == focusedFlowId); + scope = flow != null + ? new FrameAnimationGraphTopology(graph).GetReachable(flow.EntryNodeId).Nodes + : Array.Empty(); + } + else + { + scope = graph.Nodes.Where(node => node != null).ToArray(); + } + var positions = FrameAnimationGraphLayoutService.Calculate(graph, scope); + FrameAnimationGraphMutationService.SetNodePositions(graph, positions, "Auto Layout Frame Animation Graph"); + graphView?.ApplyCalculatedPositions(positions); + ScheduleLightValidation(); + } + + private string IssueMarker(FrameAnimationEditorSelectionKind kind, object value) + { + var matching = validationIssues.Where(issue => + issue.Selection.Kind == kind && ReferenceEquals(issue.Selection.Value, value)).ToArray(); + if (matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error)) + { + return " [E]"; + } + return matching.Length > 0 ? " [W]" : string.Empty; + } + + private void RefreshCanvasSummary() + { + if (canvasLabel == null) + { + return; + } + if (graph == null) + { + canvasLabel.text = "选择或创建 FrameAnimationGraph"; + canvasLabel.style.display = DisplayStyle.Flex; + return; + } + canvasLabel.style.display = graph.Nodes.Count == 0 ? DisplayStyle.Flex : DisplayStyle.None; + var located = selection.Kind == FrameAnimationEditorSelectionKind.None + ? "Graph" + : selection.Kind + ": " + SelectionName(selection); + canvasLabel.text = + $"右键或从 Clips 拖入以创建节点\n\n{graph.DisplayName} ({graph.Id})\n" + + $"Clips {graph.Clips.Count} Nodes {graph.Nodes.Count} Edges {graph.Edges.Count} Flows {graph.Flows.Count}\n\n" + + $"当前定位:{located}"; + } + + private static string SelectionName(FrameAnimationEditorSelection selected) + { + return selected.Value switch + { + FrameClip clip => clip.Id, + AnimationFlow flow => flow.Id, + FrameAnimationImportSource source => source.DisplayName, + AnimationNode node => node.InternalId, + AnimationEdge edge => edge.InternalId, + FrameAnimationGraph selectedGraph => selectedGraph.Id, + _ => string.Empty + }; + } + + private void DrawSelectionProperties() + { + if (graph == null) + { + EditorGUILayout.HelpBox("请选择 FrameAnimationGraph。", MessageType.Info); + return; + } + + if (multiSelectedNodes.Count > 1) + { + EditorGUILayout.LabelField("Multiple Nodes", EditorStyles.boldLabel); + EditorGUILayout.LabelField("Selected", multiSelectedNodes.Count.ToString()); + EditorGUILayout.HelpBox("第四阶段不提供批量属性编辑。可以对选中节点执行自动布局或安全删除。", MessageType.Info); + if (GUILayout.Button("Auto Layout Selected Nodes")) + { + var positions = FrameAnimationGraphLayoutService.Calculate(graph, multiSelectedNodes); + FrameAnimationGraphMutationService.SetNodePositions( + graph, positions, "Auto Layout Selected Frame Animation Nodes"); + graphView?.ApplyCalculatedPositions(positions); + } + if (GUILayout.Button("Delete Selected Nodes")) + { + graphView?.RequestDeleteNodes(multiSelectedNodes); + } + return; + } + + EditorGUI.BeginChangeCheck(); + switch (selection.Kind) + { + case FrameAnimationEditorSelectionKind.Clip: + DrawClipProperties(selection.Value as FrameClip); + break; + case FrameAnimationEditorSelectionKind.Flow: + DrawFlowProperties(selection.Value as AnimationFlow); + break; + case FrameAnimationEditorSelectionKind.Source: + DrawSourceProperties(selection.Value as FrameAnimationImportSource); + break; + case FrameAnimationEditorSelectionKind.Node: + DrawNodeProperties(selection.Value as AnimationNode); + break; + case FrameAnimationEditorSelectionKind.Edge: + DrawEdgeProperties(selection.Value as AnimationEdge); + break; + default: + DrawGraphProperties(); + break; + } + if (EditorGUI.EndChangeCheck()) + { + ScheduleLightValidation(); + RefreshBrowser(); + RefreshCanvasSummary(); + } + } + + private void DrawGraphProperties() + { + var serialized = new SerializedObject(graph); + serialized.Update(); + EditorGUILayout.LabelField("Graph", EditorStyles.boldLabel); + using (new EditorGUI.DisabledScope(true)) + { + EditorGUILayout.PropertyField(serialized.FindProperty("id")); + } + EditorGUILayout.PropertyField(serialized.FindProperty("displayName")); + EditorGUILayout.LabelField("Clips", graph.Clips.Count.ToString()); + EditorGUILayout.LabelField("Flows", graph.Flows.Count.ToString()); + EditorGUILayout.LabelField("Sources", graph.ImportSources.Count.ToString()); + DrawDefaultPlayablePopup(); + var currentEndBehavior = graph.Settings.NewManualClipDefaultEndBehavior; + var nextEndBehavior = (FrameClipEndBehavior)EditorGUILayout.EnumPopup( + "New Manual End Behavior", currentEndBehavior); + if (nextEndBehavior != currentEndBehavior) + { + Undo.RecordObject(graph, "Set New Manual Clip End Behavior"); + graph.Settings.SetNewManualClipDefaultEndBehavior(nextEndBehavior); + EditorUtility.SetDirty(graph); + } + if (serialized.ApplyModifiedProperties()) + { + EditorUtility.SetDirty(graph); + } + if (GUILayout.Button("Rename Graph ID")) + { + ShowRenameGraph(); + } + } + + private void DrawDefaultPlayablePopup() + { + var ids = new List { string.Empty }; + ids.AddRange(graph.Clips.Where(clip => clip != null).Select(clip => clip.Id)); + ids.AddRange(graph.Flows.Where(flow => flow != null).Select(flow => flow.Id)); + var labels = ids.Select(id => string.IsNullOrEmpty(id) ? "" : id).ToArray(); + var current = Mathf.Max(0, ids.IndexOf(graph.Settings.DefaultPlayableId)); + var next = EditorGUILayout.Popup("Default Playable", current, labels); + if (next != current) + { + Undo.RecordObject(graph, "Set Default Frame Animation Playable"); + graph.Settings.SetDefaultPlayableId(ids[next]); + EditorUtility.SetDirty(graph); + } + } + + private void DrawClipProperties(FrameClip clip) + { + if (clip == null) + { + EditorGUILayout.HelpBox("Clip 已失效。", MessageType.Warning); + return; + } + EnsureFrameList(clip); + clipSerializedObject.Update(); + EditorGUILayout.LabelField(clip.IsImported ? "Imported Clip" : "Manual Clip", EditorStyles.boldLabel); + using (new EditorGUI.DisabledScope(true)) + { + EditorGUILayout.PropertyField(clipSerializedObject.FindProperty("id")); + } + EditorGUILayout.PropertyField(clipSerializedObject.FindProperty("displayName")); + EditorGUILayout.PropertyField(clipSerializedObject.FindProperty("speed")); + EditorGUILayout.PropertyField(clipSerializedObject.FindProperty("defaultEndBehavior")); + EditorGUILayout.LabelField("Frame Count", clip.FrameCount.ToString()); + EditorGUILayout.LabelField("Total Duration", clip.TotalDurationMs + " ms"); + EditorGUILayout.LabelField("Storage", AssetDatabase.IsSubAsset(clip) ? "Graph sub-asset" : "External .asset"); + var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip); + if (!AssetDatabase.IsSubAsset(clip) && owners.Count > 1) + { + EditorGUILayout.HelpBox("共享外部 Manual Clip:" + string.Join(", ", owners.Select(owner => owner.name)), MessageType.Warning); + } + if (clip.IsImported) + { + EditorGUILayout.LabelField("Source Tag", clip.ImportInfo.SourceTagName); + EditorGUILayout.LabelField("Missing", clip.ImportInfo.IsMissingFromSource ? "Yes" : "No"); + } + if (clipSerializedObject.ApplyModifiedProperties()) + { + EditorUtility.SetDirty(clip); + } + + if (!clip.IsImported) + { + frameList.DoLayoutList(); + if (frameList.index >= 0 && GUILayout.Button("Duplicate Selected Frame")) + { + DuplicateFrame(clipSerializedObject.FindProperty("frames"), frameList.index); + clipSerializedObject.ApplyModifiedProperties(); + EditorUtility.SetDirty(clip); + } + } + if (clip.IsImported) + { + using (new EditorGUI.DisabledScope(true)) + { + frameList.DoLayoutList(); + } + if (GUILayout.Button("Locate ImportSource")) + { + var source = graph.ImportSources.FirstOrDefault(item => item != null && item.InternalId == clip.ImportInfo.ImportSourceId); + SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Source, source)); + SetResourceTab(ResourceTab.Sources); + } + if (GUILayout.Button("Copy As Manual Clip")) + { + FrameAnimationManualClipWindow.Show(graph, clip, copied => + { + RefreshBrowser(); + SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, copied)); + }); + } + } + + if (GUILayout.Button("Rename Clip ID")) + { + ShowRenameClip(clip); + } + if (GUILayout.Button("Locate Node References")) + { + LocateClipReferences(clip); + } + if (GUILayout.Button(AssetDatabase.IsSubAsset(clip) ? "Delete Clip Sub-Asset" : "Remove Clip Reference")) + { + ConfirmRemoveClip(clip); + } + } + + private void EnsureFrameList(FrameClip clip) + { + if (frameListClip == clip && frameList != null) + { + return; + } + frameListClip = clip; + clipSerializedObject = new SerializedObject(clip); + var frames = clipSerializedObject.FindProperty("frames"); + frameList = new ReorderableList(clipSerializedObject, frames, !clip.IsImported, true, !clip.IsImported, !clip.IsImported) + { + elementHeight = 72f, + drawHeaderCallback = rect => EditorGUI.LabelField(rect, "Frames (frameName/sourceIndex are read-only)"), + drawElementCallback = (rect, index, active, focused) => DrawFrameElement(frames, rect, index), + onAddCallback = list => AddFrame(frames), + onRemoveCallback = list => + { + ReorderableList.defaultBehaviours.DoRemoveButton(list); + clipSerializedObject.ApplyModifiedProperties(); + EditorUtility.SetDirty(clip); + }, + onReorderCallback = _ => + { + clipSerializedObject.ApplyModifiedProperties(); + EditorUtility.SetDirty(clip); + } + }; + } + + private static void DrawFrameElement(SerializedProperty frames, Rect rect, int index) + { + if (index < 0 || index >= frames.arraySize) + { + return; + } + var element = frames.GetArrayElementAtIndex(index); + rect.y += 2f; + var line = EditorGUIUtility.singleLineHeight; + EditorGUI.PropertyField(new Rect(rect.x, rect.y, rect.width, line), element.FindPropertyRelative("sprite"), new GUIContent($"#{index} Sprite")); + EditorGUI.PropertyField(new Rect(rect.x, rect.y + line + 2f, rect.width, line), element.FindPropertyRelative("durationMs")); + using (new EditorGUI.DisabledScope(true)) + { + var half = (rect.width - 4f) * 0.5f; + EditorGUI.PropertyField(new Rect(rect.x, rect.y + (line + 2f) * 2f, half, line), element.FindPropertyRelative("frameName")); + EditorGUI.PropertyField(new Rect(rect.x + half + 4f, rect.y + (line + 2f) * 2f, half, line), element.FindPropertyRelative("sourceIndex")); + } + } + + private void AddFrame(SerializedProperty frames) + { + var index = frames.arraySize; + frames.InsertArrayElementAtIndex(index); + var element = frames.GetArrayElementAtIndex(index); + element.FindPropertyRelative("sprite").objectReferenceValue = null; + element.FindPropertyRelative("durationMs").intValue = 100; + element.FindPropertyRelative("frameName").stringValue = string.Empty; + element.FindPropertyRelative("sourceIndex").intValue = -1; + clipSerializedObject.ApplyModifiedProperties(); + frameList.index = index; + EditorUtility.SetDirty(frameListClip); + } + + private static void DuplicateFrame(SerializedProperty frames, int index) + { + if (index >= 0 && index < frames.arraySize) + { + frames.InsertArrayElementAtIndex(index); + } + } + + private void DrawFlowProperties(AnimationFlow flow) + { + var index = graph.Flows.ToList().IndexOf(flow); + if (index < 0) + { + EditorGUILayout.HelpBox("Flow 已失效。", MessageType.Warning); + return; + } + var serialized = new SerializedObject(graph); + serialized.Update(); + var property = serialized.FindProperty("flows").GetArrayElementAtIndex(index); + EditorGUILayout.LabelField("Animation Flow", EditorStyles.boldLabel); + using (new EditorGUI.DisabledScope(true)) + { + EditorGUILayout.PropertyField(property.FindPropertyRelative("id")); + EditorGUILayout.PropertyField(property.FindPropertyRelative("entryNodeId")); + } + EditorGUILayout.PropertyField(property.FindPropertyRelative("displayName")); + EditorGUILayout.PropertyField(property.FindPropertyRelative("hasEndBehaviorOverride")); + if (property.FindPropertyRelative("hasEndBehaviorOverride").boolValue) + { + EditorGUILayout.PropertyField(property.FindPropertyRelative("endBehaviorOverride")); + } + if (serialized.ApplyModifiedProperties()) + { + EditorUtility.SetDirty(graph); + } + var flowData = graph.EditorData.FlowEditorData.FirstOrDefault(data => data != null && data.FlowId == flow.Id); + var color = flowData?.Color ?? Color.white; + var nextColor = EditorGUILayout.ColorField("Canvas Color", color); + if (nextColor != color) + { + FrameAnimationGraphMutationService.SetFlowColor(graph, flow, nextColor); + RefreshGraphView(); + } + EditorGUILayout.LabelField("Entry Candidate", + lastSelectedNode != null && graph.Nodes.Contains(lastSelectedNode) + ? $"{lastSelectedNode.DisplayName} ({lastSelectedNode.InternalId})" + : "