
오늘은 플레이어 구현을 어느정도 마무리하고 UI 시스템을 구축한다.
테스트로 사용하던 Input Manager 대신에 Input System을 사용한다. 플레이어의 여러 액션들을 구현한다.
플레이어 프리팹에 Player Input 컴포넌트를 부착한다. 키보드의 화살표 키로 사용자의 입력값을 받는다. 입력값은 Vector2로 변환된다.
private PlayerInput playerInput;
private Rigidbody rig;
private InputAction attackAction;
private void SetMove()
{
if (inputDir == Vector2.zero)
{
rig.velocity = Vector3.zero;
return;
}
Vector3 moveDir = Vector3.zero;
moveDir.x = inputDir.x;
moveDir.z = inputDir.y;
rig.velocity = moveDir * playerModel.playerSpeed;
}
private void OnMove(InputValue value)
{
inputDir = value.Get<Vector2>();
}
Vector3로 값을 변환해서 플레이어의 Rigidbody에 속력값을 정하는 방식으로 이동을 구현한다.
받은 버튼의 입력을 따로 저장할 수도 있지만, 일단 간단하게 구현한다. 이벤트를 수행하는 방식으로 공격한다.
private void Init()
{
playerInput = GetComponent<PlayerInput>();
attackAction = playerInput.actions["Attack"];
}
private void SubscribeEvents()
{
attackAction.started += Fire;
}
private void UnSubscribeEvents()
{
attackAction.started -= Fire;
}
카메라의 기능 중 WorldToScreenPoint를 응용해서 플레이어가 이동할 수 있는 범위를 제한할 수 있다. 이번 탄막게임의 경우, 좌우에 UI 가 있으므로, 해당 크기또한 고려를 해야 한다.
UI 요소를 불러와서, 그 크기만큼 제한한다. 모든 UI는 RectTransform을 컴포넌트로 가진다. 해당 컴포넌트를 참조해서 width /height 값을 통해 조절할 수 있다.
해상도 기준으로 자른다
뷰포트 기준으로 판별한다. 뷰포트는 x,y값이 0~1 사이이기 때문에 가변적인 해상도에서 범용적으로 쓰일 수 있다.
// UI 참조
[SerializeField] RectTransform leftUI;
[SerializeField] RectTransform rightUI;
// 좌, 우 UI 사이즈
private float leftMargin;
private float rightMargin;
private void Init()
{
if(leftUI!= null)
{
leftMargin = leftUI.rect.width/Camera.main.pixelWidth;
}
else
{
leftMargin = 0.1f;
Debug.LogWarning("왼쪽 UI 참조 안됐음");
}
if (rightUI != null)
{
rightMargin = 1- rightUI.rect.width/Camera.main.pixelWidth;
}
else
{
rightMargin = 0.9f;
Debug.LogWarning("오른쪽 UI 참조 안됐음");
}
}
private void SetMove()
{
Vector2 clampInput = ClampMoveInput(inputDir);
if (clampInput == Vector2.zero)
{
rig.velocity = Vector3.zero;
return;
}
Vector3 moveDir = new Vector3(clampInput.x, 0f, clampInput.y);
rig.velocity = moveDir * playerModel.playerSpeed;
}
private Vector2 ClampMoveInput(Vector2 inputDirection)
{
if (inputDirection == Vector2.zero)
{
return Vector2.zero;
}
// 카메라 기준 스크린 좌표로 판단
//Vector3 screenPos = Camera.main.WorldToScreenPoint(transform.position);
// 플레이어가 카메라 뒤에 있다는 뜻
//if (screenPos.z <= 0) return Vector2.zero;
//if (screenPos.x <= 0 && inputDirection.x < 0) inputDirection.x = 0;
//if (screenPos.x >= Camera.main.pixelWidth && inputDirection.x > 0) inputDirection.x = 0;
//if (screenPos.y <= 0 && inputDirection.y < 0) inputDirection.y = 0;
//if (screenPos.y >= Camera.main.pixelHeight && inputDirection.y > 0) inputDirection.y = 0;
// 뷰포트 기준 좌표로 좀 더 간단화 가능
// 뷰포트는 0~1사이의 값으로만 정해져 있다. 매우 정확하게 떨어지진 않겠지만, 어느정도 커버가 된다
Vector3 viewportPos = Camera.main.WorldToViewportPoint(transform.position);
if(viewportPos.z<=0) return Vector2.zero;
if (viewportPos.x <= leftMargin && inputDirection.x < 0) inputDirection.x = 0;
if (viewportPos.x >= rightMargin && inputDirection.x > 0) inputDirection.x = 0;
if (viewportPos.y <= 0 && inputDirection.y < 0) inputDirection.y = 0;
if (viewportPos.y >= 1 && inputDirection.y > 0) inputDirection.y = 0;
return inputDirection;
}
이전 특강에서 배웠던 기술들을 활용하여 UI를 구현해본다. UI로만 활용되는 씬은 크게 3가지다
팝업을 어떤 부분들에 쓸 것인지는 UI 플로우 차트에 나와있다. 개발 관점에서 구현 해보면서, 팝업 요소를 추가 및 삭제한다. 스크립팅은 이전 UI 특강 부분을 거의 그대로 가져다 쓰기 때문에 생략한다.
세이브 로드 정보를 게임 시작과 함께 불러온다. 정보들을 바탕으로, 세이브 슬롯에 정보가 표시될 수 있게 한다.
CSV를 통해 캐릭터, 장비 등의 아이템들을 스크립터블 오브젝트로 생성하고, 미리 생성해둔 프리팹 상에 해당 정보들을 연결 시킨다. 세이브 파일을 통해 불러오는 정보는 플레이어의 변동 정보들이다. 세이브 되는 필드는 UML을 참고 바람.
세이브에서 불러오는 정보들을 바탕으로, 일부 UI 내용이 결정된다.
세이브 파일을 새로 생성하거나, 기존에 있었던 세이브 파일을 통해 게임으로 접속하면, 메인화면이 등장한다. 파티 교체, 장비, 장비강화, 스테이지 선택 등 여러 가지 일들이 일어나는 가장 큰 씬이다.
상점으로 들어가면 전용 씬으로 이동되며, 여기서는 뽑기가 가능하다.
UI Binding 및 Manager, PopUP 구현 완료
GetOrAddComponent 함수를 쓸 일이 생겨서, Util static 클래스를 생성했다.
public static T GetOrAddComponent<T>(this GameObject go) where T : Component
{
T comp = go.GetComponent<T>();
if (comp == null)
{
comp = go.AddComponent<T>();
}
return comp;
}
// 특수한 기능이 있는 UI를 사용하고자 할 경우, 식별되는 이름을 사용한다.
public class BaseUI : MonoBehaviour
{
private Dictionary<string, GameObject> goDict;
private Dictionary<string, Component> compDict;
private void Awake()
{
RectTransform[] transforms = GetComponentsInChildren<RectTransform>();
goDict = new Dictionary<string, GameObject>(transforms.Length<<2);
foreach (Transform t in transforms)
{
goDict.TryAdd(t.gameObject.name, t.gameObject);
}
Component[] components = GetComponentsInChildren<Component>();
compDict = new Dictionary<string, Component>(components.Length << 2);
foreach (Component comp in components)
{
compDict.TryAdd($"{comp.gameObject.name}_{comp.GetType().Name}",comp);
}
}
// string으로 특정 UI 게임오브젝트 찾기
public GameObject GetUI(in string name)
{
goDict.TryGetValue(name, out GameObject gameObject);
if(gameObject == null)
{
Debug.LogError($"다음 UI 오브젝트가 없습니다: {name}");
}
return gameObject; // 없을경우 Null
}
// string으로 특정 UI 컴포넌트 찾기
public T GetUI<T>(in string name) where T : Component
{
compDict.TryGetValue(name, out Component comp);
if(comp != null)
{
return comp as T;
}
GameObject go = GetUI(name);
if(go == null)
{
return null;
}
comp = go.GetComponent<T>();
if (comp == null) return null;
compDict.TryAdd($"{name}_{typeof(T).Name}", comp);
return comp as T;
}
// UI에 포인터핸들러를 부착 후, 가져오기
public PointerHandler GetEvent(in string name)
{
GameObject go = GetUI(name);
PointerHandler temp = go.GetOrAddComponent<PointerHandler>();
return temp;
}
}
다른 구성원의 할 일을 뺏어온 것이 되었다만, UI Manager가 필요한 상황이라 급하게 구현했다. 모든 매니저는 해당 스크립트를 상속받는다.
public class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
private static T instance;
public static T Instance
{
get
{
if (instance == null)
{
instance = FindObjectOfType<T>();
if (instance != null) return instance;
GameObject go = new GameObject("UIManager");
instance = go.AddComponent<T>();
DontDestroyOnLoad(go);
}
return instance;
}
}
protected virtual void Awake()
{
Init();
}
protected virtual void Init()
{
if (instance == null)
{
instance = this as T;
DontDestroyOnLoad(gameObject);
}
else if (instance != this)
{
Destroy(gameObject);
}
}
protected virtual void OnApplicationQuit()
{
instance = null;
}
public virtual void DestroyManager()
{
if (instance != null)
{
Destroy(gameObject);
}
}
}
Lazy Initialization이 적용됐다.virtual로 구현해서, 상속받는 매니저들이 해당 함수들도 사용할 수 있게 했다. 하위 매니저들은 override를 쓰고, base. 키워드로 부모의 함수를 먼저 수행하면 된다.OnApplicationQuit
만약, 게임 종료 시 매니저를 참조하는 코드가 있을 경우 문제가 생길 수 있다. 해당 문제를 해결하기 위해 게임 종료 시instance를null로 바꾼다.
특강 때 스크립트와 다른점은 없다.
팝업되는 UI를 스택으로 관리한다.
public class PopUpUI : MonoBehaviour
{
// TODO:
// 팝업 되면 팝업 판넬 범위 바깥을
// 1. 클릭하지 못하게 막거나,
// 2. 클릭 시 팝업 이전으로 돌아가게 한다.
private Stack<BaseUI> stack = new Stack<BaseUI>();
public void PushUIStack(BaseUI ui)
{
if (stack.Count > 0)
{
BaseUI top = stack.Peek();
top.gameObject.SetActive(false);
}
stack.Push(ui);
//blocker.SetActive(true);
}
public void PopUIStack()
{
if(stack.Count<=0) return;
Destroy(stack.Pop().gameObject);
if(stack.Count >0)
{
BaseUI top = stack.Peek();
top.gameObject.SetActive(true);
}
else
{
//blocker.SetActive(false);
}
}
}
추가적인 작업이 필요하다.
Blocker : 팝업이 띄워졌을 때는 팝업 범위 밖은 입력을 막는다.Canceler : 팝업이 띄워졌을 때, 팝업 범위 밖을 입력하면 팝업이 전부 닫힌다.팝업을 어느때나 불러오기 위해 싱글톤으로 구현됐다. BaseUI를 상속받는 PopUpUI를 관리한다.
public class UIManager : Singleton<UIManager>
{
[SerializeField] string popUpPath = "JYL/UI/Canvas_PopUp";
[SerializeField] string prefabPath = "JYL/UI";
private PopUpUI popUp;
public PopUpUI PopUp
{
get
{
if (popUp == null)
{
popUp = FindObjectOfType<PopUpUI>();
if (popUp != null) return popUp;
PopUpUI prefab = Resources.Load<PopUpUI>(popUpPath);
if (prefab == null)
{
Debug.LogWarning($"해당 경로에 팝업 프리팹이 없음: {popUpPath}");
return null;
}
return Instantiate(prefab);
}
return popUp;
}
}
protected override void Awake() => base.Awake();
// 팝업 UI를 꺼낸다
public T ShowPopUp<T>() where T : BaseUI
{
string path = $"{prefabPath}/{typeof(T).Name}";
T prefab = Resources.Load<T>(path);
if (prefab == null)
{
Debug.LogWarning($"해당 경로에 팝업 프리팹 없음: {path}");
return null;
}
T instance = Instantiate(prefab, PopUp.transform);
PopUp.PushUIStack(instance);
return instance;
}
public void ClosePopUp()
{
PopUp.PopUIStack();
}
}
public class HomeUI : BaseUI
{
void Start()
{
GetUI<TMP_Text>("OpenMenuText").text = "Open Menu";
GetEvent("MenuBtn").Click += data => UIManager.Instance.ShowPopUp<MenuPopUp>();
}
}
Menu가 나타나게 해본다.public class MenuPopUp : BaseUI
{
void Start()
{
GetUI<TMP_Text>("BackBtnText").text = "Close";
GetEvent("BackBtn").Click += data => UIManager.Instance.ClosePopUp();
}
}
텍스트도 원하는 대로 바뀌었고, 클릭 시 메뉴 화면이 나온다.
주의점
프리팹으로 저장할 때, 캔버스를 포함해서 저장하면 안된다. UI 요소만 프리팹화 해서, 프리팹에MenuPopUp과 같이 스크립트를 작성해서 부착해야 한다.