GameDev #8 入力システム [Devlog #024]

Table of Contents

ゲーム開発者の教科書:Game Programming in C++ を読んで理解したことについてを要約します(内容の転載を避け、詳しく説明しすぎないように配慮します)

ゲームプログラミング in C++


入力システム

ここでは、入力デバイスをまとまったシステムに統合させる

入力システム

ゲームループの"入力処理"で入力デバイスの現在の状態を取得し、ゲームループの"ゲームワールド更新"でゲームワールドに影響を与える

ポーリング

SDL_GetKeyboardState()で取得した"キーボードの各キーの状態"をアクターのProcessInput()に渡し、アクターはその状態を各コンポーネントのProcessInput()に渡す

コンポーネントのProcessInput()では、フレームごとにキーの値を参照して判定する(ポーリングアプローチ(polling:定期的な問い合わせ))

ポジティブエッジとネガティブエッジ

キャラクターのジャンプを例にすると、

スペースキーの値が1のフレームで必ずキャラクターをジャンプさせるのではなく、スペースキーのポジティブエッジ(positive edge)が立つフレームでだけジャンプさせる

spacekeyLastという変数で1つ前のフレームの値を残し、“前のフレームの値が0"かつ"現在のフレームの値が1"のときだけジャンプさせる

// 例
if (spacekey == 1 and spacekeyLast == 0){
	character.jump();
}
spacekeyLast = spacekey;

前のフレームでの入力値を保存して、現フレームと比較するアプローチを一般化することで、NonePressedReleasedHeldの4種類の状態を簡単に管理できる(生の値もゲームには依然として必要)

イベント

毎フレームGame::ProcessInput()で、イベントがキューにあるのかをチェックし、選択したイベントにだけ応答する

今回は、必要な時にのみSDLイベントを使う(マウスホイールのスクロールなど)

SDL_GetKeyboardState()から取得されるキーボード状態が更新されるのは、メッセージポンプのループでSDL_PollEvents()を呼び出したあと

基礎的なInputSystemのアーキテクチャ

InputSystemクラスでデータを記録する

アクター・コンポーネントが欲しい情報は、ProseccInput()を経由してInputStateのヘルパー関数から得られるようにする

InputSystem.h

enum ButtonStateは4種類の状態を表す

class KeyboardState()はキーボード入力に対するヘルパー(※後で詳細を説明) )

struct InputStateはキーボード状態のメンバを持つ )

InputSystemクラスでは、Initialize()Shutdown()を持つ

PrepareForUpdate()SDL_PollEvents()の前に呼び出す

Update()SDL_PollEvents()の後に呼び出す

GetState()は、InputState型のメンバ変数mStateへのconst参照を返す

Game.h

InputSystemへのポインタmInputSystemを追加する

class InputSystem* mInputSystem;
Game.cpp
Game::Initialize()

InputSystemを生成し初期化する

// Initialize input system
mInputSystem = new InputSystem();
if (!mInputSystem->Initialize()) {
	SDL_Log("Failed to initialize input system");
	return false;
}
Game::Shutdown()

InputSystemをシャットダウンして削除する

mInputSystem->Shutdown();
delete mInputSystem;
Actor.h

ProcessInput()の宣言を変更する

アタッチされたすべてのコンポーネントのProcessInput()を呼び出すため、オーバーライドできない

// ProcessInput function called from Game (not overridable)
void ProcessInput(const struct InputState& state);

Actor自身の独自入力のためのオーバーライド可能なActorInput()の宣言を変更し、InputStateのconst参照を受け取るようにする

// Any actor-specific input code (overridable)
virtual void ActorInput(const struct InputState& state);
Component.h

ProcessInput()の宣言を変更する

// Process input for this component
virtual void ProcessInput(const struct InputState& state) {}
Game.cpp
Game::ProcessInput()
mInputSystem->PrepareForUpdate();	// ※

SDL_Event event;
while (SDL_PollEvent(&event)) {
	switch (event.type) {
		case SDL_QUIT:
			mIsRunning = false;
			break;
		case SDL_MOUSEWHEEL:
			mInputSystem->ProcessEvent(event);
			break;
		default:
			break;
	}
}

mInputSystem->Update();	// ※
const InputState& state = mInputSystem->GetState();	// ※

if (state.Keyboard.GetKeyState(SDL_SCANCODE_ESCAPE) == EReleased) {
	mIsRunning = false;
}

mUpdatingActors = true;
for (auto actor : mActors) {
	actor->ProcessInput(state);
}
mUpdatingActors = false;

以上がInputSystemが入力デバイスをサポートするのに必要な基本部分になる

後ほど、デバイスごとに状態をカプセル化する新しいクラスを追加し、そのクラスのインスタンスをInputState構造体に追加する

キーボード入力

InputSystem.h
class KeyboardState

現在の状態を指すポインタmCurrStateと、1つ前の状態を保存する配列mPrevState[SDL_NUM_SCANCODES]を追加する(SDL_NUM_SCANCODESはSDLがキーボードを管理するバッファサイズ)

GetKeyValue()はキーの現在値をbool値で返す

GetKeyState()は4種のボタン状態を返す

クラスInputSystemをfriendにして、InputSystemKeyboardStateのメンバを操作しやすくする

メンバデータstruct InputStateKeyboardStateのインスタンスであるKeyboardを追加する

InputSystem.cpp
InputSystem::Initialize()

mCurrStateポインタを設定し、mPrevStateのメモリをゼロクリアする

// Keyboard
// Assign current state pointer
mState.Keyboard.mCurrState = SDL_GetKeyboardState(NULL);
// Clear previous state memory
memset(mState.Keyboard.mPrevState, 0, SDL_NUM_SCANCODES);
InputSystem::PrepareForUpdate()

現在の状態を"前回のバッファ"にコピーする(SDL_PollEvents()の呼び出し前に)

// Copy current state to previous
// Keyboard
memcpy(mState.Keyboard.mPrevState, mState.Keyboard.mCurrState, SDL_NUM_SCANCODES);
KeyboardState::GetKeyValue()

mCurrStateバッファををインデックス参照して、値が1ならtrue、0ならfalseを返す

KeyboardState::GetKeyState()

現在のフレームと前のフレームのキーの状態から、4種類のボタン状態を判定する

マウス入力

ポジション入力には絶対と相対の2つのモードがある

SDLは、スクロールホイールのデータをイベント経由でしか渡さない

カーソル表示は、SDL_ShowCursor()で制御する

ボタンと位置

SDL_GetMouseState()は、マウスの位置とボタンの状態を取得する(戻り値のビット列がボタンの状態を示し、渡すint型変数が座標を示す)

SDLの2次元座標系では左上隅が(0, 0)になる

buttons変数が取得されている時、左マウスボタンが押されている場合、leftIsDownはtrueになる

int x = 0, y = 0;
Uint32 buttons = SDL_GetMouseState(&x, &y);
bool leftIsDown = (buttons & SDL_BUTTON(SDL_BUTTON_LEFT)) != 0;

SDL_BUTTON()マクロは、ボタン定数に基づいて1をビットシフトする
SDL_BUTTON(SDL_BUTTON_LEFT)は、1 << ((1)-1)0x0001
SDL_BUTTON(SDL_BUTTON_MIDDLE)は、1 << ((2)-1)0x0002

class MouseState

32ビット符号なし整数でボタンの状態(現在mCurrButtonsと1つ前mPrevButtons)を保存

Vector2でマウスポジションmMousePosを保存する

クラスInputSystemをfriendにして、InputSystemMouseStateのメンバを操作しやすくする

メンバデータstruct InputStateMouseStateインスタンスのMouseを追加

InputSystem.cpp
InputSystem::Initialize()
// Mouse (just set everything to 0)
mState.Mouse.mCurrButtons = 0;
mState.Mouse.mPrevButtons = 0;
InputSystem::PrepareForUpdate()

現在のボタン状態を"1つ前のボタン状態"にコピーする

// Mouse
mState.Mouse.mPrevButtons = mState.Mouse.mCurrButtons;
InputSystem::Update()

SDL_GetMouseState()MouseStateのすべてのメンバを更新する

// Mouse
int x = 0, y = 0;
mState.Mouse.mCurrButtons = SDL_GetMouseState(&x, &y);
mState.Mouse.mMousePos.x = static_cast<float>(x);
mState.Mouse.mMousePos.y = static_cast<float>(y);

相対移動

SDLには、相対(relative)マウスモードがサポートされており、GetRelativeMouseState()を2回呼び出す間に発生した移動量を取得できる

相対マウスモードでは、マウスポインタを隠し、ウィンドウ内にマウスをロックし、フレームごとにマウスをセンタリングする

InputSystem.cpp
InputSystem::SetRelativeMouseMode()

相対マウスモードの有効/無効を切り替える

MouseStateのbool型変数であるmIsRelativeは、相対マウスモードの状態を表す

InputSystem::Update()

モードに応じてマウス入力の関数を使い分ける

スクロールホイール

SDLは、スクロールホイールの状態をポーリングする関数を提供せず、SDL_MOUSEWHEELイベントを呼び出す

Game.cpp
Game::ProcessInput()
SDL_Event event;
while (SDL_PollEvent(&event)) {
	switch (event.type) {
		case SDL_QUIT:
			mIsRunning = false;
			break;
		case SDL_MOUSEWHEEL:
			mInputSystem->ProcessEvent(event);
			break;
		default:
			break;
	}
}
InputSystem.h
class MouseState

垂直と水平方向のスクロールを保存するメンバ変数mScrollWheelを追加する

// Motion of scroll wheel
Vector2 mScrollWheel;
InputSystem.cpp
InputSystem::ProcessEvent()

event.wheel構造体からスクロールホイールのx、y値を取得する

switch (event.type) {
	case SDL_MOUSEWHEEL:
		mState.Mouse.mScrollWheel = Vector2(static_cast<float>(event.wheel.x), static_cast<float>(event.wheel.y));
		break;
	default:
		break;
}
InputSystem::PrepareForUpdate()

mScrollWheel変数をクリアする

mState.Mouse.mScrollWheel = Vector2::Zero;

コントローラー入力

コントローラーを使う前に、SDLサブシステムを初期化する必要がある

Game.cpp

Game::Initialize()

SDL_INIT_GAMECONTROLLERフラグを追加する

SDL_Init(SDL_INIT_VIDEO|SDL_INIT_AUDIO|SDL_INIT_GAMECONTROLLER);

コントローラーを1つだけ使う

InputSystem.h

SDL_GameController*ポインタのメンバ変数mControllerを追加する

InputSystem.cpp
InputSystem::Initialize()

SDL_GameControllerOpen()でコントローラーを初期化して、mControllerSDL_GameController構造体へのポインタを返す

DL_GameControllerOpen(0)でコントローラー0をオープンする

※ サポート外のコントローラーは、SDL_GameControllerAddMappingsFromFile()を使って、ファイルからコントローラーマッピングの設定を行って対応する https://github.com/gabomdq/SDL_GameControllerDB

ボタン

SDLは、SDL_GameControllerGetButton()で、それぞれのボタンを個別に問い合わせる必要がある

InputSystem.h
class ControllerState

現在と1つ前のボタン状態を保存する配列mCurrButtons[]mPrevButtons[]を用意する

mIsConnectedは、コントローラーが接続されているかを示す

クラスInputSystemをfriendにして、InputSystemControllerStateのメンバを操作しやすくする

メンバデータstruct InputStateControllerStateインスタンスのControllerを追加

InputSystem.cpp
InputSystem::Initialize()

