三百行Qt贪吃蛇:QWidget自绘与QTimer循环拆解
简介:这份基于 Qt 与 C++ 的简略版贪吃蛇项目,面向希望借小型案例理解图形界面与游戏循环的 Qt 初学者及 C++ 练习者,重点示范从窗口搭建、对象组织到碰撞检测、计分和结束判定的完整实现思路。压缩包共 8 个文件、约 209KB,以 2 个 cpp 源文件和 1 个 h 头文件承载主窗口与核心逻辑,配合 1 个 ui 界面文件、1 个 pro 工程文件、1 个 user 配置及 2 张 jpg 效果图,便于直接查看工程结构与运行界面。已有 264 人学习,适合按 Qt 工程方式打开研究,也方便对照效果图快速定位界面与逻辑入口。代码整体围绕 QGraphicsView/Scene 组织蛇、食物和边界对象,涉及键盘方向控制、定时器驱动移动、碰撞检测、蛇身增长、食物重生成、分数更新,并体现类职责划分和信号槽协作,可作为课程设计、Qt 入门练习或小型游戏原型参考。
1. 三百行代码的 QT 贪吃蛇:小到能一口气读完,大到能把 Qt 三大机制都摸一遍
一个基于 QT 和 C++ 的简略版贪吃蛇游戏,代码量通常只有三四百行,却能把 Qt 里最常打交道的三套机制全部走一遍:QWidget 自绘、QTimer 驱动的游戏循环、键盘事件分发。它不依赖任何第三方库,不用图片素材,一个qmake就能编出来,对刚摸 Qt 的人特别友好——你能在一晚上读完所有源码,而不是淹没在框架里。
它适合三类人:第一类是想把 Qt 的paintEvent、keyPressEvent、信号槽真正用一遍而不是只看文档的新手;第二类是手里有课程作业或小 Demo 需求,想要一个结构干净、能直接往上加功能的底座;第三类是老手,拿它当“最小可复现样本”,验证某个 Qt 版本的编译链、打包流程是否通。不适合的也很明确:想要物理引擎、粒子效果、关卡编辑器的,这份简略版给不了,它刻意砍掉了所有非核心的东西。
2. 项目骨架与技术选型:QWidget 自绘 + QTimer 驱动,为什么不用 QGraphicsScene
先看一个数字对比。用 QLabel 拼棋盘的做法,24×20 的格子就是 480 个控件对象,每一帧都要改几百个setStyleSheet,Qt 的样式表解析开销会直接把帧率拖垮;用 QGraphicsScene 则要引入 scene、view、item 三层坐标,对一个只有“蛇 + 食物”两个元素的游戏属于明显的过度设计。简略版选择的是第三条路:一个QWidget子类,所有东西都在paintEvent里用QPainter画出来。这是这类小游戏里最常见、也是最省心的做法。
2.1 目录结构与文件职责
标准形态只有五个文件,Qt Creator 直接打开.pro就能跑:
snake_qt/ ├── snake_qt.pro # qmake 工程文件,Qt Creator 双击即开 ├── CMakeLists.txt # 备用构建脚本,给命令行或 CI 用 ├── main.cpp # 只有 QApplication + show,不放任何游戏逻辑 ├── gamewidget.h # 类声明:蛇身容器、方向、计时器、状态位 └── gamewidget.cpp # 全部实现:reset / tick / paint / keyPressmain.cpp保持极简是这个结构的第一个约定:游戏逻辑一行都不要往main里塞。因为后面你想把GameWidget塞进一个带菜单栏的QMainWindow、或者塞进一个 Tab 页里时,main.cpp不需要改。gamewidget.h里只放声明和那几个static constexpr常量(格子边长、列数、行数、初始速度),所有魔法数字集中在一处,改棋盘大小只动这几行。
文件拆到这个粒度就够了,别一上来就搞Snake类、Food类、Board类、GameEngine类四五个文件——简略版的蛇身就是一个std::deque<QPoint>,食物就是一个QPoint,为它们各建一个类,纯属给自己找事。等到第 6 章要写测试了,再拆不迟。
2.2 为什么是 QWidget 自绘而不是 QGraphicsScene
关键差别在“谁帮你管重绘”。QGraphicsScene的优势是大量 item 的局部刷新和碰撞查询,贪吃蛇每一帧蛇身都在整体位移,绝大多数格子都要重画,局部刷新带来的收益接近零,反而要为每个 item 维护 scene 坐标和 view 坐标的换算。
QWidget自绘的逻辑非常直白:update()触发一次paintEvent,你在里面按格子坐标从头画一遍。格子坐标和像素坐标的换算只有一个乘法p.x() * kCell,没有第二套坐标系,出 bug 时肉眼就能对出来。
代价也很清楚:窗口一拉伸,如果你的绘制代码里混用了width()和固定kCell,画面立刻错位。所以构造函数里那句setFixedSize(kCols * kCell, kRows * kCell)不是可选项,而是这套方案的前提——棋盘尺寸固定,坐标系才固定。
2.3 .pro 与 CMakeLists 两套构建配置
Qt Creator 认.pro,命令行和 CI 更认 CMake,两个都留着不冲突:
QT += core gui widgets CONFIG += c++17 TEMPLATE = app TARGET = snake_qt SOURCES += main.cpp gamewidget.cpp HEADERS += gamewidget.h这里最容易漏的是widgets。Qt 4 时代QT += gui就够了,Qt 5 把 widgets 拆成了独立模块,只写core gui会在#include <QWidget>那一行直接报找不到头文件。CONFIG += c++17也不是摆设,std::deque、constexpr、结构化绑定这些都用得上,默认标准可能是 C++11。
CMake 版本对应:
cmake_minimum_required(VERSION 3.16) project(snake_qt LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 类里有 Q_OBJECT,必须开,否则链接期报 vtable 未定义 find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets) add_executable(snake_qt main.cpp gamewidget.cpp gamewidget.h) target_link_libraries(snake_qt PRIVATE Qt${QT_VERSION_MAJOR}::Widgets)CMAKE_AUTOMOC ON和Q_OBJECT是绑定关系。只要类里写了Q_OBJECT宏(用了信号槽就必须写),moc 就要生成moc_xxx.cpp,CMake 不开 AUTOMOC 就会在链接阶段报一堆undefined reference to vtable for GameWidget。这个错误信息极具误导性,新手很容易怀疑是析构函数写错了,其实是构建系统没跑 moc。
提示:
.pro和CMakeLists.txt别同时维护太久,功能加多了容易两边不同步,最后只在一边能编过。
2.4 游戏状态机的三个状态
简略版常用两个bool:m_over和m_paused。能跑,但组合出四种状态,其中“既结束又暂停”是不合法的,靠代码自觉去避免。稍微讲究一点会写成枚举:
enum class GameState { Running, Paused, Over };三态的好处是tick()开头一句if (m_state != GameState::Running) return;就把暂停和结束一起挡掉了,不用写if (m_over || m_paused)。状态迁移也很清楚:Running收到空格进Paused,收到 R 回Running(同时 reset),撞墙或撞自己进Over,Over只接受 R。简略版为了代码短,用 bool 也能接受,但二次开发加“倒计时开始”“关卡切换”时,建议第一时间换成枚举,不然状态位会越加越乱。
3. 核心模块拆解:一条 120ms 的 tick 链路,从方向输入到蛇身落格
贪吃蛇的全部逻辑可以压缩成一句话:每隔固定时间,把蛇头往当前方向推一格,判断是否吃到食物、是否撞死,然后重绘。这个“每隔固定时间”就是QTimer的timeout信号,间隔一般取 120~160ms,太快人反应不过来,太慢手感像 PPT。下面按一次 tick 的完整链路往下拆。
3.1 蛇身数据结构:std::deque 与 QPoint
蛇的本质是一个有序的点序列,头在最前,尾在最后。移动一格的操作是“头部插入一个新点,尾部删掉一个点”,这正是双端队列的强项:
#include <deque> #include <QPoint> std::deque<QPoint> m_snake; // front = 蛇头,back = 蛇尾 QPoint m_dir{1, 0}; // 当前方向,单位向量 QPoint m_food; // 食物所在的格子坐标用QPoint而不是自定义struct Pos { int x, y; },是因为QPoint自带operator==,后面判重、判撞、std::find全都能直接用,还省了一个哈希函数的活。坐标存的是“格子下标”而不是像素,0~23 这种范围,和像素的换算只发生在绘制那一刻。
std::deque对比std::vector:vector头部插入是 O(n),蛇长到几十节时每帧都要整体搬一次;deque头尾插入删除都是 O(1),代价是随机访问略慢一点,但这个场景里除了绘制要遍历,没有随机访问需求。用QList<QPoint>也行,Qt 6 的QList已经是vector实现了,头插一样是 O(n),所以还是std::deque更贴切。
注意:
std::deque的迭代器在头尾插入删除后会失效,别在遍历蛇身的同时做push_front,先算完再改。
3.2 tick 主循环:移动、吃食物、死亡三条分支
一次 tick 的完整实现大概是这样:
void GameWidget::tick() { if (m_over || m_paused) return; // 暂停或结束,这一帧直接跳过 // 一个 tick 只消费一个方向,防止连按两键互相覆盖 if (!m_input.isEmpty()) { QPoint d = m_input.dequeue(); if (d != -m_dir) m_dir = d; // 二次保险:禁止 180 度掉头 } const QPoint head = m_snake.front() + m_dir; // 算出新蛇头(还没落盘) if (hitWall(head) || hitSelf(head)) { // 先判死,再改数据 m_over = true; m_timer.stop(); update(); return; } const bool eat = (head == m_food); m_snake.push_front(head); // 头插:蛇变长一格 if (eat) { m_score += 10; if (m_score > m_best) m_best = m_score; m_timer.setInterval(qMax(60, kBaseInterval - m_score / 5)); // 越吃越快 spawnFood(); } else { m_snake.pop_back(); // 尾删:长度不变,整体前移 } update(); // 请求重绘,Qt 会合并多次调用 }顺序上有两个细节值得单独说。第一,碰撞判定必须用“算出来的 head”,而不是先push_front再判断——先落盘的话,蛇头已经进了m_snake,hitSelf会拿它跟自己比,必然判定撞死。第二,吃食物时不能pop_back,这就是“吃到变长”的实现方式;不吃时才删尾巴,保持长度不变。
update()在一帧里调用一次就够了,Qt 内部会把多次update()合并成一次paintEvent,所以不用自己维护脏标记。
3.3 碰撞检测:墙、自身、尾节点的特殊处理
撞墙的判定很直接,越界即死:
bool GameWidget::hitWall(const QPoint &p) const { return p.x() < 0 || p.x() >= kCols || p.y() < 0 || p.y() >= kRows; }撞自己就要小心了,这里有一个非常经典的误判:
bool GameWidget::hitSelf(const QPoint &p) const { // 尾节点这一 tick 会移走,不算障碍; // 吃食物时尾巴不动,但食物只生成在空格上,head 不可能同时等于尾节点,所以安全 for (auto it = m_snake.begin(); it != m_snake.end() - 1; ++it) if (*it == p) return true; return false; }为什么排除最后一个节点?假设蛇长 3 节,头在 (5,5),尾巴在 (4,5),蛇往左走一步,新头是 (4,5)——正是尾巴当前的位置。但这一帧尾巴会移走,所以实际不会撞上。如果判定时把尾巴也算进去,就会出现“明明还有空隙却死了”的玄学现象,玩家一眼就能看出不对。
反过来也有坑:如果这一帧吃到食物,尾巴不动,那么新头等于尾节点就真该死了。但在本实现里食物只生成在空格上(spawnFood会排除蛇身所有格子),所以“头等于尾”和“吃到食物”不可能同时成立,排除尾巴是安全的。这个推理链条要在注释里写清楚,不然下次改食物生成逻辑时,这个假设就悄悄失效了。
另外,m_snake.end() - 1在 deque 上对随机访问迭代器是合法的,蛇长度恒 ≥ 3,不会出现空容器减一的情况。
3.4 食物生成与棋盘占满的兜底
食物生成看着简单,随手rand() % kCols就完事,但它有个隐性要求:不能生成在蛇身上,否则玩家永远吃不到,游戏卡死。最稳的做法是先收集所有空格再随机取:
void GameWidget::spawnFood() { QVector<QPoint> freeCells; freeCells.reserve(kCols * kRows - int(m_snake.size())); for (int y = 0; y < kRows; ++y) for (int x = 0; x < kCols; ++x) { QPoint p(x, y); if (std::find(m_snake.begin(), m_snake.end(), p) == m_snake.end()) freeCells.append(p); } if (freeCells.isEmpty()) { // 蛇塞满整个棋盘 = 通关 m_over = true; m_timer.stop(); return; } m_food = freeCells.at(QRandomGenerator::global()->bounded(freeCells.size())); }480 个格子遍历一遍,对 120ms 一次的频率来说开销可以忽略,完全不需要优化成“随机重试直到不撞”的做法——那个做法在蛇很长时会退化成近似死循环,蛇占了 479 格时可能抽几百次都抽不中,是血泪经验。
QRandomGenerator::global()是 Qt 5.10+ 提供的线程安全随机源,替代了老的qrand()。生成范围是[0, size),正好当数组下标用。
3.5 paintEvent 绘制与坐标换算
绘制负责把格子坐标翻译成像素:
QRect GameWidget::cellRect(const QPoint &p) const { return QRect(p.x() * kCell, p.y() * kCell, kCell, kCell); } void GameWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.fillRect(rect(), QColor(24, 26, 32)); // 底色 // 棋盘格:要和格子严格对齐,所以不开启抗锯齿 p.setPen(QPen(QColor(255, 255, 255, 16), 1)); for (int x = 0; x <= kCols; ++x) p.drawLine(x * kCell, 0, x * kCell, kRows * kCell); for (int y = 0; y <= kRows; ++y) p.drawLine(0, y * kCell, kCols * kCell, y * kCell); // 食物 p.setPen(Qt::NoPen); p.setBrush(QColor(255, 92, 92)); p.drawEllipse(cellRect(m_food).adjusted(3, 3, -3, -3)); // 蛇身:越靠头越亮,一眼能看出方向 for (int i = 0; i < int(m_snake.size()); ++i) { p.setBrush(QColor::fromHsv(150, 180, qMax(90, 255 - i * 6))); p.drawRoundedRect(cellRect(m_snake[i]).adjusted(1, 1, -1, -1), 6, 6); } // 分数 p.setPen(Qt::white); p.setFont(QFont(QStringLiteral("monospace"), 12)); p.drawText(rect().adjusted(8, 6, -8, 0), Qt::AlignLeft | Qt::AlignTop, QStringLiteral("SCORE %1 BEST %2").arg(m_score).arg(m_best)); // 暂停 / 结束遮罩 if (m_over || m_paused) { p.fillRect(rect(), QColor(0, 0, 0, 140)); p.setPen(Qt::white); p.setFont(QFont(QStringLiteral("monospace"), 20, QFont::Bold)); p.drawText(rect(), Qt::AlignCenter, m_over ? QStringLiteral("GAME OVER - 按 R 重开") : QStringLiteral("PAUSED")); } }adjusted(1,1,-1,-1)是把矩形四边各缩 1 像素,让相邻蛇身之间留出缝隙,视觉上才有“一节一节”的感觉;adjusted(3,3,-3,-3)让食物比格子小一圈,看起来像个球而不是方块。遮罩用QColor(0,0,0,140)半透明黑盖一层,比单独画一个居中面板简单得多。
drawText传QRect加对齐标志时,文本会在这个矩形内自动对齐,但不会自动换行——所以提示语写成了一行,避免出现文字被裁掉的情况。真要多行就加Qt::TextWordWrap。
4. 键盘输入与游戏循环:方向队列、失焦暂停和重开逻辑怎么写才不粘手
这一章处理的是“手感”。同样一套规则,方向变量写法不同,玩起来就是两个游戏:一个是顺滑的,一个是按下右键却撞死在左墙上的。贪吃蛇的输入有个特殊性——玩家的操作频率可能高于 tick 频率,两次按键之间蛇还没动,这时候第二次按键该丢弃、该覆盖、还是该排队,决定了整个手感。
4.1 keyPressEvent 与自定义焦点的获取
先得有焦点,QWidget默认不接收键盘事件:
GameWidget::GameWidget(QWidget *parent) : QWidget(parent) { setFixedSize(kCols * kCell, kRows * kCell); setFocusPolicy(Qt::StrongFocus); // 必须,否则收不到按键 setWindowTitle(QStringLiteral("Snake / Qt")); connect(&m_timer, &QTimer::timeout, this, &GameWidget::tick); reset(); m_timer.start(kBaseInterval); }Qt::StrongFocus表示既接受 Tab 焦点也接受点击焦点。只写Qt::ClickFocus的话,程序启动时窗口没有焦点,玩家按方向键毫无反应,得先用鼠标点一下窗口——这是新手最常见的“代码没错但游戏不动”的原因。Qt::StrongFocus能保证窗口一显示就拿到键盘。
按键分发:
void GameWidget::keyPressEvent(QKeyEvent *event) { switch (event->key()) { case Qt::Key_Up: case Qt::Key_W: enqueueDir({0, -1}); break; case Qt::Key_Down: case Qt::Key_S: enqueueDir({0, 1}); break; case Qt::Key_Left: case Qt::Key_A: enqueueDir({-1, 0}); break; case Qt::Key_Right: case Qt::Key_D: enqueueDir({1, 0}); break; case Qt::Key_Space: if (!m_over) { m_paused = !m_paused; update(); } break; case Qt::Key_R: reset(); break; default: QWidget::keyPressEvent(event); return; // 不认识的键交还父类 } }default分支里调用父类实现不是客套,是让 Esc、Alt+F4 这些系统键还能正常走。全部吞掉的话,窗口可能连关闭快捷键都失效。
4.2 方向队列:为什么一个 QPoint 变量一定会翻车
最容易写错的版本长这样:
case Qt::Key_Up: m_dir = {0, -1}; break; case Qt::Key_Left: m_dir = {-1, 0}; break;看着没问题,实际会出两类 bug。第一类是“吃掉一次转向”:蛇正在向右走,玩家飞快地按了“上”再按“左”(想走一个拐角),两次按键都发生在同一个 tick 之间,m_dir被覆盖成左,而上键那次转向直接消失。玩家体感是“我明明按了上”。
第二类是“瞬间自杀”:蛇向右走,玩家按了“上”又立刻按“下”,m_dir变成下——而“下”正是当前方向的反向,下一帧蛇头直接撞进自己的脖子。
正确做法是用一个容量很小的队列:
void GameWidget::enqueueDir(QPoint d) { if (m_over || m_paused) return; if (m_input.size() >= 2) return; // 最多缓存两帧,防止按键堆积 const QPoint base = m_input.isEmpty() ? m_dir : m_input.last(); if (d == base || d == -base) return; // 同向或反向直接丢弃 m_input.enqueue(d); }三个判断各有用途。size() >= 2的上限是为了防止玩家疯狂按键把队列塞满,导致松开手之后蛇还在“执行历史指令”转圈,那种延迟感非常难受。d == base是同向重复,没必要记。d == -base是反向,必须拿“队列里最后一个方向”做基准而不是m_dir——因为队列里可能已经压了一个待执行的转向,用m_dir比较会漏掉“上、下”这种连续反向的情况。
tick()里每次只出队一个,这样连按两个不同方向时,蛇会分两帧依次拐弯,走出一条 L 形,这就是玩家期待的行为。
4.3 失焦暂停与重开:别让玩家切个窗口回来就死
窗口失焦的处理经常被忽略。玩家按 Alt+Tab 切出去看个消息,回来发现蛇已经撞墙了,这种体验很伤:
void GameWidget::focusOutEvent(QFocusEvent *event) { if (!m_over) m_paused = true; // 切走自动暂停 update(); QWidget::focusOutEvent(event); }注意是暂停而不是停定时器。定时器继续跑,tick()开头一看m_paused直接返回,逻辑更简单,恢复时也不用重启定时器、不用重新对齐相位。如果为了省电想真停下,那恢复时得m_timer.start(interval)并且注意别把剩余时间算错。
重开走reset(),它要把所有状态回滚到初始值,包括那些容易被漏掉的:
void GameWidget::reset() { m_snake.clear(); const int cy = kRows / 2; m_snake.push_back({6, cy}); m_snake.push_back({5, cy}); m_snake.push_back({4, cy}); // 头在前,尾在后,蛇头朝右 m_dir = {1, 0}; m_input.clear(); // 队列必须清,否则重开后蛇自己拐弯 m_score = 0; m_over = false; m_paused = false; m_timer.setInterval(kBaseInterval); // 速度也要复位 spawnFood(); update(); }m_input.clear()和m_timer.setInterval(kBaseInterval)是最容易漏的两行。前者漏了,玩家在死亡瞬间按的方向会在重开后立刻生效,蛇开局就往奇怪的方向跑;后者漏了,重开后直接继承上一局的高难度速度,越玩越快,最后快到看不清。
4.4 定时器精度与手感调优
QTimer默认是Qt::CoarseTimer,精度约 5%,对 140ms 来说偏差 7ms 左右,玩起来感觉不到。但如果你的间隔设到 60ms 以下追求“高速模式”,就该显式指定m_timer.setTimerType(Qt::PreciseTimer),否则抖动会变成肉眼可见的节奏不匀。
调试手感时我一般会把几个参数做成可以即时改的:kBaseInterval起点 140、每次得分提速 12ms、下限 60ms。60ms 大概是普通人能反应过来的极限,再快就只能靠背板了。改完记得同步reset()里的复位值,不然会出现“第一局正常、第二局起飞”的诡异现象。
5. 避坑与排查:编译、运行、打包三段路上最容易翻车的五件事
这一章是踩坑记录,按出现的时间顺序排。每一条都写了现象、原因、解决,遇到问题直接对号入座,别从头读。
5.1 编译与链接期的两个拦路虎
现象一:fatal error: QWidget: No such file or directory,但代码里明明#include <QWidget>写得没错。
原因:.pro里只写了QT += core gui。Qt 5 之后 widgets 被拆成了独立模块,gui里只有QWindow、QPainter、QColor这些,QWidget、QApplication、QLabel全在widgets模块,头文件路径根本没加进编译命令。
解决:改成QT += core gui widgets。CMake 对应的写法是find_package(... COMPONENTS Widgets)加上target_link_libraries(app PRIVATE Qt6::Widgets),两处缺一个都会失败,但报错点不同——find_package没写会在配置阶段就退出,target_link_libraries没写则是在链接阶段报一堆undefined reference to QWidget::xxx。
现象二:编译通过,链接时报undefined reference to vtable for GameWidget,或者报undefined reference to GameWidget::staticMetaObject。
原因:类里写了Q_OBJECT宏,但没有跑 moc。moc 负责生成信号槽所需的元对象代码,包括虚函数表和staticMetaObject。qmake 项目会自动处理,但如果你的.h文件没被加进HEADERS,qmake 就扫描不到;CMake 项目则是CMAKE_AUTOMOC没开会扫不到。
解决:qmake 侧确认HEADERS += gamewidget.h;CMake 侧确认set(CMAKE_AUTOMOC ON)且头文件被列进了add_executable的源文件列表里。这个错误的迷惑性在于,它看起来像“析构函数没定义”或者“虚函数没实现”,很容易往错的方向查。
提示:如果一次改动后突然冒出 vtable 错误,先尝试删掉 build 目录重新构建。moc 生成的中间文件有时会残留,增量构建会拿旧的凑合,清一次就正常了。
5.2 运行时的两个逻辑坑
现象三:蛇明明还有空间,却在自己撞到自己之前就判死了;或者反过来,蛇头穿过了尾巴,玩家觉得“这都能活?”。
原因:hitSelf的判定集合错了。一种写法是把整个m_snake都拿去比,包括那个即将移走的尾节点,导致紧贴尾巴走的时候误判;另一种是判定时机错,先push_front再判,蛇头把自己算了进去,新蛇一出生就死。
解决:判定必须在push_front之前,用算出来的head去比;比较时遍历begin()到end() - 1,排除尾节点。如果后续加了“吃到食物尾巴不动”的变体规则(比如加速道具),这个假设就失效了,得改成动态判断:只有当这一帧不吃食物时才排除尾巴。
现象四:方向键按下去感觉“粘手”,快速按“上”再按“左”,蛇只拐了一次;或者反过来,蛇不受控地绕圈。
原因:用单个QPoint m_dir存方向,后按的覆盖先按的。反过来“绕圈”是因为把队列做成了无上限,玩家连按五下,蛇在接下来五帧里依次执行,手停了蛇还在转。
解决:队列容量卡死在 2,enqueueDir里用“队列尾部方向”而不是当前方向做反向判定。再加一条d == base过滤同向重复,能挡掉大部分误按。
5.3 打包发布:双击没反应
现象五:在自己的开发机上跑得好好的,拷到别人机器上双击 exe,要么毫无反应,要么弹窗报“缺少 Qt5Core.dll”。
原因:动态链接的 Qt 程序在运行时需要Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll三个基础库,加上platforms/qwindows.dll这个平台插件。开发机上 Qt 的 bin 目录在 PATH 里所以能找到,换台机器就全丢了。特别注意platforms目录是必须的,缺了它程序会启动失败并打印This application failed to start because no Qt platform plugin could be initialized。
解决:用 Qt 自带的部署工具把所有依赖一次性拷齐:
# Windows(在 Qt 命令行环境里执行,确保 windeployqt 在 PATH 中) windeployqt --release --no-translations build/snake_qt.exe # macOS macdeployqt snake_qt.app -dmg--no-translations会跳过几十种语言包,一个贪吃蛇用不上,能把发布目录从几十 MB 压到十几 MB。跑完之后build目录里应该多出platforms/、styles/、几个 dll,整个目录一起打包才能拷给别人。
6. 二次开发:从单文件 Widget 拆成可测试的游戏内核
简略版为了短,把绘制和逻辑塞在同一个类里。真要往上加功能,第一刀应该切在“逻辑不依赖界面”这条线上,不然每改一条规则都得开窗口手动点半天验证,效率极低。
具体做法是把tick()里的纯逻辑抽成一个不继承QWidget的GameCore类:m_snake、m_food、m_score、hitWall、hitSelf、spawnFood全部搬过去,对外只暴露step()、turn(QPoint)、reset()和几个只读访问器。GameWidget变成一个纯粹的“渲染 + 输入适配层”,QTimer触发时调core.step(),然后update()。这之后GameCore就能脱离 Qt 的界面模块单测了,喂一串方向、跑 N 步、断言蛇长和分数,几毫秒跑完,比手点一百次可靠。
顺手的几个小改造,按性价比排:难度递增已经在tick()里用setInterval实现了,改成独立函数更方便调参:
void GameWidget::applyDifficulty() { const int step = m_score / 50; // 每 50 分提一档 const int interval = qMax(60, kBaseInterval - step * 12); // 下限 60ms m_timer.setInterval(interval); }可掉头模式(穿墙)只需要改hitWall的调用点,把越界坐标取模回绕:
// 穿墙:出界后从对面进来,hitWall 不再判死 head.setX((head.x() + kCols) % kCols); head.setY((head.y() + kRows) % kRows);最高分持久化用QSettings三行就够,不用自己造配置文件格式:
#include <QSettings> // 构造函数里读 QSettings cfg(QStringLiteral("SomeStudio"), QStringLiteral("snake_qt")); m_best = cfg.value(QStringLiteral("best")).toInt(); // 游戏结束那一刻写 cfg.setValue(QStringLiteral("best"), m_best);组织名和应用名都是占位,换成自己的即可,QSettings在 Windows 上会落到注册表,Linux 落到~/.config,跨平台不用管。
验证改动的顺序我固定成三步:先单元测试跑GameCore,确认规则对;再跑一次带界面的,确认渲染没崩;最后打包一份到没有 Qt 环境的机器上双击,确认依赖齐全。三步里第二步最容易被跳过,但恰恰是渲染层最容易出问题——逻辑对、画面错,比逻辑错更难查。
从那以后我每次拆这类小项目,都先把“逻辑核心”和“界面外壳”的边界画出来再动手,哪怕最终只有三百行,也强制走一遍这个分层。边界不清的时候,加第三个功能就会开始互相牵连,改一处崩两处。希望帮到你。
本文还有配套的精品资源,点击获取