基于MFC C++开发的超级玛丽游戏实例项目
简介:《超级玛丽实例程序MFC C++编写》是一个面向C++初学者的实践型学习资源,采用Visual C++ 6.0开发环境和微软基础类库(MFC)构建Windows平台下的经典游戏。项目涵盖MFC框架核心概念,如应用程序类CWinApp、窗口类CWnd、消息处理机制与事件驱动编程,并结合定时器控制、GDI图形绘制、键盘输入响应等技术实现游戏逻辑与界面渲染。通过分析源码与资源文件,学习者可掌握C++面向对象特性在实际项目中的应用,理解Windows消息循环与游戏主循环的集成方式,完成从程序框架搭建到游戏功能实现的完整流程。该项目是融合基础知识与动手能力训练的优质入门实践。
MFC框架下的超级玛丽游戏开发:从窗口创建到实时渲染
你有没有试过用现代C++的方式去复刻一个90年代的经典?不是那种“我写了个贪吃蛇”的小打小闹,而是真正把 超级玛丽 这样的平台跳跃神作,用MFC——这个听起来像是上古时代的Windows框架——完整实现出来。🤯
别笑!这事儿真有人干成了,而且背后的技术逻辑比你想的要优雅得多。
我们今天不讲什么“Hello World”,也不搞花里胡哨的界面美化。我们要做的,是 拆解一套完整的2D游戏引擎雏形 ,看看如何在一个看似落后的技术栈里,构建出流畅、稳定、可扩展的游戏架构。整个过程将围绕 MFC(Microsoft Foundation Classes) 展开,但你会惊讶地发现:它其实是个被严重低估的“老派高手”。
窗口即世界:CWinApp 与 CWnd 的双剑合璧 🏰
在 Windows 桌面开发中,一切始于 WinMain —— 那个每个 C/C++ 程序员都背得滚瓜烂熟的入口函数。但如果你还在手动写 WinMain ,那你已经落后了整整一代。
MFC 的厉害之处,就在于它把这套繁琐流程彻底封装了起来。开发者不再需要关心注册窗口类、消息循环、线程调度这些底层细节,取而代之的是两个核心对象:
CWinApp:应用程序的灵魂CWnd:视觉世界的载体
它们之间的协作,就像导演和舞台的关系。前者掌控全局节奏,后者负责呈现内容。
应用程序对象:theApp 的魔法登场 ✨
class CSuperMarioApp : public CWinApp {
public:
virtual BOOL InitInstance();
};
CSuperMarioApp theApp; // ← 就这么一行,搞定程序入口
看到没?没有 main() ,也没有 WinMain() 。只有一个全局变量 theApp ,就这么静静地躺在代码里。
但这可不是普通的全局变量。它是 MFC 运行时自动识别的“应用程序单例”。当程序启动时,MFC 会通过静态构造机制找到这个唯一的 CWinApp 派生类实例,并将其设为当前应用上下文。整个过程依赖于 C++ 的一个冷知识: 所有全局对象在进入 main 前就已经完成构造 。
于是,MFC 利用这一点,在内部实现了标准的 _tWinMain 函数,其调用链如下:
graph TD
A[操作系统调用 WinMain] --> B[MFC 提供的 _tWinMain]
B --> C[AfxGetApp() 获取 theApp 实例]
C --> D[pApp->InitApplication()]
D --> E[pApp->InitInstance()]
E --> F{成功?}
F -- 是 --> G[pApp->Run()]
F -- 否 --> H[返回 FALSE, 终止程序]
G --> I[消息循环: GetMessage → TranslateMessage → DispatchMessage]
看到了吗?你在 InitInstance() 里写的每一行初始化代码,其实都是这场宏大演出的第一幕。
💡 小贴士:
InitInstance()是唯一必须重写的函数。如果它返回FALSE,程序直接退出,连窗口都不会弹出来。所以这里非常适合做资源检查、路径验证等前置判断。
初始化不只是“开始”,更是“准备就绪” 🔧
对于像超级玛丽这样的游戏来说,初始化阶段远不止“创建窗口”那么简单。我们需要确保以下几件事万无一失:
- 工作目录正确设置
默认情况下,Windows 可执行文件的工作目录可能不是 EXE 所在路径,导致加载图片、关卡数据失败。
```cpp
TCHAR szExePath[MAX_PATH];
GetModuleFileName(NULL, szExePath, MAX_PATH);
CString strDir(szExePath);
strDir = strDir.Left(strDir.ReverseFind(‘\‘));
SetCurrentDirectory(strDir); // 让相对路径指向资源文件夹
```
- 命令行参数解析
用于开启调试模式、跳关测试或禁用音效:
cpp if (m_lpCmdLine && lstrcmp(m_lpCmdLine, _T("/debug")) == 0) { m_bDebugMode = TRUE; AfxMessageBox(_T("Debug Mode Activated!")); }
- ActiveX 控件支持(可选)
虽然游戏本身不需要 ActiveX,但如果未来想嵌入帮助文档浏览器或视频播放器,记得提前启用容器支持:
cpp AfxEnableControlContainer(); // 必须在任何窗口创建前调用
- 防止多实例运行
游戏只能开一个窗口,否则存档冲突、音频混乱。使用互斥量(Mutex)轻松解决:
cpp HANDLE hMutex = CreateMutex(NULL, TRUE, _T("SuperMario_SingleInstance_Mutex")); if (GetLastError() == ERROR_ALREADY_EXISTS) { CloseHandle(hMutex); AfxMessageBox(_T("游戏已在运行!")); return FALSE; }
⚠️ 别忘了在
ExitInstance()中释放句柄哦!
- 异常处理兜底机制
资源缺失怎么办?权限不足呢?MFC 提供了 TRY/CATCH 宏家族来捕获结构化异常:
cpp TRY { HBITMAP hBmp = (HBITMAP)LoadImage(NULL, _T("background.bmp"), IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE); if (!hBmp) AfxThrowUserException(); } CATCH(CFileException, e) { AfxMessageBox(_T("找不到背景图,请检查资源目录")); return FALSE; } AND_CATCH_ALL(e) { AfxMessageBox(_T("未知错误")); return FALSE; } END_CATCH
你看,短短几十行代码,我们就搭建出了一个健壮的启动框架。这才是真正的“工业级”做法。
主窗口设计:不只是画布,更是战场 🎮
有了应用程序主体,接下来就是创建主窗口。对于超级玛丽这类全屏游戏,我们显然不能走传统的 WS_OVERLAPPEDWINDOW 路线。我们需要的是 沉浸式体验 :无边框、置顶显示、分辨率自适应。
创建全屏窗口的艺术 🖼️
直接继承 CFrameWnd 并重写 CreateEx 是最灵活的做法:
BOOL CGameWindow::Create(LPCTSTR lpszClassName,
LPCTSTR lpszWindowName,
DWORD dwStyle,
const RECT& rect,
CWnd* pParentWnd,
UINT nID,
CCreateContext* pContext)
{
return CFrameWnd::CreateEx(
WS_EX_TOPMOST | WS_EX_APPWINDOW, // 始终置顶 + 显示在任务栏
NULL, // 使用默认类名
_T("Super Mario Game"), // 窗口标题
WS_POPUP | WS_VISIBLE, // 无边框弹出式,立即可见
CW_USEDEFAULT, CW_USEDEFAULT,
GetSystemMetrics(SM_CXSCREEN), // 动态获取屏幕宽度
GetSystemMetrics(SM_CYSCREEN), // 动态获取屏幕高度
NULL, NULL, NULL);
}
几个关键点解释一下:
| 样式 | 作用 |
|---|---|
WS_EX_TOPMOST |
强制窗口始终位于其他普通窗口之上 |
WS_EX_APPWINDOW |
即使用了工具窗口样式,也能出现在任务栏 |
WS_POPUP |
创建无系统边框的独立窗口 |
CW_USEDEFAULT |
让系统自动分配位置 |
这样一来,我们的游戏一启动就是全屏状态,没有任何干扰元素。
注册自定义窗口类,掌握绘图控制权 🎨
默认情况下,Windows 在每次重绘前都会发送 WM_ERASEBKGND 消息清除背景。这在普通应用中没问题,但在高频率刷新的游戏场景下,会导致严重的闪烁问题。
解决方案是: 禁用自动擦除背景 。
怎么做?通过注册自己的窗口类,并将背景刷设为 NULL_BRUSH :
WNDCLASS wc = {0};
wc.style = CS_HREDRAW | CS_VREDRAW; // 大小改变时重绘
wc.lpfnWndProc = ::DefWindowProc; // MFC 内部管理
wc.hInstance = AfxGetInstanceHandle();
wc.hIcon = LoadIcon(IDR_MAINFRAME);
wc.hCursor = LoadCursor(IDC_ARROW);
wc.hbrBackground = (HBRUSH)GetStockObject(NULL_BRUSH); // 关键!透明背景
wc.lpszClassName = _T("GameWindowClass");
if (!AfxRegisterClass(&wc)) {
AfxThrowResourceException();
}
然后在 PreCreateWindow 中指定类名:
BOOL CGameWindow::PreCreateWindow(CREATESTRUCT& cs)
{
if (!CFrameWnd::PreCreateWindow(cs))
return FALSE;
cs.lpszClass = _T("GameWindowClass"); // 使用已注册的类
cs.style &= ~FWS_ADDTOTITLE; // 不在标题栏显示文档名
return TRUE;
}
这一招看似微不足道,实则是后续实现双缓冲绘制的前提条件。否则,哪怕你做了再多优化,画面还是会“闪瞎眼”👀。
绘图革命:OnPaint 与双缓冲的完美配合 🎨💥
终于到了最激动人心的部分—— 绘图 。
在 MFC 中,所有客户区绘制都由 OnPaint() 函数驱动。每当窗口需要更新时(比如调用了 InvalidateRect ),系统就会投递 WM_PAINT 消息,进而触发 OnPaint 。
CPaintDC:你的专属画笔助手 🖌️
void CGameWindow::OnPaint()
{
CPaintDC dc(this); // 自动调用 BeginPaint / EndPaint
CRect clientRect;
GetClientRect(&clientRect);
dc.FillSolidRect(&clientRect, RGB(135, 206, 235)); // 天空蓝背景
dc.MoveTo(0, clientRect.bottom - 50);
dc.LineTo(clientRect.right, clientRect.bottom - 50); // 地面线
}
注意这里的 CPaintDC ,它不仅仅是一个设备上下文包装器,更重要的是:
- 自动处理无效区域裁剪(只画脏区)
- 析构时自动调用
EndPaint,避免资源泄漏 - 支持与 GDI 对象无缝交互
但如果你就这样跑起来看效果……恭喜你,迎接你的将是剧烈闪烁 😵💫
为什么?因为每一次 BitBlt 或 FillRect 都是直接往屏幕上画。频繁刷新等于“撕裂式渲染”。
双缓冲技术:消除闪烁的终极武器 🛡️
解决办法很简单:先在内存里画好一整帧,再一次性拷贝到屏幕。这就是传说中的 双缓冲 。
我们可以自己封装一个 CMemDC 类:
class CMemDC : public CDC
{
public:
CMemDC(CDC& dc, const CRect& rect)
{
CreateCompatibleDC(&dc);
m_bitmap.CreateCompatibleBitmap(&dc, rect.Width(), rect.Height());
SelectObject(&m_bitmap);
m_rect = rect;
}
~CMemDC()
{
GetDC()->BitBlt(0, 0, m_rect.Width(), m_rect.Height(), this, 0, 0, SRCCOPY);
}
private:
CBitmap m_bitmap;
CRect m_rect;
};
然后改造 OnPaint :
void CGameWindow::OnPaint()
{
CPaintDC dcScreen(this);
CRect clientRect;
GetClientRect(&clientRect);
CMemDC memDC(dcScreen, clientRect); // 所有绘制都在内存中完成
DrawGameScene(&memDC); // 包括背景、角色、UI等
} // 析构时自动 BitBlt 上屏
现在,无论你怎么疯狂刷新,画面都丝般顺滑 🌟
下面是完整流程图:
flowchart LR
A[触发InvalidateRect] --> B[产生WM_PAINT]
B --> C[OnPaint被调用]
C --> D[创建内存DC]
D --> E[绘制所有图形到内存位图]
E --> F[BitBlt复制到位屏DC]
F --> G[完成平滑刷新]
📊 性能对比表:
技术手段 是否消除闪烁 CPU占用 实现难度 直接绘图 ❌ 低 简单 双缓冲(内存DC) ✅ 中等 中等 GDI+双缓冲 ✅ 较高 高 DirectX渲染 ✅ 高性能 复杂
选择哪种方案取决于项目需求。但对于 MFC 游戏开发而言,纯 GDI + 双缓冲已是性价比极高的选择。
精准刷新:让每一像素都物尽其用 🎯
你以为 Invalidate() 就完事了?Too young.
盲目调用 Invalidate() 会让整个客户区变为无效区域,导致不必要的重绘开销。尤其是在每秒刷新60次的游戏循环中,这种浪费会迅速拖垮性能。
局部刷新才是王道 🧩
假设马里奥移动了一小段距离,我们只需要刷新他旧位置和新位置之间的区域即可:
void CGameWindow::UpdatePlayerPosition(const CPoint& oldPos, const CPoint& newPos)
{
CRect updateRect(oldPos, CSize(32, 32));
updateRect.UnionRect(updateRect, CRect(newPos, CSize(32, 32)));
updateRect.InflateRect(2, 2); // 防止边缘残留
InvalidateRect(&updateRect, FALSE); // FALSE表示不清除背景
}
参数说明:
UnionRect:合并两个矩形区域InflateRect:扩大一点以防残影InvalidateRect(rect, FALSE):仅使指定区域无效,且不发送WM_ERASEBKGND
| 调用方式 | 重绘区域 | CPU占用 | 用户感知 |
|---|---|---|---|
Invalidate() |
整个客户区 | 高 | 明显卡顿 |
InvalidateRect(&rect, FALSE) |
局部区域 | 低 | 流畅自然 |
控制刷新频率,拒绝“WM_PAINT风暴” 🌪️
即使定时器每16ms触发一次(约60FPS),也不是每次都需要重绘。可以通过标记位减少冗余调用:
void CGameWindow::OnTimer(UINT_PTR nIDEvent)
{
switch (nIDEvent)
{
case IDT_GAME_LOOP:
UpdateGameState();
if (m_bNeedRedraw)
{
CRect playerArea(m_playerPos, CSize(32,32));
playerArea.InflateRect(10,10);
InvalidateRect(&playerArea, FALSE);
m_bNeedRedraw = FALSE;
}
break;
}
}
引入 m_bNeedRedraw 标志位后,只有在发生动画变化或碰撞事件时才请求重绘。这是一种典型的“变更驱动”模型,也是现代游戏引擎的基本设计范式。
flowchart TB
A[定时器触发OnTimer] --> B{是否有状态变更?}
B -->|是| C[计算需刷新区域]
C --> D[调用InvalidateRect]
D --> E[等待WM_PAINT]
B -->|否| F[跳过重绘]
E --> G[执行双缓冲绘制]
效率提升立竿见影!
坐标系转换:从小地图到大世界 🌍
在2D游戏中,有两种坐标体系共存:
- 设备坐标(Device Coordinates) :以像素为单位,原点在左上角
(0,0) - 逻辑坐标(Logical Coordinates) :以“瓦片”或“米”为单位,更适合描述游戏世界
两者之间需要建立映射关系。
逻辑坐标 ↔ 屏幕坐标的自由穿梭 🔀
CPoint CGameWindow::LogicToClient(const CPoint& logicPt)
{
int scaleX = 2; // 每逻辑单位对应2像素
int scrollX = m_cameraOffset.x;
return CPoint(logicPt.x * scaleX - scrollX, logicPt.y);
}
CPoint CGameWindow::ClientToLogic(const CPoint& clientPt)
{
int scaleX = 2;
int scrollX = m_cameraOffset.x;
return CPoint((clientPt.x + scrollX) / scaleX, clientPt.y);
}
应用场景举例:
- 将玩家位置(逻辑坐标)转为屏幕坐标用于绘制
- 将鼠标点击(设备坐标)反向映射到游戏世界
| 坐标类型 | 单位 | 原点位置 | 变换方向 |
|---|---|---|---|
| 设备坐标 | 像素 | 左上角(0,0) | 输出显示 |
| 逻辑坐标 | 游戏单位 | 自定义(如地图起点) | 业务计算 |
摄像机跟随:让主角永远在视野中央 👁️
随着角色移动,摄像机应自动跟踪。通过维护 m_cameraOffset 实现滚动:
void CGameWindow::UpdateCamera()
{
CRect clientRect;
GetClientRect(&clientRect);
int targetX = m_player.m_pos.x - clientRect.Width() / 4; // 提前四分之一屏
int maxScroll = max(0, m_mapWidth * TILE_SIZE - clientRect.Width());
m_cameraOffset.x = min(max(targetX, 0), maxScroll);
Invalidate(); // 触发重绘
}
结合缩放因子还能实现动态视野调整:
double zoomFactor = 1.5;
int screenX = (logicX - cameraX) * zoomFactor;
最终在 DrawGameScene 中统一使用转换后的坐标:
void CGameWindow::DrawGameScene(CDC* pDC)
{
for (auto& tile : m_visibleTiles)
{
CPoint screenPos = LogicToClient(tile.logicPos);
pDC->DrawEdge(&CRect(screenPos, CSize(16,16)), EDGE_RAISED, BF_RECT);
}
}
这套机制让你可以轻松驾驭超长地图,同时保持局部细节清晰可见。
GDI绘图进阶:精灵、动画与分层渲染 🎞️
GDI 虽然是老技术,但在轻量级2D游戏中依然表现出色。特别是当我们结合位图资源、精灵图切割和分层绘制时,完全可以做出专业级视觉效果。
位图加载:从资源文件到内存图像 🖼️
推荐两种方式:
方式一:从资源ID加载(打包发布首选)
IDB_BACKGROUND BITMAP "res\\background.bmp"
CBitmap bitmap;
bitmap.LoadBitmap(IDB_BACKGROUND);
方式二:从磁盘文件加载(调试方便)
HBITMAP hBmp = (HBITMAP)::LoadImage(
NULL,
_T("C:\\game\\player.png"),
IMAGE_BITMAP,
0, 0,
LR_LOADFROMFILE
);
⚠️ 注意:PNG/JPG需 GDI+ 支持,BMP 最兼容。
精灵图切割:时间驱动的动画帧提取 ⏱️
将多个动作帧整合为一张大图,称为“精灵图”(Sprite Sheet)。例如奔跑动画有6帧,每帧32x32像素:
void CAnimation::DrawFrame(CDC* pDC, int x, int y)
{
DWORD now = GetTickCount();
if (now - m_lastTick > 100) { // 每100ms切换帧
m_currentFrame = (m_currentFrame + 1) % m_totalFrames;
m_lastTick = now;
}
CDC srcDC;
srcDC.CreateCompatibleDC(pDC);
CBitmap* pOldBmp = srcDC.SelectObject(&m_spriteSheet);
int srcX = m_currentFrame * m_frameWidth;
pDC->BitBlt(x, y, m_frameWidth, m_frameHeight,
&srcDC, srcX, 0, SRCCOPY);
srcDC.SelectObject(pOldBmp);
}
透明色处理:TransparentBlt 来帮忙 🪄
很多老式精灵图用品红 RGB(255,0,255) 当透明背景。这时要用 TransparentBlt :
#pragma comment(lib, "Msimg32.lib") // 必须链接
::SetStretchBltMode(pDC->GetSafeHdc(), COLORONCOLOR);
::TransparentBlt(
pDC->GetSafeHdc(),
x, y, width, height,
srcDC.GetSafeHdc(),
srcX, srcY, width, height,
RGB(255, 0, 255)
);
虽然不如 Alpha 通道细腻,但复古风正需要这种“像素感”。
分层绘制:背景、角色、UI各司其职 🧱
典型层级结构:
| 层级 | 内容 | 更新频率 |
|---|---|---|
| Layer 0 | 静态背景 | 极低(仅初始绘制) |
| Layer 1 | 可破坏平台 | 中等(随关卡变化) |
| Layer 2 | 主角与敌人 | 高(每帧) |
| Layer 3 | UI(血条、分数) | 中高(状态变更时) |
实现时可通过“脏矩形”机制决定是否重绘某层:
void CGameEngine::Render(CDC* pDC)
{
if (m_bBgDirty) {
DrawBackground(pDC);
m_bBgDirty = false;
}
DrawPlatforms(pDC);
DrawCharacters(pDC);
DrawUI(pDC);
}
Z轴顺序由绘制先后决定:后画覆盖先画。若需动态排序,可用 zOrder 字段维护渲染队列。
时间驱动:SetTimer 与游戏主循环的诞生 ⏳
再好的画面也需要“活”起来。这就轮到定时器出场了。
WM_TIMER:异步事件调度的核心 🔔
MFC 提供 SetTimer API 来注册周期性任务:
UINT m_nMainTimerID = SetTimer(1, 16, NULL); // ~60FPS 主逻辑
UINT m_nUITimerID = SetTimer(2, 33, NULL); // ~30FPS UI动画
在 OnTimer 中根据 ID 分发处理:
void OnTimer(UINT_PTR nIDEvent)
{
switch (nIDEvent)
{
case 1: UpdateGameLogic(); InvalidateRect(nullptr, FALSE); break;
case 2: UpdateUIAnimation(); break;
default: CWnd::OnTimer(nIDEvent); break;
}
}
| 定时器ID | 用途 | 典型频率 |
|---|---|---|
| 1 | 主逻辑更新 | 16ms (60Hz) |
| 2 | UI动画 | 33ms (30Hz) |
| 3 | 敌人AI | 200ms |
| 4 | 音效控制 | 100ms |
固定时间步长:物理模拟的稳定性保障 ⚖️
变步长更新会导致运动漂移。解决方案是“固定时间步长 + 时间累积器”:
const double FIXED_TIMESTEP = 1.0 / 60.0;
double accumulator = 0.0;
void GameLoop(double deltaTime)
{
accumulator += deltaTime;
while (accumulator >= FIXED_TIMESTEP)
{
UpdatePhysics(FIXED_TIMESTEP);
accumulator -= FIXED_TIMESTEP;
}
Render();
}
这样即使帧率波动,角色跳跃轨迹也始终保持一致。
消息映射:键盘、鼠标与菜单的交响曲 🎼
最后,让我们连接用户输入。
键盘响应:OnKeyDown 的精确捕捉 ⌨️
void CGameWnd::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags)
{
switch (nChar)
{
case VK_LEFT: m_bLeftPressed = TRUE; break;
case VK_RIGHT: m_bRightPressed = TRUE; break;
case VK_SPACE:
if (!m_bJumping) {
m_bJumping = TRUE;
m_fVerticalVelocity = -JUMP_FORCE;
}
break;
}
InvalidateRect(nullptr);
}
配合定时器中的加速度逻辑,就能实现流畅行走与惯性滑行。
自定义消息:模块间通信的桥梁 🌉
定义全局消息进行跨模块通知:
const UINT WM_GAME_EVENT = RegisterWindowMessage(_T("WM_GAME_EVENT"));
PostMessage(WM_GAME_EVENT, GAME_EVENT_ENEMY_DEFEATED, nEnemyType);
BEGIN_MESSAGE_MAP(CGameWnd, CWnd)
ON_REGISTERED_MESSAGE(WM_GAME_EVENT, &CGameWnd::OnGameEvent)
END_MESSAGE_MAP()
形成松耦合架构,便于后期扩展。
结语:老树也能开新花 🌸
回头看,MFC 看似陈旧,但它提供的抽象能力、封装机制和运行时支持,依然是许多现代框架的思想源头。用它开发超级玛丽,不仅是怀旧,更是一次对“底层原理”的深度回溯。
当你亲手写出第一个不闪烁的动画帧时,那种成就感,远胜于调用一百个第三方库。
毕竟,真正的工程师,从不怕“古老”的技术——只要它还能解决问题。💪
简介:《超级玛丽实例程序MFC C++编写》是一个面向C++初学者的实践型学习资源,采用Visual C++ 6.0开发环境和微软基础类库(MFC)构建Windows平台下的经典游戏。项目涵盖MFC框架核心概念,如应用程序类CWinApp、窗口类CWnd、消息处理机制与事件驱动编程,并结合定时器控制、GDI图形绘制、键盘输入响应等技术实现游戏逻辑与界面渲染。通过分析源码与资源文件,学习者可掌握C++面向对象特性在实际项目中的应用,理解Windows消息循环与游戏主循环的集成方式,完成从程序框架搭建到游戏功能实现的完整流程。该项目是融合基础知识与动手能力训练的优质入门实践。
更多推荐




所有评论(0)