feat: 动画系统第五阶段(未验收)
This commit is contained in:
@@ -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 帧开始播放。
|
||||
- 拖动时间轴后保持 Paused,Previous/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. 综合校验
|
||||
|
||||
|
||||
Reference in New Issue
Block a user