C++ GameDev: От консоли к клону Cuphead

Практический курс для перехода от базового синтаксиса к созданию графических 2D-игр. Вы освоите ООП, работу с памятью и библиотеку SFML для реализации механик жанра Run and Gun.

1. Архитектура на классах: Инкапсуляция игрока, врагов и использование функций

Архитектура на классах: Инкапсуляция игрока, врагов и использование функций

Приветствую, будущий создатель игр! Ты уже освоил фундамент C++: переменные, циклы и ветвления. Это кирпичи, из которых строится программа. Но чтобы построить не просто сарай, а сложный небоскреб (или в твоем случае — клон Cuphead), тебе нужен чертеж и правильная структура. В программировании эту роль играют классы и объектно-ориентированное программирование (ООП).

В этой статье мы перейдем от написания кода «сплошной простыней» в main() к созданию архитектуры. Мы спроектируем сущности Игрока и Врага, научимся прятать их данные (инкапсуляция) и заставим их взаимодействовать через функции-методы.

Почему процедурный код не подходит для Boss Rush?

Представь, что ты пишешь игру, используя только переменные и массивы. Для одного игрока тебе понадобятся:

* int playerX, playerY; * int playerHP; * int playerAmmo;

А теперь представь, что у тебя есть босс, который стреляет 50 снарядами. Тебе придется создавать массивы для координат каждого снаряда: int bulletX[50], int bulletY[50], bool bulletActive[50]. Код превратится в хаос, где изменение одной переменной может сломать всю игру. Это называется «спагетти-код».

Решение — Классы. Класс — это чертеж, описывающий сущность. Объект — это конкретный экземпляр, созданный по этому чертежу.

!Различие между классом (чертежом) и объектами (реализациями)

Инкапсуляция: Защита данных

Первый кит ООП, который нам нужен — это инкапсуляция. Это объединение данных и методов работы с ними в одну упаковку (класс) и скрытие деталей реализации от пользователя.

В играх типа Run and Gun здоровье игрока не должно меняться произвольно из любой части программы. Оно должно уменьшаться только когда игрок получает урон.

В C++ для этого используются модификаторы доступа:

* private: данные доступны только внутри самого класса (скрыты от внешнего мира). * public: данные или методы доступны из любой части программы.

Проектируем класс Player

Давай создадим класс для нашего героя. У него должны быть координаты, здоровье и методы для движения.

Обрати внимание: мы не даем прямого доступа к hp. Мы даем метод takeDamage. Если в будущем мы захотим добавить звук при получении урона или эффект неуязвимости, мы добавим это в один метод, и это заработает везде.

Математика движения

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

Формула равномерного прямолинейного движения в дискретном времени (каждый кадр) выглядит так:

Где: * — новая позиция объекта (координата или ). * — текущая позиция объекта. * — скорость движения (пиксели в секунду или единицы в кадр). * — шаг времени (время, прошедшее с прошлого кадра).

В нашем простом консольном примере мы пока пренебрегаем и считаем, что один вызов функции — это один шаг времени, поэтому формула упрощается до .

Проектируем класс Enemy

Враги в Cuphead ведут себя иначе, чем игрок. Они часто движутся по паттернам. Создадим простой класс врага.

Здесь метод update() — это сердце логики врага. В игровом цикле мы будем вызывать update() для каждого врага, и они будут «жить» своей жизнью.

Связываем всё вместе: Игровой цикл

Теперь, когда у нас есть классы, функция main становится чистой и понятной. Она превращается в Игровой Цикл (Game Loop).

Игровой цикл обычно состоит из трех этапов:

  • Input: Чтение ввода игрока.
  • Update: Обновление состояния мира (движение врагов, проверка столкновений).
  • Render: Отрисовка кадра.
  • !Основные этапы игрового цикла

    Вот как это выглядит в коде (псевдо-код для консоли):

    Преимущества такого подхода

  • Масштабируемость: Если ты захочешь добавить второго игрока (Mugman), тебе нужно просто написать Player mugman(150, 100);. Тебе не нужно копировать десятки переменных.
  • Безопасность: Ты не можешь случайно изменить здоровье врага, пытаясь изменить его координату, потому что данные разделены по разным объектам.
  • Читаемость: Код в main читается как сценарий: «Игрок двигается, враг обновляется, проверяем столкновение».
  • Заключение

    Мы сделали огромный шаг вперед. Вместо разрозненных переменных мы теперь мыслим объектами. Игрок — это объект, который «знает» свое здоровье и «умеет» бегать. Враг — это объект, который «умеет» патрулировать.

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

    2. Управление памятью и STL: Переход от массивов к векторам и работа с указателями

    Управление памятью и STL: Переход от массивов к векторам и работа с указателями

    Приветствую, архитектор игровых миров! В прошлой статье мы научились создавать чертежи наших сущностей — классы Player и Enemy. Мы инкапсулировали данные и заставили объекты взаимодействовать. Но мы столкнулись с серьезным ограничением: мы создавали объекты вручную, по одному.

    В реальном уровне типа «Run and Gun» на игрока могут лететь сотни пуль, а враги могут появляться и исчезать динамически. Если ты попытаешься объявить Enemy enemy1, enemy2, ..., enemy100;, твой код превратится в ночной кошмар. Обычные статические массивы (int arr[10]) тоже не подходят, так как их размер жестко задан до запуска игры.

    Сегодня мы разберем две фундаментальные темы C++, которые превратят твой код из статичной картинки в живой механизм: указатели и динамические массивы (std::vector).

    Что такое память и где живут переменные?

    Чтобы понять, как управлять армией врагов, нужно понять, как компьютер хранит данные. Представь память компьютера как огромный шкаф с миллионами пронумерованных ячеек.

    Когда ты пишешь int hp = 3;, компьютер:

  • Находит свободную ячейку.
  • Запоминает, что имя hp привязано к номеру этой ячейки (адресу).
  • Кладет туда число 3.
  • !Визуализация того, как переменная связана с адресом в памяти.

    Стек (Stack) и Куча (Heap)

    В C++ есть две основные области памяти:

  • Стек (Stack): Здесь живут локальные переменные. Это очень быстрая память, но она работает автоматически. Когда функция завершается, все переменные стека уничтожаются. Размер стека ограничен.
  • Куча (Heap): Это огромное пространство для данных, которые должны жить долго или размер которых мы не знаем заранее. Управление этой памятью — твоя задача (или задача умных контейнеров).
  • Указатели: Карта сокровищ

    Указатель — это переменная, которая хранит не само значение (например, здоровье), а адрес другой переменной в памяти. Это как записка, на которой написано: «Клад лежит в ячейке №5042».

    Для работы с указателями используются два оператора: * & (амперсанд): Взять адрес переменной («Где ты живешь?»). (звездочка): Получить значение по адресу («Что лежит в этом доме?»).

    Зачем это нужно в играх? Чтобы передавать тяжелые объекты (например, босса с загруженными текстурами) в функции, не копируя их целиком, а просто сообщая функции: «Босс находится по этому адресу».

    STL и std::vector: Прощайте, статические массивы

    В C++ есть Standard Template Library (STL) — набор готовых инструментов. Самый важный для нас сейчас — это std::vector.

    Вектор — это умный динамический массив. Он умеет: * Растягиваться, когда ты добавляешь элементы. * Сжиматься, когда ты их удаляешь. * Хранить сколько угодно объектов (пока есть оперативная память).

    Чтобы использовать его, нужно подключить библиотеку:

    Создание армии врагов

    Давай заменим одинокого врага из прошлого урока на целую орду, используя вектор.

    Итерация: Обход всех врагов

    В игровом цикле нам нужно обновить состояние каждого врага. Для этого мы пробегаем по вектору циклом.

    Проблема удаления: «Мертвые души»

    В играх враги умирают или улетают за экран. Если мы не будем их удалять из памяти, игра начнет тормозить, потребляя всё больше ресурсов. Это называется утечкой производительности (хотя технически вектор освободит память при закрытии, забивать его мусором нельзя).

    Представим, что мы хотим рассчитать объем памяти, занимаемый нашими врагами. Формула будет выглядеть так:

    Где: * — общий объем занимаемой памяти (в байтах). * — количество врагов в векторе. * — размер одного объекта класса Enemy (в байтах).

    Если будет постоянно расти и никогда не уменьшаться, заполнит всю оперативную память.

    Как правильно удалять из вектора?

    У вектора есть метод erase, но с ним нужно быть осторожным внутри цикла for. Если ты удалишь элемент под индексом 0, все остальные элементы сдвинутся влево, и элемент 1 станет 0. Если счетчик цикла i увеличится, ты пропустишь одного врага.

    Лучший способ для новичка — идти по вектору с конца:

    !Иллюстрация того, как вектор перестраивает данные после удаления элемента.

    Динамическое создание пуль

    Теперь применим это к стрельбе. Игрок нажимает пробел — создается новая пуля. Пуля летит, попадает во врага или улетает — пуля удаляется.

    Такой подход позволяет иметь на экране хоть 1, хоть 1000 пуль одновременно, не меняя код программы.

    Указатели и полиморфизм (Задел на будущее)

    Иногда в векторе хранят не сами объекты, а указатели на них: std::vector<Enemy*>. Это нужно, когда у нас есть разные типы врагов (Летающий, Бегающий, Босс), которые наследуются от одного базового класса Enemy. Вектор объектов vector<Enemy> может хранить только базовых врагов, а вектор указателей может указывать на любого наследника. Но об этом — в следующих уроках про Наследование.

    Итог

    Сегодня мы совершили революцию в архитектуре нашей игры:

  • Узнали, что память имеет адреса, и с ними можно работать через указатели.
  • Заменили жесткие массивы на гибкие векторы (std::vector).
  • Научились динамически добавлять (push_back) и удалять (erase) игровые объекты.
  • Теперь ты можешь создать уровень, где враги появляются волнами, а игрок поливает их градом пуль. В следующей статье мы наконец-то отойдем от скучной консоли и подключим графическую библиотеку, чтобы увидеть нашего Cuphead во всей красе!

    Готовься, будет красочно!

    3. Графический движок: Подключение SFML, игровой цикл и отрисовка спрайтов

    Графический движок: Подключение SFML, игровой цикл и отрисовка спрайтов

    Приветствую, разработчик! Мы прошли долгий путь, работая с черным экраном консоли и белым текстом. Мы научились создавать классы, управлять памятью и векторами. Но давай будем честны: Cuphead не стал бы хитом, если бы он выглядел как таблица в Excel. Играм нужна графика.

    Сегодня мы совершим квантовый скачок: мы откажемся от iostream и подключим настоящую графическую библиотеку. Твой код начнет открывать окна, рисовать картинки и реагировать на события мыши и клавиатуры в реальном времени.

    Что такое SFML?

    Для создания графики мы будем использовать SFML (Simple and Fast Multimedia Library). Это библиотека для C++, которая берет на себя сложную работу по общению с видеокартой, операционной системой и звуковой картой.

    Почему именно SFML? * Простота: Она идеально подходит для 2D-игр. * Объектно-ориентированность: Она написана на C++ и отлично ложится на те знания о классах, которые ты получил в прошлых уроках.

    Создание окна: Твой первый холст

    В консольных программах окно нам давала операционная система. В графическом приложении мы должны создать его сами. Главный класс здесь — sf::RenderWindow.

    Вот минимальный код для открытия окна:

    Анатомия графического цикла

    Вспомни наш цикл while(true) из консольной игры. В графике он работает по строгому алгоритму, который называется Double Buffering (Двойная буферизация). Чтобы картинка не мерцала, мы рисуем всё на скрытом «холсте» (в памяти), а потом мгновенно показываем его пользователю.

    !Принцип двойной буферизации: пока ты видишь один кадр, компьютер уже рисует следующий.

    Цикл состоит из трех обязательных шагов:

  • Clear (Очистка): Мы заливаем окно цветом (обычно черным), стирая всё, что было в прошлом кадре. Если этого не сделать, движущиеся объекты будут оставлять за собой «шлейф».
  • Draw (Отрисовка): Мы даем команды нарисовать спрайты, текст, фигуры. Важно: порядок имеет значение! То, что нарисовано позже, перекроет то, что нарисовано раньше.
  • Display (Отображение): Мы меняем скрытый буфер и видимый экран местами. Только в этот момент игрок видит изменения.
  • Координатная плоскость в 2D

    В школе нас учили, что ось направлена вверх. В компьютерной графике (и в SFML) это не так. Начало координат находится в левом верхнем углу экрана.

    * Ось направлена вправо. * Ось направлена вниз.

    Если ты хочешь опустить персонажа ниже, ты должен увеличивать его координату .

    Формула новой позиции при движении вниз:

    где — новая координата по вертикали, — текущая координата, а — скорость движения (положительное число).

    Спрайты и Текстуры: Краска и Холст

    Чтобы отобразить нашего героя (Cuphead), нам нужны две сущности: Текстура и Спрайт.

  • sf::Texture (Текстура): Это само изображение (пиксели), загруженное из файла (например, .png). Она тяжелая и хранится в видеопамяти.
  • sf::Sprite (Спрайт): Это легкий объект, который «знает», какую текстуру использовать, и где её нарисовать (координаты, поворот, масштаб).
  • > Представь, что Текстура — это банка с краской или штамп, а Спрайт — это отпечаток этого штампа на бумаге. Ты можешь сделать 100 спрайтов, используя одну текстуру.

    Пример загрузки и отрисовки

    Важное правило: Текстура должна существовать (жить в памяти) всё время, пока существует спрайт. Если текстура выйдет из области видимости и уничтожится, спрайт превратится в «Белый квадрат» (White Square Problem).

    Интеграция с нашими классами

    Помнишь наш класс Player? Давай обновим его для работы с SFML. Вместо просто переменных x и y, у него теперь будет свой спрайт.

    Теперь в main игровой цикл будет выглядеть так:

    Заключение

    Поздравляю! Ты перешел из текстового мира в графический. Теперь у тебя есть окно, игровой цикл и спрайты. Твой «движок» начал обретать форму.

    В следующей статье мы разберем анимацию: как заставить Cuphead бежать, а не просто скользить по экрану, используя смену кадров текстуры.

    Официальная документация SFML

    4. Физика и взаимодействие: Обработка ввода, гравитация, стрельба и детекция коллизий

    Физика и взаимодействие: Обработка ввода, гравитация, стрельба и детекция коллизий

    Приветствую, коллега! В прошлый раз мы научились рисовать спрайты на экране. Наш Cuphead (или пока просто квадрат с текстурой) уже умеет появляться в окне. Но давай признаем честно: пока это не игра, а интерактивная картина. Персонаж проходит сквозь стены, пули не вылетают, а прыжок невозможен, потому что нет гравитации.

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

    Delta Time: Почему твоя игра работает с разной скоростью?

    Прежде чем писать физику, нужно решить фундаментальную проблему. Если ты напишешь x += 5 в игровом цикле, скорость персонажа будет зависеть от мощности компьютера. На мощном ПК, выдающем 2000 кадров в секунду (FPS), герой улетит за край экрана мгновенно. На старом ноутбуке с 30 FPS он будет ползти как черепаха.

    Чтобы движение было плавным и одинаковым везде, мы используем Delta Time (). Это время, прошедшее с отрисовки предыдущего кадра.

    В SFML для этого есть класс sf::Clock:

    Теперь формула равномерного движения выглядит так:

    Где: * — новая позиция объекта. * — старая позиция. * — скорость (пиксели в секунду). * — время, прошедшее с последнего кадра (в секундах).

    Гравитация и Прыжки: Основы кинематики

    Платформер без гравитации — это вид сверху. В платформере типа Cuphead персонаж всегда должен стремиться упасть вниз, пока не ударится о землю.

    Физика прыжка строится на изменении вертикальной скорости. У нас появляются две силы:

  • Гравитация: Постоянно увеличивает скорость падения (тянет вниз).
  • Импульс прыжка: Мгновенно задает большую скорость вверх.
  • !Схема изменения скорости персонажа во время прыжка под действием гравитации.

    Давай добавим физические свойства в наш класс Player.

    Формула изменения скорости под действием гравитации:

    Где: * — новая вертикальная скорость. * — текущая вертикальная скорость. * — ускорение свободного падения (константа). * — шаг времени.

    Обработка ввода: События против Опроса

    В SFML есть два способа узнать, что нажал игрок. И для Run and Gun нам нужны оба.

    1. Real-time Input (Опрос состояния)

    Используется для движения. Нам нужно знать, удерживает ли игрок кнопку «Вправо» прямо сейчас.

    2. Event Polling (Обработка событий)

    Используется для однократных действий, таких как прыжок или выстрел. Если использовать isKeyPressed для стрельбы, то за одно короткое нажатие вылетит 50 пуль, потому что цикл работает очень быстро. Нам нужно поймать именно момент нажатия.

    Стрельба: Векторы и управление памятью

    Мы уже обсуждали векторы (std::vector) в уроке про память. Теперь применим их на практике. Стрельба — это создание нового объекта Bullet в точке, где находится игрок, и задание ему скорости.

    Создадим простую структуру пули:

    В основном классе игры (Game) у нас будет список пуль:

    Не забывай удалять пули, которые улетели за экран! Иначе через 10 минут игры память переполнится.

    Детекция коллизий: AABB

    Самое интересное в шутере — попадание. Как компьютер понимает, что картинка пули коснулась картинки врага? Мы не проверяем каждый пиксель (это слишком медленно). Мы используем упрощенную модель: AABB (Axis-Aligned Bounding Box) — выровненный по осям ограничивающий прямоугольник.

    !Визуализация принципа пересечения двух прямоугольных областей (AABB).

    SFML делает эту задачу тривиальной. У каждого спрайта есть метод getGlobalBounds(), который возвращает прямоугольник (sf::FloatRect), описывающий его границы.

    Чтобы проверить пересечение, используем метод intersects().

    Алгоритм проверки попаданий

    Математически проверка пересечения двух прямоугольников и выглядит как серия сравнений:

    Где: * — координата правой грани прямоугольника A. * — координата левой грани прямоугольника B. * — логическое "И".

    Если все эти условия истинны, значит, прямоугольники накладываются друг на друга.

    Собираем всё вместе

    Теперь наш игровой цикл (Game Loop) выглядит как настоящий движок:

  • Расчет Delta Time: dt = clock.restart().asSeconds();
  • Обработка событий: Прыжки, выстрелы, закрытие окна.
  • Update:
  • * Применение гравитации к игроку. * Движение игрока и врагов (умножаем на dt). * Движение пуль. * Проверка коллизий (Пуля vs Враг, Игрок vs Враг). * Удаление мертвых объектов.
  • Render: Очистка, отрисовка всех спрайтов, отображение.
  • Заключение

    Поздравляю! Ты только что реализовал ядро любого платформера. Твой персонаж теперь чувствует вес, прыгает и может постоять за себя. Это уже не просто картинки, это — система.

    В следующей статье мы займемся эстетикой и логикой состояний. Мы заставим Cuphead перебирать ногами при беге (анимация спрайтов) и научимся управлять состояниями игры (Меню -> Игра -> Game Over).

    Готовься, будет красиво!

    5. Игровой процесс: Анимация, паттерны атак босса и менеджмент состояний игры

    Игровой процесс: Анимация, паттерны атак босса и менеджмент состояний игры

    Приветствую, коллега! Мы уже проделали огромную работу. У нас есть окно, физика, стрельба и коллизии. Но если посмотреть правде в глаза, наш проект пока напоминает техническую демо-версию, а не игру уровня Cuphead. Персонаж скользит по полу как хоккейная шайба, босс (если он есть) просто стоит или двигается по прямой, а игра начинается сразу после запуска и заканчивается закрытием окна.

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

    Оживление спрайтов: Спрайтшиты и sf::IntRect

    До сих пор мы использовали статические изображения. Но Cuphead славится своей анимацией в стиле 1930-х годов. Как это работает в коде? Неужели мы загружаем 100 отдельных файлов для бега героя?

    Нет. В геймдеве используется техника Sprite Sheet (Лист спрайтов). Это одно большое изображение, на котором кадры анимации расположены в ряд (или сеткой).

    !Иллюстрация принципа работы спрайтшита: мы видим только ту часть картинки, которая попадает в рамку.

    Как работает анимация в SFML?

    У спрайта есть свойство TextureRect — это прямоугольник, который определяет, какую часть текстуры нужно отобразить прямо сейчас. Чтобы создать иллюзию движения, мы просто сдвигаем этот прямоугольник через равные промежутки времени.

    Для этого нам понадобится класс sf::IntRect (Integer Rectangle). Он принимает 4 параметра: координата , координата , ширина и высота.

    Предположим, у нас есть спрайтшит, где каждый кадр имеет размер пикселей.

    Формула для вычисления координаты текущего кадра:

    Где: * — координата левого верхнего угла рамки на текстуре. * — номер текущего кадра (0, 1, 2...). * — ширина одного кадра.

    Реализация в коде

    Давай добавим анимацию в наш класс Player. Нам понадобятся таймер и счетчик кадров.

    Теперь, если ты запустишь игру, твой персонаж будет перебирать ногами. Чтобы сделать анимацию умной (например, переключаться между анимацией «Стоит» и «Бежит»), тебе нужно будет менять координату прямоугольника IntRect (если разные анимации расположены на разных строках спрайтшита) или загружать другую текстуру.

    Интеллект Босса: Конечные автоматы (FSM)

    Враги в играх типа Cuphead не действуют случайно. Они работают по четким алгоритмам. Босс может стрелять, потом делать рывок, потом отдыхать. Эта структура называется Finite State Machine (Конечный Автомат).

    Суть проста: Босс всегда находится в одном из состояний. В зависимости от условий (время, здоровье, расстояние до игрока) он переключается в другое состояние.

    Давай спроектируем простого босса.

    Состояния босса

    Для удобства используем enum class (перечисление):

    Логика переключений

    В классе Boss у нас будет переменная state и таймер, чтобы отслеживать, сколько времени босс находится в текущем состоянии.

    Такой подход позволяет создавать сложные многофазные бои. Например, если здоровье босса падает ниже 50%, ты можешь изменить логику внутри switch, добавив новые, более агрессивные атаки.

    Менеджмент состояний игры: Меню и Game Over

    Сейчас твоя игра — это просто бесконечный цикл while. Но полноценная игра имеет структуру: Меню -> Игра -> Пауза -> Победа/Поражение.

    Это тоже реализуется через машину состояний, но уже на уровне всего приложения.

    !Схема переходов между основными экранами игры.

    Структура Game Loop

    В функции main (или в классе Game) мы вводим глобальное состояние.

    Почему это важно?

    Разделение логики на состояния предотвращает ошибки. Например, ты не хочешь, чтобы враги двигались и убивали игрока, пока он находится в главном меню. Изолируя код внутри case GameState::PLAYING, ты гарантируешь, что игровой мир живет только тогда, когда это нужно.

    Математика таймеров

    Во всех примерах выше мы использовали таймеры. Давай формализуем это. Таймер — это переменная-аккумулятор, которая накапливает прошедшее время dt.

    Формула накопления времени:

    Где: * — текущее значение таймера. * — значение таймера в предыдущем кадре. * — время, прошедшее с последнего кадра (Delta Time).

    Условие срабатывания события:

    Где: * — пороговое значение (например, 0.1 секунды для кадра анимации или 2.0 секунды для атаки босса).

    Когда условие выполняется, мы обычно сбрасываем таймер () или вычитаем из него пороговое значение (), чтобы сохранить точность времени.

    Заключение

    Поздравляю! Теперь у тебя есть каркас настоящей игры.

  • Анимация делает мир живым.
  • Паттерны босса делают геймплей интересным и предсказуемым (в хорошем смысле, чтобы игрок мог учиться).
  • Состояния игры превращают набор механик в законченное приложение с началом и концом.
  • В следующей, заключительной статье курса, мы займемся полировкой: добавим звуковые эффекты, подсчет очков, параллакс-фон и соберем финальный проект, который не стыдно показать друзьям.

    Твой клон Cuphead почти готов!