☰
C++超级玛丽源码解析:SDL2游戏循环、碰撞检测与关卡改造
2026/10/20 12:59:56 网站建设 项目流程

简介:一份经典超级玛丽(超级马里奥)的C++源码实现,适合游戏开发初学者、在校学生及C++爱好者学习参考,可用于理解从零搭建2D平台跳跃游戏的核心逻辑与工程结构。压缩包共49个文件,大小1.48MB,包含头文件与C++源文件、游戏位图资源、可执行文件以及完整工程配置文件,既有可直接运行的成品,也有便于研读和二次开发的源代码。源码覆盖游戏主循环、角色与敌人对象、地图关卡数据结构、物理碰撞、图形渲染及音效播放等模块,可系统学习面向对象封装、游戏循环机制、用户输入处理、碰撞检测、内存管理与文件读写等知识点。已有3384人学习,尤其适合希望结合经典案例快速入门游戏编程,并进一步修改关卡、替换角色素材进行实践练习的读者。

1. 超级玛丽 C++ 源码:不只是怀旧,而是一份能当教学范本的游戏工程

说实在的,做游戏开发这几年,“复古游戏源码”的压缩包我拆过不少,大多数打开发现是半成品,能编译通过就算运气好。这份用 C++ 写的超级玛丽(超级马里奥)源码,属于少数能一口气跑起来的:编译、加载、键盘操作、闯关、死亡重来,流程完整。它没有依赖 Unity 这类引擎,图形层用的是 SDL2,逻辑代码只有几千行,反而比很多大工程讲得更清楚。对刚入行的朋友,它是一份能逐行读完的完整项目;对老手,它是一个改起来不心疼、验证想法很顺手的原型底座。下面把结构、编译、地图修改和踩坑一次拆完。

2. 源码结构拆解:主循环、AABB 碰撞与状态机

2.1 模块划分:一份小型 C++ 游戏工程的标配文件

解压源码包后,第一件事是看文件分布。我拆的版本大概长这样:

SuperMario/ ├── main.cpp // 程序入口,初始化窗口和渲染器 ├── game.h / game.cpp // 游戏主体,关卡切换与整体调度 ├── player.h / player.cpp // 玩家角色:状态、物理、输入响应 ├── enemy.h / enemy.cpp // 敌人AI:巡逻、转向、死亡 ├── level.h / level.cpp // 关卡加载、瓦片地图读取 ├── texture.h / texture.cpp // 贴图管理,负责把图片绑定到渲染器 ├── map_data.h // 关卡地图的二维数组,纯数据定义 └── assets/ ├── tiles.png // 瓦片图集 ├── mario.png // 角色帧动画素材 ├── enemy.png // 敌人素材 └── bgm.wav / jump.wav // 音效文件

先讲模块划分的意义。这种“单文件多类”的结构对个人项目非常友好,每个类的职责一眼可见,没有复杂的继承树,也不存在依赖注入容器之类的抽象。当年很多商业项目的早期原型其实也是这么起步的,只是后来才逐步膨胀。对学习者来说,难点不在于读懂某个类,而在于理解这些类之间怎么互相调用:Game持有Player和Level的实例,Level内部保存地图数组,Player在地图数据之上做碰撞检测。这种关系属于“组合优先于继承”的典型体现,值得对着源码自己画一遍调用链。

音频模块通常是一组封装好的SoundManager,依赖 SDL_mixer。如果下载的包不带音频,直接注释掉相关调用也能跑,不影响核心逻辑。这也说明这份源码的耦合度很低,模块之间的边界设计得足够清楚。

2.2 游戏主循环:输入、更新、渲染的顺序不能乱

所有游戏都有一个主循环,超级玛丽也不例外。源码里的主循环一般长这样:

while (game.isRunning()) { // 1. 输入采样:先收集本帧的键盘事件 SDL_Event ev; while (SDL_PollEvent(&ev)) { if (ev.type == SDL_QUIT) { game.quit(); } else if (ev.type == SDL_KEYDOWN) { player.handleKeyDown(ev.key.keysym.sym); } else if (ev.type == SDL_KEYUP) { player.handleKeyUp(ev.key.keysym.sym); } } // 2. 逻辑更新:按当前的输入状态更新角色物理 player.update(dt); for (auto& enemy : game.getEnemies()) { enemy.update(dt); } // 3. 渲染:清屏、画地图、画角色、画敌人 SDL_SetRenderDrawColor(renderer, 0x5C, 0x94, 0xFC, 255); SDL_RenderClear(renderer); level.render(renderer); player.render(renderer); for (auto& enemy : game.getEnemies()) { enemy.render(renderer); } SDL_RenderPresent(renderer); // 帧率控制:把帧率锁在 60 FPS 附近 static Uint32 lastTime = SDL_GetTicks(); Uint32 now = SDL_GetTicks(); Uint32 elapsed = now - lastTime; if (elapsed < 16) { SDL_Delay(16 - elapsed); } lastTime = now; }

初看这段代码像是三块各自独立的部分,实际上顺序是强约束。输入必须放在最前面,因为后续更新依赖“按键是否按下”;逻辑更新紧随其后,因为物理计算要消费输入状态;渲染放到最后,否则画面会和逻辑帧错位。如果把渲染提到更新前面,屏幕上显示的是上一帧的位置,观感上就是“操作有延迟”。

帧率控制用的是最简单的SDL_Delay法,锁 60 FPS 的平均节奏。这里有一个容易被忽略的细节:UPDATE传出去的那个dt,多数源码版本并没有真正计算它,而是当作固定步长使用。也就是说游戏假定每 16ms 更新一次,如果机器卡顿,逻辑会变慢而不是“跳步追赶”。正式一点的游戏会让dt参与所有物理公式,但这份源码为了可读性选择了固定步长。理解了这一步,后面调试“跳跃手感飘”时你就知道从哪里下手,这也是我经常说的“先看循环,再查物理,别上来就乱改参数”。

2.3 碰撞检测:AABB 判交与分轴解算

平台跳跃游戏的碰撞是物理核心,超级玛丽源码里的碰撞属于经典的 AABB(轴对齐边界框)方案。角色、砖块、敌人全部抽象成一个矩形,然后逐帧判断矩形是否相交。核心判交函数可以用四行写完:

bool checkAABB(const SDL_Rect& a, const SDL_Rect& b) { return (a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y); }

参数说明:a.x/a.y是矩形左上角坐标,a.w/a.h是宽和高。这个函数判断两个矩形在二维空间里是否存在重叠区域,四个条件缺一不可。逻辑上就是“A 的左边在 B 的右边左边、A 的右边在 B 的左边的右边、A 的上边在 B 的上边下边、A 的下边在 B 的上边下边”。

但只有判交还不够,真正让游戏“不穿墙”的是响应逻辑。源码里常用的是分轴解算:先处理水平方向的移动和碰撞,再处理垂直方向的移动和碰撞。水平方向移动后,只检查左右方向上的瓦片是否与角色相交;若是,则把角色位置回退到墙边。垂直方向同理。这么做的原因是避免两个轴同时计算时出现“卡墙角”的叠加错误。分轴的代价是每个实体每帧做两次碰撞检测,但这才换来稳定不抖动的手感。

对熟手来说,看这部分代码时重点观察瓦片坐标到像素坐标的换算,通常会有一个类似tileIndex = pixelX / TILE_SIZE的步骤。如果TILE_SIZE定义成 32,而素材实际是 40,那碰撞结果会整体偏移,出现“角色站在空地”的诡异效果。这是检查这份源码时最容易踩的隐藏坑,后面排查章节会细说。

3. 编译与运行:从源码压缩包到第一帧画面的完整流程

3.1 前置条件:搭建 SDL2 开发环境

这份源码的图形层基于 SDL2,所以第一步是让系统能找到 SDL2 的头文件和动态库。Windows 下最常见的方案是用 vcpkg 安装,省去手动配路径的麻烦:

vcpkg install sdl2:x86-windows

这里我特意用了x86-windows而不是x64-windows,原因很实际:不少老源码工程配置的是 32 位编译,如果安装 64 位库,链接阶段会报“无法解析的外部符号”。当然,下载包里的 Visual Studio 工程也可能是 x64 的,你先打开工程属性看一眼平台工具集再决定装哪个版本,这是最稳的做法。

如果你不想用 vcpkg,也可以直接去 SDL2 官网下载开发库压缩包,然后把include和lib目录手动填入工程的“VC++ 目录”选项。这条路径的问题在于很容易配错目录层级,我曾经把include/SDL2写成了include,导致头文件找不到,卡了半小时。现在我的习惯是优先用 vcpkg 或 CMake 的find_package,避免手工埋雷。

3.2 方案 A:Visual Studio 工程直编

下载包里通常附带.sln或.vcxproj文件。打开后,先检查两处:一是平台工具集是不是你本机装了的版本,二是运行库是/MD还是/MT。如果是/MT(静态链接),SDL2 的调试库和发布库需要严格对应,否则链接报错。

命令行编译更可控,我一般这样做:

cmake -S . -B build -G "Visual Studio 17 2022" cmake --build build --config Release

参数说明:-S .指定源码根目录,-B build指定构建输出目录,-G指定 VS 版本。若你在当下环境中没有 VS2022,就把生成器换成你装的版本。编译结束后,把assets整个目录复制到build/Release下面,再运行 exe。这是因为源码运行时会从工作目录读取资源,而 IDE 默认工作目录是build/Release之外的位置,不复制就会黑屏。

3.3 方案 B:Linux 下用 CMake 编译

Linux 下更简单,只要系统里有 SDL2 开发库就能编。CMakeLists 可以这样写:

cmake_minimum_required(VERSION 3.16) project(super_mario_cpp) find_package(SDL2 REQUIRED) add_executable(mario main.cpp game.cpp player.cpp enemy.cpp level.cpp texture.cpp ) target_include_directories(mario PRIVATE ${SDL2_INCLUDE_DIRS}) target_link_libraries(mario PRIVATE ${SDL2_LIBRARIES})

这段配置的关键是find_package(SDL2 REQUIRED),它会自动定位 SDL2 的头文件和库文件路径。如果系统里没装,可以用发行版包管理器装:

sudo apt install libsdl2-dev

装好之后,编译命令只有两行:

cmake -S . -B build && cmake --build build -j

成功后运行./build/mario,窗口就出来了。注意运行时要保证assets目录就在当前工作目录下,否则角色纹理全是暗色方块。

3.4 资源加载与路径约定

资源加载的路径写法透露出不少信息。多数版本使用相对路径,比如./assets/tiles.png,这意味着程序启动时的当前目录必须位于项目根目录或资源所在目录。在 IDE 里运行,要么配置“工作目录”,要么把assets放到构建目录里。我个人的习惯是把assets直接拷贝到输出目录,然后让代码里所有资源路径以assets/开头。这样不管从哪里启动都能跑,而且后续打包分发也方便。

有的版本还会在启动参数里接收“关卡编号”,类似./mario --level 1。这个属于后期开发者自己加的功能,底层其实还是读取map_data.h中的二维数组。如果你下载的版本没有这个参数,不用失望,直接改数组就能换关卡。

4. 常见问题排查:编译失败与运行翻车的五个现场

4.1 现象:编译通过,运行秒退,控制台提示找不到 tiles.png

这种情况在我第一次跑源码时几乎必然出现。原因不是代码逻辑,而是资源路径的“当前目录”不是你以为的那个目录。Visual Studio 默认把工作目录设为.vcxproj所在目录,而不是exe所在目录,所以当代码用"assets/tiles.png"相对路径时,它会从工程目录找,自然找不到。

解决方式有两种:在 IDE 里把“调试 > 工作目录”改成$(ProjectDir),让路径基准回到项目根目录;或者把assets复制到输出目录。前者适合开发调试,后者适合分发。从那以后我对每个源码包,都是先看一眼资源路径假设,再决定怎么启动。

4.2 现象:跳跃高度抖得离谱,帧率越高跳得越高

这个坑典型到值得单独写一节。源码里的角色跳跃大多是这样实现:按下跳跃键时给vy一个负初速度,然后每帧vy += gravity; pos += vy;。如果gravity和vy都没有乘以时间增量dt,那么 60 FPS 和 120 FPS 下角色每秒的位移差接近一倍。

原因就是逻辑更新没有做帧率归一化。解决的办法有二:一是给所有物理量乘上dt / 16.666f的缩放因子,让增量与时间挂钩;二是采用固定时间步,把逻辑更新次数锁死在一个恒定频率,即使渲染帧率波动,物理也不会漂移。我建议改这份源码时选择第二种,因为它改动小,只需要在update(dt)里把dt固定为 16.666,且不要让SDL_Delay失效。

4.3 现象:角色卡在墙角,或者跳起顶砖后“粘”在砖块下缘

这个现象比碰撞穿墙更隐蔽。原因是碰撞响应没有分轴处理,X 和 Y 的位移同时计算后,矩形同时与左右墙面和下方砖块重叠,代码做位置回退时不知道该优先满足哪个方向,导致角色被推到一个错误的位置。

解决方法是把移动拆成两步:先沿 X 轴移动并检测,记录碰撞,修正x;再沿 Y 轴移动并检测,修正y。这样角色走到墙边时,垂直方向上能正常受重力下落,水平方向被墙挡住,不会出现“贴墙卡死”。源码里如果已经有这个逻辑,请忽略此处;如果还没有,这就是动手优化最值得下手的位置。本质上,它也是区分代码里“能用”和“好用”的一条分界线。

4.4 现象:敌人走到平台边缘就“抖动”,像在反复犹豫

看敌人逻辑会发现,它们的巡逻通常靠一个dir标志位控制方向,碰到实体障碍或平台边缘就翻转方向。翻转后,下一帧又检测到同样的边缘条件,于是再次翻转,结果就是敌人卡在边缘处左右反复横跳。

根因是“翻转条件”和“翻转后的状态”没有隔离。常见做法是加一个方向冷却计时器:只有与上一帧方向不同的情况下才允许翻转,或者检测到边缘时先让敌人停顿几帧再转向。我给源码写补丁时用的就是“边缘硬延迟”,在敌人状态里加turnCooldown,翻转时置为 30,每帧减 1,为 0 时才允许下一次翻转。效果立刻从“抖动”变成“正常巡逻到边缘,停顿,回头”。

4.5 现象:编译时提示 fatal error: SDL.h:No such file or directory

头文件找不到,几乎都是包含路径的问题。你装了 SDL2,但编译器根本没被告诉“去哪里找头文件”。Visual Studio 下手动配置步数为:项目属性 → VC++ 目录 → 包含目录,追加 SDL2 的include路径;库目录同样追加lib/x86或lib/x64。工具链差异是新手最容易放弃的地方,也是我反复强调用 CMake 或 vcpkg 的原因。

这里给个实用检查套路:先看工程里有没有CMakeLists.txt,有就优先用 CMake;没有再看是.sln还是.vcxproj,双斜杠路径检查。直接留一个#include <SDL.h>单独编译,确认头文件能找到,再回去编译整体工程,能省下大量翻车时间。

5. 地图与关卡改造:读瓦片数组,改出你自己的关卡

5.1 地图存储:二维数组与瓦片索引

源码的关卡地图是一块二维数组常量,几乎每个版本都有个map_data.h文件,里面定义类似这样的数据:

// 0=空白 1=实心地面 2=砖块 3=问号砖 4=水管 5=敌人出生点 const short levelMap[13][15] = { {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, {0, 0, 0, 3, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 0, 0, 0, 0, 2, 4, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1}, {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, {0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, {1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1} };

瓦片索引的数字对应tiles.png中指定位置的素材块。渲染时按行、列遍历数组,根据数字去图集里切图即可。碰撞检测时则判断数字是否属于“实心瓦片集合”,例如把1、2、4视为不可穿透,0、3、5视为可穿透。这个设计很朴素,却完全够用。

改地图时有一个基本边界约束:行数和列数必须与源码里的常量定义一致。比如定义是13×15,你改成12×15就会越界。常见的灵活做法是同时在level.h里定义LEVEL_ROWS和LEVEL_COLS两个宏,地图数组的维度引用它们,这样以后改尺寸只需动宏定义。

5.2 自定义一关:从替换数组到关卡尺寸调整

如果你只是想做一个测试关卡,直接把levelMap里的数字换掉即可。比如把第五行的5换成2,那个位置就会多出一块砖;把本来铺好的 1 改成 0,角色就会掉到虚空。这些改动都是编译后立刻直观可见的,非常适合边改边编译验证。

当你需要一张更大的地图,涉及的是增加数组行数和更新LEVEL_ROWS常量,同时需要确认render里遍历地图的循环用的是这两个常量。如果某个循环写死了 13,那超出部分的瓦片就永远不会被绘制。我遇到过一位同事改地图后发现后半场景黑屏,排查两小时就是卡在这个写死的数字上。

此外,出生点的处理也值得关注。地图里的5往往并不直接作为瓦片渲染,而是在关卡加载时被扫描到,然后在对应坐标生成一个敌人对象。这就意味着,把5放在悬崖边上会让敌人生成后立刻掉下悬崖。所以设计关卡时要刻意给敌对区域留出站脚空间。这是关卡编辑里最容易忽略的“隐形逻辑”。

5.3 敌人难度与物理参数调参

很多下载者拿到源码后觉得“跳跃太重”或“敌人太快”,第一反应是去代码里找“速度”数字。这个方向没错,但要找对文件。重力、跳跃初速度、移动速度多数定义在player.cpp的常量区,也可能是config.h里的宏,模样类似:

const float kGravity = 0.35f; const float kJumpSpeed = -7.0f; const float kMoveSpeed = 2.2f; const float kEnemySpeed = 1.0f;

这几个参数的物理含义值得展开:kGravity是每帧给vy增加的下落加速度,值越大角色下落越快,跳跃弧线也越陡;kJumpSpeed是起跳瞬间的竖直初速度,负值代表向上,绝对值越大跳得越高;kMoveSpeed是水平移动速度,影响角色横向响应速度。调参时保持“重力加速度”和“跳跃初速度”的比例关系很重要:两者同时放大 1.2 倍,跳跃高度会显著变化,因为高度与速度的平方成正比,与重力成反比。

// 跳跃高度约等于 (kJumpSpeed^2) / (2 * kGravity) // 原参数跳跃高度约为 70 像素 // 想跳得更高,可以只把 kJumpSpeed 改为 -8.5f,同时小幅微调 kGravity const float kGravity = 0.32f; // 稍微减小,滞空时间更长 const float kJumpSpeed = -8.5f; // 初始速度更大

敌人速度没必要全局修改,更好的办法是给不同的敌人类型定义独立速度。源码里如果Enemy类只有一个无参构造,可以自己加一个带速度参数的版本,然后在5出生点扫描时按坐标决定敌人类型,难度梯度立刻就有了。这种小改动不破坏原逻辑,又能显著提升游戏可玩性。

6. 进阶补充:给角色加一个“滑铲”状态,并建立一个快速回归验证习惯

当你想让这份源码的游玩体验更像新世代的平台跳跃游戏,最值得先加的是角色状态扩展。原版超级玛丽只有站立、跑动、跳跃三种状态,我基于源码的PlayerState枚举加了一个“滑铲”动作:玩家在跑动中按下蹲键时进入滑铲状态,持续约 300ms,期间角色碰撞盒高度减半,水平速度变为原本的 1.5 倍,同时处于短暂的无敌帧。

enum class PlayerState { Idle, Run, Jump, Slide }; void Player::update(float dt) { if (state == PlayerState::Slide) { slideTimer += dt; if (slideTimer > 300.0f) { state = PlayerState::Run; hitbox.h = kNormalHeight; } } // 原有物理逻辑省略,碰撞盒高度在 Slide 状态下改为一半 }

这段代码的关键是把状态变更和物理参数解耦。滑铲期间不改重力系数,只在碰撞盒高度和水平速度上做临时调整,结束后恢复原值,手感就不会发飘。修改这类源码时,最怕的不是改错逻辑,而是改错了无法立刻知道自己改坏了什么。我的习惯是给源码包先建一个本地 git 仓库,让原始版本形成一个提交点,每改一个功能就单独跑一张固定的小地图做回归验证:能正常起跳、能顶砖、能踩怪死亡、能过关,这四条没问题才继续改下一个功能。

从那以后,我再也没遇到过“改了一晚上最后不知道哪里崩了”的窘境。先有基线,再谈改动,这份源码尤其适合拿来做这样的练习。希望这篇拆解能帮你在复现和改造它的路上少踩一些坑。

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

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

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

立即咨询