A Simple Game from Start to Finish, Part 1
Foreword to Same Game
In this five part series, we'll be creating a version of a game called SameGame using the Microsoft Foundation Class library from start to finish. We'll include features beyond the simple removing of blocks in the game. We'll implement an undo/redo subsystem and some user configuration dialogs. We'll show you step by step with not only source code but screenshots how to build a fun game from start to finish and how to use Microsoft's MFC classes. Every article comes with complete source code, so you can build and run the game yourself.
The rules to the SameGame are quite simple, you try to remove all of the colored blocks from the playing field. In order to remove a block the player must click on any block that is next to, vertically or horizontally, another with the same color. When this happens all of the blocks of that color that are adjacent to the clicked block are removed. All of the blocks above the ones removed then fall down to take their place. When an entire column is removed, all columns to the right are shifted to the left to fill that space. The blocks aren't shifted individually but as a column. The game is over when there are no more valid moves remaining. The goal is to end with an empty board in as little time as possible. Some versions of the SameGame use a scoring algorithm that can be implemented as an additional exercise for the user.
Starting the Same Game Project
In this article we'll be using Visual Studio 2005 to create our game. The following instructions can easily be adapted to all other versions of Visual Studio. First start up Visual Studio and create a new project. The type of project is "Visual C++" -> "MFC" -> "MFC Application".
Next the MFC application wizard will appear. If you do not choose the name SameGame, then the names of your classes will be slightly different than those that appear in this article. This allows you to select quite a few options that the resulting generated code will include. For our simple game we can disable quite a few of these options. The following graphics show which options to select in order to get the project just the way we want it.
Selecting "Single document" allows the application to use the document/view architecture when multiple documents aren't necessary. The last setting of interest on this page is "Use of MFC". The two options are for a shared DLL or as a static library. Using a DLL means that your users must have the MFC DLLs installed on their computer, which most computers do. The static library option links the MFC library right into your application. The executable that is produced will be larger in size but will work on any Windows machine.
Advance through the next three pages, taking the defaults until the following page is displayed.
(If you are using Visual 2010, this screen does not have a "None" option for Toolbars. Just choose "Use a Classic Menu" without checking either toolbar.) A thick frame allows the user to resize the window. Since our game is a static size, un-check this option. A maximize box isn't needed, nor is a status bar or a toolbar. Advancing to the next page will bring you to the "Advanced Features" page.
Turn off printing, ActiveX controls and set the number of recent files to zero. Since we won't actually be loading any files, this option won't be necessary. The last page of the MFC Application Wizard presents you with a list of generated classes.
Four classes that will be generated for you are the basis for the game. The first on the list is the view class, here it is called CSameGameView. I will come back to this class in a minute. The next class in the list is the application class. This class is a wrapper for the entire application and a main function is provided for your application by this class. The base class isn't selectable and must be CWinApp.
The next class in the list is the document class, CSameGameDoc based on the CDocument class. The document class is where all of the application data is stored. Again the base class cannot be changed.
The last class is the CMainFrame class. This CFrameWnd based class is the wrapper class for the actual window. The main frame class contains the menu and the client area view. The client area is where the actual game will be drawn.
Now back to the view class. The base class is a dropdown with a list of views that are generally available, each with its own use and application. The default view type is CView, which is a generic view where all of the display and interaction with the user must be done manually. This is the one that we want to select.
I will quickly go down the list and explain what each view type is used for, just for your information. The CEditView is a generic view which consists of a simple text box. The CFormView allows the developer to insert other common controls into it, i.e. edit boxes, combo boxes, buttons, etc. The CHtmlEditView has an HTML editor built into the view. The CHtmlView embeds the Internet Explorer browser control into the view. The CListView has an area similar to an Explorer window with lists and icons. The CRichEditView is similar to WordPad; it allows text entry but also text formatting, colors and stuff like that. A CScrollView is a generic view similar to CView but allows scrolling. Finally the CTreeView embeds a tree control into the view.
Finishing the MFC Application Wizard will produce a running MFC application. Since we haven't written any code yet it is a very generic window with nothing in it, but it is a fully functioning application all the same. Below is a screenshot of what your generic application ought to look like. To build your application, you can go to the Debug menu, and select Start without Debugging. Visual Studio may prompt you to rebuild the project—select "Yes".
Notice it has a default menu (File, Edit and Help) and an empty client area. Before we get to actual coding I'd like to explain a little about the document/view architecture that is used in MFC applications and how we are going to apply it to our game.
Part 2 : Creating a Playable Game
By the end of this article we will have a "playable" version of the SameGame. I have playable in quotes because we will have the game in a state the will allow the player to click to remove blocks and end the game when there are no more valid moves left. The game won't be very feature-rich but will be playable. In the remaining articles we'll add more features to increase the difficulty and allow the game to be customized a little.
As for this article we'll be looking into event driven programming and how to get our game to respond to mouse clicks. Once we can respond to clicks we'll discuss the algorithm we'll use to remove the blocks and finally, how to tell when the game is over.
Event Driven Programming
Event driven programming, if you've never done it before, is a complete paradigm change in programming. If this isn't your first encounter with event driven programming then go ahead and skip to the next section.
Up till now you've probably only written procedural programs in C++. The difference between the two types of programming paradigms is that the flow of control in event driven programming is determined by events not a predetermined set of steps. It is a reactive program. The user does something like click on a button and the program reacts to that event by executing some code. The main loop in an event driven program simply waits for an event to happen then calls the appropriate event handler and goes back to wait for another event. An event handler is a piece of code that is called each time a specific event happens.
Mouse Clicks
The MFC library is inherently event driven and therefore makes it pretty easy for us to create event handlers and respond to any event that we want. To set up event handling in MFC, Visual Studio lists all of the messages that are available to respond to. In this case messages are synonymous with events. All of the Windows messages are constants that start with WM_ followed by the message name. To respond to mouse clicks in the client area of the view there are messages for the left, right and middle mouse buttons. The event that we will use is the WM_LBUTTONDOWN. This message is sent by the MFC framework every time the user clicks the left mouse button down. All we need to do is set up an event handler to listen for this message to be sent and then respond. To add an event handler open up the Properties Window from the CSameGameView header file. Do this by pressing Alt+Enter or from the menu View->Other Windows->Properties Window. Below is what you'll see in the properties window. (If it isn't, make sure your cursor is placed within the class declaration inside the SameGameView.h file.)
In the screenshot my cursor is hovering over the "Messages" section, click on it. Look for the WM_LBUTTONDOWN option, click on it, click the dropdown as shown in the screenshot below and select "<Add> OnLButtonDown".
This will add the OnLButtonDown event handler to your view with some default code in it to call the CView implementation of the function. Here we'll add the following code to the function body (changes in bold) Note that this code won't yet compile, but we'll get to that shortly. That's OK to do—the code won't compile, but it lets us figure out what needs to be done to make this function work, without worrying yet about how to write the other functions we will rely on.
Please do wait to compile the resulting code until you've finished the article, since the changes will cascade; as we go through how to implement each of the functions we need, we'll discover we need more functions. But eventually we'll get through all of them.
void CSameGameView::OnLButtonDown(UINT nFlags, CPoint point)
{
// First get a pointer to the document
CSameGameDoc* pDoc = GetDocument();
ASSERT_VALID(pDoc);
if(!pDoc)
return;
// Get the row and column of the block that was clicked on
int row = point.y / pDoc->GetHeight();
int col = point.x / pDoc->GetWidth();
// Delete the blocks from the document
int count = pDoc->DeleteBlocks(row, col);
// Check if there were any blocks deleted
if(count > 0)
{
// Force the view to redraw
Invalidate();
UpdateWindow();
// Check if the game is over
if(pDoc->IsGameOver())
{
// Get the count remaining
int remaining = pDoc->GetRemainingCount();
CString message;
message.Format(_T("No more moves left\nBlocks remaining: %d"),
remaining);
// Display the results to the user
MessageBox(message, _T("Game Over"), MB_OK | MB_ICONINFORMATION);
}
}
// Default OnLButtonDown
CView::OnLButtonDown(nFlags, point);
}
The two arguments to the function are an integer of bit-flags which can be ignored and a CPoint object. The CPoint object contains the (x, y) coordinate of where the mouse was clicked within your view. We'll use this to figure out which block they clicked. The first few lines of code are familiar to us by now; we are just getting a valid pointer to the document. To find the row and column of the block that was clicked we use some simple integer math and divide the x coordinate by the width of a block and the y by the height.
// Get the row and column of the block that was clicked on
int row = point.y / pDoc->GetHeight();
int col = point.x / pDoc->GetWidth();
Since we are using integer division the result is the exact row and column the user clicked on.
Once we have the row and column we will call a function, DeleteBlocks (we'll add it next) on the document to delete the adjacent blocks. This function will return the number of blocks that it deleted. If none are deleted then the function essentially ends. If there were blocks deleted then we need to force the view to redraw itself now that we've changed the game board. The function call Invalidate() signals to the view that the whole client area needs to be redrawn and UpdateWindow() does that redraw.
int count = pDoc->DeleteBlocks(row, col);
// Check if there were any blocks deleted
if(count > 0)
{
// Force the view to redraw
Invalidate();
UpdateWindow();
// ...
}
}
Now that the board has been updated and redrawn we test if the game is over. In the section entitled "Finishing Condition" we'll go over exactly how we can tell if the game is over. For now we'll just add a call to it.
if(pDoc->IsGameOver())
{
// Get the count remaining
int remaining = pDoc->GetRemainingCount();
CString message;
message.Format(_T("No more moves left\nBlocks remaining: %d"),
remaining);
// Display the results to the user
MessageBox(message, _T("Game Over"), MB_OK | MB_ICONINFORMATION);
}
If the game is over we get the number of blocks remaining on the board and report that to the user. We create a CString object which is MFC's string class and call its built-in format method. The format method behaves just like sprintf(). Here we use the MFC _T() macro to allow for different kinds of strings (i.e. ASCII or wide character formats). We finally call the MessageBox() function that displays a small dialog with the title "Game Over" and the message that we created using the format method. The dialog has an OK button (MB_OK) and an information icon (MB_ICONINFORMATION).
Now that this event handler is in place we need to implement the three functions on the document that we called, IsGameOver, DeleteBlocks and GetRemainingCount. These functions are just simple wrappers for the same functions on the game board. So they can just be added to the header file for the document just after the DeleteBoard function, like the following.
bool IsGameOver() { return m_board.IsGameOver(); }
int DeleteBlocks(int row, int col)
{ return m_board.DeleteBlocks(row, col); }
int GetRemainingCount()
{ return m_board.GetRemainingCount(); }
Once we have added these wrapper functions to the document it is time to modify the game board to take care of these operations. In the header file for the game board add the following public methods (again put them right below the DeleteBoard function).
/* Is the game over? */
bool IsGameOver(void) const;
/* Get the number of blocks remaining */
int GetRemainingCount(void) const { return m_nRemaining; }
/* Function to delete all adjacent blocks */
int DeleteBlocks(int row, int col);
Two of the functions are fairly complex and will require quite a bit of code but the GetRemainingCount function simply returns the count of remaining blocks. We'll store that count a member variable called m_nRemaining. We need to add this to the game board in the private member section of the class.
/* Number of blocks remaining */
int m_nRemaining;
Since we are adding another data member to our class we need to initialize it in the constructor like so (changesbolded).
CSameGameBoard::CSameGameBoard(void)
: m_arrBoard(NULL),
m_nColumns(15), m_nRows(15),
m_nHeight(35), m_nWidth(35), // <-- don't forget the comma!
m_nRemaining(0)
{
m_arrColors[0] = RGB( 0, 0, 0);
m_arrColors[1] = RGB(255, 0, 0);
m_arrColors[2] = RGB(255,255, 64);
m_arrColors[3] = RGB( 0, 0,255);
// Create and setup the board
SetupBoard();
}
We also need to update the count of remaining blocks in the SetupBoard method (changes bolded):
void CSameGameBoard::SetupBoard(void)
{
// Create the board if needed
if(m_arrBoard == NULL)
CreateBoard();
// Randomly set each square to a color
for(int row = 0; row < m_nRows; row++)
for(int col = 0; col < m_nColumns; col++)
m_arrBoard[row][col] = (rand() % 3) + 1;
// Set the number of spaces remaining
m_nRemaining = m_nRows * m_nColumns;
}
Deleting blocks from the board is a two step process. First we change all of the same colored, adjacent blocks to the background color, in essence deleting them, and then we have to move the above blocks down and the blocks to the right, left. We call this compacting the board.
Deleting blocks is a prime candidate for the use of recursion. We'll create a recursive helper function called DeleteNeighborBlocks that is private that will do the bulk of the work of deleting blocks. In the private section of the class right after the CreateBoard() function add the following.
/* Direction enumeration for deleting blocks */
enum Direction
{
DIRECTION_UP,
DIRECTION_DOWN,
DIRECTION_LEFT,
DIRECTION_RIGHT
};
/* Recursive helper function for deleting blocks */
int DeleteNeighborBlocks(int row, int col, int color,
Direction direction);
/* Function to compact the board after blocks are eliminated */
void CompactBoard(void);
We will use the enumeration for direction in the recursive helper function that will keep us from trying to recurse back to the block we just came from. Next up is actually implementing the DeleteBlocks algorithm!
Part 3 :A Simple Game from Start to Finish
The View: Drawing Your Game
Now that the document contains an initialized game board object we need to display this information to the user. This is where we can actually start to see our game come to life.
The first step is to add code to resize the window to the correct size. Right now the window is a default size that isn't what we want. We'll do this in the OnInitialUpdate override. The view class inherits a default OnInitialUpdate that sets up the view and we want to override it so that we can resize the window when the view is initially updated. This can be achieved by opening up the Properties Window from the CSameGameView header file (which will actually be called SameGameView.h). Do this by pressing Alt+Enter or from the menu View-> Properties Window (on some versions of Visual Studio, it will be View-> Other Windows -> Properties Window). Below is what you'll see in the properties window.
In the screenshot my cursor is hovering over the "Overrides" section; click on it. Look for the OnInitialUpdate option, click on it, click the dropdown as shown in the screenshot below and select "<Add> OnInitialUpdate".
This will add the OnInitialUpdate override to your view with some default code in it to call the CView implementation of the function. Then we just add a call to the ResizeWindow function that we will write. So this leaves us with the following in the header file (changes bolded).
#pragma once
class CSameGameView : public CView
{
protected: // create from serialization only
CSameGameView();
DECLARE_DYNCREATE(CSameGameView)
// Attributes
public:
CSameGameDoc* GetDocument() const;
// Overrides
public:
virtual void OnDraw(CDC* pDC); // overridden to draw this view
virtual BOOL PreCreateWindow(CREATESTRUCT& cs);
protected:
// Implementation
public:
void ResizeWindow();
virtual ~CSameGameView();
#ifdef _DEBUG
virtual void AssertValid() const;
virtual void Dump(CDumpContext& dc) const;
#endif
// Generated message map functions
protected:
DECLARE_MESSAGE_MAP()
public:
virtual void OnInitialUpdate();
};
#ifndef _DEBUG // debug version in SameGameView.cpp
inline CSameGameDoc* CSameGameView::GetDocument() const
{ return reinterpret_cast<CSameGameDoc*>(m_pDocument); }
#endif
While we're adding in the resize code, we also need to add drawing code to the CSameGameView class. The header and source files for the view already contain a function override called OnDraw. This is where we'll put the drawing code. Here is the full source code for the view (changes bolded).
#include "stdafx.h"
#include "SameGame.h"
#include "SameGameDoc.h"
#include "SameGameView.h"
#ifdef _DEBUG
#define new DEBUG_NEW
#endif
// CSameGameView
IMPLEMENT_DYNCREATE(CSameGameView, CView)
BEGIN_MESSAGE_MAP(CSameGameView, CView)
END_MESSAGE_MAP()
// CSameGameView construction/destruction
CSameGameView::CSameGameView()
{
}
CSameGameView::~CSameGameView()
{
}
BOOL CSameGameView::PreCreateWindow(CREATESTRUCT& cs)
{
return CView::PreCreateWindow(cs);
}
// CSameGameView drawing
void CSameGameView::OnDraw(CDC* pDC) // MFC will comment out the argument name by default; uncomment it
{
// First get a pointer to the document
CSameGameDoc* pDoc = GetDocument();
ASSERT_VALID(pDoc);
if(!pDoc)
return;
// Save the current state of the device context
int nDCSave = pDC->SaveDC();
// Get the client rectangle
CRect rcClient;
GetClientRect(&rcClient);
// Get the background color of the board
COLORREF clr = pDoc->GetBoardSpace(-1, -1);
// Draw the background first
pDC->FillSolidRect(&rcClient, clr);
// Create the brush for drawing
CBrush br;
br.CreateStockObject(HOLLOW_BRUSH);
CBrush* pbrOld = pDC->SelectObject(&br);
// Draw the squares
for(int row = 0; row < pDoc->GetRows(); row++)
{
for(int col = 0; col < pDoc->GetColumns(); col++)
{
// Get the color for this board space
clr = pDoc->GetBoardSpace(row, col);
// Calculate the size and position of this space
CRect rcBlock;
rcBlock.top = row * pDoc->GetHeight();
rcBlock.left = col * pDoc->GetWidth();
rcBlock.right = rcBlock.left + pDoc->GetWidth();
rcBlock.bottom = rcBlock.top + pDoc->GetHeight();
// Fill in the block with the correct color
pDC->FillSolidRect(&rcBlock, clr);
// Draw the block outline
pDC->Rectangle(&rcBlock);
}
}
// Restore the device context settings
pDC->RestoreDC(nDCSave);
br.DeleteObject();
}
// CSameGameView diagnostics
#ifdef _DEBUG
void CSameGameView::AssertValid() const
{
CView::AssertValid();
}
void CSameGameView::Dump(CDumpContext& dc) const
{
CView::Dump(dc);
}
// non-debug version is inline
CSameGameDoc* CSameGameView::GetDocument() const
{
ASSERT(m_pDocument->IsKindOf(RUNTIME_CLASS(CSameGameDoc)));
return (CSameGameDoc*)m_pDocument;
}
#endif //_DEBUG
void CSameGameView::OnInitialUpdate()
{
CView::OnInitialUpdate();
// Resize the window
ResizeWindow();
}
void CSameGameView::ResizeWindow()
{
// First get a pointer to the document
CSameGameDoc* pDoc = GetDocument();
ASSERT_VALID(pDoc);
if(!pDoc)
return;
// Get the size of the client area and the window
CRect rcClient, rcWindow;
GetClientRect(&rcClient);
GetParentFrame()->GetWindowRect(&rcWindow);
// Calculate the difference
int nWidthDiff = rcWindow.Width() - rcClient.Width();
int nHeightDiff = rcWindow.Height() - rcClient.Height();
// Change the window size based on the size of the game board
rcWindow.right = rcWindow.left +
pDoc->GetWidth() * pDoc->GetColumns() + nWidthDiff;
rcWindow.bottom = rcWindow.top +
pDoc->GetHeight() * pDoc->GetRows() + nHeightDiff;
// The MoveWindow function resizes the frame window
GetParentFrame()->MoveWindow(&rcWindow);
}
It is very simple to draw the game board, we are just going to loop through each row, column by column, and draw a colored rectangle. There is one argument to the OnDraw function and that is a CDC pointer. The CDC class is the base class for all device contexts. A device context is the generic interface to a device such as the screen or a printer. Here we'll use it to draw to the screen.
We first start the function by getting a pointer to the document so that we can get the board information. Next we call the SaveDC function from the device context. This function saves the state of the device context so that we can restore it after we are done.
// Get the client rectangle
CRect rcClient;
GetClientRect(&rcClient);
// Get the background color of the board
COLORREF clr = pDoc->GetBoardSpace(-1, -1);
// Draw the background first
pDC->FillSolidRect(&rcClient, clr);
Next we need to color the background of the client area black so we get the dimensions of the client area by calling GetClientRect. A call to GetBoardSpace(-1,-1) on the document will return the background color and FillSolidRect will fill the client area with that background color.
// Create the brush for drawing
CBrush br;
br.CreateStockObject(HOLLOW_BRUSH);
CBrush* pbrOld = pDC->SelectObject(&br);
...
// Restore the device context settings
pDC->RestoreDC(nDCSave);
br.DeleteObject();
Now it is time to draw the individual rectangles. This is accomplished by drawing a colored rectangle and then drawing a black outline around it. We now have to create a brush object to do the outline. The type of brush we are creating, HOLLOW_BRUSH, is called hollow because when we draw a rectangle MFC will want to fill in the middle with some pattern. We don't want this so we'll use a hollow brush so that the previously drawn colored rectangle will show through. Creating a brush allocates GDI memory that we have to later delete so that we don't leak GDI resources.
// Draw the squares
for(int row = 0; row < pDoc->GetRows(); row++)
{
for(int col = 0; col < pDoc->GetColumns(); col++)
{
// Get the color for this board space
clr = pDoc->GetBoardSpace(row, col);
// Calculate the size and position of this space
CRect rcBlock;
rcBlock.top = row * pDoc->GetHeight();
rcBlock.left = col * pDoc->GetWidth();
rcBlock.right = rcBlock.left + pDoc->GetWidth();
rcBlock.bottom = rcBlock.top + pDoc->GetHeight();
// Fill in the block with the correct color
pDC->FillSolidRect(&rcBlock, clr);
// Draw the block outline
pDC->Rectangle(&rcBlock);
}
}
The nested for loops are very simple, they iterate row by row, column by column, getting the color of the corresponding board space from the document using the GetBoardSpace function from the document, calculating the size of the rectangle to color and then drawing the block. The drawing uses two functions, FillSolidRect() to fill in the colored portion of the block and Rectangle() to draw the outline of the block. This is what draws all of the blocks in the client area of our view.
The last function that we've inserted into the view is one to resize the window based on the dimensions of the game board. In later articles we'll allow the user to change the number of blocks and size of the blocks so this function will come in handy then too. Again we start by getting a pointer to the document followed by getting the size of the current client area and the current window.
// Get the size of the client area and the window
CRect rcClient, rcWindow;
GetClientRect(&rcClient);
GetParentFrame()->GetWindowRect(&rcWindow);
Finding the difference between these two gives us the amount of space used by the title bar, menu and borders of the window. We can then add the differences back onto the size of the desired client area (# of blocks by # of pixels per block) to get the new window size.
// Calculate the difference
int nWidthDiff = rcWindow.Width() - rcClient.Width();
int nHeightDiff = rcWindow.Height() - rcClient.Height();
// Change the window size based on the size of the game board
rcWindow.right = rcWindow.left +
pDoc->GetWidth() * pDoc->GetColumns() + nWidthDiff;
rcWindow.bottom = rcWindow.top +
pDoc->GetHeight() * pDoc->GetRows() + nHeightDiff;
// The MoveWindow function resizes the frame window
Finally the GetParentFrame function returns a pointer to the CMainFrame class that is the actual window for our game and we resize the window by calling MoveWindow.
GetParentFrame()->MoveWindow(&rcWindow);
Your game should now look similar to this:
Conclusion
Aucun commentaire:
Enregistrer un commentaire