
简介一份基于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 负责管理和切换不同版本的 qmakeqt5-qmake 是生成 Makefile 的工具qtcreator 是官方 IDE。最后两行用于验证安装结果。如果输出中能看到 qmake 和 g 的版本号说明工具链已经可用。选择包管理器而不是官网安装包主要是为了环境一致性。apt 装的 Qt 库路径在 /usr/libqmake 配置也写到系统目录命令行编译和 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 c11 EOF这个文件里最关键的是 TARGET 和 SOURCES 两行。TARGET 决定最终生成的可执行文件名注意不要带空格和中文否则 make 阶段容易出现路径错误。SOURCES 和 HEADERS 是维护文件清单的之后每新增一个类文件要么手动加一行要么在 Qt Creator 里右键项目选择 Add Existing Files让 IDE 帮你改。CONFIG c11 这行容易被忽略如果你在代码里用了 nullptr、auto、lambda而编译器还在默认按 C98 标准编译报错信息会非常迷惑先检查这一行是最快的排错路径。再配一个最小 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 生成 Makefilemake 调用 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; QVectorQPoint 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() { QVectorQPoint 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 缓存才跑得起来的虚惊。希望这一整套思路能帮到你。本文还有配套的精品资源点击获取