Курс Arduino с нуля · Звук
Урок 21. Проект: игра на скорость реакции
Собираем игру, в которую хочется переиграть: случайная пауза, сигнал, кнопка и результат в миллисекундах. Учим плату бросать жребий и мерить время.
Что понадобится
- Плата Uno из набора
- Кабель USB
- Макетная плата
- Светодиод (любого цвета)
- Резистор 470 Ом
- Тактовая кнопка
- Зуммер
- 5 проводов «папа-папа»
Все детали есть в обучающем наборе, по которому идёт курс.
Блок «Звук» мы закончим тем, что соберём настоящую игру — такую, в которую интересно играть вдвоём и в которую хочется переиграть. Правила простые: ждёшь сигнала и жмёшь кнопку как можно быстрее. Плата считает твоё время в миллисекундах и объявляет результат.
Деталей понадобится всего три, и все знакомые: светодиод, кнопка и зуммер. А вот приёмов будет два новых — жребий и измерение времени.
Как устроена игра
Ключевая мысль: плата не умеет измерять время напрямую. Она просто дважды смотрит на свои внутренние часы — millis() из одиннадцатого урока — и вычитает одно число из другого. Разница и есть ваша реакция.
Собираем схему
Ничего нового: зуммер из девятнадцатого урока, кнопка из одиннадцатого, светодиод из пятого. Обратите внимание — питание 5 В на макетке в этот раз не нужно совсем: и кнопка, и светодиод, и зуммер работают от выводов платы, а общей им нужна только земля.
Отключите USB перед сборкой. И не забудьте: у зуммера есть «плюс», а у светодиода — длинная ножка. Обе детали не любят, когда их переворачивают.
Главная ловушка кнопки. Два вывода с одной её стороны соединены всегда — про это был десятый урок. Поэтому оба провода нельзя брать с одной стороны канавки: сигнальный уходит на плату сверху, а земляной — снизу. Если подключить оба сверху, плата будет считать кнопку нажатой постоянно, и игра закончится, не начавшись.
Проверьте перед включением: «плюс» зуммера идёт в столбик 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, плата всё забудет: переменные хранятся в оперативной памяти. Чтобы рекорд пережил выключение, его надо записать в постоянную память платы — это отдельная тема, до неё мы ещё доберёмся.
Какие результаты бывают
Если у вас получилось меньше 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 миллисекунд означает, что кнопку нажали наугад: сигнал просто не успел дойти до глаз и мозга.
Почему рекорд пропадает после выключения?
Переменные живут в оперативной памяти, а она очищается при отключении питания. Чтобы рекорд сохранялся, его нужно записывать в постоянную память платы — это отдельная тема.
