Продвинутый курс Arduino · Большие проекты 2

Урок 8. Проект: игровая приставка

Финал курса: меню на экране, три игры — «Реакция», «Повтори» и новый «Бегун» с прыжками через кактусы — и таблица рекордов, которую не стирает даже выключение питания.

Урок 8 из 8⏱ примерно 55 минутОбновлено: 14 августа 2026

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

  • Контроллер 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. Новинка: «Бегун»

4Бег: человечек в нижней строкекактус каждый шаг сдвигается влево4Прыжок: та же колонка, верхняя строкастолкновение — это «кактус в колонке 2, а человечек не в воздухе»Человечек и кактус — свои символы из точек, как значки метеостанции. Вся физика игры —два числа: колонка кактуса и время окончания прыжка.
Человечек живёт в колонке 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. Собираем приставку

<Reakciya>Rekord:231ms<Povtori>Rekord:9ur.<Begun>Rekordanetкнопки 1 и 3кнопка 2 — игратьпод названием — несгораемый рекордменю закольцованоКаждая игра — отдельная функция, которая возвращает результат. Меню запускает её,показывает счёт и сверяет его с рекордом из вечной памяти.
Кнопки 1 и 3 листают меню по кругу, кнопка 2 запускает игру. Под названием — рекорд из вечной памяти.

Пришло время большой сборки. Каждая игра — функция, возвращающая результат: 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 точек свободы. Для любопытных это отличная кроличья нора.

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