资讯动态

C++ SDL2超级玛丽源码拆解:2D游戏物理、碰撞检测与敌人AI实现

发布时间:2026/10/20 13:00:19 来源:尧图企业网站定制
简介经典任天堂平台跳跃游戏《超级玛丽》的C源码工程包面向游戏开发初学者与进阶者用于学习2D游戏架构设计与C编程实践。资源压缩包为RAR格式共49个文件大小约1.48MB包含9个头文件、6个C源文件及6个BMP位图资源另有2个可直接运行的EXE程序与若干工程配置文件如.dsp/.dsw/.rc等完整覆盖游戏主循环、角色类、地图关卡、物理引擎及音效图形等经典模块。截至目前已有3384人学习下载。通过学习该源码可掌握游戏主循环的构建方法、马里奥跳跃与碰撞检测的物理模拟、关卡文件的读写流程以及基于MFC的图形绘制与消息处理技巧。同时工程内附“超级玛丽可执行文件”方便在Visual C环境下直接运行调试边看效果边读代码对理解对象封装、内存管理与经典游戏算法均有直观帮助。1. 一个C写成的超级玛丽源码值得为这份手感投入一个下午如果只看截图里像素颗粒的粗糙程度这份超级玛丽C源码并不会让人眼前一亮但真正下载下来编译跑起来跳第一下就会明白它追求的是街机那种“肩膀下沉、落地有惯性、踩怪会反弹”的原始手感。整套工程基于C和一个跨平台多媒体库编写地图关卡用文本矩阵描述敌人AI跑在状态机上玩家控制器里写的是完整的重力与碰撞系统。如果你有C基础想研究2D游戏程序设计的完整链路或者需要一份能改成课程设计的完整工程这份超级玛丽源码值得花一个下午逐行读一遍也值得当作二次开发的起点。2. 项目骨架与技术选型SDL2渲染、实体组件设计与地图数据驱动拿到源码包的第一个动作我建议不是打开编辑器漫无目的地翻而是在终端里把目录结构整体扫一遍。这套工程的目录划分很干净模块边界一目了然属于那种“不算大、但五脏俱全”的2D游戏模板。下面先拆目录再讲技术选型最后看地图是怎么存成文本的。2.1 源码目录拆解从入口到资源文件的完整路径典型的工程结构长这样mario_cpp/ ├── CMakeLists.txt ├── assets/ │ ├── textures/ # 马里奥、板栗怪、砖块、管道、金币精灵图 │ ├── levels/ # 关卡文本文件 1-1.txt、1-2.txt │ └── sounds/ # 跳跃、顶砖、金币、死亡音效 ├── src/ │ ├── main.cpp # 入口与主循环 │ ├── Game.h / Game.cpp # 游戏状态切换与实体管理 │ ├── Player.h / Player.cpp │ ├── Enemy.h / Enemy.cpp │ ├── TileMap.h / TileMap.cpp │ ├── Level.h / Level.cpp │ ├── CollisionManager.h / CollisionManager.cpp │ └── Camera.h / Camera.cpp └── include/ # 公共头文件src下按“实体”划分文件而不是按“功能”划分这是2D动作游戏里比较顺手的一种组织方式。main.cpp只管主循环和窗口生命周期Game类持有玩家、敌人、关卡和碰撞管理器作为全局调度者Player和Enemy各自封装移动、状态和渲染逻辑CollisionManager单独抽出来而不是写在Player内部是为了后续做子弹、道具碰撞时不用重复写包围盒判断。assets目录和src目录同级这点很关键。很多C小游戏会把资源放在build目录里一换编译器路径就全断这里把资源独立出来配合CMake里设置的工作目录能省掉后续大量路径排查。第一次编译跑不起来十有八九不是代码问题而是资源路径没对齐。2.2 技术选型对比SDL2、SFML与Unity引擎的取舍为什么这份源码不用现成的游戏引擎而选择自己用渲染库把马里奥“画”出来我拆解时觉得核心原因是学习价值。用引擎做马里奥拖个组件就能跑但玩家控制器、状态切换、碰撞响应这些逻辑全被引擎藏起来了读代码的人学不到本质。方案上手曲线渲染路径适合场景SDL2偏中自己控制表面与纹理2D像素复刻、底层渲染学习轻封装2D图形库偏低封装好精灵与窗口快速做小游戏Demo商业级引擎偏高完整渲染与编辑器团队协作与3D大项目这套源码走的明显是第一条路线渲染层只用了跨平台多媒体库的窗口、纹理和音频能力游戏逻辑完全手写。这样做的好处是代码里每一处都是“显式”的——你为什么按这个速度移动、碰撞后为什么回退这么多像素一行行都能对上坏处是工作量更大但作为源码赏析来说这反而是优点。我比较认同这种选择。对于想搞懂2D横版游戏实现原理的人SDL2加上手写物理比在引擎里拖几个组件更容易建立完整的知识链路。而且SDL2在Windows、Linux、macOS下都能跑跨平台成本低这也让这份源码的可复现性高了不少。2.3 关卡是怎么存下来的用文本矩阵描述瓷砖地图打开assets/levels/1-1.txt你会看到类似这样一堆数字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 3 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 3 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 3 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0这种设计在2D像素游戏里非常常见。每个数字代表一种瓷砖类型0是空白1是普通地面2是脆砖块3是问号砖里面有金币或蘑菇4是管道。关卡加载时TileMap读取这个文本矩阵把每一格换算成屏幕坐标生成对应的矩形包围盒供碰撞检测使用。这个做法的好处是关卡和代码完全解耦。想加自定义关卡不用改一行C代码往levels里放一个新的txt文件再在Game里注册一下关卡名就行。我改造这项目时最先动刀的就是这里——新增一张测试地图的成本极低非常适合用来验证物理参数改动的效果。3. 核心模块拆解一个游戏帧内马里奥的移动、碰撞与判定如何协作很多初学者读游戏源码上来先找“马里奥在哪个函数里跑”容易被主循环和渲染代码绕晕。我的建议是反过来读先看一帧里系统做了什么事再看玩家控制器怎么改变位置最后看碰撞器怎么约束位置。这一章就按这个顺序拆。3.1 主循环与固定时间步长为什么不用SDL_Delay直接限帧main.cpp里的主循环大致长这样while (game.isRunning()) { Uint32 frameStart SDL_GetTicks(); // 1. 处理窗口事件与键盘输入 while (SDL_PollEvent(event)) { if (event.type SDL_QUIT) game.quit(); } // 2. 固定时间步长逻辑更新永远按 60FPS 的节奏走 float dt 1.0f / 60.0f; game.handleInput(); game.update(dt); // 物理、AI、碰撞都在这一个调用里完成 // 3. 渲染 game.render(); // 4. 控制帧率让出 CPU Uint32 elapsed SDL_GetTicks() - frameStart; if (elapsed 16) { SDL_Delay(16 - elapsed); } }逻辑说明这里最关键的是把逻辑更新和渲染解耦。update永远按1/60秒的固定步长调用不管实际屏幕刷新率是多少、机器卡不卡而SDL_Delay只负责“这帧跑太快就稍微歇一会儿”它不决定游戏速度。参数说明dt固定为1/60而不是用实际耗时能避免一个经典问题——当帧率波动时马里奥的移动速度随着帧率先快后慢手感飘忽。如果你把SDL_Delay(16)当成限帧手段在渲染耗时超过16毫秒的机器上游戏会整体变慢而固定步长方案下即使某一帧渲染卡了逻辑依然按真实时间推进不会积累误差。我在拆这个工程时第一件事就是把dt打印出来看确认它没有用到SDL_GetTicks算出来的差值。看到是固定值才放心往下读。3.2 玩家移动模型加速度、摩擦、跳跃与“手感”的来源马里奥的手感不是玄学就是一组经过调校的数字。Player::update里能看到完整的运动模型void Player::update(float dt, const uint8_t* keys) { // 水平方向读取左右键决定目标加速度 int dir 0; if (keys[SDL_SCANCODE_LEFT]) dir - 1; if (keys[SDL_SCANCODE_RIGHT]) dir 1; if (dir ! 0) { // 有输入向目标方向加速 vel.x dir * walkAccel * dt; // walkAccel 300.0f facing dir; } else { // 无输入地面摩擦让速度指数衰减 vel.x * groundFriction; // groundFriction 0.85f } // 限制最大水平速度 if (vel.x maxSpeed) vel.x maxSpeed; // maxSpeed 160.0f if (vel.x -maxSpeed) vel.x -maxSpeed; // 垂直方向重力与最大下落速度 vel.y gravity * dt; // gravity 900.0f if (vel.y maxFallSpeed) vel.y maxFallSpeed; // maxFallSpeed 400.0f // 移动并分轴处理碰撞 pos.x vel.x * dt; resolveCollisionX(); pos.y vel.y * dt; resolveCollisionY(); // 跳跃只有在地面时才允许起跳 if (jumpPressed onGround) { vel.y -jumpVelocity; // jumpVelocity 400.0f onGround false; } }逻辑说明这套模型的关键在于“加速度”和“摩擦”的配合。马里奥不是一按方向键就立刻达到最大速度而是像真实物体一样逐渐加速松开按键后也不是立刻停下而是靠摩擦系数让速度指数衰减。正是这种延迟感构成了经典马里奥“厚重”手感的主体。垂直方向则简单直接重力恒定下落有最大速度限制跳跃初速度设定为400保证跳一块砖的高度大概在3到4格左右。参数说明walkAccel设为300是从零加速到160像素/秒大约需要0.53秒这个数值在60FPS下体感响应敏捷但不失重。groundFriction设为0.85意味着松键后大约4帧速度掉一半快速点按能做出非常短促的“点走”动作。跳跃初速度400和重力900配合跳跃滞空时间约0.9秒刚好能跳过两个格子的宽度。想调难度优先动这几个常量就够。3.3 AABB碰撞检测与瓷砖网格贴墙滑动、顶砖、踩怪的实现碰撞检测用的不是像素级精度而是轴对齐包围盒AABB这也是2D横版游戏最常用的方案。核心判断只有四行bool AABBvsAABB(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; }但检测出“撞了”只是第一步真正决定手感的是“撞了之后怎么处理”。这份源码用的是分轴分离策略void Player::resolveCollisionX() { SDL_Rect playerBox getBox(); for (const auto tile : level-getSolidTiles()) { if (AABBvsAABB(playerBox, tile.rect)) { // 从左侧撞墙把玩家右边缘推到砖块左边缘 if (vel.x 0) { pos.x tile.rect.x - width; } // 从右侧撞墙把玩家左边缘推到砖块右边缘 else if (vel.x 0) { pos.x tile.rect.x tile.rect.w; } vel.x 0; // 撞墙后横向速度立刻清零 playerBox getBox(); // 重新计算包围盒避免一次循环内重复碰撞 } } }逻辑说明为什么先处理X轴再处理Y轴如果同时处理两个轴马里奥斜着撞向墙角时回退距离会被两个方向的约束重复计算导致角色被“弹”出去或者陷进地里。先X后Y每处理完一个轴就重新计算包围盒能保证角色始终贴合墙面滑动这也是“贴着墙往上跳”能流畅实现的原因。参数说明getBox返回的SDL_Rect是玩家当前的位置和宽高width保存的是角色精灵的实际像素宽度。注意回退时用tile.rect.x - width而不是tile.rect.x 1之类的近似值否则帧率低时会看到角色陷进墙半个身位。顶砖和踩怪的检测逻辑类似不过触发点不同顶砖检测的是“上升时头部碰到可击碎砖块”踩怪检测的是“下落时脚底碰到敌人且速度向下”。这两个分支都从AABB判断出发加上速度和碰撞双方类型的判断组合成完整的交互矩阵。3.4 敌人AI设计板栗怪的巡逻转向与龟壳状态机敌人AI没有用复杂的寻路算法每个敌人都是一台简单状态机。板栗怪的update函数非常直白void Goomba::update(float dt) { // 沿当前方向匀速移动 vel.x dir * speed; // speed 40.0f vel.y gravity * dt; pos.x vel.x * dt; // 前方是砖块或悬崖掉头 if (isBlockAhead(dir) || isCliffAhead(dir)) { dir -dir; } pos.y vel.y * dt; resolveTileCollision(); // 被踩扁后的短暂状态 if (stomped) { stompTimer - dt; if (stompTimer 0.0f) setDead(); } }逻辑说明isBlockAhead检查前方瓷砖是否不可通行isCliffAhead检查前方地面是否悬空——经典马里奥里板栗怪走到悬崖边会掉下去但不会主动回头这份源码加了悬崖检测后敌人会乖乖在平台范围内巡逻关卡设计时控制敌人活动的载体更顺手。speed设为40像素/秒明显比玩家慢保证玩家能轻松追上而不是被堵死。龟壳类敌人则多一个“被踢飞”状态当马里奥从侧面撞它时它进入高速移动状态速度从40激增到240像素/秒撞到墙或另一个敌人时反弹或消失。这个状态转换拆成枚举来管理扩展新行为时只需要加一个枚举值和一个分支属于很典型的状态机写法。这套敌人设计虽然简单但配合不同速度、巡逻范围和触发器能组合出丰富的关卡障碍。读懂了这里后面给敌人加跳跃、加远程攻击都是顺水推舟的事。4. 编译运行与避坑排查从拿到源码到窗口弹出的血泪经验这一章写编译和运行。我拆这份源码时前前后后踩过三个坑每一个都花了大半个小时排查。把它们记下来你跑的时候能少走不少弯路。4.1 环境准备CMake SDL2 在不同系统下的正确配置方式工程用CMake组织依赖三个跨平台多媒体库的组件窗口与渲染、图像加载、音频播放。CMakeLists.txt核心部分如下cmake_minimum_required(VERSION 3.16) project(mario CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(SDL2 REQUIRED) find_package(SDL2_image REQUIRED) find_package(SDL2_mixer REQUIRED) add_executable(mario src/main.cpp src/Game.cpp src/Player.cpp src/Enemy.cpp src/Level.cpp src/TileMap.cpp src/CollisionManager.cpp src/Camera.cpp ) target_include_directories(mario PRIVATE include) target_link_libraries(mario PRIVATE SDL2::SDL2 SDL2::SDL2_image SDL2::SDL2_mixer )在Linux下安装依赖后直接编译sudo apt install libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev cmake -S . -B build cmake --build build -j4 ./build/mario逻辑说明find_package会去系统路径下找SDL2的CMake配置找到后通过target_link_libraries把库链接进可执行文件。Windows下如果找不到通常是SDL2开发包路径没加入CMAKE_PREFIX_PATH或者下载的开发库版本与MinGW/MSVC不匹配。参数说明-S指定源码目录-B指定构建目录所有编译产物都生成在build里不会污染源码目录。这是CMake官方推荐的做法比在src下直接生成中间文件干净得多。提示如果运行时窗口闪退优先确认SDL2_image和SDL2_mixer是否正常初始化。main.cpp里通常会有判断分支并打印错误信息把打印信息打开比瞎猜省时间。4.2 避坑1画面一卡一卡而且马里奥速度忽快忽慢现象窗口能弹出但是走路像在泥里方向键一按一顿节奏完全不对。原因有人会把SDL_Delay(16)当作“限帧”手段然后在update里用SDL_GetTicks计算真实耗时当dt。帧率稳定时还好但系统和窗口管理器稍一干扰真实耗时就在8到25毫秒之间跳物理系统跟着忽快忽慢。更隐蔽的是在64位系统上如果dt单位是“毫秒”而不是“秒”马里奥的移动速度会直接放大几十倍。解决统一使用固定时间步长dt固定为1/60更新逻辑不依赖实际帧耗时。想限制帧率就在帧尾做一次SDL_Delay但别用它参与任何物理计算。改完之后把鼠标快速甩动窗口速度应该完全不变才算合格。4.3 避坑2马里奥朝墙跑会卡进砖里然后整个人疯狂抖动现象角色贴近墙边时半截身子陷进砖块画面像贴图抽搐一样抖动松开方向键才恢复。原因碰撞检测用“先整段移动、再整体回退”的策略X和Y轴同时做AABB分离。斜向撞墙角时X轴回退把角色推向右Y轴回退又把它推向上一帧的位置两个约束互相打架每帧都在推挤就形成了抖动。解决改成“分轴移动、分轴碰撞、分轴回退”——先移动X再检测X速度清零后立即重新计算包围盒然后移动Y再检测Y。每个轴只处理自己的位移回退距离永远从当前包围盒推导不做跨轴干预。这份源码在resolveCollisionX和resolveCollisionY里就是分开写的如果你拿到的版本还在一起拆成两个函数就好。4.4 避坑3窗口弹出来了但满屏紫块音乐和贴图全部加载失败现象游戏能运行但整个画面是紫色的默认纹理控制台刷出一串资源加载失败日志。原因资源路径写的是相对路径“assets/textures/xxx.png”可很多IDE和终端启动程序时工作目录并不在工程根目录。比如在Visual Studio里直接运行默认工作目录可能是项目目录而不是源码根目录从build目录启动可执行文件时更是直接找不到assets。另一个常见原因是某些平台对中文空格多的路径处理不友好SDL_image的加载会静默失败。解决写一个跨平台小函数基于可执行文件所在路径拼出assets目录的绝对路径启动时把工作目录切换到可执行文件同级或上一级。我一般做法是在main开头先调用一个resolveBasePath函数用SDL_GetBasePath()拿到exe所在目录再往上一级找源码assets目录这样无论从哪个目录启动都能找到资源。中文路径问题更简单——工程目录别带中文和空格一了百了。5. 二次开发实战自定义道具、新敌人与新关卡怎么加读懂了核心模块这份源码就是一套积木。最后这一章讲两个具体的改造思路一个改道具一个改敌人行为都是最常见的扩展方向。5.1 新增“火焰花”道具从枚举到效果实现火焰花的常见实现方式是“吃了之后马里奥获得发射火球的能力”。要改的点有四个一是在关卡纹理映射表里给火焰花分配一个新的瓷砖编号二是在Player状态枚举里加一个FIRE状态三是给Player加一个发射功能在update里按攻击键生成飞行物实体四是碰撞器里让飞行物对敌人生效。enum PlayerState { SMALL, BIG, FIRE, DEAD }; void Player::applyPower(int powerType) { if (powerType PowerType::FLOWER) { state FIRE; } } void Player::tryShoot(float dt) { if (state FIRE shootPressed shootCooldown 0.0f) { spawnProjectile(facing); // facing 决定火球飞向哪边 shootCooldown 0.4f; // 两次射击间隔防止连发 } }逻辑说明applyPower很简单难点在spawnProjectile生成的实体怎么管理。常见做法是Game类维护一个实体列表新火球加入列表每帧跟所有敌人做一次AABB检测命中就把敌人标记为dead火球同时消失。参数说明shootCooldown设为0.4秒在60FPS下是24帧按住攻击键时既不会完全锁住也不会显得像机关枪。火球初速度我一般给到180像素/秒比马里奥跑速略快保证它能追上敌人而不是被甩在后面。5.2 给板栗怪加一套跳跃行为并验证手感的功夫默认板栗怪只会左右巡逻。让它具备“跳跃能力”其实是在状态机里加一个JUMP状态当玩家在水平方向一定距离内出现时敌人前摇之后向上跳一下跳到高点再自动回到巡逻状态。我改造时会先在敌人状态枚举里加一项再在update里增加一段条件判断逻辑最后调一个物理参数跳跃高度由起跳初速度控制以400像素/秒起跳能跳约3格高想让它正好跳过普通砖块高度就压低到320。验证时我习惯做一个对照表原版跳跃高度、改了之后的高度各打一个存档点反复跑第一关第一次跳跃的位置感觉不对劲立刻回滚参数。从那以后我每次动这套源码第一件事就是把原版的摩擦系数、跳跃高度、怪物速度记成一组基线数据再开始改代码。改完先跑原始关卡验证手感没被破坏才去测新功能等确认不是玄学后才算完成。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