// MIT License - Copyright (c) 2023 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
namespace WallstopStudios.UnityHelpers.Editor.Sprites
{
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEngine;
using CustomEditors;
using Utils;
using WallstopStudios.UnityHelpers.Core.Animation;
using WallstopStudios.UnityHelpers.Core.Extension;
using WallstopStudios.UnityHelpers.Core.Helper;
using WallstopStudios.UnityHelpers.Core.Serialization;
using WallstopStudios.UnityHelpers.Utils;
using Object = UnityEngine.Object;
///
/// Data class representing a single animation definition with frames, timing, and preview settings.
///
[Serializable]
public sealed class AnimationData
{
///
/// Default frames per second for new animations.
///
public const float DefaultFramesPerSecond = 12;
///
/// The sprite frames that make up this animation.
///
public List frames = new();
///
/// Constant frames per second (used when is ).
///
public float framesPerSecond = DefaultFramesPerSecond;
///
/// Name of the animation clip to be generated.
///
public string animationName = string.Empty;
///
/// Whether this animation data was created from auto-parsing.
///
public bool isCreatedFromAutoParse;
///
/// Whether the animation should loop.
///
public bool loop;
///
/// Determines how the framerate is calculated for each frame.
///
public FramerateMode framerateMode = FramerateMode.Constant;
///
/// AnimationCurve defining FPS over normalized animation progress (0-1).
/// X-axis: normalized frame position (0 = first frame, 1 = last frame).
/// Y-axis: frames per second at that position.
/// Used when is .
///
public AnimationCurve framesPerSecondCurve = AnimationCurve.Constant(
0f,
1f,
DefaultFramesPerSecond
);
///
/// Starting point in the animation loop (0-1). Applied to the generated clip settings.
///
public float cycleOffset;
///
/// Whether to show the live preview panel for this animation.
/// This field is transient and not serialized to assets.
///
[NonSerialized]
public bool showPreview;
}
///
/// Builds AnimationClips from sprites using flexible grouping and naming rules. Supports
/// auto-parsing by folders, regex-based grouping, duplicate-resolution, dry-run previews,
/// optional case-insensitive grouping, variable framerate via AnimationCurve, and live animation preview.
///
///
///
/// Problems this solves: turning folder(s) of sprites into one or many consistent
/// assets with predictable names and frame rates. The variable
/// framerate feature allows creating animations with timing variations like attack windups,
/// ease-in/ease-out effects, and dramatic pauses.
///
///
/// How it works: choose directories and a sprite name regex; optionally supply custom group
/// regex with named groups base /index or rely on common patterns
/// (e.g., name_01, name (2), name3). Configure per-animation FPS or FPS curve, loop flag, and naming
/// prefixes/suffixes. Use Calculate/Dry-Run sections to preview results before generating.
/// Enable the preview panel to see live animation playback before saving.
///
///
/// Pros: reproducible clip creation, battle-tested grouping heuristics, detailed previews,
/// variable framerate support, live animation preview.
/// Caveats: ensure regex correctness; strict numeric ordering can be toggled when mixed digits
/// produce undesired lexicographic ordering.
///
///
///
/// Walk)_(?\d+)$
/// // Add folders, enable "Resolve Duplicate Animation Names" to avoid conflicts,
/// // configure Framerate Mode to "Curve" for variable timing,
/// // then Generate to create .anim files under a chosen folder.
/// ]]>
///
public sealed class AnimationCreatorWindow : EditorWindow
{
private static readonly char[] WhiteSpaceSplitters = { ' ', '\t', '\n', '\r' };
private static readonly GUIContent AnimationNameContent = new("Animation Name");
private static readonly GUIContent FramesContent = new("Frames");
private static readonly GUIContent LoopContent = new("Loop");
private static readonly GUIContent FramerateModeContent = new(
"Framerate Mode",
"Constant: Single FPS value\nCurve: Variable FPS over animation progress"
);
private static readonly GUIContent FpsContent = new("FPS");
private static readonly GUIContent FpsCurveContent = new(
"FPS Curve",
"X: Frame progress (0-1), Y: FPS at that point"
);
private static readonly GUIContent CycleOffsetContent = new(
"Cycle Offset",
"Starting point in the animation loop (0-1)"
);
private static readonly GUIContent FlatButtonContent = new(
"Flat",
"Constant FPS throughout"
);
private static readonly GUIContent EaseInButtonContent = new(
"Ease In",
"Start slow, speed up"
);
private static readonly GUIContent EaseOutButtonContent = new(
"Ease Out",
"Start fast, slow down"
);
private static readonly GUIContent SyncButtonContent = new(
"Sync",
"Set curve to current constant FPS"
);
private static readonly GUIContent PreviewToggleContent = new(
"Preview",
"Show live animation preview"
);
private SerializedObject _serializedObject;
private SerializedProperty _animationDataProp;
private SerializedProperty _animationSourcesProp;
private SerializedProperty _spriteNameRegexProp;
private SerializedProperty _textProp;
private SerializedProperty _autoRefreshProp;
private SerializedProperty _groupingCaseInsensitiveProp;
private SerializedProperty _includeFolderNameProp;
private SerializedProperty _includeFullFolderPathProp;
private SerializedProperty _autoParseNamePrefixProp;
private SerializedProperty _autoParseNameSuffixProp;
private SerializedProperty _useCustomGroupRegexProp;
private SerializedProperty _customGroupRegexProp;
private SerializedProperty _customGroupRegexIgnoreCaseProp;
private SerializedProperty _resolveDuplicateNamesProp;
private SerializedProperty _regexTestInputProp;
private SerializedProperty _strictNumericOrderingProp;
public List animationData = new();
public List animationSources = new();
public string spriteNameRegex = ".*";
public string text;
public bool autoRefresh = true;
public bool groupingCaseInsensitive = true;
public bool includeFolderNameInAnimName;
public bool includeFullFolderPathInAnimName;
public string autoParseNamePrefix = string.Empty;
public string autoParseNameSuffix = string.Empty;
public bool useCustomGroupRegex;
public string customGroupRegex = string.Empty;
public bool customGroupRegexIgnoreCase = true;
public bool resolveDuplicateAnimationNames = true;
public string regexTestInput = string.Empty;
public bool strictNumericOrdering = false;
[HideInInspector]
[SerializeField]
private List _filteredSprites = new();
private int _matchedSpriteCount;
private int _unmatchedSpriteCount;
private Regex _compiledRegex;
private string _lastUsedRegex;
private string _searchString = string.Empty;
private Vector2 _scrollPosition;
private string _errorMessage = string.Empty;
private Regex _compiledGroupRegex;
private string _lastGroupRegex;
private string _groupRegexErrorMessage = string.Empty;
private int _lastSourcesHash;
private bool _animationDataIsExpanded = true;
private bool _autoParsePreviewExpanded = false;
private bool _autoParseDryRunExpanded = false;
private readonly Stopwatch _previewTimer = new();
private int _previewAnimationIndex = -1;
private int _previewFrameIndex;
private TimeSpan _lastPreviewTick;
private bool _isPreviewPlaying;
private readonly Dictionary _previewTextureCache = new();
private readonly Dictionary _loadedConfigs = new();
private bool _configSectionExpanded = true;
private bool _needsRefresh;
private int _lastRefreshFrame = -1;
private int _lastLayoutFrame = -1;
private float _lastScrollY;
private const float ScrollThresholdForRepaint = 1f;
private readonly struct CachedElementProperties
{
public readonly SerializedProperty animationNameProp;
public readonly SerializedProperty framesProp;
public readonly SerializedProperty loopProp;
public readonly SerializedProperty framerateModeProp;
public readonly SerializedProperty fpsProp;
public readonly SerializedProperty curveProp;
public readonly SerializedProperty cycleOffsetProp;
public readonly int arrayIndex;
public readonly int frameCount;
public CachedElementProperties(
SerializedProperty elementProp,
int index,
int frameCount
)
{
this.arrayIndex = index;
this.frameCount = frameCount;
animationNameProp = elementProp.FindPropertyRelative(
nameof(AnimationData.animationName)
);
framesProp = elementProp.FindPropertyRelative(nameof(AnimationData.frames));
loopProp = elementProp.FindPropertyRelative(nameof(AnimationData.loop));
framerateModeProp = elementProp.FindPropertyRelative(
nameof(AnimationData.framerateMode)
);
fpsProp = elementProp.FindPropertyRelative(nameof(AnimationData.framesPerSecond));
curveProp = elementProp.FindPropertyRelative(
nameof(AnimationData.framesPerSecondCurve)
);
cycleOffsetProp = elementProp.FindPropertyRelative(
nameof(AnimationData.cycleOffset)
);
}
}
private readonly Dictionary _cachedElementProperties = new();
private int _lastCacheFrame = -1;
private int _animationDataPageIndex;
private const int AnimationDataPageSize = 20;
private sealed class AutoParsePreviewRecord
{
public string folder;
public string baseName;
public int count;
public bool hasIndex;
}
private readonly List _autoParsePreview = new();
private sealed class AutoParseDryRunRecord
{
public string folderPath;
public string finalName;
public string finalAssetPath;
public int count;
public bool hasIndex;
public bool duplicateResolved;
}
private readonly List _autoParseDryRun = new();
private static readonly Regex s_ParenIndexRegex = new(
@"^(? .*?)[\s]*\(\s*(?\d+)\s*\)\s*$",
RegexOptions.Compiled | RegexOptions.CultureInvariant
);
private static readonly Regex s_SeparatorIndexRegex = new(
@"^(? .*?)[_\-\.\s]+(?\d+)$",
RegexOptions.Compiled | RegexOptions.CultureInvariant
);
private static readonly Regex s_TrailingIndexRegex = new(
@"^(? .*?)(?\d+)$",
RegexOptions.Compiled | RegexOptions.CultureInvariant
);
[MenuItem("Tools/Wallstop Studios/Unity Helpers/Animation Creator", priority = -3)]
public static void ShowWindow()
{
GetWindow("Animation Creator");
}
private void OnEnable()
{
_serializedObject = new SerializedObject(this);
_animationDataProp = _serializedObject.FindProperty(nameof(animationData));
_animationSourcesProp = _serializedObject.FindProperty(nameof(animationSources));
_spriteNameRegexProp = _serializedObject.FindProperty(nameof(spriteNameRegex));
_textProp = _serializedObject.FindProperty(nameof(text));
_autoRefreshProp = _serializedObject.FindProperty(nameof(autoRefresh));
_groupingCaseInsensitiveProp = _serializedObject.FindProperty(
nameof(groupingCaseInsensitive)
);
_includeFolderNameProp = _serializedObject.FindProperty(
nameof(includeFolderNameInAnimName)
);
_includeFullFolderPathProp = _serializedObject.FindProperty(
nameof(includeFullFolderPathInAnimName)
);
_autoParseNamePrefixProp = _serializedObject.FindProperty(nameof(autoParseNamePrefix));
_autoParseNameSuffixProp = _serializedObject.FindProperty(nameof(autoParseNameSuffix));
_useCustomGroupRegexProp = _serializedObject.FindProperty(nameof(useCustomGroupRegex));
_customGroupRegexProp = _serializedObject.FindProperty(nameof(customGroupRegex));
_customGroupRegexIgnoreCaseProp = _serializedObject.FindProperty(
nameof(customGroupRegexIgnoreCase)
);
_resolveDuplicateNamesProp = _serializedObject.FindProperty(
nameof(resolveDuplicateAnimationNames)
);
_regexTestInputProp = _serializedObject.FindProperty(nameof(regexTestInput));
_strictNumericOrderingProp = _serializedObject.FindProperty(
nameof(strictNumericOrdering)
);
UpdateRegex();
UpdateGroupRegex();
FindAndFilterSprites();
_lastSourcesHash = ComputeSourcesHash();
_needsRefresh = false;
_lastRefreshFrame = Time.frameCount;
TryAutoLoadConfigs();
_previewTimer.Start();
EditorApplication.update += OnPreviewUpdate;
Repaint();
}
private void OnDisable()
{
EditorApplication.update -= OnPreviewUpdate;
_previewTimer.Stop();
_previewAnimationIndex = -1;
_previewTextureCache.Clear();
_cachedElementProperties.Clear();
_lastCacheFrame = -1;
}
private void OnPreviewUpdate()
{
if (!_isPreviewPlaying || _previewAnimationIndex < 0)
{
return;
}
if (_previewAnimationIndex >= animationData.Count)
{
StopPreview();
return;
}
AnimationData data = animationData[_previewAnimationIndex];
if (data.frames.Count == 0)
{
return;
}
float targetFps = GetCurrentFps(data, _previewFrameIndex);
if (targetFps <= 0)
{
targetFps = AnimationData.DefaultFramesPerSecond;
}
TimeSpan frameDuration = TimeSpan.FromMilliseconds(1000.0 / targetFps);
TimeSpan elapsed = _previewTimer.Elapsed;
if (elapsed - _lastPreviewTick > frameDuration + frameDuration)
{
_lastPreviewTick = elapsed - frameDuration;
}
if (_lastPreviewTick + frameDuration > elapsed)
{
return;
}
_lastPreviewTick += frameDuration;
int nextFrame = _previewFrameIndex + 1;
if (nextFrame >= data.frames.Count)
{
if (data.loop)
{
nextFrame = 0;
}
else
{
StopPreview();
return;
}
}
_previewFrameIndex = nextFrame;
Repaint();
}
private void OnGUI()
{
_serializedObject.Update();
EventType currentEventType = Event.current.type;
bool isLayoutEvent = currentEventType == EventType.Layout;
bool isRepaintEvent = currentEventType == EventType.Repaint;
int currentFrame = Time.frameCount;
if (isLayoutEvent && currentFrame != _lastLayoutFrame)
{
_lastLayoutFrame = currentFrame;
if (autoRefresh)
{
bool regexChanged = _compiledRegex == null || _lastUsedRegex != spriteNameRegex;
int currentSourcesHash = ComputeSourcesHash();
bool sourcesChanged = currentSourcesHash != _lastSourcesHash;
if (regexChanged)
{
UpdateRegex();
_needsRefresh = true;
}
if (sourcesChanged)
{
_lastSourcesHash = currentSourcesHash;
_needsRefresh = true;
TryAutoLoadConfigs();
}
if (
_compiledGroupRegex == null
|| _lastGroupRegex != customGroupRegex
|| customGroupRegexIgnoreCase
!= ((_compiledGroupRegex?.Options & RegexOptions.IgnoreCase) != 0)
)
{
UpdateGroupRegex();
}
}
if (_needsRefresh && currentFrame != _lastRefreshFrame)
{
_lastRefreshFrame = currentFrame;
_needsRefresh = false;
FindAndFilterSprites();
}
if (currentFrame != _lastCacheFrame)
{
_cachedElementProperties.Clear();
_lastCacheFrame = currentFrame;
}
}
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition);
EditorGUILayout.LabelField("Configuration", EditorStyles.boldLabel);
PersistentDirectoryGUI.PathSelectorObjectArray(
_animationSourcesProp,
nameof(AnimationCreatorWindow)
);
EditorGUILayout.PropertyField(_spriteNameRegexProp);
EditorGUILayout.PropertyField(_textProp);
EditorGUILayout.PropertyField(_autoRefreshProp, new GUIContent("Auto Refresh Filter"));
EditorGUILayout.Space();
EditorGUILayout.LabelField("Grouping & Naming", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(
_groupingCaseInsensitiveProp,
new GUIContent("Case-Insensitive Grouping")
);
EditorGUILayout.PropertyField(
_includeFolderNameProp,
new GUIContent("Prefix Leaf Folder Name")
);
EditorGUILayout.PropertyField(
_includeFullFolderPathProp,
new GUIContent("Prefix Full Folder Path")
);
EditorGUILayout.PropertyField(
_autoParseNamePrefixProp,
new GUIContent("Auto-Parse Name Prefix")
);
EditorGUILayout.PropertyField(
_autoParseNameSuffixProp,
new GUIContent("Auto-Parse Name Suffix")
);
EditorGUILayout.PropertyField(
_resolveDuplicateNamesProp,
new GUIContent("Resolve Duplicate Animation Names")
);
EditorGUILayout.PropertyField(
_strictNumericOrderingProp,
new GUIContent("Strict Numeric Ordering")
);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Custom Group Regex", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(
_useCustomGroupRegexProp,
new GUIContent("Enable Custom Group Regex")
);
using (new EditorGUI.DisabledScope(!_useCustomGroupRegexProp.boolValue))
{
EditorGUILayout.PropertyField(
_customGroupRegexProp,
new GUIContent("Pattern (? )(?)")
);
EditorGUILayout.PropertyField(
_customGroupRegexIgnoreCaseProp,
new GUIContent("Ignore Case (Regex)")
);
}
EditorGUILayout.Space();
EditorGUILayout.LabelField("Regex Tester", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(_regexTestInputProp, new GUIContent("Test Input"));
if (!string.IsNullOrEmpty(regexTestInput))
{
if (_compiledRegex != null)
{
bool match = _compiledRegex.IsMatch(regexTestInput);
EditorGUILayout.LabelField("Filter Regex Match:", match ? "Yes" : "No");
}
else
{
EditorGUILayout.LabelField("Filter Regex Match:", "Invalid Pattern");
}
if (useCustomGroupRegex)
{
if (_compiledGroupRegex != null)
{
Match m = _compiledGroupRegex.Match(regexTestInput);
if (m.Success)
{
string b = m.Groups["base"].Success ? m.Groups["base"].Value : "";
string idx = m.Groups["index"].Success ? m.Groups["index"].Value : "";
EditorGUILayout.LabelField("Custom Group Base:", b);
EditorGUILayout.LabelField(
"Custom Group Index:",
string.IsNullOrEmpty(idx) ? "(none)" : idx
);
}
else
{
EditorGUILayout.LabelField("Custom Group Result:", "No match");
}
}
else
{
EditorGUILayout.LabelField("Custom Group Result:", "Invalid Pattern");
}
}
if (TryExtractBaseAndIndex(regexTestInput, out string fbBase, out int fbIndex))
{
EditorGUILayout.LabelField("Fallback Base:", fbBase);
EditorGUILayout.LabelField(
"Fallback Index:",
fbIndex >= 0 ? fbIndex.ToString() : "(none)"
);
}
else
{
EditorGUILayout.LabelField("Fallback Result:", "No index; base = input");
}
}
if (!string.IsNullOrWhiteSpace(_errorMessage))
{
EditorGUILayout.HelpBox(_errorMessage, MessageType.Error);
}
if (!string.IsNullOrWhiteSpace(_groupRegexErrorMessage))
{
EditorGUILayout.HelpBox(_groupRegexErrorMessage, MessageType.Error);
}
else if (
_animationSourcesProp.arraySize == 0
|| animationSources.TrueForAll(val => Objects.Null(val))
)
{
EditorGUILayout.HelpBox(
"Please specify at least one Animation Source (folder).",
MessageType.Error
);
}
EditorGUILayout.Space();
EditorGUILayout.LabelField("Animation Data", EditorStyles.boldLabel);
_searchString = EditorGUILayout.TextField("Search Animation Name", _searchString);
DrawFilteredAnimationData();
EditorGUILayout.Space();
EditorGUILayout.LabelField("Sprite Filter Status", EditorStyles.boldLabel);
EditorGUILayout.LabelField("Regex Pattern:", spriteNameRegex);
EditorGUILayout.LabelField("Matched Sprites:", _matchedSpriteCount.ToString());
EditorGUILayout.LabelField("Unmatched Sprites:", _unmatchedSpriteCount.ToString());
EditorGUILayout.Space();
EditorGUILayout.LabelField("Actions", EditorStyles.boldLabel);
DrawActionButtons();
EditorGUILayout.Space();
EditorGUILayout.LabelField("Auto-Parse Preview", EditorStyles.boldLabel);
using (new EditorGUI.DisabledScope(_filteredSprites.Count == 0))
{
if (GUILayout.Button("Generate Auto-Parse Preview"))
{
GenerateAutoParsePreview();
_autoParsePreviewExpanded = true;
}
if (GUILayout.Button("Generate Dry-Run Apply"))
{
GenerateAutoParseDryRun();
_autoParseDryRunExpanded = true;
}
}
if (_filteredSprites.Count == 0)
{
EditorGUILayout.HelpBox("No matched sprites to preview.", MessageType.Info);
}
_autoParsePreviewExpanded = EditorGUILayout.Foldout(
_autoParsePreviewExpanded,
$"Preview Groups ({_autoParsePreview.Count})",
true
);
if (_autoParsePreviewExpanded && _autoParsePreview.Count > 0)
{
using (new EditorGUI.IndentLevelScope())
{
int shown = 0;
foreach (AutoParsePreviewRecord rec in _autoParsePreview)
{
EditorGUILayout.LabelField(
$"{rec.folder} / {rec.baseName}",
$"Frames: {rec.count} | Numeric: {(rec.hasIndex ? "Yes" : "No")}"
);
shown++;
if (shown >= 200)
{
EditorGUILayout.LabelField($"Showing first {shown} groups...");
break;
}
}
}
}
_autoParseDryRunExpanded = EditorGUILayout.Foldout(
_autoParseDryRunExpanded,
$"Dry-Run Results ({_autoParseDryRun.Count})",
true
);
if (_autoParseDryRunExpanded && _autoParseDryRun.Count > 0)
{
using (new EditorGUI.IndentLevelScope())
{
int shown = 0;
foreach (AutoParseDryRunRecord rec in _autoParseDryRun)
{
string info =
$"Name: {rec.finalName} | Frames: {rec.count} | Numeric: {(rec.hasIndex ? "Yes" : "No")}";
if (rec.duplicateResolved)
{
info += " | Renamed to avoid duplicate";
}
EditorGUILayout.LabelField(rec.folderPath, info);
EditorGUILayout.LabelField("-> Asset Path:", rec.finalAssetPath);
shown++;
if (shown >= 200)
{
EditorGUILayout.LabelField($"Showing first {shown} results...");
break;
}
}
}
}
EditorGUILayout.EndScrollView();
_ = _serializedObject.ApplyModifiedProperties();
}
private void DrawCheckSpritesButton()
{
if (GUILayout.Button("Check/Refresh Filtered Sprites"))
{
UpdateRegex();
FindAndFilterSprites();
Repaint();
}
}
private void DrawFilteredAnimationData()
{
int listSize = _animationDataProp.arraySize;
string[] searchTerms = string.IsNullOrWhiteSpace(_searchString)
? Array.Empty()
: _searchString.Split(WhiteSpaceSplitters, StringSplitOptions.RemoveEmptyEntries);
using PooledResource> matchingIndicesLease = Buffers.List.Get(
out List matchingIndices
);
{
for (int i = 0; i < listSize; ++i)
{
SerializedProperty elementProp = _animationDataProp.GetArrayElementAtIndex(i);
SerializedProperty nameProp = elementProp.FindPropertyRelative(
nameof(AnimationData.animationName)
);
string currentName =
nameProp != null ? nameProp.stringValue ?? string.Empty : string.Empty;
bool matchesSearch = true;
if (searchTerms.Length > 0)
{
for (int si = 0; si < searchTerms.Length; si++)
{
if (
currentName.IndexOf(
searchTerms[si],
StringComparison.OrdinalIgnoreCase
) < 0
)
{
matchesSearch = false;
break;
}
}
}
if (matchesSearch)
{
matchingIndices.Add(i);
}
}
int matchCount = matchingIndices.Count;
string foldoutLabel =
$"{_animationDataProp.displayName} (Showing {matchCount} / {listSize})";
_animationDataIsExpanded = EditorGUILayout.Foldout(
_animationDataIsExpanded,
foldoutLabel,
true
);
if (_animationDataIsExpanded)
{
using EditorGUI.IndentLevelScope indent = new();
if (matchCount > 0)
{
int totalPages = Mathf.CeilToInt((float)matchCount / AnimationDataPageSize);
_animationDataPageIndex = Mathf.Clamp(
_animationDataPageIndex,
0,
totalPages - 1
);
if (totalPages > 1)
{
DrawAnimationDataPagination(matchCount, totalPages);
}
int startIndex = _animationDataPageIndex * AnimationDataPageSize;
int endIndex = Mathf.Min(startIndex + AnimationDataPageSize, matchCount);
for (int i = startIndex; i < endIndex; i++)
{
DrawAnimationDataElement(matchingIndices[i]);
}
if (totalPages > 1)
{
DrawAnimationDataPagination(matchCount, totalPages);
}
}
else if (listSize > 0)
{
EditorGUILayout.HelpBox(
$"No animation data matched the search term '{_searchString}'.",
MessageType.Info
);
}
}
}
}
private void DrawAnimationDataPagination(int totalItems, int totalPages)
{
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
using (new EditorGUI.DisabledScope(_animationDataPageIndex <= 0))
{
if (GUILayout.Button("<<", GUILayout.Width(30)))
{
_animationDataPageIndex = 0;
}
if (GUILayout.Button("<", GUILayout.Width(30)))
{
_animationDataPageIndex--;
}
}
int startItem = _animationDataPageIndex * AnimationDataPageSize + 1;
int endItem = Mathf.Min(startItem + AnimationDataPageSize - 1, totalItems);
EditorGUILayout.LabelField(
$"Page {_animationDataPageIndex + 1}/{totalPages} ({startItem}-{endItem} of {totalItems})",
EditorStyles.centeredGreyMiniLabel,
GUILayout.Width(180)
);
using (new EditorGUI.DisabledScope(_animationDataPageIndex >= totalPages - 1))
{
if (GUILayout.Button(">", GUILayout.Width(30)))
{
_animationDataPageIndex++;
}
if (GUILayout.Button(">>", GUILayout.Width(30)))
{
_animationDataPageIndex = totalPages - 1;
}
}
GUILayout.FlexibleSpace();
EditorGUILayout.EndHorizontal();
}
private CachedElementProperties GetOrCreateCachedProperties(int index)
{
if (_cachedElementProperties.TryGetValue(index, out CachedElementProperties cached))
{
return cached;
}
SerializedProperty elementProp = _animationDataProp.GetArrayElementAtIndex(index);
int frameCount = index < animationData.Count ? animationData[index].frames.Count : 0;
CachedElementProperties newCached = new(elementProp, index, frameCount);
_cachedElementProperties[index] = newCached;
return newCached;
}
private void DrawAnimationDataElement(int index)
{
if (index < 0 || index >= animationData.Count)
{
return;
}
AnimationData data = animationData[index];
CachedElementProperties props = GetOrCreateCachedProperties(index);
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
EditorGUILayout.PropertyField(props.animationNameProp, AnimationNameContent);
EditorGUILayout.PropertyField(props.framesProp, FramesContent, true);
EditorGUILayout.PropertyField(props.loopProp, LoopContent);
EditorGUILayout.PropertyField(props.framerateModeProp, FramerateModeContent);
FramerateMode currentMode = (FramerateMode)props.framerateModeProp.enumValueIndex;
switch (currentMode)
{
case FramerateMode.Constant:
EditorGUILayout.PropertyField(props.fpsProp, FpsContent);
break;
case FramerateMode.Curve:
DrawCurveFieldWithPresets(data, props, index);
break;
default:
EditorGUILayout.PropertyField(props.fpsProp, FpsContent);
break;
}
EditorGUILayout.PropertyField(props.cycleOffsetProp, CycleOffsetContent);
DrawPreviewPanel(data, index);
EditorGUILayout.EndVertical();
EditorGUILayout.Space(2);
}
private void DrawCurveFieldWithPresets(
AnimationData data,
CachedElementProperties props,
int index
)
{
EditorGUILayout.BeginHorizontal();
SerializedProperty curveProp = props.curveProp;
EditorGUILayout.PropertyField(curveProp, FpsCurveContent, GUILayout.MinWidth(200));
EditorGUILayout.BeginVertical(GUILayout.Width(80));
if (GUILayout.Button(FlatButtonContent))
{
float fps =
data.framesPerSecond > 0
? data.framesPerSecond
: AnimationData.DefaultFramesPerSecond;
data.framesPerSecondCurve = AnimationCurve.Constant(0, 1, fps);
curveProp.animationCurveValue = data.framesPerSecondCurve;
}
if (GUILayout.Button(EaseInButtonContent))
{
data.framesPerSecondCurve = AnimationCurve.EaseInOut(0, 6, 1, 18);
curveProp.animationCurveValue = data.framesPerSecondCurve;
}
if (GUILayout.Button(EaseOutButtonContent))
{
data.framesPerSecondCurve = AnimationCurve.EaseInOut(0, 18, 1, 6);
curveProp.animationCurveValue = data.framesPerSecondCurve;
}
if (GUILayout.Button(SyncButtonContent))
{
data.framesPerSecondCurve = AnimationCurve.Constant(0, 1, data.framesPerSecond);
curveProp.animationCurveValue = data.framesPerSecondCurve;
}
EditorGUILayout.EndVertical();
EditorGUILayout.EndHorizontal();
DrawFrameTimingGraph(data);
}
private void DrawFrameTimingGraph(AnimationData data)
{
if (data.frames.Count < 2)
{
return;
}
EditorGUILayout.Space(4);
EditorGUILayout.LabelField("Frame Timing Preview:", EditorStyles.boldLabel);
using PooledResource> _ = Buffers.List.Get(
out List timings
);
float totalDuration = 0f;
for (int i = 0; i < data.frames.Count; i++)
{
float normalizedPosition =
data.frames.Count > 1 ? (float)i / (data.frames.Count - 1) : 0f;
float fps = data.framesPerSecondCurve.Evaluate(normalizedPosition);
if (fps <= 0)
{
fps =
data.framesPerSecond > 0
? data.framesPerSecond
: AnimationData.DefaultFramesPerSecond;
}
float frameDuration = 1000f / fps;
totalDuration += frameDuration;
timings.Add($"F{i + 1}: {frameDuration:F0}ms ({fps:F1} fps)");
}
string timingText = string.Join(" | ", timings);
EditorGUILayout.HelpBox(
$"Total: {totalDuration:F0}ms ({totalDuration / 1000f:F2}s)\n{timingText}",
MessageType.Info
);
}
private void DrawPreviewPanel(AnimationData data, int animationIndex)
{
bool isThisAnimationPreviewing = _previewAnimationIndex == animationIndex;
EditorGUILayout.BeginHorizontal();
bool wantsPreview = GUILayout.Toggle(
data.showPreview,
PreviewToggleContent,
"Button",
GUILayout.Width(80)
);
if (wantsPreview != data.showPreview)
{
data.showPreview = wantsPreview;
if (!wantsPreview && isThisAnimationPreviewing)
{
StopPreview();
}
else if (wantsPreview)
{
// Pre-load all preview textures only when preview is first enabled
// to avoid redundant texture loading on every frame (performance optimization)
foreach (Sprite spriteFrame in data.frames)
{
_ = GetPreviewTexture(spriteFrame);
}
}
}
EditorGUILayout.EndHorizontal();
if (!data.showPreview || data.frames.Count == 0)
{
return;
}
EditorGUILayout.Space(4);
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
int displayFrameIndex = isThisAnimationPreviewing ? _previewFrameIndex : 0;
displayFrameIndex = Mathf.Clamp(displayFrameIndex, 0, data.frames.Count - 1);
Sprite currentSprite = data.frames[displayFrameIndex];
if (currentSprite != null)
{
Texture2D preview = GetPreviewTexture(currentSprite);
if (preview != null)
{
float aspectRatio = (float)preview.width / preview.height;
float previewHeight = 128f;
float previewWidth = previewHeight * aspectRatio;
Rect previewRect = GUILayoutUtility.GetRect(
previewWidth,
previewHeight,
GUILayout.MaxWidth(256),
GUILayout.MaxHeight(128)
);
GUI.DrawTexture(previewRect, preview, ScaleMode.ScaleToFit);
}
}
float currentFps = GetCurrentFps(data, displayFrameIndex);
EditorGUILayout.LabelField(
$"Frame: {displayFrameIndex + 1}/{data.frames.Count} | FPS: {currentFps:F1}",
EditorStyles.centeredGreyMiniLabel
);
DrawTransportControls(data, animationIndex, isThisAnimationPreviewing);
EditorGUI.BeginChangeCheck();
float scrubberValue =
data.frames.Count > 1 ? (float)displayFrameIndex / (data.frames.Count - 1) : 0f;
float newScrubberValue = EditorGUILayout.Slider(scrubberValue, 0f, 1f);
if (EditorGUI.EndChangeCheck())
{
int newFrame = Mathf.RoundToInt(newScrubberValue * (data.frames.Count - 1));
SetPreviewFrame(animationIndex, newFrame);
}
EditorGUILayout.EndVertical();
}
private void DrawTransportControls(AnimationData data, int animationIndex, bool isActive)
{
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
if (GUILayout.Button("|<", GUILayout.Width(30)))
{
SetPreviewFrame(animationIndex, 0);
}
if (GUILayout.Button("<", GUILayout.Width(30)))
{
int prev = isActive ? _previewFrameIndex - 1 : -1;
if (prev < 0)
{
prev = data.loop ? data.frames.Count - 1 : 0;
}
SetPreviewFrame(animationIndex, prev);
}
bool isPlaying = isActive && _isPreviewPlaying;
if (GUILayout.Button(isPlaying ? "||" : ">", GUILayout.Width(40)))
{
if (isPlaying)
{
PausePreview();
}
else
{
StartPreview(animationIndex);
}
}
if (GUILayout.Button(">", GUILayout.Width(30)))
{
int next = isActive ? _previewFrameIndex + 1 : 1;
if (next >= data.frames.Count)
{
next = data.loop ? 0 : data.frames.Count - 1;
}
SetPreviewFrame(animationIndex, next);
}
if (GUILayout.Button(">|", GUILayout.Width(30)))
{
SetPreviewFrame(animationIndex, data.frames.Count - 1);
}
GUILayout.FlexibleSpace();
EditorGUILayout.EndHorizontal();
}
private void StartPreview(int animationIndex)
{
if (_previewAnimationIndex >= 0 && _previewAnimationIndex < animationData.Count)
{
animationData[_previewAnimationIndex].showPreview = false;
}
_previewAnimationIndex = animationIndex;
_previewFrameIndex = 0;
_isPreviewPlaying = true;
_lastPreviewTick = _previewTimer.Elapsed;
if (animationIndex >= 0 && animationIndex < animationData.Count)
{
animationData[animationIndex].showPreview = true;
}
Repaint();
}
private void StopPreview()
{
_isPreviewPlaying = false;
_previewAnimationIndex = -1;
_previewFrameIndex = 0;
Repaint();
}
private void PausePreview()
{
_isPreviewPlaying = false;
Repaint();
}
private void SetPreviewFrame(int animationIndex, int frameIndex)
{
if (animationIndex < 0 || animationIndex >= animationData.Count)
{
return;
}
AnimationData data = animationData[animationIndex];
if (data.frames.Count == 0)
{
return;
}
_previewAnimationIndex = animationIndex;
_previewFrameIndex = Mathf.Clamp(frameIndex, 0, data.frames.Count - 1);
_isPreviewPlaying = false;
_lastPreviewTick = _previewTimer.Elapsed;
if (!data.showPreview)
{
data.showPreview = true;
}
Repaint();
}
private Texture2D GetPreviewTexture(Sprite sprite)
{
if (sprite == null)
{
return null;
}
if (_previewTextureCache.TryGetValue(sprite, out Texture2D cached))
{
return cached;
}
Texture2D preview = AssetPreview.GetAssetPreview(sprite);
if (preview != null)
{
_previewTextureCache[sprite] = preview;
}
return preview;
}
private float GetCurrentFps(AnimationData data, int frameIndex)
{
if (data.framerateMode == FramerateMode.Constant)
{
return data.framesPerSecond > 0
? data.framesPerSecond
: AnimationData.DefaultFramesPerSecond;
}
float normalizedPosition =
data.frames.Count > 1 ? (float)frameIndex / (data.frames.Count - 1) : 0f;
float fps = data.framesPerSecondCurve.Evaluate(normalizedPosition);
return fps > 0 ? fps : AnimationData.DefaultFramesPerSecond;
}
private void DrawActionButtons()
{
DrawCheckSpritesButton();
EditorGUILayout.Space();
EditorGUILayout.LabelField("Actions", EditorStyles.boldLabel);
using (new EditorGUI.DisabledScope(_filteredSprites.Count == 0))
{
if (
GUILayout.Button(
$"Populate First Slot with {_filteredSprites.Count} Matched Sprites"
)
)
{
if (animationData.Count == 0)
{
this.LogWarn($"Add at least one Animation Data entry first.");
}
else if (animationData[0].frames.Count > 0)
{
if (
!Utils.EditorUi.Confirm(
"Confirm Overwrite",
"This will replace the frames currently in the first animation slot. Are you sure?",
"Replace",
"Cancel",
defaultWhenSuppressed: true
)
)
{
return;
}
}
if (animationData.Count > 0)
{
animationData[0].frames = new List(_filteredSprites);
animationData[0].animationName = "All_Matched_Sprites";
animationData[0].isCreatedFromAutoParse = false;
_serializedObject.Update();
Repaint();
this.Log($"Populated first slot with {_filteredSprites.Count} sprites.");
}
}
if (GUILayout.Button("Auto-Parse Matched Sprites into Animations"))
{
if (
Utils.EditorUi.Confirm(
"Confirm Auto-Parse",
"This will replace the current animation list with animations generated from matched sprites based on their names (e.g., 'Player_Run_0', 'Player_Run_1'). Are you sure?",
"Parse",
"Cancel",
defaultWhenSuppressed: true
)
)
{
AutoParseSprites();
_serializedObject.Update();
Repaint();
}
}
}
if (GUILayout.Button("Create new Animation Data"))
{
animationData.Add(new AnimationData());
_serializedObject.Update();
Repaint();
}
if (_filteredSprites.Count == 0)
{
EditorGUILayout.HelpBox(
"Cannot perform sprite actions: No sprites matched the filter criteria or sources are empty.",
MessageType.Info
);
}
bool canBulkName = animationData is { Count: > 0 } && !string.IsNullOrWhiteSpace(text);
if (canBulkName)
{
bool anyFrames = false;
for (int i = 0; i < animationData.Count; i++)
{
List fr = animationData[i]?.frames;
if (fr is { Count: > 0 })
{
anyFrames = true;
break;
}
}
canBulkName = anyFrames;
}
using (new EditorGUI.DisabledScope(!canBulkName))
{
EditorGUILayout.Space();
EditorGUILayout.LabelField("Bulk Naming Operations", EditorStyles.boldLabel);
if (GUILayout.Button($"Append '{text}' To All Animation Names"))
{
bool changed = false;
foreach (AnimationData data in animationData)
{
if (
!string.IsNullOrWhiteSpace(data.animationName)
&& !data.animationName.EndsWith($"_{text}")
)
{
data.animationName += $"_{text}";
changed = true;
}
}
if (changed)
{
this.Log($"Appended '{text}' to animation names.");
_serializedObject.Update();
Repaint();
}
else
{
this.LogWarn(
$"No animation names modified. Either none exist or they already end with '_{text}'."
);
}
}
if (GUILayout.Button($"Remove '{text}' From End of Names"))
{
bool changed = false;
string suffix = $"_{text}";
foreach (AnimationData data in animationData)
{
if (
!string.IsNullOrWhiteSpace(data.animationName)
&& data.animationName.EndsWith(suffix)
)
{
data.animationName = data.animationName.Remove(
data.animationName.Length - suffix.Length
);
changed = true;
}
else if (
!string.IsNullOrWhiteSpace(data.animationName)
&& data.animationName.EndsWith(text)
)
{
data.animationName = data.animationName.Remove(
data.animationName.Length - text.Length
);
changed = true;
}
}
if (changed)
{
this.Log($"Removed '{text}' suffix from animation names.");
_serializedObject.Update();
Repaint();
}
else
{
this.LogWarn(
$"No animation names modified. Either none exist or they do not end with '{text}' or '_{text}'."
);
}
}
}
if (!canBulkName && animationData is { Count: > 0 })
{
bool anyFrames = false;
for (int i = 0; i < animationData.Count; i++)
{
List fr = animationData[i]?.frames;
if (fr is { Count: > 0 })
{
anyFrames = true;
break;
}
}
if (anyFrames)
{
EditorGUILayout.HelpBox(
"Enter text in the 'Text' field above to enable bulk naming operations.",
MessageType.Info
);
}
}
EditorGUILayout.Space();
using (new EditorGUI.DisabledScope(animationData is not { Count: > 0 }))
{
if (GUILayout.Button("Create Animations"))
{
CreateAnimations();
}
}
if (animationData is not { Count: > 0 })
{
EditorGUILayout.HelpBox(
"Add Animation Data entries before creating.",
MessageType.Warning
);
}
DrawConfigurationPersistenceSection();
}
private void DrawConfigurationPersistenceSection()
{
EditorGUILayout.Space();
_configSectionExpanded = EditorGUILayout.Foldout(
_configSectionExpanded,
"Configuration Persistence",
true
);
if (!_configSectionExpanded)
{
return;
}
using (new EditorGUI.IndentLevelScope())
{
bool hasValidSources =
animationSources != null
&& animationSources.Exists(s =>
s != null && AssetDatabase.IsValidFolder(AssetDatabase.GetAssetPath(s))
);
using (new EditorGUI.DisabledScope(!hasValidSources))
{
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Save Config to First Source"))
{
Object firstSource = animationSources.Find(s =>
s != null && AssetDatabase.IsValidFolder(AssetDatabase.GetAssetPath(s))
);
if (firstSource != null)
{
string path = AssetDatabase.GetAssetPath(firstSource);
if (
Utils.EditorUi.Confirm(
"Confirm Save",
$"Save animation creator configuration to '{path}'?",
"Save",
"Cancel",
defaultWhenSuppressed: true
)
)
{
SaveConfig(path);
}
}
}
if (GUILayout.Button("Save to All Sources"))
{
if (
Utils.EditorUi.Confirm(
"Confirm Save All",
"Save animation creator configuration to all source folders?",
"Save All",
"Cancel",
defaultWhenSuppressed: true
)
)
{
SaveAllConfigs();
}
}
EditorGUILayout.EndHorizontal();
}
List foldersWithConfigs = GetFoldersWithConfigs();
bool hasConfigs = foldersWithConfigs.Count > 0;
using (new EditorGUI.DisabledScope(!hasConfigs))
{
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Load Config from First Source"))
{
if (foldersWithConfigs.Count > 0)
{
if (
animationData.Count > 0
&& !Utils.EditorUi.Confirm(
"Confirm Load",
"Loading configuration will replace current animation data. Continue?",
"Load",
"Cancel",
defaultWhenSuppressed: true
)
)
{
return;
}
LoadConfig(foldersWithConfigs[0]);
}
}
if (GUILayout.Button("Reset to Default"))
{
if (
Utils.EditorUi.Confirm(
"Confirm Reset",
"Reset all settings to defaults? This will clear current animation data.",
"Reset",
"Cancel",
defaultWhenSuppressed: true
)
)
{
ResetToDefault();
}
}
EditorGUILayout.EndHorizontal();
}
if (hasConfigs)
{
EditorGUILayout.Space(4);
EditorGUILayout.LabelField(
$"Found configs in {foldersWithConfigs.Count} folder(s):",
EditorStyles.miniLabel
);
using (new EditorGUI.IndentLevelScope())
{
foreach (string folder in foldersWithConfigs)
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField(folder, EditorStyles.miniLabel);
if (GUILayout.Button("Load", GUILayout.Width(50)))
{
if (
animationData.Count == 0
|| Utils.EditorUi.Confirm(
"Confirm Load",
$"Load configuration from '{folder}'? This will replace current animation data.",
"Load",
"Cancel",
defaultWhenSuppressed: true
)
)
{
LoadConfig(folder);
}
}
if (GUILayout.Button("Delete", GUILayout.Width(50)))
{
if (
Utils.EditorUi.Confirm(
"Confirm Delete",
$"Delete saved configuration from '{folder}'?",
"Delete",
"Cancel",
defaultWhenSuppressed: false
)
)
{
DeleteConfig(folder);
}
}
EditorGUILayout.EndHorizontal();
}
}
}
else if (!hasValidSources)
{
EditorGUILayout.HelpBox(
"Add valid folder sources to enable configuration persistence.",
MessageType.Info
);
}
else
{
EditorGUILayout.HelpBox(
"No saved configurations found in source folders.",
MessageType.Info
);
}
}
}
private void CreateAnimations()
{
if (animationData is not { Count: > 0 })
{
this.LogError($"No animation data to create.");
return;
}
string[] searchTerms = string.IsNullOrWhiteSpace(_searchString)
? Array.Empty()
: _searchString
.ToLowerInvariant()
.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
using PooledResource> dataToCreateLease =
Buffers.List.Get(out List dataToCreate);
if (searchTerms.Length == 0)
{
dataToCreate.AddRange(animationData);
}
else
{
foreach (AnimationData data in animationData)
{
string lowerName = (data.animationName ?? string.Empty).ToLowerInvariant();
bool allMatch = true;
for (int i = 0; i < searchTerms.Length; i++)
{
if (lowerName.IndexOf(searchTerms[i], StringComparison.Ordinal) < 0)
{
allMatch = false;
break;
}
}
if (allMatch)
{
dataToCreate.Add(data);
}
}
this.Log(
$"Creating animations based on current search filter '{_searchString}'. Only {dataToCreate.Count} out of {animationData.Count} items will be processed."
);
}
if (dataToCreate.Count == 0)
{
this.LogError(
$"No animation data matches the current search filter '{_searchString}'. Nothing to create."
);
return;
}
int totalAnimations = dataToCreate.Count;
int currentAnimationIndex = 0;
bool errorOccurred = false;
try
{
using (AssetDatabaseBatchHelper.BeginBatch(refreshOnDispose: false))
{
foreach (AnimationData data in dataToCreate)
{
currentAnimationIndex++;
string animationName = data.animationName;
if (string.IsNullOrWhiteSpace(animationName))
{
this.LogWarn(
$"Ignoring animation data entry (original index unknown due to filtering) without an animation name."
);
continue;
}
Utils.EditorUi.ShowProgress(
"Creating Animations",
$"Processing '{animationName}' ({currentAnimationIndex}/{totalAnimations})",
(float)currentAnimationIndex / totalAnimations
);
List frames = data.frames;
if (frames is not { Count: > 0 })
{
this.LogWarn(
$"Ignoring animation '{animationName}' because it has no frames."
);
continue;
}
using PooledResource> validFramesResource =
Buffers.List.Get(out List validFrames);
foreach (Sprite f in frames)
{
if (f != null)
{
validFrames.Add(f);
}
}
if (validFrames.Count == 0)
{
this.LogWarn(
$"Ignoring animation '{animationName}' because it only contains null frames."
);
continue;
}
validFrames.Sort(
(s1, s2) => EditorUtility.NaturalCompare(s1.name, s2.name)
);
AnimationClip animationClip = CreateAnimationClip(data, validFrames);
string firstFramePath = AssetDatabase.GetAssetPath(validFrames[0]);
string assetPath =
Path.GetDirectoryName(firstFramePath).SanitizePath() ?? "Assets";
if (!assetPath.EndsWith("/"))
{
assetPath += "/";
}
string finalPath = AssetDatabase.GenerateUniqueAssetPath(
$"{assetPath}{animationName}.anim"
);
AssetDatabase.CreateAsset(animationClip, finalPath);
this.Log($"Created animation at '{finalPath}'.");
}
}
}
catch (Exception e)
{
errorOccurred = true;
this.LogError($"An error occurred during animation creation", e);
}
finally
{
Utils.EditorUi.ClearProgress();
if (!errorOccurred)
{
this.Log($"Finished creating {totalAnimations} animations.");
}
else
{
this.LogError($"Animation creation finished with errors. Check console.");
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
}
private AnimationClip CreateAnimationClip(AnimationData data, List validFrames)
{
float baseFrameRate =
data.framesPerSecond > 0
? data.framesPerSecond
: AnimationData.DefaultFramesPerSecond;
AnimationClip clip = new() { frameRate = baseFrameRate };
// Use exact-size array for Unity API. SystemArrayPool returns arrays rounded up to
// the next power-of-2, and AnimationUtility.SetObjectReferenceCurve uses the array's
// Length property, causing broken animations from null trailing elements.
ObjectReferenceKeyframe[] keyframes = new ObjectReferenceKeyframe[validFrames.Count];
float currentTime = 0f;
for (int i = 0; i < validFrames.Count; i++)
{
keyframes[i].time = currentTime;
keyframes[i].value = validFrames[i];
if (i < validFrames.Count - 1)
{
float fps;
if (data.framerateMode == FramerateMode.Curve)
{
float normalizedPosition =
validFrames.Count > 1 ? (float)i / (validFrames.Count - 1) : 0f;
fps = data.framesPerSecondCurve.Evaluate(normalizedPosition);
if (fps <= 0)
{
fps = baseFrameRate;
}
}
else
{
fps = baseFrameRate;
}
currentTime += 1f / fps;
}
}
AnimationUtility.SetObjectReferenceCurve(
clip,
EditorCurveBinding.PPtrCurve("", typeof(SpriteRenderer), "m_Sprite"),
keyframes
);
AnimationClipSettings settings = AnimationUtility.GetAnimationClipSettings(clip);
settings.loopTime = data.loop;
settings.cycleOffset = Mathf.Clamp01(data.cycleOffset);
AnimationUtility.SetAnimationClipSettings(clip, settings);
return clip;
}
private void UpdateRegex()
{
if (_compiledRegex == null || _lastUsedRegex != spriteNameRegex)
{
try
{
_compiledRegex = new Regex(
spriteNameRegex,
RegexOptions.Compiled | RegexOptions.CultureInvariant
);
_lastUsedRegex = spriteNameRegex;
_errorMessage = "";
this.Log($"Regex updated to: {spriteNameRegex}");
}
catch (ArgumentException e)
{
_compiledRegex = null;
_lastUsedRegex = spriteNameRegex;
_errorMessage = $"Invalid Regex: {e.Message}";
this.LogError($"Invalid Regex '{spriteNameRegex}'", e);
}
}
}
private void UpdateGroupRegex()
{
_groupRegexErrorMessage = string.Empty;
_compiledGroupRegex = null;
if (!useCustomGroupRegex)
{
_lastGroupRegex = customGroupRegex;
return;
}
if (string.IsNullOrWhiteSpace(customGroupRegex))
{
_lastGroupRegex = customGroupRegex;
_groupRegexErrorMessage = "Custom Group Regex enabled but pattern is empty.";
return;
}
try
{
RegexOptions options = RegexOptions.Compiled | RegexOptions.CultureInvariant;
if (customGroupRegexIgnoreCase)
{
options |= RegexOptions.IgnoreCase;
}
_compiledGroupRegex = new Regex(customGroupRegex, options);
_lastGroupRegex = customGroupRegex;
}
catch (ArgumentException e)
{
_compiledGroupRegex = null;
_lastGroupRegex = customGroupRegex;
_groupRegexErrorMessage = $"Invalid Custom Group Regex: {e.Message}";
this.LogError($"Invalid Custom Group Regex '{customGroupRegex}'", e);
}
}
private void FindAndFilterSprites()
{
_filteredSprites.Clear();
_matchedSpriteCount = 0;
_unmatchedSpriteCount = 0;
if (animationSources is not { Count: > 0 } || _compiledRegex == null)
{
if (_compiledRegex == null && !string.IsNullOrWhiteSpace(spriteNameRegex))
{
this.LogWarn(
$"Cannot find sprites, regex pattern '{spriteNameRegex}' is invalid."
);
}
else if (animationSources is not { Count: > 0 })
{
this.LogWarn($"Cannot find sprites, no animation sources specified.");
}
return;
}
using PooledResource> searchPathsLease = Buffers.List.Get(
out List searchPaths
);
for (int i = 0; i < animationSources.Count; i++)
{
Object source = animationSources[i];
if (source == null)
{
continue;
}
string path = AssetDatabase.GetAssetPath(source);
if (!string.IsNullOrWhiteSpace(path) && AssetDatabase.IsValidFolder(path))
{
searchPaths.Add(path);
}
else if (source != null)
{
this.LogWarn($"Source '{source.name}' is not a valid folder. Skipping.");
}
}
if (searchPaths.Count == 0)
{
this.LogWarn($"No valid folders found in Animation Sources.");
return;
}
string[] assetGuids = AssetDatabase.FindAssets("t:sprite", searchPaths.ToArray());
int totalAssets = assetGuids.Length;
this.Log($"Found {totalAssets} total sprite assets in specified paths.");
try
{
Utils.EditorUi.ShowProgress(
"Finding and Filtering Sprites",
$"Scanning {assetGuids.Length} assets...",
0f
);
for (int i = 0; i < totalAssets; i++)
{
string guid = assetGuids[i];
string path = AssetDatabase.GUIDToAssetPath(guid);
if (i % 20 == 0 || i == totalAssets - 1)
{
float progress = (i + 1) / (float)totalAssets;
Utils.EditorUi.ShowProgress(
"Finding and Filtering Sprites",
$"Checking: {Path.GetFileName(path)} ({i + 1}/{assetGuids.Length})",
progress
);
}
Sprite sprite = AssetDatabase.LoadAssetAtPath(path);
if (sprite != null)
{
if (_compiledRegex.IsMatch(sprite.name))
{
_filteredSprites.Add(sprite);
_matchedSpriteCount++;
}
else
{
_unmatchedSpriteCount++;
}
}
}
this.Log(
$"Sprite filtering complete. Matched: {_matchedSpriteCount}, Unmatched: {_unmatchedSpriteCount}."
);
}
finally
{
Utils.EditorUi.ClearProgress();
}
}
private void AutoParseSprites()
{
if (_filteredSprites.Count == 0)
{
this.LogWarn($"Cannot Auto-Parse, no matched sprites available.");
return;
}
try
{
Dictionary>> groups =
GroupFilteredSprites(withProgress: true);
if (groups.Count == 0)
{
this.LogWarn(
$"Auto-parsing did not result in any animation groups. Check naming."
);
return;
}
int removedCount = animationData.RemoveAll(data => data.isCreatedFromAutoParse);
this.Log($"Removed {removedCount} previously auto-parsed animation entries.");
int added = ApplyAutoParseGroups(groups);
this.Log($"Auto-parsed into {added} new animation groups.");
}
finally
{
Utils.EditorUi.ClearProgress();
_serializedObject.Update();
}
}
private static string SanitizeName(string inputName)
{
inputName = inputName.Replace(" ", "_");
inputName = Regex.Replace(inputName, @"[^a-zA-Z0-9_]", "");
if (string.IsNullOrWhiteSpace(inputName))
{
return "Default_Animation";
}
return inputName.Trim('_');
}
private static string StripDensitySuffix(string name)
{
return Regex.Replace(name, @"@\d+(?:\.\d+)?x$", string.Empty);
}
private static bool TryExtractBaseAndIndex(string name, out string baseName, out int index)
{
baseName = null;
index = -1;
Match m = s_ParenIndexRegex.Match(name);
if (m.Success)
{
baseName = m.Groups["base"].Value;
_ = int.TryParse(m.Groups["index"].Value, out index);
baseName = baseName.TrimEnd('_', '-', '.', ' ');
return true;
}
m = s_SeparatorIndexRegex.Match(name);
if (m.Success)
{
baseName = m.Groups["base"].Value;
_ = int.TryParse(m.Groups["index"].Value, out index);
baseName = baseName.TrimEnd('_', '-', '.', ' ');
return true;
}
m = s_TrailingIndexRegex.Match(name);
if (m.Success && m.Groups["base"].Length > 0)
{
baseName = m.Groups["base"].Value;
_ = int.TryParse(m.Groups["index"].Value, out index);
baseName = baseName.TrimEnd('_', '-', '.', ' ');
return true;
}
return false;
}
private int ComputeSourcesHash()
{
if (animationSources == null || animationSources.Count == 0)
{
return 0;
}
return Objects.EnumerableHashCode(EnumerateSourceHashes());
IEnumerable<(int id, string path)> EnumerateSourceHashes()
{
for (int i = 0; i < animationSources.Count; i++)
{
Object src = animationSources[i];
int id = src != null ? src.GetInstanceID() : 0;
string path = src != null ? AssetDatabase.GetAssetPath(src) : string.Empty;
yield return (id, path);
}
}
}
private Dictionary<
string,
Dictionary>
> GroupFilteredSprites(bool withProgress)
{
Dictionary<
string,
Dictionary>
> spritesByBaseAndAssetPath = new(StringComparer.Ordinal);
int total = _filteredSprites.Count;
int processed = 0;
foreach (Sprite sprite in _filteredSprites)
{
processed++;
if (withProgress && (processed % 10 == 0 || processed == total))
{
Utils.EditorUi.ShowProgress(
"Auto-Parsing Sprites",
$"Processing: {sprite.name} ({processed}/{total})",
(float)processed / total
);
}
string assetPath = AssetDatabase.GetAssetPath(sprite);
string directoryPath =
Path.GetDirectoryName(assetPath).SanitizePath() ?? string.Empty;
string frameName = StripDensitySuffix(sprite.name);
string baseName;
int frameIndex;
if (useCustomGroupRegex && _compiledGroupRegex != null)
{
Match m = _compiledGroupRegex.Match(frameName);
if (m.Success)
{
Group baseGroup = m.Groups["base"];
Group indexGroup = m.Groups["index"];
baseName = baseGroup.Success ? baseGroup.Value : frameName;
frameIndex =
indexGroup.Success && int.TryParse(indexGroup.Value, out int idx)
? idx
: -1;
}
else if (!TryExtractBaseAndIndex(frameName, out baseName, out frameIndex))
{
baseName = frameName;
frameIndex = -1;
}
}
else if (!TryExtractBaseAndIndex(frameName, out baseName, out frameIndex))
{
baseName = frameName;
frameIndex = -1;
}
if (string.IsNullOrWhiteSpace(baseName))
{
this.LogWarn(
$"Could not extract valid base name for '{frameName}' at '{assetPath}'. Skipping."
);
continue;
}
if (
!spritesByBaseAndAssetPath.TryGetValue(
directoryPath,
out Dictionary> byBase
)
)
{
byBase = new Dictionary>(
groupingCaseInsensitive
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal
);
spritesByBaseAndAssetPath.Add(directoryPath, byBase);
}
List<(int index, Sprite sprite)> list = byBase.GetOrAdd(baseName);
list.Add((frameIndex, sprite));
}
return spritesByBaseAndAssetPath;
}
private int ApplyAutoParseGroups(
Dictionary>> groups
)
{
int addedCount = 0;
using PooledResource> usedNamesLease = SetBuffers
.GetHashSetPool(StringComparer.OrdinalIgnoreCase)
.Get(out HashSet usedNames);
foreach (AnimationData data in animationData)
{
if (!data.isCreatedFromAutoParse && !string.IsNullOrWhiteSpace(data.animationName))
{
usedNames.Add(data.animationName);
}
}
foreach (
KeyValuePair<
string,
Dictionary>
> kvpAssetPath in groups
)
{
string folderName = new DirectoryInfo(kvpAssetPath.Key).Name;
foreach (
(string baseKey, List<(int index, Sprite sprite)> entries) in kvpAssetPath.Value
)
{
if (entries.Count == 0)
{
continue;
}
bool hasAnyIndex = entries.Exists(e => e.index >= 0);
if (strictNumericOrdering)
{
if (hasAnyIndex)
{
entries.Sort((a, b) => a.index.CompareTo(b.index));
}
}
else
{
if (hasAnyIndex)
{
entries.Sort((a, b) => a.index.CompareTo(b.index));
}
else
{
entries.Sort(
(a, b) => EditorUtility.NaturalCompare(a.sprite.name, b.sprite.name)
);
}
}
List framesForAnim = new(entries.Count);
foreach ((int index, Sprite sprite) e in entries)
{
framesForAnim.Add(e.sprite);
}
string finalAnimName = ComposeFinalName(
baseKey,
kvpAssetPath.Key,
usedNames,
out bool _
);
usedNames.Add(finalAnimName);
animationData.Add(
new AnimationData
{
frames = framesForAnim,
framesPerSecond = AnimationData.DefaultFramesPerSecond,
animationName = finalAnimName,
isCreatedFromAutoParse = true,
loop = false,
framerateMode = FramerateMode.Constant,
framesPerSecondCurve = AnimationCurve.Constant(
0f,
1f,
AnimationData.DefaultFramesPerSecond
),
cycleOffset = 0f,
}
);
addedCount++;
}
}
return addedCount;
}
private static string EnsureUniqueName(string baseName, ISet used)
{
if (!used.Contains(baseName))
{
return baseName;
}
int counter = 2;
string candidate;
do
{
candidate = $"{baseName}_{counter}";
counter++;
} while (used.Contains(candidate));
return candidate;
}
private string ComposeFinalName(
string baseKey,
string directoryPath,
ISet usedNames,
out bool duplicateResolved
)
{
string finalNameCore = baseKey;
string folderPrefix = GetFolderPrefix(directoryPath);
if (!string.IsNullOrEmpty(folderPrefix))
{
finalNameCore = folderPrefix + "_" + finalNameCore;
}
if (!string.IsNullOrEmpty(autoParseNamePrefix))
{
finalNameCore = autoParseNamePrefix + finalNameCore;
}
if (!string.IsNullOrEmpty(autoParseNameSuffix))
{
finalNameCore = finalNameCore + autoParseNameSuffix;
}
string finalAnimName = SanitizeName(finalNameCore);
duplicateResolved = false;
if (resolveDuplicateAnimationNames && usedNames != null)
{
if (usedNames.Contains(finalAnimName))
{
finalAnimName = EnsureUniqueName(finalAnimName, usedNames);
duplicateResolved = true;
}
}
return finalAnimName;
}
private string GetFolderPrefix(string directoryPath)
{
if (!includeFolderNameInAnimName && !includeFullFolderPathInAnimName)
{
return string.Empty;
}
if (string.IsNullOrWhiteSpace(directoryPath))
{
return string.Empty;
}
string sanitized = directoryPath.SanitizePath();
if (includeFullFolderPathInAnimName)
{
if (sanitized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
sanitized = sanitized.Substring("Assets/".Length);
}
sanitized = sanitized.Trim('/');
sanitized = sanitized.Replace('/', '_');
return SanitizeName(sanitized);
}
return new DirectoryInfo(directoryPath).Name;
}
private void GenerateAutoParseDryRun()
{
_autoParseDryRun.Clear();
Dictionary>> groups =
GroupFilteredSprites(withProgress: false);
using PooledResource> usedNamesLease = SetBuffers
.GetHashSetPool(StringComparer.OrdinalIgnoreCase)
.Get(out HashSet usedNames);
foreach (AnimationData data in animationData)
{
if (!data.isCreatedFromAutoParse && !string.IsNullOrWhiteSpace(data.animationName))
{
usedNames.Add(data.animationName);
}
}
foreach (
KeyValuePair<
string,
Dictionary>
> kvp in groups
)
{
string dir = kvp.Key;
foreach ((string baseKey, List<(int index, Sprite sprite)> entries) in kvp.Value)
{
bool hasAnyIndex = entries.Exists(e => e.index >= 0);
if (strictNumericOrdering)
{
if (hasAnyIndex)
{
entries.Sort((a, b) => a.index.CompareTo(b.index));
}
}
else
{
if (hasAnyIndex)
{
entries.Sort((a, b) => a.index.CompareTo(b.index));
}
else
{
entries.Sort(
(a, b) => EditorUtility.NaturalCompare(a.sprite.name, b.sprite.name)
);
}
}
string finalName = ComposeFinalName(
baseKey,
dir,
usedNames,
out bool wasResolved
);
usedNames.Add(finalName);
string folderPath = dir;
if (!folderPath.EndsWith("/"))
{
folderPath += "/";
}
string finalAssetPath = folderPath + finalName + ".anim";
_autoParseDryRun.Add(
new AutoParseDryRunRecord
{
folderPath = dir,
finalName = finalName,
finalAssetPath = finalAssetPath,
count = entries.Count,
hasIndex = hasAnyIndex,
duplicateResolved = wasResolved,
}
);
}
}
}
private void GenerateAutoParsePreview()
{
_autoParsePreview.Clear();
Dictionary>> groups =
GroupFilteredSprites(withProgress: false);
foreach (
KeyValuePair<
string,
Dictionary>
> dir in groups
)
{
string folderName = new DirectoryInfo(dir.Key).Name;
foreach ((string baseKey, List<(int index, Sprite sprite)> entries) in dir.Value)
{
AutoParsePreviewRecord rec = new()
{
folder = folderName,
baseName = baseKey,
count = entries.Count,
hasIndex = entries.Exists(e => e.index >= 0),
};
_autoParsePreview.Add(rec);
}
}
_autoParsePreview.Sort(
(a, b) => string.Compare(a.folder, b.folder, StringComparison.Ordinal)
);
}
internal static float GetCurrentFpsForTests(AnimationData data, int frameIndex)
{
if (data.framerateMode == FramerateMode.Constant)
{
return data.framesPerSecond > 0
? data.framesPerSecond
: AnimationData.DefaultFramesPerSecond;
}
float normalizedPosition =
data.frames.Count > 1 ? (float)frameIndex / (data.frames.Count - 1) : 0f;
float fps = data.framesPerSecondCurve.Evaluate(normalizedPosition);
return fps > 0 ? fps : AnimationData.DefaultFramesPerSecond;
}
internal static AnimationClip CreateAnimationClipForTests(
AnimationData data,
List validFrames
)
{
float baseFrameRate =
data.framesPerSecond > 0
? data.framesPerSecond
: AnimationData.DefaultFramesPerSecond;
AnimationClip clip = new() { frameRate = baseFrameRate };
ObjectReferenceKeyframe[] keyframes = new ObjectReferenceKeyframe[validFrames.Count];
float currentTime = 0f;
for (int i = 0; i < validFrames.Count; i++)
{
keyframes[i].time = currentTime;
keyframes[i].value = validFrames[i];
if (i < validFrames.Count - 1)
{
float fps;
if (data.framerateMode == FramerateMode.Curve)
{
float normalizedPosition =
validFrames.Count > 1 ? (float)i / (validFrames.Count - 1) : 0f;
fps = data.framesPerSecondCurve.Evaluate(normalizedPosition);
if (fps <= 0)
{
fps = baseFrameRate;
}
}
else
{
fps = baseFrameRate;
}
currentTime += 1f / fps;
}
}
AnimationUtility.SetObjectReferenceCurve(
clip,
EditorCurveBinding.PPtrCurve("", typeof(SpriteRenderer), "m_Sprite"),
keyframes
);
AnimationClipSettings settings = AnimationUtility.GetAnimationClipSettings(clip);
settings.loopTime = data.loop;
settings.cycleOffset = Mathf.Clamp01(data.cycleOffset);
AnimationUtility.SetAnimationClipSettings(clip, settings);
return clip;
}
internal static int CalculateScrubberFrame(float scrubberValue, int frameCount)
{
if (frameCount <= 0)
{
return 0;
}
float clampedValue = Mathf.Clamp01(scrubberValue);
// Use FloorToInt with +0.5f to ensure "round half up" behavior
// Mathf.RoundToInt uses banker's rounding (rounds 0.5 to nearest even),
// which is counterintuitive for UI scrubbers where users expect 0.5 -> 1
int frame = Mathf.FloorToInt(clampedValue * (frameCount - 1) + 0.5f);
return Mathf.Clamp(frame, 0, frameCount - 1);
}
internal static float CalculateCycleOffsetClamped(float inputOffset)
{
return Mathf.Clamp01(inputOffset);
}
///
/// Creates an AnimationCreatorConfig from the current window state.
///
/// A config object representing the current settings.
internal AnimationCreatorConfig CreateConfigFromCurrentState()
{
AnimationCreatorConfig config = new()
{
version = AnimationCreatorConfig.CurrentVersion,
spriteNameRegex = spriteNameRegex,
autoRefresh = autoRefresh,
groupingCaseInsensitive = groupingCaseInsensitive,
includeFolderNameInAnimName = includeFolderNameInAnimName,
includeFullFolderPathInAnimName = includeFullFolderPathInAnimName,
autoParseNamePrefix = autoParseNamePrefix,
autoParseNameSuffix = autoParseNameSuffix,
useCustomGroupRegex = useCustomGroupRegex,
customGroupRegex = customGroupRegex,
customGroupRegexIgnoreCase = customGroupRegexIgnoreCase,
resolveDuplicateAnimationNames = resolveDuplicateAnimationNames,
strictNumericOrdering = strictNumericOrdering,
animationEntries = new List(),
};
foreach (AnimationData data in animationData)
{
AnimationCreatorConfig.AnimationDataEntry entry = new()
{
animationName = data.animationName,
framesPerSecond = data.framesPerSecond,
isCreatedFromAutoParse = data.isCreatedFromAutoParse,
loop = data.loop,
framerateMode = data.framerateMode,
cycleOffset = data.cycleOffset,
framePaths = new List(),
curveKeyframes = AnimationCreatorConfig.SerializeCurve(
data.framesPerSecondCurve
),
curvePreWrapMode = data.framesPerSecondCurve?.preWrapMode ?? WrapMode.Clamp,
curvePostWrapMode = data.framesPerSecondCurve?.postWrapMode ?? WrapMode.Clamp,
};
foreach (Sprite sprite in data.frames)
{
if (sprite != null)
{
string path = AssetDatabase.GetAssetPath(sprite);
if (!string.IsNullOrEmpty(path))
{
entry.framePaths.Add(path);
}
}
}
config.animationEntries.Add(entry);
}
return config;
}
///
/// Applies an AnimationCreatorConfig to the current window state.
///
/// The config to apply.
internal void ApplyConfigToCurrentState(AnimationCreatorConfig config)
{
if (config == null)
{
return;
}
AnimationCreatorConfig.MigrateConfig(config);
spriteNameRegex = config.spriteNameRegex;
autoRefresh = config.autoRefresh;
groupingCaseInsensitive = config.groupingCaseInsensitive;
includeFolderNameInAnimName = config.includeFolderNameInAnimName;
includeFullFolderPathInAnimName = config.includeFullFolderPathInAnimName;
autoParseNamePrefix = config.autoParseNamePrefix;
autoParseNameSuffix = config.autoParseNameSuffix;
useCustomGroupRegex = config.useCustomGroupRegex;
customGroupRegex = config.customGroupRegex;
customGroupRegexIgnoreCase = config.customGroupRegexIgnoreCase;
resolveDuplicateAnimationNames = config.resolveDuplicateAnimationNames;
strictNumericOrdering = config.strictNumericOrdering;
animationData.Clear();
foreach (AnimationCreatorConfig.AnimationDataEntry entry in config.animationEntries)
{
AnimationData data = new()
{
animationName = entry.animationName,
framesPerSecond = entry.framesPerSecond,
isCreatedFromAutoParse = entry.isCreatedFromAutoParse,
loop = entry.loop,
framerateMode = entry.framerateMode,
cycleOffset = entry.cycleOffset,
framesPerSecondCurve = AnimationCreatorConfig.DeserializeCurve(
entry.curveKeyframes,
entry.curvePreWrapMode,
entry.curvePostWrapMode
),
frames = new List(),
};
foreach (string path in entry.framePaths)
{
if (string.IsNullOrEmpty(path))
{
continue;
}
Sprite sprite = AssetDatabase.LoadAssetAtPath(path);
if (sprite != null)
{
data.frames.Add(sprite);
}
else
{
this.LogWarn(
$"Could not load sprite at path '{path}' for animation '{entry.animationName}'."
);
}
}
animationData.Add(data);
}
UpdateRegex();
UpdateGroupRegex();
FindAndFilterSprites();
_serializedObject.Update();
Repaint();
}
///
/// Saves the current configuration to a JSON file in the specified folder.
///
/// The folder path to save the config to.
/// True if the config was saved successfully, false otherwise.
internal bool SaveConfig(string folderPath)
{
if (string.IsNullOrEmpty(folderPath))
{
return false;
}
try
{
string configPath = AnimationCreatorConfig.GetConfigPath(folderPath);
string fullConfigPath = Path.GetFullPath(configPath);
AnimationCreatorConfig config = CreateConfigFromCurrentState();
string json = Serializer.JsonStringify(config, pretty: true);
File.WriteAllText(fullConfigPath, json, Encoding.UTF8);
_loadedConfigs[folderPath] = config;
this.Log($"Saved animation creator config to '{configPath}'.");
AssetDatabase.Refresh();
return true;
}
catch (Exception e)
{
this.LogError($"Failed to save config to '{folderPath}'", e);
return false;
}
}
///
/// Saves configs to all animation source folders.
///
/// The number of configs successfully saved.
internal int SaveAllConfigs()
{
int savedCount = 0;
foreach (Object source in animationSources)
{
if (source == null)
{
continue;
}
string path = AssetDatabase.GetAssetPath(source);
if (!string.IsNullOrWhiteSpace(path) && AssetDatabase.IsValidFolder(path))
{
if (SaveConfig(path))
{
savedCount++;
}
}
}
this.Log($"Saved {savedCount} animation creator configs.");
return savedCount;
}
///
/// Loads the configuration from a JSON file in the specified folder.
///
/// The folder path to load the config from.
/// True if the config was loaded successfully, false otherwise.
internal bool LoadConfig(string folderPath)
{
if (string.IsNullOrEmpty(folderPath))
{
return false;
}
try
{
string configPath = AnimationCreatorConfig.GetConfigPath(folderPath);
string fullConfigPath = Path.GetFullPath(configPath);
if (!File.Exists(fullConfigPath))
{
return false;
}
string json = File.ReadAllText(fullConfigPath, Encoding.UTF8);
AnimationCreatorConfig config = Serializer.JsonDeserialize(
json
);
if (config == null)
{
return false;
}
_loadedConfigs[folderPath] = config;
ApplyConfigToCurrentState(config);
this.Log($"Loaded animation creator config from '{configPath}'.");
return true;
}
catch (Exception e)
{
this.LogError($"Failed to load config from '{folderPath}'", e);
return false;
}
}
///
/// Attempts to auto-load configs from all animation source folders.
/// Loads the first found config.
///
/// True if any config was loaded, false otherwise.
internal bool TryAutoLoadConfigs()
{
foreach (Object source in animationSources)
{
if (source == null)
{
continue;
}
string path = AssetDatabase.GetAssetPath(source);
if (!string.IsNullOrWhiteSpace(path) && AssetDatabase.IsValidFolder(path))
{
string configPath = AnimationCreatorConfig.GetConfigPath(path);
string fullConfigPath = Path.GetFullPath(configPath);
if (File.Exists(fullConfigPath))
{
if (LoadConfig(path))
{
return true;
}
}
}
}
return false;
}
///
/// Checks if any animation source folder has a saved config.
///
/// True if any config file exists, false otherwise.
internal bool HasAnyConfig()
{
foreach (Object source in animationSources)
{
if (source == null)
{
continue;
}
string path = AssetDatabase.GetAssetPath(source);
if (!string.IsNullOrWhiteSpace(path) && AssetDatabase.IsValidFolder(path))
{
string configPath = AnimationCreatorConfig.GetConfigPath(path);
string fullConfigPath = Path.GetFullPath(configPath);
if (File.Exists(fullConfigPath))
{
return true;
}
}
}
return false;
}
///
/// Gets all folder paths that have saved configs.
///
/// List of folder paths with configs.
internal List GetFoldersWithConfigs()
{
List folders = new();
foreach (Object source in animationSources)
{
if (source == null)
{
continue;
}
string path = AssetDatabase.GetAssetPath(source);
if (!string.IsNullOrWhiteSpace(path) && AssetDatabase.IsValidFolder(path))
{
string configPath = AnimationCreatorConfig.GetConfigPath(path);
string fullConfigPath = Path.GetFullPath(configPath);
if (File.Exists(fullConfigPath))
{
folders.Add(path);
}
}
}
return folders;
}
///
/// Resets the window to default settings, discarding loaded config.
///
/// Optional folder path whose config to delete.
internal void ResetToDefault(string folderPath = null)
{
spriteNameRegex = ".*";
autoRefresh = true;
groupingCaseInsensitive = true;
includeFolderNameInAnimName = false;
includeFullFolderPathInAnimName = false;
autoParseNamePrefix = string.Empty;
autoParseNameSuffix = string.Empty;
useCustomGroupRegex = false;
customGroupRegex = string.Empty;
customGroupRegexIgnoreCase = true;
resolveDuplicateAnimationNames = true;
strictNumericOrdering = false;
animationData.Clear();
if (!string.IsNullOrEmpty(folderPath))
{
_loadedConfigs.Remove(folderPath);
}
else
{
_loadedConfigs.Clear();
}
UpdateRegex();
UpdateGroupRegex();
FindAndFilterSprites();
_serializedObject.Update();
Repaint();
this.Log($"Reset animation creator settings to defaults.");
}
///
/// Deletes a saved config file.
///
/// The folder path whose config to delete.
/// True if the config was deleted, false otherwise.
internal bool DeleteConfig(string folderPath)
{
if (string.IsNullOrEmpty(folderPath))
{
return false;
}
try
{
string configPath = AnimationCreatorConfig.GetConfigPath(folderPath);
string fullConfigPath = Path.GetFullPath(configPath);
if (!File.Exists(fullConfigPath))
{
return false;
}
File.Delete(fullConfigPath);
_loadedConfigs.Remove(folderPath);
this.Log($"Deleted animation creator config at '{configPath}'.");
AssetDatabase.Refresh();
return true;
}
catch (Exception e)
{
this.LogError($"Failed to delete config at '{folderPath}'", e);
return false;
}
}
}
#endif
}