Разработка игры «Рикошет во тьме» на SFML C++
Привет, разработчики и любители экспериментов с графикой! Сегодня хочу рассказать о создании небольшой, но атмосферной игры «Рикошет во тьме» на C++20 с использованием SFML.
Идея родилась из простого вопроса: можно ли построить игровой процесс вокруг одного физического действия — отражения шара от поверхности? Чтобы механика не превратилась в обычный экранный пинг-понг, я добавил темноту, ограниченную область видимости и цели, которые игрок должен находить по вспышкам света.
В этой статье мы разберём архитектуру проекта, игровой цикл, движение светящегося снаряда, отражение от стен и препятствий, уничтожение целей и простую систему освещения. Проект хорошо подходит тем, кто уже знает основы C++, но хочет перейти от отдельных упражнений к небольшой законченной игре.
Идея игры
Игрок запускает светящийся шар в тёмное помещение. Шар движется с постоянной скоростью, рикошетит от стен и препятствий и на короткое время освещает пространство вокруг себя. Задача — уничтожить все спрятанные цели за ограниченное количество запусков.
Основные правила получились такими:
- направление выстрела задаётся мышью;
- левая кнопка мыши запускает шар;
- после столкновения со стеной или препятствием шар отражается;
- при попадании цель исчезает, а игрок получает очки;
- клавиша R перезапускает уровень;
- Esc возвращает в меню или закрывает игру.
На первый взгляд всё просто. Но именно в таких проектах хорошо видны важные темы игровой разработки: работа со временем, нормализация векторов, проверка столкновений, разделение ответственности между классами и создание визуальной атмосферы.
Подготовка проекта
Для разработки я использовал C++20 и SFML 2.6. Проект можно собрать в Visual Studio, CLion или через CMake.
- sfml-graphics — отрисовка объектов и света;
- sfml-window — окно и события;
- sfml-system — время и векторы;
- sfml-audio — звуки выстрела и столкновений.
cmake_minimum_required(VERSION 3.20)
project(RicochetInTheDark)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(SFML 2.6 COMPONENTS
graphics
window
system
audio
REQUIRED
)
add_executable(RicochetInTheDark
src/main.cpp
src/Game.cpp
src/Ball.cpp
src/Obstacle.cpp
src/Target.cpp
src/LightSystem.cpp
)
target_link_libraries(RicochetInTheDark PRIVATE
sfml-graphics
sfml-window
sfml-system
sfml-audio
)Если SFML подключена правильно, сначала откройте обычное окно. Это маленькая проверка, которая экономит много времени: лучше сразу убедиться, что компилятор видит заголовочные файлы и библиотеки.
Структура проекта
Я не стал помещать всю игру в один main.cpp. Для маленького прототипа это кажется удобным, но уже через несколько механик файл превращается в тёмный лабиринт — причём без красивых световых эффектов.
RicochetInTheDark/ ├── assets/ │ ├── fonts/ │ ├── sounds/ │ └── shaders/ │ └── light.frag ├── src/ │ ├── main.cpp │ ├── Game.h │ ├── Game.cpp │ ├── Ball.h │ ├── Ball.cpp │ ├── Obstacle.h │ ├── Obstacle.cpp │ ├── Target.h │ ├── Target.cpp │ ├── LightSystem.h │ └── LightSystem.cpp └── CMakeLists.txt
Класс Game управляет игровым циклом. Ball отвечает за движение и отражение. Obstacle хранит геометрию препятствия, Target — состояние цели, а LightSystem создаёт карту освещения.
Главный игровой цикл
Основа любой игры на SFML — цикл обработки событий, обновления и отрисовки. Для движения обязательно используем deltaTime, иначе скорость шара будет зависеть от частоты кадров.
#include <SFML/Graphics.hpp>
#include "Game.h"
int main()
{
sf::RenderWindow window(
sf::VideoMode(1280, 720),
"Рикошет во тьме",
sf::Style::Close
);
window.setFramerateLimit(144);
Game game(window);
sf::Clock clock;
while (window.isOpen())
{
const float deltaTime = clock.restart().asSeconds();
sf::Event event{};
while (window.pollEvent(event))
{
if (event.type == sf::Event::Closed)
window.close();
game.handleEvent(event);
}
game.update(deltaTime);
window.clear(sf::Color(5, 7, 12));
game.render();
window.display();
}
return 0;
}Здесь важно соблюдать порядок: сначала читаем ввод, затем изменяем состояние мира и только после этого рисуем новый кадр.
Создаём светящийся шар
Шар хранит позицию, скорость, радиус и флаг активности. Пока игрок выбирает направление, шар остаётся на стартовой точке. После выстрела он начинает двигаться.
#pragma once
#include <SFML/Graphics.hpp>
class Ball : public sf::Drawable
{
public:
explicit Ball(float radius = 12.0f);
void launch(const sf::Vector2f& direction, float speed);
void reset(const sf::Vector2f& position);
void setPosition(const sf::Vector2f& position);
void update(float deltaTime);
void reflect(const sf::Vector2f& normal);
[[nodiscard]] bool isActive() const;
[[nodiscard]] float getRadius() const;
[[nodiscard]] sf::Vector2f getPosition() const;
[[nodiscard]] sf::Vector2f getVelocity() const;
private:
sf::CircleShape shape;
sf::Vector2f velocity{};
bool active{false};
void draw(
sf::RenderTarget& target,
sf::RenderStates states
) const override;
};#include "Ball.h"
#include <cmath>
namespace
{
sf::Vector2f normalize(const sf::Vector2f& vector)
{
const float length = std::sqrt(
vector.x * vector.x + vector.y * vector.y
);
if (length == 0.0f)
return {0.0f, 0.0f};
return vector / length;
}
float dot(
const sf::Vector2f& left,
const sf::Vector2f& right
)
{
return left.x * right.x + left.y * right.y;
}
}
Ball::Ball(float radius)
{
shape.setRadius(radius);
shape.setOrigin(radius, radius);
shape.setFillColor(sf::Color(190, 235, 255));
shape.setOutlineThickness(3.0f);
shape.setOutlineColor(sf::Color(80, 180, 255));
}
void Ball::launch(
const sf::Vector2f& direction,
float speed
)
{
velocity = normalize(direction) * speed;
active = true;
}
void Ball::reset(const sf::Vector2f& position)
{
shape.setPosition(position);
velocity = {};
active = false;
}
void Ball::setPosition(const sf::Vector2f& position)
{
shape.setPosition(position);
}
void Ball::update(float deltaTime)
{
if (active)
shape.move(velocity * deltaTime);
}
void Ball::reflect(const sf::Vector2f& normal)
{
velocity -= 2.0f * dot(velocity, normal) * normal;
}
bool Ball::isActive() const
{
return active;
}
float Ball::getRadius() const
{
return shape.getRadius();
}
sf::Vector2f Ball::getPosition() const
{
return shape.getPosition();
}
sf::Vector2f Ball::getVelocity() const
{
return velocity;
}
void Ball::draw(
sf::RenderTarget& target,
sf::RenderStates states
) const
{
target.draw(shape, states);
}Формула отражения выглядит так:
Здесь V — текущая скорость, а N — нормаль поверхности. Эта формула универсальна: она работает и для горизонтальной стены, и для вертикальной, и для наклонных поверхностей.
Запуск шара мышью
При нажатии левой кнопки мыши вычисляем направление от стартовой позиции к курсору.
void Game::handleEvent(const sf::Event& event)
{
if (event.type == sf::Event::KeyPressed)
{
if (event.key.code == sf::Keyboard::Escape)
window.close();
if (event.key.code == sf::Keyboard::R)
restartLevel();
}
if (event.type == sf::Event::MouseButtonPressed
&& event.mouseButton.button == sf::Mouse::Left
&& !ball.isActive())
{
const sf::Vector2f mousePosition =
window.mapPixelToCoords({
event.mouseButton.x,
event.mouseButton.y
});
const sf::Vector2f direction =
mousePosition - ball.getPosition();
ball.launch(direction, 520.0f);
}
}Перед выстрелом можно рисовать линию прицеливания. Она помогает игроку, но не показывает весь будущий путь — после первого рикошета начинается самое интересное.
Рикошет от границ окна
Для стен уровня достаточно проверить положение центра шара с учётом его радиуса. После столкновения меняем направление движения и возвращаем объект внутрь игрового поля.
void Game::handleWindowCollision()
{
auto position = ball.getPosition();
const float radius = ball.getRadius();
if (position.x - radius < 0.0f)
{
position.x = radius;
ball.setPosition(position);
ball.reflect({1.0f, 0.0f});
}
else if (position.x + radius > window.getSize().x)
{
position.x = window.getSize().x - radius;
ball.setPosition(position);
ball.reflect({-1.0f, 0.0f});
}
if (position.y - radius < 0.0f)
{
position.y = radius;
ball.setPosition(position);
ball.reflect({0.0f, 1.0f});
}
else if (position.y + radius > window.getSize().y)
{
position.y = window.getSize().y - radius;
ball.setPosition(position);
ball.reflect({0.0f, -1.0f});
}
}Простое отражение скорости без коррекции позиции иногда приводит к дрожанию: шар остаётся внутри стены и на следующем кадре сталкивается с ней снова.
Столкновение с препятствиями
Препятствия удобно хранить как прямоугольники. Для столкновения круга с прямоугольником находим ближайшую к центру шара точку внутри границ препятствия.
#include <algorithm>
bool intersects(
const Ball& ball,
const sf::FloatRect& rectangle
)
{
const sf::Vector2f center = ball.getPosition();
const float closestX = std::clamp(
center.x,
rectangle.left,
rectangle.left + rectangle.width
);
const float closestY = std::clamp(
center.y,
rectangle.top,
rectangle.top + rectangle.height
);
const float dx = center.x - closestX;
const float dy = center.y - closestY;
const float radius = ball.getRadius();
return dx * dx + dy * dy < radius * radius;
}После обнаружения столкновения определяем нормаль. Для прямоугольных препятствий можно сравнить проникновение по осям X и Y. Отражаем скорость по той оси, где проникновение меньше, и выталкиваем шар наружу.
void Game::resolveObstacleCollision(
const sf::FloatRect& bounds
)
{
if (!intersects(ball, bounds))
return;
auto position = ball.getPosition();
const sf::Vector2f obstacleCenter{
bounds.left + bounds.width * 0.5f,
bounds.top + bounds.height * 0.5f
};
const float dx = position.x - obstacleCenter.x;
const float dy = position.y - obstacleCenter.y;
const float overlapX =
bounds.width * 0.5f + ball.getRadius() - std::abs(dx);
const float overlapY =
bounds.height * 0.5f + ball.getRadius() - std::abs(dy);
if (overlapX < overlapY)
{
const float sign = dx < 0.0f ? -1.0f : 1.0f;
position.x += sign * overlapX;
ball.setPosition(position);
ball.reflect({sign, 0.0f});
}
else
{
const float sign = dy < 0.0f ? -1.0f : 1.0f;
position.y += sign * overlapY;
ball.setPosition(position);
ball.reflect({0.0f, sign});
}
}Для быстрых объектов позже стоит добавить непрерывную проверку столкновений. Иначе на низкой частоте кадров шар может успеть «проскочить» через тонкую стену.
Освещение и атмосфера
Главная особенность проекта — темнота. Простого чёрного прямоугольника поверх сцены недостаточно: нам нужно оставить видимой область вокруг шара.
Я использовал отдельную sf::RenderTexture как карту света. Каждый кадр она очищается тёмным цветом, после чего на неё с аддитивным смешиванием рисуется мягкий световой круг. Затем фрагментный шейдер умножает цвет игрового мира на значение карты света.
uniform sampler2D texture;
uniform sampler2D lightMap;
void main()
{
vec2 uv = gl_TexCoord[0].xy;
vec4 worldColor = texture2D(texture, uv);
vec4 lightColor = texture2D(lightMap, uv);
float ambient = 0.08;
float brightness = max(
ambient,
max(lightColor.r, max(lightColor.g, lightColor.b))
);
gl_FragColor = vec4(
worldColor.rgb * brightness,
worldColor.a
);
}Мягкий круг можно подготовить в графическом редакторе или создать программно как радиальный градиент. Его центр совпадает с позицией шара.
lightMap.clear(sf::Color::Black);
lightSprite.setPosition(ball.getPosition());
lightMap.draw(lightSprite, sf::BlendAdd);
lightMap.display();
lightShader.setUniform(
"lightMap",
lightMap.getTexture()
);
window.draw(worldSprite, &lightShader);Чтобы рикошет ощущался сильнее, в момент столкновения я ненадолго увеличиваю радиус света и создаю несколько частиц. Получается короткая вспышка: игрок видит кусок уровня и успевает запомнить расположение следующего препятствия.
Цели и подсчёт очков
Каждая цель хранит форму и флаг активности. При столкновении с шаром цель выключается, счёт увеличивается, а на её месте появляется вспышка.
void Game::checkTargets()
{
for (auto& target : targets)
{
if (!target.isActive())
continue;
const sf::Vector2f difference =
target.getPosition() - ball.getPosition();
const float radius =
target.getRadius() + ball.getRadius();
const float distanceSquared =
difference.x * difference.x
+ difference.y * difference.y;
if (distanceSquared <= radius * radius)
{
target.destroy();
score += 100;
createHitParticles(target.getPosition());
}
}
}Когда активных целей не осталось, уровень завершён. Игроку можно начислять дополнительный бонус за количество рикошетов или за неиспользованные попытки.
Обновление игрового состояния
Метод update соединяет отдельные механики, но не содержит их внутреннюю реализацию.
void Game::update(float deltaTime)
{
ball.update(deltaTime);
if (ball.isActive())
{
handleWindowCollision();
for (const auto& obstacle : obstacles)
resolveObstacleCollision(obstacle.getBounds());
checkTargets();
updateTrail(deltaTime);
updateParticles(deltaTime);
}
lightSystem.setLightPosition(ball.getPosition());
lightSystem.update(deltaTime);
if (allTargetsDestroyed())
state = GameState::Victory;
}Такой код легко читать сверху вниз. Мы видим порядок действий, но детали столкновений, частиц и света находятся в отдельных функциях и классах.
Небольшие детали, которые делают игру живой
Механика может работать идеально, но без обратной связи игра всё равно будет ощущаться пустой. Я добавил несколько простых эффектов:
- световой след за шаром;
- короткую вспышку при каждом рикошете;
- частицы при уничтожении цели;
- разные звуки для стены, препятствия и цели;
- лёгкое дрожание камеры при сильном ударе;
- изменение цвета шара при увеличении серии попаданий.
Важно не переборщить. Если экран постоянно трясётся и мигает, игрок уже сражается не с уровнем, а с собственным монитором.