2019년 11월 25일 월요일

9. 떨어지는 블럭 구현하기

이번 장에서는 3매칭으로 제거된 블럭의 빈자리를 상단에 위치한 블럭으로 채워가는 과정을 구현할 것이다.

실행 화면은 다음과 같다.
제거된 블럭 위에 있는 블럭이 에니메이션과 함께 떨어지는 것을 볼 수 있다.


소스 코드 다운로드

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

이번 장에서 작업할 내용은 다음과 같다.
  • 빈 블럭 떨어뜨리기
  • 블럭 액션 전담 클래스(BlockActionBehaviour) 추가
  • BlockPrefab에 컴포넌트 추가

스와이프로 시작된 코루틴 안에서 실행되는 로직으로 전체 흐름을 시퀀스 다이어그램으로 표현하면 아래와 같다.(시퀀스 15 ~ 20)


후처리 Enumerator 실행하기

3매칭 블럭이 제거되면 빈자리 위쪽에 위치한 블럭을 아래로 떨어뜨리는(이하 드롭, Drop) 과정이 필요하다.
시퀀스 다이어그램에서 시퀀스-10 번 실행으로 3매칭된 블럭이 제거되고, 후처리(Postprocess) 작업으로 블럭을 드롭하고 빈 블럭 위치에 새로운 블럭을 생성(Spawn)하는 과정이 필요하다.

다음과 같이 ActionManger의 스와이프 코루틴 안에서 PostprocessAfterEvaluate() Enumerator를 실행한다.
IEnumerator EvaluateBoard(Returnable matchResult)
{
    //1.매치 블럭 제거  
    yield return m_Stage.Evaluate(matchResult);

    // 매칭 블럭 제거 후 빈블럭 드롭 및 새 블럭 생성을 처리하는 후처리를 수행한다.
    if (matchResult.value)
    {
        yield return m_Stage.PostprocessAfterEvaluate(); 
    }
}
ActionManager.cs
7 3매치 블럭이 있는 경우
9 Stage 객체에게 후처리를 요청한다.

스와이프 후처리 구현하기

3매치 블럭 제거 후 호출되는 Enumerator로서 상위 블럭을 비어있는 하위 블럭 위치로 드롭시키고, 새로운 블럭을 생성(Spawn)시키는 후처리를 구현한다.(블럭 생성은 다음 장에 추가한다)
/*
 * 매칭된 블럭을 제거한 후의 후처리 로직을 담당한다
 * 빈 블럭에 상위 블럭을 Drop해서 채운 후에 새로운 블럭으로 빈자리를 채운다        
 */
public IEnumerator PostprocessAfterEvaluate()
{
    List<KeyValuePair<int, int>> unfilledBlocks = new List<KeyValuePair<int, int>>();
    List<Block> movingBlocks = new List<Block>();

    //1. 제거된 블럭에 따라, 블럭 재배치(상위 -> 하위 이동/애니메이션)
    yield return m_Board.ArrangeBlocksAfterClean(unfilledBlocks, movingBlocks);

    //2. 유저에게 생성된 블럭이 잠시동안 보이도록 다른 블럭이 드롭되는 동안 대기한다.
    yield return WaitForDropping(movingBlocks);
}
Stage.cs
블럭 재배치를 수행하는 Board 객체의 ArrangeBlocksAfterClean() Enumerator를 실행한다.
5 Enumerator 후처리 함수를 정의한다.
7 블럭 드롭이 완료 되었지만 다른 블럭으로 채워지지 않고 남겨진 블럭 위치를 수집하기 위해 List를 생성한다.
장애물이 있는 경우 하위에 채워지지 않는 빈 블럭이 발생할 수 있다.(아래 그림 참고)
8 드롭되는 블럭을 보관하는 List를 생성한다.(드롭 모니터링에 필요)
11 Board 객체에 빈 블럭을 채우도록 요청한다.
14 모든 블럭의 드롭이 완료될 때까지 대기한다.

드롭 완료 대기하기

리스트에 포함된 블럭의 애니메이션이 끝날때 까지 즉, 드롭이 끝날때 까지 대기하는 Enumerator를 구현한다
/*
 * 리스트에 포함된 블럭의 애니메이션이 끝날때 까지 기다린다.
 */
