Files
aibis-dream/Assets/Editor/FrameAnimation/FrameAnimationGraphEditorWindow.cs
T

2520 lines
109 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.UIElements;
using UnityEditorInternal;
using UnityEngine;
using UnityEngine.UIElements;
namespace AibisDream.FrameAnimation.Editor
{
public sealed class FrameAnimationGraphEditorWindow : EditorWindow
{
private enum ResourceTab { Clips, Flows, Sources }
private enum BottomTab { ImportDiff, Validation }
private enum ValidationKindFilter { All, Graph, Clip, Flow, Source, Node, Edge }
private sealed class BrowserEntry
{
public bool IsHeader;
public string Label;
public FrameAnimationEditorSelection Selection;
}
private const string LastGraphGuidKey = "AibisDream.FrameAnimation.Workspace.LastGraphGuid";
[SerializeField] private FrameAnimationGraph graph;
private ObjectField graphField;
private ToolbarButton graphPropertiesButton;
private ToolbarButton graphBreadcrumbButton;
private Label dirtyLabel;
private Label canvasLabel;
private FrameAnimationGraphView graphView;
private PopupField<string> flowFocusField;
private Label flowFocusStatusLabel;
private ToolbarButton exitFlowFocusButton;
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;
private bool frameListEditable;
private readonly FrameAnimationPreviewCoordinator previewCoordinator = new FrameAnimationPreviewCoordinator();
private readonly List<Slider> previewTimelineSliders = new List<Slider>();
private readonly List<Label> previewTimeLabels = new List<Label>();
private readonly List<Button> previewPlayButtons = new List<Button>();
private readonly List<Label> previewTargetLabels = new List<Label>();
private VisualElement clipPreviewPanel;
private FrameAnimationPreviewElement clipPreviewElement;
private Label clipPreviewStatus;
private EnumField nodePreviewPolicyField;
private EnumField previewBackgroundField;
private EnumField previewZoomField;
private Slider manualZoomSlider;
private PopupField<string> previewSpeedField;
private double lastPreviewTick;
private bool updatingPreviewUi;
[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;
var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(
"Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.uss");
if (styleSheet != null) rootVisualElement.styleSheets.Add(styleSheet);
previewCoordinator.Changed -= OnPreviewChanged;
previewCoordinator.Changed += OnPreviewChanged;
BuildToolbar();
BuildMainArea();
BuildBottomArea();
RestoreLastGraph();
SetGraph(graph);
RestoreWorkspaceState();
RestoreSelection();
RefreshBrowser();
RefreshFlowChoices();
RefreshGraphView();
UpdatePreviewTargetForSelection();
RefreshPreviewUi();
}
private void OnEnable()
{
Undo.undoRedoPerformed += OnProjectDataChanged;
EditorApplication.projectChanged += OnProjectDataChanged;
lastPreviewTick = EditorApplication.timeSinceStartup;
}
private void OnDisable()
{
Undo.undoRedoPerformed -= OnProjectDataChanged;
EditorApplication.projectChanged -= OnProjectDataChanged;
previewCoordinator.Changed -= OnPreviewChanged;
previewCoordinator.ClearTarget();
SaveWorkspaceState();
}
private void Update()
{
var now = EditorApplication.timeSinceStartup;
if (now - lastPreviewTick >= 1d / 30d)
{
var delta = Math.Max(0d, now - lastPreviewTick);
lastPreviewTick = now;
previewCoordinator.Tick(delta, graphView?.VisibleNodes ?? Array.Empty<AnimationNode>());
}
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);
graphPropertiesButton = new ToolbarButton(SelectGraphProperties)
{
name = "graph-properties-button",
text = "Graph Properties",
tooltip = "查看和编辑当前 Graph 的参数"
};
toolbar.Add(graphPropertiesButton);
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();
graphBreadcrumbButton = new ToolbarButton(SelectGraphProperties)
{
name = "graph-breadcrumb-button",
text = "Graph",
tooltip = "返回当前 Graph 的属性"
};
graphBreadcrumbButton.style.minWidth = 80f;
graphBreadcrumbButton.style.maxWidth = 220f;
canvasToolbar.Add(graphBreadcrumbButton);
canvasToolbar.Add(new ToolbarButton(ShowAllNodes) { text = "Show All" });
flowFocusStatusLabel = new Label("Show All")
{
style =
{
minWidth = 110f,
maxWidth = 240f,
flexShrink = 1f,
unityFontStyleAndWeight = FontStyle.Bold,
unityTextAlign = TextAnchor.MiddleLeft
}
};
canvasToolbar.Add(flowFocusStatusLabel);
exitFlowFocusButton = new ToolbarButton(() => ExitFlowFocus(false)) { text = "Exit Focus" };
exitFlowFocusButton.style.display = DisplayStyle.None;
canvasToolbar.Add(exitFlowFocusButton);
flowFocusField = new PopupField<string>(new List<string> { "Show All" }, 0)
{
style = { minWidth = 135f }
};
flowFocusField.RegisterValueChangedCallback(evt =>
{
if (evt.newValue == "Show All")
{
ExitFlowFocus(false);
}
else
{
var flow = graph?.Flows.FirstOrDefault(item => item != null &&
FlowChoiceLabel(item) == evt.newValue);
EnterFlowFocus(flow?.Id);
}
});
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" });
canvasToolbar.Add(new ToolbarSpacer());
canvasToolbar.Add(BuildPreviewTransport(true, true));
canvas.Add(canvasToolbar);
graphView = new FrameAnimationGraphView();
graphView.SelectionRequested += value => SetSelection(value, false);
graphView.MultiSelectionChanged += nodes =>
{
multiSelectedNodes = nodes ?? Array.Empty<AnimationNode>();
propertyContainer?.MarkDirtyRepaint();
};
graphView.SelectionSetChanged += OnCanvasSelectionSetChanged;
graphView.CreateFlowRequested += ShowCreateFlow;
graphView.NodeCreated += _ => ExitFlowFocus(false);
graphView.ExitFocusRequested += () => ExitFlowFocus(false);
graphView.GraphChanged += OnCanvasGraphChanged;
graphView.NotificationRequested += message => ShowNotification(new GUIContent(message));
graphView.WorkspaceViewTransformChanged += (position, scale) =>
{
if (graph == null) return;
FrameAnimationWorkspaceState.SetFloat(graph, "CanvasX", position.x);
FrameAnimationWorkspaceState.SetFloat(graph, "CanvasY", position.y);
FrameAnimationWorkspaceState.SetFloat(graph, "CanvasScale", scale);
};
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"));
clipPreviewPanel = BuildClipPreviewPanel();
clipPreviewPanel.style.display = DisplayStyle.None;
rightPanel.Add(clipPreviewPanel);
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 BuildPreviewTransport(bool includeTarget, bool includePreferences)
{
var controls = new VisualElement();
controls.AddToClassList("fa-preview-controls");
if (includeTarget)
{
var targetLabel = new Label("Preview: None")
{
style = { minWidth = 120f, maxWidth = 300f }
};
previewTargetLabels.Add(targetLabel);
controls.Add(targetLabel);
}
controls.Add(new ToolbarButton(previewCoordinator.Restart) { text = "↶", tooltip = "Restart" });
controls.Add(new ToolbarButton(() => previewCoordinator.Step(-1)) { text = "◀", tooltip = "Previous Frame" });
var play = new ToolbarButton(PreviewPlayPause) { text = "▶", tooltip = "Play / Pause" };
previewPlayButtons.Add(play);
controls.Add(play);
controls.Add(new ToolbarButton(() => previewCoordinator.Step(1)) { text = "▶|", tooltip = "Next Frame" });
controls.Add(new ToolbarButton(previewCoordinator.Stop) { text = "■", tooltip = "Stop" });
var timeline = new Slider(0f, 1f)
{
style = { minWidth = 90f, maxWidth = 240f, flexGrow = 1f }
};
timeline.RegisterValueChangedCallback(evt =>
{
if (!updatingPreviewUi) previewCoordinator.Seek(evt.newValue);
});
previewTimelineSliders.Add(timeline);
controls.Add(timeline);
var time = new Label("0 / 0 ms") { style = { minWidth = 82f } };
previewTimeLabels.Add(time);
controls.Add(time);
if (includePreferences)
{
previewSpeedField = new PopupField<string>(
new List<string> { "0.25×", "0.5×", "1×", "2×", "4×" }, "1×")
{
style = { width = 64f }
};
previewSpeedField.RegisterValueChangedCallback(evt =>
{
previewCoordinator.PreviewSpeed = ParsePreviewSpeed(evt.newValue);
SaveWorkspaceState();
});
controls.Add(previewSpeedField);
nodePreviewPolicyField = new EnumField(NodePreviewPolicy.SelectedOnly)
{
style = { width = 100f }
};
nodePreviewPolicyField.RegisterValueChangedCallback(evt =>
{
previewCoordinator.SetNodePolicy((NodePreviewPolicy)evt.newValue);
SaveWorkspaceState();
});
controls.Add(nodePreviewPolicyField);
previewBackgroundField = new EnumField(FrameAnimationPreviewBackground.Checkerboard)
{
style = { width = 100f }
};
previewBackgroundField.RegisterValueChangedCallback(evt =>
{
previewCoordinator.Background = (FrameAnimationPreviewBackground)evt.newValue;
SaveWorkspaceState();
RefreshPreviewUi();
});
controls.Add(previewBackgroundField);
}
return controls;
}
private VisualElement BuildClipPreviewPanel()
{
var panel = new VisualElement();
panel.AddToClassList("fa-clip-preview-panel");
panel.Add(new Label("Clip Preview") { style = { unityFontStyleAndWeight = FontStyle.Bold } });
clipPreviewElement = new FrameAnimationPreviewElement();
panel.Add(clipPreviewElement);
panel.Add(BuildPreviewTransport(false, false));
var zoomRow = new VisualElement { style = { flexDirection = FlexDirection.Row } };
previewZoomField = new EnumField(FrameAnimationPreviewZoomMode.Fit) { style = { width = 100f } };
previewZoomField.RegisterValueChangedCallback(evt =>
{
previewCoordinator.ZoomMode = (FrameAnimationPreviewZoomMode)evt.newValue;
SaveWorkspaceState();
RefreshPreviewUi();
});
zoomRow.Add(previewZoomField);
manualZoomSlider = new Slider("Manual", 0.25f, 8f) { style = { flexGrow = 1f } };
manualZoomSlider.RegisterValueChangedCallback(evt =>
{
previewCoordinator.ManualZoom = evt.newValue;
SaveWorkspaceState();
RefreshPreviewUi();
});
zoomRow.Add(manualZoomSlider);
panel.Add(zoomRow);
clipPreviewStatus = new Label();
clipPreviewStatus.AddToClassList("fa-preview-status");
panel.Add(clipPreviewStatus);
return panel;
}
private void PreviewPlayPause()
{
if (previewCoordinator.State == FrameAnimationPreviewState.Playing)
{
previewCoordinator.Pause();
return;
}
if (previewCoordinator.TargetKind == FrameAnimationPreviewTargetKind.Flow)
{
validationIssues = FrameAnimationEditorValidationService.Validate(graph, false, out _);
RefreshBrowser();
bottomContainer?.MarkDirtyRepaint();
RefreshGraphView();
}
previewCoordinator.Play();
if (previewCoordinator.State == FrameAnimationPreviewState.Failed)
{
ShowNotification(new GUIContent(previewCoordinator.Error.Message));
}
}
private void UpdatePreviewTargetForSelection()
{
if (graph == null)
{
previewCoordinator.ClearTarget();
return;
}
if (previewCoordinator.TargetKind == FrameAnimationPreviewTargetKind.Flow &&
previewCoordinator.Target is AnimationFlow activeFlow &&
IsSelectionInsideFlow(activeFlow, selection))
{
RefreshPreviewUi();
return;
}
switch (selection.Kind)
{
case FrameAnimationEditorSelectionKind.Clip:
previewCoordinator.SetTarget(FrameAnimationPreviewTargetKind.Clip, selection.Value);
break;
case FrameAnimationEditorSelectionKind.Node:
previewCoordinator.SetTarget(FrameAnimationPreviewTargetKind.Node, selection.Value);
break;
case FrameAnimationEditorSelectionKind.Flow:
previewCoordinator.SetTarget(FrameAnimationPreviewTargetKind.Flow, selection.Value);
break;
default:
previewCoordinator.ClearTarget();
break;
}
}
private bool IsSelectionInsideFlow(AnimationFlow flow, FrameAnimationEditorSelection value)
{
if (flow == null || graph == null) return false;
if (value.Kind == FrameAnimationEditorSelectionKind.Flow) return ReferenceEquals(value.Value, flow);
var reachable = new FrameAnimationGraphTopology(graph).GetReachable(flow.EntryNodeId);
return value.Value switch
{
AnimationNode node => reachable.Nodes.Contains(node),
AnimationEdge edge => reachable.Edges.Contains(edge),
_ => false
};
}
private void OnPreviewChanged()
{
RefreshPreviewUi();
Repaint();
}
private void RefreshPreviewUi()
{
if (rootVisualElement == null) return;
updatingPreviewUi = true;
var timeline = previewCoordinator.Timeline;
var maximum = timeline?.DisplayDurationSeconds ?? 0d;
var position = Math.Min(maximum, previewCoordinator.PositionSeconds);
foreach (var slider in previewTimelineSliders)
{
slider.lowValue = 0f;
slider.highValue = Mathf.Max(0.0001f, (float)maximum);
slider.SetValueWithoutNotify((float)position);
slider.SetEnabled(timeline != null && timeline.Segments.Count > 0 && maximum > 0d);
}
var total = timeline?.IsInfinite == true ? "∞" : FormatPreviewTime(maximum);
foreach (var label in previewTimeLabels)
{
label.text = $"{FormatPreviewTime(position)} / {total}";
}
foreach (var button in previewPlayButtons)
{
button.text = previewCoordinator.State == FrameAnimationPreviewState.Playing ? "❚❚" : "▶";
button.SetEnabled(previewCoordinator.TargetKind != FrameAnimationPreviewTargetKind.None);
}
var targetText = PreviewTargetName();
foreach (var label in previewTargetLabels)
{
label.text = targetText;
label.tooltip = targetText;
}
if (clipPreviewPanel != null)
{
clipPreviewPanel.style.display = selection.Kind == FrameAnimationEditorSelectionKind.Clip
? DisplayStyle.Flex : DisplayStyle.None;
}
if (clipPreviewElement != null)
{
clipPreviewElement.SetBackground(previewCoordinator.Background);
clipPreviewElement.SetZoom(previewCoordinator.ZoomMode, previewCoordinator.ManualZoom);
clipPreviewElement.SetDisplay(
previewCoordinator.CurrentSprite,
previewCoordinator.CurrentSprite == null,
previewCoordinator.Snapshot.IsCompleted ? previewCoordinator.Snapshot.EndBehavior.ToString() : "Empty Frame");
}
if (clipPreviewStatus != null)
{
clipPreviewStatus.text = BuildPreviewStatus();
}
if (manualZoomSlider != null)
{
manualZoomSlider.SetValueWithoutNotify(previewCoordinator.ManualZoom);
manualZoomSlider.SetEnabled(previewCoordinator.ZoomMode == FrameAnimationPreviewZoomMode.Manual);
}
previewZoomField?.SetValueWithoutNotify(previewCoordinator.ZoomMode);
nodePreviewPolicyField?.SetValueWithoutNotify(previewCoordinator.NodePolicy);
previewBackgroundField?.SetValueWithoutNotify(previewCoordinator.Background);
previewSpeedField?.SetValueWithoutNotify(FormatPreviewSpeed(previewCoordinator.PreviewSpeed));
graphView?.ApplyPreview(previewCoordinator);
updatingPreviewUi = false;
}
private string BuildPreviewStatus()
{
if (previewCoordinator.State == FrameAnimationPreviewState.Failed)
{
return $"ERROR [{previewCoordinator.Error.Code}] {previewCoordinator.Error.Message}";
}
var snapshot = previewCoordinator.Snapshot;
if (!snapshot.HasStarted || snapshot.Clip == null) return "No preview target";
var duration = snapshot.FrameIndex >= 0 && snapshot.FrameIndex < snapshot.Clip.FrameCount
? snapshot.Clip.Frames[snapshot.FrameIndex].DurationMs : 0;
var effective = snapshot.StepSpeed > 0f ? duration / snapshot.StepSpeed : double.PositiveInfinity;
return $"{previewCoordinator.State} · Frame {snapshot.FrameIndex + 1}/{snapshot.Clip.FrameCount} · " +
$"source {duration} ms · effective {(double.IsInfinity(effective) ? "" : effective.ToString("0") + " ms")} · {snapshot.Clip.Id}" +
(string.IsNullOrEmpty(snapshot.NodeId) ? string.Empty : $" · Node {snapshot.NodeId}") +
(previewCoordinator.Timeline?.IsBlocked == true ? " · Blocked (speed 0)" : string.Empty);
}
private string PreviewTargetName()
{
return previewCoordinator.Target switch
{
FrameClip clip => $"Clip: {clip.DisplayName}",
AnimationNode node => $"Node: {node.DisplayName}",
AnimationFlow flow => BuildFlowPreviewTargetName(flow),
_ => "Preview: None"
};
}
private string BuildFlowPreviewTargetName(AnimationFlow flow)
{
var snapshot = previewCoordinator.Snapshot;
if (!snapshot.HasStarted || snapshot.Clip == null) return $"Flow: {flow.DisplayName}";
var node = graph?.Nodes.FirstOrDefault(item => item != null && item.InternalId == snapshot.NodeId);
var ending = snapshot.IsCompleted && previewCoordinator.CurrentSprite == null
? $" · {snapshot.EndBehavior}" : string.Empty;
var blocked = previewCoordinator.Timeline?.IsBlocked == true ? " · Blocked" : string.Empty;
return $"Flow: {flow.DisplayName} · Node: {node?.DisplayName ?? snapshot.NodeId} · " +
$"{snapshot.Clip.Id} F{snapshot.FrameIndex + 1}/{snapshot.Clip.FrameCount}{ending}{blocked}";
}
private static string FormatPreviewTime(double seconds) => $"{Math.Max(0d, seconds) * 1000d:0} ms";
private static float ParsePreviewSpeed(string value)
{
return value switch { "0.25×" => 0.25f, "0.5×" => 0.5f, "2×" => 2f, "4×" => 4f, _ => 1f };
}
private static string FormatPreviewSpeed(float value)
{
if (Mathf.Approximately(value, 0.25f)) return "0.25×";
if (Mathf.Approximately(value, 0.5f)) return "0.5×";
if (Mathf.Approximately(value, 2f)) return "2×";
if (Mathf.Approximately(value, 4f)) return "4×";
return "1×";
}
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, true)) { text = "Clips" });
tabs.Add(new ToolbarButton(() => SetResourceTab(ResourceTab.Flows, true)) { text = "Flows" });
tabs.Add(new ToolbarButton(() => SetResourceTab(ResourceTab.Sources, true)) { 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();
focusedFlowId = string.Empty;
graph = value;
previewCoordinator.SetGraph(graph);
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 撤销。"));
}
}
previewCoordinator.SetGraph(graph);
graphField?.SetValueWithoutNotify(graph);
if (graph != null)
{
var path = AssetDatabase.GetAssetPath(graph);
EditorPrefs.SetString(LastGraphGuidKey, AssetDatabase.AssetPathToGUID(path));
}
RefreshBrowser();
RefreshFlowChoices();
RefreshGraphView();
RestoreCanvasView();
RefreshCanvasSummary();
UpdatePreviewTargetForSelection();
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, bool userInitiated = false)
{
if (userInitiated && tab != ResourceTab.Flows)
{
ExitFlowFocus(false);
}
resourceTab = tab;
SaveWorkspaceState();
RefreshBrowser();
}
private void SetBottomTab(BottomTab tab)
{
bottomTab = tab;
bottomCollapsed = false;
ApplyBottomState();
SaveWorkspaceState();
}
private void SetSelection(FrameAnimationEditorSelection value, bool locateOnCanvas = true)
{
if (value.Kind == FrameAnimationEditorSelectionKind.Flow && value.Value is AnimationFlow selectedFlow)
{
EnterFlowFocus(selectedFlow.Id);
}
else if (FrameAnimationFlowFocusPolicy.ShouldExitForSelection(graph, focusedFlowId, value))
{
ExitFlowFocus(false);
}
selection = value;
if (value.Kind == FrameAnimationEditorSelectionKind.Node && value.Value is AnimationNode node)
{
lastSelectedNode = node;
}
frameListClip = null;
frameList = null;
clipSerializedObject = null;
propertyContainer?.MarkDirtyRepaint();
RefreshCanvasSummary();
SaveWorkspaceState();
UpdatePreviewTargetForSelection();
if (locateOnCanvas && (value.Kind == FrameAnimationEditorSelectionKind.Node ||
value.Kind == FrameAnimationEditorSelectionKind.Edge))
{
graphView?.SelectAndFrame(value);
}
}
private void SelectGraphProperties()
{
if (graph == null)
{
return;
}
graphView?.ClearSelection();
resourceList?.ClearSelection();
multiSelectedNodes = Array.Empty<AnimationNode>();
lastSelectedNode = null;
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph), false);
}
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);
graphView?.ApplyPreview(previewCoordinator);
}
private void OnCanvasGraphChanged()
{
previewCoordinator.RefreshTarget(true);
RevalidateFlowFocus();
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)
{
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 = FrameAnimationFlowFocusPolicy.FindFocusedFlow(graph, focusedFlowId);
flowFocusField.SetValueWithoutNotify(focused != null ? FlowChoiceLabel(focused) : "Show All");
}
RefreshFlowFocusUi();
}
private void EnterFlowFocus(string flowId)
{
var flow = FrameAnimationFlowFocusPolicy.FindFocusedFlow(graph, flowId);
if (flow == null)
{
ExitFlowFocus(false);
return;
}
focusedFlowId = flow.Id;
RefreshFlowChoices();
graphView?.SetFocusedFlow(focusedFlowId);
}
private void ExitFlowFocus(bool frameAll)
{
focusedFlowId = string.Empty;
RefreshFlowChoices();
graphView?.SetFocusedFlow(string.Empty);
if (frameAll)
{
graphView?.FrameAll();
}
}
private void RevalidateFlowFocus()
{
if (string.IsNullOrEmpty(focusedFlowId))
{
RefreshFlowFocusUi();
return;
}
var flow = FrameAnimationFlowFocusPolicy.FindFocusedFlow(graph, focusedFlowId);
if (flow == null)
{
if (selection.Kind == FrameAnimationEditorSelectionKind.Flow &&
selection.Value is AnimationFlow selectedFlow && graph?.Flows.Contains(selectedFlow) == true)
{
EnterFlowFocus(selectedFlow.Id);
}
else
{
ExitFlowFocus(false);
}
return;
}
if (selection.Kind == FrameAnimationEditorSelectionKind.Flow &&
selection.Value is AnimationFlow activeSelection &&
graph.Flows.Contains(activeSelection) && activeSelection != flow)
{
EnterFlowFocus(activeSelection.Id);
return;
}
var selectedElementWasRemoved = selection.Value switch
{
AnimationNode node => !graph.Nodes.Contains(node),
AnimationEdge edge => !graph.Edges.Contains(edge),
_ => false
};
if (selectedElementWasRemoved)
{
selection = new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Flow, flow);
lastSelectedNode = null;
propertyContainer?.MarkDirtyRepaint();
SaveWorkspaceState();
}
if (FrameAnimationFlowFocusPolicy.ShouldExitForSelection(graph, focusedFlowId, selection))
{
ExitFlowFocus(false);
return;
}
RefreshFlowChoices();
graphView?.SetFocusedFlow(focusedFlowId);
}
private void RefreshFlowFocusUi()
{
var flow = FrameAnimationFlowFocusPolicy.FindFocusedFlow(graph, focusedFlowId);
if (flowFocusStatusLabel != null)
{
flowFocusStatusLabel.text = flow != null
? $"Focused: {flow.DisplayName} ({flow.Id})"
: "Show All";
flowFocusStatusLabel.tooltip = flowFocusStatusLabel.text;
flowFocusStatusLabel.style.color = flow != null
? new StyleColor(new Color(0.45f, 0.78f, 1f))
: new StyleColor(Color.gray);
}
if (exitFlowFocusButton != null)
{
exitFlowFocusButton.style.display = flow != null ? DisplayStyle.Flex : DisplayStyle.None;
}
}
private void OnCanvasSelectionSetChanged(IReadOnlyList<FrameAnimationEditorSelection> selections)
{
if (FrameAnimationFlowFocusPolicy.ShouldExitForSelectionSet(graph, focusedFlowId, selections))
{
ExitFlowFocus(false);
}
}
private void ShowAllNodes()
{
ExitFlowFocus(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 =>
{
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()
{
RefreshGraphNavigation();
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 void RefreshGraphNavigation()
{
var hasGraph = graph != null;
graphPropertiesButton?.SetEnabled(hasGraph);
graphBreadcrumbButton?.SetEnabled(hasGraph);
if (graphBreadcrumbButton == null)
{
return;
}
var graphName = !hasGraph
? "Graph"
: string.IsNullOrWhiteSpace(graph.DisplayName) ? graph.Id : graph.DisplayName;
graphBreadcrumbButton.text = $"Graph: {graphName}";
graphBreadcrumbButton.tooltip = hasGraph
? $"查看和编辑 {graphName} 的 Graph 参数"
: "未选择 Graph";
}
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())
{
previewCoordinator.RefreshTarget(false);
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 ? "Graph Imported Clip" :
clip.HasStandaloneImportSource ? "Source-linked External 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");
}
else if (clip.HasStandaloneImportSource)
{
EditorGUILayout.LabelField("Source Tag",
string.IsNullOrEmpty(clip.StandaloneImportSource.LastImportedTagName)
? "<all frames>"
: clip.StandaloneImportSource.LastImportedTagName);
if (GUILayout.Button("Open Standalone Clip Editor"))
{
FrameClipEditorWindow.Open(clip);
}
}
if (clipSerializedObject.ApplyModifiedProperties())
{
EditorUtility.SetDirty(clip);
}
if (!clip.IsImported && !clip.HasStandaloneImportSource)
{
frameList.DoLayoutList();
if (clipSerializedObject.ApplyModifiedProperties())
{
EditorUtility.SetDirty(clip);
}
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));
});
}
}
else if (clip.HasStandaloneImportSource)
{
using (new EditorGUI.DisabledScope(true))
{
frameList.DoLayoutList();
}
}
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)
{
var editable = !clip.IsImported && !clip.HasStandaloneImportSource;
if (frameListClip == clip && frameList != null && frameListEditable == editable)
{
return;
}
frameListClip = clip;
frameListEditable = editable;
clipSerializedObject = new SerializedObject(clip);
var frames = clipSerializedObject.FindProperty("frames");
frameList = new ReorderableList(clipSerializedObject, frames, editable, true, editable, editable)
{
elementHeight = 72f,
drawHeaderCallback = rect => EditorGUI.LabelField(rect,
editable ? "Frames (frameName/sourceIndex are read-only)" : "Frames (source-managed, 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
{
EnterFlowFocus(flow.Id);
OnCanvasGraphChanged();
}
}
}
if (GUILayout.Button("Focus Flow On Canvas"))
{
EnterFlowFocus(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
{
ExitFlowFocus(false);
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 (source.Document != null)
{
var aliasCount = source.Document.Frames.Count - source.Document.SpriteSlots.Count;
EditorGUILayout.LabelField(
$"逻辑帧 {source.Document.Frames.Count} · SpriteSlot {source.Document.SpriteSlots.Count} · 共享别名 {aliasCount}",
EditorStyles.miniLabel);
}
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, "确定");
}
previewCoordinator.RefreshTarget(true);
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)
{
EnterFlowFocus(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;
var nodePolicy = Enum.TryParse(
FrameAnimationWorkspaceState.GetString(graph, "NodePreviewPolicy", "SelectedOnly"),
out NodePreviewPolicy restoredNodePolicy)
? restoredNodePolicy : NodePreviewPolicy.SelectedOnly;
var background = Enum.TryParse(
FrameAnimationWorkspaceState.GetString(graph, "PreviewBackground", "Checkerboard"),
out FrameAnimationPreviewBackground restoredBackground)
? restoredBackground : FrameAnimationPreviewBackground.Checkerboard;
var zoomMode = Enum.TryParse(
FrameAnimationWorkspaceState.GetString(graph, "PreviewZoomMode", "Fit"),
out FrameAnimationPreviewZoomMode restoredZoom)
? restoredZoom : FrameAnimationPreviewZoomMode.Fit;
previewCoordinator.NodePolicy = nodePolicy;
previewCoordinator.Background = background;
previewCoordinator.ZoomMode = zoomMode;
previewCoordinator.PreviewSpeed = Mathf.Clamp(
FrameAnimationWorkspaceState.GetFloat(graph, "PreviewSpeed", 1f), 0.25f, 4f);
previewCoordinator.ManualZoom = Mathf.Clamp(
FrameAnimationWorkspaceState.GetFloat(graph, "PreviewManualZoom", 1f), 0.25f, 8f);
bottomPanel.style.height = FrameAnimationWorkspaceState.GetFloat(graph, "BottomHeight", 230f);
ApplyBottomState();
RestoreCanvasView();
RefreshPreviewUi();
}
private void RestoreCanvasView()
{
if (graph == null || graphView == null) return;
var position = new Vector3(
FrameAnimationWorkspaceState.GetFloat(graph, "CanvasX", 0f),
FrameAnimationWorkspaceState.GetFloat(graph, "CanvasY", 0f),
0f);
var scale = FrameAnimationWorkspaceState.GetFloat(graph, "CanvasScale", 1f);
graphView.RestoreViewTransform(position, scale);
}
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, "NodePreviewPolicy", previewCoordinator.NodePolicy.ToString());
FrameAnimationWorkspaceState.SetString(graph, "PreviewBackground", previewCoordinator.Background.ToString());
FrameAnimationWorkspaceState.SetString(graph, "PreviewZoomMode", previewCoordinator.ZoomMode.ToString());
FrameAnimationWorkspaceState.SetFloat(graph, "PreviewSpeed", previewCoordinator.PreviewSpeed);
FrameAnimationWorkspaceState.SetFloat(graph, "PreviewManualZoom", previewCoordinator.ManualZoom);
}
private void OnProjectDataChanged()
{
previewCoordinator.RefreshTarget(true);
if (graph == null)
{
RestoreLastGraph();
}
if (graph != null)
{
var previouslySelectedFlow = selection.Value as AnimationFlow;
RestoreSelection();
if (previouslySelectedFlow != null && graph.Flows.Contains(previouslySelectedFlow))
{
selection = new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Flow, previouslySelectedFlow);
}
lastSelectedNode = selection.Value as AnimationNode;
RevalidateFlowFocus();
}
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)
{
if (!AssetDatabase.IsSubAsset(clip))
{
FrameClipEditorWindow.Open(clip);
return true;
}
var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip);
if (owners.Count == 1)
{
FrameAnimationGraphEditorWindow.Open(owners[0]);
return true;
}
}
return false;
}
}
}