using UnityEngine; using Unity.Netcode; using Colosseum.Abnormalities; using Colosseum.Skills; namespace Colosseum.Player { /// /// 플레이어의 피격 제어 상태를 관리합니다. /// 경직, 넉백, 다운 상태와 피격 애니메이션 재생을 담당합니다. /// [DisallowMultipleComponent] [RequireComponent(typeof(PlayerMovement))] public class HitReactionController : NetworkBehaviour { [Header("References")] [Tooltip("플레이어 이동 컴포넌트")] [SerializeField] private PlayerMovement playerMovement; [Tooltip("스킬 실행 관리자")] [SerializeField] private SkillController skillController; [Tooltip("플레이어 네트워크 상태")] [SerializeField] private PlayerNetworkController networkController; [Tooltip("이상상태 관리자")] [SerializeField] private AbnormalityManager abnormalityManager; [Tooltip("피격 애니메이션을 재생할 Animator")] [SerializeField] private Animator animator; [Header("Animation")] [Tooltip("일반 피격 트리거 이름")] [SerializeField] private string hitTriggerParam = "Hit"; [Tooltip("일반 피격 애니메이션 속도 배율 파라미터 이름")] [SerializeField] private string hitSpeedMultiplierParam = "HitSpeedMultiplier"; [Tooltip("다운 시작 트리거 이름")] [SerializeField] private string downTriggerParam = "Down"; [Tooltip("기상 트리거 이름")] [SerializeField] private string recoverTriggerParam = "Recover"; [Header("Settings")] [Tooltip("DownBegin 종료 후 구르기 가능 구간까지 대기 시간")] [Min(0f)] [SerializeField] private float downRecoverableDelayAfterBeginExit = 0.2f; [Tooltip("애니메이션 파라미터가 없을 때 경고 로그 출력")] [SerializeField] private bool logMissingAnimationParams = false; private readonly NetworkVariable isDowned = new NetworkVariable(false); private readonly NetworkVariable isDownRecoverable = new NetworkVariable(false); private readonly NetworkVariable isKnockbackActive = new NetworkVariable(false); private readonly NetworkVariable isStaggered = new NetworkVariable(false); private float downRemainingTime; private float downRecoverableDelayRemaining = -1f; private float knockbackRemainingTime; private float staggerRemainingTime; private bool isDownRecoveryAnimating; private bool isDownLoopTimingActive; /// /// 다운 상태 여부 /// public bool IsDowned => isDowned.Value; /// /// 다운 중 구르기 가능 구간 여부 /// public bool IsDownRecoverable => isDownRecoverable.Value; /// /// 넉백 강제 이동 진행 여부 /// public bool IsKnockbackActive => isKnockbackActive.Value; /// /// 경직 상태 여부 /// public bool IsStaggered => isStaggered.Value; /// /// 경직 면역 상태 여부 /// public bool IsStaggerImmune => abnormalityManager != null && abnormalityManager.IsStaggerImmune; /// /// 넉백 면역 상태 여부 /// public bool IsKnockbackImmune => abnormalityManager != null && abnormalityManager.IsKnockbackImmune; /// /// 다운 면역 상태 여부 /// public bool IsDownImmune => abnormalityManager != null && abnormalityManager.IsDownImmune; private void Awake() { ResolveReferences(); } public override void OnNetworkSpawn() { ResolveReferences(); } private void Update() { if (!IsServer) return; UpdateKnockbackState(Time.deltaTime); UpdateStaggerState(Time.deltaTime); UpdateDownState(Time.deltaTime); } /// /// 경직을 적용합니다. /// public void ApplyStagger(float duration, bool playHitAnimation = true, float hitAnimationSpeedMultiplier = 1f) { if (!IsServer) return; ResolveReferences(); if (networkController != null && networkController.IsDead) return; if (IsStaggerImmune || isDowned.Value) return; if (duration <= 0f) { ClearStaggerState(); return; } staggerRemainingTime = Mathf.Max(staggerRemainingTime, duration); isStaggered.Value = true; skillController?.CancelSkill(SkillCancelReason.Stagger); if (playHitAnimation) { TriggerAnimationRpc(hitTriggerParam, hitAnimationSpeedMultiplier); } } /// /// 넉백을 적용합니다. /// public void ApplyKnockback(Vector3 worldVelocity, float duration, bool playHitAnimation = true, float hitAnimationSpeedMultiplier = 1f) { if (!IsServer) return; ResolveReferences(); if (networkController != null && networkController.IsDead) return; if (IsKnockbackImmune || isDowned.Value) return; if (duration <= 0f || worldVelocity.sqrMagnitude <= 0.0001f) { ClearKnockbackState(); playerMovement?.ClearForcedMovement(); return; } knockbackRemainingTime = Mathf.Max(knockbackRemainingTime, duration); isKnockbackActive.Value = true; skillController?.CancelSkill(SkillCancelReason.HitReaction); playerMovement?.ApplyForcedMovement(worldVelocity, duration); if (playHitAnimation) { TriggerAnimationRpc(hitTriggerParam, hitAnimationSpeedMultiplier); } } /// /// 다운 상태를 적용합니다. /// public void ApplyDown(float duration) { if (!IsServer) return; ResolveReferences(); if (networkController != null && networkController.IsDead) return; if (IsDownImmune) return; downRemainingTime = Mathf.Max(downRemainingTime, duration); if (isDowned.Value) return; isDowned.Value = true; isDownRecoverable.Value = false; isDownRecoveryAnimating = false; isDownLoopTimingActive = false; downRecoverableDelayRemaining = -1f; ClearKnockbackState(); ClearStaggerState(); skillController?.CancelSkill(SkillCancelReason.HitReaction); playerMovement?.ClearForcedMovement(); TriggerAnimationRpc(downTriggerParam); } /// /// DownBegin 종료 시점을 전달받아 구르기 가능 타이머를 시작합니다. /// public void NotifyDownBeginExited() { if (!IsServer || !isDowned.Value || isDownRecoveryAnimating) return; isDownLoopTimingActive = true; downRecoverableDelayRemaining = downRecoverableDelayAfterBeginExit; } /// /// Recover 상태가 끝났을 때 다운 상태를 최종 해제합니다. /// public void NotifyDownRecoverAnimationExited() { if (!IsServer || !isDowned.Value || !isDownRecoveryAnimating) return; ClearDownState(); } /// /// 다운 회복 가능 구간에서 구르기를 사용하며 다운 상태를 종료합니다. /// public bool TryConsumeDownRecoverableEvade() { if (!IsServer || !isDowned.Value || !isDownRecoverable.Value) return false; ClearDownState(); return true; } /// /// 피격 상태를 즉시 초기화합니다. /// public void ClearHitReactionState(bool playRecoverAnimation = false) { if (!IsServer) return; ResolveReferences(); playerMovement?.ClearForcedMovement(); ClearKnockbackState(); ClearStaggerState(); if (!isDowned.Value) return; ClearDownState(); if (playRecoverAnimation) { TriggerAnimationRpc(recoverTriggerParam); } } [Rpc(SendTo.Everyone)] private void TriggerAnimationRpc(string triggerName, float hitAnimationSpeedMultiplier = 1f) { ResolveReferences(); if (animator == null || string.IsNullOrWhiteSpace(triggerName)) return; if (triggerName == hitTriggerParam) { SetFloatParameterIfExists(hitSpeedMultiplierParam, Mathf.Max(0.01f, hitAnimationSpeedMultiplier)); } if (!HasTrigger(triggerName)) { if (logMissingAnimationParams) { Debug.LogWarning($"[HitReaction] Animator trigger not found: {triggerName}"); } return; } animator.SetTrigger(triggerName); } private void SetFloatParameterIfExists(string parameterName, float value) { if (animator == null || string.IsNullOrWhiteSpace(parameterName)) return; for (int i = 0; i < animator.parameterCount; i++) { AnimatorControllerParameter parameter = animator.GetParameter(i); if (parameter.type == AnimatorControllerParameterType.Float && parameter.name == parameterName) { animator.SetFloat(parameterName, value); return; } } if (logMissingAnimationParams) { Debug.LogWarning($"[HitReaction] Animator float parameter not found: {parameterName}"); } } private bool HasTrigger(string triggerName) { if (animator == null || string.IsNullOrWhiteSpace(triggerName)) return false; for (int i = 0; i < animator.parameterCount; i++) { AnimatorControllerParameter parameter = animator.GetParameter(i); if (parameter.type == AnimatorControllerParameterType.Trigger && parameter.name == triggerName) return true; } return false; } private void ResolveReferences() { if (playerMovement == null) playerMovement = GetComponent(); if (skillController == null) skillController = GetComponent(); if (networkController == null) networkController = GetComponent(); if (abnormalityManager == null) abnormalityManager = GetComponent(); if (animator == null) animator = GetComponentInChildren(); } private void UpdateKnockbackState(float deltaTime) { if (!isKnockbackActive.Value) return; knockbackRemainingTime -= deltaTime; if (knockbackRemainingTime <= 0f) { ClearKnockbackState(); } } private void UpdateStaggerState(float deltaTime) { if (!isStaggered.Value) return; staggerRemainingTime -= deltaTime; if (staggerRemainingTime <= 0f) { ClearStaggerState(); } } private void UpdateDownState(float deltaTime) { if (!isDowned.Value) return; if (isDownLoopTimingActive) { downRemainingTime -= deltaTime; } if (!isDownRecoverable.Value && downRecoverableDelayRemaining >= 0f) { downRecoverableDelayRemaining -= deltaTime; if (downRecoverableDelayRemaining <= 0f) { EnterDownRecoverableState(); } } if (downRemainingTime <= 0f) { BeginDownRecoveryAnimation(); } } private void EnterDownRecoverableState() { if (!isDowned.Value) return; isDownRecoverable.Value = true; downRecoverableDelayRemaining = -1f; } private void BeginDownRecoveryAnimation() { if (!isDowned.Value || isDownRecoveryAnimating) return; EnterDownRecoverableState(); isDownRecoveryAnimating = true; isDownLoopTimingActive = false; downRemainingTime = 0f; TriggerAnimationRpc(recoverTriggerParam); } private void ClearDownState() { isDowned.Value = false; isDownRecoverable.Value = false; isDownRecoveryAnimating = false; isDownLoopTimingActive = false; downRemainingTime = 0f; downRecoverableDelayRemaining = -1f; } private void ClearKnockbackState() { isKnockbackActive.Value = false; knockbackRemainingTime = 0f; } private void ClearStaggerState() { isStaggered.Value = false; staggerRemainingTime = 0f; } } }