资讯详情

资讯详情

用QT从零实现贪吃蛇:自绘Widget、QTimer事件循环与碰撞检测

简介基于QT框架的简略版贪吃蛇游戏工程面向QT与C初学者完整演示了利用QGraphicsView与QGraphicsScene搭建2D游戏界面的过程涵盖蛇身移动、食物生成、边界与自身碰撞检测、键盘方向控制等核心逻辑。资源包为zip格式共8个文件包含2个cpp源文件、h头文件、ui界面文件、pro工程文件以及jpg图片素材压缩包大小仅209KB结构紧凑便于快速浏览和定位关键代码。已有263人学习下载原作者为t_yang_t适合作为课程设计或入门QT游戏开发的参考范例。解压后可直接使用Qt Creator打开pro工程结合main.cpp、mainwindow.cpp与mainwindow.h对照理解对象封装、信号槽关联和绘图更新机制ui文件配合Qt Designer可调整界面布局jpg图片可辅助界面设计或装饰便于进一步扩展计分系统、关卡难度或游戏音效。整体代码量小但模块划分清楚对理解游戏循环、定时器驱动和QT事件机制很有帮助适合学习时逐行调试。1. 用 QT 做贪吃蛇一个「简略版」为什么能占满你的周末用 QT 写一个简略版贪吃蛇听起来是个两三天就能收工的练手项目真正动手之后才会发现最耗时间的不是蛇怎么转弯而是窗口怎么不闪、方向键怎么不吞、窗口一拉大格子怎么还对得上。这个标题里的「简略版」其实是个很聪明的定位不需要美术资源、不需要 UI 文件、不需要动画框架一个自绘 Widget 加一个 QTimer 就能把图形界面的核心链路全部走通。这篇文章就从零开始把它拆成能照着敲的步骤适合刚学完 C 想拿图形界面练手的人也适合已经在用 QT 做业务界面、但想补一补自绘和事件循环这块短板的开发者。2. 搭出跑得起来的界面骨架QMainWindow 和游戏画布怎么分工先别急着写蛇的逻辑。贪吃蛇和普通业务界面的最大差别在于它的大部分内容不是按钮和文本框而是一块不停变化的画布。这决定了界面结构必须把「窗口框架」和「画布」拆开否则后面加计分栏、加暂停按钮、加菜单都会互相打架。2.1 先定控件结构菜单栏、状态栏、中央画布我一般会用一个 QMainWindow 作为主窗口中央放一个自己写的 GameWidget继承 QWidget。主窗口负责标题、尺寸、菜单栏和状态栏游戏画布只负责画网格、画蛇、画食物。这样分工之后计分和速度显示可以放到状态栏暂停逻辑可以放到菜单栏核心绘制代码完全不用动。一个最小工程只需要两个文件加一个 .pro。先说工程文件QT widgets TARGET SnakeDemo TEMPLATE app SOURCES main.cpp GameWidget.cpp HEADERS GameWidget.h这里把QT widgets写出来是因为从较新的 QT 版本开始QApplication、QMainWindow这些类都被收进了 widgets 模块。如果只写了QT core gui编译时会出现找不到QApplication的错误这是新手最容易踩的第一个坑。TARGET是生成的可执行文件名改成什么都行不影响逻辑。主函数很简单但有一个细节值得注意#include QApplication #include GameWidget.h int main(int argc, char *argv[]) { QApplication a(argc, argv); GameWidget w; w.resize(640, 640); w.show(); return a.exec(); }a.exec()这一行就是 QT 的事件循环入口。程序执行到这里不会直接跑完退出而是不断处理窗口事件、键盘事件、定时器事件直到窗口关闭。很多第一次写 QT 的人会在这里犯迷糊以为show()之后就能像控制台程序一样顺序执行逻辑了其实不是所有后续动作都是靠事件驱动的。GameWidget 的头文件里需要声明游戏的核心成员。最简版本包括画布尺寸、蛇身容器、当前方向、定时器和几个槽函数#ifndef GAMEWIDGET_H #define GAMEWIDGET_H #include QWidget #include QVector #include QPoint #include QTimer class GameWidget : public QWidget { Q_OBJECT public: explicit GameWidget(QWidget *parent nullptr); protected: void paintEvent(QPaintEvent *event) override; void keyPressEvent(QKeyEvent *event) override; private slots: void onTimer(); private: enum Direction { Up, Down, Left, Right }; QVectorQPoint snake; QPoint food; Direction currentDir; Direction nextDir; QTimer *timer; int score; }; #endif // GAMEWIDGET_HQ_OBJECT宏是 QT 的信号槽机制要求的如果没有它connect 和相关功能会直接失效。QVectorQPoint用来存蛇的每一节QPoint的 x 和 y 表示格子坐标而不是像素坐标这是一开始就要定下的约定后面讲绘图时会看到这个约定能省很多事。2.2 paintEvent 里画不要在按钮里画坐标换算先做对贪吃蛇的绘制全部发生在paintEvent里。每次窗口需要重绘比如定时器触发后调用update()、窗口被拖动、被遮挡后重新暴露QT 都会回调这个函数。画布上的一切内容包括背景、网格、蛇身、食物都应该在这里面画。一个常见的错误是把绘制代码写到业务函数里比如在蛇移动的函数里直接调用画格子。这样临时画出来的内容很快会被下一次paintEvent抹掉而且窗口一刷新就全乱。正确做法是业务逻辑只负责改数据改完之后调用update()让 QT 在合适的时候统一重绘。坐标换算是另一个容易翻车的点。QPainter画的是像素坐标而蛇身存的是格子坐标。约定棋盘为 20 列 × 15 行那么在paintEvent里需要先把窗口当前大小换算成每个格子的像素尺寸void GameWidget::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.fillRect(rect(), QColor(30, 30, 30)); const int cols 20; const int rows 15; int cellSize qMin(width() / cols, height() / rows); // 画食物 painter.setBrush(Qt::red); painter.drawRect(food.x() * cellSize, food.y() * cellSize, cellSize, cellSize); // 画蛇 painter.setBrush(Qt::green); for (const QPoint p : snake) { painter.drawRect(p.x() * cellSize, p.y() * cellSize, cellSize - 1, cellSize - 1); } }注意这里cellSize是用qMin(width() / cols, height() / rows)动态算出来的不是写死 32 或 40。这样窗口拉大到 1000 像素时格子会跟着变大而不是在左下角留一大片空白。cellSize - 1是让每节之间留一条缝蛇看起来是一节一节的而不是一整块绿板子。paintEvent里没有写任何业务逻辑它只做一件事把当前状态画出来。这正是自绘界面和普通控件界面的根本区别你负责保证数据是对的paintEvent 负责保证画得对。搞清楚这一点后面蛇移动、碰撞检测的代码就不容易和绘制代码纠缠在一起了。3. 让蛇按节拍动起来QTimer、事件循环和方向输入界面能跑起来之后下一步是让蛇动。很多人会直觉地在一个循环里写「移动一格、睡 150 毫秒、再移动一格」但在 QT 里这么写会直接把界面卡死。这里需要理解 QTimer 和事件循环的关系。3.1 用 QTimer 驱动游戏循环别用 while(true)QTimer是 QT 里最常用的定时器类它依赖事件循环工作。每过设定的时间间隔它会发出一个timeout信号然后执行绑定的槽函数。蛇的每一次移动其实就是定时器每次触发时执行一次onTimer()。构造函数里这样初始化GameWidget::GameWidget(QWidget *parent) : QWidget(parent) { setFocusPolicy(Qt::StrongFocus); snake.append(QPoint(5, 7)); snake.append(QPoint(4, 7)); snake.append(QPoint(3, 7)); currentDir Right; nextDir Right; timer new QTimer(this); connect(timer, QTimer::timeout, this, GameWidget::onTimer); timer-start(150); }timer-start(150)的意思是每 150 毫秒触发一次定时器换算过来就是每秒大约移动 6.7 格这是比较舒服的初速度。这个数值后面会变成难度调节的参数想吃难度就给得更短比如 100 毫秒新手练习可以给 200 毫秒。但要注意QTimer的精度在普通桌面系统上只能做到十几毫秒级别想精确控制到个位数毫秒是做不到的游戏里也不需要。onTimer()的职责是计算新蛇头位置、判断吃到什么、移动蛇身、检查碰撞、最后调用update()请求重绘。它不应该直接调用paintEvent而是通过update()让事件循环安排重绘void GameWidget::onTimer() { currentDir nextDir; QPoint head snake.first(); QPoint newHead head; switch (currentDir) { case Up: newHead.ry() - 1; break; case Down: newHead.ry() 1; break; case Left: newHead.rx() - 1; break; case Right: newHead.rx() 1; break; } snake.insert(0, newHead); if (newHead food) { score 10; generateFood(); } else { snake.removeLast(); } if (checkCollision()) { timer-stop(); } update(); }insert(0, newHead)是把新蛇头插到容器最前面removeLast()是去掉尾巴。吃到了食物就只插头不删尾蛇看起来就长了一节。update()放在最后是因为面板状态已经改完接下来让paintEvent去画最新的一帧。3.2 方向键输入与「防回弹」先后端还是先前端方向输入看起来简单实际上有一个非常经典的坑蛇往右走的时候玩家按了上紧接着又按了左如果每次按键都直接改变当前方向蛇会在同一帧里先向上再向左最终撞上自己。这就是所谓的「回弹」问题。解决办法是引入两个方向变量currentDir是当前帧实际使用的方向nextDir是下一帧准备使用的方向。键盘事件只修改nextDir而onTimer每帧开始时才把nextDir复制给currentDir。同时还要判断新方向是否和当前方向相反相反就忽略void GameWidget::keyPressEvent(QKeyEvent *event) { Direction dir nextDir; switch (event-key()) { case Qt::Key_Up: if (currentDir ! Down) dir Up; break; case Qt::Key_Down: if (currentDir ! Up) dir Down; break; case Qt::Key_Left: if (currentDir ! Right) dir Left; break; case Qt::Key_Right: if (currentDir ! Left) dir Right; break; default: QWidget::keyPressEvent(event); return; } nextDir dir; }这里判断的是currentDir而不是nextDir这是有讲究的。如果判断nextDir那么玩家在往右走时先按上再按左第一次把nextDir改成 Up第二次因为nextDir是 Up 而不是 Right就会误判为可以向左结果下一帧直接左转蛇撞到自己腰上。还有一个小细节GameWidget构造时必须调用setFocusPolicy(Qt::StrongFocus)。如果不设置窗口默认不接受键盘焦点按键事件根本不会传到keyPressEvent里。这个问题特别隐蔽因为很多人会先在界面上放一个按钮点击按钮之后焦点跑到按钮上蛇就不响应键盘了。4. 蛇身、食物和碰撞把游戏逻辑闭环跑通移动有了接下来就是把整个游戏闭环跑通。贪吃蛇看着简单实际上包含了几类很容易混淆的判定吃到食物、撞到边界、撞到身体、食物生成位置不合法。这几件事都发生在onTimer或generateFood里和绘制完全解耦。4.1 用 QVector 维护蛇身头插尾删的代价与边界用QVectorQPoint存蛇身是最直观的写法。蛇头是snake.first()蛇尾是snake.last()。每一步先insert(0, newHead)再removeLast()逻辑非常清晰。代价是insert到开头会让内部数据整体后移格子数不多时这点开销完全可以忽略。如果棋盘尺寸做到 100×100 以上蛇身可能上千节可以考虑改用std::dequeQPoint它在头部插入的复杂度是常数级。蛇身存储决定了碰撞检测的写法。检查是否撞到身体只要看新蛇头是否和蛇身上任意一节重合bool GameWidget::checkCollision() { QPoint head snake.first(); if (head.x() 0 || head.x() 20 || head.y() 0 || head.y() 15) { return true; } for (int i 1; i snake.size(); i) { if (snake.at(i) head) { return true; } } return false; }循环从i 1开始因为snake.first()就是蛇头本身拿它和自己比永远相等。这个 bug 很常见有人从 0 开始遍历结果蛇刚开局就判定自己撞了自己。边界判定需要和棋盘尺寸保持一致这里写成 20和 15如果漏了等号蛇会到边界外一格才判死。碰撞和贪吃的判定顺序也要注意。代码里是先插入新头再判断是否吃到食物最后才检查碰撞。也可以反过来先算新头、先判断碰撞、再决定是否插入。两条路都能走但用哪种就要贯彻哪种混着写会出现「蛇明明撞墙了分数却加了 10」的怪事。4.2 食物随机生成与四类碰撞判定边界、身体、吃到自己和吃到食物食物生成的核心要求是不能生成在蛇身上否则玩家根本吃不到。最简单的写法是循环随机直到选到合法位置void GameWidget::generateFood() { do { food QPoint(QRandomGenerator::global()-bounded(20), QRandomGenerator::global()-bounded(15)); } while (snake.contains(food)); }QRandomGenerator::global()-bounded(20)返回 0 到 19 之间的随机整数配合刚才的边界判定食物不会跑到画布外面。bounded(15)对应行数 15。do-while保证第一次也会判断合法性不会出现食物和蛇头重叠的情况。这里不推荐用老式的qrand()在较新的 QT 里它已经被标记为过时而且全局随机数在多线程环境下不够可靠。QRandomGenerator是现在推荐的做法用法也一样简洁。吃食物的判定放在移动逻辑里新蛇头坐标等于食物坐标就算吃到。这里需要注意使用QPoint的相等比较如果坐标换算成像素再比就会因为cellSize变化导致明明叠在一起却判定失败。这也是为什么从一开始就约定蛇身、食物都存格子坐标的原因像素坐标只用于绘制那一层。到这里游戏的最小闭环已经成立键盘改方向、定时器驱动移动、移动后判定碰撞、碰撞后停表、重绘。整个循环的关键就在「数据和绘制分开」这个原则上所有逻辑都在onTimer和keyPressEvent里改数据paintEvent只负责把数据画出来。5. 贪吃蛇避坑与排查五个常见的翻车现场这一章是把最容易让新手卡住的问题集中列出来。每一条都是实际运行中会出现的情况按「现象 → 原因 → 解决」的顺序说清楚省得在同一个坑里跌倒两次。5.1 蛇不动或一启动就闪退现象窗口能打开画布是黑的按方向键没反应或者一打开程序就崩。原因分两类。蛇不动通常是定时器没启动比如构造函数里忘了写timer-start(150)或者timer是局部变量函数结束就被销毁了。闪退多半是snake容器为空时调用了snake.first()或者头文件里Q_OBJECT漏了导致信号槽连接失败。解决在构造函数末尾检查timer-isActive()snake初始化时至少要连续 append 三个点。闪退时用调试器停在崩溃点看调用栈90% 是空容器访问。5.2 方向键没反应 / 连续按会穿帮现象窗口有焦点时按键正常但点了一下状态栏或菜单后蛇就不再响应或者快速按两个方向时蛇直接掉头撞死。原因焦点丢失。QMainWindow默认焦点可能在菜单栏或某个子控件上键盘事件被它们消化掉。回弹问题则是方向判断逻辑写得太简单没有用currentDir做反向过滤。解决构造时调用setFocusPolicy(Qt::StrongFocus)游戏过程中如果按了空格暂停恢复时要重新setFocus()。方向逻辑按 3.2 节的写法处理判断反向时用currentDir中间变量用nextDir。5.3 窗口拉伸后蛇的位置对不上现象窗口默认尺寸时一切正常拉大窗口后蛇和食物画在左上角或者格子出现被拉伸变形。原因绘制代码里用了固定像素比如写死const int cellSize 32窗口变大时格子没有跟着变大。另一种可能是painter.drawRect用了浮点计算的cellSize缩放后出现细缝或重叠。解决在paintEvent里动态计算cellSize qMin(width()/cols, height()/rows)。要保证蛇在窗口中央还可以用painter.translate((width() - cols*cellSize)/2, (height() - rows*cellSize)/2)做整体偏移。如果 resize 后游戏重开记得在resizeEvent里重新计算并update()。5.4 食物长在蛇身上 / 开局就撞死现象食物有时候和蛇身重叠玩家吃不干净或者游戏刚开局蛇头碰了一下边界外的位置就判定死亡。原因generateFood里没有排除蛇身占用的格子。开局撞死的判定是因为碰撞检测里把 20写成了 20导致蛇头在边界上就被判死而实际上蛇头坐标 20 已经出去了。解决用do-while循环生成食物并snake.contains(food)过滤。边界判定统一写成x 0 || x cols || y 0 || y rows。这两处是逻辑正确性的核心写完要多跑几轮确认。5.5 掉帧和残留残影现象蛇移动时画面闪烁或者上一帧的蛇身印子留在画布上形成「拖影」。原因闪烁通常是没把整个画布清空就接着画。paintEvent里如果不先用fillRect(rect(), backgroundColor)铺底旧画面残留就会被保留。拖影可能是重绘区域太小QT 默认只更新了局部变化区域。解决paintEvent第一行先painter.fillRect(rect(), QColor(30,30,30))保证背景全覆盖这一点其实已经在 2.2 的代码里做了。如果用的是QGraphicsView还要设置setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate)来避免重绘不完整。普通 QWidget 自绘只要背景画满问题基本就消失了。6. 给简略版装上加速度变速、回放和自定义地图的进阶思路游戏能完整跑通之后接下来就是让它变得「有游戏性」。简略版不等于做完就停在原地三个方向不需要加多少代码就能明显提升完成度。6.1 变速成长分数、速度和手感怎么调最常见的做法是按分数分档降低定时器间隔。设定初始间隔 150ms每吃 5 个食物减少 10ms最低降到 80msvoid GameWidget::updateSpeed() { int level score / 50; int interval 150 - level * 10; if (interval 80) interval 80; timer-start(interval); }如果每次吃到食物都调用一次updateSpeed()速度会随着分数阶梯式上升而不是突然变快。要注意timer-start()会重置当前计时所以应该在移动逻辑中蛇已经完成一次移动后再更新速度否则会出现「这一帧走了两步」的错觉。分数区间定时器间隔大概速度格/秒0 分150ms6.750 分100ms10.0100 分80ms12.5速度提升必须配合食物生成区域调整不然蛇太快玩家根本反应不过来。可以每升一档把棋盘可视区域中间保留两侧变成障碍物这就要用到下一个扩展点。6.2 录制回放与自定义地图验证和扩展方向简略版的另一个实用扩展是录制回放。把每次按键产生的方向序列存进QVectorDirection游戏结束后按同样方向序列重跑一遍可以验证程序逻辑是否一致、碰撞判定有没有边界错误。一个更好的验证方式是不依赖键盘输入的自动模式预置一条已知路径定时器每帧自动走跑完断言得分和是否存活这样每次改动代码后都能快速回归测试。自定义地图也很简单多加一个QVectorQPoint walls初始化时摆放障碍物碰撞检测里把walls.contains(head)也判定为死亡。障碍物和食物生成结合时generateFood要同时排除蛇身和墙体玩家也会因为地图复杂化而获得新的挑战。我最早写贪吃蛇 demo 时把这些扩展一个没做结果代码能跑但总感觉少了点什么。后来发现真正让人坚持下去的不是「能不能跑」而是「改完一个参数后手感变化明不明显」。给每个参数留出调整入口比追求一次写成完美更重要。比如速度、格子数、食物数量都放到构造函数里用常量定义改起来方便测起来也快。如果哪天你在演示时发现蛇在窗口拉大后偏到一边记得回来看看cellSize是不是还是固定值。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →