首页 Qt 学习之路 2 Qt 学习之路 2(31):贪吃蛇游戏(1)

Qt 学习之路 2(31):贪吃蛇游戏(1)

50 9

经过前面一段时间的学习,我们已经了解到有关 Qt 相当多的知识。现在,我们将把前面所讲过的知识综合起来,开发一个贪吃蛇游戏。游戏很简单,相信大家都有见过,多多少少也都玩过。我们在实现这个贪吃蛇游戏时,会利用到事件系统、Graphics View Framework、QPainter 等相关内容,也会了解到一个游戏所具有的一些特性,比如游戏循环等,在 Qt 中如何体现出来。当然,最重要的是,通过一个相对较大的程序,学习到如何将之前的点点滴滴结合在一起。

本部分的代码出自:http://qtcollege.co.il/developing-a-qt-snake-game/,但是有一些基于软件工程方面考虑的修改,例如常量放置的位置等。

前面说过,Qt 提供了自己的绘制系统,还提供了 Graphics View Framework。很明显,绘制图形和移动图形,是一个游戏的核心。对于游戏而言,将其中的每一个部分看做对象是非常合理的,也是相当有成效的。因此,我们选择 Graphics View Framework 作为核心框架。回忆一下,这个框架具有一系列面向对象的特性,能够让我们将一个个图形作为对象进行处理。同时,Graphics View Framework 的性能很好,即便是数千上万的图形也没有压力。这一点非常适合于游戏。

正如我们前面所说,Graphics View Framework 有三个主要部分:

  • QGraphicsScene:能够管理元素的非 GUI 容器;
  • QGraphicsItem:能够被添加到场景的元素;
  • QGraphicsView:能够观察场景的可视化组件视图。

对于游戏而言,我们需要一个QGraphicsScene,作为游戏发生的舞台;一个QGraphicsView,作为观察游戏舞台的组件;以及若干元素,用于表示游戏对象,比如蛇、食物以及障碍物等。

大致分析过游戏组成以及各部分的实现方式后,我们可以开始编码了。这当然是一个 GUI 工程,主窗口应该是一个QGraphicsView。为了以后的实现方便(比如,我们希望向工具栏添加按钮等),我们不会直接以QGraphicsView作为顶层窗口,而是将其添加到一个主窗口上。这里,我们不会使用 QtDesigner 进行界面设计,而是直接编码完成(注意,我们这里的代码并不一定能够通过编译,因为会牵扯到其后几章的内容,因此,如果需要编译代码,请在全部代码讲解完毕之后进行):

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>

class QGraphicsScene;
class QGraphicsView;

class GameController;

class MainWindow : public QMainWindow
{
    Q_OBJECT
public:
    MainWindow(QWidget *parent = 0);
    ~MainWindow();

private slots:
    void adjustViewSize();

private:
    void initScene();
    void initSceneBackground();

    QGraphicsScene *scene;
    QGraphicsView *view;

    GameController *game;
};

#endif // MAINWINDOW_H

在头文件中声明了MainWindow。构造函数除了初始化成员变量,还设置了窗口的大小,并且需要对场景进行初始化:

MainWindow::MainWindow(QWidget *parent) :
    QMainWindow(parent),
    scene(new QGraphicsScene(this)),
    view(new QGraphicsView(scene, this)),
    game(new GameController(*scene, this))
{
    setCentralWidget(view);
    resize(600, 600);

    initScene();
    initSceneBackground();

    QTimer::singleShot(0, this, SLOT(adjustViewSize()));
}

值得说明的是最后一行代码。singleShot()函数原型如下:

static void QTimer::singleShot(int msec, QObject * receiver, const char * member);

该函数接受三个参数,简单来说,它的作用是,在 msec 毫秒之后,调用 receiver 的 member 槽函数。在我们的代码中,第一个参数传递的是 0,也就是 0ms 之后,调用this->adjustViewSize()。这与直接调用this->adjustViewSize();有什么区别呢?如果你看文档,这一段的解释很隐晦。文档中写到:“It is very convenient to use this function because you do not need to bother with a timerEvent or create a local QTimer object”,也就是说,它的作用是方便使用,无需重写timerEvent()函数或者是创建一个局部的QTimer对象。当我们使用QTimer::signleShot(0, ...)的时候,实际上也是对QTimer的简化,而不是简单地函数调用。QTimer的处理是将其放到事件列表中,等到下一次事件循环开始时去调用这个函数。那么,QTimer::signleShot(0, ...)意思是,在下一次事件循环开始时,立刻调用指定的槽函数。在我们的例子中,我们需要在视图绘制完毕后才去改变大小(视图绘制当然是在paintEvent()事件中),因此我们需要在下一次事件循环中调用adjustViewSize()函数。这就是为什么我们需要用QTimer而不是直接调用adjustViewSize()。如果熟悉 flash,这相当于 flash 里面的callLater()函数。接下来看看initScene()initSceneBackground()的代码:

void MainWindow::initScene()
{
    scene->setSceneRect(-100, -100, 200, 200);
}

void MainWindow::initSceneBackground()
{
    QPixmap bg(TILE_SIZE, TILE_SIZE);
    QPainter p(&bg);
    p.setBrush(QBrush(Qt::gray));
    p.drawRect(0, 0, TILE_SIZE, TILE_SIZE);

    view->setBackgroundBrush(QBrush(bg));
}

initScene()函数设置场景的范围,是左上角在 (-100, -100),长和宽都是 200px 的矩形。默认情况下,场景是无限大的,我们代码的作用是设置了一个有限的范围。Graphics View Framework 为每一个元素维护三个不同的坐标系:场景坐标,元素自己的坐标以及其相对于父组件的坐标。除了元素在场景中的位置,其它几乎所有位置都是相对于元素坐标系的。所以,我们选择的矩形 (-100, -100, 200, 200),实际是设置了场景的坐标系。此时,如果一个元素坐标是 (-100, -100),那么它将出现在场景左上角,(100, 100) 的坐标则是在右下角。

initSceneBackground()函数看似很长,实际却很简单。首先我们创建一个边长TILE_SIZEQPixmap,将其使用灰色填充矩形。我们没有设置边框颜色,默认就是黑色。然后将这个QPixmap作为背景画刷,铺满整个视图。

现在我们的程序看起来是这样的:

Snake Scene

在后面的章节中,我们将继续我们的游戏之旅。下一章,我们开始创建游戏对象。

50 评论

Xiao'J 2013年5月15日 - 19:19

"在头文件中声明了 MainWindow"这里应该是“定义”。谢谢豆子!

回复
豆子 2013年5月15日 - 19:26

头文件中一般都是声明的,没有函数体,所以应该不算定义的吧

回复
轨迹 2013年5月31日 - 11:55

恩,是声明的,减少编译时间。并不占用空间

回复
MT来来 2016年12月26日 - 15:10

MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent),
scene(new QGraphicsScene(this)),
view(new QGraphicsView(scene,this)),
game(new GameController(*scene,this))

MainWindow::MainWindow(QWidget *parent)这个在创建的时候就有但是后面那部分是干什么的?什么情况需要加上后面的部分

回复
豆子 2016年12月26日 - 21:00

这些是构造函数初始化列表,会在构造函数执行之前执行。具体细节可以阅读有关初始化列表的相关内容

回复
MT来来 2016年12月29日 - 09:40

请问初始化列表在哪里可以查看

豆子 2017年1月4日 - 09:13

初始化列表在一般的 C++ 入门书中都会有的

MT来来 2016年12月30日 - 10:41

大神你好 我要设置按下某个方向键速度加快该怎么设置

豆子 2017年1月4日 - 09:12

这个没有方法,我想的是,你可以在按键按下之后启动一个定时器,设置一定的加速度。

Delbert 2013年11月27日 - 19:39

我这边如果不在头文件中#include ,而直接用class进行前向声明的话,会提示QGraphicsView 和 QGraphicsScene “类没有构造函数”。

回复
豆子 2013年11月27日 - 21:19

这是 C++ 的原因:前向声明只是告诉编译器有这么个类,但是如果要使用这个类的任何函数(包括构造函数),必须要 include 头文件才可以。详细信息可以查阅 C++ 有关内容。

回复
xiongxuanwen 2015年4月10日 - 11:11

c++中,前向声明类时,不能使用该类的对象,但可以使用其指针类型或引用类型,例如:
QGraphicsScene *scene;是没有问题的
但是QGraphicsScene scene;则有问题。

回复
xiongxuanwen 2015年4月10日 - 11:12

1 类声明(declare)
class Screen;
在声明之后,定义之前,只知道Screen是一个类名,但不知道包含哪些成员。只能以有限方式使用它,不能定义该类型的对象,只能用于定义指向该类型的指针或引用,声明(不是定义)使用该类型作为形参类型或返回类型的函数。
void Test1(Screen& a){};
void Test1(Screen* a){};

