简介:这是一份面向MFC初学者与Windows游戏开发入门者的俄罗斯方块完整工程源码,基于对话框架构实现,适合想通过经典小游戏理解MFC框架与事件驱动机制的开发者练手。压缩包共61个文件,约19.92MB,以h头文件与cpp源文件为核心,另有bmp位图、ico图标、rc资源脚本等界面素材,以及sln解决方案、vcproj工程文件、exe可执行程序与pdb调试符号等,工程结构完整可直接编译运行。资源中游戏区域、得分显示与控制按钮均集中在单一对话框内,通过CDialog派生类、OnInitDialog初始化、消息映射与ON_BN_CLICKED响应按钮事件,并借助定时器控制方块下落节奏,方块生成、旋转、碰撞检测与消行逻辑以数组操作实现,还附有一份手把手制作教程文档辅助理解。目前已有278人学习下载,读者可从中掌握MFC控件使用、消息机制与游戏循环设计,并在此基础上扩展难度选择、音效或排行榜等功能。
1. 对话框框架下写俄罗斯方块:为什么我建议从 MFC 而不是控制台起步
很多人第一次接触俄罗斯方块,是在控制台里用printf画方块,跑起来之后发现刷新闪屏、按键延迟、边界判断全靠手算坐标,改一个旋转逻辑要动十几处。如果你已经会一点 C++,又想真正理解「窗口消息驱动」这套机制,MFC 基于对话框的俄罗斯方块反而是个更扎实的起点。它把窗口、消息循环、绘图设备上下文(DC)都摆在你面前,逼你去处理OnPaint、OnKeyDown、定时器这些真实桌面程序绕不开的东西。
这份资源就是一套基于 MFC 对话框的俄罗斯方块完整实现,核心是CDialogEx派生类加一个定时器驱动下落、一个内存 DC 做双缓冲绘图、一个二维数组存方块矩阵。它适合两类人:一是学过 C++ 语法但没写过 Windows 界面的,想找个能跑起来的小项目把消息映射、GDI 绘图串一遍;二是做过控制台版本、想升级到图形界面但不知道从哪下手的。下面我按「框架怎么搭 → 方块怎么画 → 逻辑怎么跑 → 坑在哪」的顺序拆开讲,每一步都能直接抄。
2. 对话框工程搭建与消息映射:从空壳到能响应按键
2.1 为什么选对话框而不是单文档
MFC 有单文档(SDI)、多文档(MDI)、对话框三种常见骨架。俄罗斯方块只有一个固定大小的游戏区,不需要菜单栏、工具栏、状态栏那一整套文档/视图架构,用对话框最省事。对话框本身就是个窗口,你可以在资源编辑器里拖控件、调尺寸,CDialogEx派生类直接接管消息,不用去理解CDocument和CView的拆分逻辑。
常见做法是:Visual Studio 新建项目选「MFC 应用」,应用程序类型选「基于对话框」,资源语言选中文,其余默认。生成后你会得到C俄罗斯方块Dlg(名字随项目)这个类,.rc文件里有一个 IDD 对话框模板。这里有个新手容易懵的点——对话框默认带「确定」「取消」两个按钮,游戏用不上,直接在资源视图里删掉,不然它们会抢焦点。
2.2 消息映射表怎么加
MFC 不像 Qt 用connect,它靠消息映射宏把 Windows 消息绑到成员函数。你要处理键盘和定时器,得在.h里声明函数,在.cpp的BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间加宏。下面是我一般会写的骨架:
// TetrisDlg.h 类声明里 protected: afx_msg void OnPaint(); afx_msg void OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags); afx_msg void OnTimer(UINT_PTR nIDEvent); DECLARE_MESSAGE_MAP() // TetrisDlg.cpp 消息映射 BEGIN_MESSAGE_MAP(CTetrisDlg, CDialogEx) ON_WM_PAINT() ON_WM_KEYDOWN() ON_WM_TIMER() END_MESSAGE_MAP()ON_WM_PAINT对应WM_PAINT,窗口需要重绘时触发;ON_WM_KEYDOWN对应键盘按下;ON_WM_TIMER对应SetTimer设的定时器到点。注意OnKeyDown要生效,对话框得能拿到焦点,后面避坑章节会讲怎么处理。
2.3 定时器与游戏节奏
下落速度靠定时器控制,SetTimer(1, 500, NULL)表示 ID 为 1、间隔 500 毫秒触发一次OnTimer。参数含义:第一个是定时器 ID,多个定时器靠它区分;第二个是毫秒间隔,数值越小方块掉得越快;第三个回调函数传NULL就走OnTimer。等级提升时用KillTimer(1)再重新SetTimer,间隔按500 - (level-1)*50递减,最低别低于 100,否则人眼跟不上。
void CTetrisDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1) { if (!MoveDown()) // 下落失败说明触底 { LockBlock(); // 固定到背景矩阵 ClearFullLines(); SpawnNewBlock(); } Invalidate(); // 请求重绘 } CDialogEx::OnTimer(nIDEvent); }Invalidate()只是标记窗口无效,真正绘图在OnPaint里。别在OnTimer里直接画,否则和系统重绘打架,画面会闪。
3. 双缓冲绘图与方块矩阵:把闪烁和坐标算清楚
3.1 内存 DC 消除闪烁
直接在OnPaint里用CPaintDC画每个方块,方块一多就会闪,因为系统先擦背景再画前景,中间有个空窗期。标准解法是双缓冲:先在内存 DC 上把整幅画面画好,再一次性BitBlt到屏幕。血泪经验是内存位图尺寸必须和客户区一致,否则拉伸变形。
void CTetrisDlg::OnPaint() { CPaintDC dc(this); CRect rect; GetClientRect(&rect); CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rect.Width(), rect.Height()); CBitmap* pOld = memDC.SelectObject(&bmp); memDC.FillSolidRect(&rect, RGB(20, 20, 20)); // 背景 DrawBoard(&memDC); // 画已固定方块 DrawBlock(&memDC); // 画当前下落方块 dc.BitBlt(0, 0, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }CreateCompatibleDC创建与屏幕兼容的内存设备上下文,CreateCompatibleBitmap创建同格式位图,SelectObject把它选进内存 DC。BitBlt的SRCCOPY表示直接拷贝像素。这套流程是 MFC 绘图的标准操作,记住「先建兼容、再选对象、画完还原」的顺序。
3.2 方块矩阵与坐标换算
俄罗斯方块有 7 种形状(I、O、T、S、Z、J、L),每种用 4×4 矩阵存旋转状态最省心。我一般用一个三维数组int shapes[7][4][4],第一维是形状编号,第二维是旋转态(0~3),第三维是 16 个格子。格子值 1 表示实心,0 表示空。
游戏区用int board[20][10],20 行 10 列。方块在屏幕上的位置由curRow、curCol决定,绘制时把矩阵坐标映射到像素:x = boardLeft + (curCol + j) * cellSize,y = boardTop + (curRow + i) * cellSize。cellSize一般取 20~30 像素,太小看不清,太大窗口放不下。
void CTetrisDlg::DrawBlock(CDC* pDC) { for (int i = 0; i < 4; i++) for (int j = 0; j < 4; j++) if (shapes[curType][curRot][i][j]) { int x = boardLeft + (curCol + j) * cellSize; int y = boardTop + (curRow + i) * cellSize; pDC->FillSolidRect(x, y, cellSize-1, cellSize-1, colorOf(curType)); pDC->Draw3dRect(x, y, cellSize, cellSize, RGB(255,255,255), RGB(80,80,80)); } }FillSolidRect填色,Draw3dRect加个立体边框让方块有质感。减 1 是为了留出格线缝隙,不然方块糊成一片。
3.3 碰撞检测的边界条件
下落和左右移动前都要做碰撞检测,判断目标位置是否越界或压到已固定方块。逻辑是:遍历当前方块矩阵的每个实心格,算出它在 board 里的目标行列,如果列小于 0、列大于 9、行大于 19,或者board[目标行][目标列]非空,就判定碰撞。
bool CTetrisDlg::CheckCollision(int row, int col, int rot) { for (int i = 0; i < 4; i++) for (int j = 0; j < 4; j++) if (shapes[curType][rot][i][j]) { int r = row + i, c = col + j; if (c < 0 || c >= 10 || r >= 20) return true; if (r >= 0 && board[r][c]) return true; } return false; }注意r >= 0这个判断,方块刚生成时可能有一部分在顶部之上(行号为负),那部分不算碰撞,否则新方块一出来就卡死。
4. 旋转、消行与计分:逻辑跑通后的三个关键函数
4.1 旋转的墙踢处理
旋转不是简单把矩阵转 90 度,贴着墙或贴着其他方块时旋转会失败,需要「墙踢」——尝试左右平移一格再转。常见做法是旋转后如果碰撞,依次尝试col-1、col+1、col-2、col+2,哪个能放下就用哪个。
void CTetrisDlg::Rotate() { int newRot = (curRot + 1) % 4; int offsets[] = {0, -1, 1, -2, 2}; for (int k = 0; k < 5; k++) { if (!CheckCollision(curRow, curCol + offsets[k], newRot)) { curCol += offsets[k]; curRot = newRot; Invalidate(); return; } } }offsets数组就是踢墙的尝试顺序,先原地,再左一格、右一格,最后左右两格。I 型方块在窄空间里旋转最容易翻车,这套偏移基本能覆盖。
4.2 消行与矩阵下移
一行填满后要删掉,上面的行整体下移。从下往上遍历,遇到满行就把该行以上所有行复制到下一行,最顶行清空。别用memcpy整块搬,逐行循环更直观也不易错。
int CTetrisDlg::ClearFullLines() { int cleared = 0; for (int r = 19; r >= 0; r--) { bool full = true; for (int c = 0; c < 10; c++) if (!board[r][c]) { full = false; break; } if (full) { for (int rr = r; rr > 0; rr--) for (int c = 0; c < 10; c++) board[rr][c] = board[rr-1][c]; for (int c = 0; c < 10; c++) board[0][c] = 0; r++; // 重新检查当前行,因为上面掉下来了 cleared++; } } return cleared; }r++那行是关键,消行后上面的行掉到当前位置,得重新判断这一行是否也满了,否则连续满行会漏消。
4.3 计分与等级
计分规则各家不同,我用的是:消 1 行 100 分,2 行 300 分,3 行 500 分,4 行 800 分,再乘以当前等级。等级每消 10 行升一级,定时器间隔随之缩短。分数和等级显示在对话框的静态文本控件上,用SetDlgItemInt或SetWindowText更新。
void CTetrisDlg::UpdateScore(int lines) { int add = 0; switch (lines) { case 1: add = 100; break; case 2: add = 300; break; case 3: add = 500; break; case 4: add = 800; break; } score += add * level; totalLines += lines; if (totalLines / 10 + 1 > level) { level++; KillTimer(1); SetTimer(1, max(100, 500 - (level-1)*50), NULL); } SetDlgItemInt(IDC_SCORE, score); SetDlgItemInt(IDC_LEVEL, level); }max(100, ...)保证间隔不低于 100 毫秒,不然高等级下根本来不及操作。
5. 避坑与常见问题:对话框版俄罗斯方块最容易翻车的五处
5.1 按键没反应
现象:程序跑起来,方向键按了没动静。原因:对话框上的按钮或静态文本控件抢了焦点,WM_KEYDOWN发给了控件而不是对话框。解决:在OnInitDialog里把焦点设到对话框本身,或者重写PreTranslateMessage拦截按键消息。我一般用后者,更稳。
BOOL CTetrisDlg::PreTranslateMessage(MSG* pMsg) { if (pMsg->message == WM_KEYDOWN) { OnKeyDown(pMsg->wParam, 1, 0); return TRUE; // 消息不再往下传 } return CDialogEx::PreTranslateMessage(pMsg); }5.2 画面闪烁严重
现象:方块下落时整个窗口闪。原因:没做双缓冲,或者OnEraseBkgnd还在擦背景。解决:加内存 DC 双缓冲,同时重写OnEraseBkgnd直接返回TRUE,告诉系统别擦背景,背景由OnPaint统一画。
BOOL CTetrisDlg::OnEraseBkgnd(CDC* pDC) { return TRUE; }5.3 方块重叠或穿墙
现象:方块能压到已有方块上,或者移出左右边界。原因:碰撞检测漏了某个方向,或者旋转后没重新检测。解决:所有改变curRow、curCol、curRot的操作前都调一次CheckCollision,确认返回false再改。别嫌麻烦,这是唯一可靠的做法。
5.4 定时器越跑越快
现象:玩一会儿方块掉得飞快。原因:每次升级都SetTimer但没KillTimer,多个定时器叠加触发。解决:SetTimer前先KillTimer(1),确保同一 ID 只有一个定时器。这个坑我在早期项目里踩过,调试了半天才发现是定时器泄漏。
5.5 内存 DC 资源泄漏
现象:长时间运行后程序变卡甚至崩溃。原因:CreateCompatibleDC和CreateCompatibleBitmap创建的对象没释放。解决:OnPaint里memDC和bmp是局部对象,析构时会自动释放,但SelectObject选进去的旧对象必须还原,否则位图无法释放。养成「选进去什么,画完还原什么」的习惯。
6. 进阶技巧:把游戏区做成可配置,并用GetTickCount做帧率无关下落
6.1 游戏区参数外置
硬编码 20×10 和 30 像素格子,改起来要翻代码。我一般把行数、列数、格子大小、游戏区左上角坐标定义成常量或从配置文件读,方便调整窗口布局。比如:
const int ROWS = 20; const int COLS = 10; const int CELL = 28; const int BOARD_LEFT = 20; const int BOARD_TOP = 20;窗口客户区大小按BOARD_LEFT*2 + COLS*CELL和BOARD_TOP*2 + ROWS*CELL算,在OnInitDialog里MoveWindow调整。这样换皮肤或加侧边栏只改几个数。
6.2 用GetTickCount替代纯定时器
定时器间隔是固定的,但系统调度有抖动,高等级下 100 毫秒的定时器实际可能 110 毫秒才触发,手感发飘。更稳的做法是定时器只做「检查」,真正判断是否该下落用GetTickCount()算时间差:
DWORD lastDrop = GetTickCount(); void CTetrisDlg::OnTimer(UINT_PTR nIDEvent) { DWORD now = GetTickCount(); if (now - lastDrop >= (DWORD)dropInterval) { lastDrop = now; if (!MoveDown()) { LockBlock(); ClearFullLines(); SpawnNewBlock(); } Invalidate(); } CDialogEx::OnTimer(nIDEvent); }dropInterval随等级变化,定时器固定 30 毫秒跑一次做轮询。这样下落节奏由真实时间决定,不受定时器抖动影响。GetTickCount返回系统启动以来的毫秒数,精度足够,别用time(),那个只到秒级。
6.3 验证清单
改完代码别急着玩,按这个顺序过一遍:按键四个方向加旋转是否都响应;方块贴左右墙能否旋转;消一行后上面是否整体下移;连续消四行分数是否按 800×等级算;等级提升后定时器是否只有一个在跑;窗口最小化再恢复画面是否正常。这几项过了,基本就没大问题。
从那以后我每次写 MFC 小游戏,都强制先跑一遍双缓冲和焦点检查,再动游戏逻辑,不然调到最后分不清是绘图问题还是逻辑问题。希望帮到你。
本文还有配套的精品资源,点击获取