feat: 플레이어 다운 및 넉백 피격 반응 추가

- HitReactionController로 다운과 넉백 전용 로직을 분리
- 다운 시작, 루프, 회복 애니메이션과 DownEffect를 연결
- 행동 상태와 스킬 취소가 피격 반응과 연동되도록 정리
- 자동 검증 러너에 다운 및 넉백 검증을 추가
This commit is contained in:
2026-03-19 23:35:51 +09:00
parent a65ba77931
commit 9791b11d13
29 changed files with 7108 additions and 55 deletions

View File

@@ -0,0 +1,211 @@
using UnityEngine;
using Unity.Netcode;
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("피격 애니메이션을 재생할 Animator")]
[SerializeField] private Animator animator;
[Header("Animation")]
[Tooltip("일반 피격 트리거 이름")]
[SerializeField] private string hitTriggerParam = "Hit";
[Tooltip("다운 시작 트리거 이름")]
[SerializeField] private string downTriggerParam = "Down";
[Tooltip("기상 트리거 이름")]
[SerializeField] private string recoverTriggerParam = "Recover";
[Header("Settings")]
[Tooltip("애니메이션 파라미터가 없을 때 경고 로그 출력")]
[SerializeField] private bool logMissingAnimationParams = false;
private readonly NetworkVariable<bool> isDowned = new NetworkVariable<bool>(false);
private float downRemainingTime;
/// <summary>
/// 다운 상태 여부
/// </summary>
public bool IsDowned => isDowned.Value;
/// <summary>
/// 넉백 강제 이동 진행 여부
/// </summary>
public bool IsKnockbackActive => playerMovement != null && playerMovement.IsForcedMoving;
private void Awake()
{
ResolveReferences();
}
public override void OnNetworkSpawn()
{
ResolveReferences();
}
private void Update()
{
if (!IsServer || !isDowned.Value)
return;
downRemainingTime -= Time.deltaTime;
if (downRemainingTime <= 0f)
{
RecoverFromDown();
}
}
/// <summary>
/// 넉백을 적용합니다.
/// </summary>
public void ApplyKnockback(Vector3 worldVelocity, float duration, bool playHitAnimation = true)
{
if (!IsServer)
return;
ResolveReferences();
if (networkController != null && networkController.IsDead)
return;
playerMovement?.ApplyForcedMovement(worldVelocity, duration);
if (playHitAnimation)
{
TriggerAnimationRpc(hitTriggerParam);
}
}
/// <summary>
/// 다운 상태를 적용합니다.
/// </summary>
public void ApplyDown(float duration)
{
if (!IsServer)
return;
ResolveReferences();
if (networkController != null && networkController.IsDead)
return;
downRemainingTime = Mathf.Max(downRemainingTime, duration);
if (isDowned.Value)
return;
isDowned.Value = true;
skillController?.CancelSkill(SkillCancelReason.HitReaction);
playerMovement?.ClearForcedMovement();
TriggerAnimationRpc(downTriggerParam);
}
/// <summary>
/// 다운 상태를 해제합니다.
/// </summary>
public void RecoverFromDown()
{
if (!IsServer || !isDowned.Value)
return;
isDowned.Value = false;
downRemainingTime = 0f;
TriggerAnimationRpc(recoverTriggerParam);
}
/// <summary>
/// 피격 상태를 즉시 초기화합니다.
/// </summary>
public void ClearHitReactionState(bool playRecoverAnimation = false)
{
if (!IsServer)
return;
ResolveReferences();
playerMovement?.ClearForcedMovement();
if (!isDowned.Value)
return;
isDowned.Value = false;
downRemainingTime = 0f;
if (playRecoverAnimation)
{
TriggerAnimationRpc(recoverTriggerParam);
}
}
[Rpc(SendTo.Everyone)]
private void TriggerAnimationRpc(string triggerName)
{
ResolveReferences();
if (animator == null || string.IsNullOrWhiteSpace(triggerName))
return;
if (!HasTrigger(triggerName))
{
if (logMissingAnimationParams)
{
Debug.LogWarning($"[HitReaction] Animator trigger not found: {triggerName}");
}
return;
}
animator.SetTrigger(triggerName);
}
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 (animator == null)
animator = GetComponentInChildren<Animator>();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ebad07a2d5fc29b4ba061866bfa1568e

View File

