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();