Skip to content

Instantly share code, notes, and snippets.

@alanjfs
Last active October 16, 2019 08:58
Show Gist options
  • Save alanjfs/1ec4fbf3dcc5cc509106a2ae9f8f242b to your computer and use it in GitHub Desktop.
Save alanjfs/1ec4fbf3dcc5cc509106a2ae9f8f242b to your computer and use it in GitHub Desktop.
Pong with EnTT and Magnum

Pong

With Magnum and EnTT. An adaptation of ecs_pong which is example material for Flecs, another ECS framework, developed as a learning exercise of both Magnum, EnTT and ECS in general. Runs at a solid 60 fps, can you believe it?

pong2

There are NOTE markings left in the code to highlight various differences and things I struggled with and would have liked to have done.


Usage

This example depends on Magnum and EnTT.

Build

git clone https://gist.github.com/1ec4fbf3dcc5cc509106a2ae9f8f242b.git pong
cd pong
mkdir externals
wget -O externals/entt.hpp https://raw.githubusercontent.com/skypjack/entt/03c4267b84fafca32be264892c81fb0d17d7c2f7/single_include/entt/entt.hpp 
mkdir build
cd build
cmake ..
msbuild pong.sln

Todo

There are things in here I'm not happy with yet.

  1. Generic Collision Handling Make collision handling more general; currently, it's explicitly evaluating the ball and explicitly comparing its position with the two paddles. I figure, there must be a way to have just three rigid bodies, each with a shape, position and velocity, and compute an appropriate response to collisions from that. Without explicitly mentioning the ball.
  2. Generic AI Like collisions, I don't like how AiThink currently mentioned the three entities explicitly. Surely there must be a more general way of letting entities with a "brain" think for themselves, without explicitly mentioning who's got the brain. Only problem is, that brain currently operates based on the knowledge on where the player is and where the ball is. Not sure what the best approach is here.
  3. Unified Input System Input handling is currently at odds with Magnum; Magnum provides an event handler, that I'm converting into a "state", which is then handled by the input system. I'd imagine it would be better to either call the system from Magnum's keyPressEvent, or just think of that event handler itself as the system.
  4. Say NO to globals Those globals at the top of the file; I'm not a fan. There's an Application class (Pong) which is probably the best place for these, but it would (probably) mean having systems be methods of that class as well, such that they can access them as they are now. And I really like the simplifity of having systems be free functions. Unsure of the best approach here.
  5. Unified Movement System There's a "MovePaddle" and a "MoveBall"; to my mind, there should only need to be one "Move"; if something isn't where it needs to be - either by having a velocity or by having been affected by the Input system - then it should move. Seems like that could be made generic.
  6. Optimal Performance For performance, paddles and shader shouldn't get created and destroyed on each frame; they should be made once and reused.
cmake_minimum_required(VERSION 3.1)
project(pong)
# Add module path in case this is project root
if(PROJECT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR)
set(CMAKE_MODULE_PATH "${PROJECT_SOURCE_DIR}/../../modules/" ${CMAKE_MODULE_PATH})
endif()
find_package(Magnum REQUIRED
GL
MeshTools
Primitives
Shaders
Sdl2Application)
set_directory_properties(PROPERTIES CORRADE_USE_PEDANTIC_FLAGS ON)
add_executable(pong pong.cpp)
target_link_libraries(magnum-primitives PRIVATE
Magnum::Application
Magnum::GL
Magnum::Magnum
Magnum::MeshTools
Magnum::Primitives
Magnum::Shaders)
install(TARGETS pong DESTINATION ${MAGNUM_BINARY_INSTALL_DIR})
/* Pong
--------------------------
______
|______|
o
______
|______|
--------------------------
A re-implementation of https://github.com/SanderMertens/ecs_pong
To some extent. There are pieces missing in that example, coming from the
underlying framework. Such as rigid bodies, collision generation and rendering.
This things were implemented "my way", using Magnum.
# Remainder
- I wasn't able to figure out how to generate collisions
containing a normal and "depth"; the amount of intersection
happening. That is used to push the ball out from the paddle,
which isn't happening here. The result is a ball intersecting
the paddle somewhat.
*/
#include <algorithm>
#include <string>
#include <chrono>
#include <thread>
#include <Magnum/Platform/Sdl2Application.h>
#include <Magnum/GL/DefaultFramebuffer.h>
#include <Magnum/GL/Mesh.h>
#include <Magnum/GL/Renderer.h>
#include <Magnum/MeshTools/Compile.h>
#include <Magnum/MeshTools/Transform.h>
#include <Magnum/Primitives/Circle.h>
#include <Magnum/Primitives/Square.h>
#include <Magnum/Shaders/Flat.h>
#include <Magnum/Trade/MeshData2D.h>
#include <Magnum/Timeline.h>
#include "externals/entt.hpp"
using namespace Magnum;
using namespace Math::Literals; // For _rgbf
static entt::registry World;
#define BALL_RADIUS 10
#define PLAYER_HEIGHT 8
#define PLAYER_WIDTH 60
#define PLAYER_SPEED 400
#define PADDLE_AIM_C 1.5
#define BALL_SPEED 430.0
#define BALL_SERVE_SPEED (BALL_SPEED / 3.0)
#define BALL_BOOST 0.5
#define COURT_WIDTH 200
#define COURT_HEIGHT 300
// TODO: There's already a Key enum in Magnum;
// use that and get rid of this.
struct Key {
enum {
None = 0,
A,
D,
Left,
Right,
Count
};
};
using Entity = entt::registry::entity_type;
// Global references to entities
//
// TODO: This isn't very nice. E.g. introducing a second player would
// mean refactoring most of the code, due to the player itself
// being hardcoded and not generalised.
//
Entity ball;
Entity player;
Entity ai;
/**
* --------------------------------------------------------------
*
* Components
*
* These encapsulates all data in an ECS architecture
*
* --------------------------------------------------------------
*/
typedef std::string Name;
struct Circle {
int radius;
};
struct Rectangle {
int width, height;
};
struct Collision {
double nx, ny;
// struct { double x, y; } normal;
double depth;
Entity entity_1, entity_2;
};
struct Position {
double x, y;
};
struct TargetPosition : public Position {
// Called simply "target" in the original example
// But really, it's the target position, before physics
};
struct Velocity {
double x, y;
};
struct Input {
int32_t x, y, z;
uint8_t keys[Key::Count];
Input() : x(0), y(0), z(0) {
for (uint32_t ii = 0; ii < Key::Count; ++ii) {
keys[ii] = Key::None;
}
}
};
/**
* ---------------------------------------------------------
*
* Systems
*
* These operate on the aforementioned data. They typically
* don't carry state and thus won't need a constructor or class.
* They utilise a "view" which is ECS jargon for a subset of all data.
* The interesting bit being that it doesn't matter what entity is
* associated with the data; the system only knows about the data
*
* ---------------------------------------------------------
*/
static void PlayerInput() {
World.view<Input, TargetPosition>().each([=](auto& input, auto& target) {
// Move paddle using A/D or Left/Right
if (input.keys[Key::A] || input.keys[Key::Left]) {
target.x = -PLAYER_SPEED;
}
else if (input.keys[Key::D] || input.keys[Key::Right]) {
target.x = PLAYER_SPEED;
}
else {
target.x = 0;
}
});
}
static void AiThink() {
auto& ball_pos = World.get<Position>(ball);
auto& player_pos = World.get<Position>(player);
auto& ai_pos = World.get<Position>(ai);
double target_x = ball_pos.x + (player_pos.x > 0
? static_cast<double>(PLAYER_WIDTH) / 2.5 + BALL_RADIUS
: -static_cast<double>(PLAYER_WIDTH) / 2.5 + BALL_RADIUS
);
auto& target = World.get<TargetPosition>(ai);
target.x = target_x - ai_pos.x;
}
static void MovePaddle(double delta_time) {
World.view<Position, TargetPosition>().each([=](auto& pos, const auto& target) {
double abs_target = fabs(target.x);
double dir = abs_target / target.x;
double movement = (abs_target > PLAYER_WIDTH * delta_time)
? PLAYER_SPEED * dir
: target.x;
pos.x += movement * delta_time;
// Keep paddle in the court
auto court = static_cast<double>(COURT_WIDTH);
pos.x = std::clamp(pos.x, -court + PLAYER_WIDTH, court - PLAYER_WIDTH);
});
}
static void MoveBall(double delta_time) {
World.view<Position, Velocity>().each([=](auto& pos, const auto& velocity) {
pos.x += velocity.x * delta_time;
pos.y += velocity.y * delta_time;
});
}
static void ComputeCollisions() {
auto& ball_pos = World.get<Position>(ball);
auto& ball_vel = World.get<Velocity>(ball);
/*
o If the ball is within the surface area
o of the paddle, then we've got a hit.
______o_____
|____________| ___
/ \
______________________/_____\_
| \ ^ / |
| normal / \_|_/ | depth
| + |
| |
|______________________________|
*/
World.view<Position, Rectangle>().each([=](auto entity, auto& pos, auto& rectangle) {
auto horizontal = std::clamp(ball_pos.x, pos.x - rectangle.width, pos.x + rectangle.width) == ball_pos.x;
auto vertical = std::clamp(ball_pos.y, pos.y - rectangle.height, pos.y + rectangle.height) == ball_pos.y;
if (vertical && horizontal) {
// TODO: Compute these
// I struggled here; to figure out the normal and depth of an intersection.
// Looks simple! But I'm at a loss, and moved on. :(
auto nx = 0.0;
auto ny = 0.0;
auto depth = 0.0;
World.assign<Collision>(entity, nx, ny, depth, ball, entity);
}
});
}
static void HandleCollisions() {
auto& ball_pos = World.get<Position>(ball);
auto& ball_vel = World.get<Velocity>(ball);
World.view<Collision>().each([&](auto entity, const auto& col) {
/* Move the ball out of the paddle */
ball_pos.y -= col.ny * col.depth;
/* Use the paddle position to determine where the ball hit */
const auto& paddle_pos = World.get<Position>(col.entity_2);
double angle = PADDLE_AIM_C * (ball_pos.x - paddle_pos.x) / PLAYER_WIDTH;
double abs_angle = fabs(angle);
ball_vel.x = sin(angle) * BALL_SPEED;
ball_vel.y = cos(angle) * BALL_SPEED;
if (abs_angle > 0.6) {
ball_vel.x *= (1 + abs_angle * BALL_BOOST);
ball_vel.y *= (1 + abs_angle * BALL_BOOST);
}
if (ball_pos.y < paddle_pos.y) {
ball_vel.y *= -1;
}
World.remove<Collision>(entity);
});
}
static void BounceWalls() {
World.view<Position, Velocity>().each([](auto& pos, auto& vel) {
// NOTE: The only entity with a velocity is the ball,
// so this loops iterates only once for that one entity.
auto courtWidth = static_cast<double>(COURT_WIDTH);
auto courtHeight = static_cast<double>(COURT_HEIGHT);
if (std::clamp(pos.x, -courtWidth + BALL_RADIUS, courtWidth - BALL_RADIUS) != pos.x) {
vel.x *= -1.0; // Reverse x velocity if ball hits a vertical wall
}
/* If ball hits horizontal wall, reset the game */
int ceiling = std::clamp(pos.y, -courtHeight, courtHeight) != pos.y;
if (ceiling) {
pos = Position{0.0, 0.0};
vel = Velocity{0.0, BALL_SPEED * -ceiling};
}
});
}
static void RendererSystem() {
GL::Renderer::setClearColor(0x333333_rgbf);
GL::defaultFramebuffer.clear(GL::FramebufferClear::Color | GL::FramebufferClear::Depth);
GL::defaultFramebuffer.setViewport({{}, {COURT_WIDTH * 2, COURT_HEIGHT * 2}});
auto shader = Shaders::Flat2D{};
shader.setColor(0xffffff_rgbf);
auto paddle = MeshTools::compile(Primitives::squareSolid());
auto ball = MeshTools::compile(Primitives::circle2DSolid(10));
auto projection = Matrix3::projection({COURT_WIDTH * 2, COURT_HEIGHT * 2});
// Paddles
World.view<Rectangle, Position>().each([&](const auto& rectangle,
const auto& position) {
auto model = Matrix3::translation(Vector2(position.x, position.y))
* Matrix3::scaling(Vector2(rectangle.width, rectangle.height));
shader.setTransformationProjectionMatrix(projection * model);
paddle.draw(shader);
});
// Ball
World.view<Circle, Position>().each([&](const auto& circle,
const auto& position) {
auto model = Matrix3::translation(Vector2(position.x, position.y))
* Matrix3::scaling(Vector2(circle.radius / 2.0));
shader.setTransformationProjectionMatrix(projection * model);
ball.draw(shader);
});
}
static void LogInput() {
World.view<Input, TargetPosition>().each([](const auto& input, const auto& target) {
Debug() << "TargetPosition" << target.x;
});
World.view<Name, Position, TargetPosition>().each([](const auto& name, const auto& position, const auto& target) {
Debug() << name << target.x << position.x;
});
}
/**
* ---------------------------------------------------------
*
* Application
*
* ---------------------------------------------------------
*/
class Pong : public Platform::Application {
public:
explicit Pong(const Arguments& arguments);
private:
void drawEvent() override;
void keyPressEvent(KeyEvent& event) override;
void keyReleaseEvent(KeyEvent& event) override;
// Use this to keep track of time and delta time
Timeline _timeline;
unsigned int _count { 0 };
};
Pong::Pong(const Arguments& arguments) :
Platform::Application{
arguments,
Configuration{}.setTitle("Pong")
.setSize({COURT_WIDTH * 2, COURT_HEIGHT * 2})
}
{
this->setSwapInterval(1);
// Defined globally
ball = World.create();
player = World.create();
ai = World.create();
World.assign<Position>(ball, 0.0, 0.0);
World.assign<Velocity>(ball, 100.0, BALL_SERVE_SPEED * 2);
World.assign<Circle>(ball, BALL_RADIUS);
World.assign<Name>(player, "Player");
World.assign<Position>(player, 0.0, COURT_HEIGHT - PLAYER_HEIGHT - 20.0);
World.assign<Input>(player);
World.assign<TargetPosition>(player, 0.0, 0.0);
World.assign<Rectangle>(player, PLAYER_WIDTH, PLAYER_HEIGHT);
World.assign<Name>(ai, "AI");
World.assign<Position>(ai, 0.0, -COURT_HEIGHT + PLAYER_HEIGHT + 20.0);
World.assign<TargetPosition>(ai, 0.0, 0.0);
World.assign<Rectangle>(ai, PLAYER_WIDTH, PLAYER_HEIGHT);
_timeline.start();
}
void Pong::keyPressEvent(KeyEvent& event) {
World.view<Input>().each([&](auto& input) {
if (event.key() == KeyEvent::Key::D) input.keys[Key::D] = 1;
if (event.key() == KeyEvent::Key::A) input.keys[Key::A] = 1;
if (event.key() == KeyEvent::Key::Left) input.keys[Key::Left] = 1;
if (event.key() == KeyEvent::Key::Right) input.keys[Key::Right] = 1;
});
}
void Pong::keyReleaseEvent(KeyEvent& event) {
World.view<Input>().each([&](auto& input) {
if (event.key() == KeyEvent::Key::D) input.keys[Key::D] = 0;
if (event.key() == KeyEvent::Key::A) input.keys[Key::A] = 0;
if (event.key() == KeyEvent::Key::Left) input.keys[Key::Left] = 0;
if (event.key() == KeyEvent::Key::Right) input.keys[Key::Right] = 0;
});
}
void Pong::drawEvent() {
auto delta_time = _timeline.previousFrameDuration();
auto time = _timeline.previousFrameTime();
PlayerInput();
AiThink();
MovePaddle(delta_time);
MoveBall(delta_time);
ComputeCollisions();
HandleCollisions();
BounceWalls();
RendererSystem();
// LogInput();
swapBuffers();
_timeline.nextFrame();
_count += 1;
// Log current FPS, once every 60th event
if (_count % 60 == 0) Debug() << 1.0f / delta_time << "fps";
redraw();
}
int main(int argc, char** argv) {
Pong app({argc, argv});
return app.exec();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment