
运一次上下左右控制一个方块躲避障碍这样的小游戏从新手到能跑通的完整过程你会踩哪些坑、需要懂哪些原理我今天一次性讲清楚。1. Pygame到底是什么它不是引擎而是一套媒体工具箱1.1 Pygame的来历与定位Pygame 是一个基于 SDL 库的 Python 第三方库专门用来处理游戏开发里最常见的那些事窗口创建、图像绘制、键盘鼠标输入、声音播放、碰撞检测基础数据结构。它最早由 Pete Shinners 在 2000 年左右发起目的很朴素——让 Python 程序员不用去折腾底层窗口 API 和图形接口就能直接做 2D 游戏。这里有一个很多新手一开始搞不清的点Pygame 不是一个游戏引擎。你打开 Unity 或者 Godot里面自带场景、节点、物理引擎、动画状态机Pygame 不提供这些东西它只给你一堆零件——一个可以画图的窗口、一堆处理输入的 API、一组几何结构体。你要做的引擎就是一个 Python 脚本里的循环。恰恰因为这样Pygame 特别适合用来理解游戏的核心运行机制游戏 循环 状态 输入 渲染。1.2 为什么说Pygame是第一款游戏的最佳选择市面上能用来写 2D 游戏的 Python 库不少Arcade、Pyglet、Tkinter 的 Canvas 也能画图甚至有人用 Kivy 做小游戏。但我给纯新手的建议永远是先学 Pygame。原因很简单资料多。搜Python 游戏开发十篇文章里有八篇是 Pygame 的遇到问题更容易找到解决方案。门槛低。核心 API 就几十个安装完你就能上手画第一个矩形不用理解复杂的场景树和组件系统。结构自由度大。正因为没有引擎约束你必须自己设计游戏状态、自己决定如何组织代码这反而逼你把基础打好。1.3 Pygame 2.x相比1.x的最大变化很多人现在搜到老教程还会遇到pygame.sprite模块里那些老写法但要注意 Pygame 2.x 在主循环、图像转换、文本渲染上都有明显改进。最大的体感差异有三个pygame.display.set_mode()不再强制要求立即传入分辨率可以后续再动态调整窗口大小。内置了对高清屏 DPI 缩放的基础支持虽然仍不完美后面我会专门说。性能上比 1.x 好了不少Surface.convert()和convert_alpha()的建议依旧有效但普通小游戏里不优化也不会卡。另外要提一嘴pygame-ce社区版它是原 Pygame 停更维护后由社区分叉出来的活跃版本。如果你在 2025 年才开始学我其实更推荐直接用pygame-ceAPI 基本兼容修复了大量老问题安装名是pygame-ce导入仍然是import pygame。2. 安装与环境准备多花五分钟就少崩溃一整晚2.1 各平台安装方式与Python版本坑安装本身不难但很多人的第一个坑恰恰出在这里。我用的是 Windows直接pip install pygame pygame-ce如果你在 macOS 或 Linux 上流程也差不多。但有几个细节值得注意尽量不用系统自带的 Python。macOS 自带的 Python 是 2.x 时代遗留的装了也没意义Windows 商店版的 Python 有时路径有问题pip 装包倒是可以但后面 PyInstaller 打包时容易出幺蛾子。建议直接去 python.org 下载官方版本选 3.10 到 3.12 之间的版本都行。检查 pip 是不是对应版本。同一个命令行里敲python --version和pip --version确认两者来自同一个环境。经常有人开了虚拟环境然后在全局 pip 安装装完发现 import 不到。Linux 下可能出现音频设备找不到、窗口打不开的问题常规解决办法是安装 SDL 依赖Ubuntu 系一般执行sudo apt install libsdl2-2.0-0 libsdl2-mixer-2.0-0 libsdl2-image-2.0-0。装完验证方式很简单在终端里跑python -c import pygame; print(pygame.version.ver)如果打印出类似2.6.1的版本号就说明环境没问题。2.2 那个著名的窗口一闪而过事故这是 Pygame 新手区提问率最高的问题脚本运行了一个黑色窗口闪了一下马上消失没有报错跑完就退出。本质原因特别简单——你没有让程序进入持续循环。窗口默认是画出后立刻执行完脚本就销毁的想让窗口停留你必须写一个while循环阻塞住主线程。很多人会在这里直接抄网上的模板然后疑惑为什么别人代码能停留。我建议你亲手理解一下这个问题的原理set_mode 创建的窗口生命周期与 Python 进程绑定进程没退出窗口就在进程一结束窗口立刻销毁。你需要在代码里留一个循环结构让它挂住。后面第三部分我会专门写这个循环这是整个游戏开发最核心的骨架。2.3 给素材文件建一个干净的目录结构这是我做第一个项目时没人告诉我的事。Pygame 项目一开始可以只有一个.py文件但当你开始加载图片、声音、字体时散乱的路径会让你后患无穷。最稳的项目结构不管以后写大游戏还是小玩意儿都受用my_game/ ├── main.py ├── settings.py ├── assets/ │ ├── images/ # 图片素材 │ ├── sounds/ # 音效音频 │ └── fonts/ # 字体文件 └── modules/ ├── player.py └── enemies.py路径问题直接关系到最后打包我放在第 6 部分细讲但目录结构从第一天就养成习惯后面省事非常多。3. 核心循环游戏开发的第一性原理3.1 主循环游戏本质是一个无限循环 条件退出不管多复杂的游戏剥到最里面都是这样一个骨架import pygame pygame.init() screen pygame.display.set_mode((800, 600)) pygame.display.set_caption(我的第一个游戏) running True while running: # 1. 处理输入事件 for event in pygame.event.get(): if event.type pygame.QUIT: running False # 2. 更新游戏状态 # 3. 绘制画面 screen.fill((0, 0, 0)) pygame.display.flip() pygame.quit()理解这段代码你就理解了游戏工业化开发里常说的游戏循环。游戏之所以和你平时写的那种从上到下跑一遍就结束的程序不同是因为它对实时性有要求需要每秒几十次地重复做三件事收集输入用户按了哪个键、点了哪里、窗口有没有被关闭。更新状态根据输入和游戏规则去算新位置、新血量、新得分。渲染画面把最新的状态画到屏幕上。running False是唯一合法的退出方式。这里有一个常见误解新手以为要在代码里写break来退出游戏实际上对于while循环设置标志位让循环条件失效然后在while之后统一调pygame.quit()永远是最可控的退出方式。因为如果中途直接 break窗口资源的释放时机就很尴尬容易在退出时有杂音或窗口残留。3.2 Surface 与 blit屏幕上的一切都是贴像素我刚学 Pygame 时最困惑的概念是Surface。后来我把它理解成一张能画东西的画布一切瞬间清楚了。窗口是一个 Surface你 load 进来的图片也是一个 Surface甚至一行文字、一个矩形都会被 Pygame 渲染成一个小 Surface。那动词也就是绘制在 Pygame 里叫做blit。它的意思是把一块 Surface 的像素原样贴到另一块 Surface 上。比如我把角色图片贴到窗口的正中央hero_image pygame.image.load(assets/images/hero.png) screen.blit(hero_image, (400, 300))第二参数是一个元组坐标代表这张图片要贴到窗口的哪个位置。注意那是图片的左上角位置。一帧一帧地改变这个坐标再重新 blit角色就动起来了。这就是动画的全部秘密——不是真的有动画只是快速切换静帧。Surface上还有一个常用的方法叫convert_alpha()它的作用是把图片转换成和窗口一样的像素格式这样 blit 时不用做格式转换速度快很多透明通道也能保留。这是初步优化最有效的一行代码。3.3 Rect 与坐标系统左上角原点向下为正Pygame 的坐标系和你在数学课上学的不一样原点在左上角x 轴向右增长y 轴向下增长。也就是说(0, 0)是屏幕左上角(100, 50)是往右 100 像素、往下 50 像素。对于刚接触的读者这个方向性最容易搞混。一个简单记忆法想一想文本在 Word 里是怎么排的电脑屏幕上的坐标就是这个逻辑。Rect是 Pygame 里最实用的数据结构之一。它本质是一个矩形区域保存了x, y, width, height四个值同时提供了大量方便属性left, right, top, bottom, center, centerx, centery等等。为什么说它实用因为游戏里几乎所有的碰撞检测、位置判断都是在和 Rect 打交道。举个判断玩家是否超出屏幕左边界的例子if player_rect.left 0: player_rect.left 0这样写比if player_rect.x 0: player_rect.x 0更直观因为left这个语义本身就是左边界坐标。Rect 还提供move(x, y)方法返回移动后的新矩形以及colliderect()方法判断两个矩形是否相交。这些都是后面碰撞检测的基础。3.4 Clock 与 FPS为什么你的游戏一会快一会慢新人写的第一版游戏往往不控制帧率结果在性能好的电脑上角色飞一样地跑性能差的电脑上慢吞吞。这是因为游戏状态更新的次数取决于循环的迭代速度而在不同机器上循环速度并不一样。解决办法是给游戏加上Clock节拍器clock pygame.time.Clock() while running: # ... clock.tick(60)tick(60)的意思是让这一帧循环最多持续 1/60 秒如果这一帧处理得太快就原地等待一下保证每秒最多执行 60 次循环。这样游戏速度就和硬件无关了。但这里还有一个进阶问题如果你的循环里某个操作开销很大一帧实际耗时超过了 1/60 秒那tick(60)并不会阻止你变慢它只是限速不加速所以性能优化永远是必要的。等游戏复杂度上来后你还会用到增量时间delta time把角色的移动速度写成每秒 200 像素而不是每帧 5 像素Pygame 2.x 推荐的做法是帧率乘以时间增量。第一批开发时先让tick(60)控住速度已经足够应付绝大多数小游戏了。4. 事件处理键盘、鼠标以及那个事件锁问题4.1 pygame.event.get() 和 poll() 的差异游戏循环第一步是处理输入Pygame 里输入的表现形式就是事件Event。事件是什么你按一个键、移动鼠标、点关闭窗口按钮操作系统都会产生一条消息Pygame 把这些消息翻译成事件对象塞进一个队列里。你得把它们取出来处理。最常用的取事件函数是for event in pygame.event.get(): if event.type pygame.QUIT: running False它一次取出当前队列里的所有事件。但我看很多人没注意过另一个函数pygame.event.poll()它的区别是每次只取一个事件如果队列为空就返回一个pygame.NOEVENT类型的事件。什么时候用 poll当你不想一次性取空队列里所有事件而是想要精确控制每次循环只处理一个事件时可以用 while 循环包住它。实践中大多数游戏用get()就足够了但理解 poll 能帮你理清事件队列的逻辑get 是把积水一次抽干poll 是一滴一滴接。4.2 键盘事件与连续按键两种思路处理键盘输入有两种完全不同的思路新手很容易混淆。第一种是事件驱动也就是你按下某个键的瞬间KEYDOWN事件进入队列松开时KEYUP事件进入队列。适用于菜单选择、跳跃触发、打开背包等一次性动作。第二种是状态查询用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这里有个很多人踩过的坑如果把左右移动也写在事件驱动的for event循环里你会发现按一下只走一格移动手感就像打字机一样一顿一顿的。原因就是KEYDOWN只在按下的那一帧产生一个事件不会一直发。所以判断是否按住必须用get_pressed()判断按下的一瞬间才用KEYDOWN事件。区分好这两个场景你的手感会立刻上一个档次。4.3 事件锁到底锁的是什么热词里出现了一个很有意思的词——事件锁。很多人在做游戏时发现一个按键按下去程序里同一个动作被触发了两次以上或者游戏状态在按下和松开之间极端闪烁。这其实就是典型的事件竞争问题也就是游戏开发里需要事件锁的场景。举一个最简单的例子。你想做一个按空格暂停的功能if event.type pygame.KEYDOWN and event.key pygame.K_SPACE: paused not paused表面看没问题但如果某帧里用户快速按了两下空格事件队列里可能排了两个KEYDOWN你的逻辑就会瞬间执行两次取反结果暂停等于没暂停。这种按下动作应该只生效一次但物理上多触发了一次的问题就需要加锁。两个常见的锁机制状态锁用帧数或时间戳记录上次触发的时间短时间内不允许再次触发。比如last_press_time 200毫秒 之后才允许下一次触发防抖。状态翻转锁在KEYDOWN里执行取反之后立刻设一个space_handled True标志直到KEYUP事件出现才把它设回 False。这样同一个按键的按住过程中无论KEYDOWN出现多少次只处理第一次。space_handled False for event in pygame.event.get(): if event.type pygame.KEYDOWN and event.key pygame.K_SPACE: if not space_handled: paused not paused space_handled True if event.type pygame.KEYUP and event.key pygame.K_SPACE: space_handled False这就是游戏里事件锁的直观解释。它锁的不是事件本身而是防止一个物理动作在多帧中产生重复逻辑。做格斗游戏、射击游戏时这类需求会更多提前理解这个概念可以省掉不少 debug 时间。4.4 自定义事件与窗口事件pygame.event.Event()允许你自己制造一个事件对象再用pygame.event.post()把它丢回事件队列。这个能力看起来冷门但在做定时事件比如每 5 秒生成一个敌人时非常好用。你可以设置一个自定义定时事件SPAWN_ENEMY pygame.USEREVENT 1 pygame.time.set_timer(SPAWN_ENEMY, 5000) while running: for event in pygame.event.get(): if event.type SPAWN_ENEMY: spawn_enemy()这样你就能在主循环里统一处理所有动作事件而不用在循环外挂一堆各自为政的计时器了。还有一个必须处理的窗口事件是pygame.QUIT它对应点击窗口关闭按钮。如果只设置running False不够还要记得之后pygame.quit()释放窗口资源。在复杂的游戏里你可能还要处理pygame.WINDOWRESIZED事件来重新计算布局不过基础阶段先不管。5. 从零到一写一个能玩的接苹果小游戏光讲 API 不落地等于白讲。下面用一个小游戏把前面所有概念串起来玩家在底部左右移动从屏幕顶端不断掉下苹果接住得分漏掉则损失一条命。这个游戏麻雀虽小五脏俱全。5.1 游戏拆解四个核心部分写代码前先拆游戏规则。任何游戏都可以拆成玩家控制、掉落物、碰撞判定、计分与结束条件四个部分玩家一个矩形或图片跟随鼠标或键盘左右移动。掉落物苹果图片或者一个红色圆形从顶部以一定速度下落。碰撞判定苹果与玩家接触的瞬间得分并移除苹果。结束条件某个苹果落到底部并超出屏幕范围时生命减一生命归零则游戏结束。这种先拆规则再写代码的习惯比写码本身更重要。拆得越细你的代码结构就越自然。5.2 精灵与组为什么老手一上来就建 ClassPygame 的pygame.sprite.Sprite类和pygame.sprite.Group类是官方推荐的管理游戏对象的方式。Sprite是一个可绘制、可移动、可碰撞的实体基类Group则是一个容器可以批量绘制、批量更新、批量检测碰撞。import pygame class Apple(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.image pygame.Surface((20, 20)) self.image.fill((255, 0, 0)) self.rect self.image.get_rect(center(x, y)) self.speed 3 def update(self): self.rect.y self.speed if self.rect.top 600: self.kill()这里的self.image和self.rect是 Sprite 的两个约定俗成的属性draw()方法会依据 rect 位置把 image 画出来update()方法则是让每个精灵执行自己的行为逻辑。你只需要在主循环里调用apples.draw(screen) apples.update()游戏中所有苹果的位置更新和绘制就全部完成了。省去了手写一个苹果列表然后逐个遍历的麻烦。用面向对象的方式来组织游戏对象是几百行后项目还能不被搞乱的关键。虽然用 dict 或 list 也能实现但类封装行为、Group 来遍历可读性和扩展性完全不在一个层面。5.3 碰撞检测矩形碰撞和像素完美碰撞的取舍Pygame 里最朴素的碰撞检测是两个矩形是否相交对应Rect.colliderect()hits pygame.sprite.spritecollide(player, apples, True) if hits: score 1spritecollide(精灵, 组, True)会遍历组内所有精灵检测与传入精灵是否碰撞第三个参数为 True 表示碰撞到的对象会从组里移除。这个函数默认用的就是矩形碰撞。矩形碰撞的缺点是如果你的物体不规则比如一个圆形苹果被当作正方形检测四个角都会造成看起来没碰到却算碰到的假碰撞。解决办法有两种用collide_mask参数指定像素级碰撞。Pygame 会按图像的非透明像素来检测精确度最高但性能开销也大。小游戏里几十个物体的像素碰撞没问题几百个就要小心了。手动缩小碰撞盒。就是给 Rect 的宽高乘一个系数让判定区域比画面区域小一点。比如 30x30 的圆形图片碰撞盒设成 24x24。这招在实战中非常常用混合了精确度和性能。我的建议是先全部用矩形碰撞把游戏跑通等确认功能稳定后再逐个替换成需要的精确模式。不要一上来就追求完美导致半天调试不出结果。5.4 游戏状态机开始界面、游戏中、结束界面一个正经游戏绝不可能只有一个永远在跑的主循环。它至少有三个状态开始菜单、游戏进行中、结束画面。在 Pygame 里没有现成的场景系统所以你得自己用状态变量去控制最常见的写法是GAME_MENU 0 GAME_PLAY 1 GAME_OVER 2 game_state GAME_MENU while running: if game_state GAME_MENU: # 显示标题和提示文字 # 检测按键按下空格进入游戏 elif game_state GAME_PLAY: # 更新苹果、检测碰撞、更新时间 # 生命归零时切到 GAME_OVER elif game_state GAME_OVER: # 显示最终得分 # 按任意键重新开始这就是一个极简的状态机。写状态机的时候有个容易踩的坑不要用if去改状态后又同一帧继续执行新状态的逻辑。比如在GAME_MENU分支里检测到空格把状态改成GAME_PLAY如果下面没有elif而是连续if同一帧它会继续跑游戏逻辑导致开局瞬间就产生一次碰撞或更新。所以状态分支必须用elif明确互斥或者每个分支后面continue跳过本轮剩余逻辑。类似地从结束画面按下空格重开游戏时一定要重置所有精灵、分数和时间否则你会看到上一局的残局。状态机是游戏从能跑走向能玩的分水岭。早期我用一个超级大的主循环堆所有逻辑改个功能就要滚半天代码用状态机拆分之后每个界面的代码彼此隔离主循环清爽多了。6. 新手最容易翻车的四个地方文字、音效、打包和高清屏6.1 中文显示全变方块字体加载的坑如果你尝试在 Pygame 里写screen.blit(font.render(你好, 游戏, True, (255,255,255)), (100, 100))会很惊喜地发现方块全是方块。这是 Pygame 新手绕不开的坑。原因在于 Pygame 的默认字体不知道去哪加载或者加载到的字体根本不支持中文字符。解决办法也不是很难你只需要告诉它一个支持中文的字体路径。Windows 下可以这样写font_path C:/Windows/Fonts/msyh.ttc # 微软雅黑 font pygame.font.Font(font_path, 36)为了跨平台稳定更好的做法是把字体文件放进项目的assets/fonts/目录里然后用相对路径加载。字体文件一般不能乱拷贝传播注意看微软雅黑等字体的版权但自用学习完全没问题或者可以使用思源黑体等开源字体。还有一个冷门细节pygame.font.SysFont可以通过系统字体名创建字体它在不同系统上字段不同比如 Windows 认 微软雅黑macOS 可能认 PingFang SC 或 Songti SC因此跨平台时会出问题。用pygame.font.Font(文件路径)加载固定字体文件是保证任何机器上效果一致的最稳方案。6.2 音效不响初始化的顺序问题如果你的游戏里用了音频素材必须在任何音频操作之前调用pygame.mixer.pre_init()或pygame.mixer.init()。这里大多数人会栽的跟头是在pygame.init()之后再调用mixer.init有时会静默失败——播放音效时没有声音也没报错。标准做法是提前设置pygame.mixer.pre_init(44100, -16, 2, 512) pygame.init()这段代码写在pygame.init()之前。第二、三、四个参数分别是采样率、位深度、声道数、缓冲大小。如果不清楚怎么填默认值就够用重要的是pre_init 必须在 init 前。然后加载音效jump_sound pygame.mixer.Sound(assets/sounds/jump.wav) jump_sound.play()注意play()不是等音频放完只负责启动播放。想做背景音乐用pygame.mixer.music.load(bgm.mp3); pygame.mixer.music.play(-1)-1表示循环播放。若遇到 MP3 在部分环境不支持用 WAV 或 OGG 格式最保险。6.3 PyInstaller 打包路径机制的隐藏炸弹游戏写好后想发给朋友玩第一步基本就是pip install pyinstaller然后pyinstaller -F main.py。这一步能跑出 exe 但大概率会出现一个大问题游戏启动后找不到图片、找不到音效。原因不是你的代码有问题而是 PyInstaller 打出来的程序运行时工作目录和 Python 环境不同相对路径失效了。解决办法是写一个统一的路径工具函数。判断程序是打包状态还是开发状态分别取不同的基准目录import sys, os def resource_path(relative_path): base_path getattr(sys, _MEIPASS, os.path.abspath(.)) return os.path.join(base_path, relative_path)在使用素材路径时全部通过resource_path(assets/images/hero.png)获取。这样开发时它返回项目目录下的路径打包时它返回 PyInstaller 释放临时文件的目录路径。这个工具函数我建议写进所有做 Pygame 项目的人的工具箱真的不知道救了多少次场。另外-F参数会把所有东西打成一个超大文件启动慢杀软也容易误报。如果你发给别人玩建议用-w隐藏命令行窗口加-F如果只给自己用保留窗口还能方便打印日志。6.4 高清屏和多屏环境下的坐标失真Pygame 2.x 在 Windows 高分屏下的表现依然不完美。如果你的屏幕是 2K、4K 且系统缩放比例调到 125% 或 150%你会觉得窗口比设定的小鼠标坐标和画面内容对不上。根本原因是 Windows 在缩放时系统会把 Pygame 创建的窗口按逻辑像素显示但 Pygame 拿到的鼠标坐标却是物理像素。一个简单粗暴的兼容方案是在代码开头让窗口自适应缩放import ctypes ctypes.windll.user32.SetProcessDPIAware()然后再创建窗口。这个 API 调用在 Windows 上告诉系统我自己处理 DPI不需要你帮我缩放通常能解决大多数错位问题。但如果你要发布给各种屏幕配置不同的玩家更稳妥的方案是写分辨率自适配——即固定的游戏逻辑分辨率比如 960x540然后把整个窗口缩放显示。这一块不是新手必须掌握的但如果你准备把作品发布给别人玩迟早会面对它。第一次遇到坐标错位先查 DPI再查是不是窗口真的发生了 Resize 事件两个排查方向基本覆盖九成问题。最后说几句心里话如果让我给后来人一个最精简的建议那就是别急着学很多花哨的功能先把一个球在屏幕上移动、碰撞、计分、结束这四件事串起来跑通。这个过程能让你真正理解什么是事件循环、什么是矩形碰撞、什么是状态切换。我在实际开发中最大的体会是Pygame 是一个让人敢于动手的库——它不完美但它把游戏制作的门槛压到了一个下午就可以跨过去的高度。卡住了就去翻事件队列、去打印精灵数量、去查 FPS这些小技巧比任何插件都好用。你的第一个游戏或许画面粗糙但亲手创造出一个能运行的交互世界那种感觉会是持续学习的最大动力。