오늘 배우는 내용은 디지털 공구리다. 정석적인 방법이 아닌 현업에서 효율을 포기하고 구현과 유지보수를 쉽게하기 위해 쓰는 방법들이다.


UI 문제

유니티 엔진의 고질적인 문제들을 알아보자

1. UI 참조 문제


한 페이지에만 해도 UI 요소가 수십가지에 달한다. 프로젝트가 커질 수록 각 UI에 기능을 부착할 때, 이벤트로 어떤 오브젝트의 어떤 기능이 수행이 되는지 일일히 확인하는 것이 매우 복잡하고 어려워진다. 이러한 이유 때문에 관리체계가 필요하다

2. 직렬화

위의 사진의 NEXT 버튼이 처음에는 다음 보상을 확인하는 버튼이였다가, 이후에는 기획 상의 변경으로 인해 다음 씬으로 넘어가는 버튼으로 변경되었다고 해보자.

그래서 버튼명을 직관적으로 하기 위해 변경한다고 해보자

[SerializeField] Button nextSceneButton;


이렇게 하면, 기존의 참조가 풀리는 문제가 발생한다. 하나의 UI요소에도 100개 가까이 오브젝트가 붙을 수 있다. 그렇기 때문에 직렬화로 관리하기 힘들다. 또한 UI 변동사항에 대응하기 힘들다.

3. 관리 문제

UI의 타이밍을 파악하기 힘들다. 각 UI가 서로 연관되어 있게 구현되었다면, 어느 타이밍에 어떤 UI가 활성화 되고 비활성화 되는지 파악하려면 각 UI의 이벤트를 전부 확인해야 한다.

위와같은 UI의 문제들을 해결하려면 어떻게 하는지 알아보자

UI 싱글톤
보통 [확인, 취소 창] 과 같이 모든 씬에서 필요한 UI에 한하여 싱글톤으로 관리한다
관리를 편리하게 하기 위해서 UIManager를 사용한다.


UI 스크립팅

MVP 패턴으로 관리한다

  • M : 로컬일 수도 있고, 서버DB일 수도 있다
  • V : UI 요소
  • P : 하나의 스크립트로 관리한다. UI 요소들을 스크립트 상에서 저장하고, 이벤트로 수행 내용들을 정리한다.

이러한 스크립팅 방식의 UI 관리 방법으로, 아래와 같은 문제들을 해결할 수 있다.

역추적 문제

에디터 상에서 UI 이벤트들을 관리하면 앞서 설명했듯이 역추적이 힘들어진다. 하지만, 스크립트로 관리하면 이 문제는 해결된다

nextSceneButton.onClick.AddListener(Test);

예를들어 보물상자에서 코인이 튀어나와 사라지는 UI가 있다고 했을 때, 어디서 어떤애가 참조해서 사라지게 한 것인지, 규모가 커졌을 때 찾기 힘들다. 이때, onClick.AddListenerTest를 추가해서 역순으로 찾을 수 있다

참조 문제

에디터 상의 드래그&드롭으로 참조시키는 것보다 Awake()에서 GetComponent를 써서 참조시키는 것이 낫다.

nextSceneButton.GetComponent<Button>();

갯수 문제

UI의 갯수가 정말 많지만, 개중에는 쓸 데 없는 UI들이 많다. 기능은 없고 그저 꾸미는 용도로만 UI를 뜻한다. 예를들어 위의 사진에서 WIN!, COMPLETED!, 그리고 코인 이미지 등, 기능적으로는 쓰이지 않는 UI들이 있다. 우리가 신경쓰고 관리해야 하는 UI는 상호작용이 되는 것들이다.

빨간 동그라미들 처럼 직접 다뤄야 하는 UI들의 경우, 게임 오브젝트의 이름을 지정해준다. 이 이름을 기준으로 UI 바인딩을 하고, 쉽게 스크립트 상으로 가져올 거다.


UI 바인딩

"UI의 요소들을 사전에 매핑해놓는다" 라고 이해하면된다.

게임 오브젝트 바인딩

딕셔너리로 관리하고자 하는 UI들을 보관한다.

public class BaseUI : MonoBehaviour
{
    // UI 요소를 스트링으로 찾는다
    public Dictionary<string, GameObject> gameObjectDic;
    // UI에는(게임 오브젝트에는) 무조건 Transform이 있다
    public Dictionary<string, Component> componentDic;
    private void Awake()
    {
        // 칸바스에 있는 모든 자식들의 트랜스폼 컴포넌트들을 가져온다
        // 비활성화 된 UI도 가져오기 위해 오버로딩을 이용해 true 추가
        RectTransform[] transform = GetComponentsInChildren<RectTransform>(true);
        // 효율을 챙기려면 갯수를 미리 정해둔다(포화도가 80% 아래일 필요가 있다)
        gameObjectDic = new Dictionary<string, GameObject>(transform.Length << 2);
        foreach (RectTransform child in transform)
        {
            // 이름이 중복 되는 것은 어짜피 관리할 필요가 없는 것이기 때문에
            // TryAdd로 같은 이름은 넣지않는다
            gameObjectDic.TryAdd(child.gameObject.name, child.gameObject);
        }
    }
}

게임 오브젝트 참조

UI의 이름만으로 간단하게 찾을 수 있다.

// BaseUI를 상속받는다
public class WinPresenter : BaseUI
{
   [SerializeField] GameObject nextButton;
   [SerializeField] GameObject coinText;

	private void Start()
	{
		nextButton = gameObjectDic["NextButton"];
		coinText = gameObjectDic["CoinText"];
	}
}

컴포넌트 바인딩

게임 오브젝트를 참조시키는 것은 해결했으니, Button이면 Button 컴포넌트, TextText 컴포넌트를 가져오도록 한다.

// BaseUI
public void Awake()
{
	// 모든 컴포넌트들을 싹 다 가져오게 된다
	Component[] components = GetComponentsInChildren<Component>(true);
	componentDic = new Dictionary<string, Component>(components.Length << 2);
	foreach (Component component in components)
	{
	    //  키값 : 게임오브젝트_컴포넌트
	    //  -> 모든 게임 오브젝트들이 컴포넌트가 겹치는 경우가 많으니, 구별을 위해 이렇게 한다
	    componentDic.TryAdd($"{component.gameObject.name}_{component.GetType().Name}", component);
	}
}

GetType()
생성된 객체가 존재할 때 쓸 수 있는 메서드다. 현재 실제 타입을 반환한다. 예를들어 Button을 상속받은 MyButton이면 MyButton을 반환한다.

컴포넌트 참조

컴포넌트들을 참조시켜보자

//WinPresenter
[SerializeField] Button nextButton;
[SerializeField] Image nextButtonImage;
[SerializeField] Text coinText;

private Start()
{

	nextButton = componentDic["NextButton_Button"] as Button;
	nextButtonImage = componentDic["NextButton_Image"] as Image;
	coinText = componentDic["CoinText_Text"] as Text;
	 이렇게 바로 값을 바꿔줄 수 있다
	coinText.text = "+2000";
}

문제

  1. 키값을 잘못 가져올 수 있다.
  2. 딕셔너리를 퍼블릭으로 풀기에는 좋지 않다.

함수로 찾기

위의 문제들을 함수를 만들어 해결해보자. 먼저, gameObjectDic부터 함수화 한다. public으로 풀어뒀던 딕셔너리들은 private로 변경한다.

게임 오브젝트 참조

// BaseUI

// in parameter로 입력 유형을 제한한다
public GameObject GetUI(in string name)
{
    gameObjectDic.TryGetValue(name, out GameObject gameObject);
    // 찾지 못할 경우 null로 반환한다
    return gameObject;
}

이제 GetUI로 UI의 게임 오브젝트를 가져올 수 있다.

// WinPresenter
private void Start()
{
	 // 이 방법도 귀찮으면 필드 선언에서 익명메서드로 처리하는 것도 좋다
	 nextButton = GetUI("NextButton");
	 coinText = GetUI("CoinText");
     
     // 이렇게 활성화 / 비활성화 가능
     nextButton.SetActive(false);
}

// 익명메서드 처리 방법
[SerializeField] GameObject nextButton => GetUI("NextButton");
[SerializeField] GameObject CoinText => GetUI("CoinCountText");

컴포넌트 참조

제네릭 T를 사용해서 컴포넌트 타입에 따라 다른 형태로 반환할 수 있게 범용성을 챙긴다.

