Files
Northbound/Assets/Scripts/EnemyAIController.cs
2026-02-16 22:17:37 +09:00

571 lines
20 KiB
C#

using Unity.Netcode;
using UnityEngine;
using UnityEngine.AI;
using System.Collections;
namespace Northbound
{
/// <summary>
/// 몬스터와 적대 세력의 AI 컨트롤러
/// </summary>
[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<GameObject> 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<EnemyAIState> _currentState = new NetworkVariable<EnemyAIState>(
EnemyAIState.Idle,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
private NetworkVariable<ulong> _targetPlayerId = new NetworkVariable<ulong>(
0,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
private GameObject _cachedTargetPlayer;
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
_agent = GetComponent<NavMeshAgent>();
_enemyUnit = GetComponent<EnemyUnit>();
_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<IDamageable>();
if (damageable == null) continue;
ITeamMember teamMember = col.GetComponentInParent<ITeamMember>();
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<IDamageable>() != 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<NetworkObject>();
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<IDamageable>();
if (damageable == null) damageable = target.GetComponent<IDamageable>();
if (damageable == null) damageable = target.GetComponentInParent<IDamageable>();
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<Collider>();
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<Core>();
if (core != null)
{
_coreTransform = core.transform;
_coreCollider = core.GetComponent<Collider>() ?? core.GetComponentInChildren<Collider>();
}
}
#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
}
}