위 글은 인프런에 있는 Rookiss님의 [C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part3: 유니티 엔진 강의를 듣고 남긴 필기입니다.
- UI 요소는 Transform 대신 Rect Transform을 사용한다.
- 화면의
해상도
에 유동적
으로 대응하는 UI를 만들기 위해서는 Anchor의 활용
이 필수적.RectTransform Component
를 갖는 부모가 있어야 함.- 대상 UI요소를 그 부모 오브젝트를 기준으로
어디에, 어떠한 비율로 배치할지
를 결정하는 개념. - 부모 오브젝트의 가로, 세로 비율에 대해서 영향을 받음.
부모 오브젝트의 가로폭이 줄어듬에 따라, 대상 UI 요소인 Button의 가로 폭도 줄어들었다.
- 앵커의 위치는 부모 오브젝트와의 거리 비율에 기반해 정해진다.
- (파란 영역 / 가로 : 25%, 50%, 25% / 세로 : 26%, 48%, 26% 비율)
- 해당 UI 요소의 네 모서리(파란 점) 위치는 앵커로부터 고정된 거리에 기반해 정해진다.
버튼을 클릭할 때, 캐릭터가 움직이는 현상을 다음의 코드로 방지함.
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를 만듦.
팝업용 UI
인 UI_Popup
과 Scene의 기본적인 UI
인 UI_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();
}
}
|
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);
}
}
|
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_Inven
과 UI_Inven_Item
을 만듦
UI_Inven
아래의 Panel
에GridLayoutGroup
컴포넌트를 붙여, 각각의 item
들을 격자 형태로 배치
Scripts > UI > Scene
아래에 UI_Inven.cs
와 Scripts > 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);
}
|