Курс Arduino с нуля · Звук

Урок 21. Проект: игра на скорость реакции

Собираем игру, в которую хочется переиграть: случайная пауза, сигнал, кнопка и результат в миллисекундах. Учим плату бросать жребий и мерить время.

Урок 21 из 31⏱ примерно 50 минутОбновлено: 27 июля 2026

Что понадобится

  • Плата Uno из набора
  • Кабель USB
  • Макетная плата
  • Светодиод (любого цвета)
  • Резистор 470 Ом
  • Тактовая кнопка
  • Зуммер
  • 5 проводов «папа-папа»

Все детали есть в обучающем наборе, по которому идёт курс.

Блок «Звук» мы закончим тем, что соберём настоящую игру — такую, в которую интересно играть вдвоём и в которую хочется переиграть. Правила простые: ждёшь сигнала и жмёшь кнопку как можно быстрее. Плата считает твоё время в миллисекундах и объявляет результат.

Деталей понадобится всего три, и все знакомые: светодиод, кнопка и зуммер. А вот приёмов будет два новых — жребий и измерение времени.

Как устроена игра

Паузаот 2 до 6 секунджми — и это фальстартСигналсвет и пискздесь засекаем времяНажатиеждём кнопкуздесь смотрим на часы сноваРезультатразница в мспечатаем и оцениваемэто и есть твоя реакцияПлата дважды смотрит на свои часы millis() и вычитает одно из другого.
Вся игра — это четыре шага. Самое интересное происходит между вторым и третьим.

Ключевая мысль: плата не умеет измерять время напрямую. Она просто дважды смотрит на свои внутренние часы — millis() из одиннадцатого урока — и вычитает одно число из другого. Разница и есть ваша реакция.

Собираем схему

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

Отключите USB перед сборкой. И не забудьте: у зуммера есть «плюс», а у светодиода — длинная ножка. Обе детали не любят, когда их переворачивают.

++abcdefghij1102036141719+91112341Зуммер: «плюс» в столбик 3 → вывод 12; второй вывод — в шину «−».2Кнопка верхом на канавке: столбик 9 СВЕРХУ от канавки → вывод 2,столбик 11 СНИЗУ от канавки → нижняя шина «−» (оба провода сверху — и кнопка «нажата» всегда).3Светодиод: вывод 10 → резистор 470 Ом → длинная ножка; короткая — в шину «−».4Земля: GND платы → верхняя шина «−», перемычка соединяет её с нижней.
Зуммер на выводе 12, кнопка на 2, светодиод через резистор на 10. Шина «+» пустует — так и должно быть.

Главная ловушка кнопки. Два вывода с одной её стороны соединены всегда — про это был десятый урок. Поэтому оба провода нельзя брать с одной стороны канавки: сигнальный уходит на плату сверху, а земляной — снизу. Если подключить оба сверху, плата будет считать кнопку нажатой постоянно, и игра закончится, не начавшись.

UnoD10D12D2GND470 ОмVD1+BFSB1Три знакомые детали: светодиод на выводе 10, кнопка на 2, зуммер на 12.
На чертеже: VD1 — светодиод, SB1 — кнопка, BF — зуммер.

Проверьте перед включением: «плюс» зуммера идёт в столбик 3 и оттуда на вывод 12; кнопка сидит верхом на канавке, её провод на вывод 2 уходит сверху от канавки, а провод на землю — снизу; длинная ножка светодиода смотрит в сторону резистора; обе шины «−» соединены между собой и с GND платы.

Шаг 1. Простейшая игра

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

const int LED = 10;
const int BUTTON = 2;
const int BUZZER = 12;

void setup() {
  pinMode(LED, OUTPUT);
  pinMode(BUZZER, OUTPUT);
  pinMode(BUTTON, INPUT_PULLUP);
  Serial.begin(9600);
}

void loop() {
  Serial.println("приготовились...");
  delay(3000);

  digitalWrite(LED, HIGH);
  tone(BUZZER, 1200, 60);
  unsigned long start = millis();  // засекли время

  // Стоим здесь, пока кнопку не нажмут.
  while (digitalRead(BUTTON) == HIGH) {
  }

  unsigned long reaction = millis() - start;
  digitalWrite(LED, LOW);

  Serial.print("твоё время: ");
  Serial.print(reaction);
  Serial.println(" мс");

  delay(2000);
}

Здесь есть новая для нас конструкция — while. Она означает «пока условие верно, топчись на месте». Строчка while (digitalRead(BUTTON) == HIGH) {} читается так: «пока кнопка не нажата — ничего не делай». Программа замирает в этой точке ровно до нажатия, а потом идёт дальше и считает разницу.

А как же «не подвешивать программу»? В прошлых уроках мы старательно избегали долгих delay — и правильно делали. Но здесь другое дело: игра и есть последовательность «жди — сигнал — нажатие». Плате в это время нечем больше заняться, поэтому ожидание на месте — честное и самое понятное решение. Правило не «никогда не ждать», а «не ждать, когда есть другие дела».

Откройте монитор порта и сыграйте пару раз. Уже работает — но играть неинтересно: пауза всегда одинаковая, и её легко подгадать.

Шаг 2. Плата бросает жребий

Нужна случайная пауза. В Ардуино для этого есть random.

const int LED = 10;
const int BUTTON = 2;
const int BUZZER = 12;

void setup() {
  pinMode(LED, OUTPUT);
  pinMode(BUZZER, OUTPUT);
  pinMode(BUTTON, INPUT_PULLUP);
  Serial.begin(9600);

  // A0 ни к чему не подключён, на нём случайный шум —
  // из него и «перемешиваем» жребий.
  randomSeed(analogRead(A0));
}

void loop() {
  Serial.println("жди сигнала...");

  // Пауза от 2 до 6 секунд: угадать невозможно.
  delay(random(2000, 6000));

  digitalWrite(LED, HIGH);
  tone(BUZZER, 1200, 60);
  unsigned long start = millis();

  while (digitalRead(BUTTON) == HIGH) {
  }

  unsigned long reaction = millis() - start;
  digitalWrite(LED, LOW);

  Serial.print("твоё время: ");
  Serial.print(reaction);
  Serial.println(" мс");

  delay(2000);
}

random(2000, 6000) выдаёт случайное число от 2000 до 5999 — то есть паузу от двух до почти шести секунд. Угадать момент теперь невозможно.

Зачем строчка randomSeed? Вот в чём хитрость: компьютеры не умеют быть по-настоящему случайными. Без этой строки плата после каждого включения выдавала бы одну и ту же последовательность «случайных» чисел. Поэтому мы даём ей отправную точку — показание свободного входа A0. К нему ничего не подключено, он ловит наводки, и число там каждый раз разное. Мелочь, а игра из-за неё становится честной.

Шаг 3. Ловим фальстарт

Осталась лазейка: можно нажать кнопку заранее и держать. Тогда плата увидит «нажато» в тот же миг, что и зажжёт светодиод, и покажет рекордные 0 мс. Закроем эту дырку.

const int LED = 10;
const int BUTTON = 2;
const int BUZZER = 12;

void setup() {
  pinMode(LED, OUTPUT);
  pinMode(BUZZER, OUTPUT);
  pinMode(BUTTON, INPUT_PULLUP);
  Serial.begin(9600);
  randomSeed(analogRead(A0));
}

void loop() {
  Serial.println("жди сигнала...");

  unsigned long wait = random(2000, 6000);
  unsigned long began = millis();

  // Ждём паузу, но всё это время подглядываем за кнопкой.
  while (millis() - began < wait) {
    if (digitalRead(BUTTON) == LOW) {
      Serial.println("фальстарт! рано нажал");
      tone(BUZZER, 200, 600);
      delay(1500);
      return;  // выходим из loop — игра начнётся заново
    }
  }

  digitalWrite(LED, HIGH);
  tone(BUZZER, 1200, 60);
  unsigned long start = millis();

  while (digitalRead(BUTTON) == HIGH) {
  }

  unsigned long reaction = millis() - start;
  digitalWrite(LED, LOW);

  Serial.print("твоё время: ");
  Serial.print(reaction);
  Serial.println(" мс");

  delay(2000);
}

Раньше пауза была одной командой delay — и всё это время программа была слепа. Теперь мы ждём иначе: крутимся в while и на каждом обороте проверяем кнопку. Тот же приём, что и с millis в одиннадцатом уроке, только цель другая — не «успеть сделать другое дело», а «не пропустить нажатие».

Команда return — новая. Она означает «выйти из функции прямо сейчас». Раз loop() закончился, плата тут же начнёт его заново — и игра стартует с чистого листа.

Шаг 4. Рекорд и оценка

Игра без рекорда — не игра. Добавим память о лучшем результате и разные звуки для «обычно» и «новый рекорд».

const int LED = 10;
const int BUTTON = 2;
const int BUZZER = 12;

unsigned long best = 0;  // лучший результат; 0 — рекорда ещё нет

// Короткий сигнал заданной высоты и длительности.
void beep(int freq, int ms) {
  tone(BUZZER, freq, ms);
  delay(ms + 40);
}

void setup() {
  pinMode(LED, OUTPUT);
  pinMode(BUZZER, OUTPUT);
  pinMode(BUTTON, INPUT_PULLUP);
  Serial.begin(9600);
  randomSeed(analogRead(A0));
}

void loop() {
  Serial.println("жди сигнала...");

  unsigned long wait = random(2000, 6000);
  unsigned long began = millis();

  while (millis() - began < wait) {
    if (digitalRead(BUTTON) == LOW) {
      Serial.println("фальстарт!");
      beep(200, 500);
      delay(1500);
      return;
    }
  }

  digitalWrite(LED, HIGH);
  tone(BUZZER, 1200, 60);
  unsigned long start = millis();

  while (digitalRead(BUTTON) == HIGH) {
  }

  unsigned long reaction = millis() - start;
  digitalWrite(LED, LOW);

  Serial.print("время: ");
  Serial.print(reaction);
  Serial.println(" мс");

  if (best == 0 || reaction < best) {
    best = reaction;
    Serial.println("новый рекорд!");
    beep(880, 120);
    beep(1175, 120);
    beep(1568, 260);
  } else {
    Serial.print("рекорд пока ");
    Serial.print(best);
    Serial.println(" мс");
    beep(600, 200);
  }

  delay(2000);
}

Условие best == 0 || reaction < best читается как «рекорда ещё не было или текущий результат лучше». Ноль здесь работает как отметка «пусто» — тот же приём, что и -1 в прошлом уроке.

И обратите внимание на функцию beep: три её вызова подряд превращаются в маленькую победную мелодию. Мы написали её один раз в девятнадцатом уроке и теперь просто пользуемся.

Рекорд живёт до выключения. Как только вы вынете USB, плата всё забудет: переменные хранятся в оперативной памяти. Чтобы рекорд пережил выключение, его надо записать в постоянную память платы — это отдельная тема, до неё мы ещё доберёмся.

Какие результаты бывают

очень быстрообычный человекне выспался100180260340420500мсМеньше 100 мс — не реакция, а угадывание: свет ещё не успел дойти до глаз.
Средняя реакция человека на свет — около 250 миллисекунд. Спортсмены выжимают 150.

Если у вас получилось меньше 100 мс — это не рекорд, а угадывание: сигнал ещё физически не успел дойти до глаз и мозга. Хороший повод посмотреть, не жмёте ли вы кнопку заранее.

Не работает? Разбираемся

  • Игра сразу показывает 0 мс. Кнопка нажата или залипла: проверьте, что она стоит верхом на канавке, а не повёрнута на 90 градусов. Ловушка из десятого урока.
  • Ничего не происходит после сигнала. Кнопка не доходит до платы: посмотрите, что провод идёт из того же столбика, где стоит её ножка, и что вторая ножка сидит в шине «−».
  • Светодиод не загорается. Скорее всего, перевёрнут: длинная ножка должна смотреть в сторону резистора. Или провод ушёл не на вывод 10.
  • Зуммер молчит. Проверьте «плюс» и вывод 12 — как в уроке про зуммер.
  • Каждый раз одинаковая пауза. Пропущена строка randomSeed — или к входу A0 всё-таки что-то подключено, и шума на нём нет.
  • В мониторе порта иероглифы. Скорость выбрана не та: и в программе, и в мониторе должно стоять 9600.

Задания для тренировки

  • РазминкаСделайте так, чтобы перед стартом плата отсчитывала «три-два-один» тремя короткими сигналами, а уже потом уходила в случайную паузу.
  • ПосложнееДобавьте уровни сложности: после каждой удачной попытки паузу делаем короче, а на нажатие даём всё меньше времени. Не успел — проигрыш и низкий гудок.
  • Со звёздочкой ⭐Устройте серию из пяти попыток и посчитайте средний результат и лучший. Разбор ниже.
Показать разбор «серия из пяти попыток»

Главный приём здесь — вынести одну попытку в отдельную функцию, которая возвращает результат. Тогда серия становится обычным циклом, а фальстарт — просто нулём, который эта функция вернула.

const int LED = 10;
const int BUTTON = 2;
const int BUZZER = 12;
const int TRIES = 5;

