2019년 11월 25일 월요일

5. 3-Match 없는 시작화면 구성하기

마우스/터치 이벤트를 처리하기 전에 스테이지를 구성하는 플레이 보드를 구성하는 블럭의 배치를 조정하는 작업을 먼저 진행하겠습니다.

아래는 지난 장에서 구성한 스테이지를 실행한 화면이다.

플레이어의 이벤트를 처리하지 않고 스테이지 파일에서 정보를 로드하여 구성한 시작화면으로 자세히 살펴보면 이상한 부분을 발견 할 수 있다.시작화면에서 상하좌우로 3개 이상 연속으로 배치되어 있는 블럭을 볼 수 있다. 랜덤하게 블럭 종류를 발생시켰기 때문에 나타나는 자연스러운 현상이다.

이번 장에서는 터치/마우스 이벤트를 처리하기 전 초기 블럭 배치에서 3개 이상 연속되는 블럭이 배치되지 않도록 게임판(Board)를 뒤섞는(Shuffle) 기능을 추가할 것이다.

소스 코드 다운로드

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

이번 장은 전체 흐름을 이해하는 데는 크게 중요한 부분은 아니기 때문에 초기 도입부까지만 보고 다음장으로 건너뛰어도 무방하다. 3개이상 블럭이 연속되지 않도록 처리하는 로직에 대한 설명 부분으로 자세한 알고리즘을 이해하지 않아도 진행에 무리는 없다.

BoardShuffler 클래스 추가하기

플레이 판(Board)에 배치된 블럭을 섞는 역할을 담당하는 클래스 BoardShuffler를 추가할 것이다.
BoardShuffler 추가한 클래스 다이어그램은 아래와 같다(일부 클래스 제외하고 표현하였다)


Board Shuffler에게 셔플 요청하기

아래는 두번째 장에서 사용한 시퀀스 다이어그램으로 보드 구성 과정을 설명하고 있다.
Board를 구성하는 ComposeStage() 메소드(시퀀스 2번)에서 Cell과 Block의 GameObject를 생성하기(시퀀스 3번) 전에 보드를 재구성 하도록 Shuffler에게 블럭 재배치를 요청하는 코드를 추가했다.

internal void ComposeStage(GameObject cellPrefab, GameObject blockPrefab, Transform container)
{
    //1. 스테이지 구성에 필요한 Cell,Block, Container(Board) 정보를 저장한다. 
    m_CellPrefab = cellPrefab;
    m_BlockPrefab = blockPrefab;
    m_Container = container;

    //2. 3매치된 블럭이 없도록 섞는다.  
    BoardShuffler shuffler = new BoardShuffler(this, true);
    shuffler.Shuffle();

    //3. Cell, Block Prefab을 이용해서 Board에 Cell/Block GameObject를 추가한다. 
    float initX = CalcInitX(0.5f);
    // -- 중략 --
}
Board.cs
10 BoardShuffler 객체를 생성한다. 멤버로 저장되지 않기 때문에 Shuffle을 수행하고 삭제될 것이다.
11 생성된 shuffler에게 Shuffle을 요청한다.

계속해서, Board 클래스가 Shuffle을 수행하기 위해 필요한 메소드를 아래와 같이 추가한다.
public bool CanShuffle(int nRow, int nCol, bool bLoading)
{
    if (!m_Cells[nRow, nCol].type.IsBlockMovableType())
        return false;

    return true;
}

public void ChangeBlock(Block block, BlockBreed notAllowedBreed)
{
    BlockBreed genBreed;

    while (true)
    {
        genBreed = (BlockBreed)UnityEngine.Random.Range(0, 6); 

        if (notAllowedBreed == genBreed)
            continue;

        break;
    }

    block.breed = genBreed;
}
Board.cs
1 - 7 주어진 위치의 블럭이 셔플 가능한지 즉, 셔플 대상인지 검사하는 메소드.
bLoading : 메소드가 호출되는 단계를 나타낸다. 플레이 시작할 때 호출되면 true가 전달된다.
3, 4 Shuffle의 대상은 이동 가능한 블럭이 위치할 수 있는 곳이다.
지정된 위치(row, col)에 블럭이 이동가능한지 CellType으로 판단한다. CellType의 확장 메소드(Extension Method)를 사용한다.
9 - 24 Block의 Breed를 notAllowedBreed에 지정된 값을 제외한 다른 Breed로 변경한다.
13 - 21 notAllowedBreed와 중복되지 않도록 Breed를 랜덤하게 설정한다.
23 새로 생성된 Breed를 블럭에 설정한다.

