플레이어/적/몬스터 팀 시스템 생성

몬스터 및 적 AI 구현
This commit is contained in:
2026-01-27 15:30:02 +09:00
parent 9a47af4317
commit 194845a9e1
33 changed files with 2519 additions and 445 deletions

View File

@@ -0,0 +1,714 @@
using Unity.Netcode;
using UnityEngine;
using UnityEngine.AI;
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;
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 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;
// 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();
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 = DetectPlayer();
if (player != null)
{
SetTargetPlayer(player);
TransitionToState(EnemyAIState.ChasePlayer);
}
}
private void UpdateMoveToCore()
{
// 플레이어 감지
GameObject player = DetectPlayer();
if (player != null)
{
SetTargetPlayer(player);
TransitionToState(EnemyAIState.ChasePlayer);
return;
}
// 코어가 없으면 찾기
if (_coreTransform == null)
{
FindCore();
_hasSetCoreDestination = false;
return;
}
// 코어 표면까지의 실제 거리 계산
float distanceToCore = GetDistanceToCoreSurface();
// 공격 범위 안에 있으면 공격 상태로 전환
if (distanceToCore <= attackRange)
{
TransitionToState(EnemyAIState.Attack);
return;
}
// 경로가 설정되지 않았거나 무효화된 경우에만 설정
if (!_hasSetCoreDestination || !_agent.hasPath || _agent.pathStatus == NavMeshPathStatus.PathInvalid)
{
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;
}
}
}
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)
{
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);
}
}
private void UpdateAttack()
{
// 코어 공격 중인지 확인
bool attackingCore = _coreTransform != null &&
GetDistanceToCoreSurface() <= attackRange * 1.2f;
if (attackingCore)
{
float distanceToCore = GetDistanceToCoreSurface();
if (distanceToCore > attackRange * 1.2f)
{
TransitionToState(EnemyAIState.MoveToCore);
return;
}
// 코어를 바라보기
Vector3 directionToCore = (_coreTransform.position - transform.position).normalized;
directionToCore.y = 0;
if (directionToCore != Vector3.zero)
{
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>");
}
}
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);
}
}
}
private void UpdateReturnToOrigin()
{
GameObject player = DetectPlayer();
if (player != null)
{
SetTargetPlayer(player);
TransitionToState(EnemyAIState.ChasePlayer);
return;
}
if (!_agent.pathPending && _agent.remainingDistance <= _agent.stoppingDistance)
{
if (!_agent.hasPath || _agent.velocity.sqrMagnitude == 0f)
{
if (showDebugInfo)
{
Debug.Log($"<color=green>[EnemyAI] {gameObject.name}이(가) 원래 위치로 복귀했습니다.</color>");
}
TransitionToState(EnemyAIState.Idle);
}
}
}
#endregion
#region Detection
private GameObject DetectPlayer()
{
Collider[] colliders = Physics.OverlapSphere(transform.position, detectionRange, playerLayer);
GameObject closestPlayer = null;
float closestDistance = float.MaxValue;
foreach (Collider col in colliders)
{
// 자기 자신 제외
if (col.transform.root == transform.root)
continue;
// 플레이어 팀 확인 (부모에서 찾기)
ITeamMember teamMember = col.GetComponentInParent<ITeamMember>();
if (teamMember == null || teamMember.GetTeam() != TeamType.Player)
continue;
// 플레이어 위치 (루트 오브젝트 사용)
Transform playerRoot = col.transform.root;
Vector3 playerPosition = playerRoot.position;
// 거리 체크
float distance = Vector3.Distance(transform.position, playerPosition);
if (distance > detectionRange)
continue;
// 시야각 확인 (360도면 모든 방향 감지)
if (detectionAngle < 360f)
{
Vector3 directionToTarget = (playerPosition - transform.position).normalized;
float angleToTarget = Vector3.Angle(transform.forward, directionToTarget);
if (angleToTarget > 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 (hit.transform.root != playerRoot)
{
lineOfSight = false;
}
}
if (lineOfSight)
{
// 가장 가까운 플레이어 찾기
if (distance < closestDistance)
{
closestDistance = distance;
closestPlayer = playerRoot.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;
}
#endregion
#region Combat
private void AttackPlayer(GameObject player)
{
IDamageable damageable = player.GetComponentInParent<IDamageable>();
if (damageable != null)
{
damageable.TakeDamage(attackDamage, NetworkObjectId);
_lastAttackTime = Time.time;
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;
}
}
private void ClearTargetPlayer()
{
_targetPlayerId.Value = 0;
_cachedTargetPlayer = null;
}
private GameObject GetTargetPlayer()
{
if (_targetPlayerId.Value == 0) return null;
if (_cachedTargetPlayer != null && _cachedTargetPlayer.activeSelf)
{
return _cachedTargetPlayer;
}
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(_targetPlayerId.Value, out NetworkObject networkObject))
{
_cachedTargetPlayer = networkObject.gameObject;
return _cachedTargetPlayer;
}
return null;
}
#endregion
#region Utilities
private void FindCore()
{
Core core = FindFirstObjectByType<Core>();
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>");
}
}
#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);
// 시야각 (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;
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);
}
}
}
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
}
}