尧图精选

C++ MFC实现围棋游戏:从规则算法到界面交互的完整工程解析

🕒 发布时间:2026/9/14 15:57:30 📁 来源:尧图网络
简介VS2010环境下使用VC与MFC编写的围棋游戏完整工程面向具备基础C知识、希望学习Windows程序设计与游戏逻辑开发的读者。项目实现棋盘绘制、黑白棋子落子、胜负判断等核心功能工程内包含头文件、源文件、位图资源及VS工程配置便于直接编译运行与二次修改。压缩包共33个文件以10个头文件、8个C源文件为主辅以4张位图、2个图标、2个文本说明以及解决方案/工程配置文件整体仅164KB轻量且结构完整。目前已有98人浏览学习适合作为课程设计或入门练手的参考范例。通过阅读源码可了解MFC框架下界面与人机交互的基本写法并借鉴其模块划分与算法实现完善自己的棋类项目随附的待解决问题说明还能帮助理清后续优化思路。1. 一个 VS2010 工程里藏着的围棋规则实现打开这个名为“VS2010环境编写的VC围棋游戏.rar”的压缩包时我原本以为又是一个用 MFC 对话框拼出来的入门作业。等看到CGo.h、CCoordinateView.h、RemoveDeadStones这些文件名后才发现里面装的是把围棋规则真正落到 C 代码里的工程。它解决的是两个问题一是 19 路棋盘上的提子、禁着点、死子判定怎么写二是 MFC 里鼠标点击坐标和棋盘交叉点怎么对齐。对正在用 VC 做棋类课设、或者想从控制台程序升级到 GUI 的开发者来说这份源码比单纯画控件的示例更有参考价值。我拆完之后最想先讲的是CGo这个类因为所有规则都在那里界面只是它的消费者。2. 棋盘与坐标从 CGo 类看围棋数据模型2.1 先拆文件再决定从哪个类入手读代码拿到工程不要先点“生成解决方案”先把文件列表过一遍分清规则代码和界面代码。这个压缩包里除了MyFirstMFC.sln和vcxproj项目文件外值得优先打开的四个文件是CGo.h、CGo.cpp、MyFirstMFCView.cpp、AssiDlg.cpp。剩下的MainFrm.cpp、MyFirstMFCDoc.cpp是 MFC 单文档模板自带的框架文件可以放到最后看。各文件职责如下文件职责CGo.h/CGo.cpp围棋核心规则棋盘状态、落子判断、提子算法MyFirstMFCView.cpp视图绘制与鼠标落子入口CCoordinateView.h棋盘坐标辅助视图处理行列标线AssiDlg.h/AssiDlg.cpp辅助对话框常见用于设置或提示BlackPiece.bmp/UserImages.bmp棋子与棋盘位图资源待解决问题.txt作者记录未完成事项能当排错线索特别说一下待解决问题.txt它往往写着“劫争未做”“提子后不刷新”这类遗留问题。读它比读源码更能快速知道当前版本的边界在哪里如果你打算在此基础上二次开发先看这个文件能少踩几个坑。CGo是整份代码的规则核心去看CGo.cpp时如果发现棋盘数组、提子函数都定义在CGo类里而MyFirstMFCView只调用它的公有方法说明作者把业务逻辑和界面分层了。这是 MFC 工程里难得的干净结构值得模仿。2.2 为什么围棋棋盘用二维数组而不是 vector围棋棋盘是 19×19 的交叉点最朴素的数据结构就是二维数组。作者在CGo里大概率会写成下面这样const int BOARD_SIZE 19; int m_board[BOARD_SIZE][BOARD_SIZE]; // m_board[row][col] 的取值 // 0 - 空交叉点 // 1 - 黑棋 // 2 - 白棋选择二维数组而不是std::vectorstd::vectorint一个原因是 VS2010 时代的 MFC 工程更偏保守原生数组在调试器监视窗口里可以直接展开成棋盘形状m_board[3][3]的写法一眼就能看出坐标是否越界另一个原因是围棋算法要频繁访问上下左右四个邻居m_board[r-1][c]比m_board[(r-1)*19c]可读性高很多。缺点是需要小心边界访问下面CountLiberty函数里第一件事就是越界判断。2.3 落子前检查不只是“空位可下”很多控制台五子棋思路会把落子写成“数组空就写入”但围棋不能这样。一个合格的落子函数要同时处理三种情况位置是否越界、位置是否已有棋子、落子后是否属于自杀。下面这段伪代码是CGo::PlaceStone最常见的样子bool CGo::PlaceStone(int row, int col, int color) { if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) return false; // 越界直接拒绝 if (m_board[row][col] ! 0) return false; // 非空位置不能落子 m_board[row][col] color; // 先临时落子 if (HasNoLiberty(row, col, color)) { m_board[row][col] 0; // 无气则恢复为空 return false; } RemoveDeadStones(row, col, color); // 提掉对方无气的棋块 return true; }核心逻辑在于“先临时落子再判断气最后决定是否回滚”。把棋盘写回空值这一步很容易被漏掉一旦漏了界面上的某处就会留下一个“幽灵棋子”接下来的提子判断全部错乱。RemoveDeadStones是后面要展开的提子过程这里先留个调用点。3. 气、提子与劫把围棋规则写进 C3.1 用 DFS 数气visited 是防死循环的关键围棋中“气”指与某颗棋子直接相邻的空交叉点。一个棋块的气是整块棋所有相邻空点的并集。要判断提子必须先实现统计气的函数。最直接的做法是深度优先搜索从任意一颗棋子出发向上下左右四个方向延伸遇到空点记一口气遇到同色棋继续走遇到对方棋或边界停下。下面这个实现是CGo类里常见的写法int CGo::CountLiberty(int row, int col, int color, bool visited[][BOARD_SIZE]) { if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) return 0; // 越界不算气 if (visited[row][col]) return 0; // 已访问过避免死循环 if (m_board[row][col] 0) { visited[row][col] true; return 1; // 空点算一口气 } if (m_board[row][col] ! color) return 0; // 对方棋子挡住 visited[row][col] true; // 标记同色棋子 return CountLiberty(row - 1, col, color, visited) CountLiberty(row 1, col, color, visited) CountLiberty(row, col - 1, color, visited) CountLiberty(row, col 1, color, visited); }visited是整个函数的灵魂。没有它一个由几十颗同色棋组成的棋块会造成无限递归VS2010 默认栈空间下很快就0xC00000FD栈溢出。另一个细节是空点必须马上标记并返回 1否则同一个空点会被四个方向的同色棋子重复计数气数会虚高。递归顺序不影响结果但建议固定为上、下、左、右方便在调用栈里跟踪。3.2 提子流程先标记再清空顺序错就出 bug统计出某块棋气为 0 之后就要把整块棋从棋盘上清空。很多初版实现会在遍历过程中直接修改m_board这会导致后续棋块的邻居状态变化同一轮提子里漏提。常见做法是分两轮第一轮找出所有无气棋块并标记第二轮统一清空。代码如下void CGo::RemoveDeadStones(int row, int col, int playerColor) { int opponent (playerColor 1) ? 2 : 1; bool dead[BOARD_SIZE][BOARD_SIZE] { false }; for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { if (m_board[r][c] opponent) { bool visited[BOARD_SIZE][BOARD_SIZE] { false }; if (CountLiberty(r, c, opponent, visited) 0) { MarkDead(r, c, opponent, dead); } } } } for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { if (dead[r][c]) { m_board[r][c] 0; } } } }MarkDead又是一个 DFS作用是把整块无气棋写入dead标记。两轮循环听上去啰嗦但可以避免一个经典错误在清空第一个棋块后第二个棋块原本的气被错误地“增加”了导致本该被提的子活下来。这种 bug 在界面上表现为“白棋被黑棋围死后还留在棋盘上”肉眼很难定位。如果要进一步提高效率可以用一个全局visited来避免对同一棋块重复调用CountLiberty但教学工程里两层遍历的开销可以接受。3.3 劫争与禁着点至少要在落子前拦住“自杀”围棋里的打劫不能立即提回同一颗子完整实现需要记录历史棋盘状态来比较。教学型工程通常只做“禁止自杀”这一层待解决问题.txt里很可能就写着“打劫未处理”。如果要从工程角度补上最朴素的方法是保存上一手棋盘快照bool CGo::IsKoMove(int row, int col, int color, int previousBoard[][BOARD_SIZE]) { // 临时落子 m_board[row][col] color; RemoveDeadStones(row, col, color); bool sameAsPrevious true; for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { if (m_board[r][c] ! previousBoard[r][c]) { sameAsPrevious false; break; } } } m_board[row][col] 0; // 恢复现场 return sameAsPrevious; }这里只做恢复现场不提真实落子因为在落子流程里调用PlaceStone之前需要先检查。如果你接手的版本里没有劫争代码建议在PlaceStone开头增加一个参数previousBoard这样至少能挡住最直接的“提回”操作。对于课程设计来说做到“禁自杀 可提子”已经能覆盖 90% 的对局场景。4. MFC 交互与绘制棋盘为什么总点不准4.1 从 CPoint 到行列的坐标换算MFC 视图类响应WM_LBUTTONDOWN时得到的CPoint是窗口客户区像素坐标不是棋盘行列。直接把point.x / CELL_SIZE当列号是新手最常见错误因为棋盘交叉点并不是从 (0,0) 开始的。作者在CCoordinateView里应该有一组偏移量用来表示棋盘左上角第一个交叉点在窗口中的位置。换算公式如下int row (point.y - ORIGIN_Y CELL_SIZE / 2) / CELL_SIZE; int col (point.x - ORIGIN_X CELL_SIZE / 2) / CELL_SIZE;如果没加CELL_SIZE / 2点击两个交叉点正中间时会落到下一行用户会觉得鼠标“飘”。ORIGIN_X和ORIGIN_Y不是随便取的它们要和背景棋盘位图UserImages.bmp里的棋盘起点对齐。最稳妥的验证办法是在OnDraw里画一个小红点在ORIGIN_X, ORIGIN_Y如果红点不在棋盘左上角星位上就说明偏移量设置有误。4.2 OnDraw 里画棋盘和棋子这个工程的界面基于 MFC 单文档视图架构MyFirstMFCView::OnDraw会在窗口重绘时被调用。要画棋盘常见做法是先加载UserImages.bmp作为背景再把棋子位图贴到对应交叉点上。代码大约长这样void CMyFirstMFCView::OnDraw(CDC* pDC) { // 先画棋盘背景位图 CBitmap bmpBoard; bmpBoard.LoadBitmap(IDB_BOARD); // 资源 ID 来自 .rc 文件 CDC memDCMap; memDCMap.CreateCompatibleDC(pDC); CBitmap* pOldBmp memDCMap.SelectObject(bmpBoard); pDC-BitBlt(0, 0, BOARD_WIDTH, BOARD_HEIGHT, memDCMap, 0, 0, SRCCOPY); memDCMap.SelectObject(pOldBmp); // 再画棋子 for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { int color m_game.GetCell(r, c); if (color ! 0) { DrawStone(pDC, r, c, color); } } } }BitBlt是同步块拷贝把内存 DC 中的棋盘位图直接搬到窗口 DC。DrawStone里要计算棋子圆心位置x ORIGIN_X c * CELL_SIZEy ORIGIN_Y r * CELL_SIZE再画出半径约为CELL_SIZE / 2 - 2的圆。如果这里不做半点抗锯齿直接Ellipse棋子边缘会出现明显锯齿视觉上不如加载带透明通道的BlackPiece.bmp精致。如果还想画九宫格星位可以单独在(3,3)、(15,15)这些坐标画实心小圆点MFC 里用Ellipse或SetPixel都可以尺寸控制在 3 像素以内这一步能显著提高棋盘辨识度。4.3 双缓冲与资源加载解决闪烁和空白MFC 的OnDraw在Invalidate()后重绘如果每次都直接在窗口 DC 上先画背景再画棋子用户会看到背景闪一下。常见解决方法是先在一个内存位图上完成所有绘制再一次拷贝到窗口void CMyFirstMFCView::DrawBoardWithDoubleBuffer(CDC* pDC) { CDC memDC; CBitmap memBmp; memDC.CreateCompatibleDC(pDC); memBmp.CreateCompatibleBitmap(pDC, clientWidth, clientHeight); CBitmap* pOld memDC.SelectObject(memBmp); // 所有绘制发生在这里画背景、画网格、画棋子 DrawBackground(memDC); DrawGrid(memDC); DrawStones(memDC); pDC-BitBlt(0, 0, clientWidth, clientHeight, memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }双缓冲的代价是多占用一块内存位图对应 19 路棋盘来说可以忽略。注意CreateCompatibleBitmap的尺寸取客户区大小不要取整个窗口否则带标题栏的窗口会有一部分绘制内容被遮挡。资源加载失败时LoadBitmap返回 0画面只显示灰白色背景这时要检查MyFirstMFC.rc里IDB_BOARD是否和UserImages.bmp在同一个项目目录下。提示双缓冲实现里如果CreateCompatibleBitmap失败先检查窗口是否已经完成初始化不要在OnCreate里直接调用这个函数。5. 终局数子与调试验证给提子算法找一个可复现的测试5.1 终局数子的简化实现围棋终局判定比五子棋复杂工程化时不要追求完整点目规则先用“区域填充”拿到地盘数即可。思路是扫描所有空点用 Flood Fill 找出每一块空区域的边界颜色边界只有一方颜色时该区域归属该方。代码如下int CGo::CountTerritory(int owner) { bool visited[BOARD_SIZE][BOARD_SIZE] { false }; int points 0; for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { if (m_board[r][c] 0 !visited[r][c]) { int area 0; int boundaryColor FloodFillEmpty(r, c, visited, area); if (boundaryColor owner) points area; } } } return points; }FloodFillEmpty返回该空区域的唯一边界色如果边界既有黑又有白就按无主地处理。这个实现不处理贴目也不区分单官和双官但对于课程设计级别的胜负展示已经足够。要接入界面只需在“结束”按钮里把黑方和白方的CountTerritory相加再和各方的棋子数相加比较大小。5.2 用手顺回放验证别靠肉眼点验证提子算法最可靠的办法不是打开程序边点边看而是准备一组已知结果的手顺让程序自动落子并用OutputDebugString输出每一步的棋盘状态。例如下面这组测试// 黑(1)先行白(2)应对 // 黑 3,3 // 白 3,4 // 黑 4,3 // 白 4,4 // 黑 3,5 - 这一步后白 3,4 是否被提掉在PlaceStone返回true后打印落子坐标和当前棋盘快照再在RemoveDeadStones前后打印棋子数量差。这样能把“界面点击是否生效”和“提子算法是否正确”两个问题彻底分开。最后把日志写到文件里任何棋谱都能回放比在断点里盯着m_board高效得多。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →