Skip to content

TUTORIAL Better Collisions

Javidx9 edited this page Sep 12, 2022 · 3 revisions

Since we have adjusted the game to use tiles, our game space (or "world space") no longer operates in "screen space". This is an important concept, and is used frequently during game development.

World space is where all of our collisions, game objects and simulations live and exist. Screen space is how world space is visualised. Keeping them distinct offers several advantages. For our simple break out clone, having the tiles occupy a 1.0 x 1.0 region of world space, makes it very easy to determine which array location they exist in (we simply cast the position from floating point, to an integer). Also, we can in principle change the size of the screen, we don't need to adjust anything in our world space to compensate.

To implement collision detection now, I'm going to assume, our ball is actually a rectangle. For our purposes, this is a sufficient approximation, as all of the collision boundaries are going to be axis-aligned, i.e. we have no collisions at angles. Therefore, we can check the four sides of the ball rectangle, to see if they are in collision with the tiles in our array.

olc::vf2d vBallPos = { 0.0f, 0.0f };
olc::vf2d vBallDir = { 0.0f, 0.0f };
float fBallSpeed = 20.0f;
float fBallRadius = 5.0f;

Here, I've brought back in the vectors that represent our ball's properties. and in OnUserUpdate(), before drawing the world, I'll update the ball's location, and test for collisions. We follow a simple ruleset:

  • For each midpoint of the edge of the ball rectangle:
    • Determine location in tile array
    • If location == 0, empty space, no collision
    • If location == 10, collision, but with wall tile
    • Anything else, decrease array value by 1

That last test is important, and adds challenge to the game. It makes some tiles require more hits than others, and as they are hit the tile ID decreases, thus changing its colour when rendered. After sufficient hits, the tile ID becomes 0 - it disappears.

I want to test for collisions before I move the ball. This ensures that the ball has not entered an occupied tile, this could end with multiple collisions per tile. So I update the balls position along its direction vector, and generate the "potential position".

// Calculate where ball should be, if no collision
olc::vf2d vPotentialBallPos = vBallPos + vBallDir * fBallSpeed * fElapsedTime;

Next, I want to generate 4 offset positions that represent the midpoints of each of the ball's rectangular edges. This location will actually lie on the edge of the ball at the north, east, south and west positions. Curiously, Im going to create a single vector to represent this - all will become clear! (also dont forget, the radius is half the ball's width). This radius value is scaled from screen space into world space, by dividing by the vBlockSize vector, since radius is drawn, it exists in screen space, but our collisions all occur in world space.

// Test for hits 4 points around ball
olc::vf2d vTileBallRadialDims = { fBallRadius / vBlockSize.x, fBallRadius / vBlockSize.y };

Now, we have four collision points to test. Since the check is the same for each one, I'll create a convenient little lambda function. This implements the rules I described earlier, it takes in a 2D vector called "point". This vector is used to select which edge of the ball we are testing, as it transforms vTileBallRadialDims appropriately, effectively zeroing out the axes we are not interested in, and mirroring the ones we are. The function returns true if the ball hit a tile only - this may come in handy later.

auto TestResolveCollisionPoint = [&](const olc::vf2d& point)
{
        olc::vi2d vTestPoint = vPotentialBallPos + vTileBallRadialDims * point;
        auto& tile = blocks[vTestPoint.y * 24 + vTestPoint.x];
	if (tile == 0)
	{
		// Do Nothing, no collision
		return false;
	}
	else
	{
		// Ball has collided with a tile
		bool bTileHit = tile < 10;
		if (bTileHit) tile--;
				
		// Collision response
		if (point.x == 0.0f)	
			vBallDir.y *= -1.0f;				
		if (point.y == 0.0f)	
			vBallDir.x *= -1.0f;
		return bTileHit;
	}
};

With the lambda in place, we now test the four sides of the ball:

bool bHasHitTile = false;
bHasHitTile |= TestResolveCollisionPoint(olc::vf2d(0, -1));
bHasHitTile |= TestResolveCollisionPoint(olc::vf2d(0, +1));
bHasHitTile |= TestResolveCollisionPoint(olc::vf2d(-1, 0));
bHasHitTile |= TestResolveCollisionPoint(olc::vf2d(+1, 0));		

Notice how I specify the northern edge, then southern, then western, then eastern? This approach has allowed me to reuse code, and keeps the code to a minimum. It's important to try and do things like this when developing games, and move away from developing everything uniquely. If I tested the four edges differently, I would need four times as much code - and later if I choose to change the collision criteria, I would have to rewrite them all.

The next thing to add is a temporary hack. Whilst I'm constructing the game and debugging it, I don't always want to play it, so I'm going to put in an artificial "floor" for the ball to bounce off.

if (vBallPos.y > 20.0f) vBallDir.y *= -1.0f;

Finally, we worked out if the ball was going to hit something. If it did, its direction vector would have been changed. Now we have sufficient information to actually update the ball's position.

// Actually update ball position with modified direction
vBallPos += vBallDir * fBallSpeed * fElapsedTime;

The last thing to add here, are the starting conditions for the ball. In OnUserCreate():

// Start Ball
float fAngle = float(rand()) / float(RAND_MAX) * 2.0f * 3.14159f;
fAngle = -0.4f;
vBallDir = { cos(fAngle), sin(fAngle) };
vBallPos = { 12.5f, 15.5f };

By choosing a random angle, I can create a unit vector for the ball's direction using cosine and sine. The ball is finally positioned in world space. The 0.5f part of the position, will place it in the middle of a tile.

At last we can draw the ball, after we have drawn the tiles. Note it is converted from world space to screen space.

// Draw Ball
FillCircle(vBallPos * vBlockSize, fBallRadius, olc::CYAN);

