반응형

Programming 

모바일 가상 조이스틱 구현하기

 

작성 기준 버전 :: 2019.2 - 2019.4

 

[이 포스트의 내용은 유튜브 영상으로도 시청하실 수 있습니다]

 

 

 

요즘에는 오락실이 별로 많지 않지만 제가 어렸을 때 오락실에서 게임할 때는 오락실 기계에 붙어있는 조이스틱과 버튼을 사용해서 게임을 플레이 했었다. 그리고 본격적으로 PC게임으로 넘어왔을 때는 키보드와 마우스를 통해 게임 속의 캐릭터나 유닛들을 조작했다.

 

 

또 구세대 2G 폰에서는 핸드폰에 달린 숫자 버튼들이 게임을 조작하는 데 쓰였었다. 한참 모바일 기기에서 새로운 시도를 하던 때에는 게임을 위한 전용 휴대폰도 나왔었다.

 

 

하지만 어느 새 스마트폰의 시대가 와버렸는데 스마트폰은 보통 입력을 받아들일 수 있는 부분이 화면의 터치 밖에 없다. 그래서 게임 조작 방식에 많은 제약이 있을 수 밖에 없었다.

 

터치만 가능한 모바일 기기에서 어떻게 하면 좀 더 콘솔 게임과 같은 조작이 가능할까 하며 고안된게 바로 가상 조이스틱이다. 가상 조이스틱은 화면에 조이스틱 UI를 띄우고 그것을 터치하여 조작하는 방식의 컨트롤러다.

 

조이스틱 UI 배치하기

 

 

 

가상 조이스틱을 구현하기 위해서 먼저 필요한 리소스는 조이스틱을 위에서 아래로 쳐다보았을 때의 조이스틱 바닥 부분과 레버 머리부분을 표현한 이미지이다.

 

 

 

이미지 리소스를 준비하고 나면 이미지 게임 오브젝트를 두 개 만들어서 위의 이미지와 같이 넣어준다. 빨간색 레버가 자식 게임 오브젝트가 되도록 만든다.

 

 

그리고 조이스틱 레버 이미지의 위치를 화면 좌측하단에서부터 계산하기 위해서 Joystick 게임 오브젝트의 앵커를 left bottom으로 설정한다.

 

조이스틱 스크립트 작성

 

드래그 이벤트 가져오기

 

using UnityEngine;
using UnityEngine.EventSystems; // 키보드, 마우스, 터치를 이벤트로 오브젝트에 보낼 수 있는 기능을 지원

public class VirtualJoystick : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{    
    public void OnBeginDrag(PointerEventData eventData)
    {
        Debug.Log("Begin");
    }
    
    // 오브젝트를 클릭해서 드래그 하는 도중에 들어오는 이벤트
    // 하지만 클릭을 유지한 상태로 마우스를 멈추면 이벤트가 들어오지 않음    
    public void OnDrag(PointerEventData eventData)
    {
        Debug.Log("Drag");
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        Debug.Log("End");
    }
}

 

이미지 배치가 끝나면 조이스틱 기능을 만들기 위해서 VirtualJoystick이라는 이름으로 C# 스크립트를 생성한다. 그리고 제일 상단에 EventSystems 네임스페이스를 using 선언해준다. 이 이벤트 시스템 네임스페이스에는 유니티에서 키보드, 마우스, 터치 등의 사용자 입력을 오브젝트에 이벤트로 보낼 수 있는 기능들을 지원한다.

 

그 다음에는 MonoBehaviour 상속 뒤에 IBeginDragHandler, IDragHandler, IEndDragHandler를 추가한다. 여기서 I로 시작하는 이 핸들러들은 인터페이스라는 것으로 클래스와는 다르게 구현해야할 함수만을 지정해주는 것이다. 이 인터페이스를 상속받으면 인터페이스가 가지고 있는 함수들을 강제로 구현하도록 에러가 표시된다.

 

