2019년 11월 24일 일요일

4. 파일에서 스테이지 데이터 로드하기

이번 장에서는 스테이지 파일에서 플레이 데이터를 읽어들여서 보드 구성을 동적으로 만드는 과정을 진행하겠습니다.
아래와 같은 형태의 스테이지들을 구성해보겠습니다.

소스 코드 다운로드

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

이번에 작업할 내용은 아래와 같다.
  • JSON 포맷의 스테이지 파일 만들기
  • JSON 파일에서 로딩 된 정보로 스테이지 꾸미기
  • StageReader 클래스 추가
  • CellFactory 클래스 추가

스테이지 파일 준비하기

Resources 폴더 하위에 스테이지 파일을 만들 것이다.
  1. Assets/Resources 폴더 하위에 Stage 폴더를 생성한다.
  2. Resources/Stage 폴더 하위에 텍스트 파일을 아래와 같이 추가한다.
    • stage_0001.txt
    • stage_0002.txt
    • stage_0003.txt
  3. 아래 텍스트를 stage_0001.txt 파일에 입력 후 저장한다.
{
    "row":9,
    "col":9,
    "cells":[
                0,0,1,1,1,1,1,0,0,
                0,1,1,1,1,1,1,1,0,
                1,1,1,1,1,1,1,1,1,
                1,1,1,0,0,0,1,1,1,
                1,1,1,0,0,0,1,1,1,
                1,1,1,0,0,0,1,1,1,
                1,1,1,1,1,1,1,1,1,
                1,1,1,1,1,1,1,1,1,
                1,1,1,1,1,1,1,1,1
             ]
}
stage_0001.txt
유니티 에디터에서 텍스트 파일을 추가하는 작업이 불편해서 아래와 같이 Visual Studio를 이용했다.


스테이지 파일은 JSON 형식으로 다음을 의미한다.
  • row : 보드 행(row) 개수
  • col : 보드의 열(column) 개수
  • cells : 보드의 행과 열 위치의 Cell 타입 정보를 담고 있는 배열
    • 1차원 배열로 전체 보드 데이터를 담을 수 있는 크기 이어야 한다.
      예를 들어, 9 x 9 보드인 경우 81개의 데이터가 필요하다.
    • 저장된 데이터는 enum CellType의 값을 int형으로 변환된 값이다.
      • 0 : 비어있는 Cell
      • 1 : 배경이 있는 Cell


스테이지 파일 읽기

스테이지 파일을 읽어서, 데이터를 보관하고, 사용하기 위해서 아래와 같은 클래스들을 추가할 것이다(노란색)

먼저, 스테이지 JSON 파일을 읽어와서 데이터를 보관할 클래스를 작성한다.

JSON Serialized Object 작성하기

유니티에서 제공하는 JsonUtility를 사용해서 JSON 파일을 읽어서 Object로 변환할 것이다. 변환되는 Object는 JSON to Object 변환이 적용되도록 Serializable한 객체로 선언되어야 한다.
namespace Ninez.Stage
{
    [System.Serializable]
    public class StageInfo
    {
        public int row;
        public int col;

        public int[] cells;

        public override string ToString()
        {
            return JsonUtility.ToJson(this);
        }
    }
}
StageInfo.cs
1 namespace를 선언한다.
3 [Serializable] Attribute룰 선언한다. 유티니 serializer를 사용하겠다는 선언.
4 일반(plain) 클래스를 선언한다. MonoBehaviour 클래스는 유니티 serializer에서 지원하지 않는다.
6 - 7 row, col 멤버를 선언한다. JSON 데이터의 키(key) 이름과 일치해야 한다.
9 cells 멤버를 선언한다. 보드 크기 만큼의 데이터를 담을 수 있도록 배열로 선언한다.
JSON 데이터의 키 이름과 일치해야 한다.
11 - 14 디버깅 용도. StageInfo의 멤버를 다시 JSON으로 변환해서 결과를 확인하는데 사용한다.

