본문 바로가기

Unity 개발 공부

[내배캠] 본캠 2일차. 팀 카드 뒤집기 게임 #2

 

 

카드뒤집기를 차근차근 진행해보며 생기는 문제점들에 대해서 보완하며  공부한 내용을 TIL을 작성하였다.
먼저 한가지 발견한 오류에 대해서 짚고넘어가자.

1. 스크립트 만들고 복붙하면, 스크립트 클래스명도 복붙되서, public이다보니 스크립트가 여러개일때 네임이 같아져 오류.
아래와 같은 오류메시지를 로그창에 출력했다.
'GameManager' 형식은 동일한 매개 변수 형식을 가진 'Update' 멤버를 미리 정의합니다.



2. for문 instantiate으로 card 프리팹을 계산한 좌표 x,y 식으로 나열할때, card 안에 text가 안 따라온 문제 : 
text 의 canvas를 render mode 를 screenspace-overlay 에서 world space로 바꿔주면 따라간다. 그리고 scale이 엄청커지므로 1 -> 0.01로 줄여주었다.


우선 card 스크립트에서 resources 폴더안의 특정이미지 (네이밍을 Jin01이라할때) 만으로 대응시키고싶을때
변수 frontimage는 SpriteRenderer 타입으로 선언.
front와 back 장은 GameObject 타입으로 선언.

frontimage.sprite = Resources.Load<Sprite>("Jin01"); 로 불러들일 수 있다.

그러면 board에서 생성한 card 16장 인스턴스에 대해서는 어떻게 각각 대응하게 넣을 수 있을까.

우선 card 스크립트에서 보드에서 펼친 카드들에 이미지를 순서대로 넣어주는 메서드를 만들어줄 필요가 있다.
그리고 실행은 card들이 보드스크립트에서 생성될때 대응해서 들어가도록 board 스크립트에서 해당 메서드를 이용해 실행문을 써준다.

먼저 기반작업이 필요한데, 보통 for문으로 i=0으로 초기화후 , 원하는 숫자까지 증감하므로, 정수로 받을 수 있도록 인덱싱을 해주면 좋다.
이미지 네이밍을 앞부분의 string부를 통일시키고, 뒷단에 0부터 ~ 원하는 숫자까지 정수로 순서대로 네이밍해주고.
Resources 폴더 내에 집어넣으면 Resources 라는 클래스를 통해 런타임중에 불러들일 수 있다. 경로는 Assets/Resources 가 되어야한다. 
파일 확장자는 보통 생략한다.
Resources.Load<T>(string path) 메서드는 제네릭 메서드로 T 타입의 객체를 반환한다. 여기서는 Sprite이므로 Sprite타입을 반환한다. -> 리소스가 존재x 타입이 틀리면
null을 반환한다. 소괄호안에 경로를 넣는다.

card스크립트에서 게임오브젝트 타입으로 card의 앞면, 뒷면을만든다. 
public GameObject front;
public GameObject back;

그리고 front의 스프라이트 렌더러라는 컴포넌트도 변수를 선언해준다.
public SpriteRenderer frontimage;

인덱싱할 변수를 선언해준다. 초기화를 위해 할당도 해주면 좋다.
int cardIndex = 0;

함수를 만든다. 이 함수는 보드 스크립트에 가져와 쓸것이기때문에 public으로 만들어준다.

public void Setting (int number) 
// 매개변수를 넣어준다. 이 매개변수가 board 스크립트에서 card를 16장 생성할때 쓰인 변수를 할당해 프론트이미지에 resources 내의 이미지소스들을 넣을것이다.

{
cardIndex = number;
frontimage.sprite = Resources.Load<Sprite>($"Jin{cardIndex}");  
}
// frontimage 라는 spriterenderer 컴포넌트의  sprite 프로퍼티에 접근해서 할당한 것은 바로
// resources 클래스의 Sprite타입을 load 하는 기능, 소괄호안에 $" string 타입+ { 인덱싱 변수}" 를 적어줄 수 있다. 
// 문자열 보간을 사용했다. $ 기호를 문자열 리터럴 앞에 붙이면 문자열 내부에 { } 를 사용해 변수나 표현식을 삽입가능하다.
// Resources 폴더를 사용하는것은 편리하지만, 모든 리소스를 넣으면 빌드시 전체 리소스가 메모리에 로드되어 무거울수있다. 사용하지않는 리소스가많다면 Addressable Assets 시스템 고려하기.


