// Animancer // https://kybernetik.com.au/animancer // Copyright 2018-2024 Kybernetik // #if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using UnityEditor; using UnityEditorInternal; using UnityEngine; using static Animancer.Editor.AnimancerGUI; namespace Animancer.Editor.Tools { /// [Editor-Only] [Pro-Only] /// A for generating s from s. /// /// /// Documentation: /// /// Generate Sprite Animations /// /// https://kybernetik.com.au/animancer/api/Animancer.Editor.Tools/GenerateSpriteAnimationsTool /// [Serializable] public class GenerateSpriteAnimationsTool : SpriteModifierTool { /************************************************************************************************************************/ #region Tool /************************************************************************************************************************/ [NonSerialized] private List _Names; [NonSerialized] private Dictionary> _NameToSprites; [NonSerialized] private ReorderableList _Display; [NonSerialized] private bool _NamesAreDirty; [NonSerialized] private double _PreviewStartTime; [NonSerialized] private long _PreviewFrameIndex; [NonSerialized] private bool _RequiresRepaint; /************************************************************************************************************************/ /// public override int DisplayOrder => 3; /// public override string Name => "Generate Sprite Animations"; /// public override string HelpURL => Strings.DocsURLs.GenerateSpriteAnimations; /// public override string Instructions { get { if (Sprites.Count == 0) return "Select the Sprites you want to generate animations from."; return "Configure the animation settings then click Generate."; } } /************************************************************************************************************************/ /// public override void OnEnable(int index) { base.OnEnable(index); _Names = new(); _NameToSprites = new(); _Display = AnimancerToolsWindow.CreateReorderableList( _Names, "Animations to Generate", DrawDisplayElement); _Display.elementHeightCallback = CalculateDisplayElementHeight; _PreviewStartTime = EditorApplication.timeSinceStartup; } /************************************************************************************************************************/ /// public override void OnSelectionChanged() { _NameToSprites.Clear(); _Names.Clear(); _NamesAreDirty = true; } /************************************************************************************************************************/ /// public override void DoBodyGUI() { var property = GenerateSpriteAnimationsSettings.SerializedProperty; property.serializedObject.Update(); using (var label = PooledGUIContent.Acquire("Settings")) EditorGUILayout.PropertyField(property, label, true); property.serializedObject.ApplyModifiedProperties(); GenerateSpriteAnimationsSettings.Instance.FillDefaults(); var sprites = Sprites; if (_NamesAreDirty) { _NamesAreDirty = false; GatherNameToSprites(sprites, _NameToSprites); _Names.AddRange(_NameToSprites.Keys); } using (new EditorGUI.DisabledScope(true)) { var previewCurrentTime = EditorApplication.timeSinceStartup - _PreviewStartTime; _PreviewFrameIndex = (long)(previewCurrentTime * GenerateSpriteAnimationsSettings.FrameRate); _Display.DoLayoutList(); GUILayout.BeginHorizontal(); { GUILayout.FlexibleSpace(); GUI.enabled = sprites.Count > 0; if (GUILayout.Button("Generate")) { Deselect(); GenerateAnimationsBySpriteName(sprites); } } GUILayout.EndHorizontal(); } EditorGUILayout.HelpBox("This function is also available via:" + "\n• The 'Assets/Create/Animancer' menu." + "\n• The Context Menu in the top right of the Inspector for Sprite and Texture assets", MessageType.Info); if (_RequiresRepaint) { _RequiresRepaint = false; AnimancerToolsWindow.Repaint(); } } /************************************************************************************************************************/ /// Calculates the height of an animation to generate. private float CalculateDisplayElementHeight(int index) { if (_NameToSprites.Count <= 0 || _Names.Count <= 0) return 0; var lineCount = _NameToSprites[_Names[index]].Count + 3; return (LineHeight + StandardSpacing) * lineCount; } /************************************************************************************************************************/ /// Draws the details of an animation to generate. private void DrawDisplayElement(Rect area, int index, bool isActive, bool isFocused) { area.y = Mathf.Ceil(area.y + StandardSpacing * 0.5f); area.height = LineHeight; DrawAnimationHeader(ref area, index, out var sprites); DrawAnimationBody(ref area, sprites); } /************************************************************************************************************************/ /// Draws the name and preview of an animation to generate. private void DrawAnimationHeader(ref Rect area, int index, out List sprites) { var width = area.width; var previewSize = 3 * LineHeight + 2 * StandardSpacing; var previewArea = StealFromRight(ref area, previewSize, StandardSpacing); previewArea.height = previewSize; // Name. var name = _Names[index]; AnimancerToolsWindow.BeginChangeCheck(); name = EditorGUI.TextField(area, name); if (AnimancerToolsWindow.EndChangeCheck()) { _Names[index] = name; } NextVerticalArea(ref area); // Frame Count. sprites = _NameToSprites[name]; var frame = (int)(_PreviewFrameIndex % sprites.Count); var enabled = GUI.enabled; GUI.enabled = false; EditorGUI.TextField(area, $"Frame {frame} / {sprites.Count}"); NextVerticalArea(ref area); // Preview Time. GUI.enabled = true; var beforeControlID = GUIUtility.GetControlID(FocusType.Passive); var newFrame = EditorGUI.IntSlider(area, frame, 0, sprites.Count); var afterControlID = GUIUtility.GetControlID(FocusType.Passive); var hotControl = GUIUtility.hotControl; if (newFrame != frame || (hotControl > beforeControlID && hotControl < afterControlID)) { _PreviewStartTime = EditorApplication.timeSinceStartup; _PreviewStartTime -= newFrame / GenerateSpriteAnimationsSettings.FrameRate; _PreviewFrameIndex = newFrame; frame = newFrame % sprites.Count; } GUI.enabled = enabled; NextVerticalArea(ref area); area.width = width; // Preview. DrawSprite(previewArea, sprites[frame]); _RequiresRepaint = true; } /************************************************************************************************************************/ /// Draws the sprite contents of an animation to generate. private void DrawAnimationBody(ref Rect area, List sprites) { var previewFrame = (int)(_PreviewFrameIndex % sprites.Count); for (int i = 0; i < sprites.Count; i++) { var sprite = sprites[i]; var fieldArea = area; var thumbnailArea = StealFromLeft( ref fieldArea, fieldArea.height, StandardSpacing); AnimancerToolsWindow.BeginChangeCheck(); sprite = DoObjectFieldGUI(fieldArea, "", sprite, false); if (AnimancerToolsWindow.EndChangeCheck()) { sprites[i] = sprite; } if (i == previewFrame) EditorGUI.DrawRect(fieldArea, new(0.25f, 1, 0.25f, 0.1f)); DrawSprite(thumbnailArea, sprite); NextVerticalArea(ref area); } } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Methods /************************************************************************************************************************/ /// Uses and creates new animations from those groups. private static void GenerateAnimationsBySpriteName(List sprites) { if (sprites.Count == 0) return; sprites.Sort(NaturalCompare); var nameToSprites = new Dictionary>(); GatherNameToSprites(sprites, nameToSprites); var pathToSprites = new Dictionary>(); var message = StringBuilderPool.Instance.Acquire() .Append("Do you wish to generate the following animations?"); const int MaxLines = 25; var line = 0; foreach (var nameToSpriteGroup in nameToSprites) { var path = AssetDatabase.GetAssetPath(nameToSpriteGroup.Value[0]); path = Path.GetDirectoryName(path); path = Path.Combine(path, nameToSpriteGroup.Key + ".anim"); pathToSprites.Add(path, nameToSpriteGroup.Value); if (++line <= MaxLines) { message.AppendLine() .Append("- ") .Append(path) .Append(" (") .Append(nameToSpriteGroup.Value.Count) .Append(" frames)"); } } if (line > MaxLines) { message.AppendLine() .Append("And ") .Append(line - MaxLines) .Append(" others."); } if (!EditorUtility.DisplayDialog("Generate Sprite Animations?", message.ReleaseToString(), "Generate", "Cancel")) return; foreach (var pathToSpriteGroup in pathToSprites) CreateAnimation(pathToSpriteGroup.Key, pathToSpriteGroup.Value.ToArray()); AssetDatabase.SaveAssets(); } /************************************************************************************************************************/ private static char[] _Numbers, _TrimOther; /// Groups the `sprites` by name into the `nameToSptires`. private static void GatherNameToSprites(List sprites, Dictionary> nameToSprites) { for (int i = 0; i < sprites.Count; i++) { var sprite = sprites[i]; var name = sprite.name; // Remove numbers from the end. _Numbers ??= new char[] { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }; name = name.TrimEnd(_Numbers); // Then remove other characters from the end. _TrimOther ??= new char[] { ' ', '_', '-' }; name = name.TrimEnd(_TrimOther); // Doing both at once would turn "Attack2-0" (Attack 2 Frame 0) into "Attack" (losing the number). if (!nameToSprites.TryGetValue(name, out var spriteGroup)) { spriteGroup = new(); nameToSprites.Add(name, spriteGroup); } // Add the sprite to the group if it's not a duplicate. if (spriteGroup.Count == 0 || spriteGroup[^1] != sprite) spriteGroup.Add(sprite); } } /************************************************************************************************************************/ /// Creates and saves a new that plays the `sprites`. private static void CreateAnimation(string path, params Sprite[] sprites) { var frameRate = GenerateSpriteAnimationsSettings.FrameRate; var hierarchyPath = GenerateSpriteAnimationsSettings.HierarchyPath; var type = GenerateSpriteAnimationsSettings.TargetType.Type ?? typeof(SpriteRenderer); var property = GenerateSpriteAnimationsSettings.PropertyName; if (string.IsNullOrWhiteSpace(property)) property = "m_Sprite"; var clip = new AnimationClip { frameRate = frameRate, }; var spriteKeyFrames = new ObjectReferenceKeyframe[sprites.Length]; for (int i = 0; i < spriteKeyFrames.Length; i++) { spriteKeyFrames[i] = new() { time = i / (float)frameRate, value = sprites[i] }; } var spriteBinding = EditorCurveBinding.PPtrCurve(hierarchyPath, type, property); AnimationUtility.SetObjectReferenceCurve(clip, spriteBinding, spriteKeyFrames); AssetDatabase.CreateAsset(clip, path); } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Menu Functions /************************************************************************************************************************/ private const string GenerateAnimationsBySpriteNameFunctionName = "Generate Animations By Sprite Name"; /************************************************************************************************************************/ /// Should be enabled or greyed out? [MenuItem(Strings.CreateMenuPrefix + GenerateAnimationsBySpriteNameFunctionName, validate = true)] private static bool ValidateGenerateAnimationsBySpriteName() { var selection = Selection.objects; for (int i = 0; i < selection.Length; i++) { var selected = selection[i]; if (selected is Sprite || selected is Texture) return true; } return false; } /// Calls with the selected s. [MenuItem( itemName: Strings.CreateMenuPrefix + GenerateAnimationsBySpriteNameFunctionName, priority = Strings.AssetMenuOrder + 6)] private static void GenerateAnimationsBySpriteName() { var sprites = new List(); var selection = Selection.objects; for (int i = 0; i < selection.Length; i++) { var selected = selection[i]; if (selected is Sprite sprite) { sprites.Add(sprite); } else if (selected is Texture2D texture) { sprites.AddRange(LoadAllSpritesInTexture(texture)); } } GenerateAnimationsBySpriteName(sprites); } /************************************************************************************************************************/ private static List _CachedSprites; /// /// Returns a list of s which will be passed into /// by . /// private static List GetCachedSpritesToGenerateAnimations() { if (_CachedSprites == null) return _CachedSprites = new(); // Delay the call in case multiple objects are selected. if (_CachedSprites.Count == 0) { EditorApplication.delayCall += () => { GenerateAnimationsBySpriteName(_CachedSprites); _CachedSprites.Clear(); }; } return _CachedSprites; } /************************************************************************************************************************/ /// /// Adds the to the . /// [MenuItem("CONTEXT/" + nameof(Sprite) + GenerateAnimationsBySpriteNameFunctionName)] private static void GenerateAnimationsFromSpriteByName(MenuCommand command) { GetCachedSpritesToGenerateAnimations().Add((Sprite)command.context); } /************************************************************************************************************************/ /// Should be enabled or greyed out? [MenuItem("CONTEXT/" + nameof(TextureImporter) + GenerateAnimationsBySpriteNameFunctionName, validate = true)] private static bool ValidateGenerateAnimationsFromTextureBySpriteName(MenuCommand command) { var importer = (TextureImporter)command.context; var sprites = LoadAllSpritesAtPath(importer.assetPath); return sprites.Length > 0; } /// /// Adds all sub-assets of the to the /// . /// [MenuItem("CONTEXT/" + nameof(TextureImporter) + GenerateAnimationsBySpriteNameFunctionName)] private static void GenerateAnimationsFromTextureBySpriteName(MenuCommand command) { var cachedSprites = GetCachedSpritesToGenerateAnimations(); var importer = (TextureImporter)command.context; cachedSprites.AddRange(LoadAllSpritesAtPath(importer.assetPath)); } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ } /************************************************************************************************************************/ #region Settings /************************************************************************************************************************/ /// [Editor-Only] Settings for . /// https://kybernetik.com.au/animancer/api/Animancer.Editor.Tools/GenerateSpriteAnimationsSettings [Serializable, InternalSerializableType] public class GenerateSpriteAnimationsSettings : AnimancerSettingsGroup { /************************************************************************************************************************/ /// Gets or creates an instance. public static GenerateSpriteAnimationsSettings Instance => AnimancerSettingsGroup.Instance; /// The representing the . public static SerializedProperty SerializedProperty => Instance.GetSerializedProperty(null); /************************************************************************************************************************/ /// public override string DisplayName => "Generate Sprite Animations Tool"; /// public override int Index => 6; /************************************************************************************************************************/ [SerializeField] [Tooltip("The frame rate to use for new animations")] private float _FrameRate = 12; /// The frame rate to use for new animations. public static ref float FrameRate => ref Instance._FrameRate; /************************************************************************************************************************/ [SerializeField] [Tooltip("The Transform Hierarchy path from the Animator to the object being animated" + " using forward slashes '/' between each object name")] private string _HierarchyPath; /// The Transform Hierarchy path from the to the object being animated. public static ref string HierarchyPath => ref Instance._HierarchyPath; /************************************************************************************************************************/ [SerializeField] [Tooltip("The type of component being animated. Defaults to " + nameof(SpriteRenderer) + " if not set." + " Use the type picker on the right or drag and drop a component onto it to set this field.")] private SerializableTypeReference _TargetType = new(typeof(SpriteRenderer)); /// The type of component being animated. Defaults to if not set. public static ref SerializableTypeReference TargetType => ref Instance._TargetType; /************************************************************************************************************************/ /// The default value for . public const string DefaultPropertyName = "m_Sprite"; [SerializeField] [Tooltip("The path of the property being animated. Defaults to " + DefaultPropertyName + " if not set.")] private string _PropertyName = DefaultPropertyName; /// The path of the property being animated. public static ref string PropertyName => ref Instance._PropertyName; /************************************************************************************************************************/ /// Reverts any empty values to their defaults. public void FillDefaults() { if (string.IsNullOrWhiteSpace(_TargetType.QualifiedName)) _TargetType = new(typeof(SpriteRenderer)); if (string.IsNullOrWhiteSpace(_PropertyName)) _PropertyName = DefaultPropertyName; } /************************************************************************************************************************/ } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ } #endif