2
\$\begingroup\$

Here's my attempt at reimplementing part of https://diep.io/, a 2D game where tanks battle with each other. The tanks are circular and they have cannons which fire bullets. The bullets can hit other tanks and they disappear after three seconds. Here's a random YouTube video if you want to see how the original game works: https://www.youtube.com/watch?v=9R6zsD5rdd8.

I'm using Box2D for the physics and SFML for graphics and input. Currently, I have only implemented basic tanks and bullets, but I want to make sure the overall structure is good before continuing so that I don't spend a bunch of time refactoring later. I plan on implementing health and damage in the future but I'm not asking for help on those.

arena.fwd.h:

#ifndef CPPDIEP_ARENA_FWD_H
#define CPPDIEP_ARENA_FWD_H

/// @file
/// Forward declaration for Arena used to avoid circular dependencies.

namespace cppdiep {

class Arena;

} // namespace cppdiep

#endif // CPPDIEP_ARENA_FWD_H

arena.h:

#ifndef CPPDIEP_ARENA_H
#define CPPDIEP_ARENA_H

#include "arena.fwd.h"

#include <concepts>
#include <cstdint>
#include <memory>
#include <utility>
#include <vector>

#include <Box2D/Common/b2Math.h>
#include <Box2D/Dynamics/b2World.h>
#include <SFML/Graphics/Color.hpp>
#include <SFML/Graphics/RenderTarget.hpp>

#include "bullet.h"
#include "tank.h"
#include "time.h"

namespace cppdiep {

/// The Arena class manages all of the objects in the game.
class Arena {
public:
  /// Construct an arena.
  /// @param size the side length of the arena.
  /// @param time_step the number of seconds that each time step represents.
  Arena(float size, float time_step);

  /// Draw the arena to an SFML render target.
  /// @param target the SFML render target to draw to.
  void draw(sf::RenderTarget &target) const;

  /// Advance the state of the arena by one time step.
  void step();

  /// Spawn a non-tank object in the arena.
  /// @tparam ObjectType the type of the object to spawn
  /// @param args the arguments to be forwarded to the object's constructor
  /// @return A reference to the new object.
  // clang-format and doxygen doen't handle the requires expression properly.
  // clang-format off
  template <std::derived_from<Object> ObjectType, typename... Args>
  /// @cond
    requires(!std::derived_from<ObjectType, Tank>)
  /// @endcond
  ObjectType &spawnObject(Args &&...args) {
    ObjectType *object = new ObjectType(*this, std::forward<Args>(args)...);
    objects.emplace_back(object);
    return *object;
  }
  // clang-format on

  /// Spawn a tank in the arena.
  /// @tparam TankType the type of the tank to spawn
  /// @param args the arguments to be forwarded to the tank's constructor
  /// @return A reference to the new tank.
  template <std::derived_from<Tank> TankType, typename... Args>
  TankType &spawnObject(Args &&...args) {
    TankType *tank = new TankType(*this, std::forward<Args>(args)...);
    tanks.emplace_back(tank);
    return *tank;
  }

  /// Get the time step size.
  /// @return The time step size.
  float getTimeStep() const { return time_step; }

  /// Get the current time.
  /// @return The current time in steps.
  Time getTime() const { return time; }

private:
  friend Object;

  /// Alias for the type of the smart pointers used to store the polymorphic
  /// objects.
  /// @tparam ObjectType the type of the object that the pointer points to.
  template <std::derived_from<Object> ObjectType>
  using ObjectPtr = std::unique_ptr<ObjectType, typename ObjectType::Deleter>;

  /// Alias for the type of the container used to store objects.
  /// @tparam ObjectType the type of the objects stored in the container.
  template <std::derived_from<Object> ObjectType>
  using ObjectContainer = std::vector<ObjectPtr<ObjectType>>;

  /// Get the arena's Box2D world.
  /// @return A reference to the arena's Box2D world.
  b2World &getB2World() { return b2_world; }

  /// @copydoc getB2World()
  const b2World &getB2World() const { return b2_world; }

  /// The Box2D world of the arena. The gravity vector is zero since the world
  /// is horizontal. This has to be destructed after all of the objects have
  /// been destructed since the destructors of the objects will access the world
  /// to destroy their bodies.
  b2World b2_world{b2Vec2(0.f, 0.f)};

  /// Container of all of the objects in the arena except for tanks.
  ObjectContainer<Object> objects;

  /// Container of all of the tanks in the arena. Tank barrels can overlap with
  /// other objects, so they have to be kept separately and drawn in a
  /// consistent order after other objects.
  ObjectContainer<Tank> tanks;

  /// The number of seconds in each time step.
  const float time_step;

  /// The current time as the number of time steps since the arena was created.
  Time time = 0;
};

} // namespace cppdiep

#endif // CPPDIEP_ARENA_H

arena.cpp:

#include "arena.h"

#include <array>
#include <concepts>
#include <memory>
#include <utility>
#include <vector>

#include <Box2D/Collision/Shapes/b2ChainShape.h>
#include <Box2D/Common/b2Math.h>
#include <Box2D/Dynamics/b2Body.h>
#include <Box2D/Dynamics/b2Fixture.h>
#include <Box2D/Dynamics/b2World.h>
#include <SFML/Graphics/RenderTarget.hpp>

#include "box2d_categories.h"
#include "bullet.h"
#include "render_utils.h"
#include "tank.h"
#include "time.h"

namespace cppdiep {

Arena::Arena(float size, float time_step) : time_step(time_step) {
  b2BodyDef border_body_def;
  b2Body &border_body = *b2_world.CreateBody(&border_body_def);
  std::array border_vertices = {
      b2Vec2(size / 2.f, size / 2.f), b2Vec2(-size / 2.f, size / 2.f),
      b2Vec2(-size / 2.f, -size / 2.f), b2Vec2(size / 2.f, -size / 2.f)};
  b2ChainShape border_chain;
  border_chain.CreateLoop(border_vertices.data(), border_vertices.size());
  b2FixtureDef border_fixture_def;
  border_fixture_def.shape = &border_chain;
  border_fixture_def.friction = 0.25f;
  border_fixture_def.restitution = 0.25f;
  border_fixture_def.filter.categoryBits = box2d_categories::BORDER;
  border_fixture_def.filter.maskBits = box2d_categories::TANK;
  border_body.CreateFixture(&border_fixture_def);
}

void Arena::draw(sf::RenderTarget &target) const {
  target.clear(colors::BACKGROUND);
  // Bullets are drawn first so that they are underneath the tank barrels.
  for (const ObjectPtr<Object> &object : objects) {
    object->draw(target);
  }
  for (const ObjectPtr<Tank> &tank : tanks) {
    tank->draw(target);
  }
}

void Arena::step() {
  // Replace objects that need to be destroyed with objects moved from the end
  // of the vector. Iterating in reverse simplifies things since we don't have
  // to worry about skipping over objects when removing an object.
  auto new_end = objects.end();
  for (auto it = objects.rbegin(); it != objects.rend(); ++it) {
    if ((*it)->step()) {
      *it = std::move(*--new_end);
    }
  }
  objects.erase(new_end, objects.end());
  // The tanks have to be rendered in a consistent order because their barrels
  // may overlap with other tanks.
  std::erase_if(tanks,
                [](const ObjectPtr<Tank> &tank) { return tank->step(); });
  b2_world.Step(time_step, 8, 3);
  ++time;
}

} // namespace cppdiep

object.h:

#ifndef CPPDIEP_OBJECT_H
#define CPPDIEP_OBJECT_H

#include <Box2D/Common/b2Math.h>
#include <Box2D/Dynamics/b2Body.h>
#include <Box2D/Dynamics/b2World.h>
#include <SFML/Graphics/RenderTarget.hpp>

#include "arena.fwd.h"

namespace cppdiep {

/// A game object, such as a tank, a bullet, or a polygon.
class Object {
public:
  Object(const Object &) = delete;

  /// Get the current position of the object.
  /// @return The current position of the object.
  b2Vec2 getPosition() const { return getB2Body().GetPosition(); }

  /// Get the current velocity of the object.
  /// @return The current velocity of the object.
  b2Vec2 getVelocity() const { return getB2Body().GetLinearVelocity(); }

  /// Draw the object to an SFML render target.
  /// @param target the SFML render target to draw to.
  virtual void draw(sf::RenderTarget &target) const = 0;

protected:
  /// Construct an object.
  /// @param arena the arena that contains the object. The object will keep a
  /// reference to the arena so the
  /// @param b2_body_def the Box2D body definition that will be used to create
  /// the object's body.
  Object(Arena &arena, const b2BodyDef &b2_body_def);

