-
Notifications
You must be signed in to change notification settings - Fork 912
TUTORIAL Sprites
OK, so time to do a bit of a reset. In the previous tutorials we've looked at using the built in pixel drawing routines used in the olc::PixelGameEngine. These are great for overlays, debugging information and still for drawing stuff, but they do tend to make your game look a bit simple and basic. If we want it to look a little more polished, then it's time to use sprites. A sprite is a 2D array of pixels, that looks like an image. In fact, it is an image! Typically, PNG format image files are used, as these are very clear, and can contain transparency information.
Before we can start using sprites, we're going to rethink how we implement the breakout game. Instead of representing the field as lines, instead we are going to represent the whole play area as a 2D grid of tiles.
private:
float fBatPos = 20.0f;
float fBatWidth = 40.0f;
olc::vf2d vBall = { 200.0f, 200.0f };
olc::vf2d vBallVel = { 200.0f, -100.0f };
float fBatSpeed = 250.0f;
float fBallRadius = 5.0f;
olc::vi2d vBlockSize = { 16,16 };
std::unique_ptr<int[]> blocks;
To the private variables of the class, I've added a 2D vector which represents the size in pixels of one tile - vBlockSize, in this case it will be 16x16 pixels. I've also added a pointer to an array of integers. Each tile in the grid will store an integer value. The different values will inform us of what to draw at that location.
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;
}
}
return true;
}
I've removed all the previous code from OnUserCreate(), and replaced it with code that initialises our grid to contain 24x30 tiles. I then go and set each element in turn in the grid to 0, unless its on the left, top, or right hand boundary of the grid.
Now let's draw this, just to keep up to date.
// Draw Screen
Clear(olc::DARK_BLUE);
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
FillRect(olc::vi2d(x, y) * vBlockSize, vBlockSize, olc::WHITE);
break;
}
}
}
I've gone and removed all of our previous code, and replaced it with these two nested for loops, that draw each tile. Right now all we have are empty tiles, or boundary tiles, which get drawn as filled in white rectangles.
As you can see, we have now constructed the play field out of tiles. The filled in white rectangles are functional but not very pretty. Let's replace them with sprites. You are going to need some sprites, either you draw them yourself with an art tool, or download ready made ones. This is the one I am using:
It's pretty small at 16x16 pixels, but dont forget if you want the pixelated look (and that's optional) you will have specified a larger pixel size when you called the Construct() function. This will have the effect of enlarging the sprite.
Loading a sprite is very simple. Firstly we need to create a pointer to the sprite. In this tutorial, I'm using so-called "smart pointers" (or else people will complain), but regular raw pointers managed with new and delete work just as well.
Added to the private variables:
float fBallRadius = 5.0f;
olc::vi2d vBlockSize = { 16,16 };
std::unique_ptr<int[]> blocks;
std::unique_ptr<olc::Sprite> sprTile;
Note at this point we have not yet loaded the image. This is the file we will use, and its in a folder relative to our working folder called "./gfx". You can use any file path you like to access your resources. I like to load resources in OnUserCreate() because at this point the olc::PixelGameEngine is up and running and ready to load image data.
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;
}
}
// Load the sprite
sprTile = std::make_unique<olc::Sprite>("./gfx/tut_tile.png");
return true;
}
Once the sprite is loaded, drawing them is very simple. I'll replace the FillRect() function from earlier, with the simplest of sprite drawing functions - DrawSprite(). All we need to pass in is the location of where to draw it, and the pointer to the loaded sprite.
// Draw Screen
Clear(olc::DARK_BLUE);
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
DrawSprite(olc::vi2d(x, y) * vBlockSize, sprTile.get());
break;
}
}
}
I think already it's clear that bringing in imagery to the application starts to make a real difference. This is why sprites are important! We could go ahead and create separate sprite files for all the other block types, however, this can be a bit fiddly for the artist. Sometimes you want to have a bunch of sprites in one image file, so you can work on them together. This is known as a "sprite sheet" or sometimes a "texture atlas". Fortunately, the olc::PixelGameEngine can handle these too. Here is my combines sprite sheet with 4 types of tile. Notice the coloured ones have a transparent center? This will make it look nicer later.
Lets load this file instead, and update our drawing code to use the DrawPartialSprite() function. This allows us to specify a source rectangle within a given sprite. the olc::PixelGameEngine will cut out that rectangle, and draw it as if it were a single sprite. This source rectangle is described by the last two arguments shown here, I know my tiles are vBlockSize, so I can specify a top left coordinate of the tile in multiples of vBlockSize. This is just for convenience - you can specify any coordinate you want.
// Draw Screen
Clear(olc::DARK_BLUE);
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;
}
}
}
We'll now need to add the other blocks to the play field. I do this in the nested for loops in OnUserCreate(). If you want different levels, you could store different layouts in a file and read them in, you could store the play fields as strings in the code, whatever suits your needs. For this tutorial - I'll just hardcode in the pattern.
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;
}
}
So that's starting to look almost like a simple game now! But, notice something isn't quite right? Our coloured tiles have black centers. Here we need to think about transparency. By default, the olc::PixelGameEngine does not use transparency, but provides two drawing modes to deal with different types of transparency. If your sprite has varying degrees of translucency, then you will need to enable full alpha blending mode. This is an expensive mode to operate within, as it requires a number of calculations per pixel when drawing, however it will blend the sprites accurately, and can look really cool. Most of the time, your sprites will have binary transparency, which can act like a mask, i.e. either draw the pixel or don't. This is very quick, and doesn't require additional calculations. You can select between these modes using the SetPixelMode() function. NOTE: If you do enable transparency, make sure you disable it when you are done drawing sprites (or any pixels) that don't require transparency. Setting the pixel mode will affect ALL subsequent draw calls.
// 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
Notice that the DrawPartialSprite() functions are sandwiched between SetPixelMode() calls.
And that's it for sprites, pretty simple stuff! As you play about with sprites, you'll notice there are additional arguments you can supply to the DrawSprite() and DrawPartialSprite() functions. These allow you to flip/mirror the sprite horizontally/vertically/both and enlarge the sprite by "integer scaling". This approach means 1 sprite pixel will become N pixels in game. You cannot shrink sprites (though more on that later).
In the next tutorial, we will bring in a bit more game play again. But here is the complete 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;
olc::vf2d vBall = { 200.0f, 200.0f };
olc::vf2d vBallVel = { 200.0f, -100.0f };
float fBatSpeed = 250.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");
return true;
}
bool OnUserUpdate(float fElapsedTime) override
{
// 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
return true;
}
};
int main()
{
BreakOut demo;
if (demo.Construct(512, 480, 2, 2))
demo.Start();
return 0;
}
```cpp