여기서 [F12]를 눌러서 일일이 구현해야할 함수를 보고 작성할 수도 있지만, 에러가 표시되고 있는 인터페이스 위에 커서를 두고 [Ctrl + .] 단축키를 누르면 구현해야할 함수들을 자동으로 생성되게 할 수 있다.

 

참고로 BeginDrag는 드래그를 시작할 때, Drag는 드래그 중일 때, EndDrag는 드래그를 끝냈을 때를 의미한다.

 

먼저 드래그를 체크해보기 위해서 각 드래그 이벤트 함수에 로그 코드를 추가하고 코드를 저장한 뒤 에디터로 돌아간다.

 

 

그리고 생성한 컴포넌트를 Joystic 게임 오브젝트에 부착하고 게임을 플레이시킨다.

 

 

그 다음 조이스틱 이미지 위에 클릭하고 드래그하면 Begin, Drag, End 순서로 로그가 출력된다.

 

레버 이미지가 드래그를 따라서 움직이게 만들기

 

using UnityEngine;
using UnityEngine.EventSystems; // 키보드, 마우스, 터치를 이벤트로 오브젝트에 보낼 수 있는 기능을 지원

public class VirtualJoystick : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    [SerializeField]
    private RectTransform lever;    // 추가
    private RectTransform rectTransform;    // 추가

    private void Awake()    // 추가
    {
        rectTransform = GetComponent<RectTransform>();
    }
    
    public void OnBeginDrag(PointerEventData eventData)
    {  
        // Debug.Log("Begin");

        // 추가
        var inputDir = eventData.position - rectTransform.anchoredPosition;
        lever.anchoredPosition = inputDir;
    }
    
    // 오브젝트를 클릭해서 드래그 하는 도중에 들어오는 이벤트    // 하지만 클릭을 유지한 상태로 마우스를 멈추면 이벤트가 들어오지 않음    
    public void OnDrag(PointerEventData eventData)
    {
        // Debug.Log("Drag");
        
        // 추가
        var inputDir = eventData.position - rectTransform.anchoredPosition;
        lever.anchoredPosition = inputDir;
    }
    
    public void OnEndDrag(PointerEventData eventData)
    {
        // Debug.Log("End");
        
        // 추가
        lever.anchoredPosition = Vector2.zero;
    }
}

 

드래그 이벤트가 제대로 동작하는 것을 확인했으니 터치한 위치를 따라서 레버 이미지가 따라 움직이도록 만들 차례이다.

 

우선 빨간 레버 이미지의 Rect Transform 컴포넌트를 가질 lever 변수와 Joystick의 Rect Transform을 가지고 있을 rectTransform 변수를 선언한다. 그리고 lever 렉트 트랜스폼은 에디터의 인스펙터 뷰에서 넣어주기 위해서 SerializeField 어트리뷰트를 걸어주고 본체의 rectTransform은 Awake 함수에서 GetComponent로 가지고 와서 저장해둔다.

 

그 다음은 터치한 위치를 찾아내서 lever 이미지가 그 위치로 이동하게 만들어 줘야한다.

 

터치한 위치는 이벤트 함수의 매개변수로 받는 eventData.position을 통해서 가져올 수 있다. 이렇게 가져온 eventData.position에서 가상 조이스틱 게임 오브젝트의 위치인 rectTransform.anchoredPosition을 빼주면 lever가 있어야할 위치를 구할 수 있게 된다. 구한 inputDir를 lever.anchoredPosition에 넣어준다. 이 과정은 OnBeginDrag와 OnDrag, 두 이벤트에서 동일하게 처리해준다.

 

그리고 마지막으로 가상 조이스틱에서 손을 뗐을 때의 이벤트인 OnEndDrag에서는 lever.anchoredPosition을 Vector2.zero로 만들어서 조이스틱의 중심으로 다시 돌아가게 만들어준다.

 

 

코드를 저장하고 에디터로 돌아간 다음에는 컴포넌트의 비어있는 lever 프로퍼티에 레버 이미지를 할당해준다.

 

 

