반응형
모바일 게임에서 플레이어를 상하좌우로 움직이기 위한 Joystick 시스템을 구현해 보자.
- 엔진 : 유니티
- 언어 : C#
1. Player Ojbect, Joystick Object, GameManager 생성
- GameManager는 Joystick Object를 관리하기 위해 생성하였다.
- Player는 간단한 구체를 생성하였다.

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

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;
}
}
반응형