들어가며
기억력 카드 뒤집기 게임, 룰도 쉽고 다양한 게임들의 미니 게임으로 활용되기도 하는 게임이다.
처음에 카드의 배치를 전체적으로 보여줄 수도 있고, 아닐 수도 있다. 이때 플레이어는 자신의 기억력에 의존하여(+운) 카드들을 뒤집어 가며 짝을 맞춰 하나씩 카드를 없애는 게임이다.
오늘은 카드 뒤집기(FlipCard) 장르의 퍼즐 게임을 만들어보겠다. 사실 퍼즐이라고 하는 게 맞을지 모르겠지만... 플레이어는 자신의 기억력을 최대한으로 발휘하여 게임을 클리어하려고 하는 게임이니 아슬아슬하게 퍼즐의 범주에 들어가지 않을까 싶다.
전체적인 게임 흐름
우선 안보여줄 수도 있겠지만, 지금 만드는 게임에서는 게임을 시작하면 모든 카드를 "잠깐" 보여준 뒤에 뒷면으로 뒤집어주겠다.
이후, 플레이어는 자신의 기억력에 의존하여 짝이 맞는 카드를 연속으로 2개 선택하고, 해당 카드들이 짝이 맞는 카드라면 해당 카드들은 사라진다.
위와 같은 방식으로 모든 카드를 없애면 해당 스테이지(퍼즐)는 클리어 판정을 내리겠다.
개발 전 고려사항
전체적이고 대략적인 게임 개발 맥락을 잡았으니, 다음은 이 맥락을 구체적으로 만들어 Task(과제)화 시키는 것이다.
가장 먼저, 카드의 핵심 로직들 중 하나인 "짝이 맞는 카드를 어떻게 판단할 것인가?"가 가장 큰 고려사항이 될 것 같다.
이는 카드 데이터의 구조를 어떤 식으로 설계하느냐에 따라서 달라질 것인데, 지금 계획으로는 카드마다 숫자를 부여해 주는 것이다.
그렇다면 어떻게 카드에게 한 쌍의 숫자만을 부여할 수 있을까? 미리 보여주자면 다음과 같은 방식을 썼다.
for (int i = 1; i <= (totalCards - 1) / 2; i++)
{
numbers.Add(i);
numbers.Add(i);
}
여기서 중요한 점은 i를 몇 번 반복하냐가 관건이다. n이 세팅되는 카드의 개수라고 한다면, 총 (n-1) / 2번 반복한다. 4개의 카드가 세팅된다면 2번 반복하는 것이고, 6개면 3번, 12개면 6번, 이때 i 를 부여하면 짝이 맞는 숫자를 부여할 수 있게 된다.
다음은 카드를 몇 개 배치할 것인가도 고려사항 중 하나가 될 수 있다. 가장 먼저 생각한 것은 난이도에 따라서 카드의 개수가 늘어나는 것이다. 그래서 생각한 것이 가장 낮은 난이도에 9개를 세팅하는 것을 생각했다.
여기서 한 단계 더 높아진다면 25개, 하나 더 높아지면 49개....
n이 난이도라고 가정하고 가장 낮은 난이도를 1이라고 할 때, 카드의 갯수는 (2n-1)^2이 될 것이다.
그렇다면, 카드의 개수가 홀수가 되지 않느냐는 물음이 있을 수 있다. 당연히 생각해 두고 의도한 사항이다.
생성되는 카드의 수가 홀수이면, 지금 개발하려는 게임 구조상 당연히 하나가 남을 것이다. 나는 그 카드를 조커 카드로 일종의 함정 또는 꽝 같은 느낌으로 만들 것이다.
개발 계획
이제 어느 정도 골자가 잡혔다고 판단했고, 중간 목표들을 정하기 위해 순서를 계획했다. 순서는 다음과 같다.
1. 기본 세팅
2. 카드 생성
3. 카드 상호작용 구현
4. 카드 매치 구현
5. 클리어 판정
개발 단계
그 동안 이 챌린지를 진행하면서 언급을 안 했던 것 같은데, 지금 진행하는 챌린지 겸 소규모 프로젝트는 모두 하나의 유니티 프로젝트에서 진행되고 있다. 즉, 퍼즐 장르의 게임을 모조리 모은 프로젝트가 탄생할 것이다.
(여차하면 지금 만들고 있는 프로토타입의 UI와 시스템을 손봐서 포트폴리오용 또는 구글 플레이에 출시 과정을 공부할 때 사용하지 않을까 싶다.)
그래서 폴더 정리에 신경을 쓰고 있고, 스크립트의 경우에는 namespace를 사용해서 관리하고 있었다.
다른 이름으로 클래스를 만들면 되는거 아닌가 생각이 들 수도 있다.
하지만 이름이 겹치는 클래스들은 대부분 기능이 비슷하고 상호작용하는 클래스들도 한정적이기 때문에 namespace를 활용해서 혹시라도 다른 게임의 클래스를 불러오면 안 되니 일종의 안전장치의 역할도 겸하고 있다고 생각하면 될 것 같다.

기본 세팅
기본 세팅은 그렇게 비중있게 차지할 부분도 아니라고 생각해서 빠르게 넘기도록 하겠다.

카드의 구성은 위 사진처럼 만들었다.
기본 세팅단계에서 이루어진 단계들은 대부분 이전 단계와 동일하게 만들었으며, 사람마다 달라질 수도 있으니 크게 중요한 것은 아니라 생각되어 넘어가겠다.
또한, PuzzleController 클래스도 미리 만들어두었다.
코드는 다음과 같다.
using UnityEngine;
namespace FlipCard
{
public class PuzzleController : MonoBehaviour
{
[SerializeField]
CardGenerater cardGenerater;
private int level;
[SerializeField]
public int Level
{
get => level;
set
{
level = value;
Debug.Log($"Level set to: {level}");
}
}
private void Start()
{
GameStart();
}
private void GameStart()
{
//테스트용 기본값
level = 1;
cardGenerater.GenerateCard();
}
}
}
카드 생성
카드의 크기는 200,300으로 2:3의 비율이고, 카드들의 간격은 100으로 두었다.
개발 전 고려사항에서 얘기 했던 것처럼 기본 난이도(가장 낮은 난이도)에서는 3*3 으로 9장의 카드가 배치된다. 그리고 난이도에 따라서 더 많은 양의 카드가 배치될 것이다.
그렇다면 보드의 크기는 1000, 1300이 되어야 카드들의 배치가 균형 있게 보일 것이다.

때문에 카드의 갯수가 늘어날수록 카드의 크기가 줄어들고 그에 따라서 배치되는 위치도 달라져야 한다.
Grid Layout Group 컴포넌트를 사용한다면 위치까지 계산을 할 필요는 없겠지만, 컴포넌트의 세팅이 달라질 경우 UI가 무너지는 것을 막기 위해서 모든 것을 코드로 관리하도록 하기로 했다.
이를 수식으로....표현할 자신은 없다.
하지만 코드로 표현하면 다음과 같다.
private Vector2 GetCardSize(out float gap)
{
int level = GameObject.FindAnyObjectByType<PuzzleController>().Level;
// 3x3 기준 전체 길이 (가로 기준)
float totalLength = 3 * baseCardWidth + 2 * baseGap;
// 스케일 비율 계산식
float scale = totalLength / ((2 * level + 1) * baseCardWidth + 2 * level * baseGap);
// 비율에 따라 축소된 크기 계산
float newWidth = baseCardWidth * scale;
float newHeight = baseCardHeight * scale;
gap = baseGap * scale;
return new Vector2(newWidth, newHeight);
}
이 코드를 이용하여 카드를 생성하는 CardGenerater 클래스를 작성해주었다.
using UnityEngine;
namespace FlipCard
{
public class CardGenerater : MonoBehaviour
{
[SerializeField]
private GameObject cardPrefab;
[Header("기본 카드 설정 (3x3 기준)")]
[SerializeField] private float baseCardWidth = 200f;
[SerializeField] private float baseCardHeight = 300f;
[SerializeField] private float baseGap = 100f;
public void GenerateCard()
{
float gap = 0;
int level = GameObject.FindAnyObjectByType<PuzzleController>().Level;
// 카드 크기 및 간격 계산
Vector3 size = GetCardSize(out gap);
float w = size.x;
float h = size.y;
int count = 2 * level + 1;
// 전체 폭과 높이
float totalWidth = count * w + (count - 1) * gap;
float totalHeight = count * h + (count - 1) * gap;
// 중심 기준 좌표 시작점
float startX = -totalWidth / 2f + w / 2f;
float startY = totalHeight / 2f - h / 2f;
// 카드 배치 루프
for (int y = 0; y < count; y++)
{
for (int x = 0; x < count; x++)
{
Vector3 pos = new Vector3(
startX + x * (w + gap),
startY - y * (h + gap),
0f
);
GameObject card = Instantiate(cardPrefab, transform);
card.transform.localPosition = pos;
card.transform.localScale = Vector3.one * (w / baseCardWidth); // 비율에 맞춰 축소
card.name = $"Card( {x}, {y} )"; //카드 이름 설정
}
}
Debug.Log($"Generated {count * count} cards of size ({w}, {h}) with gap {gap}");
}
private Vector2 GetCardSize(out float gap)
{
int level = GameObject.FindAnyObjectByType<PuzzleController>().Level;
// 3x3 기준 전체 길이 (가로 기준)
float totalLength = 3 * baseCardWidth + 2 * baseGap;
// 스케일 비율 계산식
float scale = totalLength / ((2 * level + 1) * baseCardWidth + 2 * level * baseGap);
// 비율에 따라 축소된 크기 계산
float newWidth = baseCardWidth * scale;
float newHeight = baseCardHeight * scale;
gap = baseGap * scale;
return new Vector2(newWidth, newHeight);
}
}
}
이후 테스트를 위해서 CardGenerater와 PuzzleController를 씬에 붙여주었다.


CardGenerater는 무조건 Board(카드의 상위 오브젝트)에 붙여야 한다.
애초에 Board에 붙이는 것을 전제로 코드를 짰다. (왜냐하면 코드에 카드가 생성되는 transform을 자기 자신으로 썼기 때문이다.)
이후 테스트를 진행해 보았다.



테스트 결과는 잘 작동되고 있는 것을 확인할 수 있었다.
짝이 맞는 카드와 조커 생성
카드 상호작용 구현 단계로 넘어가기 전에 해야 할 것이 있다.
바로 카드가 짝이 맞다고 판단할 근거가 있어야 한다. 그리고 이를 플레이어가 눈으로 확인이 가능해야 할 것이다. 그래서 각 카드에 숫자를 부여하고, 카드가 부여받은 숫자를 텍스트로 확인할 수 있도록 만드는 작업을 해야 한다.

그래서 카드의 앞면에 텍스트를 추가했다.
그리고 텍스트의 설정은 다음과 같다.

