Merge branch 'feature/帧动画编辑器重构' into 'develop'

Feature/帧动画编辑器重构

See merge request aibis-dream/aibis-dream!771
This commit is contained in:
2026-07-22 14:30:43 +00:00
15 changed files with 3025 additions and 282 deletions
@@ -0,0 +1,254 @@
using System;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace AibisDream.FrameAnimation.Editor
{
internal enum FrameAnimationBrowserEntryKind
{
Header,
Clip,
Flow,
Source
}
internal enum FrameAnimationBrowserIssueState
{
None,
Warning,
Error
}
internal sealed class FrameAnimationBrowserEntry
{
public FrameAnimationBrowserEntryKind Kind;
public string Title = string.Empty;
public string Id = string.Empty;
public string TypeLabel = string.Empty;
public string Meta = string.Empty;
public string StatusLabel = string.Empty;
public Color? AccentColor;
public FrameAnimationBrowserIssueState IssueState;
public FrameAnimationEditorSelection Selection;
public bool IsHeader => Kind == FrameAnimationBrowserEntryKind.Header;
}
internal readonly struct FrameAnimationFlowColorPalette
{
public readonly Color Raw;
public readonly Color Stroke;
public readonly Color Header;
public readonly Color Muted;
public readonly Color Text;
public FrameAnimationFlowColorPalette(Color raw, Color stroke, Color header, Color muted, Color text)
{
Raw = raw;
Stroke = stroke;
Header = header;
Muted = muted;
Text = text;
}
}
internal static class FrameAnimationFlowColorUtility
{
private static readonly Color Panel = new Color32(43, 47, 52, 255);
private static readonly Color LightText = new Color32(244, 246, 248, 255);
private static readonly Color DarkText = new Color32(24, 27, 30, 255);
private static readonly Color Neutral = new Color32(100, 168, 216, 255);
internal static Color ResolveRaw(FrameAnimationGraph graph, AnimationFlow flow)
{
return Opaque(ResolveStored(graph, flow));
}
internal static Color ResolveStored(FrameAnimationGraph graph, AnimationFlow flow)
{
if (graph == null || flow == null)
{
return Neutral;
}
var data = graph.EditorData?.FlowEditorData == null
? null
: System.Linq.Enumerable.FirstOrDefault(graph.EditorData.FlowEditorData,
item => item != null && item.FlowId == flow.Id);
return data?.Color ?? Neutral;
}
internal static FrameAnimationFlowColorPalette CreatePalette(Color value)
{
var raw = Opaque(value);
var luminance = Luminance(raw);
var stroke = luminance < 0.30f
? Color.Lerp(raw, Color.white, Mathf.InverseLerp(0.30f, 0.02f, luminance) * 0.42f)
: luminance > 0.82f
? Color.Lerp(raw, Color.black, Mathf.InverseLerp(0.82f, 1f, luminance) * 0.22f)
: raw;
stroke.a = 1f;
var header = Color.Lerp(Panel, stroke, 0.68f);
header.a = 1f;
var muted = Color.Lerp(Panel, stroke, 0.42f);
muted.a = 1f;
var text = Luminance(header) > 0.22f ? DarkText : LightText;
return new FrameAnimationFlowColorPalette(raw, stroke, header, muted, text);
}
internal static Color Opaque(Color value) => new Color(
Mathf.Clamp01(value.r), Mathf.Clamp01(value.g), Mathf.Clamp01(value.b), 1f);
internal static float Luminance(Color value)
{
var linear = value.linear;
return linear.r * 0.2126f + linear.g * 0.7152f + linear.b * 0.0722f;
}
}
internal sealed class FrameAnimationResourceRowElement : VisualElement
{
private readonly VisualElement flowDot;
private readonly Label typeBadge;
private readonly Label titleLabel;
private readonly Label idLabel;
private readonly Label metaLabel;
private readonly Label statusBadge;
internal FrameAnimationResourceRowElement()
{
AddToClassList("fa-resource-row");
var main = new VisualElement();
main.AddToClassList("fa-resource-row__main");
flowDot = new VisualElement { pickingMode = PickingMode.Ignore };
flowDot.AddToClassList("fa-flow-dot");
typeBadge = new Label { pickingMode = PickingMode.Ignore };
typeBadge.AddToClassList("fa-badge");
titleLabel = new Label { pickingMode = PickingMode.Ignore };
titleLabel.AddToClassList("fa-resource-row__name");
idLabel = new Label { pickingMode = PickingMode.Ignore };
idLabel.AddToClassList("fa-resource-row__id");
statusBadge = new Label { pickingMode = PickingMode.Ignore };
statusBadge.AddToClassList("fa-badge");
main.Add(flowDot);
main.Add(typeBadge);
main.Add(titleLabel);
main.Add(idLabel);
main.Add(statusBadge);
metaLabel = new Label { pickingMode = PickingMode.Ignore };
metaLabel.AddToClassList("fa-resource-row__meta");
Add(main);
Add(metaLabel);
}
internal void Bind(FrameAnimationBrowserEntry entry, bool selected)
{
userData = entry;
var isHeader = entry?.IsHeader == true;
EnableInClassList("fa-resource-group", isHeader);
EnableInClassList("fa-resource-row--selected", selected);
EnableInClassList("fa-resource-row--error", entry?.IssueState == FrameAnimationBrowserIssueState.Error);
EnableInClassList("fa-resource-row--warning", entry?.IssueState == FrameAnimationBrowserIssueState.Warning);
if (entry == null)
{
style.display = DisplayStyle.None;
return;
}
style.display = DisplayStyle.Flex;
titleLabel.text = entry.Title;
titleLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
idLabel.text = entry.Id;
metaLabel.text = entry.Meta;
typeBadge.text = entry.TypeLabel;
typeBadge.style.display = isHeader ? DisplayStyle.None : DisplayStyle.Flex;
idLabel.style.display = isHeader ? DisplayStyle.None : DisplayStyle.Flex;
metaLabel.style.display = isHeader ? DisplayStyle.None : DisplayStyle.Flex;
statusBadge.text = entry.StatusLabel;
statusBadge.style.display = isHeader || string.IsNullOrEmpty(entry.StatusLabel) ? DisplayStyle.None : DisplayStyle.Flex;
flowDot.style.display = !isHeader && entry.AccentColor.HasValue ? DisplayStyle.Flex : DisplayStyle.None;
if (entry.AccentColor.HasValue)
{
flowDot.style.backgroundColor = FrameAnimationFlowColorUtility.CreatePalette(entry.AccentColor.Value).Stroke;
}
typeBadge.EnableInClassList("fa-badge--blue", entry.Kind == FrameAnimationBrowserEntryKind.Clip);
typeBadge.EnableInClassList("fa-badge--purple", entry.Kind == FrameAnimationBrowserEntryKind.Flow);
typeBadge.EnableInClassList("fa-badge--green", entry.Kind == FrameAnimationBrowserEntryKind.Source);
statusBadge.EnableInClassList("fa-badge--red", entry.IssueState == FrameAnimationBrowserIssueState.Error);
statusBadge.EnableInClassList("fa-badge--yellow", entry.IssueState == FrameAnimationBrowserIssueState.Warning);
}
}
internal sealed class FrameAnimationFrameRowElement : VisualElement
{
private readonly ObjectField spriteField;
private readonly IntegerField durationField;
private readonly Label frameNameLabel;
private readonly Label sourceIndexLabel;
private FrameClip clip;
private int index;
private bool readOnly;
private Action changed;
internal FrameAnimationFrameRowElement()
{
AddToClassList("fa-frame-row");
spriteField = new ObjectField("Sprite") { objectType = typeof(Sprite), allowSceneObjects = false };
durationField = new IntegerField("Duration Ms");
var readOnlyRow = new VisualElement();
readOnlyRow.AddToClassList("fa-frame-row__readonly");
frameNameLabel = new Label();
sourceIndexLabel = new Label();
readOnlyRow.Add(frameNameLabel);
readOnlyRow.Add(sourceIndexLabel);
Add(spriteField);
Add(durationField);
Add(readOnlyRow);
spriteField.RegisterValueChangedCallback(OnSpriteChanged);
durationField.RegisterValueChangedCallback(OnDurationChanged);
}
internal void Bind(FrameClip value, int itemIndex, bool isReadOnly, Action onChanged)
{
clip = value;
index = itemIndex;
readOnly = isReadOnly;
changed = onChanged;
userData = itemIndex;
var frame = clip != null && index >= 0 && index < clip.Frames.Count ? clip.Frames[index] : null;
spriteField.SetValueWithoutNotify(frame?.Sprite);
durationField.SetValueWithoutNotify(frame?.DurationMs ?? 100);
frameNameLabel.text = "Frame " + (frame?.FrameName ?? string.Empty);
sourceIndexLabel.text = "Source " + (frame?.SourceIndex.ToString() ?? "-1");
spriteField.SetEnabled(!readOnly);
durationField.SetEnabled(!readOnly);
}
private void OnSpriteChanged(ChangeEvent<UnityEngine.Object> evt)
{
if (readOnly || clip == null || index < 0 || index >= clip.Frames.Count) return;
var serialized = new SerializedObject(clip);
serialized.Update();
var element = serialized.FindProperty("frames").GetArrayElementAtIndex(index);
element.FindPropertyRelative("sprite").objectReferenceValue = evt.newValue;
serialized.ApplyModifiedProperties();
EditorUtility.SetDirty(clip);
changed?.Invoke();
}
private void OnDurationChanged(ChangeEvent<int> evt)
{
if (readOnly || clip == null || index < 0 || index >= clip.Frames.Count) return;
var serialized = new SerializedObject(clip);
serialized.Update();
var element = serialized.FindProperty("frames").GetArrayElementAtIndex(index);
element.FindPropertyRelative("durationMs").intValue = Mathf.Max(1, evt.newValue);
serialized.ApplyModifiedProperties();
durationField.SetValueWithoutNotify(Mathf.Max(1, evt.newValue));
EditorUtility.SetDirty(clip);
changed?.Invoke();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 876191b33b10eef44b133cc291ebc912
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,3 +1,642 @@
:root {
--fa-bg: rgb(27, 29, 32);
--fa-canvas: rgb(30, 32, 35);
--fa-panel: rgb(36, 39, 43);
--fa-panel-raised: rgb(43, 46, 51);
--fa-panel-hover: rgb(48, 52, 58);
--fa-field: rgb(28, 31, 34);
--fa-line: rgb(17, 19, 21);
--fa-line-soft: rgb(62, 67, 74);
--fa-text: rgb(215, 217, 220);
--fa-muted: rgb(143, 150, 158);
--fa-accent: rgb(61, 145, 212);
--fa-accent-soft: rgb(36, 79, 112);
--fa-success: rgb(86, 185, 123);
--fa-warning: rgb(224, 184, 88);
--fa-error: rgb(223, 106, 106);
}
.fa-workbench {
flex-grow: 1;
flex-direction: column;
background-color: var(--fa-bg);
color: var(--fa-text);
font-size: 12px;
}
.fa-global-toolbar-host,
.fa-main-host,
.fa-bottom-host {
flex-shrink: 0;
}
.fa-main-host {
flex-grow: 1;
min-height: 0;
}
.fa-workbench .unity-toolbar {
min-height: 32px;
padding-left: 6px;
padding-right: 6px;
background-color: var(--fa-panel-raised);
border-bottom-width: 1px;
border-bottom-color: var(--fa-line);
}
.fa-workbench .unity-toolbar-button,
.fa-workbench .unity-button {
min-height: 24px;
margin-left: 2px;
margin-right: 2px;
padding-left: 8px;
padding-right: 8px;
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-left-color: var(--fa-line);
border-right-color: var(--fa-line);
border-top-color: var(--fa-line);
border-bottom-color: var(--fa-line);
border-top-left-radius: 3px;
border-top-right-radius: 3px;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
background-color: rgb(54, 58, 63);
color: var(--fa-text);
}
.fa-workbench .unity-toolbar-button:hover,
.fa-workbench .unity-button:hover {
background-color: rgb(70, 75, 81);
}
.fa-button--primary {
background-color: rgb(42, 105, 150);
border-left-color: rgb(25, 74, 104);
border-right-color: rgb(25, 74, 104);
border-top-color: rgb(25, 74, 104);
border-bottom-color: rgb(25, 74, 104);
}
.fa-button--danger {
background-color: rgb(87, 54, 56);
color: rgb(244, 205, 207);
}
.fa-toolbar-group {
flex-direction: row;
align-items: center;
flex-shrink: 0;
}
.fa-toolbar-separator {
width: 1px;
height: 21px;
margin-left: 5px;
margin-right: 5px;
background-color: var(--fa-line);
}
.fa-panel {
flex-direction: column;
flex-shrink: 0;
min-height: 0;
background-color: var(--fa-panel);
}
.fa-panel-header {
height: 28px;
min-height: 28px;
padding-left: 9px;
padding-right: 9px;
-unity-text-align: middle-left;
-unity-font-style: bold;
background-color: rgb(44, 47, 51);
border-bottom-width: 1px;
border-bottom-color: var(--fa-line);
}
.fa-resizer--vertical {
width: 4px;
background-color: rgb(24, 26, 29);
}
.fa-resizer--horizontal {
height: 4px;
background-color: rgb(24, 26, 29);
}
.fa-resizer--vertical:hover,
.fa-resizer--horizontal:hover {
background-color: var(--fa-accent);
}
.fa-resource-tabs {
min-height: 34px;
padding-left: 4px;
padding-right: 4px;
padding-top: 3px;
background-color: rgb(31, 34, 38);
}
.fa-resource-tabs .unity-toolbar-button,
.fa-bottom-tabs .unity-toolbar-button {
flex-grow: 1;
flex-basis: 0;
min-height: 28px;
margin-left: 1px;
margin-right: 1px;
border-left-width: 0;
border-right-width: 0;
border-top-width: 0;
border-bottom-width: 2px;
border-bottom-color: rgba(0, 0, 0, 0);
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
background-color: rgba(0, 0, 0, 0);
color: rgb(169, 175, 182);
}
.fa-resource-tabs .unity-toolbar-button:hover,
.fa-bottom-tabs .unity-toolbar-button:hover {
color: var(--fa-text);
background-color: rgb(48, 52, 57);
}
.fa-resource-tabs .unity-toolbar-button.fa-tab--active,
.fa-bottom-tabs .unity-toolbar-button.fa-tab--active {
color: white;
background-color: var(--fa-panel-raised);
border-bottom-color: var(--fa-accent);
-unity-font-style: bold;
}
.fa-resource-tools {
padding-left: 7px;
padding-right: 7px;
padding-top: 7px;
padding-bottom: 7px;
border-bottom-width: 1px;
border-bottom-color: var(--fa-line);
}
.fa-resource-list {
flex-grow: 1;
min-height: 0;
background-color: var(--fa-panel);
}
.fa-resource-row {
height: 48px;
padding-left: 9px;
padding-right: 7px;
padding-top: 5px;
padding-bottom: 4px;
border-left-width: 3px;
border-left-color: rgba(0, 0, 0, 0);
border-bottom-width: 1px;
border-bottom-color: rgb(32, 35, 39);
}
.fa-resource-row:hover {
background-color: var(--fa-panel-hover);
}
.fa-resource-row--selected {
background-color: rgb(37, 75, 103);
border-left-color: rgb(98, 178, 234);
}
.fa-resource-row--error {
border-left-color: var(--fa-error);
}
.fa-resource-row--warning {
border-left-color: var(--fa-warning);
}
.fa-resource-row__main,
.fa-resource-row__meta,
.fa-badge-row {
flex-direction: row;
align-items: center;
}
.fa-resource-row__main {
height: 20px;
}
.fa-resource-row__name {
flex-grow: 1;
min-width: 0;
-unity-font-style: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fa-resource-row__id {
max-width: 115px;
margin-left: 5px;
color: rgb(156, 163, 171);
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fa-resource-row__meta {
height: 17px;
color: var(--fa-muted);
font-size: 10px;
}
.fa-resource-group {
height: 27px;
padding-left: 8px;
padding-right: 8px;
-unity-text-align: middle-left;
-unity-font-style: bold;
color: rgb(184, 189, 195);
background-color: rgb(41, 44, 49);
border-top-width: 1px;
border-top-color: rgb(52, 56, 61);
border-bottom-width: 1px;
border-bottom-color: rgb(26, 28, 31);
}
.fa-badge {
min-height: 16px;
margin-right: 4px;
padding-left: 5px;
padding-right: 5px;
-unity-text-align: middle-center;
font-size: 9px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
background-color: rgb(52, 56, 61);
color: rgb(174, 180, 187);
}
.fa-badge--blue { background-color: rgb(37, 63, 85); color: rgb(143, 202, 239); }
.fa-badge--green { background-color: rgb(35, 63, 49); color: rgb(131, 209, 160); }
.fa-badge--yellow { background-color: rgb(74, 62, 37); color: rgb(240, 200, 111); }
.fa-badge--red { background-color: rgb(75, 41, 44); color: rgb(239, 141, 147); }
.fa-badge--purple { background-color: rgb(59, 48, 75); color: rgb(195, 168, 232); }
.fa-flow-dot {
width: 8px;
height: 8px;
margin-right: 6px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
.fa-canvas-panel {
flex-grow: 1;
min-width: 280px;
min-height: 0;
flex-direction: column;
background-color: var(--fa-canvas);
}
.fa-canvas-toolbar {
min-height: 32px;
}
.fa-toolbar-label {
margin-left: 2px;
margin-right: 4px;
color: var(--fa-muted);
-unity-text-align: middle-left;
}
.fa-canvas-toolbar #flow-focus-field {
flex-shrink: 1;
}
.fa-flow-focus {
min-width: 110px;
max-width: 240px;
padding-left: 7px;
padding-right: 7px;
-unity-font-style: bold;
-unity-text-align: middle-left;
border-left-width: 3px;
border-left-color: rgba(0, 0, 0, 0);
}
.fa-empty-state {
color: var(--fa-muted);
}
.fa-node {
min-width: 224px;
width: 224px;
max-width: 224px;
min-height: 164px;
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-left-color: var(--fa-line);
border-right-color: var(--fa-line);
border-top-color: var(--fa-line);
border-bottom-color: var(--fa-line);
border-top-left-radius: 5px;
border-top-right-radius: 5px;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
background-color: rgb(43, 47, 52);
}
.fa-node .title {
min-height: 31px;
padding-left: 7px;
padding-right: 7px;
background-color: rgb(46, 111, 155);
border-bottom-width: 1px;
border-bottom-color: var(--fa-line);
}
.fa-node .title-label {
-unity-font-style: bold;
}
.fa-node .input,
.fa-node .output {
min-width: 12px;
}
.fa-node__meta {
flex-direction: row;
justify-content: space-between;
margin-left: 8px;
margin-right: 8px;
margin-top: 4px;
margin-bottom: 4px;
color: rgb(175, 181, 188);
font-size: 10px;
}
.fa-node__clip-id {
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.fa-node__behavior {
margin-left: 5px;
color: rgb(201, 205, 210);
}
.fa-node__badges {
min-height: 21px;
margin-left: 8px;
margin-right: 8px;
margin-top: 5px;
flex-direction: row;
align-items: center;
overflow: hidden;
}
.fa-node__entry {
min-width: 16px;
height: 16px;
margin-right: 5px;
padding-left: 3px;
padding-right: 3px;
-unity-text-align: middle-center;
-unity-font-style: bold;
font-size: 9px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.fa-node__preview {
height: 78px;
margin-left: 8px;
margin-right: 8px;
margin-top: 1px;
}
.fa-node--focused {
border-left-width: 2px;
}
.fa-node.selected {
border-left-width: 2px;
border-right-width: 2px;
border-top-width: 2px;
border-bottom-width: 2px;
border-left-color: rgb(105, 185, 237);
border-right-color: rgb(105, 185, 237);
border-top-color: rgb(105, 185, 237);
border-bottom-color: rgb(105, 185, 237);
}
.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-inspector-scroll,
.fa-bottom-scroll {
flex-grow: 1;
min-height: 0;
background-color: var(--fa-panel);
}
.fa-inspector-title {
padding-left: 11px;
padding-right: 11px;
padding-top: 10px;
padding-bottom: 8px;
border-bottom-width: 1px;
border-bottom-color: var(--fa-line);
}
.fa-inspector-title__name {
font-size: 15px;
-unity-font-style: bold;
}
.fa-inspector-title__id {
margin-top: 3px;
color: var(--fa-muted);
font-size: 10px;
}
.fa-section {
padding-bottom: 8px;
border-bottom-width: 1px;
border-bottom-color: var(--fa-line);
}
.fa-section__title {
height: 27px;
padding-left: 10px;
-unity-text-align: middle-left;
-unity-font-style: bold;
background-color: rgb(41, 44, 48);
}
.fa-section__body {
padding-left: 10px;
padding-right: 10px;
padding-top: 7px;
}
.fa-section__body > .unity-base-field,
.fa-section__body > .unity-button,
.fa-section__body > .fa-readonly-row,
.fa-section__body > .fa-callout {
margin-top: 2px;
margin-bottom: 2px;
}
.fa-readonly-row {
min-height: 22px;
flex-direction: row;
align-items: center;
}
.fa-readonly-row__label {
width: 118px;
color: rgb(173, 179, 186);
}
.fa-readonly-row__value {
flex-grow: 1;
min-width: 0;
color: rgb(197, 202, 208);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.fa-callout {
padding-left: 9px;
padding-right: 9px;
padding-top: 7px;
padding-bottom: 7px;
white-space: normal;
border-left-width: 3px;
border-left-color: var(--fa-accent);
background-color: rgb(34, 48, 58);
color: rgb(188, 209, 223);
}
.fa-callout--warning { border-left-color: var(--fa-warning); background-color: rgb(57, 51, 33); color: rgb(228, 208, 161); }
.fa-callout--error { border-left-color: var(--fa-error); background-color: rgb(63, 39, 42); color: rgb(239, 184, 188); }
.fa-frame-row {
min-height: 72px;
padding-left: 5px;
padding-right: 5px;
padding-top: 4px;
padding-bottom: 4px;
border-bottom-width: 1px;
border-bottom-color: rgb(48, 51, 55);
}
.fa-frame-row__readonly {
flex-direction: row;
}
.fa-frame-row__readonly > .unity-label {
flex-grow: 1;
color: var(--fa-muted);
font-size: 10px;
}
.fa-bottom-panel {
flex-direction: column;
min-height: 28px;
background-color: rgb(32, 35, 39);
border-top-width: 1px;
border-top-color: var(--fa-line);
}
.fa-bottom-toolbar {
min-height: 31px;
padding-left: 4px;
padding-right: 4px;
}
.fa-bottom-tabs {
min-width: 230px;
height: 30px;
flex-direction: row;
align-items: stretch;
}
.fa-bottom-tabs .unity-toolbar-button {
min-width: 108px;
padding-left: 14px;
padding-right: 14px;
}
.fa-bottom-toolbar .fa-bottom-collapse {
width: 28px;
min-width: 28px;
padding-left: 0;
padding-right: 0;
font-size: 14px;
}
.fa-result-controls {
min-height: 31px;
flex-direction: row;
align-items: center;
padding-left: 8px;
padding-right: 8px;
border-bottom-width: 1px;
border-bottom-color: var(--fa-line);
}
.fa-result-header,
.fa-result-row {
min-height: 29px;
flex-direction: row;
align-items: center;
padding-left: 10px;
padding-right: 10px;
border-bottom-width: 1px;
border-bottom-color: rgb(41, 44, 48);
}
.fa-result-header {
min-height: 26px;
color: var(--fa-muted);
background-color: rgb(40, 43, 48);
}
.fa-result-row:hover { background-color: var(--fa-panel-hover); }
.fa-result-severity { width: 82px; -unity-font-style: bold; }
.fa-result-kind { width: 135px; }
.fa-result-object { width: 180px; }
.fa-result-message { flex-grow: 1; min-width: 260px; white-space: normal; }
.fa-severity--error { color: var(--fa-error); }
.fa-severity--warning { color: var(--fa-warning); }
.fa-severity--info { color: rgb(114, 189, 232); }
.fa-preview {
position: relative;
overflow: hidden;
@@ -25,26 +664,6 @@
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;
@@ -55,6 +674,26 @@
border-bottom-color: rgb(28, 28, 28);
}
.fa-selection-preview-header {
min-height: 34px;
flex-direction: column;
justify-content: center;
}
.fa-selection-preview-title {
color: var(--fa-text);
-unity-font-style: bold;
}
.fa-selection-preview-target {
margin-top: 1px;
color: var(--fa-muted);
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fa-clip-preview-panel .fa-preview {
height: 190px;
margin-bottom: 4px;
@@ -63,10 +702,82 @@
.fa-preview-controls {
flex-direction: row;
align-items: center;
flex-shrink: 0;
min-height: 27px;
padding-left: 0;
padding-right: 0;
}
.fa-selection-preview-panel .fa-preview-controls .fa-preview-button {
width: 24px;
min-width: 24px;
max-width: 24px;
height: 24px;
min-height: 24px;
max-height: 24px;
padding-left: 0;
padding-right: 0;
padding-top: 0;
padding-bottom: 0;
margin-left: 1px;
margin-right: 1px;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
.fa-preview-button__icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.fa-preview-timeline {
min-width: 28px;
flex-grow: 1;
flex-shrink: 1;
margin-left: 5px;
margin-right: 5px;
}
.fa-preview-time {
width: 82px;
min-width: 82px;
flex-shrink: 0;
white-space: nowrap;
-unity-text-align: middle-right;
}
.fa-preview-settings {
min-height: 27px;
flex-direction: row;
align-items: center;
}
.fa-preview-setting {
flex-grow: 1;
flex-basis: 0;
min-width: 0;
margin-right: 5px;
flex-direction: row;
align-items: center;
}
.fa-preview-setting > .unity-label {
width: 64px;
min-width: 64px;
color: var(--fa-muted);
font-size: 10px;
}
.fa-preview-setting__field {
flex-grow: 1;
min-width: 0;
}
.fa-preview-status {
font-size: 10px;
color: rgb(185, 185, 185);
margin-top: 2px;
white-space: normal;
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<ui:UXML xmlns:ui="UnityEngine.UIElements" editor-extension-mode="True">
<ui:VisualElement name="fa-workbench" class="fa-workbench">
<ui:VisualElement name="global-toolbar-host" class="fa-global-toolbar-host" />
<ui:VisualElement name="main-host" class="fa-main-host" />
<ui:VisualElement name="bottom-host" class="fa-bottom-host" />
</ui:VisualElement>
</ui:UXML>
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 3286d11c482fe8640a72c689bed6780c
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
File diff suppressed because it is too large Load Diff
@@ -12,10 +12,12 @@ namespace AibisDream.FrameAnimation.Editor
{
private readonly Label subtitle;
private readonly Label behavior;
private readonly Label flowBadge;
private readonly Label issueBadge;
private readonly Label entryBadge;
private readonly VisualElement badgeContainer;
private readonly FrameAnimationPreviewElement preview;
private readonly Action<FrameAnimationClipNodeView> selectedCallback;
private FrameAnimationFlowColorPalette? entryPalette;
public AnimationNode Data { get; }
public Port InputPort { get; }
@@ -29,30 +31,40 @@ namespace AibisDream.FrameAnimation.Editor
this.selectedCallback = selectedCallback;
viewDataKey = data.InternalId;
AddToClassList("fa-node");
style.minWidth = 245f;
style.maxWidth = 285f;
InputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi,
typeof(AnimationNode));
InputPort.portName = "In";
InputPort.portName = string.Empty;
inputContainer.Add(InputPort);
OutputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Single,
typeof(AnimationNode));
OutputPort.portName = "Next";
OutputPort.portName = string.Empty;
outputContainer.Add(OutputPort);
subtitle = new Label();
subtitle.AddToClassList("fa-node__clip-id");
behavior = new Label();
flowBadge = new Label();
issueBadge = new Label();
issueBadge.style.unityFontStyleAndWeight = FontStyle.Bold;
extensionContainer.Add(subtitle);
extensionContainer.Add(behavior);
extensionContainer.Add(flowBadge);
extensionContainer.Add(issueBadge);
behavior.AddToClassList("fa-node__behavior");
var meta = new VisualElement();
meta.AddToClassList("fa-node__meta");
meta.Add(subtitle);
meta.Add(behavior);
extensionContainer.Add(meta);
preview = new FrameAnimationPreviewElement();
preview.AddToClassList("fa-node__preview");
extensionContainer.Add(preview);
badgeContainer = new VisualElement();
badgeContainer.AddToClassList("fa-node__badges");
entryBadge = new Label("E") { tooltip = "Flow Entry" };
entryBadge.AddToClassList("fa-node__entry");
badgeContainer.Add(entryBadge);
issueBadge = new Label();
issueBadge.AddToClassList("fa-badge");
issueBadge.style.unityFontStyleAndWeight = FontStyle.Bold;
badgeContainer.Add(issueBadge);
extensionContainer.Add(badgeContainer);
RefreshExpandedState();
}
@@ -65,28 +77,67 @@ namespace AibisDream.FrameAnimation.Editor
internal void Refresh(
FrameAnimationGraph graph,
IReadOnlyList<FrameAnimationEditorIssue> issues,
IReadOnlyList<AnimationFlow> flows,
IReadOnlyCollection<string> entryFlowIds)
IReadOnlyList<AnimationFlow> flows)
{
var clip = graph.Clips.FirstOrDefault(item => item != null && item.Id == Data.ClipId);
title = string.IsNullOrWhiteSpace(Data.DisplayName) ? Data.ClipId : Data.DisplayName;
subtitle.text = clip != null ? $"Clip: {Data.ClipId}" : $"Clip: <Missing {Data.ClipId}>";
subtitle.text = clip != null ? Data.ClipId : $"Missing: {Data.ClipId}";
var speed = Data.SpeedOverride.HasValue
? $"Speed {Data.SpeedOverride.Value:0.###} (Node)"
: clip != null ? $"Speed {clip.Speed:0.###} (Clip)" : "Speed ?";
var end = Data.EndBehaviorOverride?.ToString() ?? "Continue / terminal fallback";
behavior.text = $"{speed} · {end}";
var used = flows.Select(flow => flow.Id).ToArray();
var entries = used.Where(entryFlowIds.Contains).ToArray();
flowBadge.text = used.Length == 0
? "Unused"
: "Flows: " + string.Join(", ", used.Take(3)) + (used.Length > 3 ? $" +{used.Length - 3}" : string.Empty) +
(entries.Length > 0 ? " [ENTRY]" : string.Empty);
var used = flows.Where(flow => flow != null).ToArray();
var entryFlows = used.Where(flow => flow.EntryNodeId == Data.InternalId).ToArray();
entryPalette = entryFlows.Length > 0
? FrameAnimationFlowColorUtility.CreatePalette(
FrameAnimationFlowColorUtility.ResolveRaw(graph, entryFlows[0]))
: (FrameAnimationFlowColorPalette?)null;
entryBadge.style.display = entryPalette.HasValue ? DisplayStyle.Flex : DisplayStyle.None;
if (entryPalette.HasValue)
{
entryBadge.style.backgroundColor = entryPalette.Value.Stroke;
entryBadge.style.color = entryPalette.Value.Text;
entryBadge.tooltip = "Flow Entry: " + string.Join(", ", entryFlows.Select(flow => flow.Id));
}
foreach (var label in badgeContainer.Query<Label>(className: "fa-flow-badge").ToList())
{
label.RemoveFromHierarchy();
}
if (used.Length == 0)
{
var unused = new Label("UNUSED");
unused.AddToClassList("fa-badge");
unused.AddToClassList("fa-badge--yellow");
unused.AddToClassList("fa-flow-badge");
badgeContainer.Insert(entryPalette.HasValue ? 1 : 0, unused);
}
else
{
var insertIndex = entryPalette.HasValue ? 1 : 0;
foreach (var flow in used.Take(3))
{
var palette = FrameAnimationFlowColorUtility.CreatePalette(
FrameAnimationFlowColorUtility.ResolveRaw(graph, flow));
var badge = new Label(flow.Id) { tooltip = FlowTooltip(flow) };
badge.AddToClassList("fa-badge");
badge.AddToClassList("fa-flow-badge");
badge.style.backgroundColor = palette.Muted;
badge.style.color = palette.Text;
badgeContainer.Insert(insertIndex++, badge);
}
}
var matching = issues.Where(issue => issue.Selection.Kind == FrameAnimationEditorSelectionKind.Node &&
ReferenceEquals(issue.Selection.Value, Data)).ToArray();
issueBadge.text = matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error)
? "ERROR"
: matching.Length > 0 ? "WARNING" : string.Empty;
issueBadge.style.display = matching.Length > 0 ? DisplayStyle.Flex : DisplayStyle.None;
issueBadge.EnableInClassList("fa-badge--red",
matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error));
issueBadge.EnableInClassList("fa-badge--yellow",
matching.Length > 0 && matching.All(issue => issue.Severity != FrameAnimationValidationSeverity.Error));
issueBadge.style.color = matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error)
? new StyleColor(new Color(1f, 0.35f, 0.3f))
: new StyleColor(new Color(1f, 0.72f, 0.25f));
@@ -96,6 +147,30 @@ namespace AibisDream.FrameAnimation.Editor
: "创建唯一的顺序后继";
}
private static string FlowTooltip(AnimationFlow flow) =>
$"Flow: {flow.DisplayName} ({flow.Id})";
internal void SetFocusedFlow(FrameAnimationGraph graph, AnimationFlow flow, bool belongs)
{
EnableInClassList("fa-node--focused", flow != null && belongs);
if (flow != null && belongs)
{
var palette = FrameAnimationFlowColorUtility.CreatePalette(
FrameAnimationFlowColorUtility.ResolveRaw(graph, flow));
titleContainer.style.backgroundColor = palette.Header;
var titleLabel = titleContainer.Q<Label>(className: "title-label");
if (titleLabel != null) titleLabel.style.color = palette.Text;
style.borderLeftColor = palette.Stroke;
}
else
{
titleContainer.style.backgroundColor = StyleKeyword.Null;
var titleLabel = titleContainer.Q<Label>(className: "title-label");
if (titleLabel != null) titleLabel.style.color = StyleKeyword.Null;
style.borderLeftColor = StyleKeyword.Null;
}
}
internal void SetDimmed(bool dimmed)
{
style.opacity = dimmed ? 0.45f : 1f;
@@ -119,7 +194,10 @@ namespace AibisDream.FrameAnimation.Editor
internal sealed class FrameAnimationEdgeView : Edge
{
private readonly Action<FrameAnimationEdgeView> selectedCallback;
private Color baseColor = Color.white;
private Color baseColor = new Color32(119, 127, 135, 255);
private bool hasError;
private bool hasWarning;
private FrameAnimationFlowElementState previewState;
public AnimationEdge Data { get; }
internal FrameAnimationEdgeView(AnimationEdge data, Action<FrameAnimationEdgeView> selectedCallback)
@@ -128,6 +206,7 @@ namespace AibisDream.FrameAnimation.Editor
this.selectedCallback = selectedCallback;
viewDataKey = data.InternalId;
userData = data;
RegisterCallback<AttachToPanelEvent>(_ => ApplyVisualState());
}
public override void OnSelected()
@@ -146,12 +225,9 @@ namespace AibisDream.FrameAnimation.Editor
var matching = (issues ?? Array.Empty<FrameAnimationEditorIssue>()).Where(issue =>
issue.Selection.Kind == FrameAnimationEditorSelectionKind.Edge &&
ReferenceEquals(issue.Selection.Value, Data)).ToArray();
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;
hasError = matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error);
hasWarning = !hasError && matching.Length > 0;
ApplyVisualState();
tooltip = matching.Length == 0
? $"{Data.ExitName} / {Data.Condition}"
: string.Join("\n", matching.Select(issue => $"[{issue.Code}] {issue.Message}"));
@@ -159,25 +235,47 @@ namespace AibisDream.FrameAnimation.Editor
internal void SetPreviewState(FrameAnimationFlowElementState state)
{
if (state == FrameAnimationFlowElementState.Current)
previewState = state;
ApplyVisualState();
}
internal void SetFocusedFlow(Color? flowColor, bool belongs)
{
baseColor = flowColor.HasValue && belongs
? FrameAnimationFlowColorUtility.CreatePalette(flowColor.Value).Stroke
: new Color32(119, 127, 135, 255);
ApplyVisualState();
}
private void ApplyVisualState()
{
var color = hasError
? new Color32(223, 106, 106, 255)
: hasWarning ? new Color32(224, 184, 88, 255) : baseColor;
var width = 3f;
if (!hasError && !hasWarning)
{
edgeControl.inputColor = new Color(0.25f, 0.75f, 1f);
edgeControl.outputColor = new Color(0.25f, 0.75f, 1f);
if (previewState == FrameAnimationFlowElementState.Current)
{
color = Color.Lerp(color, Color.white, 0.28f);
width = 5f;
}
else if (previewState == FrameAnimationFlowElementState.Passed)
{
color = Color.Lerp(color, new Color32(86, 185, 123, 255), 0.22f);
width = 3.5f;
}
else if (previewState == FrameAnimationFlowElementState.Upcoming)
{
color = Color.Lerp(color, new Color32(43, 47, 52, 255), 0.42f);
width = 2f;
}
}
else if (state == FrameAnimationFlowElementState.Passed)
edgeControl.inputColor = color;
edgeControl.outputColor = color;
if (panel != null)
{
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;
edgeControl.edgeWidth = Mathf.RoundToInt(width);
}
}
}
@@ -285,14 +383,12 @@ namespace AibisDream.FrameAnimation.Editor
if (graph != null)
{
var topology = new FrameAnimationGraphTopology(graph);
var entries = new HashSet<string>(graph.Flows.Where(flow => flow != null)
.Select(flow => flow.EntryNodeId));
foreach (var node in graph.Nodes.Where(node => node != null))
{
var view = new FrameAnimationClipNodeView(node, OnNodeSelected);
view.SetPosition(new Rect(FrameAnimationGraphMutationService.GetNodePosition(graph, node),
new Vector2(255f, 150f)));
view.Refresh(graph, issues, topology.FindFlowsUsingNode(node.InternalId), entries);
new Vector2(224f, 164f)));
view.Refresh(graph, issues, topology.FindFlowsUsingNode(node.InternalId));
nodeViews[node.InternalId] = view;
AddElement(view);
}
@@ -372,8 +468,16 @@ namespace AibisDream.FrameAnimation.Editor
focusedFlowId = flowId ?? string.Empty;
if (graph == null || string.IsNullOrEmpty(flowId))
{
foreach (var view in nodeViews.Values) view.SetDimmed(false);
foreach (var view in edgeViews.Values) view.SetDimmed(false);
foreach (var view in nodeViews.Values)
{
view.SetDimmed(false);
view.SetFocusedFlow(graph, null, false);
}
foreach (var view in edgeViews.Values)
{
view.SetDimmed(false);
view.SetFocusedFlow(null, false);
}
return;
}
var flow = graph.Flows.FirstOrDefault(item => item != null && item.Id == flowId);
@@ -383,8 +487,19 @@ namespace AibisDream.FrameAnimation.Editor
: new FrameAnimationReachability(Array.Empty<AnimationNode>(), Array.Empty<AnimationEdge>());
var nodeIds = new HashSet<string>(reachable.Nodes.Select(node => node.InternalId));
var edgeIds = new HashSet<string>(reachable.Edges.Select(edge => edge.InternalId));
foreach (var pair in nodeViews) pair.Value.SetDimmed(!nodeIds.Contains(pair.Key));
foreach (var pair in edgeViews) pair.Value.SetDimmed(!edgeIds.Contains(pair.Key));
var flowColor = flow != null ? FrameAnimationFlowColorUtility.ResolveRaw(graph, flow) : (Color?)null;
foreach (var pair in nodeViews)
{
var belongs = nodeIds.Contains(pair.Key);
pair.Value.SetDimmed(!belongs);
pair.Value.SetFocusedFlow(graph, flow, belongs);
}
foreach (var pair in edgeViews)
{
var belongs = edgeIds.Contains(pair.Key);
pair.Value.SetDimmed(!belongs);
pair.Value.SetFocusedFlow(flowColor, belongs);
}
}
internal void SelectAndFrame(FrameAnimationEditorSelection target)
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5ad5208284c226e4297926dd64539442
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
@@ -0,0 +1,166 @@
fileFormatVersion: 2
guid: 0a870adb61eef3b4bb54dd6a93d043ef
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 12
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: WebGL
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: iPhone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Server
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -12,6 +12,7 @@ using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;
using UnityEngine.TestTools;
namespace AibisDream.EditorTools
{
@@ -479,6 +480,7 @@ namespace AibisDream.EditorTools
var context = new SnapshotRestoreContext(new SaveSnapshot(), strictMode: true);
context.SetPhase("Provider restore");
context.Warn("optional state missing");
LogAssert.Expect(LogType.Error, "[SnapshotRestore] required state missing");
context.Error("required state missing");
Assert.That(context.StrictMode, Is.True);
@@ -26,6 +26,14 @@ MonoBehaviour:
importSourceId: d329feaf92a945f885e419c05824cd56
sourceTagName: "\u6263\u5934\u706F\u6CE1"
isMissingFromSource: 0
hasStandaloneImportSource: 0
standaloneImportSource:
texture: {fileID: 0}
asepriteJson: {fileID: 0}
pivot: {x: 0.5, y: 0.5}
manageSpriteSlicing: 0
lastSourceHash:
lastImportedTagName:
--- !u!114 &-8307372092058666549
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -76,6 +84,14 @@ MonoBehaviour:
importSourceId: d329feaf92a945f885e419c05824cd56
sourceTagName: "\u4F38\u624B\u8868\u60C5idle"
isMissingFromSource: 0
hasStandaloneImportSource: 0
standaloneImportSource:
texture: {fileID: 0}
asepriteJson: {fileID: 0}
pivot: {x: 0.5, y: 0.5}
manageSpriteSlicing: 0
lastSourceHash:
lastImportedTagName:
--- !u!114 &-5742946164860628285
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -126,6 +142,14 @@ MonoBehaviour:
importSourceId: d329feaf92a945f885e419c05824cd56
sourceTagName: "\u6342\u5934\u8868\u60C5idle"
isMissingFromSource: 0
hasStandaloneImportSource: 0
standaloneImportSource:
texture: {fileID: 0}
asepriteJson: {fileID: 0}
pivot: {x: 0.5, y: 0.5}
manageSpriteSlicing: 0
lastSourceHash:
lastImportedTagName:
--- !u!114 &-2521262061966760486
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -152,6 +176,14 @@ MonoBehaviour:
importSourceId: d329feaf92a945f885e419c05824cd56
sourceTagName: "\u6263\u5934 \uFF1F"
isMissingFromSource: 0
hasStandaloneImportSource: 0
standaloneImportSource:
texture: {fileID: 0}
asepriteJson: {fileID: 0}
pivot: {x: 0.5, y: 0.5}
manageSpriteSlicing: 0
lastSourceHash:
lastImportedTagName:
--- !u!114 &-1981100710209546618
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -178,6 +210,14 @@ MonoBehaviour:
importSourceId: d329feaf92a945f885e419c05824cd56
sourceTagName: "\u4F38\u624B \u77F3\u5316\u8868\u60C5"
isMissingFromSource: 0
hasStandaloneImportSource: 0
standaloneImportSource:
texture: {fileID: 0}
asepriteJson: {fileID: 0}
pivot: {x: 0.5, y: 0.5}
manageSpriteSlicing: 0
lastSourceHash:
lastImportedTagName:
--- !u!114 &-1764538467543073397
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -224,6 +264,14 @@ MonoBehaviour:
importSourceId: d329feaf92a945f885e419c05824cd56
sourceTagName: "\u4F38\u624B\u5207\u5C4F\u7279\u6548 "
isMissingFromSource: 0
hasStandaloneImportSource: 0
standaloneImportSource:
texture: {fileID: 0}
asepriteJson: {fileID: 0}
pivot: {x: 0.5, y: 0.5}
manageSpriteSlicing: 0
lastSourceHash:
lastImportedTagName:
--- !u!114 &-1158850888323987525
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -250,6 +298,14 @@ MonoBehaviour:
importSourceId: d329feaf92a945f885e419c05824cd56
sourceTagName: "\u62FF\u4F4F\u5E3D\u5B50"
isMissingFromSource: 0
hasStandaloneImportSource: 0
standaloneImportSource:
texture: {fileID: 0}
asepriteJson: {fileID: 0}
pivot: {x: 0.5, y: 0.5}
manageSpriteSlicing: 0
lastSourceHash:
lastImportedTagName:
--- !u!114 &-757131795367400242
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -296,6 +352,14 @@ MonoBehaviour:
importSourceId: d329feaf92a945f885e419c05824cd56
sourceTagName: "\u6263\u5934\u5207\u5C4F\u7279\u6548"
isMissingFromSource: 0
hasStandaloneImportSource: 0
standaloneImportSource:
texture: {fileID: 0}
asepriteJson: {fileID: 0}
pivot: {x: 0.5, y: 0.5}
manageSpriteSlicing: 0
lastSourceHash:
lastImportedTagName:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -388,6 +452,14 @@ MonoBehaviour:
importSourceId: d329feaf92a945f885e419c05824cd56
sourceTagName: "\u5BF9\u624B\u6307\u5207\u5C4F\u7279\u6548"
isMissingFromSource: 0
hasStandaloneImportSource: 0
standaloneImportSource:
texture: {fileID: 0}
asepriteJson: {fileID: 0}
pivot: {x: 0.5, y: 0.5}
manageSpriteSlicing: 0
lastSourceHash:
lastImportedTagName:
--- !u!114 &7171718461941095968
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -438,6 +510,14 @@ MonoBehaviour:
importSourceId: d329feaf92a945f885e419c05824cd56
sourceTagName: "\u6263\u5934\u8868\u60C5idle"
isMissingFromSource: 0
hasStandaloneImportSource: 0
standaloneImportSource:
texture: {fileID: 0}
asepriteJson: {fileID: 0}
pivot: {x: 0.5, y: 0.5}
manageSpriteSlicing: 0
lastSourceHash:
lastImportedTagName:
--- !u!114 &8692045413535011072
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -464,6 +544,14 @@ MonoBehaviour:
importSourceId: d329feaf92a945f885e419c05824cd56
sourceTagName: "\u6458\u5E3D"
isMissingFromSource: 0
hasStandaloneImportSource: 0
standaloneImportSource:
texture: {fileID: 0}
asepriteJson: {fileID: 0}
pivot: {x: 0.5, y: 0.5}
manageSpriteSlicing: 0
lastSourceHash:
lastImportedTagName:
--- !u!114 &8885103194751219695
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -514,3 +602,11 @@ MonoBehaviour:
importSourceId: d329feaf92a945f885e419c05824cd56
sourceTagName: "\u5BF9\u624B\u6307\u8868\u60C5idle"
isMissingFromSource: 0
hasStandaloneImportSource: 0
standaloneImportSource:
texture: {fileID: 0}
asepriteJson: {fileID: 0}
pivot: {x: 0.5, y: 0.5}
manageSpriteSlicing: 0
lastSourceHash:
lastImportedTagName:
@@ -137,6 +137,22 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
Assert.That(graph.EditorData.FlowEditorData.Single().FlowId, Is.EqualTo("NewFlow"));
}
[Test]
public void SetFlowColor_IsStoredAndSupportsUndo()
{
var flow = new AnimationFlow("Flow", "Flow", "entry");
var graph = CreateGraph("FlowColor.asset", flows: new[] { flow });
graph.EditorData.GetOrCreateFlowData(flow.Id, Color.red);
FrameAnimationGraphMutationService.SetFlowColor(graph, flow, new Color(0.1f, 0.4f, 0.9f, 0.2f));
Assert.That(graph.EditorData.FlowEditorData.Single().Color.b, Is.EqualTo(0.9f).Within(0.001f));
Assert.That(graph.EditorData.FlowEditorData.Single().Color.a, Is.EqualTo(0.2f).Within(0.001f));
Undo.FlushUndoRecordObjects();
Undo.PerformUndo();
Assert.That(graph.EditorData.FlowEditorData.Single().Color, Is.EqualTo(Color.red));
}
[Test]
public void ResourceQuery_SearchFilterAndSort_AreStable()
{
@@ -5,6 +5,7 @@ using System.Reflection;
using AibisDream.FrameAnimation.Editor;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
@@ -179,6 +180,92 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
Assert.That(JsonUtility.ToJson(graph), Is.EqualTo(before));
}
[Test]
public void FlowColorPalette_IsOpaqueAndReadableForExtremeColors()
{
var dark = FrameAnimationFlowColorUtility.CreatePalette(new Color(0.005f, 0.01f, 0.02f, 0f));
var light = FrameAnimationFlowColorUtility.CreatePalette(new Color(1f, 0.98f, 0.9f, 0.1f));
Assert.That(dark.Raw.a, Is.EqualTo(1f));
Assert.That(dark.Stroke.maxColorComponent, Is.GreaterThan(dark.Raw.maxColorComponent));
Assert.That(light.Raw.a, Is.EqualTo(1f));
Assert.That(FrameAnimationFlowColorUtility.Luminance(light.Text),
Is.LessThan(FrameAnimationFlowColorUtility.Luminance(light.Header)));
}
[Test]
public void GraphView_FocusedFlowUsesItsColorAndShowAllKeepsNeutralEdges()
{
var clip = Clip("Clip", 1f, FrameClipEndBehavior.HoldLastFrame, 100);
var first = new AnimationNode(clip.Id, internalId: "first");
var second = new AnimationNode(clip.Id, internalId: "second");
var edge = new AnimationEdge(first.InternalId, second.InternalId, internalId: "edge");
var flow = new AnimationFlow("Flow", "Flow", first.InternalId);
var graph = Graph(new[] { clip }, new[] { first, second }, new[] { edge }, new[] { flow });
var stored = new Color(0.12f, 0.72f, 0.38f, 0.15f);
graph.EditorData.GetOrCreateFlowData(flow.Id, stored);
var palette = FrameAnimationFlowColorUtility.CreatePalette(stored);
var view = new FrameAnimationGraphView();
view.Bind(graph, Array.Empty<FrameAnimationEditorIssue>(), string.Empty);
var edgeView = view.Query<FrameAnimationEdgeView>().First();
Assert.That(edgeView.edgeControl.inputColor.r, Is.EqualTo(119f / 255f).Within(0.001f));
Assert.That(view.Query<Label>(className: "fa-flow-badge").ToList().Count, Is.EqualTo(2));
view.SetFocusedFlow(flow.Id);
Assert.That(edgeView.edgeControl.inputColor.r, Is.EqualTo(palette.Stroke.r).Within(0.003f));
Assert.That(edgeView.edgeControl.inputColor.g, Is.EqualTo(palette.Stroke.g).Within(0.003f));
Assert.That(view.Query<FrameAnimationClipNodeView>(className: "fa-node--focused").ToList().Count,
Is.EqualTo(2));
}
[Test]
public void GraphView_SharedPathSwitchesToNewFocusedFlowColor()
{
var clip = Clip("Clip", 1f, FrameClipEndBehavior.HoldLastFrame, 100);
var first = new AnimationNode(clip.Id, internalId: "first");
var second = new AnimationNode(clip.Id, internalId: "second");
var edge = new AnimationEdge(first.InternalId, second.InternalId, internalId: "edge");
var redFlow = new AnimationFlow("Red", "Red", first.InternalId);
var blueFlow = new AnimationFlow("Blue", "Blue", first.InternalId);
var graph = Graph(new[] { clip }, new[] { first, second }, new[] { edge }, new[] { redFlow, blueFlow });
graph.EditorData.GetOrCreateFlowData(redFlow.Id, Color.red);
graph.EditorData.GetOrCreateFlowData(blueFlow.Id, Color.blue);
var view = new FrameAnimationGraphView();
view.Bind(graph, Array.Empty<FrameAnimationEditorIssue>(), redFlow.Id);
var edgeView = view.Query<FrameAnimationEdgeView>().First();
var red = edgeView.edgeControl.inputColor;
view.SetFocusedFlow(blueFlow.Id);
var blue = edgeView.edgeControl.inputColor;
Assert.That(red.r, Is.GreaterThan(red.b));
Assert.That(blue.b, Is.GreaterThan(blue.r));
}
[Test]
public void GraphView_EdgeValidationColorOverridesFlowAndPreviewColor()
{
var clip = Clip("Clip", 1f, FrameClipEndBehavior.HoldLastFrame, 100);
var first = new AnimationNode(clip.Id, internalId: "first");
var second = new AnimationNode(clip.Id, internalId: "second");
var edge = new AnimationEdge(first.InternalId, second.InternalId, internalId: "edge");
var flow = new AnimationFlow("Flow", "Flow", first.InternalId);
var graph = Graph(new[] { clip }, new[] { first, second }, new[] { edge }, new[] { flow });
graph.EditorData.GetOrCreateFlowData(flow.Id, Color.green);
var issue = new FrameAnimationEditorIssue(FrameAnimationValidationSeverity.Error, "BrokenEdge",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Edge, edge), "Broken", string.Empty);
var view = new FrameAnimationGraphView();
view.Bind(graph, new[] { issue }, flow.Id);
var edgeView = view.Query<FrameAnimationEdgeView>().First();
edgeView.SetPreviewState(FrameAnimationFlowElementState.Current);
Assert.That(edgeView.edgeControl.inputColor.r, Is.EqualTo(223f / 255f).Within(0.003f));
Assert.That(edgeView.edgeControl.inputColor.g, Is.EqualTo(106f / 255f).Within(0.003f));
}
[Test]
public void PreviewSample_IsIsolatedAndStructurallyPlayable()
{
@@ -197,7 +284,7 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
}
[Test]
public void Workbench_CreateGUIBuildsSharedAndClipPreviewElements()
public void Workbench_CreateGUIBuildsTabsAndSelectionPreviewForClipNodeAndFlow()
{
var sample = AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(
"Assets/GameContent/Test/FrameAnimation/Preview/PreviewSampleGraph.asset");
@@ -215,14 +302,87 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
Is.GreaterThanOrEqualTo(2));
Assert.That(window.rootVisualElement.Q<Button>("graph-properties-button"), Is.Not.Null);
Assert.That(window.rootVisualElement.Q<Button>("graph-breadcrumb-button"), Is.Not.Null);
Assert.That(window.rootVisualElement.Q<VisualElement>("canvas-toolbar").Query<Slider>().ToList(),
Is.Empty);
Assert.That(window.rootVisualElement.Query<IMGUIContainer>().ToList(), Is.Empty);
Assert.That(window.rootVisualElement.Q<VisualElement>("fa-workbench"), Is.Not.Null);
var clipsTab = window.rootVisualElement.Q<Button>("resource-tab-clips");
typeof(FrameAnimationGraphEditorWindow).GetMethod("SetResourceTab",
BindingFlags.Instance | BindingFlags.NonPublic)?.Invoke(window,
new[] { clipsTab.userData, (object)false });
Assert.That(clipsTab.ClassListContains("fa-tab--active"), Is.True);
var importDiffTab = window.rootVisualElement.Q<Button>("bottom-tab-import-diff");
typeof(FrameAnimationGraphEditorWindow).GetMethod("SetBottomTab",
BindingFlags.Instance | BindingFlags.NonPublic)?.Invoke(window,
new[] { importDiffTab.userData });
Assert.That(importDiffTab.ClassListContains("fa-tab--active"), Is.True);
var transport = window.rootVisualElement.Q<Toolbar>("preview-transport");
Assert.That(transport, Is.Not.Null);
Assert.That(transport.Q<Button>("preview-restart"), Is.Not.Null);
Assert.That(transport.Q<Button>("preview-previous-frame"), Is.Not.Null);
Assert.That(transport.Q<Button>("preview-play-pause"), Is.Not.Null);
Assert.That(transport.Q<Button>("preview-next-frame"), Is.Not.Null);
Assert.That(transport.Q<Button>("preview-stop"), Is.Not.Null);
Assert.That(transport.Q<Button>("preview-stop").Q<Image>().image, Is.Not.Null);
Assert.That(transport.Q<Slider>("preview-timeline"), Is.Not.Null);
Assert.That(transport.Q<Label>("preview-time"), Is.Not.Null);
Assert.That(transport.Q<Button>("preview-play-pause").enabledSelf, Is.False);
var flowsTab = window.rootVisualElement.Q<Button>("resource-tab-flows");
typeof(FrameAnimationGraphEditorWindow).GetMethod("SetResourceTab",
BindingFlags.Instance | BindingFlags.NonPublic)?.Invoke(window,
new[] { flowsTab.userData, (object)true });
Assert.That(flowsTab.ClassListContains("fa-tab--active"), Is.True);
Assert.That(window.rootVisualElement.Q<Button>("resource-tab-clips")
.ClassListContains("fa-tab--active"), Is.False);
var validationTab = window.rootVisualElement.Q<Button>("bottom-tab-validation");
typeof(FrameAnimationGraphEditorWindow).GetMethod("SetBottomTab",
BindingFlags.Instance | BindingFlags.NonPublic)?.Invoke(window,
new[] { validationTab.userData });
Assert.That(validationTab.ClassListContains("fa-tab--active"), Is.True);
Assert.That(window.rootVisualElement.Q<Button>("bottom-tab-import-diff")
.ClassListContains("fa-tab--active"), Is.False);
var clip = sample.Clips.First(item => item != null);
typeof(FrameAnimationGraphEditorWindow).GetMethod("SetSelection",
BindingFlags.Instance | BindingFlags.NonPublic)?.Invoke(window, new object[]
var setSelection = typeof(FrameAnimationGraphEditorWindow).GetMethod("SetSelection",
BindingFlags.Instance | BindingFlags.NonPublic);
setSelection?.Invoke(window, new object[]
{
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, clip),
false
});
AssertSelectionPreview(window, "Clip Preview");
var playButton = transport.Q<Button>("preview-play-pause");
Assert.That(playButton.enabledSelf, Is.True);
Assert.That(playButton.text, Is.Empty);
Assert.That(playButton.tooltip, Is.EqualTo("Play"));
Assert.That(playButton.Q<Image>(), Is.Not.Null);
Assert.That(playButton.Q<Image>().image, Is.Not.Null);
typeof(FrameAnimationGraphEditorWindow).GetMethod("PreviewPlayPause",
BindingFlags.Instance | BindingFlags.NonPublic)?.Invoke(window, null);
Assert.That(playButton.text, Is.Empty);
Assert.That(playButton.tooltip, Is.EqualTo("Pause"));
Assert.That(playButton.Q<Image>().image, Is.Not.Null);
var node = sample.Nodes.First(item => item != null);
setSelection?.Invoke(window, new object[]
{
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, node),
false
});
AssertSelectionPreview(window, "Node Preview");
var flow = sample.Flows.First(item => item != null);
setSelection?.Invoke(window, new object[]
{
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Flow, flow),
false
});
AssertSelectionPreview(window, "Flow Preview");
typeof(FrameAnimationGraphEditorWindow).GetMethod("SelectGraphProperties",
BindingFlags.Instance | BindingFlags.NonPublic)?.Invoke(window, null);
var selected = (FrameAnimationEditorSelection)typeof(FrameAnimationGraphEditorWindow)
@@ -236,6 +396,15 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
}
}
private static void AssertSelectionPreview(FrameAnimationGraphEditorWindow window, string expectedTitle)
{
var panel = window.rootVisualElement.Q<VisualElement>("selection-preview-panel");
Assert.That(panel, Is.Not.Null);
Assert.That(panel.style.display.value, Is.EqualTo(DisplayStyle.Flex));
Assert.That(window.rootVisualElement.Q<Label>("selection-preview-title").text,
Is.EqualTo(expectedTitle));
}
private FrameClip Clip(string id, float speed, FrameClipEndBehavior end, params int[] durations)
{
var clip = Track(ScriptableObject.CreateInstance<FrameClip>());
@@ -0,0 +1,261 @@
# 帧动画角色配置指南(策划版)
本文说明帧动画角色的 **Flow 配置**与 **Yarn 调用**。Clip 的切图、导入和帧时长配置不在本文展开。
示例资源:`Assets/GameContent/Huoshan/Actor/火山Graph.asset`
> 当前的 `火山Graph` 已有 Clip,但还没有配置 Flow。下文的 Flow 名称是教学示例,不代表资源中已经存在。
## 1. 先理解 Clip 和 Flow
- **Clip**:一段独立帧动画,例如 `摘帽``扣头切屏特效``伸手表情idle`
- **Flow**:把多个 Clip 按顺序串成一次完整表演,例如 `摘帽 → 伸手表情idle`
- Yarn 调用时,Clip ID 和 Flow ID 的写法完全相同,系统会自动查找对应内容。
适合直接调用 Clip 的情况:只播放一个动作或切换一个循环表情。
适合配置 Flow 的情况:动作需要连续播放多个阶段,并且希望 Yarn 只写一条命令。
## 2. 打开火山 Graph
1. 在 Project 窗口选中 `Assets/GameContent/Huoshan/Actor/火山Graph.asset`
2. 点击 Inspector 中的 **Open Frame Animation Graph Editor**
3. 也可以从 Unity 菜单打开:**Window > Aibis Dream > Frame Animation Graph Editor**,再选择 `火山Graph`
编辑器左侧是 Clip / Flow 列表,中间是节点画布,右侧是当前选中内容的属性。
## 3. 配置一个 Flow
下面以新建 `摘帽到伸手_Flow` 为例,预期顺序为:
```text
摘帽 → 伸手表情idle(循环)
```
### 第一步:把 Clip 放到画布
1. 在左侧选择 **Clips**
2. 把 `摘帽` 从左侧拖到中间画布,生成一个 Node。
3. 再把 `伸手表情idle` 拖到画布,生成第二个 Node。
同一个 Clip 可以在画布中生成多个 Node。Node 只是 Flow 中对 Clip 的一次引用,不会复制或修改原 Clip。
### 第二步:连接播放顺序
`摘帽` Node 右侧的输出点拖线,连接到 `伸手表情idle` Node 左侧的输入点。
当前 Flow 只支持单线顺序播放:
- 一个 Node 最多只能连接一个后继 Node;
- 不支持分支;
- 不支持把路径连成环;
- 播放顺序由连线决定,不由节点在画布上的左右位置决定。
### 第三步:创建 Flow 并指定入口
1. 选中 `摘帽` Node。
2. 右键该 Node,选择 **Create Flow From Node**;也可用画布上方 **Canvas > Create Flow From Selection**
3. 将 Flow ID 填为 `摘帽到伸手_Flow`
4. 确认入口是 `摘帽` Node。入口 Node 会显示 **E** 标记。
Flow ID 就是 Yarn 中填写的动画名。建议使用有明确含义且不易重复的名称,例如 `摘帽到伸手_Flow`
> Clip ID 和 Flow ID 共用同一套命名空间,不能重名。修改 ID 后,已有 Yarn 文本不会自动更新,必须同步搜索并修改调用。
### 第四步:设置结尾行为
选中最后一个 Node,在右侧设置 **Override End Behavior**。常用选项:
| 选项 | 播放结束后的表现 | 常见用途 |
| --- | --- | --- |
| `Loop` | 从头循环最后一个 Clip | idle、持续表情 |
| `HoldLastFrame` | 停在最后一帧 | 一次动作的定格结尾 |
| `Clear` | 清空当前 Sprite | 动画结束后不显示图片 |
| `HideTarget` | 隐藏渲染目标 | 动画结束后隐藏角色 |
本例最后的 `伸手表情idle` 应使用 `Loop`
注意:只有终点 Node 才能设置结束行为。一个 Node 如果设置了结束行为,就不能再连接后继 Node。中间 Node 播完后会自动进入下一个 Node,不需要设置结束行为。
如需单独调整某个 Node 的速度,可勾选 **Override Speed**`1` 为原速,`2` 为两倍速,`0.5` 为半速。速度必须大于 `0`
### 第五步:预览、校验和保存
1. 在左侧选择刚创建的 Flow,点击右侧 **Focus Flow On Canvas**
2. 使用预览区的播放按钮检查顺序和循环结果。
3. 点击顶部 **Validate**,底部 **Validation** 中不能有 Error。
4. 点击顶部 **Save** 保存。
## 4. Yarn 调用
### 初始化角色
帧动画角色首次出现时,先初始化:
```yarn
<<init_actor 火山 clinic FrameAnimation>>
```
参数依次为:
```text
角色名 槽位名 角色类型
```
`火山` 会加载 Addressable 地址为 `FrameAnimation/火山` 的 Graph。`init_actor` 会等待角色 Prefab 和 Graph 加载完成,因此下一行可以直接切动画。
同一段角色出场流程中只需初始化一次,不要在每次换动画前重复初始化。
> 初始化完成后不会自动播放 Graph 中的 Default Playable,需要再调用一次 Clip 或 Flow。
### 调用一个 Clip
**Clip ID** 直接写在命令的第一个参数中:
```yarn
<<change_actor_state 捂头表情idle 火山>>
```
这条命令会直接播放 `火山Graph` 中的 `捂头表情idle` Clip。Clip 播完后的表现由该 Clip 的 **End Behavior** 决定:
- `Loop`:持续循环,直到被下一次状态切换替换;
- `HoldLastFrame`:播放一次并停在最后一帧;
- `Clear`:播放一次后清空图片;
- `HideTarget`:播放一次后隐藏角色渲染目标。
#### Clip:播放后立刻继续 Yarn
```yarn
<<change_actor_state "伸手 石化表情" 火山>>
hs: 我太伤心了。
```
`change_actor_state` 只负责开始播放,Yarn 不会等 Clip 播完,会立即执行下一行。适用于:
- 切换 idle 或持续循环表情;
- Clip 需要和对白同时播放;
- 后续时机由策划自己用 `wait` 控制。
#### Clip:播放完成后再继续 Yarn
```yarn
<<change_actor_state_async 扣头切屏特效 火山>>
<<change_actor_state 扣头表情idle 火山>>
```
`change_actor_state_async` 会等待 Clip
- 非循环 Clip:等待整段播放结束;
- `Loop` Clip:等待第一轮播放结束,然后继续执行 Yarn;动画本身仍会循环。
因此,一次性动作之后要准确切换 idle 时,推荐使用上面的“异步动作 Clip → 循环 idle Clip”写法,不需要猜测 `wait` 秒数。
### 调用一个 Flow
Flow 的调用格式与 Clip 完全相同,只需把第一个参数换成 **Flow ID**。例如已配置 `摘帽到伸手_Flow`
#### Flow:播放后立刻继续 Yarn
```yarn
<<change_actor_state 摘帽到伸手_Flow 火山>>
hs: 戴上“实实”牌帽子,给你的头顶添件宝!
```
Yarn 会立即继续对白,Flow 则在后台按照连线依次播放。适合整段表演与对白同时发生的情况。
#### Flow:播放到终点首轮后再继续 Yarn
```yarn
<<change_actor_state_async 摘帽到伸手_Flow 火山>>
// Flow 的前置动作和终点 Loop 首轮播放完后,才执行这里
```
`change_actor_state_async` 会从入口开始等待整条 Flow
- 终点为非循环 Clip:等待所有节点自然播放结束;
- `前置动作 → 终点 Loop` 的 Flow:等待前置动作和终点 Loop 的第一轮全部结束。
如果终点是 `Loop`,命令返回后终点 Clip 仍会继续循环,直到被下一次状态切换替换。
### 快速选择命令
| 要播放的内容 | 希望 Yarn 是否等待 | 写法 |
| --- | --- | --- |
| 单个 Clip | 不等待 | `<<change_actor_state ClipID 角色名>>` |
| 单个 Clip | 等完整动画;Loop 等第一轮 | `<<change_actor_state_async ClipID 角色名>>` |
| 一整条 Flow | 不等待 | `<<change_actor_state FlowID 角色名>>` |
| 一整条 Flow | 等到终点首轮完成 | `<<change_actor_state_async FlowID 角色名>>` |
命令本身不需要标明目标是 Clip 还是 Flow。系统会用 ID 在当前角色的 Graph 中查找;因此 Clip ID 和 Flow ID 不能重名。
### 名称中有空格时
Clip ID、Flow ID、角色名或槽位名中包含空格时,必须加英文双引号:
```yarn
<<change_actor_state "伸手 石化表情" 火山>>
<<change_actor_state_async "摘帽 到 伸手_Flow" 火山>>
```
不含空格时可以不加;为减少出错,也可以统一加英文双引号。
## 5. 火山的完整示例
下面同时演示 Clip 和 Flow 调用,并假设已按前文创建 `摘帽到伸手_Flow`
```yarn
<<init_actor 火山 clinic FrameAnimation>>
// 调用 Loop Clip:立即继续 Yarnidle 在对白期间持续循环
<<change_actor_state 捂头表情idle 火山>>
<<fade_in_actor 火山>>
hs: 医——生——救——我——!
// 调用 Flow:等“摘帽”播完,再等终点“伸手表情idle”完成第一轮
<<change_actor_state_async 摘帽到伸手_Flow 火山>>
// Flow 返回后,终点的伸手 idle 仍在循环
hs: 戴上“实实”牌帽子,给你的头顶添件宝!
// 调用一次性 Clip,并等待它完整播完
<<change_actor_state_async "扣头切屏特效" 火山>>
// 再调用另一个 Loop Clip,替换当前状态并继续对白
<<change_actor_state "扣头表情idle" 火山>>
hs: 我……没有活干了。
```
## 6. 提交前检查
- Flow 的入口是否是第一个动作,而不是终点 idle?
- 节点是否按预期连线,且没有分支或环?
- 只有最后一个 Node 设置了结束行为吗?
- 需要持续显示的 idle 是否设置为 `Loop`
- Flow ID 是否与已有 Clip / Flow 重名?
- Yarn 中的 ID 与 Graph 完全一致,包括空格和大小写吗?
- 需要等动画时是否用了 `change_actor_state_async`
- 顶部 **Validate** 是否无 Error
- 是否点击 **Save**
## 7. 常见问题
**调用后没有动画**
先检查角色是否用 `FrameAnimation` 类型初始化,再检查 Graph 的 Addressable 地址是否为 `FrameAnimation/角色名`。火山应为 `FrameAnimation/火山`
**提示 playable 找不到**
Yarn 中传入的是 Clip ID 或 Flow ID,不是资源文件名、Graph 名或 Node 的 Display Name。检查字符、空格和大小写是否完全一致。
**动画刚开始就被打断**
后面很可能紧跟了另一条 `change_actor_state`。普通命令不会等待;需要等当前动画完成时改用 `change_actor_state_async`
**循环动画导致剧情无法继续**
使用 `change_actor_state_async` 等待 Loop 时,只会等待第一轮,不会无限阻塞。如果仍未继续,先运行 **Validate** 检查 Flow 路径和结尾配置。
**想让 Flow 中途停住**
当前 Flow 是顺序播放,不支持分支或中途等待 Yarn。应拆成两个 Clip / Flow,在 Yarn 中分两次调用。