- HitReactionController에 경직, 다운 회복 가능 구간, Hit 속도 배율 파라미터 처리를 추가 - DownBegin/Recover 상태 종료를 StateMachineBehaviour로 받아 구르기 허용 구간과 다운 해제를 분리 - 드로그 발구르기를 경직 이펙트로 전환하고 넉백/경직 이펙트에서 Hit 애니메이션 속도 배율을 설정 가능하게 정리 - PlayMode 테스트를 추가해 경직/넉백이 Hit 애니메이션 속도 배율을 실제로 반영하는지 자동 검증
472 lines
19 KiB
C#
472 lines
19 KiB
C#
using System;
|
|
using System.Collections;
|
|
|
|
using UnityEngine;
|
|
|
|
using Unity.Netcode;
|
|
|
|
using Colosseum.Abnormalities;
|
|
using Colosseum.Skills;
|
|
|
|
#if UNITY_EDITOR
|
|
using UnityEditor;
|
|
#endif
|
|
|
|
namespace Colosseum.Player
|
|
{
|
|
/// <summary>
|
|
/// 플레이어 이상상태와 행동 제어 연동을 자동 검증하는 디버그 러너.
|
|
/// 기절, 침묵, 사망, 리스폰 순으로 상태를 검사합니다.
|
|
/// </summary>
|
|
[DisallowMultipleComponent]
|
|
public class PlayerAbnormalityVerificationRunner : NetworkBehaviour
|
|
{
|
|
[Header("References")]
|
|
[SerializeField] private AbnormalityManager abnormalityManager;
|
|
[SerializeField] private PlayerActionState actionState;
|
|
[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;
|
|
[SerializeField] private AbnormalityData silenceData;
|
|
|
|
[Header("Execution")]
|
|
[Tooltip("에디터 플레이 시작 시 자동 검증 실행")]
|
|
[SerializeField] private bool runOnStartInEditor = false;
|
|
|
|
[Tooltip("각 검증 단계 사이 대기 시간")]
|
|
[Min(0.05f)]
|
|
[SerializeField] private float settleDelay = 0.2f;
|
|
|
|
[Header("Result")]
|
|
[SerializeField] private bool isRunning;
|
|
[SerializeField] private bool lastRunPassed;
|
|
[SerializeField] private int totalChecks;
|
|
[SerializeField] private int failedChecks;
|
|
[TextArea(5, 12)]
|
|
[SerializeField] private string lastReport = string.Empty;
|
|
|
|
private readonly System.Text.StringBuilder reportBuilder = new System.Text.StringBuilder();
|
|
|
|
public override void OnNetworkSpawn()
|
|
{
|
|
if (!IsOwner || !ShouldEnableRunner())
|
|
{
|
|
enabled = false;
|
|
return;
|
|
}
|
|
|
|
ResolveReferences();
|
|
LoadDefaultAssetsIfNeeded();
|
|
|
|
if (runOnStartInEditor)
|
|
{
|
|
StartCoroutine(RunVerificationRoutine());
|
|
}
|
|
}
|
|
|
|
[ContextMenu("Run Verification")]
|
|
public void RunVerification()
|
|
{
|
|
if (!Application.isPlaying || !IsOwner || isRunning)
|
|
return;
|
|
|
|
StartCoroutine(RunVerificationRoutine());
|
|
}
|
|
|
|
private IEnumerator RunVerificationRoutine()
|
|
{
|
|
if (isRunning)
|
|
yield break;
|
|
|
|
ResolveReferences();
|
|
LoadDefaultAssetsIfNeeded();
|
|
|
|
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;
|
|
}
|
|
|
|
isRunning = true;
|
|
totalChecks = 0;
|
|
failedChecks = 0;
|
|
lastRunPassed = false;
|
|
reportBuilder.Clear();
|
|
AppendLine("=== Player Abnormality Verification Start ===");
|
|
|
|
abnormalityManager.RemoveAllAbnormalities();
|
|
RequestRespawnRpc();
|
|
yield return new WaitForSeconds(settleDelay);
|
|
|
|
Verify("초기 상태: 사망 아님", !networkController.IsDead);
|
|
Verify("초기 상태: 이동 가능", actionState.CanMove);
|
|
Verify("초기 상태: 스킬 사용 가능", actionState.CanUseSkills);
|
|
Verify("초기 상태: 구르기 가능", actionState.CanEvade);
|
|
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);
|
|
|
|
Verify("기절 적용: IsStunned", abnormalityManager.IsStunned);
|
|
Verify("기절 적용: ActionState.IsStunned", actionState.IsStunned);
|
|
Verify("기절 적용: 이동 불가", !actionState.CanMove);
|
|
Verify("기절 적용: 점프 불가", !actionState.CanJump);
|
|
Verify("기절 적용: 스킬 사용 불가", !actionState.CanUseSkills);
|
|
Verify("기절 적용: 이동속도 0", Mathf.Approximately(actionState.MoveSpeedMultiplier, 0f));
|
|
|
|
yield return new WaitForSeconds(stunData.duration + settleDelay);
|
|
|
|
Verify("기절 해제: IsStunned false", !abnormalityManager.IsStunned);
|
|
Verify("기절 해제: 이동 가능 복구", actionState.CanMove);
|
|
Verify("기절 해제: 스킬 사용 가능 복구", actionState.CanUseSkills);
|
|
|
|
abnormalityManager.ApplyAbnormality(silenceData, gameObject);
|
|
yield return new WaitForSeconds(settleDelay);
|
|
|
|
Verify("침묵 적용: IsSilenced", abnormalityManager.IsSilenced);
|
|
Verify("침묵 적용: 이동 가능 유지", actionState.CanMove);
|
|
Verify("침묵 적용: 점프 가능 유지", actionState.CanJump);
|
|
Verify("침묵 적용: 스킬 사용 불가", !actionState.CanUseSkills);
|
|
|
|
yield return new WaitForSeconds(silenceData.duration + settleDelay);
|
|
|
|
Verify("침묵 해제: IsSilenced false", !abnormalityManager.IsSilenced);
|
|
Verify("침묵 해제: 스킬 사용 가능 복구", actionState.CanUseSkills);
|
|
|
|
abnormalityManager.ApplyAbnormality(stunData, gameObject);
|
|
yield return new WaitForSeconds(settleDelay);
|
|
networkController.TakeDamageRpc(networkController.Health + 1f);
|
|
yield return new WaitForSeconds(settleDelay);
|
|
|
|
Verify("사망 처리: IsDead", networkController.IsDead);
|
|
Verify("사망 처리: 입력 불가", !actionState.CanReceiveInput);
|
|
Verify("사망 처리: 이동 불가", !actionState.CanMove);
|
|
Verify("사망 처리: 스킬 사용 불가", !actionState.CanUseSkills);
|
|
Verify("사망 처리: 활성 이상상태 제거", abnormalityManager.ActiveAbnormalities.Count == 0);
|
|
|
|
RequestRespawnRpc();
|
|
yield return new WaitForSeconds(settleDelay);
|
|
|
|
Verify("리스폰: IsDead false", !networkController.IsDead);
|
|
Verify("리스폰: 활성 이상상태 없음", abnormalityManager.ActiveAbnormalities.Count == 0);
|
|
Verify("리스폰: 이동 가능", actionState.CanMove);
|
|
Verify("리스폰: 스킬 사용 가능", actionState.CanUseSkills);
|
|
|
|
lastRunPassed = failedChecks == 0;
|
|
AppendLine(lastRunPassed
|
|
? "=== Verification Passed ==="
|
|
: $"=== Verification Failed: {failedChecks}/{totalChecks} checks failed ===");
|
|
|
|
lastReport = reportBuilder.ToString();
|
|
Debug.Log(lastReport);
|
|
isRunning = false;
|
|
}
|
|
|
|
[Rpc(SendTo.Server)]
|
|
private void RequestRespawnRpc()
|
|
{
|
|
if (networkController == null)
|
|
return;
|
|
|
|
networkController.Respawn();
|
|
}
|
|
|
|
private void ResolveReferences()
|
|
{
|
|
if (abnormalityManager == null)
|
|
abnormalityManager = GetComponent<AbnormalityManager>();
|
|
if (actionState == null)
|
|
actionState = GetComponent<PlayerActionState>();
|
|
if (networkController == null)
|
|
networkController = GetComponent<PlayerNetworkController>();
|
|
if (skillInput == null)
|
|
skillInput = GetComponent<PlayerSkillInput>();
|
|
if (skillController == null)
|
|
skillController = GetComponent<SkillController>();
|
|
if (playerMovement == null)
|
|
playerMovement = GetComponent<PlayerMovement>();
|
|
if (hitReactionController == null)
|
|
hitReactionController = GetComponent<HitReactionController>();
|
|
}
|
|
|
|
private void LoadDefaultAssetsIfNeeded()
|
|
{
|
|
#if UNITY_EDITOR
|
|
if (stunData == null)
|
|
{
|
|
stunData = AssetDatabase.LoadAssetAtPath<AbnormalityData>("Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Stun.asset");
|
|
}
|
|
|
|
if (silenceData == null)
|
|
{
|
|
silenceData = AssetDatabase.LoadAssetAtPath<AbnormalityData>("Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Silence.asset");
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private void Verify(string label, bool condition)
|
|
{
|
|
totalChecks++;
|
|
if (!condition)
|
|
{
|
|
failedChecks++;
|
|
}
|
|
|
|
AppendLine($"{(condition ? "[PASS]" : "[FAIL]")} {label}");
|
|
}
|
|
|
|
private void AppendLine(string text)
|
|
{
|
|
if (reportBuilder.Length > 0)
|
|
{
|
|
reportBuilder.AppendLine();
|
|
}
|
|
|
|
reportBuilder.Append(text);
|
|
}
|
|
|
|
private bool ShouldEnableRunner()
|
|
{
|
|
#if UNITY_EDITOR
|
|
return true;
|
|
#else
|
|
return Debug.isDebugBuild;
|
|
#endif
|
|
}
|
|
|
|
private IEnumerator RunInvincibilitySkillVerification()
|
|
{
|
|
SkillData invincibilitySkill = skillInput != null ? skillInput.GetSkill(6) : null;
|
|
if (invincibilitySkill == null)
|
|
{
|
|
AppendLine("[SKIP] 무적 스킬 검증: 7번 슬롯 스킬이 없습니다.");
|
|
yield break;
|
|
}
|
|
|
|
if (skillController == null || !skillController.ExecuteSkill(invincibilitySkill))
|
|
{
|
|
Verify("무적 스킬 검증: 스킬 실행 성공", false);
|
|
yield break;
|
|
}
|
|
|
|
yield return WaitForConditionOrTimeout(() => actionState.IsDamageImmune, GetSkillDuration(invincibilitySkill) + 0.5f);
|
|
|
|
float healthBeforeDamage = networkController.Health;
|
|
|
|
Verify("무적 적용: IsDamageImmune", actionState.IsDamageImmune);
|
|
networkController.TakeDamageRpc(15f);
|
|
yield return new WaitForSeconds(settleDelay);
|
|
Verify("무적 적용: 대미지 무시", Mathf.Approximately(networkController.Health, healthBeforeDamage));
|
|
|
|
if (silenceData != null)
|
|
{
|
|
abnormalityManager.ApplyAbnormality(silenceData, gameObject);
|
|
yield return WaitForConditionOrTimeout(() => actionState.IsSilenced, settleDelay + 0.5f);
|
|
|
|
Verify("무적 중 침묵 적용: IsSilenced", actionState.IsSilenced);
|
|
Verify("무적 중 침묵 적용: 무적 상태 유지", actionState.IsDamageImmune);
|
|
Verify("무적 중 침묵 적용: 스킬 신규 사용 불가", !actionState.CanStartSkill(invincibilitySkill));
|
|
|
|
abnormalityManager.RemoveAbnormality(silenceData);
|
|
yield return new WaitForSeconds(settleDelay);
|
|
}
|
|
|
|
yield return WaitForConditionOrTimeout(() => !actionState.IsDamageImmune, GetSkillDuration(invincibilitySkill) + 1.5f);
|
|
|
|
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.05f);
|
|
|
|
Verify("넉백 적용: IsKnockbackActive", actionState.IsKnockbackActive);
|
|
Verify("넉백 적용: 스킬 사용 불가", !actionState.CanUseSkills);
|
|
Verify("넉백 적용: 구르기 불가", !actionState.CanEvade);
|
|
|
|
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("다운 적용: 초기 구르기 불가", !actionState.CanEvade);
|
|
Verify("다운 적용: 이동속도 0", Mathf.Approximately(actionState.MoveSpeedMultiplier, 0f));
|
|
|
|
RequestDownBeginExitedRpc();
|
|
yield return WaitForConditionOrTimeout(() => hitReactionController.IsDownRecoverable, settleDelay + 1f);
|
|
|
|
Verify("다운 회복 가능 진입: IsDownRecoverable", hitReactionController.IsDownRecoverable);
|
|
Verify("다운 회복 가능 진입: 일반 스킬 사용 불가 유지", !actionState.CanUseSkills);
|
|
|
|
SkillData evadeSkill = skillInput != null ? skillInput.GetSkill(6) : null;
|
|
if (evadeSkill != null)
|
|
{
|
|
Verify("다운 회복 가능 진입: 구르기 시작 가능", actionState.CanStartSkill(evadeSkill));
|
|
}
|
|
else
|
|
{
|
|
AppendLine("[SKIP] 다운 회복 가능 진입: 구르기 스킬이 없습니다.");
|
|
}
|
|
|
|
yield return WaitForConditionOrTimeout(() => !hitReactionController.IsDowned, 2.5f);
|
|
|
|
Verify("다운 해제: IsDowned false", !hitReactionController.IsDowned);
|
|
Verify("다운 해제: IsDownRecoverable false", !hitReactionController.IsDownRecoverable);
|
|
Verify("다운 해제: 이동 가능 복구", actionState.CanMove);
|
|
Verify("다운 해제: 스킬 사용 가능 복구", actionState.CanUseSkills);
|
|
Verify("다운 해제: 구르기 가능 복구", actionState.CanEvade);
|
|
}
|
|
|
|
[Rpc(SendTo.Server)]
|
|
private void RequestDownRpc(float duration)
|
|
{
|
|
hitReactionController?.ApplyDown(duration);
|
|
}
|
|
|
|
[Rpc(SendTo.Server)]
|
|
private void RequestDownBeginExitedRpc()
|
|
{
|
|
hitReactionController?.NotifyDownBeginExited();
|
|
}
|
|
|
|
[Rpc(SendTo.Server)]
|
|
private void RequestKnockbackRpc(Vector3 velocity, float duration)
|
|
{
|
|
hitReactionController?.ApplyKnockback(velocity, duration, false);
|
|
}
|
|
|
|
private float GetSkillDuration(SkillData skill)
|
|
{
|
|
if (skill == null || skill.AnimationClips.Count == 0)
|
|
return settleDelay;
|
|
|
|
float totalLength = 0f;
|
|
var clips = skill.AnimationClips;
|
|
for (int i = 0; i < clips.Count; i++)
|
|
{
|
|
if (clips[i] != null)
|
|
totalLength += clips[i].length;
|
|
}
|
|
|
|
return Mathf.Max(settleDelay, totalLength / 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;
|
|
while (elapsed < timeout)
|
|
{
|
|
if (predicate())
|
|
yield break;
|
|
|
|
elapsed += Time.deltaTime;
|
|
yield return null;
|
|
}
|
|
}
|
|
}
|
|
}
|