
简介基于VS2010与MFC编写的VC围棋对弈源码包面向C初学者、课程设计及小型游戏开发爱好者可作为理解棋盘类游戏设计、学习MFC界面编程与GDI绘图的参考。资源共33个文件压缩包仅164KB其中10个头文件与8个cpp源文件构成主要代码按模块实现棋盘二维数组建模、落子合法性检测、胜负判断以及玩家交互逻辑4个bmp位图与2个ico图标提供棋盘棋子等界面素材2个txt文档包含使用说明与待解决问题其余sln、vcxproj、rc等工程文件则便于在VS2010中直接打开编译与调试当前已有98人学习/下载。通过阅读源码可掌握面向对象模块划分、棋盘状态管理、控制台与界面交互处理等实用技能并了解棋盘绘制与坐标转换的具体实现在此基础上也可自行尝试引入AI搜索思路实现人机对战或增加网络对弈功能。整体结构紧凑、代码量适中适合作为图形界面编程与棋类算法入门的练手项目。1. 一套 MFC 围棋源码先看清它到底值不值得打开拿到一份 VS2010 环境下用 VC 写的围棋游戏源码包里面躺着 MyFirstMFC、CGO、AssiDlg、CCoordinateView、BlackPiece.bmp 这些文件第一反应别急着点开工程就编译。这套代码的看点不在围棋规则本身写得有多深而在于它把「MFC 单文档框架 坐标换算 位图贴棋盘」这条老路线完整走了一遍正好是很多教材和培训课收尾时最爱布置的综合作业形态。适合谁看想补 Win32/MFC 实战经验的入门者、接手老工程需要快速读懂文档视图结构的在职开发者以及手里攥着类似作业代码想梳理清楚的同学。我拆完这套工程后一个很直观的感受MFC 写围棋棋盘真正的拦路虎从来不是落子那张 if 判断而是画棋盘时的坐标换算和贴图句柄的生命周期管理。2. 工程骨架MyFirstMFC 的文档视图结构怎么组织的2.1 从 vcxproj 和 sln 反推项目形态打开文件列表MyFirstMFC.vcxproj、MyFirstMFC.vcxproj.filters、MyFirstMFC.sln 三个文件决定了这是一个完整的 Visual Studio 工程不是散装代码。vcxproj 意味着使用 MSBuild 构建系统.filters 文件只是 IDE 里虚拟目录的映射关系不影响编译结果真正决定输出类型的是 vcxproj 里ConfigurationType标签这个工程属于 MFC 应用所以对应的是 Application 类型链接时会自动带上 MFC 静态库或共享 DLL。### 2.2 文档类、视图类、对话框类各自管什么 围棋游戏这类交互程序用 MFC 的 Document/View 架构来拆会很顺手。文档类是 MyFirstMFCDoc负责维护棋盘数据的模型层一个二维数组就能存完黑白棋状态视图类是 MyFirstMFCView 和 CCoordinateView前者负责把文档里的棋盘数据画到屏幕上后者显然是为了带坐标标注的棋盘视图而存在的。再看 CCoordinateListView从名字推断它处理的是坐标列表很可能在界面侧边栏或某个面板里列出当前落子坐标序列。 框架里还有几个容易被忽略但很关键的配角MainFrm.cpp 定义主框架窗口负责承载菜单、工具栏和视图窗口的布局AssiDlg.cpp 是一个辅助对话框典型用途是显示对局信息、比分或者设置参数CGO.cpp 和 CGo.h 放在源码根目录正是核心的围棋规则引擎——落子合法性判断、提子、死活简化处理都应该在这一对文件里。其他像 stdafx.h、targetver.h 是 MFC 工程的公共预编译头和版本宏MyFirstMFC.rc 和 resource.h 管着菜单、对话框、位图这些资源的 ID 映射。 ### 2.3 一个典型的 MFC 围棋程序启动顺序 用 MFC 向导生成的工程入口函数由框架提供真正干活的是应用类的 InitInstance 方法。整个启动流程是这样的 1. 进入 CMyFirstMFCApp::InitInstance初始化 MFC 内核和 AfxOleInit 之类的环境 2. 用 CSingleDocTemplate 把文档类、视图类、主框架类绑定在一起 3. 打开文档。如果命令行没有指定文件就新建一个空白文档对应围棋开局的空棋盘 4. 视图类接管窗口WM_PAINT 触发时执行 OnDraw把 19 路或 13 路棋盘画出来 理解这份启动顺序再去定位代码就有的放矢了。搜索棋盘初始化或清空棋盘逻辑时去 MyFirstMFCDoc 的构造函数和 OnNewDocument 里找看棋盘绘制去 MyFirstMFCView::OnDraw处理鼠标落子去视图类的 OnLButtonDown。老 MFC 工程一个麻烦是类和文件不一定同名像 CCoordinateView 就单独放在 CCoordinateView.cpp 里和主视图类分开。用 Visual Studio 的转到定义功能逐层跳转比全局搜索文件名更高效。 ### 2.4 待解决问题.txt 的正确读法 文件列表里有一份《待解决问题.txt》这是别人留下的开发笔记也是拆项目时最有价值的入口。老手拆项目有个习惯先找 TODO、FIXME 和待解决问题清单能直接看出作者在哪里卡壳。这份文档大概率记录了作者遇到的若干问题比如贴图闪烁、坐标不准、AI 没有落子优先级、或者吃子判断漏了某种边界情况。拿着这份清单对照代码一方面能看到作者的思考残留另一方面也标出了这套代码的薄弱环节。以我拆类似 MFC 业作的经验这类清单最后几项往往是后续版本才修而未修恰恰是你可以接手做改进的切入口。 ## 3. 棋盘绘制与坐标换算决定画面是否正常的核心逻辑 ### 3.1 棋盘和棋子的位图资源方案 从文件列表里的 BlackPiece.bmp 和 UserImages.bmp 判断这套代码没有用 GDI 画圆这种矢量方案而是选定贴位图方案。位图方案的好处是棋子边缘可以直接放预渲染的渐变光和阴影视觉观感远好于代码画的同心圆但代价是资源和代码耦合度变高坐标算偏移一格整张棋盘就歪了。 先建立一个基本认知屏幕上每一次 WM_PAINT视图类都要把棋盘和当前棋局完整刷一遍。常见做法是先在内存中创建兼容位图画好棋盘和所有棋子后一次 BitBlt 到窗口上避开每画一个棋子就刷新一次的闪烁问题。代码结构大致长这样 cpp void CMyFirstMFCView::OnDraw(CDC* pDC) { // 先拿到文档中的棋盘数据 CMyFirstMFCDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; // 创建内存 DC用于离屏绘制避免画面闪烁 CDC memDC; memDC.CreateCompatibleDC(pDC); // 取客户区尺寸并创建对应大小的兼容位图 CRect rect; GetClientRect(rect); CBitmap bmpMem; bmpMem.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBmp memDC.SelectObject(bmpMem); // 第一步绘制棋盘木纹底色 DrawBoardBackground(memDC); // 第二步绘制网格线 DrawGridLines(memDC); // 第三步按落子数据循环绘制黑棋和白棋位图 DrawAllPieces(pDC, pDoc); // 一次整块拷贝到窗口杜绝局部刷新闪烁 pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); // 恢复并清理 GDI 对象 memDC.SelectObject(pOldBmp); bmpMem.DeleteObject(); }逻辑说明OnDraw 是 MFC 视图重绘的唯一入口窗口遮挡、尺寸变化、程序启动都会触发它。这里用两段式绘制——先画到内存 DC再整体拷贝到屏幕是 GDI 程序处理闪烁的经典套路代价是每帧多一次 BitBlt 的开销但对围棋这种低帧率画面完全够用。参数说明CreateCompatibleDC 需要传入目标 DC 来确定颜色格式CreateCompatibleBitmap 的宽高必须和客户区一致否则贴出来会裁切。SelectObject 返回旧位图指针绘制结束后必须恢复否则位图句柄泄漏程序跑久了会出现画面绘制异常甚至 GDI 对象耗尽。3.2 交叉点坐标与像素坐标的准确换算这是整套代码里最容易翻车、也最值得琢磨的地方。棋盘是 19 路时交叉点是 19×19画布上如果留出边距 margin每个格子边长是 cellSize那么交叉点 (row, col) 的像素坐标就是x margin col * cellSize y margin row * cellSize反过来鼠标点击位置换算成交叉点索引要做的就是把客户区坐标减掉 margin再除以 cellSize然后四舍五入取最近的交叉点// 鼠标点下的客户区坐标 CPoint point; // 棋盘边缘留白和格子边长按实际绘制参数设置 int margin 30; int cellSize 30; // 换算到最近交叉点索引 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; }逻辑说明先判断是否在棋盘范围内再做索引换算最后才能去查数组和更新棋盘状态。加 cellSize / 2 再取整是一种常见的rounding手法——鼠标点在格子中央区域时它归到更近那条线员工点得更准。参数说明margin 和 cellSize 必须和 OnDraw 里的绘制参数一致出现棋子和鼠标点错位时首先查这两个值。这套代码里 BlackPiece.bmp 位图尺寸通常略大于格子贴图时还需要把位图左上角偏移半个格子来居中// 在交叉点 (row, col) 处绘制棋子位图居中 int pieceSize 28; // 位图像素尺寸 int x margin col * cellSize - pieceSize / 2; int y margin row * cellSize - pieceSize / 2;这个偏移很容易被忽略。有人直接把位图原样贴到交叉点上结果所有棋子看起来都往右下角偏了半格那种哪里不对劲但说不上来的画面感很大概率就来自这里。3.3 视图坐标与客户区坐标的差异处理在 MFC 里你拿到的鼠标坐标分两种OnLButtonDown 里 point 是客户区坐标而 OnMouseMove 或对话框控件传递的坐标可能是屏幕坐标或父窗口坐标。许多老工程在 OnLButtonDown 里用 ClientToScreen 多转了一次结果落子判断完全错乱。正确做法是统一在客户区坐标系里做判断。控件类里如果用到屏幕坐标用 ScreenToClient 转回来再计算。还有一个容易被忽略的坑窗口有工具栏和状态栏时视图的客户区原点不在主窗口左上角MainFrm 的 OnCreate 里创建工具栏后视图区域自然避让了这些空间所以视图里的鼠标坐标只需在视图坐标系内处理不要试图手动减去工具栏高度。套代码里 CCoordinateView 和 CCoordinateListView 的存在说明作者有坐标对齐这块的设计意图CCoordinateView 承担棋盘绘制和坐标轴标签通常是最左边和最上边的数字或字母标记CCoordinateListView 则可能是把最近几次落子以坐标列表形式展示在旁边辅助对局者观察。这两者的联动走的是 MFC 的 UpdateAllViews 机制// 文档数据变更后通知所有视图刷新 GetDocument()-UpdateAllViews(this); // 在坐标列表视图中重写 OnUpdate 来同步刷新坐标显示 void CCoordinateListView::OnUpdate(CView* pSender, LPARAM lHint, CObject* pHint) { // 从文档重新读取最近落子序列 // 然后刷新列表控件内容 }逻辑说明MFC 的文档视图模式下数据修改方只管调用 UpdateAllViews所有注册过的视图都会收到 OnUpdate 回调谁的数据依赖变了就自己重画。这样围棋棋盘视图和坐标列表视图能各自刷新不会互相覆盖。参数说明lHint 和 pHint 可以传递额外的增量信息比如只标记第几步发生了变化视图据此只刷新对应局部区域降低无效绘制。4. 落子交互与吃子判定从鼠标点击到棋盘状态更新4.1 鼠标按下到落子完成的完整链路棋盘数据真正变更的始发点是视图的鼠标事件。整个链路是鼠标按下 → 坐标换算 → 调用文档的落子接口 → 文档更新二维数组和落子记录 → 文档调用 UpdateAllViews → 所有视图重绘。这样一条流程走完才是一次干净的交互。void CMyFirstMFCView::OnLButtonDown(UINT nFlags, CPoint point) { // 换算得到交叉点索引 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; } // 文档负责仲裁这一步是否合法 CMyFirstMFCDoc* pDoc GetDocument(); if (pDoc-IsLegalMove(row, col)) { pDoc-PlacePiece(row, col); } CView::OnLButtonDown(nFlags, point); }逻辑说明视图只负责把物理坐标翻译成语义坐标规则判断完全交给文档层这样后续增加 AI 或网络对战只需替换文档内部的策略不必动界面代码。IsLegalMove 检查包括这交叉点是空的、不是禁着点、落了之后自己还有气。参数说明nFlags 可以判断是否按住 Ctrl 之类有些围棋程序会用它做强制落子或标记功能但这套代码大概率没有没必要强行扩展。4.2 气的计算与提子扫描围棋规则落到代码上核心是对四大块空白区域的持续维护棋串连在一起的同色棋子、气棋串相邻的空点、眼位和禁着点。从这套工程的体量看它很可能只实现了部分规则比如简单的提子和禁着检查活棋判断或完整终局判断可能留待后续。气的计算用并查集或泛洪填充都能实现。学生作业最常见的是扫描法落子后先找出它属于哪个棋串统计这个棋串的外部相邻空点数量。如果气为 0就提掉整个棋串。// 用深度优先搜索收集同色连通块并计算气 void CGO::CollectGroup(int row, int col, int color, std::vectorCPoint group, int liberties, bool visited[19][19]) { // 边界检查超出棋盘范围直接结束 if (row 0 || row 19 || col 0 || col 19) return; if (visited[row][col]) return; int cur m_board[row][col]; // 空点算一口气但不继续扩散 if (cur EMPTY) { liberties; return; } // 异色棋子是这块区域的边界不算气也不扩散 if (cur ! color) return; // 同色棋子标记访问并继续向四周扩散 visited[row][col] true; group.push_back(CPoint(row, col)); CollectGroup(row - 1, col, color, group, liberties, visited); CollectGroup(row 1, col, color, group, liberties, visited); CollectGroup(row, col - 1, color, group, liberties, visited); CollectGroup(row, col 1, color, group, liberties, visited); }逻辑说明递归搜索以当前落子点为起点收集整个同色连通块遇到空点累加一口气遇到异色或边界就停止递归。函数结束连通块的气数和全部棋子坐标都就绪了调用方据此决定是否提子。参数说明visited 数组每个落子操作前要清空一次长度 19×19 对应棋盘交叉点最大规模如果改成 13 路或 9 路要把所有相关常量同步修改。递归深度最多不会超过棋盘格子总数不用担心爆栈。吃子检查的完整步骤通常是落一颗黑棋 → 先检查它所在的同色连通块的气 → 如果气为 0吃掉整块黑棋 → 如果没被吃再检查四周相邻的异色棋串看它们是否气尽 → 有的实现还会做劫的判断但学生作业能走到这一步已经算完整了。打印这些判断为排查留证据的价值很高——你很难在数组里直接看到气到底算对没有但打印出来一目了然// 调试辅助打印当前棋盘状态0空 1黑 2白 void CGO::DebugPrintBoard() { for (int i 0; i 19; i) { CString line; for (int j 0; j 19; j) { TCHAR ch _T(.); if (m_board[i][j] 1) ch _T(X); else if (m_board[i][j] 2) ch _T(O); line ch; } OutputDebugString(line _T(\n)); } }在落子函数入口和提子函数出口各调用一次把输出贴到文本文件里对拍基本能定位绝大多数吃子规则问题。4.3 落子记录与悔棋的实现路径文件列表里 CCoordinateListView 暗示作者维护了落子历史。有了历史记录悔棋就只是弹栈一个操作。常见做法是把整盘棋的每一步都存进一个动态数组每步包含棋子颜色和坐标悔棋时pop出最后一步并往棋盘数组写回空格。// 悔棋操作撤销最近一步 bool CGO::UndoLastMove() { if (m_moveHistory.empty()) return false; MoveRecord lastMove m_moveHistory.back(); m_moveHistory.pop_back(); // 恢复棋盘状态该位置清空 m_board[lastMove.row][lastMove.col] EMPTY; // 如果实现了提子记录还需要把被提的棋子恢复回去这一步很容易遗漏 // 推荐在 MoveRecord 里保存被提掉的棋子坐标列表 return true; }逻辑说明悔棋功能的难点不在弹栈而在恢复被提掉的棋子。如果 MoveRecord 只记了当前步的落子坐标被提走的对方棋子就永远回不来了悔棋下几步之后棋盘数据和实际步数完全对不上。参数说明MoveRecord 的数据结构建议包含 color、row、col、capturedPiecesvector 四个字段其中 capturedPieces 记录这一步提掉了哪些棋子悔棋时一并恢复。这套源码如果没做这块加上的改动成本不高正好可以做二次开发的起点。5. 避坑记录VS2010 老工程翻新时最容易踩的五个坑5.1 工程打开即报错MFC 头文件找不到现象用 VS2017 及以上版本打开 MyFirstMFC.sln编译直接报fatal error C1083: 无法打开包括文件: afxwin.h: No such file or directory。原因高版本 Visual Studio 默认不安装「适用于最新 v142 生成工具的 MFC」组件老工程的 MFC 头文件不在默认包含路径里。解决进入 Visual Studio Installer → 修改 → 单个组件 → 勾选「适用于最新 v142 生成工具的 C MFC (x86 和 x64)」并安装需要管理员权限装完重启 IDE 再编译即可。5.2 编码错乱中文注释和字符串全部乱码现象打开 AssiDlg.cpp 或 CGO.cpp里面的中文注释完全乱掉编译时还可能出现莫名其妙的字符串常量错误。原因VS2010 的源文件默认保存为 GB2312/GBK高版本 IDE 读取时默认按 UTF-8 解析编码不对注释和字符串就全乱了。解决推荐直接用 VS 的「文件 → 高级保存选项 → 保存为 Unicode (UTF-8 带签名) - 代码页 65001」逐文件转存。换编码这个操作看着琐碎但偷懒不做的结果就是代码永远读不顺。5.3 位图资源失效BlackPiece.bmp 显示为黑色方块现象棋盘和网格线都正常但棋子画出来是一块黑底或没有棋子。原因位图里的透明色没有被处理。BlackPiece.bmp 如果是 24 位或 32 位 BMP需要手动处理透明通道老代码如果用的是黑色作为 mask 色在 GDI 的 TransparentBlt 或掩码位图技术中会直接把黑棋连轮廓一起抠掉。解决检查 OnDraw 里的贴图调用先用 TransparentBlt 指定掩码色再画或者改用预乘 Alpha 的 PNG 资源。MFC 本身对 PNG 支持不好常见做法是用 CImage 载入 PNG 再转换成 DIB 段和 Alpha 通道来绘制。5.4 棋盘闪烁严重每走一步整个窗口白屏再恢复现象落子或拖动窗口时棋盘频繁闪烁观感极差。原因OnDraw 里逐行逐列画网格和棋子没有做离屏缓冲每次窗口重绘都直接操作屏幕 DC大量 GDI 调用导致重绘跟不上。解决上一章写的双缓冲是标配——先在内存位图里画完 19 条横线、19 条竖线和全部棋子最后一次性 BitBlt 上屏。如果绘制过程还包括贴位图的话内存 DC 里先 SelectObject 也是一次必要的准备。5.5 坐标偏移鼠标点到线上棋子却落在格子里现象点第 3 行第 3 列的交叉点棋子经常落在第 4 行第 4 列越往中间偏移越明显。原因OnDraw 画网格时用的 margin 和 cellSize 与 OnLButtonDown 里换算用的是两组不同的常量或者位图贴图偏移量忘减了。解决把 margin、cellSize、pieceSize 统一抽成视图类的成员变量并集中初始化禁止在绘制函数里随手写死数字。老工程里这种两处常量不一致的问题非常常见光靠肉眼很难看出来——这两组参数一旦不一致落子验证就会出现系统性偏移。6. 把棋盘跑起来之后加一个悔棋和历史列表算有用的二次开发代码能编译、能落子、能提子这是第一关。但我一直觉得围棋类 MFC 小项目做二次开发最有价值的是「重放对局」能力落子历史已经通过 CCoordinateListView 记录下来了把记录导成 SGF 或简单的文本复盘是顺手的事。回放状态的推进逻辑用消息定时器或按钮翻页都行。真正要落实的步骤是在文档类里加一个 replayIndex 指针落子时把坐标 push 进历史数组复盘时按 replayIndex 逐步恢复棋盘状态同时高亮当前步所在交叉点。MFC 里辅助对话框加一个「下一步」按钮正好配 AssiDlg点击后文档更新一个落子索引并触发 UpdateAllViews两边视图同步刷新。做法不复杂但让这套围棋代码从「能下」变成一个「能讲解棋谱」的小工具档次完全不同。做法上我习惯在调试阶段就把 DebugPrintBoard 的输出和 CCoordinateListView 的坐标列表对拍之后再叠加 Replay 功能——先有可靠的数据再做数据回放功能不用把时间花在陪黑匣子捉迷藏上。拿来跑通旧工程、读懂文档视图架构这套设计它的价值是值得的。但从我自己的血泪经验说拿到任何一份 MFC 老代码第一步永远是把 Debug 输出和落子打印打通再谈改动功能。落子判断对不对、提子逻辑够不够严谨单靠鼠标点几盘是验证不出来的把关键节点打印到 Output 窗口逐条核对比任何直觉都靠谱。从那以后我每次拿到类似的 MFC 小游戏代码都会强制先走一遍编译 → 打通调试输出 → 翻读待解决问题清单 → 再动功能开发。这套流程对得起调试的头发希望帮到你。本文还有配套的精品资源点击获取