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

Qt 学习之路 2(34):贪吃蛇游戏(4)

86 7

这将是我们这个稍大一些的示例程序的最后一部分。在本章中,我们将完成GameController中有关用户控制的相关代码。

首先,我们来给GameController添加一个事件过滤器:

bool GameController::eventFilter(QObject *object, QEvent *event)
{
    if (event->type() == QEvent::KeyPress) {
        handleKeyPressed((QKeyEvent *)event);
        return true;
    } else {
        return QObject::eventFilter(object, event);
    }
}

回忆一下,我们使用QGraphicsScene作为游戏场景。为什么不直接继承QGprahicsScene,重写其keyPressEvent()函数呢?这里的考虑是:第一,我们不想只为重写一个键盘事件而继承QGraphicScene。这不符合面向对象设计的要求。继承首先应该有“是一个(is-a)”的关系。我们将游戏场景继承QGraphcisScene当然满足这个关系,无可厚非。但是,继承还有一个“特化”的含义,我们只想控制键盘事件,并没有添加其它额外的代码,因此感觉并不应该作此继承。第二,我们希望将表示层与控制层分离:明明已经有了GameController,显然,这是一个用于控制游戏的类,那么,为什么键盘控制还要放在场景中呢?这岂不将控制与表现层耦合起来了吗?基于以上两点考虑,我们选择不继承QGraphicsScene,而是在GameController中为场景添加事件过滤器,从而完成键盘事件的处理。下面我们看看这个handleKeyPressed()函数是怎样的:

void GameController::handleKeyPressed(QKeyEvent *event)
{
    switch (event->key()) {
        case Qt::Key_Left:
            snake->setMoveDirection(Snake::MoveLeft);
            break;
        case Qt::Key_Right:
            snake->setMoveDirection(Snake::MoveRight);
            break;
        case Qt::Key_Up:
            snake->setMoveDirection(Snake::MoveUp);
            break;
        case Qt::Key_Down:
            snake->setMoveDirection(Snake::MoveDown);
            break;
    }
}

这段代码并不复杂:只是设置蛇的运动方向。记得我们在前面的代码中,已经为蛇添加了运动方向的控制,因此,我们只需要修改这个状态,即可完成对蛇的控制。由于前面我们已经在蛇的对象中完成了相应控制的代码,因此这里的游戏控制就是这么简单。接下来,我们要完成游戏逻辑:吃食物、生成新的食物以及咬到自己这三个逻辑:

void GameController::snakeAteFood(Snake *snake, Food *food)
{
    scene.removeItem(food);
    delete food;

    addNewFood();
}

首先是蛇吃到食物。如果蛇吃到了食物,那么,我们将食物从场景中移除,然后添加新的食物。为了避免内存泄露,我们需要在这里 delete 食物,以释放占用的空间。当然,你应该想到,我们肯定会在addNewFood()函数中使用 new 运算符重新生成新的食物。

void GameController::addNewFood()
{
    int x, y;

    do {
        x = (int) (qrand() % 100) / 10;
        y = (int) (qrand() % 100) / 10;

        x *= 10;
        y *= 10;
    } while (snake->shape().contains(snake->mapFromScene(QPointF(x + 5, y + 5))));

    Food *food = new Food(x , y);
    scene.addItem(food);
}

addNewFood()代码中,我们首先计算新的食物的坐标:使用一个循环,直到找到一个不在蛇身体中的坐标。为了判断一个坐标是不是位于蛇的身体上,我们利用蛇的shape()函数。需要注意的是,shape()返回元素坐标系中的坐标,而我们计算而得的 x,y 坐标位于场景坐标系,因此我们必须利用QGraphicsItem::mapFromScene()将场景坐标系映射为元素坐标系。当我们计算出食物坐标后,我们在堆上重新创建这个食物,并将其添加到游戏场景。

void GameController::snakeAteItself(Snake *snake)
{
    QTimer::singleShot(0, this, SLOT(gameOver()));
}

void GameController::gameOver()
{
    scene.clear();

    snake = new Snake(*this);
    scene.addItem(snake);
    addNewFood();
}

