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;
前のフレームでの入力値を保存して、現フレームと比較するアプローチを一般化することで、None
、Pressed
、Released
、Held
の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にして、InputSystem
がKeyboardState
のメンバを操作しやすくする
メンバデータstruct InputState
にKeyboardState
のインスタンスである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にして、InputSystem
がMouseState
のメンバを操作しやすくする
メンバデータstruct InputState
にMouseState
インスタンスの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()
でコントローラーを初期化して、mController
にSDL_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にして、InputSystem
がControllerState
のメンバを操作しやすくする
メンバデータstruct InputState
にControllerState
インスタンスのController
を追加
InputSystem.cpp
InputSystem::Initialize()
コントローラー0をオープンした後、mIsConnected
変数にmController
ポインタのnullptr判定に基づく真偽値をセットする
mCurrButtons
とmPrevButtons
のメモリをクリアする
// 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()
デッドゾーンと最大値のための定数値deadZone
とmaxValue
を宣言する(必要であればユーザー設定値にする)
三項演算子を使って入力の絶対値を取得し、値に応じて-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_CONTROLLERDEVICEADDED
とSDL_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.”