☰
200行Qt贪吃蛇实战:QPainter自绘与QTimer驱动
2026/10/14 14:00:04 网站建设 项目流程

简介:面向 Qt 初学者的完整贪吃蛇项目,演示如何用约 200 行代码搭建一个界面版游戏。项目覆盖 QWidget 主窗口、QGraphicsView/Scene 图形渲染、定时器驱动的蛇移动、键盘方向控制以及食物生成与碰撞检测等核心机制,适合刚接触 Qt 的学生或开发者用作课程设计与入门练手。压缩包共 68 个文件,以 cpp/h 源码、pro/pri 工程配置、png 图片素材和 exe 可执行程序为主,同时包含 Visual Studio 与 Qt Creator 工程文件、编译中间产物及调试记录,整体约 57.11MB;解压后目录结构清晰,源码、资源与配置分离,便于对照阅读和二次修改。目前已有 570 人学习下载,说明其实用性和参考价值得到了不少同阶段学习者认可。通过对源码和可运行程序的分析,可以直观掌握贪吃蛇游戏从界面搭建到逻辑实现的完整流程,包括移动状态更新、分数刷新、开始/暂停/重新开始等交互设计,同时体会模块化封装与 Qt 信号槽通信在小型项目中的实际应用,为后续开发更复杂的 Qt 程序积累经验。

1. 200行Qt贪吃蛇:别被QGraphicsScene吓住

“200行代码写一个简易的Qt界面贪吃蛇”,这句话听起来像标题党,但按下面这套组合做下来,实际代码量就是能压在200行上下:QWidget做窗口、QTimer做心跳、QPainter画画面。不需要QGraphicsScene,不需要设计器拖控件,也不需要单独建UI文件。这套方案的立足点是把游戏逻辑看成一串可变状态,界面只负责把状态画出来。所以它真正解决的是:用最小的代码量,把Qt的事件循环、定时器、键盘输入和自绘机制完整走一遍。

适合刚学完基础信号槽、想动手折腾一个小项目的开发者,也适合做课程设计时不想让代码被模板占满的人。贪吃蛇虽然简单,但“蛇身怎么存、方向怎么防掉头、撞自己怎么判断”这些点,跟做正式一点的小游戏是同一套思路。

下面我按自己复现这个方向时的拆解顺序讲:先定数据结构,再写绘制,最后接定时器与按键,并把踩过的坑单独放一章。

2. 先建模型再碰界面:蛇身、食物和方向的存储设计

很多初学者一上手就开一个.ui文件,先把格子底图画出来,再想着怎么让蛇动起来,最后被布局和信号槽拖住。我一般反过来,先写游戏状态,再考虑画布。贪吃蛇的画面完全由状态推导:蛇身坐标变了,重绘就变了;食物坐标变了,重绘就变了。界面只是状态的一张照片,照片随时可以重拍。

2.1 为什么不用QGraphicsScene:复杂度全在渲染,不在这里

Qt 官方示例和不少教程里,小游戏偏爱 QGraphicsScene,因为它有现成的碰撞检测、可拖拽 Item、动画框架。但这个题目只有一块 20×20 的网格,活动对象最多几十个,用 QGraphicsScene 引入的 Item 管理、场景更新反而成了额外负担。更现实的问题是:当把蛇身拆成多个 GraphicsItem 时,方向、碰撞、生长逻辑都被拆散到 Item 层,排查起来特别绕。自绘 QWidget 的思路是:所有逻辑都写在step()一个函数里,画面只负责根据状态画出来,这种单向依赖最好调试。

所以这里不用 QGraphicsScene,连 QMainWindow 都可以不用。直接继承 QWidget,自己处理 paintEvent 和 keyPressEvent。这样整个程序只有一个类、一个文件,逻辑从头到尾是线性的,新手也能看清每一行在干什么。

2.2 核心数据结构:一个QWidget子类,四个变量

先定义全局的棋盘常量,再给出类定义。这两个常量建议放到代码文件最上方,后面所有函数共用,避免在多个地方出现魔法数字。

#include <QWidget> #include <QTimer> #include <QPainter> #include <QKeyEvent> #include <QPoint> #include <QVector> #include <QRandomGenerator> #include <algorithm> const int kGrid = 20; // 每行/每列的格子数 const int kCell = 20; // 初始每个格子的像素边长 const int kInterval = 100; // 初始定时器间隔,单位ms class SnakeGame : public QWidget { public: explicit SnakeGame(QWidget* parent = nullptr); protected: void paintEvent(QPaintEvent*) override; void keyPressEvent(QKeyEvent*) override; private: QTimer timer_; QVector<QPoint> snake_; // 蛇身,头在 front() QPoint food_; int dx_ = 1; // 当前方向增量,1=向右 int dy_ = 0; // 0=垂直不动,-1=向上 int score_ = 0; bool over_ = false; void reset(); void newFood(); void step(); };

这里没写Q_OBJECT宏,因为我们不需要自定义信号槽,也不使用tr()、property等元对象特性。这样单个 cpp 文件可以直接编译,不需要额外跑 moc,大大降低上手门槛。

snake_用QVector<QPoint>存,front()是头,push_back是尾。方向用坐标增量而不是枚举,省去一大段 switch 映射,也方便判断是否掉头。dx_ = 1表示当前向右,dy_ = 0表示垂直方向没有移动。这个设计看起来简单,但后面所有逻辑都建立在它上面,改起来非常顺手。

2.3 初始化与食物生成:让随机不落在蛇身上

游戏开始和按“重新开始”时,都需要把状态拉回初值。蛇身我习惯放 3 个格子,头在中间偏右的位置,初始方向向右。食物则通过一个看起来有点“暴力”的循环生成:随机取一个单元格,如果落在蛇身上就重新随机一次。20×20 的棋盘上,蛇身不长时命中概率很低;等蛇身占到三分之一以上,循环次数才会明显增加,但那时游戏也快结束了,完全可接受。

void SnakeGame::reset() { snake_.clear(); snake_ << QPoint(10, 10) << QPoint(9, 10) << QPoint(8, 10); dx_ = 1; dy_ = 0; score_ = 0; over_ = false; newFood(); } void SnakeGame::newFood() { while (true) { int x = QRandomGenerator::global()->bounded(kGrid); int y = QRandomGenerator::global()->bounded(kGrid); QPoint p(x, y); if (!snake_.contains(p)) { food_ = p; break; } } }

QRandomGenerator::global()->bounded(kGrid)返回 0 到 kGrid-1 的随机数,正好落在网格下标范围内。如果你用的是 Qt 5 老代码,习惯写qrand() % kGrid,效果一样,但 Qt 6 里建议用 QRandomGenerator,因为它的分布质量更好,调用也更直接。reset()里必须先clear()再追加初始蛇身,防止上一次游戏留下的坐标残留。构造函数里启动定时器并连接 timeout 到step(),这部分放到第 4 章说。

一个值得注意的细节:食物生成时,QVector::contains会逐个比较 QPoint 坐标,蛇身短时开销很小。千万不要为了“优化”去写一个 bool 二维数组,那会让整个类多一个成员,而且在 200 行预算里毫无必要。

3. 用QPainter把网格画出来:drawRect画蛇,drawEllipse画食物

数据模型定下来之后,界面绘制就纯粹是体力活了。paintEvent 里唯一要做的事是:根据snake_和food_的坐标,把对应格子涂上颜色。我习惯先画背景,再画食物,最后画蛇。顺序不是随意定的:背景垫底,食物要露出来,蛇身如果覆盖在食物上,也应该正常盖住,这样层级最直观。

3.1 paintEvent整体布局:先背景,再食物,最后蛇

void SnakeGame::paintEvent(QPaintEvent*) { QPainter p(this); p.fillRect(rect(), QColor(30, 30, 36)); // 根据实际窗口大小动态计算格子像素,避免拉伸错位 const int cell = std::min(width(), height()) / kGrid; // 食物:红色圆形 p.setPen(Qt::NoPen); p.setBrush(QColor(255, 85, 85)); p.drawEllipse(food_.x() * cell + 2, food_.y() * cell + 2, cell - 4, cell - 4); // 蛇:圆角矩形,蛇头颜色更亮 for (int i = 0; i < snake_.size(); ++i) { QRect r(snake_[i].x() * cell + 1, snake_[i].y() * cell + 1, cell - 2, cell - 2); p.setBrush(i == 0 ? QColor(140, 255, 170) : QColor(80, 210, 130)); p.drawRoundedRect(r, 4, 4); } }

这里std::min(width(), height()) / kGrid是关键。窗口的实际客户区和顶层窗口大小之间会有平台差异,如果直接用固定的kCell计算坐标,在部分平台上右侧或底部会被裁掉一条。动态计算虽然只差一两个像素,但能保证任何尺寸下网格都完整落在画布内。

setPen(Qt::NoPen)必须显式设置,否则 QPainter 默认会用黑色画一个 1 像素的边框,把每个色块框起来,在深色背景下特别难看。食物画圆形时,cell - 4相当于在格子四周各留 2 像素边距,视觉上不会顶着边。蛇身用drawRoundedRect,圆角半径 4 像素,比直角方块耐看。蛇头单独用一个浅绿色,玩家不用数格子也能快速定位方向。

3.2 参数选择:网格数、格子像素、窗口尺寸怎么组合

下面这张表是我在模拟项目 X 里实际用过的一组参数,直接照着填就能跑。它们之间是联动关系,改动时要一起调。

参数取值作用备注
kGrid20每行每列的格子数越小地图越简单,越大蛇的活动范围越大
kCell20初始每个格子的像素边长实际绘制用动态 cell,这里只决定窗口初始大小
窗口尺寸400×400初始窗口用setFixedSize(kGrid * kCell, kGrid * kCell)
定时器间隔100蛇每 100ms 移动一格相当于 10 帧/秒,节奏适中

如果你把kGrid改成 30,窗口固定尺寸也要改成 600×600,否则每个格子会被压缩得很小。如果改成kCell=16,界面会更紧凑,但圆角半径 4 像素在部分低分辨率屏幕上会显得像圆点。我建议新手先用 20+20 这套组合跑通,再去调这些值。

窗口尺寸这里有个细节:setFixedSize(kGrid * kCell, kGrid * kCell)设置的是 QWidget 自身的大小,在 Windows 这类带边框和标题栏的系统上,实际客户区会比这个数小一点。所以绘制时一定要用std::min(width(), height()) / kGrid动态计算格子像素,而不是拿kCell硬算。这也是我后来不再纠结 400 还是 396 的原因。

3.3 闪烁处理:QWidget自带双缓冲,别再自己画QPixmap

网上有些老文章会教你建立一个 QPixmap,把所有画面画到 pixmap 上,再一次性drawPixmap到窗口,理由是防止闪烁。这个做法在 Qt 4 时代还有必要,但现代 Qt 的 QWidget 内部已经有 backing store,普通小游戏直接重绘并不会出现肉眼可见的闪烁。

真正需要留意的是setAttribute(Qt::WA_OpaquePaintEvent)。这个属性告诉系统“这个控件完全由自己绘制,不需要预填充背景”。如果你设了它但又忘了在 paintEvent 里fillRect,上一帧的画面会残留在新的画布上,形成类似重影的现象。我们的代码里每帧都先fillRect(rect(), QColor(30, 30, 36)),所以不开这个属性也没问题。遇到闪烁时,优先检查是不是用了 WA_OpaquePaintEvent,而不是一开始就去搭双缓冲。

4. 移动、转向与碰撞:一帧更新三步走

绘制完成后,游戏还不会动。动起来靠的是 QTimer 每隔 100ms 触发一次step(),在step()里完成三件事:计算新蛇头位置、判断是否吃到食物、判断是否死亡。这里要特别强调:移动逻辑必须写在step()里,不能写在paintEvent()里。paintEvent 可能因为窗口移动、遮挡等任意时机被调用,如果在那里改状态,会出现蛇像抽搐一样一次走两步的怪象。

4.1 用QTimer驱动单步:100ms的间隔参数要这样调

构造函数中把定时器连接好:

SnakeGame::SnakeGame(QWidget* parent) : QWidget(parent) { resize(kGrid * kCell, kGrid * kCell); setFocusPolicy(Qt::StrongFocus); // 让窗口能接收键盘事件 connect(&timer_, &QTimer::timeout, this, &SnakeGame::step); timer_.setInterval(kInterval); reset(); }

setFocusPolicy(Qt::StrongFocus)很关键,没有它键盘事件可能只在点击窗口后才有效,很多人因此以为按键失效。connect只调用这一次,之后无论重开多少次游戏都不要再次 connect,否则同一个 timeout 信号会触发多个step(),蛇会肉眼可见地加速。

step()的完整实现:

void SnakeGame::step() { if (over_) return; // 1. 计算新蛇头位置 QPoint head = snake_.front(); head.rx() += dx_; head.ry() += dy_; // 2. 撞墙判断 if (head.x() < 0 || head.x() >= kGrid || head.y() < 0 || head.y() >= kGrid) { over_ = true; update(); return; } // 3. 判断是否吃到食物 bool ate = (head == food_); snake_.prepend(head); if (ate) { ++score_; newFood(); } else { snake_.pop_back(); } // 4. 撞自己判断(从下标1开始找,跳过蛇头自己) if (snake_.indexOf(snake_.front(), 1) != -1) over_ = true; update(); }

这段代码的顺序是不能换的。先算 head,再撞墙,再prepend,然后决定是否pop_back,最后才判断撞自己。这样当蛇头下一步会移动到原本尾巴的位置时,因为尾巴已经被pop_back()移除,不会误判为撞到自己。如果先判断碰撞再移动,蛇在贴着自己尾巴走的时候会莫名死亡。

每次step()做完所有状态修改后,调用update()。update()会异步触发一次 paintEvent,不要直接调repaint(),那样会强制立刻重绘,在事件循环紧张的瞬间造成不必要的开销。

4.2 方向输入:四个方向的判断与防掉头

键盘输入是另一个常见翻车点。防掉头不是“当前在水平方向就不能按上下”,而是“不能按与当前方向完全相反的方向”。我见过很多新手这么写:if (dx_ != 0) break;然后允许按上,结果蛇水平向右时按“下”也被放行了,这是合法的转弯,但如果你同时拦住了左,问题就变成“向右时按上会直接掉头”还是“向←时按上”?所以正确写法是只禁反向:

void SnakeGame::keyPressEvent(QKeyEvent* e) { switch (e->key()) { case Qt::Key_Up: if (dy_ == 1) break; // 当前正在向下,禁反向 dx_ = 0; dy_ = -1; break; case Qt::Key_Down: if (dy_ == -1) break; // 当前正在向上,禁反向 dx_ = 0; dy_ = 1; break; case Qt::Key_Left: if (dx_ == 1) break; // 当前正在向右,禁反向 dx_ = -1; dy_ = 0; break; case Qt::Key_Right: if (dx_ == -1) break; // 当前正在向左,禁反向 dx_ = 1; dy_ = 0; break; default: QWidget::keyPressEvent(e); return; } }

注意这里判断的是“当前方向的反向”,而不是“当前方向本身”。例如当前向右,dx_ = 1,按左时dx_ == 1成立,说明玩家想直接掉头,于是拦下。按上时dy_ == 1不成立,说明不是反向,允许转上。这个逻辑只要写错一条,就会出现“按上却向下走”的玄学问题。

另外一个细节:方向变化是立即生效的,不是等下一帧。如果你在step()内部读dx_/dy_,按键事件发生在两次 step 之间,那么下一次 step 就会按新方向走,这符合直觉。不要为了“平滑”而缓冲方向,那样反而会让操控有延迟感。

4.3 吃食物与碰撞检测:prepend和pop_back的顺序不能换

前面 step 的代码已经体现了顺序。这里再单独把吃食物这条线拎出来说说。很多实现会这样写:

if (head == food_) { snake_.prepend(head); newFood(); } else { snake_.prepend(head); snake_.pop_back(); }

也能跑,但多了一个分支,代码量不紧凑。更简洁的方式是先prepend,再根据ate决定是否pop_back。顺序一旦反过来,比如先pop_back()再prepend(head),吃到食物时蛇身长度仍然会加 1,但尾巴被错误地截掉了,视觉上就是“食物消失但蛇没变长”。

碰撞检测里还有一个细节:撞自己的判断要放在pop_back()之后。如果蛇头即将移动到尾巴当前的位置,而尾巴本来就要在下一步移走,这时候不应该算碰撞。我们的顺序天然规避了这个问题。如果你想写“撞尾巴也算死”的严格模式,可以把碰撞判断挪到pop_back()之前,但那是另一套玩法,别让初版代码混进这种边界规则。

5. 避坑:贪吃蛇最容易翻车的5个现场与排查方案

这里把我在调试过程中遇到最多的问题列出来。每一条都是先讲现象,再讲原因,最后给解决。新手照着对照自己的代码,能省掉大量瞎猜时间。

5.1 按键只在第一次生效:焦点没拿到

现象:窗口刚弹出来时按方向键完全没反应,用鼠标点一下窗口之后再按就正常了。

原因:QWidget 默认不接受键盘焦点。按键事件只会发给当前聚焦的 widget,而 QWidget 没有设置焦点策略时不会主动获取焦点。很多例子里只 show 了窗口,没有指定焦点策略。

解决:构造函数里加上setFocusPolicy(Qt::StrongFocus);,并且在show()之后调用setFocus()。如果游戏内部还有子控件,比如把画布放到一个 QFrame 里,那要确保是画布拿到焦点,而不是外层的 QWidget。最简单还是让整个游戏窗口就只有一个 widget,不给焦点四处跑的机会。

5.2 按下“上”直接掉头往回走:方向判断写反了

现象:蛇正向右走,按“上”后蛇头却掉头向左,瞬间撞死。

原因:键盘分支里判断反向时写成了if (dx_ != 0)或其他变形。比如当前向右dx_=1,按“上”时你判断if (dx_ != 0) break;,这个条件成立直接 break,但实际应该放行的是“非反向”按键。更常见的是写成这样:

case Qt::Key_Up: if (dy_ == 0) break; // 错误:当前水平移动时 dy_=0,按上被拦 dx_ = 0; dy_ = -1;

结果就是水平移动时上下键完全失效,等垂直移动时按左右又把方向打乱。解决:按上只检查dy_ == 1,按下只检查dy_ == -1,按左只检查dx_ == 1,按右只检查dx_ == -1。逻辑想不通就画张坐标图,把四个方向的增量标出来,一眼就明白。

5.3 蛇越跑越快:定时器被反复start

现象:游戏开始是正常速度,玩到中途突然加速,像按了快进。

原因:最常见是connect被写进了reset()或构造函数之外会被重复调用的函数。每调用一次connect,就多一个 timeout 到 step 的连接,一个信号触发多个 step,蛇天然变快。另一种情况是误以为timer_.start()会“加速”,在吃到食物时反复调用start(),但 start 只是启动或重启计时器,不会改变 interval。

解决:connect只在构造函数执行一次。加速逻辑用timer_.setInterval()修改间隔,不要反复 start。如果发现代码里有循环start,先把那个循环拆掉。实在不行在 start 前先timer_.stop(),但治标不治本。

5.4 蛇吃到了食物但长度不变:逻辑顺序错了

现象:食物被蛇头碰上后消失,分数也加了,但蛇身长度没有增加,甚至有时候蛇尾突然少一截。

原因:在prepend之前就做了pop_back,或者吃食物分支里还执行了pop_back。典型的错误写法:

snake_.prepend(head); if (ate) { newFood(); } snake_.pop_back(); // 不管吃没吃都弹尾巴

解决:把pop_back()放到else分支里,或者像第 4 章那样用bool ate控制。这个 bug 不会导致崩溃,但视觉上非常明显,而且是新手最容易犯的顺序问题。调试时可以专门在吃食物的瞬间打印snake_.size(),看是否从 3 变成 4,一目了然。

5.5 窗口被拖拽后网格错位:没有锁定尺寸

现象:窗口边缘可以拉伸,拉大后蛇身和食物跟网格线对不上,右侧留白或者画布被裁掉。

原因:绘制时用了固定kCell = 20计算坐标,但窗口实际尺寸变了,20×20 的网格像素和窗口不匹配。另一个原因是顶层窗口的边框和标题栏占用了尺寸,固定 400×400 的窗口客户区并不是 400。

解决:两种方案任选。第一种是setFixedSize(kGrid * kCell, kGrid * kCell);,禁止拉伸,适合纯展示。第二种是在 paintEvent 里动态计算cell = std::min(width(), height()) / kGrid;,所有绘制都用这个 cell,而不是固定 kCell。我推荐第二种,因为它同时兼容窗口管理器差异,也不用担心不同系统的边框宽度。如果用了第二种还错位,检查一下是不是有地方把width()和height()写反了。

6. 从200行到300行:计分、加速、暂停与重开

跑通基础版之后,很多人会忍不住往里加功能。这里我给几条最小化的扩展方向,每一段都不超过几行,不会把代码撑爆。

6.1 计分:不用QLabel,直接写进窗口标题

最省事的计分方式是把分数更新到窗口标题里。在step()吃到食物时,加一行:

setWindowTitle(QStringLiteral("贪吃蛇 分数:%1").arg(score_));

这样不需要额外布局,也不会遮挡游戏画面。如果你想要分数在画布内显示,也可以直接在 paintEvent 里用p.drawText(QPoint(5, 5), ...),但 0 行是网格坐标,文字会和格子重叠,反而不如标题栏整洁。

6.2 加速:缩短定时器间隔要有下限

吃食物后让蛇稍微快一点,能显著增加可玩性。常见做法是每次食物把间隔减 5,但不设下限。

void SnakeGame::speedUp() { int interval = timer_.interval(); if (interval > 50) timer_.setInterval(interval - 5); }

下限 50ms 对应 20 帧每秒,再快就接近纯视觉极限,键盘输入会开始有“吞按键”的感觉。加速操作放在step()中吃到食物的分支里,调用speedUp()。

6.3 暂停和重开:空格与R键的最小实现

在keyPressEvent里加两个分支:

case Qt::Key_Space: if (timer_.isActive()) timer_.stop(); else timer_.start(); break; case Qt::Key_R: reset(); timer_.start(); break;

空格暂停的依据是定时器当前状态,不需要额外布尔变量。R 重开时先reset()再start(),因为reset()只是把状态拉回初值,并不会自动重启定时器。注意如果游戏已经over_ = true,step 里会直接 return,所以按 R 后必须显式 start,否则蛇永远不动。

我的一个习惯是:每次加功能后,跑十局观察手感。如果按方向键有“卡一下”的感觉,优先检查是不是在 paintEvent 里做了耗时操作;如果快速连按方向键偶尔会掉头,那说明你某个分支的反向判断漏了一条。这些小功能并不难,难的是保持状态修改都在step()一个入口里,别让按键直接改蛇身坐标,否则重绘时你会看到蛇瞬间瞬移,那才是真让人头大。希望这份经验帮到你,动手写一个出来,比光看文章有效十倍。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询