☰
用Pygame从零开发Flappy Bird:核心机制与工程实践
2026/10/12 19:24:23 网站建设 项目流程

1. 做游戏之前:先想清楚你要复刻什么

想用Pygame开发第一个小游戏,多数人第一反应是“打开编辑器,写个贪吃蛇”。但以我见过不少初学者的经验,这个起点往往不是最快的路径——贪吃蛇涉及蛇身增长、方向队列、食物刷新、自碰撞检测,逻辑层比画面层复杂得多。你的第一目标应该是“跑通一个闭环”,而不是“复刻童年回忆”。

我的建议是找一个机制足够简单的参照物,比如Flappy Bird。它的核心逻辑只有三件事:方块受重力下落、点击给一个向上速度、柱子不断向右移动并计分。这三件事恰好覆盖了Pygame最常用的能力:帧率控制、事件监听、坐标变换、碰撞检测、文本渲染。做完它,你对Pygame的掌握程度比做完半个贪吃蛇扎实得多。

1.1 Pygame适合做什么,不适合做什么

先说清楚边界。Pygame适合做2D平面游戏、教学演示、原型验证,以及不需要高帧率和高精度的休闲玩法。它不适合做3D渲染、物理仿真、大规模粒子特效,更不适合做需要精确网络同步的联机玩法。它是SDL库的Python封装,本质上是给你一套窗口管理、图形绘制、事件分发、音视频播放的接口,游戏逻辑仍然要你自己写。

所以,把Pygame理解成“画板+计时器+输入监听器”的组合会更准确。你负责的,是在每一帧里决定“画什么、画在哪、要不要碰撞”,Pygame负责的是“把这块画布按30fps或60fps推到屏幕上”。

1.2 安装和项目结构

安装没什么可说的,pip install pygame即可。但有件事容易被忽略:Pygame在初始化时需要一个固定的代码顺序。很多人遇到的“窗口闪退”“没有响应”这类问题,九成都是初始化顺序写错了。

一个干净的项目骨架是这样的:

import sys import pygame def main(): pygame.init() screen = pygame.display.set_mode((640, 480)) pygame.display.set_caption("我的第一个小游戏") clock = pygame.time.Clock() running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False # 游戏逻辑更新 # 绘制 pygame.display.flip() clock.tick(60) pygame.quit() sys.exit() if __name__ == "__main__": main()

把入口收敛到main()里,用clock.tick(60)锁帧率,用pygame.display.flip()刷新画面。这套骨架几乎可以套用到所有Pygame项目,后续所有游戏逻辑都是在while running循环里扩展。

有个小细节值得单独提一下:clock.tick(60)不一定真的能跑满60帧。它保证的是“这一帧不会快于1/60秒”,但若某帧逻辑太重,实际帧率会低于60。后面会专门说这个问题。

2. 帧率、事件循环与坐标体系:绕不开的三个地基

Pygame的所有玩法,本质上都是在一个循环里反复执行“读输入-算状态-画画面”。这个循环跑得稳不稳,决定了游戏手感好不好。很多初学朋友问“为什么我的方块动起来一卡一卡的”,答案往往不在绘画代码里,而在帧率和状态更新的关系上。

2.1 帧率是速度的基准,不是装饰

先看一个常见写法:让方块每帧向右移动1像素。

x += 1

如果帧率是60fps,方块每秒移动60像素;如果某台电脑只能跑到30fps,方块每秒就只移动30像素。同一个游戏在不同设备上速度不同,这在高速移动的玩法里会直接破坏手感。

更合理的做法是引入“每帧时间跨度”的概念,通常叫dt(delta time)。它在Pygame里的获取方式很简单:

dt = clock.tick(60) / 1000.0 # 转成秒 x += 200 * dt # 每秒移动200像素

这样速度就和帧率解耦了——不管屏幕刷新是30还是60,方块每秒都稳定移动200像素。Pygame的tick()方法返回上一帧到这一帧的毫秒数,除以1000转成秒,做匀速运动时直接乘速度即可。重力、跳跃、滚动都建议用这种方式,而不是直接加固定步长。

2.2 事件循环:Pygame的全部“感知”能力

Pygame里,按键、鼠标、窗口关闭、甚至手柄输入,全部以“事件”的形式进入队列。你需要在每一帧开头把队列里的事件取出来,一一处理。

for event in pygame.event.get(): if event.type == pygame.KEYDOWN: if event.key == pygame.K_SPACE: bird.velocity = -jump_strength

初学者容易犯的一个错误是:把事件处理写在游戏循环外面,或者只处理QUIT事件就以为万事大吉。按键没反应、鼠标点不动,基本都是这个原因——事件队列里的消息没有被及时取走,越积越多,后面的帧就会拿到一堆旧事件,造成“按一下跳好几次”的延迟感。

另一个容易踩的坑是“按下和抬起”。如果你要的是“按住持续移动”,应该监听KEYDOWN设置状态位,在KEYUP时清除;如果只监听KEYDOWN,那就只能触发单次行为。这两个语义差异在动作游戏里影响很大。

2.3 坐标系:左上角是原点,y轴向下

Pygame的坐标系和数学课上的直角坐标系不太一样。原点在窗口左上角,x轴向右增大,y轴向下增大。这意味着,想让方块往上飞,需要减小它的y坐标,而不是增大。

这一点听起来简单,但很多小游戏的手感问题都出在这里——重力方向写反了、物体往下掉反而画面往上飘,根源都是y轴方向弄反了。一个稳健的做法是:把所有的游戏逻辑坐标都抽象成(x, y),并在逻辑更新完成后统一交给绘制函数。不要在绘制函数里做坐标变换,那样调试起来非常痛苦。

3. 核心玩法落地:一个可飞可摔的小方块

现在进入正题,把Flappy Bird风格的玩法拆解开写。这部分会逐步搭建:方块物理、柱子生成、碰撞判定和计分。每一段都附上代码和“为什么这么写”的说明。

3.1 方块物理:重力、跳跃和速度钳制

小方块的状态可以用三个数字描述:x坐标、y坐标、垂直速度。每一帧做三件事:速度加上重力增量、坐标加上速度、如果碰到地面则重置位置。

GRAVITY = 800.0 # 重力加速度,单位:像素/秒² JUMP_SPEED = -320.0 # 跳跃初速度,正值向下,负值向上 MAX_FALL_SPEED = 500.0 velocity = min(velocity + GRAVITY * dt, MAX_FALL_SPEED) y += velocity * dt if y >= ground_y - bird_radius: y = ground_y - bird_radius velocity = 0

MAX_FALL_SPEED是很多教程不会提的细节。没有它,方块在下坠过程中速度会无限增大,可能出现“一帧从屏幕顶端瞬移到地面”的穿越问题。钳制最大下落速度,既保证画面合理,也降低碰撞检测的精度压力。

跳跃的实现也有讲究。不要直接修改y坐标,而是给速度赋一个向上的负值,然后让重力逐渐把它拉回来。这样能形成抛物线轨迹,手感比“点击后固定上移一段”自然得多。这个负值的大小需要反复试——它决定了跳跃高度和滞空时长,是游戏手感的第一个调音旋钮。

3.2 柱子的生成:用距离控制节奏,而不是用位置

柱子的生成逻辑,新手最容易写崩。如果代码里写死“每隔300像素生成一根柱子”,由于速度恒定,排序看似没问题,但只要速度变化(比如加速模式),柱子就会越挤越密或越来越稀。

更稳的方案是:记录“下一根柱子距上一根的最小间距”,并动态计算生成时机。

PIPE_SPACING = 220.0 # 两根柱子之间的水平间距 PIPE_WIDTH = 70 if last_pipe_x is None or screen_width - (last_pipe_x + PIPE_WIDTH) >= PIPE_SPACING: add_new_pipe()

生成时的关键参数是柱子之间的垂直缝隙大小。缝隙太大没有难度,太小又会让玩家觉得“明明是同一个位置却被判定撞了”。建议先把缝隙设在200像素左右试玩,再每次减10像素调整难度。

柱子本身是Rect对象列表。每根柱子需要两个矩形:上柱和下柱。为了方便管理,用一个字典记录位置、上柱矩形、下柱矩形和是否已计分。

def create_pipe_at(x): gap_y = random.randint(120, 320) top_pipe = pygame.Rect(x, 0, PIPE_WIDTH, gap_y - GAP_SIZE) bottom_pipe = pygame.Rect(x, gap_y + GAP_SIZE, PIPE_WIDTH, screen_height - gap_y - GAP_SIZE) return {"x": x, "top": top_pipe, "bottom": bottom_pipe, "scored": False}

random.randint决定了每根柱子缝隙的纵向位置。为了让游戏公平,缝隙位置应有合理区间,不要贴顶或贴地。否则玩家会反复因为极端位置而死亡,挫败感远高于挑战感。

3.3 移动与生命周期:循环的世界

柱子生成后,每帧向左移动:

pipe["x"] -= SCROLL_SPEED * dt pipe["top"].x = pipe["x"] pipe["bottom"].x = pipe["x"]

一旦柱子的x坐标加上宽度小于0,说明已经完全离开屏幕左侧,直接剔除。列表的清理很关键,否则柱子对象会无限累积,帧率随之下降。

pipes = [p for p in pipes if p["x"] + PIPE_WIDTH > 0]

这里有个细节:对Rect对象的x赋值是可行的,但如果你重新设置整个Rect,旧引用就会失效。所以先修改存储的x,再同步给两个Rect,保持数据源唯一。

3.4 碰撞判定:用矩形碰撞,别用像素精度

Pygame自带Rect.colliderect()方法,适合大部分2D游戏。先用方块的外接矩形和柱子矩形做粗略判定:

bird_rect = pygame.Rect(bird_x - bird_radius, bird_y - bird_radius, bird_radius * 2, bird_radius * 2) if bird_rect.colliderect(pipe["top"]) or bird_rect.colliderect(pipe["bottom"]): game_over()

很多“明明没碰到却死了”的抱怨,根源在外接矩形比视觉图形大。解决方法是把碰撞矩形向内缩小一圈,比如在Rect基础上调用inflate(-6, -6),让判定范围比显示范围更宽容。

hitbox = bird_rect.inflate(-6, -6) if hitbox.colliderect(pipe["top"]) or hitbox.colliderect(pipe["bottom"]): game_over()

这种“碰撞箱比视觉小一圈”的手法,几乎所有动作游戏都在用。它不降低挑战感,但会让玩家觉得判定公平,而不是被看不见的边界坑死。

地面碰撞同样别漏掉:方块y坐标加上半径大于地面高度时,也属于游戏结束。否则玩家会看到方块陷进地面里,非常出戏。

4. 从能玩到好玩的四个细节:碰撞、计分、音效与状态流

代码能跑、柱子能刷、方块能飞,这只是“能玩”。距离“好玩”,还有四个细节值得打磨。它们不复杂,但对体验的提升立竿见影。

4.1 碰撞后的死亡策略:冻结而非立即重置

很多原型在碰撞瞬间直接回到起跳点,玩家还没反应过来发生了什么。更好的做法是:碰撞时进入“死亡状态”,让方块做一次下落或翻转动画,1.5秒后再回到开始界面。这么做有几个好处:玩家能清楚看到失败原因;避免误触屏幕直接重启,导致“还没看清就死了”的挫败感。

关键是在状态机里增加一个STATE字段:

