ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Python与pygame从零复刻超级玛丽奥:游戏开发实战复盘

用Python与pygame从零复刻超级玛丽奥:游戏开发实战复盘 小时候在红白机上搓手柄的日子可能是我对“游戏”这个概念最早的认知。后来开始写代码心里一直有个念头能不能用自己敲出来的代码还原一版能跑起来的超级玛丽奥这念头搁了好几年直到我真正用 Python 搭配 pygame 把它做出来才发现这件事没有想象中那么遥远。pygame 虽然不像 Unity 那样能直接拖拽场景但它胜在足够轻、足够底层能让你清清楚楚看见“游戏循环是怎么转的”“角色是怎么一步步动起来的”。这篇不是教学文档而是我踩完坑、把项目跑顺之后的完整复盘适合刚学 Python 没多久、想动手做点真东西的人。1. 环境准备从零到成功跑起 pygame我踩过的三个坑网上很多教程默认你已经有 Python 环境了但实际咨询里大半的“为什么我运行不了”都是环境问题。这一节直接把我遇到的坑写出来你对照着走能省很多时间。1.1 Python 版本选择与虚拟环境隔离Pygame 对 Python 版本不算挑剔但我建议直接上 Python 3.9 到 3.12 之间的稳定版本。太老的版本比如 3.6、3.7 安装最新版 pygame 时经常会出现 wheel 不兼容的情况而太新的版本比如 3.13 刚发布的那阵子有些依赖库还没跟上容易碰到“非官方二进制包缺失”的报错。我自己的实践是用虚拟环境跑游戏项目不要直接把 pygame 装进系统全局 Python。原因很简单——你以后还会学爬虫、学数据分析每个项目依赖的库版本可能会互相打架。用venv隔离一个项目一个环境干净又省心。# 创建并激活虚拟环境Windows 环境 python -m venv mario_env mario_env\Scripts\activate # Mac / Linux 环境 python3 -m venv mario_env source mario_env/bin/activate激活后命令行提示符前面会出现(mario_env)这就说明你已经进入了独立环境。这时候再去装 pygame就不会污染系统环境。1.2 “error: failed to build pygame when getting requirements to build wheel”到底怎么破这是搜索热词里高频出现的报错我当初也撞上过。这个报错的本质是pip 尝试安装 pygame 时没有找到匹配你系统平台和 Python 版本的预编译 wheel 包于是退回去尝试从源码编译结果因为缺少编译工具链而失败。解决办法其实就两种。第一种升级 pip 和 wheel然后再装python -m pip install --upgrade pip setuptools wheel python -m pip install pygame --pre--pre表示允许安装预发布版本有些时候官方正式版还没发布适配新版 Python 的 wheel但预发布版已经提前支持了。第二种如果你不想折腾直接指定版本安装比如python -m pip install pygame2.6.0这个版本比较经典兼容性很稳。实测下来绝大多数“failed to build”报错在升级 pip 后都能消失。1.3 用 10 行代码验证环境是否就绪装好之后不要急着开写先跑一个最小窗口验证环境。新建一个test.pyimport pygame pygame.init() screen pygame.display.set_mode((640, 480)) pygame.display.set_caption(Pygame 环境测试) running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False pygame.display.flip() pygame.quit()能弹出一个黑色窗口说明环境已经通了。接下来所有代码都基于这个窗口基础扩展。2. 游戏骨架让代码动起来之前先理解 pygame 的核心机制很多新手犯的错误是看了一堆教程上来就写角色类、写碰撞检测结果事件循环没写好画面一闪就没了。这种挫败感很打击人。所以我把 pygame 的核心驱动逻辑先讲明白这块不搞清楚后面写再多代码也白搭。2.1 Surface、Rect、Eventpygame 世界的三块基石Surface你把 Surface 理解成一块画布。屏幕是主画布角色、背景、道具各自也是一块小画布。小画布通过blit()方法贴到主画布上一帧一帧地刷新就产生了“画面动起来”的效果。Rect这是 pygame 最实用的类之一。它本质是个矩形区域存储x、y、width、height四个值。角色图片加载进来后通过get_rect()拿到包围图片的矩形之后移动、碰撞检测全部基于这个矩形。Event所有鼠标点击、键盘按下、窗口关闭等操作都会生成一个事件。游戏每帧通过pygame.event.get()领取事件再决定下一步做什么。这三个概念贯穿整个项目后面的所有功能都是围绕它们展开的。2.2 游戏主循环与帧率控制一切“手感”的基础游戏本质上就是一个无限循环处理输入 - 更新逻辑 - 渲染画面然后重复。这个循环跑得稳定游戏才会流畅。import pygame FPS 60 clock pygame.time.Clock() while running: # 1. 处理事件 for event in pygame.event.get(): if event.type pygame.QUIT: running False # 2. 更新逻辑角色位置、敌人移动、碰撞检测 update() # 3. 渲染画面 screen.fill((0, 0, 0)) all_sprites.draw(screen) pygame.display.flip() # 4. 限制帧率 clock.tick(FPS)帧率控制是个细节中的细节。clock.tick(60)的意思是“每秒钟最多循环 60 次”。如果不限制帧率不同电脑跑出来的速度会完全不同——我办公室的电脑能跑 400 帧以上家里的老笔记本只有 30 帧如果不锁帧同一个游戏在不同设备上速度天差地别。锁了帧才谈得上一致的游戏手感。2.3 精灵类与精灵组管理大量游戏对象的最佳实践超级玛丽奥的关卡里有金币、砖块、敌人、蘑菇等各种对象。如果给每个对象都写一套独立的管理逻辑代码很快就乱成一锅粥。pygame 提供了sprite.Sprite基类我强烈建议你继承它来写角色和敌人。import pygame class Player(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.image pygame.Surface((30, 40)) # 先用色块代替图片 self.image.fill((255, 0, 0)) self.rect self.image.get_rect(topleft(x, y)) def update(self): # 在这里更新玩家的位置和状态 pass然后再用一个pygame.sprite.Group()来装所有精灵all_sprites pygame.sprite.Group() player Player(100, 300) all_sprites.add(player)这么设计的好处是什么所有精灵统一用all_sprites.update()更新、all_sprites.draw(screen)绘制新增一个敌人类只需像玩家一样继承 Sprite然后加入 Group其他什么都不用改。项目的扩展性一下就打开了。3. “手感”的根源重力、跳跃与像素级碰撞判定超级玛丽奥玩起来“带感”核心是物理手感。跳跃的弧线、落下时踩到敌人头顶的判定这些东西做得糙一点点玩家立刻就能感觉出来“不对劲”。这一节是整套代码里技术含量最高的部分我拆开来讲。3.1 重力参数与跳跃速度的设计思路横向卷轴游戏里角色所有竖向运动都可以简化为“重力 跳跃初速度”的叠加。我用的物理模型是这样的GRAVITY 0.8 JUMP_POWER -15 class Player(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.image pygame.Surface((30, 40)) self.image.fill((255, 0, 0)) self.rect self.image.get_rect(topleft(x, y)) self.speed_x 0 self.speed_y 0 self.on_ground False def update(self): # 每帧给 y 方向速度增加一个重力 self.speed_y GRAVITY if self.speed_y 20: self.speed_y 20 # 限制最大下落速度防止穿墙 self.rect.x self.speed_x self.rect.y self.speed_y # 跳跃逻辑写在外部事件处理里跳跃时给speed_y设一个负值向上。这里JUMP_POWER -15配合GRAVITY 0.8实测跳跃高度大约 140 像素刚好是 4 个砖块的高度这个数值是反复调出来的。你有没有想过为什么跳跃高度不用固定速度而是用负速度因为重力每帧都在把角色往下拉角色上升速度越来越慢到最高点后速度变正开始下落这样就形成了一条平滑的抛物线。如果用固定速度角色会像电梯一样机械地上升下降那手感就完全毁了。3.2 像素级碰撞检测从 Rect 碰撞说起Pygame 最基础的碰撞检测是基于 Rect 的也就是两个矩形区域是否有重叠if player.rect.colliderect(brick.rect): # 碰撞了但直接使用colliderect做完整项目会有一个非常明显的缺陷角色下落速度较大时可能上一帧还在方块上方下一帧就直接穿过方块了Rect 之间完全没有重叠碰撞判定失效。这就是俗称的“穿墙”。解决这个问题有两条路一种是用小的 Rect 做“脚底传感器”专门检测脚下有没有地面另一种是把角色的移动拆成两个方向分别检测——先检测水平移动再检测垂直移动。我项目里用的是第二种因为它更贴近真实的碰撞流程。def update(self, tiles): # 水平移动 self.rect.x self.speed_x for tile in tiles: if self.rect.colliderect(tile.rect): if self.speed_x 0: self.rect.right tile.rect.left elif self.speed_x 0: self.rect.left tile.rect.right # 垂直移动 self.rect.y self.speed_y self.on_ground False for tile in tiles: if self.rect.colliderect(tile.rect): if self.speed_y 0: self.rect.bottom tile.rect.top self.on_ground True elif self.speed_y 0: self.rect.top tile.rect.bottom self.speed_y 0这段代码的关键在于碰碰撞后把角色矩形“推”回碰撞前的位置。下落时撞到砖块顶部就把角色的底部对齐到砖块的顶部上跳时顶到砖块底部就把角色顶部对齐到砖块底部。这样既解决了碰撞穿透也顺便确定了角色的“着地”状态。3.3 头顶砖块与踩敌人方向性碰撞的细化处理上一节的代码已经能处理“脚下踩平台”和“头顶撞砖块”这两种碰撞了。但你做超级玛丽会注意到一个细节角色跳起来从侧面撞到砖块应该被砖块挡住而如果撞在砖块侧面底角那种位置游戏里的表现往往又是另一个样。为了让手感更细腻我给角色设计了一个“碰撞方向优先级”垂直方向优先于水平方向。具体做法是每小时先做水平移动的碰撞再做垂直移动的碰撞并且每一段都单独遍历地面列表。这样虽然多了一次循环但碰撞判断的准确性高了很多。在我的项目里踩踏敌人用的是单独一个“脚底判定”矩形# 脚底 sensor 矩形比角色整体矩形小并且位于底部 feet_rect pygame.Rect( self.rect.x 2, self.rect.bottom - 4, self.rect.width - 4, 4 ) if feet_rect.colliderect(enemy.rect) and self.speed_y 0: # 玛丽踩到敌人了 self.kill_enemy(enemy) self.speed_y JUMP_POWER * 0.6 # 踩敌人后弹跳注意踩敌人的前提条件是self.speed_y 0也就是角色正在下落中。如果角色从侧面碰到敌人但speed_y 0那就要触发受伤逻辑。这个细节不处理好就会出现“从旁边碰一下敌人也把敌人踩死”的荒谬情况。4. 横版卷轴摄像机跟随与基于二维数组的地图设计超级玛丽奥每一关的场景都很大但屏幕永远只能看到一小块。这里涉及两个问题摄像机怎么移动以及地图数据怎么组织。4.1 摄像机跟随原理与实现所谓“摄像机跟随”本质上是让所有地图元素减去一个偏移量再渲染。玩家在世界中的位置是player.world_x屏幕宽度是SCREEN_WIDTH那么摄像机的偏移量可以这样算camera_x player.world_x - SCREEN_WIDTH // 3 camera_x max(0, camera_x) # 防止摄像机跑到地图左边外面然后每一帧渲染时把每个对象的绘制坐标都减去camera_xscreen.blit(tile.image, (tile.rect.x - camera_x, tile.rect.y))为什么用SCREEN_WIDTH // 3而不是SCREEN_WIDTH // 2因为玩家站在屏幕左侧大概三分之一的位置时摄像机才开始移动视野能看到更多右侧的未知区域这在横版卷轴里是很经典的“视线设计”——右侧是前进方向要留出更大的探索空间。4.2 用二维数组搭地图比“硬编码放砖块”优雅十倍如果在地图编辑器里一个个拖砖块那做一个超长关卡可能会累死。我的做法是维护一个二维数组数组每个数字代表一种地图元素MAP_DATA [ , , , XXXXXXXXXXXX , , ? ? , , XXX XXXXXX XXXXXX XXXXXX , P , ]注意这里用了字符串数组P代表玩家出生点X代表砖块?代表问号砖块。加载时遍历数组遇到对应字符就生成对应的 Sprite 对象。这样做的好处非常明显想改关卡结构直接改这个字符串数组就行完全不用动代码。4.3 按需加载一次只渲染屏幕内的砖块地图很长如果每帧把整张地图的几千个砖块都做一次碰撞检测和绘制性能会明显下降。优化做法是只处理视线范围内的切片start_x int(camera_x // TILE_SIZE) end_x int((camera_x SCREEN_WIDTH) // TILE_SIZE 1) for row in range(len(MAP_DATA)): for col in range(start_x, min(end_x, len(MAP_DATA[row]))): tile tile_map[row][col] if tile: screen.blit(tile.image, (col * TILE_SIZE - camera_x, row * TILE_SIZE))每次循环只渲染十几块砖与整个地图几千块相比性能开销差距是上百倍的。做这个优化后我在地图上铺了三四千块砖游戏依然能稳定在 60 帧。5. 敌人与道具系统从“会走”到“好玩”的关键一步一个光能跑能跳的玛丽奥其实有点像技术 Demo要让游戏真正“好玩”得加上敌人和金币的交互。这里的玩法逻辑要比单纯移动角色更有设计感。5.1 最简单的巡逻敌人让板栗仔来回走我第一个做的是经典的红头蘑菇敌人我们那叫板栗仔。它的逻辑非常简单碰到墙或者走到边缘就掉头。class Enemy(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.image pygame.Surface((28, 28)) self.image.fill((139, 69, 19)) self.rect self.image.get_rect(topleft(x, y)) self.speed_x -2 def update(self, tiles): self.rect.x self.speed_x for tile in tiles: if self.rect.colliderect(tile.rect): if self.speed_x 0: self.rect.right tile.rect.left else: self.rect.left tile.rect.right self.speed_x * -1这段代码能正常工作但有一个小缺陷如果敌人走到平台边缘它不会掉下去而是会直接悬空继续走就像踩在空气上一样。要做得更像原版需要额外检测脚下有没有地面# 检测前方脚下是否有地面 front_x self.rect.right 1 if self.speed_x 0 else self.rect.left - 1 ground_ahead False for tile in tiles: if tile.rect.collidepoint((front_x, self.rect.bottom 2)): ground_ahead True break if not ground_ahead: self.speed_x * -1加了边缘检测之后敌人才是真的“会走路”而不是飘在空中。5.2 金币、砖块与“变大蘑菇”的事件流经典超级玛丽里顶“”方块会弹出金币或者蘑菇。这个机制的实现本质上是一个事件触发链条顶砖块 - 播放顶砖动画 - 生成一个往上弹的道具精灵 - 道具上升一定高度后开始做抛物线运动。我的实现方式是定义一个Item类蘑菇或者金币它有几个状态静止、上升、运动。被顶出来时进入上升状态慢慢向上滑升到一定高度后变成运动状态受重力影响开始跌落和水平移动。class Item(pygame.sprite.Sprite): def __init__(self, x, y, kind): super().__init__() self.kind kind # mushroom 或 coin self.image pygame.Surface((24, 24)) self.image.fill((255, 215, 0) if kind coin else (255, 0, 0)) self.rect self.image.get_rect(topleft(x, y)) self.state rising self.rise_speed 4 def update(self, tiles): if self.state rising: self.rect.y - self.rise_speed if self.rect.y self.target_y: self.state moving self.speed_y 0 self.speed_x 2 if self.kind mushroom else 0 elif self.state moving: self.speed_y GRAVITY self.rect.x self.speed_x self.rect.y self.speed_y蘑菇水平移动、金币原地闪光这个区分让游戏内容立刻丰富了一点。5.3 玩家受伤判定与“大小玛丽”切换玩家碰到敌人时根据当前状态好多种处理方式大玛丽状态缩成小玛丽获得短暂无敌帧。小玛丽状态直接死亡扣除一条命。这里实现的一个重点是“无敌帧”。角色被击中后不能马上再次被击中否则会被敌人连击致死。我用了一个invincible_timer计时器来控制if pygame.sprite.spritecollide(self, enemies, False): if self.invincible_timer 0: if self.is_big: self.is_big False self.image load_small_mario() self.invincible_timer 90 # 大约 1.5 秒的无敌时间 else: self.lives - 1 if self.lives 0: self.game_over()无敌时间内角色图片会闪烁这个用帧计数器配合奇偶帧隐藏角色就能实现效果非常经典。6. 完善体验音效、计分、关卡初始化和窗口事件细节游戏的核心玩法做完之后还有一个“从能玩到好看”的阶段。这个阶段经常被新手忽略但它恰恰是决定一个游戏“像不像作品”的分水岭。6.1 给角色加上真实素材图片替代色块前面为了讲逻辑全程用色块代替角色。但你自己做的时候我建议尽快换成真正的素材。网上有很多免费的“像素风玛丽奥素材包”加载方式和色块方式几乎一样self.image pygame.image.load(assets/images/mario_stand.png).convert_alpha() self.image pygame.transform.scale(self.image, (TILE_SIZE, TILE_SIZE * 2))convert_alpha()很重要——它把图片处理成带透明通道的格式否则 PNG 的透明区域会变成黑色方块。另外pygame.transform.scale可以把小号像素图放大到合适的尺寸注意这个操作每帧执行会很浪费性能建议在初始化时缩放好再存下来。跑动动画怎么做非常简单就是把几帧行走的图片按时间顺序循环切换。每帧检查一个anim_timer每隔 0.1 秒切换到下一帧图片循环往复self.anim_timer 1 if self.anim_timer % 6 0: self.current_frame (self.current_frame 1) % len(self.walk_frames) self.image self.walk_frames[self.current_frame]6.2 音效、背景音乐与计分系统音效这块有个细节pygame 的mixer模块对 MP3 格式支持有限某些 MP3 文件会报错或者声音异常建议全部用 WAV 格式的音效和 OGG 格式的背景音乐。初始化音效pygame.mixer.init(frequency44100, size-16, channels2, buffer512) jump_sound pygame.mixer.Sound(assets/sounds/jump.wav) coin_sound pygame.mixer.Sound(assets/sounds/coin.wav)计分系统就是维护一个全局变量显示用pygame.font.Font渲染文字font pygame.font.SysFont(Arial, 24) score_text font.render(fSCORE: {score:06d}, True, (255, 255, 255)) screen.blit(score_text, (20, 20))注意{score:06d}这个格式化写法它能把数字补足到 6 位显示成000025比直接显示25更有游戏感。6.3 窗口焦点与像素风缩放容易被忽略的体验细节两个小细节让你的游戏更像一个“正经游戏”。第一个窗口失焦时自动暂停。如果玩家切到别的窗口游戏还在自己跑回来时角色已经死光了这体验很糟糕。在事件循环里监听WINDOWFOCUSLOST和WINDOWFOCUSGAINEDif event.type pygame.WINDOWFOCUSLOST: paused True if event.type pygame.WINDOWFOCUSGAINED: paused False第二个像素风放大。做复古游戏通常内部用低分辨率比如 480x270然后整体放 3 倍显示到 1440x810。这样既能保证像素风格锐利又能适配现代屏幕。实现方式是SCREEN_WIDTH, SCREEN_HEIGHT 480, 270 screen pygame.display.set_mode((SCREEN_WIDTH * 3, SCREEN_HEIGHT * 3)) display_surface pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT)) # 每帧先绘制到 display_surface然后整体缩放 pygame.transform.scale(display_surface, (SCREEN_WIDTH * 3, SCREEN_HEIGHT * 3), screen)这个方法推荐给所有做像素风格游戏的人比直接拉大窗口里的单个图片清晰多了。7. 打包发布把这堆 Python 代码变成 exe 文件游戏做好了想发给朋友玩总不能让对方先装 Python、再装 pygame、再复制代码吧。这时候就需要打包成独立可执行文件。7.1 用 PyInstaller 打包的完整流程我试过几个打包工具最省心的是 PyInstaller一条命令就能搞定pip install pyinstaller pyinstaller --noconfirm --windowed --add-data assets;assets mario.py--windowed是去掉控制台窗口--add-data把素材目录一起打包进去。这步配置有个大坑——如果你的代码里图片路径写的是相对路径打包后 exe 会找不到素材。稳妥做法是在代码里动态获取资源根目录import sys import os def resource_path(relative_path): try: base_path sys._MEIPASS except AttributeError: base_path os.path.abspath(.) return os.path.join(base_path, relative_path)然后所有加载素材的地方都改成resource_path(assets/images/mario_stand.png)。7.2 打包后常见问题与排查思路PyInstaller 打包的游戏最常出现的坑是“双击没反应”。我遇到过的情况有两种一种是杀毒软件误杀。解决办法是开发时经常打包验证发现杀软误报就换个打包方式或者加信任。另一种是缺 DLL 或素材路径问题。这种时候先建一个带控制台的版本来排查pyinstaller --noconfirm mario.py运行生成的 exe如果有报错会直接显示在控制台里。看到具体报错信息再回代码里改比瞎猜高效得多。另外打包出来的 exe 体积通常有 30MB 到 50MB这很正常因为 Python 解释器和 pygame 库都要一起打包进去。如果想缩小体积可以尝试用 UPX 压缩可执行文件但有时候会导致杀软误报我一般不强求压缩。7.3 资源文件管理别让素材散落一地游戏做大了素材文件会非常多。我建议的项目目录结构是这样的super_mario/ ├── mario.py # 主程序 ├── settings.py # 全局常量窗口大小、速度、重力等 ├── sprites/ │ ├── __init__.py │ ├── player.py # 玩家类 │ ├── enemy.py # 敌人类 │ ├── brick.py # 地图砖块类 │ └── item.py # 道具类 ├── assets/ │ ├── images/ │ ├── sounds/ │ └── levels/ │ └── level1.txt # 关卡地图文本把全局常量集中到settings.py是后期调手感最快的路径。我调跳跃高度时只需要改JUMP_POWER一个值完全不用在几十个文件里找参数。尾声一个建议这项目全部写完我从“会写 Python 基础语法”到“能独立做完一个完整的 pygame 游戏”对类的设计、事件循环、碰撞检测这些概念的理解完全上了一个台阶。很多时候我们学编程总觉得差了点什么其实就是缺一个“东一块西一块拼出完整作品”的过程。超级玛丽奥是一个刚刚好的项目——没简单到无聊也没复杂到劝退。做完它你会发现自己脑子里对“游戏是怎么做出来的”有了一个非常具象的认知。接下来要是想继续深入可以试试给游戏加一个 Boss 战、做一张水下关卡、把地图改成从文件加载的二进制格式玩法空间还有很多就看你想往哪个方向折腾了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进