ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

PyGame贪吃蛇实战:从网格逻辑到类结构重构

2026/10/1 3:31:11 拓冰建站 浏览量
PyGame贪吃蛇实战:从网格逻辑到类结构重构 我最早接触 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”这个选项重装一次勾上就行。实际执行安装命令时更推荐用模块方式而不是直接敲 pippython -m pip install pygame这样能避免多版本 Python 环境下pip指到别的解释器的问题。Python 3.11、3.12 这些版本目前都能正常安装 pygame。另一个建议是直接用虚拟环境。新手会觉得虚拟环境麻烦但如果你一段时间里要在好几个 Python 项目之间切换包版本它真的能救命。在项目目录里执行python -m venv venv然后 Windows 上激活venv\Scripts\activatemacOS 或 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/simpleimport pygame导入失败有本地文件也叫pygame.py恰好躺在当前目录里检查项目目录和当前工作目录不要用包名命名自己的脚本启动示例时黑屏或报 no available video device无显示器环境正常桌面开发不会遇到远程服务器测试可设置SDL_VIDEODRIVERdummy但运行游戏还是要本地桌面装好之后怎么确认有效两条命令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(fScore: {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(fFinal 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, growFalse): 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(growFalse) # 按实际逻辑再判断是否增长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, encodingutf-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, encodingutf-8) as f: json.dump({best: score}, f, ensure_asciiFalse)每次界面渲染时除了画当前分数再把最高分显示在角落。玩家看到“历史最高 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 独立成一个类把游戏状态单独管起来把可变参数放进配置文件。这二十来分钟的重构会让你的贪吃蛇从一个“能跑的脚本”变成一个“能继续生长的项目”。