The plasma glass now lives in vRIO's encoder strip: PlasmaCanvas grew a dot-pitch parameter (default keeps the standalone glass pixel-identical) and is compile-linked into VRio.App at pitch 3, parked at the strip's left slot. The axis gauges move to the grid's right edge, the strip is as tall as the glass, and the status text sits between them with the axis readout split onto two lines. The button grid also compacts: rows shift up past the layout's empty row 0 and the spare bottom row is trimmed (the shared CockpitLayout is untouched, so coordinates still match RIOJoy's map). Control strip: the COM row labels are now the device names -- vRIO and vPLASMA -- coloured by port status (green open, gray closed), replacing the port-status line. The plasma row auto-opens COM12 at startup like the RIO row does COM11; one Rescan refreshes both pickers. Plasma log lines share the wire log under a vPLASMA: prefix, and the standalone gestures carry over (double-click self-test, right-click reset). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
529 lines
21 KiB
C#
529 lines
21 KiB
C#
using VPlasma.App;
|
||
using VRio.Core.Device;
|
||
using VRio.Core.Panel;
|
||
using VRio.Core.Protocol;
|
||
|
||
namespace VRio.App;
|
||
|
||
/// <summary>
|
||
/// The interactive cockpit control panel. Visually it mirrors RIOJoy's profile
|
||
/// editor map (same functional groups, cell geometry, and lamp colours), but
|
||
/// here the cells are the <em>physical controls</em>: pressing one emits a
|
||
/// button/keypad packet on the wire, and the fill shade tracks the lamp state
|
||
/// the host last commanded (including flash modes). The encoder strip is live
|
||
/// too — drag the X/Y box and the Z/L/R gauges to move the analog axes. The
|
||
/// gauges span each axis' realistic hardware travel (<see cref="RioAxisRange"/>),
|
||
/// not the full 14-bit wire range: Z/L/R rest at raw 0 at the gauge bottom and
|
||
/// fill toward full travel (negative on the wire for the throttle, positive
|
||
/// for the pedals), and the stick covers ±80 around its center.
|
||
///
|
||
/// <para>The strip's left slot hosts the built-in plasma glass: MainForm parks
|
||
/// its <see cref="PlasmaCanvas"/> child at <see cref="PlasmaSlot"/>, and the
|
||
/// strip height and gauge positions derive from the glass size.</para>
|
||
///
|
||
/// <para>Left-click = momentary press (release on mouse-up). Right-click =
|
||
/// latch the button down / release it (handy for testing holds).</para>
|
||
/// </summary>
|
||
internal sealed class PanelCanvas : Control
|
||
{
|
||
// Cell geometry — identical to RIOJoy's PanelView so the two panels align.
|
||
private const int CellW = 66;
|
||
private const int CellH = 34;
|
||
|
||
private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitLayout.Buttons();
|
||
|
||
// First grid row any group occupies (the shared layout leaves row 0
|
||
// empty). Rendering shifts all rows up by this, so the button grid starts
|
||
// right under the encoder strip instead of a blank cell row below it.
|
||
private static readonly int FirstRow = CockpitLayout.Groups.Min(g => g.OriginRow);
|
||
|
||
// Encoder-strip geometry. The built-in plasma glass parks at the strip's
|
||
// left edge (MainForm adds its PlasmaCanvas as a child at PlasmaSlot),
|
||
// the axis gauges — Z, the pedal gauges L / R, and the X/Y stick box —
|
||
// hug the grid's right edge, and the green status text fills the gap
|
||
// between them. The strip is exactly as tall as the glass.
|
||
internal const int PlasmaDotPitch = 3; // 2 px dots: the 128×32 glass fits the strip
|
||
internal static readonly Size PlasmaSize = PlasmaCanvas.SizeFor(PlasmaDotPitch);
|
||
private const int StripTop = 6;
|
||
internal static readonly Point PlasmaSlot = new(6, StripTop);
|
||
private static readonly int StripH = PlasmaSize.Height;
|
||
private static readonly int TopStrip = StripTop + StripH + 6; // button grid starts under the strip
|
||
private const int Bar = 30;
|
||
private static readonly int BaseX = GridWidth() - 6 - 200; // the gauge cluster spans 200 px, right-aligned
|
||
private static readonly Rectangle BoxZ = new(BaseX, StripTop, Bar, StripH);
|
||
private static readonly Rectangle BoxL = new(BaseX + 34, StripTop, Bar, StripH);
|
||
private static readonly Rectangle BoxR = new(BaseX + 34 + 82 - Bar, StripTop, Bar, StripH);
|
||
private static readonly Rectangle BoxXY = new(BaseX + 120, StripTop, 80, StripH);
|
||
|
||
private readonly System.Windows.Forms.Timer _flashTimer = new() { Interval = 100 };
|
||
private bool _wasFlashing;
|
||
|
||
private readonly HashSet<int> _latched = new();
|
||
private readonly HashSet<int> _externalHeld = new();
|
||
private int? _mouseDownAddress;
|
||
|
||
private RioAxis? _dragAxis; // Z / L / R gauge drag
|
||
private bool _dragStick; // X/Y box drag
|
||
|
||
private string _statusText = string.Empty;
|
||
|
||
public PanelCanvas()
|
||
{
|
||
DoubleBuffered = true;
|
||
BackColor = Color.FromArgb(28, 28, 28);
|
||
Size = GridSize();
|
||
|
||
_flashTimer.Tick += (_, _) =>
|
||
{
|
||
bool flashing = AnyLampFlashing();
|
||
if (flashing || _wasFlashing)
|
||
Invalidate();
|
||
_wasFlashing = flashing;
|
||
};
|
||
_flashTimer.Start();
|
||
}
|
||
|
||
/// <summary>Current lamp-state byte for an address (from the device).</summary>
|
||
public Func<int, byte>? LampProvider { get; set; }
|
||
|
||
/// <summary>Current raw value of an axis (from the device).</summary>
|
||
public Func<RioAxis, short>? AxisProvider { get; set; }
|
||
|
||
/// <summary>
|
||
/// Axis value as transmitted to the host (joystick Y sign flip applied).
|
||
/// The text readout shows this; the dot and gauges track physical state.
|
||
/// </summary>
|
||
public Func<RioAxis, short>? WireAxisProvider { get; set; }
|
||
|
||
/// <summary>The user pressed a panel control.</summary>
|
||
public event Action<int>? AddressPressed;
|
||
|
||
/// <summary>The user released a panel control.</summary>
|
||
public event Action<int>? AddressReleased;
|
||
|
||
/// <summary>The user dragged an axis to a new raw value.</summary>
|
||
public event Action<RioAxis, short>? AxisMoved;
|
||
|
||
/// <summary>When true (default), the X/Y stick snaps back to center on release.</summary>
|
||
public bool StickSpringsBack { get; set; } = true;
|
||
|
||
/// <summary>Green status text drawn in the strip area (the mockup's status panel).</summary>
|
||
public string StatusText
|
||
{
|
||
get => _statusText;
|
||
set
|
||
{
|
||
if (_statusText == value) return;
|
||
_statusText = value;
|
||
Invalidate();
|
||
}
|
||
}
|
||
|
||
protected override void Dispose(bool disposing)
|
||
{
|
||
if (disposing) _flashTimer.Dispose();
|
||
base.Dispose(disposing);
|
||
}
|
||
|
||
// Split from GridSize so the static gauge layout can use the width
|
||
// without touching TopStrip (which initializes after AllButtons).
|
||
private static int GridWidth()
|
||
{
|
||
int maxCol = 0;
|
||
foreach (PanelButton b in AllButtons)
|
||
if (b.Col > maxCol) maxCol = b.Col;
|
||
return (maxCol + 1) * CellW + 6;
|
||
}
|
||
|
||
private static Size GridSize()
|
||
{
|
||
int maxRow = 0;
|
||
foreach (PanelButton b in AllButtons)
|
||
if (b.Row > maxRow) maxRow = b.Row;
|
||
// The last used row plus a small margin under the lowest group frame.
|
||
return new Size(GridWidth(), TopStrip + (maxRow - FirstRow + 1) * CellH + 6);
|
||
}
|
||
|
||
private static Rectangle Cell(int col, int row) =>
|
||
new(col * CellW + 2, TopStrip + (row - FirstRow) * CellH + 2, CellW - 4, CellH - 4);
|
||
|
||
// ---- Painting ----------------------------------------------------------
|
||
|
||
protected override void OnPaint(PaintEventArgs e)
|
||
{
|
||
Graphics g = e.Graphics;
|
||
g.Clear(Color.FromArgb(28, 28, 28));
|
||
|
||
using var titleFont = new Font("Segoe UI", 8f, FontStyle.Bold);
|
||
using var btnFont = new Font("Segoe UI", 7.5f);
|
||
using var subFont = new Font("Segoe UI", 6.75f);
|
||
using var groupPen = new Pen(Color.FromArgb(80, 80, 80));
|
||
|
||
DrawEncoderStrip(g, titleFont, subFont);
|
||
|
||
// Group frames + titles.
|
||
foreach (PanelGroup grp in CockpitLayout.Groups)
|
||
{
|
||
var frame = new Rectangle(
|
||
grp.OriginCol * CellW + 1,
|
||
TopStrip + (grp.OriginRow - FirstRow) * CellH + 1,
|
||
grp.Cols * CellW,
|
||
(grp.Rows + 1) * CellH);
|
||
g.DrawRectangle(groupPen, frame);
|
||
TextRenderer.DrawText(g, grp.Title, titleFont,
|
||
new Rectangle(frame.X + 2, frame.Y, frame.Width - 2, CellH), Color.Gainsboro,
|
||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
|
||
}
|
||
|
||
const TextFormatFlags oneLine = TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
|
||
TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix;
|
||
int tick = Environment.TickCount;
|
||
|
||
foreach (PanelButton b in AllButtons)
|
||
{
|
||
Rectangle r = Cell(b.Col, b.Row);
|
||
bool held = IsHeld(b.Address);
|
||
bool yellow = b.Group.Title is "Secondary" or "Screen";
|
||
|
||
// Lamp shade: locally held renders bright (a real pressed cap floods
|
||
// bright); otherwise the host-commanded lamp state decides, with the
|
||
// flash bits blinking between the commanded brightness and off.
|
||
LampBrightness shade = LampBrightness.Off;
|
||
if (b.LampCapable)
|
||
{
|
||
byte state = LampProvider?.Invoke(b.Address) ?? 0;
|
||
shade = RioLampState.Brightness(state);
|
||
if (shade != LampBrightness.Off && !FlashPhaseOn(RioLampState.Flash(state), tick))
|
||
shade = LampBrightness.Off;
|
||
}
|
||
if (held)
|
||
shade = LampBrightness.Bright;
|
||
|
||
Color fill = !b.LampCapable
|
||
? (held ? Color.FromArgb(205, 228, 255) // keypad — bright blue when pressed
|
||
: Color.FromArgb(150, 182, 226)) // keypad — neutral blue
|
||
: yellow
|
||
? shade switch
|
||
{
|
||
LampBrightness.Bright => Color.FromArgb(245, 210, 60),
|
||
LampBrightness.Dim => Color.FromArgb(140, 118, 38),
|
||
_ => Color.FromArgb(70, 60, 24),
|
||
}
|
||
: shade switch
|
||
{
|
||
LampBrightness.Bright => Color.FromArgb(230, 70, 70),
|
||
LampBrightness.Dim => Color.FromArgb(120, 50, 50),
|
||
_ => Color.FromArgb(64, 40, 40),
|
||
};
|
||
using (var fb = new SolidBrush(fill)) g.FillRectangle(fb, r);
|
||
|
||
Color fg = (b.LampCapable && !yellow) ? Color.White : Color.Black;
|
||
string hex = $"{b.Address:X2}";
|
||
string? label = CockpitLayout.PhysicalLabel(b.Address);
|
||
|
||
if (label is not null && b.Group.Kind != PanelGroupKind.Keypad)
|
||
{
|
||
TextRenderer.DrawText(g, label, btnFont, new Rectangle(r.X, r.Y + 1, r.Width, r.Height / 2), fg,
|
||
TextFormatFlags.HorizontalCenter | TextFormatFlags.Top | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
|
||
TextRenderer.DrawText(g, hex, subFont, new Rectangle(r.X, r.Bottom - r.Height / 2 - 1, r.Width, r.Height / 2), fg,
|
||
TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
|
||
}
|
||
else
|
||
{
|
||
TextRenderer.DrawText(g, label ?? hex, btnFont, r, fg, oneLine);
|
||
}
|
||
|
||
if (_latched.Contains(b.Address))
|
||
{
|
||
using var latch = new Pen(Color.Gold, 2f);
|
||
g.DrawRectangle(latch, r.X, r.Y, r.Width - 1, r.Height - 1);
|
||
}
|
||
else if (held)
|
||
{
|
||
using var hi = new Pen(Color.White, 2f);
|
||
g.DrawRectangle(hi, r.X, r.Y, r.Width - 1, r.Height - 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
private bool IsHeld(int address) =>
|
||
_mouseDownAddress == address || _latched.Contains(address) || _externalHeld.Contains(address);
|
||
|
||
/// <summary>
|
||
/// Mark an address held/released by a non-mouse source (keyboard or
|
||
/// gamepad via the input router) so it lights up like a click.
|
||
/// </summary>
|
||
public void SetExternalHeld(int address, bool held)
|
||
{
|
||
if (held ? _externalHeld.Add(address) : _externalHeld.Remove(address))
|
||
Invalidate();
|
||
}
|
||
|
||
private static bool FlashPhaseOn(LampFlash flash, int tick)
|
||
{
|
||
int halfPeriod = flash switch
|
||
{
|
||
LampFlash.FlashSlow => 500,
|
||
LampFlash.FlashMed => 250,
|
||
LampFlash.FlashFast => 125,
|
||
_ => 0,
|
||
};
|
||
return halfPeriod == 0 || tick / halfPeriod % 2 == 0;
|
||
}
|
||
|
||
private bool AnyLampFlashing()
|
||
{
|
||
if (LampProvider is not { } lamps)
|
||
return false;
|
||
for (int a = 0; a < RioAddressSpace.LampCount; a++)
|
||
{
|
||
byte state = lamps(a);
|
||
if (RioLampState.Flash(state) != LampFlash.Solid && RioLampState.Brightness(state) != LampBrightness.Off)
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private void DrawEncoderStrip(Graphics g, Font labelFont, Font valueFont)
|
||
{
|
||
using var pen = new Pen(Color.FromArgb(120, 160, 120));
|
||
using var bg = new SolidBrush(Color.FromArgb(20, 40, 20));
|
||
using var fillBrush = new SolidBrush(Color.FromArgb(90, 190, 90));
|
||
|
||
short Axis(RioAxis a) => AxisProvider?.Invoke(a) ?? (short)0;
|
||
|
||
// Vertical gauges: Z (throttle) and the pedals L / R. Each rests at
|
||
// raw 0 (gauge bottom) and fills toward full travel at the top.
|
||
DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxZ, "Z", Axis(RioAxis.Throttle), RioAxisRange.ThrottleFull);
|
||
DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxL, "L", Axis(RioAxis.LeftPedal), RioAxisRange.PedalFull);
|
||
DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxR, "R", Axis(RioAxis.RightPedal), RioAxisRange.PedalFull);
|
||
|
||
// X/Y stick box with a crosshair at the current position.
|
||
g.FillRectangle(bg, BoxXY);
|
||
g.DrawRectangle(pen, BoxXY);
|
||
using (var mid = new Pen(Color.FromArgb(50, 90, 50)))
|
||
{
|
||
g.DrawLine(mid, BoxXY.X, BoxXY.Y + BoxXY.Height / 2, BoxXY.Right, BoxXY.Y + BoxXY.Height / 2);
|
||
g.DrawLine(mid, BoxXY.X + BoxXY.Width / 2, BoxXY.Y, BoxXY.X + BoxXY.Width / 2, BoxXY.Bottom);
|
||
}
|
||
PointF stick = StickToPoint(Axis(RioAxis.JoystickX), Axis(RioAxis.JoystickY));
|
||
g.FillEllipse(fillBrush, stick.X - 4, stick.Y - 4, 8, 8);
|
||
TextRenderer.DrawText(g, "X / Y", labelFont,
|
||
new Rectangle(BoxXY.X, BoxXY.Bottom - 16, BoxXY.Width, 14), Color.Gainsboro,
|
||
TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom);
|
||
|
||
// The mockup's green status/instruction area, between the plasma glass
|
||
// and the gauges; the live axis readout sits directly under the status
|
||
// lines (painted per frame, so drags track).
|
||
var statusRect = new Rectangle(
|
||
PlasmaSlot.X + PlasmaSize.Width + 12, StripTop,
|
||
BoxZ.Left - PlasmaSlot.X - PlasmaSize.Width - 24, StripH);
|
||
if (statusRect.Width > 60)
|
||
{
|
||
using var statusFont = new Font("Consolas", 8f);
|
||
var green = Color.FromArgb(120, 220, 120);
|
||
int readoutTop = statusRect.Y;
|
||
if (_statusText.Length > 0)
|
||
{
|
||
TextRenderer.DrawText(g, _statusText, statusFont, statusRect, green,
|
||
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak);
|
||
readoutTop += TextRenderer.MeasureText(g, _statusText, statusFont,
|
||
new Size(statusRect.Width, 0), TextFormatFlags.WordBreak).Height + 2;
|
||
}
|
||
|
||
short WireAxis(RioAxis a) => WireAxisProvider?.Invoke(a) ?? Axis(a);
|
||
string readout =
|
||
$"Z {WireAxis(RioAxis.Throttle),4} L {WireAxis(RioAxis.LeftPedal),3} R {WireAxis(RioAxis.RightPedal),3}\n" +
|
||
$"X {WireAxis(RioAxis.JoystickX),3} Y {WireAxis(RioAxis.JoystickY),3}";
|
||
TextRenderer.DrawText(g, readout, statusFont,
|
||
new Rectangle(statusRect.X, readoutTop, statusRect.Width, statusRect.Bottom - readoutTop), green,
|
||
TextFormatFlags.Left | TextFormatFlags.Top);
|
||
}
|
||
}
|
||
|
||
private static void DrawVGauge(Graphics g, Pen pen, Brush bg, Brush fill, Font labelFont,
|
||
Rectangle box, string label, short value, int full)
|
||
{
|
||
g.FillRectangle(bg, box);
|
||
// Fill upward from the raw-0 rest position at the bottom, proportional
|
||
// to how far the control has travelled toward `full` (either sign).
|
||
float norm = Math.Max(0f, Math.Min(1f, value / (float)full));
|
||
int h = (int)(norm * (box.Height - 2));
|
||
if (h > 0)
|
||
g.FillRectangle(fill, box.X + 1, box.Bottom - 1 - h, box.Width - 2, h);
|
||
g.DrawRectangle(pen, box);
|
||
TextRenderer.DrawText(g, label, labelFont, box, Color.Gainsboro,
|
||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||
}
|
||
|
||
private static PointF StickToPoint(short x, short y)
|
||
{
|
||
// Raw X runs opposite the screen direction: stick right = negative on
|
||
// the wire (RIOJoy's calibrator maps negative samples to the high half
|
||
// of its output axis), so mirror the value for display.
|
||
float nx = Norm(-x, RioAxisRange.JoystickExtent);
|
||
float ny = Norm(y, RioAxisRange.JoystickExtent);
|
||
return new PointF(
|
||
BoxXY.X + 1 + nx * (BoxXY.Width - 2),
|
||
BoxXY.Bottom - 1 - ny * (BoxXY.Height - 2)); // up = positive Y
|
||
}
|
||
|
||
/// <summary>Raw axis value → 0..1 across its ±extent travel, clamped.</summary>
|
||
private static float Norm(int value, int extent) =>
|
||
Math.Max(0f, Math.Min(1f, (value + extent) / (2f * extent)));
|
||
|
||
// ---- Interaction -------------------------------------------------------
|
||
|
||
protected override void OnMouseDown(MouseEventArgs e)
|
||
{
|
||
base.OnMouseDown(e);
|
||
Focus();
|
||
|
||
// Axis gauges first (they live above the button grid).
|
||
if (e.Button == MouseButtons.Left)
|
||
{
|
||
if (BoxXY.Contains(e.Location))
|
||
{
|
||
_dragStick = true;
|
||
Capture = true;
|
||
UpdateStick(e.Location);
|
||
return;
|
||
}
|
||
RioAxis? gauge = HitGauge(e.Location);
|
||
if (gauge is { } axis)
|
||
{
|
||
_dragAxis = axis;
|
||
Capture = true;
|
||
UpdateGauge(axis, e.Location);
|
||
return;
|
||
}
|
||
}
|
||
|
||
PanelButton? hit = HitButton(e.Location);
|
||
if (hit is null)
|
||
return;
|
||
|
||
if (e.Button == MouseButtons.Right)
|
||
{
|
||
// Latch toggle: press-and-hold without keeping the mouse down.
|
||
if (_latched.Remove(hit.Address))
|
||
AddressReleased?.Invoke(hit.Address);
|
||
else
|
||
{
|
||
_latched.Add(hit.Address);
|
||
AddressPressed?.Invoke(hit.Address);
|
||
}
|
||
Invalidate();
|
||
}
|
||
else if (e.Button == MouseButtons.Left)
|
||
{
|
||
if (_latched.Remove(hit.Address))
|
||
{
|
||
// Left-clicking a latched button releases it.
|
||
AddressReleased?.Invoke(hit.Address);
|
||
Invalidate();
|
||
return;
|
||
}
|
||
_mouseDownAddress = hit.Address;
|
||
AddressPressed?.Invoke(hit.Address);
|
||
Invalidate();
|
||
}
|
||
}
|
||
|
||
protected override void OnMouseMove(MouseEventArgs e)
|
||
{
|
||
base.OnMouseMove(e);
|
||
if (_dragStick)
|
||
UpdateStick(e.Location);
|
||
else if (_dragAxis is { } axis)
|
||
UpdateGauge(axis, e.Location);
|
||
}
|
||
|
||
protected override void OnMouseUp(MouseEventArgs e)
|
||
{
|
||
base.OnMouseUp(e);
|
||
|
||
if (_dragStick)
|
||
{
|
||
_dragStick = false;
|
||
Capture = false;
|
||
if (StickSpringsBack)
|
||
{
|
||
AxisMoved?.Invoke(RioAxis.JoystickX, 0);
|
||
AxisMoved?.Invoke(RioAxis.JoystickY, 0);
|
||
}
|
||
return;
|
||
}
|
||
if (_dragAxis is not null)
|
||
{
|
||
_dragAxis = null;
|
||
Capture = false;
|
||
return;
|
||
}
|
||
|
||
ReleaseMomentary();
|
||
}
|
||
|
||
protected override void OnMouseCaptureChanged(EventArgs e)
|
||
{
|
||
base.OnMouseCaptureChanged(e);
|
||
_dragStick = false;
|
||
_dragAxis = null;
|
||
ReleaseMomentary();
|
||
}
|
||
|
||
private void ReleaseMomentary()
|
||
{
|
||
if (_mouseDownAddress is not { } addr)
|
||
return;
|
||
_mouseDownAddress = null;
|
||
AddressReleased?.Invoke(addr);
|
||
Invalidate();
|
||
}
|
||
|
||
private static PanelButton? HitButton(Point p)
|
||
{
|
||
foreach (PanelButton b in AllButtons)
|
||
if (Cell(b.Col, b.Row).Contains(p))
|
||
return b;
|
||
return null;
|
||
}
|
||
|
||
private static RioAxis? HitGauge(Point p)
|
||
{
|
||
if (BoxZ.Contains(p)) return RioAxis.Throttle;
|
||
if (BoxL.Contains(p)) return RioAxis.LeftPedal;
|
||
if (BoxR.Contains(p)) return RioAxis.RightPedal;
|
||
return null;
|
||
}
|
||
|
||
private void UpdateStick(Point p)
|
||
{
|
||
// Dragging right lowers raw X — the wire sign is opposite the screen
|
||
// direction (see StickToPoint).
|
||
short x = FromNorm((BoxXY.Right - p.X) / (float)BoxXY.Width, RioAxisRange.JoystickExtent);
|
||
short y = FromNorm((BoxXY.Bottom - p.Y) / (float)BoxXY.Height, RioAxisRange.JoystickExtent);
|
||
AxisMoved?.Invoke(RioAxis.JoystickX, x);
|
||
AxisMoved?.Invoke(RioAxis.JoystickY, y);
|
||
}
|
||
|
||
private void UpdateGauge(RioAxis axis, Point p)
|
||
{
|
||
Rectangle box = axis switch
|
||
{
|
||
RioAxis.Throttle => BoxZ,
|
||
RioAxis.LeftPedal => BoxL,
|
||
_ => BoxR,
|
||
};
|
||
// Gauge bottom = rest (raw 0), top = full travel (sign per axis).
|
||
float norm = Math.Max(0f, Math.Min(1f, (box.Bottom - p.Y) / (float)box.Height));
|
||
AxisMoved?.Invoke(axis, (short)(norm * RioAxisRange.Full(axis)));
|
||
}
|
||
|
||
private static short FromNorm(float norm, int extent)
|
||
{
|
||
norm = Math.Max(0f, Math.Min(1f, norm));
|
||
return (short)(-extent + norm * 2 * extent);
|
||
}
|
||
}
|