#include "JoystickPad.h" #include namespace { constexpr float kPi = juce::MathConstants::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); } }