花了一整天把基于 Qt(C++)的贪吃蛇从零写到能跑,从环境配置到最终出包整个流程理了一遍。这篇就把这个项目的设计思路、核心代码、踩坑过程全部摊开讲,想拿 Qt 做点小练习的,或者正在学 C++ 想找入门项目的,都能在这里找到能直接抄的作业。
1. 项目拆解:贪吃蛇游戏的核心设计思路
贪吃蛇看起来简单,但拆开来看其实包含几个独立模块:游戏状态管理、蛇身的数据结构、食物随机生成、碰撞检测,还有界面渲染。如果全塞进一个 main.cpp 里当然也能跑,但代码一多就会变成一坨乱麻。所以我在动手之前就把结构想清楚,按“数据逻辑与界面分离”的思路来组织。
1.1 游戏核心循环:用 QTimer 而不是线程
贪吃蛇和大多数小游戏一样,核心是一个循环:每隔一段时间,蛇向前移动一步,检测是否吃到食物、是否撞墙、是否撞到自己。Qt 里实现循环有好几种方式,但最贴合这种需求的是 QTimer 定时器。
这里有个关键点值得展开说明:为什么用 QTimer 而不是开线程加 sleep?因为 QTimer 是事件驱动的,它回到 Qt 的事件循环里工作,不会阻塞 UI,也不会出现跨线程更新界面的麻烦。开个 std::thread 做游戏循环,每帧还要手动投递事件给主线程,处理起来繁琐,还容易踩数据竞争的坑。实测下来,QTimer 是这个小体量项目最省心的方案。
定时器间隔直接决定游戏难度。我初始设置成 150ms 移动一步,每吃 5 个食物减少 10ms,最低到 80ms。这个参数可以自己调,太快了新手根本反应不过来,太慢了又没挑战性。80ms 的节奏已经接近普通人的操作极限,再快就纯粹靠预判了。
1.2 蛇身数据结构:QVector 也够用
蛇身是一个在头部添加、尾部移除的序列。常见的做法有 std::deque、链表,或者用两个坐标数组加偏移。我用的反而是 QVector,配合每次移动时删除尾部、头部插入新坐标。
很多同学可能会问:链表不是更适合频繁在两端操作吗?理论上,std::list 在两端插入删除是 O(1),但贪吃蛇的身体长度最多也就几十个节点,QVector 即使最坏情况要移动元素,开销也完全无感。而在渲染阶段,QVector 可以直接顺序遍历,配合 Qt 的隐式共享机制,传给绘制函数时成本极低。链表因为节点分散在内存中,缓存不友好,在这个场景反而没什么优势。项目小不是问题,但选择数据结构时想清楚“规模”和“访问模式”,这个习惯很值钱。
1.3 界面渲染:QPainter 绘制,而不是堆 QLabel
第一次做这类游戏,很容易想到用 QLabel 当蛇身的格子,每次移动就重新摆放所有 label 的位置。这样确实能跑,但效率很低,代码也啰嗦得一塌糊涂。几十个 label 在界面上反复移动,每次都要触发布局计算,完全没有必要。
我用 QPainter 直接在 QWidget 上绘制。先画背景网格,再依次画蛇身方块和食物圆点,整体也就几十个图形的绘制指令,在 100ms 级别的刷新节奏下毫无压力。QPainter 是 Qt 跨平台绘图里最成熟的一环,设置好坐标后,代码非常干净。这也是很多 Qt 图形项目常用的路子:自定义 widget 重写 paintEvent,而不是堆一堆控件。记住这个原则,后面做图表、自定义动画、仪表盘都能用上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开发环境搭建:版本选择与工具链
这一部分解决之后所有问题的基础。环境搭不对,后面全是连环坑。我最终用的组合是 Qt 5.15.2 + Qt Creator + MinGW 64-bit,这个组合针对 Windows 平台来说足够省心。
2.1 Qt 版本怎么选:5.15.2 还是 Qt 6
如果你的目标是快速做出一个能运行、能分享的桌面小工具,Qt 5.15.2 是个很稳的选择。它处于长期维护周期内,网上资料极其丰富,各种报错基本都能搜到现成答案。Qt 6 生态更新、性能更好,但接口有变化,部分模块拆分更细,新手学习时多了一层成本。比如字符串正则这块,Qt 6 里 QRegExp 被 QRegularExpression 取代,老代码迁移需要改。
从零开始且不依赖老项目的话,可以直接上 Qt 6.5 LTS 或更新版本。贪吃蛇这种规模,任何受支持的 Qt 版本都能轻松胜任,不必太纠结版本号。重点是编译器套件要配套,版本混搭才是后面麻烦的根源。
提示:官方在线安装器会让你勾选模块和编译器套件。选 MinGW 64-bit 套件的好处是编译器、调试器都在安装包里,开箱即用,省去额外配置的功夫。如果走 VS Code 配置 C/C++ 环境加 Qt 插件,也能用,但中间要处理 tasks.json、launch.json、编译参数等一系列细节,对新手来说有点分散精力。
2.2 MSVC 和 MinGW 怎么选,部署差异很大
Windows 上开发 Qt,通常会面对 MSVC 和 MinGW 两条套件路线。MSVC 用的是微软编译器,调试工具和 Windows 平台特性结合更好,但发布时需要带上对应的 VC++ 运行库。不然换一台没有装 Visual C++ Redistributable 的机器,程序会直接弹“找不到 VCRUNTIME140.dll”或“找不到 MSVCP140.dll”的错。
MinGW 套件的依赖关系相对简单,核心库文件跟着程序走,只要把 Qt 的运行库目录带上就行,不需要额外安装系统级运行库。我最后选了 MinGW,部署环节省掉不少麻烦。如果你的目标是做商业级 Windows 软件,那用 MSVC 也无可厚非,就是打包时记得把运行库一并处理。
3. 核心代码实现:一步步把游戏写出来
下面按模块逐段讲。代码量不大,但每一段背后都有设计取舍,不只是“能跑”。
3.1 文件结构和类设计
文件划分我尽量精简:
- main.cpp —— 程序入口
- SnakeGame.h / SnakeGame.cpp —— QWidget 主窗体,包含游戏循环、渲染和事件处理
- GameConfig.h —— 网格数量、初始速度、方块尺寸等常量
把配置单独放一个头文件里,好处是调难度、调窗口大小时不用在代码里翻来翻去。这个习惯对后面做更复杂项目帮助很大,配置集中管理是减少维护成本的基本功。
3.2 游戏逻辑骨架
先定义方向枚举和蛇身容器:
cpp复制enum class Direction { Up, Down, Left, Right };
class SnakeGame : public QWidget {
Q_OBJECT
public:
explicit SnakeGame(QWidget *parent = nullptr);
protected:
void paintEvent(QPaintEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
private slots:
void onTimeout();
private:
void initGame();
void spawnFood();
bool checkCollision() const;
void moveSnake();
Direction m_dir;
QVector<QPoint> m_snake;
QPoint m_food;
int m_score;
QTimer m_timer;
};
QVector 的每个元素是一个 QPoint,保存网格坐标,比如 (3, 5) 表示第 3 列第 5 行。这样比直接用像素坐标容易计算,绘制时乘上格子尺寸即可。
3.3 移动逻辑:先加头,再判断是否要减尾
移动是每帧触发一次的核心动作。根据当前方向计算新头部坐标,然后分两种情况处理:吃到食物就保留尾部,身体变长;没吃到就删除尾部,形成前进效果。
cpp复制void SnakeGame::moveSnake() {
QPoint head = m_snake.first();
switch (m_dir) {
case Direction::Up: head.ry() -= 1; break;
case Direction::Down: head.ry() += 1; break;
case Direction::Left: head.rx() -= 1; break;
case Direction::Right: head.rx() += 1; break;
}
m_snake.insert(0, head);
if (head == m_food) {
m_score += 10;
spawnFood();
} else {
m_snake.removeLast();
}
}
这里有个细节要特别注意:先插入新头,再判断是否吃到食物。如果你先判断再插入,逻辑就会绕弯。移动顺序和碰撞判断的配合,是这个环节最容易出错的地方。
3.4 食物随机生成:用上 C++11 的随机数
食物的位置不能落在蛇身上,所以生成后要校验。我用 C++11 起的 std::mt19937 和 uniform_int_distribution,而不是老式的 rand(),分布更均匀,也不容易产生周期性偏差。
cpp复制void SnakeGame::spawnFood() {
std::mt19937 gen(std::random_device{}());
std::uniform_int_distribution<int> distX(0, GRID_COUNT - 1);
std::uniform_int_distribution<int> distY(0, GRID_COUNT - 1);
QPoint candidate;
do {
candidate = QPoint(distX(gen), distY(gen));
} while (m_snake.contains(candidate));
m_food = candidate;
}
不过这段代码有个可以优化的地方:std::random_device{} 每次构造生成器都会访问系统熵池,频繁调用并不高效。更合理的做法是把生成器作为成员变量,只在初始化时构造一次,后续生成食物直接复用。对贪吃蛇来说性能影响约等于零,但写成成员变量更规范。
3.5 碰撞检测:撞墙和撞自己
碰撞检测分两种。撞墙简单,坐标越界即可。撞自己要从索引 1 开始遍历,因为索引 0 是头自己,如果从 0 开始遍历那永远会判成碰撞。
cpp复制bool SnakeGame::checkCollision() const {
const QPoint& head = m_snake.first();
if (head.x() < 0 || head.x() >= GRID_COUNT ||
head.y() < 0 || head.y() >= GRID_COUNT) {
return true;
}
for (int i = 1; i < m_snake.size(); ++i) {
if (m_snake[i] == head) {
return true;
}
}
return false;
}
蛇身撞自己这件事,还有一个前置条件需要提前处理:禁止反向操作。如果蛇正向右移动,你按了左方向键,下一帧蛇头直接往左,会瞬间穿进身体。这个问题表面在碰撞检测,实际在事件处理,具体方案见下文“键盘事件”一节。
3.6 键盘事件处理:别让按键直接改方向
键盘事件是整个项目里最容易埋雷的地方。贪吃蛇不允许掉头操作,所以在 keyPressEvent 里必须判断当前方向,反向输入直接忽略。
cpp复制void SnakeGame::keyPressEvent(QKeyEvent *event) {
switch (event->key()) {
case Qt::Key_Up:
if (m_dir != Direction::Down) m_dir = Direction::Up;
break;
case Qt::Key_Down:
if (m_dir != Direction::Up) m_dir = Direction::Down;
break;
case Qt::Key_Left:
if (m_dir != Direction::Right) m_dir = Direction::Left;
break;
case Qt::Key_Right:
if (m_dir != Direction::Left) m_dir = Direction::Right;
break;
case Qt::Key_Space:
if (!m_timer.isActive()) initGame();
break;
default:
QWidget::keyPressEvent(event);
}
}
这种直接改 m_dir 的方式能挡住大多数反向操作,但它还有一个边角问题:如果用户快速两连按,比如向右移动时快速按了“上”和“左”,两次按键都发生在同一帧内的定时器回调之前,先改成上、再改成左,就绕过了掉头限制。
更稳妥的做法是保存一个待处理方向成员变量,按键事件只更新它,真正应用到 m_dir 的操作放到 moveSnake 开头。这样保证一帧内只能应用一次方向切换,不会出现“穿透”问题。贪吃蛇这个小坑,很多人玩到后期才会发现。
注意:QWidget 只有获得焦点时才会收到键盘事件。如果界面上有按钮抢了焦点,主窗口就收不到按键。用 setFocusPolicy(Qt::StrongFocus) 保证主窗体始终能获取键盘输入。
3.7 界面绘制:paintEvent 的具体实现
渲染部分继承 QWidget 并重写 paintEvent。核心步骤是先铺背景色,再画网格线,再画食物,最后画蛇身。
cpp复制void SnakeGame::paintEvent(QPaintEvent *) {
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const int cell = 20;
painter.fillRect(rect(), QColor(30, 30, 40));
// 画网格
QPen pen(QColor(60, 60, 70));
pen.setWidth(1);
painter.setPen(pen);
for (int i = 0; i <= GRID_COUNT; ++i) {
painter.drawLine(0, i * cell, GRID_COUNT * cell, i * cell);
painter.drawLine(i * cell, 0, i * cell, GRID_COUNT * cell);
}
// 画食物
painter.setBrush(QColor(255, 80, 80));
painter.setPen(Qt::NoPen);
painter.drawEllipse(m_food.x() * cell + cell / 2, m_food.y() * cell + cell / 2, cell / 2, cell / 2);
// 画蛇
for (int i = 0; i < m_snake.size(); ++i) {
if (i == 0) {
painter.setBrush(QColor(80, 220, 120));
} else {
painter.setBrush(QColor(60, 180, 100));
}
painter.drawRect(m_snake[i].x() * cell + 2, m_snake[i].y() * cell + 2, cell - 4, cell - 4);
}
}
蛇身方块在边缘留了 2 像素空隙,这样邻接的身体不会粘连成一大块,视觉上会清爽很多。蛇头单独用亮绿色,和身体区分开,玩家能一眼锁定当前方向朝向。这类细节对游戏观感影响挺大,值得多花几分钟调。
3.8 游戏状态管理与定时器控制
定时器的启停对应游戏状态。我设了一个 initGame() 方法,用于重置所有成员变量:
cpp复制void SnakeGame::initGame() {
m_snake.clear();
m_snake << QPoint(GRID_COUNT / 2, GRID_COUNT / 2);
m_dir = Direction::Right;
m_score = 0;
spawnFood();
m_timer.start(m_speed);
update();
}
定时器回调里,先移动,再检测碰撞,碰撞了停表,否则刷新界面:
cpp复制void SnakeGame::onTimeout() {
moveSnake();
if (checkCollision()) {
m_timer.stop();
update();
return;
}
update();
}
这里注意一个顺序问题:碰撞检测必须在移动之后做,因为这一帧的位置更新了,才知道新头是否撞墙或者撞到自己。先碰撞后移动,逻辑上是错的。
3.9 信号槽的连接
在构造函数里连接定时器:
cpp复制SnakeGame::SnakeGame(QWidget *parent)
: QWidget(parent), m_dir(Direction::Right), m_score(0) {
setFocusPolicy(Qt::StrongFocus);
setFixedSize(GRID_COUNT * CELL_SIZE, GRID_COUNT * CELL_SIZE);
connect(&m_timer, &QTimer::timeout, this, &SnakeGame::onTimeout);
initGame();
}
QTimer 的 timeout 信号连接到 onTimeout 槽,这是 Qt 事件驱动模型最基础也最好用的组合。需要说明的是,这里的 setFixedSize 固定了窗口尺寸,避免用户拉动窗口导致绘制坐标错乱。如果你希望窗口可调整大小,那就要在 paintEvent 里重新计算格子大小,而不是固定常量。
4. 遇到的坑:从编译错误到运行崩溃
下面整理一下这个项目里最容易踩的坑,每个都是实际遇到过或者同事项目里碰到过的。
4.1 编译时报 “cannot mix incompatible Qt library”
这是一个很经典的编译报错,报错长这样:fatal: cannot mix incompatible Qt library (version ex50601) with this library。
出现场景通常是本地装了多个 Qt 版本,或者项目配置里选的 Qt 版本与编译时链接的库版本不一致。比如 Kit 里配的是 Qt 5.15.2,但编译参数却指向了 Qt 6 的头文件,或者反过来。排查思路是:先看项目配置文件里引用的 Qt 版本和编译器套件,再到 Qt Creator 的 Kit 设置中确认当前 Qt 版本是否正确。修改后删除 build 目录,重新 qmake 或 cmake,再重新构建,问题基本能解决。如果还不行,八成是环境变量里的 Qt 路径写死了,去系统环境变量清理一下。
4.2 运行时找不到 Qt 平台插件
贪吃蛇在开发机上跑得好好的,拷贝到另一台电脑双击就报错,典型错误是:
code复制qt.qpa.plugin: could not find the Qt platform plugin "windows" in "..."
或者某些 Linux 嵌入式场景下的 linuxfb 插件缺失。原因就是程序发布时没有把 Qt 的 platform 插件一起带过去。Qt 运行时会在可执行文件相对路径下的 platforms 子目录里寻找插件文件,如果缺少 qwindows.dll,程序起不来。
解决方案是用 windeployqt 工具自动收集运行所需的 Qt 运行库和插件。命令行里进入 exe 所在目录,执行 windeployqt 你的程序名.exe,它会自动把 Qt 运行库、platforms 目录拷贝过来。之后整个目录就能直接拷贝到目标机器运行。
4.3 按键无响应:焦点问题害人不浅
前面提过焦点问题,这里展开讲。有人把重新开始按钮放在主窗口上,点完按钮后键盘就没反应了。原因是按钮抢占了焦点。解决方案是主窗口构造函数调用 setFocus(),按钮的 focusPolicy 设为 Qt::NoFocus,或者在按钮点击槽函数末尾重新 setFocus()。
还有一个情况:如果使用了模态对话框,比如用 QMessageBox 提示游戏结束,点击“确定”之后主窗口不一定自动恢复焦点,最好在对话框关闭后显式调用 setFocus()。这类问题看似不起眼,但实际体验非常影响操作流畅度。
4.4 蛇穿身体的“瞬移”问题
这个在前面键盘事件部分聊过:同一帧内的连续按键会绕过反向限制。这是贪吃蛇项目里一个非常有代表性的逻辑 bug。实际表现是快速连按两个方向键后,蛇没有按预期轨迹移动,而是直接穿进了自己的身体。
解决思路可以概括为“延迟应用”。用一个 m_pendingDir 保存按键意图,在 moveSnake 开头统一消费并应用方向。每个时间片只能消费一次,这样不管用户按得多快,蛇每帧只改变一次方向。
4.5 莫名其妙的闪退与 0000005 崩溃
搜索的时候经常看到“Qt 写的软件容易闪退,报 0000005”,这是 Windows 上的访问违规错误,英文是 Access Violation。这在贪吃蛇这样的小项目里不常出现,但有一个坑值得提:在 paintEvent 中访问已被释放的对象。
我之前重构时把蛇身数据挪到了一个独立管理类里,但窗口析构时管理类先被销毁了,导致最后一次 paintEvent 刷新访问到悬空指针,程序直接崩溃。排查这类问题,Qt Creator 内置的调试器会直接定位到崩溃函数,但更有效的是先审查对象生命周期。记住一条铁律:不要在 paintEvent 里访问生命周期不明确的外部对象。
5. 扩展与进阶:把玩具变成作品
基础版本能跑之后,还有几个低成本高回报的扩展方向,按优先级排一下。
5.1 分数显示与最高分记录
分数可以在吃到食物时累加,在窗口顶部用 paintEvent 绘制一行文字。更进一步可以用 QSettings 保存历史最高成绩,下次启动时自动读取。QSettings 在不同平台会自动选择合适的位置存储,Windows 上是注册表,Linux 上是配置文件,省去手动管理的麻烦。这一块能让你顺带熟悉 Qt 的持久化小技巧。
5.2 声音反馈与视觉细节
吃到食物、游戏结束时播放一个短音效,游戏体验会直接上一个台阶。Qt 里播放简单音效用 QSoundEffect,加载一个 wav 文件即可。界面美化方面可以加渐变色背景、蛇身圆角、移动朝向时蛇头的“眼睛”方向等。这些细节积少成多,整体观感完全不同。
5.3 向 Qt MVVM 模式和更多应用延伸
如果想把这个小游戏和更大的工程架构方式衔接,可以试试抽出 GameModel、GameView、GameViewModel 三个层次。游戏状态数据放在 Model 中,渲染逻辑放在 View,中间层用信号槽传递指令与状态。贪吃蛇这种规模用 MVVM 有点“杀鸡用牛刀”,但这个练习对理解 Qt 的信号槽机制、数据绑定思想很有帮助。实际项目中看到的 QChart 绘图、图像缩放交互、复杂的业务状态管理,本质上都是基于这个分层逻辑。
5.4 发布成安装包
完成开发后,用 windeployqt 处理运行库,再用 NSIS 或 Inno Setup 打包成安装程序,给朋友分享时体验会好很多。如果后续想往嵌入式方向走,也可以尝试交叉编译到树莓派等设备上跑,Qt 良好的跨平台特性在这种小项目上能充分体现。
我在实际写这个项目时最深的体会是,Qt 开发不在于会摆弄多少控件,而在于理解事件循环、信号槽和对象生命周期这三个核心机制。把这三样理顺了,不光贪吃蛇能顺利跑起来,后面做任何 Qt 项目都会顺畅很多。
最后分享一个小技巧:写代码之前先画出游戏状态迁移关系,比如“运行、暂停、死亡、重开”这几个状态之间的转换条件。这看起来没什么技术含量,但能让后期的调试思路非常清晰,尤其当你想增加暂停功能、得分动画、关卡难度时,这个状态图就是你的导航地图。希望这篇经验能帮你少走弯路,完成一版自己满意的 Qt 贪吃蛇。
