资讯动态

VC++ MFC围棋程序实战:双人对战与核心逻辑实现

发布时间:2026/10/21 2:43:58 来源:尧图企业网站定制
简介这是一份基于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里。这种分离方式的好处是如果你想换一套UIBoard类可以原封不动地搬到别的框架里用。我一般拿到一个新项目也会先找这种“逻辑和界面分不分得开”的信号分得越干净二次开发越省事。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打开就能编译。我实测用的是VS2019VS2017和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 改造验证怎么确认你的修改没把原逻辑改坏每次改完代码不要只跑一遍看看能不能编译。我一般会做三个验证第一跑一局完整的双人对战确认落子、提子、打劫、胜负判定都正常第二故意在已经有棋子的位置落子确认程序会忽略第三故意制造一个自杀落子确认程序会拒绝。这三个场景覆盖了核心逻辑的主要分支跑通了基本就没大问题。从那以后我每次改这种棋类项目的核心逻辑都会先把这三个场景走一遍再去看界面效果。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