게임을 플레이시키고 가상 조이스틱 위에 드래그해보면 빨간색 레버 이미지가 터치한 위치를 잘 따라오는 것을 볼 수 있다. 그리고 터치를 끝내면 자동으로 중심으로 레버가 돌아가기까지 한다.

 

 

하지만 너무 잘 따라와서 문제인데 드래그를 유지한 채로 조이스틱 영역 밖으로 움직이면 빨간 레버 역시 조이스틱 위치를 벗어나 버린다. 레버가 조이스틱 영역을 벗어나지 않도록 수정할 필요가 있어보인다.

 

레버 이동 제한하기

using UnityEngine;
using UnityEngine.EventSystems; // 키보드, 마우스, 터치를 이벤트로 오브젝트에 보낼 수 있는 기능을 지원

public class VirtualJoystick : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    [SerializeField]
    private RectTransform lever;
    private RectTransform rectTransform;
    // 추가
    [SerializeField, Range(10f, 150f)]
    private float leverRange;    
    
    private void Awake()
    {
        rectTransform = GetComponent<RectTransform>();
    }
    
    public void OnBeginDrag(PointerEventData eventData)
    {
        var inputDir = eventData.position - rectTransform.anchoredPosition;
        //추가
        var clampedDir = inputDir.magnitude < leverRange ? 
            inputDir : inputDir.normalized * leverRange;

        // lever.anchoredPosition = inputDir;
        lever.anchoredPosition = clampedDir;    // 변경

    }
    
    // 오브젝트를 클릭해서 드래그 하는 도중에 들어오는 이벤트
    // 하지만 클릭을 유지한 상태로 마우스를 멈추면 이벤트가 들어오지 않음    
    public void OnDrag(PointerEventData eventData)
    {
        var inputDir = eventData.position - rectTransform.anchoredPosition;
        // 추가
        var clampedDir = inputDir.magnitude < leverRange ? inputDir : inputDir.normalized * leverRange;

        // lever.anchoredPosition = inputDir;
        lever.anchoredPosition = clampedDir;    // 변경
    }
    
    public void OnEndDrag(PointerEventData eventData)
    {
        lever.anchoredPosition = Vector2.zero;
    }
}

 

먼저 레버가 움직일 수 있는 거리를 조이스틱의 중심부로부터 일정 거리로 제한시키기 위해서 float 타입으로 leverRange라는 변수를 추가한다. private으로 두되 에디터에서 수정할 수 있도록 SerializeField 어트리뷰트와 특정 범위 내에서만 값을 조절할 수 있도록 Range 어트리뷰트를 추가해준다. Range의 범위는 10에서 150 사이로 하자.

 

leverRange 변수를 만들고 나면 이벤트 함수에서 inputPos를 lever의 anchoredPosition에 바로 넣지 말고 inputPos의 길이와 leverRange를 비교한 뒤, leverRange보다 짧으면 inputPos를 바로 주고, 길면 inputPos를 정규화한 다음 leverRange를 곱하는 방식으로 inputPos의 거리를 제한하여 lever의 anchoredPosition에 넣어준다. 이 과정도 OnDrag에서 똑같이 처리해준다.

 

 

 

코드를 저장하고 에디터로 돌아가서 레버 이미지가 조이스틱 영역을 크게 벗어나지 않는 적절한 영역을 확인한다. 지금은 100 정도가 적당해 보인다.

 

 

VirtualJoystick 컴포넌트가 부착되어 있는 Joystick 게임 오브젝트를 선택하고 인스펙터 뷰에서 Lever Range 프로퍼티를 100으로 설정한다.

 

 

게임을 플레이시키고 가상 조이스틱을 움직여보면 드래그를 조이스틱 밖으로 끌어도 조이스틱의 레버가 조이스틱 영역을 벗어나지 않는 것을 볼 수 있다.

 

조작 기능 추가하기

using UnityEngine;
using UnityEngine.EventSystems; // 키보드, 마우스, 터치를 이벤트로 오브젝트에 보낼 수 있는 기능을 지원

