Pygame游戏开发入门:从事件循环到接苹果完整实战
学Pygame这件事我在很多场合跟人聊过。它是Python生态里最被低估的入门级游戏开发库之一既没有商业引擎那种“拖拽式”的傻瓜便利也没有纯写算法那么枯燥。用它写第一行代码创建第一个窗口控制一个小方块移动那种“屏幕由我掌控”的成就感是后续做Unity、Godot之前非常需要的底气。这篇博文想把Pygame从“会用”讲到“能独立写一个小游戏”。不绕弯子直接从设计思路、核心概念、事件处理、碰撞检测、精灵管理、音频播放一路讲到完整的“接苹果”小游戏实战最后把打包发布和调试避坑也一并交代清楚。内容适合三拨人刚学完Python基础语法还没找到练手项目的新手想搞懂游戏主循环和事件驱动机制的学生以及以后打算转向Unity或者独立开发但想先低成本验证游戏设计想法的玩家型程序员。1. 为什么用Pygame做游戏开发入门先聊清楚一个最容易被忽略的问题既然现在Unity、Godot、Cocos这些引擎铺天盖地为什么还要回头用Pygame这种“老古董”写游戏我自己的体会是商业引擎把底层细节藏得太深了。你在Unity里拖一个组件、加一段脚本游戏跑起来了但你其实不太清楚每一帧到底发生了什么。Pygame则逼着你自己写事件循环、自己控制刷新频率、自己算碰撞边界游戏跑起来的那一刻你脑子里对“一个游戏是怎么运转的”有一个非常完整的闭环认知。这种底层理解等到以后用引擎时能帮你少走很多弯路。用打比方的方式说用Unreal或Unity做游戏像住精装房水电、管线、墙漆全配好你拎包入住Pygame像是给你一套毛坯房加基础工具箱水管要自己接墙要自己刷但住进去之后你比谁都清楚每根线路通向哪里。对一个想长期在游戏开发这条路走下去的人来说这种“懂原理”远比“跑得快”重要。从语言层面看Python语法本身就是很接近伪代码的存在。思路理顺了代码就顺了。Pygame的所有功能模块划分也特别符合直觉pygame.display管显示、pygame.event管输入、pygame.sprite管游戏对象、pygame.mixer管声音你不需要背一堆奇怪的API只需要理解“一个游戏窗口背后有一台永不停歇的机器每秒钟转60圈每圈做三件事收集输入、更新逻辑、画出画面”剩下的事情都是往这个框架里填肉。2. 环境准备与四个必须先懂的核心概念2.1 安装Pygame的正确姿势安装Pygame之前先把Python环境理清楚。我见过太多人在这一步踩坑明明执行了pip install pygame控制台也显示安装成功但一运行import pygame就报ModuleNotFoundError。原因基本都是电脑里装了不止一个Pythonpip装到了另一个环境。一个稳妥的做法是永远用python -m pip install pygame而不是直接打pip install pygame。python -m pip会保证把包装进当前这个Python解释器对应的环境里。如果你用虚拟环境先激活环境再安装如果你用PyCharm直接在Terminal里执行命令PyCharm会自动切换到项目解释器。如果网络下载慢可以加国内镜像源常见的清华、阿里源都可以。安装完成后用一行代码验证python -c import pygame; print(pygame.version.ver)能看到版本号说明环境没问题。Pygame 2.x版本在Windows、macOS、Linux三大平台表现都很稳macOS用户注意别选错wheel版本推荐直接用Python 3.9以上的官方版本装起来最省心。2.2 坐标系和Surface屏幕上的一切都是方块Pygame的坐标系是我要强调的第一个容易绕晕的点。窗口左上角是原点(0,0)x轴向右增加y轴向下增加。很多新手习惯数学里的坐标系结果发现对象往“下”移动时y反而要加半天反应不过来。这不是设计缺陷它跟屏幕扫描方向天然一致写代码时反而少一步转换。再说Surface中文叫“表面”或“画布”它是Pygame里最重要的基础概念。窗口本身是Surface窗口里加载的图片是Surface用代码画的圆形和矩形也是Surface。理解这个之后你就能明白为什么Pygame里几乎一切操作都是“把一个Surface画到另一个Surface上”。screen.blit(hero, (x, y))就是把hero这块小画布贴到screen这块大画布上。Rect则是一个矩形区域描述对象叫Rect。它包含x、y、width、height四个基础属性以及top、bottom、left、right、center等便捷属性。碰撞检测就是两个Rect的相交判断角色移动就是更新Rect的坐标。只要你把所有游戏对象都当作“带着坐标的矩形”Pygame的逻辑就会清晰很多。2.3 事件队列和主循环游戏的心脏游戏和普通脚本最大的不同在于普通脚本跑完就结束游戏则必须“一直活着”持续响应键盘、鼠标、窗口关闭等外部输入。这一个“一直活着”的机制在Pygame里由事件队列和主循环共同完成。外部每做一次动作比如按下一个键、点一下鼠标、关闭窗口Pygame就会把对应的事情作为一个事件放进一个叫“事件队列”的队列里。你的代码用pygame.event.get()把队列里的事件全部取出来逐个判断是什么类型再分别处理。这就像餐厅的前台客人按铃、叫号、举手服务员挨个处理。完整的主循环长这样import pygame import sys pygame.init() screen pygame.display.set_mode((800, 600)) while True: for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() sys.exit() pygame.display.flip()如果不写事件循环窗口就会立刻卡死在macOS上甚至会直接弹出“未响应”。很多新手只写了set_mode就开画结果窗口白屏问题就出在这里。记住一个口诀init负责启动event.get()负责听display.flip()负责供。三者缺一窗口立刻罢工。2.4 帧率与Clock为什么游戏要有节奏游戏画面不是无条件地越流畅越好关键是速度稳定。同一款游戏60Hz的机器和144Hz的机器如果帧率绑定在逻辑上低刷屏的机器跑得慢、高刷屏的机器跑得像开了加速器这种体验是灾难性的。Pygame用pygame.time.Clock()来控制帧率clock pygame.time.Clock() while True: # 游戏逻辑 clock.tick(60) # 限制每秒最多60帧tick(60)的意思是“让程序最多每秒循环60次”。这样无论机器性能多好游戏速度都是均匀的。如果不做限制一个while True空转能吃掉几百%的CPU风扇狂转。哪怕只是写着玩的小项目也该养成加Clock的习惯这对电脑好也能让后续难度设计更容易控制。3. 核心开发环节与代码拆解3.1 精灵类和精灵组批量管理游戏对象游戏里通常同时存在很多对象玩家、敌人、子弹、道具如果每个对象都手动维护一个列表再遍历更新代码会越来越乱。Pygame提供了Sprite类和Group类来解局这个麻烦。Sprite是“精灵”一个游戏对象的基类。你把角色的图片、位置、移动逻辑封装到一个继承Sprite的类里把类创建的对象添加到Group精灵组之后就可以一句话让组里的所有精灵“自己执行update方法”class Player(pygame.sprite.Sprite): def __init__(self): super().__init__() self.image pygame.Surface((50, 50)) self.image.fill((0, 255, 0)) self.rect self.image.get_rect() self.rect.center (400, 500) def update(self): keys pygame.key.get_pressed() if keys[pygame.K_LEFT]: self.rect.x - 5 if keys[pygame.K_RIGHT]: self.rect.x 5 player_group pygame.sprite.Group() player Player() player_group.add(player) # 主循环里一行搞定更新和绘制 player_group.update() player_group.draw(screen)这里有个新手必踩的坑在Sprite子类的__init__里忘记调用super().__init__()。不调这行Sprite内部的image和rect属性不会被初始化Group的draw方法找不到rect程序直接报错AttributeError。这个问题我当年排了半小时后来发现所有Sprite子类第一行就该写super().__init__()没有例外。这也是“事件锁”这个概念的近亲——初始化不完整后面整个对象就锁死在坏状态里。Sprite Group的另一个好处是它自带碰撞检测方法。pygame.sprite.groupcollide()可以一次性检查两组精灵之间的所有碰撞并返回碰撞字典在子弹打敌人的场景里省掉大量手写双重遍历。3.2 碰撞检测Game Over是怎么发生的碰撞检测是游戏开发里绕不开的关卡。Pygame最基础的碰撞检测是Rect与Rect的相交判断rect1.colliderect(rect2)返回True就说明两个矩形区域重叠了。为什么矩形这么重要因为矩形检测快尤其当场景里对象很多时处理速度直接影响帧率。如果检测小球撞平台给小球和平台各画一个略微内缩的Rect用colliderect判断效果已经相当精准。只有当精灵图片形状特别不规则、要求很高时才需要进阶的pygame.mask像素级碰撞。而collide_mask的原理是算出两张图片的非透明像素区域再做两两比对精度高但开销也大新手阶段基本用不上。碰撞检测里有个经典坑叫“穿模”高速物体上一帧还在左边下一帧直接跳到右边中间的碰撞完全没被检测到。子弹速度太快时这个问题尤其明显。解决办法有两个一是限制单帧位移不超过被碰撞物体尺寸的一半二是用“连续运动细分”把一次大移动拆成好几小步逐步做碰撞检测。这一步虽然消耗性能但对高速物体是刚需。3.3 输入响应按键事件和持续按键要分清Pygame处理键盘输入有两种方式很多人没搞清楚差别导致角色移动时一顿一顿。第一种是基于事件。pygame.KEYDOWN事件在按键被按下的那一瞬间触发一次之后不会持续触发。它适合做单次动作按空格跳跃、按E开火、按ESC打开菜单。第二种是基于当前状态查询。pygame.key.get_pressed()返回一个“此时所有按键是否被按住”的状态列表每一帧查询适合做持续移动。正确组合方式是持续移动用get_pressed()单次动作用事件。如果你用KEYDOWN来控制角色移动按一下动一下松开就停手感极其糟糕。还有个细节是get_pressed()会忽略窗口失焦的情况窗口被点击切换后按键会“卡住”需要在窗口重新获焦时重置按键状态这个问题在多人联机场景尤其要处理。“事件锁”这个词在游戏里还有个更广义的用法当while循环里跑了一个耗时操作比如下载文件、读取大图片时整个事件队列会被“锁住”无法响应。用户会看到窗口卡死、鼠标转圈。解决思路是别在主循环里干重活用异步线程或者分帧处理。我写过脚本加载大量素材时界面假死后来把所有加载放进程外再传结果回主线程立刻流畅。3.4 音效与背景音乐别让声音成为劝退项很多人做完视觉效果就收工游戏一点声音都不上体验立马打折。Pygame的音频模块分两块pygame.mixer.Sound是短期音效比如射击、得分、跳跃pygame.mixer.music是流式背景音乐适合长时间播放的BGM。用法pygame.mixer.init() # 音效 jump_sound pygame.mixer.Sound(jump.wav) jump_sound.play() # 背景音乐 pygame.mixer.music.load(bgm.mp3) pygame.mixer.music.play(-1) # -1代表无限循环我踩过的坑有几个提醒一下一是mixer.music.play(-1)里的参数如果传了0音乐播一遍就停了新手容易懵二是Sound加载大音频文件会导致内存爆炸背景音乐别用Sound加载用music流式播放三是Pygame对MP3格式的兼容性在部分平台不太好尤其某些Linux发行版这时换成OGG或WAV能解决很多莫名其妙的问题。音频加载成功与否最好在代码里做异常捕获别让游戏启动时因为少了个声音文件直接崩溃。4. 实战一个完整的“接苹果”小游戏4.1 游戏设计规则先想清楚再动手我把前面的知识点串起来做一个经典的“接苹果”小游戏。规则是这样的屏幕底部有一个篮子左右方向键控制篮子移动顶部不断掉落苹果接到得分漏接就扣生命值生命值归零游戏结束。随着得分增加苹果下落速度逐渐加快。这个设计麻雀虽小但五脏俱全输入处理、精灵移动、碰撞检测、计分、难度递进、游戏结束状态管理正好覆盖Pygame第一阶段学习的所有核心点。先想清楚游戏设计再动手写代码这个习惯非常重要很多初学者直接打开编辑器就开始码写到一半发现逻辑互相冲突返工成本极高。4.2 完整代码从初始化到游戏结束我把代码拆成四块讲解方便对照。第一块初始化和常量配置import pygame import random import sys # 初始化 pygame.init() SCREEN_WIDTH, SCREEN_HEIGHT 480, 640 FPS 60 screen pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption(接苹果) clock pygame.time.Clock() # 颜色常量 WHITE (255, 255, 255) RED (200, 50, 50) BROWN (139, 69, 19) BLACK (0, 0, 0) # 游戏参数 basket_speed 8 apple_speed_start 3 lives 5 score 0把窗口宽高、颜色、初始速度都统一定义在最前面是我后来才养成的习惯。当年我把一堆魔法数字直接散落在代码各处想调参时得满文件找烦到怀疑人生。集中管理参数游戏平衡性调整会变得特别舒服“改一个数字游戏变个玩法”的体验也很直观。第二块定义篮子类和苹果类class Basket(pygame.sprite.Sprite): def __init__(self): super().__init__() self.image pygame.Surface((80, 20)) self.image.fill(BROWN) self.rect self.image.get_rect() self.rect.bottom SCREEN_HEIGHT - 20 self.rect.centerx SCREEN_WIDTH // 2 def update(self): keys pygame.key.get_pressed() if keys[pygame.K_LEFT] and self.rect.left 0: self.rect.x - basket_speed if keys[pygame.K_RIGHT] and self.rect.right SCREEN_WIDTH: self.rect.x basket_speed class Apple(pygame.sprite.Sprite): def __init__(self): super().__init__() self.image pygame.Surface((20, 20), pygame.SRCALPHA) pygame.draw.circle(self.image, RED, (10, 10), 10) self.rect self.image.get_rect() self.rect.x random.randint(0, SCREEN_WIDTH - 20) self.rect.y -20 self.speed apple_speed_start def update(self): self.rect.y self.speed我在Apple的构建中给Surface加了SRCALPHA参数这样圆形的Canvas才能有透明背景否则就是一块白底黑框。很多人在Pygame里画圆形发现边缘一个巨大的白色方块就是因为没开透明。Sprite类里构造Surface后别忘了再把图片画到Surface上这块逻辑已经封装在类的内部主循环里只需要无脑update和draw。第三块主循环里的碰撞检测和计分# 创建精灵组 all_sprites pygame.sprite.Group() apple_group pygame.sprite.Group() basket Basket() all_sprites.add(basket) score 0 lives 5 # 生成苹果 def spawn_apple(): apple Apple() all_sprites.add(apple) apple_group.add(apple) spawn_apple() running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False all_sprites.update() # 检查苹果与篮子的碰撞 caught_apple pygame.sprite.spritecollide(basket, apple_group, True) for apple in caught_apple: score 1 # 得分每满5分提升难度 if score % 5 0: for a in apple_group: a.speed 0.2 # 检查苹果是否漏接 for apple in apple_group: if apple.rect.top SCREEN_HEIGHT: apple.kill() lives - 1 if lives 0: running False # 保持场上有苹果 if len(apple_group) 0: spawn_apple() # 绘制 screen.fill(WHITE) all_sprites.draw(screen) # 显示分数和生命值 font pygame.font.Font(None, 36) score_text font.render(fScore: {score}, True, BLACK) lives_text font.render(fLives: {lives}, True, BLACK) screen.blit(score_text, (10, 10)) screen.blit(lives_text, (SCREEN_WIDTH - 120, 10)) pygame.display.flip() clock.tick(FPS) pygame.quit() sys.exit()pygame.sprite.spritecollide(basket, apple_group, True)的意思是让sprite苹果组里所有与篮子碰撞的精灵返回列表并且这些精灵会被从组里删除也就是消失。第三个参数True是消除被碰撞的苹果如果写False苹果会从篮子里穿过去逻辑就错了。还有一个细节我特意安排了漏接的苹果用apple.kill()移除Apple的kill()是Sprite自带的方法它会把精灵从所有精灵组里移除这个是group管理对象的核心便利处。第四块为什么这个架构能扩展。如果你现在想加一个“炸弹”掉落物只需要新写一个Bomb类加入all_sprites和一个bomb_group在主循环加一组碰撞判断整个架构不需要翻新。文件结构建议以后更大项目拆成settings.py、sprites.py、main.py三个文件这个练手项目虽然单文件足够但按模块拆分的意识要尽早建立。4.3 参数选择说明为什么这样配数这几个参数都经过简单计算不是随手编的篮子宽度80、窗口宽480意味着篮子在水平方向有6个身位的移动空间既不容易无聊也不至于太难苹果初始下落速度3撞到篮子底部约5条命能熬几分钟符合入门体验得分每满5分全局加速0.2大概第15分时苹果速度达到4.2难度有梯度但不会突然崩塌。做游戏数值的规律我自己的经验是“前期要宽容、后期要刺激”。新手玩半分钟就死挫败感极强老手玩十分钟没变化无聊感骤升。间隔递增的加速方案虽然简单但正好踩在舒适区和紧张区的平衡点上。5. 常见问题与调试技巧实录5.1 事件队列与“事件锁”问题很多同学问过“事件锁”到底是什么概念。Pygame本身没有名为“事件锁”的API它更像一个比喻当事件队列里的消息得不到及时处理时整个游戏就像被锁住一样。最常见的原因有三个。第一个是主循环里塞了耗时操作。如果你在主循环里调用time.sleep(0.5)或者做了一个大文件的读写事件队列会积压窗口失去响应。正确姿势是耗时任务用pygame.time.get_ticks()做计时器把任务拆碎到每一帧只做一点或者用threading做后台线程。第二个是忘记调用pygame.event.get()或pygame.event.pump()。Pygame要求窗口定期“泵”一下事件否则系统以为程序死了。如果你写了个纯计算的动画循环结果窗口转菊花优先检查这里。第三个是全屏循环里嵌套另一个循环。比如你在while running里又写了个while True等用户输入内层循环会把外层事件处理完全阻塞表现就是无论如何点关闭按钮都没反应。排查方式是看代码缩进里有没有两层循环嵌套。5.2 碰撞检测失败的三种情况碰撞检测失败是最让人抓狂的问题之一列表排查法比较好用先确认两个精灵的rect位置是实时的。调试时用print(apple.rect.x, apple.rect.y, basket.rect.x, basket.rect.y)打印出来手动把苹果挪到篮子附近看看坐标是否重叠。确认Rects尺寸与视觉表现一致。我画一个20像素的圆形但Surface恰好是20×20视觉上的圆形和Rect之间有差异碰撞检测把“圆形”当成“方块”视觉上感觉还没碰到就算命中了。要修正就手动内缩rect.inflate(-4, -4)。确认移动后再碰撞检测。一定先update()让所有对象位置更新再做碰撞检测顺序反了会导致“看起来碰到了但判定没发生”。高速物体穿透问题之前讲过把大位移拆碎成move多段。比如一篇诚实的高速子弹一节位移不要超过被碰撞物体的宽度。5.3 音频不出声和CPU占用过高音频不出声的问题千奇百怪但九成是这三个原因文件路径不对、音频编码格式不受支持、忘记初始化mixer。Pygame对wav和ogg兼容性最好mp3偶尔出问题建议开发期统一用wav。路径方面用相对路径“sound.mp3”时注意工作目录是启动目录不是脚本目录最稳妥的方法是os.path.join(os.path.dirname(__file__), sound.mp3)这样怎么切换目录都不会找不到资源。CPU占用过高基本是主循环里做了大量不必要的计算或者没有控制帧率。调试时在循环末尾打印clock.get_fps()如果数值远低于tick设定的值说明逻辑太重如果CPU还是100%检查是否无意间创建了大量对象又没有释放。PyGame在游戏退出时记得调用pygame.quit()否则音频和显示资源不会正常释放。5.4 调试技巧print先生与get_ticks计时器写游戏时我最常用的调试工具反而是老土的print()。在碰撞检测里、按键响应里、精灵生成里各打一个标记用鼠标跑几遍就能确认逻辑分支走到了没有。比断点快因为游戏是循环结构断点一旦停住整个画面冻结交互状态看不清楚。第二个常用技巧是用pygame.time.get_ticks()做时间戳它返回东M初始化以来的毫秒数。我可以用来判断“这帧距上帧过去了多久”从而实现与帧率无关的游戏速度delta_time (pygame.time.get_ticks() - last_ticks) / 1000.0然后移动量乘上delta_time。这样做出的游戏在不同帧率下速度一致是进阶到引擎级别的重要意识。6. 从练手到发布打包与后续扩展6.1 用PyInstaller把自己做的游戏打包成exe游戏写好了想发给同学朋友玩得打包成独立可执行文件。PyInstaller是最好上手的选择pip install pyinstaller pyinstaller -F -w --name 接苹果 main.py-F生成单文件-w表示不显示终端黑窗口。如果游戏里有外部图片和音频文件打包后默认不会带上需要加--add-data参数pyinstaller -F -w --add-data sound.wav;. --add-data image.png;. 接苹果.pyWindows下注意分号是;macOS和Linux下面是冒号:。打包之后把exe发给别人双击能跑起来成就感跟第一次弹出游戏窗口不相上下。但有个大坑如果打包环境是64位Python生成的exe也只能在64位系统上运行。要做32位版需要装32位Python再打包一遍。6.2 图形化改进用真实图片替代色块我们前面的小游戏全部用色块和圆形代替美术资源这是开发初期最省事的方案。真正要见人的游戏得用真实图片。做法很简单准备一张带透明背景的PNG小图和一个篮子图把类里的pygame.Surface替换成pygame.image.load(player.png)class Basket(pygame.sprite.Sprite): def __init__(self): super().__init__() self.image pygame.image.load(basket.png).convert_alpha() self.rect self.image.get_rect()注意convert_alpha()它把图片转换为与显示格式一致的surface且保留透明度绘制效率更高。不调用也能显示但性能会差。加载大图时可以用pygame.transform.scale(self.image, (80, 80))来缩放。同时pygame.transform.rotate可以做旋转做飞机转向效果非常方便唯一需要注意的是旋转后rect尺寸会变化中心点会偏移旋转前后需要重新设置center坐标。6.3 向真正的入门项目进发事件与状态管理接苹果游戏做完之后下一步可以往两个方向升级。方向一加游戏状态一个简单的“开始菜单→游戏主场景→暂停→Game Over”流程用game_state变量控制游戏阶段每个状态写一套更新和绘制逻辑。这比一口气让所有交互都堆在主循环里清晰得多也是很多大型游戏的基础架构雏形。方向二加存档积分榜用普通文本文件存最高分运行时读入结束后写入逻辑简单但手感立刻高级。到了这个阶段你对Pygame的基本使用就已经走上正轨了。再往后可以看看pygame.freetype做自定义字体、pygame.transform做粒子效果、pygame.mask做像素级碰撞、pygame.mixer做音效混音甚至用Pygame做一个小型的2D平台跳跃游戏并加入刚体重力模拟。这些知识在网上能零散找到但有这个基础你会发现别人写的代码不再晦涩。我在实际做过的几个练手项目里一直把Pygame当“游戏逻辑试验田”。很多独立游戏的点子动手写原型最快的方式不是开Unity新工程而是用Pygame二十分钟搭一版可玩Demo。等验证了玩法核心真的有趣再考虑移植到商业引擎做产品化省下的时间不是一星半点。希望这篇介绍能帮你把第一块游戏开发的敲门砖搬起来把那个属于你自己的小窗口点亮。写代码的路上坑很多踩完这些坑再回头看你会发现每一段没白走的路都算数。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →