길찾기 알고리즘인 A*알고리즘을 활용한, 유니티에서 제공하는 길찾기 패키지이다. 어렵게 알고리즘 구조를 짤 필요없이 간편하게 에디터 상에서 적들을 플레이어에게 따라오게 만들 수 있다

  • 자동 전투자동 길찾기등에 사용할 수 있는 기능이다. 생성된 맵에서 여러 변화요인들을 감지, 목적지 까지 이동시킨다
  • 유니티에는 길찾기 알고리즘 중에서 A* 알고리즘이 적용되어 있다. 먼저 이 알고리즘을 이론적으로 파악한 후, 해당 알고리즘을 통해 만들어진 길찾기 기능인 NavMesh를 다뤄보자
  • 편리한 기능이지만 많은 연산량으로 인해 정확한 사용이 요구되는 기능이기 때문에 주의하면서 사용해야 한다

A*알고리즘

나무위키 - A*알고리즘

  • 다익스트라 알고리즘을 확장하여 만들어진, 자료구조 중 그래프의 탐색을 사용해서 최단 경로의 길을 찾는 알고리즘이다
  • 노드(타일)에서 목적지와의 거리, 현재까지 진행된 거리, 이전 노드와의 거리를 연산하고, 다음 노드로 넘어가 다시 연산하는 로직으로 구성되어 있다
  • 현재 상태의 비용을 g(x)g(x), 현재 상태에서 다음 상태로 이동할 때의 휴리스틱 함수h(x)h(x)라고 할 때, 둘을 더한 f(x)=g(x)+h(x)f(x)=g(x)+h(x)가 최소가 되는 지점을 우선적으로 탐색하는 방법
  • f(x)f(x)가 작은 값부터 탐색하는 특성상 우선순위 큐가 사용된다. 휴리스틱 함수 h(x)h(x)에 따라 성능이 극명하게 갈리며, f(x)=g(x)f(x)=g(x)일 때는 다익스트라 알고리즘과 동일하다
    • 스타크래프트의 길찾기 또한 해당 알고리즘이 사용되었다

왜 사용함?

  • A*를 사용하는 이유는 다익스트라를 직접 현실 문제에 적용하기가 매우 부담되기 때문이다. 현실 세계의 사람이 다닐 수 있는 "거리"를 생각 해보자. 길 하나만 전부 노드화 시키기에도 그 수가 엄청나게 많아질 수 있다. 목적지 까지를 생각하면 탐색해야 하는 공간도 그만큼 커지게 되고(공간 복잡도 증가), 시간 복잡도 또한 마찬가지로 기하급수적으로 커질 것이다. 노드화 시킨다 가정하고 다익스트라를 사용하여 경로를 발견 했다고 해보자. 그렇게 탐색한 경로가 자동차 정체 구간, 출근길 등 다양한 변수로 인해 오히려 더 느려질 수 있는 경우도 발생할 수 있다. 이러한 변수 때문에 A* 알고리즘을 사용하는 것

사용 방법

  1. f(x)f(x)오름차순 우선순위 큐노드로 삽입한다(Enqueue)
  2. 우선순위 큐에서 최우선 노드를 Pop한다(Dequeue)
  3. 해당 노드에서 이동할 수 있는 노드를 찾는다(그래프 탐색 - BFS, DFS)
  4. 그 노드들의 f(x)f(x)를 구한다
  5. 그 노드들을 우선순위 큐에 삽입한다
  6. 목표 노드에 도달할 때까지 반복한다
PQ.push(start_node, g(start_node) + h(start_node))       //우선순위 큐에 시작 노드를 삽입한다.

while PQ is not empty       //우선순위 큐가 비어있지 않은 동안
    node = PQ.pop       //우선순위 큐를 pop한다.

    if node == goal_node       //만일 해당 노드가 목표 노드이면 반복문을 빠져나온다.
        break

    for next_node in (next_node_begin...next_node_end)       //해당 노드에서 이동할 수 있는 다음 노드들을 보는 동안
        PQ.push(next_node, g(node) + cost + h(next_node))       //우선순위 큐에 다음 노드를 삽입한다.

