Foreword
This series of articles focuses on a 2D game development with C++ and OpenGL for Windows platform. We will not only focus on OpenGL but also talk about the designs that are commonly used in game programming with a full object oriented approach. You should already be familiar with the C++ language in order to get the maximum out of this series. There is a message board at the bottom of the article that you can use if you have questions, remarks or suggestions.
The series is divided into three articles:
- Part 1 : covers the window creation and the setting-up of OpenGL.
- Part 2: covers resources handling and displaying simple animations.
- Part 3: groups everything together and talk about the game logic.
Contents
The first article in the series focused on the main window creation and the set-up of OpenGL. This article will be a bit more fun because we will be able to load and display graphic files and display some animations. We will also see how we can efficiently manage those resources. The picture you see at the top of the article is what we will reach at the end of the article. This is not yet a game because it doesn't have any game logic: the only thing it does is the ability to move the character on the screen and animating it correctly (collision detection is not implemented).
We first start by organizing our files in a better way. I usually create a src folder which contains all my source files (*.cpp and *.h), a bin folder which contains the final executable and all the required resources, an obj folder which is used for the intermediate files resulting from the compilation and a dependencies folder which contains all external dependencies that are required for the compilation of my project (we will see later that we use an external dependency). The main advantage is that we now have a bin folder which contains what will be distributed. If you have many resources (images, music, configuration files, ...), you can even divide this bin folder into specific sub-folders. Take a look at the attached zip file to see the folder organization.
Let's now change the project settings in order to use this folder configuration. For the source files, just copy them into the src folder and add them to your project. To configure the output folder and the intermediate folder, change the Output Directory and the Intermediate Directory in the General section as shown in the following picture.
$(SolutionDir) and $(ConfigurationName) are predefined macros. The first one translates to the folder of the solution and the second one translates to the current active configuration (debug or release): in the obj folder, two sub-folders will be created, one for each configuration. Don't forget to apply those changes to both configurations (debug and release).
Unfortunately, OpenGL doesn't provide any support for loading graphic files. So, we have the choice to either write the code to load the images ourselves (and do that for each of the formats we are interested in), or to use an existing library that does the job for us. As you probably already guessed, the second choice is probably the best: we will gain a considerable amount of time and we will use a library that has already been tested and debugged and which is probably compatible with much more file formats than we will be able to write.
There are several options for which library to use. Two that I am aware of are: DevIL and FreeImage. DevIL is a bit more adapted to OpenGL so that is the reason why I've chosen this one, but FreeImage is a perfectly valid choice as well.
The first thing we do is to copy the required DevIL files in the dependencies folder: we first create a sub-folder called DevIL and we copy there the content of the archive that can be found on the DevIL website. We have to modify the name of a file in order to use it correctly: in the "include\IL" folder, you will find a file named config.h.win, rename it to config.h. Then copy the DevIL.dll file into your bin folder because it is used by your executable.
We then have to configure the project settings in order to use DevIL. In C/C++ category -> General -> Additional Include Directories, specify dependencies\DevIL\include\. This tells the compiler where to find the header files required for DevIL
. This way, we won't need to supply the full path to the DevIL header file.
In Linker category-> General -> Additional Library Directories, specify dependencies\DevIL\lib. This tells the linker where to find additional folders which may contain library to link with.
And in Linker category -> Input -> Additional Dependencies, specify DevIL.lib. This tells the linker that the project must be linked with the DevIL
library. Keep in mind that we were already linking to OpenGL32.lib.
Now that everything is set-up correctly to use DevIL
, we are ready to load some images and display them. But first, let's think a bit of how we will manage those files a bit more efficiently. Suppose that we need to display a tree that is contained in a file called tree.png, the brute force approach is to simply load the file and store it in memory so that we can reuse it for each frame that needs to be drawn. This seems nice as a first approach but there is a small problem: Suppose that we now need to display this tree more than once, then we will load the texture several times in memory which is clearly inefficient. We need a way to be able to reuse the same texture if it is needed at different locations in our code. This is easily solved by delegating the loading to a specific class: the texture manager. Let's first take a look at this class before going into the details of the file loading itself:
class CTextureManager
{
public:
CTexture* GetTexture(const std::string& strTextName);
bool ReleaseTexture(const std::string& strTextName);
static CTextureManager* GetInstance();
protected:
CTextureManager();
~CTextureManager();
private:
typedef std::map<std::string,CTexture*> TTextureMap;
TTextureMap m_Textures;
};
The first thing to notice about this class is that it is implemented as a singleton pattern. If you never heard about the singleton pattern before, take a look at the references, there's a link to an article discussing it. Basically, it ensures that the class has only one instance and provides a way to access it. In our case, the constructor is protected which forbids anybody to create an instance directly. Instead, a static
method (GetInstance
) allows you to retrieve the unique instance of the class:
CTextureManager* CTextureManager::GetInstance()
{
static CTextureManager Instance;
return &Instance;
}
I won't discuss this pattern in detail here but don't hesitate to take a look at the article or Google for it (there are plenty of articles discussing it). In our case, we only want a single instance of this class and having a global point to access it makes it easy to use:
CTexture* pTexture = CTextureManager::GetInstance()->GetTexture("MyTexture.bmp");
The constructor of the class takes care of initializing the DevIL
library properly:
CTextureManager::CTextureManager() : m_Textures()
{
ilInit();
ilOriginFunc(IL_ORIGIN_UPPER_LEFT);
ilEnable(IL_ORIGIN_SET);
}
Before calling any DevIL
function, you first have to call ilInit
in order to initialize the library. We will also specify how the images will be loaded: the upper-left corner first. This is done so that we won't have inverted textures. By default this option is disabled so we enable it by calling ilEnable(IL_ORIGIN_SET)
.
Let's now look at the GetTexture
method:
CTexture* CTextureManager::GetTexture(const string& strTextName)
{
TTextureMap::const_iterator iter = m_Textures.find(strTextName);
if (iter != m_Textures.end())
return iter->second;
CTexture* pNewText = NULL;
try
{
pNewText = new CTexture(strTextName);
}
catch (CException& e)
{
delete pNewText;
throw e;
}
m_Textures[strTextName] = pNewText;
return pNewText;
}
The code is not too difficult to understand: We first try to retrieve the texture specified by strTextName
in the map of already loaded texture. If it was found, it is returned, otherwise we try to load it from the file. As we will see later, the constructor of CTexture
attempts to load the file and throw an exception if it fails to do so. Then, in the texture manager, if an exception was caught, we delete the texture (to avoid a memory leak) and we re-throw the exception. If the texture was loaded successfully, it is stored in the map (using its name as a key) and it is returned.
A method to release existing texture is also provided:
bool CTextureManager::ReleaseTexture(const std::string& strTextName)
{
bool bFound = false;
TTextureMap::iterator iter = m_Textures.find(strTextName);
if (iter != m_Textures.end())
{
bFound = true;
if (iter->second)
delete iter->second;
m_Textures.erase(iter);
}
return bFound;
}
Here also, the code is rather self-explanatory: we simply try to retrieve the texture from the map and on success, we delete it and remove the pointer from the map. If the texture was successfully removed, the function returns true
.
Let's now look at the CTexture
class in more detail:
class CTexture
{
friend class CTextureManager;
public:
void SetColorKey(unsigned char Red, unsigned char Green, unsigned char Blue);
unsigned int GetWidth() const { return m_TextData.nWidth; }
unsigned int GetHeight() const { return m_TextData.nHeight; }
void AddReference();
void ReleaseReference();
void Bind() const;
protected:
CTexture(const std::string& strFileName);
~CTexture();
private:
void LoadFile(const std::string& strFileName);
struct STextureData
{
unsigned int nWidth;
unsigned int nHeight;
unsigned char* pData;
};
STextureData m_TextData;
mutable GLuint m_glId;
int m_iRefCount;
std::string m_strTextName;
};
For this class, we can also see that the constructor has been made protected. The reason is that only the CTextureManager
class should be able to create textures, that's the reason it has been made a friend of this class. The core of the CTexture
class is the STextureData
structure, which contains all the data loaded from the file: an array of bytes containing the file data and the width and height of the texture. Let's see how the file is loaded, which is done in the LoadFile(const std::string& strFileName)
function:
void CTexture::LoadFile(const std::string& strFileName)
{
ILuint imgId;
ilGenImages(1,&imgId);
ilBindImage(imgId);
if (!ilLoadImage(strFileName.c_str()))
{
string strError = "Failed to load file: " + strFileName;
throw CException(strError);
}
m_TextData.nWidth = ilGetInteger(IL_IMAGE_WIDTH);
m_TextData.nHeight = ilGetInteger(IL_IMAGE_HEIGHT);
unsigned int size = m_TextData.nWidth * m_TextData.nHeight * 4;
m_TextData.pData = new unsigned char[size];
ilCopyPixels(0, 0, 0, m_TextData.nWidth, m_TextData.nHeight,
1, IL_RGBA, IL_UNSIGNED_BYTE, m_TextData.pData);
ilDeleteImage(imgId);
}
As you can see, we are using DevIL
to load the file. The first thing we do is create a new image id in DevIL
and bind it with the current image. This is needed if you want to do some manipulation on a certain image using its id. In fact, we will only use it to delete the image later when we have finished using it. Next, we try to load the file using ilLoadImage
: The function takes care of the different file formats and will return false
if the load failed (you can also retrieve an error code by calling ilGetError
). If that's the case, we simply throw an exception. If you remember the first article, those exceptions will be caught in the main function and display an error message before exiting the program. We then retrieve the width and height of the image (the ilGetInteger
and ilCopyPixels
functions always work on the current active image). We then allocate room for the data in the m_TextData.pData
field: each pixel is coded on 4 bytes (we will see this later). We then call the ilCopyPixels
function to copy the image data in our buffer. The three first parameters are the X, Y and Z offset of where to start copying (the Z offset is used for volumetric images), and the three next parameters are the number of pixels to copy in those directions (here also, we don't use volumetric images so the Depth is 1). Then we specify the format of the image: a RGBA format which means 1 byte for each color channel (Red, Green and Blue, or RGB) and one byte for the alpha channel (A).The alpha channel is used to specify the transparency of the pixel. A value of 0 means fully transparent and a value of 255 means fully opaque. We then specify the type of each component: they should be coded as unsigned bytes (unsigned chars). The last argument of the function is the pointer to the buffer where to copy the pixels. At the end, we delete the DevIL
image data because we won't need it anymore.
Remark: There's an easier way to load textures with DevIL
if you want to use them in OpenGL. The ILUT
library allows you to load an image and associate it directly with an OpenGL texture by calling ilutGLLoadImage
which returns the OpenGL id of the texture. This is the easiest way to go but you won't be able to manipulate the raw data directly as we will do to set the color key.
Once the data has been loaded from the file, we need to generate a new OpenGL texture and supply the data. This is done the first time the texture is requested to be used, in the CTexture::Bind()
function:
void CTexture::Bind() const
{
if(!m_glId)
{
glGenTextures(1,&m_glId);
glBindTexture(GL_TEXTURE_2D,m_glId);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);
glTexImage2D(GL_TEXTURE_2D, 0, 4, m_TextData.nWidth, m_TextData.nHeight,
0,GL_RGBA,GL_UNSIGNED_BYTE,m_TextData.pData);
}
glBindTexture(GL_TEXTURE_2D,m_glId);
}
The important point to understand when working with textures is that OpenGL only works with one texture at a time. So, in order to texture a polygon, you need to select the active texture (also called 'binding'). This is done by calling glBindTexture
. Each OpenGL texture has its own Id, which is in our case stored in the m_glId
member of the CTexture
class. An Id of 0 is reserved and will never be generated by OpenGL, so we can use it to specify that our texture has not been generated in OpenGL yet. So, the first time this function is called, m_glId
will be 0
. If we look inside the if
condition (so, if the texture is not generated), the first thing we do is ask OpenGL to generate a free Id for us by calling glGenTextures
.
The m_glId
is mutable because we still want the bind function to be const
and this member will be modified only once, when the texture is generated. The glGenTextures
function lets you generate multiple Id (the first argument is the number of Id to be generated), but we only want a single Id, which will be stored in m_glId
. We then call glBindTexture
: this binds the texture specified by its Id to the active 2 dimensional active texture (you can also work with 1 dimensional textures). This is needed so that each subsequent calls to texture manipulation routines will affect this specific texture (this in fact makes our texture the active one in OpenGL).
We then specify the filtering for minification and magnification: in general, one point of the texture, also called a texel, does not map directly to one pixel on the screen. Sometimes a texel covers less than a pixel (so that you have more than one texel in one pixel) or sometimes it is the opposite (a texel covers multiple pixels). When a pixel contains only a portion of a texel, it is called magnification and when a pixel contains several texels, it is called minification. Those two functions tells how should OpenGL interpret those situations: if you specify GL_LINEAR
, OpenGL uses a linear average of the 2x2 array of texels that lies nearest to the center of the pixel. If you specify GL_NEAREST
, OpenGL will use the texel with coordinates nearest the center of the pixel. There are other options for the minification filter which consists of having multiple copies of the texture for different sizes (those copies are called mip-maps) but we won't enter into too much details here.
Next, glTexEnvf
sets the drawing mode to GL_MODULATE
so that the color of the textured polygons will be a modulation of the texture color and the color on which the texture is pasted. This is needed in order to make some parts of the image transparent using the alpha channel. Finally, we generate the OpenGL texture by calling glTexImage2D
: the first argument to the function is the type of texture (1 or 2 dimensions), the second argument is the level of the texture in case we are using multiple resolutions of the texture (mip-maps).
In our case, we don't use multiple resolution, so we specify 0. The third argument is the number of components (R, G, B and A) that are used for modulating and blending. The two following arguments are the width and the height of the texture. The 6th argument specifies the width of the texture border, which is 0 in our case. The 7th and 8th arguments describe the format and data type of the texture data: the texture format is RGBA and each component of a texel is an unsigned byte. The last parameter is the pointer to the data.
Warning: OpenGL works with textures that have a size (width and height) which is a power of two (so, a 128x128 texture is valid but a 128x120 is not). On some graphic cards, displaying textures that do not follow this rule might fail and you will only see a white rectangle. A solution to the problem is to have all your textures follow this rule (even if you have to leave some non-used space in the image file). Another solution is to manage that when loading the image: you could always create a buffer which has the correct dimensions and load the image in it (but you have to take care how you do this, because the unused pixels should be left on each line).
If CTexture::Bind()
is called when the texture is already available, the function only calls glBindTexture
, which makes this texture the active one. We will see later how this texture will be used to be drawn on the screen.
A feature that is often used in games is what we call color keying. Some file formats do not support a transparent channel (like a BMP file), so if you want to make some parts of the texture transparent, the only option is to use a specific color that will be made transparent. OpenGL does not support color keying but it can easily be added by using the alpha channel of the texture. That is what the CTexture::SetColorKey
function is doing:
void CTexture::SetColorKey(unsigned char Red,
unsigned char Green,
unsigned char Blue)
{
if (m_glId)
{
glDeleteTextures(1,&m_glId);
m_glId = 0;
}
unsigned long Count = m_TextData.nWidth * m_TextData.nHeight * 4;
for (unsigned long i = 0; i<Count; i+=4)
{
if ( (m_TextData.pData[i]==Red) && (m_TextData.pData[i+1]==Green)
&& (m_TextData.pData[i+2]==Blue) )
m_TextData.pData[i+3] = 0;
else
m_TextData.pData[i+3] = 255;
}
}
The function is quite basic: we walk over our texture data and if we find a pixel of the specified color, we set its alpha channel to 0, which means fully transparent. For all the other pixels, we reset the channel to 255 (suppress a previous color key). But we first need to check if the texture was already specified to OpenGL. If that is the case, we need to reload the texture in OpenGL. This is done by simply setting m_glId
to 0
(if you remember, the Bind
function first checks if this variable is 0
). By calling glDeleteTextures
, we delete the texture in OpenGL (the first argument is the number of textures we want to delete and the second is their Id).
Finally, the texture is reference counted and its constructor is protected, so that you can't create a CTexture
object directly. The reference counted is done through the AddReference
and ReleaseReference
functions:
void CTexture::AddReference()
{
m_iRefCount++;
}
void CTexture::ReleaseReference()
{
m_iRefCount--;
if (m_iRefCount == 0)
CTextureManager::GetInstance()->ReleaseTexture(m_strTextName);
}
As you can see, nothing really fancy here: whenever a CTexture
object is referenced, AddReference
is called which increases the reference count. Once the texture is not needed anymore, ReleaseReference
is called which decrements the reference count. Once it reaches 0
, the texture will be released from the texture manager (which will delete it). Reference counting is used because several CImage
objects can reference the same texture. We need to know how many of them are still using the texture instead of releasing it whenever one of the image objects is destroyed.
Let's now look at how this texture is used by the CImage
class. As we saw earlier, the CTexture
is not manipulated directly by the user. The reason is that it is mainly a wrapper around a resource file and such file can be made of several images: suppose that you want to display several kind of trees in your game, it could be convenient to have them all stored in the same file. So, the texture class in itself doesn't have any functionality to draw the image on the screen, but only to load a file. The image class is the one responsible to draw the texture (or only a part of it) on the screen. Several images can then reference the same texture but use a different portion of it.
typedef CSmartPtr<CImage> TImagePtr;
class CImage
{
public:
void BlitImage(int iXOffset=0, int iYOffset=0) const;
CTexture* GetTexture() const { return m_pTexture; }
static TImagePtr CreateImage(const std::string& strFileName);
static TImagePtr CreateImage(const std::string& strFileName,
const TRectanglei& textCoord);
~CImage();
protected:
CImage(const std::string& strFileName);
CImage(const std::string& strFileName, const TRectanglei& textCoord);
private:
CTexture* m_pTexture;
TRectanglei m_rectTextCoord;
};
Before going into the details about how to instantiate this class, we will look at how it works. It has two members: the texture from which the image comes from and a rectangle specifying the portion of the texture which contains the image. I won't put the code of the CRectangle
class because it is very trivial: It contains four members which are the top, bottom, left and right coordinates of the rectangle plus some support functions (like checking if it intersects with another rectangle, retrieve the width and height of the rectangle, ...). It is a template class, so you can choose the type of the rectangle coordinates (integer
, float
, double
, ...). TRectanglei
is a typedef for a rectangle with integer coordinates. Let's see how the BlitImage
function works, by drawing the texture at the location specified by the arguments:
void CImage::BlitImage(int iXOffset, int iYOffset) const
{
if (m_pTexture)
{
m_pTexture->Bind();
float Top = ((float)m_rectTextCoord.m_Top)/m_pTexture->GetHeight();
float Bottom = ((float)m_rectTextCoord.m_Bottom)/m_pTexture->GetHeight();
float Left = ((float)m_rectTextCoord.m_Left)/m_pTexture->GetWidth();
float Right = ((float)m_rectTextCoord.m_Right)/m_pTexture->GetWidth();
glBegin(GL_QUADS);
glTexCoord2f(Left,Top); glVertex3i(iXOffset,iYOffset,0);
glTexCoord2f(Left,Bottom); glVertex3i(iXOffset,iYOffset+
m_rectTextCoord.GetHeight(),0);
glTexCoord2f(Right,Bottom); glVertex3i(iXOffset+m_rectTextCoord.GetWidth(),
iYOffset+m_rectTextCoord.GetHeight(),0);
glTexCoord2f(Right,Top); glVertex3i(iXOffset+m_rectTextCoord.GetWidth(),
iYOffset,0);
glEnd();
}
}
We first bind the texture (make it the active one in OpenGL), then we calculate the coordinates of the image within the texture. Those values are expressed between 0 and 1, with 0 being the top/left side of the texture and 1 being the bottom/right side of the texture. We then draw a rectangle as seen in the first tutorial, except that before specifying each point, we call glTexCoord2f
which specifies a texel (point in a texture) in the current binded OpenGL texture. By doing this, OpenGL will be able to associate texels from the texture to pixels on the screen, and display our textured rectangle using the active texture.
Let's now look at the constructors and destructor. There are two constructors (which are protected): one which accepts only a texture name and one which accepts both a texture name and a rectangle. The one with only the texture name will use the full texture as the image, and the other one will use the image contained at the specified rectangle in the file.
CImage::CImage(const string& strFileName)
: m_pTexture(NULL), m_rectTextCoord()
{
m_pTexture = CTextureManager::GetInstance()->GetTexture(strFileName);
m_pTexture->AddReference();
m_rectTextCoord.m_Top = m_rectTextCoord.m_Left = 0;
m_rectTextCoord.m_Bottom = m_pTexture->GetHeight();
m_rectTextCoord.m_Right = m_pTexture->GetWidth();
}
CImage::CImage(const string& strFileName, const TRectanglei& textCoord)
: m_pTexture(NULL), m_rectTextCoord(textCoord)
{
m_pTexture = CTextureManager::GetInstance()->GetTexture(strFileName);
m_pTexture->AddReference();
}
CImage::~CImage()
{
if (m_pTexture)
m_pTexture->ReleaseReference();
}
The constructors retrieve the texture through the texture manager. Remember that this call can throw an exception if the texture doesn't exist. Then the reference count of the texture is increased. In case no rectangle was specified, the full texture is used as an image. The destructor simply releases the texture which decrements the reference count as seen earlier in the texture class.
As I already said, the constructors of the class are protected. The reason for that is to force the user to use a smart pointer that wraps the CImage
class. Ok, before panicking because of this strange thing, let me first say that wrapping the CImage
class into a smart pointer is not a necessity but it is very useful to make sure that all of the resources are released when not used anymore. If you don't allocate dynamically CImage
objects (using new), this is already done for you (through the destructor). But as soon as you are creating dynamic objects, you can always forget to delete them, which lead to unreleased resources. Furthermore, if you start exchanging those objects between different parts of your code, which part should be responsible to delete the object? All those problems are solved by wrapping the object into a smart pointer class. I won't fully discuss how it is implemented because there are already a lot of articles covering this subject (you can have a look at the references, there is a link to a good article). In brief, a smart pointer takes care of the lifetime of the object which it is maintaining: when the object is not needed anymore, it is destroyed. You can 'share' this pointer and once it is not needed anymore, the pointed object will be deleted. You can also easily access the wrapped object as if you were manipulating it directly: the smart pointer overloads the ->
and .
operators to redirect them to the owned object. All of that sounds a bit complicated, but the usage is really easy: Instead of using the pointer to the object directly, you give it to a smart pointer which will take care of its lifetime for you (you don't have to delete the pointer anymore). The access to the object is almost transparent because you can still access the members as if you were using the pointer directly.
For this tutorial, I provided my own smart pointer class but it is preferable in general to use the boost::shared_ptr
class (see references). The reason why I provided mine is simply to avoid having yet another dependency so that it is easier for you to compile the project (you don't have to download the package from boost). You can have a look at how it is implemented but I won't give a full explanation here.
Finally, the CImage
class provides two static
helper functions to be able to create instances of the class. They simply create a new instance, pass it to a smart pointer and return the smart pointer:
TImagePtr CImage::CreateImage(const string& strFileName)
{
TImagePtr imgPtr(new CImage(strFileName));
return imgPtr;
}
TImagePtr CImage::CreateImage(const string& strFileName, const TRectanglei& textCoord)
{
TImagePtr imgPtr(new CImage(strFileName,textCoord));
return imgPtr;
}
What would be a game without animations? Probably quite boring to play, so let's look at how we can add some dynamism here by playing animations. The basic idea behind animations in 2D games is rather simple: It is the same as a cartoon, which consists of breaking up the movement into distinct images. The brute force approach would be to have a loop in which you sleep for a while before displaying the next image. As you might already have guessed, this doesn't work at all. You have several issues if you try to do that: first, nothing will be displayed at all because you never swap the buffers (which was done in the CMainWindow::Draw()
function). Second, if you do that, the rest of your program is not processed at all, which also means that you would only be able to display one animation at a time. Not very convenient... The correct approach consists of letting each 'animation' remember its state (e.g. which image it is currently displaying) and asking all of them to draw their current image. When a new frame should be drawn, each animation is 'asked' to go to the next image in the animation. This way, you keep a continuous flow in your program.
Let's now take a look at the CImageList
class. This class is basically a wrapper class around a std::list
which contains images and provides some helper functions to play the images:
class CImageList
{
public:
CImageList();
CImageList(const CImageList& other);
~CImageList();
CImageList& operator= (const CImageList& other);
void Clear();
void AppendImage(TImagePtr pImage);
unsigned GetImagesCount() const;
void GoToFirstImage();
bool GoToNextImage();
TImagePtr GetCurrentImage() const;
private:
typedef std::list<TImagePtr> TImageList;
TImageList m_lstImages;
TImageList::iterator m_iterCurrentImg;
};
The implementation is pretty straightforward: it basically adds images to a std::list<TImagePtr>
on demand and keeps an iterator which points to the currently active image. Let's for example take a look at the GoToNextImage()
function:
bool CImageList::GoToNextImage()
{
if (m_iterCurrentImg != m_lstImages.end() )
m_iterCurrentImg++;
else
return false;
if (m_iterCurrentImg != m_lstImages.end() )
{
m_iterCurrentImg = m_lstImages.begin();
return true;
}
return false;
}
We first check if the iterator is valid (doesn't point at the end of the list). The iterator is invalid when the list is empty: in that case we simply return from the function, otherwise we increase the iterator. We then check if the iterator reached the end of the list (which happens when it was previously pointing to the last image). In that case we reset it to the first image and we return true
. I won't explain the other functions because they are rather trivial, but don't hesitate to take a look at the code.
Let's now look at the CAnimatedSprite
class which allows you to group several animations together. Let's take an example: suppose that you are writing a game in which the player plays a knight. This knight will of course have multiple different animations: walk, attack, standstill, ... In general, you will need to provide such animations for each direction your knight can have in your game. This class will then be used to represent your knight: you will be able to load several animations and replay them later on demand:
class CAnimatedSprite
{
public:
CAnimatedSprite();
~CAnimatedSprite();
void AddAnimation(const std::string& strAnimName,
const CImageList& lstAnimation);
void PlayAnimation(const std::string& strAnimName);
void DrawSprite();
void NextFrame();
void SetPosition(int XPos, int YPos)
{
m_iXPos = XPos;
m_iYPos = YPos;
}
void OffsetPosition(int XOffset, int YOffset)
{
m_iXPos += XOffset;
m_iYPos += YOffset;
}
private:
typedef std::map<std::string, CImageList> TAnimMap;
typedef TAnimMap::iterator TAnimMapIter;
TAnimMap m_mapAnimations;
TAnimMapIter m_iterCurrentAnim;
int m_iXPos;
int m_iYPos;
};
The principle of the class is the following: it contains a map of all animations that can be played for the sprite, with the key being a string
identifying the animation and the value being a CImageList
object containing the animation. The AddAnimation
and PlayAnimation
simply add or retrieve an animation from the map:
void CAnimatedSprite::AddAnimation(const string &strAnimName,
const CImageList& lstAnimation)
{
m_mapAnimations[strAnimName] = lstAnimation;
}
void CAnimatedSprite::PlayAnimation(const string &strAnimName)
{
m_iterCurrentAnim = m_mapAnimations.find(strAnimName);
if (m_iterCurrentAnim == m_mapAnimations.end())
{
string strError = "Unable to play: " + strAnimName;
strError += ". Animation not found.";
throw CException(strError);
}
}
When trying to play an non existing animation, an exception is thrown. The m_iterCurrentAnim
variable is an iterator pointing to the current animation. It is used in the DrawSprite
and NextFrame
method to access the current animation:
void CAnimatedSprite::DrawSprite()
{
if (m_iterCurrentAnim == m_mapAnimations.end())
return;
m_iterCurrentAnim->second.GetCurrentImage()
->BlitImage(m_iXPos,m_iYPos);
}
void CAnimatedSprite::NextFrame()
{
if (m_iterCurrentAnim == m_mapAnimations.end())
return;
m_iterCurrentAnim->second.GoToNextImage();
}
In the DrawSprite
method, we retrieve the current image of the current animation and simply blit it at the specified position on the screen (remember how the CImage
class was working). In the NextFrame
, we simply go to the next image in the current animation.
After all those explanations, it is time for a concrete example to see how we will use all those classes. The example will be quite simple and far from a complete game, but it shows the principles. The purpose is to have an animated character (a knight) that can be controlled through the direction keys. It moves in a simple scene: grass with some trees on it, in an isometric view. There is no collision detection yet, which means that the knight can move through the trees. Another thing that is not implemented is the order in which the sprites are drawn: the knight will always be drawn on top of the scene, no matter where he is, which is wrong in some situations (if he is behind a tree, the tree should be drawn on top of the knight). This is left as an exercise to the reader :).
All the code will be implemented in the CMainWindow
class. Let's first add some member variables in this class:
TImagePtr m_pGrassImg;
TImagePtr m_pTreesImg[16];
CAnimatedSprite* m_pKnightSprite;
bool m_KeysDown[4];
std::string m_strLastDir;
We first declare some TImagePtr
which will hold several images that will be drawn (grass and trees). We then declare the CAnimatedSprite
which will be used to draw the knight. We finally have an array of 4 booleans to store the current state of the direction keys and a string
that contains the current direction of the knight. Those variables are initialized in the constructor of the main window class:
m_pGrassImg = CImage::CreateImage("GrassIso.bmp");
m_pGrassImg->GetTexture()->SetColorKey(0,128,128);
m_pKnightSprite = new CAnimatedSprite;
CAnimFileLoader fileLoader1("KnightWalk.bmp", 8, 96, 96);
CTextureManager::GetInstance()->GetTexture("KnightWalk.bmp")
->SetColorKey(111, 79, 51);
m_pKnightSprite->AddAnimation("WalkE",
fileLoader1.GetAnimation(0,7));
m_pKnightSprite->AddAnimation("WalkSE",
fileLoader1.GetAnimation(8,15));
m_pKnightSprite->AddAnimation("WalkS",
fileLoader1.GetAnimation(16,23));
m_pKnightSprite->AddAnimation("WalkSW",
fileLoader1.GetAnimation(24,31));
m_pKnightSprite->AddAnimation("WalkW",
fileLoader1.GetAnimation(32,39));
m_pKnightSprite->AddAnimation("WalkNW",
fileLoader1.GetAnimation(40,47));
m_pKnightSprite->AddAnimation("WalkN",
fileLoader1.GetAnimation(48,55));
m_pKnightSprite->AddAnimation("WalkNE",
fileLoader1.GetAnimation(56,63));
CAnimFileLoader fileLoader2("KnightPause.bmp", 8, 96, 96);
CTextureManager::GetInstance()->GetTexture("KnightPause.bmp")
->SetColorKey(111, 79, 51);
m_pKnightSprite->AddAnimation("PauseE",
fileLoader2.GetAnimation(0,7));
m_pKnightSprite->AddAnimation("PauseSE",
fileLoader2.GetAnimation(8,15));
m_pKnightSprite->AddAnimation("PauseS",
fileLoader2.GetAnimation(16,23));
m_pKnightSprite->AddAnimation("PauseSW",
fileLoader2.GetAnimation(24,31));
m_pKnightSprite->AddAnimation("PauseW",
fileLoader2.GetAnimation(32,39));
m_pKnightSprite->AddAnimation("PauseNW",
fileLoader2.GetAnimation(40,47));
m_pKnightSprite->AddAnimation("PauseN",
fileLoader2.GetAnimation(48,55));
m_pKnightSprite->AddAnimation("PauseNE",
fileLoader2.GetAnimation(56,63));
m_pKnightSprite->PlayAnimation("PauseE");
for (int i=0; i<4; i++)
m_KeysDown[i] = false;
m_strLastDir = "E";
m_pKnightSprite->SetPosition(350,250);
This looks like a lot of code but we need to load quite a bunch of animations for our knight: 2 animations (walk and pause) for each direction (8 different directions). We are using a new class here: the CAnimFileLoader
class. It is a simple helper class to easily load an image list from a file. It takes the file name, the number of images per row, the width and the height of an image as parameters in the constructor and you can retrieve an image list later by simply specifying the start index and the stop index of images in the file (it returns a CImageList
object). If you now look at the code, we first load the grass image and specify its color key, then we load all the 'walk' animations for our knight. Each animation name depends on the direction, e.g. for the 'walk' east direction, the animation name is "WalkE
". This will be used later to play a specific animation. We then specify that the default animation is the "PauseE
" animation.
Let's now look at how we handle the events when a key is pressed. This is done in the ProcessEvent
function:
void CMainWindow::ProcessEvent(UINT Message, WPARAM wParam, LPARAM lParam)
{
switch (Message)
{
case WM_CLOSE :
PostQuitMessage(0);
break;
case WM_SIZE:
OnSize(LOWORD(lParam),HIWORD(lParam));
break;
case WM_KEYDOWN :
switch (wParam)
{
case VK_UP:
m_KeysDown[0] = true;
break;
case VK_DOWN:
m_KeysDown[1] = true;
break;
case VK_LEFT:
m_KeysDown[2] = true;
break;
case VK_RIGHT:
m_KeysDown[3] = true;
break;
}
UpdateAnimation();
break;
case WM_KEYUP :
switch (wParam)
{
case VK_UP:
m_KeysDown[0] = false;
break;
case VK_DOWN:
m_KeysDown[1] = false;
break;
case VK_LEFT:
m_KeysDown[2] = false;
break;
case VK_RIGHT:
m_KeysDown[3] = false;
break;
}
UpdateAnimation();
break;
}
}
As you can see, we handle the WM_KEYDOWN
and the WM_KEYUP
messages, which correspond to a key pressed and a key released respectively. When such message is sent, the WPARAM
contains the code of the key which is pressed or released. We simply then set or reset the flag in our array to specify the state of the corresponding key (so, the first element in the array corresponds to the up key, the second to the down key, ...). We then call the UpdateAnimation
function:
void CMainWindow::UpdateAnimation()
{
bool keyPressed = false;
for (int i=0; i<4; i++)
{
if (m_KeysDown[i])
{
keyPressed = true;
break;
}
}
string strAnim;
if (!keyPressed)
strAnim = "Pause" + m_strLastDir;
if (keyPressed)
{
string vertDir;
string horizDir;
if (m_KeysDown[0])
vertDir = "N";
else if (m_KeysDown[1])
vertDir = "S";
if (m_KeysDown[2])
horizDir = "W";
else if (m_KeysDown[3])
horizDir = "E";
m_strLastDir = vertDir + horizDir;
strAnim = "Walk" + m_strLastDir;
}
m_pKnightSprite->PlayAnimation(strAnim);
}
We first check if at least one key is pressed. If that's not the case, we specify that the animation that should be played is "Pause" + the name of the last knight direction. If at least one key is pressed, we check which ones are pressed and we build the last direction string
. Let's now look at the Draw
function:
void CMainWindow::Draw()
{
glClear(GL_COLOR_BUFFER_BIT);
int xPos=0, yPos=0;
for (int i=0; i<8; i++)
{
for (int j=0; j<6; j++)
{
xPos = i * 256/2 - 128;
if (i%2)
yPos = (j * 128) - 128/2;
else
yPos = (j * 128);
m_pGrassImg->BlitImage(xPos, yPos);
}
}
m_pTreesImg[0]->BlitImage(15,25);
m_pTreesImg[1]->BlitImage(695,55);
m_pTreesImg[2]->BlitImage(15,25);
m_pTreesImg[3]->BlitImage(300,400);
m_pTreesImg[4]->BlitImage(125,75);
m_pTreesImg[5]->BlitImage(350,250);
m_pTreesImg[6]->BlitImage(400,350);
m_pTreesImg[7]->BlitImage(350,105);
m_pTreesImg[8]->BlitImage(530,76);
m_pTreesImg[9]->BlitImage(125,450);
m_pTreesImg[10]->BlitImage(425,390);
m_pTreesImg[11]->BlitImage(25,125);
m_pTreesImg[12]->BlitImage(550,365);
m_pTreesImg[13]->BlitImage(680,250);
m_pTreesImg[14]->BlitImage(245,325);
m_pTreesImg[15]->BlitImage(300,245);
m_pKnightSprite->DrawSprite();
m_pKnightSprite->NextFrame();
SwapBuffers(m_hDeviceContext);
}
We first draw the grass: if you open the GrassIso.bmp file, you can see that this is a losange, and not a rectangle. That shape is typically used for isometric games to give an impression of 3D. After the grass is drawn, we draw some trees at some predefined positions on the screen. As you can see, manipulating the object contained in the smart pointer is completely transparent (it is as if it were manipulating the object directly). We finally draw the knight sprite and switch to the next frame in the animation. Moving the knight sprite is done in the Update
function:
void CMainWindow::Update(DWORD dwCurrentTime)
{
int xOffset = 0;
int yOffset = 0;
if (m_KeysDown[0])
yOffset -= 5;
if (m_KeysDown[1])
yOffset += 5;
if (m_KeysDown[2])
xOffset -= 5;
if (m_KeysDown[3])
xOffset += 5;
m_pKnightSprite->OffsetPosition(xOffset, yOffset);
}
If one of the keys is pressed, we move the sprite by a certain offset. As the time is passed to the function, we could also calculate the offset to apply to the sprite depending on the time elapsed. So, you are now ready to test the example and move your knight on the screen. Of course, the scene should probably be loaded from a file that is generated from a specific editor, but that falls outside the scope of this article.
This terminates the second article of the series, in which we saw how to load graphic files and render them on the screen and how to display animations. The next article is the last one of the series. We will see there how to draw text on the screen, how to manage the different states of a game and apply everything we saw on a concrete example.
[1]
Singleton article: A good introduction to the singleton pattern
[2]
Shared pointers: An extensive article about shared pointers
[3]
Boost shared_ptr: The boost library about
shared_ptr
[4]
Reiner's tileset: Free resources from which the images of the example were taken from
[5]
DevIL:
DevIL
library
[6]
FreeImage:
FreeImage
library
Thanks to Jeremy Falcon and El Corazon for their advices and help. Thanks also to the CodeProject editors for their great job.
- 15th August, 2008: Initial post
- 29th March, 2009: Updated source code