"하루만에 퍼즐 게임 만들기" 챌린지 (2일차) : 매치-3 (애니팡)

들어가며

오늘의 퍼즐 게임은 애니팡과 같은 게임이다. 같은 노드(아이콘, 블록이 될 수도 있음)가 일렬로 3개가 되면 사라지면서 점수를 얻는 게임이다.

 

게임의 규칙은 다음과 같다.

  • 10*10의 보드에 모든 칸에 노드가 생성
  • 노드를 옮길 수 있는 방향은 상하좌우 밖에 없음
  • 같은 색의 노드가 일렬로 3개 이상 연결 시 파괴 (매치)
  • 노드가 이동하고 매치가 되지 않는다면 (일렬로 3개 이상 연결이 되지 않는다면) 다시 원위치로 돌아온다.

 

이런 형태의 게임을 개발하려고 고려한다면, 슬라이드 퍼즐처럼 고려해야할 점이 있다. 그것은 바로 교착 상태(Deadlock)이다.

(실제로 이렇게 부르는지는 잘 모르겠지만, 이렇게 부르도록 하겠다.)

 

교착 상태란, 슬라이드 퍼즐이 처음 생성될 때 어떤 방법으로든 클리어가 불가능한 경우를 기억한다면 쉽게 이해할 수 있다. 이 게임에서의 교착 상태는 어떤 경우에도 게임 진행이 불가능한 경우를 말한다.

즉, 10*10 크기의 보드 위에 있는 100개의 모든 노드들이 어떤 경우로 이동을 하더라도 (다시 매치가 되지 않으면 돌아오게 되니) 매치가 되지 않는 경우를 말할 것이다.

 

캔디 크러시라는 같은 장르의 게임에서는 이런 교착 상태가 감지가 될 경우, 특수 연출 후에 보드를 섞는 등의 해결책이 존재한다.

또는, 무조건 특정 패턴을 섞어서 교착 상태가 벌어지는 일 자체를 막는 방법 등 교착 상태를 방지하는 방법은 많이 존재한다.

 


노드의 생성

노드의 생성 모습

 

클래스 만들기

우선 노드의 생성을 만들기 전에 미리 클래스들을 만들고 시작하겠다. 필요한 클래스들은 총 3개이다.

  • PuzzleController : 퍼즐을 관리하는 클래스로 주로 퍼즐 로직의 프로세스를 담당하거나, 데이터를 관리
  • NodeGenerater : 노드의 생성에 관한 클래스로 노드들의 생성과 관련있는 함수들을 가지고 있음
  • Node : 각 노드들이 각자 가지고 있는 클래스로 노드에 대한 처리를 위한 함수와 데이터를 가지고 있음

(생성 이후 노드들의 정렬은 Grid Rayout 컴포넌트를 사용함)

 

PuzzleController

using UnityEngine;
namespace Match_3
{
    public class PuzzleController : MonoBehaviour
    {
        [SerializeField]
        private NodeGernerater m_NodeGenerater;

        [SerializeField]
        private int lows = 10;
        [SerializeField]
        private int cols = 10;

        [SerializeField]
        Node[,] nodes;

        private void Start()
        {
            GameStart();
        }
        public void GameStart()
        {
            nodes = m_NodeGenerater.GenerateNode(lows, cols);
        }
    }
}

 

NodeGenerater

using UnityEngine;

namespace Match_3
{
    public class NodeGernerater : MonoBehaviour
    {
        [SerializeField]
        private GameObject node;
        [SerializeField]
        private Transform trs;
        [SerializeField]
        Color[] colors;

        public Node[,] GenerateNode(int lows, int cols)
        {
            Node[,] nodeList = new Node[lows, cols];
            int nodeCount = lows * cols;

            for(int x = 0; x < lows; x++)
            {
                for(int y = 0; y < cols; y++)
                {
                    GameObject newNode = Instantiate(node, trs);
                    Node currentNode = newNode.GetComponent<Node>();
                    currentNode.SetColor(RandomColor());
                    nodeList[x, y] = currentNode;
                }
            }
            return nodeList;
        }

        private Color RandomColor()
        {
            Color result = Color.white;

            int rand = Random.Range(0, colors.Length);
            result = colors[rand];

            return result;
        }
    }
}

 

