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

3433 lines
155 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 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 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<FrameAnimationBrowserEntry> browserEntries = new List<FrameAnimationBrowserEntry>();
private ToolbarSearchField searchField;
private EnumField filterField;
private EnumField sortField;
private VisualElement propertyContainer;
private VisualElement bottomContainer;
private VisualElement leftPanel;
private VisualElement rightPanel;
private VisualElement bottomPanel;
private VisualElement globalToolbarHost;
private VisualElement mainHost;
private VisualElement bottomHost;
private readonly List<ToolbarButton> resourceTabButtons = new List<ToolbarButton>();
private readonly List<ToolbarButton> bottomTabButtons = new List<ToolbarButton>();
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 Dictionary<Button, Image> previewButtonImages = new Dictionary<Button, Image>();
private static readonly HashSet<string> MissingPreviewIconWarnings = new HashSet<string>();
private readonly List<Label> previewTargetLabels = new List<Label>();
private VisualElement clipPreviewPanel;
private FrameAnimationPreviewElement clipPreviewElement;
private Label selectionPreviewTitle;
private Label clipPreviewStatus;
private EnumField previewBackgroundField;
private EnumField previewZoomField;
private Slider manualZoomSlider;
private PopupField<string> previewSpeedField;
private ToolbarButton bottomCollapseButton;
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 visualTree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
"Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.uxml");
if (visualTree != null)
{
visualTree.CloneTree(rootVisualElement);
}
else
{
var fallback = new VisualElement { name = "fa-workbench" };
fallback.AddToClassList("fa-workbench");
fallback.Add(globalToolbarHost = new VisualElement { name = "global-toolbar-host" });
fallback.Add(mainHost = new VisualElement { name = "main-host" });
fallback.Add(bottomHost = new VisualElement { name = "bottom-host" });
rootVisualElement.Add(fallback);
}
var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(
"Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.uss");
if (styleSheet != null) rootVisualElement.styleSheets.Add(styleSheet);
globalToolbarHost = rootVisualElement.Q<VisualElement>("global-toolbar-host");
mainHost = rootVisualElement.Q<VisualElement>("main-host");
bottomHost = rootVisualElement.Q<VisualElement>("bottom-host");
resourceTabButtons.Clear();
bottomTabButtons.Clear();
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 _);
RefreshBottomPanel();
RefreshBrowser();
RefreshGraphView();
}
UpdateDirtyLabel();
}
private void BuildToolbar()
{
var toolbar = new Toolbar();
toolbar.AddToClassList("fa-global-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(CreateToolbarSeparator());
toolbar.Add(new ToolbarButton(CreateGraph) { text = "Create" });
var saveButton = new ToolbarButton(SaveGraph) { text = "Save" };
saveButton.AddToClassList("fa-button--primary");
toolbar.Add(saveButton);
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" });
globalToolbarHost.Add(toolbar);
}
private void BuildMainArea()
{
var main = new VisualElement
{
style =
{
flexDirection = FlexDirection.Row,
flexGrow = 1f,
overflow = Overflow.Hidden
}
};
main.AddToClassList("fa-main");
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))
}
};
canvas.AddToClassList("fa-canvas-panel");
var canvasToolbar = new Toolbar { name = "canvas-toolbar" };
canvasToolbar.AddToClassList("fa-canvas-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(CreateToolbarSeparator());
var viewLabel = new Label("View");
viewLabel.AddToClassList("fa-toolbar-label");
canvasToolbar.Add(viewLabel);
flowFocusField = new PopupField<string>(new List<string> { "All Flows" }, 0)
{
name = "flow-focus-field",
style = { minWidth = 150f, maxWidth = 260f, flexShrink = 1f }
};
flowFocusField.RegisterValueChangedCallback(evt =>
{
if (evt.newValue == "All Flows")
{
ExitFlowFocus(false);
}
else
{
var flow = graph?.Flows.FirstOrDefault(item => item != null &&
FlowChoiceLabel(item) == evt.newValue);
EnterFlowFocus(flow?.Id);
}
});
canvasToolbar.Add(flowFocusField);
canvasToolbar.Add(new ToolbarSpacer { style = { flexGrow = 1f } });
canvasToolbar.Add(new ToolbarButton(() => graphView?.FrameSelection())
{
text = "Frame Selection",
tooltip = "Frame the current canvas selection"
});
var canvasActions = new ToolbarMenu { text = "Canvas" };
canvasActions.menu.AppendAction("Frame All Visible", _ => graphView?.FrameAll());
canvasActions.menu.AppendAction("Frame Current Flow", _ => graphView?.FrameCurrentFlow(focusedFlowId));
canvasActions.menu.AppendAction("Auto Layout", _ => AutoLayoutCanvas());
canvasActions.menu.AppendAction("Create Flow From Selection", _ => CreateFlowFromCanvasSelection());
canvasActions.menu.AppendSeparator();
canvasActions.menu.AppendAction("Node Preview/Static", _ => SetNodePreviewPolicy(NodePreviewPolicy.Static),
_ => previewCoordinator.NodePolicy == NodePreviewPolicy.Static
? DropdownMenuAction.Status.Checked : DropdownMenuAction.Status.Normal);
canvasActions.menu.AppendAction("Node Preview/Selected Only", _ => SetNodePreviewPolicy(NodePreviewPolicy.SelectedOnly),
_ => previewCoordinator.NodePolicy == NodePreviewPolicy.SelectedOnly
? DropdownMenuAction.Status.Checked : DropdownMenuAction.Status.Normal);
canvasActions.menu.AppendAction("Node Preview/All Visible", _ => SetNodePreviewPolicy(NodePreviewPolicy.AllVisible),
_ => previewCoordinator.NodePolicy == NodePreviewPolicy.AllVisible
? DropdownMenuAction.Status.Checked : DropdownMenuAction.Status.Normal);
canvasToolbar.Add(canvasActions);
canvas.Add(canvasToolbar);
graphView = new FrameAnimationGraphView();
graphView.SelectionRequested += value => SetSelection(value, false);
graphView.MultiSelectionChanged += nodes =>
{
multiSelectedNodes = nodes ?? Array.Empty<AnimationNode>();
RefreshInspector();
};
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
}
};
canvasLabel.AddToClassList("fa-empty-state");
canvas.Add(canvasLabel);
main.Add(canvas);
rightPanel = new VisualElement
{
style =
{
width = 338f,
minWidth = 260f,
maxWidth = 650f,
flexShrink = 0f,
flexDirection = FlexDirection.Column
}
};
rightPanel.AddToClassList("fa-panel");
rightPanel.Add(CreatePanelHeader("Properties"));
clipPreviewPanel = BuildSelectionPreviewPanel();
clipPreviewPanel.style.display = DisplayStyle.None;
rightPanel.Add(clipPreviewPanel);
propertyContainer = new ScrollView(ScrollViewMode.Vertical) { style = { flexGrow = 1f } };
propertyContainer.AddToClassList("fa-inspector-scroll");
rightPanel.Add(propertyContainer);
main.Add(CreateVerticalResizer(rightPanel, 260f, 650f, "RightWidth", resizeFromLeft: true));
main.Add(rightPanel);
mainHost.Add(main);
}
private Toolbar BuildPreviewTransport()
{
var controls = new Toolbar { name = "preview-transport" };
controls.AddToClassList("fa-preview-controls");
controls.Add(CreatePreviewButton("preview-restart", previewCoordinator.Restart, "Restart",
"d_Refresh", "Refresh"));
controls.Add(CreatePreviewButton("preview-previous-frame", () => previewCoordinator.Step(-1),
"Previous Frame", "Animation.PrevKey"));
var play = CreatePreviewButton("preview-play-pause", PreviewPlayPause, "Play", "PlayButton");
previewPlayButtons.Add(play);
controls.Add(play);
controls.Add(CreatePreviewButton("preview-next-frame", () => previewCoordinator.Step(1),
"Next Frame", "Animation.NextKey"));
controls.Add(CreatePreviewButton("preview-stop", previewCoordinator.Stop, "Stop",
"Assets/Editor/FrameAnimation/Icons/PreviewStop.png"));
var timeline = new Slider(0f, 1f)
{
name = "preview-timeline"
};
timeline.AddToClassList("fa-preview-timeline");
timeline.RegisterValueChangedCallback(evt =>
{
if (!updatingPreviewUi) previewCoordinator.Seek(evt.newValue);
});
previewTimelineSliders.Add(timeline);
controls.Add(timeline);
var time = new Label("0 / 0 ms") { name = "preview-time" };
time.AddToClassList("fa-preview-time");
previewTimeLabels.Add(time);
controls.Add(time);
return controls;
}
private ToolbarButton CreatePreviewButton(
string name,
Action action,
string tooltip,
params string[] iconSources)
{
var button = new ToolbarButton(action) { name = name, tooltip = tooltip };
button.AddToClassList("fa-preview-button");
var image = new Image { pickingMode = PickingMode.Ignore };
image.AddToClassList("fa-preview-button__icon");
button.Add(image);
previewButtonImages[button] = image;
SetPreviewButtonIcon(button, tooltip, iconSources);
return button;
}
private void SetPreviewButtonIcon(Button button, string tooltip, params string[] iconSources)
{
button.tooltip = tooltip;
if (!previewButtonImages.TryGetValue(button, out var image)) return;
image.image = null;
foreach (var iconSource in iconSources)
{
var icon = iconSource.StartsWith("Assets/", StringComparison.Ordinal)
? AssetDatabase.LoadAssetAtPath<Texture2D>(iconSource)
: EditorGUIUtility.IconContent(iconSource)?.image;
if (icon == null) continue;
image.image = icon;
return;
}
if (MissingPreviewIconWarnings.Add(tooltip))
{
var attempted = iconSources.Length > 0 ? $" Tried: {string.Join(", ", iconSources)}." : string.Empty;
Debug.LogWarning($"Frame Animation Preview: no reliable Unity editor icon is available for " +
$"'{tooltip}'.{attempted}");
}
}
private VisualElement BuildSelectionPreviewPanel()
{
var panel = new VisualElement { name = "selection-preview-panel" };
panel.AddToClassList("fa-clip-preview-panel");
panel.AddToClassList("fa-selection-preview-panel");
var header = new VisualElement();
header.AddToClassList("fa-selection-preview-header");
selectionPreviewTitle = new Label("Selection Preview");
selectionPreviewTitle.name = "selection-preview-title";
selectionPreviewTitle.AddToClassList("fa-selection-preview-title");
header.Add(selectionPreviewTitle);
var targetLabel = new Label("Preview: None");
targetLabel.AddToClassList("fa-selection-preview-target");
previewTargetLabels.Add(targetLabel);
header.Add(targetLabel);
panel.Add(header);
clipPreviewElement = new FrameAnimationPreviewElement { name = "selection-preview-element" };
panel.Add(clipPreviewElement);
panel.Add(BuildPreviewTransport());
var settingsRow = new VisualElement();
settingsRow.AddToClassList("fa-preview-settings");
previewSpeedField = new PopupField<string>(
new List<string> { "0.25×", "0.5×", "1×", "2×", "4×" }, "1×");
previewSpeedField.RegisterValueChangedCallback(evt =>
{
previewCoordinator.PreviewSpeed = ParsePreviewSpeed(evt.newValue);
SaveWorkspaceState();
});
settingsRow.Add(CreatePreviewSetting("Speed", previewSpeedField));
previewBackgroundField = new EnumField(FrameAnimationPreviewBackground.Checkerboard);
previewBackgroundField.RegisterValueChangedCallback(evt =>
{
previewCoordinator.Background = (FrameAnimationPreviewBackground)evt.newValue;
SaveWorkspaceState();
RefreshPreviewUi();
});
settingsRow.Add(CreatePreviewSetting("Background", previewBackgroundField));
panel.Add(settingsRow);
var zoomRow = new VisualElement();
zoomRow.AddToClassList("fa-preview-settings");
previewZoomField = new EnumField(FrameAnimationPreviewZoomMode.Fit);
previewZoomField.RegisterValueChangedCallback(evt =>
{
previewCoordinator.ZoomMode = (FrameAnimationPreviewZoomMode)evt.newValue;
SaveWorkspaceState();
RefreshPreviewUi();
});
zoomRow.Add(CreatePreviewSetting("Zoom", 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 static VisualElement CreatePreviewSetting(string labelText, VisualElement field)
{
var setting = new VisualElement();
setting.AddToClassList("fa-preview-setting");
setting.Add(new Label(labelText));
field.AddToClassList("fa-preview-setting__field");
setting.Add(field);
return setting;
}
private void SetNodePreviewPolicy(NodePreviewPolicy policy)
{
previewCoordinator.SetNodePolicy(policy);
SaveWorkspaceState();
RefreshPreviewUi();
}
private void PreviewPlayPause()
{
if (previewCoordinator.State == FrameAnimationPreviewState.Playing)
{
previewCoordinator.Pause();
return;
}
if (previewCoordinator.TargetKind == FrameAnimationPreviewTargetKind.Flow)
{
validationIssues = FrameAnimationEditorValidationService.Validate(graph, false, out _);
RefreshBrowser();
RefreshBottomPanel();
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)
{
var isPlaying = previewCoordinator.State == FrameAnimationPreviewState.Playing;
SetPreviewButtonIcon(button, isPlaying ? "Pause" : "Play",
isPlaying ? "PauseButton" : "PlayButton");
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 = previewCoordinator.TargetKind != FrameAnimationPreviewTargetKind.None &&
previewCoordinator.Target != null
? DisplayStyle.Flex : DisplayStyle.None;
}
if (selectionPreviewTitle != null)
{
selectionPreviewTitle.text = previewCoordinator.TargetKind switch
{
FrameAnimationPreviewTargetKind.Clip => "Clip Preview",
FrameAnimationPreviewTargetKind.Node => "Node Preview",
FrameAnimationPreviewTargetKind.Flow => "Flow Preview",
_ => "Selection Preview"
};
}
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);
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 => $"Flow: {flow.DisplayName}",
_ => "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 = 286f,
minWidth = 220f,
maxWidth = 520f,
flexShrink = 0f,
flexDirection = FlexDirection.Column
}
};
panel.AddToClassList("fa-panel");
var tabs = new Toolbar();
tabs.AddToClassList("fa-resource-tabs");
AddResourceTab(tabs, ResourceTab.Clips, "Clips");
AddResourceTab(tabs, ResourceTab.Flows, "Flows");
AddResourceTab(tabs, ResourceTab.Sources, "Sources");
panel.Add(tabs);
var tools = new VisualElement();
tools.AddToClassList("fa-resource-tools");
searchField = new ToolbarSearchField();
searchField.RegisterValueChangedCallback(_ =>
{
SaveWorkspaceState();
RefreshBrowser();
});
tools.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);
tools.Add(controls);
panel.Add(tools);
resourceList = new ListView(browserEntries, 48f, MakeBrowserItem, BindBrowserItem)
{
selectionType = SelectionType.Single,
virtualizationMethod = CollectionVirtualizationMethod.DynamicHeight,
style = { flexGrow = 1f }
};
resourceList.AddToClassList("fa-resource-list");
resourceList.selectionChanged += OnBrowserSelectionChanged;
resourceList.itemsChosen += chosen =>
{
var entry = chosen.Cast<FrameAnimationBrowserEntry>().FirstOrDefault(item => !item.IsHeader);
if (entry != null)
{
SetSelection(entry.Selection);
}
};
panel.Add(resourceList);
var actions = new VisualElement { style = { flexDirection = FlexDirection.Row } };
actions.AddToClassList("fa-resource-actions");
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 = 210f,
minHeight = 28f,
maxHeight = 520f,
flexShrink = 0f,
borderTopWidth = 1f,
borderTopColor = new StyleColor(Color.black)
}
};
bottomPanel.AddToClassList("fa-bottom-panel");
var toolbar = new Toolbar();
toolbar.AddToClassList("fa-bottom-toolbar");
var tabs = new VisualElement();
tabs.AddToClassList("fa-bottom-tabs");
AddBottomTab(tabs, BottomTab.ImportDiff, "Import Diff");
AddBottomTab(tabs, BottomTab.Validation, "Validation");
toolbar.Add(tabs);
toolbar.Add(new ToolbarSpacer { style = { flexGrow = 1f } });
bottomCollapseButton = new ToolbarButton(ToggleBottom)
{
name = "bottom-collapse-button",
text = "▾",
tooltip = "Collapse results"
};
bottomCollapseButton.AddToClassList("fa-bottom-collapse");
toolbar.Add(bottomCollapseButton);
bottomPanel.Add(toolbar);
bottomContainer = new ScrollView(ScrollViewMode.Vertical) { style = { flexGrow = 1f } };
bottomContainer.AddToClassList("fa-bottom-scroll");
bottomPanel.Add(bottomContainer);
var resizer = CreateHorizontalResizer(bottomPanel, 120f, 520f, "BottomHeight");
bottomHost.Add(resizer);
bottomHost.Add(bottomPanel);
}
private static VisualElement CreatePanelHeader(string text)
{
var label = new Label(text);
label.AddToClassList("fa-panel-header");
return label;
}
private static VisualElement CreateToolbarSeparator()
{
var separator = new VisualElement();
separator.AddToClassList("fa-toolbar-separator");
return separator;
}
private void AddResourceTab(Toolbar toolbar, ResourceTab tab, string text)
{
var button = new ToolbarButton(() => SetResourceTab(tab, true))
{
name = $"resource-tab-{tab.ToString().ToLowerInvariant()}",
text = text,
userData = tab
};
resourceTabButtons.Add(button);
toolbar.Add(button);
}
private void AddBottomTab(VisualElement toolbar, BottomTab tab, string text)
{
var button = new ToolbarButton(() => SetBottomTab(tab))
{
name = tab == BottomTab.ImportDiff ? "bottom-tab-import-diff" : "bottom-tab-validation",
text = text,
userData = tab
};
button.AddToClassList("fa-bottom-tab");
bottomTabButtons.Add(button);
toolbar.Add(button);
}
private void RefreshTabStyles()
{
foreach (var button in resourceTabButtons)
{
button.EnableInClassList("fa-tab--active", button.userData is ResourceTab tab && tab == resourceTab);
}
foreach (var button in bottomTabButtons)
{
if (!(button.userData is BottomTab tab)) continue;
button.EnableInClassList("fa-tab--active", tab == bottomTab);
var count = tab == BottomTab.Validation ? validationIssues.Count : ImportDiffCount();
var label = tab == BottomTab.Validation ? "Validation" : "Import Diff";
button.text = count > 0 ? $"{label} {count}" : label;
}
}
private int ImportDiffCount()
{
return importPreview?.Sources.Sum(source =>
source.Issues.Count + source.ClipDiffs.Count + source.SpriteDiffs.Count) ?? 0;
}
private static bool SelectionMatches(
FrameAnimationEditorSelection first,
FrameAnimationEditorSelection second) =>
first.Kind == second.Kind && ReferenceEquals(first.Value, second.Value);
private VisualElement MakeBrowserItem()
{
var row = new FrameAnimationResourceRowElement();
row.RegisterCallback<PointerDownEvent>(evt =>
{
if (evt.button == 0 && row.userData is FrameAnimationBrowserEntry entry &&
entry.Selection.Kind == FrameAnimationEditorSelectionKind.Clip && !entry.IsHeader)
{
browserDragPending = true;
browserDragStart = evt.position;
}
});
row.RegisterCallback<PointerMoveEvent>(evt =>
{
if (!browserDragPending || Vector2.Distance(browserDragStart, evt.position) < 5f ||
!(row.userData is FrameAnimationBrowserEntry 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}");
});
row.RegisterCallback<PointerUpEvent>(_ => browserDragPending = false);
return row;
}
private void BindBrowserItem(VisualElement element, int index)
{
var row = (FrameAnimationResourceRowElement)element;
if (index < 0 || index >= browserEntries.Count)
{
row.Bind(null, false);
return;
}
var entry = browserEntries[index];
row.Bind(entry, SelectionMatches(entry.Selection, selection));
}
private void OnBrowserSelectionChanged(IEnumerable<object> values)
{
var entry = values.Cast<FrameAnimationBrowserEntry>().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();
RefreshInspector();
RefreshBottomPanel();
}
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();
RefreshTabStyles();
}
private void SetBottomTab(BottomTab tab)
{
bottomTab = tab;
bottomCollapsed = false;
ApplyBottomState();
SaveWorkspaceState();
RefreshTabStyles();
RefreshBottomPanel();
}
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;
RefreshInspector();
resourceList?.RefreshItems();
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();
RefreshTabStyles();
}
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 FrameAnimationBrowserEntry
{
Kind = FrameAnimationBrowserEntryKind.Header,
Title = 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 refs = FrameAnimationResourceQuery.ReferenceCount(graph, clip);
var issueState = GetIssueState(FrameAnimationEditorSelectionKind.Clip, clip);
var missing = clip.IsImported && clip.ImportInfo.IsMissingFromSource;
browserEntries.Add(new FrameAnimationBrowserEntry
{
Kind = FrameAnimationBrowserEntryKind.Clip,
Title = clip.DisplayName,
Id = clip.Id,
TypeLabel = clip.IsImported ? "IMPORTED" : "MANUAL",
Meta = $"{clip.FrameCount} frames · {clip.TotalDurationMs} ms · {source} · refs {refs}",
StatusLabel = missing ? "MISSING" : issueState == FrameAnimationBrowserIssueState.Error
? "ERROR" : issueState == FrameAnimationBrowserIssueState.Warning ? "WARNING" : string.Empty,
IssueState = missing && issueState == FrameAnimationBrowserIssueState.None
? FrameAnimationBrowserIssueState.Warning : issueState,
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))
{
var issueState = GetIssueState(FrameAnimationEditorSelectionKind.Flow, flow);
browserEntries.Add(new FrameAnimationBrowserEntry
{
Kind = FrameAnimationBrowserEntryKind.Flow,
Title = flow.DisplayName,
Id = flow.Id,
TypeLabel = "FLOW",
Meta = $"entry {flow.EntryNodeId}",
StatusLabel = issueState == FrameAnimationBrowserIssueState.Error
? "ERROR" : issueState == FrameAnimationBrowserIssueState.Warning ? "WARNING" : string.Empty,
AccentColor = FrameAnimationFlowColorUtility.ResolveRaw(graph, flow),
IssueState = issueState,
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);
var issueState = GetIssueState(FrameAnimationEditorSelectionKind.Source, source);
browserEntries.Add(new FrameAnimationBrowserEntry
{
Kind = FrameAnimationBrowserEntryKind.Source,
Title = source.DisplayName,
Id = source.InternalId,
TypeLabel = "SOURCE",
Meta = $"{count} clips · {(source.IsEnabled ? "Enabled" : "Disabled")} · {(source.ManageSpriteSlicing ? "Writable" : "Read Only")}",
StatusLabel = issueState == FrameAnimationBrowserIssueState.Error
? "ERROR" : issueState == FrameAnimationBrowserIssueState.Warning ? "WARNING" : string.Empty,
IssueState = issueState,
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();
RefreshInspector();
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> { "All Flows" };
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) : "All Flows");
}
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;
RefreshInspector();
SaveWorkspaceState();
}
if (FrameAnimationFlowFocusPolicy.ShouldExitForSelection(graph, focusedFlowId, selection))
{
ExitFlowFocus(false);
return;
}
RefreshFlowChoices();
graphView?.SetFocusedFlow(focusedFlowId);
}
private void RefreshFlowFocusUi()
{
var flow = FrameAnimationFlowFocusPolicy.FindFocusedFlow(graph, focusedFlowId);
if (flowFocusField != null)
{
flowFocusField.tooltip = flow != null
? $"Focused Flow: {flow.DisplayName} ({flow.Id})"
: "Show every Flow without assigning shared paths to one color";
if (flow != null)
{
var palette = FrameAnimationFlowColorUtility.CreatePalette(
FrameAnimationFlowColorUtility.ResolveRaw(graph, flow));
flowFocusField.style.borderLeftWidth = 3f;
flowFocusField.style.borderLeftColor = palette.Stroke;
}
else
{
flowFocusField.style.borderLeftWidth = 0f;
flowFocusField.style.borderLeftColor = StyleKeyword.Null;
}
}
}
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 FrameAnimationBrowserIssueState GetIssueState(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 FrameAnimationBrowserIssueState.Error;
}
return matching.Length > 0 ? FrameAnimationBrowserIssueState.Warning : FrameAnimationBrowserIssueState.None;
}
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 RefreshInspector()
{
if (propertyContainer == null) return;
propertyContainer.Clear();
if (graph == null)
{
propertyContainer.Add(CreateCallout("请选择 FrameAnimationGraph。"));
return;
}
if (multiSelectedNodes.Count > 1)
{
AddInspectorTitle("Multiple Nodes", $"{multiSelectedNodes.Count} selected");
var section = CreateInspectorSection("Selection", out var body);
body.Add(CreateCallout("可对当前选中的节点执行自动布局或安全删除。"));
body.Add(new Button(() =>
{
var positions = FrameAnimationGraphLayoutService.Calculate(graph, multiSelectedNodes);
FrameAnimationGraphMutationService.SetNodePositions(
graph, positions, "Auto Layout Selected Frame Animation Nodes");
graphView?.ApplyCalculatedPositions(positions);
}) { text = "Auto Layout Selected Nodes" });
var delete = new Button(() => graphView?.RequestDeleteNodes(multiSelectedNodes))
{ text = "Delete Selected Nodes" };
delete.AddToClassList("fa-button--danger");
body.Add(delete);
propertyContainer.Add(section);
return;
}
switch (selection.Kind)
{
case FrameAnimationEditorSelectionKind.Clip:
BuildClipInspector(selection.Value as FrameClip);
break;
case FrameAnimationEditorSelectionKind.Flow:
BuildFlowInspector(selection.Value as AnimationFlow);
break;
case FrameAnimationEditorSelectionKind.Source:
BuildSourceInspector(selection.Value as FrameAnimationImportSource);
break;
case FrameAnimationEditorSelectionKind.Node:
BuildNodeInspector(selection.Value as AnimationNode);
break;
case FrameAnimationEditorSelectionKind.Edge:
BuildEdgeInspector(selection.Value as AnimationEdge);
break;
default:
BuildGraphInspector();
break;
}
}
private void AddInspectorTitle(string name, string id)
{
var title = new VisualElement();
title.AddToClassList("fa-inspector-title");
var nameLabel = new Label(name);
nameLabel.AddToClassList("fa-inspector-title__name");
var idLabel = new Label(id ?? string.Empty);
idLabel.AddToClassList("fa-inspector-title__id");
title.Add(nameLabel);
title.Add(idLabel);
propertyContainer.Add(title);
}
private static VisualElement CreateInspectorSection(string title, out VisualElement body)
{
var section = new VisualElement();
section.AddToClassList("fa-section");
var titleLabel = new Label(title);
titleLabel.AddToClassList("fa-section__title");
body = new VisualElement();
body.AddToClassList("fa-section__body");
section.Add(titleLabel);
section.Add(body);
return section;
}
private static Label CreateCallout(string text, string modifier = null)
{
var label = new Label(text);
label.AddToClassList("fa-callout");
if (!string.IsNullOrEmpty(modifier)) label.AddToClassList(modifier);
return label;
}
private static void AddReadOnly(VisualElement body, string label, object value)
{
var row = new VisualElement();
row.AddToClassList("fa-readonly-row");
var name = new Label(label);
name.AddToClassList("fa-readonly-row__label");
var content = new Label(value?.ToString() ?? string.Empty);
content.AddToClassList("fa-readonly-row__value");
row.Add(name);
row.Add(content);
body.Add(row);
}
private static PropertyField AddBoundProperty(
VisualElement body,
SerializedObject serialized,
SerializedProperty property,
string label = null,
bool enabled = true)
{
var field = new PropertyField(property, label ?? property.displayName);
field.SetEnabled(enabled);
body.Add(field);
field.Bind(serialized);
return field;
}
private void TrackInspectorEdits(VisualElement element, UnityEngine.Object target)
{
element.RegisterCallback<FocusOutEvent>(_ =>
{
if (target != null) EditorUtility.SetDirty(target);
previewCoordinator.RefreshTarget(false);
ScheduleLightValidation();
RefreshBrowser();
RefreshGraphView();
RefreshCanvasSummary();
});
}
private void BuildGraphInspector()
{
AddInspectorTitle(graph.DisplayName, graph.Id);
var section = CreateInspectorSection("Graph", out var body);
var serialized = new SerializedObject(graph);
serialized.Update();
AddBoundProperty(body, serialized, serialized.FindProperty("displayName"), "Display Name");
AddReadOnly(body, "ID", graph.Id);
AddReadOnly(body, "Clips", graph.Clips.Count);
AddReadOnly(body, "Flows", graph.Flows.Count);
AddReadOnly(body, "Sources", graph.ImportSources.Count);
var playableIds = new List<string> { string.Empty };
playableIds.AddRange(graph.Clips.Where(clip => clip != null).Select(clip => clip.Id));
playableIds.AddRange(graph.Flows.Where(flow => flow != null).Select(flow => flow.Id));
var labels = playableIds.Select(id => string.IsNullOrEmpty(id) ? "<None>" : id).ToList();
var selectedIndex = Mathf.Max(0, playableIds.IndexOf(graph.Settings.DefaultPlayableId));
var defaultPlayable = new PopupField<string>("Default Playable", labels, selectedIndex);
defaultPlayable.RegisterValueChangedCallback(evt =>
{
var index = labels.IndexOf(evt.newValue);
if (index < 0) return;
Undo.RecordObject(graph, "Set Default Frame Animation Playable");
graph.Settings.SetDefaultPlayableId(playableIds[index]);
EditorUtility.SetDirty(graph);
});
body.Add(defaultPlayable);
var endBehavior = new EnumField("New Manual End Behavior", graph.Settings.NewManualClipDefaultEndBehavior);
endBehavior.RegisterValueChangedCallback(evt =>
{
Undo.RecordObject(graph, "Set New Manual Clip End Behavior");
graph.Settings.SetNewManualClipDefaultEndBehavior((FrameClipEndBehavior)evt.newValue);
EditorUtility.SetDirty(graph);
});
body.Add(endBehavior);
body.Add(new Button(ShowRenameGraph) { text = "Rename Graph ID" });
TrackInspectorEdits(section, graph);
propertyContainer.Add(section);
}
private void BuildClipInspector(FrameClip clip)
{
if (clip == null)
{
propertyContainer.Add(CreateCallout("Clip 已失效。", "fa-callout--warning"));
return;
}
AddInspectorTitle(clip.DisplayName, clip.Id);
var details = CreateInspectorSection(clip.IsImported ? "Imported Clip" : "Manual Clip", out var body);
var serialized = new SerializedObject(clip);
serialized.Update();
AddReadOnly(body, "ID", clip.Id);
AddBoundProperty(body, serialized, serialized.FindProperty("displayName"), "Display Name");
AddBoundProperty(body, serialized, serialized.FindProperty("speed"), "Speed");
AddBoundProperty(body, serialized, serialized.FindProperty("defaultEndBehavior"), "Default End Behavior");
AddReadOnly(body, "Frame Count", clip.FrameCount);
AddReadOnly(body, "Total Duration", clip.TotalDurationMs + " ms");
AddReadOnly(body, "Storage", AssetDatabase.IsSubAsset(clip) ? "Graph sub-asset" : "External .asset");
if (clip.IsImported)
{
AddReadOnly(body, "Source Tag", clip.ImportInfo.SourceTagName);
AddReadOnly(body, "Missing", clip.ImportInfo.IsMissingFromSource ? "Yes" : "No");
}
var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip);
if (!AssetDatabase.IsSubAsset(clip) && owners.Count > 1)
{
body.Add(CreateCallout("共享外部 Manual Clip" + string.Join(", ", owners.Select(owner => owner.name)),
"fa-callout--warning"));
}
TrackInspectorEdits(details, clip);
propertyContainer.Add(details);
var frames = CreateInspectorSection("Frames", out var frameBody);
BuildFrameList(frameBody, clip);
propertyContainer.Add(frames);
var actions = CreateInspectorSection("Actions", out var actionBody);
if (clip.IsImported)
{
actionBody.Add(new Button(() =>
{
var source = graph.ImportSources.FirstOrDefault(item => item != null &&
item.InternalId == clip.ImportInfo.ImportSourceId);
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Source, source));
SetResourceTab(ResourceTab.Sources);
}) { text = "Locate ImportSource" });
actionBody.Add(new Button(() => FrameAnimationManualClipWindow.Show(graph, clip, copied =>
{
RefreshBrowser();
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, copied));
})) { text = "Copy As Manual Clip" });
}
actionBody.Add(new Button(() => ShowRenameClip(clip)) { text = "Rename Clip ID" });
actionBody.Add(new Button(() => LocateClipReferences(clip)) { text = "Locate Node References" });
var remove = new Button(() => ConfirmRemoveClip(clip))
{
text = AssetDatabase.IsSubAsset(clip) ? "Delete Clip Sub-Asset" : "Remove Clip Reference"
};
remove.AddToClassList("fa-button--danger");
actionBody.Add(remove);
propertyContainer.Add(actions);
}
private void BuildFrameList(VisualElement body, FrameClip clip)
{
var indices = Enumerable.Range(0, clip.FrameCount).ToList();
var list = new ListView(indices, 72f,
() => new FrameAnimationFrameRowElement(),
(element, index) => ((FrameAnimationFrameRowElement)element).Bind(
clip, index, clip.IsImported, OnFrameInspectorChanged))
{
selectionType = SelectionType.Single,
style = { height = Mathf.Clamp(clip.FrameCount * 72f + 2f, 74f, 360f) }
};
body.Add(list);
if (clip.IsImported) return;
var controls = new VisualElement { style = { flexDirection = FlexDirection.Row } };
controls.Add(new Button(() => MutateFrames(clip, list.selectedIndex, FrameMutation.Add)) { text = "+" });
controls.Add(new Button(() => MutateFrames(clip, list.selectedIndex, FrameMutation.Duplicate)) { text = "Duplicate" });
controls.Add(new Button(() => MutateFrames(clip, list.selectedIndex, FrameMutation.MoveUp)) { text = "↑" });
controls.Add(new Button(() => MutateFrames(clip, list.selectedIndex, FrameMutation.MoveDown)) { text = "↓" });
var remove = new Button(() => MutateFrames(clip, list.selectedIndex, FrameMutation.Remove)) { text = "" };
remove.AddToClassList("fa-button--danger");
controls.Add(remove);
body.Add(controls);
}
private enum FrameMutation { Add, Duplicate, Remove, MoveUp, MoveDown }
private void MutateFrames(FrameClip clip, int selectedIndex, FrameMutation mutation)
{
if (clip == null || clip.IsImported) return;
var serialized = new SerializedObject(clip);
serialized.Update();
var frames = serialized.FindProperty("frames");
Undo.RecordObject(clip, "Edit Frame Animation Frames");
if (mutation == FrameMutation.Add)
{
var index = frames.arraySize;
frames.InsertArrayElementAtIndex(index);
var frame = frames.GetArrayElementAtIndex(index);
frame.FindPropertyRelative("sprite").objectReferenceValue = null;
frame.FindPropertyRelative("durationMs").intValue = 100;
frame.FindPropertyRelative("frameName").stringValue = string.Empty;
frame.FindPropertyRelative("sourceIndex").intValue = -1;
}
else if (selectedIndex >= 0 && selectedIndex < frames.arraySize)
{
if (mutation == FrameMutation.Duplicate) frames.InsertArrayElementAtIndex(selectedIndex);
else if (mutation == FrameMutation.Remove) frames.DeleteArrayElementAtIndex(selectedIndex);
else if (mutation == FrameMutation.MoveUp && selectedIndex > 0)
frames.MoveArrayElement(selectedIndex, selectedIndex - 1);
else if (mutation == FrameMutation.MoveDown && selectedIndex < frames.arraySize - 1)
frames.MoveArrayElement(selectedIndex, selectedIndex + 1);
}
serialized.ApplyModifiedProperties();
EditorUtility.SetDirty(clip);
OnFrameInspectorChanged();
RefreshInspector();
}
private void OnFrameInspectorChanged()
{
previewCoordinator.RefreshTarget(false);
ScheduleLightValidation();
RefreshBrowser();
RefreshGraphView();
}
private void BuildFlowInspector(AnimationFlow flow)
{
var index = graph.Flows.ToList().IndexOf(flow);
if (flow == null || index < 0)
{
propertyContainer.Add(CreateCallout("Flow 已失效。", "fa-callout--warning"));
return;
}
AddInspectorTitle(flow.DisplayName, flow.Id);
var section = CreateInspectorSection("Animation Flow", out var body);
var serialized = new SerializedObject(graph);
serialized.Update();
var property = serialized.FindProperty("flows").GetArrayElementAtIndex(index);
AddReadOnly(body, "ID", flow.Id);
AddReadOnly(body, "Entry Node", flow.EntryNodeId);
AddBoundProperty(body, serialized, property.FindPropertyRelative("displayName"), "Display Name");
AddBoundProperty(body, serialized, property.FindPropertyRelative("hasEndBehaviorOverride"), "Override End Behavior");
AddBoundProperty(body, serialized, property.FindPropertyRelative("endBehaviorOverride"), "End Behavior");
var color = FrameAnimationFlowColorUtility.ResolveStored(graph, flow);
var colorField = new ColorField("Canvas Color") { value = color, showAlpha = true };
colorField.RegisterValueChangedCallback(evt =>
{
FrameAnimationGraphMutationService.SetFlowColor(graph, flow, evt.newValue);
RefreshBrowser();
RefreshFlowFocusUi();
RefreshGraphView();
});
body.Add(colorField);
AddReadOnly(body, "Entry Candidate", lastSelectedNode != null && graph.Nodes.Contains(lastSelectedNode)
? $"{lastSelectedNode.DisplayName} ({lastSelectedNode.InternalId})"
: "<Select a Node on canvas>");
var setEntry = new Button(() =>
{
if (!FrameAnimationGraphMutationService.SetFlowEntry(graph, flow, lastSelectedNode, out var error))
{
EditorUtility.DisplayDialog("无法修改 Flow 入口", error, "确定");
return;
}
EnterFlowFocus(flow.Id);
OnCanvasGraphChanged();
}) { text = "Set Selected Node As Entry" };
setEntry.SetEnabled(lastSelectedNode != null && graph.Nodes.Contains(lastSelectedNode));
body.Add(setEntry);
TrackInspectorEdits(section, graph);
propertyContainer.Add(section);
var actions = CreateInspectorSection("Actions", out var actionBody);
actionBody.Add(new Button(() =>
{
EnterFlowFocus(flow.Id);
graphView?.FrameCurrentFlow(flow.Id);
}) { text = "Focus Flow On Canvas" });
actionBody.Add(new Button(() => ShowRenameFlow(flow)) { text = "Rename Flow ID" });
var delete = new Button(() => DeleteFlowFromInspector(flow)) { text = "Delete Flow" };
delete.AddToClassList("fa-button--danger");
actionBody.Add(delete);
propertyContainer.Add(actions);
}
private void DeleteFlowFromInspector(AnimationFlow 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, "确定");
return;
}
ExitFlowFocus(false);
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph));
OnCanvasGraphChanged();
}
private void BuildSourceInspector(FrameAnimationImportSource source)
{
var index = graph.ImportSources.ToList().IndexOf(source);
if (source == null || index < 0)
{
propertyContainer.Add(CreateCallout("ImportSource 已失效。", "fa-callout--warning"));
return;
}
AddInspectorTitle(source.DisplayName, source.InternalId);
var section = CreateInspectorSection("Import Source", out var body);
var serialized = new SerializedObject(graph);
serialized.Update();
var property = serialized.FindProperty("importSources").GetArrayElementAtIndex(index);
AddReadOnly(body, "Internal ID", source.InternalId);
AddBoundProperty(body, serialized, property.FindPropertyRelative("displayName"), "Display Name");
AddBoundProperty(body, serialized, property.FindPropertyRelative("isEnabled"), "Enabled");
AddBoundProperty(body, serialized, property.FindPropertyRelative("texture"), "Texture");
AddBoundProperty(body, serialized, property.FindPropertyRelative("asepriteJson"), "Aseprite JSON");
AddBoundProperty(body, serialized, property.FindPropertyRelative("pivot"), "Pivot");
AddBoundProperty(body, serialized, property.FindPropertyRelative("manageSpriteSlicing"), "Manage Sprite Slicing");
AddBoundProperty(body, serialized, property.FindPropertyRelative("defaultNewClipEndBehavior"), "Default Clip End");
AddReadOnly(body, "Last Source Hash", source.LastSourceHash);
AddReadOnly(body, "Associated Clips", graph.Clips.Count(clip => clip != null &&
clip.ImportInfo?.ImportSourceId == source.InternalId));
body.Add(new Button(() => GUIUtility.systemCopyBuffer = source.InternalId) { text = "Copy Internal ID" });
TrackInspectorEdits(section, graph);
propertyContainer.Add(section);
var actions = CreateInspectorSection("Import", out var actionBody);
actionBody.Add(new Button(() =>
{
importPreview = FrameAnimationImportService.PreviewSource(graph, source.InternalId);
SetBottomTab(BottomTab.ImportDiff);
}) { text = "Preview Source" });
actionBody.Add(new Button(() =>
{
importPreview = FrameAnimationImportService.PreviewSource(graph, source.InternalId);
ApplyImportPreview();
}) { text = "Refresh Source" });
var remove = new Button(() => RemoveSourceFromInspector(source)) { text = "Remove ImportSource" };
remove.AddToClassList("fa-button--danger");
actionBody.Add(remove);
propertyContainer.Add(actions);
}
private void RemoveSourceFromInspector(FrameAnimationImportSource source)
{
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)), "确定");
return;
}
if (!EditorUtility.DisplayDialog("删除 ImportSource",
"该操作不会删除 Texture、JSON、Sprite 或 SpriteRect。确认删除来源?", "删除", "取消")) return;
if (!FrameAnimationAssetOperations.RemoveSource(graph, source, out var error))
{
EditorUtility.DisplayDialog("无法删除 ImportSource", error, "确定");
return;
}
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph));
RefreshBrowser();
}
private void BuildNodeInspector(AnimationNode node)
{
if (node == null || !graph.Nodes.Contains(node))
{
propertyContainer.Add(CreateCallout("Node 已失效。", "fa-callout--warning"));
return;
}
AddInspectorTitle(string.IsNullOrEmpty(node.DisplayName) ? node.ClipId : node.DisplayName, node.InternalId);
var section = CreateInspectorSection("Animation Node", out var body);
AddReadOnly(body, "Internal ID", node.InternalId);
var name = new TextField("Display Name") { value = node.DisplayName, isDelayed = true };
name.RegisterValueChangedCallback(evt =>
{
FrameAnimationGraphMutationService.SetNodeDisplayName(graph, node, evt.newValue);
OnCanvasGraphChanged();
});
body.Add(name);
var clips = graph.Clips.Where(clip => clip != null).ToList();
var clipLabels = clips.Select(clip => $"{clip.DisplayName} ({clip.Id})").ToList();
var current = clips.FindIndex(clip => clip.Id == node.ClipId);
if (current < 0) clipLabels.Insert(0, $"<Missing: {node.ClipId}>");
var clipField = new PopupField<string>("Clip", clipLabels, Mathf.Max(0, current));
clipField.RegisterValueChangedCallback(evt =>
{
var selectedIndex = clipLabels.IndexOf(evt.newValue);
if (current < 0) selectedIndex--;
if (selectedIndex < 0 || selectedIndex >= clips.Count) return;
FrameAnimationGraphMutationService.SetNodeClip(graph, node, clips[selectedIndex]);
OnCanvasGraphChanged();
});
body.Add(clipField);
var speedToggle = new Toggle("Override Speed") { value = node.SpeedOverride.HasValue };
var speed = new FloatField("Speed") { value = node.SpeedOverride ?? 1f };
speed.SetEnabled(speedToggle.value);
speedToggle.RegisterValueChangedCallback(evt =>
{
speed.SetEnabled(evt.newValue);
FrameAnimationGraphMutationService.SetNodeSpeed(graph, node, evt.newValue ? speed.value : (float?)null);
OnCanvasGraphChanged();
});
speed.RegisterValueChangedCallback(evt =>
{
if (!speedToggle.value) return;
FrameAnimationGraphMutationService.SetNodeSpeed(graph, node, evt.newValue);
OnCanvasGraphChanged();
});
body.Add(speedToggle);
body.Add(speed);
var endToggle = new Toggle("Override End Behavior") { value = node.EndBehaviorOverride.HasValue };
var end = new EnumField("End Behavior", node.EndBehaviorOverride ?? FrameClipEndBehavior.HoldLastFrame);
end.SetEnabled(endToggle.value);
endToggle.RegisterValueChangedCallback(evt => ApplyNodeEndBehavior(node, evt.newValue,
(FrameClipEndBehavior)end.value, end));
end.RegisterValueChangedCallback(evt =>
{
if (endToggle.value) ApplyNodeEndBehavior(node, true, (FrameClipEndBehavior)evt.newValue, end);
});
body.Add(endToggle);
body.Add(end);
var flows = new FrameAnimationGraphTopology(graph).FindFlowsUsingNode(node.InternalId);
AddReadOnly(body, "Used By Flows", flows.Count == 0 ? "None" : string.Join(", ", flows.Select(flow => flow.Id)));
body.Add(new Button(() => GUIUtility.systemCopyBuffer = node.InternalId) { text = "Copy Internal ID" });
propertyContainer.Add(section);
var actions = CreateInspectorSection("Actions", out var actionBody);
actionBody.Add(new Button(() => ShowCreateFlow(node)) { text = "Create Flow From Node" });
var delete = new Button(() => graphView?.RequestDeleteNodes(new[] { node })) { text = "Delete Node" };
delete.AddToClassList("fa-button--danger");
actionBody.Add(delete);
propertyContainer.Add(actions);
}
private void ApplyNodeEndBehavior(AnimationNode node, bool enabled, FrameClipEndBehavior behavior, VisualElement field)
{
field.SetEnabled(enabled);
var outgoing = graph.Edges.Where(edge => edge != null && edge.FromNodeId == node.InternalId).ToArray();
var removeOutgoing = outgoing.Length == 0 || !enabled || EditorUtility.DisplayDialog(
"终点行为与后继冲突", "该节点已有后继 Edge。设置结束行为会删除该 Edge,是否继续?",
"删除 Edge 并应用", "取消");
if (!removeOutgoing) return;
if (FrameAnimationGraphMutationService.SetNodeEndBehavior(graph, node,
enabled ? behavior : (FrameClipEndBehavior?)null, outgoing.Length > 0, out var error))
{
OnCanvasGraphChanged();
}
else if (!string.IsNullOrEmpty(error))
{
EditorUtility.DisplayDialog("无法修改结束行为", error, "确定");
}
}
private void BuildEdgeInspector(AnimationEdge edge)
{
if (edge == null || !graph.Edges.Contains(edge))
{
propertyContainer.Add(CreateCallout("Edge 已失效。", "fa-callout--warning"));
return;
}
AddInspectorTitle("Animation Edge", edge.InternalId);
var section = CreateInspectorSection("Connection", out var body);
AddReadOnly(body, "From", edge.FromNodeId);
AddReadOnly(body, "To", edge.ToNodeId);
AddReadOnly(body, "Exit", edge.ExitName);
AddReadOnly(body, "Condition", edge.Condition);
propertyContainer.Add(section);
var actions = CreateInspectorSection("Actions", out var actionBody);
var disconnect = new Button(() => graphView?.RequestDeleteEdges(new[] { edge })) { text = "Disconnect Edge" };
disconnect.AddToClassList("fa-button--danger");
actionBody.Add(disconnect);
propertyContainer.Add(actions);
}
private void RefreshBottomPanel()
{
if (bottomContainer == null) return;
bottomContainer.Clear();
if (bottomCollapsed) return;
RefreshTabStyles();
if (bottomTab == BottomTab.ImportDiff) BuildImportDiffPanel();
else BuildValidationPanel();
}
private void BuildValidationPanel()
{
var controls = new VisualElement();
controls.AddToClassList("fa-result-controls");
controls.Add(CreateSeverityToggle("Errors", showErrors, value => showErrors = value));
controls.Add(CreateSeverityToggle("Warnings", showWarnings, value => showWarnings = value));
controls.Add(CreateSeverityToggle("Info", showInfo, value => showInfo = value));
var filter = new EnumField(validationKindFilter) { style = { width = 130f } };
filter.RegisterValueChangedCallback(evt =>
{
validationKindFilter = (ValidationKindFilter)evt.newValue;
SaveWorkspaceState();
RefreshBottomPanel();
});
controls.Add(filter);
var rerun = new Button(ValidateFull) { text = "Run Validation" };
rerun.AddToClassList("fa-button--primary");
controls.Add(rerun);
bottomContainer.Add(controls);
bottomContainer.Add(CreateResultHeader("Severity", "Kind", "Object", "Message / Suggestion"));
foreach (var issue in validationIssues.Where(ShowIssue))
{
var message = string.IsNullOrEmpty(issue.Suggestion)
? issue.Message : issue.Message + " · 建议:" + issue.Suggestion;
var row = CreateResultRow(issue.Severity.ToString(), issue.Selection.Kind.ToString(),
SelectionName(issue.Selection), $"[{issue.Code}] {message}", issue.Severity);
if (issue.Selection.Value != null)
{
row.RegisterCallback<PointerDownEvent>(evt =>
{
if (evt.button != 0) return;
SetSelection(issue.Selection);
SetResourceTab(issue.Selection.Kind switch
{
FrameAnimationEditorSelectionKind.Clip => ResourceTab.Clips,
FrameAnimationEditorSelectionKind.Flow => ResourceTab.Flows,
FrameAnimationEditorSelectionKind.Source => ResourceTab.Sources,
_ => resourceTab
});
});
}
bottomContainer.Add(row);
}
}
private Toggle CreateSeverityToggle(string text, bool value, Action<bool> setter)
{
var toggle = new Toggle(text) { value = value };
toggle.RegisterValueChangedCallback(evt =>
{
setter(evt.newValue);
RefreshBottomPanel();
});
return toggle;
}
private void BuildImportDiffPanel()
{
if (importPreview == null)
{
bottomContainer.Add(CreateCallout("点击 Preview Imports 或来源 Preview 计算导入差异。"));
return;
}
bottomContainer.Add(CreateResultHeader("Status", "Type", "Object", "Summary"));
foreach (var source in importPreview.Sources)
{
var sourceRow = CreateResultRow("SOURCE", "ImportSource", source.Source.DisplayName,
source.Document == null ? "无法读取来源文档" :
$"{source.Document.Frames.Count} logical frames · {source.Document.SpriteSlots.Count} sprite slots",
null);
sourceRow.RegisterCallback<PointerDownEvent>(evt =>
{
if (evt.button != 0) return;
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Source, source.Source));
SetResourceTab(ResourceTab.Sources);
});
bottomContainer.Add(sourceRow);
foreach (var issue in source.Issues)
{
bottomContainer.Add(CreateResultRow(issue.Severity.ToString(), "Import Issue",
issue.TargetId, $"[{issue.Code}] {issue.Message}", issue.Severity));
}
foreach (var diff in source.ClipDiffs)
{
var severity = diff.Kind == FrameAnimationImportChangeKind.Error
? FrameAnimationValidationSeverity.Error
: diff.Kind == FrameAnimationImportChangeKind.Missing
? FrameAnimationValidationSeverity.Warning : (FrameAnimationValidationSeverity?)null;
var row = CreateResultRow(diff.Kind.ToString(), "FrameClip", diff.SourceTagName,
diff.Summary, severity);
if (diff.Clip != null)
{
row.RegisterCallback<PointerDownEvent>(evt =>
{
if (evt.button != 0) return;
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, diff.Clip));
SetResourceTab(ResourceTab.Clips);
});
}
bottomContainer.Add(row);
}
foreach (var diff in source.SpriteDiffs)
{
var severity = diff.Kind == FrameAnimationSpriteChangeKind.Error
? FrameAnimationValidationSeverity.Error : (FrameAnimationValidationSeverity?)null;
bottomContainer.Add(CreateResultRow(diff.Kind.ToString(), "SpriteRect", diff.FrameName,
$"{diff.Summary} {diff.Rect}", severity));
}
}
}
private static VisualElement CreateResultHeader(string first, string second, string third, string fourth)
{
var row = CreateResultCells(first, second, third, fourth);
row.AddToClassList("fa-result-header");
return row;
}
private static VisualElement CreateResultRow(
string first,
string second,
string third,
string fourth,
FrameAnimationValidationSeverity? severity)
{
var row = CreateResultCells(first, second, third, fourth);
row.AddToClassList("fa-result-row");
var severityLabel = row.Q<Label>(className: "fa-result-severity");
if (severity == FrameAnimationValidationSeverity.Error) severityLabel.AddToClassList("fa-severity--error");
else if (severity == FrameAnimationValidationSeverity.Warning) severityLabel.AddToClassList("fa-severity--warning");
else if (severity == FrameAnimationValidationSeverity.Info) severityLabel.AddToClassList("fa-severity--info");
return row;
}
private static VisualElement CreateResultCells(string first, string second, string third, string fourth)
{
var row = new VisualElement();
var firstLabel = new Label(first ?? string.Empty);
firstLabel.AddToClassList("fa-result-severity");
var secondLabel = new Label(second ?? string.Empty);
secondLabel.AddToClassList("fa-result-kind");
var thirdLabel = new Label(third ?? string.Empty);
thirdLabel.AddToClassList("fa-result-object");
var fourthLabel = new Label(fourth ?? string.Empty);
fourthLabel.AddToClassList("fa-result-message");
row.Add(firstLabel);
row.Add(secondLabel);
row.Add(thirdLabel);
row.Add(fourthLabel);
return row;
}
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);
RefreshBottomPanel();
}
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);
RefreshBottomPanel();
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 ? 31f : FrameAnimationWorkspaceState.GetFloat(graph, "BottomHeight", 210f);
bottomContainer.style.display = bottomCollapsed ? DisplayStyle.None : DisplayStyle.Flex;
if (bottomCollapseButton != null)
{
bottomCollapseButton.text = bottomCollapsed ? "▴" : "▾";
bottomCollapseButton.tooltip = bottomCollapsed ? "Expand results" : "Collapse results";
}
RefreshBottomPanel();
}
private VisualElement CreateVerticalResizer(
VisualElement panel,
float min,
float max,
string stateName,
bool resizeFromLeft = false)
{
var resizer = new VisualElement
{
style = { width = 4f }
};
resizer.AddToClassList("fa-resizer--vertical");
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 = 4f }
};
resizer.AddToClassList("fa-resizer--horizontal");
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", 286f);
rightPanel.style.width = FrameAnimationWorkspaceState.GetFloat(graph, "RightWidth", 338f);
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", 210f);
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();
RefreshInspector();
RefreshBottomPanel();
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;
}
}
}