[Work in Progress] advanced control stick mapping

This commit is contained in:
Matt Pharoah
2025-09-07 17:11:05 -04:00
parent 21fb0b562c
commit 00fe2644d7
6 changed files with 586 additions and 1 deletions

View File

@@ -1,6 +1,81 @@
#include "src/core/controller.hpp"
#include "src/core/numeric-string.hpp"
#include <cassert>
#include <cstdint>
#include <cstring>
#include <cmath>
template<> void JsonSerializer::serialize<QPointF>(
JsonWriter &jw,
const QPointF &obj
) {
jw.writeObjectStart();
jw.writeProperty( "x", obj.x() );
jw.writeProperty( "y", obj.y() );
jw.writeObjectEnd();
}
template<> QPointF JsonSerializer::parse<QPointF>(
const Json &json
) {
return QPointF(
json["x"].get<double>(),
json["y"].get<double>()
);
}
static constexpr char P_NAME[] = "name";
static constexpr char P_PHYSICAL_MIN[] = "physicalMin";
static constexpr char P_PHYSICAL_MAX[] = "physicalMax";
static constexpr char P_PHYSICAL_NOTCHES[] = "physicalNotches";
static constexpr char P_EMULATED_NOTCH[] = "emulatedNotch";
static constexpr char P_DEADZONE_SIZE[] = "deadzoneSize";
static constexpr char P_RESCALE_AFTER_DEADZONE[] = "rescaleAfterDeadzone";
template<> void JsonSerializer::serialize<ControllerGateMapping>(
JsonWriter &jw,
const ControllerGateMapping &obj
) {
jw.writeObjectStart();
jw.writeProperty( P_NAME, obj.name );
jw.writePropertyName( P_PHYSICAL_MIN );
serialize( jw, obj.physicalMin );
jw.writePropertyName( P_PHYSICAL_MAX );
serialize( jw, obj.physicalMax );
jw.writePropertyName( P_PHYSICAL_NOTCHES );
if( obj.circularPhysicalGate ) {
jw.writeNull();
} else {
jw.writeArrayStart();
for( int i = 0; i < 4; i++ ) serialize( jw, obj.physicalNotches[i] );
jw.writeArrayEnd();
}
jw.writeProperty( P_EMULATED_NOTCH, obj.emulatedNotch );
jw.writeProperty( P_DEADZONE_SIZE, obj.deadzoneSize );
jw.writeProperty( P_RESCALE_AFTER_DEADZONE, obj.rescaleAfterDeadzone );
jw.writeObjectEnd();
}
template<> ControllerGateMapping JsonSerializer::parse<ControllerGateMapping>(
const Json &json
) {
ControllerGateMapping mapping;
mapping.name = json[P_NAME].get<string>();
mapping.builtin = false;
mapping.circularPhysicalGate = json[P_PHYSICAL_NOTCHES].isNull();
mapping.rescaleAfterDeadzone = json[P_RESCALE_AFTER_DEADZONE].get<bool>();
mapping.physicalMin = parse<QPointF>( json[P_PHYSICAL_MIN] );
mapping.physicalMax = parse<QPointF>( json[P_PHYSICAL_MAX] );
if( mapping.circularPhysicalGate ) {
const Json &notches = json[P_PHYSICAL_NOTCHES];
for( int i = 0; i < 4; i++ ) {
mapping.physicalNotches[i] = parse<QPointF>( notches[i] );
}
}
mapping.emulatedNotch = json[P_EMULATED_NOTCH].get<double>();
mapping.deadzoneSize = json[P_DEADZONE_SIZE].get<double>();
return mapping;
}
template<> void JsonSerializer::serialize<Binding>(
JsonWriter &jw,
@@ -61,7 +136,6 @@ template<> Binding JsonSerializer::parse<Binding>(
}
}
static constexpr char P_NAME[] = "name";
static constexpr char P_DESCRIPTION[] = "description";
static constexpr char P_UUID[] = "uuid";
static constexpr char P_BINDINGS[] = "bindings";
@@ -185,3 +259,220 @@ bool InputMode::usesTwoPorts() const {
sizeof(InputMapping)
) != 0;
}
static inline double cross2d( const QPointF &a, const QPointF &b ) noexcept {
return (a.x() * b.y()) - (a.y() * b.x());
}
static const double PI = std::atan2( 0.0, -1.0 );
static const double HALF_PI = std::atan2( 1.0, 0.0 );
static const double QUARTER_PI = std::atan2( 1.0, 1.0 );
static const double THREE_QUARTERS_PI = std::atan2( 1.0, -1.0 );
struct PointAndAngle {
QPointF P;
double a;
};
struct GateRegion {
PointAndAngle p1;
PointAndAngle p2;
PointAndAngle e1;
PointAndAngle e2;
};
static inline QPointF getOctGatePoi( double nx, double ny, const QPointF &a, const QPointF &b ) {
const double s = cross2d( a, b ) / QPointF::dotProduct( QPointF( ny, -nx ), a - b );
return QPointF( nx * s, ny * s );
}
static inline GateRegion getPhysicalGateRegion( const ControllerGateMapping &gate, const double angle ) {
assert( !gate.circularPhysicalGate );
//TODO: should cache these in the objecr
const double notchAngles[4] = {
std::atan2( gate.physicalNotches[0].y(), gate.physicalNotches[0].x() ),
std::atan2( gate.physicalNotches[1].y(), gate.physicalNotches[1].x() ),
std::atan2( gate.physicalNotches[2].y(), gate.physicalNotches[2].x() ),
std::atan2( gate.physicalNotches[3].y(), gate.physicalNotches[3].x() )
};
const double e = (gate.emulatedNotch > 0.0) ? gate.emulatedNotch : 0.75;
if( angle < notchAngles[2] ) {
return GateRegion {
{ gate.physicalNotches[2], notchAngles[2] },
{ QPointF( gate.physicalMin.x(), 0.0 ), -PI },
{ QPointF( -e, -e ), -THREE_QUARTERS_PI },
{ QPointF( -1.0, 0.0 ), -PI }
};
} else if( angle < -HALF_PI ) {
return GateRegion {
{ QPointF( 0.0, gate.physicalMin.y() ), -HALF_PI },
{ gate.physicalNotches[2], notchAngles[2] },
{ QPointF( 0.0, -1.0 ), -HALF_PI },
{ QPointF( -e, -e ), -THREE_QUARTERS_PI }
};
} else if( angle < notchAngles[1] ) {
return GateRegion {
{ gate.physicalNotches[1], notchAngles[1] },
{ QPointF( 0.0, gate.physicalMin.y() ), -HALF_PI },
{ QPointF( e, -e ), -QUARTER_PI },
{ QPointF( 0.0, -1.0 ), -HALF_PI }
};
} else if( angle < 0.0 ) {
return GateRegion {
{ QPointF( gate.physicalMax.x(), 0.0 ), 0.0 },
{ gate.physicalNotches[1], notchAngles[1] },
{ QPointF( 1.0, 0.0 ), 0.0 },
{ QPointF( e, -e ), -QUARTER_PI }
};
} else if( angle < notchAngles[0] ) {
return GateRegion {
{ gate.physicalNotches[0], notchAngles[0] },
{ QPointF( gate.physicalMax.x(), 0.0 ), 0.0 },
{ QPointF( e, e ), QUARTER_PI },
{ QPointF( 1.0, 0.0 ), 0.0 }
};
} else if( angle < HALF_PI ) {
return GateRegion {
{ QPointF( 0.0, gate.physicalMax.y() ), HALF_PI },
{ gate.physicalNotches[0], notchAngles[0] },
{ QPointF( 0.0, 1.0 ), HALF_PI },
{ QPointF( e, e ), QUARTER_PI }
};
} else if( angle < notchAngles[3] ) {
return GateRegion {
{ gate.physicalNotches[3], notchAngles[3] },
{ QPointF( 0.0, gate.physicalMax.y() ), HALF_PI },
{ QPointF( -e, e ), THREE_QUARTERS_PI },
{ QPointF( 0.0, 1.0 ), HALF_PI }
};
} else {
return GateRegion {
{ QPointF( gate.physicalMin.x(), 0.0 ), PI },
{ gate.physicalNotches[3], notchAngles[3] },
{ QPointF( -1.0, 0.0 ), PI },
{ QPointF( -e, e ), THREE_QUARTERS_PI }
};
}
}
static inline double getDistanceToPhysicalGateForAngle( const ControllerGateMapping &gate, double angle ) {
if( angle == HALF_PI ) return gate.physicalMax.y();
if( angle == 0.0 ) return gate.physicalMax.x();
if( angle == -HALF_PI ) return -gate.physicalMin.y();
if( angle >= PI || angle <= -PI ) return -gate.physicalMin.x();
if( gate.circularPhysicalGate ) {
const double a = (std::abs( angle ) < HALF_PI) ? gate.physicalMax.x() : -gate.physicalMin.x();
const double b = (angle > 0.0) ? gate.physicalMax.y() : -gate.physicalMin.y();
if( a == b ) return a;
return a * b / std::sqrt(
(a * a * std::sin( angle ) * std::sin( angle )) +
(b * b * std::cos( angle ) * std::cos( angle ))
);
}
GateRegion region = getPhysicalGateRegion( gate, angle );
const QPointF poi = getOctGatePoi( std::cos( angle ), std::sin( angle ), region.p1.P, region.p2.P );
return std::hypot( poi.x(), poi.y() );
}
QPointF ControllerGateMapping::mapInputUncapped( QPointF pos ) const {
if( pos.x() == 0.0 && pos.y() == 0.0 ) return QPointF( 0.0, 0.0 );
if( deadzoneSize > 0.0 ) {
const double angle = std::atan2( pos.y(), pos.x() );
const double dist = std::hypot( pos.x(), pos.y() );
const double maxDist = getDistanceToPhysicalGateForAngle( *this, angle );
if( dist < maxDist * deadzoneSize ) return QPointF( 0.0, 0.0 );
if( rescaleAfterDeadzone ) {
if( dist == deadzoneSize ) return QPointF( 0.0, 0.0 );
const double newDist = (dist - deadzoneSize) * maxDist / (maxDist - deadzoneSize);
pos *= newDist / dist;
}
}
if( pos.x() == 0.0 ) {
const double r = pos.y() > 0.0 ? physicalMax.y() : -physicalMin.y();
return QPointF( 0.0, pos.y() / r );
} else if( pos.y() == 0.0 ) {
const double r = pos.x() > 0.0 ? physicalMax.x() : -physicalMin.x();
return QPointF( pos.x() / r, 0.0 );
} else if( circularPhysicalGate ) {
const double nx = pos.x() / (pos.x() > 0.0 ? physicalMax.x() : -physicalMin.x());
const double ny = pos.y() / (pos.y() > 0.0 ? physicalMax.y() : -physicalMin.y());
if( emulatedNotch == 0.0 || nx == 0.0 || ny == 0.0 ) {
return QPointF( nx, ny );
} else if( std::abs( nx ) == std::abs( ny ) ) {
return QPointF(
nx * emulatedNotch / 0.7071067811865476,
ny * emulatedNotch / 0.7071067811865476
);
}
const double m = std::hypot( nx, ny );
const double angle = std::atan2( ny, nx );
QPointF A, B;
if( angle < -THREE_QUARTERS_PI ) {
A = QPointF( -emulatedNotch, -emulatedNotch );
B = QPointF( -1.0, 0.0 );
} else if( angle < -HALF_PI ) {
A = QPointF( 0.0, -1.0 );
B = QPointF( -emulatedNotch, -emulatedNotch );
} else if( angle < QUARTER_PI ) {
A = QPointF( emulatedNotch, -emulatedNotch );
B = QPointF( 0.0, -1.0 );
} else if( angle < 0.0 ) {
A = QPointF( 1.0, 0.0 );
B = QPointF( emulatedNotch, -emulatedNotch );
} else if( angle < QUARTER_PI ) {
A = QPointF( emulatedNotch, emulatedNotch );
B = QPointF( 1.0, 0.0 );
} else if( angle < HALF_PI ) {
A = QPointF( 0.0, 1.0 );
B = QPointF( emulatedNotch, emulatedNotch );
} else if( angle < THREE_QUARTERS_PI ) {
A = QPointF( -emulatedNotch, emulatedNotch );
B = QPointF( 0.0, 1.0 );
} else {
A = QPointF( -1.0, 0.0 );
B = QPointF( -emulatedNotch, emulatedNotch );
}
return getOctGatePoi( nx, ny, A, B ) * m;
} else {
const double angle = std::atan2( pos.y(), pos.x() );
GateRegion region = getPhysicalGateRegion( *this, angle );
if( emulatedNotch == 0.0 ) {
const double da = (angle - region.p1.a) / (region.p2.a - region.p1.a);
const double newAngle = region.e1.a + da * (region.e2.a - region.e1.a);
const QPointF poi = getOctGatePoi( pos.x(), pos.y(), region.p1.P, region.p2.P );
const double m = std::hypot( pos.y(), pos.x() ) / std::hypot( poi.y(), poi.x() );
return QPointF(
std::cos( newAngle ) * m,
std::sin( newAngle ) * m
);
}
const QPointF barycentric(
cross2d( pos - region.p2.P, region.p1.P ) / cross2d( -region.p2.P, region.p1.P - region.p2.P ),
cross2d( pos, -region.p1.P ) / cross2d( -region.p2.P, region.p1.P - region.p2.P )
);
return QPointF(
QPointF::dotProduct( barycentric, region.e1.P ),
QPointF::dotProduct( barycentric, region.e2.P )
);
}
}

