1. 为什么用 Pygame 写贪吃蛇是个好主意
贪吃蛇这个游戏,几乎每个学编程的人都动过手。我见过太多人一上来就想写个 3D 射击或者联机对战,结果卡在渲染管线或者网络同步上,三天就放弃了。贪吃蛇不一样,它的规则简单到可以用一句话说清楚:蛇吃到食物变长,撞墙或撞自己就结束。但它的实现又足够复杂,能让你把游戏开发里最核心的几个概念都摸一遍——游戏循环、事件处理、碰撞检测、状态管理、渲染刷新。
Pygame 是 Python 里最成熟的 2D 游戏库之一,底层基于 SDL,跨平台支持好,Windows、macOS、Linux 上都能跑。它的 API 设计很直白,没有太多抽象层,你写几行代码就能在屏幕上画出东西。对于想理解“游戏到底是怎么跑起来的”这件事,Pygame 是个很好的起点。你不需要先学一堆引擎概念,直接上手写,边写边理解。
这篇文章面向的是已经会一点 Python 基础的人——知道列表、字典、循环、函数怎么写就行。如果你完全没碰过 Python,建议先花两天把基础语法过一遍再回来。我会从零开始,把贪吃蛇的每个模块拆开讲,包括为什么这么设计、参数怎么定、哪里容易踩坑。最后你会得到一个可以直接运行的完整游戏,并且知道每一行代码在干什么。
我写这个项目大概用了三个晚上,第一个晚上把基本框架搭起来,第二个晚上处理碰撞和计分,第三个晚上优化手感和加细节。中间踩了不少坑,比如蛇移动速度不稳定、食物生成在蛇身上、按键响应延迟等等。这些我都会在文章里标出来,让你少走弯路。
2. 项目整体设计与核心思路拆解
2.1 游戏循环:整个程序的心脏
任何游戏的核心都是一个循环,通常叫“游戏循环”或“主循环”。它的逻辑是这样的:每一帧,先处理输入事件,然后更新游戏状态,最后重新绘制画面。这个循环每秒执行几十次,人眼看起来就是连续动画。
Pygame 里用pygame.time.Clock()来控制帧率。我一般把贪吃蛇的帧率设在 10 到 15 之间。为什么这么低?因为贪吃蛇的蛇是格子式移动的,不是像素级平滑移动。如果帧率设到 60,蛇会快得根本反应不过来。10 帧意味着蛇每秒移动 10 格,这个速度对新手来说比较友好。你可以根据自己手感调整,我实测 12 帧是个比较舒服的值。
这里有个关键点:游戏循环里不能写time.sleep()来降速。sleep会阻塞整个线程,导致事件处理也卡住,按键会变得不灵敏。正确做法是用clock.tick(FPS),它会在保持帧率的同时让出 CPU 时间,事件响应依然流畅。
2.2 网格系统:把屏幕切成豆腐块
贪吃蛇的移动是基于格子的,所以我们需要一个网格系统。假设窗口大小是 600x600,每个格子 20x20 像素,那么横向和纵向各有 30 个格子。蛇的每个身体段、食物、墙壁都对齐到这些格子上。
为什么用网格而不是像素级移动?因为网格让碰撞检测变得极其简单。你只需要比较两个格子的坐标是否相同,不需要做复杂的矩形相交计算。而且网格移动的视觉节奏感更强,玩家更容易预判蛇的走向。
网格的坐标我用(col, row)表示,col 是列号,row 是行号,从 0 开始。转换成屏幕像素坐标就是(col * CELL_SIZE, row * CELL_SIZE)。这个转换在绘制的时候做,逻辑层只关心格子坐标。
2.3 蛇的数据结构:列表还是队列
蛇的身体本质上是一个有序的坐标序列。头部在最前面,尾部在最后面。每次移动,头部增加一个新格子,尾部删掉一个格子(如果没吃到食物)。这天然就是一个队列结构。
Python 里用collections.deque是最合适的,因为它在两端插入和删除都是 O(1) 复杂度。用普通列表也行,但pop(0)是 O(n),蛇长了之后会有性能问题。虽然贪吃蛇的蛇长通常不会超过几百,但养成用好数据结构的习惯没坏处。
我用deque存蛇身,头部在左端,尾部在右端。移动时appendleft(new_head),没吃到食物就pop()。吃到食物就不 pop,蛇自然变长。
2.4 方向控制:防止反向自杀
蛇不能直接掉头,比如正在向右走,不能立刻改成向左。这需要在处理方向输入时做判断:新方向不能和当前方向相反。
我用一个方向向量来表示,比如(1, 0)是向右,(-1, 0)是向左,(0, 1)是向下,(0, -1)是向上。判断反向就是看两个向量相加是否为零向量。如果是零向量,说明方向相反,忽略这次输入。
这里有个细节:不能直接修改当前方向,而是要把新方向存到一个“待定方向”变量里,等下一帧移动时再应用。否则在同一帧内连续按两个键,可能会绕过反向检测。比如蛇向右走,玩家快速按了上和左,如果直接改方向,先改成上再改成左,左和右是相反的,但检测时当前方向已经变成上了,就检测不出来。用待定方向可以避免这个问题。
2.5 食物生成:别让食物长在蛇身上
食物生成看起来简单,随机选一个空格子就行。但实现的时候要注意:不能生成在蛇身上。做法是收集所有空格子,然后从中随机选一个。如果蛇已经占满整个屏幕,那就没有空格子了,这时候应该判定胜利。
收集空格子的方式是遍历整个网格,排除蛇身占用的格子。30x30 的网格有 900 个格子,遍历一遍很快,不用担心性能。但如果你把网格设得很大,比如 100x100,那每次生成食物都遍历一万个格子就有点浪费了。优化方法是维护一个空闲格子集合,蛇移动时更新这个集合。不过对于贪吃蛇这个规模,直接遍历完全够用。
2.6 游戏状态管理:开始、进行、结束
游戏至少有三个状态:等待开始、进行中、游戏结束。我用一个字符串变量来存当前状态,比如"START"、"PLAYING"、"GAME_OVER"。不同状态下,游戏循环做的事情不一样。
等待开始时,画面显示提示文字,按任意键开始。进行中,正常处理输入和更新。游戏结束时,显示得分和重新开始提示。这种状态机模式让代码结构很清晰,不会把所有逻辑混在一起。
3. 核心细节解析与实操要点
3.1 窗口初始化和基础配置
先安装 Pygame:pip install pygame。然后初始化:
import pygame import random from collections import deque pygame.init() CELL_SIZE = 20 GRID_WIDTH = 30 GRID_HEIGHT = 30 WINDOW_WIDTH = CELL_SIZE * GRID_WIDTH WINDOW_HEIGHT = CELL_SIZE * GRID_HEIGHT screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) pygame.display.set_caption("贪吃蛇") clock = pygame.time.Clock() FPS = 12这里CELL_SIZE设为 20 是个经验值。太小了蛇看起来像蚂蚁,太大了屏幕装不下几个格子。20 像素在 600x600 的窗口里刚好,蛇身和食物都清晰可见。
颜色定义我用 RGB 元组:
BLACK = (0, 0, 0) WHITE = (255, 255, 255) GREEN = (0, 200, 0) DARK_GREEN = (0, 150, 0) RED = (200, 0, 0) GRAY = (40, 40, 40)蛇头用亮绿色,蛇身用暗绿色,这样能一眼看出头在哪。食物用红色,对比强烈。背景用黑色,不刺眼。
3.2 蛇的初始化和移动逻辑
蛇的初始位置我放在屏幕中间,长度 3,方向向右:
def init_snake(): start_col = GRID_WIDTH // 2 start_row = GRID_HEIGHT // 2 return deque([(start_col, start_row), (start_col - 1, start_row), (start_col - 2, start_row)])注意 deque 左边是头,所以第一个元素是头的位置,后面是身体。方向初始为(1, 0)。
移动逻辑:
def move_snake(snake, direction, food): head_col, head_row = snake[0] new_head = (head_col + direction[0], head_row + direction[1]) if new_head == food: snake.appendleft(new_head) return True # 吃到食物 else: snake.appendleft(new_head) snake.pop() return False这里先计算新头位置,然后判断是否吃到食物。吃到就不删尾部,蛇变长。没吃到就删尾部,蛇长度不变。
3.3 碰撞检测的三种情况
碰撞检测分三种:撞墙、撞自己、吃到食物。
撞墙判断很简单,新头的 col 或 row 超出范围就是撞墙:
def check_wall_collision(head): col, row = head return col < 0 or col >= GRID_WIDTH or row < 0 or row >= GRID_HEIGHT撞自己就是新头位置和蛇身某个格子重合。注意要排除尾部,因为尾部下一帧会移走。但为了简单,我直接检查除了尾部之外的所有身体段:
def check_self_collision(snake, new_head): return new_head in list(snake)[:-1]这里把 deque 转成 list 再切片,去掉最后一个元素。蛇不长的时候性能没问题。
吃到食物就是新头和食物坐标相同。吃到后分数加一,重新生成食物。
3.4 食物生成的正确姿势
前面说了,食物不能生成在蛇身上。实现如下:
def generate_food(snake): occupied = set(snake) empty_cells = [(c, r) for c in range(GRID_WIDTH) for r in range(GRID_HEIGHT) if (c, r) not in occupied] if not empty_cells: return None # 胜利 return random.choice(empty_cells)用 set 来存蛇身坐标,查找是 O(1)。然后列表推导式生成所有空格子。900 个格子遍历一遍大概几毫秒,完全可接受。
如果返回 None,说明蛇占满了整个屏幕,这时候应该显示胜利画面。这个情况在实际游戏中几乎不可能出现,但代码里处理一下更严谨。
3.5 绘制函数的细节
绘制蛇的时候,我给每个格子留 1 像素的间隙,这样蛇身看起来是一节一节的,更有质感:
def draw_snake(snake): for i, (col, row) in enumerate(snake): x = col * CELL_SIZE y = row * CELL_SIZE color = GREEN if i == 0 else DARK_GREEN pygame.draw.rect(screen, color, (x + 1, y + 1, CELL_SIZE - 2, CELL_SIZE - 2), border_radius=4)border_radius=4让方块有圆角,看起来更柔和。蛇头用亮绿色,身体用暗绿色。
食物画成圆形,比方形更显眼:
def draw_food(food): if food: col, row = food center_x = col * CELL_SIZE + CELL_SIZE // 2 center_y = row * CELL_SIZE + CELL_SIZE // 2 pygame.draw.circle(screen, RED, (center_x, center_y), CELL_SIZE // 2 - 2)3.6 文字渲染和得分显示
Pygame 渲染文字需要先创建字体对象:
font = pygame.font.SysFont("arial", 24) big_font = pygame.font.SysFont("arial", 48)SysFont使用系统字体,不需要额外文件。得分显示在左上角:
def draw_score(score): text = font.render(f"得分: {score}", True, WHITE) screen.blit(text, (10, 10))游戏结束画面用大字体显示“游戏结束”和最终得分,下面用小字体提示“按 R 重新开始”。
4. 完整实操流程与核心环节实现
4.1 主循环的完整结构
把前面所有模块串起来,主循环长这样:
def main(): snake = init_snake() direction = (1, 0) pending_direction = (1, 0) food = generate_food(snake) score = 0 state = "START" running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN: if state == "START": state = "PLAYING" elif state == "GAME_OVER": if event.key == pygame.K_r: snake = init_snake() direction = (1, 0) pending_direction = (1, 0) food = generate_food(snake) score = 0 state = "PLAYING" elif state == "PLAYING": if event.key == pygame.K_UP and direction != (0, 1): pending_direction = (0, -1) elif event.key == pygame.K_DOWN and direction != (0, -1): pending_direction = (0, 1) elif event.key == pygame.K_LEFT and direction != (1, 0): pending_direction = (-1, 0) elif event.key == pygame.K_RIGHT and direction != (-1, 0): pending_direction = (1, 0) if state == "PLAYING": direction = pending_direction head_col, head_row = snake[0] new_head = (head_col + direction[0], head_row + direction[1]) if check_wall_collision(new_head) or check_self_collision(snake, new_head): state = "GAME_OVER" else: ate = move_snake(snake, direction, food) if ate: score += 1 food = generate_food(snake) if food is None: state = "GAME_OVER" screen.fill(BLACK) draw_grid() draw_snake(snake) draw_food(food) draw_score(score) if state == "START": draw_center_text("按任意键开始", big_font) elif state == "GAME_OVER": draw_center_text("游戏结束", big_font) draw_center_text(f"最终得分: {score}", font, offset_y=60) draw_center_text("按 R 重新开始", font, offset_y=100) pygame.display.flip() clock.tick(FPS) pygame.quit()这个结构清晰地把输入处理、状态更新、渲染分开。每一帧都按这个顺序执行。
4.2 方向输入的防反逻辑
注意上面代码里方向判断用的是direction而不是pending_direction。为什么?因为direction是当前实际移动方向,pending_direction是下一帧要应用的方向。如果玩家快速按了两个键,比如先按上再按左,用direction判断的话,按上时direction还是向右,上不是右的反向,所以pending_direction变成上。然后按左时,direction仍然是向右(因为还没到下一帧),左是右的反向,所以被忽略。这样就不会出现蛇直接掉头的情况。
如果我用pending_direction来判断,按上之后pending_direction变成上,再按左时,左和上不相反,就会被接受,结果蛇在下一帧直接向左,而当前方向是向右,相当于掉头了。这就是 bug。
4.3 网格绘制:让画面更有结构感
画网格线是个可选步骤,但我觉得加上之后画面更有“棋盘”的感觉,玩家更容易判断距离:
def draw_grid(): for x in range(0, WINDOW_WIDTH, CELL_SIZE): pygame.draw.line(screen, GRAY, (x, 0), (x, WINDOW_HEIGHT), 1) for y in range(0, WINDOW_HEIGHT, CELL_SIZE): pygame.draw.line(screen, GRAY, (0, y), (WINDOW_WIDTH, y), 1)网格线用深灰色,不抢眼但能看清。如果你觉得太花,可以去掉这个函数调用。
4.4 居中文字的绘制
游戏开始和结束的文字需要居中显示:
def draw_center_text(text, font, offset_y=0): surface = font.render(text, True, WHITE) rect = surface.get_rect(center=(WINDOW_WIDTH // 2, WINDOW_HEIGHT // 2 + offset_y)) screen.blit(surface, rect)get_rect(center=...)自动计算左上角坐标,让文字居中。offset_y用来调整多行文字的间距。
4.5 帧率与手感调优
FPS 设为 12 是我反复测试后的值。如果你觉得蛇太慢,可以调到 15;觉得太快,调到 8。但不要超过 20,否则蛇会像闪电一样,根本来不及反应。
另外,按键响应有个小技巧:不要用pygame.key.get_pressed()来检测方向键,因为那是持续检测,按住不放会连续触发。用KEYDOWN事件更精确,每次按键只触发一次。
还有一个手感问题:如果玩家在蛇移动的间隙快速按了两个方向键,比如右转下再转左,由于pending_direction只存最后一个有效方向,中间那个会被覆盖。这是合理的,因为蛇不能在一帧内转两次弯。但如果你想让操作更跟手,可以维护一个方向队列,每帧从队列取一个方向。不过对于贪吃蛇来说,单变量足够了。
5. 常见问题与排查技巧实录
5.1 蛇移动速度忽快忽慢
这个问题我遇到过。原因是clock.tick(FPS)放在循环末尾,但渲染和逻辑更新耗时不稳定。如果某一帧逻辑计算特别慢,tick会补偿时间,导致下一帧跳得更快。
解决办法是固定时间步长。简单做法是把tick放在循环开头,并且不要依赖tick的返回值做逻辑。更严谨的做法是用累加器模式:
accumulator = 0 while running: dt = clock.tick(60) / 1000.0 accumulator += dt while accumulator >= 1.0 / FPS: update_game() accumulator -= 1.0 / FPS render()这样逻辑更新频率固定,渲染频率跟随显示器。不过对于贪吃蛇这种简单游戏,直接clock.tick(FPS)放在循环末尾已经够用了,速度波动肉眼几乎看不出来。
5.2 食物生成在蛇身上
这个 bug 很常见。如果你用random.randint直接生成坐标,就有概率生成在蛇身上。必须用前面说的“收集空格子再随机选”的方法。
还有一种情况:蛇吃到食物后,食物重新生成,但生成的位置刚好是蛇头下一帧要移动到的位置。这会导致蛇刚吃完又立刻吃到。虽然概率低,但体验不好。可以在生成食物时排除蛇头周围一圈的格子,或者干脆不管,因为这种情况很少见。
5.3 按键没反应或延迟
按键延迟通常是因为事件处理放在了渲染之后,或者用了time.sleep。确保pygame.event.get()在每帧开头调用,并且不要阻塞。
另一个原因是窗口没有获得焦点。Pygame 窗口需要点击一下才能接收键盘事件。如果你在终端里运行,窗口弹出后要用鼠标点一下窗口内部。
5.4 蛇撞自己判定太严格
有时候蛇头明明没有碰到身体,却判定撞自己了。这是因为check_self_collision检查了包括尾部在内的所有身体段。但尾部下一帧会移走,所以应该排除尾部。
我之前的代码是new_head in list(snake)[:-1],去掉了最后一个元素。但注意,如果蛇刚吃到食物,尾部不会移走,这时候尾部也应该算碰撞。所以更准确的判断是:如果没吃到食物,排除尾部;如果吃到了,不排除。
不过实际玩的时候,蛇头撞到尾部的概率极低,因为尾部总是在移动。为了代码简单,我直接排除尾部,影响不大。
5.5 游戏结束后无法重新开始
重新开始需要重置所有状态:蛇、方向、食物、分数、游戏状态。我见过有人只重置了蛇和分数,忘了重置方向,结果重新开始后蛇直接往原来的方向走,可能立刻撞墙。
确保在重新开始时把所有变量都恢复到初始值。我习惯写一个reset_game()函数,把所有初始化逻辑封装进去,避免遗漏。
5.6 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 蛇移动速度不稳定 | 帧率控制不当 | 用clock.tick(FPS)固定帧率 |
| 食物长在蛇身上 | 随机生成未排除蛇身 | 收集空格子后再随机选 |
| 按键无响应 | 事件处理被阻塞 | 确保event.get()每帧调用 |
| 蛇直接掉头 | 方向判断逻辑错误 | 用当前方向判断反向,用待定方向存新方向 |
| 撞自己判定过严 | 未排除尾部 | 检查碰撞时排除最后一个身体段 |
| 重新开始后立刻结束 | 状态未完全重置 | 写reset_game()统一重置 |
| 窗口无响应 | 主循环卡死 | 检查是否有死循环或阻塞操作 |
| 文字显示乱码 | 字体不支持中文 | 用SysFont指定支持中文的字体 |
5.7 几个提升体验的小技巧
第一,给蛇加一个“死亡闪烁”效果。游戏结束时,让蛇身闪烁几次再显示结束画面。实现方法是记录死亡时间,在渲染时根据时间决定是否绘制蛇身。
第二,加音效。Pygame 的mixer模块可以播放音效。吃到食物时播放“叮”的一声,死亡时播放“轰”的声音。音效文件可以用免费的素材,或者用代码生成简单的方波。
第三,加难度递增。每吃 5 个食物,FPS 增加 1,蛇移动变快。这样游戏后期更有挑战性。实现时注意 FPS 不要超过 20,否则没法玩。
第四,加暂停功能。按空格键暂停,再按恢复。暂停时显示“已暂停”文字。这个功能实现简单,但很实用。
第五,加最高分记录。用文件存储最高分,每次游戏结束比较并更新。文件读写用open()就行,注意异常处理。
6. 从贪吃蛇延伸出去还能学什么
贪吃蛇写完之后,你其实已经掌握了游戏开发的基本套路。接下来可以尝试几个方向来巩固和扩展。
第一个方向是加 AI 自动寻路。让电脑控制蛇,用 BFS 或 A* 算法找最短路径到食物。这个过程中你会学到图搜索算法,以及如何避免蛇把自己困死。我试过用 BFS,蛇能吃到 50 多个食物,但后期容易把自己绕死。改进方法是加一个“安全检测”,吃完食物后要能找到一条到尾巴的路径,否则不走。
第二个方向是改成双人对战。两条蛇在同一个屏幕上,吃食物变长,撞墙、撞自己、撞对方都算输。这需要处理两个蛇的碰撞检测,以及各自的计分。代码量增加不多,但趣味性翻倍。
第三个方向是加障碍物。在网格里随机生成一些墙壁,蛇撞到墙壁就死。这需要修改碰撞检测,把墙壁也加入判断。障碍物可以每吃 10 个食物刷新一次,增加变化。
第四个方向是做成手机能玩的版本。用pygame的触摸事件,或者用kivy这样的移动端框架重写。不过 Pygame 在移动端支持一般,更推荐用pygame-ce或者直接换引擎。
第五个方向是加网络联机。用socket实现两个玩家在不同电脑上对战。这个难度陡增,涉及网络同步、延迟处理、状态一致性等问题。建议先把单机版做扎实再考虑。
我个人觉得,贪吃蛇最大的价值不是游戏本身,而是它让你完整地走了一遍“设计-编码-调试-优化”的流程。这个流程在任何一个软件项目里都是通用的。你踩过的每一个坑,比如状态重置不完整、碰撞检测边界条件、帧率控制,在以后做更复杂的项目时都会再次遇到。所以别小看这个简单的游戏,把它写透,比草草写十个半成品项目收获大得多。
最后分享一个我调试贪吃蛇时的小习惯:在屏幕上显示当前帧率和蛇的长度。帧率用clock.get_fps()获取,蛇长度就是len(snake)。这两个数字能帮你快速判断性能问题和游戏进度。调试信息用灰色小字显示在右下角,不影响游戏画面。这个习惯我保持了很久,后来做任何图形程序都会加一个调试信息层,排查问题效率高很多。