diff --git a/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs b/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs
new file mode 100644
index 00000000..830adba7
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs
@@ -0,0 +1,441 @@
+using UnityEngine;
+
+using UnityEditor;
+
+///
+/// 애니메이션 클립의 특정 프레임 구간 재생 속도를 조절하는 에디터 윈도우입니다.
+/// 지정한 프레임 범위의 키프레임 간격을 늘리거나 줄여 특정 동작을 강조할 수 있습니다.
+///
+/// 사용법:
+/// 1. Tools → Animation Section Speed Editor 열기
+/// 2. Project 창에서 .anim 파일 선택 (FBX 내부 클립은 Extract 후 사용)
+/// 3. 프레임 범위와 속도 배율 설정
+/// 4. Apply 클릭
+///
+/// ※ AssetDatabase.ImportAsset()을 호출하지 않고 AnimationUtility API로 메모리에서 수정 후
+/// SetDirty + SaveAssets로 저장합니다. Model Importer 파이프라인을 우회하여
+/// FBX 전체 타임라인이 끌려오는 문제를 방지합니다.
+///
+public class AnimationSectionSpeedEditor : EditorWindow
+{
+ [MenuItem("Tools/Animation Section Speed Editor")]
+ public static void ShowWindow()
+ {
+ GetWindow("Section Speed Editor");
+ }
+
+ private AnimationClip clip;
+ private int fps = 30;
+ private int startFrame;
+ private int endFrame = 10;
+ private float speedMultiplier = 1f;
+ private Vector2 scrollPos;
+
+ private void OnGUI()
+ {
+ scrollPos = EditorGUILayout.BeginScrollView(scrollPos);
+
+ // ── 클립 선택 ──
+ EditorGUI.BeginChangeCheck();
+ clip = (AnimationClip)EditorGUILayout.ObjectField("Animation Clip", clip, typeof(AnimationClip), false);
+ if (EditorGUI.EndChangeCheck() && clip != null)
+ {
+ endFrame = Mathf.FloorToInt(clip.length * fps);
+ }
+
+ EditorGUILayout.Space();
+
+ if (clip == null)
+ {
+ EditorGUILayout.HelpBox("Animation Clip을 선택하세요.\n(FBX 내부 클립은 미리 Extract 해야 합니다)", MessageType.Info);
+ EditorGUILayout.EndScrollView();
+ return;
+ }
+
+ // ── 클립 정보 (실제 키프레임 기반) ──
+ float actualLength = GetActualLastKeyframeTime(clip);
+ EditorGUILayout.LabelField("Clip Info", EditorStyles.boldLabel);
+ EditorGUI.indentLevel++;
+ EditorGUILayout.LabelField("Length (keyframes)", $"{actualLength:F3}s");
+ EditorGUILayout.LabelField("m_StopTime", $"{clip.length:F3}s");
+
+ if (Mathf.Abs(clip.length - actualLength) > 0.01f)
+ {
+ EditorGUILayout.HelpBox($"m_StopTime({clip.length:F3}s)이 실제 키프레임 길이({actualLength:F3}s)와 다릅니다.\n" +
+ "스피드 변경 시 실제 키프레임 길이를 기준으로 계산합니다.", MessageType.Warning);
+ }
+
+ EditorGUILayout.LabelField("Total Frames ({fps}fps)", $"{Mathf.Max(1, Mathf.FloorToInt(actualLength * fps))}");
+ EditorGUI.indentLevel--;
+
+ EditorGUILayout.Space();
+
+ // ── FPS 설정 ──
+ fps = EditorGUILayout.IntField("FPS", fps);
+ fps = Mathf.Max(1, fps);
+
+ int totalFrames = Mathf.Max(1, Mathf.FloorToInt(actualLength * fps));
+
+ // ── 프레임 범위 ──
+ EditorGUILayout.LabelField("Target Frame Range", EditorStyles.boldLabel);
+ EditorGUI.indentLevel++;
+
+ int newStart = EditorGUILayout.IntField("Start Frame", startFrame);
+ int newEnd = EditorGUILayout.IntField("End Frame", endFrame);
+
+ startFrame = Mathf.Clamp(newStart, 0, totalFrames - 1);
+ endFrame = Mathf.Clamp(newEnd, 0, totalFrames);
+
+ // 슬라이더
+ float startFloat = startFrame;
+ float endFloat = endFrame;
+ EditorGUILayout.MinMaxSlider(ref startFloat, ref endFloat, 0, totalFrames);
+ startFrame = Mathf.RoundToInt(startFloat);
+ endFrame = Mathf.RoundToInt(endFloat);
+ EditorGUI.indentLevel--;
+
+ if (startFrame >= endFrame)
+ {
+ EditorGUILayout.HelpBox("시작 프레임이 끝 프레임보다 크거나 같습니다.", MessageType.Warning);
+ EditorGUILayout.EndScrollView();
+ return;
+ }
+
+ float startTime = (float)startFrame / fps;
+ float endTime = (float)endFrame / fps;
+ float duration = endTime - startTime;
+
+ EditorGUILayout.Space();
+
+ // ── 타임라인 시각화 ──
+ DrawTimeline(startTime, endTime, actualLength);
+
+ EditorGUILayout.Space();
+
+ // ── 속도 배율 ──
+ EditorGUILayout.LabelField("Speed", EditorStyles.boldLabel);
+ EditorGUI.indentLevel++;
+
+ speedMultiplier = EditorGUILayout.FloatField("Speed Multiplier", speedMultiplier);
+ speedMultiplier = Mathf.Max(0.1f, speedMultiplier);
+
+ // 프리셋 버튼
+ EditorGUILayout.BeginHorizontal();
+ if (GUILayout.Button("0.25x")) speedMultiplier = 0.25f;
+ if (GUILayout.Button("0.5x")) speedMultiplier = 0.5f;
+ if (GUILayout.Button("1.0x")) speedMultiplier = 1f;
+ if (GUILayout.Button("2.0x")) speedMultiplier = 2f;
+ EditorGUILayout.EndHorizontal();
+ EditorGUI.indentLevel--;
+
+ EditorGUILayout.Space();
+
+ // ── 미리보기 ──
+ float newDuration = duration / speedMultiplier;
+ float timeDelta = newDuration - duration;
+ float newLength = actualLength + timeDelta;
+
+ EditorGUILayout.LabelField("Preview", EditorStyles.boldLabel);
+ EditorGUI.indentLevel++;
+
+ EditorGUIUtility.labelWidth = 160;
+ EditorGUILayout.LabelField("Original Section", $"{duration:F3}s (frame {startFrame}~{endFrame})");
+ EditorGUILayout.LabelField("New Section", $"{newDuration:F3}s");
+ EditorGUILayout.LabelField("Time Delta", $"{(timeDelta >= 0 ? "+" : "")}{timeDelta:F3}s");
+ EditorGUILayout.LabelField("New Clip Length", $"{newLength:F3}s");
+ EditorGUIUtility.labelWidth = 0;
+ EditorGUI.indentLevel--;
+
+ EditorGUILayout.Space();
+
+ // ── 적용 버튼 ──
+ if (speedMultiplier == 1f)
+ {
+ EditorGUILayout.HelpBox("속도 배율이 1.0이면 변경 사항이 없습니다.", MessageType.Info);
+ }
+
+ EditorGUI.BeginDisabledGroup(speedMultiplier == 1f);
+ if (GUILayout.Button("Apply", GUILayout.Height(40)))
+ {
+ ApplySpeedChange();
+ }
+ EditorGUI.EndDisabledGroup();
+
+ EditorGUILayout.EndScrollView();
+ }
+
+ ///
+ /// 타임라인을 간단한 바 형태로 시각화합니다.
+ ///
+ private void DrawTimeline(float startTime, float endTime, float clipLength)
+ {
+ float barHeight = 24f;
+ float padding = 4f;
+ Rect barRect = GUILayoutUtility.GetRect(EditorGUIUtility.currentViewWidth - 20, barHeight * 2 + padding * 3);
+
+ // 전체 타임라인 배경
+ EditorGUI.DrawRect(barRect, new Color(0.2f, 0.2f, 0.2f, 1f));
+
+ float startNorm = Mathf.Clamp01(startTime / clipLength);
+ float endNorm = Mathf.Clamp01(endTime / clipLength);
+
+ // ── 원본 구간 (위쪽 바) ──
+ float topY = barRect.y + padding;
+ Rect origSectionRect = new Rect(
+ barRect.x + barRect.width * startNorm,
+ topY,
+ barRect.width * (endNorm - startNorm),
+ barHeight);
+ EditorGUI.DrawRect(origSectionRect, new Color(1f, 0.5f, 0.2f, 0.8f));
+
+ // 원본 구간 레이블
+ GUIStyle labelStyle = new GUIStyle(EditorStyles.miniLabel);
+ labelStyle.alignment = TextAnchor.MiddleCenter;
+ labelStyle.normal.textColor = Color.white;
+ EditorGUI.LabelField(origSectionRect, $"Original ({startFrame}~{endFrame})", labelStyle);
+
+ // ── 변경 후 구간 (아래쪽 바) ──
+ float newDuration = (endTime - startTime) / speedMultiplier;
+ float newClipLength = clipLength + (newDuration - (endTime - startTime));
+ float newEndNorm = Mathf.Clamp01((startTime + newDuration) / newClipLength);
+
+ float botY = topY + barHeight + padding;
+ Rect newSectionRect = new Rect(
+ barRect.x + barRect.width * startNorm,
+ botY,
+ barRect.width * (newEndNorm - startNorm),
+ barHeight);
+ EditorGUI.DrawRect(newSectionRect, new Color(0.2f, 0.8f, 0.4f, 0.8f));
+
+ EditorGUI.LabelField(newSectionRect, $"New ({speedMultiplier}x)", labelStyle);
+
+ // 바 테두리
+ EditorGUI.DrawRect(barRect, new Color(0.4f, 0.4f, 0.4f, 1f));
+ }
+
+ ///
+ /// 본 트랜스폼 커브에서 실제 마지막 키프레임 시간을 구합니다.
+ /// clip.length는 m_StopTime 기반이라 FBX Extract 클립에서는 실제 데이터와 다를 수 있습니다.
+ /// IK 커브나 모션 플로트 커브는 제외하고, 본의 m_LocalRotation/Position/Scale만 참조합니다.
+ ///
+ private static readonly string[] BoneTransformProperties =
+ {
+ "m_LocalRotation.x", "m_LocalRotation.y", "m_LocalRotation.z", "m_LocalRotation.w",
+ "m_LocalPosition.x", "m_LocalPosition.y", "m_LocalPosition.z",
+ "m_LocalScale.x", "m_LocalScale.y", "m_LocalScale.z",
+ "localEulerAnglesRaw.x", "localEulerAnglesRaw.y", "localEulerAnglesRaw.z",
+ };
+
+ private static float GetActualLastKeyframeTime(AnimationClip clip)
+ {
+ float maxTime = 0f;
+ EditorCurveBinding[] bindings = AnimationUtility.GetCurveBindings(clip);
+ foreach (EditorCurveBinding binding in bindings)
+ {
+ // 모션 플로트 커브: path가 "0"이면 Humanoid muscle curve → 건너뛰기
+ if (binding.path == "0")
+ continue;
+
+ // 본 트랜스폼 프로퍼티만 참조 (IK 커브 등 제외)
+ bool isBoneTransform = false;
+ for (int i = 0; i < BoneTransformProperties.Length; i++)
+ {
+ if (binding.propertyName == BoneTransformProperties[i])
+ {
+ isBoneTransform = true;
+ break;
+ }
+ }
+ if (!isBoneTransform)
+ continue;
+
+ AnimationCurve curve = AnimationUtility.GetEditorCurve(clip, binding);
+ if (curve == null || curve.length == 0) continue;
+
+ float lastTime = curve.keys[curve.length - 1].time;
+ if (lastTime > maxTime)
+ maxTime = lastTime;
+ }
+
+ // 이벤트도 고려
+ AnimationEvent[] events = AnimationUtility.GetAnimationEvents(clip);
+ foreach (var evt in events)
+ {
+ if (evt.time > maxTime)
+ maxTime = evt.time;
+ }
+
+ return maxTime;
+ }
+
+ ///
+ /// Humanoid muscle curve인지 확인합니다. path가 "0"이면 Humanoid muscle curve입니다.
+ /// bone transform, IK, prop, eyeLight 모두 수정 대상에 포함합니다.
+ ///
+ private static bool IsHumanoidMuscleCurve(string path)
+ {
+ return path == "0";
+ }
+
+ ///
+ /// AnimationUtility API를 사용하여 메모리에서 특정 구간의 재생 속도를 조절합니다.
+ /// AssetDatabase.ImportAsset()을 호출하지 않으므로 Model Importer 파이프라인을 우회합니다.
+ ///
+ /// 처리 순서:
+ /// 1. AnimationUtility.GetAllCurves로 모든 커브를 가져옴
+ /// 2. 본 경로(IsBonePath) 필터링
+ /// 3. 대상 구간 내 키프레임 시간 재배열 + 접선 스케일링
+ /// 4. 대상 구간 외 후속 키프레임 시간 이동
+ /// 5. AnimationUtility.SetEditorCurve로 커브 덮어쓰기
+ /// 6. SerializedObject로 m_StopTime 갱신
+ /// 7. AnimationEvent 시간 이동
+ /// 8. SetDirty + SaveAssets (ImportAsset 호출 금지)
+ ///
+ private void ApplySpeedChange()
+ {
+ if (clip == null) return;
+
+ float startTime = (float)startFrame / fps;
+ float endTime = (float)endFrame / fps;
+ float duration = endTime - startTime;
+ float newDuration = duration / speedMultiplier;
+ float timeDelta = newDuration - duration;
+ float actualLength = GetActualLastKeyframeTime(clip);
+
+ Undo.RegisterCompleteObjectUndo(clip, $"Section Speed {speedMultiplier}x (frames {startFrame}~{endFrame})");
+
+ // ── 1. 모든 커브 가져오기 ──
+ EditorCurveBinding[] bindings = AnimationUtility.GetCurveBindings(clip);
+ AnimationCurve[] curves = new AnimationCurve[bindings.Length];
+ for (int i = 0; i < bindings.Length; i++)
+ {
+ curves[i] = AnimationUtility.GetEditorCurve(clip, bindings[i]);
+ }
+
+ int modifiedCurves = 0;
+ int modifiedKeyframes = 0;
+ int skippedCurves = 0;
+
+ // ── 2~4. 커브별 키프레임 시간 수정 ──
+ for (int i = 0; i < bindings.Length; i++)
+ {
+ EditorCurveBinding binding = bindings[i];
+ AnimationCurve curve = curves[i];
+
+ // Humanoid muscle curve만 건너뛰기 (IK, prop, eyeLight 모두 수정 대상)
+ if (IsHumanoidMuscleCurve(binding.path))
+ {
+ skippedCurves++;
+ continue;
+ }
+
+ if (curve == null || curve.length == 0)
+ continue;
+
+ // 새 키프레임 배열 생성
+ Keyframe[] newKeys = new Keyframe[curve.length];
+ bool anyModified = false;
+
+ for (int k = 0; k < curve.length; k++)
+ {
+ Keyframe oldKey = curve.keys[k];
+ float t = oldKey.time;
+
+ if (t >= startTime - 0.0001f && t <= endTime + 0.0001f)
+ {
+ // 구간 내 키프레임: 시간 재배열 + 접선 스케일링
+ float normalizedPos = (t - startTime) / duration;
+ float newTime = startTime + normalizedPos * newDuration;
+
+ // 접선 스케일링: 시간축이 늘어나면 기울기는 줄어야 함
+ // dy/dt' = dy/dt * speedMultiplier (t' = t/speedMultiplier 이므로)
+ float tangentScale = speedMultiplier;
+
+ newKeys[k] = new Keyframe(
+ newTime,
+ oldKey.value,
+ oldKey.inTangent * tangentScale,
+ oldKey.outTangent * tangentScale,
+ oldKey.inWeight,
+ oldKey.outWeight);
+
+ anyModified = true;
+ modifiedKeyframes++;
+ }
+ else if (t > endTime + 0.0001f)
+ {
+ // 구간 이후 키프레임: 시간만 이동 (접선은 그대로)
+ newKeys[k] = new Keyframe(
+ t + timeDelta,
+ oldKey.value,
+ oldKey.inTangent,
+ oldKey.outTangent,
+ oldKey.inWeight,
+ oldKey.outWeight);
+
+ anyModified = true;
+ modifiedKeyframes++;
+ }
+ else
+ {
+ // 구간 이전 키프레임: 그대로
+ newKeys[k] = oldKey;
+ }
+ }
+
+ if (anyModified)
+ {
+ curves[i] = new AnimationCurve(newKeys);
+ AnimationUtility.SetEditorCurve(clip, binding, curves[i]);
+ modifiedCurves++;
+ }
+ }
+
+ // ── 5. m_StopTime 갱신 (SerializedObject로) ──
+ float newStopTime = actualLength + timeDelta;
+ var serializedClip = new SerializedObject(clip);
+ SerializedProperty stopTimeProp = serializedClip.FindProperty("m_StopTime");
+ if (stopTimeProp != null)
+ {
+ stopTimeProp.floatValue = newStopTime;
+ serializedClip.ApplyModifiedProperties();
+ }
+
+ // ── 6. AnimationEvent 시간 이동 ──
+ AnimationEvent[] events = AnimationUtility.GetAnimationEvents(clip);
+ bool eventsModified = false;
+ for (int i = 0; i < events.Length; i++)
+ {
+ float evtTime = events[i].time;
+
+ if (evtTime >= startTime - 0.0001f && evtTime <= endTime + 0.0001f)
+ {
+ float normalizedPos = (evtTime - startTime) / duration;
+ events[i].time = startTime + normalizedPos * newDuration;
+ eventsModified = true;
+ }
+ else if (evtTime > endTime + 0.0001f)
+ {
+ events[i].time = evtTime + timeDelta;
+ eventsModified = true;
+ }
+ }
+
+ if (eventsModified)
+ {
+ AnimationUtility.SetAnimationEvents(clip, events);
+ }
+
+ // ── 7. 저장 (ImportAsset 호출 금지) ──
+ EditorUtility.SetDirty(clip);
+ AssetDatabase.SaveAssets();
+ Repaint();
+
+ Debug.Log($"[SectionSpeedEditor] {clip.name}: frames {startFrame}~{endFrame} → {speedMultiplier}x " +
+ $"({duration:F3}s → {newDuration:F3}s) " +
+ $"| clip: {actualLength:F3}s → {newStopTime:F3}s " +
+ $"| curves: {modifiedCurves}, keyframes: {modifiedKeyframes}, skipped: {skippedCurves}" +
+ $" | events: {(eventsModified ? "modified" : "none")}");
+ }
+
+}
diff --git a/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs.meta b/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs.meta
new file mode 100644
index 00000000..527bf54a
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/AnimationSectionSpeedEditor.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 11af7b19aec6eff43b6fa837908b7fe6
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Editor/ReverseAnimation.cs b/Assets/_Game/Scripts/Editor/ReverseAnimation.cs
new file mode 100644
index 00000000..40cc276b
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/ReverseAnimation.cs
@@ -0,0 +1,83 @@
+using UnityEngine;
+
+using UnityEditor;
+
+using System.Collections.Generic;
+
+///
+/// 선택한 AnimationClip의 키프레임 순서를 반전합니다.
+/// FBX 임포트 클립에는 적용 불가 — 우선 Extract 후 사용하세요.
+///
+public static class ReverseAnimation
+{
+ public static AnimationClip GetSelectedClip()
+ {
+ var clips = Selection.GetFiltered(typeof(AnimationClip), SelectionMode.Assets);
+ if (clips.Length > 0)
+ return clips[0] as AnimationClip;
+
+ return null;
+ }
+
+ [MenuItem("Tools/ReverseAnimation")]
+ public static void Reverse()
+ {
+ var clip = GetSelectedClip();
+ if (clip == null)
+ return;
+
+ float clipLength = clip.length;
+
+ List curves = new List();
+ EditorCurveBinding[] editorCurveBindings = AnimationUtility.GetCurveBindings(clip);
+
+ foreach (EditorCurveBinding i in editorCurveBindings)
+ {
+ var curve = AnimationUtility.GetEditorCurve(clip, i);
+ curves.Add(curve);
+ }
+
+ clip.ClearCurves();
+
+ for (int i = 0; i < curves.Count; i++)
+ {
+ var curve = curves[i];
+ var binding = editorCurveBindings[i];
+ var keys = curve.keys;
+ int keyCount = keys.Length;
+
+ var postWrapmode = curve.postWrapMode;
+ curve.postWrapMode = curve.preWrapMode;
+ curve.preWrapMode = postWrapmode;
+
+ for (int j = 0; j < keyCount; j++)
+ {
+ Keyframe K = keys[j];
+ K.time = clipLength - K.time;
+
+ var tmp = -K.inTangent;
+ K.inTangent = -K.outTangent;
+ K.outTangent = tmp;
+
+ keys[j] = K;
+ }
+
+ curve.keys = keys;
+ clip.SetCurve(binding.path, binding.type, binding.propertyName, curve);
+ }
+
+ // AnimationEvent 시간도 반전
+ var events = AnimationUtility.GetAnimationEvents(clip);
+ if (events.Length > 0)
+ {
+ for (int i = 0; i < events.Length; i++)
+ {
+ events[i].time = clipLength - events[i].time;
+ }
+ AnimationUtility.SetAnimationEvents(clip, events);
+ }
+
+ Debug.Log("[ReverseAnimation] Animation reversed: " + clip.name);
+ EditorUtility.SetDirty(clip);
+ }
+}
diff --git a/Assets/_Game/Scripts/Editor/ReverseAnimation.cs.meta b/Assets/_Game/Scripts/Editor/ReverseAnimation.cs.meta
new file mode 100644
index 00000000..760e3877
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/ReverseAnimation.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: cf6abf78150fb14409bc53815b9f432f
\ No newline at end of file