들어가며
오늘의 퍼즐 게임은 2048과 같은 게임이다. 챗GPT는 이를 "숫자 합치기"라고 소개하였고, 그래서 이 퍼즐의 장르를 NumberSum이라고 임시 이름을 주었다.
NumberSum의 규칙은 다음과 같다.
- 보드는 4*4로 이루어져 있다.
- 드래그를 통해 방향을 입력받고, 입력받은 방향으로 모든 노드들이 이동한다.
- 만약 같은 숫자를 가지고 있는 노드들이 이동하다가 만나면 병합(merge)한다.
- 병합한 노드는 숫자가 더해지고, 색도 달라진다.
숫자 합치기 게임도 고려사항은 존재한다.
- 드래그를 입력받는 방식은 매치3 퍼즐 게임의 방식을 채택한다.
- 2차원 배열을 사용하여 노드들의 정보를 등록하고 관리한다.
- 노드는 숫자를 담는 컨테이너일 뿐이다. (움직이는 것은 숫자 데이터뿐)
- 빈칸( 빈 노드 )은 0이라는 숫자를 가진 노드로 한다.
보드의 형태
0,3 1,3 2,3 3,3
0,2 1,2 2,2 3,2
0,1 1,1 2,1 3,1
0,0 1,0 2,0 3,0
//rows : 세로 y, 행
//cols : 가로 x, 열
실제 보드는 이런 식으로 구성이 될 것이며, 숫자들은 노드가 2차원 배열에서 가지고 있을 index이다.
실제 구현
이제 코드 짜기 전에 고려해야 할 사항들은 모두 점검한 것 같다. 그렇다면 일단 계획의 큰 틀을 나누어 개발을 진행해 보도록 하겠다.
보드와 노드의 생성
보드는 노드를 생성하는 NodeGenerater의 역할이자 배경의 역할을 맡을 것이다. 하위 오브젝트로는 Nodes라는 빈 오브젝트를 가지고 있으며, 보드를 꽉 채우는 크기이다.
Nodes는 Grid Rayout 컴포넌트를 가지고 있어서, 하위 오브젝트로 생성될 노드들의 정렬을 자동으로 이루어질 것이다.
노드들은 처음 계획한 대로 4*4의 크기로 생성이 될 것이며, 크기도 어색하지 않도록 만들어주었다.

지금 단계에서 만들 스크립트는 Node, PuzzleController, NodeGenerater이다. 각 스크립트의 내용은 다음과 같다.
Node
using UnityEngine;
using UnityEngine.UI;
namespace NumberSum
{
public class Node : MonoBehaviour
{
public int NodeNumber;//노드가 가지고 있는 숫자
Vector2Int NodePos; //노드의 위치
Image NodeImage;
Text NodeText;
[SerializeField]
private PuzzleController Controller;
private void OnEnable()
{
NodeImage = GetComponent<Image>();
NodeText = GetComponentInChildren<Text>();
Controller = GameObject.Find("@Controller").GetComponent<PuzzleController>();
}
public void SetInfo(int number, Vector2Int pos, Color color)
{
NodeNumber = number;
NodePos = pos;
NodeImage.color = color;
if (NodeNumber > 0)
{
NodeText.text = NodeNumber.ToString();
}
else
{
NodeText.text = "";
}
gameObject.name = NodePos.ToString();
}
}
}
PuzzleController
using UnityEngine;
namespace NumberSum
{
public class PuzzleController : MonoBehaviour
{
[SerializeField]
private int rows = 4; //세로 y , 행
[SerializeField]
private int cols = 4; //가로 x , 열
[SerializeField]
private NodeGenerater m_NodeGenerater;
[SerializeField]
Node[,] nodes;
private void Start()
{
GameStart();
}
public void GameStart()
{
nodes = m_NodeGenerater.GenerateNode(rows, cols);
}
}
}
NodeGenerater
using UnityEngine;
namespace NumberSum
{
public class NodeGenerater : MonoBehaviour
{
[SerializeField]
private GameObject Node;
[SerializeField]
private Transform Trs;
[SerializeField]
private Color[] Colors;
public Node[,] GenerateNode(int rows, int cols)
{
int count = rows * cols;
Node[,] nodes = new Node[cols, rows];
for (int y = 0; y < rows; y++)
{
for(int x = 0; x < cols; x++)
{
nodes[x, y] = Instantiate(Node, Trs).GetComponent<Node>();
//모두다 빈칸으로 생성
nodes[x, y].SetInfo(0 , new Vector2Int(x, y), Colors[0]);
}
}
return nodes;
}
}
}
각 클래스들의 설명은 다음과 같다.
Node는 노드들이 각자 자신의 정보를 담고 있을 클래스이다.
PuzzleController는 이 장르의 퍼즐 게임의 전체적인 관리를 담당하는 매니저 같은 역할이다.
NodeGenerater는 쿼리에 따라 Node를 생성하는 등의 기능을 제공한다.
노드의 랜덤 생성
보드와 노드를 만들었다면, 이제는 노드가 무작위 위치에 생성이 되도록 만드는 기능을 구현할 것이다.
구현할 모습은 다음과 같다.

코드 구현은 다음과 같다.
Node
using UnityEngine;
using UnityEngine.UI;
namespace NumberSum
{
public class Node : MonoBehaviour
{
public int NodeNumber;//노드가 가지고 있는 숫자
Vector2Int NodePos; //노드의 위치
Image NodeImage;
Text NodeText;
[SerializeField]
private PuzzleController Controller;
private void OnEnable()
{
NodeImage = GetComponent<Image>();
NodeText = GetComponentInChildren<Text>();
Controller = GameObject.Find("@Controller").GetComponent<PuzzleController>();
}
public void SetInfo(int number, Vector2Int pos, Color color)
{
NodeNumber = number;
NodePos = pos;
NodeImage.color = color;
if (NodeNumber > 0)
{
NodeText.text = NodeNumber.ToString();
}
else
{
NodeText.text = "";
}
gameObject.name = NodePos.ToString();
}
public void ClearNode()
{
SetInfo(0, NodePos, Color.white);
}
public void MergeNode(int number, Color color)
{
SetInfo(number, NodePos, color);
}
public bool IsEmpty()
{
return NodeNumber == 0;
}
}
}
Node 클래스에는 3개의 함수를 추가하였다. 각각 ClearNode, MergeNode, IsEmpty이다.
ClearNode 함수는 노드 2개가 병합이 된다면, 1개는 빈 노드로 변해야 할 것이니, 미리 만들어 놓은 함수이다.
MergeNode 함수도 ClearNode 함수와 같은 맥락으로 미리 만들어둔 함수이다.
중요한 건 IsEmpty 함수이다. 해당 노드가 비어있는지(NodeNum == 0)를 boolean 값으로 반환하는 함수이다. 다른 방법으로는 프로퍼티로 만들어두는 방법도 있다.
노드들 중에서 노드를 하나 고르고, 그 노드가 비어있다면 "숫자 2" 노드를 생성(실제로는 생성은 아니지만)을 시킬 것이기 때문에 필요했다.
PuzzleController
using UnityEngine;
namespace NumberSum
{
public class PuzzleController : MonoBehaviour
{
[SerializeField]
private int rows = 4; //세로 y , 행
[SerializeField]
private int cols = 4; //가로 x , 열
[SerializeField]
private NodeGenerater m_NodeGenerater;
[SerializeField]
Node[,] nodes;
private void Start()
{
GameStart();
}
public void GameStart()
{
nodes = m_NodeGenerater.GenerateNode(rows, cols);
for(int i = 0; i < 2; i++)
{
m_NodeGenerater.RandomGenerateNode(nodes, rows, cols);
}
}
}
}
하나의 스테이지를 담당하는 역할인 PuzzleController는 씬이 시작되면 NodeGenerater의 노드 랜덤 생성 함수를 호출할 것이다.
그런데 2개를 실행시킬 것이니, 반복문으로 2번 돌렸다.
NodeGenerater
using System.Data;
using UnityEngine;
namespace NumberSum
{
public class NodeGenerater : MonoBehaviour
{
[SerializeField]
private GameObject Node;
[SerializeField]
private Transform Trs;
[SerializeField]
private Color[] Colors;
public Node[,] GenerateNode(int rows, int cols)
{
int count = rows * cols;
Node[,] nodes = new Node[cols, rows];
for (int y = 0; y < rows; y++)
{
for(int x = 0; x < cols; x++)
{
nodes[x, y] = Instantiate(Node, Trs).GetComponent<Node>();
//모두다 빈칸으로 생성
nodes[x, y].SetInfo(0 , new Vector2Int(x, y), Colors[0]);
}
}
return nodes;
}
public void RandomGenerateNode(Node[,] nodes , int rows, int cols)
{
Debug.Log($"{GetType()}::RandomGenerateNode");
int randX = Random.Range(0, cols);
int randY = Random.Range(0, rows);
if (nodes[randX, randY].IsEmpty())
{
nodes[randX, randY].MergeNode(2, Colors[1]);
}
else
{
RandomGenerateNode(nodes, rows, cols);
}
}
}
}
RandomGeneraterNode가 실제로 노드를 생성시키는 구간이다.
PuzzleController한테 Node들의 현재 상태들을 저장하고 있는 2차원 배열을 받고, 그중에서 랜덤한 노드 하나를 선택한다.
그리고 그 노드가 비어있다면(NodeNum == 0) 해당 노드에 "숫자 2 노드"의 정보를 뒤집어 씌우는 것으로 노드의 랜덤 생성을 처리했다.
노드의 이동과 병합
노드의 조작 방식은 시작하기 전에 정했듯이 조작 방식은 2일 차에 만들었던 매치 3의 방식을 따라가기로 했다. 다만, 특정 노드를 선택하는 방식이 아니라는 점을 고려하여 코드를 작성해야 한다.
유저의 조작을 받아 방향을 전달하는 클래스인 TouchController를 만들어주겠다. 코드는 다음과 같다.
using UnityEngine;
using UnityEngine.EventSystems;
namespace NumberSum
{
public class TouchController : MonoBehaviour, IPointerDownHandler, IPointerUpHandler
{
Vector2 touchDownPos;
[SerializeField]
PuzzleController controller;
private void Start()
{
controller = GameObject.Find(@"Controller").GetComponent<PuzzleController>();
}
public void OnPointerDown(PointerEventData eventData)
{
touchDownPos = eventData.position;
}
public void OnPointerUp(PointerEventData eventData)
{
Vector2 touchUpPos = eventData.position;
Vector2 delta = touchUpPos - touchDownPos;
// 드래그 거리가 너무 짧으면 무시
if (delta.magnitude < 2f)
return;
Vector2Int dir;
if (Mathf.Abs(delta.x) > Mathf.Abs(delta.y))
{
// 수평 이동
dir = (delta.x > 0) ? Vector2Int.right : Vector2Int.left;
}
else
{
// 수직 이동
dir = (delta.y > 0) ? Vector2Int.up : Vector2Int.down;
}
controller.MoveNodes(dir);
}
}
}
IPointerDown과 IPonterUp 인터페이스들을 통해 클릭 버튼을 누른 순간(터치를 하는 순간)과 클릭에서 손을 떼는 순간(터치를 떼는 순간)을 알 수 있게 되었다. 이 두 이벤트가 일어나는 시점에 마우스(터치라면 손가락의 위치)의 위치를 가져와서 서로 빼서 방향을 구해주고, PuzzleController의 MoveNodes 함수의 매개변수로 전달해 주었다.
이제 이걸로 유저가 원하는 이동 방향을 구할 수 있게 되었으니, 실제로 노드가 이동하는 기능을 구현하겠다.
노드의 이동을 구현하기 전에, 고려해야 할 점이 있다. 그것은 바로 이동 방향에 따라 반복문의 순서(혹은 범위까지)가 달라질 수 있다는 점이다.
유저가 오른쪽으로 드래그(혹은 슬라이드)를 했다면, 모든 노드가 오른쪽으로 이동해야 한다. 이때, 만일 왼쪽의 노드부터 이동 처리를 해준다고 생각해 보자.
2 0 2 2
2 4 2 2
예를 들어서 이렇게 숫자를 가진 두 줄이 존재한다고 가정한다면, 왼쪽의 노드부터 이동처리를 해주자.
윗줄의 프로세스는 이렇게 될 것이다.
- 가장 첫 번째 2의 오른쪽이 빈칸(NodeNumber == 0)인가? > Yes > 이동한다
- 이동했던 노드의 오른쪽이 빈칸인가? > No
- 그렇다면 자신과 같은 숫자인가? > Yes (세 번째 노드도 2이므로) > 병합한다 > 4가 되었다.
- 병합했던 노드의 오른쪽이 빈칸인가? > No
- 그렇다면 자신과 같은 숫자인가? > No > 다음 노드의 이동 처리(종료)
이런 식으로 흘러갈 것이다. 그렇다면 첫 번째 줄의 결과 생각해 보자면 다음과 같다.
0 0 4 2
우리가 생각하기에 이것이 자연스러운가? 개발자는 자신이 게임을 개발했으니 그 안에 숨겨져 있는 코드들을 이해하고, 자신이 작성했으니 이해할 것이다. 다만, 유저의 입장에서 본다면 납득이 가능할까?
위의 예시를 이어서 해본다면 두 줄이 오른쪽으로 슬라이드 했을 때의 결과(반복문이 왼쪽 노드부터 시작할 때)는 다음과 같을 것이다.
0 0 4 2
2 4 0 4
그렇다면 오른쪽 노드부터 이동 처리를 한다면 어떻게 될까?
0 0 2 4
0 0 2 8
또는
0 0 2 4
0 2 4 4
처리를 어디까지 했느냐에 따라 다르겠지만, 위의 예시처럼 되는 것이 자연스러울 것이다. 이렇게 보니, 반복문의 순서가 왼쪽부터(정순) 처리되느냐 오른쪽(역순)부터 처리되느냐에 따라 많은 것이 달라지는 확인할 수 있다.
PuzzleController
using UnityEngine;
namespace NumberSum
{
public class PuzzleController : MonoBehaviour
{
[SerializeField]
private int rows = 4; //세로 y , 행
[SerializeField]
private int cols = 4; //가로 x , 열
[SerializeField]
private NodeGenerater m_NodeGenerater;
[SerializeField]
Node[,] nodes;
private void Start()
{
GameStart();
}
public void GameStart()
{
nodes = m_NodeGenerater.GenerateNode(rows, cols);
for(int i = 0; i < 2; i++)
{
m_NodeGenerater.RandomGenerateNode(nodes, rows, cols);
}
}
public void MoveNodes(Vector2Int dir)
{
if (dir.x == 1) // 오른쪽
{
for (int y = 0; y < rows; y++)
for (int x = cols - 2; x >= 0; x--) // 오른쪽부터 검사
TryMove(x, y, dir);
}
else if (dir.x == -1) // 왼쪽
{
for (int y = 0; y < rows; y++)
for (int x = 1; x < cols; x++)
TryMove(x, y, dir);
}
else if (dir.y == 1) // 위
{
for (int x = 0; x < cols; x++)
for (int y = rows - 2; y >= 0; y--)
TryMove(x, y, dir);
}
else if (dir.y == -1) // 아래
{
for (int x = 0; x < cols; x++)
for (int y = 1; y < rows; y++)
TryMove(x, y, dir);
}
//노드의 이동 및 병합이 끝나면 새로운 노드 추가
for(int i = 0; i < 2; i++)
{
m_NodeGenerater.RandomGenerateNode(nodes, rows, cols);
}
}
private void TryMove(int x, int y, Vector2Int dir)
{
Node current = nodes[x, y];
if (current.IsEmpty()) return;
int targetX = x;
int targetY = y;
while (true)
{
int nextX = targetX + dir.x;
int nextY = targetY + dir.y;
if (nextX < 0 || nextX >= cols || nextY < 0 || nextY >= rows)
break; // 경계 밖이면 멈춤
Node next = nodes[nextX, nextY];
if (next.IsEmpty())
{
// 빈 칸이면 숫자를 옮긴다
next.MergeNode(current.NodeNumber, current.GetColor());
current.ClearNode();
targetX = nextX;
targetY = nextY;
current = next; // 계속 진행
}
else if (next.NodeNumber == current.NodeNumber)
{
// 같은 숫자면 병합
next.MergeNode(next.NodeNumber * 2, current.GetColor());
current.ClearNode();
break;
}
else
{
// 다른 숫자면 멈춤
break;
}
}
}
}
}
PuzzleController에서는 MoveNodes 함수는 매개변수로 방향을 받는다. 매개변수로 들어온 dir(방향)에 맞춰서 각 노드의 x, y 그리고 dir를 매개변수로 TryMove 함수를 호출한다. 그리고 모든 노드들의 이동 처리가 끝나면 새로운 노드가 추가되는 함수를 호출한다.
즉, MoveNodes는 슬라이드가 입력되었다면 이루어져야 하는 프로세스라고 볼 수 있다.
TryMove 함수는 실제 이동할 Node를 매개변수로 받은 x, y를 통해 선택하고, 해당 노드가 이동 또는 병합을 해야 하면 해당하는 처리를 실행시키는 함수이다.
(병합 처리에서 색상을 잘못 지정한 오타가 있음, 지금 코드라면 병합을 해도 색이 그대로일 것이다. 최종 코드에서는 수정함)
Node
public Color GetColor()
{
return NodeImage.color;
}
Node 클래스에는 GetColor 함수를 추가하여 현재 색상의 정보를 PuzzleController가 쉽게 접근할 수 있도록 만들었다.
교착 상태 처리 (게임 오버)
2048 게임에서 게임오버가 되는 순간은 언제일까? 바로 더 이상 움직일 곳이 없을 때이다. 즉, 게임의 진행이 불가능한 상태를 말할 것이다. 이런 상태가 되는 순간 게임은 종료가 될 것이다.
게임오버를 판단하는 것은 언제가 되어야 할까? Node들의 이동 처리가 모두 끝난 뒤일 것이다.
그렇다면 어떻게 판단할 것인가?
- 빈칸이 하나라도 있으면 교착 상태가 아님 (이동이 가능함)
- 인접한 칸 중 같은 숫자가 하나라도 있으면 교착 상태가 아님 (이동하여 병합이 가능함)
- 그 외에는 모두 교착 상태 (게임오버)
위와 같은 조건들이 게임오버를 판단하는 조건들이 될 것이다. 이를 좀 더 코드적으로 풀어본다면 이와 같을 것이다.
보드에 빈칸(NodeNumber == 0 || NodeImage.color == Color.white)이 없고, 인접한 노드 중 같은 숫자(NodeNumber)가 없으면 교착 상태이다.
코드로 구현한 것은 다음과 같다.
PuzzleController
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace NumberSum
{
public class PuzzleController : MonoBehaviour
{
[SerializeField]
private int rows = 4; //세로 y , 행
[SerializeField]
private int cols = 4; //가로 x , 열
[SerializeField]
private NodeGenerater m_NodeGenerater;
[SerializeField]
private Text GameoverText;
[SerializeField]
Node[,] nodes;
public bool isGameOver = false;
private void Start()
{
GameStart();
}
public void GameStart()
{
GameoverText.gameObject.SetActive(isGameOver);
nodes = m_NodeGenerater.GenerateNode(rows, cols);
for(int i = 0; i < 2; i++)
{
m_NodeGenerater.RandomGenerateNode(nodes, rows, cols);
}
}
public void MoveNodes(Vector2Int dir)
{
if (dir.x == 1) // 오른쪽
{
for (int y = 0; y < rows; y++)
for (int x = cols - 2; x >= 0; x--) // 오른쪽부터 검사
TryMove(x, y, dir);
}
else if (dir.x == -1) // 왼쪽
{
for (int y = 0; y < rows; y++)
for (int x = 1; x < cols; x++)
TryMove(x, y, dir);
}
else if (dir.y == 1) // 위
{
for (int x = 0; x < cols; x++)
for (int y = rows - 2; y >= 0; y--)
TryMove(x, y, dir);
}
else if (dir.y == -1) // 아래
{
for (int x = 0; x < cols; x++)
for (int y = 1; y < rows; y++)
TryMove(x, y, dir);
}
//노드의 이동 및 병합이 끝나면 새로운 노드 추가
for(int i = 0; i < 2; i++)
{
m_NodeGenerater.RandomGenerateNode(nodes, rows, cols);
}
//여기에 교착상태 확인 로직을 추가
if (IsDeadlock(nodes))
{
GameOver();
}
}
private void GameOver()
{
//게임 오버 처리
isGameOver = true;
GameoverText.gameObject.SetActive(isGameOver);
}
private void TryMove(int x, int y, Vector2Int dir)
{
Node current = nodes[x, y];
if (current.IsEmpty()) return;
int targetX = x;
int targetY = y;
while (true)
{
int nextX = targetX + dir.x;
int nextY = targetY + dir.y;
if (nextX < 0 || nextX >= cols || nextY < 0 || nextY >= rows)
break; // 경계 밖이면 멈춤
Node next = nodes[nextX, nextY];
if (next.IsEmpty())
{
// 빈 칸이면 숫자를 옮긴다
next.MergeNode(current.NodeNumber, current.GetColor());
current.ClearNode();
targetX = nextX;
targetY = nextY;
current = next; // 계속 진행
}
else if (next.NodeNumber == current.NodeNumber)
{
// 같은 숫자면 병합
next.MergeNode(next.NodeNumber * 2, m_NodeGenerater.Colors[GetExponentInt(next.NodeNumber * 2)]);
current.ClearNode();
break;
}
else
{
// 다른 숫자면 멈춤
break;
}
}
}
int GetExponentInt(int x)
{
return (int)Mathf.Round(Mathf.Log(x, 2f));
}
bool IsDeadlock(Node[,] nodes)
{
//빈칸이 있는지 확인
foreach (Node node in nodes)
{
if (node.IsEmpty())
{
return false;
}
}
//빈칸이 없다면 같은 숫자의 인접한 노드들이 있는지 확인
for (int y = 0;y < rows; y++)
{
for (int x = 0;x < cols; x++)
{
int tempValue = nodes[x, y].NodeNumber;
if (tempValue == 0) continue; //빈칸인 경우 건너뛰기
if(x > 0 && tempValue == nodes[x - 1, y].NodeNumber) //왼쪽
{
return false;
}
if (x < cols - 1 && tempValue == nodes[x + 1, y].NodeNumber) //오른쪽
{
return false;
}
if (y > 0 && tempValue == nodes[x, y - 1].NodeNumber) //아래
{
return false;
}
if(y < rows - 1 && tempValue == nodes[x, y + 1].NodeNumber) //위
{
return false;
}
}
}
//빈칸이 없고, 인접한 노드 중에서 같은 숫자가 없다면 교착 상태
return true;
}
}
}
위 코드에서 실제로 교착 상태(게임오버)인지 판단하는 처리를 하는 것은 IsDeadlock 함수가 담당한다.
이 함수에서는 3가지의 프로세스가 있다.
- nodes에서 빈칸이 있는지 확인 (빈칸이 있다면 false를 반환하고 return)
- 같은 숫자끼리 인접해 있는지 확인 (하나라도 인접해 있다면 false를 반환하고 retrun)
- 위 조건들에 걸리지 않았다면 교착 상태라고 판단 (true를 반환)
버그 수정 및 디테일
TouchController
using UnityEngine;
using UnityEngine.EventSystems;
namespace NumberSum
{
public class TouchController : MonoBehaviour, IPointerDownHandler, IPointerUpHandler
{
Vector2 touchDownPos;
[SerializeField]
PuzzleController controller;
private void Start()
{
controller = GameObject.Find("@Controller").GetComponent<PuzzleController>();
}
public void OnPointerDown(PointerEventData eventData)
{
if (controller.isGameOver) return;
touchDownPos = eventData.position;
Debug.Log($"{GetType()}::OnPointerDown, Position : {touchDownPos}");
}
public void OnPointerUp(PointerEventData eventData)
{
if (controller.isGameOver) return;
Vector2 touchUpPos = eventData.position;
Vector2 delta = touchUpPos - touchDownPos;
// 드래그 거리가 너무 짧으면 무시
if (delta.magnitude < 2f)
return;
Vector2Int dir;
if (Mathf.Abs(delta.x) > Mathf.Abs(delta.y))
{
// 수평 이동
dir = (delta.x > 0) ? Vector2Int.right : Vector2Int.left;
}
else
{
// 수직 이동
dir = (delta.y > 0) ? Vector2Int.up : Vector2Int.down;
}
controller.MoveNodes(dir);
Debug.Log($"{GetType()}::OnPointerUp , Direction : {dir}");
}
}
}
TouchController가 게임오버가 된 후에도 슬라이드를 인식하지 않게끔 if문과 PuzzleController의 Boolean 타입의 변수인 isGameOver를 통해 처리를 하였다.
NodeGenerater
using System.Data;
using UnityEngine;
namespace NumberSum
{
public class NodeGenerater : MonoBehaviour
{
[SerializeField]
private GameObject Node;
[SerializeField]
private Transform Trs;
[SerializeField]
public Color[] Colors;
public Node[,] GenerateNode(int rows, int cols)
{
int count = rows * cols;
Node[,] nodes = new Node[cols, rows];
for (int y = 0; y < rows; y++)
{
for(int x = 0; x < cols; x++)
{
nodes[x, y] = Instantiate(Node, Trs).GetComponent<Node>();
//모두다 빈칸으로 생성
nodes[x, y].SetInfo(0 , new Vector2Int(x, y), Colors[0]);
}
}
return nodes;
}
public void RandomGenerateNode(Node[,] nodes, int rows, int cols)
{
Debug.Log($"{GetType()}::RandomGenerateNode");
int maxTry = rows * cols; // 최대 시도 횟수 (무한 루프 방지)
for (int i = 0; i < maxTry; i++)
{
int randX = Random.Range(0, cols);
int randY = Random.Range(0, rows);
if (nodes[randX, randY].IsEmpty())
{
nodes[randX, randY].MergeNode(2, Colors[1]);
return; // ✅ 생성 완료 후 즉시 종료
}
}
Debug.LogWarning($"{GetType()}::모든 칸이 가득 찼습니다. 새 노드를 생성할 수 없습니다.");
}
}
}
NodeGenerater의 랜덤 노드 생성 함수를 바꿨다.
기존의 RandomGenerateNode 함수는 재귀 함수로 만들었기에, 빈 노드가 아닌 노드가 선택되면 다시 자신을 불러 무한 루프를 구현했으나, 빈칸 이 없는 경우에 끝없이 자기 자신을 부르는 문제가 발생했다.
이 경우에는 빈칸이 있을 경우에만 호출하도록 바꾸거나 하면 되겠지만, 공부를 위해서 반복문을 활용하여 함수를 만들어보았다.
최종 코드 및 테스트
Node
using UnityEngine;
using UnityEngine.UI;
namespace NumberSum
{
public class Node : MonoBehaviour
{
public int NodeNumber;//노드가 가지고 있는 숫자
Vector2Int NodePos; //노드의 위치
Image NodeImage;
Text NodeText;
[SerializeField]
private PuzzleController Controller;
private void OnEnable()
{
NodeImage = GetComponent<Image>();
NodeText = GetComponentInChildren<Text>();
Controller = GameObject.Find("@Controller").GetComponent<PuzzleController>();
}
public void SetInfo(int number, Vector2Int pos, Color color)
{
NodeNumber = number;
NodePos = pos;
NodeImage.color = color;
if (NodeNumber > 0)
{
NodeText.text = NodeNumber.ToString();
}
else
{
NodeText.text = "";
}
gameObject.name = NodePos.ToString();
}
public void ClearNode()
{
SetInfo(0, NodePos, Color.white);
}
public void MergeNode(int number, Color color)
{
SetInfo(number, NodePos, color);
}
public bool IsEmpty()
{
return NodeNumber == 0;
}
public Color GetColor()
{
return NodeImage.color;
}
}
}
NodeGenerater
using System.Data;
using UnityEngine;
namespace NumberSum
{
public class NodeGenerater : MonoBehaviour
{
[SerializeField]
private GameObject Node;
[SerializeField]
private Transform Trs;
[SerializeField]
public Color[] Colors;
public Node[,] GenerateNode(int rows, int cols)
{
int count = rows * cols;
Node[,] nodes = new Node[cols, rows];
for (int y = 0; y < rows; y++)
{
for(int x = 0; x < cols; x++)
{
nodes[x, y] = Instantiate(Node, Trs).GetComponent<Node>();
//모두다 빈칸으로 생성
nodes[x, y].SetInfo(0 , new Vector2Int(x, y), Colors[0]);
}
}
return nodes;
}
public void RandomGenerateNode(Node[,] nodes, int rows, int cols)
{
Debug.Log($"{GetType()}::RandomGenerateNode");
int maxTry = rows * cols; // 최대 시도 횟수 (무한 루프 방지)
for (int i = 0; i < maxTry; i++)
{
int randX = Random.Range(0, cols);
int randY = Random.Range(0, rows);
if (nodes[randX, randY].IsEmpty())
{
nodes[randX, randY].MergeNode(2, Colors[1]);
return; // ✅ 생성 완료 후 즉시 종료
}
}
Debug.LogWarning($"{GetType()}::모든 칸이 가득 찼습니다. 새 노드를 생성할 수 없습니다.");
}
}
}
PuzzleController
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace NumberSum
{
public class PuzzleController : MonoBehaviour
{
[SerializeField]
private int rows = 4; //세로 y , 행
[SerializeField]
private int cols = 4; //가로 x , 열
[SerializeField]
private NodeGenerater m_NodeGenerater;
[SerializeField]
private Text GameoverText;
[SerializeField]
Node[,] nodes;
[SerializeField]
public bool isGameOver = false;
private void Start()
{
GameStart();
}
public void GameStart()
{
GameoverText.gameObject.SetActive(isGameOver);
nodes = m_NodeGenerater.GenerateNode(rows, cols);
for(int i = 0; i < 2; i++)
{
m_NodeGenerater.RandomGenerateNode(nodes, rows, cols);
}
}
public void MoveNodes(Vector2Int dir)
{
if (dir.x == 1) // 오른쪽
{
for (int y = 0; y < rows; y++)
for (int x = cols - 2; x >= 0; x--) // 오른쪽부터 검사
TryMove(x, y, dir);
}
else if (dir.x == -1) // 왼쪽
{
for (int y = 0; y < rows; y++)
for (int x = 1; x < cols; x++)
TryMove(x, y, dir);
}
else if (dir.y == 1) // 위
{
for (int x = 0; x < cols; x++)
for (int y = rows - 2; y >= 0; y--)
TryMove(x, y, dir);
}
else if (dir.y == -1) // 아래
{
for (int x = 0; x < cols; x++)
for (int y = 1; y < rows; y++)
TryMove(x, y, dir);
}
//노드의 이동 및 병합이 끝나면 새로운 노드 추가
for(int i = 0; i < 2; i++)
{
m_NodeGenerater.RandomGenerateNode(nodes, rows, cols);
}
//여기에 교착상태 확인 로직을 추가
if (IsDeadlock(nodes))
{
GameOver();
}
}
private void GameOver()
{
//게임 오버 처리
isGameOver = true;
GameoverText.gameObject.SetActive(isGameOver);
}
private void TryMove(int x, int y, Vector2Int dir)
{
Node current = nodes[x, y];
if (current.IsEmpty()) return;
int targetX = x;
int targetY = y;
while (true)
{
int nextX = targetX + dir.x;
int nextY = targetY + dir.y;
if (nextX < 0 || nextX >= cols || nextY < 0 || nextY >= rows)
break; // 경계 밖이면 멈춤
Node next = nodes[nextX, nextY];
if (next.IsEmpty())
{
// 빈 칸이면 숫자를 옮긴다
next.MergeNode(current.NodeNumber, current.GetColor());
current.ClearNode();
targetX = nextX;
targetY = nextY;
current = next; // 계속 진행
}
else if (next.NodeNumber == current.NodeNumber)
{
// 같은 숫자면 병합
next.MergeNode(next.NodeNumber * 2, m_NodeGenerater.Colors[GetExponentInt(next.NodeNumber * 2)]);
current.ClearNode();
break;
}
else
{
// 다른 숫자면 멈춤
break;
}
}
}
int GetExponentInt(int x)
{
return (int)Mathf.Round(Mathf.Log(x, 2f));
}
bool IsDeadlock(Node[,] nodes)
{
//빈칸이 있는지 확인
foreach (Node node in nodes)
{
if (node.IsEmpty())
{
return false;
}
}
//빈칸이 없다면 같은 숫자의 인접한 노드들이 있는지 확인
for (int y = 0;y < rows; y++)
{
for (int x = 0;x < cols; x++)
{
int tempValue = nodes[x, y].NodeNumber;
if (tempValue == 0) continue; //빈칸인 경우 건너뛰기
if(x > 0 && tempValue == nodes[x - 1, y].NodeNumber) //왼쪽
{
return false;
}
if (x < cols - 1 && tempValue == nodes[x + 1, y].NodeNumber) //오른쪽
{
return false;
}
if (y > 0 && tempValue == nodes[x, y - 1].NodeNumber) //아래
{
return false;
}
if(y < rows - 1 && tempValue == nodes[x, y + 1].NodeNumber) //위
{
return false;
}
}
}
//빈칸이 없고, 인접한 노드 중에서 같은 숫자가 없다면 교착 상태
return true;
}
}
}
TouchController
using UnityEngine;
using UnityEngine.EventSystems;
namespace NumberSum
{
public class TouchController : MonoBehaviour, IPointerDownHandler, IPointerUpHandler
{
Vector2 touchDownPos;
[SerializeField]
PuzzleController controller;
private void Start()
{
controller = GameObject.Find("@Controller").GetComponent<PuzzleController>();
}
public void OnPointerDown(PointerEventData eventData)
{
if (controller.isGameOver) return;
touchDownPos = eventData.position;
Debug.Log($"{GetType()}::OnPointerDown, Position : {touchDownPos}");
}
public void OnPointerUp(PointerEventData eventData)
{
if (controller.isGameOver) return;
Vector2 touchUpPos = eventData.position;
Vector2 delta = touchUpPos - touchDownPos;
// 드래그 거리가 너무 짧으면 무시
if (delta.magnitude < 2f)
return;
Vector2Int dir;
if (Mathf.Abs(delta.x) > Mathf.Abs(delta.y))
{
// 수평 이동
dir = (delta.x > 0) ? Vector2Int.right : Vector2Int.left;
}
else
{
// 수직 이동
dir = (delta.y > 0) ? Vector2Int.up : Vector2Int.down;
}
controller.MoveNodes(dir);
Debug.Log($"{GetType()}::OnPointerUp , Direction : {dir}");
}
}
}
테스트 영상
'Game Develop > 소규모 프로젝트' 카테고리의 다른 글
| "하루만에 퍼즐 게임 만들기" (4일차) : 파이프 연결 (Connect Pipe) [하] (0) | 2025.11.23 |
|---|---|
| "하루만에 퍼즐 게임 만들기" (4일차) : 파이프 연결 (Connect Pipe) [중] (0) | 2025.11.23 |
| "하루만에 퍼즐 게임 만들기" 챌린지 (4일차) : 파이프 연결 (Connect Pipe) [상] (1) | 2025.11.17 |
| "하루만에 퍼즐 게임 만들기" 챌린지 (2일차) : 매치-3 (애니팡) (1) | 2025.11.03 |
| "하루만에 퍼즐 게임 만들기" 챌린지 (1일차) : 슬라이딩 퍼즐 (0) | 2025.10.19 |