Первый мой человечек на OLED не шагал — он скользил. Одна поза, нарисованная через drawBitmap, честно ехала по экрану слева направо, но ноги были приколоты к земле намертво: фигурка катилась, как на коньках. Проблема не в коде движения, а в кадрах: живая ходьба — это смена поз, минимум две, а лучше три-четыре, и каждая поза живёт в памяти отдельным битмапом. Сегодня собираю полноценный спрайт: позы шага из PROGMEM, переключение кадров по millis с настраиваемым темпом, движение по экрану и стирание старого места — все четыре детали, без которых фигурка либо мерцает, либо пачкает за собой след.
Базу мы уже закладывали: как битмап превращается в массив байтов и сколько он весит — разобрано в уроке про drawBitmap, а конвертер картинок в C-массив показан в уроке про image2cpp. Здесь то же самое работает в цикле: не одна картинка, а очередь кадров, которую листает таймер. Железо прежнее — SSD1306 128×64 на I2C; для симуляции темпа пригодится ручка на A0, для паузы — кнопка на D2.
План: две позы шага с байтами, полный скетч ходьбы через весь экран, разбор граблей с хвостами старых кадров и симулятор, где позы видны в текстовой сетке.
Две позы шага: байты и память
Спрайт человечка у меня 16×16 пикселей: голова, руки, туловище и ноги, которые и делают всю работу. Две позы — ноги врозь и ноги вместе — дают узнаваемый шаг: чередование читается глазом именно на смене ширины постановки ног. Четыре позы дали бы плавнее походку, но на 16 пикселях высоты разница между третьей и четвёртой позой тонет: детали меньше двух пикселей на этом размере сливаются в шум. Я много раз пробовал дорисовывать промежуточные кадры и вернулся к двум — при 24×24 и выше расширенный цикл уже оправдан.
Теперь память. Один кадр 16×16 — это 16 строк по два байта, итого 32 байта; две позы — 64 байта, и в PROGMEM они почти не чувствуются. Но масштаб считается заранее: кадр размером со весь экран занимает 128×64/8 = 1024 байта, а SRAM у Uno всего 2048 байта — два полных кадра в обычном массиве съедят половину оперативки ещё до первой переменной. Поэтому каждый массив поз помечается словом PROGMEM: байты лежат во flash, а в оперативку читаются по одному через pgm_read_byte в момент отрисовки — библиотека Adafruit_GFX делает это внутри drawBitmap сама.
Темп шага задаёт период кадра. Мой диапазон — 100-150 миллисекунд на позу: при 120 мс получается два шага в секунду, живо, но без бешеной дрожи. Ниже 60 мс позы сливаются в кашу, выше 250 мс ходьба превращается в статичные остановки. Период держим в константе и сравниваем с millis — механику неблокирующих таймеров мы проходили в уроке про millis вместо delay, здесь она листает кадры.
Размер спрайта выбирайте по той же арифметике. 8×8 весит восемь байт на позу, но человечка в восьми пикселях высоты не собрать: только намёк на фигуру. 32×32 даёт уже мимику и складки одежды, но каждый кадр стоит 128 байт, а зона стирания расползается. 16×16 — мой компромисс: 32 байта на позу, четыре позы — 128 байт во flash, и глаз уже читает шаг. Начинайте с малого размера и увеличивайте только когда видите, что не хватает именно деталей, а не кадров.
| Параметр | Значение | Почему так |
|---|---|---|
| Позы шага | 2 кадра 16×16 | Детали мельче 2 px сливаются; 4 позы оправданы от 24×24 |
| Период кадра | 100-150 мс | 120 мс = около двух шагов в секунду; ниже 60 мс — каша |
| Шаг по x | 2 px за кадр | 1 px едва ползёт, 4 px рвёт шаг при периоде 120 мс |
| Стирание | fillRect по старой зоне | drawBitmap ставит пиксели, но ничего не подтирает |
| Массивы | PROGMEM, 64 байта на пару | Кадр со весь экран — 1024 байта против 2048 SRAM |
Скетч: ходьба через весь экран
Adafruit_SSD1306 display(128, 64, &Wire, -1); // Pozy shaga 16x16: 16 strok po 2 bayta = 32 bayta na kadr.// Bajty sobrany v image2cpp; PROGMEM derzhit ih vo flash.const uint8_t PROGMEM shagA[] = { 0x01, 0xC0, 0x03, 0xE0, 0x07, 0xF0, 0x07, 0xF0, 0x07, 0xF0, 0x03, 0xE0, 0x01, 0xC0, 0x00, 0x80, 0x0F, 0xF8, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x01, 0x40, 0x01, 0x40, 0x02, 0x20, 0x04, 0x10 };const uint8_t PROGMEM shagB[] = { 0x01, 0xC0, 0x03, 0xE0, 0x07, 0xF0, 0x07, 0xF0, 0x07, 0xF0, 0x03, 0xE0, 0x01, 0xC0, 0x00, 0x80, 0x0F, 0xF8, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x01, 0x40, 0x01, 0x40, 0x01, 0x40, 0x01, 0x40 }; int x = 0; // levyy kray sprajtaint pozy = 0; // 0 ili 1 - aktivnaya pozalong tKadra = 0;const long PERIOD = 120; void setup() { display.begin(SSD1306_SWITCHCAPVCC, 0x3C); display.setTextColor(SSD1306_WHITE);} void loop() { if (millis() - tKadra >= PERIOD) { tKadra = millis(); // 1. Steret STARUYU pozu: zony odinakovye, menyaetsya x display.fillRect(x, 44, 16, 17, SSD1306_BLACK); // 2. Dvinutsya i perevernut kadr x += 2; if (x > 112) x = 0; pozy = 1 - pozy; // 3. Narisovat novuyu pozu na novom meste if (pozy == 0) { display.drawBitmap(x, 44, shagA, 16, 16, SSD1306_WHITE); } else { display.drawBitmap(x, 44, shagB, 16, 16, SSD1306_WHITE); } // 4. Zemlya pod nogami i vyvod kadra display.drawLine(0, 61, 127, 61, SSD1306_WHITE); display.display(); }}Порядок внутри кадра критичен. Сначала fillRect заливает чёрным прежнюю зону спрайта, потом сдвигается x, потом рисуется новая поза, и только в конце display() выталкивает буфер на экран. Если поменять местами стирание и отрисовку, экран на мгновение покажет две фигуры в соседних клетках — заметно на шаге 2 пикселя, когда зоны поз перекрываются. drawBitmap ставит белые пиксели поверх буфера, но старые не трогает: оставленный без стирания предыдущий кадр даёт хвост из ног через весь экран — классика первого запуска.
Пауза и рестарт получаются бесплатными. Пока millis не дотянул до периода, loop крутится вхолостую, и остановить фигуру — значит просто не запускать листание: одного флага вокруг всего блока if хватает. Позиция и номер позы живут в глобальных переменных, поэтому после паузы шаг продолжается с того же места — отдельное состояние «пауза» хранить не нужно. В игре этого мало: там фигура ещё и прыгает, а физику прыжка и столкновений разбирает урок про Flappy Bird — тот же каркас кадров плюс одна переменная вертикальной скорости.
Второй нюанс — движение против поз. Шаг по x на 2 пикселя за кадр при периоде 120 мс согласован с шириной постановки ног: если двигать на 4 пикселя, фигура проскакивает межкадровое расстояние и походка рвётся, хотя сами позы меняются исправно. Темп и скорость — разные ручки: период листает кадры, шаг по x ведёт фигуру по земле. На этот разбор я потратил вечера два, пока не полез с примером в чужой скетч игры — там дисциплина кадров уже стояла, и урок про анимацию без мерцания разобрал её по косточкам.
Буфер и display() работают так, что промежуточных состояний на экране не видно вообще: всё, что нарисовано до display(), живёт только в RAM. Почему так устроено и где лежит буфер 1024 байта — отдельная тема, разобранная в уроке про буфер и clearDisplay. Для анимации это означает свободу: можно стереть, перерисовать и подвинуть хоть пять объектов за один кадр — экран увидит только итог.
Тот же каркас ест любой спрайт: сердце с двумя фазами пульсации, капля дождя, стрелка компаса — меняются массивы и ширина зоны стирания. Развилка одна: спрайт едет по экрану — стирайте старую зону и сдвигайте x; спрайт стоит на месте — достаточно перерисовывать только его клетку, и кадр почти ничего не стоит. У меня сердечко на будильнике живёт в углу шапки рядом с часами: 16×16, две фазы, период полсекунды, и таймеры не мешают друг другу, потому что у каждого свой момент на millis.
В симуляторе видно главное свойство цикла: позы сменяются строго по таймеру, а не по скорости loop — выкрутите ручку в верхнее положение, и дрожь кадра станет заметной, в нижнее — фигура делает паузу в каждой позе. В паузе по кнопке таймер стоит, но позиция сохраняется: продолжение идёт с того же места, потому что x и поза живут в глобальных переменных между кадрами. На плате этот же каркас рисует уже байты из PROGMEM: симулятор честно показывает логику листания, а пиксельную картинку даёт железо.
Если фигура ходит в обе стороны, зону стирания считают от старой позиции в сторону новой: fillRect от меньшего x шириной 16 плюс |dx| — иначе при смене направления за спиной остаётся столбик. Дешевле держать dx одной переменной и стирать объединение двух зон — это две строки кода против отдельной ветки на каждое направление.
- 1. Сколько байт занимает один кадр спрайта 16×16?
- 2. Зачем массивам поз слово PROGMEM?
- 3. Что случится без стирания старой зоны перед новым кадром?
- 4. Какой период кадра даёт примерно два шага в секунду?
- 5. Почему движение по x и период кадров — разные настройки?
- Ходьба — это смена поз: две позы 16×16 дают узнаваемый шаг, четыре оправданы от 24×24
- Кадр 16×16 весит 32 байта; кадры держат в PROGMEM: кадр со весь экран — 1024 байта против 2048 SRAM
- Кадры листает таймер на millis: период 100-150 мс — живой шаг, ниже 60 мс позы сливаются
- Порядок кадра: стереть старую зону fillRect, сдвинуть x, нарисовать позу, display() в конце
- Темп и шаг по x — отдельные ручки: 2 px за кадр при 120 мс согласованы с шириной ног
Частые вопросы по теме урока
- Сколько байт занимает один кадр спрайта 16×16?
- 16 строк по два байта: восемь пикселей упакованы в один байт, итого 32 байта. Кадр размером со весь экран 128×64 весит уже 1024 байта. 32
- Зачем массивам поз слово PROGMEM?
- SRAM у Uno 2048 байта, кадр со весь экран — 1024: два таких массива в оперативке оставят скетч без памяти. PROGMEM держит байты во flash. Байты лежат во flash и не съедают SRAM Uno
- Что случится без стирания старой зоны перед новым кадром?
- drawBitmap только ставит пиксели поверх буфера. Прежняя поза остаётся, и фигура тащит за собой хвост — лечится fillRect чёрным по старой зоне. За фигурой потянется след из прежних поз
- Какой период кадра даёт примерно два шага в секунду?
- При 120 мс на позу и двух позах цикл занимает 240 мс — около четырёх смен кадра и двух видимых шагов в секунду. 60 мс превращает позы в кашу. 120 мс
- Почему движение по x и период кадров — разные настройки?
- Шаг 4 px при периоде 120 мс рвёт походку, хотя позы меняются правильно: темп и скорость движения согласуются по ширине постановки ног. Период листает позы, шаг по x ведёт фигуру — согласовать надо отдельно