  /// Destruct an object.
  virtual ~Object();

  /// Advance the state of the object by one time step and return whether the
  /// object should be destroyed now.
  /// @return Whether the object should be destroyed now.
  virtual bool step() {
    // Health and damage haven't been implemented yet so this just returns
    // false.
    return false;
  }

  /// Get a reference to the arena that contains the object.
  /// @return A reference to the arena that contains the object.
  Arena &getArena() const { return arena; }

  /// Get a reference to the Box2D body of the object.
  /// @return A reference to the Box2D body of the object.
  b2Body &getB2Body() { return b2_body; }

  /// @copydoc getB2Body()
  const b2Body &getB2Body() const { return b2_body; }

private:
  friend Arena;

  /// A deleter that the arena passes to the smart pointer. This is necessary
  /// since the destructor is not public.
  struct Deleter {
    void operator()(Object *object) const { delete object; }
  };

  /// The arena that contains the object.
  Arena &arena;

  /// The Box2D body of the object.
  b2Body &b2_body;
};

} // namespace cppdiep

#endif // CPPDIEP_OBJECT_H

object.cpp:

#include "object.h"

#include "arena.h"

namespace cppdiep {

Object::Object(Arena &arena, const b2BodyDef &b2_body_def)
    : arena(arena), b2_body(*arena.getB2World().CreateBody(&b2_body_def)) {}

Object::~Object() { arena.getB2World().DestroyBody(&b2_body); }

} // namespace cppdiep

tank.h:

#ifndef CPPDIEP_TANK_H
#define CPPDIEP_TANK_H

#include <Box2D/Collision/Shapes/b2Shape.h>
#include <Box2D/Common/b2Math.h>
#include <Box2D/Dynamics/b2Body.h>
#include <Box2D/Dynamics/b2Fixture.h>
#include <Box2D/Dynamics/b2World.h>
#include <SFML/Graphics/Color.hpp>
#include <SFML/Graphics/RenderTarget.hpp>

#include "arena.fwd.h"
#include "object.h"
#include "render_utils.h"

namespace cppdiep {

/// A generic tank.
class Tank : public Object {
public:
  /// Get the radius of the tank body.
  /// @return the radius of the tank body.
  float getRadius() const {
    return getB2Body().GetFixtureList()->GetShape()->m_radius;
  }

  /// Get the current target position of the tank.
  /// @return the target position of the tank relative to the tank's position.
  virtual b2Vec2 getTarget() const = 0;

  /// Get the direction of the tank's target relative to the tank.
  /// @return The direction of the tank's target as an angle in radians.
  float getTargetAngle() const {
    b2Vec2 target = getTarget();
    return std::atan2(target.y, target.x);
  }

  /// Get the color of the tank.
  /// @return The color of the tank.
  sf::Color getColor() const { return color; }

  void draw(sf::RenderTarget &target) const override;

protected:
  /// Construct a Tank.
  /// @param arena the arena that contains the tank.
  /// @param position the initial position of the tank.
  /// @param radius the radius of the tank's body.
  /// @param color the color of the tank.
  Tank(Arena &arena, const b2Vec2 &position, float radius,
       const sf::Color &color);

  /// Helper function for drawing cannons.
  /// @param target the SFML render target to draw to.
  /// @param length the length of the barrel. The barrel starts from the center
  /// of the tank.
  /// @param width the width of the barrel.
  /// @param angle the angle that the cannon is pointing towards in radians.
  void drawCannon(sf::RenderTarget &target, float length, float width,
                  float angle) const;

  /// Apply a force to move the tank.
  /// @param vec the direction and speed to move in. A magnitude of 1 represents
  /// full speed.
  void move(const b2Vec2 &vec) {
    getB2Body().ApplyForceToCenter(getMoveForce() * vec, true);
  }

  /// Fire the tank's cannon(s).
  virtual void fire() = 0;

private:
  friend Arena;

  /// Draw the tank's cannon(s).
  /// @param target the SFML render target to draw to.
  virtual void drawCannons(sf::RenderTarget &target) const = 0;

  /// Get the magnitude of the force used to move the tank.
  /// @param the magnitude of the force used to move the tank.
  virtual float getMoveForce() const = 0;

  /// The color of the tank.
  sf::Color color;
};

} // namespace cppdiep

#endif // CPPDIEP_TANK_H

tank.cpp:

#include "tank.h"

#include <Box2D/Collision/Shapes/b2CircleShape.h>
#include <Box2D/Common/b2Math.h>
#include <Box2D/Dynamics/b2Body.h>
#include <Box2D/Dynamics/b2Fixture.h>
#include <Box2D/Dynamics/b2World.h>
#include <SFML/Graphics/CircleShape.hpp>
#include <SFML/Graphics/Color.hpp>
#include <SFML/Graphics/RectangleShape.hpp>
#include <SFML/Graphics/RenderTarget.hpp>

#include "arena.h"
#include "box2d_categories.h"
#include "render_utils.h"

namespace cppdiep {

namespace {
b2BodyDef makeB2BodyDef(const b2Vec2 &position) {
  b2BodyDef body_def;
  body_def.type = b2_dynamicBody;
  body_def.position = position;
  body_def.fixedRotation = true;
  body_def.linearDamping = 1.f;
  return body_def;
}
} // namespace

Tank::Tank(Arena &arena, const b2Vec2 &position, float radius,
           const sf::Color &color)
    : Object(arena, makeB2BodyDef(position)), color(color) {
  b2CircleShape body_shape;
  body_shape.m_radius = radius;
  b2FixtureDef fixture_def;
  fixture_def.shape = &body_shape;
  fixture_def.density = 1.f;
  fixture_def.friction = 0.3f;
  fixture_def.restitution = 0.25f;
  fixture_def.filter.categoryBits = box2d_categories::TANK;
  fixture_def.filter.maskBits = box2d_categories::TANK |
                                box2d_categories::BORDER |
                                box2d_categories::BULLET;
  getB2Body().CreateFixture(&fixture_def);
}

void Tank::draw(sf::RenderTarget &target) const {
  drawCannons(target);
  drawCircle(target, getPosition(), getRadius(), getColor());
}

void Tank::drawCannon(sf::RenderTarget &target, float length, float width,
                      float angle) const {
  sf::RectangleShape cannon_shape(sf::Vector2f(length, width));
  cannon_shape.setOrigin(0.f, width / 2.f);
  cannon_shape.setPosition(convertVector(getPosition()));
  cannon_shape.setFillColor(colors::CANNON);
  cannon_shape.setOutlineThickness(OUTLINE_THICKNESS);
  cannon_shape.setOutlineColor(darken(colors::CANNON));
  cannon_shape.setRotation(radiansToDegrees(angle));
  target.draw(cannon_shape);
}

} // namespace cppdiep

basic_tank.h:

#ifndef CPPDIEP_BASIC_TANK_H
#define CPPDIEP_BASIC_TANK_H

#include <SFML/Graphics/RenderTarget.hpp>

#include "tank.h"

namespace cppdiep {

/// A tank with a single cannon.
class BasicTank : public Tank {
protected:
  using Tank::Tank;
  void fire() override;

private:
  void drawCannons(sf::RenderTarget &target) const override;
  float getMoveForce() const override { return 15.f * getRadius(); }
};

} // namespace cppdiep

#endif // CPPDIEP_BASIC_TANK_H

basic_tank.cpp:

#include "basic_tank.h"

#include <cmath>

#include <Box2D/Common/b2Math.h>
#include <SFML/Graphics/RenderTarget.hpp>

#include "arena.h"
#include "bullet.h"

namespace cppdiep {

void BasicTank::drawCannons(sf::RenderTarget &target) const {
  float radius = getRadius();
  drawCannon(target, 2 * radius, radius, getTargetAngle());
}

void BasicTank::fire() {
  b2Vec2 target_vec = getTarget();
  target_vec.Normalize();
  float bullet_radius = getRadius() / 2.f;
  float impulse_magnitude = 10.f * getRadius();
  // The bullet is spawned in the barrel just outside of the tank body to avoid
  // teleportation due to the bullet intersecting the tank body. This causes
  // some teleportation if the spawned bullet intersects another object. In the
  // future, collisions between a bullet and the tank that fired it will be
  // disabled and the bullet will be spawned inside the tank body.
  getArena().spawnObject<Bullet>(
      getPosition() + (getRadius() + bullet_radius) * target_vec, getVelocity(),
      impulse_magnitude * target_vec, bullet_radius, getColor());
  // Simulate recoil by applying the same impulse in the opposite direction to
  // the tank.
  getB2Body().ApplyLinearImpulse(-impulse_magnitude * target_vec,
                                 getB2Body().GetWorldCenter(), true);
}

} // namespace cppdiep

external_control_tank.h:

#ifndef CPPDIEP_EXTERNAL_CONTROL_TANK_H
#define CPPDIEP_EXTERNAL_CONTROL_TANK_H

#include <concepts>

#include <Box2D/Common/b2Math.h>

#include "tank.h"

namespace cppdiep {

/// A tank controlled externally using the move() and fire() methods.
/// @tparam BaseTank the type of the tank to be controlled externally.
template <std::derived_from<Tank> BaseTank>
class ExternalControlTank final : public BaseTank {
public:
  using BaseTank::BaseTank;

  /// Apply a force to move the tank in the direction of the given vector.
  /// @param vec a vector indicating the direction and speed to move in. A
  /// magnitude of 1 indicates full speed.
  void move(const b2Vec2 &vec) { BaseTank::move(vec); }

  /// @copydoc Tank::getTarget()
  b2Vec2 getTarget() const override { return target; }

  /// Set the target point of the tank. This is the point that the tank will aim
  /// towards.
  /// @param target the target point relative to the position of the tank.
  void setTarget(const b2Vec2 &target) { this->target = target; }

  /// Fire the tank's cannon(s).
  void fire() { BaseTank::fire(); }

private:
  /// The current target point of the tank.
  b2Vec2 target{0.f, 0.f};
};

} // namespace cppdiep

#endif // CPPDIEP_EXTERNAL_CONTROL_TANK_H

bullet.h:

#ifndef CPPDIEP_BULLET_H
#define CPPDIEP_BULLET_H

#include <Box2D/Common/b2Math.h>
#include <Box2D/Dynamics/b2Body.h>
#include <Box2D/Dynamics/b2Fixture.h>
#include <Box2D/Dynamics/b2World.h>
#include <SFML/Graphics/RenderTarget.hpp>

#include "arena.fwd.h"
#include "object.h"
#include "render_utils.h"
#include "time.h"

namespace cppdiep {

/// A bullet fired from a cannon. Bullets disappear after three seconds.
class Bullet : public Object {
public:
  /// Get the radius of the bullet.
  /// @return The radius of the bullet.
  float getRadius() const {
    return getB2Body().GetFixtureList()->GetShape()->m_radius;
  }

  void draw(sf::RenderTarget &target) const override;

protected:
  /// Construct a bullet.
  /// @param arena the arena that contains the bullet.
  /// @param position the initial position of the bullet.
  /// @param velocity the initial velocity of the bullet.
  /// @param impulse the impulse applied to the bullet on top of the initial
  /// velocity.
  /// @param radius the radius of the bullet.
  /// @param color the color of the bullet.
  Bullet(Arena &arena, const b2Vec2 &position, const b2Vec2 &velocity,
         const b2Vec2 &impulse, float radius, const sf::Color &color);

  bool step() override;

private:
  friend Arena;

  /// The color of the bullet.
  sf::Color color;

  /// The time when the bullet should be destroyed.
  Time destroy_time;
};

} // namespace cppdiep

#endif // CPPDIEP_BULLET_H

bullet.cpp:

#include "bullet.h"

#include <Box2D/Collision/Shapes/b2CircleShape.h>
#include <Box2D/Dynamics/b2Body.h>
#include <Box2D/Dynamics/b2World.h>
#include <SFML/Graphics/RenderTarget.hpp>

#include "arena.h"
#include "box2d_categories.h"
#include "render_utils.h"

namespace cppdiep {

namespace {
b2BodyDef makeB2BodyDef(const b2Vec2 &position, const b2Vec2 &velocity) {
  b2BodyDef body_def;
  body_def.type = b2_dynamicBody;
  body_def.position = position;
  body_def.linearVelocity = velocity;
  body_def.linearDamping = 0.5f;
  body_def.angularDamping = 0.5f;
  body_def.bullet = true;
  return body_def;
}
} // namespace

Bullet::Bullet(Arena &arena, const b2Vec2 &position, const b2Vec2 &velocity,
               const b2Vec2 &impulse, float radius, const sf::Color &color)
    : Object(arena, makeB2BodyDef(position, velocity)), color(color),
      destroy_time(arena.getTime() + 3.f / arena.getTimeStep()) {
  b2CircleShape body_shape;
  body_shape.m_radius = radius;
  b2FixtureDef fixture_def;
  fixture_def.shape = &body_shape;
  fixture_def.density = 1.f;
  fixture_def.friction = 0.3f;
  fixture_def.restitution = 0.25f;
  fixture_def.filter.categoryBits = box2d_categories::BULLET;
  fixture_def.filter.maskBits =
      box2d_categories::BULLET | box2d_categories::TANK;
  getB2Body().CreateFixture(&fixture_def);
  getB2Body().ApplyLinearImpulse(impulse, getB2Body().GetWorldCenter(), true);
}

void Bullet::draw(sf::RenderTarget &target) const {
  drawCircle(target, getPosition(), getRadius(), color);
}

bool Bullet::step() {
  if (Object::step()) {
    return true;
  }
  return getArena().getTime() >= destroy_time;
}

} // namespace cppdiep

box2d_categories.h:

#ifndef CPPDIEP_B2_CATEGORIES_H
#define CPPDIEP_B2_CATEGORIES_H

#include <Box2D/Common/b2Settings.h>

namespace cppdiep {

/// Box2D collision category bitmasks.
namespace box2d_categories {

/// Box2D collision category bitmask for the arena border.
inline constexpr uint16 BORDER = 1u << 0;

/// Box2D collision category bitmask for tanks.
inline constexpr uint16 TANK = 1u << 1;

/// Box2D collision category bitmask for bullets.
inline constexpr uint16 BULLET = 1u << 2;

} // namespace box2d_categories

} // namespace cppdiep

#endif // CPPDIEP_B2_CATEGORIES_H

render_utils.h:

#ifndef CPPDIEP_RENDER_UTILS_H
#define CPPDIEP_RENDER_UTILS_H

/// @file
/// Constants and helper functions used for rendering.

#include <numbers>

#include <Box2D/Common/b2Math.h>
#include <SFML/Graphics/CircleShape.hpp>
#include <SFML/Graphics/Color.hpp>
#include <SFML/Graphics/RenderTarget.hpp>
#include <SFML/System/Vector2.hpp>

namespace cppdiep {

/// Colors used for rendering.
namespace colors {

/// Color for blue tanks and bullets.
inline const sf::Color BLUE(0, 178, 225);

/// Color for red tanks and bullets.
inline const sf::Color RED(241, 78, 84);

/// Color for tank cannons.
inline const sf::Color CANNON(153, 153, 153);

/// Background color of the arena.
inline const sf::Color BACKGROUND(205, 205, 205);

} // namespace colors

/// The thickness of the outlines around the objects. This is negative to make
/// the outline inside the edge of the object.
inline constexpr float OUTLINE_THICKNESS = -0.125f;

/// Darken a color to get the color of the outline.
/// @param color the color to darken.
/// @return The darkened color.
inline sf::Color darken(const sf::Color &color) {
  return sf::Color(color.r * 0.75, color.g * 0.75, color.b * 0.75, color.a);
}

/// Draw a circle.
/// @param target the SFML render target to draw to.
/// @param position the position of the center of the circle.
/// @param radius the radius of the circle.
/// @param color the color of the circle.
inline void drawCircle(sf::RenderTarget &target, const b2Vec2 &position,
                       float radius, const sf::Color &color) {
  sf::CircleShape shape(radius);
  shape.setOrigin(radius, radius);
  shape.setPosition(position.x, position.y);
  shape.setFillColor(color);
  shape.setOutlineThickness(OUTLINE_THICKNESS);
  shape.setOutlineColor(darken(color));
  target.draw(shape);
}

/// Convert radians to degrees.
/// @param radians an angle in radians.
/// @return The angle in degrees.
inline float radiansToDegrees(float radians) {
  return radians * 180.f / std::numbers::pi_v<float>;
}

/// Convert a Box2D vector to an SFML vector.
/// @param b2_vec a Box2D vector.
/// @return The SFML vector.
inline sf::Vector2f convertVector(const b2Vec2 &b2_vec) {
  return sf::Vector2f(b2_vec.x, b2_vec.y);
}

/// Convert an SFML vector to a Box2D vector.
/// @param sf_vec an SFML vector.
/// @return The Box2D vector.
inline b2Vec2 convertVector(const sf::Vector2f &sf_vec) {
  return b2Vec2(sf_vec.x, sf_vec.y);
}

} // namespace cppdiep