// BaseUI
// 오버로딩으로 컴포넌트를 가져와보자. T를 Component로 제한한다.
public T GetUI<T>(in string name) where T : Component
{
    componentDic.TryGetValue($"{name}_{typeof(T).Name}", out Component component);
    if (component != null)
        return component as T;

    // 만약에 이름을 잘못 쓴 경우 다른 게임 오브젝트 찾아본다
    GameObject go = GetUI(name);
    // 같은 이름의 게임 오브젝트가 없으면 null
    if (go == null) return null;

    // 만약 같은 이름의 게임 오브젝트는 있으면 컴포넌트를 찾아봄
    component = go.GetComponent<T>();
    // 찾고자 하는 컴포넌트가 없으면 null
    if (component == null) return null;

    // 찾고자 하는 컴포넌트를 찾았으면 딕셔너리에 추가하고 반환해준다
    // 게임 진행 중 컴포넌트들이 사라지거나, 추가될 수 있는 경우를 대비한다
    componentDic.TryAdd($"{name}_{typeof(T).Name}", component);
    return component as T;
}

typeof(T)
앞선 GetType()과는 다르게 클래스명 등 미리 지정된 타입을 받아올 때 쓴다.
T.name으로는 쓸 수 없다. T에 해당하는 클래스에 name이라는 필드가 있는지 없는지 알 수 없기 때문. GetType()typeof() 둘 다 C#에서 제공하는 기능이다.

이제 컴포넌트를 가져올 수 있다. 간편하게 GetUI옆에 <>홀화살괄호에 컴포넌트 명만 추가하면 된다.

// WinPresenter
// GetUI<컴포넌트이름>(게임오브젝트이름)
[SerializeField] Button NextButton => GetUI<Button>("NextButton");
[SerializeField] Image NextButtonImage => GetUI<Image>("NextButton");
[SerializeField] Text CoinCountText => GetUI<Text>("CoinCountText");
[SerializeField] Text ScoreText => GetUI<Text>("ScoreText");

이벤트 핸들러

UI의 여러 기능 수행을 이벤트로 처리하는 것을 이벤트 핸들러로 관리해보자. 2019 버전 이전에서는 쓰기 힘든 기능이다. 2021 버전 이상인지 확인하자.

일반적인 예시

Button의 경우, 단순히 클릭에 대한 이벤트만 있을 뿐이다. 호버링이나 하이라이트 같은 것들은 따로 관리가 필요하다. 여기서 이벤트 핸들러를 사용할 수 있다.

private void OnEnable()
{
    NextButton.onClick.AddListener(ButtonClick);
}
private void OnDisable()
{
    NextButton.onClick.RemoveListener(ButtonClick);
}
private void ButtonClick()
{
    Debug.Log("버튼 클릭됨");
}

PointerHandler

모든 플랫폼에서 적용이 가능한 범용성이 넓은 기능이다. 인터페이스들을 상속받는 관리자로 구현한다. 인터페이스들은 IEventSystemHandler를 상속받는다.

사용하려면 이벤트 시스템이 있어야 한다. Button 같은 UI 요소를 씬에 추가하면 자동으로 추가된다.

  • IEventSystemHandler
    인터페이스. 이벤트를 위한 인터페이스들이 있다. 게임 오브젝트에 어떤 컴포넌트가 있든, 이 핸들러가 먼저 수행된다

  • PointerEventData
    여러 정보들을 담고 있음. 좌클릭, 우클릭, 휠버튼 입력을 확인 가능.

주요 필드

필드설명사용 예시
position화면상의 마우스 커서 좌표 (스크린 좌표계)마우스 클릭 위치, 조준 지점 계산 등
pointerEnter현재 마우스가 올려진 UI GameObjectUI 버튼 위에 있는지 판별
pointerPress마지막으로 클릭된 UI GameObject클릭 대상 확인
pointerCurrentRaycast현재 Raycast 정보 (스크린 → UI)어떤 UI 요소 위에 있는지 확인
pointerDrag드래그 중인 대상 오브젝트마우스로 끌어서 조작하는 UI
delta이전 프레임 대비 마우스 이동 벡터마우스로 커서 이동량 추적 (조준 등)
scrollDelta마우스 스크롤 방향 및 세기무기 줌, 스크롤 기반 UI 조작 등
button어떤 마우스 버튼을 눌렀는지 (Left, Right, Middle)좌클릭 발사, 우클릭 특수기 등

그 외

