
게임을 하다 보면 플레이어가 주요 목표로 안내하는 것이 매우 중요하며 이는 게임의 몰입성을 늘리고 불쾌한 경험을 줄이는데 있어서 굉장히 유용한 기능이다. 널리 사용되는 방법 중 하나는 이동해야 하는 방향을 오버레이하는 아이콘이 있는 나침반을 제공한다는 것이다.
이번 Lib에는 플레이어가 수집해야 할 목표가 오버레이 된 일반 나침반을 만들고 플레이어가 목표에 도달하면 이벤트를 발생하여 새 장비를 장착하고 월드 목표를 없애도록 해보자.
Demo 씬을 로드 해준다.

시작하기에 앞서 SoftMaskForUGUI라는 패키지를 사용할 것이고, 이는 나침반의 가장자리를 부드럽게하고 모양을 다듬는 데 사용된다. 패키지 사용은 선택사항이라서 생략해도 된다.
자세한 설명은 공식 gitHub 링크를 참고하자
먼저 새 Compass라는 이름으로 Canvas를 하나 만들어주고 자식 오브젝트로 CompassMask라는 이름으로 Image 오브젝트를 만들어준다.
CompassMask 속성을 아래와 같이 설정해준다.

그런 다음 SoftMask 컴포넌트를 추가해준 다음 아래와 같이 속성을 설정해준다.

그 다음 CompassMask에 Panel을 하나 추가해주고 이름과 색상을 적절히 설정해주고 Soft Maskable 컴포넌트를 추가해준다.

그 다음 CompassContainer에 Raw Image를 추가해주고 아래와 같이 설정해준다.

그리고 Soft Maskable 컴포넌트를 추가해준다.
중요한 점은 Width를 잘 기억해두어야 한다.
CompassMask를 1024로 해주었고CompassImage를 2048로 설정해주었다.
게임 씬 상단부에 저렇게 나침반이 생기면 성공이다.

이제 나침반에 목표 위치를 표시해주기 위해 CompassMask의 자식으로 CompassObjectives라는 게임 오브젝트를 추가해준다.

그 다음 Image를 하나 추가해주고 CompassArrow라는 이름으로 바꿔준 후에 적절한 위치, 스케일로 수정해준다.

하단과 같이 나침반 상단에 Arrow가 생겨야 한다.
이제 플레이어를 우클릭으로 회전시킬 건데 이때 나침반도 같이 회전하도록 CompassManager 스크립트를 작성해보자
using UnityEngine;
using UnityEngine.UI;
public class CompassManager : MonoBehaviour
{
public static CompassManager Instance;
public RawImage CompassImage;
private void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
else if (Instance != null)
{
Destroy(gameObject);
}
}
private void LateUpdate() => UpdateCompassHeading();
private void UpdateCompassHeading()
{
if (PlayerController.Instance == null) {
return;
}
Vector2 compassUvPosition = Vector2.right * (PlayerController.Instance.transform.rotation.eulerAngles.y / 360);
CompassImage.uvRect = new Rect(compassUvPosition, Vector2.one);
}
}
Compass Manager는 정적 인스턴스 변수를 만들고 싱글톤 패턴으로 사용한다.UpdateCompassHeading : 플레이어의 회전에 따라 나침반 uv 위치를 계산한다. 그런 다음 Raw Image의 uvRect를 새로 계산된 회적각으로 설정한다. 이 기능이 정상적으로 작동하려면 이미지의
Wrap Mode가 Repeat으로 설정 되어있어야한다. Compass 스프라이트를 클릭하여 설정을 확인하자.
이제 Compass에 추가해주고 CompassImage로 초기화해준다.

이제 회전에 따라 위 나침반도 같이 회전하는 것을 볼 수 있다.
목두 개의 목표를 만들 것인데 월드 목표는 색상, 아이콘, 완료시 호출할 메서드를 정의할 수 있도록 아래와 같이 작성해주었다.
using UnityEngine;
using UnityEngine.Events;
public class Objective : MonoBehaviour
{
[SerializeField] private Color _iconColor = new Color(0, .8f, 1);
[SerializeField] private Sprite _objectiveIcon;
[SerializeField] private UnityEvent _onCompleteEvents;
private void OnTriggerEnter(Collider other) {
_onCompleteEvents.Invoke();
Destroy(this.gameObject);
}
}
UnityEvents는 씬에서 콜백 메서드를 수행하는 기능을 생성하는 매우 편리한 방법이다. 워크플로를 분리하는 데 매우 유용하다.
이제 Object_Bow, Object_Quiver에 Objective 컴포넌트를 추가해주고 아래와 같이 메소드를 연결해준다.


이 때 각 목표는 나침반에 표시되어야 한다. UI Objective 프리팹을 만들고 로직을 구현해보다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class CompassObjective : MonoBehaviour
{
public Image ObjectiveImage;
public bool IsCompassObjectiveActive {get; private set;}
private RectTransform _rectTransform;
public Transform WorldGameObject {get; private set;}
public const float MinVisiblityRange = 5;
public const float MaxVisiblityRange = 30;
public CompassObjective Configure(GameObject worldGameObject, Color color, Sprite sprite = null) {
WorldGameObject = worldGameObject.transform;
_rectTransform = GetComponent<RectTransform>();
ObjectiveImage.color = color;
if (sprite != null) {
ObjectiveImage.sprite = sprite;
}
ObjectiveImage.transform.localScale = Vector3.zero;
UpdateCompassPosition();
return this;
}
private void LateUpdate() => UpdateCompassPosition();
public void UpdateCompassPosition() {
if (WorldGameObject == null || !IsCompassObjectiveActive || CompassManager.Instance == null) {
return;
}
_rectTransform.localPosition = Vector2.right * GetObjectiveAngle(WorldGameObject) * (CompassManager.Instance.CompassImage.rectTransform.sizeDelta.x / 2);
}
private void Update() => ObjectiveImage.transform.localScale = Vector3.Lerp(ObjectiveImage.transform.localScale, IsCompassObjectiveActive && WorldGameObject != null ? Vector3.one : Vector3.zero, Time.deltaTime * 8);
public static float GetObjectiveAngle(Transform worldObjectiveTransform) =>
PlayerController.Instance == null ? -1 :
Vector3.SignedAngle(PlayerController.Instance.transform.forward,
GetObjectiveDirection(worldObjectiveTransform,
PlayerController.Instance.transform), Vector3.up) / 180;
private static Vector3 GetObjectiveDirection(Transform objectiveTransform,
Transform sourceTransform) => (new Vector3(objectiveTransform.position.x,
sourceTransform.position.y, objectiveTransform.position.z) -
sourceTransform.position).normalized;
public void UpdateUiIndex(int newIndex)
{
_rectTransform.SetSiblingIndex(newIndex);
UpdateVisibility();
}
private void UpdateVisibility()
{
if(PlayerController.Instance == null)
{ return; }
float currentDistance = Vector3.Distance(WorldGameObject.position,
PlayerController.Instance.transform.position);
IsCompassObjectiveActive = currentDistance < MaxVisibilityRange &&
currentDistance > MinVisibilityRange;
}
}
GetObjectAngle : 플레이어의 전방 방향과 목표로의 방향 사이의 부호 있는 각도를 계산한다.
부호 있는 각도는 각도 값을 양수 또는 음수로 반환하며, 이는 방향이 초기 벡터의 오른쪽인지 왼쪽인지를 나타낸다. 음수는 왼쪽, 양수는 오른쪽에 있다.
이제 프리팹을 만들어보자. Image로 CompassObjective 오브젝트를 하나 만들어준다.

그리고 Soft Maskable를 추가해주고 Compass Objective 스크립트를 추가해준다. 이 때 Objective Image에는 Image 컴포넌트를 그대로 끌어온다.

이제 프리팹화 시켜준다.
public RectTransform CompassObjectiveParent;
public GameObject CompassObjectivePrefab;
private readonly List<CompassObjective> _currentObjectives = new List<CompassObjective>();
private IEnumerator Start() {
WaitForSeconds updateDelay = new WaitForSeconds(1);
while (enabled) {
SortCompassObjectives();
yield return updateDelay;
}
}
private void SortCompassObjectives() {
if (PlayerController.Instance == null) { return; }
CompassObjective[] orderedObjectives = _currentObjectives.Where(o => o.WorldGameObject != null)
.OrderByDescending(o => Vector3.Distance(PlayerController.Instance.transform.position, o.WorldGameObject.position)).ToArray();
for (int i = 0; i < orderedObjectives.Length; i++) {
orderedObjectives[i].UpdateUiIndex(i);
}
}
public void AddObjectiveForObject(GameObject compassObjectiveGameObject, Color color, Sprite sprite) =>
_currentObjectives.Add(Instantiate(CompassObjectivePrefab, CompassObjectiveParent, false).GetComponent<CompassObjective>().Configure(compassObjectiveGameObject, color, sprite));
일단 start 메소드를 코루틴을 써서 실행되도록 변환했다. 플레이어와의 거리에 따라 목표 정렬 순서를 업데이트하고 가시성을 설정한다. 이제 Objective에 시작 메소드를 추가해준다.
private void Start() => CompassManager.Instance.AddObjectiveForObject(gameObject, _iconColor, _objectiveIcon);
실행해보면 잘 작동한다.

실제로 목표에 도달하면 UI는 사라지지만 아직 오브젝트가 실제로 소멸되지는 않는다.
UI 게임 오브젝트에 대한 참조를 제거하고 삭제하는 다음 메서드를 CompassManager에 추가해준다.
ublic void RemoveCompassObjective(CompassObjective compassObjective) {
_currentObjectives.Remove(compassObjective);
Destroy(compassObjective.gameObject);
}
이제 UpdateCompassPosition 메서드를 업데이트해주자.
public void UpdateCompassPosition() {
if (WorldGameObject == null) {
if(ObjectiveImage.transform.localScale.magnitude <= .1f) {
CompassManager.Instance?.RemoveCompassObjective(this);
}
return;
}
if (!IsCompassObjectiveActive || CompassManager.Instance == null) {
return;
}
_rectTransform.localPosition = Vector2.right * GetObjectiveAngle(WorldGameObject) * (CompassManager.Instance.CompassImage.rectTransform.sizeDelta.x / 2);
}
실제 오브젝트도 파괴된다. 잘 작동한다.
