Files
Northbound/Assets/Scripts/EnemyAIController.cs

1163 lines
42 KiB
C#

using Unity.Netcode;
using UnityEngine;
using UnityEngine.AI;
using System.Collections;
using System.Collections.Generic;
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;
[Header("Camp Alert System")]
[Tooltip("이 크립이 속한 캠프 (null이면 독립 크립)")]
public CreepCamp creepCamp;
[Header("Aggro System")]
[Tooltip("어그로(위협도) 시스템 활성화")]
public bool enableAggroSystem = true;
[Tooltip("데미지당 어그로 증가량")]
public float damageAggroMultiplier = 2f;
[Tooltip("어그로 감쇠율 (초당)")]
public float aggroDecayRate = 5f;
[Tooltip("새 타겟 전환에 필요한 최소 어그로 차이")]
public float aggroSwitchThreshold = 20f;
[Tooltip("타겟 재평가 주기 (초)")]
public float targetReevaluateInterval = 1f;
[Tooltip("거리 기반 타겟 전환 허용 거리차")]
public float distanceSwitchThreshold = 5f;
private NavMeshAgent _agent;
private EnemyUnit _enemyUnit;
private Core _core;
private Transform _coreTransform;
private Collider _coreCollider;
private Vector3 _originPosition;
private Vector3 _chaseStartPosition;
private float _lastAttackTime;
private bool _hasSetCoreDestination;
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;
private Animator _animator;
private Unity.Netcode.Components.NetworkAnimator _networkAnimator;
private bool _isAttacking = false;
private GameObject _pendingAttackTarget;
private float _attackStartTime;
private const float ATTACK_TIMEOUT = 1f; // 애니메이션 이벤트 미발생 시 타임아웃
// 어그로 시스템
private Dictionary<ulong, float> _aggroTable = new Dictionary<ulong, float>();
private float _lastTargetReevaluateTime;
private System.Action<ulong, int> _damageTakenHandler;
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
_agent = GetComponent<NavMeshAgent>();
_enemyUnit = GetComponent<EnemyUnit>();
_animator = GetComponent<Animator>();
_networkAnimator = GetComponent<Unity.Netcode.Components.NetworkAnimator>();
_originPosition = transform.position;
if (IsServer)
{
// NavMeshAgent 초기 설정
_agent.speed = moveSpeed;
_agent.acceleration = 100f; // 높은 가속도로 즉시 정지 가능
_agent.angularSpeed = 360f; // 빠른 회전
_agent.stoppingDistance = attackRange * 0.9f; // 공격 범위까지 더 가까이 이동
_agent.autoBraking = true;
_agent.updateRotation = true;
_agent.updateUpAxis = false;
if (aiType == TeamType.Monster)
{
FindCore();
TransitionToState(EnemyAIState.MoveToCore);
}
else if (aiType == TeamType.Hostile)
{
TransitionToState(EnemyAIState.Idle);
}
// 사망 이벤트 구독
if (_enemyUnit != null)
{
_enemyUnit.OnDeath += HandleDeath;
// 데미지 받음 이벤트 구독 (어그로 시스템)
if (enableAggroSystem)
{
_damageTakenHandler = (attackerId, damage) => HandleDamageTaken(attackerId, damage);
_enemyUnit.OnDamageTaken += _damageTakenHandler;
}
}
}
}
public override void OnNetworkDespawn()
{
if (_enemyUnit != null)
{
_enemyUnit.OnDeath -= HandleDeath;
if (_damageTakenHandler != null)
{
_enemyUnit.OnDamageTaken -= _damageTakenHandler;
}
}
base.OnNetworkDespawn();
}
private void Update()
{
if (!IsServer) return;
if (!_agent.isOnNavMesh) return;
// 어그로 시스템 업데이트
if (enableAggroSystem)
{
ReevaluateTarget();
}
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);
// 캠프 내 모든 크립에게 경고 전파
if (creepCamp != null)
{
creepCamp.AlertAllCreeps(player, this);
}
TransitionToState(EnemyAIState.ChasePlayer);
}
}
private void UpdateMoveToCore()
{
if (_isRecalculatingPath) return; // 코루틴 대기 중이면 중단
if (_coreTransform == null) { FindCore(); return; }
// 0. 경로 계산 중이면 대기
if (_agent.pathPending) return;
// 0.5. 코어 콜라이더 표면까지의 거리로 도달 확인 (이동 중 여부와 관계없이 항상 체크)
if (GetDistanceToCoreSurface() <= attackRange)
{
SetTargetPlayer(_coreTransform.gameObject);
TransitionToState(EnemyAIState.Attack);
return;
}
// 1. Player 감지 (코어로 가는 도중에도 Player/건물을 타겟팅)
GameObject detectedPlayer = DetectTarget();
if (detectedPlayer != null)
{
SetTargetPlayer(detectedPlayer);
// 캠프 내 모든 크립에게 경고 전파
if (creepCamp != null)
{
creepCamp.AlertAllCreeps(detectedPlayer, this);
}
TransitionToState(EnemyAIState.ChasePlayer);
return;
}
// 2. 경로 상의 장애물(건물) 감지 - 경로 상태와 관계없이 확인
GameObject obstacle = DetectObstacle();
if (obstacle != null)
{
SetTargetPlayer(obstacle);
TransitionToState(EnemyAIState.Attack);
return;
}
// 3. 코어로 가는 경로 설정
if (!_hasSetCoreDestination || !_agent.hasPath || _agent.pathStatus == NavMeshPathStatus.PathInvalid)
{
_agent.SetDestination(_core.GetNavMeshPosition());
_hasSetCoreDestination = true;
}
}
private void UpdateChasePlayer()
{
GameObject targetPlayer = GetTargetPlayer();
if (targetPlayer == null)
{
OnLostTarget();
return;
}
// 타겟이 사망했는지 확인
IDamageable damageable = targetPlayer.GetComponentInParent<IDamageable>();
if (damageable != null && damageable.IsDead())
{
OnLostTarget();
return;
}
// 코어 타겟인지 확인
bool isCoreTarget = (targetPlayer == _coreTransform?.gameObject);
// 거리 계산 - 코어는 콜라이더 표면까지의 거리 사용
float distanceToPlayer;
if (isCoreTarget)
{
distanceToPlayer = GetDistanceToCoreSurface();
}
else
{
distanceToPlayer = GetDistanceToTarget(targetPlayer);
}
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()
{
// 공격 타임아웃 체크 (애니메이션 이벤트가 발생하지 않은 경우)
if (_isAttacking && Time.time - _attackStartTime > ATTACK_TIMEOUT)
{
if (showDebugInfo)
{
Debug.LogWarning($"[EnemyAIController] 공격 타임아웃 - 애니메이션 이벤트 미발생: {gameObject.name}");
}
// 타임아웃 시 공격 실행 후 상태 리셋
PerformAttack();
_isAttacking = false;
_pendingAttackTarget = null;
}
GameObject target = GetTargetPlayer();
if (target == null)
{
if (showDebugInfo) {}
OnLostTarget();
return;
}
// 타겟이 사망했는지 확인
IDamageable damageable = target.GetComponentInParent<IDamageable>();
if (damageable != null && damageable.IsDead())
{
OnLostTarget();
return;
}
// 코어 공격 시에는 도달 시점부터 항상 공격 가능하다고 가정 (거리 체크 생략)
bool isCoreTarget = (target == _coreTransform?.gameObject);
if (!isCoreTarget)
{
// 일반 타겟(플레이어, 장애물)은 거리 체크 필요
float distance = GetDistanceToTarget(target);
// 공격 범위 체크 (공격 시 약간의 거리 여유 1.2f 부여)
if (distance > attackRange * 1.2f)
{
// 거리가 멀어지면 추적 상태로 전환
TransitionToState(EnemyAIState.ChasePlayer);
return;
}
}
// 타겟 바라보기
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);
}
}
private void UpdateReturnToOrigin()
{
// 복귀 중에는 플레이어 감지하지 않음 (무한 루프 방지)
// Idle 상태에 도달하면 다시 감지 시작
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;
// 이미 사망한 타겟은 제외
if (damageable.IsDead()) 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;
// 타겟 설정 시 기본 어그로 부여 (최초 감지)
if (enableAggroSystem && !_aggroTable.ContainsKey(networkObject.NetworkObjectId))
{
_aggroTable[networkObject.NetworkObjectId] = 10f; // 기본 어그로
}
}
else
{
_targetPlayerId.Value = 0;
}
}
private void ClearTargetPlayer()
{
_targetPlayerId.Value = 0;
_cachedTargetPlayer = null;
}
private GameObject GetTargetPlayer()
{
// 1순위: 물리적 참조 (장애물/서버 전용)
if (_cachedTargetPlayer != null && _cachedTargetPlayer.activeInHierarchy)
{
// 사망 상태 체크
IDamageable damageable = _cachedTargetPlayer.GetComponentInParent<IDamageable>();
if (damageable == null || damageable.IsDead())
{
ClearTargetPlayer();
return null;
}
return _cachedTargetPlayer;
}
// 2순위: 네트워크 ID 검색
if (_targetPlayerId.Value != 0)
{
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(_targetPlayerId.Value, out NetworkObject networkObject))
{
_cachedTargetPlayer = networkObject.gameObject;
// 사망 상태 체크
IDamageable damageable = _cachedTargetPlayer.GetComponentInParent<IDamageable>();
if (damageable != null && damageable.IsDead())
{
ClearTargetPlayer();
return null;
}
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)
{
// 이미 공격 중이면 대기
if (_isAttacking) return;
// 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)
{
_lastAttackTime = Time.time;
_isAttacking = true;
_pendingAttackTarget = target;
_attackStartTime = Time.time;
// 애니메이션 재생 (서버에서 NetworkAnimator로 동기화)
if (_networkAnimator != null)
{
_networkAnimator.SetTrigger("Attack");
}
else if (_animator != null)
{
_animator.SetTrigger("Attack");
// 애니메이션은 있지만 NetworkAnimator가 없으면 즉시 공격 (이벤트 미지원)
PerformAttack();
_isAttacking = false;
}
else
{
// 애니메이션이 없으면 즉시 공격 실행
PerformAttack();
_isAttacking = false;
}
}
}
else
{
// 공격할 수 없는 대상이면 상태를 해제합니다.
if (showDebugInfo)
{
Debug.LogWarning($"[EnemyAIController] IDamageable을 찾을 수 없음: {target.name}");
}
OnLostTarget();
}
}
/// <summary>
/// 애니메이션 이벤트: 공격 타격 시점에 호출
/// </summary>
public void OnAttackHit()
{
PerformAttack();
}
/// <summary>
/// 애니메이션 이벤트: 공격 완료 시 호출
/// </summary>
public void OnAttackComplete()
{
_isAttacking = false;
_pendingAttackTarget = null;
}
/// <summary>
/// 실제 공격 수행 (데미지 적용)
/// </summary>
private void PerformAttack()
{
if (_pendingAttackTarget == null)
{
if (showDebugInfo)
{
Debug.LogWarning("[EnemyAIController] PerformAttack: 타겟이 없음");
}
return;
}
IDamageable damageable = _pendingAttackTarget.GetComponentInChildren<IDamageable>();
if (damageable == null) damageable = _pendingAttackTarget.GetComponent<IDamageable>();
if (damageable == null) damageable = _pendingAttackTarget.GetComponentInParent<IDamageable>();
if (damageable != null && !damageable.IsDead())
{
damageable.TakeDamage(attackDamage, NetworkObjectId);
OnAttackPerformed?.Invoke(_pendingAttackTarget);
if (showDebugInfo)
{
Debug.Log($"[EnemyAIController] {gameObject.name}이(가) {_pendingAttackTarget.name}에게 {attackDamage} 데미지 적용");
}
}
else
{
if (showDebugInfo)
{
Debug.LogWarning($"[EnemyAIController] PerformAttack: 대상이 유효하지 않음 - {_pendingAttackTarget.name}");
}
}
}
#endregion
#region Aggro System
private void HandleDamageTaken(ulong attackerId, int damage)
{
if (!IsServer) return;
if (!enableAggroSystem) return;
// 공격자에게 어그로 추가
AddAggro(attackerId, damage * damageAggroMultiplier);
// 즉시 타겟 전환 고려
ConsiderTargetSwitch(attackerId);
}
private void AddAggro(ulong attackerId, float amount)
{
if (_aggroTable.ContainsKey(attackerId))
{
_aggroTable[attackerId] += amount;
}
else
{
_aggroTable[attackerId] = amount;
}
}
private void DecayAggro()
{
List<ulong> keysToRemove = new List<ulong>();
foreach (var kvp in _aggroTable)
{
float newAggro = kvp.Value - aggroDecayRate * Time.deltaTime;
if (newAggro <= 0)
{
keysToRemove.Add(kvp.Key);
}
else
{
_aggroTable[kvp.Key] = newAggro;
}
}
// 어그로가 0 이하인 항목 제거
foreach (var key in keysToRemove)
{
_aggroTable.Remove(key);
}
}
private void ConsiderTargetSwitch(ulong attackerId)
{
// 현재 ChasePlayer 또는 Attack 상태가 아니면 무시
if (_currentState.Value != EnemyAIState.ChasePlayer &&
_currentState.Value != EnemyAIState.Attack)
{
return;
}
// 현재 타겟의 어그로 확인
ulong currentTargetId = _targetPlayerId.Value;
float currentAggro = 0f;
if (currentTargetId != 0 && _aggroTable.TryGetValue(currentTargetId, out float currAgg))
{
currentAggro = currAgg;
}
// 공격자의 어그로 확인
if (!_aggroTable.TryGetValue(attackerId, out float attackerAggro))
{
return;
}
// 어그로 차이가 임계값 이상이면 타겟 전환
if (attackerAggro >= currentAggro + aggroSwitchThreshold)
{
// 공격자를 NetworkObject로 찾기
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(attackerId, out NetworkObject attackerObj))
{
GameObject attacker = attackerObj.gameObject;
// 유효한 타겟인지 확인 (사망하지 않음, 거리 내에 있음)
IDamageable damageable = attacker.GetComponentInParent<IDamageable>();
if (damageable != null && !damageable.IsDead())
{
float distance = GetDistanceToTarget(attacker);
if (distance <= maxChaseDistance)
{
SetTargetPlayer(attacker);
// 캠프에 알림
if (creepCamp != null)
{
creepCamp.AlertAllCreeps(attacker, this);
}
if (_currentState.Value != EnemyAIState.ChasePlayer)
{
TransitionToState(EnemyAIState.ChasePlayer);
}
}
}
}
}
}
private void ReevaluateTarget()
{
if (!enableAggroSystem) return;
if (Time.time - _lastTargetReevaluateTime < targetReevaluateInterval) return;
_lastTargetReevaluateTime = Time.time;
// 어그로 감쇠
DecayAggro();
// 현재 추적 중이 아니면 감지 로직에 맡김
if (_currentState.Value != EnemyAIState.ChasePlayer &&
_currentState.Value != EnemyAIState.Attack)
{
return;
}
GameObject currentTarget = GetTargetPlayer();
if (currentTarget == null) return;
// 최고 어그로 타겟 찾기
ulong highestAggroId = 0;
float highestAggro = 0f;
foreach (var kvp in _aggroTable)
{
if (kvp.Value > highestAggro)
{
// 유효한 타겟인지 확인
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(kvp.Key, out NetworkObject netObj))
{
IDamageable damageable = netObj.GetComponentInParent<IDamageable>();
if (damageable != null && !damageable.IsDead())
{
float distance = GetDistanceToTarget(netObj.gameObject);
if (distance <= maxChaseDistance)
{
highestAggroId = kvp.Key;
highestAggro = kvp.Value;
}
}
}
}
}
// 현재 타겟이 최고 어그로가 아니면 전환 고려
ulong currentTargetNetId = 0;
var currentNetObj = currentTarget.GetComponentInParent<NetworkObject>();
if (currentNetObj != null)
{
currentTargetNetId = currentNetObj.NetworkObjectId;
}
if (highestAggroId != 0 && highestAggroId != currentTargetNetId)
{
float currentAggro = 0f;
_aggroTable.TryGetValue(currentTargetNetId, out currentAggro);
if (highestAggro >= currentAggro + aggroSwitchThreshold)
{
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(highestAggroId, out NetworkObject newTargetObj))
{
SetTargetPlayer(newTargetObj.gameObject);
}
}
}
// 거리 기반 타겟 전환 (어그로가 비슷하면 더 가까운 타겟으로)
ConsiderDistanceBasedSwitch();
}
private void ConsiderDistanceBasedSwitch()
{
GameObject currentTarget = GetTargetPlayer();
if (currentTarget == null) return;
float currentDistance = GetDistanceToTarget(currentTarget);
// 주변 플레이어 검색
Collider[] colliders = Physics.OverlapSphere(transform.position, detectionRange, playerLayer);
foreach (Collider col in colliders)
{
if (col.transform.root == transform.root) continue;
if (col.gameObject == currentTarget) continue;
IDamageable damageable = col.GetComponentInParent<IDamageable>();
if (damageable == null || damageable.IsDead()) continue;
ITeamMember teamMember = col.GetComponentInParent<ITeamMember>();
if (teamMember != null && teamMember.GetTeam() != TeamType.Player) continue;
float newDistance = GetDistanceToTarget(col.gameObject);
// 현재 타겟보다 충분히 가까우면 전환 고려
if (currentDistance - newDistance >= distanceSwitchThreshold)
{
// 현재 타겟의 어그로 가져오기
ulong currentTargetId = _targetPlayerId.Value;
float currentAggro = 0f;
if (currentTargetId != 0)
{
_aggroTable.TryGetValue(currentTargetId, out currentAggro);
}
// 새 타겟이 어그로가 비슷하거나 높으면 전환
var newNetObj = col.GetComponentInParent<NetworkObject>();
if (newNetObj != null)
{
float newAggro = 0f;
_aggroTable.TryGetValue(newNetObj.NetworkObjectId, out newAggro);
// 거리 우선: 어그로가 크게 차이나지 않으면 더 가까운 타겟으로
if (newAggro >= currentAggro - aggroSwitchThreshold * 0.5f)
{
SetTargetPlayer(col.gameObject);
if (creepCamp != null)
{
creepCamp.AlertAllCreeps(col.gameObject, this);
}
return; // 한 번에 하나만 전환
}
}
}
}
}
#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:
_agent.isStopped = true;
_agent.ResetPath();
// 즉시 정지를 위해 velocity 초기화
_agent.velocity = Vector3.zero;
// 복귀 완료 - 무적 해제
if (_enemyUnit != null)
{
_enemyUnit.SetInvulnerable(false);
}
break;
case EnemyAIState.Attack:
_agent.isStopped = true;
_agent.ResetPath();
// 즉시 정지를 위해 velocity 초기화
_agent.velocity = Vector3.zero;
break;
case EnemyAIState.MoveToCore:
_agent.isStopped = false;
_agent.speed = moveSpeed;
if (_core != null)
{
_agent.SetDestination(_core.GetNavMeshPosition());
_hasSetCoreDestination = true;
}
break;
case EnemyAIState.ChasePlayer:
_agent.isStopped = false;
_agent.speed = moveSpeed * chaseSpeedMultiplier;
_chaseStartPosition = transform.position;
break;
case EnemyAIState.ReturnToOrigin:
_agent.isStopped = false;
_agent.speed = moveSpeed;
_agent.SetDestination(_originPosition);
// 복귀 시작 - 체력 회복 및 무적
if (_enemyUnit != null)
{
_enemyUnit.HealToFull();
_enemyUnit.SetInvulnerable(true);
}
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;
// 타입에 따라 적절한 상태로 전환
// Monster: 코어로 이동, Hostile: 원래 위치로 복귀
if (aiType == TeamType.Monster)
{
TransitionToState(EnemyAIState.MoveToCore);
}
else
{
TransitionToState(EnemyAIState.ReturnToOrigin);
}
}
private void FindCore()
{
_core = FindFirstObjectByType<Core>();
if (_core != null)
{
_coreTransform = _core.transform;
// 물리적 충돌을 막는 MeshCollider를 우선 찾기 (Trigger 콜라이더 무시)
Collider[] allColliders = _core.GetComponentsInChildren<Collider>();
foreach (Collider col in allColliders)
{
if (!col.isTrigger)
{
_coreCollider = col;
break;
}
}
// Fallback: 모든 콜라이더가 Trigger이면 첫 번째 콜라이더 사용
if (_coreCollider == null && allColliders.Length > 0)
{
_coreCollider = allColliders[0];
}
}
}
#endregion
#region Public API
public EnemyAIState GetCurrentState()
{
return _currentState.Value;
}
/// <summary>
/// 캠프 내 다른 크립으로부터 경고 전파 받음
/// </summary>
public void ReceiveAlert(GameObject target)
{
if (!IsServer) return;
if (target == null) return;
// 이미 해당 타겟을 추적 중이면 무시
if (_currentState.Value == EnemyAIState.ChasePlayer || _currentState.Value == EnemyAIState.Attack)
{
GameObject currentTarget = GetTargetPlayer();
if (currentTarget == target) return;
}
// 이미 사망한 타겟은 무시
IDamageable damageable = target.GetComponentInParent<IDamageable>();
if (damageable != null && damageable.IsDead()) return;
// 타겟 설정 및 추적 시작
SetTargetPlayer(target);
if (_currentState.Value == EnemyAIState.Idle || _currentState.Value == EnemyAIState.MoveToCore)
{
TransitionToState(EnemyAIState.ChasePlayer);
}
}
#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);
}
}
private void OnDrawGizmosSelected()
{
#if UNITY_EDITOR
// 상태 및 타겟 정보 표시
string stateInfo = $"State: {_currentState.Value}";
string targetInfo = "Target: None";
if (_cachedTargetPlayer != null)
{
targetInfo = $"Target: {_cachedTargetPlayer.name}";
}
else if (_targetPlayerId.Value != 0)
{
targetInfo = $"Target ID: {_targetPlayerId.Value}";
}
else if (_coreTransform != null && _currentState.Value == EnemyAIState.MoveToCore)
{
targetInfo = "Target: Core (moving)";
}
string attackInfo = _isAttacking ? " [ATTACKING]" : "";
string distanceInfo = "";
if (_coreTransform != null)
{
float distToCore = GetDistanceToCoreSurface();
distanceInfo = $"\nDistToCore: {distToCore:F1} (Range: {attackRange})";
}
// 어그로 정보 표시
string aggroInfo = "";
if (enableAggroSystem && _aggroTable.Count > 0)
{
float currentAggro = 0f;
if (_targetPlayerId.Value != 0 && _aggroTable.TryGetValue(_targetPlayerId.Value, out float agg))
{
currentAggro = agg;
}
aggroInfo = $"\nAggro: {currentAggro:F0} (Targets: {_aggroTable.Count})";
}
string fullInfo = $"{gameObject.name}\n{stateInfo}\n{targetInfo}{attackInfo}{distanceInfo}{aggroInfo}";
UnityEditor.Handles.Label(
transform.position + Vector3.up * 3f,
fullInfo,
new GUIStyle(GUI.skin.label) { fontSize = 12, normal = { textColor = Color.white } }
);
// 타겟으로 선 그리기
if (_cachedTargetPlayer != null)
{
Gizmos.color = Color.magenta;
Gizmos.DrawLine(transform.position + Vector3.up, _cachedTargetPlayer.transform.position + Vector3.up);
}
else if (_coreTransform != null && _currentState.Value == EnemyAIState.MoveToCore)
{
Gizmos.color = Color.cyan;
Gizmos.DrawLine(transform.position + Vector3.up, _coreTransform.position + Vector3.up);
}
#endif
}
#endregion
}
}