456 lines
19 KiB
C#
456 lines
19 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Globalization;
|
||
using System.Linq;
|
||
using System.Security.Cryptography;
|
||
using System.Text;
|
||
using UnityEditor;
|
||
using UnityEditor.U2D.Sprites;
|
||
using UnityEngine;
|
||
|
||
namespace AibisDream.FrameAnimation.Editor
|
||
{
|
||
internal sealed class FrameAnimationSpritePlan
|
||
{
|
||
public string TexturePath { get; }
|
||
public IReadOnlyList<SpriteRect> ExistingRects { get; }
|
||
public IReadOnlyList<SpriteRect> PlannedRects { get; }
|
||
public IReadOnlyDictionary<int, string> SpriteNamesBySourceIndex { get; }
|
||
public IReadOnlyDictionary<int, Sprite> ReadOnlySpritesBySourceIndex { get; }
|
||
|
||
public FrameAnimationSpritePlan(
|
||
string texturePath,
|
||
IReadOnlyList<SpriteRect> existingRects,
|
||
IReadOnlyList<SpriteRect> plannedRects,
|
||
IReadOnlyDictionary<int, string> spriteNamesBySourceIndex,
|
||
IReadOnlyDictionary<int, Sprite> readOnlySpritesBySourceIndex)
|
||
{
|
||
TexturePath = texturePath ?? string.Empty;
|
||
ExistingRects = existingRects ?? Array.Empty<SpriteRect>();
|
||
PlannedRects = plannedRects ?? Array.Empty<SpriteRect>();
|
||
SpriteNamesBySourceIndex = spriteNamesBySourceIndex ?? new Dictionary<int, string>();
|
||
ReadOnlySpritesBySourceIndex = readOnlySpritesBySourceIndex ?? new Dictionary<int, Sprite>();
|
||
}
|
||
|
||
public bool MatchesSprite(int sourceIndex, Sprite sprite)
|
||
{
|
||
if (sprite == null)
|
||
{
|
||
return false;
|
||
}
|
||
if (ReadOnlySpritesBySourceIndex.TryGetValue(sourceIndex, out var readOnlySprite))
|
||
{
|
||
return readOnlySprite == sprite;
|
||
}
|
||
return SpriteNamesBySourceIndex.TryGetValue(sourceIndex, out var spriteName) &&
|
||
sprite.name == spriteName;
|
||
}
|
||
|
||
public bool TryResolveSprite(
|
||
int sourceIndex,
|
||
IReadOnlyDictionary<string, Sprite> writableSprites,
|
||
out Sprite sprite)
|
||
{
|
||
if (ReadOnlySpritesBySourceIndex.TryGetValue(sourceIndex, out sprite))
|
||
{
|
||
return sprite != null;
|
||
}
|
||
if (SpriteNamesBySourceIndex.TryGetValue(sourceIndex, out var spriteName) &&
|
||
writableSprites != null && writableSprites.TryGetValue(spriteName, out sprite))
|
||
{
|
||
return sprite != null;
|
||
}
|
||
sprite = null;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
internal sealed class FrameAnimationTextureSnapshot
|
||
{
|
||
public string TexturePath { get; }
|
||
public TextureImporterType TextureType { get; }
|
||
public SpriteImportMode SpriteImportMode { get; }
|
||
public IReadOnlyList<SpriteRect> SpriteRects { get; }
|
||
|
||
public FrameAnimationTextureSnapshot(
|
||
string texturePath,
|
||
TextureImporterType textureType,
|
||
SpriteImportMode spriteImportMode,
|
||
IReadOnlyList<SpriteRect> spriteRects)
|
||
{
|
||
TexturePath = texturePath;
|
||
TextureType = textureType;
|
||
SpriteImportMode = spriteImportMode;
|
||
SpriteRects = spriteRects;
|
||
}
|
||
}
|
||
|
||
internal static class FrameAnimationSpriteUtility
|
||
{
|
||
public static FrameAnimationSpritePlan BuildPlan(
|
||
FrameAnimationImportSource source,
|
||
AsepriteSourceDocument document,
|
||
FrameAnimationImportSourcePreview preview)
|
||
{
|
||
var texturePath = AssetDatabase.GetAssetPath(source.Texture);
|
||
var importer = AssetImporter.GetAtPath(texturePath) as TextureImporter;
|
||
if (importer == null)
|
||
{
|
||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TextureImporterInvalid,
|
||
$"无法读取 TextureImporter:{texturePath}"));
|
||
return new FrameAnimationSpritePlan(texturePath, null, null, null, null);
|
||
}
|
||
|
||
var existingRects = source.ManageSpriteSlicing && importer.spriteImportMode != SpriteImportMode.Multiple
|
||
? Array.Empty<SpriteRect>()
|
||
: ReadSpriteRects(importer);
|
||
return source.ManageSpriteSlicing
|
||
? BuildWritablePlan(source, document, preview, texturePath, existingRects)
|
||
: BuildReadOnlyPlan(source, document, preview, texturePath, existingRects);
|
||
}
|
||
|
||
public static Rect ToUnityRect(RectInt asepriteRect, int textureHeight)
|
||
{
|
||
return new Rect(
|
||
asepriteRect.x,
|
||
textureHeight - asepriteRect.y - asepriteRect.height,
|
||
asepriteRect.width,
|
||
asepriteRect.height);
|
||
}
|
||
|
||
public static FrameAnimationTextureSnapshot CaptureSnapshot(FrameAnimationSpritePlan plan)
|
||
{
|
||
var importer = AssetImporter.GetAtPath(plan.TexturePath) as TextureImporter;
|
||
if (importer == null)
|
||
{
|
||
throw new InvalidOperationException($"无法读取 TextureImporter:{plan.TexturePath}");
|
||
}
|
||
|
||
return new FrameAnimationTextureSnapshot(
|
||
plan.TexturePath,
|
||
importer.textureType,
|
||
importer.spriteImportMode,
|
||
CloneRects(ReadSpriteRects(importer)));
|
||
}
|
||
|
||
public static void ApplyPlan(FrameAnimationSpritePlan plan)
|
||
{
|
||
var importer = AssetImporter.GetAtPath(plan.TexturePath) as TextureImporter;
|
||
if (importer == null)
|
||
{
|
||
throw new InvalidOperationException($"无法读取 TextureImporter:{plan.TexturePath}");
|
||
}
|
||
|
||
Undo.RecordObject(importer, "Refresh Frame Animation Sprite Slicing");
|
||
importer.textureType = TextureImporterType.Sprite;
|
||
importer.spriteImportMode = SpriteImportMode.Multiple;
|
||
WriteSpriteRects(importer, plan.PlannedRects);
|
||
importer.SaveAndReimport();
|
||
}
|
||
|
||
public static void RestoreSnapshot(FrameAnimationTextureSnapshot snapshot)
|
||
{
|
||
var importer = AssetImporter.GetAtPath(snapshot.TexturePath) as TextureImporter;
|
||
if (importer == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
importer.textureType = snapshot.TextureType;
|
||
importer.spriteImportMode = snapshot.SpriteImportMode;
|
||
WriteSpriteRects(importer, snapshot.SpriteRects);
|
||
importer.SaveAndReimport();
|
||
}
|
||
|
||
public static IReadOnlyDictionary<string, Sprite> LoadSpritesByName(string texturePath)
|
||
{
|
||
return AssetDatabase.LoadAllAssetsAtPath(texturePath)
|
||
.OfType<Sprite>()
|
||
.GroupBy(sprite => sprite.name)
|
||
.Where(group => group.Count() == 1)
|
||
.ToDictionary(group => group.Key, group => group.Single());
|
||
}
|
||
|
||
public static string ComputeSourceHash(
|
||
FrameAnimationImportSource source,
|
||
IReadOnlyList<SpriteRect> spriteRects = null)
|
||
{
|
||
if (source == null || source.Texture == null || source.AsepriteJson == null)
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
var texturePath = AssetDatabase.GetAssetPath(source.Texture);
|
||
var builder = new StringBuilder();
|
||
builder.AppendLine("FrameAnimationImportHash:v2");
|
||
builder.AppendLine(source.AsepriteJson.text ?? string.Empty);
|
||
builder.AppendLine(AssetDatabase.AssetPathToGUID(texturePath));
|
||
builder.AppendLine(AssetDatabase.GetAssetDependencyHash(texturePath).ToString());
|
||
builder.AppendLine(source.Pivot.x.ToString("R", CultureInfo.InvariantCulture));
|
||
builder.AppendLine(source.Pivot.y.ToString("R", CultureInfo.InvariantCulture));
|
||
builder.AppendLine(source.ManageSpriteSlicing ? "write" : "read");
|
||
|
||
var rects = spriteRects;
|
||
if (rects == null && AssetImporter.GetAtPath(texturePath) is TextureImporter importer)
|
||
{
|
||
rects = ReadSpriteRects(importer);
|
||
}
|
||
|
||
foreach (var rect in (rects ?? Array.Empty<SpriteRect>()).OrderBy(item => item.name))
|
||
{
|
||
builder.Append(rect.name).Append('|')
|
||
.Append(rect.spriteID).Append('|')
|
||
.Append(rect.rect.x.ToString("R", CultureInfo.InvariantCulture)).Append(',')
|
||
.Append(rect.rect.y.ToString("R", CultureInfo.InvariantCulture)).Append(',')
|
||
.Append(rect.rect.width.ToString("R", CultureInfo.InvariantCulture)).Append(',')
|
||
.Append(rect.rect.height.ToString("R", CultureInfo.InvariantCulture)).Append('|')
|
||
.Append(rect.pivot.x.ToString("R", CultureInfo.InvariantCulture)).Append(',')
|
||
.Append(rect.pivot.y.ToString("R", CultureInfo.InvariantCulture)).AppendLine();
|
||
}
|
||
|
||
using var sha = SHA256.Create();
|
||
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(builder.ToString()));
|
||
return BitConverter.ToString(bytes).Replace("-", string.Empty).ToLowerInvariant();
|
||
}
|
||
|
||
private static FrameAnimationSpritePlan BuildWritablePlan(
|
||
FrameAnimationImportSource source,
|
||
AsepriteSourceDocument document,
|
||
FrameAnimationImportSourcePreview preview,
|
||
string texturePath,
|
||
IReadOnlyList<SpriteRect> existingRects)
|
||
{
|
||
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)
|
||
{
|
||
var unityRect = ToUnityRect(slot.Rect, source.Texture.height);
|
||
SpriteRect matched = null;
|
||
var slotHasError = false;
|
||
foreach (var aliasName in slot.FrameNames)
|
||
{
|
||
var nameMatches = planned.Where(rect => rect.name == aliasName).ToArray();
|
||
if (nameMatches.Length > 1)
|
||
{
|
||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.SpriteMatchFailed,
|
||
$"Texture 中存在多个名为 '{aliasName}' 的 SpriteRect。"));
|
||
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
|
||
FrameAnimationSpriteChangeKind.Error,
|
||
slot.CanonicalFrameName,
|
||
unityRect,
|
||
"SpriteRect 名称重复",
|
||
slot.FrameNames));
|
||
slotHasError = true;
|
||
break;
|
||
}
|
||
if (nameMatches.Length == 1 && !assigned.Contains(nameMatches[0]))
|
||
{
|
||
matched = nameMatches[0];
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (slotHasError)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (matched == null)
|
||
{
|
||
var rectMatches = planned.Where(rect => !assigned.Contains(rect) && RectEquals(rect.rect, unityRect))
|
||
.ToArray();
|
||
if (rectMatches.Length > 1)
|
||
{
|
||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.SpriteMatchFailed,
|
||
$"共享帧 '{slot.CanonicalFrameName}' 的 rect 对应多个现有 SpriteRect。"));
|
||
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
|
||
FrameAnimationSpriteChangeKind.Error,
|
||
slot.CanonicalFrameName,
|
||
unityRect,
|
||
"SpriteRect rect 匹配不唯一",
|
||
slot.FrameNames));
|
||
continue;
|
||
}
|
||
matched = rectMatches.SingleOrDefault();
|
||
}
|
||
|
||
if (matched == null)
|
||
{
|
||
matched = new SpriteRect
|
||
{
|
||
name = slot.CanonicalFrameName,
|
||
rect = unityRect,
|
||
pivot = source.Pivot,
|
||
alignment = SpriteAlignment.Custom,
|
||
spriteID = GUID.Generate()
|
||
};
|
||
planned.Add(matched);
|
||
assigned.Add(matched);
|
||
MapSlot(slot, matched.name, spriteNamesBySourceIndex);
|
||
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
|
||
FrameAnimationSpriteChangeKind.Added,
|
||
matched.name,
|
||
unityRect,
|
||
BuildSlotSummary("新增 SpriteRect", slot),
|
||
slot.FrameNames));
|
||
continue;
|
||
}
|
||
|
||
var changed = !RectEquals(matched.rect, unityRect) || matched.pivot != source.Pivot ||
|
||
matched.alignment != SpriteAlignment.Custom;
|
||
matched.rect = unityRect;
|
||
matched.pivot = source.Pivot;
|
||
matched.alignment = SpriteAlignment.Custom;
|
||
assigned.Add(matched);
|
||
MapSlot(slot, matched.name, spriteNamesBySourceIndex);
|
||
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
|
||
changed ? FrameAnimationSpriteChangeKind.Updated : FrameAnimationSpriteChangeKind.Unchanged,
|
||
matched.name,
|
||
unityRect,
|
||
BuildSlotSummary(changed ? "更新 rect/pivot,保留 spriteID" : "无变化", slot),
|
||
slot.FrameNames));
|
||
}
|
||
|
||
foreach (var retained in originalRects.Where(rect => !assigned.Contains(rect)))
|
||
{
|
||
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
|
||
FrameAnimationSpriteChangeKind.Retained,
|
||
retained.name,
|
||
retained.rect,
|
||
"源中已消失或不再作为共享帧主 Sprite,保留现有 SpriteRect"));
|
||
}
|
||
|
||
return new FrameAnimationSpritePlan(
|
||
texturePath,
|
||
existingRects,
|
||
planned,
|
||
spriteNamesBySourceIndex,
|
||
null);
|
||
}
|
||
|
||
private static FrameAnimationSpritePlan BuildReadOnlyPlan(
|
||
FrameAnimationImportSource source,
|
||
AsepriteSourceDocument document,
|
||
FrameAnimationImportSourcePreview preview,
|
||
string texturePath,
|
||
IReadOnlyList<SpriteRect> existingRects)
|
||
{
|
||
var sprites = AssetDatabase.LoadAllAssetsAtPath(texturePath).OfType<Sprite>().ToArray();
|
||
var matches = new Dictionary<int, Sprite>();
|
||
foreach (var slot in document.SpriteSlots)
|
||
{
|
||
var unityRect = ToUnityRect(slot.Rect, source.Texture.height);
|
||
var rectMatches = sprites.Where(sprite => RectEquals(sprite.rect, unityRect)).ToArray();
|
||
if (rectMatches.Length != 1)
|
||
{
|
||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.SpriteMatchFailed,
|
||
$"共享帧 '{slot.CanonicalFrameName}' 无法按 rect 唯一匹配 Sprite,候选数:{rectMatches.Length}。"));
|
||
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
|
||
FrameAnimationSpriteChangeKind.Error,
|
||
slot.CanonicalFrameName,
|
||
unityRect,
|
||
"只读 Sprite 匹配失败",
|
||
slot.FrameNames));
|
||
continue;
|
||
}
|
||
|
||
foreach (var sourceIndex in slot.SourceIndices)
|
||
{
|
||
matches[sourceIndex] = rectMatches[0];
|
||
}
|
||
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
|
||
FrameAnimationSpriteChangeKind.Unchanged,
|
||
rectMatches[0].name,
|
||
unityRect,
|
||
BuildSlotSummary($"匹配 Sprite '{rectMatches[0].name}'", slot),
|
||
slot.FrameNames));
|
||
}
|
||
|
||
return new FrameAnimationSpritePlan(texturePath, existingRects, existingRects, null, matches);
|
||
}
|
||
|
||
private static void MapSlot(
|
||
AsepriteSpriteSlot slot,
|
||
string spriteName,
|
||
IDictionary<int, string> spriteNamesBySourceIndex)
|
||
{
|
||
foreach (var sourceIndex in slot.SourceIndices)
|
||
{
|
||
spriteNamesBySourceIndex[sourceIndex] = spriteName;
|
||
}
|
||
}
|
||
|
||
private static string BuildSlotSummary(string prefix, AsepriteSpriteSlot slot)
|
||
{
|
||
return slot.SourceIndices.Count > 1
|
||
? $"{prefix};{slot.SourceIndices.Count} 个 SourceFrame 共享"
|
||
: prefix;
|
||
}
|
||
|
||
private static IReadOnlyList<SpriteRect> ReadSpriteRects(TextureImporter importer)
|
||
{
|
||
var factories = new SpriteDataProviderFactories();
|
||
factories.Init();
|
||
var provider = factories.GetSpriteEditorDataProviderFromObject(importer);
|
||
if (provider == null)
|
||
{
|
||
return Array.Empty<SpriteRect>();
|
||
}
|
||
|
||
provider.InitSpriteEditorDataProvider();
|
||
return CloneRects(provider.GetSpriteRects());
|
||
}
|
||
|
||
private static void WriteSpriteRects(TextureImporter importer, IReadOnlyList<SpriteRect> rects)
|
||
{
|
||
var factories = new SpriteDataProviderFactories();
|
||
factories.Init();
|
||
var provider = factories.GetSpriteEditorDataProviderFromObject(importer);
|
||
if (provider == null)
|
||
{
|
||
throw new InvalidOperationException($"无法获取 Sprite Data Provider:{importer.assetPath}");
|
||
}
|
||
|
||
provider.InitSpriteEditorDataProvider();
|
||
provider.SetSpriteRects(CloneRects(rects).ToArray());
|
||
provider.Apply();
|
||
}
|
||
|
||
private static IReadOnlyList<SpriteRect> CloneRects(IEnumerable<SpriteRect> rects)
|
||
{
|
||
return (rects ?? Array.Empty<SpriteRect>()).Select(rect => new SpriteRect
|
||
{
|
||
name = rect.name,
|
||
rect = rect.rect,
|
||
pivot = rect.pivot,
|
||
alignment = rect.alignment,
|
||
border = rect.border,
|
||
spriteID = rect.spriteID
|
||
}).ToArray();
|
||
}
|
||
|
||
private static bool RectEquals(Rect left, Rect right)
|
||
{
|
||
return Mathf.Approximately(left.x, right.x) &&
|
||
Mathf.Approximately(left.y, right.y) &&
|
||
Mathf.Approximately(left.width, right.width) &&
|
||
Mathf.Approximately(left.height, right.height);
|
||
}
|
||
|
||
private static FrameAnimationImportIssue Error(
|
||
FrameAnimationImportSource source,
|
||
FrameAnimationImportIssueCode code,
|
||
string message)
|
||
{
|
||
return new FrameAnimationImportIssue(
|
||
FrameAnimationValidationSeverity.Error,
|
||
code,
|
||
source?.InternalId,
|
||
source?.InternalId,
|
||
message);
|
||
}
|
||
}
|
||
}
|