summaryrefslogtreecommitdiff
path: root/Source
diff options
context:
space:
mode:
authorJordan Woyak <jordan.woyak@gmail.com>2019-02-04 18:50:07 -0600
committerJordan Woyak <jordan.woyak@gmail.com>2019-02-10 07:55:47 -0600
commit0064f70c8a17cd40c15486ac385508ce0ee20cf1 (patch)
treea7e5dce2d6b8a6882362be3a696b5457bfb64e16 /Source
parent46918f420d443c3a32bd27170e3fa0e4f7bc5255 (diff)
DolphinQt/ControllerEmu: Replace Input Radius/Shape settings with an input calibration "wizard".
Diffstat (limited to 'Source')
-rw-r--r--Source/Core/Common/Matrix.h66
-rw-r--r--Source/Core/Core/HW/GCPadEmu.cpp6
-rw-r--r--Source/Core/Core/HW/WiimoteEmu/Extension/Nunchuk.cpp3
-rw-r--r--Source/Core/DolphinQt/Config/Mapping/MappingIndicator.cpp215
-rw-r--r--Source/Core/DolphinQt/Config/Mapping/MappingIndicator.h38
-rw-r--r--Source/Core/DolphinQt/Config/Mapping/MappingWidget.cpp27
-rw-r--r--Source/Core/DolphinQt/Config/Mapping/MappingWindow.cpp2
-rw-r--r--Source/Core/InputCommon/ControllerEmu/ControlGroup/AnalogStick.cpp5
-rw-r--r--Source/Core/InputCommon/ControllerEmu/ControlGroup/Cursor.cpp5
-rw-r--r--Source/Core/InputCommon/ControllerEmu/ControlGroup/Tilt.cpp10
-rw-r--r--Source/Core/InputCommon/ControllerEmu/ControlGroup/Tilt.h6
-rw-r--r--Source/Core/InputCommon/ControllerEmu/StickGate.cpp239
-rw-r--r--Source/Core/InputCommon/ControllerEmu/StickGate.h49
13 files changed, 584 insertions, 87 deletions
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 <array>
+#include <cmath>
// 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 <typename T>
+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<T, 2> data = {};
+
+ struct
+ {
+ T x;
+ T y;
+ };
+};
+
+template <typename T>
+TVec2<T> operator+(TVec2<T> lhs, const TVec2<T>& rhs)
+{
+ return lhs += rhs;
+}
+
+template <typename T>
+TVec2<T> operator-(TVec2<T> lhs, const TVec2<T>& rhs)
+{
+ return lhs -= rhs;
+}
+
+template <typename T>
+TVec2<T> operator*(TVec2<T> lhs, T scalar)
+{
+ return lhs *= scalar;
+}
+
+using Vec2 = TVec2<float>;
+using DVec2 = TVec2<double>;
+
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 <array>
#include <cmath>
+#include <numeric>
+#include <QAction>
+#include <QDateTime>
+#include <QMessageBox>
#include <QPainter>
#include <QTimer>
@@ -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 <QToolButton>
#include <QWidget>
+#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);
+
+ 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();
- QTimer* m_timer;
+ 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 5e7e0b0024..c8d185a2fe 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)
{
@@ -74,10 +75,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)
{
@@ -136,7 +141,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<ControllerEmu::ReshapableInput*>(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 5b1386c5e2..6883d5b81f 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<Input>(Translate, named_direction));
controls.emplace_back(std::make_unique<Input>(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<Input>(Translate, _trans("Hide")));
controls.emplace_back(std::make_unique<Input>(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<NumericSetting>(_trans("Center"), 0.5));
numeric_settings.emplace_back(std::make_unique<NumericSetting>(_trans("Width"), 0.5));
numeric_settings.emplace_back(std::make_unique<NumericSetting>(_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<Input>(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<NumericSetting>(_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<u32> 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<u32> 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<u32> 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)
{
+ numeric_settings.emplace_back(std::make_unique<NumericSetting>(_trans("Dead Zone"), 0, 0, 50));
}
-ControlState ReshapableInput::GetDeadzoneRadiusAtAngle(double ang) const
+ControlState ReshapableInput::GetDeadzoneRadiusAtAngle(double angle) const
{
- return CalculateInputShapeRadiusAtAngle(ang) * numeric_settings[SETTING_DEADZONE]->GetValue();
+ // 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 ang) const
+ControlState ReshapableInput::GetInputRadiusAtAngle(double angle) 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));
+ // Handle the "default" state.
+ if (m_calibration.empty())
+ {
+ return GetDefaultInputRadiusAtAngle(angle);
+ }
+
+ return GetCalibrationDataRadiusAtAngle(m_calibration, angle);
}
-void ReshapableInput::AddReshapingSettings(ControlState default_radius, ControlState default_shape,
- int max_deadzone)
+ControlState ReshapableInput::GetCalibrationDataRadiusAtAngle(const CalibrationData& data,
+ double angle)
{
- // 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<NumericSetting>(_trans("Input Radius"), default_radius, 0, 140));
- numeric_settings.emplace_back(
- std::make_unique<NumericSetting>(_trans("Input Shape"), default_shape, 0, 50));
- numeric_settings.emplace_back(std::make_unique<NumericSetting>(_trans("Dead Zone"), 0, 0, 50));
+ 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<std::string> 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 <optional>
+#include <vector>
+
+#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<u32> 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<u32> 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<u32> 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<ControlState>;
+
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;
-protected:
- void AddReshapingSettings(ControlState default_radius, ControlState default_shape,
- int max_deadzone);
+ 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:
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