"하루만에 퍼즐 게임 만들기" 챌린지 (7일차) : 사과 게임(AppleSum)

들어가며

저번에는 지뢰찾기를 가져오겠다고 했는데, "갑자기 웬 사과냐?"라고 할 수 있다고 생각한다. 왜 갑자기 사과게임으로 계획을 수정했냐면, 동생이랑 대화를 나누다가 사과게임에 대한 얘기가 나왔었다.

이 게임 이야기를 하다가 갑자기 삘이 꽂혀서 기존 계획에는 없던 사과 게임을 추가하고 말았다.

 

사과 게임이 뭔지 모르는 분들을 위해 부연 설명을 해보자면, 드래그를 통해서 직사각형 영역을 만들면 그 안의 사과의 합이 10이 되도록 하여 사라지게 하는 게임이다.

 

유튜브에서 잠깐 유행하기도 했던 나도 지나가면서 잠깐씩 보기는 했던 게임이었는데, 갑자기 생각이 나서 파바박 만들어버렸다...

 

원작은 일본의 웹 사이트에서 즐길 수 있는 게임인데, 얼마 전 유튜브에서 꽤나 열풍이었던 게임이었다.

 

간단하면서도 생각보다는 어려운 게임에 많은 사람들이 도전했지만, 원작에서는 시간 제한도 있다보니 대부분은 좋지 못한 점수를 받기 일쑤였다.

 

그래서 오늘은 이 게임을 유니티를 통해서 구현을 해보도록 하겠다.


 

핵심 목표와 최소 기능

프로토타입 개발을 위해 이전처럼 핵심 목표와 최소 기능을 정해보자. 그러기 위해선 내가 만들려는 게임이 어떤 규칙을 가지고 있는지를 알아야 무엇을 구현해야할지를 정할 수 있다.

 

개발하려는 사과 게임에는 다음과 같은 규칙이 있다.

1. 숫자가 적힌 오브젝트(사과)들이 랜덤하게 분포되어 생성된다.
2. 플레이어는 드래그를 통해서 해당 영역안에 들어오는 사과들을 선택한다.
3. 선택된 사과들의 숫자의 합이 10이라면 성공
4. 성공 시 사과들이 사라진다.
5. 실패 시 아무런 변화도 없다.
6. 제한된 시간이 끝나면 게임이 끝난다.

 

해당 규칙들을 정리하면서 대충은 핵심 목표와 최소 기능들이 눈에 잡히기 시작했다.

 

순서대로 사과 오브젝트들의 랜덤한 생성, 드래그하여 오브젝트 선택, 사과들의 매치(Match) 및 이후 처리, 제한시간(타이머) 총 4가지의 핵심 목표가 있겠지만, 이 중에서 제한시간은 제외하여도 상관 없겠다는 생각이 들었다.

 

타이머 기능을 제외해도 상관이 없다고 생각한 이유는 프로토타입에서 굳이 구현하지 않아도 될 기능처럼 보였기 때문이다. 타이머 기능이 없더라도 게임의 규칙을 이해하는데 큰 어려움이 없으며, 프로토타입이 완성될 가능성이 높기 때문에 굳이 시간과 비용을 들일 기능이 아니라고 생각했다.

 

그래서 시간이 남는다면 해당 기능을 구현해보는 것으로 하고 최종적으로 정리된 핵심 목표들은 다음과 같다.

1. 사과 랜덤 생성 및 숫자 랜덤 부여
2. 사과 선택 로직 + 드래그 기능
3. 정답 판정 및 처리
4. (선택) 제한 시간

 

이를 바탕으로 한 최소 기능 또한 도출할 수 있다.

1-1) 사과 오브젝트 랜덤 생성
1-2) 숫자 랜덤 부여

2-1) 사과 선택 로직
2-2) 드래그 시 시각적으로 해당 영역 표시
2-3) 드래그 하여 선택되는 사과들을 시각적으로 표시