# 状态枚举:READY, PLAYING, DYING, GAME_OVER if STATE == "PLAYING": update_bird(dt) update_pipes(dt) check_collision() elif STATE == "DYING": bird_velocity = min(bird_velocity + GRAVITY * dt, MAX_FALL_SPEED) bird_y += bird_velocity * dt death_timer -= dt if death_timer <= 0: STATE = "GAME_OVER"

状态机的好处是:不同阶段执行不同逻辑,避免大量flag判断散落各处。Pygame项目规模一大,没有状态机几乎必乱。

4.2 计分:通过后加分,不是碰到就加分

计分逻辑看起来简单,但有个隐蔽的坑:一根柱子应该在玩家通过缝隙后加1分,而不是在柱子生成时加。否则玩家只要站着不动,分数也会疯涨。

实现方式是给每根柱子加一个scored标记:

if not pipe["scored"] and pipe["x"] + PIPE_WIDTH < bird_x: score += 1 pipe["scored"] = True

条件是用“柱子右边缘越过方块x坐标”来判断。这个时机比“方块中心越过柱子中心”更宽松,玩家会觉得“我明明差点撞到但得了分”,心理上更爽快。

4.3 音效:不追求华丽,但要即时反馈

音效最好在碰撞、得分、起跳三个事件里都配上。Pygame播放音效很简单,麻烦的是素材。你不想随便下载一个版权不明的音频文件,但自己录音又很麻烦。

一个实用技巧是用Python内置的array模块合成简单的提示音。比如得分音可以是一段短促的正弦波:

import array import math import pygame def make_beep(freq=660, duration=0.08, volume=0.5): sample_rate = 22050 n_samples = int(sample_rate * duration) buf = array.array("h") for i in range(n_samples): t = i / sample_rate val = int(volume * 32767 * math.sin(2 * math.pi * freq * t)) buf.append(val) return pygame.mixer.Sound(buffer=buf.tobytes())

实测下来,这种合成音效足够用,还避免了对本地音频文件的路径依赖。音效播放前记得在初始化时调用pygame.mixer.init(),否则会静默失败。

4.4 状态流和UI文字:别让玩家迷路

“开始界面-游戏界面-结束界面”的切换,要显式地画出来。很多原型的问题在于:进入游戏后直接开始,玩家还没准备好;死了之后没有任何提示,玩家不知道按什么键重开。

建议至少画三个元素:标题文字、得分、重开提示。Pygame的文字渲染要先创建Font对象,再渲染成Surface,最后blit到屏幕。这个流程不算复杂,但很多人会在blit时搞错坐标对齐方式。

font = pygame.font.SysFont("Arial", 36) text_surface = font.render(f"Score: {score}", True, (255, 255, 255)) text_rect = text_surface.get_rect(center=(screen_width / 2, 50)) screen.blit(text_surface, text_rect)

用get_rect(center=...)来控制对齐,比手动计算x偏移靠谱得多。

5. 发布之前:代码还有哪些值得修复的坏味道

一个原型和一个小游戏的差别,往往不在玩法,而在工程组织。你很可能已经注意到,游戏逻辑越多,main()越长,最后变成一坨千行代码。这一节聊几个值得提前处理的坏味道。

5.1 把游戏逻辑从渲染逻辑里拆出来

一个常见的错误是:在绘制代码里改游戏状态。比如在screen.blit()之前判断是否碰撞、是否得分,这会带来两类问题:绘制被打断时逻辑没执行;逻辑和绘制顺序耦合,后续想调整帧率或加动画会非常痛苦。

我习惯的拆法是:逻辑更新集中在一个函数里(update(dt)),画面绘制集中在另一个函数里(render(screen))。逻辑函数负责改所有状态,渲染函数只看状态、不改状态。这个模式对于所有Pygame项目都适用,也是后面加功能时最省心的结构。

5.2 global变量成堆,尽早用类或数据类

随着柱子、分数、状态一起增加,你可能会写出一堆global bird_y、global score。这不是语法错误,但调试很痛苦。你无法快速判断一个变量当前在哪里被修改,而这类问题在游戏开发里几乎必然出现。

不需要把整个游戏用面向对象重写,但至少可以把“游戏世界”的公共状态汇聚到一个地方。比如用一个GameState字典或一个简单的类来承载所有可变数据:

class GameState: def __init__(self): self.bird_y = screen_height / 2 self.bird_velocity = 0.0 self.pipes = [] self.score = 0 self.state = "READY"

之后所有函数只接受state参数并修改它的属性。虽然多敲几行代码,但排查问题的效率提升一个量级。

5.3 硬编码数值:能收敛就收敛

重力、跳跃速度、柱子间距、缝隙宽度这些数值,散落在十几个地方的话,调整手感就是噩梦。把它们统一放在常量区,并在顶部集中维护:

GAME_CONFIG = { "gravity": 800.0, "jump_speed": -320.0, "pipe_spacing": 220.0, "gap_size": 180.0, "scroll_speed": 180.0, "max_fall_speed": 500.0, }

实测调手感时,你只需要改顶部数据,反复启动游戏验证即可。不要觉得这是小题大做——一个数值在不同函数里两处不一致,通常就是手感奇怪或判定不对的根源。

5.4 别忘了“暂停”:按住窗口时会出大问题

窗口失焦时,Pygame仍可能继续执行游戏循环,但事件不会更新,方块可能继续下落、柱子继续滚动,回来时已经死了。处理方式很简单,用一个PAUSED状态,在窗口失去焦点时暂停,获得焦点时恢复。这是小游戏里最容易让玩家困惑的细节之一,也是最容易修复的一个。

6. 运行异常排查:常见报错背后的真实原因

写完代码总会碰到几个报错。我见过的、新手最容易卡住的,汇总成下面几类,提供可复现的分析链路。

6.1 “pygame.error: video system not initialized”怎么处理

这个错一般是pygame.display.set_mode()之前就已经使用绘制相关API。很多人的代码是因为import的模块里,在顶层直接执行了绘制操作,而主模块还没调用pygame.init()。解决办法是检查所有模块的顶层代码,确认没有在函数外调用display相关函数。如果实在查不出来,可以在pygame.init()后立刻打印一行日志,确认初始化确实执行完毕。

6.2 画面闪动,方块有残影

这通常不是游戏逻辑问题,而是忘了调用pygame.display.flip()或者每帧没有清除旧画面。Pygame绘制新帧前,需要用screen.fill((0, 0, 0))涂一层背景,否则上一帧的图案会残留在缓冲区里。记住一个固定流程:填充背景 -> 绘制所有元素 ->flip()。

如果做了flip还闪,检查是否把flip放进了事件循环内部,导致某些事件触发时才刷新。

6.3 图像加载失败:需要绝对路径还是相对路径?

Pygame的pygame.image.load()使用相对路径时,依赖的是当前工作目录,不是脚本所在目录。这一条经常坑到人:脚本在A目录,你在B目录用命令行启动,图像就是找不到。

稳妥办法是用脚本文件的位置计算路径:

import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) img_path = os.path.join(BASE_DIR, "assets", "bird.png")

如果没用外部图片,而是用pygame.draw.rect()画矩形和圆形,就完全不用考虑路径问题。从学习角度看,这反而能帮你把注意力放在逻辑上。

6.4 分数显示跳动、文字模糊

分数文字如果反复渲染和擦除,容易出现“叠字”现象。解决方法有两步:第一,保证每帧填充背景后再渲染文字;第二,把不变的文字(比如标题、按钮)在初始化时渲染一次,存成Surface,每帧直接blit,而不是每帧重新渲染。实测下来,后者能省不少CPU开销,页面也更加干净。

6.5 表现不佳:一个容易忽略的死循环点

如果你写了一个while True但没有pygame.event.get(),窗口会一直转圈,无法响应关闭事件。表现是程序跑起来但无法退出,只能强制杀进程。解决办法是:事件循环放在主循环里,而不是主循环外面;主循环的条件用状态变量而不是字面量True。

7. 性能优化:怎么让你的小游戏跑得更稳

Pygame的性能不上不下,但足够支撑轻量小游戏。问题是——如何让它“稳定”,而不是“最高能跑多少帧”。很多新手做完原型后说“有点卡”,其实不是渲染性能不足,而是逻辑层拖了后腿。

7.1 绘制开销:能合并的合并,能预渲染的预渲染

每帧调用pygame.draw.rect()几百次,性能通常还能接受;但如果场景里有大量静态元素,就应该预先渲染成一张Surface,每帧只blit一次。比如背景天空、地面纹理,这些完全可以在初始化时画好,没必要每帧重画。

7.2 对象池:减少创建和销毁的GC压力

每一帧都在创建新的Rect、新的列表对象,长时间运行时会频繁触发Python的垃圾回收。解决方式不是“别创建对象”,而是“复用对象”。柱子的移动可以只改x坐标,不必每帧创建新对象。分数显示更新时,用同一个Surface重新fill即可,而不是创建新的Font实例。

对于轻量小游戏,这可能不是最大的性能瓶颈,但做了能明显提升长时间运行时的稳定性。

7.3 用好pygame.sprite.Group还是自己管理列表

Pygame自带的sprite.Group提供精灵管理,还能和spritecollide搭配做碰撞检测。但很多人一开始用不惯,反而不如直接操作列表直观。我的看法是:如果游戏中的可交互对象不超过几十个,直接操作列表完全够用,代码也更清晰;如果未来要加子弹、敌人、道具等多类对象,再切到sprite.Group会更划算。

不要为了用某个框架特性而引入复杂度,先把简单方案跑通,遇到瓶颈再升级。

7.4 display.flip vs display.update:谁更适合你

flip()会更新整个窗口,update()可以只更新指定区域。小游戏场景里,两者区别不大;如果屏幕上有大量静态内容,用update(rect_list)只刷新变化区域,可以节省GPU到CPU的传输开销。不过实测下来,普通2048级别窗口里差异不显著,优先保证代码简洁即可。

8. 从第一个小游戏到更大的项目:什么时候该换掉Pygame

做完第一个小游戏之后,你可能会想:是不是可以再做更大的?游戏设计、玩法机制、美术素材,需求越滚越大。此时有必要想一想,Pygame的边界在哪里。

如果你只做单机2D游戏、原型验证、学习练习,Pygame完全够用。它部署简单,环境依赖少,改起来快,非常适合快速迭代。但如果你开始涉及下面的需求,就要认真考虑换引擎或框架了:

  • 复杂的物理计算(刚体碰撞、关节约束):Pygame没有内置物理引擎,手写刚体系统非常容易出错,建议接Pymunk或用其他框架。
  • 跨平台移动端:Pygame虽然能打包,但对触摸交互的支持不算顺畅,更适合玩桌面键鼠。
  • 大规模场景和复杂的动画状态机:Pygame只是绘图层,不提供场景管理和动画状态系统,需要你自己慢慢搭建。
  • 自定义渲染管线、骨骼动画、粒子系统:这些在Pygame里做既费时又容易踩坑,直接用成熟的游戏引擎体验更好。

一个务实的建议是:把Pygame当作“动手学习游戏核心循环”的入门工具,而不是“终极游戏开发平台”。它教你的东西——状态机、帧率控制、碰撞管理、事件分发——在任何游戏引擎里都通用。用Pygame做的第一个成功能跑通,你就已经理解了游戏开发最本质的一环;接下来选择更重的工具,就是水到渠成的事了。

从小处下手,先跑通一次完整的生命周期,再看哪里可以改。等你亲手调出一个手感不错的版本,再回头看这篇文章里的每个细节,大概率会有一半已经内化成自己的经验了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询