Огонёк на джойстике: первая игра из светодиодов

Сложность
Компоненты
Arduino Unoмодуль джойстика5 светодиодов5 резисторов 220 Оммакетная платапровода Dupont
Навыки
analogReadджойстикINPUT_PULLUPдребезг кнопкиmillisмассивы

Коротко: пять светодиодов в ряд и джойстик. Наклон влево или вправо переносит огонёк на соседний светодиод, нажатие на джойстик заставляет его мигать, повторное нажатие — снова гореть ровно.

Проект с джойстиком и светодиодами

Зачем

Светофор только выводил сигналы и ничего не знал о внешнем мире. Здесь плата впервые реагирует на действия человека: читает положение джойстика и нажатие кнопки.

Задача выглядит простой, но в ней спрятана главная проблема первого урока — delay(). Пока плата ждёт, чтобы мигнуть светодиодом, она не видит джойстик. Значит, мигать придётся по-другому.

Что понадобилось

  • плата Arduino;
  • модуль джойстика;
  • пять светодиодов одного цвета и пять резисторов на 220 Ом;
  • макетная плата и провода Dupont.

Светодиоды подключаю так же, как в светофоре: пин → резистор → светодиод → GND. Джойстик — пятью проводами:

Что Куда
Светодиоды 1–5 пины 2, 3, 4, 5, 6
Джойстик GND GND
Джойстик +5V 5V
Джойстик VRx A0
Джойстик VRy не нужен
Джойстик SW пин 7

Проект с джойстиком и светодиодами

Проект с джойстиком и светодиодами

Как устроен джойстик

Внутри модуля два потенциометра — по одному на каждую ось — и кнопка, которая срабатывает при нажатии на ручку.

Каждый потенциометр включён делителем напряжения, как в уроке о мультиметре. В центре на выходе VRx примерно 2,5 В, при наклоне до упора — около 0 или 5 В. Это легко проверить мультиметром.

Arduino читает это напряжение функцией analogRead() и возвращает число от 0 до 1023. Прежде чем писать игру, стоит посмотреть, какие числа выдаёт именно ваш джойстик. Для этого достаточно короткого скетча, который печатает значение в монитор порта:

void setup() {
  Serial.begin(9600);
}

void loop() {
  Serial.println(analogRead(A0));
  delay(100);
}

В центре получится число около 512, при наклоне — близко к 0 или к 1023. Какое направление даёт 0, зависит от того, как повёрнут модуль. У меня наклон вправо даёт числа около 0 — это пригодится ниже.

Как собирал

Сначала целиком скетч, потом разберу его по частям:

const int LED_PINS[] = {2, 3, 4, 5, 6};
const int LED_COUNT = sizeof(LED_PINS) / sizeof(LED_PINS[0]);

const int JOY_X = A0;
const int JOY_BUTTON = 7;

const int LOW_THRESHOLD = 300;   // наклон в сторону 0
const int HIGH_THRESHOLD = 700;  // наклон в сторону 1023
const unsigned long BLINK_INTERVAL = 300;
const unsigned long DEBOUNCE_DELAY = 50;

int current = 0;            // какой светодиод выбран
bool blinking = false;      // мигает или горит ровно
bool stickCentered = true;  // джойстик вернулся в центр

bool ledOn = true;
unsigned long lastBlink = 0;

int lastButtonReading = HIGH;
int buttonState = HIGH;
unsigned long lastDebounce = 0;

void setup() {
  for (int i = 0; i < LED_COUNT; i++) {
    pinMode(LED_PINS[i], OUTPUT);
  }
  pinMode(JOY_BUTTON, INPUT_PULLUP);
}

void loop() {
  readStick();
  readButton();
  updateLeds();
}

