547 lines
19 KiB
C#
547 lines
19 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)
|
|
{
|
|
Debug.LogWarning($"<color=orange>[EnemyAI] {gameObject.name}이(가) NavMesh 위에 있지 않습니다!</color>");
|
|
}
|
|
|
|
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 = 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) Debug.Log("<color=red>[EnemyAI] 타겟 상실 - 상태 해제</color>");
|
|
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);
|
|
|
|
if (showDebugInfo)
|
|
Debug.Log($"<color=red>[EnemyAI] {gameObject.name} -> {target.name} 타격 성공! (데미지: {attackDamage})</color>");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// 3. 만약 IDamageable을 못 찾았다면 로그를 남겨서 범인을 찾습니다.
|
|
if (showDebugInfo)
|
|
Debug.LogWarning($"<color=yellow>[EnemyAI] {target.name}에 IDamageable 스크립트가 없습니다!</color>");
|
|
|
|
// 공격할 수 없는 대상이면 상태를 해제합니다.
|
|
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;
|
|
}
|
|
}
|
|
|
|
private void OnExitState(EnemyAIState state) { }
|
|
|
|
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
|
|
}
|
|
} |