1. 项目概述一个MFC中国象棋项目的深度拆解最近在整理旧项目时翻出了一个用MFC和Visual C写的中国象棋游戏。这个项目虽然年头不短但麻雀虽小五脏俱全从棋盘绘制、棋子移动、规则判定到胜负判断完整地走了一遍Windows桌面应用开发的全流程。对于想深入理解MFC框架、Windows消息机制以及如何将复杂业务逻辑比如象棋规则用C对象模型来实现的朋友来说这是一个非常经典的练手项目。今天我就把这个项目的源代码拿出来掰开揉碎了讲讲里面的门道不仅仅是“怎么跑起来”更重要的是“为什么这么设计”以及“实际开发中会遇到哪些坑”。这个项目适合两类人一类是正在学习C和Windows编程想找个有挑战性的综合项目练手的初学者另一类是已经有一定基础但对MFC的消息映射、GDI绘图或者复杂状态管理感到头疼想看看具体案例的中级开发者。我会假设你至少了解C的基本语法和面向对象概念对Visual Studio IDE有初步接触。不用担心MFC我会从最核心的框架讲起。2. 项目整体架构与MFC框架选型2.1 为什么选择MFC而不是其他框架首先得回答一个根本问题现在有Qt、WinForms、WPF甚至各种跨平台框架为什么还要回头啃MFC这个“老古董”来做中国象棋这恰恰是这个项目的学习价值所在。MFC虽然古老但它是对Windows API最直接、最经典的C封装。通过它你能清晰地理解一个Windows桌面应用从消息循环、窗口创建、资源管理到图形绘制的完整生命周期。对于学习底层原理而言它比那些高度抽象的现代框架更有教育意义。这个象棋项目采用经典的文档/视图Document/View架构。这是MFC单文档应用的标准模式非常契合棋类游戏。文档类CMyChessDoc负责管理游戏的核心数据模型。也就是棋盘的状态32个棋子在什么位置、当前轮到哪一方走棋、游戏是否结束、棋谱历史记录等。它不关心这些数据怎么显示只负责存储和逻辑。视图类CMyChessView负责数据的可视化呈现和用户交互。它从文档类获取棋盘数据然后用GDI在窗口上画出棋盘和棋子。同时它捕获鼠标点击消息将用户“点哪里”的坐标转换成“移动哪个棋子到哪”的逻辑指令再交给文档类去处理和验证。主框架窗口类CMainFrame负责管理菜单栏、工具栏和状态栏。比如“新游戏”、“悔棋”、“关于”这些菜单项的命令响应就在这里处理。这种分离设计的好处是逻辑清晰。修改游戏规则比如新增“翻棋”玩法只需改动文档类想换一套更漂亮的棋子皮肤只需重写视图类的绘制代码想加个计时器功能可以在主框架里添加。三者通过MFC内置的GetDocument()等机制通信耦合度较低。2.2 解决方案与工程结构解析在Visual Studio中打开项目你会看到一个典型的MFC工程结构。我以VS2010环境为例但原理相通。MyChineseChess/ ├── MyChineseChess.sln (解决方案文件) ├── MyChineseChess/ │ ├── MyChineseChess.vcxproj (项目文件) │ ├── Resource.h (资源ID定义) │ ├── MyChineseChess.rc (资源脚本图标、菜单、对话框) │ ├── stdafx.h/.cpp (预编译头文件) │ ├── TargetVer.h (目标平台版本定义) │ ├── MyChineseChess.h/.cpp (应用类 CMyChessApp) │ ├── MainFrm.h/.cpp (主框架类 CMainFrame) │ ├── MyChessDoc.h/.cpp (文档类 CMyChessDoc) │ ├── MyChessView.h/.cpp (视图类 CMyChessView) │ └── res/ (资源目录图标、位图)关键点解析stdafx.h预编译头这里集中包含了几乎整个项目都会用到的系统头文件比如afxwin.hMFC核心、afxext.hMFC扩展。预编译的目的是加速编译过程。一个常见的坑是如果你在某个.cpp文件里自行包含了windows.h可能会引起宏定义冲突比如min/max。最佳实践是所有需要Windows API或MFC特性的地方都通过#include “stdafx.h”来引入并确保它是第一个被包含的头文件。资源文件(.rc)这是Windows编程的特色。菜单项的文字、ID、排列对话框的布局程序的图标甚至棋子位图都在这里以声明式的方式定义。在代码中我们通过ID如ID_GAME_NEW来引用它们。实操心得在资源编辑器中修改菜单后记得在代码中为新的菜单项添加消息映射ON_COMMAND和对应的处理函数否则点击会没反应。应用类 CMyChessApp派生自CWinApp。它的InitInstance()函数是程序的入口点虽然main/WinMain被框架隐藏了。在这里我们注册文档模板将文档、视图和框架窗口关联起来。一个重要的细节MFC应用是单线程的所有消息处理都在主线程。这意味着如果在棋子移动的AI计算如果有的话或者复杂的绘制中执行耗时操作界面会“卡死”。对于象棋游戏一般计算量不大但如果未来要加入AI就需要考虑多线程或异步处理这是后话。3. 核心数据模型与游戏逻辑实现3.1 棋盘与棋子的数据表示游戏的核心是数据。如何用C的数据结构来表示一个10行9列的中国象棋棋盘和32个棋子在CMyChessDoc类中你可能会看到类似这样的定义// 棋子类型枚举 enum PieceType { PIECE_NONE 0, // 空位 PIECE_KING, // 将/帅 PIECE_ADVISOR, // 士/仕 PIECE_BISHOP, // 象/相 PIECE_KNIGHT, // 马/傌 PIECE_ROOK, // 车/俥 PIECE_CANNON, // 炮/炮 PIECE_PAWN // 兵/卒 }; // 棋子颜色阵营 enum PieceColor { COLOR_RED, // 红方 COLOR_BLACK // 黑方 }; // 棋子结构体 struct ChessPiece { PieceType type; PieceColor color; bool isVisible; // 是否被吃掉了 // 可能还有位置信息但更常见的做法是位置由棋盘数组索引隐含 }; // 棋盘一个10行9列的二维数组每个元素是一个ChessPiece或指向它的指针/智能指针。 // 更高效的实现直接用整数编码类型和颜色。 class CMyChessDoc : public CDocument { private: int m_board[10][9]; // 用整数编码棋子。例如0表示空1-7表示红方棋子类型11-17表示黑方棋子类型。 PieceColor m_currentPlayer; // 当前行棋方 CPoint m_selectedPos; // 当前选中的棋子位置(-1, -1表示未选中) std::vectorMoveHistory m_history; // 棋谱历史 // ... 其他状态如游戏是否结束、胜负方等。 };为什么用二维数组而不是对象列表对于棋盘类游戏基于位置的随机访问给定坐标立刻知道是什么棋子是最频繁的操作。二维数组或一维数组模拟二维的O(1)访问效率远高于遍历链表或向量来查找某个位置的棋子。整数编码比存储对象指针更节省内存序列化保存棋局也更简单。初始化棋盘在文档类的OnNewDocument()或一个专门的ResetGame()函数中需要按照中国象棋的初始布局设置m_board数组的值。例如红方底线的车、马、相、士、帅、士、相、马、车依次摆放在第0行假设行号从上到下增加的第0,1,2,3,4,5,6,7,8列。3.2 棋子移动规则的算法实现这是游戏逻辑中最复杂也最有趣的部分。规则判断函数通常形如bool CMyChessDoc::IsMoveValid(const CPoint from, const CPoint to) const { // 1. 基础检查 if (!IsPositionOnBoard(from) || !IsPositionOnBoard(to)) return false; if (from to) return false; // 不能原地不动 int piece m_board[from.y][from.x]; if (piece 0 || GetPieceColor(piece) ! m_currentPlayer) return false; // 选中的必须是己方棋子 if (m_board[to.y][to.x] ! 0 GetPieceColor(m_board[to.y][to.x]) m_currentPlayer) return false; // 不能吃自己的子 // 2. 根据棋子类型分发到具体的规则函数 PieceType type GetPieceType(piece); switch (type) { case PIECE_KING: return IsKingMoveValid(from, to); case PIECE_ADVISOR: return IsAdvisorMoveValid(from, to); case PIECE_BISHOP: return IsBishopMoveValid(from, to); case PIECE_KNIGHT: return IsKnightMoveValid(from, to); case PIECE_ROOK: return IsRookMoveValid(from, to); case PIECE_CANNON: return IsCannonMoveValid(from, to); case PIECE_PAWN: return IsPawnMoveValid(from, to); default: return false; } }接下来我们以**马KNIGHT和炮CANNON**为例看看具体规则如何实现。马的走法走日字蹩马腿bool CMyChessDoc::IsKnightMoveValid(const CPoint from, const CPoint to) const { int dx to.x - from.x; int dy to.y - from.y; // 马走日绝对坐标差为(±1, ±2)或(±2, ±1) if (!((abs(dx)1 abs(dy)2) || (abs(dx)2 abs(dy)1))) { return false; } // 检查蹩马腿马腿位置在from和to的“日”字拐角处 CPoint blockPos; if (abs(dx) 2) { // 横向走日检查竖向马腿 blockPos.x from.x dx/2; blockPos.y from.y; } else { // abs(dy)2纵向走日检查横向马腿 blockPos.x from.x; blockPos.y from.y dy/2; } // 马腿位置必须有棋子则被蹩住不能走 if (m_board[blockPos.y][blockPos.x] ! 0) { return false; } return true; }关键细节蹛马腿的判断是新手实现时最容易出错的地方。必须准确计算“马腿”的坐标。上述代码通过判断是横向走日还是纵向走日来定位马腿位置逻辑清晰。炮的走法直线移动吃子需隔一子bool CMyChessDoc::IsCannonMoveValid(const CPoint from, const CPoint to) const { // 炮必须直线移动 if (from.x ! to.x from.y ! to.y) return false; // 计算路径上的棋子数 int stepX (to.x from.x) ? 1 : (to.x from.x) ? -1 : 0; int stepY (to.y from.y) ? 1 : (to.y from.y) ? -1 : 0; int x from.x stepX; int y from.y stepY; int pieceCount 0; while (x ! to.x || y ! to.y) { if (m_board[y][x] ! 0) pieceCount; x stepX; y stepY; } int targetPiece m_board[to.y][to.x]; if (targetPiece 0) { // 目标为空则移动路径上必须无任何棋子 return (pieceCount 0); } else { // 目标有敌方棋子则路径上必须恰好有一个棋子作为“炮架” return (pieceCount 1); } }注意事项炮的移动和吃子逻辑是分开的。移动时像车一样不能越子吃子时必须隔一个棋子。代码中通过遍历起点到终点的路径不包含终点本身统计遇到的棋子数量从而统一判断这是一种简洁的实现。将帅不能照面这是一个特殊的全局规则不能只放在IsKingMoveValid里判断。因为即使将/帅本身没动其他棋子移开后也可能导致“照面”。通常需要在每次走棋后或走棋前的预判中单独调用一个CheckKingFaceToFace()函数来检查。算法是找到双方将/帅的位置如果它们在同一列且中间没有任何棋子则判犯规。3.3 游戏状态管理与胜负判定文档类还需要管理游戏流程。行棋方切换每次成功走棋后执行m_currentPlayer (m_currentPlayer COLOR_RED) ? COLOR_BLACK : COLOR_RED;。胜负判定将死检查当前行棋方是否无任何合法着法可走。这需要遍历己方所有棋子对每个棋子遍历所有可能的目标位置调用IsMoveValid。如果找不到任何一步合法走法则被将死对方获胜。困毙同上但当前行棋方未被将军只是无子可动通常判和或根据规则判负。将帅被吃在走棋逻辑中如果一步棋的目标位置是对方的将/帅则直接判定游戏结束。更严谨的做法是在走棋后检查对方将/帅是否还在棋盘上。棋谱记录与悔棋使用std::vectorMoveHistory来记录每一步。MoveHistory结构体至少应包含from,to,movedPiece,capturedPiece被吃的子如果没有则为0。悔棋时从历史记录中弹出最后一步恢复棋盘状态和行棋方。一个关键点悔棋操作需要同时更新视图。通常的做法是在文档类中完成数据回滚后调用UpdateAllViews(NULL)通知所有视图更新显示。4. 视图绘制与用户交互实现4.1 使用GDI绘制棋盘与棋子视图类CMyChessView的重头戏在OnDraw(CDC* pDC)函数中。这里使用Windows GDI进行绘图。绘制棋盘 棋盘是固定的9x10网格。首先计算每个格子的像素大小cellWidth,cellHeight通常根据窗口客户区大小动态计算。void CMyChessView::OnDraw(CDC* pDC) { CMyChessDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; CRect clientRect; GetClientRect(clientRect); int boardWidth clientRect.Width() * 0.9; // 棋盘占90%宽度 int boardHeight clientRect.Height() * 0.9; int cellW boardWidth / 9; int cellH boardHeight / 10; int offsetX (clientRect.Width() - boardWidth) / 2; int offsetY (clientRect.Height() - boardHeight) / 2; // 1. 绘制棋盘背景和网格线 CBrush brBackground(RGB(240, 220, 180)); // 浅黄色木纹背景 pDC-FillRect(CRect(offsetX, offsetY, offsetXboardWidth, offsetYboardHeight), brBackground); CPen gridPen(PS_SOLID, 2, RGB(0, 0, 0)); CPen* pOldPen pDC-SelectObject(gridPen); // 画竖线 for (int i 0; i 9; i) { pDC-MoveTo(offsetX i * cellW, offsetY); pDC-LineTo(offsetX i * cellW, offsetY 10 * cellH); } // 画横线 for (int j 0; j 10; j) { pDC-MoveTo(offsetX, offsetY j * cellH); pDC-LineTo(offsetX 9 * cellW, offsetY j * cellH); } // 画楚河汉界中间一条粗线或文字 // ... 省略具体代码 pDC-SelectObject(pOldPen); }绘制棋子 棋子可以用文字“车”、“马”、“炮”绘制也可以用提前加载的位图绘制。文字绘制简单但位图更美观。// 2. 绘制棋子 for (int row 0; row 10; row) { for (int col 0; col 9; col) { int piece pDoc-m_board[row][col]; if (piece 0) continue; // 空位跳过 CPoint center(offsetX col * cellW cellW/2, offsetY row * cellH cellH/2); int radius min(cellW, cellH) / 2 - 5; // 绘制圆形棋子背景 COLORREF fillColor (GetPieceColor(piece) COLOR_RED) ? RGB(255, 200, 200) : RGB(100, 100, 100); CBrush pieceBrush(fillColor); CPen piecePen(PS_SOLID, 2, RGB(0,0,0)); pDC-SelectObject(pieceBrush); pDC-SelectObject(piecePen); pDC-Ellipse(center.x - radius, center.y - radius, center.x radius, center.y radius); // 绘制棋子文字 CString strPiece GetPieceText(piece); // 根据编码返回“车”、“马”等 pDC-SetTextColor((GetPieceColor(piece) COLOR_RED) ? RGB(200, 0, 0) : RGB(0, 0, 0)); pDC-SetBkMode(TRANSPARENT); pDC-DrawText(strPiece, CRect(center.x-radius, center.y-radius, center.xradius, center.yradius), DT_CENTER | DT_VCENTER | DT_SINGLELINE); // 如果这个位置被选中高亮显示例如画一个红色圆圈 if (pDoc-m_selectedPos.x col pDoc-m_selectedPos.y row) { CPen hlPen(PS_SOLID, 3, RGB(255, 0, 0)); pDC-SelectObject(hlPen); CBrush* pOldBr pDC-SelectObject(CBrush::FromHandle((HBRUSH)GetStockObject(NULL_BRUSH))); pDC-Ellipse(center.x - radius - 3, center.y - radius - 3, center.x radius 3, center.y radius 3); pDC-SelectObject(pOldBr); } } }性能优化提示OnDraw在窗口需要重绘时会被频繁调用。如果绘制复杂比如使用高质量位图可能会闪烁。可以采用双缓冲技术先在内存DC中绘制整个画面然后一次性BitBlt到屏幕DC。这是MFC图形编程中一个非常经典的优化技巧。4.2 鼠标交互与消息映射用户通过鼠标点击来走棋。这需要处理WM_LBUTTONDOWN消息。在视图类的消息映射表中添加BEGIN_MESSAGE_MAP(CMyChessView, CView) ON_WM_LBUTTONDOWN() // ... 其他消息映射 END_MESSAGE_MAP()实现OnLButtonDown处理函数void CMyChessView::OnLButtonDown(UINT nFlags, CPoint point) { CMyChessDoc* pDoc GetDocument(); // 1. 将鼠标客户区坐标转换为棋盘网格坐标 CPoint boardPos PointToBoardPosition(point); if (!IsPositionOnBoard(boardPos)) return; // 点在了棋盘外 // 2. 获取文档中当前选中的位置 CPoint selectedPos pDoc-m_selectedPos; if (selectedPos.x -1) { // 之前没有选中任何棋子 // 尝试选中当前位置的棋子必须是当前行棋方的棋子 if (pDoc-CanSelectPieceAt(boardPos)) { selectedPos boardPos; pDoc-UpdateAllViews(NULL); // 刷新视图高亮显示选中的棋子 } } else { // 之前已经选中了一个棋子 // 尝试移动选中的棋子到当前位置 if (pDoc-TryMovePiece(selectedPos, boardPos)) { // 移动成功切换行棋方清空选中状态 selectedPos.x selectedPos.y -1; pDoc-SwitchPlayer(); pDoc-UpdateAllViews(NULL); // 刷新视图显示新棋局 // 检查游戏是否结束 if (pDoc-CheckGameOver()) { CString msg (pDoc-GetWinner() COLOR_RED) ? _T(红方获胜) : _T(黑方获胜”); AfxMessageBox(msg); } } else { // 移动无效。有两种处理方式 // A. 清空选中状态让用户重新选简单 // selectedPos.x selectedPos.y -1; // pDoc-UpdateAllViews(NULL); // B. 如果点击的是另一个己方棋子则改为选中它更符合用户习惯 if (pDoc-CanSelectPieceAt(boardPos)) { selectedPos boardPos; pDoc-UpdateAllViews(NULL); } } } CView::OnLButtonDown(nFlags, point); }PointToBoardPosition函数将像素坐标(point.x, point.y)转换为棋盘行列索引(col, row)。需要用到之前计算好的offsetX, offsetY, cellW, cellH。用户体验细节视觉反馈选中棋子时高亮显示如上述代码中的红色圆圈移动过程中如果鼠标悬停在合法目标格子上可以改变光标形状或格子颜色。音效可以在TryMovePiece成功时播放一个落子音效。MFC中可以使用PlaySound函数播放WAV文件。悔棋操作通常绑定到菜单项ID_EDIT_UNDO或快捷键CtrlZ。在其命令处理函数中调用文档类的UndoLastMove()然后更新视图。5. 功能扩展与高级实现技巧一个基础的中国象棋游戏已经完成了。但要让它更完善、更专业还可以加入很多功能。5.1 网络对战功能的实现思路实现双人对战网络版核心是网络通信和状态同步。一个简单的方案是使用TCP套接字采用客户端-服务器C/S架构或点对点P2P架构。C/S架构服务器作为裁判和状态同步中心。两个客户端连接服务器所有走棋操作都发送给服务器服务器验证后广播给另一个客户端。服务器还可以负责匹配玩家、记录棋谱等。P2P架构两个客户端直接连接。需要解决谁先走红方、网络地址发现NAT穿透等问题实现更复杂。在MFC中可以使用Windows Sockets APIWSAStartup,socket,bind,listen,accept,send,recv或MFC封装的CAsyncSocket/CSocket类。关键点消息协议设计定义简单的应用层协议。例如用一个结构体表示一步棋#pragma pack(push, 1) // 按1字节对齐避免不同平台/编译器下结构体大小不一致 struct ChessMovePacket { unsigned char cmd; // 命令字如 M表示走棋C表示聊天 unsigned char fromX, fromY; unsigned char toX, toY; // 可以加入时间戳、玩家ID等 }; #pragma pack(pop)线程处理网络接收数据是阻塞或异步的不能在主UI线程中长时间等待。需要创建单独的工作线程来处理网络通信收到数据后通过Windows消息PostMessage或自定义事件通知主线程更新UI。这是MFC多线程编程的难点切记不可在工作线程中直接调用MFC窗口类的方法如更新控件这会导致不可预知的问题。正确的做法是工作线程将数据打包通过PostMessage发送一个自定义消息如WM_USER 100到主窗口主窗口的消息处理函数再安全地更新UI。5.2 简单AI电脑对手的实现为象棋游戏添加一个初级AI可以让单人游戏。最简单的AI算法是随机走法枚举当前电脑方所有棋子的所有合法走法然后随机选一个。虽然很弱但实现简单。稍强一点的可以用基于规则的简单评估生成所有合法走法列表。对每一个走法进行评分。评分规则可以包括吃子价值吃掉对方的车、马、炮等大子得分高。棋子位置价值棋子走到更有利的位置如车占肋、马卧槽加分。威胁价值走完这步后是否将军是否形成对对方大子的威胁选择得分最高的走法。这需要设计一个评估函数EvaluateBoard()对当前棋盘局面给出一个分数正数对红方有利负数对黑方有利。AI假设是黑方的任务就是找到一步棋使得走完后的新棋盘局面评估分数最低因为对黑方有利意味着分数负得越多。这就是**极小化极大算法Minimax**的雏形。如果再加入一层递归考虑对方下一步怎么走就构成了一个简单的搜索树。性能考虑象棋分支因子很大递归深度不能太深2-3层可能就够用了。需要用到Alpha-Beta剪枝来优化搜索效率。对于MFC项目如果AI思考时间过长一定要放在工作线程中计算避免界面卡顿。5.3 棋谱保存与复盘功能棋谱记录如PGN格式和复盘是象棋软件的重要功能。实现起来并不复杂。保存在文档类中我们已经用std::vectorMoveHistory记录了每一步。可以将这个列表序列化到文件。最简单的文本格式可以是每行记录一步“车二平五”、“马8进7”这样的中式记谱法或者更通用的“fromX,fromY,toX,toY”坐标格式。MFC文档类本身支持序列化Serialize函数可以方便地利用它来保存和加载整个文档包括棋盘状态和历史记录。复盘加载棋谱文件后将历史记录恢复到m_history。提供一个“上一步”、“下一步”的播放控件。复盘时根据当前步数索引将棋盘状态设置到历史记录中的对应位置然后重绘视图。注意复盘模式可能需要禁用用户的走棋操作。6. 常见问题排查与调试技巧在开发这个MFC象棋项目时你肯定会遇到一些典型问题。这里记录几个我踩过的坑和解决方法。6.1 编译与链接错误“无法找到 vcbuild.exe” 或 “MSB3428” 错误 这是缺少Visual C构建工具。不要被复杂的错误信息吓到。解决方案是安装对应版本的Microsoft Visual C Redistributable和Visual Studio 构建工具。更一劳永逸的方法是直接通过Visual Studio Installer确保安装了对应工作负载下的“C桌面开发”组件及其所有默认子项。MFC相关链接错误LNK2001, LNK2005 确保项目配置正确。在项目属性 - 配置属性 - 常规中“MFC的使用”应设置为“在共享DLL中使用MFC”对于发布或“在静态库中使用MFC”如果不想依赖系统MFC DLL。如果从旧版本VS升级项目可能需要更新项目平台工具集。6.2 运行时逻辑错误棋子走法规则判断错误现象某个棋子可以走到不该走的位置或者不能走到该走的位置。调试在IsMoveValid和相关子函数中设置断点。重点关注边界条件比如马的蹩腿计算、炮的隔子判断、将帅的九宫格限制。打印出from和to的坐标手动计算是否符合规则。一个有用的技巧在视图的OnDraw函数中临时绘制出当前选中棋子的所有合法目标位置比如用绿色小点可以直观地验证规则函数是否正确。游戏状态不同步现象走棋后棋盘显示没更新或者行棋方没切换。检查确认走棋成功后是否调用了pDoc-UpdateAllViews(NULL)这个函数会触发所有关联视图的OnDraw。确认文档数据m_board,m_currentPlayer确实被正确修改了。可以在TryMovePiece函数前后打印棋盘状态来对比。如果是多视图比如同时有棋盘视图和棋谱列表视图确保文档的更新通知能到达所有视图。6.3 界面与性能问题绘图闪烁原因OnDraw中直接向屏幕DC绘制在复杂绘制时中间过程会被用户看到。解决方案双缓冲。void CMyChessView::OnDraw(CDC* pDC) { // ... 获取文档数据计算绘制区域等 CRect rect; GetClientRect(rect); // 创建内存DC和兼容位图 CDC memDC; CBitmap memBitmap; memDC.CreateCompatibleDC(pDC); memBitmap.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBitmap memDC.SelectObject(memBitmap); // 先用背景色填充内存DC memDC.FillSolidRect(rect, RGB(255, 255, 255)); // 将所有的绘制操作都画到memDC上 DrawChessBoard(memDC); DrawChessPieces(memDC); // ... 其他绘制 // 最后一次性将内存DC的内容拷贝到屏幕DC pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); // 清理 memDC.SelectObject(pOldBitmap); memBitmap.DeleteObject(); memDC.DeleteDC(); }鼠标点击坐标转换不准现象点击棋子边缘时选不中或者点格子A却选中了格子B。检查PointToBoardPosition函数。确保计算cellW,cellH,offsetX,offsetY的逻辑与OnDraw中绘制棋盘时完全一致。考虑加入一个“点击容差”比如点击位置距离格子中心在半个格子宽度内都算选中该格子。6.4 内存与资源管理GDI资源泄漏 MFC中CPen,CBrush,CFont,CBitmap等GDI对象如果不用了必须及时删除或由MFC对象析构函数删除。确保它们在函数退出前被SelectObject回旧的或者直接定义在栈上让析构函数处理。一个良好习惯对于需要临时使用的GDI对象在函数开头创建函数结束时MFC类会自动调用其析构函数释放资源。调试技巧多用TRACE宏输出调试信息在Visual Studio的输出窗口查看。对于复杂的规则判断可以写一些单元测试用例在控制台程序里测试核心逻辑函数确保正确后再集成到MFC项目中。使用Visual Studio的图形化调试器观察m_board数组在每一步走棋前后的变化。这个MFC中国象棋项目虽然界面看起来简单但背后涉及了Windows程序框架、消息机制、图形绘制、数据结构、算法逻辑等多个核心知识点。把它从头到尾实现并调试通过对深入理解Windows桌面开发有极大的帮助。希望这篇超详细的解析能帮你少走弯路更顺畅地完成自己的象棋程序。
网站建设
高端定制
企业官网