VC++ MFC双人对战围棋程序:棋盘规则与提子算法全解析

发布时间:2026/10/17 6:58:06
VC++ MFC双人对战围棋程序:棋盘规则与提子算法全解析 简介这是一款基于VC与MFC编写的双人围棋对弈程序适合对Windows桌面开发感兴趣的初学者下载研读。程序代码结构完整涵盖棋盘绘制、落子判断、胜负判定等核心逻辑并针对15、17寸液晶屏提供了窗口尺寸适配选项运行流畅。压缩包共24个文件约36KB以7个头文件、6个C源文件为主配合若干bmp与ico资源文件以及项目工程文件便于读者用Visual Studio直接打开编译和阅读。目前已有171人浏览学习对于想快速上手MFC消息响应、GDI绘图和简单博弈算法的开发者来说是一份轻量实用的参考资料。通过拆解源码可以清楚看到作者如何组织应用框架与实现双人交互并能在此基础上扩展功能或移植到其他项目。1. 双人对决的VC围棋程序难点全在棋盘内而不在AI看标题你大概率会默认它内置了什么AI棋力我拿到这份VC工程之后却在菜单里翻了半天没有人机对战入口没有难度选项它就是干干净净的一张19路棋盘黑先白后两人轮流用鼠标落子该提的子自动提掉终局自己按菜单里的“计算胜负”让程序数子。这个定位恰好把围棋程序里最难伺候的部分留给了规则逻辑而不是搜索引擎。双人对决意味着每步棋的合法性、气与提子、禁着点都要能自洽漏一个这棋就没法下。这份VC/MFC源代码适合当游戏编程入门骨架也适合让你反向检验自己对Windows消息循环和MFC文档视图结构掌握得扎不扎实。2. 工程结构拆解文档/视图分工与一盘对局的完整消息流2.1 工程里的四个类分别扛什么活这是典型的MFC单文档SDI结构整个程序由四个类协作完成类名所在文件职责CGoAppGoGame.cpp程序入口创建文档模板CMainFrameMainFrm.cpp主窗口框架菜单栏挂载点CGoDocGoGameDoc.cpp棋盘数组、落子规则、提子计算、胜负判定CGoViewGoGameView.cpp棋盘绘制、鼠标点击响应分工原则和MFC一贯的约定一样业务规则全部放Doc界面绘制全部放View。落子能不能落、落下去提哪些子、谁赢了这些永远不写在View里View只负责两件事——接收鼠标消息以及把Doc里的棋盘状态画出来。棋盘数据的存储方式很直接就是Doc类里一个二维int数组static const int BOARD_SIZE 19; static const int EMPTY 0; static const int BLACK 1; static const int WHITE 2; int m_board[BOARD_SIZE][BOARD_SIZE]; // 空交叉点为0黑子为1白子为2 int m_turn; // 当前轮到哪一方落子参数说明m_board[行][列]的取值只会是0、1、2三个常量这样判断某一格状态时只需要做整数比较不需要额外建结构体。m_turn在每次成功落子后由BLACK切换成WHITE或由WHITE切回BLACK。2.2 一次落子的完整消息链路View类的消息映射只挂了两条BEGIN_MESSAGE_MAP(CGoView, CView) ON_WM_LBUTTONDOWN() ON_WM_ERASEBKGND() END_MESSAGE_MAP()ON_WM_LBUTTONDOWN负责鼠标左键ON_WM_ERASEBKGND是后面避坑章节要提到的重绘关键。一次落子的完整流程如下鼠标左键按下框架回调CGoView::OnLButtonDown把鼠标屏幕坐标转成窗口客户区坐标按棋盘格子尺寸换算成 行/列调用CGoDoc::TryPlace(row, col, m_turn)做合法性判断和提子合法则切换落子方调用Invalidate()触发重绘OnDraw把棋盘和所有棋子重新画一遍坐标换算是整个链路上最容易出错的一步工程里用的是这个写法int col (point.x - EDGE_PADDING CELL_SIZE / 2) / CELL_SIZE; int row (point.y - EDGE_PADDING CELL_SIZE / 2) / CELL_SIZE; if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) return;EDGE_PADDING是棋盘四周留白CELL_SIZE是相邻交叉点之间的距离。加CELL_SIZE / 2再除CELL_SIZE本质是做四舍五入让鼠标点在交叉点周围半个格子内时都能就近落到目标交叉点上。2.3 绘制全在OnDraw里没有花活棋盘的绘制逻辑写在CGoView::OnDraw里没有用任何贴图资源全部是用GDI画线画圆void CGoView::OnDraw(CDC* pDC) { // 画19条横线和19条竖线 for (int i 0; i BOARD_SIZE; i) { pDC-MoveTo(EDGE_PADDING, EDGE_PADDING i * CELL_SIZE); pDC-LineTo(EDGE_PADDING (BOARD_SIZE - 1) * CELL_SIZE, EDGE_PADDING i * CELL_SIZE); pDC-MoveTo(EDGE_PADDING i * CELL_SIZE, EDGE_PADDING); pDC-LineTo(EDGE_PADDING i * CELL_SIZE, EDGE_PADDING (BOARD_SIZE - 1) * CELL_SIZE); } // 逐格画棋子 for (int r 0; r BOARD_SIZE; r) for (int c 0; c BOARD_SIZE; c) { if (m_board[r][c] EMPTY) continue; CBrush br(m_board[r][c] BLACK ? RGB(0, 0, 0) : RGB(255, 255, 255)); pDC-SelectObject(br); int x EDGE_PADDING c * CELL_SIZE; int y EDGE_PADDING r * CELL_SIZE; pDC-Ellipse(x - 12, y - 12, x 12, y 12); } }这段代码里Ellipse画的是圆半径取12像素配合30像素的格距刚好留出视觉间隔。注意SelectObject选入新画刷后没有恢复旧画刷这种写法在当前坐标变换下能跑严谨做法是保存返回值、画完恢复后面如果要加棋盘高亮选中效果就必须改掉。3. 核心模块逐行解读气、提子、禁着点这三座大山3.1 落子入口动棋盘之前先想好怎么回滚围棋规则里最难的不是谁赢谁输而是“这个点到底能不能下”。工程把这一整套判断收敛在CGoDoc::TryPlace里bool CGoDocument::TryPlace(int row, int col, int color) { if (m_board[row][col] ! EMPTY) return false; int snapshot[BOARD_SIZE][BOARD_SIZE]; memcpy(snapshot, m_board, sizeof(m_board)); m_board[row][col] color; // 第一步提走相邻的、没有气的对方棋子组 for (int d 0; d 4; d) { int nr row DIR[d][0]; int nc col DIR[d][1]; if (!IsInBoard(nr, nc)) continue; if (m_board[nr][nc] GetOpponent(color)) { if (CountLiberty(nr, nc) 0) RemoveDeadGroup(nr, nc); } } // 第二步如果自己这组没气说明是禁着点回滚 if (CountLiberty(row, col) 0) { memcpy(m_board, snapshot, sizeof(m_board)); return false; } return true; }参数说明row和col是0~18的行列索引color在调用时传入当前的m_turn。函数返回true表示落子成功并已处理完提子返回false表示交叉点已有棋子或构成禁着点。这里最关键的是做了一次棋盘快照把361个int整体memcpy到临时数组。19路棋盘满盘也就361个交叉点拷贝整份棋盘是微秒级操作性能上完全可以接受。思路是“先把子放上去再走完整套提子检查发现不合规就把快照恢复回来”用空间换逻辑清晰。3.2 数气把连通棋子当成一个整体做BFS围棋里的“气”是和一组棋子直接相邻的空交叉点数量。注意是“一组”不是“一颗”。同色棋子只要在横竖方向上连在一起就是一个整体共享气数。这个工程求气用的是BFSint CGoDocument::CountLiberty(int row, int col) { int color m_board[row][col]; if (color EMPTY) return 0; bool visited[BOARD_SIZE][BOARD_SIZE]; bool libSeen[BOARD_SIZE][BOARD_SIZE]; memset(visited, 0, sizeof(visited)); memset(libSeen, 0, sizeof(libSeen)); int liberties 0; std::queuePos q; q.push({row, col}); visited[row][col] true; while (!q.empty()) { Pos cur q.front(); q.pop(); for (int d 0; d 4; d) { int nr cur.row DIR[d][0]; int nc cur.col DIR[d][1]; if (!IsInBoard(nr, nc)) continue; if (m_board[nr][nc] EMPTY) { if (!libSeen[nr][nc]) { libSeen[nr][nc] true; liberties; } } else if (m_board[nr][nc] color !visited[nr][nc]) { visited[nr][nc] true; q.push({nr, nc}); } } } return liberties; }两个标志数组的分工不同visited管棋子组遍历避免同一组棋子被重复入队libSeen管空点去重避免同一个空交叉点被旁边多个己方棋子重复计数。很多新手的翻车点就在这——直接对每个棋子数空邻居然后累加一个空点旁边五个同色棋子气数被算成5提子判断当然全乱。CountLiberty(row, col)的返回值就是这一整组连通棋子当前的气数。判断提子时只要返回值是0整组全提。3.3 提子与禁着点先提对方再查自己RemoveDeadGroup负责把一整组没气的棋子从棋盘上清掉同样用BFSvoid CGoDocument::RemoveDeadGroup(int row, int col) { int color m_board[row][col]; std::queuePos q; q.push({row, col}); m_board[row][col] EMPTY; while (!q.empty()) { Pos cur q.front(); q.pop(); for (int d 0; d 4; d) { int nr cur.row DIR[d][0]; int nc cur.col DIR[d][1]; if (!IsInBoard(nr, nc)) continue; if (m_board[nr][nc] color) { m_board[nr][nc] EMPTY; q.push({nr, nc}); } } } }注意入队前就必须把棋盘置空否则相邻同色棋子会重复入队队列里出现同一坐标的多个副本。这个函数本身不判断气数调用方保证传入的坐标一定是无气组。两个函数拼在一起就是禁着点判定的完整逻辑。禁着点正确的判断顺序是先提对方死子、再看自己落下的那组棋子是否有气。如果反过来先查自己落子点自己无气但同时这一步能提掉对方一大片棋子并让自己重新有气这种棋型会被误判成禁着点。工程里用快照回滚的方案从根源上避免了顺序错误——先放任走完整流程最后统一检查并恢复。3.4 终局裁判简化版数子算法这个工程没有做点目用的是中国规则数子法的简化版。终局时先把死子手动提掉然后调用ComputeScore对棋盘做一次全盘扫描void CGoDocument::ComputeScore(double black, double white) { black 0; white 0; bool visited[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] BLACK) { black 1.0; continue; } if (m_board[r][c] WHITE) { white 1.0; continue; } if (visited[r][c]) continue; // BFS扫描一片相连的空交叉点 std::queuePos q; q.push({r, c}); visited[r][c] true; int area 0; bool touchesBlack false, touchesWhite false; while (!q.empty()) { Pos cur q.front(); q.pop(); area; for (int d 0; d 4; d) { int nr cur.row DIR[d][0]; int nc cur.col DIR[d][1]; if (!IsInBoard(nr, nc)) continue; if (m_board[nr][nc] EMPTY) { if (!visited[nr][nc]) { visited[nr][nc] true; q.push({nr, nc}); } } else if (m_board[nr][nc] BLACK) { touchesBlack true; } else if (m_board[nr][nc] WHITE) { touchesWhite true; } } } if (touchesBlack !touchesWhite) black area; if (touchesWhite !touchesBlack) white area; } black - 3.75; // 中国规则黑贴3又3/4子 }空交叉点区域会整个做一次BFS统计这片空点接触到了哪些颜色的棋子。只贴着一方棋子的空点归那一方两边都贴的空点按共活处理双方都不加。黑棋最后总分扣掉3.75子作为贴目数值和实盘规则一致稍作调整就能切换成日韩规则的点目制。4. 编译运行与避坑记录跨编译器把老工程跑起来的一条教训4.1 从dsw到可运行的四步这份工程拿到的形态是传统MFC工作区不是新式解决方案文件。跑起来分四种情况用VC6.0打开GoGame.dsw直接F5编译这是最顺的路径老版编译器对这类工程基本零配置。用新版VS打开同一份dswIDE会提示转换工程格式确认转换后自动生成sln。转换后如果报错找不到afxwin.h这类头文件说明当前VS只装了Win32桌面组件、没装MFC库需要重新运行VS安装程序勾选“适用于最新v143生成工具的C MFC”。中文注释或字符串用窄字符时在工程属性里把字符集设为“使用多字节字符集”否则会遇到C2664这类字符集相关的转换报错。编译通过后运行窗口标题显示“围棋 - 双人对战”菜单里有“新对局”“提子确认”“计算胜负”三个入口。棋盘默认19路没有网络对弈和AI适合本地两人轮流落子。4.2 避坑清单坑一新版VS编译报错找不到afxwin.h。现象是转换完工程后一编译就抛“无法打开包括文件: afxwin.h”。原因往往是安装VS时没有勾选MFC和ATL组件。解决方法是打开VS Installer在“使用C的桌面开发”工作负载里勾上“适用于最新v143生成工具的C MFC”修复后重启VS编译即可。坑二鼠标点击总是落到相邻交叉点。现象是点交叉点总差一格越靠棋盘边缘越明显。原因是视图收到的坐标还没做ScreenToClient转换加上没按EDGE_PADDING做偏移就除格子尺寸。解决方法是先调用ScreenToClient(point)再按前面2.2节的公式换算出行列不要直接用原始屏幕坐标。坑三吃子之后死子还赖在棋盘上。现象是提子逻辑在Doc层明明修改了数组界面上的死子就是不消失。原因很简单——改了数据没发重绘消息。解决方法是每次TryPlace返回true之后在View的落子处理末尾调用Invalidate()让OnDraw重新执行。吃子后棋盘残影基本都是这个原因。坑四能提对方棋子的落子被误判成禁着点。现象是某一步棋明明能把对方一大片提掉程序却拒绝落子。原因很隐蔽如果先查自己的气再提对方落子后自己的组因为对方棋子挡着暂时无气此时程序已经返回false根本不会执行后面的提子流程。工程里用快照回滚方案把“先提对方”变成必然步骤就不存在这个顺序问题了。坑五窗口拖动后棋盘闪烁且出现残影。现象是对局中拖动窗口棋盘区域闪得厉害部分区域重叠。原因是默认WM_ERASEBKGND会用背景色刷一遍窗口OnDraw再画一遍棋盘两次绘制叠加产生闪烁。解决方法是重写OnEraseBkgnd并返回TRUE关掉背景擦除把棋盘绘制整体做进双缓冲内存DC后再一次性贴屏。5. 二次开发扩展双人对决升级成完整围棋工具的三条路线5.1 先加悔棋功能最容易也最实用工程里已经有棋盘快照的完整写法悔棋只是把这个机制做成历史栈。在Doc类里维护一个栈结构每次成功落子前把当前m_board整体压栈悔棋时从栈顶弹一份复制回数组再切换回上一手落子方。std::vectorBoardSnapshot m_history; bool CGoDocument::Undo() { if (m_history.empty()) return false; m_board m_history.back().board; m_turn m_history.back().turn; m_history.pop_back(); return true; }棋盘快照本身是深拷贝所以悔棋恢复的是一整份完整的19路局面不需要逐格增量更新逻辑上零风险。菜单里挂一项“悔棋”调Undo()后刷新视图就行。5.2 网络版双人对决分清哪些状态需要同步原工程是本地双人轮流落子做成局域网联机版时要做网络对弈理论上只需同步三类信息——手数、落子坐标、当前回合。棋盘数据不需要在网络上整体传输因为两端各自运行着同一套规则引擎收到“第37手落于L12”后本地重新执行一次TryPlace提子结果自然一致。我一般用CSocket或Winsock封装一个简单的消息结构体客户端发落子请求服务端校验后转发给对方其余局面由各端自行推演。5.3 加一个落子计时器双人对决适合加读秒。视图里放一个WM_TIMER定时器每手落子后重置计时超时弹窗判负。MFC的SetTimer配合OnTimer处理函数就能完成成本很低但能明显提升对局体验。从那以后我每次拿到一份MFC小游戏源码都会先把“落子入口到重绘链路”从头读一遍再动手改。这份工程留给我的最深体会是黑白棋规则看似简单状态一致性里全是细节把TryPlace和重绘理顺了后面加什么功能都顺。希望帮到你。本文还有配套的精品资源点击获取