Игры на микроконтроллере: реакция, змейка и «Жизнь» Конвея

Время, случайность, состояние и цикл без блокировок — на примерах игр для ESP32 и Arduino

Игра на реакцию, змейка на матрице 8×8 и «Жизнь» Конвея учат тому же, что и серьёзные приборы: измерять время, не блокировать цикл, хранить состояние и честно получать случайные числа.

Первое устройство, которое хочется показать друзьям, редко бывает метеостанцией. Чаще это игра: кнопка, светодиод и вопрос «кто быстрее». Выглядит несерьёзно, но в игре сходится почти всё, на чём спотыкаются в приборах: точный замер времени, реакция на нажатие, а не на удержание, случайные числа, которые не повторяются от включения к включению, и главный цикл, который не засыпает в delay(), пока игрок жмёт кнопку.

Ниже — пять игр из галереи PinPort, от простой к сложной, и то, чему учит каждая.

Игра на реакцию: время и случайность

Игра на реакцию на ESP32 — светодиод на D2 через резистор 220 Ом и кнопка на D15 с внутренней подтяжкой. Случайная пауза, вспышка, замер до нажатия:

delay(random(1500, 4000));

digitalWrite(LED, HIGH);
unsigned long start = millis();
while (digitalRead(BUTTON) == HIGH) {
  if (millis() - start > 3000) break;       // не ждать вечно
}
unsigned long reaction = millis() - start;

Кнопка подключена между выводом и землёй, поэтому нажатая читается как LOW. Время реакции человека на вспышку — около 200–250 мс, а millis() считает с шагом в миллисекунду, так что точности хватает с запасом.

Случайная пауза нужна, чтобы игрок не мог жать по ритму. Но random() сам по себе не случаен: это формула, которая от одного и того же начального числа выдаёт одну и ту же последовательность. На Arduino Uno без randomSeed() каждая партия после включения начнётся с тех же пауз. У ESP32 ядро Arduino без засева берёт числа прямо из аппаратного генератора, а randomSeed() переключает random() на формулу — поэтому засев в примере тоже из аппаратного генератора: randomSeed(esp_random()). У Uno такого генератора нет, и обычно читают analogRead висящего входа: на нём бродит наводка, и число каждый раз другое. В симуляторе висящий вход ведёт себя так же. Но наводка — источник слабый: её значения лежат в узком диапазоне и меняются медленно. Надёжнее время первого нажатия, поэтому в «Саймоне» засев сделан от него — randomSeed(micros()): человек никогда не нажимает кнопку с точностью до микросекунды.

Самое простое ожидание — delay(random(1500, 4000)) — прячет недостаток: во время паузы кнопка не проверяется вовсе. Если зажать её заранее, цикл while закончится мгновенно, и в порт уйдёт реакция 0 мс. Поэтому пауза в примере — не delay(), а цикл, который слушает кнопку и считает нажатие до вспышки фальстартом:

unsigned long wait = random(1500, 4000), from = millis();
while (millis() - from < wait) {
  if (digitalRead(BUTTON) == LOW) {
    Serial.println("фальстарт: рано нажал");
    while (digitalRead(BUTTON) == LOW) delay(10);
    return;                  // новый раунд с новой случайной задержкой
  }
  delay(1);
}

Игра на реакцию

Замените randomSeed(esp_random()) на randomSeed(1) и запустите игру дважды: паузы перед вспышкой в обоих запусках будут одинаковыми — так выглядит незасеянный генератор

Открыть игру

«Поймай огонёк»: цикл, который не спит

Игра на реакцию блокирует программу: пока идёт delay() или крутится while, больше ничего не происходит. Для игры с одним событием это допустимо. Когда на экране что-то движется, а кнопку можно нажать в любой момент, нужен другой подход — цикл, который ничего не ждёт, а только проверяет, не пора ли действовать.

В «Поймай огонёк» огонёк бегает по ленте NeoPixel из восьми пикселей, центральный (номер 4) подсвечен зелёным. Нажали, когда огонёк на нём, — уровень пройден, и огонёк бежит быстрее:

if (millis() - lastMove >= stepMs) {        // пора сдвинуть огонёк
  lastMove = millis();
  pos += step;
  if (pos == PIXELS - 1 || pos == 0) step = -step;
  draw();
}
int btn = digitalRead(BUTTON);
if (lastBtn == HIGH && btn == LOW) { /* нажатие: проверить pos */ }
lastBtn = btn;

Две идеи в десяти строках. Первая — время на millis(): движение и опрос кнопки идут в одном цикле, и ни одно не ждёт другого. Вторая — реакция на фронт, а не на уровень. Сравнение с прошлым состоянием lastBtn ловит момент нажатия. Без него удержанная кнопка засчитывалась бы как нажатие на каждом проходе цикла, то есть сотни раз в секунду.

С каждым уровнем шаг умножается на 4/5, целочисленно:

Уровень123456
Шаг огонька, мс25020016012810281

Окно, в которое можно попасть, равно шагу: столько огонёк стоит на зелёном пикселе. На шестом уровне это 81 мс — меньше времени реакции. Игрок, который жмёт в ответ на увиденное, опаздывает, и попадать приходится заранее, по ритму: за полный пробег туда и обратно — 14 шагов — огонёк проходит цель дважды, на четвёртом и десятом шаге.

Змейка: состояние в двух массивах

Змейка на матрице 8×8 — первая игра, где состояние больше одного числа. Змейка хранится в двух массивах координат, голова — нулевой элемент:

int8_t sx[64], sy[64];
int nx = (sx[0] + DX[dir] + 8) % 8, ny = (sy[0] + DY[dir] + 8) % 8;   // сквозь стены
bool ate = nx == foodX && ny == foodY;
if (ate && len < 64) len++;
for (int i = len - 1; i > 0; i--) { sx[i] = sx[i - 1]; sy[i] = sy[i - 1]; }
sx[0] = nx; sy[0] = ny;

Шаг змейки — это сдвиг массива на одну позицию и новая голова. Если змейка съела точку, длина увеличивается до сдвига, и последний элемент не теряется — так змейка растёт. Прибавка 8 перед % 8 нужна потому, что в C остаток от отрицательного числа отрицателен: (0 − 1) % 8 даёт −1, а (0 − 1 + 8) % 8 — 7, противоположный край.

Направление хранится числом от 0 до 3 — индексом в таблицах DX и DY (вправо, вниз, влево, вверх). Поворот по энкодеру — прибавить 1 или 3 по модулю 4: прибавить 3 — то же, что отнять 1, но без отрицательных чисел. Энкодер читается по спаду сигнала CLK: в этот момент уровень DT говорит, в какую сторону повернули ручку.

Картинка собирается заново двадцать раз в секунду: каждая строка матрицы — байт, и точка в столбце x — это бит 0x80 >> x. Еда мигает с периодом 300 мс: (millis() / 150) % 2. Скорость растёт на 20 мс за каждую точку: от 400 мс на шаг до 140.

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

bool ate = nx == foodX && ny == foodY;
int body = ate ? len : len - 1;      // хвост уходит, если не ела
for (int i = 0; i < body; i++) if (sx[i] == nx && sy[i] == ny) hit = true;

Змейка на светодиодной матрице 8×8

Поменяйте начальную скорость stepMs = 400 на 250 и шаг ускорения 20 на 10: змейка стартует быстрее, а до предела в 150 мс дойдёт после десяти съеденных точек

Открыть змейку

«Жизнь» Конвея: мир в восьми байтах

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

Поле 8×8 на Uno занимает восемь байт — по биту на клетку. Соседи считаются по кругу, поле замкнуто в тор:

bool alive(int x, int y) { return field[(y + 8) % 8] & (0x80 >> ((x + 8) % 8)); }

if (n == 3 || (n == 2 && alive(x, y))) next[y] |= 0x80 >> x;

Главная деталь — второй массив next. Новое поколение пишется в него, а не в field. Если менять поле на месте, клетка, пересчитанная первой, испортит счёт соседей для следующих, и правила перестанут выполняться. Два буфера по восемь байт — 16 байт из 2048 байт ОЗУ Uno.

Число живых клеток считается приёмом b &= b - 1: каждое такое действие гасит младшую единицу в байте, и число повторов до нуля равно числу единиц. Поле засевается заново, если оно вымерло, застыло (новое поколение совпало со старым) или прошло 200 поколений. Последнее условие нужно из-за мигалок: мигалка повторяется каждые два поколения, и сравнение с предыдущим шагом её не поймает.

Планер, с которого начинается поле, сдвигается на клетку по диагонали каждые четыре поколения. Один на пустом поле 8×8 он возвращается в исходное место через 32 поколения. С паузой 300 мс это около десяти секунд.

Что общего у всех игр

ИграЧему учит
Реакцияmillis(), случайная пауза, засев генератора
Поймай огонёкцикл без блокировок, реакция на фронт кнопки
Змейкасостояние в массивах, модульная арифметика, энкодер
Жизнь Конвеябитовые поля, двойной буфер
Саймонпоследовательность в памяти, антидребезг, таймаут ожидания

В «Саймоне» на Uno стоят четыре светодиода и четыре кнопки. Последовательность до 32 огней хранится в массиве seq, каждый раунд добавляет один. Темп — max(150, 450 - len * 20): 430 мс в первом раунде, предел 150 мс с пятнадцатого. Нажатие засчитывается по отпусканию, с паузой 20 мс от дребезга, а на ответ даётся 5 секунд.

Типичные ошибки и проверка в симуляторе

Ошибки в игровом коде

  • delay() в главном цикле. Пока идёт задержка, нажатия теряются. Всё, что должно происходить одновременно, переводят на millis().
  • Реакция на уровень вместо фронта. Удержанная кнопка срабатывает на каждом проходе.
  • Незасеянный random() на Uno или засев постоянным числом. Одинаковые партии после каждого включения.
  • Выход за массив. В змейке длина ограничена 64 — числом клеток. Без проверки len < 64 массив переполнился бы и затёр соседние переменные.
  • Отрицательный остаток. Координата −1 вместо 7 при переходе через край.
  • Перерисовка на каждом проходе. Матрица по SPI не нуждается в тысяче обновлений в секунду: двадцати хватает глазу и не занимает шину.

Как играть в симуляторе

Кнопки нажимаются щелчком мыши, ручку энкодера в змейке крутят щелчком по краям или колесом. Счёт и события идут в монитор порта: «новая игра», «съела», номер поколения и число живых клеток. Лента NeoPixel и матрица MAX7219 показывают картинку так же, как настоящие. В сценариях примеров видно, как игру проверяют автоматически: в «Поймай огонёк» кнопка нажимается на 2,6 секунды — огонёк шагает раз в 250 мс и стоит на зелёном с 2,5 до 2,75 с, — и в порту ожидается «попали».

Итог

Игры на микроконтроллере — удобный полигон для приёмов, которые потом понадобятся в любом приборе. Время меряют millis() и не блокируют цикл, кнопку ловят по фронту, генератор случайных чисел засевают, состояние держат в массивах и битах, а новое поколение пишут в отдельный буфер. Каждую из этих ошибок в игре видно сразу — змейка врезается в хвост, огонёк не ловится, партии повторяются, — и исправлять их нагляднее, чем в приборе, который молча выдаёт неправильные числа.

Попробуйте в симуляторе

«Поймай огонёк»: игра на реакцию на ленте NeoPixel

Поменяйте множитель stepMs * 4 / 5 на stepMs * 9 / 10: шаг на шестом уровне станет 145 мс вместо 81, и попадать можно будет по реакции, а не по ритму

Поймай огонёк

Arduino Uno: «Жизнь» Конвея на матрице 8×8

Удалите строку с мигалкой field[5] и следите за монитором порта: у одинокого планера живых всё время 5, а на исходное место он возвращается каждые 32 поколения

Жизнь Конвея

Игра «Саймон» на четырёх кнопках

Поменяйте темп на max(100, 450 - len * 30): предельные 100 мс наступят в двенадцатом раунде, а не на 150 мс в пятнадцатом, как сейчас

Игра Саймон

← Все статьи