CellType Extension Method 추가하기

enum type인 CellType은 클래스와 달리 메소드를 추가할 수 없다.
같은 로직을 수행하는 아래 두 예를 살펴보자.
//사용 예 1)
if( cell.type == CellType.BASIC || cell.type == CellType.JELLY)

//사용 예 2)
if( cell.type.IsMovableType())
2번과 같이 메소드로 호출하는 것이 가독성이 좋을 뿐만 아니라, 확장성도 뛰어나다.
1번의 경우 같은 if 문을 여러 군데에서 사용하는 경우에는 조건이 하나 변경되면 전부 수정해야한다.
C#의 확장 메소드(Extension Method) 기능을 사용하면 2번과 같이 enum 타입을 메소드처럼 호출할 수 있다.

C# Extension Method

특수한 형태의 static 메소드로서 마치 다른 클래스에 메소드를 추가하는 것과 같이 사용할 수 있다.
static bool Found(this String str, char ch)
{
   int position = str.IndexOf(ch);
   return position >= 0;
}

static void Main(string[] args)
{
   string s = "This is a Test";
   bool found = s.Found('z');
}
1 static 으로 선언한다.
첫번째 파라미터 선언 앞에 this를 표기한다. 형식은 this 타입 파라미터명이다. 두번째 파라미터 부터는 확장 메소드에 전달되는 인자를 의미한다. 파라미터 개수는 제한없이 추가할 수 있다. String 클래스의 확장 메소드로 Found를 정의하는 것을 의미하며, 파라미터로 char ch가 전달된다.
"bingle".Found('r')과 같이 호출한다.
10 string 클래스는 Found() 메소드가 존재하지 않는다.
그러나 확장 메소드를 정의할 경우, 해당 객체의 메소드를 호출하는 것과 동일한 구문으로 사용할 수 있다.

CellType Extension Method

아래 코드를 CellDefine.cs에 추가한다.
static class CellTypeMethod
{
    /*
     * 블럭이 위치할 수 있는 타입인지 체크한다. 현재 위치한 블럭의 상태와 관계업음.
     */
    public static bool IsBlockAllocatableType(this CellType cellType)
    {
        return !(cellType == CellType.EMPTY);
    }

    /*
     * 블럭이 다른 위치로 이동 가능한 타입인지 체크한다. 현재 포함하고 있는 상태와 관계업음.
     */
    public static bool IsBlockMovableType(this CellType cellType)
    {
        return !(cellType == CellType.EMPTY);
    }
}
CellDefine.cs
1 CellType 확장 메소드를 정의할 수 있는 static 클래스를 선언한다.
6 - 9 CellType 확장 메소드 IsBlockAllocatableType()를 정의한다. 첫번째 파라미터에 this를 사용.
블럭이 위치할 수 있는 Cell인지 검사한다.
14 - 17 CellType 확장 메소드 IsBlockMovableType()를 정의한다.
Cell에 위치한 블럭이 이동 가능한 블럭인지 검사한다.

이제부터
cellType.IsBlockAllocatableType(), cellType.IsBlockMovableType()와 같이 CellType의 메소드처럼 호출할 수 있다.

Block 클래스 Extension Method 추가하기

Block 클래스에 아래와 같이 Extension Method를 추가한다.
Block 클래스는 메소드를 추가할 수 있는 일반 클래스이지만, enum 타입과는 다른 이유로 Extension Method를 사용한다.
이유는 아래 코드를 보면서 설명하겠다.
static class BlockMethod
{
    public static bool IsSafeEqual(this Block block, Block targetBlock)
    {
        if (block == null)
            return false;

        return block.isEqual(targetBlock);
    }
}
BlockDefine.cs
1 확장 메소드를 정의할 수 있는 static 클래스를 선언한다.
3 - 9 Block 클래스의 확장 메소드를 정의한다.
블럭이 null아니고 타겟 블럭과 breed가 같은 경우에 true를 리턴한다.

아래의 동일한 로직의 두가지 사용 예를 살펴보자.
Block block = GetBlock(row, col);

//사용 예 1
if(block != null && block.IsEqual(targeBlock))
    DoSomethins();

//사용 예 2
if(block.IsSafeEqual(targetBlock))
    DoSomething();
사용예 1 block에 대해서 항상 null 체크 후에 메소스를 호출해야 한다. 그렇지 않으면 NPE(Null Point Exception)이 발생한다.
사용예 2 block에 대해서 null 체크없이 메소드 호출을 할 수 있다. block이 null 인 경우에도 NPE가 발생하지 않는다.
확장 메소드의 특성을 이용한 것으로 확장 메소드 구현 코드를 살펴보면 block의 null을 내부에서 체크하는 것을 볼 수 있다.
사용예 1보다는 조금 더 코드가 보기 좋고 사용하기에도 편리하다.

Block 객체에 대해서 항상 null 체크를 해야하는 경우에 Extension Method를 사용하면 좀 더 깔끔한 코드를 얻을 수 있으며, null 체크를 하지 않는 실수를 예방할 수 있다.

Block 클래스 멤버 및 메소드 추가

Shuffle에 필요한 멤버와 몇 가지 조회 메소드를 Block 클래스에 추가한다. 조회 메소드는 앞으로 계속해서 사용하게 될 것이다.
public Transform blockObj { get { return m_BlockBehaviour?.transform; } }

Vector2Int m_vtDuplicate;       //블럭 중복 개수, Shuffle시에 중복검사에 사용.

public int horzDuplicate        //가로방향 중복 검사시 사용 
{
    get { return m_vtDuplicate.x; }
    set { m_vtDuplicate.x = value; }
}
    
public int vertDuplicate       //세로방향 중복 검사시 사용
{
    get { return m_vtDuplicate.y; }
    set { m_vtDuplicate.y = value; }
}

public void ResetDuplicationInfo()
{
    m_vtDuplicate.x = 0;
    m_vtDuplicate.y = 0;
}

public bool IsEqual(Block target)
{
    if (IsMatchableBlock() && this.breed == target.breed)
        return true;

    return false;
}

public bool IsMatchableBlock()
{
    return !(type == BlockType.EMPTY);
}
Block.cs
1 Block에 연결된 GameObject의 Transfrom을 구한다.
3 주변과 중복된 블럭의 개수. Shuffle 수행과정에서 중복 검사 과정에서 사용하는 용도
5 - 15 가로(세로) 방향 중복 개수 get/set
17 - 21 중복 개수를 0으로 리셋 시킨다.
23 - 29 같은 종류(breed)의 블럭인지 비교한다.
31 - 34 다른 블럭과 매칭 가능한 블럭인지 검사한다. 즉, 3 매치 대상이 되는 블럭인지 검사한다.
모든 블럭이 3매치의 대상이 되는 것은 아니다. 장애물 블럭과 같이 제거되지 않는 블럭이 있을 수 있다.

IsEqual(), IsMatchableBlock() 메소드는 게임 진행 과정에서 계속해서 사용될 것이다.
반면에, Duplicate 속성과 메소드는 중복 검사 시에만 사용된다.

BlockShuffler

블럭을 뒤섞는 Shuffler 클래스를 작성해보자.
적용한 셔플 알고리즘은 즉석에서 생각한 원시적인(?) 방법으로 단순 참고만 하세요.
메모리를 좀 더 효율적으로 사용하는 알고리즘을 생각해 볼 수 있지만 클래스를 추가로 만들지 않기 위해 현재 방식을 사용했습니다. Block에 새로 추가한 m_vtDuplicate는 셔플에서만 사용하는 데이터인데 Block의 멤버로 할당되어 있기 때문에 블럭 한개당 8바이트가 낭비되고 있습니다. 약간의 수고만 추가하면 1바이트만 사용하도록 수정이 가능합니다.
추가로, 좋은 알고리즘이 있으면 공유 부탁드립니다.