3-1) 선택된 사과들의 정답 판정(숫자 합 판정)
3-2) 정답 처리 및 오답 처리

(선택사항)
4-1) 제한 시간 타이머 기능
4-2) 클리어 및 게임 오버 처리

 

최소 기능만 봤을 때, 1번들은 쉽게 해결할 것 같지만, 드래그의 경우는 아직 처리를 해본 적이 없어서 어려움이 있을 것이라 생각한다.

또한, 3번들 같은 경우에는 드래그 기능이 어떻게 구현되느냐에 따라서 그 양상이 달라질 수 있기 때문에 아직은 감이 잡히지는 않는다.

 


개발 초기 단계

사과 생성

중요하지 않다고 생각되는 내용들은 빠르게 넘어가도록 하겠다.

 

사과 오브젝트 만들기 및 구성요소
9*9 크기의 사과 Board 생성 예상도

 

프로젝트 초기 단계의 세팅에서 대부분은 이미 이전 단계에서 많이 해왔던 것들이고, 사람마다 달라질 수 있는 부분이니 패스하겠다.

하지만, 지금 이 단계에서 알아두어야할 점은 사과 오브젝트들의 정렬을 이번에도 Grid Layout Group 컴포넌트를 사용했다는 점이다.

초기 단계에서는 신경쓰지 않았던 점이 나중에 어떻게 돌아오는지, 이를 해결하는 방법은 또 무엇인지는 밑에 중간 단계에서 확인해보도록 하자.

 

스크립트는 곧바로 랜덤 생성 로직을 만들어놓았다. 이런 타일 맵 구조의 오브젝트를 생성하는건 이전 게임에서부터 많이 보여주었다고 생각해서, 빠르게 넘어가도록 하겠다.

 

PuzzleController.cs

using UnityEngine;

namespace AppleSum
{
    public class PuzzleController : MonoBehaviour
    {
        [SerializeField]
        private PuzzleGenerater puzzleGenerater;

        [SerializeField]
        private const int APPLE_COUNT = 81;

        private void OnEnable()
        {
            puzzleGenerater = GameObject.Find("Board").GetComponent<PuzzleGenerater>();
        }
        private void Start()
        {
            GameStart();
        }
        private void GameStart()
        {
            puzzleGenerater.GeneratePuzzle(APPLE_COUNT);
        }
    }
}

 

PuzzleGenerater.cs

using UnityEngine;

namespace AppleSum
{
    public class PuzzleGenerater : MonoBehaviour
    {
        [SerializeField]
        private GameObject applePrefab;
        public void GeneratePuzzle(int appleCount)
        {
            Debug.Log("Puzzle Generated");
            for(int i = 0; i < appleCount; i++)
            {
                GameObject newApple = Instantiate(applePrefab, transform);
                newApple.name = $"Apple_{i + 1}";
            }
        }
    }
}

 

Apple 프리팹 생성 테스트 결과

하지만, 테스트 결과는 빼놓을 수 없다. 그만큼 중요하다고 생각하기 때문이다.

일단 현재로써는 의도한대로 잘 작동하고 있다.


사과 오브젝트 초기화

사과 오브젝트에는 합을 계산할 숫자도 부여되어야 한다. 그러므로, 해당 데이터를 사과 오브젝트를 생성하는 과정에서 초기화도 동시에 이루어지도록 하겠다.

 

그러기 위해서는 각 사과가 가지고 있을 클래스가 필요하니, Apple.cs를 새로 만들도록 하겠다.

Apple.cs

using UnityEngine;
using UnityEngine.UI;

namespace AppleSum
{
    public class Apple : MonoBehaviour
    {
        private Text numText;
        [SerializeField]
        private int number = 0;
        public int Number
        {
            get { return number; }
            set
            {
                if(value > 0 && value < 10) number = value;
                numText.text = number.ToString();
            }
        }
				
        public void InitApple(int number, int name)
        {
            numText = GetComponentInChildren<Text>();
            Number = number;
            gameObject.name = $"Apple_{name}";
        }
    }
}

 

그리고 이건 팁 또는 잡담이기는 한데, 그동안 변수와 프로퍼티를 조금 혼동하여 쓰고 있었다.

 

하지만, 최근에야 그걸 깨닫고 해당 문법이나 오류에 대해 깨닫고 자료를 조사하여 정리를 해두었다. 조만간 올리지 않을까 싶지만, 조금 얘기를 하자면 number는 변수, Number가 프로퍼티라고 하는 것이 맞다.

 

프로퍼티에 연결된 변수를 백킹 필드(Backing Field)라고 하며, Apple.cs에서는 numberNumber의 백킹 필드라고 할 수 있다.

 

PuzzleGenerater.cs

using UnityEngine;

namespace AppleSum
{
    public class PuzzleGenerater : MonoBehaviour
    {
        [SerializeField]
        private GameObject applePrefab;
        public void GeneratePuzzle(int appleCount)
        {
            Debug.Log("Puzzle Generated");
            for(int i = 0; i < appleCount; i++)
            {
                GameObject newApple = Instantiate(applePrefab, transform);
                AppleInit(newApple, i);
            }
        }
        //사과 초기화 함수
        private void AppleInit(GameObject appleObj, int name)
        {
            Apple apple = appleObj.GetComponent<Apple>();
            if (apple == null) apple = appleObj.AddComponent<Apple>();

            apple.InitApple(Random.Range(1, 10) , name);
        }
    }
}

 

그리고, 사과 오브젝트를 생성하는 기능을 가지고 있는 클래스인 PuzzleGenerater에 사과의 데이터를 초기화해주는 함수를 추가하였다.

 

사과 초기화 테스트 결과

해당 코드도 테스트를 해본 결과, 의도한대로 잘 작동하고 있음을 확인했다.


개발 중간 단계

이제 이 프로토타입의 완성을 좌우하는 중요한 기능인 드래그 기능을 구현할 차례이다.

우선, 드래그를 하면 시작한 지점에서부터 현재 마우스가 있는 위치까지의 영역을 표시하는 UI를 만들어 현재 드래그 영역이 어느 정도의 크기와 영역을 가지고 있는지 유저가 확인하기 쉽도록 새로운 오브젝트를 만들어 표시하도록 하겠다.

 

유저에게 드래그 영역을 표시해주는 UI 오브젝트를 DragBox라고 하고, 유저의 입력을 감지하고 DragBox를 컨트롤 할 클래스를 가지고 있을DragController를 만들었다.

 

 

DragController는 마우스 클릭 같은 입력을 감지해야한다. 그래서 Image 컴포넌트를 붙여주도록 하겠다. 하지만, DragBox는 그저 표시용일 뿐이니, Raycast Target의 체크를 해제하여 클릭 이벤트 등을 감지하거나 반응하지 않도록 하여 로직이나 동작하는 것이 꼬이지 않도록 하겠다.

 

이제 어느 정도 세팅과 구조를 잡았으니, 인터페이스를 통해서 DragController 클래스를 작성해보도록 하겠다. 코드는 다음과 같다.

 

DragController.cs

using UnityEngine;
using UnityEngine.EventSystems;

namespace AppleSum
{
    public class DragController : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler
    {
        [SerializeField]
        private RectTransform dragBox;

        private Vector2 startPos;
        private RectTransform canvasRect;

        private void OnEnable()
        {
            if(canvasRect == null)
            {
                canvasRect = GetComponentInParent<RectTransform>();
            }
            if(dragBox != null)
            {
                dragBox.gameObject.SetActive(false);
            }
        }

