【XInput】遊戲手柄模擬鼠標動作

老週一般很少玩遊戲,在某寶上買了一堆散件,計劃在過年期間自己做個機械臂耍耍。頭腦中劃過一道紫藍色的閃電,想起用遊戲手柄來控制機械臂。機械臂是由樹莓派(大草莓)負責控制,然後客戶端通過 Socket UDP 來發送信號。優先考慮在 PC 和手機上測試,就順便折騰一下 XInput API。當然,讀取手柄數據有多套 API。本文老周先介紹 XInput 方案,後面再介紹 Windows.Gaming.Input 方案。Windows.Gaming.Input 是 UWP API,也可以在.NET 項目中使用。.NET 程序適合用這套 API。

XInput 中的 X 指的就是“西瓜手柄”,哦不,是 XBox 手柄。當然了,並不侷限於 XB 手柄,結構與 XB 相似的手柄也能用。老周用的是北通的阿修羅無線版,經測試是可用的。

XInput API 基本的定義都在 Xinput.h 頭文件中。讀手柄的數值要用到 XInputGetState 函數,它的原型如下:

DWORD WINAPI XInputGetState
(
    _In_  DWORD         dwUserIndex,  // Index of the gamer associated with the device
    _Out_ XINPUT_STATE* pState        // Receives the current state
) 

dwUserIndex 指的是手柄設備索引,範圍爲 0 - 3,也就是隻能連接四個手柄(其實也夠玩了)。如果只連接了一個手柄,這個參數直接用 0 就可以了。如果你想用 for 循環來訪問各個手柄,還可以用到以下宏:

#define XUSER_MAX_COUNT       4

pState 參數是指針類型,指向 XINPUT_STATE 結構體,它只有兩個成員:

typedef struct _XINPUT_STATE
{
    DWORD                               dwPacketNumber;
    XINPUT_GAMEPAD                      Gamepad;
} XINPUT_STATE, *PXINPUT_STATE;

DWORD 就是 double word,一個 word 是 16 位無符號整數,兩個就是 32 位。所以 dwPacketNumber 字段是一個整數值。它表示你讀取數據的序號,它的值會不斷累加,在讀取數據時,咱們可以用一個變量保存它的值,每次讀手柄後進行比較,如果這個序號沒有變化,說明用戶沒有操作手柄;相反,如果值不同,表明手柄的狀態有改變。

Gamepad 字段是另一個結構體—— XINPUT_GAMEPAD。

typedef struct _XINPUT_GAMEPAD
{
    WORD                                wButtons;
    BYTE                                bLeftTrigger;
    BYTE                                bRightTrigger;
    SHORT                               sThumbLX;
    SHORT                               sThumbLY;
    SHORT                               sThumbRX;
    SHORT                               sThumbRY;
} XINPUT_GAMEPAD, *PXINPUT_GAMEPAD;

wButtons 表示手柄被按下的按鍵,“w”表示它的值是 word 類型。以下宏定義了這些按鍵:

1、方向鍵。

/* 下面這四個是手柄上的方向鍵:上、下、左、右 */
#define XINPUT_GAMEPAD_DPAD_UP            0x0001
#define XINPUT_GAMEPAD_DPAD_DOWN       0x0002
#define XINPUT_GAMEPAD_DPAD_LEFT         0x0004
#define XINPUT_GAMEPAD_DPAD_RIGHT       0x0008

就是中間那四個,如下圖紅圈內。

2、開始和返回。

#define XINPUT_GAMEPAD_START            0x0010
#define XINPUT_GAMEPAD_BACK             0x0020

如下圖黃圈那兩個鍵:

3、X、Y、A、B 鍵。

#define XINPUT_GAMEPAD_A                0x1000
#define XINPUT_GAMEPAD_B                0x2000
#define XINPUT_GAMEPAD_X                0x4000
#define XINPUT_GAMEPAD_Y                0x8000

就是右搖桿上面的四個鍵,如下圖綠色圈內部分:

 

4、下面兩個表示搖桿上的按鈕按下時觸發:

/* 左搖桿按下 */
#define XINPUT_GAMEPAD_LEFT_THUMB       0x0040

/* 右搖桿按下 */
#define XINPUT_GAMEPAD_RIGHT_THUMB      0x0080

 

5、下面兩個是“肩膀”鍵,在手柄的左上角和右上角。

#define XINPUT_GAMEPAD_LEFT_SHOULDER    0x0100
#define XINPUT_GAMEPAD_RIGHT_SHOULDER   0x0200

下圖中藍色圈內就是。

 

好,回到 XINPUT_GAMEPAD 結構體,接着看其他字段。