print goal_node_dist       //시작 노드에서 목표 노드까지의 거리를 출력한다.

hueristic : 멘헤탄 디스탄스

  • Manhattan Distance. 보통 휴리스틱 함수로 많이 쓰인다
  • 맨해튼 거리(Manhattan Distance)는 격자 기반에서 주로 사용되며, 이동이 상하좌우로만 제한될 때 적절한 휴리스틱
  • 수식: h(x)=x2x1+y2y1h(x)=∣x_2−x_1∣+∣y_2−y_1∣
  • 예를 들어, (2, 3)에서 (5, 7)까지의 맨해튼 거리는: 52+73=3+4=7|5−2∣+∣7−3∣=3+4=7

유니티에서 쓰인 heuristic : 유클리디안 디스탄스

  • 유클리디안 거리 (Euclidean Distance) 가 일반적으로 사용된다. 이는 두 점 사이의 직선 거리로, 현실 세계의 거리 추정에 가장 가깝다
  • 수식: h(x)=(x2x1)2+(y2y1)2+(z2z1)2h(x)=\sqrt{(x_2−x_1)^2+(y_2−y_1)^2+(z_2−z_1)^2}
  • NavMesh는 3D 공간에서도 작동하므로, 3D 좌표 상의 거리 계산이 필요
  • UnityNavMesh다양한 방향(심지어 곡선 형태도 가능)으로 이동이 가능한 비격자 기반 경로 탐색을 수행하므로, 맨해튼 디스턴스는 실제 거리와 괴리가 크다. 그래서 유클리디안 거리를 사용

TPS 게임 만들기 - 3

  • 이어서, 게임에 배운 내용들을 추가해보자
  • 오늘은 네비게이션 시스템으로 적들이 플레이어를 쫒아오게 만들 것이다

맵 추가

유니티 - Sci-Fi Styled Modular Pack

  • 캐릭터와 어울릴 만한 맵을 찾아서 추가한다. 캐릭터가 SF풍이니 SF풍 맵을 찾아옴

UI

  • 내비메시 전에 먼저, UI 요소들을 추가해보자

  • UGUI 특성상, 자주 바뀔 만한 요소들은 캔버스를 따로 둬야 한다
  • 하나의 요소만 바꿔도 싹다 바꿔야 하기 때문이다
    그래서, 자주 바뀌지 않는 고정적인 UI들과 변동적인 UI들을 서로 다른 Canvas에 배치하는 것을 권장한다

에임 UI 추가

  • 에임 상태일 때, 에임 이미지가 나오게한다. 그리고 애니메이션으로 조준점이 나타나게 해보자

이미지추가

  • 먼저 사용할 이미지를 다운로드 한다. 그리고 프로젝트 창에서 드래그&드롭으로 추가한다
  • 에임 이미지는 인터넷 아무곳에서나 쉽게 다운로드 받을 수 있으니, 원하는 이미지로 한다
  • 이미지 설정은 위와같이 하면 에셋으로 쓸 수 있다. 스프라이트로 하자
  • 하이라키 창에서 UI -> Image를 추가한다. 여기서 Source Image에 해당 에임 이미지를 넣으면 된다
  • 화면에 나오는 이미지 크기는 하이라키 창에서 Canvas를 선택, 씬 창에서 2D 화면으로 변경 후(단축키: 2) Rect Tool로 변경해준다