        public void OnPointerDown(PointerEventData eventData)
        {
            //화면 좌표를 UI 좌표로 변환
            RectTransformUtility.ScreenPointToLocalPointInRectangle(
                canvasRect,                     //기준이 되는 RectTransform
                eventData.position,             //화면 상의 마우스 좌표
                eventData.pressEventCamera,     //해당 canvas를 렌더링 하는 카메라
                out startPos                    //변환된 로컬 ui 좌표가 저장될 변수
                );
            dragBox.gameObject.SetActive(true);
            dragBox.anchoredPosition= startPos;
            dragBox.sizeDelta = Vector2.zero;
        }

        public void OnDrag(PointerEventData eventData)
        {
            Vector2 currPos;
            RectTransformUtility.ScreenPointToLocalPointInRectangle(
                canvasRect,                     
                eventData.position,             
                eventData.pressEventCamera,     
                out currPos //현재 좌표 가져오기
                );

            Vector2 diff = currPos - startPos;
            dragBox.anchoredPosition = startPos + diff / 2f;
            dragBox.sizeDelta = new Vector2(Mathf.Abs(diff.x), Mathf.Abs(diff.y));
        }

        public void OnPointerUp(PointerEventData eventData)
        {
            dragBox.gameObject.SetActive(false);
        }
    }
}

 

위 코드는 소스코드라고 할 정도로 정형화된 형태이다. 다만, 주의해야할 점은 dragBox의 크기를 지정할 때 무조건 절대값으로 들어가야한다는 것이다. 이 때문에 abs() 메서드를 사용했다.

 

밑의 영상을 보고 추가적인 설명을 이어가도록 하겠다.

 

위의 영상은 dragBox의 사이즈를 지정할 때, abs()를 사용하지 않았을 때의 영상이다. 영상으로 보면 확인할 수 있듯이, 특정한 경우에만 DragBox가 안보이는 문제가 발생한다.

startPos를 원점이라고 둘 때, 제 1사분면인 시작점 기준 오른쪽 위로 드래그하는 경우에만 DragBox가 제대로 렌더링이 되는 문제이다.(그 외에는 전부 DragBox가 보이지 않음)

 

이 문제의 답은 간단한데, 모든 오브젝트는 0보다 큰 양수의 값이 사이즈여야 제대로 렌더링이 된다.(상식적으로 생각해봐도 그렇다.)

그런데, dragBox의 사이즈를 지정하는 과정에서 음수 값이 들어가게 된다면 제 1사분면을 제외한 세 면에서의 DragBox의 크기는 음수 값이 들어가기 때문에 렌더링이 안되는 것이다. 그에 비해 제 1사분면은 두 값이 모두 양수가 들어가기 때문에 제대로 렌더링이 된다.

 

따라서 abs()를 사용하여 모든 사분면에서 양수 값이 들어가게 해야 렌더링이 제대로 될 것이다.

 

 


사과 선택 로직

드래그를 통해 영역으로 드래그를 하여 드래그 안에 사과가 포함되면 해당 사과를 선택상태로 만들고, 마우스를 떼면 동시에 선택된 사과들에 대한 합을 구한 뒤에 정답 처리가 진행 될 것이다.

 

그렇다면, 우선 사과가 선택되는 기능을 구현하기 전에, 사과만 선택 가능해야하니 사과만을 선택하는 방법을 생각해야 했다. 여러 방안들이 떠올랐지만, 그 중에서 비용이나 성능을 생각해서 구현 가능성이 있어 보이는 방법을 선택했다.

 

선택이 가능한 UI인 사과를 미리 List에 담아둘 것이다. 그리고 드래그 박스가 활성화 되면 해당 영역 안의 UI들이 이 List에 들어있는지 검사하여 선택 상태로 만들 것이다.

 

DragController.cs

using NUnit.Framework;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystem;
using UnityEngine.UI;

namespace AppleSum
{
    public class DragController : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler
    {
        [SerializeField]
        private RectTransform dragBox;
        [SerializeField]
        private List<Apple> selectableItems;
        [SerializeField]
        private HashSet<Apple> selectedItems = new HashSet<Apple>();
        private Vector2 startPos;
        private RectTransform canvasRect;

