2019년 11월 25일 월요일

7. 블럭 스와이프 구현하기

이번 장에서는 스와이프 대상 블럭을 애니메이션과 함께 위치를 이동하는 동작을 구현할 것이다.
실행화면은 다음과 같다.

소스 코드 다운로드

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

이번 장에서 작업할 내용은 다음과 같다.
  • 코루틴 이용하기
  • 2D 이동 애니메이션 구현하기

아래와 같은 클래스들이 추가될 것이다.


진행하게될 전체 과정을 간략히 시퀀스 다이어그램으로 표현하면 아래와 같다.
전체 흐름을 먼저 살펴보면 앞으로 추가될 코드를 좀 더 쉽게 이해할 수 있을 것 같다.


  1. 매 프레임마다 StageController의 Update() 이벤트가 호출된다.
  2. 이벤트 핸들러 함수 OnInputHandler()를 호출한다.
  3. InputHandler에게 스와이프 정보를 요청한다.
  4. ActionManager에게 스와이프 액션 실행을 요청한다.
  5. ActionManager는 스와이프 액션을 수행하는 코루틴을 실행한다.
  6. ActionManager의 코루틴에서 Stage 객체의 코루틴을 실행한다.
  7. Stage 객체의 코루틴에서 Block 객체에 이동을 요청한다.
  8. Block 객체는 액션 전담 Action2D의 MoveTo 코루틴을 실행한다.
시퀀스 3번까지는 이전 장에서 구현하였고, 이번 장에서는 시퀀스 4 ~ 8을 구현할 것이다.

StageController에서 스와이프 액션 적용하기

StageController 클래스는 플레이 진행을 총괄하는 역할이다.
StageController는 직접 특정 작업을 하지는 않는다. 그는 슈퍼바이저이고 관리자이다. 세부적인 일을 직접 처리하지 않고 항상 해당 일에 적합한 대상에게 위임한다. 블럭의 스와이프 액션 또한 마찬가지이다.

스와이프 액션을 위해 StageController가 하는 일은 새로 고용된 ActionManager에게 스와이프 작업을 실행하도록 지시하는 것 정도이다.
아래 코드를 StageController에 추가한다.(highlight 표시)
public class StageController : MonoBehaviour
{
        -- 중 략 --
    InputManager m_InputManager;
    ActionManager m_ActionManager;

    void BuildStage()
    {
        //1. Stage를 구성한다.
        m_Stage = StageBuilder.BuildStage(nStage : 2);
        m_ActionManager = new ActionManager(m_Container, m_Stage);

        //2. 생성한 stage 정보를 이용하여 씬을 구성한.
        m_Stage.ComposeStage(m_CellPrefab, m_BlockPrefab, m_Container);
    }

    void OnInputHandler()
    {
        //1. Touch Down 
        if (!m_bTouchDown && m_InputManager.isTouchDown)
        {
            //1.1 보드 기준 Local 좌표를 구한다.
            Vector2 point = m_InputManager.touch2BoardPosition;

            //1.2 Play 영역(보드)에서 클릭하지 않는 경우는 무시
            if (!m_Stage.IsInsideBoard(point))
                return;

            //1.3 클릭한 위치이 블럭을 구한다.
            BlockPos blockPos;
            if (m_Stage.IsOnValideBlock(point, out blockPos))
            {
                //1.3.1 유효한(스와이프 가능한) 블럭에서 클릭한 경우
                m_bTouchDown = true;        //클릭 상태 플래그 ON
                m_BlockDownPos = blockPos;  //클릭한 블럭의 위치(row, col) 저장
                m_ClickPos = point;         //클릭한 Local 좌표 저장
                //Debug.Log($"Mouse Down In Board : (blockPos})");
            }
        }
        //2. Touch UP : 유효한 블럭 위에서 Down 후에만 UP 이벤트 처리
        else if (m_bTouchDown && m_InputManager.isTouchUp)
        {
            //2.1 보드 기준 Local 좌표를 구한다.
            Vector2 point = m_InputManager.touch2BoardPosition;

            //2.2 스와이프 방향을 구한다.
            Swipe swipeDir = m_InputManager.EvalSwipeDir(m_ClickPos, point);

            Debug.Log($"Swipe : {swipeDir} , Block = {m_BlockDownPos}");

            if (swipeDir != Swipe.NA)
                m_ActionManager.DoSwipeAction(m_BlockDownPos.row, m_BlockDownPos.col, swipeDir);

            m_bTouchDown = false;   //클릭 상태 플래그 OFF
        }
    }
}
StageController.cs
5 ActionManager 객체를 선언한다.
11 스테이지 구성단계에서 ActionManager 객체를 생성한다.
컨테이너(Board GameObject)와 Stage 객체 정보를 인자로 전달한다.
51, 52 ActionManager에게 스와이프 액션을 요청한다.
ActionManger 객체에게 스와이프 액션을 수행하도록 요청하는 것이 전부이다.

