Змейка в моей мастерской начиналась дважды, и первый раз я её похоронил. Версия на delay(150) работала ровно до первой попытки повернуть под давлением: пока delay спал, digitalRead не вызывался, и примерно каждое второе нажатие уходило в никуда — змейка слушалась через раз. Игровой цикл с паузой в середине слеп во время сна, и это не лечится подбором паузы: короче — кнопки ловятся, но игра несётся; длиннее — темп честный, а управление ватное. Лечится выносом паузы из цикла: кадры по millis с окном, опрос кнопок каждый проход loop — механика та же, что в уроке про millis вместо delay, только здесь окно ещё и меняется на ходу.
Вторая причина посвятить змейке целый урок — буфер кадра. Экран SSD1306 держит 1024 байта кадра в ОЗУ платы, и display() перекачивает их на контроллер целиком — арифметику буфера я считал в уроке про 1024 байта на кадр. Для игры из этого следуют два правила: рисуй в ОЗУ сколько нужно, отправляй один раз в конце. Строго говоря, буфер у библиотеки один, а не два, но глазу живётся так, будто их два: процесс рисования с экрана не виден в принципе, зритель получает только готовый кадр — именно за это ценят двойную буферизацию в больших графических системах.
Управление — двумя кнопками вместо джойстика: левая крутит направление против часовой, правая по часовой. Для змейки это выгоднее абсолютных стрелок: четыре направления превращаются в два действия, и поворот всегда относительно хода, поэтому нажатием в воздухе нельзя отправить змейку в себя. Кнопки с подтяжкой и фронты я разбирал в базовом уроке про digitalRead, а здесь покажу, что в игре фронт важнее уровня вдвойне. Рекорд времени и физика прыжка остались за флаппи из этого же раздела — змейка добавляет работу с массивом и переменную скорость.
Сетка, кадр и один display() на цикл
Поле — сетка, а не пиксели. При клетке 8×8 пикселей экран 128×64 даёт 16 столбцов и 8 рядов; верхний ряд я сразу отдал под счёт и скорость, поле осталось 16×7 — 112 клеток. Змейка живёт в двух массивах: sx[] хранит столбцы, sy[] ряды, голова — нулевой элемент, хвост — последний. Клетка рисуется fillRect(x*8, y*8, 7, 7): семь пикселей из восьми оставляют зазор, и глаз читает клетки, а не слитную полосу. Кадр целиком: clearDisplay(), строка счёта, тело циклом по массиву, еда — и ровно один display() в конце. Второй display() посередине сборки — верный способ увидеть разорванную картинку; почему мерцание у буферного экрана живёт не там, где его ищут, я подробно разбирал в уроке про анимацию без мерцания.
Скорость — переменная, и это вторая половина геймплея. Окно кадра стартует со 160 мс, каждая съеденная еда отнимает шесть, пол — 70 мс: к десятой еде кадр 100 мс, к шестнадцатой — предельные 70, дальше решают руки, а не рефлексы. Текущее окно я печатаю в шапке рядом со счётом — не украшение, а честный спидометр: когда игрок жалуется, что «игра подтормаживает», цифра сразу показывает, дело в окне или в шине. Формула ускорения — одна строка: shag = shag - 6 с зажимом через if. Классические реализации разгоняют игру ступенями раз в пять очков, но плавный разгон ощущается честнее и не даёт рывка, к которому не успеваешь привыкнуть.
Про ОЗУ — считаем вслух, потому что на Uno каждый байт на счету. Два массива по 80 сегментов — 320 байт, буфер экрана — 1024, счёт и служебные — десятки; из 2 КБ платы остаётся запас на будущее оборудование. Потолок в 80 сегментов — не скупость: за партию дольше сотни клеток не вырастает никто, а массив на все 112 съел бы ещё 64 байта. Если хотите бесконечный рост, переносите поле на Mega или ESP32 — там и буферов не жаль.
| Цикл | Что видит игрок | Цена | Вердикт |
|---|---|---|---|
| delay(150) внутри loop | каждое второе нажатие теряется, змейка слушается через раз | ноль лишних строк | так выглядела моя похороненная версия |
| кадр по millis-окну, кнопки каждый проход | клики не пропадают никогда, темп ровный | одна переменная-окно | выбор для любой игры на экране |
| окно зависит от счёта: 160→70 мс | игра ускоряется по мере роста | одна строка вычитания | здесь: минус 6 мс за каждую еду |
| attachInterrupt от кнопки | фронт ловится даже внутри чужой паузы | дребезг даёт двойные повороты | для змейки избыточно: фронтов и так хватает |
Поворот по фронту и хвост, который кусает
Поворот засчитывается по фронту: прошлые состояния обеих кнопок лежат в переменных knL и knP, и новая команда проходит только на переходе из HIGH в LOW. Уровневая логика в игре даёт классическую картину: палец на кнопке — направление крутится каждый кадр, и через полсекунды змейка кружится на месте, как щенок за хвостом. Анти-дребезг я тут сознательно не ставил: механический дребезг добавит два-три лишних фронта на нажатие, но двойной поворот за один тап в змейке терпим — а окно на 50-80 мс по millis при желании ставится одной строкой, я собирал его для меню в уроке про две кнопки.
Коллизии считаются по массиву координат, без геометрии: новую голову сравниваем с сегментами тела от первого до хвоста. Тонкость, на которой я обжёгся: клетка, которую хвост покидает в этом же кадре, — легальная, и проверять её на удар значит убивать змейку за законный разворот в узком месте. Поэтому длина проверки зависит от того, растём ли мы в этом кадре: еда съедена — хвост стоит на месте, проверяем всё тело; не съедена — последний сегмент из проверки выпадает. Стена ловится отдельно, четырьмя сравнениями: поле не прощает выход за край, и это единственные четыре случая, когда координата бывает вне сетки.
«Хвост съедает себя» — не фигура речи, а точное имя бага из моей первой версии. Я увеличивал длину массива до сдвига тела, и старая позиция головы записывалась поверх хвоста — тело слипалось с головой на первом же обеде, змейка умирала через две секунды. Мораль простая до обидного: единственный источник правды в игре — массив координат, экран — производная, которую каждый кадр рисуют заново. Порядок операций поменялся местами — сдвиг, потом голова, потом рост — и баг исчез навсегда; с тех пор рисование я пишу в самом конце функции хода, что бы ни происходило в ней выше.
#include <Wire.h>#include <Adafruit_GFX.h>#include <Adafruit_SSD1306.h> Adafruit_SSD1306 display(128, 64, &Wire, -1); const int KN_LEVO = 2; // поворот против часовойconst int KN_PRAVO = 3; // поворот по часовой int sx[80]; // столбцы: голова — элемент 0int sy[80]; // рядыint len = 3;int dir = 1; // 0 вверх, 1 вправо, 2 вниз, 3 влевоint edaX, edaY;int schet = 0;int shag = 160; // окно кадра, мс: старт 160, пол 70unsigned long tShag = 0;int knL = HIGH, knP = HIGH;int sost = 0; // 0 — игра, 1 — экран итогов int dxPo(int d) { if (d == 1) return 1; if (d == 3) return -1; return 0;}int dyPo(int d) { if (d == 0) return -1; if (d == 2) return 1; return 0;} void postavitEdy() { bool svobodno = false; while (!svobodno) { edaX = random(0, 16); edaY = random(1, 8); // ряд 0 отдан счёту svobodno = true; for (int i = 0; i < len; i++) if (sx[i] == edaX && sy[i] == edaY) svobodno = false; }} void sbros() { len = 3; dir = 1; schet = 0; shag = 160; sx[0] = 8; sy[0] = 4; sx[1] = 7; sy[1] = 4; sx[2] = 6; sy[2] = 4; postavitEdy(); sost = 0;} void risovatKadr() { display.clearDisplay(); display.setTextSize(1); display.setCursor(0, 0); display.print("SCHET " + String(schet) + " SHAG " + String(shag)); for (int i = 0; i < len; i++) display.fillRect(sx[i] * 8, sy[i] * 8, 7, 7, SSD1306_WHITE); display.fillRect(edaX * 8, edaY * 8, 7, 7, SSD1306_WHITE); display.display(); // ровно одна посылка кадра} void ekranItogov() { display.clearDisplay(); display.setTextSize(2); display.setCursor(8, 16); display.print("GAME OVER"); display.setTextSize(1); display.setCursor(8, 40); display.print("SCHET " + String(schet)); display.setCursor(8, 52); display.print("KNOPKA - zanovo"); display.display();} void setup() { Serial.begin(9600); pinMode(KN_LEVO, INPUT_PULLUP); pinMode(KN_PRAVO, INPUT_PULLUP); display.begin(SSD1306_SWITCHCAPVCC, 0x3C); display.setTextColor(SSD1306_WHITE); Wire.setClock(400000); // кадр 92 мс -> 23 мс, иначе пол 70 не достижим sbros(); risovatKadr();} void loop() { int l = digitalRead(KN_LEVO); int p = digitalRead(KN_PRAVO); if (sost == 1) { if ((l == LOW && knL == HIGH) || (p == LOW && knP == HIGH)) sbros(); knL = l; knP = p; return; } if (l == LOW && knL == HIGH) dir = (dir + 3) % 4; if (p == LOW && knP == HIGH) dir = (dir + 1) % 4; knL = l; knP = p; if (millis() - tShag < shag) return; tShag = millis(); int nx = sx[0] + dxPo(dir); int ny = sy[0] + dyPo(dir); if (nx < 0 || nx > 15 || ny < 1 || ny > 7) { // стена sost = 1; Serial.print("schet="); Serial.println(schet); ekranItogov(); return; } bool est = (nx == edaX && ny == edaY); int doK = est ? len : len - 1; // уходящий хвост не проверяем for (int i = 1; i < doK; i++) { if (sx[i] == nx && sy[i] == ny) { sost = 1; Serial.print("schet="); Serial.println(schet); ekranItogov(); return; } } for (int i = len - 1; i > 0; i--) { // сдвиг тела к хвосту sx[i] = sx[i - 1]; sy[i] = sy[i - 1]; } sx[0] = nx; sy[0] = ny; if (est) { if (len < 80) len++; schet++; shag = shag - 6; if (shag < 70) shag = 70; postavitEdy(); } risovatKadr();}Строка Wire.setClock(400000) в setup — не украшение, а условие честного разгона: полный кадр 1024 байта идёт по шине около 92 мс на стоковых 100 кГц, и когда окно падает ниже 92, кадры физически не успевают — игра перестаёт ускоряться, хотя millis показывает нужные 70. На 400 кГц посылка сжимается до 23 мс, и пол в 70 мс становится достижимым с запасом. Провода короче полуметра держат 400 кГц без вопросов; это тот же трюк, который я применяю в приборах с живыми графиками.
Еда спавнится циклом с проверкой: пока random попал на клетку тела, кидаем снова. Без этой проверки еда однажды заспавнится под змейкой и растворится в ней — игрок не съест и не поймёт, почему счёт стоит. Заодно это единственный while в скетче, и он всегда завершается: клеток в поле больше, чем сегментов у змейки, свободная клетка существует по определению.
Текстовое поле в симуляторе шире платного: 20 столбцов против 16, потому что движок рисует сетку знакомест 6×8, а не клетками 8×8. Логика при этом один в один: те же массивы, тот же сдвиг от хвоста, та же формула скорости и те же коллизии с пропуском уходящего хвоста. Наберите пару еден и следите за шапкой: SHAG уменьшается на шесть за каждую, и к десятой окно упрётся в сотню — дальше только руки.
Serial печатает счёт на гибели — за этим стоит привычка из приборных скетчей: одна строка на событие стоит ноль, а спор «до скольких ты дошёл» закрывает за секунду. Если скучно играть без звука, добавьте зуммер на D8 и короткий tone(8, 1800, 40) на еду — движок симулятора поддерживает tone на зуммере, и в макетке появится слышимый пульс очков, как во флаппи.
- 1. Почему нажатия кнопок терялись в первой версии с delay(150)?
- 2. Какой размер поля у змейки в платной версии этого урока?
- 3. Какая клетка тела не проверяется на удар, когда змейка в этом кадре не растёт?
- 4. Почему на Uno со стоковой шиной 100 кГц разгон до 70 мс не ощущается?
- 5. Где единственный источник правды о положении змейки?
- Игровой цикл без delay: кадр по millis-окну 160→70 мс (минус 6 за еду), кнопки опрашиваются каждый проход loop
- Поле — сетка 16×7 клеток по 8 пикселей; клетка — fillRect 7×7, зазор в один пиксель читается как сетка
- Координаты змейки — два массива sx/sy: голова в элементе 0, тело сдвигается от хвоста, рост — редкое исключение из сдвига
- Коллизии по массиву: уходящая хвостовая клетка не проверяется, если змейка не растёт, — иначе укус в пустоту
- Кадр 1024 байта: 92 мс на 100 кГц и 23 мс на 400 кГц — без Wire.setClock разгон змейки упирается в шину
Частые вопросы по теме урока
- Почему нажатия кнопок терялись в первой версии с delay(150)?
- Пауза в середине цикла делает его слепым: пока delay спит, опроса нет, и нажатие короче паузы не оставляет фронта. Во время delay loop не опрашивает кнопки, фронты между паузами пропадают
- Какой размер поля у змейки в платной версии этого урока?
- Клетка 8×8 на экране 128×64 даёт 16 столбцов и 8 рядов; нулевой ряд печатаёт счёт, поле — 16×7, то есть 112 клеток. 16×7 клеток — верхний ряд отдан счёту
- Какая клетка тела не проверяется на удар, когда змейка в этом кадре не растёт?
- Хвост уезжает с последней клетки к моменту постановки головы: удар в неё — ложное срабатывание. При росте хвост стоит, и проверка покрывает всё тело. Клетка, которую хвост покидает в этом же кадре
- Почему на Uno со стоковой шиной 100 кГц разгон до 70 мс не ощущается?
- 1024 байта × 9 бит / 100 000 ≈ 92 мс на посылку. Помогает Wire.setClock(400000): кадр сжимается до 23 мс. Полный кадр идёт по шине около 92 мс и съедает весь выигрыш
- Где единственный источник правды о положении змейки?
- Экран перерисовывается с нуля каждый кадр из массива координат; попытка читать правду с дисплея и дала баг «хвост съедает себя». В массивах координат sx[]/sy[], экран — только производная