4

【XInput】手柄模拟鼠标运作之 .NET P/Invoke 和 UWP-API 方案 - 东邪独孤

 6 months ago
source link: https://www.cnblogs.com/tcjiaan/p/18048489
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】手柄模拟鼠标运作之 .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

367389-20240302180130072-160064775.png

这里能看到 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 读取一次数据,并显示在窗口上。窗口的结构如下:

367389-20240302221719676-2053298221.png

主要用到的是 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,

     // 其他按键
}

一起来看看效果。

367389-20240302224701216-6675084.png

最后,共享点猛料给大伙伴。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。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK