using Unity.Netcode; using UnityEngine; using UnityEngine.AI; using System.Collections; namespace Northbound { /// /// 몬스터와 적대 세력의 AI 컨트롤러 /// [RequireComponent(typeof(NavMeshAgent))] [RequireComponent(typeof(EnemyUnit))] public class EnemyAIController : NetworkBehaviour { [Header("AI Type")] [Tooltip("Monster: 코어로 이동, Hostile: 제자리에서 대기")] public TeamType aiType = TeamType.Monster; [Header("Detection")] [Tooltip("플레이어 감지 범위")] public float detectionRange = 15f; [Tooltip("시야 각도 (0-360, 360=전방향)")] [Range(0, 360)] public float detectionAngle = 120f; [Tooltip("탐지할 레이어")] public LayerMask playerLayer = ~0; [Tooltip("시야 체크 장애물 레이어")] public LayerMask obstacleLayer = ~0; [Header("Chase Settings")] [Tooltip("추적 최대 거리 (이 거리 이상 추적하면 중단)")] public float maxChaseDistance = 30f; [Tooltip("추적 포기 거리 (플레이어와 이 거리 이상 멀어지면 추적 중단)")] public float chaseGiveUpDistance = 25f; [Header("Combat")] [Tooltip("공격 범위")] public float attackRange = 2f; [Tooltip("공격 간격 (초)")] public float attackInterval = 1.5f; [Tooltip("공격 데미지")] public int attackDamage = 10; [Header("Movement")] [Tooltip("이동 속도")] public float moveSpeed = 3.5f; [Tooltip("추적 중 속도 배율")] public float chaseSpeedMultiplier = 1.5f; [Header("Debug")] [Tooltip("디버그 정보 표시")] public bool showDebugInfo = true; [Header("Events")] public System.Action OnAttackPerformed; private NavMeshAgent _agent; private EnemyUnit _enemyUnit; private Transform _coreTransform; private Collider _coreCollider; private Vector3 _originPosition; private Vector3 _chaseStartPosition; private float _lastAttackTime; private bool _hasSetCoreDestination; private float _lastDetectionLogTime; private bool _isRecalculatingPath = false; // NavMesh 갱신 대기 플래그 private NetworkVariable _currentState = new NetworkVariable( EnemyAIState.Idle, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server ); private NetworkVariable _targetPlayerId = new NetworkVariable( 0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server ); private GameObject _cachedTargetPlayer; public override void OnNetworkSpawn() { base.OnNetworkSpawn(); _agent = GetComponent(); _enemyUnit = GetComponent(); _originPosition = transform.position; if (IsServer) { // NavMeshAgent 초기 설정 _agent.speed = moveSpeed; _agent.acceleration = 8f; _agent.angularSpeed = 120f; _agent.stoppingDistance = attackRange * 0.7f; _agent.autoBraking = true; _agent.updateRotation = true; _agent.updateUpAxis = false; if (!_agent.isOnNavMesh) { } if (aiType == TeamType.Monster) { FindCore(); TransitionToState(EnemyAIState.MoveToCore); } else if (aiType == TeamType.Hostile) { TransitionToState(EnemyAIState.Idle); } // 사망 이벤트 구독 if (_enemyUnit != null) { _enemyUnit.OnDeath += HandleDeath; } } } public override void OnNetworkDespawn() { if (_enemyUnit != null) { _enemyUnit.OnDeath -= HandleDeath; } base.OnNetworkDespawn(); } private void Update() { if (!IsServer) return; if (!_agent.isOnNavMesh) return; switch (_currentState.Value) { case EnemyAIState.Idle: UpdateIdle(); break; case EnemyAIState.MoveToCore: UpdateMoveToCore(); break; case EnemyAIState.ChasePlayer: UpdateChasePlayer(); break; case EnemyAIState.Attack: UpdateAttack(); break; case EnemyAIState.ReturnToOrigin: UpdateReturnToOrigin(); break; case EnemyAIState.Dead: break; // 사망 상태에서는 아무것도 하지 않음 } } #region State Updates private void UpdateIdle() { GameObject player = DetectTarget(); if (player != null) { SetTargetPlayer(player); TransitionToState(EnemyAIState.ChasePlayer); } } private void UpdateMoveToCore() { if (_isRecalculatingPath) return; // 코루틴 대기 중이면 중단 if (_coreTransform == null) { FindCore(); return; } // 0. Player 감지 (코어로 가는 도중에도 Player를 타겟팅) GameObject detectedPlayer = DetectTarget(); if (detectedPlayer != null) { SetTargetPlayer(detectedPlayer); TransitionToState(EnemyAIState.ChasePlayer); return; } // 1. 코어로 가는 경로가 '완전(Complete)'한지 먼저 확인 // NavMesh가 갱신되었다면 에이전트는 즉시 Complete 상태가 됩니다. if (_agent.hasPath && _agent.pathStatus == NavMeshPathStatus.PathComplete) { if (!_hasSetCoreDestination) { _agent.SetDestination(_coreTransform.position); _hasSetCoreDestination = true; } // [중요] 길이 열렸으므로 아래의 장애물 탐지 로직을 아예 실행하지 않고 리턴! return; } // 2. 코어 도달 확인 if (GetDistanceToCoreSurface() <= attackRange) { TransitionToState(EnemyAIState.Attack); return; } // 3. 길이 막혔을 때(Partial)만 아주 좁은 범위에서 장애물을 찾음 GameObject obstacle = DetectObstacle(); if (obstacle != null) { SetTargetPlayer(obstacle); TransitionToState(EnemyAIState.Attack); return; } // 4. 경로가 유효하지 않을 때만 재설정 if (!_agent.hasPath || _agent.pathStatus == NavMeshPathStatus.PathInvalid) { _agent.SetDestination(_coreTransform.position); _hasSetCoreDestination = true; } } private void UpdateChasePlayer() { GameObject targetPlayer = GetTargetPlayer(); if (targetPlayer == null) { OnLostTarget(); return; } float distanceToPlayer = Vector3.Distance(transform.position, targetPlayer.transform.position); Vector3 chaseReferencePoint = (aiType == TeamType.Monster) ? _chaseStartPosition : _originPosition; float distanceFromReference = Vector3.Distance(transform.position, chaseReferencePoint); if (distanceToPlayer > chaseGiveUpDistance || distanceFromReference > maxChaseDistance) { OnLostTarget(); return; } if (distanceToPlayer <= attackRange) { TransitionToState(EnemyAIState.Attack); return; } if (_agent.isOnNavMesh && !_agent.isStopped) { _agent.SetDestination(targetPlayer.transform.position); } } private void UpdateAttack() { GameObject target = GetTargetPlayer(); if (target == null) { if (showDebugInfo) {} OnLostTarget(); return; } // 핵심: ClosestPoint를 사용해 '벽 표면'까지의 실제 거리 계산 float distance = GetDistanceToTarget(target); // 코어 혹은 일반 타겟 공격 범위 체크 (공격 시 약간의 거리 여유 1.2f 부여) if (distance <= attackRange * 1.2f) { // 타겟 바라보기 Vector3 direction = (target.transform.position - transform.position).normalized; direction.y = 0; if (direction != Vector3.zero) { transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(direction), Time.deltaTime * 5f); } // 공격 실행 if (Time.time - _lastAttackTime >= attackInterval) { AttackTarget(target); } } else { // 거리가 멀어지면 타겟 종류에 따라 상태 전환 if (target == _coreTransform?.gameObject) TransitionToState(EnemyAIState.MoveToCore); else TransitionToState(EnemyAIState.ChasePlayer); } } private void UpdateReturnToOrigin() { GameObject target = DetectTarget(); if (target != null) { SetTargetPlayer(target); TransitionToState(EnemyAIState.ChasePlayer); return; } if (!_agent.pathPending && _agent.remainingDistance <= _agent.stoppingDistance) { if (!_agent.hasPath || _agent.velocity.sqrMagnitude == 0f) { TransitionToState(EnemyAIState.Idle); } } } #endregion #region Detection & Target Management private GameObject DetectTarget() { LayerMask targetMask = playerLayer | obstacleLayer; Collider[] colliders = Physics.OverlapSphere(transform.position, detectionRange, targetMask); GameObject closestTarget = null; float closestDistance = float.MaxValue; foreach (Collider col in colliders) { if (col.transform.root == transform.root) continue; IDamageable damageable = col.GetComponentInParent(); if (damageable == null) continue; ITeamMember teamMember = col.GetComponentInParent(); bool isAttackable = (teamMember != null && teamMember.GetTeam() == TeamType.Player) || (teamMember == null); if (!isAttackable) continue; float distance = Vector3.Distance(transform.position, col.transform.position); if (detectionAngle < 360f) { Vector3 directionToTarget = (col.transform.position - transform.position).normalized; if (Vector3.Angle(transform.forward, directionToTarget) > detectionAngle / 2f) continue; } if (distance < closestDistance) { closestDistance = distance; // 부모(Walls)가 아닌 실제 콜라이더가 있는 자식(wall)을 잡음 closestTarget = col.gameObject; } } return closestTarget; } public GameObject DetectObstacle() { RaycastHit hit; Vector3 origin = transform.position + Vector3.up * 1.0f; Vector3 direction = transform.forward; float castDistance = attackRange * 1.5f; if (Physics.SphereCast(origin, 0.5f, direction, out hit, castDistance, obstacleLayer)) { if (hit.collider.GetComponentInParent() != null) { // hit.collider.gameObject를 반환함으로써 자식 오브젝트를 직접 타겟팅함 return hit.collider.gameObject; } } return null; } private void SetTargetPlayer(GameObject target) { if (target == null) return; _cachedTargetPlayer = target; var networkObject = target.GetComponentInParent(); if (networkObject != null) _targetPlayerId.Value = networkObject.NetworkObjectId; else _targetPlayerId.Value = 0; } private void ClearTargetPlayer() { _targetPlayerId.Value = 0; _cachedTargetPlayer = null; } private GameObject GetTargetPlayer() { // 1순위: 물리적 참조 (장애물/서버 전용) if (_cachedTargetPlayer != null && _cachedTargetPlayer.activeInHierarchy) return _cachedTargetPlayer; // 2순위: 네트워크 ID 검색 if (_targetPlayerId.Value != 0) { if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(_targetPlayerId.Value, out NetworkObject networkObject)) { _cachedTargetPlayer = networkObject.gameObject; return _cachedTargetPlayer; } } // 3순위: 코어 확인 if (_currentState.Value == EnemyAIState.Attack && _coreTransform != null) { if (GetDistanceToCoreSurface() <= attackRange * 1.5f) return _coreTransform.gameObject; } return null; } #endregion #region Combat Logic private void AttackTarget(GameObject target) { // 1. 타겟의 자식, 본인, 부모 순으로 샅샅이 뒤져서 IDamageable을 찾습니다. IDamageable damageable = target.GetComponentInChildren(); if (damageable == null) damageable = target.GetComponent(); if (damageable == null) damageable = target.GetComponentInParent(); if (damageable != null) { // 2. 공격 쿨타임 체크 if (Time.time - _lastAttackTime >= attackInterval) { damageable.TakeDamage(attackDamage, NetworkObjectId); _lastAttackTime = Time.time; OnAttackPerformed?.Invoke(target); } } else { // 공격할 수 없는 대상이면 상태를 해제합니다. OnLostTarget(); } } #endregion #region Utilities & Distance private float GetDistanceToTarget(GameObject target) { if (target == _coreTransform?.gameObject) return GetDistanceToCoreSurface(); Collider col = target.GetComponent(); if (col != null) { return Vector3.Distance(transform.position, col.ClosestPoint(transform.position)); } return Vector3.Distance(transform.position, target.transform.position); } private float GetDistanceToCoreSurface() { if (_coreTransform == null) return float.MaxValue; if (_coreCollider != null) { Vector3 closestPoint = _coreCollider.ClosestPoint(transform.position); return Vector3.Distance(transform.position, closestPoint); } return Vector3.Distance(transform.position, _coreTransform.position); } private void TransitionToState(EnemyAIState newState) { if (_currentState.Value == newState) return; OnExitState(_currentState.Value); _currentState.Value = newState; OnEnterState(newState); } private void OnEnterState(EnemyAIState state) { switch (state) { case EnemyAIState.Idle: case EnemyAIState.Attack: _agent.isStopped = true; _agent.ResetPath(); break; case EnemyAIState.MoveToCore: case EnemyAIState.ChasePlayer: case EnemyAIState.ReturnToOrigin: _agent.isStopped = false; _agent.speed = (state == EnemyAIState.ChasePlayer) ? moveSpeed * chaseSpeedMultiplier : moveSpeed; if (state == EnemyAIState.ChasePlayer) _chaseStartPosition = transform.position; if (state == EnemyAIState.ReturnToOrigin) _agent.SetDestination(_originPosition); break; case EnemyAIState.Dead: _agent.isStopped = true; _agent.ResetPath(); _agent.enabled = false; // NavMeshAgent 비활성화 break; } } private void OnExitState(EnemyAIState state) { } private void HandleDeath(ulong killerId) { if (!IsServer) return; // 사망 상태로 전환 TransitionToState(EnemyAIState.Dead); ClearTargetPlayer(); if (showDebugInfo) {} } private void OnLostTarget() { ClearTargetPlayer(); if (IsServer && _agent.isOnNavMesh) { // 1. 현재 가던 길을 즉시 멈춤 _agent.ResetPath(); _hasSetCoreDestination = false; // 2. NavMesh가 채워질 시간을 벌기 위한 코루틴 시작 StartCoroutine(WaitAndRepathRoutine()); } } private IEnumerator WaitAndRepathRoutine() { _isRecalculatingPath = true; // NavMesh 바닥이 채워지는 시간을 기다림 (0.15~0.2초 권장) yield return new WaitForSeconds(0.2f); _isRecalculatingPath = false; // 코어로 다시 이동 상태 전환 TransitionToState(aiType == TeamType.Monster ? EnemyAIState.MoveToCore : EnemyAIState.Idle); } private void FindCore() { Core core = FindFirstObjectByType(); if (core != null) { _coreTransform = core.transform; _coreCollider = core.GetComponent() ?? core.GetComponentInChildren(); } } #endregion #region Public API public EnemyAIState GetCurrentState() { return _currentState.Value; } #endregion #region Gizmos (기존 코드 유지) private void OnDrawGizmos() { if (!showDebugInfo) return; Gizmos.color = Color.yellow; Gizmos.DrawWireSphere(transform.position, detectionRange); Gizmos.color = Color.red; Gizmos.DrawWireSphere(transform.position, attackRange); if (detectionAngle < 360f) { Vector3 forward = transform.forward * detectionRange; Vector3 left = Quaternion.Euler(0, -detectionAngle / 2f, 0) * forward; Vector3 right = Quaternion.Euler(0, detectionAngle / 2f, 0) * forward; Gizmos.color = Color.blue; Gizmos.DrawLine(transform.position, transform.position + left); Gizmos.DrawLine(transform.position, transform.position + right); } } #endregion } }