2019년 11월 1일 금요일

1. 유니티 3매치 퍼즐 프로젝트 만들기

안녕하세요.
3 매치 퍼즐 게임 빙글(Bingle) 프로젝트를 시작하겠습니다.

첫번째 작업으로 유니티 2D 프로젝트를 생성해서 9x9 보드 플레이에 적합한 카메라를 설정하고, 플레이를 관리하는 메인 컨트롤러(Controller) 및 스크립트를 만들어 보겠습니다.(이하 존칭 생략)

이번에 작업할 내용은 아래와 같다.
  • 프로젝트 생성
  • 카메라 설정
  • 플레이 씬(Scene) 기본 구성 및 스크립트 작성

소스 코드 다운로드

https://github.com/ninez-entertain/BingleStarter 에서 소스코드를 다운받을 수 있다. >git clone https://github.com/ninez-entertain/BingleStarter.git 또한, 각 진행 과정은 개별 branch로 제공되며 소스코드 다운로드 후에 각 스텝 별로 브랜치를 이용할 수 있다. 이번 장의 브랜치는 "step-1"으로 아래 git 명령어로 이번 장의 브랜치로 바로 이동할 수 있다. git checkout step-1

프로젝트 만들기

유니티 허브에서 [New]를 선택한 후 프로젝트 정보를 아래와 같이 입력한다.
  • Template : 2D
  • Project Name : BingleStarter
  • Location : 원하는 위치
[Create] 버튼을 선택해서 프로젝트를 생성한다.

개발환경 구성하기

유니티 개발환경은 아래와 같이 구성한다(개인 취향에 맞게 구성하시면 됩니다).
  • 화면 오른쪽 상단에서 [2 by 3] 선택한다.
  • [Project] 탭을 드래그해서 [Hierarchy] 탭 하위에 부착한다.
  • [Window | Console ] 메뉴 선택해서 [Consol] 탭을 선택해서, [Game] 탭 또는 [Scene] 탭 옆에 부착한다.
아래와 같이 구성하면, [Hierarchy]와 [Project]에서 [Inspector]로 드래그하기 쉬운 장점이 있다.

기본 폴더 생성하기

리소스(소스코드, 이미지 등)을 별로 관리하기 위해서 [Assets] 하위에 필요한 폴더를 미리 생성한다.
  • Scripts
  • Sprites
  • Resources
  • Prefabs

이제 기본 준비는 마쳤습니다.

카메라 설정

모바일 향으로 세로방향(Portrait) 게임을 만들 것이며 고해상도(1440 x 2560)를 기준으로 개발환경을 설정할 것이다. 실행은 해상도 관계없이 동작을 보장하며 개발편의상 환경을 고해상도로 설정했다.

아래와 같이 해상도를 추가한다.([Build Setting]에서 Android를 선택하면 기본 제공되는 해상도이다.)
  1. [Game] 탭에서 해상도 영역을 클릭하면 지원하는 해상도가 표시된다.
  2. 하단 [+] 메뉴를 선택하면 [Add] 팝업이 나타난다.
  3. Width : 1440, Height : 2560 을 입력한다.
  4. [OK]를 선택하고 추가한 해상도를 선택하면 [Scene] 화면이 아래와 같이 보여진다.

유니티의 카메라 초기 디폴트 size는 5로 설정되어 있으며, 이것은 현재 디바이스 화면의 높이를 원점(0, 0)을 기준으로 상하 5미터씩 전체 10미터 높이를 투영(Projection) 영역으로 설정한다는 의미이다. 즉, 화면에 보여지는 플레이 영역의 높이가 10미터라는 것을 의미한다. 이때 좌우 넓이는 현재 화면 비율에 따라서 정해진다. 예를 들어, 2:1의 화면비율인 경우에 size가 '5'인 경우 넓이는 2.5가 될 것이다.

지정한 화면크기가 1440 x 2560 이고 높이(2560)가 10미터이므로 넓이는 (1440 x 10) / 2560 = 5.625 미터가 되며 원점을 기준으로 좌우 2.81미터 (5.625 / 2)로 설정된다.
위의 그림을 보면 좌우 사각영역이 3미터에 조금 부족한 것을 볼 수 있다.