블럭 셔플의 기본 알고리즘은 다음과 같다.
  1. 현재 셔플가능한 블럭을 SortedList에 담아둔다. SortedList에 추가될때 블럭의 순서가 1차적으로 한번 섞이게 된다.
  2. SortedBlock에서 블럭을 한 개씩 꺼내서 보드의 첫번째 (row = 0, col =0)부터 순차적으로 블럭을 채워 나간다.
  3. 보드에 블럭을 배치할 때 상하좌우를 검사하여 3개이상 매칭될 수 있는지 검사한다.
    - 3개 이상 연속 배치되는 경우, 큐에 보관하고
    - 그렇지 않은 경우, 블럭을 Board에 배치한다.
  4. 3의 과정을 거치면 보드는 3개 이상 연속 배치되는 블럭이 없이 채워지고 SortedList의 블럭이 모두 소진된다.
  5. SortedList의 블럭이 모두 소진 후에 3에서 큐에 저장된 블럭이 큐에 남아 있게된다.
  6. 큐에 있는 블럭을 추가로 꺼내서 아직 채우지 않은 보드의 나머지에 블럭을 채워나간다.
  7. 마지막에 큐에 남은 블럭이 주변과 3 매칭되는 경우가 발생하면, 이때 블럭의 breed을 새로운 값으로 할당받아서 3매칭을 모두 제거한다.
요약하면 아래 그림과 같다. (아래 번호는 알고리즘에 표기된 번호와 관계없으며 주요 과정만 표기하였다)

BoardShuffler 클래스 선언

다음과 같이 Shuffler의 멤버 및 생성자를 정의한다.
using System.Collections.Generic;
using UnityEngine;
using Ninez.Core;

namespace Ninez.Board
{
    using BlockVectorKV = KeyValuePair<Block, Vector2Int>;

    public class BoardShuffler
    {
        Board m_Board;
        bool m_bLoadingMode;

        SortedList<int, BlockVectorKV> m_OrgBlocks = new SortedList<int, BlockVectorKV>();
        IEnumerator<KeyValuePair<int, BlockVectorKV>> m_it;
        Queue<BlockVectorKV> m_UnusedBlocks = new Queue<BlockVectorKV>();
        bool m_bListComplete;

        public BoardShuffler(Board board, bool bLoadingMode)
        {
            m_Board = board;
            m_bLoadingMode = bLoadingMode;
        }
    }
}
BoardShuffler.cs
7 using 문을 사용해서 type을 재정의한다.(Generic Type Alias)
C, C++의 typedef와 유사한 용도로 생각하면 된다.(아래 Note 참고)
11 작업 대상이 되는 보드 객체를 참조하는 멤버를 선언한다.
12 씬이 시작되고 보드 구성시에 호출되면 true, 플레이 도중에 호출되면 false를 갖는다.
플레이 도중에 호출되는 경우는 더이상 이동할 수 있는 블럭이 없는 경우에 보드를 섞거나, 셔플 아이템을 사용해서 강제로 보드를 섞는 경우에 사용될 것이다. (나중에 구현하게 될 것이다. 이 경우에는 블럭이 이동하는 애니메이션을 함께 보여줄 것이다)
14 블럭을 섞기 위해 사용하는 SortedList. 리스트에 담기면서 1차로 블럭이 셔플된다.
15 SortedList에서 블럭을 하나씩 꺼내는데 사용되는 Enumerator
16 블럭 배치 과정 중에 3 매치 발생된 블럭을 임시로 보관하는 큐
17 SortedList에서 조회할 블럭이 남아있으면 true, 조회를 모두 마치고 큐에 남아 있는 블럭을 처리하는 과정이면 false를 가진다.
19 - 23 생성자. 보드와 호출모드를 저장한다.

Generic Type Alias : using 문을 이용한 type 재정의

Generict으로 표현된 복잡한 표현식을 using 문을 사용해서 간결한 형태의 Type으로 재 정의할 수 있는 기능을 제공한다.
using 문을 사용하는 경우 using BlockVectorKV = KeyValuePair<Block, Vector2Int>; Queue<BlockVectorKV> m_UnusedBlocks = new Queue<BlockVectorKV>(); using 문을 사용하지 않는 경우 Queue<KeyValuePair<Block, Vector2Int>> m_UnusedBlocks = new Queue<KeyValuePair<Block, Vector2Int>>(); 타입을 재정의하면 위와 같이 가독성이 좋아진다.

Shuffle() 메소드 작성

셔플을 수행하는 메인 메소드를 아래와 같이 작성한다.
간단히 설명하면, 필요한 데이터를 준비하고, 준비된 데이터를 이용해서 셔플을 수행한다. 주석은 소스에 자세히 표기하였다.
public void Shuffle(bool bAnimation = false)
{
    //1. 셔플 시작전에 각 블럭의 매칭 정보를 업데이트한다
    PrepareDuplicationDatas();

    //2. 셔플 대상 블럭을 별도 리스트에 보관한다
    PrepareShuffleBlocks();

    //3. 1), 2)에서 준비한 데이터를 이용하여 셔플을 수행한다.
    RunShuffle(bAnimation);
}
BoardShuffler.cs

블럭 매칭 정보 업데이트하기

보드를 구성하는 모든 블럭의 연속배치 정보를 계산한다. 셔플 미대상인 블럭(현재 상태에서 움직이지 못하는 블럭)의 주변을 조사해서 연속배치 정보를 각 블럭에 기록하는 것이 주 목적이다. 셔플 대상 블럭은 새로 배치할 것이므로 '0'으로 초기화 하고, 미대상 블럭은 주변 미대상 블럭과의 연속배치 정보를 계산해서 기록한다.
void PrepareDuplicationDatas()
{
    for (int nRow = 0; nRow < m_Board.maxRow; nRow++)
        for (int nCol = 0; nCol < m_Board.maxCol; nCol++)
        {
            Block block = m_Board.blocks[nRow, nCol];

            if (block == null)
                continue;

            if (m_Board.CanShuffle(nRow, nCol, m_bLoadingMode))
                block.ResetDuplicationInfo();
            //움직이지 못하는 블럭(밧줄에 묶인 경우 등 현재상태에서 이동불가한 블럭)의 매칭 정보를 계산한다.
            else
            {
                block.horzDuplicate = 1;
                block.vertDuplicate = 1;

                //좌하 위치에 셔플 미대상(즉, 움직이지 못하는 블럭)인 블럭의 매치 상태를 반영한다
                //(3개이상 매치되는 경우는 발생하지 않기 때문에 인접한 블럭만 검사하면 된다)
                //Note : 좌하만 계산해도 전체 블럭을 모두 검사할 수 있다.
                if (nCol > 0 && !m_Board.CanShuffle(nRow, nCol - 1, m_bLoadingMode) && m_Board.blocks[nRow, nCol - 1].IsSafeEqual(block))
                {
                    block.horzDuplicate = 2;
                    m_Board.blocks[nRow, nCol - 1].horzDuplicate = 2;
                }

                if (nRow > 0 && !m_Board.CanShuffle(nRow - 1, nCol, m_bLoadingMode) && m_Board.blocks[nRow - 1, nCol].IsSafeEqual(block))
                {
                    block.vertDuplicate = 2;
                    m_Board.blocks[nRow - 1, nCol].vertDuplicate = 2;
                }
            }
        }
}
BoardShuffler.cs
3, 4 전체 블럭을 대상으로 for loop 적용.
11, 12 셔플 대상 블럭인 경우 연속배치 정보를 '0'으로 초기화한다.
14 - 33 셔플 미대상 블럭 즉, 현재 보드 구성에서 움직일 수 없는 블럭의 연속배치 정보를 계산한다.
현재 블럭이 밧줄(또는 얼음)등에 갇혀서 일시적으로 이동하지 못하는 블럭이 셔플 미대상 블럭이다. 미대상 블럭이 연속으로 배치된 경우를 계산하는 것이 이 메소드의 주요한 목적이다.

블럭 데이터 준비하기

