
简介基于Python与pygame库开发的贪吃蛇游戏代码包面向Python初学者、游戏开发入门者以及想巩固面向对象编程的读者完整演示2D小游戏的开发流程。资源共20个文件压缩包约5.04MB内含12个Python脚本覆盖主程序、游戏状态、蛇与食物管理、方向控制、音效管理等模块另有ogg背景音乐与音效、中文字体及说明文档不同文件类型分工明确便于对照运行与二次修改。已有283人下载学习。通过研究项目代码可掌握pygame窗口初始化、蛇身移动与增长、随机食物生成、边界与自身碰撞判定、键盘控制与音效播放等实现手法同时体会模块化设计与类封装带来的代码组织优势适合边阅读边动手改造为后续开发更复杂的游戏打下基础。1. 拆开这个 pygame 贪吃蛇不是入门 demo是工程结构样本网上绝大多数 pygame 贪吃蛇教程都是把主循环、蛇身坐标、食物生成、碰撞检测全塞到一个snake.py里能跑但改起来头疼。这个项目不一样压缩包解开后你能看到core/、libs/、res/三层结构游戏状态、蛇体逻辑、方向管理、食物刷新、音效播放、防多开锁全部独立成模块res/里还带了中文字体和四段音频资源。对刚写完单文件版贪吃蛇、想看看工程化怎么拆的人或者需要交一份像样的 Python 课设 / 期末项目的人这是一个可以直接抄结构的样本。下面按「跑起来 → 主循环 → 玩法实现 → 资源细节」的顺序拆。2. 目录结构与运行环境pygame 安装、报错和入口脚本2.1 先读目录再读代码解压myPySnake-code.zip后目录结构对应关系如下路径职责game.py程序入口初始化 pygame、创建窗口、跑主循环constants.py窗口宽高、背景色、帧率、分数等全局常量core/game_state.py游戏状态机运行中、暂停、结束core/game_board.py游戏区域边界与绘制core/snake.py蛇的坐标序列、移动、增长、自撞检测libs/direction_manager.py方向键输入处理防止反向移动libs/food_manager.py食物坐标生成与刷新libs/sound_manager.py背景音乐与吃东西 / 结束音效的播放封装libs/lock.py进程锁防止游戏多开libs/sprite.py简单精灵基类统一draw接口res/Kaiti_GB2312.ttf中文字体、background.mp3、eat.ogg、cheer.ogg、gameover.ogg这个拆分思路值得直接搬到自己项目里凡是会变的东西常量放一个文件凡是独立行为移动、生成、播声音封装成类。注意res/里的背景音乐是.mp3音效是.ogg这个后面在sound_manager里会有不同的加载方式。2.2 pygame 安装与两个高频报错运行这个项目的最小依赖只有一个 pygame。建议先建虚拟环境再装python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install pygame python game.py如果pip install pygame直接报error: failed to build pygame when getting requirements to build wheel说明 pip 拿到的不是预编译的 wheel 包而是试图从源码构建。这在 Python 3.12 之后比较常见解决方式有两种# 强制只使用预编译 wheel pip install --only-binary :all: pygame # 或者降级到官方渠道的 Python 3.10 / 3.11装完之后别急着跑游戏先用 pygame 自带的一个 Demo 验证环境是否正常python -m pygame.examples.aliens能弹出外星人射击窗口说明 SDL 库、显示器和音频设备都工作正常。在 VSCode 里跑的话记得先让 VSCode 选中.venv这个解释器否则终端里python指向的还是全局环境。2.3 入口脚本 game.py 的启动逻辑game.py的结构是典型的 pygame 入口初始化 → 加载资源 → 进入主循环 → 退出清理。核心骨架如下import pygame from constants import WINDOW_WIDTH, WINDOW_HEIGHT, FPS from core.game_state import GameState from core.snake import Snake from core.game_board import GameBoard from libs.direction_manager import DirectionManager from libs.food_manager import FoodManager from libs.sound_manager import SoundManager def main(): pygame.init() screen pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) pygame.display.set_caption(贪吃蛇 - pygame) clock pygame.time.Clock() state GameState() snake Snake() board GameBoard(WINDOW_WIDTH, WINDOW_HEIGHT) food FoodManager(board) direction DirectionManager() sound SoundManager(res/) running True move_timer 0 while running: dt clock.tick(FPS) move_timer dt for event in pygame.event.get(): if event.type pygame.QUIT: running False if state.is_running() and move_timer snake.move_interval: direction.apply(snake) if board.hit_wall(snake.head()) or snake.hits_self(): state.game_over() sound.play_gameover() elif snake.head() food.position: snake.grow() state.add_score(10) food.respawn(snake.body) sound.play_eat() snake.speed_up() move_timer 0 screen.fill((18, 22, 28)) board.draw(screen) food.draw(screen) snake.draw(screen) pygame.display.flip() pygame.quit() if __name__ __main__: main()这段代码有几处值得说明。clock.tick(FPS)返回的是上一帧到现在的毫秒数用它累加到move_timer里而不是每帧都让蛇动一下——这样蛇的移动节奏和画面刷新帧率就解耦了即使 FPS 从 60 掉到 30蛇的移动速度也不会变快。move_interval初始值在snake.py里定义常见设置为 150ms每吃一个食物减小一点下限一般控制在 80ms 左右太快人眼就反应不过来了。pygame.display.flip()负责把绘制好的内容一次性提交到屏幕pygame 默认是双缓冲模式所有draw调用都先画在后备缓冲区里flip()时才交换显示。pygame.quit()放在主循环外保证退出时 SDL 的音频和显示资源能被正确释放。另外注意event.type pygame.QUIT只处理了窗口右上角的关闭按钮ESC退出其实应该也在KEYDOWN分支里处理这个项目里键盘事件的实际处理比上面这段骨架更细它的完整逻辑放在direction_manager里做了下一章展开。3. 游戏循环与状态管理方向锁、帧率节拍和按键事件3.1 GameState少用散落的布尔变量很多贪吃蛇代码里会看到game_over False、paused False这样散落的标志位一旦逻辑变复杂经常会漏复位。core/game_state.py的做法是把游戏流程抽象成一个状态机常见实现如下from enum import Enum class GameState: class State(Enum): RUNNING 1 PAUSED 2 GAME_OVER 3 def __init__(self): self.state self.State.RUNNING self.score 0 def is_running(self): return self.state self.State.RUNNING def toggle_pause(self): if self.state self.State.RUNNING: self.state self.State.PAUSED elif self.state self.State.PAUSED: self.state self.State.RUNNING def game_over(self): self.state self.State.GAME_OVER def add_score(self, points): self.score points枚举定义状态而不是用魔法数字或字符串这样在别的模块里引用时拼写错误会在运行前就被发现。is_running()在game.py的主循环里作为闸门使用只有RUNNING状态下才推进蛇的移动和食物判定PAUSED时画面冻结GAME_OVER时则只允许按键选择重新开始或退出。score也放在状态类里而不是主循环的局部变量是因为分数需要在多个地方访问——渲染得分文本、吃到食物时累加、游戏结束时展示集中管理比到处传参干净。当前状态触发条件迁移目标RUNNING按空格键常见做法PAUSEDPAUSED按空格键RUNNINGRUNNING撞墙 / 咬到自己GAME_OVERGAME_OVER按回车键RUNNING重置蛇和食物状态机的好处是你永远不会出现在GAME_OVER状态下蛇还在走的情况因为move_timer那一段代码只有在is_running()为真时才会执行。3.2 DirectionManager为什么单独用一个类管理方向这是单个文件里实现起来最容易出 bug 的部分。贪吃蛇不允许 180 度掉头——蛇头朝右的时候按左键蛇会直接穿过自己的脖子。常见的错误写法是直接在按键事件里改全局direction变量if event.key pygame.K_LEFT and direction ! (1, 0): direction (-1, 0)这样做的问题在于如果蛇头朝右玩家在这一帧里同时按下「上」和「左」两个按键事件会按顺序处理结果可能变成先向上、再向左而向左是非法方向却被第二条判定漏过去了。项目中libs/direction_manager.py解决这个问题的思路是不直接改方向而是缓存新方向在蛇真正移动前做合法性校验import pygame class DirectionManager: def __init__(self): self.direction (1, 0) def handle_key(self, key): candidates { pygame.K_w: (0, -1), pygame.K_a: (-1, 0), pygame.K_s: (0, 1), pygame.K_d: (1, 0), pygame.K_UP: (0, -1), pygame.K_DOWN: (0, 1), pygame.K_LEFT: (-1, 0), pygame.K_RIGHT: (1, 0), } if key in candidates: self.direction self._resolve(candidates[key]) def _resolve(self, new_dir): dx new_dir[0] self.direction[0] dy new_dir[1] self.direction[1] if (dx, dy) (0, 0): return self.direction return new_dir_resolve里的核心技巧是向量相加如果新方向和当前方向是相反方向两个分量相加必然得到(0, 0)此时拒绝本次输入。其余情况全部合法。这样在同一个事件循环里先按上、再按左最终方向取决于最后一次合法请求而不会因为按键顺序导致蛇反向移动。game.py里对应的事件处理是elif event.type pygame.KEYDOWN: if event.key pygame.K_ESCAPE: running False elif event.key pygame.K_f: pygame.display.toggle_fullscreen() else: direction.handle_key(event.key)F键这里调用的是pygame.display.toggle_fullscreen()它是 pygame 2.0 之后推荐的窗口/全屏切换方式不需要重新创建 surface比旧的set_mode方案性能开销小。注意在 macOS 上这种方式有时会闪一下黑屏这是 SDL 的视频驱动行为不是代码问题。3.3 移动节拍帧率和速度是两回事前面game.py骨架里已经出现了move_timer的用法这里说下参数设计和调优方向参数推荐初始值调整逻辑FPS60恒定值控制刷新率move_interval150ms每吃一个食物减 5ms下限 80ms蛇身每段尺寸20px和网格对齐网格宽高根据窗口大小计算用整数除法让蛇身刚好卡进格子移动节拍用毫秒累加而不是帧计数原因在于clock.tick(FPS)的返回值受系统负载影响当 CPU 紧张时帧率掉到 40用帧计数的话蛇会同步变慢体验不好。用毫秒累加则保证蛇每 150ms 无条件动一格哪怕画面掉帧也只是视觉上跳变速度感不会变。要把 FPS 显示到窗口标题栏辅助调参可以这样pygame.display.set_caption(f贪吃蛇 - {clock.get_fps():.0f} FPS)get_fps()返回的是最近一秒的平均帧率比自己在循环里数帧准确得多。4. 蛇身坐标、食物生成与碰撞判定玩法逻辑的代码实现4.1 Snake 用列表存坐标头插尾删core/snake.py的核心数据结构是一个坐标列表每个元素是(x, y)网格坐标下标 0 是蛇头class Snake: def __init__(self, start_pos(5, 5), length3): self.body [(start_pos[0] - i, start_pos[1]) for i in range(length)] self.move_interval 150 self.grow_pending False def head(self): return self.body[0] def move(self, direction): dx, dy direction head self.body[0] new_head (head[0] dx, head[1] dy) self.body.insert(0, new_head) if self.grow_pending: self.grow_pending False else: self.body.pop() def grow(self): self.grow_pending True def hits_self(self): return self.head() in self.body[1:]move()的「头插尾删」是贪吃蛇的标准做法insert(0, new_head)把新头放进去pop()删掉尾巴蛇身长度保持不变。grow()并没有直接让蛇变长而是置一个grow_pending标志等下一次move()时插了头不删尾长度自然增加 1。用标志位延迟增长的好处是增长逻辑和移动逻辑解耦——吃食物的那一帧蛇头已经和食物重叠了如果先grow()再move()新身体段会出现在错误的位置。hits_self()用self.head() in self.body[1:]切片排除蛇头自身因为头永远在列表里直接判断会永远为真。判断是从头到尾逐个比较坐标元组蛇身长度超过几百后会有轻微性能消耗但贪吃蛇场景下蛇身一般不超过几十段完全不需要优化成哈希表。4.2 食物生成避开蛇身而不是随机硬塞libs/food_manager.py的责任是生成不在蛇身上的随机坐标import random class FoodManager: def __init__(self, board): self.board board self.position None self.respawn([]) def respawn(self, snake_body): while True: x random.randint(0, self.board.cols - 1) y random.randint(0, self.board.rows - 1) if (x, y) not in snake_body: self.position (x, y) breakrespawn接受蛇身坐标列表作为参数用while True循环生成随机点直到避开蛇身。当蛇身占满接近整个棋盘时这个循环会退化但实际游戏里蛇长到那种程度玩家早就撞墙了可以忽略。random.randint的上下界用了board.cols - 1也就是棋盘坐标从 0 开始计数和蛇身坐标的起始值保持一致避免食物出现在边界外。如果想让食物出现的位置更「公平」可以记录连续没出现过的格子但这在贪吃蛇里属于过度设计随机生成 避让蛇身已经足够。4.3 三种碰撞的判定与处理碰撞检测分散在三个地方这是模块划分的结果判定方式和处理动作如下碰撞类型判定位置判定条件处理动作撞墙game_board.pyhead().x 0等越界判断状态切到 GAME_OVER咬自己snake.py蛇头坐标在body[1:]中状态切到 GAME_OVER吃到食物game.py主循环蛇头坐标等于食物坐标加分 蛇身 1 重开食物game_board.py里的撞墙判断class GameBoard: def __init__(self, width, height, tile_size20): self.cols width // tile_size self.rows height // tile_size self.width width self.height height def hit_wall(self, head): x, y head return x 0 or x self.cols or y 0 or y self.rows def draw(self, screen): pygame.draw.rect( screen, (40, 44, 52), pygame.Rect(0, 0, self.width, self.height), 1 )cols和rows用整数除法算出来保证蛇头坐标永远不会落在窗口像素边界上。hit_wall只判断蛇头不判断蛇身——蛇身跟着蛇头走不可能蛇身出界而蛇头在界内。pygame.draw.rect的最后一个参数1是边框宽度只画一个外框不填充内部这样游戏区域和窗口边缘有清晰的视觉边界。4.4 分数、音效和速度联动的时序回到game.py主循环里那一段elif snake.head() food.position: snake.grow() state.add_score(10) food.respawn(snake.body) sound.play_eat() snake.speed_up()执行顺序是有讲究的。先grow()再respawn()respawn需要拿到增长后的蛇身坐标否则新食物可能刷在刚长出来的那一节身体上。speed_up()放在最后因为它修改move_interval只影响下一帧的移动判定不影响当前帧玩家已经看到的画面。libs/sound_manager.py的实现大概是这样的import pygame class SoundManager: def __init__(self, res_dir): self.res_dir res_dir self.eat_sound pygame.mixer.Sound(f{res_dir}/eat.ogg) self.cheer_sound pygame.mixer.Sound(f{res_dir}/cheer.ogg) self.over_sound pygame.mixer.Sound(f{res_dir}/gameover.ogg) pygame.mixer.music.load(f{res_dir}/background.mp3) pygame.mixer.music.play(-1) def play_eat(self): self.eat_sound.play() def play_cheer(self): self.cheer_sound.play() def play_gameover(self): self.over_sound.play()eat.ogg和cheer.ogg都是短音效用pygame.mixer.Sound加载可以叠加播放——蛇连续吃到食物时前一个音效还没播完后一个play()调用会直接叠加不会相互打断。背景音乐是长音频用pygame.mixer.music加载play(-1)的-1表示无限循环播放过程中不会被短音效打断。短音效用的是.ogg格式因为 OGG 的编解码开销比 MP3 小适合需要低延迟播放的交互音效背景音乐用 MP3体积压缩率更高。5. 中文字体加载与防多开锁这个项目藏起来的两个细节5.1 用随包字体解决中文乱码pygame 的默认字体不支持中文pygame.font.SysFont(simhei, 24)在 Windows 上能用但同样的代码放到没有中文字体的 Linux 服务器上就直接方块字。这个项目的做法是把Kaiti_GB2312.ttf放进res/目录随代码分发import pygame import os font_path os.path.join(res, Kaiti_GB2312.ttf) font pygame.font.Font(font_path, 24) score_text font.render(f得分: {state.score}, True, (255, 255, 255)) screen.blit(score_text, (10, 10))pygame.font.Font(path, size)直接加载字体文件不依赖操作系统安装了哪些字体因此任何环境跑出来的渲染效果都一致。font.render(..., True, color)的第二个参数是抗锯齿开关True 时文字边缘平滑缺点是每个字体的渲染耗时大概是关抗锯齿的两倍但得分文本每帧最多 render 一次性能开销可忽略。字体文件随包分发还有一个好处你可以直接换字体文件来改游戏整体视觉风格不用改任何代码。5.2 lock.py防止游戏窗口多开游戏多开会导致多个窗口同时监听键盘事件键盘焦点在哪个窗口上哪个就响应体验很混乱。libs/lock.py用文件锁阻止第二个实例启动Linux/macOS 下的常见实现import os import fcntl LOCK_PATH /tmp/mysnake_single_instance.lock def acquire_lock(): lock_fd open(LOCK_PATH, w) try: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: return None return lock_fd def release_lock(lock_fd): if lock_fd: fcntl.flock(lock_fd, fcntl.LOCK_UN) lock_fd.close()LOCK_EX | LOCK_NB表示获取写锁且不阻塞如果第一个实例已经持有锁第二个实例的flock调用会立即抛BlockingIOError此时直接输出提示并退出。锁文件在进程结束时会自动释放不要主动删除锁文件——因为刚释放锁的瞬间删除文件可能被第三个进程误判为「锁不存在」。game.py调用方式是lock_fd acquire_lock() if lock_fd is None: print(游戏已经运行中不能多开) sys.exit(1) # 主循环... release_lock(lock_fd)如果目标是 Windowsfcntl不存在要换成msvcrt.locking或直接改用 socket 监听本地端口的方式用socket.bind((“127.0.0.1”, 0))来检测重复启动。README 里没有写锁的实现细节但拆过libs/lock.py就能明白这个小文件解决了一个真实存在的交互问题——一节课设展示时不小心把游戏启动了两遍两个窗口抢占键盘的尴尬场面做过一次就不会忘。5.3 音效和字体的加载路径已踩过坑项目里所有资源加载用的是相对路径比较稳妥的做法是用pathlib定位到代码文件所在目录而不是依赖当前工作目录from pathlib import Path BASE_DIR Path(__file__).resolve().parent FONT_PATH BASE_DIR / res / Kaiti_GB2312.ttf MUSIC_PATH BASE_DIR / res / background.mp3Path(__file__).resolve().parent拿到的是game.py所在的绝对路径这样无论你是用python game.py还是从 VSCode 的调试器启动或者双击打包后的快捷方式资源路径都不会断。直接写res/eat.ogg时如果当前工作目录在压缩包外game.py会立刻报pygame.error: Unable to open file而且错误信息不会提示路径拼接问题排查起来很绕。另外如果想把游戏打包成单文件可执行程序res/目录不会被自动打包进.exe需要用 PyInstaller 的--add-data res:res参数把资源文件绑进产物里。这一步做对之后整个项目才算真正交付完成一个.exe双击就能跑字体、音效、背景音乐全在包里不依赖目标机器装什么环境。本文还有配套的精品资源点击获取