ARTICLE DETAIL

资讯详情

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

MFC/VC++俄罗斯方块实战:消息循环、GDI双缓冲与核心算法解析

MFC/VC++俄罗斯方块实战:消息循环、GDI双缓冲与核心算法解析 简介一份基于VS2008环境使用MFC打造的俄罗斯方块游戏完整源码面向认真学习VC开发、希望从经典案例中掌握框架用法的读者贴合课程设计与自学场景。压缩包共20个文件解压后约9.81MB内部以6个头文件与3个C源文件构成代码主体其余包括图标、RC脚本、解决方案与工程配置等可直接编译运行。代码在MFC对话框中实现了方块由四个小方格组成的表示方式并完整支持左右移动、向下加速、旋转变形同时处理了边界碰撞判断、满行消行以及游戏暂停等核心环节此外还使用多线程完成下落计时借助GDI函数完成游戏区域与当前方块的实时绘制能直观呈现消息循环与定时刷新之间的协调。当前已有310人学习下载对想参考MFC游戏项目结构、理解多线程与绘图结合的开发者来说是一份结构清楚、便于二次修改的优质源码。1. MFC/VC俄罗斯方块为什么老技术反而适合练手游戏开发技术栈已经卷到了跨平台与WebGPU再谈Windows MFC/VC俄罗斯方块看起来像是回溯到上世纪的练习册。但恰恰是这个题目把Windows桌面程序最难啃的三块骨头压缩在局部消息循环与窗口过程、GDI绘制与重绘机制、C对象与Win32句柄的生命周期管理。许多工程师能熟练写出算法却在“怎么让方块在窗口里动起来”这一步卡住根源就是没完整跑通过一个GUI版小游戏。用CView承载棋盘、OnDraw接双击缓冲、SetTimer驱动下落、PreTranslateMessage拦截方向键这一套组合几乎覆盖了MFC应用的全部主干结构。对准备客户端开发面试、想补Windows GUI经验、或者要把控制台程序改造成带界面的开发者来说这份源码是最值得拆开看几遍的样板工程。本文不假定你已经拿到完整带注释的项目而是按最普通的工程结构把代码思路和关键参数讲透。2. 在MFC工程里搭俄罗斯方块的绘制骨架CView与双缓冲2.1 用单文档视图而不是对话框承载游戏画布MFC应用向导会先逼你做一个选择基于对话框还是单文档视图SDI。俄罗斯方块不需要文档序列化、没有工具栏和状态栏需求对话框方案看起来省事但实际开发中会踩到键盘焦点问题——对话框里的Button和Static控件会抢方向键焦点让空格和上下左右变得不可控。更稳妥的选择是SDI加上CView派生类视图天然参与MFC消息路由OnDraw由框架在窗口尺寸变化、遮挡恢复时自动调用棋盘重绘不用自己维护脏矩形。2.1.1 从向导生成SDI工程后要改的三个地方生成工程后我会先改三处。第一在消息映射里屏蔽WM_ERASEBKGND系统默认会用白色擦背景和后续双缓冲冲突第二在OnInitialUpdate里初始化棋盘数组和随机种子第三在OnSize里重新计算棋盘原点坐标保证窗口拉大缩小时棋盘居中且贴着底部。BEGIN_MESSAGE_MAP(CTetrisView, CView) ON_WM_ERASEBKGND() ON_WM_TIMER() ON_WM_SIZE() END_MESSAGE_MAP() BOOL CTetrisView::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 告诉系统背景已处理不再执行默认擦除 }这里ON_WM_ERASEBKGND把消息映射到返回TRUE的处理函数等于跳过了系统默认的白色背景填充。许多人做游戏时从第一步就漏掉这个映射导致双缓冲写完依然闪烁。注意工程编译选项里要选择“在共享DLL中使用MFC”或“静态链接MFC库”分发时客户端机器若缺少对应版本VC运行库程序启动会直接弹“缺少mfc140.dll”之类的错误。VC 6.0时代的老写法是在OnEraseBkgnd里FillSolidRect填黑再返回TRUE效果一样但会多一次不必要的GDI调用运行效率上不如直接返回TRUE因为整个客户区随后会被双缓冲内容完全覆盖。2.1.2 棋盘尺寸、格子大小与坐标换算棋盘固定为10列20行格子像素尺寸用m_nCellSize表示通常取20到24。窗口客户区尺寸每次变化时重算棋盘左上角坐标void CTetrisView::OnSize(UINT nType, int cx, int cy) { CView::OnSize(nType, cx, cy); int boardW m_nCols * m_nCellSize; // 10 x 24 240 int boardH m_nRows * m_nCellSize; // 20 x 24 480 m_originX (cx - boardW) / 2; m_originY cy - boardH - 24; // 底部留出24像素边距 }这样窗口宽度小于240时m_originX会变成负数必要时要限制窗口最小尺寸否则棋盘被裁剪。底部留24像素是为了给下一块预览和分数面板留空间具体数值可以按界面布局调整。把原点坐标存在成员变量里是因为OnDraw和鼠标命中测试都要用避免各算一遍造成坐标漂移。2.2 双缓冲与OnDraw让重绘不闪烁2.2.1 内存DC、兼容位图与BitBlt的配合在OnDraw里直接连续画几十个填充矩形用户会看到明显的闪烁和撕裂。原因是系统默认先擦除客户区背景再执行绘制代码擦除和绘制之间有一帧空白。常见做法是把所有画面先画到内存位图上最后一次BitBlt一次性推上屏幕用户永远看不到半成品。void CTetrisView::OnDraw(CDC* pDC) { CRect rcClient; GetClientRect(rcClient); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 绘制黑色背景 memDC.FillSolidRect(rcClient, RGB(16, 16, 16)); // 绘制网格线、已固定的方块、当前活动方块、右侧信息面板 DrawBoard(memDC); DrawFallenBlocks(memDC); DrawActiveBlock(memDC); DrawInfoPanel(memDC); // 一次拷贝到屏幕 pDC-BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); // 恢复原始位图避免GDI句柄泄漏 memDC.SelectObject(pOldBmp); }CreateCompatibleDC创建的内存DC与屏幕DC格式一致CreateCompatibleBitmap生成同样尺寸的离屏位图。SelectObject把位图选入DC后所有绘制操作都落在离屏表面。BitBlt的SRCCOPY光栅操作码表示源像素整体覆盖目标。函数结束前把旧位图选回否则memDC析构时位图被提前释放后续绘制会拿到悬空GDI对象。表格MFC绘制方案取舍速查绘制方式闪烁GDI句柄开销实现难度适用场景直接操作CClientDC明显最低低鼠标画板、临时预览OnDraw 双缓冲无每帧3个左右低俄罗斯方块这类游戏界面局部InvalidateRect轻微低中单行消除时的精细更新Direct2D硬件加速无独立渲染器高大量粒子或渐变特效俄罗斯方块一轮下落、一次旋转、一次消行都会触发重绘如果用局部刷新反而要计算具体失效矩形代码复杂度上升但收益有限。双缓冲下每帧绘制完整棋盘只在1到2毫秒左右即使放到远程桌面会话里也不会让位图操作成为瓶颈。远程桌面环境下偶尔出现画面粘滞不是代码问题是RDP对GDI绘制的批处理延迟与双缓冲本身无关。2.2.2 BitBlt参数与常见错误BitBlt(destX, destY, width, height, srcDC, srcX, srcY, rop)里有两组坐标容易搞混前两个是目标矩形左上角后两个是源矩形左上角宽高单位是像素。若源位图尺寸比目标矩形小BitBlt会复制位图边界外的未定义内容画面上出现杂色条纹。解决方法是保证CreateCompatibleBitmap的宽度高度与GetClientRect完全一致。窗口刚创建时OnDraw可能被提前调用此时客户区尺寸还是0需要判断宽高非零再创建位图否则会出现黑屏。2.3 用颜色表管理方块类型七个形状七个颜色建议用静态常量数组集中管理而不是在绘制函数里散落RGB字面量const COLORREF BLOCK_COLORS[7] { RGB(0, 240, 240), // I RGB(240, 240, 0), // O RGB(160, 0, 240), // T RGB(0, 240, 0), // S RGB(240, 0, 0), // Z RGB(0, 0, 240), // J RGB(240, 160, 0) // L };绘制某个格子时先从棋盘数组读出形状类型再用CreateSolidBrush(BLOCK_COLORS[type])创建画刷。颜色表的顺序必须与形状定义表SHAPES中的索引严格保持一致这是最容易出错的点——T和S颜色对调在调试窗口里很难一眼看出来。后续如果需要支持皮肤切换只需要替换这张表绘制代码完全不动。3. 俄罗斯方块核心逻辑源码拆解4x4形状矩阵、碰撞检测与消行3.1 7种形状的4x4矩阵定义标准俄罗斯方块有I、O、T、S、Z、J、L七种形状。用4x4矩阵表达每一种矩阵内值为1的位置有方块0为空。旋转时直接切换旋转状态碰撞检测变成坐标映射与棋盘数组比对比用结构体存储各小块坐标的写法更直观。// SHAPES[形状类型][旋转状态][4][4] // 旋转状态按顺时针计算0为初始状态 const int SHAPES[7][4][4][4] { // I初始为横向旋转90度后竖向 { { {0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0} }, { {0,0,1,0}, {0,0,1,0}, {0,0,1,0}, {0,0,1,0} }, // 180度旋转后与初始相同 { {0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0} }, { {0,0,1,0}, {0,0,1,0}, {0,0,1,0}, {0,0,1,0} } }, // O4个旋转状态完全一致 // T、S、Z、J、L按标准形状填写T的初始形状如下 };SHAPES[type][rot][row][col]四个维度分别表示形状类型、旋转状态、矩阵行、矩阵列。I的形状旋转180度后与初始一致O无论怎么旋转都不变化所以表里存在重复数据。用静态三维数组的优点是读取速度快游戏循环里每帧做碰撞检测不需要实时计算旋转矩阵缺点是牺牲了一点存储空间。如果追求极致压缩可以用16位整数位运算表示一个旋转状态但可读性明显下降不利于源码阅读和调试。3.1.1 用字符画验证形状定义写完这张表后最好的验证方法是在调试器里把它打印成字符画。在断言或断点位置调用void DumpShape(int type, int rot) { CString log; for (int i 0; i 4; i) { for (int j 0; j 4; j) { log SHAPES[type][rot][i][j] ? _T(#) : _T(.); } log _T(\r\n); } OutputDebugString(log); }在Visual Studio的输出窗口里#组成的位置就是方块实际覆盖的区域。用这个方法很容易发现形状表里某一行多了一个1或者少了一个1这是单纯看代码很难定位的问题。输出调试字符串用OutputDebugString而非printf是因为它不会阻塞GUI线程也不会在Release版本中引发命令行窗口闪烁。3.2 旋转与墙踢先转再撞撞了就平移试探旋转动作不能直接把当前坐标映射到新矩阵——旋转后方块可能越过左边界或右边界也可能与已固定的方块重叠。常见做法是先计算新的旋转状态再用一组平移偏移量去试探找到第一个能放下的位置就采用。bool CTetrisView::TryRotate() { int newRot (m_curRot 1) % 4; // 墙踢偏移表原地、右移1格、左移1格、右移2格、左移2格 const int kicks[][2] {{0,0}, {1,0}, {-1,0}, {2,0}, {-2,0}}; for (int k 0; k 5; k) { if (CanPlace(kicks[k][0], kicks[k][1], newRot)) { m_curX kicks[k][0]; m_curY kicks[k][1]; m_curRot newRot; return true; } } return false; }墙踢偏移表里的{1,0}表示旋转后如果与原位置冲突尝试向右平移一格{2,0}是针对贴着墙边的狭长形状比如I在左右边界附近旋转时需要移动两格才能容纳。这里实现的墙踢比经典SRS系统简单SRS还包含垂直方向偏移和针对J、L、T的对称偏移表但对大多数玩家手感来说水平两格的试探已经足够顺滑。注意偏移表的作用域是“本次旋转尝试”成功后才写入m_curX和m_curY失败则保持原位置不变这种先探测后提交的写法让游戏状态始终一致。3.3 碰撞检测与消行的边界约定碰撞检测是所有动作的公共前提移动、旋转、下落都要调用。棋盘坐标约定为m_board[行][列]活动方块的位置由左上角(m_curX, m_curY)与4x4矩阵叠加得到。bool CTetrisView::CanPlace(int dx, int dy, int rot) { const int (*cur)[4] SHAPES[m_curType][rot]; for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (cur[i][j] 0) continue; int bx m_curX j dx; int by m_curY i dy; // 超出左右边界或落到棋盘底部 if (bx 0 || bx m_nCols || by m_nRows) return false; // 上方允许负坐标方块可以部分在棋盘外生成 if (by 0 m_board[by][bx] ! 0) return false; } } return true; }m_curX和m_curY是活动方块矩阵左上角对应的棋盘坐标dx和dy是试探位移rot是目标旋转状态。by为负时表示方块还在窗口顶部的生成区此时不能判越界否则新方块一出生就被判定碰撞。m_board[by][bx]存放0或形状类型加一的值非零即占用。有些实现用独立布尔数组记录占用状态但那样绘制颜色时还要再查一次类型表不如直接在棋盘数组里存类型。下落到边界后固定当前方块并清理满行int CTetrisView::ClearLines() { int cleared 0; for (int y m_nRows - 1; y 0; y--) { bool full true; for (int x 0; x m_nCols; x) { if (m_board[y][x] 0) { full false; break; } } if (full) { cleared; // 从当前行开始把上方所有行下移一行 for (int yy y; yy 0; yy--) memcpy(m_board[yy], m_board[yy - 1], sizeof(m_board[0])); memset(m_board[0], 0, sizeof(m_board[0])); y; // 当前行被上方数据替换原地重新检查 } } return cleared; }从底部往上扫描找到满行就把该行以上的所有数据向下移动一行最顶行补零。y配合for循环的y--确保连续多行消除时不会跳过新落到当前位置的行。memcpy的拷贝长度sizeof(m_board[0])是棋盘第二维度的字节数假设int m_board[20][10]一行就是40字节不能写死数字否则改变列数时这里会静默出错。表格消行计分与等级速度对照单次消除行数得分等级每升1级下落间隔1100500ms → 440ms2300每级减少60ms3500最低限制100ms4800达到100ms后不再加速计分规则可以直接写在FixToBoard之后等级变化时调用ChangeSpeed重新设置定时器间隔。4. Windows消息驱动SetTimer定时下落、键盘事件与状态机4.1 SetTimer驱动下落为什么不用Sleep或循环从控制台程序转过来的人最容易犯的错是在一个while循环里Sleep(500)然后调用更新逻辑。这在MFC里会立刻卡死界面主线程被Sleep占用窗口消息全部排队鼠标拖动标题栏都会变成白框系统甚至会判定程序无响应。正确做法是把节奏交给消息循环用SetTimer在每间隔固定毫秒时投递一条WM_TIMER消息。int CTetrisView::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CView::OnCreate(lpCreateStruct) -1) return -1; m_nSpeed 500; // 初始下降间隔500毫秒 SetTimer(GAME_TIMER_ID, m_nSpeed, NULL); return 0; } void CTetrisView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent GAME_TIMER_ID m_nState STATE_RUNNING) { if (CanPlace(0, 1, m_curRot)) { m_curY; // 能放下就下落一行 } else { FixToBoard(); // 固定到棋盘 int lines ClearLines(); AddScore(lines); SpawnNewBlock(); // 生成下一个方块 // 新方块出生点立刻被占用说明游戏结束 if (!CanPlace(0, 0, m_curRot)) m_nState STATE_OVER; } Invalidate(FALSE); // 触发重绘但不擦背景 } CView::OnTimer(nIDEvent); }SetTimer(GAME_TIMER_ID, m_nSpeed, NULL)中第一个参数是定时器ID第二个是间隔毫秒数第三个回调函数指针填NULL表示消息发往窗口过程。WM_TIMER是低优先级消息系统在消息队列中有鼠标键盘事件时优先分发输入消息所以游戏不会因为下落逻辑占用CPU而丢键盘响应。Invalidate(FALSE)的FALSE参数很重要它告诉框架“背景已经画好不要擦除”配合第2章的双缓冲OnDraw里整体重绘一次不会出现闪烁。4.1.1 速度变化时的SetTimer重设等级提升时要调快下落速度不能只改m_nSpeed成员变量。定时器在SetTimer时已经把间隔固化到系统修改变量不会影响已注册的定时器void CTetrisView::ChangeSpeed(int newMs) { if (newMs 100) newMs 100; // 速度上限不能再快 KillTimer(GAME_TIMER_ID); m_nSpeed newMs; SetTimer(GAME_TIMER_ID, m_nSpeed, NULL); }先KillTimer再SetTimer确保旧定时器被清理避免同一个ID出现两个定时器实例。有的Windows版本允许直接SetTimer覆盖间隔但行为在不同系统上有差异显式清理最稳妥。速度变化后要检查m_nSpeed下限通常不低于100毫秒否则普通玩家根本没有反应时间。4.2 键盘处理PreTranslateMessage与OnKeyDown的差异MFC中按键响应可以放在OnKeyDown或PreTranslateMessage里。OnKeyDown是窗口过程收到WM_KEYDOWN后的标准处理路径但在对话框程序里方向键会被控件用来切换焦点一般不会到达视图。PreTranslateMessage在消息被TranslateMessage和DispatchMessage之前先行拦截能够吞掉按键事件最适合游戏这类需要独占键盘的场景。BOOL CTetrisView::PreTranslateMessage(MSG* pMsg) { if (pMsg-message WM_KEYDOWN m_nState STATE_RUNNING) { switch (pMsg-wParam) { case VK_LEFT: if (CanPlace(-1, 0, m_curRot)) m_curX--; break; case VK_RIGHT: if (CanPlace(1, 0, m_curRot)) m_curX; break; case VK_DOWN: if (CanPlace(0, 1, m_curRot)) { m_curY; m_nScore 1; // 软降每格加1分 } break; case VK_UP: TryRotate(); break; case VK_SPACE: HardDrop(); break; } Invalidate(FALSE); return TRUE; // 消息已处理停止继续分发 } return CView::PreTranslateMessage(pMsg); }返回TRUE表示这条消息不再进入后续分发流程防止方向键被系统翻译成焦点移动命令。Invalidate(FALSE)放在switch之后无论哪个按键生效都只重绘一帧避免每个case里重复写。注意m_curX和m_curY之前必须经过CanPlace校验否则方块会直接穿墙。硬降的逻辑值得一提它不能用循环加Sleep而要在单次调用里一口气落到底void CTetrisView::HardDrop() { int dropped 0; while (CanPlace(0, 1, m_curRot)) { m_curY; dropped; } FixToBoard(); int lines ClearLines(); m_nScore dropped lines * 100; SpawnNewBlock(); Invalidate(FALSE); }dropped记录硬降经过的格数每格1分消行分按表格中的梯度另算。硬降后同样要检查新生方块的碰撞如果卡死把这一逻辑抽取成LockCurrentBlock()方法供OnTimer和HardDrop共用而不是在两处复制三行相同的固定逻辑。表格按键与游戏状态允许表按键READYRUNNINGPAUSEDOVER空格开始游戏硬降恢复重新开始P无效暂停恢复无效方向键无效移动/旋转无效无效回车开始无效无效重新开始4.3 游戏状态机运行/暂停/结束的状态互斥状态用枚举成员变量表示所有输入入口都先检查当前状态避免在游戏结束时按方向键改坏棋盘enum GameState { STATE_READY, STATE_RUNNING, STATE_PAUSED, STATE_OVER };PreTranslateMessage的处理里空格键的行为依赖当前状态。运行中按空格是硬降准备状态按空格开始暂停状态按空格恢复结束状态按空格重新开局。把状态判断放在按键处理前确保同一按键在不同状态下只触发一个动作。OnTimer里如果忘了加m_nState STATE_RUNNING判断暂停后会发现方块继续下落这是新手最常见的逻辑漏项。状态机不复杂但它是保证源码可维护的关键许多扩展功能比如暂停菜单、复活、录像回放都要从这里接入。5. 源码调试与性能验证断点观察消息序列与OnDraw耗时5.1 用断点验证消息处理顺序在PreTranslateMessage、OnTimer、OnDraw三处各下一个断点运行程序后先按一次左移键再等一个下落周期观察调用堆栈和断点命中顺序。正常的消息顺序是PreTranslateMessage(VK_LEFT)→OnDraw→OnTimer→OnDraw。如果连续出现两次OnTimer而没有键盘消息插入说明定时器消息在队列里积压原因是OnDraw执行时间超过了定时器间隔消息来不及处理。Windows会把同一时间片内的多个WM_TIMER合并为一条所以积压不会无限膨胀但下落节奏会明显卡顿。此时优先优化绘制函数而不是加大定时器间隔。5.2 用QueryPerformanceCounter测量逐帧绘制耗时在OnDraw开头和结尾各采集一次高精度计数器输出到调试窗口能快速定位性能瓶颈LARGE_INTEGER freq, start, end; QueryPerformanceFrequency(freq); QueryPerformanceCounter(start); // ...双缓冲绘制代码... QueryPerformanceCounter(end); double ms (end.QuadPart - start.QuadPart) * 1000.0 / freq.QuadPart; CString log; log.Format(_T(OnDraw: %.2f ms\r\n), ms); OutputDebugString(log);QueryPerformanceCounter的精度远高于GetTickCount适合测量亚毫秒级的GDI绘制。双缓冲下10x20棋盘24像素格子每帧绘制应在1毫秒上下。若超过10毫秒检查两点是否错误地把Invalidate(FALSE)写成了Invalidate(TRUE)导致每次重绘多执行一次背景擦除是否在OnDraw里反复创建画刷和画笔却没有DeleteObjectGDI对象累积后BitBlt性能会断崖式下跌。验证消行正确性时在ClearLines返回后打开Watch窗口看m_board[19][0]到m_board[19][9]。最底行消除后应全部归零若始终有残留值检查memcpy的sizeof(m_board[0])参数棋盘定义为int m_board[20][10]时一行是40字节行列定义写反时数据拷贝长度错位表面上看代码逻辑没问题实际上已经把相邻行的数据覆盖掉。进阶改造中最值得做的一步是把游戏核心逻辑从MFC视图类中剥离用纯C类表示棋盘和旋转规则视图类只负责消息转发与绘制调用。这样俄罗斯方块的落地方案就从“MFC独占逻辑”变成“核心算法独立”可以方便地接到控制台、SDL或Qt前台上。源码调试时也只需要对核心类写断言不必每次都用窗口程序手工验证逻辑。工程里保留一份TetrisCore.h放纯逻辑让阅读源码的人把注意力先放在算法上而不是界面粘贴里。本文还有配套的精品资源点击获取
返回列表