
贪吃蛇这个项目说句不太客气的话属于编程练习里被写烂了但总想亲手再做一次的经典款。大学用控制台写过一遍后来系统学了 C 和 GUI 开发手痒又用 Qt 重写了一遍。结果发现这个项目真不是随便玩玩那么简单——QWidget 窗口、QPainter 绘图、QTimer 定时器、键盘事件、类设计、碰撞检测几乎全摊上了对一个刚学会 C 语法、想接触图形界面开发的新手来说是最完整也最舒服的第一课。这篇文章记录的是我用 Qt 5.15.2 MSVC2019_64 从空工程到一个可玩版本的全过程重点放在环境选择的坑、游戏数据结构和三个核心类怎么划分、QPainter 绘制贪吃蛇界面的细节、QTimer 游戏循环与方向队列的处理以及我实际踩过的几个 Bug。看完之后你能直接从零搭出项目而不是只得到一个孤零零的源码文件。1. 环境与工程搭建版本、编译器与运行库的三重坑1.1 选 Qt 5.15.2 还是 Qt 6.x网上搜 Qt 下载跳出来的版本五花八门新手很容易随便抓一个就开始装。我这里选的是 Qt 5.15.2原因很实在Qt 5 系列到后期维护已经非常稳定网上教程和第三方库的兼容性也最好Qt 6 虽然新但模块划分和个别 API 变化对初学者不友好。如果你学 C 用的教材是基于 Qt 5 的那就别犹豫直接 5.15.2。安装时还要面对编译器选型。Qt 官方安装器会问你套件常见的是MSVC2019_64和MinGW。选MSVC2019_64意味着你在 Windows 上需要依赖 Microsoft Visual C 运行库也就是多数人搜索的 Visual C Redistributable (x64) 那个东西。如果你电脑已经装了 Visual Studio那运行库一般已经在直接用 Qt Creator 编译跑起来没问题没装的话运行 exe 时大概率报错找不到 VCRUNTIME140.dll。这时候别慌去微软官方下载对应的 VC 运行库装一遍就解决了不用重装 Qt。我的建议是既然走 C 路线就直接装 Visual Studio Community然后把 Qt Creator 的构建套件指向 MSVC 工具链这样调试器的体验比 MinGW 版本好不少后续学 Windows 原生开发也用得上。1.2 Qt Creator 和 VS Code 怎么选不少新手看到写 C就想着用 VS Code 配 c/c插件我也走过这条路。但做 Qt 项目我强烈建议头一两次老老实实用 Qt Creator。原因很简单Qt Creator 打开.pro文件后会自动解析 include 路径、自动配置构建套件、按 F5 就能断点调试你几乎不用管环境变量和 tasks.json 那些破事。VS Code 也能配但你需要手动在c_cpp_properties.json里写 Qt 的 include 目录对新手来说就是多了一堆和无头苍蝇一样的报错机会。我自己在配置阶段就遇到过类似的报错信息:-1: error: dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets\qapplication.h does not exist排查了一圈问题是工程路径带了中文目录Qt 的 shadow build影子构建生成路径里就出现了乱码导致依赖解析失败。解决方案也简单项目放在纯英文路径下删除构建目录后重新构建。这类问题没有太多技术含量纯粹是工具链的路径处理太脆弱早踩早预防。1.3 用不用 Qt Designer我建议不用Qt 自带所见即所得的设计器可以拖控件生成界面很多教材也喜欢这么演示。但贪吃蛇这种游戏项目界面几乎全部是自定义绘制没有窗体和按钮值得拖。我更推荐手写代码创建窗口、状态栏和 timer这样你能看清控件创建顺序也能理解主窗口的结构以后的游戏项目包括一些嵌入式项目都没法完全靠设计器完成手写是绕不开的基本功。这个阶段的所有准备做完了下一步就是思考游戏本身蛇的数据结构怎么设计类怎么划分。这比环境安装重要得多因为贪吃蛇唯一的难点其实不在绘图而在逻辑。2. 数据结构先行用 C 表达蛇而不是硬编码2.1 蛇的移动模型我为什么选了 QVectorQPoint写贪吃蛇之前你首先要回答一个问题程序里的蛇在内存中长什么样最直观的模型是一串坐标点每个点代表蛇身占用的一个格子。比如蛇头在 (5,5)第二节在 (4,5)第三节在 (3,5)那么向右移动一步后变成新头 (6,5)原来的 (5,5) 变成第二节最后一节从尾部删掉。核心思路就是头插 尾删。C 里能实现这种操作的东西不少std::listQPoint经典双向链表头插尾删效率高但要判断蛇头是否撞到身体需要遍历链表而且内存不连续QVectorQPoint连续内存随机访问 O(1)头插会移动元素但对贪吃蛇这种最多几百节的数据规模来说这点开销几乎可以忽略std::dequeQPoint双端队列头尾操作都高效也支持随机访问其实是很不错的选择。我最终用了QVectorQPoint主要原因是它和 Qt 的紧密结合。QPoint本身就是整数坐标对QVector可以直接塞进信号槽的参数中后面调试时qDebug() snake.body()打印的东西也清爽直观。蛇类大概长这样class Snake { public: Snake() : m_grow(false) { // 初始蛇身三段头在中间尾巴朝左 m_body QPoint(10, 10) QPoint(9, 10) QPoint(8, 10); } void move(QPoint direction) { QPoint newHead m_body.first() direction; m_body.push_front(newHead); if (!m_grow) { m_body.pop_back(); } else { m_grow false; // 这个标记只维持一个 tick } } void setGrow() { m_grow true; } bool contains(QPoint pos) const { return m_body.contains(pos); } const QVectorQPoint body() const { return m_body; } private: QVectorQPoint m_body; bool m_grow; };这里有一个非常容易踩的细节m_grow是用来延迟尾巴删除的标记。当你吃到食物时希望蛇身增长一格而不是当场删掉尾巴。正确做法是在吃到食物的那一帧调用setGrow()然后move()里发现m_grow true就只插头不删尾同时把标记清掉。如果标记不清除下一帧蛇还会继续变长等于吃了一颗食物涨了两格。2.2 方向表示与禁止180度掉头方向我用QPoint而不是整型枚举QPoint dirRight(1, 0); QPoint dirLeft(-1, 0); QPoint dirUp(0, -1); QPoint dirDown(0, 1);用QPoint的好处是移动计算直接是坐标加向量代码里写head dir就行不用再做 switch 映射。但这里有个必须处理的规则蛇不能直接反向比如当前方向是向右你按下左键蛇头会直接钻进自己身体。处理反向逻辑时最经典的错误是只和当前方向做比较代码长这样if ((newDir currentDir) QPoint(0, 0)) { // 相反方向忽略 }这个判断单独看没问题但后面还会遇到连续快速按两个方向键的场景我们到第四章再说。这里先记住新方向与当前方向不能互为相反数。2.3 食物随机生成与存活空格子列表食物类我一开始写得很简单随机生成一个坐标再用 while 循环检查是否在蛇身上do { foodPos QPoint(rand() % cols, rand() % rows); } while (snake.contains(foodPos));游戏刚开始时有 400 个格子蛇只有 3 节这种随机碰撞非常快。但如果蛇身已经占了 360 个格子剩下 40 个空格while可能要循环很多次才能随机到一个合法位置极端情况下如果蛇身占满 399 个格子、只剩 1 个空格每一次随机命中的概率是 1/400运气不好会卡顿很久。我没有过度优化这个逻辑只是改成收集所有空格子再从中随机选一个QVectorQPoint 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; } } if (!freeCells.isEmpty()) { int idx QRandomGenerator::global()-bounded(freeCells.size()); foodPos freeCells[idx]; }贪吃蛇地图大量只有 20x20 左右全图扫描每次也就 400 次检查算上contains遍历蛇身消耗依然很低但行为上完全消除了随机死循环的风险。如果你把地图扩到 50x50这个方案依然撑得住就算格子数量过万你还可以再用一份空闲集合做增量维护但这是后话新手不用管。3. 绘制与窗口系统QPainter 做 2D 画面的实操细节3.1 渲染选型为什么不用 QGraphicsViewQt 做 2D 游戏常见的路数有 QGraphicsView QGraphicsScene、QSprite 动画、以及自定义 QWidget 重写paintEvent。当时也纠结过用 QGraphicsView毕竟它封装了场景管理和碰撞检测听起来更高级。但贪吃蛇的本质只是画一堆方块和几条网格线QGraphicsView 的高级功能一个都用不上反而把逻辑搞复杂你不但要管 item还要处理 item 的 z 值、场景刷新很容易把自己绕进去。我的选择是直接用 QWidget 重写paintEvent加 QPainter原因有两条一是绘制路径最短代码量最少二是 QPainter 是 Qt 绘图的底层基础这个学好了以后无论做图表、绘图工具还是自定义控件底层思路都一样。搜索引擎里很多人问qt 绘图、qt桌面画线归根结底都是在问 QPainter 用法的不同侧面。3.2 paintEvent 里到底画了哪些东西我的 GameWidget 继承自 QWidget整个绘图代码都放在paintEvent(QPaintEvent *)里。核心代码大致如下void GameWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 1. 背景 painter.fillRect(rect(), QColor(28, 30, 34)); // 2. 网格线 painter.setPen(QPen(QColor(48, 52, 58), 1)); for (int x 0; x m_cols; x) { painter.drawLine(x * CELL_SIZE, 0, x * CELL_SIZE, m_rows * CELL_SIZE); } for (int y 0; y m_rows; y) { painter.drawLine(0, y * CELL_SIZE, m_cols * CELL_SIZE, y * CELL_SIZE); } // 3. 食物 painter.setPen(Qt::NoPen); painter.setBrush(QColor(231, 76, 60)); painter.drawEllipse(m_food.pos() * CELL_SIZE QPoint(3, 3), CELL_SIZE / 2 - 3, CELL_SIZE / 2 - 3); // 4. 蛇身 const auto body m_snake.body(); for (const QPoint p : body) { QRectF r(p.x() * CELL_SIZE 1, p.y() * CELL_SIZE 1, CELL_SIZE - 2, CELL_SIZE - 2); painter.setBrush(QColor(52, 152, 219)); painter.drawRoundedRect(r, 6, 6); } // 5. 蛇头稍微亮一点 if (!body.isEmpty()) { QPoint head body.first(); QRectF r(head.x() * CELL_SIZE 1, head.y() * CELL_SIZE 1, CELL_SIZE - 2, CELL_SIZE - 2); painter.setBrush(QColor(241, 196, 15)); painter.drawRoundedRect(r, 6, 6); } }这里有个新手最容易看懵的点逻辑坐标和像素坐标的换算。m_food.pos()存的是格子坐标 (x, y)取值范围是 0~19要画到屏幕上必须乘上CELL_SIZE才能得到像素坐标。我见过有人画蛇时漏乘结果所有方块全堆在窗口左上角一个像素里调试时还以为蛇没显示出来。另一个细节是drawRoundedRect(..., CELL_SIZE - 2, CELL_SIZE - 2)留了 2 像素间隙。如果不留蛇身相邻两格画出来完全连在一起视觉上就是一块长条板子看不出蛇的节数交互体验很怪。网格线留不加粗也是这个道理。3.3 双缓冲与闪屏到底要不要自己处理Qt 的 QWidget 默认自带双缓冲机制你在paintEvent里的绘制会先画到后台缓冲再一次性提交到屏幕上。所以新手完全不需要手动创建QPixmap做离线绘图再贴图那样反而容易画出偏移或残留。如果你发现窗口闪屏先检查是不是在 paintEvent 里做了耗时操作比如动态加载大图片或者定时器刷新频率太吓人。贪吃蛇一秒钟刷新 6~8 次正常情况是不可能闪的。如果还闪可以给 GameWidget 设置setAttribute(Qt::WA_OpaquePaintEvent)告诉 Qt 这个控件会自行绘制全部区域跳过无关清理这在窗口尺寸变化时有一定优化效果但对本项目的实际帮助很有限——知道有这回事就行。绘制层面还有一个反直觉的点贪吃蛇的运动是离散的不是连续动画。这意味着你不需要每帧重绘蛇移动的中间状态只需要 tick 到来时改一次蛇身坐标然后update()一下。如果你画出了蛇平移的连续动画说明你的更新逻辑掺进了多余的时间插值反而容易让碰撞判断和画面不同步。4. 游戏循环与控制QTimer、按键事件和方向锁4.1 用 QTimer 驱动游戏循环拒绝 while(true)很多从控制台编程转过来的新手第一反应是写一个死循环来驱动游戏while (true) { snake.move(...); widget.update(); Sleep(150); }这段代码放在 GUI 程序里会立刻让窗口假死——因为 Qt 的事件循环被这个 while 卡死了窗口没有机会处理鼠标、键盘和绘制消息。正确做法是让 Qt 自己的事件循环来挤时间驱动游戏也就是 QTimerQTimer *timer new QTimer(this); timer-setInterval(150); connect(timer, QTimer::timeout, this, GameWidget::onGameTick); timer-start();onGameTick里面做一件事推进游戏逻辑一步并请求重绘void GameWidget::onGameTick() { if (m_state ! GameState::Running) return; m_snake.move(currentDirection()); if (checkCollision()) { gameOver(); return; } if (m_snake.body().first() m_food.pos()) { m_snake.setGrow(); spawnFood(); } update(); }注意GameState判断一定要放在最前面。否则游戏结束和暂停状态下timer 还在触发逻辑你会看到已经 Game Over 了蛇还在继续游走的诡异现象。还有一个速度递增的小技巧。随着得分增加想让蛇跑得越来越快可以在每次得分时修改 timer 间隔int newInterval qMax(80, m_timer-interval() - 5); m_timer-setInterval(newInterval);在 QTimer 运行中修改 interval 是安全的它会从下一个周期开始生效不需要 stop 再 start。我把速度下限设成 80ms因为再快就接近人类反应极限操作上的误差也会被无限放大游戏会从考验操作变成纯粹拼运气。4.2 方向队列与 180 度掉头的完整解法第四章开头提过的连续按键问题这里展开说。考虑这样一个场景蛇正向右走玩家在同一个 tick 间隔内快速按了向上、再按向左。如果程序只保存最后一次按键的方向那么当前方向是左——但蛇此刻实际还在向右移动这一帧结束后蛇头会向右前进一步下一帧再向左玩家瞬间反向撞到自己身体。更严谨的做法是维护一个方向队列std::queueQPoint m_directionQueue; void GameWidget::keyPressEvent(QKeyEvent *event) { QPoint dir keyToDirection(event-key()); if (dir.isNull()) return; // 反向检查队列为空时跟当前方向比否则跟队尾方向比 QPoint lastDir m_directionQueue.empty() ? m_snake.currentDirection() : m_directionQueue.back(); if (dir lastDir QPoint(0, 0)) { // 反向输入直接忽略 return; } // 避免队列中连续两个相同方向 if (!m_directionQueue.empty() dir m_directionQueue.back()) { return; } m_directionQueue.push(dir); }然后在onGameTick里取出队首方向if (!m_directionQueue.empty()) { QPoint dir m_directionQueue.front(); m_directionQueue.pop(); m_snake.setDirection(dir); } m_snake.move(m_snake.currentDirection());这里的关键点有两个。第一反向判断不能只和当前方向比还要考虑队列中已排队的按键——否则连续按上、左时第二步向左默认向左但上一步在队列里的向上还在等待执行。第二队首方向的判断放在 move 之前而不是放在键盘事件中这保证了同一 tick 内按键队列里的方向和绘制顺序完全同步。4.3 焦点问题为什么按了方向键没反应另一个很隐蔽的问题是焦点。如果你的 GameWidget 界面上还有 QLabel、QPushButton 这类默认可聚焦的控件键盘事件默认会被焦点控件接收而不是你的游戏界面结果就是按键没反应玩家心态直接崩溃。解决办法有两种一种简单粗暴在构造函数里写setFocusPolicy(Qt::StrongFocus)并把分数显示控件设置为setFocusPolicy(Qt::NoFocus)保证焦点永远在 GameWidget 上另一种更通用的是在 MainWindow 里安装事件过滤器拦截键盘事件再转给游戏逻辑。新手用第一种就够了。如果你后来把这款游戏移植到触摸设备或者需要模拟键盘操作思路也是一样只是把keyPressEvent的来源换成外部信号而已。Qt 的事件机制本质上是谁注册谁接收你在初学阶段把这个模型想清楚后面用信号槽处理各种输入都会顺很多。5. 从零做完后我踩过的 Bug日志、边界和内存5.1 崩溃一蛇身坐标访问越界游戏刚上手时我写了一个小测试开局后蛇直接往右走每次 tick 输出m_snake.body().first()。结果有一次我手贱在paintEvent里想绘制蛇头却写成了遍历body[i]且i从 0 到m_length而m_length是从某个未初始化的成员变量读出来的。Qt 里面这类问题的表现非常统一窗口能弹出来但执行到绘图那一行就闪退qDebug()里没有任何错误信息。排查方法也很固定——在可疑的循环里加打印确认下标范围。这个让我学到的教训是QVector::first()和last()在空容器上会直接崩溃但[]越界访问却是未定义行为可能不崩但画出垃圾内容。正式游戏逻辑里我不再去赌蛇永远至少有三节而是封装一个isEmpty()判断。5.2 碰撞检查顺序先移动后检查最容易出错的是碰撞检查的时机。错误版本是这样的m_snake.move(dir); // 检查新头是否撞墙 if (head.x() 0 || head.x() cols) gameOver(); // 检查新头是否撞到身体 if (snake.body().indexOf(head, 1) ! -1) gameOver();你看到问题没有移动之后蛇头已经进到新位置但你用新位置的坐标去判断是否撞墙逻辑上没问题。问题出在身体判断上indexOf(head, 1)从下标 1 开始搜索这个方案理论上也正确。但如果你先判断是否吃到食物再判断是否撞墙就会出大问题。我实际碰到过的场景是蛇头移动到新位置那个位置恰好有食物于是蛇身增长但新位置同时也是墙壁边界之外。此时如果食物判断写在撞墙判断前面spawnFood()会在一个已经游戏结束的状态里执行产生一个无效的食物。所以正确的顺序永远是移动 - 撞墙/撞身 - 吃食物这个顺序不能乱。贴一段当时修正后的完整 tick 逻辑void GameWidget::onGameTick() { if (m_state ! GameState::Running) return; if (!m_directionQueue.empty()) { m_snake.setDirection(m_directionQueue.front()); m_directionQueue.pop(); } m_snake.move(m_snake.currentDirection()); // 1. 先动 QPoint head m_snake.body().first(); if (head.x() 0 || head.x() m_cols || head.y() 0 || head.y() m_rows) { // 2. 撞墙 gameOver(); return; } if (m_snake.body().indexOf(head, 1) ! -1) { // 3. 撞身 gameOver(); return; } if (head m_food.pos()) { // 4. 吃食物 m_snake.setGrow(); m_score 10; spawnFood(); } update(); }5.3 运行库缺失与编译通过但启动失败文章开头讲环境时提到过VCRUNTIME140.dll的问题我在实际把 exe 拷贝到别的机器上跑时也遇到了。把 debug 版 Qt 的 DLL 一起带过去是另一个常规坑Qt5Cored.dll、Qt5Widgetsd.dll这类带d后缀的文件是调试版专属发布程序时必须用 Qt 的 windeployqt 工具打包。windeployqt .\Snake.exe这个工具会把当前 exe 依赖的 Qt 运行库和平台插件自动复制到同目录。做完这一步再把整个目录拷给对方一般就不会报缺 DLL 了。如果还报错检查是不是目标机器缺 VC 运行库——也就是搜索热词里反复出现的microsoft visual c redistributable装一遍最新版即可。5.4 定时器悬空与界面对象生命周期还有一个很隐蔽的崩溃来源你在 GameWidget 构造函数里 new 了一个 QTimer并connect到成员函数。当窗口关闭时GameWidget 被销毁QTimer 作为子对象理论上也会被析构。但如果你在堆上手动 new 了一个 GameWidget却没有delete那这个 timer 可能还在跑回调函数访问到已经失效的成员对象导致崩溃。我习惯的做法是所有 QObject 子类统一在构造函数里new并指定this为父对象游戏结束时在gameOver()里调用m_timer-stop()。这样即使父对象销毁顺序出问题timer 也已经停止回调把崩溃路径掐断在源头。调试这类问题qDebug 是救命稻草。我一般会写qDebug() tick, head m_snake.body().first() , state (int)m_state;日志一打游戏状态机的流转立刻一清二楚比盯着屏幕看蛇乱跑强得多。6. 项目扩展别让贪吃蛇永远只是贪吃蛇6.1 用 QSS 把界面从简陋抢救回来默认的 QWidget 界面确实丑但 Qt 的 QSS 可以在不引入任何素材的情况下把界面做得像样。我花了半小时写了一段样式表给主窗口加了深色渐变背景分数标签改成圆角白字状态栏分隔线调成半透明。这不需要改任何游戏逻辑纯粹是 UI 层面的锦上添花但对拿得出手这三个字来说非常关键。QSS 的语法类似 CSS但作用范围只限 Qt 控件。新手注意一条坑自定义绘制的 GameWidget 不接受 QSS 的background-color属性因为它是通过 paintEvent 完全自绘的。你想改背景还得到 paintEvent 里面改 QPainter 的 fillRect 颜色。6.2 把最高分存起来一个真实的数据库入门场景初级版贪吃蛇一关窗口分数就没了体验上差一口气。给这个项目加最高分持久化是学习数据库操作的好场景。你可以用 SQLite也可以用我后来接触的 TDengine——后者更偏时序场景这里其实杀鸡用牛刀。SQLite 在 Qt 里用起来简单到不像数据库QSqlDatabase db QSqlDatabase::addDatabase(QSQLITE); db.setDatabaseName(snake.db);但要注意的是写库操作不要在 UI 线程的主循环里同步执行。一个稳妥的做法是用信号槽把游戏结束分数信号发出主窗口在收到信号后用单独的线程或者至少延迟一下再写库避免写入耗时导致游戏结束瞬间界面卡一下。这在桌面上不明显但你以后做嵌入式或移动端时会发现这一步的体验差距很大。6.3 架构升级从全部塞进Widget到model/view思路贪吃蛇初版代码最丑的地方是 GameWidget 里既包含游戏逻辑状态、又包含绘制代码两者耦合在一起。等你想把游戏移植到不同界面上时比如换成 QML、换个皮肤、接一个网页前端做数据面板就会非常痛苦。往 model/view 方向重构一点把 Snake、Food、GameState 全挪到一个逻辑类 GameModel 里它不碰任何 UI 代码GameWidget 只负责监听 GameModel 的通知然后重绘。这个思路和搜索热词里频繁出现的qt 表格大数据卡顿优化 tablewidget 到 qtableview 自定义 model是同一个思想只不过场景更小。你在贪吃蛇上养成了逻辑与显示分离的习惯以后做 QTableView 自定义 model、做 MVVM 框架、甚至写 Qt Vue3 那种前后端混搭方案都会顺畅很多。写到这里我的贪吃蛇项目基本到了一个可以拿给朋友玩的完成度。实话实说这个项目不算难但它逼着你同时处理 C 的容器与生命周期、Qt 的事件循环、绘图和定时器任何一个环节掉链子游戏都跑不顺。如果你也是刚学完 C 语法、准备接触 GUI 开发我建议别去抄网上的完整源码按这篇文章的顺序一步步写遇到 bug 就把 qDebug 打起来慢慢定位。亲手把这套流程走通之后你对 Qt 项目的理解会比看一百集教程都扎实。