2019년 11월 25일 월요일

6. 스와이프(Swipe) 이벤트 처리하기

이번 장에서는 마우스와 터치를 이용해서 두 개의 블럭 위치를 교환하는 스와이프(Swipe, 화면을 터치해서 미끄러지듯이 미는 동작) 이벤트를 처리하도록 하겠습니다.

플레이 화면은 아래와 같습니다.
블럭을 상하좌우로 스와이프하면 터치한 블럭 위치와 스와이프 동작을 Console에 출력하는 기능을 구현합니다.

소스 코드 다운로드

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

이번 장에서 작업할 내용은 다음과 같다.
  • 마우스/터치 통합 이벤트 매니저 추가 : InputManager
  • 스와이프 이벤트 처리
  • StageController에 스와이프 이벤트 적용

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


마우스/터치 통합 이벤트 처리

개발 과정에서는 마우스 이벤트를 사용하고, 모바일 앱에서는 터치 이벤트를 지원하도록 통합 InputManager를 도입해서 플레이어의 이벤트를 처리할 것이다.

추가될 클래스는 아래와 같다.

각 클래스는 아래와 같이 생성한다.
  1. Scripts/Utils 폴더 하위에 InputEvent 폴더를 생성한다.
  2. InputEvent 폴더 하위에 다음 파일을 추가할 것이다.
    • IInputHadlerBase.cs
    • InputManager.cs
    • MouseHandler.cs
    • TouchHandler.cs
    • TouchEvaluator.cs

InputHandler 인터페이스 : IInputHandlerBase

마우스와 터치 이벤트 처리를 동일한 프로토타입으로 처리할 수 있도록 InputHandler 인터페이스를 정의한다.
3-Match 퍼즐 게임에 필요한 UP, DOWN 이벤트만 처리하는 제한된 기능만을 제공할 것이다.
using UnityEngine;

namespace Ninez.Util
{
    public interface IInputHandlerBase
    {
        bool isInputDown { get; }
        bool isInputUp{ get; }
        Vector2 inputPosition { get; } //Screen(픽셀) 좌표
    }
}
Utils/InputEvent/IInputHandlerBase.cs
5 IInputHandlerBase 인터페이스 선언
7 DOWN 상태인지 조회한다.
8 UP 상태인지 조회한다. DOWN 상태 이후에 UP 상태가 될 수 있다.
9 DOWN/UP이 발생한 스크린(픽셀) 좌표를 리턴한다.

MouseHander 구현

마우스 이벤트 처리를 위해 IInputHandlerBase를 구현한 클래스를 아래와 같이 정의한다.
using UnityEngine;

namespace Ninez.Util
{
    public class MouseHandler : IInputHandlerBase
    {
        bool IInputHandlerBase.isInputDown => Input.GetButtonDown("Fire1");
        bool IInputHandlerBase.isInputUp => Input.GetButtonUp("Fire1");

        Vector2 IInputHandlerBase.inputPosition => Input.mousePosition;
    }
}
Utils/InputEvent/MouseHandler.cs
5 IInputHandlerBase 인터페이스를 구현할 클래스를 선언한다.
7 마우스 왼쪽 버튼이 DOWN 상태이면 true를 리턴한다.
속성을 람다(Lambda) 표현식으로 구현하였다.
8 마우스 왼쪽 버튼이 UP 상태이면 true를 리턴한다.
10 마우스 위치(Screen 좌표)를 리턴한다.

유니티에서는 Update() 함수에서 입력 이벤트를 처리하도록 가이드하고 있다. Update()는 매 프레임마다 호출되기 때문에 마우스 버튼이 DOWN 상태인 경우 첫번째 GetButtonDown() 호출시 true를 리턴하고, 다음 프레임부터는 DOWN 상태를 유지하고 있더라도 false를 리턴한다. 또한 버튼이 릴리즈(UP)되고 다시 DOWN될 때 까지는 계속해서 false를 리턴한다. 즉, 마우스 버튼 첫번째 클릭시에만 isInputDown 이 true를 리턴한다.

TouchHandler 구현

모바일 단말의 터치 이벤트 처리를 위해 IInputHandlerBase를 구현한 클래스를 아래와 같이 정의한다.
using UnityEngine;

namespace Ninez.Util
{
    public class TouchHandler : IInputHandlerBase
    {
        bool IInputHandlerBase.isInputDown => Input.GetTouch(0).phase == TouchPhase.Began;
        bool IInputHandlerBase.isInputUp => Input.GetTouch(0).phase == TouchPhase.Ended;

