feat: 帧动画编辑器及相关文档

This commit is contained in:
2026-07-22 22:30:10 +08:00
parent 1fdca74bfb
commit 8722c92706
8 changed files with 666 additions and 19 deletions
@@ -702,16 +702,50 @@
.fa-preview-controls {
flex-direction: row;
align-items: center;
flex-shrink: 0;
min-height: 27px;
}
.fa-selection-preview-panel .fa-preview-controls .unity-toolbar-button {
width: 24px;
min-width: 24px;
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 {
@@ -69,6 +69,8 @@ namespace AibisDream.FrameAnimation.Editor
private readonly List<Slider> previewTimelineSliders = new List<Slider>();
private readonly List<Label> previewTimeLabels = new List<Label>();
private readonly List<Button> previewPlayButtons = new List<Button>();
private readonly Dictionary<Button, Image> previewButtonImages = new Dictionary<Button, Image>();
private static readonly HashSet<string> MissingPreviewIconWarnings = new HashSet<string>();
private readonly List<Label> previewTargetLabels = new List<Label>();
private VisualElement clipPreviewPanel;
private FrameAnimationPreviewElement clipPreviewElement;
@@ -364,34 +366,78 @@ namespace AibisDream.FrameAnimation.Editor
mainHost.Add(main);
}
private VisualElement BuildPreviewTransport()
private Toolbar BuildPreviewTransport()
{
var controls = new VisualElement();
var controls = new Toolbar { name = "preview-transport" };
controls.AddToClassList("fa-preview-controls");
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" };
controls.Add(CreatePreviewButton("preview-restart", previewCoordinator.Restart, "Restart",
"d_Refresh", "Refresh"));
controls.Add(CreatePreviewButton("preview-previous-frame", () => previewCoordinator.Step(-1),
"Previous Frame", "Animation.PrevKey"));
var play = CreatePreviewButton("preview-play-pause", PreviewPlayPause, "Play", "PlayButton");
previewPlayButtons.Add(play);
controls.Add(play);
controls.Add(new ToolbarButton(() => previewCoordinator.Step(1)) { text = "▶|", tooltip = "Next Frame" });
controls.Add(new ToolbarButton(previewCoordinator.Stop) { text = "■", tooltip = "Stop" });
controls.Add(CreatePreviewButton("preview-next-frame", () => previewCoordinator.Step(1),
"Next Frame", "Animation.NextKey"));
controls.Add(CreatePreviewButton("preview-stop", previewCoordinator.Stop, "Stop",
"Assets/Editor/FrameAnimation/Icons/PreviewStop.png"));
var timeline = new Slider(0f, 1f)
{
style = { minWidth = 90f, maxWidth = 240f, flexGrow = 1f }
name = "preview-timeline"
};
timeline.AddToClassList("fa-preview-timeline");
timeline.RegisterValueChangedCallback(evt =>
{
if (!updatingPreviewUi) previewCoordinator.Seek(evt.newValue);
});
previewTimelineSliders.Add(timeline);
controls.Add(timeline);
var time = new Label("0 / 0 ms") { style = { minWidth = 82f } };
var time = new Label("0 / 0 ms") { name = "preview-time" };
time.AddToClassList("fa-preview-time");
previewTimeLabels.Add(time);
controls.Add(time);
return controls;
}
private ToolbarButton CreatePreviewButton(
string name,
Action action,
string tooltip,
params string[] iconSources)
{
var button = new ToolbarButton(action) { name = name, tooltip = tooltip };
button.AddToClassList("fa-preview-button");
var image = new Image { pickingMode = PickingMode.Ignore };
image.AddToClassList("fa-preview-button__icon");
button.Add(image);
previewButtonImages[button] = image;
SetPreviewButtonIcon(button, tooltip, iconSources);
return button;
}
private void SetPreviewButtonIcon(Button button, string tooltip, params string[] iconSources)
{
button.tooltip = tooltip;
if (!previewButtonImages.TryGetValue(button, out var image)) return;
image.image = null;
foreach (var iconSource in iconSources)
{
var icon = iconSource.StartsWith("Assets/", StringComparison.Ordinal)
? AssetDatabase.LoadAssetAtPath<Texture2D>(iconSource)
: EditorGUIUtility.IconContent(iconSource)?.image;
if (icon == null) continue;
image.image = icon;
return;
}
if (MissingPreviewIconWarnings.Add(tooltip))
{
var attempted = iconSources.Length > 0 ? $" Tried: {string.Join(", ", iconSources)}." : string.Empty;
Debug.LogWarning($"Frame Animation Preview: no reliable Unity editor icon is available for " +
$"'{tooltip}'.{attempted}");
}
}
private VisualElement BuildSelectionPreviewPanel()
{
var panel = new VisualElement { name = "selection-preview-panel" };
@@ -564,7 +610,9 @@ namespace AibisDream.FrameAnimation.Editor
}
foreach (var button in previewPlayButtons)
{
button.text = previewCoordinator.State == FrameAnimationPreviewState.Playing ? "❚❚" : "▶";
var isPlaying = previewCoordinator.State == FrameAnimationPreviewState.Playing;
SetPreviewButtonIcon(button, isPlaying ? "Pause" : "Play",
isPlaying ? "PauseButton" : "PlayButton");
button.SetEnabled(previewCoordinator.TargetKind != FrameAnimationPreviewTargetKind.None);
}
var targetText = PreviewTargetName();
+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:
@@ -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:
@@ -5,6 +5,7 @@ using System.Reflection;
using AibisDream.FrameAnimation.Editor;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
@@ -305,10 +306,28 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
Is.Empty);
Assert.That(window.rootVisualElement.Query<IMGUIContainer>().ToList(), Is.Empty);
Assert.That(window.rootVisualElement.Q<VisualElement>("fa-workbench"), Is.Not.Null);
Assert.That(window.rootVisualElement.Q<Button>("resource-tab-clips")
.ClassListContains("fa-tab--active"), Is.True);
Assert.That(window.rootVisualElement.Q<Button>("bottom-tab-import-diff")
.ClassListContains("fa-tab--active"), Is.True);
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",
@@ -335,6 +354,18 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
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[]
@@ -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 中分两次调用。