如果蛇咬到了它自己,游戏即宣告结束。因此,我们直接调用gameOver()函数。这个函数将场景清空,然后重新创建蛇并增加第一个食物。为什么我们不直接调用gameOver()函数,而是利用QTimer调用呢(希望你没有忘记QTimer::singleShot(0, ...)的用法)?这是因为,我们不应该在一个 update 操作中去清空整个场景。因此我们使用QTimer,在 update 事件之后完成这个操作。

至此,我们已经把这个简单的贪吃蛇游戏全部完成。最后我们来看一下运行结果:

snake 演示

文末的附件中是我们当前的全部代码。如果你检查下这部分代码,会发现我们其实还没有完成整个游戏:Wall对象完全没有实现,难度控制也没有完成。当然,通过我们的讲解,希望你已经理解了我们设计的原则以及各部分代码之间的关系。如果感兴趣,可以继续完成这部分代码。豆子在 github 上面创建了一个代码库,如果你感觉自己的改进比较成功,或者希望与大家分享,欢迎 clone 仓库提交代码!

附件:snake
git:git@github.com:devbean/snake-game.git

86 评论

strak47 2013年1月4日 - 00:14

😛 找个时间好好完整读下代码

回复
JiaPan 2013年2月4日 - 22:08

调试状态下,碰到红点时提示:
然后就退出了。

底层由于接收到操作系统的信号而停止 .

信号名称 :
SIGSEGV
信号含义 :
Segmentation fault

回复
豆子 2013年2月5日 - 15:02

段错误一般是内存问题,我这里没有运行是正常的,是不是你的代码编译不完整?

回复
斯啦丝拉 2013年2月26日 - 22:07

我的环境是Ubuntu 12.10 64bit/gcc 2.7.2

void GameController::snakeAteFood(Snake *snake, Food *food)
{
scene.removeItem(food);
delete food;

addNewFood();
}中,
delete food;这句代码会导致程序报错退出。注释掉程序可以正常使用,但是内存会泄露。如果在一个函数体内new/delete Food,程序不会崩溃。
还望答疑。

回复
豆子 2013年2月27日 - 14:10

会不会是 gcc 版本太低?2.7.2?

回复
斯啦丝拉 2013年2月28日 - 20:28

呃……写错了,4.7.2

回复
豆子 2013年3月4日 - 10:22

这个还没有测试过,暂不知是哪里的问题…不好意思哦

回复
qingxp9 2013年5月26日 - 23:31

我在ubuntu12.04 gcc编译后,游戏开始直接向上吃food,程序也报错退出。 win下mingw编译正常

豆子 2013年5月27日 - 13:21

这个我没有在 ubuntu 上面测试过,可以的话帮忙检查下哪里问题哦~

devnull 2013年9月22日 - 17:46

我也遇到这个问题了,有人知道怎么解决吗? ❓

回复
豆子 2013年9月22日 - 20:19

你的环境是怎样的?我在 openSUSE 12.3 上面使用 gcc 4.7.2 20130108,Qt 4.8.4 64bits 测试是正常的

回复
devnull 2013年9月22日 - 21:16

我是在Ubuntu 12.04 TLS 64bit,Qt-5.1.1, gcc 4.6.3测试的,现象和@qingxp9,@斯啦丝拉的一样,注释掉delete food;就能正常运行。

回复
豆子 2013年9月23日 - 09:02

的确有这个问题,不过现在也没有弄清楚怎么回事,稍等一段时间研究研究

sxy 2015年6月25日 - 21:27

我看第一个food是在controller构造函数中new的,名字叫a1,把这个删掉调用addnewfood创建food就不会delete出错了,不过每次第一个food都在蛇右边第一个格子,不知道怎么回事

newbie 2014年3月22日 - 15:00

”在构造函数中使用new来分配内存时,必须在相应析构函数中使用delete来释放内存。“
引自《C++ Prime Plus》(第五版 )P381

回复
sunskybrave 2017年7月31日 - 17:44

