资讯详情 pygame贪吃蛇开发与PyInstaller打包:从游戏状态机到独立EXE分发
📅 2026/10/17 20:50:58
很多用 Python 学游戏开发的朋友第一次让贪吃蛇跑起来的时候心情都挺激动的但紧接着往往会撞上同一个尴尬想把这游戏发给没学过编程的朋友玩对方不是没装 Python 环境就是装了依赖之后启动还报错。我自己也经历过这个阶段后来才意识到让脚本能跑起来和让脚本能被别人直接用完全是两码事。所以这篇文章准备把两件事一起讲透用 pygame 写一个逻辑完整的贪吃蛇再用 PyInstaller 打包成双击就能运行的 exe。适合想把 Python 练手项目真正做成能发给别人用的读者也适合拿来学习游戏主循环和打包原理的入门者。1. 为什么贪吃蛇打包这个组合值得认真做一遍1.1 贪吃蛇不是幼稚项目它是状态机的入门课写贪吃蛇绕不开当前游戏处于什么状态、下一帧要变成什么状态这组问题。蛇的位置是状态行进方向是状态是否吃到食物、是否撞墙、是否还活着都是状态。任何大型游戏背后都是这种状态机逻辑的规模放大版。把贪吃蛇做透你对主循环 事件 碰撞检测的理解会直接迁移到后续任何游戏项目上。很多看起来高级的玩法本质都是在回答几个基础问题玩家的输入如何影响状态状态每秒要更新几次更新之后如何刷新画面这些恰恰是贪吃蛇每天都在训练的东西。1.2 补上打包交付这一课大多数自学者长期停留在 IDE 里按运行按钮的阶段缺少一个关键意识用户没有 Python 环境怎么运行你的程序打包就是把这个意识变成现实的一步。它能一次性解决 Python 环境、依赖库、资源文件、桌面入口等一堆问题。通过打包你会被迫去理解解释器、依赖、路径、权限这些平时藏在开发环境背后的东西。这个被迫理解的过程才是提升能力的关键。我见过不少朋友写完游戏直接发源码然后被对方一句运行不了搞到崩溃。这真不是代码写得不好而是缺少交付思维。打包课早补早受益。1.3 技术栈为什么选 pygamepygame 在 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, size20, colorWHITE): 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, fGame Over! 得分: {score}, (WIDTH // 2 - 110, HEIGHT // 2 - 20), size36) pygame.display.flip() pygame.time.wait(2000) pygame.quit() sys.exit() if __name__ __main__: main()3.3 代码里几个值得单独拿出来讲的地方方向缓冲的设计turn_to只更新pending_directionmove时才写入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 一条命令完成首次打包安装 PyInstallerpip install pyinstaller进入snake_game目录执行pyinstaller -F -w main.py-F表示打成单个 exe 文件-w表示不显示黑色控制台窗口执行完后项目目录下会多出三个东西main.spec、build/、dist/。你真正要交付的是dist/main.exe其他都可以不关心。我给这份打包命令再补一个更完整的版本顺手把名字和图标设好pyinstaller -F -w --name SnakeGame --iconicon.ico main.py没有icon.ico文件时先把这句里的--icon去掉默认图标是 PyInstaller 那个不影响运行。4.3 打包产物的用途说明产物作用是否交付给用户dist/SnakeGame.exe最终的独立可执行文件交付build/打包过程中的临时工作目录不交付main.specPyInstaller 配置文件记录打包参数保留下次重打包用这里有件事值得说明build/目录虽然占了空间但它缓存了依赖分析结果。如果你只是改了代码、没换依赖第二次打包会快很多。不过它体积不小整理项目时删掉也无所谓下次自动重新生成。4.4 常用打包参数速查参数含义什么时候用-F打包成单个 exe 文件分发给别人文件干净-D打包成目录内含 exe 和依赖自己本地用启动更快-w隐藏控制台窗口GUI 游戏几乎必用--iconxxx.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, encodingutf-8) as f: traceback.print_exc(filef)这样即使最终版用了-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, encodingutf-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 的机器上双击验证一遍模拟真实用户的使用路径。这个习惯比记住任何一个打包参数都重要。文章里的代码和坑大多来自于被同学、朋友反复折磨出来的经验希望帮你少走几步弯路。下一步不妨把打包好的游戏发给身边完全不懂编程的朋友看他能不能顺利打开玩上——能的话这门课就算真正毕业了。