はじめに
なぜキーコンフィグなのか?
職業柄ふだんは学生さんにゲーム開発を教えてるのですが、DXライブラリで作らせると、GetHitKeyStateAllでキーボードオンリーのゲームを作るか、よくてもGetJoypadInputState(DX_INPUT_KEY_PAD1)を使用して作っています。
それ自体は良いのですが、 DXライブラリのパッドの定数の割り当ては例えばXboxコントローラとはかなり食い違っています
- Startボタン→PAD_INPUT_R
- Selectボタン→PAD_INPUT_L
- Aボタン→PAD_INPUT_A
- Bボタン→PAD_INPUT_B
- Xボタン→PAD_INPUT_C
- Yボタン→PAD_INPUT_X
- 左ショルダー→PAD_INPUT_Y
- 右ショルダー→PAD_INPUT_Z
- 左レバー押し込み→PAD_INPUT_START
- 右レバー押し込み→PAD_INPUT_M
このように合ってるのもあれば、違ってるのが結構多いことに気づくと思います(違うものの方が多い!)
「じゃあそれに合わせてゲーム側を設定すればええやん!」と思われるかもしれませんが、これはあくまでもXboxコントローラとの対応関係で、他のPADを持ってきたらまた全然違う事もあり得ます。
そういうところまで考えるとキーコンフィグ(PADコンフィグ)が必要なんじゃないかな~と思うわけです。とりあえずここまで読んで「必要ねえよ!」と思う方はそっ閉じしていただいて構いません。
あくまでも簡易版です
今回解説するのはあくまでも簡易版です。アナログ軸までやるとかなり面倒っぽいので「ボタンの割り当てを変更できるように」程度にしましょう。
またゲーム中のメニューからキーの変更ができるように作りますが、UnrealEngineの設定のようにその場で入力の定義を増やしたり割り当てボタンの種類を増やしたりはしません。
ゲーム内で定義したup,down,right,left,jump,attack,change,ok,cancel,pauseに対する各キーの割り当てをその場で定義するだけです。
さて、言い訳も済んだところで実際のコードと解説に入っていきます。
コードについて
注意点
コードについての注意点ですが、使用するエディタはVisualStudio2022。
文字セットはプロジェクトの設定でマルチバイトではなくデフォルトのUnicodeです。また、C++標準はC++20を使用しています。
使用するコンテナ等
使用するSTLコンテナはmap(もしくはunordered_map)とvector。あと文字列用にstring(もしくはwstring)です。 この辺が全然わからない場合は、事前に他の解説記事等で予習しておいてください。
設計思想
入力と反応の間にワンクッション
初心者の間は、入力情報を取得後すぐにゲームのアクションにつなげると思いますが、それは入力とアクションとが強い結合になってしまっているため柔軟性に欠けます。それだと勿論キーコンフィグもできません。
そこで、入力と反応の間にワックッションを挟みます。例えばPAD_INPUT_Aを直接攻撃に結びつけるのではなく、"attack"という文字列とPAD_INPUT_Aを対応させる「テーブル」的なものを作っておいて、ゲーム側から「"attack"が押されたか?」という指示があれば、"attack"に紐づいた入力を調べてその結果を返します。
これだけであれば別にif文で一つ一つやってもいいのですが、それだと他のボタンへ切り替えられませんね?そう。他のボタンへ切り替え可能にするのが今回の目的ですので、そのためにmapやvectorを使用するわけです。
その他
今回は入力コンフィグをすることだけを目的とするので、イベントドリブンなつくりにはしません。それはまた別の機会に解説しようと思います。
また、複数プレイを想定せず、1Pのみを考えます。入力はパッドのボタン(方向キー含む)、キーボード、マウスクリックとします。
キーコンフィグはバイナリでセーブ・ロードを行います。
Inputクラス
今回はキーコンフィグの部分とそのテストができる状態までを解説する予定です。で、その入力のメインになるInputを作ります。
が、その前に入力種別(InputType)と、1つ1つの入力情報(InputInfo)を作ります。
InputType(列挙型)
/// <summary>
/// 入力種別
/// </summary>
enum class InputType {
keyboard,
gamepad,
mouse
};
はい、これらは入力情報がなんなのか?を区別するための定義ですね。
InputMapInfo(構造体)
/// <summary>
/// 入力対応マップに使用する
/// </summary>
struct InputMapInfo {
InputType type;//入力種別
int buttonID;//キーボードもパッドもマウスもintで指定できるためbuttonIDとします
};
Inputクラス本体
これは、前述した実際の入力とゲーム側のアクション定義のためのテーブルを内包し、ゲーム側からのアクションチェック要求にこたえるインターフェイスを提供するクラスです。つまり…。
using InputActionMap_t = std::map<std::wstring, std::vector<InputMapInfo>>;
//入力
class Input
{
private:
InputActionMap_t inputActionMap_;//入力とアクションの対応テーブル
std::map<std::wstring, bool> currentInput_;//現在の押下状態
std::map<std::wstring, bool> lastInput_;//直前の押下状態
public:
Input();
/// <summary>
/// 入力情報を更新する(毎フレーム呼び出してください)
/// </summary>
void Update();
/// <summary>
/// 入力チェック(押下状態)
/// </summary>
/// <param name="action">調べたいアクションの名前</param>
/// <returns>true 押されてる / false 押されてない</returns>
bool IsPressed(const std::wstring& action)const;
/// <summary>
/// 入力チェック(トリガー状態[押した瞬間])
/// </summary>
/// <param name="action">調べたいアクションの名前</param>
/// <returns>true 今押された / false 押されてないか押しっぱなし</returns>
bool IsTriggered(const std::wstring& action)const;
};
キーコンフィグをいじるのは後にするとして、ひとまずはアクションと実際の入力が対応している部分を作りましょう。まずコンストラクタにて例えば"OK"に当たる部分ならば
inputActionMap_[L"OK"] = { {InputType::keyboard,KEY_INPUT_RETURN},{InputType::gamepad,PAD_INPUT_R} };//RがXboxコンのstartにあたる
のように定義します。一応解説しておきますと、map<文字列,ベクタ配列>の連想配列で、値がまた配列となっています。文字列はアクションを表す文字列でベクタ配列が実際に対応する入力になるわけです。
例えばOKが押されたかどうかを知りたければ、この配列を全部見てみて1つでも押下されていれば「押されている」とみなします。実際は全部見る必要はなく最初に押されてるものを見つけた時点で押されてると見做します。逆に押されてない場合は全部見ることになります。
さて、他のキーも定義しましょう。
inputActionMap_[L"OK"] = { {InputType::keyboard,KEY_INPUT_RETURN},{InputType::gamepad,PAD_INPUT_R} };//RがXboxコンのstartにあたる
inputActionMap_[L"pause"]= { {InputType::keyboard,KEY_INPUT_P}, {InputType::gamepad,PAD_INPUT_L} };//LがXboxコンのselectにあたる
inputActionMap_[L"cancel"]= { {InputType::keyboard,KEY_INPUT_ESCAPE}, {InputType::gamepad,PAD_INPUT_Z} };//右ショルダー
inputActionMap_[L"up"]= { {InputType::keyboard,KEY_INPUT_UP}, {InputType::gamepad,PAD_INPUT_UP} };
inputActionMap_[L"down"]= { {InputType::keyboard,KEY_INPUT_DOWN}, {InputType::gamepad,PAD_INPUT_DOWN} };
inputActionMap_[L"left"]= { {InputType::keyboard,KEY_INPUT_LEFT}, {InputType::gamepad,PAD_INPUT_LEFT} };
inputActionMap_[L"right"]= { {InputType::keyboard,KEY_INPUT_RIGHT}, {InputType::gamepad,PAD_INPUT_RIGHT} };
inputActionMap_[L"attack"]= { {InputType::keyboard,KEY_INPUT_Z}, {InputType::gamepad,PAD_INPUT_C} };//XコンX
inputActionMap_[L"jump"]= { {InputType::keyboard,KEY_INPUT_X}, {InputType::gamepad,PAD_INPUT_A} };//XコンA
inputActionMap_[L"change"]= { {InputType::keyboard,KEY_INPUT_C}, {InputType::gamepad,PAD_INPUT_X} };//XコンY
あとはこれを実際の押下情報と対応させる部分を作ればいいわけです。それをやるのがUpdate関数です。みてみましょう。
void Input::Update()
{
//ひとまず全ての入力を取得する
char keystate[256] = {};
int padState = {};
int mouseState = {};
GetHitKeyStateAll(keystate);
padState=GetJoypadInputState(DX_INPUT_PAD1);
mouseState=GetMouseInput();
//それぞれのアクション名に割り当たっている全ての入力をチェック
for (const auto& mapInfo : inputActionMap_) {
bool isPressed = false;
for (const auto& inputInfo : mapInfo.second) {
isPressed = (inputInfo.type == InputType::keyboard && keystate[inputInfo.buttonID]) ||
(inputInfo.type == InputType::gamepad && padState&inputInfo.buttonID) ||
(inputInfo.type == InputType::mouse && mouseState&inputInfo.buttonID);
if (isPressed) {//ヒットしたらループを抜ける
break;
}
}
currentInput_[mapInfo.first] = isPressed;
}
}
ご覧のようにまず、全ての入力情報を取得しています。そしてひとつでもヒットしたら入力をtrueにしてループを抜けています。
では、IsPressed関数はどうなるかというと、単純です。currentInput_の中身を返せばいいだけですので
bool Input::IsPressed(const std::wstring& action) const
{
auto it = currentInput_.find(action);
if (it == currentInput_.end()) {//未定義のボタン名が来たら無条件でfalseを返す(若しくはassert起こす)
return false;
}
else {
return it->second;
}
}
となります。このときの注意点は
return currentInput_[action];
という訳にはいかないという事です。これをOKにするには関数の後ろのconstを消さなきゃいけないのですが、それは設計的にちょっといやなので、上記のようなコードになっています。もしなぜconstが必要なのか、なぜcurrentInput_[action]でエラーが出るか分からない人は、ひとまず関数のお尻のconstを消してもらっても構いません。理由が分かってからconstつけるようなプログラミングを行っても問題ないと思います(理由がわからないまま闇雲に従っても…ネ)
さて、ひとまずmain.cppにでもテストコードを書いて確認してみましょう。
int WINAPI WinMain(_In_ HINSTANCE , _In_opt_ HINSTANCE , _In_ LPSTR , _In_ int ){
ChangeWindowMode(true);
DxLib_Init();
SetDrawScreen(DX_SCREEN_BACK);
Input input;
while (ProcessMessage() != -1) {
ClearDrawScreen();
input.Update();
if (input.IsPressed(L"OK")) {
DrawString(50,50,L"OK is pressed",0xffffff);
}
ScreenFlip();
}
DxLib_End();
return 0;
}
ここまで正しくかけていれば、エンターキーもしくはstartボタンを押すと"OK is pressed"と表示されると思います。
あと、IsTrigger関数の方は簡単です。押した瞬間を取りたいので「今押されてて、直前で押されてない」が分かればいいわけです。
なお、mapやvectorには=による代入演算子が定義されているため、内容のコピーは=で行えばいいわけです。
なので、Update関数の先頭で
lastInput_ = currentInput_;
と書けばコピーされ、あとはIsTrigger関数を
bool Input::IsTriggered(const std::wstring& action) const
{
if (IsPressed(action)) {
auto it = lastInput_.find(action);
if (it == lastInput_.end()) {//未定義のボタン名が来たら無条件でfalseを返す(若しくはassert起こす)
return true;
}
else {
return !it->second;
}
}
else {
return false;
}
}
このように定義すればいいわけです。テストコードも、IsPressedの下にIsTriggeredを書けばできます。
input.Update();
if (input.IsPressed(L"OK")) {
DrawString(50,50,L"OK is pressed",0xffffff);
}
if (input.IsTriggered(L"OK")) {
DrawString(50, 70, L"OK is triggered", 0xffffff);
}
いかがでしょうか?これでひとまず「ワンクッション置いた入力」の実装ができました。
キーの書き換え
さて、次の大きな命題ですが、キーコンフィグと言うからには、アクションと入力の割り当ての書き換えを実装する必要があります。
とはいえ、それほど難しいことではありません。Inputクラス内にRewriteInputActionなどと言う関数を作り、それで対象のアクションの対応関係を書き換えれば済む話だからです。
上書き関数RewriteInputActionの定義
void Input::RewriteInputAction(const std::wstring& action, const InputMapInfo& info)
{
auto& table= inputActionMap_[action];
bool isHit = false;
for (auto& elem : table) {
if (elem.type == info.type) {
elem.buttonID = info.buttonID;
isHit = true;
return;
}
}
table.push_back(info);
}
少し雑に書きましたが、入力タイプ(キーボード、パッド、マウス)が既に定義されているものなら上書きします(元のは消えます)。で、入力タイプが元に無かったものなら、今回定義したものが追加されます。
今回の解説では削除は実装しません。あしからず。
では、テストコードとしてメインループ内で以下のように記述してみます。
if (input.IsTriggered(L"change")) {
InputMapInfo info = {InputType::keyboard,KEY_INPUT_SPACE};
input.RewriteInputAction(L"OK", info);
info = { InputType::gamepad,PAD_INPUT_A};
input.RewriteInputAction(L"OK", info);
info = { InputType::mouse,MOUSE_INPUT_LEFT};
input.RewriteInputAction(L"OK", info);
}
今回の定義では"change"はキーボードのCに割り当てておりますので、Cを押してみてください。そうするとENTERやスタートボタンではOKに反応しなくなり、代わりにスペースキー、Aボタン、左クリックに反応するようになるかと思います。
「やっぱりやーめた!」機能
キーの書き換えについてですが、途中で「やっぱり書き換えたくない」という事もあります。ただし現状のままではそのまま書き換えてしまっているため、キャンセルもできないですし、複数のアクションに同じキーを割り当てたりして元に戻れなくなったりします。
なので、色々書き換えを行っても「決定(コミット!)」を行わない限り、実際のキー対応関係は変化しないようにしてみます。つまりRewriteInputActionで書き換えられてしまうのはあくまでも内部の一時テーブルで、これをコミットして初めて実際のキーコンフィグが書き換わるようにします。
やる事は簡単です。まず、inputActionMap_と同じ型のtempActionMap_を用意します。そしてコンストラクタの時にinputActionMap_の値をコピーしておきます
//宣言
InputActionMap_t tempActionMap_;//キー書き換え一時テーブル
//コンストラクタ内
tempActionMap_ = inputActionMap_;
で、先ほどのRewrite~の中身を全部tempActionMap_に入れ替えます。
void Input::RewriteInputAction(const std::wstring& action, const InputMapInfo& info)
{
auto& table= tempActionMap_[action];//全部って言ってもここだけ
bool isHit = false;
for (auto& elem : table) {
if (elem.type == info.type) {
elem.buttonID = info.buttonID;
isHit = true;
return;
}
}
table.push_back(info);
}
勿論このままでは一生変更が反映されないためCommit()関数を作ります。コピーするだけなので簡単ですね。
void Input::Commit()
{
inputActionMap_ = tempActionMap_;
}
とりあえずattackボタンでコミットするようにしてみましょう(テストなので適当)
if (input.IsTriggered(L"attack")) {
input.Commit();
}
はい、これで、Rewriteの時点では書き換わらずに、Commitで初めて書き換わる事が分かったと思います。
デフォルトに戻す機能
次に、格ゲーなどによくある「デフォルトに戻す機能」を作りましょう。これも簡単ですね。初期化の段階でデフォルト(defaultActionMap_)にコピーしておいて、RollbackDefault関数でそのdefaultActionMap_の内容をinputActionMap_に戻せばいいだけです。
//宣言
InputActionMap_t defaultActionMap_;//デフォルトキー保存テーブル
//コンストラクタ内
defaultActionMap_= inputActionMap_;
void Input::RollbackDefault()
{
inputActionMap_ = defaultActionMap_;
}
はい、終わりです。簡単ですね。次は"cancel"アクションで戻してみましょうか。
if (input.IsTriggered(L"cancel")) {
input.RollbackDefault();
}
キーコンフィグを保存
さて、ここまできたら、いよいよキー情報をストレージに保存するだけです。名前もそのままinput.configにしましょう。特に深く考えずバイナリで保存します。色々セキュアじゃないですが、その辺は各自でやってください。この記事はあくまでもヒントくらいに思ってください。
保存すべき情報
保存すべき情報は「アクション数」「アクション名(文字列サイズ&文字列)」「対応キー数(ベクタサイズ)」「実際のベクタ配列の内容(種別とコード)」ていう感じですね。バイナリで保存しましょう。
void Input::SaveConfigFile(const std::string& path)
{
FILE* fp=nullptr;
auto error=fopen_s(&fp,path.c_str(),"wb");
if (fp == nullptr)return;
int actionCount = inputActionMap_.size();
fwrite(&actionCount, sizeof(actionCount), 1, fp);//アクション数書き込み
for (const auto& inputInfo: inputActionMap_) {
int nameLen=inputInfo.first.size();
auto wlen=wcslen(inputInfo.first.c_str());
fwrite(&nameLen, sizeof(nameLen), 1, fp);//アクション名サイズ書き込み
fwrite(inputInfo.first.c_str(), sizeof(inputInfo.first[0]),nameLen , fp);//アクション名書き込み
int inputTypeCount=inputInfo.second.size();
fwrite(&inputTypeCount, sizeof(inputTypeCount), 1, fp);//入力種別数書き込み
fwrite(inputInfo.second.data(), sizeof(inputInfo.second[0]), inputTypeCount, fp);
}
fclose(fp);
}
はい、ちょっと雑になってきましたが、許してね。時間ギリギリなの…。あと、ここでnameLenなどにautoを使うのは、やめようね。
バイト数はきっちり把握しておく必要があるので、uint32_tなどが使えればいいけど、せめてintとかにしておきましょう。
キーコンフィグを読み込み
Saveの時の逆ですね。はい。
void Input::LoadConfigFile(const std::string& path)
{
inputActionMap_.clear();//アクションマップクリア
FILE* fp = nullptr;
auto error = fopen_s(&fp, path.c_str(), "rb");
if (fp == nullptr)return;
int actionCount = 0;
fread_s(& actionCount, sizeof(actionCount),sizeof(actionCount), 1, fp);//アクション数読み込み
for (int actionIdx = 0; actionIdx < actionCount;++actionIdx) {
int nameLen = 0;
fread_s(&nameLen, sizeof(nameLen),sizeof(nameLen), 1, fp);//アクション名サイズ読み込み
std::wstring actionName;
actionName.resize(nameLen);
fread_s(&actionName[0], sizeof(actionName[0])*actionName.size(), sizeof(actionName[0]), nameLen, fp);//アクション名読み込み
int inputTypeCount = 0;
fread_s(&inputTypeCount, sizeof(inputTypeCount),sizeof(inputTypeCount), 1, fp);//入力種別数読み込み
for (int inputIdx = 0; inputIdx < inputTypeCount; ++inputIdx) {
InputMapInfo info = {};
fread_s(&info, sizeof(info), sizeof(info), 1, fp);//入力マップ情報を1つずつ読み込み
inputActionMap_[actionName].push_back(info);
}
}
fclose(fp);
tempActionMap_ = inputActionMap_;
}
はい、実際にはこれをゲーム上メニューで書き換えるところまで実演したい所さんなのですが、もうすぐ日が変わりそうなので、今回はこのへんにしておきます。
実際のキーコンフィグメニューを作ってみるのはまた次の投稿時にしましょう。
多分ここまで読んだ人は自分でインターフェース作って、キーコンフィグ作れるかと思います。頑張りましょう。分からないところはご質問ください。
今回はここまで読んでいただきありがとうございました。