很多用 Python 学游戏开发的朋友,第一次让贪吃蛇跑起来的时候,心情都挺激动的,但紧接着往往会撞上同一个尴尬:想把这游戏发给没学过编程的朋友玩,对方不是没装 Python 环境,就是装了依赖之后启动还报错。我自己也经历过这个阶段,后来才意识到,让脚本能跑起来和让脚本能被别人直接用,完全是两码事。所以这篇文章准备把两件事一起讲透:用 pygame 写一个逻辑完整的贪吃蛇,再用 PyInstaller 打包成双击就能运行的 exe。适合想把 Python 练手项目真正做成"能发给别人用"的读者,也适合拿来学习游戏主循环和打包原理的入门者。
1. 为什么"贪吃蛇+打包"这个组合值得认真做一遍
1.1 贪吃蛇不是"幼稚项目",它是状态机的入门课
写贪吃蛇,绕不开"当前游戏处于什么状态、下一帧要变成什么状态"这组问题。蛇的位置是状态,行进方向是状态,是否吃到食物、是否撞墙、是否还活着都是状态。任何大型游戏,背后都是这种状态机逻辑的规模放大版。
把贪吃蛇做透,你对"主循环 + 事件 + 碰撞检测"的理解会直接迁移到后续任何游戏项目上。很多看起来高级的玩法,本质都是在回答几个基础问题:玩家的输入如何影响状态?状态每秒要更新几次?更新之后如何刷新画面?这些恰恰是贪吃蛇每天都在训练的东西。
1.2 补上"打包交付"这一课
大多数自学者长期停留在 IDE 里按运行按钮的阶段,缺少一个关键意识:用户没有 Python 环境,怎么运行你的程序?
打包就是把这个意识变成现实的一步。它能一次性解决 Python 环境、依赖库、资源文件、桌面入口等一堆问题。通过打包,你会被迫去理解解释器、依赖、路径、权限这些平时藏在开发环境背后的东西。这个"被迫理解"的过程,才是提升能力的关键。
我见过不少朋友,写完游戏直接发源码,然后被对方一句"运行不了"搞到崩溃。这真不是代码写得不好,而是缺少"交付"思维。打包课早补早受益。
1.3 技术栈为什么选 pygame
pygame 在 PyInstaller 下有稳定的 hook 支持,打包时依赖收集比较省心;事件循环、按键监听、精灵渲染也都写得很直白,贪吃蛇这套逻辑用 pygame 来写,复杂度刚好不会淹没核心思路。
如果用 tkinter,也不是不能做,但后面想加音效、动画、稍微花哨一点的界面,还得额外想办法。pygame 则什么都齐,做完贪吃蛇还能往上叠更多玩法。如果你希望这个项目不只是"做完就扔",pygame 是更合适的地基。
2. 写代码前先把游戏状态想明白:数据结构、方向缓冲、碰撞与节奏
2.1 蛇身用什么数据结构存最省事
很多初学者一上来就把蛇身做成一组独立对象,每帧挨个移动,代码写得很累,还容易出边界问题。
其实贪吃蛇最优雅的表示方式,就是一个坐标列表:蛇头在索引 0,尾巴在末尾。移动的逻辑非常整齐:"新头部插到列表开头,尾巴从末尾弹掉"。吃到食物的时候保留尾巴,蛇自然就"变长了"。
用代码表达就是:
snake = [(10, 10)] # 蛇头是列表第一个元素 new_head = (10, 11) # 计算出新头部 snake.insert(0, new_head) # 头部插到最前面 if not eat_food: snake.pop() # 没吃到食物就去掉尾巴这个思路理解之后,你会发现不管蛇多长,核心逻辑都只有这几行。列表的插入操作虽然理论上是 O(n),但贪吃蛇长度撑死几百,性能完全不是问题,可读性才是重点。
2.2 方向控制:为什么不能立刻改方向
键盘按下的方向不能马上生效,至少要防一件事:反向。
如果蛇正向右走,玩家突然按左,蛇头会瞬间穿过自己身体,这在任何版本贪吃蛇里都是不合理的。所以需要一个"待处理方向"的缓冲,在更新状态的时刻统一切换。
判断反向不用比对方向名,直接比对坐标向量就行。当前方向是(1, 0),新方向(-1, 0),两者相加等于(0, 0),说明完全相反,直接丢弃。这个方法简单到几乎没有出错空间。
2.3 碰撞、食物、加速,怎么揉进同一帧
游戏每一帧的更新顺序要固定,我习惯这样排:
处理按键事件 → 蛇移动一步 → 检查是否撞墙或撞自己 → 判断是否吃到食物 → 生成新食物 → 更新分数和速度。
先移动再判定,是因为碰撞检测必须基于移动后的新坐标。吃到食物后,新食物不能生成在蛇身上,所以要有"避开蛇身随机生成"的逻辑。分数递增会直接推高帧率,也就是把蛇的移动速度和难度一起提上去。
2.4 游戏主循环的四个固定步骤
pygame 程序的基本骨架就是四步:处理事件、更新状态、绘制画面、控制帧率。
clock.tick(fps)限制的是每秒最多循环多少次。蛇的移动速度其实就等于帧率:每帧走一格,那么 8 FPS 就代表每秒走 8 格。把帧率和难度绑定,代码会特别自然,后面做加速、减速都不用额外设计机制。
这个骨架是所有游戏的心脏。你以后玩过的任何动作游戏、射击游戏、平台跳跃,底层都是这四个步骤的无限循环,只不过状态更新和绘制逻辑更复杂罢了。
3. 完整可运行的 pygame 贪吃蛇,代码与实现细节
3.1 依赖与目录结构
这个项目只需要一个依赖:pygame。安装命令:
pip install pygame目录结构不必复杂,单文件就能搞定:
snake_game/ ├── main.py如果你后面要加图片、音效,可以在同级建assets/文件夹。现在先聚焦逻辑本身。
3.2 完整代码
下面这份代码我实际跑过,直接复制到main.py就能玩。方向键控制移动,Esc 退出,撞墙或撞到自身游戏结束。
import sys import random import pygame # ---- 游戏常量 ---- CELL_SIZE = 20 # 每个格子像素 COLS, ROWS = 30, 20 # 横向 30 格,纵向 20 格 WIDTH, HEIGHT = CELL_SIZE * COLS, CELL_SIZE * ROWS FPS_BASE = 8 # 初始帧率,也就是蛇的初始速度 FPS_STEP = 2 # 每 5 分提升多少速率 # ---- 颜色 ---- BLACK = (20, 20, 20) WHITE = (235, 235, 235) GREEN = (0, 180, 0) GREEN_DARK = (0, 130, 0) RED = (200, 50, 50) # ---- 方向映射 ---- DIRS = { pygame.K_UP: (0, -1), pygame.K_DOWN: (0, 1), pygame.K_LEFT: (-1, 0), pygame.K_RIGHT: (1, 0), } class Snake: def __init__(self): start_x, start_y = COLS // 2, ROWS // 2 self.body = [(start_x, start_y)] self.direction = (1, 0) self.pending_direction = (1, 0) self.grow = False # 吃到食物后置 True,下一帧保留尾巴 def turn_to(self, new_dir): # 禁止直接反向:两个方向向量相加为 (0, 0) 说明方向相反 if (self.direction[0] + new_dir[0], self.direction[1] + new_dir[1]) != (0, 0): self.pending_direction = new_dir def move(self): # 更新方向时,才把缓冲方向写进当前方向 self.direction = self.pending_direction head = self.body[0] new_head = (head[0] + self.direction[0], head[1] + self.direction[1]) self.body.insert(0, new_head) if not self.grow: self.body.pop() else: self.grow = False def hits_wall(self): x, y = self.body[0] return not (0 <= x < COLS and 0 <= y < ROWS) def hits_self(self): return self.body[0] in self.body[1:] def head_pos(self): return self.body[0] def random_food(snake): """生成一个不出现在蛇身上的食物坐标""" while True: pos = (random.randint(0, COLS - 1), random.randint(0, ROWS - 1)) if pos not in snake.body: return pos def draw_cell(surface, color, col, row): # 每个格子留 1 像素空隙,蛇看起来更清爽 pygame.draw.rect(surface, color, (col * CELL_SIZE, row * CELL_SIZE, CELL_SIZE - 1, CELL_SIZE - 1)) def draw_text(screen, text, pos, size=20, color=WHITE): font = pygame.font.SysFont("simhei", size) surface = font.render(text, True, color) screen.blit(surface, pos) def main(): pygame.init() screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("贪吃蛇") clock = pygame.time.Clock() snake = Snake() food = random_food(snake) score = 0 running = True while running: # 1. 处理事件 for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN: if event.key in DIRS: snake.turn_to(DIRS[event.key]) elif event.key == pygame.K_ESCAPE: running = False # 2. 更新世界状态 snake.move() if snake.hits_wall() or snake.hits_self(): break if snake.head_pos() == food: snake.grow = True score += 1 food = random_food(snake) # 3. 绘制画面 screen.fill(BLACK) for i, (col, row) in enumerate(snake.body): color = GREEN if i == 0 else GREEN_DARK draw_cell(screen, color, col, row) draw_cell(screen, RED, food[0], food[1]) draw_text(screen, f"得分: {score}", (10, 10)) pygame.display.flip() # 4. 控制节奏:分数越高帧率越快 fps = FPS_BASE + (score // 5) * FPS_STEP fps = min(fps, 20) clock.tick(fps) # 结束画面 screen.fill(BLACK) draw_text(screen, f"Game Over! 得分: {score}", (WIDTH // 2 - 110, HEIGHT // 2 - 20), size=36) pygame.display.flip() pygame.time.wait(2000) pygame.quit() sys.exit() if __name__ == "__main__": main()3.3 代码里几个值得单独拿出来讲的地方
方向缓冲的设计:turn_to只更新pending_direction,move时才写入direction。这样做的好处是,同一帧内即使玩家连续按了好几个方向键,蛇也不会"瞬移"或出现斜向移动这类 bug。如果不加缓冲、在按键回调里直接改方向,高速连按会让状态变得很难预测。
碰撞对象的选择:hits_self直接判断蛇头坐标是否出现在蛇身列表中。这里要注意,判断的时机必须放在move()之后,因为只有移动完成后的新头部坐标才能代表当前真实位置。
食物生成:random_food用了一个无限循环,直到碰撞不到蛇身。当蛇很短时这个循环基本一遍就出结果;当蛇长得快占满屏幕时,循环次数会上升,但那些极端情况下游戏本来就快结束了。
格子间距:draw_cell里每个格子宽高都减了 1 像素,整条蛇会出现细密的网格线,视觉上比全贴在一起清楚很多。这个小细节不花成本,但对体验的提升很直接。
3.4 运行效果验证
在项目目录下执行:
python main.py理论上你会看到一条绿色小蛇在黑色窗口里匀速前进,吃到红色食物会变长,每 5 分提速一次。如果窗口没出现,多半是 pygame 没装好,或者 Python 版本不对。pygame 2.x 对 Python 3.8 到 3.12 都支持得比较稳,遇到安装报错先去看版本匹配。
4. PyInstaller 打包实战:一条命令之后发生了什么
4.1 打包前先理解 PyInstaller 在干什么
PyInstaller 做的事不是传统意义上的"编译",它是把 Python 解释器、你的脚本、所有依赖库一起塞进一个自包含的目录或者单文件里。运行时,这个 exe 会先把自己解压到一个临时目录,再从内存里启动解释器执行你的 main.py。
理解这一点特别重要,因为后面遇到资源路径问题、安全软件拦截、启动慢,根源都在这里。你只有知道它"解压到临时目录再运行"这个机制,才能解释为什么明明源码里能读到图片,打包后却读不到。
4.2 一条命令完成首次打包
安装 PyInstaller:
pip install pyinstaller进入snake_game目录,执行:
pyinstaller -F -w main.py-F表示打成单个 exe 文件-w表示不显示黑色控制台窗口
执行完后,项目目录下会多出三个东西:main.spec、build/、dist/。你真正要交付的是dist/main.exe,其他都可以不关心。
我给这份打包命令再补一个更完整的版本,顺手把名字和图标设好:
pyinstaller -F -w --name SnakeGame --icon=icon.ico main.py没有icon.ico文件时先把这句里的--icon去掉,默认图标是 PyInstaller 那个,不影响运行。
4.3 打包产物的用途说明
| 产物 | 作用 | 是否交付给用户 |
|---|---|---|
dist/SnakeGame.exe | 最终的独立可执行文件 | 交付 |
build/ | 打包过程中的临时工作目录 | 不交付 |
main.spec | PyInstaller 配置文件,记录打包参数 | 保留,下次重打包用 |
这里有件事值得说明:build/目录虽然占了空间,但它缓存了依赖分析结果。如果你只是改了代码、没换依赖,第二次打包会快很多。不过它体积不小,整理项目时删掉也无所谓,下次自动重新生成。
4.4 常用打包参数速查
| 参数 | 含义 | 什么时候用 |
|---|---|---|
-F | 打包成单个 exe 文件 | 分发给别人,文件干净 |
-D | 打包成目录,内含 exe 和依赖 | 自己本地用,启动更快 |
-w | 隐藏控制台窗口 | GUI 游戏几乎必用 |
--icon=xxx.ico | 设置 exe 图标 | 想让软件更"正经"时 |
--add-data "assets;assets" | 把资源目录打进包 | 代码里读了外部图片/音频文件 |
--exclude-module tkinter | 排除某个用不到的模块 | 压缩体积时 |
注意--add-data在路径分隔符上有平台差异,当前场景用的是分号。如果你以后在类 Unix 系统上打包,要换成冒号。
4.5 验证打包结果
打包完成后,直接双击dist/SnakeGame.exe,应该能看到和源码运行一模一样的游戏窗口。
我有个建议:验证一定要放在没有安装 Python 的干净环境里做,哪怕是用一台虚拟机或者另一个账号临时测一下。因为在开发机上跑,很可能是你本机的 Python 环境"兜了底",掩盖了打包缺失依赖的问题。这个习惯能帮你挡掉很多交付事故。
5. 打包后的四个高频坑:资源路径、安全拦截、闪退、体积
5.1 资源文件在打包后找不到
假设你的游戏加了背景图片和音效,源码里这样读:
image = pygame.image.load("assets/bg.png") audio = pygame.mixer.Sound("assets/bgm.wav")源码运行一切正常,但-F打包后双击 exe,要么黑屏,要么报错"找不到文件"。原因前面说过了:onefile 模式下,程序先把所有文件解压到临时目录,当前工作目录是 exe 所在目录,两者不一致,相对路径自然失效。
解决方法有两个部分。先写一个资源路径函数:
import sys import os def resource_path(relative_path): """兼容打包前后两种运行方式的资源路径""" if hasattr(sys, "_MEIPASS"): # 打包后运行:资源在解压临时目录里 base_dir = sys._MEIPASS else: # 源码运行:资源就在项目目录下 base_dir = os.path.abspath(".") return os.path.join(base_dir, relative_path)然后把所有读资源的路径换成resource_path(...)。打包含资源的命令也要改:
pyinstaller -F -w --add-data "assets;assets" --name SnakeGame main.py这个坑是打包资源类游戏最经典的问题,建议你不管现在用不用的上,都先把resource_path函数备着。
5.2 exe 被系统安全软件提示风险
pyinstaller 生成的单文件 exe 被安全软件提醒,是概率比较高的现象。原因分两层:一是这类 exe 运行时会自解压并执行内存代码,行为和某些恶意程序的特征确实有相似之处;二是因为 exe 没有数字签名,信誉度低。
我自己遇到过的处理方式,按有效程度排:
- 把 PyInstaller 保持在较新版本,新版对常见误报的兼容更好
- 非必要不用 UPX 这类壳压缩工具,压缩后反而容易让安全软件更警惕
- 加上项目图标和版本信息,让 exe 看起来更"正常"
- 给安全软件手动添加排除目录后重新打包验证
这里有一点要提醒:不要为了消除告警去关掉系统安全防护,这是本末倒置。你要做的是理解告警来源,然后正常打包、正常分发。
5.3 双击闪退却看不到任何报错
这是新人最容易崩溃的场景。源码在 IDE 里跑得好好的,exe 双击后屏幕一闪就没了,什么信息都不给。
原因多半是代码在运行时抛了异常,但因为用了-w,控制台被隐藏,异常输出你看不到。
排查办法很简单明确:
先去掉-w重新打包:
pyinstaller -F --name SnakeGame main.py然后在命令行里直接运行:
dist\SnakeGame.exe如果程序崩溃,命令行窗口会打印 Python 的 traceback。绝大多数闪退,在这一步就能看到明确的错误行。
还有一种更稳妥的方式,在main()外面套一层异常钩子,把日志写到文件:
import traceback def main(): ... if __name__ == "__main__": try: main() except Exception: with open("error.log", "w", encoding="utf-8") as f: traceback.print_exc(file=f)这样即使最终版用了-w,用户遇到问题也能把日志发给你定位。
5.4 打包后的体积和启动速度问题
pygame 游戏打包出来动辄 30MB 起步,目录版甚至更大,这是正常现象。里面塞了一个完整的 Python 解释器、pygame 库,还有 PyInstaller 启动运行时的支撑文件,体积小不了。
如果觉得太大,可以尝试:
pyinstaller -F -w --exclude-module pygame._freetype --exclude-module numpy --name SnakeGame main.py--exclude-module的原理是告诉 PyInstaller"这些模块我不需要,别收进来"。用之前你最好确认代码里确实没用这些模块,贸然排除可能导致运行时找不到依赖。
启动慢则和模式有关,-F单文件每次运行都要先解压到临时目录,比-D目录版慢是预期的。想启动快就用-D,想分发方便就用-F。个人作品的交付,我更推荐-F,用户双击就完事,体验最重要。
6. 从"能玩的 Demo"到"像样的小产品":值得继续投入的方向
6.1 先给游戏一个更好的"面子"
图标、窗口标题、暂停功能、重玩按钮,这些看似小事,其实决定了用户拿到手的第一印象。
图标用--icon参数打进去,窗口标题在pygame.display.set_caption("贪吃蛇")里改。暂停功能可以这样加:按 P 键时让主循环进入等待状态,再按恢复。重玩按钮意味着"Game Over"后不能只等两秒退出,而是询问玩家是否再来一局。这些加起来能明显提升 demo 的成熟度。
6.2 最高分别写到 exe 所在目录
如果游戏要保存最高分,直接写到 exe 同级目录会有隐患:程序装在受保护的目录时可能没有写权限,onefile 模式每次启动工作目录也不稳定。
更通用的做法是写到用户目录:
import os import json user_dir = os.path.expanduser("~") score_path = os.path.join(user_dir, ".snake_highscore.json") # 读 if os.path.exists(score_path): with open(score_path, "r", encoding="utf-8") as f: high_score = json.load(f) else: high_score = 0这个路径在个人电脑和办公电脑上都能稳定读写,兼容性比相对路径可靠得多。我踩过直接写当前目录然后莫名其妙丢数据的坑之后,就再也不往 exe 旁边写任何文件了。
6.3 玩法扩展:暂停、音效、AI 寻路
音效直接用pygame.mixer.Sound,提前把较短的音乐片段转成 wav 格式,体积和兼容性都更好。
AI 自动寻路是很有意思的进阶方向。思路是每次蛇移动后,用广度优先搜索(BFS)计算蛇头到食物的最短路径,然后把下一步方向交给turn_to。这个功能实现之后,贪吃蛇就不再需要你动手操作了,能让朋友看得一愣一愣的。而且通过这个过程,你对"寻路算法如何在真实游戏里被调用"会有很直观的认识。
6.4 把打包流程沉淀成自己的标准套路
做完这个项目你会发现,打包 GUI 游戏的核心步骤几乎是大同小异的:写主逻辑 → 本地测试 → 收集资源 → 写 resource_path → 复制最终文件 → 干净环境验证。把这些步骤固定成一份清单或者一条脚本,以后任何 Python 项目要交付时都能直接复用。
我自己现在打包任何新项目,都会先在没装 Python 的机器上双击验证一遍,模拟真实用户的使用路径。这个习惯比记住任何一个打包参数都重要。文章里的代码和坑,大多来自于被同学、朋友反复"折磨"出来的经验,希望帮你少走几步弯路。下一步,不妨把打包好的游戏发给身边完全不懂编程的朋友,看他能不能顺利打开玩上——能的话,这门课就算真正毕业了。