3449 lines
156 KiB
C#
3449 lines
156 KiB
C#
using System;
|
||
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using UnityEditor;
|
||
using UnityEditor.Callbacks;
|
||
using UnityEditor.UIElements;
|
||
using UnityEditorInternal;
|
||
using UnityEngine;
|
||
using UnityEngine.UIElements;
|
||
|
||
namespace AibisDream.FrameAnimation.Editor
|
||
{
|
||
public sealed class FrameAnimationGraphEditorWindow : EditorWindow
|
||
{
|
||
private enum ResourceTab { Clips, Flows, Sources }
|
||
private enum BottomTab { ImportDiff, Validation }
|
||
private enum ValidationKindFilter { All, Graph, Clip, Flow, Source, Node, Edge }
|
||
|
||
private 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(AibisEditorMenus.FrameAnimationGraphEditor)]
|
||
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("asyncCompletionMode"),
|
||
"Async Completion");
|
||
body.Add(CreateCallout(
|
||
"Only affects async playback when the resolved terminal clip loops.",
|
||
"fa-callout--info"));
|
||
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("asyncCompletionMode"),
|
||
new GUIContent(
|
||
"Async Completion",
|
||
"Only affects async playback when the resolved terminal clip loops."));
|
||
EditorGUILayout.HelpBox(
|
||
"Async Completion only affects a Flow whose resolved terminal clip loops.",
|
||
MessageType.Info);
|
||
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;
|
||
}
|
||
}
|
||
}
|