public class VirtualJoystick : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    [SerializeField]
    private RectTransform lever;
    private RectTransform rectTransform;
    [SerializeField, Range(10f, 150f)]
    private float leverRange;
    
    private Vector2 inputVector;    // 추가
    private bool isInput;    // 추가

    private void Awake()
    {
        rectTransform = GetComponent<RectTransform>();
    }
    
    public void OnBeginDrag(PointerEventData eventData)
    {
        // var inputDir = eventData.position - rectTransform.anchoredPosition;
        // var clampedDir = inputDir.magnitude < leverRange ? inputDir 
        //     : inputDir.normalized * leverRange;
        // lever.anchoredPosition = clampedDir;

        ControlJoystickLever(eventData);  // 추가
        isInput = true;    // 추가
    }
        
    // 오브젝트를 클릭해서 드래그 하는 도중에 들어오는 이벤트
    // 하지만 클릭을 유지한 상태로 마우스를 멈추면 이벤트가 들어오지 않음    
    public void OnDrag(PointerEventData eventData)
    {
        // var inputDir = eventData.position - rectTransform.anchoredPosition;
        // var clampedDir = inputDir.magnitude < leverRange ? inputDir 
        //     : inputDir.normalized * leverRange;
        // lever.anchoredPosition = clampedDir;
        
        ControlJoystickLever(eventData);    // 추가
        isInput = false;    // 추가
    }
    
    // 추가
    public void ControlJoystickLever(PointerEventData eventData)
    {
        var inputDir = eventData.position - rectTransform.anchoredPosition;
        var clampedDir = inputDir.magnitude < leverRange ? inputDir 
            : inputDir.normalized * leverRange;
        lever.anchoredPosition = clampedDir;
        inputVector = clampedDir / leverRange;
    }
    
    public void OnEndDrag(PointerEventData eventData)
    {
        lever.anchoredPosition = Vector2.zero;
    }
}

 

이제 조작 기능을 추가할 차례이다.

 

이번에는 Vector2로 inputVector 변수와 bool 타입으로 isInput 변수를 추가한다.

 

그리고 OnBeginDrag와 OnDrag 이벤트 함수에서 inputVector 값을 넣어줄 차례인데, 두 이벤트 모두 동작하고 있는 함수의 내용이 완전히 동일하다는 것을 알 수 있다. 그렇기 때문에 ControlJoystickLever라는 함수를 새로 만들고 두 함수의 내용을 복사해서 붙여넣어 준다.

 

그 다음에는 ControlJoystickLever 함수에서 clampedDir를 leverRange로 나누어서 inputVector에 넣어준다. clampedDir를 바로 써도 될 것 같은데 굳이 leverRange로 나누어서 사용하는 이유는 이 clampedDir는 해상도를 기반으로 만들어진 값이라 캐릭터의 이동속도로 쓰기에는 너무 큰 값을 가지고 있기 때문이다.

 

이런 값으로 캐릭터를 움직이면 너무 빠른 속도로 캐릭터가 움직일게 분명하기 때문에 입력 방향의 값을 0-1 사이 값으로 만들어서 정규화된 값으로 캐릭터에 전달하기 위한 것이다.

 

그리고 화면 해상도를 기준으로 값이 정해지기 때문에 해상도가 바뀌면 입력 방향 값의 크기가 바뀌어서 캐릭터의 이동 속도가 바뀌는 문제도 있어서 0-1사이로 정규화된 값을 사용해야 한다. 캐릭터는 이렇게 전달받은 정규화된 이동 벡터에 이동속도를 곱해서 일정한 속도로 이동하게 된다.

 

그 다음엔 OnBeginDrag에서 isInput을 true로 바꿔주고 OnEndDrag에서는 false로 바꿔준다.

 

private void InputControlVector()
{
    Debug.Log(inputDirection.x + " / " + inputDirection.y);
    // 캐릭터에게 입력벡터를 전달
}

 

