using Unity.Netcode; using UnityEngine; using UnityEngine.AI; 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; 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 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; // NavMesh 위에 있는지 확인 if (!_agent.isOnNavMesh) { Debug.LogWarning($"[EnemyAI] {gameObject.name}이(가) NavMesh 위에 있지 않습니다!"); } else { Debug.Log($"[EnemyAI] {gameObject.name} NavMeshAgent 초기화 완료"); } // AI 타입에 따라 초기 상태 설정 if (aiType == TeamType.Monster) { FindCore(); TransitionToState(EnemyAIState.MoveToCore); } else if (aiType == TeamType.Hostile) { TransitionToState(EnemyAIState.Idle); } } } 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; } } #region State Updates private void UpdateIdle() { GameObject player = DetectPlayer(); if (player != null) { SetTargetPlayer(player); TransitionToState(EnemyAIState.ChasePlayer); } } private void UpdateMoveToCore() { // 플레이어 감지 GameObject player = DetectPlayer(); if (player != null) { SetTargetPlayer(player); TransitionToState(EnemyAIState.ChasePlayer); return; } // 코어가 없으면 찾기 if (_coreTransform == null) { FindCore(); _hasSetCoreDestination = false; return; } // 코어 표면까지의 실제 거리 계산 float distanceToCore = GetDistanceToCoreSurface(); // 공격 범위 안에 있으면 공격 상태로 전환 if (distanceToCore <= attackRange) { TransitionToState(EnemyAIState.Attack); return; } // 경로가 설정되지 않았거나 무효화된 경우에만 설정 if (!_hasSetCoreDestination || !_agent.hasPath || _agent.pathStatus == NavMeshPathStatus.PathInvalid) { if (_agent.SetDestination(_coreTransform.position)) { _hasSetCoreDestination = true; if (showDebugInfo) { Debug.Log($"[EnemyAI] {gameObject.name} 코어로 경로 설정 (표면 거리: {distanceToCore:F2}m, 공격범위: {attackRange:F2}m)"); } } else { Debug.LogWarning($"[EnemyAI] {gameObject.name}이(가) 코어로 가는 경로를 찾을 수 없습니다!"); _hasSetCoreDestination = false; } } } 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) { if (showDebugInfo) { string referenceType = (aiType == TeamType.Monster) ? "추적 시작" : "원점"; Debug.Log($"[EnemyAI] {gameObject.name}이(가) 추적을 중단합니다. (플레이어 거리: {distanceToPlayer:F2}m, {referenceType} 거리: {distanceFromReference:F2}m)"); } OnLostTarget(); return; } // 공격 범위 확인 if (distanceToPlayer <= attackRange) { TransitionToState(EnemyAIState.Attack); return; } // 플레이어 추적 - 매 프레임 업데이트 if (_agent.isOnNavMesh && !_agent.isStopped) { _agent.SetDestination(targetPlayer.transform.position); } } private void UpdateAttack() { // 코어 공격 중인지 확인 bool attackingCore = _coreTransform != null && GetDistanceToCoreSurface() <= attackRange * 1.2f; if (attackingCore) { float distanceToCore = GetDistanceToCoreSurface(); if (distanceToCore > attackRange * 1.2f) { TransitionToState(EnemyAIState.MoveToCore); return; } // 코어를 바라보기 Vector3 directionToCore = (_coreTransform.position - transform.position).normalized; directionToCore.y = 0; if (directionToCore != Vector3.zero) { Quaternion targetRotation = Quaternion.LookRotation(directionToCore); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * 5f); } // 코어 공격 IDamageable coreHealth = _coreTransform.GetComponent(); if (coreHealth != null && Time.time - _lastAttackTime >= attackInterval) { coreHealth.TakeDamage(attackDamage, NetworkObjectId); _lastAttackTime = Time.time; Debug.Log($"[EnemyAI] {gameObject.name}이(가) 코어를 공격! (데미지: {attackDamage}, 표면 거리: {distanceToCore:F2}m)"); } } else { // 플레이어 공격 GameObject targetPlayer = GetTargetPlayer(); if (targetPlayer == null) { OnLostTarget(); return; } float distanceToPlayer = Vector3.Distance(transform.position, targetPlayer.transform.position); if (distanceToPlayer > attackRange * 1.2f) { TransitionToState(EnemyAIState.ChasePlayer); return; } // 플레이어를 바라보기 Vector3 directionToPlayer = (targetPlayer.transform.position - transform.position).normalized; directionToPlayer.y = 0; if (directionToPlayer != Vector3.zero) { Quaternion targetRotation = Quaternion.LookRotation(directionToPlayer); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * 5f); } // 공격 if (Time.time - _lastAttackTime >= attackInterval) { AttackPlayer(targetPlayer); } } } private void UpdateReturnToOrigin() { GameObject player = DetectPlayer(); if (player != null) { SetTargetPlayer(player); TransitionToState(EnemyAIState.ChasePlayer); return; } if (!_agent.pathPending && _agent.remainingDistance <= _agent.stoppingDistance) { if (!_agent.hasPath || _agent.velocity.sqrMagnitude == 0f) { if (showDebugInfo) { Debug.Log($"[EnemyAI] {gameObject.name}이(가) 원래 위치로 복귀했습니다."); } TransitionToState(EnemyAIState.Idle); } } } #endregion #region Detection private GameObject DetectPlayer() { Collider[] colliders = Physics.OverlapSphere(transform.position, detectionRange, playerLayer); GameObject closestPlayer = null; float closestDistance = float.MaxValue; foreach (Collider col in colliders) { // 자기 자신 제외 if (col.transform.root == transform.root) continue; // 플레이어 팀 확인 (부모에서 찾기) ITeamMember teamMember = col.GetComponentInParent(); if (teamMember == null || teamMember.GetTeam() != TeamType.Player) continue; // 플레이어 위치 (루트 오브젝트 사용) Transform playerRoot = col.transform.root; Vector3 playerPosition = playerRoot.position; // 거리 체크 float distance = Vector3.Distance(transform.position, playerPosition); if (distance > detectionRange) continue; // 시야각 확인 (360도면 모든 방향 감지) if (detectionAngle < 360f) { Vector3 directionToTarget = (playerPosition - transform.position).normalized; float angleToTarget = Vector3.Angle(transform.forward, directionToTarget); if (angleToTarget > detectionAngle / 2f) continue; } // 시야 체크 (레이캐스트) - 플레이어 중심으로 Vector3 rayStart = transform.position + Vector3.up * 1f; // 적의 눈 높이 Vector3 rayTarget = playerPosition + Vector3.up * 1f; // 플레이어 중심 Vector3 rayDirection = (rayTarget - rayStart).normalized; float rayDistance = Vector3.Distance(rayStart, rayTarget); bool lineOfSight = true; // 장애물 체크 (옵션) if (Physics.Raycast(rayStart, rayDirection, out RaycastHit hit, rayDistance, obstacleLayer)) { // 맞은 오브젝트가 플레이어의 루트나 자식인지 확인 if (hit.transform.root != playerRoot) { lineOfSight = false; } } if (lineOfSight) { // 가장 가까운 플레이어 찾기 if (distance < closestDistance) { closestDistance = distance; closestPlayer = playerRoot.gameObject; } } } // 감지 성공 시 로그 (1초에 한 번만) if (closestPlayer != null && showDebugInfo && Time.time - _lastDetectionLogTime >= 1f) { string angleInfo = detectionAngle >= 360f ? "전방향" : $"{Vector3.Angle(transform.forward, (closestPlayer.transform.position - transform.position).normalized):F1}°"; Debug.Log($"[EnemyAI] {gameObject.name}이(가) {closestPlayer.name}을(를) 감지! (거리: {closestDistance:F2}m, 각도: {angleInfo})"); _lastDetectionLogTime = Time.time; } return closestPlayer; } #endregion #region Combat private void AttackPlayer(GameObject player) { IDamageable damageable = player.GetComponentInParent(); if (damageable != null) { damageable.TakeDamage(attackDamage, NetworkObjectId); _lastAttackTime = Time.time; if (showDebugInfo) { Debug.Log($"[EnemyAI] {gameObject.name}이(가) {player.name}을(를) 공격! (데미지: {attackDamage})"); } } } #endregion #region Distance Calculation private float GetDistanceToCoreSurface() { if (_coreTransform == null) return float.MaxValue; if (_coreCollider != null) { Vector3 closestPoint = _coreCollider.ClosestPoint(transform.position); float distanceToSurface = Vector3.Distance(transform.position, closestPoint); return distanceToSurface; } else { return Vector3.Distance(transform.position, _coreTransform.position); } } #endregion #region State Management private void TransitionToState(EnemyAIState newState) { if (_currentState.Value == newState) return; if (showDebugInfo) { Debug.Log($"[EnemyAI] {gameObject.name} 상태 변경: {_currentState.Value} → {newState}"); } OnExitState(_currentState.Value); _currentState.Value = newState; OnEnterState(newState); } private void OnEnterState(EnemyAIState state) { switch (state) { case EnemyAIState.Idle: _agent.isStopped = true; _agent.speed = moveSpeed; _agent.ResetPath(); break; case EnemyAIState.MoveToCore: _agent.isStopped = false; _agent.speed = moveSpeed; _hasSetCoreDestination = false; if (showDebugInfo) { Debug.Log($"[EnemyAI] {gameObject.name}이(가) 코어로 이동 시작"); } break; case EnemyAIState.ChasePlayer: _agent.isStopped = false; _agent.speed = moveSpeed * chaseSpeedMultiplier; _chaseStartPosition = transform.position; if (showDebugInfo) { Debug.Log($"[EnemyAI] {gameObject.name}이(가) 추적 시작! (시작 위치: {_chaseStartPosition})"); } break; case EnemyAIState.Attack: _agent.isStopped = true; _agent.ResetPath(); break; case EnemyAIState.ReturnToOrigin: _agent.isStopped = false; _agent.speed = moveSpeed; _agent.stoppingDistance = 1f; _agent.SetDestination(_originPosition); ClearTargetPlayer(); break; } } private void OnExitState(EnemyAIState state) { if (state == EnemyAIState.ReturnToOrigin) { _agent.stoppingDistance = attackRange * 0.7f; } } private void OnLostTarget() { if (aiType == TeamType.Hostile) { TransitionToState(EnemyAIState.ReturnToOrigin); } else if (aiType == TeamType.Monster) { ClearTargetPlayer(); _hasSetCoreDestination = false; TransitionToState(EnemyAIState.MoveToCore); } } #endregion #region Target Management private void SetTargetPlayer(GameObject player) { var networkObject = player.GetComponentInParent(); if (networkObject != null) { _targetPlayerId.Value = networkObject.NetworkObjectId; _cachedTargetPlayer = player; } } private void ClearTargetPlayer() { _targetPlayerId.Value = 0; _cachedTargetPlayer = null; } private GameObject GetTargetPlayer() { if (_targetPlayerId.Value == 0) return null; if (_cachedTargetPlayer != null && _cachedTargetPlayer.activeSelf) { return _cachedTargetPlayer; } if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(_targetPlayerId.Value, out NetworkObject networkObject)) { _cachedTargetPlayer = networkObject.gameObject; return _cachedTargetPlayer; } return null; } #endregion #region Utilities private void FindCore() { Core core = FindFirstObjectByType(); if (core != null) { _coreTransform = core.transform; _coreCollider = core.GetComponent(); if (_coreCollider == null) { _coreCollider = core.GetComponentInChildren(); } if (_coreCollider != null) { Debug.Log($"[EnemyAI] {gameObject.name}이(가) 코어를 찾았습니다! (위치: {_coreTransform.position}, Collider: {_coreCollider.GetType().Name})"); } else { Debug.LogWarning($"[EnemyAI] {gameObject.name}이(가) 코어를 찾았지만 Collider가 없습니다."); } } else { Debug.LogWarning($"[EnemyAI] {gameObject.name}이(가) 코어를 찾을 수 없습니다!"); } } #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); // 시야각 (360도가 아닐 때만 표시) if (detectionAngle < 360f) { Vector3 forward = transform.forward * detectionRange; Vector3 leftBoundary = Quaternion.Euler(0, -detectionAngle / 2f, 0) * forward; Vector3 rightBoundary = Quaternion.Euler(0, detectionAngle / 2f, 0) * forward; Gizmos.color = Color.blue; Gizmos.DrawLine(transform.position, transform.position + leftBoundary); Gizmos.DrawLine(transform.position, transform.position + rightBoundary); } // 원점 표시 (적대 세력만) if (aiType == TeamType.Hostile && Application.isPlaying) { Gizmos.color = Color.green; Gizmos.DrawWireSphere(_originPosition, 1f); Gizmos.DrawLine(transform.position, _originPosition); } // 추적 시작 위치 표시 if (Application.isPlaying && (_currentState.Value == EnemyAIState.ChasePlayer || _currentState.Value == EnemyAIState.Attack)) { Gizmos.color = Color.cyan; Gizmos.DrawWireSphere(_chaseStartPosition, 1.5f); Gizmos.DrawLine(transform.position, _chaseStartPosition); } // 코어 방향 및 표면까지의 거리 표시 if (Application.isPlaying && _currentState.Value == EnemyAIState.MoveToCore && _coreTransform != null) { Gizmos.color = Color.magenta; Gizmos.DrawLine(transform.position, _coreTransform.position); Gizmos.DrawWireSphere(_coreTransform.position, 2f); if (_coreCollider != null) { Vector3 closestPoint = _coreCollider.ClosestPoint(transform.position); Gizmos.color = Color.green; Gizmos.DrawLine(transform.position, closestPoint); Gizmos.DrawWireSphere(closestPoint, 0.5f); } } } private void OnDrawGizmosSelected() { OnDrawGizmos(); #if UNITY_EDITOR if (Application.isPlaying && _agent != null) { string pathInfo = _agent.hasPath ? $"Path: {_agent.path.status}" : "No Path"; string navMeshInfo = _agent.isOnNavMesh ? "On NavMesh" : "OFF NAVMESH!"; string velocityInfo = $"Velocity: {_agent.velocity.magnitude:F2}"; string distanceInfo = ""; if (_coreTransform != null && _currentState.Value == EnemyAIState.MoveToCore) { float surfaceDistance = GetDistanceToCoreSurface(); distanceInfo = $"\nCore Surface Dist: {surfaceDistance:F2}m"; } string angleInfo = detectionAngle >= 360f ? "\nDetection: 360° (전방향)" : $"\nDetection: {detectionAngle}°"; UnityEditor.Handles.Label(transform.position + Vector3.up * 3f, $"Enemy AI\nState: {_currentState.Value}\nType: {aiType}\n{navMeshInfo}\n{pathInfo}\n{velocityInfo}{angleInfo}\nRange: {detectionRange}m\nAttack: {attackRange}m{distanceInfo}"); } #endif } #endregion } }