셔플 대상 블럭을 별도의 리스트(SortedList)에 보관한다.
보관될 때 랜덤한 순서로 재정렬되기 때문에 리스트에 블럭이 저장되면서 1차적으로 셔플이 수행된다.
void PrepareShuffleBlocks()
{
    for (int nRow = 0; nRow < m_Board.maxRow; nRow++)
        for (int nCol = 0; nCol < m_Board.maxCol; nCol++)
        {
            if (!m_Board.CanShuffle(nRow, nCol, m_bLoadingMode))
                continue;

            //Sorted List에 순서를 정하기 위해서 중복값이 없도록 랜덤 값을 생성한후 키값으로 저장한다
            while (true)
            {
                int nRandom = UnityEngine.Random.Range(0, 10000);
                //detect key duplication
                if (m_OrgBlocks.ContainsKey(nRandom))
                    continue;

                m_OrgBlocks.Add(nRandom, new BlockVectorKV(m_Board.blocks[nRow, nCol], new Vector2Int(nCol, nRow)));
                break;
            }
        }

    m_it = m_OrgBlocks.GetEnumerator();
}
BoardShuffler.cs
6, 7 셔플 대상이 아닌 경우에 리스트에 보관하지 않는다.
10 - 19 리스트에 보관하면서 소팅 기준이 되는 키값을 임의로 발생시킨다.
이때, 중복이 발생하지 않도록 하기 위해 while문을 사용해서 중복되는 경우 소팅 키값을 재 발행한다.

준비된 데이터로 셔플 수행

지금까지 준비된 데이터를 이용해서 전체 블럭을 대상으로 요청한 위치(nRow, nCol)에 새로 배치할 블럭을 리턴 받은 후 저장한다.
void RunShuffle(bool bAnimation)
{
    for (int nRow = 0; nRow < m_Board.maxRow; nRow++)
    {
        for (int nCol = 0; nCol < m_Board.maxCol; nCol++)
        {
            //1. 셔플 미대상 블럭은 PASS
            if (!m_Board.CanShuffle(nRow, nCol, m_bLoadingMode))  
                continue;

            //2. 셔플 대상 블럭은 새로 배치할 블럭을 리턴받아서 저장한다.
            m_Board.blocks[nRow, nCol] = GetShuffledBlock(nRow, nCol);
        }
    } 
} 
BoardShuffler.cs
3 - 5 보드를 구성하는 전체 블럭을 처리하기 위해서 for 문을 사용한다.
8, 9 셔플 미대상 블럭은 처리하지 않는다.
12 지정된 위치(nRow, nCol)에 새로 배치할 블럭을 요청한 후, 리턴받은 셔플된 블럭을 보드에 재배치한다.

지정된 위치의 셔플 블럭 구하기

위의 RunShuffle()의 주요 로직은 지정된 위치에 셔플된 블럭을 구하는 것으로 대부분의 복잡한 로직은 여기에서 구현된다. 위에서 설명한 알고리즘의 2 ~ 7이 모두 아래 함수에서 진행된다.
Block GetShuffledBlock(int nRow, int nCol)
{
    BlockBreed prevBreed = BlockBreed.NA;   //처음 비교시에 종류를 저장
    Block firstBlock = null;                //리스트를 전부 처리하고 큐만 남은 경우에 중복 체크 위해 사용 (큐에서 꺼낸 첫번째 블럭)

    bool bUseQueue = true;  //true : 큐에서 꺼냄, false : 리스트에서 꺼냄
    while (true)
    {
        //1. Queue에서 블럭을 하나 꺼낸다. 첫번재 후보이다.
        BlockVectorKV blockInfo = NextBlock(bUseQueue);
        Block block = blockInfo.Key;

        //2. 리스트에서 블럭을 전부 처리한 경우 : 전체 루프(for 문 포함)에서 1회만 발생
        if (block == null)
        {
            blockInfo = NextBlock(true);
            block = blockInfo.Key;
        }

        Debug.Assert(block != null, $"block can't be null : queue  count -> {m_UnusedBlocks.Count}");

        if (prevBreed == BlockBreed.NA) //첫비교시 종류 저장
            prevBreed = block.breed;

        //3. 리스트를 모두 처리 한 경우
        if (m_bListComplete)
        {
            if (firstBlock == null)
            {
                //3.1 전체 리스트를 처리하고, 처음으로 큐에서 꺼낸 경우
                firstBlock = block;  // 큐에서 꺼낸 첫번째 블럭
            }
            else if (System.Object.ReferenceEquals(firstBlock, block))
            {
                //3.2 처음 보았던 블럭을 다시 처리하는 경우, 
                //    즉, 큐에 들어있는 모든 블럭이 조건에 맞지 않는 경우 (남은 블럭 중에 조건에 맞는게 없는 경우)
                m_Board.ChangeBlock(block, prevBreed);
            }
        }

        //4. 상하좌우 인접 블럭과 겹치는 개수를 계산한다
        Vector2Int vtDup = CalcDuplications(nRow, nCol, block);

        //5. 2개 이상 매치되는 경우, 현재 위치에 해당 블럭이 올 수 없으므로 큐에 보관하고 다음 블럭 처리하도록 continue한다
        if (vtDup.x > 2 || vtDup.y > 2)
        {
            m_UnusedBlocks.Enqueue(blockInfo);
            bUseQueue = m_bListComplete || !bUseQueue;

            continue;
        }

        //6. 블럭이 위치할 수 있는 경우, 찾은 위치로 Bock GameObject를 이동시킨다.
        block.vertDuplicate = vtDup.y;
        block.horzDuplicate = vtDup.x;
        if (block.blockObj != null)
        {
            float initX = m_Board.CalcInitX(Constants.BLOCK_ORG);
            float initY = m_Board.CalcInitY(Constants.BLOCK_ORG);
            block.Move(initX + nCol, initY + nRow);
        }

        //7. 찾은 블럭을 리턴한다.
        return block;
    }
}
BoardShuffler.cs
소스에 대한 설명은 주석에 모두 포함시키는 것이 이해하기 쉬울 것 같아서 별도의 설명은 추가하지 않았다.
위에서 설명한 알고리즘을 구현하는 코드로서 알고리즘과 함께 보여준 그림으로 로직을 이해하는 것이 좋을 것 같다