public IEnumerator WaitForDropping(LFist movingBlocks)
{
    WaitForSeconds waitForSecond = new WaitForSeconds(0.05f); //50ms 마다 검사한다.

    while (true)
    {
        bool bContinue = false;

        // 이동 중인 블럭이 있는지 검사하다.
        for (int i = 0; i < movingBlocks.Count; i++)
        {
            if (movingBlocks[i].isMoving)
            {
                bContinue = true;
                break;
            }
        }

        if (!bContinue)
            break;

        yield return waitForSecond;
    }

    movingBlocks.Clear();
    yield break;
}
Stage.cs
6 모니터링 주기를 선언한다. 50ms 마다 검사한다.
8 - 26 애니메이션 중인 블럭이 없을 때까지 무한루프.
13 - 20 모니터링 주기마다 모든 블럭을 검사한다. 이동중인 블럭이 있으며 for 문 종료.
22, 23 이동 중인 블럭이 없으면 즉, 모든 블럭 애니메이션이 종료된 경우 while 문을 종료한다.
28 이동중인 블럭 리스트를 초기화한다.

빈 자리로 블럭 떨어뜨리기

Board 클래스에서 블럭의 빈자리를 채우는 Enumerator를 구현한다.
using IntIntKV = KeyValuePair<int, int>;

/*
 * 전체 블럭 구성을 재배치한다.
 * 비어있는 블럭을 위에 있는 블럭으로 채운다.
 * - MATCH 블럭이 제거된 후에 호출된다.
 * 
 * @param unfilledBlocks 다른 블럭으로 채워지지 않고 남겨진 블럭 위치를 리턴받기 위해서 Caller에서 전달한다.  
 */
public IEnumerator ArrangeBlocksAfterClean(List<IntIntKV> unfilledBlocks, List<Block> movingBlocks)
{
    SortedList<int, int> emptyBlocks = new SortedList<int, int>();
    List<IntIntKV> emptyRemainBlocks = new List<IntIntKV>();

    for (int nCol = 0; nCol < m_nCol; nCol++)
    {
        emptyBlocks.Clear();

        //1.같은 열(col)에 빈 블럭을 수집한다.
        //  현재 col의 다른 row의 비어있는 븝럭 인덱스를 수집한다. sortedList이므로 첫번째 노드가 가장 아래쪽 블럭 위치이다
        for (int nRow = 0; nRow < m_nRow; nRow++)
        {
            if (CanBlockBeAllocatable(nRow, nCol))
                emptyBlocks.Add(nRow, nRow);
        }

        //아래쪽에 비었는 블럭이 없는 경 
        if (emptyBlocks.Count == 0)
            continue;

        //2. 이동이 가능한 블럭을 비어있는 하단 위치로 이동한다.

        //2.1 가장 아래쪽부터 비어있는 블럭을 처리한다
        IntIntKV first = emptyBlocks.First();

        //2.2 비어있는 블럭 위쪽 방향으로 이동 가능한 블럭을 탐색하면서 빈 블럭을 채워나간다
        for (int nRow = first.Value + 1; nRow < m_nRow; nRow++)
        {
            Block block = m_Blocks[nRow, nCol];

            //2.2.1 이동 가능한 아이템이 아닌 경우 pass
            if (block == null || m_Cells[nRow, nCol].type == CellType.EMPTY) //TODO EMPTY를 직접체크하지 않고 이러한 부류를 함수로 체크
                continue;

            //2.2.3 이동이 필요한 블럭 발견
            block.dropDistance = new Vector2(0, nRow - first.Value);    //GameObject 애니메이션 이동
            movingBlocks.Add(block);

            //2.2.4 빈 공간으로 이동
            Debug.Assert(m_Cells[first.Value, nCol].IsObstracle() == false, $"{m_Cells[first.Value, nCol]}");
            m_Blocks[first.Value, nCol] = block;        // 이동될 위치로 Board에서 저장된 위치 이동

            //2.2.5 다른 곳으로 이동했으므로 현재 위치는 비워둔다
            m_Blocks[nRow, nCol] = null;

            //2.2.6 비어있는 블럭 리스트에서 사용된 첫번째 노드(first)를 삭제한다
            emptyBlocks.RemoveAt(0);

            //2.2.7 현재 위치의 블럭이 다른 위치로 이동했으므로 현재 위치가 비어있게 된다.
            //그러므로 비어있는 블럭을 보관하는 emptyBolocks에 추가한다
            emptyBlocks.Add(nRow, nRow);

            //2.2.8 다음(Next) 비어었는 블럭을 처리하도록 기준을 변경한다
            first = emptyBlocks.First();
            nRow = first.Value; //Note : 빈곳 바로 위부터 처리하도록 위치 조정, for 문에서 nRow++ 하기 때문에 +1을 하지 않는다
        }
    }

    yield return null;

    //드롭으로 채워지지 않는 블럭이 있는 경우(왼쪽 아래 순으로 들어있음)
    if (emptyRemainBlocks.Count > 0)
    {
        unfilledBlocks.AddRange(emptyRemainBlocks);
    }

    yield break;
}
Board.cs
1 Generic Type Alias를 선언한다. 이제부터 KeyValuePair<int, int> 대신 IntIntKV를 사용한다.
10 Enumerator 함수 선언.
unfilledBlocks : 빈 블럭으로 남는 위치를 저장할 리스트 객체. Caller에서 전달 받는다.
movingBlocks : 이동이 필요한 블럭 리스트를 저장할 리스트 객체. Caller에서 전달 받는다.
12 비어있는 블럭 위치를 저장할 리스트.
13 드롭 이후에도 빈 채로 남아있는 블럭을 임시 보관하는 리스트
15 첫번째 열(col)부터 마지막 열(col) 순으로 처리한다.
위에서 아래로 떨어지는 블럭을 처리하는 것이기 때문에 처리 기준을 열(col)로 한다.
17 새로운 열(column)을 처리하는 경우 빈블럭 리스트를 초기화 한다.
21 - 25 같은 열(col)에 존재하는 빈 블럭을 emptyBlocks 리스트에 보관한다.
28, 29 처리중인 열(col)에 빈 블럭이 없으면 다음 열(col)을 처리하도록 continue 문 사용한다.
34 - 66 비어있는 블럭 위쪽 방향에 이동가능한 블럭을 탐색하면서 빈 블럭 위치로 떨어뜨린다.
빈 블럭이 모두 없어질때까지 반복한다.
자세한 설명은 소스에 주석으로 표기했다.
46 드롭되는 Block 객체의 드롭거리(dropDistance) 속성을 설정한다.
dropDistance 속성을 설정하면 Block GameObject에게 이동 애니메이션이 적용된다.
72 - 75 채워지지 않은 빈블록 목록을 저장한다.

