Building Simple Animated Video Games Using C++ and MFCTheo Pavlidis This tutorial is meant for beginners in video games. It assumes knowledge of C++, some familiriaty with Visual Studio and MFC (the Microsoft Foundation Classes), and understanding of the concept of a thread. No previous experience in writing video game programs is assumed. I use the term Simple Animated Video Games to describe two-dimensional games that can be run on a PC with a mouse and a keyboard. I use Window Threads for most of the characters in the game. (The exceptions are characters controlled by the player.) I start by describing the class implementing the animated objects, then I discuss their management, and finally I discuss the objects controlled by the player. The first game I implemented is a submarine chase game and now and then terminology from that game sneaks in, but the code can be used easily for any two-dimensional animation. There is another method for animation without using threads, relying on background processes. I have used such a method in my book Interactive Computer Graphics in X, PWS, 1996 (pp. 75-92) to show how to build simple video games. (The book is out of print, but you might find it in a library.) For those who cannot locate the book and are curious about the method I give a brief account at the end of this tutorial while covering also some of the trade-offs of the two approaches. The Animated ObjectsEach character is implemented as an object of a class CGraphin that has the following members (in addition to the standard constructor and destructor). protected: CWinThread * m_pThread; static UINT ThreadFunc(LPVOID pParam); GParam m_param; public: void SetGraphin(GParam *p); void GetGraphin(GParam *p); void StartWork(BOOL resume); void StopWork(BOOL terminal); void Draw(CDC *pDC, HICON IconList[]); A graphin is essentially a thread with an associated function and a parameter structure GParam. This structure contains not only input parameters but also placeholders of the state variables of the object used by the calling thread. Bellow is a partial listing of the structure with the essential members. Additional members affect the "lifespan" of the object, details of its motion, etc. typedef struct _GParam { int id; // index of the object. If -1, the thread is not running int x0, y0; // initial position int dx, dy; // speed int type; // determines how it is drawn int x, y, ix; // state of the object // ... } GParam; This structure begins life in the calling program and it is copied in the
graphin object during initialization. The SetGraphin(GParam *p) function
contains only the statement In earlier version (of May 15, 2006) I had used the methods SuspendThread() and ResumeThread() to control the animation. However, one has to be very careful in using these functions and the MFC documentation recommends to have threads terminate themselves. In this implementation I have added a variable stop_thread that is a member of the parameter structure passed to the thread. The functions for starting and stopping the thread using that variable are listed next:
void CGraphin::StartWork(BOOL resume)
{
if(m_param.id < 0) return;
if(resume==TRUE) {
m_param.x0 = m_param.x;
m_param.y0 = m_param.y;
}
m_param.stop_thread = FALSE;
m_pThread = AfxBeginThread(&CGraphin::ThreadFunc, &m_param);
ASSERT(m_pThread != NULL);
}
void CGraphin::StopWork(BOOL terminal)
{
if(m_param.id < < 0) return;
m_param.stop_thread = TRUE;
if(terminal == TRUE) m_param.id = -1;
}
The thread work function and the drawing method require a bit more discussion. Below is a minimal listing of the first. I omit statements that check the boundary of the playing area or any static objects.
UINT CGraphin::ThreadFunc(LPVOID pParam)
{
GParam *p = (GParam *)pParam;
p->x = p->x0;
p->y = p->y0;
for(int i=0; i<p->iter; i++) {
if(p->stop_thread == TRUE) break;
::Sleep(p->delay);
p->x += p->dx;
p->x += p->dy;
p->ix = i;
}
return 0;
}
The program works equally well when the thread function is taken out of the class because all the class instance variables are passed as argument. It has been included in the class for "bookeeping purposes".
A minimal listing of the drawing function follows. void CGraphin::Draw(CDC *pDC, HICON IconList[]) { if(m_param.id < 0) return; HICON icon; switch(m_param.type) { case SUBMARINE: if(m_param.dx > 0) icon = IconList[SUB_R]; else icon = IconList[SUB_L]; pDC->DrawIcon(m_param.x, m_param.y, icon); return; case BOMB: pDC->SelectStockObject(WHITE_PEN); pDC->SelectStockObject(BLACK_BRUSH); pDC->Ellipse(m_param.x-3, m_param.y-3, m_param.x+3, m_param.y+3); return; case FISH: if(m_param.dx > 0) icon = m_param.ix%2 ? IconList[FISH_R1] : IconList[FISH_R2]; else icon = m_param.ix%2 ? IconList[FISH_L1] : IconList[FISH_L2]; pDC->DrawIcon(m_param.x, m_param.y, icon); return; default:; } } Draw() is called by the program managing the threads. Each object knows where and how to draw itself, the "how" being provided by the type parameter. I prefer to use icons or simple drawing fucntions (rather than bitmaps) to avoid slowing the game down. The third object uses two icons for each direction alternating between the two to improve the animation effect. As pair of such images is shown below. The items of the IconList are created from the resources with a statement such as the one below. This action is usually taken by the managing module (discussed in the next section) at the beginning of execution. IconList[FISH_L1] = ::AfxGetApp()->LoadIcon(IDI_ICON3); Managing the Animated ObjectsThe managing of the Graphins is done by an object of class CGame that maintains arrays of graphins and starts a monitor thread that "keeps an eye" on them. CGame stands between the application that deals with user interaction, displays, etc and the internals of the game itself. The various events handlers call methods of CGame. CGame also receives (during initialization) a pointer to a drawable window, m_view_ptr that is used to redraw the screen in response to the motion of the animated objects. Here is the listing of the work function of the monitor thread. It is invoked with this as argument.
UINT ThreadFuncMonitor(LPVOID pParam)
{
CGame *pw = (CGame *)pParam;
while(pw->m_Run==TRUE) {
::Sleep(100);
pw->SnapShotOfGame();
}
return 0;
}
The monitor function is quite trivial and all the work is done by the SnapShotOfGame() function that is given below. It includes some "submarine chase" specific code. If two objects "collide" we execute the first half of the if-else statement, otherwise we execute the second where we may modify the parameters of a Graphin
void CGame::SnapShotOfGame()
{
CRect rsub, rscreen;
POINT bomb, sub;
GParam Subtmp, Bombtmp;
m_Nflash = 0;
for(int j=0; j<m_Nsub; j++) {
m_submarine[j].GetGraphin(&Subtmp);
if(Submtp.id < 0) continue;
sub.x = Subtmp.x; sub.y = Subtmp.y;
// ... code to compute rectangle rsub around a submarine shape
for(int i=0; i<m_NextBomb; i++) {
m_bomb[i].GetGraphin(&Bombtmp);
if(Bombmtp.id < 0) continue;
bomb.x = Bombtmp.x; bomb.y = Bombtmp.y;
if(rsub.PtInRect(bomb)==TRUE) {
::MessageBeep(MB_ICONEXCLAMATION);
m_flash[m_Nflash++] = bomb;
m_bomb[i].StopWork(TRUE);
m_submarine[j].StopWork(TRUE);
m_Hits++;
break;
}
else {
// code to decide whether to modify motion
// or not. If not, we continue the loop
m_submarine[j].StopWork(FALSE);
Subtmp.id = j;
// the next two lines modify the motion,
// could be something else
if(abs(Subtmp.dx) < 2) Subtmp.dx = -Subtmp.dx;
else Subtmp.dx = - Subtmp.dx/2;
m_submarine[j].SetGraphin(&Subtmp);
m_submarine[j].StartWork(TRUE);
}
}
}
GetRepaintArea(&r); // Estimate area to be repainted.
m_view_ptr->InvalidateRect(&r, FALSE);
}
The animation related part of the Draw() function of the CGame is given next.
// Plot explosions
CBrush *oldBrush = pDC->SelectObject(&orangeBrush);
for(int j=0; j<m_Nflash; j++)
pDC->Ellipse(m_flash[j].x-20, m_flash[j].y-20,
m_flash[j].x+20, m_flash[j].y+20);
pDC->SelectObject(oldBrush);
// Plot animated objects
for(int j=0; j<m_Nsub; j++) m_submarine[j].Draw(pDC, m_IconList);
for(int j=0; j<m_NextBomb; j++) m_bomb[j].Draw(pDC, m_IconList);
// This is here rather than in the SnapShot
// because we want to see the final scene!
if(m_Hits>=m_Nsub) EndofGame(pDC, ALL_SUBS_SUNK);
The only noteworthy feature is that all drawing is done within the Draw() function and flicker is reduced by a (rough) calculation of the area to be repainted. Several other functions are needed but they do not deal with animation. The following five are used to communicate with the environment void StartGame(); void PauseGame(); void ResumeGame(); void StopGame(); void Hit(CPoint point);The first four are invoked by the user clicking on toolbar buttons or making menu selection. The last is invoked when the user clicks a mouse button or uses the keyboard. There can be several variants of the Hit() function. The PlayerObjects controlled by the player move less frequently than those that are animated,so I use transparent bitmaps for their form rather than icons that come in a fixed size. The only challenge is the creation of such a bitmap and I used for that the method described in Chris Becke's tutorial on bitmaps that is part of Microsoft's MVPS (Most Valuable Professional Site). Recent versions of MFC have a MaskBlt() method that can be used to display transparent bitmaps but there is a warning that "not all devices support" the method and, furthermore, it requires the passing of an already constucted mark bitmap. The MVPS method constructs that bitmap as well and that is where most the code lies. The player object has four members, the first three needed for its display and the fourth chosen by the user. HBITMAP m_Pix; HBITMAP m_PixMask; CSize m_PixDim; CPoint m_PixPos;The initialization code deals with the creation of the transparent bitmap where ResID is the resource number of the bitmap, for example IDB_BITMAP1. #define SelectBitmap(dc,bm) ((HBITMAP)SelectObject((dc),(HGDIOBJ)(bm))) BOOL CPlayer::InitPlayer(int ResID, CPoint origin) { // using MVPS method (gdi02) m_Pix = (HBITMAP)::LoadImageA(AfxGetResourceHandle(), MAKEINTRESOURCE(ResID), IMAGE_BITMAP, 0, 0, 0); ASSERT(m_Pix != NULL); BITMAP bm; GetObject(m_Pix, sizeof(bm), &bm); m_PixDim.SetSize(bm.bmWidth, bm.bmHeight); m_PixMask = CreateBitmap(bm.bmWidth, bm.bmHeight, 1, 1, NULL); // copied from MVPS HDC hdcSrc = CreateCompatibleDC(NULL); HDC hdcDst = CreateCompatibleDC(NULL); HBITMAP hbmSrcT = SelectBitmap(hdcSrc, m_Pix); HBITMAP hbmDstT = SelectBitmap(hdcDst, m_PixMask); // set background color to that of upper left corner of m_Pix COLORREF clrTopLeft = GetPixel(hdcSrc,0,0); COLORREF clrSaveBk = SetBkColor(hdcSrc,clrTopLeft);//1 // This call sets up the mask bitmap. BitBlt(hdcDst, 0, 0, bm.bmWidth, bm.bmHeight, hdcSrc, 0, 0, SRCCOPY);//2 COLORREF clrSaveDstText = SetTextColor(hdcSrc, RGB(255, 255, 255));//3 SetBkColor(hdcSrc, RGB(0,0,0)); BitBlt(hdcSrc, 0, 0, bm.bmWidth, bm.bmHeight, hdcDst, 0, 0, SRCAND);//4 // cleanup SetBkColor(hdcSrc,clrSaveBk); SelectBitmap(hdcSrc,hbmSrcT); SelectBitmap(hdcDst,hbmDstT); DeleteDC(hdcSrc); DeleteDC(hdcDst); m_PixPos.x = origin.x; return TRUE; } What the above code does is detect the color of the top left pixel of the bitmap and assume that all pixels of that color should be transparent. m_PixMask is created as one bit map (the arguments 1, 1 mean one plane, one bit) and the original bitmap is copied upon it. Because the background color was set to that of the top left corner (statement 1), statement 2 will mappixels with that color to 1 in the 1-bit bitmap and the all the rest will map to 0. (The background "color" of a bitmap is white, and is stored as binary 0. This is historical with bitmaps of letters were defined with 1 for black and 0 for white, which of course the opposite of what we expect on the basis of brightness.) Statement 3 sets the foreground to white and the following statement sets the background to black. (We use the "text" functions because in reality they deal with any 1-bit bitmap besides text.) Statement 4 performs a logical AND between the pixels of the mask and the original thus making all pixels with the bacround color equal to 0. If the background was 0 to start with, steps 3-4 are not needed. The code for drawing the bitmap that is shown below. First we perform an AND operation between the mask and the image. Wherever the mask is 0 the destination is set to 0, otherwise is left alone. In essence, we open a "hole" where the nontransparent part of the bitmap will go. Next we copy the color bitmap using the SRCPAINT mode that performs a logical OR operation. Since the destination pixels had been zeroed before we copy the color part. Ans since the transparent pixels had been set to 0 before, they are left untouched! void CPlayer::DrawPlayer(CDC *pDC, CPoint spot) { // compute m_PixPos from spot. spot is usually the mouse position HDC hdcMem = CreateCompatibleDC(NULL); CDC dcMemory; dcMemory.Attach(hdcMem); HBITMAP hbmT = SelectBitmap(hdcMem, m_PixMask); pDC->BitBlt(m_PixPos.x, m_PixPos.y, m_PixDim.cx, m_PixDim.cy, &dcMemory, 0, 0, SRCAND); SelectBitmap(hdcMem, m_Pix); pDC->BitBlt(m_PixPos.x, m_PixPos.y, m_PixDim.cx, m_PixDim.cy, &dcMemory, 0, 0, SRCPAINT); SelectBitmap(hdcMem, hbmT); DeleteDC(hdcMem); } If we were going to use the MaskBlt() method of CDC the code would have been // WARNING: UNTESTED CODE void CPlayer::DrawPlayer(CDC *pDC, CPoint spot) { // compute m_PixPos from spot. spot is usually the mouse position // .... pDC->BitBlt(m_PixPos.x, m_PixPos.y, m_PixDim.cx, m_PixDim.cy, &dcMemory, 0, 0, &m_PixMask, 0, 0, MAKEROP4(SRCAND, SRCPAINT); ... } Exiting the ProgramIt is important to stop all the threads before exiting the program. The MFC implementation invokes the OnClose() function of the main window upon exit and we need to overwrite this function.
void CMainFrame::OnClose()
{
CView* vp = GetActiveView();
if(vp==NULL) return;
((CSubChaseView *)vp)->EndGame();
::Sleep(100); // give threads a chance to terminate
CFrameWnd::OnClose(); // invoke the basis function
}
The EndGame() functions calls the StopWork() function for all graphins and also sets the m_Run flag for the monitor thread. Another Way for AnimationIt is possible to do animation without threads by taking advantage of the OnIdle() function of MFC. This functions is called when there are no messages in the queue, usually because there has been no user action. In such a game Graphins will not a have a thread associated with them, so the code of the game would look like
BOOL CMyApp::OnIdle(LONG Kount)
{
CWinApp::OnIdle(Kount);
// Loop for moving the Graphins around,
// checking their interactions, etc
return TRUE;
}
The variable Kount is incremented by the system each time the function OnIdle() is called and reset to zero anytime a message is handled. In a way this implementation is simpler because you have control of all the objects in one place and you do not have to be concerned about communication between processes. You may use the value of Kount to move objects at different speeds, for example CGraphin critter[100]; // .... if((Kount % critter[j].m_param.delay) == 0) // move critter[j] // .... The trouble with this design is that you have to be careful about how you use the "idle" time. The system does not respond to user messages while the program is computing there and you usually you must spread the work amongst different calls of OnIdle(). As a result the simplicity of the implementation is misleading. Of course multithreading was not available in 16 bit Windows and other older operating systems, so in the "old days" one had no choice but to use OnIdle() or equivalent. In my opinion, associating each object with a thread provides a certain conceptual simplicity that ultimately results in more robust software. In addition, it pays off in performance, even on single processor systems. |
| theopavlidis.com | Site Map |