들어가며
최근에 Java 공부를 시작했다. 그래서 그런지 정신이 없다 보니 블로그 관리에 신경을 못쓰고 있는 것 같다. 그래서 2026년 4월까지는 꽤나 바쁜 삶을 보낼 것 같다. 하지만, 그럼에도 유니티는 꾸준히 만져야겠다는 생각이 들어 1일 1게임 만들기 챌린지를 시작하려 한다.
챌린지에 대한 설명은 다음과 같다.
하루에 퍼즐 게임 하나를 정해서 핵심 로직만 구현하기
규칙
- 순수 시간으로 하루만 투자한다.
- 매일 매일 만들지 않아도 된다. ( 단, 꾸준히 해야 함 )
- 만일 하루 안에 마무리가 되지 않은 경우, 실패로 표기 해놓고 나중에 마무리하기
- 게임을 개발할 때, 연출적인 부분은 생략해도 된다.
- 하루 안에 게임에 쓰이는 로직을 구현하는 것에 목표를 둔다.
실제로 2025년도의 추석 연휴가 절반 넘게 지났을 때부터 시작을 했었는데, 규칙들은 두 번째 게임을 만들게 되던 날에 대부분 세워졌다. 챌린지로 할 생각은 없었으나 첫번째 게임을 만들다가 하루 안에 끝나는 것을 보고 챌린지를 하면 좋겠다는 생각이 들었다. 그리고 두번째 게임을 만들면서 여러 시행착오를 겪으며 규칙을 세우게 되었다.
목표
챌린지 시작 3일 차가 된 지금, 만든 게임은 4개가 있다.
- Sliding Puzzle (슬라이드 퍼즐)
- Match-3 (매치 3 퍼즐)
- Number Sum (숫자 합치기)
- Flip Card (카드 뒤집기)
이 중에 위에 있는 3개는 이번에 챌린지를 진행하면서 만들었고, 카드 뒤집기 같은 경우는 이전에 만들어둔 것을 자료로 가지고 있어서 일단 포함을 시켰다. 하지만, 새로 만들지 가지고 있는 것을 사용할지는 아직은 정하진 않았다. 이 외에도 계획된 퍼즐 게임들은 다음과 같다.
- 파이프 연결 퍼즐 (예시 : Infinite Loop )
- 중복 없는 미로 ( 갔던 길을 다시 밟을 수 없는 미로 )
- 경로 찾기 ( 점을 선으로 겹치지 않게 잇기 )
- 상자 밀기 ( Sokoban류 )
이것은 초기 계획이며, 추후 추가될 가능성이 많다.
슬라이딩 퍼즐 만들기

