Keyboard and Xbox-gamepad input drive the panel

New input pipeline: sources feed an InputRouter (VRio.Core.Input) that
calls the same VRioDevice press/release/axis entry points the mouse
canvas uses, so routed input is indistinguishable on the wire.

- Bindings live in %APPDATA%\vRIO\bindings.txt (plain text, commented
  defaults written on first run; Reload/Edit buttons in the new Input
  group). Keys and pad buttons press any RIO address, momentary or
  toggle; axes bind in normalized units of each axis realistic travel
  window with deflect (spring-back), rate (position holds), deadzone,
  and invert options - RioAxisRange supplies the wire signs.
- Router suppresses key auto-repeat, edge-detects pad buttons, and
  hold-counts per address so overlapping sources press once and
  release last; axes only write when the composed value changes, so
  mouse drags keep working while sources idle.
- Xbox pad via a zero-dependency XInput P/Invoke poller (xinput1_4,
  fallback xinput9_1_0), throttled rescan while disconnected.
- MainForm intercepts bound keys in ProcessCmdKey so arrows/space
  reach the panel instead of moving focus; keys release on focus
  loss; center-axes and host ResetRequest reset the rate integrators.
- Canvas highlights router-held addresses like clicks.
- Defaults: arrows=stick, W/S=throttle, Q/E=pedals, numpad=internal
  keypad, IJKL/Space=hat+main; pad left stick/triggers/right stick =
  stick/pedals/throttle-rate, ABXY/dpad/shoulders = named buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-06 11:30:55 -05:00
