"하루만에 퍼즐 게임 만들기" (4일차) : 파이프 연결 (Connect Pipe) [중]

들어가며

저번 글에서는 파이프의 이동과 드래그 드롭에 관련된 처리와 진행하면서 생긴 버그를 잡는 것까지 진행했다.

오늘의 글은 파이프의 회전과 중간에 발생한 이상한 오류, 그리고 성공과 실패를 처리하는 알고리즘? 로직? 을 작성한 부분까지 나가보겠다.

 


파이프의 회전

파이프의 회전을 구현할 때, 그냥 각도만 바꾸면 참 편하겠지만, 현재 회전을 얼마나 하였는지 나중에 클리어 판정을 내릴 때에 문제가 될 것이라는 것을 대충은 짐작하고 있었다. 그래서 파이프의 회전 처리를 최대한 나중으로 미룬 것이다.

 

지금 상황을 조금만 생각을 해본다면, 파이프는 방향성을 가지고 있는 오브젝트이고, 퍼즐의 성공과 실패를 판단하기 위해서는 파이프들의 연결점이 연결이 되어 있다는 것을 판단할 로직이 필요하다.

 

그래서 그 로직을 위한 포석을 깔기 위해 Pipe 클래스를 수정하였다.

코드는 다음과 같다.

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

namespace ConnectPipe
{
    public enum PipeForm
    {
        Straight,   //일자형
        Cross,      //십자형
        Curve,      //ㄱ자형
        T_Shape,    //T자형
    }

    public enum Direction
    {
        Up = 0,     //회전 0
        Right,      //회전 1
        Down,       //회전 2
        Left,       //회전 3
    }
    public class Pipe : MonoBehaviour , IPointerDownHandler ,IBeginDragHandler, IDragHandler, IEndDragHandler
    {
        [SerializeField]
        PipeForm pipeForm;

        public Direction CurrRotation //현재 회전 상태
        {
            get
            {
                if (rectTrs.rotation.eulerAngles.z == 0) return Direction.Up;
                else if (rectTrs.rotation.eulerAngles.z == 90) return Direction.Right;
                else if (rectTrs.rotation.eulerAngles.z == 180) return Direction.Down;
                else return Direction.Left;
               
            }
            set
            {
                //어떤 수가 들어오든 0~3 사이의 숫자만 받기 위해
                int dir = (int)value % 4;
                 switch ((Direction)dir)
                {
                    case Direction.Up:
                        rectTrs.rotation = Quaternion.Euler(0, 0, 0);
                        break;
                    case Direction.Right:
                        rectTrs.rotation = Quaternion.Euler(0, 0, 90);
                        break;
                    case Direction.Down:
                        rectTrs.rotation = Quaternion.Euler(0, 0, 180);
                        break;
                    case Direction.Left:
                        rectTrs.rotation = Quaternion.Euler(0, 0, 270);
                        break;
                }
            }
        }

    //기본 상태( CurrRotation.Up )일 때 기준 연결점의 방향
        public Direction[] BaseConnectDirection
         {
            get
            {
                Direction[] result;
                switch (pipeForm)
                {
                    case PipeForm.Straight: //일자형
                        result = new Direction[2];
                        result[0] = Direction.Up;
                        result[1] = Direction.Down;
                        break;
                    case PipeForm.Cross: //십자형
                        result = new Direction[4];
                        result[0] = Direction.Up;
                        result[1] = Direction.Right;
                        result[2] = Direction.Down;
                        result[3] = Direction.Left;
                        break;
                    case PipeForm.Curve: //ㄱ자형
                        result = new Direction[2];
                        result[0] = Direction.Down;
                        result[1] = Direction.Left;
                        break;
                    case PipeForm.T_Shape: //T자형
                        result = new Direction[3];
                        result[0] = Direction.Down;
                        result[1] = Direction.Left;
                        result[2] = Direction.Right;
                        break;
                    default:
                        result = new Direction[0];
                        break;
                }
                return result;
            }
        } 
    //회전 + 기본 상태의 연결점의 위치로 얻어낸 실제 연결점 위치
        public Direction[] RealConnectDirection
        {
            get
            {
                Direction[] baseDirs = BaseConnectDirection;
                Direction[] result = new Direction[baseDirs.Length];

                int rotate = (int)CurrRotation;

                for(int i = 0; i < baseDirs.Length; i++)
                {
                    int rotated = ((int)baseDirs[i] + rotate) % 4;
                    //만약 기본 상태의 연결점에 왼쪽(3)이 있었고, 현재 방향이 왼쪽이라면(3번 회전함)
                    //실제로 연결점은 아래를 향하고 있을 것임
                    //계산식으로 보면 원래 연결점(왼쪽 == 3) + 현재 회전 방향(왼쪽 == 3) 은 6임
                    //6을 4로 나눈 것의 나머지를 보면 2
                    //2는 Direction에서 Down(아래)에 해당
                    result[i] = (Direction)rotated;
                }
                return result;
            }
        }
        

        [SerializeField]
        InputController inputController;

        [SerializeField]
        Canvas canvas;

        [SerializeField]
        bool isSelected;

        Outline[] outline;
        Image[] image;
        RectTransform rectTrs;

        private Transform originParent;
        private Vector2 originAnchoredPos;

        //테스트용 Start
        private void Start()
        {
            InitPipe(PipeForm.Straight);
        }
        //파이프의 필드 초기화
        public void InitPipe(PipeForm argForm)
        {
		        //RectTransform을 가장 먼저 가져와야 밑에 CurrRotation과 꼬이지 않는다.
            rectTrs = GetComponent<RectTransform>();

            inputController = GameObject.Find("@Controller").GetComponent<InputController>();
            canvas = GameObject.Find("Canvas").GetComponent<Canvas>();
            pipeForm = argForm;

            CurrRotation = Direction.Up; //회전 상태를 0으로 초기화

            outline = new Outline[transform.childCount];
            outline = GetComponentsInChildren<Outline>();

            image = new Image[transform.childCount];
            image = GetComponentsInChildren<Image>();

        }
        
//이하 생략...

 

Pipe 클래스에 프로퍼티 3개를 새로 추가해줬다.

CurrRotation Direction 타입의 프로퍼티로 파이프의 현재 회전상태를 나타낸다. get은 현재 파이프의 z 축 회전 값에 따라서 Direction을 반환한다.

 

예시 )

z 축으로 0도 회전 == Direction.Up
z축으로 90도 회전 == Direction.Right
z축으로 180도 회전 == Direction.Down
그 외의 경우 == Direction.Left

 

set에서는 파이프의 회전을 들어온 방향에 맞게 90의 배수만큼 회전시켰다. 이렇게 처리함으로써, 모든 파이프는 90의 배수만큼의 회전을 할 것이다.

실제로, InitPipe 함수에 파이프의 회전을 CurrRotation = Direction.Up; 으로 제어하는 코드가 있다.

 

BaseConnectDirection Direction 타입의 배열을 반환하는 프로퍼티로 회전이 없는 경우(Direction.Up)에 파이프의 연결점이 어디에 위치하고 있는지를 나타낸다. 이 프로퍼티는 get만 존재하며, 반환할 때 현재 파이프의 형태에 따라서 다른 배열을 반환한다.

 

RealConnectDirection Direction 타입의 배열을 반환하는 프로퍼티 BaseConnectDrection CurrRotation 프로퍼티들을 바탕으로 회전을 고려한 현재 파이프가 어느 방향과 연결되어 있는지 반환하는 프로퍼티이다.

 


 

여기까지 작성이 되었다면, InputController에서 실제로 회전처리를 해주도록 하겠다. 프로퍼티를 이렇게 작성해 놨으니, 실제로 사용하는 부분은 쉬워진다.

 

InputController의 코드는 다음과 같다.

        public void RotateSelectPipe()
        {
            if (curSelectPipe != null)
            {
                //시계 방향으로 회전
                curSelectPipe.GetComponent<Pipe>().CurrRotation += 1;
            }
        }

 

현재 선택된 파이프의 Pipe 스크립트를 가져와서 CurrRotation 프로퍼티에 기존보다 1 높은 값으로 set을 해주었다. 이렇게 하면 Direction이 시계방향으로 순서를 만들어 놨기에 시계 방향으로 회전할 것이다.

 

이제 이 RotateSelectPipe 함수를 버튼에 연결하여 테스트를 해보겠다.

버튼을 만들어서 함수 연결하기

테스트 영상

 

테스트 결과, 선택된 파이프가 있을 경우에만 처리가 잘 되고 있는 것도 확인할 수 있었다.


비치명적 에디터 내부 오류 발생

위에 까지 진행을 하던 때에 처음보는 오류가 발생해서 당황했다. 오류 내용은 다음과 같은데.....

Assertion failed on expression:
'!(o->TestHideFlag(Object::kDontSaveInEditor) && (options & kAllowDontSaveObjectsToBePersistent) == 0)'
UnityEngine.GUIUtility:ProcessEvent (int,intptr,bool&)

 

찾아보니, 이 오류는 유니티 에디터 내부에서 숨김 플래그가 잘못된 상태로 에디터에서 저장/직렬화를 시도할 때 발생하는 비치명적 에디터 내부 오류이다.

 

내 코드에서 문제 되는 부분은 InputController EmptySelectPipe 함수 부분이다.

void EmptySelectPipe()
{
    PointerEventData nullEventData = null;
    curSelectPipe.GetComponent<Pipe>().OnPointerDown(nullEventData);

    curSelectPipe = null;
}

 

OnPointerDown 함수는 EventSystem을 통해 호출되는 함수이다. 그런데 직접 null 이벤트를 넘기면서 호출을 하니, PointerEventData를 참조하려던 OnPointerDown이 숨겨진 객체?(DontSavelnEditor)에 접근하여 오류가 나오는 것이다.

이벤트 오브젝트가 저장 안 되는 타입인데 뭔가 참조하려니 해당 오류가 나온 것이다.

 

위 함수에서 OnPointerDown을 직접 호출한 이유는 파이프의 선택 상태를 해제하기 위해서 호출을 했던 것인데, 이런 오류가 생겼으니, 직접 제어하는 수밖에 없었다.

 

void EmptySelectPipe()
{
    if (curSelectPipe != null)
    {
        Pipe pipe = curSelectPipe.GetComponent<Pipe>();
        pipe.isSelected = false; // 직접 해제
        pipe.SetOutLineColor(false); // 선택 해제 시 색상 초기화

        curSelectPipe = null;
    }
}

 

그래서 기존에 private였던 SetOutLineColor 함수를 public으로 변경해 주었다.

 

변경된 InputController의 전체 코드는 다음과 같다.

using UnityEngine;
using UnityEngine.EventSystems;

namespace ConnectPipe
{
    public class InputController : MonoBehaviour
    {
        [SerializeField]
        GameObject curSelectPipe;
        private void Update()
        {
            if (Input.GetKeyDown(KeyCode.R))
            {
                RotateSelectPipe();
            }
            if (Input.GetKeyDown(KeyCode.Delete))
            {
                RemoveSelectPipe();
            }
            if (Input.GetKeyDown(KeyCode.Escape))
            {
                EmptySelectPipe();
            }
        }

        public void SetSelectPipe(GameObject selectPipe)
        {
            if (curSelectPipe == null && selectPipe.GetComponent<Pipe>() != null)
            {
                curSelectPipe = selectPipe;
            }
            //같은 파이프를 두번 클릭 했다면 취소 또는 Null이 매개변수로 들어왔으면 취소
            else if (curSelectPipe == selectPipe || selectPipe == null)
            {
                curSelectPipe = null;
            }
            
        }