애니메이터 설정

  • 에임 이미지를 위한 애니메이터를 설정하자. Image UIAnimator 컴포넌트를 추가하고, 위 사진과 같이 상태 설정을 해준다. 각 상태에 맞는 애니메이션을 생성 후 추가해준다
  • 프로젝트 창에서 애니메이션을 추가하는 방법이다
  • 애니메이터의 상태 간 Transition을 생성 해주고, 위와 같이 설정해준다. ActiveAimConditions를 반대로 해준다
  • 에임 이미지가 회전하며 채워지고, 회전하며 없어지는 식으로 구현하려고 한다. Image Type 및 설정을 위 사진과 같이 변경해준다
  • ActiveAim 애니메이션과 그 반대의 애니메이션의 프로퍼티를 위 사진과 같이 설정해준다

    주의

    • 이걸 바꿔줘야 다른 애니메이션 편집이 가능함

PlayerController 수정

  • 에임 애니메이션을 에임 상태와 연동해보자
// 이미지 컴포넌트를 불러오려고 한다
private Image aimImage;
// 애니메이터를 드래그&드롭으로 참조시킴
[SerializeField] private Animator aimAnimator;

// 이미지기 애니메이터와 같은 게임 오브젝트에 묶여 있으니, 다음과 같이 찾을 수 있음
private void Init()
{
	aimImage = aimAnimator.GetComponent<Image>();
}

private void SetAimAnimation(bool value)
{
	// 에임 이미지를 비활성화 상태로 시작할 거다. 비활성화 상태이면 활성화
    // 이걸 해줘야 게임 맨처음 시작 시 에임 이미지가 사라지는 애니메이션이 재생 안됨
    if (!aimImage.enabled) aimImage.enabled = true;
    // 에임 애니메이터의 파라매터를 true 또는 false로 바꿈
    aimAnimator.SetBool("IsAim", value);
}

// 애니메이션 구독
// IsAiming 값에 따라 애니메이션들이 재생된다
public void SubscribeEvents()
{
	status.IsAiming.Subscribe(SetAimAnimation);
}

public void UnsubscribeEvents()
{
	status.IsAiming.UnSubscribe(SetAimAnimation);
}

프리팹 설정

  • 게임 캐릭터 하위로 배치. override해서 프리팹 저장을 잊지말자

실행

  • 원하던 바와 같이 조준때와 비 조준때 애니메이션이 재생된다

HP 게이지 구현

  • HP 게이지가 화면 상에서, 캐릭터 위에 떠다니게 구현 해보자

  • HPUITransform만 있는 빈 게임 오브젝트다. Background는 체력바 뒤에 있는 것, Gauge가 체력 값에 따라 늘고 줄어드는 것이다. 둘 다 이미지UI

  • 캔버스 랜더모드 월드 스페이스 설정

HP 게이지 스크립트 작성

  • HP 게이지가 카메라를 기준으로 회전할 수 있게 스크립트 작성

UI를 카메라에 맞게 회전 시키기

실험 1 LookAt

  • LookAt() 으로 체력바 이미지를 회전시킨다
private void LateUpdate()
{
	transform.LookAt(Camera.main.transform);
}

  • 위와 같이 화면 가장자리에서 보면 조금 이상한 느낌이 든다. 수평이 맞지 않기 때문. 카메라의 좌표로 회전을 하기 때문에 이와 같이 된다

실험 2

  • 현재 카메라의 방향으로 회전. 즉, 카메라의 방향 벡터를 적용시키기
private void LateUpdate() => SetUIRotate2();

private void SetUIRotate2()
{
	transform.forward = Camera.main.transform.forward;
}

  • 훨씬 자연스럽다. 카메라 가장자리에서도 수평으로 제대로 나온다

실험 3

  • 실험 2의 카메라의 방향 벡터를 반대로 적용시키기
private void LateUpdate() => SetUIRotate3();

private void SetUIRotate3()
{
	transform.forward = -Camera.main.transform.forward;
}

  • 다른 것이 없어보인다. 텍스트를 적용해서 보면 이야기가 달라진다
  • 방법 2
  • 방법 3. 뒤집힌다...
  • 방법 2 로 적용하도록 한다