typedef struct _XINPUT_GAMEPAD
{
    ……
    BYTE                                bLeftTrigger;
    BYTE                                bRightTrigger;
    SHORT                               sThumbLX;
    SHORT                               sThumbLY;
    SHORT                               sThumbRX;
    SHORT                               sThumbRY;
} XINPUT_GAMEPAD, *PXINPUT_GAMEPAD;

bLeftTrigger 和 bRightTrigger 是兩個扳機鍵,玩打鬼子游戲時用來開槍,它的範圍是 0 - 255,所以類型是字節。

後機四個 sThumb-- 是兩個搖桿的讀數(左搖桿的X、Y值,右搖桿的X、Y值),範圍是有符號的 16 位整數值,取值在 -32768 和 32767 內,搖桿位於中心位置時,讀值爲 0。搖桿向前(向上)推時Y爲正值,向後(向下)推時Y爲負值;搖桿向左推時X爲負值,向右推時X爲正值。

就這樣了,有了上述知識,你已經可以讀手柄了。下面咱們做個示例。

新建一個 C++ 控制檯項目就可以了,不需要 Windows / Win32 應用。

a、包含 Xinput.h 頭文件。

#include <stdio.h>
#include <Windows.h>
#include <Xinput.h>

 

b、光包含頭文件還不行,因爲項目默認沒有導入相關的 .lib 文件。.lib 可不是什麼靜態庫,只是描述動態庫的符號罷了。在“解決方案”窗口右擊項目,打開屬性窗口。“配置”處選“所有”,免得爲 Debug 和 Release 版本重複配置。

 

在左邊導航節點中找到“鏈接器” -> “輸入”,並選中。在窗口右邊點擊“附加依賴項”右邊的下拉箭頭,點擊“編輯...”。

 

在彈出的對話框中加上 “Xinput.lib”。

確定保存即可。 

下面是整個程序的代碼:

#include <stdio.h>
#include <Windows.h>
#include <Xinput.h>

/* 此變量保存讀數序號 */
static unsigned long readOrder = 0;

/* 入口點 */
int main(int argc, char** argv)
{
    /* 開始讀數 */
    XINPUT_STATE xis = { 0 };
    while (1)
    {
        if (ERROR_SUCCESS != XInputGetState(0, &xis)) {
            continue;    /* 這一次沒讀成功,下一次再讀 */
        }
        /* 注意比較一下數據序號,相同的值不需要處理 */
        if (readOrder == xis.dwPacketNumber) {
            continue;
        }
        /* 保存新的序號 */
        readOrder = xis.dwPacketNumber;
        /* 分析數據 */
        printf_s("左搖桿:x= %d,y= %d\t右搖桿:x= %d,y= %d\n",
            xis.Gamepad.sThumbLX,
            xis.Gamepad.sThumbLY,
            xis.Gamepad.sThumbRX,
            xis.Gamepad.sThumbRY);

        /* 休息一會兒 */
        Sleep(60);
    }
    printf_s("即將退出\n");
    return 0;
}

XInputGetState 函數調用成功,返回 ERROR_SUCCESS,也就是 0。注意:每次讀取後,要比較一下數據序號,如果新序號沒有變,說明手柄的狀態未改變,不用處理;如果值不相同,說明有新的狀態,要處理,並且保存最新的數據序號

把你的手柄連接好,運行程序。接着推動左右搖桿,會看到控制檯打印各個座標值。

如果需要,還可以加入對按鍵的判斷,比如這裏,我加入對 X、Y、A、B 鍵的判斷。

    while (1)
    {
        ………………
        /* 判斷按鍵 */
        if ((xis.Gamepad.wButtons & XINPUT_GAMEPAD_A) == XINPUT_GAMEPAD_A)
        {
            printf_s("你按下了【A】鍵\t");
        }
        else if ((xis.Gamepad.wButtons & XINPUT_GAMEPAD_B) == XINPUT_GAMEPAD_B)
        {
            printf_s("你按下了【B】鍵\t");
        }
        else if ((xis.Gamepad.wButtons & XINPUT_GAMEPAD_X) == XINPUT_GAMEPAD_X)
        {
            printf_s("你按下了【X】鍵\t");
        }
        else if ((xis.Gamepad.wButtons & XINPUT_GAMEPAD_Y) == XINPUT_GAMEPAD_Y)
        {
            printf_s("你按下了【Y】鍵");
        }
        printf_s("\n");

        /* 休息一會兒 */
        Sleep(60);
    }

各個鍵位的宏所定義的值都是佔用一個二進制位,所以這裏咱們可以通過位運算來確定哪個按鈕被觸發。

效果如下:

 

-----------------------------------------------------------------------------------------------------

好了,現在,離模擬鼠標動作不遠了,下一步就是如發送消息了。發送輸入模擬需要用 SendInput 函數。它的原型如下:

UINT SendInput(
    UINT cInputs,                   // number of input in the array
    LPINPUT pInputs,                // array of inputs
    int cbSize);                    // sizeof(INPUT)

爲了好看,我去掉修飾參數的宏。這個函數如果返回 0,說明輸入消息發送不成功;成功的時候是返回已發送的消息數。調用這個函數的核心是 INPUT 結構體。一個 INPUT 實例就代表一條指令,一個操作可能會有多條指令完成,所以, INPUT以數組的形式傳遞。

cInputs 參數指定數組中 INPUT 實例的個數,cbSize 是一個 INPUT 實例的大小(字節,用 sizeof 運算符)。pInputs 就是指向 INPUT 數組第一個元素的指針。

然後,咱們瞭解一下 INPUT 結構體。

typedef struct tagINPUT {
    DWORD   type;

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

指向 INPUT 結構的指針是 LP(長指針、分配遠程堆,所以有 far),這個咱們一般不用管它,這東西是16位處理器的遺留物,現在處理器都 32 位以上了。不過,咱們得關注的是:這個結構體裏面,除了第一個字段 type,其他的字段是共用內存的(union)。說人話就是,mi、ki、hi 字段的偏移地址相同。不過,這個結構體是 8 字節對齊的,type 只有4字節,剩下4字節留空,下一個字段是從第9個字節開始(偏移是8)。最後就相當於第一個字段用了8字節,後面的字段用32字節,整個結構體40字節。如果 dll import 到 .NET 項目,得注意這個偏移,不然後無法調用。如果你照抄網上的 PInvoke 代碼,至少在 64 位系統上是不起作用的。老周後面的水文中會告訴大夥伴們怎麼 Dll Import,

mi、ki、hi 這幾個貨的大小並不一致,分別佔用 32、24、8 字節。爲了使用訪問性能最優,故選擇 8 字節對齊。說人話就是程序單次處理8個字節,如 8、16、24、32、64 等。

INPUT 結構體的 type 字段指明要模擬的輸入類型:

#define INPUT_MOUSE     0            /* 鼠標 */
#define INPUT_KEYBOARD  1            /* 鍵盤 */
#define INPUT_HARDWARE  2            /* 硬件消息 */

從名字就知道是啥了,就是模擬鼠標、鍵盤事件,這兩個是最常用的;第三個是除鼠標、鍵盤以外的硬件輸入消息,直接用消息編號,這個極少用。這三個值對應 INPUT 結構體中的 mi、ki、hi 字段。

咱們的例子只是模擬鼠標動作,所以用到 MOUSEINPUT 結構體。

typedef struct tagMOUSEINPUT {
    LONG    dx;
    LONG    dy;
    DWORD   mouseData;
    DWORD   dwFlags;
    DWORD   time;
    ULONG_PTR dwExtraInfo;
} MOUSEINPUT, *PMOUSEINPUT, FAR* LPMOUSEINPUT;

dwFlags 是一個整數值,可以由多個二進制組合使用,包括:

#define MOUSEEVENTF_MOVE        0x0001 /* mouse move */
#define MOUSEEVENTF_LEFTDOWN    0x0002 /* left button down */
#define MOUSEEVENTF_LEFTUP      0x0004 /* left button up */
#define MOUSEEVENTF_RIGHTDOWN   0x0008 /* right button down */
#define MOUSEEVENTF_RIGHTUP     0x0010 /* right button up */
#define MOUSEEVENTF_MIDDLEDOWN  0x0020 /* middle button down */
#define MOUSEEVENTF_MIDDLEUP    0x0040 /* middle button up */
#define MOUSEEVENTF_XDOWN       0x0080 /* x button down */
#define MOUSEEVENTF_XUP         0x0100 /* x button down */
#define MOUSEEVENTF_WHEEL                0x0800 /* wheel button rolled */
#if (_WIN32_WINNT >= 0x0600)
#define MOUSEEVENTF_HWHEEL              0x01000 /* hwheel button rolled */
#endif
#if(WINVER >= 0x0600)
#define MOUSEEVENTF_MOVE_NOCOALESCE      0x2000 /* do not coalesce mouse moves */
#endif /* WINVER >= 0x0600 */
#define MOUSEEVENTF_VIRTUALDESK          0x4000 /* map to entire virtual desktop */
#define MOUSEEVENTF_ABSOLUTE             0x8000 /* absolute move */

這裏老周僅模擬移動、左鍵按下/彈起、右鍵按下/彈起,以及滾輪。如果要模擬滾輪,要指定 MOUSEEVENTF_WHEEL,滾輪的值通過 MOUSEINPUT 結構體的 mouseData 字段傳遞

 

把前面的示例程序改一下,這回咱們不輸出文本了,而是從手柄讀數據,然後用 SendInput 函數發送鼠標模擬。

    while (1)
    {
        if (ERROR_SUCCESS != XInputGetState(0, &xis)) {
            continue;    /* 這一次沒讀成功,下一次再讀 */
        }
        /* 注意比較一下數據序號,相同的值不需要處理 */
        if (readOrder == xis.dwPacketNumber) {
            continue;
        }
        /* 保存新的序號 */
        readOrder = xis.dwPacketNumber;
        
        // 轉換一下
        int xx = xis.Gamepad.sThumbRX / 1000;
        int yy = -xis.Gamepad.sThumbRY / 1000;
        // 這個是滾輪
        int wheel = xis.Gamepad.sThumbLY / 500;
        /* 準備發送消息 */
        INPUT mouseAction = { 0 };
        mouseAction.type = INPUT_MOUSE;
        // 設置偏移座標
        mouseAction.mi.dwFlags = MOUSEEVENTF_MOVE | MOUSEEVENTF_WHEEL;
        mouseAction.mi.dx = xx;
        mouseAction.mi.dy = yy;
        mouseAction.mi.mouseData = wheel;    // 滾輪數據
        SendInput(1, &mouseAction, sizeof(INPUT));

        /* 模擬左鍵單擊 */
        if ((xis.Gamepad.wButtons & XINPUT_GAMEPAD_A) == XINPUT_GAMEPAD_A)
        {
            // 一次單擊包含兩條消息:按下 + 彈起
            INPUT leftDown = { 0 };
            leftDown.type = INPUT_MOUSE;
            leftDown.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
            INPUT leftUp = { 0 };
            leftUp.type = INPUT_MOUSE;
            leftUp.mi.dwFlags = MOUSEEVENTF_LEFTUP;
            // 創建數組
            INPUT cmds[] = { leftDown, leftUp };
            SendInput(2, cmds, sizeof(INPUT));
        }

        /* 模擬右鍵單擊 */
        if ((xis.Gamepad.wButtons & XINPUT_GAMEPAD_B) == XINPUT_GAMEPAD_B)
        {
            // 也是兩條消息:右鍵按下 + 彈起
            INPUT rightDown = { 0 };
            rightDown.type = INPUT_MOUSE;
            rightDown.mi.dwFlags = MOUSEEVENTF_RIGHTDOWN;
            INPUT rightUp = { 0 };
            rightUp.type = INPUT_MOUSE;
            rightUp.mi.dwFlags = MOUSEEVENTF_RIGHTUP;
            // 創建數組
            INPUT inputs[] = { rightDown, rightUp };
            SendInput(2, inputs, sizeof(INPUT));
        }

        /* 休息一會兒 */
        Sleep(60);
    }

MOUSEINPUT結構體的 dwFlags 字段要注意一下:

1、移動鼠標用的是 MOUSEEVENTF_MOVE, 滾輪用的是 MOUSEEVENTF_WHEEL。這裏老周是把兩者合起來發送:MOUSEEVENTF_MOVE | MOUSEEVENTF_WHEEL;

2、MOUSEEVENTF_MOVE 值可以與 MOUSEEVENTF_ABSOLUTE 值組合用。如果指定了 MOUSEEVENTF_ABSOLUTE,表示使用絕對定位座標,值的範圍在 0 和 65535 之間。這個範圍不管你的顯示器屏幕的大小,總之,左上角是 (0, 0),右下角是 (65535, 65535),鼠標指針的位置在這範圍內換算。這裏不用 MOUSEEVENTF_ABSOLUTE 值,dx、dy 就變成移動量,以像素爲單位的。正值表示向下/向右移動;負值表示向上/向左移動。這裏還是選擇移動量好一些,可避免鼠標指針飄得太快難以操控。例如,-22 表示向反方向移動 22 像素,+50 表示正向移動 50 像素。

前面的演示中咱們知道搖桿的讀值是 -32768 到 32767,這個值有點大,所以老周做了運算:

int xx = xis.Gamepad.sThumbRX / 1000;
int yy = -xis.Gamepad.sThumbRY / 1000;
// 這個是滾輪
int wheel = xis.Gamepad.sThumbLY / 500;

移動量除以 1000,滾輪值除以 500。這個換算不是固定的,只是老周覺得這個值比較合適,除數的值越大,活動的範圍越小。

在上面演示中,老周用 A 鍵模擬左鍵單擊,B 鍵模擬右鍵單擊。左搖桿的 Y 方向模擬滾輪,右搖桿模擬鼠標指針的移動。當然,你可以使用任何你喜歡的鍵和搖桿來模擬。我這裏僅作參考。

用手柄移動手柄的效果如下:

 

模擬鼠標點擊效果如下:

 

滾輪模擬的效果如下:

 

好了,今天就說到這裏。下一篇咱們用 .NET P/Invoke 來實現。

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