Board 클래스에 조회함수 추가하기

지정된 위치에 블럭이 새로 할당될 수 있는지 체크하는 함수를 추가한다. 지정된 위치가 드롭이 가능한지 검사할 때 사용한다.
bool CanBlockBeAllocatable(int nRow, int nCol)
{
    if (!m_Cells[nRow, nCol].type.IsBlockAllocatableType())
        return false;

    return m_Blocks[nRow, nCol] == null;
}
Board.cs
3, 4 블럭이 할당될 수 없는 Cell 이면 false를 리턴한다.
6 드롭 가능하려면 비어있는 블럭이어야 한다.

SortedList 확장 메소드 추가하기

SortedList에서 첫번째 노드를 조회하는 확장 메소드를 추가한다. (위치 : Utils/Useful.cs)
using System.Collections.Generic;

namespace Ninez.Util
{
    public static class SortedListMethods
    {
        /**
         * SortedList 확장 메소드.
         * 첫번째 노드의 key-value 를 구한다.
         * (주의) 비어있는 경우 T1, T2 타입의 디폴값이 전달되므로 호출전에 비어있는지 체크하는 것이 안정한다.
         * 
         * 사용 예) KeyValuePair<int, Vector2) kv = sortedList.First();
         */
        public static KeyValuePair<T1, T2> First<T1, T2>(this SortedList<T1, T2> sortedList)
        {
            if (sortedList.Count == 0)
                return new KeyValuePair<T1, T2>();

            return new KeyValuePair<T1, T2>(sortedList.Keys[0], sortedList.Values[0]);
        }
    }
}
Utils/Useful.cs
SortedList에 저장된 첫번째 노드를 조회하는데 사용될 확장 메소드를 추가한다.사용 방법은 아래와 같다.
KeyValuePair<int, Vector2) kv = sortedList.First()
kv.value, kv.key

