简介:这是一份基于VC++/MFC编写的围棋对弈程序源码,面向有一定C++基础、希望深入理解Windows图形界面与游戏逻辑的开发者,尤其适合想通过完整小项目巩固MFC框架的学习者。压缩包共24个文件,约36KB,包含7个h头文件与6个cpp源文件构成核心逻辑,另有3个bmp位图与2个ico图标负责界面素材,以及dsp、dsw工程文件和rc资源脚本,可直接用Visual Studio打开编译运行。程序支持双人对决,启动后可选择适配15、17寸液晶的窗口尺寸,落子响应迅速,交互流畅。目前已有171人学习关注。通过阅读源码,读者能掌握MFC文档视图结构、绘图刷新、鼠标消息处理与棋盘状态管理等关键实现,并借鉴作者在界面布局与资源组织上的思路,是入门Windows游戏编程与MFC实战的轻量参考案例。1. 双人对决的VC++围棋程序:一个能直接跑起来的MFC实战项目很多人学完VC++和MFC的语法之后,卡在“不知道拿什么练手”这一步。控件拖了、消息映射写了、对话框弹出来了,但真让自己从零搭一个能交互、有规则、能双人对战的完整程序,还是不知道从哪下手。这个双人对决的VC++围棋程序就是冲着这个缺口来的——它用MFC搭界面,用GDI画棋盘和棋子,把落子、提子、禁着点判断、胜负判定这些围棋核心逻辑全部用C++实现了一遍,没有依赖任何第三方棋类引擎。整个工程可以直接用Visual Studio打开编译,跑起来就是两个人轮流点鼠标下棋的完整程序。适合正在学MFC想找一个有实际逻辑的项目练手的人,也适合想拿一个现成的棋类框架改成五子棋、黑白棋的开发者。它不是一个玩具级的Demo,棋盘状态管理、气计算、打劫判断这些容易翻车的地方都做了处理,代码结构也留了扩展空间。2. 工程结构与核心模块:从棋盘数据到界面渲染的完整链路2.1 项目目录与文件职责划分拿到源码包之后,先别急着编译,花两分钟把目录结构看清楚,后面改代码的时候能省很多时间。这个工程是标准的MFC对话框应用结构,主要文件如下:文件职责GoGameDlg.h / .cpp主对话框类,负责界面初始化、鼠标消息响应、按钮事件Board.h / .cpp棋盘核心类,管理19x19棋盘状态、落子、提子、气计算Stone.h棋子数据结构,记录颜色和位置Resource.h / .rc资源定义,包括对话框布局、菜单、图标stdafx.h / .cpp预编译头,MFC标准包含核心逻辑全部集中在Board类里,界面部分在GoGameDlg里。这种分离方式的好处是,如果你想换一套UI,Board类可以原封不动地搬到别的框架里用。我一般拿到一个新项目也会先找这种“逻辑和界面分不分得开”的信号,分得越干净,二次开发越省事。2.2 棋盘数据结构:二维数组还是位棋盘打开Board.h,第一眼看到的是棋盘状态的存储方式。这个项目用的是最直观的二维数组:// Board.h class Board { public: enum StoneColor { EMPTY = 0, BLACK = 1, WHITE = 2 }; Board(); void Reset(); // 重置棋盘 bool PlaceStone(int row, int col, StoneColor color); // 落子 StoneColor GetStone(int row, int col) const; int GetCapturedCount(StoneColor color) const; private: static const int BOARD_SIZE = 19; StoneColor m_board[BOARD_SIZE][BOARD_SIZE]; // 棋盘状态 int m_capturedBlack; // 黑方被提子数 int m_capturedWhite; // 白方被提子数 bool HasLiberty(int row, int col, bool visited[][BOARD_SIZE]); // 判断某位置是否有气 void RemoveGroup(int row, int col); // 提掉整块无气的棋 bool IsKo(int row, int col, StoneColor color); // 打劫判断 };用二维数组的好处是直观,m_board[row][col]一眼就能看出是哪个位置。19x19一共361个点,每个点用一个枚举值表示,内存占用可以忽略不计。常见做法是用位棋盘来加速气计算,但这个项目规模小,二维数组加递归搜索完全够用,代码可读性反而更好。如果你后面想改成五子棋,把BOARD_SIZE改成15,再改一下胜负判定逻辑就行。2.3 落子与提子逻辑:气计算的递归实现围棋最核心的规则就是“气”——一块棋如果没有气,就要被提掉。这个项目用递归的方式计算气,代码在Board.cpp里:// Board.cpp bool Board::HasLiberty(int row, int col, bool visited[][BOARD_SIZE]) { // 边界检查 if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return false; if (visited[row][col]) return false; visited[row][col] = true; // 空位就是气 if (m_board[row][col] == EMPTY) return true; // 如果是同色棋子,继续向四个方向搜索 StoneColor color = m_board[row][col]; if (HasLiberty(row - 1, col, visited)) return true; if (HasLiberty(row + 1, col, visited)) return true; if (HasLiberty(row, col - 1, visited)) return true; if (HasLiberty(row, col + 1, visited)) return true; return false; }这段代码的逻辑是:从某个棋子出发,向上下左右四个方向递归搜索,如果碰到空位就说明这块棋还有气,返回true;如果搜完所有相连的同色棋子都没找到空位,说明这块棋已经被包围,需要提掉。visited数组用来防止重复访问,避免无限递归。参数说明:row和col是当前搜索的位置,visited是访问标记数组,每次调用PlaceStone之前需要先清零。注意这个递归的深度最大是棋盘上的棋子数,19x19最多361层,不会栈溢出。落子的时候,先判断该位置是否为空,然后临时放上棋子,再检查周围对方的棋是否有气,没气的提掉,最后检查自己这块棋是否有气,没气的回滚。这个顺序不能反,否则会出现“自杀”的情况。2.4 界面渲染:用GDI画棋盘和棋子界面部分在GoGameDlg.cpp的OnPaint函数里,用GDI的LineTo和Ellipse画棋盘线和棋子:// GoGameDlg.cpp void CGoGameDlg::OnPaint() { CPaintDC dc(this); // 画棋盘线 int margin = 30; int cellSize = 25; for (int i = 0; i < 19; i++) { // 横线 dc.MoveTo(margin, margin + i * cellSize); dc.LineTo(margin + 18 * cellSize, margin + i * cellSize); // 竖线 dc.MoveTo(margin + i * cellSize, margin); dc.LineTo(margin + i * cellSize, margin + 18 * cellSize); } // 画星位 int stars[][2] = {{3,3},{3,9},{3,15},{9,3},{9,9},{9,15},{15,3},{15,9},{15,15}}; for (int i = 0; i < 9; i++) { int x = margin + stars[i][0] * cellSize; int y = margin + stars[i][1] * cellSize; dc.Ellipse(x - 3, y - 3, x + 3, y + 3); } // 画棋子 for (int r = 0; r < 19; r++) { for (int c = 0; c < 19; c++) { StoneColor color = m_board.GetStone(r, c); if (color == EMPTY) continue; int x = margin + c * cellSize; int y = margin + r * cellSize; CBrush brush(color == BLACK ? RGB(0,0,0) : RGB(255,255,255)); CBrush* oldBrush = dc.SelectObject(&brush); dc.Ellipse(x - 10, y - 10, x + 10, y + 10); dc.SelectObject(oldBrush); } } }这段代码先画19条横线和19条竖线,再画9个星位,最后遍历棋盘数组把棋子画上去。margin是棋盘边缘留白,cellSize是每个格子的大小,这两个值决定了棋盘的视觉比例。如果你想调整棋盘大小,改这两个参数就行,但要注意鼠标点击的坐标换算也要同步改。鼠标点击的处理在OnLButtonDown里,把屏幕坐标转换成棋盘行列,然后调用Board的PlaceStone。坐标换算的公式是col = (point.x - margin + cellSize/2) / cellSize,加cellSize/2是为了让点击落在交叉点附近也能正确识别。3. 编译运行与双人对战流程:从打开工程到第一手棋3.1 环境准备与编译步骤这个工程是标准的MFC项目,用Visual Studio打开就能编译。我实测用的是VS2019,VS2017和VS2022也可以,但要注意MFC组件需要单独安装。如果你装VS的时候没勾选“使用C++的桌面开发”里的MFC选项,打开工程会报一堆“找不到afxwin.h”的错误。安装MFC组件的步骤:打开Visual Studio Installer,点“修改”,在“单个组件”里搜索“MFC”,勾选“适用于最新v143生成工具的C++ MFC”,然后点“修改”等安装完成。这一步不做的话,后面所有编译都是白费。编译的时候,打开GoGame.sln,选择Debug x86或者Release x86,直接按F7。如果一切正常,会在Debug或Release目录下生成GoGame.exe。双击就能运行。注意:如果编译报错“无法打开包括文件: afxwin.h”,说明MFC组件没装好,回到上面那一步重新检查。3.2 双人对战的操作流程程序跑起来之后,是一个对话框界面,中间是棋盘,两边显示黑白双方的提子数。操作流程很简单:黑方先手,鼠标点击棋盘交叉点落子如果落子位置已经有棋子,程序会忽略这次点击如果落子后对方有棋子被提,提子数会更新如果落子会导致自己无气(自杀),程序会拒绝这次落子如果触发打劫,程序会提示“打劫,请重新落子”双方轮流落子,直到一方认输或双方都选择停一手界面上有两个按钮:“认输”和“停一手”。认输就是直接结束游戏,对方获胜。停一手是围棋里的“Pass”,如果双方连续停一手,游戏结束,开始数子判定胜负。3.3 胜负判定与数子逻辑这个项目用的是中国规则数子法。游戏结束后,程序会遍历棋盘,把每个空点归属到最近的颜色,然后计算黑方和白方的总子数。黑方需要贴3又3/4子(相当于7.5目),所以黑方需要185子以上才能赢。数子的代码在Board.cpp的CalculateScore函数里:// Board.cpp int Board::CalculateScore(StoneColor color) { int score = 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] == color) { score++; // 自己的棋子算一子 } else if (m_board[r][c] == EMPTY && !visited[r][c]) { // 空点需要判断归属 StoneColor owner = GetAreaOwner(r, c, visited); if (owner == color) score++; } } } return score; }GetAreaOwner的逻辑是:从一个空点出发,向四周扩散,如果这个区域只接触到一种颜色的棋子,那么这个区域就归那种颜色。如果同时接触到黑白两色,就是“公气”,双方都不算。参数说明:color是要计算分数的颜色,visited是访问标记数组。这个函数的时间复杂度是O(n²),19x19的棋盘跑起来瞬间完成,不需要优化。4. 避坑与常见问题:编译报错、落子异常、界面闪烁的排查4.1 编译报错“无法打开包括文件: afxwin.h”现象:打开工程按F7,直接报错找不到afxwin.h,后面跟着一堆MFC相关的头文件都找不到。原因:Visual Studio安装的时候没有勾选MFC组件。MFC不是默认安装的,需要手动在Installer里勾选。解决:打开Visual Studio Installer,点“修改”,在“单个组件”里搜索“MFC”,勾选对应版本的MFC组件,点“修改”等安装完成。安装完之后重新打开工程,清理解决方案再重新编译。4.2 落子之后棋子不显示现象:鼠标点击棋盘,程序没崩溃,但棋盘上什么都没画出来。原因:OnPaint函数没有被触发。MFC的对话框默认不会在每次数据变化后自动重绘,需要在落子之后手动调用Invalidate()。解决:在PlaceStone调用成功之后,加一行Invalidate();,强制对话框重绘。如果加了Invalidate还是不行,检查OnPaint里画棋子的循环是不是写错了行列顺序,比如把m_board[r][c]写成了m_board[c][r]。4.3 提子逻辑异常,棋子被误提现象:明明还有气的棋,程序却把它提掉了,或者该提的棋没提。原因:HasLiberty函数的visited数组没有在每次调用前清零,导致上一次搜索的标记影响了这一次。解决:在PlaceStone里调用HasLiberty之前,先把visited数组全部置为false。我一般会写一个辅助函数ClearVisited(),每次搜索前调一次,避免忘记。4.4 界面闪烁严重现象:每次落子之后,整个棋盘闪一下,体验很差。原因:OnPaint里直接画所有内容,没有使用双缓冲。GDI直接画到屏幕上,每次重绘都会先擦除背景再画,造成闪烁。解决:在OnPaint里加双缓冲。先创建一个内存DC和兼容位图,所有绘制操作先画到内存DC上,最后一次性BitBlt到屏幕DC。代码大概是这样:// GoGameDlg.cpp - 双缓冲绘制 void CGoGameDlg::OnPaint() { CPaintDC dc(this); CRect rect; GetClientRect(&rect); CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bitmap; bitmap.CreateCompatibleBitmap(&dc, rect.Width(), rect.Height()); memDC.SelectObject(&bitmap); // 所有绘制操作改成画到memDC // ... 画棋盘、画棋子 ... dc.BitBlt(0, 0, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); }4.5 打劫判断不生效现象:对方提子之后,自己立刻提回来,程序没有拦截。原因:IsKo函数的判断逻辑写反了,或者没有记录上一步的棋盘状态。解决:打劫判断需要记录上一步的棋盘状态,然后模拟当前落子,如果落子后的棋盘状态和上一步之前的状态相同,就是打劫。这个项目里用了一个简单的哈希比较,把棋盘状态转成一个字符串来对比。如果你要改,注意每次落子后都要更新“上一步状态”的记录。5. 二次开发与改造思路:从围棋到五子棋、AI对战、网络对战的扩展路径5.1 改成五子棋:改棋盘大小和胜负判定这个工程改成五子棋是最简单的改造路径。第一步,把Board.h里的BOARD_SIZE从19改成15。第二步,把CalculateScore函数删掉,换成五子棋的胜负判定:任意方向连续五子即获胜。// Board.cpp - 五子棋胜负判定 bool Board::CheckWin(int row, int col, StoneColor color) { int directions[4][2] = {{0,1},{1,0},{1,1},{1,-1}}; for (int d = 0; d < 4; d++) { int count = 1; // 正方向 for (int i = 1; i < 5; i++) { int r = row + directions[d][0] * i; int c = col + directions[d][1] * i; if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) break; if (m_board[r][c] != color) break; count++; } // 反方向 for (int i = 1; i < 5; i++) { int r = row - directions[d][0] * i; int c = col - directions[d][1] * i; if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) break; if (m_board[r][c] != color) break; count++; } if (count >= 5) return true; } return false; }这段代码从落子点出发,向四个方向分别统计连续同色棋子的数量,任意方向达到5个就判胜。参数说明:row和col是最后落子的位置,color是落子颜色。注意方向数组里{1,-1}表示右上到左下这个斜方向,正方向和反方向要分开统计。5.2 加入简单AI:用贪心算法实现电脑落子如果你想加一个电脑对手,最简单的做法是贪心算法:遍历所有空点,对每个点打分,选分数最高的位置落子。打分规则可以这样设计:棋型分数自己连五100000对方连五90000自己活四10000对方活四9000自己活三1000对方活三900这个打分表的意思是:优先自己赢,其次阻止对方赢,然后才是做自己的棋。实现的时候,对每个空点,分别计算如果自己下在这里能形成什么棋型,以及如果对方下在这里能形成什么棋型,取两者中较大的分数作为该点的得分。// AI.cpp - 贪心算法选点 CPoint AI::ChooseBestMove(Board& board, StoneColor aiColor) { StoneColor oppColor = (aiColor == BLACK) ? WHITE : BLACK; int bestScore = -1; CPoint bestMove(-1, -1); for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { if (board.GetStone(r, c) != EMPTY) continue; // 评估自己下这里的分数 int myScore = EvaluatePosition(board, r, c, aiColor); // 评估对方下这里的分数 int oppScore = EvaluatePosition(board, r, c, oppColor); int score = max(myScore, oppScore); if (score > bestScore) { bestScore = score; bestMove = CPoint(r, c); } } } return bestMove; }这个AI很弱,但作为练手足够了。如果你想做强一点,可以把贪心算法换成极小极大搜索加Alpha-Beta剪枝,但那需要额外处理搜索深度和评估函数的平衡,代码量会大很多。5.3 网络对战:用Winsock实现双机对战把双人对战改成网络对战,核心是把落子操作从本地鼠标点击改成网络消息。MFC里可以用CAsyncSocket类,这是MFC对Winsock的封装,用起来比原生Winsock简单。改造思路:一台机器做服务端,一台做客户端。服务端监听端口,客户端连接上来之后,双方轮流发送落子坐标。收到对方落子消息后,调用Board的PlaceStone,然后Invalidate重绘。// Network.cpp - 发送落子消息 void CGoGameDlg::SendMove(int row, int col) { if (m_pSocket == nullptr) return; char buffer[32]; sprintf_s(buffer, "%d,%d", row, col); m_pSocket->Send(buffer, strlen(buffer)); } // 接收落子消息 void CGoGameDlg::OnReceive(int nErrorCode) { char buffer[1024]; int len = m_pSocket->Receive(buffer, 1024); if (len > 0) { buffer[len] = '\0'; int row, col; sscanf_s(buffer, "%d,%d", &row, &col); m_board.PlaceStone(row, col, m_opponentColor); Invalidate(); } CAsyncSocket::OnReceive(nErrorCode); }网络对战要注意的是同步问题:双方都要维护同一个棋盘状态,任何一方落子后都要把坐标发给对方。如果网络延迟大,可能会出现双方同时落子导致状态不一致的情况,需要加一个简单的锁或者回合制控制。5.4 改造验证:怎么确认你的修改没把原逻辑改坏每次改完代码,不要只跑一遍看看能不能编译。我一般会做三个验证:第一,跑一局完整的双人对战,确认落子、提子、打劫、胜负判定都正常;第二,故意在已经有棋子的位置落子,确认程序会忽略;第三,故意制造一个自杀落子,确认程序会拒绝。这三个场景覆盖了核心逻辑的主要分支,跑通了基本就没大问题。从那以后我每次改这种棋类项目的核心逻辑,都会先把这三个场景走一遍,再去看界面效果。希望帮到你。本文还有配套的精品资源,点击获取
简介:这是一份基于VC++/MFC编写的围棋对弈程序源码,面向有一定C++基础、希望深入理解Windows图形界面与游戏逻辑的开发者,尤其适合想通过完整小项目巩固MFC框架的学习者。压缩包共24个文件,约36KB,包含7个h头文件与6个cpp源文件构成核心逻辑,另有3个bmp位图与2个ico图标负责界面素材,以及dsp、dsw工程文件和rc资源脚本,可直接用Visual Studio打开编译运行。程序支持双人对决,启动后可选择适配15、17寸液晶的窗口尺寸,落子响应迅速,交互流畅。目前已有171人学习关注。通过阅读源码,读者能掌握MFC文档视图结构、绘图刷新、鼠标消息处理与棋盘状态管理等关键实现,并借鉴作者在界面布局与资源组织上的思路,是入门Windows游戏编程与MFC实战的轻量参考案例。1. 双人对决的VC++围棋程序:一个能直接跑起来的MFC实战项目很多人学完VC++和MFC的语法之后,卡在“不知道拿什么练手”这一步。控件拖了、消息映射写了、对话框弹出来了,但真让自己从零搭一个能交互、有规则、能双人对战的完整程序,还是不知道从哪下手。这个双人对决的VC++围棋程序就是冲着这个缺口来的——它用MFC搭界面,用GDI画棋盘和棋子,把落子、提子、禁着点判断、胜负判定这些围棋核心逻辑全部用C++实现了一遍,没有依赖任何第三方棋类引擎。整个工程可以直接用Visual Studio打开编译,跑起来就是两个人轮流点鼠标下棋的完整程序。适合正在学MFC想找一个有实际逻辑的项目练手的人,也适合想拿一个现成的棋类框架改成五子棋、黑白棋的开发者。它不是一个玩具级的Demo,棋盘状态管理、气计算、打劫判断这些容易翻车的地方都做了处理,代码结构也留了扩展空间。2. 工程结构与核心模块:从棋盘数据到界面渲染的完整链路2.1 项目目录与文件职责划分拿到源码包之后,先别急着编译,花两分钟把目录结构看清楚,后面改代码的时候能省很多时间。这个工程是标准的MFC对话框应用结构,主要文件如下:文件职责GoGameDlg.h / .cpp主对话框类,负责界面初始化、鼠标消息响应、按钮事件Board.h / .cpp棋盘核心类,管理19x19棋盘状态、落子、提子、气计算Stone.h棋子数据结构,记录颜色和位置Resource.h / .rc资源定义,包括对话框布局、菜单、图标stdafx.h / .cpp预编译头,MFC标准包含核心逻辑全部集中在Board类里,界面部分在GoGameDlg里。这种分离方式的好处是,如果你想换一套UI,Board类可以原封不动地搬到别的框架里用。我一般拿到一个新项目也会先找这种“逻辑和界面分不分得开”的信号,分得越干净,二次开发越省事。2.2 棋盘数据结构:二维数组还是位棋盘打开Board.h,第一眼看到的是棋盘状态的存储方式。这个项目用的是最直观的二维数组:// Board.h class Board { public: enum StoneColor { EMPTY = 0, BLACK = 1, WHITE = 2 }; Board(); void Reset(); // 重置棋盘 bool PlaceStone(int row, int col, StoneColor color); // 落子 StoneColor GetStone(int row, int col) const; int GetCapturedCount(StoneColor color) const; private: static const int BOARD_SIZE = 19; StoneColor m_board[BOARD_SIZE][BOARD_SIZE]; // 棋盘状态 int m_capturedBlack; // 黑方被提子数 int m_capturedWhite; // 白方被提子数 bool HasLiberty(int row, int col, bool visited[][BOARD_SIZE]); // 判断某位置是否有气 void RemoveGroup(int row, int col); // 提掉整块无气的棋 bool IsKo(int row, int col, StoneColor color); // 打劫判断 };用二维数组的好处是直观,m_board[row][col]一眼就能看出是哪个位置。19x19一共361个点,每个点用一个枚举值表示,内存占用可以忽略不计。常见做法是用位棋盘来加速气计算,但这个项目规模小,二维数组加递归搜索完全够用,代码可读性反而更好。如果你后面想改成五子棋,把BOARD_SIZE改成15,再改一下胜负判定逻辑就行。2.3 落子与提子逻辑:气计算的递归实现围棋最核心的规则就是“气”——一块棋如果没有气,就要被提掉。这个项目用递归的方式计算气,代码在Board.cpp里:// Board.cpp bool Board::HasLiberty(int row, int col, bool visited[][BOARD_SIZE]) { // 边界检查 if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return false; if (visited[row][col]) return false; visited[row][col] = true; // 空位就是气 if (m_board[row][col] == EMPTY) return true; // 如果是同色棋子,继续向四个方向搜索 StoneColor color = m_board[row][col]; if (HasLiberty(row - 1, col, visited)) return true; if (HasLiberty(row + 1, col, visited)) return true; if (HasLiberty(row, col - 1, visited)) return true; if (HasLiberty(row, col + 1, visited)) return true; return false; }这段代码的逻辑是:从某个棋子出发,向上下左右四个方向递归搜索,如果碰到空位就说明这块棋还有气,返回true;如果搜完所有相连的同色棋子都没找到空位,说明这块棋已经被包围,需要提掉。visited数组用来防止重复访问,避免无限递归。参数说明:row和col是当前搜索的位置,visited是访问标记数组,每次调用PlaceStone之前需要先清零。注意这个递归的深度最大是棋盘上的棋子数,19x19最多361层,不会栈溢出。落子的时候,先判断该位置是否为空,然后临时放上棋子,再检查周围对方的棋是否有气,没气的提掉,最后检查自己这块棋是否有气,没气的回滚。这个顺序不能反,否则会出现“自杀”的情况。2.4 界面渲染:用GDI画棋盘和棋子界面部分在GoGameDlg.cpp的OnPaint函数里,用GDI的LineTo和Ellipse画棋盘线和棋子:// GoGameDlg.cpp void CGoGameDlg::OnPaint() { CPaintDC dc(this); // 画棋盘线 int margin = 30; int cellSize = 25; for (int i = 0; i < 19; i++) { // 横线 dc.MoveTo(margin, margin + i * cellSize); dc.LineTo(margin + 18 * cellSize, margin + i * cellSize); // 竖线 dc.MoveTo(margin + i * cellSize, margin); dc.LineTo(margin + i * cellSize, margin + 18 * cellSize); } // 画星位 int stars[][2] = {{3,3},{3,9},{3,15},{9,3},{9,9},{9,15},{15,3},{15,9},{15,15}}; for (int i = 0; i < 9; i++) { int x = margin + stars[i][0] * cellSize; int y = margin + stars[i][1] * cellSize; dc.Ellipse(x - 3, y - 3, x + 3, y + 3); } // 画棋子 for (int r = 0; r < 19; r++) { for (int c = 0; c < 19; c++) { StoneColor color = m_board.GetStone(r, c); if (color == EMPTY) continue; int x = margin + c * cellSize; int y = margin + r * cellSize; CBrush brush(color == BLACK ? RGB(0,0,0) : RGB(255,255,255)); CBrush* oldBrush = dc.SelectObject(&brush); dc.Ellipse(x - 10, y - 10, x + 10, y + 10); dc.SelectObject(oldBrush); } } }这段代码先画19条横线和19条竖线,再画9个星位,最后遍历棋盘数组把棋子画上去。margin是棋盘边缘留白,cellSize是每个格子的大小,这两个值决定了棋盘的视觉比例。如果你想调整棋盘大小,改这两个参数就行,但要注意鼠标点击的坐标换算也要同步改。鼠标点击的处理在OnLButtonDown里,把屏幕坐标转换成棋盘行列,然后调用Board的PlaceStone。坐标换算的公式是col = (point.x - margin + cellSize/2) / cellSize,加cellSize/2是为了让点击落在交叉点附近也能正确识别。3. 编译运行与双人对战流程:从打开工程到第一手棋3.1 环境准备与编译步骤这个工程是标准的MFC项目,用Visual Studio打开就能编译。我实测用的是VS2019,VS2017和VS2022也可以,但要注意MFC组件需要单独安装。如果你装VS的时候没勾选“使用C++的桌面开发”里的MFC选项,打开工程会报一堆“找不到afxwin.h”的错误。安装MFC组件的步骤:打开Visual Studio Installer,点“修改”,在“单个组件”里搜索“MFC”,勾选“适用于最新v143生成工具的C++ MFC”,然后点“修改”等安装完成。这一步不做的话,后面所有编译都是白费。编译的时候,打开GoGame.sln,选择Debug x86或者Release x86,直接按F7。如果一切正常,会在Debug或Release目录下生成GoGame.exe。双击就能运行。注意:如果编译报错“无法打开包括文件: afxwin.h”,说明MFC组件没装好,回到上面那一步重新检查。3.2 双人对战的操作流程程序跑起来之后,是一个对话框界面,中间是棋盘,两边显示黑白双方的提子数。操作流程很简单:黑方先手,鼠标点击棋盘交叉点落子如果落子位置已经有棋子,程序会忽略这次点击如果落子后对方有棋子被提,提子数会更新如果落子会导致自己无气(自杀),程序会拒绝这次落子如果触发打劫,程序会提示“打劫,请重新落子”双方轮流落子,直到一方认输或双方都选择停一手界面上有两个按钮:“认输”和“停一手”。认输就是直接结束游戏,对方获胜。停一手是围棋里的“Pass”,如果双方连续停一手,游戏结束,开始数子判定胜负。3.3 胜负判定与数子逻辑这个项目用的是中国规则数子法。游戏结束后,程序会遍历棋盘,把每个空点归属到最近的颜色,然后计算黑方和白方的总子数。黑方需要贴3又3/4子(相当于7.5目),所以黑方需要185子以上才能赢。数子的代码在Board.cpp的CalculateScore函数里:// Board.cpp int Board::CalculateScore(StoneColor color) { int score = 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] == color) { score++; // 自己的棋子算一子 } else if (m_board[r][c] == EMPTY && !visited[r][c]) { // 空点需要判断归属 StoneColor owner = GetAreaOwner(r, c, visited); if (owner == color) score++; } } } return score; }GetAreaOwner的逻辑是:从一个空点出发,向四周扩散,如果这个区域只接触到一种颜色的棋子,那么这个区域就归那种颜色。如果同时接触到黑白两色,就是“公气”,双方都不算。参数说明:color是要计算分数的颜色,visited是访问标记数组。这个函数的时间复杂度是O(n²),19x19的棋盘跑起来瞬间完成,不需要优化。4. 避坑与常见问题:编译报错、落子异常、界面闪烁的排查4.1 编译报错“无法打开包括文件: afxwin.h”现象:打开工程按F7,直接报错找不到afxwin.h,后面跟着一堆MFC相关的头文件都找不到。原因:Visual Studio安装的时候没有勾选MFC组件。MFC不是默认安装的,需要手动在Installer里勾选。解决:打开Visual Studio Installer,点“修改”,在“单个组件”里搜索“MFC”,勾选对应版本的MFC组件,点“修改”等安装完成。安装完之后重新打开工程,清理解决方案再重新编译。4.2 落子之后棋子不显示现象:鼠标点击棋盘,程序没崩溃,但棋盘上什么都没画出来。原因:OnPaint函数没有被触发。MFC的对话框默认不会在每次数据变化后自动重绘,需要在落子之后手动调用Invalidate()。解决:在PlaceStone调用成功之后,加一行Invalidate();,强制对话框重绘。如果加了Invalidate还是不行,检查OnPaint里画棋子的循环是不是写错了行列顺序,比如把m_board[r][c]写成了m_board[c][r]。4.3 提子逻辑异常,棋子被误提现象:明明还有气的棋,程序却把它提掉了,或者该提的棋没提。原因:HasLiberty函数的visited数组没有在每次调用前清零,导致上一次搜索的标记影响了这一次。解决:在PlaceStone里调用HasLiberty之前,先把visited数组全部置为false。我一般会写一个辅助函数ClearVisited(),每次搜索前调一次,避免忘记。4.4 界面闪烁严重现象:每次落子之后,整个棋盘闪一下,体验很差。原因:OnPaint里直接画所有内容,没有使用双缓冲。GDI直接画到屏幕上,每次重绘都会先擦除背景再画,造成闪烁。解决:在OnPaint里加双缓冲。先创建一个内存DC和兼容位图,所有绘制操作先画到内存DC上,最后一次性BitBlt到屏幕DC。代码大概是这样:// GoGameDlg.cpp - 双缓冲绘制 void CGoGameDlg::OnPaint() { CPaintDC dc(this); CRect rect; GetClientRect(&rect); CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bitmap; bitmap.CreateCompatibleBitmap(&dc, rect.Width(), rect.Height()); memDC.SelectObject(&bitmap); // 所有绘制操作改成画到memDC // ... 画棋盘、画棋子 ... dc.BitBlt(0, 0, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); }4.5 打劫判断不生效现象:对方提子之后,自己立刻提回来,程序没有拦截。原因:IsKo函数的判断逻辑写反了,或者没有记录上一步的棋盘状态。解决:打劫判断需要记录上一步的棋盘状态,然后模拟当前落子,如果落子后的棋盘状态和上一步之前的状态相同,就是打劫。这个项目里用了一个简单的哈希比较,把棋盘状态转成一个字符串来对比。如果你要改,注意每次落子后都要更新“上一步状态”的记录。5. 二次开发与改造思路:从围棋到五子棋、AI对战、网络对战的扩展路径5.1 改成五子棋:改棋盘大小和胜负判定这个工程改成五子棋是最简单的改造路径。第一步,把Board.h里的BOARD_SIZE从19改成15。第二步,把CalculateScore函数删掉,换成五子棋的胜负判定:任意方向连续五子即获胜。// Board.cpp - 五子棋胜负判定 bool Board::CheckWin(int row, int col, StoneColor color) { int directions[4][2] = {{0,1},{1,0},{1,1},{1,-1}}; for (int d = 0; d < 4; d++) { int count = 1; // 正方向 for (int i = 1; i < 5; i++) { int r = row + directions[d][0] * i; int c = col + directions[d][1] * i; if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) break; if (m_board[r][c] != color) break; count++; } // 反方向 for (int i = 1; i < 5; i++) { int r = row - directions[d][0] * i; int c = col - directions[d][1] * i; if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) break; if (m_board[r][c] != color) break; count++; } if (count >= 5) return true; } return false; }这段代码从落子点出发,向四个方向分别统计连续同色棋子的数量,任意方向达到5个就判胜。参数说明:row和col是最后落子的位置,color是落子颜色。注意方向数组里{1,-1}表示右上到左下这个斜方向,正方向和反方向要分开统计。5.2 加入简单AI:用贪心算法实现电脑落子如果你想加一个电脑对手,最简单的做法是贪心算法:遍历所有空点,对每个点打分,选分数最高的位置落子。打分规则可以这样设计:棋型分数自己连五100000对方连五90000自己活四10000对方活四9000自己活三1000对方活三900这个打分表的意思是:优先自己赢,其次阻止对方赢,然后才是做自己的棋。实现的时候,对每个空点,分别计算如果自己下在这里能形成什么棋型,以及如果对方下在这里能形成什么棋型,取两者中较大的分数作为该点的得分。// AI.cpp - 贪心算法选点 CPoint AI::ChooseBestMove(Board& board, StoneColor aiColor) { StoneColor oppColor = (aiColor == BLACK) ? WHITE : BLACK; int bestScore = -1; CPoint bestMove(-1, -1); for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { if (board.GetStone(r, c) != EMPTY) continue; // 评估自己下这里的分数 int myScore = EvaluatePosition(board, r, c, aiColor); // 评估对方下这里的分数 int oppScore = EvaluatePosition(board, r, c, oppColor); int score = max(myScore, oppScore); if (score > bestScore) { bestScore = score; bestMove = CPoint(r, c); } } } return bestMove; }这个AI很弱,但作为练手足够了。如果你想做强一点,可以把贪心算法换成极小极大搜索加Alpha-Beta剪枝,但那需要额外处理搜索深度和评估函数的平衡,代码量会大很多。5.3 网络对战:用Winsock实现双机对战把双人对战改成网络对战,核心是把落子操作从本地鼠标点击改成网络消息。MFC里可以用CAsyncSocket类,这是MFC对Winsock的封装,用起来比原生Winsock简单。改造思路:一台机器做服务端,一台做客户端。服务端监听端口,客户端连接上来之后,双方轮流发送落子坐标。收到对方落子消息后,调用Board的PlaceStone,然后Invalidate重绘。// Network.cpp - 发送落子消息 void CGoGameDlg::SendMove(int row, int col) { if (m_pSocket == nullptr) return; char buffer[32]; sprintf_s(buffer, "%d,%d", row, col); m_pSocket->Send(buffer, strlen(buffer)); } // 接收落子消息 void CGoGameDlg::OnReceive(int nErrorCode) { char buffer[1024]; int len = m_pSocket->Receive(buffer, 1024); if (len > 0) { buffer[len] = '\0'; int row, col; sscanf_s(buffer, "%d,%d", &row, &col); m_board.PlaceStone(row, col, m_opponentColor); Invalidate(); } CAsyncSocket::OnReceive(nErrorCode); }网络对战要注意的是同步问题:双方都要维护同一个棋盘状态,任何一方落子后都要把坐标发给对方。如果网络延迟大,可能会出现双方同时落子导致状态不一致的情况,需要加一个简单的锁或者回合制控制。5.4 改造验证:怎么确认你的修改没把原逻辑改坏每次改完代码,不要只跑一遍看看能不能编译。我一般会做三个验证:第一,跑一局完整的双人对战,确认落子、提子、打劫、胜负判定都正常;第二,故意在已经有棋子的位置落子,确认程序会忽略;第三,故意制造一个自杀落子,确认程序会拒绝。这三个场景覆盖了核心逻辑的主要分支,跑通了基本就没大问题。从那以后我每次改这种棋类项目的核心逻辑,都会先把这三个场景走一遍,再去看界面效果。希望帮到你。本文还有配套的精品资源,点击获取