├── .gitignore ├── Qml ├── Back.qml ├── Groove.qml ├── Handle.qml ├── Joystick.qml ├── Lights.qml ├── Main.qml └── SparseLight.qml ├── QmlJoystick.pro ├── QmlJoystick.qrc ├── README.md └── Source └── Main.cpp /.gitignore: -------------------------------------------------------------------------------- 1 | *.user 2 | build -------------------------------------------------------------------------------- /Qml/Back.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtGraphicalEffects 1.0 3 | 4 | Canvas { 5 | property double radius: 512 6 | property string color: "#34393f" 7 | 8 | id: root 9 | width: 2 * radius 10 | height: 2 * radius 11 | antialiasing: true 12 | smooth: true 13 | contextType: '2d' 14 | onPaint: { 15 | if (context) { 16 | context.reset() 17 | context.beginPath() 18 | context.arc(radius, radius, 0.99 * radius, 0, 2 * Math.PI) 19 | 20 | let gradient = context.createLinearGradient(0, 0, width, height) 21 | gradient.addColorStop(0, Qt.darker(root.color, 1.25)) 22 | gradient.addColorStop(1, Qt.lighter(root.color, 1.25)) 23 | 24 | context.fillStyle = gradient 25 | context.fill() 26 | } 27 | } 28 | 29 | layer.enabled: false 30 | layer.effect: InnerShadow { 31 | color: "#80000000" 32 | radius: 64 / 512 * root.radius 33 | samples: 32 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Qml/Groove.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtGraphicalEffects 1.0 3 | 4 | Canvas { 5 | property double radius: 356 6 | property string color: "#34393f" 7 | 8 | id: root 9 | width: 2 * radius 10 | height: 2 * radius 11 | antialiasing: true 12 | smooth: true 13 | contextType: '2d' 14 | onPaint: { 15 | if (context) { 16 | context.reset() 17 | context.beginPath() 18 | context.arc(radius, radius, 0.98 * radius, 0, 2 * Math.PI) 19 | context.fillStyle = root.color 20 | context.fill() 21 | context.strokeStyle = Qt.lighter(root.color, 1.25) 22 | context.lineWidth = 6 / 356 * radius 23 | context.stroke() 24 | } 25 | } 26 | 27 | layer.enabled: true 28 | layer.effect: DropShadow { 29 | color: "#40000000" 30 | horizontalOffset: 24 / 356 * root.radius 31 | verticalOffset: 24 / 356 * root.radius 32 | radius: 32 / 356 * root.radius 33 | samples: 32 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Qml/Handle.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtGraphicalEffects 1.0 3 | 4 | Item { 5 | property double radius: 230 6 | property string color: "#34393f" 7 | property string lightColor: "#8bfcee" 8 | 9 | id: root 10 | width: 2 * radius 11 | height: 2 * radius 12 | 13 | Rectangle { 14 | anchors.centerIn: root 15 | width: 2 * radius 16 | height: 2 * radius 17 | radius: 0.94 * root.radius 18 | color: root.color 19 | layer.enabled: true 20 | layer.effect: DropShadow { 21 | color: "#80000000" 22 | radius: 48 / 230 * root.radius 23 | samples: 32 24 | } 25 | } 26 | 27 | Canvas { 28 | x: 0 29 | y: 0 30 | width: 2 * radius 31 | height: 2 * radius 32 | antialiasing: true 33 | smooth: true 34 | contextType: '2d' 35 | onPaint: { 36 | if (context) { 37 | context.reset() 38 | context.beginPath() 39 | context.arc(radius, radius, 228 / 230 * radius, 0, 2 * Math.PI) 40 | context.fillStyle = root.color 41 | context.fill() 42 | 43 | context.strokeStyle = Qt.lighter(root.color, 1.25) 44 | context.lineWidth = 2 / 230 * radius 45 | context.stroke() 46 | 47 | context.lineWidth = 6 / 230 * radius 48 | context.lineJoin = "round" 49 | 50 | for (let i = 0; i < 4; i++) { 51 | context.translate(radius, radius) 52 | context.rotate(i * Math.PI / 2) 53 | context.translate(-radius, -radius) 54 | 55 | context.beginPath() 56 | context.moveTo(214 / 230 * radius, 64 / 230 * radius) 57 | context.lineTo(230 / 230 * radius, 48 / 230 * radius) 58 | context.lineTo(246 / 230 * radius, 64 / 230 * radius) 59 | context.shadowColor = lightColor 60 | context.shadowBlur = 8 / 230 * radius 61 | context.shadowOffsetX = 0 62 | context.shadowOffsetY = 0 63 | context.strokeStyle = lightColor 64 | context.stroke() 65 | } 66 | } 67 | } 68 | 69 | layer.enabled: true 70 | layer.effect: InnerShadow { 71 | color: "#80000000" 72 | radius: 48 / 230 * root.radius 73 | samples: 32 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Qml/Joystick.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | 3 | Item { 4 | id: root 5 | width: 1340 6 | height: 1340 7 | 8 | property double scaleRatio: 1 9 | property string color: "#22262a" 10 | property bool pressedOnHandle: false 11 | property double axisX: 0 12 | property double axisY: 0 13 | property double theta: 0 14 | property double radius: 0 15 | readonly property double maxRadius: 275 * scaleRatio 16 | 17 | function update() { 18 | handle.x = 440 * scaleRatio + maxRadius * axisX 19 | handle.y = 440 * scaleRatio - maxRadius * axisY 20 | 21 | for (let i = 0; i < lights.lights.count; i++) { 22 | let tempTheta = -theta 23 | 24 | if (tempTheta < 0) 25 | tempTheta += 2 * Math.PI 26 | 27 | let angleBetween = Math.abs(tempTheta - lights.lights.itemAt(i).angle) 28 | 29 | lights.lights.itemAt(i).lightOn = (angleBetween < Math.PI / 6 || angleBetween > 2 * Math.PI - Math.PI / 6) 30 | lights.lights.itemAt(i).lightBrightness = Math.min(axisX * axisX + axisY * axisY, 1) 31 | } 32 | } 33 | 34 | function reset() { 35 | axisX = 0 36 | axisY = 0 37 | theta = 0 38 | pressedOnHandle = false 39 | root.update() 40 | } 41 | 42 | onScaleRatioChanged: reset() 43 | 44 | Item { 45 | id: center 46 | x: root.width / 2 47 | y: root.height / 2 48 | } 49 | 50 | Back { 51 | x: 158 * scaleRatio 52 | y: 158 * scaleRatio 53 | radius: 512 * scaleRatio 54 | color: root.color 55 | } 56 | 57 | Groove { 58 | x: 314 * scaleRatio 59 | y: 314 * scaleRatio 60 | radius: 356 * scaleRatio 61 | color: root.color 62 | } 63 | 64 | Lights { 65 | id: lights 66 | x: 10 * scaleRatio 67 | y: 10 * scaleRatio 68 | radius: 660 * scaleRatio 69 | } 70 | 71 | Handle { 72 | id: handle 73 | x: 440 * scaleRatio 74 | y: 440 * scaleRatio 75 | radius: 230 * scaleRatio 76 | color: root.color 77 | 78 | Item { 79 | id: handleCenter 80 | anchors.centerIn: handle 81 | } 82 | } 83 | 84 | MouseArea { 85 | anchors.fill: parent 86 | onPressed: { 87 | let point = mapToItem(handleCenter, mouseX, mouseY) 88 | pressedOnHandle = point.x * point.x + point.y * point.y < handle.radius * handle.radius 89 | } 90 | 91 | onReleased: reset() 92 | 93 | onPositionChanged: { 94 | if (pressedOnHandle) { 95 | let point = mapToItem(center, mouseX, mouseY) 96 | let length = Math.sqrt(point.x * point.x + point.y * point.y) 97 | if (length > maxRadius) 98 | length = maxRadius 99 | 100 | theta = Math.atan2(-point.y, point.x) 101 | 102 | axisX = length * Math.cos(theta) / maxRadius 103 | axisY = length * Math.sin(theta) / maxRadius 104 | 105 | root.update() 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Qml/Lights.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | 3 | Item { 4 | id: root 5 | width: 2 * radius 6 | height: 2 * radius 7 | 8 | property double radius: 660 9 | property alias lights: lights 10 | 11 | Repeater { 12 | id: lights 13 | model: 36 14 | SparseLight { 15 | property double angle: 2 * index * Math.PI / 36 16 | radius: 12 / 660 * root.radius 17 | lightOn: false 18 | lightBrightness: 0 19 | x: root.radius - radius + (root.radius - radius) * Math.cos(angle) 20 | y: root.radius - radius + (root.radius - radius) * Math.sin(angle) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Qml/Main.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.15 2 | import QtQuick.Window 2.15 3 | 4 | Window { 5 | id: window 6 | width: 800 7 | height: 800 8 | visible: true 9 | color: "#22262a" 10 | 11 | Joystick { 12 | anchors.centerIn: parent 13 | width: 1340 * scaleRatio 14 | height: 1340 * scaleRatio 15 | color: window.color 16 | scaleRatio: 0.7 * Math.min(window.height / 1366, window.width / 768) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Qml/SparseLight.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtGraphicalEffects 1.0 3 | 4 | Item { 5 | id: root 6 | width: 2 * radius 7 | height: 2 * radius 8 | 9 | property double radius: 12 10 | property string backgroundColor: "#34393f" 11 | property string lightColor: "#8bfcee" 12 | property bool lightOn: false 13 | property double lightBrightness: 1.0 14 | 15 | Canvas { 16 | id: background 17 | width: 2 * radius 18 | height: 2 * radius 19 | antialiasing: true 20 | smooth: true 21 | contextType: '2d' 22 | onPaint: { 23 | if (context) { 24 | context.reset() 25 | context.beginPath() 26 | context.arc(radius, radius, 0.9 * radius, 0, 2 * Math.PI) 27 | context.fillStyle = backgroundColor 28 | context.fill() 29 | } 30 | } 31 | } 32 | 33 | Canvas { 34 | id: light 35 | width: 2 * radius 36 | height: 2 * radius 37 | antialiasing: true 38 | smooth: true 39 | contextType: '2d' 40 | onPaint: { 41 | if (context) { 42 | context.reset() 43 | context.beginPath() 44 | context.arc(radius, radius, 0.8 * radius, 0, 2 * Math.PI) 45 | context.fillStyle = lightColor 46 | context.fill() 47 | } 48 | } 49 | 50 | visible: lightOn 51 | opacity: lightBrightness 52 | layer.enabled: true 53 | layer.effect: Glow { 54 | anchors.fill: root 55 | radius: 2 * root.radius 56 | samples: 24 57 | color: lightColor 58 | source: light 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /QmlJoystick.pro: -------------------------------------------------------------------------------- 1 | QT += quick 2 | 3 | SOURCES += \ 4 | Source/Main.cpp 5 | 6 | RESOURCES += QmlJoystick.qrc 7 | -------------------------------------------------------------------------------- /QmlJoystick.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | Qml/Back.qml 4 | Qml/Groove.qml 5 | Qml/Handle.qml 6 | Qml/Joystick.qml 7 | Qml/Lights.qml 8 | Qml/Main.qml 9 | Qml/SparseLight.qml 10 | 11 | 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QmlJoystick 2 | Joystick written in `QML`. 3 | 4 | ## Build 5 | 1) Install `Qt 5.x.y`. 6 | 2) Open `QmlJoystick.pro` with `QtCreator` and build & run it. 7 | 8 | ## Video 9 | https://github.com/user-attachments/assets/61f210fe-0a5b-4191-b154-83180739b8d2 10 | 11 | ## Keywords 12 | `QML`, 13 | `Joystick`, 14 | `Control`, 15 | `QML Sample`. 16 | -------------------------------------------------------------------------------- /Source/Main.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | int main(int argc, char *argv[]) 5 | { 6 | #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) 7 | QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling); 8 | #endif 9 | QGuiApplication app(argc, argv); 10 | 11 | QQmlApplicationEngine engine; 12 | const QUrl url(QStringLiteral("qrc:/Qml/Main.qml")); 13 | QObject::connect(&engine, &QQmlApplicationEngine::objectCreated, 14 | &app, [url](QObject *obj, const QUrl &objUrl) { 15 | if (!obj && url == objUrl) 16 | QCoreApplication::exit(-1); 17 | }, Qt::QueuedConnection); 18 | engine.load(url); 19 | 20 | return app.exec(); 21 | } 22 | --------------------------------------------------------------------------------