NodeGeneraterGenerateNode 함수가 노드들을 생성하고 노드들의 정보를 담은 Node 클래스의 2차원 배열로 반환해줄 것이다.

 

Node

using UnityEngine;
using UnityEngine.UI;

namespace Match_3
{
    public class Node : MonoBehaviour
    {
        [SerializeField]
        private Color nodeColor;

        Image nodeImage;

        private void Awake()
        {
            nodeImage = GetComponent<Image>();
        }

        public void SetColor(Color color)
        {
            nodeColor = color;
            nodeColor.a = 1.0f;
            nodeImage.color = nodeColor;
        }
    }
}

 


노드의 생성

클래스들을 미리 만들어서 틀을 만들었으니, 위의 사진처럼 노드들이 생성되게끔 구현해보겠다.

우선, 생성될 때 사진에서도 볼 수 있듯이 생성되자마자 3개가 일렬로 놓여 매치(Match)가 발생할 수 있으니 이를 방지하는 로직도 준비를 해야 한다.

 

NodeGenerater에 추가될 코드는 다음과 같다.

private Color GetValidColor(Node[,] nodeList, int x, int y)
{
    Debug.Log($"{GetType()}::GetValidColor , ({x},{y})");

    Color candidate;
    int loopCount = 0;

    do
    {
        candidate = RandomColor();
        loopCount++;

        // 무한 루프 방지용
        if (loopCount > 50)
        {
            Debug.LogWarning($"{GetType()}::GetValidColor, 너무 많은 반복 발생. 색상 수를 늘리세요.");
            break;
        }

    } while (
        // 왼쪽 2개와 같은 색이면 안 됨
        (x >= 2 &&
         nodeList[x - 1, y] != null &&
         nodeList[x - 2, y] != null &&
         nodeList[x - 1, y].nodeColor == candidate &&
         nodeList[x - 2, y].nodeColor == candidate)
        ||
        // 아래 2개와 같은 색이면 안 됨
        (y >= 2 &&
         nodeList[x, y - 1] != null &&
         nodeList[x, y - 2] != null &&
         nodeList[x, y - 1].nodeColor == candidate &&
         nodeList[x, y - 2].nodeColor == candidate)
    );

    return candidate;
}

 

위 함수를 GenerateNode 함수의 RandomColor 함수 대신에 넣으면 된다.


노드의 이동과 매치 처리

노드의 선택

먼저, 노드의 이동을 처리하기 위해서는 우선 어떤 노드를 움직일 것인지를 정해야한다. 그렇기 때문에 (모바일 환경이라는 가정 하에) 유저는 다음과 같은 조작법을 행할 수 있다.

1. 두 개의 노드를 순차적으로 터치하여 선택
2. 1개를 터치한 후 드래그하여 방향 지정

 

그래서 터치를 당한 노드가 자기 자신을 PuzzleController에게 알려 이동 처리를 하게끔 프로세스를 만들었다.

코드는 다음과 같다.

Node

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace Match_3
{
    public class Node : MonoBehaviour, IPointerDownHandler, IPointerUpHandler
    {
        [SerializeField]
        PuzzleController puzzleController;

        public Color nodeColor;
        public Vector2Int nodeIndex;

        Image nodeImage;
        
        Vector2 touchDownPos;
        
        private void Awake()
        {
            nodeImage = GetComponent<Image>();
            puzzleController = GameObject.Find("@Controller").GetComponent<PuzzleController>();
        }


        public void SetInfo(Color color , Vector2Int pos)
        {
            SetColor(color);

            nodeIndex = pos;
            gameObject.name = $"({nodeIndex.x} , {nodeIndex.y})";
        }
        public void SetColor(Color color)
        {
            nodeColor = color;
            nodeColor.a = 1.0f;
            nodeImage.color = nodeColor;
        }

        public void OnPointerDown(PointerEventData eventData)
        {
            puzzleController.SelectNode(this);
            touchDownPos = eventData.position;
        }

        public void OnPointerUp(PointerEventData eventData)
        {
            Vector2 touchUpPos = eventData.position;
            if((touchUpPos-touchDownPos).magnitude > 2.0f)
            {
                Vector2 dir = (touchUpPos - touchDownPos).normalized;

                puzzleController.SelectNode(dir);
            }
        }
    }
}

 

노드가 터치를 인식하는 방법은 IPointer 인터페이스들을 사용하였다.

