using System; using System.Collections.Generic; using System.Text; using UnityEngine; using Unity.Netcode; using Colosseum.Abnormalities; using Colosseum.Passives; using Colosseum.Stats; using Colosseum.Combat; using Colosseum.Player; using Colosseum.Skills; namespace Colosseum.Enemy { /// /// 적 캐릭터 기본 클래스. /// 네트워크 동기화, 스탯 관리, 대미지 처리를 담당합니다. /// public class EnemyBase : NetworkBehaviour, IDamageable { [Header("References")] [Tooltip("CharacterStats 컴포넌트 (없으면 자동 검색)")] [SerializeField] protected CharacterStats characterStats; [Tooltip("Animator 컴포넌트")] [SerializeField] protected Animator animator; [Tooltip("NavMeshAgent 또는 이동 컴포넌트")] [SerializeField] protected UnityEngine.AI.NavMeshAgent navMeshAgent; [Tooltip("이상상태 관리자")] [SerializeField] protected AbnormalityManager abnormalityManager; [Tooltip("플레이어와의 물리 겹침을 계산할 본체 콜라이더")] [SerializeField] private Collider bodyCollider; [Header("Data")] [SerializeField] protected EnemyData enemyData; [Header("Threat Settings")] [Tooltip("피격 시 공격자 기준 위협 수치를 누적할지 여부")] [SerializeField] private bool useThreatSystem = true; [Tooltip("실제 적용된 피해량에 곱해지는 위협 배율")] [Min(0f)] [SerializeField] private float damageThreatMultiplier = 1f; [Tooltip("초당 감소하는 위협 수치")] [Min(0f)] [SerializeField] private float threatDecayPerSecond = 0f; [Tooltip("현재 타겟보다 이 값 이상 높을 때만 새 타겟으로 전환합니다.")] [Min(0f)] [SerializeField] private float retargetThreshold = 0f; [Header("Shield")] [Tooltip("보호막 타입이 지정되지 않았을 때 사용할 기본 보호막 이상상태 데이터")] [SerializeField] private AbnormalityData shieldStateAbnormality; [Header("Player Separation")] [Tooltip("적과 플레이어 사이에 추가로 유지할 수평 간격")] [Min(0f)] [SerializeField] private float playerSeparationPadding = 0.1f; [Tooltip("플레이어와 닿아 있을 때 적의 수평 이동을 멈출지 여부")] [SerializeField] private bool freezeHorizontalMotionOnPlayerContact = true; // 네트워크 동기화 변수 protected NetworkVariable currentHealth = new NetworkVariable(100f); protected NetworkVariable currentMana = new NetworkVariable(50f); protected NetworkVariable isDead = new NetworkVariable(false); protected NetworkVariable currentShield = new NetworkVariable(0f); private readonly ShieldCollection shieldCollection = new ShieldCollection(); // 플레이어 분리용 (레이어 의존 없이 CharacterController로 식별) private readonly Collider[] overlapBuffer = new Collider[8]; private readonly Dictionary threatTable = new Dictionary(); private readonly List threatTargetBuffer = new List(); // 점프 등 Y 루트모션 스킬 중 NavMeshAgent 비활성화 상태 추적 private bool isAirborne = false; // 점프 타겟 이동 private bool hasJumpTarget = false; private Vector3 jumpStartXZ; private Vector3 jumpTargetXZ; // 이벤트 public event Action OnHealthChanged; // currentHealth, maxHealth public event Action OnShieldChanged; // oldShield, newShield public event Action OnDamageTaken; // damage public event Action OnDeath; // Properties public float CurrentHealth => currentHealth.Value; public float MaxHealth => characterStats != null ? characterStats.MaxHealth : 100f; public float CurrentMana => currentMana.Value; public float MaxMana => characterStats != null ? characterStats.MaxMana : 50f; public float Shield => currentShield.Value; public bool IsDead => isDead.Value; public CharacterStats Stats => characterStats; public EnemyData Data => enemyData; public Animator Animator => animator; public bool UseThreatSystem => useThreatSystem; public bool IsTouchingPlayerContact => IsTouchingPlayer(); public override void OnNetworkSpawn() { // 컴포넌트 참조 확인 if (characterStats == null) characterStats = GetComponent(); if (animator == null) animator = GetComponentInChildren(); if (navMeshAgent == null) navMeshAgent = GetComponent(); if (abnormalityManager == null) abnormalityManager = GetComponent(); if (bodyCollider == null) bodyCollider = GetComponent(); // 서버에서 초기화 if (IsServer) { InitializeStats(); } // 클라이언트에서 체력 변화 감지 currentHealth.OnValueChanged += OnHealthChangedInternal; currentShield.OnValueChanged += OnShieldChangedInternal; } protected virtual void Update() { if (!IsServer || IsDead) return; if (shieldCollection.Tick(Time.deltaTime)) { RefreshShieldState(); } UpdateThreatState(Time.deltaTime); OnServerUpdate(); ApplyPlayerContactMovementLock(); } /// /// 서버 Update 확장 포인트 (하위 클래스에서 override) /// protected virtual void OnServerUpdate() { } /// /// 접촉 잠금 보정을 사용하지 않는 적만 플레이어 겹침 해소를 적용합니다. /// private void LateUpdate() { if (!IsServer || IsDead) return; if (freezeHorizontalMotionOnPlayerContact) return; Vector3 separationOffset = ComputePlayerSeparationOffset(); if (separationOffset.sqrMagnitude <= 0.000001f) return; if (navMeshAgent != null && !isAirborne && navMeshAgent.enabled) { if (navMeshAgent.velocity.sqrMagnitude > 0.01f) navMeshAgent.isStopped = true; navMeshAgent.Move(separationOffset); } else { transform.position += separationOffset; } } /// /// 점프 타겟 설정. UseSkillAction에서 jumpToTarget 스킬 시전 시 호출합니다. /// public void SetJumpTarget(Vector3 targetPos) { jumpTargetXZ = new Vector3(targetPos.x, 0f, targetPos.z); hasJumpTarget = true; } /// /// 보스 스킬 루트모션이 플레이어 방향으로 진입하는 것을 차단합니다. /// Y 루트모션이 필요한 스킬(점프 등)은 NavMeshAgent를 비활성화하고 직접 이동합니다. /// jumpToTarget 스킬은 XZ를 대상 위치로 lerp합니다. /// private void OnAnimatorMove() { if (!IsServer || animator == null || navMeshAgent == null) return; var skillCtrl = GetComponent(); bool needsYMotion = skillCtrl != null && skillCtrl.IsPlayingAnimation && skillCtrl.UsesRootMotion && !skillCtrl.IgnoreRootMotionY; Vector3 deltaPosition = animator.deltaPosition; if (needsYMotion) { // Y 루트모션 필요: NavMeshAgent 비활성화 후 transform 직접 이동 if (navMeshAgent.enabled) { navMeshAgent.enabled = false; isAirborne = true; jumpStartXZ = new Vector3(transform.position.x, 0f, transform.position.z); } if (hasJumpTarget) { // XZ: 애니메이션 진행도에 따라 목표 위치로 lerp float t = Mathf.Clamp01(animator.GetCurrentAnimatorStateInfo(0).normalizedTime); Vector3 newXZ = Vector3.Lerp(jumpStartXZ, jumpTargetXZ, t); Vector3 desiredDelta = new Vector3( newXZ.x - transform.position.x, deltaPosition.y, newXZ.z - transform.position.z); if (freezeHorizontalMotionOnPlayerContact) desiredDelta = LimitHorizontalDeltaAgainstPlayerContact(desiredDelta); transform.position += desiredDelta; } else { // jumpToTarget 없으면 기존처럼 애니메이션 루트모션 그대로 적용 if (freezeHorizontalMotionOnPlayerContact) deltaPosition = LimitHorizontalDeltaAgainstPlayerContact(deltaPosition); transform.position += deltaPosition; } } else { // 착지 후 NavMeshAgent 복원 if (isAirborne) { isAirborne = false; if (hasJumpTarget) { // lerp가 1.0에 못 미쳐도 착지 시 목표 지점으로 보정하되, 플레이어 표면을 넘지 않도록 제한합니다. Vector3 landingDelta = new Vector3( jumpTargetXZ.x - transform.position.x, 0f, jumpTargetXZ.z - transform.position.z); if (freezeHorizontalMotionOnPlayerContact) landingDelta = LimitHorizontalDeltaAgainstPlayerContact(landingDelta); transform.position += landingDelta; } hasJumpTarget = false; navMeshAgent.enabled = true; navMeshAgent.Warp(transform.position); } if (freezeHorizontalMotionOnPlayerContact) { ApplyPlayerContactMovementLock(); deltaPosition = LimitHorizontalDeltaAgainstPlayerContact(deltaPosition); } navMeshAgent.Move(deltaPosition); } if (animator.deltaRotation != Quaternion.identity) transform.rotation *= animator.deltaRotation; } public override void OnNetworkDespawn() { currentHealth.OnValueChanged -= OnHealthChangedInternal; currentShield.OnValueChanged -= OnShieldChangedInternal; } /// /// 스탯 초기화 (서버에서만 실행) /// protected virtual void InitializeStats() { if (enemyData != null && characterStats != null) { enemyData.ApplyBaseStats(characterStats); } // NavMeshAgent 속도 설정 if (navMeshAgent != null && enemyData != null) { navMeshAgent.speed = enemyData.MoveSpeed; navMeshAgent.angularSpeed = enemyData.RotationSpeed; } currentHealth.Value = MaxHealth; currentMana.Value = MaxMana; isDead.Value = false; shieldCollection.Clear(); RefreshShieldState(); } /// /// 대미지 적용 (서버에서 실행) /// public virtual float TakeDamage(float damage, object source = null) { return TakeDamage(new DamageContext(damage, source)); } /// /// 대미지 컨텍스트를 사용해 대미지를 적용합니다. /// public virtual float TakeDamage(DamageContext damageContext) { if (!IsServer || isDead.Value) return 0f; float damage = damageContext.Amount; if (damage <= 0f) return 0f; if (ShouldIgnoreIncomingDamage(damage, damageContext.Source)) return 0f; float mitigatedDamage = ConsumeShield(damage); float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value); currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage); GameObject sourceObject = damageContext.SourceGameObject; CombatBalanceTracker.RecordDamage(sourceObject, gameObject, actualDamage); RegisterThreatFromDamage(actualDamage, sourceObject); OnDamageTaken?.Invoke(actualDamage); // 대미지 피드백 (애니메이션, 이펙트 등) OnTakeDamageFeedback(actualDamage, damageContext.Source); if (currentHealth.Value <= 0f) { HandleDeath(); } return actualDamage; } /// /// 하위 클래스가 특정 상태에서 피해를 무시해야 할 때 사용합니다. /// protected virtual bool ShouldIgnoreIncomingDamage(float damage, object source) { return false; } /// /// 대미지 피드백 (애니메이션, 이펙트) /// protected virtual void OnTakeDamageFeedback(float damage, object source) { if (animator != null) { animator.SetTrigger("Hit"); } } private Vector3 ComputePlayerSeparationOffset() { if (bodyCollider == null) return Vector3.zero; float scanRadius = GetPlayerDetectionRadius(); int count = Physics.OverlapSphereNonAlloc(bodyCollider.bounds.center, scanRadius, overlapBuffer); Vector3 separationOffset = Vector3.zero; int overlapCount = 0; for (int i = 0; i < count; i++) { if (!TryGetPlayerCharacterController(overlapBuffer[i], out CharacterController playerController)) continue; if (!Physics.ComputePenetration( bodyCollider, bodyCollider.transform.position, bodyCollider.transform.rotation, playerController, playerController.transform.position, playerController.transform.rotation, out Vector3 separationDirection, out float separationDistance)) { continue; } separationDirection.y = 0f; if (separationDirection.sqrMagnitude <= 0.0001f) separationDirection = -transform.forward; separationOffset += separationDirection.normalized * (separationDistance + playerSeparationPadding); overlapCount++; } if (overlapCount <= 0) return Vector3.zero; separationOffset /= overlapCount; separationOffset.y = 0f; return separationOffset; } private bool IsTouchingPlayer() { if (bodyCollider == null) return false; float scanRadius = GetPlayerDetectionRadius(); int count = Physics.OverlapSphereNonAlloc(bodyCollider.bounds.center, scanRadius, overlapBuffer); for (int i = 0; i < count; i++) { if (!TryGetPlayerCharacterController(overlapBuffer[i], out CharacterController playerController)) continue; if (Physics.ComputePenetration( bodyCollider, bodyCollider.transform.position, bodyCollider.transform.rotation, playerController, playerController.transform.position, playerController.transform.rotation, out _, out float separationDistance) && separationDistance > 0.0001f) { return true; } } return false; } private Vector3 LimitHorizontalDeltaAgainstPlayerContact(Vector3 deltaPosition) { Vector3 horizontalDelta = new Vector3(deltaPosition.x, 0f, deltaPosition.z); if (horizontalDelta.sqrMagnitude <= 0.000001f) return deltaPosition; horizontalDelta = ClampHorizontalDeltaToPlayerSurface(horizontalDelta); if (horizontalDelta.sqrMagnitude <= 0.000001f) { deltaPosition.x = 0f; deltaPosition.z = 0f; return deltaPosition; } if (TryGetPlayerContactBlockDirection(out Vector3 currentBlockDirection) && IsBlockedByContactForwardHemisphere(horizontalDelta, currentBlockDirection)) { horizontalDelta = Vector3.zero; } if (horizontalDelta.sqrMagnitude > 0.000001f && TryGetProjectedPlayerContactBlockDirection(horizontalDelta, out Vector3 projectedBlockDirection) && IsBlockedByContactForwardHemisphere(horizontalDelta, projectedBlockDirection)) { horizontalDelta = ClampHorizontalDeltaBeforePlayerOverlap(horizontalDelta); } deltaPosition.x = horizontalDelta.x; deltaPosition.z = horizontalDelta.z; return deltaPosition; } private Vector3 ClampHorizontalDeltaToPlayerSurface(Vector3 horizontalDelta) { if (bodyCollider == null || horizontalDelta.sqrMagnitude <= 0.000001f) return horizontalDelta; if (!WouldCrossPlayerSurface(horizontalDelta)) return horizontalDelta; if (WouldCrossPlayerSurface(Vector3.zero)) return Vector3.zero; float min = 0f; float max = 1f; for (int i = 0; i < 8; i++) { float mid = (min + max) * 0.5f; if (WouldCrossPlayerSurface(horizontalDelta * mid)) max = mid; else min = mid; } return horizontalDelta * min; } private float GetPlayerDetectionRadius() { float enemyRadius = GetBodyHorizontalRadius(); return enemyRadius + 1f + playerSeparationPadding; } private float GetBodyHorizontalRadius() { if (bodyCollider != null) { Bounds bounds = bodyCollider.bounds; return Mathf.Max(bounds.extents.x, bounds.extents.z); } return navMeshAgent != null ? navMeshAgent.radius : 0.5f; } private void ApplyPlayerContactMovementLock() { if (!freezeHorizontalMotionOnPlayerContact || navMeshAgent == null || !navMeshAgent.enabled || isAirborne) { return; } if (!IsTouchingPlayer()) return; if (!navMeshAgent.isStopped) navMeshAgent.isStopped = true; if (navMeshAgent.hasPath) navMeshAgent.ResetPath(); } private bool TryGetPlayerContactBlockDirection(out Vector3 blockDirection) { return TryGetPlayerContactBlockDirection(Vector3.zero, out blockDirection); } private bool TryGetProjectedPlayerContactBlockDirection(Vector3 horizontalDelta, out Vector3 blockDirection) { return TryGetPlayerContactBlockDirection(horizontalDelta, out blockDirection); } private bool TryGetPlayerContactBlockDirection(Vector3 horizontalOffset, out Vector3 blockDirection) { blockDirection = Vector3.zero; if (bodyCollider == null) return false; float scanRadius = GetPlayerDetectionRadius() + horizontalOffset.magnitude; Vector3 scanCenter = bodyCollider.bounds.center + horizontalOffset; Vector3 bodyPosition = bodyCollider.transform.position + horizontalOffset; int count = Physics.OverlapSphereNonAlloc(scanCenter, scanRadius, overlapBuffer); int overlapCount = 0; for (int i = 0; i < count; i++) { if (!TryGetPlayerCharacterController(overlapBuffer[i], out CharacterController playerController)) continue; if (!Physics.ComputePenetration( bodyCollider, bodyPosition, bodyCollider.transform.rotation, playerController, playerController.transform.position, playerController.transform.rotation, out Vector3 separationDirection, out float separationDistance) || separationDistance <= 0.0001f) { continue; } Vector3 playerCenter = playerController.bounds.center; Vector3 towardPlayer = playerCenter - scanCenter; towardPlayer.y = 0f; if (towardPlayer.sqrMagnitude <= 0.0001f) { towardPlayer = -separationDirection; towardPlayer.y = 0f; if (towardPlayer.sqrMagnitude <= 0.0001f) continue; } blockDirection += towardPlayer.normalized; overlapCount++; } if (overlapCount <= 0 || blockDirection.sqrMagnitude <= 0.0001f) { blockDirection = Vector3.zero; return false; } blockDirection.Normalize(); return true; } private static bool IsBlockedByContactForwardHemisphere(Vector3 horizontalDelta, Vector3 blockDirection) { if (horizontalDelta.sqrMagnitude <= 0.000001f || blockDirection.sqrMagnitude <= 0.0001f) return false; float blockedAmount = Vector3.Dot(horizontalDelta, blockDirection); return blockedAmount >= 0f; } private Vector3 ClampHorizontalDeltaBeforePlayerOverlap(Vector3 horizontalDelta) { if (bodyCollider == null || horizontalDelta.sqrMagnitude <= 0.000001f) return horizontalDelta; if (!HasProjectedPlayerOverlap(horizontalDelta)) return horizontalDelta; if (HasProjectedPlayerOverlap(Vector3.zero)) return Vector3.zero; float min = 0f; float max = 1f; for (int i = 0; i < 8; i++) { float mid = (min + max) * 0.5f; if (HasProjectedPlayerOverlap(horizontalDelta * mid)) max = mid; else min = mid; } return horizontalDelta * min; } private bool WouldCrossPlayerSurface(Vector3 horizontalOffset) { if (bodyCollider == null) return false; float enemyRadius = GetBodyHorizontalRadius(); float minimumSurfaceDistance = enemyRadius + playerSeparationPadding; float scanRadius = GetPlayerDetectionRadius() + horizontalOffset.magnitude; Vector3 scanCenter = bodyCollider.bounds.center + horizontalOffset; int count = Physics.OverlapSphereNonAlloc(scanCenter, scanRadius, overlapBuffer); for (int i = 0; i < count; i++) { if (!TryGetPlayerCharacterController(overlapBuffer[i], out CharacterController playerController)) continue; Vector3 closestPoint = playerController.ClosestPoint(scanCenter); Vector3 horizontalToSurface = closestPoint - scanCenter; horizontalToSurface.y = 0f; if (horizontalToSurface.magnitude <= minimumSurfaceDistance + 0.001f) return true; } return false; } private bool HasProjectedPlayerOverlap(Vector3 horizontalOffset) { if (bodyCollider == null) return false; float scanRadius = GetPlayerDetectionRadius() + horizontalOffset.magnitude; Vector3 scanCenter = bodyCollider.bounds.center + horizontalOffset; Vector3 bodyPosition = bodyCollider.transform.position + horizontalOffset; int count = Physics.OverlapSphereNonAlloc(scanCenter, scanRadius, overlapBuffer); for (int i = 0; i < count; i++) { if (!TryGetPlayerCharacterController(overlapBuffer[i], out CharacterController playerController)) continue; if (Physics.ComputePenetration( bodyCollider, bodyPosition, bodyCollider.transform.rotation, playerController, playerController.transform.position, playerController.transform.rotation, out _, out float separationDistance) && separationDistance > 0.0001f) { return true; } } return false; } private static bool TryGetPlayerCharacterController(Collider overlapCollider, out CharacterController playerController) { playerController = null; if (overlapCollider == null) return false; playerController = overlapCollider.GetComponent(); if (playerController == null) playerController = overlapCollider.GetComponentInParent(); if (playerController == null) return false; return playerController.GetComponent() != null || playerController.GetComponentInParent() != null; } /// /// 체력 회복 (서버에서 실행) /// public virtual float Heal(float amount) { if (!IsServer || isDead.Value) return 0f; float oldHealth = currentHealth.Value; currentHealth.Value = Mathf.Min(MaxHealth, currentHealth.Value + amount); float actualHeal = currentHealth.Value - oldHealth; return actualHeal; } /// /// 보호막을 적용합니다. /// public virtual float ApplyShield(float amount, float duration, AbnormalityData shieldAbnormality = null, GameObject source = null) { if (!IsServer || isDead.Value || amount <= 0f) return 0f; AbnormalityData shieldType = shieldAbnormality != null ? shieldAbnormality : shieldStateAbnormality; float actualAppliedShield = shieldCollection.ApplyShield(shieldType, amount, duration, source); RefreshShieldState(); return actualAppliedShield; } /// /// 사망 애니메이션 재생 (모든 클라이언트에서 실행) /// [Rpc(SendTo.Everyone)] private void PlayDeathAnimationRpc() { if (animator != null) { // EnemyAnimationController 비활성화 (더 이상 애니메이션 제어하지 않음) var animController = GetComponent(); if (animController != null) { animController.enabled = false; } // 모든 트리거 리셋 animator.ResetTrigger("Attack"); animator.ResetTrigger("Skill"); animator.ResetTrigger("Hit"); animator.ResetTrigger("Jump"); animator.ResetTrigger("Land"); animator.ResetTrigger("Die"); // 즉시 Die 상태로 전환 (다른 애니메이션 중단) animator.Play("Die", 0, 0f); } } /// /// 사망 처리 (서버에서 실행) /// protected virtual void HandleDeath() { isDead.Value = true; shieldCollection.Clear(); RefreshShieldState(); ClearAllThreat(); // 실행 중인 스킬 즉시 취소 var skillController = GetComponent(); if (skillController != null) { skillController.CancelSkill(SkillCancelReason.Death); } // 모든 클라이언트에서 사망 애니메이션 재생 PlayDeathAnimationRpc(); if (navMeshAgent != null) { navMeshAgent.isStopped = true; } OnDeath?.Invoke(); Debug.Log($"[Enemy] {name} died!"); } /// /// 리스폰 /// public virtual void Respawn() { if (!IsServer) return; isDead.Value = false; ClearAllThreat(); InitializeStats(); if (navMeshAgent != null) { navMeshAgent.isStopped = false; } if (animator != null) { animator.Rebind(); } var skillController = GetComponent(); if (skillController != null) { skillController.CancelSkill(SkillCancelReason.Respawn); } } // 체력 변화 이벤트 전파 private void OnHealthChangedInternal(float oldValue, float newValue) { OnHealthChanged?.Invoke(newValue, MaxHealth); } private void OnShieldChangedInternal(float oldValue, float newValue) { OnShieldChanged?.Invoke(oldValue, newValue); } private float ConsumeShield(float incomingDamage) { if (incomingDamage <= 0f || currentShield.Value <= 0f) return incomingDamage; float remainingDamage = shieldCollection.ConsumeDamage(incomingDamage); RefreshShieldState(); return remainingDamage; } private void RefreshShieldState() { currentShield.Value = shieldCollection.TotalAmount; ShieldAbnormalityUtility.SyncShieldAbnormalities( abnormalityManager, shieldCollection.ActiveShields, gameObject); } /// /// 공격자 기준 위협 수치를 누적합니다. /// public virtual void AddThreat(GameObject source, float amount) { if (!IsServer || !useThreatSystem || amount <= 0f) return; if (!IsValidThreatTarget(source)) return; threatTable.TryGetValue(source, out float currentThreat); threatTable[source] = currentThreat + amount; } /// /// 특정 대상의 위협 수치를 강제로 설정합니다. /// public virtual void SetThreat(GameObject source, float amount) { if (!IsServer || !useThreatSystem) return; if (!IsValidThreatTarget(source) || amount <= 0f) { ClearThreat(source); return; } threatTable[source] = amount; } /// /// 특정 대상의 위협 수치를 제거합니다. /// public virtual void ClearThreat(GameObject source) { if (source == null) return; threatTable.Remove(source); } /// /// 모든 위협 수치를 초기화합니다. /// public virtual void ClearAllThreat() { threatTable.Clear(); } /// /// 가장 높은 위협 수치를 가진 타겟을 반환합니다. /// public virtual GameObject GetHighestThreatTarget(GameObject currentTarget = null, string requiredTag = null, float maxDistance = Mathf.Infinity) { if (!useThreatSystem) return null; CleanupThreatTable(); GameObject highestThreatTarget = null; float highestThreat = float.MinValue; float currentThreat = -1f; if (currentTarget != null && threatTable.TryGetValue(currentTarget, out float cachedCurrentThreat) && IsSelectableThreatTarget(currentTarget, requiredTag, maxDistance)) { currentThreat = cachedCurrentThreat; } foreach (var pair in threatTable) { if (!IsSelectableThreatTarget(pair.Key, requiredTag, maxDistance)) continue; if (highestThreatTarget == null || pair.Value > highestThreat) { highestThreatTarget = pair.Key; highestThreat = pair.Value; } } if (highestThreatTarget == null) return null; if (currentThreat >= 0f && currentTarget != null && currentTarget != highestThreatTarget && highestThreat < currentThreat + retargetThreshold) { return currentTarget; } return highestThreatTarget; } /// /// 특정 대상의 현재 위협 수치를 반환합니다. /// public float GetThreat(GameObject source) { if (source == null) return 0f; return threatTable.TryGetValue(source, out float threat) ? threat : 0f; } /// /// 런타임 디버그용 위협 요약 문자열을 반환합니다. /// public string GetThreatDebugSummary() { if (!useThreatSystem) return "위협 시스템이 비활성화되어 있습니다."; CleanupThreatTable(); if (threatTable.Count == 0) return "등록된 위협 대상이 없습니다."; threatTargetBuffer.Clear(); foreach (var pair in threatTable) { threatTargetBuffer.Add(pair.Key); } threatTargetBuffer.Sort((a, b) => GetThreat(b).CompareTo(GetThreat(a))); StringBuilder builder = new StringBuilder(); for (int i = 0; i < threatTargetBuffer.Count; i++) { GameObject target = threatTargetBuffer[i]; if (target == null) continue; if (builder.Length > 0) { builder.AppendLine(); } builder.Append(i + 1); builder.Append(". "); builder.Append(target.name); builder.Append(" : "); builder.Append(GetThreat(target).ToString("F1")); } return builder.Length > 0 ? builder.ToString() : "등록된 위협 대상이 없습니다."; } /// /// 피격 정보를 위협 수치로 변환합니다. /// protected virtual void RegisterThreatFromDamage(float damage, object source) { if (!useThreatSystem || damage <= 0f) return; GameObject sourceObject = ResolveThreatSource(source); if (sourceObject == null) return; AddThreat(sourceObject, damage * damageThreatMultiplier * GetThreatSourceMultiplier(sourceObject)); } /// /// 위협 테이블의 무효 대상을 정리하고 자연 감소를 적용합니다. /// private void UpdateThreatState(float deltaTime) { if (!useThreatSystem || threatTable.Count == 0) return; CleanupThreatTable(); if (threatDecayPerSecond <= 0f || threatTable.Count == 0) return; threatTargetBuffer.Clear(); foreach (var pair in threatTable) { threatTargetBuffer.Add(pair.Key); } for (int i = 0; i < threatTargetBuffer.Count; i++) { GameObject target = threatTargetBuffer[i]; if (target == null || !threatTable.TryGetValue(target, out float currentThreat)) continue; float nextThreat = Mathf.Max(0f, currentThreat - (threatDecayPerSecond * deltaTime)); if (nextThreat <= 0f) { threatTable.Remove(target); continue; } threatTable[target] = nextThreat; } } /// /// 위협 대상이 유효한 선택 후보인지 확인합니다. /// private bool IsSelectableThreatTarget(GameObject target, string requiredTag, float maxDistance) { if (!IsValidThreatTarget(target)) return false; if (!string.IsNullOrEmpty(requiredTag) && !target.CompareTag(requiredTag)) return false; if (!float.IsInfinity(maxDistance)) { float distance = TargetSurfaceUtility.GetHorizontalSurfaceDistance(transform.position, target); if (distance > maxDistance) return false; } return true; } /// /// 위협 누적 대상이 유효한지 확인합니다. /// private bool IsValidThreatTarget(GameObject target) { if (target == null || !target.activeInHierarchy) return false; if (Colosseum.Team.IsSameTeam(gameObject, target)) return false; IDamageable damageable = target.GetComponent(); return damageable != null && !damageable.IsDead; } /// /// 위협 테이블에서 무효한 대상을 제거합니다. /// private void CleanupThreatTable() { if (threatTable.Count == 0) return; threatTargetBuffer.Clear(); foreach (var pair in threatTable) { if (!IsValidThreatTarget(pair.Key)) { threatTargetBuffer.Add(pair.Key); } } for (int i = 0; i < threatTargetBuffer.Count; i++) { threatTable.Remove(threatTargetBuffer[i]); } } /// /// 다양한 source 타입에서 실제 GameObject를 추출합니다. /// private static GameObject ResolveThreatSource(object source) { return source switch { GameObject sourceObject => sourceObject, Component component => component.gameObject, _ => null, }; } /// /// 공격자가 가진 현재 위협 생성 배율을 반환합니다. /// private static float GetThreatSourceMultiplier(GameObject sourceObject) { if (sourceObject == null) return 1f; ThreatController threatController = sourceObject.GetComponent(); float runtimeThreatMultiplier = threatController != null ? Mathf.Max(0f, threatController.CurrentThreatMultiplier) : 1f; float passiveThreatMultiplier = PassiveRuntimeModifierUtility.GetThreatGeneratedMultiplier(sourceObject); return runtimeThreatMultiplier * passiveThreatMultiplier; } } }