2 类定义(define)
在创建类的对象之前,必须完整的定义该类,而不只是声明类。所以,类不能具有自身类型的数据成员,但可以包含指向本类的指针或引用。
class LinkScreen
{
public:
Screen window;
LinkScreen* next;
LinkScreen* prev;
};
希望对你有帮助。

回复
Snail 2013年12月11日 - 21:47

为什么用
private slots:
void adjustViewSize();
会报错呢?一个无法解析的外部符号问题。。。
直接使用
private:
void adjustViewSize();
就没问题了。。。

回复
豆子 2013年12月13日 - 14:54

会不会没有引入 Q_OBJECT 宏,或者清理之后再重新构建下试试。

回复
rarry 2014年2月4日 - 11:59

请问TILE_SIZE在哪里定义的?

回复
豆子 2014年2月4日 - 17:01

这是一个普通的常量。可以在最后一篇《贪吃蛇游戏(4)》中下载完整的代码。

回复
lyp 2014年3月2日 - 16:40

您好。。我想问一下。。我在Label的Paintevent里向label贴图。。。这个Label没有父控件时一切正常。。可是把mainwindow作为它的父控件就无法贴图了。。求解啊。。。

回复
liyongnan 2014年3月17日 - 17:37

豆子高手,你好! 请多多指教!
我下载程序在windows Qt5下面编译,出现:
No rule to make target '../snake/mainwindow.ui', needed by 'ui_mainwindow.h'. Stop.

回复
liyongnan 2014年3月17日 - 17:46

已经解决,把原debug目录删除就可以了

回复
豆子 2014年3月17日 - 20:27

有时候就是重新构建下可能就可以了。一般这是因为 makefile 没有更新导致的。

回复
bido 2014年7月1日 - 22:15

Snake::Snake(GameController &controller) ://default constructor for snake
head(0, 0),
growing(7),
speed(10),
moveDirection(NoMove),
controller(controller)
{
}
请问为什么这个constructor 可以把自己变量的初始化写在大括号外面啊,不是应该写在里面吗。这不是一般调用被继承类的constructor所采用的方式吗?(因为我看到了冒号),c++ 初学小弟表示费解。。。

回复
豆子 2014年7月4日 - 14:28

这是构造函数初始化列表,是 C++ 语法的一部分,用于进行变量初始化。你可以看看 C++ 手册详细了解一下。

回复
lavender 2014年7月31日 - 17:10

请问class GameController会报错呢???

回复
豆子 2014年8月2日 - 15:20

GameController 哪里出错了?请问你是什么系统?什么编译器?

回复
Ada 2015年7月20日 - 12:15

mac系统 gamecontroller 同会报错 QGameController' file not found

回复
豆子 2015年7月21日 - 09:18

这个我需要在 mac 上面测试一下,一般是因为路径问题导致的。可以查看下 Qt Creator 配置的路径是不是正确?

回复
shx 2016年4月7日 - 22:39

我的也是这个错误 我的是windows系统 在help中没有GameController这个类是为什么?
新手 请指教

豆子 2016年4月11日 - 21:43

可能是因为源代码结构是不是有变化?在第四部分是有完整的代码下载的。

hlx1996 2015年11月27日 - 23:47

Graphics View Framework为每一个元素维护三个不同的坐标系:场景坐标,元素自己的坐标以及其相对于父组件的坐标。除了元素在场景中的位置,其它几乎所有位置都是相对于元素坐标系的。

请问场景坐标 和 元素自己的坐标分别是什么意思T_T,有什么区别?

回复
Jimmy 2016年3月23日 - 10:16

你好 请问为什么要使用adjustViewSize()? 由于使用了resize(600, 600); 我把scene->setSceneRect(-100, -100, 200, 200);改成scene->setSceneRect(-300, -300, 600, 600); TILE_SIZE = 10 改为TILE_SIZE = 30不就可以了吗?

回复
豆子 2016年3月23日 - 19:42

放在 adjustViewSize() 槽中去执行,主要是为了避免在绘制没有完成之前就调整了大小,导致显示异常。因为我们的程序比较简单,绘制能够很快完成,但是对于一些很复杂的程序,绘制可能需要消耗很长时间,所以会放在槽中,在 singleSlot() 信号发出之后再调用。

回复
XXDRA 2016年4月23日 - 23:01

豆哥
请问
MainWindow.h里
class QGraphicsScene;
class QGraphicsView;

