From 5c2b9ccb696605ff9417cdc042a19dca971a5b62 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Thu, 2 Apr 2026 00:11:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=ED=81=B4=EB=A6=BD=20=EB=B3=91=ED=95=A9/=EC=9E=98?= =?UTF-8?q?=EB=9D=BC=EB=82=B4=EA=B8=B0=20=EC=97=90=EB=94=94=ED=84=B0=20?= =?UTF-8?q?=ED=88=B4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A9=94=EB=89=B4?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AnimationClipMerger: 여러 클립을 순서 조정 후 이어 붙이는 에디터 윈도우 추가 - AnimationClipTrimmerWindow: 시작/종료 프레임 지정으로 클립 잘라내기 에디터 윈도우 추가 - 기존 애니메이션 에디터 툴 메뉴를 Tools/Animation/ 서브메뉴로 통합 - Merge Clips, Trim Clip, Reverse Clip, Section Speed Editor --- .../Scripts/Editor/AnimationClipMerger.cs | 280 ++++++++++++++++++ .../Editor/AnimationClipMerger.cs.meta | 2 + .../Editor/AnimationClipTrimmerWindow.cs | 238 +++++++++++++++ .../Editor/AnimationClipTrimmerWindow.cs.meta | 2 + .../Editor/AnimationSectionSpeedEditor.cs | 2 +- .../_Game/Scripts/Editor/ReverseAnimation.cs | 2 +- 6 files changed, 524 insertions(+), 2 deletions(-) create mode 100644 Assets/_Game/Scripts/Editor/AnimationClipMerger.cs create mode 100644 Assets/_Game/Scripts/Editor/AnimationClipMerger.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/AnimationClipTrimmerWindow.cs create mode 100644 Assets/_Game/Scripts/Editor/AnimationClipTrimmerWindow.cs.meta diff --git a/Assets/_Game/Scripts/Editor/AnimationClipMerger.cs b/Assets/_Game/Scripts/Editor/AnimationClipMerger.cs new file mode 100644 index 00000000..0c983cb6 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/AnimationClipMerger.cs @@ -0,0 +1,280 @@ +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]); + } +} diff --git a/Assets/_Game/Scripts/Editor/AnimationClipMerger.cs.meta b/Assets/_Game/Scripts/Editor/AnimationClipMerger.cs.meta new file mode 100644 index 00000000..a7b29ed8 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/AnimationClipMerger.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: df6d4bb7132b1f4408c8ce2fae50bd11 \ No newline at end of file diff --git a/Assets/_Game/Scripts/Editor/AnimationClipTrimmerWindow.cs b/Assets/_Game/Scripts/Editor/AnimationClipTrimmerWindow.cs new file mode 100644 index 00000000..7a660065 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/AnimationClipTrimmerWindow.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; + +using UnityEngine; + +using UnityEditor; + +/// +/// AnimationClip의 시작/종료 프레임을 설정하여 해당 구간만 잘라낸 새 클립을 생성합니다. +/// FBX 임포트 클립에는 적용 불가 — 우선 Extract 후 사용하세요. +/// +public class AnimationClipTrimmerWindow : EditorWindow +{ + private AnimationClip sourceClip; + private int startFrame; + private int endFrame; + private float clipFrameRate; + private float clipLength; + private int totalFrames; + + private const string MenuPath = "Tools/Animation/Trim Clip"; + + [MenuItem(MenuPath, false, 21)] + public static void ShowWindow() + { + var clips = Selection.GetFiltered(SelectionMode.Assets); + + if (clips.Length == 0) + { + EditorUtility.DisplayDialog( + "Trim Animation Clip", + "AnimationClip을 1개 선택해 주세요.", + "확인"); + return; + } + + if (clips.Length > 1) + { + EditorUtility.DisplayDialog( + "Trim Animation Clip", + "1개의 AnimationClip만 선택해 주세요.", + "확인"); + return; + } + + var window = GetWindow("Clip Trimmer"); + window.sourceClip = clips[0]; + window.clipFrameRate = clips[0].frameRate; + window.clipLength = clips[0].length; + window.totalFrames = Mathf.RoundToInt(clips[0].length * clips[0].frameRate); + window.startFrame = 0; + window.endFrame = window.totalFrames; + window.minSize = new Vector2(320, 200); + window.Show(); + } + + [MenuItem(MenuPath, true)] + public static bool ValidateShowWindow() + { + return Selection.GetFiltered(SelectionMode.Assets).Length == 1; + } + + private void OnGUI() + { + if (sourceClip == null) + { + EditorGUILayout.HelpBox("AnimationClip을 찾을 수 없습니다.", MessageType.Error); + return; + } + + // 소스 클립 정보 + EditorGUILayout.Space(8f); + EditorGUILayout.LabelField("소스 클립", EditorStyles.boldLabel); + EditorGUILayout.LabelField($" 이름: {sourceClip.name}"); + EditorGUILayout.LabelField($" 프레임 레이트: {clipFrameRate} FPS"); + EditorGUILayout.LabelField($" 전체 길이: {clipLength:F3}초 ({totalFrames}프레임)"); + + EditorGUILayout.Space(12f); + + // 프레임 범위 설정 + EditorGUILayout.LabelField("잘라내기 범위", EditorStyles.boldLabel); + EditorGUILayout.Space(4f); + + EditorGUI.BeginChangeCheck(); + + int newStart = EditorGUILayout.IntField("시작 프레임", startFrame); + int newEnd = EditorGUILayout.IntField("종료 프레임", endFrame); + + if (EditorGUI.EndChangeCheck()) + { + startFrame = Mathf.Clamp(newStart, 0, totalFrames); + endFrame = Mathf.Clamp(newEnd, 0, totalFrames); + + // 시작이 종료보다 크면 교체 + if (startFrame > endFrame) + (startFrame, endFrame) = (endFrame, startFrame); + } + + // 시간 표시 + float startTime = startFrame / clipFrameRate; + float endTime = endFrame / clipFrameRate; + float duration = endTime - startTime; + + EditorGUILayout.Space(4f); + EditorGUILayout.LabelField($" 구간: {startTime:F3}초 ~ {endTime:F3}초"); + EditorGUILayout.LabelField($" 길이: {duration:F3}초 ({endFrame - startFrame}프레임)"); + + // 유효성 경고 + if (startFrame == 0 && endFrame == totalFrames) + { + EditorGUILayout.HelpBox("전체 구간이 선택되었습니다. 변경 후 잘라내기를 진행하세요.", MessageType.Info); + } + + EditorGUILayout.Space(12f); + + // 잘라내기 버튼 + EditorGUI.BeginDisabledGroup(startFrame == 0 && endFrame == totalFrames); + + if (GUILayout.Button("잘라내기 (새 클립 생성)", GUILayout.Height(32f))) + { + PerformTrim(); + } + + EditorGUI.EndDisabledGroup(); + } + + /// + /// 지정된 프레임 구간만 포함하는 새 AnimationClip을 생성합니다. + /// + private void PerformTrim() + { + float startTime = startFrame / clipFrameRate; + float endTime = endFrame / clipFrameRate; + + // 결과 클립 생성 + string sourcePath = AssetDatabase.GetAssetPath(sourceClip); + string directory = System.IO.Path.GetDirectoryName(sourcePath); + string trimmedName = $"{sourceClip.name}_Trimmed_F{startFrame}_F{endFrame}"; + string savePath = System.IO.Path.Combine(directory, trimmedName + ".anim") + .Replace("\\", "/"); + savePath = AssetDatabase.GenerateUniqueAssetPath(savePath); + + AnimationClip trimmedClip = new AnimationClip + { + name = trimmedName, + frameRate = sourceClip.frameRate, + legacy = sourceClip.legacy, + wrapMode = sourceClip.wrapMode + }; + + // 커브 복사 (시간 오프셋 반영) + EditorCurveBinding[] bindings = AnimationUtility.GetCurveBindings(sourceClip); + + foreach (EditorCurveBinding binding in bindings) + { + AnimationCurve sourceCurve = AnimationUtility.GetEditorCurve(sourceClip, binding); + if (sourceCurve == null || sourceCurve.keys.Length == 0) + continue; + + AnimationCurve trimmedCurve = TrimCurve(sourceCurve, startTime, endTime); + if (trimmedCurve.keys.Length > 0) + { + trimmedClip.SetCurve(binding.path, binding.type, binding.propertyName, trimmedCurve); + } + } + + // AnimationEvent 복사 (시간 오프셋 반영) + AnimationEvent[] sourceEvents = AnimationUtility.GetAnimationEvents(sourceClip); + if (sourceEvents.Length > 0) + { + List trimmedEvents = new List(); + + foreach (AnimationEvent evt in sourceEvents) + { + if (evt.time >= startTime && evt.time <= endTime) + { + trimmedEvents.Add(new AnimationEvent + { + time = evt.time - startTime, + functionName = evt.functionName, + floatParameter = evt.floatParameter, + intParameter = evt.intParameter, + stringParameter = evt.stringParameter, + objectReferenceParameter = evt.objectReferenceParameter, + messageOptions = evt.messageOptions + }); + } + } + + if (trimmedEvents.Count > 0) + AnimationUtility.SetAnimationEvents(trimmedClip, trimmedEvents.ToArray()); + } + + // 저장 + AssetDatabase.CreateAsset(trimmedClip, savePath); + AssetDatabase.SaveAssets(); + + EditorUtility.FocusProjectWindow(); + Selection.activeObject = trimmedClip; + + Debug.Log( + $"[AnimationClipTrimmer] 잘라내기 완료: {sourceClip.name} " + + $"F{startFrame}~F{endFrame} ({endTime - startTime:F3}초) → {savePath}"); + + Close(); + } + + /// + /// 커브에서 startTime ~ endTime 구간의 키 프레임만 추출하고 시간을 0부터 시작하도록 오프셋합니다. + /// + private static AnimationCurve TrimCurve(AnimationCurve curve, float startTime, float endTime) + { + Keyframe[] sourceKeys = curve.keys; + List trimmedKeys = new List(); + + foreach (Keyframe key in sourceKeys) + { + if (key.time >= startTime && key.time <= endTime) + { + trimmedKeys.Add(new Keyframe( + key.time - startTime, + key.value, + key.inTangent, + key.outTangent + )); + } + } + + if (trimmedKeys.Count == 0) + return null; + + return new AnimationCurve(trimmedKeys.ToArray()) + { + preWrapMode = curve.preWrapMode, + postWrapMode = curve.postWrapMode + }; + } +} diff --git a/Assets/_Game/Scripts/Editor/AnimationClipTrimmerWindow.cs.meta b/Assets/_Game/Scripts/Editor/AnimationClipTrimmerWindow.cs.meta new file mode 100644 index 00000000..e9771fda --- /dev/null +++ b/Assets/_Game/Scripts/Editor/AnimationClipTrimmerWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 603e437915f49744fb7e18298349dd5e \ No newline at end of file diff --git a/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs b/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs index 830adba7..2fc0c12a 100644 --- a/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs +++ b/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs @@ -18,7 +18,7 @@ using UnityEditor; /// public class AnimationSectionSpeedEditor : EditorWindow { - [MenuItem("Tools/Animation Section Speed Editor")] + [MenuItem("Tools/Animation/Section Speed Editor")] public static void ShowWindow() { GetWindow("Section Speed Editor"); diff --git a/Assets/_Game/Scripts/Editor/ReverseAnimation.cs b/Assets/_Game/Scripts/Editor/ReverseAnimation.cs index 40cc276b..838e9de0 100644 --- a/Assets/_Game/Scripts/Editor/ReverseAnimation.cs +++ b/Assets/_Game/Scripts/Editor/ReverseAnimation.cs @@ -19,7 +19,7 @@ public static class ReverseAnimation return null; } - [MenuItem("Tools/ReverseAnimation")] + [MenuItem("Tools/Animation/Reverse Clip")] public static void Reverse() { var clip = GetSelectedClip();