먼저 클릭(또는 터치 시작)을 하면 OnPointerDown 함수를 통해 첫 번째 노드로 자신(nodeIndex)을 매개변수로써 전달할 것이며, 클릭을 종료하면 OnPointerUp 함수에 의해 방향을 구해서 그 방향을 매개변수로 PuzzleController에게 전달하도록 하였다.

 

그러나, 위의 경우를 테스트 해본 결과로는 대각선으로 드래그를 하는 경우에는 원하는 방향으로 드래그가 안되는 케이스가 존재했다. 그래서 dir의 타입을 Vectro2Int로 변경하여 애매한 소숫점 값은 버리도록 하였다.

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;
    }

    // 이제 SelectNode 호출
    puzzleController.SelectNode(dir);
}

 

NodeGenerater

public Node[,] GenerateNode(int rows, int cols)
{
    Debug.Log($"{GetType()}::GenerateNode");

    Node[,] nodeList = new Node[rows, cols];
    int nodeCount = rows * cols;

    for(int x = 0; x < rows; x++)
    {
        for(int y = 0; y < cols; y++)
        {
            //새로운 오브젝트 생성
            GameObject newNode = Instantiate(node, trs);
            Node currentNode = newNode.GetComponent<Node>();

            //매치가 일어나지 않도록 색상 선택
            Color selectedColor = GetValidColor(nodeList, x, y);

            //노드의 정보 초기화
            currentNode.SetInfo(selectedColor, new Vector2Int(x, y));
            
            //배열 등록
            nodeList[x, y] = currentNode;
        }
    }
    return nodeList;
}

 

또한, Node 클래스의 SetInfo 함수에 받아올 매개변수를 추가 했으므로, NodeGenerater도 수정을 해준다.

(x와 y의 값을 받아서 nodeIndex 값에 대입할 수 있도록)

 

PuzzleController

using UnityEngine;
namespace Match_3
{
    public class PuzzleController : MonoBehaviour
    {
        [SerializeField]
        private NodeGernerater m_NodeGenerater;

        [SerializeField]
        private int rows = 10;
        [SerializeField]
        private int cols = 10;

        [SerializeField]
        Node[,] nodes;

        [SerializeField]
        private Node firstNode;
        [SerializeField]
        private Node secondNode;

        private void Start()
        {
            GameStart();
        }
        public void GameStart()
        {
            nodes = m_NodeGenerater.GenerateNode(rows, cols);
        }

        #region MoveNode

        #endregion

        public void SelectNode(Node argNode)
        {
            Debug.Log($"{GetType()}::SelectNode, argNode : {argNode.name}");

            if(firstNode == null)
            {
                firstNode = argNode;
            }
            else if(secondNode == null)
            {
                secondNode = argNode;
            }
        }

        public void SelectNode(Vector2 dir)
        {
            Debug.Log($"{GetType()}::SelectNode(dir), dir : {dir}");
            if(firstNode == null)
            {
                Debug.Log($"오류! 첫번째로 선택된 노드가 없습니다!");
                return;
            }

            Vector2Int firstNodePos = firstNode.nodeIndex;
            Node secondNode = null;

            if (dir.x < 0) //왼쪽
            {
                if(firstNodePos.x - 1 >= 0)
                    secondNode = nodes[firstNodePos.x - 1, firstNodePos.y];
            }
            else if(dir.x > 0) //오른쪽
            {
                if (firstNodePos.x + 1 < rows)
                    secondNode = nodes[firstNodePos.x + 1, firstNodePos.y];
            }
            else if (dir.y < 0) //아래
            {
                if (firstNodePos.y -1 >= 0)
                    secondNode = nodes[firstNodePos.x, firstNodePos.y - 1];
            }
            else if (dir.y > 0) //위
            {
                if (firstNodePos.y + 1 < cols)
                    secondNode = nodes[firstNodePos.x, firstNodePos.y + 1];
            }

            SelectNode(secondNode);
        }
    }
}

 

SelectNode 함수를 오버로드하여 nodeIndex가 매개변수로 들어왔을 때와, dir가 매개변수로 들어왔을 때의 처리를 따로 해주었다.


노드의 Swap

노드의 선택까지 처리를 완료하였고, 이젠 진짜 노드의 이동을 처리할 차례이다.

우선, 노드가 이동하려면 다음과 같은 조건이 성립되어야 한다.

  • 이동하려는 두 개의 노드가 서로 인접한가?

또한, 노드의 이동에서 처리할 내용은 다음과 같다.

  • 노드의 정보만 교환 ( nodeColor )
  • 배열 내부의 참조 교환

 

Grid Rayout 컴포넌트를 사용하여 노드들을 정렬하고 있기 때문에, 실제 노드가 이동하는 것이 아니다. 실제로는 두 노드의 정보가 서로 교환(Swap) 되는 것 뿐이다.

 

PuzzleController에 추가되는 함수는 다음과 같다.

public void NodeSwap()
{
    // Mathf.Max(Mathf.Abs(x1 - x2), Mathf.Abs(y1 - y2)) == 1
    int deltaX = Mathf.Abs(firstNode.nodeIndex.x - secondNode.nodeIndex.x);
    int deltaY = Mathf.Abs(firstNode.nodeIndex.y - secondNode.nodeIndex.y);

    if (deltaX + deltaY == 1)
    {
        // (0,0) 즉 자기 자신을 제외하고
        // 스왑 로직
        Color temp = firstNode.nodeColor;
        Node tempNode = firstNode;

				//배열의 참조 교환
        nodes[firstNode.nodeIndex.x, firstNode.nodeIndex.y] = secondNode;
        nodes[secondNode.nodeIndex.x, secondNode.nodeIndex.y] = tempNode;

				//노드의 정보 교환
        firstNode.SetColor(secondNode.nodeColor);
        secondNode.SetColor(temp);

        IsMatch();
    }
}

 

 

노드의 매치 검사

노드의 Swap 이후, 노드들이 매치가 이루어졌는지를 검사 해야한다. 이때 성능 최적화를 위해 실제로 정보가 교환된 두 노드가 위치하고 있는 행과 열만 매치 검사가 이루어지면 된다.

 

코드는 다음과 같다.

        public void IsMatch(Vector2Int firstPos, Vector2Int secondPos)
        {
            HashSet<Node> matchNodes = new HashSet<Node>();

            HashSet<int> rowsToCheck = new HashSet<int>() { firstPos.y, secondPos.y };
            HashSet<int> colsToCheck = new HashSet<int>() { firstPos.x, secondPos.x };

            // 가로 검사
            foreach (int y in rowsToCheck)
            {
                int count = 1;
                for (int x = 1; x < cols; x++)
                {
                    if (IsSameColor(nodes[x, y].nodeColor, nodes[x - 1, y].nodeColor))
                    {
                        count++;
                    }
                    else
                    {
                        if (count >= 3)
                        {
                            for (int i = x - count; i < x; i++)
                                matchNodes.Add(nodes[i, y]);
                        }
                        count = 1;
                    }
                }
                if (count >= 3)
                {
                    for (int i = cols - count; i < cols; i++)
                        matchNodes.Add(nodes[i, y]);
                }
            }

            // 세로 검사
            foreach (int x in colsToCheck)
            {
                int count = 1;
                for (int y = 1; y < rows; y++)
                {
                    if (IsSameColor(nodes[x, y].nodeColor, nodes[x, y - 1].nodeColor))
                    {
                        count++;
                    }
                    else
                    {
                        if (count >= 3)
                        {
                            for (int i = y - count; i < y; i++)
                                matchNodes.Add(nodes[x, i]);
                        }
                        count = 1;
                    }
                }
                if (count >= 3)
                {
                    for (int i = rows - count; i < rows; i++)
                        matchNodes.Add(nodes[x, i]);
                }
            }

            if (matchNodes.Count > 0)
            {
                Debug.Log($"Matched {matchNodes.Count} nodes.");
                StartCoroutine(MatchCo(matchNodes));
            }
            else
            {
                Debug.Log("No match found.");
                StartCoroutine(NoMatchCo());
            }
        }

        bool IsSameColor(Color a, Color b)
        {
            return Mathf.Approximately(a.r, b.r)
                && Mathf.Approximately(a.g, b.g)
                && Mathf.Approximately(a.b, b.b);
        }
        #endregion

        IEnumerator NoMatchCo()
        {
            yield return new WaitForSeconds(0.5f);

            NodeSwapBack();

            firstNode = null;
            secondNode = null;
        }

        IEnumerator MatchCo(HashSet<Node> nodes)
        {
            yield return null;
            foreach (Node node in nodes)
            {
                node.MatchNode();
            }
            yield return new WaitForSeconds(0.5f);
            
            firstNode = null;
            secondNode = null;

            NewNodes(nodes);
        }

 