필드설명사용 예시
clickCount연속 클릭 횟수 (더블 클릭 등)더블 클릭 처리
clickTime마지막 클릭 시간빠른 클릭 시 쿨다운 제한
dragging현재 드래그 중인지 여부마우스로 조작하는 UI에 유용
eligibleForClick클릭으로 간주될 수 있는지 여부클릭 판정 예외 처리
pressPosition클릭이 시작된 스크린 위치클릭-릴리즈 간 드래그 거리 측정
  • 함수 쓰는법
On"트리거"(PointerEventData eventData) { }

위와 같이 쓰면 된다. 예를들어, IPointerClickHandler의 경우는 아래와 같다.

OnPointerClick(PointerEventData eventData) { }

IEventSystemHandler 내부를 확인해도 된다.

  • IPointerDownHandler
    UI를 눌렀을 때 반응한다.

  • IPointerUpHandler
    눌렀던 UI에서 떼었을 때 반응한다. UI 범위 안에서 누른 후 UI를 벗어나도 떼었을 때 반응한다. 이 부분이 Click과 다른 점이다.

  • IPointerClickHandler
    눌렀다 뗐을 때 반응. UI 범위 안에서 둘 다 일어나야 수행된다

  • IPointerMoveHandler
    UI 위에서 포인터가 이동 중일 때 반응

  • IPointerEnterHandler
    UI에 포인터가 들어갈 때 발동. 모바일과 같은 터치식의 경우 발동안함

  • IPointerExitHandler
    UI에서 포인터가 나갈 때 발동. 모바일과 같은 터치식의 경우 발동 안함

  • IDragHandler
    드래그 중에 발동

  • IBeginDragHandler
    드래그 시작 시 발동

  • IEndDragHandler
    드래그가 끝났을 때 발동

  • IDragAndDropHandler
    드래그가 끝나고 놓았을 때 발동. 이걸 이용해 드래그&드롭도 가능함


사용 예시 - 드래그

마우스로 UI를 클릭해서 드래그를 할 수 있다

public class JYL_PointerHandler : MonoBehaviour,
IDragHandler,
IBeginDragHandler,
IEndDragHandler
{
    private Vector3 startPos;
    
    public void OnBeginDrag(PointerEventData eventData)// => BeginDrag?.Invoke(eventData);
    {
        Debug.Log("드래그 시작할 때 발동");
        startPos = transform.position;
    }

    public void OnDrag(PointerEventData eventData)// => Drag?.Invoke(eventData);
    {
        Debug.Log("드래그 중일 때 발동");
        // delta는 Vector2 이기 때문에 형변환이 필요하다
        // 마우스 입력만큼 이동
        transform.position += (Vector3)eventData.delta;
        // 마우스 위치로 피벗이 이동
        transform.position = (Vector3)eventData.position;
    }

    public void OnEndDrag(PointerEventData eventData)// => EndDrag?.Invoke(eventData);
    {
        Debug.Log("드래그 끝날 때 발동");
        transform.position = startPos;
    }
}


다만 지금은 왼쪽 클릭, 오른쪽 클릭, 휠 버튼 모든 버튼에 반응한다. 좌클릭으로만 한정지어보자

if(eventData.button == PointerEventData.InputButton.Left)

이제 좌클릭에만 반응한다


사용예시 - 포인터

포인터의 동작에 따라 여러가지 이벤트를 수행할 수 있다. 마우스 포인터가 UI 요소 위에 들어갔다가 나올 때 이미지가 변하게 해보자.

//PointerHandler
[SerializeField] Sprite normalImage;
[SerializeField] Sprite highlightImage;

public void OnPointerEnter(PointerEventData eventData)// => Enter?.Invoke(eventData);
{
    // UI 위로 마우스 포인터가 들어갈 때 수행
    GetComponent<Image>().sprite = highlightImage;
}

public void OnPointerExit(PointerEventData eventData)// => Exit?.Invoke(eventData);
{
    GetComponent<Image>().sprite = normalImage;
}


마우스 포인터의 위치에 따라 UI가 바뀌게 된다.


문제점

각각의 UI 요소마다 인터페이스로 기능을 전부 하나하나 구현하는 것은 개방폐쇠 원칙에 위배된다. 이벤트로 수행할 함수를 불러오는 식으로 구현하는 것이 알맞다.


Event Trigger


컴포넌트로 Event Trigger를 추가해서 이벤트로 받아올 수도 있다.

다만, BaseEventData만 받아올 수 있다는 단점이 있다.


이벤트 어댑터

위의 문제를 해결하기 위해 어댑터 패턴을 적용한다

  • PointerHandler 스크립트
public class PointerHandler : MonoBehaviour,
IPointerDownHandler,
IPointerUpHandler,
IPointerClickHandler,
IPointerMoveHandler,
IPointerEnterHandler,
IPointerExitHandler,
IDragHandler,
IBeginDragHandler,
IEndDragHandler
//, IDragAndDropHandler
{

    // 어댑터 형식으로 구현한다
    public event Action<PointerEventData> Enter;
    public event Action<PointerEventData> Exit;
    public event Action<PointerEventData> Up;
    public event Action<PointerEventData> Down;
    public event Action<PointerEventData> Click;
    public event Action<PointerEventData> Move;
    public event Action<PointerEventData> Drag;
    public event Action<PointerEventData> BeginDrag;
    public event Action<PointerEventData> EndDrag;

	// 람다식으로 간편화 가능하다
    // 2줄 이상 넘어가면 함수로 따로 만들어두고 쓰는게 좋다
    public void OnBeginDrag(PointerEventData eventData) => BeginDrag?.Invoke(eventData);
    public void OnDrag(PointerEventData eventData) => Drag?.Invoke(eventData);
    public void OnEndDrag(PointerEventData eventData) => EndDrag?.Invoke(eventData);
    public void OnPointerClick(PointerEventData eventData) => Click?.Invoke(eventData);
    public void OnPointerDown(PointerEventData eventData) => Down?.Invoke(eventData);
    public void OnPointerEnter(PointerEventData eventData) => Enter?.Invoke(eventData);
    public void OnPointerExit(PointerEventData eventData) => Exit?.Invoke(eventData);
    public void OnPointerMove(PointerEventData eventData) => Move?.Invoke(eventData);
    public void OnPointerUp(PointerEventData eventData) => Up?.Invoke(eventData);
}

이벤트를 받아오는 함수를 BaseUI에 추가한다

  • BaseUI 스크립트
public JYL_PointerHandler GetEvent(in string name)
{
    GameObject gameObject = GetUI(name);
    return gameObject.GetOrAddComponent<JYL_PointerHandler>();
}

GetOrAddComponent
컴포넌트를 가져오고, 없을 경우 생성한 다음 가져온다. 유니티에서 기본으로 제공되는 함수가 아니다. VisualScripting 패키지에 포함된 확장 함수

  • 왜 AddComponent안씀?
    AddComponent를 사용하면 동일하게 컴포넌트를 추가 후 반환한다. 다만, 중복 컴포넌트를 허용하지 않는 컴포넌트들도 있음. [DisallowMultipleComponent] 어트리뷰트

Presenter

이벤트 어댑터와 이벤트를 불러오는 함수까지 만들었으니 실제로 사용해보자

  • WinPresenter 스크립트
private void OnEnable()
{
    GetEvent("NextButton").Down += Down;
    // 람다식으로 간단하게 구현도 가능함
    // 여기서 data = PointerEventData --> Down이벤트의 매개변수가 이 형식이기 때문
    // GetEvent("NextButton").Down += data => Debug.Log("누름");
    GetEvent("NextButton").Up += Up;
    // PointerEventData의 position 정보를 활용했다
    GetEvent("BoxImage").Drag += data => GetUI("BoxImage").transform.position = data.position;
}
private void OnDisable()
{
    GetEvent("NextButton").Down -= Down;
    GetEvent("NextButton").Up -= Up;
}
public void Down(PointerEventData data)
{
    Debug.Log("누름");
}
public void Up(PointerEventData data)
{
    Debug.Log("뗌");
}


원하는 방식대로 동작하는 것을 확인할 수 있다.

람다식 주의점
람다식으로 추가한 함수는 이벤트에서 삭제되지 않는다.

공통적인 사용
만약 여러 UI에서 공통적으로 사용하는 기능의 경우, 따로 함수로 빼놓고 이벤트에 추가하는 식으로 구현한다.


3D 환경

3D 환경에서도 위와 같은 포인터 핸들러를 활용할 수 있다. EventSystem을 추가하자.
2D에서는 그냥 되었던 이유가 CanvasGraphic Raycaster가 컴포넌트로 있기 때문이다. 마우스 위치로 레이캐스트를 해서, 부딪히는 물체의 정보를 불러오는 방식. 3D 에서는 메인카메라에서 레이캐스트를 해서 정보를 불러오는 방식을 배웠었다. 그것을 이용하면 된다.

메인 카메라에 컴포넌트 Physics Raycaster를 추가한다. 그러면 이제 동작한다.

// PointerHandler
public void OnPointerClick(PointerEventData eventData)// => Click?.Invoke(eventData);
{
    Debug.Log("클릭");
}
public void OnPointerDown(PointerEventData eventData) => Down?.Invoke(eventData);
public void OnPointerEnter(PointerEventData eventData)// => Enter?.Invoke(eventData);
{
    Debug.Log("들어옴");
}
public void OnPointerExit(PointerEventData eventData)// => Exit?.Invoke(eventData);
{
    Debug.Log("나감");
}

RTS 장르의 게임, 디아블로, 심즈와 같이 마우스의 포인터를 기준으로 상호작용하는 게임들이 이러한 방식으로 구현된다

큐브 옮기기

드래그로 큐브를 움직여 보자. 2D와 다르게, 마우스 좌표기준으로 움직이는 식으로 구현하면 원하는 느낌으로 움직이지 않는다.

먼저 들어올리고 내리는 것을 구현한다

// PointerHandler
public void OnBeginDrag(PointerEventData eventData)
{
    transform.Translate(Vector3.up * 1f, Space.World);
}
public void OnDrag(PointerEventData eventData)
{
    // 기물 이동
}
public void OnEndDrag(PointerEventData eventData)
{
    transform.Translate(Vector3.down*1f,Space.World);
}

이동을 만약에 이전처럼

transform.position += eventData.delta;

위와 같이 작성할 경우, 마우스 100px을 움직이는 것으로 100m가 움직일 수도 있다. 수학적인 계산이 필요한 부분이다.

// PointerHandler
public void OnDrag(PointerEventData eventData)
{
    // 기물 이동
    Plane plane = new Plane(Vector3.up, 1f);
    Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    if(plane.Raycast(ray,out float distance))
    {
        transform.position = ray.GetPoint(distance);
    }
}

Plane

구조체. 3D 공간에서의 평면 정보를 표현하는 데 필요한 정보들을 담고 있다.

  • Plane(Vector3 inNormal, Vector3 inPoint) : inNormal 벡터를 법선벡터로 하면서, inPoint 지점을 지나는 면을 생성한다.
  • Raycast(ray, out float distance) : bool을 반환한다. 레이캐스트와 평면이 평행하면(교차하지 않으면) false. out parameter로 카메라에서 평면까지의 거리를 반환한다.
  • ray.GetPoint : 광선의 방향으로 출발점에서 지정한 거리(distance)만큼 떨어진 지점의 World 좌표값을 반환한다.


HUD

  • Head's Up Display

    플레이어의 정보(체력, 무기나 아이템), 미니맵, 아군과 적군의 점수 등과 같은 정보들을 게임 화면 내에 표시하는 것을 말한다.

체력 UI

플레이어의 체력을 구현한다고 해보자. 플레이어를 계속 따라 다녀야 하니, CanvasRender ModeWorld Space로 변경한다. Canvas를 플레이어의 하위로 넣는다. 체력바 구현은 Slider로 간단히 구현해보았다.

  • Slider로 구현할 때 측면에서 보면 같이 돌아가다보니 못 보는 경우가 생긴다

Billboard 기법

이러한 문제 해결을 위해 이미지를 항상 카메라 방향으로 돌려주는 기법인 Billboard 기법을 사용해본다.

  • 옛날 게임의 나무들은 2d 스프라이트 단면 이미지를 이 기법을 사용해서 자연스러운 나무의 느낌을 살렸다.

World Space 기준

카메라 방향으로 이미지를 계속 돌린다. 후처리에 해당하는 LateUpdate()에서 수행된다

public class JYL_Billboard : MonoBehaviour
{
    private void LateUpdate()
    {
        transform.forward = Camera.main.transform.forward;
    }
}

