diff --git a/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs index 4367eb53..304d6e07 100644 --- a/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs +++ b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs @@ -257,15 +257,14 @@ namespace Colosseum.Combat.Simulation }; float resolvedAnimationSpeed = loadoutEntry.GetResolvedAnimationSpeed(); - float mainClipDuration = ResolveClipDuration(skill.SkillClip, resolvedAnimationSpeed); - float endClipDuration = ResolveClipDuration(skill.EndClip, 1f); + float totalClipDuration = ResolveTotalClipDuration(skill.AnimationClips, resolvedAnimationSpeed); int repeatCount = loadoutEntry.GetResolvedRepeatCount(); - snapshot.castDuration = Mathf.Max(MinimumActionDuration, (mainClipDuration * repeatCount) + endClipDuration + ruleSet.MovementLossSecondsPerCast); + snapshot.castDuration = Mathf.Max(MinimumActionDuration, (totalClipDuration * repeatCount) + ruleSet.MovementLossSecondsPerCast); Dictionary> effectMap = new Dictionary>(); loadoutEntry.CollectTriggeredEffects(effectMap); - BuildDamageEvents(snapshot, effectMap, context, weaponDamageMultiplier, ruleSet, mainClipDuration, resolvedAnimationSpeed, repeatCount, warnings); + BuildDamageEvents(snapshot, effectMap, context, weaponDamageMultiplier, ruleSet, totalClipDuration, resolvedAnimationSpeed, repeatCount, warnings); snapshots[slotIndex] = snapshot; } @@ -278,7 +277,7 @@ namespace Colosseum.Combat.Simulation SimulationContext context, float weaponDamageMultiplier, SimulationRuleSet ruleSet, - float mainClipDuration, + float totalClipDuration, float resolvedAnimationSpeed, int repeatCount, List warnings) @@ -286,15 +285,30 @@ namespace Colosseum.Combat.Simulation if (snapshot == null || effectMap == null || effectMap.Count == 0) return; + // 모든 클립에서 OnEffect 이벤트를 수집합니다. List effectEvents = new List(); - AnimationClip clip = snapshot.skill.SkillClip; - if (clip != null) + IReadOnlyList clips = snapshot.skill.AnimationClips; + if (clips != null) { - AnimationEvent[] clipEvents = clip.events; - for (int i = 0; i < clipEvents.Length; i++) + float timeOffset = 0f; + for (int clipIndex = 0; clipIndex < clips.Count; clipIndex++) { - if (string.Equals(clipEvents[i].functionName, "OnEffect", StringComparison.Ordinal)) - effectEvents.Add(clipEvents[i]); + AnimationClip clip = clips[clipIndex]; + if (clip == null) continue; + + AnimationEvent[] clipEvents = clip.events; + for (int i = 0; i < clipEvents.Length; i++) + { + if (string.Equals(clipEvents[i].functionName, "OnEffect", StringComparison.Ordinal)) + { + // 이벤트 시간에 이전 클립들의 누적 길이를 더합니다. + AnimationEvent offsetEvent = clipEvents[i]; + offsetEvent.time += timeOffset; + effectEvents.Add(offsetEvent); + } + } + + timeOffset += clip.length; } } @@ -303,7 +317,7 @@ namespace Colosseum.Combat.Simulation for (int iteration = 0; iteration < repeatCount; iteration++) { - float iterationOffset = mainClipDuration * iteration; + float iterationOffset = totalClipDuration * iteration; for (int eventIndex = 0; eventIndex < effectEvents.Count; eventIndex++) { @@ -546,6 +560,23 @@ namespace Colosseum.Combat.Simulation return clip.length / Mathf.Max(0.05f, speed); } + /// + /// 클립 목록 전체의 재생 시간을 합산합니다. + /// + private static float ResolveTotalClipDuration(IReadOnlyList clips, float speed) + { + if (clips == null || clips.Count == 0) + return 0f; + + float total = 0f; + for (int i = 0; i < clips.Count; i++) + { + total += ResolveClipDuration(clips[i], speed); + } + + return total; + } + private static List CollectValidPrioritySlots(RotationPolicy rotationPolicy, SkillRuntimeSnapshot[] snapshots) { List validSlots = new List(); diff --git a/Assets/_Game/Scripts/Editor/AnimationClipExtractor.cs b/Assets/_Game/Scripts/Editor/AnimationClipExtractor.cs index a7f5ad3f..34488290 100644 --- a/Assets/_Game/Scripts/Editor/AnimationClipExtractor.cs +++ b/Assets/_Game/Scripts/Editor/AnimationClipExtractor.cs @@ -345,13 +345,28 @@ public static class AnimationClipExtractor SerializedObject so = new SerializedObject(skillData); bool modified = false; - if (TryRemapClip(so.FindProperty("skillClip"), fbxToAnimMap, path, "skillClip")) + // 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("endClip"), fbxToAnimMap, path, "endClip")) + if (TryRemapClip(so.FindProperty("legacyEndClip"), fbxToAnimMap, path, "legacyEndClip")) { modified = true; relinkCount++; @@ -567,8 +582,11 @@ public static class AnimationClipExtractor 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); + IReadOnlyList skillClips = skillData.AnimationClips; + for (int i = 0; i < skillClips.Count; i++) + { + if (skillClips[i] != null) clips.Add(skillClips[i]); + } } // ── BossPhaseData ── diff --git a/Assets/_Game/Scripts/Editor/AnimationClipSkillDataMatcher.cs b/Assets/_Game/Scripts/Editor/AnimationClipSkillDataMatcher.cs new file mode 100644 index 00000000..e45e5c44 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/AnimationClipSkillDataMatcher.cs @@ -0,0 +1,101 @@ +using System; + +using UnityEditor; + +using Colosseum.Skills; + +namespace Colosseum.Editor +{ + /// + /// Animations 폴더 내 애니메이션 클립이 변경(생성/수정/삭제/이동)되면 + /// 관련 SkillData의 애니메이션 클립 목록을 자동 갱신합니다. + /// + public class AnimationClipSkillDataMatcher : AssetPostprocessor + { + private const string AnimationsFolderPath = "Assets/_Game/Animations"; + + private static void OnPostprocessAllAssets( + string[] importedAssets, + string[] deletedAssets, + string[] movedAssets, + string[] movedFromAssetPaths) + { + bool hasAnimationChange = false; + + for (int i = 0; i < importedAssets.Length; i++) + { + if (IsAnimationClipPath(importedAssets[i])) + { + hasAnimationChange = true; + break; + } + } + + if (!hasAnimationChange) + { + for (int i = 0; i < deletedAssets.Length; i++) + { + if (IsAnimationClipPath(deletedAssets[i])) + { + hasAnimationChange = true; + break; + } + } + } + + if (!hasAnimationChange) + { + for (int i = 0; i < movedAssets.Length; i++) + { + if (IsAnimationClipPath(movedAssets[i]) || IsAnimationClipPath(movedFromAssetPaths[i])) + { + hasAnimationChange = true; + break; + } + } + } + + if (!hasAnimationChange) + return; + + RefreshAllSkillDataClips(); + } + + /// + /// 지정한 경로가 Animations 폴더 내의 애니메이션 클립인지 확인합니다. + /// + private static bool IsAnimationClipPath(string assetPath) + { + return assetPath != null + && assetPath.StartsWith(AnimationsFolderPath) + && assetPath.EndsWith(".anim", StringComparison.OrdinalIgnoreCase); + } + + /// + /// 모든 SkillData의 애니메이션 클립 목록을 갱신합니다. + /// + private static void RefreshAllSkillDataClips() + { + string[] skillGuids = AssetDatabase.FindAssets("t:SkillData"); + int refreshedCount = 0; + + for (int i = 0; i < skillGuids.Length; i++) + { + string path = AssetDatabase.GUIDToAssetPath(skillGuids[i]); + SkillData skillData = AssetDatabase.LoadAssetAtPath(path); + if (skillData == null) continue; + + int clipCountBefore = skillData.AnimationClips.Count; + skillData.RefreshAnimationClips(); + + if (skillData.AnimationClips.Count != clipCountBefore) + refreshedCount++; + } + + if (refreshedCount > 0) + { + AssetDatabase.SaveAssets(); + } + } + } +} diff --git a/Assets/_Game/Scripts/Editor/AnimationClipSkillDataMatcher.cs.meta b/Assets/_Game/Scripts/Editor/AnimationClipSkillDataMatcher.cs.meta new file mode 100644 index 00000000..9fd87ea3 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/AnimationClipSkillDataMatcher.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c4524bdb16f89724b96d588200b8a3e8 \ No newline at end of file diff --git a/Assets/_Game/Scripts/Editor/BaseSkillClipAssigner.cs b/Assets/_Game/Scripts/Editor/BaseSkillClipAssigner.cs new file mode 100644 index 00000000..71f035fb --- /dev/null +++ b/Assets/_Game/Scripts/Editor/BaseSkillClipAssigner.cs @@ -0,0 +1,103 @@ +using System; + +using UnityEditor; +using UnityEditor.Animations; + +using UnityEngine; + +namespace Colosseum.Editor +{ + /// + /// AnimatorController의 Skill 상태에 base clip을 일괄 할당하는 에디터 도구입니다. + /// + public static class BaseSkillClipAssigner + { + private const string SkillStateName = "Skill"; + + /// + /// 선택한 애니메이션 클립을 모든 AnimatorController의 Skill 상태에 할당합니다. + /// + [MenuItem("Tools/Animation/Assign Base Skill Clip to All Controllers")] + private static void AssignBaseClipToAllControllers() + { + AnimationClip selectedClip = Selection.activeObject as AnimationClip; + if (selectedClip == null) + { + EditorUtility.DisplayDialog( + "Base Skill Clip 할당", + "Animations 폴더에서 할당할 AnimationClip을 선택한 후 다시 실행하세요.", + "확인"); + return; + } + + string[] controllerGuids = AssetDatabase.FindAssets("t:AnimatorController", new[] { "Assets/_Game" }); + int assignedCount = 0; + + for (int i = 0; i < controllerGuids.Length; i++) + { + string path = AssetDatabase.GUIDToAssetPath(controllerGuids[i]); + AnimatorController ac = AssetDatabase.LoadAssetAtPath(path); + if (ac == null) continue; + + if (TryAssignClipToState(ac, SkillStateName, selectedClip)) + assignedCount++; + } + + if (assignedCount > 0) + { + AssetDatabase.SaveAssets(); + EditorUtility.DisplayDialog( + "Base Skill Clip 할당 완료", + $"{selectedClip.name}을(를) {assignedCount}개 컨트롤러의 Skill 상태에 할당했습니다.", + "확인"); + } + else + { + EditorUtility.DisplayDialog( + "Base Skill Clip 할당", + "Skill 상태를 가진 컨트롤러를 찾지 못했습니다.", + "확인"); + } + } + + /// + /// AnimatorController의 지정한 상태에 클립을 할당합니다. + /// + private static bool TryAssignClipToState(AnimatorController ac, string stateName, AnimationClip clip) + { + for (int i = 0; i < ac.layers.Length; i++) + { + if (TryAssignClipInStateMachine(ac.layers[i].stateMachine, stateName, clip)) + { + EditorUtility.SetDirty(ac); + return true; + } + } + + return false; + } + + /// + /// StateMachine을 재귀적으로 탐색하여 지정한 이름의 상태에 클립을 할당합니다. + /// + private static bool TryAssignClipInStateMachine(AnimatorStateMachine sm, string stateName, AnimationClip clip) + { + for (int i = 0; i < sm.states.Length; i++) + { + if (sm.states[i].state.name == stateName) + { + sm.states[i].state.motion = clip; + return true; + } + } + + for (int i = 0; i < sm.stateMachines.Length; i++) + { + if (TryAssignClipInStateMachine(sm.stateMachines[i].stateMachine, stateName, clip)) + return true; + } + + return false; + } + } +} diff --git a/Assets/_Game/Scripts/Editor/BaseSkillClipAssigner.cs.meta b/Assets/_Game/Scripts/Editor/BaseSkillClipAssigner.cs.meta new file mode 100644 index 00000000..7c300800 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/BaseSkillClipAssigner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1a6c131eb50620a47ab0250c9d46d438 \ No newline at end of file diff --git a/Assets/_Game/Scripts/Enemy/EnemyBase.cs b/Assets/_Game/Scripts/Enemy/EnemyBase.cs index 74fb027f..b3df69e8 100644 --- a/Assets/_Game/Scripts/Enemy/EnemyBase.cs +++ b/Assets/_Game/Scripts/Enemy/EnemyBase.cs @@ -178,7 +178,6 @@ namespace Colosseum.Enemy var skillCtrl = GetComponent(); bool needsYMotion = skillCtrl != null && skillCtrl.IsPlayingAnimation - && !skillCtrl.IsInEndAnimation && skillCtrl.UsesRootMotion && !skillCtrl.IgnoreRootMotionY; diff --git a/Assets/_Game/Scripts/Player/PlayerAbnormalityVerificationRunner.cs b/Assets/_Game/Scripts/Player/PlayerAbnormalityVerificationRunner.cs index cbc54fa2..4034cb1b 100644 --- a/Assets/_Game/Scripts/Player/PlayerAbnormalityVerificationRunner.cs +++ b/Assets/_Game/Scripts/Player/PlayerAbnormalityVerificationRunner.cs @@ -394,10 +394,18 @@ namespace Colosseum.Player private float GetSkillDuration(SkillData skill) { - if (skill == null || skill.SkillClip == null) + if (skill == null || skill.AnimationClips.Count == 0) return settleDelay; - return Mathf.Max(settleDelay, skill.SkillClip.length / Mathf.Max(0.1f, skill.AnimationSpeed)); + float totalLength = 0f; + var clips = skill.AnimationClips; + for (int i = 0; i < clips.Count; i++) + { + if (clips[i] != null) + totalLength += clips[i].length; + } + + return Mathf.Max(settleDelay, totalLength / Mathf.Max(0.1f, skill.AnimationSpeed)); } private SkillData FindCancellableSkill() diff --git a/Assets/_Game/Scripts/Skills/SkillController.cs b/Assets/_Game/Scripts/Skills/SkillController.cs index 0427d3c9..b709320f 100644 --- a/Assets/_Game/Scripts/Skills/SkillController.cs +++ b/Assets/_Game/Scripts/Skills/SkillController.cs @@ -35,13 +35,12 @@ namespace Colosseum.Skills public class SkillController : NetworkBehaviour { private const string SKILL_STATE_NAME = "Skill"; - private const string END_STATE_NAME = "SkillEnd"; [Header("애니메이션")] [SerializeField] private Animator animator; [Tooltip("기본 Animator Controller (스킬 종료 후 복원용)")] [SerializeField] private RuntimeAnimatorController baseController; - [Tooltip("Skill 상태에 연결된 기본 클립 (Override용)")] + [Tooltip("Skill 상태에 연결된 기본 클립 (Override용). baseController의 Skill state에서 자동 발견됩니다.")] [SerializeField] private AnimationClip baseSkillClip; [Header("네트워크 동기화")] @@ -71,7 +70,7 @@ namespace Colosseum.Skills private readonly List currentCastStartAbnormalities = new(); private readonly Dictionary> currentTriggeredAbnormalities = new(); private readonly List currentTriggeredTargetsBuffer = new(); - private bool waitingForEndAnimation; // EndAnimation 종료 대기 중 + private int currentClipSequenceIndex; // 현재 재생 중인 클립 순서 (animationClips 내 인덱스) private int currentRepeatCount = 1; private int currentIterationIndex = 0; private GameObject currentTargetOverride; @@ -82,7 +81,6 @@ namespace Colosseum.Skills public bool IsExecutingSkill => currentSkill != null; public bool IsPlayingAnimation => currentSkill != null; - public bool IsInEndAnimation => waitingForEndAnimation; public bool UsesRootMotion => currentSkill != null && currentSkill.UseRootMotion; public bool IgnoreRootMotionY => currentSkill != null && currentSkill.IgnoreRootMotionY; public SkillData CurrentSkill => currentSkill; @@ -112,9 +110,63 @@ namespace Colosseum.Skills #if UNITY_EDITOR private void OnValidate() { + AutoDiscoverBaseSkillClip(); AutoRegisterClips(); } + /// + /// baseController의 Skill 상태에 연결된 클립을 baseSkillClip으로 자동 발견합니다. + /// + private void AutoDiscoverBaseSkillClip() + { + if (baseSkillClip != null) return; + if (baseController == null) return; + + var ac = baseController as UnityEditor.Animations.AnimatorController; + if (ac == null) return; + + AnimationClip foundClip = FindClipInState(ac, SKILL_STATE_NAME); + if (foundClip != null) + { + baseSkillClip = foundClip; + UnityEditor.EditorUtility.SetDirty(this); + } + } + + /// + /// AnimatorController의 지정한 상태에 연결된 AnimationClip을 찾습니다. + /// + private static AnimationClip FindClipInState(UnityEditor.Animations.AnimatorController ac, string stateName) + { + for (int i = 0; i < ac.layers.Length; i++) + { + AnimationClip clip = FindClipInStateMachine(ac.layers[i].stateMachine, stateName); + if (clip != null) return clip; + } + + return null; + } + + /// + /// StateMachine을 재귀적으로 탐색하여 지정한 이름의 상태에서 클립을 찾습니다. + /// + private static AnimationClip FindClipInStateMachine(UnityEditor.Animations.AnimatorStateMachine sm, string stateName) + { + for (int i = 0; i < sm.states.Length; i++) + { + if (sm.states[i].state.name == stateName && sm.states[i].state.motion is AnimationClip clip) + return clip; + } + + for (int i = 0; i < sm.stateMachines.Length; i++) + { + AnimationClip clip = FindClipInStateMachine(sm.stateMachines[i].stateMachine, stateName); + if (clip != null) return clip; + } + + return null; + } + /// /// clipAutoRegisterFilter 이름이 포함된 모든 AnimationClip을 registeredClips에 자동 등록합니다. /// 서버→클라이언트 클립 동기화 인덱스의 일관성을 위해 이름순으로 정렬합니다. @@ -173,38 +225,21 @@ namespace Colosseum.Skills var stateInfo = animator.GetCurrentAnimatorStateInfo(0); - // EndAnimation 종료 감지 - if (waitingForEndAnimation) - { - if (stateInfo.normalizedTime >= 1f) - { - if (debugMode) Debug.Log($"[Skill] EndAnimation complete: {currentSkill.SkillName}"); - RestoreBaseController(); - ClearCurrentSkillState(); - } - return; - } - - // 애니메이션 종료 시 처리 (OnSkillEnd 여부와 관계없이 애니메이션 끝까지 재생) + // 애니메이션 종료 시 처리 if (stateInfo.normalizedTime >= 1f) { + // 같은 반복 차수 내에서 다음 클립이 있으면 재생 + if (TryPlayNextClipInSequence()) + return; + + // 다음 반복 차수가 있으면 시작 if (TryStartNextIteration()) return; - if (currentSkill.EndClip != null) - { - // EndAnimation 재생 후 종료 대기 - if (debugMode) Debug.Log($"[Skill] SkillAnimation done, playing EndAnimation: {currentSkill.SkillName}"); - PlayEndClip(currentSkill.EndClip); - waitingForEndAnimation = true; - } - else - { - // EndAnimation 없으면 바로 종료 - if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}"); - RestoreBaseController(); - ClearCurrentSkillState(); - } + // 모든 클립과 반복이 끝나면 종료 + if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}"); + RestoreBaseController(); + ClearCurrentSkillState(); } } @@ -269,7 +304,6 @@ namespace Colosseum.Skills currentLoadoutEntry = loadoutEntry != null ? loadoutEntry.CreateCopy() : SkillLoadoutEntry.CreateTemporary(skill); currentSkill = skill; - waitingForEndAnimation = false; lastCancelReason = SkillCancelReason.None; BuildResolvedEffects(currentLoadoutEntry); currentRepeatCount = currentLoadoutEntry.GetResolvedRepeatCount(); @@ -385,7 +419,7 @@ namespace Colosseum.Skills return; currentIterationIndex++; - waitingForEndAnimation = false; + currentClipSequenceIndex = 0; if (debugMode && currentRepeatCount > 1) { @@ -394,18 +428,41 @@ namespace Colosseum.Skills TriggerCastStartEffects(); - if (currentSkill.SkillClip != null && animator != null) + if (currentSkill.AnimationClips.Count > 0 && animator != null) { float resolvedAnimationSpeed = currentLoadoutEntry != null ? currentLoadoutEntry.GetResolvedAnimationSpeed() : currentSkill.AnimationSpeed; animator.speed = resolvedAnimationSpeed; - PlaySkillClip(currentSkill.SkillClip); + PlaySkillClip(currentSkill.AnimationClips[0]); } TriggerImmediateSelfEffectsIfNeeded(); } + /// + /// 시퀀스 내 다음 클립이 있으면 재생합니다. + /// + private bool TryPlayNextClipInSequence() + { + if (currentSkill == null) + return false; + + int nextIndex = currentClipSequenceIndex + 1; + if (nextIndex >= currentSkill.AnimationClips.Count) + return false; + + currentClipSequenceIndex = nextIndex; + PlaySkillClip(currentSkill.AnimationClips[currentClipSequenceIndex]); + + if (debugMode) + { + Debug.Log($"[Skill] Playing clip {currentClipSequenceIndex + 1}/{currentSkill.AnimationClips.Count}: {currentSkill.AnimationClips[currentClipSequenceIndex].name}"); + } + + return true; + } + /// /// 반복 시전이 남아 있으면 다음 차수를 시작합니다. /// @@ -451,31 +508,6 @@ namespace Colosseum.Skills PlaySkillClipClientRpc(registeredClips.IndexOf(clip)); } - /// - /// 종료 클립 재생 - /// - private void PlayEndClip(AnimationClip clip) - { - if (baseSkillClip == null) - { - Debug.LogError("[SkillController] Base Skill Clip is not assigned!"); - return; - } - - var overrideController = new AnimatorOverrideController(baseController); - overrideController[baseSkillClip] = clip; - animator.runtimeAnimatorController = overrideController; - - // 애니메이터 완전 리셋 후 재생 - animator.Rebind(); - animator.Update(0f); - animator.Play(SKILL_STATE_NAME, 0, 0f); - - // 클라이언트에 클립 동기화 - if (IsServer && IsSpawned) - PlaySkillClipClientRpc(registeredClips.IndexOf(clip)); - } - /// /// 기본 컨트롤러로 복원 /// @@ -652,7 +684,7 @@ namespace Colosseum.Skills currentTriggeredAbnormalities.Clear(); currentTriggeredTargetsBuffer.Clear(); currentTargetOverride = null; - waitingForEndAnimation = false; + currentClipSequenceIndex = 0; currentRepeatCount = 1; currentIterationIndex = 0; } diff --git a/Assets/_Game/Scripts/Skills/SkillData.cs b/Assets/_Game/Scripts/Skills/SkillData.cs index d8396cbb..1d00c773 100644 --- a/Assets/_Game/Scripts/Skills/SkillData.cs +++ b/Assets/_Game/Scripts/Skills/SkillData.cs @@ -56,6 +56,120 @@ namespace Colosseum.Skills [CreateAssetMenu(fileName = "NewSkill", menuName = "Colosseum/Skill")] public class SkillData : ScriptableObject { + private const string SkillAssetPrefix = "Data_Skill_"; + private const string ClipAssetPrefix = "Anim_"; + private const string AnimationsSearchPath = "Assets/_Game/Animations"; + +#if UNITY_EDITOR + /// + /// 레거시 마이그레이션 및 애니메이션 클립 자동 매칭. + /// 에디터에서 애셋 로드/수정 시 자동 실행됩니다. + /// + private void OnValidate() + { + MigrateLegacyClips(); + RefreshAnimationClips(); + } + + /// + /// 기존 skillClip/endClip 데이터를 animationClips 리스트로 이관합니다. + /// + private void MigrateLegacyClips() + { + if (legacySkillClip == null && legacyEndClip == null) + return; + + if (animationClips.Count > 0) + return; + + if (legacySkillClip != null) + { + animationClips.Add(legacySkillClip); + legacySkillClip = null; + } + + if (legacyEndClip != null) + { + animationClips.Add(legacyEndClip); + legacyEndClip = null; + } + + UnityEditor.EditorUtility.SetDirty(this); + Debug.Log($"[SkillData] 레거시 클립을 animationClips로 이관 완료: {SkillName ?? name}", this); + } + + /// + /// 애셋 이름 기반으로 매칭되는 애니메이션 클립을 자동 수집합니다. + /// SkillData 이름이 'Data_Skill_'으로 시작하면 'Anim_{key}_{순서}' 클립을 찾아 animationClips에 채웁니다. + /// + public void RefreshAnimationClips() + { + if (!name.StartsWith(SkillAssetPrefix)) + return; + + string key = name.Substring(SkillAssetPrefix.Length); + if (string.IsNullOrEmpty(key)) + return; + + string[] guids = UnityEditor.AssetDatabase.FindAssets("t:AnimationClip", new[] { AnimationsSearchPath }); + var matchedClips = new List<(AnimationClip clip, int order)>(); + + for (int i = 0; i < guids.Length; i++) + { + string path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[i]); + AnimationClip clip = UnityEditor.AssetDatabase.LoadAssetAtPath(path); + if (clip == null) continue; + + string clipName = clip.name; + if (!clipName.StartsWith(ClipAssetPrefix)) + continue; + + string remaining = clipName.Substring(ClipAssetPrefix.Length); + + if (remaining == key) + { + // 정확 매칭 (순서 번호 없음) → 최우선 + matchedClips.Add((clip, -1)); + } + else if (remaining.StartsWith(key + "_")) + { + string suffix = remaining.Substring(key.Length + 1); + if (int.TryParse(suffix, out int order)) + { + matchedClips.Add((clip, order)); + } + } + } + + if (matchedClips.Count == 0) + return; + + // 정렬: 순서 번호 없음(-1) → 순서 번호 오름차순 + matchedClips.Sort((a, b) => a.order.CompareTo(b.order)); + + // 변경이 있는 경우만 갱신 (무한 루프 방지) + bool changed = matchedClips.Count != animationClips.Count; + if (!changed) + { + for (int i = 0; i < matchedClips.Count; i++) + { + if (matchedClips[i].clip != animationClips[i]) + { + changed = true; + break; + } + } + } + + if (!changed) return; + + animationClips.Clear(); + for (int i = 0; i < matchedClips.Count; i++) + animationClips.Add(matchedClips[i].clip); + + UnityEditor.EditorUtility.SetDirty(this); + } +#endif [Header("기본 정보")] [SerializeField] private string skillName; [TextArea(2, 4)] @@ -73,13 +187,15 @@ namespace Colosseum.Skills [SerializeField] private SkillBaseType baseTypes = SkillBaseType.None; [Header("애니메이션")] - [Tooltip("기본 Animator Controller의 'Skill' 상태에 덮어씌워질 클립")] - [SerializeField] private AnimationClip skillClip; - [Tooltip("종료 애니메이션 (선택)")] - [SerializeField] private AnimationClip endClip; + [Tooltip("순차 재생할 클립 목록. 애셋 이름이 Data_Skill_ 접두사면 Anim_{이름}_{순서} 클립을 자동 수집합니다.")] + [SerializeField] private List animationClips = new(); [Tooltip("애니메이션 재생 속도 (1 = 기본, 2 = 2배속)")] [Min(0.1f)] [SerializeField] private float animationSpeed = 1f; + // 레거시 마이그레이션 (기존 skillClip/endClip 데이터 보존) + [SerializeField, HideInInspector] private AnimationClip legacySkillClip; + [SerializeField, HideInInspector] private AnimationClip legacyEndClip; + [Header("루트 모션")] [Tooltip("애니메이션의 이동/회전 데이터를 캐릭터에 적용")] [SerializeField] private bool useRootMotion = false; @@ -123,8 +239,15 @@ namespace Colosseum.Skills public SkillRoleType SkillRole => skillRole; public SkillActivationType ActivationType => activationType; public SkillBaseType BaseTypes => baseTypes; - public AnimationClip SkillClip => skillClip; - public AnimationClip EndClip => endClip; + /// + /// 순차 재생할 클립 목록입니다. + /// + public IReadOnlyList AnimationClips => animationClips; + + /// + /// 첫 번째 클립 (레거시 호환성). 기존 코드에서 SkillClip을 참조하는 곳에서 사용됩니다. + /// + public AnimationClip SkillClip => animationClips.Count > 0 ? animationClips[0] : null; public float AnimationSpeed => animationSpeed; public float Cooldown => cooldown; public float ManaCost => manaCost;