Flappy Bird на ATtiny85 — восемь килобайт флеша, 512 байт ОЗУ и пять-шесть свободных ножек — остаётся моим любимым доказательством того, что игре нужны не гигагерцы, а три вещи: состояние, таймер и одна кнопка. Клоны на SSD1306 живут на каждом втором сайте про Arduino не случайно: экран за двести рублей, кнопка, четыре провода — и вечер гарантирован. Моя версия на Uno пережила дачный вечер втроём: рекорд держался на четырнадцати трубах, спор о честной гравитации — до часу ночи. Строк в том скетче было сто двадцать — сотня с хвостом, как в заголовке урока.
Что собираем: птица у левого края, трубы едут справа налево со щелью на случайной высоте, счёт в шапке, столкновение — game over с экраном итогов и рестартом по кнопке. Прыжок — кнопка на D2 с INPUT_PULLUP: та самая классическая схема с подтяжкой, никаких новых деталей в макетку добавлять не придётся.
OLED для игры — принципиальный выбор, а не привычка: пиксель переключается за микросекунды, следа за движущейся точкой не остаётся, тогда как вязкие кристаллы LCD тянут за кадром серый шлейф — сравнение экранов я делал отдельным уроком. Плюс буферная модель: кадр собирается в ОЗУ и уходит одной посылкой.
Отсюда же — арифметика того легендарного ATtiny85: в его 512 байтах ОЗУ целый кадр SSD1306 физически не помещается — буфер один стоит 1024 байта, поэтому тамошние клоны рисуют постранично, частями. На Uno буфер влезает целиком, но съедает половину SRAM, а сверху просит String-конкатенации для счёта — держите строки короткими и не плодите их в loop без нужды.
Физика прыжка: скорость плюс гравитация
Вся физика — два числа и одна строка арифметики. Позиция y и вертикальная скорость v; каждый кадр скорость растёт на гравитацию, позиция — на скорость: v = v + G, затем y = y + v / 10. Прыжок — одномоментная установка скорости в минус: v = -45, минус четыре с половиной пикселя за кадр. При кадре 60 мс и гравитации +0.3 пикселя за кадр в квадрате апогей наступает через пятнадцать кадров — около секунды, высота прыжка выходит тридцать четыре пикселя, чуть больше половины экрана. Числа стартовые, не священные: крутите их первыми — ощущение игры меняется именно от них.
Скорость я храню в десятых долях пикселя — целым числом: −45 означает −4.5. Дроби в Arduino софтверные, на двадцати кадрах в секунду хватило бы и float, но целые десятые дают ровную предсказуемую арифметику без сюрпризов округления и с запасом по скорости на слабых платах — привычка, которую я заработал на метео-станции, где каждый проход loop считался.
Кнопка — по фронту, не по уровню: сравниваем текущее чтение с прошлым, и прыжок засчитывается один раз на нажатие, сколько бы оно ни длилось. Ловля по уровню — digitalRead(2) == LOW без памяти — отдаёт прыжок каждый кадр, пока кнопка зажата: птица прилипает к потолку. Фронт и дребезг здесь почти не страшны: лишний фронт в воздухе лишь чуть подхлестнёт взлёт, а на землю птицу он не вернёт.
Одна хитрость, которая спасает от потерь: кнопка опрашивается каждый проход loop, а физика — только раз в кадр. Нажатие между кадрами попадает в флаг-очередь: прыжок в очереди, применится на следующем кадре. Без этой очереди резкий тап за миллисекунду до пересчёта терялся бы молча — а в игре потерянный прыжок стоит партии и доверия к прибору.
#include <Wire.h>#include <Adafruit_GFX.h>#include <Adafruit_SSD1306.h> Adafruit_SSD1306 display(128, 64, &Wire, -1); int y = 32; // позиция птицы, 0..63int v = 0; // скорость в десятых пикселя за кадрint pipeX = 128; // левый край трубыint gapY = 20; // верх щели высотой 24 пикселяint schet = 0;int rekord = 0;bool counted = false;bool kn = false;long lastKadr = 0; void setup() { display.begin(SSD1306_SWITCHCAPVCC, 0x3C); Wire.setClock(400000); // кадр ~23 мс вместо ~92: иначе 20 fps не выйдет display.setTextColor(SSD1306_WHITE); pinMode(2, INPUT_PULLUP);} void loop() { bool ch = (digitalRead(2) == LOW); if (ch && !kn) v = -55; // прыжок по фронту: -5.5 пикселя за кадр kn = ch; if (millis() - lastKadr >= 50) { lastKadr = millis(); v = v + 3; // гравитация +0.3 пикселя за кадр y = y + v / 10; if (y < 4) { y = 4; v = 0; } if (pipeX > -14) pipeX = pipeX - 2; else { pipeX = 128; gapY = random(8, 44); counted = false; } if (pipeX + 14 < 16 && !counted) { counted = true; schet = schet + 1; if (schet > rekord) rekord = schet; } // грубый прямоугольник птицы 8x8 у x=16: столкновение с трубой или полом bool vTrube = (pipeX < 24 && pipeX + 14 > 16) && (y < gapY || y + 8 > gapY + 24); bool oPol = (y > 56); display.clearDisplay(); display.setTextSize(1); display.setCursor(0, 0); display.print(schet); if (vTrube || oPol) { display.setTextSize(2); display.setCursor(0, 24); display.print("SCHET "); display.print(schet); display.display(); while (digitalRead(2) == HIGH) { } // рестарт — по новой кнопке y = 32; v = 0; schet = 0; pipeX = 128; gapY = 20; counted = false; } else { display.fillRect(pipeX, 0, 14, gapY, SSD1306_WHITE); display.fillRect(pipeX, gapY + 24, 14, 64 - gapY - 24, SSD1306_WHITE); display.fillCircle(20, y, 4, SSD1306_WHITE); display.display(); } }}Трубы, счёт и game over
Труба — вертикальный столбец с щелью: на пиксельном экране это два fillRect сверху и снизу от зазора, в текстовой версии симулятора — символ решётки во всех рядах, кроме двух рядов щели. Высота щели у меня — два игровых ряда из шести, вертикаль выбирается random(1, 5) на каждый новый пролёт. Скорость препятствий задаёт ритм: столбец влево раз в четыре кадра — при кадре 60 мс это один столбец за четверть секунды, весь пролёт экрана около пяти секунд; медленнее — скучно, быстрее — нервно, границу я нашёл только игрой, не расчётом.
Очки начисляются за проход: когда труба пересекает колонку птицы и уходит левее, счёт растёт на единицу — флаг counted не даёт начислить дважды за одну трубу. Столкновение — труба напротив птицы вне щели или пол; дальше смена состояния: экран GAME OVER крупным шрифтом, счёт и рекорд. Рекорд живёт в переменной до сброса питания — для вечного рекорда просится EEPROM, это отдельная тема. Рестарт — только после отпускания кнопки и нового нажатия: если принимать нажатие сразу, тот же тап, что убил птицу, мгновенно запустит новую партию. Автомат из двух состояний — частный случай конечного автомата на светофоре.
Про картинку в симуляторе — честно: движок не рисует пиксели, поэтому экран там собран из символов — O это птица, решётка это трубы, точки это воздух. Вся логика — физика, счёт, состояния, рестарт — та же, что на плате, до последнего знака равенства. Пиксельная версия через fillCircle и fillRect стоит в код-блоке выше, а правила движения примитивов я раскладывал в уроке про графику. Общее у обоих миров одно: полная перерисовка кадра и один display() в конце — без этого экран рвётся.
Про звук — последняя строка атмосферы, и тут важен тип зуммера. tone() умеет ноты только на пассивном пьезо-излучателе: частоту задаёт скетч, и мой прыжок — это щелчок 1400 Гц на 50 миллисекунд, а гибель — низкий тон 250 Гц на полсекунды. Активный зуммер пищит одну свою ноту от уровня HIGH и в этой игре только мешает: различить прыжок и гибель он не может. Пин у меня — D8, он же в макетке симулятора.
И про честность случайности щелей: random без посадки зерна повторяет одну и ту же серию после каждого сброса — партии станут копиями друг друга. Мне хватает randomSeed(analogRead(A0)) со свободного входа; полный разбор сида и его ловушек — в уроке про random и randomSeed. В симуляторе зерно не работает — движок сидит на браузерном генераторе, и серии там всегда новые, — но на плате без него никуда.
| Константа | Стартовое значение | Что делает | Куда крутить |
|---|---|---|---|
| Кадр | 60 мс | Темп всей физики | Дольше — медленнее и проще |
| Прыжок | v = -45, то есть -4.5 пикселя за кадр | Сила взлёта | Больше по модулю — резче бросок |
| Гравитация | +3 в десятых, то есть +0.3 пикселя за кадр | Разгон падения | Больше — тяжелее держаться в воздухе |
| Труба | столбец раз в 4 кадра | Скорость препятствий | Реже — больше времени на решение |
- Гравитация от ручки. Добавьте в макетку потенциометр и заведите map(analogRead(A0), 0, 1023, 1, 6) вместо константы: сложность регулируется ползунком прямо во время партии.
- Узкая щель. Оставьте в щели один ряд вместо двух: random тот же, шансы падают вдвое — тест на точность тайминга.
- Две трубы. Держите в переменных два столбца со сдвигом фазы и двигайте оба: экранный ритм станет плотнее, флаг counted у каждого свой.
- Звук очков. Добавьте короткий tone(8, 1800, 40) при начислении очка — и счёт станет слышен, не отрывая глаз от экрана.
- Физика — скорость плюс гравитация: v = v + G и y = y + v / 10 каждый кадр; прыжок — однократная установка скорости в минус
- Прыжок ловится по фронту кнопки, а не по уровню — иначе птица получает импульс каждый кадр и липнет к потолку
- Кадры по millis: 60 мс в симуляторе; на Uno при 100 кГц честный потолок около 10 кадров в секунду (кадр ~92 мс), для 20 кадров разгоняйте шину до 400 кГц (~23 мс)
- Труба — столбец со щелью random(1, 5); очки за проход колонки птицы, флаг counted не даёт задвоить
- Game over — состояние, а не остановка: экран итогов, рекорд, рестарт только после отпускания и нового нажатия кнопки
- 1. Что происходит со скоростью птицы каждый кадр, пока кнопка не нажата?
- 2. Как устроен сам прыжок?
- 3. Почему при кадре 60 мс на Uno с шиной 100 кГц игра начнёт притормаживать?
- 4. Где находится щель трубы, если gapRow = 3?
- 5. Как принять рестарт, не поймав ту же кнопку, что убила птицу?
Игра собрана: прыжок по фронту, трубы на случайной высоте, счёт с флагом честности, экран итогов и аккуратный рестарт. В код-блоке — пиксельная версия для платы с разгоном шины до 400 кГц; в симуляторе вы уже сыграли партию-другую текстовой. Следующий шаг — рекорд в EEPROM, чтобы переживал выключение, и второй игрок на второй кнопке: движок у вас теперь есть.
Задание на плату: залейте пиксельную версию, найдите свои константы прыжка и гравитации — у меня после пары вечеров они осели на −5.5 и +0.3 при кадре 50 мс — и подпишите их маркером на корпусе. А дальше жанр снова сменит гравитация на приборы: следующий урок — панель из трёх датчиков на одном экране, где вместо труб будут зоны, ритмы обновления и борьба с хвостами старых цифр.
Частые вопросы по теме урока
- Что происходит со скоростью птицы каждый кадр, пока кнопка не нажата?
- v = v + G каждый кадр: падение ускоряется, как у тела в воздухе — в этом вся простота флаппи. Растёт на гравитацию
- Как устроен сам прыжок?
- Прыжок — импульс: v = -45 один раз на нажатие; дальше гравитация сама гасит подъём и тянет вниз. Скорость один раз ставится в отрицательное значение
- Почему при кадре 60 мс на Uno с шиной 100 кГц игра начнёт притормаживать?
- 1024 байта × 9 бит / 100 000 ≈ 92 мс на кадр; для двадцати кадров в секунду нужен Wire.setClock(400000) — тогда кадр сжимается до ~23 мс. Кадр передаётся около 92 мс — шина не успевает за таймером
- Где находится щель трубы, если gapRow = 3?
- Щель занимает два ряда: gapRow и следующий за ним — три и четыре в этом случае; остальные ряды столбца закрыты. В рядах 3 и 4
- Как принять рестарт, не поймав ту же кнопку, что убила птицу?
- Флаг отпускания: гибель не превращается в новую партию от того же тапа — сначала кнопка отпускается, потом нажимается заново. Ждать отпускания кнопки, затем нового нажатия