Merge pull request #7792 from jordan-woyak/auto-calibration

DolphinQt/ControllerEmu: Add stick calibration "wizard".
This commit is contained in:
Tilka
2019-02-13 02:13:27 +00:00
committed by GitHub
13 changed files with 592 additions and 95 deletions

View File

@ -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);
}

View File

@ -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);
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;
};

View File

@ -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<ControllerEmu::ReshapableInput*>(group), *indicator);
form_layout->addRow(calibrate);
}
form_layout->addRow(indicator);
}
return group_box;
}

View File

@ -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()