Files
Colosseum/Assets/_Game/Scripts/Editor/AnimationClipExtractor.cs
dal4segno bd99283f17 refactor: FBX 애니메이션 클립 독립 .anim 추출 및 자동 등록 시스템 구축
- FBX 내장 AnimationClip을 개별 .anim 파일로 추출하는 에디터 툴 추가 (AnimationClipExtractor)
  - 스킬/보스/AnimatorController에서 참조 중인 클립만 선택적 추출
  - 추출 후 모든 참조(SkillData, BossPhaseData, AnimatorController)를 .anim으로 자동 relink
  - 추출 완료된 FBX 자동 삭제 (참조 안전성 검증 포함)
- SkillController: registeredClips를 OnValidate에서 _Player_ 이름 기반 자동 등록
- PlayerSkillInput: skillSlots를 OnValidate에서 _Skill_Player_ 이름 기반 자동 등록
- 38개 FBX 삭제, 40+개 .anim 파일로 교체 완료
2026-04-02 11:01:50 +09:00

681 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEngine;
using UnityEditor;
using UnityEditor.Animations;
using Colosseum.Skills;
using Colosseum.Enemy;
/// <summary>
/// 스킬/보스/애니메이션 컨트롤러에서 참조 중인 FBX 내장 AnimationClip을 개별 .anim으로 추출하고,
/// 모든 참조를 갱신한 뒤 원본 FBX를 삭제하는 에디터 도구입니다.
/// </summary>
public static class AnimationClipExtractor
{
private const string MenuPathExtract = "Tools/Animation/Extract Clips from FBX";
private const string MenuPathRelink = "Tools/Animation/Relink Clips to Extracted .anim";
// ─────────────────────────────────────────────
// Extract + Relink + Delete FBX (메인 진입점)
// ─────────────────────────────────────────────
[MenuItem(MenuPathExtract)]
public static void ExtractAndRelink()
{
// ── 1. 참조 중인 클립 수집 (SkillData + BossPhaseData + AnimatorController) ──
HashSet<AnimationClip> referencedClips = CollectReferencedClips();
if (referencedClips.Count == 0)
{
EditorUtility.DisplayDialog(
"Animation Clip Extractor",
"스킬/보스/컨트롤러에서 참조 중인 AnimationClip이 없습니다.",
"확인");
return;
}
// ── 2. FBX 서브에셋인 클립만 필터링 ──
var extractPlan = new List<(string fbxPath, AnimationClip clip, string clipName, string outputPath)>();
int skipStandalone = 0;
int skipExisting = 0;
foreach (AnimationClip clip in referencedClips)
{
if (clip == null) continue;
string assetPath = AssetDatabase.GetAssetPath(clip);
if (assetPath.EndsWith(".anim", StringComparison.OrdinalIgnoreCase))
{
skipStandalone++;
continue;
}
if (!assetPath.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase))
{
skipStandalone++;
continue;
}
string fbxDir = Path.GetDirectoryName(assetPath).Replace("\\", "/");
string fbxName = Path.GetFileNameWithoutExtension(assetPath);
string outputPath = $"{fbxDir}/{fbxName}.anim";
if (AssetDatabase.AssetPathToGUID(outputPath) != string.Empty)
{
skipExisting++;
continue;
}
extractPlan.Add((assetPath, clip, fbxName, outputPath));
}
// ── 3. 요약 ──
string summary = $"참조 중인 클립: {referencedClips.Count}개\n"
+ $"추출 대상: {extractPlan.Count}개\n";
if (skipStandalone > 0)
summary += $"이미 독립 파일: {skipStandalone}개 (건너뜀)\n";
if (skipExisting > 0)
summary += $"이미 추출됨: {skipExisting}개 (건너뜀)\n";
if (extractPlan.Count == 0)
{
bool doRelink = EditorUtility.DisplayDialog(
"Animation Clip Extractor",
$"{summary}\n추출할 클립이 없습니다.\n\n기존 추출된 .anim으로 참조를 갱신하시겠습니까?",
"Relink", "취소");
if (doRelink)
{
int relinked = PerformRelink();
PerformDeleteFbx();
EditorUtility.DisplayDialog("Animation Clip Extractor",
$"Relink: {relinked}개 참조 갱신", "확인");
}
return;
}
summary += "\n── 추출 대상 ──\n";
foreach (var (fbxPath, clip, clipName, _) in extractPlan)
summary += $" {clip.name} → {clipName}.anim\n";
// 삭제될 FBX 목록 (중복 제거)
var fbxsToDelete = new HashSet<string>(extractPlan.Select(e => e.fbxPath));
summary += $"\n── 삭제될 FBX ({fbxsToDelete.Count}개) ──\n";
foreach (string fbx in fbxsToDelete.OrderBy(p => p))
summary += $" {Path.GetFileName(fbx)}\n";
summary += "\n추출 → 참조 갱신 → FBX 삭제 순서로 진행합니다.";
if (!EditorUtility.DisplayDialog(
"Animation Clip Extractor",
$"{summary}\n진행하시겠습니까?",
"진행", "취소"))
{
return;
}
// ── 4. 클립 추출 ──
AssetDatabase.StartAssetEditing();
int successCount = 0;
int errorCount = 0;
try
{
foreach (var (fbxPath, sourceClip, clipName, outputPath) in extractPlan)
{
try
{
AnimationClip extractedClip = ExtractClip(sourceClip, clipName);
string savePath = AssetDatabase.GenerateUniqueAssetPath(outputPath);
AssetDatabase.CreateAsset(extractedClip, savePath);
Debug.Log($"[AnimationClipExtractor] 추출: {sourceClip.name} → {savePath}");
successCount++;
}
catch (Exception e)
{
Debug.LogError($"[AnimationClipExtractor] 추출 실패: {sourceClip.name} ({fbxPath})\n{e}");
errorCount++;
}
}
}
finally
{
AssetDatabase.StopAssetEditing();
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
// ── 5. Relink (모든 참조 소스) ──
int relinkCount = PerformRelink();
// ── 6. FBX 삭제 ──
int deletedCount = PerformDeleteFbx();
// ── 7. 결과 ──
string resultMsg = $"추출: {successCount}개 성공";
if (errorCount > 0) resultMsg += $", {errorCount}개 실패";
resultMsg += $"\nRelink: {relinkCount}개 참조 갱신";
resultMsg += $"\nFBX 삭제: {deletedCount}개";
Debug.Log($"[AnimationClipExtractor] {resultMsg}");
EditorUtility.DisplayDialog("Animation Clip Extractor", resultMsg, "확인");
}
[MenuItem(MenuPathExtract, true)]
public static bool ValidateExtract()
{
return !EditorApplication.isPlayingOrWillChangePlaymode;
}
// ─────────────────────────────────────────────
// Relink Only (별도 메뉴)
// ─────────────────────────────────────────────
[MenuItem(MenuPathRelink)]
public static void RelinkOnly()
{
int count = PerformRelink();
PerformDeleteFbx();
EditorUtility.DisplayDialog(
"Animation Clip Relink",
$"{count}개 참조를 갱신했습니다.",
"확인");
}
[MenuItem(MenuPathRelink, true)]
public static bool ValidateRelink()
{
return !EditorApplication.isPlayingOrWillChangePlaymode;
}
// ─────────────────────────────────────────────
// FBX 삭제
// ─────────────────────────────────────────────
/// <summary>
/// 추출 완료된 FBX를 삭제합니다.
/// {FBX이름}.anim이 동일 폴더에 존재하면, 해당 FBX의 모든 서브에셋 클립이
/// 추출된 것으로 간주하고 삭제합니다.
/// </summary>
/// <returns>삭제된 FBX 수</returns>
private static int PerformDeleteFbx()
{
var fbxToAnimMap = BuildFbxToAnimMap();
int deletedCount = 0;
AssetDatabase.StartAssetEditing();
try
{
foreach (var (fbxPath, _) in fbxToAnimMap)
{
// FBX 내의 서브에셋 클립이 모두 .anim으로 교체되었는지 확인
UnityEngine.Object[] subAssets = AssetDatabase.LoadAllAssetsAtPath(fbxPath);
AnimationClip[] clips = subAssets.OfType<AnimationClip>().ToArray();
if (clips.Length == 0)
continue;
// 모든 클립이 추출된 .anim으로 relink되었는지 확인
bool allRelinked = true;
foreach (AnimationClip clip in clips)
{
string clipAssetPath = AssetDatabase.GetAssetPath(clip);
// FBX 서브에셋 클립이 여전히 FBX에만 있는지 확인
if (clipAssetPath == fbxPath)
{
// 이 클립이 누군가 참조하고 있는지 확인
if (IsClipReferencedByAnyAsset(clip))
{
allRelinked = false;
break;
}
}
}
if (!allRelinked)
{
Debug.LogWarning($"[AnimationClipExtractor] FBX 삭제 스킵 (아직 참조 중인 클립이 있음): {fbxPath}");
continue;
}
AssetDatabase.DeleteAsset(fbxPath);
Debug.Log($"[AnimationClipExtractor] FBX 삭제: {fbxPath}");
deletedCount++;
}
}
finally
{
AssetDatabase.StopAssetEditing();
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
return deletedCount;
}
/// <summary>
/// 지정한 클립이 프로젝트 내 어떤 에셋에서 참조되고 있는지 확인합니다.
/// </summary>
private static bool IsClipReferencedByAnyAsset(AnimationClip clip)
{
if (clip == null) return false;
string clipPath = AssetDatabase.GetAssetPath(clip);
int clipInstanceId = clip.GetInstanceID();
// SkillData에서 참조 확인
string[] skillGuids = AssetDatabase.FindAssets("t:SkillData");
foreach (string guid in skillGuids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
string[] deps = AssetDatabase.GetDependencies(path, false);
if (deps.Contains(clipPath))
return true;
}
// BossPhaseData에서 참조 확인
string[] phaseGuids = AssetDatabase.FindAssets("t:BossPhaseData");
foreach (string guid in phaseGuids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
string[] deps = AssetDatabase.GetDependencies(path, false);
if (deps.Contains(clipPath))
return true;
}
// AnimatorController에서 참조 확인
string[] controllerGuids = AssetDatabase.FindAssets("t:AnimatorController", new[] { "Assets/_Game" });
foreach (string guid in controllerGuids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
string[] deps = AssetDatabase.GetDependencies(path, false);
if (deps.Contains(clipPath))
return true;
}
return false;
}
// ─────────────────────────────────────────────
// Relink 핵심 로직
// ─────────────────────────────────────────────
/// <summary>
/// SkillData / BossPhaseData / AnimatorController에서 FBX 서브에셋 클립 참조를
/// 추출된 .anim 파일로 교체합니다.
/// </summary>
/// <returns>갱신된 참조 수</returns>
private static int PerformRelink()
{
var fbxToAnimMap = BuildFbxToAnimMap();
if (fbxToAnimMap.Count == 0)
{
Debug.LogWarning("[AnimationClipExtractor] 매핑 가능한 FBX→.anim 쌍이 없습니다.");
return 0;
}
int relinkCount = 0;
AssetDatabase.StartAssetEditing();
try
{
// ── SkillData relink ──
string[] skillGuids = AssetDatabase.FindAssets("t:SkillData");
foreach (string guid in skillGuids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
SkillData skillData = AssetDatabase.LoadAssetAtPath<SkillData>(path);
if (skillData == null) continue;
SerializedObject so = new SerializedObject(skillData);
bool modified = false;
if (TryRemapClip(so.FindProperty("skillClip"), fbxToAnimMap, path, "skillClip"))
{
modified = true;
relinkCount++;
}
if (TryRemapClip(so.FindProperty("endClip"), fbxToAnimMap, path, "endClip"))
{
modified = true;
relinkCount++;
}
if (modified)
so.ApplyModifiedProperties();
}
// ── BossPhaseData relink ──
string[] phaseGuids = AssetDatabase.FindAssets("t:BossPhaseData");
foreach (string guid in phaseGuids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
BossPhaseData phaseData = AssetDatabase.LoadAssetAtPath<BossPhaseData>(path);
if (phaseData == null) continue;
SerializedObject so = new SerializedObject(phaseData);
if (TryRemapClip(so.FindProperty("phaseStartAnimation"), fbxToAnimMap, path, "phaseStartAnimation"))
{
so.ApplyModifiedProperties();
relinkCount++;
}
}
// ── AnimatorController relink ──
string[] controllerGuids = AssetDatabase.FindAssets("t:AnimatorController", new[] { "Assets/_Game" });
foreach (string guid in controllerGuids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
AnimatorController controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(path);
if (controller == null) continue;
int remapped = RemapControllerClips(controller, fbxToAnimMap, path);
if (remapped > 0)
{
EditorUtility.SetDirty(controller);
relinkCount += remapped;
}
}
}
finally
{
AssetDatabase.StopAssetEditing();
AssetDatabase.SaveAssets();
}
if (relinkCount > 0)
Debug.Log($"[AnimationClipExtractor] Relink 완료: {relinkCount}개 참조 갱신됨");
return relinkCount;
}
/// <summary>
/// AnimatorController의 모든 상태에서 FBX 서브에셋 클립을 .anim으로 교체합니다.
/// </summary>
private static int RemapControllerClips(
AnimatorController controller,
Dictionary<string, AnimationClip> fbxToAnimMap,
string controllerPath)
{
int count = 0;
// 컨트롤러의 모든 StateMachine과 State를 순회
foreach (AnimatorControllerLayer layer in controller.layers)
{
if (layer.stateMachine == null) continue;
count += RemapStateMachineClips(layer.stateMachine, fbxToAnimMap, controllerPath);
}
return count;
}
/// <summary>
/// StateMachine 내의 모든 상태에서 클립 참조를 교체합니다.
/// </summary>
private static int RemapStateMachineClips(
AnimatorStateMachine stateMachine,
Dictionary<string, AnimationClip> fbxToAnimMap,
string controllerPath)
{
int count = 0;
// 일반 State
foreach (ChildAnimatorState childState in stateMachine.states)
{
count += TryRemapMotion(childState.state, fbxToAnimMap, controllerPath);
}
// BlendTree (하위 상태 머신 포함)
foreach (ChildAnimatorStateMachine childSm in stateMachine.stateMachines)
{
count += TryRemapMotion(childSm.stateMachine.states[0].state, fbxToAnimMap, controllerPath);
// 하위 StateMachine 재귀
if (childSm.stateMachine != null)
count += RemapStateMachineClips(childSm.stateMachine, fbxToAnimMap, controllerPath);
}
return count;
}
/// <summary>
/// AnimatorState의 Motion(AnimationClip)을 .anim으로 교체합니다.
/// </summary>
private static int TryRemapMotion(
AnimatorState state,
Dictionary<string, AnimationClip> fbxToAnimMap,
string controllerPath)
{
if (state == null || state.motion == null)
return 0;
AnimationClip currentClip = state.motion as AnimationClip;
if (currentClip == null)
return 0;
string clipPath = AssetDatabase.GetAssetPath(currentClip);
// 이미 .anim이거나 FBX 서브에셋이 아니면 스킵
if (clipPath.EndsWith(".anim", StringComparison.OrdinalIgnoreCase))
return 0;
if (!clipPath.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase))
return 0;
if (!fbxToAnimMap.TryGetValue(clipPath, out AnimationClip replacementClip))
return 0;
state.motion = replacementClip;
Debug.Log($"[AnimationClipExtractor] Controller Relink: {Path.GetFileName(controllerPath)}"
+ $" | {currentClip.name} → {replacementClip.name}");
return 1;
}
/// <summary>
/// FBX 경로 → 추출된 .anim AnimationClip 매핑을 구축합니다.
/// </summary>
private static Dictionary<string, AnimationClip> BuildFbxToAnimMap()
{
var map = new Dictionary<string, AnimationClip>();
string[] animGuids = AssetDatabase.FindAssets("t:AnimationClip", new[] { "Assets/_Game" });
foreach (string guid in animGuids)
{
string animPath = AssetDatabase.GUIDToAssetPath(guid);
if (!animPath.EndsWith(".anim", StringComparison.OrdinalIgnoreCase))
continue;
string animName = Path.GetFileNameWithoutExtension(animPath);
string fbxPath = $"{Path.GetDirectoryName(animPath).Replace("\\", "/")}/{animName}.fbx";
if (AssetDatabase.AssetPathToGUID(fbxPath) != string.Empty)
{
AnimationClip animClip = AssetDatabase.LoadAssetAtPath<AnimationClip>(animPath);
if (animClip != null)
map[fbxPath] = animClip;
}
}
return map;
}
/// <summary>
/// SerializedProperty의 AnimationClip 참조가 FBX 서브에셋이면 교체합니다.
/// </summary>
private static bool TryRemapClip(
SerializedProperty clipProperty,
Dictionary<string, AnimationClip> fbxToAnimMap,
string assetPath,
string fieldName)
{
if (clipProperty == null || clipProperty.propertyType != SerializedPropertyType.ObjectReference)
return false;
AnimationClip currentClip = clipProperty.objectReferenceValue as AnimationClip;
if (currentClip == null) return false;
string clipAssetPath = AssetDatabase.GetAssetPath(currentClip);
if (clipAssetPath.EndsWith(".anim", StringComparison.OrdinalIgnoreCase))
return false;
if (!clipAssetPath.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase))
return false;
if (!fbxToAnimMap.TryGetValue(clipAssetPath, out AnimationClip replacementClip))
return false;
clipProperty.objectReferenceValue = replacementClip;
Debug.Log($"[AnimationClipExtractor] Relink: {Path.GetFileName(assetPath)}.{fieldName}"
+ $" | {currentClip.name} → {replacementClip.name}");
return true;
}
// ─────────────────────────────────────────────
// 참조 클립 수집
// ─────────────────────────────────────────────
/// <summary>
/// 프로젝트 내 SkillData, BossPhaseData, AnimatorController에서 참조 중인 AnimationClip을 수집합니다.
/// </summary>
private static HashSet<AnimationClip> CollectReferencedClips()
{
var clips = new HashSet<AnimationClip>();
// ── SkillData ──
string[] skillGuids = AssetDatabase.FindAssets("t:SkillData");
foreach (string guid in skillGuids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
SkillData skillData = AssetDatabase.LoadAssetAtPath<SkillData>(path);
if (skillData == null) continue;
if (skillData.SkillClip != null) clips.Add(skillData.SkillClip);
if (skillData.EndClip != null) clips.Add(skillData.EndClip);
}
// ── BossPhaseData ──
string[] phaseGuids = AssetDatabase.FindAssets("t:BossPhaseData");
foreach (string guid in phaseGuids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
BossPhaseData phaseData = AssetDatabase.LoadAssetAtPath<BossPhaseData>(path);
if (phaseData == null) continue;
if (phaseData.PhaseStartAnimation != null) clips.Add(phaseData.PhaseStartAnimation);
}
// ── AnimatorController ──
string[] controllerGuids = AssetDatabase.FindAssets("t:AnimatorController", new[] { "Assets/_Game" });
foreach (string guid in controllerGuids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
AnimatorController controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(path);
if (controller == null) continue;
CollectClipsFromController(controller, clips);
}
return clips;
}
/// <summary>
/// AnimatorController의 모든 상태에서 AnimationClip을 수집합니다.
/// </summary>
private static void CollectClipsFromController(AnimatorController controller, HashSet<AnimationClip> clips)
{
foreach (AnimatorControllerLayer layer in controller.layers)
{
if (layer.stateMachine == null) continue;
CollectClipsFromStateMachine(layer.stateMachine, clips);
}
}
/// <summary>
/// StateMachine 내의 모든 상태에서 AnimationClip을 수집합니다.
/// </summary>
private static void CollectClipsFromStateMachine(AnimatorStateMachine stateMachine, HashSet<AnimationClip> clips)
{
foreach (ChildAnimatorState childState in stateMachine.states)
{
if (childState.state?.motion is AnimationClip clip)
clips.Add(clip);
}
foreach (ChildAnimatorStateMachine childSm in stateMachine.stateMachines)
{
if (childSm.stateMachine != null)
CollectClipsFromStateMachine(childSm.stateMachine, clips);
}
}
// ─────────────────────────────────────────────
// 클립 추출
// ─────────────────────────────────────────────
/// <summary>
/// 소스 클립의 모든 커브와 이벤트를 복사한 독립 AnimationClip을 생성합니다.
/// </summary>
private static AnimationClip ExtractClip(AnimationClip source, string clipName)
{
AnimationClip extracted = new AnimationClip
{
name = clipName,
frameRate = source.frameRate,
legacy = source.legacy,
wrapMode = source.wrapMode,
localBounds = source.localBounds
};
EditorCurveBinding[] bindings = AnimationUtility.GetCurveBindings(source);
foreach (EditorCurveBinding binding in bindings)
{
AnimationCurve curve = AnimationUtility.GetEditorCurve(source, binding);
if (curve == null || curve.keys.Length == 0)
continue;
extracted.SetCurve(binding.path, binding.type, binding.propertyName, curve);
}
AnimationEvent[] events = AnimationUtility.GetAnimationEvents(source);
if (events.Length > 0)
{
AnimationEvent[] copiedEvents = new AnimationEvent[events.Length];
for (int i = 0; i < events.Length; i++)
{
copiedEvents[i] = new AnimationEvent
{
time = events[i].time,
functionName = events[i].functionName,
floatParameter = events[i].floatParameter,
intParameter = events[i].intParameter,
stringParameter = events[i].stringParameter,
objectReferenceParameter = events[i].objectReferenceParameter,
messageOptions = events[i].messageOptions
};
}
AnimationUtility.SetAnimationEvents(extracted, copiedEvents);
}
return extracted;
}
}