void readStick() {
  int x = analogRead(JOY_X);

  if (x > LOW_THRESHOLD && x < HIGH_THRESHOLD) {
    stickCentered = true;
    return;
  }
  if (!stickCentered) return;
  stickCentered = false;

  if (x <= LOW_THRESHOLD && current < LED_COUNT - 1) current++;
  if (x >= HIGH_THRESHOLD && current > 0) current--;

  ledOn = true;
  lastBlink = millis();
}

void readButton() {
  int reading = digitalRead(JOY_BUTTON);

  if (reading != lastButtonReading) {
    lastDebounce = millis();
  }
  lastButtonReading = reading;

  if (millis() - lastDebounce < DEBOUNCE_DELAY) return;
  if (reading == buttonState) return;

  buttonState = reading;
  if (buttonState == LOW) {
    blinking = !blinking;
    ledOn = true;
    lastBlink = millis();
  }
}

void updateLeds() {
  if (blinking && millis() - lastBlink >= BLINK_INTERVAL) {
    ledOn = !ledOn;
    lastBlink = millis();
  }

  for (int i = 0; i < LED_COUNT; i++) {
    bool on = (i == current) && (!blinking || ledOn);
    digitalWrite(LED_PINS[i], on ? HIGH : LOW);
  }
}

loop() теперь состоит из трёх вызовов: прочитать джойстик, прочитать кнопку, обновить светодиоды. Ни один из них не ждёт, поэтому цикл крутится тысячи раз в секунду, и плата успевает следить за всем сразу.

Массив вместо пяти констант

В светофоре у каждого светодиода была своя константа. Здесь их пять, и они одинаковые по смыслу, поэтому лежат в массиве LED_PINS. Выбранный светодиод — это просто индекс current, а переместить огонёк значит прибавить или отнять единицу.

LED_COUNT считается из размера массива. Если добавить в ряд шестой светодиод, достаточно дописать его пин в массив — остальной код менять не придётся.

Джойстик: один наклон — один шаг

Если просто проверять «джойстик наклонён вправо», огонёк улетит в конец ряда за долю секунды: loop() успеет выполниться сотни раз, пока рука держит ручку.

Поэтому в readStick() есть флаг stickCentered. Огонёк сдвигается только в момент наклона, а следующий шаг возможен, когда джойстик вернётся в центр. Пороги LOW_THRESHOLD и HIGH_THRESHOLD (300 и 700) оставляют вокруг центра запас, чтобы небольшое дрожание ручки не считалось наклоном.

Кнопка: подтяжка и дребезг

Кнопка джойстика замыкает пин на GND. Пока она отпущена, пин ни к чему не подключён и ловит наводки — digitalRead() будет возвращать случайные значения. Режим INPUT_PULLUP включает внутри платы резистор, который подтягивает пин к 5 В. В итоге логика перевёрнута: отпущена — HIGH, нажата — LOW.

Вторая проблема — дребезг. Контакты кнопки при нажатии несколько миллисекунд стучат друг о друга, и плата видит не одно нажатие, а пачку. Режим мигания тогда переключится случайное число раз.

readButton() решает это так: принимает новое состояние кнопки, только если оно не менялось 50 мс. И реагирует на переход в LOW — момент нажатия, а не на всё время, пока кнопка зажата.

Мигание без delay()

В светофоре я бы написал delay(300) между включением и выключением. Здесь так нельзя: на эти 300 мс плата оглохнет, и наклон джойстика потеряется.

Вместо ожидания updateLeds() смотрит на часы. millis() возвращает, сколько миллисекунд прошло с включения платы. Если с последнего переключения прошло больше BLINK_INTERVAL, светодиод меняет состояние, а время запоминается. Если нет — функция ничего не делает и сразу возвращает управление.

Для бэкендера это знакомый приём: не блокировать поток ожиданием, а на каждой итерации цикла проверять, не пора ли что-то сделать.

После перемещения огонька и после нажатия кнопки ledOn сбрасывается в true. Без этого огонёк мог бы переехать на новое место в фазе «выключен» и на мгновение пропасть из виду.

Попробовать в симуляторе