资讯动态

VC++ MFC双人对决围棋程序实战:从落子提子到胜负计算

发布时间:2026/10/21 2:43:58 来源:尧图企业网站定制
简介这是一份基于VC与MFC框架编写的双人对决围棋程序源码面向正在学习Windows游戏编程、希望理解MFC文档视图架构与图形绘制流程的开发者。程序支持双人轮流落子并可根据15、17寸液晶屏选择合适窗口尺寸落子响应迅速适合作为课程设计、毕业设计或自学练手项目。压缩包共24个文件约36KB包含7个h头文件与6个cpp源文件构成视图、文档、主框架等核心模块另有3个bmp位图与2个ico图标提供棋盘与界面素材以及dsp、dsw工程文件、rc资源脚本和clw类向导文件方便直接编译运行。目前已有171人学习下载。通过阅读源码读者可掌握MFC单文档程序的搭建方式、棋盘绘制与鼠标交互逻辑、落子判定与界面刷新思路并借鉴作者在图形类封装上的组织方法为后续扩展胜负判断、悔棋或人机对战功能打下基础。1. 双人对决的VC围棋程序一个能直接跑起来的MFC实战项目很多做VC的朋友都有个心结MFC学了一堆控件和消息映射却始终没做出一个像样的完整程序。这个双人对决的围棋程序正好补上这一环——它不是那种只画个棋盘就结束的Demo而是把落子、提子、禁着点判断、胜负计算都串了起来用纯VC和MFC写成打开解决方案就能编译运行。适合两类人一是刚学完MFC基础、想找个有真实业务逻辑的项目练手的二是想研究棋类游戏状态管理和界面刷新机制的。棋盘绘制走的是GDI双缓冲避免闪烁规则部分独立成类和界面解耦改起来不伤筋动骨。下面按“能干什么、怎么跑、怎么改”的顺序拆一遍。2. 项目架构与核心模块棋盘、规则、界面三层怎么分2.1 整体目录结构与文件职责拿到源码包第一件事是看目录。这个项目没有用第三方库全部依赖VC自带组件结构很干净GoGame/ ├── GoGame.sln // VS解决方案文件 ├── GoGame/ │ ├── GoGame.h/.cpp // 应用程序入口CWinApp派生 │ ├── MainFrm.h/.cpp // 主框架窗口 │ ├── GoGameView.h/.cpp // 视图类负责棋盘绘制和鼠标交互 │ ├── GoGameDoc.h/.cpp // 文档类持有棋盘数据 │ ├── Board.h/.cpp // 棋盘逻辑核心落子、提子、气计算 │ ├── RuleEngine.h/.cpp // 规则引擎禁着点、打劫、胜负判定 │ └── res/ // 图标、菜单等资源文档/视图架构是MFC的经典模式这里用得比较到位。CGoGameDoc持有Board对象CGoGameView负责把Board的状态画出来鼠标点击时反过来调用Board的落子接口。RuleEngine单独拆出来是因为规则判断和棋盘数据是两回事——棋盘只管“哪里有没有子”规则管“这步能不能下”。常见做法是把所有逻辑塞进View里几百行就乱成一团。这个项目把规则独立出来后面想加AI或者悔棋改动点很明确。2.2 棋盘数据结构二维数组还是位棋盘Board类内部用的是最直观的二维数组// Board.h class Board { public: static const int BOARD_SIZE 19; enum Stone { EMPTY 0, BLACK 1, WHITE 2 }; Board(); void Reset(); Stone GetStone(int row, int col) const; bool PlaceStone(int row, int col, Stone color); int GetLiberties(int row, int col) const; void RemoveGroup(int row, int col); private: Stone m_grid[BOARD_SIZE][BOARD_SIZE]; // 记录每步落子用于悔棋 std::vectorstd::pairint,int m_history; };m_grid是19×19的枚举数组每个位置存EMPTY、BLACK或WHITE。m_history用vector记录落子顺序悔棋时直接pop_back再恢复棋盘状态。这里没有用位棋盘压缩因为双人对决场景下性能完全够用19×19361个格子一次气计算最多遍历整个棋盘现代CPU毫秒级完成。参数说明BOARD_SIZE改成9或13就能切换小棋盘但界面绘制那边也要同步改后面会讲。PlaceStone返回bool表示是否成功失败原因包括位置已有子、越界等规则层面的禁着点判断放在RuleEngine里做。2.3 气与提子的计算逻辑围棋最核心的算法就是“气”的计算。一块棋的气等于它所有相邻空点的集合如果气为0就要被提掉。GetLiberties用广度优先搜索实现// Board.cpp int Board::GetLiberties(int row, int col) const { Stone color m_grid[row][col]; if (color EMPTY) return 0; bool visited[BOARD_SIZE][BOARD_SIZE] { false }; std::queuestd::pairint,int q; std::setstd::pairint,int liberties; // 用set去重 q.push({row, col}); visited[row][col] true; const int dr[] {-1, 1, 0, 0}; const int dc[] {0, 0, -1, 1}; while (!q.empty()) { auto [r, c] q.front(); q.pop(); for (int i 0; i 4; i) { int nr r dr[i], nc c dc[i]; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) continue; if (visited[nr][nc]) continue; if (m_grid[nr][nc] EMPTY) { liberties.insert({nr, nc}); } else if (m_grid[nr][nc] color) { visited[nr][nc] true; q.push({nr, nc}); } } } return (int)liberties.size(); }逻辑说明从目标点出发把同色连通块全部找出来同时收集所有相邻的空点。用set是为了避免同一个空点被多个棋子重复计数。四个方向用dr/dc数组表示这是棋盘类算法的标准写法。参数说明visited数组每次调用都重新初始化在19×19规模下开销可忽略。如果以后要支持更大棋盘可以改成成员变量加时间戳标记避免反复清零。提子的触发时机是在PlaceStone之后先落子然后检查上下左右四个方向的对方棋块如果某块气为0就整块移除。这里有个容易翻车的地方——提子后自己的棋可能反而没气了自杀手所以顺序必须是“先提对方再检查自己”。3. 编译运行与界面交互从解决方案到落子响应3.1 环境准备与编译步骤这个项目用的是VC和MFC推荐用Visual Studio 2019或2022安装时勾选“使用C的桌面开发”和“MFC支持”。打开GoGame.sln后配置选Debug x64或Release x64都行直接F7编译。如果报错“无法打开 afxwin.h”说明MFC组件没装去VS Installer里补上。编译通过后F5运行会出现一个带菜单栏的窗口棋盘是19×19的网格。菜单里有“新游戏”“悔棋”“退出”几个基本项。没有用对话框做设置所有操作都在主窗口完成减少了一层消息传递。提示如果编译时提示std::pair相关错误检查是否包含了utility和queue头文件。VC不同版本对标准库的隐式包含行为有差异显式包含最稳妥。3.2 棋盘绘制的双缓冲实现MFC的OnDraw直接画图会闪烁因为每次重绘都先擦背景再画前景。这个项目在CGoGameView::OnDraw里用了内存DC做双缓冲void CGoGameView::OnDraw(CDC* pDC) { CGoGameDoc* pDoc GetDocument(); CRect rect; GetClientRect(rect); // 内存DC CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 先填充背景 memDC.FillSolidRect(rect, RGB(220, 179, 92)); // 木色 // 画网格线 int margin 30; int cellSize (min(rect.Width(), rect.Height()) - 2 * margin) / 18; for (int i 0; i 19; i) { memDC.MoveTo(margin, margin i * cellSize); memDC.LineTo(margin 18 * cellSize, margin i * cellSize); memDC.MoveTo(margin i * cellSize, margin); memDC.LineTo(margin i * cellSize, margin 18 * cellSize); } // 画棋子 for (int r 0; r 19; r) for (int c 0; c 19; c) { Board::Stone s pDoc-GetBoard().GetStone(r, c); if (s Board::EMPTY) continue; int x margin c * cellSize; int y margin r * cellSize; CBrush brush(s Board::BLACK ? RGB(0,0,0) : RGB(255,255,255)); CBrush* pOldBrush memDC.SelectObject(brush); memDC.Ellipse(x - cellSize/2 2, y - cellSize/2 2, x cellSize/2 - 2, y cellSize/2 - 2); memDC.SelectObject(pOldBrush); } // 一次性拷贝到屏幕 pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }逻辑说明所有绘制先在memDC上完成最后用BitBlt一次性贴到屏幕DC。这样屏幕只刷新一次肉眼看不到闪烁。cellSize根据窗口大小动态计算窗口拉伸时棋盘跟着缩放。参数说明margin是棋盘边缘留白改成50可以让棋盘更居中。cellSize/2 - 2里的减2是让棋子之间留一点缝隙看起来更舒服。如果想画星位天元、角星在固定坐标画小圆点即可这个项目没做算是个可扩展点。3.3 鼠标点击到落子的消息链路鼠标左键按下时OnLButtonDown被触发把屏幕坐标转换成棋盘行列再调用文档对象的落子接口void CGoGameView::OnLButtonDown(UINT nFlags, CPoint point) { CGoGameDoc* pDoc GetDocument(); CRect rect; GetClientRect(rect); int margin 30; int cellSize (min(rect.Width(), rect.Height()) - 2 * margin) / 18; // 屏幕坐标 - 棋盘行列四舍五入到最近交叉点 int col (point.x - margin cellSize/2) / cellSize; int row (point.y - margin cellSize/2) / cellSize; if (row 0 || row 19 || col 0 || col 19) return; // 交给规则引擎判断 if (pDoc-GetRuleEngine().IsValidMove(row, col, pDoc-GetCurrentColor())) { pDoc-GetBoard().PlaceStone(row, col, pDoc-GetCurrentColor()); pDoc-GetRuleEngine().ApplyCapture(pDoc-GetBoard(), row, col); pDoc-SwitchColor(); Invalidate(); // 触发重绘 } else { MessageBeep(MB_ICONWARNING); // 非法落子提示音 } CView::OnLButtonDown(nFlags, point); }逻辑说明坐标转换用(point.x - margin cellSize/2) / cellSize加半个格子宽是为了让点击落在交叉点附近就能命中而不是必须精确点到交叉点。IsValidMove检查禁着点和打劫通过后才真正落子、提子、切换执子方。参数说明MessageBeep是Windows API非法落子时响一声比弹对话框轻量。如果觉得吵可以去掉改成状态栏提示。Invalidate()会触发OnDraw配合双缓冲完成界面更新。4. 规则引擎与胜负判定禁着点、打劫、数子4.1 禁着点判断自杀手和打劫RuleEngine::IsValidMove要处理两种情况一是落子后自己没气且不能提对方自杀手二是打劫重复上一手局面。自杀手判断可以复用Board的气计算bool RuleEngine::IsValidMove(Board board, int row, int col, Board::Stone color) { if (board.GetStone(row, col) ! Board::EMPTY) return false; // 模拟落子 board.SetStone(row, col, color); // 检查是否能提对方 bool canCapture false; const int dr[] {-1,1,0,0}, dc[] {0,0,-1,1}; for (int i 0; i 4; i) { int nr row dr[i], nc col dc[i]; if (nr 0 || nr 19 || nc 0 || nc 19) continue; if (board.GetStone(nr, nc) Opponent(color)) { if (board.GetLiberties(nr, nc) 0) { canCapture true; break; } } } // 自己是否有气 bool hasLiberty (board.GetLiberties(row, col) 0); // 恢复 board.SetStone(row, col, Board::EMPTY); if (!hasLiberty !canCapture) return false; // 自杀手 // 打劫判断和上上步局面相同则禁止 if (IsKo(board, row, col, color)) return false; return true; }逻辑说明先模拟落子然后检查四个方向是否有对方无气棋块可提再检查自己是否有气。两者都不满足就是自杀手。打劫判断需要保存历史局面简单做法是记录最近两步的棋盘哈希如果当前落子后和两步前相同则禁止。参数说明Opponent是个辅助函数黑返回白、白返回黑。打劫判断在双人对决里可以简化——很多业余对局不严格判打劫但既然做了规则引擎加上更完整。4.2 数子与胜负计算双人对决结束时需要计算双方地盘。中国规则是数子法活棋加围住的空点。RuleEngine::CalculateScore遍历棋盘对每个空点做洪水填充如果只被一种颜色包围就归该方std::pairint,int RuleEngine::CalculateScore(Board board) { bool visited[19][19] { false }; int blackScore 0, whiteScore 0; for (int r 0; r 19; r) { for (int c 0; c 19; c) { if (board.GetStone(r, c) Board::BLACK) { blackScore; continue; } if (board.GetStone(r, c) Board::WHITE) { whiteScore; continue; } if (visited[r][c]) continue; // 洪水填充空区域 std::queuestd::pairint,int q; std::setBoard::Stone borders; int emptyCount 0; q.push({r, c}); visited[r][c] true; while (!q.empty()) { auto [cr, cc] q.front(); q.pop(); emptyCount; for (int i 0; i 4; i) { int nr cr dr[i], nc cc dc[i]; if (nr 0 || nr 19 || nc 0 || nc 19) continue; Board::Stone s board.GetStone(nr, nc); if (s Board::EMPTY !visited[nr][nc]) { visited[nr][nc] true; q.push({nr, nc}); } else if (s ! Board::EMPTY) { borders.insert(s); } } } if (borders.size() 1) { if (*borders.begin() Board::BLACK) blackScore emptyCount; else whiteScore emptyCount; } // borders为空或两种颜色都有是公气不计分 } } return {blackScore, whiteScore}; }逻辑说明遍历所有空点对每个未访问的空区域做BFS同时记录接触到的棋子颜色。如果只接触黑色这块空归黑只接触白色归白两种都有则是公气双方都不算。最后加上棋子本身的数量。参数说明这个算法在19×19上跑一次不到10毫秒终局时调用一次完全没问题。dr/dc数组在多个函数里重复定义实际项目里可以提成类静态成员。4.3 悔棋功能的实现悔棋在双人对决里很实用实现依赖Board里的m_history。每步落子前把当前棋盘状态压栈悔棋时弹出恢复void Board::Undo() { if (m_history.empty()) return; auto last m_history.back(); m_history.pop_back(); m_grid[last.first][last.second] EMPTY; // 如果有提子需要恢复被提的棋子这里简化处理 }实际项目中提子后恢复比较复杂需要在落子时把被提棋子的位置也记录下来。常见做法是定义一个MoveRecord结构包含落子位置、被提棋子列表、执子方悔棋时反向操作。这个项目如果只做单步悔棋可以接受简化版要做多步悔棋建议把MoveRecord补全。5. 避坑与常见问题编译、绘制、规则里的那些坑5.1 编译报错“无法打开源文件 afxwin.h”现象打开解决方案后直接编译提示找不到MFC头文件。 原因Visual Studio安装时没有勾选MFC组件或者项目属性里的“使用MFC”设置不对。 解决打开VS Installer修改当前VS版本在“单个组件”里搜索MFC并勾选安装。安装完成后重启VS再检查项目属性 → 配置属性 → 常规 → “使用MFC”是否为“在共享DLL中使用MFC”。5.2 棋盘绘制闪烁严重现象窗口拉伸或落子后棋盘明显闪一下。 原因OnDraw里直接往屏幕DC画图没有用内存DC缓冲。 解决按3.2节的写法先创建兼容DC和位图所有绘制在内存DC完成最后BitBlt一次性输出。另外在OnEraseBkgnd里直接返回TRUE阻止系统擦背景。5.3 落子位置偏移现象点击交叉点附近棋子画到了隔壁位置。 原因坐标转换时没有加半个格子的偏移或者margin和cellSize的计算在绘制和点击两处不一致。 解决把margin和cellSize的计算抽成一个公共函数绘制和点击都调用它。坐标转换公式统一为(point.x - margin cellSize/2) / cellSize。5.4 提子后棋盘状态错乱现象提掉对方一块棋后自己的棋反而消失了或者棋盘上出现空白。 原因提子顺序错误先检查了自己没气就移除或者移除时没有正确遍历整块棋。 解决严格按“落子 → 检查对方四方向棋块 → 移除无气块 → 检查自己”的顺序。移除时用BFS找到整块同色棋全部置EMPTY不要只移除一个子。5.5 打劫判断导致正常落子被禁现象明明可以下的地方程序提示非法。 原因打劫判断过于严格把普通提子也当成了打劫。 解决打劫的严格定义是“单子提单子且局面重复”需要比较落子前后的棋盘哈希。简化做法是只记录最近一步的落子位置如果当前落子位置和上一步提子位置相同且只提了一个子才判打劫。不要把所有提子都当打劫。6. 二次开发改造从双人对决到AI对战和网络版这个项目最值钱的地方是规则和界面解耦改起来有明确的切入点。如果你想加一个简单AI不用动界面只需要在RuleEngine旁边加一个AIPlayer类实现一个GetMove(Board)接口。最基础的随机落子AI十行代码就能写std::pairint,int AIPlayer::GetMove(Board board, Board::Stone color) { std::vectorstd::pairint,int validMoves; for (int r 0; r 19; r) for (int c 0; c 19; c) if (m_rule.IsValidMove(board, r, c, color)) validMoves.push_back({r, c}); if (validMoves.empty()) return {-1, -1}; return validMoves[rand() % validMoves.size()]; }然后在View的落子逻辑里判断当前执子方如果是AI就调用GetMove自动落子。想强一点可以加一个简单的启发式评分优先选靠近已有棋子、靠近边角的位置。再强一点就要上蒙特卡洛树搜索了那是另一个量级的工程。改网络版的话核心是把Board的状态序列化后通过Socket传输。MFC自带CSocket类可以在文档类里加一个CGoGameSocket成员。落子后把(row, col, color)打包发出去对方收到后调用同样的落子接口。注意网络延迟会导致双方视图不一致常见做法是本地先落子再发消息收到对方确认后如果冲突就回滚。这块坑比较多建议先做局域网直连别一上来就搞房间匹配。我自己的习惯是每次改规则相关代码后都会手动下几盘棋验证先测普通落子、再测提子、再测打劫、最后测终局数子。这四步走完基本能覆盖90%的规则bug。从那以后我每次动RuleEngine都强制走一遍这个流程省了很多后悔药。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