Возможно вы уже видели эту картинку много раз, но я все равно решил ее сюда вставить
Возможно вы уже видели эту картинку много раз, но я все равно решил ее сюда вставить

Вступление

Всем привет!

Пришла, значит, мне в голову идея - сделать свою игру по типу 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, что скорость игры зависела не от частоты кадров, а от времени, тем самым, на разных компьютерах игра будет обновляться за одно и тоже время.

Запуск

Билдим проект, запускаем и видим следующее

Ура! все работает, персонаж двигается.
Итоговая иерархия файлов выглядит так:

Заключение

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

Репозиторий с проектом - тут сделано больше, чем написано в статье, т.к. я не сразу начал ее писать.

Если у вас остались какие-либо вопросы, задавайте их в комментариях, я и другие ребята с удовольствием на них ответят

Комментарии (15)


  1. Tolkik
    16.03.2024 12:52

    На kychka-pc уже и так есть гайд многолетней давности, как сделать полноценную игру.


    1. DaniilUbica Автор
      16.03.2024 12:52

      Может быть, но, как я уже писал, моя публикация - не гайд


      1. ArtFilips
        16.03.2024 12:52

        Возможно, посчитаю нужным сказать, что в библиотеке есть класс шаблонный класс rect. В принципе, на этом мои знания заканчиваются).


    1. includedlibrary
      16.03.2024 12:52
      +1

      Всегда интересно читать историю разработки какого-нибудь проекта. Первая статья, конечно, мало чем отличается от типичного гайда по SFML, ну так ведь это первая статья из цикла. Надеюсь, дальше будет интереснее


      1. DaniilUbica Автор
        16.03.2024 12:52

        Спасибо, постараюсь написать следующую часть как можно быстрее)


  1. Travisw
    16.03.2024 12:52

    А сможешь загрузить 3д модель в игру?


    1. DaniilUbica Автор
      16.03.2024 12:52

      SFML - мультимедиа библиотека для работы с 2D графикой, поэтому 3D модель загрузить не выйдет. Но можно эмулировать 3D, используя для этого, например, raycast, тогда можно делать 3D игры по типу Doom, Wolfenstein и т.д.


      1. includedlibrary
        16.03.2024 12:52

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


        1. DaniilUbica Автор
          16.03.2024 12:52

          Это-то да, но в таком случае не имеет смысла использовать SFML, можно создавать окно с помощью нативных средств системы или с помощью GLFW.


          1. includedlibrary
            16.03.2024 12:52
            +1

            Насколько я помню, в SFML есть не только создание окна и 2d рендеринг. Там ещё есть поддержка различных графических форматов и вывода аудио. Так что смысл всё же имеется (некоторые предпочитают SDL, но он имеет сишный интерфейс).


            1. DaniilUbica Автор
              16.03.2024 12:52

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


  1. 0Bannon
    16.03.2024 12:52

    Давай что-нибудь серьезное. А вот это в очередной раз подвигать спрайт - таких полно туториалов.

    Давай стейт машину, какие-нибудь паттерны игровые, как вообще архитектуру самой игры продумать на примере какой-нибудь tower defense. Как свой написать проигрыватель анимаций, физику простую, коллизии попиксельные, а не только ААВВ хит боксы и тд. Вот это будет серия классная.


    1. DaniilUbica Автор
      16.03.2024 12:52

      Я постараюсь впечатлить Вас в продолжении)

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

      Так же повторюсь снова - моя статья - не гайд


  1. dmitriy3342
    16.03.2024 12:52

    Я пару лет назад сделал шаблон с конфигами, вроде удобно с него начинать
    https://marketplace.visualstudio.com/items?itemName=AngaldSoft.templatesfml251winpingpong


    1. DaniilUbica Автор
      16.03.2024 12:52

      Может быть кому-то и удобно, но, как по мне, проще взять тестовый код с сайта SFML, из которого нужно убрать только вывод фигуры, и получить готовый код main'а, чем разбираться в полном коде понга со звуком