Наша разработка игры «Breakout» близится к завершению, но было бы здорово добавить еще хотя бы одну новую игровую механику, не так ли?
Бонусы в игре «Breakout»
Идея новой механики заключается в том, что всякий раз, когда разрушается кирпич, есть небольшой шанс, что из него выпадет бонус (PowerUp). Такой бонус будет медленно падать вниз, и если он столкнется с ракеткой игрока, то возникнет интересный эффект, определяемый типом бонуса. Например, какой-то из бонусов может увеличивать размеры ракетки, другой — позволит мячу проходить сквозь объекты. При этом в игре также будут представлены и негативные бонусы, отрицательно влияющие на игрока.
Мы можем смоделировать бонусы при помощи класса GameObject с несколькими дополнительными свойствами. Именно поэтому мы определяем класс PowerUp, который наследует класс GameObject:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
const glm::vec2 SIZE(60.0f, 20.0f); const glm::vec2 VELOCITY(0.0f, 150.0f); class PowerUp : public GameObject { public: std::string Type; float Duration; bool Activated; // Конструктор PowerUp(std::string type, glm::vec3 color, float duration, glm::vec2 position, Texture2D texture) : GameObject(position, SIZE, texture, color, VELOCITY), Type(type), Duration(duration), Activated() { } }; |
Как уже было сказано, класс PowerUp — это тот же класс GameObject, но с дополнительным свойством-состоянием, поэтому мы можем определить его просто в одном заголовочном файле — power_up.h:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
#ifndef POWER_UP_H #define POWER_UP_H #include <string> #include <glad/glad.h> #include <glm/glm.hpp> #include "game_object.h" // Размер бонусного блока const glm::vec2 POWERUP_SIZE(60.0f, 20.0f); // Начальная скорость бонусного блока const glm::vec2 VELOCITY(0.0f, 150.0f); // Класс PowerUp наследует переменные состояния и функции рендеринга от класса GameObject, // но при этом содержит дополнительную информацию: // состояние бонусов, их длительность, активен ли бонус в данный момент или нет. // Тип бонуса сохранен в строковой переменной class PowerUp : public GameObject { public: // Состояние бонуса std::string Type; float Duration; bool Activated; // Конструктор PowerUp(std::string type, glm::vec3 color, float duration, glm::vec2 position, Texture2D texture) : GameObject(position, POWERUP_SIZE, texture, color, VELOCITY), Type(type), Duration(duration), Activated() { } }; #endif |
Каждый бонус характеризуется своим типом в виде строковой переменной, продолжительностью действия, и переменной, которая сообщает — активен ли бонус в данный момент или нет. В рамках нашей игры мы собираемся реализовать в общей сложности 4 позитивных и 2 негативных бонуса:
speed — увеличивает скорость мяча на 20%;
sticky — когда мяч сталкивается с ракеткой, он прилипает к ней до того момента, пока игрок не нажмет пробел. Это позволяет игроку выбрать новую наиболее удачную позицию для мяча, прежде чем продолжить игру;
pass-through — определение столкновений отключено для нетвердых кирпичей, что позволяет мячу проходить сквозь них;
pad-size-increase — увеличивает ширину ракетки на 50 пикселей;
confuse — активирует на короткий период времени эффект постобработки «Confuse», сбивая игрока с толку;
chaos — активирует на короткий период времени эффект постобработки «Chaos», сильно дезориентируя игрока.
Вы можете найти используемые текстуры здесь:
Подобно текстурам кирпичей уровней, цветовая гамма каждой из текстур бонусов представлена в виде оттенков серого. Благодаря этому, при умножении на цветовой вектор цвет бонусов остается сбалансированным.
Поскольку бонусы имеют состояние, длительность и определенные эффекты, связанные с типом бонуса, то мы хотели бы отслеживать все активные на данный момент бонусы; мы сохраним их в векторе:
1 2 3 4 5 6 7 8 |
class Game { public: [...] std::vector<PowerUp> PowerUps; [...] void SpawnPowerUps(GameObject &block); void UpdatePowerUps(float dt); }; |
Мы также определили две функции для управления бонусами:
функция Game::SpawnPowerUps() — добавляет бонус в месте заданного блока;
функция Game::UpdatePowerUps() — управляет всеми активными в текущий момент игры бонусами.
Добавление бонусов
При каждом разрушении кирпича существует небольшой шанс создания бонуса. Эту возможность можно реализовать при помощи функции Game::SpawnPowerUps():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
bool ShouldSpawn(unsigned int chance) { unsigned int random = rand() % chance; return random == 0; } void Game::SpawnPowerUps(GameObject &block) { if (ShouldSpawn(75)) // шанс 1 из 75 this->PowerUps.push_back( PowerUp("speed", glm::vec3(0.5f, 0.5f, 1.0f), 0.0f, block.Position, tex_speed)); if (ShouldSpawn(75)) this->PowerUps.push_back( PowerUp("sticky", glm::vec3(1.0f, 0.5f, 1.0f), 20.0f, block.Position, tex_sticky); if (ShouldSpawn(75)) this->PowerUps.push_back( PowerUp("pass-through", glm::vec3(0.5f, 1.0f, 0.5f), 10.0f, block.Position, tex_pass)); if (ShouldSpawn(75)) this->PowerUps.push_back( PowerUp("pad-size-increase", glm::vec3(1.0f, 0.6f, 0.4), 0.0f, block.Position, tex_size)); if (ShouldSpawn(15)) // негативные бонусы должны появляться чаще this->PowerUps.push_back( PowerUp("confuse", glm::vec3(1.0f, 0.3f, 0.3f), 15.0f, block.Position, tex_confuse)); if (ShouldSpawn(15)) this->PowerUps.push_back( PowerUp("chaos", glm::vec3(0.9f, 0.25f, 0.25f), 15.0f, block.Position, tex_chaos)); } |
Функция Game::SpawnPowerUps() с некоторой вероятностью (1 из 75 — для позитивных бонусов и 1 из 15 — для негативных бонусов) создает новый объект класса PowerUp и устанавливает ему соответствующие свойства. Каждому бонусу присваивается определенный цвет, чтобы сделать его более узнаваемым для пользователя, и продолжительность действия в секундах, зависящую от типа бонуса; длительность 0.0f
означает, что его продолжительность является бесконечной. Кроме того, каждому бонусу присваивается местоположение разрушенного кирпича и одна из текстур, представленных в начале этого урока.
Активация бонусов
Теперь мы должны обновить функцию Game::DoCollisions(), чтобы не только проверить наличие столкновений между мячом и ракеткой, но и наличие столкновений между ракеткой и бонусом. Обратите внимание, что мы вызываем функцию SpawnPowerUps() непосредственно после уничтожения кирпича:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
void Game::DoCollisions() { for (GameObject &box : this->Levels[this->Level].Bricks) { if (!box.Destroyed) { Collision collision = CheckCollision(*Ball, box); if (std::get<0>(collision)) // если произошло столкновение { // разрушаем кирпич, если он не твердый if (!box.IsSolid) { box.Destroyed = true; this->SpawnPowerUps(box); } [...] } } } [...] for (PowerUp &powerUp : this->PowerUps) { if (!powerUp.Destroyed) { if (powerUp.Position.y >= this->Height) powerUp.Destroyed = true; if (CheckCollision(*Player, powerUp)) { // Столкновение с ракеткой, активируем бонус ActivatePowerUp(powerUp); powerUp.Destroyed = true; powerUp.Activated = true; } } } } |
Для каждого бонуса, который еще не уничтожен, мы проверяем — достиг ли он нижнего края экрана или столкнулся ли с ракеткой игрока. В обоих случаях бонус уничтожается, но при столкновении с ракеткой он еще и активируется.
Активация бонуса осуществляется путем установки его свойства Activated
в значение true
и включения соответствующего эффекта с помощью функции ActivatePowerUp():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
void ActivatePowerUp(PowerUp &powerUp) { if (powerUp.Type == "speed") { Ball->Velocity *= 1.2; } else if (powerUp.Type == "sticky") { Ball->Sticky = true; Player->Color = glm::vec3(1.0f, 0.5f, 1.0f); } else if (powerUp.Type == "pass-through") { Ball->PassThrough = true; Ball->Color = glm::vec3(1.0f, 0.5f, 0.5f); } else if (powerUp.Type == "pad-size-increase") { Player->Size.x += 50; } else if (powerUp.Type == "confuse") { if (!Effects->Chaos) Effects->Confuse = true; // активируется при условии, что в текущий момент не был активирован эффект "Хаоса" } else if (powerUp.Type == "chaos") { if (!Effects->Confuse) Effects->Chaos = true; } } |
Название функции ActivatePowerUp() говорит само за себя: она активирует эффект от бонуса (напомню, список эффектов представлен в начале урока). Мы проверяем тип бонуса и соответствующим образом меняем состояние игры. Для эффектов «Sticky» и «Pass-Through» мы также меняем цвет ракетки и мяча, чтобы дать пользователю некоторую обратную связь относительно того, какой эффект в данный момент активен.
Поскольку два вышеупомянутых эффекта несколько изменяют логику игры, то мы сохраняем их эффекты в качестве свойств мяча; таким образом, мы можем изменить логику игры, в зависимости от того эффекта, который в данный момент применен к мячу. Единственное, что нужно изменить в заголовочном файле класса BallObject, — это добавить пару свойств.
#Класс BallObject
Заголовочный файл — ball_object.h:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
#ifndef BALLOBJECT_H #define BALLOBJECT_H #include <glad/glad.h> #include <glm/glm.hpp> #include "game_object.h" #include "texture.h" // Класс BallObject получен из класса GameObject. // Помимо необходимой информации о состоянии мяча в нем присутствуют еще некоторые дополнительные функции class BallObject : public GameObject { public: // Состояние мяча float Radius; bool Stuck; bool Sticky, PassThrough; // Конструкторы BallObject(); BallObject(glm::vec2 pos, float radius, glm::vec2 velocity, Texture2D sprite); // Перемещаем мячик, удерживая его в пределах границ окна (за исключением нижнего края); возвращаем новую позицию glm::vec2 Move(float dt, unsigned int window_width); // Возвращаем мяч в исходное состояние с заданным положением и скоростью void Reset(glm::vec2 position, glm::vec2 velocity); }; #endif |
Файл реализации — ball_object.cpp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
#include "ball_object.h" BallObject::BallObject() : GameObject(), Radius(12.5f), Stuck(true), Sticky(false), PassThrough(false) { } BallObject::BallObject(glm::vec2 pos, float radius, glm::vec2 velocity, Texture2D sprite) : GameObject(pos, glm::vec2(radius * 2.0f, radius * 2.0f), sprite, glm::vec3(1.0f), velocity), Radius(radius), Stuck(true), Sticky(false), PassThrough(false) { } glm::vec2 BallObject::Move(float dt, unsigned int window_width) { // Если мяч не зафиксирован ракеткой if (!this->Stuck) { // Перемещаем мяч this->Position += this->Velocity * dt; // Затем проверяем, находится ли он за пределами границ окна, и если да, то изменяем скорость и восстанавливаем правильное положение if (this->Position.x <= 0.0f) { this->Velocity.x = -this->Velocity.x; this->Position.x = 0.0f; } else if (this->Position.x + this->Size.x >= window_width) { this->Velocity.x = -this->Velocity.x; this->Position.x = window_width - this->Size.x; } if (this->Position.y <= 0.0f) { this->Velocity.y = -this->Velocity.y; this->Position.y = 0.0f; } } return this->Position; } // Сбрасываем мяч в стартовое положение (если мяч находится за пределами границ окна) void BallObject::Reset(glm::vec2 position, glm::vec2 velocity) { this->Position = position; this->Velocity = velocity; this->Stuck = true; this->Sticky = false; this->PassThrough = false; } |
Затем мы можем реализовать эффект «Sticky», слегка обновив функцию DoCollisions() в коде определения столкновения между мячом и ракеткой:
1 2 3 4 5 |
if (!Ball->Stuck && std::get<0>(result)) { [...] Ball->Stuck = Ball->Sticky; } |
Здесь мы устанавливаем свойство мяча Stuck
равное свойству Sticky
. Если активирован эффект «Sticky», то мяч будет прилипать к ракетке всякий раз, когда он с ней сталкивается; игрок должен снова нажать пробел, чтобы запустить мяч.
Аналогичное небольшое изменение в той же самой функции DoCollisions() сделано и для эффекта «Pass-Through». Когда свойство мяча PassThrough
установлено в true
, мы не выполняем никакой обработки столкновения на нетвердых кирпичах:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
Direction dir = std::get<1>(collision); glm::vec2 diff_vector = std::get<2>(collision); if (!(Ball->PassThrough && !box.IsSolid)) { if (dir == LEFT || dir == RIGHT) // горизонтальное столкновение { [...] } else { [...] } } |
Другие эффекты (например, изменение скорости мяча, размера ракетки или применение эффектов «Chaos» и «Confuse») активируются простым изменением состояния игры.
Обновление бонусов
Всё, что осталось сделать, — это убедиться, что бонусы могут двигаться, как только они появились, и что они деактивируются, как только их продолжительность действия закончится; в противном случае бонусы останутся активными навсегда.
В рамках функции Game::UpdatePowerUps() мы перемещаем бонусы в зависимости от их скорости и уменьшаем продолжительность действия активных в текущий момент бонусов. Всякий раз, когда длительность бонуса становится равной 0.0f
, его эффект деактивируется, и соответствующие переменные сбрасываются в исходное состояние:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
void Game::UpdatePowerUps(float dt) { for (PowerUp &powerUp : this->PowerUps) { powerUp.Position += powerUp.Velocity * dt; if (powerUp.Activated) { powerUp.Duration -= dt; if (powerUp.Duration <= 0.0f) { // Убираем бонус из списка (в дальнейшем он будет удален) powerUp.Activated = false; // Деактивируем эффекты if (powerUp.Type == "sticky") { if (!isOtherPowerUpActive(this->PowerUps, "sticky")) { // Сбрасываем только в том случае, если больше никаких других бонусов типа "Sticky" не активировано Ball->Sticky = false; Player->Color = glm::vec3(1.0f); } } else if (powerUp.Type == "pass-through") { if (!isOtherPowerUpActive(this->PowerUps, "pass-through")) { // Cбрасываем только в том случае, если больше никаких других бонусов типа "Pass-Through" не активировано Ball->PassThrough = false; Ball->Color = glm::vec3(1.0f); } } else if (powerUp.Type == "confuse") { if (!isOtherPowerUpActive(this->PowerUps, "confuse")) { // Сбрасываем только в том случае, если больше никаких других бонусов типа "Confuse" не активировано Effects->Confuse = false; } } else if (powerUp.Type == "chaos") { if (!isOtherPowerUpActive(this->PowerUps, "chaos")) { // Сбрасываем только в том случае, если больше никаких других бонусов типа "Chaos" не активировано Effects->Chaos = false; } } } } } this->PowerUps.erase(std::remove_if(this->PowerUps.begin(), this->PowerUps.end(), [](const PowerUp &powerUp) { return powerUp.Destroyed && !powerUp.Activated; } ), this->PowerUps.end()); } |
Вы можете видеть, что для каждого эффекта реализовано его отключение, при этом соответствующие элементы сбрасываются в исходное состояние. Мы также устанавливаем свойство бонуса Activated
в false
. В конце функции Game::UpdatePowerUps() мы проходимся циклом по вектору класса PowerUps и стираем каждый бонус, если он уничтожен и деактивирован. Мы используем функцию remove_if() из заголовочного файла algorithm, чтобы стереть элементы вектора при помощи лямбда-выражений.
Примечание: Функция remove_if() перемещает все элементы, на которых лямбда-выражение имеет значение true
, в конец объекта контейнера и возвращает итератор, указывающий на начало диапазона удаленных элементов. Затем контейнерная функция erase() использует этот итератор и итератор конца вектора для удаления всех элементов между данными итераторами.
Может случиться так, что в то время, когда активирован один из бонусных эффектов, другой бонус того же типа столкнется с ракеткой игрока. В таком случае у нас есть более 1 бонуса заданного типа, который в настоящее время активен в векторе класса PowerUps. При этом, всякий раз, когда один из этих бонусов отключается, мы не хотим отключать его эффект, так как другой бонус того же типа все еще может быть активен. По этой причине мы используем функцию IsOtherPowerUpActive(), чтобы проверить — есть ли еще один активный бонус того же типа. Только если эта функция возвращает false
, мы отключаем бонус. Таким образом, продолжительность бонусов данного типа увеличивается до продолжительности последнего активированного бонуса данного типа:
1 2 3 4 5 6 7 8 9 10 |
bool IsOtherPowerUpActive(std::vector<PowerUp> &powerUps, std::string type) { for (const PowerUp &powerUp : powerUps) { if (powerUp.Activated) if (powerUp.Type == type) return true; } return false; } |
Функция проверяет наличие всех активированных бонусов и возвращает true
, если находит активные бонусы одинакового типа.
Последнее, что осталось сделать — это визуализировать бонусы:
1 2 3 4 5 6 7 8 9 10 11 |
void Game::Render() { if (this->State == GAME_ACTIVE) { [...] for (PowerUp &powerUp : this->PowerUps) if (!powerUp.Destroyed) powerUp.Draw(*Renderer); [...] } } |
Совместив все эти функции, мы получим работающую систему бонусов и усилений, которая не только делает игру более увлекательной, но и намного более сложной:
GitHub / Часть №10: Система бонусов и усилений в игре «Breakout» на С++/OpenGL — Исходный код