Игры на микроконтроллере: реакция, змейка и «Жизнь» Конвея
Время, случайность, состояние и цикл без блокировок — на примерах игр для 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, целочисленно:
| Уровень | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| Шаг огонька, мс | 250 | 200 | 160 | 128 | 102 | 81 |
Окно, в которое можно попасть, равно шагу: столько огонёк стоит на зелёном пикселе. На шестом уровне это 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 мс в пятнадцатом, как сейчас
Игра Саймон