co-authored by Claude Fable 5
parent 1f0781f06a
commit e590b89c47
9 changed files with 1315 additions and 6 deletions
+255
View File
@@ -0,0 +1,255 @@
using System.Globalization;
using VRio.Core.Device;
namespace VRio.Core.Input;
/// <summary>
/// The plain-text bindings file: one binding per line, <c>#</c> comments,
/// case-insensitive keywords, whitespace-separated tokens. Grammar:
/// <code>
/// key &lt;name&gt; button &lt;addr&gt; [toggle]
/// key &lt;name&gt; axis &lt;axis&gt; deflect &lt;n&gt; | rate &lt;n&gt;
/// pad &lt;button&gt; button &lt;addr&gt; [toggle]
/// padaxis &lt;src&gt; axis &lt;axis&gt; [invert] [deadzone &lt;d&gt;] [rate &lt;n&gt;]
/// </code>
/// Bad lines are reported (with their line number) and skipped, so one typo
/// doesn't take the whole profile down.
/// </summary>
public static class BindingProfileFormat
{
/// <summary>
/// Parse a bindings file. <paramref name="errors"/> receives one message
/// per rejected line; every well-formed line still becomes a binding.
/// </summary>
public static BindingProfile Parse(string text, out IReadOnlyList<string> errors)
{
var keyButtons = new List<KeyButtonBinding>();
var keyAxes = new List<KeyAxisBinding>();
var padButtons = new List<PadButtonBinding>();
var padAxes = new List<PadAxisBinding>();
var errs = new List<string>();
errors = errs;
string[] lines = text.Split('\n');
for (int i = 0; i < lines.Length; i++)
{
string line = lines[i];
int hash = line.IndexOf('#');
if (hash >= 0) line = line.Substring(0, hash);
string[] tok = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (tok.Length == 0)
continue;
try
{
ParseLine(tok, keyButtons, keyAxes, padButtons, padAxes);
}
catch (FormatException ex)
{
errs.Add($"line {i + 1}: {ex.Message}");
}
}
return new BindingProfile(keyButtons, keyAxes, padButtons, padAxes);
}
private static void ParseLine(string[] tok,
List<KeyButtonBinding> keyButtons, List<KeyAxisBinding> keyAxes,
List<PadButtonBinding> padButtons, List<PadAxisBinding> padAxes)
{
if (tok.Length < 4)
throw new FormatException("expected '<source> <name> <target> <value> …'");
string source = tok[0].ToLowerInvariant();
string target = tok[2].ToLowerInvariant();
switch (source, target)
{
case ("key", "button"):
keyButtons.Add(new KeyButtonBinding(tok[1], ParseAddress(tok[3]), ParseToggle(tok, 4)));
break;
case ("key", "axis"):
{
if (tok.Length < 6)
throw new FormatException("key axis bindings need 'deflect <n>' or 'rate <n>'");
KeyAxisMode mode = tok[4].ToLowerInvariant() switch
{
"deflect" => KeyAxisMode.Deflect,
"rate" => KeyAxisMode.Rate,
var other => throw new FormatException($"unknown key-axis mode '{other}' (deflect/rate)"),
};
keyAxes.Add(new KeyAxisBinding(tok[1], ParseRioAxis(tok[3]), mode, ParseFloat(tok[5])));
break;
}
case ("pad", "button"):
padButtons.Add(new PadButtonBinding(ParsePadButton(tok[1]), ParseAddress(tok[3]), ParseToggle(tok, 4)));
break;
case ("padaxis", "axis"):
{
bool invert = false;
float deadzone = 0f, rate = 0f;
for (int i = 4; i < tok.Length; i++)
{
switch (tok[i].ToLowerInvariant())
{
case "invert":
invert = true;
break;
case "deadzone":
deadzone = ParseFloat(TakeValue(tok, ref i, "deadzone"));
break;
case "rate":
rate = ParseFloat(TakeValue(tok, ref i, "rate"));
break;
default:
throw new FormatException($"unknown padaxis option '{tok[i]}'");
}
}
padAxes.Add(new PadAxisBinding(ParsePadAxis(tok[1]), ParseRioAxis(tok[3]), invert, deadzone, rate));
break;
}
default:
throw new FormatException($"unknown binding form '{tok[0]} … {tok[2]}'");
}
}
private static string TakeValue(string[] tok, ref int i, string option)
{
if (i + 1 >= tok.Length)
throw new FormatException($"'{option}' needs a value");
return tok[++i];
}
private static bool ParseToggle(string[] tok, int index)
{
if (tok.Length <= index)
return false;
if (tok.Length == index + 1 && tok[index].Equals("toggle", StringComparison.OrdinalIgnoreCase))
return true;
throw new FormatException($"unexpected '{tok[index]}' (only 'toggle' may follow the address)");
}
private static int ParseAddress(string s)
{
int addr;
bool ok = s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? int.TryParse(s.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out addr)
: int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out addr);
if (!ok || !(RioAddressSpace.IsButton(addr) || RioAddressSpace.IsKeypad(addr)))
throw new FormatException($"'{s}' is not a RIO input address (0x00-0x47, 0x50-0x6F)");
return addr;
}
private static RioAxis ParseRioAxis(string s) =>
Enum.TryParse(s, ignoreCase: true, out RioAxis axis) && Enum.IsDefined(typeof(RioAxis), axis)
? axis
: throw new FormatException($"unknown RIO axis '{s}' (Throttle/LeftPedal/RightPedal/JoystickY/JoystickX)");
private static PadButtons ParsePadButton(string s) =>
Enum.TryParse(s, ignoreCase: true, out PadButtons b) && b != PadButtons.None && Enum.IsDefined(typeof(PadButtons), b)
? b
: throw new FormatException($"unknown pad button '{s}'");
private static PadAxis ParsePadAxis(string s) =>
Enum.TryParse(s, ignoreCase: true, out PadAxis a) && Enum.IsDefined(typeof(PadAxis), a)
? a
: throw new FormatException($"unknown pad axis '{s}'");
private static float ParseFloat(string s) =>
float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out float f)
? f
: throw new FormatException($"'{s}' is not a number");
/// <summary>
/// The default profile text, written verbatim (comments and all) as the
/// user's bindings file on first run.
/// </summary>
public const string DefaultText = @"# vRIO input bindings — keyboard and Xbox (XInput) controller.
#
# One binding per line, '#' starts a comment, keywords are case-
# insensitive. Edit freely, then press 'Reload bindings' in vRIO.
#
# key <name> button <addr> [toggle]
# key <name> axis <axis> deflect <n>
# key <name> axis <axis> rate <n-per-second>
# pad <button> button <addr> [toggle]
# padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n-per-second>]
#
# <addr> RIO input address: lamp buttons 0x00-0x47, internal keypad
# 0x50-0x5F, external keypad 0x60-0x6F (hex or decimal).
# <axis> Throttle | LeftPedal | RightPedal | JoystickY | JoystickX
# <name> .NET Keys name: A-Z, D0-D9 (digit row), NumPad0-NumPad9,
# Up, Down, Left, Right, Space, Enter, OemMinus, Oemplus, ...
# <button> A B X Y DPadUp DPadDown DPadLeft DPadRight Start Back
# LeftShoulder RightShoulder LeftThumb RightThumb
# <src> LeftStickX LeftStickY RightStickX RightStickY
# LeftTrigger RightTrigger
#
# Axis values are normalized: 1 = the axis' full realistic travel
# (throttle 0..-800 raw, pedals 0..+500, stick +/-80 — the wire signs
# are applied for you). 'deflect' holds the axis there while the key
# is down and springs back on release; 'rate' walks the axis by <n>
# per second and the position sticks (use it for the throttle).
# NOTE: raw stick X runs NEGATIVE to the right (that is what RIOJoy
# expects), so 'right' is deflect -1 and pad X is inverted below.
# ---- Joystick: arrows deflect, spring back on release -------------
key Up axis JoystickY deflect 1
key Down axis JoystickY deflect -1
key Right axis JoystickX deflect -1
key Left axis JoystickX deflect 1
# ---- Throttle: W advances, S backs off, position holds ------------
key W axis Throttle rate 0.75
key S axis Throttle rate -0.75
# ---- Pedals: hold to press, spring back ---------------------------
key Q axis LeftPedal deflect 1
key E axis RightPedal deflect 1
# ---- Joystick / throttle column buttons ---------------------------
key Space button 0x40 # Main
key I button 0x42 # Hat Up
key K button 0x41 # Hat Back
key J button 0x44 # Hat Left
key L button 0x43 # Hat Right
key P button 0x45 # Pinky
key M button 0x46 # Middle
key U button 0x47 # Upper
key B button 0x3D # Panic
# ---- Internal keypad on the numpad --------------------------------
key NumPad0 button 0x50
key NumPad1 button 0x51
key NumPad2 button 0x52
key NumPad3 button 0x53
key NumPad4 button 0x54
key NumPad5 button 0x55
key NumPad6 button 0x56
key NumPad7 button 0x57
key NumPad8 button 0x58
key NumPad9 button 0x59
# ---- Xbox controller: axes ----------------------------------------
padaxis LeftStickX axis JoystickX invert deadzone 0.2
padaxis LeftStickY axis JoystickY deadzone 0.2
padaxis RightStickY axis Throttle deadzone 0.2 rate 0.75
padaxis LeftTrigger axis LeftPedal deadzone 0.12
padaxis RightTrigger axis RightPedal deadzone 0.12
# ---- Xbox controller: buttons -------------------------------------
pad A button 0x40 # Main
pad B button 0x3D # Panic
pad X button 0x45 # Pinky
pad Y button 0x47 # Upper
pad DPadUp button 0x42 # Hat Up
pad DPadDown button 0x41 # Hat Back
pad DPadLeft button 0x44 # Hat Left
pad DPadRight button 0x43 # Hat Right
pad LeftShoulder button 0x46 # Middle
pad RightShoulder button 0x3F # Throttle button
";
}
+69
View File
@@ -0,0 +1,69 @@
using VRio.Core.Device;
namespace VRio.Core.Input;
/// <summary>
/// How a key drives an axis: <see cref="Deflect"/> holds the axis at the
/// binding's value while the key is down and springs back when released
/// (stick, pedals); <see cref="Rate"/> walks the axis by value-per-second
/// while held and the position sticks (throttle).
/// </summary>
public enum KeyAxisMode
{
Deflect,
Rate,
}
/// <summary>A keyboard key pressing a RIO input address.</summary>
/// <param name="Key">Key name as the host reports it (System.Windows.Forms
/// Keys.ToString(): "W", "Up", "NumPad1", …); matched case-insensitively.</param>
/// <param name="Address">RIO input address (button 0x000x47 or keypad 0x500x6F).</param>
/// <param name="Toggle">Latch on press instead of momentary press/release.</param>
public sealed record KeyButtonBinding(string Key, int Address, bool Toggle);
/// <summary>
/// A keyboard key driving a RIO axis in normalized units, where 1 is the
/// axis' full realistic travel (<see cref="RioAxisRange.Full"/> carries the
/// wire sign, so bindings never deal with the throttle's negative direction).
/// </summary>
/// <param name="Value">Deflect: the normalized position held while the key is
/// down. Rate: normalized units per second, signed.</param>
public sealed record KeyAxisBinding(string Key, RioAxis Axis, KeyAxisMode Mode, float Value);
/// <summary>A gamepad button pressing a RIO input address.</summary>
public sealed record PadButtonBinding(PadButtons Button, int Address, bool Toggle);
/// <summary>
/// A gamepad analog control driving a RIO axis. With <paramref name="Rate"/>
/// zero the (deadzoned, optionally inverted) pad value maps directly to the
/// normalized axis position; with a positive rate the pad value is a speed —
/// the axis integrates by value × rate per second and holds (throttle-style).
/// </summary>
public sealed record PadAxisBinding(PadAxis Source, RioAxis Axis, bool Invert, float Deadzone, float Rate);
/// <summary>An immutable set of input bindings (one parsed profile file).</summary>
public sealed class BindingProfile
{
public static readonly BindingProfile Empty = new(
Array.Empty<KeyButtonBinding>(), Array.Empty<KeyAxisBinding>(),
Array.Empty<PadButtonBinding>(), Array.Empty<PadAxisBinding>());
public BindingProfile(
IReadOnlyList<KeyButtonBinding> keyButtons,
IReadOnlyList<KeyAxisBinding> keyAxes,
IReadOnlyList<PadButtonBinding> padButtons,
IReadOnlyList<PadAxisBinding> padAxes)
{
KeyButtons = keyButtons;
KeyAxes = keyAxes;
PadButtons = padButtons;
PadAxes = padAxes;
}
public IReadOnlyList<KeyButtonBinding> KeyButtons { get; }
public IReadOnlyList<KeyAxisBinding> KeyAxes { get; }
public IReadOnlyList<PadButtonBinding> PadButtons { get; }
public IReadOnlyList<PadAxisBinding> PadAxes { get; }
public int Count => KeyButtons.Count + KeyAxes.Count + PadButtons.Count + PadAxes.Count;
}
+316
View File
@@ -0,0 +1,316 @@
using VRio.Core.Device;
namespace VRio.Core.Input;
/// <summary>
/// Routes keyboard and gamepad input into a <see cref="VRioDevice"/> through
/// a <see cref="BindingProfile"/>. The host feeds key edges
/// (<see cref="KeyDown"/>/<see cref="KeyUp"/> — auto-repeat is suppressed
/// here), gamepad snapshots (<see cref="SetPadState"/>), and a steady
/// <see cref="Tick"/> for the time-based axis modes.
///
/// <para>Axes are composed in normalized units (1 = full realistic travel,
/// <see cref="RioAxisRange.Full"/> supplies the wire sign): rate integrators
/// (throttle-style, position holds) + deflect keys currently held + direct
/// pad positions, clamped to the axis' travel window. The device is only
/// written when the composed value changes, so mouse drags on the panel keep
/// working while a source is idle.</para>
///
/// <para>Buttons keep a hold count per RIO address, so a key and a pad button
/// bound to the same control overlap cleanly: the press goes on the wire at
/// 0→1 and the release at 1→0. <see cref="AddressHeldChanged"/> mirrors those
/// edges for the UI. Not thread-safe — call from one thread (the UI's).</para>
/// </summary>
public sealed class InputRouter
{
private static readonly RioAxis[] Axes = (RioAxis[])Enum.GetValues(typeof(RioAxis));
private readonly VRioDevice _device;
private BindingProfile _profile = BindingProfile.Empty;
private readonly Dictionary<string, List<KeyButtonBinding>> _keyButtons = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, List<KeyAxisBinding>> _keyAxes = new(StringComparer.OrdinalIgnoreCase);
private readonly List<PadAxisBinding>[] _padAxesByAxis;
private readonly List<KeyAxisBinding>[] _deflectByAxis;
private readonly HashSet<string> _heldKeys = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<int, int> _holdCounts = new();
private readonly HashSet<int> _toggled = new();
private readonly float[] _rate = new float[Axes.Length]; // normalized integrators
private readonly short?[] _lastSent = new short?[Axes.Length];
private PadState _pad;
private PadButtons _prevPadButtons;
public InputRouter(VRioDevice device)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
_padAxesByAxis = new List<PadAxisBinding>[Axes.Length];
_deflectByAxis = new List<KeyAxisBinding>[Axes.Length];
for (int i = 0; i < Axes.Length; i++)
{
_padAxesByAxis[i] = new List<PadAxisBinding>();
_deflectByAxis[i] = new List<KeyAxisBinding>();
}
}
/// <summary>A routed address went down (true) or came back up (false) — for panel highlighting.</summary>
public event Action<int, bool>? AddressHeldChanged;
/// <summary>
/// The active bindings. Assigning releases everything currently held
/// (keys, toggles, pad buttons) so a reload never strands a pressed input.
/// </summary>
public BindingProfile Profile
{
get => _profile;
set
{
ReleaseEverything();
_profile = value ?? BindingProfile.Empty;
_keyButtons.Clear();
_keyAxes.Clear();
foreach (KeyButtonBinding b in _profile.KeyButtons)
Bucket(_keyButtons, b.Key).Add(b);
foreach (KeyAxisBinding b in _profile.KeyAxes)
Bucket(_keyAxes, b.Key).Add(b);
for (int i = 0; i < Axes.Length; i++)
{
_padAxesByAxis[i].Clear();
_deflectByAxis[i].Clear();
}
foreach (PadAxisBinding b in _profile.PadAxes)
_padAxesByAxis[(int)b.Axis].Add(b);
foreach (KeyAxisBinding b in _profile.KeyAxes)
if (b.Mode == KeyAxisMode.Deflect)
_deflectByAxis[(int)b.Axis].Add(b);
ResetAxisState();
}
}
private static List<T> Bucket<T>(Dictionary<string, List<T>> map, string key)
{
if (!map.TryGetValue(key, out List<T>? list))
map[key] = list = new List<T>();
return list;
}
/// <summary>True if any binding uses this key (the host swallows only bound keys).</summary>
public bool HasKeyBinding(string key) => _keyButtons.ContainsKey(key) || _keyAxes.ContainsKey(key);
// ---- Keyboard -----------------------------------------------------------
/// <summary>A key went down. Repeats while held are ignored.</summary>
public void KeyDown(string key)
{
if (!_heldKeys.Add(key))
return;
if (!_keyButtons.TryGetValue(key, out List<KeyButtonBinding>? bindings))
return;
foreach (KeyButtonBinding b in bindings)
{
if (b.Toggle)
ToggleAddress(b.Address);
else
IncHold(b.Address);
}
}
/// <summary>A key came back up.</summary>
public void KeyUp(string key)
{
if (!_heldKeys.Remove(key))
return;
if (!_keyButtons.TryGetValue(key, out List<KeyButtonBinding>? bindings))
return;
foreach (KeyButtonBinding b in bindings)
{
if (!b.Toggle)
DecHold(b.Address);
}
}
/// <summary>
/// Release every held key (focus loss, keyboard input turned off).
/// Toggle latches survive, like the panel's right-click latches.
/// </summary>
public void ReleaseAllKeys()
{
foreach (string key in _heldKeys.ToArray())
KeyUp(key);
}
// ---- Gamepad ------------------------------------------------------------
/// <summary>
/// Feed the latest gamepad snapshot; button edges fire immediately, axes
/// are picked up by the next <see cref="Tick"/>. Feed <c>default</c> when
/// the pad disconnects or is disabled so everything it held releases.
/// </summary>
public void SetPadState(PadState state)
{
PadButtons changed = state.Buttons ^ _prevPadButtons;
if (changed != PadButtons.None)
{
foreach (PadButtonBinding b in _profile.PadButtons)
{
if ((changed & b.Button) == 0)
continue;
bool down = (state.Buttons & b.Button) != 0;
if (b.Toggle)
{
if (down)
ToggleAddress(b.Address);
}
else if (down)
{
IncHold(b.Address);
}
else
{
DecHold(b.Address);
}
}
}
_prevPadButtons = state.Buttons;
_pad = state;
}
// ---- Time base ----------------------------------------------------------
/// <summary>
/// Advance rate integrators by <paramref name="dtSeconds"/> and push the
/// composed axis values to the device (only where they changed).
/// </summary>
public void Tick(double dtSeconds)
{
// Pass 1: advance the rate integrators (held rate keys, rate-mode pad axes).
foreach (KeyAxisBinding b in _profile.KeyAxes)
{
if (b.Mode == KeyAxisMode.Rate && _heldKeys.Contains(b.Key))
AddRate(b.Axis, (float)(b.Value * dtSeconds));
}
foreach (PadAxisBinding b in _profile.PadAxes)
{
if (b.Rate > 0f)
AddRate(b.Axis, (float)(Shape(_pad.Axis(b.Source), b) * b.Rate * dtSeconds));
}
// Pass 2: compose each axis and write it if it moved.
for (int i = 0; i < Axes.Length; i++)
{
RioAxis axis = Axes[i];
float total = _rate[i];
foreach (KeyAxisBinding b in _deflectByAxis[i])
{
if (_heldKeys.Contains(b.Key))
total += b.Value;
}
foreach (PadAxisBinding b in _padAxesByAxis[i])
{
if (b.Rate <= 0f)
total += Shape(_pad.Axis(b.Source), b);
}
total = ClampNorm(axis, total);
short raw = (short)Math.Round(total * RioAxisRange.Full(axis));
short prev = _lastSent[i] ?? _device.GetAxis(axis);
if (raw != prev)
_device.SetAxis(axis, raw);
_lastSent[i] = raw;
}
}
/// <summary>
/// Forget integrated/last-sent axis state — call after the axes were
/// re-zeroed behind the router's back (center button, host ResetRequest)
/// so a rate-driven throttle doesn't resume from its old position.
/// </summary>
public void ResetAxisState()
{
Array.Clear(_rate, 0, _rate.Length);
for (int i = 0; i < _lastSent.Length; i++)
_lastSent[i] = null;
}
// ---- Internals ----------------------------------------------------------
private void AddRate(RioAxis axis, float delta) =>
_rate[(int)axis] = ClampNorm(axis, _rate[(int)axis] + delta);
/// <summary>Deadzone (rescaled so travel stays continuous) + inversion.</summary>
private static float Shape(float value, PadAxisBinding b)
{
float v = value;
if (b.Deadzone > 0f)
{
float mag = Math.Abs(v);
v = mag <= b.Deadzone ? 0f : Math.Sign(v) * (mag - b.Deadzone) / (1f - b.Deadzone);
}
return b.Invert ? -v : v;
}
/// <summary>Stick axes are bipolar (±1), throttle/pedals unipolar (0..1 of travel).</summary>
private static float ClampNorm(RioAxis axis, float value)
{
float min = axis is RioAxis.JoystickX or RioAxis.JoystickY ? -1f : 0f;
return Math.Max(min, Math.Min(1f, value));
}
private void ToggleAddress(int address)
{
if (_toggled.Remove(address))
DecHold(address);
else
{
_toggled.Add(address);
IncHold(address);
}
}
private void IncHold(int address)
{
_holdCounts.TryGetValue(address, out int count);
_holdCounts[address] = count + 1;
if (count == 0)
{
_device.PressAddress(address);
AddressHeldChanged?.Invoke(address, true);
}
}
private void DecHold(int address)
{
if (!_holdCounts.TryGetValue(address, out int count))
return;
if (count <= 1)
{
_holdCounts.Remove(address);
_device.ReleaseAddress(address);
AddressHeldChanged?.Invoke(address, false);
}
else
{
_holdCounts[address] = count - 1;
}
}
private void ReleaseEverything()
{
_heldKeys.Clear();
_toggled.Clear();
_prevPadButtons = PadButtons.None;
_pad = default;
foreach (int address in _holdCounts.Keys.ToArray())
{
_holdCounts.Remove(address);
_device.ReleaseAddress(address);
AddressHeldChanged?.Invoke(address, false);
}
}
}
+76
View File
@@ -0,0 +1,76 @@
namespace VRio.Core.Input;
/// <summary>
/// Gamepad button flags, bit-for-bit the XInput <c>wButtons</c> mask so the
/// App-side poller can cast the native value straight through.
/// </summary>
[Flags]
public enum PadButtons : ushort
{
None = 0,
DPadUp = 0x0001,
DPadDown = 0x0002,
DPadLeft = 0x0004,
DPadRight = 0x0008,
Start = 0x0010,
Back = 0x0020,
LeftThumb = 0x0040,
RightThumb = 0x0080,
LeftShoulder = 0x0100,
RightShoulder = 0x0200,
A = 0x1000,
B = 0x2000,
X = 0x4000,
Y = 0x8000,
}
/// <summary>A gamepad analog control usable as a binding source.</summary>
public enum PadAxis
{
LeftStickX,
LeftStickY,
RightStickX,
RightStickY,
LeftTrigger,
RightTrigger,
}
/// <summary>
/// One normalized gamepad snapshot: sticks 1..+1 (up/right positive),
/// triggers 0..1. The default value is "pad at rest / no pad connected".
/// </summary>
public readonly struct PadState
{
public PadState(PadButtons buttons,
float leftStickX, float leftStickY, float rightStickX, float rightStickY,
float leftTrigger, float rightTrigger)
{
Buttons = buttons;
LeftStickX = leftStickX;
LeftStickY = leftStickY;
RightStickX = rightStickX;
RightStickY = rightStickY;
LeftTrigger = leftTrigger;
RightTrigger = rightTrigger;
}
public PadButtons Buttons { get; }
public float LeftStickX { get; }
public float LeftStickY { get; }
public float RightStickX { get; }
public float RightStickY { get; }
public float LeftTrigger { get; }
public float RightTrigger { get; }
/// <summary>Value of one analog control.</summary>
public float Axis(PadAxis axis) => axis switch
{
PadAxis.LeftStickX => LeftStickX,
PadAxis.LeftStickY => LeftStickY,
PadAxis.RightStickX => RightStickX,
PadAxis.RightStickY => RightStickY,
PadAxis.LeftTrigger => LeftTrigger,
PadAxis.RightTrigger => RightTrigger,
_ => 0f,
};
}