정리하면, 유니티 카메라 시스템의 설정은 항상 높이를 기준으로 설정하며 넓이는 현재 해상도를 기준으로 자동으로 계산되어 설정된다.

카메라 높이 직접 설정하기

우리는 9 x 9 보드판이 필요하며 블럭 하나의 크기를 1미터로 가정하면 넓이가 9미터 이상이 되어야 카메라 안에 9개의 블럭이 보여질 것이다. 현재 화면을 기준으로 전체 넓이가 9.2미터가 되도록 카메라 높이를 설정할 것이다. 계산식은 아래와 같다.

(2560 x 9.2) / 1440 / 2 = 8.17777..

이제 카메라의 size를 8.17777로 설정하면 다음과 같은 카메라 뷰를 볼 수 있다.

넓이가 9.2미터 이기때문에 1미터 크기의 블럭 9개를 배치해도 화면에 모두 보일 것이다.

카메라 높이 자동 계산해서 설정하기

개발환경에서 화면 해상도를 1440 x 2560 으로 고정시켰기 때문에 카메라 size를 계산해서 직접 설정할 수 있었지만 실제 플레이 환경은 다양한 디바이스와 다양한 화면 해상도를 가지기 때문에 현재 플레이되는 디바이스의 해상도에 따라서 카메라 size가 자동으로 설정되어야 한다.

스크립트를 이용해서 카메라 크기가 자동으로 계산되도록 해보자.

CameraAgent 스크립트 만들기

[Project] 탭 Assets/Scripts 폴더 하위에 CameraAgent 스크립트 파일을 생성한다.

CameraAgent 코드 추가

대상 카메라와 카메라의 투영(Projection) 넓이를 저장하는 변수를 선언하고, 자동으로 계산되도록 코드를 추가한다.
public class CameraAgent : MonoBehaviour
{
    [SerializeField] Camera m_TargetCamera;
    [SerializeField] float m_BoardUnit;

    void Start()
    {
        m_TargetCamera.orthographicSize = m_BoardUnit / m_TargetCamera.aspect;
    }
}
CameraAgent.cs
3 크기(size)를 설정할 대상 카메라
4 카메라 넓이, 원점을 기준으로 설정한다. 예를 들어, 전체 넓이는 9.2로 할 경우 4.6을 입력한다.
8 카메라 높이(size)를 계산한다. 넓이 x 화면비율로 쉽게 구할 수 있다.

[SerializedField] Attribute

MonoBehaviour를 상속받은 클래스가 GameObject의 컴포넌트로 등록되면, public 멤버들이 Inspector에 보여지고 유니티 에디터에서 값을 설정할 수 있다. 그러나 public으로 지정하면 해당 객체를 참조하는 다른 객체에서 멤버에 접근이 가능하다는 단점이 있다. 이러한 단점을 보완하기 위해서 유니티는 SerializedField Attribute를 제공한다.
[SerializedField]로 선언하면 private임에도 불구하고 Inspector에서 값을 설정할 수 있도록 노출된다.

다음 과정으로 CameraAgent를 Camera GameObject에 컴포넌트로 등록한다.
  1. 소스를 저장한다.
  2. 씬에서 MainCamera GameObject를 선택한다. Inspector에 MainCamera에 등록된 컴포넌트를 볼 수 있다.
  3. [Project] 탭에서 CameraAgent 스크립트를 드래그해서 Inspector에서 드롭한다.
  4. MainCamera의 Inspector에 CameraAgent가 컴포넌트로 등록된 것을 볼 수 있다.
  5. 씬에서 MainCamera GameObject를 드래그해서 CameraAgent의 'Target Camera' 속성에 드롭하여 대상을 설정한다.
  6. CameraAgent의 'Board Unit' 속성에 '4.6'을 입력한다.

실행해서 카메라 size 살펴보기

플레이버튼(▶)을 클릭해서 플레이한 후 MainCamera의 size를 살펴보면 8.1777로 설정되어 있는 것을 확인 할 수 있다. 위에서 직접 계산해서 입력한 size와 동일한 값이다.