Since we added an artificial floor, the game can effectively play itself and we can evaluate the performance of the collision detection.

These collisions are by no means perfect, but they are sufficient for this game. Game design is often about compromising. The take home message from this tutorial is that we update the state of the game in world space, and convert a part of that world into screen space for viewing. In the next tutorial we will introduce olc::Decal which provides a great deal of graphics horsepower to add flare and special FX to the game. Here is the full code so far:

#define OLC_PGE_APPLICATION
#include "olcPixelGameEngine.h"

class BreakOut : public olc::PixelGameEngine
{
public:
	BreakOut()
	{
		sAppName = "TUTORIAL - BreakOut Clone";
	}

private:
	float fBatPos = 20.0f;
	float fBatWidth = 40.0f;
	float fBatSpeed = 250.0f;

	olc::vf2d vBallPos = { 0.0f, 0.0f };
	olc::vf2d vBallDir = { 0.0f, 0.0f };
	float fBallSpeed = 0.0f;	
	float fBallRadius = 5.0f;

	olc::vi2d vBlockSize = { 16,16 };
	std::unique_ptr<int[]> blocks;

	std::unique_ptr<olc::Sprite> sprTile;

public:
	bool OnUserCreate() override
	{
		blocks = std::make_unique<int[]>(24 * 30);
		for (int y = 0; y < 30; y++)
		{
			for (int x = 0; x < 24; x++)
			{
				if (x == 0 || y == 0 || x == 23)
					blocks[y * 24 + x] = 10;
				else
					blocks[y * 24 + x] = 0;

				if (x > 2 && x <= 20 && y > 3 && y <= 5)
					blocks[y * 24 + x] = 1;
				if (x > 2 && x <= 20 && y > 5 && y <= 7)
					blocks[y * 24 + x] = 2;
				if (x > 2 && x <= 20 && y > 7 && y <= 9)
					blocks[y * 24 + x] = 3;
			}
		}

		// Load the sprite
		sprTile = std::make_unique<olc::Sprite>("./gfx/tut_tiles.png");

		// Start Ball
		float fAngle = float(rand()) / float(RAND_MAX) * 2.0f * 3.14159f;
		fAngle = -0.4f;
		vBallDir = { cos(fAngle), sin(fAngle) };
		vBallPos = { 12.5f, 15.5f };
		return true;
	}

	bool OnUserUpdate(float fElapsedTime) override
	{
		// A better collision detection
		// Calculate where ball should be, if no collision
		olc::vf2d vPotentialBallPos = vBallPos + vBallDir * fBallSpeed * fElapsedTime;

		// Test for hits 4 points around ball
		olc::vf2d vTileBallRadialDims = { fBallRadius / vBlockSize.x, fBallRadius / vBlockSize.y };

		auto TestResolveCollisionPoint = [&](const olc::vf2d& point)
		{
			olc::vi2d vTestPoint = vPotentialBallPos + vTileBallRadialDims * point;

			auto& tile = blocks[vTestPoint.y * 24 + vTestPoint.x];
			if (tile == 0)
			{
				// Do Nothing, no collision
				return false;
			}
			else
			{
				// Ball has collided with a tile
				bool bTileHit = tile < 10;
				if (bTileHit) tile--;
				
				// Collision response
				if (point.x == 0.0f) vBallDir.y *= -1.0f;				
				if (point.y == 0.0f) vBallDir.x *= -1.0f;
				return bTileHit;
			}
		};

		bool bHasHitTile = false;
		bHasHitTile |= TestResolveCollisionPoint(olc::vf2d(0, -1));
		bHasHitTile |= TestResolveCollisionPoint(olc::vf2d(0, +1));
		bHasHitTile |= TestResolveCollisionPoint(olc::vf2d(-1, 0));
		bHasHitTile |= TestResolveCollisionPoint(olc::vf2d(+1, 0));		

		// Fake Floor
		if (vBallPos.y > 20.0f) vBallDir.y *= -1.0f;

		// Actually update ball position with modified direction
		vBallPos += vBallDir * fBallSpeed * fElapsedTime;

		// Draw Screen
		Clear(olc::DARK_BLUE);
		SetPixelMode(olc::Pixel::MASK); // Dont draw pixels which have any transparency
		for (int y = 0; y < 30; y++)
		{
			for (int x = 0; x < 24; x++)
			{
				switch (blocks[y * 24 + x])
				{
				case 0: // Do nothing
					break;
				case 10: // Draw Boundary
					DrawPartialSprite(olc::vi2d(x, y) * vBlockSize, sprTile.get(), olc::vi2d(0, 0) * vBlockSize, vBlockSize);
					break;
				case 1: // Draw Red Block
					DrawPartialSprite(olc::vi2d(x, y) * vBlockSize, sprTile.get(), olc::vi2d(1, 0) * vBlockSize, vBlockSize);
					break;
				case 2: // Draw Green Block
					DrawPartialSprite(olc::vi2d(x, y) * vBlockSize, sprTile.get(), olc::vi2d(2, 0) * vBlockSize, vBlockSize);
					break;
				case 3: // Draw Yellow Block
					DrawPartialSprite(olc::vi2d(x, y) * vBlockSize, sprTile.get(), olc::vi2d(3, 0) * vBlockSize, vBlockSize);
					break;
				}
			}
		}
		SetPixelMode(olc::Pixel::NORMAL); // Draw all pixels

		// Draw Ball
		FillCircle(vBallPos * vBlockSize, fBallRadius, olc::CYAN);
		return true;
	}
};

int main()
{
	BreakOut demo;
	if (demo.Construct(512, 480, 1, 1))
		demo.Start();
	return 0;
}