계속해서, StageInfo에서 cells 배열에 저장된 데이터를 읽어오는 메소드를 추가한다.
cells는 1차원 배열이므로 row와 col에 해당되는 값을 읽어오기 위해서는 간단한 계산식이 필요하다.
public CellType GetCellType(int nRow, int nCol)
{
    Debug.Assert(cells != null && cells.Length > nRow * col + nCol);

    if (cells.Length > nRow * col + nCol)
        return (CellType)cells[nRow * col + nCol];

    Debug.Assert(false);

    return CellType.EMPTY;
}

public bool DoValidation()
{
    Debug.Assert(cells.Length == row * col);
    Debug.Log($"cell length : {cells.Length}, row, col = ({row}, {col})");

    if (cells.Length != row * col)
        return false;

    return true;
}
StageInfo.cs
1 - 11 요청한 위치(row, col)에 해당되는 CellType을 리턴하는 메소드
3 요청한 위치가 유효한지 체크한다.(only debugging)
5 - 6 cells 배열에서 요청한 위치에 저장된 값을 CellType으로 변환해서 리턴한다.
13 - 22 JSON 데이터 유효성 검사를 수행하는 메소드
18 - 19 블럭 크기와 배열크기가 다른 경우 false를 리턴한다.
* 정상 동작을 확인하기 위해서 유닛테스트 대신 Debug.Assert()를 앞으로 자주 사용할 것이다.

JSON 파일 읽기

이제 JSON 파일을 읽어서 변환된 StagInfo 객체를 구해보자.
씬이 플레이되고 스테이지가 열릴 때 스테이지 파일(JSON)에서 구성 정보를 읽어들여서 플레이 환경을 구성한다. 스테이지를 구성하는 과정에서 JSON 파일을 로드할 것이다.
아래과 같이 StageReader 클래스를 추가한다. (위치 : Stage/StageReader.cs)
namespace Ninez.Stage
{
    public static class StageReader
    {
        public static StageInfo LoadStage(int nStage)
        {
            Debug.Log($"Load Stage : Stage/{GetFileName(nStage)}");

            //1. 리소스 파일에서 텍스트를 읽어온다.
            TextAsset textAsset = Resources.Load<Textasset>($"Stage/{GetFileName(nStage)}");
            if (textAsset != null)
            {
                //2. JSON 문자열을 객체(StageInfo)로 변환한다.
                StageInfo stageInfo = JsonUtility.FromJson<StageInfo>(textAsset.text);

                //3. 변환된 객체가 유효한지 체크한다(only Debugging)
                Debug.Assert(stageInfo.DoValidation());

                return stageInfo;
            }

            return null;
        }

        static string GetFileName(int nStage)
        {
            return string.Format("stage_{0:D4}", nStage);
        }
    }
}
StageReader.cs
1 namespace를 선언한다.
3 static 클래스 StageReader를 선언한다.
5 - 23 StageInfo 객체를 리턴하는 LoadStage() 함수를 구현한다.
nStage : 로드할 스테이지 번호
return : StageInfo 객체
10 유니티 리소스 파일을 읽어서 스테이지 데이터를 텍스트로 담고 있는 텍스트 애셋으로 생성한다.
14 JsonUtility.FromJson()을 사용해서 읽어들인 스테이지 JSON 데이터를 Serialize한 StageInfo 객체로 생성한다.
25 - 28 읽어들일 스테이지 리소스 이름을 구한다.
'stage_숫자네자리'로 구성된 파일이름을 리턴한다.

JsonUtility.FromJson()

유니티는 JSON 문자열을 읽어서 지정한 타입의 객체를 생성해서 리턴해주는 API를 제공한다. T jsonObj = JsonUtility.FromJson<T>(JSON 문자열) JsonUtility는 유니티 serializer를 사용해서 JSON을 C# 객체로 생성한다.
T 클래스는 [Serializable] Attribue를 가진 일반(plain) 클래스이어야 하며, 클래스의 멤버(필드)는 serializer에서 지원하는 타입이어야 한다.
int, boolean, string과 같은 기본형(Primitive Type)은 모두 지원하며 MonoBehaviour 클래스는 지원하지 않는다. 또한 배열은 지원하지만 맵(Dictionary)는 지원하지 않는다.

스테이지 파일로부터 스테이지 구성하기

지금까지는 스테이지 정보를 구성하는 StageBuilder 클래스는 임시로 정한(row와 col이 같은 경우 EMPTY Cell을 만드는) 규칙을 적용해서 Cell과 Block 객체를 생성(Spawn)했다.
이제부터는 스테이지 파일에서 읽어들인 정보를 이용해서 Cell과 Block 객체를 생성하도록 수정한다.

아래와 같이 StageBuilder를 수정한다.
public class StageBuilder
{
    StageInfo m_StageInfo;  //추가

    public Stage ComposeStage(int row, int col)
    public Stage ComposeStage()
    {
        Debug.Assert(m_nStage > 0, $"Invalide Stage : {m_nStage}");

        //0. 스테이지 정보를 로드한다.(보드 크기, Cell/블럭 정보 등)
        m_StageInfo = LoadStage(m_nStage);

        //1. Stage 객체를 생성한다.
        Stage stage = new Stage(this, m_StageInfo.row, m_StageInfo.col);

        //2. Cell,Block 초기 값을 생성한다.
        for (int nRow = 0; nRow < m_StageInfo.row; nRow++)
        {
            for (int nCol = 0; nCol < m_StageInfo.col; nCol++)
            {
                stage.blocks[nRow, nCol] = SpawnBlockForStage(nRow, nCol);
                stage.cells[nRow, nCol] = SpawnCellForStage(nRow, nCol);
            }
        }

        return stage;
    }

    public StageInfo LoadStage(int nStage)   //추가 메소드
    {
        StageInfo stageInfo = StageReader.LoadStage(nStage);
        if (stageInfo != null)
        {
            Debug.Log(stageInfo.ToString());
        }

        return stageInfo;
    }

    Block SpawnBlockForStage(int nRow, int nCol)
    {
        return nRow == nCol ? SpawnEmptyBlock() :SpawnBlock(); 

        if (m_StageInfo.GetCellType(nRow, nCol) == CellType.EMPTY)
            return SpawnEmptyBlock();

        return SpawnBlock();
    }

    Cell SpawnCellForStage(int nRow, int nCol)
    {
        return new Cell(nRow == nCol ? CellType.EMPTY : CellType.BASIC);

        Debug.Assert(m_StageInfo != null);
        Debug.Assert(nRow < m_StageInfo.row && nCol < m_StageInfo.col);

        return CellFactory.SpawnCell(m_StageInfo, nRow, nCol);
    }

    public static Stage BuildStage(int nStage, int row, int col)
    public static Stage BuildStage(int nStage)
    {
        StageBuilder stageBuilder = new StageBuilder(nStage);  //수정
        Stage stage = stageBuilder.ComposeStage();  //수정

        return stage;
    }
}
StageBuilder.cs
3 JSON으로 부터 로딩된 스테이지 정보를 담고있는 StageInfo 멤버를 추가한다.
5 - 6 ComposStage() 파라미터를 제거한다. row, col은 StageInfo의 멤버로 관리되기 때문에 더 이상 필요하지 않는 파라미터이다.
8 스테이지 번호가 유효한지 검사한다.(only debugging)
11 현재 플레이될 스테이지(m_nStage) 구성 정보를 로드해서 StageInfo 객체를 리턴 받는다.
14 수정. row, col을 파라미터가 아닌 StageInfo에 저장된 값을 사용하도록 변경한다.
29 - 38 LoadStage() 메소드를 추가한다.
위에서 추가한 StageReader에게 스테이지 파일을 로드해서 StageInfo를 구한다.
31 StageReader에게 스테이지 정보를 읽어서 StageInfo 객체에 담아주도록 LoadStage() 메소드를 호출한다.
42 row == col 인 경우에 빈 Cell을 생성한 기능을 삭제하고, 로딩된 스테이지 정보에서 CellType 데이터를 구해서 Cell 객체를 생성하도록 요청한다.
52 - 57 Cell 객체를 직접 생성하는 코드를 삭제하고, CellFactory에게 Cell 객체 생성을 요청하도록 수정한다.
60 - 61 보드 크기(row, col)은 스테이지 파일에서 읽어오기 때문에 파라미터에서 삭제한다.
63 0번 스테이지로 고정된 상수 값을 파라미터인 nStage로 수정한다.
64 row, col 파라미터가 제거한다. stageBuilder에 저장된 StageInfo에서 row, col을 구할 수 있다.


