☰
用Python和pygame从零复刻超级马里奥:物理、碰撞与状态机全解析
2026/10/22 15:06:17 网站建设 项目流程

简介:这是一份基于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,目的是让马里奥在屏幕偏左三分之一处,给右方视野留出更多空间,方便玩家提前看到前方敌人。这个经验是复刻手感时被很多人忽略的细节:镜头位置本身就是游戏手感的一部分。

渲染顺序上,我严格遵循三层背景的绘制顺序:

  1. 背景装饰层(云、山、灌木),只做视差滚动,不参与碰撞
  2. 地图砖块层(地面、管道、问号砖)
  3. 实体层(马里奥、敌人、奖励道具,按 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, content="coin"): 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帧作为基准速率

更精确的做法是把重力也乘上 dt:vy += 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 FPS,CPU 占用率约 40%,完全满足流畅需求。

具体的性能提升经验是:敌方数量一多,全屏遍历所有精灵做碰撞检测会带来不小开销,解决方案是引入简单的空间分块(只检测玩家周围 2 个屏幕范围内的敌人)。

写这篇文章的时候,项目代码大概在 2000 行左右。对于一个怀旧复刻项目,这个规模算是合理。如果让我重新选择一次技术方案,我依然会选 pygame + 手动物理模型。它让我真正理解了横版平台游戏的运行逻辑,这些理解在以后切换到任何引擎做游戏时都不会失去价值。

最后分享一个实用的调试小技巧:在开发过程中,可以在constants.py中加一个DEBUG = True开关,开启后把所有碰撞矩形用彩色线框绘制出来。配合这个开关,你会发现排查"为什么角色被挡在了一个看不见的位置"这类问题,效率提升十倍。

本文还有配套的精品资源,点击获取

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

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

立即咨询