// MIT License - Copyright (c) 2025 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
// ReSharper disable HeapView.CanAvoidClosure
namespace WallstopStudios.UnityHelpers.Editor.Sprites
{
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.Serialization;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
using WallstopStudios.UnityHelpers.Core.Extension;
using WallstopStudios.UnityHelpers.Core.Helper;
using Object = UnityEngine.Object;
///
/// Creates one or more AnimationClips from a single sprite sheet by selecting sprite ranges,
/// defining loop/cycle offset, and configuring per-animation constant or curve-based frame
/// rates. Includes live preview and per-definition controls.
///
///
///
/// Problems this solves: turning a sliced sprite sheet into multiple clips (e.g., Idle, Walk,
/// Attack) with minimal friction and previewing playback before saving.
///
///
/// How it works: load a Texture2D with multiple sprites (sliced), pick index ranges to form an
/// animation definition, and optionally use an for variable frame
/// rate. Preview playback with transport controls; save generated clips to assets.
///
///
/// Pros: rapid clip creation from a single sheet; visual selection and iteration.
/// Caveats: relies on existing sprite slicing; saving overwrites/creates .anim assets.
///
///
public sealed class SpriteSheetAnimationCreator : EditorWindow
{
private static bool SuppressUserPrompts { get; set; }
static SpriteSheetAnimationCreator()
{
try
{
if (Application.isBatchMode || IsInvokedByTestRunner())
{
SuppressUserPrompts = true;
}
}
catch { }
}
private static bool IsInvokedByTestRunner()
{
string[] args = Environment.GetCommandLineArgs();
for (int i = 0; i < args.Length; ++i)
{
string a = args[i];
if (
a.IndexOf("runTests", StringComparison.OrdinalIgnoreCase) >= 0
|| a.IndexOf("testResults", StringComparison.OrdinalIgnoreCase) >= 0
|| a.IndexOf("testPlatform", StringComparison.OrdinalIgnoreCase) >= 0
)
{
return true;
}
}
return false;
}
private const float ThumbnailSize = 64f;
private Texture2D _selectedSpriteSheet;
private readonly List _availableSprites = new();
private readonly List _animationDefinitions = new();
private ObjectField _spriteSheetField;
private Button _refreshSpritesButton;
private Button _loadSpritesButton;
private ScrollView _spriteThumbnailsScrollView;
private VisualElement _spriteThumbnailsContainer;
private ListView _animationDefinitionsListView;
private Button _addAnimationDefinitionButton;
private Button _generateAnimationsButton;
private VisualElement _previewContainer;
private Image _previewImage;
private Label _previewFrameLabel;
private Button _playPreviewButton;
private Button _stopPreviewButton;
private Button _prevFrameButton;
private Button _nextFrameButton;
private Slider _previewScrubber;
private bool _isDraggingToSelectSprites;
private int _spriteSelectionDragStartIndex = -1;
private int _spriteSelectionDragCurrentIndex = -1;
private StyleColor _selectedThumbnailBackgroundColor = new(
new Color(0.2f, 0.5f, 0.8f, 0.4f)
);
private readonly StyleColor _defaultThumbnailBackgroundColor = new(StyleKeyword.Null);
private bool _isPreviewing;
private int _currentPreviewAnimDefIndex = -1;
private int _currentPreviewSpriteIndex;
private AnimationDefinition _currentPreviewDefinition;
private readonly EditorApplication.CallbackFunction _editorUpdateCallback;
private readonly Stopwatch _timer = Stopwatch.StartNew();
private TimeSpan? _lastTick;
[MenuItem("Tools/Wallstop Studios/Unity Helpers/Sprite Sheet Animation Creator")]
public static void ShowWindow()
{
SpriteSheetAnimationCreator window = GetWindow();
window.titleContent = new GUIContent("Sprite Animation Creator");
window.minSize = new Vector2(600, 700);
}
[Serializable]
[DataContract]
public sealed class AnimationDefinition
{
public string Name = "New Animation";
public bool loop;
public float cycleOffset;
public int StartSpriteIndex;
public int EndSpriteIndex;
public float DefaultFrameRate = 12f;
public AnimationCurve FrameRateCurve = AnimationCurve.Constant(0, 1, 12f);
public List SpritesToAnimate = new();
public TextField nameField;
public IntegerField startIndexField;
public IntegerField endIndexField;
public FloatField defaultFrameRateField;
public CurveField frameRateCurveField;
public Label spriteCountLabel;
public Button previewButton;
public Button removeButton;
public Toggle loopingField;
public FloatField cycleOffsetField;
}
public SpriteSheetAnimationCreator()
{
_editorUpdateCallback = OnEditorUpdate;
}
public void CreateGUI()
{
VisualElement root = rootVisualElement;
root.style.paddingLeft = 10;
root.style.paddingRight = 10;
root.style.paddingTop = 10;
root.style.paddingBottom = 10;
VisualElement topSection = new()
{
style = { flexDirection = FlexDirection.Row, marginBottom = 10 },
};
_spriteSheetField = new ObjectField("Sprite Sheet")
{
objectType = typeof(Texture2D),
allowSceneObjects = false,
style =
{
flexGrow = 1,
flexShrink = 0,
minHeight = 20,
},
};
_spriteSheetField.RegisterValueChangedCallback(OnSpriteSheetSelected);
topSection.Add(_spriteSheetField);
_loadSpritesButton = new Button(() =>
{
string filePath = string.Empty;
if (_spriteSheetField.value != null)
{
filePath = AssetDatabase.GetAssetPath(_spriteSheetField.value);
}
if (string.IsNullOrWhiteSpace(filePath))
{
filePath = Application.dataPath;
}
string selectedPath = Utils.EditorUi.OpenFilePanel(
"Select Sprite Sheet",
filePath,
"png,jpg,gif,bmp,psd"
);
if (string.IsNullOrWhiteSpace(selectedPath))
{
return;
}
string relativePath = DirectoryHelper.AbsoluteToUnityRelativePath(selectedPath);
if (!string.IsNullOrWhiteSpace(relativePath))
{
Texture2D loadedTexture = AssetDatabase.LoadAssetAtPath(
relativePath
);
if (loadedTexture != null)
{
_spriteSheetField.value = loadedTexture;
}
}
})
{
text = "Load Sprites",
style = { marginLeft = 5, minHeight = 20 },
};
topSection.Add(_loadSpritesButton);
_refreshSpritesButton = new Button(LoadAndDisplaySprites)
{
text = "Refresh Sprites",
style = { marginLeft = 5, minHeight = 20 },
};
topSection.Add(_refreshSpritesButton);
root.Add(topSection);
Label thumbnailsLabel = new(
"Available Sprites (Drag to select range for new animation):"
)
{
style =
{
unityFontStyleAndWeight = FontStyle.Bold,
marginTop = 5,
marginBottom = 5,
},
};
root.Add(thumbnailsLabel);
_spriteThumbnailsScrollView = new ScrollView(ScrollViewMode.Horizontal)
{
style =
{
height = ThumbnailSize + 20 + 10,
minHeight = ThumbnailSize + 20 + 10,
borderTopWidth = 1,
borderBottomWidth = 1,
borderLeftWidth = 1,
borderRightWidth = 1,
borderBottomColor = Color.gray,
borderTopColor = Color.gray,
borderLeftColor = Color.gray,
borderRightColor = Color.gray,
paddingLeft = 5,
paddingRight = 5,
paddingTop = 5,
paddingBottom = 5,
marginBottom = 10,
},
};
_spriteThumbnailsContainer = new VisualElement
{
style = { flexDirection = FlexDirection.Row },
};
_spriteThumbnailsContainer.RegisterCallback(evt =>
{
if (
_isDraggingToSelectSprites
&& _spriteThumbnailsContainer.HasPointerCapture(evt.pointerId)
)
{
VisualElement currentElementOver = evt.target as VisualElement;
VisualElement thumbChild = currentElementOver;
while (thumbChild != null && thumbChild.parent != _spriteThumbnailsContainer)
{
thumbChild = thumbChild.parent;
}
if (
thumbChild is { userData: int hoveredIndex }
&& _spriteSelectionDragCurrentIndex != hoveredIndex
)
{
_spriteSelectionDragCurrentIndex = hoveredIndex;
UpdateSpriteSelectionHighlight();
}
}
});
_spriteThumbnailsContainer.RegisterCallback(
evt =>
{
if (
evt.button == 0
&& _isDraggingToSelectSprites
&& _spriteThumbnailsContainer.HasPointerCapture(evt.pointerId)
)
{
_spriteThumbnailsContainer.ReleasePointer(evt.pointerId);
_isDraggingToSelectSprites = false;
if (
_spriteSelectionDragStartIndex != -1
&& _spriteSelectionDragCurrentIndex != -1
)
{
int start = Mathf.Min(
_spriteSelectionDragStartIndex,
_spriteSelectionDragCurrentIndex
);
int end = Mathf.Max(
_spriteSelectionDragStartIndex,
_spriteSelectionDragCurrentIndex
);
if (start <= end)
{
CreateAnimationDefinitionFromSelection(start, end);
}
}
ClearSpriteSelectionHighlight();
_spriteSelectionDragStartIndex = -1;
_spriteSelectionDragCurrentIndex = -1;
}
},
TrickleDown.TrickleDown
);
_spriteThumbnailsScrollView.Add(_spriteThumbnailsContainer);
root.Add(_spriteThumbnailsScrollView);
Label animDefsLabel = new("Animation Definitions:")
{
style =
{
unityFontStyleAndWeight = FontStyle.Bold,
marginTop = 10,
marginBottom = 5,
},
};
root.Add(animDefsLabel);
_animationDefinitionsListView = new ListView(
_animationDefinitions,
130,
MakeAnimationDefinitionItem,
BindAnimationDefinitionItem
)
{
selectionType = SelectionType.None,
style = { flexGrow = 1, minHeight = 200 },
};
root.Add(_animationDefinitionsListView);
_addAnimationDefinitionButton = new Button(AddAnimationDefinition)
{
text = "Add Animation Definition",
style = { marginTop = 5 },
};
root.Add(_addAnimationDefinitionButton);
Label previewSectionLabel = new("Animation Preview:")
{
style =
{
unityFontStyleAndWeight = FontStyle.Bold,
marginTop = 15,
marginBottom = 5,
},
};
root.Add(previewSectionLabel);
_previewContainer = new VisualElement
{
style =
{
flexDirection = FlexDirection.Column,
alignItems = Align.Center,
borderTopWidth = 1,
borderBottomWidth = 1,
borderLeftWidth = 1,
borderRightWidth = 1,
borderBottomColor = Color.gray,
borderTopColor = Color.gray,
borderLeftColor = Color.gray,
borderRightColor = Color.gray,
paddingBottom = 10,
paddingTop = 10,
minHeight = 150,
},
};
_previewImage = new Image
{
scaleMode = ScaleMode.ScaleToFit,
style =
{
width = 128,
height = 128,
marginBottom = 10,
backgroundColor = new StyleColor(new Color(0.2f, 0.2f, 0.2f)),
},
};
_previewContainer.Add(_previewImage);
_previewFrameLabel = new Label("Frame: -/- | FPS: -")
{
style = { alignSelf = Align.Center, marginBottom = 5 },
};
_previewContainer.Add(_previewFrameLabel);
_previewScrubber = new Slider(0, 1)
{
style =
{
minWidth = 200,
marginBottom = 5,
visibility = Visibility.Hidden,
},
};
_previewScrubber.RegisterValueChangedCallback(evt =>
{
if (
_currentPreviewDefinition != null
&& 0 < _currentPreviewDefinition.SpritesToAnimate.Count
)
{
int frame = Mathf.FloorToInt(
evt.newValue * (_currentPreviewDefinition.SpritesToAnimate.Count - 1)
);
SetPreviewFrame(frame);
}
});
_previewContainer.Add(_previewScrubber);
VisualElement previewControls = new()
{
style = { flexDirection = FlexDirection.Row, justifyContent = Justify.Center },
};
_prevFrameButton = new Button(() => AdjustPreviewFrame(-1))
{
text = "◀",
style = { minWidth = 40 },
};
_playPreviewButton = new Button(PlayCurrentPreview)
{
text = "▶ Play",
style = { minWidth = 70 },
};
_stopPreviewButton = new Button(StopCurrentPreview)
{
text = "◼ Stop",
style = { minWidth = 70, display = DisplayStyle.None },
};
_nextFrameButton = new Button(() => AdjustPreviewFrame(1))
{
text = "▶",
style = { minWidth = 40 },
};
previewControls.Add(_prevFrameButton);
previewControls.Add(_playPreviewButton);
previewControls.Add(_stopPreviewButton);
previewControls.Add(_nextFrameButton);
_previewContainer.Add(previewControls);
root.Add(_previewContainer);
_generateAnimationsButton = new Button(GenerateAnimations)
{
text = "Generate Animation Files",
style = { marginTop = 15, height = 30 },
};
root.Add(_generateAnimationsButton);
if (_selectedSpriteSheet != null)
{
_spriteSheetField.SetValueWithoutNotify(_selectedSpriteSheet);
LoadAndDisplaySprites();
}
_animationDefinitionsListView.Rebuild();
}
private void OnEnable()
{
EditorApplication.update += _editorUpdateCallback;
string data = SessionState.GetString(GetType().FullName, "");
if (!string.IsNullOrEmpty(data))
{
JsonUtility.FromJsonOverwrite(data, this);
}
if (_selectedSpriteSheet != null)
{
EditorApplication.delayCall += () =>
{
if (_spriteSheetField != null)
{
_spriteSheetField.value = _selectedSpriteSheet;
}
LoadAndDisplaySprites();
_animationDefinitionsListView.Rebuild();
};
}
}
private void OnDisable()
{
EditorApplication.update -= _editorUpdateCallback;
StopCurrentPreview();
string data = JsonUtility.ToJson(this);
SessionState.SetString(GetType().FullName, data);
}
private void OnEditorUpdate()
{
if (
!_isPreviewing
|| _currentPreviewDefinition is not { SpritesToAnimate: { Count: > 0 } }
)
{
return;
}
_lastTick ??= _timer.Elapsed;
float targetFps = 0;
if (1 < _currentPreviewDefinition.SpritesToAnimate.Count)
{
_currentPreviewDefinition.FrameRateCurve.Evaluate(
_currentPreviewSpriteIndex
/ (_currentPreviewDefinition.SpritesToAnimate.Count - 1f)
);
}
if (targetFps <= 0)
{
targetFps = _currentPreviewDefinition.DefaultFrameRate;
}
if (targetFps <= 0)
{
targetFps = 1;
}
TimeSpan elapsed = _timer.Elapsed;
TimeSpan deltaTime = TimeSpan.FromMilliseconds(1000 / targetFps);
// Prevent time accumulation drift: if _lastTick has fallen significantly behind
// (e.g., editor was paused/unfocused), clamp it BEFORE checking the frame advance
// condition. This prevents rapid "catch-up" animation that makes the preview
// appear to run at too high FPS.
// Allow at most one frame of lag before resetting to current time.
if (elapsed - _lastTick.Value > deltaTime + deltaTime)
{
_lastTick = elapsed - deltaTime;
}
if (_lastTick + deltaTime > elapsed)
{
return;
}
_lastTick += deltaTime;
int nextFrame = _currentPreviewSpriteIndex.WrappedIncrement(
_currentPreviewDefinition.SpritesToAnimate.Count
);
SetPreviewFrame(nextFrame);
}
private void UpdateSpriteSelectionHighlight()
{
if (
!_isDraggingToSelectSprites
|| _spriteSelectionDragStartIndex == -1
|| _spriteSelectionDragCurrentIndex == -1
)
{
ClearSpriteSelectionHighlight();
return;
}
int minIdx = Mathf.Min(
_spriteSelectionDragStartIndex,
_spriteSelectionDragCurrentIndex
);
int maxIdx = Mathf.Max(
_spriteSelectionDragStartIndex,
_spriteSelectionDragCurrentIndex
);
for (int i = 0; i < _spriteThumbnailsContainer.childCount; i++)
{
VisualElement thumb = _spriteThumbnailsContainer.ElementAt(i);
if (thumb.userData is int thumbIndex)
{
if (thumbIndex >= minIdx && thumbIndex <= maxIdx)
{
thumb.style.backgroundColor = _selectedThumbnailBackgroundColor;
thumb.style.borderBottomColor =
_selectedThumbnailBackgroundColor.value * 1.5f;
thumb.style.borderTopColor = _selectedThumbnailBackgroundColor.value * 1.5f;
thumb.style.borderLeftColor =
_selectedThumbnailBackgroundColor.value * 1.5f;
thumb.style.borderRightColor =
_selectedThumbnailBackgroundColor.value * 1.5f;
}
else
{
thumb.style.backgroundColor = _defaultThumbnailBackgroundColor;
thumb.style.borderBottomColor = Color.clear;
thumb.style.borderTopColor = Color.clear;
thumb.style.borderLeftColor = Color.clear;
thumb.style.borderRightColor = Color.clear;
}
}
}
}
private void ClearSpriteSelectionHighlight()
{
for (int i = 0; i < _spriteThumbnailsContainer.childCount; i++)
{
VisualElement thumb = _spriteThumbnailsContainer.ElementAt(i);
thumb.style.backgroundColor = _defaultThumbnailBackgroundColor;
thumb.style.borderBottomColor = Color.clear;
thumb.style.borderTopColor = Color.clear;
thumb.style.borderLeftColor = Color.clear;
thumb.style.borderRightColor = Color.clear;
}
}
private void CreateAnimationDefinitionFromSelection(
int startSpriteIndex,
int endSpriteIndex
)
{
if (
startSpriteIndex < 0
|| endSpriteIndex < 0
|| startSpriteIndex >= _availableSprites.Count
|| endSpriteIndex >= _availableSprites.Count
)
{
this.LogWarn(
$"Invalid sprite indices for new animation definition from selection."
);
return;
}
AnimationDefinition newDefinition = new()
{
Name =
_selectedSpriteSheet != null
? $"{_selectedSpriteSheet.name}_Anim_{_animationDefinitions.Count}"
: $"New_Animation_{_animationDefinitions.Count}",
StartSpriteIndex = startSpriteIndex,
EndSpriteIndex = endSpriteIndex,
DefaultFrameRate = 12f,
};
newDefinition.FrameRateCurve = AnimationCurve.Constant(
0,
1,
newDefinition.DefaultFrameRate
);
_animationDefinitions.Add(newDefinition);
UpdateSpritesForDefinition(newDefinition);
_currentPreviewAnimDefIndex = _animationDefinitions.Count - 1;
StartOrUpdateCurrentPreview(newDefinition);
_animationDefinitionsListView.Rebuild();
if (_animationDefinitionsListView.itemsSource.Count > 0)
{
_animationDefinitionsListView.ScrollToItem(_animationDefinitions.Count - 1);
}
}
private void OnSpriteSheetSelected(ChangeEvent