☰
PyGame贪吃蛇实战:从网格逻辑到类结构重构
2026/10/1 3:31:06 网站建设 项目流程

我最早接触 PyGame 的时候,出发点特别功利:想找个能讲清楚“游戏到底是怎么动起来”的例子,又不想一上来就扯引擎。选来选去还是贪吃蛇最合适,窗口、事件、循环、坐标、碰撞全都有,代码量又刚好压在一个周末能写完的范围内。后来我把示例整理成一套可以直接运行、方便逐步扩展的 PyGame 贪吃蛇工程,也用在了给新手讲 Python 图形编程的第一课上。

这篇内容就沿着我实际动手的顺序来写:先拆解贪吃蛇背后的网格逻辑,再把 Python 环境和 PyGame 安装里容易卡人的几个点挨个捋一遍,然后给出完整的主循环代码,接着说让游戏手感更好的一些小技巧,最后把脚本重构成更像正式项目的类结构。适合已经学过 Python 基础语法、想拿一个小游戏练手的人,也适合准备带学生做项目的朋友。

1. 动工前先把“网格游戏”的三件事想清楚

很多人一上来就写代码,结果写一半发现蛇拐弯的判定全是混乱的。贪吃蛇看起来简单,但它本质上是一个跑在网格上的回合制游戏,每一“回合”就是把蛇头往某个方向推一格。这三件事不先想清楚,后面一定会返工。

1.1 网格化坐标:从像素世界到棋盘世界

PyGame 的窗口是用像素定位的,屏幕上任意一点都能用一个(x, y)像素坐标表示。但贪吃蛇的移动、碰撞、食物生成,都更适合用一种“棋盘坐标”来思考。蛇不需要出现在任意像素位置,它只出现在格子交点处。

做法是给窗口定一个固定大小,比如 600×600 像素,再选一个格子边长,比如 25 像素,这样棋盘就有 24×24 个格子。棋盘坐标(grid_x, grid_y)和像素坐标之间只需要一个换算公式:

像素x = grid_x * CELL_SIZE 像素y = grid_y * CELL_SIZE

格子边长选 20、25、30 都可以,主要看你想让窗口里同时容纳多少个格子。格子越小,蛇活动的空间越大,但操作难度也会提升;格子太大又显得画面很空。我习惯用 25 这个值,窗口 600 像素时刚好 24 格,视觉上既不拥挤,也方便算位置。

有了这层映射,碰撞检测会简单非常多:不需要比较像素距离,直接比较两个格子的坐标是否相等就行。判断蛇头是否吃到食物,等价于判断蛇头的网格坐标是否等于食物的网格坐标。

1.2 蛇的移动本质:头部插入 + 尾巴弹出

蛇移动这件事,最容易让新手困惑的是“蛇身怎么跟上去”。你可能会想,每一格身体都要朝头部方向移动一次,那坐标更新得多麻烦。实际上游戏里根本不这么做。

蛇可以用一个坐标列表表示,列表头部是蛇头,列表后面是蛇尾。每次移动,只需要做两件事:

  • 根据当前方向,计算出新蛇头坐标,插入到列表最前面。
  • 如果没有吃到食物,就把列表最后一个元素弹出去,维持蛇身长度不变。

如果吃到了食物,就不弹出最后一个元素。这样蛇身自然增加一节。你可以理解为一条蛇每步只换一个“头”,没吃到东西时尾巴同时消失,视觉上看起来整条蛇在往前走。这个思路一出来,代码逻辑就清楚了一半。

1.3 四个方向与合法转向

方向也用网格向量表示。向右是(1, 0),向左是(-1, 0),向上是(0, -1),向下是(0, 1)。判断按键时,直接修改这个方向向量。

这里有个关键限制:蛇不能原地调头。比如当前正在向右移动,玩家按下左键,这时候不能把方向改成(-1, 0),因为蛇头会直接反插进自己第一节身体。所以每次改方向之前都要判断一下:新方向和当前方向是否正好相反。判断方法也很直接,两个方向相加,如果结果是(0, 0),就说明玩家想掉头。

但仅仅做好“禁止掉头”还不够,后面我会专门讲“方向缓存”。因为 PyGame 的事件处理发生在主循环的每一帧,而蛇的移动更新往往也在同一帧。如果你按下方向键的瞬间,正好这一帧已经执行过移动逻辑,按键就会延迟一帧才生效;如果这个延迟出现在快速连按的场景下,会出现蛇自己卡一下甚至反向的错觉。一个成熟的贪吃蛇工程,会把“当前朝向”和“下一个朝向”分开存。

2. 环境准备:Python 和 PyGame 安装里最容易翻车的环节

环境配置本身不难,但这是我看到问题最多的地方。很多初学者代码没问题,反而卡在 Python 没装好、pip 命令不对、装完 pygame 后 import 直接报错这些地方。下面是我实际遇到过、也教别人解决过的几类情况。

2.1 用对 Python 和 pip:版本与虚拟环境

先去确认你的 Python 版本,Windows 上打开命令行工具执行python --version,能打印出 3.10 或更高的版本号就比较好办。如果你发现自己输入python没反应,大概率是安装时没有勾选“把 Python 加入系统 PATH”这个选项,重装一次勾上就行。

实际执行安装命令时,更推荐用模块方式而不是直接敲 pip:

python -m pip install pygame

这样能避免多版本 Python 环境下pip指到别的解释器的问题。Python 3.11、3.12 这些版本目前都能正常安装 pygame。

另一个建议是直接用虚拟环境。新手会觉得虚拟环境麻烦,但如果你一段时间里要在好几个 Python 项目之间切换包版本,它真的能救命。在项目目录里执行:

python -m venv venv

然后 Windows 上激活venv\Scripts\activate,macOS 或 Linux 上执行source venv/bin/activate,之后再安装 pygame 就不会污染全局环境,而且装错了可以随时把整个 venv 目录删掉重来。

2.2 pip install pygame 卡住或报错怎么办

我列一个常见的错误对照表,都是我实际踩过或帮人排查过的:

现象常见原因处理办法
pip install pygame提示 externally-managed-environment系统级 Python 受 PEP 668 约束,不想用系统环境使用 venv 虚拟环境后再安装
安装过程很慢或卡住包源下载速度慢使用国内镜像源,如python -m pip install pygame -i https://pypi.tuna.tsinghua.edu.cn/simple
import pygame导入失败有本地文件也叫pygame.py,恰好躺在当前目录里检查项目目录和当前工作目录,不要用包名命名自己的脚本
启动示例时黑屏或报 no available video device无显示器环境正常桌面开发不会遇到;远程服务器测试可设置SDL_VIDEODRIVER=dummy,但运行游戏还是要本地桌面

装好之后怎么确认有效?两条命令:

python -m pip show pygame python -m pygame --version

能看到版本号就说明环境已经通了。PyGame 官方还带了一堆示例程序,想快速检验安装是否完整,可以执行python -m pygame.examples.aliens,能跑起来一个太空射击游戏,说明你的音视频模块都正常。

2.3 VS Code 里调试时容易被忽略的细节

用 VS Code 写 Python 的朋友,装完 pygame 后最容易犯的错是忘记选解释器。你辛辛苦苦在虚拟环境里装了包,结果 VS Code 右上角还指着全局 Python,运行时当然会报ModuleNotFoundError: No module named 'pygame'。

解决方式很简单,打开命令面板,找到“Python: Select Interpreter”,选择你创建目录下的venv。如果你习惯用启动配置,也可以在.vscode/launch.json里显式指定。这个步骤不复杂,但一旦忘了,报错信息会误导你以为是 pygame 本身装坏了。

3. 从零到可运行:一版直白的贪吃蛇代码拆解

环境弄好之后,我通常建议先不做任何封装,用一个单文件脚本把游戏跑通。这一节代码可以直接复制运行,适合作为第一版。

3.1 先搭窗口、帧率和全局常量

