简介这是一份面向 C 初学者与游戏开发爱好者的超级马里奥仿制项目源码基于 C 与 EasyX 图形库实现适合作为图形编程、游戏循环与面向对象设计的练手案例。压缩包共 239 个文件约 10.6MB其中 21 个 cpp 与 21 个 h 构成核心逻辑涵盖马里奥、怪物、砖块、道具、平台、场景与事件等模块163 个 png 与 25 个 mp3 提供贴图与音效另有 ico、wav、sln、vcxproj 等工程与资源文件可直接在 Visual Studio 2022 配合 EasyX_20220901 编译运行。项目已完整实现 1-1、1-2、1-3 三个关卡支持 a/d 移动、k 跳跃、j 加速或发射火球、s 下蹲或钻入管道等操作通关 1-3 后程序自动关闭属正常现象。目前已有 567 人学习读者可借此理解碰撞检测、状态机与关卡数据组织方式并在此基础上扩展新关卡或玩法。1. 从零手搓超级马里奥C 与 EasyX 能做出什么很多人第一次萌生「自己写个游戏」的念头脑子里蹦出来的画面就是那个穿背带裤、顶砖块、踩蘑菇的红帽子水管工。可真去搜「C 小游戏代码」翻出来的多半是控制台里跑的黑框贪吃蛇连个图形窗口都没有。这份「基于 C 与 EasyX 图形库对超级马里奥游戏仿制源码项目说明」要解决的正是从黑框到彩色窗口之间那道坎用 C 做逻辑用 EasyX 做渲染把马里奥的移动、跳跃、碰撞、地图滚动、敌人巡逻这一整套东西跑起来。它适合两类人——刚学完 C 基础语法、想找个能看见成果的练手项目的新手以及想快速验证 2D 横版游戏核心机制、不想一上来就啃引擎源码的熟手。EasyX 是 Windows 平台上轻量到几乎零配置的图形库一个头文件加一个库配好就能画图这也是它在国内 C 教学圈里长期当主力军的原因。2. 环境搭建与 EasyX 最小可运行窗口2.1 为什么选 EasyX 而不是 SDL、raylib选图形库这件事直接决定你后面是写游戏还是写构建脚本。SDL 和 raylib 都是跨平台的成熟方案功能强、生态好但代价是你要先学会配 CMake、拉依赖、处理链接顺序新手很容易卡在「库找不到」这一步就放弃了。EasyX 的定位完全不同它只服务 Windows Visual Studio 这一条链路安装包双击运行自动把graphics.h、easyx.h和对应的.lib塞进 VS 的目录里新建项目直接#include graphics.h就能用。对于「仿制超级马里奥」这种目标明确的练手项目你需要的绘图能力无非是贴图、画矩形、输出文字、读键盘、定时刷新EasyX 全都覆盖而且 API 名字直白到不用查文档——initgraph开窗口、loadimage加载图片、putimage贴图、BeginBatchDraw批量绘制。所以这里的取舍很清楚要跨平台和长期工程化选 SDL/raylib要一周内看到马里奥在屏幕上跑起来EasyX 是阻力最小的路。2.2 VS Code 与 Visual Studio 两条配置路线热词里「vscode配置c/c环境」和「vscode c」出现频率很高说明不少人习惯用 VS Code 写代码。但 EasyX 官方只提供 Visual Studio 的安装器用 VS Code 就得手动配。两条路线我都走过下面分别说。Visual Studio 路线最省事。去 EasyX 官网下载对应 VS 版本的安装包运行后它会自动检测已装的 VS 并写入头文件和库。装完新建「空项目」把源码.cpp加进去直接编译即可。注意 VS 版本和 EasyX 安装包要对应装错版本会出现「找不到 graphics.h」。VS Code 路线要手动指定三个路径。核心是让编译器找到头文件和库并在链接时带上easyx相关的库。下面是一个可用的tasks.json编译任务片段{ version: 2.0.0, tasks: [ { label: build-mario, type: shell, command: g, args: [ -g, -I, C:/EasyX/include, // EasyX 头文件目录 -L, C:/EasyX/lib/VC2015/x64, // 对应位数的库目录 -o, ${workspaceFolder}/mario.exe, ${workspaceFolder}/main.cpp, -leasyx, // 链接 EasyX 库 -lgdi32, -lole32, // EasyX 依赖的系统库 -mwindows // 去掉控制台黑框 ], group: { kind: build, isDefault: true } } ] }参数说明-I告诉编译器去哪找graphics.h-L指定库文件位置注意 x64 和 x86 要和你的编译器位数一致混用会报链接错误-leasyx是主库-lgdi32、-lole32是 EasyX 底层依赖的 Windows 系统库漏掉会报一堆未定义符号-mwindows让程序以窗口模式启动不加的话每次运行都会先弹一个控制台窗口。如果编译时报undefined reference to initgraph九成是-L路径写错或位数不匹配。2.3 第一个能跑起来的窗口配置对不对跑一个最小程序就知道。下面这段代码开一个 800x600 的窗口画个矩形按任意键退出#include graphics.h #include conio.h int main() { initgraph(800, 600); // 创建 800x600 的绘图窗口 setbkcolor(WHITE); // 设置背景色为白 cleardevice(); // 用背景色清屏 setfillcolor(RGB(255, 0, 0)); // 设置填充色为红 fillrectangle(100, 100, 200, 200); // 画一个实心矩形 _getch(); // 等待按键防止窗口一闪而过 closegraph(); // 关闭绘图窗口 return 0; }逻辑说明initgraph是 EasyX 的入口调用后所有绘图操作才有意义cleardevice用当前背景色清空画面游戏主循环里每帧都要调fillrectangle的四个参数是左上角和右下角坐标EasyX 的坐标系原点在窗口左上角x 向右、y 向下这点和数学坐标系相反后面算跳跃和重力时要注意。跑通这个窗口环境就算立住了接下来才是往里面填马里奥。3. 马里奥核心机制移动、跳跃与碰撞3.1 用状态机管住马里奥的每个动作新手写马里奥最容易翻车的地方是把移动、跳跃、下落全塞进一个if里结果角色要么卡在空中要么按一下跳三下。正确做法是用状态机把动作拆开。马里奥的状态无非几种站立、跑动、跳跃上升、下落、死亡。每个状态有自己的进入条件和退出条件主循环每帧根据输入和物理量切换状态。enum MarioState { STAND, RUN, JUMP, FALL, DEAD }; struct Mario { float x, y; // 位置浮点避免整数抖动 float vx, vy; // 水平、垂直速度 bool onGround; // 是否站在地面 MarioState state; }; void updateMario(Mario m, float dt) { const float GRAVITY 2000.0f; // 重力加速度像素/秒^2 const float MOVE_SPEED 300.0f; // 水平移动速度 const float JUMP_SPEED -700.0f;// 起跳初速度向上为负 // 水平输入 if (GetAsyncKeyState(VK_LEFT) 0x8000) { m.vx -MOVE_SPEED; m.state RUN; } else if (GetAsyncKeyState(VK_RIGHT) 0x8000) { m.vx MOVE_SPEED; m.state RUN; } else { m.vx 0; if (m.onGround) m.state STAND; } // 跳跃只有站在地面才能起跳 if ((GetAsyncKeyState(VK_SPACE) 0x8000) m.onGround) { m.vy JUMP_SPEED; m.onGround false; m.state JUMP; } // 重力积分 m.vy GRAVITY * dt; m.x m.vx * dt; m.y m.vy * dt; // 根据垂直速度更新状态 if (!m.onGround) m.state (m.vy 0) ? JUMP : FALL; }参数说明dt是上一帧到这一帧的时间差单位秒用GetTickCount或clock算出来。所有速度都乘dt再累加这样游戏在不同帧率下表现一致——这就是所谓的「帧率无关」。GRAVITY取 2000 左右手感比较接近原版太小会飘太大像石头砸地。JUMP_SPEED是负值因为 y 轴向下为正向上跳就是 y 减小。onGround这个标志位是关键它由碰撞检测来更新跳跃时必须检查它否则会出现空中连跳的 bug。3.2 碰撞检测AABB 与分轴处理马里奥踩没踩到地面、撞没撞到砖块全靠碰撞检测。2D 横版游戏里最常用的是 AABB轴对齐包围盒因为矩形和矩形判交只要四次比较快且够用。但直接判交有个经典坑如果同时处理水平和垂直碰撞角色贴着墙走的时候会被卡住或者穿墙。稳妥做法是分轴处理——先移动 x 轴检测并修正 x 方向碰撞再移动 y 轴检测并修正 y 方向碰撞。struct Rect { float x, y, w, h; }; bool overlap(const Rect a, const 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; } // 分轴移动先 x 后 y void moveAndCollide(Mario m, const std::vectorRect tiles, float dt) { // ---- X 轴 ---- m.x m.vx * dt; Rect box { m.x, m.y, 32, 48 }; // 马里奥碰撞盒 for (auto t : tiles) { if (overlap(box, t)) { if (m.vx 0) m.x t.x - 32; // 向右撞贴左边 else if (m.vx 0) m.x t.x t.w; // 向左撞贴右边 m.vx 0; box.x m.x; } } // ---- Y 轴 ---- m.y m.vy * dt; box.y m.y; m.onGround false; for (auto t : tiles) { if (overlap(box, t)) { if (m.vy 0) { // 下落撞到地面 m.y t.y - 48; m.onGround true; } else if (m.vy 0) { // 上升顶到砖块 m.y t.y t.h; } m.vy 0; box.y m.y; } } }逻辑说明碰撞盒宽 32 高 48是马里奥贴图的逻辑尺寸比视觉贴图略小一圈手感更宽容。X 轴处理时一旦检测到重叠就立刻把角色推回接触面并清零水平速度这样贴墙走不会抖。Y 轴处理时先假设不在地面检测到向下碰撞才把onGround置真这个顺序保证了站在地面上时每帧都能重新确认接地状态。tiles是所有实心砖块的矩形列表实际项目里通常用二维数组存地图遍历时只检查角色附近的格子全图遍历在关卡大时会拖慢帧率。3.3 地图滚动与摄像机跟随马里奥走到屏幕右边要继续前进就得让地图滚动。本质是摄像机位置在变所有绘制坐标都要减去摄像机偏移。摄像机跟随有个小技巧不要让马里奥永远钉在屏幕正中而是设一个「死区」角色在死区内移动时摄像机不动超出死区才跟随这样画面不会因为角色微小移动而频繁晃动。struct Camera { float x, y; }; void updateCamera(Camera cam, const Mario m, int screenW) { const float DEAD_ZONE 200.0f; // 屏幕中央死区宽度 float center cam.x screenW / 2.0f; if (m.x center DEAD_ZONE / 2) { cam.x m.x - screenW / 2.0f - DEAD_ZONE / 2; } else if (m.x center - DEAD_ZONE / 2) { cam.x m.x - screenW / 2.0f DEAD_ZONE / 2; } if (cam.x 0) cam.x 0; // 不允许摄像机越过关卡左边界 } // 绘制时统一减去摄像机偏移 void drawMario(const Mario m, const Camera cam) { putimage((int)(m.x - cam.x), (int)(m.y - cam.y), marioImg); }参数说明DEAD_ZONE取 200 像素左右太小画面跟得紧但容易晃太大角色会跑到屏幕边缘才滚动。cam.x限制不小于 0防止看到关卡左边的空白。绘制时所有世界坐标都要减cam.x包括砖块、敌人、道具漏掉任何一个都会出现「角色动了背景没动」的错位。4. 敌人、道具与关卡数据的组织方式4.1 蘑菇怪巡逻逻辑与踩踏判定没有敌人的马里奥不叫马里奥。最基础的敌人是蘑菇怪Goomba行为很简单水平匀速巡逻碰到墙或走到平台边缘就掉头。踩踏判定是重点——马里奥从上方落下碰到敌人头顶算踩死从侧面碰到算受伤。判定靠比较两者的相对位置和速度。struct Goomba { float x, y, vx; bool alive; }; void updateGoomba(Goomba g, const std::vectorRect tiles, float dt) { if (!g.alive) return; g.x g.vx * dt; Rect box { g.x, g.y, 32, 32 }; for (auto t : tiles) { if (overlap(box, t)) { if (g.vx 0) g.x t.x - 32; else g.x t.x t.w; g.vx -g.vx; // 撞墙掉头 box.x g.x; } } } // 马里奥与敌人的交互判定 void checkEnemyCollision(Mario m, Goomba g) { if (!g.alive) return; Rect mb { m.x, m.y, 32, 48 }; Rect gb { g.x, g.y, 32, 32 }; if (!overlap(mb, gb)) return; // 马里奥在下落且脚底高于敌人中心判定为踩踏 if (m.vy 0 (m.y 48) (g.y 16)) { g.alive false; m.vy -400.0f; // 踩踏后小反弹 } else { m.state DEAD; // 侧面接触受伤 } }逻辑说明踩踏判定的核心是m.vy 0正在下落加上脚底位置高于敌人中心两个条件同时满足才算踩中。只判重叠不判方向会出现「从下面顶到敌人也算踩死」的荒谬情况。踩踏后给一个向上的小速度-400手感上有个弹跳反馈这是原版就有的细节。敌人掉头逻辑和马里奥撞墙类似但敌人不需要分轴因为它只做水平移动。4.2 用二维数组还是对象列表存关卡关卡数据怎么存直接影响你改关卡的效率。两种常见做法一是用二维字符数组每个字符代表一种地块比如#是砖块、?是问号块、G是蘑菇怪出生点二是用对象列表每个对象带类型和坐标。前者适合手工编辑、直观后者适合程序生成、灵活。// 方案一字符地图改关卡就是改字符串 const char* LEVEL_1[] { , , ? , G , ########################################, }; // 解析成碰撞矩形 std::vectorRect parseLevel(const char** map, int rows, int cols, int tileSize) { std::vectorRect tiles; for (int r 0; r rows; r) { for (int c 0; c cols; c) { if (map[r][c] #) { tiles.push_back({ (float)(c * tileSize), (float)(r * tileSize), (float)tileSize, (float)tileSize }); } } } return tiles; }参数说明tileSize是单个地块的像素尺寸通常取 32 或 48要和贴图尺寸一致。字符地图的好处是你在代码里就能看出关卡长什么样加一行砖块就是加几个#。缺点是关卡大了字符串会很长且不方便做动态生成。实际项目里我一般用字符地图做静态关卡用对象列表管敌人和道具两者结合。4.3 问号块、金币与顶砖反馈问号块被顶到要弹出金币或道具这个交互是马里奥的灵魂之一。实现上给问号块加一个状态未顶、已顶。马里奥上升顶到它时如果未顶就触发掉落金币动画并把状态改为已顶贴图换成空块。struct QuestionBlock { float x, y; bool used; }; void hitBlock(QuestionBlock q, Mario m) { if (q.used) return; q.used true; // 触发金币动画在块上方生成一个向上飞的金币 spawnCoin(q.x 8, q.y - 32); // 顶砖时给马里奥一个向下的反作用力模拟撞头 m.vy 100.0f; }逻辑说明used标志防止同一个块被反复触发。顶砖时给马里奥一个向下的速度模拟撞到硬物的顿挫感这个细节很多人会漏导致顶砖像穿过空气。金币动画用一个独立的粒子或精灵列表管理每帧更新位置和生命周期飞完就移除。5. 避坑与排查那些让马里奥跑不起来的坑5.1 画面闪烁为什么必须用批量绘制现象角色一动整个画面闪得厉害像老式日光灯。原因EasyX 默认每调用一次绘图函数就刷新一次屏幕一帧里画几十个砖块加角色屏幕被刷新几十次人眼就看到中间态。解决用BeginBatchDraw和FlushBatchDraw把一帧的绘制攒起来一次性提交。BeginBatchDraw(); // ... 这一帧所有的 cleardevice、putimage、fillrectangle ... FlushBatchDraw();注意cleardevice也要放在批量绘制之间否则清屏和绘制不同步还是会闪。这个坑几乎每个 EasyX 新手都会踩属于血泪经验。5.2 角色抖动浮点坐标与整数绘制的矛盾现象马里奥移动时轻微抖动尤其是慢速移动时。原因位置用float存但putimage的坐标参数是int每帧取整时小数部分被截断导致位置在相邻像素间跳。解决位置继续用float算物理绘制时用round四舍五入而不是直接截断或者干脆接受一像素抖动——原版像素游戏本来就是这个观感。如果抖动明显到影响体验检查是不是把dt算错了帧率不稳会放大这个问题。5.3 按键连跳GetAsyncKeyState 的陷阱现象按住空格马里奥落地瞬间又跳起来形成连跳。原因GetAsyncKeyState返回的是按键当前是否按下按住不放每帧都返回真落地那帧onGround刚变真就立刻触发跳跃。解决要么加一个「跳跃键刚按下」的边沿检测要么在起跳后设一个短冷却。边沿检测的写法是记录上一帧的按键状态只有「上帧没按、这帧按了」才算新按下。static bool prevSpace false; bool nowSpace (GetAsyncKeyState(VK_SPACE) 0x8000) ! 0; bool jumpPressed nowSpace !prevSpace; // 只在按下的瞬间为真 prevSpace nowSpace;5.4 链接报错库位数与运行库不匹配现象编译通过链接时报LNK2019 无法解析的外部符号或者运行时报缺少MSVCP140.dll。原因EasyX 库的位数x86/x64和项目平台不一致或者运行库设置MT/MD和库的编译方式不一致。解决在 VS 里检查「项目属性 → 链接器 → 高级 → 目标计算机」和「C/C → 代码生成 → 运行库」确保和 EasyX 库匹配。热词里「microsoft visual c redistributable」和「visual c redistributable」频繁出现多半就是运行库没装全装一个对应版本的 redistributable 能解决大部分「缺少 dll」的问题。5.5 内存与资源贴图加载失败不报错现象loadimage之后画面空白但程序不崩。原因EasyX 的loadimage加载失败时返回一个空图像对象不会抛异常后续putimage画出来就是空白。解决加载后检查图像宽高是否为 0路径用绝对路径或确认工作目录正确。VS 里调试时的工作目录默认是项目目录不是 exe 所在目录图片放错地方就会静默失败。IMAGE img; loadimage(img, _T(res/mario.png)); if (img.getwidth() 0) { MessageBox(GetHWnd(), _T(贴图加载失败), _T(错误), MB_OK); }6. 让手感更像原版几个调参技巧把功能跑通只是第一步马里奥之所以是马里奥手感占了很大比重。原版有几个不太起眼但影响巨大的设计复刻时值得专门调。第一个是可变跳跃高度。原版马里奥轻点跳跃键跳得矮按住跳得高。实现方式是起跳时给一个初速度如果玩家在上升过程中松开跳跃键就把垂直速度砍掉一半。这一行代码能让操作手感立刻上一个档次if (m.vy 0 !(GetAsyncKeyState(VK_SPACE) 0x8000)) { m.vy * 0.5f; // 松开跳跃键上升速度减半 }第二个是加速度而不是瞬时速度。原版马里奥起步和刹车都有个短暂的加速过程不是按下就满速。把水平速度改成向目标速度逼近用vx (targetVx - vx) * accel * dt这种形式accel取 10 到 15 之间移动会顺滑很多。瞬时变速玩起来像在冰面上滑缺少重量感。第三个是土狼时间Coyote Time。玩家在离开平台边缘后的几帧内仍然可以起跳这是现代平台游戏普遍采用的宽容设计原版也有类似效果。实现很简单记录一个「最后一次在地面的时间」只要当前时间减去它小于 0.1 秒就允许起跳。参数建议值作用GRAVITY2000重力越大下落越快JUMP_SPEED-700起跳初速度MOVE_SPEED300最大水平速度ACCEL12水平加速度系数COYOTE_TIME0.1s离地后仍可跳的宽限DEAD_ZONE200摄像机死区宽度这些数值没有标准答案取决于你的地块尺寸和贴图比例。我的习惯是先按上表设一版然后自己玩五分钟觉得跳得飘就加 GRAVITY觉得跑得黏就加 ACCEL调到「按下去有反馈、松开有惯性」为止。调参这件事没有后悔药只能一遍遍试但试出来的手感是抄不走的。最后说个验证方法把游戏录屏逐帧看马里奥的起跳和落地。如果起跳瞬间位置有跳变说明dt或初速度有问题如果落地后有一帧悬空说明onGround的更新时机不对。逐帧看比凭感觉调准得多。我自己做这个项目时光跳跃手感就调了三个晚上最后发现最影响体验的不是重力大小而是松开跳跃键时那个速度衰减的系数。希望帮到你。本文还有配套的精品资源点击获取