diff --git a/Assets/_Game/Scripts/AI/BehaviorActions/Actions/BossPatternActionBase.cs b/Assets/_Game/Scripts/AI/BehaviorActions/Actions/BossPatternActionBase.cs
index 02c8be3f..4b80d8f5 100644
--- a/Assets/_Game/Scripts/AI/BehaviorActions/Actions/BossPatternActionBase.cs
+++ b/Assets/_Game/Scripts/AI/BehaviorActions/Actions/BossPatternActionBase.cs
@@ -252,6 +252,10 @@ public abstract partial class BossPatternActionBase : Action
return Status.Failure;
}
+ runtimeState.SetCurrentPatternSkillBoundary(
+ startsFromIdle: IsFirstSkillStep(currentStepIndex),
+ returnsToIdle: IsLastSkillStep(currentStepIndex));
+
GameObject skillTarget = activeTarget;
if (step.Skill.JumpToTarget)
{
@@ -376,6 +380,7 @@ public abstract partial class BossPatternActionBase : Action
if (isChargeWaiting)
EndChargeWait(broken: false);
+ runtimeState?.SetCurrentPatternSkillBoundary(false, false);
activePattern = null;
activeTarget = null;
currentStepIndex = 0;
@@ -384,6 +389,36 @@ public abstract partial class BossPatternActionBase : Action
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)
{
runtimeState?.CompletePatternExecution(activePattern, result);
diff --git a/Assets/_Game/Scripts/Enemy/BossBehaviorRuntimeState.cs b/Assets/_Game/Scripts/Enemy/BossBehaviorRuntimeState.cs
index bbca5fa5..156a7001 100644
--- a/Assets/_Game/Scripts/Enemy/BossBehaviorRuntimeState.cs
+++ b/Assets/_Game/Scripts/Enemy/BossBehaviorRuntimeState.cs
@@ -64,6 +64,9 @@ namespace Colosseum.Enemy
protected float nextPatternReadyTime;
protected BossPatternExecutionResult lastPatternExecutionResult;
protected BossPatternData lastExecutedPattern;
+ protected BossPatternData activePattern;
+ protected bool currentPatternSkillStartsFromIdle;
+ protected bool currentPatternSkillReturnsToIdle;
protected GameObject lastReviveCaster;
protected GameObject lastRevivedTarget;
protected float lastReviveEventTime = float.NegativeInfinity;
@@ -108,6 +111,21 @@ namespace Colosseum.Enemy
///
public BossPatternData LastExecutedPattern => lastExecutedPattern;
+ ///
+ /// 현재 패턴 실행 중인지 여부
+ ///
+ public bool IsExecutingPattern => activePattern != null && lastPatternExecutionResult == BossPatternExecutionResult.Running;
+
+ ///
+ /// 현재 스킬 스텝이 패턴 시작에서 Idle과 이어져야 하는지 여부
+ ///
+ public bool CurrentPatternSkillStartsFromIdle => currentPatternSkillStartsFromIdle;
+
+ ///
+ /// 현재 스킬 스텝이 패턴 종료에서 Idle로 돌아가야 하는지 여부
+ ///
+ public bool CurrentPatternSkillReturnsToIdle => currentPatternSkillReturnsToIdle;
+
///
/// EnemyBase 접근자
///
@@ -204,8 +222,11 @@ namespace Colosseum.Enemy
///
public void BeginPatternExecution(BossPatternData pattern)
{
+ activePattern = pattern;
lastExecutedPattern = pattern;
lastPatternExecutionResult = BossPatternExecutionResult.Running;
+ currentPatternSkillStartsFromIdle = false;
+ currentPatternSkillReturnsToIdle = false;
}
///
@@ -215,11 +236,23 @@ namespace Colosseum.Enemy
{
lastExecutedPattern = pattern;
lastPatternExecutionResult = result;
+ activePattern = null;
+ currentPatternSkillStartsFromIdle = false;
+ currentPatternSkillReturnsToIdle = false;
if (pattern != null && IsTerminalPatternExecutionResult(result))
StartCommonPatternInterval();
}
+ ///
+ /// 현재 실행할 패턴 스킬이 패턴 시작/종료 경계인지 기록합니다.
+ ///
+ public void SetCurrentPatternSkillBoundary(bool startsFromIdle, bool returnsToIdle)
+ {
+ currentPatternSkillStartsFromIdle = startsFromIdle;
+ currentPatternSkillReturnsToIdle = returnsToIdle;
+ }
+
///
/// 부활 스킬 사용 사실을 보스 AI에 알립니다.
///
diff --git a/Assets/_Game/Scripts/Enemy/BossPatternDebugRunner.cs b/Assets/_Game/Scripts/Enemy/BossPatternDebugRunner.cs
index f24000a1..8bcd8341 100644
--- a/Assets/_Game/Scripts/Enemy/BossPatternDebugRunner.cs
+++ b/Assets/_Game/Scripts/Enemy/BossPatternDebugRunner.cs
@@ -210,6 +210,10 @@ namespace Colosseum.Enemy
yield break;
}
+ runtimeState.SetCurrentPatternSkillBoundary(
+ startsFromIdle: IsFirstSkillStep(pattern, i),
+ returnsToIdle: IsLastSkillStep(pattern, i));
+
GameObject stepTarget = currentTarget;
if (step.Skill.JumpToTarget)
{
@@ -339,6 +343,8 @@ namespace Colosseum.Enemy
if (isChargeWaiting)
EndChargeWait(broken: false);
+ runtimeState?.SetCurrentPatternSkillBoundary(false, false);
+
if (currentPattern != null && runtimeState != null)
{
if (applyCooldown)
@@ -355,6 +361,36 @@ namespace Colosseum.Enemy
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)
{
if (runtimeState == null || pattern == null)
diff --git a/Assets/_Game/Scripts/Skills/SkillController.cs b/Assets/_Game/Scripts/Skills/SkillController.cs
index f4b989df..f68e5e3b 100644
--- a/Assets/_Game/Scripts/Skills/SkillController.cs
+++ b/Assets/_Game/Scripts/Skills/SkillController.cs
@@ -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 currentLoopExitEffects = new();
private readonly List currentReleaseStartEffects = new();
private bool loopHoldRequested = false;
+ private bool shouldBlendIntoCurrentSkill = true;
+ private bool shouldRestoreToIdleAfterCurrentSkill = true;
+ private bool isBossPatternBoundarySkill = false;
// 쿨타임 추적
private Dictionary cooldownTracker = new Dictionary();
+ 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
///
/// 스킬 클립으로 Override Controller 생성 후 재생
///
- 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);
}
///
@@ -624,22 +650,30 @@ namespace Colosseum.Skills
///
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);
+ }
+
+ ///
+ /// 현재 스킬이 Idle 복귀가 필요한 경계 스킬일 때만 기본 상태 복귀를 수행합니다.
+ ///
+ private void RestoreBaseControllerIfNeeded()
+ {
+ if (!shouldRestoreToIdleAfterCurrentSkill)
+ return;
+
+ RestoreBaseController();
}
///
/// 클라이언트: override 컨트롤러 적용 + 스킬 상태 재생 (원자적 실행으로 타이밍 문제 해결)
///
[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);
}
///
/// 클라이언트: 기본 컨트롤러 복원
///
[Rpc(SendTo.NotServer)]
- private void RestoreBaseControllerClientRpc()
+ private void RestoreBaseControllerClientRpc(int recoveryStateHash)
{
- if (animator != null && baseController != null)
- animator.runtimeAnimatorController = baseController;
+ RestoreBaseAnimationState(recoveryStateHash);
}
///
@@ -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;
}
///
@@ -1368,5 +1404,177 @@ namespace Colosseum.Skills
currentTriggeredTargetsBuffer.Clear();
}
+
+ ///
+ /// 기본 컨트롤러를 기반으로 런타임 OverrideController를 준비합니다.
+ ///
+ private bool EnsureRuntimeOverrideController()
+ {
+ if (animator == null || baseController == null || baseSkillClip == null)
+ return false;
+
+ if (runtimeOverrideController == null || runtimeOverrideController.runtimeAnimatorController != baseController)
+ runtimeOverrideController = new AnimatorOverrideController(baseController);
+
+ return true;
+ }
+
+ ///
+ /// 지정한 클립을 Skill 상태 override로 적용합니다.
+ ///
+ 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;
+ }
+
+ ///
+ /// 현재 스킬이 패턴 경계에서 Idle과 블렌드해야 하는지 결정합니다.
+ ///
+ private void ResolveSkillBoundaryTransitions()
+ {
+ shouldBlendIntoCurrentSkill = true;
+ shouldRestoreToIdleAfterCurrentSkill = true;
+ isBossPatternBoundarySkill = false;
+
+ Colosseum.Enemy.BossBehaviorRuntimeState runtimeState = GetComponent();
+ if (runtimeState == null || !runtimeState.IsExecutingPattern)
+ return;
+
+ isBossPatternBoundarySkill = true;
+ shouldBlendIntoCurrentSkill = runtimeState.CurrentPatternSkillStartsFromIdle;
+ shouldRestoreToIdleAfterCurrentSkill = runtimeState.CurrentPatternSkillReturnsToIdle;
+ }
+
+ ///
+ /// 현재 재생할 클립이 스킬 시작 블렌드 대상인지 반환합니다.
+ ///
+ private bool ShouldBlendIntoClip()
+ {
+ if (isBossPatternBoundarySkill)
+ return false;
+
+ if (!shouldBlendIntoCurrentSkill)
+ return false;
+
+ if (isPlayingReleasePhase)
+ return false;
+
+ return currentClipSequenceIndex == 0 && currentIterationIndex == 1;
+ }
+
+ ///
+ /// 현재 스킬 진입에 사용할 전환 시간을 반환합니다.
+ ///
+ private float GetSkillEnterTransitionDuration()
+ {
+ if (isBossPatternBoundarySkill)
+ return bossPatternEnterTransitionDuration;
+
+ return skillEnterTransitionDuration;
+ }
+
+ ///
+ /// 현재 스킬 종료에 사용할 전환 시간을 반환합니다.
+ ///
+ private float GetSkillExitTransitionDuration()
+ {
+ if (isBossPatternBoundarySkill && shouldRestoreToIdleAfterCurrentSkill)
+ return bossPatternExitTransitionDuration;
+
+ return skillExitTransitionDuration;
+ }
+
+ ///
+ /// 스킬 시작 전 기본 상태를 저장합니다.
+ ///
+ private void CacheRecoveryState()
+ {
+ if (animator == null)
+ {
+ cachedRecoveryStateHash = 0;
+ return;
+ }
+
+ AnimatorStateInfo currentState = animator.GetCurrentAnimatorStateInfo(0);
+ cachedRecoveryStateHash = currentState.fullPathHash;
+ }
+
+ ///
+ /// 스킬 상태 해시를 반환합니다.
+ ///
+ private static int GetSkillStateHash()
+ {
+ return Animator.StringToHash($"{BaseLayerName}.{SKILL_STATE_NAME}");
+ }
+
+ ///
+ /// 스킬 종료 후 복귀할 상태 해시를 결정합니다.
+ ///
+ 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;
+ }
+
+ ///
+ /// 보스 패턴 종료 시 현재 페이즈에 맞는 Idle 상태 해시를 반환합니다.
+ ///
+ private int ResolveBossIdleStateHash()
+ {
+ Colosseum.Enemy.BossBehaviorRuntimeState runtimeState = GetComponent();
+ 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;
+ }
+
+ ///
+ /// 기본 스킬 클립 Override를 복원하고 지정한 상태로 부드럽게 복귀합니다.
+ ///
+ 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);
+ }
}
}