@@ -93,7 +93,7 @@ namespace Colosseum.Player
GUILayout.Label($"사망 상태: {(networkController != null && networkController.IsDead ? "Dead" : "Alive")}");
if (TryGetComponent<PlayerActionState>(out var actionState))
{
GUILayout.Label($"무적 상태: {(actionState.IsDamageImmune ? "Immune" : "Normal")} / 이동:{actionState.CanMove} / 스킬:{actionState.CanUseSkills}");
GUILayout.Label($"무적:{(actionState.IsDamageImmune ? "On" : "Off")} / 다운:{(actionState.IsDowned ? "On" : "Off")} / 이동:{actionState.CanMove} / 스킬:{actionState.CanUseSkills}");
}
GUILayout.Label("입력 예시: 기절 / Data_Abnormality_Player_Stun / 0");

View File

@@ -27,6 +27,8 @@ namespace Colosseum.Player
[SerializeField] private PlayerNetworkController networkController;
[SerializeField] private PlayerSkillInput skillInput;
[SerializeField] private SkillController skillController;
[SerializeField] private PlayerMovement playerMovement;
[SerializeField] private HitReactionController hitReactionController;
[Header("Test Data")]
[SerializeField] private AbnormalityData stunData;
@@ -84,7 +86,7 @@ namespace Colosseum.Player
ResolveReferences();
LoadDefaultAssetsIfNeeded();
if (abnormalityManager == null || actionState == null || networkController == null || stunData == null || silenceData == null)
if (abnormalityManager == null || actionState == null || networkController == null || playerMovement == null || hitReactionController == null || stunData == null || silenceData == null)
{
Debug.LogWarning("[AbnormalityVerification] Missing references or test data.");
yield break;
@@ -105,9 +107,16 @@ namespace Colosseum.Player
Verify("초기 상태: 이동 가능", actionState.CanMove);
Verify("초기 상태: 스킬 사용 가능", actionState.CanUseSkills);
Verify("초기 상태: 무적 상태 아님", !actionState.IsDamageImmune);
Verify("초기 상태: 마지막 취소 이유 없음", skillController == null || skillController.LastCancelReason == SkillCancelReason.None);
yield return RunInvincibilitySkillVerification();
yield return RunStunCancellationVerification();
yield return RunDownVerification();
yield return RunKnockbackVerification();
abnormalityManager.ApplyAbnormality(stunData, gameObject);
yield return new WaitForSeconds(settleDelay);
@@ -187,6 +196,10 @@ namespace Colosseum.Player
skillInput = GetComponent<PlayerSkillInput>();
if (skillController == null)
skillController = GetComponent<SkillController>();
if (playerMovement == null)
playerMovement = GetComponent<PlayerMovement>();
if (hitReactionController == null)
hitReactionController = GetComponent<HitReactionController>();
}
private void LoadDefaultAssetsIfNeeded()
@@ -276,6 +289,109 @@ namespace Colosseum.Player
Verify("무적 해제: IsDamageImmune false", !actionState.IsDamageImmune);
}
private IEnumerator RunStunCancellationVerification()
{
SkillData cancellableSkill = FindCancellableSkill();
if (cancellableSkill == null)
{
AppendLine("[SKIP] 기절 강제 취소 검증: 테스트용 스킬이 없습니다.");
yield break;
}
if (skillController != null)
{
yield return WaitForConditionOrTimeout(() => !skillController.IsPlayingAnimation, 1.5f);
}
if (skillController == null || !skillController.ExecuteSkill(cancellableSkill))
{
Verify("기절 강제 취소 검증: 스킬 실행 성공", false);
yield break;
}
yield return new WaitForSeconds(0.05f);
abnormalityManager.ApplyAbnormality(stunData, gameObject);
yield return WaitForConditionOrTimeout(() => abnormalityManager.IsStunned, settleDelay + 0.5f);
yield return new WaitForSeconds(0.05f);
Verify("기절 강제 취소: 스킬 애니메이션 중단", !skillController.IsPlayingAnimation);
Verify("기절 강제 취소: 취소 이유 기록", skillController.LastCancelReason == SkillCancelReason.Stun);
yield return new WaitForSeconds(stunData.duration + settleDelay);
}
private IEnumerator RunKnockbackVerification()
{
abnormalityManager.RemoveAllAbnormalities();
hitReactionController.ClearHitReactionState();
yield return new WaitForSeconds(settleDelay);
Vector3 startPosition = transform.position;
Vector3 knockbackVelocity = Vector3.back * 6f;
RequestKnockbackRpc(knockbackVelocity, 0.2f);
yield return new WaitForSeconds(0.3f);
float movedDistance = Vector3.Distance(startPosition, transform.position);
Verify("넉백 적용: 위치 이동 발생", movedDistance > 0.2f);
Verify("넉백 적용: 강제 이동 종료", !playerMovement.IsForcedMoving);
}
private IEnumerator RunDownVerification()
{
SkillData cancellableSkill = FindCancellableSkill();
if (cancellableSkill == null)
{
AppendLine("[SKIP] 다운 검증: 테스트용 스킬이 없습니다.");
yield break;
}
if (skillController != null)
{
yield return WaitForConditionOrTimeout(() => !skillController.IsPlayingAnimation, 1.5f);
}
if (skillController == null || !skillController.ExecuteSkill(cancellableSkill))
{
Verify("다운 강제 취소 검증: 스킬 실행 성공", false);
yield break;
}
yield return new WaitForSeconds(0.05f);
RequestDownRpc(0.6f);
yield return WaitForConditionOrTimeout(() => hitReactionController.IsDowned, settleDelay + 0.5f);
yield return new WaitForSeconds(0.05f);
Verify("다운 적용: IsDowned", hitReactionController.IsDowned);
Verify("다운 적용: ActionState.IsDowned", actionState.IsDowned);
Verify("다운 강제 취소: 스킬 애니메이션 중단", !skillController.IsPlayingAnimation);
Verify("다운 강제 취소: 취소 이유 기록", skillController.LastCancelReason == SkillCancelReason.HitReaction);
Verify("다운 적용: 이동 불가", !actionState.CanMove);
Verify("다운 적용: 점프 불가", !actionState.CanJump);
Verify("다운 적용: 스킬 사용 불가", !actionState.CanUseSkills);
Verify("다운 적용: 이동속도 0", Mathf.Approximately(actionState.MoveSpeedMultiplier, 0f));
yield return WaitForConditionOrTimeout(() => !hitReactionController.IsDowned, 1.5f);
Verify("다운 해제: IsDowned false", !hitReactionController.IsDowned);
Verify("다운 해제: 이동 가능 복구", actionState.CanMove);
Verify("다운 해제: 스킬 사용 가능 복구", actionState.CanUseSkills);
}
[Rpc(SendTo.Server)]
private void RequestDownRpc(float duration)
{
hitReactionController?.ApplyDown(duration);
}
[Rpc(SendTo.Server)]
private void RequestKnockbackRpc(Vector3 velocity, float duration)
{
hitReactionController?.ApplyKnockback(velocity, duration, false);
}
private float GetSkillDuration(SkillData skill)
{
if (skill == null || skill.SkillClip == null)
@@ -284,6 +400,21 @@ namespace Colosseum.Player
return Mathf.Max(settleDelay, skill.SkillClip.length / Mathf.Max(0.1f, skill.AnimationSpeed));
}
private SkillData FindCancellableSkill()
{
if (skillInput == null)
return null;
for (int i = 0; i < 6; i++)
{
SkillData skill = skillInput.GetSkill(i);
if (skill != null)
return skill;
}
return null;
}
private IEnumerator WaitForConditionOrTimeout(Func<bool> predicate, float timeout)
{
float elapsed = 0f;

View File

@@ -22,6 +22,9 @@ namespace Colosseum.Player
[Tooltip("스킬 실행 관리자")]
[SerializeField] private SkillController skillController;
[Tooltip("피격 제어 관리자")]
[SerializeField] private HitReactionController hitReactionController;
[Tooltip("관전 관리자")]
[SerializeField] private PlayerSpectator spectator;
@@ -35,6 +38,11 @@ namespace Colosseum.Player
/// </summary>
public bool IsStunned => abnormalityManager != null && abnormalityManager.IsStunned;
/// <summary>
/// 다운 상태 여부
/// </summary>
public bool IsDowned => hitReactionController != null && hitReactionController.IsDowned;
/// <summary>
/// 침묵 상태 여부
/// </summary>
@@ -68,17 +76,17 @@ namespace Colosseum.Player
/// <summary>
/// 플레이어가 직접 이동 입력을 사용할 수 있는지 여부
/// </summary>
public bool CanMove => CanReceiveInput && !IsStunned && !BlocksMovementForCurrentSkill();
public bool CanMove => CanReceiveInput && !IsStunned && !IsDowned && !BlocksMovementForCurrentSkill();
/// <summary>
/// 점프 가능 여부
/// </summary>
public bool CanJump => CanReceiveInput && !IsStunned && !BlocksJumpForCurrentSkill();
public bool CanJump => CanReceiveInput && !IsStunned && !IsDowned && !BlocksJumpForCurrentSkill();
/// <summary>
/// 일반 스킬 시작 가능 여부
/// </summary>
public bool CanUseSkills => CanReceiveInput && !IsStunned && !IsSilenced && !BlocksSkillUseForCurrentSkill();
public bool CanUseSkills => CanReceiveInput && !IsStunned && !IsDowned && !IsSilenced && !BlocksSkillUseForCurrentSkill();
/// <summary>
/// 특정 스킬의 시작 가능 여부.
@@ -89,7 +97,7 @@ namespace Colosseum.Player
if (skill == null)
return false;
if (!CanReceiveInput || IsStunned || IsSilenced)
if (!CanReceiveInput || IsStunned || IsDowned || IsSilenced)
return false;
return !BlocksSkillUseForCurrentSkill();
@@ -102,7 +110,7 @@ namespace Colosseum.Player
{
get
{
if (!CanReceiveInput || IsStunned)
if (!CanReceiveInput || IsStunned || IsDowned)
return 0f;
return abnormalityManager != null ? abnormalityManager.MoveSpeedMultiplier : 1f;
@@ -117,6 +125,8 @@ namespace Colosseum.Player
abnormalityManager = GetComponent<AbnormalityManager>();
if (skillController == null)
skillController = GetComponent<SkillController>();
if (hitReactionController == null)
hitReactionController = GetOrCreateHitReactionController();
if (spectator == null)
spectator = GetComponentInChildren<PlayerSpectator>();
}
@@ -144,5 +154,14 @@ namespace Colosseum.Player
return CurrentSkill == null || CurrentSkill.BlockOtherSkillsWhileCasting;
}
private HitReactionController GetOrCreateHitReactionController()
{
HitReactionController foundController = GetComponent<HitReactionController>();
if (foundController != null)
return foundController;
return gameObject.AddComponent<HitReactionController>();
}
}
}

