我以前带过一个刚学Python的朋友,他语法书翻了两个月,循环、函数、列表都认识,但总觉得没写出过真正的程序。我让他别去跟风做爬虫、别一上来就碰数据分析,先花一个晚上把贪吃蛇写出来。第二天他跟我说,写完这个游戏之后,之前死活想不明白的while循环、类、字典、坐标模型,突然全通了。
这件事其实不奇怪。Python贪吃蛇游戏看起来简单,但它把入门阶段几乎所有关键知识点串在了一条线上:游戏循环、事件处理、状态管理、碰撞检测、坐标计算、随机生成、渲染绘制。哪怕你以后要去做Web开发、做数据分析、做自动化脚本,这套"循环加事件"的心智模型都是通用的。这篇文章我会从零开始拆解这个项目,给出完整可运行的代码,讲清楚每一个关键选择背后的理由,再把实战中踩过的坑和排查思路一并分享。适合刚学完Python语法但没做过完整项目的读者,也适合想用pygame做点图形界面试试水的人。
1. 为什么贪吃蛇是Python入门的试金石
1.1 一个游戏串起Python核心语法
很多人学Python时会陷入一个怪圈:语法都看懂了,一动手就不知道从哪写起。原因很简单,零散的知识点没有载体。贪吃蛇游戏刚好是最佳载体之一,它麻雀虽小,五脏俱全。
先看它覆盖的知识点:游戏的主循环需要while True,蛇身需要列表存储,每个坐标是一个元组,移动方向需要字典或元组来映射,吃到食物后蛇身变长涉及列表的insert和pop,边界判断和自身碰撞判断是条件表达式,随机生成食物是random模块,画蛇、画食物、画分数需要调用pygame的绘图API。这些不是你分别去背的知识点,而是围绕"让一条蛇在一个窗口里动起来"这个目标自然长出来的。
更重要的一点是,贪吃蛇的代码量非常适中。完整实现大概一百多行,不会像大项目那样让人望而生畏,又比"打印九九乘法表"这种练习有真实感。写完之后你会获得一种完整的心智体验:一个程序从启动到运行、从输入到输出、从逻辑到界面,每一步都在你的掌控之中。这种掌控感对建立编程信心特别重要。
1.2 技术选型:pygame、curses、tkinter怎么选
实现贪吃蛇的图形方案有不少,最常用的三个是pygame、curses和tkinter。我在实际操作中的建议是:优先选pygame。
用curses可以在终端里跑贪吃蛇,好处是不用装任何图形库,但对新手特别不友好。curses的坐标系统和刷新机制跟普通直觉差别很大,而且终端里的字符画面对齐问题很烦人,调试时经常踩到莫名其妙的坑。tkinter是Python自带的GUI库,画一个贪吃蛇也完全可行,但它的主循环机制和游戏循环不完全一致,直接拿来做实时游戏需要额外处理,反而不如pygame顺滑。
pygame是专门为游戏开发的库,里面已经封装好了最为关键的三件套:事件队列、时钟对象、绘图表面。事件队列用来接收键盘输入,时钟对象用来控制帧率,绘图表面用来绘制画面。这三个东西恰好对应游戏开发最核心的"输入-更新-绘制"循环,逻辑链路非常清晰。代码结构上你会自然地写出一个类来表示游戏,一个类表示蛇,一个类表示食物,类和对象那一章的知识也能在这里落地。
安装pygame的方式也很直接,在你的终端或VSCode集成终端里执行:
bash复制pip install pygame
如果你用的是虚拟环境,记得先激活虚拟环境再装。如果你还没配置Python环境,这里也顺带提一句:VSCode里装好Python扩展之后,在命令面板里选择解释器,然后在终端里运行python脚本就行,不需要额外配什么复杂的东西。只要import pygame不报ModuleNotFoundError,环境就算通了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从游戏循环到状态机:先搭骨骼
2.1 主循环的三个固定动作:事件、更新、绘制
pygame写游戏,本质上就是写一个不停转的循环。这个循环的模板几乎是固定的,不管你做贪吃蛇、俄罗斯方块还是飞机大战,骨架都长一样:
python复制while running:
# 1. 处理事件(键盘、鼠标、窗口关闭)
# 2. 更新游戏状态(蛇移动、判断碰撞、加分)
# 3. 绘制画面(画蛇、画食物、画分数)
clock.tick(FPS)
每一步的作用都很明确。处理事件是响应输入,更新状态是改变数据,绘制画面是把数据画到屏幕上。这三步做完,一帧就结束了,紧接着进入下一帧。你不主动退出的话,这个循环会一直转下去,直到玩家撞墙或者按了关闭按钮。
这里有一个新手容易混淆的点:游戏循环里的"帧"和绘图里的"帧"是一回事吗?不完全一样。游戏循环里的一帧是一次完整的逻辑更新,可以没有画面变化;而画面刷新是pygame内部按帧率处理的事。我们只要保证每次循环都调用一次pygame.display.flip()把画好的内容推到屏幕上,视觉上就会连续起来。
2.2 帧率与蛇速:clock.tick(10)怎么控制速度
pygame提供的pygame.time.Clock对象,核心方法就是tick(FPS)。它做的事情是:让当前循环停一下,确保每秒最多执行FPS次。比如clock.tick(10),意思就是每秒最多跑10帧。
你可能会问,贪吃蛇的速度到底由什么决定?答案很简单:蛇的移动频率等于游戏帧率。因为我们在每一帧里都调用了一次snake.move(),所以帧率是10时蛇每秒移动10格,帧率是30时蛇每秒移动30格。理解了这个关系,你就知道为什么不能把FPS设成60了——蛇动得跟飞一样,根本没法玩。
我刚接触pygame的时候踩过一个坑,就是控制速度时不去用clock.tick,而是自己写time.sleep(0.1)。表面上效果差不多,实际上差别很大。time.sleep会让整个程序进程阻塞,相当于事件处理也被堵住了,你按下方向键,程序要等睡眠结束才能反应过来,操作延迟非常明显。而clock.tick是在保证帧率的前提下把多余的时间让出来,事件响应依然是流畅的。
2.3 用方向字典管理移动向量
贪吃蛇的方向控制,新手最常见的写法是四个if判断,分别处理上下左右。我更进一步建议你用一个字典把方向名映射到坐标向量,这样做有两个好处:一是代码清晰,二是为后面扩展"穿墙模式""障碍物"等玩法留出空间。
在pygame的坐标系里,左上角是原点(0,0),x轴向右增大,y轴向下增大。所以上、下、左、右分别对应:
python复制DIRECTIONS = {
"UP": (0, -CELL),
"DOWN": (0, CELL),
"LEFT": (-CELL, 0),
"RIGHT": (CELL, 0),
}
这里CELL是格子的边长。方向向量用"每帧移动多少像素"来表示,好处是后面移动时直接把向量加到蛇头坐标上就行,不用再乘速度、乘时间。这一步想通了,整个坐标计算都会变得非常简洁。
这个方向字典也是一个很典型的"用数据代替逻辑"的思路。后面如果要做斜向移动或更复杂的移动规则,你只需要改这个字典,而不是改一堆if分支。
3. 蛇的移动数学:列表就是一条活蛇
3.1 头插尾删:为什么蛇身会跟着移动
蛇的核心数据结构就是一个列表,列表里的每个元素是一个坐标元组,比如[(100, 100), (80, 100), (60, 100)],第一个元素是蛇头,后面的依次是蛇身。
蛇移动的逻辑特别优雅,只有两步:在列表头部插入新坐标,然后删除列表尾部的坐标。这样整条蛇看起来就像往前走了一格。如果吃到了食物,我们只在头部插入新坐标,不删除尾部,蛇就变长了一格。
python复制def move(self):
dx, dy = self.direction
head_x, head_y = self.head()
new_head = (head_x + dx, head_y + dy)
self.body.insert(0, new_head)
if not self.growing:
self.body.pop()
else:
self.growing = False
这个"头插尾删"的思路值得仔细体会。它背后是队列的思想,先进先出。蛇身只是蛇头走过的路径留下的一串痕迹,只不过这条痕迹的长度是固定的。很多人做这块时容易绕进去,想不通为什么蛇身会跟着拐弯,等你把"蛇身就是历史路径"这个认知建立起来,整个移动逻辑就通了。
从性能角度说,列表头部插入是O(n)操作,但贪吃蛇的蛇身通常也就几十个节点,这个开销完全不是问题。不过你也要知道,如果哪天你要做一个包含成千上万个实体的游戏,这种数据结构就需要换成双端队列collections.deque了,因为deque的头部插入是O(1)的。
3.2 碰撞判定的三种死法
碰撞判定是游戏结束逻辑的核心。贪吃蛇里有三种碰撞需要处理:撞墙、咬到自己、吃到食物。
撞墙的判断非常简单,检查新蛇头坐标是否超出窗口边界就行。注意pygame坐标系的特性,x从0到WIDTH,y从0到HEIGHT,超出这个范围就算撞墙。
咬到自己的判断用的是in操作符:判断蛇头坐标是否在蛇身其余部分里。这里有个细节,为什么判断的是self.body[1:]而不是整个body?因为蛇头必然等于蛇头,如果判断整个body就永远为True了,游戏一开始就会结束。正确的写法是:
python复制def hit_self(self):
return self.head() in self.body[1:]
吃到食物的判断最简单,直接比较蛇头坐标和食物坐标是否相等。因为蛇和食物都在同一个网格上,坐标对齐之后,只要数值相等就说明重合了。
这里我想额外说一个容易被忽略的边界情况:如果蛇的长度刚好等于整个地图的格子数,游戏理论上应该判胜利,但实际上几乎不可能发生,因为地图很大,蛇要吃到最后一个食物时已经没有可移动的空间了。所以大多数实现都不做胜利判断,直接按撞墙或撞到自己结束。
3.3 食物生成:避开蛇身的随机坐标
食物生成看起来只是随机选一个坐标,但有一个很关键的细节:不能生成到蛇身上。否则食物会直接和蛇重叠,玩家根本来不及反应就已经吃到了,体验很差。
最简单的实现是做一个while循环,不断随机生成坐标,直到生成一个不在蛇身上的坐标:
python复制def random_position(self, snake):
while True:
x = random.randrange(0, WIDTH // CELL) * CELL
y = random.randrange(0, HEIGHT // CELL) * CELL
if (x, y) not in snake.body:
return (x, y)
这种办法绝大多数情况下都很快,因为地图很大、蛇身很短,随机几次就能找到空位。但如果蛇身特别长,占据了地图的大半区域,极端情况下这个循环可能跑很久。严谨一点的做法是:先生成所有空坐标的列表,然后从中随机选一个。对贪吃蛇这个项目来说,while循环完全够了,但你要知道它的局限性。
另一个容易忽略的细节是坐标对齐。random.randrange(0, WIDTH // CELL)生成的是格子序号,比如0到31,乘以CELL之后才是像素坐标。如果漏了这一步,直接random.randrange(0, WIDTH),食物就有可能出现在两个格子之间,蛇头从旁边路过时永远吃不到。
4. pygame渲染与画面细节
4.1 初始化窗口与颜色模型
pygame的启动流程是固定的,先用pygame.init()初始化所有模块,然后创建窗口,再创建时钟和字体:
python复制pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Python 贪吃蛇")
clock = pygame.time.Clock()
font = pygame.font.SysFont("simhei", 24)
这里的pygame.display.set_mode返回的是一个Surface对象,你可以把它理解成一块画布。所有绘制操作都是在内存里更新这块画布,最后通过pygame.display.flip()一次性显示到屏幕上。这种机制叫双缓冲,能有效避免画面闪烁。
颜色在pygame里用RGB元组表示,比如(0, 0, 0)是黑色,(0, 200, 0)是绿色,(200, 0, 0)是红色。很多人会直接用(255, 0, 0)这种纯饱和颜色,我个人的习惯是稍微压低一点饱和度,长期盯着屏幕看眼睛会舒服很多。这个纯粹看个人审美,代码上没有任何区别。
关于中文字体,这里有一个坑需要提前说:pygame默认的字体不支持中文,你直接pygame.font.Font(None, 36)画中文会变成乱码方块。解决方法是指定一个系统中文字体路径,或者用pygame.font.SysFont("simhei", 24)。但SysFont依赖系统里有对应字体,换到别的电脑上可能找不到。一个更稳妥的方案是分数标签直接用英文,比如Score: 10,这样彻底避开字体问题。
4.2 画蛇、画食物、画分数
绘制这一块的代码非常直观。蛇身的每一节都是一个矩形,食物也是一个矩形,分数通过字体渲染成Surface后用blit贴到窗口上:
python复制def draw(self):
self.screen.fill(BLACK)
for seg in self.snake.body:
pygame.draw.rect(self.screen, GREEN,
(seg[0], seg[1], CELL - 1, CELL - 1))
pygame.draw.rect(self.screen, RED,
(self.food.pos[0], self.food.pos[1], CELL - 1, CELL - 1))
score_text = self.font.render(f"Score: {self.score}", True, WHITE)
self.screen.blit(score_text, (10, 10))
pygame.display.flip()
screen.fill(BLACK)是每帧绘制前把整块画布清空,否则上一帧的画面会残留,蛇会拖出重影。清空之后再把所有元素画上去,最后flip显示出来。
pygame.draw.rect的参数分别是目标表面、颜色、矩形四元组。这里的四元组是(x, y, width, height),坐标用蛇身坐标,宽高都取CELL减去1。为什么要减1?因为如果方块完全填满格子,相邻蛇身之间就没有空隙,蛇看起来会是一条粗大的实心条,不如留一个像素的间距有网格感和层次感。
字体渲染那一行self.font.render(f"Score: {self.score}", True, WHITE)返回一个Surface,然后通过blit把它画到窗口的左上角(10, 10)位置。这里有个小细节:blit的坐标是Surface的左上角,不是中心点。
4.3 事件循环里不能做的事
pygame的事件处理用pygame.event.get()获取所有待处理事件,通过type和key来判断是哪种事件。方向键对应的键值分别是pygame.K_UP、pygame.K_DOWN、pygame.K_LEFT、pygame.K_RIGHT。
在事件循环里有一个重要的原则:不要做耗时操作。事件循环的目的是快速响应输入,如果你在这里面做文件读写、网络请求、大量打印,游戏就会卡顿。我刚做游戏的时候犯过一个错,在每次方向键按下时都去打印一行日志到控制台,结果当蛇跑得快时,控制台输出导致画面明显掉帧。后来我把日志去掉,或者只在调试模式下才打印,帧率就恢复正常了。
还有一个常见错误是想当然地在事件循环里更新状态。比如直接在KEYDOWN里把蛇头坐标改了。正确的做法是:事件循环里只记录方向和状态的变化,真正的位置更新放在update阶段统一处理。这样能避免"一帧内多次移动""按键顺序导致的逻辑错乱"这类问题。
5. 新手最容易踩的四个坑
5.1 方向键瞬间掉头:蛇为什么突然"自杀"
这是贪吃蛇项目里最经典的bug。蛇正在向右移动,玩家按了一下上,然后又快速按了一下左,蛇会在极短的时间内从上变成左,这个过程中蛇头可能还没有移动,但方向已经变了两次。真正要命的是另一种情况:蛇向右移动时玩家按下左键,如果代码没有检查反向,蛇头会直接掉头,瞬间撞上自己的身体。
因为蛇的移动是"头插尾删",当蛇头向左移动一格,而蛇身由于尾删除也在跟进,新蛇头的位置恰好是原来第二段蛇身的位置,结果头部检测碰撞时发现新位置已经在身体列表里了,游戏立刻判定死亡。
解决这个问题的标准做法是在设置direction时做反向拦截:
python复制def set_direction(self, new_dir):
dx, dy = self.direction
nx, ny = new_dir
if (nx, ny) == (-dx, -dy):
return # 不允许掉头
self.direction = (nx, ny)
这里有一个更隐蔽的细节我当初排查了很久:如果蛇身长度只有1,其实是可以任意转向的,因为身体只有头部自己,怎么掉头都不会撞到自己。但很多实现简单粗暴地禁止了反向,导致蛇特别短的时候操作手感有点奇怪。这个可以接受,因为蛇很快就长大了,这种极限情况几乎不影响体验。
另外要提醒一个点:方向键连按。如果你在同一个事件帧里连续按了上和左,上面的set_direction会依次执行。蛇原本向右,先按上,方向变成上,再按左,方向变成左。此时蛇的方向已经从左跳到上,没有经过"上"这个中间状态。严格来说这可能会造成"瞬移转向"的观感,但在实际游戏中,这种级别的视觉误差极小,玩家通常不会注意到。如果你想追求完美,可以加一个下一帧才生效的方向队列,不过对新手项目来说,当前方案已经够用了。
5.2 事件处理太慢:按了方向键没反应
有的玩家反馈"蛇走得好快,按方向键根本来不及转弯"。这个问题的根源多半是帧率设置太高,或者更新位置和事件处理被放在了同一个循环里,却没有把蛇移动频率和键盘响应频率分开。
之前说过,clock.tick(FPS)决定了蛇的移动速度。如果你把FPS设成30,蛇每秒移动30格,12x16的格子地图吃完可能只需要几秒。玩家看到的不是游戏流畅,而是蛇快得像闪电。要理解一点:FPS是"逻辑更新频率",不只是"画面刷新频率"。对于贪吃蛇这种需要玩家思考的策略游戏,10的FPS是主流选择,移动节奏刚好,不会快得反应不过来,也不会慢得无聊。
如果你想要画面更流畅、又想让蛇的移动速度保持不变,那么可以做一个"蛇每N帧移动一格"的逻辑,而不是每帧都移动。举个例子,保持FPS为60,但让蛇每6帧移动一次,这样画面响应键盘的频率是60帧,而蛇的实际移动速度仍然是每秒10格。不过这个优化对贪吃蛇来说意义不大,反而会让代码变复杂,一般只在想追求流畅视觉时才需要。
5.3 初始化坐标不对齐:食物和蛇错位
蛇的初始坐标、食物的随机坐标,都必须保证在网格对齐的交叉点上。网格的交叉点坐标是CELL的整数倍,即0、20、40、60等等。
很多新手在初始化蛇头时直接写:
python复制start_x = WIDTH // 2
start_y = HEIGHT // 2
如果WIDTH是640,640除以2刚好是320,是20的倍数,看不出问题。但如果窗口宽度改成650,或者蛇的初始坐标经过计算后落到了330,那么蛇头就不在网格线上。食物生成在(320, 320),蛇头跑在(330, 320),两者永远不会重合,就会出现"食物明明在眼前却吃不到"的诡异现象。
正确的写法是对齐到网格:
python复制start_x = (WIDTH // 2) // CELL * CELL
start_y = (HEIGHT // 2) // CELL * CELL
这里先用WIDTH // 2算出中间位置的像素坐标,再除以CELL取整,乘回CELL,得到离中间位置最近的网格交叉点。这个"除CELL乘CELL"的操作是网格游戏中最常用的坐标对齐手法,一定要记牢。
5.4 闪屏和汉字乱码
闪屏问题多半是缺少pygame.display.flip()。你可能会觉得,画完所有东西然后flip一次和每画一个就update一次有什么区别?实际上区别很大。双缓冲机制下,所有绘制先发生在后台缓冲,flip时才整体切换。如果每次绘制都立即刷新,就会产生画面撕裂和闪烁。
汉字乱码的问题之前提过,根源是字体。pygame.font.SysFont("simhei", 24)在Windows上通常能正常工作,但如果你把代码发给macOS或Linux的朋友,simhei字体不一定存在,程序会报错或显示方块。最稳妥的方法是把字体文件一起打包,用pygame.font.Font("fonts/simhei.ttf", 24)指定字体文件的相对路径。这样不管在哪台机器上运行,只要字体文件在,显示就不会出问题。
6. 从能玩到好玩:五个扩展方向
6.1 加速机制与难度曲线
基础版贪吃蛇是匀速的,玩久了容易腻。一个简单有效的改动是:每吃掉一个食物,游戏节奏就微幅加快。实现思路是在食物计数的同时减少clock.tick的参数:
python复制FPS = min(10 + self.score // 30, 25)
self.clock.tick(FPS)
比如初始FPS是10,每吃掉3个食物增加1,上限25。这样玩家在前几局会觉得越来越紧张,但又不会快到完全无法操作。这个过程的本质是难度曲线设计:前期让玩家建立起操作节奏,中后期逐渐增加压力。
6.2 最高分与JSON存档
做完基本逻辑之后,可以加一个最高分记录。做法很简单,用JSON文件存一下分数,游戏结束时读取和更新:
python复制import json
import os
def load_best_score():
if os.path.exists("best_score.json"):
with open("best_score.json", "r", encoding="utf-8") as f:
return json.load(f).get("best_score", 0)
return 0
def save_best_score(score):
with open("best_score.json", "w", encoding="utf-8") as f:
json.dump({"best_score": score}, f)
这个扩展的价值不只是多了一个分数,而是让你体验到了"数据存储"和"游戏状态持久化"。以后做任何项目,只要需要保存用户数据,思路都是这个套路:字典、JSON、文件读写。
6.3 障碍物和穿墙模式
增加障碍物的逻辑是:在游戏开始前生成一组固定的障碍坐标,蛇头撞上去也算死亡。关键点在于障碍物不能堵住整个出口,否则游戏必然卡死。我实际测试时发现,在地图中央放几个方块,或者沿地图边缘留出缺口,难度提升非常明显。
穿墙模式则是反向的玩法:蛇从一边出界,从另一边进来。实现时只要把蛇头的坐标用取模运算限制在地图范围内:
python复制new_head_x = (head_x + dx) % WIDTH
new_head_y = (head_y + dy) % HEIGHT
取模运算让坐标自动"环绕",顺便也让之前的撞墙判断失效。穿墙模式下撞墙不再结束游戏,这意味着自撞变成唯一的死法,玩法重点也从"边界意识"变成了"路径规划"。
6.4 用PyInstaller打包成exe
自己写的游戏想发给朋友玩,不用让朋友装Python环境,直接打包成exe就行。用PyInstaller可以做到:
bash复制pip install pyinstaller
pyinstaller -F -w snake.py
-F表示打包成单文件,-w表示不显示控制台窗口。打包完成后exe在dist目录下,双击就能运行。这里有几个实战中的坑:第一,如果代码里使用了外部字体文件,需要把字体文件和exe放在同一个目录下,或者用--add-data参数把字体打进exe;第二,pygame程序的exe体积通常有二三十兆,这是正常的,不用惊讶;第三,杀毒软件有时会误报,这是PyInstaller打包程序的常见现象,让朋友添加信任即可。
6.5 给蛇装上大脑:BFS自动寻路
最后一个进阶方向是AI自动玩。思路很简单:把蛇头当作起点,把食物当作终点,用广度优先搜索(BFS)在地图上找一条最短路径,然后沿这条路径移动。BFS保证在无权重图中找到的路径一定是最短的,非常契合这种网格地图。
但自动寻路有个需要特别小心的坑:如果蛇按最短路径奔向食物,很可能把自己围死。更复杂的策略会用汉密尔顿回路(一条经过所有格子且不重复的路径)保证蛇永远不撞自己,但实现复杂度会高出不少。我自己做AI贪吃蛇时用的折中方案是:先用BFS找食物路径,同时模拟沿路径移动后的蛇身,检查是否会导致蛇被困住,如果会,就转而寻找一条通向安全区域的"逃生路径"。
这个扩展做完,你对算法的理解会有一个全新的维度。贪吃蛇不是AI的入门项目首选,但它绝对是理解"状态空间搜索"的直观教学案例。
写到这里,我仍然记得自己第一版贪吃蛇只实现了最基础的移动和吃食物,蛇身还经常因为碰撞逻辑写得不对而无缘无故死亡。后来一步步完善,加了分数、加速、存档、障碍物,整个项目变得越来越像一个真正的游戏。如果你也打算做一遍这个项目,我的建议是:先照着这篇文章把核心流程跑通,然后关掉参考代码,凭自己的理解重写一次。你会发现在重写的每一行里,之前那些"理所当然"的判断、循环和数据结构,都开始有了它存在的理由。这个从模仿到独立实现的过程,才是这个经典小游戏带给你的真正收获。