コントローラー0をオープンした後、mIsConnected変数にmControllerポインタのnullptr判定に基づく真偽値をセットする

mCurrButtonsmPrevButtonsのメモリをクリアする

// Get the connected controller, if it exists
mController = SDL_GameControllerOpen(0);
// Initialize controller state
mState.Controller.mIsConnected = (mController != nullptr);
memset(mState.Controller.mCurrButtons, 0, SDL_CONTROLLER_BUTTON_MAX);
memset(mState.Controller.mPrevButtons, 0, SDL_CONTROLLER_BUTTON_MAX);
InputSystem::PrepareForUpdate()

現在のボタン状態を"1つ前のボタン状態"にコピーする

// Controller
memcpy(mState.Controller.mPrevButtons, mState.Controller.mCurrButtons, SDL_CONTROLLER_BUTTON_MAX);
InputSystem::Update()

各ボタンの状態を問い合わせてmCurrButtons配列に格納する

// Controller
// Buttons
for (int i = 0; i < SDL_CONTROLLER_BUTTON_MAX; i++) {
	mState.Controller.mCurrButtons[i] = SDL_GameControllerGetButton(mController, SDL_GameControllerButton(i));
}

アナログスティックとトリガー

SDLは合計6つの軸をサポートする(2つの2軸アナログスティックと、2つの1軸トリガー)

トリガーの値の範囲は0~32767で、アナログスティックの値の範囲は-32768~32767である($y$軸正は下、$x$軸正は右)

デバイスごとの不正確さを考慮し、勝手な入力 = ファントム入力(phantom input)などが発生しないように、軸からの入力にフィルターをかけて遊びをいれる(デッドゾーン)

InputSystem.cpp
InputSystem::Filter1D()

デッドゾーンと最大値のための定数値deadZonemaxValueを宣言する(必要であればユーザー設定値にする)

三項演算子を使って入力の絶対値を取得し、値に応じて-1.0fから1.0fまでの範囲にクランプする

// A value < dead zone is interpreted as 0%
const int deadZone = 250;
// A value > max value is interpreted as 100%
const int maxValue = 30000;

float retVal = 0.0f;

// Take absolute value of input
int absValue = input > 0 ? input : -input;
// Ignore input within dead zone
if (absValue > deadZone) {
	// Compute fractional value between dead zone and max value
	retVal = static_cast<float>(absValue - deadZone) /
	(maxValue - deadZone);
	// Make sure sign matches original value
	retVal = input > 0 ? retVal : -1.0f * retVal;
	// Clamp between -1.0f and 1.0f
	retVal = Math::Clamp(retVal, -1.0f, 1.0f);
}

return retVal;
InputSystem.h
class ControllerState

左右それぞれの1軸トリガーのために、2つのfloat型変数を追加する

// Left/right trigger
float mLeftTrigger;
float mRightTrigger;
InputSystem.cpp
InputSystem::Update()

SDL_GameControllerGetAxis()で取得した値をFilter1D()で0.0fから1.0fまでの範囲に変換する

// Triggers
mState.Controller.mLeftTrigger = Filter1D(SDL_GameControllerGetAxis(mController, SDL_CONTROLLER_AXIS_TRIGGERLEFT));
mState.Controller.mRightTrigger = Filter1D(SDL_GameControllerGetAxis(mController, SDL_CONTROLLER_AXIS_TRIGGERRIGHT));

アナログスティックを2次元でフィルタリングする

アナログスティックの場合、Filter1Dを独立して$x,y$軸に適用するのではなく、2次元の入力を同心円として解釈する(2次元のベクトルを作り、そのベクトルの長さを計算する)

デッドゾーンよりも長さが短ければVector2::Zero、長ければデッドゾーンと最大値の間で小数値を求める

InputSystem.cpp
InputSystem::Filter2D()
const float deadZone = 8000.0f;
const float maxValue = 30000.0f;