import pygame import random pygame.init() WIDTH, HEIGHT = 600, 600 CELL_SIZE = 25 GRID_W, GRID_H = WIDTH // CELL_SIZE, HEIGHT // CELL_SIZE BG_COLOR = (25, 25, 35) SNAKE_COLOR = (46, 204, 113) FOOD_COLOR = (231, 76, 60) TEXT_COLOR = (255, 255, 255) screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("贪吃蛇 - PyGame") clock = pygame.time.Clock() font = pygame.font.SysFont("Arial", 30) snake = [(GRID_W // 2, GRID_H // 2)] direction = (1, 0) food = (GRID_W // 2 + 3, GRID_H // 2) score = 0 running = True

这段代码本身没有逻辑,全部是基础设施。pygame.init()会把 SDL 需要的基础模块初始化好,pygame.display.set_mode创建窗口,Clock用来控制帧率。蛇用列表存,初始放在棋盘正中央;方向先给一个向右向量,避免开局就是零向量导致蛇不会动。

3.2 食物生成与基础绘制函数

def create_food(snake_body): while True: pos = (random.randrange(GRID_W), random.randrange(GRID_H)) if pos not in snake_body: return pos def draw_snake(snake_body): for idx, (px, py) in enumerate(snake_body): rect = pygame.Rect( px * CELL_SIZE, py * CELL_SIZE, CELL_SIZE, CELL_SIZE ) if idx == 0: pygame.draw.rect(screen, (30, 200, 110), rect) else: pygame.draw.rect(screen, SNAKE_COLOR, rect) def draw_food(food_pos): cx = food_pos[0] * CELL_SIZE + CELL_SIZE // 2 cy = food_pos[1] * CELL_SIZE + CELL_SIZE // 2 pygame.draw.circle(screen, FOOD_COLOR, (cx, cy), CELL_SIZE // 2 - 2)

食物生成必须考虑一个特殊情况:新生成的位置不能落在蛇身上,否则食物就会直接嵌在蛇的身体里,看起来非常难受。create_food里的while循环就是这样工作的,虽然理论上可能循环多次,但这个棋盘只有几百个格子,实际执行时几乎不会卡顿。

绘制蛇的时候,我给蛇头单独用了一个更亮的绿色,方便玩家随时看清蛇头朝向。食物画成圆,比画方块亲切一点。这里要注意像素坐标和网格坐标不能混用。很多新手把snake列表里的坐标直接传给pygame.draw.rect,矩形就画到了窗口外面的某个像素位置上,看起来就像是蛇瞬间消失了。

3.3 主循环:事件、更新、绘制三件事

while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN: if event.key == pygame.K_UP and direction[1] != 1: direction = (0, -1) elif event.key == pygame.K_DOWN and direction[1] != -1: direction = (0, 1) elif event.key == pygame.K_LEFT and direction[0] != 1: direction = (-1, 0) elif event.key == pygame.K_RIGHT and direction[0] != -1: direction = (1, 0) head = snake[0] new_head = ( head[0] + direction[0], head[1] + direction[1] ) if new_head[0] < 0 or new_head[0] >= GRID_W or new_head[1] < 0 or new_head[1] >= GRID_H: draw_game_over() break snake.insert(0, new_head) if new_head == food: score += 1 food = create_food(snake) else: snake.pop() if new_head in snake[1:]: draw_game_over() break screen.fill(BG_COLOR) draw_food(food) draw_snake(snake) score_surface = font.render(f"Score: {score}", True, TEXT_COLOR) screen.blit(score_surface, (10, 10)) pygame.display.flip() clock.tick(10)

主循环的核心就是三件事:先处理玩家输入,再更新游戏状态,最后把画面画出来。顺序不能反。如果你先刷新画面再处理输入,按键至少会延迟一帧,体感上就是“手按了但蛇没反应”。

这里帧率设成 10,意味着每秒最多跑 10 帧,每一帧蛇移动一次,看起来是缓慢爬行的效果。对初版足够用了,后面我会说更灵活的调速方式。

3.4 碰撞判定和死亡画面重构

上面代码里我直接写了draw_game_over()这个函数,它负责在蛇撞墙或咬到自己之后,先渲染一个结束画面,再退出循环:

def draw_game_over(): screen.fill(BG_COLOR) text1 = font.render("GAME OVER", True, FOOD_COLOR) text2 = font.render(f"Final Score: {score}", True, TEXT_COLOR) screen.blit(text1, (WIDTH // 2 - text1.get_width() // 2, HEIGHT // 2 - 60)) screen.blit(text2, (WIDTH // 2 - text2.get_width() // 2, HEIGHT // 2)) pygame.display.flip() pygame.time.wait(1800)

这里有个细节:如果直接running = False退出循环,程序会立刻结束,玩家根本看不到自己的分数。所以先用pygame.time.wait让结束画面停留 1.8 秒,给玩家一个缓冲时间,再退出。很多我见过的初学者版本都忽略了这个“死亡反馈”,游戏倒是逻辑对,玩起来却像闪退了一样。

4. 手感调优:方向缓存、逻辑帧与边界方案

第一版跑通之后,你很快会发现手感有点别扭。最典型的是两个问题:方向按键偶尔按反;玩久了觉得速度一成不变,缺少压迫感。这两个都可以通过调整主循环的结构解决。

4.1 为什么高速连按时蛇会反向反直觉

我用一个真实操作来说明。假设蛇正在向右爬,玩家想快速接一个向上,于是在很短的间隔里依次按下“上”和“左”。如果每个事件处理都直接改direction,事件处理顺序是先上后左。可问题是,按下“上”之后,这一帧的移动可能已经执行了,蛇头确实转向了上方;但紧接着“左”事件也被处理了,看起来蛇只是往上拐了一点点又横着往左,整个路径就很像蛇被强行掰了一下。

还有一种更容易出 bug 的情况:在当前帧的碰撞检测之后才收到方向键,玩家会感觉按键失效。解决办法是增加一个“待生效方向”的变量:

next_direction = direction # 在事件循环里只改 next_direction if event.key == pygame.K_UP and direction[1] != 1: next_direction = (0, -1) elif event.key == pygame.K_DOWN and direction[1] != -1: next_direction = (0, 1) elif event.key == pygame.K_LEFT and direction[0] != 1: next_direction = (-1, 0) elif event.key == pygame.K_RIGHT and direction[0] != -1: next_direction = (1, 0) # 在真正移动蛇头时,才把 next_direction 赋给 direction direction = next_direction head = snake[0] new_head = (head[0] + direction[0], head[1] + direction[1])

这样按键处理只登记“玩家想往哪走”,真正改变方向是在移动发生的那一瞬间。快速连按也不会出现一帧内多个方向互相干扰的情况。另一个重要的判断点是“禁止掉头”。上面的代码里,判反向用的还是direction而不是next_direction,这是正确的:只要当前实际运动方向是向右,你按下左键就被忽略,哪怕当前帧还没走到那一步,也不允许提前把方向缓存成反向。

4.2 用“移动间隔”而不是直接调 FPS 控制速度

第一版里clock.tick(10)把渲染帧率和移动速度绑在了一起。这样做有一个问题:当你把FPS调到 30 以上来追求画面流畅时,蛇的移动速度也会一起飙升,瞬间变成火箭。而把FPS调低,画面又会一卡一卡的。

更好的做法是把渲染帧率和逻辑帧拆开。固定渲染帧率,比如 60,保证画面刷新稳定;移动速度单独用一个“移动间隔”来控制:

move_interval = 100 # 每 100 毫秒移动一次,也就是每秒 10 格 accumulator = 0 while running: dt = clock.tick(60) accumulator += dt while accumulator >= move_interval: # 这里执行方向更新、蛇头移动、碰撞检测 step_logic() accumulator -= move_interval draw_screen()

clock.tick(60)返回的是上一帧消耗的毫秒数,accumulator不断累加,累加够了就走一步逻辑。如果你想让蛇从每秒 10 格变成每秒 12 格,直接算1000 / 12 ≈ 83毫秒,把move_interval改成 83 就行。这个方案的好处是,哪怕你电脑偶尔卡一下掉到 40 帧,蛇的移动速度也不会突然变慢,因为累积时间是按照真实流逝时间计算的。这也是很多成熟游戏引擎里常见的时间步进思路。

4.3 撞墙死亡还是穿墙循环

第一版默认撞墙就结束,代码是连续的坐标上下界判断。你也可以做穿墙模式,让蛇从右边出去、左边回来,类似经典的手机版贪吃蛇。

穿墙模式的移动计算改动很小,把“直接加方向向量”改成取模运算:

grid_w = GRID_W grid_h = GRID_H new_head = ( (head[0] + direction[0]) % grid_w, (head[1] + direction[1]) % grid_h )

但穿墙模式有一个需要注意的副作用:蛇头瞬间传送到对面时,如果对面格子上正好有自己身体,还是会被判定为撞到身体。这个逻辑是合理的,玩家需要习惯这种“空间跳跃可能会撞到自己”的规则。我个人更喜欢穿墙模式,因为教学演示时它能让玩家把注意力集中在“转向”这个核心操作上,不需要反复因为撞墙重开。

5. 从脚本到骨架:把简单游戏整成可维护的结构

当你决定往这个项目里加菜单、加最高分、加音效时,单文件脚本会越来越难维护。这时候就需要把蛇、食物、游戏状态拆成独立的类。

5.1 用 Snake 类把方向缓存封装进去

先处理最核心的蛇。把上一节的方向缓存逻辑封装进类里:

class Snake: def __init__(self, start_pos): self.body = [start_pos] self.direction = (1, 0) self.next_direction = (1, 0) def head(self): return self.body[0] def should_turn(self, new_dir): # 掉头检查:新方向和当前方向相加不能为零向量 if new_dir[0] + self.direction[0] == 0 and new_dir[1] + self.direction[1] == 0: return False return True def set_direction(self, new_dir): if self.should_turn(new_dir): self.next_direction = new_dir def move(self, grow=False): self.direction = self.next_direction hx, hy = self.body[0] new_head = (hx + self.direction[0], hy + self.direction[1]) self.body.insert(0, new_head) if not grow: self.body.pop() return new_head def collides_with_self(self): return self.body[0] in self.body[1:]

set_direction只负责登记方向,move才真正改变蛇头坐标。这样你把Snake类拿出去,直接喂给它“玩家按了什么键”“这次吃没吃到食物”,它就能自己驱动蛇移动,游戏的其他部分不掺和蛇的内部细节。

5.2 用 Game 类管理状态流转

游戏往往有多种状态:开始菜单、运行中、暂停、死亡。用字符串常量或者定义一组常量都可以,好处是事件处理不再散落在主循环里。

class Game: def __init__(self): self.state = "ready" # ready / running / paused / dead self.snake = Snake((GRID_W // 2, GRID_H // 2)) self.food = create_food(self.snake.body) self.score = 0 self.best = load_best_score() def toggle_pause(self): if self.state == "running": self.state = "paused" elif self.state == "paused": self.state = "running" def restart(self): self.__init__() def update(self): if self.state != "running": return head = self.snake.move(grow=False) # 按实际逻辑再判断是否增长

update里先判断state,不是运行状态就直接不更新。这样你按空格暂停时,蛇会定在原地,画面不会被继续推进;按 R 重开时,直接调restart()把所有状态归零。这种状态机的思路,比在 while 循环里写一堆if嵌套要清晰得多。

5.3 把常量提取到单独的配置文件

游戏里的窗口大小、格子尺寸、颜色、移动间隔这些散落在各处的“魔法数”,适合统一放到一个constants.py里。

WIDTH, HEIGHT = 600, 600 CELL_SIZE = 25 GRID_W, GRID_H = WIDTH // CELL_SIZE, HEIGHT // CELL_SIZE BASE_MOVE_INTERVAL = 100 SPEED_UP_PER_FOOD = 5 BG_COLOR = (25, 25, 35) SNAKE_COLOR = (46, 204, 113) FOOD_COLOR = (231, 76, 60) TEXT_COLOR = (255, 255, 255)

为什么要单独放?因为后期调参太频繁。比如你想把窗口从 600 改成 800,格子从 25 改成 20,如果颜色和窗口大小混在一堆代码里,你至少要全局搜索三遍。单独文件一改,其他模块只要from constants import *就行。这个习惯放到任何 Python 项目里都不过时。

5.4 最高分存档:JSON 文件就够了

加入最高分不需要数据库,一个 JSON 文件完全够用。玩家每局结束,如果当前分数大于历史最高分,就写回文件:

import json def load_best_score(): try: with open("best_score.json", "r", encoding="utf-8") as f: return json.load(f).get("best", 0) except FileNotFoundError: return 0 def save_best_score(score): with open("best_score.json", "w", encoding="utf-8") as f: json.dump({"best": score}, f, ensure_ascii=False)

每次界面渲染时,除了画当前分数,再把最高分显示在角落。玩家看到“历史最高 32,我这次只拿 15”,重玩的动力就会更强。对于 PyGame 项目的学习者来说,这也是第一次接触本地数据持久化,虽然只是最简单的读写,但已经把“游戏状态保存”这个概念落地了。

6. 继续玩下去的进阶方向与我的三条经验

贪吃蛇项目最讨喜的一点是:它做完了基础版本,立刻能往很多方向延展,不会让你陷入“不知道下一步做什么”的空档。我提几个我自己尝试过的方向。

6.1 难度曲线与食物分数变化

一个让人长期玩的贪吃蛇,难度应该随着得分提升。最简单的设计是每吃一个食物,移动间隔缩短几毫秒。我前面给过一个公式感:基础移动间隔 100 毫秒,每吃 5 个食物再缩短 5 毫秒,这样前中期体验顺滑,后期逐渐紧张。

更进阶的做法是引入“特殊食物”。普通食物加 1 分,稀有食物只出现几秒钟,吃到加 5 分。实现上也不复杂,维护一个special_food和它剩余的生命周期,每帧把生存时间减掉,时间到就隐藏。这个功能能明显增加游戏的操作深度,玩家会主动思考“要不要冒风险绕路去吃那个特殊食物”。

6.2 做一个能看到全局的 AI 蛇

如果你想挑战一点算法内容,可以给游戏写一个自动模式。最简单的 AI 是贪心判断:在蛇头四周找出所有可行方向,排除撞墙和撞到自己身体的选择,然后选一个让蛇头与食物曼哈顿距离最近的方向。

曼哈顿距离的计算方式是:

distance = abs(food_x - head_x) + abs(food_y - head_y)

这个策略在小棋盘上表现尚可,但蛇身变长后容易把自己困死,因为贪心算法只看下一步,不考虑整条通路的连通性。更好的做法是用广度优先搜索判断有没有能到食物的路径,如果路径不存在,就换成“追着尾巴走”的保守策略。我建议你先写贪心版本,跑起来观察它的失误点,再决定要不要碰 BFS,这种由简入繁的节奏,比直接抄一段智能寻路代码更能学到东西。

6.3 把项目当成教学 Demo 时,代码注释的边界

很多新手写代码喜欢每行都加注释,结果是代码没看明白,注释先被水淹没。我一般遵循这样的原则:写清楚“为什么这么做”,而不是“做了什么”。比如:

# 新蛇头如果落在蛇身其他位置,说明蛇咬到了自己 if new_head in snake.body[1:]: game_over = True

这是说明意图的注释。而类似“把变量 score 加一”的注释,一行都没有必要。用在教学场景,这个原则还能帮助初学者学会如何提炼注释的重点。

6.4 如果让我再重写一遍,我会注意什么

如果从头再写一遍这个项目,我会先定好网格和状态机,再动手写主循环,而不是先写一堆函数再回头搭框架。网格决定了场景单位,状态机决定了游戏什么时候做什么,这两件事定了,其他代码都只是往里填积木。那次我中途重构才把方向缓存和逻辑帧拆开,其实拆起来并不难,但如果不拆,后面加音效、加暂停、加最高分的时候,每次都会在原脚本上打补丁,代码很快就看不懂了。

所以我对这个项目的最终建议是:第一版怎么粗暴都行,能跑就是胜利;但跑通之后,一定给自己留一点重构的时间。把 Snake 独立成一个类,把游戏状态单独管起来,把可变参数放进配置文件。这二十来分钟的重构,会让你的贪吃蛇从一个“能跑的脚本”,变成一个“能继续生长的项目”。

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

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

立即咨询