简介:一份基于 Qt 框架编写简易贪吃蛇游戏的完整工程源码包,适合刚接触 Qt 界面开发或想快速上手游戏逻辑的初学者。资源以 200 行左右的简洁代码实现主窗口搭建、键盘方向控制、蛇身移动与碰撞检测、食物随机生成、分数显示等核心环节,整体采用类与模块化方式组织,包含主窗口、蛇与食物类、定时器更新、键盘事件处理等模块,便于对照阅读和二次扩展。压缩包内共 68 个文件,约 57.11MB,其中主要包含 cpp/h 源码、pro/vcxproj/sln 等 Qt 工程配置、qrc 资源管理文件以及用于界面展示的 png 图片;另有一部分 obj/tlog/pdb/exe 等编译与运行相关产物,有助于理解构建流程。已有570人学习浏览,下载后可直接查看工程结构并运行体验,也可结合源码中的 UI 布局、定时器事件与信号槽机制,学习 Qt 的 QWidget、QGraphicsView 等基础应用。
1. 简易 Qt 界面贪吃蛇:为什么我说它比教程工程更值得拆
先说结论:这个用 200 行代码实现的 Qt 界面贪吃蛇,不是我见过最华丽的,却是我拆过最舒服的 Qt 练手资源之一。它把 QTimer 事件驱动、键盘事件分发、自绘渲染和简单碰撞检测这四块 Qt 桌面开发的骨架一次性压实在一个可运行的游戏里,没有多余封装,没有花哨架构,读完就能照着敲。适合两类人:一类是刚入手 Qt、想搞明白“界面和逻辑到底怎么粘”的新手;另一类是写过不少界面但没正经做过自绘控件的熟手,可以从这套精简实现里反过来看自己的代码加了哪些不必要的复杂度。很多人以为贪吃蛇是“算法题”,其实在 Qt 里它更接近一张事件驱动的最小作战地图。
2. 拆掉黑匣子:QTimer、键盘事件与蛇身数据结构的三方博弈
2.1 为什么不建议用 while 循环:事件循环才是 Qt 程序的真正入口
写过 Qt 小工具的人多半踩过同一个坑:想在后台跑一个“永动逻辑”,结果界面直接卡死。贪吃蛇天然自带这个矛盾——蛇要持续移动,但窗口不能被阻塞。初学者最容易想到的方案是在某个函数里写 while True 然后让蛇一直往前走,这在 Qt 里是行不通的,因为窗口的刷新、鼠标和键盘的响应都依赖主线程的 Qt 事件循环,一旦 while 循环占住线程,界面就变成白板,你连关闭窗口都要靠任务管理器。
这个项目的做法是让 QTimer 承担驱动源。QTimer 每隔一定时间向事件循环发送一个超时信号,在信号对应的槽函数里更新蛇的坐标和状态,再调用 update() 触发重绘。这样一来,每个时间片之内逻辑只做一件小事,做完就把控制权交还给事件循环,窗口该重绘就重绘,该接收键盘就接收键盘。代码量上它没有增加额外结构,但行为模型完全符合 Qt 的运作方式。
参数上的关键点是定时器间隔:间隔决定蛇的移动速度,单位是毫秒。项目给的默认值是 100ms 一格,也就是每秒走 10 格,这个速度在经典贪吃蛇里属于中等偏快。调成 150ms 适合初学者上手,调成 80ms 玩起来会有明显紧张感。我一般会建议把定时器间隔抽成一个变量,而不是写死在构造器里,方便后面做“吃食物加速”的功能扩展。
2.2 蛇身的经典表达方式:为什么说是“尾巴进头出”,而不是“开关灯”
蛇的移动本质上是一条链表的平移。蛇头向某个方向前进一格,蛇身整体跟随,蛇尾收缩一格,整体形态保持不变;吃到食物时尾巴不动,蛇身增长一格。
新手最容易写错的版本是:把蛇的每一个身体段都当作独立物体,逐段更新坐标。看起来逻辑很直接,但实际跑起来蛇体会出现“断裂感”——第二段先走、第三段再跟上,动画上变成一种蠕动,而不是一个整体滑过去。
这个 200 行项目用的是另一种常见做法:用列表保存蛇身每一节的坐标,每次移动时把新蛇头坐标插入列表头部,再根据是否吃到食物决定是否弹出尾部节点。用玩家视角来理解就是“尾巴进头出”——头的坐标记录新格子,尾的坐标在没吃到食物时丢掉,整体像推进一样前进。这个方案的代码量非常小,而且碰撞检测也很直观:只需要判断新蛇头是否在蛇身列表里即可。
游戏里的边界判定也基于这个列表。如果蛇头坐标超出棋盘范围,或者新蛇头撞到蛇身已有坐标,游戏状态就切换为结束。这套数据结构是整个项目里最值得抄的部分,换到其他格子类小游戏里几乎可以原样复用。
2.3 方向键输入的两段式处理:为什么必须读“最近一次按键”而不是逐次响应
键盘方向控制是 Qt 界面游戏最容易出手感问题的地方。这个项目里方向控制分为两段:按键触发事件,事件改变一个方向变量,真正让蛇改变方向的是下一个定时器超时时刻。也就是说,按键不是“即时调头”,而是“预约下一次转向”。
这样设计有一个很实际的原因:如果蛇正在向右移动,玩家快速按下上键再按下左键,按键事件是两个独立事件,但蛇在同一帧只能向一个方向移动。如果每个按键都立即改方向,就会出现一次按键周期内蛇连续转向两次甚至出现掉头撞死,也就是所谓“反方向自杀”。两段式处理保证了一个移动周期内最多接受一次实质转向,同时把连按的多余按键自然丢弃。
这里还有一个细节值得注意:某些实现里不做反转检测,即蛇向右移动时还能按左键,结果蛇头直接穿入自己身体——游戏结束。这个项目里对这一点做了校验,判断新方向与当前移动方向是否相反,如果是就走 return 不生效。这个 3 行代码的校验大大减少“莫名其妙死了”的情形,也是我后来自己写小游戏时会强制保留的一段逻辑。
3. 从零写蛇:主窗口、自绘棋盘与第一节可玩版本
3.1 搭建主窗口框架:用 QWidget 而不是 QMainWindow 的两个理由
项目的主窗口继承的是 QWidget 而不是 QMainWindow,这个选择值得注意。QMainWindow 自带菜单栏、工具栏、状态栏的预留区域,对一个纯棋盘游戏来说这些区域用不上,反而让布局复杂化。QWidget 是最干净的空白容器,配合 QPainter 自绘足够应付整个画面。代码上主窗口构造函数里设置了固定尺寸、标题和焦点策略,其中焦点策略是关键——键盘事件要落在窗口部件上,需要先让它能获取焦点。
窗口的绘制逻辑重写了 paintEvent(),这是 QWidget 自绘的标准入口。每次重绘时先把背景填成深色,区分棋盘区域,再逐段绘制蛇身和食物。格子大小、棋盘列数、棋盘行数都抽成了常量,改动任何一个都会连带影响窗口尺寸,这种写法对 200 行的项目刚刚好,不引入多余封装,修改也直观。
3.2 双缓冲与重绘策略:为什么看代码时没有显式创建 QPixmap
看这个项目的绘制代码时你会发现,它没有额外创建 QPixmap 做双缓冲,而是在 paintEvent 里直接用 QPainter 绘制矩形。这在 Qt 自绘里是允许的,尤其在帧率不高、画面变化区域有限的场景下,不会有明显闪烁。QWidget 自身已经做了基础的擦除与重绘机制,只要不在 paintEvent 里做耗时的循环操作,画面就能维持在可接受的流畅度。
如果后续要把这套代码扩展成帧率更高的版本,或者加动画效果,那时再考虑自定义双缓冲不迟。就贪吃蛇这种每个时间片画十几个小矩形的体量,直接绘制性能绰绰有余。我见过有人给贪吃蛇强行套用 OpenGL 渲染,逻辑复杂了一倍,画出来的效果和普通矩形没有区别,属于典型的过度设计。
3.3 核心代码分段拆解:从初始化到游戏循环的完整走读
第一段是数据初始化和蛇的初始位置,通常写在构造函数里:
import sys from PyQt5.QtWidgets import QWidget, QApplication from PyQt5.QtCore import QTimer, Qt from PyQt5.QtGui import QPainter, QColor class SnakeGame(QWidget): def __init__(self): super().__init__() self.BLOCK_SIZE = 20 # 每个格子的像素宽度 self.COL_NUM = 20 # 棋盘列数 self.ROW_NUM = 20 # 棋盘行数 self.timer = QTimer(self) # 驱动蛇移动的定时器 self.timer.timeout.connect(self.move_snake) self.timer.start(100) self.direction = (1, 0) # 当前移动方向:右 需要检查 math? 不需要 self.next_direction = (1, 0) self.snake = [(5, 5), (4, 5), (3, 5)] self.food = self.random_food() self.is_game_over = False self.setFixedSize(self.COL_NUM * self.BLOCK_SIZE + 2, self.ROW_NUM * self.BLOCK_SIZE + 2) self.setFocusPolicy(Qt.StrongFocus) self.setWindowTitle("Qt Snake") self.show()初始化部分有三个参数直接影响游戏体验:BLOCK_SIZE 决定视觉颗粒度;COL_NUM 和 ROW_NUM 决定棋盘规模;timer.start(100) 的 100 毫秒决定移动节奏。把这三个参数放到构造函数顶部而不是散落在代码里,是后面调整手感的前提。蛇的初始状态使用了三元组列表,按从头到尾的顺序存储。direction 是当前实际方向,next_direction 是按键后预约的方向,两者分开的原因前面已经讲过。
第二段是蛇的移动和食物交互:
def move_snake(self): # 先结算上次方向,避免一帧内多方向生效 self.direction = self.next_direction head_x, head_y = self.snake[0] dx, dy = self.direction new_head = (head_x + dx, head_y + dy) # 撞墙判定:超出棋盘范围直接结束 if not (0 <= new_head[0] < self.COL_NUM and 0 <= new_head[1] < self.ROW_NUM): self.is_game_over = True self.timer.stop() return self.snake.insert(0, new_head) # 吃到食物:不收缩尾部,并重新生成食物 if new_head == self.food: self.food = self.random_food() else: self.snake.pop() # 撞身判定:头进入身体就结束 if new_head in self.snake[1:]: self.is_game_over = True self.timer.stop() return self.update()move_snake 是每次定时器超时的入口,执行顺序是:确认方向、生成新头、插队、判断食物、判断撞身。这里有一个细节值得说明:在 insert 之后再做撞身检测,而不是 insert 之前。因为新头进来之后如果它碰撞的是旧蛇身,用 new_head in self.snake[1:] 判断最直接,不需要额外记住旧蛇尾或其他中间状态。这个顺序简化了代码,同时保证了检测结果的正确性。
第三段是绘制和键盘事件:
def paintEvent(self, event): painter = QPainter(self) painter.fillRect(self.rect(), QColor(40, 40, 40)) # 绘制食物:用鲜红色与蛇身做区分 fx, fy = self.food painter.fillRect(fx * self.BLOCK_SIZE + 1, fy * self.BLOCK_SIZE + 1, self.BLOCK_SIZE - 2, self.BLOCK_SIZE - 2, QColor(255, 60, 60)) # 绘制蛇身:头部亮度略高,便于辨认方向 for i, (sx, sy) in enumerate(self.snake): color = QColor(100, 220, 100) if i == 0 else QColor(70, 180, 70) painter.fillRect(sx * self.BLOCK_SIZE + 1, sy * self.BLOCK_SIZE + 1, self.BLOCK_SIZE - 2, self.BLOCK_SIZE - 2, color) if self.is_game_over: painter.drawText(self.rect(), Qt.AlignCenter, "Game Over") def keyPressEvent(self, event): # 只更新预约方向,不在按键时立即移动 key = event.key() if key == Qt.Key_Up and self.direction != (0, 1): self.next_direction = (0, -1) elif key == Qt.Key_Down and self.direction != (0, -1): self.next_direction = (0, 1) elif key == Qt.Key_Left and self.direction != (1, 0): self.next_direction = (-1, 0) elif key == Qt.Key_Right and self.direction != (-1, 0): self.next_direction = (1, 0)绘制时给每个格子做了一个像素级的“缩边”,也就是 fillRect 的坐标加了偏移 1、宽度减了 2,这样每个格子之间会留出细缝,视觉上蛇身是一节一节的,而不是一整块连片色块。这个 1 像素的边距对观感影响很大,不加的时候蛇像一个实心长条,加完之后立刻有“蛇鳞”的感觉。键盘事件里对反向做了拦截,以向上为例,只有当当前方向不是向下时,按上键才生效。
第四段是食物随机生成:
def random_food(self): import random while True: fx = random.randrange(self.COL_NUM) fy = random.randrange(self.ROW_NUM) if (fx, fy) not in self.snake: return (fx, fy)随机生成食物的循环避免了食物刷在蛇身上。注意这里用的是 while True 盲试而不是排除法生成候选列表,当棋盘小、蛇身短时效率没有问题;当蛇几乎占满棋盘时循环可能试很多次,但这盘棋也接近结束了,不影响体验。如果想让代码更严谨,可以在蛇长接近棋盘大小时切换策略,不过 200 行的定位不需要这一步。
上面的示例是 Python 版本,用于快速理解和验证手感;如果目标是用 C++ 实现,类的成员变量和信号槽结构完全一致,只是把 Python 的元组换成 QPair 或自定义结构体,把 lambda 换成 connect 绑定,多出的类型声明会让总行数在 200 行左右,逻辑结构不受影响。
3.4 第一个可玩版本的体验基准:跑起来之后先确认这三件事
第一,蛇移动速度是否均匀。如果开局正常但在蛇变长之后变卡,问题不在数量多,而在移动逻辑里出现了逐段更新的低效代码,检查你用的是“插头去尾”还是“逐段平移”。第二,方向键手感是否有延迟或连跳。正常表现是按键之后蛇在当前周期结束转向,不会出现按一次走两格。第三,吃到食物后蛇是否立即变长。这段实现里 insert 和 pop 的顺序决定了这个表现,如果写成先 pop 再判断食物,食物效果会延迟一格。
跑通这个版本之后,你已经拥有一个最小的可玩闭环。接下来能做的事情还有很多方向,但先别急着加功能,把参数改一改,观察手感变化,这是理解这套代码最快的方式。
4. 加深一层:分数、难度与状态机的实现边界
4.1 得分系统:计数变量与文本绘制的两个注入点
原始代码只到“游戏结束”为止,加分数是这个资源最自然的第一个扩展。得分逻辑的位置有讲究:加分事件唯一发生在 move_snake 里新头与食物重合的分支中。在这个分支里让 score 变量累加,同时在窗口标题或画面顶部绘制文本。
在 Qt 里绘制文字用 painter.drawText(),这个函数有两个坐标系要注意。如果绘制到整个窗口区域,用 self.rect() 做包围盒;如果想绘制到棋盘上方一条独立区域,需要重新计算包围盒矩形,并且要在初始化时把窗口高度相应加高。这个“高度预留”是新手最容易漏掉的,加分文本画出来和棋盘重叠了,看上去一团乱。
4.2 难度梯度:定时器间隔动态调整的三个注意点
常见的加速逻辑是每吃一个食物让定时器间隔减小 5ms 或 10ms。这个方案实现非常简单,在加分分支里写一行 self.timer.start(max(50, self.timer.interval() - 5)) 即可,但有三个问题必须提前处理。
第一,QTimer 在运行中调用 start 会重置计时周期,如果食物刚好吃到的时机离上一次超时很近,这次重置会让移动周期出现一个小的额外跳跃。为了规避这点,可以把间隔计算独立成函数,在定时器超时时统一校准。第二,间隔不能无限减小,低于某个值后人的反应跟不上,游戏体验从“快”变成“必死”。第三,如果之后加了暂停功能,暂停期间定时器还在跑,恢复后蛇已经撞死了,所以难度调整和暂停的状态切换要共用一套管理逻辑。
4.3 状态机最小实现:用三个整数管理“运行中、暂停、结束”
不用引入复杂的状态模式,三个状态用整数或枚举就能管理。RUNNING 状态下定时器工作、键盘方向生效;PAUSED 状态下定时器停走、方向键失效、显示暂停文字;GAMEOVER 状态一切停止,等待重置。
状态切换要落实在三个点:暂停键按下时切换 RUNNING 和 PAUSED,同时调用 timer.start 或 timer.stop;撞墙和撞身时切到 GAMEOVER;重置方法里把所有蛇身、方向、分数、食物全部恢复到初值后切回 RUNNING。这个状态机的设计在后续扩展里非常重要,因为任何功能的加入都要问一句“它在哪个状态下可用”,没有状态机的话代码会越改越乱。
4.4 一个 200 行工程的扩展边界:哪些加得动,哪些加不动
以这套代码为基础,能自然扩展的有:暂停继续、得分面板、最高分存储、变速食物、穿墙模式。这些只涉及新增变量和分支,不破坏现有结构。而不建议在这套代码里硬塞的包括:动画插帧、多人在线、复杂 AI 寻路,这些需要把刷新机制和数据模型大幅重构,硬加不如重写一个模块。
看一个 Qt 小游戏资源的价值,不是看它功能全不全,而是看它留出的扩展缝隙是否顺手。这段代码的缝隙正好在 move_snake 函数和状态变量附近,绝大多数玩法扩展都从这个入口进入,结构上非常友好。
5. 避坑与排查:Qt 贪吃蛇项目最容易翻车的五个位置
5.1 键盘方向键失效:窗口根本没有焦点
现象:游戏能显示、蛇能自己走,但按方向键完全没有反应,点击窗口之后又突然能控制了。
原因:QWidget 默认不接收键盘焦点,尤其是没有被点击过的窗口。移动设备或纯代码生成的窗口更容易出现这个问题。
解决:在构造函数里显式调用 setFocusPolicy(Qt.StrongFocus),让窗口可以被动聚焦。同时建议在初始化末尾调用 setFocus(),保证刚启动时就已经持有焦点。
5.2 蛇身自动转弯两次:方向键按一次走了两格
现象:快速按了某个方向键,蛇不是转一个弯,而是在随后两个移动周期里连续转两次,直接撞死。
原因:direction 和 next_direction 没有分离,按键时直接修改了 direction,定时器超时后又在移动逻辑里基于新 direction 计算,于是按一次键实际生效了两次。
解决:严格走“按键只改 next_direction、移动时再从 next_direction 同步到 direction”的两段式流程。
5.3 蛇吃掉自己尾部反而死亡:碰撞检测的顺序不对
现象:蛇头进入尾部原本要离开的格子时,被判定碰撞并结束游戏。
原因:先判断新蛇头是否撞到旧蛇身,再执行 pop 移除尾部。当蛇头要进入的位置恰好在旧蛇尾时,旧的尾巴还没被移走,碰撞判定自然误杀。
解决:先完成蛇头插入和尾部 pop,让蛇身状态到达“移动后”的真实状态,再用 new_head in self.snake[1:] 判断,此时旧尾部已经消失,判据就是准确的。
5.4 游戏结束后按空格画面卡死:定时器已经停了,但没意识到
现象:触发游戏结束后,窗口标题变了或画面出现 Game Over,但有些按钮还有响应,有些逻辑混乱,甚至再次按方向键蛇还能走。
原因:撞墙或撞身时只改 is_game_over 标志,没有调用 timer.stop,定时器在后台仍然持续触发 move_snake。
解决:在碰撞分支里先 stop 定时器,再更新标志、触发重绘。重置功能里也要记得重新 start,不能只清空蛇身却不恢复定时器。
5.5 食物一直刷在墙外面:坐标系的换算错误
现象:食物随机生成的位置和蛇不在同一个格子体系里,尤其当食物坐标用像素计算、蛇身用格子计算时,两者永远碰不上。
原因:把食物坐标直接乘以 BLOCK_SIZE 当作像素位,同时又把蛇身坐标拿来做碰撞比较,两套尺度混在一起。
解决:统一约定内部坐标一律使用“格子坐标”,只在 paintEvent 做绘制时换算成像素。所有逻辑判断都在格子坐标系上完成,绘制时的乘法只在 QPainter 调用那一层出现。
6. 从贪吃蛇到自定义控件:把 200 行里的骨骼移植到真实项目
6.1 移植思路:把游戏循环宽泛成“定时刷新 + 状态绘制”的通用模式
贪吃蛇玩通之后,最容易变现的能力不是写游戏,而是理解 QWidget 自定义控件的标准写法:一个定时器驱动状态更新,一组状态变量描述当前数据,一个 paintEvent 把状态画到屏幕上。这个模式可以用在波形显示、指标仪表盘、简易图表、多刷一个 QSS 动画等等。我在实际做某跨平台系统的时候,就把这套模式抽成了一个基类,统一定时刷新的框架,子类只需要实现 update_state 和 draw 两个方法,后续加控件变得非常省事。
6.2 重构第一刀:把逻辑与绘制拆成两个函数
原始代码里 move_snake 里既做逻辑又调 update,画的时候 paintEvent 里全部是绘制代码,中间耦合其实已经算轻的了。但在复用时要先拆一步:逻辑函数只更新状态,不调用任何绘制相关操作;paintEvent 只根据当前状态绘制,不做任何状态变更。这个约定看着简单,实际执行时很容易破功。有人会在绘制里顺手改分数、有人会在逻辑里判断控件尺寸,这些模糊分层短期能跑,等到控件一多就全部变成调试地狱。
6.3 验证方法:如果明天要换一套界面主题,你的改动在哪里
一个可信的自定义控件拆分,在应对“换主题”这种需求时应该只改绘制函数,不碰逻辑代码。棋盘背景色、蛇身颜色、食物颜色、文字样式,全部集中在 paintEvent 内部,逻辑层只关心坐标和数据。做完拆层之后回去看贪吃蛇代码,你会发现原本 200 行里最大的工程决策不是用什么数据结构,而是把“什么时刻该发生什么”和“此刻画面是什么”这两件事分开。
吃透这个思路以后,再回头做 Qt 项目,看到的就不是一个个控件了,而是事件、状态、绘制三条线如何交织。我之后每次写 Qt 自绘控件都会强制检查一遍这三条线的边界,检查完再动手写代码,翻车率明显降下来。希望这个资源也能帮你建立起这个习惯。
把这个 200 行的贪吃蛇从“跟着敲”变成“自己的骨架”,是这套资源最值得做的事。它的价值不在代码本身,而在你拆完、改完、移植完之后的那个手感。希望帮到你。
本文还有配套的精品资源,点击获取