ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用C++和SDL2复刻超级玛丽:从框架到资源加载的完整实践

用C++和SDL2复刻超级玛丽:从框架到资源加载的完整实践 简介基于C的超级玛丽游戏源码包包含完整的图片与背景音乐是面向C初学者和游戏开发入门者的经典练手项目。压缩包共33个文件大小约7.4MB涵盖14个mp3格式的音乐音效、6个bmp格式的图片素材以及C源码、工程配置和Visual Studio解决方案文件解压后即可编译运行。目前已有734人学习下载适合用于理解游戏主循环、碰撞检测、对象状态切换、音频播放等基础知识。源码采用面向对象思想编写将角色、子弹、敌人、金币、关卡等抽象为独立模块结构清晰、便于扩展同时完整实现了跳跃、踩敌、吃金币、通关等玩法。通过研究这份代码能够学习到游戏资源加载与管理的具体方法也可以作为课程设计或毕业设计的参考基础。1. 超级玛丽用 C 复刻为什么多年后还值得自己动手写一遍先说结论标题里最值钱的不在“超级玛丽”而在后半句。游戏逻辑写起来不难真正让新手卡住的是图片素材怎么加载、背景音乐怎么循环、资源文件夹怎么组织。我用 C 从零写过一个可玩的版本也翻过网上流传的同类源码发现掉进同一个坑的代码不在少数界面起来了玛丽不动图片动了没有声音声音响了关卡过不去。这篇文章把从框架到资源加载的落地路径完整讲一遍会用 SDL2 做底层库代码按 C11 标准写能学习能改。适合已经读懂基本语法、想用一个小项目把指针、结构体、事件循环揉在一起练手的人也适合拿到源码却编译不过、看不懂资源加载为何失败的读者。2. 先立框架再谈玩法主循环、地图与实体的基本设计很多人拿到一份游戏源码第一反应是找 main 函数然后顺着往下读。这个习惯对于几百行的控制台程序没问题但放到超级玛丽这种带渲染、输入、音频、碰撞的项目里多半会读成黑匣子。我一般会先看三个东西主循环在哪、地图数据用什么结构、游戏对象怎么表示。把这三件事理清源码的骨架就出来了。2.1 选型为什么用 SDL2 而不是 EasyX 或控制台如果你是在 vscode 配置 c/c 环境时顺手搜到这类源码大概率会遇到三种库控制台 API、EasyX、SDL2。控制台画字符表演示还行做不了真正意义上的图片渲染EasyX 简单但 Windows only而且对音频支持约等于零SDL2 跨平台、自带音频子系统、配合 SDL_image 和 SDL_mixer 能直接吃下图片和背景音乐是社区里复刻 2D 平台游戏最常见的选择。用 SDL2 还有一个现实原因网上的超级玛丽源码十个里有八个基于它。你学会读这一家的工程结构再去看别的项目迁移成本很低。唯一要忍受的是环境配置比 EasyX 麻烦一点需要在链接器里加上 SDL2、SDL2_image、SDL2_mixer 的库路径和 dll。这个坑后面避坑章节会专门说。2.2 主循环的最小骨架先跑起来再谈画面不管游戏多复杂核心都是“处理输入—更新逻辑—渲染画面”这个循环。下面是去掉所有业务后最小可运行骨架#include SDL.h const int SCREEN_WIDTH 800; const int SCREEN_HEIGHT 600; int main(int argc, char* argv[]) { if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO) 0) { SDL_Log(SDL_Init failed: %s, SDL_GetError()); return -1; } SDL_Window* window SDL_CreateWindow( C Super Mario, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, SCREEN_WIDTH, SCREEN_HEIGHT, SDL_WINDOW_SHOWN); SDL_Renderer* renderer SDL_CreateRenderer( window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC); bool running true; SDL_Event event; while (running) { while (SDL_PollEvent(event)) { if (event.type SDL_QUIT) { running false; } } update(); // 更新游戏逻辑位移、动画、碰撞判断 render(); // 绘制背景、地图、人物、HUD SDL_Delay(16); // 近似 60 FPS } SDL_DestroyRenderer(renderer); SDL_DestroyWindow(window); SDL_Quit(); return 0; }SDL_Init的参数是位掩码这里同时初始化视频和音频子系统如果只写SDL_INIT_VIDEO后面 SDL_mixer 初始化时会非常迷惑。SDL_CreateRenderer里我加了SDL_RENDERER_PRESENTVSYNC让垂直同步去限制帧率此时SDL_Delay(16)是兜底防止某些环境换 vsync 失效后空转。两件事做同一件事看起来冗余实际是防止有一台机器不按常理出牌。update()和render()是分隔开的这是整个工程最重要的一条纪律。以后你想加血条、加敌人、加音效都往这两个函数里塞但不要混在一起否则改着改着就分不清哪个变量是“逻辑值”哪个是“绘制值”了。2.3 地图数据用字符串数组代替 Tile Map 编辑器超级玛丽的地图本质上是一张瓷砖表每一块是 32×32 或 48×48 的方块。很多源码会用一个 JSON 文件去描述地图但对学习项目来说字符串数组更直观想改一处直接敲字符不需要额外解析器。#include vector #include string const int TILE_SIZE 32; std::vectorstd::string mapData { ############################, #P...........?##..........#, #.........####............#, #..................M......#, #...$..................####, ############################ };字符约定可以自己定我的习惯是这样#是砖块P是玛丽出生点M是敌人?是问号砖$是金币。解析的时候按行、按列扫一遍for (int row 0; row (int)mapData.size(); row) { for (int col 0; col (int)mapData[row].size(); col) { char tile mapData[row][col]; if (tile #) { spawnWall(col * TILE_SIZE, row * TILE_SIZE); } else if (tile P) { playerStartX col * TILE_SIZE; playerStartY row * TILE_SIZE; } else if (tile M) { spawnEnemy(col * TILE_SIZE, row * TILE_SIZE); } } }col * TILE_SIZE是把格子坐标换算成像素坐标这一步是新手最容易漏的。有人直接在数组下标上做碰撞然后渲染时又乘 32两边对不上人物就像踩在空气上。解析时统一换算成像素之后物理计算、渲染、碰撞都用同一套单位不要混用。字符串数组的另一个好处是可以直接放进源码不用处理外部文件缺失的问题等你把逻辑跑通了再改成读外部关卡文件也不迟。3. 让玛丽“活”起来图片渲染、动画帧与键盘响应框架立起来之后下一步是把图片变成“人物”。这一步有两类素材一是单张角色图二是把奔跑、跳跃、站立都放在同一张图集里的精灵表。大多数超级玛丽项目用后者因为动画切换本质上是“裁剪同一张大图里的不同区域”。3.1 加载图片PNG 透明通道与纹理化直接上 SDL 的SDL_LoadBMP加载位图会遇到一个恶心的问题位图没有 alpha 通道背景色会变成一个矩形贴在屏幕上。解决方法是加载后用SDL_SetColorKey指定一种颜色为透明色。但现在的主流做法是直接使用 PNG交给 SDL_image 处理。#include SDL_image.h SDL_Texture* loadTexture(SDL_Renderer* renderer, const char* path) { SDL_Surface* surface IMG_Load(path); if (!surface) { SDL_Log(IMG_Load failed: %s, IMG_GetError()); return nullptr; } SDL_Texture* texture SDL_CreateTextureFromSurface(renderer, surface); SDL_FreeSurface(surface); // 纹理已经上传 GPUsurface 可以释放 return texture; }// 使用示例 SDL_Texture* marioTex loadTexture(renderer, images/mario.png); if (!marioTex) { // 在这里退出或走兜底逻辑不要带着空指针往下跑 }IMG_Load返回的SDL_Surface是一块内存像素数据SDL_CreateTextureFromSurface才把它真正变成渲染器能高效使用的纹理。用完立刻释放 surface这个习惯能让你在一张张加载图集时少占几十 MB 内存。3.2 动画切换计数器驱动的精灵裁剪假设 mario.png 是一张 96×32 的图集里面从左到右是站立、走路 1、走路 2 三帧每帧 32×32。渲染时用源矩形srcRect去裁剪图集用目标矩形dstRect决定画在屏幕哪里const int FRAME_INTERVAL 6; // 每 6 帧切换一次60 FPS 下约每秒 10 帧 int frameCounter 0; int marioFrame 0; void updateAnimation() { frameCounter; if (frameCounter FRAME_INTERVAL) { frameCounter 0; marioFrame (marioFrame 1) % 3; } } void renderMario(SDL_Renderer* renderer, SDL_Texture* marioTex, int marioX, int marioY) { SDL_Rect srcRect { marioFrame * 32, 0, 32, 32 }; SDL_Rect dstRect { marioX, marioY, 32, 32 }; SDL_RenderCopy(renderer, marioTex, srcRect, dstRect); }FRAME_INTERVAL控制动画速度调大到 12 就是慢放调小到 2 就是快放。注意这里不是按真实时间而是按帧数计算的如果哪一天你引入了动态帧率这套就失效了到时可以改用SDL_GetTicks()做时间驱动。SDL_RenderCopy的第三个参数传srcRect第四个传dstRect两个矩形语义完全不同一个是“取原图哪里”一个是“画到屏幕哪里”写反了画面会直接花掉。3.3 输入响应SDL_KEYDOWN 与 SDL_GetKeyboardState 的选择控制玛丽移动有两种常见写法。一种是接事件在SDL_PollEvent循环里判断SDL_KEYDOWN/SDL_KEYUP另一种是每帧直接查询键盘状态。平台游戏里角色需要持续按住方向键移动更适合后者。const Uint8* keys SDL_GetKeyboardState(nullptr); if (keys[SDL_SCANCODE_LEFT]) { mario.vx -MARIO_SPEED; mario.facingLeft true; } else if (keys[SDL_SCANCODE_RIGHT]) { mario.vx MARIO_SPEED; mario.facingLeft false; } else { mario.vx 0; } if (keys[SDL_SCANCODE_SPACE] mario.onGround) { mario.vy -JUMP_FORCE; mario.onGround false; }SDL_GetKeyboardState返回一个状态数组用 SDL_SCANCODE 系列常量索引keys[SDL_SCANCODE_SPACE]为 1 表示这一帧按键正被按下。“必须在地面才能跳”这个判断是硬编码进按键分支的没有它角色在空中按一下空格就无限连跳直接破坏游戏手感。跳跃力度的数值JUMP_FORCE和重力加速度要先粗调一轮跳太高关卡设计白做跳太低管子跳不过去。这个没有玄学只能一遍遍试。4. 背景音乐与音效资源加载、循环播放和释放顺序到了音乐这一环劝退率很高。图片只要路径对就能显示音频却要处理初始化、格式、循环、音量、释放五件事任何一环错了表现都是“没声音”。但反过来说音频也是最能提升“这东西像个游戏”的模块一份带背景音乐的源码和一份静音源码给人的完成度感受天差地别。4.1 SDL_mixer 初始化采样率、格式与缓冲区的选择SDL_mixer 是 SDL 的官方音频扩展库先Mix_OpenAudio再Mix_LoadMUS是一个固定流程。Mix_OpenAudio的四个参数分别是采样率、音频格式、声道数、缓冲块大小#include SDL_mixer.h if (Mix_OpenAudio(44100, MIX_DEFAULT_FORMAT, 2, 2048) 0) { SDL_Log(Mix_OpenAudio failed: %s, Mix_GetError()); return -1; } Mix_Music* bgm Mix_LoadMUS(audio/overworld.ogg); if (!bgm) { SDL_Log(Mix_LoadMUS failed: %s, Mix_GetError()); return -1; } if (Mix_PlayMusic(bgm, -1) -1) { SDL_Log(Mix_PlayMusic failed: %s, Mix_GetError()); }44100 是 CD 音质采样率对游戏音乐足够MIX_DEFAULT_FORMAT是 16 位有符号整型第二个参数 2 是双声道立体声2048 是缓冲区大小代表一次处理 2048 个采样帧调大到 4096 可以降低爆音概率但会增加播放延迟。如果你的代码没在SDL_Init里加SDL_INIT_AUDIO这里会直接报错日志永远指向音频设备排查半天发现是初始化顺序问题。4.2 音乐循环、音量与资源释放的正确姿势这里要注意Mix_LoadMUS和Mix_LoadWAV的区别。背景音乐用前者返回Mix_Music*短音效跳跃、吃金币用后者返回Mix_Chunk*。这两个类型不互通有源码把音效当背景音乐加载结果Mix_PlayMusic一直返回 -1。// 背景音乐无限循环 Mix_VolumeMusic(MIX_MAX_VOLUME / 2); Mix_PlayMusic(bgm, -1); // 音效单独放通道播放一次 Mix_Chunk* jumpSound Mix_LoadWAV(audio/jump.wav); if (jumpSound) { Mix_PlayChannel(-1, jumpSound, 0); } // 退出时的释放顺序先停音乐再释放音乐最后关音频 Mix_HaltMusic(); Mix_FreeMusic(bgm); if (jumpSound) Mix_FreeChunk(jumpSound); Mix_CloseAudio();Mix_PlayMusic的第二个参数是循环次数-1 表示无限循环0 表示只播一次。很多人写成 0然后纳闷音乐怎么放完就没了。音量范围是 0 到MIX_MAX_VOLUME128MIX_MAX_VOLUME / 2就是一半音量。音效的Mix_PlayChannel第一个参数传 -1让系统找一个空闲通道避免手动分配通道号导致两个音效互相覆盖。4.3 从网上拿到的 BGM 格式踩雷我自己遇到过一种情况下载的源码里配套音乐是 .mp3本地开发环境放得好好的打包给别人就静音。排查下来是 MP3 解码依赖 SDL_mixer 的特定插件分发时少带一个 dll 就无声。最省心的做法是把音乐统一转成 OGG 或 WAV 再入库OGG 体积小且解码库随 SDL_mixer 分发几乎没有额外的版权顾虑。另一个常见问题是音乐文件本身有很长的结尾空白听着像卡住实际是音轨没剪干净用音频工具裁到 23 秒内再进素材库。5. 碰撞检测、敌人 AI 与常见问题排查游戏源码里最容易让新人“翻车”的地方不在画面在碰撞。超级玛丽这类平台跳跃游戏所有手感都建立在“站得住、踩得准、跳得过去”这三个字上。很多源码能跑但玩起来穿墙、陷地、踩不中敌人问题都出在碰撞模型太粗糙。这一章我按自己做项目时踩过的坑来写。5.1 AABB 碰撞检测先懂原理再决定要不要用 SDL 自带的SDL 自带SDL_HasIntersection可以判断两个SDL_Rect是否相交。但直接用它做角色碰撞会有一个问题它只告诉你“碰了”不告诉你“从哪个方向碰的”。而平台游戏必须区分“头顶撞砖”和“脚底踩地”所以我会手写一个分割轴判定bool checkCollision(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 的左边是否在 b 右边左边”“a 的右边是否在 b 左边右边”等全部满足才算相交。要判断方向更实用的做法是分轴移动先水平移动再垂直移动每次移动后单独检测。也就是“x vx; 检测水平碰撞; y vy; 检测垂直碰撞”而不是“x vx; y vy; 最后检测一次”。后一种写法在角色速度快一点时会出现隧穿——上一帧还在砖块左边下一帧直接穿到右边中间没有相交帧。5.2 敌人 AI一个 Goomba 级敌人的最小逻辑游戏源码里最常见的敌人是左右巡逻型地面走到底就回头。它的行为看起来简单但有两个边界条件不处理就会穿模一是碰到墙壁要回头二是走到地面边缘要回头。struct Enemy { float x, y; float speed; int direction; // 1 或 -1 }; void updateEnemy(Enemy e) { int frontX (e.direction 1) ? e.x TILE_SIZE : e.x - 1; int bottomY e.y TILE_SIZE; // 前方没有地板了回头 if (tileAt(frontX, bottomY) ! #) { e.direction * -1; } // 前方是墙壁回头 if (tileAt(frontX, e.y) #) { e.direction * -1; } e.x e.direction * e.speed; }tileAt把像素坐标换算回格子坐标从地图二维数组里取出对应字符。注意“前方没有地板”的判断里用的是e.y TILE_SIZE也就是脚底那一行如果这一格不是#说明敌人已经走到悬崖边必须掉头。e.direction * -1是翻转方向用乘法比if分支更简洁。5.3 避坑记录现象、原因、解决1. 图片加载成功但显示成一坨色块或黑方块。原因是 PNG 的 alpha 通道丢失或者素材本身是 JPG 转存的伪 PNG。JPG 没有透明通道背景色会被一起加载进来。解决优先在美术阶段导出带透明通道的 PNG如果你拿到的素材只有 BMP用SDL_SetColorKey(surface, SDL_TRUE, SDL_MapRGB(surface-format, 255, 0, 255))把品红背景设为透明注意这种方案的边缘会有锯齿。2. 角色从高处落下直接穿过地板。原因是自由落体速度累加过快一帧内位移超过一个砖块高度碰撞检测完全错过。解决给下落速度设上限if (mario.vy MAX_FALL_SPEED) mario.vy MAX_FALL_SPEED;比如 12 像素/帧同时把碰撞改成“先移动、后检测、再回退”的分步逻辑。还有一招更稳把一帧拆成两个子步每步只移动一半位移多次检测。3. 编译过了但 exe 拷到别的电脑提示找不到 VCRUNTIME140.dll。原因是编译时用了动态运行库/MD目标机器没有安装对应的 Visual C Redistributable。解决分发时要么让用户装一遍运行库要么在 Visual Studio 里把“运行库”改为/MT静态链接。vscode 配置 c/c 环境时如果自己写 CMake可以在 CMakeLists 里加set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded$$CONFIG:Debug:Debug)麻烦一次后面清净很多。4. 背景音乐播放时断时续或一按跳跃键音乐就卡一下。原因是音频缓冲太小、CPU 瞬时负载过高导致数据喂不上。解决把Mix_OpenAudio的缓冲从 2048 调到 4096同时检查主循环里是否把Mix_PlayMusic放进每帧调用——它只需要调用一次循环内部会自动播不要每帧重新触发。如果用的是 WAV 大文件一次性加载会占内存加载时界面瞬间卡顿这时换成 OGG 压体积是治本。6. 从“能跑”到“能玩”这套源码值不值得继续投入的判断标准很多人做完一个项目会想这个源码到底算不算成功我给自己定了三个验证标准你也可以照着测自己手头的项目第一能否在干净环境里一条命令编译通过第二去掉音乐和图片后是否还会报错第三把窗口从 800×600 拉大到 1920×1080角色是否还在地板上。第三条最阴险——如果碰撞逻辑里写死了窗口尺寸全屏之后玛丽会直接掉出地图。验证项判定标准常见失败信号编译可复现新机器按 README 步骤能一键构建缺头文件、库路径写死绝对路径资源独立图片/音乐缺失时给出明确报错黑屏静默失败、空指针崩溃分辨率适配窗口缩放后地图和角色同步缩放角色跑到地图外、HUD 错位这三点都通过了源码就值得往深了投入。我会把下一步优先级放在“加状态机”上站立、跑动、跳跃、下落、死亡五个状态用枚举管理状态的进入条件和退出条件写清楚。很多源码只有一个update()里堆if跑到后来每个新功能都要翻旧代码这就是为什么有些游戏源码越改越烂。还有一个值得做的方向是把关卡数据从源码中拆出去改成读外部文件。字符串数组写死在代码里方便学习但你要调关卡手感每改一次都得重新编译。拆出去之后写一个简单的关卡编辑器用字符拼地图存成 txt运行时加载开发效率翻倍。这个改造也强迫你理解“数据与逻辑分离”这件事比多写两百行业务代码划算得多。我自己做这个项目时最后悔的是没在第一天就统一资源路径处理。Windows 下的相对路径以“当前工作目录”为基准在 vscode 里调试和直接双击 exe工作目录不一样图片加载结果就不同这种问题最耗时间。现在我都会在项目入口处打印SDL_GetBasePath()把资源目录和程序目录绑定在一起这个习惯帮我避开了一整类“换个启动方式就崩”的坑。如果你正卡在“快做完了但总有点不对劲”的状态希望这些经验能帮你省下几个晚上的排查时间。本文还有配套的精品资源点击获取
返回列表