Разработка игры на C++/SFML: Начало
Вступление
Всем привет!
Пришла, значит, мне в голову идея - сделать свою игру по типу Vampire Survivors и Brotato, а потом я подумал, что можно еще и цикл статей написать про то, как я ее разрабатываю, вдруг кому-то это покажется полезным (ну или хотя бы смешным. А может читатели начнут писать мне гневные комментарии под этой статьей и я заплачу и брошу программирование, кто знает).
Ну, собственно, вот - первая часть.
В ней я покажу, как я создал персонажа и научил его бегать.
Дисклеймер
Автор статьи также учится программировать, поэтому в статье возможны(они есть) недочеты и ошибки, прошу отнестись с пониманием и поправить в комментариях. Также не призываю принимать эту статью как гайд, это просто мои мысли, которыми я решил поделиться.
Предначало
Хотелось бы начать с того, как установить SFML, но на просторах интернета итак есть много русскоязычных и не только гайдов. Не маленькие, сами найдете инфу по установке либы под свое окружение.
Сам я установил ее в давние времена с помощью vcpkg по аналогии с этим видео.
Разрабатывать я буду, используя C++17, среду Microsoft Visual Studio 2019, свое бурное воображение и огромное количество кофе.
Начало
А начну я с того, что возьму тестовый код с сайта SFML(ссылка на сайт), уберу из него все лишнее и запущу проект - появится окошко.
Вот код:
// main.cpp
#include <SFML/Graphics.hpp>
int main()
{
sf::RenderWindow window(sf::VideoMode(200, 200), "SFML works!");
while (window.isOpen())
{
sf::Event event;
while (window.pollEvent(event))
{
if (event.type == sf::Event::Closed)
window.close();
}
window.clear();
window.display();
}
return 0;
}
Здесь уже расскажу поподробней
sf::RenderWindow
- это окно игры, оно используется для отрисовки 2D объектов. Первым аргументом я передаю размеры окна, вторым - название.
С девятой по двадцатую строчку идет главный цикл программы, в котором мы обрабатываем ивенты, отрисовываем графику, вызываем методы обновления состояния и так далее.
В конце цикла можно увидеть вызов window.clear()
, этот метод, как ни странно, очищает окно. По умолчанию окно заливается черным цветом, но аргументом можно указать нужный цвет, например, белый - sf::Color::White
.
Также вызывается и метод sf::Window::display
. Он выводит на окно все, что было отрендерено в текущем кадре.
Немного про Event Loop
Внутри главного цикла можно увидеть следующую конструкцию
sf::Event event;
while (window.pollEvent(event)) {
if (event.type == sf::Event::Closed) {
window.close();
}
}
В ней обрабатываются ивенты из очереди событий. Что это такое? Ну если коротко, то все события, будь то изменение размера окна, нажатие на кнопку закрытия или изменение фокуса окна, помещаются в очередь, из которой они потом обрабатываются.
Проинициализировать ивент можно передав его в метод sf::Window::pollEvent
, который возвращает true
, если было обнаружено событие в очереди и, соответственно, false
, если ивентов не было.
Опишу примерную работу ивент лупа:
Окну приходит какое-то событие
Оно записывает его в очередь событий
Каждый шаг основного цикла создается переменная, в которую записывается первый ивент в очереди
Вызывается метод
sf::Window::pollEvent
, в который по ссылке передается переменная типаsf::Event
, которую нужно проинициализироватьОбрабатывается ивент исходя из его типа
Если остались какие-то вопросы, вы можете задать их в комментариях или обратиться к документации SFML - вот ссылка на статью с ивентами
Продолжаем начинать
Неплохо было бы создать отдельный файл, для настроек проекта, думаю, не нужно объяснять, зачем.
Для начала сделаю просто хедер, в котором будут храниться константы с нужными значениями, а потом уже можно будет сделать что-то посложней и поинтересней.
// Constants.h
#pragma once
constexpr float WINDOW_HEIGHT = 720.0;
constexpr float WINDOW_WIDTH = 1280.0;
Подключаю этот файл в свой main.cpp и вместо задания размера окна напрямую, использую константы
#include <SFML/Graphics.hpp>
#include "include/Engine/Constants.h"
int main() {
sf::RenderWindow window(sf::VideoMode(WINDOW_WIDTH, WINDOW_HEIGHT), "Title");
while (window.isOpen()) {
sf::Event event;
while (window.pollEvent(event)) {
if (event.type == sf::Event::Closed) {
window.close();
}
}
window.clear(sf::Color::White);
window.display();
}
return 0;
}
Создание главного героя
Ура, наконец-то я сдвинулся с начала. В статье у вас прошло всего несколько минут, а я уже 40 минут сижу и пытаюсь грамотно изложить свои мысли. Написание статьи тратит куда больше сил и времени, чем кажется.
Ну, начнем.
В игре подразумевается несколько типов персонажей, поэтому было бы неплохо создать базовый класс Character
, от которого все будут наследоваться, в него можно вынести общие методы, по типу геттеров, да и обрабатывать взаимодействие с разными типами врагов так будет проще за счет полиморфизма.
У каждого персонажа будет какое-то количество hp, размер, позиция, его скорость, спрайт и направление, в котором он двигается(в моем случае их два - влево и вправо).
// Character.h
#pragma once
#include <SFML/Graphics.hpp>
enum class Direction : bool {
LEFT = 0,
RIGHT = 1
};
class Character {
protected:
float m_health;
float m_speed;
sf::Vector2f m_size;
sf::Vector2f m_pos;
sf::Sprite m_sprite;
Direction m_direction = Direction::RIGHT;
public:
virtual ~Character();
virtual void Update(float time) = 0;
void takeDamage(float damage);
void setPosition(sf::Vector2f& pos);
void setDirection(Direction direction);
float getHP() const;
sf::Vector2f getSize() const;
sf::Vector2f getPosition() const;
sf::Sprite getSprite() const;
Direction getDirection() const;
};
Как-то так выглядит класс - это, так называемая, база моего персонажа.
В нем есть абстрактный метод Update()
, что означает, что каждый класс-наследник должен реализовать его по-своему, и набор методов, описывающих общий функционал всех персонажей.
Ах да, вам же нужно пояснить про разные типы из SFML.
Нупока расскажу немного про те, который используюся в этой статье, а про остальные буду пояснять по ходу.
sf::Vector2f
- это класс, описывающий двумерный вектор, я использую его, чтобы хранить позиции и размеры объектов. Возможно такое применение не совсем правильно, но мы в интернете, я могу делать все что хочу, вы меня не найдете.sf::Sprite
- класс, описывающий спрайт, спрайт - это некий графический объект, которым мы можем управлять, менять его текстуру и другий свойства, ок оторых вы можете почитать тутsf::Texture
- это класс текстуры, по сути просто картинка, которую мы загрузили и можем натянуть куда-нибудьПро взаимоействия спрайтов и текстур в SFML можно почитать тут
Теперь посмотрим на реализацию класса
Character
:
// Character.cpp
#include "..\include\Engine\Character.h"
Character::~Character() {}
void Character::takeDamage(float damage) {
m_health -= damage;
}
void Character::setPosition(sf::Vector2f& pos) {
m_pos = pos;
}
void Character::setDirection(Direction direction) {
m_direction = direction;
}
float Character::getHP() const {
return m_health;
}
sf::Vector2f Character::getSize() const {
return m_size;
}
sf::Vector2f Character::getPosition() const {
return m_pos;
}
sf::Sprite Character::getSprite() const {
return m_sprite;
}
Direction Character::getDirection() const {
return m_direction;
}
Тут и комментировать особо нечего, просто сеттеры и геттеры, так что, двигаемся дальше, к созданию класса игрока.
// Player.h
#pragma once
#include "Engine/Character.h"
class PlayerController;
enum class State {
IDLE,
RUN
};
class Player : public Character {
private:
State m_state;
PlayerController* m_controller;
public:
Player() = delete;
Player(sf::Texture& texture, sf::Vector2f start_pos, float health);
~Player();
void Update(float time) override;
void setState(State state);
};
Тут вроде тоже ничего сложного, пока не обращайте внимания на класс PlayerController
, расскажу про него чуть позже. Player
от базового класса пока отличается только тем, что у него есть свое состояние, которое он хранит, нужно это для будущей отрисовки анимаций.
Посмотрим на реализацию
// Player.cpp
#include "../include/Player.h"
#include "../include/Engine/PlayerController.h"
Player::Player(sf::Texture& texture, sf::Vector2f start_pos, float health) {
m_pos = start_pos;
m_health = health;
m_controller = PlayerController::getPlayerController();
m_sprite.setTexture(texture)
m_size = sf::Vector2f(m_sprite.getTextureRect().width, m_sprite.getTextureRect().height);
}
Player::~Player() {}
void Player::Update(float time) {
m_state = State::IDLE;
m_controller->controllPlayer(this, time);
if (m_state == State::RUN) {
}
else {
}
m_sprite.setPosition(m_pos);
}
void Player::setState(State state) {
m_state = state;
}
Инициализируем все поля класса, полю m_size
присваиваем значение исходя из размера загруженной текстуры.
В методе Update
обновляем состояние игрока и меняем позицию спрайта, тут можно сделать много всего интересного но пока ограничимся этим.
Управление игроком
Ладно, вам уже наверное интересно, что же такое PlayerController
.
Как вы могли догадаться из названия, PlayerController
- это сущность, которая управляет игроком, а именно изменяет его позицию и обновляет его состояние.
Определение класса выглядит так:
// PlayerController.h
#pragma once
class Player;
class PlayerController {
private:
PlayerController() = default;
static PlayerController* controller;
public:
PlayerController(PlayerController const&) = delete;
void operator=(PlayerController const&) = delete;
~PlayerController();
static PlayerController* getPlayerController();
void controllPlayer(Player* player, float time);
};
PlayerController
- это singletone
класс, что означает, что в программе всегда будет только один объект этой сущности.
Чтобы реализовать это, я сделал конструктор приватным, удалил конструктор копирования, создал статическое поле типа класса и статический геттер, который вернет нам это поле.
Реализация выглядит так
// PlayerController.cpp
#include "../include/Engine/PlayerController.h"
#include "../include/Player.h"
#include "../include/Engine/Constants.h"
PlayerController* PlayerController::controller = nullptr;
PlayerController::~PlayerController() {
delete controller;
}
PlayerController* PlayerController::getPlayerController() {
if (!controller) {
controller = new PlayerController();
}
return controller;
}
void PlayerController::controllPlayer(Player* player, float time) {
sf::Vector2f updated_pos = player->getPosition();
if (sf::Keyboard::isKeyPressed(sf::Keyboard::A)) {
updated_pos.x -= PLAYER_SPEED * time;
player->setState(State::RUN);
player->setDirection(Direction::LEFT);
}
else if (sf::Keyboard::isKeyPressed(sf::Keyboard::D)) {
updated_pos.x += PLAYER_SPEED * time;
player->setState(State::RUN);
player->setDirection(Direction::RIGHT);
}
if (sf::Keyboard::isKeyPressed(sf::Keyboard::W)) {
updated_pos.y -= PLAYER_SPEED * time;
player->setState(State::RUN);
}
else if (sf::Keyboard::isKeyPressed(sf::Keyboard::S)) {
updated_pos.y += PLAYER_SPEED * time;
player->setState(State::RUN);
}
player->setPosition(updated_pos);
}
В геттере я создаю создаю объект класса, если он еще не был создани и возвращаю его.
В методе PlayerController::controllPlayer
я обрабатываю события клавиатуры.
В зависимости от нажатой клавиши я меняю состояние и позицию переданного персонажа.
Загрузка текстур
Тут все несложно, я привык выносить все текстуры в отдельный файл, в котором будет всего одна функция - setTextures
, которая загрузит все текстуры из переданных путей. Просто и эффективно, если учитывать, какие текстуры нужны тебе в конкретном игровом уровне.
// Textures.h
#pragma once
#include <SFML/Graphics.hpp>
namespace textures {
sf::Texture player_texture;
static void setTextures() {
player_texture.loadFromFile("./Assets/player.jpg");
}
}
У меня пока только одная текстура, причем это просто картинка, не тайлсет, но об этом в следующей статье.
Финишная прямая
Наконец-то все, что нужно уже написано, осталось только все это соединить и запустить проект.
Итоговый файл main.cpp
будет выглядеть примерно так:
#include <SFML/Graphics.hpp>
#include "include/Engine/Constants.h"
#include "include/Textures.h"
#include "include/Player.h"
int main() {
sf::RenderWindow window(sf::VideoMode(WINDOW_WIDTH, WINDOW_HEIGHT), "Title");
textures::setTextures();
Player* player = new Player(textures::player_texture, sf::Vector2f(PLAYER_START_X, PLAYER_START_Y), PLAYER_START_HP);
sf::Clock clock;
while (window.isOpen()) {
float time = clock.getElapsedTime().asMicroseconds();
clock.restart();
time /= 300;
sf::Event event;
while (window.pollEvent(event)) {
if (event.type == sf::Event::Closed) {
window.close();
}
}
player->Update(time);
window.clear(sf::Color::White);
window.draw(player->getSprite());
window.display();
}
delete player;
return 0;
}
Мы создали объект класса Player
, поинициализировали его, загрузили все текстуры и нарисовали спрайт игрока с помощью window.draw
.
Также не стоит забывать про вызов метода Update
.
Здесь стоит пояснить про time
. time
- это текущее время в микросекундах, мы передаем его во все методы Update, что скорость игры зависела не от частоты кадров, а от времени, тем самым, на разных компьютерах игра будет обновляться за одно и тоже время.
Запуск
Билдим проект, запускаем и видим следующее
Ура! все работает, персонаж двигается.
Итоговая иерархия файлов выглядит так:
Заключение
Ну вот статья и подошла к концу, спасибо всем, кто дошел до этого этапа.
В следующей части я рассмотрю создание анимации персонажа и создание границ мира.
Репозиторий с проектом - тут сделано больше, чем написано в статье, т.к. я не сразу начал ее писать.
Если у вас остались какие-либо вопросы, задавайте их в комментариях, я и другие ребята с удовольствием на них ответят