我感觉newbie兄说的有点有点接近,但是有疏漏。”在构造函数中使用new来分配内存时,必须在相应析构函数中使用delete来释放内存。“中的分配内存指的是用new运算符为对象中的成员数据动态分配内存,而此处的a1并不是类的成员数据,所以你不应该在析构函数中去delete。之所会崩溃我认为是不应该像原程序那样直接去析构a1,而应该区别对待下a1,具体的原因不是很清楚。
一种解决办法是在gamecontroller.cpp 中添加静态全局变量static int num=0;
static Food *a1=NULL;然后修改函数snakeate函数为
void GameController::snakeAteFood(Snake *snake, Food *food)
{
num++;
if(num==1)
{
scene.removeItem(food);
delete a1;
}
else
{
scene.removeItem(food);
delete food;

addNewFood();
}
}

回复
sunskybrave 2017年8月1日 - 15:58

delete a1后再加个addNewFood();

回复
Pyoed 2022年3月23日 - 15:37

我也遇到这个问题,然后把代码改成
GameController::GameController(QGraphicsScene &scene, QObject *parent) :
QObject(parent),
scene(scene),
snake(new Snake(*this))
{
timer.start( 1000/33 );

scene.addItem(snake);
Food *a1 = new Food(0, -50);
scene.addItem(a1);
scene.installEventFilter(this);

resume();
}
吃第一个豆子就不会出错了,但是不知道具体原因,望解答

回复
Rui-huai Zhang 2013年4月24日 - 10:27

发现一个好玩的bug:如果控制蛇向反方向行走就会死掉 😛 (比如蛇向右走的时候按了左键)

回复
豆子 2013年4月25日 - 08:56

这个还没注意呢~有bug欢迎提交修改代码哦~ ;-P

回复
IanWu 2015年1月10日 - 18:46

因为程序会判定为 snakeAteItslef

回复
qingxp9 2013年5月16日 - 23:38

终于搞出来了,也成功加入了wall。不过遇到几个疑问,还望指教。
1、不是太清楚整个的循环是哪些一部分,有点迷糊。呃,换句话gameOver()之后执行是从哪开始呢

2、为什么判断吃FOOD是用碰撞判断,而不是直接检查head与food坐标。
可能问得有些白痴,见谅

回复
豆子 2013年5月17日 - 09:35

1. gameOver() 之后,调用了 addNewFood(),这和构造函数中的实现是一样的,因此,当按下按键之后,已然进入事件监听循环(注意我们的游戏循环始终没有暂停,所以会继续游戏);
2. 个人认为使用坐标判断也是可行的,同一问题有多种解决方案,这里只是选择了其中一个而已,有兴趣的话也可以使用坐标判断下(不过使用碰撞判断是游戏中的通用方法,所以直接选择了这种实现)。

回复
qingxp9 2013年5月17日 - 13:24

还是有点不清晰,比如 若是我不进行任何键盘操作,程序是不停的执行哪一块语句呢

回复
豆子 2013年5月20日 - 09:33

你说的是游戏循环,这个循环是在 GameController 的构造函数第一句,timer.start(1000/33); 开启的,你可以看看前面的章节。

回复
刘强 2015年6月6日 - 10:49

能把你加Wall的代码给我看一下吗?a524196310@qq.com

回复
j 2013年7月26日 - 09:44

下了源码,算是看懂了。稍微修改了点.

博主是否可以详细解释下QGprahicsScene和view的关系和使用.
如scene.sceneRect, view.sceneRect, view.size....
就是希望可以介绍下scene和view里面的重点函数和常用函数.

回复
豆子 2013年7月27日 - 10:27

各个函数的使用最好去看 API,API 说的要比我清楚准确得多。这也是我一般的建议,即文章只是点到为止,细节还是自己去查 API,否则的话不就成了 API 翻译了么?;-P

回复
ifeelnotsogood 2014年2月20日 - 13:53

首先感谢博主的文章,非常细致,最近在看,深受启发。
我用的是Mac OS X,也有前面朋友关于delete food的crash。
我目前找到的解决办法是在snake类的handleCollisions函数的foreach循环中,
如果决定吃掉果子,调用完snakeAteFood后delete collidingItem。
这样貌似不会crash,应该也释放了内存吧。
在这里加入这一句话感觉函数的封装性差了点,毕竟释放内存应该是吃果子动作的分内之举。
至于前面的delete为何会挂就没有什么头绪了。。。

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

