- HitReactionController에 경직, 다운 회복 가능 구간, Hit 속도 배율 파라미터 처리를 추가 - DownBegin/Recover 상태 종료를 StateMachineBehaviour로 받아 구르기 허용 구간과 다운 해제를 분리 - 드로그 발구르기를 경직 이펙트로 전환하고 넉백/경직 이펙트에서 Hit 애니메이션 속도 배율을 설정 가능하게 정리 - PlayMode 테스트를 추가해 경직/넉백이 Hit 애니메이션 속도 배율을 실제로 반영하는지 자동 검증
434 lines
13 KiB
C#
434 lines
13 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;
|
|
|
|
/// <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 IsHitReactionImmune => abnormalityManager != null && abnormalityManager.IsHitReactionImmune;
|
|
|
|
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 (IsHitReactionImmune || 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 (IsHitReactionImmune || 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 (IsHitReactionImmune)
|
|
return;
|
|
|
|
downRemainingTime = Mathf.Max(downRemainingTime, duration);
|
|
|
|
if (isDowned.Value)
|
|
return;
|
|
|
|
isDowned.Value = true;
|
|
isDownRecoverable.Value = false;
|
|
isDownRecoveryAnimating = 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;
|
|
|
|
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;
|
|
|
|
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;
|
|
downRemainingTime = 0f;
|
|
TriggerAnimationRpc(recoverTriggerParam);
|
|
}
|
|
|
|
private void ClearDownState()
|
|
{
|
|
isDowned.Value = false;
|
|
isDownRecoverable.Value = false;
|
|
isDownRecoveryAnimating = false;
|
|
downRemainingTime = 0f;
|
|
downRecoverableDelayRemaining = -1f;
|
|
}
|
|
|
|
private void ClearKnockbackState()
|
|
{
|
|
isKnockbackActive.Value = false;
|
|
knockbackRemainingTime = 0f;
|
|
}
|
|
|
|
private void ClearStaggerState()
|
|
{
|
|
isStaggered.Value = false;
|
|
staggerRemainingTime = 0f;
|
|
}
|
|
}
|
|
}
|