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);
+ }
+ }
+ }
+}