1. 为什么选择用 Pygame 写贪吃蛇
1.1 贪吃蛇这个项目到底适合谁
贪吃蛇这个游戏,几乎每个学编程的人都写过。但很多人写完之后就扔了,觉得“太简单了,没什么意思”。其实恰恰相反,贪吃蛇是一个非常好的练手项目,它麻雀虽小五脏俱全:有游戏循环、有碰撞检测、有状态管理、有输入处理、有渲染刷新,甚至还能加上音效、计分、关卡加速这些扩展功能。你如果能把它写透,后面再去做更复杂的游戏或者图形界面程序,思路会顺畅很多。
我当初第一次用 Pygame 写贪吃蛇的时候,踩了不少坑。比如蛇身移动时坐标怎么更新才不会出现“头尾重叠”的视觉 bug,比如食物生成时怎么避免刷到蛇身上,比如帧率控制不好导致蛇跑得忽快忽慢。这些问题看起来小,但每一个都值得单独拿出来讲清楚。所以这篇内容我打算把整个开发过程拆开,从设计思路到代码实现,再到调试技巧,尽量把每个环节的“为什么”都说明白。
适合读这篇内容的人:学过 Python 基础语法,知道列表、字典、循环、函数怎么用,但还没怎么接触过图形化编程或者游戏开发的。如果你已经写过 Pygame 项目,也可以看看我在细节处理上的一些经验,说不定能帮你省掉一些调试时间。
1.2 Pygame 到底帮我们做了什么
很多人一上来就写代码,但其实应该先搞清楚 Pygame 这个库的定位。Pygame 本质上是一套基于 SDL 的 Python 封装,它帮你处理了窗口创建、事件循环、图像绘制、声音播放这些底层操作。如果没有 Pygame,你要用 Python 做一个游戏窗口,得直接调操作系统的图形接口,那复杂度完全不是一个量级。
具体到贪吃蛇这个项目,Pygame 主要帮我们解决四件事:
- 窗口和渲染:创建一个固定尺寸的窗口,在上面画矩形、画圆、贴图片。贪吃蛇的蛇身和食物本质上就是一堆矩形块。
- 事件处理:监听键盘按键、窗口关闭事件。贪吃蛇只需要处理方向键和退出事件。
- 时间控制:通过
pygame.time.Clock控制游戏帧率,让蛇的移动速度稳定可控。 - 碰撞检测:虽然 Pygame 没有直接提供“矩形碰撞”的一行代码,但
Rect对象自带的colliderect方法足够我们判断蛇头是否撞到食物或自己的身体。
理解了这些,你就知道为什么贪吃蛇用 Pygame 写特别合适——它刚好覆盖了 Pygame 最核心的几个功能,又不会复杂到让人无从下手。
1.3 整体设计思路:先想清楚再动手
我在写任何游戏之前,都会先问自己三个问题:游戏有哪些状态?状态之间怎么切换?每个状态下要处理什么逻辑?
贪吃蛇的状态其实很简单:运行中、游戏结束、暂停(可选)。运行中状态下,每一帧要做的事情是:处理输入、更新蛇的位置、判断是否吃到食物、判断是否撞墙或撞自己、重新绘制画面。游戏结束状态下,显示结束画面和分数,等待玩家按键重新开始或退出。
数据结构方面,蛇身我用一个列表来存,列表里每个元素是一个坐标元组(x, y),列表的第一个元素是蛇头。这样蛇移动的时候,只需要在列表头部插入新坐标,然后根据是否吃到食物决定是否删除尾部坐标。这个思路比用队列或者链表都简单,而且 Python 列表的insert(0, ...)和pop()操作在这个规模下性能完全够用。
食物用一个单独的坐标元组表示,生成时随机选一个格子,但要检查这个格子是否已经在蛇身上。方向用一个变量存当前移动方向,同时要记录“上一次的方向”,防止玩家在蛇移动的间隙快速按两个相反方向导致蛇直接掉头撞自己。
这些设计决策看起来琐碎,但每一个都直接影响后面代码写起来顺不顺。我见过不少人写到一半发现蛇身更新逻辑有问题,回头改数据结构,结果整个代码都要重写。所以前期花十分钟想清楚,后面能省一个小时。
2. 核心细节与实操要点拆解
2.1 网格坐标系:为什么不用像素坐标
贪吃蛇的移动是“一格一格”跳的,不是平滑移动。如果你直接用像素坐标,蛇头每次移动 20 像素,那蛇身每个块的坐标就是(x, y)、(x-20, y)、(x-40, y)这样。但这样有个问题:判断两个块是否重叠时,你需要比较像素值是否完全相等,浮点数或者计算误差可能导致判断失败。
更稳妥的做法是用网格坐标。比如窗口是 600x600,每个格子 20x20,那网格就是 30x30。蛇头在网格中的位置是(15, 10),表示第 15 列第 10 行。绘制的时候再乘以格子大小转换成像素坐标。这样做的好处是:碰撞检测只需要比较整数是否相等,食物生成只需要在 0 到 29 之间随机取整数,逻辑非常干净。
我一般会定义两个常量:
CELL_SIZE = 20 GRID_WIDTH = 30 GRID_HEIGHT = 30 WINDOW_WIDTH = CELL_SIZE * GRID_WIDTH WINDOW_HEIGHT = CELL_SIZE * GRID_HEIGHT这样后面所有坐标都用网格单位,只有绘制的时候才做一次乘法转换。这个习惯我强烈建议你养成,后面做任何网格类游戏都会轻松很多。
2.2 蛇身移动的核心逻辑
蛇移动的本质是:在蛇头前方新增一个格子,然后判断是否吃到食物。如果吃到,尾部不动;如果没吃到,删除尾部格子。用代码表达就是:
new_head = (snake[0][0] + direction[0], snake[0][1] + direction[1]) snake.insert(0, new_head) if new_head == food: # 吃到食物,不删除尾部,蛇变长 food = generate_food() else: snake.pop()这段代码看起来简单,但有几个细节容易出错。第一,方向不能直接取反。比如当前方向是向右(1, 0),玩家按了左键,如果你直接把方向改成(-1, 0),下一帧蛇头就会撞到自己脖子。解决办法是维护一个current_direction和一个next_direction,每帧开始时把next_direction赋给current_direction,而按键事件只修改next_direction,并且检查新方向是否与当前方向相反。
第二,蛇身列表的第一个元素是蛇头,这个约定要在整个代码里保持一致。我见过有人一会儿用snake[0]表示头,一会儿用snake[-1]表示头,最后自己都搞混了。
第三,吃到食物后蛇变长,但变长的位置是在头部新增,尾部不删除。这符合直觉:蛇头往前伸了一格,尾巴没动,所以整体长度加一。
2.3 食物生成的随机策略
食物生成看起来就是random.randint一下,但有个坑:如果随机到的位置正好在蛇身上,你不能直接把食物放在那里,否则玩家一吃就出问题。常见的处理方式是循环随机,直到找到一个不在蛇身上的位置:
def generate_food(snake): while True: x = random.randint(0, GRID_WIDTH - 1) y = random.randint(0, GRID_HEIGHT - 1) if (x, y) not in snake: return (x, y)这个写法在蛇很短的时候没问题,但如果蛇很长,比如占了 800 个格子中的 700 个,那循环可能会跑很多次才找到一个空位。更优雅的做法是先收集所有空格子,然后从空格子列表里随机选一个:
def generate_food(snake): empty_cells = [(x, y) for x in range(GRID_WIDTH) for y in range(GRID_HEIGHT) if (x, y) not in snake] if not empty_cells: return None # 蛇已经占满整个屏幕,游戏胜利 return random.choice(empty_cells)这个写法虽然每次都要遍历整个网格,但 30x30 也就 900 个格子,现代计算机跑这个毫无压力。而且它顺便处理了“蛇占满屏幕”的胜利条件,一举两得。
2.4 碰撞检测的三种情况
贪吃蛇的碰撞检测分三种:撞墙、撞自己、吃到食物。撞墙的判断很简单,蛇头坐标超出网格范围就是撞墙:
if new_head[0] < 0 or new_head[0] >= GRID_WIDTH or new_head[1] < 0 or new_head[1] >= GRID_HEIGHT: game_over = True撞自己的判断是看新蛇头坐标是否已经在蛇身列表里。但这里有个细节:如果蛇没吃到食物,尾部会在这一帧被删除,所以新蛇头撞到“即将被删除的尾部”不算撞自己。严格来说,应该判断new_head in snake[:-1],排除掉尾部那一格。不过在实际游戏中,蛇头不可能在一帧内移动到尾部的位置,因为尾部离头部至少有两格距离(除非蛇长度只有 1 或 2)。但为了代码严谨,我还是建议用snake[:-1]。
吃到食物的判断就是new_head == food,这个最简单。
我一般会把这三个判断写成一个函数,返回一个状态枚举或者字符串,这样主循环里逻辑更清晰:
def check_collision(new_head, snake, food): if new_head[0] < 0 or new_head[0] >= GRID_WIDTH or new_head[1] < 0 or new_head[1] >= GRID_HEIGHT: return "wall" if new_head in snake[:-1]: return "self" if new_head == food: return "food" return "none"2.5 帧率控制与游戏速度
Pygame 的Clock.tick(fps)控制的是每秒循环多少次。贪吃蛇不需要 60 帧那么快,一般 8 到 15 帧就够了。帧率越高,蛇移动越快。我一般初始设 10 帧,然后每吃到一个食物,帧率加 0.5 或者 1,这样游戏会越来越有挑战性。
但这里有个问题:如果你直接在每一帧里更新蛇的位置,那蛇的移动速度就完全由帧率决定了。帧率 10 的时候,蛇每秒移动 10 格,看起来还行。但如果你想让蛇移动更平滑,比如每 0.1 秒移动一格,但画面刷新保持 60 帧,那就需要把“逻辑更新”和“画面渲染”分开。Pygame 里可以用一个计时器变量来累积时间:
move_timer = 0 move_interval = 100 # 毫秒 dt = clock.tick(60) move_timer += dt if move_timer >= move_interval: move_timer = 0 update_snake()这样做的好处是,画面可以保持 60 帧的流畅度,但蛇的移动速度由move_interval独立控制。不过对于贪吃蛇这种格子游戏,其实没必要这么复杂,直接用低帧率就够了。我提这个是因为如果你后面想做更复杂的游戏,这种“逻辑与渲染分离”的思路非常重要。
3. 完整实操流程与代码实现
3.1 环境准备与项目结构
首先确保你装了 Python,版本 3.7 以上都行。然后安装 Pygame:
pip install pygame如果你用的是虚拟环境,记得先激活再装。装完之后可以跑一个最简单的测试:
import pygame pygame.init() screen = pygame.display.set_mode((400, 300)) pygame.display.set_caption("测试") running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False pygame.quit()如果能看到一个空白窗口并且能正常关闭,说明环境没问题。
项目结构我建议就一个文件snake.py,最多再加一个settings.py放常量。贪吃蛇这个规模不需要搞复杂的模块拆分,一个文件写清楚反而更好维护。但如果你想让代码更整洁,可以把蛇的逻辑、食物生成、渲染分别封装成类,这个看个人习惯。
3.2 初始化与常量定义
先把所有常量定义好,后面改参数只需要改这一处:
import pygame import random import sys # 网格设置 CELL_SIZE = 20 GRID_WIDTH = 30 GRID_HEIGHT = 30 WINDOW_WIDTH = CELL_SIZE * GRID_WIDTH WINDOW_HEIGHT = CELL_SIZE * GRID_HEIGHT # 颜色定义 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) # 游戏设置 INITIAL_FPS = 10 FPS_INCREMENT = 0.5 MAX_FPS = 25颜色我选了深绿色做蛇头,浅一点的绿色做蛇身,红色做食物,黑色背景,灰色画网格线。这样视觉上比较清晰,不会太刺眼。
3.3 蛇与食物的数据结构
蛇我用一个列表存坐标元组,初始长度 3,放在屏幕中间偏左的位置,方向向右:
def init_snake(): start_x = GRID_WIDTH // 4 start_y = GRID_HEIGHT // 2 return [(start_x, start_y), (start_x - 1, start_y), (start_x - 2, start_y)]食物生成用前面说的空格子随机法:
def generate_food(snake): empty_cells = [(x, y) for x in range(GRID_WIDTH) for y in range(GRID_HEIGHT) if (x, y) not in snake] if not empty_cells: return None return random.choice(empty_cells)方向我用一个字典映射按键到方向向量:
DIRECTIONS = { pygame.K_UP: (0, -1), pygame.K_DOWN: (0, 1), pygame.K_LEFT: (-1, 0), pygame.K_RIGHT: (1, 0), }注意 Pygame 的坐标系原点在左上角,所以向上是 y 减 1,向下是 y 加 1。这个和数学坐标系是反的,刚开始写的时候容易搞混。
3.4 主循环的完整实现
主循环的结构是这样的:
def main(): pygame.init() screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) pygame.display.set_caption("贪吃蛇") clock = pygame.time.Clock() font = pygame.font.SysFont("simhei", 24) snake = init_snake() food = generate_food(snake) direction = (1, 0) next_direction = (1, 0) score = 0 fps = INITIAL_FPS game_over = False while True: for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() sys.exit() if event.type == pygame.KEYDOWN: if game_over: if event.key == pygame.K_r: snake = init_snake() food = generate_food(snake) direction = (1, 0) next_direction = (1, 0) score = 0 fps = INITIAL_FPS game_over = False elif event.key == pygame.K_q: pygame.quit() sys.exit() else: if event.key in DIRECTIONS: new_dir = DIRECTIONS[event.key] if (new_dir[0] + direction[0], new_dir[1] + direction[1]) != (0, 0): next_direction = new_dir if not game_over: direction = next_direction new_head = (snake[0][0] + direction[0], snake[0][1] + direction[1]) # 碰撞检测 if new_head[0] < 0 or new_head[0] >= GRID_WIDTH or new_head[1] < 0 or new_head[1] >= GRID_HEIGHT: game_over = True elif new_head in snake[:-1]: game_over = True else: snake.insert(0, new_head) if new_head == food: score += 1 fps = min(fps + FPS_INCREMENT, MAX_FPS) food = generate_food(snake) if food is None: game_over = True else: snake.pop() # 绘制 screen.fill(BLACK) for x in range(0, WINDOW_WIDTH, CELL_SIZE): pygame.draw.line(screen, GRAY, (x, 0), (x, WINDOW_HEIGHT)) for y in range(0, WINDOW_HEIGHT, CELL_SIZE): pygame.draw.line(screen, GRAY, (0, y), (WINDOW_WIDTH, y)) for i, (x, y) in enumerate(snake): color = DARK_GREEN if i == 0 else GREEN rect = pygame.Rect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE) pygame.draw.rect(screen, color, rect) pygame.draw.rect(screen, BLACK, rect, 1) if food: fx, fy = food rect = pygame.Rect(fx * CELL_SIZE, fy * CELL_SIZE, CELL_SIZE, CELL_SIZE) pygame.draw.rect(screen, RED, rect) score_text = font.render(f"分数: {score}", True, WHITE) screen.blit(score_text, (10, 10)) if game_over: over_text = font.render("游戏结束 按 R 重开 按 Q 退出", True, WHITE) text_rect = over_text.get_rect(center=(WINDOW_WIDTH // 2, WINDOW_HEIGHT // 2)) screen.blit(over_text, text_rect) pygame.display.flip() clock.tick(fps) if __name__ == "__main__": main()这段代码可以直接跑。我特意把网格线画出来了,方便调试的时候看清楚每个格子的位置。实际发布的时候可以去掉网格线,看起来更干净。
3.5 关键参数的计算与选择
帧率初始值我选 10,是因为在 30x30 的网格上,蛇每秒移动 10 格,从屏幕左边到右边需要 3 秒,这个速度对新手来说比较友好。如果设成 20,蛇一秒就穿过半个屏幕,反应时间太短。
每吃一个食物帧率加 0.5,上限 25。这样前期节奏慢,后期逐渐加快,但不会快到无法操作。我实测下来,25 帧已经是普通人的反应极限了,再快就变成拼手速而不是拼策略了。
格子大小 20 像素,30x30 的网格,窗口就是 600x600。这个尺寸在大多数显示器上都能完整显示,不会超出屏幕。如果你想要更大的游戏区域,可以改成 40x40,但窗口就变成 800x800 了,小屏幕笔记本可能显示不全。
分数显示放在左上角,字体大小 24,用系统自带的黑体。如果你要发布给其他人玩,建议把字体文件打包进去,不然在没有黑体的系统上会显示成方块。
4. 常见问题与排查技巧实录
4.1 蛇移动时出现“闪烁”或“拖影”
这个问题我遇到过好几次,表现是蛇移动的时候,旧的位置没有完全擦除,新位置和旧位置重叠在一起,看起来像拖影。原因通常是你没有在每一帧开始时清空屏幕。screen.fill(BLACK)这一行必须放在绘制任何东西之前。如果你用的是pygame.display.update()而不是pygame.display.flip(),还需要注意更新区域的问题。我建议直接用flip(),它会更新整个屏幕,虽然效率略低但不会出这种问题。
另一个可能的原因是帧率太高,蛇移动太快,视觉上看起来像闪烁。把帧率降到 10 左右就能解决。
4.2 按键没反应或者方向控制不灵敏
Pygame 的按键事件是在事件循环里处理的,如果你在事件循环里做了耗时操作,比如复杂的计算或者文件读写,按键事件就会堆积,导致反应迟钝。贪吃蛇的逻辑很简单,一般不会出现这个问题。但如果你发现按键没反应,先检查事件循环是不是在每一帧都执行了。
还有一个常见问题是按键重复触发。Pygame 默认会发送按键重复事件,如果你按住方向键不放,KEYDOWN事件会连续触发。对于贪吃蛇来说这其实没关系,因为方向改变只在next_direction上生效,连续触发也只是重复设置同一个方向。但如果你要做“按一次键只改变一次方向”的逻辑,就需要用pygame.key.set_repeat()来控制。
4.3 蛇吃到食物后没有变长
这个问题通常是尾部删除逻辑写错了。检查你的代码,吃到食物的时候应该跳过snake.pop()。如果你把pop()写在了if外面,那不管吃没吃到食物,蛇的长度都不会变。
还有一种可能是食物生成的位置和蛇头位置判断有问题。比如你用像素坐标判断new_head == food,但食物存的是网格坐标,那永远不可能相等。确保所有坐标都在同一个坐标系里。
4.4 游戏结束后按 R 无法重开
重开逻辑需要把所有状态变量都重置:蛇、食物、方向、分数、帧率、游戏结束标志。少重置一个就会出问题。比如你重置了蛇但没重置方向,那新蛇可能一开始就往错误的方向移动。我建议把重置逻辑写成一个函数,这样不容易漏:
def reset_game(): snake = init_snake() food = generate_food(snake) direction = (1, 0) next_direction = (1, 0) score = 0 fps = INITIAL_FPS game_over = False return snake, food, direction, next_direction, score, fps, game_over4.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 蛇移动有拖影 | 没有清空屏幕 | 每帧开始加screen.fill(BLACK) |
| 按键无反应 | 事件循环未执行 | 检查pygame.event.get()是否在循环内 |
| 蛇不变长 | 尾部删除逻辑错误 | 吃到食物时跳过snake.pop() |
| 食物刷在蛇身上 | 生成时未检查 | 用空格子列表随机选择 |
| 游戏结束无法重开 | 状态未完全重置 | 用reset_game()统一重置 |
| 蛇移动速度忽快忽慢 | 帧率不稳定 | 用clock.tick(fps)固定帧率 |
| 窗口无法关闭 | 未处理 QUIT 事件 | 在事件循环里处理pygame.QUIT |
4.6 几个我踩过的坑和独家技巧
第一个坑是蛇身颜色渐变。我一开始想给蛇身做一个从深绿到浅绿的渐变效果,结果发现每帧都要重新计算每个块的颜色,代码复杂不说,性能还下降了。后来我改成蛇头深绿、蛇身统一浅绿,简单又好看。如果你真的想要渐变,可以在蛇身列表上按索引插值,但没必要。
第二个坑是食物闪烁效果。我想让食物有一个呼吸灯一样的闪烁,结果用pygame.time.get_ticks()计算时间,每帧改变食物的透明度。但 Pygame 的draw.rect不支持透明度,需要用Surface加set_alpha。后来我放弃了,直接画一个红色方块加一个白色小点,看起来像苹果,效果也不错。
第三个技巧是加一个“暂停”功能。按空格键暂停,再按恢复。实现很简单,加一个paused变量,在更新逻辑外面套一层if not paused。但要注意暂停时不要更新帧率计时器,否则恢复后蛇会突然跳一大步。
第四个技巧是记录最高分。用一个文本文件存最高分,每次游戏结束时比较并更新。文件读写用with open就行,注意处理文件不存在的情况。这个功能虽然简单,但能大大增加游戏的可玩性。
第五个技巧是给蛇加一个“死亡动画”。游戏结束时,让蛇身从尾部开始逐帧消失,而不是直接显示结束画面。实现方式是维护一个death_timer,每帧删除蛇身列表的最后一个元素,直到列表为空再显示结束文字。这个效果做出来之后,整个游戏的质感会提升一个档次。
4.7 性能优化与扩展方向
贪吃蛇这个规模基本不需要考虑性能问题,但如果你把网格调到 100x100,蛇长到几千节,那绘制和碰撞检测就会变慢。优化方向有几个:碰撞检测用集合而不是列表,把蛇身坐标存到一个set里,判断new_head in snake_set是 O(1) 的;绘制时只绘制可见区域,超出屏幕的块直接跳过;食物生成用空格子集合而不是每次遍历整个网格。
扩展方向的话,可以加障碍物、加传送门、加双人模式、加道具系统。每一个扩展都会带来新的设计问题,比如双人模式怎么处理两个蛇的碰撞、道具系统怎么设计随机效果。这些都可以作为你后续练手的项目。
我个人觉得,贪吃蛇写完之后,下一步可以试试用 Pygame 写一个简单的平台跳跃游戏,那个会涉及到重力、跳跃、平台碰撞,比贪吃蛇复杂一个量级,但核心思路是一样的:状态管理、碰撞检测、渲染循环。把这两个项目都吃透,你对 2D 游戏开发的基本套路就掌握得差不多了。