#endif // CPPDIEP_RENDER_UTILS_H

time.h:

#ifndef CPPDIEP_TIME_H
#define CPPDIEP_TIME_H

#include <cstdint>

namespace cppdiep {

/// The signed integer type that will be used to represent time in the arena as
/// a number of steps.
using Time = std::int64_t;

} // namespace cppdiep

#endif // CPPDIEP_TIME_H

main.cpp:

#include <Box2D/Common/b2Math.h>
#include <SFML/Graphics/RenderWindow.hpp>
#include <SFML/Graphics/View.hpp>
#include <SFML/Window/ContextSettings.hpp>
#include <SFML/Window/Event.hpp>
#include <SFML/Window/Keyboard.hpp>
#include <SFML/Window/Mouse.hpp>

#include "arena.h"
#include "basic_tank.h"
#include "external_control_tank.h"
#include "render_utils.h"

int main() {
  // Set up the window.
  sf::ContextSettings settings;
  settings.antialiasingLevel = 4;
  sf::RenderWindow window(sf::VideoMode(800, 800), "CppDiep",
                          sf::Style::Titlebar | sf::Style::Close, settings);
  constexpr int frame_rate = 60;
  window.setFramerateLimit(frame_rate);
  constexpr float arena_size = 20.f;
  // The Y size of the view is negative to flip things vertically since SFML
  // uses a downwards vertical axis while the arena uses an upwards vertical
  // axis.
  sf::View view(sf::Vector2f(0.f, 0.f), sf::Vector2f(arena_size, -arena_size));
  window.setView(view);

  // Create the arena and spawn two tanks for testing.
  cppdiep::Arena arena(arena_size, 1.f / frame_rate);
  auto &tank =
      arena.spawnObject<cppdiep::ExternalControlTank<cppdiep::BasicTank>>(
          b2Vec2(0.f, 0.f), 1.f, cppdiep::colors::BLUE);
  arena.spawnObject<cppdiep::ExternalControlTank<cppdiep::BasicTank>>(
      b2Vec2(0.f, 5.f), 1.f, cppdiep::colors::RED);

  while (window.isOpen()) {
    // Make the tank cannon point towards the mouse.
    b2Vec2 mouse_position = cppdiep::convertVector(
        window.mapPixelToCoords(sf::Mouse::getPosition(window)));
    tank.setTarget(mouse_position - tank.getPosition());

    // Process events.
    sf::Event event;
    while (window.pollEvent(event)) {
      if (event.type == sf::Event::Closed) {
        window.close();
      } else if (event.type == sf::Event::MouseButtonPressed &&
                 event.mouseButton.button == sf::Mouse::Left) {
        tank.fire();
      }
    }
    if (sf::Keyboard::isKeyPressed(sf::Keyboard::W)) {
      tank.move(b2Vec2(0.f, 1.f));
    }
    if (sf::Keyboard::isKeyPressed(sf::Keyboard::A)) {
      tank.move(b2Vec2(-1.f, 0.f));
    }
    if (sf::Keyboard::isKeyPressed(sf::Keyboard::S)) {
      tank.move(b2Vec2(0.f, -1.f));
    }
    if (sf::Keyboard::isKeyPressed(sf::Keyboard::D)) {
      tank.move(b2Vec2(1.f, 0.f));
    }

    arena.step();
    arena.draw(window);
    window.display();
  }
}

CMakeLists.txt:

cmake_minimum_required(VERSION 3.18)