[Game] 탭에서 화면 크기를 다양한 종류로 선택해서 확인해보자. 넓이는 항상 4.6(원점 기준)이고 카메라 높이(size)가 화면 해상도에 따라서 변경되는 것을 볼 수 있을 것이다.

Scene 저장하기

지금까지 작업한 씬을 저장한다.
기본 씬 이름 SampleScene을 PlayScene으로 변경하여 저장한다.

씬 구성하기

PlayScene은 플레이어가 선택한 스테이지(Stage)를 플레이하는 씬이다.
현재 스테이지의 전체 플레이를 총괄하는 StageController를 추가할 것이다. StageController는 플레이에 필요한 모든 정보를 관리하고, 플레이어 이벤트(터치 or 마우스)를 처리하고 전체 플레이 흐름을 총괄하는 역할을 담당하는 클래스이다.

아래 구조로 Scripts 하위 폴더를 구성하고 스크립트 파일을 작성할 것이다.

먼저 Scripts 폴더 하위에 다음의 3개 폴더를 추가하고, CameraAgent는 Core 폴더로 이동시킨다.
  • Stage : 게임 플레이 진행을 담당하는 스크립트 저장
  • Board : 보드 구성에 필요한 스크립트 저장
  • Core : 기본 구성에 필수적인 스크립트 저장

StageController 만들기

현재 스테이지를 총괄하는 역할을 담당하는 클래스(StageController)를 생성해서 전체 플레이를 관리하는 역할을 맡길 것이다.

다음 과정으로 StageController를 추가한다.
1. Scene에 Empty GameObject를 추가하고, 이름을 Board로 변경한다.
2. Scripts/Stage 폴더에 스크립트(StageController.cs)를 추가한다.

3. StageController를 Board 객체의 컴포넌트로 등록한다.

앞으로 작성하게 될 클래스를 미리 소개하면 아래와 같다.
UML 클래스 다이어그램으로 씬의 GameObject와 MonoBehaviour 클래스 그리고 로직을 다루는 C# 클래스를 표현했다.
  • Board Game Object
    씬에 추가된 빈(empty) GameObject로 StageController를 컴포넌트로 가진다.
    스테레오타입(Stereotype) "GameObject"로 표기한다.
    블럭 스프라이트를 출력하는 GameObject가 하위로 등록될 것이다.
  • StageController
    스테이지 전체 흐름을 관리하는 MonoBehaviour 클래스.
    스테레오타입(Stereotype) "Mono"로 표기한다.
  • Stage
    Stage 운영을 위해 필요한 정보(Board, 스테이지에서 사용되는 아이템 등)을 담고 있는 객체.
    일반 C# 클래스로 별도의 스테레오타입을 표기하지 않는다.
  • Board
    보드를 구성하는 정보(행/열 크기, Cell, Block)을 담고 있는 객체, C# 클래스
  • Cell
    보드의 배경을 구성하는 정보, 움직이지 않는 요소.
    예를 들어, 젤리 또는 장애물 등 이동할 수 없는 요소등이 Cell로 표현된다.
  • Block
    이동하는 요소, 플레이 과정에서 빈 곳으로 이동하거나 제거되고 생성되는 요소이다.
  • CellType, BlockType
    Cell과 Board의 종류를 정의하는 enum 타입
각 요소(특히 Cell, Block)에 대해서는 뒤에서 구현하면서 자세히 다시 설명할 것이다.

Note

앞으로 추가될 구성 요소들(GameObject, Prefab, Script 등)의 관계를 UML 다이어그램으로 표현해서 전체 구조를 한눈에 파악할 수 있도록 할 것이다. UML 표기법은 진행하면서 설명하겠다.

StageController 기본 코드

초기화 코드를 아래와 같이 추가한다.
namespace Ninez.Stage
{
    public class StageController : MonoBehaviour
    {
        bool m_bInit;
        Stage m_Stage;

        void Start()
        {
            InitStage();
        }