위의 로직을 코드만 보면서 머릿속으로 시뮬레이션 해보면서 알고리즘을 이해하는 것이 쉬운 일은 아니다.
고전적인 순서도로 표현하는 것은 별 도움이 되지 않으며, UML 시퀀스 다이어그램은 함수단위의 로직을 표현하는 것이 아닌 객체간의 주고 받는 흐름을 표현하기에 적합한 것으로 이 또한 적합하지 않다. 액티비티 다이어그램으로 표현이 순서도 보다는 풍부한 표현이 가능하지만 함수 단위의 로직에는 시퀀스 다이어그램과 같이 적합하지 않는 것 같다.
위에서 보여준 그림이 이해하는데 도움이 되었으면 합니다.(스크롤의 압박으로 한번도 첨부하겠습니다^^)

나머지 함수들...

GetShuffledBlock()에서 사용하는 함수를 아래에 추가한다.

Constants 클래스

먼저, 프로젝트에서 사용할 상수(Constant)를 정의하는 static 클래스를 정의한다.
namespace Ninez.Core
{
    public static class Constants
    {
        public static float BLOCK_ORG = 0.5f;   //블럭의 출력 원점
    }
}
Core/Constants.cs
앞으로 진행하면서 사용되는 상수는 모두 여기에 선언될 것이다.

NextBlock 메소드

BlockVectorKV NextBlock(bool bUseQueue)
{
    if (bUseQueue && m_UnusedBlocks.Count > 0)
        return m_UnusedBlocks.Dequeue();

    if (!m_bListComplete && m_it.MoveNext())
        return m_it.Current.Value;

    m_bListComplete = true;

    return new BlockVectorKV(null, Vector2Int.zero);
}
BoardShuffler.cs
셔플에 사용할 블럭을 요청하는 메소드로서 파라미터(bUseQueue)에 따라서 Queue 또는 SortedList에서 블럭을 꺼내서 리턴한다.
1 bUseQueue는 블럭을 어디서 가져올지 지정한다.
true이면 Queue에서 블럭을 꺼내서(Dequeue) 리턴하고, false이면 List에서 블럭을 조회해서 리턴한다.
3 4 Queue에서 블럭을 리턴한다.
6, 7 SortedList에서 블럭을 리턴한다.
9 SortedList에서 꺼낼 블럭이 더 이상 없을 때 호출되는 코드이다. 리스트에 더이상 꺼낼 블럭이 없음을 플래그로 설정한다.
11 리스트에 블럭이 없는 경우 빈블럭을 리턴한다.

CalcDuplications 메소드

