简介:基于 Linux 与 Qt 的贪吃蛇小游戏完整源码包,面向希望用小型项目练习 C++、Qt 界面编程和 Linux 桌面开发流程的初中级学习者。项目将界面显示与菜单切换、游戏画面与控制、等级管理、背景音乐播放切换、英雄榜分数统计及帮助信息等模块分开组织,便于梳理可运行小游戏的模块划分、事件处理、界面跳转与状态更新逻辑,也方便按功能逐块调试。压缩包共 71 个文件,约 17.22MB,包含 cpp 与 h 源码头文件、ui 界面文件、png/jpg 图片、wav 音频、pro 与 qrc 工程配置,以及编译产物和数据库等,目录结构相对完整。已有 1475 人学习,读者可对照源码理解 Qt 信号槽、资源组织、SQLite 高分记录和音乐播放等实现,对掌握项目从界面到数据持久化的组织方式有一定帮助,也可作为课程设计或 Linux 桌面开发入门参考。
1. 需求切入:为什么用 Qt 在 Linux 下做一个贪吃蛇
很多人做 C++ 课设,第一反应是控制台黑框加system("cls")清屏,跑起来满屏闪烁,方向键还得靠getch()这种非标准接口,换台机器就编译不过。这个项目的出发点正好相反:用 Linux 下的 Qt 做一个带图形界面的贪吃蛇,把 C++ 面向对象、事件驱动、定时器、绘图这几件事串成一个能演示、能改、能扩展的完整小系统。它适合两类人——正在找 C++ 与图形界面结合的课程设计题目的人,以及想把 Qt 的信号槽、QPainter、QTimer 一次性用熟的人。做成之后你能拿出的不只是一个游戏,而是一份有架构、有模块划分、有可调参数的工程,答辩时讲得清每一层为什么这么切。
2. 技术选型与总体架构:三层拆分与四类对象的职责边界
标题里三个关键词 C++、Qt、Linux,其实对应三个决策点:语言选 C++ 是为了贴近 Qt 原生接口、不引入额外绑定;框架选 Qt 是因为它一套 API 就把窗口、绘图、定时、事件全包了;平台选 Linux 是很多课程环境的默认选择,qmake 或 CMake 都能直接跑通。真正需要想清楚的是:游戏逻辑要不要和界面混在一起。我一般会强制拆开,因为混写之后改一个速度参数都得动 UI 代码,后面加关卡、加障碍物会非常难受。
2.1 为什么不用控制台方案,以及 Qt 版本与构建方式的选择
控制台版贪吃蛇的实现成本确实低,但它有三个绕不过去的短板:刷新靠清屏导致闪烁,键盘输入依赖平台相关接口,画面尺寸被字符格锁死。Qt 方案一次性解决这三点——绘制发生在paintEvent里由框架统一调度,不会出现整屏重绘的撕裂感;键盘通过keyPressEvent接收,键值用Qt::Key_Up这类枚举,跨平台一致;坐标单位从字符变成像素,格子大小可以随意调。
版本上,通常的做法是选 Qt 5.15 或 Qt 6.x 的 LTS 线,两者在绘图和定时器 API 上差异不大,本项目用到的QWidget、QPainter、QTimer、QKeyEvent都属于长期稳定的核心模块,不碰 QML 也不碰 QtQuick,避免引入额外的运行时依赖。构建工具建议直接上 CMake,qmake 虽然更简单,但 CMake 在 Linux 发行版里更通用,后面把项目丢给别人编译时少一层解释成本。
cmake_minimum_required(VERSION 3.16) project(SnakeGame LANGUAGES CXX) # 启用 Qt 的自动 MOC / UIC / RCC,信号槽才能正常生成元对象代码 set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 REQUIRED COMPONENTS Widgets) add_executable(SnakeGame main.cpp gamewidget.cpp gamemodel.cpp ) target_link_libraries(SnakeGame PRIVATE Qt6::Widgets)这段构建脚本里最关键的是CMAKE_AUTOMOC ON,少了它,凡是写了Q_OBJECT宏的类都会在链接阶段报undefined reference to vtable,这是 Qt 新手最常见的翻车点之一。find_package换成 Qt5 时把组件名改成Qt5::Widgets即可,其余不动。
2.2 三层结构与核心类的职责划分
架构上拆成三层:数据层负责蛇身、食物、分数、状态这些纯数据以及它们的演进规则;控制层负责定时推进、碰撞判定、游戏状态机(就绪、运行、暂停、结束);表现层负责把数据层的结果画出来、接收键盘事件。三层之间只通过明确的接口通信,不互相直接读成员变量。
落到类上,通常是四个:
| 类名 | 所在层 | 主要职责 | 关键接口 |
|---|---|---|---|
GameModel | 数据层 | 保存蛇身坐标队列、食物坐标、分数、当前方向 | move()、grow()、spawnFood() |
GameController | 控制层 | 定时推进、碰撞检测、状态切换 | tick()、start()、pause() |
GameWidget | 表现层 | 绘图、键盘输入、界面刷新 | paintEvent()、keyPressEvent() |
MainWindow | 表现层 | 菜单、分数标签、开始/暂停按钮的布局 | 槽函数绑定 |
这个划分的好处是,GameModel完全不依赖 Qt 的窗口类,只有QPoint和QVector,你甚至可以单独给它写一个控制台测试程序验证移动逻辑;GameWidget只负责画,不判断输赢。验收时如果老师问你「逻辑和界面怎么解耦的」,能指着这两个文件说清楚,基本就过关了。
3. 核心模块实现:蛇身推进、碰撞判定与画面重绘
这一章是真正要写代码的地方,把游戏跑起来需要的四件事说透:定时器怎么驱动、蛇怎么移动、撞墙撞自己怎么判、画面怎么画得干净。参数怎么调、失败时看哪里,也在这一章里交代。
3.1 用 QTimer 驱动游戏节拍,而不是死循环
贪吃蛇的本质是一个离散状态机,每过一个时间片蛇前进一格。用while(true)加sleep会把事件循环堵死,窗口直接无响应。正确做法是QTimer的timeout信号连到推进槽函数,让 Qt 的事件循环自己调度。
// GameController 构造中初始化定时器 m_timer = new QTimer(this); m_timer->setInterval(m_intervalMs); // 默认 150ms,越小蛇越快 connect(m_timer, &QTimer::timeout, this, &GameController::tick); void GameController::start() { if (m_state != State::Running) { m_state = State::Running; m_timer->start(); } } void GameController::tick() { m_model->move(); // 根据当前方向推进一格 if (m_model->hitWall() || m_model->hitSelf()) { m_state = State::GameOver; m_timer->stop(); // 停止推进,避免结束后蛇还在爬 emit gameOver(m_model->score().value()); return; } if (m_model->headOnFood()) { m_model->grow(); // 吃到食物:加分 + 加长 + 重刷食物 m_model->spawnFood(); adjustSpeed(); // 速度随分数提升(可选策略) } emit updated(); // 通知界面重绘 }setInterval的单位是毫秒,150ms 是骨架手感比较稳的起点,想加难度就把它往下调,但别低于 50ms,否则人眼跟不上,键盘输入也会显得迟滞。adjustSpeed()的常见做法是每吃若干个食物就把间隔减 5ms,并设一个下界(比如 60ms),否则后期会快到不可玩。这里用emit updated()而不是让控制器直接调update(),是为了让界面和控制层保持单向依赖——控制器不知道界面存在,只负责发信号。
3.2 蛇身用队列表示,移动的本质是头进尾出
蛇身最自然的表示是一个QVector<QPoint>,索引 0 放蛇头,最后一个是蛇尾。正常移动时在头部插入新坐标、删掉尾部一个;吃到食物时只插入不删除,长度自然增加。这样不用做「整体平移」,复杂度是 O(1)。
void GameModel::move() { QPoint head = m_body.first(); switch (m_dir) { case Dir::Up: head.ry() -= 1; break; case Dir::Down: head.ry() += 1; break; case Dir::Left: head.rx() -= 1; break; case Dir::Right: head.rx() += 1; break; } m_body.prepend(head); // 头部插入新位置 if (!m_pendingGrow) { m_body.removeLast(); // 没吃到食物则去掉尾巴,长度不变 } m_pendingGrow = false; } void GameModel::grow() { m_pendingGrow = true; // 标记本次必须增长,下一次 move 不删尾 m_score += 10; }这里有个容易写错的地方:grow()不能直接往尾巴后面append一个点,因为尾巴的下一个位置得等头部移动之后才能确定。用m_pendingGrow标记、在下一次move()里跳过删尾,是干净且不会重复增长的做法。坐标用的是格子索引QPoint(x, y),不是像素,绘制时再乘以kCellSize换算,这样换分辨率不用动逻辑。
3.3 碰撞判定:边界、自身与方向反转的三个判断
碰撞分三种,边界、自身、反向。前两种决定游戏结束,第三种是「不允许 180 度掉头」,否则蛇头会直接撞进第二节身体。
bool GameModel::hitWall() const { const QPoint& h = m_body.first(); return h.x() < 0 || h.y() < 0 || h.x() >= m_cols || h.y() >= m_rows; } bool GameModel::hitSelf() const { const QPoint& h = m_body.first(); // 从索引 1 开始比较,跳过头部自身 for (int i = 1; i < m_body.size(); ++i) { if (m_body.at(i) == h) return true; } return false; } void GameModel::setDirection(Dir d) { // 禁止反向:上不能接下,左不能接右 if ((m_dir == Dir::Up && d == Dir::Down) || (m_dir == Dir::Down && d == Dir::Up) || (m_dir == Dir::Left && d == Dir::Right) || (m_dir == Dir::Right && d == Dir::Left)) { return; } m_dir = d; }hitSelf()从索引 1 开始遍历是必须的,从 0 开始会把自己和自己比,永远返回真。方向反转的判断要放在setDirection里而不是move里,因为键盘事件可能在同一时间片内连续触发多次,早拦截比晚拦截更稳。如果要做「撞墙不结束而是穿墙」,把hitWall()换成取模回绕即可,这也是个很容易演示的扩展点。
3.4 用 QPainter 绘制:格子、蛇身渐变与状态覆盖层
绘制全部集中在paintEvent里,先画背景网格,再画食物,再画蛇身,最后按状态叠加文字。关键点是开启抗锯齿、关掉不必要的重绘,让画面不闪。
void GameWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); p.fillRect(rect(), QColor(28, 30, 34)); // 深色背景 const int cell = kCellSize; // 例如 20 像素 // 食物:实心圆,视觉上区别于方块蛇身 p.setBrush(QColor(230, 90, 80)); p.setPen(Qt::NoPen); p.drawEllipse(QRect(m_model->food() * cell, QSize(cell, cell))); // 蛇身:头部亮、尾部暗,形成方向感 for (int i = 0; i < m_model->body().size(); ++i) { int t = 255 - qMin(120, i * 8); // 越靠尾越暗 p.setBrush(QColor(90, 200, 130, t)); p.drawRect(QRect(m_model->body().at(i) * cell, QSize(cell - 1, cell - 1))); // 减 1 留出缝隙 } if (m_state == State::GameOver) { p.setPen(Qt::white); p.setFont(QFont("Sans", 20, QFont::Bold)); p.drawText(rect(), Qt::AlignCenter, "Game Over"); } }QPoint * int这种写法依赖QPoint与标量的乘法运算符,得到的是像素坐标,再配合QSize(cell, cell)构造矩形。方块尺寸减 1 是为了让相邻格子之间留一道缝,视觉上能看出蛇是一节一节的。t控制透明度形成渐变,从头部到尾部逐渐变淡,这个细节在答辩演示时很能加分,成本却只有一行。状态文字直接叠在中心,比弹窗打断体验更好。
3.5 键盘输入与焦点:让方向键真正生效
很多人写完发现按方向键没反应,八成是控件没拿到焦点。QWidget默认不接受焦点,需要显式设置,否则keyPressEvent根本不会触发。
GameWidget::GameWidget(QWidget* parent) : QWidget(parent) { setFocusPolicy(Qt::StrongFocus); // 关键:让控件能接收键盘事件 setFixedSize(kCols * kCellSize, kRows * kCellSize); } void GameWidget::keyPressEvent(QKeyEvent* e) { switch (e->key()) { case Qt::Key_Up: m_model->setDirection(Dir::Up); break; case Qt::Key_Down: m_model->setDirection(Dir::Down); break; case Qt::Key_Left: m_model->setDirection(Dir::Left); break; case Qt::Key_Right: m_model->setDirection(Dir::Right); break; case Qt::Key_Space: emit togglePause(); break; default: QWidget::keyPressEvent(e); return; // 未处理的键交还父类 } e->accept(); }setFocusPolicy(Qt::StrongFocus)是这一节的核心,它同时允许点击聚焦和 Tab 聚焦。末尾把未处理的键交还父类是个好习惯,否则空格之外的快捷键(比如 Esc 关窗口)会被吞掉。如果界面上还有按钮,要注意按钮被点击后焦点会跑到按钮上,此时方向键失效——解决办法是在游戏区域mousePressEvent里重新setFocus(),或者干脆用QShortcut全局绑定。
4. 避坑与常见问题排查:图形、定时与状态的三类翻车
这个项目代码量不大,但踩坑点集中,下面五条是我见过频率最高的,每条都按「现象 → 原因 → 解决」写。
第一条:蛇移动时画面出现拖影或闪烁。现象是蛇身后拖着残影。原因是paintEvent里只画了蛇没重绘背景,或者把绘制写在了定时器的槽函数里直接QPainter p(this)。解决是确保每次paintEvent的第一件事是fillRect(rect(), 背景色)覆盖整块区域,并且所有绘制都只在paintEvent内进行,槽函数里只调update()。
第二条:蛇吃到食物后长度不增反减,或者一次增长两格。原因是grow()和move()的调用顺序错了,比如先增长再移动,或者m_pendingGrow没在移动后清零导致下一帧又跳过一次删尾。解决是把增长标记设为一次性标志位,在move()末尾无条件置回 false,并把「碰撞 → 移动 → 吃食物判断」的顺序固定下来。
第三条:定时器停不下来,Game Over 后蛇还在动。现象是游戏结束后画面仍在变化。原因是tick()里只把状态设成 GameOver,没调m_timer->stop()。解决是在任何进入结束状态的分支里先停表再发信号,同时tick()开头加一句「状态不是 Running 就直接 return」的守卫,双保险。
第四条:换机器编译报undefined reference to vtable for Xxx。原因是类里写了Q_OBJECT宏但构建系统没跑 MOC,通常是 CMake 忘了set(CMAKE_AUTOMOC ON),或者头文件和源文件不在同一目录且没被扫描到。解决是确认 AUTOMOC 打开,然后把构建目录整个删掉重新cmake一次,别在旧缓存上反复编。
第五条:方向键连按导致蛇自己撞死。现象是快速按「上右下左」时蛇会瞬移式反向。原因是同一时间片内多次调用setDirection,虽然单次拦了反向,但两次合法转向叠加后可能变成掉头(比如左→上→右)。解决是给方向加一个「本帧已转向」标记,每个 tick 只允许改一次方向,收到第二次就丢弃。
除这五条外,还有个玄学问题值得一提:窗口在部分桌面环境下尺寸比你设的setFixedSize大出几个像素,导致右侧留白。这通常是布局或窗口边框造成的,setFixedSize之后配合setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed)一般能压住。
5. 验收与答辩:把参数、边界和扩展讲成一条线
验收这件事,老师或评审看的不是蛇长不长,而是你是否真的理解自己写的东西。所以准备答辩时,别背代码,按「参数 → 边界 → 异常 → 扩展」四条线组织,每条都能现场指到具体位置。
先把可调参数做成一张表,答辩时主动讲出来,比被动被问强得多。下面这套值是我一般会用的起点,改一个数就能演示难度变化:
| 参数 | 建议值 | 作用 | 改动后的影响 |
|---|---|---|---|
格子尺寸kCellSize | 20 px | 单格像素 | 变大则窗口变大,逻辑不变 |
网格行列kCols/kRows | 30 × 22 | 场地大小 | 影响难度与窗口比例 |
| 初始间隔 | 150 ms | 蛇速 | 调小变快,建议不低于 50 |
| 加速步长 | 5 ms / 5 分 | 难度曲线 | 过大则骤难,过小则无感 |
| 加速下界 | 60 ms | 速度上限 | 防止后期不可玩 |
准备两个演示场景:一是正常吃到 50 分,展示加速曲线;二是故意撞墙和撞自己,展示结束判定与分数结算。这两个场景能在两分钟内讲完,却覆盖了 80% 的评分点。
如果评审追问架构,就按数据层、控制层、表现层的顺序讲一遍职责和依赖方向,重点强调「控制器不认识界面,只发信号」这一句,因为它直接回答了「逻辑和界面怎么解耦」。如果追问扩展,列三条现成的:把hitWall()改成取模回绕做穿墙模式;在spawnFood()里加障碍物坐标集合做关卡;把GameModel的状态序列化成文本做最高分持久化。这三条都能在不改架构的前提下落地,说明你的划分是站得住的。
还有个答辩技巧:主动交代一个你踩过的坑。比如我一开始忘了setFocusPolicy,按方向键完全没反应,查了半天以为是信号槽没连上,最后发现是控件压根没接受焦点。这种具体的排查过程比任何术语都更能证明代码是你自己写的。
从那以后我每次做 Qt 的输入相关功能,第一步就是确认焦点策略,第二步是确认事件有没有被父类吞掉,这两步走完再谈逻辑。这个习惯帮我省了很多「明明代码没错却不动」的时间。希望帮到你,这套结构照着搭,代码量不大,但每一层都讲得清,答辩时心里有底。
本文还有配套的精品资源,点击获取