project(
  CppDiep
  DESCRIPTION "Diep.io reimplemented in C++ with SFML and Box2D"
  LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

if(MSVC)
  add_compile_options(/W4 /WX)
else()
  add_compile_options(-Wall -Wextra -pedantic -Werror)
endif()

find_package(
  SFML 2.5
  COMPONENTS graphics
  REQUIRED)
find_package(Box2D REQUIRED)

add_executable(cppdiep arena.cpp basic_tank.cpp bullet.cpp main.cpp object.cpp
                       tank.cpp)
target_link_libraries(cppdiep PRIVATE sfml-graphics Box2D)

Github link: https://github.com/bkrl/cppdiep/tree/72222654a22320b2372a306894047e59ecace9a8

When you run the code, it should create a window with a blue tank and a red tank. You can move the blue tank with the WASD keys and aim with the mouse. Left-clicking will fire a bullet. Bullets collide with tanks but should pass through the border.

\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

Overall the code looks quite nice; a lot of attention to details of the C++ language, it's readable and concise.

Upgrade Box2D

It seems you are using an older version of Box2D. Since Box2D 2.4.0, the structure of the header files has changed significantly, and your code doesn't compile with the newer version.

If you are "stuck" with an old version of a library and can't upgrade it (yet), then at least make sure the documentation and build system of your project correctly specify the desired version of that library.

Template specialization vs. if constexpr

I see you have two specializations of spawnObject(); one that spawns non-Tank objects, one that spawns Tanks. However, the code it mostly identical except for the container in which the object is put. Consider using if constexpr instead:

template <std::derived_from<Object> ObjectType, typename... Args>
ObjectType &spawnObject(Args &&...args) {
  ObjectType *object = new ObjectType(*this, std::forward<Args>(args)...);
  if constexpr (std::derived_from<ObejctType, Tank>) {
    tanks.emplace_back(object);
  } else {
    objects.emplace_back(object);
  }
  return *object;
}

Use more auto where appropriate

In Arena::draw(), you can use auto inside the for-statements to avoid having to write out the types of object and tank.

In Arena::step(), you can use auto in the parameter list of the lambda you pass to std::erase_if().

While auto might hide the type of a variable you declare, often you don't care about the actual type. Furthermore, type deducation can sometimes prevent errors; sometimes you can write the wrong type name but an implicit cast is possible, so the compiler won't complain.

Use algorithms consistently

It looks to me like you should be able to use std::erase_if() for objects in Arena::step(), just like you do for tanks.

If you don't need the algorithm to preserve order, consider using std::ranges::partition() (see this post).

Don't store a reference to the Arena in Object

Your game has only one Arena, but you add a reference to it to every instance of Object. Apart from the constructor using it to initialize b2_body, most objects don't need that reference afterwards. Instead, consider passing a reference to arena only to those functions that need it, like BasicTank::fire() and Bullet::step().

You can even remove the use of getArena() from Bullet::step(), by having Bullet not store the "destroy time" of the bullet, but just the remaining steps left, and then decrement that each time step() is called.

Naming things

  • spawnObject() could be renamed to spawn(), the Object part looks redundant.
  • convertVector() is a bit vague. Also, what if you had three vector types to deal with in your code? I'd split the two overloads into to_Vector2f() and a to_b2Vec2().
  • drawCannons() implies it draws multiple cannons, yet only one cannon is ever drawn per tank. Are you planning for multi-cannon tanks? If not I would rename it to drawCannon().

Reduce the size of main()

Most of your functions are very small, but main() stands out as being the longest. Consider refactoring it and splitting off some of the things it does into separate functions.

\$\endgroup\$
3
  • \$\begingroup\$ Thank you for the review! I'm using Box2D 2.3 from the Debian stable repositories. I tried setting the version in CMakeLists.txt, but it looks like Box2D's CMake config file doesn't set its version properly. The object erasing code was mainly for efficiency. Since the order of the objects doesn't matter, I can replace a removed object with an object from the end of the vector instead of shifting everything after it. It might be a premature optimization though since the code has to scan the whole vector anyway. \$\endgroup\$
    – eesiraed
    Aug 23, 2022 at 18:45
  • \$\begingroup\$ It seems you can use std::partition() to do the equivalent of std::remove_if() without preserving order. \$\endgroup\$
    – G. Sliepen
    Aug 23, 2022 at 19:09
  • 1
    \$\begingroup\$ The arena reference in the object is used by the destructor to destroy the Box2D body. I could have made the step() method destroy the body instead but I thought it makes more sense to have the destructor do it. I am planning for multi-cannon tanks so drawCannons is plural. std::partition() looks like a good idea. Thanks for pointing that out. \$\endgroup\$
    – eesiraed
    Aug 23, 2022 at 21:56

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.