using System; using System.Collections.Generic; using System.Linq; using UnityEngine; using UnityEditor; /// /// 여러 AnimationClip을 시간 순으로 이어 붙여 새로운 클립을 생성하는 에디터 윈도우입니다. /// 클립 순서를 자유롭게 조정할 수 있습니다. /// FBX 임포트 클립에는 적용 불가 — 우선 Extract 후 사용하세요. /// public class AnimationClipMergerWindow : EditorWindow { private List clips = new List(); private Vector2 scrollPosition; private const string MenuPath = "Tools/Animation/Merge Clips"; [MenuItem(MenuPath, false, 20)] public static void ShowWindow() { var selectedClips = Selection.GetFiltered(SelectionMode.Assets); if (selectedClips.Length < 2) { EditorUtility.DisplayDialog( "Merge Animation Clips", "병합할 AnimationClip을 2개 이상 선택해 주세요.", "확인"); return; } var window = GetWindow("Clip Merger"); // 선택 순서 유지 (Selection.GetFiltered는 역순일 수 있으므로 뒤집음) window.clips = selectedClips.Reverse().ToList(); window.minSize = new Vector2(300, 250); window.Show(); } [MenuItem(MenuPath, true)] public static bool ValidateShowWindow() { return Selection.GetFiltered(SelectionMode.Assets).Length >= 2; } private void OnGUI() { EditorGUILayout.Space(8f); EditorGUILayout.LabelField($"클립 순서 ({clips.Count}개)", EditorStyles.boldLabel); EditorGUILayout.Space(4f); if (clips.Count == 0) { EditorGUILayout.HelpBox("클립이 없습니다.", MessageType.Warning); return; } scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); for (int i = 0; i < clips.Count; i++) { AnimationClip clip = clips[i]; if (clip == null) { EditorGUILayout.LabelField($"[{i + 1}] (삭제됨)", EditorStyles.boldLabel); continue; } EditorGUILayout.BeginHorizontal(); // 인덱스 EditorGUILayout.LabelField($"[{i + 1}]", GUILayout.Width(30)); // 클립 정보 EditorGUI.BeginDisabledGroup(true); EditorGUILayout.ObjectField(clip, typeof(AnimationClip), false); EditorGUI.EndDisabledGroup(); // 길이 표시 EditorGUILayout.LabelField($"{clip.length:F2}s", GUILayout.Width(50)); // 위로 / 아래로 버튼 EditorGUI.BeginDisabledGroup(i == 0); if (GUILayout.Button("▲", GUILayout.Width(28))) { clips.Swap(i, i - 1); break; } EditorGUI.EndDisabledGroup(); EditorGUI.BeginDisabledGroup(i == clips.Count - 1); if (GUILayout.Button("▼", GUILayout.Width(28))) { clips.Swap(i, i + 1); break; } EditorGUI.EndDisabledGroup(); // 제거 버튼 if (GUILayout.Button("✕", GUILayout.Width(28))) { clips.RemoveAt(i); break; } EditorGUILayout.EndHorizontal(); } EditorGUILayout.EndScrollView(); EditorGUILayout.Space(8f); // 총 길이 float totalLength = clips.Sum(c => c != null ? c.length : 0f); EditorGUILayout.LabelField($"총 길이: {totalLength:F3}초", EditorStyles.miniBoldLabel); EditorGUILayout.Space(8f); // 병합 버튼 EditorGUI.BeginDisabledGroup(clips.Count < 2); if (GUILayout.Button("병합", GUILayout.Height(32f))) { PerformMerge(); } EditorGUI.EndDisabledGroup(); } /// /// 현재 순서대로 클립들을 이어 붙여 새 AnimationClip을 생성합니다. /// private void PerformMerge() { // null 클립 제거 clips.RemoveAll(c => c == null); if (clips.Count < 2) { EditorUtility.DisplayDialog("Merge Animation Clips", "유효한 클립이 2개 이상 필요합니다.", "확인"); return; } // 첫 번째 클립이 위치한 폴더에 결과 저장 string firstClipPath = AssetDatabase.GetAssetPath(clips[0]); string directory = System.IO.Path.GetDirectoryName(firstClipPath); string mergedName = "Merged_" + string.Join("_", clips.Select(c => c.name)); string savePath = System.IO.Path.Combine(directory, mergedName + ".anim") .Replace("\\", "/"); savePath = AssetDatabase.GenerateUniqueAssetPath(savePath); AnimationClip mergedClip = new AnimationClip { name = mergedName, frameRate = clips[0].frameRate, legacy = clips[0].legacy, wrapMode = WrapMode.Once }; float timeOffset = 0f; foreach (AnimationClip sourceClip in clips) { CopyCurvesToClip(sourceClip, mergedClip, timeOffset); CopyEventsToClip(sourceClip, mergedClip, timeOffset); timeOffset += sourceClip.length; } AssetDatabase.CreateAsset(mergedClip, savePath); AssetDatabase.SaveAssets(); EditorUtility.FocusProjectWindow(); Selection.activeObject = mergedClip; Debug.Log($"[AnimationClipMerger] {clips.Count}개 클립 병합 완료: {savePath} (총 {timeOffset:F3}초)"); Close(); } /// /// 소스 클립의 모든 커브를 타겟 클립에 오프셋 적용하여 복사합니다. /// 동일 바인딩이 이미 존재하면 키 프레임을 병합합니다. /// private static void CopyCurvesToClip(AnimationClip source, AnimationClip target, float timeOffset) { EditorCurveBinding[] bindings = AnimationUtility.GetCurveBindings(source); foreach (EditorCurveBinding binding in bindings) { AnimationCurve sourceCurve = AnimationUtility.GetEditorCurve(source, binding); if (sourceCurve == null || sourceCurve.keys.Length == 0) continue; AnimationCurve existingCurve = AnimationUtility.GetEditorCurve(target, binding); AnimationCurve newCurve = existingCurve != null ? AppendKeys(existingCurve, sourceCurve, timeOffset) : OffsetCurve(sourceCurve, timeOffset); target.SetCurve(binding.path, binding.type, binding.propertyName, newCurve); } } /// /// 커브의 모든 키 프레임 시간을 오프셋만큼 이동시킨 새 커브를 반환합니다. /// private static AnimationCurve OffsetCurve(AnimationCurve curve, float offset) { Keyframe[] keys = curve.keys; for (int i = 0; i < keys.Length; i++) { keys[i].time += offset; } return new AnimationCurve(keys) { preWrapMode = curve.preWrapMode, postWrapMode = curve.postWrapMode }; } /// /// 기존 커브에 새 키 프레임들을 오프셋 적용하여 추가합니다. /// private static AnimationCurve AppendKeys(AnimationCurve existing, AnimationCurve toAppend, float offset) { List allKeys = new List(existing.keys); foreach (Keyframe key in toAppend.keys) { allKeys.Add(new Keyframe(key.time + offset, key.value, key.inTangent, key.outTangent)); } return new AnimationCurve(allKeys.ToArray()) { preWrapMode = existing.preWrapMode, postWrapMode = existing.postWrapMode }; } /// /// 소스 클립의 AnimationEvent를 오프셋 적용하여 타겟 클립에 추가합니다. /// private static void CopyEventsToClip(AnimationClip source, AnimationClip target, float timeOffset) { AnimationEvent[] events = AnimationUtility.GetAnimationEvents(source); if (events.Length == 0) return; AnimationEvent[] existingEvents = AnimationUtility.GetAnimationEvents(target); List allEvents = new List(existingEvents); foreach (AnimationEvent evt in events) { allEvents.Add(new AnimationEvent { time = evt.time + timeOffset, functionName = evt.functionName, floatParameter = evt.floatParameter, intParameter = evt.intParameter, stringParameter = evt.stringParameter, objectReferenceParameter = evt.objectReferenceParameter, messageOptions = evt.messageOptions }); } AnimationUtility.SetAnimationEvents(target, allEvents.ToArray()); } } /// /// List 확장 메서드: 두 요소의 위치를 교체합니다. /// internal static class ListExtensions { public static void Swap(this IList list, int a, int b) { (list[a], list[b]) = (list[b], list[a]); } }