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

This commit is contained in:
2026-07-22 21:59:35 +08:00
493 changed files with 18483 additions and 12797 deletions
+123
View File
@@ -0,0 +1,123 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
using AibisDream.Utility;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using Yarn.Markup;
using Yarn.Unity;
namespace AibisDream.SystemEditor.Tests
{
public sealed class AutoNextMetadataTests
{
[Test]
public void AutoNextWithoutParameter_UsesExistingFixedDelay()
{
var lineInfo = LineInfo.Generate(CreateLine("auto_next"));
Assert.That(lineInfo.isAutoSkip, Is.True);
Assert.That(lineInfo.autoNextDelaySeconds, Is.Null);
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(ConstRef.FixedDelay));
}
[Test]
public void AutoNextWithParameter_UsesSpecifiedSeconds()
{
var lineInfo = LineInfo.Generate(CreateLine("auto_next:4.5"));
Assert.That(lineInfo.isAutoSkip, Is.True);
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(4.5f));
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(4500));
}
[Test]
public void AutoNextWithZeroDelay_IsValid()
{
var lineInfo = LineInfo.Generate(CreateLine("auto_next:0"));
Assert.That(lineInfo.isAutoSkip, Is.True);
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(0f));
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.Zero);
}
[Test]
public void AutoNextParameter_UsesInvariantCulture()
{
var originalCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
var lineInfo = LineInfo.Generate(CreateLine("auto_next:4.5"));
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(4.5f));
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(4500));
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
}
}
[TestCase("auto_next:")]
[TestCase("auto_next:abc")]
[TestCase("auto_next:-1")]
[TestCase("auto_next:NaN")]
[TestCase("auto_next:Infinity")]
[TestCase("auto_next:2147484")]
public void AutoNextWithInvalidParameter_FallsBackToExistingFixedDelay(string metadata)
{
LogAssert.Expect(LogType.Warning, new Regex("auto_next 参数无效"));
var lineInfo = LineInfo.Generate(CreateLine(metadata));
Assert.That(lineInfo.isAutoSkip, Is.True);
Assert.That(lineInfo.autoNextDelaySeconds, Is.Null);
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(ConstRef.FixedDelay));
}
[TestCase("auto_next_extra")]
[TestCase("auto_next_extra:4.5")]
[TestCase("AUTO_NEXT")]
[TestCase("AUTO_NEXT:4.5")]
public void SimilarMetadata_IsNotRecognized(string metadata)
{
var line = CreateLine(metadata);
Assert.That(line.IsAutoSkipLine(), Is.False);
Assert.That(line.TryGetAutoNextDelaySeconds(out _), Is.False);
}
[Test]
public void ParameterizedTag_TakesPrecedenceOverPlainTag()
{
var lineInfo = LineInfo.Generate(CreateLine("auto_next", "auto_next:2.5"));
Assert.That(lineInfo.isAutoSkip, Is.True);
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(2.5f));
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(2500));
}
[Test]
public void MultipleParameterizedTags_UseFirstAndWarn()
{
LogAssert.Expect(LogType.Warning, new Regex("存在多个 auto_next 参数标签"));
var lineInfo = LineInfo.Generate(CreateLine("auto_next:2.5", "auto_next:4.5"));
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(2.5f));
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(2500));
}
private static LocalizedLine CreateLine(params string[] metadata)
{
return new LocalizedLine
{
TextID = "line:auto-next-test",
Metadata = metadata,
Text = new MarkupParseResult("Test line", new List<MarkupAttribute>())
};
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d476e493adf4855468ce46409dae6661
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -122,7 +122,7 @@ namespace AibisDream.FrameAnimation.Editor
{
if (clip == null || !clip.IsImported)
{
return "Manual";
return clip != null && clip.HasStandaloneImportSource ? "Standalone Source" : "Manual";
}
var sourceId = clip.ImportInfo?.ImportSourceId;
if (sourceNames != null && !string.IsNullOrEmpty(sourceId) &&
@@ -232,7 +232,7 @@ namespace AibisDream.FrameAnimation.Editor
if (clip.IsImported || AssetDatabase.IsSubAsset(clip) ||
string.IsNullOrEmpty(AssetDatabase.GetAssetPath(clip)))
{
error = "只能添加独立 .asset 形式的 Manual Clip。";
error = "只能添加独立 .asset 形式、且不由 Graph ImportSource 管理的 Clip。";
return false;
}
if (graph.Clips.Contains(clip))
@@ -403,7 +403,7 @@ namespace AibisDream.FrameAnimation.Editor
public static string MakeUniqueFlowId(FrameAnimationGraph graph, AnimationNode node)
{
var root = (string.IsNullOrWhiteSpace(node?.DisplayName) ? "Animation" : node.DisplayName) + "Flow";
var root = (string.IsNullOrWhiteSpace(node?.DisplayName) ? "Animation" : node.DisplayName) + "_Flow";
var candidate = root;
var suffix = 2;
while (!FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, candidate))
@@ -64,6 +64,7 @@ namespace AibisDream.FrameAnimation.Editor
private SerializedObject clipSerializedObject;
private FrameClip frameListClip;
private ReorderableList frameList;
private bool frameListEditable;
private readonly FrameAnimationPreviewCoordinator previewCoordinator = new FrameAnimationPreviewCoordinator();
private readonly List<Slider> previewTimelineSliders = new List<Slider>();
private readonly List<Label> previewTimeLabels = new List<Label>();
@@ -2276,7 +2277,10 @@ namespace AibisDream.FrameAnimation.Editor
}
EnsureFrameList(clip);
clipSerializedObject.Update();
EditorGUILayout.LabelField(clip.IsImported ? "Imported Clip" : "Manual Clip", EditorStyles.boldLabel);
EditorGUILayout.LabelField(
clip.IsImported ? "Graph Imported Clip" :
clip.HasStandaloneImportSource ? "Source-linked External Clip" : "Manual Clip",
EditorStyles.boldLabel);
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.PropertyField(clipSerializedObject.FindProperty("id"));
@@ -2297,14 +2301,29 @@ namespace AibisDream.FrameAnimation.Editor
EditorGUILayout.LabelField("Source Tag", clip.ImportInfo.SourceTagName);
EditorGUILayout.LabelField("Missing", clip.ImportInfo.IsMissingFromSource ? "Yes" : "No");
}
else if (clip.HasStandaloneImportSource)
{
EditorGUILayout.LabelField("Source Tag",
string.IsNullOrEmpty(clip.StandaloneImportSource.LastImportedTagName)
? "<all frames>"
: clip.StandaloneImportSource.LastImportedTagName);
if (GUILayout.Button("Open Standalone Clip Editor"))
{
FrameClipEditorWindow.Open(clip);
}
}
if (clipSerializedObject.ApplyModifiedProperties())
{
EditorUtility.SetDirty(clip);
}
if (!clip.IsImported)
if (!clip.IsImported && !clip.HasStandaloneImportSource)
{
frameList.DoLayoutList();
if (clipSerializedObject.ApplyModifiedProperties())
{
EditorUtility.SetDirty(clip);
}
if (frameList.index >= 0 && GUILayout.Button("Duplicate Selected Frame"))
{
DuplicateFrame(clipSerializedObject.FindProperty("frames"), frameList.index);
@@ -2333,6 +2352,13 @@ namespace AibisDream.FrameAnimation.Editor
});
}
}
else if (clip.HasStandaloneImportSource)
{
using (new EditorGUI.DisabledScope(true))
{
frameList.DoLayoutList();
}
}
if (GUILayout.Button("Rename Clip ID"))
{
@@ -2350,17 +2376,20 @@ namespace AibisDream.FrameAnimation.Editor
private void EnsureFrameList(FrameClip clip)
{
if (frameListClip == clip && frameList != null)
var editable = !clip.IsImported && !clip.HasStandaloneImportSource;
if (frameListClip == clip && frameList != null && frameListEditable == editable)
{
return;
}
frameListClip = clip;
frameListEditable = editable;
clipSerializedObject = new SerializedObject(clip);
var frames = clipSerializedObject.FindProperty("frames");
frameList = new ReorderableList(clipSerializedObject, frames, !clip.IsImported, true, !clip.IsImported, !clip.IsImported)
frameList = new ReorderableList(clipSerializedObject, frames, editable, true, editable, editable)
{
elementHeight = 72f,
drawHeaderCallback = rect => EditorGUI.LabelField(rect, "Frames (frameName/sourceIndex are read-only)"),
drawHeaderCallback = rect => EditorGUI.LabelField(rect,
editable ? "Frames (frameName/sourceIndex are read-only)" : "Frames (source-managed, read-only)"),
drawElementCallback = (rect, index, active, focused) => DrawFrameElement(frames, rect, index),
onAddCallback = list => AddFrame(frames),
onRemoveCallback = list =>
@@ -3337,6 +3366,11 @@ namespace AibisDream.FrameAnimation.Editor
}
if (asset is FrameClip clip)
{
if (!AssetDatabase.IsSubAsset(clip))
{
FrameClipEditorWindow.Open(clip);
return true;
}
var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip);
if (owners.Count == 1)
{
@@ -273,7 +273,13 @@ namespace AibisDream.FrameAnimation.Editor
}
}
internal sealed class FrameAnimationImportSourcePreview
internal interface IFrameAnimationImportPreviewSink
{
void AddIssue(FrameAnimationImportIssue issue);
void AddSpriteDiff(FrameAnimationSpriteDiff diff);
}
internal sealed class FrameAnimationImportSourcePreview : IFrameAnimationImportPreviewSink
{
private readonly List<FrameAnimationImportIssue> issues = new List<FrameAnimationImportIssue>();
private readonly List<FrameAnimationClipDiff> clipDiffs = new List<FrameAnimationClipDiff>();
@@ -434,6 +434,13 @@ namespace AibisDream.FrameAnimation.Editor
source.ManageSpriteSlicing && source.Texture != null).Select(source => (currentGraph, source)));
}
var standaloneOwners = AssetDatabase.FindAssets("t:FrameClip")
.Select(guid => AssetDatabase.LoadAssetAtPath<FrameClip>(AssetDatabase.GUIDToAssetPath(guid)))
.Where(clip => clip?.StandaloneImportSource != null &&
clip.StandaloneImportSource.ManageSpriteSlicing &&
clip.StandaloneImportSource.Texture != null)
.ToArray();
foreach (var pair in targetPreviews.Where(pair => pair.Key.ManageSpriteSlicing && pair.Key.Texture != null))
{
var conflicts = owners.Where(item => item.source.Texture == pair.Key.Texture &&
@@ -444,6 +451,14 @@ namespace AibisDream.FrameAnimation.Editor
$"Texture '{pair.Key.Texture.name}' 已被其他可写 ImportSource 占用:" +
string.Join(", ", conflicts.Select(item => $"{item.graph.name}/{item.source.DisplayName}"))));
}
var standaloneConflicts = standaloneOwners.Where(clip =>
clip.StandaloneImportSource.Texture == pair.Key.Texture).ToArray();
if (standaloneConflicts.Length > 0)
{
pair.Value.AddIssue(Error(pair.Key, FrameAnimationImportIssueCode.TextureOwnershipConflict,
$"Texture '{pair.Key.Texture.name}' 已被独立 Clip 来源占用:" +
string.Join(", ", standaloneConflicts.Select(clip => clip.name))));
}
}
}
@@ -40,6 +40,13 @@ namespace AibisDream.FrameAnimation.Editor
var spriteRenderer = player.GetComponent<SpriteRenderer>();
var image = player.GetComponent<Image>();
if (player.GetComponent<FrameClipPlayer>() != null)
{
EditorGUILayout.HelpBox(
"同一 GameObject 不能同时包含 FrameAnimationPlayer 和 FrameClipPlayer。",
MessageType.Error);
}
if (spriteRenderer == null && image == null)
{
EditorGUILayout.HelpBox(
@@ -526,7 +526,7 @@ namespace AibisDream.FrameAnimation.Editor
var resolved = TargetKind switch
{
FrameAnimationPreviewTargetKind.Clip when target is FrameClip clip =>
FrameAnimationResolver.TryResolve(graph, clip.Id, default, out plan, out var clipError)
TryResolvePreviewClip(clip, out plan, out var clipError)
? FrameAnimationPlaybackError.None : clipError,
FrameAnimationPreviewTargetKind.Node when target is AnimationNode node =>
FrameAnimationResolver.TryResolveNode(graph, node, out plan, out var nodeError)
@@ -552,6 +552,16 @@ namespace AibisDream.FrameAnimation.Editor
}
}
private bool TryResolvePreviewClip(
FrameClip clip,
out ResolvedPlaybackPlan resolvedPlan,
out FrameAnimationPlaybackError error)
{
return graph != null
? FrameAnimationResolver.TryResolve(graph, clip.Id, default, out resolvedPlan, out error)
: FrameAnimationResolver.TryResolveClip(clip, default, out resolvedPlan, out error);
}
private void ApplyEvaluation(FrameAnimationSessionEvaluation evaluation)
{
if (evaluation.IsFailed)
@@ -10,6 +10,36 @@ using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal readonly struct FrameAnimationSpriteSourceContext
{
public string SourceId { get; }
public Texture2D Texture { get; }
public TextAsset AsepriteJson { get; }
public Vector2 Pivot { get; }
public bool ManageSpriteSlicing { get; }
public FrameAnimationSpriteSourceContext(
string sourceId,
Texture2D texture,
TextAsset asepriteJson,
Vector2 pivot,
bool manageSpriteSlicing)
{
SourceId = sourceId ?? string.Empty;
Texture = texture;
AsepriteJson = asepriteJson;
Pivot = pivot;
ManageSpriteSlicing = manageSpriteSlicing;
}
public static FrameAnimationSpriteSourceContext From(FrameAnimationImportSource source)
{
return new FrameAnimationSpriteSourceContext(
source?.InternalId, source?.Texture, source?.AsepriteJson,
source?.Pivot ?? new Vector2(0.5f, 0.5f), source?.ManageSpriteSlicing == true);
}
}
internal sealed class FrameAnimationSpritePlan
{
public string TexturePath { get; }
@@ -91,6 +121,15 @@ namespace AibisDream.FrameAnimation.Editor
FrameAnimationImportSource source,
AsepriteSourceDocument document,
FrameAnimationImportSourcePreview preview)
{
return BuildPlan(FrameAnimationSpriteSourceContext.From(source), document, preview);
}
public static FrameAnimationSpritePlan BuildPlan(
FrameAnimationSpriteSourceContext source,
AsepriteSourceDocument document,
IFrameAnimationImportPreviewSink preview,
IReadOnlyCollection<int> includedSourceIndices = null)
{
var texturePath = AssetDatabase.GetAssetPath(source.Texture);
var importer = AssetImporter.GetAtPath(texturePath) as TextureImporter;
@@ -105,8 +144,8 @@ namespace AibisDream.FrameAnimation.Editor
? Array.Empty<SpriteRect>()
: ReadSpriteRects(importer);
return source.ManageSpriteSlicing
? BuildWritablePlan(source, document, preview, texturePath, existingRects)
: BuildReadOnlyPlan(source, document, preview, texturePath, existingRects);
? BuildWritablePlan(source, document, preview, texturePath, existingRects, includedSourceIndices)
: BuildReadOnlyPlan(source, document, preview, texturePath, existingRects, includedSourceIndices);
}
public static Rect ToUnityRect(RectInt asepriteRect, int textureHeight)
@@ -175,7 +214,14 @@ namespace AibisDream.FrameAnimation.Editor
FrameAnimationImportSource source,
IReadOnlyList<SpriteRect> spriteRects = null)
{
if (source == null || source.Texture == null || source.AsepriteJson == null)
return ComputeSourceHash(FrameAnimationSpriteSourceContext.From(source), spriteRects);
}
public static string ComputeSourceHash(
FrameAnimationSpriteSourceContext source,
IReadOnlyList<SpriteRect> spriteRects = null)
{
if (source.Texture == null || source.AsepriteJson == null)
{
return string.Empty;
}
@@ -214,17 +260,18 @@ namespace AibisDream.FrameAnimation.Editor
}
private static FrameAnimationSpritePlan BuildWritablePlan(
FrameAnimationImportSource source,
FrameAnimationSpriteSourceContext source,
AsepriteSourceDocument document,
FrameAnimationImportSourcePreview preview,
IFrameAnimationImportPreviewSink preview,
string texturePath,
IReadOnlyList<SpriteRect> existingRects)
IReadOnlyList<SpriteRect> existingRects,
IReadOnlyCollection<int> includedSourceIndices)
{
var planned = CloneRects(existingRects).ToList();
var originalRects = planned.ToArray();
var assigned = new HashSet<SpriteRect>();
var spriteNamesBySourceIndex = new Dictionary<int, string>();
foreach (var slot in document.SpriteSlots)
foreach (var slot in EnumerateSlots(document, includedSourceIndices))
{
var unityRect = ToUnityRect(slot.Rect, source.Texture.height);
SpriteRect matched = null;
@@ -331,15 +378,16 @@ namespace AibisDream.FrameAnimation.Editor
}
private static FrameAnimationSpritePlan BuildReadOnlyPlan(
FrameAnimationImportSource source,
FrameAnimationSpriteSourceContext source,
AsepriteSourceDocument document,
FrameAnimationImportSourcePreview preview,
IFrameAnimationImportPreviewSink preview,
string texturePath,
IReadOnlyList<SpriteRect> existingRects)
IReadOnlyList<SpriteRect> existingRects,
IReadOnlyCollection<int> includedSourceIndices)
{
var sprites = AssetDatabase.LoadAllAssetsAtPath(texturePath).OfType<Sprite>().ToArray();
var matches = new Dictionary<int, Sprite>();
foreach (var slot in document.SpriteSlots)
foreach (var slot in EnumerateSlots(document, includedSourceIndices))
{
var unityRect = ToUnityRect(slot.Rect, source.Texture.height);
var rectMatches = sprites.Where(sprite => RectEquals(sprite.rect, unityRect)).ToArray();
@@ -382,6 +430,32 @@ namespace AibisDream.FrameAnimation.Editor
}
}
private static IEnumerable<AsepriteSpriteSlot> EnumerateSlots(
AsepriteSourceDocument document,
IReadOnlyCollection<int> includedSourceIndices)
{
if (includedSourceIndices == null)
{
return document.SpriteSlots;
}
var included = new HashSet<int>(includedSourceIndices);
return document.SpriteSlots.Select(slot =>
{
var indices = new List<int>();
var names = new List<string>();
for (var index = 0; index < slot.SourceIndices.Count; index++)
{
if (!included.Contains(slot.SourceIndices[index])) continue;
indices.Add(slot.SourceIndices[index]);
names.Add(slot.FrameNames[index]);
}
return indices.Count == 0
? null
: new AsepriteSpriteSlot(slot.Rect, names[0], indices, names);
}).Where(slot => slot != null);
}
private static string BuildSlotSummary(string prefix, AsepriteSpriteSlot slot)
{
return slot.SourceIndices.Count > 1
@@ -440,15 +514,15 @@ namespace AibisDream.FrameAnimation.Editor
}
private static FrameAnimationImportIssue Error(
FrameAnimationImportSource source,
FrameAnimationSpriteSourceContext source,
FrameAnimationImportIssueCode code,
string message)
{
return new FrameAnimationImportIssue(
FrameAnimationValidationSeverity.Error,
code,
source?.InternalId,
source?.InternalId,
source.SourceId,
source.SourceId,
message);
}
}
@@ -11,7 +11,10 @@ namespace AibisDream.FrameAnimation.Editor
{
var clip = (FrameClip)target;
var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip);
EditorGUILayout.LabelField(clip.IsImported ? "Imported FrameClip" : "Manual FrameClip", EditorStyles.boldLabel);
var kind = clip.IsImported
? "Graph Imported FrameClip"
: clip.HasStandaloneImportSource ? "Source-linked External FrameClip" : "Manual FrameClip";
EditorGUILayout.LabelField(kind, EditorStyles.boldLabel);
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.TextField("ID", clip.Id);
@@ -23,6 +26,8 @@ namespace AibisDream.FrameAnimation.Editor
EditorGUILayout.TextField("Storage", AssetDatabase.IsSubAsset(clip) ? "Graph sub-asset" : "External .asset");
EditorGUILayout.TextField("Source Tag", clip.ImportInfo?.SourceTagName ?? string.Empty);
EditorGUILayout.Toggle("Missing", clip.ImportInfo?.IsMissingFromSource ?? false);
EditorGUILayout.ObjectField("Standalone Texture", clip.StandaloneImportSource?.Texture, typeof(Texture2D), false);
EditorGUILayout.ObjectField("Standalone JSON", clip.StandaloneImportSource?.AsepriteJson, typeof(TextAsset), false);
}
if (!AssetDatabase.IsSubAsset(clip) && owners.Count > 1)
{
@@ -31,8 +36,14 @@ namespace AibisDream.FrameAnimation.Editor
MessageType.Warning);
}
EditorGUILayout.HelpBox(
"Clip id、帧表和资产所有权操作只能在 Frame Animation Graph Editor 中执行。",
AssetDatabase.IsSubAsset(clip)
? "Graph sub-asset 的 Clip 数据请在 Frame Animation Graph Editor 中编辑。"
: "双击资产或使用下方按钮,在独立 Frame Clip Editor 中编辑。",
MessageType.Info);
if (!AssetDatabase.IsSubAsset(clip) && GUILayout.Button("Open Frame Clip Editor"))
{
FrameClipEditorWindow.Open(clip);
}
if (owners.Count == 0)
{
EditorGUILayout.HelpBox("当前没有 FrameAnimationGraph 引用该 Clip。", MessageType.Info);
@@ -0,0 +1,360 @@
using System;
using System.Linq;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
public sealed class FrameClipEditorWindow : EditorWindow
{
private FrameClip clip;
private SerializedObject clipSerializedObject;
private ReorderableList frameList;
private Vector2 scroll;
private readonly FrameAnimationPreviewCoordinator preview = new FrameAnimationPreviewCoordinator();
private FrameClipImportPreview importPreview;
private double lastUpdateTime;
[MenuItem("Window/AibisDream/Frame Animation/Frame Clip Editor")]
public static void ShowWindow()
{
GetWindow<FrameClipEditorWindow>("Frame Clip Editor");
}
public static void Open(FrameClip value)
{
var window = GetWindow<FrameClipEditorWindow>("Frame Clip Editor");
window.SetClip(value);
window.Show();
window.Focus();
}
private void OnEnable()
{
EditorApplication.update += OnEditorUpdate;
Undo.undoRedoPerformed += OnUndoRedo;
lastUpdateTime = EditorApplication.timeSinceStartup;
}
private void OnDisable()
{
EditorApplication.update -= OnEditorUpdate;
Undo.undoRedoPerformed -= OnUndoRedo;
}
private void OnUndoRedo()
{
RebuildSerializedState();
preview.RefreshTarget(false);
Repaint();
}
private void OnEditorUpdate()
{
var now = EditorApplication.timeSinceStartup;
var delta = Math.Max(0d, now - lastUpdateTime);
lastUpdateTime = now;
if (preview.Tick(delta, null)) Repaint();
}
private void OnGUI()
{
var next = (FrameClip)EditorGUILayout.ObjectField("FrameClip", clip, typeof(FrameClip), false);
if (next != clip) SetClip(next);
if (clip == null)
{
EditorGUILayout.HelpBox("选择一个外部 FrameClip .asset 开始编辑。", MessageType.Info);
return;
}
if (AssetDatabase.IsSubAsset(clip))
{
EditorGUILayout.HelpBox("Graph sub-asset Clip 请在 Frame Animation Graph Editor 中编辑。", MessageType.Info);
if (GUILayout.Button("Open Referencing Graph"))
{
var owner = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip).FirstOrDefault();
if (owner != null) FrameAnimationGraphEditorWindow.Open(owner);
}
return;
}
DrawPreview();
scroll = EditorGUILayout.BeginScrollView(scroll);
DrawClipProperties();
EditorGUILayout.Space();
DrawSourceProperties();
EditorGUILayout.Space();
frameList?.DoLayoutList();
if (clipSerializedObject != null && clipSerializedObject.ApplyModifiedProperties())
{
EditorUtility.SetDirty(clip);
preview.RefreshTarget(false);
}
if (!clip.IsImported && !clip.HasStandaloneImportSource &&
frameList != null && frameList.index >= 0 && GUILayout.Button("Duplicate Selected Frame"))
{
var frames = clipSerializedObject.FindProperty("frames");
frames.InsertArrayElementAtIndex(frameList.index);
clipSerializedObject.ApplyModifiedProperties();
EditorUtility.SetDirty(clip);
preview.RefreshTarget(false);
}
EditorGUILayout.EndScrollView();
}
private void DrawPreview()
{
var rect = GUILayoutUtility.GetRect(100f, 260f, GUILayout.ExpandWidth(true));
EditorGUI.DrawRect(rect, new Color(0.12f, 0.12f, 0.12f));
var sprite = preview.CurrentSprite;
if (sprite != null && sprite.texture != null)
{
var uv = new Rect(
sprite.textureRect.x / sprite.texture.width,
sprite.textureRect.y / sprite.texture.height,
sprite.textureRect.width / sprite.texture.width,
sprite.textureRect.height / sprite.texture.height);
var scale = Mathf.Min(rect.width / sprite.rect.width, rect.height / sprite.rect.height);
var size = sprite.rect.size * scale;
var destination = new Rect(rect.center - size * 0.5f, size);
GUI.DrawTextureWithTexCoords(destination, sprite.texture, uv, true);
}
else
{
GUI.Label(rect, preview.Error.Code == FrameAnimationPlaybackErrorCode.None
? "Empty Frame" : preview.Error.Message, EditorStyles.centeredGreyMiniLabel);
}
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button(preview.State == FrameAnimationPreviewState.Playing ? "Pause" : "Play"))
{
if (preview.State == FrameAnimationPreviewState.Playing) preview.Pause();
else preview.Play();
}
if (GUILayout.Button("Stop")) preview.Stop();
if (GUILayout.Button("<")) preview.Step(-1);
if (GUILayout.Button(">")) preview.Step(1);
}
var duration = preview.Timeline?.DisplayDurationSeconds ?? 0d;
var position = EditorGUILayout.Slider("Time", (float)preview.PositionSeconds, 0f,
Mathf.Max(0.0001f, (float)duration));
if (!Mathf.Approximately(position, (float)preview.PositionSeconds)) preview.Seek(position);
}
private void DrawClipProperties()
{
clipSerializedObject.Update();
EditorGUILayout.LabelField(clip.HasStandaloneImportSource ? "Source-linked External Clip" : "Manual External Clip",
EditorStyles.boldLabel);
var idProperty = clipSerializedObject.FindProperty("id");
var nextId = EditorGUILayout.DelayedTextField("ID", idProperty.stringValue);
if (nextId != idProperty.stringValue) RenameClip(nextId);
EditorGUILayout.PropertyField(clipSerializedObject.FindProperty("displayName"));
EditorGUILayout.PropertyField(clipSerializedObject.FindProperty("speed"));
EditorGUILayout.PropertyField(clipSerializedObject.FindProperty("defaultEndBehavior"));
EditorGUILayout.LabelField("Frame Count", clip.FrameCount.ToString());
EditorGUILayout.LabelField("Total Duration", clip.TotalDurationMs + " ms");
var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip);
EditorGUILayout.LabelField("Referencing Graphs", owners.Count.ToString());
if (owners.Count > 1)
EditorGUILayout.HelpBox("该 Clip 被多个 Graph 共享,ID 重命名被锁定。", MessageType.Warning);
if (clipSerializedObject.ApplyModifiedProperties())
{
EditorUtility.SetDirty(clip);
preview.RefreshTarget(false);
importPreview = null;
}
}
private void DrawSourceProperties()
{
EditorGUILayout.LabelField("Standalone Import Source", EditorStyles.boldLabel);
if (!clip.HasStandaloneImportSource)
{
if (GUILayout.Button("Enable Texture + JSON Source"))
{
if (!FrameClipImportService.EnableSource(clip, out var error))
EditorUtility.DisplayDialog("无法启用来源", error, "确定");
RebuildSerializedState();
}
return;
}
clipSerializedObject.Update();
var source = clipSerializedObject.FindProperty("standaloneImportSource");
EditorGUILayout.PropertyField(source.FindPropertyRelative("texture"));
EditorGUILayout.PropertyField(source.FindPropertyRelative("asepriteJson"));
EditorGUILayout.PropertyField(source.FindPropertyRelative("pivot"));
EditorGUILayout.PropertyField(source.FindPropertyRelative("manageSpriteSlicing"));
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.PropertyField(source.FindPropertyRelative("lastImportedTagName"));
EditorGUILayout.PropertyField(source.FindPropertyRelative("lastSourceHash"));
}
if (clipSerializedObject.ApplyModifiedProperties())
{
EditorUtility.SetDirty(clip);
importPreview = null;
}
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Preview Import")) importPreview = FrameClipImportService.Preview(clip);
if (GUILayout.Button("Refresh"))
{
importPreview = FrameClipImportService.Preview(clip);
ApplyImportPreview();
}
if (GUILayout.Button("Detach Source"))
{
if (EditorUtility.DisplayDialog("断开来源", "保留当前帧并转为手工 Clip", "断开", "取消"))
{
FrameClipImportService.DetachSource(clip);
importPreview = null;
RebuildSerializedState();
}
}
}
DrawImportPreview();
}
private void DrawImportPreview()
{
if (importPreview == null) return;
EditorGUILayout.LabelField("Import Preview", EditorStyles.boldLabel);
EditorGUILayout.LabelField("Selected Tag",
string.IsNullOrEmpty(importPreview.SourceTagName) ? "<all frames>" : importPreview.SourceTagName);
EditorGUILayout.LabelField("Selected Frames", importPreview.SourceIndices.Count.ToString());
EditorGUILayout.LabelField("Frame Changes", importPreview.HasFrameChanges ? "Yes" : "No");
EditorGUILayout.LabelField("Sprite Changes", importPreview.HasSpriteChanges ? "Yes" : "No");
foreach (var issue in importPreview.Issues)
{
var type = issue.Severity == FrameAnimationValidationSeverity.Error
? MessageType.Error : MessageType.Warning;
EditorGUILayout.HelpBox($"{issue.Code}: {issue.Message}", type);
}
}
private void ApplyImportPreview()
{
if (importPreview == null || importPreview.HasErrors) return;
var allow = !importPreview.HasSpriteChanges || EditorUtility.DisplayDialog(
"确认修改 TextureImporter",
"独立 Clip 刷新需要更新 Sprite 切图。是否继续?",
"应用切图并刷新",
"取消");
if (!allow) return;
if (!FrameClipImportService.Apply(importPreview, true, out var error))
{
EditorUtility.DisplayDialog("刷新失败", error, "确定");
return;
}
importPreview = FrameClipImportService.Preview(clip);
RebuildSerializedState();
preview.RefreshTarget(false);
}
private void RenameClip(string newId)
{
if (string.IsNullOrWhiteSpace(newId))
{
EditorUtility.DisplayDialog("无法重命名", "Clip ID 不能为空。", "确定");
return;
}
var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip);
if (owners.Count > 1)
{
EditorUtility.DisplayDialog("无法重命名", "共享外部 Clip 被多个 Graph 引用。", "确定");
return;
}
if (owners.Count == 1)
{
if (!FrameAnimationAssetOperations.RenameClip(owners[0], clip, newId, out var error))
EditorUtility.DisplayDialog("无法重命名", error, "确定");
return;
}
Undo.RecordObject(clip, "Rename Standalone Frame Clip");
clip.SetId(newId);
EditorUtility.SetDirty(clip);
}
private void SetClip(FrameClip value)
{
clip = value;
importPreview = null;
RebuildSerializedState();
preview.SetGraph(null);
if (clip != null) preview.SetTarget(FrameAnimationPreviewTargetKind.Clip, clip);
else preview.ClearTarget();
Repaint();
}
private void RebuildSerializedState()
{
clipSerializedObject = clip != null ? new SerializedObject(clip) : null;
frameList = clip != null ? BuildFrameList() : null;
}
private ReorderableList BuildFrameList()
{
var frames = clipSerializedObject.FindProperty("frames");
var editable = !clip.IsImported && !clip.HasStandaloneImportSource;
var list = new ReorderableList(clipSerializedObject, frames, editable, true, editable, editable)
{
elementHeight = 72f,
drawHeaderCallback = rect => EditorGUI.LabelField(rect,
editable ? "Frames" : "Frames (source-linked, read-only)"),
drawElementCallback = (rect, index, active, focused) => DrawFrame(frames, rect, index),
onAddCallback = value =>
{
var index = frames.arraySize;
frames.InsertArrayElementAtIndex(index);
var element = frames.GetArrayElementAtIndex(index);
element.FindPropertyRelative("sprite").objectReferenceValue = null;
element.FindPropertyRelative("durationMs").intValue = 100;
element.FindPropertyRelative("frameName").stringValue = string.Empty;
element.FindPropertyRelative("sourceIndex").intValue = -1;
clipSerializedObject.ApplyModifiedProperties();
value.index = index;
EditorUtility.SetDirty(clip);
preview.RefreshTarget(false);
},
onRemoveCallback = value =>
{
ReorderableList.defaultBehaviours.DoRemoveButton(value);
clipSerializedObject.ApplyModifiedProperties();
EditorUtility.SetDirty(clip);
preview.RefreshTarget(false);
},
onReorderCallback = _ =>
{
clipSerializedObject.ApplyModifiedProperties();
EditorUtility.SetDirty(clip);
preview.RefreshTarget(false);
}
};
return list;
}
private static void DrawFrame(SerializedProperty frames, Rect rect, int index)
{
if (index < 0 || index >= frames.arraySize) return;
var element = frames.GetArrayElementAtIndex(index);
rect.y += 2f;
var line = EditorGUIUtility.singleLineHeight;
EditorGUI.PropertyField(new Rect(rect.x, rect.y, rect.width, line),
element.FindPropertyRelative("sprite"), new GUIContent($"#{index} Sprite"));
EditorGUI.PropertyField(new Rect(rect.x, rect.y + line + 2f, rect.width, line),
element.FindPropertyRelative("durationMs"));
using (new EditorGUI.DisabledScope(true))
{
var half = (rect.width - 4f) * 0.5f;
EditorGUI.PropertyField(new Rect(rect.x, rect.y + (line + 2f) * 2f, half, line),
element.FindPropertyRelative("frameName"));
EditorGUI.PropertyField(new Rect(rect.x + half + 4f, rect.y + (line + 2f) * 2f, half, line),
element.FindPropertyRelative("sourceIndex"));
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5673c406c6069e241b0fad51881213fe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,333 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal sealed class FrameClipImportPreview : IFrameAnimationImportPreviewSink
{
private readonly List<FrameAnimationImportIssue> issues = new List<FrameAnimationImportIssue>();
private readonly List<FrameAnimationSpriteDiff> spriteDiffs = new List<FrameAnimationSpriteDiff>();
public FrameClip Clip { get; }
public AsepriteSourceDocument Document { get; internal set; }
public FrameAnimationSpritePlan SpritePlan { get; internal set; }
public IReadOnlyList<int> SourceIndices { get; internal set; } = Array.Empty<int>();
public string SourceTagName { get; internal set; } = string.Empty;
public string CurrentHash { get; internal set; } = string.Empty;
public bool HasFrameChanges { get; internal set; }
public IReadOnlyList<FrameAnimationImportIssue> Issues => issues;
public IReadOnlyList<FrameAnimationSpriteDiff> SpriteDiffs => spriteDiffs;
public bool HasErrors => issues.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error);
public bool HasSpriteChanges => spriteDiffs.Any(diff =>
diff.Kind == FrameAnimationSpriteChangeKind.Added ||
diff.Kind == FrameAnimationSpriteChangeKind.Updated);
public FrameClipImportPreview(FrameClip clip)
{
Clip = clip;
}
public void AddIssue(FrameAnimationImportIssue issue)
{
if (issue != null) issues.Add(issue);
}
public void AddSpriteDiff(FrameAnimationSpriteDiff diff)
{
if (diff != null) spriteDiffs.Add(diff);
}
}
internal static class FrameClipImportService
{
public static bool EnableSource(FrameClip clip, out string error)
{
error = string.Empty;
if (!IsExternalClip(clip))
{
error = "独立来源只能添加到外部 .asset FrameClip。";
return false;
}
if (clip.IsImported)
{
error = "Graph Imported Clip 必须继续由所属 Graph 的 ImportSource 管理。";
return false;
}
if (clip.HasStandaloneImportSource) return true;
Undo.RecordObject(clip, "Enable Standalone Frame Clip Source");
clip.GetOrCreateStandaloneImportSource();
EditorUtility.SetDirty(clip);
return true;
}
public static void DetachSource(FrameClip clip)
{
if (clip == null || !clip.HasStandaloneImportSource) return;
Undo.RecordObject(clip, "Detach Standalone Frame Clip Source");
clip.ClearStandaloneImportSource();
EditorUtility.SetDirty(clip);
}
public static FrameClipImportPreview Preview(FrameClip clip)
{
var preview = new FrameClipImportPreview(clip);
if (!IsExternalClip(clip))
{
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.ImportedClipOwnershipInvalid,
"独立来源只能用于外部 .asset FrameClip。"));
return preview;
}
var source = clip.StandaloneImportSource;
if (source == null)
{
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.ImportSourceReferenceInvalid,
"FrameClip 尚未启用独立来源。"));
return preview;
}
if (source.Texture == null)
{
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.TextureMissing, "独立来源缺少 Texture。"));
return preview;
}
if (source.AsepriteJson == null)
{
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.JsonMissing, "独立来源缺少 Aseprite JSON。"));
return preview;
}
var sourceId = AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(clip));
if (!AsepriteJsonParser.TryParse(source.AsepriteJson.text, sourceId, out var document, out var parseIssue))
{
preview.AddIssue(parseIssue);
return preview;
}
preview.Document = document;
ValidateFrames(clip, source, document, preview);
if (preview.HasErrors) return preview;
if (document.Tags.Count > 0)
{
var first = document.Tags[0];
preview.SourceTagName = first.Name;
if (string.IsNullOrWhiteSpace(first.Name))
{
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.TagNameInvalid,
"第一个 Tag 的名称不能为空。"));
return preview;
}
if (!AsepriteJsonParser.TryExpandTag(first, document.Frames.Count, sourceId,
out var indices, out var tagIssue))
{
preview.AddIssue(tagIssue);
return preview;
}
preview.SourceIndices = indices;
}
else
{
preview.SourceIndices = Enumerable.Range(0, document.Frames.Count).ToArray();
}
ValidateTextureOwnership(clip, source, preview);
if (preview.HasErrors) return preview;
var context = Context(clip, source);
preview.SpritePlan = FrameAnimationSpriteUtility.BuildPlan(
context, document, preview, preview.SourceIndices.Distinct().ToArray());
preview.CurrentHash = FrameAnimationSpriteUtility.ComputeSourceHash(context);
preview.HasFrameChanges = !FramesEqual(clip, preview);
if (!string.IsNullOrEmpty(source.LastSourceHash) && source.LastSourceHash != preview.CurrentHash)
{
preview.AddIssue(new FrameAnimationImportIssue(
FrameAnimationValidationSeverity.Warning,
FrameAnimationImportIssueCode.SourceChanged,
sourceId,
clip.Id,
"来源内容或切图设置自上次刷新后已发生变化。"));
}
return preview;
}
public static bool Apply(FrameClipImportPreview preview, bool allowSpriteChanges, out string error)
{
error = string.Empty;
if (preview?.Clip == null || preview.Document == null || preview.SpritePlan == null)
{
error = "独立 Clip 导入预览无效。";
return false;
}
if (preview.HasErrors)
{
error = "导入预览包含阻断错误,未修改任何资产。";
return false;
}
if (preview.HasSpriteChanges && !allowSpriteChanges)
{
error = "导入需要修改 TextureImporter,但尚未获得确认。";
return false;
}
var clip = preview.Clip;
var source = clip.StandaloneImportSource;
FrameAnimationTextureSnapshot snapshot = null;
Undo.IncrementCurrentGroup();
var group = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Refresh Standalone Frame Clip Source");
try
{
if (source.ManageSpriteSlicing && preview.HasSpriteChanges)
{
snapshot = FrameAnimationSpriteUtility.CaptureSnapshot(preview.SpritePlan);
FrameAnimationSpriteUtility.ApplyPlan(preview.SpritePlan);
}
var sprites = source.ManageSpriteSlicing
? FrameAnimationSpriteUtility.LoadSpritesByName(preview.SpritePlan.TexturePath)
: null;
var frames = preview.SourceIndices.Select(sourceIndex =>
{
var sourceFrame = preview.Document.Frames[sourceIndex];
if (!preview.SpritePlan.TryResolveSprite(sourceIndex, sprites, out var sprite))
throw new InvalidOperationException($"刷新后找不到 SourceFrame '{sourceFrame.FrameName}' 对应的 Sprite。");
return new FrameAnimationFrame(sprite, sourceFrame.DurationMs, sourceFrame.FrameName, sourceIndex);
}).ToArray();
Undo.RecordObject(clip, "Refresh Standalone Frame Clip");
clip.SetFrames(frames);
if (string.IsNullOrWhiteSpace(clip.Id))
{
var initialId = !string.IsNullOrWhiteSpace(preview.SourceTagName)
? preview.SourceTagName
: Path.GetFileNameWithoutExtension(AssetDatabase.GetAssetPath(clip));
clip.SetId(initialId);
if (string.IsNullOrWhiteSpace(clip.DisplayName)) clip.SetDisplayName(initialId);
}
source.SetLastImport(
FrameAnimationSpriteUtility.ComputeSourceHash(Context(clip, source)),
preview.SourceTagName);
EditorUtility.SetDirty(clip);
AssetDatabase.SaveAssets();
Undo.CollapseUndoOperations(group);
return true;
}
catch (Exception exception)
{
Undo.RevertAllDownToGroup(group);
if (snapshot != null) FrameAnimationSpriteUtility.RestoreSnapshot(snapshot);
AssetDatabase.SaveAssets();
error = $"独立 Clip 导入失败,已尝试回滚:{exception.Message}";
Debug.LogException(exception);
return false;
}
}
private static void ValidateFrames(
FrameClip clip,
FrameClipImportSource source,
AsepriteSourceDocument document,
FrameClipImportPreview preview)
{
if (document.Frames.Count == 0)
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.FramesMissing, "Aseprite JSON 的 frames 为空。"));
foreach (var duplicate in document.Frames.GroupBy(frame => frame.FrameName).Where(group => group.Count() > 1))
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.FrameNameDuplicate,
$"frameName '{duplicate.Key}' 在来源内重复。"));
for (var index = 0; index < document.Frames.Count; index++)
{
var frame = document.Frames[index];
if (string.IsNullOrWhiteSpace(frame.FrameName))
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.FrameNameInvalid,
$"SourceFrame[{index}] 的 frameName 为空。"));
if (frame.DurationMs <= 0)
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.FrameDurationInvalid,
$"SourceFrame '{frame.FrameName}' 的 duration 必须大于 0。"));
if (frame.Rect.width <= 0 || frame.Rect.height <= 0 || frame.Rect.x < 0 || frame.Rect.y < 0 ||
frame.Rect.xMax > source.Texture.width || frame.Rect.yMax > source.Texture.height)
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.FrameRectInvalid,
$"SourceFrame '{frame.FrameName}' 的 rect 越出 Texture 范围。"));
if (frame.Trimmed)
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.TrimmedUnsupported,
$"SourceFrame '{frame.FrameName}' 使用了不支持的 trimmed。"));
if (frame.Rotated)
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.RotatedUnsupported,
$"SourceFrame '{frame.FrameName}' 使用了不支持的 rotated。"));
}
if (document.TextureSize.x != source.Texture.width || document.TextureSize.y != source.Texture.height)
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.TextureSizeMismatch,
$"meta.size {document.TextureSize.x}x{document.TextureSize.y} 与 Texture {source.Texture.width}x{source.Texture.height} 不一致。"));
}
private static void ValidateTextureOwnership(
FrameClip clip,
FrameClipImportSource source,
FrameClipImportPreview preview)
{
if (!source.ManageSpriteSlicing) return;
var owners = new List<string>();
foreach (var guid in AssetDatabase.FindAssets("t:FrameAnimationGraph"))
{
var graph = AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(AssetDatabase.GUIDToAssetPath(guid));
if (graph == null) continue;
owners.AddRange(graph.ImportSources.Where(item => item != null && item.IsEnabled &&
item.ManageSpriteSlicing && item.Texture == source.Texture)
.Select(item => $"Graph '{graph.name}' / {item.DisplayName}"));
}
foreach (var guid in AssetDatabase.FindAssets("t:FrameClip"))
{
var other = AssetDatabase.LoadAssetAtPath<FrameClip>(AssetDatabase.GUIDToAssetPath(guid));
var otherSource = other?.StandaloneImportSource;
if (other == null || other == clip || otherSource == null || !otherSource.ManageSpriteSlicing ||
otherSource.Texture != source.Texture) continue;
owners.Add($"FrameClip '{other.name}'");
}
if (owners.Count > 0)
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.TextureOwnershipConflict,
$"Texture '{source.Texture.name}' 已有其他可写来源:{string.Join(", ", owners)}"));
}
private static bool FramesEqual(FrameClip clip, FrameClipImportPreview preview)
{
if (clip.FrameCount != preview.SourceIndices.Count) return false;
for (var index = 0; index < preview.SourceIndices.Count; index++)
{
var sourceIndex = preview.SourceIndices[index];
var sourceFrame = preview.Document.Frames[sourceIndex];
var frame = clip.Frames[index];
if (frame == null || frame.SourceIndex != sourceIndex || frame.FrameName != sourceFrame.FrameName ||
frame.DurationMs != sourceFrame.DurationMs ||
!preview.SpritePlan.MatchesSprite(sourceIndex, frame.Sprite)) return false;
}
return true;
}
private static FrameAnimationSpriteSourceContext Context(FrameClip clip, FrameClipImportSource source)
{
return new FrameAnimationSpriteSourceContext(
AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(clip)),
source.Texture, source.AsepriteJson, source.Pivot, source.ManageSpriteSlicing);
}
private static bool IsExternalClip(FrameClip clip)
{
return clip != null && !AssetDatabase.IsSubAsset(clip) &&
!string.IsNullOrEmpty(AssetDatabase.GetAssetPath(clip));
}
private static FrameAnimationImportIssue Error(
FrameClip clip,
FrameAnimationImportIssueCode code,
string message)
{
var path = clip != null ? AssetDatabase.GetAssetPath(clip) : string.Empty;
return new FrameAnimationImportIssue(
FrameAnimationValidationSeverity.Error,
code,
AssetDatabase.AssetPathToGUID(path),
clip != null ? clip.Id : string.Empty,
message);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4448f0db0909671479f225061710e439
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,51 @@
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
namespace AibisDream.FrameAnimation.Editor
{
[CustomEditor(typeof(FrameClipPlayer))]
public sealed class FrameClipPlayerEditor : UnityEditor.Editor
{
public override void OnInspectorGUI()
{
serializedObject.Update();
EditorGUILayout.PropertyField(serializedObject.FindProperty("clip"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("playOnEnable"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("speed"));
DrawWarnings();
serializedObject.ApplyModifiedProperties();
if (!Application.isPlaying) return;
var player = (FrameClipPlayer)target;
EditorGUILayout.Space();
EditorGUILayout.LabelField("Runtime State", EditorStyles.boldLabel);
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.EnumPopup("State", player.State);
EditorGUILayout.ObjectField("Clip", player.CurrentClip, typeof(FrameClip), false);
EditorGUILayout.TextField("Clip ID", player.CurrentClipId);
EditorGUILayout.IntField("Frame", player.CurrentFrameIndex);
}
Repaint();
}
private void DrawWarnings()
{
var player = (FrameClipPlayer)target;
var renderer = player.GetComponent<SpriteRenderer>();
var image = player.GetComponent<Image>();
if (player.GetComponent<FrameAnimationPlayer>() != null)
EditorGUILayout.HelpBox("同一 GameObject 不能同时包含 FrameAnimationPlayer 和 FrameClipPlayer。", MessageType.Error);
if (renderer == null && image == null)
EditorGUILayout.HelpBox("同一 GameObject 上必须存在一个 SpriteRenderer 或 Image。", MessageType.Error);
else if (renderer != null && image != null)
EditorGUILayout.HelpBox("同一 GameObject 不能同时存在 SpriteRenderer 和 Image。", MessageType.Error);
if (serializedObject.FindProperty("clip").objectReferenceValue == null)
EditorGUILayout.HelpBox("尚未绑定默认 FrameClip;仍可通过 Play(FrameClip) 临时播放。", MessageType.Info);
var value = serializedObject.FindProperty("speed").floatValue;
if (!FrameAnimationValueUtility.IsValidSpeed(value))
EditorGUILayout.HelpBox("Speed 必须是有限且不小于 0 的数值。", MessageType.Error);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d7eddf60189f61740be2fe59cda06957
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -8,20 +8,23 @@ namespace AibisDream.SystemEditor.Tests
public sealed class PresentationSnapshotContractTests
{
[Test]
public void Providers_UseDistinctStableIdsAndRestoreBeforeScreen()
public void PresentationProviders_UseStableIdsAndRestoreInDependencyOrder()
{
var showcase = new ShowcaseSnapshotProvider();
var day2Sleep = new Day2SleepPresentationSnapshotProvider();
var playTool = new PlayToolSnapshotProvider();
var screen = new ScreenSnapshotProvider();
Assert.That(showcase.SaveId, Is.EqualTo("showcase"));
Assert.That(day2Sleep.SaveId, Is.EqualTo("day2SleepPresentation"));
Assert.That(playTool.SaveId, Is.EqualTo("playTool"));
Assert.That(showcase.RestoreOrder, Is.LessThan(playTool.RestoreOrder));
Assert.That(showcase.RestoreOrder, Is.LessThan(day2Sleep.RestoreOrder));
Assert.That(day2Sleep.RestoreOrder, Is.LessThan(playTool.RestoreOrder));
Assert.That(playTool.RestoreOrder, Is.LessThan(screen.RestoreOrder));
}
[Test]
public void PresentationSections_RoundTripThroughSnapshotJson()
public void PlayToolSection_RoundTripsThroughSnapshotJson()
{
var source = new SaveSnapshot();
source.sections[SnapshotProviderIds.PlayTool] = new PlayToolSnapshotDto
@@ -31,30 +34,55 @@ namespace AibisDream.SystemEditor.Tests
isFullScreenVisible = true,
fullScreenPicName = "教室"
};
source.sections[SnapshotProviderIds.Showcase] = new ShowcaseSnapshotDto
{
displayMode = nameof(ShowcaseDisplayMode.Small),
picName = "D2S轱辘",
usesSplitLayers = true
};
var json = JsonConvert.SerializeObject(source);
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(json);
var playTool = ((JObject)restored.sections[SnapshotProviderIds.PlayTool])
.ToObject<PlayToolSnapshotDto>();
var showcase = ((JObject)restored.sections[SnapshotProviderIds.Showcase])
.ToObject<ShowcaseSnapshotDto>();
Assert.That(playTool.isObjVisible, Is.True);
Assert.That(playTool.objPicName, Is.EqualTo("证物"));
Assert.That(playTool.isFullScreenVisible, Is.True);
Assert.That(playTool.fullScreenPicName, Is.EqualTo("教室"));
Assert.That(showcase.displayMode, Is.EqualTo(nameof(ShowcaseDisplayMode.Small)));
Assert.That(showcase.picName, Is.EqualTo("D2S轱辘"));
Assert.That(showcase.usesSplitLayers, Is.True);
Assert.That(restored.schemaVersion, Is.EqualTo(1));
}
[Test]
public void ShowcaseAndDay2SleepSections_RoundTripSemanticState()
{
var source = new SaveSnapshot();
source.sections[SnapshotProviderIds.Showcase] = new ShowcaseSnapshotDto
{
displayMode = ShowcaseDisplayMode.Large.ToString(),
picName = "D2S星图",
isBackgroundVisible = true
};
source.sections[SnapshotProviderIds.Day2SleepPresentation] =
new Day2SleepPresentationSnapshotDto
{
mode = Day2SleepPresentationMode.LargeEffects.ToString(),
largeBlurAmount = 0.72f,
largeBlurSize = 0.015f,
largeScaleMultiplier = 1.1f,
largeAlpha = 0.4f
};
var json = JsonConvert.SerializeObject(source);
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(json);
var showcase = ((JObject)restored.sections[SnapshotProviderIds.Showcase])
.ToObject<ShowcaseSnapshotDto>();
var day2Sleep = ((JObject)restored.sections[SnapshotProviderIds.Day2SleepPresentation])
.ToObject<Day2SleepPresentationSnapshotDto>();
Assert.That(showcase.displayMode, Is.EqualTo("Large"));
Assert.That(showcase.picName, Is.EqualTo("D2S星图"));
Assert.That(showcase.isBackgroundVisible, Is.True);
Assert.That(day2Sleep.mode, Is.EqualTo("LargeEffects"));
Assert.That(day2Sleep.largeBlurAmount, Is.EqualTo(0.72f));
Assert.That(day2Sleep.largeScaleMultiplier, Is.EqualTo(1.1f));
Assert.That(day2Sleep.largeAlpha, Is.EqualTo(0.4f));
}
[Test]
public void OldSnapshotWithoutPresentationSections_RemainsCompatible()
{
@@ -62,8 +90,9 @@ namespace AibisDream.SystemEditor.Tests
"{\"schemaVersion\":1,\"sections\":{}}");
Assert.That(restored.schemaVersion, Is.EqualTo(SaveSnapshotSchema.CurrentVersion));
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.PlayTool), Is.False);
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.Showcase), Is.False);
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.Day2SleepPresentation), Is.False);
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.PlayTool), Is.False);
}
}
}