ARTICLE DETAIL

资讯详情

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

C语言超级玛丽源码详解:游戏循环、碰撞检测与实战改造

C语言超级玛丽源码详解:游戏循环、碰撞检测与实战改造 简介一份用C语言完整实现的经典《超级玛丽》游戏源码面向对游戏开发感兴趣的编程学习者与早期游戏技术研究者。压缩包内共33个文件其中14个mp3音频对应背景音乐与动作音效6个bmp位图存放角色、砖块及场景贴图搭配cpp与h源码文件、sln与vcproj工程文件可直接在Visual Studio中打开编译运行整体体积仅7.42MB轻量便于分析学习。已有118人学习下载。代码覆盖游戏循环、角色控制、碰撞检测、图形渲染与音效播放等核心模块阅读源码可以看清玩家按键如何转化为角色运动、精灵如何与障碍物互动以及游戏状态如何持续刷新是理解早期游戏程序结构的理想范本。在理解基础上尝试修改跳跃参数、新增敌人或扩展关卡还能进一步锻炼C语言编程与游戏逻辑设计能力。1. C语言超级玛丽源码先认识这份代码里真正值钱的部分“基于c语言的实现的超级玛丽游戏源码.zip”——很多刚把C语言学完的人找课程设计或者想练实战时都会碰到这个标题。它看起来只是一个小游戏但真正值钱的地方不是那个童年回忆而是它用C语言把“输入、逻辑、渲染、资源”完整串成了一整个可运行的程序。你会在里面看到结构体怎么组织角色状态、二维数组怎么当地图、函数指针你怎么绕开、以及一个游戏循环到底在循环什么。这篇笔记我从拿到zip开始讲一直说到怎么改出一个自己的版本。新手先跟着把画面跑起来再读代码已经会跑的重点看第三、四章里的物理参数和碰撞边界。2. 把zip源码跑起来编译环境、文件结构与第一帧画面2.1 解压后先认文件哪些是.c、哪些是.h、哪些是资源拿到这个zip第一件事不是双击某个.c文件猛读而是先看它长了什么样。常见的一种源码包结构是这样的MarioGame/ ├── src/ │ ├── main.c # 游戏入口和主循环 │ ├── mario.c # 角色控制和物理逻辑 │ ├── map.c # 地图加载与渲染 │ ├── render.c # 绘制函数集 │ └── game.h # 公共类型与全局声明 ├── res/ │ ├── images/ # 角色贴图、砖块、背景 │ ├── audio/ # 背景音乐和音效 │ └── mapdata/ # 关卡地图文件一般是txt └── README.md注意看两点。第一有没有graphics.h或easyx.h的include如果有说明这套源码是依赖Windows图形库的不是控制台黑白程序如果开头只有stdio.h和windows.h那它多半是终端字符版用printf摆出马里奥的形状。第二看res目录里有没有实际素材文件。很多zip为了减小体积把图片和音乐裁剪掉了这时源码里通常会用纯色矩形代替贴图不影响逻辑运行但观感差不少。我最常遇到的问题是大家把文件解压到桌面下的中文文件夹然后编译器报找不到源文件。建议先建一个英文路径的目录比如D:\CProjects\Mario再解压。后面的编译过程会少踩一个坑。2.2 编译工具链怎么搭EasyX、MinGW还是其他配置这套源码里占比最大的一类是用Visual Studio配合EasyX图形库写的。原因很简单EasyX在Windows下调用GDI绘图API设计得很像早期Turbo C的图形函数对国内教材体系很友好。另一类是纯控制台版只需要任意一个C编译器都能跑比如MinGW的gcc。判别的依据就是前面说的头文件。先说明常见的VS配置路径。在Visual Studio里新建一个空项目把.c文件添加进去然后去EasyX官网下载对应VS版本的安装包安装时它会把graphics.h、easyx.h和静态库自动放进VS的包含目录。完成这一步后源码里那句#include graphics.h才能通过。如果你是Code::Blocks或其他编辑器最省事的办法是不要纠结于图形库先看这套源码里有没有不带EasyX的版本。纯控制台版编译命令大致是这样gcc main.c mario.c map.c render.c -o mario.exe这里-o mario.exe指定输出文件名。如果编译时出现undefined reference一类链接错误说明源码里用了系统库需要追加参数gcc main.c mario.c map.c render.c -o mario.exe -lgdi32 -luser32-lgdi32是Windows图形设备接口库画线、画矩形、处理位图都要它-luser32负责窗口消息和键盘鼠标输入。控制台版通常只需要这两个系统库不需要额外的第三方库。很多人在这一步会怀疑“为什么别人的代码我编译不过”其实是把MinGW版本和VS版本的源码混在一起了。识别方法不复杂带initgraph、putimage、outtextxy的是EasyX写法带MoveToEx、LineTo、CreateWindow的是纯Win32 API写法。两种写法的编译参数不一样不要通吃。2.3 第一次双击运行入口函数与initgraph看清文件结构之后直接从main.c入口读起。典型代码骨架长这样#include graphics.h #include conio.h void load_resources(void); void run_game_loop(void); int main(void) { initgraph(800, 600); // 创建 800x600 的图形窗口 load_resources(); // 加载图片、音乐、地图文件 run_game_loop(); // 进入游戏主循环直到玩家退出 closegraph(); // 关闭图形窗口释放资源 return 0; }这段代码说明一个关键点C语言标准本身没有图形接口graphics.h是EasyX库提供的扩展头文件。initgraph的作用不是“画图”而是创建一个窗口并准备好内部绘图环境后续的putimage、fillrectangle、outtextxy都会绘制在这个窗口上。run_game_loop内部是一个看起来像死循环的循环体只有玩家主动关闭窗口或游戏角色生命归零时才会退出。第一次运行如果黑屏一下就闪退最常见的原因不是逻辑bug而是资源加载失败。很多源码里会这样写void load_resources(void) { loadimage(img_mario, res/images/mario.bmp); }如果res/images/mario.bmp路径不对或文件缺失loadimage会抛出异常。验证方法是先检查res目录存在且大小非零再对照源码里的相对路径。另一个隐患是编译器工作目录不在项目根目录导致相对路径解析失败。在Visual Studio里可以通过“项目属性-调试-工作目录”手动指定为$(ProjectDir)。3. 从main开始读代码游戏循环、状态机与跳跃物理3.1 游戏循环的三种写法不用理解调度器但要知道它在干嘛整个超级玛丽源码里最核心的结构不是某个函数而是那个“一直在转”的循环。它决定了角色移动、敌人AI、动画刷新能不能按预期顺序执行。最常见的写法是int running 1; void process_input(void); void update_logic(void); void render_frame(void); int main(void) { initgraph(800, 600); while (running) { process_input(); // 读取键盘状态 update_logic(); // 更新角色位置、敌人、碰撞 render_frame(); // 绘制当前画面 Sleep(16); // 暂定16ms约等于60FPS } closegraph(); return 0; }这个循环看起来简单参数却讲究。Sleep(16)让程序每帧暂停16毫秒一秒大约跑60帧。如果去掉这一句循环会以几千帧每秒的速度狂跑游戏速度完全失控跳跃变成瞬移碰撞全部失效。你也可能看到有人写Sleep(10)或Sleep(33)前者接近100FPS后者接近30FPS。不要看到数字就改先确认物理计算的单位是按“帧”还是按“秒”。还有一类源码不用Sleep而是用GetTickCount()或clock()计算真实时间差再把这个时间差传给更新函数。这种写法更科学能在不同性能的机器上保持一致速度但初学版很少这么做。读代码时如果看到dt或delta_time说明作者已经按真实时间步长处理了如果只有Sleep那它就是固定帧率版本。3.2 跳跃物理重力、初速度与手感参数超级玛丽的手感都藏在几个宏定义里。常见的参数是这样#define GRAVITY 0.45f // 每一帧向下的加速度 #define JUMP_SPEED 11.5f // 起跳瞬间向上的速度 #define MAX_FALL 12.0f // 下落最大速度防止穿墙 #define MOVE_SPEED 4.0f // 水平移动速度 typedef struct { float x, y; // 马里奥当前位置 float vx, vy; // 水平速度和垂直速度 int on_ground; // 是否站在地面上 int dir; // 面向方向1为右-1为左 } Mario;垂直方向的更新代码通常是这样的void mario_update(Mario *m) { // 水平位移保持匀速 m-x m-vx; // 垂直方向每帧累加重力 m-vy GRAVITY; if (m-vy MAX_FALL) m-vy MAX_FALL; m-y m-vy; // 地面碰撞 if (m-y GROUND_Y) { m-y GROUND_Y; m-vy 0; m-on_ground 1; } }我展开解释一下这几个数的关系。JUMP_SPEED是起跳瞬间的向上初速度C语言里屏幕坐标系y轴向下所以向上起跳时vy取负值。跳跃最大高度可以用公式估算h JUMP_SPEED * JUMP_SPEED / (2 * GRAVITY)。代入上面的数值11.5 * 11.5 / (2 * 0.45) ≈ 147像素。在800x600的窗口里这个高度大约能跳上两三个砖块手感比较接近原版。如果改成JUMP_SPEED 8跳跃高度变成71像素连一个高台都上不去如果改成GRAVITY 0.1角色会像在月球上一样飘很久才落地。你拿到的源码可能不是这几个数但调参逻辑是一样的先定跳跃最大高度和落地时间再反推GRAVITY和JUMP_SPEED。想改手感千万别只动一个参数要两个一起调。3.3 状态机为什么不能一路if到底初学者写游戏角色最容易写出一长串if判断if (按键) 移动; if (按键) 跳跃; if (碰到敌人) 死亡;。这在逻辑简单时没问题但超级玛丽这类平台游戏里很多状态本身就是互斥的跳跃中不能再跳、死亡后不能移动、下落中不能发动攻击。不加状态管理就会出现“空中二段跳”或“死了还能走”的怪现象。成熟的源码一般会定义一个状态枚举typedef enum { ST_IDLE, // 站立待机 ST_RUN, // 地面跑动 ST_JUMP, // 上升中 ST_FALL, // 下落中 ST_DEAD // 死亡 } MarioState; typedef struct { Mario base; MarioState state; int invincible; // 无敌时间受伤后闪烁用 } Player;状态切换的核心是“只有特定状态才允许特定操作”。比如跳跃的判定void player_handle_key(Player *p) { if (p-state ST_DEAD) return; if (p-state ST_IDLE || p-state ST_RUN) { if (key_down(VK_SPACE)) { p-base.vy -JUMP_SPEED; p-state ST_JUMP; } } }这里的关键是把“起跳”限定在ST_IDLE和ST_RUN两种状态内。如果角色正在ST_JUMP或ST_FALL再按空格也不会响应从逻辑上杜绝了无限二段跳。等vy 0且落地后状态再回到ST_IDLE。读这套源码时重点关注状态切换的边界条件。很多改坏了的小游戏问题都不是画面卡顿而是状态机里少了一个“落地后复位”的分支导致马里奥跳一次之后永远无法再次起跳。4. 地图、碰撞与视口让马里奥站在“真实世界”里4.1 地图的数据结构一维数组还是二维数组超级玛丽的世界看起来很大但游戏里不会真的开一块超大的内存去装整个地图。常见做法是把地图存成二维的瓦片编号0表示空地1表示地面2表示砖块3表示金币4表示水管。有些源码会用一个结构体再包一层#define TILE_EMPTY 0 #define TILE_GROUND 1 #define TILE_BRICK 2 #define TILE_COIN 3 #define TILE_PIPE 4 typedef struct { int rows; int cols; int *grid; // 用一维数组模拟二维方便动态分配 } Map; int tile_at(Map *map, int row, int col) { return map-grid[row * map-cols col]; }为什么不用int grid[100][100]这种正宗二维数组因为很多关卡地图是外部文本文件加载进来的行数和列数不固定直接写成固定二维数组会浪费内存而且加载新关卡时不好扩容。用一维数组加row * cols索引是C语言里最常见的变长二维结构写法。你会在map.c里看到类似这样的地图加载函数int map_load(Map *map, const char *path) { FILE *fp fopen(path, r); if (fp NULL) return -1; fscanf(fp, %d %d, map-rows, map-cols); map-grid (int*)malloc(map-rows * map-cols * sizeof(int)); if (map-grid NULL) { fclose(fp); return -2; } for (int r 0; r map-rows; r) { for (int c 0; c map-cols; c) { fscanf(fp, %d, map-grid[r * map-cols c]); } } fclose(fp); return 0; }这段代码有两个细节值得说。第一fscanf读取时如果文件里用的是逗号分隔而不是空格格式串就要改成%d,%d否则会读出一堆0。第二malloc之后一定要检查返回值因为地图文件只要缺一块后面所有访问都会越界。很多源码在退出时没有free(map-grid)程序结束前会内存泄漏Windows下一般感觉不到但在Linux下跑久了就会被系统警告。4.2 碰撞检测四个边界的判断顺序很重要碰撞检测是超级玛丽源码里最容易“看起来对、跑起来怪”的部分。角色和砖块都是矩形矩形相交判断本身不难int aabb_collide(float ax, float ay, float aw, float ah, float bx, float by, float bw, float bh) { if (ax aw bx) return 0; // A在B左边 if (ax bx bw) return 0; // A在B右边 if (ay ah by) return 0; // A在B上面 if (ay by bh) return 0; // A在B下面 return 1; // 四个方向都没分开说明相交 }ax、ay是角色矩形左上角坐标aw、ah是宽高。只要角色矩形和砖块矩形在任何一条轴上有重叠区域就判定为碰撞。这个算法本身没问题但把它直接用进游戏循环会出问题——角色同时按水平和垂直方向移动如果不区分先后顺序角色碰到砖块侧面时会被强行“弹”到砖块正上方或正下方产生穿墙或瞬移。正确的做法是“先水平后垂直”分两步处理。先只处理x轴移动void mario_move_and_collide(Mario *m, Map *map) { // 水平移动 m-x m-vx; if (hit_map_left_or_right(m, map)) { m-x - m-vx; // 回退到移动前的位置 m-vx 0; // 撞墙后水平速度清零 } // 垂直移动 m-y m-vy; if (hit_map_top_or_bottom(m, map)) { m-y - m-vy; m-vy 0; m-on_ground 1; } }这里hit_map_left_or_right只检查水平方向上有无阻挡hit_map_top_or_bottom只检查垂直方向。分开处理后角色撞到墙壁时不会错误地触发“落地”逻辑站上砖块时也不会被卡在砖块侧面。调试碰撞问题时我一般会先打印角色每帧的x、y、vx、vy看位移量是否合理。如果某一帧角色位移了50个像素而砖块宽度只有32像素那角色从墙左边直接瞬移到右边是完全正常的——位移量比碰撞体还大检测算法根本捕捉不到过程。这就是下一章要讲的隧穿问题。4.3 视口滚动世界坐标与屏幕坐标的换算地图可能有一百多列但窗口只有800像素宽。渲染时不能把整个地图画上去而是只画“相机能看到的那部分”。相机坐标通常就是马里奥当前位置去减半屏宽度int camera_x 0; void update_camera(int mario_x) { camera_x mario_x - SCREEN_W / 2; // 限制在地图范围内防止看到地图外面的空白区 if (camera_x 0) camera_x 0; int max_x MAP_COLS * TILE_SIZE - SCREEN_W; if (camera_x max_x) camera_x max_x; }渲染时每个瓦片的屏幕坐标是“世界坐标减相机坐标”for (int r 0; r map-rows; r) { for (int c 0; c map-cols; c) { int tile_x c * TILE_SIZE - camera_x; int tile_y r * TILE_SIZE - camera_y; // 不在屏幕范围内的瓦片直接跳过不画 if (tile_x TILE_SIZE 0 || tile_x SCREEN_W) continue; if (tile_y TILE_SIZE 0 || tile_y SCREEN_H) continue; draw_tile(tile_at(map, r, c), tile_x, tile_y); } }这段代码里TILE_SIZE是单个瓦片的像素宽高一般是32或40。注意continue那两行它叫“视口裁剪”能大幅减少无效绘制。如果你拿到手的源码跑起来很卡先看渲染循环里是不是把整张地图都putimage了一遍。很多优化不够的源码就是犯了“全图绘制”的错。5. C语言超级玛丽源码的避坑清单从解压乱码到穿墙和闪屏5.1 源码文件解压后乱码现象zip解压后文件名变成一堆乱码或者源码里的中文注释在编译器里显示为“锟斤拷”。原因zip包内的文件名在制作时使用了GBK编码而macOS或Linux下常见的解压工具默认按UTF-8解码导致文件名显示异常。源码文件本身如果是GB2312编码用UTF-8模式的编辑器打开中文注释就会乱码。解决在Windows上优先用系统自带的“全部解压缩”或7-Zip并在7-Zip的“设置-语言”里选择简体中文在macOS或Linux上先用unzip -O gbk命令指定解压字符集。源码文件乱码的补救办法是用VS Code右下角“重新打开编辑器”选择“GB2312”或“GBK”能正确还原中文注释。不改编码也能编译但读起来会非常痛苦。5.2 编译时总是报“Cannot open include file: graphics.h”现象Visual Studio或gcc编译时提示找不到graphics.h直接卡在第一步。原因graphics.h是EasyX图形库的头文件不是C标准库自带内容。编译器默认在标准include目录里找不到它说明EasyX没有安装或者安装后没有正确识别到当前编译器的版本。解决最容易的路径是安装EasyX对应Visual Studio版本的安装包它会自动配置。如果你的编译器是MinGW或Code::Blocks情况会复杂一些因为EasyX的官方安装包主要面向MSVC一种可行方案是把EasyX安装目录下的include和lib文件复制到MinGW对应目录但要注意库文件格式差异不一定都能链接成功。更稳妥的做法是放弃图形库依赖改用SDL2或者直接找一套纯控制台版的源码来代替。判断源码是不是EasyX版只看开头有没有#include graphics.h或#include easyx.h。5.3 马里奥推得动砖块或者直接穿墙现象角色站在砖块旁边按方向键能连人带砖一起平移或者从高处下落时直接穿过地面掉出世界。原因撞砖块说明碰撞检测把角色当前矩形判定为与砖块相交但没有把角色位置“回退”到砖块外侧穿墙则通常是位移速度过大一帧移动距离超过了砖块的像素宽度导致检测时角色已经完全越过了砖块边界。解决碰撞响应必须做“位置修正”不能只把速度清零。水平碰撞先回退x坐标垂直碰撞先回退y坐标顺序不能乱。防穿墙的常见做法是限制角色每帧最大位移不超过TILE_SIZE / 2比如TILE_SIZE是32那每帧位移最多16像素。对超级玛丽这种平台游戏来说把MAX_FALL限制在12左右配合逐块检测基本不会出现掉出地图的问题。5.4 跳跃手感像火箭炮或者像踩了棉花现象按键后角色瞬间飞到屏幕顶或者按了好几次空格角色才慢慢悠悠飘起来。原因跳跃参数和帧率不匹配。Sleep(16)的循环里GRAVITY取0.45左右是比较常用的一套组合但如果你把Sleep改成Sleep(5)帧率提高四倍同样的GRAVITY每帧作用四次跳跃高度会猛增。反过来如果电脑性能差Sleep(16)实际被系统拖成了30ms一帧角色就像踩了棉花。解决有两种路线。第一种是固定帧率路线把主循环稳定控制住Sleep数值和物理参数配套不要乱改。第二种是可变帧率路线计算真实帧间隔dt所有位移公式改成x vx * dtvy GRAVITY * dt这样无论帧率怎么波动物理表现都一致。第二种更专业但代码量会大一些。如果只是交作业守住第一种路线就够了。5.5 画面闪不停或者拖出残影现象角色移动时窗口内能看到之前几帧的轮廓动起来像鬼影。原因没有做双缓冲。默认的绘图模式下每帧都要先清屏再画清屏和绘制之间存在时间差屏幕刷新时画了一半、擦了一半就会闪屏和残影。解决EasyX里用批量绘制。渲染前调用BeginBatchDraw()所有绘制完成后调用FlushBatchDraw()统一显示BeginBatchDraw(); clearrectangle(0, 0, SCREEN_W, SCREEN_H); draw_background(); draw_map(); draw_mario(); FlushBatchDraw();注意一旦用了BeginBatchDraw绘图操作不会立刻显示在窗口上所以逻辑更新和渲染的顺序一定要保持“先逻辑后画面”。有些源码只在主循环里加了BeginBatchDraw却忘记在结束时调用FlushBatchDraw结果整个画面漆黑一片也是常见问题。6. 把源码改成自己的作品加怪物、换关卡、加道具的三个切入点6.1 给马里奥加一只会巡逻的蘑菇怪看懂原版源码后第一个值得做的改动是加敌人。定义一个最简陋的敌人结构体和更新函数typedef struct { float x, y; int dir; // 1向右-1向左 int alive; // 0表示被打死 } Enemy; void enemy_update(Enemy *e, Map *map) { if (!e-alive) return; e-x e-dir * 1.0f; // 撞墙掉头 if (map_wall_at(map, e-x, e-y)) { e-dir * -1; } }e-x e-dir * 1.0f中1.0是移动速度你可以改大改小。撞墙掉头用的是map_wall_at判断当前位置是不是墙如果原源码没有这个函数也可以用前后各探一格的方式实现往右走时检查右侧两格有没有砖块。把enemy_update挂进主循环后再补一个角色与敌人的碰撞判断马里奥从上方踩中敌人敌人alive 0否则马里奥进入ST_DEAD状态。这一步做完游戏的可玩性会提升一大截。6.2 替换关卡地图的两种途径多数源码里地图来自硬编码数组少数支持外部文件。如果你想换一关又不打算大改代码最快的方式是直接改数组里的数字。复制原有数组把地面层改低、加几个平台就变成了新关卡。标准做法是维护一个地图文件列表const char *level_paths[] { res/mapdata/level1.map, res/mapdata/level2.map, res/mapdata/level3.map };然后在通过关卡后递增关卡索引重新调用map_load和改马里奥出生点。第一次做时最常犯的错是只改了地图数据没把角色出生点同步移动导致马里奥一出生就卡在砖块里。记得在地图文件里约定一个特殊的瓦片编号比如9表示出生点加载时扫描一遍地图找到这个瓦片就把它的坐标设为马里奥的初始位置。6.3 验证性能和内存问题的土办法改造完成后如何证明它没改坏我一般会在主循环里加一个帧率计数器同时用Visual Studio的调试器观察内存变化。帧率打印的写法int frame_count 0; DWORD last_time GetTickCount(); // 主循环末尾 frame_count; DWORD now GetTickCount(); if (now - last_time 1000) { char str[64]; sprintf(str, FPS: %d, frame_count); setbkcolor(WHITE); outtextxy(0, 0, str); frame_count 0; last_time now; }这段代码每一秒刷新一次FPS显示能直观看出加了敌人之后渲染有没有变慢。内存检查方面最简单的方法是循环进入下一关时反复加载地图看内存占用是否持续增长如果一直涨说明map_load里的malloc没有对应free这就是“地图越换越卡”的根源。做C语言的老游戏源码确实有它的脾性很多问题不是算法看不懂而是坐标体系、字符编码、编译环境这些“外围”在互相打架。我自己踩得最深的一次是把地图文本文件的行尾多留了一个换行符结果fscanf读到最后一行时拿到了残留的换行画面从某一行开始全部错位。从那以后凡是涉及fscanf读取地图数据我总会加一层数据合法性校验宁愿多写几行防御也不让一个看不见的字符毁掉整个关卡。这套基于C语言的超级玛丽源码值得你花一个下午慢慢拆开看也希望这篇笔记能帮你少走一些弯路。希望帮到你。本文还有配套的精品资源点击获取
返回列表