Среди множества устройств управления и ввода информации для микроконтроллеров особый интерес представляет джойстик. Он отлично подойдёт для управления роботом или подвижной платформой с видеокамерой, выполнять навигацию по меню на дисплее или управлять курсором на экране. В рамках этой статьи мы подключим модуль двухосевого джойстика KY-023 к ESP32 и попробуем сделать мини-игру «стрелялку» с отображением на цветном TFT-дисплее ST7735S.

Содержание
- Описание джойстика
- Схема подключения
- Подготовка проекта в PlatformIO
- Программный код (скетч)
- Заключение
Описание джойстика KY-023
Модуль KY-023 — это аналоговое устройство в виде манипулятора, представляющее собой подвижный двухосевой рычаг с тактовой кнопкой. Подобное устройство устанавливается на геймпадах для игровых консолей.

Степень отклонения рычага (который крепится на шарнире) по осям X (горизонтальная) и Y (вертикальная) определяется посредством двух потенциометров — переменных резисторов по 10 кОм. При отклонении ручки манипулятора в какую-либо сторону сопротивление потенциометра меняется, что приводит к изменению напряжения на соответствующем выходе (VRX или VRY). Это напряжение можно считать с помощью аналогового входа микроконтроллера (аналого-цифровой преобразователь — АЦП), и, исходя из значения, определить положение рычага.
Кнопка (нормально-разомкнутая, без фиксации) подключается к цифровому входу и замыкает цепь на землю при нажатии, что позволяет определить факт нажатия. Поэтому требуется внешний подтягивающий резистор (с номиналом 10 кОм) к питанию. Хотя на плате предусмотрено посадочное место для SMD-резистора, поэтому при желании можно либо подпаять резистор, либо использовать отдельный внешний, либо подключать внутреннюю подтяжку на пине микроконтроллера.

Ниже представлено принципиальная схема модуля:

Назначение выводов:
GND — общий провод питания («земля»);
+5V — контакт подачи питания (можно +3,3 В или +5,0 В);
VRX — аналоговый выход потенциометра по оси X;
VRY — выход по оси Y;
SW — выход тактовой кнопки.
Напряжение питания: 3,3 В или 5,0 В (в зависимости от опорного напряжения питания для АЦП при работе с микроконтроллером)
Габаритные размеры: 34 мм × 26 мм × 34 мм.
Принцип работы
Когда рычаг находится в исходном состоянии, то оба потенциометра установлены в срединное состояние, то есть прикладываемое к ним напряжение питания делится пополам. При этом используется встроенный в ESP32 аналого-цифровой преобразователь, который настроен на 12-битный режим работы (измерения производятся от 0 до 4095 отсчётов). Соответственно, в исходном состоянии манипулятора АЦП будет регистрировать показания приблизительно на уровне 2048. А для определения направления движения нужно задать порог срабатывания (например, если значение меньше 1000 — движение влево, больше 3000 — вправо).
Схема подключения джойстика KY-023 к ESP32
Для работы с манипулятором воспользуемся отладочной платой NodeMCU-32S (38 pin), которая построена на модуле ESP-WROOM-32.
Подключите вывод VCC модуля KY-023 к 3.3V на ESP32. Хотя модуль может работать и от 5 Вольт, но для корректности работы АЦП на микроконтроллере лучше использовать 3.3V. Также подключите GND к общему проводу («земле»).
Далее подключите VRX (ось X) к одному из аналоговых входов микроконтроллера, например, к пину GPIO34 (ADC1_CH6). Вывод VRY (ось Y) к другому аналоговому входу, например, GPIO35 (ADC1_CH7).
Вывод кнопки SW подсоедините к цифровому входу отладочной платы, например, GPIO32. Для данного пина настроим подтягивающий резистор (INPUT_PULLUP), так как кнопка замыкает на землю при нажатии.
Таблица 1 — Подключение KY-023 к отладочной плате

Чтобы было интереснее экспериментировать с манипулятором, попробуем сделать мини-игру типа «стрелялка», используя в качестве экрана TFT-дисплей на базе контроллера ST7735S.
Возьмём небольшой экран с диагональю 0.96’’ и разрешением 80 на 160:

Таблица 2 — Подключение экрана ST7735S к отладочной плате

Ниже представлена схема подключения манипулятора KY-023 и дисплея ST7735S к ESP32, а также фотография собранного макета:

Подготовка проекта в VS Code + PlatformIO
В качестве среды разработки воспользуемся расширением PlatformIO для Visual Studio Code.
Создаём новый проект. При выборе отладочной платы необходимо указать DOIT ESP32 DEVKIT V1, которая соответствует NodeMCU-32S (38 pin) на базе модуля ESP-WROOM-32. Применяемый фреймворк — Arduino, чтобы обеспечить совместимость с соответствующей IDE. Также указываем папку, в которой будут размещены файлы проекта.
В файле platformio.ini должны быть прописаны базовые моменты проекта:
[env:esp32doit-devkit-v1]
platform = espressif32
board = esp32doit-devkit-v1
framework = arduino
monitor_speed = 115200

Для нашего проекта потребуется подключить библиотеку для работы с TFT-дисплеем. Мы воспользуемся tft_eSPI, поскольку она, в отличии от Adafruit_ST7735_and_ST7789_Library, позволяет быстро перерисовывать изображение на экране, что практически незаметно для глаз. А в нашем случае это крайне важно. Поэтому скачиваем архив библиотеки Bodmer/TFT_eSPI с репозитория на GitHub:

Распакуйте скаченный архив TFT_eSPI-master. Извлечённую папку переименуйте, убрав из название окончание «-master». Теперь папку библиотеки положите в директорию lib проекта:

Внутри папки есть файл User_Setup.h, в которую необходимо внести коррективы, чтобы настроить библиотеку на работу с конкретно нашей моделью дисплея. Сделайте копию этого файла, назвав его, например, User_Setup_joystick.h:
Первым делом нам надо объявить драйвер для контроллера ST7735. Для этого закомментируйте строчку для ILI9341, которая по умолчанию, а потом раскомментируйте строку для ST7735:
Далее в разделе, который определяет разрешение экрана, раскомментируйте строки, относящиеся к нашему индикатору (80 на 160):
После этого указываем аргумент для инициализации. В зависимости от того, какой модификации Вам попался модуль экрана, аргумент может быть разный. Для моего индикатора оптимальным аргументом оказался ST7735_GREENTAB160x80, поскольку (возможно) защитная наклейка на экране имеет зелёный ярлык, да и разрешение соответствует 160 на 80.
Обратите внимание на скриншоте выше, что в комментариях к выделенной строке написаны указания на конкретную конфигурацию для дисплеев с разрешением 160×80, где:
— BGR: порядок следования цветовых компонентов пикселя — Blue, Green, Red (а не стандартный RGB). Это означает, что библиотека должна учитывать этот порядок при выводе цвета;
— Inverted: дисплей инвертирован, то есть цвета инвертированы (например, черный становится белым и наоборот). Библиотека учитывает эту особенность при инициализации;
— 26 offset: аппаратное смещение на 26 пикселей. Это означает, что индикатор имеет физическую особенность, при которой отображаемая область начинается не с нулевой координаты, а со смещением на 26 пикселей. Это связано с тем, что фактическая отображаемая область на матрице начинается позже, чем логический ноль.
Назначим для отладочной платы конкретные выводы, к которым будут подключаться контакты индикатора:
Остальные настройки оставить без изменений. Сохраните файл.
Теперь необходимо в файле User_Setup_Select.h указать именно наш кастомный конфигурационный файл User_Setup_joystick.h, чтобы библиотека считывала наши настройки для последующей работы:
Все приготовления завершены, можно приступить к написанию кода.
Программный код (скетч)
В исходник main.cpp добавим код, который представлен ниже и максимально подробно прокомментирован:
#include <TFT_eSPI.h>
#include <SPI.h>
TFT_eSPI tft = TFT_eSPI();
TFT_eSprite backBuffer = TFT_eSprite(&tft);
// Новые размеры экрана (поменяли местами)
#define SCREEN_WIDTH 160 // широкая сторона горизонтально
#define SCREEN_HEIGHT 80 // узкая - вертикально
// Джойстик
#define JOY_X_PIN 34
#define JOY_Y_PIN 35
#define JOY_SW_PIN 32
// Прицел
int cursorX = SCREEN_WIDTH / 2;
int cursorY = SCREEN_HEIGHT / 2;
int moveThreshold = 1000;
int stepSize = 2;
// Мишень
int targetX = 20;
int targetY = 20;
int targetRadius = 6;
unsigned long targetSpawnTime = 0;
unsigned long targetLifetime = 3000;
// Счёт и таймер
int score = 0;
unsigned long gameStartTime = 0;
const int GAME_DURATION = 30000; // 30 секунд
bool gameActive = true;
unsigned long gameEndTime = 0;
const int RESTART_DELAY = 5000; // 5 секунд перед перезапуском
// Кнопка
bool lastButtonState = HIGH;
// В функции fullRedraw изменяем только расположение текста:
void fullRedraw() {
backBuffer.fillSprite(TFT_BLACK);
// Рамка по периметру экрана
backBuffer.drawRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, TFT_BLUE);
// Текст счета в верхнем левом углу
backBuffer.setCursor(2, 2);
backBuffer.setTextColor(TFT_GREEN, TFT_BLACK);
backBuffer.print("Score: ");
backBuffer.print(score);
// Таймер под счетом
unsigned long elapsed = millis() - gameStartTime;
int remaining = (GAME_DURATION - elapsed) / 1000;
remaining = max(0, remaining);
backBuffer.setCursor(2, 12);
backBuffer.setTextColor(TFT_YELLOW, TFT_BLACK);
backBuffer.print("Time: ");
backBuffer.print(remaining);
// Мишень и прицел
backBuffer.fillCircle(targetX, targetY, targetRadius, TFT_RED);
int size = 5;
backBuffer.drawFastHLine(cursorX - size, cursorY, size*2+1, TFT_WHITE);
backBuffer.drawFastVLine(cursorX, cursorY - size, size*2+1, TFT_WHITE);
backBuffer.pushSprite(0, 0);
};
// появление новой мишени
void spawnTarget() {
targetX = random(targetRadius, SCREEN_WIDTH-targetRadius);
targetY = random(targetRadius, SCREEN_HEIGHT-targetRadius);
targetSpawnTime = millis();
}
// === ВЫСТРЕЛ ===
void fire() {
if(!gameActive) return;
backBuffer.fillCircle(cursorX, cursorY, 2, TFT_YELLOW);
backBuffer.pushSprite(0, 0);
delay(50);
if (sqrt(pow(cursorX-targetX,2) + pow(cursorY-targetY,2)) <= targetRadius) {
score++;
spawnTarget();
}
fullRedraw();
}
// === КОНЕЦ ИГРЫ ===
void endGame() {
gameActive = false;
gameEndTime = millis();
backBuffer.fillSprite(TFT_BLACK);
backBuffer.setTextColor(TFT_WHITE, TFT_BLACK);
backBuffer.setTextSize(1);
backBuffer.setCursor(10, SCREEN_HEIGHT/2 - 10);
backBuffer.print("Game Over!");
backBuffer.setCursor(10, SCREEN_HEIGHT/2);
backBuffer.print("Score: ");
backBuffer.print(score);
backBuffer.pushSprite(0, 0);
}
// === ПЕРЕЗАПУСК ИГРЫ ===
void restartGame() {
score = 0;
cursorX = SCREEN_WIDTH / 2;
cursorY = SCREEN_HEIGHT / 2;
gameStartTime = millis();
gameActive = true;
spawnTarget();
fullRedraw();
}
void setup() {
Serial.begin(115200);
// Изменяем только здесь ориентацию:
tft.init();
tft.setRotation(3); // Поворот на 90 градусов
tft.fillScreen(TFT_BLACK);
backBuffer.createSprite(SCREEN_WIDTH, SCREEN_HEIGHT);
pinMode(JOY_SW_PIN, INPUT_PULLUP);
gameStartTime = millis();
spawnTarget();
fullRedraw();
}
void loop() {
// Автоматический перезапуск
if(!gameActive && millis() - gameEndTime > RESTART_DELAY) {
restartGame();
return;
}
if(!gameActive) {
delay(100);
return;
}
// Проверка времени игры
if(millis() - gameStartTime > GAME_DURATION) {
endGame();
return;
}
// Управление
int xVal = analogRead(JOY_X_PIN);
int yVal = analogRead(JOY_Y_PIN);
bool buttonState = digitalRead(JOY_SW_PIN);
int oldX = cursorX;
int oldY = cursorY;
if(xVal < moveThreshold) cursorX -= stepSize;
if(xVal > 4095-moveThreshold) cursorX += stepSize;
if(yVal < moveThreshold) cursorY -= stepSize;
if(yVal > 4095-moveThreshold) cursorY += stepSize;
cursorX = constrain(cursorX, 0, SCREEN_WIDTH-1);
cursorY = constrain(cursorY, 0, SCREEN_HEIGHT-1);
if(cursorX != oldX || cursorY != oldY) {
fullRedraw();
}
if(lastButtonState == HIGH && buttonState == LOW) {
fire();
}
lastButtonState = buttonState;
if(millis() - targetSpawnTime > targetLifetime) {
spawnTarget();
fullRedraw();
}
// Обновление таймера
static unsigned long lastTimeUpdate = 0;
if(millis() - lastTimeUpdate > 1000) {
lastTimeUpdate = millis();
fullRedraw();
}
delay(20);
}
Логика игры
— цель: управлять прицелом (крестиком) и попадать в мишени (круги)
— управление: джойстик (X, Y) + кнопка (выстрел)
— таймер: 30 секунд на игру
— счет: +1 за каждое попадание
Алгоритм работы программы
1) Инициализация:
— настройка дисплея (tft.init())
— настройка разрядности АЦП (analogReadResolution(12))
— создание буфера (backBuffer.createSprite())
2) Игровой цикл:
— чтение уровней напряжения с выходов потенциометров (analogRead)
— отрисовка прицела (drawFastHLine/VLine)
— проверка попадания (sqrt(x² + y²) <= радиус)
— обновление счета и таймера
3) Завершение игры:
— если время вышло (millis() – startTime > 30000), показываем «Game Over»
— через 5 секунд — перезапуск (restartGame())
4) Оптимизации
— двойная буферизация (TFT_eSprite), чтобы не было мерцания
— контроль частоты обновления кадров (delay(20)) для плавного управления
Чтобы скомпилировать код, необходимо нажать на пиктограмму в виде галочки в нижнем левом углу окна:

При отсутствии ошибок сборка должна пройти успешно:

Чтобы прошить микроконтроллер, подключите отладочную плату к компьютеру коротким USB-кабелем (настоятельно рекомендуется длина менее 1 метра). Убедитесь, что драйвер для USB-UART преобразователя успешно установлен на компьютер. На используемой в нашем эксперименте отладочной плате установлен чип CP2102 от компании Silicon Labs.
Процесс прошивки запускается посредством нажатия пиктограммы в виде стрелочки, указывающей направо:

Когда в терминале появится надпись «Connecting…», начнут появляться точки после названия COM-порта, к которому подключена отладочная плата. В этот момент нужно успеть зажать на пару секунд на плате кнопку «BOOT», которая справа от USB-разъёма:


Если успеть зажать, то стартует процесс прошивки, и в терминале будет отображаться прогресс:

При успешной загрузки в терминале появится соответствующее сообщение, а отладочная плата автоматически перезагрузится и начнёт исполнять прошивку.


В программном коде позиционирование джойстика выбрано так, что надписи на его плате читались так же, как и строки на индикаторе. Ниже на фотографии видно, что при повороте направо крестик курсора уперся в правый край индикатора:

Заключение
В этой статье мы рассмотрели модуль джойстика KY-023 — очень удобного инструмента для интерактивных проектов (навигация по меню, перемещение курсора по экрану, управления колёсными/гусеничными роботами или манипуляторами на сервоприводах).
При работе с манипулятором обращайте внимание на следующие важные моменты:
— корректное подключение VRX, VRY;
— питание 3,3 Вольта для корректной работы АЦП встроенного в ESP32;
— для тактовой кнопки SW нужно подключать подтягивающий к питанию резистор (либо внешний, либо встроенный в микроконтроллер INPUT_PULLUP).








