Files
Colosseum/Assets/_Game/Scripts/Player/HitReactionController.cs
dal4segno 0fa23d4389 feat: 피격 반응 면역을 경직/넉백/다운으로 분리
- 상태이상 데이터와 관리자에서 단일 피격 면역을 경직, 넉백, 다운 개별 면역으로 분리

- 플레이어 프리팹과 디버그 메뉴, 공용 경직 애니메이션을 갱신해 분리된 면역 상태를 테스트 가능하게 정리

- PlayMode 테스트를 추가해 각 면역이 대응하는 반응만 차단하는지 검증
2026-04-09 23:22:02 +09:00

452 lines
14 KiB
C#

using UnityEngine;
using Unity.Netcode;
using Colosseum.Abnormalities;
using Colosseum.Skills;
namespace Colosseum.Player
{
/// <summary>
/// 플레이어의 피격 제어 상태를 관리합니다.
/// 경직, 넉백, 다운 상태와 피격 애니메이션 재생을 담당합니다.
/// </summary>
[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<bool> isDowned = new NetworkVariable<bool>(false);
private readonly NetworkVariable<bool> isDownRecoverable = new NetworkVariable<bool>(false);
private readonly NetworkVariable<bool> isKnockbackActive = new NetworkVariable<bool>(false);
private readonly NetworkVariable<bool> isStaggered = new NetworkVariable<bool>(false);
private float downRemainingTime;
private float downRecoverableDelayRemaining = -1f;
private float knockbackRemainingTime;
private float staggerRemainingTime;
private bool isDownRecoveryAnimating;
private bool isDownLoopTimingActive;
/// <summary>
/// 다운 상태 여부
/// </summary>
public bool IsDowned => isDowned.Value;
/// <summary>
/// 다운 중 구르기 가능 구간 여부
/// </summary>
public bool IsDownRecoverable => isDownRecoverable.Value;
/// <summary>
/// 넉백 강제 이동 진행 여부
/// </summary>
public bool IsKnockbackActive => isKnockbackActive.Value;
/// <summary>
/// 경직 상태 여부
/// </summary>
public bool IsStaggered => isStaggered.Value;
/// <summary>
/// 경직 면역 상태 여부
/// </summary>
public bool IsStaggerImmune => abnormalityManager != null && abnormalityManager.IsStaggerImmune;
/// <summary>
/// 넉백 면역 상태 여부
/// </summary>
public bool IsKnockbackImmune => abnormalityManager != null && abnormalityManager.IsKnockbackImmune;
/// <summary>
/// 다운 면역 상태 여부
/// </summary>
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);
}
/// <summary>
/// 경직을 적용합니다.
/// </summary>
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);
}
}
/// <summary>
/// 넉백을 적용합니다.
/// </summary>
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);
}
}
/// <summary>
/// 다운 상태를 적용합니다.
/// </summary>
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);
}
/// <summary>
/// DownBegin 종료 시점을 전달받아 구르기 가능 타이머를 시작합니다.
/// </summary>
public void NotifyDownBeginExited()
{
if (!IsServer || !isDowned.Value || isDownRecoveryAnimating)
return;
isDownLoopTimingActive = true;
downRecoverableDelayRemaining = downRecoverableDelayAfterBeginExit;
}
/// <summary>
/// Recover 상태가 끝났을 때 다운 상태를 최종 해제합니다.
/// </summary>
public void NotifyDownRecoverAnimationExited()
{
if (!IsServer || !isDowned.Value || !isDownRecoveryAnimating)
return;
ClearDownState();
}
/// <summary>
/// 다운 회복 가능 구간에서 구르기를 사용하며 다운 상태를 종료합니다.
/// </summary>
public bool TryConsumeDownRecoverableEvade()
{
if (!IsServer || !isDowned.Value || !isDownRecoverable.Value)
return false;
ClearDownState();
return true;
}
/// <summary>
/// 피격 상태를 즉시 초기화합니다.
/// </summary>
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<PlayerMovement>();
if (skillController == null)
skillController = GetComponent<SkillController>();
if (networkController == null)
networkController = GetComponent<PlayerNetworkController>();
if (abnormalityManager == null)
abnormalityManager = GetComponent<AbnormalityManager>();
if (animator == null)
animator = GetComponentInChildren<Animator>();
}
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;
}
}
}