오늘 공부한 내용은 AI의 길찾기에 관련된 내용이었습니다. 지금 프로젝트에서 필요한 길찾기의 기능은 아래와 같았습니다.
기본 골자 : 뱀파이어 서바이벌
- 이동 : 오직 플레이어를 향해서만 이동합니다
- 같은 동료 회피 : 적 오브젝트라면 같은 적 오브젝트와는 겹쳐지지 않게 이동해야 합니다
- 둘러싸기 : 플레이어 앞에서도 겹쳐지면 안되고 둘러싸는 형태로 이동해야 합니다
조사한 결과 이에 대한 해결책은 크게 3가지 였습니다.
유체 시뮬레이션 기반 이동입니다.
-> 구현은 NavMeshAgent 대신 벡터 기반의 이동 로직을 구현해야 합니다.
새 떼나 물고기 때처럼 움직이게 하는 알고리즘으로 적이 자연스럽게 모이면서도 겹치지 않게 합니다
- 소수의 적이라면 네브 메쉬를 이용하는 것이 좋다
- 대량의 적이면 Steering Behaviors + Boid 알고리즘이 좋다
원래는 뱀파이어 서바이벌의 3d 환경인 특성상 1과 2를 합친 알고리즘을 사용하려 했습니다.
using UnityEngine;
using System.Collections.Generic;
public class BoidEnemy : MonoBehaviour
{
public Transform target; // 플레이어 타겟
public float moveSpeed = 3f; // 이동 속도
public float neighborRadius = 2f; // 주변 적 탐색 범위
public float separationDistance = 1f; // 최소 거리
public float separationForce = 2f; // 분리 강도
private Vector3 velocity; // 현재 속도
void Update()
{
if (target == null) return;
Vector3 targetDirection = (target.position - transform.position).normalized;
Vector3 separationVector = CalculateSeparation();
// 타겟을 향한 이동 + Separation 적용
velocity = (targetDirection + separationVector).normalized * moveSpeed;
transform.position += velocity * Time.deltaTime;
// 회전 방향 조정 (플레이어를 바라보도록)
if (velocity.sqrMagnitude > 0.01f)
{
transform.forward = velocity;
}
}
private Vector3 CalculateSeparation()
{
Vector3 separationVector = Vector3.zero;
int count = 0;
Collider[] colliders = Physics.OverlapSphere(transform.position, neighborRadius);
foreach (Collider col in colliders)
{
if (col.gameObject == gameObject) continue;
BoidEnemy neighbor = col.GetComponent<BoidEnemy>();
if (neighbor != null)
{
float distance = Vector3.Distance(transform.position, neighbor.transform.position);
if (distance < separationDistance)
{
separationVector += (transform.position - neighbor.transform.position).normalized / distance;
count++;
}
}
}
if (count > 0)
{
separationVector /= count;
separationVector *= separationForce;
}
return separationVector;
}
}
외부 자료등을 통해 알아본 스크립트는 위와 같았는데, 이를 기반으로 시도해본 결과 서로 적 오브젝트가 비벼지거나, 플레이어 근처에서 멈추지 않고 계속해서 이동하는 문제가 있었습니다
이를 해결하기 위해 내 앞의 적이 멈췄다면 그 뒤의 적도 서로 전달을 받는 형식으로 멈추도록 변경해 보았습니다
하지만 이렇게 변경하니 플레이어 근처의 최적해 자리로 이동하지 못하는 문제가 있었습니다.
때문에 먼저 간단하게 해결 할 수 있는 네브 메쉬를 사용해 보는 방향으로 방법을 틀었습니다
using UnityEngine;
using UnityEngine.AI;
using System.Collections.Generic;
public class BoidEnemyNavMesh : MonoBehaviour
{
public enum EnemyState { Move, Idle }
public EnemyState state = EnemyState.Move;
public Transform target;
public float stopDistance = 2f;
public float baseRadius = 2f;
public float radiusSpacing = 1.5f;
public float positionThreshold = 0.5f;
public float movementThreshold = 1.5f;
public float checkInterval = 0.5f;
public float recalculateInterval = 2f; // 포메이션 재계산 주기
private NavMeshAgent agent;
private static List<BoidEnemyNavMesh> allEnemies = new List<BoidEnemyNavMesh>();
private Vector3 targetFormationPos;
private bool formationPositionSet = false;
private static Vector3 lastPlayerPosition;
private float nextCheckTime = 0f;
private float nextRecalculateTime = 0f;
void OnEnable()
{
allEnemies.Add(this);
}
void OnDisable()
{
allEnemies.Remove(this);
NotifyFormationChange(); // 적이 사라지면 포메이션 재정렬
}
void Start()
{
agent = GetComponent<NavMeshAgent>();
agent.stoppingDistance = 0;
agent.updateRotation = false;
agent.autoBraking = true;
agent.speed = 0.5f; // 기본 이동 속도 (원하는 속도로 조정 가능)
agent.acceleration = 15f; // 가속도 (값이 너무 낮으면 탄성처럼 밀리는 느낌이 생김)
// agent.angularSpeed = 120f; // 회전 속도 (너무 크면 급격히 방향 전환, 너무 작으면 둔함)
agent.avoidancePriority = Random.Range(30, 60);
agent.obstacleAvoidanceType = ObstacleAvoidanceType.HighQualityObstacleAvoidance;
agent.radius = 0.3f;
}
void Update()
{
if (target == null) return;
float currentTime = Time.time;
if (currentTime >= nextCheckTime)
{
nextCheckTime = currentTime + checkInterval;
CheckPlayerMovement();
}
if (currentTime >= nextRecalculateTime)
{
nextRecalculateTime = currentTime + recalculateInterval;
NotifyFormationChange();
}
switch (state)
{
case EnemyState.Move:
HandleMoveState();
break;
case EnemyState.Idle:
HandleIdleState();
break;
}
}
private void HandleMoveState()
{
if (!formationPositionSet)
{
targetFormationPos = GetFormationPosition();
agent.SetDestination(targetFormationPos);
formationPositionSet = true;
}
if (!agent.pathPending && agent.remainingDistance <= positionThreshold && agent.velocity.magnitude < 0.1f)
{
ChangeState(EnemyState.Idle);
}
}
private void HandleIdleState()
{
agent.isStopped = true;
agent.velocity = Vector3.zero;
}
private void ChangeState(EnemyState newState)
{
state = newState;
switch (newState)
{
case EnemyState.Move:
agent.isStopped = false;
formationPositionSet = false;
break;
case EnemyState.Idle:
agent.isStopped = true;
agent.velocity = Vector3.zero;
break;
}
}
private void CheckPlayerMovement()
{
float distanceMoved = Vector3.Distance(lastPlayerPosition, target.position);
if (distanceMoved >= movementThreshold)
{
lastPlayerPosition = target.position;
NotifyFormationChange();
}
}
private void NotifyFormationChange()
{
foreach (var enemy in allEnemies)
{
enemy.ChangeState(EnemyState.Move);
}
}
private Vector3 GetFormationPosition()
{
int index = allEnemies.IndexOf(this);
if (index == -1) return transform.position;
int ringIndex = 0;
int ringCapacity = 6;
int ringStart = 0;
while (index >= ringStart + ringCapacity)
{
ringStart += ringCapacity;
ringCapacity += 6;
ringIndex++;
}
float radius = baseRadius + ringIndex * radiusSpacing;
float angleStep = 360f / ringCapacity;
float angle = (index - ringStart) * angleStep;
Vector3 formationPos = target.position + new Vector3(
Mathf.Cos(angle * Mathf.Deg2Rad),
0,
Mathf.Sin(angle * Mathf.Deg2Rad)
) * radius;
NavMeshHit hit;
if (NavMesh.SamplePosition(formationPos, out hit, 1.0f, NavMesh.AllAreas))
{
return hit.position;
}
return formationPos;
}
}
원래는 네브 메쉬를 사용한 상태에서도 플레이어를 감싸지 못하거나, 서로 비벼지면서 떨리는 문제가 있었는데 이를 Ring 형태의 포메이션을 구성하도록 하는것으로 문제를 해결해 보았습니다.
또한, 플레이어가 일정 범위를 이동하게 되면 재 이동.
적 오브젝트가 죽어 링 공간이 비게 되면 재 배치.
기능을 추가해서 보완했습니다.
우선은 네브 메쉬를 기반으로 구현해 보았습니다. 아무래도 rb를 이용해서 위의 기능을 구현하는 것은 쉽지 않을 것으로 보입니다만 계속해서 도전해볼 생각입니다.
특히 ring 형태의 구성을 가져가도록 해보면 변화점이 있을 것이라 생각합니다.