// Copyright 2018 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include "DolphinQt/Config/Mapping/MappingIndicator.h" #include #include #include #include #include #include #include #include #include "Common/MathUtil.h" #include "Core/HW/WiimoteEmu/Camera.h" #include "InputCommon/ControllerEmu/Control/Control.h" #include "InputCommon/ControllerEmu/ControlGroup/Cursor.h" #include "InputCommon/ControllerEmu/ControlGroup/Force.h" #include "InputCommon/ControllerEmu/ControlGroup/MixedTriggers.h" #include "InputCommon/ControllerInterface/CoreDevice.h" #include "DolphinQt/Config/Mapping/MappingWidget.h" #include "DolphinQt/QtUtils/ModalMessageBox.h" #include "DolphinQt/Settings.h" namespace { const QColor STICK_GATE_COLOR = Qt::lightGray; const QColor C_STICK_GATE_COLOR = Qt::yellow; const QColor CURSOR_TV_COLOR = 0xaed6f1; const QColor TILT_GATE_COLOR = 0xa2d9ce; const QColor SWING_GATE_COLOR = 0xcea2d9; constexpr int INPUT_DOT_RADIUS = 2; constexpr int NORMAL_INDICATOR_WIDTH = 100; constexpr int NORMAL_INDICATOR_HEIGHT = 100; constexpr int NORMAL_INDICATOR_PADDING = 2; // Per trigger. constexpr int TRIGGER_INDICATOR_HEIGHT = 32; QPen GetCosmeticPen(QPen pen) { pen.setCosmetic(true); return pen; } QPen GetInputDotPen(QPen pen) { pen.setWidth(INPUT_DOT_RADIUS * 2); pen.setCapStyle(Qt::PenCapStyle::RoundCap); return GetCosmeticPen(pen); } } // namespace QPen MappingIndicator::GetBBoxPen() const { return QPen(palette().shadow().color(), 0); } QBrush MappingIndicator::GetBBoxBrush() const { return palette().base(); } QColor MappingIndicator::GetRawInputColor() const { QColor color = palette().text().color(); color.setAlphaF(0.5); return color; } QPen MappingIndicator::GetInputShapePen() const { return QPen{GetRawInputColor(), 0.0, Qt::DashLine}; } QColor MappingIndicator::GetAdjustedInputColor() const { return Qt::red; } QColor MappingIndicator::GetCenterColor() const { return Qt::blue; } QColor MappingIndicator::GetDeadZoneColor() const { QColor color = Settings::Instance().IsThemeDark() ? Qt::white : Qt::black; color.setAlphaF(0.25); return color; } QPen MappingIndicator::GetDeadZonePen() const { return QPen(GetDeadZoneColor(), 0); } QBrush MappingIndicator::GetDeadZoneBrush(QPainter& painter) const { QBrush brush{GetDeadZoneColor(), Qt::FDiagPattern}; brush.setTransform(painter.transform().inverted()); return brush; } QColor MappingIndicator::GetTextColor() const { return palette().text().color(); } // Text color that is visible atop GetAdjustedInputColor(): QColor MappingIndicator::GetAltTextColor() const { return palette().highlightedText().color(); } void MappingIndicator::AdjustGateColor(QColor* color) { if (Settings::Instance().IsThemeDark()) color->setHsvF(color->hueF(), color->saturationF(), 1 - color->valueF()); } SquareIndicator::SquareIndicator() { // Additional pixel for border. setFixedWidth(NORMAL_INDICATOR_WIDTH + (NORMAL_INDICATOR_PADDING + 1) * 2); setFixedHeight(NORMAL_INDICATOR_HEIGHT + (NORMAL_INDICATOR_PADDING + 1) * 2); } MixedTriggersIndicator::MixedTriggersIndicator(ControllerEmu::MixedTriggers& group) : m_group(group) { setSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Ignored); setFixedHeight(TRIGGER_INDICATOR_HEIGHT * int(group.GetTriggerCount()) + 1); } namespace { constexpr float SPHERE_SIZE = 0.7f; constexpr float SPHERE_INDICATOR_DIST = 0.85f; constexpr int SPHERE_POINT_COUNT = 200; // Constructs a polygon by querying a radius at varying angles: template QPolygonF GetPolygonFromRadiusGetter(F&& radius_getter) { // A multiple of 8 (octagon) and enough points to be visibly pleasing: constexpr int shape_point_count = 32; QPolygonF shape{shape_point_count}; int p = 0; for (auto& point : shape) { const double angle = MathUtil::TAU * p / shape.size(); const double radius = radius_getter(angle); point = {std::cos(angle) * radius, std::sin(angle) * radius}; ++p; } return shape; } // Constructs a polygon by querying a radius at varying angles: template QPolygonF GetPolygonSegmentFromRadiusGetter(F&& radius_getter, double direction, double segment_size, double segment_depth) { constexpr int shape_point_count = 6; QPolygonF shape{shape_point_count}; // We subtract from the provided direction angle so it's better // to add Tau here to prevent a negative value instead of // expecting the function call to be aware of this internal logic const double center_angle = direction + MathUtil::TAU; const double center_radius_outer = radius_getter(center_angle); const double center_radius_inner = center_radius_outer - segment_depth; const double lower_angle = center_angle - segment_size / 2; const double lower_radius_outer = radius_getter(lower_angle); const double lower_radius_inner = lower_radius_outer - segment_depth; const double upper_angle = center_angle + segment_size / 2; const double upper_radius_outer = radius_getter(upper_angle); const double upper_radius_inner = upper_radius_outer - segment_depth; shape[0] = {std::cos(lower_angle) * (lower_radius_inner), std::sin(lower_angle) * (lower_radius_inner)}; shape[1] = {std::cos(center_angle) * (center_radius_inner), std::sin(center_angle) * (center_radius_inner)}; shape[2] = {std::cos(upper_angle) * (upper_radius_inner), std::sin(upper_angle) * (upper_radius_inner)}; shape[3] = {std::cos(upper_angle) * upper_radius_outer, std::sin(upper_angle) * upper_radius_outer}; shape[4] = {std::cos(center_angle) * center_radius_outer, std::sin(center_angle) * center_radius_outer}; shape[5] = {std::cos(lower_angle) * lower_radius_outer, std::sin(lower_angle) * lower_radius_outer}; return shape; } // Used to check if the user seems to have attempted proper calibration. bool IsCalibrationDataSensible(const ControllerEmu::ReshapableInput::CalibrationData& data) { // Test that the average input radius is not below a threshold. // This will make sure the user has actually moved their stick from neutral. // Even the GC controller's small range would pass this test. constexpr double REASONABLE_AVERAGE_RADIUS = 0.6; MathUtil::RunningVariance stats; for (auto& x : data) stats.Push(x); if (stats.Mean() < REASONABLE_AVERAGE_RADIUS) { return false; } // Test that the standard deviation is below a threshold. // This will make sure the user has not just filled in one side of their input. // Approx. deviation of a square input gate, anything much more than that would be unusual. constexpr double REASONABLE_DEVIATION = 0.14; return stats.StandardDeviation() < REASONABLE_DEVIATION; } // Used to test for a miscalibrated stick so the user can be informed. bool IsPointOutsideCalibration(Common::DVec2 point, ControllerEmu::ReshapableInput& input) { const auto center = input.GetCenter(); const double current_radius = (point - center).Length(); const double input_radius = input.GetInputRadiusAtAngle( std::atan2(point.y - center.y, point.x - center.x) + MathUtil::TAU); constexpr double ALLOWED_ERROR = 1.3; return current_radius > input_radius * ALLOWED_ERROR; } void DrawVirtualNotches(QPainter& p, ControllerEmu::ReshapableInput& stick, QColor notch_color) { const double segment_size = stick.GetVirtualNotchSize(); if (segment_size <= 0.0) return; p.setBrush(notch_color); for (int i = 0; i < 8; ++i) { const double segment_depth = 1.0 - ControllerEmu::MINIMUM_NOTCH_DISTANCE; const double segment_gap = MathUtil::TAU / 8.0; const double direction = segment_gap * i; p.drawPolygon(GetPolygonSegmentFromRadiusGetter( [&stick](double ang) { return stick.GetGateRadiusAtAngle(ang); }, direction, segment_size, segment_depth)); } } template void GenerateFibonacciSphere(int point_count, F&& callback) { const float golden_angle = MathUtil::PI * (3.f - std::sqrt(5.f)); for (int i = 0; i != point_count; ++i) { const float z = (1.f / point_count - 1.f) + (2.f / point_count) * i; const float r = std::sqrt(1.f - z * z); const float x = std::cos(golden_angle * i) * r; const float y = std::sin(golden_angle * i) * r; callback(Common::Vec3{x, y, z}); } } } // namespace void MappingIndicator::paintEvent(QPaintEvent*) { constexpr float max_elapsed_seconds = 0.1f; const auto now = Clock::now(); const float elapsed_seconds = std::chrono::duration_cast(now - m_last_update).count(); m_last_update = now; const auto lock = ControllerEmu::EmulatedController::GetStateLock(); Update(std::min(elapsed_seconds, max_elapsed_seconds)); Draw(); } void CursorIndicator::Draw() { const auto adj_coord = m_cursor_group.GetState(true); DrawReshapableInput(m_cursor_group, CURSOR_TV_COLOR, adj_coord.IsVisible() ? std::make_optional(Common::DVec2(adj_coord.x, adj_coord.y)) : std::nullopt); } qreal SquareIndicator::GetContentsScale() const { return (NORMAL_INDICATOR_WIDTH - 1.0) / 2; } void SquareIndicator::DrawBoundingBox(QPainter& p) { p.setBrush(GetBBoxBrush()); p.setPen(GetBBoxPen()); p.drawRect(QRectF{NORMAL_INDICATOR_PADDING + 0.5, NORMAL_INDICATOR_PADDING + 0.5, NORMAL_INDICATOR_WIDTH + 1.0, NORMAL_INDICATOR_HEIGHT + 1.0}); } void SquareIndicator::TransformPainter(QPainter& p) { p.setRenderHint(QPainter::Antialiasing, true); p.setRenderHint(QPainter::SmoothPixmapTransform, true); p.translate(width() / 2.0, height() / 2.0); const auto scale = GetContentsScale(); p.scale(scale, scale); } void ReshapableInputIndicator::DrawReshapableInput( ControllerEmu::ReshapableInput& stick, QColor gate_brush_color, std::optional adj_coord) { QPainter p(this); DrawBoundingBox(p); TransformPainter(p); // UI y-axis is opposite that of stick. p.scale(1.0, -1.0); const auto raw_coord = stick.GetReshapableState(false); UpdateCalibrationWidget(raw_coord); if (IsCalibrating()) { DrawCalibration(p, raw_coord); return; } DrawUnderGate(p); QColor gate_pen_color = gate_brush_color.darker(125); AdjustGateColor(&gate_brush_color); AdjustGateColor(&gate_pen_color); // Input gate. (i.e. the octagon shape) p.setPen(QPen(gate_pen_color, 0)); p.setBrush(gate_brush_color); p.drawPolygon( GetPolygonFromRadiusGetter([&stick](double ang) { return stick.GetGateRadiusAtAngle(ang); })); DrawVirtualNotches(p, stick, gate_pen_color); const auto center = stick.GetCenter(); p.save(); p.translate(center.x, center.y); // Deadzone. p.setPen(GetDeadZonePen()); p.setBrush(GetDeadZoneBrush(p)); p.drawPolygon(GetPolygonFromRadiusGetter( [&stick](double ang) { return stick.GetDeadzoneRadiusAtAngle(ang); })); // Input shape. p.setPen(GetInputShapePen()); p.setBrush(Qt::NoBrush); p.drawPolygon(GetPolygonFromRadiusGetter( [&stick](double ang) { return stick.GetInputRadiusAtAngle(ang); })); // Center. if (center.x || center.y) { p.setPen(GetInputDotPen(GetCenterColor())); p.drawPoint(QPointF{}); } p.restore(); // Raw stick position. p.setPen(GetInputDotPen(GetRawInputColor())); p.drawPoint(QPointF{raw_coord.x, raw_coord.y}); // Adjusted stick position. if (adj_coord) { p.setPen(GetInputDotPen(GetAdjustedInputColor())); p.drawPoint(QPointF{adj_coord->x, adj_coord->y}); } } void AnalogStickIndicator::Draw() { // Some hacks for pretty colors: const bool is_c_stick = m_group.name == "C-Stick"; const auto gate_brush_color = is_c_stick ? C_STICK_GATE_COLOR : STICK_GATE_COLOR; const auto adj_coord = m_group.GetReshapableState(true); DrawReshapableInput(m_group, gate_brush_color, (adj_coord.x || adj_coord.y) ? std::make_optional(adj_coord) : std::nullopt); } void TiltIndicator::Update(float elapsed_seconds) { WiimoteEmu::EmulateTilt(&m_motion_state, &m_group, elapsed_seconds); } void TiltIndicator::Draw() { auto adj_coord = Common::DVec2{-m_motion_state.angle.y, m_motion_state.angle.x} / MathUtil::PI; // Angle values after dividing by pi. constexpr auto norm_180_deg = 1; constexpr auto norm_360_deg = 2; // Angle may extend beyond 180 degrees when wrapping around. // Apply modulo to draw within the indicator. // Scale down the value a bit so +1 does not become -1. adj_coord *= 0.9999f; adj_coord.x = std::fmod(adj_coord.x + norm_360_deg + norm_180_deg, norm_360_deg) - norm_180_deg; adj_coord.y = std::fmod(adj_coord.y + norm_360_deg + norm_180_deg, norm_360_deg) - norm_180_deg; DrawReshapableInput(m_group, TILT_GATE_COLOR, (adj_coord.x || adj_coord.y) ? std::make_optional(adj_coord) : std::nullopt); } void MixedTriggersIndicator::Draw() { QPainter p(this); p.setRenderHint(QPainter::TextAntialiasing, true); const auto& triggers = m_group; const ControlState threshold = triggers.GetThreshold(); const ControlState deadzone = triggers.GetDeadzone(); // MixedTriggers interface is a bit ugly: constexpr int TRIGGER_COUNT = 2; std::array raw_analog_state; std::array adj_analog_state; const std::array button_masks = {0x1, 0x2}; u16 button_state = 0; triggers.GetState(&button_state, button_masks.data(), raw_analog_state.data(), false); triggers.GetState(&button_state, button_masks.data(), adj_analog_state.data(), true); // Rectangle sizes: const int trigger_height = TRIGGER_INDICATOR_HEIGHT; const int trigger_width = width() - 1; const int trigger_button_width = trigger_height; const int trigger_analog_width = trigger_width - trigger_button_width; // Bounding box background: p.setPen(Qt::NoPen); p.setBrush(GetBBoxBrush()); p.drawRect(QRectF(0.5, 0.5, trigger_width, trigger_height * TRIGGER_COUNT)); for (int t = 0; t != TRIGGER_COUNT; ++t) { const double raw_analog = raw_analog_state[t]; const double adj_analog = adj_analog_state[t]; const bool trigger_button = button_state & button_masks[t]; auto const analog_name = QString::fromStdString(triggers.controls[TRIGGER_COUNT + t]->ui_name); auto const button_name = QString::fromStdString(triggers.controls[t]->ui_name); const QRectF trigger_rect(0.5, 0.5, trigger_width, trigger_height); const QRectF analog_rect(0.5, 0.5, trigger_analog_width, trigger_height); // Unactivated analog text: p.setPen(GetTextColor()); p.drawText(analog_rect, Qt::AlignCenter, analog_name); const QRectF adj_analog_rect(0.5, 0.5, adj_analog * trigger_analog_width, trigger_height); // Trigger analog: p.setPen(Qt::NoPen); p.setBrush(GetAdjustedInputColor()); p.drawRect(adj_analog_rect); p.setPen(GetInputDotPen(GetRawInputColor())); p.drawPoint(QPoint(raw_analog * trigger_analog_width, trigger_height - INPUT_DOT_RADIUS)); // Deadzone: p.setPen(GetDeadZonePen()); p.setBrush(GetDeadZoneBrush(p)); p.drawRect(QRectF(1.5, 1.5, trigger_analog_width * deadzone, trigger_height - 2)); // Threshold setting: const int threshold_x = trigger_analog_width * threshold; p.setPen(GetInputShapePen()); p.drawLine(threshold_x, 0, threshold_x, trigger_height); const QRectF button_rect(trigger_analog_width + 0.5, 0.5, trigger_button_width, trigger_height); // Trigger button: p.setPen(GetBBoxPen()); p.setBrush(trigger_button ? GetAdjustedInputColor() : GetBBoxBrush()); p.drawRect(button_rect); // Bounding box outline: p.setPen(GetBBoxPen()); p.setBrush(Qt::NoBrush); p.drawRect(trigger_rect); // Button text: p.setPen(GetTextColor()); p.setPen(trigger_button ? GetAltTextColor() : GetTextColor()); p.drawText(button_rect, Qt::AlignCenter, button_name); // Activated analog text: p.setPen(GetAltTextColor()); p.setClipping(true); p.setClipRect(adj_analog_rect); p.drawText(analog_rect, Qt::AlignCenter, analog_name); p.setClipping(false); // Move down for next trigger: p.translate(0.0, trigger_height); } } void SwingIndicator::DrawUnderGate(QPainter& p) { auto& force = m_swing_group; // Deadzone for Z (forward/backward): const double deadzone = force.GetDeadzonePercentage(); if (deadzone > 0.0) { p.setPen(GetDeadZonePen()); p.setBrush(GetDeadZoneBrush(p)); p.drawRect(QRectF(-1, -deadzone, 2, deadzone * 2)); } // Raw Z: const auto raw_coord = force.GetState(false); p.setPen(GetCosmeticPen(QPen(GetRawInputColor(), INPUT_DOT_RADIUS))); p.drawLine(QLineF(-1, raw_coord.z, 1, raw_coord.z)); // Adjusted Z: const auto& adj_coord = m_motion_state.position; const auto curve_point = std::max(std::abs(m_motion_state.angle.x), std::abs(m_motion_state.angle.z)) / MathUtil::TAU; if (adj_coord.y || curve_point) { // Show off the angle somewhat with a curved line. QPainterPath path; path.moveTo(-1.0, (adj_coord.y + curve_point) * -1); path.quadTo({0, (adj_coord.y - curve_point) * -1}, {1, (adj_coord.y + curve_point) * -1}); p.setBrush(Qt::NoBrush); p.setPen(GetCosmeticPen(QPen(GetAdjustedInputColor(), INPUT_DOT_RADIUS))); p.drawPath(path); } } void SwingIndicator::Update(float elapsed_seconds) { WiimoteEmu::EmulateSwing(&m_motion_state, &m_swing_group, elapsed_seconds); } void SwingIndicator::Draw() { DrawReshapableInput(m_swing_group, SWING_GATE_COLOR, Common::DVec2{-m_motion_state.position.x, m_motion_state.position.z}); } void ShakeMappingIndicator::Update(float elapsed_seconds) { WiimoteEmu::EmulateShake(&m_motion_state, &m_shake_group, elapsed_seconds); for (auto& sample : m_position_samples) sample.age += elapsed_seconds; m_position_samples.erase( std::ranges::find_if(m_position_samples, [](const ShakeSample& sample) { return sample.age > 1.f; }), m_position_samples.end()); constexpr float MAX_DISTANCE = 0.5f; m_position_samples.push_front(ShakeSample{m_motion_state.position / MAX_DISTANCE}); const bool any_non_zero_samples = std::any_of(m_position_samples.begin(), m_position_samples.end(), [](const ShakeSample& s) { return s.state.LengthSquared() != 0.0; }); // Only start moving the line if there's non-zero data. if (m_grid_line_position || any_non_zero_samples) { m_grid_line_position += elapsed_seconds; if (m_grid_line_position > 1.f) { if (any_non_zero_samples) m_grid_line_position = std::fmod(m_grid_line_position, 1.f); else m_grid_line_position = 0; } } } void ShakeMappingIndicator::Draw() { QPainter p(this); DrawBoundingBox(p); TransformPainter(p); // UI y-axis is opposite that of acceleration Z. p.scale(1.0, -1.0); // Deadzone. const double deadzone = m_shake_group.GetDeadzone(); if (deadzone > 0.0) { p.setPen(GetDeadZonePen()); p.setBrush(GetDeadZoneBrush(p)); p.drawRect(QRectF(-1, 0, 2, deadzone)); } // Raw input. const auto raw_coord = m_shake_group.GetState(false); p.setPen(GetInputDotPen(GetRawInputColor())); for (std::size_t c = 0; c != raw_coord.data.size(); ++c) { p.drawPoint(QPointF{-0.5 + c * 0.5, raw_coord.data[c]}); } const double grid_line_x = 1.0 - m_grid_line_position * 2.0; p.setPen(QPen(GetRawInputColor(), 0)); p.drawLine(QPointF{grid_line_x, -1.0}, QPointF{grid_line_x, 1.0}); // Position history. const QColor component_colors[] = {Qt::blue, Qt::green, Qt::red}; p.setBrush(Qt::NoBrush); for (std::size_t c = 0; c != raw_coord.data.size(); ++c) { QPolygonF polyline; for (auto& sample : m_position_samples) polyline.append(QPointF{1.0 - sample.age * 2.0, sample.state.data[c]}); p.setPen(QPen(component_colors[c], 0)); p.drawPolyline(polyline); } } void AccelerometerMappingIndicator::Draw() { const auto accel_state = m_accel_group.GetState(); const auto state = accel_state.value_or(Common::Vec3{}); QPainter p(this); p.setRenderHint(QPainter::TextAntialiasing, true); DrawBoundingBox(p); TransformPainter(p); // UI axes are opposite that of Wii remote accelerometer. p.scale(-1.0, -1.0); const auto rotation = WiimoteEmu::GetRotationFromAcceleration(state); // Draw sphere. p.setPen(GetCosmeticPen(QPen(GetRawInputColor(), 0.5))); GenerateFibonacciSphere(SPHERE_POINT_COUNT, [&](const Common::Vec3& point) { const auto pt = rotation * point; if (pt.y > 0) p.drawPoint(QPointF(pt.x, pt.z) * SPHERE_SIZE); }); // Sphere outline. p.setPen(QPen(GetRawInputColor(), 0)); p.setBrush(Qt::NoBrush); p.drawEllipse(QPointF{}, SPHERE_SIZE, SPHERE_SIZE); p.setPen(Qt::NoPen); // Red dot. const auto point = rotation * Common::Vec3{0, 0, SPHERE_INDICATOR_DIST}; if (point.y > 0 || Common::Vec2(point.x, point.z).Length() > SPHERE_SIZE) { p.setPen(GetInputDotPen(GetAdjustedInputColor())); p.drawPoint(QPointF(point.x, point.z)); } // Blue dot. const auto point2 = -point; if (point2.y > 0 || Common::Vec2(point2.x, point2.z).Length() > SPHERE_SIZE) { p.setPen(GetInputDotPen(GetCenterColor())); p.drawPoint(QPointF(point2.x, point2.z)); } p.setBrush(Qt::NoBrush); p.resetTransform(); p.translate(width() / 2.0, height() / 2.0); // Red dot upright target. p.setPen(GetAdjustedInputColor()); p.drawEllipse(QPointF{0, -SPHERE_INDICATOR_DIST} * GetContentsScale(), INPUT_DOT_RADIUS, INPUT_DOT_RADIUS); // Blue dot target. p.setPen(GetCenterColor()); p.drawEllipse(QPointF{0, SPHERE_INDICATOR_DIST} * GetContentsScale(), INPUT_DOT_RADIUS, INPUT_DOT_RADIUS); // Only draw g-force text if acceleration data is present. if (!accel_state.has_value()) return; // G-force text: p.setPen(GetTextColor()); p.drawText(QRect(0, 0, NORMAL_INDICATOR_WIDTH / 2 - 2, NORMAL_INDICATOR_HEIGHT / 2 - 1), Qt::AlignBottom | Qt::AlignRight, QString::fromStdString( // i18n: "g" is the symbol for "gravitational force equivalent" (g-force). fmt::format("{:.2f} g", state.Length() / WiimoteEmu::GRAVITY_ACCELERATION))); } void GyroMappingIndicator::Update(float elapsed_seconds) { const auto gyro_state = m_gyro_group.GetState(); const auto angular_velocity = gyro_state.value_or(Common::Vec3{}); m_state *= WiimoteEmu::GetRotationFromGyroscope(angular_velocity * Common::Vec3(-1, +1, -1) * elapsed_seconds); m_state = m_state.Normalized(); // Reset orientation when stable for a bit: constexpr float STABLE_RESET_SECONDS = 1.f; // Consider device stable when data (with deadzone applied) is zero. const bool is_stable = !angular_velocity.LengthSquared(); if (!is_stable) m_stable_time = 0; else if (m_stable_time < STABLE_RESET_SECONDS) m_stable_time += elapsed_seconds; if (m_stable_time >= STABLE_RESET_SECONDS) m_state = Common::Quaternion::Identity(); } void GyroMappingIndicator::Draw() { const auto gyro_state = m_gyro_group.GetState(); const auto raw_gyro_state = m_gyro_group.GetRawState(); const auto angular_velocity = gyro_state.value_or(Common::Vec3{}); const auto jitter = raw_gyro_state - m_previous_velocity; m_previous_velocity = raw_gyro_state; // Consider device stable when data (with deadzone applied) is zero. const bool is_stable = !angular_velocity.LengthSquared(); // Use an empty rotation matrix if gyroscope data is not present. const auto rotation = (gyro_state.has_value() ? Common::Matrix33::FromQuaternion(m_state) : Common::Matrix33{}); QPainter p(this); DrawBoundingBox(p); TransformPainter(p); // Deadzone. if (const auto deadzone_value = m_gyro_group.GetDeadzone(); deadzone_value) { static constexpr auto DEADZONE_DRAW_SIZE = 1 - SPHERE_SIZE; static constexpr auto DEADZONE_DRAW_BOTTOM = 1; p.setPen(GetDeadZonePen()); p.setBrush(GetDeadZoneBrush(p)); p.drawRect(QRectF{-1, DEADZONE_DRAW_BOTTOM, 2, -DEADZONE_DRAW_SIZE}); if (gyro_state.has_value()) { const auto max_jitter = std::max({std::abs(jitter.x), std::abs(jitter.y), std::abs(jitter.z)}); const auto jitter_line_y = std::min(max_jitter / deadzone_value * DEADZONE_DRAW_SIZE - DEADZONE_DRAW_BOTTOM, 1.0); p.setPen(GetCosmeticPen(QPen(GetRawInputColor(), INPUT_DOT_RADIUS))); p.drawLine(QLineF(-1.0, jitter_line_y * -1.0, 1.0, jitter_line_y * -1.0)); // Sphere background. p.setPen(Qt::NoPen); p.setBrush(GetBBoxBrush()); p.drawEllipse(QPointF{}, SPHERE_SIZE, SPHERE_SIZE); } } // Sphere dots. p.setPen(GetCosmeticPen(QPen(GetRawInputColor(), 0.5))); GenerateFibonacciSphere(SPHERE_POINT_COUNT, [&](const Common::Vec3& point) { const auto pt = rotation * point; if (pt.y > 0) p.drawPoint(QPointF(pt.x, pt.z) * SPHERE_SIZE); }); // Sphere outline. const auto outline_color = is_stable ? (m_gyro_group.IsCalibrating() ? GetCenterColor() : GetRawInputColor()) : GetAdjustedInputColor(); p.setPen(QPen(outline_color, 0)); p.setBrush(Qt::NoBrush); p.drawEllipse(QPointF{}, SPHERE_SIZE, SPHERE_SIZE); p.setPen(Qt::NoPen); // Red dot. const auto point = rotation * Common::Vec3{0, 0, -SPHERE_INDICATOR_DIST}; if (point.y > 0 || Common::Vec2(point.x, point.z).Length() > SPHERE_SIZE) { p.setPen(GetInputDotPen(GetAdjustedInputColor())); p.drawPoint(QPointF(point.x, point.z)); } // Blue dot. const auto point2 = rotation * Common::Vec3{0, SPHERE_INDICATOR_DIST, 0}; if (point2.y > 0 || Common::Vec2(point2.x, point2.z).Length() > SPHERE_SIZE) { p.setPen(GetInputDotPen(GetCenterColor())); p.drawPoint(QPointF(point2.x, point2.z)); } p.setBrush(Qt::NoBrush); p.resetTransform(); p.translate(width() / 2.0, height() / 2.0); // Red dot upright target. p.setPen(GetAdjustedInputColor()); p.drawEllipse(QPointF{0, -SPHERE_INDICATOR_DIST} * GetContentsScale(), INPUT_DOT_RADIUS, INPUT_DOT_RADIUS); // Blue dot target. p.setPen(GetCenterColor()); p.drawEllipse(QPointF{}, INPUT_DOT_RADIUS, INPUT_DOT_RADIUS); } void IRPassthroughMappingIndicator::Draw() { QPainter p(this); DrawBoundingBox(p); TransformPainter(p); p.scale(1.0, -1.0); auto pen = GetInputDotPen(m_ir_group.enabled ? GetAdjustedInputColor() : GetRawInputColor()); for (std::size_t i = 0; i != WiimoteEmu::CameraLogic::NUM_POINTS; ++i) { const auto size = m_ir_group.GetObjectSize(i); const bool is_visible = size > 0; if (!is_visible) continue; const auto point = (QPointF{m_ir_group.GetObjectPositionX(i), m_ir_group.GetObjectPositionY(i)} - QPointF{0.5, 0.5}) * 2.0; pen.setWidth(size * NORMAL_INDICATOR_WIDTH / 2); p.setPen(pen); p.drawPoint(point); } } void ReshapableInputIndicator::DrawCalibration(QPainter& p, Common::DVec2 point) { const auto center = m_calibration_widget->GetCenter(); p.save(); p.translate(center.x, center.y); // Input shape. p.setPen(GetInputShapePen()); p.setBrush(Qt::NoBrush); p.drawPolygon(GetPolygonFromRadiusGetter( [this](double angle) { return m_calibration_widget->GetCalibrationRadiusAtAngle(angle); })); // Center. if (center.x || center.y) { p.setPen(GetInputDotPen(GetCenterColor())); p.drawPoint(QPointF{}); } p.restore(); // Stick position. p.setPen(GetInputDotPen(GetAdjustedInputColor())); p.drawPoint(QPointF{point.x, point.y}); } void ReshapableInputIndicator::UpdateCalibrationWidget(Common::DVec2 point) { if (m_calibration_widget) m_calibration_widget->Update(point); } bool ReshapableInputIndicator::IsCalibrating() const { return m_calibration_widget && m_calibration_widget->IsCalibrating(); } void ReshapableInputIndicator::SetCalibrationWidget(CalibrationWidget* widget) { m_calibration_widget = widget; } CalibrationWidget::CalibrationWidget(ControllerEmu::ReshapableInput& input, ReshapableInputIndicator& indicator) : m_input(input), m_indicator(indicator), m_completion_action{} { m_indicator.SetCalibrationWidget(this); // Make it more apparent that this is a menu with more options. setPopupMode(ToolButtonPopupMode::MenuButtonPopup); SetupActions(); setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Fixed); m_informative_timer = new QTimer(this); connect(m_informative_timer, &QTimer::timeout, this, [this] { // If the user has started moving we'll assume they know what they are doing. if (*std::ranges::max_element(m_calibration_data) > 0.5) return; ModalMessageBox::information( this, tr("Calibration"), tr("For best results please slowly move your input to all possible regions.")); }); m_informative_timer->setSingleShot(true); } void CalibrationWidget::SetupActions() { const auto calibrate_action = new QAction(tr("Calibrate"), this); const auto center_action = new QAction(tr("Center and Calibrate"), this); const auto reset_action = new QAction(tr("Reset"), this); connect(calibrate_action, &QAction::triggered, [this]() { StartCalibration(); m_new_center = Common::DVec2{}; }); connect(center_action, &QAction::triggered, [this]() { StartCalibration(); m_new_center = std::nullopt; }); connect(reset_action, &QAction::triggered, [this]() { m_input.SetCalibrationToDefault(); m_input.SetCenter({0, 0}); }); for (auto* action : actions()) removeAction(action); addAction(calibrate_action); addAction(center_action); addAction(reset_action); setDefaultAction(calibrate_action); m_completion_action = new QAction(tr("Finish Calibration"), this); connect(m_completion_action, &QAction::triggered, [this]() { m_input.SetCenter(GetCenter()); m_input.SetCalibrationData(std::move(m_calibration_data)); m_informative_timer->stop(); SetupActions(); }); } void CalibrationWidget::StartCalibration() { m_prev_point = {}; m_calibration_data.assign(m_input.CALIBRATION_SAMPLE_COUNT, 0.0); // Cancel calibration. const auto cancel_action = new QAction(tr("Cancel Calibration"), this); connect(cancel_action, &QAction::triggered, [this]() { m_calibration_data.clear(); m_informative_timer->stop(); SetupActions(); }); for (auto* action : actions()) removeAction(action); addAction(cancel_action); addAction(m_completion_action); setDefaultAction(cancel_action); // If the user doesn't seem to know what they are doing after a bit inform them. m_informative_timer->start(2000); } void CalibrationWidget::Update(Common::DVec2 point) { QFont f = parentWidget()->font(); QPalette p = parentWidget()->palette(); // Use current point if center is being calibrated. if (!m_new_center.has_value()) m_new_center = point; if (IsCalibrating()) { const auto new_point = point - *m_new_center; m_input.UpdateCalibrationData(m_calibration_data, m_prev_point, new_point); m_prev_point = new_point; if (IsCalibrationDataSensible(m_calibration_data)) { setDefaultAction(m_completion_action); } } else if (IsPointOutsideCalibration(point, m_input)) { // Bold and red on miscalibration. f.setBold(true); p.setColor(QPalette::ButtonText, Qt::red); } setFont(f); setPalette(p); } bool CalibrationWidget::IsCalibrating() const { return !m_calibration_data.empty(); } double CalibrationWidget::GetCalibrationRadiusAtAngle(double angle) const { return m_input.GetCalibrationDataRadiusAtAngle(m_calibration_data, angle); } Common::DVec2 CalibrationWidget::GetCenter() const { return m_new_center.value_or(Common::DVec2{}); }