用Qt开发贪吃蛇:从QPainter到事件循环的完整实践指南
简介面向QT初学者与C游戏开发入门者该资源是一套基于QT框架的简略版贪吃蛇游戏完整工程源码。它围绕图形视图框架展开从游戏区域划分、蛇的移动更新到食物随机生成演示了视图与场景的搭建、用自定义图元表示蛇身和食物、键盘事件改变方向、定时器驱动移动并集成边界与自身碰撞检测、计分和结束界面等完整逻辑。资源包共8个文件以cpp源文件、h头文件、ui界面文件、pro工程配置为主另含jpg图片素材作界面参考压缩包整体只有209KB结构精简可直接导入QT Creator阅读运行。已有260人学习下载作为轻量练手项目既能帮助理解窗口程序的事件循环与控件刷新流程也能借具体编码熟悉面向对象封装和信号槽解耦思想。适合希望用项目巩固C与QT基础、快速上手桌面应用开发的读者。1. 项目整体设计与思路拆解1.1 为什么拿贪吃蛇练Qt最划算前几天有个朋友问我想快速入门Qt该做个什么项目练手。我直接甩给他一个需求清单一个小窗口、一个定时器、几个按键事件、一块自绘画布再加一点点状态管理。这不就是贪吃蛇吗很多人觉得贪吃蛇是小学生的玩具项目看不上。但从学习角度看它把Qt桌面开发的主线全部串起来了事件循环怎么跑、QPainter怎么画图形、QTimer怎么驱动游戏逻辑、界面和业务逻辑怎么分离、按键事件怎么处理。这些知识点无论你以后去做工业上位机、图像标注工具还是音视频播放器全都绕不开。而且贪吃蛇的代码量控制得很好你不需要搭一个巨大的工程也不用引入第三方库纯Qt Widgets就能搞定。从新建项目到能跑能玩一个下午足够。但它又足够复杂蛇的移动、碰撞检测、食物生成、计分和加速每一样都是游戏开发的基础模块。做完之后你再看其他小游戏思路基本是通的。1.2 版本、编译器和IDE三件套怎么选先解决环境问题。Qt的版本选择我建议新手直接上Qt 5.15.2或者Qt 6.5以上的LTS版本。5.15.2是很多人验证过的稳定版本网上的教程和坑都齐全遇到问题搜得到答案。Qt 6的变化比较大主要是QList和QVector的合并、QPainter的API调整如果是从零开始学直接学Qt 6也没问题。我自己平时写小工具还是习惯5.15.2不是因为6不好而是项目里老代码多懒得迁移。编译器这块Windows上有两条路线MSVC和MinGW。MSVC搭配Visual Studio的调试器报错信息更细致熟悉Windows生态但发布时需要带上对应的VC运行库MinGW是GCC的Windows版本绿色、免安装编译出来的exe对运行环境要求低一些。我的建议是如果你的机器装了VS就选MSVC如果纯粹为了学QtMinGW更省事。关键是一旦选定了编译器不要中途频繁切换不同编译器编出来的代码混用很容易出一堆莫名奇妙的问题。IDE方面日常开发用Qt Creator就够它对CMake和qmake的支持都很好还有可视化的表单设计器。VSCode配Qt插件适合已经熟悉VSCode操作习惯的人调试起来也顺手但新手的配置成本偏高容易在环境上卡住。我的看法是先老老实实把Qt Creator用熟等你知道.pro和CMakeLists.txt到底干了什么再换不迟。1.3 界面与逻辑分离的骨架设计我见过不少初学者把所有的代码堆在一个类里MainWindow里既存蛇身数据又做绘图还处理定时器最后几百行代码挤在一起改一个功能要翻半天。这个习惯在做贪吃蛇时就要改过来。我推荐的骨架是这样用一个GameWidget类继承QWidget它只管两件事——接收定时器的驱动信号来更新游戏逻辑然后重写paintEvent把当前状态画出来。游戏数据本身比如蛇身坐标数组、当前方向、分数、游戏是否结束全部作为GameWidget的成员变量存在或者单独拆一个GameState结构体。这样测试数据逻辑不用启动界面以后想加存档、加回放也方便。定时器不要放在MainWindow里也不要自己new一个线程去循环就看成一个“心跳”每隔一段时间给GameWidget发一个 update 信号。GameWidget内部维护状态状态变了就调用update()触发重绘。一切以数据为驱动界面只是数据的投影。理解了这句话Qt编程就算入门了一半。另外有个细节窗口标题栏和状态栏尽量别添加多余的功能保持界面简洁。学项目的时候克制住“我想加按钮”“我想加菜单栏”的冲动先把核心逻辑跑通再考虑锦上添花。2. 核心数据结构与移动逻辑2.1 蛇身用QList还是QVector蛇身体是一个不断增长和缩短的坐标列表在Qt里最自然的选择是QList。Qt 6之前QList和QVector是不同的容器QList在链表和数组之间做了一个折中对元素的增删性能较好QVector是连续内存存储像C标准库的vector随机访问快。到了Qt 6QList和QVector统一了可以无脑用QList。从实际需求看贪吃蛇的蛇身长度顶多几百个点随便用哪个容器性能都不是瓶颈。更关键的是操作方式每次移动需要“头插一个新坐标尾部删掉一个旧坐标”。所以要先想清楚容器是怎么增删的再决定用哪个接口。QListQPoint m_snake; QPoint m_direction; // 当前移动方向比如 QPoint(1, 0) 表示向右初始化时蛇身放三段就够了比如坐标 (5, 10)、(4, 10)、(3, 10)方向设为向右。这里要注意蛇头和数组第一个元素要对齐后边的逻辑全部以第一个元素为头。2.2 移动、吃食物与碰撞检测移动逻辑的核心其实就一行公式新蛇头 旧蛇头 方向向量。然后在列表头部插入新蛇头如果没有吃到食物就把尾部删掉如果吃到了尾部保留蛇就长了一节。void GameWidget::onTimerOut() { QPoint newHead m_snake.first() m_direction; // 撞墙检测 if (newHead.x() 0 || newHead.x() kCols || newHead.y() 0 || newHead.y() kRows) { gameOver(); return; } // 撞自己检测 if (m_snake.contains(newHead)) { gameOver(); return; } bool eat (newHead m_food); m_snake.insert(0, newHead); if (!eat) { m_snake.removeLast(); } else { m_score 10; generateFood(); // 每吃5个食物可以适当加快定时器速度 if (m_score % 50 0 m_timer.interval() 50) { m_timer.setInterval(m_timer.interval() - 10); } } update(); }这里的 kCols 和 kRows 是固定的网格数比如20x20。检查撞墙时用网格边界判断不要用像素坐标判断否则会出现蛇头半个身子出去了才判定撞墙的视觉错误。撞自己检测用contains简单粗暴蛇身只有几十上百个点性能完全没问题。如果你以后做更复杂的游戏蛇身很长可以用一个二维标记数组这才是真正的优化方向。吃食物判断为什么用“相等”而不是“相交”因为坐标是网格化的蛇头和食物都精确落在整数格点上只要重合就是吃到不用处理浮点碰撞的毛刺问题。这是网格类游戏的优势也提醒我们做游戏时坐标系的设计要和逻辑模型保持一致。2.3 食物生成怎么做到不与蛇重叠食物生成的核心约束是新食物不能落在蛇身上也不能和上一个食物重合。实现方式挺多最常见的就是死循环随机试位置直到找到一个空闲格子。由于蛇身占整个网格的比例通常很小这种方法是极快的。void GameWidget::generateFood() { QRandomGenerator *gen QRandomGenerator::global(); do { int x gen-bounded(kCols); int y gen-bounded(kRows); m_food QPoint(x, y); } while (m_snake.contains(m_food)); }注意随机数生成器的选择。Qt 5.10之后建议用 QRandomGenerator::global()它底层是线程安全的、质量好的随机数源不需要自己设置种子。老代码里的 qrand() 和 qsrand() 就别用了那是过时API在Qt 6里已经移除。还有一个隐患如果蛇身占满了整个网格这个死循环会永远跳不出去。虽然在实际游戏中几乎不可能发生但严谨一点的话可以在生成之前判断 snake.size() 是否等于 kCols * kRows如果满了就直接播放胜利动画结束游戏。这就是“简略版”和“完整版”的差别简略版可以不管但心里要清楚边界在哪里。3. 绘图、布局与按键交互3.1 用QPainter画网格、蛇和食物绘图是贪吃蛇的门面。重写 paintEvent在函数里创建一个QPainter然后按顺序绘制背景、网格线、食物、蛇身、分数和提示文字。void GameWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 背景 painter.fillRect(rect(), QColor(#f7f7f7)); // 网格线 QPen gridPen(QColor(#d0d0d0)); gridPen.setWidth(1); painter.setPen(gridPen); for (int x 0; x kCols; x) { int px x * cellSize(); painter.drawLine(px, 0, px, height()); } for (int y 0; y kRows; y) { int py y * cellSize(); painter.drawLine(0, py, width(), py); } // 食物画成红色圆球 painter.setBrush(QColor(#e74c3c)); painter.setPen(Qt::NoPen); QPointF foodCenter gridToPixel(m_food) QPointF(cellSize() / 2.0, cellSize() / 2.0); painter.drawEllipse(foodCenter, cellSize() / 2.0 - 2, cellSize() / 2.0 - 2); // 蛇身 painter.setBrush(QColor(#2ecc71)); for (const QPoint p : m_snake) { QRectF r QRectF(gridToPixel(p), QSizeF(cellSize(), cellSize())) .adjusted(2, 2, -2, -2); painter.drawRoundedRect(r, 6, 6); } // 分数 painter.setPen(Qt::black); painter.drawText(12, 30, QString(得分: %1).arg(m_score)); }这里最关键的映射关系是网格坐标到像素坐标的转换。假设每个格子边长25像素那么 (col, row) 对应的像素位置就是 (col * 25, row * 25)。整个窗口的宽度和高度直接拿 kCols * cellSize 和 kRows * cellSize 去算不要再手动填一个“看起来差不多”的数字否则网格线和蛇身的位置永远对不齐。3.2 界面布局不能将就有人觉得贪吃蛇的界面就是一块画布一个QLabel都不需要。如果你的目标只是跑通逻辑确实可以。但为了让界面更规整建议用一个垂直布局上面放一个显示分数的QLabel下面放GameWidget。我见过很多新手直接把GameWidget作为MainWindow的中心部件然后在paintEvent里画分数这样不是不行但一旦想调整分数显示的位置、字体、颜色就得改绘图代码很别扭。用布局管理器把“数据展示”和“核心画布”分开是一种更清晰的思维。如果你用Qt Designer拖界面记得在属性面板里把GameWidget的sizePolicy设置成Expanding这样窗口拉伸时画布会跟着缩放。算格子大小时在resizeEvent里用 width() / kCols 动态更新别写死在构造函数里。3.3 键盘事件方向的防误判键盘事件是游戏交互的核心。重写keyPressEvent根据按下的方向键切换移动方向。这里有两个坑必须提前处理。第一个坑是“直接反向”。蛇正在向右移动时你往左按蛇头会直接撞到脖子。所以每个方向都要做判断只有新方向和当前方向不构成反向才允许切换。void GameWidget::keyPressEvent(QKeyEvent *event) { switch (event-key()) { case Qt::Key_Up: if (m_direction ! QPoint(0, 1)) m_direction QPoint(0, -1); break; case Qt::Key_Down: if (m_direction ! QPoint(0, -1)) m_direction QPoint(0, 1); break; case Qt::Key_Left: if (m_direction ! QPoint(1, 0)) m_direction QPoint(-1, 0); break; case Qt::Key_Right: if (m_direction ! QPoint(-1, 0)) m_direction QPoint(1, 0); break; case Qt::Key_Space: togglePause(); break; default: QWidget::keyPressEvent(event); break; } }第二个坑是“按键方向没反应”。排查方向先检查GameWidget有没有设置焦点策略。默认情况下QWidget是不接受键盘焦点的你必须在构造函数里写setFocusPolicy(Qt::StrongFocus);如果不加这行keyPressEvent可能永远不被触发。这个坑非常隐蔽很多人查了半天代码逻辑没问题其实就是焦点没拿住。3.4 防闪烁与刷新调优QWidget的默认绘制行为在某些平台下会先清空整个窗口再重绘导致游戏画面一闪一闪的。尤其是蛇移动速度快的时候闪烁会特别明显。解决办法在构造函数里加两个属性设置setAttribute(Qt::WA_OpaquePaintEvent, true); setAttribute(Qt::WA_NoSystemBackground, true);告诉Qt背景由你自己完全覆盖不用系统先擦一遍。这样能减少无效的重绘操作。如果你做的是更复杂的动画场景再考虑用QGraphicsView框架或者OpenGL/QML那是后话。另外QTimer的interval决定游戏速度一般不要低于50毫秒否则人类反应不过来画面也不流畅。我做测试时最快的速度是30毫秒一帧这时候闪烁和手感都还可以但再快就会明显感觉到“跳变”因为蛇每次移动是一整格颗粒度太大了。4. 常见问题与排查技巧实录4.1 中文乱码问题贪吃蛇界面里基本都有“得分”“暂停”“游戏结束”这些中文不处理好乱码会相当掉价。Qt 5在MSVC编译时如果源文件不是UTF-8编码字符串字面量可能出现乱码。解决办法是把源文件全部保存为UTF-8编码在.pro或CMake里设置编译选项支持UTF-8。如果是MSVC可以在pro里加msvc { QMAKE_CXXFLAGS /utf-8 }或者在源文件顶部加编译指令。更稳妥的做法是尽量使用QStringLiteral或tr()包裹中文字符串。Qt的tr()机制本来就是为了国际化准备的虽然贪吃蛇用不到多语言但养成用tr的习惯没错。4.2 按键没反应、定时器不触发按键没反应我前面已经说了一个焦点问题。还有一个容易被忽略的是如果界面上还有别的控件抢焦点比如某个按钮处于按下状态键盘输入会直接发给那个控件。检查方式是运行程序后点击一下空白画布如果按键恢复正常说明焦点被别的控件抢走了。定时器不触发的常见原因是事件循环卡住了。比如在槽函数里写了一个死循环或者用了sleep()整个UI线程会被堵住QTimer信号发不出去。记住QTimer依赖事件循环工作任何阻塞操作都会让定时器“停摆”。如果真的需要在后台做耗时操作应该把逻辑放到QThread里通过信号和槽通信。4.3 打包发布与运行报错开发机上跑得好好的把exe拷到别的电脑上却弹窗说“no qt platform plugin could be initialized”这个问题几乎每个Qt新人都遇到过。本质是exe运行时找不到Qt的平台插件比如windows平台的qwindows.dll。解决方法是使用Qt自带的windeployqt工具在命令行进入exe所在目录执行windeployqt snake.exe它会自动把所有依赖的Qt运行库和插件拷贝到exe旁边包括platforms目录。执行完之后把整个文件夹压缩发给别人就能直接运行了。需要注意的是windeployqt只能识别它扫描到的依赖。如果你代码里用了QCustomPlot、FFmpeg等第三方库还是要手动把对应dll一并拷贝进去不然到别人机器上会缺库。我一般会在发布前用Process Explorer或者livestatus查看实际加载了哪些dll再逐一核对。4.4 崩溃排查经验贪吃蛇算得上稳定但也会崩。最容易崩的是蛇身坐标数组越界。比如在初始化时只给蛇身塞了三个点而碰撞检测代码直接访问了m_snake[5]这种索引Qt的QList在debug模式下会断言在release下就是未定义行为可能出现诡异的现象。所以写代码时尽量使用at()接口而不是[]来读取只读数据至少保证越界时能拿到一个明确的异常。另外如果你在某个槽函数里delete了GameWidget然后又访问它这就是经典的悬空指针崩溃。做这类小项目我用调试器的经验是崩溃时先看调用栈十有八九能找到是哪个成员函数在操作哪个对象。不要一崩就重启程序瞎试。5. 从简略版到完整版扩展方向5.1 把运行数据可视化贪吃蛇玩完之后可以顺手做一个数据回放模块。比如记录每一帧蛇头的位置、速度、分数然后把这些数据拿出来展示一条分数增长曲线、移动轨迹图。Qt里做曲线图最顺手的就是QCustomPlot。我见过一个不错的练手方式用QCustomPlot把每次吃到食物时的当前分数、耗时、速度画成散点图横轴是吃到的第几个食物纵轴是当时的游戏速度。这样你一眼就能看出自己技术进步了多少、瓶颈在哪里。这不只是炫技也让你理解“界面展示”和“业务数据”分离的好处游戏里存的是纯数据展示层想画成什么样就画成什么样。5.2 外设控制与远程遥控把贪吃蛇玩出花来的一个方向是通过串口或者网络来控制方向。比如用一块STM32开发板加上摇杆串口发送指令PC端收到指令后改变蛇的移动方向。这样游戏本身的代码改动不大但涉及了Qt串口编程QSerialPort和协议解析整个项目的含金量就不一样了。另一个方向是用Qt的网络模块QTcpSocket/QUdpSocket做一个远程控制端。手机或另一台电脑发方向键指令游戏窗口在另一台机器上跑。做这个扩展时会接触到客户端/服务器架构、数据包设计、粘包拆包等问题都是实际工程里躲不开的。提醒一句如果你用HTTP接口和QNetworkAccessManager做通信注意处理网络异常有些旧版本对POST请求返回状态的处理比较粗糙接口报错了也不提示调试起来很费劲。5.3 多关卡、音效和存档再往深了做就是完整休闲游戏的套路了。加场景不同关卡地图有障碍物蛇碰到障碍物游戏结束加道具随机刷加速道具、减速道具、穿墙道具加音效吃食物时播放短促提示音用QSoundEffect加载wav格式的音效文件体积小延迟低。存档功能也可以顺手加上把当前蛇身坐标、分数、速度、地图种子序列化到本地文件。Qt的QSettings或者QDataStream都能做。QSettings适合存键值对存档配置很方便QDataStream适合存二进制数据速度快。记住写文件时考虑版本号字段以后存档格式变了也好做兼容迁移。最后补充一个调试小技巧写完贪吃蛇调试时最实用的一个技巧就是“可视化中间状态”。比如你想确认碰撞检测是不是在正确的时刻触发可以在paintEvent里临时画一个红色边框把蛇头的坐标范围画出来眼睛看到的和逻辑里的数值一对照问题马上就清楚了。Qt的绘图调试比打日志直观得多用好了能省不少时间。再分享一个我踩过的坑早期版本我在游戏结束时用了QMessageBox弹窗提示结果弹窗抢了焦点定时器还在跑关闭弹窗后蛇已经撞墙撞了好几次。后来改成游戏结束先把定时器停掉再弹窗最后重置数据流程就顺了。这个顺序问题看起来简单但做游戏逻辑时“暂停、结束、重置”这些状态切换的顺序很值得你多花几分钟仔细想清楚。本文还有配套的精品资源点击获取