diff --git a/Source/Core/Common/Matrix.h b/Source/Core/Common/Matrix.h index f5d38dc7da..ffe241f8d2 100644 --- a/Source/Core/Common/Matrix.h +++ b/Source/Core/Common/Matrix.h @@ -5,6 +5,7 @@ #pragma once #include +#include // Tiny matrix/vector library. // Used for things like Free-Look in the gfx backend. @@ -39,6 +40,71 @@ inline Vec3 operator+(Vec3 lhs, const Vec3& rhs) return lhs += rhs; } +template +union TVec2 +{ + TVec2() = default; + TVec2(T _x, T _y) : data{_x, _y} {} + + T Cross(const TVec2& rhs) const { return (x * rhs.y) - (y * rhs.x); } + T Dot(const TVec2& rhs) const { return (x * rhs.x) + (y * rhs.y); } + T LengthSquared() const { return Dot(*this); } + T Length() const { return std::sqrt(LengthSquared()); } + TVec2 Normalized() const { return *this / Length(); } + + TVec2& operator+=(const TVec2& rhs) + { + x += rhs.x; + y += rhs.y; + return *this; + } + + TVec2& operator-=(const TVec2& rhs) + { + x -= rhs.x; + y -= rhs.y; + return *this; + } + + TVec2& operator*=(T scalar) + { + x *= scalar; + y *= scalar; + return *this; + } + + TVec2 operator-() const { return {-x, -y}; } + + std::array data = {}; + + struct + { + T x; + T y; + }; +}; + +template +TVec2 operator+(TVec2 lhs, const TVec2& rhs) +{ + return lhs += rhs; +} + +template +TVec2 operator-(TVec2 lhs, const TVec2& rhs) +{ + return lhs -= rhs; +} + +template +TVec2 operator*(TVec2 lhs, T scalar) +{ + return lhs *= scalar; +} + +using Vec2 = TVec2; +using DVec2 = TVec2; + class Matrix33 { public: diff --git a/Source/Core/Core/HW/GCPadEmu.cpp b/Source/Core/Core/HW/GCPadEmu.cpp index a797e32fa3..61bf120336 100644 --- a/Source/Core/Core/HW/GCPadEmu.cpp +++ b/Source/Core/Core/HW/GCPadEmu.cpp @@ -16,6 +16,8 @@ #include "InputCommon/ControllerEmu/ControlGroup/ControlGroup.h" #include "InputCommon/ControllerEmu/ControlGroup/MixedTriggers.h" #include "InputCommon/ControllerEmu/Setting/BooleanSetting.h" +#include "InputCommon/ControllerEmu/StickGate.h" + #include "InputCommon/GCPadStatus.h" static const u16 button_bitmasks[] = { @@ -248,6 +250,10 @@ void GCPad::LoadDefaults(const ControllerInterface& ciface) m_main_stick->SetControlExpression(4, "Shift_L"); // Modifier #endif + // Because our defaults use keyboard input, set calibration shapes to squares. + m_c_stick->SetCalibrationFromGate(ControllerEmu::SquareStickGate(1.0)); + m_main_stick->SetCalibrationFromGate(ControllerEmu::SquareStickGate(1.0)); + // Triggers m_triggers->SetControlExpression(0, "Q"); // L m_triggers->SetControlExpression(1, "W"); // R diff --git a/Source/Core/Core/HW/WiimoteEmu/Extension/Nunchuk.cpp b/Source/Core/Core/HW/WiimoteEmu/Extension/Nunchuk.cpp index 184ed25fc7..49e4ed0061 100644 --- a/Source/Core/Core/HW/WiimoteEmu/Extension/Nunchuk.cpp +++ b/Source/Core/Core/HW/WiimoteEmu/Extension/Nunchuk.cpp @@ -203,6 +203,9 @@ void Nunchuk::LoadDefaults(const ControllerInterface& ciface) m_stick->SetControlExpression(2, "A"); // left m_stick->SetControlExpression(3, "D"); // right + // Because our defaults use keyboard input, set calibration shape to a square. + m_stick->SetCalibrationFromGate(ControllerEmu::SquareStickGate(1.0)); + // Buttons #ifdef _WIN32 m_buttons->SetControlExpression(0, "LCONTROL"); // C diff --git a/Source/Core/DolphinQt/Config/Mapping/MappingIndicator.cpp b/Source/Core/DolphinQt/Config/Mapping/MappingIndicator.cpp index e0b44a2895..0e03543dc7 100644 --- a/Source/Core/DolphinQt/Config/Mapping/MappingIndicator.cpp +++ b/Source/Core/DolphinQt/Config/Mapping/MappingIndicator.cpp @@ -6,7 +6,11 @@ #include #include +#include +#include +#include +#include #include #include @@ -48,9 +52,9 @@ MappingIndicator::MappingIndicator(ControllerEmu::ControlGroup* group) : m_group { setMinimumHeight(128); - m_timer = new QTimer(this); - connect(m_timer, &QTimer::timeout, this, [this] { repaint(); }); - m_timer->start(1000 / 30); + const auto timer = new QTimer(this); + connect(timer, &QTimer::timeout, this, [this] { repaint(); }); + timer->start(1000 / 30); } namespace @@ -75,6 +79,49 @@ QPolygonF GetPolygonFromRadiusGetter(F&& radius_getter, double scale) 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; + + const double sum = std::accumulate(data.begin(), data.end(), 0.0); + const double mean = sum / data.size(); + + if (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; + + // Population standard deviation. + const double square_sum = std::inner_product(data.begin(), data.end(), data.begin(), 0.0); + const double standard_deviation = std::sqrt(square_sum / data.size() - mean * mean); + + return standard_deviation < REASONABLE_DEVIATION; +} + +// Used to test for a miscalibrated stick so the user can be informed. +bool IsPointOutsideCalibration(Common::DVec2 point, ControllerEmu::ReshapableInput& input) +{ + const double current_radius = point.Length(); + const double input_radius = + input.GetInputRadiusAtAngle(std::atan2(point.y, point.x) + MathUtil::TAU); + + constexpr double ALLOWED_ERROR = 1.3; + + return current_radius > input_radius * ALLOWED_ERROR; +} + } // namespace void MappingIndicator::DrawCursor(ControllerEmu::Cursor& cursor) @@ -89,6 +136,8 @@ void MappingIndicator::DrawCursor(ControllerEmu::Cursor& cursor) const auto adj_coord = cursor.GetState(true); Settings::Instance().SetControllerStateNeeded(false); + UpdateCalibrationWidget({raw_coord.x, raw_coord.y}); + // Bounding box size: const double scale = height() / 2.5; @@ -107,6 +156,12 @@ void MappingIndicator::DrawCursor(ControllerEmu::Cursor& cursor) p.setRenderHint(QPainter::Antialiasing, true); p.setRenderHint(QPainter::SmoothPixmapTransform, true); + if (IsCalibrating()) + { + DrawCalibration(p, {raw_coord.x, raw_coord.y}); + return; + } + // Deadzone for Z (forward/backward): const double deadzone = cursor.numeric_settings[cursor.SETTING_DEADZONE]->GetValue(); if (deadzone > 0.0) @@ -198,6 +253,8 @@ void MappingIndicator::DrawReshapableInput(ControllerEmu::ReshapableInput& stick const auto adj_coord = stick.GetReshapableState(true); Settings::Instance().SetControllerStateNeeded(false); + UpdateCalibrationWidget(raw_coord); + // Bounding box size: const double scale = height() / 2.5; @@ -216,6 +273,12 @@ void MappingIndicator::DrawReshapableInput(ControllerEmu::ReshapableInput& stick p.setRenderHint(QPainter::Antialiasing, true); p.setRenderHint(QPainter::SmoothPixmapTransform, true); + if (IsCalibrating()) + { + DrawCalibration(p, raw_coord); + return; + } + // Input gate. (i.e. the octagon shape) p.setPen(gate_pen_color); p.setBrush(gate_brush_color); @@ -363,3 +426,149 @@ void MappingIndicator::paintEvent(QPaintEvent*) break; } } + +void MappingIndicator::DrawCalibration(QPainter& p, Common::DVec2 point) +{ + // TODO: Ugly magic number used in a few places in this file. + const double scale = height() / 2.5; + + // Input shape. + p.setPen(INPUT_SHAPE_PEN); + p.setBrush(Qt::NoBrush); + p.drawPolygon(GetPolygonFromRadiusGetter( + [this](double angle) { return m_calibration_widget->GetCalibrationRadiusAtAngle(angle); }, + scale)); + + // Stick position. + p.setPen(Qt::NoPen); + p.setBrush(ADJ_INPUT_COLOR); + p.drawEllipse(QPointF{point.x, point.y} * scale, INPUT_DOT_RADIUS, INPUT_DOT_RADIUS); +} + +void MappingIndicator::UpdateCalibrationWidget(Common::DVec2 point) +{ + if (m_calibration_widget) + m_calibration_widget->Update(point); +} + +bool MappingIndicator::IsCalibrating() const +{ + return m_calibration_widget && m_calibration_widget->IsCalibrating(); +} + +void MappingIndicator::SetCalibrationWidget(CalibrationWidget* widget) +{ + m_calibration_widget = widget; +} + +CalibrationWidget::CalibrationWidget(ControllerEmu::ReshapableInput& input, + MappingIndicator& 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::max_element(m_calibration_data.begin(), m_calibration_data.end()) > 0.5) + return; + + QMessageBox msg(QMessageBox::Information, tr("Calibration"), + tr("For best results please slowly move your input to all possible regions."), + QMessageBox::Ok, this); + msg.setWindowModality(Qt::WindowModal); + msg.exec(); + }); + m_informative_timer->setSingleShot(true); +} + +void CalibrationWidget::SetupActions() +{ + const auto calibrate_action = new QAction(tr("Calibrate"), this); + const auto reset_action = new QAction(tr("Reset"), this); + + connect(calibrate_action, &QAction::triggered, [this]() { StartCalibration(); }); + connect(reset_action, &QAction::triggered, [this]() { m_input.SetCalibrationToDefault(); }); + + for (auto* action : actions()) + removeAction(action); + + addAction(calibrate_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.SetCalibrationData(std::move(m_calibration_data)); + m_informative_timer->stop(); + SetupActions(); + }); +} + +void CalibrationWidget::StartCalibration() +{ + 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(); + + if (IsCalibrating()) + { + m_input.UpdateCalibrationData(m_calibration_data, point); + + if (IsCalibrationDataSensible(m_calibration_data)) + { + setDefaultAction(m_completion_action); + } + } + else if (IsPointOutsideCalibration(point, m_input)) + { + // Flashing bold and red on miscalibration. + if (QDateTime::currentDateTime().toMSecsSinceEpoch() % 500 < 350) + { + 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); +} diff --git a/Source/Core/DolphinQt/Config/Mapping/MappingIndicator.h b/Source/Core/DolphinQt/Config/Mapping/MappingIndicator.h index 3a863ec788..67c2e53afd 100644 --- a/Source/Core/DolphinQt/Config/Mapping/MappingIndicator.h +++ b/Source/Core/DolphinQt/Config/Mapping/MappingIndicator.h @@ -4,33 +4,65 @@ #pragma once +#include #include +#include "InputCommon/ControllerEmu/StickGate.h" + namespace ControllerEmu { class Control; class ControlGroup; class Cursor; class NumericSetting; -class ReshapableInput; } // namespace ControllerEmu +class QPainter; class QPaintEvent; class QTimer; +class CalibrationWidget; + class MappingIndicator : public QWidget { public: explicit MappingIndicator(ControllerEmu::ControlGroup* group); + void SetCalibrationWidget(CalibrationWidget* widget); + private: void DrawCursor(ControllerEmu::Cursor& cursor); void DrawReshapableInput(ControllerEmu::ReshapableInput& stick); void DrawMixedTriggers(); + void DrawCalibration(QPainter& p, Common::DVec2 point); void paintEvent(QPaintEvent*) override; - ControllerEmu::ControlGroup* m_group; + bool IsCalibrating() const; + void UpdateCalibrationWidget(Common::DVec2 point); - QTimer* m_timer; + ControllerEmu::ControlGroup* const m_group; + CalibrationWidget* m_calibration_widget{}; +}; + +class CalibrationWidget : public QToolButton +{ +public: + CalibrationWidget(ControllerEmu::ReshapableInput& input, MappingIndicator& indicator); + + void Update(Common::DVec2 point); + + double GetCalibrationRadiusAtAngle(double angle) const; + + bool IsCalibrating() const; + +private: + void StartCalibration(); + void SetupActions(); + + ControllerEmu::ReshapableInput& m_input; + MappingIndicator& m_indicator; + QAction* m_completion_action; + ControllerEmu::ReshapableInput::CalibrationData m_calibration_data; + QTimer* m_informative_timer; }; diff --git a/Source/Core/DolphinQt/Config/Mapping/MappingWidget.cpp b/Source/Core/DolphinQt/Config/Mapping/MappingWidget.cpp index c84f9ce422..3c6036f159 100644 --- a/Source/Core/DolphinQt/Config/Mapping/MappingWidget.cpp +++ b/Source/Core/DolphinQt/Config/Mapping/MappingWidget.cpp @@ -21,6 +21,7 @@ #include "InputCommon/ControllerEmu/ControlGroup/ControlGroup.h" #include "InputCommon/ControllerEmu/Setting/BooleanSetting.h" #include "InputCommon/ControllerEmu/Setting/NumericSetting.h" +#include "InputCommon/ControllerEmu/StickGate.h" MappingWidget::MappingWidget(MappingWindow* window) : m_parent(window) { @@ -73,10 +74,14 @@ QGroupBox* MappingWidget::CreateGroupBox(const QString& name, ControllerEmu::Con group_box->setLayout(form_layout); - bool need_indicator = group->type == ControllerEmu::GroupType::Cursor || - group->type == ControllerEmu::GroupType::Stick || - group->type == ControllerEmu::GroupType::Tilt || - group->type == ControllerEmu::GroupType::MixedTriggers; + const bool need_indicator = group->type == ControllerEmu::GroupType::Cursor || + group->type == ControllerEmu::GroupType::Stick || + group->type == ControllerEmu::GroupType::Tilt || + group->type == ControllerEmu::GroupType::MixedTriggers; + + const bool need_calibration = group->type == ControllerEmu::GroupType::Cursor || + group->type == ControllerEmu::GroupType::Stick || + group->type == ControllerEmu::GroupType::Tilt; for (auto& control : group->controls) { @@ -135,7 +140,19 @@ QGroupBox* MappingWidget::CreateGroupBox(const QString& name, ControllerEmu::Con } if (need_indicator) - form_layout->addRow(new MappingIndicator(group)); + { + auto const indicator = new MappingIndicator(group); + + if (need_calibration) + { + const auto calibrate = + new CalibrationWidget(*static_cast(group), *indicator); + + form_layout->addRow(calibrate); + } + + form_layout->addRow(indicator); + } return group_box; } diff --git a/Source/Core/DolphinQt/Config/Mapping/MappingWindow.cpp b/Source/Core/DolphinQt/Config/Mapping/MappingWindow.cpp index 7434213fc2..c1a5a034ee 100644 --- a/Source/Core/DolphinQt/Config/Mapping/MappingWindow.cpp +++ b/Source/Core/DolphinQt/Config/Mapping/MappingWindow.cpp @@ -148,6 +148,8 @@ void MappingWindow::ConnectWidgets() connect(m_profiles_save, &QPushButton::clicked, this, &MappingWindow::OnSaveProfilePressed); connect(m_profiles_load, &QPushButton::clicked, this, &MappingWindow::OnLoadProfilePressed); connect(m_profiles_delete, &QPushButton::clicked, this, &MappingWindow::OnDeleteProfilePressed); + // We currently use the "Close" button as an "Accept" button so we must save on reject. + connect(this, &QDialog::rejected, [this] { emit Save(); }); } void MappingWindow::OnDeleteProfilePressed() diff --git a/Source/Core/InputCommon/ControllerEmu/ControlGroup/AnalogStick.cpp b/Source/Core/InputCommon/ControllerEmu/ControlGroup/AnalogStick.cpp index 0cd5da6ba0..45d178ed17 100644 --- a/Source/Core/InputCommon/ControllerEmu/ControlGroup/AnalogStick.cpp +++ b/Source/Core/InputCommon/ControllerEmu/ControlGroup/AnalogStick.cpp @@ -30,11 +30,6 @@ AnalogStick::AnalogStick(const char* const name_, const char* const ui_name_, controls.emplace_back(std::make_unique(Translate, named_direction)); controls.emplace_back(std::make_unique(Translate, _trans("Modifier"))); - - // Default input radius to that of the gate radius (no resizing) - // Default input shape to an octagon (no reshaping) - // Max deadzone to 50% - AddReshapingSettings(GetGateRadiusAtAngle(0.0), 0.0, 50); } AnalogStick::ReshapeData AnalogStick::GetReshapableState(bool adjusted) diff --git a/Source/Core/InputCommon/ControllerEmu/ControlGroup/Cursor.cpp b/Source/Core/InputCommon/ControllerEmu/ControlGroup/Cursor.cpp index 0af6efc734..fda476aac6 100644 --- a/Source/Core/InputCommon/ControllerEmu/ControlGroup/Cursor.cpp +++ b/Source/Core/InputCommon/ControllerEmu/ControlGroup/Cursor.cpp @@ -32,9 +32,6 @@ Cursor::Cursor(const std::string& name_) controls.emplace_back(std::make_unique(Translate, _trans("Hide"))); controls.emplace_back(std::make_unique(Translate, _trans("Recenter"))); - // Default shape is a 1.0 square (no resizing/reshaping): - AddReshapingSettings(1.0, 0.5, 50); - numeric_settings.emplace_back(std::make_unique(_trans("Center"), 0.5)); numeric_settings.emplace_back(std::make_unique(_trans("Width"), 0.5)); numeric_settings.emplace_back(std::make_unique(_trans("Height"), 0.5)); @@ -57,8 +54,6 @@ Cursor::ReshapeData Cursor::GetReshapableState(bool adjusted) ControlState Cursor::GetGateRadiusAtAngle(double ang) const { - // TODO: Change this to 0.5 and adjust the math, - // so pointer doesn't have to be clamped to the configured width/height? return SquareStickGate(1.0).GetRadiusAtAngle(ang); } diff --git a/Source/Core/InputCommon/ControllerEmu/ControlGroup/Tilt.cpp b/Source/Core/InputCommon/ControllerEmu/ControlGroup/Tilt.cpp index 2fec9c7bd0..bacf158be0 100644 --- a/Source/Core/InputCommon/ControllerEmu/ControlGroup/Tilt.cpp +++ b/Source/Core/InputCommon/ControllerEmu/ControlGroup/Tilt.cpp @@ -29,11 +29,6 @@ Tilt::Tilt(const std::string& name_) controls.emplace_back(std::make_unique(Translate, _trans("Modifier"))); - // Set default input radius to the full 1.0 (no resizing) - // Set default input shape to a square (no reshaping) - // Max deadzone to 50% - AddReshapingSettings(1.0, 0.5, 50); - numeric_settings.emplace_back(std::make_unique(_trans("Angle"), 0.9, 0, 180)); } @@ -88,4 +83,9 @@ ControlState Tilt::GetGateRadiusAtAngle(double ang) const return SquareStickGate(max_tilt_angle).GetRadiusAtAngle(ang); } +ControlState Tilt::GetDefaultInputRadiusAtAngle(double ang) const +{ + return SquareStickGate(1.0).GetRadiusAtAngle(ang); +} + } // namespace ControllerEmu diff --git a/Source/Core/InputCommon/ControllerEmu/ControlGroup/Tilt.h b/Source/Core/InputCommon/ControllerEmu/ControlGroup/Tilt.h index 55afd43ba3..c18f6dc46d 100644 --- a/Source/Core/InputCommon/ControllerEmu/ControlGroup/Tilt.h +++ b/Source/Core/InputCommon/ControllerEmu/ControlGroup/Tilt.h @@ -20,7 +20,11 @@ public: explicit Tilt(const std::string& name); ReshapeData GetReshapableState(bool adjusted) final override; - ControlState GetGateRadiusAtAngle(double ang) const override; + ControlState GetGateRadiusAtAngle(double angle) const final override; + + // Tilt is using the gate radius to adjust the tilt angle so we must provide an unadjusted value + // for the default input radius. + ControlState GetDefaultInputRadiusAtAngle(double angle) const final override; StateData GetState(); diff --git a/Source/Core/InputCommon/ControllerEmu/StickGate.cpp b/Source/Core/InputCommon/ControllerEmu/StickGate.cpp index 64cf238e17..8b4d64fb62 100644 --- a/Source/Core/InputCommon/ControllerEmu/StickGate.cpp +++ b/Source/Core/InputCommon/ControllerEmu/StickGate.cpp @@ -8,23 +8,67 @@ #include "Common/Common.h" #include "Common/MathUtil.h" +#include "Common/Matrix.h" +#include "Common/StringUtil.h" +#include "InputCommon/ControllerEmu/Control/Control.h" #include "InputCommon/ControllerEmu/Setting/NumericSetting.h" +namespace +{ +constexpr auto CALIBRATION_CONFIG_NAME = "Calibration"; +constexpr auto CALIBRATION_DEFAULT_VALUE = 1.0; +constexpr auto CALIBRATION_CONFIG_SCALE = 100; + +// Calculate distance to intersection of a ray with a line defined by two points. +double GetRayLineIntersection(Common::DVec2 ray, Common::DVec2 point1, Common::DVec2 point2) +{ + const auto diff = point2 - point1; + + const auto dot = diff.Dot({-ray.y, ray.x}); + if (std::abs(dot) < 0.00001) + { + // Handle situation where both points are on top of eachother. + // This could occur if the user configures a single calibration value + // or when updating calibration. + return point1.Length(); + } + + return diff.Cross(-point1) / dot; +} + +Common::DVec2 GetPointFromAngleAndLength(double angle, double length) +{ + return Common::DVec2{std::cos(angle), std::sin(angle)} * length; +} +} // namespace + namespace ControllerEmu { +constexpr int ReshapableInput::CALIBRATION_SAMPLE_COUNT; + +std::optional StickGate::GetIdealCalibrationSampleCount() const +{ + return {}; +} + OctagonStickGate::OctagonStickGate(ControlState radius) : m_radius(radius) { } -ControlState OctagonStickGate::GetRadiusAtAngle(double ang) const +ControlState OctagonStickGate::GetRadiusAtAngle(double angle) const { constexpr int sides = 8; constexpr double sum_int_angles = (sides - 2) * MathUtil::PI; constexpr double half_int_angle = sum_int_angles / sides / 2; - ang = std::fmod(ang, MathUtil::TAU / sides); + angle = std::fmod(angle, MathUtil::TAU / sides); // Solve ASA triangle using The Law of Sines: - return m_radius / std::sin(MathUtil::PI - ang - half_int_angle) * std::sin(half_int_angle); + return m_radius / std::sin(MathUtil::PI - angle - half_int_angle) * std::sin(half_int_angle); +} + +std::optional OctagonStickGate::GetIdealCalibrationSampleCount() const +{ + return 8; } RoundStickGate::RoundStickGate(ControlState radius) : m_radius(radius) @@ -40,50 +84,171 @@ SquareStickGate::SquareStickGate(ControlState half_width) : m_half_width(half_wi { } -ControlState SquareStickGate::GetRadiusAtAngle(double ang) const +ControlState SquareStickGate::GetRadiusAtAngle(double angle) const { - constexpr double section_ang = MathUtil::TAU / 4; - return m_half_width / std::cos(std::fmod(ang + section_ang / 2, section_ang) - section_ang / 2); + constexpr double section_angle = MathUtil::TAU / 4; + return m_half_width / + std::cos(std::fmod(angle + section_angle / 2, section_angle) - section_angle / 2); +} + +std::optional SquareStickGate::GetIdealCalibrationSampleCount() const +{ + // Because angle:0 points to the right we must use 8 samples for our square. + return 8; } ReshapableInput::ReshapableInput(std::string name, std::string ui_name, GroupType type) : ControlGroup(std::move(name), std::move(ui_name), type) { -} - -ControlState ReshapableInput::GetDeadzoneRadiusAtAngle(double ang) const -{ - return CalculateInputShapeRadiusAtAngle(ang) * numeric_settings[SETTING_DEADZONE]->GetValue(); -} - -ControlState ReshapableInput::GetInputRadiusAtAngle(double ang) const -{ - const ControlState radius = - CalculateInputShapeRadiusAtAngle(ang) * numeric_settings[SETTING_INPUT_RADIUS]->GetValue(); - // Clamp within the -1 to +1 square as input radius may be greater than 1.0: - return std::min(radius, SquareStickGate(1).GetRadiusAtAngle(ang)); -} - -void ReshapableInput::AddReshapingSettings(ControlState default_radius, ControlState default_shape, - int max_deadzone) -{ - // Allow radius greater than 1.0 for definitions of rounded squares - // This is ideal for Xbox controllers (and probably others) - numeric_settings.emplace_back( - std::make_unique(_trans("Input Radius"), default_radius, 0, 140)); - numeric_settings.emplace_back( - std::make_unique(_trans("Input Shape"), default_shape, 0, 50)); numeric_settings.emplace_back(std::make_unique(_trans("Dead Zone"), 0, 0, 50)); } +ControlState ReshapableInput::GetDeadzoneRadiusAtAngle(double angle) const +{ + // FYI: deadzone is scaled by input radius which allows the shape to match. + return GetInputRadiusAtAngle(angle) * numeric_settings[SETTING_DEADZONE]->GetValue(); +} + +ControlState ReshapableInput::GetInputRadiusAtAngle(double angle) const +{ + // Handle the "default" state. + if (m_calibration.empty()) + { + return GetDefaultInputRadiusAtAngle(angle); + } + + return GetCalibrationDataRadiusAtAngle(m_calibration, angle); +} + +ControlState ReshapableInput::GetCalibrationDataRadiusAtAngle(const CalibrationData& data, + double angle) +{ + const auto sample_pos = angle / MathUtil::TAU * data.size(); + // Interpolate the radius between 2 calibration samples. + const u32 sample1_index = u32(sample_pos) % data.size(); + const u32 sample2_index = (sample1_index + 1) % data.size(); + const double sample1_angle = sample1_index * MathUtil::TAU / data.size(); + const double sample2_angle = sample2_index * MathUtil::TAU / data.size(); + + return GetRayLineIntersection(GetPointFromAngleAndLength(angle, 1.0), + GetPointFromAngleAndLength(sample1_angle, data[sample1_index]), + GetPointFromAngleAndLength(sample2_angle, data[sample2_index])); +} + +ControlState ReshapableInput::GetDefaultInputRadiusAtAngle(double angle) const +{ + // This will normally be the same as the gate radius. + // Unless a sub-class is doing weird things with the gate radius (e.g. Tilt) + return GetGateRadiusAtAngle(angle); +} + +void ReshapableInput::SetCalibrationToDefault() +{ + m_calibration.clear(); +} + +void ReshapableInput::SetCalibrationFromGate(const StickGate& gate) +{ + m_calibration.resize(gate.GetIdealCalibrationSampleCount().value_or(CALIBRATION_SAMPLE_COUNT)); + + u32 i = 0; + for (auto& val : m_calibration) + val = gate.GetRadiusAtAngle(MathUtil::TAU * i++ / m_calibration.size()); +} + +void ReshapableInput::UpdateCalibrationData(CalibrationData& data, Common::DVec2 point) +{ + const auto angle_scale = MathUtil::TAU / data.size(); + + const u32 calibration_index = + std::lround((std::atan2(point.y, point.x) + MathUtil::TAU) / angle_scale) % data.size(); + const double calibration_angle = calibration_index * angle_scale; + auto& calibration_sample = data[calibration_index]; + + // Update closest sample from provided x,y. + calibration_sample = std::max(calibration_sample, point.Length()); + + // Here we update all other samples in our calibration vector to maintain + // a convex polygon containing our new calibration point. + // This is required to properly fill in angles that cannot be gotten. + // (e.g. Keyboard input only has 8 possible angles) + + // Note: Loop assumes an even sample count, which should not be a problem. + for (auto sample_offset = u32(data.size() / 2 - 1); sample_offset > 1; --sample_offset) + { + const auto update_at_offset = [&](u32 offset1, u32 offset2) { + const u32 sample1_index = (calibration_index + offset1) % data.size(); + const double sample1_angle = sample1_index * angle_scale; + auto& sample1 = data[sample1_index]; + + const u32 sample2_index = (calibration_index + offset2) % data.size(); + const double sample2_angle = sample2_index * angle_scale; + auto& sample2 = data[sample2_index]; + + const double intersection = + GetRayLineIntersection(GetPointFromAngleAndLength(sample2_angle, 1.0), + GetPointFromAngleAndLength(sample1_angle, sample1), + GetPointFromAngleAndLength(calibration_angle, calibration_sample)); + + sample2 = std::max(sample2, intersection); + }; + + update_at_offset(sample_offset, sample_offset - 1); + update_at_offset(u32(data.size() - sample_offset), u32(data.size() - sample_offset + 1)); + } +} + +const ReshapableInput::CalibrationData& ReshapableInput::GetCalibrationData() const +{ + return m_calibration; +} + +void ReshapableInput::SetCalibrationData(CalibrationData data) +{ + m_calibration = std::move(data); +} + +void ReshapableInput::LoadConfig(IniFile::Section* section, const std::string& default_device, + const std::string& base_name) +{ + ControlGroup::LoadConfig(section, default_device, base_name); + + const std::string group(base_name + name + '/'); + std::string load_str; + section->Get(group + CALIBRATION_CONFIG_NAME, &load_str, ""); + const auto load_data = SplitString(load_str, ' '); + + m_calibration.assign(load_data.size(), CALIBRATION_DEFAULT_VALUE); + + auto it = load_data.begin(); + for (auto& sample : m_calibration) + { + if (TryParse(*(it++), &sample)) + sample /= CALIBRATION_CONFIG_SCALE; + } +} + +void ReshapableInput::SaveConfig(IniFile::Section* section, const std::string& default_device, + const std::string& base_name) +{ + ControlGroup::SaveConfig(section, default_device, base_name); + + const std::string group(base_name + name + '/'); + std::vector save_data(m_calibration.size()); + std::transform( + m_calibration.begin(), m_calibration.end(), save_data.begin(), + [](ControlState val) { return StringFromFormat("%.2f", val * CALIBRATION_CONFIG_SCALE); }); + section->Set(group + CALIBRATION_CONFIG_NAME, JoinStrings(save_data, " "), ""); +} + ReshapableInput::ReshapeData ReshapableInput::Reshape(ControlState x, ControlState y, ControlState modifier) { // TODO: make the AtAngle functions work with negative angles: - const ControlState ang = std::atan2(y, x) + MathUtil::TAU; + const ControlState angle = std::atan2(y, x) + MathUtil::TAU; - const ControlState gate_max_dist = GetGateRadiusAtAngle(ang); - const ControlState input_max_dist = GetInputRadiusAtAngle(ang); + const ControlState gate_max_dist = GetGateRadiusAtAngle(angle); + const ControlState input_max_dist = GetInputRadiusAtAngle(angle); // If input radius is zero we apply no scaling. // This is useful when mapping native controllers without knowing intimate radius details. @@ -103,33 +268,15 @@ ReshapableInput::ReshapeData ReshapableInput::Reshape(ControlState x, ControlSta } // Apply deadzone as a percentage of the user-defined radius/shape: - const ControlState deadzone = GetDeadzoneRadiusAtAngle(ang); + const ControlState deadzone = GetDeadzoneRadiusAtAngle(angle); dist = std::max(0.0, dist - deadzone) / (1.0 - deadzone); // Scale to the gate shape/radius: dist = dist *= gate_max_dist; - x = MathUtil::Clamp(std::cos(ang) * dist, -1.0, 1.0); - y = MathUtil::Clamp(std::sin(ang) * dist, -1.0, 1.0); + x = MathUtil::Clamp(std::cos(angle) * dist, -1.0, 1.0); + y = MathUtil::Clamp(std::sin(angle) * dist, -1.0, 1.0); return {x, y}; } -ControlState ReshapableInput::CalculateInputShapeRadiusAtAngle(double ang) const -{ - const auto shape = numeric_settings[SETTING_INPUT_SHAPE]->GetValue() * 4.0; - - if (shape < 1.0) - { - // Between 0 and 25 return a shape between octagon and circle - const auto amt = shape; - return OctagonStickGate(1).GetRadiusAtAngle(ang) * (1 - amt) + amt; - } - else - { - // Between 25 and 50 return a shape between circle and square - const auto amt = shape - 1.0; - return (1 - amt) + SquareStickGate(1).GetRadiusAtAngle(ang) * amt; - } -} - } // namespace ControllerEmu diff --git a/Source/Core/InputCommon/ControllerEmu/StickGate.h b/Source/Core/InputCommon/ControllerEmu/StickGate.h index 93b5c5792c..38ad57a8b3 100644 --- a/Source/Core/InputCommon/ControllerEmu/StickGate.h +++ b/Source/Core/InputCommon/ControllerEmu/StickGate.h @@ -4,6 +4,11 @@ #pragma once +#include +#include + +#include "Common/Matrix.h" + #include "InputCommon/ControlReference/ControlReference.h" #include "InputCommon/ControllerEmu/ControlGroup/ControlGroup.h" @@ -16,6 +21,10 @@ public: // Angle is in radians and should be non-negative virtual ControlState GetRadiusAtAngle(double ang) const = 0; + // This is provided purely as an optimization for ReshapableInput to produce a minimal amount of + // calibration points that are saved in our config. + virtual std::optional GetIdealCalibrationSampleCount() const; + virtual ~StickGate() = default; }; @@ -26,6 +35,7 @@ public: // Radius of circumscribed circle explicit OctagonStickGate(ControlState radius); ControlState GetRadiusAtAngle(double ang) const override final; + std::optional GetIdealCalibrationSampleCount() const override final; private: const ControlState m_radius; @@ -48,6 +58,7 @@ class SquareStickGate : public StickGate public: explicit SquareStickGate(ControlState half_width); ControlState GetRadiusAtAngle(double ang) const override final; + std::optional GetIdealCalibrationSampleCount() const override final; private: const ControlState m_half_width; @@ -56,37 +67,47 @@ private: class ReshapableInput : public ControlGroup { public: + // This is the number of samples we generate but any number could be loaded from config. + static constexpr int CALIBRATION_SAMPLE_COUNT = 32; + + // Contains input radius maximums at evenly-spaced angles. + using CalibrationData = std::vector; + ReshapableInput(std::string name, std::string ui_name, GroupType type); - struct ReshapeData - { - ControlState x{}; - ControlState y{}; - }; + using ReshapeData = Common::DVec2; enum { - SETTING_INPUT_RADIUS, - SETTING_INPUT_SHAPE, SETTING_DEADZONE, SETTING_COUNT, }; // Angle is in radians and should be non-negative - ControlState GetDeadzoneRadiusAtAngle(double ang) const; - ControlState GetInputRadiusAtAngle(double ang) const; + ControlState GetDeadzoneRadiusAtAngle(double angle) const; + ControlState GetInputRadiusAtAngle(double angle) const; - virtual ControlState GetGateRadiusAtAngle(double ang) const = 0; + virtual ControlState GetGateRadiusAtAngle(double angle) const = 0; virtual ReshapeData GetReshapableState(bool adjusted) = 0; + virtual ControlState GetDefaultInputRadiusAtAngle(double ang) const; + + void SetCalibrationToDefault(); + void SetCalibrationFromGate(const StickGate& gate); + + static void UpdateCalibrationData(CalibrationData& data, Common::DVec2 point); + static ControlState GetCalibrationDataRadiusAtAngle(const CalibrationData& data, double angle); + + const CalibrationData& GetCalibrationData() const; + void SetCalibrationData(CalibrationData data); protected: - void AddReshapingSettings(ControlState default_radius, ControlState default_shape, - int max_deadzone); - ReshapeData Reshape(ControlState x, ControlState y, ControlState modifier = 0.0); private: - ControlState CalculateInputShapeRadiusAtAngle(double ang) const; + void LoadConfig(IniFile::Section*, const std::string&, const std::string&) override; + void SaveConfig(IniFile::Section*, const std::string&, const std::string&) override; + + CalibrationData m_calibration; }; } // namespace ControllerEmu