资讯动态

C++超级玛丽源码解析:SDL2游戏循环、碰撞检测与关卡改造

发布时间:2026/10/20 12:59:58 来源:尧图企业网站定制
简介一份经典超级玛丽超级马里奥的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 方案 AVisual 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 方案 BLinux 下用 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.hNo 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 仓库让原始版本形成一个提交点每改一个功能就单独跑一张固定的小地图做回归验证能正常起跳、能顶砖、能踩怪死亡、能过关这四条没问题才继续改下一个功能。从那以后我再也没遇到过“改了一晚上最后不知道哪里崩了”的窘境。先有基线再谈改动这份源码尤其适合拿来做这样的练习。希望这篇拆解能帮你在复现和改造它的路上少踩一些坑。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