简介:这是一份基于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::vector<std::pair<int,int>> m_history; };m_grid是19×19的枚举数组,每个位置存EMPTY、BLACK或WHITE。m_history用vector记录落子顺序,悔棋时直接pop_back再恢复棋盘状态。这里没有用位棋盘压缩,因为双人对决场景下性能完全够用,19×19=361个格子,一次气计算最多遍历整个棋盘,现代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::queue<std::pair<int,int>> q; std::set<std::pair<int,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::pair<int,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::queue<std::pair<int,int>> q; std::set<Board::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::pair<int,int> AIPlayer::GetMove(Board& board, Board::Stone color) { std::vector<std::pair<int,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都强制走一遍这个流程,省了很多后悔药。希望帮到你。本文还有配套的精品资源,点击获取
简介:这是一份基于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::vector<std::pair<int,int>> m_history; };m_grid是19×19的枚举数组,每个位置存EMPTY、BLACK或WHITE。m_history用vector记录落子顺序,悔棋时直接pop_back再恢复棋盘状态。这里没有用位棋盘压缩,因为双人对决场景下性能完全够用,19×19=361个格子,一次气计算最多遍历整个棋盘,现代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::queue<std::pair<int,int>> q; std::set<std::pair<int,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::pair<int,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::queue<std::pair<int,int>> q; std::set<Board::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::pair<int,int> AIPlayer::GetMove(Board& board, Board::Stone color) { std::vector<std::pair<int,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都强制走一遍这个流程,省了很多后悔药。希望帮到你。本文还有配套的精品资源,点击获取