如果这样的话,倒不如试试能不能用智能指针解决这个问题了

回复
奇念实难言 2014年3月25日 - 09:50

您好博主,关于吃豆子会崩溃我问题我也遇到了,我发现是因为removeItem后,其实这个Item还是在scene中存在的,当调用advanced的函数时,自动调用其下面Item的advanced函数,地址已经被delete所以崩溃。不知道博主对这个removeItem有什么见解不?

回复
豆子 2014年3月26日 - 13:19

这个还没有注意到,可能会是这里的问题。

回复
saluta 2014年3月3日 - 18:52

博主,代码中关于addNewFood的方法,那个随机生成的过程只会把food放在右下区域,貌似qrand不会返回负值。

回复
豆子 2014年3月5日 - 09:57

这个只是个演示,所以没考虑很多东西。可以自己改进下吧 ;-P

回复
xx 2016年7月21日 - 19:43

这样就可以覆盖整个区域了
x = (int(qrand() % 200)-100)/10;
y = (int(qrand() % 200)-100)/10;

回复
Const_Lin 2014年3月11日 - 21:58

贪吃蛇和绘制系统都看看的朦朦胧胧,应该是本人基础太差吧。
下了源代码好好看看。
再次感谢豆子先生的分享。

回复
小昭 2016年3月20日 - 23:28

楼主,源代码在哪里下啊?可以给我发一份吗?

回复
豆子 2016年3月21日 - 11:37

最下面的附件里面就是源代码

回复
小昭 2016年3月21日 - 12:28

豆大神,那个附件不知道什么原因下载不了啊。我试了。。能给我邮箱发一份吗?谢谢了!1508240679@qq.com

回复
小昭 2016年3月21日 - 12:34

豆大神,已经下载好了。。谢谢了!!

回复
Const_Lin 2014年3月12日 - 22:12

豆子你好:
我想问一下,学到这里,要掌握到什么程度才算不错?

回复
豆子 2014年3月13日 - 09:21

这个真的不好说,主要是能够满足需要就可以了吧。毕竟还是学无止境的,只要你想学,永远学不完的 ;-P

回复
newbie 2014年3月19日 - 22:04

添加新食物时是不是可以直接修改已有食物的坐标?这样就不用反复的delete和new了

回复
豆子 2014年3月20日 - 08:45

应该是可以的,因为是示例代码,所以没有考虑有关优化的问题。

回复
呵呵 2014年6月25日 - 11:53

博主,请问这里用智能指针有什么要注意的么,我发现如果是智能指针,在重新make_shared前用removeItem的话运行时出错,删掉scene.clear()和removeitem就正常……

回复
呵呵 2014年6月25日 - 15:08

clear()会delete items,不过removeitem应该不会有问题吧,我在蛇死掉,重新make_shared前先removeitem,就说segmentation fault,没有这句就正常……

回复
fzyz_sb 2014年11月10日 - 11:30

我只能默默的点赞了。这个例子确实很好,但是对于很多初学者,完整的看懂代码还是很难的。
我特别喜欢把从事件到绘制结合到一起,用了"贪吃蛇"的游戏进行总结这种学习方式,谢谢豆子。

回复
i_math 2014年12月18日 - 20:31

请问下你有下载代码吗,能给个链接吗,我一直无法下载呢。

回复
i_math 2014年12月18日 - 20:27

豆子你好:
本章末尾贪吃蛇的代码附件无法下载,请问下是不是转移了地方了?

回复
豆子 2014年12月19日 - 09:32

刚刚迁移过主机,很多链接还没有恢复,暂时现将代码发送到评论留下的邮箱了

回复
Linguang 2015年1月14日 - 16:11

你好请问下我在gamecontrol的头文件添加了智能指针:
QScopedPointer food;
在其构造函数中加入了
food.reset(new Food(0,0));
scene->addItem(food.data());

回复
Linguang 2015年1月14日 - 16:14

目前食物能够正常显示,但是当退出程序的时候,会报错segmentation fault;
看了下是智能指针的析构函数报的错,感觉是scene销毁是把food也销毁了,然后指针就无法清除了,不知道是不是这个问题,然后想请问下该如何解决?