그다음 board 스크립트로 넘어가 기존의 card를 나열하는 for문안에서 이미지를 front에 불러올 코드를 작성해본다.
기존에 Go라는 GameObject를 선언동시에, Instantiate(Card)을 할당했었다.
이 Go는 즉, 카드 인스턴스를 생성하는 GameObject가 된셈인데,
여기서 GetComponent<T>() 메서드로 Card의 컴포넌트를 가져올 수 있다.
Go.GetComponent<Card>().Setting( i%8 ); 
// 그다음 setting() 메서드를 접근해서 실행하는데, 소괄호안에 for문에 쓰던 i를 8로 나눈 나머지를 대응되게 한다면
// 인덱스 넘버링을 0부터 15까지 계산해보면 0...1...2...3...4...5...6...7....다시 0...~ 7 이렇게 나머지를 내놓으면서 두번반복하며 16장의 카드에 이미지들이 할당된다.
// GetComponent<T>()의 경우 마찬가지로 제너릭메서드인데 특정 게임오브젝트에 붙은 컴포넌트를 타입T로 찾는 메서드다.
// GetComponent<T>()의 경우  Resourcs 클래스의 Load<T>(string path) 와는 다르게 인자를 소괄호에 안쓴다.
// Load<T>는 경로정보가 필수적이나, GetComponent<T>는 이미 존재하는 컴포넌트를 찾기위해 타입만 확인되면 되기때문이고 경로등 별도의 추가인자를 필요치않는다.

int[] arr = {0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7};
arr = arr.Orderby( x => Random.Range( 0f, 7f ) ) .ToArray();
// Orderby( ) 메서드는 arr[]라는 컬렉션을 오름차순으로 재배열해주는 메서드로 Linq를 사용한다. using System.Linq; 로 상단에 작성해주면 사용가능하다.
// Linq(Language Integrated Query) 란 C#에서 컬렉션,배열, XML, 데이터베이스 등 다양한 데이터 소스에 대해 쿼리를 간결하고 일관된 문법으로 사용가능하게하는 집합이다.
// 쿼리란 데이터베이스나 컬렉션 등에서 원하는 데이터를 찾거나 요청하기위해 작성하는 질의문이다.
var koreanCustomers = customers.Where(customer => customer.Country == "Korea"); Linq 쿼리 사용법중 하나로 람다식으로 이렇게 적으면
var koreanCustomers = 
    from customer in customers
    where customer.Country == "Korea"
    select customer;
라고 풀어쓸수있다.

// Orderby 소괄호 안에는 기본적인 람다식이 들어갈 수 있다.
collection.OrderBy( x => x.SomeProperty);
// 인자 x를 받아 x.SomeProperty 값을 반환한다는 뜻이다.
numbers.OrderBy( x => x);
// 컬렉션 자체가 정렬가능한 값 (int, string 등) 이라면 다음같이 바로 정렬도 가능하다. 이경우 람다식은 컬렉션 요소 자체를 반환해서 오름차순으로 정렬한다.
//복합 표현식도 가능하다.
collection.OrderBy( x=> x.FirstName + " " + x.LastName);
// 이 예제는 각 요소의 FirstName과 LastName을 결합한 문자열을 기준으로 정렬한다.
//메서드 호출도 가능하다.
collection.OrderBy( x => GetSortKey(x));
//GetSortKey라는 별도의 메서드를 정의하여 그 결과를 기준으로 정렬한다.
// 익명의 함수표현이 여러줄로 필요할경우 다음과 같이도 표현가능하다.
collection.OrderBy( x=> { 
int key = x.ComputeKey();
return key;
});
// 삼항연사자도 넣을 수 있다. 삼항연산자란  조건? 참일때의 값 : 거짓일때의 값; 으로 표현하는식으로 
collection.OrderBy( x= > x.Age > 18 ? "Adult" : "Miner" );
//이런식으로 나이가 18살이상이면 성인, 아니라면 미성년자로 표현도 가능하다.

//람다식은 이름없는 익명함수를 간단하게 표현하기 위한 문법으로 
(매개변수목록) => 식 또는 {문장들;} 
x => x+1
이 표현식은 "입력한 x 값에 1을 더한 결과를 반환하라" 라는 의미다.
// 따라서 x => Random.Range( 0f, 7f) 는 x값에 0f 이상 7f 미만의 실수들에서 랜덤으로 반환하라는 뜻이고.
// collection.OrderBy( x => Random.Range(0f, 7f) ); 의 경우엔 
//collection의 x 인자들 값에 0f 이상, 7f 미만의 실수들을 랜덤하게 반환한 실수 숫자를 기준으로 오름차순으로 정렬하라는 뜻이다. 
//그런데 OrderBy 메서드는 원래의 컬렉션을 변경하지않고, 정렬된 새로운 IEnumerable <T> 시퀀스를 반환하므로 
//이 IEnumerable<T>라는 시그니처 안에서 따로 다시 배열(Array)로 특정하기위한 메서드를 써줘야한다. ToArray(); 번외로 List<T> 하기위한 ToList(); 도 있다.


 
카드를 뒤집는 메서드를 생성 할려고 GameObject.SetActive(bool value); 를 이용해서 
public void OpenCard()
 {
     front.SetActive(true);
     back.SetActive(false);
 }
