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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user