- SkillData: skillClip/endClip 단일 필드를 animationClips 리스트로 통합
- Data_Skill_ 접두사 애셋 이름과 Anim_{key}_{순서} 클립을 자동 매칭
- 레거시 skillClip/endClip 데이터 자동 마이그레이션
- SkillController: 클립 시퀀스 내 순차 재생 로직 (TryPlayNextClipInSequence)
- baseSkillClip을 컨트롤러 Skill state에서 OnValidate로 자동 발견
- waitingForEndAnimation / IsInEndAnimation 제거
- BuildSimulationEngine: 전체 클립 duration 합산 및 모든 클립 OnEffect 이벤트 파싱
- PlayerAbnormalityVerificationRunner: GetSkillDuration 전체 클립 길이 합산으로 변경
- EnemyBase: IsInEndAnimation 참조 제거
- AnimationClipExtractor: animationClips 리스트 기반 relink/collect로 변경
- AnimationClipSkillDataMatcher: 클립 변경 시 관련 SkillData 자동 갱신 (AssetPostprocessor)
- BaseSkillClipAssigner: 모든 컨트롤러의 Skill state에 base clip 일괄 할당 에디터 메뉴
- pre-commit hook: Anim_ 네이밍 규칙에 {순서} 패턴 추가 및 Anim_↔Data_Skill_ 매칭 검증
699 lines
26 KiB
C#
699 lines
26 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;
|
|
|
|
// animationClips 리스트의 각 요소를 remap합니다.
|
|
SerializedProperty clipsProp = so.FindProperty("animationClips");
|
|
if (clipsProp != null)
|
|
{
|
|
for (int i = 0; i < clipsProp.arraySize; i++)
|
|
{
|
|
if (TryRemapClip(clipsProp.GetArrayElementAtIndex(i), fbxToAnimMap, path, $"animationClips[{i}]"))
|
|
{
|
|
modified = true;
|
|
relinkCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 레거시 필드도 remap (마이그레이션 전 애셋 보호)
|
|
if (TryRemapClip(so.FindProperty("legacySkillClip"), fbxToAnimMap, path, "legacySkillClip"))
|
|
{
|
|
modified = true;
|
|
relinkCount++;
|
|
}
|
|
|
|
if (TryRemapClip(so.FindProperty("legacyEndClip"), fbxToAnimMap, path, "legacyEndClip"))
|
|
{
|
|
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;
|
|
|
|
IReadOnlyList<AnimationClip> skillClips = skillData.AnimationClips;
|
|
for (int i = 0; i < skillClips.Count; i++)
|
|
{
|
|
if (skillClips[i] != null) clips.Add(skillClips[i]);
|
|
}
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
}
|