用Python和pygame从零复刻超级马里奥:物理、碰撞与状态机全解析

发布时间:2026/9/2 2:24:23
用Python和pygame从零复刻超级马里奥:物理、碰撞与状态机全解析 简介这是一份基于Python与Pygame库编写的超级马里奥游戏项目聚焦经典第一关完整玩法适合具备Python基础并想向游戏开发进阶的读者。资源压缩包共91个文件总大小9.52MB其中包含29个py源码、18个ogg背景音乐、9个png贴图、5个wav音效以及pyc编译文件、字体与配置文件可直接运行也可按模块拆解学习。项目采用面向对象设计将马里奥、敌人、平台、道具等抽象为独立类完整实现移动、跳跃、重力、碰撞检测、敌人AI、计分系统和关卡切换等核心机制代码组织符合游戏开发常用模式。通过分析源码可系统掌握Pygame事件循环、精灵动画、音频播放、二维地图数据结构设计等实用技能同时借助附带的README、requirements等文档快速搭建环境。已有3855人学习下载是结合趣味性与教学性的良好练手项目适合课后复刻、二次开发或课程设计参考。 如果你问我用 Python 写一个能正常玩的《超级马里奥》难不难我的回答是不难但远比你想的繁琐。网上流传的所谓Python 写马里奥大多只是渲染了几个方块能跑但手感稀烂跳跃像踩了弹簧碰撞像是靠玄学。这篇文章不是带你复制一个 Demo而是把我自己完整从零复刻一代第一关的整个过程、踩过的坑、最终沉淀出的框架逻辑全部摊开来讲。整个项目核心基于Python 3 pygame 2.x实现了一款包含角色控制、敌人系统、碰撞检测、摄像机跟随、状态机切换和基础 UI 的横版闯关小游戏。这个版本的马里奥与真正的 FC 版在操作逻辑上保持一致加速跑、跳跃高度随按键时长变化、踩敌人反弹等等。下面进入正题。1. 为什么偏要用 Python 复刻马里奥以及我最终选定的方案1.1 技术选型pygame 不是唯一答案但对我来说是最合适答案市面上做 Python 游戏绕不开几个方案pygame、arcade、cocos2d-python以及一套相对抽象但功能极强的kivy。很多人建议直接上引擎比如Godot或者Unity然后用 Python 写逻辑。但你如果只是想深入理解 2D 横版游戏的底层运行逻辑比如帧循环、碰撞响应、摄像机偏移和状态切换pygame 反而是最佳的解剖台因为它把所有控制权都交给你所见即所得。我选择的 pygame 版本是2.5.2配套 Python 版本是3.11.9。这里有一个很关键的点pygame 2.x 对 Python 3.11 的兼容性已经非常完善不需要再去折腾旧版本。另一个需要考虑的问题是资源与素材。直接使用原版 FC 的马里奥素材涉及版权问题用于个人学习可以公开分享时需要替换。我最终采用的是 open source 的像素风格素材包主要包含马里奥角色的idle、run、jump、death四套动画帧地面砖块、问号砖块、管道、旗杆等场景元件蘑菇怪Goomba、乌龟Koopa的走动与转向状态背景云朵、小山、灌木的装饰层贴图这里涉及的经验是素材资源在前期就要规划好命名规范否则写到一半你会发现找一张图比写一百行代码还头疼。1.2 项目目录结构从一开始就为后续打包留好后路我见过太多人一口气把所有代码塞进一个main.py前期很爽写到一屏放不下时开始痛苦最后整个项目连带情绪一起崩掉。写游戏本质上也是在写一个持续变化的应用结构清晰与否决定了你能不能在两天后继续修改。我最终整理出的目录如下super_mario/ ├─ main.py ├─ core/ │ ├─ game.py # 游戏主循环、事件分发、场景切换 │ ├─ camera.py # 摄像机偏移与平滑跟随 │ ├─ constants.py # 全局常量重力、速度、尺寸、颜色 │ └─ utils.py # 加载图片、加载音频、截取精灵图等工具方法 ├─ entities/ │ ├─ player.py # 马里奥角色逻辑 │ ├─ enemy.py # 敌人基类与具体敌人实现 │ └─ items.py # 金币、蘑菇、道具砖块逻辑 ├─ stages/ │ ├─ tilemap.py # 地图加载与碰撞体生成 │ └─ level_1.py # 第一关数据配置 └─ assets/ ├─ images/ ├─ sounds/ └─ fonts/核心经验是业务逻辑比如马里奥跳起来撞到砖块与表现层比如播放一段砖块抖动的动画一定要分离放在不同模块里。这不是为了炫技而是你排错时会感谢这种设计——问题要么在player.py要么在tilemap.py而不是在 1000 多行的 main 文件里抓瞎。2. 马里奥的手感从哪来物理模型、碰撞矩形与状态机的细节2.1 物理模型不要迷信真实物理引擎效果FC 版马里奥的跳跃手感之所以封神核心不是真实的抛物线而是一套经精心调校的离散物理模型。网上很多教程直接给角色加一个 gravity 常量然后不断叠加速度结果跳起来飘得飞起。我调试后的最终物理参数如下参数值说明重力加速度0.8每帧加到纵向速度上的数值最大下落速度12.0防止坠落时帧间位移过大穿透地板基础移动速度3.0非奔跑状态的横向速度奔跑速度5.5按住奔跑键后的横向速度初始跳跃速度-13.5向上的纵向速度短按/长按跳跃倍率0.5短按跳跃后速度衰减更快核心逻辑当玩家按下跳跃键后给角色一个向上的初速度如果玩家在上升过程中松开跳跃键立刻将当前纵向速度乘以一个衰减系数如果一直按住则保持正常重力叠加。这实现了经典的跳得越高取决于按键时长的手感。代码层面大致是这个思路def handle_jump_release(self): if not self.is_jumping: return if self.vy 0: # 上升中松开按键 self.vy * 0.5 # 阻断上升势头让角色快速回落 def apply_gravity(self): self.vy min(self.vy GRAVITY, MAX_FALL_SPEED)这里的核心经验是每次只调整一个参数然后跑一遍第一关测试跳跃不要一下子改好几个参数。你会发现音量手感这种东西是高度敏感的改一个数值体验可能天翻地覆。2.2 碰撞矩形几乎所有穿墙问题的根源都在这里pygame 的碰撞检测依赖pygame.Rect也就是每一个精灵都有一个矩形碰撞体。听起来简单但实际做横版动作游戏时这里藏着大坑。第一坑不能用整张图片作为碰撞矩形。像素画的素材边缘往往有不透明背景的残留直接使用原图 rect 会导致角色疯狂蹭墙、上台阶卡住、踩敌人判定范围过大。我的做法是在预处理加载图片后手动裁剪碰撞区域默认取图片中心区域四周各缩进 4~6 像素。第二坑不能直接player.rect.move_ip(vel_x, vel_y)去移动角色。横版游戏标准做法是斜向分离移动——先处理水平位移再做水平方向的碰撞检测最后处理垂直位移和垂直碰撞。这样处理的好处是踩到敌人、顶到砖块、撞墙的逻辑不会互相干扰。def move_and_collide(self, dx, dy): # 水平移动 self.rect.x dx for tile in self.collision_tiles: if self.rect.colliderect(tile.rect): if dx 0: self.rect.right tile.rect.left elif dx 0: self.rect.left tile.rect.right # 垂直移动 self.rect.y dy for tile in self.collision_tiles: if self.rect.colliderect(tile.rect): if dy 0: self.rect.bottom tile.rect.top self.on_ground True elif dy 0: self.rect.top tile.rect.bottom self.vy 0这个方案并不是我发明的而是几乎所有 2D 横版游戏都在采用的基础逻辑难的地方在于细节多个方块重叠碰撞、半格高度的台阶、顶砖块时角色要停止上升但不能卡进砖块内部。2.3 状态机:角色不是一个角色而是一组行为模式一开始我把马里奥的跳跃、跑动、站立全部揉在一个类里用大量 if 来管理结果逻辑到处打架跑动时按跳跃跳跃落地后瞬间又触发跑动动画闪烁死亡时还能移动顶砖块时播放顶砖动画但角色还在下落……这些问题本质上都是状态混乱。最终我引入一个简单的状态机定义了三组互斥状态class PlayerState: IDLE idle RUN run JUMP jump FALL fall DEAD dead状态切换的核心经验是状态的过渡条件必须集中管理不要散落在各个方法里。我在Player类里专门写了一个_transition_state方法所有可能改变状态的地方都通过这个方法来切换并在此处记录状态切换的时间戳。动画播放时根据状态和当前时间戳找到对应帧。这样排查为什么角色在空中却播放了站立动画这类问题会非常快。3. 地图工程化坐标、地块、摄像机与分层渲染3.1 从零手工摆地图非可视化编辑器的生存之道有人可能会推荐使用Tiled地图编辑器生成 JSON 后再加载。这个方案很适合大型关卡但对一个复刻学习项目来说有点杀鸡用牛刀。我选择的是轻量方案用二维数组来代表地块。LEVEL_1_MAP [ ------------------------------------------------------------------, - -, - -, - -, - -, - -, -------- ? ? -, ---------------- ----------- -, ? ? -, ? ----- -, ----- --- ? ? -, ------------------------------------------------------------------, ]每个字符对应一种地块类型-代表地面砖?代表问号砖代表管道等。在tilemap.py里维护一个字符到地块类型或图片的映射字典。这样做优点是可视化程度很高你一眼能看出关卡大概长什么样缺点是没有图形化界面那么直观但胜在快速改一个字符就能调整地图。地块大小我统一设定为32x32像素也就是 FC 时代的标准 tile 尺寸。将地图数据解析成碰撞体列表时为了减少运行时的碰撞检测计算量我会把相邻的横向地块合并为一个更大的矩形统一放入碰撞检测列表def build_collision_rects(map_data): rects [] for row_idx, row in enumerate(map_data): col_idx 0 while col_idx len(row): tile row[col_idx] if tile -: start col_idx while col_idx len(row) and row[col_idx] -: col_idx 1 rects.append(pygame.Rect(start * TILE_SIZE, row_idx * TILE_SIZE, (col_idx - start) * TILE_SIZE, TILE_SIZE)) continue col_idx 1 return rects这是一个非常实用的优化假如一屏内有 30 个地面方块横着排列合并后只剩 1 个碰撞矩形碰撞检测效率直接提升一个量级。3.2 摄像机跟随不该用紧贴角色的方式马里奥的摄像机并不是死死把角色钉在屏幕中心而是有几个明显特征当马里奥站在关卡最左侧时摄像机不动马里奥向右侧移动时摄像机跟随马里奥向左退时摄像机并不立刻退回而是有延迟实现上有两种做法第一种是每帧将世界坐标减去摄像机偏移量后渲染第二种是移动整个世界的位置。我采用的是第一种这也是工程上最标准的方案。class Camera: def __init__(self, width, height): self.rect pygame.Rect(0, 0, width, height) self.viewport_width width def update(self, target): x target.rect.centerx - self.viewport_width // 3 x max(0, x) # 不越过左边界 self.rect.x x这里采用viewport_width // 3而不是// 2目的是让马里奥在屏幕偏左三分之一处给右方视野留出更多空间方便玩家提前看到前方敌人。这个经验是复刻手感时被很多人忽略的细节镜头位置本身就是游戏手感的一部分。渲染顺序上我严格遵循三层背景的绘制顺序背景装饰层云、山、灌木只做视差滚动不参与碰撞地图砖块层地面、管道、问号砖实体层马里奥、敌人、奖励道具按 y 坐标排序保证遮挡关系正确3.3 视差滚动两行代码撑起的层次感视差滚动效果并不复杂——背景层滚动速度是前景的一半即可。但要注意的是背景层不能跟随摄像机真正的偏移量而是乘以一个系数一般情况下云层 0.3远山 0.5灌木 0.7 左右否则背景和地面像贴在一张纸上游戏显得非常扁平。def draw_background(self, surface, camera_offset): for cloud in self.clouds: screen_x cloud.rect.x - camera_offset * 0.3 surface.blit(cloud.image, (screen_x, cloud.rect.y))一个很常见的细节 bug 是背景层滚出屏幕后会在另一侧漏出黑边。解决方法是在渲染时把背景元素的范围复制两份分别绘制在偏移位置和偏移位置加/减一个屏幕宽度的区域确保任何时候都有背景覆盖。4. 敌人 AI、道具逻辑与动画系统的实现细节4.1 蘑菇怪与乌龟敌人内部需要自己管理自己的状态蘑菇怪的逻辑非常简单向左或向右匀速移动遇到砖块边界就转身。这里最关键的一点是——敌人必须知道它脚下和前方是什么否则会飞出平台。我的实现方式是给每个敌人一个direction属性每帧尝试沿该方向移动然后检测碰撞如果碰到墙体则反转方向。此外还需要检测边缘悬空前方没有地块时会转身。这两个判断分开写因为边缘转身需要额外向下发射一条射线判断脚下是否有地块。def check_edge(self): below_x self.rect.centerx self.direction * self.rect.width // 2 below_y self.rect.bottom 4 for tile in self.collision_tiles: if tile.rect.collidepoint(below_x, below_y): return True return False乌龟的逻辑会复杂一些它有两个状态——缓慢爬行和缩壳。缩壳时被踩到会变成滑行龟壳滑行中的龟壳可以撞碎砖块、撞飞其他敌人。这需要一套内部状态机来管理但核心判断仍然基于碰撞。踩敌人这个核心玩法判定逻辑必须单独说明玩家下落时踩到敌人头部玩家反弹敌人死亡玩家从侧面碰到敌人玩家掉血或死亡。这个判定不能只依赖colliderect还要考虑相对速度和位置。准确的实现方式是在移动过程中记录碰撞发生前玩家所在的 y 坐标如果旧的 y 玩家矩形高度的一半小于敌人矩形顶部才判定为踩踏。def handle_enemy_collision(self, enemy): if self.rect.top enemy.rect.centery: # 判定为踩踏 self.vy JUMP_VELOCITY * 0.6 enemy.stomped() else: # 侧面碰撞扣命 self.take_damage()4.2 问号砖块与道具事件驱动比直接改状态更优雅问号砖块被顶出物品是整个游戏交互逻辑中最容易写乱的部分。一开始我在Player类里直接修改Tile的状态不仅导致代码耦合巨大还出现了金币顶出时砖块已经变成普通砖但金币动画还在空中这种难看的 bug。后来我改成了事件回调的机制Tile类中保存一个on_hit回调当玩家顶到砖块时砖块播放抖动动画并调用回调生成道具。道具生成后由ItemManager统一管理移动、展示与被玩家拾取。代码上的关键结构如下class QuestionBlock(Tile): def __init__(self, x, y, contentcoin): super().__init__(x, y) self.content content self.used False self.bounce_offset 0 def hit(self): if self.used: return self.used True self.image EMPTY_BLOCK_IMAGE self.bounce_offset -8 # 事件交还给全局管理器去生成对应的道具 events.emit(item_spawn, self.content, self.rect.midtop)采用事件驱动最直接的好处是当你想扩展新的道具类型比如花朵无敌星只需要去item_spawn的监听器里加一个分支不影响砖块自身逻辑。道具生成后蘑菇需要在地面上移动并最终可能掉进坑里金币则直接飞上然后消失。一朵蘑菇从砖块里顶上来的过程也是一个状态机从出土到爬行各自有不同的速度与动画。4.3 动画播放脱离物理帧率的帧控制用 pygame 做动画最容易犯的错误是把游戏帧率当成了动画帧率。如果游戏是 60 FPS但角色的跑动动画只有 4 帧那按逻辑每帧切换一次动画角色就像触电一样抖动。正确做法是引入animation_timer每帧累加时间差值当累积值达到动画播放间隔时才切换到下一帧。例如跑动动画 4 帧每帧播放时长 80 毫秒那么这 4 帧画面需要 320 毫秒完整播放一次与游戏循环的 60 FPS 解耦。def update_animation(self, dt): if self.state PlayerState.RUN: self.animation_timer dt if self.animation_timer 0.08: self.animation_timer 0 self.current_frame (self.current_frame 1) % len(self.run_frames) elif self.state PlayerState.JUMP: self.current_frame 0另一个容易被忽略的点是角色朝向翻转后的动画帧缩放。马里奥向右跑和向左跑用的应该是同一组图片水平翻转后的结果。pygame.transform.flip每次调用都会产生新表面导致内存开销和性能损耗。所以我采用提前预处理在加载动画帧时就把左右两个方向的帧列表全部生成好运行时直接取用。5. 鲁棒性细节帧率控制、碰撞体缩小与资源路径的坑5.1 帧率clock.tick(60)远远不够几乎每个 pygame 教程都会让你在主循环结尾写clock.tick(60)但实际中如果你的机器性能波动或者后台开了太多应用导致帧率掉到 40 FPS整个游戏就会进入慢动作模式——因为所有物理量都基于帧累积。解决方法是引入delta time帧间隔时间将所有速度计算从每帧移动多少像素改为每秒移动多少像素。dt clock.tick(60) / 1000.0 player.rect.x player.vx * dt * 60 # 以60帧作为基准速率更精确的做法是把重力也乘上 dtvy GRAVITY * dt * 60。当然物理速度乘以一个基准帧率系数是为了保持之前的调参体验不变——否则之前调好的手感会因为引入 dt 而全部失效。调参系统和帧率系统要一起改否则会碰到诡异的我明明没改物理手感怎么漂了的问题。5.2 碰撞体缩小半像素Frozen 游戏设计中的经典技巧这个技巧在《Celeste》等现代像素动作游戏中被广泛采用——将玩家的碰撞矩形缩小到比视觉精灵略小一点的尺寸。原版 FC 马里奥的碰撞矩形也并不是铺满整个精灵图。我的实测经验是地面与头顶的碰撞体保持原尺寸左右两侧各缩进 3 像素。这个微小的调整极大改善了手感原因是在像素游戏中玩家视觉上觉得明明没碰到但碰撞矩形边缘已经接触墙面。缩小碰撞矩形可以让玩家产生为什么我好像能擦边过去的爽快感同时又不会明显出现被空气墙挡住的挫败感。尺寸缩进需要在调整图片时整体把控不要放到碰撞检测逻辑里做否则每次移动都要做一次加减法增加不必要的计算量。5.3 资源路径打包成 exe 之前先想好资源从哪读取很多初学者在开发环境里运行得好好的一旦用pyinstaller打包图片全部加载失败。原因很简单打包后的程序工作目录和源码目录不一样相对路径全部失效。我的处理方式是将资源路径统一收敛到一个工具函数中并注意先解析出当前可执行文件所在的绝对路径import sys, os def resource_path(relative_path): base_path getattr(sys, _MEIPASS, os.path.abspath(.)) return os.path.join(base_path, relative_path)然后在utils.py中统一调用resource_path(assets/images/player_idle_1.png)。这个细节让后期的打包过程变得非常顺畅没有因为找不到素材而卡住。6. 打磨到最后可玩状态音效、UI 与打包发布6.1 音效从沉默世界到有声有色的游戏体验完成视觉与玩法闭环后游戏依然显得冷因为没有声音。我补上了五类音效跳跃音效、踩怪音效、金币音效、顶砖块音效和死亡音效。这些音效来自免费音效库但播放前需要统一做音量归整——否则某些音效响起时耳膜会突然受惊。在 pygame 中加载音效要注意格式问题。pygame.mixer加载 MP3 的兼容性在不同平台上差异较大建议统一转换为WAV 格式且单声道、采样率 22050 即可。在循环播放 BGM 时设置-1作为循环次数。还要在开头调用一次pygame.mixer.pre_init(22050, -16, 1, 512)否则加载音频时可能出现卡顿。注意顺序pre_init必须在pygame.init()之前调用这个细节很多教程不会提。6.2 基础 UI 与游戏循环流程让玩家知道自己在第几关UI 虽然不算核心玩法但没有分数和生命的游戏会显得极其空。我加上了经典的顶部信息栏分数、金币数和命数。字体使用像素风格点阵字体并为中文字符提前准备好 font 文件。游戏的整体循环采用简单的状态机管理game_state MENU # MENU / PLAYING / GAME_OVER / LEVEL_COMPLETE不同状态对应不同的事件处理和渲染函数。这个结构使得后续扩展重新开始关卡、进入下一关都变得非常直接——只需要切换game_state并重新加载地图数据即可。6.3 pyinstaller 打包一份可以发给朋友的独立文件开发完项目后我顺手用pyinstaller做了一次打包生成独立的 exe 文件方便分享给朋友测试。打包命令如下pyinstaller -F -w -n SuperMario main.py --add-data assets;assets-F表示打包成单个文件-w表示运行时不显示命令行窗口--add-data负责把素材目录一起带进去。打包出来的文件体积大概 35MB去掉 BGM 之后可以压缩到 8MB 左右。这里需要特别注意的是如果你使用了resource_path函数那么在_MEIPASS模式下--add-data的资源路径才能被正确访问否则运行时会闪退。7. 实测验证与个人体会到这里项目核心已经完成。从开发到最终可玩我前后花了约两周的业余时间。最后对这个成品做了几轮完整测试第一轮是机械性测试从第一关起点一路跑到旗杆不跳跃任何敌人检验所有砖块碰撞、管道缝隙、地形边缘是否存在卡死位置。结果发现两个问题一处台阶边缘会让角色挤进墙面后无法移动一处管道底部碰撞矩形残留导致虚无遮挡。修复方法都是调整碰撞矩形的合并逻辑。第二轮测试是手感测试请了几位不太玩游戏的同学试玩核心反馈是跑起来太飘以及跳跃时方向键先于跳键按角色没有助跑加速感。针对太飘我把重力从0.7调高到0.8同时将长按跳跃的上升加速度从0.9调低到0.5。这个调整让我意识到一个非常重要的原则手感不是一次调试出来的而是反复试玩、反复微调出来的。流程上最好是玩一把 → 觉得哪里不对劲 → 记录 → 改一个参数 → 再玩一把每次只改一个变量。第三轮是性能测试使用pygame.display.set_caption实时显示 FPS同时在场景中生成大量敌人和金币来观察帧率变化。最终在普通笔记本上稳定运行 60 FPSCPU 占用率约 40%完全满足流畅需求。具体的性能提升经验是敌方数量一多全屏遍历所有精灵做碰撞检测会带来不小开销解决方案是引入简单的空间分块只检测玩家周围 2 个屏幕范围内的敌人。写这篇文章的时候项目代码大概在 2000 行左右。对于一个怀旧复刻项目这个规模算是合理。如果让我重新选择一次技术方案我依然会选 pygame 手动物理模型。它让我真正理解了横版平台游戏的运行逻辑这些理解在以后切换到任何引擎做游戏时都不会失去价值。最后分享一个实用的调试小技巧在开发过程中可以在constants.py中加一个DEBUG True开关开启后把所有碰撞矩形用彩色线框绘制出来。配合这个开关你会发现排查为什么角色被挡在了一个看不见的位置这类问题效率提升十倍。本文还有配套的精品资源点击获取