【XInput】手柄模擬鼠標運作之 .NET P/Invoke 和 UWP-API 方案

上一篇中,老周簡單膚淺地介紹了 XInput API 的使用,並模擬了鼠標移動,左、右鍵單擊和滾輪。本篇,咱們用 .NET 代碼來完成相同的效果。

說起來也是倒黴,博文寫了一半,電腦忽然斷電了。不知道什麼原因,可能是 UPS 電源出故障。重新開機進來一看,博文沒有自動保存到草稿箱。我記得以前是有自動保存這功能的。很無奈,只好重寫了。

在 dll 導入的時,容易出問題的是 INPUT 結構體,因爲這貨有 union 成員。不知各位還記不記得。

typedef struct tagINPUT {
    DWORD   type;

    union
    {
        MOUSEINPUT      mi;
        KEYBDINPUT      ki;
        HARDWAREINPUT   hi;
    } DUMMYUNIONNAME;
} INPUT, *PINPUT, FAR* LPINPUT;

 導入代碼網上一搜一大把,然而,那些代碼都是恐龍時代的,在 32 位平臺上是沒問題的,但在 64 位平臺上會無法正常用的。夥伴們可能會說,如果不自定義各種屬性,運行時不是自動處理的嗎?對的,如果應用在字段成員上的各種特性(如 [StructLayout(LayoutKind.Sequential)])是會自動對齊字節的。

而 INPUT 結構體特別啊,在 type 後面的三個字段是共享內存的,所以,必須明確設置字節偏移。這個結構體在 32 位系統中是 4 字節對齊的,大小爲 28;而在 64 位系統上是 8 字節對齊的,大小是 40 字節。type 字段佔 4 字節,這個不變,但如果 8 字節對齊,那麼,type 後面還要額外填充 4 個字節,即 mi、ki 等成員的偏移是從第 9 個字節開始的,索引是 8。如果你抄網上的代碼,offset = 4,在 64 位系統上運行,是無效的。

解決這個核心問題,dll 導入就很順利了。

public enum InputType : uint
{
    INPUT_MOUSE = 0,
    INPUT_KEYBOARD = 1,
    INPUT_HARDWARE = 2
}

 [Flags]
 public enum MouseEventFlags : uint
 {
     MOUSEEVENTF_MOVE = 0x0001,
     MOUSEEVENTF_LEFTDOWN = 0x0002,
     MOUSEEVENTF_LEFTUP = 0x0004,
     MOUSEEVENTF_RIGHTDOWN = 0x0008,
     MOUSEEVENTF_RIGHTUP = 0x0010,
     MOUSEEVENTF_ABSOLUTE = 0x8000
 }

 [Flags]
 public enum KeyboardEventFlags : uint
 {
     KEYEVENTF_KEYDOWN = 0x0000,
     KEYEVENTF_EXTENDEDKEY = 0x0001,
     KEYEVENTF_KEYUP = 0x0002,
     KEYEVENTF_UNICODE = 0x0004,
     KEYEVENTF_SCANCODE = 0x0008
 }

這些在頭文件中本來是宏定義的,我全定義爲枚舉,用起來方便幾個檔次。

[StructLayout(LayoutKind.Sequential)]
public struct MOUSEINPUT
{
    public int dx;
    public int dy;
    public uint MouseData;
    public MouseEventFlags Flags;
    public uint Time;
    public nuint ExtraInfo;
}

[StructLayout(LayoutKind.Sequential)]
public struct KEYBDINPUT
{
    public ushort Vk;
    public ushort Scan;
    public KeyboardEventFlags Flags;
    public uint Time;
    public nuint ExtraInfo;
}

以上兩個結構體無需特殊處理,就按常規就行。但下面的 INPUT 結構體就要注意了。

public enum InputType : uint
{
    INPUT_MOUSE = 0,
    INPUT_KEYBOARD = 1,
    INPUT_HARDWARE = 2
}

[StructLayout(LayoutKind.Explicit)]
public struct INPUT
{
    [FieldOffset(0)]
    public InputType Type;
    [FieldOffset(8)]
    public MOUSEINPUT mi;
    [FieldOffset(8)]
    public KEYBDINPUT ki;
}

 StructLayoutAttribute 特性類在應用時,目標結構體的成員排列要設置爲 Explicit。即由咱們手動指定各個成員的偏移字節。記住,在 64 位系統中,偏移量是 8(鑑於現在很多人都用 64 位了,所以我這裏就不設置條件編譯了,如果你要兼容,可以設定條件編譯,32 位的偏移量是 4,64位的是 8)。

 上面那一大堆東西弄好,SendInput 函數就可以導入了。

[DllImport("user32.dll")]
public static extern uint SendInput(
    uint Inputs,
    [MarshalAs(UnmanagedType.LPArray)] INPUT[] inputs,
    int size);

然後是 XInput 的函數,這個就按常規方式導入即可(熟悉的配方,熟悉的味道)。

[Flags]
public enum GamePadButtons : ushort
{
    XINPUT_GAMEPAD_DPAD_UP = 0x0001,
    XINPUT_GAMEPAD_DPAD_DOWN = 0x0002,
    XINPUT_GAMEPAD_DPAD_LEFT = 0x0004,
    XINPUT_GAMEPAD_DPAD_RIGHT = 0x0008,
    XINPUT_GAMEPAD_START = 0x0010,
    XINPUT_GAMEPAD_BACK = 0x0020,
    XINPUT_GAMEPAD_LEFT_THUMB = 0x0040,
    XINPUT_GAMEPAD_RIGHT_THUMB = 0x0080,
    XINPUT_GAMEPAD_LEFT_SHOULDER = 0x0100,
    XINPUT_GAMEPAD_RIGHT_SHOULDER = 0x0200,
    XINPUT_GAMEPAD_A = 0x1000,
    XINPUT_GAMEPAD_B = 0x2000,
    XINPUT_GAMEPAD_X = 0x4000,
    XINPUT_GAMEPAD_Y = 0x8000
}

[StructLayout(LayoutKind.Sequential)]
public struct XINPUT_GAMEPAD
{
    public GamePadButtons Buttons;
    public byte LeftTrigger;
    public byte RightTrigger;
    public short ThumbLX;
    public short ThumbLY;
    public short ThumbRX;
    public short ThumbRY;
}

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

 導入 XInputGetState 函數。

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

 

兩個 API 咱們封裝到一個類中。

 static class WinApi
 {
     [DllImport("user32.dll")]
     public static extern uint SendInput(
         uint Inputs,
         [MarshalAs(UnmanagedType.LPArray)] INPUT[] inputs,
         int size);

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

 

好了,API 已經導入,可以玩了。這一次老周只做了:

1、左邊的搖桿負責控制鼠標移動;

2、A 鍵表示左鍵單擊,B 鍵表示右鍵單擊。

 下面是示例代碼:

internal class Program
{
    // 記錄序號,如果序號改變,才表示有新的數據
    static uint SerialID = default;

    static void Main(string[] args)
    {
        while (true)
        {
            Thread.Sleep(80);
            // 讀取數據
            XINPUT_STATE state = default;
            if (WinApi.XInputGetState(0, ref state) != 0)
            {
                // 返回值不爲0,表示不成功,跳過
                continue;
            }
            // 比較一下序號,看是不是新的數據
            if (SerialID == state.PacketNumber)
            {
                continue;   // 數據是舊的,不處理
            }
            // 保存新的序號
            SerialID = state.PacketNumber;
            // 要發送的輸入消息列表
            List<INPUT> inputList = new();
            // 計算鼠標移動量
            int dx = state.GamePad.ThumbLX / 1000;
            int dy = -state.GamePad.ThumbLY / 1000;
            INPUT mouseMove = new();
            mouseMove.Type = InputType.INPUT_MOUSE;     // 消息類型是鼠標
            // 設置鼠標事件標誌
            mouseMove.mi.Flags = MouseEventFlags.MOUSEEVENTF_MOVE;
            // 設置移動量
            mouseMove.mi.dx = dx;
            mouseMove.mi.dy = dy;
            inputList.Add(mouseMove);

            // 判斷按鍵
            if ((state.GamePad.Buttons & GamePadButtons.XINPUT_GAMEPAD_A) == GamePadButtons.XINPUT_GAMEPAD_A)
            {
                // 左鍵按下消息
                INPUT lbpress = new INPUT();
                lbpress.Type = InputType.INPUT_MOUSE;
                lbpress.mi.Flags = MouseEventFlags.MOUSEEVENTF_LEFTDOWN;
                inputList.Add(lbpress);
                // 左鍵釋放
                INPUT lbrelease = new INPUT();
                lbrelease.Type = InputType.INPUT_MOUSE;
                lbrelease.mi.Flags = MouseEventFlags.MOUSEEVENTF_LEFTUP;
                inputList.Add(lbrelease);
            }
            if ((state.GamePad.Buttons & GamePadButtons.XINPUT_GAMEPAD_B) == GamePadButtons.XINPUT_GAMEPAD_B)
            {
                // 右鍵按下
                INPUT rbpress = new();
                rbpress.Type = InputType.INPUT_MOUSE;
                rbpress.mi.Flags = MouseEventFlags.MOUSEEVENTF_RIGHTDOWN;
                inputList.Add(rbpress);
                // 右鍵釋放
                INPUT rbrelease = new INPUT();
                rbrelease.Type = InputType.INPUT_MOUSE;
                rbrelease.mi.Flags = MouseEventFlags.MOUSEEVENTF_RIGHTUP;
                inputList.Add(rbrelease);
            }
            // 發送消息
            WinApi.SendInput((uint)inputList.Count, inputList.ToArray(), Marshal.SizeOf<INPUT>());
        }
    }
}

原理和上一篇中所述一樣,先讀取手柄數據,然後發送鼠標輸入消息。

 

===================================================================================

 微軟其實有提供了新的 XInput API,即給 UWP 應用程序使用的,而實際上。.NET 應用項目是可以使用 UWP API 的。畢竟,Win 10/11 是內置了運行庫的。

接下來,咱們就用 UWP 方案,這個不需要 Dll 導入,用起來方便多了。

1、像平常一樣,創建 .NET 項目。WPF、WinForms 或 UWP App 都無所謂,但不建議控制檯,有可能讀不到數據。API 文檔中說要求是可以 Focus 的窗口才能接收輸入;

2、打開系統 CMD 窗口,或任意終端都行。執行 systeminfo

這裏能看到 build 版本號,比如老周的是 Win 11,只要記住前兩位數字就行了,即 10.0.22000.0。

3、回到開發環境,打開項目文件,找到這一行。

<TargetFramework>net8.0</TargetFramework>

默認是 net-<ver>,表明這個控制檯應用是跨平臺的,我們把它改爲 Windows 特供的。

<TargetFramework>net8.0-Windows10.0.22000.0</TargetFramework>

保存,關閉文件。此時,你的項目可以用 UWP API 了。

注意:要模擬鼠標動作也是要導入 Win API 的,和前文一樣,只是讀手柄的API不同罷了。

 

下面的例子,老周就用一個 System.Threading.Timer 來每 100 ms 讀取一次數據,並顯示在窗口上。窗口的結構如下:

 

主要用到的是 Windows.Gaming.Input 命名空間下的 Gamepad 類,這個類的構造函數不是公共的,不能直接實例化,而是訪問它的靜態屬性 Gamepads。這是一個集合,如果連接了多個手柄,裏面會有多個元素。

我在窗口的 Load 事件處理中,開一個 Task 來獲取。

_ = Task.Run(async () =>
{
    while (gamePad == null)
    {
        gamePad = Gamepad.Gamepads.FirstOrDefault();
        await Task.Delay(1000);
    }
});

這裏假設只連接了一個手柄,所以總是獲取集合中的第一個元素。爲什麼要這樣獲取呢?因爲當應用程序初始化時,訪問 Gamepads 集合不一定能獲取到手柄(有時候會有一兩秒的延時),所以咱們要這樣來獲取。

本示例中,老周用來讀數據的 Timer 是後臺線程的。儘量不要用 System.Windows.Forms 下的 Timer,因爲那個定時器用的是 UI 線程。在 UI 線程上讀數據要把獲取數據的一段代碼放在 lock 裏面,否則讀到的全是 0,或者讀到錯的值。同理,WPF 也不用 DispatcherTimer,那個定時器也是在 UI 線程上運行的。

用非 UI 線程的定時器,在讀取數據時可以不進行 lock。下面是定時器使用過程:

1、在窗口類中定義 Timer 爲私有字段。

 private Gamepad? gamePad;
 private System.Threading.Timer timer;

gamepad 也是私有字段,待會兒用於引用 Gamepad 實例。

2、在窗口類的構造函數中,new 一個 Timer 實例,用 Change 方法禁用定時器。

 public MyWindow()
 {
     InitializeComponent();
     Load += OnLoad;
     FormClosing += OnClosing;
     timer = new System.Threading.Timer(OnTick);
     timer.Change(Timeout.Infinite, Timeout.Infinite);
 }

傳給 Timer 構造函數的是一個回調委託,這裏我綁定的是 OnTick 方法。委託類型接收一個 object 類型的參數,是用戶自定義的狀態數據,不使用的話可以忽略。這個 Timer 沒有 Start、Stop 等方法,用 Change 方法設置超時爲永不超時,這樣就等於禁用定時器了。

實現 OnTick 方法,循環讀取手柄數據,顯示在窗口上。

private void OnTick(object? state)
{
    if (gamePad == null) return;

    // 讀數
    GamepadReading data = gamePad.GetCurrentReading();
    BeginInvoke(() =>
    {
        // 左搖桿
        txtLeftX.Text = data.LeftThumbstickX.ToString("N4");
        txtLeftY.Text = data.LeftThumbstickY.ToString("N4");

        // 右搖桿
        txtRightX.Text = data.RightThumbstickX.ToString("N4");
        txtRightY.Text = data.RightThumbstickY.ToString("N4");

        // 左右扳機鍵
        txtLeftTrigger.Text = data.LeftTrigger.ToString("N2");
        txtRightTrigger.Text = data.RightTrigger.ToString("N2");

        // 檢查按鍵
        ckbX.Checked = (data.Buttons & GamepadButtons.X) == GamepadButtons.X;
        ckbY.Checked = (data.Buttons & GamepadButtons.Y) == GamepadButtons.Y;
        ckbStart.Checked = (data.Buttons & GamepadButtons.Menu) == GamepadButtons.Menu;
    });
}

 調用 GetCurrentReading 方法就可以獲取實時讀數了。返回的是 GamepadReading 結構體。注意它和 XInput API 的讀數範圍是不同的。

這個 UWP API 的讀範圍是 -1 到 1,如果搖桿在中間位置(默認位置),那麼讀數是 0。讀出來的值是 -1 到 1 的小數(含-1 和 1)。

GamepadButtons 枚舉定義的是手柄的按鍵,這個和 XInput API 差不多。

public enum GamepadButtons : uint
{
    // 未按下任何鍵
    None = 0u,
    // 菜單鍵,老周的手柄上是 Start 鍵
    Menu = 1u,
   
    // 這個不知道是什麼
    View = 2u,

    // A、B、X、Y 按鍵
    A = 4u,
    B = 8u,
    X = 0x10u,
    Y = 0x20u,

    // 手柄上的四個方向鍵
    DPadUp = 0x40u,
    DPadDown = 0x80u,
    DPadLeft = 0x100u,
    DPadRight = 0x200u,
  
    // 這兩個是兩個肩膀按鍵
    LeftShoulder = 0x400u,
    RightShoulder = 0x800u,

    // 下面兩個指的是搖桿上的按鍵,搖桿除了可以搖,還可以按下去。
    // 其實搖桿中間是一個輕觸按鈕
    LeftThumbstick = 0x1000u,
    RightThumbstick = 0x2000u,

     // 其他按鍵
}

一起來看看效果。

 

最後,共享點猛料給大夥伴。AOSP Android 14 原生系統,樹莓派 4 / 5 鏡像,都是最新版的。

鏈接:https://pan.baidu.com/s/1q9xnLh4n7pNBl62djxDNnQ?pwd=1981
提取碼:1981
下載後解壓出來,直接寫入內存卡就行,就跟安裝官方系統一樣。

把卡插到 Pi 上,第一次運行要用 HDMI 口連顯示器,如果顯示器不能觸控,順便連上鍵盤鼠標。如果你有 DSI 接的觸控顯示屏,需要到 設置 - 系統 - Raspberry Pi 設置中打開 7 寸觸控屏選項。不一定要官方的屏幕(很貴),某寶上隨便弄的只要是 DSI 排線連接的,多數屏幕是可以用的。DSI 排線要在樹莓派關機斷電後再連接,不要熱插拔。接了觸控屏就不要再接 HDMI 口了。

由於是原生系統,時間服務器是不能用的,要自動更新網絡時間,需要用 adb 改爲國內的 NTP 服務器,方法可以百度,很多教程。

經老周測試,不管是4代還是5代,聲音、觸控、WiFi、藍牙、HDMI 音/視頻、GPIO 等功能都可正常使用。但是,自己連接到 i2c 上的 MPU6050(重力加速和陀螺儀)不能用。這個是在設置 - 系統 - Raspberry pi 設置中的傳感選項中開啓的,反正老周買的模塊無法正常使用。

另外,把 GPIO 21 接低電平,可以觸發電源按鈕功能,就像手機上的電源鍵,可以長按關機/重啓、喚醒鎖屏等,有鍵盤的可以按 F5。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章