Продвинутый курс Arduino · Большие проекты 2
Урок 8. Проект: игровая приставка
Финал курса: меню на экране, три игры — «Реакция», «Повтори» и новый «Бегун» с прыжками через кактусы — и таблица рекордов, которую не стирает даже выключение питания.
Что понадобится
- Контроллер Uno и кабель USB
- Макетная плата
- Экран LCD1602 и потенциометр 10 кОм (контраст)
- 3 кнопки
- Зуммер
- Провода
Все детали есть в обучающем наборе, по которому идёт курс.
Финал продвинутого курса! Схема остаётся от часов-будильника — экран, три кнопки, зуммер, — а собираем мы из неё игровую приставку: меню с выбором игры, три игры и таблица рекордов, которую не сотрёт даже выключение. Две игры вы уже писали в базовом курсе — «Реакция» и «Повтори за мной», — они переезжают на экран и становятся лучше. Третья — новинка: «Бегун», где человечек прыгает через кактусы, как в браузере без интернета.
Урок построен как настоящая разработка: сначала отлаживаем игры по отдельности, потом собираем в один корпус с меню. Это и есть главная тема финала: большая программа — это маленькие, уже проверенные кубики.
Шаг 1. «Реакция» переезжает на экран
В двадцать первом уроке игра мигала светодиодом, а результат печатала в монитор. Теперь всё на экране, играется средней кнопкой, а рекорд… рекорд отправляется в вечную память — и заметьте, здесь «лучше» значит «меньше»: 200 миллисекунд круче трёхсот.
// Продвинутый курс, урок 8. Шаг 1: «Реакция» переезжает на экран.
#include <LiquidCrystal.h>
#include <EEPROM.h>
LiquidCrystal lcd(12, 11, 5, 4, 3, 2);
const int BUTTONS[3] = {7, 8, 10};
const int BUZZER = 6;
const int ADDR_RECORD = 60; // ящики 60–61: лучший результат в мс
void setup() {
lcd.begin(16, 2);
for (int i = 0; i < 3; i++) {
pinMode(BUTTONS[i], INPUT_PULLUP);
}
randomSeed(analogRead(A0));
}
void loop() {
int score = playReaction();
int record;
EEPROM.get(ADDR_RECORD, record);
if (record == -1 || (score > 0 && score < record)) {
record = score; // новый рекорд — меньше значит лучше
EEPROM.put(ADDR_RECORD, record);
}
lcd.clear();
lcd.print("Vremya: ");
lcd.print(score);
lcd.print(" ms");
lcd.setCursor(0, 1);
lcd.print("Rekord: ");
lcd.print(record);
lcd.print(" ms");
delay(2500);
}
// Одна партия: вернуть время реакции в мс (0 — фальстарт).
int playReaction() {
lcd.clear();
lcd.print("Zhdi signala...");
unsigned long pause = random(2000, 5000);
unsigned long start = millis();
while (millis() - start < pause) {
if (digitalRead(BUTTONS[1]) == LOW) { // не выдержал!
lcd.clear();
lcd.print("FALSTART!");
tone(BUZZER, 200, 400);
delay(1500);
return 0;
}
}
lcd.clear();
lcd.print("ZHMI!!!");
tone(BUZZER, 880, 120);
unsigned long signal = millis();
while (digitalRead(BUTTONS[1]) == HIGH) {} // ждём нажатия средней кнопки
return millis() - signal;
}
Игра оформлена функцией playReaction(), которая возвращает результат, — и это не случайность, а заготовка под меню: приставке будет удобно вызывать игры именно так. Проверка рекорда обрабатывает и «пустую» память (знакомое −1), и фальстарт: ноль рекордом не считается.
Шаг 2. Новинка: «Бегун»
Экран 16 на 2 — чем не игровое поле? Нижняя строка — дорога, верхняя — небо. Человечек и кактус — свои символы из точек, тем же createChar, каким метеостанция рисовала термометр и каплю.
// Продвинутый курс, урок 8. Шаг 2: «Бегун» — прыгай через кактусы!
#include <LiquidCrystal.h>
LiquidCrystal lcd(12, 11, 5, 4, 3, 2);
const int BTN_JUMP = 8; // средняя кнопка — прыжок
const int BUZZER = 6;
// Свои символы: человечек и кактус, по точкам — как значки метеостанции.
byte RUNNER[8] = {
B01110, B01110, B00100, B11111,
B00100, B00100, B01010, B10001
};
byte CACTUS[8] = {
B00100, B00101, B10101, B10101,
B10110, B01100, B00100, B00100
};
const int PLAYER_COL = 2; // человечек всегда во втором знакоместе
int cactusCol = 15; // где сейчас кактус (справа налево)
int score = 0;
int stepMs = 320; // пауза между сдвигами: меньше — быстрее
unsigned long lastStep = 0;
unsigned long jumpUntil = 0; // до какого момента человечек в воздухе
void setup() {
lcd.begin(16, 2);
pinMode(BTN_JUMP, INPUT_PULLUP);
lcd.createChar(0, RUNNER);
lcd.createChar(1, CACTUS);
lcd.setCursor(0, 0); // после createChar курсор обязателен — урок 26!
}
void loop() {
// Прыжок: запоминаем, до какого времени лететь.
if (digitalRead(BTN_JUMP) == LOW && millis() > jumpUntil) {
jumpUntil = millis() + 3L * stepMs; // полёт длится три шага игры
tone(BUZZER, 660, 60);
}
if (millis() - lastStep >= stepMs) {
lastStep = millis();
cactusCol = cactusCol - 1;
if (cactusCol < 0) {
cactusCol = 15;
score = score + 1;
stepMs = stepMs - 8; // каждый кактус чуть ускоряет игру
if (stepMs < 140) {
stepMs = 140;
}
}
bool flying = (millis() < jumpUntil);
// Столкновение: кактус в колонке человечка, а тот не в воздухе.
if (cactusCol == PLAYER_COL && !flying) {
gameOver();
return;
}
drawField(flying);
}
}
void drawField(bool flying) {
lcd.clear();
if (flying) {
lcd.setCursor(PLAYER_COL, 0); // в прыжке человечек на верхней строке
} else {
lcd.setCursor(PLAYER_COL, 1);
}
lcd.write((byte)0);
lcd.setCursor(cactusCol, 1);
lcd.write((byte)1);
lcd.setCursor(13, 0);
lcd.print(score);
}
void gameOver() {
lcd.clear();
lcd.print("Oy! Schyot: ");
lcd.print(score);
lcd.setCursor(0, 1);
lcd.print("Knopka = zanovo");
tone(BUZZER, 180, 600);
while (digitalRead(BTN_JUMP) == HIGH) {} // ждём кнопку
delay(300);
cactusCol = 15;
score = 0;
stepMs = 320;
jumpUntil = 0;
}
Присмотритесь, как мало нужно для целой игры. Вся «физика» — два числа: cactusCol, колонка кактуса, и jumpUntil, момент окончания прыжка. Раз в stepMs миллисекунд мир сдвигается на шаг; кактус, дошедший до нуля, приносит очко и рождается справа заново, а игра ускоряется — знакомый по «Повтори» приём. Столкновение — одно условие: кактус в колонке человечка, а человечек не в воздухе.
Поиграйте! Средняя кнопка — прыжок. Полёт длится ровно три шага игры, поэтому прыгать надо не когда кактус рядом, а чуть заранее — как в настоящих раннерах.
Шаг 3. Собираем приставку
Пришло время большой сборки. Каждая игра — функция, возвращающая результат: playReaction(), playSimon(), playRunner(). Меню листает названия, запускает выбранную, показывает счёт и сверяет его с рекордом. Это самый длинный скетч курса — и, честное слово, самый простой из длинных: вы узнаете в нём каждый кусок.
// Продвинутый курс, урок 8. Шаг 3: приставка целиком.
// Меню на экране, три игры и рекорды, которые не сгорают. Кнопки в меню:
// 1 и 3 — листать, 2 — играть. Это самый большой скетч курса — и весь он
// собран из кусочков, которые вы уже писали своими руками.
#include <LiquidCrystal.h>
#include <EEPROM.h>
LiquidCrystal lcd(12, 11, 5, 4, 3, 2);
const int BTN_LEFT = 7;
const int BTN_OK = 8;
const int BTN_RIGHT = 10;
const int BUZZER = 6;
// Карта вечной памяти приставки.
const int ADDR_REACTION = 60; // лучшее время реакции, мс (меньше = лучше)
const int ADDR_SIMON = 62; // лучший уровень «Повтори» (больше = лучше)
const int ADDR_RUNNER = 64; // лучший счёт «Бегуна» (больше = лучше)
const int GAMES = 3;
int currentGame = 0;
bool wasPressed[3] = {false, false, false};
byte RUNNER_ICON[8] = {
B01110, B01110, B00100, B11111,
B00100, B00100, B01010, B10001
};
byte CACTUS_ICON[8] = {
B00100, B00101, B10101, B10101,
B10110, B01100, B00100, B00100
};
void setup() {
lcd.begin(16, 2);
pinMode(BTN_LEFT, INPUT_PULLUP);
pinMode(BTN_OK, INPUT_PULLUP);
pinMode(BTN_RIGHT, INPUT_PULLUP);
randomSeed(analogRead(A0));
lcd.createChar(0, RUNNER_ICON);
lcd.createChar(1, CACTUS_ICON);
drawMenu();
}
bool clicked(int pin, int index) {
bool now = (digitalRead(pin) == LOW);
bool click = now && !wasPressed[index];
wasPressed[index] = now;
if (click) {
delay(30);
}
return click;
}
void loop() {
if (clicked(BTN_LEFT, 0)) {
currentGame = currentGame - 1;
if (currentGame < 0) {
currentGame = GAMES - 1;
}
drawMenu();
}
if (clicked(BTN_RIGHT, 2)) {
currentGame = currentGame + 1;
if (currentGame >= GAMES) {
currentGame = 0;
}
drawMenu();
}
if (clicked(BTN_OK, 1)) {
playCurrent();
drawMenu();
}
}
void drawMenu() {
lcd.clear();
if (currentGame == 0) {
lcd.print("< Reakciya >");
printRecordLine(ADDR_REACTION, " ms");
} else if (currentGame == 1) {
lcd.print("< Povtori >");
printRecordLine(ADDR_SIMON, " ur.");
} else {
lcd.print("< Begun >");
printRecordLine(ADDR_RUNNER, "");
}
}
// Строка рекорда в меню — из вечной памяти.
void printRecordLine(int addr, const char* units) {
int record;
EEPROM.get(addr, record);
lcd.setCursor(0, 1);
if (record == -1) {
lcd.print("Rekorda net");
return;
}
lcd.print("Rekord: ");
lcd.print(record);
lcd.print(units);
}
// Обновить рекорд в вечной памяти, если результат его побил.
void updateRecord(int addr, int score, bool lessIsBetter) {
int record;
EEPROM.get(addr, record);
bool better = false;
if (record == -1) {
better = (score > 0);
} else if (lessIsBetter) {
better = (score > 0 && score < record);
} else {
better = (score > record);
}
if (better) {
EEPROM.put(addr, score);
lcd.setCursor(0, 1);
lcd.print("NOVYY REKORD! ");
tone(BUZZER, 784, 300);
delay(1500);
}
}
void playCurrent() {
if (currentGame == 0) {
int ms = playReaction();
showScore("Vremya: ", ms, " ms");
updateRecord(ADDR_REACTION, ms, true);
} else if (currentGame == 1) {
int level = playSimon();
showScore("Doshli do: ", level, " ur.");
updateRecord(ADDR_SIMON, level, false);
} else {
int score = playRunner();
showScore("Schyot: ", score, "");
updateRecord(ADDR_RUNNER, score, false);
}
delay(1200);
}
void showScore(const char* label, int value, const char* units) {
lcd.clear();
lcd.print(label);
lcd.print(value);
lcd.print(units);
delay(1200);
}
/* ---------- Игра 1: реакция (из урока 21, теперь на экране) ---------- */
int playReaction() {
lcd.clear();
lcd.print("Zhdi signala...");
unsigned long pause = random(2000, 5000);
unsigned long start = millis();
while (millis() - start < pause) {
if (digitalRead(BTN_OK) == LOW) {
lcd.clear();
lcd.print("FALSTART!");
tone(BUZZER, 200, 400);
delay(1200);
return 0;
}
}
lcd.clear();
lcd.print("ZHMI!!!");
tone(BUZZER, 880, 120);
unsigned long signal = millis();
while (digitalRead(BTN_OK) == HIGH) {}
delay(30);
return millis() - signal;
}
/* ---------- Игра 2: «Повтори за мной» (из урока 30) ---------- */
const int SPOTS[3] = {2, 7, 12}; // место каждой кнопки на экране
const int NOTES[3] = {523, 659, 784};
int playSimon() {
int seq[16];
for (int i = 0; i < 16; i++) {
seq[i] = random(0, 3); // мелодия придумывается целиком
}
int level = 1;
while (level <= 16) {
// Показ: первые level шагов.
lcd.clear();
lcd.print("Smotri...");
delay(700);
for (int i = 0; i < level; i++) {
lcd.clear();
lcd.setCursor(SPOTS[seq[i]], 1);
lcd.print("*");
tone(BUZZER, NOTES[seq[i]], 250);
delay(420);
lcd.clear();
delay(120);
}
// Ответ: ловим нажатия и проверяем на лету, как в уроке 30.
lcd.clear();
lcd.print("Povtori!");
for (int i = 0; i < level; i++) {
int key = waitAnyKey();
if (key < 0) {
return level - 1; // слишком долго думали
}
lcd.setCursor(SPOTS[key], 1);
lcd.print("*");
tone(BUZZER, NOTES[key], 200);
if (key != seq[i]) {
lcd.clear();
lcd.print("Oshibka!");
tone(BUZZER, 180, 600);
delay(1200);
return level - 1;
}
delay(250);
lcd.setCursor(SPOTS[key], 1);
lcd.print(" ");
}
level = level + 1;
delay(500);
}
return 16;
}
// Ждать любую из трёх кнопок 5 секунд; вернуть её номер или −1.
int waitAnyKey() {
const int PINS[3] = {BTN_LEFT, BTN_OK, BTN_RIGHT};
unsigned long start = millis();
while (millis() - start < 5000) {
for (int i = 0; i < 3; i++) {
if (digitalRead(PINS[i]) == LOW) {
delay(30);
while (digitalRead(PINS[i]) == LOW) {}
delay(30);
return i;
}
}
}
return -1;
}
/* ---------- Игра 3: «Бегун» (новинка этого урока) ---------- */
int playRunner() {
int cactusCol = 15;
int score = 0;
int stepMs = 320;
unsigned long lastStep = millis();
unsigned long jumpUntil = 0;
lcd.clear();
while (true) {
if (digitalRead(BTN_OK) == LOW && millis() > jumpUntil) {
jumpUntil = millis() + 3L * stepMs;
tone(BUZZER, 660, 60);
}
if (millis() - lastStep >= stepMs) {
lastStep = millis();
cactusCol = cactusCol - 1;
if (cactusCol < 0) {
cactusCol = 15;
score = score + 1;
stepMs = stepMs - 8;
if (stepMs < 140) {
stepMs = 140;
}
}
bool flying = (millis() < jumpUntil);
if (cactusCol == 2 && !flying) {
tone(BUZZER, 180, 600);
delay(800);
return score;
}
lcd.clear();
if (flying) {
lcd.setCursor(2, 0);
} else {
lcd.setCursor(2, 1);
}
lcd.write((byte)0);
lcd.setCursor(cactusCol, 1);
lcd.write((byte)1);
lcd.setCursor(13, 0);
lcd.print(score);
}
}
}
Что здесь стоит рассмотреть в лупу. Карта памяти: три рекорда живут в ящиках 60, 62 и 64 — по два байта на int, как учил первый урок. Функция updateRecord с параметром lessIsBetter: у «Реакции» рекорд — меньшее время, у остальных — большее число; одна функция обслуживает оба случая. И «Повтори»: мелодия по-прежнему придумывается целиком при старте, ответ проверяется на лету, а вместо светодиодов вспыхивают звёздочки в «своих» местах экрана — географию кнопок повторяет география позиций.
Приёмка приставки: сыграйте по разу во всё. Поставьте рекорды. Выдерните USB, вставьте — рекорды на месте, в меню под каждой игрой. Дайте приставку кому-нибудь из домашних и посмотрите, как быстро вам захочется отвоевать первое место в «Бегуне».
Частые проблемы
- В меню вместо рекордов — «Rekorda net», хотя вы играли. Рекорды записываются после конца партии. Фальстарт и ноль очков рекордом не считаются — так задумано.
- «Бегун» мигает всем экраном. Так выглядит
lcd.clear()на каждом кадре — на настоящем экране это почти незаметно, но если хочется идеала, перерисовывайте только изменившиеся знакоместа: отличное упражнение. - В «Повтори» плата «нажимает» сама. Проверьте кнопки: сигнальный провод — сверху канавки, земля — снизу. И не держите кнопку: ход засчитывается по нажатию с отпусканием.
- Человечек и кактус — кракозябры. После
createCharобязателенsetCursor— ловушка из урока про экран. В скетчах он есть; верните, если случайно удалили.
Задания для тренировки
- РазминкаДобавьте в «Бегуна» звук шагов: короткий тихий «тик» на каждый сдвиг мира. Игра сразу станет живее.
- ПосложнееНаучите приставку сбрасывать рекорды: удержание кнопки 2 в меню три секунды — и рекорд текущей игры стирается (запишите в него −1). Механику удержания вы делали в сейфе.
- Со звёздочкой ⭐Придумайте четвёртую игру сами и вставьте её в меню. Например, «Сапёр на минималках»: три кнопки, под одной — «мина», угадал пустые — очко. Каркас готов: функция, возвращающая счёт, пункт меню и ящик для рекорда.
Финиш продвинутого курса
Оглянитесь: восемь уроков назад плата забывала всё при выключении, слушалась только загруженной программы и пропускала события, если была занята. Теперь ваши устройства помнят настройки и рекорды, принимают команды с компьютера, ловят мгновенные события, сглаживают сигналы конденсаторами, усиливают их транзисторами и рисуют собственные миры на экране. В коробке набора не осталось ни одной незнакомой детали — а в голове появился главный инженерный навык: собирать большое из проверенного малого.
Что дальше? Дальше — ваши собственные проекты. Методика из финала базового курса («чувствует — думает — действует») работает на любом размахе, а оба курса останутся под рукой как справочник. Придумывайте, собирайте, делитесь — у вас всё для этого есть. Спасибо, что дошли до конца!
Частые вопросы
Почему игры оформлены отдельными функциями?
Чтобы большую программу можно было собирать и проверять по частям: каждая игра отлаживается сама по себе, возвращает результат, а меню лишь вызывает их по очереди. Это главный способ не утонуть в длинном коде — и в настоящей разработке он называется декомпозицией.
Хватит ли памяти платы на четыре-пять игр?
С запасом: финальный скетч занимает лишь малую часть флеш-памяти. Раньше упрётесь в фантазию и в три кнопки, чем в память. А если каждая игра — функция, добавление новой почти не трогает остальной код.
Можно ли сделать «Бегуну» плавную анимацию, как на телефоне?
На знакогенераторном экране кадр — это 32 знакоместа, поэтому движение всегда идёт скачками по клетке. Хитрость настоящих умельцев — рисовать промежуточные фазы своими символами через createChar: до восьми своих знаков, и внутри каждого — 40 точек свободы. Для любопытных это отличная кроличья нора.
