diff --git a/AquaMai.Mods/GameSystem/ExclusiveTouch/ExclusiveTouch.cs b/AquaMai.Mods/GameSystem/ExclusiveTouch/ExclusiveTouch.cs index 4135880f..2d304d80 100644 --- a/AquaMai.Mods/GameSystem/ExclusiveTouch/ExclusiveTouch.cs +++ b/AquaMai.Mods/GameSystem/ExclusiveTouch/ExclusiveTouch.cs @@ -1,53 +1,18 @@ using System; -using AquaMai.Config.Attributes; using LibUsbDotNet.Main; using LibUsbDotNet; using MelonLoader; using UnityEngine; using AquaMai.Core.Helpers; using System.Threading; +using JetBrains.Annotations; namespace AquaMai.Mods.GameSystem.ExclusiveTouch; -[ConfigCollapseNamespace] -[ConfigSection(exampleHidden: true)] -public class ExclusiveTouch +public abstract class ExclusiveTouchBase(int playerNo, int vid, int pid, [CanBeNull] string serialNumber, [CanBeNull] string locationPath, byte configuration, int interfaceNumber, ReadEndpointID endpoint, int packetSize, int minX, int minY, int maxX, int maxY, bool flip, int radius) { - [ConfigEntry] - public static readonly bool enable1p; - - [ConfigEntry] - public static readonly int vid1p; - [ConfigEntry] - public static readonly int pid1p; - [ConfigEntry] - public static readonly string serialNumber1p = ""; - [ConfigEntry] - public static readonly byte configuration1p = 1; - [ConfigEntry] - public static readonly int interfaceNumber1p = 0; - [ConfigEntry] - public static readonly int reportId1p; - [ConfigEntry] - public static readonly ReadEndpointID endpoint1p = ReadEndpointID.Ep01; - [ConfigEntry] - public static readonly int packetSize1p = 64; - [ConfigEntry] - public static readonly int minX1p; - [ConfigEntry] - public static readonly int minY1p; - [ConfigEntry] - public static readonly int maxX1p; - [ConfigEntry] - public static readonly int maxY1p; - [ConfigEntry] - public static readonly bool flip1p; - - [ConfigEntry("触摸体积半径", zh: "基准是 1440x1440")] - public static readonly int radius1p; - - private static UsbDevice[] devices = new UsbDevice[2]; - private static TouchSensorMapper[] touchSensorMappers = new TouchSensorMapper[2]; + private UsbDevice device; + private TouchSensorMapper touchSensorMapper; private class TouchPoint { @@ -56,182 +21,155 @@ private class TouchPoint public bool IsActive; } - // [玩家][手指ID] - private static readonly TouchPoint[][] allFingerPoints = new TouchPoint[2][]; + // [手指ID] + private readonly TouchPoint[] allFingerPoints = new TouchPoint[256]; // 防吃键 - private static readonly ulong[] frameAccumulators = new ulong[2]; - private static readonly object[] touchLocks = [new object(), new object()]; + private ulong frameAccumulators; + private readonly object touchLock = new(); private const int TouchTimeoutMs = 20; - public static void OnBeforePatch() + public void Start() { - if (enable1p) + // 方便组 2P + UsbDeviceFinder finder; + + if (!string.IsNullOrWhiteSpace(serialNumber)) { - // 方便组 2P - var serialNumber = string.IsNullOrWhiteSpace(serialNumber1p) ? null : serialNumber1p; - var finder = new UsbDeviceFinder(vid1p, pid1p, serialNumber); - var device = UsbDevice.OpenUsbDevice(finder); - if (device == null) + // 优先使用序列号 + finder = new UsbDeviceFinder(vid, pid, serialNumber); + } + else if (!string.IsNullOrWhiteSpace(locationPath)) + { + // 使用位置路径匹配 + finder = new UsbDeviceLocationFinder(vid, pid, locationPath); + } + else + { + // 使用第一个匹配的设备 + finder = new UsbDeviceFinder(vid, pid); + } + + device = UsbDevice.OpenUsbDevice(finder); + if (device == null) + { + MelonLogger.Msg($"[ExclusiveTouch] Cannot connect {playerNo + 1}P"); + } + else + { + IUsbDevice wholeDevice = device as IUsbDevice; + if (wholeDevice != null) { - MelonLogger.Msg("[ExclusiveTouch] Cannot connect 1P"); + wholeDevice.SetConfiguration(configuration); + wholeDevice.ClaimInterface(interfaceNumber); } - else + touchSensorMapper = new TouchSensorMapper(minX, minY, maxX, maxY, radius, flip); + Application.quitting += () => { - IUsbDevice wholeDevice = device as IUsbDevice; + var tmpDevice = device; + device = null; if (wholeDevice != null) { - wholeDevice.SetConfiguration(configuration1p); - wholeDevice.ClaimInterface(interfaceNumber1p); - } - touchSensorMappers[0] = new TouchSensorMapper(minX1p, minY1p, maxX1p, maxY1p, radius1p, flip1p); - Application.quitting += () => - { - devices[0] = null; - if (wholeDevice != null) - { - wholeDevice.ReleaseInterface(interfaceNumber1p); - } - device.Close(); - }; - - allFingerPoints[0] = new TouchPoint[256]; - for (int i = 0; i < 256; i++) - { - allFingerPoints[0][i] = new TouchPoint(); + wholeDevice.ReleaseInterface(interfaceNumber); } + tmpDevice.Close(); + }; - devices[0] = device; - Thread readThread = new Thread(() => ReadThread(0)); - readThread.Start(); - TouchStatusProvider.RegisterTouchStatusProvider(0, GetTouchState); - } - } - } - - private static void ReadThread(int playerNo) - { - byte[] buffer = new byte[packetSize1p]; - var reader = devices[playerNo].OpenEndpointReader(endpoint1p); - while (devices[playerNo] != null) - { - int bytesRead; - ErrorCode ec = reader.Read(buffer, 100, out bytesRead); // 100ms 超时 - - if (ec != ErrorCode.None) + for (int i = 0; i < 256; i++) { - if (ec == ErrorCode.IoTimedOut) continue; // 超时就继续等 - MelonLogger.Msg($"[ExclusiveTouch] {playerNo + 1}P: 读取错误: {ec}"); - break; + allFingerPoints[i] = new TouchPoint(); } - if (bytesRead > 0) - { - OnTouchData(playerNo, buffer); - } + Thread readThread = new(ReadThread); + readThread.Start(); + TouchStatusProvider.RegisterTouchStatusProvider(playerNo, GetTouchState); } } - private static void OnTouchData(int playerNo, byte[] data) + private void ReadThread() { - byte reportId = data[0]; - if (reportId != reportId1p) return; - -#if true // PDX - for (int i = 0; i < 10; i++) - { - var index = i * 6 + 1; - if (data[index] == 0) continue; - bool isPressed = (data[index] & 0x01) == 1; - var fingerId = data[index + 1]; - ushort x = BitConverter.ToUInt16(data, index + 2); - ushort y = BitConverter.ToUInt16(data, index + 4); - HandleFinger(x, y, fingerId, isPressed, playerNo); - } -#else // 凌莞的便携屏 - // 解析第一根手指 - if (data.Length >= 7) + byte[] buffer = new byte[packetSize]; + var reader = device.OpenEndpointReader(endpoint); + + try { - byte status1 = data[1]; - int fingerId1 = (status1 >> 4) & 0x0F; // 高4位:手指ID - bool isPressed1 = (status1 & 0x01) == 1; // 低位:按下状态 - ushort x1 = BitConverter.ToUInt16(data, 2); - ushort y1 = BitConverter.ToUInt16(data, 4); + while (device != null) + { + int bytesRead; + ErrorCode ec = reader.Read(buffer, 100, out bytesRead); // 100ms 超时 - HandleFinger(x1, y1, fingerId1, isPressed1, playerNo); - } + if (ec != ErrorCode.None) + { + if (ec == ErrorCode.IoTimedOut) continue; // 超时就继续等 + MelonLogger.Msg($"[ExclusiveTouch] {playerNo + 1}P: 读取错误: {ec}"); + break; + } - // 解析第二根手指 - if (data.Length >= 14) - { - byte status2 = data[6]; - int fingerId2 = (status2 >> 4) & 0x0F; - bool isPressed2 = (status2 & 0x01) == 1; - ushort x2 = BitConverter.ToUInt16(data, 7); - ushort y2 = BitConverter.ToUInt16(data, 9); - - // 只有坐标非零才处理第二根手指 - if (x2 != 0 || y2 != 0) - { - HandleFinger(x2, y2, fingerId2, isPressed2, playerNo); + if (bytesRead > 0) + { + OnTouchData(buffer); + } } } -#endif + finally + { + // 确保 reader 被正确释放 + reader?.Dispose(); + } } - private static void HandleFinger(ushort x, ushort y, int fingerId, bool isPressed, int playerNo) + protected abstract void OnTouchData(byte[] data); + + protected void HandleFinger(ushort x, ushort y, int fingerId, bool isPressed) { // 安全检查,防止越界 if (fingerId < 0 || fingerId >= 256) return; - lock (touchLocks[playerNo]) + lock (touchLock) { - var point = allFingerPoints[playerNo][fingerId]; + var point = allFingerPoints[fingerId]; if (isPressed) { - ulong touchMask = touchSensorMappers[playerNo].ParseTouchPoint(x, y); + ulong touchMask = touchSensorMapper.ParseTouchPoint(x, y); if (!point.IsActive) { point.IsActive = true; - MelonLogger.Msg($"[ExclusiveTouch] {playerNo + 1}P: 手指{fingerId} 按下 at ({x}, {y}) -> 0x{touchMask:X}"); } point.Mask = touchMask; point.LastUpdate = DateTime.Now; - - frameAccumulators[playerNo] |= touchMask; + + frameAccumulators |= touchMask; } else { if (point.IsActive) { point.IsActive = false; - MelonLogger.Msg($"[ExclusiveTouch] {playerNo + 1}P: 手指{fingerId} 松开"); } } } } - public static ulong GetTouchState(int playerNo) + private ulong GetTouchState(int player) { - lock (touchLocks[playerNo]) + if (player != playerNo) return 0; + lock (touchLock) { ulong currentTouchData = 0; var now = DateTime.Now; - var points = allFingerPoints[playerNo]; - for (int i = 0; i < points.Length; i++) + for (int i = 0; i < allFingerPoints.Length; i++) { - var point = points[i]; + var point = allFingerPoints[i]; if (point.IsActive) { if ((now - point.LastUpdate).TotalMilliseconds > TouchTimeoutMs) { point.IsActive = false; - MelonLogger.Msg($"[ExclusiveTouch] {playerNo + 1}P: 手指{i} 超时自动释放"); } else { @@ -239,9 +177,9 @@ public static ulong GetTouchState(int playerNo) } } } - - ulong finalResult = currentTouchData | frameAccumulators[playerNo]; - frameAccumulators[playerNo] = 0; + + ulong finalResult = currentTouchData | frameAccumulators; + frameAccumulators = 0; return finalResult; } diff --git a/AquaMai.Mods/GameSystem/ExclusiveTouch/TouchSensorMapper.cs b/AquaMai.Mods/GameSystem/ExclusiveTouch/TouchSensorMapper.cs index e97d1b02..f1e13728 100644 --- a/AquaMai.Mods/GameSystem/ExclusiveTouch/TouchSensorMapper.cs +++ b/AquaMai.Mods/GameSystem/ExclusiveTouch/TouchSensorMapper.cs @@ -231,15 +231,27 @@ public ulong ParseTouchPoint(float x, float y) // 检查所有传感器 for (int i = 0; i < 34; i++) { - bool isInsidePolygon = PolygonRaycasting.IsVertDistance(_sensors[i], canvasPoint, radius); - if (!isInsidePolygon) + bool isInsidePolygon; + + if (radius > 0) { - isInsidePolygon = PolygonRaycasting.IsCircleIntersectingPolygonEdges(_sensors[i], canvasPoint, radius); + // 当有半径时,需要检查圆与多边形的关系 + isInsidePolygon = PolygonRaycasting.IsVertDistance(_sensors[i], canvasPoint, radius); + if (!isInsidePolygon) + { + isInsidePolygon = PolygonRaycasting.IsCircleIntersectingPolygonEdges(_sensors[i], canvasPoint, radius); + } + if (!isInsidePolygon) + { + isInsidePolygon = PolygonRaycasting.InPointInInternal(_sensors[i], canvasPoint); + } } - if (!isInsidePolygon) + else { + // 当半径为0时,只需要检查点是否在多边形内部 isInsidePolygon = PolygonRaycasting.InPointInInternal(_sensors[i], canvasPoint); } + if (isInsidePolygon) { res |= 1ul << i; diff --git a/AquaMai.Mods/GameSystem/ExclusiveTouch/UsbDeviceLocationFinder.cs b/AquaMai.Mods/GameSystem/ExclusiveTouch/UsbDeviceLocationFinder.cs new file mode 100644 index 00000000..b441c57d --- /dev/null +++ b/AquaMai.Mods/GameSystem/ExclusiveTouch/UsbDeviceLocationFinder.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LibUsbDotNet.Main; + +namespace AquaMai.Mods.GameSystem.ExclusiveTouch; + +/// +/// 基于 USB 物理位置路径的设备查找器 +/// 支持格式: "2.2" (端口链) +/// +public class UsbDeviceLocationFinder : UsbDeviceFinder +{ + private readonly string locationPath; + + public UsbDeviceLocationFinder(int vid, int pid, string locationPath) + : base(vid, pid) + { + this.locationPath = locationPath?.Trim(); + } + + public override bool Check(UsbRegistry usbRegistry) + { + // 先检查 VID/PID + if (!base.Check(usbRegistry)) return false; + + // 如果没有指定位置,就只匹配 VID/PID + if (string.IsNullOrWhiteSpace(locationPath)) return true; + + // 获取 LocationPaths (SPDRP 0x23) + var locationPaths = usbRegistry[SPDRP.LocationPaths] as string[]; + if (locationPaths != null && locationPaths.Length > 0) + { + // 检查是否有任何一个路径匹配 + foreach (var path in locationPaths) + { + if (MatchLocation(path, locationPath)) + { + return true; + } + } + } + + // 尝试 LocationInformation (SPDRP 0x0D) + var locationInfo = usbRegistry[SPDRP.LocationInformation] as string; + if (!string.IsNullOrEmpty(locationInfo)) + { + if (MatchLocation(locationInfo, locationPath)) + { + return true; + } + } + + // 尝试 DeviceID + var deviceId = usbRegistry["DeviceID"] as string; + if (!string.IsNullOrEmpty(deviceId)) + { + if (deviceId.IndexOf(locationPath, StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + } + + return false; + } + + private bool MatchLocation(string deviceLocation, string targetLocation) + { + if (string.IsNullOrEmpty(deviceLocation)) return false; + + // 完全匹配 + if (deviceLocation.Equals(targetLocation, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // 部分匹配 (包含) + if (deviceLocation.IndexOf(targetLocation, StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + + // 提取端口号进行匹配 + var devicePorts = ExtractPortNumbers(deviceLocation); + var targetPorts = ExtractPortNumbers(targetLocation); + + if (devicePorts.Count > 0 && targetPorts.Count > 0) + { + return PortsMatch(devicePorts, targetPorts); + } + + return false; + } + + private List ExtractPortNumbers(string path) + { + var numbers = new List(); + + // Windows 格式: PCIROOT(0)#PCI(0801)#PCI(0003)#USBROOT(0)#USB(2)#USB(2)#USBMI(1) + // 只提取 #USB(n) 部分,忽略 USBROOT 和 USBMI + var usbMatches = System.Text.RegularExpressions.Regex.Matches( + path, + @"#USB\((\d+)\)" + ); + + foreach (System.Text.RegularExpressions.Match match in usbMatches) + { + if (match.Groups.Count > 1 && int.TryParse(match.Groups[1].Value, out int port)) + { + numbers.Add(port); + } + } + + // 如果没有找到 Windows 格式,尝试简单的点分格式: 2.2 或 2.3.1 + if (numbers.Count == 0) + { + var parts = path.Split(new[] { '.', '-' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var part in parts) + { + // 跳过 "bus" 和 "addr" 这样的前缀 + if (part.StartsWith("bus", StringComparison.OrdinalIgnoreCase) || + part.StartsWith("addr", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (int.TryParse(part.Trim(), out int port)) + { + numbers.Add(port); + } + } + } + + return numbers; + } + + private bool PortsMatch(List devicePorts, List targetPorts) + { + if (targetPorts.Count == 0) return false; + if (devicePorts.Count < targetPorts.Count) return false; + + // 检查 targetPorts 是否是 devicePorts 的子序列 + int j = 0; + for (int i = 0; i < devicePorts.Count && j < targetPorts.Count; i++) + { + if (devicePorts[i] == targetPorts[j]) + { + j++; + } + } + + return j == targetPorts.Count; + } +} diff --git a/AquaMai.Mods/GameSystem/PdxTouch.cs b/AquaMai.Mods/GameSystem/PdxTouch.cs new file mode 100644 index 00000000..3419bbf1 --- /dev/null +++ b/AquaMai.Mods/GameSystem/PdxTouch.cs @@ -0,0 +1,81 @@ +using System; +using AquaMai.Config.Attributes; +using AquaMai.Mods.GameSystem.ExclusiveTouch; +using LibUsbDotNet.Main; + +namespace AquaMai.Mods.GameSystem; + +[ConfigSection("PDX 独占触摸")] +public class PdxTouch +{ + [ConfigEntry("触摸体积半径", zh: "基准是 1440x1440")] + public static readonly int radius = 30; + + [ConfigEntry("1P 设备路径", zh: "USB 端口路径,例如 2.2。请使用配置工具中显示的路径。留空则使用第一个检测到的设备作为 1P")] + public static readonly string path1p = ""; + + [ConfigEntry("2P 设备路径")] + public static readonly string path2p = ""; + + private static readonly PdxTouchDevice[] devices = new PdxTouchDevice[2]; + + public static void OnBeforePatch() + { + if (string.IsNullOrWhiteSpace(path1p) && string.IsNullOrWhiteSpace(path2p)) + { + // 没有配置任何路径,使用第一个设备 + devices[0] = new PdxTouchDevice(0, null); + devices[0].Start(); + } + else + { + // 配置了路径,按路径查找 + if (!string.IsNullOrWhiteSpace(path1p)) + { + devices[0] = new PdxTouchDevice(0, path1p); + devices[0].Start(); + } + if (!string.IsNullOrWhiteSpace(path2p)) + { + devices[1] = new PdxTouchDevice(1, path2p); + devices[1].Start(); + } + } + } + + private class PdxTouchDevice(int playerNo, string locationPath) : ExclusiveTouchBase( + playerNo, + vid: 0x3356, + pid: 0x3003, + serialNumber: null, // PDX 设备没有序列号 + locationPath, // 使用路径匹配 + configuration: 1, + interfaceNumber: 1, + ReadEndpointID.Ep02, + packetSize: 64, + minX: 18432, + minY: 0, + maxX: 0, + maxY: 32767, + flip: true, + radius) + { + private const byte ReportId = 2; + protected override void OnTouchData(byte[] data) + { + byte reportId = data[0]; + if (reportId != ReportId) return; + + for (int i = 0; i < 10; i++) + { + var index = i * 6 + 1; + if (data[index] == 0) continue; + bool isPressed = (data[index] & 0x01) == 1; + var fingerId = data[index + 1]; + ushort x = BitConverter.ToUInt16(data, index + 2); + ushort y = BitConverter.ToUInt16(data, index + 4); + HandleFinger(x, y, fingerId, isPressed); + } + } + } +}