fix: 보스 패턴 애니메이션 전환 안정화
- SkillController를 패턴 경계 기준으로 분기해 보스 첫 스킬은 즉시 시작하고 마지막 스킬만 Idle로 부드럽게 복귀하도록 조정 - 보스 패턴 실행 중 현재 스킬이 첫/마지막 스텝인지 BossBehaviorRuntimeState와 패턴 실행 경로에서 공유하도록 확장 - 패턴 내부 연속 클립 전환은 하드 전환으로 유지해 시작 프레임 스킵과 중간 Idle 복귀 문제를 줄이고 종료 전환 시간을 별도 노출
This commit is contained in:
@@ -50,6 +50,11 @@ namespace Colosseum.Skills
|
||||
public class SkillController : NetworkBehaviour
|
||||
{
|
||||
private const string SKILL_STATE_NAME = "Skill";
|
||||
private const string BaseLayerName = "Base Layer";
|
||||
private const string MoveStateName = "Move";
|
||||
private const string IdleStateName = "Idle";
|
||||
private const string BossIdlePhase1StateName = "Idle_Phase1";
|
||||
private const string BossIdlePhase3StateName = "Idle_Phase3";
|
||||
|
||||
[Header("애니메이션")]
|
||||
[SerializeField] private Animator animator;
|
||||
@@ -58,6 +63,16 @@ namespace Colosseum.Skills
|
||||
[Tooltip("Skill 상태에 연결된 기본 클립 (Override용). baseController의 Skill state에서 자동 발견됩니다.")]
|
||||
[SerializeField] private AnimationClip baseSkillClip;
|
||||
|
||||
[Header("애니메이션 전환")]
|
||||
[Tooltip("스킬 상태 진입 시 사용할 고정 전환 시간(초)")]
|
||||
[Min(0f)] [SerializeField] private float skillEnterTransitionDuration = 0.06f;
|
||||
[Tooltip("보스 패턴 첫 스킬 진입 시 사용할 고정 전환 시간(초)")]
|
||||
[Min(0f)] [SerializeField] private float bossPatternEnterTransitionDuration = 0.02f;
|
||||
[Tooltip("스킬 종료 후 기본 상태 복귀 시 사용할 고정 전환 시간(초)")]
|
||||
[Min(0f)] [SerializeField] private float skillExitTransitionDuration = 0.12f;
|
||||
[Tooltip("보스 패턴 마지막 스킬 종료 후 Idle 복귀 시 사용할 고정 전환 시간(초)")]
|
||||
[Min(0f)] [SerializeField] private float bossPatternExitTransitionDuration = 0.2f;
|
||||
|
||||
[Header("네트워크 동기화")]
|
||||
[Tooltip("이 이름이 포함된 클립이 자동 등록됩니다. 서버→클라이언트 클립 동기화에 사용됩니다.")]
|
||||
[SerializeField] private string clipAutoRegisterFilter = "_Player_";
|
||||
@@ -104,9 +119,14 @@ namespace Colosseum.Skills
|
||||
private readonly List<SkillEffect> currentLoopExitEffects = new();
|
||||
private readonly List<SkillEffect> currentReleaseStartEffects = new();
|
||||
private bool loopHoldRequested = false;
|
||||
private bool shouldBlendIntoCurrentSkill = true;
|
||||
private bool shouldRestoreToIdleAfterCurrentSkill = true;
|
||||
private bool isBossPatternBoundarySkill = false;
|
||||
|
||||
// 쿨타임 추적
|
||||
private Dictionary<SkillData, float> cooldownTracker = new Dictionary<SkillData, float>();
|
||||
private AnimatorOverrideController runtimeOverrideController;
|
||||
private int cachedRecoveryStateHash;
|
||||
|
||||
|
||||
public bool IsExecutingSkill => currentSkill != null;
|
||||
@@ -138,6 +158,8 @@ namespace Colosseum.Skills
|
||||
{
|
||||
baseController = animator.runtimeAnimatorController;
|
||||
}
|
||||
|
||||
EnsureRuntimeOverrideController();
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
@@ -287,7 +309,7 @@ namespace Colosseum.Skills
|
||||
|
||||
// 모든 클립과 단계가 끝나면 종료
|
||||
if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}");
|
||||
RestoreBaseController();
|
||||
RestoreBaseControllerIfNeeded();
|
||||
CompleteCurrentSkillExecution(SkillExecutionResult.Completed);
|
||||
}
|
||||
}
|
||||
@@ -380,6 +402,8 @@ namespace Colosseum.Skills
|
||||
currentSkill = skill;
|
||||
lastCancelReason = SkillCancelReason.None;
|
||||
lastExecutionResult = SkillExecutionResult.Running;
|
||||
CacheRecoveryState();
|
||||
ResolveSkillBoundaryTransitions();
|
||||
BuildResolvedEffects(currentLoadoutEntry);
|
||||
currentRepeatCount = currentLoadoutEntry.GetResolvedRepeatCount();
|
||||
currentIterationIndex = 0;
|
||||
@@ -542,7 +566,7 @@ namespace Colosseum.Skills
|
||||
? currentLoadoutEntry.GetResolvedAnimationSpeed()
|
||||
: currentSkill.AnimationSpeed;
|
||||
animator.speed = resolvedAnimationSpeed;
|
||||
PlaySkillClip(currentPhaseAnimationClips[0]);
|
||||
PlaySkillClip(currentPhaseAnimationClips[0], ShouldBlendIntoClip());
|
||||
}
|
||||
|
||||
TriggerImmediateSelfEffectsIfNeeded();
|
||||
@@ -564,7 +588,7 @@ namespace Colosseum.Skills
|
||||
return false;
|
||||
|
||||
currentClipSequenceIndex = nextIndex;
|
||||
PlaySkillClip(currentPhaseAnimationClips[currentClipSequenceIndex]);
|
||||
PlaySkillClip(currentPhaseAnimationClips[currentClipSequenceIndex], blendIn: false);
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
@@ -592,7 +616,7 @@ namespace Colosseum.Skills
|
||||
/// <summary>
|
||||
/// 스킬 클립으로 Override Controller 생성 후 재생
|
||||
/// </summary>
|
||||
private void PlaySkillClip(AnimationClip clip)
|
||||
private void PlaySkillClip(AnimationClip clip, bool blendIn)
|
||||
{
|
||||
if (baseSkillClip == null)
|
||||
{
|
||||
@@ -600,23 +624,25 @@ namespace Colosseum.Skills
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ApplyOverrideClip(clip))
|
||||
{
|
||||
Debug.LogError("[SkillController] Skill override clip 적용에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log($"[Skill] PlaySkillClip: {clip.name}, BaseClip: {baseSkillClip.name}");
|
||||
}
|
||||
|
||||
var overrideController = new AnimatorOverrideController(baseController);
|
||||
overrideController[baseSkillClip] = clip;
|
||||
animator.runtimeAnimatorController = overrideController;
|
||||
|
||||
// 애니메이터 완전 리셋 후 재생
|
||||
animator.Rebind();
|
||||
animator.Update(0f);
|
||||
animator.Play(SKILL_STATE_NAME, 0, 0f);
|
||||
if (blendIn)
|
||||
animator.CrossFadeInFixedTime(GetSkillStateHash(), GetSkillEnterTransitionDuration(), 0, 0f);
|
||||
else
|
||||
animator.Play(GetSkillStateHash(), 0, 0f);
|
||||
|
||||
// 클라이언트에 클립 동기화
|
||||
if (IsServer && IsSpawned)
|
||||
PlaySkillClipClientRpc(registeredClips.IndexOf(clip));
|
||||
PlaySkillClipClientRpc(registeredClips.IndexOf(clip), blendIn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -624,22 +650,30 @@ namespace Colosseum.Skills
|
||||
/// </summary>
|
||||
private void RestoreBaseController()
|
||||
{
|
||||
if (animator != null && baseController != null)
|
||||
{
|
||||
animator.runtimeAnimatorController = baseController;
|
||||
animator.speed = 1f;
|
||||
}
|
||||
int recoveryStateHash = ResolveRecoveryStateHash();
|
||||
RestoreBaseAnimationState(recoveryStateHash);
|
||||
|
||||
// 클라이언트에 복원 동기화
|
||||
if (IsServer && IsSpawned)
|
||||
RestoreBaseControllerClientRpc();
|
||||
RestoreBaseControllerClientRpc(recoveryStateHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 스킬이 Idle 복귀가 필요한 경계 스킬일 때만 기본 상태 복귀를 수행합니다.
|
||||
/// </summary>
|
||||
private void RestoreBaseControllerIfNeeded()
|
||||
{
|
||||
if (!shouldRestoreToIdleAfterCurrentSkill)
|
||||
return;
|
||||
|
||||
RestoreBaseController();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 클라이언트: override 컨트롤러 적용 + 스킬 상태 재생 (원자적 실행으로 타이밍 문제 해결)
|
||||
/// </summary>
|
||||
[Rpc(SendTo.NotServer)]
|
||||
private void PlaySkillClipClientRpc(int clipIndex)
|
||||
private void PlaySkillClipClientRpc(int clipIndex, bool blendIn)
|
||||
{
|
||||
if (baseSkillClip == null || animator == null || baseController == null) return;
|
||||
if (clipIndex < 0 || clipIndex >= registeredClips.Count || registeredClips[clipIndex] == null)
|
||||
@@ -648,22 +682,21 @@ namespace Colosseum.Skills
|
||||
return;
|
||||
}
|
||||
|
||||
var overrideController = new AnimatorOverrideController(baseController);
|
||||
overrideController[baseSkillClip] = registeredClips[clipIndex];
|
||||
animator.runtimeAnimatorController = overrideController;
|
||||
animator.Rebind();
|
||||
animator.Update(0f);
|
||||
animator.Play(SKILL_STATE_NAME, 0, 0f);
|
||||
if (!ApplyOverrideClip(registeredClips[clipIndex]))
|
||||
return;
|
||||
if (blendIn)
|
||||
animator.CrossFadeInFixedTime(GetSkillStateHash(), GetSkillEnterTransitionDuration(), 0, 0f);
|
||||
else
|
||||
animator.Play(GetSkillStateHash(), 0, 0f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 클라이언트: 기본 컨트롤러 복원
|
||||
/// </summary>
|
||||
[Rpc(SendTo.NotServer)]
|
||||
private void RestoreBaseControllerClientRpc()
|
||||
private void RestoreBaseControllerClientRpc(int recoveryStateHash)
|
||||
{
|
||||
if (animator != null && baseController != null)
|
||||
animator.runtimeAnimatorController = baseController;
|
||||
RestoreBaseAnimationState(recoveryStateHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -874,7 +907,7 @@ namespace Colosseum.Skills
|
||||
if (TryStartReleasePhase())
|
||||
return true;
|
||||
|
||||
RestoreBaseController();
|
||||
RestoreBaseControllerIfNeeded();
|
||||
CompleteCurrentSkillExecution(SkillExecutionResult.Cancelled);
|
||||
return true;
|
||||
}
|
||||
@@ -1078,7 +1111,7 @@ namespace Colosseum.Skills
|
||||
if (TryStartReleasePhase())
|
||||
return;
|
||||
|
||||
RestoreBaseController();
|
||||
RestoreBaseControllerIfNeeded();
|
||||
CompleteCurrentSkillExecution(SkillExecutionResult.Completed);
|
||||
}
|
||||
|
||||
@@ -1138,7 +1171,7 @@ namespace Colosseum.Skills
|
||||
? currentLoadoutEntry.GetResolvedAnimationSpeed()
|
||||
: currentSkill.AnimationSpeed;
|
||||
animator.speed = resolvedAnimationSpeed;
|
||||
PlaySkillClip(currentPhaseAnimationClips[0]);
|
||||
PlaySkillClip(currentPhaseAnimationClips[0], blendIn: false);
|
||||
|
||||
if (debugMode)
|
||||
Debug.Log($"[Skill] 해제 단계 시작: {currentSkill.SkillName}");
|
||||
@@ -1231,6 +1264,9 @@ namespace Colosseum.Skills
|
||||
currentRepeatCount = 1;
|
||||
currentIterationIndex = 0;
|
||||
loopHoldRequested = false;
|
||||
cachedRecoveryStateHash = 0;
|
||||
shouldBlendIntoCurrentSkill = true;
|
||||
shouldRestoreToIdleAfterCurrentSkill = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1368,5 +1404,177 @@ namespace Colosseum.Skills
|
||||
|
||||
currentTriggeredTargetsBuffer.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 기본 컨트롤러를 기반으로 런타임 OverrideController를 준비합니다.
|
||||
/// </summary>
|
||||
private bool EnsureRuntimeOverrideController()
|
||||
{
|
||||
if (animator == null || baseController == null || baseSkillClip == null)
|
||||
return false;
|
||||
|
||||
if (runtimeOverrideController == null || runtimeOverrideController.runtimeAnimatorController != baseController)
|
||||
runtimeOverrideController = new AnimatorOverrideController(baseController);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 지정한 클립을 Skill 상태 override로 적용합니다.
|
||||
/// </summary>
|
||||
private bool ApplyOverrideClip(AnimationClip clip)
|
||||
{
|
||||
if (clip == null)
|
||||
return false;
|
||||
|
||||
if (!EnsureRuntimeOverrideController())
|
||||
return false;
|
||||
|
||||
// 같은 상태에 다른 스킬 클립을 연속 적용할 때는 새 override 인스턴스를 다시 붙여야
|
||||
// Unity가 바뀐 Motion을 안정적으로 반영합니다.
|
||||
runtimeOverrideController = new AnimatorOverrideController(baseController);
|
||||
runtimeOverrideController[baseSkillClip] = clip;
|
||||
animator.runtimeAnimatorController = runtimeOverrideController;
|
||||
animator.Update(0f);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 스킬이 패턴 경계에서 Idle과 블렌드해야 하는지 결정합니다.
|
||||
/// </summary>
|
||||
private void ResolveSkillBoundaryTransitions()
|
||||
{
|
||||
shouldBlendIntoCurrentSkill = true;
|
||||
shouldRestoreToIdleAfterCurrentSkill = true;
|
||||
isBossPatternBoundarySkill = false;
|
||||
|
||||
Colosseum.Enemy.BossBehaviorRuntimeState runtimeState = GetComponent<Colosseum.Enemy.BossBehaviorRuntimeState>();
|
||||
if (runtimeState == null || !runtimeState.IsExecutingPattern)
|
||||
return;
|
||||
|
||||
isBossPatternBoundarySkill = true;
|
||||
shouldBlendIntoCurrentSkill = runtimeState.CurrentPatternSkillStartsFromIdle;
|
||||
shouldRestoreToIdleAfterCurrentSkill = runtimeState.CurrentPatternSkillReturnsToIdle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 재생할 클립이 스킬 시작 블렌드 대상인지 반환합니다.
|
||||
/// </summary>
|
||||
private bool ShouldBlendIntoClip()
|
||||
{
|
||||
if (isBossPatternBoundarySkill)
|
||||
return false;
|
||||
|
||||
if (!shouldBlendIntoCurrentSkill)
|
||||
return false;
|
||||
|
||||
if (isPlayingReleasePhase)
|
||||
return false;
|
||||
|
||||
return currentClipSequenceIndex == 0 && currentIterationIndex == 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 스킬 진입에 사용할 전환 시간을 반환합니다.
|
||||
/// </summary>
|
||||
private float GetSkillEnterTransitionDuration()
|
||||
{
|
||||
if (isBossPatternBoundarySkill)
|
||||
return bossPatternEnterTransitionDuration;
|
||||
|
||||
return skillEnterTransitionDuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 스킬 종료에 사용할 전환 시간을 반환합니다.
|
||||
/// </summary>
|
||||
private float GetSkillExitTransitionDuration()
|
||||
{
|
||||
if (isBossPatternBoundarySkill && shouldRestoreToIdleAfterCurrentSkill)
|
||||
return bossPatternExitTransitionDuration;
|
||||
|
||||
return skillExitTransitionDuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스킬 시작 전 기본 상태를 저장합니다.
|
||||
/// </summary>
|
||||
private void CacheRecoveryState()
|
||||
{
|
||||
if (animator == null)
|
||||
{
|
||||
cachedRecoveryStateHash = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
AnimatorStateInfo currentState = animator.GetCurrentAnimatorStateInfo(0);
|
||||
cachedRecoveryStateHash = currentState.fullPathHash;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스킬 상태 해시를 반환합니다.
|
||||
/// </summary>
|
||||
private static int GetSkillStateHash()
|
||||
{
|
||||
return Animator.StringToHash($"{BaseLayerName}.{SKILL_STATE_NAME}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스킬 종료 후 복귀할 상태 해시를 결정합니다.
|
||||
/// </summary>
|
||||
private int ResolveRecoveryStateHash()
|
||||
{
|
||||
if (animator == null)
|
||||
return 0;
|
||||
|
||||
if (isBossPatternBoundarySkill && shouldRestoreToIdleAfterCurrentSkill)
|
||||
{
|
||||
int bossIdleStateHash = ResolveBossIdleStateHash();
|
||||
if (bossIdleStateHash != 0)
|
||||
return bossIdleStateHash;
|
||||
}
|
||||
|
||||
if (cachedRecoveryStateHash != 0 && animator.HasState(0, cachedRecoveryStateHash))
|
||||
return cachedRecoveryStateHash;
|
||||
|
||||
int moveStateHash = Animator.StringToHash($"{BaseLayerName}.{MoveStateName}");
|
||||
if (animator.HasState(0, moveStateHash))
|
||||
return moveStateHash;
|
||||
|
||||
int idleStateHash = Animator.StringToHash($"{BaseLayerName}.{IdleStateName}");
|
||||
if (animator.HasState(0, idleStateHash))
|
||||
return idleStateHash;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보스 패턴 종료 시 현재 페이즈에 맞는 Idle 상태 해시를 반환합니다.
|
||||
/// </summary>
|
||||
private int ResolveBossIdleStateHash()
|
||||
{
|
||||
Colosseum.Enemy.BossBehaviorRuntimeState runtimeState = GetComponent<Colosseum.Enemy.BossBehaviorRuntimeState>();
|
||||
if (runtimeState == null)
|
||||
return 0;
|
||||
|
||||
int phase = runtimeState.CurrentPatternPhase;
|
||||
string stateName = phase >= 3 ? BossIdlePhase3StateName : BossIdlePhase1StateName;
|
||||
int stateHash = Animator.StringToHash($"{BaseLayerName}.{stateName}");
|
||||
return animator.HasState(0, stateHash) ? stateHash : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 기본 스킬 클립 Override를 복원하고 지정한 상태로 부드럽게 복귀합니다.
|
||||
/// </summary>
|
||||
private void RestoreBaseAnimationState(int recoveryStateHash)
|
||||
{
|
||||
if (animator == null || baseController == null || baseSkillClip == null)
|
||||
return;
|
||||
|
||||
animator.speed = 1f;
|
||||
|
||||
if (recoveryStateHash != 0 && animator.HasState(0, recoveryStateHash))
|
||||
animator.CrossFadeInFixedTime(recoveryStateHash, GetSkillExitTransitionDuration(), 0, 0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user