fix: 보스 패턴 애니메이션 전환 안정화

- SkillController를 패턴 경계 기준으로 분기해 보스 첫 스킬은 즉시 시작하고 마지막 스킬만 Idle로 부드럽게 복귀하도록 조정
- 보스 패턴 실행 중 현재 스킬이 첫/마지막 스텝인지 BossBehaviorRuntimeState와 패턴 실행 경로에서 공유하도록 확장
- 패턴 내부 연속 클립 전환은 하드 전환으로 유지해 시작 프레임 스킵과 중간 Idle 복귀 문제를 줄이고 종료 전환 시간을 별도 노출
This commit is contained in:
2026-04-11 14:18:29 +09:00
parent 5d58397fe0
commit 12a481b596
4 changed files with 344 additions and 32 deletions

View File

@@ -252,6 +252,10 @@ public abstract partial class BossPatternActionBase : Action
return Status.Failure; return Status.Failure;
} }
runtimeState.SetCurrentPatternSkillBoundary(
startsFromIdle: IsFirstSkillStep(currentStepIndex),
returnsToIdle: IsLastSkillStep(currentStepIndex));
GameObject skillTarget = activeTarget; GameObject skillTarget = activeTarget;
if (step.Skill.JumpToTarget) if (step.Skill.JumpToTarget)
{ {
@@ -376,6 +380,7 @@ public abstract partial class BossPatternActionBase : Action
if (isChargeWaiting) if (isChargeWaiting)
EndChargeWait(broken: false); EndChargeWait(broken: false);
runtimeState?.SetCurrentPatternSkillBoundary(false, false);
activePattern = null; activePattern = null;
activeTarget = null; activeTarget = null;
currentStepIndex = 0; currentStepIndex = 0;
@@ -384,6 +389,36 @@ public abstract partial class BossPatternActionBase : Action
waitEndTime = 0f; waitEndTime = 0f;
} }
private bool IsFirstSkillStep(int stepIndex)
{
if (activePattern == null || activePattern.Steps == null)
return false;
for (int i = 0; i < activePattern.Steps.Count; i++)
{
PatternStep candidate = activePattern.Steps[i];
if (candidate != null && candidate.Type == PatternStepType.Skill && candidate.Skill != null)
return i == stepIndex;
}
return false;
}
private bool IsLastSkillStep(int stepIndex)
{
if (activePattern == null || activePattern.Steps == null)
return false;
for (int i = activePattern.Steps.Count - 1; i >= 0; i--)
{
PatternStep candidate = activePattern.Steps[i];
if (candidate != null && candidate.Type == PatternStepType.Skill && candidate.Skill != null)
return i == stepIndex;
}
return false;
}
private Status FinalizeResolvedPattern(BossPatternExecutionResult result) private Status FinalizeResolvedPattern(BossPatternExecutionResult result)
{ {
runtimeState?.CompletePatternExecution(activePattern, result); runtimeState?.CompletePatternExecution(activePattern, result);

View File

@@ -64,6 +64,9 @@ namespace Colosseum.Enemy
protected float nextPatternReadyTime; protected float nextPatternReadyTime;
protected BossPatternExecutionResult lastPatternExecutionResult; protected BossPatternExecutionResult lastPatternExecutionResult;
protected BossPatternData lastExecutedPattern; protected BossPatternData lastExecutedPattern;
protected BossPatternData activePattern;
protected bool currentPatternSkillStartsFromIdle;
protected bool currentPatternSkillReturnsToIdle;
protected GameObject lastReviveCaster; protected GameObject lastReviveCaster;
protected GameObject lastRevivedTarget; protected GameObject lastRevivedTarget;
protected float lastReviveEventTime = float.NegativeInfinity; protected float lastReviveEventTime = float.NegativeInfinity;
@@ -108,6 +111,21 @@ namespace Colosseum.Enemy
/// </summary> /// </summary>
public BossPatternData LastExecutedPattern => lastExecutedPattern; public BossPatternData LastExecutedPattern => lastExecutedPattern;
/// <summary>
/// 현재 패턴 실행 중인지 여부
/// </summary>
public bool IsExecutingPattern => activePattern != null && lastPatternExecutionResult == BossPatternExecutionResult.Running;
/// <summary>
/// 현재 스킬 스텝이 패턴 시작에서 Idle과 이어져야 하는지 여부
/// </summary>
public bool CurrentPatternSkillStartsFromIdle => currentPatternSkillStartsFromIdle;
/// <summary>
/// 현재 스킬 스텝이 패턴 종료에서 Idle로 돌아가야 하는지 여부
/// </summary>
public bool CurrentPatternSkillReturnsToIdle => currentPatternSkillReturnsToIdle;
/// <summary> /// <summary>
/// EnemyBase 접근자 /// EnemyBase 접근자
/// </summary> /// </summary>
@@ -204,8 +222,11 @@ namespace Colosseum.Enemy
/// </summary> /// </summary>
public void BeginPatternExecution(BossPatternData pattern) public void BeginPatternExecution(BossPatternData pattern)
{ {
activePattern = pattern;
lastExecutedPattern = pattern; lastExecutedPattern = pattern;
lastPatternExecutionResult = BossPatternExecutionResult.Running; lastPatternExecutionResult = BossPatternExecutionResult.Running;
currentPatternSkillStartsFromIdle = false;
currentPatternSkillReturnsToIdle = false;
} }
/// <summary> /// <summary>
@@ -215,11 +236,23 @@ namespace Colosseum.Enemy
{ {
lastExecutedPattern = pattern; lastExecutedPattern = pattern;
lastPatternExecutionResult = result; lastPatternExecutionResult = result;
activePattern = null;
currentPatternSkillStartsFromIdle = false;
currentPatternSkillReturnsToIdle = false;
if (pattern != null && IsTerminalPatternExecutionResult(result)) if (pattern != null && IsTerminalPatternExecutionResult(result))
StartCommonPatternInterval(); StartCommonPatternInterval();
} }
/// <summary>
/// 현재 실행할 패턴 스킬이 패턴 시작/종료 경계인지 기록합니다.
/// </summary>
public void SetCurrentPatternSkillBoundary(bool startsFromIdle, bool returnsToIdle)
{
currentPatternSkillStartsFromIdle = startsFromIdle;
currentPatternSkillReturnsToIdle = returnsToIdle;
}
/// <summary> /// <summary>
/// 부활 스킬 사용 사실을 보스 AI에 알립니다. /// 부활 스킬 사용 사실을 보스 AI에 알립니다.
/// </summary> /// </summary>

View File

@@ -210,6 +210,10 @@ namespace Colosseum.Enemy
yield break; yield break;
} }
runtimeState.SetCurrentPatternSkillBoundary(
startsFromIdle: IsFirstSkillStep(pattern, i),
returnsToIdle: IsLastSkillStep(pattern, i));
GameObject stepTarget = currentTarget; GameObject stepTarget = currentTarget;
if (step.Skill.JumpToTarget) if (step.Skill.JumpToTarget)
{ {
@@ -339,6 +343,8 @@ namespace Colosseum.Enemy
if (isChargeWaiting) if (isChargeWaiting)
EndChargeWait(broken: false); EndChargeWait(broken: false);
runtimeState?.SetCurrentPatternSkillBoundary(false, false);
if (currentPattern != null && runtimeState != null) if (currentPattern != null && runtimeState != null)
{ {
if (applyCooldown) if (applyCooldown)
@@ -355,6 +361,36 @@ namespace Colosseum.Enemy
chargeTelegraphApplied = false; chargeTelegraphApplied = false;
} }
private static bool IsFirstSkillStep(BossPatternData pattern, int stepIndex)
{
if (pattern == null || pattern.Steps == null)
return false;
for (int i = 0; i < pattern.Steps.Count; i++)
{
PatternStep candidate = pattern.Steps[i];
if (candidate != null && candidate.Type == PatternStepType.Skill && candidate.Skill != null)
return i == stepIndex;
}
return false;
}
private static bool IsLastSkillStep(BossPatternData pattern, int stepIndex)
{
if (pattern == null || pattern.Steps == null)
return false;
for (int i = pattern.Steps.Count - 1; i >= 0; i--)
{
PatternStep candidate = pattern.Steps[i];
if (candidate != null && candidate.Type == PatternStepType.Skill && candidate.Skill != null)
return i == stepIndex;
}
return false;
}
private void ApplyPatternFlowState(BossPatternData pattern) private void ApplyPatternFlowState(BossPatternData pattern)
{ {
if (runtimeState == null || pattern == null) if (runtimeState == null || pattern == null)

View File

@@ -50,6 +50,11 @@ namespace Colosseum.Skills
public class SkillController : NetworkBehaviour public class SkillController : NetworkBehaviour
{ {
private const string SKILL_STATE_NAME = "Skill"; 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("애니메이션")] [Header("애니메이션")]
[SerializeField] private Animator animator; [SerializeField] private Animator animator;
@@ -58,6 +63,16 @@ namespace Colosseum.Skills
[Tooltip("Skill 상태에 연결된 기본 클립 (Override용). baseController의 Skill state에서 자동 발견됩니다.")] [Tooltip("Skill 상태에 연결된 기본 클립 (Override용). baseController의 Skill state에서 자동 발견됩니다.")]
[SerializeField] private AnimationClip baseSkillClip; [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("네트워크 동기화")] [Header("네트워크 동기화")]
[Tooltip("이 이름이 포함된 클립이 자동 등록됩니다. 서버→클라이언트 클립 동기화에 사용됩니다.")] [Tooltip("이 이름이 포함된 클립이 자동 등록됩니다. 서버→클라이언트 클립 동기화에 사용됩니다.")]
[SerializeField] private string clipAutoRegisterFilter = "_Player_"; [SerializeField] private string clipAutoRegisterFilter = "_Player_";
@@ -104,9 +119,14 @@ namespace Colosseum.Skills
private readonly List<SkillEffect> currentLoopExitEffects = new(); private readonly List<SkillEffect> currentLoopExitEffects = new();
private readonly List<SkillEffect> currentReleaseStartEffects = new(); private readonly List<SkillEffect> currentReleaseStartEffects = new();
private bool loopHoldRequested = false; 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 Dictionary<SkillData, float> cooldownTracker = new Dictionary<SkillData, float>();
private AnimatorOverrideController runtimeOverrideController;
private int cachedRecoveryStateHash;
public bool IsExecutingSkill => currentSkill != null; public bool IsExecutingSkill => currentSkill != null;
@@ -138,6 +158,8 @@ namespace Colosseum.Skills
{ {
baseController = animator.runtimeAnimatorController; baseController = animator.runtimeAnimatorController;
} }
EnsureRuntimeOverrideController();
} }
#if UNITY_EDITOR #if UNITY_EDITOR
@@ -287,7 +309,7 @@ namespace Colosseum.Skills
// 모든 클립과 단계가 끝나면 종료 // 모든 클립과 단계가 끝나면 종료
if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}"); if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}");
RestoreBaseController(); RestoreBaseControllerIfNeeded();
CompleteCurrentSkillExecution(SkillExecutionResult.Completed); CompleteCurrentSkillExecution(SkillExecutionResult.Completed);
} }
} }
@@ -380,6 +402,8 @@ namespace Colosseum.Skills
currentSkill = skill; currentSkill = skill;
lastCancelReason = SkillCancelReason.None; lastCancelReason = SkillCancelReason.None;
lastExecutionResult = SkillExecutionResult.Running; lastExecutionResult = SkillExecutionResult.Running;
CacheRecoveryState();
ResolveSkillBoundaryTransitions();
BuildResolvedEffects(currentLoadoutEntry); BuildResolvedEffects(currentLoadoutEntry);
currentRepeatCount = currentLoadoutEntry.GetResolvedRepeatCount(); currentRepeatCount = currentLoadoutEntry.GetResolvedRepeatCount();
currentIterationIndex = 0; currentIterationIndex = 0;
@@ -542,7 +566,7 @@ namespace Colosseum.Skills
? currentLoadoutEntry.GetResolvedAnimationSpeed() ? currentLoadoutEntry.GetResolvedAnimationSpeed()
: currentSkill.AnimationSpeed; : currentSkill.AnimationSpeed;
animator.speed = resolvedAnimationSpeed; animator.speed = resolvedAnimationSpeed;
PlaySkillClip(currentPhaseAnimationClips[0]); PlaySkillClip(currentPhaseAnimationClips[0], ShouldBlendIntoClip());
} }
TriggerImmediateSelfEffectsIfNeeded(); TriggerImmediateSelfEffectsIfNeeded();
@@ -564,7 +588,7 @@ namespace Colosseum.Skills
return false; return false;
currentClipSequenceIndex = nextIndex; currentClipSequenceIndex = nextIndex;
PlaySkillClip(currentPhaseAnimationClips[currentClipSequenceIndex]); PlaySkillClip(currentPhaseAnimationClips[currentClipSequenceIndex], blendIn: false);
if (debugMode) if (debugMode)
{ {
@@ -592,7 +616,7 @@ namespace Colosseum.Skills
/// <summary> /// <summary>
/// 스킬 클립으로 Override Controller 생성 후 재생 /// 스킬 클립으로 Override Controller 생성 후 재생
/// </summary> /// </summary>
private void PlaySkillClip(AnimationClip clip) private void PlaySkillClip(AnimationClip clip, bool blendIn)
{ {
if (baseSkillClip == null) if (baseSkillClip == null)
{ {
@@ -600,23 +624,25 @@ namespace Colosseum.Skills
return; return;
} }
if (!ApplyOverrideClip(clip))
{
Debug.LogError("[SkillController] Skill override clip 적용에 실패했습니다.");
return;
}
if (debugMode) if (debugMode)
{ {
Debug.Log($"[Skill] PlaySkillClip: {clip.name}, BaseClip: {baseSkillClip.name}"); Debug.Log($"[Skill] PlaySkillClip: {clip.name}, BaseClip: {baseSkillClip.name}");
} }
var overrideController = new AnimatorOverrideController(baseController); if (blendIn)
overrideController[baseSkillClip] = clip; animator.CrossFadeInFixedTime(GetSkillStateHash(), GetSkillEnterTransitionDuration(), 0, 0f);
animator.runtimeAnimatorController = overrideController; else
animator.Play(GetSkillStateHash(), 0, 0f);
// 애니메이터 완전 리셋 후 재생
animator.Rebind();
animator.Update(0f);
animator.Play(SKILL_STATE_NAME, 0, 0f);
// 클라이언트에 클립 동기화 // 클라이언트에 클립 동기화
if (IsServer && IsSpawned) if (IsServer && IsSpawned)
PlaySkillClipClientRpc(registeredClips.IndexOf(clip)); PlaySkillClipClientRpc(registeredClips.IndexOf(clip), blendIn);
} }
/// <summary> /// <summary>
@@ -624,22 +650,30 @@ namespace Colosseum.Skills
/// </summary> /// </summary>
private void RestoreBaseController() private void RestoreBaseController()
{ {
if (animator != null && baseController != null) int recoveryStateHash = ResolveRecoveryStateHash();
{ RestoreBaseAnimationState(recoveryStateHash);
animator.runtimeAnimatorController = baseController;
animator.speed = 1f;
}
// 클라이언트에 복원 동기화 // 클라이언트에 복원 동기화
if (IsServer && IsSpawned) if (IsServer && IsSpawned)
RestoreBaseControllerClientRpc(); RestoreBaseControllerClientRpc(recoveryStateHash);
}
/// <summary>
/// 현재 스킬이 Idle 복귀가 필요한 경계 스킬일 때만 기본 상태 복귀를 수행합니다.
/// </summary>
private void RestoreBaseControllerIfNeeded()
{
if (!shouldRestoreToIdleAfterCurrentSkill)
return;
RestoreBaseController();
} }
/// <summary> /// <summary>
/// 클라이언트: override 컨트롤러 적용 + 스킬 상태 재생 (원자적 실행으로 타이밍 문제 해결) /// 클라이언트: override 컨트롤러 적용 + 스킬 상태 재생 (원자적 실행으로 타이밍 문제 해결)
/// </summary> /// </summary>
[Rpc(SendTo.NotServer)] [Rpc(SendTo.NotServer)]
private void PlaySkillClipClientRpc(int clipIndex) private void PlaySkillClipClientRpc(int clipIndex, bool blendIn)
{ {
if (baseSkillClip == null || animator == null || baseController == null) return; if (baseSkillClip == null || animator == null || baseController == null) return;
if (clipIndex < 0 || clipIndex >= registeredClips.Count || registeredClips[clipIndex] == null) if (clipIndex < 0 || clipIndex >= registeredClips.Count || registeredClips[clipIndex] == null)
@@ -648,22 +682,21 @@ namespace Colosseum.Skills
return; return;
} }
var overrideController = new AnimatorOverrideController(baseController); if (!ApplyOverrideClip(registeredClips[clipIndex]))
overrideController[baseSkillClip] = registeredClips[clipIndex]; return;
animator.runtimeAnimatorController = overrideController; if (blendIn)
animator.Rebind(); animator.CrossFadeInFixedTime(GetSkillStateHash(), GetSkillEnterTransitionDuration(), 0, 0f);
animator.Update(0f); else
animator.Play(SKILL_STATE_NAME, 0, 0f); animator.Play(GetSkillStateHash(), 0, 0f);
} }
/// <summary> /// <summary>
/// 클라이언트: 기본 컨트롤러 복원 /// 클라이언트: 기본 컨트롤러 복원
/// </summary> /// </summary>
[Rpc(SendTo.NotServer)] [Rpc(SendTo.NotServer)]
private void RestoreBaseControllerClientRpc() private void RestoreBaseControllerClientRpc(int recoveryStateHash)
{ {
if (animator != null && baseController != null) RestoreBaseAnimationState(recoveryStateHash);
animator.runtimeAnimatorController = baseController;
} }
/// <summary> /// <summary>
@@ -874,7 +907,7 @@ namespace Colosseum.Skills
if (TryStartReleasePhase()) if (TryStartReleasePhase())
return true; return true;
RestoreBaseController(); RestoreBaseControllerIfNeeded();
CompleteCurrentSkillExecution(SkillExecutionResult.Cancelled); CompleteCurrentSkillExecution(SkillExecutionResult.Cancelled);
return true; return true;
} }
@@ -1078,7 +1111,7 @@ namespace Colosseum.Skills
if (TryStartReleasePhase()) if (TryStartReleasePhase())
return; return;
RestoreBaseController(); RestoreBaseControllerIfNeeded();
CompleteCurrentSkillExecution(SkillExecutionResult.Completed); CompleteCurrentSkillExecution(SkillExecutionResult.Completed);
} }
@@ -1138,7 +1171,7 @@ namespace Colosseum.Skills
? currentLoadoutEntry.GetResolvedAnimationSpeed() ? currentLoadoutEntry.GetResolvedAnimationSpeed()
: currentSkill.AnimationSpeed; : currentSkill.AnimationSpeed;
animator.speed = resolvedAnimationSpeed; animator.speed = resolvedAnimationSpeed;
PlaySkillClip(currentPhaseAnimationClips[0]); PlaySkillClip(currentPhaseAnimationClips[0], blendIn: false);
if (debugMode) if (debugMode)
Debug.Log($"[Skill] 해제 단계 시작: {currentSkill.SkillName}"); Debug.Log($"[Skill] 해제 단계 시작: {currentSkill.SkillName}");
@@ -1231,6 +1264,9 @@ namespace Colosseum.Skills
currentRepeatCount = 1; currentRepeatCount = 1;
currentIterationIndex = 0; currentIterationIndex = 0;
loopHoldRequested = false; loopHoldRequested = false;
cachedRecoveryStateHash = 0;
shouldBlendIntoCurrentSkill = true;
shouldRestoreToIdleAfterCurrentSkill = true;
} }
/// <summary> /// <summary>
@@ -1368,5 +1404,177 @@ namespace Colosseum.Skills
currentTriggeredTargetsBuffer.Clear(); 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);
}
} }
} }