챌린지의 첫 번째 목표, 슬라이딩 퍼즐이다.
슬라이딩 퍼즐은 단순화한 모습을 보면 위와 같다. 저 숫자가 적혀있는 이미지들을 이제부턴 노드(Node)라고 부르겠다.
이 게임의 규칙은 다음과 같다. 이동을 원하는 노드를 터치&클릭을 하면 인접한 빈칸으로 이동한다. 이와 같은 방식으로 노드들을 올바른 순서대로 옮기면 된다.
(실제 게임에서는 노드들에 숫자가 써있는 대신 순서에 맞게 배치하면 어떤 그림이 완성되는 등으로 완성해야할 배열에 대해 암시적으로 힌트를 준다.)
전치 수
슬라이딩 퍼즐을 만들 때, 로직 상에서 중요한 것은 "전치 수"라는 것이다.
전치 수란, 퍼즐을 1차원 배열로 만들고 그 안의 숫자들을 살펴보면 앞에 있는 숫자가 뒤에 있는 더 작은 숫자보다 큰 경우를 뜻하는 것이다.
이 전치 수의 총합이 짝수라면 그 퍼즐은 풀 수 있는 상태이며, 홀수 일 경우 풀 수 없는 상태이다.
예를 들어 다음과 같은 퍼즐이 있다.
1 2 3
4 5 6
7 8 []
이를 1차원 배열로 본다면 다음과 같다.
[1, 2, 3, 4, 5, 6, 7, 8] //빈칸은 제외한다.
지금은 순서대로 정렬이 되어 있어서 전치 수가 0이라고 볼 수 있다. 하지만, 다음 배열로 보면 전치 수를 이해할 수 있다.
[2, 8, 3, 1, 6, 4, 7, 5]
- 2의 전치 수 : 1개
- 8의 전치 수 : 6개
- 3의 전치 수 : 1개
- 1의 전치 수 : 0개
- 6의 전치 수 : 2개
- 4의 전치 수 : 0개
- 7의 전치 수 : 1개
- 5의 전치 수 : 0개
- 총 전치 수 : 11개 (홀수이므로 풀 수 없는 상태)
슬라이딩 퍼즐의 초기 상태가 랜덤으로 주어질 때, 풀 수 없는 상태로 주어지면 안 되니 이를 보안할 로직이 필요함을 인지해야 한다.
개발
using UnityEngine;
public class NodeGenerater : MonoBehaviour
{
[SerializeField]
GameObject Node; //프리팹
[SerializeField]
Transform NodesTransform; //부모 오브젝트
public int[] nodeNumArr;
public Node[] GenerateNode(int lows , int cols)
{
nodeNumArr = CreateList(lows, cols);
Node[] nodeList = new Node[nodeNumArr.Length];
for (int i = 0; i < nodeNumArr.Length; i++)
{
GameObject newNode = Instantiate(Node ,NodesTransform);
Node nodeScript = newNode.GetComponent<Node>();
nodeScript.Init(nodeNumArr[i], i);
nodeList[i] = nodeScript;
}
return nodeList;
}
public int[] CreateList(int lows, int cols)
{
int[] list = new int[lows * cols];
for (int i = 0; i < lows * cols; i++)
{
list[i] = i;
}
for (int i = list.Length-1; i >= 0; i--)
{
int rand = Random.Range(0, list.Length);
int temp = list[rand];
list[rand] = list[i];
list[i] = temp;
}
if (IsSolvable(list))
{
Debug.Log($"{GetType()}::Can Solvable List");
return list;
}
else
{
Debug.Log($"{GetType()}::Can't Slovable List. Regenerate List.");
return CreateList(lows, cols);
}
}
private bool IsSolvable(int[] arr)
{
int inversionCount = 0;
for (int i = 0; i < arr.Length; i++)
{
if (arr[i] == 8) continue; // 8은 빈칸 (비교 대상 X)
for (int j = i + 1; j < arr.Length; j++)
{
if (arr[j] == 8) continue; // 빈칸은 비교 대상 X
if (arr[i] > arr[j])
inversionCount++;
}
}
return inversionCount % 2 == 0;
}
}
NodeGenerater 클래스는 노드를 생성하는 기능을 담당하고 있다.
GenerateNode 함수는 PuzzleController한테 매개변수를 받아 실제로 노드들을 생성하는 함수이며,
CreatList 함수는 처음에 랜덤으로 주어질 NodeNum을 정하는 기능을 가지고 있고, 이때 만들어진 배열은 IsSolvable 함수의 매개변수가 된다.
IsSolvalbe 함수는 매개변수로 들어온 1차원 배열의 전치 수를 계산하여 풀 수 있는 상태인지 아닌지를 Boolean으로 반환한다. 만약 풀 수 없는 경우에는 다시 CreatList 함수가 재귀하여 다시 배열을 만들고 다시 IsSolvable로 검사한다.
using UnityEngine;
using UnityEngine.UI;
public class Node : MonoBehaviour
{
public int nodeNumber = -1; //노드의 숫자
public int nodeIndex = 0; //배열 안에서의 인덱스 (확인용)
private PuzzleController controller;
[SerializeField]
private Text nodeText;
[SerializeField]
private Image nodeImage;
//public Vector2Int nodePos { get { return new Vector2Int(nodeIndex % low, nodeIndex / low); } private set; }
public bool isBlank { get { return nodeNumber == 8; } }
private void Awake()
{
controller = GameObject.Find("@Controller").GetComponent<PuzzleController>();
}
public void Init(int num, int index)
{
nodeNumber = num;
nodeIndex = index;
gameObject.name = nodeIndex.ToString();
if(nodeNumber == 8)
{
nodeText.text = "";
nodeImage.enabled = false;
}
else
{
nodeText.text = nodeNumber.ToString();
nodeImage.enabled = true;
}
}
public void Swap(int num)
{
nodeNumber = num;
if(nodeNumber == 8)
{
nodeText.text = "";
nodeImage.enabled = false;
}
else
{
nodeText.text = nodeNumber.ToString();
nodeImage.enabled = true;
}
}
public void OnClick()
{
controller.TrySwap(this);
}
}
Node 클래스는 숫자가 쓰여있는 노드들이 가지고 있는 클래스이다.
NodeNum은 노드가 현재 가지고 있는 숫자를 나타내며, NodeIndex는 해당 오브젝트(노드)의 위치를 나타내는 값으로 처음 생성된 이후로 값이 변하지 않는다.
Swap 함수에서 실제로는 노드가 이동하는 것이 아닌, 노드가 가지고 있는 NodeNum을 교환하는 것뿐이다. 노드들은 실제로 좌표 값이 변하지 않고 NodeNum을 교환하고 빈칸에 해당하는 8을 받았을 경우, 빈칸처럼 보이기 위해 이미지 컴포넌트를 끄고, 텍스트값도 빈 값으로 할당해 준다.
using System.Linq;
using UnityEngine;
public class PuzzleController : MonoBehaviour
{
[SerializeField]
int lows = 3;
[SerializeField]
int cols = 3;
[SerializeField]
NodeGenerater m_NodeGenerater;
private Node[] nodes;
[SerializeField]
private int[] nodeNums;
private void Start()
{
nodes = m_NodeGenerater.GenerateNode(lows, cols);
nodeNums = new int[nodes.Length];
for(int i = 0; i < nodes.Length; i++)
{
nodeNums[i] = nodes[i].nodeNumber;
}
}
public void TrySwap(Node argNode)
{
if (argNode.isBlank)
{
Debug.Log($"{GetType()}::argument node is blank.");
return;
}
Node blankNode = null;
if(!IsEmptyNearby(argNode, out blankNode))
{
Debug.Log($"{GetType()}::no empty node.");
return;
}
SwapNode(argNode, blankNode);
if (IsGameEnd())
{
GameEnd();
}
}
bool IsEmptyNearby(Node argNode, out Node blankNode)
{
blankNode = null;
int index = argNode.nodeIndex;
// 왼쪽 노드
if (index % cols != 0) // 같은 행 안에서만 가능
{
Node left = nodes[index - 1];
if (left.isBlank)
{
blankNode = left;
return true;
}
}
// 오른쪽 노드
if (index % cols != cols - 1)
{
Node right = nodes[index + 1];
if (right.isBlank)
{
blankNode = right;
return true;
}
}
// 위쪽 노드
if (index - cols >= 0)
{
Node up = nodes[index - cols];
if (up.isBlank)
{
blankNode = up;
return true;
}
}
// 아래쪽 노드
if (index + cols < nodes.Length)
{
Node down = nodes[index + cols];
if (down.isBlank)
{
blankNode = down;
return true;
}
}
return false;
}
private void SwapNode(Node argNode, Node blankNode)
{
//실제로 교환이 일어나는 것은 NodeNum
//이후 처리는 Node가 알아서
//nodes는 현재 바뀌지 않음, nodeNums가 바뀜
//배열 먼저 바꾸고
nodeNums[argNode.nodeIndex] = blankNode.nodeNumber;
nodeNums[blankNode.nodeIndex] = argNode.nodeNumber;
//NodeNum 바꾸기
int temp = argNode.nodeNumber;
argNode.Swap(blankNode.nodeNumber);
blankNode.Swap(temp);
}
private bool IsGameEnd()
{
int[] correct = { 0, 1, 2, 3, 4, 5, 6, 7, 8 };
//배열의 내용을 비교
if (nodeNums.SequenceEqual(correct))
{
Debug.Log($"{GetType()}::Is game end.");
return true;
}
return false;
}
private void GameEnd()
{
}
}
슬라이딩 퍼즐의 전반적인 관리를 맡는 PuzzleController 클래스이다.
Start 함수에서 배열의 초기화 및 노드 생성 함수를 호출한다.
TrySwap 함수는 노드가 클릭되었을 때, 필요한 프로세스를 진행한다.
IsEmptyNearby 함수는 매개변수로 들어온 노드(클릭된 노드)의 상하좌우에 빈 노드(NodeNum == 8)가 있는지 확인한다.
SwapNode 함수는 실제로 교환이 일어나는 함수이다. (배열 수정과 NodeNum의 교환)
IsGameEnd 함수는 노드의 Swap이 끝나면 게임 클리어 조건을 맞췄는지 확인하는 함수이다.
테스트

클리어 영상은 빠르게 찍을 자신이 없어서 찍지 않았습니다...
'Game Develop > 소규모 프로젝트' 카테고리의 다른 글
| "하루만에 퍼즐 게임 만들기" (4일차) : 파이프 연결 (Connect Pipe) [하] (0) | 2025.11.23 |
|---|---|
| "하루만에 퍼즐 게임 만들기" (4일차) : 파이프 연결 (Connect Pipe) [중] (0) | 2025.11.23 |
| "하루만에 퍼즐 게임 만들기" 챌린지 (4일차) : 파이프 연결 (Connect Pipe) [상] (1) | 2025.11.17 |
| "하루만에 퍼즐 게임 만들기" 챌린지 (3일차) : 숫자 합치기 (2048) (1) | 2025.11.07 |
| "하루만에 퍼즐 게임 만들기" 챌린지 (2일차) : 매치-3 (애니팡) (1) | 2025.11.03 |