7

【XInput】游戏手柄模拟鼠标动作 - 东邪独孤

 7 months ago
source link: https://www.cnblogs.com/tcjiaan/p/18019745
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

【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

就是中间那四个,如下图红圈内。

367389-20240218173226109-1509835083.png

2、开始和返回。

#define XINPUT_GAMEPAD_START            0x0010
#define XINPUT_GAMEPAD_BACK             0x0020

如下图黄圈那两个键:

367389-20240218173723748-1688282523.png

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

就是右摇杆上面的四个键,如下图绿色圈内部分:

367389-20240218174001082-1087091059.png

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

下图中蓝色圈内就是。

367389-20240218174448627-965846156.png

好,回到 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 版本重复配置。

367389-20240218175829983-1073269584.png

在左边导航节点中找到“链接器” -> “输入”,并选中。在窗口右边点击“附加依赖项”右边的下拉箭头,点击“编辑...”。

367389-20240218180118891-2019286905.png

在弹出的对话框中加上 “Xinput.lib”。

367389-20240218180328038-30326417.png

确定保存即可。 

下面是整个程序的代码:

#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。注意:每次读取后,要比较一下数据序号,如果新序号没有变,说明手柄的状态未改变,不用处理;如果值不相同,说明有新的状态,要处理,并且保存最新的数据序号

把你的手柄连接好,运行程序。接着推动左右摇杆,会看到控制台打印各个坐标值。

367389-20240218185125882-95750017.png

如果需要,还可以加入对按键的判断,比如这里,我加入对 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);
    }

各个键位的宏所定义的值都是占用一个二进制位,所以这里咱们可以通过位运算来确定哪个按钮被触发。

效果如下:

367389-20240219104115123-307358834.png

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

好了,现在,离模拟鼠标动作不远了,下一步就是如发送消息了。发送输入模拟需要用 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 方向模拟滚轮,右摇杆模拟鼠标指针的移动。当然,你可以使用任何你喜欢的键和摇杆来模拟。我这里仅作参考。

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

367389-20240219162521853-500747606.gif

模拟鼠标点击效果如下:

367389-20240219162554731-2144517772.gif

滚轮模拟的效果如下:

367389-20240219162636724-268995538.gif

好了,今天就说到这里。下一篇咱们用 .NET P/Invoke 来实现。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK