Dino Run v2.1.1 Golden-Heart (Часть 5): Эволюция легендарной игры на ESP32

Разработка портативных игр на микроконтроллерах — это отличный способ изучить работу с графикой, аппаратными ресурсами и оптимизацией. В этой статье мы разберем финальную сборку проекта Dino Run v2.1.1 от разработчика AndiBond. Это полноценная аркада с продвинутыми механиками, которые редко встретишь в любительских проектах.

Что нового в версии v2.1.1 Golden-Heart?

Последнее обновление превратило простую «бегалку» в аркаду с глубоким геймплеем. Основные изменения коснулись баланса и интерфейса:

Что изменилось:
  • 1. Система бонусов и выживания
    В игру внедрены три типа выпадающих предметов:
    Обычные сердца: Накопительная система. Соберите 5 штук, чтобы восстановить 1 единицу здоровья (HP).
    Звезда (Power-Up): Дает временную неуязвимость и активирует магнит. Во время действия звезды бонусы сами притягиваются к игроку.
    Золотое сердце: Редчайший артефакт (шанс появления 10%), который мгновенно восстанавливает +1 HP без необходимости долгого сбора.
  • 2. Режим Босса (Epic Boss Fight)
    Каждые 100 набранных очков игра переходит в режим сражения. Справа появляется Мега-Кактус, который атакует игрока снарядами на разной высоте. Игроку нужно продержаться 50 секунд (игровых тиков), чтобы победить босса и получить бонусные очки.
  • 3. Оптимизированный UI и центрирование
    Для версии v2.1.1 был полностью переписан механизм вывода текста. Теперь заголовок игры, версия и авторство идеально центрируются по горизонтали независимо от длины строки, что придает игре профессиональный вид.

Технические особенности версии v2.1.1

В отличие от базовых версий, сборка Golden-Heart включает в себя:

Что изменилось:
  • Интеллектуальная система бонусов: Обычные сердца (сбор 5 штук для +1 HP) и редкое Золотое сердце (мгновенное восстановление жизни).
  • Механика Power-Up: Магнитная звезда дает бессмертие и притягивает бонусы к игроку.
  • Битвы с Боссами: Каждые 100 очков игра переходит в режим Boss Fight с уникальными паттернами атак.
  • Энергонезависимая память: Рекорды сохраняются в NVS-память ESP32 через библиотеку Preferences.

Симуляция в Wokwi: Тестируем без железа

Перед тем как брать в руки паяльник, я рекомендую проверить всё в онлайн-симуляторе. Это сэкономит время на отладку.

Попробовать игру в браузере:

Схема подключения

Краткое напоминание распиновки. Для звуковых эффектов обязательно используйте пассивный зуммер.

Важно: резистор на 100–220 Ом между GPIO 25 и плюсом зуммера нужен, чтобы ограничить ток и не перегружать порт ESP32

Тип зуммера: код использует функцию tone(), поэтому лучше всего подойдет пассивный зуммер (он позволяет менять тональность). Активный зуммер будет просто пищать на одной ноте.

КомпонентПин дисплея / кнопкиПин ESP32 (GPIO)Описание
OLED DisplayVCC3.3VПитание дисплея
OLED DisplayGNDGNDОбщий минус
OLED DisplaySCLGPIO 22Тактовая линия I2C
OLED DisplaySDAGPIO 21Линия данных I2C
КнопкаPin 1GPIO 15Сигнал прыжка
КнопкаPin 2GNDЗамыкание на землю
BuzzerЧерез резистор 100 ОмGPIO 25Выход звукового сигнала
BuzzerNegative (-)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, который перехватывает управление и меняет поведение врагов и системы начисления очков.

Dino Run v2.1.1 Golden-Heart

AndiBond.com

Заключение

Версия v2.1.1 Golden-Heart является финальной в линейке классических Dino Run. Она демонстрирует, как с помощью одной кнопки и недорогих компонентов можно реализовать глубокий и азартный геймплей.


Поддержите проект AndiBond

Создание качественных гайдов, поиск рабочих решений и отладка кода занимают много времени. Все мои проекты остаются открытыми и бесплатными, чтобы каждый мог войти в мир электроники с минимальным порогом входа.

Если этот туториал сэкономил ваше время или помог запустить вашу первую игру на ESP32, вы можете поддержать развитие блога. Ваша поддержка помогает мне покупать новые датчики, дисплеи и контроллеры для будущих обзоров.

Каждый донат — это топливо для новых статей и видео. Спасибо, что вы со мной!»

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *