카드 짝 맞추기 게임에서 짝을 맞춰야 할 카드를 배치하는 기능입니다.
여기서 그냥 카드를 각 위치에 맞게 배치해줘도 되지만,
마치 카드게임(블랙잭, 하스스톤 등)처럼 카드가 일정한 애니메이션으로 분배되도록 만들고 싶었습니다.
그 결과 위와 같이 좌표 값 x: 0, y : 0 에서 뭉쳐있는 카드가 각각의 자리로 분배되어 보이도록 만들었습니다.
public class CardManager : MonoBehaviour
{
public GameObject card;
float timer;
int cardsLeft;
private bool isCardGenerated; // 카드가 분배 되었는지 확인하기 위한 bool값
Dictionary<GameObject, Vector3> cardList = new Dictionary<GameObject, Vector3>(); // Generated 할 카드 오브젝트와 분배할 위치를 Dictionary에 저장
[HideInInspector] public GameObject firstCard;
[HideInInspector] public GameObject secondCard;
void Start()
{
// Start() 시에는 false로 값을 할당합니다.
// GenerateCard() 함수가 호출 되어야 분배될 카드 오브젝트가 생성 된 것입니다.
// GenerateCardMoveToTarget() 코루틴 함수를 통해 전부 분배가 되어야 true로 변경됩니다.
isCardGenerated = false;
}
void Update()
{
if(firstCard != null) {
timer += Time.deltaTime;
if(timer >= 5) {
firstCard.GetComponent<Card>().CloseCard();
firstCard = null;
timer = 0;
}
}
// 카드가 분배가 완료되지 않았으면 GenerateCardMoveToTarget() 코루틴을 호출합니다.
// GenerateCardMoveToTarget() 함수는 매개변수로 WaitSeconds를 입력받습니다.
if (isCardGenerated == false)
{
StartCoroutine(GenerateCardMoveToTarget(0.03f));
}
}
// 카드 오브젝트를 생성해 카드 묶음 Dictionary에 Init 하는 함수입니다.
// 분배를 해야 할 위치를 계산해 Dictionary에 추가합니다.
public void GenerateCard() {
int[] cards = {0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7};
cards = cards.OrderBy(item => Random.Range(-1.0f, 1.0f)).ToArray();
cardsLeft = cards.Length;
float cardTerm = card.transform.localScale.x + 0.1f;
for(int i = 0; i < 16; i++) {
GameObject newCard = Instantiate(card);
newCard.transform.parent = GameObject.Find("Cards").transform;
float x = (i % 4) * 1.4f - 2.1f;
float y = (i / 4) * 1.4f - 3.0f;
newCard.transform.position = new Vector3(0f, 0f, 0f);
Vector3 target = new Vector3(x, y, 0);
string cardName = "card" + cards[i].ToString();
newCard.transform.Find("Front").GetComponent<SpriteRenderer>().sprite = Resources.Load<Sprite>(cardName);
cardList.Add(newCard, target);
}
}
// Generate된 카드를 분배 위치로 이동시키는 코루틴 함수입니다.
// 선형 보간법을 사용해서 이동시킵니다.
IEnumerator GenerateCardMoveToTarget(float waitSeconds)
{
foreach (KeyValuePair<GameObject, Vector3> card in cardList)
{
GameObject cardGameObject = card.Key;
Vector3 cardVector3 = card.Value;
cardGameObject.transform.position = Vector3.Lerp(cardGameObject.transform.position, cardVector3, 0.1f);
yield return new WaitForSeconds(waitSeconds);
}
isCardGenerated = true;
}
}
기능을 구현할 때 핵심 코드들입니다.
코드는 짧지만 개선할 부분이 많아서 한 번 하나씩 정리해봤습니다.
문제가 되는 코드(수정 전)
// ...
public void GenerateCard() {
// ...
for(int i = 0; i < 16; i++) {
GameObject newCard = Instantiate(card);
newCard.transform.parent = GameObject.Find("Cards").transform;
// ...
수정 후
public void GenerateCard() {
// ...
GameObject cardsGameObject = GameObject.Find("Cards");
if (cardsGameObject == null)
return;
for(int i = 0; i < 16; i++) {
GameObject newCard = Instantiate(card);
newCard.transform.parent = cardsGameObject.transform;
같은 GameObject를 반복적으로 Find하는 코드를 반복문 밖을 옮겨서 한번만 Find하도록 수정했습니다.
GameObject.Find()는 씬 내에서 이름을 기반으로 모든 오브젝트를 검색하는 역할로
오브젝트가 많아지면 성능에 악영향을 줄 수 있습니다.
그래서 위와 같이 호출되는 부분을 줄이도록 코드를 수정했습니다.
그리고 Cards GameObject의 null 체크를 해서 게임 진행 중 Crash가 안나도록 방지했습니다.
문제가 되는 코드(수정 전)
// ...
public void GenerateCard() {
// ...
for(int i = 0; i < 16; i++) {
// ...
string cardName = "card" + cards[i].ToString();
수정 후
// ...
public void GenerateCard() {
// ...
for(int i = 0; i < 16; i++) {
// ...
string cardName = $"card{cards[i]}";
문자열은 불변(immutable)하므로 두 문자열을 더할 때마다 새로운 문자열이 생성됩니다.
이는 메모리 할당과 복사 작업을 유발합니다.
그래서 조금이라도 성능적으로 효율적이기 위해 문자열 보간으로 변경해봤습니다.
성능에 큰 도움을 줄것은 잘 모르겠지만, 코드 가독성도 향상되고 코드가 더 간결해졌습니다.
우선 사용을 해서 기능을 구현했지만, 아직 모르는게 많았습니다.
그래서 하나씩 다시 정리해보기로 했습니다.
코루틴을 사용하면 작업을 다수의 프레임에 분산할 수 있습니다. Unity에서 코루틴은 실행을 일시 정지하고 제어를 Unity에 반환하지만 중단한 부분에서 다음 프레임을 계속할 수 있는 메서드입니다.
<Unity Docs - 코루틴>
유니티 Docs에는 위와 같이 정리되어 있습니다.
주로 사용하는 용도로는 실행을 일시 정지하고 제어할 수 있다는 것입니다.
이런 코루틴을 사용해서 스킬의 시전 시간에 의한 딜레이 같은 것들을 구현하는데 도움을 줍니다.
그런데 유니티 코루틴 함수를 보면 IEnumerator라는 타입을 반환하는 것을 볼 수 있습니다.
코루틴을 공부하다가 IEnumerator는 무엇이고 왜 반환하는지 궁금해서 찾아봤습니다.
코루틴 함수를 선언할 때 반환형을 IEnumerator로 지정하는데, 이 인터페이스를 열거자라고 합니다.
int[] array = new int[] { 1, 2, 3, 4, 5 };
// 1) foeach를 사용해 배열의 값 출력
foreach (int num in array)
Console.WriteLine($"number : {num}");
// 2) IEnumerator를 사용해 배열의 값 출력
IEnumerator enumerator = array.GetEnumerator();
while (enumerator.MoveNext())
Console.WriteLine($"Enumerator : {enumerator.Current}");
위 코드의 1번과 2번은 동일한 출력 값을 출력합니다.
IEnumerator가 클래스 내부의 컬렉션에 대해 반복할 수 있도록 도와준다는 것을 알게되었습니다.
그러면 코루틴과 연관성은 무엇이고 왜 리턴형이 IEnumerator인지 궁금해서 찾아봤습니다.
컬렉션(List, Array 등)의 IEnumerator는 Current에 순회할 요소를 담지만,
코루틴은 지연 함수 또는 비동기 함수의 IEnumerator를 담는다.
코루틴은 실행 중인 함수의 실행을 일시 중단하고 나중에 해당 지점부터 다시 실행할 수 있는 기능을 제공합니다.
이렇게 동작하면서 함수 실행을 중단하고 나중에 이어서 실행하는 것을 통해 비동기 작업이나 지연 작업을 효율적으로 처리할 수 있습니다.
코루틴은 코드를 조각조각으로 나눠서 실행할 수 있으며, 특정 조건이나 시간 간격에 따라 실행을 제어할 수 있습니다.
코루틴은 IEnumerator 인터페이스를 사용하여 지연 함수나 비동기 함수의 실행 과정을 제어합니다.
yield 반환(return) 라인은 실행이 일시 중지되고 다음 프레임에서 다시 시작되는 지점입니다.
코루틴은 필요한 시점에 실행을 중단하거나 다시 시작할 수 있는데,
이러한 동작은 코루틴 내부에서 'yield' 문을 사용하여 구현됩니다.
'yield return' 문을 만나면 코루틴은 실행을 중지하고 해당 값을 반환한 후,
다음에 어디서부터 실행을 이어나갈지 결정합니다.
// ...
StartCoroutine(CoroutineTest());
}
private IEnumerator CoroutineTest()
{
yield return new WaitForSeconds(1.0f);
yield return new WaitForSecondsRealtime(1.0f);
yield return null;
yield break;
}
위와 같이 여러 개의 yield return을 추가해서 코루틴의 실행 단위를 나눌 수 있습니다.
코루틴은 더 복잡한 비동기 동작과 흐름 제어에 적합하며, 'Invoke()' 는 간단한 시간 지연 작업에 사용됩니다.
--
글을 작성하는데 계속 고민하고 자료를 찾느라 2일은 걸린 것 같습니다.
시간은 꽤 걸렸지만, 회고를 작성하고 생각을 적는 것의 중요성을 확실하게 느꼈습니다.
익숙하지 않은 기능을 구글링해서 구현하고 끝낼 경우에는 잊어 먹는 경우가 많습니다.
그리고 내부 로직의 순서나 동작 원리를 자세히 안보고 넘어갈 수 있습니다.
이런 문제가 계속 쌓이지 않기 위해 시간이 걸리더라도 작성해봤습니다.
나중에 시간이 된다면 구조도와 같은 가시적인 표, 도형을 그려서 효율적으로 학습해볼 예정입니다.