StageController 수정

스테이지를 구성하는 방법이 변경되었으므로 보드 크기를 인자로 전달하는 부분을 제거하고 로드할 스테이지 번호을 전달한다.
void BuildStage()
{
    //1. Stage를 구성한다.
    m_Stage = StageBuilder.BuildStage(nStage: 0, row: 9, col: 9);
    m_Stage = StageBuilder.BuildStage(nStage : 1);

    //2. 생성한 stage 정보를 이용하여 씬을 구성한.
    m_Stage.ComposeStage(m_CellPrefab, m_BlockPrefab, m_Container);
}
StageController.cs
스테이지 번호는 '1'부터 시작하므로 첫번째 스테이지를 로딩하도록 인자를 '1'로 전달하여 BuildStage(1)를 호출한다.

실행하기

플레이버튼(▶)을 클릭해서 플레이 결과를 확인해 보자.
Cell 스프라이트가 출력되고 해당 Cell 위에 블럭이 랜덤하게 위치하고 있는 것을 볼 수 있다.

자세히 살펴보면 상하가 뒤집혀 있는 것을 알 수 있다.
이유는 JSON의 cells는 1차원 배열로서 화면에 보이는 왼쪽상단에서 인덱스 0 부터 80까지 배치되어 있으나, 플레이 화면에서 보이는 Cell/Block의 위치는 왼쪽하단이 인덱스 0에 해당되는 값으로 사용된다.

cell[0]의 값이 '0'이기 때문에 화면의 왼쪽하단 cell[0]에 해당되는 부분이 비어있는 것을 볼 수 있다.
우리가 원하는 결과는 눈에 보이는 JSON cells 배열값과 화면이 매칭되는 아래와 같은 화면일 것이다.

우리가 보는 json 형태와 화면에 출력되는 Cell과 Block의 모양이 같으면 개발 편의성이 있음으로 아래와 같이 수정해보자.
단순히 인덱스 계산부분을 수정했다.
(스테이지 에디터가 없이 텍스트 파일을 직접 편집하기 때문에 발생하는 불편함으로 나중에 스테이지 에디터를 만들게 되면 필요하지 않는 코드이다.)
public CellType GetCellType(int nRow, int nCol)
{
    Debug.Assert(cells != null && cells.Length > nRow * col + nCol, $"Invalid Row/Col = {nRow}, {nCol}");

    if (cells.Length > nRow * col + nCol)
        return (CellType)cells[nRow * col + nCol];

    int revisedRow = (row - 1) - nRow;
    if (cells.Length > revisedRow * col + nCol)
        return (CellType)cells[revisedRow * col + nCol];

    Debug.Assert(false);

    return CellType.EMPTY;
}
StageInfo.cs

다시 실행해보자. JSON 파일과 동일한 위치에 Cell과 Block이 위치하는 것을 볼 수 있다.

stage_0002.txt과 stage-0003.txt 파일도 임의의 값을 입력해서 실행해보자.
row와 col의 값을 다양하게 변경해보면서 테스트 해보면 재미있는 결과를 얻을 수 있을 것이다. 9보다 큰 사이즈의 보드를 사용하면 화면에 일부만 보일 것이다. 이 경우에 Inspector에서 CameraAgent의 'Board Unit' 속성의 값을 변경하면 해결할 수 있다. 보드크기 / 2 + 0.1 정도가 적당할 것이다.


마치며

지금까지 결과를 정리한 UML 다이어그램을 공유하면서 이번 장을 마치겠습니다.(클릭해서 크게 볼 수 있습니다)

다음 장에서는 터치/마우스 이벤트 처리해서 블럭을 매칭하는 과정을 진행하겠습니다.

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

댓글 2개:

  1. CellFactory 클래스에 대한 내용 빠져있네요. 예제 코드에 있어서 가져와서 해결했어요.

    답글삭제
  2. 중간에 빠져있는 코드가 뭔가 꼭 "함 풀어봐라~!" 이러는 느낌이라서 좋은데요? ㅋㅋㅋㅋㅋㅋㅋㅋ?

    답글삭제