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:
@@ -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 <name> button <addr> [toggle]
|
||||
/// key <name> axis <axis> deflect <n> | rate <n>
|
||||
/// pad <button> button <addr> [toggle]
|
||||
/// padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n>]
|
||||
/// </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
|
||||
";
|
||||
}
|
||||
@@ -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 0x00–0x47 or keypad 0x50–0x6F).</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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user