Replace nav buttons with a virtual joystick pad

This commit is contained in:
Armin 2026-10-09 17:58:07 +02:00
commit c7ebd3ed4d
6 changed files with 418 additions and 76 deletions

287
Source/JoystickPad.cpp Normal file
View file

@ -0,0 +1,287 @@
#include "JoystickPad.h"
#include <cmath>
namespace {
constexpr float kPi = juce::MathConstants<float>::pi;
float cardinalAngle (int direction)
{
switch (direction)
{
case JoystickPad::Right: return 0.0f;
case JoystickPad::Down: return kPi * 0.5f;
case JoystickPad::Left: return kPi;
case JoystickPad::Up: return -kPi * 0.5f;
default: return 0.0f;
}
}
juce::Path withCentre (juce::Path p, float cx, float cy)
{
auto bounds = p.getBounds();
p.applyTransform (juce::AffineTransform::translation (cx - bounds.getCentreX(),
cy - bounds.getCentreY()));
return p;
}
// Triangle pointing "up" (in screen space) centred on the origin.
juce::Path arrowUp (float s)
{
juce::Path p;
p.addTriangle (0.0f, -s * 0.90f,
-s * 0.55f, s * 0.55f,
s * 0.55f, s * 0.55f);
return p;
}
void drawArrow (juce::Graphics& g, float cx, float cy, int direction,
float r, juce::Colour col)
{
auto p = arrowUp (r);
float angle = cardinalAngle (direction);
p.applyTransform (juce::AffineTransform::rotation (angle + kPi * 0.5f, 0.0f, 0.0f));
p = withCentre (p, cx, cy);
g.setColour (col);
g.fillPath (p);
}
} // namespace
JoystickPad::JoystickPad()
{
setOpaque (true);
}
void JoystickPad::setRepeatSpeed (int initialDelayMs_, int repeatMs_)
{
initialDelayMs = initialDelayMs_;
repeatMs = repeatMs_;
}
int JoystickPad::directionFrom (const juce::MouseEvent& e) const
{
const float w = (float) getWidth();
const float h = (float) getHeight();
const float cx = w * 0.5f;
const float cy = h * 0.5f;
const float wr = juce::jmin (w, h) * 0.5f * 0.78f;
const float dx = e.position.x - cx;
const float dy = e.position.y - cy;
const float rad = std::hypot (dy, dx);
if (rad < wr * 0.12f)
return None;
const float a = std::atan2 (dy, dx); // -pi..pi, y grows downwards
const float oct = kPi * 0.25f;
if (a >= -oct && a < oct) return Right;
if (a >= oct && a < 3.0f * oct) return Down;
if (a >= 3.0f * oct || a < -3.0f * oct) return Left;
return Up;
}
void JoystickPad::beginHold (int direction)
{
active = (Direction) direction;
holdStartMs = juce::Time::getMillisecondCounter();
lastFireMs = holdStartMs;
repeatsStarted = false;
if (onAction)
onAction (direction);
startTimer (16);
repaint();
}
void JoystickPad::endHold()
{
active = None;
stopTimer();
repaint();
}
void JoystickPad::mouseDown (const juce::MouseEvent& e)
{
const int d = directionFrom (e);
if (d != None)
beginHold (d);
else
endHold();
}
void JoystickPad::mouseDrag (const juce::MouseEvent& e)
{
const int d = directionFrom (e);
if (d != None && d != (int) active)
beginHold (d);
else if (d == None && active != None)
endHold();
}
void JoystickPad::mouseUp (const juce::MouseEvent&)
{
endHold();
}
void JoystickPad::timerCallback()
{
if (active == None)
{
stopTimer();
return;
}
const int64 now = juce::Time::getMillisecondCounter();
if (! repeatsStarted)
{
if (now - lastFireMs >= initialDelayMs)
{
repeatsStarted = true;
lastFireMs = now;
if (onAction)
onAction ((int) active);
}
}
else if (now - lastFireMs >= repeatMs)
{
lastFireMs = now;
if (onAction)
onAction ((int) active);
}
}
void JoystickPad::paint (juce::Graphics& g)
{
const float w = (float) getWidth();
const float h = (float) getHeight();
const float cx = w * 0.5f;
const float cy = h * 0.5f;
const float bezelR = juce::jmin (w, h) * 0.5f - 1.0f;
const float wellR = bezelR * 0.78f;
// Clear the full component first (opaque component: every pixel must be
// repainted or stale content tears/flickers during redraws).
g.fillAll (juce::Colour (0xFF000000));
// Ambient under-shadow (soft, offset downward).
{
float alpha = 0.5f;
for (int i = 3; i >= 1; --i)
{
g.setColour (juce::Colour::fromFloatRGBA (0.0f, 0.0f, 0.0f, alpha / (float) i));
g.fillEllipse (cx - bezelR - (float) i, cy - bezelR - (float) i + 3.0f,
2.0f * (bezelR + (float) i), 2.0f * (bezelR + (float) i));
}
}
// Outer bezel: soft radial metal.
{
juce::ColourGradient bezel (juce::Colour (0xFF2E2E2E), cx - bezelR * 0.35f, cy - bezelR * 0.35f,
juce::Colour (0xFF0A0A0A), cx + bezelR * 0.45f, cy + bezelR * 0.45f, true);
g.setGradientFill (bezel);
g.fillEllipse (cx - bezelR, cy - bezelR, 2.0f * bezelR, 2.0f * bezelR);
}
// Bezel rim shading for a 3D ring (dark only - no bright top-light arc).
{
auto ring = [&](float r0, float r1)
{
juce::Path p;
p.addEllipse (cx - r1, cy - r1, r1 * 2.0f, r1 * 2.0f);
p.addEllipse (cx - r0, cy - r0, r0 * 2.0f, r0 * 2.0f);
p.setUsingNonZeroWinding (false);
return p;
};
g.setColour (juce::Colour (0xFF050505));
g.fillPath (ring (bezelR * 0.93f, bezelR));
}
// Recessed well (flat).
{
g.setColour (juce::Colour (0xFF0A0A0A));
g.fillEllipse (cx - wellR, cy - wellR, 2.0f * wellR, 2.0f * wellR);
}
auto wedgePath = [&](int direction) -> juce::Path
{
// JUCE measures pie/arc angles clockwise from 12 o'clock, so add
// 90 degrees to our visual heading (0 = right, +90 = down).
const float a = cardinalAngle (direction) + kPi * 0.5f;
const float start = a - kPi * 0.25f;
const float end = a + kPi * 0.25f;
juce::Path p;
p.addPieSegment (cx - wellR, cy - wellR, wellR * 2.0f, wellR * 2.0f,
start, end, 0.0f);
return p;
};
// Quarter-button wedges (flat).
for (int d = Up; d <= Right; ++d)
{
auto p = wedgePath (d);
g.setColour (juce::Colour (0xFF151515));
g.fillPath (p);
g.setColour (juce::Colour (0xFF242424));
g.strokePath (p, juce::PathStrokeType (1.0f));
}
// Flat seams between the quarters (a single dark channel).
for (int i = 0; i < 4; ++i)
{
const float a = juce::degreesToRadians (45.0f + 90.0f * (float) i);
const float dx = std::cos (a);
const float dy = std::sin (a);
g.setColour (juce::Colour (0xFF050505));
g.drawLine (cx + dx * 2.0f, cy + dy * 2.0f, cx + dx * (wellR - 2.0f), cy + dy * (wellR - 2.0f), 2.0f);
}
// Resting glyphs on the four quarters.
for (int d = Up; d <= Right; ++d)
if (d != (int) active)
drawArrow (g, cx + std::cos (cardinalAngle (d)) * wellR * 0.55f,
cy + std::sin (cardinalAngle (d)) * wellR * 0.55f,
d, wellR * 0.16f, juce::Colour (0xFF454545));
// Lit quarter (active direction) - flat bright fill, black glyph.
if (active != None)
{
auto p = wedgePath ((int) active);
g.setColour (juce::Colour (0xFFECECEC));
g.fillPath (p);
drawArrow (g, cx + std::cos (cardinalAngle ((int) active)) * wellR * 0.55f,
cy + std::sin (cardinalAngle ((int) active)) * wellR * 0.55f,
(int) active, wellR * 0.16f, juce::Colour (0xFF0A0A0A));
}
// Center cap that ties the four quarters together (static, no stick).
{
const float sr = wellR * 0.30f;
juce::ColourGradient cap (juce::Colour (0xFF181818), cx, cy - sr,
juce::Colour (0xFF080808), cx, cy + sr, false);
g.setGradientFill (cap);
g.fillEllipse (cx - sr, cy - sr, sr * 2.0f, sr * 2.0f);
g.setColour (juce::Colour (0xFF060606));
g.drawEllipse (cx - sr, cy - sr, sr * 2.0f, sr * 2.0f, 1.0f);
g.setColour (juce::Colour (0xFF2A2A2A));
g.drawEllipse (cx - sr, cy - sr, sr * 2.0f, sr * 2.0f, 0.5f);
g.setColour (juce::Colour (0xFF3A3A3A));
g.drawEllipse (cx + sr * 0.30f, cy + sr * 0.20f, sr * 0.28f, sr * 0.28f, 2.0f);
}
// Mechanical screws around the bezel.
for (int i = 0; i < 4; ++i)
{
const float a = juce::degreesToRadians (45.0f + 90.0f * (float) i);
const float srx = cx + std::cos (a) * bezelR * 0.92f;
const float sry = cy + std::sin (a) * bezelR * 0.92f;
const float sr0 = 2.0f;
g.setColour (juce::Colour (0xFF222222));
g.fillEllipse (srx - sr0, sry - sr0, sr0 * 2.0f, sr0 * 2.0f);
g.setColour (juce::Colour (0xFF080808));
g.fillEllipse (srx - sr0 * 0.4f, sry - sr0 * 0.4f, sr0 * 0.8f, sr0 * 0.8f);
}
}