海边沫沫

相濡以沫,不如相忘于江湖
posts - 9, comments - 113, trackbacks - 0, articles - 0
  C++博客 :: 首页 :: 新随笔 :: 联系 :: 聚合  :: 管理

写个小游戏练一练手

Posted on 2007-12-16 12:10 海边沫沫 阅读(7025) 评论(14)  编辑 收藏 引用 所属分类: 高起点C++学习之路
学习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类及其派生类,它们分别代表了游戏中可以移动和旋转的各种类型的方块,如下图:
Sprites.jpg

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的网格,如下图:
GameBoard.jpg

这设计的时候,我将这个网格设计为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在窗口上画图了。最终游戏效果如下图:
RussiaBlock.jpg

这里是我的源代码,使用VS 2008可以直接打开。欢迎大家探讨。

Feedback

# re: 写个小游戏练一练手  回复  更多评论   

2007-12-16 20:07 by TD
Very good!
很详细,设计的也很好,博主很棒!

鸡蛋里面挑点骨头试试~~
1.关于bool值的判断:m_isGameOver 本身就是bool值,使用m_isGameOver ==false当然没错,只是有点怪,你觉得如何?

2.程序中出现的一些常数,如Board的长和宽12*22,还有些别的,改成宏定义或者enum应该更好点,哪天觉得想改一下尺寸,会比较方便

3.线程同步的问题

# re: 写个小游戏练一练手  回复  更多评论   

2007-12-16 22:47 by 梦在天涯
强,看到这里越来越多的高手,超高兴,希望以后多交流奥!

# re: 写个小游戏练一练手  回复  更多评论   

2007-12-17 01:13 by Fox
我以前在TC里面写过一个,可惜后来源码丢了大半,一个执行档也不是最终版。
和大多数俄罗斯方块不同的地方在于,我对方块和面板的存储都是用位存储。

# re: 写个小游戏练一练手  回复  更多评论   

2007-12-17 01:14 by Fox
看看最近有没有时间,也用MFC实现过来

# re: 写个小游戏练一练手  回复  更多评论   

2007-12-17 09:24 by 绝对零度
在symbian上写过,我也是用位来存储的。

# re: 写个小游戏练一练手  回复  更多评论   

2007-12-17 10:29 by 崔友志
博主真厚道 愿更上一层楼!

# re: 写个小游戏练一练手  回复  更多评论   

2007-12-17 12:44 by cpp
用地图数据应该是比较方便的,我用的是bool类型的地图,方块信息用十进制数据,用的时候转化为二进制。当时写的时候没有用面向对象的思想,数据结构用的都是全局变量。扩展性还可以,代码写得就有点乱了。Cppblog上也是高手如云。

# re: 写个小游戏练一练手  回复  更多评论   

2007-12-17 13:37 by 海边沫沫
@TD

你说得不错,游戏中确实需要考虑线程同步问题。首先是多个线程访问同一个变量的时候,最好能加入竞争机制。在我这个游戏中,就没有对m_GameActionQue的访问进行控制,不过两个线程在尾部写,一个线程在头部读,倒没有出现问题。

其次是这里
if(pDlg->m_GameActionQue.empty())
{
::Sleep(100);
}
我在一个循环中不断进行判断队列是否为空,这种做法虽然可行,但是不好,因为在队列为空的时候不断循环判断很占用CPU时间。正确的做法应该是调用WaitForSingleObject使线程阻塞起来,当别的线程向队列中添加消息的时候解除阻塞。

# re: 写个小游戏练一练手  回复  更多评论   

2007-12-17 13:49 by 秦歌
强人!

# re: 写个小游戏练一练手  回复  更多评论   

2007-12-18 21:20 by DeathKnight
赞一个 设计很简洁明快 我喜欢

# re: 写个小游戏练一练手  回复  更多评论   

2007-12-19 18:33 by 柯南
不错,很有意思

# re: 写个小游戏练一练手  回复  更多评论   

2007-12-19 19:13 by 海边沫沫
我今天稍微把游戏修改了一下,不用
if(pDlg->m_GameActionQue.empty())
{
::Sleep(100);
}
了,而是在对话框类中加入了一个CEvent类的变量m_event

然后,读取队列的时候使用
if(pDlg->m_GameActionQue.empty())
{
pDlg->m_event.Lock();
}
而在其他写入队列的时候调用
m_event.SetEvent();

这样修改之后,游戏对键盘的响应就基本上没有延时了,而且占用CPU要下降不少。

# re: 写个小游戏练一练手  回复  更多评论   

2007-12-20 13:53 by 秦歌
厉害

# re: 写个小游戏练一练手  回复  更多评论   

2009-04-23 19:13 by 向往
不错,不错,看起来有些老练.继续加油.

只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   博问   Chat2DB   管理