본문으로 바로가기

8장 Input System - ③ 컨트롤러 입력

게임이 영화나 애니메이션과 같은 엔터테인먼트와 다른 이유는 다양한 입력 장치에 반응하기 때문이다. 8장에서는 게임의 대표적인 입력장치인 키보드, 마우스, 컨트롤러를 활용하여 유저의 입력을 받는 방식을 구현한다. 모든 액터와 컴포넌트는 자신이 필요로 하는 입력과 상호작용할 수 있도록 입력 장치를 시스템에 통합하는 방법을 다룬다.

💡 8장의 목차

  • 입력 장치
  • 키보드와 마우스 입력
  • 컨트롤러 입력

컨트롤러 입력

아래와 같은 이유로 키보드나 마우스의 입력에 비해 컨트롤러의 입력은 더 복잡하다.

  • 컨트롤러는 키보드나 마우스보다 훨씬 다양한 센서를 갖고 있다.
  • 키보드, 마우스와 다르게 컨트롤러의 경우 여러 개의 연결이 가능하다.
  • 컨트롤러는 핫스와핑(프로그램 실행 동안 연결 및 분리) 기능을 제공해야 한다.

컨트롤러 활성화

우선은 하나의 컨트롤러가 게임 시작 전에 연결되어 있음을 가정하고 학습을 진행한다.

컨트롤러를 사용하기 위해서는 SDL을 초기화할 때 아래와 같이 컨트롤러에 해당하는 SDL_INIT_GAMECONTROLLER 플래그를 추가해야 한다.

SDL_Init(SDL_INIT_VIDEO|SDL_INIT_AUDIO|SDL_INIT_GAMECONTROLLER);

다음 컨트롤러를 초기화하기 위해서는 SDL_GameControllerOpen() 함수를 사용해야 한다. 이 함수의 반환 값은 SDL_Controller 구조체에 대한 포인터이며 실패 시 nullptr을 반환한다. 비활성화하기 위해서는 SDL_GameControllerClose() 함수를 호출한다.

mController = SDL_GameControllerOpen(0);    // 0번 컨트롤러 활성화
//SDL_GameControllerClose(0);   // 0번 컨트롤러 비활성화

버튼

SDL의 게임 컨트롤러는 다양한 버튼을 지원한다. SDL에 정의된 다양한 버튼 상수는 다음과 같다.

버튼 상수
A, B, X, Y SDL_CONTROLLER_BUTTON_* (*A,B,X,Y로 치환)
Back SDL_CONTROLLER_BACK
Start SDL_CONTROLLER_START
왼쪽/오른쪽 스틱 누르기 SDL_CONTROLLER_BUTTON_*STICK(*LEFT, RIGHT로 치환
왼쪽/오른쪽 숄더 SDL_CONTROLLER_BUTTON_*SHOULDER(*LEFT, RIGHT로 치환)
방향패드 SDL_CONTROLLER_DPAD_*(*UP, DOWN, LEFT, RIGHT로 치환)

SDL에는 모든 컨트롤러 버튼의 상태를 동시에 조회하는 메커니즘을 가지고 있지 않다. 그래서 SDL_GameControllerGetButton함수를 사용해 각 버튼을 개별적으로 조회해야 한다. 따라서 매 프레임마다 InputSystem에서는 아래와 같은 메커니즘으로 SDL로부터 버튼 입력 상태를 받아올 수 있다.

void InputSystem::Update()
{
    // .. (생략) ..
    for (int i = 0; i < SDL_CONTROLLER_BUTTON_MAX; i++)
    {
        mState.Controller.mCurrButtons[i] =
            SDL_GameControllerGetButton(mController,
                SDL_GameControllerButton(i));
    }
    // .. (이하 생략) ..

이후 처리과정은 키보드 버튼과 동일하므로 전체 소소코드와 교재를 참고하자.

아날로그 스틱과 트리거

SDL은 x/y 2개의 축을 스틱 2개와 하나의 축을 가진 트리거 2개의 입력을 제공한다. 축을 가진 스틱과 트리거의 입력값은 true, false가 아닌 -32,768 ~ 32,767의 범위 값을 가진다. SDL에서 제공하는 컨트롤러 축 상숫값은 아래와 같다.

상수
왼쪽 아날로그 스틱 SDL_CONTROLLER_AXIS_LEFT*(*XY로 치환)
오른쪽 아날로그 스틱 SDL_CONTROLLER_AXIS_RIGHT*(*XY로 치환)
왼쪽/오른쪽 트리거 SDL_CONTROLLER_AXIS_TRIGGER*(*LEFTRIGHT로 치환)

필터링

API가 제공하는 범위 값은 어디까지나 이론적인 것에 불과하다. 아날로그 장비에서 측정되는 수치인만큼 각 개별 장치에는 오차가 존재한다. 만약 플레이어가 스틱을 계속 오른쪽 끝으로 유지한다면 스틱의 X축 값이 최댓값인 32,767이 나와야 하지만, 최댓값이 아니라 최댓값에 근사한 값이 나올 가능성이 크다. 반대로 유저가 스틱에서 손을 떼면 X축 값이 0이 나오길 기대하지만, 실제로는 기기의 오차로 인해서 0에 가까운 수가 나올 가능성이 크다. 그래서 API가 제공한 값에 존재하는 오차를 제거하는 방법이 필요하고 이를 필터링이라고 한다. 필터링 없이 API가 제공하는 값을 그대로 사용한다면 게임 내 캐릭터는 기대와 다르게 움직일 수 있다.

▲ 필터링 예시

위 그림처럼 0에 가까운 값은 0.0으로 해석(이 구역을 데드 존이라 함)하고 최솟값에 가까운 값은 최솟값(-1.0)으로 해석하고 최댓값에 가까운 값을 최댓값(1.0)으로 해석한다. 그리고 그 사이의 값은 데드존의 기준과 최댓값의 기준 사이를 0.0 ~ 1.0이라 했을 때 비례 값으로 환산하여 적용한다. 예를 들어 24,500이라는 입력값은 0.75로 필터링된다. 아래는 필터링을 구현한 소스코드이다. 코드상에서 데드존의 기준은 250으로 위 그림보다 작지만, 실제 트리거에서 잘 동작한다.

float InputSystem::Filter1D(int input)
{
    // 값이 데드 존보다 작으면 0%로 해석한다.
    const int deadZone = 250;
    // 값이 최댓값보다 크면 100%로 해석한다.
    const int maxValue = 30000;

    float retVal = 0.0f;

    // 입력의 절댓값을 얻는다.
    int absValue = input > 0 ? input : -input;
    // 데드 존 내의 입력값은 무시한다.
    if (absValue > deadZone)
    {
        // 데드 존과 최댓값 사이의 분숫값을 계산
        retVal = static_cast<float>(absValue - deadZone) /
            (maxValue - deadZone);

        // 원래값의 부호와 일치시킨다.
        retVal = input > 0 ? retVal : -1.0f * retVal;

        // 값이 -1.0f 와 1.0f 범위를 벗어나지 않게 한다.
        retVal = Math::Clamp(retVal, -1.0f, 1.0f);
    }
    return retVal;
}

2차원 필터링

앞서 다룬 필터링은 트리거와 같이 1차원 축을 가진 입력장치에서 잘 동작한다. 그러나 트리거가 아닌 스틱은 x/y축을 가진 2차원 입력장치이기 때문에 조금 더 복잡한 필터링 과정이 필요하다. 이미 구현한 1차원 필터링을 x, y값에 각각 적용하게 될 경우 <1.0,1.0>와 같은 값이 나올 수 있다. 이 값이 의미하는 바는, 스틱을 오른쪽 끝으로 조작하거나 위쪽 끝으로 조작할 경우 입력값의 최대 크기가 1.0이지만 스틱을 대각선으로 입력할 경우 1보다 더 큰 값을 입력하게 된다는 것이다.

때문에 다음과 같은 절차로 2차원 입력값을 필터링할 수 있다.

  • 입력받은 x, y값으로 벡터를 만든다.
  • 벡터의 길이가 데드존보다 작다면,
    • 영벡터로 만든다.
  • 벡터의 길이가 데드존보다 크다면,
    • 데드 존과 최댓값 사이의 값으로 스케일 한다.
Vector2 InputSystem::Filter2D(int inputX, int inputY)
{
    const float deadZone = 8000.0f;
    const float maxValue = 30000.0f;

    // 2D 벡터를 만든다
    Vector2 dir;
    dir.x = static_cast<float>(inputX);
    dir.y = static_cast<float>(inputY);

    float length = dir.Length();
    // IF 길이가 데드존 보다 작다면 입력 없음으로 처리
    if (length < deadZone)
    {
        dir = Vector2::Zero;
    }
    else
    {
        // 데드존과 최댓값 동심원 사이의 분숫값을 계산
        float f = (length - deadZone) / (maxValue - deadZone);
        // f 값을 0.0f와 1.0f 사이로 한정
        f = Math::Clamp(f, 0.0f, 1.0f);
        // 벡터를 정규화한뒤 분숫값으로
        // 벡터를 스케일
        dir *= f / length;
    }
    return dir;
}

나머지 전체 구현은 아래의 소스코드와 교재를 참고하자.

복수개의 컨트롤러 지원

여러 개의 컨트롤러를 사용하기 위해서는 게임 시작 시 모든 연결된 컨트롤러를 초기화하기 위해 아래와 같이 모든 조이스틱을 반복하면서 각 조이스틱을 식별하기 위한 컨트롤러 감지 코드를 실행해야 한다.

for (int i = 0; i < SDL_NumKoysticks(); ++i)
{
    // 이 조이스틱이 컨트롤러인가?
    if (SDL_IsGameController(i))
    {
        // 이 컨트롤러를 사용하기 위해서 열자
        SDL_GameController* controller = SDL_GameControllerOpen(i);
        // SDL_GameController* 벡터에 포인터를 추가한다.
    }
}

또한 SDL은 핫 스와핑(게임 중에 컨트롤러를 추가/제거)을 지원하기 위해 컨트롤러를 추가하거나 제거할 때 아래의 2가지 다른 이벤트를 생성한다.

  • SDL_CONTROLLERDEVICEADDED
  • SDL_CONTROLLERDEVICEREMOVED

이상으로 8장 InputSystem 에 대한 요약을 마친다.


출처:

에이콘 출판 <Game Programming in C++>