적을 생성하는 과정에서 평소에 쓰지 않던 기능이 활용되면서 공부할거리가 늘었다!
그것은 바로 (두둥)
AI Navigation |
기본 기능 :
내비게이션 시스템은 씬 지오메트리에서 자동으로 생성되는 내비게이션 메시를 사용하여 게임 월드에서 지능을 갖고 움직일 수 있는 캐릭터를 생성하는 데 도움을 준다
구성 요소 :
- 내비메시(NavMesh)는 내비게이션 메시의 줄임말로, 게임 월드에서 걸을 수 있는 표면을 뜻하며, 내비메시를 사용하여 게임 월드 안에 있는 움직일 수 있는 한 위치에서 다른 위치로 이동할 수 있는 경로를 찾을 수 있다.
- 내비메시 에이전트(NavMesh Agent) 에이전트는 내비메시를 사용하여 게임 월드를 추론하며, 움직이는 장애물뿐만 아니라 서로를 피하는 방법을 알게 된다.
- 오프 메시 링크(Off-Mesh Link) 컴포넌트를 사용하여 걸을 수 있는 표면만으로는 정의할 수 없는 내비게이션 단축키를 통합할 수 있다. 예를 들어 배수로나 울타리를 뛰어넘거나 문을 지나가기 전에 여는 행동 등은 모두 오프 메시 링크로 정의할 수 있다. (이해하는 데 도움이 된 글)
- 내비메시 장애물(NavMesh Obstacle) 컴포넌트를 사용하여 에이전트가 월드를 탐색하는 동안 회피해야 하는 움직이는 장애물을 정의할 수 있다. 움직이는 장애물이라면 에이전트가 이를 피하도록 하고, 장애물이 정지한 경우 내비메시에 구멍을 카빙하여 에이전트가 장애물을 돌아가도록 경로를 변경하거나, 정지한 장애물이 경로를 완전히 차단할 경우 에이전트가 다른 경로를 찾게 할 수 있다.
이번 강의에서 적의 움직임은 NavMeshAgent의 변수, 메서드, 프로퍼티를 통해 적의 상태에 따라 다른 움직임이 구현되도록 설계되어 있으며, 이 움직임은 NavMesh를 바탕으로 이루어지고, 나무와 같은 장애물은 NavMeshObstacle로 설정되어 적이 피해 가도록 짜여 있다.
NavMesh
- Package Manager에서 AI Navigation 설치
- Window → Navigation 창 ( 메뉴: Window > AI > Navigation )
- 각 오브젝트를 NavMesh에 포함시킬 것인지 설정
- 내비게이션에 영향을 주는 씬 지오메트리를 Select - 걸을 수 있는 표면과 장애물
- 내비게이션 스태틱을 Check하여 내비메시 베이킹 프로세스 안에 선택한 오브젝트를 포함시킨다.
- 베이크 설정을 Adjust하여 에이전트의 크기에 맞춘다
* 여기서 Select 가능한 지오메트리는 Mesh Renderer를 가지고 있어야 한다.
→ 설정이 완료되면 Bake 부분에 있는 Bake 버튼을 누르면 된다. (NavMesh 에셋이 생성됨)
관련 클래스 및 구조체 (코드에서 쓰이는 것)
NavMeshHit:
- NavMeshHit는 NavMesh 쿼리의 결과로 반환된다.
- 이 구조체는 NavMesh의 특정 위치에 대한 정보를 저장한다. 가장 일반적으로 사용되는 정보 중 하나는 hit.position이며, 이는 NavMesh 위의 특정 위치를 나타낸다.
- 다른 유용한 정보로는 hit.distance (시작 위치와 히트 위치 사이의 거리), hit.normal (히트 위치에서의 NavMesh의 법선) 등이 있다.
NavMeshPath:
- NavMeshPath는 경로를 나타내는 데 사용되는 클래스이다.
- 이 클래스는 경로를 따라가는 동안 방문해야 할 모든 코너의 위치 목록을 포함한다. 이는 path.corners를 통해 액세스할 수 있다.
- 또한 경로의 상태(path.status)를 통해 경로가 완전한지, 부분적인지, 또는 경로를 찾을 수 없는지와 같은 경로의 유효성을 확인할 수 있다.
NavMeshAgent
내비메시 에이전트 - Unity 매뉴얼
NavMeshAgent 컴포넌트는 목표를 향해 움직일 때 서로를 피해가는 캐릭터 생성에 유용합니다. 에이전트는 내비메시를 이용하여 게임 월드에 대해 추론하고 서로 또는 기타 움직이는 장애물을 피할
docs.unity3d.com
스크립트에서 쓰이는 변수와 메서드
- isStop : 내비메시 에이전트의 정지 혹은 움직임 재개 상태를 설정
- speed : 경로를 따라갈 때의 최대 움직임 속도
- SetDestination : 새로운 경로 계산을 트리거하여 목적지를 설정하거나 업데이트함
- CalculatePath : 구체화된 지점까지의 경로를 계산하고 계산 결과 경로를 저장함
- remainingDistance : 현재 경로에서 에이전트의 위치와 목적지 사이의 거리 (Read Only)
NavMeshObstacles
에이전트가 이동하는 동안 피해야 하는 장애물을 정의
(장애물이 될 오브젝트에 컴포넌트를 붙여 설정)
나무 오브젝트에 내비 메시 장애물 설정을 추가한 모습이다.
바닥의 내비메시에 vertex가 추가되었다.
이렇게 내비메시를 변경하는 것을 카빙이라고 한다. 이 프로세스는 장애물의 어떤 부분이 내비메시에 닿는지를 감지한 다음, 내비메시에서 해당 부분을 깎아내어 구멍을 만든다.
강의에서는 Rigidbody를 추가하지는 않았지만, Rigidbody 컴포넌트가 종종 추가되어 장애물을 동적으로 설정하거나 충돌을 감지하는 등의 처리를 하기도 한다.
(참고)
Carving을 계산할 때 매우 많은 비용이 소모되기 때문에, 움직이는 장애물은 충돌 회피를 통해 처리한다고 한다.
Carving 사용: NavMeshObstacle의 Carving 옵션을 활성화하면 장애물이 실제로 NavMesh에서 영역을 '카빙'하거나 잘라내게 된다. 이는 움직이는 장애물의 경우에 비용이 많이 들 수 있으므로, 빈번하게 움직이는 오브젝트에는 사용하지 않는 것이 좋습니다.
Carving 미사용: NavMeshObstacle의 Carving을 사용하지 않으면, 오브젝트는 '가상의 장애물'로 작동하며, RVO 등의 충돌 회피 메커니즘을 사용하여 에이전트가 그 주변을 피하게 된다. 실제로 NavMesh를 변경하지 않지만, 움직이는 에이전트들은 그 장애물을 인식하고 피하게 된다.
즉, 동적 장애물의 경우 네비메시 장애물 컴포넌트를 사용하되, Carving을 꺼놓고 RVO 기능을 활용하여 에이전트가 충돌 없이 움직이게 할 수 있다.
스크립트
유니티에서 네비게이션 시스템에 대한 설정이 완료되면, 적의 실제 움직임을 구현하기 위해 스크립트를 작성하게 된다. 적에게 붙은 NavMeshAgent 컴포넌트에 기반하여 이 컴포넌트의 변수와 메서드를 통해 상황에 따른 움직임을 조작한다. 완성된 스크립트를 적 오브젝트에 부착하여 움직임이 일어날 수 있도록 한다.
적의 상태
public enum AIState
{
Idle,
Wandering,
Attacking,
Fleeing
}
적이 행동을 실행할 때 위의 열거형에 기반하여 다른 실행을 하게 된다.
멤버 변수
// 기본값들
[Header("Stats")]
public int health;
public float walkSpeed;
public float runSpeed;
public ItemData[] dropOnDeath;
// 적의 움직임의 기준이 되는 값들
[Header("AI")]
private AIState aiState;
public float detectDistance;
public float safeDistance;
// Wandering 상태를 판별할 때와 움직임을 설정할 때 쓰이는 값들
[Header("Wandering")]
public float minWanderDistance;
public float maxWanderDistance;
public float minWanderWaitTime;
public float maxWanderWaitTime;
// Attack 상태를 판별할 때와 움직임을 설정할 때 쓰이는 값들
[Header("Combat")]
public int damage;
public float attackRate;
private float lastAttackTime;
public float attackDistance;
//플레이어와의 거리 감지
private float playerDistance;
public float fieldOfView = 120f;
// 적 오브젝트(혹은 하위 오브젝트)에 붙은 컴포넌트
private NavMeshAgent agent;
private Animator animator;
private SkinnedMeshRenderer[] meshRenderers;
많아 보이지만(실제로 많기도 함)... 뜯어보면 어려운 내용은 없다.
특히 변수명들이 직관적이어서 흐름을 따라가기 좋았다. 변수명을 잘 설정하고 연관성 있는 변수끼리 묶어두는 것의 중요성이란.. 각각의 쓰임은 메서드를 통해 확인하기
메서드
private void Update()
{
playerDistance = Vector3.Distance(transform.position, PlayerController.instance.transform.position);
animator.SetBool("Moving", aiState != AIState.Idle);
switch (aiState)
{
case AIState.Idle: PassiveUpdate(); break;
case AIState.Wandering: PassiveUpdate(); break;
case AIState.Attacking: AttackingUpdate(); break;
case AIState.Fleeing: FleeingUpdate(); break;
}
}
기본적으로 AIState enum을 통해 적의 상태를 정의하고, 이 상태를 바탕으로 적의 움직임이 결정된다.
Update 내부에서 지속적으로 현재 상태를 확인하고, 상태에 해당하는 움직임이 일어날 수 있도록 메서드를 호출하고 있다.
그렇다면 상태(aiState)는 어떤 영향을 주는가
SetState(AIState newState)
private void SetState(AIState newState)
{
aiState = newState;
switch (aiState)
{
case AIState.Idle:
{
agent.speed = walkSpeed;
agent.isStopped = true;
}
break;
case AIState.Wandering:
{
agent.speed = walkSpeed;
agent.isStopped = false;
}
break;
case AIState.Attacking:
{
agent.speed = runSpeed;
agent.isStopped = false;
}
break;
case AIState.Fleeing:
{
agent.speed = runSpeed;
agent.isStopped = false;
}
break;
}
animator.speed = agent.speed / walkSpeed;
}
SetState는 내비메시 에이전트의 속성을 활용하여 적의 움직임 속도를 조절하고, 정지 상태를 설정한다.
애니메이션의 속도 또한 함께 조절해주고 있다.
- Update에서 보면 알 수 있듯이, idle상태를 제외하고는 Moving이 true가 되므로 동일한 애니메이션이 적용된다.
- 그런데 적의 움직임 속도는 상황에 따라 달라지므로, 이 Moving 애니메이션의 속도 또한 조절해주어야 자연스러운 움직임이 구현될 수 있다.
- walkSpeed보다 agent의 움직임 속도가 빠를 경우(runSpeed로 설정된 경우) 그 만큼 빠른 애니메이션을 적용시켜주기 위해 agent.speed / walkSpeed라는 코드가 나왔다.
SetState를 통해 속도를 조절하는 것과 그 외의 처리들(목적지 재설정, 공격 등)은 어디에서 이루어지는가
- PassiveUpdate(), AttackingUpdate(), FleeingUpdate()
(이 메서드들은 Update에서 호출된다)
PassiveUpdate()
private void PassiveUpdate()
{
if (aiState == AIState.Wandering && agent.remainingDistance < 0.1f)
{
SetState(AIState.Idle);
Invoke("WanderToNewLocation", Random.Range(minWanderWaitTime, maxWanderWaitTime));
}
if (playerDistance < detectDistance)
{
SetState(AIState.Attacking);
}
}
private void WanderToNewLocation()
{
if(aiState != AIState.Idle)
{
return;
}
SetState(AIState.Wandering);
agent.SetDestination(GetWanderLocation());
}
private Vector3 GetWanderLocation()
{
NavMeshHit hit;
NavMesh.SamplePosition(transform.position + (Random.onUnitSphere) * Random.Range(minWanderDistance, maxWanderDistance), out hit, maxWanderDistance, NavMesh.AllAreas); //경로 상 가장 가까운 곳 가져오는 메서드
int i = 0;
while(Vector3.Distance(transform.position, hit.position) < detectDistance)
{
NavMesh.SamplePosition(transform.position + (Random.onUnitSphere * Random.Range(minWanderDistance, maxWanderDistance)), out hit, maxWanderDistance, NavMesh.AllAreas);
i++;
if (i == 30)
break;
}
return hit.position;
}
- 이 메서드는 크게 두 가지 실행을 시킨다. - 특정 주기를 두고 목적지 재설정 / 플레이어가 근접했을 때 공격 상태로 변경
- 적이 목적지에 근접했을 때, 상태를 Idle로 변경하여 움직이지 않도록 (새로운 목적지 설정도 반복적으로 이루어지지 않도록) 만들고, 특정한 주기 동안 대기 후 새로운 목적지를 찾아 움직이도록 하는 메서드를 호출한다. (이 메서드에서 다시 상태를 Wandering으로 변경시켜줌으로써 전체 행위가 반복된다)
- 목적지를 뽑는 것은 GetWanderLocation을 통해 이루어진다. 이 메서드는 주어진 오브젝트 주변의 무작위 위치를 찾는 데 사용되며, 이 위치는 NavMesh 상에 있고, 현재 위치와 특정 거리(detectDistance) 이상 떨어져 있어야 한다.
- NavMeshHit : NavMesh.SamplePosition 메서드의 결과를 저장하는 데 사용되는 구조체. 이 구조체에는 NavMesh 상의 위치와 그 위치의 정확한 데이터가 포함된다.
- 처음의 무작위 위치 계산
- Random.onUnitSphere는 무작위 방향의 단위 벡터를 반환한다.
- 이 단위 벡터에 Random.Range(minWanderDistance, maxWanderDistance)를 곱하여 무작위 방향과 거리를 가지는 벡터를 얻는다.
- 이 벡터를 현재의 transform.position에 더하여 무작위 위치를 계산한다.
- NavMesh.SamplePosition을 사용하여 이 무작위 위치가 NavMesh 상의 어느 위치에 해당하는지 찾는다. (SamplePosition 또한 월드좌표를 기준으로 하지만, 여기서 반환된 값은 NavMesh에 의해 정의된 이동 가능한 영역 내에 있음을 보장한다는 점이 중요하다. 인자로 주어진 위치에 NavMesh가 존재하지 않는다면, 해당 위치에서 지정된 거리 내에서 NavMesh 상의 가장 가까운 유효한 지점을 찾아 반환한다. )
- 유효성 검사:
- while 루프를 사용하여 생성된 무작위 위치가 현재 위치와 detectDistance 이상 떨어져 있는지 확인한다.
- 만약 생성된 위치가 detectDistance보다 가까우면 새로운 무작위 위치를 계산한다.
- 이 검사를 최대 30번 반복한다. 만약 30번의 시도 후에도 유효한 위치를 찾지 못하면, 마지막으로 생성된 위치를 반환한다. (i == 30 조건으로 확인)
AttackingUpdate()
private void AttackingUpdate()
{
if (playerDistance > attackDistance || !IsPlayerInFieldOfView())
{
agent.isStopped = false;
NavMeshPath path = new NavMeshPath();
if(agent.CalculatePath(PlayerController.instance.transform.position, path))
{
agent.SetDestination(PlayerController.instance.transform.position);
}
else
{
SetState(AIState.Fleeing);
}
}
else
{
agent.isStopped = true;
if(Time.time - lastAttackTime > attackRate)
{
lastAttackTime = Time.time;
PlayerController.instance.GetComponent<IDamagable>().TakePhysicalDamage(damage);
animator.speed = 1;
animator.SetTrigger("Attack");
}
}
}
private bool IsPlayerInFieldOfView()
{
Vector3 directionToPlayer = PlayerController.instance.transform.position - transform.position;
float angle = Vector3.Angle(transform.forward, directionToPlayer);
return angle < fieldOfView * 0.5f;
}
- Attacking 상태에 들어서면, 적은 공격 혹은 플레이어를 쫓아가는 움직임을 실행한다. 플레이어가 적이 도달할 수 없는 위치에 있을 때를 제외하고는 쫓아가기 & 공격 행위를 반복한다.
- 플레이어가 적이 도달할 수 있는 위치에 있는지 확인하는 과정은 CalculatePath() 메서드를 통해 이루어진다.
- CalculatePath 메서드는 목적지까지의 경로를 계산할 수 있을 경우 true를 반환하고, 그렇지 않은 경우 false를 반환한다.
- 경로를 계산할 수 없는 경우: 장애물 혹은 벽에 의해 막힌 경우, 플레이어가 NavMesh 외부에 있는 경우 등
- CalculatePath의 반환값이 true인 경우 아까 살펴보았던 SetDestination 메서드를 통해 목적지를 재설정하여 플레이어를 쫓아가고, false가 반환될 경우 Fleeing으로 상태가 변경되어 다른 메서드가 호출된다.
- 공격의 경우, 시간과 관련된 프로퍼티나 변수를 통해 알 수 있듯이, 쿨다운이 반영되어 주기적으로 공격하게 된다.
FleeingUpdate()
private void FleeingUpdate()
{
if(agent.remainingDistance < 0.1f)
{
agent.SetDestination(GetFleeLocation());
}
else
{
SetState(AIState.Wandering);
}
}
private Vector3 GetFleeLocation()
{
NavMeshHit hit;
NavMesh.SamplePosition(transform.position + (Random.onUnitSphere) * safeDistance, out hit, maxWanderDistance, NavMesh.AllAreas); //경로 상 가장 가까운 곳 가져오는 메서드
int i = 0;
while (GetDestinationAngle(hit.position) > 90 || playerDistance < safeDistance)
{
NavMesh.SamplePosition(transform.position + (Random.onUnitSphere * safeDistance), out hit, maxWanderDistance, NavMesh.AllAreas);
i++;
if (i == 30)
break;
}
return hit.position;
}
private float GetDestinationAngle(Vector3 targetPos)
{
return Vector3.Angle(transform.position - PlayerController.instance.transform.position, transform.position + targetPos );
}
- FleeingUpdate의 경우, 조건문을 통해 공격 중 마지막으로 설정한 목적지 부근에 도착했는지 체크하고, 도착한 경우 GetFleeingLocation()을 통해 목적지를 재설정하고, 그렇지 않을 경우 그냥 aiState를 Wandering으로 바꾸어버린다.
- Fleeing모드로 변경된 시점에 목적지에 이미 도달했다면, 장애물이나 방해 요소에 이미 근접했다는 뜻이므로 safeDistance를 반영거나 플레이어로부터 떨어진 방향으로 이동하기 위해서 GetFleeingLocation을 활용하는 반면, 목적지까지 거리가 남았다면 위험거리가 아니므로 바로 Wanderingㅇ로 상태를 변경하는 것으로 이해
- GetFleeingLocation은 GetWanderLocation과 유사하지만, wanderDistance가 아닌 safeDistance가 반영되고, 플레이어와 목적지 사이의 각도를 계산하는 GetDestinationAngle을 활용하고 있다는 점에서 차이가 있다.
- GetDestinationAngle의 반환값이 90보다 클 경우 플레이어의 방향보다는 그 반대 방향에 가깝기 때문에 while문을 실행한다.
이 외에 TakePhysicalDamage(), Die(), DamageFlash() 등의 적이 입은 데미지를 처리하는 메서드들이 스크립트 내부에 포함되어 있다.
아무튼 실행 내용이 복잡하지 않고 네비게이션 시스템과 직접적인 연관이 있는 것은 아니니 스킵,,
'👾 내일배움캠프 > 🎮 TIL & WIL' 카테고리의 다른 글
내일배움캠프 37일차 TIL - ArrayList (0) | 2023.10.18 |
---|---|
내일배움캠프 36일차 TIL - 알고리즘 풀다가 C# 공부하기 (DateTime) (1) | 2023.10.06 |
내일배움캠프 34일차 TIL - 아이템 및 인벤토리 (1) | 2023.10.06 |
내일배움캠프 7주차 WIL - 개인 과제 완성 (0) | 2023.10.06 |
내일배움캠프 (휴일) TIL - 플레이어의 움직임 추가 공부 (0) | 2023.09.24 |