Очистка с помощью диспетчера объектов

Эта статья является частью серии «Игра с нуля на C++ и SFML».

1) Введение
2) Наше первое окно и игровой цикл
3) Менеджер состояний
4) Добавление нашего игрока
5) Уборка с менеджером объектов
6) Добавить мяч
7) ИИ!
8) Завершение игры

Уборка так быстро? Есть две причины, по которым я хочу провести рефакторинг нашей кодовой базы сейчас.

Во-первых, мы часто повторяемся, а я не люблю переписывать один и тот же код снова и снова.

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

1. Класс видимых объектов

Для более крупной игры было бы интересно использовать паттерн компонентов. Кроме того, физика и графика не должны быть так связаны друг с другом. Но здесь мы уже охватываем много разных понятий и менеджер объектов достаточно новой сложности. Так что мы собираемся пойти с простым наследованием и продолжать использовать нашу графику в качестве помощников по физике.

Хорошо пойдем! Мы собираемся создать класс VisibleObject, от которого будут наследоваться все наши игровые объекты.

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

Вы сделали? Хорошо, вот моя версия:

#ifndef PANG_VISIBLE_OBJECT_H
#define PANG_VISIBLE_OBJECT_H
#include <SFML/Graphics.hpp>
#include <iostream>
class VisibleObject {
public:
  VisibleObject(std::string textureFilename);
  virtual ~VisibleObject();
  virtual void handleInput(sf::Event *event) {};
  virtual void update(float timeElapsed) = 0;
  virtual void draw(sf::RenderWindow *window);
  virtual void collideWith(VisibleObject *target) {};
  virtual void move(float x, float y);
  virtual void setPosition(float x, float y);
  virtual sf::Vector2<float> getPosition();
  virtual float getTop();
  virtual float getBottom();
  virtual float getLeft();
  virtual float getRight();
  virtual sf::Rect<float> getBoundingRect();
private:
  sf::Texture _texture;
  sf::Sprite _sprite;
  bool _isLoaded;
};
#endif //PANG_VISIBLE_OBJECT_H

Я решил придерживаться шаблона, который мы используем для нашего игрового цикла: init (выполняется конструктором), handleInput, update и draw.

Я добавил позиции move и set/get, потому что они понадобятся нам для взаимодействия с нашим объектом.

collideWith будет использоваться для обработки коллизий. Мы будем выполнять наши действия в зависимости от типа объекта, с которым мы столкнулись.

Методы get top/bottom/right/left предназначены для того, чтобы сделать наши вычисления более читабельными. getBoundingRect также доступен, потому что иногда объект sf::Rect<float> легче манипулировать.

Наконец-то наши _texture и _sprite, вы их уже знаете. Логическое значение _isLoaded сообщит нам, правильно ли загружен спрайт. В противном случае мы не хотим его рисовать.

Теперь давайте посмотрим на реализацию. Как всегда, попробуйте написать свой, прежде чем смотреть код в этой статье 😉

#include "visible-object.h"
VisibleObject::VisibleObject(std::string textureFilename) {
  if (!_texture.loadFromFile(textureFilename)) {
    std::cout << "Error while loading asset" << std::endl;
    return;
  }
  _isLoaded = true;
  _sprite.setTexture(_texture);
}
VisibleObject::~VisibleObject() {}
void VisibleObject::draw(sf::RenderWindow *window) {
  if (!_isLoaded) return;
  window->draw(_sprite);
}
void VisibleObject::move(float x, float y) {
  if (!_isLoaded) return;
  _sprite.move(x, y);
}
void VisibleObject::setPosition(float x, float y) {
  if (!_isLoaded) return;
  _sprite.setPosition(x, y);
}
sf::Vector2<float> VisibleObject::getPosition() {
  return _sprite.getPosition();
}
float VisibleObject::getTop() {
  sf::Rect<float> boundingRect = getBoundingRect();
  return boundingRect.top;
}
float VisibleObject::getBottom() {
  sf::Rect<float> boundingRect = getBoundingRect();
  return boundingRect.top + boundingRect.height;
}
float VisibleObject::getLeft() {
  sf::Rect<float> boundingRect = getBoundingRect();
  return boundingRect.left;
}
float VisibleObject::getRight() {
  sf::Rect<float> boundingRect = getBoundingRect();
  return boundingRect.left + boundingRect.width;
}
sf::Rect<float> VisibleObject::getBoundingRect() {
  return _sprite.getGlobalBounds();
}

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

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

2. Наш менеджер объектов

Этот класс тоже будет довольно простым. Нам нужно хранить список объектов, иметь возможность получать/добавлять/удалять их, и нам нужен метод для обработки входных данных, обновления и отрисовки всех объектов.

#ifndef PANG_VISIBLE_OBJECT_MANAGER_H
#define PANG_VISIBLE_OBJECT_MANAGER_H
#include <SFML/Graphics.hpp>
#include "visible-object.h"
class VisibleObjectManager {
public:
  ~VisibleObjectManager();
  void add(std::string name, VisibleObject *object);
  void remove(std::string name);
  VisibleObject *get(std::string name);
  void handleInputAll(sf::Event *event);
  void updateAll(float timeElapsed);
  void drawAll(sf::RenderWindow *window);
private:
  std::map<std::string, VisibleObject*> _objects;
};
#endif

И реализация:

#include "visible-object-manager.h"
VisibleObjectManager::~VisibleObjectManager() {
  auto itr = _objects.begin();
  while (itr != _objects.end()) {
    delete itr->second;
    itr++;
  }
}
void VisibleObjectManager::add(std::string name, VisibleObject *object) {
  _objects.insert(std::pair<std::string, VisibleObject*>(name, object));
}
void VisibleObjectManager::remove(std::string name) {
  auto results = _objects.find(name);
  if (results != _objects.end()) {
    delete results->second;
    _objects.erase(results);
  }
}
VisibleObject *VisibleObjectManager::get(std::string name) {
  auto results = _objects.find(name);
  if (results == _objects.end()) return NULL;
  return results->second;
}
void VisibleObjectManager::handleInputAll(sf::Event *event) {
  auto itr = _objects.begin();
  while (itr != _objects.end()) {
    itr->second->handleInput(event);
    itr++;
  }
}
void VisibleObjectManager::updateAll(float timeElapsed) {
  auto itr = _objects.begin();
  while (itr != _objects.end()) {
    itr->second->update(timeElapsed);
    itr++;
  }
  // Detect collision. 
  // This could be improved in a lot of way but give us a starting point
  auto originItr = _objects.begin();
  while (originItr != _objects.end()) {
    sf::Rect<float> originBound = originItr->second->getBoundingRect();
    auto targetItr = _objects.begin();
    while (targetItr != _objects.end()) {
      if (targetItr == originItr) { targetItr++; continue; }
      
      sf::Rect<float> targetBound = targetItr->second->getBoundingRect();
      if (originBound.intersects(targetBound)) {
        originItr->second->collideWith(targetItr->second);
      }
      targetItr++;
    }
    originItr++;
  }
}
void VisibleObjectManager::drawAll(sf::RenderWindow *window) {
  auto itr = _objects.begin();
  while (itr != _objects.end()) {
    itr->second->draw(window);
    itr++;
  }
}

Если у вас возникли проблемы с пониманием этого кода, загляните на https://www.geeksforgeeks.org/iterators-c-stl/

Есть только одна «сложная» часть: обнаружение столкновений. Для этой игры я решил сделать максимально простое обнаружение внутри диспетчера объектов. Мы перебираем каждый элемент и смотрим, не сталкиваются ли они друг с другом. Если это так, мы вызываем метод столкновения.

Каждая сущность должна решить, как она будет реагировать на столкновение. Многое можно было бы улучшить в этом методе, но пока этого достаточно.

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

2. Наши объекты

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

Мы собираемся сделать это вместе для нашего игрового экрана. Я позволю вам воспроизвести процесс для меню и заставки.

Во-первых, давайте добавим диспетчер объектов в наши состояния. В game-state.h добавить:

protected:
  VisibleObjectManager _visibleObjectManager;

