ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MFC对话框俄罗斯方块:双缓冲绘图与消息映射实战

MFC对话框俄罗斯方块:双缓冲绘图与消息映射实战 简介这是一份面向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) * cellSizey 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、col1、col-2、col2哪个能放下就用哪个。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 小游戏都强制先跑一遍双缓冲和焦点检查再动游戏逻辑不然调到最后分不清是绘图问题还是逻辑问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表