using System; using System.Collections.Generic; using System.Text; using UnityEngine; using Unity.Netcode; using Colosseum.Stats; using Colosseum.Combat; 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; [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; // 네트워크 동기화 변수 protected NetworkVariable currentHealth = new NetworkVariable(100f); protected NetworkVariable currentMana = new NetworkVariable(50f); protected NetworkVariable isDead = new NetworkVariable(false); // 플레이어 분리용 (레이어 의존 없이 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 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 bool IsDead => isDead.Value; public CharacterStats Stats => characterStats; public EnemyData Data => enemyData; public Animator Animator => animator; public bool UseThreatSystem => useThreatSystem; public override void OnNetworkSpawn() { // 컴포넌트 참조 확인 if (characterStats == null) characterStats = GetComponent(); if (animator == null) animator = GetComponentInChildren(); if (navMeshAgent == null) navMeshAgent = GetComponent(); // 서버에서 초기화 if (IsServer) { InitializeStats(); } // 클라이언트에서 체력 변화 감지 currentHealth.OnValueChanged += OnHealthChangedInternal; } protected virtual void Update() { if (!IsServer || IsDead) return; UpdateThreatState(Time.deltaTime); OnServerUpdate(); } /// /// 서버 Update 확장 포인트 (하위 클래스에서 override) /// protected virtual void OnServerUpdate() { } /// /// 보스와 플레이어가 겹치면 플레이어를 밀어냅니다. /// 점프 착지 포함, 항상 실행됩니다. /// private void LateUpdate() { if (!IsServer || IsDead) return; float separationDist = navMeshAgent != null ? Mathf.Max(navMeshAgent.stoppingDistance, navMeshAgent.radius + 0.5f) : 1f; int count = Physics.OverlapSphereNonAlloc(transform.position, separationDist, overlapBuffer); for (int i = 0; i < count; i++) { if (!overlapBuffer[i].TryGetComponent(out var cc)) continue; Vector3 toPlayer = overlapBuffer[i].transform.position - transform.position; toPlayer.y = 0f; float dist = toPlayer.magnitude; if (dist >= separationDist) continue; // 플레이어를 보스 바깥으로 밀어냄 Vector3 pushDir = dist > 0.001f ? toPlayer.normalized : transform.forward; cc.Move(pushDir * (separationDist - dist)); // 보스가 이동 중이었으면 정지 (플레이어 안으로 더 진입하지 않도록) if (navMeshAgent != null && !isAirborne && navMeshAgent.velocity.sqrMagnitude > 0.01f) navMeshAgent.isStopped = true; } } /// /// 점프 타겟 설정. 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.IsInEndAnimation && 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); transform.position = new Vector3(newXZ.x, transform.position.y + deltaPosition.y, newXZ.z); } else { // jumpToTarget 없으면 기존처럼 애니메이션 루트모션 그대로 적용 transform.position += deltaPosition; } } else { // 착지 후 NavMeshAgent 복원 if (isAirborne) { isAirborne = false; if (hasJumpTarget) { // lerp가 1.0에 못 미쳐도 착지 시 정확한 위치로 스냅 transform.position = new Vector3(jumpTargetXZ.x, transform.position.y, jumpTargetXZ.z); } hasJumpTarget = false; navMeshAgent.enabled = true; navMeshAgent.Warp(transform.position); } // XZ 차단: 플레이어 방향으로의 이동 방지 (일반 이동 중에만) float blockRadius = Mathf.Max(navMeshAgent.stoppingDistance, navMeshAgent.radius + 0.5f); int count = Physics.OverlapSphereNonAlloc(transform.position, blockRadius, overlapBuffer); for (int i = 0; i < count; i++) { if (!overlapBuffer[i].TryGetComponent(out _)) continue; Vector3 toPlayer = overlapBuffer[i].transform.position - transform.position; toPlayer.y = 0f; if (toPlayer.sqrMagnitude < 0.0001f) continue; Vector3 deltaXZ = new Vector3(deltaPosition.x, 0f, deltaPosition.z); if (Vector3.Dot(deltaXZ, toPlayer.normalized) > 0f) { deltaPosition.x = 0f; deltaPosition.z = 0f; } } navMeshAgent.Move(deltaPosition); } if (animator.deltaRotation != Quaternion.identity) transform.rotation *= animator.deltaRotation; } public override void OnNetworkDespawn() { currentHealth.OnValueChanged -= OnHealthChangedInternal; } /// /// 스탯 초기화 (서버에서만 실행) /// 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; } /// /// 대미지 적용 (서버에서 실행) /// public virtual float TakeDamage(float damage, object source = null) { if (!IsServer || isDead.Value) return 0f; float actualDamage = Mathf.Min(damage, currentHealth.Value); currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage); RegisterThreatFromDamage(actualDamage, source); OnDamageTaken?.Invoke(actualDamage); // 대미지 피드백 (애니메이션, 이펙트 등) OnTakeDamageFeedback(actualDamage, source); if (currentHealth.Value <= 0f) { HandleDeath(); } return actualDamage; } /// /// 대미지 피드백 (애니메이션, 이펙트) /// protected virtual void OnTakeDamageFeedback(float damage, object source) { if (animator != null) { animator.SetTrigger("Hit"); } } /// /// 체력 회복 (서버에서 실행) /// 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; } /// /// 사망 애니메이션 재생 (모든 클라이언트에서 실행) /// [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; 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); } /// /// 공격자 기준 위협 수치를 누적합니다. /// 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); } /// /// 위협 테이블의 무효 대상을 정리하고 자연 감소를 적용합니다. /// 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 = Vector3.Distance(transform.position, target.transform.position); 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, }; } } }