카드뒤집기 프로젝트가 마무리되었다.
기획은 총 5개의 스테이지를 구현하는것이었다.
5개의 스테이지에 최대한 다양한 특수기믹들을 넣어서 카드뒤집기를 만들어보는게 계획이었고
추려져서
구현해야하는 사항들은아래와 같았다.
스테이지1: 원상복귀 - 짝을 맞추면 사라지지않고 남아있다가, 시간이지나면 순차적으로 다시 뒤집힘
스테이지2: 턴제, 카드 반쪽자리 맞추기 - Try 횟수가 정해져있고, 카드가 반으로 쪼개진 퍼즐 요소 도입
스테이지3: 두번째로 공개한 카드 가리기 - 두번째 카드를 뒤집을때 짝이 맞지않았다면 그림을 보여주지않음
스테이지4: 시간내에 못하면 카드 전체 원상복귀, 카드가 x초마다 한칸씩 밀림 - 시간내에 모든 짝을 맞추지못한다면 전체 카드가 다시 뒤집혀버린다. x초마다 카드 배열이 옆으로 한칸씩 밀린다.
스테이지5: 카드 셔플, 시간 줄이고 늘리기 - 특정분기마다 카드가 뒤섞인다. 짝을 맞추지못하면 - 시간, 짝을 맞추면 + 보너스시간
스테이지를 관리하기위해서 Enum 을 썼어도되지만 아직 미숙하기때문에 지난시간 강의에서 배운 방식으로
카드 프리팹생성 시에 썻던 type 변수를 선언하고
type별로 카드 front에 sprite이미지가 다르게 들어오는것을 기준
먼저 스테이지1의 원상복귀의 자세한 동작원리는 10초가 지나면 뒤집어진 카드들이 다시 돌아오는데,
5초가 지난뒤 뒤집힐것이다라는 힌트로 흔들거리는 애니메이션이 송출되고, 그다음에 다시 뒤집혀지는 메커니즘을 짜기로 햇다.
해당 기능을 위해서 구현한 코드들은 아래와 같다.
먼저 Card.cs 에서 이러한 코루틴함수와, 코루틴실행함수를 만들어줬다.
public void StartTurnBackCorutine(float bonusDelay)
{
StartCoroutine(TurnBackCoroutine(bonusDelay));
}
private IEnumerator TurnBackCoroutine(float bonusDelay)
{
yield return new WaitForSeconds(5f+bonusDelay);
anim.SetBool("IsOpen", false);
anim.SetBool("IsTurnBackStart", true);
yield return new WaitForSeconds(5f);
anim.SetBool("IsTurnBackStart", false);
front.SetActive(false);
back.SetActive(true);
}
IsOpen은 카드를 깔때 실행되는 애니메이션이고
IsTurnBackStart의 경우 흔들거리는 로테이션 애니메이션이다.
그다음은 언제 이 애니메이션이 나오냐? 바로 두카드가 맞쳐졌을때 코루틴이 실시되도록 만들었다.
bonusDelay 변수는 짝을 연달아 맞춰서 다시 돌아올때도 연달아 돌아오면 난이도가 너무 어려워져서 이걸 완화시키기 위해 만들었다.
해당부분은 코루틴에도 매개변수로 인자를 넣었고
Matched 함수 내에서도 선언, 할당함으로서 사용할 수 있다.
private int matchPairCount = 0;를 필드로 GameManager.cs에 선언하고.. 아래와 같이 Matched() 메서드에 작성한것이다.
CardFront 관련 라인은 CardFront.cs라고 별도로 만든 스크립트와 관련된 변수이다.
해당 부분은 CardFront 타입의 변수 front1, front2에 firstcard, secondcard 즉 열어본 카드 객체 두개에 대한 CardFront 컴포넌트를 가져와서 할당하였다. GetComponentInChildren<CardFront>(); 의경우 Card의 부모부터 하위 자식 객체인 CardFront 까지 전부 체크하여 CardFront 컴포넌트를 front1,front2에 할당한다는 말이다. CardFront.cs에서 만든 메서드를 씬에서 고른 firstcard,secondcard 에대해서 실행시키기 위해 할당하는 과정이다.
public void Matched()
{
if (firstcard.cardIndex == secondcard.cardIndex)
cardCount -= 2;
if (Card.instance.type == 0) // 스테이지1(type==0)용
{
float bonusDelay = matchPairCount * 0.5f; // 짝맞추기가 1회 성공할때마다 보너스 딜레이가 0.5초씩 증가해서 난이도 완화
float bonusDelay2 = matchPairCount * 0.5f;
firstcard.StartTurnBackCorutine(bonusDelay);
secondcard.StartTurnBackCorutine(bonusDelay);
CardFront front1 = firstcard.GetComponentInChildren<CardFront>(); //CardFront 는 Card에 있는 자식 오브젝트, CardFront.cs로 관리
CardFront front2 = secondcard.GetComponentInChildren<CardFront>();
if (front1 != null)
front1.StartTremBleFrontCard(bonusDelay2);
if (front2 != null)
front2.StartTremBleFrontCard(bonusDelay2);
Invoke("RestoreCardCount", 10.0f + bonusDelay);
matchPairCount++;
}
else // 짝 못맞췄을경우
{
if (Card.instance.type == 0)
{
firstcard.CloseCard();
secondcard.CloseCard();
}
}
}
10초뒤에는 카드카운트가 다시 복구되는 메서드도 작성하여 cardCount가 빠졌을때 다시 실시되어서 뒤집어서 복구되었는데도 카드카운트가 다되면 게임을 클리어하는 부분은 방지했다.
private void RestoreCardCount() // stage1(cad type==0)의 카드 카운트 복구메서드
{
cardCount += 2;
}
하다보니 카드에 x좌표로 좌우로 흔들거리는 애니메이션도 넣고싶어 애니메이션을 만들어 Card프리팹 인스펙터에 컴포넌트로 있는 애니메이션 컨트롤러에 추가하였었느나, 문제가 발생했다.
바로 0,0,0 월드 좌표를 기준으로 움직임을 넣었다보니 해당 메서드가 실행되는 상황에서... 중앙 한점으로 Card 인스턴스 객체들이 몰려서 소환되버리는 불상사가 생긴것이다.
이걸 해결하기 위해 찾아보니,
Local 좌표로 받아오기위해서는 위에 부모(Parent)가 되는 객체가 있고 그안의 자식의 좌표를 움직이면 된다고했다.
그리하여 Card 안에 있는 Front라는 자식객체에 애니메이션을 넣었고..
CardFront.cs라는 별도의 스크립트를 만들어 해당 스크립트에 코드를 작성했다.
마찬가지로 코루틴을 이용하여 5초뒤에 실행되게했으며 보너스 딜레이 인자도 넣어줬다.
gameObject.activeInHierarchy 의 경우 게임오브젝트가 활성화되어있는지 뿐아니라, 그것의 상위 계층 모두 활성화되어있는지를 체크하고 반환한다. 여기서는 CardFront.cs에서 사용되었으므로 해당 front의 상위계층인 Card 프리팹 까지도 체크한다.
참고로 하위계층은 상관하지않는다.
using UnityEngine;
using System.Collections;
public class CardFront : MonoBehaviour
{
public Animator anim;
public void StartTremBleFrontCard (float bonusDelay2)
{
if (!gameObject.activeInHierarchy) //CardFront이 비활성화, 혹은 같은 계층의 (여기선 Card)가 비활성화되어있을때
{
gameObject.SetActive(true); // CardFront을 활성화
}
StartCoroutine(FrontCardTremBle(bonusDelay2));
}
private IEnumerator FrontCardTremBle(float bonusDelay2)
{
yield return new WaitForSeconds(5f+bonusDelay2);
anim.SetBool("IsFront", true);
yield return new WaitForSeconds(5f);
anim.SetBool("IsFront", false);
}
}
그다음 눈여겨볼만한 코드는
스테이지5의 셔플코드다.
이부분은 팀원과 내가 비슷한 기능을 구현했지만 방법이 달라서 공유해본다.
1. 먼저 내가 한방식은 마찬가지로 코루틴 방식이었다.
그 이유는 카드를 셔플하기전에 한번 까서 보여주고 셔플한뒤에 카드를 덮으면 애니메이션적으로 좀더 보는맛도있고, 난이도도 완화되지않을까에서 기인한 것이다.
먼저 GameManager.cs에 필드맴버변수를 선언했다.
하나는 리셔플용 체크 변수를 초기화해준거고, 하나는 셔플링중인지 체크해주는 플래그다.
private float lastReshuffleTime = 0f; // 리셔플용 시간 체크 변수
public bool isSuffling = false;
그다음은 메서드를 코루틴으로 구현했다.
처음에 firstcard 와 secondcard가 null 이 아님을 체크하는 이유는,
셔플전에 첫번째 카드를 고르고 카드가 열린상태에서 셔플이 실시되어버리면, 고른카드가 firstcard에 할당되어있어 셔플후 매치를 시키면 firstcard에 할당된 카드는 남고 두번째 카드만 매치되면서 사라져버리는 불참사를 막기 위함이었다.
그래서 firstcard에 무언가 할당되었다면, firstcard를 닫아버리고, null상태로 만들어서 할당을 비워버렸다.
second카드도 안전장치로 마찬가지로 실시하였다. 이경우는 매치하는 순간 셔플이 되어버릴경우 nullexception문제가 생기는것을 방지하기위해 마찬가지로 작성하였다.
그다음 isSuffling을 true로 만들어주고,
Card[] allCards라는 배열변수에 씬내에 활성화된 모든 Card 타입의 컴포넌트를 찾아서 집어넣어준다.
Object.FindObjectsByType<T>의 경우 씬내에 활성화된 모든 <>안의 타입의 컴포넌트를 찾아 배열로 반환해준다는 기능이며 소괄호 안의 () 경우 해당 조건에 맞는 정렬방법을 쓸수있다.
FindObjectsSortMode.None 이란 글에서 유추할 수 있듯 그냥 아무방법도 안쓰겠다, 조건없이 가겠다라는 뜻이다.
만약에 씬내의 비활성화된 친구까지 포함하여 찾고싶다면
Resource.FindObjectsOfTypeAll을 사용할수도있다.
여하튼 씬내에 활성화된 모든 Card를 찾아 allCards라는 배열에 넣었다면,
remainingCards라는 배열변수에는 allCards 중에 파괴된 카드를 제외한 카드들을 찾아서 다시한번 넣어준다.
이때 Where 이라는 함수를 람다식과 함께 사용하였는데
Wherer의 경우 IEnumerable<T> 형식이므로 ToArray() 메서드를 뒤에 붙여서 배열에 반환하기전에 변환까지 거쳐준다.
Where( 인자 => 조건 ) 으로 하면 해당 조건에 해당하는 것만 찾아서 꺼내준다는 뜻이된다.
비슷한 함수로는 FindAll( ) 또한 List <T> 타입에서 쓰이고있다.
List<T>.FindAll:
List<T> 클래스에 내장된 인스턴스 메서드로, 조건에 맞는 요소들을 새 List<T>로 반환합니다.
호출 시 바로 조건에 맞는 모든 요소를 평가하여 새 리스트를 반환합니다.
LINQ의 Where:
IEnumerable<T> 확장 메서드로, 조건에 맞는 요소들을 지연 실행(deferred execution)하는 IEnumerable<T>로 반환합니다. 만약 List나 배열로 필요하다면 ToList()나 ToArray() 메서드로 변환할 수 있습니다.
해당 Where의 경우
실제로 결과를 순회할 때까지 평가하지 않고, 지연 실행됩니다. 즉, Where 호출 후 나중에 결과를 읽어야 실제 조건검사가 이루어집니다.
배열에 할당하는것이며 코루틴으로 조건들을 이용해 처리할것이므로 해당 Where문을 이용하였다.
참고로 Array.FindAll라고 하면 마찬가지로 배열등에도 쓸수있어진다.
내용을 보자면 파괴된 카드들, 그러니까 즉 null처리된 카드들은 제외하고 꺼내서 할당하겠다라는 뜻이다.
보통 카드들이 파괴되면 비활성화상태가되는게아닌가 생각하는데
가짜 null 상태로 처리되어 엄연히 씬안에 존재할 수 있기때문에 이것을 where문으로 걸러주는것이다.
그다음 foreach 문은 간략하게 배열을 끝날때까지 반복해주는 문법인데, foreach ( 콜렉션타입 인자 in 콜렉션) 으로
remainingCards 배열 안의 card 인자들에 대해서 다음과 같은 일을 수행케했다.
만약 card값이 null이아니라면, 먼저 카드들을 앞면으로 오픈하고, 0,01초 뒤에
availablePositions 라는 새로운 벡터2 타입의 리스트를 만들어서
pos라는 Vector2 타입 변수에 해당 리스트의 카드의 slotindex에 맞게 좌표값들을 넣어준다.
그리고 앞서 만든 availablePositions 리스트에 전부 넣어준다.
여기서 slotIndex의 경우 Card.cs에 선언후,
Board.cs에서 카드들을 배열화하여 instantiate하는 상황에서
Go.GetComponent<Card>().slotIndex = i; 라고 같이 넣어서 값들을 slotIndex값에 할당하게 하였다.
그다음
랜덤하게 섞어서 다시 리스트에 넣는 과정을 통해 셔플을 일으켰다.
그리고 for문을 이용하여
remainingCard[i]의 좌표를 아까 섞은 availablePosition[i] 에 들어간 좌표들을 가지고 배정했다.
그다음 0.05초뒤에 카드가 다시 뒤집히고
isSuffling을 false로 만들고 코루틴을 종료시킨다.
private IEnumerator ReshuffleRoutine()
{
if (firstcard != null) // 셔플전에 첫번째 카드를 고르고 셔플이되어버렸을때, 고른카드가 열려있어서 매치시키면 하나만 삭제됌 수정
{
firstcard.CloseCardInvoke();
firstcard = null;
}
if (secondcard != null)
{
secondcard.CloseCardInvoke();
secondcard = null;
}
//if (shuffleMessageUI != null)
//shuffleMessageUI.SetActive(true);
isSuffling = true;
Card[] allCards = Object.FindObjectsByType<Card>(FindObjectsSortMode.None); // 버전확인 2023 이후버전
Card[] remainingCards = allCards.Where(card => card != null).ToArray(); // 파괴된 카드 제외후 배열에 다시 담기
foreach (Card card in remainingCards)
{
if (card != null)
{
card.anim.SetBool("IsOpen", true);
card.front.SetActive(true);
card.back.SetActive(false);
}
}
yield return new WaitForSeconds(0.01f);
List<Vector2> availablePositions = new List<Vector2>();
foreach (Card card in remainingCards)
{
Vector2 pos = new Vector2((card.slotIndex % 4) * 1.4f - 2.1f,
(card.slotIndex / 4) * 1.4f - 3.6f);
availablePositions.Add(pos);
}
availablePositions = availablePositions.OrderBy(p => Random.value).ToList();
for (int i = 0; i < remainingCards.Length; i++)
{
if (remainingCards[i] != null && remainingCards[i].gameObject != null)
{
remainingCards[i].transform.position = availablePositions[i];
}
}
yield return new WaitForSeconds(0.05f);
foreach (Card card in remainingCards)
{
if (card != null)
card.CloseCardInvoke();
}
//if (shuffleMessageUI != null)
// shuffleMessageUI.SetActive(false);
isSuffling = false;
yield break;
}
앞서 말했던 SlotIndex는 Card에 맴버로 선언하고
Board에서도 끌어와서 Go 지역변수를 이용해서 꺼내어서 썼다.
참고로 Card는 Board의 맴버 변수로서 Card프리팹을 엔진 인스펙터창에서 연결햇으며. Card프리팹에는 Card.cs가 달려있어서
Card.cs에서 선언한 SlotIndex는 Board에서 끌어와서 쓸수있는것이다. GetComponent<Card>() 를 이용해 찾아서 반환하며, 뒤에 .(닷)을 붙여서 slotIndex를 찾아 i를 할당했다.
참고로 Go는 Start내에서만 있는 지역변수여서 Board의 인스펙터창에는 맴버가아니므로 찾을 수 없다.
public class Board : MonoBehaviour
{
public GameObject Card;
// Start is called once before the first execution of Update after the MonoBehaviour is created
void Start()
{
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();
for (int i = 0; i < 16; i++)
{
GameObject Go = Instantiate(Card, this.transform);
float x = (i % 4) * 1.4f - 2.1f;
float y = (i / 4) * 1.4f - 3.6f;
Go.transform.position = new Vector2(x, y);
Go.GetComponent<Card>().Setting(arr[i]);
Go.GetComponent<Card>().slotIndex = i;
}
GameManager.instance.cardCount = arr.Length;
}
// Update is called once per frame
void Update()
{
}
}
해당 코루틴 함수는 아래와 같이 if 조건을 만족할때 실행되도록 만들었다. 즉 5초넘어갈때마다 체크후 리셔플이 실시되도록 세팅하였다.
if (time - lastReshuffleTime > 5f && cardCount > 0) // 5초 넘어갈때마다 체크후 리셔플 코루틴 실시
{
StartCoroutine(ReshuffleRoutine());
lastReshuffleTime = time;
}
----------------------------------
두번째 방식은 코루틴이 아닌 방식을 사용하여, 카드를 까지않고 간단하게 바로 셔플을 일어나게하는 방식이다.
아래와 같이 맴버들을 선언한다. 참고로 앞에 접근제한자가 public이나 [SerializeField] .. private 가 없으므로 엔진상의 인스펙터창에는 뜨지않는다. private 또는 internal로 취급되기때문이다.
int[] shuffleArr = { 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7 }; // 스테이지 5 섞일 이미지의 번호가 들어갈 배열
int matchedIdx; // 맞춘 카드의 이미지 번호
int match = 8; // 전체 카드 쌍의 개수
int matchCount = 0; // 맞춘 카드 쌍의 개수
int[] matchedArr; // 맞춘 카드의 이미지 번호를 저장할 배열
그다음 짝을 맞춘지 체크하는
matched() 메서드에 아래와 같이 matchedArr[matchCount] = matchedIdx;
matchCount++ 해준다.
if (firstcard.cardIndex == secondcard.cardIndex)
{
TimeManager.Instance.plusTime();
matchedIdx = firstcard.GetComponent<Card>().cardIndex; // 맞춘 카드의 인덱스 카져오기
// 카드를 맞출 때마다 맞춘 카드의 번호를 배열에 저장
matchedArr[matchCount] = matchedIdx;
matchCount++;
firstcard.DestroyCard();
secondcard.DestroyCard();
}
실행을 위한 Shuffle() 함수는 아래와 같다.
비슷하지만 다른데,
먼저 기존의 씬위에 있는 카드 오브젝트들은 전부 파괴해버리고,
새롭게 재배치하되, isMatched가 true 일경우엔 if문에서 체크후 break; 하고 continue;로 for문을 뛰어넘게 하는 방식으로 짝이 맞은경우에는 배열에 포함되지않게 하는 방식으로 거른뒤에, 새로이 프리팹을 인스턴스로 씬에 생성하는 방식을 사용했다.
public void Shuffle()
{
// 배열 랜덤 정렬(카드 재배치에 사용할 배열)
shuffleArr = shuffleArr.OrderBy(x => Random.Range(0.0f, 7.0f)).ToArray();
// 셔플 이미지 화면에 활성화 후 잠시 뒤 비활성화
shuffleImage.SetActive(true);
Invoke("disableShuffleImage", 0.5f);
// 섞은 카드를 재생성하면 문제가 발생하기에 원래 화면에 있던 카드 제거
foreach (Transform card in board)
{
Destroy(card.gameObject);
}
// 맞춘 카드를 제외한 카드들 재배치
for (int i = 0; i < shuffleArr.Length; i++)
{
int num = shuffleArr[i];
bool isMatched = false;
for (int j = 0; j < matchedArr.Length; j++)
{
if (num == matchedArr[j]) // 맞춘 카드의 이미지 번호와 섞인 배열의 숫자와 비교
{
// 이미 맞춘 카드라면 isMatched 값을 참으로 변경
isMatched = true;
break;
}
}
if (isMatched)
{
continue; // 다음 카드로 넘어감
}
GameObject go = Instantiate(cards, board.transform);
float x = (i % 4) * 1.4f - 2.1f;
float y = (i / 4) * 1.4f - 3.6f;
go.transform.position = new Vector2(x, y);
go.GetComponent<Card>().Setting(shuffleArr[i]);
}
firstcard = null; // 첫번째 카드 초기화
secondcard = null; // 두번째 카드 초기화
}
void disableShuffleImage()
{
shuffleImage.SetActive(false); // 셔플 이미지 비활성화
}'Unity 개발 공부' 카테고리의 다른 글
| [내배캠] 본캠 7일차. 기초문법 조건문,반복문, 메서드, 재귀함수, 클래스, 복습 (0) | 2025.04.15 |
|---|---|
| [내배캠] 본캠 6일차. 기초문법 변수,자료형 그리고 기초연산문제 복습 (0) | 2025.04.14 |
| [내배캠] 본캠 4일차. 팀 카드 뒤집기 게임 Git 병합 충돌 #4 (0) | 2025.04.10 |
| [내배캠] 본캠 3일차. 팀 카드 뒤집기 게임 #3 (0) | 2025.04.09 |
| [내배캠] 본캠 2일차. 팀 카드 뒤집기 게임 #2 (0) | 2025.04.08 |