很多人学完 Python 基础后遇到的第一道坎,不是不知道语法,而是不知道拿它干嘛。写爬虫要啃 HTTP 和 HTML,搞数据分析得先跟 Pandas、Numpy 打交道,唯独 Pygame 看起来最亲切——装个库、画个方块、按几下键盘,屏幕上就有东西跟着动。但真去翻教程又会发现,绝大部分文章教完"窗口 + 移动"就收工了,等你想着手做一个完整的游戏时,依然一脸茫然:分数怎么做?碰撞怎么检测?游戏结束怎么办?素材从哪来?
这篇我就拿"接水果"这个小项目,把从装环境到打磨玩法的一条完整链路走给你看。全程不依赖任何图片素材,所有画面都用 Pygame 的绘图接口画出来,这样你才能把精力全部集中在游戏逻辑本身。学完之后,你不但能照着手敲出一个可玩的游戏,更重要的是理解游戏循环、精灵体系、碰撞检测、状态管理这一整套底层思维——这些才是 Pygame 真正能教给你的东西,换到别的游戏引擎同样适用。
1. 为什么是"接水果"这个项目,以及开局前的准备
1.1 这个项目的学习价值在哪里
选入门项目有个很现实的标准:规则一眼能看懂,代码量又不能小到什么都学不到。"接水果"完美符合这两点——玩家控制一个挡板,接住从屏幕上方不断掉落的水果,接到加分,漏掉则扣命或结束。它的核心元素覆盖了游戏开发的五个基础模块:
- 窗口创建与画面刷新
- 键盘输入与物体移动
- 精灵(Sprite)的生成、更新、销毁
- 碰撞检测与计分
- 游戏状态切换(进行中、结束、重开)
这五个模块几乎在所有的 2D 小游戏中都会出现。你把这套流程走通,后面再做打砖块、飞机大战、贪吃蛇,本质上只是换皮和改规则。所以这篇我不只给你能跑的代码,每个环节都会解释为什么这样写,以及换个需求时该怎么变。
1.2 目录结构和环境搭建
先搭一个干净的工作目录,我习惯这种结构:
catch_fruit/ ├── main.py └── assets/ ├── catch.wav └── gameover.wavassets目录放声音文件,main.py放主程序。就算现在的版本还用不到音频,也建议一开始就把目录分好,免得后面代码越写越长时再重建工程。
安装 Pygame 很简单,建议先建虚拟环境再安装,避免污染全局 Python 环境:
python -m venv venv source venv/bin/activate # Windows 上使用 venv\Scripts\activate pip install pygame装完验证一下:
python -c "import pygame; print(pygame.version.ver)"能正常输出版本号就说明环境没问题。如果安装速度慢,换成国内镜像源就行。
1.3 画面的实现策略:为什么先用绘图接口而不是图片
很多新手一上来就满世界找水果 PNG 素材,结果贴在游戏里尺寸不对、背景不透明,光是处理素材就耗掉半天。所以这个项目里的"水果"和"挡板"全部用pygame.Surface自己画:一个色块加一条高光,视觉上简单干净,但代码量极小。
import pygame # 初始化 pygame.init() SCREEN_WIDTH = 800 SCREEN_HEIGHT = 600 screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption("接水果小游戏")set_mode返回的screen对象就是主画布,游戏里所有画面都是先画在这个画布上,再一次性显示出来。这里涉及一个底层概念:Pygame 默认采用双缓冲机制,你对screen做的所有绘制操作并不会立刻出现在屏幕上,而是先画到一个后台缓冲区,调用pygame.display.flip()之后才整体呈现。代码里你看到的flip()就相当于"把画好的这帧交给显示器",这是防止画面闪烁的关键,也是后面要讲的"游戏循环"中规定动作之一。
不用图片不等于不能表达物体。给方块做一点渐变,再加上一个不同颜色的边框,代表不同的水果,玩家在视觉上也能轻松区分。等你把逻辑完整跑通之后,再回来替换成美术素材也不迟。
2. 游戏循环和帧率控制:先把这个"永远在转的轮子"看清楚
2.1 一个能正常关闭的窗口
游戏和普通脚本最大的区别是:普通脚本从上到下执行完就结束,游戏则必须保持窗口常驻、持续响应输入、不断重绘画面,直到玩家主动退出。这个"一直运行"的结构就是游戏循环(Game Loop)。
先写一个基本骨架:
running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False screen.fill((0, 0, 0)) pygame.display.flip() pygame.quit()把pygame.QUIT事件单独拎出来说。很多初学者会忘记处理它,结果窗口点"关闭"按钮没反应,只能去终端 Ctrl+C。pygame.event.get()会取出这段时间内产生的所有事件,包括键盘、鼠标、窗口关闭等,游戏循环里必须逐个检查。这里的fill((0, 0, 0))是给整个画面刷成黑色,这一步不做的后果是上一帧的画面还残留在缓冲区,叠加起来会形成"残影"或"拖尾"效果。
2.2 clock.tick(FPS) 到底在干什么
上面的代码虽然能跑,但有个致命问题:循环会以最大速度运行,在你的机器上可能每秒跑几千帧。先不说 CPU 占用率拉满,更麻烦的是游戏逻辑的执行频率完全不可控。Pygame 提供pygame.time.Clock来限制循环频率:
clock = pygame.time.Clock() FPS = 60 while running: # 事件处理、绘制、逻辑更新... clock.tick(FPS)clock.tick(FPS)的作用是让这一帧的执行时间至少为 1/60 秒。如果循环体跑得快,它会主动休眠;如果跑得慢,它不会加速,只是如实反映实际帧率。
我建议你做这个实验:把clock.tick(60)去掉,然后移动挡板,你会发现挡板"飘"得极快,几乎抓不住;再加上clock.tick(10),挡板又会变得像慢动作。这就是帧率对游戏手感最直接的体现。FPS这个数值本身也是一个重要的调参手段,数值越大,更新越频繁,物体移动越平滑,但同时硬件负担也越高。60 是通用选择,兼容了流畅度和性能。
2.3 关于"速度"的一个认知升级:帧率和速度是怎么关联的
新手最容易踩的坑在这里:写移动逻辑时直接写self.rect.x += 5,然后发现不同电脑上游戏速度不一样。原因在于,这个 5 是"每帧移动的像素数",而不是"每秒移动的像素数"。在 60 FPS 下,每秒移动 300 像素;在 30 FPS 下,每秒只有 150 像素。机器性能不同,帧率不同,实际游戏速度就不同。
入门阶段可以靠clock.tick(FPS)把帧率固定住来规避这个问题,但你要意识到更深一层:如果某帧因为加载资源等原因卡顿了一下,游戏速度就会在那一瞬间变慢。更严格的做法是引入"时间步长"(delta time),用上一帧到当前帧的实际时间差来缩放移动距离,这样才能让游戏逻辑和渲染帧率解耦。
Pygame 里可以用pygame.time.get_ticks()获取程序启动以来经过的毫秒数,两帧的差值就是 delta time:
last_tick = pygame.time.get_ticks() while running: now = pygame.time.get_ticks() dt = (now - last_tick) / 1000.0 # 换算成秒 last_tick = now player.rect.x += 300 * dt # 相当于每秒移动 300 像素这个写法在入门项目里可能有点超前,但懂了之后,将来做跨平台或高帧率游戏时就不会被"为什么速度又变了"折磨。我自己的习惯是:先固定 FPS 把游戏做出来,后期要优化手感时再引入 delta time,两种方案心里都有数,遇事不慌。
3. 精灵(Sprite)体系:用类和精灵组替代一堆零散变量
3.1 为什么要用精灵而不是自己维护一个列表
"接水果"这个游戏里,玩家挡板是一个对象,每一颗落下的水果也是一个对象。如果不用任何结构,你会写出大量重复代码:x1 = 100; y1 = 10; x2 = 300; y2 = 50……每次都要手动更新、手动画、手动判断是否出界。Pygame 的解决方案是pygame.sprite.Sprite类:它帮你把图片(self.image)和矩形位置(self.rect)封装在一起,并提供了一整套批量更新、批量绘制、批量碰撞检测的方法。
先定义玩家类:
class Player(pygame.sprite.Sprite): def __init__(self): super().__init__() self.image = pygame.Surface((80, 20)) self.image.fill((0, 255, 255)) self.rect = self.image.get_rect() self.rect.midbottom = (SCREEN_WIDTH // 2, SCREEN_HEIGHT - 30) def update(self): keys = pygame.key.get_pressed() if keys[pygame.K_LEFT] and self.rect.left > 0: self.rect.x -= 5 if keys[pygame.K_RIGHT] and self.rect.right < SCREEN_WIDTH: self.rect.x += 5这里的self.rect是 Pygame 用于定位和碰撞的核心数据结构,它有x、y、width、height、left、right、top、bottom、center等属性,修改任何一个关联属性都会同步更新矩形的位置。midbottom表示用矩形的"下边中点"去对准(400, 570)这个坐标,这样初始化时挡板就正好停在窗口底部中间。
3.2 键盘控制的那点手感秘密
pygame.key.get_pressed()和事件里的KEYDOWN是两种不同的输入方式。get_pressed()返回的是当前键盘每个按键的按下状态,适合做"持续移动";KEYDOWN事件则在按键被按下的瞬间触发,适合做"跳跃""开火"这类单次动作。
这里有个常见手感问题:如果只用KEYDOWN事件改位置,按下一次只移动一格,想持续移动得按住并不断触发重复事件,而且按键延迟和重复频率受操作系统控制,手感又闷又难调。get_pressed()的写法是每一帧都检查按键状态,按住左键就持续往左移动,逻辑直观,灵敏度完全由自己控制。
3.3 水果的生成、下落和清理
水果物体同样继承Sprite,它的特点是会自己往下掉,掉落过程中要给不同的水果设置不同的下落速度,这样画面才有层次感:
import random class Fruit(pygame.sprite.Sprite): def __init__(self): super().__init__() size = random.randint(20, 40) self.image = pygame.Surface((size, size)) self.image.fill((255, 100, 100)) self.rect = self.image.get_rect() self.rect.x = random.randint(0, SCREEN_WIDTH - size) self.rect.y = -size self.speed = random.uniform(3, 6) def update(self): self.rect.y += self.speed if self.rect.top > SCREEN_HEIGHT: self.kill()水果的初始位置放在屏幕上方外侧(y = -size),这样水果是"从画面外"掉进来的,而不是凭空出现在画面内。self.kill()是Sprite类的一个重要方法,它会把精灵从它所属的所有精灵组中移除。这里的作用是:水果从底部掉出屏幕后就不再参与绘制和碰撞,防止它永远存在于内存中。初学者最容易漏掉这步,结果玩了几分钟后游戏越跑越慢,就是因为页面外的东西堆了几百个还在参与运算。
精灵组(pygame.sprite.Group)就是为这些精灵准备的"容器",它有几个非常有用的方法:
group.add(sprite):添加成员group.update():自动调用所有成员的update()group.draw(surface):把所有成员的self.image画到指定画布group.empty():清空所有成员
对比一下两种写法:
| 手动管理列表 | 精灵组 |
|---|---|
自己写for fruit in fruits: fruit.update() | fruits.update()一行搞定 |
自己写for fruit in fruits: screen.blit(fruit.image, fruit.rect) | fruits.draw(screen)一行搞定 |
| 手动删除出界对象较繁琐 | kill()自动移除 |
| 碰撞检测要自己嵌套循环 | spritecollide一行搞定 |
有了这张对比表,你应该能理解为什么 Pygame 官方文档也推荐用精灵组。它把"管理一堆游戏对象"这个高频操作封装成了标准接口,属于 "写一次、受益整个项目" 的投资。
3.4 碰撞检测只写了一行
挡板接到水果的判定,本质是判断两个矩形是否相交。Pygame 提供pygame.sprite.spritecollide方法:
hits = pygame.sprite.spritecollide(player, fruits, True) if hits: score += len(hits)第三个参数True表示碰撞到的水果会被自动从组里移除,也就是"被吃掉了"。这里我踩过一个小坑想提醒你:如果不把碰撞到的水果移除,同一颗水果会在连续的几帧里持续和挡板重叠,导致分数疯狂上涨。原因很简单——挡板横向移动时和水果重叠的时间可能持续多帧,每帧都碰撞一次就变成每秒加几十次分。dokill=True配合"碰撞后立刻生成新水果"的逻辑,才能保证一颗水果只加一次分。
4. 游戏状态、计分和难度曲线:让游戏真正"成立"
4.1 引入状态机的时机
现在游戏已经有基本的"接"和"掉"了,但还缺一个重要环节:生命。如果把生命值做完,还要处理"游戏结束时画面怎么停""按什么键重开"这类逻辑。代码一多,如果全写在while running里,逻辑就会互相打架——比如游戏都结束了,水果还在往下掉,事件还在响应移动。
这时就该引入状态机。它其实很简单,本质是一个变量state,根据state的值决定走哪套逻辑:
STATE_PLAYING = "playing" STATE_GAMEOVER = "gameover" state = STATE_PLAYING lives = 3 score = 0主循环里,每一帧先看状态,再分派逻辑:
while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN: if state == STATE_GAMEOVER and event.key == pygame.K_r: # 重置游戏 score = 0 lives = 3 fruits.empty() state = STATE_PLAYING if state == STATE_PLAYING: # 游戏进行中的逻辑:移动、生成水果、碰撞检测 ... else: # 游戏结束画面,显示分数和提示文字 ...状态机的核心价值是让每一块的职责清晰:进行中才处理游戏逻辑,结束后只处理重开和退出。不要小看这个思路,后续任何稍微复杂一点的游戏,都会有"标题画面""暂停""结算界面"等多个状态,早期建立一个清晰的状态分支结构,比自己到处打补丁省心得多。
4.2 生命值、计分和难度递增的配合
"接水果"的生命值设计我倾向于用"漏掉一颗扣一命"而不是"掉到地上就结束",这样新手也能有一次失误的余地。漏掉水果的检测,正好可以利用之前的kill逻辑:水果如果掉出屏幕,说明玩家没接到。问题在于,掉出屏幕的水果本来就会被kill,那怎么统计"漏掉"呢?
有两种方案。方案一是水果的update里掉出屏幕时,向主循环发送信号——但跨类传数据比较绕。方案二更直接:在主循环的fruits.update()之前记录一下组里的数量,更新后对比一下少了几颗,少的那些就是漏掉的。不过这个方案在"碰撞吃掉"和"掉出屏幕"同时发生时不好区分。
更干净的做法是给Fruit一个标记属性:
class Fruit(pygame.sprite.Sprite): def __init__(self, is_bomb=False): super().__init__() self.flag_lost = False ... def update(self): self.rect.y += self.speed if self.rect.top > SCREEN_HEIGHT: self.flag_lost = True self.kill()然后在主循环更新之后统一检查:
for fruit in fruits: if fruit.flag_lost: lives -= 1这里我把水果做成每次只检查一轮flag_lost,避免同一颗果子反复扣血。当然,如果你愿意给Fruit再传一个"是否炸弹"的选项,把它扩展成接炸弹扣命、接水果加分,那游戏的策略性会再上一个台阶。这里点到为止,你先掌握基础版本。
难度递增方面,我采用最简单的公式——分数每增加 5 分,新生成水果的初始速度就快一点:
base_speed = 3 new_fruit_speed = base_speed + score // 5 * 0.3这样设计的好处是:你不用手动去卡"第 10 关加多少、第 20 关加多少"这种阶梯值,一条公式天然把难度曲线做得滑顺、可预测。想要更陡,就把0.3调成0.5;想要更平缓,调成0.1。整个游戏的"手感调参"就是几个数字的事,这也是做游戏很有意思的地方。
4.3 结束时显示什么,以及"重开"是个好功能
游戏结束界面不能只是一块黑屏。至少要有三样东西:最终分数、历史最高分、重开提示。Pygame 的文字显示比想象中啰嗦一点,它不像print那么容易——需要先创建字体对象,再渲染成 Surface,最后blit到屏幕上:
font = pygame.font.Font(None, 36) def draw_text(text, size, color, x, y): f = pygame.font.Font(None, size) img = f.render(text, True, color) screen.blit(img, (x, y))font.render会生成一个包含文字的图片对象,第二行代码True表示开启抗锯齿,能让文字边缘平滑一些(对小白友好,稍微好看点)。None字体是 Pygame 内置的默认字体,它不支持中文,这点特别坑——如果你render("游戏结束"),屏幕上会显示一堆方框。要么改用英文提示,要么用pygame.font.match_font匹配系统里的中文字体:
font_path = pygame.font.match_font("microsoftyahei") # Windows 相关字体 font = pygame.font.Font(font_path, 36)实操中我见过这个坑被反复踩,所以建议你一开始就决定好:要么全程英文界面,要么直接匹配中文字体。等界面文字能正常显示,游戏结束的闭环才算真正跑通——分数出现,提示按 R 重开,按了 R 之后状态回到STATE_PLAYING,生命值归位、分数清零、水果组清空。这个"完整闭环"的存在感极其重要,它让玩家觉得你做的不是一个"演示程序",而是一个可以反复玩的东西。
5. 音效、存档与性能优化,以及打包时的实用提醒
5.1 音效很提气,但别踩音频格式的坑
音效对游戏体验的提升,远比你想象的大。一个简单的"叮"一声接住提示,和一个低沉失败音做游戏结束反馈,能让整个项目的"完成度"上一个台阶。Pygame 的音频有两条口子:
pygame.mixer.Sound:适合短音效,加载后可以反复播放pygame.mixer.music:适合背景音乐,支持流式加载
初始化需要单独调一次:
pygame.mixer.init() catch_sound = pygame.mixer.Sound("assets/catch.wav")在碰撞代码里加上:
if hits: catch_sound.play()关于音频格式,我建议优先使用wav或ogg,尽量避免mp3。原因有几个:一是mp3在部分系统上需要额外的解码依赖,换环境后容易出现"初始化成功但播放没声音"的诡异问题;二是wav的加载行为在不同平台上更加稳定,对入门项目省心。音效素材可以自己用免费工具生成,也可以用现成的免费音效包,注意音量不要太炸,catch_sound.set_volume(0.5)可以控制。
5.2 性能优化的三板斧
入门项目最容易积累性能问题的三个地方,提前打好预防针:
第一,图片转换。如果你用了pygame.image.load加载图片,Surface默认格式在blit时会经历像素格式转换,比较耗性能。加载之后马上调用convert()(如果有透明背景则用convert_alpha())能显著加速:
img = pygame.image.load("assets/fruit.png").convert_alpha()第二,对象膨胀。每生成一颗水果就是一个新对象,如果玩久了不清理,哪怕每个对象逻辑很轻,数量积累到上千个也会导致每帧更新耗时明显上升。所以我在前面强调掉出屏幕的kill(),配合限制场上水果数量的上限(比如最多 20 个),就是最简单有效的防膨胀手段:
if len(fruits) < 20: fruits.add(Fruit())第三,重复计算。像score // 5 * 0.3这种公式,每次生成水果都算一遍没问题,但如果你在update里做了大量冗余计算(比如每帧都重新加载字体、重新读取文件),那就要警惕了。原则是:能放到初始化里的,绝不放进循环里。
5.3 最高分存档:JSON 读写很简单
给游戏加一个"最高分"存档,是性价比极高的功能,它让玩家有持续挑战的目标。用标准库json就行,不需要引入数据库:
import json import os def load_highscore(): try: with open("highscore.json", "r") as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return 0 def save_highscore(value): with open("highscore.json", "w") as f: json.dump(value, f)读取时用try包裹,是因为存档文件可能不存在或损坏,直接报错会让游戏崩溃。这也是实战中很重要的容错思想:外部输入(包括文件)永远有可能是坏的,程序要能优雅降级而不是直接退出。在每次游戏结束的时候判断:
if score > highscore: highscore = score save_highscore(highscore)这样即使游戏进程退出再重开,最高分还在。我第一次给自己游戏加这个功能时,玩得明显上瘾了许多——有个数字在逼着你去刷新它。
5.4 打包成可执行文件时要注意什么
游戏写完想发给朋友玩,通常要打包成 exe。这里有个我踩过两次的坑:打包后找不到assets目录下的音频文件。原因很简单:程序内用了相对路径"assets/catch.wav",而打包后工作目录并不总是和 exe 所在目录一致。
最省事的办法,是启动时动态计算资源路径:
import sys import os def resource_path(relative_path): base_path = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) return os.path.join(base_path, relative_path)一段经典的兼容打包环境的路径函数,能同时照顾源码运行和打包运行两种情况。至于具体打包工具的选项,根据你用的打包工具灵活设置,把assets目录作为资源一起打进去即可。这方面的坑说大不大,说小不小,但一旦遇到就是"发给朋友双击后报错"这种尴尬场景,还是提前处理为好。
5.5 几个调试小经验,能省下一晚上的时间
最后分享几条调试心得,都是我自己写 Pygame 时真实踩过的:
一是"画面不动"先查循环。如果你发现窗口出来了但内容不更新,绝大多数情况是pygame.display.flip()没被调用,或者被写进了条件分支里没有执行。
二是"能跑但很卡"先看终端输出。Pygame 有些输出会直接打到终端里,比如音频解码报错。卡顿和异常往往同时出现,先看报错信息定位,比瞎改代码高效得多。
三是"按键没反应"别急着怀疑逻辑。先检查事件循环是不是被某个阻塞操作卡住了,比如在循环里调用time.sleep(),这是新手常踩的坑——你以为只是暂停一下,其实卡住了整个事件处理,所有按键操作都排队等待。
四是把调试信息直接画在界面上。我平时会在开发阶段用draw_text把当前 FPS、精灵数量、状态变量直接显示在窗口左上角,跑起来一眼就能看到各项数据变化,比在各种print之间来回切换直观得多。调试完再删掉这些显示代码就行。
写到这里,整个"接水果"项目该具备的东西已经齐了:窗口、角色、掉落物、碰撞、计分、生命、状态切换、音效、存档、打包注意事项。要说还有什么遗憾,大概是这个项目还没有"炸弹"和"加速道具"这些进阶元素,但那些都属于在基础骨架上做加法。对第一次接触 Pygame 的人来说,把当前这套循环和精灵体系彻底吃透,比功能多不多更重要。如果你也在做自己的第一个 Pygame 项目,我建议你先把整体逻辑跑通,再去追求画面和玩法丰富度——看到一个自己能玩的游戏成形,那种成就感是很有推动力的。