문제점

  1. 스케일 문제 : 가까우면 체력바가 커지고, 멀리 있으면 체력바가 작아진다.
  2. 가시성 문제 : 탑뷰에서 볼 경우 체력바가 캐릭터를 가리게 된다.

WorldToScreenPoint

이러한 문제들을 해결하려면, CanvasRender ModeScreen Space - Overlay로 변경한다. 그러면 크기가 고정적이다. 이제 UI를 지속적으로 오브젝트 위에 위치하게 하면 된다. 여기서 쓰이는 것이 WorldToScreenPoint. World에 있는 좌표를 스크린 기준의 좌표로 변환한다. 이를 통해 UI를 크기가 변하지 않고, 게임 오브젝트의 위치에 따라 자동으로 따라다니게 할 수 있다

public class HUD : MonoBehaviour
{
	private void LateUpdate()
	{
		transform.position = Camera.main.WorldToScreenPoint(follow.position);
	}
}


UI가 오브젝트를 잘 따라다닌다. UI의 위치는 피벗을 통해 조절하자. 또는 스크립트로 수정한다

// 1당 1px다. 200f = 200px
transform.position = Camera.main.WorldToScreenPoint(follow.position)+ Vector3.up * 200f;

UI Manager

UI 관리 프로세스를 위해 Manager를 만든다. 싱글톤으로 관리한다. UI가 종류가 매우 많을텐데, 하나하나 드래그로 참조시키며 관리하기는 힘들다.

public class UIManager : MonoBehaviour
{
    private static UIManager instance;
    public static UIManager Instance { get { return instance; } }
    private void Awake()
    {
        if(instance == null)
        {
            instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
        }
    }
}

매니저들을 관리하는 클래스를 임시로 만든다. 좀 더 호출하기 편해진다.

public class JYL_Manager : MonoBehaviour
{
    public static UIManager UI => UIManager.Instance;
}

팝업 UI


칸바스를 새로 추가한다.

맨 앞에 위치해야 하므로 Sort Order를 조절한다.

Resources 폴더


폴더를 만들어 UI 프리팹을 관리한다. 여기에 방금 만든 칸바스를 프리팹화 한다.

UIManager 스크립트 수정

만든 UI를 불러오는 스크립트를 추가한다.

private PopUpCanvas popUpCanvas;
public PopUpCanvas PopUpCanvas
{
    get
    {
        if (popUpCanvas != null)
        {
            return popUpCanvas;
        }
        popUpCanvas = FindObjectOfType<PopUpCanvas>();
        if (popUpCanvas != null)
        {
            return popUpCanvas;
        }
        PopUpCanvas prefab = Resources.Load<PopUpCanvas>("UI/Canvas_PopUp");
        return Instantiate(prefab);
    }
}
// 팝업 만들기
public T ShowPopUp<T>() where T : BaseUI
{
    T prefab = Resources.Load<T>($"UI/PopUp/{typeof(T).Name}");
    // UI를 만들 때는 canvans의 자식으로 설정해야 한다.
    T instance = Instantiate(prefab, PopUpCanvas.transform);
    
	PopUpCanvas.AddUI(instance);
    return instance;
}
// 팝업 삭제
public void ClosePopUp()
{
    PopUpCanvas.RemoveUI();
}

PopUpCanvas 스크립트

BaseUI를 스택으로 가진다. Stack으로 팝업 순서대로 나타나고 사라진다.

public class PopUpCanvas : MonoBehaviour
{
	// 다른 곳을 클릭하는 것을 방지한다.
    [SerializeField] GameObject blocker;
	// 스택으로 관리한다.
    private Stack<BaseUI> stack = new Stack<BaseUI>();
	
    // 스택에 UI를 추가
    public void AddUI(BaseUI ui)
    {
    	// 현재 활성화 된 UI를 비활성화 시키고 새로 추가된 UI가 활성화된다
        if (stack.Count > 0)
        {
            BaseUI top = stack.Peek();
            top.gameObject.SetActive(false);
        }
        stack.Push(ui);

        blocker.SetActive(true);
    }

    public void RemoveUI()
    {
        if (stack.Count == 0)
            return;
		
        // 스택에서 제거하고 파괴
        BaseUI top = stack.Pop();
        Destroy(top.gameObject);
		
        // 스택이 남아있으면 전 단계로 간다.
        if (stack.Count > 0)
        {
            top = stack.Peek();
            top.gameObject.SetActive(true);
        }
        else
        {
            blocker.SetActive(false);
        }
    }
}

메뉴 화면이 나오는 팝업 창을 만들어 Resources 폴더에 추가한다.

public class MenuPopUp : BaseUI
{
    private void Start()
    {
        GetEvent("SettingButton").Click += data => Manager.UI.ShowPopUp<SettingPopUp>();
        GetEvent("QuitButton").Click += data => Manager.UI.ClosePopUp();
    }
}


Presenter(스크립트)와 UI의 이름을 통일시킨다. 굳이 다를 이유가 없다.


요 버튼을 누르면 PopUp되게 해본다.

Home


해당 버튼 UI의 이름은 Setting이다

public class Home : BaseUI
{
    private void Start()
    {
        GetEvent("Setting").Click += data => Manager.UI.ShowPopUp<MenuPopUp>();
    }
}

SettingPopUp

public class SettingPopUp : BaseUI
{
    private void Start()
    {
        GetEvent("GraphicButton").Click += data => Manager.UI.ShowPopUp<GraphicPopUp>();
        GetEvent("SoundButton").Click += data => Manager.UI.ShowPopUp<SoundPopUp>();
        GetEvent("BackButton").Click += data => Manager.UI.ClosePopUp();
    }
}

GraphicPopUp

public class GraphicPopUp : BaseUI
{
    private void Start()
    {
        GetEvent("SaveButton").Click += data => Manager.UI.ClosePopUp();
        GetEvent("CancelButton").Click += data => Manager.UI.ClosePopUp();
    }
}

SoundPopUp

public class SoundPopUp : BaseUI
{
    private void Start()
    {
        GetEvent("SaveButton").Click += data => Manager.UI.ClosePopUp();
        GetEvent("CancelButton").Click += data => Manager.UI.ClosePopUp();
    }
}

팝업 패널의 바깥 부분 눌렀을때

  1. 패널의 바깥에 있는 버튼들이 눌리는 문제가 있다
    편법으로 팝업 캔버스 크기 만큼 이미지를 키워서 하나 넣는다. 이름은 Blocker. 그리고 비활성화. 팝업 캔버스 스크립트에서 참조. 프리팹을 저장한다. AddUI함수에서 블로커 활성화. Remove UI에서 stack.count0일때 비활성화. 이러면 팝업이 있을때는 패널 바깥의 UI들이 안눌린다
  2. 바로 게임으로 돌아가게 만들어보자
  • 이것도 편법이다 Backer 라는 이름으로 버튼을 만들고 캔버스를 꽉차게 만든다. 버튼의 이벤트에 캔버스를 꺼지게 만들면 된다(stack == 0 까지 Pop())

  • 드래그&드롭이 되는 윈도우 창(인벤토리,시스템 등) / WorldToScreen - Overlay / 상시켜져있는 UI(싱글톤) 등으로 캔버스를 나눈다
  • BaseUIDestroy 되지 않게 해야한다.

UI 애니메이션

  • 애니메이터를 달아서 스크립트 상에서 재생시키는 것도 가능하다만...
    Dotween을 쓰는 게 낫다!
// Home
GetEvent("Case Unlock").Enter += data => GetUI<Animator>("Case Unlock").Play("ButtonEnter", 0, 0);
GetEvent("Case Unlock").Click += data => GetUI<Animator>("Case Unlock").Play("ButtonClick", 0, 0);

Aninator.Play
Play(stateName, layer, normalizedTime);

  • layer : 기본값 -1. Base Layer다. 다른 Layer에 있는 애니메이션이면 이 값을 변경하면 된다.
  • normalizedTime : 시작 부분을 정한다. 0.5f 이면 애니메이션 중간까지 생략된다
의미
0f애니메이션 시작 지점부터
0.5f애니메이션의 중간에서
1f애니메이션의 에서 시작 (즉시 끝남)
2f애니메이션을 2번 반복한 시점에서 시작
float.NegativeInfinity현재 진행 상태 유지 (기본값)

도전과제 - RTS 게임

  • 마우스로 드래그 해서 여러 유닛을 선택할 때, 드래그 중 범위 안에 들어온 유닛들은 하이라이트,외곽선 표시 방법
  • 드래그 중에 생기는 사각형 생성 방법
  • 미니맵 클릭 구현방법
profile
개발 박살내자

0개의 댓글