Разработка портативных игр на микроконтроллерах — это отличный способ изучить работу с графикой, аппаратными ресурсами и оптимизацией. В этой статье мы разберем финальную сборку проекта Dino Run v2.1.1 от разработчика AndiBond. Это полноценная аркада с продвинутыми механиками, которые редко встретишь в любительских проектах.
Что нового в версии v2.1.1 Golden-Heart?
Последнее обновление превратило простую «бегалку» в аркаду с глубоким геймплеем. Основные изменения коснулись баланса и интерфейса:
Что изменилось:
Пропустили предыдущие части?
Технические особенности версии v2.1.1
В отличие от базовых версий, сборка Golden-Heart включает в себя:
Что изменилось:
Симуляция в Wokwi: Тестируем без железа
Перед тем как брать в руки паяльник, я рекомендую проверить всё в онлайн-симуляторе. Это сэкономит время на отладку.
Попробовать игру в браузере:
Схема подключения
Краткое напоминание распиновки. Для звуковых эффектов обязательно используйте пассивный зуммер.
Важно: резистор на 100–220 Ом между GPIO 25 и плюсом зуммера нужен, чтобы ограничить ток и не перегружать порт ESP32
Тип зуммера: код использует функцию tone(), поэтому лучше всего подойдет пассивный зуммер (он позволяет менять тональность). Активный зуммер будет просто пищать на одной ноте.
| Компонент | Пин дисплея / кнопки | Пин ESP32 (GPIO) | Описание |
|---|---|---|---|
| OLED Display | VCC | 3.3V | Питание дисплея |
| OLED Display | GND | GND | Общий минус |
| OLED Display | SCL | GPIO 22 | Тактовая линия I2C |
| OLED Display | SDA | GPIO 21 | Линия данных I2C |
| Кнопка | Pin 1 | GPIO 15 | Сигнал прыжка |
| Кнопка | Pin 2 | GND | Замыкание на землю |
| Buzzer | Через резистор 100 Ом | GPIO 25 | Выход звукового сигнала |
| Buzzer | Negative (-) | GND | Общая земля |
Код проекта (v2.1.1)
Полный исходный код проекта (v2.1.1)
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Preferences.h>
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define BUZZER_PIN 25
#define BUTTON_PIN 15
// ДАННЫЕ ПРОЕКТА
const String version = "v2.1.1 Golden-Heart";
const String author = "AndiBond";
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
Preferences preferences;
// --- BITMAPS ---
const unsigned char dino_frame1[] PROGMEM = { 0x00, 0x00, 0x07, 0xf0, 0x07, 0xf8, 0x07, 0xfc, 0x07, 0xfc, 0x07, 0xf0, 0x07, 0xf0, 0x47, 0xe0, 0x47, 0xe0, 0xc7, 0xe0, 0xff, 0xe0, 0xff, 0xe0, 0x7f, 0xe0, 0x3f, 0xe0, 0x1c, 0x20, 0x18, 0x00 };
const unsigned char dino_frame2[] PROGMEM = { 0x00, 0x00, 0x07, 0xf0, 0x07, 0xf8, 0x07, 0xfc, 0x07, 0xfc, 0x07, 0xf0, 0x07, 0xf0, 0x47, 0xe0, 0x47, 0xe0, 0xc7, 0xe0, 0xff, 0xe0, 0xff, 0xe0, 0x7f, 0xe0, 0x3f, 0xe0, 0x08, 0x70, 0x00, 0x30 };
const unsigned char cactus_mini_bmp[] PROGMEM = { 0x18, 0x18, 0x18, 0x98, 0xdb, 0xff, 0xdb, 0x18, 0x18, 0x18 };
const unsigned char heart_bonus_bmp[] PROGMEM = { 0x66, 0xff, 0xff, 0xff, 0x7e, 0x3c, 0x18 };
const unsigned char star_bmp[] PROGMEM = { 0x18, 0x3c, 0x7e, 0xff, 0xff, 0x7e, 0x3c, 0x18 };
const unsigned char ptero_bmp[] PROGMEM = { 0x20, 0x00, 0x70, 0x00, 0xf8, 0x00, 0xff, 0x80, 0xff, 0xe0, 0x1f, 0x00, 0x0e, 0x00, 0x04, 0x00 };
// Параметры
int dinoY = 39, velocity = 0, gravity = 2;
bool isJumping = false, legToggle = false, starActive = false, isBossMode = false;
int score = 0, highScore = 0, lives = 3, heartsCollected = 0, level = 1, gameSpeed = 7;
unsigned long invincibilityUntil = 0, starEndTime = 0;
int obstacleX = 128, obstacleY = 45, obsType = 0, bonusX = -20, bonusY = 30, bonusType = 0;
int bossTimer = 0, bulletX = -20, bulletY = 45;
void setup() {
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(BUZZER_PIN, OUTPUT);
randomSeed(analogRead(0));
if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { for(;;); }
preferences.begin("dino-game", false);
highScore = preferences.getInt("highscore", 0);
showStartScreen();
}
void loop() {
if (digitalRead(BUTTON_PIN) == LOW && !isJumping) { velocity = -12; isJumping = true; tone(BUZZER_PIN, 600, 80); }
if (isJumping) { dinoY += velocity; velocity += gravity; if (dinoY >= 39) { dinoY = 39; isJumping = false; } }
else { if(millis() % 200 < 100) legToggle = !legToggle; }
if (!isBossMode) {
level = (score / 10) + 1;
gameSpeed = constrain(6 + level, 7, 14);
obstacleX -= gameSpeed;
if (starActive && bonusX > 20 && bonusX < 110) {
if (bonusY > dinoY + 4) bonusY--; else if (bonusY < dinoY + 4) bonusY++;
bonusX -= (gameSpeed + 2);
} else { bonusX -= gameSpeed; }
if (score > 0 && score % 100 == 0) { isBossMode = true; bossTimer = 500; bulletX = 120; tone(BUZZER_PIN, 100, 500); }
} else {
bossTimer--; bulletX -= (gameSpeed + 2);
if (bulletX < -10) { bulletX = 110; bulletY = random(0, 10) > 5 ? 45 : 30; }
if (bulletX < 32 && bulletX + 8 > 20 && !starActive && millis() > invincibilityUntil) {
if (dinoY < bulletY + 10 && dinoY + 16 > bulletY) { lives--; tone(BUZZER_PIN, 150, 300); invincibilityUntil = millis() + 1000; bulletX = -10; if (lives <= 0) handleGameOver(); }
}
if (bossTimer <= 0) { isBossMode = false; score += 10; tone(BUZZER_PIN, 1000, 500); obstacleX = 140; }
}
if (starActive && millis() > starEndTime) starActive = false;
if (!isBossMode && obstacleX < -20) {
score++; obstacleX = 128 + random(0, 40);
obsType = (level >= 2 && random(0, 10) > 7) ? 1 : 0;
obstacleY = (obsType == 1) ? random(25, 40) : 45;
if (score % 5 == 0 && bonusX < -10) {
bonusX = 128; bonusY = random(20, 40);
int r = random(0, 100);
bonusType = (r > 90) ? 2 : (r > 70 ? 1 : 0);
}
}
if (bonusX > 15 && bonusX < 35 && dinoY < bonusY + 8 && dinoY + 16 > bonusY) {
if (bonusType == 0) { heartsCollected++; if (heartsCollected >= 5) { heartsCollected = 0; if (lives < 3) lives++; } }
else if (bonusType == 1) { starActive = true; starEndTime = millis() + 7000; }
else if (bonusType == 2) { if (lives < 3) lives++; }
tone(BUZZER_PIN, 2000, 100); bonusX = -30;
}
if (!isBossMode && millis() > invincibilityUntil && !starActive && obstacleX < 32 && obstacleX + 8 > 20) {
if (dinoY < obstacleY + 10 && dinoY + 16 > obstacleY) { lives--; tone(BUZZER_PIN, 150, 300); invincibilityUntil = millis() + 1000; obstacleX = 140; if (lives <= 0) handleGameOver(); }
}
drawGame();
delay(35);
}
void printCenter(String text, int y) {
int16_t x1, y1; uint16_t w, h;
display.getTextBounds(text, 0, 0, &x1, &y1, &w, &h);
display.setCursor((128 - w) / 2, y);
display.print(text);
}
void drawGame() {
display.clearDisplay();
display.setTextColor(WHITE);
display.setCursor(0, 0); display.print("HP:"); display.print(lives);
display.setCursor(32, 0); display.print("H:"); display.print(heartsCollected);
if (isBossMode) { display.setCursor(55, 0); display.print("BOSS:"); display.print(bossTimer/10); }
else if (starActive) { display.setCursor(58, 0); display.print("*PWR*"); }
else { display.setCursor(62, 0); display.print("S:"); display.print(score); }
display.setCursor(98, 0); display.print("HI:"); display.print(highScore);
display.drawLine(0, 10, 128, 10, WHITE); display.drawLine(0, 55, 128, 55, WHITE);
if (isBossMode) {
display.drawBitmap(110, 35, cactus_mini_bmp, 8, 10, WHITE);
display.drawBitmap(bulletX, bulletY, cactus_mini_bmp, 8, 10, WHITE);
} else if (bonusX > -10) {
if (bonusType == 0) display.drawBitmap(bonusX, bonusY, heart_bonus_bmp, 8, 7, WHITE);
else if (bonusType == 1) display.drawBitmap(bonusX, bonusY, star_bmp, 8, 8, WHITE);
else { if ((millis() / 100) % 2 == 0) display.drawBitmap(bonusX, bonusY, heart_bonus_bmp, 8, 7, WHITE); display.drawRect(bonusX-1, bonusY-1, 10, 9, WHITE); }
}
if (millis() > invincibilityUntil || (millis() / 100) % 2 == 0) {
display.drawBitmap(20, dinoY, (isJumping ? dino_frame1 : (legToggle ? dino_frame1 : dino_frame2)), 16, 16, WHITE);
if (starActive) display.drawCircle(27, dinoY + 7, 11, WHITE);
}
if (!isBossMode) {
if (obsType == 0) display.drawBitmap(obstacleX, obstacleY, cactus_mini_bmp, 8, 10, WHITE);
else display.drawBitmap(obstacleX, obstacleY, ptero_bmp, 12, 8, WHITE);
}
display.display();
}
void handleGameOver() {
if (score > highScore) { highScore = score; preferences.putInt("highscore", score); }
display.clearDisplay(); display.setTextSize(1);
printCenter("GAME OVER", 10); printCenter(version, 22); printCenter(author, 34);
printCenter("Score: " + String(score) + " HI: " + String(highScore), 46);
display.display(); delay(3000); lives = 3; score = 0; heartsCollected = 0; isBossMode = false; showStartScreen();
}
void showStartScreen() {
display.clearDisplay(); display.setTextSize(1);
printCenter("DINO RUN", 10); printCenter(version, 25); printCenter(author, 40);
printCenter("Record: " + String(highScore), 55); display.display();
while(digitalRead(BUTTON_PIN) == HIGH) delay(10);
tone(BUZZER_PIN, 800, 100); delay(200);
}Разбор механик для разработчиков
Сохранение данных (NVS)
В проекте используется библиотека Preferences.h, которая в отличие от устаревшей EEPROM работает с именованными ключами. Это позволяет безопасно хранить highScore в системном разделе памяти ESP32.
Математика интерфейса
Для того чтобы игра выглядела «дорого», была реализована функция printCenter. Она динамически вычисляет ширину текстового блока и центрирует его на экране 128×64. Это исключает смещение текста при изменении версии или имени автора.
Логика Boss Fight
Битва с боссом реализована через состояние isBossMode. Это отдельный игровой цикл внутри основного loop, который перехватывает управление и меняет поведение врагов и системы начисления очков.

AndiBond.com
Заключение
Версия v2.1.1 Golden-Heart является финальной в линейке классических Dino Run. Она демонстрирует, как с помощью одной кнопки и недорогих компонентов можно реализовать глубокий и азартный геймплей.
Поддержите проект AndiBond
Создание качественных гайдов, поиск рабочих решений и отладка кода занимают много времени. Все мои проекты остаются открытыми и бесплатными, чтобы каждый мог войти в мир электроники с минимальным порогом входа.
Если этот туториал сэкономил ваше время или помог запустить вашу первую игру на ESP32, вы можете поддержать развитие блога. Ваша поддержка помогает мне покупать новые датчики, дисплеи и контроллеры для будущих обзоров.
Каждый донат — это топливо для новых статей и видео. Спасибо, что вы со мной!»