HP 게이지 늘고 줄어드는거 구현

  • HP게이지의 색깔은 빨간색이다. 백그라운드는 하얀색
  • 위와 같이 설정 해두고, 스크립트에서 구현한다. Fill Amount 값에 따라서 이미지가 늘어나고 줄어든다

HP 게이지 증감 스크립트

  • 대상의 체력 값에 따라 달라지게 한다. 입력받는 float 값은 0 ~ 1사이이다
//UI 게이지의 FillAmount를 설정.표시 대상의 HP로 설정
public void SetImageFillAmount(float amount)
{
    // 현재수치 / 최대수치만큼
    HPGauge.fillAmount = amount;
}

HP 프리팹 설정

  • HP를 표현하고자 하는 오브젝트의 하위로 설정

위에서 바라볼 때

  • 카메라가 이렇게 위에서 쳐다 볼 때 HP바가 캐릭터를 가린다
  • HP바 또한 카메라를 기준으로 회전해서 대상을 안 가리게 해보자
  • 편법으로 좀 쉽게 구현이 가능하다. 그냥 HPUICanvas의 포지션들 조정만으로도 구현이 가능하다

애니메이션으로 효과주기

  • 딜레이를 가지고 점점 감소하는 효과를 보여주는 애니메이션으로 구현할 수도 있다. Gauge는 즉시 감소, DelayGauge는 애니메이션 효과로 천천히 감소 시키는 식으로 연출을 할 수 있다
  • 방법이 여러가지가 있겠지만, 애니메이션으로 블렌드를 활용해 처리하거나, 스크립트 상으로 최종 FillAmount와 현재 FillAmount를 따로 두고, 업데이트마다 deltaTime을 이용해 깎아나가는 방식도 가능. 코루틴도 가능하다

HP 게이지 전체 스크립트

public class JYL_HpGaugeUI : MonoBehaviour
{
    [SerializeField] private Image HPGauge;

    private Transform cameraTransform;

    private void Awake()
    {
        Init();
    }

    private void LateUpdate()
    {
        SetUIForwardVector(cameraTransform.forward);
    }

    private void Init()
    {
        cameraTransform = Camera.main.transform;
        // image = GetComponentInChildren<Image>();
    }

    //UI 게이지의 FillAmount를 설정.표시 대상의 HP로 설정
    public void SetImageFillAmount(float amount)
    {
        // 현재수치 / 최대수치만큼
        HPGauge.fillAmount = amount;
    }

    private void SetUIForwardVector(Vector3 target)
    {
        transform.forward = target;
    }
}

PlayerStatus 스크립트 수정

  • 현재 체력과 최대 체력을 필드로 추가해준다
[field:SerializeField]
[field:Range(0,100)]
public int MaxHP { get; set; }

// 현재 체력이 변할 때 마다 이벤트 발생
public JYL_ObservableProperty<int> CurrentHP {  get; private set; } = new();

PlayerController 수정

private void Init()
{
	hpUI.SetImageFillAmount(1);
}
private void SetHpUIGauge(int currentHP)
{
	// 둘 다 int이므로 하나는 형변환을 해줘야 결과값이 `float`가 된다
    float hp = (float)currentHP / status.MaxHP;
    hpUI.SetImageFillAmount(hp);
}

public void SubscribeEvents()
{
	status.CurrentHP.Subscribe(SetHpUIGauge);
}

public void UnsubscribeEvents()
{
	status.CurrentHP.UnSubscribe(SetHpUIGauge);
}
public void TakeDamage(int damage)
{
    // 체력을 떨어뜨리지만, 0 이하면 플레이어가 죽어야함
    status.CurrentHP.Value -= damage;
    if (status.CurrentHP.Value <= 0) PlayerDie();
}

public void RecoverHP(int recoverAmount)
{
    // 체력을 회복시킨다. MaxHP 초과는 하지 못하게 한다
    int hp = status.CurrentHP.Value + recoverAmount;
    // Clamp로 최대값, 최소값을 설정할 수 있다
    status.CurrentHP.Value = Mathf.Clamp(hp, 0, status.MaxHP);
}

public void PlayerDie()
{
    // 직접 구현해보자
    Debug.Log("플레이어 죽음");
}

실행

  • 체력바가 늘었다 줄었다가 가능해졌다

내비매시

  • NavMesh. 길찾기 알고리즘인 A*을 활용한 유니티의 기능이다

  • 패키지 매니저에서 좌측 상단에서 PackagesUnity Registry로 변경한다. 이후 Nav만 검색해도 AI Navigation이 나온다. 설치하자

  • 만들어놓은 맵 구성요소들을 Map으로 프리팹화 해두고, 컴포넌트로 위의 사진과 같이 NavMeshSurface를 추가한다

  • Bake 버튼을 누르면 위의 사진과 같이 파란 영역들이 생겨난다. 이것이 정해진 물체들이 길찾기로 이동할 수 있는 영역들을 나타낸 것이다

  • Collect Objects에서 All Game Objects는 모든 게임 오브젝트를, Current Object Hierarchy는 이 컴포넌트가 있는 게임 오브젝트 및 그 하위 오브젝트들을 대상으로 Bake 된다
  • Include Layers : 지정된 레이어만 Bake 한다

  • 위와 같은 방식으로 들어가면 Area 설정이 가능하다. Cost비용. 높을수록 경로로써 안 지나가려고 하는 저항 수치
  • 분홍색 영역의 Cost가 충분히 높을 경우, 빨간색 점선이 아니라, 빨간색 실선을 경로로 움직인다

  • 해당 컴포넌트를 추가한 게임 오브젝트의 NavMesh 속성을 정해줄 수 있다
  • 이렇게 Bake 시, 다른 영역은 지정색에 따라 결과가 달라진다

  • NavMeshComponent에서 위와같이 세팅으로 들어갈 수 있다
  • Agent Types에서 에이전트의 타입을 추가, 삭제 가능
  • Radius : 에이전트의 단면적. Bake 시 통행 가능 영역에 영향을 준다
    • 작은 캐릭터들은 좁은 틈새도 들어오는데 보스는 문만 지나가도 못오는 것을 생각하면 이해가 됨
  • Height : 에이전트의 높이
  • Step Height : 그냥 넘어갈 수 있는 높이
  • Max Slope : 경사면을 몇 도까지 올라갈 수 있는 지 정할 수 있다
    • Bake 영역에 영항을 줌
  • Generated Links : NavMesh Link와 관련있다
    • 영역을 넘어다닐 수 있다. 위로 점프, 옆으로 점프

내비매시 에이전트 컴포넌트

  • 몬스터 프리팹에 추가하면 된다
  • 적을 추적할 때의 상태를 설정해 줄 수 있다

타겟(목적지)으로 길찾기

  • 스크립트로 기능을 구현한다
  • 몬스터 프리팹에 스크립트를 추가한다
NavMeshAgent navAgent;
// 요기에 타겟(플레이어)을 참조시킨다
[SerializeField] Transform targetTransform;

void Awake()
{
	navAgent = GetComponent<NavMeshAgent>();
}

public void Update()
{
	// 업데이트마다 타겟의 위치를 목적지로 설정하고, 추적함
	navAgent.SetDestination(targetTransfrom.position);
}

몬스터구현

  • NavMesh 기능으로 플레이어를 쫒아가는 몬스터를 구현해본다

인터페이스 IDamagable

  • 플레이어와 몬스터가 서로 쉽게 상호작용을 할 수 있게 인터페이스로 구현한다
public interface IDamagable
{
    public void TakeDamage(int damage);
}

PlayerController 인터페이스 상속

  • 플레이어 컨트롤러에 IDamagable을 상속시킨다
  • 이미 TakeDamage()함수가 구현되어 있어서 매개변수만 맞춰준다