回复
张子墨 2015年6月9日 - 22:41

E:\Qt\Qt5.5.0\Tools\mingw492_32\i686-w64-mingw32\lib\libpthread.dll.a:-1: error: file not recognized: File format not recognized
collect2.exe:-1: error: error: ld returned 1 exit status
在QT5上运行下载的程序报错

回复
豆子 2015年6月11日 - 09:33

一般是环境问题,库文件格式无法识别

回复
邵明宁 2015年7月16日 - 11:45

关于吃食物崩溃的问题,解决方案如下 :
scene.addItem(snake);
Food *a1 = new Food(0, -50);
scene.addItem(a1);

先往场景中添加snake 再添加food !
就不会崩溃了,也不会有内存泄露的问题。

回复
h_m_l 2016年8月23日 - 21:57

果然!

回复
alan 2017年12月28日 - 14:33

这是为什么??

回复
QwQhai 2019年6月5日 - 17:04

因为在addNewFood的时候 需要检测蛇身, 没有new Snake的话, 你就访问非法内存了.

回复
shell 2020年1月5日 - 23:27

还是不清楚,能说具体点吗?

回复
克成 2015年8月6日 - 17:58

豆子老师,贪吃蛇这个游戏如果先按向上再按向下,游戏会重启,这是因为头吃掉了尾巴吗?我觉得应该是蛇在向上之后只能向左向右不能向下,我修改了move部分的代码,加了两个bool变量,用于控制这个,但是导致游戏还能正常运行,但是一按键就会重启,这是为什么?

回复
灰熊 2015年8月9日 - 11:16

博主你好,看了你的源代码,我有个问题想请教下,不知您能不能抽空回答下,谢谢:)
为什么墙那部分没有添加额外的代码?既然这样那我去掉墙这部分的实现行吗?

回复
江小白 2016年2月11日 - 11:47

你试试就不就知道了,哈哈

回复
黄凌 2016年3月15日 - 11:51

实际上 foreach 进行遍历的时候,最好不要修改被遍历体。
delete food 就是在foreach还没结束的时候进行的,所以会崩溃。
解决办法是使用延迟删除:
void GameController::snakeAteFood(Snake *snake, Food *food)
{
scene.removeItem(food);

QTimer::singleShot(0, [=](){ delete food; });

addNewFood();
}

回复
小怪 2016年4月1日 - 22:41

博主您好 我向游戏中加了菜单栏 主窗口不知为什么出现了滚动条

回复
豆子 2016年4月6日 - 21:22

可能是菜单栏占用了一部分高度,而下面游戏界面是固定了大小的

回复
小二 2016年5月18日 - 10:17

豆子老师你好!附件下载不了,恳请你发一份到我的邮箱,好吗?121723662@qq.com

回复
flying_rat 2016年7月13日 - 21:14