unsigned long results[TRIES];

void setup() {
  pinMode(LED, OUTPUT);
  pinMode(BUZZER, OUTPUT);
  pinMode(BUTTON, INPUT_PULLUP);
  Serial.begin(9600);
  randomSeed(analogRead(A0));
}

// Одна попытка. Возвращает время в миллисекундах, а 0 — это фальстарт.
unsigned long oneTry() {
  unsigned long wait = random(2000, 6000);
  unsigned long began = millis();

  while (millis() - began < wait) {
    if (digitalRead(BUTTON) == LOW) {
      return 0;
    }
  }

  digitalWrite(LED, HIGH);
  tone(BUZZER, 1200, 60);
  unsigned long start = millis();

  while (digitalRead(BUTTON) == HIGH) {
  }

  digitalWrite(LED, LOW);
  return millis() - start;
}

void loop() {
  Serial.println("серия из пяти попыток");

  unsigned long sum = 0;
  unsigned long best = 0;

  for (int i = 0; i < TRIES; i++) {
    unsigned long r = 0;

    // Повторяем попытку, пока она не окажется честной.
    while (r == 0) {
      r = oneTry();
      if (r == 0) {
        Serial.println("фальстарт, попытка не засчитана");
        tone(BUZZER, 200, 400);
        delay(1200);
      }
    }

    results[i] = r;
    sum = sum + r;

    if (best == 0 || r < best) {
      best = r;
    }

    Serial.print("попытка ");
    Serial.print(i + 1);
    Serial.print(": ");
    Serial.print(r);
    Serial.println(" мс");

    delay(1500);
  }

  Serial.print("все попытки: ");
  for (int i = 0; i < TRIES; i++) {
    Serial.print(results[i]);
    Serial.print(" ");
  }
  Serial.println();

  Serial.print("средний результат: ");
  Serial.print(sum / TRIES);
  Serial.println(" мс");

  Serial.print("лучший: ");
  Serial.print(best);
  Serial.println(" мс");

  tone(BUZZER, 1568, 400);
  delay(6000);
}

Посмотрите на while (r == 0): попытка повторяется, пока не получится честной. Это цикл внутри цикла, и он читается легко именно потому, что вся сложность спрятана в oneTry().

Сумма копится в отдельной переменной, а деление на количество даёт среднее. Обратите внимание на тип unsigned long у суммы: пять результатов по 300 мс — это немного, но если считать десятки попыток, обычный int переполнится.

Что мы узнали

  • Время измеряют двумя взглядами на часы: millis() до и после, а потом вычитают.
  • random(от, до) бросает жребий, а randomSeed делает его разным при каждом включении.
  • while — это «топчись на месте, пока условие верно». Иногда ждать на месте правильно: когда других дел у платы нет.
  • return досрочно выходит из функции — удобно, чтобы прервать неудачный круг игры.
  • Отдельная функция, возвращающая результат, превращает сложную программу в простой цикл.

Блок «Звук» закончен. Ваша плата умеет пищать, играть мелодии, слушаться света и мерить человеческую реакцию — а вы собрали из этого две законченные вещи: инструмент и игру.

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

Частые вопросы

Как измерить время реакции на Arduino?

Плата дважды смотрит на свои часы: запоминает millis() в момент сигнала и снова читает millis() после нажатия кнопки. Разница этих двух чисел и есть время реакции в миллисекундах.

Зачем нужна команда randomSeed?

Без неё плата после каждого включения выдаёт одну и ту же последовательность «случайных» чисел, и паузу можно выучить. randomSeed(analogRead(A0)) берёт отправную точку из шума на свободном входе A0, и жребий каждый раз получается новым.

Как поймать фальстарт — нажатие до сигнала?

Не ждать паузу командой delay, а крутиться в цикле while и на каждом обороте проверять кнопку. Если нажали раньше времени, объявляем фальстарт и выходим из loop командой return — игра начнётся заново.

Какое время реакции считается хорошим?

Средняя реакция человека на свет — около 250 миллисекунд, тренированные люди показывают 150–180. Результат меньше 100 миллисекунд означает, что кнопку нажали наугад: сигнал просто не успел дойти до глаз и мозга.

Почему рекорд пропадает после выключения?

Переменные живут в оперативной памяти, а она очищается при отключении питания. Чтобы рекорд сохранялся, его нужно записывать в постоянную память платы — это отдельная тема.

← Все уроки курса