上一章里简单介绍了元素/场景结构,接下来两章将会用这个架构来制作一个完整的程序------贪吃蛇,相信大家都玩过,至少知道这个游戏,在智能手机出现前,这个游戏几乎是所有手机的标配游戏,而今天将会介绍下如何利用前面介绍的元素/场景架构来实现这个程序,首先看下程序的大概样子
先简单的分析下游戏的结构,最主要的是蛇和食物,这里绿色的蛇使用QGraphicsRectItem,而绿色的蛇使用的是QGraphicsPathItem,这两个元素被放置于场景中,即QGraphicsScene中,然后用QGraphicsView显示出来,我们看到的这个程序实际上就是QGraphicsView,这几个元素构成了程序的主体,剩下的就是地图上的网格(细线),这里一个办法是使用设置QGraphicsScene的背景,另外一种方法就是我这里采用的QGraphicsLineItem,两种方法纯属个人洗好,还有就是地图四周的“墙”,这些墙都是QGraphicsRectItem,这些墙把地图围了起来。
在了解了游戏的结构后就是制作的顺序了
1 确定地图和显示器,即QGraphicsScene和QGraphicsView
2 在地图准确的位置上放置QGraphicsLineItem形成地图上的网格
3 在地图四周放置QGraphicsRectItem形成地图上的墙
4 在地图上放置蛇和食物
5 设置蛇的功能,能被按键操作运动方向
6 完成游戏的判断,如迟到食物后蛇边长,同时出现新的食物,吃到自己或撞到墙后游戏结束
明确了游戏的制作顺序后就可以开始码了。。。。。这个游戏中需要用到一个结构和一个枚举
struct GridPoint
{
int x;
int y;
bool operator==(const GridPoint& p){ return (p.x == x && p.y == y); }
};
enum MoveDirction{GoUp,GoDown,GoLeft,GoRight};
MoveDirction是个枚举量,蛇的运动方向使用这个枚举值设定,略显特殊的是GridPoint这个结构(你喜欢的话也可以称为类),注意上面游戏截图的蛇的身体是有诺干个格子构成的,而每个格子都具有一个坐标,也就是这个GridPoint,这些坐标被放进一个QList里用于记录蛇的身体
首先是头文件
class Snake : public QDialog
{
Q_OBJECT
private:
MoveDirction currentDirction_enum; //蛇的运动方向
QGraphicsView* gameView_GraphicsView; //游戏地图和显示
QGraphicsScene* gameMap_GraphicsScene;
QGraphicsRectItem* food_GraphicsRectItem; //食物
QGraphicsPathItem* snake_GraphicsPathItem; //蛇
QTimer* clock_Timer; //计时器
QList<GridPoint> snakePath_List; //蛇身体的坐标
void createGameMap(); // 这些私有函数用于实现游戏的功能,设置地图网格,放置墙,食物,等等
void createWall();
void putFood(int x = 5, int y = 15);
void setSnakeShape(const QList<GridPoint>& newSnakePath = QList<GridPoint>());
public:
Snake(QWidget *parent = 0);
~Snake();
protected:
void keyPressEvent(QKeyEvent* event);
signals:
void gameLost();
void eatFood();
private slots:
void movingSnake(); //这些槽用于游戏时候的判断,移动蛇,食物等等
void movingFood();
void gameOver();
};
这个类的成员变量的含义见注释,其中clock_Timer用于计时,QTimer类有个信号timeout(),这个信号每个一段事件就会发射,通过setInterval()函数可以设定信号发射的间隔,通过start()和stop()函数可以控制信号开始/停止发射信号,通过这个计时器可以很方便的控制蛇的运动
接下来是这个类的构造函数
const int MAP_COUNT_SNAKE = 20; //地图由各自组成,这个值表示格子的数量,值为20,表示地图由20X20个格子组成
const int MAP_SIZE_SNAKE = 20; //每个格子的尺寸均为QSize(MAP_SIZE_SNAKE,MAP_SIZE_SNAKE)
const int MOVE_SPEED_SNAKE = 700; //设默认移动速度,单位为毫秒
Snake::Snake(QWidget *parent)
: QDialog(parent)
{
currentDirction_enum = GoLeft; //确定蛇其实的运动方向是向左边
gameView_GraphicsView = new QGraphicsView; //设置地图和显示器
gameMap_GraphicsScene = new QGraphicsScene;
clock_Timer = new QTimer(this);
gameView_GraphicsView->setScene(gameMap_GraphicsScene);
gameMap_GraphicsScene->setSceneRect(-3, -3, 406, 406); //在放置墙的函数里在一起介绍为什么显示的区域会这么奇葩
food_GraphicsRectItem = NULL; //食物和蛇的放置会有专门的函数来完成,这里先把食物对象设为NULL,把蛇的颜色设为绿色
snake_GraphicsPathItem = new QGraphicsPathItem;
snake_GraphicsPathItem->setBrush(QBrush(QColor(Qt::green)));
gameMap_GraphicsScene->addItem(snake_GraphicsPathItem);
clock_Timer->setInterval(MOVE_SPEED_SNAKE); //设定定时器发射信号的间隔为700毫秒,也就是说蛇每隔0.7秒就移动一次
clock_Timer->start();
createGameMap();
createWall();
putFood();
setSnakeShape();
QHBoxLayout* main_Layout = new QHBoxLayout;
main_Layout->addWidget(gameView_GraphicsView);
setLayout(main_Layout);
main_Layout->setSizeConstraint(QLayout::SetFixedSize);
connect(clock_Timer, SIGNAL(timeout()), this, SLOT(movingSnake()));
connect(this, SIGNAL(eatFood()), this, SLOT(movingFood()));
connect(this, SIGNAL(gameLost()), this, SLOT(gameOver()));
}
构造函数略显长但并不复制,首先,确定的蛇初始移动方向,然后设置了生产了地图和显示,并确定了显示的区域坐标(一个略显奇葩的坐标),接下来是设定食物,蛇对象的初始化,并开启了计时器。然后几个私有函数的功能可以从他们的名字就能看出来,实在看不出来的可以翻下上面的游戏制作步骤
接下来逐个看下每隔函数/槽的具体实现
void Snake::createGameMap()
{
for (int i = 1; i < MAP_COUNT_SNAKE ; ++i)
{
QGraphicsLineItem* hItem = new QGraphicsLineItem(0, i*MAP_SIZE_SNAKE, MAP_COUNT_SNAKE*MAP_SIZE_SNAKE, i*MAP_SIZE_SNAKE);
QGraphicsLineItem* vItem = new QGraphicsLineItem(i*MAP_SIZE_SNAKE, 0, i*MAP_SIZE_SNAKE, MAP_COUNT_SNAKE*MAP_SIZE_SNAKE);
gameMap_GraphicsScene->addItem(hItem);
gameMap_GraphicsScene->addItem(vItem);
}
gameMap_GraphicsScene->setBackgroundBrush(QBrush(QColor(Qt::gray)));
}
这个创建地图函数作用是在准确的位置放置QGraphicsLineItem.视觉效果就是在一个空白的地图上画上网格,其中地图网格的数量和每个网格的大小都使用了常量表示方便以后可能的修改,由于整个游戏都不需要用到这些QGraphicsLineItem,所以就直接在这里生产而不是把他们作为类的成员变量。前面说过形成网格也可以利用QGraphicsScen,通过设置他的背景来达到同样的效果。
void Snake::createWall()
{
QGraphicsRectItem* upWall_GraphicsRectItem = new QGraphicsRectItem(0, -5, MAP_COUNT_SNAKE*MAP_SIZE_SNAKE, 5);
QGraphicsRectItem* downWall_GraphicsRectItem = new QGraphicsRectItem(0, MAP_COUNT_SNAKE*MAP_SIZE_SNAKE, MAP_COUNT_SNAKE*MAP_SIZE_SNAKE, 5);
QGraphicsRectItem* leftWall_GraphicsRectItem = new QGraphicsRectItem(-5, -5, 5, MAP_COUNT_SNAKE*MAP_SIZE_SNAKE + 10);
QGraphicsRectItem* rightWall_GraphicsRectItem = new QGraphicsRectItem(MAP_COUNT_SNAKE*MAP_SIZE_SNAKE, -5, 5, MAP_COUNT_SNAKE*MAP_SIZE_SNAKE + 10);
gameMap_GraphicsScene->addItem(upWall_GraphicsRectItem);
gameMap_GraphicsScene->addItem(downWall_GraphicsRectItem);
gameMap_GraphicsScene->addItem(leftWall_GraphicsRectItem);
gameMap_GraphicsScene->addItem(rightWall_GraphicsRectItem);
upWall_GraphicsRectItem->setBrush(QColor(Qt::black));
downWall_GraphicsRectItem->setBrush(QColor(Qt::black));
leftWall_GraphicsRectItem->setBrush(QColor(Qt::black));
rightWall_GraphicsRectItem->setBrush(QColor(Qt::black));
}
这个是创建“墙”的函数,前面提到显示区域坐标略显奇葩,地图由20X20个格子构成,每个格子的长宽都是20,换句话说,地图的大小应该是400X400,那正常情况显示这个区域的话就把显示区域坐标设为(0,0,400,400)但由于游戏需要在地图的四周放置“墙”,为了把地图四周的墙也显示出来,所以显示的区域就扩大了,从函数里看出墙的厚度是5,所以显示区域我设为(-3,-3,406,406),当然如果你喜欢的话也可以设为(-5,-5,410,410)这样只是墙看起来比较厚点
void Snake::putFood(int x, int y)
{
if (x < 0 || x >=MAP_COUNT_SNAKE || y < 0 || y>=MAP_COUNT_SNAKE)
return;
if (food_GraphicsRectItem == NULL)
{
food_GraphicsRectItem = new QGraphicsRectItem(0, 0, MAP_SIZE_SNAKE , MAP_SIZE_SNAKE);
food_GraphicsRectItem->setBrush(QBrush(QColor(Qt::yellow)));
gameMap_GraphicsScene->addItem(food_GraphicsRectItem);
}
food_GraphicsRectItem->setPos(x*MAP_SIZE_SNAKE, y*MAP_SIZE_SNAKE);
}
放置食物函数,这个函数参数就是食物出现的坐标,在放置前首先要判断下坐标是否位于地图只能,如果位于地图外面,则该函数什么都不做,另外在构造函数里食物被初始化为NULL,所以这里生产前需要判断下是否是NULL,如果不是,说明食物已经存在,那这个函数就不需要另外创建新的食物,而只需要把食物移动到制定的坐标即可,这里使用QGraphicsItem的函数setPos(int,int)就可以把元素放在制定的地图坐标上
void Snake::setSnakeShape(const QList<GridPoint>& newSnakePath)
{
if (newSnakePath.isEmpty()) //生产默认的蛇
{
for (int i = 0; i < 5; ++i)
{
GridPoint p;
p.x = 10+i;
p.y = 10;
snakePath_List.append(p);
}
}
else
snakePath_List = newSnakePath;
QPainterPath paths; //生成蛇的“区域”
for (auto A : snakePath_List)
{
paths.addRect(A.x*MAP_SIZE_SNAKE, A.y*MAP_SIZE_SNAKE, MAP_SIZE_SNAKE, MAP_SIZE_SNAKE);
}
snake_GraphicsPathItem->setPath(paths);
snake_GraphicsPathItem->update();
}
这里先要说明下游戏地图坐标的计算方式,游戏地图是有MAX_COUNT_SNAME*MAX_COUNT_SNAME个格子构成,这里MAX_COUNT_SNAME常量的值设为20,然后格子的大小是MAP_SIZE_SNAKE*MAP_SIZE_SNAKE;其中左上角的格子坐标为(0,0),右下角格子的坐标为(19,19),然后左上角格子所在区域为(x,y,width,height),即(0,0,MAX_COUNT_SNAME,MAX_COUNT_SNAME),右下角格子所在区域为(19,19,MAX_COUNT_SNAME,MAX_COUNT_SNAME),而格子的区域大小,也就是(x,y,width,height),则刚好可以用QRectF来表示
PS:Qt中表示规则区域通常用QRectF来表示,这个类记录一个区域就采用(x,y,width,height)这种形式
在明确了坐标表示后就可以看下这个设置蛇的函数,该函数的参数是一个坐标的链表,前面提到,蛇的身体是诺干个格子构成的,那外面只要有这些格子的坐标就可以生成蛇了,首先判断下参数是不是为空,如果参数为空,说明这是游戏起始,需要额外给一个默认的值,有了这些(连续的)坐标后,可以把这些坐标转化为QPainterPath类了,这个类是一个用于表示不规则的复制区域的类,以我们的这个蛇为例,蛇的身体形状比较复制,但可以肯定的是他是由连续的方块(QRectF)构成的,而我们只要知道一个坐标,假如这个坐标是(1,1),那这个坐标所代表的QRectF就是(1*MAX_COUNT_SNAME,1*MAX_COUNT_SNAME,MAX_COUNT_SNAME,MAX_COUNT_SNAME),然后把诺干个QRectF按顺序添加到QPainterPath类里面,我们就能得到蛇的外形,而蛇使用QGraphicsPathItem类来表示,使用他的setPath(const QPainterPath&)就可以设置蛇的新的外形,最后由于外形改变了,调用update()函数刷新下,使得新形状能够显示
void Snake::keyPressEvent(QKeyEvent* event)
{
switch (event->key())
{
case Qt::Key_W:
{
if (currentDirction_enum != GoDown)
currentDirction_enum = GoUp;
break;
}
case Qt::Key_S:
{
if (currentDirction_enum != GoUp)
currentDirction_enum = GoDown;
break;
}
case Qt::Key_A:
{
if (currentDirction_enum != GoRight)
currentDirction_enum = GoLeft;
break;
}
case Qt::Key_D:
{
if (currentDirction_enum != GoLeft)
currentDirction_enum = GoRight;
break;
}
default:
break;
}
}
这个游戏铜鼓键盘W-S-A-D来控制蛇的移动方向,这里重写了类的按键事件是的程序可以通过按键来控制蛇的移动方向,而蛇不能反向移动,所以在改变之前需要判断下,比如蛇当前移动方向是向上,这时候按了S键希望蛇直接向下移动,但程序会会略这次操作
void Snake::movingSnake()
{
GridPoint snakeHead = snakePath_List.first(); //在蛇前面添加一个格子
int X = snakeHead.x;
int Y = snakeHead.y;
if (currentDirction_enum == GoUp)
--Y;
else if (currentDirction_enum == GoDown)
++Y;
else if (currentDirction_enum == GoLeft)
--X;
else if (currentDirction_enum == GoRight)
++X;
snakeHead.x = X;
snakeHead.y = Y;
if (X < 0 || X >= MAP_COUNT_SNAKE || Y < 0 || Y >= MAP_COUNT_SNAKE)//通过坐标添加格子后判断是否撞墙
{
emit gameLost();
return;
}
if (snakePath_List.contains(snakeHead)) //判断是否吃到自己
{
emit gameLost();
return;
}
if (snakeHead.x*MAP_SIZE_SNAKE == food_GraphicsRectItem->pos().x() && snakeHead.y*MAP_SIZE_SNAKE == food_GraphicsRectItem->pos().y()) //吃到食物
emit eatFood();
else
snakePath_List.pop_back(); //如果没撞墙/吃到自己/吃到食物,则删掉最后一个格子
snakePath_List.push_front(snakeHead);
setSnakeShape(snakePath_List);
}
该函数/槽同定时器的timeOut()信号连接,也就是说这个函数每个0,7秒执行一次,而这个函数的作用就是移动蛇,蛇的移动采用在前面添加一个格子(的坐标),然后删除最后一个格子(的坐标)这种形式,首先是添加格子,这里需要用判断下蛇的移动方向,例如向左,新格子就出现在原先第一个格子的坐标,由于蛇不能向后移动,但移动方向的判断在按键事件函数里已经做过了,所以这里不需要再次判断了
在最前端添加了一个格子后,需要判断下,有无 撞墙/吃自己/吃到食物的情况,这里这些情况都可以通过坐标来判断,如果撞墙或吃到自己则游戏失败,这里会发射一个gameLost()信号,而如果吃到食物的话则会发射eatFood()信号通知程序放置新的食物,注意这里使用的if--else语句,如果吃到食物的话是不会删除最后一个格子的,这样的话蛇每次吃到食物就会边长一格
void Snake::movingFood()
{
qsrand(QTime(0, 0, 0).secsTo(QTime::currentTime()));
GridPoint p;
while (true)
{
p.x = qrand() % 20;
p.y = qrand() % 20;
if (!(snakePath_List.contains(p)))
break;
}
putFood(p.x, p.y);
}
当蛇吃到食物后会发射信号eatFood()信号,而这个信号则会同movingFood()相连接,放蛇吃到食物是,食物会在地图上消失并出现在地图的另一个位置,而使用移动食物就可以实现这个功能而不需要每次删除一个QGraphicsRectItem对象然后再次创建他,所以这里函数取名movingFood(),这个函数采用Qt自带的随机数生产2个在0-19直接的整数作为新的食物的坐标,但生产坐标后需要判断下这个坐标是否位于蛇的身体内,如果是,则需要重新生产,这里通过QList的成员函数contains()来判断新生成的坐标是否位于蛇身坐标只能(即新坐标等于蛇身坐标中的某一个),而这个contains()函数对比的画需要用到链表内元素的运算符==,这也是最开始的GridPoint结构为什么要添加一个==运算符函数的原因
void Snake::gameOver()
{
clock_Timer->stop();
}
当蛇吃到自己或撞墙的时候会发射gameLost()信号,该信号回合gameOver()槽相连接,这个槽也很简单,停止计时器,计时器停止后就不会发射信号,这样蛇就停止运动了
完整代码
https://pan.baidu.com/s/1bSXJoa