ActionManager 추가하기

ActionManager는 코루틴을 이용한 플레이 액션을 총괄한다.
위의 클래스 다이어그램을 살펴보면 ActionManager는 Stage 객체 만을 참조(⬦⎯⎯)하고 있는 것을 볼 수 있다. Block 또는 Cell 객체 등을 사용(Dependency 관계로 표기)하거나 참조하지 않는다.

ActionManage는 액션의 흐름을 관리하고 실제 액션은 액션의 대상에게 위임한다. 예를 들어, 블럭을 이동하는 액션이 필요한 경우에 블럭 레퍼런스를 직접 구해서 액션을 실행하지 않고, 블럭을 관리하고 있는 대상에게 액션을 요청하는 식이다. ActionManager는 블럭이라는 존재가 있는지 조차 알지 못한다. 좀 더 정확히 표현하자면, 블럭이 있는지 관심조차 없다. 단지 액션이 필요하다는 것을 인지하면(요청을 받으면) 그 대상에게 필요한 액션을 수행하라고 요청할 뿐이다. 그 대상이 어떻게 액션을 수행하는지도 물론 관심없다. 관심있는 것은 오직 요청한 액션이 종료 되었는지 뿐이다.
using System.Collections;
using Ninez.Util;
using UnityEngine;

namespace Ninez.Stage
{
    public class ActionManager 
    {
        Transform m_Container;          //컨테이저 (Board GameObject)
        Stage m_Stage;                  
        MonoBehaviour m_MonoBehaviour;  //코루틴 호출시 필요한 MonoBehaviour
        bool m_bRunning;                //액션 실행 상태 : 실행중인 경우 true

        public ActionManager(Transform container, Stage stage)
        {
            m_Container = container;
            m_Stage = stage;

            m_MonoBehaviour = container.gameObject.GetComponent<MonoBehaviour>();
        }

        /*
         * 코루틴 Wapper 메소드   
         */
        public Coroutine StartCoroutine(IEnumerator routine)
        {
            return m_MonoBehaviour.StartCoroutine(routine);
        }

        /*
         * 스와이프를 액션을 시작한다.
         * @param nRow, nCol 블럭 위치
         * @swipeDir 스와이프 방향
         */
        public void DoSwipeAction(int nRow, int nCol, Swipe swipeDir)
        {
            Debug.Assert(nRow >= 0 && nRow < m_Stage.maxRow && nCol >= 0 && nCol < m_Stage.maxCol);

            if (m_Stage.IsValideSwipe(nRow, nCol, swipeDir))
            {
                StartCoroutine(CoDoSwipeAction(nRow, nCol, swipeDir));
            }
        }

