☰
C++ EasyX仿制超级马里奥:从游戏循环到碰撞检测的完整实战
2026/10/18 15:18:00 网站建设 项目流程

简介:基于C++与EasyX图形库还原超级马里奥玩法的项目源码包,面向具备基础C++语法、希望进阶图形界面与游戏循环开发的初学者。资源已完整实现1-1、1-2、1-3三个关卡,涵盖移动、跳跃、加速、发射火球、下蹲钻管道等核心操作,操作采用A/D移动、K跳跃、J加速/发射火球、S下蹲/钻管道,原汁原味还原经典操作方式;并配有项目说明,便于理解整体结构与编译环境配置。压缩包共239个文件,约10.6MB,其中163个png承担角色与场景素材,25个mp3和1个wav提供背景音乐与音效,21个cpp与21个h构成游戏逻辑与模块声明,另有sln与vcxproj工程文件,可在Visual Studio 2022下直接打开运行。适合用来源码学习关卡设计、碰撞检测和EasyX绘图流程,也可作为课程设计或入门游戏开发的参考。目前已有568人学习下载,对于想模仿经典平台跳跃游戏机制的开发者来说,是一份紧凑且可直接运行的实践资料。

1. 基于 C++ 与 EasyX 的超级马里奥仿制源码:能跑吗,能学到什么,坑在哪

基于 C++ 与 EasyX 图形库的超级马里奥仿制,是 C++ 入门阶段少有的「一个项目把游戏主干全串起来」的选题。拿到这个压缩包的读者通常要犹豫三件事:能不能编译、能学到什么、值不值得花一周去啃。

先给结论:值得。EasyX 不做物理、不做场景管理,它只负责把像素画到窗口上,你反而能在几百行代码里看清游戏循环的真实结构——窗口初始化、主循环、键盘输入、地图渲染、碰撞检测、重力跳跃、帧动画、敌人 AI,一个都不少。

它适合两类人:想做点能演示作品的学生,以及想验证自己 C++ 到底学没学明白的从业者。下面按这类源码最常见的组织方式,把跑通步骤、核心参数和翻车点一次讲透。

2. EasyX 窗口与游戏循环:先让主角在 800×600 画布里动起来

拿到 zip 之后第一件事不是读代码,而是确认它能不能编译。EasyX 程序只能在 Windows 环境下编译运行,因为 initgraph 底层走的是系统 GDI 接口,换到 Linux 或 macOS 上用什么编译器都起不来。常见做法是新建一个空的控制台项目,把源码里的 .cpp 文件和图片资源放进项目目录,装好 EasyX 后直接编译。这类源码包附带的项目说明文档,开头两页一般就是环境要求和按键约定:左右方向键移动、空格跳跃,照着配置比对着代码猜省事得多。

2.1 initgraph 与双缓冲:为什么这几行是画面稳定的地基

initgraph 会创建一个窗口并建立绘图设备上下文,窗口默认左上角是坐标原点,x 轴向右,y 轴向下。这个 y 轴方向在后面写跳跃、写角色下落时非常容易把人绕进去,切记「向下为正」。分辨率的选择直接决定地图大小和角色比例:800×600 配合 50 像素的砖块,地图可以有 16×12 格;如果你改成 1024×768,角色和砖块都要等比放大,否则画面中间会空一大片。

#include <graphics.h> #include <conio.h> int main() { // 初始化 800x600 的游戏窗口 initgraph(800, 600); // 双缓冲:所有绘制先写到后台缓冲区 BeginBatchDraw(); while (!_kbhit()) // 任意键退出游戏 { cleardevice(); // 用背景色清掉上一帧,否则会拖残影 // 绘制一帧:先用矩形占位,后续换成贴图 solidrectangle(100, 300, 140, 340); // 把后台缓冲一次性刷到屏幕 FlushBatchDraw(); Sleep(16); // 近似 60 FPS 的帧间隔 } EndBatchDraw(); // 结束批量绘制 closegraph(); // 关闭窗口并释放 GDI 资源 return 0; }

这是整份源码的最小骨架,四件事各就各位:initgraph 开窗口,BeginBatchDraw 开后台缓冲,循环体里清屏、绘制、刷屏,退出时 EndBatchDraw 配 closegraph 收尾。BeginBatchDraw 和 EndBatchDraw 必须成对出现,否则系统资源不会正常回收。cleardevice 会按 setbkcolor 设置的背景色填充全屏,漏掉它,上一帧的角色会拉出一串残影。Sleep(16) 只是近似值,Windows 的定时器实际会多睡 1~2ms,跑久了画面明显偏慢,所以帧节奏要交给 2.2 的时间戳来校准。

参数上有两个习惯建议直接养成:把窗口宽高提成常量,比如const int WIN_W = 800; const int WIN_H = 600;,后面所有地图换算、相机偏移都依赖这两个值;写矩形坐标时按「左上角 + 宽高」的思维组织,别直接用两个对角点,这样和后面碰撞用的 Box 结构能对上。

2.2 键盘输入与帧率校准:马里奥的响应感从哪来

EasyX 本身没有封装按键事件,常见做法是直接调 Windows API。_kbhit只能判断「有没有按」,不能判断「按了什么」,所以用它做退出判断;角色移动需要按帧采样每个键的状态,用 GetAsyncKeyState 最直接。

#include <windows.h> int playerX = 100, playerY = 300; // 主角位置 const int SPEED = 4; // 水平速度:像素/帧 DWORD lastFrame = GetTickCount(); while (!_kbhit()) { // 0x8000 表示该键此刻处于按下状态 if (GetAsyncKeyState(VK_LEFT) & 0x8000) playerX -= SPEED; if (GetAsyncKeyState(VK_RIGHT) & 0x8000) playerX += SPEED; // 按真实时间刷新,而不是无脑 Sleep DWORD now = GetTickCount(); if (now - lastFrame >= 16) { cleardevice(); solidrectangle(playerX, playerY, playerX + 40, playerY + 40); FlushBatchDraw(); lastFrame = now; } }

GetAsyncKeyState 返回的是一个短整型,最高位为 1 表示按键正被按下,所以必须和 0x8000 做位与。只写if (GetAsyncKeyState(VK_LEFT))会把「历史按没按过」也当成真,表现就是松开方向键角色还在跑——这是输入处理里最常见的翻车点。SPEED 单位是像素/帧,60 帧下 4 像素约等于每秒 240 像素,1 秒横穿三分之一屏,这个速度感基本接近马里奥原版的「轻快」;调到 2 像散步,调到 8 以上碰撞就容易被单帧位移穿透(第五章会专门讲)。

用 GetTickCount 校准帧率有两个直接收益:一是 Sleep(16) 的累计误差被消掉,二是「更新逻辑」和「绘制」解耦之后,切动画帧、计分、刷敌人全都以 now 这个时间轴为准,而不是以循环次数为准。注意 GetTickCount 是毫秒级定时器,做帧率统计精度不够,第六章会给出替代方案。

3. 地图数组与碰撞检测:仿制马里奥的物理地基怎么搭

角色能不能跑起来取决于循环,跑起来之后像不像马里奥,全看地图和碰撞这两块。砖块、管道、台阶的位置在源码里基本都是一张二维数组地图,碰撞交给 AABB 矩形重叠判定。这两块合起来占了整个项目接近一半的代码量,也是最容易做出「能看不能玩」效果的部分。

3.1 用二维数组把砖块变成数据:地图渲染的最小命令

地图用 int 二维数组存,每一格对应一个图块。约定一般写在整个文件开头的注释里:0 是空白,1 是地面,2 是砖块,3 是问号箱。渲染时把数组坐标换算成屏幕坐标,逐个画矩形。

const int ROWS = 12, COLS = 16; // 地图 12 行 x 16 列 const int TILE = 50; // 每格 50x50 像素 int map[ROWS][COLS] = { {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,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,1,1,1,1,1,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,0,0,0,1,1,1,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 < ROWS; row++) { for (int col = 0; col < COLS; col++) { if (map[row][col] == 0) continue; // 空白不画 int x = col * TILE; // 列号 -> 屏幕 x int y = row * TILE; // 行号 -> 屏幕 y setfillcolor(map[row][col] == 1 ? RGB(180, 90, 40) : RGB(220, 180, 60)); solidrectangle(x, y, x + TILE, y + TILE); } }

两个换算关系必须刻进脑子:屏幕 x = 列号 × TILE,屏幕 y = 行号 × TILE。行号越大越靠下,正好和 EasyX 的 y 轴向下一致。二维数组的优点在于关卡结构是可视化的,你在文本里能直接看出断层、阶梯和空档,改关卡等于改数字;缺点是不能表达斜坡和不规则地形,真要做斜坡得额外引入一类「斜坡图块」,在碰撞里单独写斜率判断。

TILE 这个参数直接决定角色比例。马里奥角色大约占两格高度,所以 TILE 取 50 时,角色矩形建议宽 30~40、高 70~80,跑起来才协调。TILE 太小(比如 25)会把碰撞误差放大,踩砖边时经常卡一下;太大(80 以上)会让角色显得很小。新项目统一从 50 起步,等手感稳定了再动。

3.2 AABB 碰撞检测与移动顺序:穿墙和卡砖的根源在这

碰撞检测的选型不用纠结:像素级碰撞最精确,但你不可能手工维护马里奥每一帧的像素轮廓;Tile 级碰撞是本次仿制真正该用的方案。原理是 AABB——把角色和砖块都视为轴对齐矩形,判断两个矩形是否重叠。

struct Box { float x, y, w, h; }; // 左上角坐标 + 宽高 bool AABB(const Box& a, const Box& b) { // 两个矩形在 x 和 y 方向同时有交集才算碰撞 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 一起更新,再统一检测一次——角色斜向撞到墙角时,程序没法判断该回退 x 还是回退 y,表现出来就是卡砖和穿墙。

float speedX = 4; // 水平速度 float speedY = 0; // 垂直速度 // 先水平移动,水平撞到砖块就回退 x 到砖块边缘 player.x += speedX; if (撞到砖块) { if (speedX > 0) player.x = 砖块左边 - player.w; else player.x = 砖块右边; speedX = 0; } // 再垂直移动,垂直撞到砖块就吸附到砖块上下沿 player.y += speedY; if (撞到砖块) { if (speedY > 0) player.y = 砖块上边 - player.h; // 落地 else player.y = 砖块下边; // 顶头 speedY = 0; }

顺序必须是先水平后垂直,反过来会出现贴着墙往下滑的怪手感。回退时直接赋值到砖块边缘,而不是「减去这次的位移增量」,是为了杜绝下一帧仍重叠造成的抖动。检测范围不需要全图扫描,只检查角色周围的 3×3 格子就够了,地图拉长到几百列之后这个习惯能省下大量无效计算。

提示:分轴检测的顺序不要反过来。先处理水平再处理垂直,否则下落时贴着墙面会有明显的吸附感。

3.3 重力与跳跃:马里奥的「轻」是参数调出来的

重力不需要引擎,两句搞定:每帧给垂直速度加一个常量,然后用速度更新 y。y 轴向下为正,所以起跳瞬间垂直速度是一个负数。

float vy = 0; // 垂直速度,向下为正 bool onGround = false; // 是否站在砖块上 const float GRAVITY = 0.5f; // 重力加速度 const float JUMP_VY = -12.5f; // 起跳初速度 // 按下跳跃键且在地面时,给一个向上的初速度 if ((GetAsyncKeyState(VK_SPACE) & 0x8000) && onGround) vy = JUMP_VY; vy += GRAVITY; // 每帧重力累加到速度 player.y += vy; // 速度驱动位置 if (撞到地面) { vy = 0; onGround = true; }

这套参数可以直接抄,抄完按下面的表微调:

参数推荐范围效果说明
GRAVITY0.4~0.6越大下落越猛,落点越难预判
JUMP_VY-11~-13绝对值越大跳得越高
最大跳跃高度约 v²/(2g)-12.5 与 0.5 组合约 156 像素

156 像素差不多是三格砖块的高度,这正是马里奥能跳上两层台阶、又不至于蹦到三层之上的关键。JUMP_VY 小于 -16 会变成离谱高跳,大于 -9 连一格砖都上不去。调手感别靠感觉,把这两个参数提成常量,每次改完跑一段三级台阶测试关,几秒就能判断合不合适。进阶一点的仿制还会做「按多久跳多高」:跳跃键一旦松开,就把 vy 乘以一个 0.5 左右的衰减系数,形成短按低跳、长按高跳的原版手感。

4. 角色帧动画与敌人 AI:从贴图切帧到横向卷轴的进阶代码

地图和碰撞解决的是「能不能走」,动画和敌人解决的是「像不像游戏」。马里奥的质感来自帧动画的节奏感和敌人巡逻的机械感,两段代码都不长,但细节差一点观感就差很多。

4.1 loadimage 与精灵帧切换:走路动画的两种实现

EasyX 里图片用 IMAGE 对象管理,loadimage 负责从文件加载。走路动画无非是「每隔几十毫秒换一帧」,四帧循环就够。

