Navigation System
경로계산 및 실시간 장애물회피 알고리즘을 Navigation 이 제공한다.
Navigation 에 사용되는 Object는 4가지이다.
- NevMesh - Agent가 걸어다닐 표면
- NavMesh Agent - NevMesh상에서 경로를 계산하고 이동하는 컴포넌트
- NavMesh Obstacle - Agent를 가로막는 장애물
- Off Mesh Link - 끊어진 NavMesh 사이를 잇는 연결지점(ex. 뛰어넘을 수 있는 울타리/기어오를 수 있는 담벼락)
우리는 볼드체 2가지만 사용할 것이다.
NavMesh 빌드
NavMesh는 정적 게임오브젝트를 대상으로 생성된다(Bake라고 한다).
현재 프로젝트의 Level Game Object에는 static 체크가 되어있으므로, NavMesh가 사용가능하다.
일단 Bake해보자.
기존에는 이 창에 Bake 버튼이 있었는데, 이 글 에 따르면 해당 Bake 버튼은 NavMeshSurface 컴포넌트로 이동했다고 한다.
zombie를 만들자
zombie animator의 fsm은
인데, 상태는 3개이고(Idle/Move/Die)
Parameter로 Die(트리거타입), HasTarget(bool타입) 2개가 있다는것에 유의하라.
Die 트리거 발동시 무조건 Die 상태가 되고,
그외라면 HasTarget 여부에 따라 Idle/Move가 정해진다.
이제
1. 좀비의 물리적 표면(캡슐콜라이더)
2. 좀비의 공격범위(박스 콜라이더) -> isTrigger 체크
를 넣자.
그리고, Audio Source를 넣자. 아직 아무 mp3도 넣지 말자.
이제, zombie 오브젝트에 NavMesh Agent 컴포넌트를 넣고, Enemy 스크립트를 구현하면 된다.
마지막으로, 피탄시 피가 튀는 효과를 구현한 Prefab인 BloocSprayEffect 프리팹을 넣자.
Enemy.cs
스펙은 아래와 같다.
- LivingEntity가 제공하는 기본 생명체기능 보유
- 외부에서 Enemy 초기능력치를 셋업가능(강한좀비/약한좀비 구분)
- 추적을 통한 주기적 경로갱신
- 피격시 피탄효과 재생
- Trigger Collider 를 통한 상대방 공격동작
- 사망시 추적활동 중단
- 사망시 사망애니메이션/효과음 재생
using UnityEngine.AI; // AI, 내비게이션 시스템 관련 코드를 가져오기
...
public class Enemy : LivingEntity {
LayerMask란, 특정 Layer를 가진 게임오브젝트에 물리/그래픽처리를 적용할때 사용하는 자료구조이다.
public LayerMask whatIsTarget; // 추적 대상 레이어
hasTarget 변수는 setter 없이 getter만 존재한다. 임의로 값할당이 불가능하며(자동으로 할당됨) 읽는것만 가능하다는 뜻이다. HW랑 비슷하다고 보면 된다.
private bool hasTarget
{
get
{
// 추적할 대상이 존재하고, 대상이 사망하지 않았다면 true
if (targetEntity != null && !targetEntity.dead)
{
return true;
}
// 그렇지 않다면 false
return false;
}
}
Awake 함수를 구현하자.
private void Awake() {
// 초기화
pathFinder = GetComponent<NavMeshAgent>();
enemyAnimator = GetComponent<Animator>();
enemyAudioPlayer = GetComponent<AudioSource>();
enemyRenderer = GetComponentInChildren<Renderer>(); // 렌더러 컴포넌트는 자식 오브젝트에 있으므로 GetComponetInChildren()로 찾는다.
}
Renderer는 Zombie의 자식오브젝트인 Zombie_Cylinder 에 있으므로, GetComponentInChildren을 사용한다.
Zombie를 생성할 때, 체력/이동속도/공격력은 외부에서 넣은 값으로 생성할 것이다.
따라서, Setup 함수는 Zombie 자체가 아니라 외부에서 호출할 함수이므로 public으로 선언한다.
// 적 AI의 초기 스펙을 결정하는 셋업 메서드
public void Setup(float newHealth, float newDamage, float newSpeed, Color skinColor) {
startingHealth = newHealth;
health = newHealth;
damage = newDamage;
pathFinder.speed = newSpeed;
enemyRenderer.material.color = skinColor;
}
NavMesh Agent 컴포넌트는 속도를 가지고 있다. 따라서, pathFinder의 speed 변수에 할당받은 newSpeed를 그대로 넣어준다.
또한, 외형의 색(체력에 비례하여 붉게 만들것이다)은 렌더러 객체의 material.color 로 초기화가능하다.
Start하자마자, 계속 백그라운드에서 동작할 추적알고리즘 코루틴을 선언해야한다.
또한 마찬가지로, 매 frame마다 추적대상 존재여부를 체크하여 zombieAnimator 의 hasTarget 트리거에 옳은 값을 넣어줘야 한다.
private void Start() {
// 게임 오브젝트 활성화와 동시에 AI의 추적 루틴 시작
StartCoroutine(UpdatePath());
}
private void Update() {
// 추적 대상의 존재 여부에 따라 다른 애니메이션을 재생
enemyAnimator.SetBool("HasTarget", hasTarget);
}
0.25초마다 실행될 UpdatePath함수의 경우, 시야범위 내에서 검색을 실패한다면 LivingEntity를 찾아서 새로운 target으로 등록한다.
// 주기적으로 추적할 대상의 위치를 찾아 경로를 갱신
private IEnumerator UpdatePath() {
// 살아있는 동안 무한 루프
while (!dead)
{
if(hasTarget){
pathFinder.isStopped = false;
pathFinder.SetDestination(targetEntity.transform.position);
}
else{
pathFinder.isStopped = true;
Collider[] colliders = Physics.OverlapSphere(transform.position, 10f, whatIsTarget); // 중심위치와 반지름을 입력받아, 반경 내에 있는 모든 콜라이더를 배열로 반환
for(int i = 0; i < colliders.Length; i++){
LivingEntity livingEntity = colliders[i].GetComponent<LivingEntity>();
if(livingEntity != null && !livingEntity.dead){
targetEntity = livingEntity;
break;
}
}
}
// 0.25초 주기로 처리 반복
yield return new WaitForSeconds(0.25f);
}
}
OnDamage는 피격지점과 피격방향을 받아서 HitEffect인 피격 Prefab 효과를 재생한다.
// 데미지를 입었을때 실행할 처리
public override void OnDamage(float damage, Vector3 hitPoint, Vector3 hitNormal) {
if(!dead){
// 피격 파티클 효과 재생
hitEffect.transform.position = hitPoint; // 공격받은 지점
hitEffect.transform.rotation = Quaternion.LookRotation(hitNormal); // 공격받은 방향
hitEffect.Play();
enemyAudioPlayer.PlayOneShot(hitSound);
}
// LivingEntity의 OnDamage()를 실행하여 데미지 적용
base.OnDamage(damage, hitPoint, hitNormal);
}
- hitNormal 방향은, "공격이 날아온 방향을 바라보는 방향" 이다.
피격효과 재생 후, 부모인 LivingEntity의 OnDamage 호출을 통해 데미지 적용을 실행한다.
이제 Die함수를 보자.
Die의 경우에는, 자신의 게임오브젝트에 추가된 모든 Collider 컴포넌트를 비활성화해야한다. 시체와 닿는 메커니즘은 필요없으니까.
GetComponents<Collider> 를 통해, 자신의 모든 콜라이더 컴포넌트 배열을 가져온다. 그리고 싹다 false 처리 한다.
// 사망 처리
public override void Die() {
// LivingEntity의 Die()를 실행하여 기본 사망 처리 실행
base.Die();
Collider[] enemyColliders = GetComponents<Collider>(); // 자신의 모든 콜라이더 컴포넌트 배열
for(int i = 0; i < enemyColliders.Length; i++){
enemyColliders[i].enabled = false; // 싹다 끈다
}
pathFinder.isStopped = true;
pathFinder.enabled = false;
enemyAnimator.SetTrigger("Die");
enemyAudioPlayer.PlayOneShot(deathSound);
}
pathFinder.enabled 를 false 해줘야 다른 NavMesh Agent들이 서로 방해하지 않도록 경로계산한다.
만약 끄지 않으면, 다른 좀비AI가 사망한 좀비를 넘지 않고 피해다녀서 이상한 동선을 그리게 된다.
OnTriggerStay를 보자.
이 함수는, 충돌한 상대방 게임 오브젝트가 자신의 공격대상객체가 맞는지 체크 후 맞다면 공격을 실행한다.
공격주기는 0.02초이다.
private void OnTriggerStay(Collider other) {
// 트리거 충돌한 상대방 게임 오브젝트가 추적 대상이라면 공격 실행
if(!dead && Time.time >= lastAttackTime + timeBetAttack){
LivingEntity attackTarget = other.GetComponent<LivingEntity>();
if(attackTarget != null && attackTarget == targetEntity){
lastAttackTime = Time.time;
Vector3 hitPoint = other.ClosestPoint(transform.position); // 적과 상대방의 가장 가까운 점
Vector3 hitNormal = transform.position - other.transform.position; // 적과 상대방의 법선 벡터
attackTarget.OnDamage(damage, hitPoint, hitNormal);
}
}
}
에서,
Vector3 hitPoint = other.ClosestPoint(transform.position); // 적과 상대방의 가장 가까운 점
Vector3 hitNormal = transform.position - other.transform.position; // 적과 상대방의 법선 벡터
Collider 컴포넌트의 ClosestPoint() 메서드는, 콜라이더 표면 위의 점 중 특정 위치와 가장 가까운 점을 반환한다.
공격자에서 피격자로 향하는 방향은, 자신의 위치에서 공격자의 위치를 빼기만 하면 된다. (매우 당연한, 고등학교수준 기하와벡터다.)
최종완성된 Enemy.cs
using System.Collections;
using UnityEngine;
using UnityEngine.AI; // AI, 내비게이션 시스템 관련 코드를 가져오기
// 적 AI를 구현한다
public class Enemy : LivingEntity {
public LayerMask whatIsTarget; // 추적 대상 레이어
private LivingEntity targetEntity; // 추적할 대상
private NavMeshAgent pathFinder; // 경로계산 AI 에이전트
public ParticleSystem hitEffect; // 피격시 재생할 파티클 효과
public AudioClip deathSound; // 사망시 재생할 소리
public AudioClip hitSound; // 피격시 재생할 소리
private Animator enemyAnimator; // 애니메이터 컴포넌트
private AudioSource enemyAudioPlayer; // 오디오 소스 컴포넌트
private Renderer enemyRenderer; // 렌더러 컴포넌트
public float damage = 20f; // 공격력
public float timeBetAttack = 0.5f; // 공격 간격
private float lastAttackTime; // 마지막 공격 시점
// 추적할 대상이 존재하는지 알려주는 프로퍼티
private bool hasTarget
{
get
{
// 추적할 대상이 존재하고, 대상이 사망하지 않았다면 true
if (targetEntity != null && !targetEntity.dead)
{
return true;
}
// 그렇지 않다면 false
return false;
}
}
private void Awake() {
// 초기화
pathFinder = GetComponent<NavMeshAgent>();
enemyAnimator = GetComponent<Animator>();
enemyAudioPlayer = GetComponent<AudioSource>();
enemyRenderer = GetComponentInChildren<Renderer>(); // 렌더러 컴포넌트는 자식 오브젝트에 있으므로 GetComponetInChildren()로 찾는다.
}
// 적 AI의 초기 스펙을 결정하는 셋업 메서드
public void Setup(float newHealth, float newDamage, float newSpeed, Color skinColor) {
startingHealth = newHealth;
health = newHealth;
damage = newDamage;
pathFinder.speed = newSpeed;
enemyRenderer.material.color = skinColor;
}
private void Start() {
// 게임 오브젝트 활성화와 동시에 AI의 추적 루틴 시작
StartCoroutine(UpdatePath());
}
private void Update() {
// 추적 대상의 존재 여부에 따라 다른 애니메이션을 재생
enemyAnimator.SetBool("HasTarget", hasTarget);
}
// 주기적으로 추적할 대상의 위치를 찾아 경로를 갱신
private IEnumerator UpdatePath() {
// 살아있는 동안 무한 루프
while (!dead)
{
if(hasTarget){
pathFinder.isStopped = false;
pathFinder.SetDestination(targetEntity.transform.position);
}
else{
pathFinder.isStopped = true;
Collider[] colliders = Physics.OverlapSphere(transform.position, 10f, whatIsTarget); // 중심위치와 반지름을 입력받아, 반경 내에 있는 모든 콜라이더를 배열로 반환
for(int i = 0; i < colliders.Length; i++){
LivingEntity livingEntity = colliders[i].GetComponent<LivingEntity>();
if(livingEntity != null && !livingEntity.dead){
targetEntity = livingEntity;
break;
}
}
}
// 0.25초 주기로 처리 반복
yield return new WaitForSeconds(0.25f);
}
}
// 데미지를 입었을때 실행할 처리
public override void OnDamage(float damage, Vector3 hitPoint, Vector3 hitNormal) {
if(!dead){
// 피격 파티클 효과 재생
hitEffect.transform.position = hitPoint; // 공격받은 지점
hitEffect.transform.rotation = Quaternion.LookRotation(hitNormal); // 공격받은 방향
hitEffect.Play();
enemyAudioPlayer.PlayOneShot(hitSound);
}
// LivingEntity의 OnDamage()를 실행하여 데미지 적용
base.OnDamage(damage, hitPoint, hitNormal);
}
// 사망 처리
public override void Die() {
// LivingEntity의 Die()를 실행하여 기본 사망 처리 실행
base.Die();
Collider[] enemyColliders = GetComponents<Collider>(); // 자신의 모든 콜라이더 컴포넌트 배열
for(int i = 0; i < enemyColliders.Length; i++){
enemyColliders[i].enabled = false; // 싹다 끈다
}
pathFinder.isStopped = true;
pathFinder.enabled = false;
enemyAnimator.SetTrigger("Die");
enemyAudioPlayer.PlayOneShot(deathSound);
}
private void OnTriggerStay(Collider other) {
// 트리거 충돌한 상대방 게임 오브젝트가 추적 대상이라면 공격 실행
if(!dead && Time.time >= lastAttackTime + timeBetAttack){
LivingEntity attackTarget = other.GetComponent<LivingEntity>();
if(attackTarget != null && attackTarget == targetEntity){
lastAttackTime = Time.time;
Vector3 hitPoint = other.ClosestPoint(transform.position); // 적과 상대방의 가장 가까운 점
Vector3 hitNormal = transform.position - other.transform.position; // 적과 상대방의 법선 벡터
attackTarget.OnDamage(damage, hitPoint, hitNormal);
}
}
}
}
완성햇으니, Enemy 컴포넌트에 해당 스크립트를 넣어서 좀비를 완성하자.
Enemy 컴포넌트 설정
Enemy 컴포넌트는 추적대상을 레이어를 이용해 감지하므로, PlayerCharacter 게임오브젝트에 레이어를 할당해야한다.
자식까지 적용하겠냐는 팝업에는, No를 누른다.
그 후, 위와 같이 Enemy Component를 설정하고
좀비가 쫓아오는걸 볼 수 있다!!!!
이제, 좀비를 자동생성하기 위해 Prefab으로 만들자.
What I learned
- 다형성을 통한 객체관리
- virtual로 선언된 가상메서드의 override
- delegate -> 메서드를 할당받아 원하는 시점에 할당된 메서드를 매번 실행
- Action -> input과 output이 없는 메서드를 등록할 수 있는 delegate 타입
- NavMesh Agent 컴포넌트의 SetDestination() 메서드를 사용해서 Agent가 이동할 위치를 지정할 수 있다.
- NavMesh Agent 컴포넌트의 isStopped 필드를 사용해서 Agent 이동여부를 결정할 수 있다.
'Development > Unity Engine' 카테고리의 다른 글
[Retro유니티] 적 생성기 (0) | 2024.12.29 |
---|---|
[Retro유니티] HUD Canvas 및 GameManager.cs (0) | 2024.12.28 |
[Retro유니티] PlayerHealth.cs 완성 (0) | 2024.12.25 |
[Unity] 기본 Editor 변경: Visual Studio -> VSCode (0) | 2024.12.24 |
[Retro유니티] UI 슬라이더 사용을 통한 체력바 구현 & PlayerHealth.cs (0) | 2024.12.24 |