[C# Unity] 코루틴을 사용해 카드 분배 애니메이션 구현

Arthur·2023년 8월 11일
0
post-thumbnail

구현한 기능


카드 짝 맞추기 게임에서 짝을 맞춰야 할 카드를 배치하는 기능입니다.

여기서 그냥 카드를 각 위치에 맞게 배치해줘도 되지만,
마치 카드게임(블랙잭, 하스스톤 등)처럼 카드가 일정한 애니메이션으로 분배되도록 만들고 싶었습니다.

그 결과 위와 같이 좌표 값 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;
    }
}

기능을 구현할 때 핵심 코드들입니다.
코드는 짧지만 개선할 부분이 많아서 한 번 하나씩 정리해봤습니다.



개선해야 할 점 및 셀프 피드백


1) private 멤버 변수명 변경

  • isCardGenerated => _isCardGenerated
  • cardList => _cardList
  • card => _card
  • timer => _timer
  • cardsLeft => _cardsLeft
  • firstCard => _firstCard
  • secondCard => _secondCard

2) GenerateCard() 함수에서 Cards 오브젝트를 반복적으로 Find

문제가 되는 코드(수정 전)


// ...

    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가 안나도록 방지했습니다.


3) 문자와 문자를 더하는 코드 수정

문제가 되는 코드(수정 전)

	// ...
    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)하므로 두 문자열을 더할 때마다 새로운 문자열이 생성됩니다.
이는 메모리 할당과 복사 작업을 유발합니다.
그래서 조금이라도 성능적으로 효율적이기 위해 문자열 보간으로 변경해봤습니다.

성능에 큰 도움을 줄것은 잘 모르겠지만, 코드 가독성도 향상되고 코드가 더 간결해졌습니다.

4) 코루틴에 대한 이해 부족

우선 사용을 해서 기능을 구현했지만, 아직 모르는게 많았습니다.

  • 코루틴이 무엇인지?
  • 왜 IEnumerator 반환 타입을 사용하는지?
  • yield return은 무엇인지?
  • Invoke랑 차이점은 무엇인지?

그래서 하나씩 다시 정리해보기로 했습니다.



코루틴이란?


코루틴을 사용하면 작업을 다수의 프레임에 분산할 수 있습니다. Unity에서 코루틴은 실행을 일시 정지하고 제어를 Unity에 반환하지만 중단한 부분에서 다음 프레임을 계속할 수 있는 메서드입니다.
<Unity Docs - 코루틴>

  • 코루틴의 동기 작업은 스레드가 아니라 메인 스레드에서 실행됩니다.
  • 코루틴은 HTTP 전송, 에셋 로드, 파일 I/O 완료 등과 같이 긴 비동기 작업을 처리하는 경우에 사용하는 것이 가장 좋습니다.

유니티 Docs에는 위와 같이 정리되어 있습니다.
주로 사용하는 용도로는 실행을 일시 정지하고 제어할 수 있다는 것입니다.

이런 코루틴을 사용해서 스킬의 시전 시간에 의한 딜레이 같은 것들을 구현하는데 도움을 줍니다.

그런데 유니티 코루틴 함수를 보면 IEnumerator라는 타입을 반환하는 것을 볼 수 있습니다.
코루틴을 공부하다가 IEnumerator는 무엇이고 왜 반환하는지 궁금해서 찾아봤습니다.



IEnumerator란?


코루틴 함수를 선언할 때 반환형을 IEnumerator로 지정하는데, 이 인터페이스를 열거자라고 합니다.

  • IEnumerator는 Count가 없어서 반복문으로 실행 시 언제 끝날지 수치로 알 수 없다.
    - 그래서 IEnumerator는 무한으로 실행 될 수도 있다.
    • 실패할때까지 다음 이동(enumerator.MoveNext())을 계속 호출한다
  • C#에서 어떤 자료에 순차적으로 열거되어 있는 값을 접근하도록 도와주는 인터페이스
    - MoveNext()를 통해 순서가 몰라도 다음 값을 가져올 수 있다.
    • Current를 사용해서 현재 값을 알 수 있다.
    • foreach가 IEnumerator를 통해 자료를 순차적으로 접근한다.
  • C#의 모든 Collections 컬렉션은 IEnumerable, IEnumerator를 상속받아 구현하고 있다.
    - 그래서 List, Array 같은 컬렉션 클래스 객체들은 foreach문에서 돌릴 수 있다.
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인지 궁금해서 찾아봤습니다.