* 프로젝트 설정을 닷넷 4.0 지원으로 변경하면 필요하지 않는 확장 메소드이다.
* Useful.cs 파일은 개별 파일로 추가 하기에는 작은 유틸리티 성격의 코드를 추가하는 용도로 사용한다.(소스 파일 개수 줄이는 용도)

Block 속성 추가하기

다음과 같이 이동에 필요한 속성을 Block 클래스에 추가한다.
public class Block
{
     //  -- 중 략 --
    BlockActionBehaviour m_BlockActionBehaviour;

    public bool isMoving
    {
        get
        {
            return blockObj != null && m_BlockActionBehaviour.isMoving;
        }
    }

    public Vector2 dropDistance 
    {
        set 
        {  
            m_BlockActionBehaviour?.MoveDrop(value); 
        }
    }
}
Block.cs
4 블럭의 애니메이션을 전담할 BlockActionBehaviour 컴포넌트에 대한 참조 멤버를 선언한다.
6 - 12 블럭이 애니메이션 중인지 검사하는 속성
14 - 20 Block GameObject가 주어진 위치로 이동하도록 요청하는 속성

BlockActionBehaviour를 Block GameObject 생성시에 참조하는 코드를 추가한다.
Block InstantiateBlockObj(GameObject blockPrefab, Transform containerObj)
{
    //  -- 중 략 --
    this.blockBehaviour = newObj.transform.GetComponent<BlockBehaviour>();
    m_BlockActionBehaviour = newObj.transform.GetComponent<BlockActionBehaviour>();

    return this;
}
Block.cs
5 Block Game Object 생성 시에 BlockActionBehaviour 컴포넌트에 대한 참조를 설정한다.

BlockActionBehaviour 추가하기

블럭의 액션을 전담하는 MonoBehaviour 클래스를 추가한다. (위치 : Board/Blocks/BlockActionBehaviour.cs)
using System.Collections;
using System.Collections.Generic;
using Ninez.Scriptable;
using Ninez.Util;
using UnityEngine;

namespace Ninez.Board
{
    public class BlockActionBehaviour : MonoBehaviour
    {
        public bool isMoving { get; set; }

 Queue<Vector3> m_MovementQueue = new Queue<Vector3>(); //x=col, y=row, z = acceleration

 /*
         * 아래쪽으로 주어진 거리만큼 이동한다.
         * fDropDistance : 이동할 스텝 수 즉, 거리 (unit)   
         */
 public void MoveDrop(Vector2 vtDropDistance)
 {
     m_MovementQueue.Enqueue(new Vector3(vtDropDistance.x, vtDropDistance.y, 1));

     if (!isMoving)
     {
                StartCoroutine(DoActionMoveDrop());
            }
 }

        IEnumerator DoActionMoveDrop(float acc = 1.0f)
        {
            isMoving = true;

            while (m_MovementQueue.Count > 0)
            {
                Vector2 vtDestination = m_MovementQueue.Dequeue();

                float duration = Core.Constants.DROP_TIME;
                yield return CoStartDropSmooth(vtDestination, duration * acc);
            }

            isMoving = false;
            yield break;
        }

        IEnumerator CoStartDropSmooth(Vector2 vtDropDistance, float duration)
        {
            Vector3 to = new Vector3(transform.position.x + vtDropDistance.x, transform.position.y - vtDropDistance.y, transform.position.z);
            yield return Action2D.MoveTo(transform, to, duration);
        }
    }
}
Blocks/BlockActionBehaviour.cs
11 이동 상태 속성을 정의한다.
13 이동할 위치를 저장하는 큐(Queue)를 생성한다.
하나의 블럭에 대해서 이동 요청이 여러 번 올 수 있다.(장애물이 있는 드롭 시에 발생한다. 뒤에서 다룰 것이다)
큐에 이동할 위치가 쌓이고, 큐에 쌓이는 순서대로 블럭 이동이 적용된다.
19 - 27 주어진 거리 만큼 이동을 요청한다.
이동을 즉시 실행하지 않고 큐에 이동할 위치를 보관한다. 이동을 수행하는 코루틴이 없는 경우 코루틴을 시작한다.
다음 프레임에서 큐에서 이동 정보를 꺼내어 이동 애니메이션을 수행할 것이다.
29 - 43 드롭 이동 애니메이션을 수행하는 Enumerator를 정의한다.
큐에 이동정보가 있으면 꺼내서 이동을 적용한다. 큐에 있는 모든 정보를 처리할 때까지 수행한다.
45 - 49 드롭 애니메이션을 수행하는 Enumerator를 정의한다.
이동 위치를 계산하여 MoveTo 액션을 수행한다.

