简介:这份Pygame教程面向已掌握Python基础、希望入门2D游戏开发的读者,系统讲解游戏主循环、Surface对象、Event事件、Rect区域等核心概念,帮助读者从零搭建可运行的游戏程序。教程采用“知识点+示例”的编写方式,覆盖Display显示模块、Transform图像变形、Time时间控制、Draw绘图函数、Font文本字体以及精灵与碰撞检测等模块,并配有飞机大战、贪吃蛇等常见游戏场景的实践思路,便于边学边练、逐点吃透。资源包为1个PDF文件,大小约1.32MB,单文件结构适合按目录顺序通读或按模块检索查阅。目前已有1564人学习,说明其在Pygame入门资料中具有一定参考价值。读者可从中获得从环境安装、事件监听到绘图与字体渲染的完整知识链条,并借助示例代码理解游戏循环与碰撞检测的实现方式,适合作为课程学习或自学游戏开发的配套读物。
1. 游戏循环:为什么一个while True就能撑起整个 Pygame 程序
很多人第一次跑通 Pygame 示例时都会有一个错觉:屏幕上出现了一个窗口、贴了一行字,程序就“活了”。但真正让这个窗口不闪退、能响应关闭按钮、能持续刷新的,不是那行pygame.init(),而是藏在末尾那个看起来最不起眼的while True。这个死循环就是 Pygame 的心脏,业内叫它游戏主循环(Game Loop)。搞不懂它,你写出来的每个程序都只能“一闪而过”;搞懂了它,你才明白 Pygame 这套 pygame 课程资源里反复强调的“事件监听 + 状态更新 + 画面重绘”到底在循环什么。这一章先把结论摆出来:Pygame 不是“画完就完”,而是“每帧重画一遍”,所有模块都是围绕这个循环节奏服务的。
2. 主循环的三段式结构:事件、状态、重绘为什么必须按这个顺序
2.1 从“一闪而过”说起:没有循环的窗口为什么活不过一帧
先看一个反例。如果你只写pygame.display.set_mode()然后pygame.display.flip(),程序会瞬间结束,窗口刚创建就被销毁,肉眼看到的就是黑屏一闪。原因在于 Pygame 的窗口并不是一个“常驻”的操作系统窗口,它的存活完全依赖进程持续运行。进程一退出,窗口资源就被回收。
所以主循环的第一个职责,是让进程别死。但光不死还不够,窗口还需要不断被“喂”新内容,否则操作系统会认为它无响应。这就引出了主循环真正要干的三件事,原文把它总结得很清楚:
while True: # 1. 处理事件:把这一帧内发生的所有输入取出来 for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() sys.exit() # 2. 更新游戏状态(位置、分数、动画帧等) # 3. 把更新后的状态绘制到屏幕上 pygame.display.flip()这三段的顺序不是随便排的。先取事件、再改状态、最后重绘,是因为事件代表“这一帧玩家做了什么”,状态更新要基于这些输入,而重绘必须发生在状态确定之后。如果你把flip()放到事件处理前面,玩家这一帧的按键就要等到下一帧才反映到画面上,手感上就是“延迟一帧”。在 60 FPS 下这是 16 毫秒,单看不明显,但在快速操作的飞机大战里,累积起来就是“操作发飘”。
pygame.event.get()这个调用本身也有讲究:它返回的是自上次调用以来累积的所有事件,并且会把事件队列清空。这意味着你必须在每一帧都调用它,否则队列会越积越长,最终表现为“按了很多次键,程序过一会儿才一次性响应”。常见的实现思路是每帧只取一次、遍历处理,而不是在多个地方零散地调get()。
2.2flip()与update():整屏刷新和局部刷新的取舍
原文特意对比了pygame.display.flip()和pygame.display.update()。这两个方法都能让画面显示出来,但机制不同:
| 方法 | 刷新范围 | 适用场景 |
|---|---|---|
flip() | 整个显示 Surface | 全屏动画、画面整体变化 |
update() | 可指定矩形区域,不传参时等同 flip | 局部变化、静态背景 + 小范围动效 |
flip()在双缓冲模式下会交换前后缓冲,把后台画好的整帧一次性推到前台,避免撕裂。update(rect)则只把指定区域拷贝到屏幕,适合“背景不动、只有一个小精灵在动”的场景,能省掉大量无谓的像素拷贝。我一般会这么理解:flip()是“整页翻书”,update()是“只擦掉改动的那个字再重写”。新手阶段用flip()最省心,等画面元素多了、帧率掉下来了,再考虑用update()做局部优化。
提示:
update()不传参数时行为和flip()一致,但如果你传了矩形区域却漏掉了某个变化的元素,那个元素就会“残影”留在屏幕上,这是局部刷新最常见的翻车点。
2.3 帧率与Clock:为什么你的循环跑得“太快”反而是问题
主循环如果不加任何限制,会以 CPU 能跑多快就跑多快的速度空转。在一台普通机器上,这个循环每秒可能执行几千次,结果是:CPU 占用飙到 100%,动画快得看不清,而且不同机器上速度完全不一样——这就是游戏开发里经典的“帧率依赖”问题。
pygame.time.Clock就是来解决这个的。它的tick(fps)方法会在每帧末尾“补睡”一段时间,把循环速度压到目标帧率:
clock = pygame.time.Clock() while True: for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() sys.exit() # ... 更新与绘制 ... clock.tick(60) # 把这一帧的剩余时间睡掉,锁定 60 FPStick(60)的返回值是这一帧实际经过的毫秒数,你可以拿它做基于时间的运动计算,而不是“每帧移动固定像素”。原文提到一般人能接受的最低 FPS 约 30,流畅则要 60 以上,这个经验值在 Pygame 里同样成立。Clock对象还提供get_time()(上一帧耗时)和get_fps()(当前帧率),排查卡顿时这两个方法是第一手数据。
3. Surface 与 Rect:Pygame 把“画什么”和“画在哪”拆成了两套对象
3.1 Surface 是画布,blit是贴图:理解“主屏也是一张 Surface”
原文反复强调一个概念:screen = pygame.display.set_mode((400, 400))返回的screen本质上就是一个 Surface 对象,是整个程序里最大的一张“纸”。所有文字、图片、图形,都是先各自生成一张小 Surface,再用blit()贴到大 Surface 上。
# 生成一张包含文字的 Surface text = font.render("hello", True, (255, 0, 0), (0, 0, 0)) # 拿到它的矩形区域,设置居中 text_rect = text.get_rect() text_rect.center = (200, 200) # 把文字 Surface 贴到主屏的指定位置 screen.blit(text, text_rect)blit(source, dest, area=None, special_flags=0)的参数值得逐个说清:source是要贴的 Surface;dest可以是坐标元组也可以是 Rect;area传一个 Rect 时相当于“只抠出 source 的这一块来贴”,做精灵图裁切时很有用;special_flags控制颜色混合方式,默认是直接覆盖,传BLEND_RGBA_ADD之类的值可以做发光、叠加效果。
这里有个容易忽略的点:blit是覆盖式的,它不会自动擦除上一帧的内容。所以主循环里通常要先screen.fill(背景色)把整张纸刷一遍,再贴新内容,否则旧画面会残留。这就是为什么原文的示例里fill()总是出现在绘制之前。
3.2 Rect 不只是坐标:碰撞检测为什么靠它而不是靠像素
Rect 是 Pygame 里表示矩形区域的对象,有left、top、width、height以及派生的center、centerx、bottom等属性。新手常把它当成“一个存坐标的元组”,但它真正的价值在于内置的碰撞检测方法:
rect_a = pygame.Rect(0, 0, 50, 50) rect_b = pygame.Rect(30, 30, 50, 50) if rect_a.colliderect(rect_b): print("两个矩形相交了")colliderect()做的是轴对齐包围盒(AABB)相交判断,只比较四条边的位置关系,复杂度是常数级。相比之下,如果按像素逐个比对,一张 50×50 的图就要比 2500 次。游戏里成百上千个精灵同时检测碰撞,用像素级根本跑不动,所以 Pygame 的精灵碰撞体系全部建立在 Rect 之上。代价是精度:两个圆形物体用矩形包围盒判断,边角处会“提前”判定碰撞。常见的做法是给精灵一个略小于图像的 Rect,或者对圆形物体用距离判断做二次筛选。
原文还提到subsurface()和get_offset(),这两个方法配合使用可以从父 Surface 里切出一块子 Surface,子 Surface 的绘制会直接反映到父对象上。这在做“血条”“面板”这类需要局部更新的 UI 时很实用,因为你可以只重绘子区域。
3.3convert()到底省了什么:像素格式与 blit 速度的关系
原文在加载图片时特意写了pygame.image.load("xxx.png").convert(),并解释“保证图像像素格式与显示格式相同”。这句话背后的机制是:image.load()加载出来的 Surface 保留的是图片文件本身的像素格式(可能是 24 位、32 位、带不带 alpha 等),而显示 Surface 有它自己的格式。如果两者不一致,每次blit都要做一次格式转换,图片越多、帧率越低。
convert()把图像转换成和显示 Surface 相同的格式,之后blit就是纯粹的内存拷贝。带透明通道的图片则用convert_alpha(),它保留 alpha 通道但同样对齐格式。这是一个“加载时多花几毫秒、运行时每帧都省”的典型取舍,几乎所有正式项目都会在加载后立刻 convert。
4. 事件系统与时间控制:输入、定时、暂停是怎么被统一调度的
4.1 事件队列的本质:为什么event.get()必须每帧清空
Pygame 的事件系统是一个生产者-消费者队列。操作系统和 SDL 底层把键盘、鼠标、窗口消息塞进队列,你的主循环通过pygame.event.get()消费。原文列出的pygame.QUIT、键盘事件、鼠标事件,全都是这个队列里的消息类型。
关键机制在于:队列是有容量上限的,而且get()一次性取走全部。如果你某一帧因为逻辑卡顿没有调用get(),事件就会堆积;等下一帧一次性取出时,你会看到“连续好几个 KEYDOWN”,程序行为就乱了。所以事件处理必须放在主循环最前面,且每帧只调一次。
处理键盘事件时,event.key给出具体按键常量(如pygame.K_SPACE),event.type区分KEYDOWN和KEYUP。鼠标事件则用event.pos拿坐标、event.button拿左右键。原文的示例覆盖了这些基本用法,实际项目里更常见的是“按下持续移动”的需求——这不能只靠KEYDOWN,因为操作系统有按键重复延迟,正确做法是维护一个按键状态集合,在KEYDOWN时加入、KEYUP时移除,每帧根据集合更新位置。
4.2time.wait()与Clock.tick():两种“等”的区别
原文把pygame.time.wait(3000)用作“游戏暂停 3 秒后加载图片”。这个方法是阻塞式的:调用它的那一行会实实在在睡 3 秒,期间事件队列不处理、画面不刷新。用在加载前的简单停顿没问题,但绝不能放进主循环里做“每帧限速”,否则整个程序会卡死。
Clock.tick()则是非阻塞补偿:它计算这一帧已经花了多少时间,只睡掉差额部分,而且是在帧末调用,不影响本帧的事件和绘制。两者名字都带“等”,但一个把控制权交出去、一个把控制权留住,这是新手最容易混淆的地方。原文还提到set_timer()可以创建定时器,每隔固定时间往事件队列里投一个自定义事件,适合做“每隔 1 秒生成一个敌人”这类周期逻辑——它不阻塞,和主循环天然契合。
4.3 精灵与碰撞检测的调度位置
原文最后提到精灵和碰撞检测。精灵(Sprite)本质上是“一个 Surface + 一个 Rect + 一组更新/绘制方法”的封装,pygame.sprite.Group负责批量调用它们的update()和draw()。在主循环里的位置很明确:状态更新阶段调group.update(),重绘阶段调group.draw(screen),碰撞检测则夹在两者之间,用pygame.sprite.spritecollide()或groupcollide()完成。
这套调度的设计动机是把“逐个处理”变成“批量处理”。一百个精灵如果手动循环更新、手动 blit,代码又长又容易漏;交给 Group 之后,主循环只需要三行。碰撞检测同理,spritecollide内部会先用 Rect 做粗筛,再按需做精细判断,把性能开销压在必要范围内。
5. 避坑与排查:Pygame 新手最常翻车的五个地方
5.1 窗口一闪而过
现象:运行程序,窗口出现一瞬间就消失,终端没有任何报错。原因:主循环缺失,或者主循环里没有pygame.event.get(),程序执行完绘制就自然结束。解决:确认while True存在,且循环体内有事件遍历;如果只是想看静态画面,至少保留事件循环和flip()。
5.2 画面残影、拖影
现象:移动的物体后面拖着一串旧位置。原因:主循环里没有在绘制前screen.fill()清屏,或者用了update(rect)但矩形区域没覆盖到旧位置。解决:每帧绘制前先fill()背景色;用局部刷新时,把“旧位置”和“新位置”两个矩形都纳入更新区域。
5.3 中文显示成方块
现象:font.render()渲染中文,屏幕上全是方框。原因:默认字体不含中文字形。解决:用pygame.font.SysFont()指定系统中文字体名,或用pygame.font.Font()加载一个明确的中文字体文件路径。原文示例里直接写了字体文件路径,就是这个道理。
5.4 按键响应“粘滞”或“连发”
现象:按住方向键,物体移动一顿一顿,或者松开后还在动。原因:只处理了KEYDOWN没处理KEYUP,或者依赖操作系统的按键重复。解决:维护按键状态字典,KEYDOWN置 True、KEYUP置 False,每帧根据状态更新;不要用KEYDOWN的重复触发来驱动连续移动。
5.5 帧率越高跑得越快
现象:同一份代码,在性能好的机器上物体飞得飞快,在慢机器上像蜗牛。原因:位移写成了“每帧固定像素”,没有和实际经过的时间挂钩。解决:用clock.tick(60)的返回值或clock.get_time()拿到帧间隔,位移量 = 速度 × 帧间隔(秒)。这样无论帧率如何波动,单位时间内的移动距离都一致。
6. 从“能跑”到“跑得稳”:Pygame 这套抽象的设计取舍
把上面几层拆完,会发现 Pygame 的设计其实一直在做同一类取舍:用简单换可控,用约定换性能。
Surface 和 Rect 分离,意味着“画什么”和“画在哪”解耦,代价是你得自己维护两者的同步——精灵移动时忘了同步 Rect,碰撞检测就会错位。事件队列每帧清空,意味着输入处理必须及时,代价是不能“回放”历史事件。flip()整屏刷新简单可靠,代价是像素拷贝量大;update()局部刷新省性能,代价是区域算错就出残影。Clock.tick()锁定帧率让行为可预测,代价是它只是“限速”,并不能保证每帧都按时完成——如果单帧逻辑太重,实际帧率还是会掉,这时候get_fps()就是你的体温计。
我一般会这么理解这套抽象的定位:Pygame 不试图替你做游戏架构决策,它只提供“画布、贴图、事件、时钟、矩形”这几块最基础的积木,剩下的状态管理、场景切换、资源加载全交给你。这也是为什么这份教程从主循环讲到 Display、Surface、Transform、Time、Event、Draw、Font、精灵,顺序不是随意的——它按“一个程序从启动到跑起来需要哪些最小构件”来组织,先让你跑通闭环,再逐个模块加深。
验证自己是否真的理解了,有个很朴素的办法:不看教程,从零写一个“方块随方向键移动、碰到边界反弹、按 ESC 退出、窗口标题显示当前 FPS”的小程序。如果这四件事能一次写对、不查文档,说明主循环、事件、Rect、Clock 这四块已经长在你脑子里了。我早年学的时候就是卡在“按键粘滞”上反复翻车,后来养成一个习惯:凡是涉及持续输入,先把按键状态字典建好,再写移动逻辑,从那以后这类问题再没出现过。希望帮到你。
本文还有配套的精品资源,点击获取