ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

PyGame入门实战:从零搭建游戏循环与事件处理

PyGame入门实战:从零搭建游戏循环与事件处理 1. 为什么我不建议一上来就啃游戏引擎很多人想学游戏开发第一反应是去下载那些功能齐全的大型引擎结果装完打开界面就懵了——满屏的面板、属性、节点树光是搞清楚哪个窗口对应哪个功能就要花掉一整个周末。我见过太多人卡在这一步热情还没点燃就被浇灭了。PyGame 走的是完全相反的路线。它本质上就是一组帮你在窗口里画图、放声音、接收键盘鼠标输入的 Python 模块没有编辑器没有可视化界面你写的每一行代码都直接对应屏幕上发生的事。这种“裸奔”式的开发体验恰恰是理解游戏循环、帧率控制、碰撞检测这些底层概念的最佳途径。这篇文章适合谁如果你已经会写 Python 的基本语法——知道列表、字典、循环、函数怎么用——但从来没做过任何图形界面程序那这篇就是为你准备的。我会从零开始把 PyGame 的安装、窗口创建、事件处理、画面绘制、帧率控制这几个核心环节全部拆开讲清楚每一步都告诉你为什么这么做以及我实际踩过的坑。需要提前说明的是PyGame 并不是只能做小游戏。它的 2D 渲染能力足够支撑中等规模的独立游戏很多在游戏平台上架的像素风作品就是用 PyGame 或其衍生库完成的。但它的定位始终是“让你专注于游戏逻辑本身”而不是帮你自动生成场景和物理效果。理解这一点你后面的学习路径会清晰很多。2. 环境搭建少装一个包都可能让你卡半天2.1 Python 版本的选择不是越新越好PyGame 对 Python 版本有明确的兼容范围。截至我写这篇文章的时候PyGame 2.x 系列稳定支持 Python 3.8 到 3.12。如果你用的是 Python 3.13 或更高版本可能会遇到预编译包还没跟上的情况安装时直接报编译错误。我的建议是用 Python 3.10 或 3.11。这两个版本既不太老能享受现代 Python 的语法特性又足够稳定PyGame 的轮子包wheel覆盖完整安装时不需要本地编译。如果你电脑上已经装了其他版本可以用虚拟环境来隔离避免把系统环境搞乱。python -m venv pygame_env # Windows pygame_env\Scripts\activate # macOS / Linux source pygame_env/bin/activate虚拟环境的好处是你在这个环境里装的 PyGame 不会影响其他项目。万一装崩了直接删掉整个文件夹重新来就行不用重装 Python。2.2 安装 PyGame 时最容易忽略的细节安装命令本身很简单pip install pygame但这里有几个坑我必须要提醒你。第一个坑是pip 版本过旧。有些系统自带的 pip 版本比较老解析依赖时可能拉不到最新的 PyGame 包。先跑一下pip install --upgrade pip再装 PyGame能省掉很多莫名其妙的报错。第二个坑是网络问题导致的超时。PyGame 的包大概十几兆如果网络不稳定下载到一半断了pip 可能会留下一个损坏的缓存。这时候你会看到类似“ERROR: Could not install packages due to an OSError”的提示。解决办法是加上超时参数并清缓存pip install pygame --timeout 120 --no-cache-dir第三个坑是macOS 上的架构问题。如果你用的是 M 系列芯片的 Mac确保你的 Python 也是 arm64 版本。有些通过某些方式安装的 Python 是 x86_64 架构装 PyGame 时虽然能装上但运行时会报“mach-o file, but is an incompatible architecture”的错误。检查方法是在终端输入python -c import platform; print(platform.machine())输出应该是arm64。装完之后用下面这行代码验证是否成功import pygame print(pygame.version.ver)如果输出了版本号比如2.5.2说明安装没问题。如果报ModuleNotFoundError那多半是装到了另一个 Python 环境里检查一下你的虚拟环境是否激活。2.3 开发工具的选择别在这件事上纠结太久写 PyGame 代码用什么编辑器都行。VS Code、PyCharm、甚至记事本加命令行都可以。我个人的习惯是 VS Code 加 Python 插件轻量、启动快调试功能也够用。唯一需要注意的是确保你的编辑器使用的 Python 解释器和你安装 PyGame 的那个环境是同一个。VS Code 里按CtrlShiftP输入“Python: Select Interpreter”选你刚才创建的虚拟环境。PyCharm 则在项目设置里指定解释器路径。这个细节看起来不起眼但新手最容易在这里翻车——明明装了 PyGame编辑器里却一直报“模块未找到”。3. 第一个窗口从黑屏到能关闭只差一个事件循环3.1 最小可运行代码的每一行都在干什么先看一段能跑起来的最简代码然后我逐行拆解import pygame import sys pygame.init() screen pygame.display.set_mode((800, 600)) pygame.display.set_caption(我的第一个 PyGame 窗口) running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False pygame.quit() sys.exit()pygame.init()做的事情比你想的多。它会初始化 PyGame 的所有子模块——显示、声音、字体、手柄等等。如果你确定不需要某些模块比如声音可以单独初始化但在入门阶段直接全量初始化最省事。set_mode((800, 600))创建了一个 800 像素宽、600 像素高的窗口。这个函数返回的是一个 Surface 对象你可以把它理解成一块画布。后面所有的绘制操作要么画在这块画布上要么画在从它派生出来的其他 Surface 上。pygame.display.set_caption()设置窗口标题栏的文字。这个不是必须的但不设的话窗口标题会显示为“pygame window”看起来很不专业。事件循环是整个游戏的心脏。pygame.event.get()会返回一个列表里面是自上次调用以来发生的所有事件——用户点了关闭按钮、按了键盘、动了鼠标、调整了窗口大小等等。如果你不处理这些事件操作系统会认为你的程序卡死了窗口会变成“无响应”状态。pygame.QUIT事件专门对应点击窗口关闭按钮这个动作。捕获到它之后把running设为False循环结束程序退出。3.2 为什么你的窗口一打开就卡死新手最常遇到的问题是窗口出来了但点关闭按钮没反应整个窗口白屏标题栏显示“无响应”。原因几乎总是同一个你没有在循环里调用pygame.event.get()。操作系统会不断向你的程序发送各种事件消息如果你的程序不读取这些消息消息队列就会堆积系统判定程序失去响应。另一个常见原因是在循环里写了死循环或耗时操作。比如你在主循环里放了一个while True去等待某个条件那事件就永远没机会被处理。记住一个原则主循环的每一轮迭代都必须尽快完成把控制权交还给事件系统。还有一个隐蔽的坑在某些系统上窗口创建后需要调用一次pygame.event.pump()或处理一次事件窗口才会真正显示出来。如果你发现窗口创建了但内容不刷新可以在主循环之前加一句pygame.event.pump()试试。3.3 帧率控制不限速的循环会吃掉整个 CPU上面的最小代码有一个严重问题while running循环会以 CPU 能跑的最快速度疯狂执行一秒可能跑几百万次。这不仅浪费电还会让你的电脑风扇狂转。解决办法是加一个时钟对象来控制帧率clock pygame.time.Clock() while running: # ... 事件处理 ... # ... 绘制操作 ... clock.tick(60)clock.tick(60)的意思是确保这一轮循环距离上一轮循环至少间隔 1/60 秒。如果这一轮跑得快它就主动等待一会儿如果跑得慢它就不等待直接进入下一轮。这样游戏的逻辑更新和画面刷新就稳定在每秒 60 次。为什么是 60因为大多数显示器的刷新率是 60Hz每秒 60 帧正好和显示器同步画面看起来最流畅。如果你的显示器是 144Hz 的可以设成 144但对入门项目来说 60 完全够用。注意clock.tick()的返回值是上一轮循环实际消耗的毫秒数。如果你要做基于时间的动画比如“每秒移动 100 像素”需要用这个值来计算位移而不是假设每帧固定移动多少像素。这个细节后面讲动画时会展开。4. 画面绘制从填充背景到画出会动的方块4.1 Surface 和 RectPyGame 里最重要的两个概念PyGame 里所有的可见元素都是Surface。窗口本身是一个 Surface你加载的图片是一个 Surface甚至文字渲染出来也是一个 Surface。Surface 有宽度、高度、像素格式等属性你可以对它进行填充、绘制、旋转、缩放等操作。Rect是另一个核心概念它表示一个矩形区域有 x、y、width、height 四个属性。Rect 本身不显示任何东西但它用来定位 Surface、检测碰撞、定义绘制区域。这两个概念的关系可以这样理解Surface 是“画”Rect 是“画框”。你把画放进画框里再把画框放到窗口的某个位置。创建一个 Rect 的常见方式rect pygame.Rect(100, 200, 50, 50)这表示一个左上角在 (100, 200)、宽 50、高 50 的矩形。PyGame 的坐标系原点在窗口左上角x 向右增大y 向下增大。这一点和数学课上学的笛卡尔坐标系不同刚开始可能会不习惯但记住“y 往下是正方向”就行了。4.2 填充背景与绘制基本图形每一帧的开始你通常需要先用一个颜色填充整个窗口把上一帧的内容擦掉。这叫做“清屏”。screen.fill((30, 30, 30)) # 深灰色背景颜色用 RGB 元组表示每个分量的范围是 0 到 255。(0, 0, 0)是黑色(255, 255, 255)是白色(255, 0, 0)是红色。画一个矩形pygame.draw.rect(screen, (0, 200, 100), (100, 200, 50, 50))第一个参数是目标 Surface第二个是颜色第三个是矩形的位置和大小。还可以加第四个参数指定线宽如果不加或者设为 0就是实心填充。画圆pygame.draw.circle(screen, (200, 100, 0), (400, 300), 40)第三个参数是圆心坐标第四个是半径。画线pygame.draw.line(screen, (255, 255, 255), (0, 0), (800, 600), 3)最后那个 3 是线宽。这些绘制函数都有一个共同点它们直接修改目标 Surface 的像素数据不会返回任何东西。所以你不需要接收返回值调用完就生效了。4.3 双缓冲与 flip为什么画面会闪烁如果你直接在窗口的 Surface 上绘制然后立刻显示可能会看到画面闪烁。这是因为绘制过程不是瞬间完成的显示器在绘制到一半的时候刷新了画面就会看到撕裂或闪烁。PyGame 的解决方案是双缓冲。set_mode()创建的窗口默认就是双缓冲的你绘制的内容先写到一个隐藏的后台缓冲区等你调用pygame.display.flip()或pygame.display.update()时整个后台缓冲区一次性交换到前台显示。# 每一帧的绘制流程 screen.fill((30, 30, 30)) pygame.draw.rect(screen, (0, 200, 100), (100, 200, 50, 50)) pygame.display.flip()flip()和update()的区别是flip()更新整个窗口update()可以只更新指定的矩形区域。对于小项目直接用flip()最省心。如果你要做局部刷新优化可以传一个矩形列表给update()只刷新变化的部分。实测经验在某些 Linux 桌面环境下如果不在每帧末尾调用flip()或update()窗口可能完全不显示内容。养成“绘制完就 flip”的习惯能避免很多平台差异问题。4.4 让方块动起来位置更新与帧率的关系静态画面没什么意思让方块动起来只需要在每一帧改变它的位置x 100 speed 5 while running: # ... 事件处理 ... screen.fill((30, 30, 30)) x speed if x 800: x -50 pygame.draw.rect(screen, (0, 200, 100), (x, 200, 50, 50)) pygame.display.flip() clock.tick(60)这段代码让方块以每帧 5 像素的速度向右移动超出右边界后从左边重新出现。在 60 帧每秒的情况下实际速度是 300 像素每秒。但这里有一个问题如果某一帧因为系统卡顿耗时较长方块会“跳”一大段距离。对于入门项目这种误差可以接受。但如果你要做精确的物理模拟或计时就需要用clock.tick()的返回值来计算基于时间的位移dt clock.tick(60) / 1000.0 # 转换为秒 x speed_per_second * dt这样无论帧率如何波动方块每秒移动的距离都是恒定的。这个技巧在后面的进阶内容里会反复用到。5. 事件处理进阶键盘、鼠标与持续按键5.1 事件类型与事件对象的结构PyGame 的事件系统基于一个队列。每次调用pygame.event.get()你拿到的是自上次调用以来所有事件的列表。每个事件是一个对象有type属性表示事件类型还有其他属性根据类型不同而不同。常见的事件类型事件类型触发时机关键属性QUIT点击窗口关闭按钮无KEYDOWN按下键盘按键key,mod,unicodeKEYUP松开键盘按键key,modMOUSEBUTTONDOWN按下鼠标按钮pos,buttonMOUSEBUTTONUP松开鼠标按钮pos,buttonMOUSEMOTION移动鼠标pos,rel,buttons处理键盘事件的基本写法for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.KEYDOWN: if event.key pygame.K_SPACE: print(空格键被按下) elif event.key pygame.K_ESCAPE: running Falsepygame.K_SPACE、pygame.K_ESCAPE这些是 PyGame 预定义的键码常量。用常量而不是数字代码可读性更好也不容易出错。5.2 持续按键的正确处理方式KEYDOWN事件只在按下的那一瞬间触发一次。如果你想让角色在按住方向键时持续移动不能只靠KEYDOWN事件因为操作系统在按住键不放时会先有一个短暂的延迟然后才开始重复发送KEYDOWN事件而且重复频率是可以设置的不同系统不一样。正确的做法是用pygame.key.get_pressed()获取当前所有按键的按下状态。keys pygame.key.get_pressed() if keys[pygame.K_LEFT]: x - speed if keys[pygame.K_RIGHT]: x speed if keys[pygame.K_UP]: y - speed if keys[pygame.K_DOWN]: y speedget_pressed()返回一个布尔序列索引是键码值是True表示当前被按住。这个函数应该在每一帧的事件处理之后、绘制之前调用这样角色的移动才能和当前按键状态同步。注意get_pressed()返回的状态是“当前这一刻”的快照。如果你在事件循环内部调用它可能拿到的是旧状态。把它放在主循环里、事件处理之后是最稳妥的位置。5.3 鼠标交互点击、移动与按钮状态鼠标事件的处理和键盘类似但多了一个位置信息elif event.type pygame.MOUSEBUTTONDOWN: if event.button 1: # 左键 print(f左键点击在 {event.pos}) elif event.button 3: # 右键 print(f右键点击在 {event.pos})event.pos是一个(x, y)元组表示点击位置相对于窗口左上角的坐标。鼠标移动事件MOUSEMOTION会非常频繁地触发如果你在里面做复杂计算可能会拖慢帧率。一个常见的优化是只在需要的时候才处理鼠标移动比如拖拽操作。elif event.type pygame.MOUSEMOTION: if dragging: rect.x event.pos[0] - offset_x rect.y event.pos[1] - offset_y这里的dragging是一个布尔标志在MOUSEBUTTONDOWN时设为True在MOUSEBUTTONUP时设为False。offset_x和offset_y记录点击位置相对于矩形左上角的偏移这样拖拽时矩形不会“跳”到鼠标位置。5.4 事件处理的常见误区第一个误区是在事件循环里做耗时操作。比如在KEYDOWN里加载一张大图或播放一段长音频这会导致事件队列堆积窗口卡顿。正确的做法是设置一个标志在主循环的更新阶段再执行耗时操作。第二个误区是忘记处理QUIT事件。有些教程的示例代码只处理键盘事件结果用户点关闭按钮没反应。无论你的游戏多简单QUIT事件都必须处理。第三个误区是混淆event.key和event.unicode。event.key是键码表示按的是哪个物理键event.unicode是输入的字符受键盘布局和修饰键影响。如果你要做文本输入用event.unicode如果你要做游戏控制用event.key。6. 从能跑到好用我的调试与优化心得6.1 用 print 调试游戏循环的陷阱在游戏循环里加print是最直接的调试方式但有一个陷阱每帧都打印会导致输出刷屏而且 print 本身会拖慢循环速度让你误以为游戏性能有问题。更好的做法是只在特定条件满足时打印或者用计数器控制打印频率。frame_count 0 while running: # ... frame_count 1 if frame_count % 60 0: print(f当前帧: {frame_count}, 方块位置: {x})这样每 60 帧才打印一次既能看到状态变化又不会刷屏。另一个技巧是把调试信息画在窗口上而不是打印到控制台。PyGame 的字体模块可以渲染文字到 Surfacefont pygame.font.SysFont(None, 24) text font.render(fFPS: {clock.get_fps():.1f}, True, (255, 255, 255)) screen.blit(text, (10, 10))clock.get_fps()返回最近一段时间的平均帧率。把这个数字显示在窗口角落你能直观地看到性能变化。6.2 资源加载的时机与路径问题图片、声音、字体这些资源应该在主循环之前加载完毕而不是在循环内部反复加载。加载一次反复使用这是基本原则。路径问题是新手最容易踩的坑。如果你用相对路径加载图片image pygame.image.load(player.png)程序运行时的工作目录决定了这个路径相对于哪里。如果你在命令行里从不同目录启动程序或者把程序打包成可执行文件相对路径就会失效。稳妥的做法是用os.path构造绝对路径import os base_path os.path.dirname(os.path.abspath(__file__)) image_path os.path.join(base_path, assets, player.png) image pygame.image.load(image_path)__file__是当前脚本的文件路径os.path.dirname取它所在的目录然后拼上资源子目录和文件名。这样无论从哪里启动程序路径都是正确的。6.3 性能优化的几个实用手段PyGame 的性能瓶颈通常出现在两个地方绘制调用次数过多和Surface 转换不当。如果你要绘制大量相同的图片不要每次都blit原始图片。先把图片用convert()或convert_alpha()转换成窗口的像素格式image pygame.image.load(player.png).convert_alpha()convert_alpha()保留透明度convert()不保留。转换之后PyGame 在 blit 时不需要做像素格式转换速度会快很多。这个优化在绘制几十个精灵时效果不明显但绘制几百个时差距就出来了。另一个手段是减少每帧的填充区域。如果你知道只有一小块区域发生了变化可以用screen.fill(color, rect)只填充那块区域而不是整个窗口。不过对于 800x600 这种小窗口全屏填充的开销可以忽略这个优化留到窗口更大时再考虑。还有一个容易忽略的点避免在循环里创建新的 Surface 或字体对象。创建对象是有开销的每帧创建几百个对象会让垃圾回收器频繁工作。把创建操作移到循环外面循环里只做 blit。6.4 我踩过的三个典型坑第一个坑在 Windows 上窗口标题中文乱码。set_caption()传入中文字符串时某些 PyGame 版本在 Windows 上会显示乱码。解决办法是确保源文件用 UTF-8 编码并且在文件开头加上# -*- coding: utf-8 -*-。如果还是不行可以尝试用pygame.display.set_caption()的字节串形式但更推荐升级到最新版 PyGame新版本对 Unicode 的支持已经好很多了。第二个坑退出时程序卡住不结束。有时候pygame.quit()之后程序还在运行需要手动结束进程。这通常是因为还有未处理的线程或未释放的资源。确保在pygame.quit()之后调用sys.exit()并且不要在退出前留下未关闭的文件或网络连接。第三个坑在不同电脑上帧率差异巨大。同样的代码在你的电脑上跑 60 帧在别人的电脑上只有 20 帧。除了硬件差异还要检查是否开启了垂直同步。set_mode()可以传vsync1参数来开启垂直同步但这在某些驱动上会导致帧率被锁定在显示器刷新率。如果你要做性能测试先关掉垂直同步。7. 下一步可以往哪里走窗口能开了方块能动了键盘鼠标能响应了这已经是一个完整游戏循环的雏形。接下来你可以往几个方向扩展。精灵和精灵组是 PyGame 提供的一个组织游戏对象的机制。把每个游戏角色封装成Sprite子类用Group来批量更新和绘制代码会清爽很多。这是从“能跑”到“好维护”的关键一步。碰撞检测是游戏逻辑的核心。PyGame 的Rect自带colliderect()方法可以检测两个矩形是否重叠。对于像素级精确碰撞可以用pygame.sprite.collide_mask()但性能开销更大入门阶段用矩形碰撞就够了。声音和音乐方面pygame.mixer模块可以播放音效和背景音乐。注意音效和音乐要分开管理音效用Sound对象音乐用music模块两者的加载和播放方式略有不同。打包发布是最后一步。pyinstaller可以把你的 Python 脚本打包成可执行文件但 PyGame 的资源文件需要额外配置确保打包后程序能找到图片和声音。这个环节坑比较多建议等游戏基本完成后再折腾。如果你跟着这篇文章把代码都敲了一遍现在应该有一个能响应键盘、能绘制图形、帧率稳定的窗口程序了。这个基础打好了后面学任何游戏开发框架都会轻松很多因为游戏循环、事件驱动、帧率控制这些概念是相通的。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表