        Vector2 IInputHandlerBase.inputPosition => Input.GetTouch(0).position;
    }
}
Utils/InputEvent/TouchHandler.cs
5 IInputHandlerBase 인터페이스를 구현할 클래스를 선언한다.
7 첫번째 터치 포인트가 눌리면 true를 리턴한다.
속성을 람다(Lambda) 표현식으로 구현하였다.
8 첫번째 터치 포인트가 릴리즈 상태가 되면 true를 리턴한다.
10 터치 위치(Screen 좌표)를 리턴한다.

터치 이벤틀 핸들러는 안드로이드 빌드 후에 테스트할 것이다.

통합 이벤트 매니저 : InputManager

마우스와 터치 이벤트를 통합 연동하는 클래스로서 실행되는 환경에 따라 MouseHander를 사용할지 TouchHandler를 사용할지 결정하며, 스테이지의 컨테이너(보드) 기준으로 좌표를 변경하거나 스와이프 동작을 평가하는 등의 추가 메소드를 제공한다.
using UnityEngine;

namespace Ninez.Util
{
    public class InputManager
    {
        Transform m_Container;

#if UNITY_ANDROID && !UNITY_EDITOR
        IInputHandlerBase m_InputHandler = new TouchHandler();
#else
        IInputHandlerBase m_InputHandler = new MouseHandler();
#endif
        public InputManager(Transform container)
        {
            m_Container = container;
        }

        public bool isTouchDown => m_InputHandler.isInputDown;
        public bool isTouchUp => m_InputHandler.isInputUp;
        public Vector2 touchPosition => m_InputHandler.inputPosition;
        public Vector2 touch2BoardPosition => TouchToPosition(m_InputHandler.inputPosition);

        /*
         * 터치 좌표(Screen 좌표)를 보드의 루트인 컨테이너 기준으로 변경된 2차원 좌표를 리턴한다
         * @param vtInput Screen 좌표 즉,픽셀 좌표 ( 좌하(0,0) -> 우상(Screen.Width, Screen.Height) )
         * */
        Vector2 TouchToPosition(Vector3 vtInput)
        {
            //1. 스크린 좌표 -> 월드 좌표
            Vector3 vtMousePosW = Camera.main.ScreenToWorldPoint(vtInput);

            //2. 컨테이너 local 좌표계로 변환(컨테이너 위치 이동시에도 컨테이너 기준의 로컬 좌표계이므로 화면 구성이 유연하다)
            Vector3 vtContainerLocal = m_Container.transform.InverseTransformPoint(vtMousePosW);

            return vtContainerLocal;
        }                       
    }
}
Utils/InputEvent/InputManager.cs
7 컨테이너(Board) 객체 레퍼런스.
터치(또는 마우스)로 입력된 Screen 좌표를 보드의 원점(0,0)을 기준으로 하는 Local 좌표로 변환하기 위해 Board GameObject의 레퍼런스가 필요하다.
9, 10 모바일(안드로이드) 디바이스에서 실행되는 경우 TouchHandler가 사용 되도록 한다.
11, 12 개발환경(또는 네이티브 어플리케이션)으로 실행되는 경우 MouseHandler가 사용 되도록 한다.
14, 17 생성자, 파라미터로 컨테이너(Board GameObject)를 전달받는다.
19 인터페이스를 구현한 객체에 DOWN 상태를 요청하고 결과를 리턴한다.
MouseHandler 또는 TouchHandler의 isInputDown에게 실행을 위임한다.
20 인터페이스를 구현한 객체에 UP 상태를 요청하고 결과를 리턴한다.
MouseHandler 또는 TouchHandler의 isInputUp에게 실행을 위임한다.
21 인터페이스를 구현한 객체에게 Screen 좌표를 요청하고 결과를 리턴한다.
MouseHandler 또는 TouchHandler의 inputPosition에게 실행을 위임한다.
22 Board의 원점을 기준으로 변환된 Local 좌표를 리턴한다.
28 - 37 Screen 좌표를 씬의 Board 기준 Local 좌표로 변환한다.
vtInput : Screen 좌표 즉, 픽셀 좌표
return : Board 기준 Local 좌표

아래 그림은 Board의 원점을 기준으로 하는 Local 좌표계를 나타낸 것이다.
가운데 원점(0,0)을 중심으로 오른쪽으로 증가하고, 왼쪽으로 감소한다. 보드의 크기가 9미터이기 때문에 보드의 왼쪽 하단은 (-4.5, -4.5)의 Local 좌표를 갖는다.

Board에 입력 이벤트 적용하기

플레이어의 입력 이벤트를 처리하기 위해서 Board GameObject에 등록된 StageController에서 InputManager 객체를 사용한다.
매 프레임마다 호출되는 Update() 함수에서 입력 이벤트를 처리하는 OnInputHandler() 함수를 호출한다.
    public class StageController : MonoBehaviour
    {
        InputManager m_InputManager;

        void InitStage()
        {
            if (m_bInit)
                return;

            m_bInit = true;
            m_InputManager = new InputManager(m_Container);

            BuildStage();
        }

        private void Update()
        {
            if (!m_bInit)
                return;

            OnInputHandler();
        }

        void OnInputHandler()
        {
            //Touch Down 
            if (m_InputManager.isTouchDown)
            {
                Vector2 point = m_InputManager.touchPosition;

                Debug.Log($"Input Down = {point}, local = {m_InputManager.touch2BoardPosition}");
            }
            //Touch UP : 유효한 블럭 위에서 Down 후에 발생하는 경우
            else if (m_InputManager.isTouchUp)
            {
                Vector2 point = m_InputManager.touchPosition;
                Debug.Log($"Input Up = {point}, local = {m_InputManager.touch2BoardPosition}");
            }
        }
    }
StageController.cs
3 InputManager 객체를 선언한다.
11 InitStage() 함수에서 InputManager 객체를 생성한다. 컨터이너(Board GameObject)를 인자로 전달한다.
16 - 22 유티니 프레임당 호출되는 이벤트 함수 Update()를 추가한다.
이벤트 처리는 Update()에서 처리되어야 한다(유니티 권장)
21 Update() 안에서 이벤트 처리를 담당하는 OnInputHandler() 함수를 호출한다. 즉, 매 플레임마다 호출된다.
24 - 39 스테이지의 사용자 입력을 처리하는 이벤트 핸들러 함수를 정의한다.
27 - 32 DOWN 이벤트인 경우, 좌표를 출력한다.
34 - 38 UP 이벤트인 경우, 좌표를 출력한다.
마우스(터치) 입력을 받아서 이벤트 종류와 좌표를 로그로 출력한다.

실행하기

플레이버튼(▶)을 클릭해서 마우스로 플레이 화면을 클릭해보자.
입력 상태(DOWN or UP)와 보드기준 Local 좌표가 출력되는 것을 Consol 탭에서 확인할 수 있다.


스와이프 이벤트 처리하기

이동 가능한 블럭 즉, 스와이프 가능한 블럭 위에서 입력 이벤트를 수신해서 스와이프 방향을 출력해보자.

스와이프 이벤트 처리하기

플레이어의 스와이프 이벤트(터치 후 상하좌우 방향으로 이동시키는 동작)를 평가하는 TouchEvaluator 클래스를 추가하고, InputManager 클래스가 TouchEvaluator를 사용해서 스와이프 결과를 리턴하도록 메소드를 추가할 것이다.
namespace Ninez.Util
{
    public enum Swipe
    {
        NA      = -1,
        RIGHT   = 0,
        UP      = 1,
        LEFT    = 2,
        DOWN    = 3
    }

    public static class TouchEvaluator
    {
        /*
         * 두 지점을 사용하여 Swipe 방향을 구한다.
         * UP : 45~ 135, LEFT : 135 ~ 225, DOWN : 225 ~ 315, RIGHT : 0 ~ 45, 0 ~ 315
         */
        public static Swipe EvalSwipeDir(Vector2 vtStart, Vector2 vtEnd)
        {
            float angle = EvalDragAngle(vtStart, vtEnd);
            if (angle < 0)
                return Swipe.NA;

            int swipe = (((int)angle + 45) % 360) / 90;

            switch (swipe)
            {
                case 0: return Swipe.RIGHT;
                case 1: return Swipe.UP;
                case 2: return Swipe.LEFT;
                case 3: return Swipe.DOWN;
            }

            return Swipe.NA;
        }

        /*
         * 두 포인트 사이의 각도를 구한다.
         * Input(마우스, 터치) 장치 드래그시에 드래그한 각도를 구하는데 활용한다.
         * @return 두 포인드 사이의 각도
         */
        static float EvalDragAngle(Vector2 vtStart, Vector2 vtEnd)
        {
            Vector2 dragDirection = vtEnd - vtStart;
            if (dragDirection.magnitude <= 0.2f)
                return -1f;

            float aimAngle = Mathf.Atan2(dragDirection.y, dragDirection.x);
            if (aimAngle < 0f)
            {
                aimAngle = Mathf.PI * 2 + aimAngle;
            }

            return aimAngle * Mathf.Rad2Deg;
        }
    }
}
Utils/InputEvent/TouchEvaluator.cs
3 - 10 스와이프 방향을 나타내는 Swipe enum 타입을 선언한다.
12 static 클래스 TouchEvaluator를 선언한다.
18 - 35 스와이프 방향을 구하는 메소드.
Swipe enum 타입을 리턴한다.
42 - 55 스와이프 방향을 계산하기 위해 드래그한 두 포인트 사이의 각도를 구한다.
두점 사이의 각도를 구하기 위해서 Arc Tangent 함수를 사용한다.(아래 Note 참고)
0 ~ 360 사이의 값을 리턴한다.

Arc Tangent

점 P(x, y)와 원점사이의 각도를 구하는데 사용한다.(탄젠트의 역함수)
atan와 atan2의 차이점은? atan𝜽는 -π/2 < 𝜽 < π/2 를 표현함으로 전체 각도를 표현하지 못한다. atan2𝜽는 -π < 𝜽 < π를 표현할 수 있어서 각도 계산시에 atan2를 사용한다. 여기에서는 atan2()를 사용할 것이다.

InputManager에 스와이프 메소드 추가하기

InputManager 클래스에 아래와 같이 스와이프 방향을 리턴하는 메소드를 추가한다.
    public Swipe EvalSwipeDir(Vector2 vtStart, Vector2 vtEnd)
    {
        return TouchEvaluator.EvalSwipeDir(vtStart, vtEnd);
    }
InputManager.cs
파라미터로 전달된 두 포인트를 TouchEvaluator에 전달해서 위임한 결과를 리턴한다.

InputManager에서 TouchEvaluator를 사용함으로 입력 이벤트 클래스에 아래와 같이 반영한다.


플레이어 이벤트 핸들러에서 스와이프 적용하기

스와이프 동작을 감지하기 위해서 아래와 같이 이벤트 핸들러를 수정한다.
public class StageController : MonoBehaviour
{
    //Members for Event
    bool m_bTouchDown;          //입력상태 처리 플래그, 유효한 블럭을 클릭한 경우 true
    BlockPos m_BlockDownPos;    //블럭 위치 (보드에 저장된 위치)
    Vector3 m_ClickPos;         //DOWN 위치(보드 기준 Local 좌표)

    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}");

            m_bTouchDown = false;   //클릭 상태 플래그 OFF
        }        
    }
}
StageController.cs
자세한 설명은 소스의 주석에 표기하였다.
4 - 6 이벤트 처리에 필요한 멤버를 선언한다.
11 - 30 마우스/터치 DOWN 이벤트를 처리한다.
보드 영역 안에서 스와이프 가능한 블럭을 클릭한 경우에만 릴리즈(UP) 이벤트를 처리하기 위해 클릭한 정보를 저장한다.
32 - 46 릴리즈(UP) 이벤트를 처리한다.
릴리즈 좌표를 이용하여 InputManage에게 스와이프 방향을 물어본다.
클릭을 시작한 위치와 릴리즈한 위치를 이용해서 InputManager에게 스와이프 방향을 조회하고 결과를 콘솔에 출력한다.

계속해서 OnInputHandler()에서 사용하는 메소드와 클래스를 추가적으로 정의한다.

Stage 메소드 추가

지정된 좌표에 위치한 블럭이 유효한 블럭(이동가능한 블럭)인지 체크하는 메소드를 추가한다.
using Ninez.Util;
 
public bool IsOnValideBlock(Vector2 point, out BlockPos blockPos)
{
    //1. Local 좌표 -> 보드의 블럭 인덱스로 변환한다.
    Vector2 pos = new Vector2(point.x + (maxCol/ 2.0f), point.y + (maxRow / 2.0f));
    int nRow = (int)pos.y;
    int nCol = (int)pos.x;

    //리턴할 블럭 인덱스 생성
    blockPos = new BlockPos(nRow, nCol);

    //2. 스와이프 가능한지 체크한다.
    return board.IsSwipeable(nRow, nCol);
}
Stage.cs
1 IsOnValideBlock 메소드를 선언한다.
point : 보드 기준 Local 좌표
blockPos : point 좌표에 위치한 블럭의 위치 정보. out 파라미터로 Caller에게 전달된다.
리턴 : 유효한 블럭이면 true, 그렇지 않으면 false
4 - 6 Local 좌표를 보드에 저장된 블럭의 위치정보(row, col)로 변환한다
9 호출자에게 전달할 out 파라미터를 설정한다. 블럭의 위치 정보가 전달된다.
12 스와이프 가능하면 유효한 블럭으로 간주한다.

