简介:一份基于Linux与Qt开发的贪吃蛇小游戏完整工程,适合正在学习C++/Qt或需要课程设计参考的开发者。项目按功能划分为界面显示、游戏控制、等级管理、背景音乐、英雄榜与帮助等模块,各模块职责清楚,结构清晰,便于阅读和二次修改。压缩包共71个文件,包括21个png图片、10个cpp源码、8个h头文件、8个jpg图片、3个ui界面文件及3个wav音频,还附带makefile、pro工程文件和编译后的可执行程序,总体积约17.22MB,在Linux下配置好Qt环境即可直接编译运行。目前已有1474人学习下载,说明该案例对同类游戏开发具有一定参考价值。通过这份代码可以掌握Qt中绘图、事件处理、定时器、信号槽、界面切换以及SQLite记录高分等常见技术的综合用法,同时理解多模块协作与资源文件管理方式,尤其适合作为本科或高职阶段图形界面编程项目的起步模板。
1. 用贪吃蛇入门Qt:为什么建议在Linux下做
我见过不少想学 Qt 的朋友,一上来就啃官方文档,啃了三天还在看信号槽的原理,连一个能跑的窗口都没弹出来。如果你也是 C++ 语法有些基础、但图形界面一直没真正动手的状态,我建议直接做贪吃蛇。这个小项目麻雀虽小五脏俱全,覆盖了 Qt 的事件循环、定时器、绘图、键盘输入和数据结构设计,在 Linux 下配合 Qt Creator 从零搭起来,一个周末就能跑通。它适合刚学完 C++ 想用 Qt 做第一个完整小游戏的开发者,也适合把课程设计当练习的人——做完之后你会发现,Qt 不再是一个黑匣子,而是手里顺手的工具。
2. 环境准备与工程骨架:从空窗口到 qmake 编译通过
2.1 在 Linux 上装 Qt 工具链:两条路线怎么选
在 Linux 下装 Qt 常见做法是直接从官网下载开源版安装包,但我一般更推荐先用发行版自带的包管理器装。以 Ubuntu/Debian 系为例,把以下命令按顺序执行,Qt 开发和编译最基础的组件就齐了:
sudo apt update sudo apt install build-essential sudo apt install qtbase5-dev qtchooser qt5-qmake qtcreator qmake --version g++ --version第一条是刷新软件源索引;第二条装 gcc/g++ 和 make,这是编译 C++ 源码的底层工具链;第三条是核心,qtbase5-dev 提供 Qt Widgets 模块的头文件和链接库,qtchooser 负责管理和切换不同版本的 qmake,qt5-qmake 是生成 Makefile 的工具,qtcreator 是官方 IDE。最后两行用于验证安装结果。如果输出中能看到 qmake 和 g++ 的版本号,说明工具链已经可用。
选择包管理器而不是官网安装包,主要是为了环境一致性。apt 装的 Qt 库路径在 /usr/lib,qmake 配置也写到系统目录,命令行编译和 Qt Creator 打开的构建环境是同一套,不会出现“IDE 里能编、命令行却链接失败”的两套 Qt 打架的玄学问题。如果你用的是 Arch 系发行版,就换成 sudo pacman -S qt5-base qtcreator 同理。如果你所在的环境没有桌面,只有命令行,也不要慌,这个项目可以先用纯命令行维护代码,用 qmake 和 make 编译,最后再想办法运行图形环境。
提示:qtchooser 和 qt5-qmake 必须配套,某些发行版默认会把 qmake 指向 Qt 6,打开 Qt 5 的 .pro 文件时会报语法不兼容。
2.2 工程骨架:qmake 的 .pro 文件怎么组织
Qt 5 官方推荐 qmake,课程和网上大部分教程也都围绕 .pro 文件组织工程。一个最精简的贪吃蛇工程,其实只需要一个 .pro 文件和一个 main.cpp 就能跑出空窗口:
# 创建 .pro 工程文件 cat > greedy-snake.pro << 'EOF' QT += core gui greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = greedy-snake TEMPLATE = app SOURCES += main.cpp HEADERS += snake.h CONFIG += c++11 EOF这个文件里最关键的是 TARGET 和 SOURCES 两行。TARGET 决定最终生成的可执行文件名,注意不要带空格和中文,否则 make 阶段容易出现路径错误。SOURCES 和 HEADERS 是维护文件清单的,之后每新增一个类文件,要么手动加一行,要么在 Qt Creator 里右键项目选择 Add Existing Files,让 IDE 帮你改。CONFIG += c++11 这行容易被忽略,如果你在代码里用了 nullptr、auto、lambda,而编译器还在默认按 C++98 标准编译,报错信息会非常迷惑,先检查这一行是最快的排错路径。
再配一个最小 main.cpp 验证工具链:
#include <QApplication> #include <QWidget> int main(int argc, char *argv[]) { QApplication app(argc, argv); QWidget w; w.setWindowTitle("Greedy Snake"); w.resize(600, 600); w.show(); return app.exec(); }这段代码的作用是创建一个 600×600 的空白窗口。QApplication 是每个 Qt GUI 程序必须有的对象,负责事件循环和全局初始化,一个进程只能创建一次。w.resize 设置初始窗口大小,w.show 让窗口可见,app.exec() 进入事件循环,之后程序才会开始处理键盘、绘制、定时器等消息。如果你把这行删掉,程序会一闪而过。
写完这两个文件,在终端里进入工程目录执行:
qmake make ./greedy-snakeqmake 会根据 .pro 生成 Makefile,make 调用 g++ 编译链接,最后运行可执行文件。如果这一步能弹出窗口,说明你的工具链和工程骨架都没问题,可以开始写游戏逻辑了。这里有个小建议:每加一个类、每改一次头文件,都先 qmake 再 make,不要图省事只 make,因为新增类文件时 Makefile 里没有对应依赖,直接 make 会漏编译。
2.3 Qt Creator 里的构建配置:Kits 选不对什么都白搭
如果你习惯用 IDE 而不是命令行,在 Qt Creator 打开项目后,第一个要检查的就是左下角的构建套件(Kits)。点开那台小电脑图标,看编译器列表里有没有带 GCC 字样的 Desktop 套件,比如 “Desktop Qt 5.x GCC 64bit”。如果列表显示 No Kits,说明 Qt Creator 没有自动探测到编译器,需要先确认 build-essential 是否装好,再重启 Qt Creator。
构建目录也建议手动指定。在项目设置页面把 Build Directory 改成 build 目录,和源码分开。这样做的好处是,清理临时文件时可以直接删除整个 build 目录,不影响源码。我见过不少同学把编译产物和源代码堆在一起,最后清理时手一抖把源码删了,那种心疼只有经历过的人才懂。
还有一个容易出问题的点是,Qt Creator 默认会在打开项目时弹出一个版本选择框,如果你同时装了 Qt 5 和 Qt 6,一定要选对和代码匹配的版本。本文的代码基于 Qt 5 的 widgets 模块,如果用 Qt 6 打开,大部分写法仍然兼容,但个别 API 的行为有差异,新手期建议先锁定一个大版本,别来回切换。
3. 拆解核心逻辑:方向输入、移动算法与三类碰撞判定
3.1 方向控制:QKeyEvent 与定时器如何协同
贪吃蛇移动的本质是“定时器驱动的离散格子跳变”,不是连续动画。你不能让蛇头每帧平滑位移,而应该每隔固定的时间步,让蛇头走到下一个格子。这一步由 QTimer 和重写 keyPressEvent 配合完成。先在 snake.h 里声明游戏画布类:
#include <QWidget> #include <QTimer> #include <QVector> #include <QPoint> class GameBoard : public QWidget { Q_OBJECT public: explicit GameBoard(QWidget *parent = nullptr); protected: void paintEvent(QPaintEvent *event) override; void keyPressEvent(QKeyEvent *event) override; private: void tick(); QTimer timer; QVector<QPoint> snake; QPoint food; int dir; bool isRunning; };QVector 是 Qt 提供的动态数组容器,QPoint 存储 x/y 坐标。用 QVector 表示蛇身,索引 0 固定是蛇头,后续操作只有“头部插入”和“尾部删除”两种,再配合随机生成的 QPoint 食物坐标,数据结构部分就齐了。构造函数里完成定时器绑定:
GameBoard::GameBoard(QWidget *parent) : QWidget(parent) { setFocusPolicy(Qt::StrongFocus); timer.setInterval(200); connect(&timer, &QTimer::timeout, this, &GameBoard::tick); timer.start(); }setFocusPolicy(Qt::StrongFocus) 是最容易漏的一行。QWidget 默认不接收键盘焦点,如果不设置,方向键按破也不会触发 keyPressEvent。我把这个教训记住了:所有需要键盘输入的 QWidget 子类,构造时第一件事就是设置强焦点。
3.2 蛇身移动:QVector 头插尾删与反向键过滤
每一帧移动的逻辑可以拆成三步:按当前方向算出新蛇头位置、把新蛇头插入头部、从尾部删掉一格。如果新蛇头正好落在食物上,就不删尾部,蛇自然变长。
void GameBoard::tick() { QPoint head = snake.first(); QPoint newHead = head; switch (dir) { case 0: newHead.ry() -= 1; break; // 上 case 1: newHead.rx() += 1; break; // 右 case 2: newHead.ry() += 1; break; // 下 case 3: newHead.rx() -= 1; break; // 左 } if (newHead.x() < 0 || newHead.x() >= cols || newHead.y() < 0 || newHead.y() >= rows) { gameOver(); return; } for (int i = 0; i < snake.size() - 1; ++i) { if (snake[i] == newHead) { gameOver(); return; } } snake.insert(0, newHead); if (newHead == food) { spawnFood(); } else { snake.removeLast(); } update(); }这里假设 GameBoard 已经有 cols 和 rows 两个成员变量表示网格列数和行数。自身碰撞的 for 循环只遍历到 size()-1,这个细节是查了很久才发现的:蛇尾那一格在移动后会马上删除,所以新蛇头如果移动到了旧蛇尾的位置,属于合法移动。如果无脑使用 snake.contains(newHead),蛇在绕圈时会被误判碰撞提前死亡,这是贪吃蛇实现里最经典的边界 bug。
方向键的处理要过滤反向。方向用 0上 1右 2下 3左 的整数表示,按左键时如果当前方向是右,直接忽略这次按键:
void GameBoard::keyPressEvent(QKeyEvent *event) { if (state != Running) return; switch (event->key()) { case Qt::Key_Up: if (dir != 2) dir = 0; break; case Qt::Key_Down: if (dir != 0) dir = 2; break; case Qt::Key_Left: if (dir != 1) dir = 3; break; case Qt::Key_Right: if (dir != 3) dir = 1; break; } }反向过滤的四个 if 一个都不能省,否则蛇正在右行时按左键,蛇头直接反向冲进身体,一帧死亡。注意这里没有考虑同一帧内连按两次方向键的情况,因为 QTimer 的 tick 是异步的,连续按两下会先把方向改了,再触发下一个 tick,实际效果是蛇在新方向基础上又偏移一次,要彻底解决需要做一个“待处理方向队列”,但课程设计阶段用上面这套直接覆盖的方案已经很够。
3.3 食物生成:随机坐标的两步法与满屏防呆
食物的坐标需要在游戏区域内随机生成,但不能落在蛇身上。最朴素的方法是 do-while 死循环重试,蛇短时没问题,蛇长到几百格时,碰撞概率变高,重试次数可能指数上升,偶发卡顿。所以更稳的两步法是:先遍历所有空格收集候选坐标,再随机取一个。
void GameBoard::spawnFood() { QVector<QPoint> candidates; for (int x = 0; x < cols; ++x) { for (int y = 0; y < rows; ++y) { QPoint p(x, y); if (!snake.contains(p)) candidates.append(p); } } if (candidates.isEmpty()) { gameOver(); return; } food = candidates[QRandomGenerator::global()->bounded(candidates.size())]; }这里使用 QRandomGenerator::global()->bounded(size),生成的结果在 [0, size) 之间,正好当数组下标。如果用旧的 qrand 或者 rand(),还需要自己处理取模,而且全局随机种子管理容易出问题,Qt 5.10 之后的代码建议都走 QRandomGenerator。一个容易忽略的细节是,spawnFood 之后必须调用 update(),否则分数变了、蛇变长了,但界面上的新食物不出现。我曾经把这个问题归因于 Qt 定时器不稳定,最后发现只是忘了刷新,白折腾了半天。
3.4 游戏状态机:暂停与结束时的按键接管
贪吃蛇最少要有四种状态:初始化、运行中、暂停、结束。如果不用状态机,很容易出现“游戏结束后按方向键还能继续跑”“暂停后定时器还在走”的尴尬情况。在 snake.h 的 private 区补一个枚举:
enum GameState { Init, Running, Paused, GameOver }; GameState state = Init;暂停和继续用空格键切换,方向键只在 Running 状态响应:
void GameBoard::keyPressEvent(QKeyEvent *event) { if (event->key() == Qt::Key_Space) { if (state == Running) state = Paused; else if (state == Paused) state = Running; return; } if (state != Running) return; // 方向键处理同上 }值得注意的动作是:暂停时不要停 QTimer,而是把状态切成 Paused,让 tick() 在开头判断一下状态再决定要不要移动。这样比 stop/start 省心,也避免重启定时器时间隔重置的副作用。游戏结束的处理则相反,一定要调用 timer.stop(),同时把 state 置为 GameOver,双保险。
4. 界面渲染与视觉优化:绘制顺序、加速节奏与失败反馈
4.1 渲染方案:QWidget 重绘为什么比 QGraphicsView 更适合练手
贪吃蛇的画面其实是几十个格子组成的小网格,渲染方案主流是两种:一种是继承 QWidget、重写 paintEvent,另一种是用 QGraphicsView/QGraphicsScene。QWidget 方案代码量最小、概念最直接,所有绘制集中在一个 paintEvent 函数里,QPainter 提供的 fillRect 就能满足全部需求。QGraphicsView 是 Qt 的图形视图框架,引入了 Scene、Item、Transform 等新概念,适合做更复杂的场景、碰撞检测、缩放、动画,但第一周学它容易把精力耗在框架关系上。
如果你只是完成课程设计或练手,QWidget 方案足够。如果后面想扩展成完整游戏,可以等逻辑稳定后再迁到 QGraphicsView,迁移成本不算大,因为游戏逻辑已经独立了。
4.2 paintEvent 的绘制顺序和尺寸计算
paintEvent 在每次调用 update() 之后被 Qt 自动触发。绘制顺序统一是:背景、网格、蛇身、食物、分数。顺序错了会出现食物被蛇身盖住之类的视觉 bug。
void GameBoard::paintEvent(QPaintEvent *) { QPainter painter(this); int cellW = width() / cols; int cellH = height() / rows; painter.fillRect(rect(), QColor(30, 30, 30)); for (int i = 0; i < snake.size(); ++i) { QRect r(snake[i].x() * cellW, snake[i].y() * cellH, cellW - 1, cellH - 1); painter.fillRect(r, i == 0 ? QColor(100, 220, 100) : QColor(60, 180, 60)); } QRect foodRect(food.x() * cellW, food.y() * cellH, cellW - 1, cellH - 1); painter.fillRect(foodRect, QColor(240, 80, 80)); painter.setPen(Qt::white); painter.drawText(20, 30, QString("Score: %1").arg(score)); }每个方块的尺寸是 width() / cols,这意味着窗口拉大时格子会自动放大。绘制时方块宽高都减了 1 像素,是为了让相邻格子之间留出黑缝,视觉上更容易区分身体节段。蛇头用了更亮的绿色,食物用了红色,整体颜色统一,界面看起来不至于太廉价。如果窗口被用户拉伸,resizeEvent 里必须主动调用 update(),否则新露出的区域不会重绘:
void GameBoard::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); update(); }4.3 加速逻辑:每吃五个食物缩短定时器间隔
初始 200ms 一步舒服,但后期玩家手感上来后会嫌慢。常规做法是每吃 5 个食物把 QTimer 间隔减少 15ms,下限设为 80ms。低于 80ms 后不再加速,因为普通人的键盘反应和视觉反馈已经跟不上了。
void GameBoard::onFoodEaten() { int eaten = snake.size() - initialLen; if (eaten >= 5 && eaten % 5 == 0) { int newInterval = timer.interval() - 15; timer.setInterval(qMax(newInterval, 80)); } }QTimer::setInterval 在已启动状态下会立即生效,下一次超时开始使用新间隔,不需要 stop 再 start。加速逻辑放在吃到食物分支里,和移动逻辑分开,否则容易把加速判定和移动次数混在一起。
4.4 结束反馈:让失败这件事被玩家看见
一个看起来“完整”的贪吃蛇,至少要有清晰的失败反馈。gameOver 时停止定时器、改变状态、弹出消息框:
void GameBoard::gameOver() { timer.stop(); state = GameOver; QMessageBox::information(this, "Game Over", QString("最终得分: %1").arg(score)); }这里有一个顺序问题:必须先 stop 再弹消息框。如果弹框时定时器还在跑,Qt 的消息循环会让定时器继续触发,游戏在弹框背后还在走,玩家点完确定发现蛇已经死了好几回。我第一次写就踩了这坑,弹框弹了三秒,蛇自己把墙撞完了。
5. 常见问题与排查:五个最容易翻车的 Qt 开发点
5.1 键盘事件完全没反应:十有八九是焦点问题
现象是窗口能正常显示,但方向键按下去毫无反应,终端里如果加了 qDebug 也打不出任何输出。原因基本可以锁定为 QWidget 默认不接收键盘焦点,必须显式把焦点策略设置为 Qt::StrongFocus;另一个容易被忽略的原因是界面里有其他子控件抢走了焦点。解决方法是构造函数里设置 setFocusPolicy(Qt::StrongFocus),并且在窗口显示后调用 setFocus() 强制拿回焦点。
5.2 拉大窗口后出现大片空白或残影
现象是窗口从小拉到大,新露出的区域是灰色或上一帧的内容。原因是窗口尺寸变了,但 Qt 不会自动重绘新区域,paintEvent 没有因为 resize 而触发。解决方法是重写 resizeEvent 并在里面调用 update(),顺带检查游戏中每次改变状态后是否都调用了 update(),漏一下画面就停在旧状态。注意不要用 repaint() 代替 update(),repaint 会强制同步绘制,在高频场景下反而卡顿。
5.3 新增类后编译报 undefined reference to vtable
现象是代码看起来没问题,链接阶段报 vtable 相关的 undefined reference。原因是类里声明了 Q_OBJECT 宏,需要 moc 工具生成元对象代码,但 Makefile 还是旧的,没有把新的 moc 文件编进去,直接 make 不会自动感知新增类。解决方法是删除 build 目录,重新执行 qmake && make;在 Qt Creator 里就是右键项目选择 Run qmake,然后再构建。这个操作几乎是所有 Q_OBJECT 类问题的解药。
5.4 中文乱码:标题和弹窗全是问号
现象是代码里写死的字符串在窗口里显示成乱码或问号。原因是源文件编码和 Qt 解析编码不一致,Linux 下 Qt 默认按 UTF-8 解析,而有些从 Windows 拷贝来的源码是 GBK,不同编码下同一个字符串的字节序列不一样,显示自然就乱了。解决方法是把源文件统一另存为 UTF-8,如果是从 Windows 拷贝的工程,全选所有 .cpp 和 .h 统一转编码,再重新编译。
5.5 蛇移动一卡一卡,像幻灯片
现象是蛇正常走几步后突然顿一下,然后又恢复,顿的时候肉眼可见。原因是游戏循环里做了同步阻塞操作,常见元凶是日志输出、文件写入、或者食物生成用了低效的死循环重试。解决方法是游戏主循环里不要做任何 IO,打印调试信息用 qDebug 可以,但正式跑的时候注释掉;保存最高分放到 gameOver 之后做。定位这类问题时,把候选代码逐段注释,看卡顿消失在哪一段,基本一抓一个准。
6. 从贪吃蛇到小框架:把代码重构成可扩展的游戏结构
贪吃蛇第一版跑通后,强烈建议做一次重构。把游戏逻辑从窗口界面里拆出来,大致分成三层:数据层负责蛇、食物、分数、状态;规则层负责移动、碰撞、生成;表现层负责绘制、键盘、弹框。第一版这三个层次全部堆在 GameBoard 里,写起来快,但后续加功能会越来越多 if。我重构时做的第一件事是新建一个 GameCore 类,让它只持有逻辑数据,不依赖任何 QWidget 类型。GameBoard 的 tick() 只调 core.step(),然后根据返回值决定画什么、弹不弹框。这样做的收益是 GameCore 可以脱离 GUI 单独测试,写一个命令行测试文件喂一串方向指令进去,断言每一步蛇头坐标,跑一遍就知道逻辑对不对,不需要打开窗口手动验证。
紧接着可以加一个“最高分持久化”功能,用 QSettings 比手写文件简单得多:
void saveHighScore(int score) { QSettings settings("MyApp", "GreedySnake"); int last = settings.value("highScore", 0).toInt(); if (score > last) { settings.setValue("highScore", score); settings.sync(); } }QSettings 在 Linux 下默认写到 ~/.config/MyApp/GreedySnake.conf,读的时候用 value("highScore", 0) 带默认值,文件不存在也不会崩。这个库把跨平台路径差异封装掉了,比手动 QFile 加 QDir 再处理路径分隔符省心太多。再往后可以考虑把方向输入改成“指令队列”而不是直接覆盖,这样能处理同一帧内多次按键。做法是在数组里缓存待处理的方向,tick 时只消费队列头部的指令。这个改动虽然小,但游戏手感会明显改善,也是从“能玩”到“好玩”的分水岭。
我从那以后每次做完一版,都会强制自己走一遍编译清理流程:删 build 目录、qmake、make、命令行运行。这个习惯帮我避开了好几次借助 IDE 缓存才跑得起来的虚惊。希望这一整套思路能帮到你。
本文还有配套的精品资源,点击获取