class GameController;
是什么作用 为什么要这样写?
如果是Qt自带,加入头文件不就可以了
如果是自己声明那不是需要有类定义吗?

或者是仅仅声明作用?
忘了好多,请豆哥指点

回复
豆子 2016年4月24日 - 22:43

这是 C++ 的前置声明。加入头文件的问题是,如果头文件发生改变,所有 include 的文件都要重新编译。在大型项目中,这会消耗大量时间。使用前置声明则不存在这个问题。具体细节可以参考 C++ 相关内容。

回复
XXDRA 2016年4月24日 - 23:12

嗯 我去查资料

回复
XXDRA 2016年4月23日 - 23:09

运行过完成后的项目,有一点缺陷
正在向一个方向运动是,按对应的反方向键,会判断为碰撞。
例如正在向左运动,此时按右方向键,就会判定为碰撞,然后重新初始化。
我认为可以这样:
在按键监听里增加判断,当为当前方向的反方向是,进行忽略。

回复
我是安娜 2016年5月14日 - 23:10

请问楼主的代码是不是Qt4呢

回复
XXDRA 2016年5月20日 - 15:53

5

回复
ursula 2016年6月24日 - 17:13

您好,看了您的文章给了我很大的帮助,不过有个问题我不太理解,mainwindow类里的成员变量GameController *game放在mainwindow类有什么作用呢,在mainwindow.cpp里也没有用到,谢谢!

回复
豆子 2016年6月24日 - 17:56

这个类作为整个游戏的控制器,在 MainWindow 里面只起到一个全局控制的作用(因为 MainWindow 相当于是全局单例)。目前在这里没有其它用处,不过在以后的扩展中,比如你需要增加暂停、重启等功能,就可以在 MainWindow 直接连接 GameController 的槽函数了。

回复
柒月 2017年3月20日 - 16:26

您好,首先很感谢你的文章,有一个问题想问一下。为什么按照这个代码创建的界面最左侧和最上边的边界不能完全贴合应用窗口。。按照计算的结果应该是完全贴合才对,谢谢!

回复
Sunl 2018年6月19日 - 11:20

void MainWindow::initScene()
{
scene->setSceneRect(-100, -100, 200, 200);
}

这里,感觉,应该修改为:scene->setSceneRect(-100, -100, 210, 210);
因为,后面的蛇在X和Y轴的运行范围(如下代码)都是[-100, 100],每个格子的宽度(TILE_SIZE为10),则总宽度好像应该为210。
void Snake::moveLeft()
{
head.rx() -= SNAKE_SIZE;
if (head.rx() 100) {
head.rx() = -100;
}
}

void Snake::moveUp()
{
head.ry() -= SNAKE_SIZE;
if (head.ry() 100) {
head.ry() = -100;
}
}

回复
李小白 2018年7月6日 - 10:04

请问结果图中的网格线是怎么出来的,程序中并未有画呀

回复
豆子 2018年7月7日 - 09:44

是在 MainWindow::initSceneBackground() 中定义的画刷

回复
jack 2020年3月24日 - 17:24

请问为什么我在win10+vs2019+qt5.9.8编译出来的主界面有拖动条?

回复
jack 2020年3月24日 - 17:27

view->fitInView(scene->sceneRect(), Qt::KeepAspectRatio);
把 Qt::KeepAspectRatioByExpanding 改成 Qt::KeepAspectRatio就可以了。。

回复
冲锋的羊陀 2020年4月15日 - 20:19

有个问题没有查找到,view->setBackgroundBrush(QBrush(bg))这里默认使用平铺绘画出了网格,如果是想使用拉伸(图片)应该怎么做呢

回复
tron 2020年10月9日 - 21:35

>我们没有设置边框颜色,默认就是黑色。然后将这个QPixmap作为背景画刷,铺满整个视图。
想问下如何设置边框颜色
>scene->setSceneRect(-100, -100, 200, 200);
请问这里,后两个参数是什么,为什么我设置了300之后,蛇的可观测区域变小了.

回复
hhsun9 2022年3月9日 - 15:30

内容里面的链接打不开啊 求源码

回复
豆子 2022年6月4日 - 21:56

文中链接好像已经不存在了,最后一篇有源码地址。

回复

发表评论

关于我

devbean

devbean

豆子,生于山东,定居南京。毕业于山东大学软件工程专业。软件工程师,主要关注于 Qt、Angular 等界面技术。

主题 Salodad 由 PenciDesign 提供 | 静态文件存储由又拍云存储提供 | 苏ICP备13027999号-2