그렇다면 코루틴은 왜 IEnumerator를 리턴할까?

컬렉션(List, Array 등)의 IEnumerator는 Current에 순회할 요소를 담지만,
코루틴은 지연 함수 또는 비동기 함수의 IEnumerator를 담는다.

코루틴은 실행 중인 함수의 실행을 일시 중단하고 나중에 해당 지점부터 다시 실행할 수 있는 기능을 제공합니다.
이렇게 동작하면서 함수 실행을 중단하고 나중에 이어서 실행하는 것을 통해 비동기 작업이나 지연 작업을 효율적으로 처리할 수 있습니다.
코루틴은 코드를 조각조각으로 나눠서 실행할 수 있으며, 특정 조건이나 시간 간격에 따라 실행을 제어할 수 있습니다.

코루틴은 IEnumerator 인터페이스를 사용하여 지연 함수나 비동기 함수의 실행 과정을 제어합니다.



yield retrun이란?


yield 반환(return) 라인은 실행이 일시 중지되고 다음 프레임에서 다시 시작되는 지점입니다.

코루틴은 필요한 시점에 실행을 중단하거나 다시 시작할 수 있는데,
이러한 동작은 코루틴 내부에서 'yield' 문을 사용하여 구현됩니다.

'yield return' 문을 만나면 코루틴은 실행을 중지하고 해당 값을 반환한 후,
다음에 어디서부터 실행을 이어나갈지 결정합니다.

yield return의 종류

  • yield return null : 다음 프레임에 실행
  • yield return new WaitForSeconds(float) : 매개변수(float형)로 입력한 값에 해당하는 Seconds만큼 기다렸다가 실행(유니티상 시간을 기준으로 둔다)
  • yield return new WaitForSecondsRealtime(float) : 매개변수로 입력한 값에 해당하는 Seconds만큼을 기다렸다가 실행(현실 시간을 기준으로 둔다)
  • yield break : 코루틴을 끝내버리는 코드이다.

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와 차이점


  • 코루틴은 함수의 실행을 중지하고 나중에 다시 시작할 수 있는 비동기적인 메커니즘으로, 복잡한 비동기 작업이나 긴 작업을 다룰 때 유용합니다.
  • 'Invoke()'는 메서드를 지정된 시간 후에 실행하는 간단한 지연 작업을 처리할 때 사용됩니다.

코루틴은 더 복잡한 비동기 동작과 흐름 제어에 적합하며, 'Invoke()' 는 간단한 시간 지연 작업에 사용됩니다.



작성하면서 느낀 점

--

글을 작성하는데 계속 고민하고 자료를 찾느라 2일은 걸린 것 같습니다.
시간은 꽤 걸렸지만, 회고를 작성하고 생각을 적는 것의 중요성을 확실하게 느꼈습니다.

익숙하지 않은 기능을 구글링해서 구현하고 끝낼 경우에는 잊어 먹는 경우가 많습니다.
그리고 내부 로직의 순서나 동작 원리를 자세히 안보고 넘어갈 수 있습니다.

이런 문제가 계속 쌓이지 않기 위해 시간이 걸리더라도 작성해봤습니다.

나중에 시간이 된다면 구조도와 같은 가시적인 표, 도형을 그려서 효율적으로 학습해볼 예정입니다.



참고자료


  • [유니티] 코루틴의 사용법 총정리 - Unity Coroutine => 링크
  • [Unity] 열거자 인터페이스 IEnumerator => 링크
  • MSDocs - IEnumerator 인터페이스 => 링크
  • 열거형(IEnumerable, IEnumerator) | C# 프로그래밍 자습서 초보자: 17
    => 링크
  • 여기 저기서 보이는 IEnumerable이란 무엇일까? => 링크
  • #15 | UNITY의 IENUMERATOR 및 코루틴 🎮 | 초보자를 위한 Unity | Unity 튜토리얼 => 링크
  • Unity Docs - 코루틴1 => 링크
  • Unity Docs - 코루틴2 => 링크
  • Unity 코루틴(Coroutine) 이해하기: 동작원리 및 구현 => 링크
  • [C#] IEnumerable, IEnumerator 그리고 yield => 링크
  • chatGPT
profile
기술에 대한 고민과 배운 것을 회고하는 게임 서버 개발자의 블로그입니다.

0개의 댓글