        public void RotateSelectPipe()
        {
            if (curSelectPipe != null)
            {
                //시계 방향으로 회전
                curSelectPipe.GetComponent<Pipe>().CurrRotation += 1;
            }
        }

        //배치되어 있던 파이프를 제거
        public void RemoveSelectPipe()
        {
            if (curSelectPipe != null)
            {
                //파이프 제거 로직

                //제거 후 현재 선택한 파이프를 비우기
                curSelectPipe = null;
            }
        }

        //현재 선택된 파이프를 선택 해제
        void EmptySelectPipe()
        {
            if (curSelectPipe != null)
            {
                Pipe pipe = curSelectPipe.GetComponent<Pipe>();
                pipe.isSelected = false; // 직접 해제
                pipe.SetOutLineColor(false); // 선택 해제 시 색상 초기화

                curSelectPipe = null;
            }
        }

    }
}

퍼즐 클리어 판정 (DFS)

이제 드디어 클리어 판정을 판단하는 로직을 만들어볼 차례이다.

자, 파이프는 방향을 가지고 있다. 그리고 각 파이프는 z 축을 기준으로 90도씩 회전이 가능하다. 기본 상태(회전이 없는 상태)에서의 연결방향을 정의해 놓았고, 이를 통해 회전이 추가된 이후의 실제 연결점의 위치도 프로퍼티로 만들어 구할 수 있게 처리를 해놨다.

이것에 대한 설명을 좀 더 해보자면,

 

방향은 다음과 같이 선언되어 있다.

- Up == 0
- Right == 1
- Down = = 2
- Left == 3

 

예시를 들어보면 가장 처음 만들었던 파이프 종류인 일자형 파이프(Straight) Up(==0)과  Down(==2)이 기본 형태에서의 연결점이다.

여기서 만약 회전을 1회 했다면? Right(==1) Left(==3) 현재(실제) 연결점이 될 것이다.

한 번 더 회전을 했다면(2회) Down(==2) Up(==1) 현재(실제) 연결점이 된다.

 

또 다른 예시로 Curve 형태(ㄱ자)의 파이프가 있다고 해보자.

  • Curve 폼의 기본 형태의 연결점이 Down과 Left
  • 1회 회전 시  Up(=0) Left(=3)가 연결점
  • 2회 회전 시  Right(=1) Up(=0)
  • 3회 회전 시  Down(=2) Right(=1)

이런 식이 된다. 이것이 아까 맨 처음 Pipe 스크립트에서 만들어 놓았던 것이다. 이제 파이프의 실제 연결점의 방향은 알아낼 수 있다면, 어떻게 연결되었다는 것을 판단할 것인가?

 

이는 다음과 같은 방법으로 해결했다.

  1. 현재 블록 파이프가 배치되어 있는지 확인한다.
  2. 배치되어 있다면 연결점의 방향을 확인한다.
  3. 해당 방향의 블록 상태가 Fill인지 확인한다.
  4. Fill이라면 그 블록의 파이프의 연결점의 방향( RealConnectDirection )을 가지고 온다.
  5. 그 파이프의 연결점 방향이 현재 블록을 향하는지 확인한다.
  6. 현재 블록을 향하고 있다면 서로 연결된 파이프이다.
  7. 그 파이프가 배치된 블록으로 이동하여 반복한다.

DFS 알고리즘이 실제로 이렇게 진행되는지는 배우지 않았기도 했고, 아직 공부하지 않아서 모르겠지만 본인의 코드는 이런 식으로 진행될 것이다.

맨 처음에는 시작점에서 시작하여 이런 식으로 판단하며 나아가다가 도착점과 만나게 되면 클리어 판정을 내는 것이다.

 

변경된 PuzzleController의 코드는 다음과 같다.

using Unity.VisualScripting;
using UnityEngine;

namespace ConnectPipe
{
    public class PuzzleController : MonoBehaviour
    {
        [SerializeField]
        private PuzzleGenerater generater;

        private int rows = 10; //y, 행
        private int cols = 10; //x, 열

        Block[,] blocks;
        Block startBlock;

        //방향별 좌표 이동량
        private static readonly Vector2Int[] dirOffset =
        {
            new Vector2Int(0, 1),   // Up = 0
            new Vector2Int(1, 0),   // Right = 1
            new Vector2Int(0, -1),  // Down = 2
            new Vector2Int(-1, 0)   // Left = 3
        };


        private void Start()
        {
            GameStart();
        }

        public void GameStart()
        {
            blocks = generater.GenerateBlock(rows, cols);

            //임시로 랜덤으로 시작점과 도착점 정하기
            int startPosX = Random.Range(0, cols);
            int startPosY = Random.Range(0, rows);
            int endPosX = Random.Range(0, cols);
            int endPosY = Random.Range(0, rows);
            //랜덤으로 나와서 겹치는 경우가 있을 수도 있음
            //어차피 계획으로는 레벨 에디터를 만들어서 레벨을 만들고, 그걸 스크립터블오브젝트든 뭐든 데이터로 스테이지로 만들 계획임

            SelectStartEndBlock(new Vector2Int(startPosX,startPosY) , new Vector2Int(endPosX, endPosY));
        }

        void SelectStartEndBlock(Vector2Int startPos, Vector2Int endPos)
        {
            //만약 시작점과 도착점의 위치가 같을 경우
            if (startPos == endPos)
            {
                Debug.Log("Error! StartBlock position and EndBlock position is Same Position!");
                return;
            }
            blocks[startPos.x, startPos.y].SetState(BlockState.Start);
            startBlock = blocks[startPos.x, startPos.y];

            blocks[endPos.x, endPos.y].SetState(BlockState.End);
            //endBlock = blocks[endPos.x, endPos.y];
        }
        
				//버튼에 연결한 함수
        public void OnClickTestStartButton()
        {
            IsBlockHasConnected(startBlock.GetPosition());
        }
        
        void IsBlockHasConnected(Vector2Int startPos)
        {
            bool[,] visited = new bool[cols, rows];

            // StartBlock에서 연결된 첫 파이프 찾기
            foreach (Vector2Int dir in dirOffset)
            {
                Vector2Int nextPos = startPos + dir;
                if (!IsInRange(nextPos)) continue; //검색한 위치가 보드 밖이면 건너뛰기

                Block nextBlock = blocks[nextPos.x, nextPos.y];
                if (nextBlock == null) continue; //다음 블록이 없다면 건너뛰기
                if (nextBlock.GetState() != BlockState.Fill) continue; //다음 블록이 Fill이 아니면 건너뛰기

                Pipe nextPipe = nextBlock.GetPipe() ;
                if (nextPipe == null) continue; //만약, 파이프가 없다면 건너뛰기

                // 반대 블록에서 이곳으로도 연결이 되어 있는가?
                Direction oppositeDir = GetOpposite(GetDirectionFromOffset(dir));
                foreach (Direction connectDir in nextPipe.RealConnectDirection)
                {
                    if (connectDir == oppositeDir)
                    {
                        // 연결된 블록을 찾았다면 그곳에서 DFS 시작
                        if (DFS(nextPos, visited))
                        {
                            Debug.Log("Puzzle Clear!");
                            return;
                        }
                    }
                }
            }
            Debug.Log("Faild...");
        }

