C++与EasyX打造超级马里奥:碰撞检测与游戏状态机源码拆解

📅 发布时间:2026/9/23 6:19:48
C++与EasyX打造超级马里奥:碰撞检测与游戏状态机源码拆解
简介这是一个基于C和EasyX图形库仿制的超级马里奥小游戏源码包面向C初学者和游戏开发入门者适合用于学习游戏循环、碰撞检测、关卡渲染等基础实现。资源共239个文件压缩包约10.6MB核心包含21个cpp与21个h源文件以及163张png素材和25个mp3音频另有VS工程文件与项目说明文档可直接用Visual Studio 2022打开编译运行。项目已实现1-1、1-2、1-3三个完整关卡支持移动、跳跃、发射火球、下蹲钻管道等经典操作通关后程序自动关闭。资源附带项目说明便于对照代码理解马里奥游戏的框架设计和模块划分目前已吸引567人学习下载适合想通过实际项目提升C编程能力或了解EasyX游戏开发流程的读者。1. 用 C 和 EasyX 复刻超级马里奥一个值得拆开看的完整小游戏源码先说结论这个项目的技术天花板不在“把马里奥画出来”而在碰撞检测、游戏状态调度和关卡数据组织这三件事上。它用纯 C 配合 EasyX 图形库把超级马里奥 1-1、1-2、1-3 三个关卡完整跑通了入口是 10 个相互协作的 .cpp 源文件通关后程序自动退出——说明作者把终点判定写进了主循环而不是靠人工结束。适合刚学完 C 语法、想做一个“能拿得出手”的图形程序的人也适合已经在写控制台小游戏、想看看窗口程序里事件循环和渲染循环怎么组织的开发者。它解决的是“从语法到游戏”之间的断层问题你会在里面看到真实的按键轮询、障碍物判定、怪物 AI 和道具状态流转而不是教科书里那种一个 main 函数写完就结束的玩具。下面我按拆项目的习惯从工程骨架、场景构建、角色交互、踩坑记录到扩展验证一层层把它剥开。2. 工程骨架与事件驱动模型先搞清楚 10 个源文件各管什么2.1 源文件职责划分从文件名反推项目架构拿到源码压缩包先别急着编译把解压后的文件列表过一遍。这个项目的 .cpp 文件命名非常规范基本做到了“一个文件管一件事”源文件职责推断关键依赖check.cpp碰撞检测判断马里奥与方块、墙壁、怪物是否重叠block, mario, wallevent.cpp键盘事件监听把 a/d/k/j/s 映射成游戏动作gamesceneblock.cpp砖块与问号方块包含被顶碎、弹出道具的逻辑image, propgamescene.cpp场景调度管理三个关卡的切换与渲染入口全部mario.cpp马里奥角色包含尺寸状态小/大/火球、速度、跳跃check, wallmonster.cpp怪物 AI巡逻移动、掉头、被踩扁/被火球击杀check, wallwall.cpp地面和管道的碰撞体数据提供静态障碍物判定无prop.cpp道具类型与效果蘑菇/火花/金币的生效逻辑marioimage.cpp图片资源加载与绘制封装统一 EasyX 调用无platform.cpp平台逻辑处理单向通过的面板和移动平台check这种拆分方式值得学碰撞检测单独放一个文件不掺入渲染代码后续如果要换图形库或者加物理引擎只动 check.cpp 和调用它的地方。gamescene.cpp 是控制中枢它不直接操作像素而是调 mario、monster、block 暴露出来的接口这种“场景管逻辑、对象管自身”的分层是游戏代码能持续加关卡的前提。2.2 编译环境准备VS2022 与 EasyX 的搭配细节作者标注的编译环境是 Microsoft Visual Studio 2022 和 EasyX_20220901。EasyX 的安装本身不复杂下载后直接运行安装程序它会自动识别 VS 版本并向 include 和 lib 路径写入头文件与静态库。容易出问题的是这两个地方项目属性里的字符集必须是“使用多字节字符集”如果保持默认的 Unicode所有图片路径和中文字符串都会编译报错。这是因为 EasyX 的 API 接收的是 const char * 而不是宽字符指针。链接器输入里需要手动加EasyXa.libDebug 模式或EasyX.libRelease 模式。虽然新版安装包会自动配置但如果你把源码拷到别的机器上重新建项目这两步十有八九要手工再走一遍。我用 msvc 编译这类窗口程序时习惯在 main 函数前先调用一个初始化函数将 EasyX 的绘图窗口和逻辑坐标统一设置好避免后续渲染时坐标系混乱。#include graphics.h #include conio.h // 初始化窗口宽度 960高度 640窗口标题用 TCHAR 宏兼容多字节/Unicode void InitGameWindow() { initgraph(960, 640); // 创建绘图窗口 setbkcolor(WHITE); // 背景色影响 clearcliprgn 后的底色 cleardevice(); // 立即清屏 setorigin(0, 0); // 恢复默认原点左上角 } int main() { InitGameWindow(); // 游戏主循环的入口由 gamescene.cpp 提供 RunGameScene(); closegraph(); // 关闭图形窗口释放资源 return 0; }逻辑说明initgraph的两个参数是窗口宽高单位像素。setorigin用来重置坐标系原点因为某些操作会改变它保险起见初始化的最后强制归零。cleardevice是在设置背景色后主动刷新一次画面避免首次绘制出现残留噪点。参数说明960x640 是 EasyX 项目里比较通用的安全分辨率兼容 1366x768 以上的屏幕又不会在小屏笔记本上溢出。如果你的素材是 400x400 左右的单帧图960 宽度可以放下 24 列地砖刚好接近经典马里奥的屏幕横向跨度。2.3 主循环拆解渲染循环与事件循环的合流这个项目的 event.cpp 不是 EasyX 自带的kbhit轮询而是把按键状态和游戏的帧更新同步处理。窗口程序的事件模型和命令行的差异就在这里控制台程序按一个键、程序响一次窗口程序则要求每一帧都去“问”系统当前有哪些键被按下而且要能识别长按。// event.cpp 中典型的按键轮询代码 #include graphics.h // 返回当前帧所有处于按下状态的按键组合 int PollInput() { int action 0; if (GetAsyncKeyState(A) 0x8000) action | 1; // 左移 if (GetAsyncKeyState(D) 0x8000) action | 2; // 右移 if (GetAsyncKeyState(K) 0x8000) action | 4; // 跳跃 if (GetAsyncKeyState(J) 0x8000) action | 8; // 加速/火球 if (GetAsyncKeyState(S) 0x8000) action | 16; // 下蹲/钻管道 return action; }逻辑说明GetAsyncKeyState是 Windows API它检测按键的实时状态而不用等消息队列适合游戏里需要高频采样的场景。返回值最高位为 1 表示当前帧该键被按下用按位或的方式把多个按键合并进一个整数上层再用位与拆解。这样 event.cpp 不直接改变游戏状态只返回“这帧用户按了什么”具体响应由 gamescene 根据马里奥当前状态决定。参数说明字母键的虚拟键码直接用 ASCII 大写字符表示0x8000是常量0x8000表示检测最高位。这种写法比GetKeyState更实时但要注意它不支持检测多键组合的“同时按下”顺序对动作游戏来说够用了。顺着 event.cpp 往下gamescene.cpp 里的主循环大概长这样// 主循环每帧处理输入、更新逻辑、渲染 void RunGameScene() { while (!IsGameOver()) { int input PollInput(); UpdateMario(input); // 根据输入更新马里奥位置 UpdateMonsters(); // 更新怪物 AI CheckAllCollisions(); // 统一碰撞检测 RenderFrame(); // 绘制本帧画面 Sleep(16); // 约 60 FPS } }这里暴露了作者的调度思路输入、逻辑、碰撞、渲染四个阶段严格分离。Sleep(16)是常见的锁帧手段因为 EasyX 没有内置 vsync不加延时帧率会冲到几百导致跳跃高度不稳定。我一般会把帧延时做成可配置参数Release 模式下用 16 毫秒Debug 模式下用 0 毫秒方便快速观察但碰撞检测的容错就得跟着改。3. 场景构建与瓦片地图block、wall、platform 的协同方式3.1 瓦片地图的设计思路用数据而不是硬编码摆关卡打开 wall.cpp 和 block.cpp核心不是绘图函数而是关卡数据的初始化方式。经典做法是把关卡地图抽象成二维数组每个格子对应一个瓦片类型。这个项目里 1-1、1-2、1-3 三个关卡能完整跑下来靠的正是这种数据驱动——同一套渲染和碰撞逻辑替换地图数组就生成新关卡。// wall.cpp 中典型的地形初始化片段 // 地图图例0空白, 1地面砖, 2问号方块, 3管道 void InitLevel(int levelId) { // 以 1-1 关卡为例局部地图数据 const int map[15][20] { {0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,0,2,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,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,1}, }; // 根据数组内容实例化墙体和砖块 for (int row 0; row 15; row) { for (int col 0; col 20; col) { int type map[row][col]; if (type 1) AddWall(col * TILE_SIZE, row * TILE_SIZE); else if (type 2) AddBlock(col * TILE_SIZE, row * TILE_SIZE); } } }逻辑说明二维数组的行列坐标乘以瓦片尺寸转换成像素坐标这样关卡设计和代码逻辑彻底解耦。新增关卡就是新增一个数组不需要改碰撞函数。注意AddWall和AddBlock内部会各自创建一个碰撞盒后续 check.cpp 遍历的就是这些碰撞盒集合。参数说明TILE_SIZE是瓦片边长经典马里奥是 16 像素×16 像素放大两倍到 32 像素在 EasyX 窗口里更清晰。数组第一维是行垂直方向第二维是列水平方向和屏幕坐标的 x、y 顺序要对应好否则关卡会“竖过来”——这是最容易踩的坑。3.2 block.cpp 的状态逻辑空心砖和问号方块的区别处理block.cpp 比 wall.cpp 多一层状态方块可以被打碎、可以弹出道具、可以被顶动。这个项目用了一个简单的枚举区分方块的类型和当前状态而非为每种方块写独立类。// block.cpp 中方块状态管理 enum BlockState { BLOCK_UNTOUCHED, // 未被顶过 BLOCK_USED, // 已被顶过显示为使用后的砖块 BLOCK_BROKEN // 已碎裂 }; // 马里奥从下方顶到方块时调用 int HitBlock(Block* block, int marioState) { if (block-state BLOCK_USED) return 0; // 已用过不再响应 if (block-state BLOCK_BROKEN) return 0; if (block-type BRICK) { // 大马里奥可以顶碎砖块小马里奥顶不动 if (marioState MARIO_BIG) { block-state BLOCK_BROKEN; return EFFECT_BRICK_BREAK; } else { block-state BLOCK_USED; return EFFECT_BUMP; } } if (block-type QUESTION_BLOCK) { block-state BLOCK_USED; SpawnProp(block-x, block-y - TILE_SIZE); // 在方块上方生成道具 return EFFECT_PROP; } return 0; }逻辑说明HitBlock返回一个整数效果码调用方拿到后决定播放什么动画或音效。注意大马里奥顶砖块和顶问号方块的结果不同——这是马里奥游戏里很关键的状态差异。道具在方块上方生成而不是在内部这样马里奥从侧面经过时不会被卡住。参数说明marioState从小到大用 0、1、2 分别代表小马里奥、大马里奥、火球马里奥。这个值由 mario.cpp 维护碰撞检测时需要传入。SpawnProp的 y 坐标用了block-y - TILE_SIZE表示道具出现在方块上方一个格子处有向上弹出的视觉效果实际实现可能还加一个初速度和重力模拟。3.3 image.cpp 的加载封装避免重复 IO 和资源泄漏EasyX 加载图片的函数是loadimage但直接在每个文件里调用会导致同一张图被反复从磁盘读取。image.cpp 封装了统一的图片容器用图片 ID 做索引加载一次后全局复用。// image.cpp 全局图片资源管理 #include graphics.h #include map #include string static std::mapint, IMAGE g_images; // 加载指定 ID 的图片如果已加载过则直接返回避免重复读盘 IMAGE* GetImage(int id) { auto it g_images.find(id); if (it ! g_images.end()) { return it-second; } static const char* paths[] { res/mario_small.png, res/mario_big.png, res/brick.png, res/question.png, res/monster_walk.png, res/background.png }; IMAGE img; loadimage(img, paths[id]); // EasyX 从项目目录读取图片 g_images[id] img; return g_images[id]; }逻辑说明用std::map做缓存键是图片 ID值是 EasyX 的 IMAGE 对象。loadimage读取 png 文件到内存第二次走缓存直接返回指针。这个封装解决了两个问题一是避免初始化阶段加载所有图片导致启动慢二是防止局部 IMAGE 对象在函数返回后被销毁绘制时出现花屏。注意 IMAGE 对象复制有代价所以返回指针而不是值。参数说明paths数组用相对路径定位图片资源前提是图片放在项目的 res 子目录下并且“当前工作目录”是项目目录。VS 调试时工作目录默认是项目文件所在目录改为 Release 运行后可能变成 exe 所在目录这时路径就失效了。常见解决办法是在代码里根据可执行文件路径拼接资源目录或者干脆把图片放 exe 旁边这个项目直接用了相对路径所以发布时需要保持 res 文件夹和 exe 同目录。4. 马里奥的交互与怪物 AI从按键到碰撞响应的完整链路4.1 mario.cpp 的状态机尺寸变化如何影响碰撞阈值马里奥的核心玩法不在跑步而在“状态切换”。小马里奥吃蘑菇变大大马里奥吃火花发射火球受伤后从小到大再到死亡这是最经典的GAME_STATE机。mario.cpp 里的状态转移不出意外遵循下面的规则当前状态事件结果状态SMALL吃到蘑菇BIG碰撞盒变大击碎砖块SMALL碰到怪物DEAD复活为 SMALL 或直接死亡BIG吃到火花FIRE可发射火球BIG碰到怪物SMALL削减状态而非立即死亡FIRE碰到怪物SMALL火球能力消失碰撞盒的尺寸变化是最容易被忽略的细节。小马里奥占据 1×1 瓦片大马里奥占 1×2 瓦片如果碰撞检测里用了硬编码的宽高变大后一部分身体会嵌进墙里然后被自己的碰撞代码“推”出来出现抽搐效果。// mario.cpp 中马里奥碰撞盒随状态变化 void UpdateMarioCollisionBox(Mario* mario, GameState state) { switch (state) { case MARIO_SMALL: mario-box.width TILE_SIZE; mario-box.height TILE_SIZE; break; case MARIO_BIG: case MARIO_FIRE: mario-box.width TILE_SIZE; mario-box.height TILE_SIZE * 2; // 大马里奥是两倍高度 break; } }逻辑说明碰撞盒的更新必须在每帧物理更新之前完成否则位置更新使用新盒子、碰撞检测使用旧盒子会出现从不穿墙的“延迟卡顿”。这个函数在三态切换时都要调用包括受伤从大变小。参数说明TILE_SIZE 是全局常量这里直接复用来保证碰撞盒和瓦片对齐。宽度保持一个瓦片是因为马里奥的宽度始终不变高度翻倍对应视觉上的大小变化。火球马里奥和大马里奥碰撞盒相同区别只在发射火球的权限。4.2 check.cpp 的碰撞响应穿墙与踩怪的具体判定check.cpp 是整个项目里最难写对的文件。马里奥游戏需要同时支持四种碰撞左右撞墙、头顶方块、脚踩怪物/地面、下落进入管道。每种碰撞的响应方向不同一个经典的分法是把速度拆成 x 和 y 两个方向分别检测、分别修正。// check.cpp 中水平和垂直方向分离的碰撞检测 void ResolveCollision(Mario* mario, const Wall* walls, int wallCount) { // 先水平方向移动再检测并修正 mario-x mario-vx; for (int i 0; i wallCount; i) { if (IsOverlap(mario-box, walls[i].box)) { // 根据运动方向决定推到墙的哪一侧 if (mario-vx 0) mario-x walls[i].box.left - mario-box.width; else if (mario-vx 0) mario-x walls[i].box.right; mario-vx 0; // 落地后水平速度清零 } } // 再垂直方向移动检测并修正 mario-y mario-vy; bool onGround false; for (int i 0; i wallCount; i) { if (IsOverlap(mario-box, walls[i].box)) { if (mario-vy 0) { mario-y walls[i].box.top - mario-box.height; onGround true; } else if (mario-vy 0) { mario-y walls[i].box.bottom; // 这里应触发头顶方块的逻辑 } mario-vy 0; } } mario-onGround onGround; }逻辑说明先移动 x 再检测 x 方向再移动 y 再检测 y 方向这种分离方案是 2D 平台游戏的标准做法。如果在 x 和 y 同时检测马里奥斜向撞墙时会被推进墙里然后又被弹出来发生抖动。IsOverlap是简单的 AABB 矩形相交判断a.left b.right a.right b.left a.top b.bottom a.bottom b.top。参数说明vx和vy是水平与垂直速度单位是像素/帧。跳跃时vy设为负值因为 y 轴向下重力每帧给vy加一个增量直到落地。onGround标志非常重要只有在地面上才允许触发跳跃否则玩家按住跳键不放会让马里奥在空中反复弹跳。4.3 monster.cpp 的移动 AI掉头、坠落与踩踏判定怪物的 AI 在这个项目里不算复杂包括板栗仔和乌龟水平巡逻、碰到墙掉头、遇到悬崖坠落。难点在“被踩”的视觉和逻辑分离——踩到怪物的判定帧和怪物实际消失或翻转的时机要协调。// monster.cpp 三种常见怪物的行为更新 void UpdateMonster(Monster* m, const Wall* walls, int wallCount) { // 先水平移动 m-x m-direction * m-speed; // 碰撞检测撞墙则掉头 for (int i 0; i wallCount; i) { if (IsOverlap(m-box, walls[i].box)) { m-direction * -1; m-x m-direction * m-speed; // 反向再走一步避免卡在墙内 break; } } // 悬崖检测脚下没地面则掉头 bool hasGround false; for (int i 0; i wallCount; i) { if (abs(m-box.bottom - walls[i].box.top) 4 m-box.right walls[i].box.left m-box.left walls[i].box.right) { hasGround true; break; } } if (!hasGround) m-direction * -1; } // 马里奥踩到怪物时的处理 int OnStompMonster(Mario* mario, Monster* m) { m-state MONSTER_SQUASHED; mario-vy -JUMP_VELOCITY * 0.6f; // 踩到怪物后小幅度弹跳 return 100; // 得分 }逻辑说明撞墙掉头的实现里有一行很值得注意——original_x direction * speed * 2这是为了把怪物从墙里“推”出来否则下一帧IsOverlap依然为真怪物会在墙边反复翻转抽搐。悬崖检测用的是“脚下 4 像素范围内是否有地面”的容差判断比严格等于更稳定。踩踏弹跳的vy系数 0.6 是手调参数取值太大马里奥二次跳太高、太小没有打击感。参数说明MONSTER_SQUASHED状态对应的怪物流血倒地的动画帧在绘制时替换成压扁的图片。这个状态要延迟 1 秒左右再移除实体不然“刚踩到怪物就消失”会让玩家觉得判定有问题。IGN 一个经典的反馈是延迟 500ms 以上会大大提升“踩踏感”。4.4 prop.cpp 的道具生效为什么无敌星和火球的判定优先级最高道具在触发瞬间就要修改马里奥状态但状态机里有些转换不能同时发生比如无敌状态和火球状态互斥。prop.cpp 里生效逻辑的顺序决定了玩家的体验。// prop.cpp 道具生效逻辑按优先级从高到低 void ApplyProp(Mario* mario, Prop* prop) { switch (prop-type) { case PROP_FIRE_FLOWER: if (mario-state ! MARIO_SMALL) { mario-state MARIO_FIRE; // 大马里奥变成火球马里奥 } break; case PROP_SUPER_MUSHROOM: if (mario-state MARIO_SMALL) { mario-state MARIO_BIG; } break; case PROP_STAR: mario-state MARIO_STAR; // 无敌星无视其他状态 break; } }逻辑说明MARIO_STAR状态覆盖掉大马里奥或火球马里奥掉到MARIO_SMALL时无敌效果还会持续一段时间直到计时结束。这段代码的顺序刻意让对方块花火花的优先级低于无敌星否则无敌状态下吃到火花可能会被覆盖成普通火球状态。参数说明道具类型用枚举值区分后续每加一种新道具都要在case里补一个入口。如果扩展成“蘑菇同时变大和回血”需要在这个函数里加状态叠加逻辑而不是在 mario.cpp 里再做一次判断。5. 避坑记录EasyX 马里奥项目里最常见的五个翻车现场5.1 画面闪烁没有双缓冲导致残影和抖动现象游戏刚跑起来整个窗口像雪花屏一样狂闪尤其地面砖块和马里奥移动时最明显。原因EasyX 默认的绘图模式是直接写显存每帧先清屏再绘制中间过程暴露给用户就产生了闪烁。这个项目如果直接调用cleardevice后逐块画图必然闪。解决用双缓冲三件套在每帧绘制前加BeginBatchDraw绘制完成后调用FlushBatchDraw让 EasyX 把全部绘制结果一次性提交到窗口。这一条能解决 80% 的闪烁问题。void RenderFrame() { BeginBatchDraw(); // 开始双缓冲 cleardevice(); // 清空后台缓冲区 DrawBackground(); // 绘制背景层 DrawBlocks(); // 绘制砖块 DrawMario(); // 绘制马里奥 DrawMonsters(); // 绘制怪物 FlushBatchDraw(); // 一次性提交到屏幕 }5.2 坐标系混淆图片绘制位置和碰撞盒错位现象马里奥能踩到面前 10 像素远的空气或者明明碰到砖块却什么都没发生。原因EasyX 的屏幕原点在左上角y 轴向下。如果加载的图片素材里包含透明边距或者监制时用了不同的绘图原点图片视觉位置和碰撞盒的几何位置就不一致。解决在 image.cpp 的GetImage返回 IMAGE 指针时统一记录IMAGE*的宽高并在绘制调用里手动指定左上角坐标而不依赖图片自带的信息。同时在开发阶段启动一个“显示碰撞盒”的调试开关用矩形框画出所有实体边界。5.3 碰撞穿透速度过快导致直接飞过墙现象马里奥高速跑动尤其按 J 加速时偶尔整个角色穿过砖块或地面掉出地图。原因碰撞检测是离散的每帧只检测当前位置是否和墙体重叠。如果一帧里马里奥移动了超过墙体厚度的距离上一帧还没碰到、下一帧已经越过检测就漏了。跳跃下落时重力加速度让垂直速度越来越大更容易触发这种穿透。解决最暴力的办法是限制最大速度让它小于最小墙体厚度的 1/2。更规范的方案是使用“扫掠碰撞检测”把一帧内马里奥移动的路径当作一条线段检测这条线段和墙面的交点。对业余小游戏来说限制速度是最实际的做法。// 限制马里奥最大下落速度防止穿地 if (mario-vy MAX_FALL_SPEED) { mario-vy MAX_FALL_SPEED; // 典型值15 像素/帧 }5.4 按下 S 钻入管道状态判定顺序出错导致卡死现象走到管道口按 S 没反应或者马里奥“钻”进去了但整个程序卡住不动。原因进入管道的条件比较苛刻要站在管道正上方、同时按 S 和方向键、并且管道口没有被砖块堵住。如果事件监听把 S 的响应优先级放在“下蹲动画”之前马里奥永远只下蹲不穿管道。解决把“进入管道”判定提在最前满足坐标条件时直接切换到管道移动状态不再触发下蹲动画。还要保证管道内的移动有单独的循环直到完全进入后切换到下一关卡场景。5.5 中文乱码与图片路径失败现象控制台的调试输出显示方块字或者程序启动后人物和砖块全无。原因VS2022 默认项目字符集是 UnicodeEasyX 的函数用 char* 传路径中文路径和字符串会编译失败或运行时乱码。图片加载失败则通常是相对路径的当前工作目录不对。解决把项目字符集改成“使用多字节字符集”在 项目属性 → 配置属性 → 常规 → 字符集 里改。图片路径用英文目录避免编码问题同时用GetModuleFileName获取 exe 路径后再拼资源路径让程序在任意目录下启动都能找到素材。6. 进阶玩法把 1-1 改造成你自己的关卡以及两个验证技巧项目自带的 1-1、1-2、1-3 三关全通但改关卡才是这个源码最有价值的地方。刚才在 wall.cpp 里看到的地图数组直接改数字就能换地形——把 1 改成 3 就是多一根管道把 2 改成 4 就是新增一种砖块。加新关卡时我建议在InitLevel里再追加一个case 4复制 1-1 的地图数组然后改局部不要新建整个文件。验证新关卡是否合理的技巧有两招。第一招是“输入回放”手动记录一整套按键序列然后让程序自动按这个序列跑观察马里奥是否能无伤通过。做法是在 event.cpp 里加一个缓存数组把每帧action存下来跑完一次后回放。这个技巧能快速发现砖块排布导致的跳跃死角和多余的墙体。第二招是在 gamescene.cpp 里加一个调试开关按 F5 时在窗口标题显示马里奥的坐标、状态、当前帧速度这样你能直接看到马里奥在哪个位置卡住、哪段速度异常。关卡边界和状态机优化是最值得下功夫的两个方向。原版马里奥的稳定感来自细腻的手感起跳时如果有 3 帧的“土狼时间”离开平台后仍能起跳、落地时有 3 帧的“跳跃缓冲”按了跳但还没落地落地瞬间自动弹起游戏体验会完全不同。这个源码目前没有实现这两个机制改起来也不复杂——在onGround判定里加一个“给最近 3 帧保留跳跃资格”的计数器即可。另外火球发射后子弹的飞行轨迹可以加个重力影响变成抛物线命中判定和视觉效果都更丰富。// 土狼时间实现离地 3 帧内仍可跳跃 void UpdateCoyoteTime(Mario* mario) { if (mario-onGround) { mario-coyoteTimer 3; // 重置土狼时间 } else { if (mario-coyoteTimer 0) { mario-coyoteTimer--; // 倒计时 // 如果此时按了跳跃键仍然可以起跳 if (mario-jumpPressed) { mario-vy -JUMP_VELOCITY; mario-coyoteTimer 0; } } } }逻辑说明coyoteTimer初始为 0站在地面上时每一帧重置为 3离开地面后每帧减 1减到 0 之前起跳判定仍然有效。这个 3 帧的缓冲区间让玩家从平台边缘跑出去后稍晚一点按跳也能跳起来是平台游戏手感的分水岭。参数说明JUMP_VELOCITY和MAX_FALL_SPEED是对跳跃手感影响最大的两个参数。JUMP_VELOCITY建议值在 -18 到 -22 之间负号代表向上MAX_FALL_SPEED在 12 到 16 之间。调参时每次只改一个用上面说的回放功能跑同一段关卡对比否则改完手感变了都不知道是哪个参数造成的。我自己每次拿到这种源码第一件事就是把mario-x、mario-y、state全部用sprintf输出到调试文件跑一遍完整流程后打开看坐标轨迹。这个习惯帮我发现过一个隐蔽 bug管道出口的碰撞盒和绘制位置差了 8 像素角色在管道口卡了三个版本都没发现直到盯着坐标数据才看出来。项目本身跑起来很稳定三个关卡完整无报错你下载后建议先按原样编译通过再逐步改地图数组和手感参数。希望这份拆解能让你少走我在 EasyX 和碰撞检测上走过的弯路祝把你的第一关改得比原作还顺手。本文还有配套的精品资源点击获取