Day 18 입력 시스템
게임이 키보드나 마우스 또는 다양한 입력 장치에 반응한다는 사실은 게임과 입력 장치가 상호작용이 가능함을 뜻한다. 게임 프로젝트에서 게임 루프의 '프로세스 입력' 단계 동안 입력 장치의 현재 상태를 가져온다. 그리고 이 현재 입력 상태는 게임 루프의 '게임 세계 갱신' 단계 동안 게임 세계에 영향을 미친다. 일부 입력 장치에서는 이진값만 얻을 수 있다. 예를 들어 키보드에서는 각 키의 상태를 얻을 수 있고, 이 상태는 키를 눌렀는지 또는 뗐는지에에 따라 true나 false가 된다. 입력 장치는 키가 '반쯤 눌러졌는지'는 감지할 수 없으므로 키가 '반쯤 눌러졌는지'를 파악할 수 있는 방법은 없다. 다른 입력 장치는 값의 범위를 제공한다. 예를 들어 대부분의 조이스틱은 사용자가 조이스틱을 특정 방향으로 얼마나 움직였는지를 결정할 수 있도록 두 축의 범위 값을 제공한다. 게임에서 사용하는 대부분의 장치는 복합적이며 이는 여러 유형의 입력이 하나의 장치에 결합돼 있다는 걸 뜻한다. 예를 들어 일반적인 컨드롤러는 이진값만 얻을 수 있는 버튼 뿐만 아니라 범위값을 제공하는 2개의 조이스틱과 트리거를 가진다. 비슷하게 마우스의 이동과 스크롤 휠은 범위 값일 수 있지만, 마우스 버튼은 이진값을 제공한다.
폴링
이전의 게임 프로젝트에서는 키보드의 모든 키에 대한 이진 상태를 얻기 위해 SDL_GetKeyboardState 함수를 사용했다. 이 키보드 상태를 모든 액터의 ProcessInput 함수에 전달했고, 액터는 키보드 상태를 모든 컴포넌트의 ProcessInput 함수에 전달했다. 그러고 나서 이 함수에서는 W키를 눌렀을 때 플레이어 캐릭터를 앞으로 전진시키는 것과 같은 액션을 실행할지 여부를 결정하기 위해 특정 키의 상태를 조회했다. 프레임마다 특정 키의 값을 확인했으므로 이 접근법은 키의 상태를 폴링(polling)하는 것으로 간주된다. 폴링을 기반으로 설계된 입력 시스템은 이해하기에는 개념적으로 단순하다. 따라서 게임 개발자들은 폴링 접근법을 사용하는 것을 선호한다. 폴링은 특히 캐릭터 이동 등에 잘 동작한다. 왜냐하면 프레임마다 일부 입력 장치의 상태를 알아야하며, 이 입력 장치의 상태를 기반으로 캐릭터 이동을 갱신해야하기 때문이다.
상승 엣지와 하강 엣지
게임에서 스페이스를 누르면 캐릭터가 점프한다고 하면 게임은 프레임마다 스페이스바의 상태를 체크해야할 것이다. 스페이스바를 처음 세 프레임이 지나간 후 네 번째 프레임에서 눌렀고 6 프레임 이전까지 스페이스바를 눌렀다. 그리고 스페이스바에서 손을 뗀다. 이것은 x축이 각 프레임에서의 시간에 해당하고, y축이 해당 프레이의 이진값에 해당하는 그래프로 그릴 수 있다.
4프레임에서 스파에스바는 0에서 1로 변경되며, 6프레임에서는 1에서 0으로 다시 돌아온다. 입력이 0에서 1로 변하는 프레임은 상승 엣지(positive edge, rising edge)이며, 입력이 1에서 0으로 변하는 프레임은 하강 엣지(negative edge, falling edge)다.
if (spacebar == 1)
character.jump()
만약 캐릭터의 대한 프로세스 입력이 위의 의사 코드와 같은 경우에 대해서 생각해보면 위 그래프처럼 스페이스바가 입력됐다고 했을 때 코드는 character.jump() 함수를 4프레임에서 한 번, 5프레임에서 한 번, 총 두 번 호출한다. 하지만 스페이스바 값이 1일 때 프레임마다 캐릭터를 점프하게 하고 싶지는 않을 것이다. 대신 스페이스바가 상승 엣지를 가진 프레임에서만 character.jump()를 호출하면 좋을 것이다. 상승 엣지에서만 character.jump() 함수를 호출한다고 하면 플레이어가 스페이스바를 얼마나 오래 누르고 있든 상관없이 캐릭터는 한 번만 점프한다. 이 경우 의사 코드는 아래와 같다.
if (spacebar has positive edge)
character.jump()
의사 코드에서 'has positive edge'용어는 마지막 프레임에서 입력값이 0이었고 현재 프레임에서는 1이라는 것을 의미한다. 하지만 현재 프레임의 키보드의 상태를 얻기 위해 SDL_GetKeyboardState를 사용하는 방법은 위의 의사 코드를 구현하는 방법으로 충분하지 않다. 0으로 초기화된 spacebarLast 변수를 추가해서 마지막 프레임의 값을 저장해고 추적해서 마지막 프레임의 값이 0이고 현재 프레임의 값이 1인 경우에만 점프를 하게 한다.
if (spacebar == 1 and spacebarLast == 0)
character.jump()
spacebarLast = spacebar
코드 전반에서 이러한 패턴을 사용할 수 있다. 그러나 이전 프레임 키값을 자동으로 추적하는 시스템을 구축하면 더 좋을 것이다. 이를 구축하면 키가 상승 엣지인지 또는 하강 엣지인지를 시스템에서 쉽게 확인할 수 있으므로 팀의 다른 프로그래머는 자신이 작성하지 않은 코드 사용에 대한 부담을 줄일 수 있다.
입력 마지막 프레임의 값을 저장하고 그 값을 현재 프레임의 값과 비교하는 접근법을 일반화하면 위의 표와 같이 4가지의 가능한 결과가 존재한다. 둘 다 값이 0이면 버튼의 상태는 None이다. 두 값이 모두 1이면 플레이어가 연속되는 프레임 동안 키를 누르는 입력 상태인 Held다. 마지막으로 값이 다르면 입력 상태는 상승 엣지나 하강 엣지이며 Pressed나 Released의 상태를 갖음을 나타낸다.
이벤트
SDL은 다양한 이벤트를 생성하고, 프로그램은 이 이벤트에 대해 선택적으로 응답한다. 현재 프로그램은 SDL_Quit 이벤트에 대해서만 응답하는데 이 이벤트는 플레이어가 윈도우를 종료할 시 발생한다. Game::ProcessInput은 프레임마다 큐에 이벤트가 있는지 검사하고 이벤트가 있다면 선택적으로 이벤트에 반응한다. SDL은 입력장치에 대한 이벤트도 생성한다. 예를 들어 플레이어가 키보드상의 키를 누를때마다 SDL은 SDL_KEYDOWN 이벤트(Pressed 상태에 해당)를 셍성한다. 역으로 플레이어가 키를 뗄 때 SDL은 SDL_KEYUP 이벤트(Released 상태에 해당)를 발생시킨다. 상승 엣지나 하강 엣지에만 관심있다면 SDL_KEYDOWN 이벤트와 SDL_KEYUP 이벤트는 이 액션에 응답하는 코드를 빠르게 셋업할 수 있는 유용한 이벤트다. 그러나 앞으로 이동하기 위해 W를 누르고 있는 경우 SDL 이벤트는 오직 상승 엣지와 하강 엣지만을 제공하기에 W를 계속 누르고 있는지를 추적하기 위해서는 추가 코드가 필요하다. SDL 이벤트와 여러 폴링 기능은 미묘한 관계다 SDL_GetKeyBoardState에서 가져온 키보드 상태는 메시지 펌프 루프에서 SDL_PollEvents를 호출한 후에만 갱신된다. 이는 코드가 SDL_PollEvents를 호출하는 위치를 알기 때문에 상태 데이터가 언제 변경되는지를 안다는 것을 의미한다. 상태 데이터가 변경되는 시점을 알 수 있다는 사실은 이전 프레임의 데이터를 저장하는 입력 시스템을 구현하는데 도움이 된다.
기본 입력 시스템 아키텍처
다앵한 입력 장치를 살펴보기 전에 먼저 입력 시스템의 구조를 살펴보자. 지금은 액터와 컴포넌트가 함수 ProcessInput 호출을 통해서 현재 키보드의 상태를 인식한다. 그러나 이 메커니즘은 ProcessInput이 SDL 함수를 직접 호출하지 않으면 마우스나 컨트롤러에 접근할 수 없다는 것을 뜻한다. 간단한 게임에서는 이 방법이 잘 통하겠지만 액터나 컴포넌트에 대한 코드를 작성하는 프로그래머 입장에서는 SDL 함수와 관련된 특정 지식은 불필요하다. 게다가 일부 SDL 입력 함수는 함수 호출 간 상태 차이를 반환한다. 개발자가 한 프레임 동안 이런 함수를 두 번 이상 호출하면 첫 번째 호출 다음에는 상태 변화가 없음을 뜻하는 0의 값을 얻을 것이다. 이런 문제를 해결하기 위해 별도의 InputSystem 클래스를 선언한다. InputSystem 클래스는 InputState라는 헬퍼 클래스에 데이터를 채운다. 그런다음 ProcessInput을 사용해서 InputState를 액터/컴포넌트에 상수 참조로 전달한다. 또한 액터/컴포넌트가 관심있는 상태를 쉽게 조회하기 위해 InputState는 몇몇 헬퍼 함수를 가진다.
enum ButtonState {
ENone,
EPressed,
EReleased,
EHeld
};
// 입력의 현재 상태를 포함하는 래퍼
struct InputState {
};
class InputSystem {
public:
bool Initialize();
void Shutdown();
// SDL_PollEvents 루프 직전에 호출된다
void PrepareForUpdate();
// SDL_PollEvents 루프 직후에 호출된다
void Update();
const InputState& GetState() const { return mState; }
private:
InputState mState;
};
위 코드는 기본 InputSystem과 관련된 코드의 초기 선언을 보여준다. 먼저 ButtonState가 가질 수 있는 상태 4개를 열거형으로 선언한다. 다음으로 InputState 구조체를 선언한다. 마지막으로 InputSystem을 선언한다. InputSystem은 Game처럼 Initialize/Shutdown 함수를 포함한다. 또한 InputSystem은 SDL_PollEvents 전에 호출되는 PrepareForUpdate 함수를 가진다. 그리고 이벤트를 폴링한 후에 호출되는 Update 함수도 있다. GetState 함수는 멤버 데이터를 가진 InputState의 상수 참조를 반환한다. 이 코드를 게임에 통합하기 위해 위해 InputSystem 포인터형의 mInputSystem 변수를 Game 멤버 데이터로 추가한다. Game::Initialize에서는 InputSystem을 할당하고 초기화하며 Game::Shutdown에서는 InputSystem을 삭제한다. 그리고 다음과 같이 Actor와 Component 둘 다 ProcessInput의 선언을 변경한다.
void ProcessInput(const InputState& state);
Actor의 ProcessInput은 재정의하지 않았었는데 Actor는 자신에게 부착된 모든 컴포넌트의 ProcessInput만 호출되기 때문이다. 하지만 액터는 자신에게만 통지되는 특정 입력을 처리하도록 재정의 가능한 ActorInput 함수를 가지고 있다. 그래서 ProcessInput처럼 ActorInput의 선언도 InputState의 상수 참조를 전달받도록 변경한다. 마지막으로 Game::ProcessInput의 구현은 다음과 같이 변경된다.
void Game::ProcessInput() {
mInputSystem->PrepareForUpdate();
// SDL_PollEvents 루프...
mInputSystem->Update();
const InputState& state = mInputSystem->GetState();
// 여기서는 대상이 되는 키를 모두 처리한다...
// 키 상태를 모든 액터의 ProcessInput으로 보낸다
}
InputSystem 사용을 통해 이제 개발자는 몇몇 입력 장치에 대한 지원을 추가하는 데 필요한 기본 사항을 갖추게 됐다. 이 각각의 입력 장치를 지원하려면 상태를 캡슐화한 새로운 클래스를 제작한 뒤 해당 클래스의 인스턴스를 InputState 구조ㄹ체에 추가해야 한다.
SDL_GetKeyboardState 함수는 키보드 상태에 대한 포인터를 반환했다. 특히 SDL_GetKeyboardState의 반환값은 내부 SDL 데이터를 가리키며, 응용프로그램의 생명 주기에 걸쳐 변경되지 않는다. 따라서 키보드의 현재 상태를 추적하기 위해서는 한 번만 초기화된 단일 포인터만 있으면 된다. 그러나 SDL은 SDL_PollEvents를 호출할 때 현재 키보드 상태를 덮어쓰므로 이전 프레임 상태를 저장하기 위해서는 별도의 배열이 필요하다. 그래서 InputSystem 내에 이전 프레임 상태를 저장하기 위한 KeyboardState에 멤버 데이터를 선언한다. KeyboardState는 현재 상태를 가리키는 포인터와 이전 상태를 위한 배열을 가진다. 배열의 크기는 SDL이 키보드 스캔에 대해 사용하는 버퍼 크기와 같다. KeyboardState 멤버 함수에는 키의 기본 현재값을 얻어오는 메소드(GetKeyValue)와 4가지 버튼 상태 중 하나를 반환하는 메소드(GetKeyState)가 있다. 마지막으로 InputSystem을 KeyboardState의 friend로 선언해서 InputSystem이 KeyboardState의 멤버 데이터를 직접 조작하기 쉽게 만들어준다.
다음으로 InputState의 멤버 데이터로 Keyboard라는 KeyboardState 인스턴스를 추가한다.
struct InputState
{
KeyboardState Keyboard;
};
그리고 InputSystem 클래스의 Initialize와 PrepareForUpdate 함수에 코드를 추가한다. Initialize에서는 mCurrState 포인터를 먼저 설정하고 mPrevState의 메모리를 0으로 초기화한다(게임을 시작하기 전에 키는 어떤 상태도 갖고 있지 않다). SDL_GetKeyboardState 함수로 현재 상태의 포인터를 얻으며 memset으로 mPrevState의 메모리를 초기화한다.
// InputSystem::Initialize 함수 내에서 ...
// 현재 상태의 포인터를 설정한다
mState.Keyboard.mCurrState = SDL_GetKeyboardState(NULL);
// 이전 상태 메모리를 0으로 초기화한다
memset(mState.Keyboard.mPrevState, 0, SDL_NUM_SCANCODE);
그리고 PrepareForUpdate에선느 모든 현재 데이터를 이전 버퍼로 복사한다. PrepareForUpdate를 호출할 때 현재 데이터는 이전 프레임의 데이터이다. 새로운 프레임에서 PrepareForUpdate를 호출하는 시점에서 아직 SDL_PollEvents는 호출되지 않았기 때문이다. SDL_PollEvents는 mCurrState가 가리키는 내부 SDL의 키보드 상태 데이터를 갱신하므로 SDL_PollEvents 호출 전후의 상황을 이해하는 것은 매우 중요하다. 그래서 SDL이 현재 상태를 덮어쓰기 전에 memcpy를 이용해서 현재 버퍼를 이전 버퍼로 복사한다.
// InputSystem::PrepareForUpdate 함수 내에서 ...
memcpy(mState.Keyboard.mPrevState,
mState.Keyboard.mCurrState,
SDL_NUM_SCANCODES);
다음으로 KeyboardState의 멤버 변수를 구현해야한다. GetKeyValue는 간단하다. mCurrState 버퍼에 색인을 해서 값이 1이면 true를 반환하고 값이 0이면 false를 반환한다.
위 코드는 버튼 상태를 반환하는 함수이다. 이 함수는 4개의 버튼 상태 중 어느 것을 반환해야하는지를 결정하기 위해 현재 프레임과 이전 프레임 키 상태 둘 다를 사용한다. 이제 KeyboardState의 GetKeyValue 함수를 사용해서 키 값에 접근하는 것이 가능하다. 예를 들어 다음 코드는 스페이스바의 현재값이 true인지를 확인한다.
if (state.Keyboard.GetKeyValue(SDL_SCANCODE_SPACE))
그러나 무엇보다 InputState 객체의 이점은 키의 버튼 상태를 조회할 수 있다는 데 있다. 예를 들어 다음의 Game::ProcessInput 코드는 Escape 키의 버튼 상태가 EReleased 상태인지 감지하고 EReleased 경우에만 게임을 종료 상태로 만든다.
if (state.Keyboard.GetKeyState(SDL_SCANCODE_ESCAPE) == EReleased) {
mIsRunning = false;
}
즉 Escape 키를 누르면 즉시 게임이 종료되지는 않지만, 키를 놓으면 게임은 종료된다.
마우스 입력에는 신경써야할 3개의 주요 입력 타입이 있다.
버튼 입력 코드는 버튼의 수가 몇 개 안된다는 점을 제외하면 키보드 코드와 비슷하다. 이동 입력은 2가지의 입력 모드(절대 및 상대)가 있어서 좀 더 복잡하다. 프레임마다 한 번의 함수 호출로 마우스 입력을 폴링할 수 있지만 스크롤 휠의 경우 SDL은 이벤트를 통해서만 데이터를 알려주므로 일부 SDL 이벤트를 처리하기 위해서는 InputSystem에 약간의 코드를 추가해야한다. 기본적으로 SDL은 시스템 마우스 커서를 보여준다. 그리고 SLD_ShowCursor 함수를 사용하면 커서를 활성화하거나 비활성화하는 것이 가능하다. SDL_TRUE를 인자로 넘기면 커서가 활성화되며 SDL_FALSE를 인자로 넘기면 커서는 비활성화된다. 예를 들어 아래의 코드는 커서를 비활성화한다.
SDL_ShowCursor(SDL_FALSE);
버튼과 위치
마우스의 위치와 마우스 버튼의 상태를 알아내려면 SDL_GetMouseState 함수를 한 번 호출하면 된다. 이 함수의 반환값은 버튼 상태의 비트 마스크다. 그리고 마우스의 x/y 좌표를 얻기 위해 두 정수 타입의 주소를 다음과 같이 전달한다.
int x = 0, y = 0;
Uint32 buttons = SDL_GetMouseState(&x, &y);
SDL_GetMouseState의 반환값이 비트 마스크(bit mask)이므로 특정 버튼을 뗐는지 또는 누르고 있는지를 알아내려면 올바른 비트값으로 비트 단위 AND 연산을 사용해야한다. 예를 들어 SDL_GetMouseState로 채워진 버튼 변수가 주어지면 다음 코드는 왼쪽 마우스 버튼을 누른 상태라면 true가 된다.
bool leftIsDown = (buttons & SDL_BUTTON(SDL_BUTTON_LEFT)) == 1;
SDL_BUTTON 매크로는 요청한 버튼을 기반으로 비트를 이동시킨다. 그리고 비트 단위 AND 연산은 버튼이 눌러진 상태라면 1을 반환하고, 뗀 상태라면 0을 반환한다.
위 표는 SDL이 지원하는 5개의 여러 마우스 버튼에 해당하는 버튼 상수를 보여준다.
위 코드는 MouseState의 초기 선언이다. 이전 버튼의 비트 마스크를 저장하기 위해 32비트 unsigned integer를 사용한다. 그리고 현재 마우스 위치를 저장하기 위해 Vector2를 사용한다. 그리고 InputState에 Mouse라는 MouseState 인스턴스를 추가한다. 그런 다음 InputSystem의 PrepareForUpdate에 다음 코드를 추가한다. 아래 코드는 현재 버튼 상태를 이전 상태에 복사한다.
mState.Mouse.mPrevButtons = mState.Mouse.MCurrButtons;
Update에서는 MouseState 멤버를 갱신하기 위해 SDL_GetMouseState를 호출한다.
int x = 0, y = 0;
mState.Mouse.mCurrButtons = SDL_GetRelativeMouseState(&x, &y);
mState.Mouse.mMousePos.x = static_cast<float>(x);
mState.Mouse.mMousePos.y = static_cast<float>(y);
이제 InputState의 기본 마우스 정보에 접근해보자. 예를 들어 왼쪽 마우스 버튼이 EPressed 상태에 있는지를 확인하려면 아래 코드를 사용한다.
if (mState.Mouse.GetButtonState(SDL_BUTTON_LEFT) == EPressed)
상대 모션
SDL은 마우스의 움직임을 감지하는데 있어 2가지의 다른 모드를 제공한다. 기본 모드로 SDL은 마우스의 현재 좌표를 알린다. 하지만 때때로 개발자는 프레임 간 마우스의 상대적인 변화를 알기 원할 수도 있다. 예를 들어 많은 1인칭 게임에서는 카메라를 회전하기 위해 마우스를 사용한다. 카메라의 회전 속도는 플레이어가 얼마나 빨리 마우스를 이동시키는지에 의존한다. 이 경우 마우스의 정확한 좌표는 유용하지 않지만, 프레임 간 상대적인 이동은 유용하다. 이전 프레임의 마우스 위치를 저장해두면 프레임 간 상대적인 이동을 구하는 것이 가능하다. 하지만 SDL은 SDL_GetRelativeMouseState 함수를 호출해서 상대적인 이동을 구할 수 있는 마우스 모드를 지원한다. SDL은 상대 마우스 모드의 큰 이점은 이 모드가 프레임마다 마우스를 숨기거나 윈도우 영역에 마우스를 고정시키는 것이 가능하고 마우스를 중심에 배치할 수 있다는 데 있다. 이 모드를 사용하면 플레이어가 실수로 마우스 커서를 윈도우 영역 바깥으로 이동시키는 것을 막아준다. 상대 마우스 모드를 활성화하려면 다음과 같이 코드를 작성한다.
SDL_SetRelativeMouseMode(SDL_TRUE);
상대 마우스 모드를 비활성화하려면 파라미터로 SDL_FALSE를 전달한다. 상대 마우스 모드가 활성화되면 SDL_GetMouseState를 사용하는 대신 SDL_GetRelativeMouseState를 사용해야한다. InputSystem에서 상대 마우스 모드를 지원하기 위해 먼저 상대 마우스 모드를 활성화 또는 비활성화하는 함수를 추가한다.
MouseState의 mIsRelative 변수에 상대 마우스 모드의 상태를 저장한다. 초기값은 false로 설정한다. 다음으로 InputSystem::Update의 코드를 변경해서 상대 마우스 모드일 경우 올바른 마우스의 위치와 버튼값을 얻어올 수 있도록 함수를 변경한다.
int x = 0, y = 0;
if (mState.Mouse.mIsRelative) {
mState.Mouse.mCurrButtons = SDL_GetRelativeMouseState(&x, &y);
}
else {
mState.Mouse.mCurrButtons = SDL_GetMouseState(&x, &y);
}
mState.Mouse.mMousePos.x = static_cast<float>(x);
mState.Mouse.mMousePos.y = static_cast<float>(y);
이제 상대 마우스 모드를 활성화 해서 MouseState를 통해 상대적인 마우스 위치에 접근하는 것이 가능하다.
스크롤 휠
스크롤 휠에 경우 SDL은 휠의 현재 상태를 조회하는 기능을 제공하지 않는다. 대신 SDL은 SDL_MOUSEWHEEL 이벤트를 생성한다. 그래서 입력 시스템에서 스크롤 휠을 지원하려면 먼저 입력 시스템에 SDL 이벤트를 전달해야한다. ProcessEvent 함수를 통해서 SDL 이벤트를 전달받을 수 있으며 입력 시스템에 마우스 휠 이벤트를 전달하기 위해서 Game::ProcessInput에서 이벤트를 폴링하는 루프를 수정한다.
SDL_Event event;
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_MOUSEWHEEL:
mInputSystem->ProcessEvent(event);
break;
// 다른 case들은 생략 ...
}
}
다음으로 MouseState에 다음 멤버 변수를 추가한다.
Vector2 mScrollWheel;
대부분의 마우스가 수평 수직 방향 둘다 지원하므로 SDL은 두 방향의 스크롤링 값을 알려준다. 그래서 MouseState에서는 Vector2를 사용한다. 그런 다음 InputSystem을 변경해야한다. 먼저 event.wheel 구조체의 스크롤 휠 x/y 값을 읽도록 ProcessEvent를 구현한다.
그리고 마우스 휠 이벤트는 스크롤 휠이 움직이는 프레임에서만 트리거되므로 PrepareForUpdate 함수에서 mScrollWheel 변수는 리셋해야한다.
mState.Mouse.mScrollWheel = Vector2::Zero;
위 코드는 스크롤 휠이 프레임 1에서는 움직이지만 프레임 2에서는 움직이지 않는 다는 것을 보장한다. 그래서 프레임 2에서는 잘못된 스크 값을 전달받지 않는다. 그래서 프레임마다 스크롤 휠 상태 접근이 가능하다.
Vector2 scroll = state.Mouse.GetScrollWheel();
여러 가지 이유로 SDL에서 컨트롤러 입력을 감지하는 것은 마우스나 키보드 입력을 감지하는 것보다 더 어렵다. 먼저 컨트롤러는 마우스나 키보드보다 더 다양한 센서를 갖고있따. 예를 들어 표준 마이크로소프트 Xbox 컨트롤러는 2개의 아날로그 조이스티과 방향 패드, 4개의 표준 버튼, 3개의 특수 버튼, 2개의 범퍼 버튼, 그리고 2개의 트리거 등 데이터를 얻기 위한 많고 다양한 센서를 갖고 있다. 또한 PC/Mac 사용자는 하나의 키보드와 마우스를 가지지만 컨트롤러의 경우 여러 개의 컨트롤러를 연결하는 것이 가능하다. 마지막으로 컨트롤러는 핫 스와핑(hot swapping)을 지원하는데 이는 프로그램이 실행 중인 동안 컨트롤러를 연결하거나 분리하는 것이 가능하다는 것을 뜻한다. 이러한 요인은 컨트롤러 입력을 다루는데 복잡성을 증대시킨다. 컨트롤러를 사용하기 전에 앞서 컨트롤러를 다루는 SDL 서브시스템을 초기화해야한다. SDL 서브시스템을 활성화하기 위해서는 Game::Initialize의 SDL_Init 호출에 SDL_INIT_GAMECONTROLLER 플래그를 추가하면 된다.
SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMECONTROLLER)
컨트롤러 한 개 활성화하기
컨트롤러를 초기화하기 위해서는 SDL_GameControllerOpen 함수를 사용해야한다. 이 함수는 초기화가 성곡하면 SDL_Controller 구조체에 대한 포인터를 반환하며 실패하면 nullptr을 반환한다. 컨트롤러의 상태를 알아보기 위해서는 SDL_Controller 변수를 조회하면 된다. 우선 컨트롤러가 하나라고 생각하고 InputState 멤버 변수로 mController라는 SDL_Controller 포인터를 추가한다. 그리고 컨트롤러 0을 열기 위해 다음과 같은 함수를 추가한다.
mController = SDL_GameControllerOpen(0);
컨트롤러를 비활성화하려면 파라미터로 SDL_GameController 포인터를 취하는 SDL_GameControllerClose 함수를 호출한다. 플레이어가 컨트롤러를 갖고 있다는 걸 추측하고 싶지않다면 코드상에서 컨트롤러에 접근하려고 할 때마다 null 체크를 하도록한다.
버튼
SDL의 게임 컨트롤러는 다양한 버튼을 지원한다. SDL은 마이크로소프트 Xbox 컨트롤러의 버튼 이름을 미러링하는 명명 규칙을 사용한다. 예를 들어 버튼 이름은 A, B, X, Y이다.
위 표는 SDL에 정의된 다양한 버튼 상수를 나열한다. 여기서 *는 여러가지 가능한 값을 나타내는 와일드 카드다. 왼쪽 및 오른쪽 스틱 버튼은 사용자가 왼쪽/오른쪽 스틱을 물리적으로 누를 때 사용된다. 예를 들어 일부 게임에서는 전력 질주를 위해 오른쪽 스틱을 누른다. SDL에는 모든 컨트롤러 버튼의 상태를 동시에 조회하는 메커니즘을 갖고 있지 않다. 그래서 SDL_GameControllerGetButton 함수를 사용해 각 버튼을 개별적으로 조회해야한다. 컨트롤러 버튼 이름에 대한 열거형은 컨트롤러가 가질 수 있는 버튼의 최대 수인 SDL_CONTROLLER_BUTTON_MAX 멤버를 정의하고 있는데 이 값을 활용한다.
class ControllerState {
public:
friend class InputSystem;
// 버튼
bool GetButtonValue(SDL_GameControllerButton button) const;
ButtonState GetButtonState(SDL_GameControllerButton button) const;
bool GetIsConnected() const { return mIsConnected; }
private:
// 현재/이전 버튼
Uint8 mCurrButtons[SDL_CONTROLLER_BUTTON_MAX];
Uint8 mPrevButtons[SDL_CONTROLLER_BUTTON_MAX];
// 컨트롤러가 연결되어 있는가
bool mIsConnected;
};
위 코드처럼 ControllerState 클래스의 초기 버전은 현재의 버튼 상태와 이전의 버튼 상태에 대한 배열을 포함한다. 또한 코드는 컨트롤러가 연결되어 있는지 여부를 판단하기 위해 이진값을 갖고 있다. 마지막으로 클래스는 버튼 값과 버튼의 상태를 조회하는 함수에 대한 선언을 갖고 있다. 이제 ControllerState의 인스턴스를 InputState에 추가한다.
ControllerState Controller;
다음으로 InputSystem::Initialize에서 컨트롤러 0을 열기를 시도하고 mController 포인터가 nullptr인지 아닌지에 따라 mIsConnected 변수를 설정한다. 그리거 mCurrButtons와 mPrevButtons 둘 다 메모리 값을 초과한다.
mController = SDL_GameControllerOpen(0);
mState.Controller.mIsConnected = (mController != nullptr);
memset(mState.Controller.mCurrButtons, 0, SDL_CONTROLLER_BUTTON_MAX);
memset(mState.Controller.mPrevButtons, 0, SDL_CONTROLLER_BUTTON_MAX);
키보드와 마찬가지로 PrepareForUpdate 코드에서는 현재의 버튼 상태를 이전의 버튼 상태로 복사한다.
memcpy(mState.Controller.mPrevButtons,
mState.Controller.mCurrButtons,
SDL_CONTROLLER_BUTTON_MAX);
마지막으로 Update에서 mCurrButtons 배열을 반복하면서 해당 버튼에 대한 상태를 조회하는 SDL_GameControllerGetButton 함수의 호출 결과를 각 버튼의 요소값에 저장한다.
for (int i = 0; i < SDL_CONTROLLER_BUTTON_MAX; i++) {
mState.Controller.mCurrButtons[i] =
SDL_GameControllerGetButton(mController,
SDL_GameControllerButton(i));
}
위의 코드로 이제 키보드 및 마우스 버튼에서 사용했던 패턴처럼 특정한 게임 컨트롤러 버튼의 상태를 조회하는 것이 가능해졌다. 예를 들어 아래 코드는 컨트롤러의 A 버튼이 현재 프레임에서 상승 엣지를 갖고 있는지 확인한다.
if (state.Controller.GetButtonState(SDL_CONTROLLER_BUTTON_A) == EPressed)
아날로그 스틱과 트리거
SDL은 총 6개의 축을 지원한다. 각 아날로그 스틱은 2개의 축을 가진다. 하나는 x 방향이고 다른 하나는 y 방향이다. 또한 트리거 각각은 1개의 축을 가지고 있다.
위 표는 축의 리스트를 보여준다. 트리거의 경우 값의 범위는 0에서 32,767이며, 0은 트리거에 어떠한 입력도 없음을 뜻한다. 아날로그 스틱 축의 경우 값의 범위는 -32,767에서 32,767이며, 0은 스틱이 기울어지지 않고 중심을 맞추고 있다는 것을 나타낸다. 양의 y축은 아날로그 스틱의 아래쪽에 해당하며, 양의 x축 값은 오른쪽에 해당한다. 그러나 이러한 축의 연속적인 입력과 관련된 문제는 API에서 지정한 범위가 이론적인 것에 불과하다는 데 있다. 각 개별 장치는 부정확도를 가지기 때문이다. 예를 들어 가운데로 돌아가는 아날로그 스틱 중 하나를 움직인 후 손을 떼보면 이 부정확도에 대해 알아볼 수 있다. 스틱이 움직이지 않으므로 스틱의 x축과 y축에 대한 값으로 0을 기대하는 것은 합리적이다. 하지만 실제로 값은 0에 가깝긴 하지만 정확히 0은 아니다. 거꾸로 플레이어가 스틱을 계속해서 오른쪽으로 세게 유지한다면 스틱 x축이 알리는 값이 가깝겠지만 정확히 최대값과 일치하지는 않는다. 이 상황은 2가지 이유로 게임에서 문제가 된다. 먼저 원치 않는 입력이 발생해서 플레이어가 입력 축을 변경시키지 않았지만 게임은 뭔가가 발생하고 있다고 인식할 가능성이 있다. 또한 많은 게임에서 아날로그 스틱이 이 방향으로 얼마나 멀리 움직였는지를 기반으로 캐릭터를 이동시킨다. 그래서 스틱을 약간만 움직이면 캐릭터는 천천히 걷는 반면 스틱을 계속해서 한 방향으로 이동시키면 캐릭터를 전력질주하게 만든다. 그러나 축이 최댓값을 가질 때만 플레이어가 전력 질주하게 만든다면 플레이어는 결코 전력 질주하지 못할 것이다. 이 문제를 해결하기 위해 축의 입력을 처리하는 코드는 값을 필터링(filtering)해야 한다. 특히 0에 가까운 값을 0으로 해석하고 최소 최대에 가까운 값을 최솟값 또는 최댓값으로 해석해야한다. 또한 정수값을 정규화된 부동소수점 값 범위로 변환하면 입력 시스템을 사용하기가 편리해진다. 양과 음의 값을 모두 가지는 축은 정규화된 값으로 -1.0에서 1.0의 범위를 가진다.
위 그림은 하나의 축에 대한 필터링의 예이다. 선 위의 숫자는 필터링 전의 정수값이며 선 아래 숫자는 필터링 이후의 부동소수점 값이다. 0.0으로 해석하고 싶은 0에 가까운 영역은 데드 존(dead zone)이라고 부른다.
위 코드는 입력 시스템이 트리거와 같은 1차원 축을 필터링하는 데 사용하는 InputSystem::Filter1D 함수의 구현을 보여준다. 먼저 데드 존과 최대값에 대한 2개의 상수를 선언한다. 여기서 데드 존의 값은 250인데 이 값은 트리거에서 잘 동작한다. 그리고 삼항연산자를 사용해서 입력의 절대값을 얻는다. 이 값이 데드 존의 상수보다 작다면 0.0f를 반환한다. 그렇지 않으면 입력값을 데드존과 최대값 사이를 나타내는 분수값으로 변환한다. 예를 들어 deadZone과 maxValue 사이의 중간 입력값은 0.5f다. 그런 다음 분수값의 원래 입력 부호와 일치하는지를 확인하고 마지막으로 입력값이 최댓값 상수보다 더 큰 경우를 처리하기 위해 값을 -1.0에서 1.0의 범위로 한정한다. 데드 존이 5000인 Filter1D함수를 사용하면 5000의 입력값은 0.0f를 반환하고 -17500의 값은 -0.5f를 반환한다. Filter1D 함수는 오직 트리거와 같은 단일 축일 경우에만 잘 동작한다. 그러나 아날로그 스틱은 2개의 다른 축이 하나로 결합돼 있으므로 2차원으로 아날로그 스틱을 필터링하는 것이 더 좋다. 이제 왼쪽, 오른쪽 트리거를 위해 ControllerState에 2개의 float 값을 추가한다.
float mLeftTrigger;
float mRightTrigger;
InputSystem::Update에서는 두 트리거의 값을 읽기 위해 SDL_GameControllerGetAxis 함수를 사용한다. 그리고 이 값에 Filter1D 함수를 호출해서 값을 0.0에서 1.0의 범위로 반환한다(트리거는 음수를 가지지 않는다). 예를 들어 아래 코드는 mLeftTrigger의 값을 설정한다.
mState.Controller.mLeftTrigger =
Filter1D(SDL_GameControllerGetAxis(mController,
SDL_CONTROLLER_AXIS_TRIGGERLEFT));
그리고 GetLeftTrigger()와 GetRightTrigger() 함수를 추가해서 트리거 값에 접근한다. 예를 들어 아래 코드는 왼쪽 트리거 값을 얻는다.
float left = state.Controller.GetLeftTrigger();
2차원에서 아날로그 스틱 필터링하기
아날로그 스틱은 일반적으로 스틱의 방향을 플레이어 캐릭터가 이동하는 방향과 일치시킨다. 예를 들어 대각선 위쪽으로 스틱을 밀면 캐릭터 또한 화면상에서 해당 방향으로 이동한다. 이를 구현하기 위해서는 x축과 y축을 동시에 해석해야한다. x축과 y축에 개별적으로 Filter1D 함수를 사용하면 문제없다고 생각할 수 있겠지만, 그렇게 하면 문제가 발생한다. 플레이어가 스틱을 위쪽 방향으로 밀면 정규화된 벡터값은 이 된다. 한편 플레이어가 오른쪽 상단으로 스틱을 밀면 정규화된 벡터는 이 두 벡터의 길이는 다르며 이 벡터를 캐릭터가 이동하는 속도로 사용한다면 문제가 된다. 캐릭터는 한 방향으로 똑바로 이동하는 것보다 대각선으로 이동할 때 더 빠르게 이동하는 것이다. 길이가 1보다 큰 벡터를 정규화하면 되겠지만, 각 축을 개별적으로 다룬다는 것은 개발자가 데드 존과 최댓값을 사각형 범위로 해설한다는 것을 의미한다.
그래서 더 좋은 접근법은 위 그림처럼 두 축을 동심원으로써 해석하는 것이다. 사각형 경계는 원래 입력값을 나타내며 내부 원은 데드 존 그리고 바깥 원은 최대값을 나타낸다.
위 코드는 Filter2D의 함수 코드이며 아날로그 스틱의 x축과 y축을 인자로 받아 2차원상에서 필터링한다. 먼저 2D 벡터를 생성한뒤 벡터의 길이를 결정한다. 데드 존보다 작은 길이는 Vector2::Zero가 된다. 데드 존보다 큰 길이의 경우에는 데드 존과 최댓값 사이의 분수값이 되며 벡터의 길이를 이 분수값으로 설정한다. 그리고 ControllerState에 왼쪽 및 오른쪽 스틱에 대한 2개의 Vector2를 추가한다. InputSystem::Update는 각 스틱의 두 축에 대한 값을 얻어온 다음 Filter2D를 실행해서 최종 아날로스 스틱값을 얻는다. 예를 들어 아래 코드는 왼쪽 스틱을 필터링하고 컨트롤러의 상태 결과를 저장한다.
x = SDL_GameControllerGetAxis(mController, SDL_CONTROLLER_AXIS_LEFTX);
y = -SDL_GameControllerGetAxis(mController, SDL_CONTROLLER_AXIS_LEFTY);
mState.Controller.mLeftStick = Filter2D(x, y);
위 코드에서 y축 값을 반전시키는데 y축 값을 반전시키는 이유는 SDL이 +y가 아래쪽인 SDL 좌표계에서 y축 값을 반환하기 때문이다. 따라서 게임 좌표계에서 원하는 값을 얻으려면 값을 반전시켜야한다. 그리고 아래 코드와 같이 InputState를 통해 왼쪽 스틱값에 접근한다.
Vector2 leftStick = state.Controller.GetLeftStick();
복수개의 컨트롤러 지원
복수개의 컨트롤러 지원은 하나를 지원하는 것보다 좀 더 복잡하다. 먼저 게임 시작 시 모든 연결된 컨트롤러를 초기화하기 위해 모든 조이스틱을 반복하면서 각 조이스틱을 식별하기 위한 컨트롤러 감지 코드를 작성해야한다. 다음과 같이 각 컨트롤러를 개별적으로 여는 것이 가능하다.
for (int i = 0; i < SDL_NumJoyStick(); ++i)
{
// 이 조이스틱이 컨트롤러인가?
if (SDL_IsGameController(i))
{
// 이 컨트롤러를 사용하기 위해서 컨트롤러를 연다
SDL_GameController* controller = SDL_GameControllerOpen(i);
// SDL_GameController* 벡터에 포인터를 추가한다
}
}
다음으로 InputState를 변경해서 하나의 ControllerState가 아니라 복수개의 ControllerState를 포함하도록 수정한다. 또한 이런 다양한 컨트롤러 각각을 지원하기 위해 InputSystem의 모든 함수를 갱신한다. 핫 스와핑(게임 중에 컨트롤러를 추가/제거)을 지원하기 위해 SDL은 컨트롤러를 추가하거나 제거할 때 2가지 다른 이벤트를 생성한다.
현재 InputState에서 데이터를 사용할 때는 특정 입력 장치와 키가 직접 액션에 매핑하고 있다고 가정한다. 예를 들어 캐릭터가 스페이스바의 상승 엣지일 때 점프한다면 ProcessInput에 다음과 같은 코드를 추가하면된다.
bool shouldJump = state.Keyboard.GetKeyState(SDL_SCANCODE_SPACE)
== Pressed;
이 방법은 잘 동작하지만 추상적인 '점프' 액션을 정의하는 것이 좋다. 추상적인 점프 액션을 정의하고 난 후에는 게임 코드에서 점프가 스페이스바에 해당한다고 지정하는 메커니즘을 구현하면된다. 이를 지원하려면 추상적인 액션과 이 추상적인 액션에 대응하는 {장치, 버튼} 쌍 사이의 맵이 필요하다. 동일한 추상 액션에 여러 바인딩을 허용하면 시스템을 더욱더 향상시킬수 있다. 즉 스페이스파와 컨트롤러의 A 버튼을 동시에 '점프'로 바인딩시킬수 있다는 뜻이다. 이러한 추상 액션을 정의하면 얻는 또 다른 이점은 AI가 제어하는 캐릭터가 동일한 액션을 수행하기가 수월해지는 데 있다. AI가 점프해야한다면 '점프' 액션을 하도록 AI 캐릭터에게 명령을 내리기만 하면 된다. 별더의 입력 처리를 위한 코드 추가는 불필요하다. 추상적인 액션을 통해 향상되는 또다른 이점은 W 및 S키나 또는 컨트롤러의 축하나의 해당하는 'ForwardAxis'액션과 같이 축을 따라가는 이동의 정의를 가능하게 해주는데 있다. 이 액션을 사용하면 개발자는 게임의 캐릭터의 움직임을 구체적으로 지정하는 것이 가능해진다. 마지막으로 이런 타입 매핑을 사용하기 위해서는 파일로부터 매핑 데이터를 로드하는 메커니즘을 추가하면되는데, 이렇게 구현하면 디자이너나 사용자가 코드를 수정하지않고 매핑 설정을 하는 것이 가능하다.
이번 게임 프로젝트에서는 게임 컨트롤러로 우주선을 움직인다. 왼쪽 스틱은 우주선이 스틱 방향으로 이동하게 해주고, 오른쪽 스틱은 우주선의 방향으로 회전시킨다. 오른쪽 트리거는 레이저를 발사한다. 이 구조는 '트윈 스틱 슈터(twin stick shooter)' 게임으로ㅓ 대중화된 컨트롤 구조다. 입력 시스템이 왼쪽/오른쪽 스틱의 2D축을 반환하면 트윈 스틱 스타일 컨트롤을 구현하는 데는 그렇게 많은 코드가 필요하지 않다. 먼저 Ship::ActorInput에서 왼쪽 및 오른쪽 스틱의 데이터를 얻고 멤버 변수에 해당 데이터를 저장하기위해 아래와 같은 코드 라인을 추가한다. 플레이어가 오른쪽 스틱에서 완진히 손일 뗐다면 우주선이 초기각도 0으로 되돌아가지않도록 오른쪽 스틱 값 체크시 NearZero를 추가한다.
if (state.Controller.GetIsConnected()) {
mVelocityDir = state.Controller.GetLeftStick();
if (!Math::NearZero(state.Controller.GetRightStick().Length())) {
mRotationDir = state.Controller.GetRightStick();
}
}
Ship::UpdateActor에서 속도의 방향과 속력, 델타 시간을 토대로 액터를 이동시키는 다음과 같은 코드를 추가한다.
Vector2 pos = GetPosition();
pos += mVelocityDir * mSpeed * deltaTime;
SetPosition(pos);
mVelocity은 1보다 작을수 있으므로 이 코드는 왼쪽 스틱을 한 방향으로 얼마다 멀리 움직였는지에 따라 이동폭이 결정된다. 마지막으로 UpdateActor에는 액터를 회전시키기위한 각을 구하기 위해 atan2함수와 mRotationDir 벡터를 사용하는 다음 코드를 추가한다.
float angle = Math::atan2(mRotationDir.y, mRotation.x);
SetRotation(angle);