세부 사항
-게임 한 라운드 종료 시, 승리자에 대한 정보 및 승패 횟수를 UI로 띄움
-게임의 한 라운드가 완전히 종료했을 때, 게임 승패 횟수를 이벤트로 UI 표시
-게임이 완전히 종료했을 때 재시작 UI 표시
-플레이어가 스킬을 획득했을 때, 해당 스킬 아이콘을 UI로 표시하는 기능
UI를 만드는 과정은 어렵다기 보다는 순전히 노가다의 영역이다 보니, 어려은 부분이 있었다기 보다는 작업 시간이 오래 걸리고 코드가 복잡해졌다는 아쉬움 정도만 있다.
전체적인 작업 내용 및 구조를 살피기에 앞서 간단하게 헤매면서 배웠던 부분에 대해 알아보고자 한다.
우측 상단에 이와 같이 오른쪽부터 카드가 쌓이는 구조를 만들기 위해, Horizontal Layout Group을 활용하였다. 여기서, 오른쪽부터 누적되어 아이콘이 쌓이는 구조를 만들기 위해선 이와 같이 만들어주어야 한다.
게임의 UI를 구성하기 위해서 가장 확실한 방법은, 각 UI를 컨트롤하는 스크립트를 각각 구성하고, 전체적인 UI활성화 및 비활성화와 관련된 부분을 부모 오브젝트로 두는 방식이다.
내가 구현해야 할 UI 종류는 크게 3가지였다.
또한 UI를 테스트하기 위한 임시 게임 매니저 역할을 하는 녀석도 만들다 보니 제법 작업량이 많았다.
전체적인 구성 방식은 다음과 같다.
랜덤 맵 생성기와 카메라 이동에 관한 부분은 기존에 구성했던 것이다. 어차피 인게임 시스템에 포함되는 UI는 이것의 하위 오브젝트로 넣어도 될 것이라 판단했다. 아무래도 카메라와 같이 연동되는 UI를 다루는 데 편리함을 주기 위해서였다.
UI에 대한 상세 설명을 하기에 앞서, UI에서의 승패 및 카드 정보 등 다양한 정보를 담아두면서 변화를 주기 위한 임시 매니저를 구성했다. 이는 나중에 네트워크 연결을 하면서 다른 매니저로 대체할 예정이다.
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
public class TestIngameManager : MonoBehaviour
{
#region Singleton
public static TestIngameManager Instance { get; private set; }
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
Init();
}
#endregion
public static event Action OnRoundOver;
public static event Action OnGameSetOver;
public static event Action OnSkillObtained;
private bool isRoundOver = false;
private bool isGameSetOver = false;
private bool isGameOver = false;
public bool IsGameOver { get { return isGameOver; } }
private Dictionary<string, int> playerRoundScore = new Dictionary<string, int>();
private Dictionary<string, int> playerGameScore = new Dictionary<string, int>();
private string currentWinner;
// 테스트용
private List<string> leftPlayerSkill = new List<string>();
private List<string> rightPlayerSkill = new List<string>();
private void Init()
{
playerRoundScore.Add("Left", 0);
playerRoundScore.Add("Right", 0);
playerGameScore.Add("Left", 0);
playerGameScore.Add("Right", 0);
}
void Update()
{
// 테스트용 코드
if(Input.GetKeyDown(KeyCode.R))
{
RoundOver("Right");
}
if(Input.GetKeyDown(KeyCode.E))
{
RoundOver("Left");
}
if(Input.GetKeyDown(KeyCode.T))
{
ObtainSkill("Left", "1");
}
if(Input.GetKeyDown(KeyCode.Y))
{
ObtainSkill("Right", "2");
}
}
public string ReadScore(out int leftScore, out int rightScore)
{
leftScore = playerRoundScore["Left"];
rightScore = playerRoundScore["Right"];
return currentWinner;
}
public int ReadRoundScore(out int rightScore)
{
rightScore = playerGameScore["Right"];
return playerGameScore["Left"];
}
public void GameStart()
{
isRoundOver = false;
isGameSetOver = false;
isGameOver = false;
playerRoundScore["Left"] = 0;
playerRoundScore["Right"] = 0;
playerGameScore["Left"] = 0;
playerGameScore["Right"] = 0;
}
public void RoundStart()
{
isRoundOver = false;
}
private void RoundOver(string winner)
{
isRoundOver = true;
playerRoundScore[winner] += 1;
currentWinner = winner;
if(playerRoundScore["Right"] >=2)
{
GameSetOver("Right");
}
else if (playerRoundScore["Left"] >= 2)
{
GameSetOver("Left");
}
Debug.Log(winner);
OnRoundOver?.Invoke();
}
public void GameSetStart()
{
isGameSetOver = false;
if (playerRoundScore["Right"] >= 2 || playerRoundScore["Left"] >= 2)
{
playerRoundScore["Left"] = 0;
playerRoundScore["Right"] = 0;
}
}
private void GameSetOver(string winner)
{
isGameSetOver = true;
playerGameScore[winner] += 1;
currentWinner = winner;
OnGameSetOver?.Invoke();
if (playerGameScore["Left"] >= 3 || playerGameScore["Right"] >= 3)
{
GameOver();
}
}
private void GameOver()
{
isGameOver = true;
}
#region TestCode - Card
public string[] GetSkillInfo(string player)
{
if(player == "Left")
{
string[] skillInfo = new string[leftPlayerSkill.Count];
for (int i = 0; i < leftPlayerSkill.Count; i++)
{
skillInfo[i] = leftPlayerSkill[i];
}
return skillInfo;
}
else if(player == "Right")
{
string[] skillInfo = new string[rightPlayerSkill.Count];
for (int i = 0; i < rightPlayerSkill.Count; i++)
{
skillInfo[i] = rightPlayerSkill[i];
}
return skillInfo;
}
return null;
}
public void ObtainSkill(string player, string skill)
{
if(player == "Left")
{
leftPlayerSkill.Add(skill);
Debug.Log(skill);
}
else if(player == "Right")
{
rightPlayerSkill.Add(skill);
Debug.Log(skill);
}
OnSkillObtained?.Invoke();
}
#endregion
}
Static은 아닌 인게임 캔버스이다. 여기에서 게임의 상태에 따른 UI의 활성화와 비활성화 여부를 결정한다.
using UnityEngine;
public class IngameUIManager : MonoBehaviour
{
[SerializeField] GameObject roundOverPanel;
[SerializeField] GameObject gameRestartPanel;
private void OnEnable()
{
TestIngameManager.OnRoundOver += RoundOverPanelShow;
}
private void OnDisable()
{
TestIngameManager.OnRoundOver -= RoundOverPanelShow;
}
private void RoundOverPanelShow()
{
roundOverPanel.SetActive(true);
}
public void HideRoundOverPanel()
{
roundOverPanel.SetActive(false);
}
public void RestartPanelShow()
{
gameRestartPanel.SetActive(true);
}
public void HideRestartPanel()
{
gameRestartPanel.SetActive(false);
}
}
한 라운드의 종료 시 UI의 상태를 반영하는 스크립트이다. 이미지의 FillAmount를 이용하여 반씩 채워지는 방식으로 구성했다.
using System.Collections;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class RoundOverPanelController : MonoBehaviour
{
[Header("Reference")]
[SerializeField] private IngameUIManager gameUIManager;
[Header("UI")]
[SerializeField] private TMP_Text winnerText;
[SerializeField] private Image leftImage;
[SerializeField] private Image LeftFillImage;
[SerializeField] private Image rightImage;
[SerializeField] private Image RightFillImage;
Color textColor;
string leftTextColor = "#FF8400";
string rightTextColor = "#009EFF";
private Coroutine shrink;
private void OnEnable()
{
Init();
}
private void Init()
{
leftImage.rectTransform.localScale = Vector3.one;
rightImage.rectTransform.localScale = Vector3.one;
string winner = TestIngameManager.Instance.ReadScore(out int left, out int right);
TextInit(winner, left, right);
ImageInit(left, right);
}
private void TextInit(string winner, int left, int right)
{
if(winner == "Left")
{
if (ColorUtility.TryParseHtmlString(leftTextColor, out textColor))
{
winnerText.color = textColor;
}
if(left == 1)
{
winnerText.text = "Half Orange";
}
else if (left == 2)
{
winnerText.text = "Round Orange";
}
}
else if (winner == "Right")
{
if (ColorUtility.TryParseHtmlString(rightTextColor, out textColor))
{
winnerText.color = textColor;
}
if (right == 1)
{
winnerText.text = "Half Blue";
}
else if (right == 2)
{
winnerText.text = "Round Blue";
}
}
}
private void ImageInit(int left, int right)
{
if(left == 0) LeftFillImage.fillAmount = 0;
else LeftFillImage.fillAmount = (float)left / 2;
if(right == 0) RightFillImage.fillAmount = 0;
else RightFillImage.fillAmount = (float)right / 2;
}
public void ShrinkImage()
{
shrink = StartCoroutine(ShrinkCoroutine());
}
IEnumerator ShrinkCoroutine()
{
float postDelay = 0.05f;
float elapsedTime = 0;
while(elapsedTime < postDelay)
{
elapsedTime += Time.deltaTime;
float t = 1 - elapsedTime / postDelay;
leftImage.rectTransform.localScale = new Vector3(t, t, t);
rightImage.rectTransform.localScale = new Vector3(t, t, t);
yield return null;
}
leftImage.rectTransform.localScale = new Vector3(0, 0, 0);
rightImage.rectTransform.localScale = new Vector3(0, 0, 0);
gameUIManager.HideRoundOverPanel();
shrink = null;
}
}
게임 재시작 UI이다. 아주 간단하다, 버튼에 대한 리스너 반응을 구현해야 한다. 다만 아직 씬 전환이나 이런 부분은 아직 연결할 수 없다 보니 테스트용으로만 로그를 찍을 수 있게 해 놓았다.
using UnityEngine;
using UnityEngine.UI;
public class GameRestartPanelController : MonoBehaviour
{
[SerializeField] Button yesButton;
[SerializeField] Button noButton;
private void Awake()
{
yesButton.onClick.AddListener(RestartGame);
noButton.onClick.AddListener(EndGame);
}
private void RestartGame()
{
TestIngameManager.Instance.GameStart();
Debug.Log("게임 재시작");
// TODO : 카드 선택 화면으로 이동
}
private void EndGame()
{
Debug.Log("게임 종료");
// TODO : 메인 화면으로 이동
}
}
스코어 UI이다. 게임의 상태 정보를 받아오고, 이벤트를 통해 승패의 변화가 생겼을 때 비활성화된 이미지를 활성화시켜 덮는 방식으로 UI를 구현했다.
using UnityEngine;
using UnityEngine.UI;
public class StaticScoreUI : MonoBehaviour
{
[SerializeField] GameObject[] leftWinImages;
[SerializeField] GameObject[] rightWinImages;
private void OnEnable()
{
Init();
TestIngameManager.OnGameSetOver += ScoreChange;
}
private void OnDisable()
{
TestIngameManager.OnGameSetOver -= ScoreChange;
}
private void Init()
{
for(int i = 0; i < leftWinImages.Length; i++)
{
leftWinImages[i].SetActive(false);
rightWinImages[i].SetActive(false);
}
}
private void ScoreChange()
{
int leftWinNum = TestIngameManager.Instance.ReadRoundScore(out int rightWinNum);
for(int i = 0; i < leftWinNum; i++)
{
leftWinImages[i].SetActive(true);
}
for(int i = 0; i < rightWinNum; i++)
{
rightWinImages[i].SetActive(true);
}
}
}
스킬 정보 UI이다. 카드로 스킬을 획득할 때마다 미리 만들어 둔 프리팹을 Instantiate 시켜서 해당 오브젝트에 배치하는 방식으로 구성했다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class StaticCardInfoUI : MonoBehaviour
{
[SerializeField] GameObject UIImagePrefab;
[SerializeField] Transform leftPanelContent;
[SerializeField] Transform rightPanelContent;
[SerializeField] List<GameObject> leftUIImage = new List<GameObject>();
[SerializeField] List<GameObject> rightUIImage = new List<GameObject>();
string[] leftCardList;
string[] rightCardList;
private void OnEnable()
{
TestIngameManager.OnSkillObtained += ShowCardList;
}
private void OnDisable()
{
TestIngameManager.OnSkillObtained -= ShowCardList;
}
private void ShowCardList()
{
leftCardList = TestIngameManager.Instance.GetSkillInfo("Left");
rightCardList = TestIngameManager.Instance.GetSkillInfo("Right");
if(leftCardList.Length > leftUIImage.Count)
{
for(int i = leftUIImage.Count; i < leftCardList.Length; i++)
{
GameObject skillInfo = Instantiate(UIImagePrefab);
skillInfo.transform.SetParent(leftPanelContent);
// TODO : 카드 정보 입력
leftUIImage.Add(skillInfo);
}
}
if(rightCardList.Length > rightUIImage.Count)
{
for (int i = rightUIImage.Count; i < rightCardList.Length; i++)
{
GameObject skillInfo = Instantiate(UIImagePrefab);
skillInfo.transform.SetParent(rightPanelContent);
// TODO : 카드 정보 입력
rightUIImage.Add(skillInfo);
}
}
}
}
팀장님이 다음 맵 제작에 대한 결정을 내렸다.
그래도 좀 만들기 간단한 맵이어서 금방 만들 수 있을 것 같다.
인게임 UI는 지금 당장 작동하는지에만 초점을 맞춰 구성한 방식이다. 다만 팀장님은 좀 더 디테일을 살리고, 보완해야 할 목록을 정리해주었다.
왼쪽 UI 크기 줄이기 - 반투명 중심색깔 약간 검정색
씬 넘어가는거 애니메이션 처리 -> 오브젝트 옮길 수 있는지 확인
그 외에 디테일 같은거 원 같은거 함 해보기
카드쪽 UI도 내가 하게 될 가능성이 있다... 참고
리팩토링을 후순위에 둔 것은 DoTween 때문이다. DoTween을 쓰면은 지금 상황에서의 구조를 어느정도 갈아엎어야 할 수도 있기 때문에, 해당 작업을 완료한 후 리팩토링도 해보자.