터치한 위치가 보드 위에 있는지 체크하는 메소드를 추가한다.
public bool IsInsideBoard(Vector2 ptOrg)
{
    // 계산의 편의를 위해서 (0, 0)을 기준으로 좌표를 이동한다. 
    // 8 x 8 보드인 경우: x(-4 ~ +4), y(-4 ~ +4) -> x(0 ~ +8), y(0 ~ +8) 
    Vector2 point = new Vector2(ptOrg.x + (maxCol / 2.0f), ptOrg.y + (maxRow / 2.0f));

    if (point.y < 0 || point.x < 0 || point.y > maxRow || point.x > maxCol)
        return false;

    return true;
}
Stage.cs
1 IsInsideBoard 메소드를 선언한다.
ptOrg : 보드 기준 Local 좌표
리턴 : 보드안에 포함되면 true, 그렇지 않으면 false


Board 메소드 추가

요청한 위치의 Cell이 이동가능한 블럭을 포함할 수 있는 경우에 true를 리턴하는 메소드를 추가한다.
public bool IsSwipeable(int nRow, int nCol)
{
    return m_Cells[nRow, nCol].type.IsBlockMovableType();
}
Board.cs

BlockPos 구조체 추가

블럭의 위치(row, col)를 저장하는 BlockPos 구조체(structure)를 정의한다.
int 값 두 개를 보관하는 구조체로 Vector2Int 클래스를 대신 사용할 수 있지만 pos.x 보다는 pos.col(또는 pos.row)와 같이 사용하는 것이 가독성이 좋기 때문에 블럭의 row와 column의 위치를 보관하는 구조체를 추가한다.
(pos.x가 column을 pos.y가 row를 의미하도록 사용하면 되지만 row에 x값을 실수로 사용할 가능성이 높아서 구조체를 별도로 정의한 것이다.)

Value Type vs Reference Type

C# 클래스 객체는 Reference 타입으로 생성시에 힙(Heap)에 할당되고, 메소드의 인자나 리턴값으로 전달될 때 해당 객체의 참조(Reference)가 전달된다. 반면에 Value 타입인 Vector3는 스택에 할당되고 값이 전달될 때 참조가 아닌 전체 메모리가 복사되어 전달되며 스택을 벋어나게되면 메모리에서 해제된다. 그렇기 때문에 스택에 할당되는 경우 GC가 발생하지 않는다. 유니티에서 대표적으로 Vector 타입의 객체들이 struct로 정의되어 있다. Update()에서 new Vector3()를 마음껏 호출해도 GC가 발생하지 이유이기도하다.

namespace Ninez.Util
{
    /**
     * 블럭의 위치를 저장하는 structure
     */
    public struct BlockPos
    {
        public int row { get; set; }
        public int col { get; set; }

        public BlockPos(int nRow = 0, int nCol = 0)
        {
            row = nRow;
            col = nCol;
        }

        //----------------------------------------------------------------------
        // Struct 필수 override function
        //----------------------------------------------------------------------
        public override bool Equals(object obj)
        {
            return obj is BlockPos pos && row == pos.row && col == pos.row;
        }

        public override int GetHashCode()
        {
            var hashCode = -928284752;
            hashCode = hashCode * -1521134295 + row.GetHashCode();
            hashCode = hashCode * -1521134295 + col.GetHashCode();
            return hashCode;
        }

        public override string ToString()
        {
            return $"(row = {row}, col = {col})";
        }
    }
}
Utils/BlockPos.cs
6 구조체(struct) BlockPos를 선언한다.
11 - 15 구조체의 생성자. 파라미터로 전달받은 row, col 값을 저장한다.
20 - 36 struct 정의시에 재정의(override)가 필요한 메소드를 구현한다.
Dictionary<>등의 키로 사용될 때 재정의하지 않으면 원하지 않는 결과가 나타날 수 있다.

실행하기

플레이버튼(▶)을 클릭해서 실행한 후 블럭 위에서 마우스를 클릭해서 상하좌우로 스와이프 액션을 입력해보자. 아래와 같이 Consol 탭에 스와이프 결과가 출력되는 것을 볼 수 있다.

다음 장에서는 스와이프 동작으로 두 개의 블럭을 아래와 같이 이동시키는 기능을 구현할 것이다.

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

댓글 없음:

댓글 쓰기