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); + } } }