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; /// /// 스킬/보스/애니메이션 컨트롤러에서 참조 중인 FBX 내장 AnimationClip을 개별 .anim으로 추출하고, /// 모든 참조를 갱신한 뒤 원본 FBX를 삭제하는 에디터 도구입니다. /// 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 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(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 삭제 // ───────────────────────────────────────────── /// /// 추출 완료된 FBX를 삭제합니다. /// {FBX이름}.anim이 동일 폴더에 존재하면, 해당 FBX의 모든 서브에셋 클립이 /// 추출된 것으로 간주하고 삭제합니다. /// /// 삭제된 FBX 수 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().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; } /// /// 지정한 클립이 프로젝트 내 어떤 에셋에서 참조되고 있는지 확인합니다. /// 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 핵심 로직 // ───────────────────────────────────────────── /// /// SkillData / BossPhaseData / AnimatorController에서 FBX 서브에셋 클립 참조를 /// 추출된 .anim 파일로 교체합니다. /// /// 갱신된 참조 수 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(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(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(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; } /// /// AnimatorController의 모든 상태에서 FBX 서브에셋 클립을 .anim으로 교체합니다. /// private static int RemapControllerClips( AnimatorController controller, Dictionary 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; } /// /// StateMachine 내의 모든 상태에서 클립 참조를 교체합니다. /// private static int RemapStateMachineClips( AnimatorStateMachine stateMachine, Dictionary 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; } /// /// AnimatorState의 Motion(AnimationClip)을 .anim으로 교체합니다. /// private static int TryRemapMotion( AnimatorState state, Dictionary 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; } /// /// FBX 경로 → 추출된 .anim AnimationClip 매핑을 구축합니다. /// private static Dictionary BuildFbxToAnimMap() { var map = new Dictionary(); 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(animPath); if (animClip != null) map[fbxPath] = animClip; } } return map; } /// /// SerializedProperty의 AnimationClip 참조가 FBX 서브에셋이면 교체합니다. /// private static bool TryRemapClip( SerializedProperty clipProperty, Dictionary 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; } // ───────────────────────────────────────────── // 참조 클립 수집 // ───────────────────────────────────────────── /// /// 프로젝트 내 SkillData, BossPhaseData, AnimatorController에서 참조 중인 AnimationClip을 수집합니다. /// private static HashSet CollectReferencedClips() { var clips = new HashSet(); // ── SkillData ── string[] skillGuids = AssetDatabase.FindAssets("t:SkillData"); foreach (string guid in skillGuids) { string path = AssetDatabase.GUIDToAssetPath(guid); SkillData skillData = AssetDatabase.LoadAssetAtPath(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(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(path); if (controller == null) continue; CollectClipsFromController(controller, clips); } return clips; } /// /// AnimatorController의 모든 상태에서 AnimationClip을 수집합니다. /// private static void CollectClipsFromController(AnimatorController controller, HashSet clips) { foreach (AnimatorControllerLayer layer in controller.layers) { if (layer.stateMachine == null) continue; CollectClipsFromStateMachine(layer.stateMachine, clips); } } /// /// StateMachine 내의 모든 상태에서 AnimationClip을 수집합니다. /// private static void CollectClipsFromStateMachine(AnimatorStateMachine stateMachine, HashSet 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); } } // ───────────────────────────────────────────── // 클립 추출 // ───────────────────────────────────────────── /// /// 소스 클립의 모든 커브와 이벤트를 복사한 독립 AnimationClip을 생성합니다. /// 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; } }