diff --git a/.gitignore b/.gitignore index 7539c501..a29d3248 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs +.claude/ +PLAN.md +CLAUDE.md +AGENTS.md # Build results [Dd]ebug/ @@ -340,4 +344,6 @@ ASALocalRun/ # BeatPulse healthcheck temp database healthchecksdb /ExternalResources -dist/ \ No newline at end of file +dist/ +# Added by code-review-graph +.code-review-graph/ diff --git a/Directory.Packages.props b/Directory.Packages.props index ad38b324..eedc3645 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,7 @@ + diff --git a/EasyCon2.slnx b/EasyCon2.slnx index 203d07ae..327457c0 100644 --- a/EasyCon2.slnx +++ b/EasyCon2.slnx @@ -17,9 +17,11 @@ + + @@ -27,6 +29,7 @@ + diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 10f96313..00000000 --- a/PLAN.md +++ /dev/null @@ -1,17 +0,0 @@ -我需要你为以下任务制定实现计划,只做分析和设计,不要写任何代码。 - -任务:xxx - -现有架构: -- 1 -- 2 - -约束: -- 不能 -- 必须 - -输出: -1.关键设计决策及各方案的 trade-off -2.文件/模块变更清单(新增/修改等) -3.实现顺序,分阶段,标出风险点 -4.需要我确认的不确定点 \ No newline at end of file diff --git a/ci/test.bat b/ci/test.bat index dbffd9cf..bcedea84 100644 --- a/ci/test.bat +++ b/ci/test.bat @@ -3,12 +3,11 @@ chcp 65001 >nul setlocal enabledelayedexpansion set "ROOT_DIR=%~dp0.." -set "TFM=net10.0" set "CONFIG=%1" if "%CONFIG%"=="" set "CONFIG=Release" echo Running tests (%CONFIG%)... -dotnet test "%ROOT_DIR%\EasyCon2.slnx" --nologo -c %CONFIG% -f %TFM% +dotnet test "%ROOT_DIR%\EasyCon2.slnx" --nologo -c %CONFIG% if errorlevel 1 ( echo ERROR: Tests failed pause diff --git a/src/EasyCon.Core/Config/KeyMappingConfig.cs b/src/EasyCon.Core/Config/KeyMappingConfig.cs index 661e26e5..eacdd79c 100644 --- a/src/EasyCon.Core/Config/KeyMappingConfig.cs +++ b/src/EasyCon.Core/Config/KeyMappingConfig.cs @@ -6,20 +6,20 @@ public record KeyMappingConfig public int B { get; set; } = 75; // Keys.K public int X { get; set; } = 73; // Keys.I public int Y { get; set; } = 74; // Keys.J - public int L { get; set; } = 85; // Keys.U - public int R { get; set; } = 79; // Keys.O - public int ZL { get; set; } = 81; // Keys.Q - public int ZR { get; set; } = 69; // Keys.E + public int L { get; set; } = 71; // Keys.G + public int R { get; set; } = 84; // Keys.T + public int ZL { get; set; } = 70; // Keys.F + public int ZR { get; set; } = 82; // Keys.R public int Plus { get; set; } = 107; // Numpad Add public int Minus { get; set; } = 109; // Numpad Subtract public int Capture { get; set; } = 90; // Keys.Z public int Home { get; set; } = 67; // Keys.C public int LClick { get; set; } = 81; // Keys.Q public int RClick { get; set; } = 69; // Keys.E - public int Up { get; set; } = 38; // Keys.Up - public int Down { get; set; } = 40; // Keys.Down - public int Left { get; set; } = 37; // Keys.Left - public int Right { get; set; } = 39; // Keys.Right + public int Up { get; set; } = 0; + public int Down { get; set; } = 0; + public int Left { get; set; } = 0; + public int Right { get; set; } = 0; public int UpRight { get; set; } = 0; public int DownRight { get; set; } = 0; public int UpLeft { get; set; } = 0; diff --git a/src/EasyCon.Core/Input/IInputBinder.cs b/src/EasyCon.Core/Input/IInputBinder.cs new file mode 100644 index 00000000..a41b374c --- /dev/null +++ b/src/EasyCon.Core/Input/IInputBinder.cs @@ -0,0 +1,13 @@ +using EasyCon.Core.Config; +using EasyDevice; + +namespace EasyCon.Core.Input; + +public interface IInputBinder +{ + void Start(); + void Stop(); + void SetEnabled(bool enabled); + void UpdateKeyMapping(KeyMappingConfig mapping); + void RegisterEscapeKey(Func keydown, Func keyup); +} \ No newline at end of file diff --git a/src/EasyCon.Device/NintendoSwitchPriv.cs b/src/EasyCon.Device/NintendoSwitchPriv.cs index 10d33d4a..9a00c8ed 100644 --- a/src/EasyCon.Device/NintendoSwitchPriv.cs +++ b/src/EasyCon.Device/NintendoSwitchPriv.cs @@ -9,6 +9,21 @@ public partial class NintendoSwitch DateTime _nextSendTime = DateTime.MinValue; private readonly EventWaitHandle _ewh = new(false, EventResetMode.ManualReset); + public void ApplyReport(SwitchReport report) + { + lock (this) + { + _keystrokes.Clear(); + _report.Button = report.Button; + _report.HAT = report.HAT; + _report.LX = report.LX; + _report.LY = report.LY; + _report.RX = report.RX; + _report.RY = report.RY; + Signal(); + } + } + void Signal() { if (this.IsConnected()) diff --git a/src/EasyCon.WinInput/EasyCon.WinInput.csproj b/src/EasyCon.WinInput/EasyCon.WinInput.csproj new file mode 100644 index 00000000..7746aa94 --- /dev/null +++ b/src/EasyCon.WinInput/EasyCon.WinInput.csproj @@ -0,0 +1,12 @@ + + + net10.0-windows + warnings + + + + + + + + diff --git a/src/EasyCon.WinInput/GamepadInputBinder.cs b/src/EasyCon.WinInput/GamepadInputBinder.cs new file mode 100644 index 00000000..bf871c8b --- /dev/null +++ b/src/EasyCon.WinInput/GamepadInputBinder.cs @@ -0,0 +1,50 @@ +using EasyCon.Core.Config; +using EasyCon.Core.Input; +using EasyDevice; +using GamepadApi; +using System.Runtime.Versioning; + +namespace EasyCon.WinInput; + +[SupportedOSPlatform("windows")] +public class GamepadInputBinder : IInputBinder +{ + private readonly GamepadManager _manager; + private readonly int _deviceIndex; + private readonly NintendoSwitch _ns; + private readonly GamepadMappingConfig _config; + private volatile bool _enabled; + + public GamepadInputBinder(GamepadManager manager, int deviceIndex, NintendoSwitch ns, GamepadMappingConfig config) + { + _manager = manager; + _deviceIndex = deviceIndex; + _ns = ns; + _config = config; + } + + public void Start() + { + _manager.StateChanged += OnStateChanged; + } + + public void Stop() + { + _manager.StateChanged -= OnStateChanged; + _ns.Reset(); + } + + public void SetEnabled(bool enabled) => _enabled = enabled; + + public void UpdateKeyMapping(KeyMappingConfig mapping) { } + + public void RegisterEscapeKey(Func keydown, Func keyup) { } + + private void OnStateChanged(GamepadDevice device, GamepadState state) + { + if (!_enabled) return; + if (device.Index != _deviceIndex) return; + var report = GamepadMapper.Map(state, _config); + _ns.ApplyReport(report); + } +} \ No newline at end of file diff --git a/src/EasyCon.WinInput/GamepadMapper.cs b/src/EasyCon.WinInput/GamepadMapper.cs new file mode 100644 index 00000000..e956a32b --- /dev/null +++ b/src/EasyCon.WinInput/GamepadMapper.cs @@ -0,0 +1,81 @@ +using EasyDevice; +using GamepadApi; +using System.Numerics; + +namespace EasyCon.WinInput; + +public static class GamepadMapper +{ + public static SwitchReport Map(GamepadState state, GamepadMappingConfig config) + { + var report = new SwitchReport(); + + MapButtons(state.Buttons, config.IsSwitchXY, report); + MapTriggers(state.LeftTrigger, state.RightTrigger, config.TriggerThreshold, report); + MapHat(state.Buttons, report); + (report.LX, report.LY) = MapStick(state.LeftStick); + (report.RX, report.RY) = MapStick(state.RightStick); + + return report; + } + + static void MapButtons(GamepadButtons buttons, bool isSwitchXY, SwitchReport report) + { + if (isSwitchXY) + { + if (buttons.HasFlag(GamepadButtons.A)) report.Button |= (ushort)SwitchButton.B; + if (buttons.HasFlag(GamepadButtons.B)) report.Button |= (ushort)SwitchButton.A; + if (buttons.HasFlag(GamepadButtons.X)) report.Button |= (ushort)SwitchButton.Y; + if (buttons.HasFlag(GamepadButtons.Y)) report.Button |= (ushort)SwitchButton.X; + } + else + { + if (buttons.HasFlag(GamepadButtons.A)) report.Button |= (ushort)SwitchButton.A; + if (buttons.HasFlag(GamepadButtons.B)) report.Button |= (ushort)SwitchButton.B; + if (buttons.HasFlag(GamepadButtons.X)) report.Button |= (ushort)SwitchButton.X; + if (buttons.HasFlag(GamepadButtons.Y)) report.Button |= (ushort)SwitchButton.Y; + } + + if (buttons.HasFlag(GamepadButtons.LeftShoulder)) report.Button |= (ushort)SwitchButton.L; + if (buttons.HasFlag(GamepadButtons.RightShoulder)) report.Button |= (ushort)SwitchButton.R; + if (buttons.HasFlag(GamepadButtons.LeftThumb)) report.Button |= (ushort)SwitchButton.LCLICK; + if (buttons.HasFlag(GamepadButtons.RightThumb)) report.Button |= (ushort)SwitchButton.RCLICK; + if (buttons.HasFlag(GamepadButtons.Start)) report.Button |= (ushort)SwitchButton.PLUS; + if (buttons.HasFlag(GamepadButtons.Back)) report.Button |= (ushort)SwitchButton.MINUS; + } + + static void MapTriggers(float left, float right, float threshold, SwitchReport report) + { + if (left > threshold) report.Button |= (ushort)SwitchButton.ZL; + if (right > threshold) report.Button |= (ushort)SwitchButton.ZR; + } + + static void MapHat(GamepadButtons buttons, SwitchReport report) + { + var up = buttons.HasFlag(GamepadButtons.DPadUp); + var down = buttons.HasFlag(GamepadButtons.DPadDown); + var left = buttons.HasFlag(GamepadButtons.DPadLeft); + var right = buttons.HasFlag(GamepadButtons.DPadRight); + + report.HAT = (up, down, left, right) switch + { + (true, false, false, false) => (byte)SwitchHAT.TOP, + (false, true, false, false) => (byte)SwitchHAT.BOTTOM, + (false, false, true, false) => (byte)SwitchHAT.LEFT, + (false, false, false, true) => (byte)SwitchHAT.RIGHT, + (true, false, true, false) => (byte)SwitchHAT.TOP_LEFT, + (true, false, false, true) => (byte)SwitchHAT.TOP_RIGHT, + (false, true, true, false) => (byte)SwitchHAT.BOTTOM_LEFT, + (false, true, false, true) => (byte)SwitchHAT.BOTTOM_RIGHT, + _ => (byte)SwitchHAT.CENTER, + }; + } + + static (byte x, byte y) MapStick(Vector2 stick) + { + return ( + (byte)Math.Clamp((int)(stick.X * 127) + 128, 0, 255), + (byte)Math.Clamp((int)(-stick.Y * 127) + 128, 0, 255) + ); + } +} \ No newline at end of file diff --git a/src/EasyCon.WinInput/GamepadMappingConfig.cs b/src/EasyCon.WinInput/GamepadMappingConfig.cs new file mode 100644 index 00000000..97d61115 --- /dev/null +++ b/src/EasyCon.WinInput/GamepadMappingConfig.cs @@ -0,0 +1,7 @@ +namespace EasyCon.WinInput; + +public record GamepadMappingConfig +{ + public bool IsSwitchXY { get; init; } = true; + public float TriggerThreshold { get; init; } = 0.1f; +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/VPad/KeyBinder.cs b/src/EasyCon.WinInput/KeyBinder.cs similarity index 95% rename from src/EasyCon2.Avalonia.Core/VPad/KeyBinder.cs rename to src/EasyCon.WinInput/KeyBinder.cs index 089a1b0b..0fc9d198 100644 --- a/src/EasyCon2.Avalonia.Core/VPad/KeyBinder.cs +++ b/src/EasyCon.WinInput/KeyBinder.cs @@ -1,8 +1,9 @@ using EasyCon.Core.Config; +using EasyCon.Core.Input; using EasyDevice; using System.Runtime.Versioning; -namespace EasyCon2.Avalonia.Core.VPad; +namespace EasyCon.WinInput; [SupportedOSPlatform("windows")] public class KeyBinder @@ -81,4 +82,9 @@ public void UnregisterAllKeys() { LowLevelKeyboard.GetInstance().UnregisterKeyEventAll(); } + + public void RegisterEscapeKey(Func keydown, Func keyup) + { + LowLevelKeyboard.GetInstance().RegisterKeyEvent(0x1B, keydown, keyup); + } } \ No newline at end of file diff --git a/src/EasyCon.WinInput/KeyboardInputBinder.cs b/src/EasyCon.WinInput/KeyboardInputBinder.cs new file mode 100644 index 00000000..9bc52a98 --- /dev/null +++ b/src/EasyCon.WinInput/KeyboardInputBinder.cs @@ -0,0 +1,32 @@ +using EasyCon.Core.Config; +using EasyCon.Core.Input; +using EasyDevice; +using System.Runtime.Versioning; + +namespace EasyCon.WinInput; + +[SupportedOSPlatform("windows")] +public class KeyboardInputBinder : IInputBinder +{ + private readonly KeyBinder _keyBinder = new(); + private readonly NintendoSwitch _ns; + private KeyMappingConfig _mapping; + + public KeyboardInputBinder(NintendoSwitch ns, KeyMappingConfig mapping) + { + _ns = ns; + _mapping = mapping; + } + + public void Start() => _keyBinder.RegisterAllKeys(_mapping, _ns); + public void Stop() => _keyBinder.UnregisterAllKeys(); + public void SetEnabled(bool enabled) => _keyBinder.ControllerEnabled = enabled; + public void UpdateKeyMapping(KeyMappingConfig mapping) + { + _mapping = mapping; + _keyBinder.RegisterAllKeys(_mapping, _ns); + } + + public void RegisterEscapeKey(Func keydown, Func keyup) + => _keyBinder.RegisterEscapeKey(keydown, keyup); +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/VPad/LowLevelKeyboard.cs b/src/EasyCon.WinInput/LowLevelKeyboard.cs similarity index 97% rename from src/EasyCon2.Avalonia.Core/VPad/LowLevelKeyboard.cs rename to src/EasyCon.WinInput/LowLevelKeyboard.cs index e6f62d00..551ec3e9 100644 --- a/src/EasyCon2.Avalonia.Core/VPad/LowLevelKeyboard.cs +++ b/src/EasyCon.WinInput/LowLevelKeyboard.cs @@ -2,10 +2,10 @@ using System.Runtime.InteropServices; using System.Runtime.Versioning; -namespace EasyCon2.Avalonia.Core.VPad; +namespace EasyCon.WinInput; [SupportedOSPlatform("windows")] -internal class LowLevelKeyboard +class LowLevelKeyboard { [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId); diff --git a/src/EasyCon2.Avalonia.Core/VPad/JCPainter.cs b/src/EasyCon2.Avalonia.Core/VPad/JCPainter.cs index f4539ef9..12e43d01 100644 --- a/src/EasyCon2.Avalonia.Core/VPad/JCPainter.cs +++ b/src/EasyCon2.Avalonia.Core/VPad/JCPainter.cs @@ -11,11 +11,11 @@ internal class JCPainter(IReporter repoter, IControllerAdapter adapter, GetJCImg { private DateTime _starttime = DateTime.MinValue; private static readonly Color _gray50 = Color.FromRgb(50, 50, 50); - private readonly Brush _brushLightDefault = new SolidColorBrush(Colors.Black, 50 / 255); + private readonly Brush _brushLightDefault = new SolidColorBrush(Colors.Black, 130.0 / 255.0); private readonly Brush _brushStickBG = new SolidColorBrush(_gray50); private readonly Brush _brushStickBGDown = new SolidColorBrush(Colors.Lime); - private readonly Brush _brushStickAreaBG = new SolidColorBrush(Colors.Black, 50 / 255); - private readonly Brush _brushStickAreaBGDown = new SolidColorBrush(Colors.Lime, 200 / 255); + private readonly Brush _brushStickAreaBG = new SolidColorBrush(Colors.Black, 50.0 / 255.0); + private readonly Brush _brushStickAreaBGDown = new SolidColorBrush(Colors.Lime, 200.0 / 255.0); private readonly Brush _brushButtonDown = new SolidColorBrush(Colors.Lime); private readonly Brush _brushButtonUp = new SolidColorBrush(_gray50); @@ -67,7 +67,7 @@ public void OnPaint(DrawingContext g, Rect rectangle) DrawRectangle( g, _pen, - new SolidColorBrush(adapter.CurrentLight, 50 / 255), + new SolidColorBrush(adapter.CurrentLight, 130.0 / 255.0), x, y, w, diff --git a/src/EasyCon2.Avalonia.Core/VPad/VPadService.cs b/src/EasyCon2.Avalonia.Core/VPad/VPadService.cs index 53f13830..bb57b574 100644 --- a/src/EasyCon2.Avalonia.Core/VPad/VPadService.cs +++ b/src/EasyCon2.Avalonia.Core/VPad/VPadService.cs @@ -1,18 +1,19 @@ using Avalonia.Threading; using EasyCon.Core.Config; +using EasyCon.Core.Input; using EasyDevice; -using System.Runtime.Versioning; namespace EasyCon2.Avalonia.Core.VPad; -[SupportedOSPlatform("windows")] public class VPadService { private VPadOverlay? _overlay; - private readonly KeyBinder _keyBinder = new(); + private IInputBinder? _binder; private readonly NintendoSwitch _gamepad; private readonly IControllerAdapter _adapter; private bool _active; + private Func? _escKeyDown; + private Func? _escKeyUp; public VPadService(NintendoSwitch gamepad, IControllerAdapter adapter) { @@ -20,13 +21,15 @@ public VPadService(NintendoSwitch gamepad, IControllerAdapter adapter) _adapter = adapter; } + public bool IsActive => _active; + private bool Active { get => _active; set { _active = value; - _keyBinder.ControllerEnabled = value; + _binder?.SetEnabled(value); if (_overlay != null) Dispatcher.UIThread.Post(() => _overlay.IsActive = value); } @@ -60,25 +63,37 @@ public void Show() }); } + public void SwitchInput(IInputBinder binder) + { + _binder?.Stop(); + _binder = binder; + _binder.Start(); + if (_active) _binder.SetEnabled(true); + if (_escKeyDown != null) + _binder.RegisterEscapeKey(_escKeyDown, _escKeyUp!); + } + public void UpdateKeyMapping(KeyMappingConfig mapping) { - _keyBinder.RegisterAllKeys(mapping, _gamepad); - RegisterEscapeKey(); + _binder?.UpdateKeyMapping(mapping); } - private void RegisterEscapeKey() + public void RegisterEscapeKey(Func keydown, Func keyup) { - const int VK_ESCAPE = 0x1B; - LowLevelKeyboard.GetInstance().RegisterKeyEvent(VK_ESCAPE, - () => - { - if (!_active) return false; - Active = false; - Dispatcher.UIThread.Post(() => _overlay?.Hide()); - return true; - }, - () => false); + _escKeyDown = keydown; + _escKeyUp = keyup; + _binder?.RegisterEscapeKey(keydown, keyup); } - public void Deactivate() => Active = false; + public void HideOverlay() + { + Active = false; + Dispatcher.UIThread.Post(() => _overlay?.Hide()); + } + + public void Deactivate() + { + if (Active) + Active = false; + } } \ No newline at end of file diff --git a/src/EasyCon2/App/EasyConForm.Designer.cs b/src/EasyCon2/App/EasyConForm.Designer.cs index 8d89fcdc..9da66e6a 100644 --- a/src/EasyCon2/App/EasyConForm.Designer.cs +++ b/src/EasyCon2/App/EasyConForm.Designer.cs @@ -93,6 +93,7 @@ private void InitializeComponent() buttonSerialPortSearch = new Button(); buttonControllerHelp = new Button(); buttonKeyMapping = new Button(); + comboInputMode = new ComboBox(); openFileDialog = new OpenFileDialog(); saveFileDialog = new SaveFileDialog(); toolStripStatusLabel1 = new ToolStripStatusLabel(); @@ -498,9 +499,9 @@ private void InitializeComponent() buttonRecordPause.FlatAppearance.BorderSize = 0; buttonRecordPause.FlatStyle = FlatStyle.Flat; buttonRecordPause.Font = new Font("微软雅黑", 9F); - buttonRecordPause.Location = new Point(247, 73); + buttonRecordPause.Location = new Point(189, 91); buttonRecordPause.Name = "buttonRecordPause"; - buttonRecordPause.Size = new Size(116, 59); + buttonRecordPause.Size = new Size(174, 41); buttonRecordPause.TabIndex = 6; buttonRecordPause.Text = "暂停录制"; buttonRecordPause.UseVisualStyleBackColor = true; @@ -509,14 +510,13 @@ private void InitializeComponent() // buttonRecord // buttonRecord.AccessibleName = "录制脚本"; - tblController.SetColumnSpan(buttonRecord, 2); buttonRecord.Dock = DockStyle.Fill; buttonRecord.FlatAppearance.BorderSize = 0; buttonRecord.FlatStyle = FlatStyle.Flat; buttonRecord.Font = new Font("微软雅黑", 9F); - buttonRecord.Location = new Point(9, 73); + buttonRecord.Location = new Point(9, 91); buttonRecord.Name = "buttonRecord"; - buttonRecord.Size = new Size(232, 59); + buttonRecord.Size = new Size(174, 41); buttonRecord.TabIndex = 5; buttonRecord.Text = "录制脚本"; buttonRecord.UseVisualStyleBackColor = true; @@ -621,11 +621,11 @@ private void InitializeComponent() buttonShowController.FlatAppearance.BorderSize = 0; buttonShowController.FlatStyle = FlatStyle.Flat; buttonShowController.Font = new Font("微软雅黑", 9F); - buttonShowController.Location = new Point(9, 9); + buttonShowController.Location = new Point(189, 9); buttonShowController.Name = "buttonShowController"; - buttonShowController.Size = new Size(113, 58); + buttonShowController.Size = new Size(174, 30); buttonShowController.TabIndex = 3; - buttonShowController.Text = "虚拟手柄"; + buttonShowController.Text = "连接"; buttonShowController.UseVisualStyleBackColor = true; buttonShowController.Click += buttonShowController_Click; // @@ -689,9 +689,9 @@ private void InitializeComponent() buttonControllerHelp.FlatAppearance.BorderSize = 0; buttonControllerHelp.FlatStyle = FlatStyle.Flat; buttonControllerHelp.Font = new Font("微软雅黑", 9F); - buttonControllerHelp.Location = new Point(247, 9); + buttonControllerHelp.Location = new Point(189, 45); buttonControllerHelp.Name = "buttonControllerHelp"; - buttonControllerHelp.Size = new Size(116, 58); + buttonControllerHelp.Size = new Size(174, 40); buttonControllerHelp.TabIndex = 33; buttonControllerHelp.Text = "帮助"; buttonControllerHelp.UseVisualStyleBackColor = true; @@ -704,14 +704,25 @@ private void InitializeComponent() buttonKeyMapping.FlatAppearance.BorderSize = 0; buttonKeyMapping.FlatStyle = FlatStyle.Flat; buttonKeyMapping.Font = new Font("微软雅黑", 9F); - buttonKeyMapping.Location = new Point(128, 9); + buttonKeyMapping.Location = new Point(9, 45); buttonKeyMapping.Name = "buttonKeyMapping"; - buttonKeyMapping.Size = new Size(113, 58); + buttonKeyMapping.Size = new Size(174, 40); buttonKeyMapping.TabIndex = 4; buttonKeyMapping.Text = "按键映射"; buttonKeyMapping.UseVisualStyleBackColor = true; buttonKeyMapping.Click += buttonKeyMapping_Click; // + // comboInputMode + // + comboInputMode.AccessibleName = "输入模式"; + comboInputMode.Dock = DockStyle.Fill; + comboInputMode.DropDownStyle = ComboBoxStyle.DropDownList; + comboInputMode.Location = new Point(9, 9); + comboInputMode.Name = "comboInputMode"; + comboInputMode.Size = new Size(174, 28); + comboInputMode.TabIndex = 7; + comboInputMode.SelectedIndexChanged += comboInputMode_SelectedIndexChanged; + // // openFileDialog // openFileDialog.Filter = "文本文件 (*.txt)|*.txt|所有文件 (*.*)|*.*"; @@ -1002,21 +1013,22 @@ private void InitializeComponent() // tblController // tblController.BackColor = Color.FromArgb(230, 229, 224); - tblController.ColumnCount = 3; - tblController.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33333F)); - tblController.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33333F)); - tblController.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33334F)); - tblController.Controls.Add(buttonShowController, 0, 0); - tblController.Controls.Add(buttonKeyMapping, 1, 0); - tblController.Controls.Add(buttonControllerHelp, 2, 0); - tblController.Controls.Add(buttonRecord, 0, 1); - tblController.Controls.Add(buttonRecordPause, 2, 1); + tblController.ColumnCount = 2; + tblController.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50F)); + tblController.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50F)); + tblController.Controls.Add(comboInputMode, 0, 0); + tblController.Controls.Add(buttonShowController, 1, 0); + tblController.Controls.Add(buttonKeyMapping, 0, 1); + tblController.Controls.Add(buttonControllerHelp, 1, 1); + tblController.Controls.Add(buttonRecord, 0, 2); + tblController.Controls.Add(buttonRecordPause, 1, 2); tblController.Dock = DockStyle.Fill; tblController.Location = new Point(762, 23); tblController.Margin = new Padding(4); tblController.Name = "tblController"; tblController.Padding = new Padding(6); - tblController.RowCount = 2; + tblController.RowCount = 3; + tblController.RowStyles.Add(new RowStyle(SizeType.Absolute, 36F)); tblController.RowStyles.Add(new RowStyle(SizeType.Percent, 50F)); tblController.RowStyles.Add(new RowStyle(SizeType.Percent, 50F)); tblController.Size = new Size(372, 141); @@ -1154,5 +1166,6 @@ private void InitializeComponent() private ComboBox comboVideoSource; private Button btnCaptureToggle; private Button btnOpenCaptureConsole; + private ComboBox comboInputMode; } } diff --git a/src/EasyCon2/App/EasyConForm.cs b/src/EasyCon2/App/EasyConForm.cs index 17bd6e83..18526546 100644 --- a/src/EasyCon2/App/EasyConForm.cs +++ b/src/EasyCon2/App/EasyConForm.cs @@ -3,6 +3,7 @@ using EasyCon.Core; using EasyCon.Core.Config; using EasyCon.Script.Assembly; +using EasyCon.WinInput; using EasyCon2.Avalonia.Core.Editor; using EasyCon2.Avalonia.Core.VPad; using EasyCon2.Forms; @@ -13,6 +14,7 @@ using EasyCon2.Views; using EasyDevice; using EasyScript; +using GamepadApi; using System.Diagnostics; using System.IO; using System.Media; @@ -41,6 +43,8 @@ public partial class EasyConForm : Form, IOutputAdapter, IControllerAdapter // Editor private ScriptEditorControl scriptEditor; private VPadService? _vpadService; + private GamepadManager? _gamepadManager; + private GamepadMappingConfig _gamepadMappingConfig = new(); private FoldingManager? _foldingManager; private CustomFoldingStrategy? _foldingStrategy; @@ -123,7 +127,10 @@ private void EasyConForm_Load(object sender, EventArgs e) InitEditor(); InitServices(); InitTimer(); - RegisterKeys(); + + comboInputMode.Items.Add("键盘"); + comboInputMode.SelectedIndex = 0; + InitGamepadManager(); // 初始化菜单项状态 代码自动补全ToolStripMenuItem.Checked = _configService.Config.EnableAutoCompletion; @@ -495,6 +502,42 @@ private void ShowControllerHelp() (注意:在有脚本远程运行的情况下无法使用)", "关于虚拟手柄").Show(); } + private void comboInputMode_SelectedIndexChanged(object sender, EventArgs e) + { + var idx = comboInputMode.SelectedIndex; + buttonKeyMapping.Enabled = idx == 0; + buttonRecord.Enabled = idx == 0; + } + + private void InitGamepadManager() + { + if (_gamepadManager != null) return; + _gamepadManager = new GamepadManager(); + _gamepadManager.DeviceConnected += _ => RefreshInputModeList(); + _gamepadManager.DeviceDisconnected += _ => RefreshInputModeList(); + _gamepadManager.Start(); + } + + private void RefreshInputModeList() + { + if (!IsHandleCreated) return; + BeginInvoke(() => + { + var selected = comboInputMode.SelectedIndex; + comboInputMode.Items.Clear(); + comboInputMode.Items.Add("键盘"); + if (_gamepadManager != null) + { + foreach (var d in _gamepadManager.GetConnectedDevices()) + comboInputMode.Items.Add($"手柄 {d.Index}"); + } + if (selected >= 0 && selected < comboInputMode.Items.Count) + comboInputMode.SelectedIndex = selected; + else + comboInputMode.SelectedIndex = 0; + }); + } + private void buttonShowController_Click(object sender, EventArgs e) { if (!_deviceService.IsConnected) @@ -504,9 +547,40 @@ private void buttonShowController_Click(object sender, EventArgs e) } _vpadService ??= new VPadService(_deviceService.Device, this); - _vpadService.UpdateKeyMapping(_configService.KeyMapping); + + if (_vpadService.IsActive) + { + buttonShowController.Text = "连接"; + _vpadService.HideOverlay(); + return; + } + + var idx = comboInputMode.SelectedIndex; + if (idx == 0) + { + _vpadService.SwitchInput(new KeyboardInputBinder(_deviceService.Device, _configService.KeyMapping)); + } + else if (idx > 0 && _gamepadManager != null) + { + var gIdx = idx - 1; + var binder = new GamepadInputBinder(_gamepadManager, gIdx, _deviceService.Device, _gamepadMappingConfig); + _vpadService.SwitchInput(binder); + } + buttonShowController.Text = "断开"; _vpadService.Show(); + // Register Escape key to hide overlay + _vpadService.RegisterEscapeKey( + () => + { + if (_vpadService is not { IsActive: true }) return false; + + buttonShowController.Text = "连接"; + _vpadService.HideOverlay(); + return true; + }, + () => false); + if (_configService.Config.ShowControllerHelp) { ShowControllerHelp(); @@ -533,6 +607,16 @@ private void buttonControllerHelp_Click(object sender, EventArgs e) private void RegisterKeys() { _vpadService?.UpdateKeyMapping(_configService.KeyMapping); + _vpadService?.RegisterEscapeKey( + () => + { + if (_vpadService is not { IsActive: true }) return false; + + buttonShowController.Text = "连接"; + _vpadService.HideOverlay(); + return true; + }, + () => false); } #endregion @@ -833,7 +917,7 @@ private void buttonRecord_Click(object sender, EventArgs e) return; if (_vpadService != null) { - _vpadService.UpdateKeyMapping(_configService.KeyMapping); + RegisterKeys(); _vpadService.Show(); } buttonRecord.Text = "停止录制"; diff --git a/src/EasyCon2/App/MainForm.cs b/src/EasyCon2/App/MainForm.cs index 3b74d49d..8eb46cda 100644 --- a/src/EasyCon2/App/MainForm.cs +++ b/src/EasyCon2/App/MainForm.cs @@ -3,6 +3,7 @@ using EasyCon.Core; using EasyCon.Core.Config; using EasyCon.Script.Assembly; +using EasyCon.WinInput; using EasyCon2.Avalonia.Core.Editor; using EasyCon2.Avalonia.Core.VPad; using EasyCon2.Forms; @@ -665,7 +666,7 @@ private void btnRecord_Click(object sender, EventArgs e) if (_vpadService != null) { - _vpadService.UpdateKeyMapping(_configService.KeyMapping); + _vpadService.SwitchInput(new KeyboardInputBinder(_deviceService.Device, _configService.KeyMapping)); _vpadService.Show(); } else @@ -721,7 +722,7 @@ private void btnShowController_Click(object sender, EventArgs e) } _vpadService ??= new VPadService(_deviceService.Device, this); - _vpadService.UpdateKeyMapping(_configService.KeyMapping); + _vpadService.SwitchInput(new KeyboardInputBinder(_deviceService.Device, _configService.KeyMapping)); _vpadService.Show(); } diff --git a/src/EasyCon2/EasyCon2.csproj b/src/EasyCon2/EasyCon2.csproj index 19e5f54e..2fe740c9 100644 --- a/src/EasyCon2/EasyCon2.csproj +++ b/src/EasyCon2/EasyCon2.csproj @@ -18,6 +18,7 @@ + diff --git a/test/EasyCon.WinInput.Tests/EasyCon.WinInput.Tests.csproj b/test/EasyCon.WinInput.Tests/EasyCon.WinInput.Tests.csproj new file mode 100644 index 00000000..905c2dbd --- /dev/null +++ b/test/EasyCon.WinInput.Tests/EasyCon.WinInput.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0-windows + false + true + warnings + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/test/EasyCon.WinInput.Tests/GamepadMapperTests.cs b/test/EasyCon.WinInput.Tests/GamepadMapperTests.cs new file mode 100644 index 00000000..eef3492f --- /dev/null +++ b/test/EasyCon.WinInput.Tests/GamepadMapperTests.cs @@ -0,0 +1,151 @@ +using EasyCon.WinInput; +using EasyDevice; +using GamepadApi; +using System.Numerics; + +namespace EasyCon.WinInput.Tests; + +public class GamepadMapperTests +{ + static readonly GamepadMappingConfig DefaultConfig = new(); + + [Test] + public void Map_NoButtonsPressed_AllButtonsZero() + { + var state = new GamepadState(); + var report = GamepadMapper.Map(state, DefaultConfig); + + Assert.Multiple(() => + { + Assert.That(report.Button, Is.EqualTo((ushort)0)); + Assert.That(report.HAT, Is.EqualTo((byte)SwitchHAT.CENTER)); + Assert.That(report.LX, Is.EqualTo(128)); + Assert.That(report.LY, Is.EqualTo(128)); + Assert.That(report.RX, Is.EqualTo(128)); + Assert.That(report.RY, Is.EqualTo(128)); + }); + } + + [Test] + public void Map_SingleButtonA_IsSwitchXY_MapsToSwitchB() + { + var state = new GamepadState { Buttons = GamepadButtons.A }; + var config = new GamepadMappingConfig { IsSwitchXY = true }; + var report = GamepadMapper.Map(state, config); + + Assert.That(report.Button & (ushort)SwitchButton.B, Is.Not.Zero); + Assert.That(report.Button & (ushort)SwitchButton.A, Is.Zero); + } + + [Test] + public void Map_SingleButtonA_NotSwitchXY_MapsToSwitchA() + { + var state = new GamepadState { Buttons = GamepadButtons.A }; + var config = new GamepadMappingConfig { IsSwitchXY = false }; + var report = GamepadMapper.Map(state, config); + + Assert.That(report.Button & (ushort)SwitchButton.A, Is.Not.Zero); + Assert.That(report.Button & (ushort)SwitchButton.B, Is.Zero); + } + + [Test] + public void Map_TriggerBelowThreshold_NotPressed() + { + var state = new GamepadState { LeftTrigger = 0.05f, RightTrigger = 0.05f }; + var config = new GamepadMappingConfig { TriggerThreshold = 0.1f }; + var report = GamepadMapper.Map(state, config); + + Assert.That(report.Button & (ushort)SwitchButton.ZL, Is.Zero); + Assert.That(report.Button & (ushort)SwitchButton.ZR, Is.Zero); + } + + [Test] + public void Map_TriggerAboveThreshold_Pressed() + { + var state = new GamepadState { LeftTrigger = 0.5f, RightTrigger = 0.5f }; + var config = new GamepadMappingConfig { TriggerThreshold = 0.1f }; + var report = GamepadMapper.Map(state, config); + + Assert.That(report.Button & (ushort)SwitchButton.ZL, Is.Not.Zero); + Assert.That(report.Button & (ushort)SwitchButton.ZR, Is.Not.Zero); + } + + [Test] + public void Map_StickCenter_OutputCenter() + { + var state = new GamepadState + { + LeftStick = new Vector2(0f, 0f), + RightStick = new Vector2(0f, 0f), + }; + var report = GamepadMapper.Map(state, DefaultConfig); + + Assert.That(report.LX, Is.EqualTo(128)); + Assert.That(report.LY, Is.EqualTo(128)); + Assert.That(report.RX, Is.EqualTo(128)); + Assert.That(report.RY, Is.EqualTo(128)); + } + + [Test] + public void Map_StickFullRight_OutputMax() + { + var state = new GamepadState { LeftStick = new Vector2(1f, 0f) }; + var report = GamepadMapper.Map(state, DefaultConfig); + + Assert.That(report.LX, Is.EqualTo(255)); + } + + [Test] + public void Map_StickFullLeft_OutputMin() + { + var state = new GamepadState { LeftStick = new Vector2(-1f, 0f) }; + var report = GamepadMapper.Map(state, DefaultConfig); + + Assert.That(report.LX, Is.EqualTo(1)); + } + + [Test] + public void Map_RightStick_WritesToRY_NotLY() + { + var state = new GamepadState + { + LeftStick = new Vector2(0f, 0.5f), + RightStick = new Vector2(0.8f, -0.3f), + }; + var report = GamepadMapper.Map(state, DefaultConfig); + + // Left stick Y should reflect left input only + var expectedLY = (byte)Math.Clamp((int)(-0.5f * 127) + 128, 0, 255); + Assert.That(report.LY, Is.EqualTo(expectedLY)); + + // Right stick values should be independent + var expectedRX = (byte)Math.Clamp((int)(0.8f * 127) + 128, 0, 255); + var expectedRY = (byte)Math.Clamp((int)(0.3f * 127) + 128, 0, 255); + Assert.That(report.RX, Is.EqualTo(expectedRX)); + Assert.That(report.RY, Is.EqualTo(expectedRY)); + } + + [Test] + public void Map_DPadUp_MapsToTop() + { + var state = new GamepadState { Buttons = GamepadButtons.DPadUp }; + var report = GamepadMapper.Map(state, DefaultConfig); + + Assert.That(report.HAT, Is.EqualTo((byte)SwitchHAT.TOP)); + } + + [Test] + public void Map_MultipleButtons_AllMapped() + { + var state = new GamepadState + { + Buttons = GamepadButtons.A | GamepadButtons.LeftShoulder | GamepadButtons.Start, + }; + var config = new GamepadMappingConfig { IsSwitchXY = true }; + var report = GamepadMapper.Map(state, config); + + Assert.That(report.Button & (ushort)SwitchButton.B, Is.Not.Zero, "A→B (SwitchXY)"); + Assert.That(report.Button & (ushort)SwitchButton.L, Is.Not.Zero, "LeftShoulder→L"); + Assert.That(report.Button & (ushort)SwitchButton.PLUS, Is.Not.Zero, "Start→PLUS"); + } +} \ No newline at end of file diff --git a/tools/GamepadApiDemo/GamepadApiDemo.csproj b/tools/GamepadApiDemo/GamepadApiDemo.csproj new file mode 100644 index 00000000..bfdd7418 --- /dev/null +++ b/tools/GamepadApiDemo/GamepadApiDemo.csproj @@ -0,0 +1,12 @@ + + + + Exe + net10.0-windows + + + + + + + diff --git a/tools/GamepadApiDemo/Program.cs b/tools/GamepadApiDemo/Program.cs new file mode 100644 index 00000000..d3ae30fd --- /dev/null +++ b/tools/GamepadApiDemo/Program.cs @@ -0,0 +1,55 @@ +using GamepadApi; + +var manager = new GamepadManager(); +var cts = new CancellationTokenSource(); + +Console.CancelKeyPress += (_, e) => +{ + e.Cancel = true; + cts.Cancel(); +}; + +manager.DeviceConnected += device => +{ + Console.WriteLine($"\n >> 手柄 {device.Index} 已连接"); +}; + +manager.DeviceDisconnected += index => +{ + Console.WriteLine($"\n >> 手柄 {index} 已断开"); +}; + +manager.StateChanged += (device, state) => +{ + var pressed = new List(); + foreach (GamepadButtons flag in Enum.GetValues()) + { + if (flag == GamepadButtons.None) continue; + if (state.Buttons.HasFlag(flag)) + pressed.Add(flag.ToString()); + } + + var ls = state.LeftStick; + var rs = state.RightStick; + + Console.SetCursorPosition(0, 3); + Console.WriteLine($" 手柄 {device.Index} | Packet: {state.PacketNumber} "); + Console.WriteLine($" 按键: {(pressed.Count > 0 ? string.Join(", ", pressed) : "(无)")} "); + Console.WriteLine($" 左摇杆: ({ls.X,6:F3}, {ls.Y,6:F3}) 右摇杆: ({rs.X,6:F3}, {rs.Y,6:F3})"); + Console.WriteLine($" 左扳机: {state.LeftTrigger,5:F3} 右扳机: {state.RightTrigger,5:F3} "); +}; + +Console.WriteLine("=== GamepadApi Demo ==="); +Console.WriteLine("连接手柄后自动显示状态,按 Ctrl+C 退出\n"); + +manager.Start(); + +try +{ + await Task.Delay(-1, cts.Token); +} +catch (OperationCanceledException) +{ + manager.Stop(); + Console.WriteLine("\n已退出。"); +} \ No newline at end of file