몬스터 이동 AI
- 코어로의 경로가 막혀있을 경우 장애물을 파괴하여 경로 확보 몬스터를 소환하는 EnemyPortal 생성
This commit is contained in:
@@ -174,8 +174,6 @@ namespace Northbound
|
||||
{
|
||||
DetachWeapon();
|
||||
}
|
||||
|
||||
Debug.Log("[AttackAction] 공격 완료");
|
||||
}
|
||||
|
||||
// ========================================
|
||||
@@ -199,8 +197,6 @@ namespace Northbound
|
||||
string socket = socketName ?? equipmentData.socketName;
|
||||
_equipmentSocket.AttachToSocket(socket, equipmentData.equipmentPrefab);
|
||||
_isWeaponEquipped = true;
|
||||
|
||||
Debug.Log($"[AttackAction] 무기 장착: {socket}");
|
||||
}
|
||||
|
||||
private void DetachWeapon(string socketName = null)
|
||||
@@ -214,8 +210,6 @@ namespace Northbound
|
||||
{
|
||||
_equipmentSocket.DetachFromSocket(socket);
|
||||
_isWeaponEquipped = false;
|
||||
|
||||
Debug.Log($"[AttackAction] 무기 해제: {socket}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
using UnityEngine.AI;
|
||||
using System.Collections;
|
||||
|
||||
namespace Northbound
|
||||
{
|
||||
@@ -18,38 +19,38 @@ namespace Northbound
|
||||
[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;
|
||||
|
||||
@@ -66,6 +67,7 @@ namespace Northbound
|
||||
private float _lastAttackTime;
|
||||
private bool _hasSetCoreDestination;
|
||||
private float _lastDetectionLogTime;
|
||||
private bool _isRecalculatingPath = false; // NavMesh 갱신 대기 플래그
|
||||
|
||||
private NetworkVariable<EnemyAIState> _currentState = new NetworkVariable<EnemyAIState>(
|
||||
EnemyAIState.Idle,
|
||||
@@ -100,17 +102,11 @@ namespace Northbound
|
||||
_agent.updateRotation = true;
|
||||
_agent.updateUpAxis = false;
|
||||
|
||||
// NavMesh 위에 있는지 확인
|
||||
if (!_agent.isOnNavMesh)
|
||||
{
|
||||
Debug.LogWarning($"<color=orange>[EnemyAI] {gameObject.name}이(가) NavMesh 위에 있지 않습니다!</color>");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log($"<color=green>[EnemyAI] {gameObject.name} NavMeshAgent 초기화 완료</color>");
|
||||
}
|
||||
|
||||
// AI 타입에 따라 초기 상태 설정
|
||||
if (aiType == TeamType.Monster)
|
||||
{
|
||||
FindCore();
|
||||
@@ -130,21 +126,11 @@ namespace Northbound
|
||||
|
||||
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.Idle: UpdateIdle(); break;
|
||||
case EnemyAIState.MoveToCore: UpdateMoveToCore(); break;
|
||||
case EnemyAIState.ChasePlayer: UpdateChasePlayer(); break;
|
||||
case EnemyAIState.Attack: UpdateAttack(); break;
|
||||
case EnemyAIState.ReturnToOrigin: UpdateReturnToOrigin(); break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +138,7 @@ namespace Northbound
|
||||
|
||||
private void UpdateIdle()
|
||||
{
|
||||
GameObject player = DetectPlayer();
|
||||
GameObject player = DetectTarget();
|
||||
if (player != null)
|
||||
{
|
||||
SetTargetPlayer(player);
|
||||
@@ -162,89 +148,67 @@ namespace Northbound
|
||||
|
||||
private void UpdateMoveToCore()
|
||||
{
|
||||
// 플레이어 감지
|
||||
GameObject player = DetectPlayer();
|
||||
if (player != null)
|
||||
if (_isRecalculatingPath) return; // 코루틴 대기 중이면 중단
|
||||
if (_coreTransform == null) { FindCore(); return; }
|
||||
|
||||
// 1. 코어로 가는 경로가 '완전(Complete)'한지 먼저 확인
|
||||
// NavMesh가 갱신되었다면 에이전트는 즉시 Complete 상태가 됩니다.
|
||||
if (_agent.hasPath && _agent.pathStatus == NavMeshPathStatus.PathComplete)
|
||||
{
|
||||
SetTargetPlayer(player);
|
||||
TransitionToState(EnemyAIState.ChasePlayer);
|
||||
if (!_hasSetCoreDestination)
|
||||
{
|
||||
_agent.SetDestination(_coreTransform.position);
|
||||
_hasSetCoreDestination = true;
|
||||
}
|
||||
// [중요] 길이 열렸으므로 아래의 장애물 탐지 로직을 아예 실행하지 않고 리턴!
|
||||
return;
|
||||
}
|
||||
|
||||
// 코어가 없으면 찾기
|
||||
if (_coreTransform == null)
|
||||
{
|
||||
FindCore();
|
||||
_hasSetCoreDestination = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 코어 표면까지의 실제 거리 계산
|
||||
float distanceToCore = GetDistanceToCoreSurface();
|
||||
|
||||
// 공격 범위 안에 있으면 공격 상태로 전환
|
||||
if (distanceToCore <= attackRange)
|
||||
// 2. 코어 도달 확인
|
||||
if (GetDistanceToCoreSurface() <= attackRange)
|
||||
{
|
||||
TransitionToState(EnemyAIState.Attack);
|
||||
return;
|
||||
}
|
||||
|
||||
// 경로가 설정되지 않았거나 무효화된 경우에만 설정
|
||||
if (!_hasSetCoreDestination || !_agent.hasPath || _agent.pathStatus == NavMeshPathStatus.PathInvalid)
|
||||
// 3. 길이 막혔을 때(Partial)만 아주 좁은 범위에서 장애물을 찾음
|
||||
GameObject obstacle = DetectObstacle();
|
||||
if (obstacle != null)
|
||||
{
|
||||
if (_agent.SetDestination(_coreTransform.position))
|
||||
{
|
||||
_hasSetCoreDestination = true;
|
||||
|
||||
if (showDebugInfo)
|
||||
{
|
||||
Debug.Log($"<color=cyan>[EnemyAI] {gameObject.name} 코어로 경로 설정 (표면 거리: {distanceToCore:F2}m, 공격범위: {attackRange:F2}m)</color>");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"<color=orange>[EnemyAI] {gameObject.name}이(가) 코어로 가는 경로를 찾을 수 없습니다!</color>");
|
||||
_hasSetCoreDestination = false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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($"<color=yellow>[EnemyAI] {gameObject.name}이(가) 추적을 중단합니다. (플레이어 거리: {distanceToPlayer:F2}m, {referenceType} 거리: {distanceFromReference:F2}m)</color>");
|
||||
}
|
||||
OnLostTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
// 공격 범위 확인
|
||||
if (distanceToPlayer <= attackRange)
|
||||
{
|
||||
TransitionToState(EnemyAIState.Attack);
|
||||
return;
|
||||
}
|
||||
|
||||
// 플레이어 추적 - 매 프레임 업데이트
|
||||
if (_agent.isOnNavMesh && !_agent.isStopped)
|
||||
{
|
||||
_agent.SetDestination(targetPlayer.transform.position);
|
||||
@@ -253,80 +217,48 @@ namespace Northbound
|
||||
|
||||
private void UpdateAttack()
|
||||
{
|
||||
// 코어 공격 중인지 확인
|
||||
bool attackingCore = _coreTransform != null &&
|
||||
GetDistanceToCoreSurface() <= attackRange * 1.2f;
|
||||
|
||||
if (attackingCore)
|
||||
GameObject target = GetTargetPlayer();
|
||||
if (target == null)
|
||||
{
|
||||
float distanceToCore = GetDistanceToCoreSurface();
|
||||
|
||||
if (distanceToCore > attackRange * 1.2f)
|
||||
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)
|
||||
{
|
||||
TransitionToState(EnemyAIState.MoveToCore);
|
||||
return;
|
||||
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(direction), Time.deltaTime * 5f);
|
||||
}
|
||||
|
||||
// 코어를 바라보기
|
||||
Vector3 directionToCore = (_coreTransform.position - transform.position).normalized;
|
||||
directionToCore.y = 0;
|
||||
if (directionToCore != Vector3.zero)
|
||||
// 공격 실행
|
||||
if (Time.time - _lastAttackTime >= attackInterval)
|
||||
{
|
||||
Quaternion targetRotation = Quaternion.LookRotation(directionToCore);
|
||||
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * 5f);
|
||||
}
|
||||
|
||||
// 코어 공격
|
||||
IDamageable coreHealth = _coreTransform.GetComponent<IDamageable>();
|
||||
if (coreHealth != null && Time.time - _lastAttackTime >= attackInterval)
|
||||
{
|
||||
coreHealth.TakeDamage(attackDamage, NetworkObjectId);
|
||||
_lastAttackTime = Time.time;
|
||||
Debug.Log($"<color=red>[EnemyAI] {gameObject.name}이(가) 코어를 공격! (데미지: {attackDamage}, 표면 거리: {distanceToCore:F2}m)</color>");
|
||||
AttackTarget(target);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
// 거리가 멀어지면 타겟 종류에 따라 상태 전환
|
||||
if (target == _coreTransform?.gameObject) TransitionToState(EnemyAIState.MoveToCore);
|
||||
else TransitionToState(EnemyAIState.ChasePlayer);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateReturnToOrigin()
|
||||
{
|
||||
GameObject player = DetectPlayer();
|
||||
if (player != null)
|
||||
GameObject target = DetectTarget();
|
||||
if (target != null)
|
||||
{
|
||||
SetTargetPlayer(player);
|
||||
SetTargetPlayer(target);
|
||||
TransitionToState(EnemyAIState.ChasePlayer);
|
||||
return;
|
||||
}
|
||||
@@ -335,10 +267,6 @@ namespace Northbound
|
||||
{
|
||||
if (!_agent.hasPath || _agent.velocity.sqrMagnitude == 0f)
|
||||
{
|
||||
if (showDebugInfo)
|
||||
{
|
||||
Debug.Log($"<color=green>[EnemyAI] {gameObject.name}이(가) 원래 위치로 복귀했습니다.</color>");
|
||||
}
|
||||
TransitionToState(EnemyAIState.Idle);
|
||||
}
|
||||
}
|
||||
@@ -346,224 +274,71 @@ namespace Northbound
|
||||
|
||||
#endregion
|
||||
|
||||
#region Detection
|
||||
#region Detection & Target Management
|
||||
|
||||
private GameObject DetectPlayer()
|
||||
private GameObject DetectTarget()
|
||||
{
|
||||
Collider[] colliders = Physics.OverlapSphere(transform.position, detectionRange, playerLayer);
|
||||
LayerMask targetMask = playerLayer | obstacleLayer;
|
||||
Collider[] colliders = Physics.OverlapSphere(transform.position, detectionRange, targetMask);
|
||||
|
||||
GameObject closestPlayer = null;
|
||||
GameObject closestTarget = null;
|
||||
float closestDistance = float.MaxValue;
|
||||
|
||||
foreach (Collider col in colliders)
|
||||
{
|
||||
// 자기 자신 제외
|
||||
if (col.transform.root == transform.root)
|
||||
continue;
|
||||
if (col.transform.root == transform.root) continue;
|
||||
|
||||
IDamageable damageable = col.GetComponentInParent<IDamageable>();
|
||||
if (damageable == null) continue;
|
||||
|
||||
// 플레이어 팀 확인 (부모에서 찾기)
|
||||
ITeamMember teamMember = col.GetComponentInParent<ITeamMember>();
|
||||
if (teamMember == null || teamMember.GetTeam() != TeamType.Player)
|
||||
continue;
|
||||
bool isAttackable = (teamMember != null && teamMember.GetTeam() == TeamType.Player) || (teamMember == null);
|
||||
if (!isAttackable) continue;
|
||||
|
||||
// 플레이어 위치 (루트 오브젝트 사용)
|
||||
Transform playerRoot = col.transform.root;
|
||||
Vector3 playerPosition = playerRoot.position;
|
||||
|
||||
// 거리 체크
|
||||
float distance = Vector3.Distance(transform.position, playerPosition);
|
||||
if (distance > detectionRange)
|
||||
continue;
|
||||
float distance = Vector3.Distance(transform.position, col.transform.position);
|
||||
|
||||
// 시야각 확인 (360도면 모든 방향 감지)
|
||||
if (detectionAngle < 360f)
|
||||
{
|
||||
Vector3 directionToTarget = (playerPosition - transform.position).normalized;
|
||||
float angleToTarget = Vector3.Angle(transform.forward, directionToTarget);
|
||||
|
||||
if (angleToTarget > detectionAngle / 2f)
|
||||
continue;
|
||||
Vector3 directionToTarget = (col.transform.position - transform.position).normalized;
|
||||
if (Vector3.Angle(transform.forward, directionToTarget) > 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 (distance < closestDistance)
|
||||
{
|
||||
// 맞은 오브젝트가 플레이어의 루트나 자식인지 확인
|
||||
if (hit.transform.root != playerRoot)
|
||||
{
|
||||
lineOfSight = false;
|
||||
}
|
||||
closestDistance = distance;
|
||||
// 부모(Walls)가 아닌 실제 콜라이더가 있는 자식(wall)을 잡음
|
||||
closestTarget = col.gameObject;
|
||||
}
|
||||
}
|
||||
return closestTarget;
|
||||
}
|
||||
|
||||
if (lineOfSight)
|
||||
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)
|
||||
{
|
||||
// 가장 가까운 플레이어 찾기
|
||||
if (distance < closestDistance)
|
||||
{
|
||||
closestDistance = distance;
|
||||
closestPlayer = playerRoot.gameObject;
|
||||
}
|
||||
// hit.collider.gameObject를 반환함으로써 자식 오브젝트를 직접 타겟팅함
|
||||
return hit.collider.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($"<color=cyan>[EnemyAI] {gameObject.name}이(가) {closestPlayer.name}을(를) 감지! (거리: {closestDistance:F2}m, 각도: {angleInfo})</color>");
|
||||
_lastDetectionLogTime = Time.time;
|
||||
}
|
||||
|
||||
return closestPlayer;
|
||||
return null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Combat
|
||||
|
||||
private void AttackPlayer(GameObject player)
|
||||
private void SetTargetPlayer(GameObject target)
|
||||
{
|
||||
IDamageable damageable = player.GetComponentInParent<IDamageable>();
|
||||
if (damageable != null)
|
||||
{
|
||||
damageable.TakeDamage(attackDamage, NetworkObjectId);
|
||||
_lastAttackTime = Time.time;
|
||||
if (target == null) return;
|
||||
_cachedTargetPlayer = target;
|
||||
|
||||
if (showDebugInfo)
|
||||
{
|
||||
Debug.Log($"<color=red>[EnemyAI] {gameObject.name}이(가) {player.name}을(를) 공격! (데미지: {attackDamage})</color>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#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($"<color=magenta>[EnemyAI] {gameObject.name} 상태 변경: {_currentState.Value} → {newState}</color>");
|
||||
}
|
||||
|
||||
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($"<color=cyan>[EnemyAI] {gameObject.name}이(가) 코어로 이동 시작</color>");
|
||||
}
|
||||
break;
|
||||
|
||||
case EnemyAIState.ChasePlayer:
|
||||
_agent.isStopped = false;
|
||||
_agent.speed = moveSpeed * chaseSpeedMultiplier;
|
||||
_chaseStartPosition = transform.position;
|
||||
|
||||
if (showDebugInfo)
|
||||
{
|
||||
Debug.Log($"<color=cyan>[EnemyAI] {gameObject.name}이(가) 추적 시작! (시작 위치: {_chaseStartPosition})</color>");
|
||||
}
|
||||
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<NetworkObject>();
|
||||
if (networkObject != null)
|
||||
{
|
||||
_targetPlayerId.Value = networkObject.NetworkObjectId;
|
||||
_cachedTargetPlayer = player;
|
||||
}
|
||||
var networkObject = target.GetComponentInParent<NetworkObject>();
|
||||
if (networkObject != null) _targetPlayerId.Value = networkObject.NetworkObjectId;
|
||||
else _targetPlayerId.Value = 0;
|
||||
}
|
||||
|
||||
private void ClearTargetPlayer()
|
||||
@@ -574,17 +349,23 @@ namespace Northbound
|
||||
|
||||
private GameObject GetTargetPlayer()
|
||||
{
|
||||
if (_targetPlayerId.Value == 0) return null;
|
||||
// 1순위: 물리적 참조 (장애물/서버 전용)
|
||||
if (_cachedTargetPlayer != null && _cachedTargetPlayer.activeInHierarchy) return _cachedTargetPlayer;
|
||||
|
||||
if (_cachedTargetPlayer != null && _cachedTargetPlayer.activeSelf)
|
||||
// 2순위: 네트워크 ID 검색
|
||||
if (_targetPlayerId.Value != 0)
|
||||
{
|
||||
return _cachedTargetPlayer;
|
||||
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(_targetPlayerId.Value, out NetworkObject networkObject))
|
||||
{
|
||||
_cachedTargetPlayer = networkObject.gameObject;
|
||||
return _cachedTargetPlayer;
|
||||
}
|
||||
}
|
||||
|
||||
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(_targetPlayerId.Value, out NetworkObject networkObject))
|
||||
// 3순위: 코어 확인
|
||||
if (_currentState.Value == EnemyAIState.Attack && _coreTransform != null)
|
||||
{
|
||||
_cachedTargetPlayer = networkObject.gameObject;
|
||||
return _cachedTargetPlayer;
|
||||
if (GetDistanceToCoreSurface() <= attackRange * 1.5f) return _coreTransform.gameObject;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -592,7 +373,122 @@ namespace Northbound
|
||||
|
||||
#endregion
|
||||
|
||||
#region Utilities
|
||||
#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;
|
||||
|
||||
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()
|
||||
{
|
||||
@@ -600,115 +496,29 @@ namespace Northbound
|
||||
if (core != null)
|
||||
{
|
||||
_coreTransform = core.transform;
|
||||
|
||||
_coreCollider = core.GetComponent<Collider>();
|
||||
if (_coreCollider == null)
|
||||
{
|
||||
_coreCollider = core.GetComponentInChildren<Collider>();
|
||||
}
|
||||
|
||||
if (_coreCollider != null)
|
||||
{
|
||||
Debug.Log($"<color=green>[EnemyAI] {gameObject.name}이(가) 코어를 찾았습니다! (위치: {_coreTransform.position}, Collider: {_coreCollider.GetType().Name})</color>");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"<color=orange>[EnemyAI] {gameObject.name}이(가) 코어를 찾았지만 Collider가 없습니다.</color>");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"<color=orange>[EnemyAI] {gameObject.name}이(가) 코어를 찾을 수 없습니다!</color>");
|
||||
_coreCollider = core.GetComponent<Collider>() ?? core.GetComponentInChildren<Collider>();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Gizmos
|
||||
|
||||
#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);
|
||||
|
||||
// 감지 범위
|
||||
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;
|
||||
|
||||
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 + 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);
|
||||
}
|
||||
Gizmos.DrawLine(transform.position, transform.position + left);
|
||||
Gizmos.DrawLine(transform.position, transform.position + right);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
35
Assets/Scripts/EnemyPortal.cs
Normal file
35
Assets/Scripts/EnemyPortal.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using Northbound;
|
||||
using System.Collections.Generic;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
using static Northbound.ObstacleSpawner;
|
||||
using static UnityEditor.FilePathAttribute;
|
||||
|
||||
public class EnemyPortal : MonoBehaviour
|
||||
{
|
||||
[Header("Spawn Settings")]
|
||||
[Tooltip("소환할 몬스터 목록")]
|
||||
[SerializeField] private List<GameObject> Enemies = new();
|
||||
|
||||
// Start is called once before the first execution of Update after the MonoBehaviour is created
|
||||
void Start()
|
||||
{
|
||||
GlobalTimer.Instance.OnCycleComplete += SpawnEnemy;
|
||||
}
|
||||
|
||||
private void SpawnEnemy()
|
||||
{
|
||||
foreach (GameObject obj in Enemies)
|
||||
{
|
||||
GameObject enemy = Instantiate(obj, transform);
|
||||
enemy.GetComponent<NetworkObject>().Spawn();
|
||||
Debug.Log(enemy);
|
||||
}
|
||||
}
|
||||
|
||||
// Update is called once per frame
|
||||
void Update()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/EnemyPortal.cs.meta
Normal file
2
Assets/Scripts/EnemyPortal.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cb91abd3aae74d941a739a48aefb74a1
|
||||
Reference in New Issue
Block a user