        void InitStage()
        {
            if (m_bInit)
                return;

            m_bInit = true;
        }
    }
}
Stage/StageController.cs
Stage 객체 참조를 선언하고, Start() 함수에서 초기화 메소드 InitStage()를 호출한다. 지금은 초기화 플래그만 간단히 설정하고, 다음 단계에서 Stage 객체를 생성해서 씬을 구성할 것이다.
3 MonoBehaviour를 상속한 클래스를 선언한다.
5 초기화 여부 상태 플래그
6 Stage를 참조할 변수를 선언한다.
10 씬이 시작되면 초기화 함수를 호출한다.
13 - 19 컨트롤러 초기화 함수로 초기화 상태 플래그를 설정한다.

Stage 클래스 추가하기

Stage 운영을 위해 필요한 정보(Board, 스테이지에서 사용되는 아이템 등)을 담고 있는 객체이다.
MonoBehaviour 클래스가 아닌, 일반(plain) C# 클래스이다.
  1. Assets/Scripts/Stage/Stage.cs Script를 추가한다.
  2. MonoBehaviour 상속을 제거하고 필요한 멤버와 속성을 정의한다.
using UnityEngine;
using Ninez.Board;

namespace Ninez.Stage
{
    public class Stage
    {
        int m_nRow;
        int m_nCol;
        public int maxRow { get { return m_nRow; } }
        public int maxCol { get { return m_nCol; } }

        Ninez.Board.Board m_Board;
        public Ninez.Board.Board board { get { return m_Board; } }
    }
}
Stage/Stage.cs
8 - 11 플레이할 게임판(Board)의 크기(행:m_nRow, 열:m_nCol)를 저장할 멤버와 속성을 선언한다.
13 - 14 스테이지를 플레이할 게임판(Board)을 저장할 멤버를 선언한다.
스테이지는 Board 객체를 가지고 있다.

Board 클래스 추가하기

보드를 구성하는 데이터(보드 크기(행 x 열), Cell, Block)을 담고 있는 클래스이다.
  1. Assets/Scripts/Board/Board.cs Script를 추가한다.
  2. 아래와 같이 MonoBehaviour 상속을 제거하고 초기 코드 추가한다.
namespace Ninez.Board
{
    public class Board
    {
        int m_nRow;
        int m_nCol;

        public int maxRow { get { return m_nRow; } }
        public int maxCol { get { return m_nCol; } }

        Cell[,] m_Cells;
        public Cell[,] cells { get { return m_Cells; } }

        Block[,] m_Blocks;
        public Block[,] blocks { get { return m_Blocks; } }

        public Board(int nRow, int nCol)
        {
            m_nRow = nRow;
            m_nCol = nCol;

            m_Cells = new Cell[nRow, nCol];
            m_Blocks = new Block[nRow, nCol];
        }
    }
}
Board/Board.cs
5 - 9 보드의 크기(행,열) 정보를 저장하는 멤버 및 속성을 선언하고 정의한다.
11, 12 보드를 구성하는 Cell을 저장하는 2차원 배열을 선언한다.
14, 15 보드를 구성하는 Block을 저장하는 2차원 배열을 선언한다.
17 - 24 생성자. 보드 크기 정보를 저장하고, 보드 크기만큼을 저장할 수 있는 Cell과 Block 배열을 생성한다.

주어진 보드 크기에 해당하는 Cell과 Block 객체를 저장할 수 있도록 배열을 각각 생성한다. 즉, 9x9 보드를 만드는 경우 81개의 Cell과 Block 객체를 각각 저장할 수 있는 배열이 생성된다.

Cell 클래스 추가하기