셔플 과정에서 이미 새로 배치 완료된 블럭과 현재 배치할 블럭 간의 연속배치 개수를 계산하는 메소드를 추가한다.
Vector2Int CalcDuplications(int nRow, int nCol, Block block)
{
    int colDup = 1, rowDup = 1;

    if (nCol > 0 && m_Board.blocks[nRow, nCol - 1].IsSafeEqual(block))
        colDup += m_Board.blocks[nRow, nCol - 1].horzDuplicate;

    if (nRow > 0 && m_Board.blocks[nRow - 1, nCol].IsSafeEqual(block))
        rowDup += m_Board.blocks[nRow - 1, nCol].vertDuplicate;

    if (nCol < m_Board.maxCol - 1 && m_Board.blocks[nRow, nCol + 1].IsSafeEqual(block))
    {
        Block rightBlock = m_Board.blocks[nRow, nCol + 1];
        colDup += rightBlock.horzDuplicate;

        //셔플 미대상블럭이 현재 블럭과 중복되는 경우, 셔플미대상 블럭의 중복 정보도 함께 업데이트한다
        if (rightBlock.horzDuplicate == 1)
            rightBlock.horzDuplicate = 2;
    }

    if (nRow < m_Board.maxRow - 1 && m_Board.blocks[nRow + 1, nCol].IsSafeEqual(block))
    {
        Block upperBlock = m_Board.blocks[nRow + 1, nCol];
        rowDup += upperBlock.vertDuplicate;

        //셔플 미대상블럭이 현재 블럭과 중복되는 경우, 셔플미대상 블럭의 중복 정보도 함께 업데이트한다
        if (upperBlock.vertDuplicate == 1)
            upperBlock.vertDuplicate = 2;
    }

    return new Vector2Int(colDup, rowDup);
}
BoardShuffler.cs
1 메소드를 정의한다.
block : 새로 배치할 블럭
3 중복개수의 기본값을 1로 설정한다. 주변에 연속 배치되는 블럭이 없는 경우가 '1'에 해당된다.
5, 6 왼쪽 블럭과 같은 블럭 즉, Breed가 같으면 colDup을 '1' 증가시킨다.
8, 9 아래 블럭과 같은 블럭 즉, Breed가 같으면 rowDup을 '1' 증가시킨다.
11, 19 오른쪽 블럭과 같은 블럭 즉, Breed가 같으면 colDup을 '1' 증가시킨다.
새로운 블럭으로 채워가는 과정이기 때문에 오른쪽 블럭은 비교대상이 아닐 것 같지만, 이동하지 않지만 블럭을 가지고 있는 셔플 미대상 블럭이 위치할 수 있기 때문에 함께 고려되어야 한다.
21, 29 위쪽 블럭과 같은 블럭 즉, Breed가 같으면 rowDup을 '1' 증가시킨다.
새로운 블럭으로 채워가는 과정이기 때문에 위쪽 블럭은 비교대상이 아닐 것 같지만, 이동하지 않지만 블럭을 가지고 있는 셔플 미대상 블럭이 위치할 수 있기 때문에 함께 고려되어야 한다.
31 계산된 결과를 리턴한다.

실행하기

실행하기 전에 먼저 빈 블럭이 없는 스테이지 파일로 먼저 테스트해 보자. stage_0001을 아래와 같이 수정한다.
{
    "row":9,
    "col":9,
    "cells":[
                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,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,1,1,1,
                1,1,1,1,1,1,1,1,1,
                1,1,1,1,1,1,1,1,1
             ]
}
stage_0001.txt

플레이버튼(▶)을 클릭해서 플레이 결과를 확인해 보자.
아래와 같이 3개이상 연속되는 블럭이 없는 보드를 볼 수 있다. 스테이지를 변경하면서 실행해보아도 3개이상 연속되는 블럭이 없는 것을 확인할 수 있다.

다음 장에서는 터치/마우스 이벤트를 처리하여 플레이하는 과정을 시작하겠습니다.

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

댓글 3개:

  1. 와 이번편 진짜... 어렵다 ㅠㅠ 이걸 로직 짜신것도 대단하시다..

    답글삭제
  2. 저 혹시 게임진행중에 더이상 어떤한경우에도 match가 없는 상황이 나오면 어떡하나요??

    답글삭제