Constant 추가

public static class Constants
{
    public static float BLOCK_ORG = 0.5f;       //블럭의 출력 원점
    public static float SWIPE_DURATION = 0.2f;  //블럭 스와이프 애니메이션 시간
    public static float DROP_TIME = 0.3f;       //블럭이 떨어지는 시간
}
Core/Constants.cs

BlockPrefab에 BlockActionBehaviour 컴포넌트 추가하기

블럭 드롭 액션을 전담하는 BlockActionBehaviour를 BlockPrefab에 추가한다.
  1. Project 탭에서 Block Prefab을 선택한다.
  2. Inspector에서 [Open Prefab]을 선택한다.
  3. Scripts/Board/Blocks/BlockActionBehaviour를 Inspector로 드래그해서 Block GameObject의 컴포넌트로 등록한다.


추가된 BlockActionBehaviour를 추가한 후에 클래스 다이어그램은 다음과 같다.(GameObject와 MonoBehaviour 위주로 표기하였다)

실행하기

플레이 버튼을 클릭해서 실행 결과를 확인해보자. 제거된 빈 블럭 자리에 위치한 블럭이 아래로 떨어지는 것을 볼 수 있다.

플레이 화면을 살펴보면 아래와 같이 3매치된 블럭이 제거되지 않고 남아있는 상태를 볼 수 있다.
이러한 현상은 드롭으로 인하여 빈 자리가 채워진 후에 나타나며, 드롭이 된 후에 추가로 매칭된 블럭이 있는지 다시 검사하지 않기 때문이다. 즉, 3매칭 블럭 제거 작업을 한번만 수행하기 때문이다.

계속해서, 드롭된 상태의 보드에 게임규칙을 다시 적용해서(Evaluate) 3매치된 블럭을 제거하도록 구현해 보자.

드롭 이후 게임규칙 반복 적용하기

ActionManager의 EvaluateBoard()를 아래와 같이 수정한다.
블럭 제거와 드롭을 한번만 수행하던 작업을 3매치 블럭이 더이상 발생되지 않을 때까지 반복한다.
기존 코드를 while 문으로 반복 실행하도록 수정한다.
IEnumerator EvaluateBoard(Returnable<bool> matchResult)
{
    bool bFirst = true;

    while (true)    //매칭된 블럭이 있는 경우 반복 수행한다.
    {
        //1. 매치 블럭 제거
        Returnable<bool> bBlockMatched = new Returnable<bool>(false);
        yield return StartCoroutine(m_Stage.Evaluate(bBlockMatched));

        //2. 3매치 블럭이 있는 경우 후처리 싱행 (블럭 드롭 등)
        if (bBlockMatched.value)
        {
            matchResult.value = true;

            // 매칭 블럭 제거 후 빈블럭 드롭 후 새 블럭 생성
            yield return StartCoroutine(m_Stage.PostprocessAfterEvaluate());
        }
        //3. 3매치 블럭이 없는 경우 while 문 종료
        else
            break;  
    }

    yield break;
}
ActionManager.cs
5 - 22 매칭 블럭이 있으면 반복 실행한다.
9 3매치 블럭을 제거한다.
12 - 19 매칭 블럭이 있는 경우, 리턴 결과를 저장하고 후처리를 수행한다.
21, 22 매칭 블럭이 없는 경우, while 문을 중단한다.

위의 시퀀스 다이어그램에서 10 ~ 20 과정이 while 문으로 반복되는 구조이다. 요악하면 아래와 같다.


실행하기

플레이버튼(▶)을 클릭해서 플레이 결과를 확인해보자. 시작 부분을 살펴보면 3매치 블럭이 제거되고 드롭 이후에 발생된 3매치 블럭이 제거되는 것을 볼 수 있다.

다음 장에서는 떨어지는 블럭의 거리에 따른 속도를 지정하고, 매칭 효과로 사운드를 추가하고 블럭이 제거될 때 폭발하는 효과를 적용해보자.

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

댓글 없음:

댓글 쓰기