        /*
         * 스와이프 액션을 수행하는 코루틴
         */
        IEnumerator CoDoSwipeAction(int nRow, int nCol, Swipe swipeDir)
        {
            if (!m_bRunning)  //다른 액션이 수행 중이면 PASS
            {
                m_bRunning = true;    //액션 실행 상태 ON

                //1. swipe action 수행
                Returnable<bool> bSwipedBlock = new Returnable<bool>(false);
                yield return m_Stage.CoDoSwipeAction(nRow, nCol, swipeDir, bSwipedBlock);

                m_bRunning = false;  //액션 실행 상태 OFF
            }
            yield break;
        }
    }
}
ActionManager.cs
7 ActionManager 클래스를 선언한다.
9, 10 컨테이너 GameObject와 Stage 객체를 참조한다(⬦⎯⎯).
11 코루틴 호출시 필요한 MonoBehaviour. 컨테이너에서 구한다.(19 line)
12 액션의 실행 상태 플래그. 액션이 실행중이면 true, 종료되었으면 false
14 - 20 생성자. 파라미터로 컨테이너(Board)와 Stage 객체를 전달 받는다.
19 컨테이너로부터 MonoBehaviour 컴포넌트를 구한다.
25 - 28 코루틴을 수행하는 StartCoroutine()의 Wrapper 메소드.
35 - 43 스와이프 액션 수행을 요청 받는 메소드로서 코루틴을 실행한다
48 - 61 스와이프 액션을 수행하는 코루틴.
Stage 객체에게 스와이프 액션을 위임하고, Stage 객체에게 위임한 액션이 종료될 때까지 기다린다.
50 다른 액션이 실행중인 경우에는 수행하지 않는다.
52 액션 상태 플래그 ON
55 코루틴 실행 결과를 전달받을 Returnable 객체를 생성한다.
코루틴은 IEnumerator를 리턴할 뿐 코루틴 수행 결과값을 리턴해주지 않는다. 그래서 Returanable 객체를 인자로 전달한다.
56 Stage 객체의 코루틴 CoDoSwipeAction()을 실행하고, 코루틴이 종료될 때까지 기다린다.
58 액션 상태 플래그 OFF

Stage에서 스와이프 액션 수행하기

스와이프의 대상은 블럭이다. 블럭 정보는 보드가 관리하고 있으며 보드에 대한 명령은 Stage가 총괄하고 있다. 이러한 이유로 보드를 총괄하는 Stage 객체에게 스와이프 액션을 요청할 것이다.모든 블럭 정보를 알고 있는 Stage 객체가 직접 코루틴을 통해 블럭에게 액션을 명령할 것이다.
(Block을 직접 소유하고 있는 것은 Board 객체이기 때문에 Stage 객체 또한 Board 객체에게 스와이프 액션을 위함하는 것이 좀 더 나은 표현인 것이다. 이 부분은 진행하면서 좀 더 설명하기로 하겠다.)
using Ninez.Core;

public IEnumerator CoDoSwipeAction(int nRow, int nCol, Swipe swipeDir, Returnable<bool> actionResult)
    {
        actionResult.value = false; //코루틴 리턴값 RESET

        //1. 스와이프되는 상대 블럭 위치를 구한다. (using SwipeDir Extension Method)
        int nSwipeRow = nRow, nSwipeCol = nCol;
        nSwipeRow += swipeDir.GetTargetRow(); //Right : +1, LEFT : -1
        nSwipeCol += swipeDir.GetTargetCol(); //UP : +1, DOWN : -1

        Debug.Assert(nRow != nSwipeRow || nCol != nSwipeCol, "Invalid Swipe : ({nSwipeRow}, {nSwipeCol})");
        Debug.Assert(nSwipeRow >= 0 && nSwipeRow < maxRow && nSwipeCol >= 0 && nSwipeCol < maxCol, $"Swipe 타겟 블럭 인덱스 오류 = ({nSwipeRow}, {nSwipeCol}) ");

        //2. 스와이프 가능한 블럭인지 체크한다. (인덱스 Validation은 호출 전에 검증됨)
        if (m_Board.IsSwipeable(nSwipeRow, nSwipeCol))
        {
            //2.1 스와이프 대상 블럭(소스, 타겟)과 각 블럭의 이동전 위치를 저장한다.
            Block targetBlock = blocks[nSwipeRow, nSwipeCol];
            Block baseBlock = blocks[nRow, nCol];
            Debug.Assert(baseBlock != null && targetBlock != null);

            Vector3 basePos = baseBlock.blockObj.transform.position;
            Vector3 targetPos = targetBlock.blockObj.transform.position;

            //2.2 스와이프 액션을 실행한다.
            if (targetBlock.IsSwipeable(baseBlock))
            {
                //2.2.1 상대방의 블럭 위치로 이동하는 애니메이션을 수행한다
                baseBlock.MoveTo(targetPos, Constants.SWIPE_DURATION);
                targetBlock.MoveTo(basePos, Constants.SWIPE_DURATION);

                yield return new WaitForSeconds(Constants.SWIPE_DURATION);

                //2.2.2 Board에 저장된 블럭의 위치를 교환한다
                blocks[nRow, nCol] = targetBlock;
                blocks[nSwipeRow, nSwipeCol] = baseBlock;

                actionResult.value = true;
            }
        }

        yield break;
    }
Stage.cs
5 코루틴 실행 결과를 전달하는 객체에 초기값으로 false를 설정한다.
8 - 10 스와이프할 대상 블럭의 위치(row, col)를 구한다.
Swipe enum 타입의 확장 메소드로 구현된 GetTargetRow()와 GetTargetCol()을 사용한다.
12, 13 블럭 위치 정보의 Validation을 체크한다.
16 - 41 스와이프 대상이 되는 두개의 블럭 정보를 구해서 스와이프 액션을 실행한다.
19 - 24 스와이프 대상 Block 객체와 위치 정보를 구한다.
30, 31 블럭에게 지정된 위치로 이동하도록 요청한다. 프레임마다 블럭이 이동하는 모습을 볼 수 있다.
이동을 요청받은 블럭은 코루틴을 생성해서 프레임마다 블럭의 위치를 변경할 것이다.
33 스와이프 액션이 실행되는 동안 대기한다.
36, 37 스와이프 액션이 종료되면 보드에 저장된 블럭의 위치를 서로 바꾼다.
최종적으로 스와이프 대상 2개의 블럭 위치가 변경된다.
39 액션 수행 결과로 true를 설정한다.

계속해서, 코루틴 CoDoSwipeAction()에서 사용하는 메소드들을 추가로 정의한다.

Stage 조회 메소드 추가

주어진 위치가 스와이프 액션이 유효한지 체크하는 메소드를 Stage 클래스에 추가한다.
public bool IsValideSwipe(int nRow, int nCol, Swipe swipeDir)
{
    switch (swipeDir)
    {
        case Swipe.DOWN: return nRow > 0; ;
        case Swipe.UP: return nRow < maxRow - 1;
        case Swipe.LEFT: return nCol > 0;
        case Swipe.RIGHT: return nCol < maxCol - 1;
        default:
            return false;
    }
}
Stage.cs
스와이프 진행 방향이 주어진 위치(row, col)에서 유효한지 체크한다.
위아래/좌우 경계를 벋어나는 스와이프 액션인지 체크한 결과를 리턴한다.

Swipe enum 확장 메소드

아래와 같이 Swipe enum 타입에 확장 메소드를 추가한다. Swipe enum 이 정의된 TouchEvaluator.cs에 코드를 추가한다. 스와이프 액션이 수행될 때 대상 블럭의 위치 offset를 구하는 역할을 수행한다.
namespace Ninez.Util
{
    public static class SwipeDirMethod
    {
        public static int GetTargetRow(this Swipe swipeDir)
        {
            switch (swipeDir)
            {
                case Swipe.DOWN: return -1; ;
                case Swipe.UP: return 1;
                default:
                    return 0;
            }
        }

        public static int GetTargetCol(this Swipe swipeDir)
        {
            switch (swipeDir)
            {
                case Swipe.LEFT: return -1; ;
                case Swipe.RIGHT: return 1;
                default:
                    return 0;
            }
        }
    }
}
TouchEvaluator.cs
3 확장 메소드 정의를 위해 static 클래스를 선언한다.
5 - 14 DOWN/UP 스와이프 액션에 대한 row offset를 리턴한다.
UP인 경우 +1, DOWN인 경우 -1
16 - 25 LEFT/RIGHT 스와이프 액션에 대한 col offset를 리턴한다.
RIGHT인 경우 +1, LEFT인 경우 -1

Constants 추가

스와이프 애니메이션 시간을 상수로 정의한다.
public static class Constants
{
    public static float BLOCK_ORG = 0.5f;       //블럭의 출력 원점
    public static float SWIPE_DURATION = 0.2f;  //블럭 스와이프 애니메이션 시간
}
Core/Constants.cs

Returnable 클래스 추가

코루틴의 결과를 수신하기 위한 범용 클래스를 정의한다.
namespace Ninez.Util
{
    public class Returnable<T>
    {
        public T value { get; set; }

        public Returnable(T value)
        {
            this.value = value;
        }
    }
}
Utils/Returnable.cs

Block 이동 액션 구현하기

