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})" : "