Начнем с самого простого: поля. Единственное, что ему нужно сделать, это загрузить правильные активы. Давайте создадим наши файлы objects/entities/field.h :

#ifndef PANG_FIELD_H
#define PANG_FIELD_H
#include "../visible-object.h"
class Field: public VisibleObject {
public:
  Field();
  void update(float timeElapsed);
};
#endif //PANG_FIELD_H

И objects/entities/field.cpp :

#include "field.h"
Field::Field() : VisibleObject("../assets/field.png") { }
void Field::update(float timeElapsed) { }

Это так просто! Теперь немного сложнее. Давайте создадим класс Paddle, который будет представлять нашего игрока. Это требует немного больше работы.

Он может обрабатывать ввод и обновляться. Нам нужна скорость и направление. Поскольку мы ограничиваем нашу ракетку внутри поля, и эти ограничения никогда не меняются, я решил также поместить их в класс, чтобы мы не вычисляли их заново в каждом кадре. Давайте создадим objects/entities/paddle.h :

#ifndef PANG_PADDLE_H
#define PANG_PADDLE_H
#include "../visible-object.h"
class Paddle: public VisibleObject {
public:
  Paddle(float constraintTop, float constraintBottom);
  void handleInput(sf::Event *event);
  void update(float timeElapsed);
private:
  enum Direction { DIRECTION_NONE, DIRECTION_UP, DIRECTION_DOWN };
  Direction _direction = DIRECTION_NONE;
  float _speed = 800.0f;
  float _constraintTop;
  float _constraintBottom;
};
#endif //PANG_PADDLE_H

И objects/entities/paddle.cpp :

#include "paddle.h"
Paddle::Paddle(float constraintTop, float constraintBottom) : VisibleObject("../assets/paddle.png") {
  // So we don't recalculate fieldBound on each turn as it never changes
  _constraintTop = constraintTop;
  _constraintBottom = constraintBottom;
}
void Paddle::handleInput(sf::Event *event) {
  if (event->type == sf::Event::KeyPressed) {
    if (event->key.code == sf::Keyboard::Up) {
      _direction = DIRECTION_UP;
    } else if (event->key.code == sf::Keyboard::Down) {
      _direction = DIRECTION_DOWN;
    }
  } else if (event->type == sf::Event::KeyReleased) {
    _direction = DIRECTION_NONE;
  }
}
void Paddle::update(float timeElapsed) {
  float velocity = 0.0f;
  if (_direction == DIRECTION_UP) {
    velocity = _speed * -1;
  } else if (_direction == DIRECTION_DOWN) {
    velocity = _speed;
  }
  move(0, velocity * timeElapsed);
  sf::Vector2 pos = getPosition();
  if (pos.y < _constraintTop) {
    setPosition(pos.x, _constraintTop);
  } else if (pos.y + getBoundingRect().height > _constraintBottom) {
    setPosition(pos.x, _constraintBottom - getBoundingRect().height);
  }
}

Там ничего особенного. Мы сохранили логику из нашего игрового состояния и переместили ее в отдельный класс.

Теперь, когда у нас есть классы, давайте воспользуемся ими. Вы можете удалить все частные члены из класса PlayingState и обновить файл playing-state.cpp следующим образом:

#include "playing-state.h"
void PlayingState::init() {
  Field *field = new Field();
  field->setPosition(100, 200);
  _visibleObjectManager.add("field", field);
  Paddle *player1 = new Paddle(field->getTop() + 10, field->getBottom() - 10);
  player1->setPosition(150, 718);
  _visibleObjectManager.add("player1", player1);
}
void PlayingState::handleInput(sf::Event *event) {
  _visibleObjectManager.handleInputAll(event);
}
void PlayingState::update(float timeElapsed) {
  _visibleObjectManager.updateAll(timeElapsed);
}
void PlayingState::draw(sf::RenderWindow *window) {
  _visibleObjectManager.drawAll(window);
}

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

Код для этого шага, включая рефакторинг нашей заставки и меню, доступен по адресу https://github.com/mel-mouk/sfml-pong/tree/0.0.9.

Я надеюсь, что вы нашли эту статью полезной! Следующую часть вы можете найти здесь👋