Block 객체에 이동을 요청하는 메소드를 추가한다. 이동을 요청받은 블럭은 코루틴을 생성해서 Action2D 객체에게 이동 액션 수행을 위임한다.
        public void MoveTo(Vector3 to, float duration)
        {
            m_BlockBehaviour.StartCoroutine(Util.Action2D.MoveTo(blockObj, to, duration));
        }

        public bool IsSwipeable(Block baseBlock)
        {
            return true;
        }
Block.cs
1 - 4 블럭의 위치를 주어진 위치(to)로 정해진 시간동안에 이동하는 메소드.
코루틴을 생성해서 Actin2D 객체에 Move 애니메이션을 위임한다.
6 -9 swipe 가능한 블럭인지 체크한다.
baseBlock : 스와이프 기준 블럭, 기준블럭의 종류에 따라서 가능 여부가 달라진다. 확장성을 고려해서 제공하는 메소드로 현재는 조거없이 true를 리턴한다.

Action2D

GameObject의 2D 액션(Move, Scale, Rotate 등)을 구현하는 클래스로서 코루틴으로 호출하도록 IEnumerator를 리턴하는 메소드로 구현한다.(소스 위치 : Utils/Action2D.cs)
using System.Collections;
using UnityEngine;

namespace Ninez.Util
{
    public static class Action2D
    {
        /*
         * 지정된 시간동안 지정된 위치로 이동한다.
         * 
         * @param target 애니메이션을 적용할 타겟 GameObject
         * @param to 이동할 목표 위치
         * @param duration 이동 시간
         * @param bSelfRemove 애니메이션 종류 후 타겟 GameObject 삭제 여부 플래그
         */
        public static IEnumerator MoveTo(Transform target, Vector3 to, float duration, bool bSelfRemove = false)
        {
            Vector2 startPos = target.transform.position;

            float elapsed = 0.0f;
            while (elapsed < duration)
            {
                elapsed += Time.smoothDeltaTime;
                target.transform.position = Vector2.Lerp(startPos, to, elapsed / duration);

                yield return null;
            }

            target.transform.position = to;

            if (bSelfRemove)
                Object.Destroy(target.gameObject, 0.1f);

            yield break;
        }
    }
}
Utils/Action2D.cs
지정된 시간동안 지정된 위치로 GameObject를 이동시키는 MoveTo 애니메이션을 수행한다.
16 - 35 target : 이동시킬 대상 GameObject의 Transform
to : 이동할 목표 위치
duration : 이동 시간
bSeflRmove : 애니메이션 종료후 삭제 여부 플래그
18 시작 위치를 저장한다.
21 주어진 이동시간이 남아있는지 체크한다.
24 선형보간법(Vector2.Lerp API)를 이용해서 GameObject의 좌표(transform.position)을 이동시킨다.(아래 Note 참고)
경과시간을 전체 이동시간으로 나누어서 시간의 변화량(0 ~ 1.0)을 계산한다.
26 다음 프레임까지 실행을 지연한다.
31, 32 Self Destroy 모드인 경우, GameObject를 제거(Destroy)한다.

선형보간법(linear interpolation)

보간법이란 어떤 데이터에 나타나있지 않은 부분을 주어진 데이터들을 이용해서 추정하는 방법을 말한다. 직선위에 놓인 점P1과 점P2 사이의 점들에 대해서 시간의 변화에 따라서 변화된 점을 예측하는 것을 선형보간법이라고 한다.
유니티는 2차 평면에서 선형보간법을 구현한 Vector2.Lerp() API를 제공한다. public static Vector2 Lerp(Vector2 a, Vector2 b, float t); 세번째 파라미터(f)가 시간의 변화를 의미하여 0 ~ 1.0 의 값을 가지며, 0.5인 경우 중간값을 구할 수 있다.

도입 부분에서 제공한 클래스 다이어그램과 시퀀스 다이어그램을 다시 한번 살펴보자.
지금까지 진행한 과정을 좀 더 쉽게 이해할 수 있을 것이다.

실행하기

플레이버튼(▶) 클릭해서 실행한 후 블럭 위에서 마우스를 클릭해서 상하좌우로 스와이프 액션을 입력해보자. 비어있는 블럭방향이나 경계를 벋어난 스와이프는 동작하지 않는 것을 확인할 수 있다.

다음 장에서는 스와이프된 블럭이 3개 연속 배치되는 경우에 블럭을 제거하는 기능을 추가해 보겠습니다.

댓글 없음:

댓글 쓰기