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 { /// /// 플레이어 이상상태와 행동 제어 연동을 자동 검증하는 디버그 러너. /// 기절, 침묵, 사망, 리스폰 순으로 상태를 검사합니다. /// [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(); if (actionState == null) actionState = GetComponent(); if (networkController == null) networkController = GetComponent(); if (skillInput == null) skillInput = GetComponent(); if (skillController == null) skillController = GetComponent(); if (playerMovement == null) playerMovement = GetComponent(); if (hitReactionController == null) hitReactionController = GetComponent(); } private void LoadDefaultAssetsIfNeeded() { #if UNITY_EDITOR if (stunData == null) { stunData = AssetDatabase.LoadAssetAtPath("Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Stun.asset"); } if (silenceData == null) { silenceData = AssetDatabase.LoadAssetAtPath("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 predicate, float timeout) { float elapsed = 0f; while (elapsed < timeout) { if (predicate()) yield break; elapsed += Time.deltaTime; yield return null; } } } }