简介:本资源是一份基于MFC框架实现经典俄罗斯方块游戏的完整C++项目源码,面向Windows桌面应用开发初学者及C++/MFC进阶学习者,旨在帮助读者深入理解图形界面编程、游戏逻辑设计与面向对象建模。项目包含33个文件,涵盖8个头文件(h)定义类结构与接口、7个源文件(cpp)实现核心逻辑(如CBlock方块类、CGameBoard游戏板类、视图与文档类)、3个图标(ico)和2个位图(bmp)用于界面资源,以及sln/suo/dsp等工程配置文件,包体大小为1.26MB,结构规范,便于逐模块研读。已有60人学习下载,资源提供可直接编译运行的完整工程,含详细注释、ReadMe说明及资源目录组织逻辑,特别适合通过经典小游戏实践掌握MFC消息机制、GDI绘图、定时器控制与矩阵状态管理等关键技术点。
1. MFC 俄罗斯方块:不是怀旧玩具,而是 Windows 桌面 GUI 工程能力的「压力测试仪」
你可能在 VS2019 或 VS2022 里新建过一个“MFC 应用程序”,点完向导就卡在空白窗口前——连个按钮都加不上去,更别说响应键盘、刷新画面、管理方块状态。这不是你手生,是 MFC 的底层契约太硬:它不封装消息循环,不托管绘图上下文,不自动绑定控件事件。而俄罗斯方块恰恰把这三根骨头全拆开摆上台面:你要亲手拦截WM_KEYDOWN捕获 ←→↓↑,用CDC在客户区双缓冲绘图避免闪烁,靠CArray<CPoint>管理当前方块坐标,再用CMap<int, int, int, int>记录已落定的砖格。它不考算法(旋转逻辑就 4 种硬编码),专考你对 Windows GDI、消息映射、资源 ID、视图/文档分离模型的真实掌控力。适合两类人:刚学完 Win32 API 想进阶到框架层的 C++ 新手;或多年写 Qt/C# 的工程师,想回炉验证自己是否真懂“Windows 原生 UI 是怎么一帧一帧活过来的”。这不是玩具项目,是你调试OnDraw()时发现GetClientRect()返回尺寸比窗口小 4 像素、查了 3 小时才发现忘了处理WS_BORDER样式后的顿悟现场。
2. 从零搭起骨架:MFC 单文档工程 + 视图类 + 双缓冲绘图
2.1 创建最小可运行 MFC 工程(VS2019 起手式)
打开 Visual Studio 2019/2022 → 新建项目 → 选择MFC 应用程序→ 项目名填TetrisMFC→ 点击“创建” → 在向导中:
- 应用程序类型选单文档
- 高级功能全部取消勾选(不勾“打印和预览”、“OLE 支持”、“数据库支持”)
- 用户界面功能只留工具栏和状态栏(后面要显示分数和下一方块预览)
- 最后一步点击“完成”
提示:务必选“单文档”,别选“基于对话框”。对话框模板会禁用
CView类的OnDraw(),而俄罗斯方块必须靠CView::OnDraw()主动重绘——这是 MFC 中唯一能稳定控制绘图时机的入口。
生成后,工程结构里你会看到CTetrisMFCView类(继承自CView),这就是我们所有游戏逻辑的主战场。打开TetrisMFCView.cpp,找到OnDraw(CDC* pDC)函数——现在它是空的,但马上就要被我们塞满。
2.2 实现双缓冲绘图:告别闪烁的硬核写法
MFC 默认使用pDC直接画到屏幕,一旦InvalidateRect()频繁触发,就会肉眼可见地“闪”。解决方案不是调用某个开关,而是手动创建内存 DC + 位图:
void CTetrisMFCView::OnDraw(CDC* pDC) { // 1. 获取客户区大小 CRect rect; GetClientRect(&rect); // 2. 创建兼容内存DC和位图 CDC memDC; CBitmap bitmap; memDC.CreateCompatibleDC(pDC); bitmap.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBitmap = memDC.SelectObject(&bitmap); // 3. 在内存DC上绘制(关键:所有绘图操作都对 memDC 做!) DrawGameBoard(&memDC); // 自定义函数:画背景网格、已落定方块 DrawCurrentPiece(&memDC); // 自定义函数:画正在下落的方块 DrawNextPiece(&memDC); // 自定义函数:画右上角预览方块 // 4. 一次性 BitBlt 到屏幕DC pDC->BitBlt(0, 0, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); // 5. 清理资源(注意顺序!先恢复旧位图,再删除位图对象) memDC.SelectObject(pOldBitmap); bitmap.DeleteObject(); }参数说明与逻辑拆解:
CreateCompatibleDC(pDC):创建与屏幕 DC 兼容的内存 DC,保证颜色深度、像素格式一致;CreateCompatibleBitmap():创建与屏幕匹配的位图,宽高必须用GetClientRect()动态获取,不能写死640x480(否则 DPI 缩放时错位);SelectObject()返回旧位图指针,必须保存并在最后恢复,否则下次OnDraw会因位图被释放而崩溃;BitBlt()是最终输出动作,SRCCOPY表示直接拷贝,无混合;DeleteObject()必须在SelectObject(pOldBitmap)之后调用,否则位图仍被 DC 占用,删除失败且内存泄漏。
2.3 定义方块数据结构:用 CArray + 枚举体实现可读可调
俄罗斯方块共 7 种基础形状(I、O、T、S、Z、J、L),每种有 4 个旋转态。我们不用动态分配,用栈上数组 + 枚举提升可维护性:
// TetrisPiece.h #pragma once #include <afxtempl.h> // for CArray enum class PieceType { I, O, T, S, Z, J, L, None }; struct TetrisPiece { PieceType type; CArray<CPoint, CPoint&> blocks; // 当前方块的4个相对坐标(以中心为原点) CPoint position; // 当前方块在游戏板上的左上角绝对坐标(单位:格) // 初始化各类型方块的默认形态(旋转态0) void Init(PieceType t) { type = t; blocks.RemoveAll(); switch (t) { case PieceType::I: blocks.Add(CPoint(-1, 0)); blocks.Add(CPoint(0, 0)); blocks.Add(CPoint(1, 0)); blocks.Add(CPoint(2, 0)); break; case PieceType::O: blocks.Add(CPoint(0, 0)); blocks.Add(CPoint(1, 0)); blocks.Add(CPoint(0, 1)); blocks.Add(CPoint(1, 1)); break; case PieceType::T: blocks.Add(CPoint(-1, 0)); blocks.Add(CPoint(0, 0)); blocks.Add(CPoint(1, 0)); blocks.Add(CPoint(0, -1)); break; // ... 其余类型省略,完整代码见 GitHub repo tetris-mfc-core } position = CPoint(3, 0); // 新方块出生位置:第3列,第0行(顶部居中) } // 顺时针旋转:(x,y) → (y,-x),再平移回中心 void Rotate() { CArray<CPoint, CPoint&> newBlocks; for (int i = 0; i < blocks.GetSize(); i++) { CPoint p = blocks[i]; newBlocks.Add(CPoint(p.y, -p.x)); } blocks = newBlocks; } };为什么用CArray而不用std::vector?
MFC 工程默认关闭 STL 异常支持(/EHsc),且大量老代码依赖CArray的序列化、Serialize()接口。CArray<CPoint>可直接存入CArchive,未来加存档功能时一行代码搞定,而std::vector需额外包装。
3. 键盘与定时器驱动:让方块真正“活”起来
3.1 拦截方向键与空格:重载 PreTranslateMessage()
MFC 的消息映射机制对WM_KEYDOWN有特殊处理:默认情况下,方向键会被框架当作菜单导航键吞掉,根本传不到OnKeyDown()。必须在消息进入队列前截获:
// TetrisMFCView.cpp BOOL CTetrisMFCView::PreTranslateMessage(MSG* pMsg) { if (pMsg->message == WM_KEYDOWN) { switch (pMsg->wParam) { case VK_LEFT: MovePiece(-1, 0); // 左移 return TRUE; // 已处理,不再传递 case VK_RIGHT: MovePiece(1, 0); // 右移 return TRUE; case VK_DOWN: MovePiece(0, 1); // 加速下落 return TRUE; case VK_UP: RotatePiece(); // 旋转 return TRUE; case VK_SPACE: DropPiece(); // 瞬间落地 return TRUE; } } return CView::PreTranslateMessage(pMsg); }关键点:
return TRUE表示消息已被处理,阻止其继续分发;若返回FALSE,方向键仍会被框架当菜单键用;VK_LEFT等是 Windows SDK 定义的虚拟键码,不要用 ASCII 码如'a',否则 Alt+Tab 切换时会误触发;- 所有移动/旋转逻辑必须先校验是否合法(见 4.1 节),否则方块会穿墙。
3.2 用 SetTimer 实现游戏主循环:精度与负载的平衡
俄罗斯方块下落速度需随等级提升(如 Level 1: 1s/格,Level 10: 0.1s/格)。MFC 不提供requestAnimationFrame,只能靠SetTimer():
// 在 CTetrisMFCView 构造函数中启动初始定时器 CTetrisMFCView::CTetrisMFCView() { m_nGameLevel = 1; m_nDropIntervalMs = 1000; // 初始1秒 SetTimer(1, m_nDropIntervalMs, nullptr); // ID=1,无回调函数 } // 在 OnTimer 中处理下落逻辑 void CTetrisMFCView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1) { if (!MovePiece(0, 1)) { // 尝试下移一格 LockCurrentPiece(); // 无法下移 → 锁定当前方块 CheckLines(); // 消行检测 SpawnNewPiece(); // 生成新方块 } } CView::OnTimer(nIDEvent); } // 更新等级与定时器(每消10行升一级) void CTetrisMFCView::UpdateLevel() { int linesCleared = GetLinesCleared(); m_nGameLevel = min(10, 1 + linesCleared / 10); m_nDropIntervalMs = max(100, 1000 - (m_nGameLevel - 1) * 100); KillTimer(1); SetTimer(1, m_nDropIntervalMs, nullptr); }参数说明:
SetTimer(1, ms, nullptr):ID 设为 1(便于识别),ms为毫秒间隔,nullptr表示使用OnTimer()回调而非独立线程;KillTimer()必须在SetTimer()前调用,否则旧定时器未销毁,新定时器会叠加触发;max(100, ...)限制最快速度为 10 FPS,防止 CPU 占用 100%;min(10, ...)限制最高等级为 10,避免m_nDropIntervalMs变成负数导致SetTimer失败。
4. 状态栏与实时信息显示:让 UI 不只是“能动”,还要“会说”
4.1 在状态栏显示分数、等级、下一方块预览
MFC 状态栏(CStatusBar)默认有多个窗格,我们需要扩展它来显示动态内容。在CTetrisMFCView中添加成员变量:
// TetrisMFCView.h class CTetrisMFCView : public CView { // ... private: int m_nScore; int m_nLevel; TetrisPiece m_nextPiece; // 下一方块预览 CStatusBar* m_pStatusBar; };在CTetrisMFCView::OnInitialUpdate()中初始化状态栏窗格:
void CTetrisMFCView::OnInitialUpdate() { CView::OnInitialUpdate(); // 获取主框架的状态栏指针 CMainFrame* pMainFrame = (CMainFrame*)AfxGetMainWnd(); m_pStatusBar = &pMainFrame->m_wndStatusBar; // 设置状态栏窗格文本(ID_INDICATOR_XXX 在 MainFrm.cpp 中定义) m_pStatusBar->SetPaneText(0, _T("分数: 0")); // 第0窗格:分数 m_pStatusBar->SetPaneText(1, _T("等级: 1")); // 第1窗格:等级 m_pStatusBar->SetPaneText(2, _T("下一方块: I")); // 第2窗格:预览 }每当分数/等级变化时,更新状态栏:
void CTetrisMFCView::UpdateStatusBar() { CString strScore, strLevel, strNext; strScore.Format(_T("分数: %d"), m_nScore); strLevel.Format(_T("等级: %d"), m_nLevel); strNext.Format(_T("下一方块: %c"), PieceTypeToChar(m_nextPiece.type)); m_pStatusBar->SetPaneText(0, strScore); m_pStatusBar->SetPaneText(1, strLevel); m_pStatusBar->SetPaneText(2, strNext); }注意:PieceTypeToChar()是自定义转换函数,将PieceType::I映射为字符'I',方便状态栏显示。不要用 Unicode 方块符号(如 ▓),部分系统字体不支持,会显示为方框。
4.2 在视图右上角绘制下一方块预览图
状态栏文字不够直观?我们在游戏区域右上角加一个 4×4 小网格,实时绘制下一方块:
void CTetrisMFCView::DrawNextPiece(CDC* pDC) { const int previewX = 400; // 距右边界400像素 const int previewY = 20; // 距顶边界20像素 const int cellSize = 16; // 每格16×16像素 // 绘制预览区边框 CRect previewRect(previewX, previewY, previewX + 4 * cellSize, previewY + 4 * cellSize); pDC->Draw3dRect(previewRect, RGB(128,128,128), RGB(64,64,64)); // 绘制下一方块(居中显示在4×4网格内) for (int i = 0; i < m_nextPiece.blocks.GetSize(); i++) { CPoint p = m_nextPiece.blocks[i]; // 将相对坐标 (-1,-1)~(2,2) 映射到 4×4 网格中心 int x = previewX + (p.x + 1) * cellSize; int y = previewY + (p.y + 1) * cellSize; CRect blockRect(x, y, x + cellSize, y + cellSize); pDC->FillSolidRect(blockRect, GetPieceColor(m_nextPiece.type)); pDC->Draw3dRect(blockRect, RGB(0,0,0), RGB(255,255,255)); } }关键细节:
Draw3dRect()绘制立体边框,比纯Rectangle()更符合经典 Tetris UI 风格;GetPieceColor()返回各类型方块颜色(I=青色,O=黄色等),用switch(type)返回RGB(r,g,b);- 坐标映射
(p.x + 1)是为了让方块在 4×4 区域内居中,避免偏左或偏上。
5. 避坑指南:那些让 MFC 俄罗斯方块“编译通过却运行崩溃”的血泪现场
5.1 现象:程序启动后立即黑屏或闪退,调试器停在OnDraw()第一行
原因:GetClientRect(&rect)返回的rect尺寸为0x0,导致CreateCompatibleBitmap(0,0)失败,后续SelectObject()返回NULL,memDC处于无效状态,任何绘图操作触发断言。
解决:在OnDraw()开头加保护:
GetClientRect(&rect); if (rect.IsRectEmpty()) return; // 防止窗口尚未完成布局时绘图更彻底的方案是在OnSize()中缓存m_clientRect,OnDraw()直接用缓存值。
5.2 现象:按方向键无反应,但PreTranslateMessage()断点能命中
原因:MovePiece()或RotatePiece()内部调用了InvalidateRect(),但未加UpdateWindow(),导致OnDraw()未被触发,画面不更新。
解决:所有改变游戏状态的操作(移动、旋转、消行)后,必须显式刷新:
bool CTetrisMFCView::MovePiece(int dx, int dy) { // ... 逻辑校验 if (isValid) { m_currentPiece.position.Offset(dx, dy); InvalidateRect(NULL, FALSE); // FALSE 表示不擦除背景,提升性能 UpdateWindow(); // 强制立即重绘,避免延迟 } return isValid; }5.3 现象:状态栏文字显示乱码(如“分数: ?”),或中文显示为方框
原因:MFC 默认使用 ANSI 字符集,而 VS2015+ 新建工程默认启用 Unicode。若字符串字面量未加L前缀,SetPaneText()会将其当 ANSI 处理,导致宽字符截断。
解决:统一使用 Unicode 字符串:
// 错误写法(ANSI) m_pStatusBar->SetPaneText(0, "分数: 0"); // 正确写法(Unicode) m_pStatusBar->SetPaneText(0, _T("分数: 0")); // _T 宏自动适配 // 或显式宽字符 m_pStatusBar->SetPaneText(0, L"分数: 0");5.4 现象:游戏运行几分钟后 CPU 占用飙升至 100%,任务管理器显示TetrisMFC.exe持续满载
原因:SetTimer()间隔设为0或负数(如m_nDropIntervalMs计算溢出为负),导致 Windows 发送WM_TIMER消息洪流。
解决:在UpdateLevel()中严格约束:
m_nDropIntervalMs = max(50, 1000 - (m_nGameLevel - 1) * 100); // 下限50ms(20FPS) if (m_nDropIntervalMs <= 0) m_nDropIntervalMs = 50;5.5 现象:窗口缩放后,方块绘制错位,网格线断裂
原因:OnDraw()中硬编码了cellSize = 20,未根据GetClientRect()动态计算每格像素数;或DrawGameBoard()使用CDC::SetMapMode(MM_LOENGLISH)后未恢复。
解决:所有绘图尺寸必须基于客户区动态计算:
void CTetrisMFCView::DrawGameBoard(CDC* pDC) { CRect rect; GetClientRect(&rect); const int boardWidth = 10; // 游戏板10列 const int boardHeight = 20; // 游戏板20行 const int cellSize = min(rect.Width() / boardWidth, rect.Height() / boardHeight); // 后续所有坐标计算基于 cellSize }6. 进阶技巧:用 CArchive 实现本地存档 + 用 CDialog 实现暂停菜单
6.1 用 CArchive 保存/加载游戏状态(含方块坐标、分数、等级)
MFC 的CArchive是二进制序列化的黄金标准,比手写 JSON 或 INI 稳定十倍。我们定义一个存档结构:
// GameSaveData.h #pragma once #include <afxtempl.h> #include "TetrisPiece.h" struct GameSaveData { int m_nScore; int m_nLevel; int m_nLinesCleared; TetrisPiece m_currentPiece; TetrisPiece m_nextPiece; CArray<CPoint, CPoint&> m_board; // 已落定方块坐标数组(x,y)→ 一维索引:y*10+x void Serialize(CArchive& ar) { if (ar.IsStoring()) { ar << m_nScore << m_nLevel << m_nLinesCleared; m_currentPiece.Serialize(ar); m_nextPiece.Serialize(ar); int size = m_board.GetSize(); ar << size; for (int i = 0; i < size; i++) { ar << m_board[i].x << m_board[i].y; } } else { ar >> m_nScore >> m_nLevel >> m_nLinesCleared; m_currentPiece.Serialize(ar); m_nextPiece.Serialize(ar); int size; ar >> size; m_board.RemoveAll(); for (int i = 0; i < size; i++) { int x, y; ar >> x >> y; m_board.Add(CPoint(x, y)); } } } };在视图类中添加存档方法:
void CTetrisMFCView::SaveGame(const CString& filePath) { CFile file; if (!file.Open(filePath, CFile::modeCreate | CFile::modeWrite)) return; CArchive ar(&file, CArchive::store); GameSaveData data; SaveToData(data); data.Serialize(ar); ar.Close(); file.Close(); } void CTetrisMFCView::LoadGame(const CString& filePath) { CFile file; if (!file.Open(filePath, CFile::modeRead)) return; CArchive ar(&file, CArchive::load); GameSaveData data; data.Serialize(ar); ar.Close(); file.Close(); LoadFromData(data); }关键点:
CArchive必须配合CFile使用,不能直接操作FILE*;Serialize()中IsStoring()判断方向,读写顺序必须严格一致,否则反序列化时数据错位;CArray的Serialize()已由 MFC 实现,无需重写,但元素类型(如CPoint)也必须支持Serialize()。
6.2 用模态对话框实现暂停菜单(带继续/重新开始/退出)
新建对话框资源(ID:IDD_PAUSE_DIALOG),拖入三个按钮:IDC_BTN_RESUME、IDC_BTN_RESTART、IDC_BTN_EXIT。添加对话框类CPauseDialog,在CTetrisMFCView中调用:
void CTetrisMFCView::OnPauseGame() { CPauseDialog dlg; INT_PTR result = dlg.DoModal(); if (result == IDOK) { // 用户点了“继续”,什么也不做 } else if (result == IDC_BTN_RESTART) { NewGame(); } else if (result == IDC_BTN_EXIT) { AfxGetMainWnd()->PostMessage(WM_CLOSE); } }在CPauseDialog::OnBnClickedBtnResume()中:
void CPauseDialog::OnBnClickedBtnResume() { EndDialog(IDOK); // 关闭对话框,返回 IDOK }为什么用模态对话框而非非模态?
暂停时必须冻结游戏主循环(KillTimer(1)),若用非模态,用户可点击其他窗口导致OnDraw()被意外触发,而游戏状态已暂停,坐标未更新,必然崩溃。模态对话框天然阻塞消息队列,安全可靠。
6.3 一个真实踩坑后的习惯:永远在OnDestroy()中清理定时器和 GDI 对象
我曾在一个版本中忘记在OnDestroy()中KillTimer(),结果用户切换到其他程序再切回来,OnTimer()仍在后台疯狂触发,MovePiece()操作一个已被析构的m_currentPiece,直接访问违规内存。从此养成铁律:
void CTetrisMFCView::OnDestroy() { CView::OnDestroy(); KillTimer(1); // 必杀定时器 // 若有自定义位图、画笔等GDI对象,此处 DeleteObject() }同样,在OnDraw()中创建的CBitmap和CDC是栈对象,函数退出自动析构,绝不在类中保存它们为成员变量——那是 GDI 句柄泄漏的温床。
希望帮到你。
本文还有配套的精品资源,点击获取