feat: 动画系统第五阶段(未验收)

This commit is contained in:
2026-07-16 18:49:12 +08:00
parent 9b6d008263
commit 46602803dc
23 changed files with 2591 additions and 116 deletions
@@ -6,6 +6,70 @@ using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal static class FrameAnimationFlowFocusPolicy
{
public static AnimationFlow FindFocusedFlow(FrameAnimationGraph graph, string focusedFlowId)
{
return graph?.Flows.FirstOrDefault(flow => flow != null && flow.Id == focusedFlowId);
}
public static bool Contains(
FrameAnimationGraph graph,
string focusedFlowId,
FrameAnimationEditorSelection selection)
{
var flow = FindFocusedFlow(graph, focusedFlowId);
if (flow == null)
{
return false;
}
var reachable = new FrameAnimationGraphTopology(graph).GetReachable(flow.EntryNodeId);
return selection.Value switch
{
AnimationNode node => reachable.Nodes.Contains(node),
AnimationEdge edge => reachable.Edges.Contains(edge),
AnimationFlow selectedFlow => selectedFlow == flow,
_ => false
};
}
public static bool ShouldExitForSelection(
FrameAnimationGraph graph,
string focusedFlowId,
FrameAnimationEditorSelection selection)
{
if (string.IsNullOrEmpty(focusedFlowId))
{
return false;
}
return selection.Kind switch
{
FrameAnimationEditorSelectionKind.None => false,
FrameAnimationEditorSelectionKind.Flow => false,
FrameAnimationEditorSelectionKind.Node => !Contains(graph, focusedFlowId, selection),
FrameAnimationEditorSelectionKind.Edge => !Contains(graph, focusedFlowId, selection),
_ => true
};
}
public static bool ShouldExitForSelectionSet(
FrameAnimationGraph graph,
string focusedFlowId,
IEnumerable<FrameAnimationEditorSelection> selections)
{
if (string.IsNullOrEmpty(focusedFlowId))
{
return false;
}
var items = (selections ?? Array.Empty<FrameAnimationEditorSelection>()).ToArray();
return items.Length > 0 && items.Any(item =>
(item.Kind == FrameAnimationEditorSelectionKind.Node ||
item.Kind == FrameAnimationEditorSelectionKind.Edge)
? !Contains(graph, focusedFlowId, item)
: ShouldExitForSelection(graph, focusedFlowId, item));
}
}
internal sealed class FrameAnimationGraphImpact
{
public IReadOnlyList<AnimationFlow> Flows { get; }
@@ -0,0 +1,72 @@
.fa-preview {
position: relative;
overflow: hidden;
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-left-color: rgb(25, 25, 25);
border-right-color: rgb(25, 25, 25);
border-top-color: rgb(25, 25, 25);
border-bottom-color: rgb(25, 25, 25);
}
.fa-preview__image {
position: absolute;
}
.fa-preview__empty {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
-unity-text-align: middle-center;
color: rgb(190, 190, 190);
}
.fa-node__preview {
height: 82px;
margin-top: 5px;
}
.fa-node--preview-current {
border-left-color: rgb(55, 174, 240);
border-right-color: rgb(55, 174, 240);
border-top-color: rgb(55, 174, 240);
border-bottom-color: rgb(55, 174, 240);
}
.fa-node--preview-passed {
border-left-color: rgb(83, 154, 113);
}
.fa-node--preview-upcoming {
opacity: 0.72;
}
.fa-clip-preview-panel {
flex-shrink: 0;
padding-left: 6px;
padding-right: 6px;
padding-top: 4px;
padding-bottom: 5px;
border-bottom-width: 1px;
border-bottom-color: rgb(28, 28, 28);
}
.fa-clip-preview-panel .fa-preview {
height: 190px;
margin-bottom: 4px;
}
.fa-preview-controls {
flex-direction: row;
align-items: center;
}
.fa-preview-status {
font-size: 10px;
color: rgb(185, 185, 185);
margin-top: 2px;
}
@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: a38dd39c699e42348667cc1264ea9076
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
@@ -33,6 +33,8 @@ namespace AibisDream.FrameAnimation.Editor
private Label canvasLabel;
private FrameAnimationGraphView graphView;
private PopupField<string> flowFocusField;
private Label flowFocusStatusLabel;
private ToolbarButton exitFlowFocusButton;
private string focusedFlowId = string.Empty;
private IReadOnlyList<AnimationNode> multiSelectedNodes = Array.Empty<AnimationNode>();
private AnimationNode lastSelectedNode;
@@ -64,6 +66,21 @@ namespace AibisDream.FrameAnimation.Editor
private SerializedObject clipSerializedObject;
private FrameClip frameListClip;
private ReorderableList frameList;
private readonly FrameAnimationPreviewCoordinator previewCoordinator = new FrameAnimationPreviewCoordinator();
private readonly List<Slider> previewTimelineSliders = new List<Slider>();
private readonly List<Label> previewTimeLabels = new List<Label>();
private readonly List<Button> previewPlayButtons = new List<Button>();
private readonly List<Label> previewTargetLabels = new List<Label>();
private VisualElement clipPreviewPanel;
private FrameAnimationPreviewElement clipPreviewElement;
private Label clipPreviewStatus;
private EnumField nodePreviewPolicyField;
private EnumField previewBackgroundField;
private EnumField previewZoomField;
private Slider manualZoomSlider;
private PopupField<string> previewSpeedField;
private double lastPreviewTick;
private bool updatingPreviewUi;
[MenuItem("Window/Aibis Dream/Frame Animation Graph Editor")]
public static void ShowWindow()
@@ -90,6 +107,11 @@ namespace AibisDream.FrameAnimation.Editor
{
rootVisualElement.Clear();
rootVisualElement.style.flexDirection = FlexDirection.Column;
var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(
"Assets/Editor/FrameAnimation/FrameAnimationGraphEditor.uss");
if (styleSheet != null) rootVisualElement.styleSheets.Add(styleSheet);
previewCoordinator.Changed -= OnPreviewChanged;
previewCoordinator.Changed += OnPreviewChanged;
BuildToolbar();
BuildMainArea();
BuildBottomArea();
@@ -100,23 +122,35 @@ namespace AibisDream.FrameAnimation.Editor
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;
@@ -179,21 +213,36 @@ namespace AibisDream.FrameAnimation.Editor
};
var canvasToolbar = new Toolbar();
canvasToolbar.Add(new ToolbarButton(ShowAllNodes) { text = "Show All" });
flowFocusStatusLabel = new Label("Show All")
{
style =
{
minWidth = 110f,
maxWidth = 240f,
flexShrink = 1f,
unityFontStyleAndWeight = FontStyle.Bold,
unityTextAlign = TextAnchor.MiddleLeft
}
};
canvasToolbar.Add(flowFocusStatusLabel);
exitFlowFocusButton = new ToolbarButton(() => ExitFlowFocus(false)) { text = "Exit Focus" };
exitFlowFocusButton.style.display = DisplayStyle.None;
canvasToolbar.Add(exitFlowFocusButton);
flowFocusField = new PopupField<string>(new List<string> { "Show All" }, 0)
{
style = { minWidth = 170f }
style = { minWidth = 135f }
};
flowFocusField.RegisterValueChangedCallback(evt =>
{
if (evt.newValue == "Show All")
{
SetFocusedFlow(string.Empty);
ExitFlowFocus(false);
}
else
{
var flow = graph?.Flows.FirstOrDefault(item => item != null &&
FlowChoiceLabel(item) == evt.newValue);
SetFocusedFlow(flow?.Id ?? string.Empty);
EnterFlowFocus(flow?.Id);
}
});
canvasToolbar.Add(flowFocusField);
@@ -201,6 +250,8 @@ namespace AibisDream.FrameAnimation.Editor
canvasToolbar.Add(new ToolbarButton(() => graphView?.FrameCurrentFlow(focusedFlowId)) { text = "Frame Flow" });
canvasToolbar.Add(new ToolbarButton(AutoLayoutCanvas) { text = "Auto Layout" });
canvasToolbar.Add(new ToolbarButton(CreateFlowFromCanvasSelection) { text = "Create Flow" });
canvasToolbar.Add(new ToolbarSpacer());
canvasToolbar.Add(BuildPreviewTransport(true, true));
canvas.Add(canvasToolbar);
graphView = new FrameAnimationGraphView();
graphView.SelectionRequested += value => SetSelection(value, false);
@@ -209,9 +260,19 @@ namespace AibisDream.FrameAnimation.Editor
multiSelectedNodes = nodes ?? Array.Empty<AnimationNode>();
propertyContainer?.MarkDirtyRepaint();
};
graphView.SelectionSetChanged += OnCanvasSelectionSetChanged;
graphView.CreateFlowRequested += ShowCreateFlow;
graphView.NodeCreated += _ => ExitFlowFocus(false);
graphView.ExitFocusRequested += () => ExitFlowFocus(false);
graphView.GraphChanged += OnCanvasGraphChanged;
graphView.NotificationRequested += message => ShowNotification(new GUIContent(message));
graphView.WorkspaceViewTransformChanged += (position, scale) =>
{
if (graph == null) return;
FrameAnimationWorkspaceState.SetFloat(graph, "CanvasX", position.x);
FrameAnimationWorkspaceState.SetFloat(graph, "CanvasY", position.y);
FrameAnimationWorkspaceState.SetFloat(graph, "CanvasScale", scale);
};
canvas.Add(graphView);
canvasLabel = new Label("选择或创建 FrameAnimationGraph")
{
@@ -242,6 +303,9 @@ namespace AibisDream.FrameAnimation.Editor
}
};
rightPanel.Add(CreatePanelHeader("Properties"));
clipPreviewPanel = BuildClipPreviewPanel();
clipPreviewPanel.style.display = DisplayStyle.None;
rightPanel.Add(clipPreviewPanel);
propertyContainer = new IMGUIContainer(DrawSelectionProperties) { style = { flexGrow = 1f } };
rightPanel.Add(propertyContainer);
main.Add(CreateVerticalResizer(rightPanel, 260f, 650f, "RightWidth", resizeFromLeft: true));
@@ -249,6 +313,301 @@ namespace AibisDream.FrameAnimation.Editor
rootVisualElement.Add(main);
}
private VisualElement BuildPreviewTransport(bool includeTarget, bool includePreferences)
{
var controls = new VisualElement();
controls.AddToClassList("fa-preview-controls");
if (includeTarget)
{
var targetLabel = new Label("Preview: None")
{
style = { minWidth = 120f, maxWidth = 300f }
};
previewTargetLabels.Add(targetLabel);
controls.Add(targetLabel);
}
controls.Add(new ToolbarButton(previewCoordinator.Restart) { text = "↶", tooltip = "Restart" });
controls.Add(new ToolbarButton(() => previewCoordinator.Step(-1)) { text = "◀", tooltip = "Previous Frame" });
var play = new ToolbarButton(PreviewPlayPause) { text = "▶", tooltip = "Play / Pause" };
previewPlayButtons.Add(play);
controls.Add(play);
controls.Add(new ToolbarButton(() => previewCoordinator.Step(1)) { text = "▶|", tooltip = "Next Frame" });
controls.Add(new ToolbarButton(previewCoordinator.Stop) { text = "■", tooltip = "Stop" });
var timeline = new Slider(0f, 1f)
{
style = { minWidth = 90f, maxWidth = 240f, flexGrow = 1f }
};
timeline.RegisterValueChangedCallback(evt =>
{
if (!updatingPreviewUi) previewCoordinator.Seek(evt.newValue);
});
previewTimelineSliders.Add(timeline);
controls.Add(timeline);
var time = new Label("0 / 0 ms") { style = { minWidth = 82f } };
previewTimeLabels.Add(time);
controls.Add(time);
if (includePreferences)
{
previewSpeedField = new PopupField<string>(
new List<string> { "0.25×", "0.5×", "1×", "2×", "4×" }, "1×")
{
style = { width = 64f }
};
previewSpeedField.RegisterValueChangedCallback(evt =>
{
previewCoordinator.PreviewSpeed = ParsePreviewSpeed(evt.newValue);
SaveWorkspaceState();
});
controls.Add(previewSpeedField);
nodePreviewPolicyField = new EnumField(NodePreviewPolicy.SelectedOnly)
{
style = { width = 100f }
};
nodePreviewPolicyField.RegisterValueChangedCallback(evt =>
{
previewCoordinator.SetNodePolicy((NodePreviewPolicy)evt.newValue);
SaveWorkspaceState();
});
controls.Add(nodePreviewPolicyField);
previewBackgroundField = new EnumField(FrameAnimationPreviewBackground.Checkerboard)
{
style = { width = 100f }
};
previewBackgroundField.RegisterValueChangedCallback(evt =>
{
previewCoordinator.Background = (FrameAnimationPreviewBackground)evt.newValue;
SaveWorkspaceState();
RefreshPreviewUi();
});
controls.Add(previewBackgroundField);
}
return controls;
}
private VisualElement BuildClipPreviewPanel()
{
var panel = new VisualElement();
panel.AddToClassList("fa-clip-preview-panel");
panel.Add(new Label("Clip Preview") { style = { unityFontStyleAndWeight = FontStyle.Bold } });
clipPreviewElement = new FrameAnimationPreviewElement();
panel.Add(clipPreviewElement);
panel.Add(BuildPreviewTransport(false, false));
var zoomRow = new VisualElement { style = { flexDirection = FlexDirection.Row } };
previewZoomField = new EnumField(FrameAnimationPreviewZoomMode.Fit) { style = { width = 100f } };
previewZoomField.RegisterValueChangedCallback(evt =>
{
previewCoordinator.ZoomMode = (FrameAnimationPreviewZoomMode)evt.newValue;
SaveWorkspaceState();
RefreshPreviewUi();
});
zoomRow.Add(previewZoomField);
manualZoomSlider = new Slider("Manual", 0.25f, 8f) { style = { flexGrow = 1f } };
manualZoomSlider.RegisterValueChangedCallback(evt =>
{
previewCoordinator.ManualZoom = evt.newValue;
SaveWorkspaceState();
RefreshPreviewUi();
});
zoomRow.Add(manualZoomSlider);
panel.Add(zoomRow);
clipPreviewStatus = new Label();
clipPreviewStatus.AddToClassList("fa-preview-status");
panel.Add(clipPreviewStatus);
return panel;
}
private void PreviewPlayPause()
{
if (previewCoordinator.State == FrameAnimationPreviewState.Playing)
{
previewCoordinator.Pause();
return;
}
if (previewCoordinator.TargetKind == FrameAnimationPreviewTargetKind.Flow)
{
validationIssues = FrameAnimationEditorValidationService.Validate(graph, false, out _);
RefreshBrowser();
bottomContainer?.MarkDirtyRepaint();
RefreshGraphView();
}
previewCoordinator.Play();
if (previewCoordinator.State == FrameAnimationPreviewState.Failed)
{
ShowNotification(new GUIContent(previewCoordinator.Error.Message));
}
}
private void UpdatePreviewTargetForSelection()
{
if (graph == null)
{
previewCoordinator.ClearTarget();
return;
}
if (previewCoordinator.TargetKind == FrameAnimationPreviewTargetKind.Flow &&
previewCoordinator.Target is AnimationFlow activeFlow &&
IsSelectionInsideFlow(activeFlow, selection))
{
RefreshPreviewUi();
return;
}
switch (selection.Kind)
{
case FrameAnimationEditorSelectionKind.Clip:
previewCoordinator.SetTarget(FrameAnimationPreviewTargetKind.Clip, selection.Value);
break;
case FrameAnimationEditorSelectionKind.Node:
previewCoordinator.SetTarget(FrameAnimationPreviewTargetKind.Node, selection.Value);
break;
case FrameAnimationEditorSelectionKind.Flow:
previewCoordinator.SetTarget(FrameAnimationPreviewTargetKind.Flow, selection.Value);
break;
default:
previewCoordinator.ClearTarget();
break;
}
}
private bool IsSelectionInsideFlow(AnimationFlow flow, FrameAnimationEditorSelection value)
{
if (flow == null || graph == null) return false;
if (value.Kind == FrameAnimationEditorSelectionKind.Flow) return ReferenceEquals(value.Value, flow);
var reachable = new FrameAnimationGraphTopology(graph).GetReachable(flow.EntryNodeId);
return value.Value switch
{
AnimationNode node => reachable.Nodes.Contains(node),
AnimationEdge edge => reachable.Edges.Contains(edge),
_ => false
};
}
private void OnPreviewChanged()
{
RefreshPreviewUi();
Repaint();
}
private void RefreshPreviewUi()
{
if (rootVisualElement == null) return;
updatingPreviewUi = true;
var timeline = previewCoordinator.Timeline;
var maximum = timeline?.DisplayDurationSeconds ?? 0d;
var position = Math.Min(maximum, previewCoordinator.PositionSeconds);
foreach (var slider in previewTimelineSliders)
{
slider.lowValue = 0f;
slider.highValue = Mathf.Max(0.0001f, (float)maximum);
slider.SetValueWithoutNotify((float)position);
slider.SetEnabled(timeline != null && timeline.Segments.Count > 0 && maximum > 0d);
}
var total = timeline?.IsInfinite == true ? "∞" : FormatPreviewTime(maximum);
foreach (var label in previewTimeLabels)
{
label.text = $"{FormatPreviewTime(position)} / {total}";
}
foreach (var button in previewPlayButtons)
{
button.text = previewCoordinator.State == FrameAnimationPreviewState.Playing ? "❚❚" : "▶";
button.SetEnabled(previewCoordinator.TargetKind != FrameAnimationPreviewTargetKind.None);
}
var targetText = PreviewTargetName();
foreach (var label in previewTargetLabels)
{
label.text = targetText;
label.tooltip = targetText;
}
if (clipPreviewPanel != null)
{
clipPreviewPanel.style.display = selection.Kind == FrameAnimationEditorSelectionKind.Clip
? DisplayStyle.Flex : DisplayStyle.None;
}
if (clipPreviewElement != null)
{
clipPreviewElement.SetBackground(previewCoordinator.Background);
clipPreviewElement.SetZoom(previewCoordinator.ZoomMode, previewCoordinator.ManualZoom);
clipPreviewElement.SetDisplay(
previewCoordinator.CurrentSprite,
previewCoordinator.CurrentSprite == null,
previewCoordinator.Snapshot.IsCompleted ? previewCoordinator.Snapshot.EndBehavior.ToString() : "Empty Frame");
}
if (clipPreviewStatus != null)
{
clipPreviewStatus.text = BuildPreviewStatus();
}
if (manualZoomSlider != null)
{
manualZoomSlider.SetValueWithoutNotify(previewCoordinator.ManualZoom);
manualZoomSlider.SetEnabled(previewCoordinator.ZoomMode == FrameAnimationPreviewZoomMode.Manual);
}
previewZoomField?.SetValueWithoutNotify(previewCoordinator.ZoomMode);
nodePreviewPolicyField?.SetValueWithoutNotify(previewCoordinator.NodePolicy);
previewBackgroundField?.SetValueWithoutNotify(previewCoordinator.Background);
previewSpeedField?.SetValueWithoutNotify(FormatPreviewSpeed(previewCoordinator.PreviewSpeed));
graphView?.ApplyPreview(previewCoordinator);
updatingPreviewUi = false;
}
private string BuildPreviewStatus()
{
if (previewCoordinator.State == FrameAnimationPreviewState.Failed)
{
return $"ERROR [{previewCoordinator.Error.Code}] {previewCoordinator.Error.Message}";
}
var snapshot = previewCoordinator.Snapshot;
if (!snapshot.HasStarted || snapshot.Clip == null) return "No preview target";
var duration = snapshot.FrameIndex >= 0 && snapshot.FrameIndex < snapshot.Clip.FrameCount
? snapshot.Clip.Frames[snapshot.FrameIndex].DurationMs : 0;
var effective = snapshot.StepSpeed > 0f ? duration / snapshot.StepSpeed : double.PositiveInfinity;
return $"{previewCoordinator.State} · Frame {snapshot.FrameIndex + 1}/{snapshot.Clip.FrameCount} · " +
$"source {duration} ms · effective {(double.IsInfinity(effective) ? "" : effective.ToString("0") + " ms")} · {snapshot.Clip.Id}" +
(string.IsNullOrEmpty(snapshot.NodeId) ? string.Empty : $" · Node {snapshot.NodeId}") +
(previewCoordinator.Timeline?.IsBlocked == true ? " · Blocked (speed 0)" : string.Empty);
}
private string PreviewTargetName()
{
return previewCoordinator.Target switch
{
FrameClip clip => $"Clip: {clip.DisplayName}",
AnimationNode node => $"Node: {node.DisplayName}",
AnimationFlow flow => BuildFlowPreviewTargetName(flow),
_ => "Preview: None"
};
}
private string BuildFlowPreviewTargetName(AnimationFlow flow)
{
var snapshot = previewCoordinator.Snapshot;
if (!snapshot.HasStarted || snapshot.Clip == null) return $"Flow: {flow.DisplayName}";
var node = graph?.Nodes.FirstOrDefault(item => item != null && item.InternalId == snapshot.NodeId);
var ending = snapshot.IsCompleted && previewCoordinator.CurrentSprite == null
? $" · {snapshot.EndBehavior}" : string.Empty;
var blocked = previewCoordinator.Timeline?.IsBlocked == true ? " · Blocked" : string.Empty;
return $"Flow: {flow.DisplayName} · Node: {node?.DisplayName ?? snapshot.NodeId} · " +
$"{snapshot.Clip.Id} F{snapshot.FrameIndex + 1}/{snapshot.Clip.FrameCount}{ending}{blocked}";
}
private static string FormatPreviewTime(double seconds) => $"{Math.Max(0d, seconds) * 1000d:0} ms";
private static float ParsePreviewSpeed(string value)
{
return value switch { "0.25×" => 0.25f, "0.5×" => 0.5f, "2×" => 2f, "4×" => 4f, _ => 1f };
}
private static string FormatPreviewSpeed(float value)
{
if (Mathf.Approximately(value, 0.25f)) return "0.25×";
if (Mathf.Approximately(value, 0.5f)) return "0.5×";
if (Mathf.Approximately(value, 2f)) return "2×";
if (Mathf.Approximately(value, 4f)) return "4×";
return "1×";
}
private VisualElement BuildLeftPanel()
{
var panel = new VisualElement
@@ -263,9 +622,9 @@ namespace AibisDream.FrameAnimation.Editor
}
};
var tabs = new Toolbar();
tabs.Add(new ToolbarButton(() => SetResourceTab(ResourceTab.Clips)) { text = "Clips" });
tabs.Add(new ToolbarButton(() => SetResourceTab(ResourceTab.Flows)) { text = "Flows" });
tabs.Add(new ToolbarButton(() => SetResourceTab(ResourceTab.Sources)) { text = "Sources" });
tabs.Add(new ToolbarButton(() => SetResourceTab(ResourceTab.Clips, true)) { text = "Clips" });
tabs.Add(new ToolbarButton(() => SetResourceTab(ResourceTab.Flows, true)) { text = "Flows" });
tabs.Add(new ToolbarButton(() => SetResourceTab(ResourceTab.Sources, true)) { text = "Sources" });
panel.Add(tabs);
searchField = new ToolbarSearchField();
searchField.RegisterValueChangedCallback(_ =>
@@ -432,7 +791,9 @@ namespace AibisDream.FrameAnimation.Editor
{
SaveGraph();
SaveWorkspaceState();
focusedFlowId = string.Empty;
graph = value;
previewCoordinator.SetGraph(graph);
selection = graph != null
? new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph)
: default;
@@ -451,6 +812,7 @@ namespace AibisDream.FrameAnimation.Editor
}
}
previewCoordinator.SetGraph(graph);
graphField?.SetValueWithoutNotify(graph);
if (graph != null)
{
@@ -460,7 +822,9 @@ namespace AibisDream.FrameAnimation.Editor
RefreshBrowser();
RefreshFlowChoices();
RefreshGraphView();
RestoreCanvasView();
RefreshCanvasSummary();
UpdatePreviewTargetForSelection();
propertyContainer?.MarkDirtyRepaint();
bottomContainer?.MarkDirtyRepaint();
}
@@ -478,8 +842,12 @@ namespace AibisDream.FrameAnimation.Editor
}
}
private void SetResourceTab(ResourceTab tab)
private void SetResourceTab(ResourceTab tab, bool userInitiated = false)
{
if (userInitiated && tab != ResourceTab.Flows)
{
ExitFlowFocus(false);
}
resourceTab = tab;
SaveWorkspaceState();
RefreshBrowser();
@@ -495,28 +863,29 @@ namespace AibisDream.FrameAnimation.Editor
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;
}
if (value.Kind == FrameAnimationEditorSelectionKind.Flow && value.Value is AnimationFlow flow)
{
SetFocusedFlow(flow.Id);
}
frameListClip = null;
frameList = null;
clipSerializedObject = null;
propertyContainer?.MarkDirtyRepaint();
RefreshCanvasSummary();
SaveWorkspaceState();
UpdatePreviewTargetForSelection();
if (locateOnCanvas && (value.Kind == FrameAnimationEditorSelectionKind.Node ||
value.Kind == FrameAnimationEditorSelectionKind.Edge))
{
if (!SelectionBelongsToFocusedFlow(value))
{
SetFocusedFlow(string.Empty);
}
graphView?.SelectAndFrame(value);
}
}
@@ -633,10 +1002,13 @@ namespace AibisDream.FrameAnimation.Editor
private void RefreshGraphView()
{
graphView?.Bind(graph, validationIssues, focusedFlowId);
graphView?.ApplyPreview(previewCoordinator);
}
private void OnCanvasGraphChanged()
{
previewCoordinator.RefreshTarget(true);
RevalidateFlowFocus();
ScheduleLightValidation();
RefreshBrowser();
RefreshFlowChoices();
@@ -655,57 +1027,127 @@ namespace AibisDream.FrameAnimation.Editor
private void RefreshFlowChoices()
{
if (flowFocusField == null)
if (flowFocusField != null)
{
return;
var choices = new List<string> { "Show All" };
if (graph != null)
{
choices.AddRange(graph.Flows.Where(flow => flow != null)
.OrderBy(flow => flow.DisplayName, StringComparer.Ordinal)
.ThenBy(flow => flow.Id, StringComparer.Ordinal)
.Select(FlowChoiceLabel));
}
flowFocusField.choices = choices;
var focused = FrameAnimationFlowFocusPolicy.FindFocusedFlow(graph, focusedFlowId);
flowFocusField.SetValueWithoutNotify(focused != null ? FlowChoiceLabel(focused) : "Show All");
}
var choices = new List<string> { "Show All" };
if (graph != null)
{
choices.AddRange(graph.Flows.Where(flow => flow != null)
.OrderBy(flow => flow.DisplayName, StringComparer.Ordinal)
.ThenBy(flow => flow.Id, StringComparer.Ordinal)
.Select(FlowChoiceLabel));
}
flowFocusField.choices = choices;
var focused = graph?.Flows.FirstOrDefault(flow => flow != null && flow.Id == focusedFlowId);
flowFocusField.SetValueWithoutNotify(focused != null ? FlowChoiceLabel(focused) : "Show All");
RefreshFlowFocusUi();
}
private void SetFocusedFlow(string flowId)
private void EnterFlowFocus(string flowId)
{
focusedFlowId = graph?.Flows.Any(flow => flow != null && flow.Id == flowId) == true
? flowId
: string.Empty;
var flow = FrameAnimationFlowFocusPolicy.FindFocusedFlow(graph, flowId);
if (flow == null)
{
ExitFlowFocus(false);
return;
}
focusedFlowId = flow.Id;
RefreshFlowChoices();
graphView?.SetFocusedFlow(focusedFlowId);
SaveWorkspaceState();
}
private void ExitFlowFocus(bool frameAll)
{
focusedFlowId = string.Empty;
RefreshFlowChoices();
graphView?.SetFocusedFlow(string.Empty);
if (frameAll)
{
graphView?.FrameAll();
}
}
private void RevalidateFlowFocus()
{
if (string.IsNullOrEmpty(focusedFlowId))
{
RefreshFlowFocusUi();
return;
}
var flow = FrameAnimationFlowFocusPolicy.FindFocusedFlow(graph, focusedFlowId);
if (flow == null)
{
if (selection.Kind == FrameAnimationEditorSelectionKind.Flow &&
selection.Value is AnimationFlow selectedFlow && graph?.Flows.Contains(selectedFlow) == true)
{
EnterFlowFocus(selectedFlow.Id);
}
else
{
ExitFlowFocus(false);
}
return;
}
if (selection.Kind == FrameAnimationEditorSelectionKind.Flow &&
selection.Value is AnimationFlow activeSelection &&
graph.Flows.Contains(activeSelection) && activeSelection != flow)
{
EnterFlowFocus(activeSelection.Id);
return;
}
var selectedElementWasRemoved = selection.Value switch
{
AnimationNode node => !graph.Nodes.Contains(node),
AnimationEdge edge => !graph.Edges.Contains(edge),
_ => false
};
if (selectedElementWasRemoved)
{
selection = new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Flow, flow);
lastSelectedNode = null;
propertyContainer?.MarkDirtyRepaint();
SaveWorkspaceState();
}
if (FrameAnimationFlowFocusPolicy.ShouldExitForSelection(graph, focusedFlowId, selection))
{
ExitFlowFocus(false);
return;
}
RefreshFlowChoices();
graphView?.SetFocusedFlow(focusedFlowId);
}
private void RefreshFlowFocusUi()
{
var flow = FrameAnimationFlowFocusPolicy.FindFocusedFlow(graph, focusedFlowId);
if (flowFocusStatusLabel != null)
{
flowFocusStatusLabel.text = flow != null
? $"Focused: {flow.DisplayName} ({flow.Id})"
: "Show All";
flowFocusStatusLabel.tooltip = flowFocusStatusLabel.text;
flowFocusStatusLabel.style.color = flow != null
? new StyleColor(new Color(0.45f, 0.78f, 1f))
: new StyleColor(Color.gray);
}
if (exitFlowFocusButton != null)
{
exitFlowFocusButton.style.display = flow != null ? DisplayStyle.Flex : DisplayStyle.None;
}
}
private void OnCanvasSelectionSetChanged(IReadOnlyList<FrameAnimationEditorSelection> selections)
{
if (FrameAnimationFlowFocusPolicy.ShouldExitForSelectionSet(graph, focusedFlowId, selections))
{
ExitFlowFocus(false);
}
}
private void ShowAllNodes()
{
SetFocusedFlow(string.Empty);
graphView?.FrameAll();
}
private bool SelectionBelongsToFocusedFlow(FrameAnimationEditorSelection value)
{
if (graph == null || string.IsNullOrEmpty(focusedFlowId))
{
return true;
}
var flow = graph.Flows.FirstOrDefault(item => item != null && item.Id == focusedFlowId);
if (flow == null)
{
return false;
}
var reachable = new FrameAnimationGraphTopology(graph).GetReachable(flow.EntryNodeId);
return value.Value switch
{
AnimationNode node => reachable.Nodes.Contains(node),
AnimationEdge edge => reachable.Edges.Contains(edge),
_ => true
};
ExitFlowFocus(true);
}
private void CreateFlowFromCanvasSelection()
@@ -728,7 +1170,6 @@ namespace AibisDream.FrameAnimation.Editor
}
FrameAnimationFlowWindow.Show(graph, entry, flow =>
{
SetFocusedFlow(flow.Id);
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Flow, flow), false);
OnCanvasGraphChanged();
});
@@ -861,6 +1302,7 @@ namespace AibisDream.FrameAnimation.Editor
}
if (EditorGUI.EndChangeCheck())
{
previewCoordinator.RefreshTarget(false);
ScheduleLightValidation();
RefreshBrowser();
RefreshCanvasSummary();
@@ -1117,14 +1559,14 @@ namespace AibisDream.FrameAnimation.Editor
}
else
{
SetFocusedFlow(flow.Id);
EnterFlowFocus(flow.Id);
OnCanvasGraphChanged();
}
}
}
if (GUILayout.Button("Focus Flow On Canvas"))
{
SetFocusedFlow(flow.Id);
EnterFlowFocus(flow.Id);
graphView?.FrameCurrentFlow(flow.Id);
}
if (GUILayout.Button("Rename Flow ID"))
@@ -1152,7 +1594,7 @@ namespace AibisDream.FrameAnimation.Editor
}
else
{
SetFocusedFlow(string.Empty);
ExitFlowFocus(false);
SetSelection(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph));
OnCanvasGraphChanged();
}
@@ -1567,6 +2009,7 @@ namespace AibisDream.FrameAnimation.Editor
{
EditorUtility.DisplayDialog("刷新失败", error, "确定");
}
previewCoordinator.RefreshTarget(true);
importPreview = FrameAnimationImportService.PreviewAll(graph);
RefreshBrowser();
ValidateFull();
@@ -1638,7 +2081,7 @@ namespace AibisDream.FrameAnimation.Editor
}
else if (focusedFlowId == oldId)
{
focusedFlowId = flow.Id;
EnterFlowFocus(flow.Id);
}
RefreshBrowser();
RefreshFlowChoices();
@@ -1814,13 +2257,40 @@ namespace AibisDream.FrameAnimation.Editor
FrameAnimationWorkspaceState.GetString(graph, "ValidationKind", "All"),
out ValidationKindFilter restoredValidationKind)
? restoredValidationKind : ValidationKindFilter.All;
var nodePolicy = Enum.TryParse(
FrameAnimationWorkspaceState.GetString(graph, "NodePreviewPolicy", "SelectedOnly"),
out NodePreviewPolicy restoredNodePolicy)
? restoredNodePolicy : NodePreviewPolicy.SelectedOnly;
var background = Enum.TryParse(
FrameAnimationWorkspaceState.GetString(graph, "PreviewBackground", "Checkerboard"),
out FrameAnimationPreviewBackground restoredBackground)
? restoredBackground : FrameAnimationPreviewBackground.Checkerboard;
var zoomMode = Enum.TryParse(
FrameAnimationWorkspaceState.GetString(graph, "PreviewZoomMode", "Fit"),
out FrameAnimationPreviewZoomMode restoredZoom)
? restoredZoom : FrameAnimationPreviewZoomMode.Fit;
previewCoordinator.NodePolicy = nodePolicy;
previewCoordinator.Background = background;
previewCoordinator.ZoomMode = zoomMode;
previewCoordinator.PreviewSpeed = Mathf.Clamp(
FrameAnimationWorkspaceState.GetFloat(graph, "PreviewSpeed", 1f), 0.25f, 4f);
previewCoordinator.ManualZoom = Mathf.Clamp(
FrameAnimationWorkspaceState.GetFloat(graph, "PreviewManualZoom", 1f), 0.25f, 8f);
bottomPanel.style.height = FrameAnimationWorkspaceState.GetFloat(graph, "BottomHeight", 230f);
focusedFlowId = FrameAnimationWorkspaceState.GetString(graph, "FocusedFlow", string.Empty);
if (!graph.Flows.Any(flow => flow != null && flow.Id == focusedFlowId))
{
focusedFlowId = string.Empty;
}
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()
@@ -1895,23 +2365,31 @@ namespace AibisDream.FrameAnimation.Editor
FrameAnimationWorkspaceState.SetString(graph, "ValidationKind", validationKindFilter.ToString());
FrameAnimationWorkspaceState.SetString(graph, "SelectionKind", selection.Kind.ToString());
FrameAnimationWorkspaceState.SetString(graph, "SelectionId", GetSelectionId());
FrameAnimationWorkspaceState.SetString(graph, "FocusedFlow", focusedFlowId);
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();
lastSelectedNode = selection.Value as AnimationNode;
if (!graph.Flows.Any(flow => flow != null && flow.Id == focusedFlowId))
if (previouslySelectedFlow != null && graph.Flows.Contains(previouslySelectedFlow))
{
focusedFlowId = string.Empty;
selection = new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Flow, previouslySelectedFlow);
}
lastSelectedNode = selection.Value as AnimationNode;
RevalidateFlowFocus();
}
RefreshBrowser();
RefreshFlowChoices();
@@ -14,6 +14,7 @@ namespace AibisDream.FrameAnimation.Editor
private readonly Label behavior;
private readonly Label flowBadge;
private readonly Label issueBadge;
private readonly FrameAnimationPreviewElement preview;
private readonly Action<FrameAnimationClipNodeView> selectedCallback;
public AnimationNode Data { get; }
@@ -27,6 +28,7 @@ namespace AibisDream.FrameAnimation.Editor
Data = data;
this.selectedCallback = selectedCallback;
viewDataKey = data.InternalId;
AddToClassList("fa-node");
style.minWidth = 245f;
style.maxWidth = 285f;
@@ -48,16 +50,9 @@ namespace AibisDream.FrameAnimation.Editor
extensionContainer.Add(behavior);
extensionContainer.Add(flowBadge);
extensionContainer.Add(issueBadge);
extensionContainer.Add(new Label("Preview · Phase 5")
{
style =
{
height = 72f,
unityTextAlign = TextAnchor.MiddleCenter,
backgroundColor = new StyleColor(new Color(0.12f, 0.12f, 0.12f, 0.8f)),
marginTop = 5f
}
});
preview = new FrameAnimationPreviewElement();
preview.AddToClassList("fa-node__preview");
extensionContainer.Add(preview);
RefreshExpandedState();
}
@@ -103,13 +98,28 @@ namespace AibisDream.FrameAnimation.Editor
internal void SetDimmed(bool dimmed)
{
style.opacity = dimmed ? 0.22f : 1f;
style.opacity = dimmed ? 0.45f : 1f;
}
internal void SetPreview(
FrameAnimationPreviewDisplay display,
FrameAnimationPreviewBackground background)
{
preview.SetBackground(background);
preview.SetZoom(FrameAnimationPreviewZoomMode.Fit, 1f);
preview.SetDisplay(display.Sprite, display.IsEmpty);
preview.style.opacity = display.FlowState == FrameAnimationFlowElementState.Upcoming
? 0.55f : display.FlowState == FrameAnimationFlowElementState.Passed ? 0.82f : 1f;
EnableInClassList("fa-node--preview-current", display.FlowState == FrameAnimationFlowElementState.Current);
EnableInClassList("fa-node--preview-passed", display.FlowState == FrameAnimationFlowElementState.Passed);
EnableInClassList("fa-node--preview-upcoming", display.FlowState == FrameAnimationFlowElementState.Upcoming);
}
}
internal sealed class FrameAnimationEdgeView : Edge
{
private readonly Action<FrameAnimationEdgeView> selectedCallback;
private Color baseColor = Color.white;
public AnimationEdge Data { get; }
internal FrameAnimationEdgeView(AnimationEdge data, Action<FrameAnimationEdgeView> selectedCallback)
@@ -128,7 +138,7 @@ namespace AibisDream.FrameAnimation.Editor
internal void SetDimmed(bool dimmed)
{
style.opacity = dimmed ? 0.16f : 1f;
style.opacity = dimmed ? 0.30f : 1f;
}
internal void Refresh(IReadOnlyList<FrameAnimationEditorIssue> issues)
@@ -139,12 +149,37 @@ namespace AibisDream.FrameAnimation.Editor
var color = matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error)
? new Color(1f, 0.3f, 0.25f)
: matching.Length > 0 ? new Color(1f, 0.72f, 0.2f) : Color.white;
baseColor = color;
edgeControl.inputColor = color;
edgeControl.outputColor = color;
tooltip = matching.Length == 0
? $"{Data.ExitName} / {Data.Condition}"
: string.Join("\n", matching.Select(issue => $"[{issue.Code}] {issue.Message}"));
}
internal void SetPreviewState(FrameAnimationFlowElementState state)
{
if (state == FrameAnimationFlowElementState.Current)
{
edgeControl.inputColor = new Color(0.25f, 0.75f, 1f);
edgeControl.outputColor = new Color(0.25f, 0.75f, 1f);
}
else if (state == FrameAnimationFlowElementState.Passed)
{
edgeControl.inputColor = new Color(0.35f, 0.68f, 0.46f);
edgeControl.outputColor = new Color(0.35f, 0.68f, 0.46f);
}
else if (state == FrameAnimationFlowElementState.Upcoming)
{
edgeControl.inputColor = new Color(0.55f, 0.55f, 0.55f);
edgeControl.outputColor = new Color(0.55f, 0.55f, 0.55f);
}
else
{
edgeControl.inputColor = baseColor;
edgeControl.outputColor = baseColor;
}
}
}
internal sealed class FrameAnimationClipSearchProvider : ScriptableObject, ISearchWindowProvider
@@ -200,16 +235,22 @@ namespace AibisDream.FrameAnimation.Editor
private IReadOnlyList<FrameAnimationEditorIssue> issues = Array.Empty<FrameAnimationEditorIssue>();
private bool rebuilding;
private Vector2 contextPosition;
private string focusedFlowId = string.Empty;
internal event Action<FrameAnimationEditorSelection> SelectionRequested;
internal event Action<IReadOnlyList<AnimationNode>> MultiSelectionChanged;
internal event Action<IReadOnlyList<FrameAnimationEditorSelection>> SelectionSetChanged;
internal event Action<AnimationNode> CreateFlowRequested;
internal event Action<AnimationNode> NodeCreated;
internal event Action ExitFocusRequested;
internal event Action GraphChanged;
internal event Action<string> NotificationRequested;
internal event Action<Vector3, float> WorkspaceViewTransformChanged;
internal FrameAnimationGraphView()
{
style.flexGrow = 1f;
focusable = true;
SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
this.AddManipulator(new ContentDragger());
this.AddManipulator(new SelectionDragger());
@@ -218,9 +259,12 @@ namespace AibisDream.FrameAnimation.Editor
Insert(0, grid);
grid.StretchToParentSize();
graphViewChanged = OnGraphViewChanged;
viewTransformChanged += _ =>
WorkspaceViewTransformChanged?.Invoke(viewTransform.position, viewTransform.scale.x);
RegisterCallback<MouseUpEvent>(_ => NotifySelectionSummary());
RegisterCallback<DragUpdatedEvent>(OnDragUpdated);
RegisterCallback<DragPerformEvent>(OnDragPerform);
RegisterCallback<KeyDownEvent>(OnKeyDown);
}
internal void Bind(
@@ -325,6 +369,7 @@ namespace AibisDream.FrameAnimation.Editor
internal void SetFocusedFlow(string flowId)
{
focusedFlowId = flowId ?? string.Empty;
if (graph == null || string.IsNullOrEmpty(flowId))
{
foreach (var view in nodeViews.Values) view.SetDimmed(false);
@@ -359,6 +404,36 @@ namespace AibisDream.FrameAnimation.Editor
internal IReadOnlyList<AnimationNode> SelectedNodes =>
selection.OfType<FrameAnimationClipNodeView>().Select(view => view.Data).ToArray();
internal IReadOnlyList<AnimationNode> VisibleNodes => nodeViews.Values
.Where(view => view.worldBound.Overlaps(worldBound))
.Select(view => view.Data)
.ToArray();
internal void ApplyPreview(FrameAnimationPreviewCoordinator coordinator)
{
if (coordinator == null) return;
foreach (var view in nodeViews.Values)
{
view.SetPreview(coordinator.GetNodeDisplay(view.Data), coordinator.Background);
}
foreach (var view in edgeViews.Values)
{
view.SetPreviewState(coordinator.GetEdgeState(view.Data));
}
}
internal void RestoreViewTransform(Vector3 position, float scale)
{
var safeScale = float.IsNaN(scale) || float.IsInfinity(scale)
? 1f : Mathf.Clamp(scale, ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
if (float.IsNaN(position.x) || float.IsInfinity(position.x) ||
float.IsNaN(position.y) || float.IsInfinity(position.y))
{
position = Vector3.zero;
}
UpdateViewTransform(position, new Vector3(safeScale, safeScale, 1f));
}
internal void RequestDeleteNodes(IEnumerable<AnimationNode> nodes)
{
var items = (nodes ?? Array.Empty<AnimationNode>()).Where(node => node != null).Distinct().ToArray();
@@ -437,10 +512,11 @@ namespace AibisDream.FrameAnimation.Editor
SearchWindow.Open(new SearchWindowContext(GUIUtility.GUIToScreenPoint(panelPosition)), provider);
}
private void CreateNode(FrameClip clip, Vector2 position)
internal void CreateNode(FrameClip clip, Vector2 position)
{
if (FrameAnimationGraphMutationService.CreateNode(graph, clip, position, out var node, out var error))
{
NodeCreated?.Invoke(node);
GraphChanged?.Invoke();
SelectionRequested?.Invoke(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, node));
}
@@ -583,19 +659,61 @@ namespace AibisDream.FrameAnimation.Editor
private void OnNodeSelected(FrameAnimationClipNodeView view)
{
if (rebuilding)
{
return;
}
SelectionRequested?.Invoke(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, view.Data));
NotifySelectionSummary();
}
private void OnEdgeSelected(FrameAnimationEdgeView view)
{
if (rebuilding)
{
return;
}
SelectionRequested?.Invoke(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Edge, view.Data));
NotifySelectionSummary();
}
private void NotifySelectionSummary()
{
if (rebuilding)
{
return;
}
MultiSelectionChanged?.Invoke(SelectedNodes);
var selected = selection.Select(item => item switch
{
FrameAnimationClipNodeView nodeView => new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Node, nodeView.Data),
FrameAnimationEdgeView edgeView => new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Edge, edgeView.Data),
_ => default
})
.Where(item => item.Kind != FrameAnimationEditorSelectionKind.None)
.ToArray();
SelectionSetChanged?.Invoke(selected);
}
private void OnKeyDown(KeyDownEvent evt)
{
if (!TryHandleFocusExitShortcut(evt.keyCode, evt.target is TextField))
{
return;
}
evt.StopImmediatePropagation();
}
internal bool TryHandleFocusExitShortcut(KeyCode keyCode, bool textInputActive)
{
if (keyCode != KeyCode.Escape || textInputActive || string.IsNullOrEmpty(focusedFlowId))
{
return false;
}
ExitFocusRequested?.Invoke();
return true;
}
private void OnDragUpdated(DragUpdatedEvent evt)
@@ -0,0 +1,593 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal enum FrameAnimationPreviewTargetKind { None, Clip, Node, Flow }
internal enum FrameAnimationPreviewState { Stopped, Playing, Paused, Failed }
internal enum NodePreviewPolicy { Static, SelectedOnly, AllVisible }
internal enum FrameAnimationPreviewBackground { Checkerboard, Dark, Light }
internal enum FrameAnimationPreviewZoomMode { Fit, One, Two, Three, Four, Eight, Manual }
internal enum FrameAnimationFlowElementState { None, Upcoming, Current, Passed }
internal readonly struct FrameAnimationPreviewSegment
{
public int StepIndex { get; }
public int FrameIndex { get; }
public FrameClip Clip { get; }
public string NodeId { get; }
public double StartSeconds { get; }
public double DurationSeconds { get; }
public bool IsBlocked { get; }
public FrameAnimationPreviewSegment(
int stepIndex,
int frameIndex,
ResolvedPlaybackStep step,
double startSeconds,
double durationSeconds,
bool isBlocked)
{
StepIndex = stepIndex;
FrameIndex = frameIndex;
Clip = step.Clip;
NodeId = step.NodeId;
StartSeconds = startSeconds;
DurationSeconds = durationSeconds;
IsBlocked = isBlocked;
}
}
internal sealed class FrameAnimationPreviewTimeline
{
private const double Epsilon = 0.000000001d;
private readonly List<FrameAnimationPreviewSegment> segments = new List<FrameAnimationPreviewSegment>();
public IReadOnlyList<FrameAnimationPreviewSegment> Segments => segments;
public double DisplayDurationSeconds { get; }
public bool IsInfinite { get; }
public bool IsBlocked { get; }
public int BlockedSegmentIndex { get; } = -1;
public FrameAnimationPreviewTimeline(ResolvedPlaybackPlan plan)
{
if (plan?.Steps == null || plan.Steps.Count == 0)
{
return;
}
var cursor = 0d;
var blockedIndex = -1;
for (var stepIndex = 0; stepIndex < plan.Steps.Count; stepIndex++)
{
var step = plan.Steps[stepIndex];
if (step?.Clip == null)
{
continue;
}
for (var frameIndex = 0; frameIndex < step.Clip.FrameCount; frameIndex++)
{
var frame = step.Clip.Frames[frameIndex];
if (step.PlaybackSpeed == 0f)
{
blockedIndex = segments.Count;
segments.Add(new FrameAnimationPreviewSegment(
stepIndex, frameIndex, step, cursor, 0d, true));
break;
}
var duration = frame.DurationMs / 1000d / step.PlaybackSpeed;
segments.Add(new FrameAnimationPreviewSegment(
stepIndex, frameIndex, step, cursor, duration, false));
cursor += duration;
}
if (blockedIndex >= 0)
{
break;
}
}
DisplayDurationSeconds = cursor;
BlockedSegmentIndex = blockedIndex;
IsBlocked = blockedIndex >= 0;
IsInfinite = IsBlocked || plan.Steps[plan.Steps.Count - 1].TerminalEndBehavior == FrameClipEndBehavior.Loop;
}
public int FindSegmentIndex(double positionSeconds)
{
if (segments.Count == 0)
{
return -1;
}
var position = Math.Max(0d, positionSeconds);
for (var index = 0; index < segments.Count; index++)
{
var segment = segments[index];
if (segment.IsBlocked || position < segment.StartSeconds + segment.DurationSeconds - Epsilon)
{
return index;
}
}
return segments.Count - 1;
}
public double GetSeekSeconds(int segmentIndex)
{
return segmentIndex >= 0 && segmentIndex < segments.Count
? segments[segmentIndex].StartSeconds
: 0d;
}
public double GetPosition(FrameAnimationPlaybackSnapshot snapshot)
{
if (!snapshot.HasStarted || segments.Count == 0)
{
return 0d;
}
var segment = segments.FirstOrDefault(item =>
item.StepIndex == snapshot.StepIndex && item.FrameIndex == snapshot.FrameIndex);
if (segment.Clip == null)
{
return snapshot.IsCompleted ? DisplayDurationSeconds : 0d;
}
if (snapshot.IsCompleted && !IsInfinite)
{
return DisplayDurationSeconds;
}
var elapsed = snapshot.StepSpeed > 0f
? snapshot.FrameElapsedSeconds / snapshot.StepSpeed
: 0d;
return Math.Min(DisplayDurationSeconds, segment.StartSeconds + elapsed);
}
}
internal readonly struct FrameAnimationPreviewDisplay
{
public Sprite Sprite { get; }
public bool IsEmpty { get; }
public bool IsActive { get; }
public FrameAnimationFlowElementState FlowState { get; }
public FrameAnimationPreviewDisplay(
Sprite sprite,
bool isEmpty,
bool isActive,
FrameAnimationFlowElementState flowState)
{
Sprite = sprite;
IsEmpty = isEmpty;
IsActive = isActive;
FlowState = flowState;
}
}
internal sealed class FrameAnimationPreviewCoordinator
{
private sealed class NodeRun
{
public AnimationNode Node;
public FrameAnimationPlaybackSession Session;
public Sprite Sprite;
}
private readonly Dictionary<string, NodeRun> automaticRuns = new Dictionary<string, NodeRun>();
private FrameAnimationGraph graph;
private object target;
private ResolvedPlaybackPlan plan;
private FrameAnimationPlaybackSession session;
private Sprite currentSprite;
private bool terminalTransparent;
private bool suppressSelectedNode;
private bool flowPresentationActive;
public event Action Changed;
public FrameAnimationPreviewTargetKind TargetKind { get; private set; }
public FrameAnimationPreviewState State { get; private set; } = FrameAnimationPreviewState.Stopped;
public NodePreviewPolicy NodePolicy { get; set; } = NodePreviewPolicy.SelectedOnly;
public FrameAnimationPreviewBackground Background { get; set; } = FrameAnimationPreviewBackground.Checkerboard;
public FrameAnimationPreviewZoomMode ZoomMode { get; set; } = FrameAnimationPreviewZoomMode.Fit;
public float ManualZoom { get; set; } = 1f;
public float PreviewSpeed { get; set; } = 1f;
public FrameAnimationPreviewTimeline Timeline { get; private set; }
public FrameAnimationPlaybackError Error { get; private set; } = FrameAnimationPlaybackError.None;
public double PositionSeconds { get; private set; }
public string TransitionFromNodeId { get; private set; } = string.Empty;
public string TransitionToNodeId { get; private set; } = string.Empty;
public object Target => target;
public bool IsFlowPreview => TargetKind == FrameAnimationPreviewTargetKind.Flow &&
flowPresentationActive && State != FrameAnimationPreviewState.Failed;
public FrameAnimationPlaybackSnapshot Snapshot => session != null
? session.Snapshot
: default;
public Sprite CurrentSprite => terminalTransparent ? null : currentSprite;
public void SetGraph(FrameAnimationGraph value)
{
if (ReferenceEquals(graph, value))
{
return;
}
graph = value;
ClearTarget();
automaticRuns.Clear();
}
public void SetTarget(FrameAnimationPreviewTargetKind kind, object value)
{
if (TargetKind == kind && ReferenceEquals(target, value))
{
return;
}
TargetKind = kind;
target = value;
suppressSelectedNode = false;
BuildTarget(false, 0d);
if (kind == FrameAnimationPreviewTargetKind.Node && NodePolicy != NodePreviewPolicy.Static && session != null)
{
State = FrameAnimationPreviewState.Playing;
}
Changed?.Invoke();
}
public void ClearTarget()
{
TargetKind = FrameAnimationPreviewTargetKind.None;
target = null;
plan = null;
session = null;
Timeline = null;
currentSprite = null;
terminalTransparent = false;
Error = FrameAnimationPlaybackError.None;
PositionSeconds = 0d;
State = FrameAnimationPreviewState.Stopped;
flowPresentationActive = false;
TransitionFromNodeId = string.Empty;
TransitionToNodeId = string.Empty;
Changed?.Invoke();
}
public void RefreshTarget(bool topologyChanged)
{
if (TargetKind == FrameAnimationPreviewTargetKind.None)
{
return;
}
if (topologyChanged && TargetKind == FrameAnimationPreviewTargetKind.Flow)
{
Stop();
BuildTarget(false, 0d);
return;
}
var wasPlaying = State == FrameAnimationPreviewState.Playing;
var position = PositionSeconds;
BuildTarget(position > 0d, position);
if (wasPlaying && session != null)
{
State = FrameAnimationPreviewState.Playing;
flowPresentationActive = TargetKind == FrameAnimationPreviewTargetKind.Flow;
}
Changed?.Invoke();
}
public void SetNodePolicy(NodePreviewPolicy value)
{
NodePolicy = value;
automaticRuns.Clear();
suppressSelectedNode = false;
if (TargetKind == FrameAnimationPreviewTargetKind.Node)
{
BuildTarget(false, 0d);
if (session != null && value != NodePreviewPolicy.Static)
{
State = FrameAnimationPreviewState.Playing;
}
}
Changed?.Invoke();
}
public void Play()
{
if (session == null)
{
BuildTarget(false, 0d);
}
if (session == null)
{
return;
}
if (session.Snapshot.IsCompleted)
{
session.Restart(ApplySprite);
PositionSeconds = 0d;
}
terminalTransparent = false;
suppressSelectedNode = false;
flowPresentationActive = TargetKind == FrameAnimationPreviewTargetKind.Flow;
State = FrameAnimationPreviewState.Playing;
Changed?.Invoke();
}
public void Pause()
{
if (session == null || State != FrameAnimationPreviewState.Playing) return;
State = FrameAnimationPreviewState.Paused;
Changed?.Invoke();
}
public void Stop()
{
if (session != null)
{
session.Restart(ApplySprite);
}
State = FrameAnimationPreviewState.Stopped;
PositionSeconds = 0d;
terminalTransparent = false;
suppressSelectedNode = TargetKind == FrameAnimationPreviewTargetKind.Node;
flowPresentationActive = false;
TransitionFromNodeId = string.Empty;
TransitionToNodeId = string.Empty;
Changed?.Invoke();
}
public void Restart()
{
if (session == null)
{
BuildTarget(false, 0d);
}
session?.Restart(ApplySprite);
PositionSeconds = 0d;
terminalTransparent = false;
suppressSelectedNode = false;
flowPresentationActive = TargetKind == FrameAnimationPreviewTargetKind.Flow;
if (session != null) State = FrameAnimationPreviewState.Playing;
Changed?.Invoke();
}
public void Step(int direction)
{
if (session == null || Timeline == null || Timeline.Segments.Count == 0) return;
var current = Timeline.FindSegmentIndex(PositionSeconds);
var next = Mathf.Clamp(current + Math.Sign(direction), 0, Timeline.Segments.Count - 1);
Seek(Timeline.GetSeekSeconds(next));
}
public void Seek(double seconds)
{
if (session == null || Timeline == null) return;
var maximum = Timeline.DisplayDurationSeconds;
var targetSeconds = Math.Max(0d, Math.Min(maximum, seconds));
if (Timeline.IsInfinite && maximum > 0d && targetSeconds >= maximum)
{
targetSeconds = Math.Max(0d, maximum - 0.000001d);
}
terminalTransparent = false;
flowPresentationActive = TargetKind == FrameAnimationPreviewTargetKind.Flow;
var result = session.Seek(targetSeconds, ApplySprite);
ApplyEvaluation(result);
PositionSeconds = targetSeconds;
if (State != FrameAnimationPreviewState.Failed)
{
State = FrameAnimationPreviewState.Paused;
}
Changed?.Invoke();
}
public bool Tick(double deltaSeconds, IReadOnlyCollection<AnimationNode> visibleNodes)
{
var changed = false;
TransitionFromNodeId = string.Empty;
TransitionToNodeId = string.Empty;
if (State == FrameAnimationPreviewState.Playing && session != null)
{
var before = session.Snapshot;
var result = session.Evaluate(deltaSeconds, PreviewSpeed, ApplySprite);
var after = session.Snapshot;
if (before.StepIndex != after.StepIndex)
{
TransitionFromNodeId = before.NodeId;
TransitionToNodeId = after.NodeId;
}
ApplyEvaluation(result);
PositionSeconds = Timeline?.GetPosition(after) ?? 0d;
if (result.IsCompleted && TargetKind == FrameAnimationPreviewTargetKind.Node &&
NodePolicy != NodePreviewPolicy.Static && !suppressSelectedNode && session != null)
{
session.Restart(ApplySprite);
State = FrameAnimationPreviewState.Playing;
PositionSeconds = 0d;
terminalTransparent = false;
}
changed = true;
}
if (!IsFlowPreview)
{
changed |= TickAutomaticNodes(deltaSeconds, visibleNodes);
}
else if (automaticRuns.Count > 0)
{
automaticRuns.Clear();
changed = true;
}
if (changed) Changed?.Invoke();
return changed;
}
public FrameAnimationPreviewDisplay GetNodeDisplay(AnimationNode node)
{
if (node == null)
{
return default;
}
if (IsFlowPreview && plan != null)
{
var current = session?.Snapshot.StepIndex ?? 0;
var stepIndex = IndexOfNode(plan, node.InternalId);
var flowState = stepIndex < 0 ? FrameAnimationFlowElementState.None :
stepIndex < current ? FrameAnimationFlowElementState.Passed :
stepIndex == current ? FrameAnimationFlowElementState.Current : FrameAnimationFlowElementState.Upcoming;
if (flowState == FrameAnimationFlowElementState.Current)
{
return new FrameAnimationPreviewDisplay(CurrentSprite, CurrentSprite == null, true, flowState);
}
return Representative(node, flowState);
}
if (TargetKind == FrameAnimationPreviewTargetKind.Node && ReferenceEquals(target, node) && session != null)
{
if (NodePolicy == NodePreviewPolicy.Static && State == FrameAnimationPreviewState.Stopped &&
!suppressSelectedNode && !session.Snapshot.IsCompleted)
{
return Representative(node, FrameAnimationFlowElementState.None);
}
return new FrameAnimationPreviewDisplay(CurrentSprite, CurrentSprite == null, State == FrameAnimationPreviewState.Playing,
FrameAnimationFlowElementState.None);
}
if (automaticRuns.TryGetValue(node.InternalId, out var run))
{
return new FrameAnimationPreviewDisplay(run.Sprite, run.Sprite == null, true, FrameAnimationFlowElementState.None);
}
return Representative(node, FrameAnimationFlowElementState.None);
}
public FrameAnimationFlowElementState GetEdgeState(AnimationEdge edge)
{
if (!IsFlowPreview || plan == null || edge == null) return FrameAnimationFlowElementState.None;
if (edge.FromNodeId == TransitionFromNodeId && edge.ToNodeId == TransitionToNodeId)
{
return FrameAnimationFlowElementState.Current;
}
var from = IndexOfNode(plan, edge.FromNodeId);
var to = IndexOfNode(plan, edge.ToNodeId);
if (from < 0 || to != from + 1) return FrameAnimationFlowElementState.None;
var current = session?.Snapshot.StepIndex ?? 0;
return to <= current ? FrameAnimationFlowElementState.Passed : FrameAnimationFlowElementState.Upcoming;
}
private bool TickAutomaticNodes(double deltaSeconds, IReadOnlyCollection<AnimationNode> visibleNodes)
{
IEnumerable<AnimationNode> desired = Array.Empty<AnimationNode>();
if (NodePolicy == NodePreviewPolicy.SelectedOnly && TargetKind == FrameAnimationPreviewTargetKind.Node &&
target is AnimationNode selected && !suppressSelectedNode)
{
desired = new[] { selected };
}
else if (NodePolicy == NodePreviewPolicy.AllVisible)
{
desired = visibleNodes ?? Array.Empty<AnimationNode>();
}
var desiredNodes = desired.Where(node => node != null &&
!(TargetKind == FrameAnimationPreviewTargetKind.Node && ReferenceEquals(target, node)))
.GroupBy(node => node.InternalId).Select(group => group.First()).ToArray();
var desiredIds = new HashSet<string>(desiredNodes.Select(node => node.InternalId));
foreach (var id in automaticRuns.Keys.Where(id => !desiredIds.Contains(id)).ToArray())
{
automaticRuns.Remove(id);
}
foreach (var node in desiredNodes)
{
if (!automaticRuns.TryGetValue(node.InternalId, out var run))
{
if (!FrameAnimationResolver.TryResolveNode(graph, node, out var nodePlan, out _)) continue;
run = new NodeRun { Node = node, Session = new FrameAnimationPlaybackSession(nodePlan) };
run.Session.Start(sprite => run.Sprite = sprite);
automaticRuns[node.InternalId] = run;
}
var evaluation = run.Session.Evaluate(deltaSeconds, PreviewSpeed, sprite => run.Sprite = sprite);
if (evaluation.IsCompleted)
{
run.Session.Restart(sprite => run.Sprite = sprite);
}
else if (evaluation.IsFailed)
{
automaticRuns.Remove(node.InternalId);
}
}
return desiredNodes.Length > 0 || automaticRuns.Count > 0;
}
private void BuildTarget(bool seek, double position)
{
plan = null;
session = null;
Timeline = null;
currentSprite = null;
terminalTransparent = false;
PositionSeconds = 0d;
Error = FrameAnimationPlaybackError.None;
State = FrameAnimationPreviewState.Stopped;
flowPresentationActive = false;
var resolved = TargetKind switch
{
FrameAnimationPreviewTargetKind.Clip when target is FrameClip clip =>
FrameAnimationResolver.TryResolve(graph, clip.Id, default, out plan, out var clipError)
? FrameAnimationPlaybackError.None : clipError,
FrameAnimationPreviewTargetKind.Node when target is AnimationNode node =>
FrameAnimationResolver.TryResolveNode(graph, node, out plan, out var nodeError)
? FrameAnimationPlaybackError.None : nodeError,
FrameAnimationPreviewTargetKind.Flow when target is AnimationFlow flow =>
FrameAnimationResolver.TryResolve(graph, flow.Id, default, out plan, out var flowError)
? FrameAnimationPlaybackError.None : flowError,
_ => new FrameAnimationPlaybackError(FrameAnimationPlaybackErrorCode.PlayableNotFound, "没有可预览目标。")
};
if (resolved.Code != FrameAnimationPlaybackErrorCode.None || plan == null)
{
Error = resolved;
State = TargetKind == FrameAnimationPreviewTargetKind.None
? FrameAnimationPreviewState.Stopped : FrameAnimationPreviewState.Failed;
return;
}
Timeline = new FrameAnimationPreviewTimeline(plan);
session = new FrameAnimationPlaybackSession(plan);
session.Start(ApplySprite);
if (seek && position > 0d)
{
Seek(position);
}
}
private void ApplyEvaluation(FrameAnimationSessionEvaluation evaluation)
{
if (evaluation.IsFailed)
{
Error = evaluation.Error;
State = FrameAnimationPreviewState.Failed;
flowPresentationActive = false;
return;
}
if (!evaluation.IsCompleted) return;
State = FrameAnimationPreviewState.Stopped;
terminalTransparent = evaluation.EndBehavior == FrameClipEndBehavior.Clear ||
evaluation.EndBehavior == FrameClipEndBehavior.HideTarget;
}
private void ApplySprite(Sprite sprite)
{
currentSprite = sprite;
terminalTransparent = false;
}
private FrameAnimationPreviewDisplay Representative(AnimationNode node, FrameAnimationFlowElementState flowState)
{
var clip = graph?.Clips.FirstOrDefault(item => item != null && item.Id == node.ClipId);
var sprite = clip?.Frames.FirstOrDefault(frame => frame?.Sprite != null)?.Sprite;
return new FrameAnimationPreviewDisplay(sprite, sprite == null, false, flowState);
}
private static int IndexOfNode(ResolvedPlaybackPlan targetPlan, string nodeId)
{
if (targetPlan?.Steps == null) return -1;
for (var index = 0; index < targetPlan.Steps.Count; index++)
{
if (targetPlan.Steps[index].NodeId == nodeId) return index;
}
return -1;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 14a91ced504e4af4ad79f4f760794c3c
@@ -0,0 +1,163 @@
using UnityEngine;
using UnityEngine.UIElements;
namespace AibisDream.FrameAnimation.Editor
{
internal sealed class FrameAnimationPreviewElement : VisualElement
{
private static Texture2D checkerTexture;
private readonly Image image;
private readonly Label emptyLabel;
private Sprite sprite;
private FrameAnimationPreviewZoomMode zoomMode = FrameAnimationPreviewZoomMode.Fit;
private float manualZoom = 1f;
private Vector2 pan;
private Vector2 dragStart;
private Vector2 panStart;
private bool dragging;
public FrameAnimationPreviewElement()
{
AddToClassList("fa-preview");
focusable = true;
image = new Image { scaleMode = ScaleMode.ScaleToFit, pickingMode = PickingMode.Ignore };
image.AddToClassList("fa-preview__image");
emptyLabel = new Label("Empty Frame") { pickingMode = PickingMode.Ignore };
emptyLabel.AddToClassList("fa-preview__empty");
Add(image);
Add(emptyLabel);
RegisterCallback<GeometryChangedEvent>(_ => UpdateImageLayout());
RegisterCallback<PointerDownEvent>(OnPointerDown);
RegisterCallback<PointerMoveEvent>(OnPointerMove);
RegisterCallback<PointerUpEvent>(OnPointerUp);
SetBackground(FrameAnimationPreviewBackground.Checkerboard);
}
public void SetDisplay(Sprite value, bool isEmpty, string emptyText = "Empty Frame")
{
sprite = value;
image.sprite = value;
image.style.display = value != null ? DisplayStyle.Flex : DisplayStyle.None;
emptyLabel.text = string.IsNullOrEmpty(emptyText) ? "Empty Frame" : emptyText;
emptyLabel.style.display = value == null || isEmpty ? DisplayStyle.Flex : DisplayStyle.None;
UpdateImageLayout();
}
public void SetBackground(FrameAnimationPreviewBackground background)
{
switch (background)
{
case FrameAnimationPreviewBackground.Dark:
style.backgroundImage = StyleKeyword.None;
style.backgroundColor = new StyleColor(new Color(0.08f, 0.08f, 0.08f));
break;
case FrameAnimationPreviewBackground.Light:
style.backgroundImage = StyleKeyword.None;
style.backgroundColor = new StyleColor(new Color(0.72f, 0.72f, 0.72f));
break;
default:
style.backgroundImage = new StyleBackground(GetCheckerTexture());
style.backgroundColor = StyleKeyword.Null;
break;
}
}
public void SetZoom(FrameAnimationPreviewZoomMode mode, float value)
{
zoomMode = mode;
manualZoom = Mathf.Clamp(value, 0.25f, 8f);
if (mode == FrameAnimationPreviewZoomMode.Fit)
{
pan = Vector2.zero;
}
UpdateImageLayout();
}
private void UpdateImageLayout()
{
if (sprite == null || resolvedStyle.width <= 0f || resolvedStyle.height <= 0f)
{
return;
}
var sourceSize = sprite.rect.size;
if (sourceSize.x <= 0f || sourceSize.y <= 0f)
{
return;
}
var scale = zoomMode == FrameAnimationPreviewZoomMode.Fit
? Mathf.Min(resolvedStyle.width / sourceSize.x, resolvedStyle.height / sourceSize.y)
: GetScale();
var size = sourceSize * Mathf.Max(0.0001f, scale);
image.style.width = size.x;
image.style.height = size.y;
image.style.left = (resolvedStyle.width - size.x) * 0.5f + pan.x;
image.style.top = (resolvedStyle.height - size.y) * 0.5f + pan.y;
}
private float GetScale()
{
return zoomMode switch
{
FrameAnimationPreviewZoomMode.One => 1f,
FrameAnimationPreviewZoomMode.Two => 2f,
FrameAnimationPreviewZoomMode.Three => 3f,
FrameAnimationPreviewZoomMode.Four => 4f,
FrameAnimationPreviewZoomMode.Eight => 8f,
FrameAnimationPreviewZoomMode.Manual => manualZoom,
_ => 1f
};
}
private void OnPointerDown(PointerDownEvent evt)
{
if (zoomMode == FrameAnimationPreviewZoomMode.Fit || evt.button != 0) return;
dragging = true;
dragStart = new Vector2(evt.position.x, evt.position.y);
panStart = pan;
this.CapturePointer(evt.pointerId);
evt.StopPropagation();
}
private void OnPointerMove(PointerMoveEvent evt)
{
if (!dragging || !this.HasPointerCapture(evt.pointerId)) return;
var position = new Vector2(evt.position.x, evt.position.y);
pan = panStart + position - dragStart;
UpdateImageLayout();
evt.StopPropagation();
}
private void OnPointerUp(PointerUpEvent evt)
{
if (!dragging || !this.HasPointerCapture(evt.pointerId)) return;
dragging = false;
this.ReleasePointer(evt.pointerId);
evt.StopPropagation();
}
private static Texture2D GetCheckerTexture()
{
if (checkerTexture != null) return checkerTexture;
const int textureSize = 128;
const int cellSize = 8;
checkerTexture = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false)
{
name = "Frame Animation Preview Checker",
hideFlags = HideFlags.HideAndDontSave,
wrapMode = TextureWrapMode.Repeat,
filterMode = FilterMode.Point
};
var light = new Color32(106, 106, 106, 255);
var dark = new Color32(78, 78, 78, 255);
var pixels = new Color32[textureSize * textureSize];
for (var y = 0; y < textureSize; y++)
for (var x = 0; x < textureSize; x++)
{
pixels[y * textureSize + x] = ((x / cellSize) + (y / cellSize)) % 2 == 0 ? light : dark;
}
checkerTexture.SetPixels32(pixels);
checkerTexture.Apply(false, true);
return checkerTexture;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 54aef216ceea42ed8917b581606ecf3d
@@ -0,0 +1,142 @@
using System;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
public static class FrameAnimationPreviewSampleBuilder
{
private const string Folder = "Assets/GameContent/Test/FrameAnimation/Preview";
private const string GraphPath = Folder + "/PreviewSampleGraph.asset";
private const string TexturePath = "Assets/GameContent/Test/AnimatorRaw/手接树叶特写.png";
[MenuItem("Tools/Frame Animation/Create Phase 5 Preview Sample")]
public static void BuildFromCommandLine()
{
if (AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(GraphPath) != null)
{
Debug.Log($"Frame Animation preview sample already exists; preserved without overwrite: {GraphPath}");
return;
}
EnsureFolder(Folder);
var sprites = AssetDatabase.LoadAllAssetsAtPath(TexturePath).OfType<Sprite>()
.OrderBy(sprite => sprite.name, StringComparer.Ordinal).ToArray();
if (sprites.Length < 6)
{
throw new InvalidOperationException("第五阶段预览样例需要至少 6 个已切分 Sprite。");
}
var graph = ScriptableObject.CreateInstance<FrameAnimationGraph>();
graph.name = "PreviewSampleGraph";
AssetDatabase.CreateAsset(graph, GraphPath);
var action = CreateClip(graph, "PreviewAction", "Preview Action", FrameClipEndBehavior.HoldLastFrame,
new[]
{
new FrameAnimationFrame(sprites[0], 100, sprites[0].name, 0),
new FrameAnimationFrame(sprites[1], 160, sprites[1].name, 1),
new FrameAnimationFrame(sprites[2], 220, sprites[2].name, 2)
});
var withEmpty = CreateClip(graph, "PreviewEmpty", "Preview Empty Frame", FrameClipEndBehavior.HoldLastFrame,
new[]
{
new FrameAnimationFrame(sprites[3], 120, sprites[3].name, 3),
new FrameAnimationFrame(null, 300, "empty", -1),
new FrameAnimationFrame(sprites[4], 120, sprites[4].name, 4)
});
var idle = CreateClip(graph, "PreviewIdle", "Preview Idle Loop", FrameClipEndBehavior.Loop,
new[]
{
new FrameAnimationFrame(sprites[4], 140, sprites[4].name, 4),
new FrameAnimationFrame(sprites[5], 140, sprites[5].name, 5)
});
var finiteA = new AnimationNode(action.Id, "Finite Action", speedOverride: 2f,
internalId: "51000000000000000000000000000001");
var finiteB = new AnimationNode(withEmpty.Id, "Finite Empty",
endBehaviorOverride: FrameClipEndBehavior.HoldLastFrame,
internalId: "51000000000000000000000000000002");
var loopA = new AnimationNode(action.Id, "Loop Intro", internalId: "51000000000000000000000000000003");
var loopB = new AnimationNode(idle.Id, "Loop Terminal",
endBehaviorOverride: FrameClipEndBehavior.Loop,
internalId: "51000000000000000000000000000004");
var blocked = new AnimationNode(withEmpty.Id, "Zero Speed Block", speedOverride: 0f,
internalId: "51000000000000000000000000000005");
var clear = new AnimationNode(action.Id, "Clear Terminal",
endBehaviorOverride: FrameClipEndBehavior.Clear,
internalId: "51000000000000000000000000000006");
var hidden = new AnimationNode(idle.Id, "Hide Terminal",
endBehaviorOverride: FrameClipEndBehavior.HideTarget,
internalId: "51000000000000000000000000000007");
var finiteFlow = new AnimationFlow("PreviewFiniteFlow", "Finite Flow", finiteA.InternalId);
var loopFlow = new AnimationFlow("PreviewLoopFlow", "Loop Flow", loopA.InternalId);
var blockedFlow = new AnimationFlow("PreviewBlockedFlow", "Zero Speed Flow", blocked.InternalId);
var clearFlow = new AnimationFlow("PreviewClearFlow", "Clear Flow", clear.InternalId);
var hideFlow = new AnimationFlow("PreviewHideFlow", "Hide Flow", hidden.InternalId);
graph.Configure(
"PreviewSampleGraph",
"Phase 5 Preview Sample",
new[] { action, withEmpty, idle },
new[] { finiteA, finiteB, loopA, loopB, blocked, clear, hidden },
new[]
{
new AnimationEdge(finiteA.InternalId, finiteB.InternalId, "52000000000000000000000000000001"),
new AnimationEdge(loopA.InternalId, loopB.InternalId, "52000000000000000000000000000002")
},
new[] { finiteFlow, loopFlow, blockedFlow, clearFlow, hideFlow },
finiteFlow.Id);
var positions = new[]
{
(finiteA, new Vector2(80f, 80f)), (finiteB, new Vector2(420f, 80f)),
(loopA, new Vector2(80f, 320f)), (loopB, new Vector2(420f, 320f)),
(blocked, new Vector2(80f, 560f)), (clear, new Vector2(420f, 560f)),
(hidden, new Vector2(760f, 560f))
};
foreach (var item in positions)
{
graph.EditorData.GetOrCreateNodeData(item.Item1.InternalId, item.Item2);
}
var colors = new[]
{
new Color(0.25f, 0.65f, 0.95f), new Color(0.55f, 0.4f, 0.9f),
new Color(0.9f, 0.55f, 0.2f), new Color(0.3f, 0.75f, 0.5f), new Color(0.85f, 0.35f, 0.45f)
};
var flows = graph.Flows.ToArray();
for (var index = 0; index < flows.Length; index++)
{
graph.EditorData.GetOrCreateFlowData(flows[index].Id, colors[index]);
}
EditorUtility.SetDirty(graph);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log($"Frame Animation phase 5 preview sample created: {GraphPath}");
}
private static FrameClip CreateClip(
FrameAnimationGraph graph,
string id,
string displayName,
FrameClipEndBehavior endBehavior,
FrameAnimationFrame[] frames)
{
var clip = ScriptableObject.CreateInstance<FrameClip>();
clip.name = id;
clip.Configure(id, displayName, frames, 1f, endBehavior);
AssetDatabase.AddObjectToAsset(clip, graph);
return clip;
}
private static void EnsureFolder(string path)
{
var segments = path.Split('/');
var current = segments[0];
for (var index = 1; index < segments.Length; index++)
{
var next = current + "/" + segments[index];
if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(current, segments[index]);
current = next;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f280e66f096a410eaf3eae8cd9c01134
@@ -503,18 +503,10 @@ MonoBehaviour:
endBehaviorOverride: 0
hasSpeedOverride: 0
speedOverride: 1
- internalId: 7613045814694eb193e548b69ccc7ba8
displayName: "\u6458\u5E3D"
- internalId: a52dc7cd68fe488fab46b609a22e392e
displayName: "\u62FF\u4F4F\u5E3D\u5B50"
type: 0
clipId: "\u6458\u5E3D"
hasEndBehaviorOverride: 0
endBehaviorOverride: 0
hasSpeedOverride: 0
speedOverride: 1
- internalId: c321504f573f43c5bf3e28cc224422da
displayName: "\u5BF9\u624B\u6307\u8868\u60C5idle"
type: 0
clipId: "\u5BF9\u624B\u6307\u8868\u60C5idle"
clipId: "\u62FF\u4F4F\u5E3D\u5B50"
hasEndBehaviorOverride: 0
endBehaviorOverride: 0
hasSpeedOverride: 0
@@ -525,9 +517,9 @@ MonoBehaviour:
toNodeId: dd80262d5bb64593a611c6760951adb8
exitName: default
condition: 0
- internalId: 02804e20dbbe4285814cdec412ec60c8
fromNodeId: c321504f573f43c5bf3e28cc224422da
toNodeId: 7613045814694eb193e548b69ccc7ba8
- internalId: 2540796018b44ecab94bb188520f46de
fromNodeId: dd80262d5bb64593a611c6760951adb8
toNodeId: a52dc7cd68fe488fab46b609a22e392e
exitName: default
condition: 0
flows:
@@ -536,11 +528,6 @@ MonoBehaviour:
entryNodeId: b35481d17ea54ff59ff39dfda19a65c1
hasEndBehaviorOverride: 0
endBehaviorOverride: 0
- id: "\u6458\u5E3DFlow"
displayName: "\u6458\u5E3DFlow"
entryNodeId: c321504f573f43c5bf3e28cc224422da
hasEndBehaviorOverride: 0
endBehaviorOverride: 0
importSources:
- internalId: bb9de2607d0b45e7a42467d1c5cb6e58
displayName: "\u706B\u5C71\u50CF\u7D20\u7248"
@@ -550,25 +537,21 @@ MonoBehaviour:
pivot: {x: 0.5, y: 0.5}
manageSpriteSlicing: 1
defaultNewClipEndBehavior: 0
lastSourceHash: 141a37c7308f843f8050d62fe78a1d85d1566e1957ac8049d046a1ce23e99542
lastSourceHash: 2ed3f61a91b96921ea48beb5f5b9ff34f5b3954552ddef24ee2d661adb45c5eb
settings:
defaultPlayableId: "\u4F38\u624B\u8868\u60C5idle"
newManualClipDefaultEndBehavior: 0
editorData:
nodeEditorData:
- nodeId: b35481d17ea54ff59ff39dfda19a65c1
position: {x: 369.33334, y: 354}
position: {x: 223.33334, y: 450.66666}
- nodeId: dd80262d5bb64593a611c6760951adb8
position: {x: 798.6666, y: 354}
- nodeId: 7613045814694eb193e548b69ccc7ba8
position: {x: 614, y: 87.33334}
- nodeId: c321504f573f43c5bf3e28cc224422da
position: {x: 223.33334, y: 87.33334}
position: {x: 523.3334, y: 450.66666}
- nodeId: a52dc7cd68fe488fab46b609a22e392e
position: {x: 1010.6666, y: 381.3333}
flowEditorData:
- flowId: " \u4F38\u624B\u5207\u5C4F\u7279\u6548 Flow"
color: {r: 0.24, g: 0.65, b: 1, a: 1}
- flowId: "\u6458\u5E3DFlow"
color: {r: 0.42, g: 0.8, b: 0.42, a: 1}
--- !u!114 &1645210723439489357
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d87256f730bcd17429c4234e6643879b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,243 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &-8424868548565804110
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 408fe45af4d321848a6c97f0356e8e5c, type: 3}
m_Name: PreviewIdle
m_EditorClassIdentifier:
id: PreviewIdle
displayName: Preview Idle Loop
frames:
- sprite: {fileID: 6897356873062661571, guid: 4a779c258c48060499e71d5113f0349f, type: 3}
durationMs: 140
frameName: "\u624B\u63A5\u6811\u53F6\u7279\u5199 12.aseprite"
sourceIndex: 4
- sprite: {fileID: 8086794502733613251, guid: 4a779c258c48060499e71d5113f0349f, type: 3}
durationMs: 140
frameName: "\u624B\u63A5\u6811\u53F6\u7279\u5199 13.aseprite"
sourceIndex: 5
speed: 1
defaultEndBehavior: 1
hasImportInfo: 0
importInfo:
importSourceId:
sourceTagName:
isMissingFromSource: 0
--- !u!114 &-1587622970102939646
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 408fe45af4d321848a6c97f0356e8e5c, type: 3}
m_Name: PreviewEmpty
m_EditorClassIdentifier:
id: PreviewEmpty
displayName: Preview Empty Frame
frames:
- sprite: {fileID: 6848724603688798191, guid: 4a779c258c48060499e71d5113f0349f, type: 3}
durationMs: 120
frameName: "\u624B\u63A5\u6811\u53F6\u7279\u5199 11.aseprite"
sourceIndex: 3
- sprite: {fileID: 0}
durationMs: 300
frameName: empty
sourceIndex: -1
- sprite: {fileID: 6897356873062661571, guid: 4a779c258c48060499e71d5113f0349f, type: 3}
durationMs: 120
frameName: "\u624B\u63A5\u6811\u53F6\u7279\u5199 12.aseprite"
sourceIndex: 4
speed: 1
defaultEndBehavior: 0
hasImportInfo: 0
importInfo:
importSourceId:
sourceTagName:
isMissingFromSource: 0
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4c9e0458642034545b44454bbadcf059, type: 3}
m_Name: PreviewSampleGraph
m_EditorClassIdentifier:
id: PreviewSampleGraph
displayName: Phase 5 Preview Sample
clips:
- {fileID: 7935180235754183048}
- {fileID: -1587622970102939646}
- {fileID: -8424868548565804110}
nodes:
- internalId: 51000000000000000000000000000001
displayName: Finite Action
type: 0
clipId: PreviewAction
hasEndBehaviorOverride: 0
endBehaviorOverride: 0
hasSpeedOverride: 1
speedOverride: 2
- internalId: 51000000000000000000000000000002
displayName: Finite Empty
type: 0
clipId: PreviewEmpty
hasEndBehaviorOverride: 1
endBehaviorOverride: 0
hasSpeedOverride: 0
speedOverride: 1
- internalId: 51000000000000000000000000000003
displayName: Loop Intro
type: 0
clipId: PreviewAction
hasEndBehaviorOverride: 0
endBehaviorOverride: 0
hasSpeedOverride: 0
speedOverride: 1
- internalId: 51000000000000000000000000000004
displayName: Loop Terminal
type: 0
clipId: PreviewIdle
hasEndBehaviorOverride: 1
endBehaviorOverride: 1
hasSpeedOverride: 0
speedOverride: 1
- internalId: 51000000000000000000000000000005
displayName: Zero Speed Block
type: 0
clipId: PreviewEmpty
hasEndBehaviorOverride: 0
endBehaviorOverride: 0
hasSpeedOverride: 1
speedOverride: 0
- internalId: 51000000000000000000000000000006
displayName: Clear Terminal
type: 0
clipId: PreviewAction
hasEndBehaviorOverride: 1
endBehaviorOverride: 2
hasSpeedOverride: 0
speedOverride: 1
- internalId: 51000000000000000000000000000007
displayName: Hide Terminal
type: 0
clipId: PreviewIdle
hasEndBehaviorOverride: 1
endBehaviorOverride: 3
hasSpeedOverride: 0
speedOverride: 1
edges:
- internalId: 52000000000000000000000000000001
fromNodeId: 51000000000000000000000000000001
toNodeId: 51000000000000000000000000000002
exitName: default
condition: 0
- internalId: 52000000000000000000000000000002
fromNodeId: 51000000000000000000000000000003
toNodeId: 51000000000000000000000000000004
exitName: default
condition: 0
flows:
- id: PreviewFiniteFlow
displayName: Finite Flow
entryNodeId: 51000000000000000000000000000001
hasEndBehaviorOverride: 0
endBehaviorOverride: 0
- id: PreviewLoopFlow
displayName: Loop Flow
entryNodeId: 51000000000000000000000000000003
hasEndBehaviorOverride: 0
endBehaviorOverride: 0
- id: PreviewBlockedFlow
displayName: Zero Speed Flow
entryNodeId: 51000000000000000000000000000005
hasEndBehaviorOverride: 0
endBehaviorOverride: 0
- id: PreviewClearFlow
displayName: Clear Flow
entryNodeId: 51000000000000000000000000000006
hasEndBehaviorOverride: 0
endBehaviorOverride: 0
- id: PreviewHideFlow
displayName: Hide Flow
entryNodeId: 51000000000000000000000000000007
hasEndBehaviorOverride: 0
endBehaviorOverride: 0
importSources: []
settings:
defaultPlayableId: PreviewFiniteFlow
newManualClipDefaultEndBehavior: 0
editorData:
nodeEditorData:
- nodeId: 51000000000000000000000000000001
position: {x: 80, y: 80}
- nodeId: 51000000000000000000000000000002
position: {x: 420, y: 80}
- nodeId: 51000000000000000000000000000003
position: {x: 80, y: 320}
- nodeId: 51000000000000000000000000000004
position: {x: 420, y: 320}
- nodeId: 51000000000000000000000000000005
position: {x: 80, y: 560}
- nodeId: 51000000000000000000000000000006
position: {x: 420, y: 560}
- nodeId: 51000000000000000000000000000007
position: {x: 760, y: 560}
flowEditorData:
- flowId: PreviewFiniteFlow
color: {r: 0.25, g: 0.65, b: 0.95, a: 1}
- flowId: PreviewLoopFlow
color: {r: 0.55, g: 0.4, b: 0.9, a: 1}
- flowId: PreviewBlockedFlow
color: {r: 0.9, g: 0.55, b: 0.2, a: 1}
- flowId: PreviewClearFlow
color: {r: 0.3, g: 0.75, b: 0.5, a: 1}
- flowId: PreviewHideFlow
color: {r: 0.85, g: 0.35, b: 0.45, a: 1}
--- !u!114 &7935180235754183048
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 408fe45af4d321848a6c97f0356e8e5c, type: 3}
m_Name: PreviewAction
m_EditorClassIdentifier:
id: PreviewAction
displayName: Preview Action
frames:
- sprite: {fileID: 8903061336358530087, guid: 4a779c258c48060499e71d5113f0349f, type: 3}
durationMs: 100
frameName: "\u624B\u63A5\u6811\u53F6\u7279\u5199 0.aseprite"
sourceIndex: 0
- sprite: {fileID: -6241228139138257646, guid: 4a779c258c48060499e71d5113f0349f, type: 3}
durationMs: 160
frameName: "\u624B\u63A5\u6811\u53F6\u7279\u5199 1.aseprite"
sourceIndex: 1
- sprite: {fileID: -7482846556689437635, guid: 4a779c258c48060499e71d5113f0349f, type: 3}
durationMs: 220
frameName: "\u624B\u63A5\u6811\u53F6\u7279\u5199 10.aseprite"
sourceIndex: 2
speed: 1
defaultEndBehavior: 0
hasImportInfo: 0
importInfo:
importSourceId:
sourceTagName:
isMissingFromSource: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 746a73865e31b9641af9f7da79134b9e
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -3,6 +3,42 @@ using UnityEngine;
namespace AibisDream.FrameAnimation
{
internal readonly struct FrameAnimationPlaybackSnapshot
{
public bool HasStarted { get; }
public bool IsCompleted { get; }
public int StepIndex { get; }
public int FrameIndex { get; }
public double FrameElapsedSeconds { get; }
public FrameClip Clip { get; }
public string NodeId { get; }
public Sprite Sprite { get; }
public float StepSpeed { get; }
public FrameClipEndBehavior EndBehavior { get; }
internal FrameAnimationPlaybackSnapshot(
bool hasStarted,
bool isCompleted,
int stepIndex,
int frameIndex,
double frameElapsedSeconds,
ResolvedPlaybackStep step)
{
HasStarted = hasStarted;
IsCompleted = isCompleted;
StepIndex = stepIndex;
FrameIndex = frameIndex;
FrameElapsedSeconds = frameElapsedSeconds;
Clip = step?.Clip;
NodeId = step?.NodeId ?? string.Empty;
Sprite = step?.Clip != null && frameIndex >= 0 && frameIndex < step.Clip.FrameCount
? step.Clip.Frames[frameIndex]?.Sprite
: null;
StepSpeed = step?.PlaybackSpeed ?? 0f;
EndBehavior = step?.TerminalEndBehavior ?? FrameClipEndBehavior.HoldLastFrame;
}
}
internal readonly struct FrameAnimationSessionEvaluation
{
public static FrameAnimationSessionEvaluation Running =>
@@ -55,9 +91,17 @@ namespace AibisDream.FrameAnimation
public string PlayableId => plan.PlayableId;
public string CurrentClipId => CurrentStep?.Clip != null ? CurrentStep.Clip.Id : string.Empty;
public string CurrentNodeId => plan.IsFlow && CurrentStep != null ? CurrentStep.NodeId : string.Empty;
public string CurrentNodeId => CurrentStep?.NodeId ?? string.Empty;
public int CurrentFrameIndex => hasStarted && !hasCompleted ? frameIndex : -1;
public FrameClip CurrentClip => CurrentStep?.Clip;
internal FrameAnimationPlaybackSnapshot Snapshot =>
new FrameAnimationPlaybackSnapshot(
hasStarted,
hasCompleted,
stepIndex,
hasStarted ? frameIndex : -1,
frameElapsedSeconds,
CurrentStep);
private ResolvedPlaybackStep CurrentStep =>
stepIndex >= 0 && stepIndex < plan.Steps.Count ? plan.Steps[stepIndex] : null;
@@ -81,6 +125,24 @@ namespace AibisDream.FrameAnimation
ApplyCurrentFrame(applySprite);
}
internal void Restart(Action<Sprite> applySprite)
{
hasStarted = true;
hasCompleted = false;
stepIndex = 0;
frameIndex = 0;
frameElapsedSeconds = 0d;
ApplyCurrentFrame(applySprite);
}
internal FrameAnimationSessionEvaluation Seek(double realSeconds, Action<Sprite> applySprite)
{
Restart(applySprite);
return realSeconds <= 0d
? FrameAnimationSessionEvaluation.Running
: Evaluate(realSeconds, 1f, applySprite);
}
public FrameAnimationSessionEvaluation Evaluate(
double deltaTimeSeconds,
float playerSpeed,
@@ -39,6 +39,59 @@ namespace AibisDream.FrameAnimation
internal static class FrameAnimationResolver
{
internal static bool TryResolveNode(
FrameAnimationGraph graph,
AnimationNode node,
out ResolvedPlaybackPlan plan,
out FrameAnimationPlaybackError error)
{
plan = null;
error = FrameAnimationPlaybackError.None;
if (graph == null)
{
error = Error(FrameAnimationPlaybackErrorCode.GraphMissing, "FrameAnimationGraph 为空。");
return false;
}
if (node == null || !graph.Nodes.Contains(node) || node.Type != AnimationNodeType.Clip)
{
error = Error(FrameAnimationPlaybackErrorCode.InvalidPlayableData, "AnimationNode 不属于当前 Graph 或类型无效。");
return false;
}
var clips = graph.Clips.Where(clip => clip != null && clip.Id == node.ClipId).ToArray();
if (clips.Length != 1)
{
error = Error(
FrameAnimationPlaybackErrorCode.InvalidPlayableData,
$"Node '{node.InternalId}' 的 clipId '{node.ClipId}' 不存在或不唯一。");
return false;
}
var clip = clips[0];
if (!TryValidateClip(clip, out error))
{
return false;
}
var speed = node.SpeedOverride ?? clip.Speed;
if (!FrameAnimationValueUtility.IsValidSpeed(speed))
{
error = Error(FrameAnimationPlaybackErrorCode.InvalidSpeed, $"Node '{node.InternalId}' 的有效速度无效。");
return false;
}
plan = new ResolvedPlaybackPlan(
node.InternalId,
false,
new[]
{
new ResolvedPlaybackStep(
clip,
node.InternalId,
speed,
node.EndBehaviorOverride ?? clip.DefaultEndBehavior)
});
return true;
}
public static bool TryResolve(
FrameAnimationGraph graph,
string playableId,
@@ -3,6 +3,7 @@ using System.Linq;
using AibisDream.FrameAnimation.Editor;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.UIElements;
namespace AibisDream.FrameAnimation.Tests.EditMode
{
@@ -176,6 +177,139 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
Assert.That(graph.Edges.Single().InternalId, Is.EqualTo("stable-edge"));
}
[Test]
public void FlowFocusPolicy_ExitsOnlyWhenSelectionLeavesFocusedFlowContext()
{
var entry = AddNode("entry");
var reachable = AddNode("reachable");
var outside = AddNode("outside");
var edge = new AnimationEdge(entry.InternalId, reachable.InternalId, "focused-edge");
graph.AddEdge(edge);
var flow = new AnimationFlow("FocusFlow", "Focus Flow", entry.InternalId);
graph.AddFlow(flow);
Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection(
graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Node, entry)), Is.False);
Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection(
graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Node, reachable)), Is.False);
Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection(
graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Edge, edge)), Is.False);
Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection(
graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Node, outside)), Is.True);
Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection(
graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Graph, graph)), Is.True);
Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection(
graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Clip, clip)), Is.True);
Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection(
graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Source, null)), Is.True);
Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection(
graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Flow, flow)), Is.False);
Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection(
graph, flow.Id, default), Is.False);
}
[Test]
public void FlowFocusPolicy_MixedSelectionExits_AndTopologyExpansionBecomesReachable()
{
var entry = AddNode("entry");
var inside = AddNode("inside");
var outside = AddNode("outside");
graph.AddEdge(new AnimationEdge(entry.InternalId, inside.InternalId));
var flow = new AnimationFlow("FocusFlow", "Focus Flow", entry.InternalId);
graph.AddFlow(flow);
Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelectionSet(graph, flow.Id, new[]
{
Select(FrameAnimationEditorSelectionKind.Node, entry),
Select(FrameAnimationEditorSelectionKind.Node, inside)
}), Is.False);
Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelectionSet(graph, flow.Id, new[]
{
Select(FrameAnimationEditorSelectionKind.Node, inside),
Select(FrameAnimationEditorSelectionKind.Node, outside)
}), Is.True);
graph.AddEdge(new AnimationEdge(inside.InternalId, outside.InternalId));
Assert.That(FrameAnimationFlowFocusPolicy.Contains(
graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Node, outside)), Is.True);
}
[Test]
public void GraphViewFocus_DimsOutsideElements_AndBindDoesNotEmitSelectionChanges()
{
var entry = AddNode("entry");
var inside = AddNode("inside");
var outside = AddNode("outside");
var outsideTail = AddNode("outside-tail");
var edge = new AnimationEdge(entry.InternalId, inside.InternalId, "inside-edge");
var outsideEdge = new AnimationEdge(outside.InternalId, outsideTail.InternalId, "outside-edge");
graph.AddEdge(edge);
graph.AddEdge(outsideEdge);
var flow = new AnimationFlow("FocusFlow", "Focus Flow", entry.InternalId);
graph.AddFlow(flow);
var view = new FrameAnimationGraphView();
var selectionNotifications = 0;
view.SelectionSetChanged += _ => selectionNotifications++;
view.Bind(graph, Array.Empty<FrameAnimationEditorIssue>(), flow.Id);
var nodes = view.Query<FrameAnimationClipNodeView>().ToList()
.ToDictionary(nodeView => nodeView.Data.InternalId);
var edges = view.Query<FrameAnimationEdgeView>().ToList()
.ToDictionary(edgeView => edgeView.Data.InternalId);
Assert.That(nodes[entry.InternalId].style.opacity.value, Is.EqualTo(1f));
Assert.That(nodes[inside.InternalId].style.opacity.value, Is.EqualTo(1f));
Assert.That(nodes[outside.InternalId].style.opacity.value, Is.EqualTo(0.45f));
Assert.That(edges[edge.InternalId].style.opacity.value, Is.EqualTo(1f));
Assert.That(edges[outsideEdge.InternalId].style.opacity.value, Is.EqualTo(0.30f));
Assert.That(selectionNotifications, Is.Zero);
view.AddToSelection(nodes[entry.InternalId]);
selectionNotifications = 0;
view.Bind(graph, Array.Empty<FrameAnimationEditorIssue>(), flow.Id);
Assert.That(selectionNotifications, Is.Zero);
var exitRequests = 0;
view.ExitFocusRequested += () => exitRequests++;
Assert.That(view.TryHandleFocusExitShortcut(KeyCode.Escape, false), Is.True);
Assert.That(exitRequests, Is.EqualTo(1));
Assert.That(view.TryHandleFocusExitShortcut(KeyCode.Escape, true), Is.False);
view.SetFocusedFlow(string.Empty);
Assert.That(view.TryHandleFocusExitShortcut(KeyCode.Escape, false), Is.False);
}
[Test]
public void GraphView_NodeCreatedEventFiresOnlyAfterSuccessfulCreation()
{
var view = new FrameAnimationGraphView();
view.Bind(graph, Array.Empty<FrameAnimationEditorIssue>(), string.Empty);
var created = 0;
view.NodeCreated += _ => created++;
view.CreateNode(clip, new Vector2(15f, 25f));
Assert.That(created, Is.EqualTo(1));
var foreignClip = ScriptableObject.CreateInstance<FrameClip>();
try
{
foreignClip.Configure("Foreign", "Foreign", Array.Empty<FrameAnimationFrame>(), 1f,
FrameClipEndBehavior.HoldLastFrame);
view.CreateNode(foreignClip, Vector2.zero);
Assert.That(created, Is.EqualTo(1));
}
finally
{
UnityEngine.Object.DestroyImmediate(foreignClip);
}
}
private static FrameAnimationEditorSelection Select(
FrameAnimationEditorSelectionKind kind,
object value)
{
return new FrameAnimationEditorSelection(kind, value);
}
private AnimationNode AddNode(string id, string displayName = null)
{
var node = new AnimationNode(clip.Id, displayName ?? id, internalId: id);
@@ -0,0 +1,248 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using AibisDream.FrameAnimation.Editor;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
namespace AibisDream.FrameAnimation.Tests.EditMode
{
public sealed class FrameAnimationPreviewTests
{
private readonly List<UnityEngine.Object> objects = new List<UnityEngine.Object>();
[TearDown]
public void TearDown()
{
foreach (var item in objects.Where(item => item != null))
{
UnityEngine.Object.DestroyImmediate(item);
}
objects.Clear();
}
[Test]
public void SessionSnapshotAndSeek_UseTheRuntimeEvaluator()
{
var clip = Clip("Clip", 2f, FrameClipEndBehavior.HoldLastFrame, 100, 200, 300);
var graph = Graph(new[] { clip }, Array.Empty<AnimationNode>(), Array.Empty<AnimationEdge>(),
Array.Empty<AnimationFlow>());
Assert.That(FrameAnimationResolver.TryResolve(graph, clip.Id, default, out var plan, out _), Is.True);
var session = new FrameAnimationPlaybackSession(plan);
session.Start(_ => { });
session.Seek(0.075d, _ => { });
Assert.That(session.Snapshot.StepIndex, Is.EqualTo(0));
Assert.That(session.Snapshot.FrameIndex, Is.EqualTo(1));
Assert.That(session.Snapshot.FrameElapsedSeconds, Is.EqualTo(0.05d).Within(0.000001d));
Assert.That(session.Snapshot.StepSpeed, Is.EqualTo(2f));
}
[Test]
public void NodeResolver_AppliesNodeOverridesWithoutChangingClip()
{
var clip = Clip("Clip", 0.5f, FrameClipEndBehavior.HoldLastFrame, 100);
var node = new AnimationNode(clip.Id, speedOverride: 3f,
endBehaviorOverride: FrameClipEndBehavior.HideTarget);
var graph = Graph(new[] { clip }, new[] { node }, Array.Empty<AnimationEdge>(),
Array.Empty<AnimationFlow>());
Assert.That(FrameAnimationResolver.TryResolveNode(graph, node, out var plan, out var error),
Is.True, error.ToString());
Assert.That(plan.Steps.Single().PlaybackSpeed, Is.EqualTo(3f));
Assert.That(plan.Steps.Single().TerminalEndBehavior, Is.EqualTo(FrameClipEndBehavior.HideTarget));
Assert.That(plan.Steps.Single().NodeId, Is.EqualTo(node.InternalId));
Assert.That(clip.Speed, Is.EqualTo(0.5f));
}
[Test]
public void Timeline_FlattensFlowUsingEffectiveStepDurations()
{
var first = Clip("First", 1f, FrameClipEndBehavior.HoldLastFrame, 100, 100);
var second = Clip("Second", 1f, FrameClipEndBehavior.HoldLastFrame, 300);
var firstNode = new AnimationNode(first.Id, speedOverride: 2f);
var secondNode = new AnimationNode(second.Id, speedOverride: 0.5f);
var edge = new AnimationEdge(firstNode.InternalId, secondNode.InternalId);
var flow = new AnimationFlow("Flow", "Flow", firstNode.InternalId);
var graph = Graph(new[] { first, second }, new[] { firstNode, secondNode }, new[] { edge }, new[] { flow });
Assert.That(FrameAnimationResolver.TryResolve(graph, flow.Id, default, out var plan, out _), Is.True);
var timeline = new FrameAnimationPreviewTimeline(plan);
Assert.That(timeline.Segments.Count, Is.EqualTo(3));
Assert.That(timeline.Segments[2].NodeId, Is.EqualTo(secondNode.InternalId));
Assert.That(timeline.Segments[2].StartSeconds, Is.EqualTo(0.1d).Within(0.000001d));
Assert.That(timeline.DisplayDurationSeconds, Is.EqualTo(0.7d).Within(0.000001d));
Assert.That(timeline.IsInfinite, Is.False);
}
[Test]
public void Timeline_LoopUsesOneCycle_AndZeroSpeedStopsReachability()
{
var loopClip = Clip("Loop", 1f, FrameClipEndBehavior.Loop, 100, 200);
var loopNode = new AnimationNode(loopClip.Id, endBehaviorOverride: FrameClipEndBehavior.Loop);
var loopFlow = new AnimationFlow("LoopFlow", "Loop", loopNode.InternalId);
var loopGraph = Graph(new[] { loopClip }, new[] { loopNode }, Array.Empty<AnimationEdge>(), new[] { loopFlow });
FrameAnimationResolver.TryResolve(loopGraph, loopFlow.Id, default, out var loopPlan, out _);
var loopTimeline = new FrameAnimationPreviewTimeline(loopPlan);
Assert.That(loopTimeline.IsInfinite, Is.True);
Assert.That(loopTimeline.Segments.Count, Is.EqualTo(2));
Assert.That(loopTimeline.DisplayDurationSeconds, Is.EqualTo(0.3d).Within(0.000001d));
var blockedClip = Clip("Blocked", 1f, FrameClipEndBehavior.HoldLastFrame, 100, 100);
var blockedNode = new AnimationNode(blockedClip.Id, speedOverride: 0f);
var blockedGraph = Graph(new[] { blockedClip }, new[] { blockedNode }, Array.Empty<AnimationEdge>(),
Array.Empty<AnimationFlow>());
FrameAnimationResolver.TryResolveNode(blockedGraph, blockedNode, out var blockedPlan, out _);
var blockedTimeline = new FrameAnimationPreviewTimeline(blockedPlan);
Assert.That(blockedTimeline.IsBlocked, Is.True);
Assert.That(blockedTimeline.IsInfinite, Is.True);
Assert.That(blockedTimeline.Segments.Count, Is.EqualTo(1));
Assert.That(blockedTimeline.Segments[0].IsBlocked, Is.True);
}
[Test]
public void Coordinator_FlowPreviewCrossesNodesAndRestoresAutomaticPolicyOnStop()
{
var first = Clip("First", 1f, FrameClipEndBehavior.HoldLastFrame, 100);
var second = Clip("Second", 1f, FrameClipEndBehavior.Loop, 100);
var firstNode = new AnimationNode(first.Id);
var secondNode = new AnimationNode(second.Id, endBehaviorOverride: FrameClipEndBehavior.Loop);
var edge = new AnimationEdge(firstNode.InternalId, secondNode.InternalId);
var flow = new AnimationFlow("Flow", "Flow", firstNode.InternalId);
var graph = Graph(new[] { first, second }, new[] { firstNode, secondNode }, new[] { edge }, new[] { flow });
var coordinator = new FrameAnimationPreviewCoordinator();
coordinator.SetGraph(graph);
coordinator.SetTarget(FrameAnimationPreviewTargetKind.Flow, flow);
coordinator.Play();
coordinator.Tick(0.1d, graph.Nodes);
Assert.That(coordinator.IsFlowPreview, Is.True);
Assert.That(coordinator.Snapshot.NodeId, Is.EqualTo(secondNode.InternalId));
Assert.That(coordinator.GetNodeDisplay(firstNode).FlowState, Is.EqualTo(FrameAnimationFlowElementState.Passed));
Assert.That(coordinator.GetNodeDisplay(secondNode).FlowState, Is.EqualTo(FrameAnimationFlowElementState.Current));
coordinator.Stop();
Assert.That(coordinator.IsFlowPreview, Is.False);
Assert.That(coordinator.NodePolicy, Is.EqualTo(NodePreviewPolicy.SelectedOnly));
}
[Test]
public void Coordinator_FlowSeekAndStepCrossNodeBoundaries()
{
var first = Clip("First", 1f, FrameClipEndBehavior.HoldLastFrame, 100);
var second = Clip("Second", 1f, FrameClipEndBehavior.HoldLastFrame, 200, 200);
var firstNode = new AnimationNode(first.Id);
var secondNode = new AnimationNode(second.Id);
var flow = new AnimationFlow("Flow", "Flow", firstNode.InternalId);
var graph = Graph(new[] { first, second }, new[] { firstNode, secondNode },
new[] { new AnimationEdge(firstNode.InternalId, secondNode.InternalId) }, new[] { flow });
var coordinator = new FrameAnimationPreviewCoordinator();
coordinator.SetGraph(graph);
coordinator.SetTarget(FrameAnimationPreviewTargetKind.Flow, flow);
coordinator.Seek(0.1d);
Assert.That(coordinator.Snapshot.NodeId, Is.EqualTo(secondNode.InternalId));
Assert.That(coordinator.Snapshot.FrameIndex, Is.EqualTo(0));
coordinator.Step(1);
Assert.That(coordinator.Snapshot.NodeId, Is.EqualTo(secondNode.InternalId));
Assert.That(coordinator.Snapshot.FrameIndex, Is.EqualTo(1));
coordinator.Step(-1);
Assert.That(coordinator.Snapshot.FrameIndex, Is.EqualTo(0));
}
[Test]
public void GraphViewPreview_RendersOnePreviewPerNodeWithoutMutatingGraph()
{
var clip = Clip("Clip", 1f, FrameClipEndBehavior.Loop, 100);
var first = new AnimationNode(clip.Id);
var second = new AnimationNode(clip.Id);
var graph = Graph(new[] { clip }, new[] { first, second }, Array.Empty<AnimationEdge>(),
Array.Empty<AnimationFlow>());
var before = JsonUtility.ToJson(graph);
var view = new FrameAnimationGraphView();
view.Bind(graph, Array.Empty<FrameAnimationEditorIssue>(), string.Empty);
var coordinator = new FrameAnimationPreviewCoordinator();
coordinator.SetGraph(graph);
coordinator.SetNodePolicy(NodePreviewPolicy.AllVisible);
view.ApplyPreview(coordinator);
Assert.That(view.Query<FrameAnimationPreviewElement>().ToList().Count, Is.EqualTo(2));
Assert.That(JsonUtility.ToJson(graph), Is.EqualTo(before));
}
[Test]
public void PreviewSample_IsIsolatedAndStructurallyPlayable()
{
var sample = AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(
"Assets/GameContent/Test/FrameAnimation/Preview/PreviewSampleGraph.asset");
Assert.That(sample, Is.Not.Null);
Assert.That(sample.Clips.Count, Is.EqualTo(3));
Assert.That(sample.Flows.Count, Is.EqualTo(5));
Assert.That(sample.Clips.Any(item => item.Frames.Any(frame => frame.Sprite == null)), Is.True);
foreach (var flow in sample.Flows)
{
Assert.That(FrameAnimationResolver.TryResolve(sample, flow.Id, default, out _, out var error),
Is.True, error.ToString());
}
}
[Test]
public void Workbench_CreateGUIBuildsSharedAndClipPreviewElements()
{
var sample = AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(
"Assets/GameContent/Test/FrameAnimation/Preview/PreviewSampleGraph.asset");
var window = ScriptableObject.CreateInstance<FrameAnimationGraphEditorWindow>();
try
{
typeof(FrameAnimationGraphEditorWindow).GetField("graph",
BindingFlags.Instance | BindingFlags.NonPublic)?.SetValue(window, sample);
typeof(FrameAnimationGraphEditorWindow).GetMethod("CreateGUI",
BindingFlags.Instance | BindingFlags.NonPublic)?.Invoke(window, null);
Assert.That(window.rootVisualElement.Query<FrameAnimationPreviewElement>().ToList().Count,
Is.GreaterThanOrEqualTo(1));
Assert.That(window.rootVisualElement.Query<Slider>().ToList().Count,
Is.GreaterThanOrEqualTo(2));
}
finally
{
UnityEngine.Object.DestroyImmediate(window);
}
}
private FrameClip Clip(string id, float speed, FrameClipEndBehavior end, params int[] durations)
{
var clip = Track(ScriptableObject.CreateInstance<FrameClip>());
clip.Configure(id, id, durations.Select((duration, index) =>
new FrameAnimationFrame(null, duration, $"frame_{index}", index)), speed, end);
return clip;
}
private FrameAnimationGraph Graph(
IEnumerable<FrameClip> clips,
IEnumerable<AnimationNode> nodes,
IEnumerable<AnimationEdge> edges,
IEnumerable<AnimationFlow> flows)
{
var graph = Track(ScriptableObject.CreateInstance<FrameAnimationGraph>());
graph.Configure("Graph", "Graph", clips, nodes, edges, flows, string.Empty);
return graph;
}
private T Track<T>(T value) where T : UnityEngine.Object
{
objects.Add(value);
return value;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: be46a724d02949ef842fdcf646c65e76
@@ -0,0 +1,79 @@
# 帧动画系统第五阶段手动测试
## 1. 测试准备
1. 在 Unity 执行 `Tools > Frame Animation > Create Phase 5 Preview Sample`
2. 打开 `Assets/GameContent/Test/FrameAnimation/Preview/PreviewSampleGraph.asset`
3. 确认通过 Inspector 的 `Open Graph Editor` 进入工作台。
4. 首次测试前将 Node Preview Policy 设为 `SelectedOnly`、速度设为 `1×`、背景设为 `Checkerboard`
样例构建器只在资产不存在时创建,不会覆盖已经存在的 Preview Sample 或其他测试 Graph。
## 2. Clip 独立预览
依次选中 `PreviewAction``PreviewEmpty``PreviewIdle`
- 右侧显示 Clip Preview,顶部与右侧的 Play/Pause、Stop、Restart、Previous、Next 和时间轴保持同步。
- Pause 保留当前帧;Stop 回到实际第 0 帧;Restart 从第 0 帧开始播放。
- 拖动时间轴后保持 PausedPrevious/Next 定位到帧边界。
- `PreviewEmpty` 的空帧显示棋盘格并停留约 300 ms,不跳帧。
- `PreviewIdle` 展示一轮时间轴,总时长显示 `∞`,播放时持续循环。
- 切换 Dark、Light、Checkerboard 后画面背景立即更新,Graph 和 Clip 不变 dirty。
- 测试 Fit、1×、2×、3×、4×、8× 和 Manual;非 Fit 放大后可拖动画面。
## 3. Node 内嵌预览策略
1. 选择 `Finite Action` Node。
2. 在 `SelectedOnly` 下确认只有主选中 Node 自动播放。
3. 点击 Stop,确认该 Node 停在第 0 帧且不会立即自动重启;重新选择或点击 Play 后恢复。
4. 切换 `Static`,确认所有 Node 显示第一个非空代表帧,选中 Node 仍可使用顶部控制器手动播放。
5. 切换 `AllVisible`,确认视口内 Node 播放;平移使 Node 离开视口后停止更新,移回后重新开始。
6. 框选多个 Node,确认只有主选中 Node成为顶部上下文目标。
## 4. 有限 Flow 与跨节点时间轴
1. 在 Flows 选择 `PreviewFiniteFlow` 并播放。
2. 确认画布当前 Node、已经过路径和后续路径状态不同。
3. 播放跨过 `Finite Action -> Finite Empty` 时,当前 Node 和 Clip 信息随之切换。
4. 拖动顶部时间轴跨越节点边界,确认对应 Node 与内嵌 Sprite 同步定位。
5. Previous/Next 可以跨节点逐帧。
6. 播放自然结束后保留终点末帧;Stop 回到入口第 0 帧。
## 5. Loop、零速与结束行为
- `PreviewLoopFlow`:时间轴显示前缀加终点一轮,总时长为 `∞`;进入终点后进度只在终点循环段内循环。
- `PreviewBlockedFlow`:显示零速度阻塞位置,播放状态保持但不推进,时间轴不能越过阻塞点。
- `PreviewClearFlow`:自然结束后显示透明棋盘格并标记 Clear。
- `PreviewHideFlow`:自然结束后同样显示透明内容,但状态标记为 HideTarget。
- 将预览速度依次设为 `0.25× / 0.5× / 1× / 2× / 4×`,确认只改变播放快慢,不改变时间轴总长度。
## 6. Flow 预览与聚焦
1. 播放任意 Flow,按 Esc 或点击 Exit Focus。
2. 确认全部节点恢复正常亮度,但 Flow 预览继续运行。
3. 选择当前 Flow 内 Node/Edge,确认 Flow 继续播放且顶部仍显示 Flow 目标。
4. 选择 Flow 外 Node、Graph、Clip、Source 或其他 Flow,确认当前 Flow 预览停止并切换上下文。
5. 停止 Flow 后确认原 NodePreviewPolicy 恢复。
## 7. 修改、刷新与错误
- 播放 Clip/Node 时修改 Sprite、duration、speed 或结束行为,确认预览重建且不使用旧帧引用。
- 播放 Flow 时修改入口、Edge 或 Node Clip,确认 Flow 安全停止,重新 Play 后使用新路径。
- Preview/Refresh ImportSource 后确认活动预览失效并可使用刷新后的 Sprite 重新播放。
- 空 Clip、Missing Clip、负速度和非法 Flow 显示结构化错误,不抛异常、不修改资产。
- 预览期间观察 dirty 状态和 Undo 历史,确认单纯播放、拖动与缩放不产生资产修改。
## 8. 工作区恢复
1. 修改左右宽度、底部高度、搜索、筛选、排序、选择、画布 pan/zoom 和预览偏好。
2. 切换到其他 Graph 再切回,确认各 Graph 状态独立恢复。
3. 关闭并重新打开窗口,确认状态恢复。
4. 触发脚本重编译或 Domain Reload,确认状态恢复但播放进度不恢复。
5. 确认 Flow 聚焦始终从 Show All 开始,不恢复旧 focusedFlowId。
## 9. 第一版完整回归
- 按第一至第四阶段文档回归资产管理、导入刷新、节点编排、Flow 聚焦、Undo/Redo 和运行时播放。
- 在 `FrameAnimationRuntimeTest.unity` 验证 SpriteRenderer 与 Image 运行时结果和编辑器预览一致。
- 运行 EditMode、PlayMode、批处理编译和 `git diff --check`
- 对照 `Docs/动画系统需求整理.md` 第 9 节逐项确认第一版验收标准。
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b55da2d28f414fd8b0331cafc070d8d1
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -95,11 +95,14 @@
### 6.3 聚焦与共享节点
1. 从 Flow 下拉分别选择两个样例 Flow。
2. 点击 `Frame Flow`
3. 点击 `Show All`
1. 从左侧或 Flow 下拉分别选择两个样例 Flow。
2. 检查工具栏显示 `Focused: <Flow>``Exit Focus`点击 `Frame Flow`
3. 选择当前 Flow 内的 Node/Edge,再选择当前 Flow 外的 Node/Edge
4. 重新进入聚焦后,从 Flows 页切换到 Clips 或 Sources 页。
5. 重新进入聚焦后,成功拖入或右键创建一个 Node。
6. 分别使用 `Exit Focus``Show All`、下拉框 `Show All` 和画布获得焦点后的 `Esc` 退出。
预期:聚焦时仍保留全部节点和 Edge,只降低无关元素透明度;共享节点在两个 Flow 中均正常高亮;`Frame Flow` 框住当前可达节点;`Show All` 恢复全部显示
预期:聚焦时仍保留全部节点和 Edge,只适度降低无关元素透明度;共享节点在两个 Flow 中均正常高亮;Flow 内选择保持聚焦,范围外选择、离开 Flows 资源上下文和成功创建 Node 自动退出;四种主动退出方式都恢复全部亮度,其中 `Show All` 还会框住全部元素
## 7. 选择与问题定位
@@ -152,7 +155,7 @@
3. 点击 `Save`,关闭窗口并重新打开。
4. 触发脚本重新编译或 Domain Reload。
预期:每个具名事务完整恢复;不会出现只有画布变化而数据未恢复的情况;internalId、Edge、Flow 入口和节点位置稳定;当前 Flow 聚焦和有效 Node/Edge 选择尽量恢复;失效选择会自动清理
预期:每个具名事务完整恢复;不会出现只有画布变化而数据未恢复的情况;internalId、Edge、Flow 入口和节点位置稳定;同一窗口会话内的有效 Flow 聚焦在 Undo/Redo 后重新计算,失效聚焦自动退出;窗口重开或 Domain Reload 后始终为 `Show All`,不会恢复旧聚焦
## 11. 综合校验