// MIT License - Copyright (c) 2023 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Editor { #if UNITY_EDITOR using System; using System.Collections.Generic; using System.Reflection; using UnityEditor; using UnityEngine; using WallstopStudios.UnityHelpers.Editor.Core.Helper; using WallstopStudios.UnityHelpers.Utils; // https://gist.githubusercontent.com/yujen/5e1cd78e2a341260b38029de08a449da/raw/ac60c1002e0e14375de5b2b0a167af00df3f74b4/SeniaAnimationEventEditor.cs /// /// Interactive editor window for inspecting, creating, editing, validating, and reordering /// entries on an . /// /// /// /// Problems this solves: /// /// /// - Discoverability: Quickly lists all methods on s that are valid /// animation event handlers. In Explicit Mode, only methods decorated with /// [AnimationEvent] are shown, keeping choices intentional and type-safe. /// /// /// - Efficiency: Add, duplicate, re-order, and bulk edit events with frame snapping, optional /// precise time control, keyboard shortcuts, and sprite previews for quick visual context. /// /// /// - Safety: Validates method existence, parameter compatibility, and warns for mismatches. /// /// /// How it works: /// /// /// - Uses to scan types and find /// viable event handlers via . In /// Explicit Mode, only methods with [AnimationEvent] are included; it respects /// ignoreDerived to limit inherited exposure. /// /// /// - Presents a searchable clip list from the current Animator, then renders each event with /// method/type selection and parameter editors matching the selected signature. /// /// /// - Supports keyboard shortcuts: Delete (remove), Ctrl+D (duplicate), Up/Down (navigate). /// /// /// Usage scenarios: /// /// /// /// Authoring complex 2D/3D animation behaviors with strongly-typed methods. /// /// /// Retrofitting existing clips with validated events and consistent frame timing. /// /// /// Bulk reviewing events across clips to catch missing handlers. /// /// /// /// Pros: /// /// /// Fast discovery of valid methods and signatures. /// Explicit Mode reduces accidental misuse. /// Sprite previews aid visual alignment. /// /// /// Cons / Caveats: /// /// /// Only scans assemblies available to the editor domain. /// Methods must have void return and a supported single parameter or none. /// Changing frame rate affects timing; save to persist. /// /// /// (); /// ]]> /// public sealed class AnimationEventEditor : EditorWindow { private static IReadOnlyDictionary> _cachedTypesToMethods; private static IReadOnlyDictionary> TypesToMethods { get { if (_cachedTypesToMethods == null) { InitializeTypeCache(); } return _cachedTypesToMethods; } } private static void InitializeTypeCache() { Dictionary> typesToMethods = new(); IEnumerable types = WallstopStudios.UnityHelpers.Core.Helper.ReflectionHelpers.GetTypesDerivedFrom( includeAbstract: false ); foreach (Type type in types) { if (type == null) { continue; } List methods = AnimationEventHelpers.GetPossibleAnimatorEventsForType( type ); if (methods is { Count: > 0 }) { typesToMethods[type] = methods; } } using ( Buffers>>.List.Get( out List>> snapshot ) ) { foreach (KeyValuePair> kvp in typesToMethods) { snapshot.Add(kvp); } for (int i = 0; i < snapshot.Count; i++) { KeyValuePair> entry = snapshot[i]; if (entry.Value == null || entry.Value.Count <= 0) { _ = typesToMethods.Remove(entry.Key); } } } _cachedTypesToMethods = typesToMethods; } [MenuItem("Tools/Wallstop Studios/Unity Helpers/AnimationEvent Editor")] private static void AnimationEventEditorMenu() { GetWindow(typeof(AnimationEventEditor)); } private readonly AnimationEventEditorViewModel _viewModel = new(); private IReadOnlyDictionary> Lookup => _explicitMode ? AnimationEventHelpers.TypesToMethods : TypesToMethods; private int MaxFrameIndex => _viewModel.CurrentClip == null ? 0 : (int)Math.Round(_viewModel.FrameRate * _viewModel.CurrentClip.length); private Vector2 _scrollPosition; private Animator _sourceAnimator; private bool _explicitMode = true; private bool _controlFrameTime; private string _animationSearchString = string.Empty; // Cache for sprite previews private readonly Dictionary _spriteTextureCache = new(); private int _selectedFrameIndex = -1; // Keyboard shortcut state private int _focusedEventIndex = -1; private void OnGUI() { AnimationEventKeyboardShortcuts.Handle( Event.current, _viewModel, ref _focusedEventIndex, RecordUndo, Repaint ); Animator tmpAnimator = EditorGUILayout.ObjectField( "Animator Object", _sourceAnimator, typeof(Animator), true ) as Animator; if (tmpAnimator == null) { _sourceAnimator = null; RefreshAnimationEvents(null); return; } if (_sourceAnimator != tmpAnimator) { _sourceAnimator = tmpAnimator; RefreshAnimationEvents(null); } _explicitMode = EditorGUILayout.Toggle( new GUIContent( "Explicit Mode", "If true, restricts results to only those that explicitly with [AnimationEvent]" ), _explicitMode ); _controlFrameTime = EditorGUILayout.Toggle( new GUIContent( "Control Frame Time", "Select to edit precise time of animation events instead of snapping to nearest frame" ), _controlFrameTime ); AnimationClip selectedClip = AnimationEventClipSelector.Draw( _sourceAnimator, _viewModel, ref _animationSearchString, () => RefreshAnimationEvents(null) ); if (selectedClip == null) { return; } if (_viewModel.CurrentClip != selectedClip) { RefreshAnimationEvents(selectedClip); } _selectedFrameIndex = EditorGUILayout.IntField("FrameIndex", _selectedFrameIndex); using (new EditorGUILayout.HorizontalScope()) { if (GUILayout.Button("Add Event")) { if (0 <= _selectedFrameIndex) { AddNewEvent( _viewModel.FrameRate <= 0f ? 0f : _selectedFrameIndex / _viewModel.FrameRate ); } } if (GUILayout.Button("Add Event at Time 0")) { AddNewEvent(0f); } } // Frame rate with change detection and undo support EditorGUI.BeginChangeCheck(); float newFrameRate = EditorGUILayout.FloatField("FrameRate", _viewModel.FrameRate); if (EditorGUI.EndChangeCheck()) { _viewModel.SetFrameRate(newFrameRate); } if (_viewModel.FrameRateChanged) { EditorGUILayout.HelpBox( "Frame rate will be saved when you click 'Save'. Click 'Reset' to revert.", MessageType.Info ); } DrawGuiLine(height: 5, color: new Color(0f, 0.5f, 1f, 1f)); _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); // Use cached list to avoid allocations int stateCount = _viewModel.Count; for (int i = 0; i < stateCount; ++i) { AnimationEventItem item = _viewModel.GetEvent(i); AnimationEvent animEvent = item.animationEvent; int frame = Mathf.RoundToInt(animEvent.time * _viewModel.FrameRate); // Highlight focused event Color oldBgColor = GUI.backgroundColor; if (i == _focusedEventIndex) { GUI.backgroundColor = new Color(0.3f, 0.6f, 1f, 0.3f); } EditorGUILayout.BeginVertical(EditorStyles.helpBox); GUI.backgroundColor = oldBgColor; EditorGUILayout.PrefixLabel("Frame " + frame); AnimationEventSpritePreviewRenderer.Draw(item, _viewModel, _spriteTextureCache); using (new EditorGUI.IndentLevelScope()) { RenderAnimationEventItem(item, frame, i); if (i != stateCount - 1) { DrawGuiLine(height: 3, color: new Color(0f, 1f, 0.3f, 1f)); EditorGUILayout.Space(); } } EditorGUILayout.EndVertical(); } EditorGUILayout.EndScrollView(); DrawControlButtons(); // Show keyboard shortcuts help EditorGUILayout.Space(); EditorGUILayout.LabelField( "Shortcuts: Delete (remove event), Ctrl+D (duplicate), Up/Down (navigate)", EditorStyles.miniLabel ); } private void AddNewEvent(float time) { RecordUndo("Add Animation Event"); _viewModel.AddEvent(time); } private int AnimationEventComparison(AnimationEventItem lhs, AnimationEventItem rhs) { if (ReferenceEquals(lhs, rhs)) { return 0; } if (ReferenceEquals(null, rhs)) { return -1; } if (ReferenceEquals(null, lhs)) { return 1; } return AnimationEventEqualityComparer.Instance.Compare( lhs.animationEvent, rhs.animationEvent ); } private void DrawControlButtons() { if (!_viewModel.HasPendingChanges()) { GUILayout.Label("No changes detected..."); return; } Color oldColor = GUI.color; GUI.color = Color.green; if (GUILayout.Button("Save")) { SaveAnimation(); } GUI.color = oldColor; if (GUILayout.Button("Reset")) { RefreshAnimationEvents(); } if (_viewModel.NeedsReordering()) { if (GUILayout.Button("Re-Order")) { RecordUndo("Re-order Animation Events"); _viewModel.SortEvents(AnimationEventComparison); } } } private void RenderAnimationEventItem(AnimationEventItem item, int frame, int itemIndex) { int index = itemIndex; using (new EditorGUILayout.HorizontalScope()) { using (new EditorGUI.DisabledScope(!_viewModel.CanSwapWithPrevious(index))) { if (GUILayout.Button("Move Up") && _viewModel.TrySwapWithPrevious(index)) { RecordUndo("Move Animation Event Up"); _focusedEventIndex = index - 1; } } using (new EditorGUI.DisabledScope(!_viewModel.CanSwapWithNext(index))) { if (GUILayout.Button("Move Down") && _viewModel.TrySwapWithNext(index)) { RecordUndo("Move Animation Event Down"); _focusedEventIndex = index + 1; } } if (GUILayout.Button("Reset") && _viewModel.TryResetToBaseline(item)) { RecordUndo("Reset Animation Event"); item.selectedType = null; item.selectedMethod = null; item.cachedLookup = null; } if (GUILayout.Button($"Remove Event at frame {frame}")) { RecordUndo("Remove Animation Event"); _viewModel.RemoveEvent(item); return; } if (GUILayout.Button("Duplicate", GUILayout.Width(80))) { RecordUndo("Duplicate Animation Event"); AnimationEvent duplicated = AnimationEventEqualityComparer.Instance.Copy( item.animationEvent ); _viewModel.InsertEvent(index + 1, new AnimationEventItem(duplicated)); } } IReadOnlyDictionary> lookup = _explicitMode ? Lookup : AnimationEventMethodSelector.FilterLookup(item, Lookup); AnimationEventMethodSelector.EnsureSelection(item, lookup); AnimationEventMethodSelector.ValidateSelection(item, lookup); AnimationEventTimeFieldRenderer.DrawTimeFields( item, frame, _viewModel.FrameRate, _viewModel.CurrentClip == null ? 0f : _viewModel.CurrentClip.length, _controlFrameTime, RecordUndo, () => item.texture = null ); AnimationEventFunctionFieldRenderer.DrawFunctionFields(item, _explicitMode, RecordUndo); // Show validation status if (!item.isValid) { EditorGUILayout.HelpBox(item.validationMessage, MessageType.Warning); } if (!AnimationEventMethodSelector.DrawTypeSelector(item, lookup, RecordUndo)) { return; } if (!AnimationEventMethodSelector.DrawMethodSelector(item, lookup, RecordUndo)) { return; } AnimationEventParameterRenderer.Render(item, RecordUndo); } private void RefreshAnimationEvents(AnimationClip clip = null) { _spriteTextureCache.Clear(); _focusedEventIndex = -1; _viewModel.LoadClip(clip ?? _viewModel.CurrentClip); _selectedFrameIndex = _viewModel.CurrentClip == null ? -1 : MaxFrameIndex; } private void RecordUndo(string operationName) { Undo.RecordObject(this, operationName); } private void SaveAnimation() { AnimationClip clip = _viewModel.CurrentClip; if (clip == null) { return; } Undo.RecordObject(clip, "Save Animation Events"); AnimationUtility.SetAnimationEvents(clip, _viewModel.BuildEventArray()); if (_viewModel.FrameRateChanged) { clip.frameRate = _viewModel.FrameRate; _viewModel.ResetFrameRateChanged(); } EditorUtility.SetDirty(clip); AssetDatabase.SaveAssetIfDirty(clip); _viewModel.SnapshotBaseline(); } private void DrawGuiLine(int height = 1, Color? color = null) { Rect rect = EditorGUILayout.GetControlRect(false, height); rect.height = height; int minusWidth = EditorGUI.indentLevel * 16; rect.xMin += minusWidth; EditorGUI.DrawRect(rect, color ?? new Color(0.5f, 0.5f, 0.5f, 1f)); } } #endif }