포스트

섹션7. UI

위 글은 인프런에 있는 Rookiss님의 [C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part3: 유니티 엔진 강의를 듣고 남긴 필기입니다.

RectTransform

  • UI 요소는 Transform 대신 Rect Transform을 사용한다.
  • 화면의 해상도유동적으로 대응하는 UI를 만들기 위해서는 Anchor의 활용이 필수적.
    1. RectTransform Component를 갖는 부모가 있어야 함.
    2. 대상 UI요소를 그 부모 오브젝트를 기준으로 어디에, 어떠한 비율로 배치할지를 결정하는 개념.
    3. 부모 오브젝트의 가로, 세로 비율에 대해서 영향을 받음.

Untitled (6)

부모 오브젝트의 가로폭이 줄어듬에 따라, 대상 UI 요소인 Button의 가로 폭도 줄어들었다.

부모 오브젝트의 가로폭이 줄어듬에 따라, 대상 UI 요소인 Button의 가로 폭도 줄어들었다.

  • 앵커의 위치는 부모 오브젝트와의 거리 비율에 기반해 정해진다.
    • (파란 영역 / 가로 : 25%, 50%, 25% / 세로 : 26%, 48%, 26% 비율)
  • 해당 UI 요소의 네 모서리(파란 점) 위치는 앵커로부터 고정된 거리에 기반해 정해진다.
    • (노란 영역 / 고정된 크기)

Button Event

버튼을 클릭할 때, 캐릭터가 움직이는 현상을 다음의 코드로 방지함.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class InputManager
{
    //...

    public void OnUpdate()
    {
        if (EventSystem.current.IsPointerOverGameObject())  // UI가 클릭된 상황이라면 동작 X
            return;

        //...
    }
        
    //...
}

UI 자동화 - Bind

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//UI에 부착하는 스크립트 내부
private Dictionary<Type, UnityEngine.Object[]> _objects = new Dictionary<Type, UnityEngine.Object[]>();

//Bind 함수
void Bind<T>(Type type) where T : UnityEngine.Object
{
    string[] names = Enum.GetNames(type);
    UnityEngine.Object[] objects = new UnityEngine.Object[names.Length];
    _objects.Add(typeof(T), objects);

    for (int i = 0; i < names.Length; i++)
    {
        objects[i] = Util.FindChild<T>(gameObject, names[i], true);
    }
}
  • C#의 리플렉션을 활용해 UI 요소들을 Dictionary를 활용해 관리.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 기능성 함수들을 넣어두는 클래스인 Utils 내부의 FindChild 함수

/// <summary>
/// 부모 GameObject의 자식 GameObject 중에서 이름과 타입에 맞는 자식을 찾아 반환하는 함수
/// </summary>
/// <param name="go">부모 오브젝트</param>
/// <param name="name">찾고자하는 대상 GameObject의 이름 (선택, 기본 null)</param>
/// <param name="recursive"> 자식의 자식까지도 찾을 것인지 (선택, 기본 false)</param>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public static T FindChild<T>(GameObject go, string name = null, bool recursive = false) where T : UnityEngine.Object
{
    if (go == null)
        return null;
    if (recursive)        // 재귀 O
    {
        foreach (T component in go.GetComponentsInChildren<T>())
        {
            if (string.IsNullOrEmpty(name) ||component.name == name)
                // 이름이 비어있거나 찾던 이름과 동일하면
                return component;
        }
    }
    else        // 재귀가 X (= 직속 자식에 한해서만 찾는 경우)
    {
        for (int i = 0; i < go.transform.childCount; i++)
        {
            Transform transform = go.transform.GetChild(i);
            if (string.IsNullOrEmpty(name) || transform.name == name)
            // 이름이 비어있거나 찾던 이름과 동일하면
            {
                T component = transform.GetComponent<T>();
                if (component != null)
                    return component;
            }
        }
    }
    return null;
}
  • 위와 같은 두 함수를 이용해, UI 스크립트에서 Awake() 혹은 Start()와 같이 초반에 수행되는 함수안에서 Bind<Button>() 처럼 Bind 함수를 이용해 enum 과 UI 요소의 매핑을 수행.
  • 이후에는 Get 함수를 이용해서 관리할 수 있음.

UI 자동화 - Get

1
2
3
4
5
6
7
8
T Get<T>(int idx) where T : UnityEngine.Object
{
    UnityEngine.Object[] objects = null;
    if (!_objects.TryGetValue(typeof(T), out objects)) 
        return null;

    return objects[idx] as T;
}
  • Bind를 거친 enum에 대해서 위 Get함수를 통해 해당 UI요소의 컴포넌트에 접근할 수 있다.

UI 자동화 - UI_Base

  • 다음과 같은 클래스를 만들고 UI 스크립트가 모두 UI_Base 클래스를 상속받게 함으로써, Bind와 Get을 편리하게 사용할 수 있도록 구조를 정립함.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class UI_Base : MonoBehaviour
{
    // enum들과 UI요소의 매핑을 위한 Dictionary.
    private Dictionary<Type, UnityEngine.Object[]> _objects = new Dictionary<Type, UnityEngine.Object[]>();

    protected void Bind<T>(Type type) where T : UnityEngine.Object
    {
        string[] names = Enum.GetNames(type);
        UnityEngine.Object[] objects = new UnityEngine.Object[names.Length];
        _objects.Add(typeof(T), objects);

        for (int i = 0; i < names.Length; i++)
        {
            if(typeof(T) == typeof(GameObject))     // GameObject 전용 바인딩
                objects[i] = Util.FindChild(gameObject, names[i], true);
            else
                objects[i] = Util.FindChild<T>(gameObject, names[i], true);
            
            if(objects[i] == null)
                Debug.Log($"Failed to Bind! ({names[i]})");
        }
    }

    T Get<T>(int index) where T : UnityEngine.Object
    {
        UnityEngine.Object[] objects = null;
        if (!_objects.TryGetValue(typeof(T), out objects)) 
            return null;

        return objects[index] as T;
    }

    protected TextMeshProUGUI GetText(int index)
    {
        return Get<TextMeshProUGUI>(index);
    }
    
    protected Button GetButton(int index)
    {
        return Get<Button>(index);
    }
    
    protected Image GetImage(int index)
    {
        return Get<Image>(index);
    }
}

UI 자동화 - 이벤트 연동

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// UI 요소와 마우스 간의 상호작용을 위해 다음의 클래스를 정의.
// UI_EventHandler 스크립트가 부착된 UI 요소만이 마우스와 상호작용할 수 있다.

public class UI_EventHandler : MonoBehaviour, IPointerClickHandler, IDragHandler
{
    // EventSystem이 클릭, 드래그와 같은 입력을 감지했을 때,
    // 이를 캐치해서 콜백으로 날려주기

    public Action<PointerEventData> OnClickHandler = null;
    public Action<PointerEventData> OnDragHandler = null;

    public void OnPointerClick(PointerEventData eventData)
    {
        if (OnClickHandler != null)
            OnClickHandler.Invoke(eventData);
    }
    
    public void OnDrag(PointerEventData eventData)
    {
        if (OnDragHandler != null)
            OnDragHandler.Invoke(eventData);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// UI_Base 클래스 아래에 다음과 같은 AddUIEvent함수를 추가
// Define 클래스 아래에서 enum UIEvent로 클릭과 드래그를 구분

public static void AddUIEvent(GameObject go, Action<PointerEventData> action, Define.UIEvent type = Define.UIEvent.Click)
{
    UI_EventHandler _event = Util.GetOrAddComponent<UI_EventHandler>(go);

    switch (type)
    {
        case Define.UIEvent.Click:
            _event.OnClickHandler -= action;
            _event.OnClickHandler += action;
            break;
        case Define.UIEvent.Drag:
            _event.OnDragHandler -= action;
            _event.OnDragHandler += action;
            break;
    }
}
1
2
3
4
5
6
7
8
// Util 클래스 아래에서 다음과 같은 함수를 정의
public static T GetOrAddComponent<T>(GameObject go) where T : UnityEngine.Component
{
    T component = go.GetComponent<T>();
    if (component == null)
        component = go.AddComponent<T>();
    return component;
}
1
2
3
4
5
6
7
8
9
// C#의 Extension 기능을 활용하는 클래스 정의
public static class Extension
{
    // 다음과 같이 Extension 함수를 정의하여 코드 가독성을 더욱 향상시킨다.
    public static void AddUIEvent(this GameObject go, Action<PointerEventData> action, Define.UIEvent type = Define.UIEvent.Click)
    {
        UI_Base.AddUIEvent(go,action, type);
    }
}

결과적으로 UI 스크립트에서 다음과 같은 이벤트 바인딩을 수행할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void Start()
{
    Bind<Button>(typeof(Buttons));
    Bind<TextMeshProUGUI>(typeof(Texts));
    Bind<Image>(typeof(Images));
    Bind<GameObject>(typeof(GameObjects));

    // Button 클릭 함수 바인딩
    GetButton((int)Buttons.PointButton).gameObject.AddUIEvent(OnButtonClicked);

    // 마우스 드래그로 UI 요소의 위치를 조정
    GameObject go = GetImage((int)Images.ItemIcon).gameObject;
    AddUIEvent(go, (PointerEventData data) => { go.transform.position = data*.position; }, Define.UIEvent.Drag);*
}

public void OnButtonClicked(PointerEventData data)  {...}  // 이때, 바인딩되는 함수에는 PointerEventData data 매개변수가 있어야 한다.

UIManager

  • UI를 관리하는 UIManager를 만듦.
  • 팝업용 UIUI_PopupScene의 기본적인 UIUI_Scene을 각각 구분한다.

UIManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
public class UIManager
{
    // UI Popup의 order를 관리하는 기능
    private int _order = 10;
    
    // 팝업 목록을 저장 -> Stack 형태로
    private Stack<UI_Popup> _popupStack = new Stack<UI_Popup>();
    UI_Scene _sceneUI = null;

    public GameObject Root
    {
        get
        {
            GameObject root = GameObject.Find("@UI_Root");
            if (root == null)
                root = new GameObject { name = "@UI_Root" };
            return root;
        }
    }
    
    /// <summary>
    /// Canvas 설정
    /// </summary>
    /// <param name="go"></param>
    /// <param name="sort">PopupSystem과 연관이 없는 일반 Popup이라면 false</param>
    public void SetCanvas(GameObject go, bool sort = true)
    {
        Canvas canvas = Util.GetOrAddComponent<Canvas>(go);
        canvas.renderMode = RenderMode.ScreenSpaceOverlay;
        canvas.overrideSorting = true;
        if (sort)
        {
            canvas.sortingOrder = _order;
            _order++;
        }
        else    // PopupSystem과 연관이 없는 일반 Popup 
        {
            canvas.sortingOrder = 0;
        }
    }
        
    public T ShowSceneUI<T>(string name = null) where T : UI_Scene
    {
        if (string.IsNullOrEmpty(name))
            name = typeof(T).Name;

        GameObject go = Managers.Resource.Instantiate($"UI/Scene/{name}");
        T sceneUI = Util.GetOrAddComponent<T>(go);
        _sceneUI = sceneUI;
        
        go.transform.SetParent(Root.transform);
        
        return sceneUI;
    }
    
    /// <summary>
    /// 팝업을 띄우는 메서드
    /// </summary>
    /// <param name="name">UI Prefab의 이름 (선택) </param>
    /// <typeparam name="T">UI_Popup 타입</typeparam>
    /// <returns></returns>
    public T ShowPopupUI<T>(string name = null) where T : UI_Popup
    {
        if (string.IsNullOrEmpty(name))
            name = typeof(T).Name;

        GameObject go = Managers.Resource.Instantiate($"UI/Popup/{name}");
        T popup = Util.GetOrAddComponent<T>(go);
        _popupStack.Push(popup);
        
        go.transform.SetParent(Root.transform);
        
        return popup;
    }

    /// <summary>
    /// Stack의 가장 상단에 있는 popup을 지운다.
    /// </summary>
    public void ClosePopupUI()
    {
        // Stack을 건드릴때는 항상 팝업을 건드리는 것을 습관화하자.
        if (_popupStack.Count == 0)
            return;

        UI_Popup popup = _popupStack.Pop();
        Managers.Resource.Destroy(popup.gameObject);
        popup = null;

        _order--;
    }
    
    /// <summary>
    /// ClosePopupUI의 좀 더 안전한 버전
    /// </summary>
    /// <param name="popup"></param>
    public void ClosePopupUI(UI_Popup popup)
    {
        if (_popupStack.Count == 0)
            return;

        if (_popupStack.Peek() != popup)
        {
            Debug.Log("Close Popup Failed!");
            return;
        }
        
        ClosePopupUI();
    }

    public void CloseAllPopupUI()
    {
        while (_popupStack.Count > 0)
            ClosePopupUI();
    }

}

UI_Popup

1
2
3
4
5
6
7
8
9
10
11
12
public class UI_Popup : UI_Base
{
    public virtual void Init()
    {
        Managers.UI.SetCanvas(gameObject, true);
    }

    public virtual void ClosePopupUI()
    {
        Managers.UI.ClosePopupUI(this);
    }
}

UI_Button

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class UI_Button : UI_Popup
{
    //...
 
    private void Start()
    {
        Init();
    }

    public override void Init()
    {
        base.Init();    // UI_Popup의 Init도 호출하도록
        
        Bind<Button>(typeof(Buttons));
        Bind<TextMeshProUGUI>(typeof(Texts));
        Bind<Image>(typeof(Images));
        Bind<GameObject>(typeof(GameObjects));

        GetButton((int)Buttons.PointButton).gameObject.AddUIEvent(OnButtonClicked);

        GameObject go = GetImage((int)Images.ItemIcon).gameObject;
        AddUIEvent(go, (PointerEventData data) => { go.transform.position = data.position; }, Define.UIEvent.Drag);
    }
    
    //...

}

UI_Scene

1
2
3
4
5
6
7
public class UI_Scene : UI_Base
{
    public virtual void Init()
    {
        Managers.UI.SetCanvas(gameObject, false);
    }
}

Inventory

  • UI_InvenUI_Inven_Item 을 만듦

    Untitled (8)

    Untitled (9)

    UI_Inven아래의 PanelGridLayoutGroup 컴포넌트를 붙여, 각각의 item들을 격자 형태로 배치

  • Scripts > UI > Scene 아래에 UI_Inven.csScripts > UI > SubItem 아래에 UI_Inven_Item.cs 를 만듦

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class UI_Inven : UI_Scene
{
    enum GameObjects
    {
        GridPanel,
    }

    private void Start()
    {
        Init();
    }

    public override void Init()
    {
        base.Init();        // UI_Scene의 Init을 호출

        Bind<GameObject>(typeof(GameObjects));

        GameObject gridPanel = Get<GameObject>((int)GameObjects.GridPanel);
        foreach (Transform child in gridPanel.transform)        // GridPanel의 모든 자식들을 순회하는 코드
            Managers.Resource.Destroy(child.gameObject);

        // 실제 인벤토리 정보를 참고해서
        for (int i = 0; i < 8; i++)
        {
            GameObject item = Managers.UI.MakeSubItem<UI_Inven_Item>(parent : gridPanel.transform).gameObject;
            
            UI_Inven_Item invenItem = item.GetOrAddComponent<UI_Inven_Item>();
            invenItem.SetInfo($"집행검 {i}번");
        }
    }
    
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class UI_Inven_Item : UI_Base
{
    enum GameObjects
    {
        ItemIcon,
        ItemNameText,
    }

    private string _name;
    
    private void Start()
    {
        Init();
    }

    public override void Init()
    {
        Bind<GameObject>(typeof(GameObjects));

        Get<GameObject>((int)GameObjects.ItemNameText).GetComponent<TextMeshProUGUI>().text = _name;
        Get<GameObject>((int)GameObjects.ItemIcon).AddUIEvent((pointerEventData) => {Debug.Log($"Item Clicked! {_name}");});
    }

    public void SetInfo(string name)
    {
        _name = name;
    }

}

UIManager.cs 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
// 다음의 함수를 추가
public T MakeSubItem<T>(Transform parent = null, string name = null) where T : UI_Base
{
    if (string.IsNullOrEmpty(name))
        name = typeof(T).Name;

    GameObject go = Managers.Resource.Instantiate($"UI/SubItem/{name}");
    
    if(parent != null)
        go.transform.SetParent(parent);
        
    return Util.GetOrAddComponent<T>(go);
}
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.