下面我们继续上一章的内容。在上一章中,我们已经完成了地图的设计,当然是相当简单的。在我们的游戏中,另外的主角便是蛇和食物。下面我们便开始这部分的开发。
我们的地图是建立在QGraphicsScene
的基础之上的,所以,里面的对象应该是QGraphicsItem
实例。通常,我们会把所有的图形元素(这里便是游戏中需要的对象,例如蛇、食物等)设计为QGraphicsItem
的子类,在这个类中添加绘制自身的代码以及动画逻辑。这也是面向对象的开发方式:封装自己的属性和操作。在我们的游戏中,应该有三个对象:蛇 Snake、食物 Food 以及墙 Wall。
我们从食物开始。因为它是最简单的。我们将其作为一个红色的小圆饼,大小要比地图中的一个方格要小,因此我们可以将其放置在一个方格中。正如上面分析的那样,我们的Food
类需要继承QGraphicsItem
。按照接口约束,QGraphicsItem
的子类需要重写至少两个函数:boundingRect()
和paint()
。
boundingRect()
返回一个用于包裹住图形元素的矩形,也就是这个图形元素的范围。需要注意的是,这个矩形必须能够完全包含图形元素。所谓“完全包含”,意思是,在图形元素有动画的时候,这个矩形也必须将整个图形元素包含进去。如果范围矩形过小。图形会被剪切;如果范围矩形过大,就会影响性能。
paint()
的作用是使用QPainter
将图形元素绘制出来。
下面是 food.h 和 food.cpp 的内容:
////////// food.h ////////// #ifndef FOOD_H #define FOOD_H #include <QGraphicsItem> class Food : public QGraphicsItem { public: Food(qreal x, qreal y); QRectF boundingRect() const; void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *); QPainterPath shape() const; }; #endif // FOOD_H ////////// food.cpp ////////// #include <QPainter> #include "constants.h" #include "food.h" static const qreal FOOD_RADIUS = 3; Food::Food(qreal x, qreal y) { setPos(x, y); setData(GD_Type, GO_Food); } QRectF Food::boundingRect() const { return QRectF(-TILE_SIZE, -TILE_SIZE, TILE_SIZE * 2, TILE_SIZE * 2 ); } void Food::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) { painter->save(); painter->setRenderHint(QPainter::Antialiasing); painter->fillPath(shape(), Qt::red); painter->restore(); } QPainterPath Food::shape() const { QPainterPath p; p.addEllipse(QPointF(TILE_SIZE / 2, TILE_SIZE / 2), FOOD_RADIUS, FOOD_RADIUS); return p; }
虽然这段代码很简单,我们还是有必要解释一下。构造函数接受两个参数:x 和 y,用于指定该元素的坐标。setData()
函数是我们之后要用到的,这里简单提一句,它的作用为该图形元素添加额外的数据信息,类似于散列一样的键值对的形式。boundingRect()
简单地返回一个QRect
对象。由于我们的元素就是一个圆形,所以我们返回的是一个简单的矩形。注意,这个矩形的范围实际是四倍于实际区域的:以元素坐标 (x, y) 为中心,边长为TILE_SIZE * 2
的正方形。我们还重写了shape()
函数。这也是一个虚函数,但是并不是必须覆盖的。这个函数返回的是元素实际的路径。所谓路径,可以理解成元素的矢量轮廓线,就是QPainterPath
所表示的。我们使用addEllipse()
函数,添加了一个圆心为 (TILE_SIZE / 2, TILE_SIZE / 2),半径 FOOD_RADIUS 的圆,其范围是左上角为 (x, y) 的矩形。由于设置了shape()
函数,paint()
反而更简单。我们所要做的,就是把shape()
函数定义的路径绘制出来。注意,我们使用了QPainter::save()
和QPainter::restore()
两个函数,用于保存画笔状态。
现在我们有了第一个图形元素,那么,就让我们把它添加到场景中吧!对于一个游戏,通常需要有一个中心控制的类,用于控制所有游戏相关的行为。我们将其取名为GameController
。
GameController
的工作是,初始化场景中的游戏对象,开始游戏循环。每一个游戏都需要有一个游戏循环,类型于事件循环。想象一个每秒滴答 30 次的表。每次响起滴答声,游戏对象才有机会执行相应的动作:移动、检查碰撞、攻击或者其它一些游戏相关的活动。为方便起见,我们将这一次滴答成为一帧,那么,每秒 30 次滴答,就是每秒 30 帧。游戏循环通常使用定时器实现,因为应用程序不仅仅是一个游戏循环,还需要响应其它事件,比如游戏者的鼠标键盘操作。正因为如此,我们不能简单地使用无限的 for 循环作为游戏循环。
在 Graphics View Framework 中,每一帧都应该调用一个称为advance()
的函数。QGraphicsScene::advance()
会调用场景中每一个元素自己的advance()
函数。所以,如果图形元素需要做什么事,必须重写QGraphicsItem
的advance()
,然后在游戏循环中调用这个函数。
GameController
创建并开始游戏循环。当然,我们也可以加入pause()
和resume()
函数。现在,我们来看看它的实现:
GameController::GameController(QGraphicsScene *scene, QObject *parent) : QObject(parent), scene(scene), snake(new Snake(this)) { timer.start(1000/33); Food *a1 = new Food(0, -50); scene->addItem(a1); scene->addItem(snake); scene->installEventFilter(this); resume(); }
GameController
的构造函数。首先开启充当游戏循环的定时器,定时间隔是 1000 / 33 毫秒,也就是每秒 30(1000 / 33 = 30)帧。GameController
有两个成员变量:scene 和 snake,我们将第一个食物和蛇都加入到场景中。同时,我们为GameController
添加了事件过滤器,以便监听键盘事件。这里我们先不管这个事件过滤器,直接看看后面的代码:
void GameController::pause() { disconnect(&timer, SIGNAL(timeout()), scene, SLOT(advance())); } void GameController::resume() { connect(&timer, SIGNAL(timeout()), scene, SLOT(advance())); }
pause()
和resume()
函数很简答:我们只是连接或者断开定时器的信号。当我们把这一切都准备好之后,我们把GameController
添加到MainWindow
中:
MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), game(new GameController(scene, this)) { ... }
由于GameController
在构造时已经开始游戏循环,因此我们不需要另外调用一个所谓的“start”函数。这样,我们就把第一个食物添加到了游戏场景:
接下来是有关蛇的处理。
蛇要更复杂一些。在我们的游戏中,蛇是由黄色的小方块组成,这是最简单的实现方式了。第一个是蛇的头部,紧接着是它的身体。对此,我们有两个必须面对的困难:
- 蛇具有复杂得多的形状。因为蛇的形状随着游戏者的控制而不同,因此,我们必须找出一个能够恰好包含蛇头和所有身体块的矩形。这也是 boundingRect() 函数所要解决的问题。
- 蛇会长大(比如吃了食物之后)。因此,我们需要在蛇对象中增加一个用于代表蛇身体长度的
growing
变量:当growing
为正数时,蛇的身体增加一格;当growing
为负数时,蛇的身体减少一格。 advance()
函数用于编码移动部分,这个函数会在一秒内调用 30 次(这是我们在GameController
的定时器中决定的)。
我们首先从boundingRect()
开始看起:
QRectF Snake::boundingRect() const { qreal minX = head.x(); qreal minY = head.y(); qreal maxX = head.x(); qreal maxY = head.y(); foreach (QPointF p, tail) { maxX = p.x() > maxX ? p.x() : maxX; maxY = p.y() > maxY ? p.y() : maxY; minX = p.x() < minX ? p.x() : minX; minY = p.y() < minY ? p.y() : minY; } QPointF tl = mapFromScene(QPointF(minX, minY)); QPointF br = mapFromScene(QPointF(maxX, maxY)); QRectF bound = QRectF(tl.x(), // x tl.y(), // y br.x() - tl.x() + SNAKE_SIZE, // width br.y() - tl.y() + SNAKE_SIZE //height ); return bound; }
这个函数的算法是:遍历蛇身体的每一个方块,找出所有部分的最大的 x 坐标和 y 坐标,以及最小的 x 坐标和 y 坐标。这样,夹在其中的便是蛇身体的外围区域。
shape()
函数决定了蛇身体的形状,我们遍历蛇身体的每一个方块向路径中添加:
QPainterPath Snake::shape() const { QPainterPath path; path.setFillRule(Qt::WindingFill); path.addRect(QRectF(0, 0, SNAKE_SIZE, SNAKE_SIZE)); foreach (QPointF p, tail) { QPointF itemp = mapFromScene(p); path.addRect(QRectF(itemp.x(), itemp.y(), SNAKE_SIZE, SNAKE_SIZE)); } return path; }
在我们实现了shape()
函数的基础之上,paint()
函数就很简单了:
void Snake::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) { painter->save(); painter->fillPath(shape(), Qt::yellow); painter->restore(); }
现在我们已经把蛇“画”出来。下一章中,我们将让它“动”起来,从而完成我们的贪吃蛇游戏。
59 评论
你好,有个疑问,Food的boundingRect()中为什么是4倍于实际区域,而不是9倍于实际区域的?按照我的理解,Food应该是可以上下左右移动,这样可能的区域应该9倍于本身啊?谢谢!
食物只是占有一个方格。之所以是位于四个方格的右下角,是因为这样程序写起来更方便,而不是上下左右移动的原因。
Hi,我发现boundingRect必须要重写。把food带入snake的boundingRect就是4个方格这样更容易理解对吧。
但是为什么加上一个SNAKE_SIZE,是不是因为第28节的梗啊?
```当绘制大于1个像素时,情况比较复杂:如果绘制像素是偶数,则实际绘制会包裹住逻辑坐标值;如果是奇数,则是包裹住逻辑坐标值,再加上右下角一个像素的偏移。```
不是左上角吗?
什么意思?
不是左上角,确实是右下角,Food中的boundingRect是这样定义的QRectF(-TILE_SIZE, -TILE_SIZE, TILE_SIZE * 2, TILE_SIZE * 2 ); 而Food中的shape是这样写的p.addEllipse(QPointF(TILE_SIZE / 2, TILE_SIZE / 2), FOOD_RADIUS, FOOD_RADIUS);也就是说在以x,y为左上角的顶点,TILE_SIZE*TILE_SIZE的矩形(坐标范围是从-TILE_SIZE到TILESIZE)中添加了一个圆,圆的坐标是(TILE_SIZE/2,TILE_SIZE/2)正好是矩形的右下角的中心点
给GameController安装过滤器时为什么调用scene的函数?过滤器到底是装在scene上的还是装在GameController上的?
scene->installEventFilter(scene);
// 或者
this->installEventFilter(this);
这样行不行?
首先,注意到我们的程序,过滤器是要作为 sence 的控制,监视的是 sence 接收到的键盘事件,因此过滤器是要安装在 sence 上面(这是过滤器安装的对象);第二,过滤器本身是由 controller 实现的,因此函数的参数是 this。总之,这是为一个对象(sence)安装另外一个对象(controller)实现的过滤器。
编译的时候提示了几个错误:
\moc_food.cpp:63: error: 'staticMetaObject' is not a member of 'QGraphicsItem'
moc_food.cpp:78: error: 'qt_metacast' is not a member of 'QGraphicsItem'
moc_food.cpp:83: error: 'qt_metacall' is not a member of 'QGraphicsItem'
这到底是什么原因呢?
晕,知道原因了。因为在类定义体内增加了Q_OBJECT
求问为什么加了Q_OBJECT就会编译错误啊!!!
不知道什么错误?
豆子大神,就是我在类的定义里面为什么不可以加Q_OBJECT?加了以后就是和这楼的一样的编译错误。
\moc_food.cpp:63: error: ‘staticMetaObject’ is not a member of ‘QGraphicsItem’
moc_food.cpp:78: error: ‘qt_metacast’ is not a member of ‘QGraphicsItem’
moc_food.cpp:83: error: ‘qt_metacall’ is not a member of ‘QGraphicsItem’
加过 Q_OBJECT 之后需要重新运行 qmake 生成 makefile 才可以。
在food 头文件 加的就出错 一样的去掉就好了 已经clean qmake过了
QRectF Food::boundingRect() const
{
return QRectF(-TILE_SIZE, -TILE_SIZE,
TILE_SIZE * 2, TILE_SIZE * 2 );
}
这个返回的是个常量的QRectF,但在游戏中,随机出现的FOOD都是同样的boundingRect吗?与FOOD出现的位置没有关系?没看出boundingRect()在程序中的作用,不是有shape()来检测碰撞吗
注意这个是 Food 类的函数,所以坐标系是 Food 为基准的,而不是使用的世界坐标。shape() 用来返回 Food 的轮廓线,boundingRect() 返回的是 Food 所占据的矩形区域,这是系统回调使用的,不是我们显式调用的。
意思是return QRectF(-TILE_SIZE, -TILE_SIZE, TILE_SIZE * 2, TILE_SIZE * 2 )中,左上角的点(-TILE_SIZE, -TILE_SIZE)是以food的坐标为原点(0,0)来计算的? 真是一语中的,解决了很多困惑.
怎样看出来boundingRect()函数是系统回调使用的?谢谢
“boundingRect() 返回的是 Food 所占据的矩形区域,这是系统回调使用的,不是我们显式调用的。”哪里能看出来是系统回调的?
表明看不出来是系统回调的,从文档和函数实现的功能以及你的经验确定吧。
1,timer.start(1000/33); 每秒30次不应该是timer.start(1000/30);?
2,在 Graphics View Framework 中,每一帧都应该调用一个称为 advance() 的函数。QGraphicsScene::advance() 会调用场景中每一个元素自己的 advance() 函数。所以,如果图形元素需要做什么事,必须重写 QGraphicsItem 的 advance(),然后在游戏循环中调用这个函数。
问:Graphics View Framework每秒到底多少帧,30?还是可以自己设定.其次,蛇和食物都没有重写advance, 而controller有advance但是没加入sence,所以不会被框架调用,所以采用了定时器调用.理解正确没?
1. 每秒 30 帧,每次就是 1000/30 毫秒,大约 33 毫秒,所以每秒 30 帧,timer 的间隔时间是 1000/33。
2. Graphics View Framework 没有帧的概念,是我们用定时器实现了帧,所以每秒多少帧取决于我们的定时器的设置,你那样理解应该也没有问题。
还是关于:timer.start(1000/33);
30=1000/33;
timer.start(30)表示每30毫秒触发一次定时器事件,那么每秒共触发33次.
timer.start(33)表示每33毫秒触发一次定时器事件,那么每次共触发30次.
还是我对定时器理解有误.新手来着.
豆子师兄,我是刚接触C++的学生,我们要求做个大作业,师兄们都推荐来这里学QT。我想做只桌面宠物。可是不懂哪些功能要用哪些知识。(宠物基本就是能养,时不时会说点话)(如果可以还想实现监测cpu使用情况、温度,换衣服,可以唤出日历模块之类的)
你做的东西挺复杂的,如果刚刚接触,并且时间允许的话,先把 C++ 了解下,然后看看 Qt 的基础,比如事件、信号槽之类。基于你的项目,个人感觉应该使用 Graphics View Framework 去实现。如果不限定一定使用 C++,QML 做起动画来会更简单一些(如果你的桌面宠物需要动画的话)。说话之类可以使用 Qt Multimedia 模块解决,这点不用担心。至于监测系统状况,大概只能调用系统 API 然后将数据显示出来,只要数据格式转换好,应该也不是很麻烦。
太棒了~~~回复很及时详细 😀 💡
void Food::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *)函数是重载的么,这个项目中没有调用这个函数,是怎么一个实现机理,本人是初学者...
又仔细看了下文章的前部分,明白是重写,以后有问题一定好好想想再问...assistant上说是被QGraphicsView调用的,我想是在QGraphicsItem实例加入到QGraphicsSence中时调用的,不知理解是否正确。
大致是这个样子,不过这个函数并不一定只调用一次,只要有需要就会被系统调用(这种不是有开发者主动调用的方式就叫“回调”),例如窗口大小改变、位置移动等
嗯,谢谢豆子先生 😛
运行了一下,boundingRect()是每次绘制都自动执行了。但是我看文档上说要改变bounding rectangle必须要先调用 prepareGeometryChange(),但是这里没调用。有什么区别吗
boundingRect在每次setPos的时候都会自动调用一下。不用prepareGeometryChange,那什么时候用prepareGeometryChange呢
个人理解,这里并没有改变 bounding rect,仅仅是定义,所以不需要调用 prepareGeometryChange()。如果你需要改变 bounding rect 的话,比如需要重新计算,则需要调用 prepareGeometryChange()
豆子你好,十分感谢你的文章,实在是太有帮助了,但是我有一点不是很明白在写resume()的函数的时候,为什么用如下形式的connect不能成功?
connect(&timer,&timer.timeout(),scene,&scene->advance());
注意你的 &timer.timeout() 和 &scene->advance() 并不是取的函数指针,而是取的返回值的引用。connect() 函数的新语法接受的是函数指针。
豆子你好,我对snake的重绘有些不大明白,shape函数里 path.addRect(QRectF(0, 0, SNAKE_SIZE, SNAKE_SIZE));第一次调用是在原点处绘制,然后以后每次调用shape时,又为什么都要在原点设置这个点呢?我一直搞不明白这里,shape函数是在每次重绘都调用呢,还是只调用一次?非常感谢你的文章,通俗易懂。
对于path.addRect(QRectF(0, 0, SNAKE_SIZE, SNAKE_SIZE));
我最初也有疑惑。你可以试一下把这一句注释掉,看一下有什么差别,就能知道这条语句有什么作用了。PS:感谢豆子老师。o(^▽^)o
我的理解,shape是每次重绘都会调用的, 每次调用path.addRect(QRectF(0, 0, SNAKE_SIZE, SNAKE_SIZE)),但并不是每次都会绘制这个点,因为boundingRect 会根据tail里的所有点限定绘画的范围。
大神,能不能把initscenebackground()这个函数的每行详细解释下,不是很明白呀。。
这段代码并不是很复杂。首先创建一个
QPixmap
对象,然后在其上面进行绘制,绘制的内容是边长为TILE_SIZE
的灰色正方形,然后以此图像创建一个画刷QBrush
,并将该画刷作为背景图案进行平铺。豆子哥您好,请问boundingRectF返回的矩形到底怎么理解?
您之前关于food的返回解释的是:以(x,y)为圆心。
但是从这次的参数来说 您使用了-TILE_SIZE, -TILE_SIZE, 作为左上角 也就是-10,-10为左上角。 20为长和宽 这样画出来 中心是0,0啊?
而且关于snake的boundingRectF的返回参数值也不是了解很清楚。
拜托讲解
感谢
前一个以 (x, y) 为圆心,指的是场景坐标,也就是在 scene 中的位置。后面的以 (0, 0) 为中心,指的是在元素自己的坐标系下。
snake 的 boundingRectF() 函数中,通过遍历每一个组成方块,找到最上、最下、最左、最右这四个边界值,返回的就是由这四个边界值组成的矩形。
豆子老师,初学者表示很痛苦,能把类的声明也帖出来吗,tail是什么类型?第31讲中给的链接也打不开
http://qtcollege.co.il/developing-a-qt-snake-game/代码可以从这上面下载的
首先开启充当游戏循环的定时器,定时间隔是 1000 / 33 毫秒,也就是每秒 30(1000 / 33 = 30)帧。
时间间隔为1000/33毫秒 = 1/33秒,每秒不应该是33帧吗?
博主在吗?求回答一下我的问题T_T
/home/tool/qt_pro/game/mainwindow.cpp:8: error: invalid use of incomplete type 'struct GameController',这个类的头文件是啥?
GameController 在源代码包是应该有的,完整的代码在最后一篇。
请问为什么要先painter->save()? 我为什么之前都没有弄就直接保存一下。。。,,还有就是注释掉painter->save() 和painter->restore() 发现程序仍然运行正常,那要这两个函数何用?
注意这里的 painter 指针是参数传进来的,也就是 painter 是系统回调时创建的,可能会被其它组件共用。这里是为了防止影响到其它组件的使用(考虑到 QPainter 是一个状态机),因此需要添加这两个语句。当然,因为我们的程序比较简单,没有类似情况,所以不会有影响,但是不能保证复杂情况下也没有影响。为谨慎起见,这里添加了保存状态的代码。
不知道别人看起来怎么样 反正我看了三十章是死撑过来的,很多东西没讲清楚啊!!!比如shape 函数 使用 QPainterPath 设置一个椭圆轮廓,然后再把这个轮廓传递给QPainter画出来,可是文章就一句话带过,完全没讲清楚它具体是怎样画出来的,怎样传递的参数,各个参数的意思。好吧 我比较菜
我是反复看了好多遍才理解的,看您的文章要一边看一边查手册
具体的函数参数需要自己去看文档吧,文中只是说了一个大概。我的看法是,这样的教程类文章只需要讲清楚实现的思路,具体的函数参数这样的细节需要自己查阅相关文档。
请问Snake::paint()函数在哪里调用??
我的理解是snake每走一步就应该调用一次paint()函数绘制啊,可是一直没看到在哪里调用。
paint() 函数是系统回调函数,会在系统“需要”的时候自动调用——这正是“回调”的含义——可能是在窗口大小改变的时候,可能是在最大化、最小化的时候。对于此处,paint() 函数只是给出了 Snake 如何绘制自身,具体的绘制(也就是实际调用),是在窗口显示的时候调用的。
你好,我还有个问题,pause()函数和resume()函数在例程里面好像没怎么用,我在
void GameController::handleKeyPressed(QKeyEvent *event)函数中添加了:
case Qt::Key_A:
pause();
case Qt::Key_B:
resume();
为何我按A时,蛇没有暂停,而是恢复到原来的速度,我按B时,蛇会加快速度。
connect(&timer, SIGNAL(timeout()),
scene, SLOT(advance()));
这里scene前面要加&。
请问博主为什么碰撞区域是四倍于实际区域呢?为什么不是本身(1倍区域)进行判定呢?是和背后的碰撞判定实现有关吗?