公司动态
VC++开发围棋游戏:从MFC框架到提子算法的完整实践指南
1. 项目概述为什么选择VC开发围棋游戏如果你是一位有一定C基础或者对Windows桌面应用开发感兴趣的开发者想找一个既有挑战性又能全面锻炼功力的项目那么用VCVisual C开发一个围棋游戏绝对是一个绝佳的选择。这听起来可能有点“复古”毕竟现在Unity、UE4/5或者各种跨平台框架才是游戏开发的主流。但恰恰是这种“复古”能让你深入到图形渲染、消息循环、算法逻辑和性能优化的底层细节中这些是使用高级引擎时常常被封装和忽略的。围棋本身规则简单但逻辑复杂棋盘状态、落子判定、提子、胜负计算每一步都需要严谨的代码实现非常适合用来磨练编程基本功和算法思维。VC特别是搭配经典的MFCMicrosoft Foundation Classes框架是Windows原生桌面开发的“老炮儿”。它直接与Windows API对话没有中间层执行效率高对系统资源的控制力强。开发一个围棋游戏你将亲手处理窗口创建、GDI/GDI绘图、鼠标消息响应、定时器、文件存储等一系列核心操作。这个过程就像亲手组装一台精密的机械钟表每一个齿轮函数如何咬合每一根发条消息如何驱动你都了然于胸。虽然学习曲线比拖拽式开发要陡峭但一旦掌握你对Windows程序运行机制的理解会达到一个全新的层次。这个项目适合谁呢首先是C的初学者或中级学习者希望将书本知识转化为一个看得见、摸得着的完整项目。其次是那些对游戏逻辑、AI算法感兴趣但希望从相对轻量级的2D棋盘游戏入手的开发者。最后也包括那些希望深入理解Windows桌面应用底层原理而不满足于停留在.NET或WPF等高级框架表面的技术爱好者。通过这个项目你得到的不仅仅是一个可以运行的围棋程序更是一套扎实的桌面软件开发方法论。2. 整体架构与核心模块设计2.1 技术选型MFC还是纯Win32 API这是动手前第一个要做的决策。MFC是微软提供的一个C类库封装了大部分Windows API用面向对象的方式组织窗口、控件、文档等。它的优势是开发效率相对较高有成熟的文档视图架构对于管理游戏状态如棋谱记录、悔棋比较方便。你可以利用CView类来专门处理绘图和鼠标输入用CDocument类来管理游戏数据模型。而纯Win32 API开发意味着你直接调用CreateWindow,DispatchMessage,BeginPaint这些底层函数。这种方式代码量更大更“原始”但控制力也最强没有任何“黑盒”性能理论上也最极致。对于围棋这种绘图不复杂、逻辑计算密集的应用两者在性能上差异不大。我的建议是如果你是第一次用VC做图形界面项目从MFC入手会更容易。它能帮你快速搭建起程序骨架把精力集中在游戏逻辑本身。本指南也将主要基于MFC框架进行讲解。但我会在关键部分如消息处理和GDI绘图指出对应的Win32 API原理让你知其然更知其所以然。2.2 核心模块划分一个完整的围棋游戏可以清晰地划分为以下几个模块这种高内聚、低耦合的设计便于开发和调试图形界面模块负责棋盘、棋子的绘制以及鼠标点击、窗口重绘等用户交互的响应。这是游戏的“脸面”。游戏逻辑模块这是游戏的核心“大脑”。包括棋盘模型用一个二维数组例如int board[19][19]表示棋盘状态0为空1为黑子-1为白子。落子规则判定点击位置是否在交叉点、该位置是否已有棋子。提子算法这是围棋逻辑中最复杂的部分之一。需要实现“气”的计算判断一片相连的同色棋子是否还有“气”相邻的空交叉点若无气则需将其从棋盘上提走。胜负判定通常采用数子法或数目法。在单机版中可以在游戏结束后让程序自动计算双方围成的空点和活子总数。禁着点打劫规则一个高级规则防止全局同形再现。初期可以暂缓实现先完成基本功能。游戏控制模块管理游戏流程如轮流落子、悔棋、重新开始、棋谱保存与加载等。AI对战模块可选但推荐实现一个简单的电脑对手比如基于随机落子、或简单的模式匹配如“占角”、“守边”后期可以引入更复杂的算法如蒙特卡洛树搜索。注意在项目初期切忌贪大求全。建议采用迭代开发先实现一个能画棋盘、响应鼠标落子的静态程序再加入提子等基本逻辑最后完善悔棋、存盘、简单AI等功能。每一步都确保可运行、可测试。3. 开发环境搭建与项目创建3.1 安装Visual Studio与VC组件首先你需要安装Visual Studio。推荐使用Visual Studio 2019或2022的社区版它们对个人开发者免费且功能完整。在安装过程中务必在“工作负载”选项卡中勾选“使用C的桌面开发”。这个工作负载包含了VC编译器、链接器、MFC库、ATL库以及调试器等所有必需工具。安装完成后建议通过“工具”-“获取工具和功能”检查是否安装了“用于Windows的C MFC”这个可选组件以确保MFC支持完整。3.2 创建MFC应用程序项目打开Visual Studio选择“创建新项目”。在搜索框中输入“MFC”选择“MFC应用程序”模板点击下一步。配置项目给你的项目起个名字比如“GoGame”选择好项目位置。应用程序类型选择“单个文档”即可。我们不需要多文档界面。“文档/视图架构支持”建议勾选它天然地将数据棋局状态和显示棋盘绘制分离符合我们的模块设计。用户界面功能保持默认使用“经典菜单”和“标准停靠”工具栏即可。可以取消“最小化框”和“最大化框”因为游戏窗口大小通常固定。高级功能通常不需要改动保持默认即可。生成的类这里你会看到模板为你生成了CGoGameApp应用类、CMainFrame主框架类、CGoGameDoc文档类和CGoGameView视图类。视图类CGoGameView将是我们主要的工作战场。点击“完成”VS会为你生成一个具备基本窗口、菜单和工具栏的MFC程序框架。按F5编译运行你将看到一个标准的Windows窗口程序。恭喜你的围棋游戏“骨架”已经搭建好了。4. 核心实现从棋盘绘制到游戏逻辑4.1 定义棋盘数据模型游戏的所有状态都应该存储在文档类CGoGameDoc中。打开GoGameDoc.h头文件在类定义中添加核心数据成员。// GoGameDoc.h class CGoGameDoc : public CDocument { // ... 其他生成的代码 public: // 棋盘常量 static const int BOARD_SIZE 19; // 标准围棋棋盘19路 static const int CELL_SIZE 30; // 每个格子的像素大小 static const int MARGIN 40; // 棋盘边距 // 游戏状态 int m_board[BOARD_SIZE][BOARD_SIZE]; // 棋盘状态0空1黑-1白 bool m_isBlackTurn; // 当前是否轮到黑方落子 CPoint m_lastMove; // 上一步落子位置用于界面提示或悔棋 std::vectorstd::pairCPoint, int m_moveHistory; // 落子历史用于悔棋 // 初始化棋盘 void InitBoard(); // 在(x,y)落子color为1(黑)或-1(白)返回是否成功 bool PlaceStone(int x, int y, int color); // 计算并提走无气的棋子返回被提子的列表 std::vectorCPoint RemoveDeadStones(int x, int y, int color); // 判断游戏是否结束简易版如双方PASS bool IsGameOver(); };在GoGameDoc.cpp中实现InitBoard和PlaceStone的初始版本。// GoGameDoc.cpp void CGoGameDoc::InitBoard() { for (int i 0; i BOARD_SIZE; i) for (int j 0; j BOARD_SIZE; j) m_board[i][j] 0; m_isBlackTurn true; // 黑先 m_moveHistory.clear(); } bool CGoGameDoc::PlaceStone(int x, int y, int color) { // 1. 边界检查 if (x 0 || x BOARD_SIZE || y 0 || y BOARD_SIZE) return false; // 2. 位置是否有子 if (m_board[x][y] ! 0) return false; // 3. 暂存棋盘状态为打劫等复杂规则做准备 int tempBoard[BOARD_SIZE][BOARD_SIZE]; memcpy(tempBoard, m_board, sizeof(m_board)); // 4. 落子 m_board[x][y] color; // 5. 提子先实现后面详细讲算法 auto deadStones RemoveDeadStones(x, y, color); // 6. 检查落子后自身是否无气自杀规则 if (deadStones.empty()) { // 如果提走了对方棋子自己肯定有气。如果没提走需要检查自己新下的子是否有气 if (!HasLiberty(x, y, color)) { // 自杀恢复棋盘 memcpy(m_board, tempBoard, sizeof(m_board)); return false; } } // 7. 记录历史 m_moveHistory.push_back({ CPoint(x, y), color }); m_lastMove CPoint(x, y); m_isBlackTurn !m_isBlackTurn; // 切换回合 UpdateAllViews(NULL); // 通知所有视图更新 return true; }4.2 视图类绘制棋盘与棋子现在我们需要在视图类CGoGameView中绘制棋盘。MFC的绘图通常在OnDraw函数中进行。打开GoGameView.cpp找到OnDraw函数。// GoGameView.cpp void CGoGameView::OnDraw(CDC* pDC) { CGoGameDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; // 1. 获取文档中的棋盘数据 int boardSize pDoc-BOARD_SIZE; int cellSize pDoc-CELL_SIZE; int margin pDoc-MARGIN; // 2. 绘制棋盘背景浅黄色木纹效果 CBrush brushBg(RGB(220, 179, 92)); // 浅木色 CRect rectClient; GetClientRect(rectClient); pDC-FillRect(rectClient, brushBg); // 3. 绘制棋盘网格线 CPen gridPen(PS_SOLID, 2, RGB(0, 0, 0)); // 2像素宽的黑线 CPen* pOldPen pDC-SelectObject(gridPen); for (int i 0; i boardSize; i) { // 横线 pDC-MoveTo(margin, margin i * cellSize); pDC-LineTo(margin (boardSize - 1) * cellSize, margin i * cellSize); // 竖线 pDC-MoveTo(margin i * cellSize, margin); pDC-LineTo(margin i * cellSize, margin (boardSize - 1) * cellSize); } // 4. 绘制九个星位点天元 int starPoints[9][2] { {3,3},{3,9},{3,15},{9,3},{9,9},{9,15},{15,3},{15,9},{15,15} }; CBrush starBrush(RGB(0, 0, 0)); for (int i 0; i 9; i) { int x margin starPoints[i][0] * cellSize; int y margin starPoints[i][1] * cellSize; pDC-Ellipse(x - 4, y - 4, x 4, y 4); // 画一个小圆点 } // 5. 绘制棋子 for (int i 0; i boardSize; i) { for (int j 0; j boardSize; j) { if (pDoc-m_board[i][j] ! 0) { int centerX margin i * cellSize; int centerY margin j * cellSize; int radius cellSize / 2 - 2; // 棋子半径略小于格子一半 // 创建渐变画刷让棋子有立体感简易版 CBrush stoneBrush; if (pDoc-m_board[i][j] 1) { // 黑子 stoneBrush.CreateSolidBrush(RGB(50, 50, 50)); } else { // 白子 stoneBrush.CreateSolidBrush(RGB(255, 255, 255)); } CBrush* pOldBrush pDC-SelectObject(stoneBrush); CPen stonePen(PS_SOLID, 1, RGB(100, 100, 100)); CPen* pOldPen2 pDC-SelectObject(stonePen); pDC-Ellipse(centerX - radius, centerY - radius, centerX radius, centerY radius); // 恢复原来的画刷和笔 pDC-SelectObject(pOldBrush); pDC-SelectObject(pOldPen2); } } } // 6. 高亮显示最后一步落子可选 if (pDoc-m_lastMove.x 0 pDoc-m_lastMove.y 0) { CPen highlightPen(PS_SOLID, 3, RGB(255, 0, 0)); pDC-SelectObject(highlightPen); pDC-SelectStockObject(NULL_BRUSH); // 空心 int hx margin pDoc-m_lastMove.x * cellSize; int hy margin pDoc-m_lastMove.y * cellSize; int hRadius cellSize / 4; pDC-Ellipse(hx - hRadius, hy - hRadius, hx hRadius, hy hRadius); } pDC-SelectObject(pOldPen); // 恢复原来的笔 }4.3 实现鼠标交互落子棋盘画好了接下来要让它能响应鼠标点击。我们需要为视图类添加WM_LBUTTONDOWN消息处理函数。在“类视图”中右键点击CGoGameView选择“属性”。在属性窗口中点击“消息”图标找到WM_LBUTTONDOWN在右侧下拉框选择“添加 OnLButtonDown”。VS会自动在头文件和实现文件中添加函数声明和框架。现在编辑OnLButtonDown函数// GoGameView.cpp void CGoGameView::OnLButtonDown(UINT nFlags, CPoint point) { CGoGameDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); // 1. 将鼠标点击的像素坐标转换为棋盘坐标 (i, j) int margin pDoc-MARGIN; int cellSize pDoc-CELL_SIZE; // 计算点击位置相对于棋盘左上角的偏移 int offsetX point.x - margin; int offsetY point.y - margin; // 判断点击是否在棋盘区域内 if (offsetX 0 || offsetY 0 || offsetX (pDoc-BOARD_SIZE * cellSize) || offsetY (pDoc-BOARD_SIZE * cellSize)) { CView::OnLButtonDown(nFlags, point); return; } // 2. 将偏移量转换为最近的交叉点索引 // 技巧加上半个格子大小再除以格子大小实现“吸附”到最近交叉点 int i (offsetX cellSize / 2) / cellSize; int j (offsetY cellSize / 2) / cellSize; // 边界再次检查 if (i 0 || i pDoc-BOARD_SIZE || j 0 || j pDoc-BOARD_SIZE) { CView::OnLButtonDown(nFlags, point); return; } // 3. 调用文档类的落子逻辑 int colorToPlace pDoc-m_isBlackTurn ? 1 : -1; if (pDoc-PlaceStone(i, j, colorToPlace)) { // 落子成功视图会自动更新因为PlaceStone中调用了UpdateAllViews } else { // 落子失败如位置有子、自杀等可以播放一个提示音或显示消息 ::MessageBeep(MB_ICONEXCLAMATION); } CView::OnLButtonDown(nFlags, point); }至此一个最基础的、可以两人轮流用鼠标下棋的程序就完成了。编译运行你应该能看到棋盘并且可以交替放置黑白棋子虽然还没有提子功能。5. 核心算法精讲提子与“气”的计算这是围棋程序逻辑的灵魂也是最大的难点。我们需要实现RemoveDeadStones和HasLiberty函数。5.1 算法思路广度优先搜索BFS“气”是指一个棋子或一片相连的同色棋子相邻的空交叉点。计算一片棋子的气本质上是在棋盘上进行连通分量的查找并统计这个连通分量所有相邻的空位。查找连通块从给定坐标(x,y)开始使用BFS或DFS遍历所有颜色相同的相邻棋子上下左右四个方向将它们收集到一个集合中。统计气遍历这个连通块中的每一个棋子检查其上下左右四个相邻点。如果相邻点在棋盘内且状态为0空则这个空点就是这块棋的一口“气”。判断死活如果统计到的“气”的数量为0则这块棋是死棋需要提走。5.2 代码实现首先在CGoGameDoc类中添加一个辅助函数FindConnectedGroup用于查找连通块。// GoGameDoc.h private: // 查找与(startX,startY)同色的连通棋子存储到group中 void FindConnectedGroup(int startX, int startY, int color, std::vectorCPoint group, bool visited[BOARD_SIZE][BOARD_SIZE]);// GoGameDoc.cpp void CGoGameDoc::FindConnectedGroup(int startX, int startY, int color, std::vectorCPoint group, bool visited[BOARD_SIZE][BOARD_SIZE]) { if (startX 0 || startX BOARD_SIZE || startY 0 || startY BOARD_SIZE) return; if (visited[startX][startY]) return; if (m_board[startX][startY] ! color) return; visited[startX][startY] true; group.push_back(CPoint(startX, startY)); // 四个方向递归搜索 FindConnectedGroup(startX - 1, startY, color, group, visited); FindConnectedGroup(startX 1, startY, color, group, visited); FindConnectedGroup(startX, startY - 1, color, group, visited); FindConnectedGroup(startX, startY 1, color, group, visited); }接着实现核心的HasLiberty函数判断一块棋是否有气。// GoGameDoc.cpp bool CGoGameDoc::HasLiberty(int x, int y, int color) { bool visited[BOARD_SIZE][BOARD_SIZE] { false }; std::vectorCPoint group; FindConnectedGroup(x, y, color, group, visited); // 遍历这个连通块的所有棋子 for (const auto stone : group) { int sx stone.x; int sy stone.y; // 检查四个方向 int dirs[4][2] { {-1,0},{1,0},{0,-1},{0,1} }; for (int d 0; d 4; d) { int nx sx dirs[d][0]; int ny sy dirs[d][1]; if (nx 0 nx BOARD_SIZE ny 0 ny BOARD_SIZE) { if (m_board[nx][ny] 0) { return true; // 发现一口气是活棋 } } } } return false; // 遍历完所有棋子都没找到气是死棋 }最后实现RemoveDeadStones函数。这个函数在落子后调用目的是检查对方的棋子是否因为这步棋而变成死棋。// GoGameDoc.cpp std::vectorCPoint CGoGameDoc::RemoveDeadStones(int newX, int newY, int color) { std::vectorCPoint deadStones; int opponentColor -color; // 对方的颜色 // 只检查新落子棋子四周的对方棋子块 int dirs[4][2] { {-1,0},{1,0},{0,-1},{0,1} }; bool visited[BOARD_SIZE][BOARD_SIZE] { false }; for (int d 0; d 4; d) { int nx newX dirs[d][0]; int ny newY dirs[d][1]; if (nx 0 nx BOARD_SIZE ny 0 ny BOARD_SIZE) { if (m_board[nx][ny] opponentColor !visited[nx][ny]) { // 找到一块对方棋子 std::vectorCPoint group; FindConnectedGroup(nx, ny, opponentColor, group, visited); // 判断这块棋是否还有气 if (!HasLiberty(nx, ny, opponentColor)) { // 无气提走 for (const auto stone : group) { deadStones.push_back(stone); m_board[stone.x][stone.y] 0; // 清空棋盘位置 } } } } } return deadStones; }实操心得提子算法的调试是关键。建议在开发时在界面上用文字或日志输出每一步落子后棋盘的状态以及HasLiberty函数的判断结果。可以专门设计一些测试棋形如一个黑子被四个白子包围来验证提子逻辑是否正确。递归函数FindConnectedGroup要特别注意边界条件和访问标记visited否则容易导致栈溢出或死循环。6. 功能扩展与界面美化6.1 实现悔棋功能悔棋是棋类游戏的基本功能。我们已经用m_moveHistory记录了落子历史悔棋就变得简单。在文档类添加悔棋函数// GoGameDoc.h bool UndoLastMove();// GoGameDoc.cpp bool CGoGameDoc::UndoLastMove() { if (m_moveHistory.empty()) { return false; } // 从历史中取出最后一步 auto lastMove m_moveHistory.back(); CPoint pos lastMove.first; int color lastMove.second; // 简单处理直接将那个位置的棋子拿掉并恢复回合 // 注意这没有处理提子后恢复被提子的复杂情况是简易悔棋。 // 完整的悔棋需要存储每一步之前的完整棋盘快照内存消耗大。 // 这里实现简易版。 m_board[pos.x][pos.y] 0; m_isBlackTurn (color 1); // 轮到上一步落子方 m_moveHistory.pop_back(); // 更新最后一步指示可以指向再上一步这里简单清空 if (m_moveHistory.empty()) { m_lastMove CPoint(-1, -1); } else { m_lastMove m_moveHistory.back().first; } UpdateAllViews(NULL); return true; }在菜单或工具栏添加悔棋按钮在资源视图里打开菜单IDR_MAINFRAME添加一个“游戏”菜单下面添加“悔棋”项ID设为ID_GAME_UNDO。在主框架类CMainFrame或视图类CGoGameView中为ID_GAME_UNDO添加消息处理函数ON_COMMAND。在处理函数中调用pDoc-UndoLastMove()即可。6.2 美化棋子与棋盘之前的绘图比较简陋。我们可以做很多美化棋子立体感用GradientFill函数或多次绘制不同半径的同心圆来模拟光照效果让棋子看起来是球体。棋盘纹理不再使用纯色填充而是加载一张木质纹理的位图.bmp作为背景。平滑抗锯齿GDI绘图边缘有锯齿。可以使用GDI它支持抗锯齿。在OnDraw中创建一个Gdiplus::Graphics对象使用Gdiplus::SmoothingModeAntiAlias。落子动画在OnLButtonDown中落子前可以在目标位置画一个逐渐变大的圆使用定时器SetTimer控制动画帧。// 使用GDI绘制抗锯齿的棋子示例需在项目中配置GDI #include gdiplus.h #pragma comment(lib, gdiplus.lib) // 在OnDraw中 Gdiplus::Graphics graphics(pDC-GetSafeHdc()); graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); Gdiplus::SolidBrush blackBrush(Gdiplus::Color(255, 30, 30, 30)); Gdiplus::SolidBrush whiteBrush(Gdiplus::Color(255, 255, 255, 255)); Gdiplus::Pen outlinePen(Gdiplus::Color(255, 100, 100, 100), 1.0f); if (color 1) { graphics.FillEllipse(blackBrush, centerX - radius, centerY - radius, radius * 2, radius * 2); } else { graphics.FillEllipse(whiteBrush, centerX - radius, centerY - radius, radius * 2, radius * 2); } graphics.DrawEllipse(outlinePen, centerX - radius, centerY - radius, radius * 2, radius * 2);6.3 实现一个简单的电脑AI一个最简单的AI可以随机在空位落子。虽然很弱但能让程序实现人机对战。在文档类添加AI落子函数// GoGameDoc.h void MakeAIMove(); // 电脑走一步// GoGameDoc.cpp #include cstdlib #include ctime void CGoGameDoc::MakeAIMove() { if (!m_isBlackTurn) return; // 假设AI执白轮到白方时触发 srand((unsigned int)time(NULL)); // 收集所有空位 std::vectorCPoint emptyPoints; for (int i 0; i BOARD_SIZE; i) { for (int j 0; j BOARD_SIZE; j) { if (m_board[i][j] 0) { emptyPoints.push_back(CPoint(i, j)); } } } if (emptyPoints.empty()) return; // 随机选择一个空位落子 int index rand() % emptyPoints.size(); CPoint move emptyPoints[index]; PlaceStone(move.x, move.y, -1); // AI执白颜色为-1 }触发AI可以在玩家黑方落子成功后在CGoGameView::OnLButtonDown中调用pDoc-MakeAIMove()。更友好的方式是用定时器在玩家落子后延迟半秒再让AI走棋模拟思考过程。7. 常见问题与调试技巧实录在开发过程中你肯定会遇到各种问题。以下是我踩过的一些坑和解决方法问题1鼠标点击坐标转换不准落子位置有偏差。原因计算棋盘索引时直接用offsetX / cellSize会导致靠近格子左边的位置算到前一个格点。我们采用了(offsetX cellSize/2) / cellSize进行四舍五入式的吸附。调试在OnLButtonDown中用TRACE宏输出point.x, point.y, i, j的值观察转换逻辑是否正确。TRACE(_T(Click at (%d, %d), Board index (%d, %d)\n), point.x, point.y, i, j);输出可以在VS的“输出”窗口看到。问题2提子逻辑混乱有时不该提的提了该提的没提。原因HasLiberty函数中对于一块棋“气”的统计必须在当前棋盘状态下进行。RemoveDeadStones函数中在判断对方棋块有无气时传入的坐标(nx, ny)必须是对方棋块中的一个棋子并且HasLiberty函数内部会基于当前棋盘已落下新子去判断。排查在RemoveDeadStones函数开始和结束时将整个m_board数组打印出来可以写一个PrintBoard辅助函数输出到文件或调试窗口。单步调试重点关注HasLiberty函数对一个棋块的判断过程。检查visited数组是否正确防止了重复访问。测试经典棋形一个黑子被四个白子包围无气下一个白子后黑子应被提走。两个黑子连在一起只有一口气下一个白子后两个黑子应被提走。问题3程序运行一段时间后变慢或者下多了以后操作卡顿。原因绘图效率OnDraw中每次重绘都从头绘制整个棋盘和所有棋子。如果棋子很多且用了复杂的GDI渲染会消耗资源。算法效率HasLiberty使用递归在19x19的棋盘上问题不大但如果递归实现不好或棋盘很大可能有栈风险。优化双缓冲绘图这是解决闪烁和提升绘图性能的标准方法。在OnDraw中先在一个内存DC上绘制所有内容然后一次性贴到屏幕DC上。void CGoGameView::OnDraw(CDC* pDC) { // ... 获取pDoc等代码 CRect rect; GetClientRect(rect); CDC memDC; CBitmap memBitmap; memDC.CreateCompatibleDC(pDC); memBitmap.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBitmap memDC.SelectObject(memBitmap); // 用memDC代替pDC进行所有绘图操作... memDC.FillRect(rect, brushBg); // ... 绘制棋盘、棋子 // 最后将内存DC内容拷贝到屏幕 pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBitmap); }算法优化可以为每个连通块缓存其“气”的数量或集合只在棋盘状态改变时更新受影响的区域而不是每次都全盘BFS。但这属于高级优化初期不必考虑。问题4悔棋功能在提子后无法正确恢复被提的棋子。原因我们实现的简易悔棋只恢复了被下的那颗子没有恢复因这步棋而被提走的对方棋子。解决方案修改m_moveHistory的数据结构使其不仅能记录落子位置还能记录这一步导致的提子列表。悔棋时除了拿走自己的子还要把被提的子放回去。// 修改历史记录项 struct MoveRecord { CPoint position; // 落子点 int color; // 颜色 std::vectorCPoint capturedStones; // 这一步提走的棋子列表 }; std::vectorMoveRecord m_moveHistory;在PlaceStone函数中将RemoveDeadStones返回的列表存入当前步的记录。在UndoLastMove中取出记录恢复被提的棋子到棋盘上。开发这样一个项目最大的收获不是最终的程序而是这个过程中对Windows消息机制、图形绘制、数据结构和算法、模块化设计的深刻理解。每解决一个bug每实现一个功能都是对基本功的一次夯实。当你看到自己编写的程序能够流畅地对弈那种成就感是无可比拟的。你可以在此基础上继续扩展比如加入棋谱文件SGF格式的读写、实现更复杂的AI如基于规则的或蒙特卡洛的、甚至加入网络对战功能。这个VC围棋项目就像一个丰富的矿藏越挖越有。