using System; using System.Collections.Generic; using System.IO; using System.Linq; using UnityEditor; using UnityEngine; namespace AibisDream.FrameAnimation.Editor { internal static class FrameAnimationImportService { public static FrameAnimationImportPreview PreviewSource( FrameAnimationGraph graph, string sourceId) { var source = graph?.ImportSources.FirstOrDefault(item => item != null && item.InternalId == sourceId); return PreviewInternal(graph, source != null ? new[] { source } : Array.Empty()); } public static FrameAnimationImportPreview PreviewAll(FrameAnimationGraph graph) { return PreviewInternal( graph, graph?.ImportSources.Where(source => source != null && source.IsEnabled).ToArray() ?? Array.Empty()); } public static bool RefreshSource( FrameAnimationGraph graph, string sourceId, bool allowSpriteChanges, out FrameAnimationImportPreview preview, out string error) { preview = PreviewSource(graph, sourceId); return Apply(preview, allowSpriteChanges, out error); } public static bool RefreshAll( FrameAnimationGraph graph, bool allowSpriteChanges, out FrameAnimationImportPreview preview, out string error) { preview = PreviewAll(graph); return Apply(preview, allowSpriteChanges, out error); } public static bool Apply( FrameAnimationImportPreview preview, bool allowSpriteChanges, out string error) { error = string.Empty; if (preview == null || preview.Graph == null) { error = "导入预览或 Graph 为空。"; return false; } if (preview.HasErrors) { error = "导入预览包含阻断错误,未修改任何资产。"; return false; } if (preview.HasSpriteChanges && !allowSpriteChanges) { error = "导入需要修改 TextureImporter,但尚未获得确认。"; return false; } var graphPath = AssetDatabase.GetAssetPath(preview.Graph); if (string.IsNullOrEmpty(graphPath)) { error = "FrameAnimationGraph 必须先保存为资产才能创建 Imported Clip sub-asset。"; return false; } var snapshots = new List(); Undo.IncrementCurrentGroup(); var undoGroup = Undo.GetCurrentGroup(); Undo.SetCurrentGroupName("Refresh Frame Animation Imports"); try { Undo.RecordObject(preview.Graph, "Refresh Frame Animation Imports"); foreach (var sourcePreview in preview.Sources.Where(item => item.Source.ManageSpriteSlicing && item.HasSpriteChanges)) { var snapshot = FrameAnimationSpriteUtility.CaptureSnapshot(sourcePreview.SpritePlan); snapshots.Add(snapshot); FrameAnimationSpriteUtility.ApplyPlan(sourcePreview.SpritePlan); } foreach (var sourcePreview in preview.Sources) { ApplySource(preview.Graph, sourcePreview); sourcePreview.Source.SetLastSourceHash( FrameAnimationSpriteUtility.ComputeSourceHash(sourcePreview.Source)); } EditorUtility.SetDirty(preview.Graph); AssetDatabase.SaveAssets(); Undo.CollapseUndoOperations(undoGroup); return true; } catch (Exception exception) { try { Undo.RevertAllDownToGroup(undoGroup); for (var index = snapshots.Count - 1; index >= 0; index--) { FrameAnimationSpriteUtility.RestoreSnapshot(snapshots[index]); } AssetDatabase.SaveAssets(); } catch (Exception rollbackException) { Debug.LogError($"Frame Animation 导入回滚失败:{rollbackException}"); } error = $"导入应用失败,已尝试回滚:{exception.Message}"; Debug.LogException(exception); return false; } } private static FrameAnimationImportPreview PreviewInternal( FrameAnimationGraph graph, IReadOnlyList targets) { var preview = new FrameAnimationImportPreview(graph); if (graph == null) { return preview; } var targetSet = new HashSet(targets); var targetPreviews = new Dictionary(); foreach (var source in targets) { var sourcePreview = new FrameAnimationImportSourcePreview(source); targetPreviews[source] = sourcePreview; preview.AddSource(sourcePreview); } ValidateSourceIds(graph, targetPreviews); var contextDocuments = ParseEnabledContext(graph, targetPreviews); ValidateCrossSourceTags(graph, contextDocuments, targetPreviews); ValidateTextureOwnership(graph, targetPreviews); foreach (var pair in targetPreviews) { var source = pair.Key; var sourcePreview = pair.Value; if (!contextDocuments.TryGetValue(source, out var document)) { continue; } sourcePreview.Document = document; ValidateSource(graph, source, document, sourcePreview); if (sourcePreview.HasErrors) { continue; } sourcePreview.SpritePlan = FrameAnimationSpriteUtility.BuildPlan(source, document, sourcePreview); sourcePreview.CurrentHash = FrameAnimationSpriteUtility.ComputeSourceHash(source); if (!string.IsNullOrEmpty(source.LastSourceHash) && source.LastSourceHash != sourcePreview.CurrentHash) { sourcePreview.AddIssue(new FrameAnimationImportIssue( FrameAnimationValidationSeverity.Warning, FrameAnimationImportIssueCode.SourceChanged, source.InternalId, source.InternalId, "来源内容或切图设置自上次刷新后已发生变化。")); } if (!sourcePreview.HasErrors) { BuildClipDiffs(graph, source, document, sourcePreview); } } return preview; } private static Dictionary ParseEnabledContext( FrameAnimationGraph graph, IReadOnlyDictionary targetPreviews) { var result = new Dictionary(); foreach (var source in graph.ImportSources.Where(source => source != null && source.IsEnabled)) { targetPreviews.TryGetValue(source, out var sourcePreview); if (source.AsepriteJson == null) { sourcePreview?.AddIssue(Error(source, FrameAnimationImportIssueCode.JsonMissing, "ImportSource 缺少 Aseprite JSON。")); continue; } if (!AsepriteJsonParser.TryParse(source.AsepriteJson.text, source.InternalId, out var document, out var issue)) { sourcePreview?.AddIssue(issue); continue; } result[source] = document; } return result; } private static void ValidateSourceIds( FrameAnimationGraph graph, IReadOnlyDictionary targetPreviews) { foreach (var source in graph.ImportSources.Where(source => source != null)) { if (!FrameAnimationValueUtility.IsValidInternalId(source.InternalId) && targetPreviews.TryGetValue(source, out var sourcePreview)) { sourcePreview.AddIssue(Error(source, FrameAnimationImportIssueCode.ImportSourceIdInvalid, "ImportSource internalId 必须是 N 格式 GUID。")); } } foreach (var duplicate in graph.ImportSources .Where(source => source != null) .GroupBy(source => source.InternalId) .Where(group => group.Count() > 1)) { foreach (var source in duplicate) { if (targetPreviews.TryGetValue(source, out var sourcePreview)) { sourcePreview.AddIssue(Error(source, FrameAnimationImportIssueCode.ImportSourceIdDuplicate, $"ImportSource internalId '{source.InternalId}' 重复。")); } } } } private static void ValidateSource( FrameAnimationGraph graph, FrameAnimationImportSource source, AsepriteSourceDocument document, FrameAnimationImportSourcePreview preview) { if (source.Texture == null) { preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TextureMissing, "ImportSource 缺少 Texture。")); return; } if (document.Frames.Count == 0) { preview.AddIssue(Error(source, FrameAnimationImportIssueCode.FramesMissing, "Aseprite JSON 的 frames 为空。")); } foreach (var duplicate in document.Frames.GroupBy(frame => frame.FrameName).Where(group => group.Count() > 1)) { preview.AddIssue(Error(source, 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(source, FrameAnimationImportIssueCode.FrameNameInvalid, $"SourceFrame[{index}] 的 frameName 为空。")); } if (frame.DurationMs <= 0) { preview.AddIssue(Error(source, 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(source, FrameAnimationImportIssueCode.FrameRectInvalid, $"SourceFrame '{frame.FrameName}' 的 rect 越出 Texture 范围。")); } if (frame.Trimmed) { preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TrimmedUnsupported, $"SourceFrame '{frame.FrameName}' 使用了第一版不支持的 trimmed。")); } if (frame.Rotated) { preview.AddIssue(Error(source, FrameAnimationImportIssueCode.RotatedUnsupported, $"SourceFrame '{frame.FrameName}' 使用了第一版不支持的 rotated。")); } } if (document.TextureSize.x != source.Texture.width || document.TextureSize.y != source.Texture.height) { preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TextureSizeMismatch, $"meta.size {document.TextureSize.x}x{document.TextureSize.y} 与 Texture {source.Texture.width}x{source.Texture.height} 不一致。")); } var textureFileName = Path.GetFileName(AssetDatabase.GetAssetPath(source.Texture)); if (!string.IsNullOrEmpty(document.ImageName) && !string.Equals(document.ImageName, textureFileName, StringComparison.Ordinal)) { preview.AddIssue(Warning(source, FrameAnimationImportIssueCode.ImageNameMismatch, $"meta.image '{document.ImageName}' 与绑定 Texture '{textureFileName}' 不一致。")); } if (document.Tags.Count == 0) { preview.AddIssue(Warning(source, FrameAnimationImportIssueCode.NoTags, "JSON 中没有 frameTags,此来源不会生成 Clip。")); } foreach (var duplicate in document.Tags.GroupBy(tag => tag.Name).Where(group => group.Count() > 1)) { preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TagNameDuplicate, $"Tag '{duplicate.Key}' 在同一来源内重复。")); } foreach (var tag in document.Tags) { if (string.IsNullOrWhiteSpace(tag.Name)) { preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TagNameInvalid, "Tag 名不能为空。")); continue; } if (!AsepriteJsonParser.TryExpandTag(tag, document.Frames.Count, source.InternalId, out _, out var issue)) { preview.AddIssue(issue); } } ValidateClipAssociations(graph, source, preview); } private static void ValidateClipAssociations( FrameAnimationGraph graph, FrameAnimationImportSource source, FrameAnimationImportSourcePreview preview) { var graphPath = AssetDatabase.GetAssetPath(graph); foreach (var clip in graph.Clips.Where(clip => clip != null && clip.IsImported)) { if (!graph.ImportSources.Any(item => item != null && item.InternalId == clip.ImportInfo.ImportSourceId)) { preview.AddIssue(Error(source, FrameAnimationImportIssueCode.ImportSourceReferenceInvalid, $"Imported Clip '{clip.Id}' 指向不存在的 ImportSource。")); } if (!AssetDatabase.IsSubAsset(clip) || AssetDatabase.GetAssetPath(clip) != graphPath) { preview.AddIssue(Error(source, FrameAnimationImportIssueCode.ImportedClipOwnershipInvalid, $"Imported Clip '{clip.Id}' 必须是所属 Graph 的 sub-asset。")); } } foreach (var duplicate in graph.Clips .Where(clip => clip != null && clip.ImportInfo?.ImportSourceId == source.InternalId) .GroupBy(clip => clip.ImportInfo.SourceTagName) .Where(group => group.Count() > 1)) { preview.AddIssue(Error(source, FrameAnimationImportIssueCode.ImportSourceReferenceInvalid, $"来源 Tag '{duplicate.Key}' 同时关联了多个 Imported Clip。")); } } private static void ValidateCrossSourceTags( FrameAnimationGraph graph, IReadOnlyDictionary documents, IReadOnlyDictionary targetPreviews) { var tags = documents.SelectMany(pair => pair.Value.Tags.Select(tag => (source: pair.Key, tag))); foreach (var duplicate in tags.Where(item => !string.IsNullOrWhiteSpace(item.tag.Name)) .GroupBy(item => item.tag.Name).Where(group => group.Count() > 1)) { foreach (var item in duplicate) { if (targetPreviews.TryGetValue(item.source, out var sourcePreview)) { sourcePreview.AddIssue(Error(item.source, FrameAnimationImportIssueCode.TagConflict, $"Tag '{duplicate.Key}' 与其他启用 ImportSource 重名。")); } } } foreach (var pair in targetPreviews) { if (!documents.TryGetValue(pair.Key, out var document)) { continue; } foreach (var tag in document.Tags) { var existing = graph.Clips.Count(clip => clip != null && clip.ImportInfo?.ImportSourceId == pair.Key.InternalId && clip.ImportInfo.SourceTagName == tag.Name); if (existing > 0) { continue; } if (graph.Clips.Any(clip => clip != null && clip.Id == tag.Name) || graph.Flows.Any(flow => flow != null && flow.Id == tag.Name)) { pair.Value.AddIssue(Error(pair.Key, FrameAnimationImportIssueCode.PlayableIdConflict, $"新 Tag '{tag.Name}' 与现有 Clip / Flow playable id 冲突。")); } } } } private static void ValidateTextureOwnership( FrameAnimationGraph currentGraph, IReadOnlyDictionary targetPreviews) { var owners = new List<(FrameAnimationGraph graph, FrameAnimationImportSource source)>(); foreach (var guid in AssetDatabase.FindAssets("t:FrameAnimationGraph")) { var graph = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid)); if (graph == null) { continue; } owners.AddRange(graph.ImportSources.Where(source => source != null && source.IsEnabled && source.ManageSpriteSlicing && source.Texture != null).Select(source => (graph, source))); } if (!owners.Any(item => item.graph == currentGraph)) { owners.AddRange(currentGraph.ImportSources.Where(source => source != null && source.IsEnabled && source.ManageSpriteSlicing && source.Texture != null).Select(source => (currentGraph, source))); } var standaloneOwners = AssetDatabase.FindAssets("t:FrameClip") .Select(guid => AssetDatabase.LoadAssetAtPath(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 && !(item.graph == currentGraph && ReferenceEquals(item.source, pair.Key))).ToArray(); if (conflicts.Length > 0) { pair.Value.AddIssue(Error(pair.Key, FrameAnimationImportIssueCode.TextureOwnershipConflict, $"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)))); } } } private static void BuildClipDiffs( FrameAnimationGraph graph, FrameAnimationImportSource source, AsepriteSourceDocument document, FrameAnimationImportSourcePreview preview) { var seenTags = new HashSet(); foreach (var tag in document.Tags) { if (!AsepriteJsonParser.TryExpandTag(tag, document.Frames.Count, source.InternalId, out var indices, out _)) { continue; } seenTags.Add(tag.Name); var clip = graph.Clips.SingleOrDefault(item => item != null && item.ImportInfo?.ImportSourceId == source.InternalId && item.ImportInfo.SourceTagName == tag.Name); if (clip == null) { preview.AddClipDiff(new FrameAnimationClipDiff( FrameAnimationImportChangeKind.Added, tag.Name, null, indices, "创建 Imported Clip sub-asset")); continue; } var changed = clip.ImportInfo.IsMissingFromSource || !FramesEqual(clip, document, preview.SpritePlan, indices); preview.AddClipDiff(new FrameAnimationClipDiff( changed ? FrameAnimationImportChangeKind.Updated : FrameAnimationImportChangeKind.Unchanged, tag.Name, clip, indices, changed ? "原地更新导入帧" : "无变化")); } foreach (var clip in graph.Clips.Where(item => item != null && item.ImportInfo?.ImportSourceId == source.InternalId && !seenTags.Contains(item.ImportInfo.SourceTagName))) { preview.AddClipDiff(new FrameAnimationClipDiff( clip.ImportInfo.IsMissingFromSource ? FrameAnimationImportChangeKind.Unchanged : FrameAnimationImportChangeKind.Missing, clip.ImportInfo.SourceTagName, clip, Array.Empty(), clip.ImportInfo.IsMissingFromSource ? "保持 Missing" : "标记 Missing,不删除 Clip")); } } private static bool FramesEqual( FrameClip clip, AsepriteSourceDocument document, FrameAnimationSpritePlan spritePlan, IReadOnlyList indices) { if (clip.FrameCount != indices.Count) { return false; } for (var index = 0; index < indices.Count; index++) { var sourceIndex = indices[index]; var sourceFrame = document.Frames[sourceIndex]; var frame = clip.Frames[index]; if (frame == null || frame.SourceIndex != sourceIndex || frame.FrameName != sourceFrame.FrameName || frame.DurationMs != sourceFrame.DurationMs || spritePlan == null || !spritePlan.MatchesSprite(sourceIndex, frame.Sprite)) { return false; } } return true; } private static void ApplySource( FrameAnimationGraph graph, FrameAnimationImportSourcePreview preview) { var source = preview.Source; var writableSprites = source.ManageSpriteSlicing ? FrameAnimationSpriteUtility.LoadSpritesByName(AssetDatabase.GetAssetPath(source.Texture)) : null; foreach (var diff in preview.ClipDiffs) { if (diff.Kind == FrameAnimationImportChangeKind.Missing) { Undo.RecordObject(diff.Clip, "Mark Imported Clip Missing"); diff.Clip.SetImportMissingState(true); EditorUtility.SetDirty(diff.Clip); continue; } if (diff.Kind == FrameAnimationImportChangeKind.Unchanged && diff.Clip != null) { continue; } var frames = diff.SourceIndices.Select(sourceIndex => { var sourceFrame = preview.Document.Frames[sourceIndex]; if (!preview.SpritePlan.TryResolveSprite(sourceIndex, writableSprites, out var sprite)) { var expectedName = preview.SpritePlan.SpriteNamesBySourceIndex.TryGetValue( sourceIndex, out var plannedName) ? plannedName : sourceFrame.FrameName; throw new InvalidOperationException( $"刷新后找不到 SourceFrame '{sourceFrame.FrameName}' 对应的共享 Sprite '{expectedName}'。 "); } return new FrameAnimationFrame(sprite, sourceFrame.DurationMs, sourceFrame.FrameName, sourceIndex); }).ToArray(); if (diff.Clip != null) { Undo.RecordObject(diff.Clip, "Update Imported Frame Clip"); diff.Clip.ReplaceImportedFrames(frames, source.InternalId, diff.SourceTagName); EditorUtility.SetDirty(diff.Clip); continue; } var clip = ScriptableObject.CreateInstance(); clip.name = diff.SourceTagName; clip.Configure( diff.SourceTagName, diff.SourceTagName, frames, 1f, source.DefaultNewClipEndBehavior, new FrameClipImportInfo(source.InternalId, diff.SourceTagName, false)); AssetDatabase.AddObjectToAsset(clip, graph); Undo.RegisterCreatedObjectUndo(clip, "Create Imported Frame Clip"); graph.AddImportedClip(clip); EditorUtility.SetDirty(clip); } } private static FrameAnimationImportIssue Error( FrameAnimationImportSource source, FrameAnimationImportIssueCode code, string message) { return new FrameAnimationImportIssue( FrameAnimationValidationSeverity.Error, code, source?.InternalId, source?.InternalId, message); } private static FrameAnimationImportIssue Warning( FrameAnimationImportSource source, FrameAnimationImportIssueCode code, string message) { return new FrameAnimationImportIssue( FrameAnimationValidationSeverity.Warning, code, source?.InternalId, source?.InternalId, message); } } }