Cell은 보드의 배경을 구성하는 정보로서 움직이지 않는 요소이다. C# 클래스로 구현한다.
  1. Assets/Scripts/Board/Cells/Cell.cs 스크립트를 추가한다.
  2. 아래와 같이 MonoBehaviour 상속을 제거하고, 초기 속성을 추가한다.
    namespace Ninez.Board
    {
        public class Cell
        {
            protected CellType m_CellType;
            public CellType type
            {
                get { return m_CellType; }
                set { m_CellType = value; }
            }
        }
    }
    Board/Cells/Cell.cs
  3. Assets/Scripts/Board/Cells/CellDefine.cs 스크립트를 추가한다.
  4. 아래와 같이 클래스를 제거하고, enum 타입을 선언한다
    namespace Ninez.Board
    {
        public enum CellType
        {
            EMPTY = 0,      //빈공간, 블럭이 위치할 수 없음, 드롭 통과
            BASIC = 1,      //배경있는 기본 형 (No action)
            FIXTURE = 2,    //고정된 장애물. 변화없음
            JELLY = 3,      //젤리 : 블럭 이동 OK, 블럭 CLEAR되면 BASIC, 출력 : CellBg
        }
    }
    Board/Cells/CellDefine.cs
CellDefine.cs 스크립트는 Cell 구성에 필요한 부가 정보 정의하는데 주로 사용한다.(enum 타입 선언 및 확장 메소드 정의)
Cell의 종류를 식별하기 위해 enum 타입 CellType을 선언한다.(다음 장에서 EMPY와 BASIC Cell에 대해서 다룰 예정이다.)

Block 클래스 추가하기

플레이 과정에서 이동하는 요소로서 동적으로 생성, 삭제되는 객체이다.
  1. Assets/Scripts/Board/Blocks/Block.cs 스크립트를 추가한다.
  2. 아래와 같이 MonoBehaviour 상속을 제거하고 초기 속성을 추가한다.
    namespace Ninez.Board
    {
        public class Block
        {
            BlockType m_BlockType;
            public BlockType type
            {
                get { return m_BlockType; }
                set { m_BlockType = value; }
            }
        }
    }
    Board/Blocks/Block.cs
  3. Assets/Scripts/Board/Blocks/BlockDefine.cs 스크립트를 추가한다.
  4. 아래와 같이 클래스를 제거하고, enum 타입 BlockType을 정의한다
    namespace Ninez.Board
    {
        public enum BlockType
        {
            EMPTY = 0,
            BASIC = 1
        }
    }
    Board/Blocks/BlockDefine.cs
플레이버튼(▶)을 클릭해서 플레이 해보고 오류가 없음을 확인한다. 화면에 보이는 요소가 없기 때문에 빈화면이 보여질 것이다.

다음 장에서는 스테이지를 구성하는 게임 데이터인 보드(Board)를 구성해서 Cell과 Block이 화면에 보이는 작업을 진행할 것이다.

UML 다이어그램 파일 보기

프로젝트 설계에 사용하는 UML 도구는 오픈소스인 StarUML을 사용한다. https://sourceforge.net/projects/staruml/ 에서 프로그램을 다운받을 수 있다. 배포되는 소스에서 UML 파일 위치는 'Assets/Doc/BingleStart.uml'이다. UML을 이용한 모델링은 별도 세션으로 추후 게시할 예정이다.

문의사항 및 잘못된 부분은 댓글 및 메일(ninez.entertain@gmail.com)으로 부탁드립니다.
감사합니다.

댓글 5개:

  1. 여쭤 볼게 있습니다.
    (1440 x 9.2) / 2560 / 2 = 8.17777 이 해당 연산의 결과가 왜 8.1777이 나오나요?
    처음에 저런 제가 직접 계산한 값하고 틀렸나해서 계산기로 돌려보고 직접 해당 연산을 코드로
    돌려보기까지 했는데 2.5875가 나오는데요..

    답글삭제
  2. 안녕하세요. 확인이 늦어서 이제야 남기게 되었습니다.
    수식이 잘못되었네요 ㅠㅠ. 1440 과 2560이 바뀌었습니다.
    변경전 : (1440 x 9.2) / 2560 / 2
    변경후 : (2560 x 9.2) / 1440 / 2

    수정해서 본분에 반영했습니다.
    감사합니다.

    답글삭제
  3. 복습하러 다시 왔어요~ 다시 돌아오시는 날을 기다릴게요!

    답글삭제
  4. 퍼즐게임 알고리즘 이해하는데 좋은 정보 감사합니다.

    답글삭제
  5. 코드 짜는 습관이 MVC 패턴으로 짜신 것 같은데 제가 이해를 하고 있는게 맞나요?

    답글삭제