를 만들었는데,, 가운데 카드들이 뒤집히지않는 버그가 발생했었다.
체크해보니 0.00 로만든 TimeTxt의 바운더리가 너무 커서 간섭되어있던것이다. (레이어가 같은상태) TimeTxt를 더블클릭해서 영역을 확인하고 
width , height을 줄여주니 간섭이 사라져서 카드들이 다 잘 뒤집혔다.


그다음은 단순히,, 카드 파괴를 주석처리하고 카드는 뒤집히지만,, 시간이 지나면 다시 원복되는 코드를 구현해봤다.
card.cs에

 public void CloseCardwithDelay(float delay)
 {
     Invoke("CloseCardInvoke", delay);
     
 }
라는 함수를 구현했고.. 
CloseCardInvoke는 그대로 재탕했다.

GameManager.cs에서는 좀더 이것저것 고쳐야하는데

private int matchPairCount = 0;
먼저 카드를 맞춰도 다시 돌아갈때는 카드 카운트도 되돌리고싶어 해당 변수를 선언해줬다.

먼저 stage 0번 type에서만 해당 기능을 구현할것이라 if 문으로 아래와 같이 해줬다. 그다음
내용에서 extraDelay라는 변수를 float 타입으로 선언해주고
matchPairCount는 첫카드와 두번째카드를 맞출때마다 증가하게했다.
거기에 2f 를 곱해준것을 extraDelay에 할당했다.
그냥 기존에 Card.cs에 만들었던 CloseCardwithDelay()메서드를 호출해오기위해
firstcard.CloseCardwithDelay(10f) 이런식으로 한다면.. 10초뒤에 다시 뒤집히겠지만.. 문제는 이게 
두번째로 카드 짝을 맞췄을때 가까운 초내에 연달아 맞춰버리면.. 돌아오는것도 그 맞춘 타이밍에 비례하게 다 원복되므로 난이도가 올라갔다.
거기에 매치되는 짝이 카운트되면서 여러개 맞출수록 2초정도 더 딜레이되도록 하는 난이도조절용 코드를 작성하였다.
그래서 extraDelay는 보너스 딜레이를 위한 변수이며.. 아래와같이 소괄호에 + extraDelay로 추가해줬다.
Invoke로 소괄호안에 RestoreCardCount 메서드를 실시하는것도 마찬가지 두번째카드가 다시 뒤집힐때의 시간에 맞춰서 적용되도록하였다.
RestoreCardCount의 경우엔 카드카운트를 +2로 다시 되돌리는 코드를 작성하였다. 이로서 .. 카드가 뒤집힐때는 -2로 카운트다운되지만
그게 다시 원복되면 +2로 되돌려버리는 코드를 구현하여.. 카드가 원복되어도 카운트가 계속 되는 문제를 방지했다.


    if (firstcard.cardIndex == secondcard.cardIndex)
    {
        //firstcard.DestroyCard();
        //secondcard.DestroyCard();
        cardCount -= 2;
        if (Card.instance.type == 0)
        {
            float extraDelay = matchPairCount * 2f;
            firstcard.CloseCardwithDelay(10.0f + extraDelay);
            secondcard.CloseCardwithDelay(10.0f + extraDelay);
            Invoke("RestoreCardCount", 10.0f + extraDelay);
            matchPairCount++;
        }
        else 
        {
            firstcard.DestroyCard();
            secondcard.DestroyCard();
            time -= 10f;
            if(time <0f)
            {
                time = 0f;
            }
        }

        if (cardCount ==0)
        {
            StageResult();
        }
        audioSource.PlayOneShot(clip);
        


    }
    else
    {
        if(Card.instance.type == 1)
        {
            firstcard.CloseCard();
            secondcard.CloseCard();
            time += 3f;
        }
        else
        {
            firstcard.CloseCard();
            secondcard.CloseCard();
            
        }
        
       
    }
    firstcard = null;
    secondcard = null;

그리고 
 private void RestoreCardCount()
 {
     cardCount += 2;
 }
}


그런데, 이번엔 카드가 뒤집히고나서 다시 돌아온다는 힌트를 애니메이션으로 주고싶었다.
 5초후에 5초간 뒤집힐려는 애니메이션을 출력하고 다시 원복되는 방식을 쓰려면 단순히 invoke 소괄호에 delay 인자를 넣는거보다 
코루틴으로 제작하는것이 수월하다.

해당 작업은 코루틴으로 변경 후 업데이트할 예정이다.

 

카드 애니메이션 만들때 시행착오가 있다.
바로 animation을 만들때, 포지션값을 움직이는걸 만들면 좌표 0,0,0을 기준으로 만들기때문에
좌우로 x좌표를 움직여주는 loop 애니메이션으로 흔들리는 느낌을 주었으나, 실행시에, 카드가 x,y좌표대로 16장이 나열되는게아니라
0,0 정중앙에 16장이 몰려버렸다. 해당 문제를 해결하려면 .. local로 움직이고 싶은 오브젝트에 애니메이션을 만들고 그것의 부모를 의도된 위치에 가져다놓으면
로컬로 적용되게된다. 오브젝트의 부모 오브젝트가 필요한것이다.
여기서는 front 면이 나왔을때 5초뒤에 흔들거리며 뒤바뀐다는 힌트를 주는 애니메이션을 띄울것이므로 Card를 부모로 생각하고
그안에 front 객체에 애니메이션을 할당하니 x,y 좌표에 맞게 잘 출력되었다.

 

해당 front 객체에 애니메이션을 별도로 적용하기위해, CardFront라는 cs를 만들고 코루틴으로 마찬가지의 작업을 해주었으나, 싱글톤으로 만들어버리는 실수를했다.

이 실수로 인해 처음 active 활성화되었던 front면이 비활성화될때 전역의 모든 front면에 대해서 CardFront.cs의 상태가 적용이 되어버려 다음 짝을 맞췄을때 애니메이션을 호출하려던 코루틴은 front면이 비활성화되어있어 호출할 수 없다는 에러메시지를 송출했다. 

 

Coroutine couldn't be started because the the game object 'Front' is inactive! UnityEngine.MonoBehaviour:StartCoroutine (System.Collections.IEnumerator) CardFront:StartTremBleFrontCard () (at Assets/Scripts/CardFront.cs:19) GameManager:Matched () (at Assets/Scripts/GameManager.cs:92) Card:OpenCard () (at Assets/Scripts/Card.cs:92) UnityEngine.EventSystems.EventSystem:Update ()

 

그래서 instance로 코루틴을 만들었던 코드는 전부 삭제후

public으로 수정후에, 코루틴 메서드를 다시 작성했다.

여기서 혹여나 CardFront cs의 GameObject 의 부모인 Card가 비활성화되어있어도 해당 코루틴은 실시가 되지않을 수 있다.
이를 체크하기위해 gameObject.activeInHierarchy 라는 실행문을 쓸 수 있다. 이것은 gameobject ( 여기서 CardFront)가 포함되어있는 모든 계층에 대해서 활성화되어있는지 여부를 반환하는 식이다. 스스로만 반환하는것은 activeSelf 도 있다.
그래서 그 여부를 확인하고 비활성화 상태라면 켜주는 작업까지 체크후 코루틴을 실시한다.
if(!gameObject.activeInHierarchy)

{

gameobject.SetActive(true)

}

 StartCoroutine(FrontCardTremBle());

 

CardFront의 해당 코루틴 메서드는 public으로 작성한 메서드이므로 

GameManager.cs에서 CardFront 타입으로 들고와서 선언 및 할당이 가능하다.

front1, front2로 각각 할당하는데, 
CardFront front1 = firstcard.GetComponentInChildren<CardFront>(); 라는 식은 front1에 Card 타입의 fristcard 안에서 CardFront라는 컴포넌트를 찾아서 할당한다는 뜻이다.
GetComponent<CardFront>(); 와는 다른데, 아마 이렇게 하면 안나올 확률이 높다. 왜냐면 해당 함수는 GameObject에 대해서만 컴포넌트를 찾아오는 식인데, CardFront는 Card(firstcard) 안의 front에 들어있는 컴포넌트(cs) 이기 때문에 그 자식까지 재귀적으로 검색해야하기 때문이다. 해당 함수로 검색하면 null을 반환했을것이다.
따라서 Card 스크립트에서 해당 실행문을 쓸때는 firstcard.GetComponentInChildren<CardFront>();를 해줘야 그 안에 자식에게 붙은 컴포넌트를 찾을 수 있다.


그후,
front1.StartTremBleFrontCard();

front2.StartTremBleFrontCard();

식으로 코루틴을 실행하는 메서드를 불러와주면된다.
다만, 이때 

if( front1 != null)

front1.StartTremBleFrontCard();

if( front2 != null)

front2.StartTremBleFrontCard();

를 써줌으로서 비할당되지않았을때 코루틴을 실시한다라고 조건을 정해 에러를 방지해주면 더 좋다.