4
3

C# から XInput 経由でゲームパッドの入力を得る (外部ライブラリなし)

Posted at

はじめに

「C# ゲームパッド 入力」でググってみると、以下のような記事が見つかった。

このことから、「XInput は最近の技術なので、用いるには複雑な処理が求められるのかな…」と思った。
しかし、調べてみると実際は

  • 初期化処理不要、いきなり取得関数を呼び出して取得できる
  • Windows 8 以降に標準である関数1個だけで使える
  • 取得できる情報がシンプル
  • 情報と物理的なボタンやスティックなどの関係が定義されており、使いやすい

と、対応環境であれば非常に使いやすいAPIであることがわかった。

「関数1個だけで使える」のは基本的な入力の取得だけであり、振動機能の使用などには他の関数を用いる。
しかし、これらはこの記事では扱わない。

XInput

Windows アプリケーション上の XInput の概要 - Win32 apps | Microsoft Learn

XInput のバージョン

XInput のバージョン - Win32 apps | Microsoft Learn

XInput には、

  • XInput 1.4
  • XInput 9.1.0
  • XInput 1.3

の3バージョンがあり、対応している Windows のバージョンや用いるDLLなどに違いがある。
この記事では、最新の XInput 1.4 を扱う。
このバージョンでは、Windows 8 以降に標準搭載された XINPUT1_4.DLL を用いる。

XInput が扱うゲームパッド

XInput では、対応したゲームパッドを最大 4 個同時に扱うことができる。
それぞれのゲームパッドでは、以下の入力を取得できる。

  • 左・右スティック (アナログ入力X・Y、押し込み)
  • 右スティック (アナログ入力X・Y、押し込み)
  • 十字キー (上・下・左・右)
  • Aボタン・Bボタン・Xボタン・Yボタン
  • スタートボタン・バック (セレクト?) ボタン
  • L1ボタン・R1ボタン
  • 左・右トリガー (アナログ入力)

↓ XInput 対応ゲームパッドの例 (GameSir G4 Pro)
XInput 対応ゲームパッドの例

古いゲームパッドなど、一部のゲームパッドは XInput に対応していない。
XInput では、対応していないゲームパッドの入力は取得できない。

従来の Windows API と XInput の違い

従来の Windows API は、汎用性が高く、幅広いデバイスに対応できる作りになっているようである。
一方、XInput では、汎用性を犠牲にし、よくあるゲームパッドに合わせたシンプルな作りになっているようである。

比較ポイント 従来 XInput
同時に扱えるデバイス数 16(ドライバ依存?) 4
扱えるボタン数 32 14
扱えるアナログ軸数 6 4(スティック)+2(トリガー)
扱えるPOVスイッチ数 1 0
対応デバイス 多い 限定
ボタンの名前の定義 なし あり
左右トリガーの同時押し認識 不可? 可能

ボタンの扱いの違い

従来の Windows API では、押されたボタンを DWORD 型 (32ビット符号なし整数) のビットで表す。
そのため、API 上は 32 個のボタンを扱えるが、実際のゲームパッドにはそこまで多くのボタンがついていることは少ない。
さらに、各ボタンの名前は定義されておらず、ゲームパッド上のボタンの位置と API で用いるビットの関係はゲームパッドによって異なることがある。
そのため、ゲームなどの入力を受け取るアプリケーションでキーコンフィグに対応しておかないと、操作方法がアプリケーションの開発者の想定と異なってしまい、操作しにくくなる可能性がある。

一方、XInput では、押されたボタンは名前がついた定数 (ビット) の組み合わせで表現される。
扱えるボタンは実際のゲームパッドによくあるボタンに限定されるが、ゲームパッド上のボタンの位置と API で用いるビットの関係が定義され、キーコンフィグなしでもコントローラーによらず同じような入力を受け付けることができる。

十字キーの扱いの違い

従来の Window API では、「POVスイッチ」という概念がある。
これは、方向の入力を受け付け、入力された方向 (角度) または「中立」(入力なし) を数値で表現するものである。
ゲームパッド上では、十字キーに対応していることが多い。

XInput では、「POVスイッチ」の入力受け付けは無く、十字キーの入力は通常のボタンと同様にビットで表現される。
このため、十字キーの入力を受け取るのにも特別な処理が不要で、扱いやすい。

トリガーの扱いの違い

背面の下にあるスイッチがトリガー (アナログ入力) になっているタイプのゲームパッドにおいて、従来の Windows API では、この左右のトリガーの同時押しを検出できないことがある。
これは、従来の Windows API では「操作されていない状態が軸の中心」という性質を保つため、左右のトリガーをまとめて一つの軸として認識する (ことが多い) からである。
そのため、左右のトリガーを両方押すと、「押されていない」状態として認識され、「両方押されている」ことはアプリケーションに伝わらない。

XInput では、これらのトリガーの入力を左右独立して受け取ることができるので、「両方押されている」状態と「両方押されていない」状態を区別することができる。

C# から XInput を扱う

XInput の構造体

XInput の入力は、XINPUT_STATE 構造体に取得できる。
これは、C# では以下のように定義できる。

[StructLayout(LayoutKind.Sequential)]
public struct XINPUT_STATE
{
	public uint dwPacketNumber;    // パケット番号
	public XINPUT_GAMEPAD Gamepad; // 入力情報
}

パケット番号は、入力情報に変化があったかを表す数値である。
前回取得してから入力情報に変化がある場合はパケット番号が増加し、変化が無い場合はパケット番号も変化しない。

入力情報は、具体的な入力の状態を表す XINPUT_GAMEPAD 構造体である。
これは以下のように定義できる。

[StructLayout(LayoutKind.Sequential)]
public struct XINPUT_GAMEPAD
{
	public ushort wButtons;    // 押されているボタンの情報
	public byte bLeftTrigger;  // 左トリガーの入力情報
	public byte bRightTrigger; // 右トリガーの入力情報
	public short sThumbLX;     // 左スティックのX軸(横)の入力情報
	public short sThumbLY;     // 左スティックのY軸(縦)の入力情報
	public short sThumbRX;     // 右スティックのX軸(横)の入力情報
	public short sThumbRY;     // 右スティックのY軸(縦)の入力情報
}

押されているボタンの情報は、以下のビットの組み合わせで表現される。
押されているボタンに対応するビットは 1、押されていないボタンに対応するビットは 0 になる。
対応するボタンが無いビットの状態は未定義である。

ボタン 値 (ビット)
十字キー上 (↑) 0x0001
十字キー下 (↓) 0x0002
十字キー左 (←) 0x0004
十字キー右 (→) 0x0008
スタートボタン 0x0010
バック (セレクト?) ボタン 0x0020
左スティック押し込み 0x0040
右スティック押し込み 0x0080
L1ボタン 0x0100
R1ボタン 0x0200
Aボタン 0x1000
Bボタン 0x2000
Xボタン 0x4000
Yボタン 0x8000

左右トリガーの入力情報は、それぞれ 0 ~ 255 の整数で表す。
全く押されていない状態が 0、完全に奥まで押されている状態が 255 である。

左右スティックの各軸の入力情報は、それぞれ -32768 ~ 32767 の整数で表す。
X軸は、-32768 が一番左、32767 が一番右である。
Y軸は、-32768 が一番下、32767 が一番上である。
どちらも、0 が中央である。

各スティックの軸の表現

これらの構造体につけている

[StructLayout(LayoutKind.Sequential)]

は、DLLとの連携時、これらの構造体のデータを宣言した順番通り、アラインメントを考慮してメモリ上に配置するということを表している。

StructLayoutAttribute クラス (System.Runtime.InteropServices) | Microsoft Learn
LayoutKind 列挙型 (System.Runtime.InteropServices) | Microsoft Learn

XInput の関数

XInputGetState 関数により、ゲームパッドの入力状態を XINPUT_STATE 構造体に取得することができる。
この関数は、C# では以下のように宣言できる。

[DllImport("Xinput1_4.dll")]
public static extern uint XInputGetState(uint dwUserIndex, ref XINPUT_STATE pState);

DllImport により、関数があるDLLを指定している。
関数の別名を用いたり、文字列を用いたりなどの複雑なことはしないので、オプションは指定していない。

dwUserIndex は、情報を取得するゲームパッドを 0 ~ 3 の整数で指定する。
pState は、取得した情報を格納する XINPUT_STATE 構造体のインスタンスを指定する。

返り値として、以下の値を返す可能性がある。

返り値 意味
ERROR_SUCCESS (0) 処理に成功し、情報を格納した
ERROR_DEVICE_NOT_CONNECTED (0x48f) 指定したゲームパッドは接続されていない
その他のエラーコード その他のエラーが発生した

ゲームパッドが接続されていない場合、接続されていないゲームパッド (dwUserIndex) の状態の取得を毎フレーム試みることはせず、ゲームパッドが接続されたかを数秒ごとにチェックすることが推奨されている。

サンプルプログラム

4個のゲームパッドについて、それぞれ状態を取得し、表示するプログラムを作成した。
取得に成功した場合は、15ミリ秒間隔に設定したタイマーにより、リアルタイムに近い情報を取得する。
取得に失敗した場合 (切断されている場合を含む) は、接続を待機するため、1秒間隔に設定したタイマーにより情報を取得する。

スクリーンショット

XInputTest.cs
using System;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Text;
using System.Windows.Forms;

class XInputWrapper
{
	public const uint ERROR_SUCCESS = 0;
	public const uint ERROR_DEVICE_NOT_CONNECTED = 0x48f;

	[StructLayout(LayoutKind.Sequential)]
	public struct XINPUT_GAMEPAD
	{
		public ushort wButtons;
		public byte bLeftTrigger;
		public byte bRightTrigger;
		public short sThumbLX;
		public short sThumbLY;
		public short sThumbRX;
		public short sThumbRY;
	}

	[StructLayout(LayoutKind.Sequential)]
	public struct XINPUT_STATE
	{
		public uint dwPacketNumber;
		public XINPUT_GAMEPAD Gamepad;
	}

	[DllImport("Xinput1_4.dll")]
	public static extern uint XInputGetState(uint dwUserIndex, ref XINPUT_STATE pState);
}

class ControllerView: GroupBox
{
	private const int GRID_SIZE = 22;
	private static readonly Font FONT = new Font("MS UI Gothic", 16, GraphicsUnit.Pixel);

	private Label AddLabel(float x, float y, float width, string text)
	{
		Label label = new Label();
		label.Location = new Point((int)(GRID_SIZE * x), (int)(GRID_SIZE * y));
		label.Size = new Size((int)(GRID_SIZE * width), GRID_SIZE);
		label.Text = text;
		Controls.Add(label);
		return label;
	}

	private Label packetNoLabel, buttonLabel;
	private Label leftTriggerLabel, rightTriggerLabel;
	private Label lxLabel, lyLabel, rxLabel, ryLabel;

	public ControllerView()
	{
		SuspendLayout();
		Font = FONT;
		base.Size = new Size(GRID_SIZE * 9, (int)(GRID_SIZE * 6.5f));
		packetNoLabel = AddLabel(0.5f, 1, 8, "パケット: -");
		buttonLabel = AddLabel(0.5f, 2, 8, "ボタン: -");
		leftTriggerLabel = AddLabel(0.5f, 3,4, "LT: -");
		rightTriggerLabel = AddLabel(4.5f, 3, 4, "RT: -");
		lxLabel = AddLabel(0.5f, 4, 4, "LX: -");
		lyLabel = AddLabel(0.5f, 5, 4, "LY: -");
		rxLabel = AddLabel(4.5f, 4, 4, "RX: -");
		ryLabel = AddLabel(4.5f, 5, 4, "RY: -");
		ResumeLayout();
	}

	public new Size Size
	{
		get
		{
			return base.Size;
		}
	}

	private uint _Status = 0;
	private string _Text = "";
	private XInputWrapper.XINPUT_STATE _XInputState = new XInputWrapper.XINPUT_STATE();

	private string StatusMessage
	{
		get
		{
			if (_Status == XInputWrapper.ERROR_SUCCESS) return "";
			else if (_Status == XInputWrapper.ERROR_DEVICE_NOT_CONNECTED) return " (切断)";
			else return string.Format(" (エラー: 0x{0:x})", _Status);
		}
	}

	public new string Text
	{
		get
		{
			return base.Text;
		}
		set
		{
			_Text = value;
			base.Text = _Text + StatusMessage;
		}
	}

	public uint Status
	{
		get
		{
			return _Status;
		}
		set
		{
			_Status = value;
			base.Text = _Text + StatusMessage;
		}
	}

	public XInputWrapper.XINPUT_STATE XInputState
	{
		get
		{
			return _XInputState;
		}
		set
		{
			_XInputState = value;
			packetNoLabel.Text = string.Format("パケット: {0}", value.dwPacketNumber);
			buttonLabel.Text = string.Format("ボタン: 0x{0:x}", value.Gamepad.wButtons);
			leftTriggerLabel.Text = string.Format("LT: {0}", value.Gamepad.bLeftTrigger);
			rightTriggerLabel.Text = string.Format("RT: {0}", value.Gamepad.bRightTrigger);
			lxLabel.Text = string.Format("LX: {0}", value.Gamepad.sThumbLX);
			lyLabel.Text = string.Format("LY: {0}", value.Gamepad.sThumbLY);
			rxLabel.Text = string.Format("RX: {0}", value.Gamepad.sThumbRX);
			ryLabel.Text = string.Format("RY: {0}", value.Gamepad.sThumbRY);
		}
	}
}

class XInputTest: Form
{
	public static void Main()
	{
		Application.EnableVisualStyles();
		Application.SetCompatibleTextRenderingDefault(false);
		Application.Run(new XInputTest());
	}

	private ControllerView[] cvs = new ControllerView[4];
	private Timer connectionCheckTimer, statusCheckTimer;

	public XInputTest()
	{
		Text = "XInput テスト";
		cvs[0] = new ControllerView();
		cvs[0].Text = "0";
		cvs[0].Location = new Point(10, 10);
		Controls.Add(cvs[0]);
		cvs[1] = new ControllerView();
		cvs[1].Text = "1";
		cvs[1].Location = new Point(20 + cvs[0].Width, 10);
		Controls.Add(cvs[1]);
		cvs[2] = new ControllerView();
		cvs[2].Text = "2";
		cvs[2].Location = new Point(10, 20 + cvs[0].Height);
		Controls.Add(cvs[2]);
		cvs[3] = new ControllerView();
		cvs[3].Text = "3";
		cvs[3].Location = new Point(20 + cvs[0].Width, 20 + cvs[0].Height);
		Controls.Add(cvs[3]);
		FormBorderStyle = FormBorderStyle.FixedSingle;
		MaximizeBox = false;
		ClientSize = new Size(cvs[0].Width * 2 + 30, cvs[0].Height * 2 + 30);

		Shown += ShownHandler;
	}

	private void ShownHandler(object sender, EventArgs e)
	{
		connectionCheckTimer = new Timer();
		connectionCheckTimer.Interval = 1000;
		connectionCheckTimer.Tick += ConnectionCheck;
		connectionCheckTimer.Start();
		statusCheckTimer = new Timer();
		statusCheckTimer.Interval = 15;
		statusCheckTimer.Tick += StatusCheck;
		statusCheckTimer.Start();
	}

	private void ConnectionCheck(object sender, EventArgs e)
	{
		for (uint i = 0; i < 4; i++)
		{
			if (cvs[i].Status != XInputWrapper.ERROR_SUCCESS)
			{
				XInputWrapper.XINPUT_STATE state = new XInputWrapper.XINPUT_STATE();
				uint status = XInputWrapper.XInputGetState(i, ref state);
				cvs[i].Status = status;
				if (status == XInputWrapper.ERROR_SUCCESS) cvs[i].XInputState = state;
			}
		}
	}

	private void StatusCheck(object sender, EventArgs e)
	{
		for (uint i = 0; i < 4; i++)
		{
			if (cvs[i].Status == XInputWrapper.ERROR_SUCCESS)
			{
				XInputWrapper.XINPUT_STATE state = new XInputWrapper.XINPUT_STATE();
				uint status = XInputWrapper.XInputGetState(i, ref state);
				cvs[i].Status = status;
				if (status == XInputWrapper.ERROR_SUCCESS) cvs[i].XInputState = state;
			}
		}
	}
}

その他の XInput の機能 (一部)

この記事では扱わないが、XInput には以下の機能もある。

XInputEnable 関数の備考には、以下の記述がある。

Windows 10 以降:非推奨の。ゲーム コントローラーの入力は、アプリケーション ウィンドウのフォーカスに基づいてシステムによって自動的に有効または無効になります。

しかし、手元の Windows 11 環境で試したところ、ウィンドウがアクティブでなかったり、最小化されていたりする状態でも、問題なくゲームパッドの入力を取得できた。
「自動的に有効または無効」になる具体的な条件は不明である。

まとめ

C# で XInput を用いてゲームパッドの入力を得ることができた。
XInput は、従来の Windows API とは違い、入力元をよくあるゲームパッドに絞るかわりに、ビットとゲームパッド上のボタンの関係を定義するなど、入力を扱いやすくしている。

4
3
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3