        public void SetSelectableItems(List<Apple> list)
        {
            selectableItems = list; //완전 동일 참조 복사(동기화)
        }

        private void OnEnable()
        {
            if (canvasRect == null)
            {
                canvasRect = GetComponentInParent<RectTransform>();
            }
            if (dragBox != null)
            {
                dragBox.gameObject.SetActive(false);
            }
        }

        public void OnPointerDown(PointerEventData eventData)
        {
            //화면 좌표를 UI 좌표로 변환
            RectTransformUtility.ScreenPointToLocalPointInRectangle(
                canvasRect,                     //기준이 되는 RectTransform
                eventData.position,             //화면 상의 마우스 좌표
                eventData.pressEventCamera,     //해당 canvas를 렌더링 하는 카메라
                out startPos                    //변환된 로컬 ui 좌표가 저장될 변수
                );
            dragBox.gameObject.SetActive(true);
            dragBox.anchoredPosition = startPos;
            dragBox.sizeDelta = Vector2.zero;
        }

        public void OnDrag(PointerEventData eventData)
        {
            Vector2 currPos;
            RectTransformUtility.ScreenPointToLocalPointInRectangle(
                canvasRect,
                eventData.position,
                eventData.pressEventCamera,
                out currPos //현재 좌표 가져오기
                );

            Vector2 diff = currPos - startPos;
            dragBox.anchoredPosition = startPos + diff / 2f;
            dragBox.sizeDelta = new Vector2(Mathf.Abs(diff.x), Mathf.Abs(diff.y));

            UpdateSelection();
        }

        public void OnPointerUp(PointerEventData eventData)
        {
            dragBox.gameObject.SetActive(false);
        }
        private void UpdateSelection()
        {
            //dragBox와 넓이가 같은 실제 UI 감지 영역
            Rect boxRect = new Rect(
                dragBox.anchoredPosition - dragBox.sizeDelta / 2,
                dragBox.sizeDelta
            );

            foreach (var item in selectableItems)
            {
                //if (item == null) continue;

                // 각 UI의 중심점 계산
                Vector2 itemPos = item.gameObject.GetComponent<RectTransform>().anchoredPosition;

                item.IsSelect = boxRect.Contains(itemPos); //중심점이 영역 안에 있으면 true
                if (item.IsSelect)
                {
                    selectedItems.Add(item); //이미 중복이 되어있으면 알아서 안들어감
                }
                else
                {
                    selectedItems.Remove(item); //선택이 안되어 있다면 삭제
                }
            }
        }
    }
}

 

또한 위 코드에서는 HashSet의 중복이 안되는 특징을 활용하여, 선택된 사과를 또 중복 선택해서 담기는 일이 없도록 하였다.

 

다음은 AppleIsSelect 프로퍼티를 아직 안만들었기에, 선택 효과를 시각적으로 표시하는 기능을 구현하는 과정에서 만들어 주도록 하겠다.


사과 선택 표시

사과가 선택되었음을 표시해주는 효과로 외곽선을 추가해서 강조 표시를 해주겠다. 배경과 사과와 색감이 다른 색을 선택하여 한 눈에 확인할 수 있도록 하고, Body와 Head에만 추가하여 확실히 외곽에만 표시가 되도록 만들었다.

Outline 컴포넌트를 추가한 모습, Body와 Head에만 추가를 해줬다.
Outline은 너무 얇으면 안보일 수 있으니 적당히 설정했다.

 

이제 전 단계에서 보았던 IsSelect 프로퍼티와 백킹 필드를 Apple 클래스에 추가하겠다.

 

Apple.cs

using NUnit.Framework;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

namespace AppleSum
{
    public class Apple : MonoBehaviour
    {
        private Outline[] outlines;
        private Text numText;
        [SerializeField]
        private int number = 0;

        public int Number
        {
            get { return number; }
            set
            {
                if(value > 0 && value < 10) number = value;
                numText.text = number.ToString();
            }
        }
        private bool isSelect;
        public bool IsSelect
        {
            get { return isSelect; }
            set
            {
                isSelect = value;
                
                foreach (var item in outlines)
                {
                    if(isSelect) item.enabled = true;
                    else item.enabled = false;
                }
                Debug.Log($"Selected : {gameObject.name}");
            }
        }
        
        public void InitApple(int number, int name)
        {
            numText = GetComponentInChildren<Text>();
            outlines = GetComponentsInChildren<Outline>();
            Number = number;
            gameObject.name = $"Apple_{name}";
            IsSelect = false;
        }
    }
}

데이터 전달하기

여기까지만 만들었다고 해도 아직 드래그를 한다고 사과가 선택이 되지 않는다. 왜냐하면 DragController선택 가능한 UI를 담아둔 ListselectableItems를 초기화를 안해주었기 때문이다.

 

하지만, 메서드는 만들어놨기 때문에 데이터만 전달해주면 된다. 모든 사과가 선택 가능한 UI이니, 사과 전체를 담은 List를 만들어서 전해주면 된다. 그리고 이미 PuzzleController에서 2차원 배열로 이를 가지고 있긴 하지만, 타입이 맞지 않는다.

 

그래서 고민 끝에 PuzzleController가 가지고 있는 2차원 배열도 List로 수정을 해주기로 결정했다. 왜냐하면, 이 게임에서는 파이프 연결 퍼즐처럼 위치 값이 중요하지 않고, 각 사과가 가지고 있는 숫자가 무엇인가가 중요하기에 2차원 배열이 아닌 List로 수정하였다.

 

그렇다면, PuzzleGeneraterGeneratePuzzle 메서드의 반환 타입이 수정되어야 할 것이다. 코드는 다음과 같다.

 

PuzzleGenerater.cs

using NUnit.Framework;
using System.Collections.Generic;
using UnityEngine;

namespace AppleSum
{
    public class PuzzleGenerater : MonoBehaviour
    {
        [SerializeField]
        private GameObject applePrefab;
        public List<Apple> GeneratePuzzle(int appleCount)
        {
            Debug.Log("Puzzle Generated");
            List<Apple> appleList = new List<Apple>();
            for(int i = 0; i < appleCount; i++)
            {
                GameObject newApple = Instantiate(applePrefab, transform);
                appleList.Add(AppleInit(newApple, i));
            }
            return appleList;
        }
        private Apple AppleInit(GameObject appleObj, int name)
        {
            Apple apple = appleObj.GetComponent<Apple>();
            if (apple == null) apple = appleObj.AddComponent<Apple>();

            apple.InitApple(Random.Range(1, 10) , name);
            return apple;
        }
    }
}

 

PuzzleController.cs

using NUnit.Framework;
using System.Collections.Generic;
using UnityEngine;

namespace AppleSum
{
    public class PuzzleController : MonoBehaviour
    {
        [SerializeField]
        private PuzzleGenerater puzzleGenerater;
        [SerializeField]
        DragController dragController;

        [SerializeField]
        private const int APPLE_COUNT = 81;

        [SerializeField]
        private List<Apple> appleList; 

        private void OnEnable()
        {
            puzzleGenerater = GameObject.Find("Board").GetComponent<PuzzleGenerater>();
            dragController = puzzleGenerater.gameObject.GetComponent<DragController>();
        }
        private void Start()
        {
            GameStart();
        }
        private void GameStart()
        {
            appleList = puzzleGenerater.GeneratePuzzle(APPLE_COUNT);
            dragController.SetSelectableItems(appleList);
        }
    }
}

 

그리고 PuzzleGenerater에서 받은 사과가 담긴 List를 PuzzleControllerDragController에 선택 가능한 UI 오브젝트라고 데이터를 전달하도록 했다. 이제, 문제가 없다면 제대로 드래그를 통해 사과들이 선택되고, 선택된 사과들은 외곽선이 표시 될 것이다.

 

