Племянник, посмотрев, как мой термометр рисует цифры на LCD, выдал запрос «сделай, чтобы был как тетрис». Змейка показалась мне честнее для экрана 16x2, и первый блин я закомил в стиль, который уже разоблачали в уроке про мерцание: каждые 300 миллисекунд clear() — и полная перерисовка поля. Экран моргал, как стробоскоп, хотя менялись максимум три клетки. Вторая попытка вышла рабочей: змейка хранит себя в массивах, а за тик на экране перерисовываются ровно три ячейки — хвост, бывшая голова и новая голова. Экран замер, зато игра ожила.
В этом уроке соберём такую змейку целиком: поле, спрайты из своих символов, три кнопки, еда, скорость, растущая с длиной, и честный game over. По дороге разберём, почему тело удобно хранить двумя массивами и как проверять столкновения до сдвига, а не после. Если вы ещё не рисовали свои глифы через createChar(), сначала загляните в урок про свои символы на LCD — там механика знакогенератора.
Поле 16x2: цилиндр из 32 клеток
Экран 16x2 для игры — это поле из 32 клеток, свёрнутое в цилиндр: голова, ушедшая за правый край, выныривает из левого края той же строки. Никаких стен — единственный враг, кроме вашей растущей спины, это собственное тело. Каждая клетка — знакоместо 5 на 8 точек, и это удачный размер спрайта: одна клетка — один символ. У контроллера HD44780 для таких картинок есть восемь слотов памяти CGRAM, куда createChar() кладёт нарисованную по битам картинку; как устроена эта память, разобрано в уроке про внутренности HD44780.
В скетче ниже спрайты заменены обычными буквами: O — голова, звёздочка — тело, собака — еда. Это сделано ради симулятора, который исполняет команды библиотеки, но слоты CGRAM не эмулирует; на настоящем железе вы возвращаете createChar() с полным блоком 0xFF для тела — и игра выглядит в разы лучше, а код не меняется ни символом. Моя граница применимости: для первой версии берите символы как есть — попадание в геймплей важнее красоты пикселей; красить спрайты лучше, когда механика уже не разваливается.
| Клетка поля | Символ в скетче | Глиф на железе | Роль |
|---|---|---|---|
| Голова | O | createChar(0): блок со стрелкой-выступом | Показывает, куда ползём |
| Тело | * | createChar(1): полный блок 0xFF | Вся хвостатая часть |
| Еда | @ | createChar(2): точка в центре | Съел — вырос и ускорился |
| Пусто | пробел | не нужен | Затирает клетку, откуда ушла змея |
Тело змеи — классическая задача на массивы, и двух массивов хватает полностью: rows[i] и cols[i] хранят строку и столбец каждого сегмента, индекс ноль — голова. За тик происходит ровно три вещи. Сначала — до всякого сдвига — проверяем столкновение: клетка, куда собирается голова, не занята телом (хвост не считаем, он в этот же тик уползёт). Потом сдвигаем массивы с хвоста к голове. И последней двигается голова в новую клетку. Если съедена еда, массив становится на элемент длиннее, хвост остаётся на месте — змея выросла ровно на клетку. Порядок «проверка, потом сдвиг» я выбил себе как закон после того, как змейка научилась кусать несуществующий хвост: сначала я двигал, потом проверял — и ловил столкновение с клеткой, которую тело только что освободило. Приёмы работы с массивами мы проходили в уроке про массивы и журнал данных.
void step() { int nr = wantRow; // строка, куда идём (0 или 1) int nc = cols[0] + 1; // всегда вперёд, через край — в начало if (nc > 15) nc = 0; // 1. Проверка ДО сдвига: хвост (len-1) не считаем — он уползёт for (int i = 0; i < len - 1; i++) { if (rows[i] == nr && cols[i] == nc) { gameOver(); return; } } bool eats = (nr == foodRow && nc == foodCol); if (eats && len < 32) len = len + 1; // выросли на клетку // 2. Сдвиг с хвоста к голове for (int i = len - 1; i > 0; i--) { rows[i] = rows[i - 1]; cols[i] = cols[i - 1]; } rows[0] = nr; cols[0] = nc; // 3. Перерисовка: хвост затёрли или оставили, тело и новая голова if (eats) { spawnFood(); } else { drawCell(rows[len - 1], cols[len - 1], ' '); } drawCell(rows[1], cols[1], '*'); drawCell(nr, nc, 'O');}Управление — три кнопки на D2, D3 и D4: верхняя строка, нижняя строка, старт-рестарт. Змея всегда ползёт вперёд, кнопки лишь выбирают строку — осознанное упрощение для двухрядного поля, и именно оно делает игру на 32 клетках напряжённой: тело длиннее восьми клеток занимает обе строки, и выбор пути превращается в расчёт. Кнопки опрашиваются по фронту — сравнение с прошлым состоянием prev, как в уроке про digitalRead и кнопки, — а тики ведёт millis(): внутри loop() нет ни одного delay(), змея делает шаг, когда подошло время. Такой планировщик без блокировок — прямое применение урока про задачи на millis.
Игра в симуляторе
В макетке ниже три кнопки: D2 — переползти на верхнюю строку, D3 — на нижнюю, D4 — старт и рестарт. До старта на экране заставка, после — поле с головой O, телом из звёздочек и едой-собакой. Съеденная еда удлиняет тело и ускоряет тик — в Serial при этом идут пары len= и ms=, а при поедании — счёт. Столкновение с собственным телом — game over с длиной на экране и рестартом по D4. Поиграйте до длины десять-двенадцать: именно там обе строки начинают тесниться, и каждое решение становится выбором.
Куда расти дальше, когда механика закрепится. Первым я добавлял стены: символ решётки на краях вместо цилиндра — столкновение со стеной проверяется в step() до сдвига, одной парой сравнений. Вторым — бонусную еду, исчезающую через пять секунд: второй таймер на millis() и мигающий спрайт. Третьим — счёт рекорда в EEPROM, чтобы он переживал выключение. И самое приятное — поле 20x4: те же массивы, но 80 клеток, ползать куда интереснее, а нумерация строк разобрана в уроке про адресацию строк на LCD 20x4. Совет с границей: усложнения вносите по одному и играйте в каждое — иначе при поломке не узнаете, какая из четырёх новых строк её принесла.
- Экран 16x2 — поле из 32 клеток-знакомест; сворачивание правого края в левый превращает его в цилиндр без стен
- Тело змеи — два массива rows[] и cols[]; индекс ноль — голова, тик — это проверка, сдвиг с хвоста и перерисовка
- За тик рисуйте ровно три ячейки: хвост, бывшую голову, новую голову — полный clear() на каждом тике даёт рябь
- Столкновение проверяйте до сдвига массивов и не считайте хвост — он освобождает клетку в этом же тике
- Скорость из длины: 400 мс минус 9 мс за каждую съеденную клетку с полом 150 мс — игра усложняется сама
- 1. Почему за один тик змейки перерисовываются только три клетки, а не всё поле?
- 2. Где правильно проверять столкновение головы с телом?
- 3. Змея съела еду. Что происходит с массивами тела?
- 4. Как в этой версии устроена скорость игры?
- 5. Зачем в скетче сравнение prevUp с HIGH перед сменой строки?
Частые вопросы по теме урока
- Почему за один тик змейки перерисовываются только три клетки, а не всё поле?
- Меняются только хвост, бывшая голова и новая голова — остальное поле не трогается. Полная перерисовка 32 знаков по I2C на каждом тике выглядела бы как дрожание экрана. Три вызова по I2C занимают около 1–2 мс, а полный кадр — 20–30 мс и даёт рябь
- Где правильно проверять столкновение головы с телом?
- Проверка до сдвига ловит реальные столкновения, а исключение хвоста разрешает голове занять клетку, которую тело только что освободило. Сначала я проверял после — и змейка умирала на ровном месте. До сдвига, и хвост в проверке не участвует — он уползает в этот же тик
- Змея съела еду. Что происходит с массивами тела?
- При еде не удаляется хвостовая клетка, и длина растёт на единицу — змея становится ровно на клетку длиннее. Скорость тика при этом уменьшается, игра ускоряется. Длина увеличивается на один: хвост в этот тик не стирается
- Как в этой версии устроена скорость игры?
- Тик считается как 400 минус 9 мс за каждую съеденную клетку, но не меньше 150 мс. Длинная змея ходит быстрее — сложность растёт без дополнительных настроек. Скорость растёт вместе с длиной: от 400 мс до пола в 150 мс
- Зачем в скетче сравнение prevUp с HIGH перед сменой строки?
- Кнопка при удержании даст десятки опросов. Сравнение с прошлым состоянием выделяет момент нажатия — одно действие на одно нажатие. Тот же фронт-паттерн стоит на кнопке рестарта. Это реакция на фронт: действие один раз на нажатие, а не каждый проход loop()