Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 55 additions & 143 deletions AquaMai.Mods/GameSystem/ExclusiveTouch/ExclusiveTouch.cs
Original file line number Diff line number Diff line change
@@ -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, 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
{
Expand All @@ -56,65 +21,60 @@ 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
var finder = new UsbDeviceFinder(vid, pid, string.IsNullOrWhiteSpace(serialNumber) ? null : serialNumber);
device = UsbDevice.OpenUsbDevice(finder);
if (device == null)
{
// 方便组 2P
var serialNumber = string.IsNullOrWhiteSpace(serialNumber1p) ? null : serialNumber1p;
var finder = new UsbDeviceFinder(vid1p, pid1p, serialNumber);
var device = UsbDevice.OpenUsbDevice(finder);
if (device == null)
MelonLogger.Msg("[ExclusiveTouch] Cannot connect 1P");
}
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();
};
Comment on lines +68 to +77

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In the Application.quitting event handler, if wholeDevice.ReleaseInterface(interfaceNumber) throws an exception, tmpDevice.Close() will not be called, which could lead to an unclosed resource. It's safer to wrap the resource release logic in a try...finally block to guarantee that Close() is always called.

            Application.quitting += () =>
            {
                var tmpDevice = device;
                device = null;
                try
                {
                    if (wholeDevice != null)
                    {
                        wholeDevice.ReleaseInterface(interfaceNumber);
                    }
                }
                finally
                {
                    tmpDevice.Close();
                }
            };


devices[0] = device;
Thread readThread = new Thread(() => ReadThread(0));
readThread.Start();
TouchStatusProvider.RegisterTouchStatusProvider(0, GetTouchState);
for (int i = 0; i < 256; i++)
{
allFingerPoints[i] = new TouchPoint();
}

Thread readThread = new(ReadThread);
readThread.Start();
TouchStatusProvider.RegisterTouchStatusProvider(playerNo, GetTouchState);
}
}

private static void ReadThread(int playerNo)
private void ReadThread()
{
byte[] buffer = new byte[packetSize1p];
var reader = devices[playerNo].OpenEndpointReader(endpoint1p);
while (devices[playerNo] != null)
byte[] buffer = new byte[packetSize];
var reader = device.OpenEndpointReader(endpoint);
while (device != null)
{
int bytesRead;
ErrorCode ec = reader.Read(buffer, 100, out bytesRead); // 100ms 超时
Expand All @@ -128,120 +88,72 @@ private static void ReadThread(int playerNo)

if (bytesRead > 0)
{
OnTouchData(playerNo, buffer);
OnTouchData(buffer);
}
}
}

private static void OnTouchData(int playerNo, byte[] data)
{
byte reportId = data[0];
if (reportId != reportId1p) return;
protected abstract void OnTouchData(byte[] data);

#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 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);

HandleFinger(x1, y1, fingerId1, isPressed1, playerNo);
}

// 解析第二根手指
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);
}
}
#endif
}

private static void HandleFinger(ushort x, ushort y, int fingerId, bool isPressed, int playerNo)
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
{
currentTouchData |= point.Mask;
}
}
}
ulong finalResult = currentTouchData | frameAccumulators[playerNo];
frameAccumulators[playerNo] = 0;

ulong finalResult = currentTouchData | frameAccumulators;
frameAccumulators = 0;

return finalResult;
}
Expand Down
20 changes: 16 additions & 4 deletions AquaMai.Mods/GameSystem/ExclusiveTouch/TouchSensorMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
60 changes: 60 additions & 0 deletions AquaMai.Mods/GameSystem/PdxTouch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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: "如果要组 2P,请在这里指定对应的序列号。否则将自动使用第一个检测到的设备作为 1P")]
public static readonly string serial1p;
[ConfigEntry("2P 序列号")]
public static readonly string serial2p;

private static readonly PdxTouchDevice[] devices = new PdxTouchDevice[2];

public static void OnBeforePatch()
{
if (string.IsNullOrWhiteSpace(serial1p) && string.IsNullOrWhiteSpace(serial2p))
{
devices[0] = new PdxTouchDevice(0, null);
devices[0].Start();
}
if (!string.IsNullOrWhiteSpace(serial1p))
{
devices[0] = new PdxTouchDevice(0, serial1p);
devices[0].Start();
}
if (!string.IsNullOrWhiteSpace(serial2p))
{
devices[1] = new PdxTouchDevice(1, serial2p);
devices[1].Start();
}
}
Comment on lines 22 to 44

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for initializing devices in OnBeforePatch is a bit complex to follow due to multiple, non-exclusive if statements. This can be simplified into a more readable and maintainable structure that achieves the same result but is clearer about its intent.

    public static void OnBeforePatch()
    {
        // Initialize 1P device if serial1p is provided, or if no serials are provided at all (auto-detect mode).
        // This logic does not initialize 1P if only 2P serial is provided, matching original behavior.
        if (!string.IsNullOrWhiteSpace(serial1p) || string.IsNullOrWhiteSpace(serial2p))
        {
            devices[0] = new PdxTouchDevice(0, serial1p);
            devices[0].Start();
        }

        // Initialize 2P device if serial2p is provided.
        if (!string.IsNullOrWhiteSpace(serial2p))
        {
            devices[1] = new PdxTouchDevice(1, serial2p);
            devices[1].Start();
        }
    }

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这样岂不是更绕


private class PdxTouchDevice(int playerNo, string serialNumber) : ExclusiveTouchBase(playerNo, vid: 0x3356, pid: 0x3003, serialNumber, configuration: 1, interfaceNumber: 1, ReadEndpointID.Ep02, packetSize: 64, minX: 18432, minY: 0, maxX: 0, maxY: 32767, flip: true, radius)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The constructor call for ExclusiveTouchBase uses several magic numbers for device-specific parameters (VID, PID, coordinates, etc.). To improve readability and maintainability, consider defining these as named constants within the PdxTouchDevice class and using them in the constructor call.

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