Мой первый заход в «Змейку» кончился конфузом. Я взял чужой скетч под джойстик, которого у меня не было, приделал кнопку — и неделю не мог понять, почему змейка спокойно проходит сквозь собственный хвост. Разбор показал: проверка столкновений в том коде сравнивала голову только с одним сегментом тела. С тех пор правило: игровые скетчи я пишу сам и с нуля — в игре каждая строка обязана что-то проверять, а чужие примеры это скрывают.
Напишем свою с нуля и будем понимать каждую строку: игровой цикл на millis() с шагом 400 мс, состояние в двух массивах, еда от random() с проверкой, столкновения со стеной и хвостом, счёт в Serial и рестарт одной функцией. Управление — одной кнопкой на пине 2: каждое нажатие поворачивает движение по часовой стрелке. Цвета головы и тела возьмите из урока про колесо HSV, а поле — те же 64 пикселя, что и у бегущей строки.
Игровой цикл: шаг по таймеру, кнопка — всегда
Игра — это анимация плюс правила. Каркас кадров тот же, что в уроке про эффекты: if (millis() - lastStep >= 400) — и внутри рисуется новый шаг. Таймер нужен не для красоты: в паузах между шагами loop() крутится тысячи раз в секунду, и кнопка читается на каждом проходе. С delay(400) вы бы физически не успевали нажать между шагами — половина нажатий терялась бы, и вместо игры получалась бы лотерея. Подробный разбор неблокирующих таймеров — в уроке про millis() вместо delay().
Состояние игры — три вещи. Тело живёт в двух массивах bx[64] и by[64]: индекс 0 — голова, дальше хвост; переменная len хранит текущую длину. Направление — пара dx, dy, в которой ровно одна единица: (1, 0) — вправо, (0, 1) — вниз. Еда — точка fx, fy, которую random() каждый раз ставит на новое место. На живой плате тело можно свернуть в структуру с полями x и y — но в учебной версии два параллельных массива читаются яснее, и в симуляторе работают без сюрпризов.
Про выбор шага 400 мс. Это темп, на котором новичок успевает увидеть, подумать и нажать: змейка переползает клетку за две трети секунды. Опытные игроки сжимают шаг до 250 мс, хардкорщики — до 150, а дальше решает уже не реакция, а планирование маршрута. Константа вынесена в одно место скетча, и сложность настраивается одной цифрой без правки логики — поэтому игровые константы я сразу называю STEP_MS и TURN_MS, а не раскидываю голыми числами по коду.
| Элемент игры | Где живёт | Что делает за один шаг |
|---|---|---|
| Голова | bx[0], by[0] | сдвигается на dx, dy |
| Тело | bx[1..len-1], by[1..len-1] | каждый сегмент занимает место предыдущего |
| Еда | fx, fy | random(0, 8) с проверкой «не на змейке» |
| Счёт | score | плюс один за каждую еду, печать в Serial |
Шаг змейки — три действия по порядку. Сначала считаем, где окажется голова: nx = bx[0] + dx, ny = by[0] + dy. Потом сдвигаем тело с хвоста: цикл от len к единице копирует каждый сегмент на место предыдущего — место под новую голову освобождается. Третьим действием пишем nx и ny в голову. Сдвиг с хвоста — ключевой трюк всей игры: новая клетка тела появляется сама, из старого хвоста, ничего дописывать не нужно.
Рост работает на том же сдвиге и экономит отдельный код. Пока еда не съедена, длина не меняется: сегмент в клетке с номером len, куда сдвиг успел скопировать старый хвост, просто игнорируется. Съели — len становится на единицу больше, и тот самый скопированный хвост внезапно становится настоящим сегментом. Змейка растёт ровно тогда, когда ест, — и ни строкой кода больше.
Пара слов про потолок длины. Массивы на 64 ячейки — по числу клеток поля: длиннее физически не бывает, и ошибка «рост бесконечен» легко лечится условием len < 63. Сдвиг хвоста стоит не дороже числа сегментов: даже при длине 40 это сорок присваиваний раз в 400 мс — для Arduino пустяк. На больших полях вместо сдвига применяют кольцевой буфер с указателем головы, но на 8x8 разницы нет, а сдвиг читается на порядок проще: я всегда начинаю с простого варианта и усложняю, только когда упираюсь в реальный предел.
Столкновения, счёт и рестарт
Смертей две, и обе проверяются до сдвига. Стена: nx < 0 || nx > 7 || ny < 0 || ny > 7 — за поле выходить нельзя. Самопересечение: перебираем тело циклом и сравниваем каждый сегмент с новой головой; совпала хоть одна клетка — конец. После смерти — три красные вспышки по 120 мс и resetGame(): длина снова три, направление вправо, еда пересаживается на свободную клетку. Функция рестарта обязательна: без неё игра умирает один раз и навсегда.
Счёт печатайте парами имя=значение — Serial.print("score=") и число: такой вывод и в мониторе читается, и в плоттере не рассыпается на позиции, как учил урок про форматированный вывод. Рекорды при желании пишите в EEPROM — у Uno её тысяча ячеек, и лучший результат переживёт выключение. Хочется сложности — уменьшайте шаг с ростом счёта: 400 - score * 15 с нижней границей 120 мс. Три съеденных яблока — и игра сама подгоняет темп.
Отдельно про еду. random(0, 8) выдаёт пару координат, но у random без seed на Uno есть привычка: после каждого сброса последовательность одна и та же, и еда занимает одни и те же места. Лечится randomSeed(analogRead(A0)) в setup(): плавающий аналоговый пин ловит шум окружения и даёт непредсказуемое зерно. Проверка «не на змейке» в placeFood() нужна не для красоты: если точка заспавнится под телом, съесть её будет невозможно — змейка стоит на этой клетке и проходит по ней без столкновения, поэтому цикл просто выбирает другую клетку.
И один тонкий случай для честности. Голова может ступить в клетку, которую этим же шагом освобождает хвост: формально там тело, фактически место свободное. Моя простая проверка засчитает это как столкновение, и в народных скетчах спокойно живут оба варианта. Если захотите мягче — проверяйте сегменты до len - 1 включительно, но помните: в тот кадр, когда змейка ест, хвост остаётся на месте, и поблажка превращается в баг. Для первой игры честнее строгий вариант.
Про поворот стоит сказать отдельно: одно нажатие — одна четверть оборота, четыре нажатия возвращают исходное направление. Удерживать кнопку бессмысленно: окно TURN_MS в 250 мс пропускает повторные срабатывания, пока палец держит контакт. Если хотите непрерывного вращения — делайте это явно, отдельным режимом с шагом 500 мс, а не случайными свойствами дребезга.
#include <FastLED.h> #define NUM_LEDS 64#define BTN_CW 2 // поворот по часовой#define BTN_CCW 3 // против часовой CRGB leds[NUM_LEDS]; int bx[64], by[64]; // тело: индекс 0 — головаint len = 3;int dx = 1, dy = 0;int fx = 5, fy = 4;int score = 0;int dead = 0; unsigned long lastStep = 0;unsigned long lastTurn = 0;const int STEP_MS = 400; // пауза между шагамиconst int TURN_MS = 250; // окно от дребезга кнопок int XY(int x, int y) { if (y % 2 == 0) return y * 8 + x; return y * 8 + (7 - x);} void placeFood() { int ok = 0; while (ok == 0) { fx = random(0, 8); fy = random(0, 8); ok = 1; for (int i = 0; i < len; i++) { if (bx[i] == fx && by[i] == fy) ok = 0; } }} void resetGame() { len = 3; dx = 1; dy = 0; score = 0; dead = 0; for (int i = 0; i < len; i++) { bx[i] = 2 - i; by[i] = 4; } placeFood();} void setup() { randomSeed(analogRead(A0)); pinMode(BTN_CW, INPUT_PULLUP); pinMode(BTN_CCW, INPUT_PULLUP); FastLED.addLeds<WS2812B, 6, GRB>(leds, NUM_LEDS); FastLED.setBrightness(150); resetGame();} void loop() { unsigned long now = millis(); // кнопки опрашиваются на каждом проходе — нажатия не теряются if (now - lastTurn > TURN_MS) { if (digitalRead(BTN_CW) == LOW) { int t = dx; dx = -dy; dy = t; lastTurn = now; } if (digitalRead(BTN_CCW) == LOW) { int t = dx; dx = dy; dy = -t; lastTurn = now; } } if (now - lastStep < STEP_MS) return; lastStep = now; int nx = bx[0] + dx; int ny = by[0] + dy; if (nx < 0 || nx > 7 || ny < 0 || ny > 7) dead = 1; for (int i = 0; i < len; i++) { if (bx[i] == nx && by[i] == ny) dead = 1; } if (dead == 1) { for (int k = 0; k < 3; k++) { fill_solid(leds, NUM_LEDS, CRGB::Red); FastLED.show(); delay(120); FastLED.clear(); FastLED.show(); delay(120); } resetGame(); return; } for (int i = len; i > 0; i--) { bx[i] = bx[i-1]; by[i] = by[i-1]; } bx[0] = nx; by[0] = ny; if (nx == fx && ny == fy) { if (len < 63) len++; score++; Serial.print("score="); Serial.println(score); placeFood(); } FastLED.clear(); leds[XY(fx, fy)] = CRGB::Green; for (int i = 0; i < len; i++) { leds[XY(bx[i], by[i])] = (i == 0) ? CRGB::Yellow : CHSV(96, 255, 180); } FastLED.show();}Эксперименты для закрепления
Игра щёлкает только когда вы сами ломаете её правила и чините обратно. Четыре задачи по возрастанию — все решаются в симуляторе и на плате, без нового железа; заливки вспышек и цветов берите готовыми из урока про CRGB и fill_solid:
- Сквозные стены: вместо смерти на границе заворачивайте голову — if (nx < 0) nx = 7. Смерть оставьте только за самопересечение.
- Ускорение с ростом счёта: шаг 400 - score * 15 и не даёте ему упасть ниже 120 мс.
- Мигающая еда: рисуйте точку еды только на каждом втором кадре по счётчику кадров — еда станет заметнее на фоне тела.
- Пауза: длинное нажатие дольше 800 мс останавливает шаги, отпустили кнопку — игра продолжилась.
- Игровой цикл: шаг по таймеру millis(), кнопка опрашивается на каждом проходе loop()
- Тело — два массива bx/by, индекс 0 — голова; движение = сдвиг с хвоста плюс новая голова
- Рост бесплатный: не удаляем хвост и увеличиваем len — скопированный сегмент становится телом
- Поворот одной кнопкой: пара (dx, dy) превращается в (-dy, dx) — четверть оборота по часовой
- Две смерти — стена и свой хвост; после них вспышка и resetGame()
- 1. Почему шаг змейки — по таймеру, а кнопку читаем на каждом проходе loop()?
- 2. Что делает цикл «от len к единице» перед новым шагом?
- 3. Как змейка растёт при поедании?
- 4. Нажатие повернуло движение с (0, 1) по формуле (-dy, dx). Что получилось?
- 5. Зачем окно 250 мс после поворота?
У вас на руках полноценная игра: состояние, правила, счёт и рестарт — и всё это в трёх десятках логических строк. Счёт «на пикселях» — вещь временная: когда захотите показывать score прямо на матрице, вернитесь к шрифту 5x7 и выводите цифры глифами. На этом же каркасе идут и другие однокнопочные игры: прыгун через препятствия, лабиринт, реакция-тест — везде те же состояние, шаг по таймеру, столкновения и счёт. А для больших сборок — двух лент на разных пинах, независимых и синхронных сцен — переходите к следующему уроку.
Частые вопросы по теме урока
- Почему шаг змейки — по таймеру, а кнопку читаем на каждом проходе loop()?
- loop() между шагами прокручивается тысячи раз; опрос на каждом проходе не теряет нажатия, а delay() заморозил бы всю логику. Нажатия приходят между шагами — на каждом проходе loop() их проще поймать
- Что делает цикл «от len к единице» перед новым шагом?
- Сдвиг с хвоста — сердце шага: клетка хвоста освобождается, клетка головы готова принять новые координаты. Копирует каждый сегмент на место предыдущего, освобождая место под новую голову
- Как змейка растёт при поедании?
- Клетка под номером len после сдвига уже содержит копию старого хвоста. Увеличение len делает её настоящим сегментом — ноль лишнего кода. Просто увеличиваем len: сдвиг уже скопировал хвост в новую клетку
- Нажатие повернуло движение с (0, 1) по формуле (-dy, dx). Что получилось?
- (-dy, dx) от (0, 1) — это (-1, 0): движение влево, ещё одна четверть оборота по часовой стрелке. (-1, 0)
- Зачем окно 250 мс после поворота?
- Контакты дребезжат миллисекунды, но loop() успевает опросить пин сотни раз за одно нажатие; окно отсекает повторы. Чтобы одно нажатие с дребезгом не засчиталось несколькими поворотами