
搞了个小项目用Python的Pygame写一个自己的小游戏。这篇文章就把我从零开始的过程完整记录一遍包括环境搭建、核心概念理解、完整代码、踩过的坑以及最后怎么打包成exe文件。Pygame这个库特别适合Python初学者把语法知识变成看得见摸得着的东西——有窗口、有能移动的角色、有实时反馈比自己对着黑底白字的终端敲代码有意思多了。如果你刚学完Python基础想做个游戏验证一下能力或者想带学生/孩子入门编程这篇文章应该能直接帮到你。我会用最简单的“接水果”做示例从新建文件一路带到图形界面跑起来。这不是教科书式的讲解是我实际跑通后的经验汇总。有些坑我真的踩过有些写法也是对比过几种方案后觉得最适合新手的。你完全可以照着抄抄完再根据我列出的优化点去改。先说明一下Pygame本身不提供复杂的3D渲染、物理引擎和内置编辑器它做的就是2D游戏开发里的底层活——窗口管理、图像绘制、声音播放、事件处理。正因为足够基础你才能把游戏循环、碰撞检测、坐标变换这些核心概念彻底弄明白。1. 环境准备与开发基础1.1 安装Pygame和编辑器选型安装这一步没什么好说的一条命令搞定pip install pygame如果你用的是Anaconda也可以执行conda install pygame但对大多数场景pip就是最直接的方式。我在Windows、macOS、Linux上都跑过Pygame是彻底跨平台的这一点比很多图形库省心。建议使用Python 3.8以上版本Pygame 2.x对旧版Python的兼容已经没那么上心了。安装完验证一下python -c import pygame; print(pygame.version.ver)能输出版本号就说明装好了。编辑器方面VS Code或者PyCharm都可以对后续开发没什么本质区别别在工具上纠结太久。真正容易卡住新人的不是IDE而是搞清楚Pygame程序运行的基本逻辑。Pygame程序和平时写的“从上到下跑一遍就结束”的脚本完全不同它是一个持续循环的图形程序初始化窗口然后不停处理用户操作不停重绘画面直到玩家关闭窗口才退出。我见过太多人用写普通脚本的思维去写Pygame结果打开一个窗口就卡死或者在循环外面写绘制代码导致只显示一帧黑屏。这一点提前想明白后面会顺畅很多。1.2 最小骨架窗口、事件循环、画面刷新一个能正常打开、能正常关闭的窗口程序是所有Pygame小游戏的地基。核心代码就十几行import pygame import sys pygame.init() screen pygame.display.set_mode((800, 600)) pygame.display.set_caption(第一个游戏) while True: for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() sys.exit() pygame.display.flip()这里面有三个东西是必须要理解的pygame.init()会初始化Pygame所有模块包括显示、字体、声音等虽然这一步在实际项目中几乎必定调用但你别把它当成魔法它只是帮你把各家各户的后台任务启动起来。pygame.event.get()负责获取用户的操作比如点击关闭按钮、按下键盘、移动鼠标这些都封装成事件。每次循环调用它会得到当前所有待处理事件的列表你遍历它再根据事件类型做响应。这里有一个新手容易踩的坑如果循环里不调用pygame.event.get()窗口会立刻变成“未响应”状态。原因是操作系统一直在等程序回应界面消息而你没去处理这些消息。pygame.display.flip()用于刷新画面。Pygame默认把所有绘制操作先画在一张内存画布上这一步才真正把画布内容送达到显示器。类比一下你在纸上画画flip相当于把纸翻给观众看。不理解双缓冲机制的人往往写完绘制代码却发现什么也没显示就是漏了这个刷新动作。把这段代码跑起来能打开800x600的窗口并且点关闭能正常退出说明环境已经全部打通了。接下来可以往上叠加真正的游戏内容。2. 上手必懂的核心概念2.1 Surface、坐标系和绘制基础Pygame里所有可见的东西本质都是Surface。连窗口本身就是一张Surface。你加载的图片是Surface你画的矩形、圆形、文字最终也都输出到某张Surface上。理解这一点整个绘图逻辑就串起来了。坐标系统需要专门提醒原点在窗口左上角x轴向右为正y轴向下为正。国内学生数学课学的坐标系y轴是向上的所以很多人第一次画图时发现“往上移”变成了“往下移”这就是坐标系方向没转过来。举个例子在屏幕上画一个红色的矩形pygame.draw.rect(screen, (255, 0, 0), (50, 100, 200, 80))这句话的意思是在screen这张画布上用RGB颜色(255, 0, 0)画一个矩形起点的x坐标是50y坐标是100宽200像素高80像素。矩形左上角定位在(50, 100)往右往下各延伸200和80。这种“左上角原点宽高”的描述方式和后面要介绍的Rect对象完全一致务必形成肌肉记忆。2.2 Rect对象游戏世界的定位利器Pygame用Rect矩形对象来管理游戏对象的位置和大小。先看定义player_rect pygame.Rect(100, 500, 80, 60)括号里四个数字分别是x、y、width、height。Rect对象最方便的地方在于它自带了一系列属性和方法。比如player_rect.x直接拿到左上角x坐标player_rect.center拿到中心点坐标player_rect.move(10, 0)返回向右移动10像素后的新Rect原对象不变。真正体现Rect价值的是碰撞检测。Pygame提供了colliderect()方法用来判断两个矩形是否相交if player_rect.colliderect(fruit_rect): print(接住了!)玩过游戏的人对“碰撞判定”一定不陌生——角色碰到金币、子弹命中敌人、球打到砖块这些全都依赖碰撞检测。Pygame用Rect相交来判定简单高效。对小游戏来说把每种可碰撞对象都抽象成Rect逻辑会变得异常清晰。你不用关心对象具体长什么样碰撞只看这个看不见的矩形框。需要说明一点用矩形碰撞检测有天然缺点就是当图片形状不规则时判定框和视觉形象会有出入。比如一个圆形的苹果图四角明明没有透明区域却也会被判定为碰上了。进阶以后可以用圆形碰撞、像素级碰撞但对第一个项目来说矩形碰撞完全够用。2.3 事件系统与键盘输入控制再回头看事件。pygame.event.get()返回的是一个列表里面装着各种事件对象。判断事件类型用的是event.type常见的类型有这些事件类型触发场景pygame.QUIT点击窗口关闭按钮pygame.KEYDOWN按下任意键盘按键pygame.KEYUP松开任意键盘按键pygame.MOUSEBUTTONDOWN按下鼠标按键pygame.MOUSEMOTION移动鼠标键盘通常有两种用法。第一种是事件驱动按下按键的瞬间触发一次。适合跳跃、开枪这类动作因为你在KEYDOWN事件里写代码每按一次只执行一次。第二种是状态检测pygame.key.get_pressed()返回一个包含所有按键当前状态的元组每个元素代表一个键是否被按住。适合方向键移动人物这种“持续按住”的场景。接水果游戏里控制盘子左右移动就必须用第二种keys pygame.key.get_pressed() if keys[pygame.K_LEFT]: player_rect.x - 5 if keys[pygame.K_RIGHT]: player_rect.x 5这里有个细节值得注意直接修改player_rect.x而不是给player_rect整体重新赋值。Rect的x和y属性都是可写属性改起来非常直观这也是我选择用Rect管理玩家对象的原因之一。2.4 图片、文字与音效从色块到成品感色块和圆圈能帮你理解逻辑但做完之后发出去总感觉像半成品。想提升质感就得用图片素材。Pygame加载图片的代码如下fruit_image pygame.image.load(apple.png).convert_alpha() fruit_rect fruit_image.get_rect()这里有两个容易踩的坑。第一个坑没有.convert_alpha()的话PNG图片的透明区域会变成黑色底块。因为Pygame默认不会处理透明通道需要你手动转换像素格式。第二个坑图片路径问题。直接用相对路径apple.png时程序只能在当前工作目录下运行如果你打包成exe后换到别的目录运行就会报找不到文件的错误。稳健的做法是使用绝对路径或者用os.path.join把资源路径固定下来。文字绘制用pygame.font模块font pygame.font.Font(None, 36) text_surface font.render(得分: 0, True, (255, 255, 255)) screen.blit(text_surface, (10, 10))音效则用pygame.mixer.Sound加载wav或ogg文件。音乐建议用pygame.mixer.music模块两者分工不同一个处理短音效一个处理背景音乐循环。3. 实操从零开发一个可玩的“接水果”小游戏3.1 游戏设计与模块拆分接下来是重头戏。我选择“接水果”作为教学案例因为它覆盖了2D小游戏最核心的几块内容随机生成、玩家控制、碰撞检测、计分、生命值、游戏状态切换。逻辑不复杂代码量适中跑起来效果明显。规则是这样的玩家控制一个盘子左右移动屏幕上不断掉落水果盘子接住水果加分水果落到地面则扣除一条生命。三条命扣完游戏结束。动手前先将整体拆成几个模块避免所有逻辑堆在一起。我习惯这样安排初始化模块完成pygame初始化、窗口创建、字体载入、图片素材加载游戏循环主模块处理事件、更新游戏状态、绘制画面玩家对象Rect 移动逻辑水果对象带速度和图片的列表元素计分与状态分数、生命、游戏结束标志小项目不用强行套用面向对象但用简单的类和列表组织起来代码会清晰得多。尤其水果这种会不断生成、碰撞后又要删除的对象用列表管理最自然。3.2 初始化与全局配置我把全局配置集中放在文件开头import pygame import random import sys pygame.init() WIDTH, HEIGHT 480, 640 FPS 60 screen pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption(接水果小游戏) WHITE (255, 255, 255) BLUE (100, 149, 237) RED (255, 99, 71) player_width 100 player_height 20 player_x (WIDTH - player_width) // 2 player_y HEIGHT - 50 player_speed 8 fruit_radius 15 fruit_fall_speed 5 fruit_spawn_interval 30 score 0 lives 3 game_over False clock pygame.time.Clock() font pygame.font.Font(None, 36)把所有可调参数放在文件顶部好处是调游戏难度时不用满文件找魔法数字。想要水果掉得快一点改fruit_fall_speed想要盘子和水果更扁一点改player_height和fruit_radius。这种“配置与逻辑分离”的思路在项目变大后半段尤其受用。颜色用RGB三元组表示。你可能注意到我选了(100, 149, 237)这种偏浅的蓝色纯(0, 0, 255)在深色背景下其实很刺眼。这等小经验往往要玩一阵子才能体会配色直接影响游戏观感后面想提升视觉质量可以专门研究调色板。3.3 实现玩家控制与游戏主循环玩家控制的逻辑很直接左右方向键控制盘子移动并限制盘子不能超出屏幕边界。def handle_player_input(): global player_x keys pygame.key.get_pressed() if keys[pygame.K_LEFT] and player_x 0: player_x - player_speed if keys[pygame.K_RIGHT] and player_x WIDTH - player_width: player_x player_speed这里有一个值得展开的细节为什么要加player_x 0和player_x WIDTH - player_width这两个边界条件如果不加盘子会移出屏幕消失玩家就不知道自己的控制对象去哪了。加了之后盘子到达边界就会被“拦住”手感上踏实很多。游戏开发管这个叫“边界钳制”几乎所有横版游戏都用到。主循环是每帧执行的完整流程。一帧的定义是一次循环完成事件处理、位置更新、绘制刷新。FPS60意味着每秒循环60次帧与帧之间的间隔大约是16.7毫秒。clock.tick(FPS)的作用就是让每次循环后等到足够时间再进入下一帧防止游戏跑得飞快。while True: for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() sys.exit() if not game_over: handle_player_input() update_fruits() draw() pygame.display.flip() clock.tick(FPS)注意即使游戏结束我依然保留事件处理和刷新画面。这样才能在游戏结束画面里显示“按R重新开始”的提示并且能正常关闭窗口。如果你在游戏结束后直接停掉整个循环玩家会被卡在一个关不掉的窗口里这是很糟糕的体验。3.4 水果生成、下落与碰撞检测水果用字典存放会比较直观一个dict包含rect、speed、颜色三个字段。放到列表里管理。为什么要用字典而不是单独维护三个列表因为水果的生成和删除是一一对应的合并存放的逻辑组织方式更合理删除一个水果时三样数据同时消失不会出现列表不同步的Bug。生成水果放在update逻辑里每次循环判断是否到了生成时间fruit_list [] frame_count 0 def spawn_fruit(): x random.randint(fruit_radius, WIDTH - fruit_radius) fruit { rect: pygame.Rect(x, 0, fruit_radius * 2, fruit_radius * 2), color: random.choice([(255, 99, 71), (255, 215, 0), (50, 205, 50)]), speed: random.randint(fruit_fall_speed, fruit_fall_speed 3) } fruit_list.append(fruit)关键点来了水果的x坐标是random.randint(fruit_radius, WIDTH - fruit_radius)而不是random.randint(0, WIDTH)。如果x从0开始水果会在屏幕左右边缘被切掉一半视觉上不完整。给randint传入半径作为边界本质上是“让生成范围缩进素材半径”保证水果完整出现在画面里。这种细节新手往往要玩到亲眼看边缘有半个苹果才能意识到。更新水果位置时遍历列表让每个水果向下移动。移动用Rect自带方法def update_fruits(): global score, lives, game_over for fruit in fruit_list[:]: fruit[rect].y fruit[speed] if fruit[rect].colliderect(player_rect): score 1 fruit_list.remove(fruit) elif fruit[rect].y HEIGHT: lives - 1 fruit_list.remove(fruit) if lives 0: game_over True这里有个新手十有八九会犯的错一边遍历列表一边删除元素会导致跳过部分元素或者直接报错。解决方案是用fruit_list[:]这个浅拷贝副本做遍历而删除操作作用于原列表。这个坑在我第一次写Pygame碰撞逻辑时也踩过记住这个规则不要在遍历列表的过程中修改列表本身。碰撞判定顺序要注意。先判断是否接住再判断是否掉到底部。如果水果既不在盘子里也没到地面就让它继续下移。逻辑顺序看起来简单但如果先判断落地再判断碰撞会出现一个水果同时满足两个条件被重复删除的情况。判断顺序在游戏逻辑里是很有讲究的。3.5 完整代码照抄就能跑起来下面给出完整代码保存为game.py直接运行就能看到效果import pygame import random import sys pygame.init() WIDTH, HEIGHT 480, 640 FPS 60 screen pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption(接水果小游戏) WHITE (255, 255, 255) BLUE (100, 149, 237) RED (255, 99, 71) player_width 100 player_height 20 player_x (WIDTH - player_width) // 2 player_y HEIGHT - 50 player_speed 8 fruit_radius 15 fruit_fall_speed 5 fruit_spawn_interval 30 score 0 lives 3 game_over False frame_count 0 fruit_list [] clock pygame.time.Clock() font pygame.font.Font(None, 36) player_rect pygame.Rect(player_x, player_y, player_width, player_height) def draw_text(text, color, x, y): surface font.render(text, True, color) screen.blit(surface, (x, y)) def handle_player_input(): global player_x keys pygame.key.get_pressed() if keys[pygame.K_LEFT] and player_x 0: player_x - player_speed if keys[pygame.K_RIGHT] and player_x WIDTH - player_width: player_x player_speed player_rect.x player_x def spawn_fruit(): x random.randint(fruit_radius, WIDTH - fruit_radius) fruit { rect: pygame.Rect(x, 0, fruit_radius * 2, fruit_radius * 2), color: random.choice([RED, (255, 215, 0), (50, 205, 50)]), speed: random.randint(fruit_fall_speed, fruit_fall_speed 3) } fruit_list.append(fruit) def update_fruits(): global score, lives, game_over for fruit in fruit_list[:]: fruit[rect].y fruit[speed] if fruit[rect].colliderect(player_rect): score 1 fruit_list.remove(fruit) elif fruit[rect].y HEIGHT: lives - 1 fruit_list.remove(fruit) if lives 0: game_over True def draw(): screen.fill(BLUE) draw_text(f得分: {score}, WHITE, 10, 10) draw_text(f生命: {lives}, WHITE, WIDTH - 120, 10) pygame.draw.rect(screen, WHITE, player_rect) for fruit in fruit_list: pygame.draw.circle(screen, fruit[color], fruit[rect].center, fruit_radius) if game_over: draw_text(游戏结束, RED, WIDTH // 2 - 80, HEIGHT // 2 - 30) draw_text(按 R 重新开始, WHITE, WIDTH // 2 - 110, HEIGHT // 2 20) while True: for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() sys.exit() if event.type pygame.KEYDOWN: if event.key pygame.K_r and game_over: score 0 lives 3 game_over False fruit_list.clear() player_x (WIDTH - player_width) // 2 if not game_over: handle_player_input() frame_count 1 if frame_count % fruit_spawn_interval 0: spawn_fruit() update_fruits() draw() pygame.display.flip() clock.tick(FPS)这段代码用到了上述所有核心概念Rect管理位置、get_pressed处理持续移动、colliderect做碰撞、列表字典管理水果对象、全局变量维护分数和生命值。代码风格上我刻意保持了单文件的直白写法新手先从这里看懂流程再考虑拆分类。3.6 难度调优让游戏真正“可玩”能跑通和“好玩”是两码事。默认参数下这个游戏玩起来会比较平淡水果掉落速度恒定生成频率恒定接住和漏接的区别只是分数和生命值变动。好玩的小游戏需要一个核心机制我推荐做“难度递增”。最简单的做法是用分数控制难度系数。比如difficulty 1 score / 10 fruit_spawn_interval max(15, int(30 / difficulty)) fruit[speed] random.randint(int(fruit_fall_speed * difficulty), int(fruit_fall_speed * difficulty) 3)这个做法的本质是得分越高水果生成越快下落速度也大幅度提升玩家必须在更短的决策时间内移动盘子。difficulty按分数每10分增一档int(30 / difficulty)让生成间隔从30帧逐步缩短到15帧——15帧是下限防止游戏变成纯粹运气游戏。对于“接水果”这类游戏还有一个经典的机制是用大小板来调节容错率盘子越接越长虽然更不容易漏接但操作还不够精确的话照样会接不住边缘水果。这些改动逻辑都不复杂但会让游戏体验产生质的提升。完成基本项目后一定要试试这些变体。4. 常见问题与排查技巧实录4.1 窗口无响应、黑屏、显示异常这类问题在Pygame新手群里出现频率极高我按犯错的概率排个序报错或现象常见原因解决方法窗口一直白屏不显示内容忘记调用pygame.display.flip()确认绘制代码后紧跟刷新窗口出现“未响应”主循环里没有pygame.event.get()每帧循环必须处理事件图片显示成黑底方块PNG透明未被处理用convert_alpha()按方向键没有反应用的是KEYDOWN但写成了get_pressed确认移动逻辑用状态检测运行报错NameError变量名拼写不一致统一命名建议用常量托管全部参数有一个特别隐蔽的问题如果代码里使用了pygame.font.Font(None, 36)但没有先pygame.init()会报pygame.error: font not initialized。原因是字体模块没有被初始化。把所有初始化放一行不要在init之前调用任何Pygame模块就能避开。4.2 性能波动与掉帧优化有些朋友写完游戏后发现帧率忽高忽低水果一多就明显卡顿。原因基本集中在下面几个最典型的一个每帧加载图片。比如把pygame.image.load()写进了绘制循环导致每帧都要从硬盘读文件。Pygame里加载图片是I/O操作非常慢正确做法是在初始化阶段把图片加载到内存后续直接引用。类似的问题还有每帧创建新的Rect、每帧创建新的字体对象。写代码时记住一条原则循环里不要做不需要每帧重复的创建工作。另一个常见问题是不控制帧率上限。电脑性能好游戏跑几百帧物体的移动速度看起来像开了加速电脑性能差帧率掉到三十又变成了慢动作。解决办法就是用clock.tick(FPS)把逻辑速度与帧率解耦。游戏循环里基于“每帧移动多少像素”的逻辑在不同帧率下速度本来就不一样tick的作用是把帧率稳定下来游戏体验自然稳定。4.3 打包发布把你做的游戏分享给别人想让朋友不用装Python就能玩你的游戏就要打包成可执行文件。我用的是PyInstallerpip install pyinstaller pyinstaller --onefile -w game.py--onefile是把所有东西打包成单个exe文件-w表示运行时隐藏黑色控制台窗口。打包完成后exe在dist目录里。这块有一个常被忽视的坑如果你的游戏依赖外部图片和音频文件exe运行时会把路径锁定在解压目录直接点击exe往往找不到资源。解决方式是把资源文件用--add-data参数一并打进去或者更省事的方案是把游戏素材全部用代码内置生成。上面的示例代码完全没有依赖外部图片素材是一个战略性设计方便打包分发也让新手更快跑通。4.4 游戏逻辑Bug排查经验写游戏草率不得我总结了一套排查思路先把窗口尺寸、坐标、速度全部设成便于观察的数值。比如把fruit_fall_speed改成1水果下落会慢得像散步这时你能清楚看到碰撞边界和逻辑执行过程。这个方法叫“慢速调试法”比盯着报错信息猜来猜去高效得多。再一个技巧是针对碰撞检测的。游戏里九个问题八个出在碰撞判定上要么判定太灵敏要么判定不灵敏。我在调试时会在Rect边界附近打印两个矩形坐标print(盘子:, player_rect, 水果:, fruit[rect])当你看到两者的坐标已经重叠却还没触发colliderect就说明Rect的尺寸或位置赋值出现了偏差。比如用了图片的中心点来定位但Rect本身是左上角定位一个常见误差就出现了。中心点和左上角的转换rect.center (x, y)是一种赋值方式而rect.x x是另一种。理清Rect的定位方式能省掉大量无意义的调试时间。4.5 给新手的学习建议与扩展方向做到这一步你的第一个小游戏已经完整跑通了。很多人在这个时候会陷入“下一步不知道做什么”的迷茫。我的建议是别急着换引擎先把这个项目横向扩展出几个变体把“接水果”改成“接金币”增加不同分值的水果类型分值高的掉得快。加一个简单的开始界面和结束界面用鼠标点击控制。加音效把计分做成连击加分机制。任何一个改动都是在强化你已有的能力。更重要的是你可以用同样的代码框架做“打砖块”玩家控制一个挡板反弹小球小球打击砖块得分。只要把“碰撞后反弹”逻辑加进去就又是一款新游戏。移动、碰撞、计分、状态管理这些核心套路你已经全会了。写在最后的一点体会做这个小游戏项目我最大的感受是Pygame教你用最简单的方式理解游戏引擎的原理。外面的游戏引擎很强大但很多东西都替你封装好了反而让初学者搞不清楚幕后发生了什么。用Pygame从零搭游戏循环你会切身体会到游戏世界里的“每一帧”长什么样事件系统是怎么运转的碰撞检测为什么能生效。这些底层的直觉对以后学习任何游戏框架都极其宝贵。最后再分享一个小技巧开发时把窗口标题加上版本号比如接水果 v0.1、接水果 v0.2。每次改完功能更新版本号你就能很直观地看到自己项目的演进轨迹。这不仅是代码管理的例行操作更会让你在做小改动之后看到真实进度开发动力会足很多。现在就动手改改参数让水果掉得更快一些感受一下自己亲手做的游戏变难之后的成就感吧。