feat: 스킬 애니메이션 N클립 순차 재생 및 이름 기반 자동 매칭 시스템

- 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_ 매칭 검증
This commit is contained in:
2026-04-02 18:55:32 +09:00
parent 08b1e3d95a
commit 52b0e682a8
10 changed files with 505 additions and 86 deletions

View File

@@ -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<AbnormalityData> currentCastStartAbnormalities = new();
private readonly Dictionary<int, List<AbnormalityData>> currentTriggeredAbnormalities = new();
private readonly List<GameObject> 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();
}
/// <summary>
/// baseController의 Skill 상태에 연결된 클립을 baseSkillClip으로 자동 발견합니다.
/// </summary>
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);
}
}
/// <summary>
/// AnimatorController의 지정한 상태에 연결된 AnimationClip을 찾습니다.
/// </summary>
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;
}
/// <summary>
/// StateMachine을 재귀적으로 탐색하여 지정한 이름의 상태에서 클립을 찾습니다.
/// </summary>
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;
}
/// <summary>
/// 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();
}
/// <summary>
/// 시퀀스 내 다음 클립이 있으면 재생합니다.
/// </summary>
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;
}
/// <summary>
/// 반복 시전이 남아 있으면 다음 차수를 시작합니다.
/// </summary>
@@ -451,31 +508,6 @@ namespace Colosseum.Skills
PlaySkillClipClientRpc(registeredClips.IndexOf(clip));
}
/// <summary>
/// 종료 클립 재생
/// </summary>
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));
}
/// <summary>
/// 기본 컨트롤러로 복원
/// </summary>
@@ -652,7 +684,7 @@ namespace Colosseum.Skills
currentTriggeredAbnormalities.Clear();
currentTriggeredTargetsBuffer.Clear();
currentTargetOverride = null;
waitingForEndAnimation = false;
currentClipSequenceIndex = 0;
currentRepeatCount = 1;
currentIterationIndex = 0;
}

View File

@@ -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
/// <summary>
/// 레거시 마이그레이션 및 애니메이션 클립 자동 매칭.
/// 에디터에서 애셋 로드/수정 시 자동 실행됩니다.
/// </summary>
private void OnValidate()
{
MigrateLegacyClips();
RefreshAnimationClips();
}
/// <summary>
/// 기존 skillClip/endClip 데이터를 animationClips 리스트로 이관합니다.
/// </summary>
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);
}
/// <summary>
/// 애셋 이름 기반으로 매칭되는 애니메이션 클립을 자동 수집합니다.
/// SkillData 이름이 'Data_Skill_'으로 시작하면 'Anim_{key}_{순서}' 클립을 찾아 animationClips에 채웁니다.
/// </summary>
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<AnimationClip>(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<AnimationClip> 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;
/// <summary>
/// 순차 재생할 클립 목록입니다.
/// </summary>
public IReadOnlyList<AnimationClip> AnimationClips => animationClips;
/// <summary>
/// 첫 번째 클립 (레거시 호환성). 기존 코드에서 SkillClip을 참조하는 곳에서 사용됩니다.
/// </summary>
public AnimationClip SkillClip => animationClips.Count > 0 ? animationClips[0] : null;
public float AnimationSpeed => animationSpeed;
public float Cooldown => cooldown;
public float ManaCost => manaCost;