1949 lines
83 KiB
C#
1949 lines
83 KiB
C#
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<string> flowFocusField;
|
||
private string focusedFlowId = string.Empty;
|
||
private IReadOnlyList<AnimationNode> multiSelectedNodes = Array.Empty<AnimationNode>();
|
||
private AnimationNode lastSelectedNode;
|
||
private Vector2 browserDragStart;
|
||
private bool browserDragPending;
|
||
private ListView resourceList;
|
||
private readonly List<BrowserEntry> browserEntries = new List<BrowserEntry>();
|
||
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<FrameAnimationEditorIssue> validationIssues = Array.Empty<FrameAnimationEditorIssue>();
|
||
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<FrameAnimationGraphEditorWindow>();
|
||
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<FrameAnimationGraphEditorWindow>();
|
||
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<string>(new List<string> { "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<AnimationNode>();
|
||
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<BrowserEntry>().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<PointerDownEvent>(evt =>
|
||
{
|
||
if (evt.button == 0 && label.userData is BrowserEntry entry &&
|
||
entry.Selection.Kind == FrameAnimationEditorSelectionKind.Clip && !entry.IsHeader)
|
||
{
|
||
browserDragPending = true;
|
||
browserDragStart = evt.position;
|
||
}
|
||
});
|
||
label.RegisterCallback<PointerMoveEvent>(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<PointerUpEvent>(_ => 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<object> values)
|
||
{
|
||
var entry = values.Cast<BrowserEntry>().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<FrameAnimationEditorIssue>();
|
||
multiSelectedNodes = Array.Empty<AnimationNode>();
|
||
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<FrameAnimationGraph>(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<string> { "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<AnimationNode>();
|
||
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<AnimationNode> scope;
|
||
var selected = graphView?.SelectedNodes ?? Array.Empty<AnimationNode>();
|
||
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<AnimationNode>();
|
||
}
|
||
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> { 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) ? "<None>" : 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})"
|
||
: "<Select a Node on canvas>");
|
||
using (new EditorGUI.DisabledScope(lastSelectedNode == null || !graph.Nodes.Contains(lastSelectedNode)))
|
||
{
|
||
if (GUILayout.Button("Set Selected Node As Entry"))
|
||
{
|
||
if (!FrameAnimationGraphMutationService.SetFlowEntry(
|
||
graph, flow, lastSelectedNode, out var entryError))
|
||
{
|
||
EditorUtility.DisplayDialog("无法修改 Flow 入口", entryError, "确定");
|
||
}
|
||
else
|
||
{
|
||
SetFocusedFlow(flow.Id);
|
||
OnCanvasGraphChanged();
|
||
}
|
||
}
|
||
}
|
||
if (GUILayout.Button("Focus Flow On Canvas"))
|
||
{
|
||
SetFocusedFlow(flow.Id);
|
||
graphView?.FrameCurrentFlow(flow.Id);
|
||
}
|
||
if (GUILayout.Button("Rename Flow ID"))
|
||
{
|
||
ShowRenameFlow(flow);
|
||
}
|
||
if (GUILayout.Button("Delete Flow"))
|
||
{
|
||
if (!FrameAnimationAssetOperations.CanRemoveFlow(graph, flow, out var blocker))
|
||
{
|
||
EditorUtility.DisplayDialog("无法删除 Flow", blocker, "确定");
|
||
return;
|
||
}
|
||
if (!EditorUtility.DisplayDialog(
|
||
"确认删除 Flow",
|
||
"只删除 Flow 定义,不会删除其 Node 或 Edge。是否继续?",
|
||
"删除",
|
||
"取消"))
|
||
{
|
||
return;
|
||
}
|
||
if (!FrameAnimationAssetOperations.RemoveFlow(graph, flow, out var error))
|
||
{
|
||
EditorUtility.DisplayDialog("无法删除 Flow", error, "确定");
|
||
}
|
||
else
|
||
{
|
||
SetFocusedFlow(string.Empty);
|
||
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph));
|
||
OnCanvasGraphChanged();
|
||
}
|
||
}
|
||
}
|
||
|
||
private void DrawSourceProperties(FrameAnimationImportSource source)
|
||
{
|
||
var index = graph.ImportSources.ToList().IndexOf(source);
|
||
if (index < 0)
|
||
{
|
||
EditorGUILayout.HelpBox("ImportSource 已失效。", MessageType.Warning);
|
||
return;
|
||
}
|
||
var serialized = new SerializedObject(graph);
|
||
serialized.Update();
|
||
var property = serialized.FindProperty("importSources").GetArrayElementAtIndex(index);
|
||
EditorGUILayout.LabelField("ImportSource", EditorStyles.boldLabel);
|
||
using (new EditorGUI.DisabledScope(true))
|
||
{
|
||
EditorGUILayout.PropertyField(property.FindPropertyRelative("internalId"));
|
||
}
|
||
if (GUILayout.Button("Copy Internal ID"))
|
||
{
|
||
GUIUtility.systemCopyBuffer = source.InternalId;
|
||
}
|
||
EditorGUILayout.PropertyField(property.FindPropertyRelative("displayName"));
|
||
EditorGUILayout.PropertyField(property.FindPropertyRelative("isEnabled"));
|
||
EditorGUILayout.PropertyField(property.FindPropertyRelative("texture"));
|
||
EditorGUILayout.PropertyField(property.FindPropertyRelative("asepriteJson"));
|
||
EditorGUILayout.PropertyField(property.FindPropertyRelative("pivot"));
|
||
EditorGUILayout.PropertyField(property.FindPropertyRelative("manageSpriteSlicing"));
|
||
EditorGUILayout.PropertyField(property.FindPropertyRelative("defaultNewClipEndBehavior"));
|
||
using (new EditorGUI.DisabledScope(true))
|
||
{
|
||
EditorGUILayout.PropertyField(property.FindPropertyRelative("lastSourceHash"));
|
||
}
|
||
if (serialized.ApplyModifiedProperties())
|
||
{
|
||
EditorUtility.SetDirty(graph);
|
||
}
|
||
var clipCount = graph.Clips.Count(clip => clip != null && clip.ImportInfo?.ImportSourceId == source.InternalId);
|
||
EditorGUILayout.LabelField("Associated Clips", clipCount.ToString());
|
||
EditorGUILayout.BeginHorizontal();
|
||
if (GUILayout.Button("Preview Source"))
|
||
{
|
||
importPreview = FrameAnimationImportService.PreviewSource(graph, source.InternalId);
|
||
SetBottomTab(BottomTab.ImportDiff);
|
||
}
|
||
if (GUILayout.Button("Refresh Source"))
|
||
{
|
||
importPreview = FrameAnimationImportService.PreviewSource(graph, source.InternalId);
|
||
ApplyImportPreview();
|
||
}
|
||
EditorGUILayout.EndHorizontal();
|
||
if (GUILayout.Button("Remove ImportSource"))
|
||
{
|
||
var associated = graph.Clips.Where(clip => clip != null &&
|
||
clip.ImportInfo?.ImportSourceId == source.InternalId).ToArray();
|
||
if (associated.Length > 0)
|
||
{
|
||
EditorUtility.DisplayDialog(
|
||
"无法删除 ImportSource",
|
||
"仍关联 Imported Clip:" + string.Join(", ", associated.Select(clip => clip.Id)),
|
||
"确定");
|
||
}
|
||
else if (EditorUtility.DisplayDialog(
|
||
"删除 ImportSource",
|
||
"该操作不会删除 Texture、JSON、Sprite 或 SpriteRect。确认删除来源?",
|
||
"删除",
|
||
"取消"))
|
||
{
|
||
if (!FrameAnimationAssetOperations.RemoveSource(graph, source, out var error))
|
||
{
|
||
EditorUtility.DisplayDialog("无法删除 ImportSource", error, "确定");
|
||
}
|
||
else
|
||
{
|
||
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph));
|
||
RefreshBrowser();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private void DrawNodeProperties(AnimationNode node)
|
||
{
|
||
if (node == null)
|
||
{
|
||
EditorGUILayout.HelpBox("Node 已失效。", MessageType.Warning);
|
||
return;
|
||
}
|
||
EditorGUILayout.LabelField("AnimationNode", EditorStyles.boldLabel);
|
||
using (new EditorGUI.DisabledScope(true))
|
||
{
|
||
EditorGUILayout.TextField("Internal ID", node.InternalId);
|
||
}
|
||
if (GUILayout.Button("Copy Internal ID")) GUIUtility.systemCopyBuffer = node.InternalId;
|
||
|
||
var nextName = EditorGUILayout.TextField("Display Name", node.DisplayName);
|
||
if (nextName != node.DisplayName)
|
||
{
|
||
FrameAnimationGraphMutationService.SetNodeDisplayName(graph, node, nextName);
|
||
OnCanvasGraphChanged();
|
||
}
|
||
|
||
var clips = graph.Clips.Where(clip => clip != null).ToList();
|
||
var currentClip = clips.FindIndex(clip => clip.Id == node.ClipId);
|
||
var labels = new List<string>();
|
||
if (currentClip < 0)
|
||
{
|
||
labels.Add($"<Missing: {node.ClipId}>");
|
||
}
|
||
labels.AddRange(clips.Select(clip => $"{clip.DisplayName} ({clip.Id})"));
|
||
var popupIndex = currentClip < 0 ? 0 : currentClip;
|
||
var nextClipIndex = EditorGUILayout.Popup("Clip", popupIndex, labels.ToArray());
|
||
if (nextClipIndex != popupIndex)
|
||
{
|
||
var selectedClip = clips[currentClip < 0 ? nextClipIndex - 1 : nextClipIndex];
|
||
FrameAnimationGraphMutationService.SetNodeClip(graph, node, selectedClip);
|
||
OnCanvasGraphChanged();
|
||
}
|
||
|
||
var hasSpeed = node.SpeedOverride.HasValue;
|
||
var nextHasSpeed = EditorGUILayout.Toggle("Override Speed", hasSpeed);
|
||
var speed = node.SpeedOverride ?? 1f;
|
||
var nextSpeed = nextHasSpeed ? EditorGUILayout.FloatField("Speed", speed) : speed;
|
||
if (nextHasSpeed != hasSpeed || nextHasSpeed && !Mathf.Approximately(nextSpeed, speed))
|
||
{
|
||
FrameAnimationGraphMutationService.SetNodeSpeed(graph, node, nextHasSpeed ? nextSpeed : (float?)null);
|
||
OnCanvasGraphChanged();
|
||
}
|
||
|
||
var hasEnd = node.EndBehaviorOverride.HasValue;
|
||
var nextHasEnd = EditorGUILayout.Toggle("Override End Behavior", hasEnd);
|
||
var end = node.EndBehaviorOverride ?? FrameClipEndBehavior.HoldLastFrame;
|
||
var nextEnd = nextHasEnd
|
||
? (FrameClipEndBehavior)EditorGUILayout.EnumPopup("End Behavior", end)
|
||
: end;
|
||
if (nextHasEnd != hasEnd || nextHasEnd && nextEnd != end)
|
||
{
|
||
var outgoing = graph.Edges.Where(edge => edge != null && edge.FromNodeId == node.InternalId).ToArray();
|
||
var removeOutgoing = outgoing.Length == 0 || !nextHasEnd || EditorUtility.DisplayDialog(
|
||
"终点行为与后继冲突",
|
||
"该节点已有后继 Edge。设置结束行为会删除该 Edge,是否继续?",
|
||
"删除 Edge 并应用",
|
||
"取消");
|
||
var error = string.Empty;
|
||
if (removeOutgoing && FrameAnimationGraphMutationService.SetNodeEndBehavior(
|
||
graph, node, nextHasEnd ? nextEnd : (FrameClipEndBehavior?)null,
|
||
outgoing.Length > 0, out error))
|
||
{
|
||
OnCanvasGraphChanged();
|
||
}
|
||
else if (!string.IsNullOrEmpty(error))
|
||
{
|
||
EditorUtility.DisplayDialog("无法修改结束行为", error, "确定");
|
||
}
|
||
}
|
||
|
||
var topology = new FrameAnimationGraphTopology(graph);
|
||
var flows = topology.FindFlowsUsingNode(node.InternalId);
|
||
EditorGUILayout.LabelField("Used By Flows", flows.Count == 0
|
||
? "None" : string.Join(", ", flows.Select(flow => flow.Id)));
|
||
if (GUILayout.Button("Create Flow From Node")) ShowCreateFlow(node);
|
||
if (GUILayout.Button("Delete Node")) graphView?.RequestDeleteNodes(new[] { node });
|
||
}
|
||
|
||
private void DrawEdgeProperties(AnimationEdge edge)
|
||
{
|
||
if (edge == null)
|
||
{
|
||
EditorGUILayout.HelpBox("Edge 已失效。", MessageType.Warning);
|
||
return;
|
||
}
|
||
EditorGUILayout.LabelField("AnimationEdge", EditorStyles.boldLabel);
|
||
EditorGUILayout.TextField("Internal ID", edge.InternalId);
|
||
EditorGUILayout.TextField("From", edge.FromNodeId);
|
||
EditorGUILayout.TextField("To", edge.ToNodeId);
|
||
EditorGUILayout.TextField("Exit", edge.ExitName);
|
||
EditorGUILayout.TextField("Condition", edge.Condition.ToString());
|
||
if (GUILayout.Button("Disconnect Edge")) graphView?.RequestDeleteEdges(new[] { edge });
|
||
}
|
||
|
||
private void DrawBottomPanel()
|
||
{
|
||
if (bottomCollapsed)
|
||
{
|
||
return;
|
||
}
|
||
if (bottomTab == BottomTab.ImportDiff)
|
||
{
|
||
DrawImportDiff();
|
||
}
|
||
else
|
||
{
|
||
DrawValidation();
|
||
}
|
||
}
|
||
|
||
private void DrawImportDiff()
|
||
{
|
||
if (importPreview == null)
|
||
{
|
||
EditorGUILayout.HelpBox("点击 Preview Imports 或来源 Preview 计算导入差异。", MessageType.Info);
|
||
return;
|
||
}
|
||
foreach (var source in importPreview.Sources)
|
||
{
|
||
EditorGUILayout.LabelField(source.Source.DisplayName, EditorStyles.boldLabel);
|
||
if (GUILayout.Button("Locate Source", GUILayout.Width(110f)))
|
||
{
|
||
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Source, source.Source));
|
||
SetResourceTab(ResourceTab.Sources);
|
||
}
|
||
foreach (var issue in source.Issues)
|
||
{
|
||
EditorGUILayout.HelpBox($"[{issue.Code}] {issue.Message}", issue.Severity == FrameAnimationValidationSeverity.Error
|
||
? MessageType.Error : MessageType.Warning);
|
||
}
|
||
foreach (var group in source.ClipDiffs.GroupBy(diff => diff.Kind))
|
||
{
|
||
EditorGUILayout.LabelField($"{group.Key} ({group.Count()})", EditorStyles.miniBoldLabel);
|
||
foreach (var diff in group)
|
||
{
|
||
EditorGUILayout.BeginHorizontal();
|
||
EditorGUILayout.LabelField($"{diff.SourceTagName}: {diff.Summary}");
|
||
if (diff.Clip != null && GUILayout.Button("Locate", GUILayout.Width(70f)))
|
||
{
|
||
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, diff.Clip));
|
||
SetResourceTab(ResourceTab.Clips);
|
||
}
|
||
EditorGUILayout.EndHorizontal();
|
||
}
|
||
}
|
||
if (source.SpriteDiffs.Count > 0)
|
||
{
|
||
EditorGUILayout.LabelField("SpriteRect", EditorStyles.miniBoldLabel);
|
||
foreach (var group in source.SpriteDiffs.GroupBy(diff => diff.Kind))
|
||
{
|
||
EditorGUILayout.LabelField($"{group.Key} ({group.Count()})", EditorStyles.miniLabel);
|
||
foreach (var diff in group)
|
||
{
|
||
EditorGUILayout.LabelField($" {diff.FrameName}: {diff.Summary} {diff.Rect}",
|
||
EditorStyles.wordWrappedMiniLabel);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private void DrawValidation()
|
||
{
|
||
EditorGUILayout.BeginHorizontal();
|
||
showErrors = GUILayout.Toggle(showErrors, "Errors", "Button");
|
||
showWarnings = GUILayout.Toggle(showWarnings, "Warnings", "Button");
|
||
showInfo = GUILayout.Toggle(showInfo, "Info", "Button");
|
||
validationKindFilter = (ValidationKindFilter)EditorGUILayout.EnumPopup(
|
||
validationKindFilter, GUILayout.Width(110f));
|
||
EditorGUILayout.EndHorizontal();
|
||
foreach (var issue in validationIssues.Where(ShowIssue))
|
||
{
|
||
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
|
||
EditorGUILayout.LabelField($"[{issue.Severity}] {issue.Code}", EditorStyles.boldLabel);
|
||
EditorGUILayout.LabelField(issue.Message, EditorStyles.wordWrappedLabel);
|
||
if (!string.IsNullOrEmpty(issue.Suggestion))
|
||
{
|
||
EditorGUILayout.LabelField("建议:" + issue.Suggestion, EditorStyles.wordWrappedMiniLabel);
|
||
}
|
||
if (issue.Selection.Value != null && GUILayout.Button("Locate", GUILayout.Width(80f)))
|
||
{
|
||
SetSelection(issue.Selection);
|
||
SetResourceTab(issue.Selection.Kind switch
|
||
{
|
||
FrameAnimationEditorSelectionKind.Clip => ResourceTab.Clips,
|
||
FrameAnimationEditorSelectionKind.Flow => ResourceTab.Flows,
|
||
FrameAnimationEditorSelectionKind.Source => ResourceTab.Sources,
|
||
_ => resourceTab
|
||
});
|
||
}
|
||
EditorGUILayout.EndVertical();
|
||
}
|
||
}
|
||
|
||
private bool ShowIssue(FrameAnimationEditorIssue issue)
|
||
{
|
||
var severityVisible = issue.Severity switch
|
||
{
|
||
FrameAnimationValidationSeverity.Error => showErrors,
|
||
FrameAnimationValidationSeverity.Warning => showWarnings,
|
||
_ => showInfo
|
||
};
|
||
return severityVisible && (validationKindFilter == ValidationKindFilter.All ||
|
||
validationKindFilter.ToString() == issue.Selection.Kind.ToString());
|
||
}
|
||
|
||
private void CreateGraph()
|
||
{
|
||
var path = EditorUtility.SaveFilePanelInProject(
|
||
"Create FrameAnimationGraph", "FrameAnimationGraph", "asset", "请选择保存位置。");
|
||
if (string.IsNullOrEmpty(path))
|
||
{
|
||
return;
|
||
}
|
||
var created = ScriptableObject.CreateInstance<FrameAnimationGraph>();
|
||
var id = Path.GetFileNameWithoutExtension(path);
|
||
created.Configure(id, id, Array.Empty<FrameClip>(), Array.Empty<AnimationNode>(),
|
||
Array.Empty<AnimationEdge>(), Array.Empty<AnimationFlow>(), string.Empty);
|
||
AssetDatabase.CreateAsset(created, path);
|
||
Undo.RegisterCreatedObjectUndo(created, "Create FrameAnimationGraph");
|
||
AssetDatabase.SaveAssets();
|
||
SetGraph(created);
|
||
}
|
||
|
||
private void SaveGraph()
|
||
{
|
||
if (graph == null)
|
||
{
|
||
return;
|
||
}
|
||
clipSerializedObject?.ApplyModifiedProperties();
|
||
EditorUtility.SetDirty(graph);
|
||
AssetDatabase.SaveAssets();
|
||
UpdateDirtyLabel();
|
||
}
|
||
|
||
private void UpdateDirtyLabel()
|
||
{
|
||
if (dirtyLabel == null)
|
||
{
|
||
return;
|
||
}
|
||
var dirty = graph != null && (EditorUtility.IsDirty(graph) || graph.Clips.Any(clip => clip != null && EditorUtility.IsDirty(clip)));
|
||
dirtyLabel.text = graph == null ? "No Graph" : dirty ? "● Unsaved" : "Saved";
|
||
dirtyLabel.style.color = dirty ? new StyleColor(new Color(1f, 0.65f, 0.2f)) : new StyleColor(Color.gray);
|
||
}
|
||
|
||
private void CreateManualClip()
|
||
{
|
||
if (graph == null)
|
||
{
|
||
return;
|
||
}
|
||
FrameAnimationManualClipWindow.Show(graph, null, clip =>
|
||
{
|
||
RefreshBrowser();
|
||
SetResourceTab(ResourceTab.Clips);
|
||
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, clip));
|
||
});
|
||
}
|
||
|
||
private void AddExistingClip()
|
||
{
|
||
if (graph == null)
|
||
{
|
||
return;
|
||
}
|
||
FrameAnimationExistingClipWindow.Show(graph, clip =>
|
||
{
|
||
RefreshBrowser();
|
||
SetResourceTab(ResourceTab.Clips);
|
||
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, clip));
|
||
});
|
||
}
|
||
|
||
private void AddSource()
|
||
{
|
||
if (graph == null)
|
||
{
|
||
return;
|
||
}
|
||
Undo.RecordObject(graph, "Add Frame Animation ImportSource");
|
||
var source = new FrameAnimationImportSource();
|
||
graph.AddImportSource(source);
|
||
EditorUtility.SetDirty(graph);
|
||
SetResourceTab(ResourceTab.Sources);
|
||
RefreshBrowser();
|
||
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Source, source));
|
||
}
|
||
|
||
private void PreviewAllImports()
|
||
{
|
||
if (graph == null)
|
||
{
|
||
return;
|
||
}
|
||
importPreview = FrameAnimationImportService.PreviewAll(graph);
|
||
SetBottomTab(BottomTab.ImportDiff);
|
||
bottomContainer.MarkDirtyRepaint();
|
||
}
|
||
|
||
private void RefreshAllImports()
|
||
{
|
||
PreviewAllImports();
|
||
ApplyImportPreview();
|
||
}
|
||
|
||
private void ApplyImportPreview()
|
||
{
|
||
if (importPreview == null || importPreview.HasErrors)
|
||
{
|
||
SetBottomTab(BottomTab.ImportDiff);
|
||
return;
|
||
}
|
||
var allowSpriteChanges = !importPreview.HasSpriteChanges || EditorUtility.DisplayDialog(
|
||
"确认修改 TextureImporter", BuildSpriteConfirmation(importPreview), "应用切图并刷新", "取消");
|
||
if (!allowSpriteChanges)
|
||
{
|
||
return;
|
||
}
|
||
if (!FrameAnimationImportService.Apply(importPreview, allowSpriteChanges, out var error))
|
||
{
|
||
EditorUtility.DisplayDialog("刷新失败", error, "确定");
|
||
}
|
||
importPreview = FrameAnimationImportService.PreviewAll(graph);
|
||
RefreshBrowser();
|
||
ValidateFull();
|
||
}
|
||
|
||
private static string BuildSpriteConfirmation(FrameAnimationImportPreview preview)
|
||
{
|
||
var added = preview.Sources.Sum(source => source.SpriteDiffs.Count(diff => diff.Kind == FrameAnimationSpriteChangeKind.Added));
|
||
var updated = preview.Sources.Sum(source => source.SpriteDiffs.Count(diff => diff.Kind == FrameAnimationSpriteChangeKind.Updated));
|
||
var retained = preview.Sources.Sum(source => source.SpriteDiffs.Count(diff => diff.Kind == FrameAnimationSpriteChangeKind.Retained));
|
||
return $"新增 SpriteRect:{added}\n更新 SpriteRect:{updated}\n保留旧 SpriteRect:{retained}\n\n是否继续?";
|
||
}
|
||
|
||
private void ValidateFull()
|
||
{
|
||
if (graph == null)
|
||
{
|
||
return;
|
||
}
|
||
validationIssues = FrameAnimationEditorValidationService.Validate(graph, true, out var validationPreview);
|
||
if (validationPreview != null)
|
||
{
|
||
importPreview = validationPreview;
|
||
}
|
||
SetBottomTab(BottomTab.Validation);
|
||
bottomContainer.MarkDirtyRepaint();
|
||
RefreshBrowser();
|
||
RefreshGraphView();
|
||
}
|
||
|
||
private void ScheduleLightValidation()
|
||
{
|
||
validateAt = EditorApplication.timeSinceStartup + 0.3d;
|
||
}
|
||
|
||
private void ShowRenameClip(FrameClip clip)
|
||
{
|
||
var refs = graph.Nodes.Count(node => node != null && node.ClipId == clip.Id);
|
||
FrameAnimationTextInputWindow.Show(
|
||
"Rename Clip ID", "New Clip ID", clip.Id,
|
||
$"将更新 {refs} 个 Node 引用和匹配的 defaultPlayableId。外部字符串调用无法自动修复。",
|
||
value => FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, value, clip)
|
||
? string.Empty : "ID 为空或与现有 Clip / Flow 冲突。",
|
||
value =>
|
||
{
|
||
if (!FrameAnimationAssetOperations.RenameClip(graph, clip, value, out var error))
|
||
{
|
||
EditorUtility.DisplayDialog("重命名失败", error, "确定");
|
||
}
|
||
RefreshBrowser();
|
||
RefreshGraphView();
|
||
ValidateFull();
|
||
});
|
||
}
|
||
|
||
private void ShowRenameFlow(AnimationFlow flow)
|
||
{
|
||
var oldId = flow.Id;
|
||
FrameAnimationTextInputWindow.Show(
|
||
"Rename Flow ID", "New Flow ID", flow.Id,
|
||
"将同步更新 defaultPlayableId 和 Flow EditorData;外部字符串调用无法自动修复。",
|
||
value => FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, value, exceptFlow: flow)
|
||
? string.Empty : "ID 为空或与现有 Clip / Flow 冲突。",
|
||
value =>
|
||
{
|
||
if (!FrameAnimationAssetOperations.RenameFlow(graph, flow, value, out var error))
|
||
{
|
||
EditorUtility.DisplayDialog("重命名失败", error, "确定");
|
||
}
|
||
else if (focusedFlowId == oldId)
|
||
{
|
||
focusedFlowId = flow.Id;
|
||
}
|
||
RefreshBrowser();
|
||
RefreshFlowChoices();
|
||
RefreshGraphView();
|
||
ValidateFull();
|
||
});
|
||
}
|
||
|
||
private void ShowRenameGraph()
|
||
{
|
||
FrameAnimationTextInputWindow.Show(
|
||
"Rename Graph ID", "New Graph ID", graph.Id,
|
||
"Graph id 可能被 Addressable key、资源查找或外部工具引用,无法自动迁移外部调用。",
|
||
value => string.IsNullOrWhiteSpace(value) ? "Graph id 不能为空。" : string.Empty,
|
||
value =>
|
||
{
|
||
FrameAnimationAssetOperations.RenameGraph(graph, value, out _);
|
||
RefreshCanvasSummary();
|
||
});
|
||
}
|
||
|
||
private void LocateClipReferences(FrameClip clip)
|
||
{
|
||
var nodes = graph.Nodes.Where(node => node != null && node.ClipId == clip.Id).ToArray();
|
||
if (nodes.Length == 0)
|
||
{
|
||
EditorUtility.DisplayDialog("定位引用", "该 Clip 未被任何 Node 引用。", "确定");
|
||
return;
|
||
}
|
||
if (nodes.Length == 1)
|
||
{
|
||
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, nodes[0]));
|
||
return;
|
||
}
|
||
var menu = new GenericMenu();
|
||
foreach (var node in nodes)
|
||
{
|
||
var captured = node;
|
||
menu.AddItem(new GUIContent($"{node.DisplayName}/{node.InternalId}"), false,
|
||
() => SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, captured)));
|
||
}
|
||
menu.ShowAsContext();
|
||
}
|
||
|
||
private void ConfirmRemoveClip(FrameClip clip)
|
||
{
|
||
if (!FrameAnimationAssetOperations.CanRemoveClip(graph, clip, out var blocker))
|
||
{
|
||
EditorUtility.DisplayDialog("无法移除 Clip", blocker, "确定");
|
||
return;
|
||
}
|
||
var subAsset = AssetDatabase.IsSubAsset(clip);
|
||
var message = subAsset
|
||
? "将从 Graph 移除并删除 Clip sub-asset。"
|
||
: "只从当前 Graph 移除引用,不删除外部 .asset。";
|
||
if (clip.IsImported && !clip.ImportInfo.IsMissingFromSource)
|
||
{
|
||
message += "\n\n源 Tag 仍存在时,下次刷新会重新创建 Imported Clip。";
|
||
}
|
||
if (!EditorUtility.DisplayDialog("确认移除 Clip", message, subAsset ? "删除" : "移除", "取消"))
|
||
{
|
||
return;
|
||
}
|
||
if (!FrameAnimationAssetOperations.RemoveClip(graph, clip, out var error))
|
||
{
|
||
EditorUtility.DisplayDialog("无法移除 Clip", error, "确定");
|
||
return;
|
||
}
|
||
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph));
|
||
RefreshBrowser();
|
||
ValidateFull();
|
||
}
|
||
|
||
private void ToggleBottom()
|
||
{
|
||
bottomCollapsed = !bottomCollapsed;
|
||
ApplyBottomState();
|
||
SaveWorkspaceState();
|
||
}
|
||
|
||
private void ApplyBottomState()
|
||
{
|
||
if (bottomPanel == null)
|
||
{
|
||
return;
|
||
}
|
||
bottomPanel.style.height = bottomCollapsed ? 28f : FrameAnimationWorkspaceState.GetFloat(graph, "BottomHeight", 230f);
|
||
bottomContainer.style.display = bottomCollapsed ? DisplayStyle.None : DisplayStyle.Flex;
|
||
}
|
||
|
||
private VisualElement CreateVerticalResizer(
|
||
VisualElement panel,
|
||
float min,
|
||
float max,
|
||
string stateName,
|
||
bool resizeFromLeft = false)
|
||
{
|
||
var resizer = new VisualElement
|
||
{
|
||
style = { width = 5f, backgroundColor = new StyleColor(new Color(0.08f, 0.08f, 0.08f)) }
|
||
};
|
||
float startWidth = 0f;
|
||
float startX = 0f;
|
||
resizer.RegisterCallback<PointerDownEvent>(evt =>
|
||
{
|
||
startWidth = panel.resolvedStyle.width;
|
||
startX = evt.position.x;
|
||
resizer.CapturePointer(evt.pointerId);
|
||
});
|
||
resizer.RegisterCallback<PointerMoveEvent>(evt =>
|
||
{
|
||
if (!resizer.HasPointerCapture(evt.pointerId)) return;
|
||
var delta = evt.position.x - startX;
|
||
panel.style.width = Mathf.Clamp(startWidth + (resizeFromLeft ? -delta : delta), min, max);
|
||
});
|
||
resizer.RegisterCallback<PointerUpEvent>(evt =>
|
||
{
|
||
if (resizer.HasPointerCapture(evt.pointerId)) resizer.ReleasePointer(evt.pointerId);
|
||
FrameAnimationWorkspaceState.SetFloat(graph, stateName, panel.resolvedStyle.width);
|
||
});
|
||
return resizer;
|
||
}
|
||
|
||
private VisualElement CreateHorizontalResizer(VisualElement panel, float min, float max, string stateName)
|
||
{
|
||
var resizer = new VisualElement
|
||
{
|
||
style = { height = 5f, backgroundColor = new StyleColor(new Color(0.08f, 0.08f, 0.08f)) }
|
||
};
|
||
float startHeight = 0f;
|
||
float startY = 0f;
|
||
resizer.RegisterCallback<PointerDownEvent>(evt =>
|
||
{
|
||
startHeight = panel.resolvedStyle.height;
|
||
startY = evt.position.y;
|
||
resizer.CapturePointer(evt.pointerId);
|
||
});
|
||
resizer.RegisterCallback<PointerMoveEvent>(evt =>
|
||
{
|
||
if (!resizer.HasPointerCapture(evt.pointerId)) return;
|
||
var delta = startY - evt.position.y;
|
||
panel.style.height = Mathf.Clamp(startHeight + delta, min, max);
|
||
});
|
||
resizer.RegisterCallback<PointerUpEvent>(evt =>
|
||
{
|
||
if (resizer.HasPointerCapture(evt.pointerId)) resizer.ReleasePointer(evt.pointerId);
|
||
FrameAnimationWorkspaceState.SetFloat(graph, stateName, panel.resolvedStyle.height);
|
||
});
|
||
return resizer;
|
||
}
|
||
|
||
private void RestoreWorkspaceState()
|
||
{
|
||
if (graph == null || leftPanel == null)
|
||
{
|
||
return;
|
||
}
|
||
resourceTab = Enum.TryParse(FrameAnimationWorkspaceState.GetString(graph, "ResourceTab", "Clips"), out ResourceTab restoredTab)
|
||
? restoredTab : ResourceTab.Clips;
|
||
bottomTab = Enum.TryParse(FrameAnimationWorkspaceState.GetString(graph, "BottomTab", "Validation"), out BottomTab restoredBottom)
|
||
? restoredBottom : BottomTab.Validation;
|
||
clipFilter = Enum.TryParse(FrameAnimationWorkspaceState.GetString(graph, "ClipFilter", "All"), out FrameAnimationClipFilter restoredFilter)
|
||
? restoredFilter : FrameAnimationClipFilter.All;
|
||
clipSort = Enum.TryParse(FrameAnimationWorkspaceState.GetString(graph, "ClipSort", "Source"), out FrameAnimationClipSort restoredSort)
|
||
? restoredSort : FrameAnimationClipSort.Source;
|
||
searchField.SetValueWithoutNotify(FrameAnimationWorkspaceState.GetString(graph, "Search", string.Empty));
|
||
filterField.SetValueWithoutNotify(clipFilter);
|
||
sortField.SetValueWithoutNotify(clipSort);
|
||
leftPanel.style.width = FrameAnimationWorkspaceState.GetFloat(graph, "LeftWidth", 330f);
|
||
rightPanel.style.width = FrameAnimationWorkspaceState.GetFloat(graph, "RightWidth", 360f);
|
||
bottomCollapsed = FrameAnimationWorkspaceState.GetBool(graph, "BottomCollapsed", false);
|
||
validationKindFilter = Enum.TryParse(
|
||
FrameAnimationWorkspaceState.GetString(graph, "ValidationKind", "All"),
|
||
out ValidationKindFilter restoredValidationKind)
|
||
? restoredValidationKind : ValidationKindFilter.All;
|
||
bottomPanel.style.height = FrameAnimationWorkspaceState.GetFloat(graph, "BottomHeight", 230f);
|
||
focusedFlowId = FrameAnimationWorkspaceState.GetString(graph, "FocusedFlow", string.Empty);
|
||
if (!graph.Flows.Any(flow => flow != null && flow.Id == focusedFlowId))
|
||
{
|
||
focusedFlowId = string.Empty;
|
||
}
|
||
ApplyBottomState();
|
||
}
|
||
|
||
private void RestoreSelection()
|
||
{
|
||
if (graph == null)
|
||
{
|
||
selection = default;
|
||
return;
|
||
}
|
||
|
||
if (!Enum.TryParse(
|
||
FrameAnimationWorkspaceState.GetString(graph, "SelectionKind", "Graph"),
|
||
out FrameAnimationEditorSelectionKind kind))
|
||
{
|
||
kind = FrameAnimationEditorSelectionKind.Graph;
|
||
}
|
||
var id = FrameAnimationWorkspaceState.GetString(graph, "SelectionId", string.Empty);
|
||
object value = kind switch
|
||
{
|
||
FrameAnimationEditorSelectionKind.Graph => graph,
|
||
FrameAnimationEditorSelectionKind.Clip => ResolveClipSelection(id),
|
||
FrameAnimationEditorSelectionKind.Flow => graph.Flows.FirstOrDefault(item => item != null && item.Id == id),
|
||
FrameAnimationEditorSelectionKind.Source => graph.ImportSources.FirstOrDefault(item => item != null && item.InternalId == id),
|
||
FrameAnimationEditorSelectionKind.Node => graph.Nodes.FirstOrDefault(item => item != null && item.InternalId == id),
|
||
FrameAnimationEditorSelectionKind.Edge => graph.Edges.FirstOrDefault(item => item != null && item.InternalId == id),
|
||
_ => null
|
||
};
|
||
selection = value != null
|
||
? new FrameAnimationEditorSelection(kind, value)
|
||
: new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph);
|
||
}
|
||
|
||
private FrameClip ResolveClipSelection(string globalId)
|
||
{
|
||
if (!string.IsNullOrEmpty(globalId) && GlobalObjectId.TryParse(globalId, out var parsed))
|
||
{
|
||
var clip = GlobalObjectId.GlobalObjectIdentifierToObjectSlow(parsed) as FrameClip;
|
||
if (clip != null && graph.Clips.Contains(clip))
|
||
{
|
||
return clip;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private string GetSelectionId()
|
||
{
|
||
return selection.Kind switch
|
||
{
|
||
FrameAnimationEditorSelectionKind.Clip when selection.Value is FrameClip clip =>
|
||
GlobalObjectId.GetGlobalObjectIdSlow(clip).ToString(),
|
||
FrameAnimationEditorSelectionKind.Flow when selection.Value is AnimationFlow flow => flow.Id,
|
||
FrameAnimationEditorSelectionKind.Source when selection.Value is FrameAnimationImportSource source => source.InternalId,
|
||
FrameAnimationEditorSelectionKind.Node when selection.Value is AnimationNode node => node.InternalId,
|
||
FrameAnimationEditorSelectionKind.Edge when selection.Value is AnimationEdge edge => edge.InternalId,
|
||
_ => string.Empty
|
||
};
|
||
}
|
||
|
||
private void SaveWorkspaceState()
|
||
{
|
||
if (graph == null)
|
||
{
|
||
return;
|
||
}
|
||
FrameAnimationWorkspaceState.SetString(graph, "ResourceTab", resourceTab.ToString());
|
||
FrameAnimationWorkspaceState.SetString(graph, "BottomTab", bottomTab.ToString());
|
||
FrameAnimationWorkspaceState.SetString(graph, "ClipFilter", clipFilter.ToString());
|
||
FrameAnimationWorkspaceState.SetString(graph, "ClipSort", clipSort.ToString());
|
||
FrameAnimationWorkspaceState.SetString(graph, "Search", searchField?.value ?? string.Empty);
|
||
FrameAnimationWorkspaceState.SetBool(graph, "BottomCollapsed", bottomCollapsed);
|
||
FrameAnimationWorkspaceState.SetString(graph, "ValidationKind", validationKindFilter.ToString());
|
||
FrameAnimationWorkspaceState.SetString(graph, "SelectionKind", selection.Kind.ToString());
|
||
FrameAnimationWorkspaceState.SetString(graph, "SelectionId", GetSelectionId());
|
||
FrameAnimationWorkspaceState.SetString(graph, "FocusedFlow", focusedFlowId);
|
||
}
|
||
|
||
private void OnProjectDataChanged()
|
||
{
|
||
if (graph == null)
|
||
{
|
||
RestoreLastGraph();
|
||
}
|
||
if (graph != null)
|
||
{
|
||
RestoreSelection();
|
||
lastSelectedNode = selection.Value as AnimationNode;
|
||
if (!graph.Flows.Any(flow => flow != null && flow.Id == focusedFlowId))
|
||
{
|
||
focusedFlowId = string.Empty;
|
||
}
|
||
}
|
||
RefreshBrowser();
|
||
RefreshFlowChoices();
|
||
RefreshGraphView();
|
||
propertyContainer?.MarkDirtyRepaint();
|
||
bottomContainer?.MarkDirtyRepaint();
|
||
RefreshCanvasSummary();
|
||
}
|
||
}
|
||
|
||
internal static class FrameAnimationGraphAssetOpenHandler
|
||
{
|
||
[OnOpenAsset]
|
||
private static bool OnOpenAsset(int instanceId, int line)
|
||
{
|
||
var asset = EditorUtility.InstanceIDToObject(instanceId);
|
||
if (asset is FrameAnimationGraph graph)
|
||
{
|
||
FrameAnimationGraphEditorWindow.Open(graph);
|
||
return true;
|
||
}
|
||
if (asset is FrameClip clip)
|
||
{
|
||
var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip);
|
||
if (owners.Count == 1)
|
||
{
|
||
FrameAnimationGraphEditorWindow.Open(owners[0]);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
}
|