简介:这份源代码工程将Visual C++与开源2D引擎HGE结合,完整重现了超级玛丽的核心玩法,适合希望学习2D游戏框架搭建、对象管理和事件驱动的开发者研读。压缩包共183个文件,除可直接运行的exe和依赖dll外,还包含png/bmp/gif图像素材、wav/mod音效、hpp/h头文件、cpp源码以及dat/ent配置实体文件,整体约8.93MB,目录结构清晰,方便对照代码与资源。已有719人学习下载。代码覆盖HGE环境下的游戏主循环、渲染与帧同步、物理碰撞检测、输入响应、音频播放及状态机管理,并演示了如何用资源管理器加载和复用图片、声音。对想通过经典马里奥项目掌握DirectX/HGE开发路径的读者而言,这是一份不错的实战参照。
1. visual c++与HGE游戏引擎的超级玛丽源代码:一份可以直接拆的2D游戏教材
第一次打开这个工程时,我习惯性地先把里面的bmp文件看了一遍:splash.bmp、gameover.bmp、pic3.bmp,再加上1.5.bmp,一个经典马里奥游戏的画面闭环已经摆在眼前。这是一套用visual c++配合HGE游戏引擎开发的超级玛丽(超级马里奥)完整源代码,核心是用DirectX做2D渲染,把游戏循环、输入、碰撞、动画这些最基础也最关键的模块,全部落到了能编译的C++工程里。它适合两类人:一类是刚学完C++语法、想搞清楚“游戏到底怎么跑起来”的新手;另一类是手上有老VC工程、需要快速理解HGE引擎组织方式的开发者。接下来,我从引擎初始化拆到具体逻辑,给你一份可以直接照着跑起来的实战笔记。
2. 把HGE引擎先跑起来:初始化、回调函数与老工程的构建脚本
HGE(Highly Efficient Game Engine)是一款基于DirectX的2D游戏引擎,它替你管好了窗口创建、DirectX设备初始化、消息循环和资源加载,你只需要回答“每一帧游戏逻辑做什么”和“每一帧画什么”。这套源码的入口不复杂,甚至有点复古:main函数里几行初始化,剩下的逻辑全在回调函数里。
2.1 hgeCreate到System_Start:引擎初始化这十行不能省
先看最典型的HGE入口代码,几乎每个HGE工程都是这个结构:
// main.cpp #include <hge.h> hge* hge = 0; // 引擎全局指针,所有接口都通过它调用 bool WINAPI Initialize(); // 初始化游戏资源 bool WINAPI FrameFunc(); // 每帧逻辑回调 bool WINAPI RenderFunc(); // 每帧渲染回调 void WINAPI Cleanup(); // 退出时清理资源 int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int) { hge = hgeCreate(HGE_VERSION); // 创建引擎实例 // 设置引擎行为:窗口模式 + 800x600分辨率 hge->System_SetState(HGE_WINDOWED, true); hge->System_SetState(HGE_SCREENWIDTH, 800); hge->System_SetState(HGE_SCREENHEIGHT, 600); hge->System_SetState(HGE_TITLE, "Super Mario - HGE"); hge->System_SetState(HGE_FRAMEFUNC, FrameFunc); hge->System_SetState(HGE_RENDERFUNC, RenderFunc); hge->System_SetState(HGE_LOGFILE, "hge.log"); if (hge->System_Initiate()) { // 初始化DirectX Initialize(); // 加载游戏资源 hge->System_Start(); // 进入游戏循环 } else { // 初始化失败,日志文件hge.log里会写原因 MessageBoxA(0, "Initiate failed, check hge.log", "HGE", MB_OK); } Cleanup(); // 清理资源 hge->System_Shutdown(); // 关闭引擎 hge->Release(); // 释放引擎实例 return 0; }HGE_VERSION是引擎版本的宏,hgeCreate返回一个全局引擎指针,之后所有功能都从hge指针上调。System_SetState是HGE的配置入口,参数很多,但最关键的就是上面这几个:窗口模式、分辨率、标题、逻辑回调、渲染回调和日志路径。这套源码里没有用resource.xml做资源管理,而是直接在Initialize里一份份加载位图,这是老HGE工程的常见做法。
注意System_Initiate内部会创建DirectX设备,这一步失败多半是显卡驱动不兼容或者窗口模式设置冲突。System_Start才是真正进入循环的地方,它会在内部反复调用你设置好的FrameFunc和RenderFunc,直到FrameFunc返回true为止。
2.2 FrameFunc与RenderFunc:HGE驱动游戏的方式就是反复调两个回调
HGE没有暴露完整的游戏循环代码,但你可以把FrameFunc理解成“游戏主循环的每一帧”。马里奥的移动、跳跃、敌人AI、碰撞检测,全都在这个函数里写。RenderFunc则专门负责绘图。
// FrameFunc:每帧逻辑 bool WINAPI FrameFunc() { // 按ESC退出游戏 if (hge->Input_KeyDown(HGEK_ESCAPE)) return true; // dt是这一帧经过的秒数,所有运动量都乘以它 float dt = hge->Timer_GetDelta(); // 更新马里奥速度、位置、碰撞…… UpdateMario(dt); UpdateEnemies(dt); CheckCollisions(); return false; // 返回false继续循环,返回true退出 } // RenderFunc:每帧渲染 bool WINAPI RenderFunc() { hge->Gfx_BeginScene(); // 开始一帧的绘制 hge->Gfx_Clear(0xFF000000); // 清屏为黑色 DrawBackground(); // 画背景 DrawTiles(); // 画砖块和地面 DrawEnemies(); // 画敌人 DrawPlayer(); // 画马里奥 DrawHUD(); // 画分数和生命 hge->Gfx_EndScene(); // 结束这一帧 return false; }逻辑和渲染分开是HGE的约定,不是强制。关键是理解dt的作用:HGE默认不锁帧,直接跑满显示器刷新率。如果运动量不乘dt,同样的移动速度在60Hz和120Hz显示器上就是两倍差距。Timer_GetDelta返回的是上一帧到这一帧的秒数,单位是秒,所有速度参数都应该按“像素/秒”来设计。
2.3 mk.bat与cp.bat:旧VC工程靠两个批处理完成构建
这套源码里没有.sln解决方案文件,构建靠的是mk.bat和cp.bat两个批处理,这是当年VC6时代很常见的工程组织方式。
@echo off rem cp.bat:把游戏资源复制到exe输出目录 copy /Y pic3.bmp Release\pic3.bmp copy /Y splash.bmp Release\splash.bmp copy /Y gameover.bmp Release\gameover.bmp copy /Y 1.5.bmp Release\1.5.bmp @echo off rem mk.bat:用命令行完成编译链接 cl /nologo /GX /O2 /I "include" main.cpp /link /out:Mario.execp.bat负责把bmp资源拷到Release目录,mk.bat用cl命令直接编译。这种脚本最大的坑是工作目录:如果你在调试器里F5运行,工作目录默认是工程文件所在目录,资源能加载到;如果直接双击exe,工作目录变成exe所在目录,资源路径就对不上了。
| 文件 | 常见用途 |
|---|---|
| splash.bmp | 启动/标题画面 |
| gameover.bmp | 游戏结束画面 |
| pic3.bmp | 马里奥、敌人、道具的精灵图集 |
| 1.5.bmp | 背景或地形贴图,命名风格类似“关卡1.5” |
| cp.bat | 资源拷贝脚本 |
| mk.bat | 编译链接脚本 |
我一般拿到这类老工程,第一步不是打开IDE,而是先看资源清单和批处理内容,确认哪些bmp是必须存在的。这份源码的资源依赖很朴素,没有打包成HAP资源包,所以缺一个bmp就会直接导致Texture_Load返回0。
3. 把马里奥的移动、跳跃与碰撞写成代码:从按键到砖块判定的完整链路
这一章是游戏玩法的核心。超级玛丽的玩法本质上是三件事:读取输入、更新物理、检测碰撞。HGE提供了干净的输入接口,物理和碰撞则需要你按游戏逻辑自己组织。
3.1 Input_KeyDown与Input_GetKeyState:区分“按下瞬间”和“按住不放”
HGE输入系统有两类判断:Input_KeyDown判断按键被按下的那一瞬间,Input_GetKeyState判断按键当前是否处于按住状态。跳跃用KeyDown,左右移动用GetKeyState,这是2D平台游戏的基本分工。
// 输入与移动控制 void UpdateMario(float dt) { // 左右移动:按住才生效 if (hge->Input_GetKeyState(HGEK_LEFT)) { mario.vx = -MOVE_SPEED; } else if (hge->Input_GetKeyState(HGEK_RIGHT)) { mario.vx = MOVE_SPEED; } else { mario.vx = 0.0f; } // 跳跃:起跳瞬间判断一次,必须在onGround状态 if (hge->Input_KeyDown(HGEK_SPACE) && mario.onGround) { mario.vy = -JUMP_SPEED; mario.onGround = false; // 离开地面,禁止二段跳 } // 水平方向先移动,再做碰撞修正 mario.x += mario.vx * dt; }关键参数:MOVE_SPEED是横向移动速度,典型值在160到240像素/秒之间,太小了马里奥走不动,太大了砖块碰撞会穿透。onGround标志位用来限制跳跃次数,马里奥只有站在地面上才能起跳,这是平台游戏防“空中二段跳”的基本做法。
3.2 重力与跳跃参数:为什么跳起来的高度刚好是两块砖
2D平台游戏的重力实现很简单:每帧给垂直速度叠加一个重力加速度,再把速度应用到位置。但参数选得不合适,跳跃手感会非常奇怪。
// 物理参数 const float GRAVITY = 1500.0f; // 重力加速度:像素/秒^2 const float MOVE_SPEED = 180.0f; // 横向移动速度:像素/秒 const float JUMP_SPEED = 520.0f; // 起跳初速度:像素/秒 void UpdatePhysics(float dt) { // 垂直方向积分:速度 += 重力 * 时间 mario.vy += GRAVITY * dt; mario.y += mario.vy * dt; // 触地判定:马里奥底部碰到底面,重置onGround if (mario.y + MARIO_H >= groundY) { mario.y = groundY - MARIO_H; mario.vy = 0.0f; mario.onGround = true; } }| 参数 | 单位 | 常见取值 | 说明 |
|---|---|---|---|
| GRAVITY | 像素/秒² | 1200~1800 | 重力加速度,影响下落速度 |
| MOVE_SPEED | 像素/秒 | 160~240 | 横向移动速度 |
| JUMP_SPEED | 像素/秒 | 480~560 | 起跳初速度 |
| MARIO_W/H | 像素 | 24×44 | 碰撞盒宽度和高度 |
跳跃高度的物理公式是 h = v² / (2g)。把JUMP_SPEED=520、GRAVITY=1500代进去:
520² / (2 × 1500) ≈ 90.1 像素如果一块砖高32像素,马里奥跳约2.8块砖高,这就是超级玛丽经典的跳跃幅度。调手感时先改JUMP_SPEED和GRAVITY这两个值,不要动碰撞盒尺寸,这是我调参数的血泪经验。
3.3 与瓦片地图的碰撞检测:一次只修正一个轴
超级玛丽的地图是典型的瓦片地图,砖块、地面、管道都铺在一个二维数组上。碰撞检测的核心思路是把马里奥的像素碰撞盒映射到瓦片格子上,检查覆盖到的格子是否有砖块。
const int TILE_W = 32; const int TILE_H = 32; const int MAP_W = 64; const int MAP_H = 15; // 取格子的砖块类型,0表示空地,非0表示有砖块 int TileAt(int tx, int ty) { if (tx < 0 || tx >= MAP_W) return 1; // 地图边界视为墙 if (ty < 0 || ty >= MAP_H) return 0; return tileMap[ty][tx]; } // 检测马里奥碰撞盒覆盖了哪些格子 bool CheckMarioVsMap() { int tx0 = (int)(mario.x / TILE_W); int tx1 = (int)((mario.x + MARIO_W - 1) / TILE_W); int ty0 = (int)(mario.y / TILE_H); int ty1 = (int)((mario.y + MARIO_H - 1) / TILE_H); for (int ty = ty0; ty <= ty1; ty++) { for (int tx = tx0; tx <= tx1; tx++) { if (TileAt(tx, ty) != 0) return true; } } return false; }这段代码的问题是只告诉你“有没有碰上”,没告诉你“从哪边碰上的”。实际工程里我一般把碰撞拆成两步:先做水平移动,检测一次碰撞;如果碰撞了,把x方向的位置修正回碰撞前,同时把vx清零;再做垂直移动,检测一次,修正y。这样能避免一次移动xy两个轴时,马里奥被砖块“卡”在墙里的问题。先处理哪个轴取决于游戏逻辑——俯视角游戏通常先处理移动方向,平台游戏我习惯先处理垂直方向,让下落撞头的判定更准确。
4. 精灵、动画与镜头:pic3.bmp怎么变成会跑的超级玛丽
HGE把2D渲染封装成了精灵(Sprite)和动画(Animation)两个类。马里奥的奔跑、跳跃、死亡动作不可能每次重画一张图,而是从一张图集里裁出不同区域来切换,这也是pic3.bmp存在的意义。
4.1 Texture_Load与hgeSprite:把位图切成可复用的精灵
HGE的典型流程是先用Texture_Load把bmp加载为纹理,再用hgeSprite截取纹理的某一块区域作为精灵。
// 加载马里奥所在图集 HTEXTURE tex = hge->Texture_Load("pic3.bmp"); if (tex == 0) { // 加载失败,多半是文件不存在或工作目录不对 return; } // 从图集(0, 0)位置截取32x48区域作为站立帧 hgeSprite* marioIdle = new hgeSprite(tex, 0, 0, 32, 48); // 从图集(32, 0)位置截取另一块作为行走第一帧 hgeSprite* marioWalk1 = new hgeSprite(tex, 32, 0, 32, 48);hgeSprite构造函数的四个参数是图集坐标和宽高:(tx, ty, w, h),单位是像素。关键是把图集坐标量准,截歪了会出现马里奥脑袋上顶着一块砖的诡异画面。我给位图做精灵时一般会在测试里直接渲染一个带边框的调试精灵,确认裁剪区域正确再做动画。
4.2 hgeAnimation帧动画:FPS参数决定走路节奏
逐帧手写切换太麻烦,HGE提供了现成的hgeAnimation类,你只需要指定帧数、帧率和回放模式。
// 8帧行走动画,12帧/秒,循环播放 hgeAnimation* walkAnim = new hgeAnimation( tex, // 纹理 8, // 帧数 12.0f, // 播放帧率(FPS) 0, 48, // 起始坐标 (x, y) 32, 48, // 每帧宽高 HGEANIM_LOOP // 循环模式 ); walkAnim->SetMode(HGEANIM_LOOP); // 循环播放行走动画 // 每帧在FrameFunc里更新动画 void UpdateMario(float dt) { if (mario.vx != 0.0f) { walkAnim->Update(dt); // 走路时播放动画 } else { walkAnim->SetFrame(0); // 停下时回到站立帧 } }hgeAnimation的参数顺序容易记混:纹理、帧数、播放速度、起始坐标、宽高、模式。SetFrame(0)是停住动画的常用手段。播放速度12FPS意味着每1000/12毫秒切换一帧,这个值决定马里奥走路的急切程度,过快看起来像在跑,过慢像在滑冰。
4.3 渲染顺序与摄像机偏移:为什么马里奥永远在画面中间
渲染顺序决定遮挡关系,2D游戏的铁律是:先画背景,再画地面和砖块,然后画敌人,最后画玩家。如果顺序颠倒,马里奥会被砖块盖住。
bool WINAPI RenderFunc() { hge->Gfx_BeginScene(); hge->Gfx_Clear(0xFF1E90FF); // 天空蓝 // 摄像机偏移:让马里奥保持在画面中心 float cameraX = mario.x - SCREEN_W / 2; if (cameraX < 0) cameraX = 0; float maxX = MAP_W * TILE_W - SCREEN_W; if (cameraX > maxX) cameraX = maxX; // 按顺序绘制 DrawGround(cameraX); DrawTiles(cameraX); DrawEnemies(cameraX); walkAnim->Render(mario.x - cameraX, mario.y - cameraY); DrawHUD(); hge->Gfx_EndScene(); return false; }摄像机偏移的实现很简单:没有真正的摄像机,只是把所有物体的渲染坐标都减去同一个偏移量cameraX。马里奥位置减去偏移后正好等于屏幕中心,看起来就是摄像机在跟着他走。关卡边界要限制偏移范围,否则开局和通关时画面会越过地图边缘露出黑色背景。这份源码里应该也有类似的限制逻辑,没有的话加上这个判断基本就不会穿帮。
5. 避坑:重新编译这套超级玛丽源码时最容易翻车的五个位置
老HGE工程在今天的Visual Studio上重编,问题比新工程多得多。下面是几个高频翻车点,每一条都是能编译通过但跑不起来或者资源加载失败的典型场景。
5.1 现象:hge.h和hge.lib无法找到
编译器报fatal error C1083: Cannot open include file: 'hge.h': No such file or directory。链接器报无法打开hge.lib。
原因:HGE引擎的头文件和库文件没有加入编译器搜索目录。老工程通常用相对路径引用引擎包,一旦换目录就找不到。
解决:拿到引擎包后,先把include和lib目录的绝对路径记下来,在项目属性里配置两个位置:C/C++ → 常规 → 附加包含目录填include路径;链接器 → 常规 → 附加库目录填lib路径。然后把hge.dll复制到exe同级目录。HGE老库多为32位,项目平台要选Win32,不要用x64。
5.2 现象:编译通过,运行后黑屏闪退,没有任何提示
启动后窗口一闪而过,或者一直是黑屏。
原因:HGE初始化DirectX设备失败。这是HGE最典型的故障,但它有一个好习惯——会把失败原因写进日志文件。
解决:检查代码里有没有System_SetState(HGE_LOGFILE, "hge.log"),没有就加上。编译运行后在exe目录打开hge.log,看到类似“Direct3D init failed”的提示,基本可以锁定是窗口模式、分辨率与显卡驱动的兼容性问题。我把窗口模式改为true,分辨率降为800x600,黑屏问题基本就解决大半。
5.3 现象:资源文件都在,但马里奥显示成黑块或透明方块
程序能跑,画面也在,但玩家角色和敌人全是黑色方块或者看不见。
原因:Texture_Load返回了0,纹理加载失败。最常见的是工作目录问题:调试器默认工作目录是.vcproj所在目录,而你双击exe运行时工作目录是exe所在目录。bmp放在exe目录旁,F5运行时就会找不到。
解决:先在初始化代码里写一行:
if (tex == 0) { hge->System_Log("load pic3.bmp failed: %s", GetLastErrorText()); }然后用绝对路径测一次。如果绝对路径正常,就在项目属性 → 调试 → 工作目录设置为exe输出目录。老代码里路径有中文也是这个症状,bmp文件路径尽量全英文。
5.4 现象:跳跃高一次矮一次,手感忽轻忽重
同样的按键操作,有时候跳得老高,有时候贴地就落下来,像中了邪。
原因:FrameFunc里直接用固定速度累加,或者用了Timer_GetDelta但没有正确应用到每个运动量上。HGE默认不锁帧,在刷新率高的显示器上,每帧时间变短,固定的位移值会让整体速度变快,而跳跃是基于瞬间速度的,表现就时高时低。
解决:把HGE_FPS设为60,统一所有运动参数为“每秒”单位,并在每个位置更新语句里乘以dt:
mario.x += mario.vx * dt; mario.y += mario.vy * dt;这种帧率问题最容易看成玄学,实际上就是dt没有乘或者乘得不全。某帧漏了dt,那一帧的移动量就瞬间翻倍。
5.5 现象:换电脑运行提示缺少MSVCP140.dll或MSVCR110.dll
双击exe弹出“无法启动此程序,因为计算机中丢失MSVCP140.dll”。
原因:工程编译时链接的是VC运行库动态库,目标机器上没有对应的运行库版本。MSVCP140对应VS2015到2022的Redistributable,老工程里有时还会混着2010、2013的库。
解决:在编译配置里选择“多线程(/MT)”静态链接运行库,入口在项目属性 → C/C++ → 代码生成 → 运行库,把“多线程DLL(/MD)”改为“多线程(/MT)”,生成的exe就不依赖外部运行库了。如果只改一两个配置,直接安装对应的Microsoft Visual C++ Redistributable软件包也能解决,但分发给别人时不如静态链接一劳永逸。
6. 从跑通到改造:给这份超级玛丽加一个无敌星星
跑通这份源码只是第一步,真正的学习价值在于改造。这里演示一个最简单的功能扩展:吃星星后15秒无敌,期间碰到敌人直接把敌人顶飞。
6.1 定义一个15秒的invincible状态
核心思路是在马里奥状态结构里加两个字段,然后在更新函数里做倒计时:
// 状态结构体中新增字段 struct PlayerState { // ……原有字段…… bool invincible; // 无敌状态标记 float invincibleTimer; // 剩余无敌时间 }; void UpdatePowerUp(float dt) { if (player.invincible) { player.invincibleTimer -= dt; if (player.invincibleTimer <= 0.0f) { player.invincible = false; // 时间到,无敌结束 } } } // 碰撞敌人时判断 bool HitEnemy(Enemy* enemy) { if (player.invincible) { enemy->vy = -400.0f; // 顶飞敌人 enemy->dead = true; return true; // 无敌,不掉血 } return false; // 普通状态,走死亡逻辑 } // 渲染时用颜色闪烁提示剩余时间 void RenderPlayer() { if (player.invincible && ((int)(player.invincibleTimer * 8) & 1)) { marioSprite->SetColor(0xFFFFAA00); // 橙色闪烁 } else { marioSprite->SetColor(0xFFFFFFFF); // 正常颜色 } marioSprite->Render(player.x - cameraX, player.y); }关键参数是invincibleTimer的单位。它与dt保持一致,用秒做单位,所以赋值是15.0f而不是15。闪烁频率由乘以8再按位与1控制,等价于每0.125秒切换一次颜色,视觉上就是快速闪烁的效果,比一直高亮更能提示“马上要失效了”。敌人被顶飞的-400.0f是垂直初速度,配合重力能产生一个漂亮的抛物线掉落到屏幕外。
6.2 用日志输出验证改造
我改完这种功能的第一件事不是进关卡找星星吃,而是加日志确认状态切换正确:
hge->System_Log("invincible=%d timer=%.2f pos=(%.1f,%.1f)", player.invincible, player.invincibleTimer, player.x, player.y);System_Log会把文本追加到hge.log里。跑一遍,确认三件事:吃到星星后invincible变为1;15秒后自动变回0;碰到敌人时没有走死亡流程。这套验证流程比肉眼盯着屏幕靠谱得多,尤其是timer这类秒级数据,靠看画面判断误差太大。
话说回来,我调试这份源码时最深刻的教训,不是引擎接口有多难记,而是老工程里那些看起来多余的bat脚本和bmp文件,恰恰是工程能跑起来的前提。从那以后,我每次拿到老游戏源码,都强制自己先把资源清单和构建脚本完整过一遍,再动代码编辑器。这份超级玛丽源码不多,但足够把HGE引擎这套“初始化 → 回调 → 资源 → 逻辑”的套路走通,你下载后值得完整跑一遍看看到底哪里会翻车。希望帮到你。
本文还有配套的精品资源,点击获取