feat(frame-animation): 编辑器支持共享 SpriteRect 与 Graph 属性面板

This commit is contained in:
2026-07-17 20:19:24 +08:00
parent 7646cb4db3
commit 447d61f941
4 changed files with 329 additions and 66 deletions
@@ -15,18 +15,53 @@ namespace AibisDream.FrameAnimation.Editor
public string TexturePath { get; }
public IReadOnlyList<SpriteRect> ExistingRects { get; }
public IReadOnlyList<SpriteRect> PlannedRects { get; }
public IReadOnlyDictionary<string, Sprite> ReadOnlySprites { get; }
public IReadOnlyDictionary<int, string> SpriteNamesBySourceIndex { get; }
public IReadOnlyDictionary<int, Sprite> ReadOnlySpritesBySourceIndex { get; }
public FrameAnimationSpritePlan(
string texturePath,
IReadOnlyList<SpriteRect> existingRects,
IReadOnlyList<SpriteRect> plannedRects,
IReadOnlyDictionary<string, Sprite> readOnlySprites)
IReadOnlyDictionary<int, string> spriteNamesBySourceIndex,
IReadOnlyDictionary<int, Sprite> readOnlySpritesBySourceIndex)
{
TexturePath = texturePath ?? string.Empty;
ExistingRects = existingRects ?? Array.Empty<SpriteRect>();
PlannedRects = plannedRects ?? Array.Empty<SpriteRect>();
ReadOnlySprites = readOnlySprites ?? new Dictionary<string, Sprite>();
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;
}
}
@@ -63,10 +98,12 @@ namespace AibisDream.FrameAnimation.Editor
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TextureImporterInvalid,
$"无法读取 TextureImporter{texturePath}"));
return new FrameAnimationSpritePlan(texturePath, null, null, null);
return new FrameAnimationSpritePlan(texturePath, null, null, null, null);
}
var existingRects = ReadSpriteRects(importer);
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);
@@ -145,7 +182,7 @@ namespace AibisDream.FrameAnimation.Editor
var texturePath = AssetDatabase.GetAssetPath(source.Texture);
var builder = new StringBuilder();
builder.AppendLine("FrameAnimationImportHash:v1");
builder.AppendLine("FrameAnimationImportHash:v2");
builder.AppendLine(source.AsepriteJson.text ?? string.Empty);
builder.AppendLine(AssetDatabase.AssetPathToGUID(texturePath));
builder.AppendLine(AssetDatabase.GetAssetDependencyHash(texturePath).ToString());
@@ -184,56 +221,113 @@ namespace AibisDream.FrameAnimation.Editor
IReadOnlyList<SpriteRect> existingRects)
{
var planned = CloneRects(existingRects).ToList();
var byName = planned.GroupBy(rect => rect.name).ToDictionary(group => group.Key, group => group.ToList());
foreach (var frame in document.Frames)
var originalRects = planned.ToArray();
var assigned = new HashSet<SpriteRect>();
var spriteNamesBySourceIndex = new Dictionary<int, string>();
foreach (var slot in document.SpriteSlots)
{
var unityRect = ToUnityRect(frame.Rect, source.Texture.height);
if (byName.TryGetValue(frame.FrameName, out var matches) && matches.Count > 1)
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)
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.SpriteMatchFailed,
$"Texture 中存在多个名为 '{frame.FrameName}' 的 SpriteRect。"));
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
FrameAnimationSpriteChangeKind.Error, frame.FrameName, unityRect, "SpriteRect 名称重复"));
continue;
}
if (matches != null && matches.Count == 1)
if (matched == null)
{
var rect = matches[0];
var changed = !RectEquals(rect.rect, unityRect) || rect.pivot != source.Pivot;
rect.rect = unityRect;
rect.pivot = source.Pivot;
rect.alignment = SpriteAlignment.Custom;
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(
changed ? FrameAnimationSpriteChangeKind.Updated : FrameAnimationSpriteChangeKind.Unchanged,
frame.FrameName,
FrameAnimationSpriteChangeKind.Added,
matched.name,
unityRect,
changed ? "更新 rect/pivot,保留 spriteID" : "无变化"));
BuildSlotSummary("新增 SpriteRect", slot),
slot.FrameNames));
continue;
}
var newRect = new SpriteRect
{
name = frame.FrameName,
rect = unityRect,
pivot = source.Pivot,
alignment = SpriteAlignment.Custom,
spriteID = GUID.Generate()
};
planned.Add(newRect);
byName[frame.FrameName] = new List<SpriteRect> { newRect };
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(
FrameAnimationSpriteChangeKind.Added, frame.FrameName, unityRect, "新增 SpriteRect"));
changed ? FrameAnimationSpriteChangeKind.Updated : FrameAnimationSpriteChangeKind.Unchanged,
matched.name,
unityRect,
BuildSlotSummary(changed ? "更新 rect/pivot,保留 spriteID" : "无变化", slot),
slot.FrameNames));
}
var sourceNames = new HashSet<string>(document.Frames.Select(frame => frame.FrameName));
foreach (var retained in existingRects.Where(rect => !sourceNames.Contains(rect.name)))
foreach (var retained in originalRects.Where(rect => !assigned.Contains(rect)))
{
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
FrameAnimationSpriteChangeKind.Retained, retained.name, retained.rect, "源中已消失,保留现有 SpriteRect"));
FrameAnimationSpriteChangeKind.Retained,
retained.name,
retained.rect,
"源中已消失或不再作为共享帧主 Sprite,保留现有 SpriteRect"));
}
return new FrameAnimationSpritePlan(texturePath, existingRects, planned, null);
return new FrameAnimationSpritePlan(
texturePath,
existingRects,
planned,
spriteNamesBySourceIndex,
null);
}
private static FrameAnimationSpritePlan BuildReadOnlyPlan(
@@ -244,38 +338,55 @@ namespace AibisDream.FrameAnimation.Editor
IReadOnlyList<SpriteRect> existingRects)
{
var sprites = AssetDatabase.LoadAllAssetsAtPath(texturePath).OfType<Sprite>().ToArray();
var matches = new Dictionary<string, Sprite>();
foreach (var frame in document.Frames)
var matches = new Dictionary<int, Sprite>();
foreach (var slot in document.SpriteSlots)
{
var unityRect = ToUnityRect(frame.Rect, source.Texture.height);
var named = sprites.Where(sprite => sprite.name == frame.FrameName).ToArray();
var unityRect = ToUnityRect(slot.Rect, source.Texture.height);
var rectMatches = sprites.Where(sprite => RectEquals(sprite.rect, unityRect)).ToArray();
Sprite match = null;
if (named.Length == 1 && RectEquals(named[0].rect, unityRect))
{
match = named[0];
}
else if (named.Length == 0 && rectMatches.Length == 1)
{
match = rectMatches[0];
}
if (match == null || (named.Length > 0 && rectMatches.Length > 0 && !named.Intersect(rectMatches).Any()))
if (rectMatches.Length != 1)
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.SpriteMatchFailed,
$"SourceFrame '{frame.FrameName}' 无法按名称和 rect 唯一匹配 Sprite。"));
$"共享帧 '{slot.CanonicalFrameName}' 无法按 rect 唯一匹配 Sprite,候选数:{rectMatches.Length}。"));
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
FrameAnimationSpriteChangeKind.Error, frame.FrameName, unityRect, "只读 Sprite 匹配失败"));
FrameAnimationSpriteChangeKind.Error,
slot.CanonicalFrameName,
unityRect,
"只读 Sprite 匹配失败",
slot.FrameNames));
continue;
}
matches[frame.FrameName] = match;
foreach (var sourceIndex in slot.SourceIndices)
{
matches[sourceIndex] = rectMatches[0];
}
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
FrameAnimationSpriteChangeKind.Unchanged, frame.FrameName, unityRect, $"匹配 Sprite '{match.name}'"));
FrameAnimationSpriteChangeKind.Unchanged,
rectMatches[0].name,
unityRect,
BuildSlotSummary($"匹配 Sprite '{rectMatches[0].name}'", slot),
slot.FrameNames));
}
return new FrameAnimationSpritePlan(texturePath, existingRects, existingRects, matches);
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)