
简介一份基于Qt框架、用约200行代码实现的贪吃蛇游戏完整工程适合正在学习界面编程或桌面应用开发的Qt初学者。项目以主窗口和图形视图构建游戏画面通过定时器驱动蛇体移动利用方向键控制转向实现了食物随机生成、碰撞检测、长度增长、计分显示等核心玩法。读者可以从中理解Qt的界面布局、事件处理、图形渲染以及信号与槽机制并能学习如何将游戏逻辑与界面类分离。资源包共有68个文件压缩后约57.11MB包含源文件和头文件、Qt工程配置、图片素材、构建中间文件及可直接运行的exe程序源码和资源目录分离便于按需查看和二次修改。压缩包内多个目录区分明确附带的可执行程序也可直接运行体验。已有569人学习下载适合结合完整小游戏项目进行Qt开发实战练习。 如果你打算学 Qt 但一直卡在“不知道做什么练手”我强烈建议别一上来就去啃控件手册而是先写一个带界面的小游戏。贪吃蛇是我认为性价比最高的入门项目它涉及 Qt 的事件循环、键盘事件、绘图、定时器、状态管理这几个最核心的知识点而且整个坑相当短——200 行 C 就能跑出一个完整可玩的窗口程序。这篇文章就用一个真实的贪吃蛇 Demo 来讲清楚每一步是怎么设计的以及哪些地方容易踩坑。这个 Demo 适合三类人刚接触 Qt 想快速建立“窗口程序到底怎么跑起来”概念的新手准备做课程设计但不想用现成模板、想自己动手写清楚逻辑的人以及想把 Qt 的绘图和定时器机制从“看过”变成“真会用”的开发者。整体代码基于 QWidget QPainter QTimer 实现不依赖 QML、不依赖 QGraphicsView连图标、音频、动画都没有但该有的游戏逻辑一项不缺蛇的移动、吃食物增长、撞墙撞自己判定、分数统计、暂停和重启。1. 为什么用 200 行 Qt 代码写贪吃蛇1.1 贪吃蛇是 Qt 入门的“最小完整项目”很多人学 Qt 是从 QPushButton、QLabel、QLineEdit 这些控件开始的拖几个控件上去再连一下 clicked 信号感觉好像会了但一问“怎么自己画一个界面”“怎么处理键盘输入”“怎么让程序自己动起来”一下子就答不上来。原因很简单控件只是 Qt 的一小部分真正撑起一个应用的是事件循环、绘图系统和定时器调度而这些东西只有在写一个完整的小程序时才能串起来。贪吃蛇恰好把这个闭环补上了。它的核心循环非常明确定时器推进游戏状态、键盘事件改变方向、绘图函数把状态画到窗口上。这三个动作是几乎所有桌面游戏和工具类软件的通用骨架贪吃蛇用最少的代码量把它们全部覆盖了。写一遍之后再去理解 Qt 的 Model/View、信号槽、多线程那一套会轻松很多因为你已经知道底层的事件循环是怎么驱动的了。1.2 200 行是如何压缩出来的我最初写这个项目时其实写了三百多行后来删掉了很多“看起来很专业但完全没用”的部分才压到 200 行上下。压缩的关键在于不引入额外的类、不引入状态机库所有逻辑都放在一个 QWidget 子类里。按行数拆一下成员变量和构造函数大约 25 行初始化游戏和生成食物大约 25 行移动与碰撞判定大约 30 行绘制代码约 35 行键盘事件约 20 行main 入口不到 10 行剩下的几十行是信号连接、头文件包含和边界处理。这里面的核心思想是“能用 Qt 自带机制解决的绝不自己造轮子”比如定时器直接用 QTimer坐标直接用 QPoint随机数直接用 QRandomGenerator。少用一层封装代码就少一大截而且出问题时更容易定位。2. 环境准备Qt 安装与工程骨架2.1 版本与编译器怎么选做这个小项目不需要最新版本Qt 5.15.2 和 Qt 6 的任意 LTS 版本都可以。我的建议是如果你只是想快速跑通并且手头教程多是面向 Qt 5 的直接装 5.15.2如果是新项目且不担心资料版本差异装 6.5 或更新的版本也没问题本文涉及的 QPainter、QTimer、QKeyEvent 在 Qt 5 和 Qt 6 里用法完全一致没有迁移成本。编译器方面Windows 下通常两个选择MinGW 和 MSVC。MinGW 是 Qt 官方提供的开源工具链安装简单解压即用适合新手MSVC 适合需要和 Visual Studio 结合的场景。两者编译本项目都没有问题。需要提醒的是安装 Qt 时不要只装 Qt 库本身还要在组件列表里把你选择的编译器对应的 Kit 勾上比如 msvc2019_64 或 mingw81_64否则 Qt Creator 里找不到可用的编译套件打开项目会看到“No suitable kits”的提示。2.2 新建工程与最小配置在 Qt Creator 里新建工程时我习惯选择 Qt Widgets Application然后把 QMainWindow 改成 QWidget 的子类因为贪吃蛇不需要菜单栏、工具栏和状态栏。如果你用的是 CMake最终的构建配置非常简单cmake_minimum_required(VERSION 3.16) project(snake) find_package(Qt6 COMPONENTS Widgets REQUIRED) qt_add_executable(snake main.cpp snake.h snake.cpp ) target_link_libraries(snake PRIVATE Qt6::Widgets)如果用 qmake则是QT widgets CONFIG c11 SOURCES main.cpp snake.cpp HEADERS snake.h这个项目只依赖 Widgets 模块不需要 Core 之外的网络、图表等模块所以配置起来很干净。这里有个小经验如果 IDE 里自动生成的 .pro 或 CMakeLists 里带了乱七八糟的 QT 模块最好先删掉等编译报错说缺什么再加什么这样能避免编译时因为模块不对而出各种奇怪问题。2.3 编译套件与运行目录的小坑建好工程后第一次编译运行大概率会遇到两个问题一是套件没有配置好运行按钮是灰色的二是编译通过但程序启动时报错。排查思路很简单套件问题去 Qt Creator 的“工具 → 选项 → Kits”里检查编译器、调试器和 Qt 版本路径是否都选了启动报错最常见的是找不到平台插件这个我在第 5 节详细讲。先把这个基础环境弄通再开始写代码不然代码写了一半却被环境卡住很打击信心。3. 贪吃蛇核心设计数据、绘图、事件与时钟3.1 数据模型蛇身与食物用什么存贪吃蛇的数据模型其实特别朴素不需要数据库也不需要复杂结构。我用的方法是用一个 QList 存蛇身坐标QListQPoint snake; QPoint food; int dir;每个 QPoint 表示一个网格坐标比如 (3, 5) 表示第 3 列第 5 行的格子。为什么不直接用像素坐标因为游戏逻辑基于网格像素只是最终渲染的结果。用网格坐标判断碰撞、计算移动都要简单得多画的时候再乘以格子大小换算成像素就行。蛇身的增长逻辑是贪吃蛇的精髓蛇头每步前进一步如果没吃到食物蛇尾就收缩一格所以整体长度不变如果吃到了食物蛇尾不收缩长度加一。这个操作对应到 QList 上就是 prepend 新蛇头必要时 removeLast。有人可能觉得这里应该用链表因为蛇身本质上是一个队列但在这个格子规模20×20下QList 的复制成本几乎可以忽略而且代码更短更直观。对于这种玩具级项目可读性优先于性能优化。3.2 渲染方案QPainter 为什么够用绘制界面我选了 QPainter直接在 QWidget 的 paintEvent 里画。为什么不选 QGraphicsView因为在贪吃蛇这个场景里没有复杂的场景图、没有碰撞检测框架、没有拖拽交互QGraphicsView 的“场景-视图-图元”模型反而带来额外的抽象负担。为什么不选 QMLQML 适合做动画丰富、界面动态变化的应用但要把 C 数据暴露给 QML 再处理键盘事件入门成本比 QPainter 高不少。QPainter 的好处是命令式绘制代码从上到下一次性把画面画完先填充背景再画网格线再画食物最后画蛇身。绘制函数只负责读取当前游戏状态不修改任何逻辑数据这样就不会出现“绘制过程中状态被改变”的并发问题。这也符合一个重要的编码习惯绘制要和逻辑分离。3.3 驱动方式QTimer 如何驱动一局游戏游戏不是每帧死循环跑出来的而是由 QTimer 定时器周期性地触发逻辑更新。我设置了一个 150ms 的定时器每次超时就执行一次移动判断如果判断结果为 true游戏还在进行就调用 update() 触发重绘。这里最关键的一点是不能在 UI 线程里用 sleep 或者 while 循环做延时否则窗口会一直转圈变成假死状态。QTimer 是异步的到点之后会回到事件循环里执行回调界面在这个过程中始终保持响应。这种方式是 Qt 桌面应用的常见模式理解了它以后写任何定时刷新类的程序比如实时数据显示、波形绘制都能直接套用。4. 关键代码实现与逐段解析4.1 初始化、食物生成与移动逻辑初始化游戏时我把蛇初始化为三节方向朝右食物随机生成void SnakeGame::initGame() { snake.clear(); snake QPoint(4, ROWS / 2) QPoint(3, ROWS / 2) QPoint(2, ROWS / 2); dir Right; score 0; isRunning true; newFood(); }食物生成时要注意一个细节不能生成在蛇身上。最简单的办法是遍历所有格子把不在蛇身上的格子收集起来再随机挑一个void SnakeGame::newFood() { QListQPoint freeCells; for (int x 0; x COLS; x) { for (int y 0; y ROWS; y) { QPoint p(x, y); if (!snake.contains(p)) freeCells p; } } food freeCells[QRandomGenerator::global()-bounded(freeCells.size())]; }这个实现虽然直观但在蛇很长时每次都要遍历 400 个格子效率不高。不过对于一个入门 Demo正确性优先级高于性能而且 400 个格子的遍历在现代 CPU 上耗时可以在微秒级别完全不影响体验。移动逻辑是整个游戏的核心顺序是先算出新蛇头的位置再判断是否撞墙或撞自己如果没撞就更新蛇身列表bool SnakeGame::moveSnake() { if (!isRunning) return false; QPoint head snake.first(); switch (dir) { case Up: head.ry() - 1; break; case Down: head.ry() 1; break; case Left: head.rx() - 1; break; case Right: head.rx() 1; break; } if (head.x() 0 || head.x() COLS || head.y() 0 || head.y() ROWS || snake.contains(head)) { gameOver(); return false; } snake.prepend(head); if (head food) { score; newFood(); } else { snake.removeLast(); } return true; }这里有一个新手常犯的错误先判断 head 是否已经在 snake 里但如果蛇已经吃到食物且食物正好在旧蛇头前面contains 判断不会误判因为食物生成时已经避开了蛇身。真正容易出错的是“撞自己”的时机如果新蛇头等于蛇尾所在的格子而这一格不在蛇头紧邻的位置那么 contains 会判定碰撞这其实是合理的因为蛇头不能跨越蛇身。4.2 绘制代码10 行画完一整个界面绘制部分我保持了最直观的写法背景用深灰色网格线用更暗的颜色食物用红色椭圆蛇身用绿色方块蛇头稍微亮一点方便看清方向void SnakeGame::paintEvent(QPaintEvent *) { QPainter painter(this); painter.fillRect(rect(), QColor(30, 30, 30)); painter.setPen(QPen(QColor(60, 60, 60), 1)); for (int i 0; i COLS; i) painter.drawLine(i * BLOCK, 0, i * BLOCK, ROWS * BLOCK); for (int j 0; j ROWS; j) painter.drawLine(0, j * BLOCK, COLS * BLOCK, j * BLOCK); painter.setBrush(QColor(255, 80, 80)); painter.setPen(Qt::NoPen); painter.drawEllipse(food.x() * BLOCK 2, food.y() * BLOCK 2, BLOCK - 4, BLOCK - 4); for (int i 0; i snake.size(); i) { const QPoint p snake.at(i); painter.setBrush(i 0 ? QColor(100, 220, 120) : QColor(60, 180, 80)); painter.drawRect(p.x() * BLOCK 1, p.y() * BLOCK 1, BLOCK - 2, BLOCK - 2); } }要注意的是绘制函数里不能调用任何可能阻塞或重入的函数比如 QMessageBox 弹窗、sleep、update 等。如果你在 paintEvent 里又调用了 update()会引发无限递归轻则界面卡死重则程序崩溃。我的习惯是绘制函数保持“纯函数”风格只读状态、只画图不产生副作用。4.3 按键输入与反向保护键盘事件的核心是方向控制但有个隐藏的逻辑必须处理蛇不能直接掉头。向右移动时按左键应该忽略否则蛇头会直接穿进自己的身体游戏瞬间结束void SnakeGame::keyPressEvent(QKeyEvent *event) { switch (event-key()) { case Qt::Key_Up: if (dir ! Down) dir Up; break; case Qt::Key_Down: if (dir ! Up) dir Down; break; case Qt::Key_Left: if (dir ! Right) dir Left; break; case Qt::Key_Right: if (dir ! Left) dir Right; break; case Qt::Key_Space: isRunning !isRunning; isRunning ? timer.start() : timer.stop(); break; default: QWidget::keyPressEvent(event); } }空格键用来暂停和继续这是游戏体验中很容易被忽略但它很重要的功能。另一个容易被忽略的点是焦点问题如果你的窗口里还有其他控件或者窗口本身不是活动窗口键盘事件可能根本不会送到这个 QWidget 上解决办法是在构造函数里加上 setFocusPolicy(Qt::StrongFocus)。我不少次看到有人说“按键没反应”最后就是这个原因。4.4 main 入口与主循环最后是 main 函数代码短到没有太多可说的int main(int argc, char *argv[]) { QApplication app(argc, argv); SnakeGame game; game.show(); return app.exec(); }QApplication 创建了事件循环exec() 启动循环后程序进入了“等待事件-处理事件-重绘窗口”的循环状态。所有按键、定时器、重绘请求都是通过这个事件循环机制送达的这也是整个 Qt 程序能跑起来的根源。4.5 完整运行效果与代码行数核对把上面这些代码合到一起正好接近 200 行。运行之后就是一个 400×400 像素的窗口深色背景绿色蛇身红色食物键盘上下左右控制空格暂停。游戏结束后会弹出一个 QMessageBox 显示分数点击确认后自动重新开始。这个版本没有处理一个进阶问题游戏结束后立刻重新开局时如果玩家之前按了某个方向键可能会导致新一局开局瞬间就撞墙。我的处理是在 gameOver 函数里重置方向并清空蛇身再重新初始化游戏void SnakeGame::gameOver() { isRunning false; timer.stop(); QMessageBox::information(this, 游戏结束, QString(得分%1).arg(score)); initGame(); timer.start(); }QMessageBox 虽然简单但已经能撑起一个完整的“结束-提示-重开”流程。5. 常见报错与踩坑记录5.1 “no qt platform plugin could be initialized”很多人在自己电脑上双击编译好的 exe 时碰到这个报错它翻译过来是“找不到 Qt 的平台插件”。这不是代码的问题而是运行环境缺少 Qt 的动态库和插件目录。Qt 运行时需要找到 plugins/platforms/qwindows.dll才能创建窗口。解决方法是把插件目录复制到 exe 旁边或者在环境变量 PATH 中加入 Qt 的 bin 目录。最省事的方法是用 Qt 自带的 windeployqt 工具cd 你的exe所在目录 windeployqt snake.exe这个命令会自动把依赖的 DLL、插件、资源文件全部复制到 exe 当前目录。注意 windeployqt 需要和编译用的编译器版本一致MinGW 编译的就用 MinGW 版本的 windeployqt否则可能带入错误的运行库。5.2 按键无反应、画面不刷新按键没反应多数不是代码逻辑问题而是焦点问题。如果你在窗口里拖了按钮或其他控件焦点可能落在控件上QWidget 收不到箭头键消息。在构造函数里调用setFocusPolicy(Qt::StrongFocus)是最简单的解法。画面不刷新则要检查移动逻辑的返回值。我在 moveSnake() 里加了 bool 返回值游戏结束后返回 false这种情况下不会调用 update()。如果你把这段逻辑去掉改成无条件 update()就会出现“游戏结束了画面还在闪”的问题。正确的做法是只有游戏状态发生变化时才触发重绘。5.3 快速连按方向键导致蛇“自杀”这是贪吃蛇新手最容易踩的逻辑坑。假设蛇头向右玩家快速按了“上”再按“左”在同一个定时器周期内方向先变为上又立即变为左。由于定时器还没触发下一次移动蛇实际是向左移动而蛇头右侧是旧蛇身向左就会直接撞上自己游戏结束。解决方案有两种。最简单的是在按键时不允许反向这个我已经实现了。但更严谨的解决方案是引入一个 pendingDir 变量在定时器超时时才真正应用方向变更避免一帧内多次修改方向。200 行版本我选择了前者它已经能满足绝大多数场景如果之后想把这个游戏做得更耐玩建议升级到后者。6. 关于界面与手感调优的个人经验写到这里这个贪吃蛇已经能玩了。但如果你和我一样做完基础版后总觉得手感差一口气可以试试下面几个调整。把格子数从 20×20 调整到 16×16 或 24×24游戏节奏会明显不同格子越少场地越小战斗越激烈格子越多场地越大蛇身长度增长带来的压迫感更强。我觉得 20×20 是相对舒服的中间值适合大多数人。增加食物绘制的一个小技巧食物画成圆形会让界面看起来不那么“像素化”这也是我在绘制代码里用 drawEllipse 而不是 drawRect 的原因。蛇身的圆角也可以做但 QPainter 里画圆角矩形需要多一点代码考虑到 200 行的预算我保留为直角。最后是难度递增的扩展思路。最自然的做法是每吃 5 个食物定时器间隔缩短 10ms最低到 80ms 左右就不再缩短。代码上加一行判断即可但游戏体验会有一个明显的从休闲到紧张的过程。我个人在实际操作中强烈建议把这个加上它能让一个玩具项目瞬间有了一点“完成品”的感觉。如果你还想要更完整的存档、历史最高分、障碍物地图那 200 行就真的不够用了得按模块重构但那已经是下一步的事了。本文还有配套的精品资源点击获取