简介:这是一份基于Linux与Qt框架完成的贪吃蛇小游戏工程,适合正在学习C++/Qt图形界面开发或需要课程设计的开发者。整个项目按功能划分为界面显示、游戏逻辑、等级管理、音乐播放、英雄榜与帮助六大模块,结构清晰,便于理解Qt信号槽、事件处理与资源文件的使用方式。压缩包内含71个文件,以cpp/h源文件、ui界面文件、png/jpg图片素材及wav音频资源为主,并附带pro工程文件、Makefile和数据库文件,解压后即可在Qt 5.4.1环境下打开构建。包体大小约17.22MB,已有1473人学习浏览。通过这套代码,读者可掌握使用QPainter绘制游戏画面、QTimer驱动游戏循环、SQLite保存高分记录、QMediaPlayer播放背景音乐等常用技巧,同时也能参考其模块化组织方式,快速迁移到其他小游戏或Qt项目实战中。 我入行那会儿,第一个正经图形界面项目就是贪吃蛇。当时选的是Linux + Qt的组合,原因特别朴素:手里的开发机就是Ubuntu,而Qt的QPainter绘图和信号槽机制,做这种网格类小游戏几乎是量身定做。这次重新把这个老项目翻出来整理,发现“贪吃蛇”这种看起来不起眼的小玩意儿,其实把Qt开发里最容易忽略又最要命的几个点全串起来了:事件循环、定时器、绘图刷新、焦点处理,还有打包发布时Linux平台插件的坑。整个项目完整代码量不大,但每一块逻辑背后都有值得展开的设计考量。想把Qt基础打牢的读者,或者想找一个能快速跑通的图形界面练手项目的人,跟着走一遍应该会很有收获。
1. 项目拆解:贪吃蛇到底练了什么
1.1 为什么选Qt而不是其他GUI方案
Linux下的图形界面方案其实不少,GTK、wxWidgets、SDL、甚至纯终端的curses库都能做贪吃蛇。我最终选Qt,主要是看中几件事。
第一是信号槽机制。游戏里的“食物被吃”“游戏结束”这类事件,天然适合用信号槽去解耦。按下键盘产生一个事件,事件改变蛇的方向,方向改变触发表单重绘。如果用传统回调函数写,代码会越堆越乱,而信号槽的连接方式让代码路径非常清晰。
第二是QPainter绘图。贪吃蛇的界面本质上就是在一个固定网格上画方块,QPainter对这类2D绘图的支持非常舒服,画矩形、画网格线、画圆角,几行代码就能搞定。不用像SDL那样自己管理窗口表面和刷新缓冲。
第三是跨平台迁移。Linux下开发完,后续如果想弄到Windows上演示,Qt的源码迁移成本几乎是零。CMake或qmake工程文件稍微配一下,重新编译就能跑,这对练手项目相当友好。
1.2 核心功能拆解与模块划分
一个完整的贪吃蛇游戏,拆开来看其实就这几件事:
- 蛇身的存储与移动:蛇身体是一串坐标点,每走一步,头部朝当前方向前进一格,尾部去掉一格,保证总长度不变;吃到食物后,尾部不删,长度加一。
- 定时驱动:游戏不能靠死循环跑,得用一个定时器定时触发“走一步”的逻辑。这一步决定了游戏速度,也就是传统“帧”的概念。
- 碰撞检测:撞到边界直接输,撞到自己身体也要输。
- 食物生成:食物不能出现在蛇身上,每次生成位置要随机,但要保证有效。
- 键盘控制:方向键改变蛇的朝向,需要屏蔽掉“原地掉头”这种无效操作。
- 界面反馈:分数显示、暂停提示、结束画面这些交互反馈。
这六个模块要是能在一个工程里理清楚,Qt的事件驱动模型基本就入门了。
2. 环境搭建与工程结构
2.1 Linux下安装Qt的几种方式
Linux装Qt,常见的路子有两条。
第一条是用Qt官方在线安装器。从官网下载安装器,给执行权限后运行,选择需要的组件装就行。在线安装好处是组件齐全,官方维护,缺点是安装器体积不小,国内下载速度也是个问题。
第二条是用离线安装包。不会经常需要换版本的话,离线包可以直接拷到有网环境下载,然后装到任意目录。装完之后在/opt/Qt或你自定义的目录里就能看到一整套Qt工具链。
我用离线包比较多,因为开发机上可能不只一个Qt版本,离线包可以按版本号放在不同目录,切换的时候只需要改环境变量。
装完之后有个很容易踩的坑:编译Qt项目时提示找不到GL/gl.h或者链接不到OpenGL相关的库。原因是系统没装OpenGL开发头文件。Ubuntu/Debian系执行一下:
sudo apt install build-essential libgl1-mesa-devCentOS/RHEL系则是:
sudo yum install gcc-c++ mesa-libGL-devel提示:有些精简版系统连基本的C++编译器都没有,第一步先确认
g++ --version能正常输出,再谈Qt安装。
2.2 创建工程与目录组织
工程创建我建议直接用Qt Creator,省去手写pro文件的麻烦。新建项目时选择Qt Widgets Application,基类选QMainWindow或QWidget都行,贪吃蛇这个场景用QWidget反而更轻量。
建完后的目录结构大概是这样的:
SnakeGame/ ├── SnakeGame.pro ├── main.cpp ├── mainwindow.cpp ├── mainwindow.h ├── mainwindow.ui └── resources.qrcmainwindow.ui在贪吃蛇项目里其实用不太上,我一般直接删掉,整个界面都在paintEvent里画。这样代码更集中,新手理解起来也直观。
SnakeGame.pro文件里有几个重点参数,新手容易忽略:
QT += core gui greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = SnakeGame TEMPLATE = appTARGET决定编译出来的可执行文件名,和项目名不一致时会容易混淆,建议保持一致。
3. 核心代码实现:从蛇身到界面渲染
3.1 数据结构与移动逻辑
贪吃蛇的蛇身存储,最直观的方式是用一个QVector<QPoint>或者QList<QPoint>,每一项表示蛇身某一个节点的坐标。为什么用容器而不是直接定义一堆成员变量?因为蛇身长度是动态变化的,容器天然支持头插尾删。
我习惯定义成:
QVector<QPoint> m_snake; QPoint m_direction; // 当前前进方向,例如 (1,0) 表示向右移动逻辑看着简单,但有个关键点:不管蛇有没有吃到食物,算法都是先让头部走一格,然后判断是否吃到食物,吃到了就保留尾部,没吃到就删掉尾部。
每次移动时,新头部坐标是当前头部坐标加上方向向量:
QPoint newHead = m_snake.first() + m_direction; m_snake.prepend(newHead); if (!m_foodEaten) { m_snake.removeLast(); }这个方案比“整体移动每个节点”更高效,也是业界最常见的写法。
注意:
prepend在QVector里其实没有push_front直观,但Qt的容器接口跨平台统一,用习惯就好,性能差异在这个规模下完全无感。
3.2 QTimer驱动与碰撞检测
游戏循环我直接用QTimer,它的本质是基于事件循环的定时触发。传统游戏可能用多线程跑循环,但Qt里定时器方案更干净,也避免线程修改UI导致崩溃。
m_timer = new QTimer(this); m_timer->setInterval(150); // 每150毫秒走一格,大约每秒7格 connect(m_timer, &QTimer::timeout, this, &GameWidget::updateGame);为什么选150毫秒这个值?纯个人经验:太快新手反应不过来,太慢玩起来着急。150毫秒在20x20的棋盘上,体验比较均衡。后续在第四章我会提到如何通过动态改这个值来实现加速机制。
碰撞检测分两类。一类是撞墙,一类是撞自己。
// 撞墙检测 if (newHead.x() < 0 || newHead.x() >= m_cols || newHead.y() < 0 || newHead.y() >= m_rows) { gameOver(); return; } // 撞自己检测(注意排除尾部,因为尾部马上要移动) for (int i = 0; i < m_snake.size() - 1; i++) { if (m_snake.at(i) == newHead) { gameOver(); return; } }撞自己这里有个很容易忽略的细节:比较时要不要排除最后一个节点?当蛇没有吃到食物时,最后一个节点这一帧会尾删,所以蛇头移动到原来尾部的位置其实是安全的。不加这个判定,会出现“明明没碰到自己却宣布游戏结束”的诡异情况,我早期调试时被这个坑过。
3.3 QPainter绘制与刷新策略
绘制是贪吃蛇里最有意思的部分。Qt的绘制paintEvent会被系统频繁调用,所以不能在里面做复杂计算,只负责画。
先定义网格参数:
const int m_rows = 20; // 20行 const int m_cols = 20; // 20列 const int m_blockSize = 25; // 每个格子25像素画网格线用一个for循环,纵线和横线分别画,颜色用浅灰色,让整个棋盘有参照感。
蛇身绘制我推荐用圆角矩形,视觉上比纯方块舒服很多,代码也就是多填两个参数的事:
painter.setBrush(QBrush(QColor(46, 139, 87))); painter.setPen(Qt::NoPen); painter.drawRoundedRect(rect, 4, 4);食物用红色画实心圆,比方块醒目,也不容易和蛇身混淆。
这里必须提一个刷新策略的问题。很多人在更新完蛇的位置后直接调用this->update(),这个函数只是向事件系统发出一个“需要重绘”的请求,并不是立即重绘。真正执行绘制是在事件循环下次处理绘制事件时。这个机制是Qt高效的关键——多次调用update()系统会自动合并成一次重绘。
如果贪蛇游戏里发现运动轨迹“拖影”或者闪烁,通常不是update的锅,而是没有在绘制前清空背景。正确做法是绘制第一行就填充背景色:
painter.fillRect(rect(), QColor(255, 250, 240));3.4 键盘控制与焦点处理
贪吃蛇的方向控制,重写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里都有一次反向判断:当蛇正在向左走,你按右键,这时候如果直接改变方向,蛇会瞬间穿过自己的身体,这在经典贪吃蛇里是不允许的。屏蔽反向是硬性要求。
键盘事件不响应的坑,几乎每个新手都会遇到。最常见的原因有两个:
第一个是焦点问题。如果界面上有其他控件抢占了焦点,键盘事件就发不到QWidget上。解决办法是在构造和显示后设置焦点策略:
setFocusPolicy(Qt::StrongFocus);第二个是重写事件时忘记调用父类实现。有些情况但不处理方向键时,最好在末尾调用QWidget::keyPressEvent(event),避免吞掉其他键的事件。
4. 玩法打磨:状态机、计分与扩展方向
4.1 游戏状态管理与界面联动
最开始做这个项目时,我直接用布尔变量控制游戏状态,比如bool isRunning、bool isPaused,后来发现状态一多,各种组合判断能把人绕晕。比如暂停状态下按空格要恢复运行,游戏结束按空格要重新开始,开始前的“按任意键开始”也是一套逻辑。
后面我改成状态机,用枚举定义状态,代码清晰不止一个量级:
enum GameState { STATE_READY, // 等待开始 STATE_RUNNING, // 游戏中 STATE_PAUSED, // 暂停 STATE_GAMEOVER // 结束 };在这里使用switch处理按键事件时,逻辑路径会非常明确,该在哪个状态响应什么按键一目了然。状态切换用独立函数changeState()做,进入某个状态时统一处理“启动/停止定时器”“刷新界面文字”,不会出现状态改了但组件没跟上更新的情况。
4.2 计分、加速与扩展方向
基础版已经能跑之后,加分功能可以层层叠加。
计分逻辑最简单,吃到一次食物加10分,直接用QLabel显示在窗口顶部。食物生成时要检查随机坐标是否在蛇身上,做法是一个while循环不断生成,直到不在蛇上为止。数据量小,不会有多大的性能损耗。
加速机制的做法是每次吃到食物后,把定时器的间隔缩短一点,比如减少5毫秒,下限设在80毫秒:
int interval = m_timer->interval(); m_timer->setInterval(qMax(80, interval - 5));这个设计会让游戏逐渐变紧张,可玩性明显上升。
再往后的扩展方向就多了:加音效可以用QSoundEffect;加关卡可以改成障碍物地图;加排行榜可以用QSettings存本地记录;实现AI自动寻路可以尝试A*算法。我在另一篇文章里看到有人还用Qt里的QCustomPlot去画贪吃蛇的轨迹图,虽然没有必要,但确实说明Qt的绘图生态很够用。
5. 常见问题与排查技巧实录
5.1 高概率翻车点速查表
我把这几年在Qt开发里反复遇到的问题整理成了一个速查表,这些坑不少都是在带新人时被反复踩过,值得直接抄走。
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 键盘方向键没反应 | 焦点没有落在游戏控件上 | 调用setFocusPolicy(Qt::StrongFocus) |
| 界面闪烁、有残影 | 绘制前没清空背景 | paintEvent第一行填充背景色 |
| 蛇移动速度时快时慢 | 定时器间隔设置不合理或CPU占用过高 | 检查setInterval,避免在绘制里做耗时操作 |
| 编译报找不到GL/gl.h | 缺OpenGL开发包 | 安装libgl1-mesa-dev |
| 中文显示为乱码 | 源文件编码不是UTF-8 | 代码文件统一存为UTF-8,用tr包裹字符串 |
| 边跑边调试卡顿 | 调试模式下断言检查拖慢逻辑 | 无特殊处理,正常现象 |
| 蛇撞自己误判 | 碰撞检测没排除即将删除的尾节点 | 遍历时排除m_snake.size() - 1 |
这几个坑里,“蛇撞自己误判”我认为最隐蔽,逻辑上差一行代码,表现上却像随机Bug,排查起来很费时间。建议初始写碰撞逻辑时就直接把尾部排除写好,免得后面绕回来改。
5.2 发布打包与依赖处理
项目开发完要发给别人跑,Linux下的发布比Windows更麻烦些。Windows上有windeployqt一键收集DLL,Linux下的对应工具是linuxdeployqt,但使用起来对Qt版本和系统环境的依赖比较敏感,偶尔会遇到平台插件路径不对或者依赖库缺失的问题。
github上有不少linuxdeployqt的现成release可用,下载后配合打包脚本操作起来会方便许多。建议将工程目录里编译好的可执行文件与依赖的Qt库放到同一个目录,运行前设置LD_LIBRARY_PATH指向Qt的lib目录,这样切换版本时影响最小。
一个特别容易出错的地方是:开发机编译的程序拿到另一个干净环境运行,启动时报错qt.qpa.plugin: Could not find the Qt platform plugin "xcb"。这个问题本质是系统里缺少xcb相关运行库,把依赖补齐一般就能解决:
sudo apt install libxcb-xinerama0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-render-util0提示:如果只在自己的机器上跑,直接设置
export LD_LIBRARY_PATH=/opt/Qt/5.15.2/gcc_64/lib就能解决大部分加载问题;角度再多一点,程序里用绝对路径写QLibraryInfo::location()查看Qt库路径,对排查也很有帮助。
另外,打包之前记得用ldd查一下可执行文件依赖了哪些动态库,命令输出里如果看到not found,就是缺失项。手动把缺失的.so文件拷到程序目录,再用patchelf设置rpath,发布体验会稳定不少。
最后再分享一个围绕Qt排障的个人习惯:遇到这类平台插件初始化报错,第一反应不是重装Qt,而是先用ldd查依赖、用环境变量打印调试信息(比如QT_DEBUG_PLUGINS=1),这能直接显示出到底是哪个插件加载失败了。这个习惯让我少跑了好几次冤枉路。后面再往深了玩Qt,你会发现绘图、串口、网络每个方向都有对应的坑,但排查思路基本相通。先把这个小游戏跑通了,后面真正做项目时心里会特别有底。
本文还有配套的精品资源,点击获取