사용할 에셋은 이걸로 정했다.
다만 이 프리팹을 다운받고 보니 Material이 다 깨져 있었던 탓에 Material을 전부 Standard로 수정해야 했다. 또한 맵이 전체적으로 프리팹 설정이 되어 있어서 맵 구조를 수정하는 건 불가능해 그대로 사용하기로 했다.
그래도 맵 자체가 디자인이 잘 되어 있는 편이다 보니 그대로 쓰기로 했다.
조준점 UI를 만들어보고자 한다. 이전에 프로젝트를 할 때 조준점을 만들어보기야 했지만, 이번엔 살짝 다른 느낌으로 만들어보고자 한다.
Image로 AimImage를 만들었다. 이제 캔버스를 만들고 이미지로 해당 UI를 띄우면 될 것이다.
여기서 우리는 ArrowHead를 띄울 때의 효과를 주기 위해서, 조준할 때에만 ArrowHead를 생성하도록 만들고 애니메이션 효과를 넣어줄 것이다.
변화가 많이 일어나는 캔버스이므로 캔버스를 따로 지정하여 애니메이션을 만들어주도록 하자.
UI캔버스를 따로 만들고 이미지를 이용해 애니메이션을 만들어 보자.
ArrowHead 이미지를 사진에서 보다시피 아래와 같이 세팅해준다.
다음으로 애니메이션을 설정하여 위와 같이 Fill Amount를 기준으로 켜지고 꺼지는 애니메이션을 각각 만들자.
애니메이션이 정상작동되는 것을 확인하고 애니메이터 세팅을 하였다.
이전에 사용했던 변수인 IsAim을 이곳에도 사용하며, 플레이어 컨트롤러에 그대로 등록하면 된다.
using UnityEngine;
using UnityEngine.UI;
public class PlayerController : MonoBehaviour
{
[SerializeField] private Image _aimImage;
[SerializeField] private Animator _aimAnimator;
...
private void Awake() => Init();
private void OnEnable() => SubscribeEvents();
private void Update() => HandlePlayerControl();
private void OnDisable() => UnsubscribeEvents();
private void Init()
{
_status = GetComponent<PlayerStatus>();
_movement = GetComponent<PlayerMovement>();
_animator = GetComponent<Animator>();
_aimImage = _aimAnimator.GetComponent<Image>();
_hpUI.SetImageFillAmount(1);
_status.CurrentHp.Value = _status.MaxHp;
}
...
public void SubscribeEvents()
{
...
_status.IsAming.Subscribe(SetAimAnimation);
}
public void UnsubscribeEvents()
{
...
_status.IsAming.UnSubscribe(SetAimAnimation);
}
private void SetAimAnimation(bool value)
{
_animator.SetBool("IsAim", value);
_aimAnimator.SetBool("IsAim", value);
}
private void SetMoveAniation(bool value) => _animator.SetBool("IsMove", value);
private void SetAttackAnimation(bool value) => _animator.SetBool("IsAttack", value);
}
다만 이와 같이 구현했을 때 문제점이 생길 수 있다. 그 문제점에 관해서는 앞에서의 애니메이터를 참고해보자.
애니메이터 상에서 처음 실행되는 애니메이션이 ArrowHead가 사라지는 애니메이션이다 보니 실행 후에 한 번 실행되어 버리는 문제가 발생한다.
이를 해결하기 위한 방법은 두 가지로 나눌 수 있다.
여기서 두 번째 방법을 사용해보기로 한다. PlayerController 스크립트에 내용을 추가한다.
private void SetAimAnimation(bool value)
{
if (!_aimImage.enabled) _aimImage.enabled = true;
_animator.SetBool("IsAim", value);
_aimAnimator.SetBool("IsAim", value);
}
HPUI를 구현한 것 또한 이전 프로젝트에서 진행해 본 내용이지만, 이번엔 다소 다른 방법으로 구현해보고자 한다. 바로 몬스터나 플레이어의 바로 위에 생성되게 하는 것이다.
먼저 캔버스를 만들고 하위 오브젝트로 HPUI 빈 오브젝트를 만들어 관리하고, 그 아래에 Background와 게이지 표시바를 만든다.
Background만 단순 이미지로 만들고 게이지 이미지만 그림판으로 흰색 모양 사각형으로 넣고, 색깔을 입힌다.
또한 Image Type과 Filled Method, Fill Origin을 설정한다.
Fill Amount를 조절하면서 정상적으로 게이지 작동이 확인되었으며, 여기서 플레이어의 바로 머리 위에 뜰 수 있도록 Canvas의 설정을 조절한다.
Canvas를 World Space로 설정하여 위치를 조절한다. 예를 들어 몬스터라먼 몬스터 머리 위에, 플레이어라면 플레이어의 머리 위에 뜨도록 조절하는 것이다. 이와 같이 설정한 후 자식 오브젝트로 설정하면, 상시적으로 체력 바가 해당 오브젝트의 위에 존재하게 된다.
다만 단순히 붙이기만 하면 체력바 자체는 고정되어 있기 때문에, 시점에 따라 체력바가 안 보이거나, 뒷면만 보일 수 있다. 따라서 플레이어의 시점으로 체력바가 따라올 수 있도록 스크립트를 추가한다.
using UnityEngine;
using UnityEngine.UI;
public class HpGuageUI : MonoBehaviour
{
[SerializeField] private Image _image;
private Transform _cameraTransform;
private void Awake() => Init();
private void LateUpdate() => SetUIForwardVector(_cameraTransform.forward);
private void Init()
{
_cameraTransform = Camera.main.transform;
}
public void SetImageFillAmount(float value)
{
_image.fillAmount = value;
}
private void SetUIForwardVector(Vector3 target)
{
transform.forward = target;
}
}
이와 같이 작성하고서 테스트를 진행해보자.
작동 자체는 잘 되지만 문제점이 있는 것을 확인할 수 있다. UI가 몬스터를 가리는 현상이 관찰된다. 이걸 해결하기 위해서는 어떻게 해야 할까?
꼼수라고 할 수도 있지만, 방법은 의외로 단순하게 해결할 수 있다.
Canvas 위쪽에 Pivot으로 지정할 빈 오브젝트를 넣어보자. 이렇게 하면 해당 축을 기준으로 UI가 움직이기 때문에 UI가 몬스터를 가리는 것을 어느정도 방지할 수 있다.
세부적인 테스트와 에셋 반영 시의 상황을 염두에 두어야 하지만 의도대로 작동하는 것을 확인할 수 있다.
이와 같이 UI에 대한 세팅도 마친 후 HP의 증감을 코드로 구현한 후 연결해보자.
...
[field: SerializeField]
[field:Range(0, 500)]
public int MaxHp { get; set; }
// Player Stat Event----
public ObservableProperty<int> CurrentHp { get; private set; } = new();
...
using UnityEngine;
using UnityEngine.UI;
public class PlayerController : MonoBehaviour
{
...
[SerializeField] private HpGuageUI _hpUI;
...
private void Init()
{
...
_hpUI.SetImageFillAmount(1);
_status.CurrentHp.Value = _status.MaxHp;
}
private void HandlePlayerControl()
{
if (!isControlActive) return;
HandleMovement();
HandleAiming();
HandleShooting();
// 테스트용 코드
if(Input.GetKey(KeyCode.Q))
{
TakeDamage(1);
}
if(Input.GetKey(KeyCode.E))
{
RecoveryHP(1);
}
}
...
public void TakeDamage(int value)
{
_status.CurrentHp.Value -=value;
if (_status.CurrentHp.Value <= 0) Dead();
}
public void RecoveryHP(int value)
{
int hp = _status.CurrentHp.Value + value;
_status.CurrentHp.Value = Mathf.Clamp(hp, 0, _status.MaxHp);
}
public void Dead()
{
Debug.Log("플레이어 사망 처리");
}
public void SubscribeEvents()
{
_status.IsMoving.Subscribe(SetMoveAniation);
_status.IsAming.Subscribe(_aimCamera.gameObject.SetActive);
_status.IsAming.Subscribe(SetAimAnimation);
_status.IsAttacking.Subscribe(SetAttackAnimation);
_status.CurrentHp.Subscribe(SetHPUIGuage);
}
public void UnsubscribeEvents()
{
_status.IsMoving.Subscribe(SetMoveAniation);
_status.IsAming.UnSubscribe(_aimCamera.gameObject.SetActive);
_status.IsAming.UnSubscribe(SetAimAnimation);
_status.IsAttacking.UnSubscribe(SetAttackAnimation);
_status.CurrentHp.UnSubscribe(SetHPUIGuage);
}
...
private void SetHPUIGuage(int currentHp) => _hpUI.SetImageFillAmount((float)currentHp / _status.MaxHp);
}
사용할 몬스터 에셋은 아래와 같은 것이다.
몬스터는 플레이어를 쫓도록 설계해야 하고, 벽이나 장애물 등을 인지할 수 있어야 한다.
이런 몬스터 AI를 만들 수 있는 기능이 유니티에 존재하고 이걸 Navimesh라고 한다. 이번엔 이 Navimesh를 이용한 몬스터 움직임 구현을 해 보려고 한다.
유니티 패키지매니저에서 기본적으로 제공되는 AI Navigation을 다운받아보자.
그러면 위와 같이 기본 AI Navigation 패키지가 깔리고 Window 탭에서 AI 관련 기능이 추가된 것을 알 수 있다.
아래와 같이 네비게이션 기능이 추가된 것을 확인할 수 있으며, 이 기능에 대해 알아보고자 한다.
맵 프리팹을 기준으로 NavMeshSurface라는 컴포넌트를 추가해보자.
이와 같은 컴포넌트 창이 추가된 것을 알 수 있고, 해당 내용을 살펴보자.
해당 네비메쉬를 통해 움직이는 몬스터 등의 오브젝트의 이름, 크기를 결정하는 직경, 높이를 설정하고 오르거나 내려갈 수 있는 높이, 경사 등을 설정할 수 있다. 기본적으로 제공되는 Humanoid가 있으며, 필요에 따라 Agent Type를 변경하여 몬스터의 크기 및 이동 반경 등을 상세 설정할 수 있다.
해당 지역을 어떤 지역으로 설정할 지 레이어처럼 달 수 있는 기능이다. 예를 들어 늪 지형이라고 하면서 Cost를 추가로 배치할 수 있고 코스트에 따라 몬스터가 우회하여 이동하게 하는 등의 지형적 특징을 반영할 수 있다.
커스텀에 따라 최대 32개의 Area를 지정할 수 있다.
해당 옵션이 중요한 옵션이므로 좀 더 상세히 살펴보자.
여기서 첫 번째와 세 번째의 차이라고 하면 아래와 같은 상황을 생각할 수 있다.
선반이라는 오브젝트가 맵 구성의 자식 오브젝트가 아닐 경우, 1번의 경우는 선반 위에 올라갈 수 있게 네비메쉬가 설정되고 3번의 경우는 올라갈 수 없게 설정된다.
또한 몬스터의 종류가 여럿일 경우 한 맵에서 내비메쉬 설정 또한 여러 개를 할 수 있다는 사실도 알게 되었다.
이와 설정한 후 내비메쉬를 돌아다닐 오브젝트에게 NavMeshAgent를 넣는다.
여기서 중요한 점은 NavMesh로 설정된 맵과 Agent가 일치해야만 하고, 일치하지 않거나 Agent가 없으면 바로 오류가 나니 주의해야 한다.
그러면 몬스터 구현을 위한 코드를 추가하도록 하자.
우선 몬스터는 데미지를 받을 것이므로 IDamageable 인터페이스부터 구현하자.
public interface IDamageable
{
public void TakeDamage(int value);
}
그 다음으로 확장성을 고려해 몬스터 추상클래스를 구현하고, 몬스터를 구현한다.
public abstract class Monster : MonoBehaviour
{
}
using DesignPattern;
using UnityEngine;
using UnityEngine.AI;
public class NormalMonster : Monster, IDamageable
{
private bool _isActivateControl = true;
private bool _canTracking = true;
[SerializeField] private int MaxHp;
private ObservableProperty<int> CurrentHp;
private ObservableProperty<bool> IsMoving = new();
private ObservableProperty<bool> IsAttacking = new();
[Header("Config Navmesh")]
private NavMeshAgent _navMeshAgent;
[SerializeField] private Transform _targetTransform;
private void Awake() => Init();
private void Update() => HandleControl();
private void Init()
{
_navMeshAgent = GetComponent<NavMeshAgent>();
_navMeshAgent.isStopped = true;
}
private void HandleControl()
{
if (!_isActivateControl) return;
HandleMove();
}
private void HandleMove()
{
if (_targetTransform == null) return;
if (_canTracking)
{
_navMeshAgent.SetDestination(_targetTransform.position);
}
_navMeshAgent.isStopped = !_canTracking;
IsMoving.Value = _canTracking;
}
public void TakeDamage(int value)
{
// TODO
}
}
이와 같이 구현하고, 우선은 몬스터가 잘 따라오는지 확인해보자.
Navmesh에는 코스트라는 개념이 있다고 말한 바가 있다. 이 코스트가 어떻게 작용하는지 알아보자.
여기 이렇게 길목에 Cost가 100인 길목을 만들어 놓았다고 하자. 여기서 파란색 위치에 있는 몬스터는 원래라면 노란색 위치에 있는 목적지인 위쪽까지 가기 위해 직선 거리의 길로 가겠지만, 이와 같이 코스트가 큰 길목이 있게 되면 길을 돌아가게 된다.