feat: 기본기3 브릿지 클립 및 애니메이션 편집 유틸 추가

- 기본기3 1타 브릿지 클립과 패턴/스킬 자산을 정리해 1타-2타 연결 흐름을 보강

- 애니메이션 브릿지 클립 생성 에디터 창을 추가해 앞뒤 프레임을 잘라 새 클립을 만들 수 있게 지원

- 선택한 애니메이션 클립의 마지막 프레임에 누락 키를 일괄 추가하는 에디터 메뉴를 추가
This commit is contained in:
2026-04-17 11:22:02 +09:00
parent 8c08e63c81
commit dadaa56511
12 changed files with 130829 additions and 80434 deletions

View File

@@ -0,0 +1,364 @@
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEditor;
/// <summary>
/// 두 AnimationClip의 경계 포즈를 샘플링해 브릿지용 AnimationClip을 생성합니다.
/// 독립 .anim 클립 기준으로 사용하며, 생성 후 수동 미세 조정을 권장합니다.
/// </summary>
public class AnimationBridgeClipGeneratorWindow : EditorWindow
{
private const string MenuPath = "Tools/Animation/Generate Bridge Clip";
private AnimationClip sourceClip;
private AnimationClip targetClip;
private int sourceEndTrimFrames;
private int targetStartTrimFrames;
private int bridgeDurationFrames = 4;
private string outputName = string.Empty;
private string outputDirectory = "Assets/_Game/Animations";
[MenuItem(MenuPath, false, 22)]
public static void ShowWindow()
{
AnimationClip[] selectedClips = Selection.GetFiltered<AnimationClip>(SelectionMode.Assets);
AnimationBridgeClipGeneratorWindow window = GetWindow<AnimationBridgeClipGeneratorWindow>("Bridge Clip");
window.minSize = new Vector2(380f, 280f);
window.InitializeFromSelection(selectedClips);
window.Show();
}
private void InitializeFromSelection(AnimationClip[] selectedClips)
{
if (selectedClips == null || selectedClips.Length < 2)
return;
sourceClip = selectedClips[0];
targetClip = selectedClips[1];
string sourcePath = AssetDatabase.GetAssetPath(sourceClip);
if (!string.IsNullOrEmpty(sourcePath))
outputDirectory = Path.GetDirectoryName(sourcePath)?.Replace("\\", "/") ?? outputDirectory;
RefreshOutputName();
}
private void OnGUI()
{
EditorGUILayout.Space(8f);
EditorGUILayout.LabelField("브릿지 클립 생성", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"앞 클립의 끝 포즈와 뒤 클립의 시작 포즈를 샘플링해 짧은 브릿지 클립을 생성합니다.\n" +
"Humanoid 클립은 생성 후 수동 미세 조정을 권장합니다.",
MessageType.Info);
EditorGUI.BeginChangeCheck();
sourceClip = (AnimationClip)EditorGUILayout.ObjectField("앞 클립", sourceClip, typeof(AnimationClip), false);
targetClip = (AnimationClip)EditorGUILayout.ObjectField("뒤 클립", targetClip, typeof(AnimationClip), false);
if (EditorGUI.EndChangeCheck())
RefreshOutputName();
using (new EditorGUI.DisabledScope(sourceClip == null))
{
sourceEndTrimFrames = Mathf.Max(0, EditorGUILayout.IntField("앞 클립 끝에서 제외 프레임", sourceEndTrimFrames));
}
using (new EditorGUI.DisabledScope(targetClip == null))
{
targetStartTrimFrames = Mathf.Max(0, EditorGUILayout.IntField("뒤 클립 시작에서 제외 프레임", targetStartTrimFrames));
}
bridgeDurationFrames = Mathf.Max(1, EditorGUILayout.IntField("브릿지 길이(프레임)", bridgeDurationFrames));
outputName = EditorGUILayout.TextField("출력 이름", outputName);
outputDirectory = EditorGUILayout.TextField("출력 폴더", outputDirectory);
EditorGUILayout.Space(8f);
if (sourceClip != null)
DrawClipSummary("앞 클립 샘플", sourceClip, sourceEndTrimFrames, useEndSample: true);
if (targetClip != null)
DrawClipSummary("뒤 클립 샘플", targetClip, targetStartTrimFrames, useEndSample: false);
EditorGUILayout.Space(12f);
using (new EditorGUI.DisabledScope(!CanGenerate()))
{
if (GUILayout.Button("브릿지 클립 생성", GUILayout.Height(32f)))
GenerateBridgeClip();
}
}
private void DrawClipSummary(string label, AnimationClip clip, int trimFrames, bool useEndSample)
{
float sampleTime = GetSampleTime(clip, trimFrames, useEndSample);
int totalFrames = Mathf.RoundToInt(clip.length * clip.frameRate);
int sampleFrame = Mathf.RoundToInt(sampleTime * clip.frameRate);
EditorGUILayout.LabelField(label, EditorStyles.boldLabel);
EditorGUILayout.LabelField(
$" {clip.name} / {clip.frameRate:F0} FPS / {clip.length:F3}초 / {totalFrames}프레임");
EditorGUILayout.LabelField($" 샘플 프레임: {sampleFrame} / 샘플 시간: {sampleTime:F3}초");
}
private bool CanGenerate()
{
if (sourceClip == null || targetClip == null)
return false;
if (string.IsNullOrWhiteSpace(outputName) || string.IsNullOrWhiteSpace(outputDirectory))
return false;
return AssetDatabase.IsValidFolder(outputDirectory);
}
private void RefreshOutputName()
{
if (sourceClip == null || targetClip == null)
return;
outputName = $"{sourceClip.name}_to_{targetClip.name}_Bridge";
}
private void GenerateBridgeClip()
{
float frameRate = sourceClip != null ? sourceClip.frameRate : 30f;
float duration = bridgeDurationFrames / frameRate;
float sourceSampleTime = GetSampleTime(sourceClip, sourceEndTrimFrames, useEndSample: true);
float targetSampleTime = GetSampleTime(targetClip, targetStartTrimFrames, useEndSample: false);
AnimationClip bridgeClip = new AnimationClip
{
name = outputName,
frameRate = frameRate,
legacy = sourceClip.legacy,
wrapMode = WrapMode.Once,
localBounds = sourceClip.localBounds
};
HashSet<EditorCurveBinding> floatBindings = new HashSet<EditorCurveBinding>(new EditorCurveBindingComparer());
AddCurveBindings(floatBindings, AnimationUtility.GetCurveBindings(sourceClip));
AddCurveBindings(floatBindings, AnimationUtility.GetCurveBindings(targetClip));
foreach (EditorCurveBinding binding in floatBindings)
{
AnimationCurve sourceCurve = AnimationUtility.GetEditorCurve(sourceClip, binding);
AnimationCurve targetCurve = AnimationUtility.GetEditorCurve(targetClip, binding);
if (sourceCurve == null && targetCurve == null)
continue;
float startValue = EvaluateCurveOrDefault(sourceCurve, sourceSampleTime);
float endValue = EvaluateCurveOrDefault(targetCurve, targetSampleTime);
AnimationCurve bridgeCurve = CreateBridgeCurve(startValue, endValue, duration);
AnimationUtility.SetEditorCurve(bridgeClip, binding, bridgeCurve);
}
HashSet<EditorCurveBinding> objectBindings = new HashSet<EditorCurveBinding>(new EditorCurveBindingComparer());
AddCurveBindings(objectBindings, AnimationUtility.GetObjectReferenceCurveBindings(sourceClip));
AddCurveBindings(objectBindings, AnimationUtility.GetObjectReferenceCurveBindings(targetClip));
foreach (EditorCurveBinding binding in objectBindings)
{
ObjectReferenceKeyframe[] sourceKeys = AnimationUtility.GetObjectReferenceCurve(sourceClip, binding);
ObjectReferenceKeyframe[] targetKeys = AnimationUtility.GetObjectReferenceCurve(targetClip, binding);
Object startValue = SampleObjectReferenceValue(sourceKeys, sourceSampleTime);
Object endValue = SampleObjectReferenceValue(targetKeys, targetSampleTime);
if (startValue == null && endValue == null)
continue;
ObjectReferenceKeyframe[] bridgeKeys = CreateBridgeObjectCurve(startValue, endValue, duration);
AnimationUtility.SetObjectReferenceCurve(bridgeClip, binding, bridgeKeys);
}
string savePath = AssetDatabase.GenerateUniqueAssetPath(
Path.Combine(outputDirectory, outputName + ".anim").Replace("\\", "/"));
AssetDatabase.CreateAsset(bridgeClip, savePath);
CopyClipSettings(sourceClip, bridgeClip, duration);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
EditorUtility.FocusProjectWindow();
Selection.activeObject = bridgeClip;
Debug.Log(
$"[AnimationBridgeClipGenerator] 브릿지 생성 완료: {sourceClip.name} -> {targetClip.name} " +
$"(앞-{sourceEndTrimFrames}f / 뒤+{targetStartTrimFrames}f / 길이 {bridgeDurationFrames}f) => {savePath}");
}
private static void AddCurveBindings(HashSet<EditorCurveBinding> destination, EditorCurveBinding[] bindings)
{
if (bindings == null)
return;
for (int i = 0; i < bindings.Length; i++)
destination.Add(bindings[i]);
}
private static float GetSampleTime(AnimationClip clip, int trimFrames, bool useEndSample)
{
if (clip == null)
return 0f;
float frameRate = Mathf.Max(1f, clip.frameRate);
int totalFrames = Mathf.Max(0, Mathf.RoundToInt(clip.length * frameRate));
int clampedTrim = Mathf.Clamp(trimFrames, 0, totalFrames);
int sampleFrame = useEndSample
? Mathf.Max(0, totalFrames - clampedTrim)
: clampedTrim;
float sampleTime = sampleFrame / frameRate;
return Mathf.Clamp(sampleTime, 0f, clip.length);
}
private static float EvaluateCurveOrDefault(AnimationCurve curve, float time)
{
return curve != null ? curve.Evaluate(time) : 0f;
}
private static AnimationCurve CreateBridgeCurve(float startValue, float endValue, float duration)
{
AnimationCurve curve = new AnimationCurve(
new Keyframe(0f, startValue),
new Keyframe(duration, endValue));
for (int i = 0; i < curve.keys.Length; i++)
{
AnimationUtility.SetKeyLeftTangentMode(curve, i, AnimationUtility.TangentMode.ClampedAuto);
AnimationUtility.SetKeyRightTangentMode(curve, i, AnimationUtility.TangentMode.ClampedAuto);
}
return curve;
}
private static Object SampleObjectReferenceValue(ObjectReferenceKeyframe[] keys, float time)
{
if (keys == null || keys.Length == 0)
return null;
Object value = keys[0].value;
for (int i = 0; i < keys.Length; i++)
{
if (keys[i].time > time)
break;
value = keys[i].value;
}
return value;
}
private static ObjectReferenceKeyframe[] CreateBridgeObjectCurve(Object startValue, Object endValue, float duration)
{
return new[]
{
new ObjectReferenceKeyframe { time = 0f, value = startValue },
new ObjectReferenceKeyframe { time = duration, value = endValue }
};
}
/// <summary>
/// 원본 클립의 루프/미러/루트 모션 설정을 유지합니다.
/// </summary>
private static void CopyClipSettings(AnimationClip source, AnimationClip target, float duration)
{
if (source == null || target == null)
return;
SerializedObject sourceSerializedObject = new SerializedObject(source);
SerializedObject targetSerializedObject = new SerializedObject(target);
SerializedProperty sourceSettings = sourceSerializedObject.FindProperty("m_AnimationClipSettings");
SerializedProperty targetSettings = targetSerializedObject.FindProperty("m_AnimationClipSettings");
if (sourceSettings == null || targetSettings == null)
return;
string[] relativePropertyNames =
{
"m_AdditiveReferencePoseClip",
"m_AdditiveReferencePoseTime",
"m_OrientationOffsetY",
"m_Level",
"m_CycleOffset",
"m_HasAdditiveReferencePose",
"m_LoopTime",
"m_LoopBlend",
"m_LoopBlendOrientation",
"m_LoopBlendPositionY",
"m_LoopBlendPositionXZ",
"m_KeepOriginalOrientation",
"m_KeepOriginalPositionY",
"m_KeepOriginalPositionXZ",
"m_HeightFromFeet",
"m_Mirror"
};
for (int i = 0; i < relativePropertyNames.Length; i++)
{
SerializedProperty sourceProperty = sourceSettings.FindPropertyRelative(relativePropertyNames[i]);
SerializedProperty targetProperty = targetSettings.FindPropertyRelative(relativePropertyNames[i]);
if (sourceProperty == null || targetProperty == null)
continue;
CopySerializedPropertyValue(sourceProperty, targetProperty);
}
SerializedProperty startTimeProperty = targetSettings.FindPropertyRelative("m_StartTime");
if (startTimeProperty != null)
startTimeProperty.floatValue = 0f;
SerializedProperty stopTimeProperty = targetSettings.FindPropertyRelative("m_StopTime");
if (stopTimeProperty != null)
stopTimeProperty.floatValue = Mathf.Max(0f, duration);
targetSerializedObject.ApplyModifiedPropertiesWithoutUndo();
}
private static void CopySerializedPropertyValue(SerializedProperty source, SerializedProperty target)
{
switch (source.propertyType)
{
case SerializedPropertyType.Integer:
target.intValue = source.intValue;
break;
case SerializedPropertyType.Boolean:
target.boolValue = source.boolValue;
break;
case SerializedPropertyType.Float:
target.floatValue = source.floatValue;
break;
case SerializedPropertyType.ObjectReference:
target.objectReferenceValue = source.objectReferenceValue;
break;
}
}
private sealed class EditorCurveBindingComparer : IEqualityComparer<EditorCurveBinding>
{
public bool Equals(EditorCurveBinding x, EditorCurveBinding y)
{
return x.path == y.path
&& x.propertyName == y.propertyName
&& x.type == y.type
&& x.isPPtrCurve == y.isPPtrCurve;
}
public int GetHashCode(EditorCurveBinding obj)
{
unchecked
{
int hash = 17;
hash = (hash * 31) + (obj.path != null ? obj.path.GetHashCode() : 0);
hash = (hash * 31) + (obj.propertyName != null ? obj.propertyName.GetHashCode() : 0);
hash = (hash * 31) + (obj.type != null ? obj.type.GetHashCode() : 0);
hash = (hash * 31) + obj.isPPtrCurve.GetHashCode();
return hash;
}
}
}
}