学习C++的最好方式,就是使用C++来做项目。然而,我手中并没有需要使用C++的工作,咋办?只好自己写个小游戏练练手了。
我选择的游戏是俄罗斯方块,之所以选择它,是因为它简单,简单到对于很多高手来讲,实现这样一个游戏,他们几乎只需要一个.cpp源文件就够了。然而我认为,优雅的代码来自于完善的设计。下面,我把自己设计该游戏的过程写下来,欢迎大家探讨。
首先,是选择用户界面。我选择的是一个基于对话框的MFC项目。对话框在这里有两个作用,一个是作为显示游戏画面的画布,另一个就是用来接受用户的键盘输入。MFC的CDialog类对键盘输入做了一些预处理,因此在OnKeyDown中根本捕获不到上下左右四个方向键的按键动作,因此,必须重写PreTranslateMessage函数,如下:
BOOL CRussiaBlockDlg::PreTranslateMessage(MSG* pMsg)
{
//return CDialog::PreTranslateMessage(pMsg);
return FALSE;
}
其次,是考虑游戏运行的方式。在这里,我使用了一个队列和两个辅助线程。队列是干什么的呢?是用来保存游戏动作的。在用户按下按键之后,对话框的OnKeyDown将用户的按键翻译为游戏动作,然后依次添加到队列的尾部,然后,在另外的线程中,通过读取该队列的头部,就知道接下来游戏该如何进行了。两个辅助线程,其中一个就是游戏线程,该线程从队列中读取游戏动作,并进行游戏,另外一个辅助线程是干嘛的呢?很简单,就是定时往队列中投递DOWN动作。
先定义一个枚举类型,如下:
enum GameAction{LEFT,RIGHT,ROTATE,DOWN,RESTART};
然后,在对话框类中添加一个用于保存游戏动作的队列,如下:
deque<GameAction> m_GameActionQue; 这时,对话框类的OnKeyDown函数看起来是这样的:
void CRussiaBlockDlg::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags)
{
if((nFlags & 0x7F) == 57) //空格键开始
{
if(m_isRunning == true)
{
m_GameActionQue.push_back(RESTART);
}else
{
m_isRunning = true;
this->Invalidate();
AfxBeginThread(GameThreadFunc,NULL);
AfxBeginThread(TimerThreadFunc,NULL);
}
}else if((nFlags & 0x7F) == 75 && m_isRunning == true) //左方向键
{
m_GameActionQue.push_back(LEFT);
}else if((nFlags & 0x7F) == 72 && m_isRunning == true) //上方向键
{
m_GameActionQue.push_back(ROTATE);
}else if((nFlags & 0x7F) == 77 && m_isRunning == true) //右方向键
{
m_GameActionQue.push_back(RIGHT);
}else if((nFlags & 0x7F) == 80 && m_isRunning == true) //下方向键
{
m_GameActionQue.push_back(DOWN);
}
CDialog::OnKeyDown(nChar, nRepCnt, nFlags);
}
这时,对话框类基本上已经完成了自己的使命,剩下的工作只是在OnPaint函数中适当的绘制一些提示信息而已。工作的重点进入到线程函数GameThreadFunc中。在该线程中,游戏的进行和几个类密切相关,它们分别是GameBoard类和Sprite类及Sprite的派生类。
先来看Sprite类及其派生类,它们分别代表了游戏中可以移动和旋转的各种类型的方块,如下图:
Sprite类的定义如下:
class Sprite
{
public:
Sprite(void);
~Sprite(void);
int x;
int y;
int tiles[4][4];
void left(void);
void right(void);
void down(void);
virtual void rotate(void);
};
可以看到,我使用x,y来代表Sprite左上角的坐标,使用一个二维数组来表示Sprite的形状,这Sprite的派生类中,只有该二维数组的值不同,rotate函数的实现不同,所以基类的rotate函数是虚函数。
为了创建Sprite对象,还用到了Factory模式,我使用了类SpriteFactory,其GetSprite函数如下:
Sprite* SpriteFactory::GetSprite(void)
{
switch(std::rand() % 7)
{
case 0:
return new ISprite();
case 1:
return new LSprite();
case 2:
return new SSprite();
case 3:
return new ZSprite();
case 4:
return new PSprite();
case 5:
return new OSprite();
case 6:
return new TSprite();
}
return NULL;
}
另外一个类GameBoard,代表的是游戏的主界面,其主要结构是个10*20的网格,如下图:
这设计的时候,我将这个网格设计为12*22的,周围的这一圈,在显示的时候是不可见的,之所以要这一圈存在,是为了方便碰撞检测,不让Sprite跑出界。GameBoard类的定义如下:
class GameBoard
{
public:
GameBoard(void);
~GameBoard(void);
int tiles[22][12];
int m_score;
int m_speed;
Sprite* m_pSprite;
void sprite_left(void);
void sprite_right(void);
void sprite_rotate(void);
void sprite_down(void);
void clear(void);
void combine(void);
bool collide(Sprite* sprite);
void show(void);
};
其中的m_pSprite指针保存了一个Sprite对象,分别提供四个函数指挥这个这个Sprite对象向上向下向右移动和旋转,在每次移动和旋转之前,都需要进行碰撞检测,函数collide提供碰撞检测的功能。如果Sprite对象下降到底部之后,就应该调用combine函数将这个Sprite对象的tiles数组的数据加入到GameBoard的tiles数组中,并创建另外一个Sprite对象。而clear函数的作用,主要是在一局游戏Game Over后开始下一局时,清空GameBoard。
有了这几个类,游戏线程看起来就是这样的了:
UINT GameThreadFunc(LPVOID pParam)
{
CRussiaBlockDlg* pDlg = dynamic_cast<CRussiaBlockDlg*>(::AfxGetApp()->GetMainWnd());
CClientDC dc(pDlg);
if(pDlg->m_isGameOver == true)
{
pDlg->m_isGameOver = false;
pDlg->m_GameBoard.clear();
}
while(pDlg->m_isRunning)
{
pDlg->m_GameBoard.show();
if(pDlg->m_GameActionQue.empty())
{
::Sleep(100);
}
else
{
switch(pDlg->m_GameActionQue.front()){
case RESTART:
pDlg->m_isRunning = false;
pDlg->Invalidate();
break;
case LEFT:
pDlg->m_GameBoard.sprite_left();
break;
case RIGHT:
pDlg->m_GameBoard.sprite_right();
break;
case DOWN:
pDlg->m_GameBoard.sprite_down();
break;
case ROTATE:
pDlg->m_GameBoard.sprite_rotate();
break;
}
pDlg->m_GameActionQue.pop_front();
}
}
return 0;
}
剩下的事情,就是怎样去操作数组,怎么样去调用GDI在窗口上画图了。最终游戏效果如下图:
这里是我的源代码,使用VS 2008可以直接打开。欢迎大家探讨。