        bool DFS(Vector2Int pos, bool[,] visited)
        {
            if (visited[pos.x, pos.y]) return false; //이미 방문한 블록일 경우 (DFS의 Map)

            Block current = blocks[pos.x, pos.y];
            visited[pos.x, pos.y] = true; //현재 위치에 방문 표시 남기기

            // 도착 블록인지 검사
            if (current.GetState() == BlockState.End)
                return true; //도착함

            // 파이프가 없거나 장애물일 경우
            if (current.GetState() != BlockState.Fill && current.GetState() != BlockState.Start) //start에서 시작을 해야하니 예외처리
                return false; //길 없음

            // 현재 블록의 파이프 연결방향 가져오기
            Pipe pipe = current.GetPipe();
            if (pipe == null) return false;

            //파이프의 연결방향(배열)으로 다음 블록 판단하기
            foreach (Direction dir in pipe.RealConnectDirection)
            {
                Vector2Int nextPos = pos + dirOffset[(int)dir];
                if (!IsInRange(nextPos)) continue; //보드 범위 안인가?

                Block nextBlock = blocks[nextPos.x, nextPos.y];
                if (nextBlock == null) continue; //Block 스크립트가 없는 경우(예외처리)

                //서로 연결되어 있는지 확인
                Pipe nextPipe = nextBlock.GetComponentInChildren<Pipe>();
                if (nextPipe == null && nextBlock.GetState() != BlockState.End) continue;

                // End 블록은 파이프를 배치할 수 없으니 제외
                if (nextBlock.GetState() == BlockState.End)
                {
                    // 만약 End 블록이 연결 방향에 맞게 들어오는 방향이라면 성공
                    return true;
                }

                // 연결 가능한지 체크 (양쪽이 서로를 바라보는지)
                foreach (Direction nextDir in nextPipe.RealConnectDirection)
                {
                    if (nextDir == GetOpposite(dir))
                    {
                        //서로를 바라본다면(연결되어 있음) 해당 위치로 이동해서 판단
                        if (DFS(nextPos, visited))
                            return true;
                    }
                }
            }

            return false;
        }
        //Vector2Int를 Direction으로 변환하는 함수
        private Direction GetDirectionFromOffset(Vector2Int offset)
        {
            if (offset == new Vector2Int(0, 1)) return Direction.Up;
            if (offset == new Vector2Int(1, 0)) return Direction.Right;
            if (offset == new Vector2Int(0, -1)) return Direction.Down;
            return Direction.Left;
        }

        //해당 좌표가 보드 내에 있는지 확인하는 함수
        bool IsInRange(Vector2Int pos)
        {
            return pos.x >= 0 && pos.x < cols && pos.y >= 0 && pos.y < rows;
        }

        //반대 방향을 얻는 함수
        private Direction GetOpposite(Direction dir)
        {
            return (Direction)(((int)dir + 2) % 4);
        }

    }
}

DFS 함수와 IsBlockHasConnected 함수가 실제 동작을 하는 함수이며, 스크립트의 밑에 있는 3개의 함수는 중복되는 부분을 빼둔 보조 함수이다.

 

DFS 함수와 IsBlockHasConnected 함수가 따로 존재하는 이유는 시작점에 해당하는 startBlock은 상태가 Start이기 때문에 파이프가 배치되지 않는다. 그렇기 때문에 블록 기준으로 상하좌우에 있는 블록들의 파이프를 가져와서 연결되었는지를 판단하는 함수이다.

즉, 처음 시작점에서만 IsBlockHasConnected 함수가 판단하고, 이후부터 모든 검사 대상인 블록 Fill 상태(파이프가 있는 상태)이므로 DFS 함수가 판단한다.

 

추가적으로 테스트를 하던 도중 시작점이 생성이 되지 않고 도착점만 생성된 경우를 봤는데, 이는 테스트를 위해서 시작점과 도착점을 랜덤으로 뽑아내면서 정말 낮은 확률로 둘의 위치가 동일한 좌표값이 뽑혀 시작점 블록이 도착점 블록으로 덮어씌워진 것이다.

 

비록 랜덤으로 뽑아서 생긴 문제이지만, 스테이지 정보를 받아 시작점과 도착점을 지정할 때에도 이런 경우가 있을 수도 있으니 사소한 예외처리를 해주었다.


테스트 영상