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(AibisEditorMenus.FrameClipEditor)] public static void ShowWindow() { GetWindow("Frame Clip Editor"); } public static void Open(FrameClip value) { var window = GetWindow("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) ? "" : 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")); } } } }