#include <graphics.h> IMAGE imgWalk[4]; // 四帧走路贴图 // 加载并统一缩放到 40x70,省去手动改图 loadimage(&imgWalk[0], _T("walk_0.png"), 40, 70); loadimage(&imgWalk[1], _T("walk_1.png"), 40, 70); loadimage(&imgWalk[2], _T("walk_2.png"), 40, 70); loadimage(&imgWalk[3], _T("walk_3.png"), 40, 70); int frame = 0; // 当前帧索引 DWORD lastAnim = 0; // 上次切帧的时间 // 每 120ms 切一帧,四帧循环成走路 if (GetTickCount() - lastAnim > 120) { frame = (frame + 1) % 4; lastAnim = GetTickCount(); } // 绘制到角色当前位置 putimage(player.x, player.y, &imgWalk[frame]);

切帧时间不是固定值:普通行走 120ms 合适,跑动状态压到 80ms 更有急促感,跳跃状态干脆不切帧,固定显示一张起跳图。也就是说动画帧要跟着状态机走,而不是无脑按时间循环。loadimage 的后两个参数会把素材缩放成 40×70,注意如果原图比例不是 1:1.75 左右,强行缩放会把人拉变形,素材本身比例正常时这个参数才划算。_T("")宏是为了兼容 Unicode 字符集,项目字符集没选 Unicode 的话,带_T的代码编译直接报错。

左右翻转是个容易踩的坑:EasyX 的 putimage 并不自带镜像参数,常见做法是素材准备左右两套,或者加载后自行翻转生成镜像 IMAGE。不要指望传负宽度解决问题,不同版本行为不一致。还有一类报错是图片路径问题,loadimage 的工作目录是项目目录,图片放在与 .cpp 相同的目录里最省心。

4.2 敌人巡逻 AI 与横向卷轴相机:两段值得反复抄的代码

蘑菇怪的 AI 只需要两行逻辑:往前走,碰墙掉头。它不追人、不跳,只按固定方向巡逻。这是平台跳跃敌人 AI 的起点,后面加追踪、加飞行都在这个骨架上扩展。

struct Enemy { float x, y; int dir; // 1 向右,-1 向左 int alive; // 0 表示被踩扁 }; void UpdateEnemy(Enemy& e, float speed) { if (!e.alive) return; // 踩扁后不再更新 e.x += e.dir * speed; // 沿当前方向移动 // 碰到砖块或走到地图边缘就掉头 if (碰到砖块(e) || 即将走出地图边界) e.dir = -e.dir; }

敌人碰撞直接复用第三章的 AABB,只是把角色矩形换成敌人的矩形。敌人和角色必须共用同一套 TILE 比例,否则画面上会出现敌人大过砖块的违和感。被踩中时置 alive = 0 并从绘制里跳过,比从对象数组里删除更稳妥——删除会改变数组下标,遍历时容易跳过后面的敌人。

横向卷轴是让仿制品「一眼像成品」的关键,原理却极其简单:所有物体的世界坐标不变,绘制时统一减去一个相机偏移量。

// 相机偏移:让主角维持在窗口水平中心附近 int cameraX = player.x - 400; if (cameraX < 0) cameraX = 0; // 第一屏不滚动 // 绘制砖块和角色时,全部减去 cameraX solidrectangle(col * TILE - cameraX, row * TILE, (col + 1) * TILE - cameraX, (row + 1) * TILE); putimage(player.x - cameraX, player.y, &imgWalk[frame]);

cameraX 取 player.x - 400,等于让主角永远落在窗口约 400 像素处。这个值和窗口宽度一半对齐时手感最稳,设成 0 画面就不滚。这里有一条铁律要守住:碰撞检测用世界坐标,绘制用减去 cameraX 后的屏幕坐标。很多仿制项目最后砖块乱跳、角色错位,都是把屏幕坐标拿去做碰撞了。地图很长时,二维数组全部驻留内存也不划算,第六章会给关卡外置的方案。

5. 超级马里奥仿制避坑指南:4 个让 EasyX 项目翻车的典型问题

这章写给已经能编译、但玩起来不对劲的人。四个问题都是我在帮人调这类仿制时反复遇到的,按出现频率排序,每条按现象、原因、解决三步说清。

5.1 画面闪烁、拖影严重

现象:角色和砖块边缘抖动,快速移动时拖出半透明残影,整体观感像幻灯片。

原因:没有使用 BeginBatchDraw/FlushBatchDraw,或者 FlushBatchDraw 放到了错误位置;也可能是循环体内没有先 cleardevice,上一帧内容没清掉,新旧画面叠在一起。

解决:BeginBatchDraw 放主循环开始前,EndBatchDraw 放主循环结束后,循环体内只做绘制和一次 FlushBatchDraw。检查清屏是否在每帧绘制之前,确认 FlushBatchDraw 在 Sleep 之前调用——先睡再刷屏,等待时间会被算进画面显示,体感掉帧。如果残影只出现在角色身上,多半是 putimage 的背景没有做成透明,黑色底框把上帧的残影盖住了,做透明贴图时要先处理素材抠底。

5.2 穿墙与卡砖

现象:速度调大后角色偶尔穿过砖块;下落到砖边时卡在侧面上下抖动。

原因:单帧位移接近或超过砖块厚度时,角色这一帧的位置已经「越过」砖块,AABB 检测不到重叠。卡砖抖动是回退时没吸附到边缘,而是按原位移反弹,下一帧又嵌进砖块,形成振荡。

解决:把水平速度上限控制在 TILE/3 以内(TILE 取 50 时,速度建议 5~8);坚持「先水平移动并检测,再垂直移动并检测」的顺序;回退时直接赋值到砖块边缘坐标,而不是减去位移增量。改完速度记得同步调整跳跃的水平位移量,否则跳得越快越容易穿墙。

5.3 按键失灵或角色自己跑

现象:松开方向键角色还在走;跳跃偶尔没反应;按住空格一直连跳。

原因:GetAsyncKeyState 没与 0x8000 做位与,把历史按下状态也当成了当前状态;跳跃逻辑用了「按住触发」而非「按下沿触发」,按住空格自然变成连跳。空中不允许起跳不是 bug,是设计;但如果想要二段跳,需要在空中重置跳跃计数。

解决:所有按键统一写成(GetAsyncKeyState(key) & 0x8000);跳跃改为按下沿检测——记录上一帧该键状态,当前帧从「未按」变「按下」才算一次有效跳跃。这招对顶砖块、发射子弹这类「只触发一次」的逻辑同样适用,值得记在笔记里。

5.4 图片加载失败与窗口大小异常

现象:窗口能开,角色却是黑块或者空白;有的机器上窗口大小和设置不一致。

原因:loadimage 的工作目录和源码目录不一致,相对路径找不到文件;图片格式不是 EasyX 支持的 BMP 或常见 PNG 变体。窗口异常多半是系统显示缩放把逻辑分辨率放大了,initgraph 的参数被 DPI 缩放乘了一道。

解决:图片统一放到与 .cpp 相同的项目目录,用相对路径加载;素材转成 24 位 BMP 或标准 PNG。窗口缩放问题在项目属性里把 DPI 缩放设为由应用程序控制,窗口就能按 initgraph 的参数严格显示。这个坑很隐蔽,换一台显示器就出现,排查时先看系统缩放再看代码。

6. 从复现到魔改:存档点、关卡文件外置与帧率验证方法

把前面几章拼起来,你已经有了一个「能玩一关」的仿制品。但要交作业,或者想拿到面试桌上讲,还差三件小事:存档点、关卡外置、帧率验证。每件都不难,但每件都能让作品完成度上一个台阶。

6.1 存档点:给死亡惩罚一颗后悔药

没存档的仿制游戏,死后从第一关重来,演示时很容易尴尬。最简存档是把关键状态存成一个结构体,内存里存一份就够用。

struct SaveData { float x, y; int level; int lives; }; SaveData save; // 碰到旗杆或终点时记录当前状态 save.x = player.x; save.y = player.y; save.level = currentLevel; save.lives = lives; // 角色死亡时直接读回 player.x = save.x; player.y = save.y;

参数说明:内存存档足够单次演示;想要「关掉程序再打开还在」,就把 save 按文本写入文件,下次启动时解析三个数字。存档点位置建议放在每关第一个高台阶之后,那是玩家最容易掉血的区域,后悔药给到位,别人才愿意继续试你的关卡。

6.2 关卡外置与帧率验证:让作品经得起复现

map 数组写死在代码里,改一次关卡要重编译一次。把地图存成文本文件,每行 16 个数字、逗号分隔,启动时用 ifstream 读进数组,你立刻拥有一个最简关卡编辑器。帧率验证别用 GetTickCount,毫秒精度不够;用 clock() 或 QueryPerformanceCounter,连续跑 300 帧取平均耗时,单帧低于 16ms 就算稳定。

验证清单:

  • 踩砖边缘不抖动,连跳 10 次不穿墙;
  • 从最高处坠落不掉出地图底部;
  • 按住方向键同时跳跃,不出现位移漂移;
  • 落地瞬间垂直速度清零,不出现「弹簧脚」。

最后说句实在话:我做这类仿制时,也曾在碰撞上栽过跟头,后来老老实实分轴移动、分轴检测,才发现重力本质就是速度的累加。养成「先更新逻辑、后统一绘制」的习惯,以后换任何框架都不吃亏。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询