1163 lines
42 KiB
C#
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
|
|
}
|
|
} |