VC++实现双人对弈围棋:MFC框架下的规则引擎与界面分离

发布时间:2026/10/17 6:58:06
VC++实现双人对弈围棋:MFC框架下的规则引擎与界面分离 简介一款基于VC/MFC的双人对决围棋程序源码包适合VC初、中级学习者参考游戏编程入门。程序虽不复杂但完整覆盖了MFC单文档应用的创建、界面窗口选择、棋盘绘制与落子交互等环节打开后还能按15/17寸液晶屏调整窗口。压缩包共24个文件以7个.h头文件、6个.cpp源文件、3个.bmp位图和2个.ico图标为主另含.dsp/.dsw/.rc等标准工程文件资源仅36KB结构精炼便于阅读。代码包含I_go主程序、MainFrm主窗框、Grafic绘图模块与I_goDoc文档类等模块可帮助理解VC文档视图架构。已有171人学习浏览对想亲手实现棋类小游戏、加深MFC机制的读者这份源码有不错的借鉴价值。1. 可以双人对决的VC围棋程序MFC单文档框架下的完整对弈实现打开程序黑棋先落子两个人轮流在同一台电脑上点棋盘。点错位置会被拦下来吃到对方棋子画面自动提走双方连PASS两次就自动数子判胜负。这个VC围棋程序的核心价值不在画面而在于它把一个完整的19路围棋规则引擎——禁着点、气、提子、打劫、数子——全部收在独立的C类里界面层只负责画棋盘和接鼠标消息。对想找VC/MFC源代码做游戏编程练手的人来说这是理解“逻辑与界面分离”最顺手的样本后面要加AI或网络对战都不必动绘图代码。2. 工程结构与技术选型MFC单文档框架下的类职责划分2.1 为什么双人围棋适合用MFC实现围棋单机双人对弈天然是一个回合制本地应用没有网络包要收发没有定时器驱动的实时刷新只有一个棋盘状态和一套轮流落子的规则。MFC的单文档框架恰好把数据、绘制、主窗口三等分落在这个项目上就是棋盘数据、棋盘绘制、菜单与状态栏三块东西。另一个实际好处是MFC界面代码和业务规则可以拆得足够干净。规则引擎用一个不依赖任何afx头文件的普通C类就能实现调试规则逻辑时根本不需要启动窗口直接在控制台写几行代码调用落子函数就能验证吃子对不对。游戏编程里“玩法逻辑和界面渲染分离”这一课这个项目给出了非常典型的示范。国内很多讲VC/MFC源代码游戏编程的教程都喜欢拿围棋、五子棋、扫雷来当范例原因也在这里项目规模小但该有的MFC机制一个不少。有人觉得MFC老了但作为入门棋类项目它完全够用。一个回合制棋类程序需要的消息映射、GDI绘制、菜单事件、状态栏刷新做完这个项目都能覆盖一遍日后要写桌面工具或调试面板这些能力是直接平移的。2.2 工程文件与类分工整个项目按MFC单文档框架的惯例分成四个类职责分得很清晰文件/类职责关键成员与方法CGoGame规则引擎不依赖MFCm_board[19][19]PlaceStoneCountLibertyRemoveGroupStoneCalWinnerCGoView棋盘绘制与鼠标交互OnPaintOnLButtonDownPassDrawStoneCGoDoc对局数据容器序列化和存档扩展点CMainFrame菜单、PASS指令、状态栏OnNewGameOnPassUpdateStatusBarCGoGame是整个项目的核心它不包含MFC头文件内部只有纯C的数组、栈、队列。这么做不是巧合而是为了让规则引擎可以被单独编译和测试不用每次验证一个吃子逻辑都要走一遍窗口程序。CGoView持有一个CGoGame对象作为成员鼠标点击产生的坐标转换后直接调用它返回成功后刷新画面。实际工程里CGoDoc的角色经常被弱化因为对局数据几乎都在CGoGame里管着Doc只需要在新建和打开文档时调用InitBoard即可。如果后面想加棋谱保存才需要把序列化逻辑补到CGoDoc里。2.3 一局棋的数据流一次落子的完整链路是这样的玩家在CGoView客户区按下鼠标左键系统把屏幕坐标传给OnLButtonDown这里先做屏幕坐标到棋盘行列的换算然后把行列交给CGoGame::PlaceStonePlaceStone内部依次做禁着点检查、临时落子、检查对方气数并提子、检查自身气数、判断打劫合法就切换回合最后CGoView调用InvalidateRect触发OnPaint重绘整个棋盘状态栏同步更新为对方回合。PASS的链路稍微短一点菜单点击后进入View的Pass函数m_passCount加一如果双方连续PASS达到两次就认定对局结束调CalWinner数子并弹窗显示结果如果只有一方PASS则只切换回合不清劫标因为对手下一手仍可能形成劫争。理清这两条链路之后项目整体结构就通透了改规则逻辑不用碰绘图改界面交互不用碰规则引擎两者之间只通过CGoGame的成员函数说话。3. 核心规则实现气、提子、打劫与数子这一章是项目最值得看的代码部分。整个规则引擎围绕一个二维数组展开但算气、提子、劫的判断是几个独立的算法步骤必须分开实现。先交代数据结构再逐个函数拆解。3.1 棋盘数据结构和初始化围棋棋盘是19乘19的交叉点用二维数组最直接。0表示空1和2分别表示黑子和白子这样代码里所有判断都是整数比较读写效率也高。#define BOARD_SIZE 19 #define EMPTY 0 #define BLACK 1 #define WHITE 2 class CGoGame { public: CGoGame() { InitBoard(); } void InitBoard() { memset(m_board, EMPTY, sizeof(m_board)); m_turn BLACK; // 黑先白后 m_passCount 0; // 连续PASS计数到2终局 m_gameOver false; m_lastRow m_lastCol -1; // 上一步落子坐标 m_capRow m_capCol -1; // 上一步被提子坐标只记录单颗提子 } int m_board[BOARD_SIZE][BOARD_SIZE]; int m_turn; int m_passCount; bool m_gameOver; int m_lastRow, m_lastCol; int m_capRow, m_capCol; };memset清零天然得到空棋盘所以InitBoard里不需要嵌套循环逐个赋值。m_lastRow和m_capRow这两组坐标是为打劫准备的只有在提子数恰好等于1时才有意义这个约束会在落子函数里严格维护。3.2 算气与提子算气是围棋规则里面最基础的算法。一块连通的同色棋子所有相邻空点的数量就是它的气气归零就要被提走。这里的实现用std::stack做显式迭代避免递归深度过大。int CGoGame::CountLiberty(int row, int col) { if (m_board[row][col] EMPTY) return 0; int color m_board[row][col]; bool visited[BOARD_SIZE][BOARD_SIZE] {false}; int liberty 0; std::stackstd::pairint,int st; st.push({row, col}); visited[row][col] true; static int dirs[4][2] {{-1,0},{1,0},{0,-1},{0,1}}; while (!st.empty()) { auto p st.top(); st.pop(); for (auto d : dirs) { int nr p.first d[0]; int nc p.second d[1]; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) continue; if (m_board[nr][nc] EMPTY) liberty; else if (m_board[nr][nc] color !visited[nr][nc]) { visited[nr][nc] true; st.push({nr, nc}); } } } return liberty; }参数row和col是连通棋块中任意一个棋子的位置函数会遍历整个同色连通块并统计所有相邻空点。visited数组保证同一颗子不会被重复入栈相邻空点也不会被重复计数。这里用栈迭代而不是递归是因为19路棋盘上一块棋最长可以延伸到接近361个点递归深度一大就容易爆栈这是我在早期版本里实际翻过车的点。提子逻辑反过来把一组无气棋子清空同时返回提掉的数量int CGoGame::RemoveGroupStone(int row, int col) { if (m_board[row][col] EMPTY) return 0; int color m_board[row][col]; int removed 0; std::stackstd::pairint,int st; st.push({row, col}); m_board[row][col] EMPTY; removed; static int dirs[4][2] {{-1,0},{1,0},{0,-1},{0,1}}; while (!st.empty()) { auto p st.top(); st.pop(); for (auto d : dirs) { int nr p.first d[0]; int nc p.second d[1]; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) continue; if (m_board[nr][nc] color) { m_board[nr][nc] EMPTY; removed; st.push({nr, nc}); } } } return removed; }算气和提子共用同一套四方向邻居遍历的结构方向数组在实现上可以提取成公共常量避免两处书写不一致。RemoveGroupStone返回的removed数量很重要PlaceStone要根据这个数判断是否记录劫点。3.3 落子主逻辑禁着点、提子与简单劫落子函数是规则引擎的心脏它把临时落子、吃子、自杀检测、劫争检测串在一起。实现采用“先放子再撤销”的策略逻辑直观代价是撤销时要保证棋盘状态完全恢复。bool CGoGame::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] ! EMPTY) return false; if (m_gameOver || m_turn ! color) return false; m_board[row][col] color; // 先临时落子 int opp (color BLACK) ? WHITE : BLACK; int captured 0; int capturedRow -1, capturedCol -1; static int dirs[4][2] {{-1,0},{1,0},{0,-1},{0,1}}; for (auto d : dirs) { int nr row d[0], nc col d[1]; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) continue; if (m_board[nr][nc] opp CountLiberty(nr, nc) 0) { int n RemoveGroupStone(nr, nc); captured n; if (n 1) { capturedRow nr; capturedCol nc; } } } // 落子后自己无气自杀撤销落子并恢复被提的子 if (CountLiberty(row, col) 0) { m_board[row][col] EMPTY; return false; } // 简单劫检测本手点在上一手被提的单颗子位置 // 并且也只提回一颗子等于还原棋形禁止 if (captured 1 m_capRow row m_capCol col capturedRow m_lastRow capturedCol m_lastCol) { m_board[row][col] EMPTY; m_board[capturedRow][capturedCol] opp; // 恢复对方那颗子 return false; } m_lastRow row; m_lastCol col; if (captured 1) { m_capRow capturedRow; m_capCol capturedCol; } else { m_capRow m_capCol -1; } m_turn opp; m_passCount 0; return true; }这里有几个关键细节。第一提子遍历顺序固定先检查四邻对方无气的整块棋会被一次性提掉。第二自杀判断发生在提子之后如果落子后自己的块没有气需要把刚落下的子和被提走的对方子都恢复只清空当前点会导致棋局缺子。第三劫争检测的三个条件缺一不可落点必须是上一手被提的单颗子位置、本步恰好只提一颗、被提的那颗正好是上一手的落子点三个条件同时成立才判定为禁着点。注意这个实现只覆盖标准单劫。对于双劫、循环劫这类少见局面教学项目一般不做全局同形检测。改进方向是把整个棋盘做哈希每次落子后比对历史局面发现重复就驳回改动量会大不少。3.4 终局数子双方连续PASS后进入数子阶段。数子前先清掉棋盘上所有无气残子然后用BFS把所有空白区域找出来按邻接颜色归属双方领地最后按中国规则贴3又3/4子判定胜负。int CGoGame::CalWinner() { // 清掉双方无气的残子 for (int i 0; i BOARD_SIZE; i) for (int j 0; j BOARD_SIZE; j) if (m_board[i][j] ! EMPTY CountLiberty(i, j) 0) RemoveGroupStone(i, j); bool visited[BOARD_SIZE][BOARD_SIZE] {false}; int blackArea 0, whiteArea 0; int blackStones 0, whiteStones 0; for (int i 0; i BOARD_SIZE; i) { for (int j 0; j BOARD_SIZE; j) { if (m_board[i][j] BLACK) blackStones; else if (m_board[i][j] WHITE) whiteStones; else if (!visited[i][j]) { std::queuestd::pairint,int q; std::setint adj; int area 0; q.push({i, j}); visited[i][j] true; while (!q.empty()) { auto p q.front(); q.pop(); area; static int dirs[4][2] {{-1,0},{1,0},{0,-1},{0,1}}; for (auto d : dirs) { int nr p.first d[0]; int nc p.second d[1]; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) continue; if (m_board[nr][nc] EMPTY !visited[nr][nc]) { visited[nr][nc] true; q.push({nr, nc}); } else if (m_board[nr][nc] ! EMPTY) { adj.insert(m_board[nr][nc]); } } } if (adj.size() 1) { if (*adj.begin() BLACK) blackArea area; else whiteArea area; } } } } double blackScore blackStones blackArea; double whiteScore whiteStones whiteArea; if (blackScore - 3.75 whiteScore) return BLACK; else return WHITE; }BFS里adj这个set是关键它记录了一块空白区域邻接的所有颜色。只有邻接单色的区域才算是有效领地既邻黑又邻白的空白区域在大贴目规则下按“共享”处理谁都不算。数子结果用浮点数比较避免贴子换算成整数时产生争议代码读起来也贴近中国规则的描述方式。4. 界面交互与绘制OnPaint、坐标换算与状态栏规则引擎本身不画任何东西所有可见内容都由CGoView负责。这一章讲棋盘怎么画、鼠标点怎么换算、PASS和状态栏怎么配合。4.1 OnPaint画棋盘和棋子棋盘绘制在OnPaint里一次性完成背景、网格线、星位、棋子全部在这里画。这样画法最笨但最稳任何一处刷新都是整盘重绘。void CGoView::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(rcClient); const int margin 30; const int cell 28; const int size (BOARD_SIZE - 1) * cell; dc.FillSolidRect(rcClient, RGB(222, 184, 135)); CPen pen(PS_SOLID, 1, RGB(0, 0, 0)); dc.SelectObject(pen); for (int i 0; i BOARD_SIZE; i) { dc.MoveTo(margin, margin i * cell); dc.LineTo(margin size, margin i * cell); dc.MoveTo(margin i * cell, margin); dc.LineTo(margin i * cell, margin size); } int stars[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)); dc.SelectObject(starBrush); for (auto s : stars) { int x margin s[1] * cell; int y margin s[0] * cell; dc.Ellipse(x - 3, y - 3, x 3, y 3); } for (int r 0; r BOARD_SIZE; r) for (int c 0; c BOARD_SIZE; c) if (m_game.m_board[r][c] ! EMPTY) DrawStone(dc, r, c); }margin和cell是关键参数margin控制棋盘到客户区左边和上边的距离cell是每格像素距离它决定了整个棋盘在窗口中的大小。19路棋盘的网格范围是18乘以cellspacing排列从margin开始这个尺寸同时被OnPaint和鼠标坐标换算共用所以最好定义成View的常量成员避免两处写魔法数字不一致。绘制棋子的函数独立成DrawStone方便后面加高亮、最后一手标记之类的功能void CGoView::DrawStone(CDC* pDC, int row, int col) { int x margin col * cell; int y margin row * cell; int stone m_game.m_board[row][col]; CBrush brush(stone BLACK ? RGB(0,0,0) : RGB(255,255,255)); CBrush* pOld pDC-SelectObject(brush); pDC-Ellipse(x - 11, y - 11, x 11, y 11); pDC-SelectObject(pOld); }半径取11像素格子半宽是14像素棋子之间留出3像素的缝隙视觉上不会粘连。白棋如果想做得更精细可以在画完后用灰色画一圈边线这个属于美化范畴不影响功能。4.2 鼠标落子与坐标换算屏幕坐标到棋盘行列的换算是整个界面层最容易出问题的地方。换算公式加上半格偏移实现四舍五入保证点格子中间和点交叉点附近都能命中正确的行列。void CGoView::OnLButtonDown(UINT nFlags, CPoint point) { int col (point.x - margin cell / 2) / cell; int row (point.y - margin cell / 2) / cell; if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) { CView::OnLButtonDown(nFlags, point); return; } if (m_game.PlaceStone(row, col, m_game.m_turn)) { UpdateStatusBar(); InvalidateRect(NULL, FALSE); } else { MessageBeep(MB_ICONERROR); } CView::OnLButtonDown(nFlags, point); }偏移换算的原理是先减去棋盘左上角margin得到相对棋盘原点的像素距离再除以cell得行列。加上cell/2再除就是在整数除法里做四舍五入。举例说明如果point.x减去margin是41cell是28直接除得1加上14后是55除28得1结果一样但如果差值正好落在格中偏左四舍五入就能修正边界误判。InvalidateRect的第二个参数传FALSE很重要。传TRUE会让MFC先擦除客户区背景再重绘画面会产生明显闪烁传FALSE则跳过擦除由OnPaint全量重画闪烁几乎消失。再配合拦截WM_ERASEBKGND消息画面就稳定了。4.3 PASS与状态栏联动PASS是终局前的关键操作。View里的Pass函数负责改回合、累计连续PASS次数、触发数子void CGoView::OnPass() { if (m_game.m_gameOver) return; m_game.m_passCount; if (m_game.m_passCount 2) { int winner m_game.CalWinner(); CString msg; msg.Format(_T(对局结束%s获胜), winner BLACK ? _T(黑棋) : _T(白棋)); MessageBox(msg); m_game.m_gameOver true; } else { m_game.m_turn (m_game.m_turn BLACK) ? WHITE : BLACK; } UpdateStatusBar(); InvalidateRect(NULL, FALSE); }状态栏刷新由CMainFrame统一处理这样View不用关心状态栏具体有几个面板void CMainFrame::UpdateStatusBar(int turn) { CString str; str.Format(_T(当前%s), turn BLACK ? _T(黑棋) : _T(白棋)); m_wndStatusBar.SetPaneText(0, str); }PASS还有一个容易遗漏的细节单方PASS后m_capRow和m_capCol不能清空也不能保留。清空会导致劫争范围变小保留则可能让对手在PASS之后还被劫限制。按照我的实现单方PASS时保留已有劫标双方PASS则直接终局不再考虑后续落子这条策略配合规则引擎里“PASS不算落子、不改m_lastRow”的设计才能保证劫检测的边界情况稳定。5. 常见问题与避坑MFC围棋项目从编译到对局的血泪经验5.1 高版本VC打开老工程直接编译不过现象拿到了源码和工程文件用新一点的IDE打开一编译就是好几页C4996、C2664、C2440错误看着像代码写得有问题实际一个都跑不动。原因老工程默认用的是多字节字符集和旧版编译选项新IDE默认开启Unicode字符集和SDL安全检查厂商接口签名变了老写法直接被判死刑。解决打开项目属性常规页把字符集改成“使用Unicode字符集”C/C页面把SDL检查改成“否”在预处理器定义里加上_CRT_SECURE_NO_WARNINGS。做完这三步大部分C4996和字符串转换错误就消掉了。这个步骤做MFC老项目移植几乎必踩一遍。5.2 棋盘画出来了但棋子死活不显示现象网格线和星位都正常落子后状态栏也提示轮到对方了但棋盘上看不到任何棋子。原因OnPaint里读取的棋盘数据和你鼠标落子的棋盘不是同一个对象。最常见的是View构造时CGoGame被复制了一份OnLButtonDown改的是成员对象OnPaint读的是另一个临时副本或者OnPaint开头不小心重新初始化了棋盘。解决在View类里只保存一个CGoGame引用或指针构造时不拷贝OnInitialUpdate里初始化一次之后所有函数都操作同一个实例。排查方法很简单在OnPaint里下断点看m_board数组的值和落子后的值比对几下就知道是不是同一个对象了。这算是MFC里最经典的“数据副本”玄学。5.3 提子提着提着整盘棋被清空现象某步落子后棋盘上大片棋子瞬间消失严重时直接白板一块程序还会卡死。原因算气函数没有做visited去重同一块棋被反复数气气数虚高导致该提的不提或者提子递归实现深度太深19路棋盘上最长链接近361层直接栈溢出崩溃。解决按第3章代码里的方式算气和提子都用std::stack做显式迭代visited数组严格标记。递归版本看着简洁在13路棋盘上可能没事一换19路就翻车。栈溢出的现象通常是程序跑到一半直接退出Debugger显示栈上异常查起来很费时间。5.4 落子总歪一格棋子画在线上现象明明点的是交叉点附近落子却跑到旁边一格的交叉点上特别是靠近棋盘边缘时偏得特别明显。原因整数除法向零取整的写法。如果直接用(point.x - margin) / cell当差值比半格多一点点时除法截断会让坐标映射到错误的行列尤其是在没有加半格偏移的情况下。解决把坐标换算封装成独立函数内部统一用(x - margin cell / 2) / cell并且入口处做边界判断。写完后用边界值单独测一遍margin位置、margincell-1位置、margin18*cell位置分别应该映射到0、0、18这些正确索引。5.5 打劫失效对手立刻原地提回现象黑棋提劫后白棋下一手马上落在原位置提回程序没有拦截棋形还原劫争形同虚设。原因劫标记的更新时机不对。可能是提了两颗以上时把capturedRow误当劫点也可能是上一方PASS之后没处理好m_capRow给对手钻了空子。解决严格按“只有当本步提子数等于1时才记录劫点”来约束多子提吃一律把m_capRow置为-1。PASS分支里保留劫标但不刷新回合记录让下一步的劫检测依然对上家提子位置有效。验证方法是构造一个经典单劫局面连续下四手确认提回被正确拦截再PASS一手后确认劫解除。6. 二次开发方向悔棋、存档与一个提子型AI双人对局跑通之后最值得先加的功能是悔棋。实现思路是给CGoGame增加一个对局记录栈每次PlaceStone成功时把落子坐标、颜色、本步被提子坐标列表全部压栈。悔棋时弹出栈顶记录把被提的子放回清掉本步落子再恢复劫标志和回合标记。注意劫标志必须跟着回滚否则历史局面和当前棋盘状态会对不上后面判定劫就失真。存档功能可以在CGoDoc里做。MFC的Serialize机制已经把CArchive封装好了只要把m_board、m_turn、m_passCount、黑白提子数这些字段按顺序写入再按相同顺序读出即可。对围棋这种回合制项目一局数据量很小整个二维数组写进去也就几百字节。AI方面不建议一上来就写蒙特卡洛。先从“双人模式里加一个白棋自动落子”开始做一个只会提子和守气的启发式AIbool TryAI(CGoGame game, int bestRow, int bestCol) { // 第一步找自己只剩一口气的棋块优先自救 for (int r 0; r BOARD_SIZE; r) for (int c 0; c BOARD_SIZE; c) if (game.m_board[r][c] WHITE game.CountLiberty(r, c) 1) { // 找出唯一的空点返回 return true; } // 第二步找对方气数为0的棋块并落子提取 // 第三步如果都找不到找一个安全空点随机落子 return false; }这个AI的水平不高但代码量不到一百行能让“人机模式”这个入口先立起来。之后想换更强的逻辑只需要替换第三步的随机落子策略函数签名保持不变View层不用动。从那以后我每次接手这种带规则引擎的棋类项目都会先强制把规则层和界面层之间划一条明确的调用边界再让规则层跑一遍纯控制台的自动化对局用例确认吃子、劫、数子都对了才去动界面代码。这套习惯让我后期调试少走了很多弯路希望帮到你。本文还有配套的精品资源点击获取