문제가 없다면 말이다...

 


선택이 안되는 버그 해결

개발 초기 단계 부분에서 언급했던 Grid Layout Component가 다시 역풍이 되어 나에게 돌아왔다. 버그의 원인이 Layout Group 컴포넌트 때문은 아니지만, 덕분에 조금 문제 해결에 단계가 추가 되었다.

 

일단, 현재 발생한 문제는 AppleIsSelect에 계속 false가 들어가는 현상이 발생했다. 어떻게든 로그를 찍으면서 찾아본 결과로 얻어냈다. AppleDragBox의 영역이 서로 다르기 때문에 선택이 되지 않은 것이다. 그렇다면 영역을 같게 만들어주면 되는 일이기에, @DragController 오브젝트를 삭제하고 Board 안에 DragBox 오브젝트를 이동시켜서 같은 뿌리(부모)를 갖게 했다.

 

하지만, 여기서 또 다른 문제가 발생한 것이다. Board가 가지고 있던 Grid Layout Group 컴포넌트에 의해서 DragBox가 같이 정렬되어버렸다.

 

원래는 Grid Layout을 disalbe을 할 예정이기는 했다. 사과가 정답 처리를 하고 나면, 사라지든 off를 하게 되었을 때 빈자리를 땡겨서 또 정렬이 되어버릴 테니까. 정답 처리를 하고 나서 추가할 기능이었지만, 지금 겪고 있는 문제를 해결하기 위해서 어쩔 수 없이 지금 당장 구현하는 것으로 결정했다.

 

그런데, 이 Layout Group 류의 컴포넌트들이 작동되는 시점이 정말 골이 아프다. 씬이 로드되자마자 되는 것이 아니라 유니티의 생명 주기 중에서 LateUpdate 직전, Layout Rebuilder가 동작한 이후라는 것이다. 그렇기 때문에 AwakeStart에서 초기화를 하는 단계에서 주의 해야 한다는 것이다.

 

아마, 바로 직전에 만들었던 해밀토니안 경로 퍼즐에서도 Layout Group 류의 컴포넌트 작동 방식에 대해서 설명했던 것이 있는데, 일맥상통한다. 그래서 해결방법도 비슷하게 해결했다.

일단 아래 코드들을 보고 나서 설명하겠다.

 

PuzzleGenerater.cs

public void DisableGridRayout()
{
    if(gridLayout == null)
    {
        gridLayout = gameObject.GetComponent<GridLayoutGroup>();
    }
    Canvas.ForceUpdateCanvases();
    gridLayout.enabled = false;
}

 

 

PuzzleController.cs

private void GameStart()
{
    appleList = puzzleGenerater.GeneratePuzzle(APPLE_COUNT);
    dragController.SetSelectableItems(appleList);
    puzzleGenerater.DisableGridRayout();
}

 

DragController.cs

private void Awake() //기존에 OnEnable이었던 함수를 Awake로 변경
{
    if (canvasRect == null)
    {
        canvasRect = GetComponentInParent<RectTransform>();
    }
    if (dragBox != null)
    {
        dragBox.gameObject.SetActive(false);
        dragBox.anchorMin = new Vector2(0.5f, 0.5f);
        dragBox.anchorMax = new Vector2(0.5f, 0.5f);
    }
}

private void UpdateSelection()
{
    //dragBox와 넓이가 같은 실제 UI 감지 영역
    Rect boxRect = new Rect(
        dragBox.anchoredPosition - dragBox.sizeDelta / 2,
        dragBox.sizeDelta
    );

    foreach (var item in selectableItems)
    {
        //if (item == null) continue;

        // 각 UI의 중심점 계산(부모 기준 좌표로 변환)
        //DragBox와 선택 가능한 UI는 같은 부모이어야 함
        Vector2 localItemPos = dragBox.InverseTransformPoint(item.transform.position);
        Rect localRect = new Rect(-dragBox.sizeDelta / 2, dragBox.sizeDelta);
        item.IsSelect = localRect.Contains(localItemPos);

        if (item.IsSelect)
        {
            selectedItems.Add(item); //이미 중복이 되어있으면 알아서 안들어감
            Debug.Log($"add HashSet : {item.gameObject.name}");
        }
        else
        {
            selectedItems.Remove(item); //선택이 안되어 있다면 삭제
        }
    }

 

이제 apple 오브젝트와 DragBox가 같은 부모가 되었으니, 부모 중심 좌표로 계산을 했다.

또한, 기존에 DragControllerOnEnable 메서드를 Awake로 변경하여 정렬되기 전에 DragBox가 꺼지도록 설정했다.

 

테스트 결과는 다음과 같다.

 

 

선택이 되지 않는 문제를 해결하였고, 사과가 선택되는 기능도 잘 작동하고 있다.

 

이제 마지막 단계만 남았다. (프로토타입 기준)

드래그를 통해 선택된 사과들의 합이 10인지 판단하고 이후 처리까지 구현하면 된다.


개발 마지막 단계

정답 처리

DragController는 선택된 사과들( selectedApples )PuzzleController에게 보내고, selectedApples를 클리어하여 다음 드래그를 준비한다.

 

DragController.cs

public void OnPointerUp(PointerEventData eventData)
{
    dragBox.gameObject.SetActive(false);
    controller.IsAppleSumTen(selectedItems);
    selectedItems.Clear();
}

 

selectedApples을 받은 PuzzleController는 전달 받은 사과들의 합이 10인지 판단하고, 10인 경우와 아닌 경우를 나눠서 처리 할 것이다.

 

PuzzleController.cs

public void IsAppleSumTen(HashSet<Apple> selectedApples)
{
    int sum = 0;

    foreach (Apple apple in selectedApples)
    {
        sum += apple.Number;
    }
    if(sum == 10)
    {
        ClearSelectedApple(selectedApples);
    }
    else
    {
        RollbackApple(selectedApples);
    }
}
//실패(합이 10이 아님)
public void RollbackApple(HashSet<Apple> selectedApples)
{
    foreach (var apple in selectedApples)
    {
        if (apple == null) continue;
        if (apple.IsSelect)
        {
            apple.IsSelect = false;
        }
    }
}
//성공(합이 10임)
public void ClearSelectedApple(HashSet<Apple> selectedApples)
{
    foreach (Apple apple in selectedApples)
    {
        if (apple == null) continue;
        if (apple.IsSelect)
        {
            apple.gameObject.SetActive(false);
        }
    }
}

 

일단 정답 처리 기능을 구현해보았고, 의도한 대로 잘 작동하고 있는지 테스트를 해보았다.

 

 

결과는 만족스러웠고, 최초로 정의한 핵심 목표에서 중요한 목표들은 모두 달성했다. 그러므로 프로토타입은 여기서 완성하도록 하겠다.


마무리

게임이라고 하기에는 아직 빠진 기능들이 더 많아서 반만 만들어진 게임같지만, 핵심 로직을 구현하고 작동하는지를 테스트하기 위한 프로토타입을 개발하는 것에 의의를 둔 것이었으니 애초 목적이었으니 만족한다.

 

특히, 드래그를 통해 특정 UI를 선택하는 기능을 구현해보고 관련해서 문제가 발생하고 해결을 했으니 값진 경험이라 생각한다. 이런 기능은 이후에 RPG 또는 샌드박스형 생존 게임 장르 같은 프로젝트를 개발할 때 인벤토리 기능에서 유용하게 사용될 수 있는 소스 코드 중 하나라고 생각되어 관련된 코드와 문제점을 잘 보관하고 있을 것이다.

 

다음 게임은 클래식한 지뢰찾기(MineSweeper) 게임으로 찾아오도록 하겠다.