feat: 帧动画系统Timeline兼容

This commit is contained in:
2026-07-22 02:32:04 +08:00
parent 9b2caf64c9
commit c6c76666ef
48 changed files with 2947 additions and 31 deletions
@@ -3,7 +3,10 @@
"rootNamespace": "AibisDream.FrameAnimation.Tests.EditMode",
"references": [
"AibisDream.FrameAnimation.Runtime",
"AibisDream.FrameAnimation.Editor"
"AibisDream.FrameAnimation.Editor",
"AibisDream.FrameAnimation.Timeline",
"Unity.Timeline",
"UnityEngine.UI"
],
"includePlatforms": [
"Editor"
@@ -0,0 +1,307 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using AibisDream.FrameAnimation.Timeline;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.TestTools;
using UnityEngine.Timeline;
using UnityEngine.UI;
namespace AibisDream.FrameAnimation.Tests.EditMode
{
public sealed class FrameAnimationTimelineTests
{
private readonly List<UnityEngine.Object> createdObjects = new List<UnityEngine.Object>();
[TearDown]
public void TearDown()
{
for (var index = createdObjects.Count - 1; index >= 0; index--)
{
if (createdObjects[index] != null)
{
UnityEngine.Object.DestroyImmediate(createdObjects[index]);
}
}
createdObjects.Clear();
}
[Test]
public void TimelineClip_ReportsDurationAndSupportedCapabilities()
{
var holdClip = CreateClip("Hold", FrameClipEndBehavior.HoldLastFrame, 2f, null, null);
var loopClip = CreateClip("Loop", FrameClipEndBehavior.Loop, 1f, null, null);
var asset = Track(ScriptableObject.CreateInstance<FrameAnimationTimelineClip>());
asset.SetFrameClipForTests(holdClip);
Assert.That(asset.duration, Is.EqualTo(0.1d).Within(0.000001d));
Assert.That(asset.TrySynchronizeSourceDuration(out var synchronizedDuration), Is.True);
Assert.That(synchronizedDuration, Is.EqualTo(0.1d).Within(0.000001d));
Assert.That(asset.TrySynchronizeSourceDuration(out _), Is.False);
Assert.That(asset.clipCaps.HasFlag(ClipCaps.ClipIn), Is.True);
Assert.That(asset.clipCaps.HasFlag(ClipCaps.SpeedMultiplier), Is.True);
Assert.That(asset.clipCaps.HasFlag(ClipCaps.Blending), Is.False);
Assert.That(asset.clipCaps.HasFlag(ClipCaps.Looping), Is.False);
asset.SetFrameClipForTests(loopClip);
Assert.That(asset.TrySynchronizeSourceDuration(out _), Is.True);
Assert.That(asset.clipCaps.HasFlag(ClipCaps.Looping), Is.True);
asset.SetFrameClipForTests(null);
Assert.That(asset.duration, Is.EqualTo(1d));
}
[Test]
public void Track_ScrubsBothDirectionsAndHonorsClipInAndSpeedMultiplier()
{
var first = CreateSprite(Color.red);
var second = CreateSprite(Color.blue);
var clip = CreateClip("Scrub", FrameClipEndBehavior.HoldLastFrame, 1f, first, second);
var playerObject = Track(new GameObject("Timeline Sprite Player"));
var renderer = playerObject.AddComponent<SpriteRenderer>();
var player = playerObject.AddComponent<FrameClipPlayer>();
var setup = CreateTimeline(player, clip, 0d, 0.2d);
setup.Clip.clipIn = 0.05d;
setup.Clip.timeScale = 2d;
Evaluate(setup.Director, 0.03d);
Assert.That(renderer.sprite, Is.SameAs(second));
Evaluate(setup.Director, 0d);
Assert.That(renderer.sprite, Is.SameAs(first));
Evaluate(setup.Director, 0.03d);
Assert.That(renderer.sprite, Is.SameAs(second));
}
[Test]
public void Track_GapUsesPreviousTerminalStateAndOverlapPrefersLaterClip()
{
var baseline = CreateSprite(Color.white);
var firstSprite = CreateSprite(Color.red);
var secondSprite = CreateSprite(Color.blue);
var hold = CreateClip("Hold", FrameClipEndBehavior.HoldLastFrame, 1f, firstSprite);
var later = CreateClip("Later", FrameClipEndBehavior.HoldLastFrame, 1f, secondSprite);
var playerObject = Track(new GameObject("Timeline Overlap Player"));
var renderer = playerObject.AddComponent<SpriteRenderer>();
renderer.sprite = baseline;
var player = playerObject.AddComponent<FrameClipPlayer>();
var setup = CreateTimeline(player, hold, 0.1d, 0.3d);
AddClip(setup.Track, later, 0.2d, 0.1d);
Evaluate(setup.Director, 0.25d);
Assert.That(renderer.sprite, Is.SameAs(secondSprite));
Evaluate(setup.Director, 0.35d);
Assert.That(renderer.sprite, Is.SameAs(firstSprite));
Evaluate(setup.Director, 0.45d);
Assert.That(renderer.sprite, Is.SameAs(firstSprite));
Evaluate(setup.Director, -0.01d);
Assert.That(renderer.sprite, Is.SameAs(baseline));
}
[Test]
public void Track_AppliesClearAndHideTerminalStates()
{
var sprite = CreateSprite(Color.green);
var clear = CreateClip("Clear", FrameClipEndBehavior.Clear, 1f, sprite);
var hide = CreateClip("Hide", FrameClipEndBehavior.HideTarget, 1f, sprite);
var playerObject = Track(new GameObject("Timeline End States"));
var renderer = playerObject.AddComponent<SpriteRenderer>();
var player = playerObject.AddComponent<FrameClipPlayer>();
var setup = CreateTimeline(player, clear, 0d, 0.1d);
AddClip(setup.Track, hide, 0.2d, 0.1d);
Evaluate(setup.Director, 0.15d);
Assert.That(renderer.enabled, Is.True);
Assert.That(renderer.sprite, Is.Null);
Evaluate(setup.Director, 0.35d);
Assert.That(renderer.enabled, Is.False);
}
[Test]
public void Track_SamplesImageAndZeroSpeedClipStaysOnFirstFrame()
{
var first = CreateSprite(Color.yellow);
var second = CreateSprite(Color.black);
var clip = CreateClip("Zero", FrameClipEndBehavior.HoldLastFrame, 0f, first, second);
var playerObject = Track(new GameObject(
"Timeline Image Player",
typeof(RectTransform),
typeof(CanvasRenderer)));
var image = playerObject.AddComponent<Image>();
var player = playerObject.AddComponent<FrameClipPlayer>();
var setup = CreateTimeline(player, clip, 0d, 1d);
Evaluate(setup.Director, 0.75d);
Assert.That(image.sprite, Is.SameAs(first));
Assert.That(setup.Asset.duration, Is.EqualTo(1d));
}
[Test]
public void Track_InvalidClipLogsStructuredErrorWithoutThrowing()
{
var invalid = CreateClip("Empty", FrameClipEndBehavior.HoldLastFrame, 1f);
var playerObject = Track(new GameObject("Timeline Invalid Clip"));
playerObject.AddComponent<SpriteRenderer>();
var player = playerObject.AddComponent<FrameClipPlayer>();
var setup = CreateTimeline(player, invalid, 0d, 1d);
LogAssert.Expect(
LogType.Error,
new Regex("Frame Animation Timeline 播放失败.*InvalidPlayableData"));
Assert.DoesNotThrow(() => Evaluate(setup.Director, 0.25d));
}
[Test]
public void Track_GathersSpriteAndEnabledPropertiesForPreview()
{
var clip = CreateClip(
"Preview",
FrameClipEndBehavior.HoldLastFrame,
1f,
CreateSprite(Color.white));
var spriteObject = Track(new GameObject("Timeline Sprite Preview"));
spriteObject.AddComponent<SpriteRenderer>();
var spritePlayer = spriteObject.AddComponent<FrameClipPlayer>();
var spriteSetup = CreateTimeline(spritePlayer, clip, 0d, 0.1d);
var spriteCollector = new RecordingPropertyCollector();
spriteSetup.Track.GatherProperties(spriteSetup.Director, spriteCollector);
Assert.That(spriteCollector.Properties, Does.Contain("SpriteRenderer:m_Sprite"));
Assert.That(spriteCollector.Properties, Does.Contain("SpriteRenderer:m_Enabled"));
var imageObject = Track(new GameObject(
"Timeline Image Preview",
typeof(RectTransform),
typeof(CanvasRenderer)));
imageObject.AddComponent<Image>();
var imagePlayer = imageObject.AddComponent<FrameClipPlayer>();
var imageSetup = CreateTimeline(imagePlayer, clip, 0d, 0.1d);
var imageCollector = new RecordingPropertyCollector();
imageSetup.Track.GatherProperties(imageSetup.Director, imageCollector);
Assert.That(imageCollector.Properties, Does.Contain("Image:m_Sprite"));
Assert.That(imageCollector.Properties, Does.Contain("Image:m_Enabled"));
}
private TimelineSetup CreateTimeline(
FrameClipPlayer player,
FrameClip clip,
double start,
double duration)
{
var timeline = Track(ScriptableObject.CreateInstance<TimelineAsset>());
var track = Track(timeline.CreateTrack<FrameAnimationTrack>());
var timelineClip = AddClip(track, clip, start, duration);
var directorObject = Track(new GameObject("Frame Animation Director"));
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
director.SetGenericBinding(track, player);
return new TimelineSetup(
director,
track,
timelineClip,
(FrameAnimationTimelineClip)timelineClip.asset);
}
private TimelineClip AddClip(
FrameAnimationTrack track,
FrameClip clip,
double start,
double duration)
{
var timelineClip = track.CreateClip<FrameAnimationTimelineClip>();
var asset = Track((FrameAnimationTimelineClip)timelineClip.asset);
asset.SetFrameClipForTests(clip);
timelineClip.start = start;
timelineClip.duration = duration;
return timelineClip;
}
private static void Evaluate(PlayableDirector director, double time)
{
director.time = Math.Max(0d, time);
director.Evaluate();
}
private FrameClip CreateClip(
string id,
FrameClipEndBehavior endBehavior,
float speed,
params Sprite[] sprites)
{
var clip = Track(ScriptableObject.CreateInstance<FrameClip>());
var frames = new List<FrameAnimationFrame>();
for (var index = 0; index < sprites.Length; index++)
{
frames.Add(new FrameAnimationFrame(sprites[index], 100, $"frame_{index}", index));
}
clip.Configure(id, id, frames, speed, endBehavior);
return clip;
}
private Sprite CreateSprite(Color color)
{
var texture = Track(new Texture2D(2, 2));
texture.SetPixels(new[] { color, color, color, color });
texture.Apply();
return Track(Sprite.Create(texture, new Rect(0, 0, 2, 2), Vector2.one * 0.5f));
}
private T Track<T>(T value) where T : UnityEngine.Object
{
createdObjects.Add(value);
return value;
}
private readonly struct TimelineSetup
{
internal PlayableDirector Director { get; }
internal FrameAnimationTrack Track { get; }
internal TimelineClip Clip { get; }
internal FrameAnimationTimelineClip Asset { get; }
internal TimelineSetup(
PlayableDirector director,
FrameAnimationTrack track,
TimelineClip clip,
FrameAnimationTimelineClip asset)
{
Director = director;
Track = track;
Clip = clip;
Asset = asset;
}
}
private sealed class RecordingPropertyCollector : IPropertyCollector
{
internal List<string> Properties { get; } = new List<string>();
public void PushActiveGameObject(GameObject gameObject) { }
public void PopActiveGameObject() { }
public void AddFromClip(AnimationClip clip) { }
public void AddFromClips(IEnumerable<AnimationClip> clips) { }
public void AddFromName<T>(string name) where T : Component =>
Properties.Add($"{typeof(T).Name}:{name}");
public void AddFromName(string name) { }
public void AddFromClip(GameObject obj, AnimationClip clip) { }
public void AddFromClips(GameObject obj, IEnumerable<AnimationClip> clips) { }
public void AddFromName<T>(GameObject obj, string name) where T : Component =>
Properties.Add($"{typeof(T).Name}:{name}");
public void AddFromName(GameObject obj, string name) { }
public void AddFromName(Component component, string name) { }
public void AddFromComponent(GameObject obj, Component component) { }
public void AddObjectProperties(UnityEngine.Object obj, AnimationClip clip) { }
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0886b53af79f4406ba432ad67cd822be
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,196 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using AibisDream.FrameAnimation.Editor;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Tests.EditMode
{
public sealed class FrameClipStandaloneImportTests
{
private const string Root = "Assets/Tests/FrameAnimation/GeneratedStandaloneClipTests";
private const string TexturePath = Root + "/Atlas.png";
private const string JsonPath = Root + "/Atlas.json";
private const string ClipPath = Root + "/Standalone.asset";
[SetUp]
public void SetUp()
{
AssetDatabase.DeleteAsset(Root);
EnsureFolder(Root);
}
[TearDown]
public void TearDown()
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh();
}
[Test]
public void Preview_UsesOnlyFirstTagAndRespectsDirection()
{
var clip = CreateSourceClip(string.Empty, JsonWithTags("reverse"));
var preview = FrameClipImportService.Preview(clip);
Assert.That(preview.HasErrors, Is.False, JoinIssues(preview));
Assert.That(preview.SourceTagName, Is.EqualTo("First"));
CollectionAssert.AreEqual(new[] { 1, 0 }, preview.SourceIndices);
Assert.That(preview.SpriteDiffs.Count(diff => diff.Kind == FrameAnimationSpriteChangeKind.Added),
Is.EqualTo(2));
}
[Test]
public void Preview_WithoutTagsUsesAllFrames()
{
var clip = CreateSourceClip("Existing", JsonWithoutTags());
var preview = FrameClipImportService.Preview(clip);
Assert.That(preview.HasErrors, Is.False, JoinIssues(preview));
Assert.That(preview.SourceTagName, Is.Empty);
CollectionAssert.AreEqual(new[] { 0, 1 }, preview.SourceIndices);
}
[Test]
public void Apply_InitializesEmptyIdAndRefreshPreservesExistingId()
{
var clip = CreateSourceClip(string.Empty, JsonWithTags("forward"));
var first = FrameClipImportService.Preview(clip);
Assert.That(FrameClipImportService.Apply(first, true, out var error), Is.True, error);
Assert.That(clip.Id, Is.EqualTo("First"));
Assert.That(clip.DisplayName, Is.EqualTo("First"));
Assert.That(clip.FrameCount, Is.EqualTo(2));
Assert.That(clip.StandaloneImportSource.LastImportedTagName, Is.EqualTo("First"));
var serialized = new SerializedObject(clip);
serialized.FindProperty("id").stringValue = "CustomId";
serialized.ApplyModifiedPropertiesWithoutUndo();
WriteJson(JsonWithTags("reverse"));
var second = FrameClipImportService.Preview(clip);
Assert.That(FrameClipImportService.Apply(second, true, out error), Is.True, error);
Assert.That(clip.Id, Is.EqualTo("CustomId"));
CollectionAssert.AreEqual(new[] { 1, 0 }, clip.Frames.Select(frame => frame.SourceIndex));
}
[Test]
public void Detach_PreservesFramesAndAllowsGraphReference()
{
var clip = CreateSourceClip("External", JsonWithoutTags());
var preview = FrameClipImportService.Preview(clip);
Assert.That(FrameClipImportService.Apply(preview, true, out var error), Is.True, error);
var frames = clip.Frames.ToArray();
var graph = ScriptableObject.CreateInstance<FrameAnimationGraph>();
graph.Configure("Graph", "Graph", Array.Empty<FrameClip>(), Array.Empty<AnimationNode>(),
Array.Empty<AnimationEdge>(), Array.Empty<AnimationFlow>(), clip.Id);
AssetDatabase.CreateAsset(graph, Root + "/Graph.asset");
Assert.That(FrameAnimationAssetOperations.AddExistingManualClip(graph, clip, out error), Is.True, error);
Assert.That(FrameAnimationGraphValidator.Validate(graph).HasErrors, Is.False);
FrameClipImportService.DetachSource(clip);
Assert.That(clip.HasStandaloneImportSource, Is.False);
CollectionAssert.AreEqual(frames, clip.Frames);
}
[Test]
public void Preview_RejectsSecondWritableStandaloneOwnerForSameTexture()
{
CreateTexture();
WriteJson(JsonWithoutTags());
var first = CreateLinkedClipAt(ClipPath, "FirstOwner");
var second = CreateLinkedClipAt(Root + "/Second.asset", "SecondOwner");
var preview = FrameClipImportService.Preview(second);
Assert.That(first.StandaloneImportSource.Texture, Is.SameAs(second.StandaloneImportSource.Texture));
Assert.That(preview.HasErrors, Is.True);
Assert.That(preview.Issues.Any(issue =>
issue.Code == FrameAnimationImportIssueCode.TextureOwnershipConflict), Is.True);
}
private static FrameClip CreateSourceClip(string id, string json)
{
CreateTexture();
WriteJson(json);
return CreateLinkedClipAt(ClipPath, id);
}
private static FrameClip CreateLinkedClipAt(string path, string id)
{
var clip = ScriptableObject.CreateInstance<FrameClip>();
clip.Configure(id, string.Empty, Array.Empty<FrameAnimationFrame>());
AssetDatabase.CreateAsset(clip, path);
Assert.That(FrameClipImportService.EnableSource(clip, out var error), Is.True, error);
var serialized = new SerializedObject(clip);
var source = serialized.FindProperty("standaloneImportSource");
source.FindPropertyRelative("texture").objectReferenceValue =
AssetDatabase.LoadAssetAtPath<Texture2D>(TexturePath);
source.FindPropertyRelative("asepriteJson").objectReferenceValue =
AssetDatabase.LoadAssetAtPath<TextAsset>(JsonPath);
source.FindPropertyRelative("manageSpriteSlicing").boolValue = true;
serialized.ApplyModifiedPropertiesWithoutUndo();
AssetDatabase.SaveAssets();
return clip;
}
private static void CreateTexture()
{
var texture = new Texture2D(4, 2, TextureFormat.RGBA32, false);
texture.SetPixels(Enumerable.Range(0, 8).Select(index => index < 4 ? Color.red : Color.green).ToArray());
texture.Apply();
File.WriteAllBytes(TexturePath, texture.EncodeToPNG());
UnityEngine.Object.DestroyImmediate(texture);
AssetDatabase.ImportAsset(TexturePath, ImportAssetOptions.ForceSynchronousImport);
}
private static void WriteJson(string json)
{
File.WriteAllText(JsonPath, json, new UTF8Encoding(false));
AssetDatabase.ImportAsset(JsonPath, ImportAssetOptions.ForceSynchronousImport);
}
private static string JsonWithTags(string direction)
{
return Json("[{\"name\":\"First\",\"from\":0,\"to\":1,\"direction\":\"" + direction +
"\"},{\"name\":\"Ignored\",\"from\":1,\"to\":1,\"direction\":\"invalid\"}]");
}
private static string JsonWithoutTags()
{
return Json("[]");
}
private static string Json(string tags)
{
return "{" +
"\"frames\":{" +
"\"FrameA\":{\"frame\":{\"x\":0,\"y\":0,\"w\":2,\"h\":2},\"rotated\":false,\"trimmed\":false,\"duration\":100,\"spriteSourceSize\":{\"x\":0,\"y\":0,\"w\":2,\"h\":2},\"sourceSize\":{\"w\":2,\"h\":2}}," +
"\"FrameB\":{\"frame\":{\"x\":2,\"y\":0,\"w\":2,\"h\":2},\"rotated\":false,\"trimmed\":false,\"duration\":200,\"spriteSourceSize\":{\"x\":0,\"y\":0,\"w\":2,\"h\":2},\"sourceSize\":{\"w\":2,\"h\":2}}}," +
"\"meta\":{\"image\":\"Atlas.png\",\"size\":{\"w\":4,\"h\":2},\"frameTags\":" + tags + "}}";
}
private static string JoinIssues(FrameClipImportPreview preview)
{
return string.Join("\n", preview.Issues.Select(issue => issue.Message));
}
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,11 @@
fileFormatVersion: 2
guid: fd9f1e89abcc595428c137e42258e008
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: