본문 바로가기

유니티

유니티 조이스틱(Joystick) 시스템 구현

반응형

모바일 게임에서 플레이어를 상하좌우로 움직이기 위한 Joystick 시스템을 구현해 보자.

 

- 엔진 : 유니티
- 언어 : C#

1. Player Ojbect, Joystick Object, GameManager 생성

  • GameManager는 Joystick Object를 관리하기 위해 생성하였다.
  • Player는 간단한 구체를 생성하였다.

Player

  • Joystick은 패드가 되는 JoystickBase, 손잡이 부분인 JoystickKnob 두 개의 Object를 생성하였다.
    • 겉의 반투명한 원이 JoystickBase, 안쪽의 하얀 원이 JoystickKnob 이다.
    • JoystickBase은 Canvas에 생성되어야 하기 때문에 Canvas의 자식 Object로 생성하였다.
    • JoystickBase의 자식 Object로 JoystickKnob(손잡이)를 생성하였다.

Joystick

2. 구현방식 설명

조이스틱을 움직이기 위해서는 다음과 같은 동작 과정이 필요하다.

화면터치 ▶ 드래그 ▶ Player가 드래그하는 방향으로 이동 + Joystick 손잡이가 드래그하는 방향으로 이동

이와같은 세 단계를 거치기 위해 Joystick 오브젝트와 Player 오브젝트 각각 구현되어야 하는 위치는 다음과 같다.

[1] 화면 터치 Joystick
[2] 드래그 Joystick
[3-1] Joystick 손잡이가 드래그하는 방향으로 이동 Joystick
[3-2] Player가 드래그하는 방향으로 이동 Player

 

[1] 화면터치

  • Joystick 스크립트(JoystickInput.cs)의 HandleInput 함수에서 Input.touchCount를 사용해 화면터치 및 드래그를 구현한다.
  • 최초 터치 시 TouchPhase.Began 구문 이동 / Joystick을 터치한 위치에 활성화
 void HandleInput()
    {
        // === 모바일 터치 입력 처리 ===
        if (Input.touchCount > 0)
        {
            // 첫 번째 터치 정보 획득
            Touch touch = Input.GetTouch(0);
            Vector2 touchScreenPos = touch.position;

            // 터치 상태에 따른 처리
            switch (touch.phase)
            {
                case TouchPhase.Began:
                    // 터치 시작 - 조이스틱 활성화
                    StartInput(touchScreenPos);
                    break;

                case TouchPhase.Moved:
                case TouchPhase.Stationary:
                    // 터치 드래그 중 - 방향 계산 및 업데이트
                    UpdateInput(touchScreenPos);
                    break;

                case TouchPhase.Ended:
                case TouchPhase.Canceled:
                    // 터치 종료 - 조이스틱 비활성화
                    EndInput();
                    break;
            }
        }

 

[2] 드래그

  • 위 함수(HandleInput)에서 TouchPhase.Moved, TouchPhase.Stationary 구문으로 이동
  • 플레이어 및 조이스틱 움직임 처리

 

[3-1] Joystick 손잡이가 드래그하는 방향으로 이동

  • Joystick UI업데이트 함수 구현
  • 조이스틱 패드(joystickBase)를 터치한 위치로 배치
  • 조이스틱 손잡이(joystickKnob)를 패드의 중앙으로부터 터치한 위치로 이동시킴
void UpdateJoystickUI()
    {
        // 조이스틱 UI 오브젝트들이 유효하지 않으면 처리하지 않음
        if (joystickBase == null || joystickKnob == null || parentCanvas == null) return;

        if (isInputActive)
        {
            // === 입력이 활성화된 상태 ===

            // 조이스틱 베이스를 화면에 표시
            joystickBase.gameObject.SetActive(true);

            // 스크린 좌표를 캔버스 로컬 좌표로 변환
            Vector2 canvasPosition;
            RectTransformUtility.ScreenPointToLocalPointInRectangle(
                parentCanvas.transform as RectTransform,
                inputStartPosition,
                parentCanvas.worldCamera,
                out canvasPosition
            );

            // 베이스를 터치 시작 위치에 배치
            joystickBase.localPosition = canvasPosition;

            // 스틱 위치 계산
            Vector2 inputVector = currentInputPosition - inputStartPosition;

            // 캔버스 스케일 팩터 고려
            float scaleFactor = parentCanvas.scaleFactor;
            Vector2 localInputVector = inputVector / scaleFactor;

            // knob이 base의 중앙으로부터 떨어진 거리가 반지름을 초과하지 않도록 제한
            Vector2 clampedOffset = Vector2.ClampMagnitude(localInputVector, baseRadius);

            // 스틱을 계산된 위치에 배치 (베이스 기준 로컬 좌표)
            joystickKnob.localPosition = clampedOffset;
        }
        else
        {
            // === 입력이 비활성화된 상태 ===

            // 조이스틱을 화면에서 숨김
            joystickBase.gameObject.SetActive(false);

            // 스틱을 중앙으로 리셋
            if (joystickKnob != null)
            {
                joystickKnob.localPosition = Vector3.zero;
            }
        }
    }

 

[3-2] Player가 드래그하는 방향으로 이동

  • Joystick 스크립트(JoystickInput.cs)에서 Action 객체를 생성
public static event Action<Vector2> OnDirectionChanged;

 

  • Player 스크립트(PlayerController.cs)에서 해당 객체를 구독
JoystickInput.OnDirectionChanged += OnDirectionChanged;

 

  • 드래그 시 Joystick 스크립트의 UpdateInput 함수에서 이동거리를 측정
  • Action 객체 Invoke를 통해, 구독한 함수를 실행시킴
void UpdateInput(Vector2 screenPosition)
    {
        // 현재 입력 위치 업데이트
        currentInputPosition = screenPosition;

        // 시작점에서 현재점으로의 벡터 계산 (스크린 좌표계)
        Vector2 inputVector = currentInputPosition - inputStartPosition;

        // 벡터를 정규화하여 방향만 추출
        Vector2 direction = Vector2.zero;
        if (inputVector.magnitude > 0.2f) // 데드존 적용
        {
            direction = inputVector.normalized;
        }

        // 계산된 방향을 이벤트로 전달
        OnDirectionChanged?.Invoke(direction);
    }

 

  • PlayerScript에서 구독한 함수가 실행되면서 Player가 이동할 방향을 설정함
void OnDirectionChanged(Vector2 direction)
    {
        inputDirection = direction;
    }

 

3. 스크립트 구현

그렇다면, 필요한 스크립트와 어느 오브젝트에 스크립트가 Component로 들어가야 하는지 보자.

[Hierarchy]

[Script 구성]

GameManager(EmptyObject)
└ JoystickInput.cs
Player
└ PlayerController.cs

  • Joystick Object의 활성화/비활성화를 제어하기 위해 JoystickInput.cs를 GameManager에 넣었다.
  • 각각의 스크립트는 아래와 같이 작성하였다.

[JoystickInput.cs]

  • joystickBase 변수에 Joystick Base Object를 넣어주고, joystickKnob 변수에 Joystick Knob Object를 넣어주자.
using UnityEngine;
using System;

/// 조이스틱 입력 처리 클래스 (UI 캔버스 기반)
/// 미리 생성된 조이스틱 UI 오브젝트를 제어하여 입력을 처리합니다.
/// 조이스틱은 캔버스에 고정되어 카메라 움직임에 영향받지 않습니다.
public class JoystickInput : MonoBehaviour
{
    [Header("조이스틱 UI 참조")]
    [Tooltip("미리 생성된 조이스틱 베이스 오브젝트 (Canvas의 자식)")]
    public RectTransform joystickBase;

    [Tooltip("조이스틱 베이스의 자식인 스틱 오브젝트")]
    public RectTransform joystickKnob;

    // === 이벤트 ===
    public static event Action<Vector2> OnDirectionChanged; // 조이스틱 방향이 변경될 때 발생하는 이벤트 (PlayerController가 구독하여 플레이어 이동에 사용)

    // === 입력 처리 관련 변수 ===
    private Vector2 inputStartPosition; // 터치/클릭을 시작한 스크린 좌표 위치
    private Vector2 currentInputPosition; // 현재 터치/마우스의 스크린 좌표 위치
    private bool isInputActive = false; // 현재 입력이 활성화되어 있는지 여부 (터치/클릭 중인지)
    private Canvas parentCanvas; // 조이스틱이 속한 캔버스 참조
    private float baseRadius; // 조이스틱 베이스의 실제 반지름
    private Vector3 baseInitialPosition; // 조이스틱 베이스의 초기 위치 (Canvas 내 고정 위치)

    /// 게임 시작 시 한 번 호출되어 필요한 컴포넌트들을 설정합니다.
    void Start()
    {
        // 캔버스 참조 획득
        parentCanvas = joystickBase.GetComponentInParent<Canvas>();

        // 조이스틱 패드와 스틱의 실제 크기 계산
        CalculateJoystickDimensions();

        // 베이스의 초기 위치 저장
        if (joystickBase != null)
        {
            baseInitialPosition = joystickBase.localPosition;
            // 초기에는 조이스틱을 비활성화 상태로 설정
            joystickBase.gameObject.SetActive(false);
        }

        // 스틱을 베이스 중앙으로 초기화
        if (joystickKnob != null)
        {
            joystickKnob.localPosition = Vector3.zero;
        }
    }

    /// 입력 처리와 UI 업데이트를 담당합니다.
    void Update()
    {
        // 터치/마우스 입력 감지 및 처리
        HandleInput();

        // 조이스틱 UI 위치 및 표시 상태 업데이트
        UpdateJoystickUI();
    }

    /// 조이스틱 베이스의 반지름을 계산하는 함수
    void CalculateJoystickDimensions()
    {
        if (joystickBase == null) return;

        // 베이스의 반지름 계산 (RectTransform 크기 기반)
        baseRadius = Mathf.Min(joystickBase.rect.width, joystickBase.rect.height) * 0.5f;
        // 패드 끝의 60%만큼 이동 가능하도록 설정
        baseRadius = baseRadius * 0.6f;
    }

    /// 터치/마우스 입력을 감지하고 처리하는 함수
    void HandleInput()
    {
        // === 모바일 터치 입력 처리 ===
        if (Input.touchCount > 0)
        {
            // 첫 번째 터치 정보 획득
            Touch touch = Input.GetTouch(0);
            Vector2 touchScreenPos = touch.position;

            // 터치 상태에 따른 처리
            switch (touch.phase)
            {
                case TouchPhase.Began:
                    // 터치 시작 - 조이스틱 활성화
                    StartInput(touchScreenPos);
                    break;

                case TouchPhase.Moved:
                case TouchPhase.Stationary:
                    // 터치 드래그 중 - 방향 계산 및 업데이트
                    UpdateInput(touchScreenPos);
                    break;

                case TouchPhase.Ended:
                case TouchPhase.Canceled:
                    // 터치 종료 - 조이스틱 비활성화
                    EndInput();
                    break;
            }
        }
        // === 에디터에서 마우스 입력 처리 (개발/테스트용) ===
        else
        {
            // 마우스 위치를 스크린 좌표로 사용
            Vector2 mouseScreenPos = Input.mousePosition;

            if (Input.GetMouseButtonDown(0))
            {
                // 마우스 클릭 시작
                StartInput(mouseScreenPos);
            }
            else if (Input.GetMouseButton(0) && isInputActive)
            {
                // 마우스 드래그 중 (입력이 활성화된 상태에서만)
                UpdateInput(mouseScreenPos);
            }
            else if (Input.GetMouseButtonUp(0))
            {
                // 마우스 클릭 종료
                EndInput();
            }
        }
    }

    /// 입력 시작 처리 함수
    void StartInput(Vector2 screenPosition)
    {
        // 입력 활성화 플래그 설정
        isInputActive = true;

        // 시작 위치 저장
        inputStartPosition = screenPosition;
        currentInputPosition = screenPosition;

        Debug.Log($"조이스틱 입력 시작: {screenPosition}");
    }

    /// 입력 업데이트 처리 함수
    void UpdateInput(Vector2 screenPosition)
    {
        // 현재 입력 위치 업데이트
        currentInputPosition = screenPosition;

        // 시작점에서 현재점으로의 벡터 계산 (스크린 좌표계)
        Vector2 inputVector = currentInputPosition - inputStartPosition;

        // 벡터를 정규화하여 방향만 추출
        Vector2 direction = Vector2.zero;
        if (inputVector.magnitude > 0.2f) // 데드존 적용
        {
            direction = inputVector.normalized;
        }

        // 계산된 방향을 이벤트로 전달
        OnDirectionChanged?.Invoke(direction);
    }

    /// 입력 종료 처리 함수
    void EndInput()
    {
        // 입력 비활성화
        isInputActive = false;

        // 이동 중지를 위해 영벡터(0,0) 전달
        OnDirectionChanged?.Invoke(Vector2.zero);

        Debug.Log("조이스틱 입력 종료");
    }

    /// 조이스틱 UI 업데이트 함수
    void UpdateJoystickUI()
    {
        // 조이스틱 UI 오브젝트들이 유효하지 않으면 처리하지 않음
        if (joystickBase == null || joystickKnob == null || parentCanvas == null) return;

        if (isInputActive)
        {
            // === 입력이 활성화된 상태 ===

            // 조이스틱 베이스를 화면에 표시
            joystickBase.gameObject.SetActive(true);

            // 스크린 좌표를 캔버스 로컬 좌표로 변환
            Vector2 canvasPosition;
            RectTransformUtility.ScreenPointToLocalPointInRectangle(
                parentCanvas.transform as RectTransform,
                inputStartPosition,
                parentCanvas.worldCamera,
                out canvasPosition
            );

            // 베이스를 터치 시작 위치에 배치
            joystickBase.localPosition = canvasPosition;

            // 스틱 위치 계산
            Vector2 inputVector = currentInputPosition - inputStartPosition;

            // 캔버스 스케일 팩터 고려
            float scaleFactor = parentCanvas.scaleFactor;
            Vector2 localInputVector = inputVector / scaleFactor;

            // knob이 base의 중앙으로부터 떨어진 거리가 반지름을 초과하지 않도록 제한
            Vector2 clampedOffset = Vector2.ClampMagnitude(localInputVector, baseRadius);

            // 스틱을 계산된 위치에 배치 (베이스 기준 로컬 좌표)
            joystickKnob.localPosition = clampedOffset;
        }
        else
        {
            // === 입력이 비활성화된 상태 ===

            // 조이스틱을 화면에서 숨김
            joystickBase.gameObject.SetActive(false);

            // 스틱을 중앙으로 리셋
            if (joystickKnob != null)
            {
                joystickKnob.localPosition = Vector3.zero;
            }
        }
    }
}

 

[PlayerController.cs]

using UnityEngine;

/// 플레이어 이동 제어
public class PlayerController : MonoBehaviour
{
    [Header("이동 설정")]
    public float moveSpeed = 5f;

    // 내부 변수
    private Vector2 inputDirection;

    void Start()
    {
        // 조이스틱 이벤트 구독
        JoystickInput.OnDirectionChanged += OnDirectionChanged;
    }

    void Update()
    {
        MovePlayer();
    }

    /// 조이스틱 입력 처리
    void OnDirectionChanged(Vector2 direction)
    {
        inputDirection = direction;
    }

    /// 플레이어 이동
    void MovePlayer()
    {
        if (inputDirection.magnitude > 0.1f)
        {
            Vector2 velocity = inputDirection * moveSpeed;
            Vector2 newPosition = (Vector2)transform.position + velocity * Time.deltaTime;

            transform.position = newPosition;
        }
    }

    void OnDestroy()
    {
        JoystickInput.OnDirectionChanged -= OnDirectionChanged;
    }
}

 

 

반응형