// Make into 2D vector
Vector2 dir;
dir.x = static_cast<float>(inputX);
dir.y = static_cast<float>(inputY);

float length = dir.Length();

// If length < deadZone, should be no input
if (length < deadZone) {
	dir = Vector2::Zero;
}
else {
	// Calculate fractional value between
	// dead zone and max value circles
	float f = (length - deadZone) / (maxValue - deadZone);
	// Clamp f between 0.0f and 1.0f
	f = Math::Clamp(f, 0.0f, 1.0f);
	// Normalize the vector, and then scale it to the
	// fractional value
	dir *= f / length;
}

return dir;
InputSystem.h
class ControllerState

左右2つの2軸アナログスティックのため、2つのVector2型変数を追加する

// Left/right sticks
Vector2 mLeftStick;
Vector2 mRightStick;
InputSystem.cpp
InputSystem::Update()

アナログスティックの値を取得し、Filter2D()で最終的な値を求める

$y$軸の正負を逆にする(+$y$を上方とする)

// Sticks
x = SDL_GameControllerGetAxis(mController, SDL_CONTROLLER_AXIS_LEFTX);
y = -SDL_GameControllerGetAxis(mController, SDL_CONTROLLER_AXIS_LEFTY);
mState.Controller.mLeftStick = Filter2D(x, y);

x = SDL_GameControllerGetAxis(mController, SDL_CONTROLLER_AXIS_RIGHTX);
y = -SDL_GameControllerGetAxis(mController, SDL_CONTROLLER_AXIS_RIGHTY);
mState.Controller.mRightStick = Filter2D(x, y);

複数のコントローラーをサポートする

すべてのジョイスティックをループ処理し、どれがコントローラーかを調べ、個々のコントローラーをオープンする

当プロジェクトでは実装しない

for(int i = 0; i < SDL_NumJoysticks(); ++i){
	if(SDL_IsGameController(i)){
		SDL_GameController* controller = SDL_GameControllerOpen(i);
	}
}

ホワイトスワップ(コントローラーの抜き差し)をサポートする際は、SDLがイベントSDL_CONTROLLERDEVICEADDEDSDL_CONTROLLERDEVICEMOVEDを生成することを利用する

入力のマッピング

抽象的なアクションと、そのアクションに対応する{デバイス, ボタン}ペアの連想配列を用意することが理想

さらに、複数バインディングを同じ抽象アクションに許すようにしたい

これにより、AI制御のキャラクターのアクション実行が容易になるほか、キーボードとコントローラーの"軸に沿った"動きを定義できる

ファイルからマッピングを読み込む機構も追加することで、マッピングをユーザーが定義できるようになる

ゲームプロジェクト

Devlog #019での実装にInputSystemを追加実装する

Ship.cpp

Ship::ActorInput()

左右のアナログスティックから値を取得し、メンバ変数に保存する

if (state.Controller.GetIsConnected()) {
	mVelocityDir = state.Controller.GetLeftStick();
	if (!Math::NearZero(state.Controller.GetRightStick().Length())) {
		mRotationDir = state.Controller.GetRightStick();
	}
}

Ship::UpdateActor()

速度の方向に基づいてアクターを動かす

mVelocityDirが1未満の長さの場合、減速する

// Update position based on velocity
Vector2 pos = GetPosition();
pos += mVelocityDir * mSpeed * deltaTime;
SetPosition(pos);

アクターを回転させる

// Update rotation
float angle = Math::Atan2(mRotationDir.y, mRotationDir.x);
SetRotation(angle);

※ 3次元で使ったクォータニオンによる回転ではなく、角度に1個のfloatを使う2次元のアクタークラスであるため実行可能となる

まとめ

参考文献

入力を記録して再生する方法についての記事:
“Game Input Recording and Playback.“Game Programming Gems 2

Oculus SDKのドキュメント:
Oculus PC SDK.

入力ラグの計測方法について:
“Programming Responsiveness.”