위 함수의 프로세스를 설명하면 다음과 같다.

  • 해당 열과 행을 돌면서 자신의 뒷 노드(index 상으로 뒤에 위치한)와 자신의 색을 비교(IsSameColor)한다.
  • 같으면 count가 증가한다.
  • count가 3 이상이 되면 해당 노드들을 matchNodes에 추가한다.
  • 위 과정을 한 번 더 반복한다. (행 검사 이후 열 검사)
  • matchNodes길이가 0보다 클 경우 MatchCo 함수(코루틴)에 매개변수로 넘겨준다.
  • 위의 경우가 아닐 때는 NoMatchCo를 호출하여 노드들을 다시 원상복귀 시킨다.

노드의 매치 처리

Node 클래스에 MatchNode 함수를 만들어 실제 처리로 매치가 발생한 처리를 해준다.

코드는 다음과 같다.

//Node.cs
public void MatchNode()
{
    Debug.Log($"{GetType()}::MatchNode, name : {gameObject.name}");
    StartCoroutine(MatchNodeCo(0.5f));
}
IEnumerator MatchNodeCo(float end)
{
    float current = 0f;
    while (current < end)
    {
        current += Time.deltaTime;
        float a= Mathf.Lerp(nodeImage.color.a, 0.0f, current);
        nodeImage.color = new Color(nodeColor.r, nodeColor.g, nodeColor.b, a);
        yield return null; //1프레임 쉬기
    }
}

 

MatchNodeCo 함수에서는 최소한의 연출을 위해 0.5초 동안 서서히 투명해지는 효과를 넣었다.


노드의 추가 생성

이제 노드들이 이동하고 검사하여 매치가되면 빈 노드가 생기므로 보드의 윗부분에서 새로운 노드들이 생겨서 한 칸씩 아래로 밀리는 것을 만들 차례이다.

 

그러기 위해서는 매치된 노드들의 열을 구하고, 해당 열의 윗부분에서 새로운 노드들이 추가되면서 열에 원래 있던 노드들(특히, 매치되어 사라진 노드부터 제일 위에 위치한 노드까지)아래로 낙하(PushDown)하는 것처럼 보이게 하기 위해서 Swap과 비슷한 로직을 구현하겠다.

 

Node

using System.Collections;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace Match_3
{
    public class Node : MonoBehaviour, IPointerDownHandler, IPointerUpHandler
    {
        [SerializeField]
        PuzzleController puzzleController;

        public Color nodeColor;
        public Vector2Int nodeIndex;

        public Image nodeImage;
        Vector2 touchDownPos;

        private void Awake()
        {
            nodeImage = GetComponent<Image>();
            puzzleController = GameObject.Find("@Controller").GetComponent<PuzzleController>();
        }


        public void SetInfo(Color color , Vector2Int pos)
        {
            SetColor(color);

            nodeIndex = pos;
            gameObject.name = $"({nodeIndex.x} , {nodeIndex.y})";
        }
        public void SetColor(Color color)
        {
            nodeColor = color;
            nodeColor.a = 1.0f;
            nodeImage.color = nodeColor;
        }
        public void Clear()
        {
            nodeColor = new Color(0f, 0f, 0f, 0f);
            nodeImage.color = nodeColor;
        }
        public bool IsEmpty
        {
            get { return Mathf.Approximately(nodeImage.color.a, 0f); }
        }

        public void OnPointerDown(PointerEventData eventData)
        {
            puzzleController.SelectNode(this);
            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;
            }

            // 이제 SelectNode 호출
            puzzleController.SelectNode(dir);
        }

        public void MatchNode()
        {
            Debug.Log($"{GetType()}::MatchNode, name : {gameObject.name}");
            StartCoroutine(MatchNodeCo(0.5f));
        }
        IEnumerator MatchNodeCo(float end)
        {
            float current = 0f;
            while (current < end)
            {
                current += Time.deltaTime;
                float a= Mathf.Lerp(nodeImage.color.a, 0.0f, current);
                nodeImage.color = new Color(nodeColor.r, nodeColor.g, nodeColor.b, a);
                yield return null; //1프레임 쉬기
            }

            Clear();
        }
    }
}

 

Node 클래스에는 Clear 함수와 IsEmpty 라는 프로퍼티를 만들어 주었다.

IsEmpty는 읽기(get)만 가능하고, 가져올 때마다 현재 노드의 이미지의 Alpha(투명도) 값을 통해 boolean 타입으로 반환한다.

Clear 함수는 노드가 실제로 비어있는 연출과 표시를 하기 위해서 노드 이미지를 투명하게 설정하는 함수이다.

 

또한 MatchNodeCo의 마지막에 Clear 함수를 호출해주었다.

 

PuzzleController

	public void NewNodes(HashSet<Node> matchNodes)
        {
            // 낙하가 필요한 열의 x 인덱스(열) 수집
            // 각 열에 대해 PushDown 실행
            HashSet<int> colsToRefill = new HashSet<int>();

            foreach (Node node in matchNodes)
            {
                // 노드 인덱스의 x열 수집
                colsToRefill.Add(node.nodeIndex.x);
            }

            foreach (int colIndex in colsToRefill)
            {
                PushDown(colIndex);
            }
        }

    public void PushDown(int colIndex)
        {
            // write 포인터: 다음에 채울(쓰기) 위치 (바닥에서부터)
            int write = 0;

            // read 포인터: 위에서 아래로 스캔
            for (int read = 0; read < rows; read++)
            {
                // 읽은 위치가 비어있지 않으면(채워져 있으면) 이를 write 위치로 복사
                if (!nodes[colIndex, read].IsEmpty)
                {
                    if (write != read)
                    {
                        // write 위치에 색 복사
                        nodes[colIndex, write].SetColor(nodes[colIndex, read].nodeColor);

                        // 원래 위치는 비우기
                        nodes[colIndex, read].Clear();
                    }
                    write++;
                }
            }

            // 이제 write부터 위쪽은 모두 비어있음 -> 리필 필요
            // RefillColumn (NodeGernerater)에 처리를 위임 (빈칸만 채우도록 구현되어 있어야 함)
            m_NodeGenerater.RefillColumn(colIndex, rows, nodes);
        }

 

PuzzleController 클래스에는 새로운 함수 두 개를 추가해줬다.

NewNodes낙하 처리를 어떤 열에 해줄지를 찾는 함수이다. 그리고 빈 노드가 생긴 열의 데이터를 PushDown 함수에 전달해준다.

PushDown 함수에는 비워진 노드가 있는 위치부터 시작해서 점점 위로 올라가면서 빈 노드와 그 위의 노드의 정보(색)을 서로 바꾸는 것으로 노드들이 실제로 낙하하는 것처럼 구현해주었다.

 

그리고 NewNodesMatchCo의 마지막에 호출된다.

 

NodeGenerate

public void RefillColumn(int colIndex, int rows, Node[,] nodes)
    {
        for (int y = 0; y < rows; y++)
        {
            // 빈칸이면 새 색 채움
            if (nodes[colIndex, y].IsEmpty)
            {
                Color newColor = GetValidColor(nodes, colIndex, y);
                nodes[colIndex, y].SetColor(newColor);
            }
        }
    }

 

그리고 NodeGenerater 클래스에는 RefillColumn 함수를 추가해줬다. 이 함수는 빈 노드에 랜덤한 색상을 부여하는 역할을 하며, 이 함수로 새로운 노드가 생기는 기능을 구현하였다.

 


낙하 이후 추가 매치 처리

낙하 처리 이후 새로운 노드가 만들어지는 것을 테스트하던 중, 낙하 이후에 매치가 된 경우를 처리하는 것을 깜빡하였다. 그래서 이번에는 낙하 이후에 매치 검사를 보드 전체에 돌려서 매치가 되었는지 검사를 하고, 매치가 되었다면 다시 이후 처리가 진행이 되도록 만들어보겠다.

 

기존의 매개변수를 받던 IsMatch 함수는 매개변수로 받은 특정 열과 행만 검사를 진행했었는데, 이번에 새로 만들 함수는 이 IsMatch를 오버로딩하여 모든 보드의 노드들을 검사하고, 매치가 일어나지 않은 노드들은 아무런 처리를 하지 않도록 하겠다.

 

기존 IsMatchNoMatchCo으로 넘어가서 SwapBack으로 넘어갔으나, 전체 보드를 검사하는 지금 프로세스에는 불필요하기 때문에 오버로딩하여 해당 과정을 제외했다.

 

PuzzleController

 

        //모든 행과 열을 검사하는 IsMatch
        public void IsMatch()
        {
            HashSet<Node> matchNodes = new HashSet<Node>();

            // 가로 검사 (y 고정)
            for (int y = 0; y < rows; y++)
            {
                int count = 1;
                for (int x = 1; x < cols; x++)
                {
                    if (IsSameColor(nodes[x, y].nodeColor, nodes[x - 1, y].nodeColor))
                    {
                        count++;
                    }
                    else
                    {
                        if (count >= 3)
                        {
                            for (int i = x - count; i < x; i++)
                                matchNodes.Add(nodes[i, y]);
                        }
                        count = 1;
                    }
                }
                if (count >= 3)
                {
                    for (int i = cols - count; i < cols; i++)
                        matchNodes.Add(nodes[i, y]);
                }
            }

            // 세로 검사 (x 고정)
            for(int x = 0; x < cols; x++)
            {
                int count = 1;
                for (int y = 1; y < rows; y++)
                {
                    if (IsSameColor(nodes[x, y].nodeColor, nodes[x, y - 1].nodeColor))
                    {
                        count++;
                    }
                    else
                    {
                        if (count >= 3)
                        {
                            for (int i = y - count; i < y; i++)
                                matchNodes.Add(nodes[x, i]);
                        }
                        count = 1;
                    }
                }
                if (count >= 3)
                {
                    for (int i = rows - count; i < rows; i++)
                        matchNodes.Add(nodes[x, i]);
                }
            }

            if (matchNodes.Count > 0)
            {
                Debug.Log($"Matched {matchNodes.Count} nodes.");
                StartCoroutine(MatchCo(matchNodes));
            }
            //else 부분은 오버로드 한 함수에서 사용하지 않을 것이니 삭제
        }

 

위의 함수처럼 기존의 IsMatch를 오버로딩한 새로운 함수를 만들었다. 이 함수는 위에서 설명한 것처럼 특정 행과 열을 검사하는게 아니라 보드 위에 모든 노드들을 대상으로 검사를 한다. 그러니 매개변수를 받을 필요가 없으며 노드들이 Swap이 된 이후가 아닌, PushBack(낙하 처리) 이후에 호출되는 것을 전제로 하기에 else에서 SwapBack(노드를 되돌리는 기능)으로 넘어가는 부분을 제외했다.

 

이후 이 함수는 새로운 노드가 생성되고, PushBack의 처리가 끝난 후에 호출이 되어야 한다.

        public void NewNodes(HashSet<Node> matchNodes)
        {
            // 낙하가 필요한 열의 x 인덱스(열) 수집
            // 각 열에 대해 PushDown 실행
            HashSet<int> colsToRefill = new HashSet<int>();

            foreach (Node node in matchNodes)
            {
                // 노드 인덱스의 x열 수집
                colsToRefill.Add(node.nodeIndex.x);
            }

            foreach (int colIndex in colsToRefill)
            {
                PushDown(colIndex);
            }

            //모든 행과 열을 검사
            IsMatch();
        }

 

 

테스트 영상


마무리

챌린지의 규칙대로 여기까지가 하루 걸려 완성한 내용이다. 교착 상태에 대한 처리도 할 계획이 있었으나, 시간이 부족한 관계로 완성하지 못했다. 물론, 실패했다고 해서 끝이라는 것은 아니라 이후에 다시 교착상태에 대한 처리를 할 생각도 있으나 당장은 아닐 것으로 생각된다. 아직 퍼즐의 종류도 많이 남아있다.

 

교착상태에 대한 아이디어가 없는 것은 아니다. 실제로 Swap이 일어나는 것이 아니라 가상으로 스왑을 한 뒤에 매치가 일어나는지 체크를 한 다음, 매치가 일어나는 경우가 없다면 교착상태로 보는 아이디어가 있다. 실제로 필요한 함수들은 전부 다 있는 만들어져 있는 상태이니, 만일 따라해보는 사람이 있다면 직접 시도해보는 것도 나쁘지 않을 것이라 생각된다.

 

그러면, 다음 포스팅에서는 숫자 합치기(2048)로 찾아오도록 하겠다.