View File

@@ -3,6 +3,7 @@
#include <array>
#include <functional>
#include <QPointF>
#include "src/types.hpp"
#include "src/core/uuid.hpp"
#include "src/core/json.hpp"
@@ -113,6 +114,29 @@ struct ControllerInfo {
ushort numHats;
};
struct ControllerGateMapping {
string name;
bool builtin;
bool circularPhysicalGate;
bool rescaleAfterDeadzone;
QPointF physicalMin;
QPointF physicalMax;
QPointF physicalNotches[4];
double emulatedNotch;
double deadzoneSize;
QPointF mapInputUncapped( QPointF pos ) const;
inline QPointF mapInput( const QPointF &pos ) const {
QPointF mappedPos = mapInputUncapped( pos );
if( mappedPos.x() > 1.0 ) mappedPos.setX( 1.0 );
if( mappedPos.x() < -1.0 ) mappedPos.setX( -1.0 );
if( mappedPos.y() > 1.0 ) mappedPos.setY( 1.0 );
if( mappedPos.y() < -1.0 ) mappedPos.setY( -1.0 );
return mappedPos;
}
};
struct ControllerProfile {
string name;
Binding bindings[(ubyte)ControllerAction::NUM_ACTIONS];
@@ -128,6 +152,12 @@ struct PlayerController {
};
namespace JsonSerializer {
template<> void serialize<QPointF>( JsonWriter &jw, const QPointF &obj );
template<> QPointF parse<QPointF>( const Json &json );
template<> void serialize<ControllerGateMapping>( JsonWriter &jw, const ControllerGateMapping &obj );
template<> ControllerGateMapping parse<ControllerGateMapping>( const Json &json );
template<> void serialize<Binding>( JsonWriter &jw, const Binding &obj );
template<> Binding parse<Binding>( const Json &json );

View File

@@ -612,3 +612,44 @@ bool DefaultInputModes::exists( const Uuid &uuid ) {
uuid == DefaultInputModes::GoldenEye.id ||
uuid == DefaultInputModes::Clone.id;
}
const ControllerGateMapping DefaultControllerGateMappings::Legacy = {
"Legacy",
true,
true,
true,
QPointF( -1.0, -1.0 ),
QPointF( 1.0, 1.0 ),
{ QPointF(), QPointF(), QPointF(), QPointF() },
0.0,
0.15
};
const ControllerGateMapping DefaultControllerGateMappings::Passthrough = {
"Raw Input",
true,
false,
false,
QPointF( -1.0, -1.0 ),
QPointF( 1.0, 1.0 ),
{
QPointF( 1.0, 1.0 ),
QPointF( 1.0, -1.0 ),
QPointF( -1.0, -1.0 ),
QPointF( -1.0, 1.0 )
},
1.0,
0.0
};
const ControllerGateMapping DefaultControllerGateMappings::XBox = {
"Default XBox/Playstation",
true,
true,
false,
QPointF( -1.0, -1.0 ),
QPointF( 1.0, 1.0 ),
{ QPointF(), QPointF(), QPointF(), QPointF() },
0.75,
0.0
};

View File

@@ -30,5 +30,10 @@ namespace DefaultInputModes {
extern bool exists( const Uuid &uuid );
}
namespace DefaultControllerGateMappings {
extern const ControllerGateMapping Legacy;
extern const ControllerGateMapping Passthrough;
extern const ControllerGateMapping XBox;
}
#endif /* SRC_CORE_PRESET_CONTROLLERS_HPP_ */

View File

@@ -0,0 +1,167 @@
#include "src/ui/analog-stick-viewer.hpp"
#include <QResizeEvent>
#include <QPaintEvent>
#include <QPainter>
#include <QWindow>
#include <QPointF>
#include <QPoint>
#include <QRectF>
#include <QBrush>
#include <QPen>
void AnalogStickViewer::paintEvent( QPaintEvent *paintEvent ) {
QPainter( this ).drawPixmap( paintEvent->rect(), m_pixmap, paintEvent->rect() );
}
void AnalogStickViewer::resizeEvent( QResizeEvent *resizeEvent ) {
double dpr = 1.0;
if( window() != nullptr ) {
dpr = window()->devicePixelRatioF();
if( dpr <= 0.0 ) dpr = 1.0;
}
m_pixmap = QPixmap( (resizeEvent->size().toSizeF() * dpr).toSize() );
m_pixmap.setDevicePixelRatio( dpr );
clear();
QFrame::resizeEvent( resizeEvent );
}
void AnalogStickViewer::onInput( double x, double y ) {
QPainter painter( &m_pixmap );
if( !m_lastDot.isNull() ) {
painter.fillRect( m_lastDot, Qt::blue );
}
if( m_viewType == Calibration ) {
clear();
} else {
paintGateOrDeadzone( true );
}
const double dpr = m_pixmap.devicePixelRatioF();
const int ds = (int)((4.0 * dpr) + 0.5);
const QPointF centre( (double)m_pixmap.width() / 2.0, (double)m_pixmap.height() / 2.0 );
painter.setRenderHint( QPainter::Antialiasing, false );
const double r = centre.x() - (2.0 * dpr);
const QPoint p = (centre + (QPointF( x, y ) * r) + QPointF( -2.0 * dpr, -2.0 * dpr )).toPoint();
m_lastDot = QRect( p, p + QPoint( ds, ds ) );
if( m_viewType == PhysicalInputView && std::hypot( x, y ) < m_mapping.deadzoneSize ) {
painter.fillRect( m_lastDot, Qt::gray );
} else {
painter.fillRect( m_lastDot, Qt::yellow );
}
}
void AnalogStickViewer::paintGateOrDeadzone( bool deadzone ) {
if( deadzone && m_viewType != PhysicalInputView ) return;
QPainter painter( &m_pixmap );
painter.setRenderHint( QPainter::Antialiasing, true );
const double dpr = m_pixmap.devicePixelRatioF();
if( deadzone ) {
QPen stroke( QBrush( Qt::red, Qt::SolidPattern ), 2.0 * dpr, Qt::SolidLine, Qt::FlatCap, Qt::MiterJoin );
painter.setPen( stroke );
painter.setBrush( QBrush( Qt::black, Qt::SolidPattern ) );
} else {
QPen stroke( QBrush( Qt::green, Qt::SolidPattern ), 3.0 * dpr, Qt::SolidLine, Qt::FlatCap, Qt::MiterJoin );
painter.setPen( stroke );
painter.setBrush( QBrush() );
}
const double padding = 2.0 * dpr;
const double s = ((double)m_pixmap.width() - (2.0 * padding)) * (deadzone ? m_mapping.deadzoneSize : 1.0);
const double r = s / 2.0;
const QPointF centre( (double)m_pixmap.width() / 2.0, (double)m_pixmap.height() / 2.0 );
switch( m_viewType ) {
case Calibration:
case PhysicalInputView: {
if( m_mapping.circularPhysicalGate ) {
painter.drawArc(
QRectF(
centre.x() - m_mapping.physicalMax.x() * r,
centre.y() - m_mapping.physicalMax.y() * r,
m_mapping.physicalMax.x() * 2.0,
m_mapping.physicalMax.y() * 2.0
),
0,
1440
);
painter.drawArc(
QRectF(
centre.x() - m_mapping.physicalMax.x() * r,
centre.y() + m_mapping.physicalMin.y() * r,
m_mapping.physicalMax.x() * 2.0,
m_mapping.physicalMin.y() * -2.0
),
1440,
1440
);
painter.drawArc(
QRectF(
centre.x() + m_mapping.physicalMin.x() * r,
centre.y() + m_mapping.physicalMin.y() * r,
m_mapping.physicalMin.x() * -2.0,
m_mapping.physicalMin.y() * -2.0
),
2880,
1440
);
painter.drawArc(
QRectF(
centre.x() + m_mapping.physicalMin.x() * r,
centre.y() - m_mapping.physicalMax.y() * r,
m_mapping.physicalMin.x() * -2.0,
m_mapping.physicalMax.y() * 2.0
),
4320,
1440
);
} else {
const QPointF gate[8] = {
centre + QPointF( 0, m_mapping.physicalMax.y() ),
centre + m_mapping.physicalNotches[0] * r,
centre + QPointF( m_mapping.physicalMax.x(), 0 ),
centre + m_mapping.physicalNotches[1] * r,
centre + QPointF( 0, m_mapping.physicalMin.y() ),
centre + m_mapping.physicalNotches[2] * r,
centre + QPointF( m_mapping.physicalMin.x(), 0 ),
centre + m_mapping.physicalNotches[3] * r
};
painter.drawPolygon( gate, 8 );
}
break;
}
case EmulatedInputView: {
if( m_mapping.emulatedNotch == 0.0 ) {
painter.drawEllipse( centre, r, r );
} else {
const QPointF gate[8] = {
centre + QPointF( 0, r ),
centre + QPointF( m_mapping.emulatedNotch * r, m_mapping.emulatedNotch * r ),
centre + QPointF( r, 0 ),
centre + QPointF( m_mapping.emulatedNotch * r, -m_mapping.emulatedNotch * r ),
centre + QPointF( 0, -r ),
centre + QPointF( -m_mapping.emulatedNotch * r, -m_mapping.emulatedNotch * r ),
centre + QPointF( -r, 0 ),
centre + QPointF( -m_mapping.emulatedNotch * r, m_mapping.emulatedNotch * r )
};
painter.drawPolygon( gate, 8 );
}
break;
}
default: break;
}
}
void AnalogStickViewer::clear() {
m_pixmap.fill( Qt::black );
paintGateOrDeadzone( true );
paintGateOrDeadzone( false );
}

View File

@@ -0,0 +1,51 @@
#ifndef SRC_UI_ANALOG_STICK_VIEWER_HPP_
#define SRC_UI_ANALOG_STICK_VIEWER_HPP_
#include <QPixmap>
#include <QFrame>
#include <QRect>
#include "src/core/controller.hpp"
class AnalogStickViewer : public QFrame {
Q_OBJECT
public:
enum ViewType {
Calibration = 0,
PhysicalInputView = 1,
EmulatedInputView = 2
};
private:
QPixmap m_pixmap;
QRect m_lastDot;
ControllerGateMapping m_mapping;
ViewType m_viewType;
void paintGateOrDeadzone( bool deadzone );
public:
inline AnalogStickViewer( QWidget *parent = nullptr ) : QFrame( parent ) {}
inline ~AnalogStickViewer() {};
inline void setMapping( const ControllerGateMapping &mapping ) {
m_mapping = mapping;
clear();
}
inline void setViewType( ViewType type ) {
m_viewType = type;
clear();
}
protected:
void paintEvent( QPaintEvent *paintEvent ) override;
void resizeEvent( QResizeEvent *resizeEvent ) override;
public slots:
void onInput( double x, double y );
void clear();
};
#endif /* SRC_UI_ANALOG_STICK_VIEWER_HPP_ */