Monster 추상 클래스

  • 플레이어 공격 시, 상대방을 쉽게 가져오기 위해서 몬스터들의 부모 클래스 역할을 한다. 추상 클래스로 구현한다
public abstract class Monster : MonoBehaviour
{
    
}

NormalMonster

  • Monster 추상클래스와 인터페이스 IDamagable을 상속받는다
  • 플레이어는 MVC 모델로 구현했지만, 시간 편의상 몬스터는 한 스크립트 안에 구현한다
public class NormalMonster : Monster, IDamagable
{
    private bool IsActivateControl = true;
    private bool canTracking = true;

    [Header("Set HP")]
    [SerializeField] private int MaxHP;
    private JYL_ObservableProperty<int> CurrentHP;
    private JYL_ObservableProperty<bool> IsMoving = new();
    private JYL_ObservableProperty<bool> IsAttacking = new();

    // 애니메이션 판단용, 움직일 때의 이벤트에 활용가능
    private NavMeshAgent navMeshAgent;

    [Header("Set Target")]
    [SerializeField] private Transform targetTransform;

    private void Awake()
    {
        Init();
        // 처음부터 쫒아가지 않게 함
        navMeshAgent.isStopped = true;
    }
    private void Update()
    {
        HandleControl();
    }
    private void Init()
    {
        navMeshAgent = GetComponent<NavMeshAgent>();
    }

    private void HandleControl()
    {
    	// 컨트롤 권한이 없으면 리턴
        if (!IsActivateControl) return;
        HandleMove();
    }

    private void HandleMove()
    {
    	// 타겟이 없을 경우 예외처리
        if (targetTransform == null) return;
		// 추적이 가능한 상태이면
        if (canTracking)
        {
            // 정지 중인지? true라면 false로 바꾼다
            //navMeshAgent.isStopped = false;
            navMeshAgent.SetDestination(targetTransform.position);
            IsMoving.Value = true;

        }
        //else
        //{
        //    navMeshAgent.isStopped = true;
        //    // 정지 중인지 확인. false라면 true로 바꿈
        //    IsMoving.Value = false;
        //}
        
        // 추적할 수 없는 상태면, 멈춘 상태
        navMeshAgent.isStopped = !canTracking;
        // 추적할 수 없는 상태면, 움직이지 않음
        IsMoving.Value = canTracking;
    }
    public void TakeDamage(int damage)
    {
        // 데미지 판정 구현
        // 1. 체력 깎기
        // 2. 체력이 0 이하면 죽음
        // 3. 체력 UI처리
        Debug.Log($"{gameObject.name}이 데미지 입음 : 2");
    }
}

몬스터마다 다른 NavMesh

  • Agent Type의 종류 마다 Bake가 따로 된다
  • 비용
  • 지나가는 길의 최단경로를 구한다. 최단경로의 코스트 만큼 추가적으로 경로 계산을 한다. 더 코스트가 낮은 경로가 있을 경우 해당 경로로 이동
  • 동적으로 못 가는 곳을 설정할 수 있다
  • 다만, 실시간으로 하는 것은 무겁다
  • 문을 통해 진행을 막는 것도 옵스타클로 쉽게 할 수 있다(실험필요)
  • 옵스타클을 SetActive true,false로 구현할 수 있다
  • 애니메이션재생과 동시에 true,열리는 문의 경우에는 애니메이션이 끝난 후 false
  • 장애물을 뛰어넘거나, 언덕을 점프할 수 있게 된다. 서로 떨어진 지역을 다리를 놓은것처럼 지나갈 수 있게 된다

도전과제

  • 몬스터 2마리 이상

  • 총을 쏴서 적에게 데미지 입히기

  • 적도 HP바 가지기

  • 떨어진 아이템을 주울 경우 체력 회복

  • 플레이어 죽는 애니메이션

  • 그럼 그와 관련한 필드가 있어야 한다. status에 상태 추가

  • 씬 전환

  • 게임 스테이터스 초기화

profile
개발 박살내자

0개의 댓글