我下载了您的附件,拷贝之后构造为什么总是报错(D:\Qt\QTobjects\snake1\gamecontroller.cpp:12: error: undefined reference to `vtable for GameController'
collect2.exe:-1: error: error: ld returned 1 exit status)

回复
豆子 2016年7月17日 - 19:26

这个是编译错误,一般是由于纯虚函数没有实现。

回复
flying_rat 2016年7月17日 - 19:28

嗯嗯,谢谢,没想到还能收到回复哈哈

回复
Moon 2016年8月9日 - 15:41

大神,我在VS2013环境下运行这个游戏,出现了这个错误错误 error C1083: 无法打开源文件: “GeneratedFiles\Debug\moc_gamecontroller.cpp”: No such file or directory F:\QT编程\mainwindow\mainwindow\c1xx mainwindow。而且VS 下没有pro文件,怎么办。求解答

回复
豆子 2016年8月12日 - 16:22

Qt 的目录下不允许有中文,把路径中的中文去掉试试

回复
杨晨 2016年8月16日 - 14:14

豆子大神,请问如果想把蛇,食物和障碍物做出3D效果该怎么做?

回复
豆子 2016年8月17日 - 08:49

3D 的话需要用 openGL 之类的库比较方便

回复
JIm 2016年8月19日 - 09:13

谢谢分享,为了不让snake冲出格子,下面两个函数应该调整一下
void Snake::moveRight()
{
head.rx() += SNAKE_SIZE;
if (head.rx() >= 100) { //加上=的条件
head.rx() = -100;
}
}

void Snake::moveDown()
{
head.ry() += SNAKE_SIZE;
if (head.ry() >= 100) { //加上=的条件
head.ry() = -100;
}
}

回复
alan 2017年12月29日 - 16:23

感谢豆子大神分享,看了snake好几天了,终于把wall做好了,我在豆子大神代码的基础上,做了以下几
个小小的改动。
1,加了wall
2,加了速度控制
3,加了暂停和继续按键
4,解决了蛇往左边走,按右方向键会吃掉自己的问题。

另外和大家一起分享下其中可能会遇到的一些坑:
1,在做wall时不要让墙占到背景方格的边界上,稍微做小一点,不然蛇和墙挨着走时,容易被判断为撞
墙了,在这个地方困扰了好久。
2,在解决了蛇往相反方向会吃掉自己的问题,我添加了Direction Snake::getMoveDirection()函数,
因为要返回enum类型的Direction,所以要把Direction的定义放到snake的外面。

回复
豆子 2018年1月3日 - 21:36

可以把修改过的代码发布到 git 上面以便大家一起学习

回复
正义叔叔 2018年7月25日 - 20:53

豆子老师为什么我用您的代码跑出来,蛇不移动?
。。。

回复
2019年8月4日 - 21:53

我也出现了运行到delete food时崩溃的问题
我发现将delete food 换成QTimer::singleShot(0, this, [food] {delete food; });就可以正常运行了
推测是还没来得及remove food就delete了food,然后又调用了food的advance()导致崩溃
可是为什么这只会出现在第一个food?想不明白

回复
zhanghl 2020年9月11日 - 17:28

qt 源码安装,然后使用 gdb 调试 core 文件,core 在此处, advance 会执行两个阶段,第二次调用 item 会 core
│3296 void QGraphicsScene::advance()
│3297 {
│3298 for (int i = 0; i │3301 item->advance(i);
│3302 }
│3303 }
如下是对应的 log,因此在 snake advance 后不能立即删除 food 对象。
advance snake 0
advance food 0
advance snake 1
程序异常结束

回复
zhanghl 2020年9月11日 - 18:08

// file delay_destructor.h

#include

template
class DelayDestructor
{
public:
DelayDestructor();
~DelayDestructor();

void AddObj(T* obj);

private:
QList objsToDelete;
};

template
DelayDestructor::~DelayDestructor()
{
for (T* item : objsToDelete) {
if (item != nullptr) {
delete item;
}
}
}

template
DelayDestructor::DelayDestructor() {}

template
void DelayDestructor::AddObj(T* obj) {
if (objsToDelete.size() > size) {
T* tmp = objsToDelete.front();
if (tmp != nullptr) {
delete tmp;
}
}

objsToDelete.append(obj);
}

//file gameconroller.cpp
void GameController::snakeAteFood(Snake *snake, Food *food)
{
scene.removeItem(food);
// delete food
delayDestructor.AddObj(food);
addNewFood();
}

回复
niu 2021年7月20日 - 10:19

豆子老师,源码下载不了,能否发邮箱一份?983156251@qq.com

回复
豆子 2021年7月22日 - 11:14

已发送

回复
niu 2021年7月22日 - 17:02

感谢豆子老师!

回复
Niuqiang 2021年11月26日 - 15:04

豆子老师,您上传的代码打不开了,有时间的话能给我发一份源码吗,感谢,邮箱:1169042296@qq.com

回复
豆子 2021年11月29日 - 09:03

已经发过了

回复
Niuqiang 2021年12月29日 - 16:38

豆子老子,程序在吃第一个中间方框的时候会崩贵。但是,当你操作蛇自己吃自己后,会随机重新生成方框,这个时候再去吃就正常了,就是第一个食物有问题。

回复

发表评论

关于我

devbean

devbean

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

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