简介:基于Qt与C++的贪吃蛇游戏毕业设计完整方案,适合计算机相关专业学生完成课程设计或毕业设计时参考。压缩包内含可编译运行的源码、毕业设计论文、任务书以及答辩PPT,从需求分析、总体设计到编码实现均有对应文档支撑;论文中对技术选型、Snake窗口界面布局、碰撞检测和食物生成策略做了详细说明,任务书则明确了设计目标、预期成果和时间安排,便于读者对照规划自己的开发进度。通过阅读源码,可以掌握Qt的事件驱动模型、信号槽机制以及C++面向对象封装方法,游戏中的按键控制、得分刷新和状态管理都有清晰的代码示例。资源共16个文件,涵盖cpp、h源码,pro、ui、qrc工程配置,doc论文与pptx演示文稿,以及png/jpg界面截图,整体6.82MB,轻量易用。目前已有505人学习下载,对于需要快速上手Qt游戏开发、梳理毕设文档写作框架的读者,是一份颇具参考价值的实例资源。
1. 开学前的那个晚上,你对着 .zip 文件在想什么
“基于Qt_C++的贪吃蛇游戏设计与实现源码论文.zip”,这个名字对两类人最扎眼:一类是正在赶课程设计或毕业设计的本科生,另一类是刚把 Qt 环境配好、想拿小项目练手的在职开发者。前者需要的是“能复现、能讲清楚、能应付答辩”,后者需要的是“结构不绕、代码能跑、改起来不费劲”。这篇博文就把这两件事一起做掉:不贴整段源码,而是把贪吃蛇在 Qt/C++ 里最合理的组织方式拆开讲——类怎么划、信号槽怎么连、定时器怎么驱动游戏循环、碰撞判定放在哪一层。你会看到一套直接能用的工程思路,而不是一个只会在你机器上跑一次的 Demo。
先给结论:Qt 版贪吃蛇的技术核心不在“蛇怎么画”,而在游戏循环的驱动机制。用QTimer驱动刷新、用事件循环处理按键、用状态机管理运行/暂停/结束,这三件事想清楚,游戏本体写起来反而很快。本文将按“选型→实现→实战→验证”的顺序推进,第 2 章先解决“用 Qt 的哪套绘图方案”这个分叉口,第 3、4 章分别拆开核心类设计和游戏主循环,最后一章给你几个答辩或自测时能说出口的加分点。
2. Qt 贪吃蛇的选型分叉:QWidget 直接绘图 vs QGraphicsView
2.1 两类方案的适用边界
用 Qt 实现贪吃蛇,绕不开的第一个选择题是:在QWidget::paintEvent里直接画,还是上QGraphicsView + QGraphicsScene的图形视图框架。常见做法是前者,因为本项目的规模根本不需要图元管理和碰撞检测接口——QGraphicsItem的优势在大量可交互对象(拖拽、旋转、层级)时才显现。贪吃蛇的“蛇身”无非是一串整型索引点阵,自己管反而更直接,没有信号槽层面的额外负担,也不用担心场景迭代更新带来的性能损耗。
我一般会建议用QWidget方案,理由有三点:
- 整张游戏地图是一个固定矩形,重绘成本极低,一帧最多 20 × 20 个方块;
- 键盘事件用
keyPressEvent重写即可,QGraphicsView里还得装事件过滤器,徒增复杂度; - 答辩时聊“绘图原理”比聊“框架 API”更有含金量,面试官也更想听到你解释双缓冲和局部刷新。
2.2 工程骨架:一个 MainWindow 加三个自定义类
接下来按“最小可运行工程”的标准搭目录。打开 Qt Creator,新建 Qt Widgets Application(注意不要勾生成 .ui 文件,手动代码更可控),然后在项目里加四个文件:mainwindow.h/cpp、gamewidget.h/cpp、snake.h/cpp、food.h/cpp。分工是 MainWindow 管菜单栏和状态栏,GameWidget 管绘制和事件,Snake 管蛇的移动、转向和自撞检测,Food 管食物生成坐标。
项目文件game.pro里需要确认两件事:第一是greaterThan(QT_MAJOR_VERSION, 4): QT += widgets这一行存在,第二是CONFIG += c++11让 lambda 表达式能用在信号槽里。下面给出一个完整的game.pro参考:
QT += core gui greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = qt_snake TEMPLATE = app CONFIG += c++11 SOURCES += main.cpp \ mainwindow.cpp \ gamewidget.cpp \ snake.cpp \ food.cpp HEADERS += mainwindow.h \ gamewidget.h \ snake.h \ food.h这里TARGET指定生成的可执行文件名,SOURCES和HEADERS必须和实际文件一一对应,漏一个都会在编译时报“undefined reference”。为什么用CONFIG += c++11?因为后续连接信号槽时会频繁用到connect(sender, &SenderType::signal, receiver, [this]{ ... })这种 lambda 写法,C++11 是它的最低要求。如果遇到error: microsoft visual c++ 14.0 or greater is required这类报错,检查的是你本机的 MSVC 编译器版本,与game.pro无关——Qt 5.15 及以上通常要求 VS2017 之后的工具链。
2.3 最小的绘图基座:paintEvent 与双缓冲
GameWidget是核心绘图类,它的paintEvent承担全部画面输出。贪吃蛇的地图可以抽象成二维网格,宏定义如下:
// gamewidget.h 中的常量定义 #define ROWS 20 // 行数(Y 方向格子数) #define COLS 20 // 列数(X 方向格子数) #define BLOCK_SIZE 24 // 每个格子的像素边长 #define OFFSET_X 10 // 地图左边距 #define OFFSET_Y 10 // 地图上边距paintEvent里最实用的技巧是:先用QPainter填充整块背景,再绘制网格线,最后按蛇身坐标逐格填色。代码如下:
void GameWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 绘制背景 painter.fillRect(rect(), QColor(40, 44, 52)); // 绘制网格线 painter.setPen(QColor(60, 64, 72)); for (int col = 0; col <= COLS; ++col) { int x = OFFSET_X + col * BLOCK_SIZE; painter.drawLine(x, OFFSET_Y, x, OFFSET_Y + ROWS * BLOCK_SIZE); } for (int row = 0; row <= ROWS; ++row) { int y = OFFSET_Y + row * BLOCK_SIZE; painter.drawLine(OFFSET_X, y, OFFSET_X + COLS * BLOCK_SIZE, y); } // 绘制食物(这里由 GameWidget 的 food 成员变量提供坐标) painter.setBrush(QColor(231, 76, 60)); painter.setPen(Qt::NoPen); painter.drawEllipse(food.pos().x() * BLOCK_SIZE + OFFSET_X, food.pos().y() * BLOCK_SIZE + OFFSET_Y, BLOCK_SIZE - 2, BLOCK_SIZE - 2); // 绘制蛇身 auto body = snake.body(); for (int i = 0; i < body.size(); ++i) { int col = body[i].x(); int row = body[i].y(); painter.setBrush(i == 0 ? QColor(52, 152, 219) : QColor(46, 134, 193)); painter.drawRect(col * BLOCK_SIZE + OFFSET_X, row * BLOCK_SIZE + OFFSET_Y, BLOCK_SIZE - 2, BLOCK_SIZE - 2); } }这段代码有四处值得注意:第一,fitInView和缩放都没做,因为窗口尺寸是固定的COLS * BLOCK_SIZE + 2*OFFSET_X,省去自适应逻辑;第二,“蛇头用亮蓝色、蛇身用深蓝色”是通过三目运算符区分索引 0 和其他索引实现的,不需要额外数组;第三,绘制食物用的是drawEllipse而不是drawRect,目的只是视觉上区分圆形的食物和方形的蛇身;第四,BLOCK_SIZE - 2这个偏移让相邻方块之间有 2 像素间隙,视觉上更有“格子感”。
3.核心设计:Snake 类、Food 类和碰撞判定
3.1 Snake 类:数据结构决定实现成本
贪吃蛇的蛇身是一个线性序列,每个节点头部插入、尾部删除。这个操作特征对应两个候选结构:QQueue<QPoint>和QList<QPoint>。前者出队是 O(1),但中间访问需要遍历;后者支持随机访问,头部插入最坏是 O(n)。当蛇长只有几十个点时要 “乱谈复杂度” 吗?要谈,但结论是:真正常用QList,不是因为它最快,而是因为绘制和自撞检测都需要按下标遍历。蛇身定义如下:
class Snake { public: enum class Direction { UP, DOWN, LEFT, RIGHT }; Snake(); // 初始化三节身体,向右前进 void setDirection(Direction d); // 设置下次移动方向 Direction direction() const; bool move(QPoint foodPos); // 按方向移动,返回是否吃到食物 bool isSelfCollision() const; // 判断头是否撞到自己 QList<QPoint> body() const { return m_body; } private: QList<QPoint> m_body; Direction m_dir; };move()的返回值设计比较关键:它返回“这次移动是否吃到了食物”,因为吃到了蛇就长一节,没吃到就删尾。外部拿到这个布尔值之后决定要不要加分、要不要让Food重新生成:
bool Snake::move(QPoint foodPos) { QPoint head = m_body.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_body.prepend(head); // 头先插进去 if (head == foodPos) { return true; // 吃到食物:尾部不删除,相当于蛇长+1 } m_body.removeLast(); // 没吃到:去掉尾巴,保持长度 return false; }head.ry() -= 1里用ry()返回引用再自减,是为了让代码更紧凑;等价写法是head.setY(head.y() - 1)。注意prepend和removeLast的顺序:必须先插头再决定删不删尾,否则吃到食物时蛇身长度会少 1。Direction用枚举类而不是普通枚举,是为了避免UP、DOWN这类名字在全局命名空间里冲突——很多 Qt 头文件里也有Up之类的定义。
3.2 Food 类:随机生成要看“可用格子”
Food类的唯一责任是产生一个新坐标,并且这个坐标不能落在蛇身上。最简单也最容易出错的写法是“循环随机直到不在蛇身上”,这在蛇身小于半个棋盘时效率很高,但接近满盘时可能死循环。更稳妥的处理是枚举所有空闲格子,再随机挑一个。代码如下:
QPoint Food::generate(const QList<QPoint>& snakeBody, int colCount, int rowCount) { QVector<QPoint> freeCells; freeCells.reserve(colCount * rowCount - snakeBody.size()); for (int x = 0; x < colCount; ++x) { for (int y = 0; y < rowCount; ++y) { QPoint pos(x, y); if (!snakeBody.contains(pos)) { freeCells.append(pos); } } } if (freeCells.isEmpty()) { return QPoint(-1, -1); // 满盘,返回无效坐标触发结束 } int index = QRandomGenerator::global()->bounded(freeCells.size()); return freeCells.at(index); }这段代码的关键是先遍历再随机,而不是随机碰运气。QRandomGenerator::global()->bounded(n)返回[0, n)区间内的整数,是 Qt 5.10 之后的推荐写法,替代已经被废弃的qrand()。当返回值是(-1, -1)时,外部应该判定为“玩家获胜”,而不是继续调用move(),否则蛇头会跑到左上角。这里的reserve只是预分配内存,算是一个小优化,但能避免频繁扩容带来的拷贝开销。
3.3 两种碰撞:撞墙与自撞,判定放在哪一层
贪吃蛇游戏结束条件有二:蛇头越界,或者蛇头撞到自己的身体。越界判定极其直白——head.x() < 0 || head.x() >= COLS || head.y() < 0 || head.y() >= ROWS。自撞判定如果写成“遍历蛇身、逐个与头比较”,要注意一个隐藏缺陷:prepend之后蛇头在索引 0,那么body[0] == body[1]吗?不可能,因为新头的位置要么在前一个头的相邻格,要么在吃到食物时前一个头的位置被顶到索引 1。所以在move()执行完后再检查body().indexOf(body().first(), 1) != -1是安全的:
bool Snake::isSelfCollision() const { return m_body.indexOf(m_body.first(), 1) != -1; }第二个参数1表示从索引 1 开始搜索,避免indexOf直接匹配到自身。把这个检查放在move()内部还是放GameWidget里?建议放Snake内部,由GameWidget在每次移动后调用:
// GameWidget 的 updateGame 方法中被调用的逻辑 bool eaten = snake.move(food.pos()); if (snake.isSelfCollision() || snake.isOutOfBoundary(COLS, ROWS)) { gameOver(); return; } if (eaten) { score += 10; food.setPos(Food::generate(snake.body(), COLS, ROWS)); if (food.pos() == QPoint(-1, -1)) { win(); return; } } update();这里isOutOfBoundary是一个新增的辅助函数,内部对m_body.first()做越界值检查。你可能会疑惑:为什么不把撞墙也封装进Snake?因为越界的范围是地图属性而不是蛇的属性,把行列数传进去会让Snake依赖具体地图尺寸。作为课程设计,代码分层干净是一个稳定的加分项。
4. 游戏主循环:QTimer 驱动、键盘事件与状态机
4.1 定时器驱动的游戏循环
Qt 没有提供专门的GameLoop类,游戏的动态效果来自QTimer。实例化一个QTimer并把它和“更新逻辑”的槽函数连接起来,这是 Qt 版的“无限循环模式”——每次定时器超时,就执行一次移动+碰撞检测+重绘。关键点在于:不要重写一个while(true)循环,因为那会阻塞 Qt 事件循环,导致窗口无响应、界面白屏、键盘按键丢失。把控制权交给事件分发机制,界面才能在游戏运行时正常响应点击和按键。
初始化代码放在GameWidget构造函数里:
timer = new QTimer(this); connect(timer, &QTimer::timeout, this, &GameWidget::onTimeout); startGame(); // 内部会调用 timer->start(150);onTimeout里依次做三件事:推进蛇的移动、处理碰撞结果、触发重绘。这个槽函数是整个游戏的“心脏”。为什么用&QTimer::timeout而不是SIGNAL(timeout())宏?前者编译期检查类型,回调闭包可以是 lambda,且不容易把槽函数名拼错——老式宏写法在重构函数名后经常留下编译通不过或运行期崩溃。
4.2 键盘方向控制与反向拦截
重写keyPressEvent是捕获方向键最简单的路径。注意三个平台差异:Windows 上方向键键值是Qt::Key_Up这一组;macOS 的 Option 组合键干扰不了这组枚举值;但如果做跨平台,event->isAutoRepeat()需要单独处理——按下不放会连续触发keyPressEvent,导致蛇连续转向。
void GameWidget::keyPressEvent(QKeyEvent *event) { if (event->isAutoRepeat()) { QWidget::keyPressEvent(event); return; } switch (event->key()) { case Qt::Key_Up: if (snake.direction() != Snake::Direction::DOWN) snake.setDirection(Snake::Direction::UP); break; case Qt::Key_Down: if (snake.direction() != Snake::Direction::UP) snake.setDirection(Snake::Direction::DOWN); break; case Qt::Key_Left: if (snake.direction() != Snake::Direction::RIGHT) snake.setDirection(Snake::Direction::LEFT); break; case Qt::Key_Right: if (snake.direction() != Snake::Direction::LEFT) snake.setDirection(Snake::Direction::RIGHT); break; case Qt::Key_Space: togglePause(); break; default: QWidget::keyPressEvent(event); } }反向拦截是贪吃蛇最容易翻车的地方:当蛇正向右移动时,玩家按下左箭头,若没有“当前方向为右时禁止改为左”的检查,蛇会在同一帧内头部向右而方向却被改成左,下一帧move()就会把头反向移动——穿体而过或者直接自撞。上面代码用snake.direction()做比较,注意“若当前方向是 DOWN 时才不允许 UP”,反过来也一样。空格键togglePause是额外加的交互,符合 P 键或空格键暂停的玩家认知惯性。
用户提到“qt 槽函数 返回值”这个搜索词在这里有一处强关联:槽函数不能返回值给信号发起的调用方,所以onTimeout不必返回游戏状态,所有状态都通过成员变量传递,打印到statusBar()或窗口标题。如果试图让onTimeout返回布尔值来判断“是否继续”,这在 Qt 的信号槽机制里行不通,会引导出错误的设计。
4.3 状态机:Running / Paused / GameOver / Win
不引入第一个状态变量前,代码会越改越乱——比如暂停时定时器还在跑,停止时又不知道按哪个键重新开始。我一般会用枚举做状态机:
enum class GameState { READY, // 等待开始 RUNNING, // 游戏进行中 PAUSED, // 暂停 GAMEOVER, // 撞墙或自撞 WIN // 满盘胜利 };游戏主循环的推进函数变成:
void GameWidget::onTimeout() { if (state != GameState::RUNNING) return; bool eaten = snake.move(food.pos()); if (snake.isSelfCollision() || snake.isOutOfBoundary(COLS, ROWS)) { setState(GameState::GAMEOVER); return; } if (eaten) { score += 10; food.setPos(Food::generate(snake.body(), COLS, ROWS)); if (food.pos() == QPoint(-1, -1)) { setState(GameState::WIN); return; } } update(); }暂停时定时器不一定需要停止。如果你用timer->stop()来实现暂停,会多出“恢复后重新计时的延时”和“状态恢复顺序”的细节;更简单的方式是像上面代码那样让onTimeout第一行检查state != GameState::RUNNING直接返回,定时器照跑,反正所有操作都被状态门卫挡在门外面。这种写法还有一个好处:调试时可以直接在onTimeout里打断点,而不必关心定时器是否还在激活。
setState内部负责 UI 反馈:
void GameWidget::setState(GameState s) { state = s; switch (s) { case GameState::READY: statusBar()->showMessage(tr("按空格键开始游戏")); break; case GameState::RUNNING: statusBar()->showMessage(tr("得分: %1").arg(score)); break; case GameState::PAUSED: statusBar()->showMessage(tr("游戏暂停,按空格键继续")); break; case GameState::GAMEOVER: statusBar()->showMessage(tr("游戏结束,按 R 重新开始")); break; case GameState::WIN: statusBar()->showMessage(tr("恭喜!你已填满整张地图")); break; } update(); }tr()包裹字符串是 Qt 国际化的习惯。标题里的“源码论文.zip”如果有配套论文,里面一定会提到 Qt 的QTranslator机制,这里用tr()是正确姿势,它会给字符串留出翻译入口。顺带一提,热词里的“qt国际化”就是这么回事:tr()只是第一步,后面还有.ts文件和lrelease发布步骤。
5. 从“能玩”到“能过答辩”:三个可讲的实现细节
踩完上面的坑,游戏已经能玩。但“能玩”和“能讲出东西”之间有一段距离。三个细节建议你实际做在代码里,答辩时面试官问哪一句你都有话说。
5.1 蛇的移动速度动态加速:一个公式的事
需求是:吃到的食物越多,蛇跑得越快。简单实现是 “每吃一份食物,就把定时器间隔减 10ms”,但间隔减到 50ms 以下后性能会撞墙,人眼也反应不过来。常见做法是用一个反比公式控制延迟:interval = max(40, 150 - eatenCount * 4)。每次move()返回 true 时更新timer->setInterval(interval)。这个设计把“难度曲线”具体成数字,比一句“支持加速”更经得起追问。
同样的思路可以放到“分数设计”上:加 10 分可能太单调,按蛇的长度加权,比如score += snakeBodySize() * 2,既能体现“越长越有价值”,又能在状态栏快速反馈玩家操作。如果你想做得更细,可以仿照经典贪吃蛇,把“吃到食物”和“每活过 N 次移动”都计入得分,二者频率相差不多,但带来的正反馈维度不同。
5.2 平滑移动:定时器间隔与 QVariantAnimation 的取舍
很多 Qt 贪吃蛇教程止步于“格子跳变”:每次定时器到点,蛇硬生生地跳到下一格,画面看起来比较生硬。实际上有个轻量方案:绘制时不做“整格对齐”,而是根据两次定时器之间的时间比例绘制一个偏移动画。设lastMoveTime为上一次移动的时刻,now为当前重绘时间,则偏移量t = min(1.0, (now - lastMoveTime) / intervalMs)。绘制蛇头时,用QPointF坐标在两个相邻格子之间做线性插值:
qreal t = ...; // 0.0 到 1.0,表示本帧位于两个格子之间的进度 QPointF drawPos( (prevHead.x() + (currHead.x() - prevHead.x()) * t) * BLOCK_SIZE + OFFSET_X, (prevHead.y() + (currHead.y() - prevHead.y()) * t) * BLOCK_SIZE + OFFSET_Y );注意:绘制用的坐标系统从QPoint(整型)提升为QPointF(浮点),所以paintEvent里蛇身绘制部分的参数类型要一起改。改动范围不超过 20 行,但视觉质感提升一个档次。这个方案比引入QVariantAnimation更适合本项目,因为QVariantAnimation是对象绑定的动画,和游戏循环耦合在一起会增加状态管理的复杂度。
5.3 构造回放:杀死不可复现的 Bug
最后建议在GameWidget里维护一个大小为 600 的QVector<QPoint>历史队列,每一帧记录蛇头坐标,按 Ctrl+T 触发“回放模式”,它会把历史坐标每隔 30ms 重放一遍。这个功能对调试极有价值:自撞的边界情况往往需要连按几个方向键才能复现,没有回放就得手速不够重开一局。
实现也不难:在onTimeout里把蛇头坐标追加进m_history,回放时清空蛇目前的状态,用一个只读定时器去驱动历史坐标序列,播放结束后恢复真实状态。答辩时就可以说:“我做了操作记录与回放,方便复现问题”,这在“设计与实现”型的论文里是一个很扎实的工作量证明。
本文还有配套的精品资源,点击获取