Курс Arduino с нуля · Большие проекты
Урок 30. Проект: игра «Повтори за мной»
Плата становится ведущим: сама сочиняет мелодию, показывает её нотами и цифрами, а вы повторяете кнопками. Уровни растут, темп ускоряется, рекорд — на экране.
Что понадобится
- Плата Uno из набора
- Кабель USB
- Макетная плата
- Экран LCD1602
- Потенциометр 10 кОм
- 3 тактовые кнопки
- Зуммер
- 24 провода «папа-папа»
Все детали есть в обучающем наборе, по которому идёт курс.
Полвека назад по миру разошлась игрушка «Саймон»: четыре цветные кнопки, которые загораются в случайном порядке, а игрок повторяет последовательность, пока хватает памяти. Сегодня соберём свою версию — «Повтори за мной». Плата впервые побудет не исполнителем, а ведущим: сама придумает задание, сама покажет его и сама рассудит, справились вы или нет.
В брошюре набора эта игра тоже есть — глава 17, там она собрана на трёх светодиодах. Нам светодиоды ставить некуда, да и незачем: на макетке уже стоит экран, он и «подсветит» кнопки, и заодно покажет уровень и рекорд. Из нового железа — только зуммер из урока 19.
Что построим
Правила простые:
- у каждой кнопки — своя нота и своё место на экране: левая, середина, правая;
- плата проигрывает мелодию, а экран подсказывает глазами: на каком месте цифра — ту кнопку и жать;
- повторили без ошибок — мелодия становится на один шаг длиннее и чуть быстрее;
- ошиблись — грустный звук, счёт и рекорд на экране, новая мелодия и снова первый уровень;
- дожали до шестнадцатого уровня — победные фанфары.
Как плата запоминает мелодию
Это главный секрет программы. Кажется, что игра «добавляет ноту» на каждом уровне, но на самом деле вся мелодия из шестнадцати шагов придумана заранее и записана в массив — как секретный код в сейфе из урока 29. Уровень — это просто число: сколько первых клеток массива играем и спрашиваем.
Придумывает мелодию команда random — мы звали её в уроке 21. У неё есть подвох, о котором честно пишет и брошюра набора: случайные числа не случайны. После каждого включения плата выдаёт одну и ту же «случайную» цепочку — запомнил мелодию один раз и выигрывай вечно. Лекарство то же, что в уроке 21: перед игрой «завести» случайность шумом свободного входа — randomSeed(analogRead(A0)).
Кто на каком выводе
Сейф из урока 29 не разбираем — из него уйдёт только сервопривод: выньте три его провода, засов игре не нужен. Кнопки остаются на выводах 7, 8 и 10, экран — на своих шести. Зуммер садится на вывод 6 — если вы делали задание со звёздочкой в уроке 29, у вас всё уже стоит.
Полный список: экран — 12, 11, 5, 4, 3, 2; кнопки — 7, 8, 10; зуммер — 6. Свободны 9 и 13. Вывод A0 остаётся пустым нарочно — его шум кормит генератор случайностей.
Собираем схему
- Отключите сервопривод: выньте его оранжевый, красный и коричневый провода. Больше ничего из урока 29 не трогайте.
- Вставьте зуммер между кнопкой 1 и проводами экрана: ножка со знаком «+» на крышке — в столбик 13 ряда a, вторая — в столбик 10. Корпус повиснет над шинами — их отверстия под ним просто не используем.
- Сигнальный провод: из столбика 13 (любое свободное отверстие рядов b–e) на вывод 6 платы.
- Минус: из столбика 10 на верхнюю шину «−» — по дорожке налево, в самое первое отверстие шины у левого края макетки.
Проверьте перед включением: знак «+» зуммера смотрит в сторону столбика 13, и именно из этого столбика провод идёт на вывод 6; минус из столбика 10 сидит в шине «−»; кнопки и экран не тронуты — при сомнении сверьтесь со схемой урока 29.
Шаг 1. Каждой кнопке — голос и место
Начинаем, как всегда, с проверки железа. Соберём мини-пианино из трёх клавиш: нажатие играет ноту и показывает цифру на «своём» краю экрана:
// Урок 30. Шаг 1: у каждой кнопки — свой голос и своё место на экране.
#include <LiquidCrystal.h>
LiquidCrystal lcd(12, 11, 5, 4, 3, 2);
const int BUTTONS[3] = {7, 8, 10};
const int NOTES[3] = {523, 659, 784}; // до, ми и соль второй октавы
const int BUZZER_PIN = 6;
// Где на экране живёт каждая кнопка: левая, середина, правая.
const int SPOTS[3] = {2, 7, 12};
void setup() {
lcd.begin(16, 2);
for (int i = 0; i < 3; i++) {
pinMode(BUTTONS[i], INPUT_PULLUP);
}
lcd.print("Tri knopki -");
lcd.setCursor(0, 1);
lcd.print("tri noty");
}
void loop() {
for (int i = 0; i < 3; i++) {
if (digitalRead(BUTTONS[i]) == LOW) {
playKey(i + 1);
}
}
}
// Сыграть кнопку: цифра на своём месте экрана плюс её нота.
void playKey(int number) {
lcd.clear();
lcd.setCursor(SPOTS[number - 1], 0);
lcd.print(number);
tone(BUZZER_PIN, NOTES[number - 1], 300);
delay(360); // нота 300 мс и короткая тишина после неё
lcd.clear();
}
- частоты 523, 659 и 784 — это до, ми и соль из таблицы нот урока 19: три ноты, которые звучат дружно в любом порядке;
- массив с позициями решает, где рисовать цифру: кнопка 1 — слева, 2 — в середине, 3 — справа. Экран повторяет географию кнопок на макетке;
- нота длится 300 миллисекунд, и после неё выдержана короткая тишина — правило из урока 19, чтобы звуки не слипались.
Прошейте и понажимайте: каждая кнопка должна отвечать своей нотой, а цифра — появляться на своей стороне. Если что-то молчит — раздел «Не работает?» ниже.
Шаг 2. Плата придумывает мелодию
Теперь пусть ведущий сочинит задание. Заводим массив на шестнадцать шагов и заполняем его случайными номерами кнопок:
// Урок 30. Шаг 2: плата придумывает мелодию и показывает её.
#include <LiquidCrystal.h>
LiquidCrystal lcd(12, 11, 5, 4, 3, 2);
const int BUTTONS[3] = {7, 8, 10};
const int NOTES[3] = {523, 659, 784};
const int BUZZER_PIN = 6;
const int SPOTS[3] = {2, 7, 12};
const int SEQ_MAX = 16; // длиннее мелодия не вырастет
int seq[SEQ_MAX]; // сама мелодия: номера кнопок по порядку
void setup() {
lcd.begin(16, 2);
// Уберите эту строку — и после каждого включения мелодия
// будет одна и та же. Случайность надо «завести» шумом.
randomSeed(analogRead(A0));
for (int i = 0; i < SEQ_MAX; i++) {
seq[i] = random(1, 4); // случайное 1, 2 или 3
}
}
void loop() {
lcd.clear();
lcd.print("Smotri:");
delay(800);
for (int i = 0; i < 5; i++) { // пока показываем первые пять шагов
playKey(seq[i]);
}
delay(2000); // передышка — и снова с начала
}
void playKey(int number) {
lcd.setCursor(SPOTS[number - 1], 1);
lcd.print(number);
tone(BUZZER_PIN, NOTES[number - 1], 300);
delay(360);
lcd.setCursor(SPOTS[number - 1], 1);
lcd.print(" "); // стереть цифру, чтобы шаги не сливались
}
random(1, 4)выдаёт 1, 2 или 3 — правая граница, как всегда у random, не входит;- цифры показа рисуются во второй строке и стираются после каждой ноты — иначе два одинаковых шага подряд слились бы в один;
- пока программа просто крутит первые пять шагов по кругу — послушайте, как звучит её «почерк».
Ради опыта закомментируйте строку с randomSeed двумя косыми чертами, прошейте и перезапустите плату несколько раз кнопкой RESET: мелодия будет одна и та же. Верните строку — теперь каждый запуск звучит по-новому.
Шаг 3. Повтори и проверь — сразу
Подключаем игрока. Плата показывает четыре шага, потом ждёт четыре нажатия. И вот отличие от сейфа из урока 29: там мы копили весь код и сравнивали в конце, а здесь каждое нажатие проверяется немедленно — зачем слушать дальше, если игрок уже ошибся?
// Урок 30. Шаг 3: четыре шага — и проверка каждого нажатия сразу.
#include <LiquidCrystal.h>
LiquidCrystal lcd(12, 11, 5, 4, 3, 2);
const int BUTTONS[3] = {7, 8, 10};
const int NOTES[3] = {523, 659, 784};
const int BUZZER_PIN = 6;
const int SPOTS[3] = {2, 7, 12};
const int SEQ_MAX = 16;
int seq[SEQ_MAX];
void setup() {
lcd.begin(16, 2);
for (int i = 0; i < 3; i++) {
pinMode(BUTTONS[i], INPUT_PULLUP);
}
randomSeed(analogRead(A0));
for (int i = 0; i < SEQ_MAX; i++) {
seq[i] = random(1, 4);
}
}
void loop() {
// Показ задания.
lcd.clear();
lcd.print("Smotri:");
delay(800);
for (int i = 0; i < 4; i++) {
playKey(seq[i]);
}
// Ответ игрока.
lcd.clear();
lcd.print("Tvoy hod!");
bool allRight = true;
for (int i = 0; i < 4; i++) {
int pressed = waitKey();
playKey(pressed); // игра отзывается нотой на нажатие
if (pressed != seq[i]) { // сверяем сразу, не дожидаясь конца
allRight = false;
break; // дальше слушать незачем
}
}
lcd.clear();
if (allRight) {
lcd.print("Verno!");
tone(BUZZER_PIN, 659, 120);
delay(150);
tone(BUZZER_PIN, 784, 250);
} else {
lcd.print("Oshibka!");
tone(BUZZER_PIN, 300, 250);
delay(280);
tone(BUZZER_PIN, 200, 500);
}
delay(2000);
}
// Стоять на месте, пока игрок не нажмёт кнопку. Вся работа — ждать,
// поэтому блокирующее ожидание здесь уместно, как в уроке 21.
int waitKey() {
while (true) {
for (int i = 0; i < 3; i++) {
if (digitalRead(BUTTONS[i]) == LOW) {
delay(30); // дребезг нажатия
while (digitalRead(BUTTONS[i]) == LOW) {} // ждём отпускания
delay(30); // дребезг отпускания
return i + 1;
}
}
}
}
void playKey(int number) {
lcd.setCursor(SPOTS[number - 1], 1);
lcd.print(number);
tone(BUZZER_PIN, NOTES[number - 1], 300);
delay(360);
lcd.setCursor(SPOTS[number - 1], 1);
lcd.print(" ");
}
- новая функция ждёт нажатия и возвращает номер кнопки; внутри — вечный цикл, и это законно: пока игрок думает, у платы правда нет других дел (урок 21);
- после нажатия программа дожидается, пока кнопку отпустят, — иначе одно долгое нажатие засчиталось бы за несколько;
- на каждое нажатие игра отзывается нотой и цифрой — как настоящий «Саймон»;
breakобрывает проверку на первой же ошибке — приём из урока 29, только там он жил внутри функции-судьи.
Шаг 4. Настоящая игра: уровни, темп, рекорд
Осталось превратить упражнение в игру. Уровень — это сколько шагов мелодии играем; после каждого успеха он растёт на один. А чтобы было веселее, с ростом уровня ноты становятся короче — игра ускоряется:
// Урок 30. Шаг 4: настоящая игра — мелодия растёт, темп ускоряется.
#include <LiquidCrystal.h>
LiquidCrystal lcd(12, 11, 5, 4, 3, 2);
const int BUTTONS[3] = {7, 8, 10};
const int NOTES[3] = {523, 659, 784};
const int BUZZER_PIN = 6;
const int SPOTS[3] = {2, 7, 12};
const int SEQ_MAX = 16; // столько уровней до полной победы
int seq[SEQ_MAX];
int level = 1; // сколько шагов играем сейчас
int record = 0; // лучший уровень с момента включения
void setup() {
lcd.begin(16, 2);
for (int i = 0; i < 3; i++) {
pinMode(BUTTONS[i], INPUT_PULLUP);
}
randomSeed(analogRead(A0));
newMelody();
}
void loop() {
// Показ: первые level шагов мелодии.
lcd.clear();
lcd.print("Uroven ");
lcd.print(level);
lcd.setCursor(0, 1);
lcd.print("Smotri...");
delay(900);
lcd.setCursor(0, 1);
lcd.print(" ");
for (int i = 0; i < level; i++) {
playKey(seq[i]);
}
// Ответ: столько же нажатий, проверка на лету.
lcd.setCursor(0, 0);
lcd.print("Tvoy hod! ");
for (int i = 0; i < level; i++) {
int pressed = waitKey();
playKey(pressed);
if (pressed != seq[i]) {
gameOver();
return; // начать loop заново — уже с первого уровня
}
}
// Уровень пройден.
if (level == SEQ_MAX) {
lcd.clear();
lcd.print("POBEDA!");
for (int f = 400; f <= 1200; f = f + 200) { // фанфары вверх
tone(BUZZER_PIN, f, 120);
delay(140);
}
delay(2000);
if (level > record) {
record = level;
}
level = 1;
newMelody();
} else {
level = level + 1;
delay(600);
}
}
void gameOver() {
tone(BUZZER_PIN, 300, 250);
delay(280);
tone(BUZZER_PIN, 200, 500);
if (level > record) {
record = level; // запомнили, докуда добрались
}
lcd.clear();
lcd.print("Oshibka na ");
lcd.print(level);
lcd.setCursor(0, 1);
lcd.print("Rekord: ");
lcd.print(record);
delay(3000);
level = 1;
newMelody(); // новая мелодия: прежнюю подсмотреть не выйдет
}
void newMelody() {
for (int i = 0; i < SEQ_MAX; i++) {
seq[i] = random(1, 4);
}
}
// Чем выше уровень, тем короче ноты — игра ускоряется.
int noteTime() {
int len = 320 - level * 12;
if (len < 140) {
len = 140;
}
return len;
}
int waitKey() {
while (true) {
for (int i = 0; i < 3; i++) {
if (digitalRead(BUTTONS[i]) == LOW) {
delay(30);
while (digitalRead(BUTTONS[i]) == LOW) {}
delay(30);
return i + 1;
}
}
}
}
void playKey(int number) {
lcd.setCursor(SPOTS[number - 1], 1);
lcd.print(number);
tone(BUZZER_PIN, NOTES[number - 1], noteTime());
delay(noteTime() + noteTime() / 5);
lcd.setCursor(SPOTS[number - 1], 1);
lcd.print(" ");
}
Что здесь важного:
- показ и опрос используют одну и ту же переменную-уровень — сколько показали, столько и спросили;
- длину ноты считает отдельная функция: триста двадцать миллисекунд минус двенадцать за каждый уровень, но не короче ста сорока — на высоких уровнях игра заметно злеет;
- после проигрыша мелодия придумывается заново — иначе можно было бы выучить старую по кусочку за попытку;
- рекорд живёт до выключения питания, как в игре на реакцию из урока 21.
Прошейте и сыграйте всей семьёй. Уровень седьмой-восьмой — уже серьёзный результат: у обычного человека в «оперативной памяти» помещается около семи вещей.
Не работает? Разбираемся
- Нет звука. Проверьте зуммер: «+» должен смотреть к столбику 13, сигнал — уходить со столбика 13 на вывод 6, минус — со столбика 10 в шину «−». Перепутанные столбики — самая частая причина тишины.
- Мелодия после каждого включения одна и та же. Потерялась строка с randomSeed — верните её в начало программы.
- Одно нажатие засчитывается за два. Дребезг сильнее обычного: увеличьте обе паузы в функции ожидания с 30 до 50–80 миллисекунд.
- Экран пустой или квадраты. Контраст: медленно покрутите потенциометр, как в уроке 26.
- Цифры появляются не там, где нажимаете. Кнопки перепутаны местами: левая должна сидеть на выводе 7, средняя на 8, правая на 10.
- Игра «нажимает» кнопки сама. Какая-то кнопка повёрнута на 90 градусов и замкнута всегда — пересадите её ножками вверх-вниз, как в уроке 10.
- Звук есть, но хриплый и тихий. Зуммер сел ножками не до конца — прижмите его, чтобы ножки ушли в отверстия полностью.
Задания для самостоятельной работы
- Задание 1. Поменяйте голоса: возьмите частоты нот из другой октавы — таблица была в уроке 19. Игра сразу зазвучит иначе.
- Задание 2. Настройте сложность под игрока: для младших сделайте победу на восьмом уровне, для чемпионов — на двадцатом. Меняется одна константа.
- Задание со звёздочкой ⭐. Сейчас плата ждёт нажатия вечно — можно уйти пить чай и вернуться. Сделайте по-турнирному: на каждое нажатие даётся пять секунд, не успели — проигрыш с надписью «Ne uspel!». Пригодятся часы millis из урока 11.
Разбор задания со звёздочкой
Вся хитрость — в функции ожидания. Раньше её цикл крутился вечно, теперь он смотрит на часы: прошло больше пяти секунд — возвращаем ноль. Ноль не может быть номером кнопки, поэтому он отлично работает «особым сигналом» — как минус один в пианино из урока 20. Осталось поймать этот ноль в игре и показать «Ne uspel!».
// Урок 30. Разбор задания: думать можно не дольше пяти секунд.
#include <LiquidCrystal.h>
LiquidCrystal lcd(12, 11, 5, 4, 3, 2);
const int BUTTONS[3] = {7, 8, 10};
const int NOTES[3] = {523, 659, 784};
const int BUZZER_PIN = 6;
const int SPOTS[3] = {2, 7, 12};
const int SEQ_MAX = 16;
const unsigned long ANSWER_LIMIT = 5000; // пять секунд на одно нажатие
int seq[SEQ_MAX];
int level = 1;
int record = 0;
void setup() {
lcd.begin(16, 2);
for (int i = 0; i < 3; i++) {
pinMode(BUTTONS[i], INPUT_PULLUP);
}
randomSeed(analogRead(A0));
newMelody();
}
void loop() {
lcd.clear();
lcd.print("Uroven ");
lcd.print(level);
lcd.setCursor(0, 1);
lcd.print("Smotri...");
delay(900);
lcd.setCursor(0, 1);
lcd.print(" ");
for (int i = 0; i < level; i++) {
playKey(seq[i]);
}
lcd.setCursor(0, 0);
lcd.print("Tvoy hod! ");
for (int i = 0; i < level; i++) {
int pressed = waitKey();
if (pressed == 0) { // ноль — значит, время вышло
lcd.clear();
lcd.print("Ne uspel!");
delay(1200);
gameOver();
return;
}
playKey(pressed);
if (pressed != seq[i]) {
gameOver();
return;
}
}
if (level == SEQ_MAX) {
lcd.clear();
lcd.print("POBEDA!");
for (int f = 400; f <= 1200; f = f + 200) {
tone(BUZZER_PIN, f, 120);
delay(140);
}
delay(2000);
if (level > record) {
record = level;
}
level = 1;
newMelody();
} else {
level = level + 1;
delay(600);
}
}
// Ждём нажатия, но не вечно: часы millis отсчитывают лимит.
int waitKey() {
unsigned long start = millis();
while (millis() - start < ANSWER_LIMIT) {
for (int i = 0; i < 3; i++) {
if (digitalRead(BUTTONS[i]) == LOW) {
delay(30);
while (digitalRead(BUTTONS[i]) == LOW) {}
delay(30);
return i + 1;
}
}
}
return 0; // особое значение: «никто не нажал»
}
void gameOver() {
tone(BUZZER_PIN, 300, 250);
delay(280);
tone(BUZZER_PIN, 200, 500);
if (level > record) {
record = level;
}
lcd.clear();
lcd.print("Oshibka na ");
lcd.print(level);
lcd.setCursor(0, 1);
lcd.print("Rekord: ");
lcd.print(record);
delay(3000);
level = 1;
newMelody();
}
void newMelody() {
for (int i = 0; i < SEQ_MAX; i++) {
seq[i] = random(1, 4);
}
}
int noteTime() {
int len = 320 - level * 12;
if (len < 140) {
len = 140;
}
return len;
}
void playKey(int number) {
lcd.setCursor(SPOTS[number - 1], 1);
lcd.print(number);
tone(BUZZER_PIN, NOTES[number - 1], noteTime());
delay(noteTime() + noteTime() / 5);
lcd.setCursor(SPOTS[number - 1], 1);
lcd.print(" ");
}
Обратите внимание: лимит объявлен константой с типом unsigned long — с миллисекундами мы всегда работаем в этом типе, привычка из урока 11.
Что мы узнали
- плата может быть ведущим: сама придумывает задание и сама судит ответ;
- случайные числа не случайны — генератор надо заводить шумом свободного входа;
- растущая сложность — это не новые данные, а число «сколько шагов открыто»;
- проверять ответ можно на лету, не дожидаясь конца, — в отличие от сейфа;
- ожидание с лимитом времени строится на millis и «особом» возвращаемом значении;
- один и тот же набор деталей — сейф вчера, игра сегодня: разницу делает программа.
Остался последний урок курса. В нём не будет готовой схемы и готовой программы — вы сами придумаете устройство и пройдёте весь путь: от идеи на бумажке до работающего прибора. Все инструменты для этого у вас уже есть.
Частые вопросы
Как сделать игру Саймон на Arduino без светодиодов?
Подсветку кнопок заменяет экран LCD1602: у каждой кнопки своё место на экране — левая, середина, правая, — и во время показа мелодии цифра появляется на нужной стороне. Заодно экран показывает уровень и рекорд, чего светодиоды не умеют.
Почему random выдаёт одну и ту же последовательность?
Генератор случайных чисел Arduino после включения всегда начинает с одного места — числа выходят «случайные», но одинаковые. Лекарство — строка randomSeed(analogRead(A0)) в начале программы: шум свободного входа A0 каждый раз заводит генератор по-новому.
Сколько уровней в игре и как это поменять?
В нашей версии полная победа — шестнадцать уровней, длина мелодии задана одной константой. Поставьте восемь для младших игроков или двадцать для чемпионов — программа пересчитает всё сама.
Чем проверка ответа отличается от сейфа из урока 29?
Сейф сначала копил весь код и сравнивал его с секретом в конце. Игра проверяет каждое нажатие сразу: как только игрок ошибся, слушать дальше незачем — звучит грустный сигнал и раунд заканчивается.
Можно ли добавить в игру светодиоды, как в брошюре набора?
Да: после урока 29 свободны выводы 9 и 13. На два светодиода хватит прямо так, а если снять экран, освободятся ещё шесть выводов — тогда получится собрать игру ровно по брошюре, с подсветкой возле каждой кнопки.