텍스트의 크기는 BestFit을 사용하기로 했다. (좋아하지는 않아 자주 사용하지는 않는다.)
카드의 크기는 level에 따라서(정확히는 배치에 따라서 크기가 달라지기 때문에) 계속해서 달라지기 때문에 각 크기에 맞는 적당한 폰트 사이즈를 알아서 정해주는 유니티의 기능을 적극 활용하기로 했다.
다음은 Card 클래스이다.
using UnityEngine;
using UnityEngine.UI;
namespace FlipCard
{
public enum CardState
{
Front,
Back
}
public enum CardType
{
Normal,
Joker,
}
public class Card : MonoBehaviour
{
[SerializeField]
private GameObject cardFront;
[SerializeField]
private GameObject cardBack;
[SerializeField]
private Text cardNumText;
[SerializeField]
private CardType cardType = CardType.Normal;
public CardType Type
{
get { return cardType; }
set { cardType = value; }
}
[SerializeField]
private CardState state = CardState.Front;
public CardState State
{
get { return state; }
set
{
state = value;
switch (state)
{
case CardState.Front:
cardFront.SetActive(true);
cardBack.SetActive(false);
break;
case CardState.Back:
cardFront.SetActive(false);
cardBack.SetActive(true);
break;
}
}
}
[SerializeField]
private int cardNum;
public int CardNum
{
get { return cardNum; }
set
{
cardNum = value;
cardNumText.text = cardNum.ToString();
}
}
private Vector2Int cardIdx;
public Vector2Int CardIdx
{
get { return cardIdx; }
}
public void SetUpCard(int number, Vector2Int index)
{
CardNum = number;
cardIdx = index;
if(cardNum == 0) cardType = CardType.Joker;
else cardType = CardType.Normal;
Init();
}
private void Init()
{
Debug.Log($"Card Init {cardIdx}");
State = CardState.Front;
}
}
}
이전 퍼즐 게임들을 만들 때, OnEnable에서 Init 함수를 호출하는 방식을 자주 사용했다. 하지만, 그럴 경우 생명 주기 문제가 발생하는 경우가 생겨서 CardIndex가 제대로 반영하지 못할 상황이 나올 수도 있기에 이를 미연에 방지하고자 했다.
그래서, CardGenerater에서 호출하는 SetupCard 함수가 OnEnable보다 먼저 호출되기에 cardIdx가 초기화되기 전에 호출되어 만일의 상황에 대비하도록 하였다.
Card의 정보를 초기화해 주기 위한 코드가 있어야 하니, 이는 CardGenerater가 카드를 생성하는 것과 동시에 할 수 있도록 하겠다. 수정된 CardGenerater 클래스는 다음과 같다.
using System.Collections.Generic;
using UnityEngine;
namespace FlipCard
{
public class CardGenerater : MonoBehaviour
{
[SerializeField]
private GameObject cardPrefab;
[Header("기본 카드 설정 (3x3 기준)")]
[SerializeField] private float baseCardWidth = 200f;
[SerializeField] private float baseCardHeight = 300f;
[SerializeField] private float baseGap = 100f;
/// <summary>
/// (2n+1)x(2n+1) 카드들을 생성하고, 짝수 카드 + 조커 1장을 부여하여 2차원 배열로 반환합니다.
/// </summary>
public Card[,] GenerateCard()
{
float gap = 0;
int level = GameObject.FindAnyObjectByType<PuzzleController>().Level;
Vector2 size = GetCardSize(out gap);
float w = size.x;
float h = size.y;
int count = 2 * level + 1;
int totalCards = count * count;
// 카드 번호 목록 생성
List<int> numbers = new List<int>();
// 짝수 카드 번호 채우기 (예: 1,1,2,2,3,3,...)
for (int i = 1; i <= (totalCards - 1) / 2; i++)
{
numbers.Add(i);
numbers.Add(i);
}
// 조커(마지막 카드)
numbers.Add(0);
// 카드 번호 섞기
for (int i = 0; i < numbers.Count; i++)
{
int rand = Random.Range(i, numbers.Count);
(numbers[i], numbers[rand]) = (numbers[rand], numbers[i]);
}
// 카드 2차원 배열 생성
Card[,] cards = new Card[count, count];
// 전체 폭과 높이
float totalWidth = count * w + (count - 1) * gap;
float totalHeight = count * h + (count - 1) * gap;
// 중심 기준 좌표 시작점
float startX = -totalWidth / 2f + w / 2f;
float startY = totalHeight / 2f - h / 2f;
int index = 0;
// 카드 배치 루프
for (int y = 0; y < count; y++)
{
for (int x = 0; x < count; x++)
{
Vector3 pos = new Vector3(
startX + x * (w + gap),
startY - y * (h + gap),
0f
);
// 카드 생성
GameObject cardObj = Instantiate(cardPrefab, transform);
cardObj.transform.localPosition = pos;
cardObj.transform.localScale = Vector3.one * (w / baseCardWidth);
cardObj.name = $"Card({x},{y})";
// 카드 설정
Card card = cardObj.GetComponent<Card>();
int num = numbers[index];
card.SetUpCard(num, new Vector2Int(x, y));
cards[x, y] = card;
index++;
}
}
Debug.Log($"Generated {count}x{count} cards (total: {totalCards}), Joker included.");
return cards;
}
/// <summary>
/// 카드 크기 및 간격 계산
/// </summary>
private Vector2 GetCardSize(out float gap)
{
int level = GameObject.FindAnyObjectByType<PuzzleController>().Level;
float totalLength = 3 * baseCardWidth + 2 * baseGap;
float scale = totalLength / ((2 * level + 1) * baseCardWidth + 2 * level * baseGap);
float newWidth = baseCardWidth * scale;
float newHeight = baseCardHeight * scale;
gap = baseGap * scale;
return new Vector2(newWidth, newHeight);
}
}
}
수정된 CardGenerater 코드는 기존 카드 생성 로직을 활용하는 선에서 카드 부여 및 초기화 로직을 추가하였다.
새롭게 추가된 초기화 로직에는 카드들에게 부여할 번호를 카드의 총 장수 -1만큼 리스트를 만든 뒤, 번호들을 2개씩(1,1, 2,2, 3,3...) 리스트에 넣었다.
그렇다면 부여할 번호가 리스트에 순서대로 들어갔으니 그대로 사용을 하면, 카드들에게도 규칙적으로 번호가 부여될 테니 셔플을 해주었다.
그 다음, 카드를 생성하고 난 뒤에 카드를 초기화하는 과정에서 리스트에 넣어놨던 번호들을 하나씩 뽑아서 카드를 초기화하는 과정(Init 함수)에 인자값으로 전달해 주었다.
초기화와 배치가 완료된 카드는 2차원 배열에 담고, 마지막에는 해당 2차원 배열을 반환해 주었다.
이제 이 2차원 배열은 카드들의 정보를 담고 있는 배열로써, PuzzleController가 해당 배열을 반환받고 가지고 있으면서 관리를 할 것이다.
그러니, PuzzleGenerater 클래스에도 2차원 배열을 선언하고 함수가 반환한 배열을 대입해 주도록 코드를 수정했다.
코드는 다음과 같다.
using UnityEngine;
namespace FlipCard
{
public class PuzzleController : MonoBehaviour
{
[SerializeField]
CardGenerater cardGenerater;
private int level;
[SerializeField]
public int Level
{
get => level;
set
{
level = value;
Debug.Log($"Level set to: {level}");
}
}
private Card[,] cards;
private void Start()
{
GameStart();
}
private void GameStart()
{
//테스트용 기본값
level = 1;
cards = cardGenerater.GenerateCard();
}
}
}
이후 잘 작동하는지 테스트를 해보... 기 전에, 카드 프리팹에 Card 스크립트를 추가해 준 다음에 테스트를 해보았다.


결과는 잘 나오고 있었다.
마무리
기록해 놓았던 내용이 짧은 줄 알았으나, 생각보다 길어서 이곳에 한 번에 올리는 것보다는 다음 글에 이어서 올리는 편이 좋을 것이라 생각해서 이쯤에서 한 번 끊고 가도록 하겠다.
'Game Develop > 소규모 프로젝트' 카테고리의 다른 글
| "하루만에 퍼즐 게임 만들기" 챌린지 (7일차) : 사과 게임(AppleSum) (1) | 2025.12.21 |
|---|---|
| "하루만에 퍼즐 게임 만들기" 챌린지 (6일차) : 해밀토니안 경로 퍼즐 (1) | 2025.12.19 |
| "하루만에 퍼즐 게임 만들기" 챌린지 (4일차) : 파이프 연결 (Connect Pipe) - 에디터 만들기 [외전] (0) | 2025.12.01 |
| "하루만에 퍼즐 게임 만들기" (4일차) : 파이프 연결 (Connect Pipe) [하] (0) | 2025.11.23 |
| "하루만에 퍼즐 게임 만들기" (4일차) : 파이프 연결 (Connect Pipe) [중] (0) | 2025.11.23 |