그 다음에는 구해낸 입력 벡터를 캐릭터에 전달하기 위한 함수로 InputContorlVector를 만든다. 이 함수 안에 캐릭터에게 이동 벡터를 전달하는 기능을 작성하면 된다. 우선 지금은 매개변수로 받은 inputVector의 값을 로그로 출력하는 코드만 작성해두자.

 

void Update()
{
    if(isInput)
    {
        InputControlVector();
    }
}

 

업데이트 함수로 가서 isInput이 true일 때, InputControlVector 함수를 호출해주면 됩니다. 

 

여기에서 한 가지 의문을 가질 수 있다. 이 InputControlVector 함수를 OnBeginDrag와 OnDrag 이벤트 함수에서도 호출할 수 있을 텐데 왜 굳이 isInput변수를 따로 만들어서 Update 함수에서 처리하는가 하는 것이다. 

 

이 부분은 OnDrag 이벤트 함수의 특성과 관련이 있다. 앞에서도 이야기했다시피 OnDrag 이벤트 함수는 오브젝트를 클릭하여 드래그하는 도중에 호출되는 이벤트이다. 하지만 여기에 꽤나 중요한 빈틈이 있다.

 

그것은 바로 클릭해서 드래그하는 도중에 클릭을 떼지 않고 마우스를 멈추면 더 이상 이벤트가 호출되지 않는다는 것이다. 그리고 다시 마우스를 움직이기 시작하면 이벤트가 호출된자. 한 마디로 드래그 도중에 마우스 이동을 멈추고 있는 동안에는 이벤트가 들어오지 않는다는 것이다.

 

그래서 드래그 도중에 터치의 이동이나 마우스 이동이 멈췄을 때도 InputControlVector 함수를 연속적으로 호출하기 위해서 isInput이 활성화된 상태일 때 Update 함수에서 지속적으로 호출하도록 만들어 준 것이다.

 

코드를 저장하고 에디터로 돌아가서 다시 테스트해보면 입력 벡터의 길이가 1이 넘지 않는 선에서 입력 방향이 출력되는 것을 볼 수 있다.

 

캐릭터 컨트롤러와 연결하기

 

이렇게 만들어진 가상 조이스틱을 캐릭터와 연결해서 조작할 수 있도록 만들어주어야 한다.

 

// 가상 조이스틱 클래스에서 컨트롤러를 소유
public CharacterController characterController;

private void InputControlVector()
{
    if (characterController)
    {
        characterController.Move(inputDirection);
    }
}

 

간단하게 예시를 들자면 위의 코드처럼 가상 조이스틱 클래스에서 캐릭터 컨트롤러를 소유하거나 콜백을 등록시켜서 호출하는 방법이 있다.

 

 

 

위의 이미지와 같은 방식으로 두 개의 조이스틱을 배치해서 사용하고 싶을 수도 있다.

 

public enum JoystickType { Move, Rotate }
public JoystickType joystickType;

private void InputControlVector()
{
    // 캐릭터에게 입력벡터를 전달
    switch(joystickType)
    {
        case JoystickType.Move:
            controller.Move(inputDirection);
            break;

        case JoystickType.Rotate:
            controller.LookAround(inputDirection);
            break;
    }
}

 

그럴 때는 가상 조이스틱에 타입을 설정할 수 있는 열거형을 만들어서 조이스틱의 타입에 따라 다르게 동작하게 만들어주면 된다.

반응형
  1. ㅇㅇ 2021.03.04 16:52

    좋은 글 친절한 설명 너무 잘 보고 갑니다!
    다만, isInput = false가 OnDrag가 아닌 OnEndDrag함수에 와야하지 않을까 싶습니다

  2. 나도 2021.07.15 10:02

    친절한 설명 덕분에 이해하면서 쭉쭉 배웠습니다. 다만 ㅇㅇ님 댓글처럼 false 부분은 End 함수쪽으로 가야해요 실수같습니다!!

+ Recent posts