View File

@@ -34,6 +34,8 @@ namespace Colosseum.Player
private InputSystem_Actions inputActions;
private bool isJumping;
private bool wasGrounded;
private Vector3 forcedMovementVelocity;
private float forcedMovementRemaining;
// 클라이언트가 기록, 서버가 소비하는 월드 스페이스 이동 방향
private NetworkVariable<Vector2> netMoveInput = new NetworkVariable<Vector2>(
@@ -48,6 +50,7 @@ namespace Colosseum.Player
public float CurrentMoveSpeed => netMoveInput.Value.magnitude * moveSpeed * GetMoveSpeedMultiplier();
public bool IsGrounded => characterController != null ? characterController.isGrounded : false;
public bool IsJumping => isJumping;
public bool IsForcedMoving => forcedMovementRemaining > 0f && forcedMovementVelocity.sqrMagnitude > 0.0001f;
public override void OnNetworkSpawn()
{
@@ -115,6 +118,7 @@ namespace Colosseum.Player
{
CleanupInputActions();
moveInput = Vector2.zero;
ClearForcedMovement();
}
private void OnEnable()
@@ -143,6 +147,7 @@ namespace Colosseum.Player
public override void OnNetworkDespawn()
{
CleanupInputActions();
ClearForcedMovement();
}
private void OnMovePerformed(InputAction.CallbackContext context) => moveInput = context.ReadValue<Vector2>();
@@ -259,10 +264,12 @@ namespace Colosseum.Player
{
if (characterController == null) return;
Vector3 forcedDelta = ConsumeForcedMovementDelta(Time.deltaTime);
if (skillController != null && skillController.IsPlayingAnimation)
{
if (!skillController.UsesRootMotion)
characterController.Move(velocity * Time.deltaTime);
characterController.Move(velocity * Time.deltaTime + forcedDelta);
return;
}
@@ -278,7 +285,7 @@ namespace Colosseum.Player
moveDirection = Vector3.zero;
float actualMoveSpeed = moveSpeed * GetMoveSpeedMultiplier();
characterController.Move((moveDirection * actualMoveSpeed + velocity) * Time.deltaTime);
characterController.Move((moveDirection * actualMoveSpeed + velocity) * Time.deltaTime + forcedDelta);
if (moveDirection != Vector3.zero)
{
@@ -323,6 +330,33 @@ namespace Colosseum.Player
return actionState.MoveSpeedMultiplier;
}
/// <summary>
/// 넉백처럼 입력과 무관한 강제 이동을 적용합니다.
/// </summary>
public void ApplyForcedMovement(Vector3 worldVelocity, float duration)
{
if (!IsServer)
return;
if (duration <= 0f || worldVelocity.sqrMagnitude <= 0.0001f)
{
ClearForcedMovement();
return;
}
forcedMovementVelocity = worldVelocity;
forcedMovementRemaining = duration;
}
/// <summary>
/// 강제 이동 상태를 즉시 초기화합니다.
/// </summary>
public void ClearForcedMovement()
{
forcedMovementVelocity = Vector3.zero;
forcedMovementRemaining = 0f;
}
private PlayerActionState GetOrCreateActionState()
{
var foundState = GetComponent<PlayerActionState>();
@@ -343,6 +377,7 @@ namespace Colosseum.Player
if (!skillController.UsesRootMotion) return;
Vector3 deltaPosition = animator.deltaPosition;
Vector3 forcedDelta = ConsumeForcedMovementDelta(Time.deltaTime);
if (blockedDirection != Vector3.zero)
{
@@ -357,11 +392,11 @@ namespace Colosseum.Player
if (skillController.IgnoreRootMotionY)
{
deltaPosition.y = 0f;
characterController.Move(deltaPosition + velocity * Time.deltaTime);
characterController.Move(deltaPosition + velocity * Time.deltaTime + forcedDelta);
}
else
{
characterController.Move(deltaPosition);
characterController.Move(deltaPosition + forcedDelta);
}
if (animator.deltaRotation != Quaternion.identity)
@@ -372,5 +407,22 @@ namespace Colosseum.Player
wasGrounded = characterController.isGrounded;
}
private Vector3 ConsumeForcedMovementDelta(float deltaTime)
{
if (forcedMovementRemaining <= 0f || forcedMovementVelocity.sqrMagnitude <= 0.0001f)
return Vector3.zero;
float appliedDeltaTime = Mathf.Min(deltaTime, forcedMovementRemaining);
forcedMovementRemaining = Mathf.Max(0f, forcedMovementRemaining - appliedDeltaTime);
Vector3 delta = forcedMovementVelocity * appliedDeltaTime;
if (forcedMovementRemaining <= 0f)
{
forcedMovementVelocity = Vector3.zero;
}
return delta;
}
}
}

View File

@@ -176,9 +176,16 @@ namespace Colosseum.Player
var movement = GetComponent<PlayerMovement>();
if (movement != null)
{
movement.ClearForcedMovement();
movement.enabled = false;
}
var hitReactionController = GetComponent<HitReactionController>();
if (hitReactionController != null)
{
hitReactionController.ClearHitReactionState();
}
// 스킬 입력 비활성화
var skillInput = GetComponent<PlayerSkillInput>();
if (skillInput != null)
@@ -190,7 +197,7 @@ namespace Colosseum.Player
var skillController = GetComponent<SkillController>();
if (skillController != null)
{
skillController.CancelSkill();
skillController.CancelSkill(SkillCancelReason.Death);
}
// 모든 클라이언트에서 사망 애니메이션 재생
@@ -222,9 +229,16 @@ namespace Colosseum.Player
var movement = GetComponent<PlayerMovement>();
if (movement != null)
{
movement.ClearForcedMovement();
movement.enabled = true;
}
var hitReactionController = GetComponent<HitReactionController>();
if (hitReactionController != null)
{
hitReactionController.ClearHitReactionState();
}
// 스킬 입력 재활성화
var skillInput = GetComponent<PlayerSkillInput>();
if (skillInput != null)
@@ -239,6 +253,12 @@ namespace Colosseum.Player
animator.Rebind();
}
var skillController = GetComponent<SkillController>();
if (skillController != null)
{
skillController.CancelSkill(SkillCancelReason.Respawn);
}
OnRespawned?.Invoke(this);
Debug.Log($"[Player] Player {OwnerClientId} respawned!");