
简介一套在VS2010环境下使用VCMFC编写的围棋游戏完整工程面向C入门者和棋类游戏开发爱好者帮助理解从窗口界面到棋盘数据结构、玩家交互与对弈规则的落地过程。压缩包共33个文件主体为10个.h头文件和8个.cpp源文件此外还有bmp/ico界面资源、rc资源描述、sln/proj工程文件及说明文档整体仅164KB适合快速下载并直接打开工程研读。代码将棋盘、棋子、玩家等拆分为独立模块棋盘类负责状态管理棋子类处理落点与颜色玩家类接收行列输入实现落子合法性判断与五子连珠胜负检测并给出基于深度优先搜索的简易AI构想压缩包内还附有待解决问题清单便于学习者沿着作者的思考脉络进行二次开发或排错。已有98人学习特别适合想在VS2010/MFC环境中快速上手围棋或同类棋类项目的人群。1. 拿到「VS2010环境编写的VC围棋游戏.rar」先判断这份代码值不值得花时间VS2010环境编写的VC围棋游戏这个压缩包打开后最怕两种结局双击 .sln 报版本不兼容或编译通过却一启动就闪退。这类项目十有八九是 VC 写的 MFC 对话框程序棋盘画在自绘控件上落子走消息响应代码量在 1000 到 3000 行之间正好卡在「能读懂」和「能改成自己的」的分界线上。把它跑通能换到什么一份可断点调试的围棋规则实现——气、提子、劫、禁着点都是独立函数一个用 GDI 做 2D 绘图的现成范本——画 19 路棋盘、把鼠标点击换算成格点还有一个能直接替换评分函数的 AI 壳。这篇文章写给三类人学 VC/MFC 但缺项目练手的学生有围棋算法却卡在绘图环节的开发者以及打开别人工程却编译不过、需要快速定位问题的从业者。下面按「认骨架 → 写规则 → 画界面 → 排坑 → 加 AI」的路径拆开重建每步都给能直接复制的代码和参数。2. 从 rar 到可编译工程认文件、定数据结构、理清消息路由2.1 先认文件从 .sln、.vcxproj 和 .rc 判断项目类型解压后不要急着双击任何文件先在命令行里扫一眼顶层结构比任何教程都直观find . -maxdepth 2 -type f | sort # 或直接列出 rar 内的文件清单 unrar l VS2010环境编写的VC围棋游戏.rar | head -30看到 .sln 和 .vcxproj 同时存在说明这是 VS2010 原生的解决方案结构。如果只看到 .dsw / .dsp那是更老的 VC6 工程VS2010 也能打开但会做一次格式转换。接着看有没有 .rc 资源文件和 resource.h——有这两样基本可以锁定它是 MFC 工程而不是纯 Win32 或控制台程序。MFC 工程的入口是一个继承 CWinApp 的 App 类主界面继承 CDialogEx后面所有消息处理和绘图逻辑都绕着这两个类转。如果你还在找 vs2010 的下载安装教程装完务必先打 VS2010 SP1 补丁再开工程。SP1 修掉的编译器 bug 里有几条正卡在这种老项目编译路径上裸装 VS2010 经常会碰到标准库头文件报错。打开工程后注意左侧的类视图——老资料里常把它叫 VC 管理器——几个核心类一眼就能看到继承关系比逐个翻文件快得多。2.2 棋盘数据结构带 padding 的二维数组是默认答案围棋棋盘 19×19最省事的表示就是二维数组。常见做法是开 21×21外圈留一圈 padding这是这类 VC 项目事实上的标准写法// GameDef.h —— 棋盘数据结构的统一约定 #define BOARD_LEN 19 // 19 路棋盘 #define PAD 1 // 外圈 padding #define MAX_BOARD (BOARD_LEN 2 * PAD) // 21 enum Stone { EMPTY 0, BLACK 1, WHITE 2 }; // 有效格点范围是 [1][1] ~ [19][19] // board[0][*]、board[20][*]、board[*][0]、board[*][20] 恒为边界 int board[MAX_BOARD][MAX_BOARD];逻辑说明padding 里的值永远不是 EMPTY数气、提子时扫描到 padding 就自然停止代码里一个越界判断都不用写。这是把 19 改成 21 的唯一理由一定要在注释里写清楚否则后来的人会把循环写成从 0 到 18一个经典翻车点。选型上也可以用一维数组 int board[361]下标按 row * 19 col 换算提子和坐标换算会啰嗦一点用 std::vector 嵌套也常见但 memset 归零、memcmp 比对快照都不如 C 数组干净。VS2010 对 C0x 支持不全但 STL 老容器完全可用我一般棋盘用 C 数组悔棋栈和历史记录用 vector各取所长。2.3 消息路由WM_LBUTTONDOWN 到落子、WM_PAINT 到重绘MFC 程序的消息分发靠消息映射表我们这个围棋游戏只关心两条鼠标按下负责落子WM_PAINT 负责画棋盘。映射表的写法如下// GoDlg.cpp —— 消息映射与两个核心响应 BEGIN_MESSAGE_MAP(CGoDlg, CDialogEx) ON_WM_LBUTTONDOWN() ON_WM_PAINT() ON_COMMAND(IDC_BTN_UNDO, CGoDlg::OnBtnUndo) // 悔棋按钮 END_MESSAGE_MAP() void CGoDlg::OnLButtonDown(UINT nFlags, CPoint point) { // 像素坐标 - 格点坐标MARGIN 是棋盘边距CELL_SIZE 是格距 int gx (point.x - MARGIN) / CELL_SIZE 1; int gy (point.y - MARGIN) / CELL_SIZE 1; if (gx 1 gx BOARD_LEN gy 1 gy BOARD_LEN) { if (TryPlaceStone(gy, gx, m_curColor)) // 规则入口 SwitchTurn(); // 换手 } Invalidate(FALSE); // 只标记客户区失效不擦背景 }逻辑说明ON_WM_LBUTTONDOWN 宏把鼠标消息路由到 OnLButtonDown函数里先做坐标换算再调规则入口合法性由 TryPlaceStone 决定合法才换手。Invalidate(FALSE) 触发一次异步重绘FALSE 表示不擦背景配合双缓冲后拉伸窗口也不闪。这里坐标参数顺序行、列全工程必须一致混用是后期规则出现魔幻行为的头号嫌疑。参数说明MARGIN 和 CELL_SIZE 是坐标换算的两个基准值。按 19 路棋盘、28 像素格距估算客户区宽度至少 24×2 18×28 552 像素对话框模板里的宽高用这个公式算别随手拉一个 300 宽的窗口让棋盘被裁掉。3. 落子、提子与劫把围棋规则完整写进 200 行 VC 代码3.1 数气Flood Fill 收集连通块再扫四邻空点气是围棋规则的地基。落子合法性、提子、终局判断全依赖数气函数这个函数写错后面全是空中楼阁。标准做法是 Flood Fill——从目标棋子出发向上下左右递归扩散收集所有同色连通的棋子再统计这些棋子相邻的独立空点数量// Rule.cpp —— 数气先收集同色连通块再统计独立气口 static bool visited[MAX_BOARD][MAX_BOARD]; static int group[MAX_BOARD * MAX_BOARD][2]; // 连通块内棋子坐标 static void CollectGroup(int r, int c, int color, int cnt) { if (board[r][c] ! color || visited[r][c]) return; visited[r][c] true; group[cnt][0] r; group[cnt][1] c; cnt; CollectGroup(r - 1, c, color, cnt); CollectGroup(r 1, c, color, cnt); CollectGroup(r, c - 1, color, cnt); CollectGroup(r, c 1, color, cnt); } int CountLiberties(int r, int c) { int color board[r][c]; if (color EMPTY) return 0; int cnt 0; memset(visited, 0, sizeof(visited)); // 每次调用必须归零 CollectGroup(r, c, color, cnt); bool seen[MAX_BOARD][MAX_BOARD] {false}; int dirs[4][2] {{1,0},{-1,0},{0,1},{0,-1}}; int libs 0; for (int i 0; i cnt; i) { for (int d 0; d 4; d) { int nr group[i][0] dirs[d][0]; int nc group[i][1] dirs[d][1]; if (board[nr][nc] EMPTY !seen[nr][nc]) { seen[nr][nc] true; libs; } } } return libs; }逻辑说明递归深度最多 361 层MFC 默认栈空间完全扛得住。seen 数组保证同一块棋周围同一个空点只算一口气——两颗相邻棋子共享同一个气口漏掉去重提子判断会提前误触发表现为「对方明明还有气却被提子」的怪象。参数说明visited 和 group 声明成 static 是为了减少递归传参开销但 CountLiberties 每次进入必须 memset。seen 是函数局部变量用 {false}初始化即可每次调用自动重建。dirs 方向数组顺序无所谓但四个方向不能漏——漏一个角部的棋子永远数不对气。3.2 提子与禁着点先提对方再查己方死活落子的完整流程是先把棋子临时放上棋盘提走四周所有气尽的对方棋子然后检查己方这颗棋有没有气。己方没气且刚才没提成任何子就是禁着点必须整盘回卷inline int Opponent(int c) { return (c BLACK) ? WHITE : BLACK; } bool TryPlaceStone(int r, int c, int color) { if (board[r][c] ! EMPTY) return false; int saved[MAX_BOARD][MAX_BOARD]; memcpy(saved, board, sizeof(board)); // 整盘快照失败可回卷 board[r][c] color; // 先落子 int dirs[4][2] {{1,0},{-1,0},{0,1},{0,-1}}; int captured 0; for (int d 0; d 4; d) { int nr r dirs[d][0], nc c dirs[d][1]; if (board[nr][nc] Opponent(color) CountLiberties(nr, nc) 0) { captured RemoveGroup(nr, nc); // 提掉整块 } } // 禁着点自己没气且没提到子 if (CountLiberties(r, c) 0 captured 0) { memcpy(board, saved, sizeof(board)); // 回卷 return false; } return true; }逻辑说明顺序不能反。先查己方气再提对方会出现「提完对方我自己明明活了却被判成禁着点」的乌龙。围棋规则里提子的优先级高于自杀判断代码顺序必须和规则顺序一致。整体快照回卷比逐颗撤销更稳多花 441 个 int 的内存换来的是永远一致的状态这笔账划算。坑点captured 记录的是被提棋子数量而不是块数。只要提走一颗以上这手棋就是合法的哪怕己方落子后暂时没气——这叫「吃子成活」是新手最容易写成自杀禁手的场景。RemoveGroup 内部除了清空棋盘还要把被提每颗棋子的坐标记进当手 Move 结构供悔棋恢复使用。3.3 劫比两步前的局面快照或换 Zobrist 哈希简单劫的判定有个讨巧做法把每手落子后的完整局面存进历史栈当前这手棋如果导致棋盘和两步前完全一致说明发生了「提回来又提回去」的循环直接判劫。为什么只看两步前因为简单劫的循环周期正好是两手// Rule.cpp —— TryPlaceStone 内部的劫校验接 3.2 的代码 struct BoardSnapshot { int board[MAX_BOARD][MAX_BOARD]; }; std::vectorBoardSnapshot g_history; // 每手棋后 push 一份 // 在禁着点判断之后、返回 true 之前加入 BoardSnapshot snap; memcpy(snap.board, board, sizeof(board)); if (g_history.size() 2) { BoardSnapshot prev g_history[g_history.size() - 2]; if (memcmp(snap.board, prev.board, sizeof(board)) 0) { memcpy(board, saved, sizeof(board)); // 回卷禁止打劫 return false; } } return true; // 调用方落子成功后g_history.push_back(snap);逻辑说明memcmp 一次对比 441 个 int纳秒级完成每手棋调一次完全不是性能瓶颈。g_history 用 std::vector 实现——VC 项目里最常用的 STL 容器push_back 和 pop_back 都是 O(1)配合悔棋栈天然兼容。调用方在落子合法并记录完提子信息后把 snap push 进历史悔棋时同步 pop两者的长度必须严格一致。如果要严格实现全局同形superko用 Zobrist 哈希给每个位置三种状态各分配一个 64 位随机数局面哈希等于所有非空点随机数的异或。落子、提子各做两次异或就是 O(1) 增量更新把整盘 memcmp 换成哈希比对代码量增加约 30 行。普通人对战程序用不到写清楚注释留给后人即可。4. 棋盘绘制与鼠标交互用 GDI 画 19 路棋盘落子不漂移4.1 坐标换算半格偏移决定你有没有「点偏一格」这一章处理整个工程里最直观也最容易改坏的部分——用 GDI 做的 2D 绘图层。棋盘绘制的核心是两套坐标互转格点坐标1 到 19和像素坐标。MARGIN 是棋盘外沿到窗口边缘的距离CELL_SIZE 是相邻交叉点间距// BoardView.cpp —— 坐标互转 // 格点 - 像素画线、画棋子时用 int GridToPixel(int g) { return MARGIN (g - 1) * CELL_SIZE; } // 像素 - 格点鼠标点击时用CELL_SIZE/2 是关键四舍五入 int PixelToGrid(int px) { return (px - MARGIN CELL_SIZE / 2) / CELL_SIZE 1; }逻辑说明整数除法向下取整直接拿 (px - MARGIN) / CELL_SIZE鼠标落在两个交叉点正中间时会偏到左边一格这就是「点偏一格」的玄学来源。加上 CELL_SIZE/2 把除法变成四舍五入每个交叉点周围半径 14 像素半个格距都是有效命中区点歪一点也不会落错格。参数说明MARGIN、CELL_SIZE 在工程里必须全局统一不能一处用宏一处用字面量。棋子直径取 CELL_SIZE - 4留出相邻棋子的缝隙棋盘线用 1 像素默认画笔星位画小实心圆坐标文字用 TextOut 标注在格点外侧。这些值都集中在 GameDef.h 里定义调完 UI 不需要动规则代码。4.2 双缓冲MFC 重绘不闪的标配写法直接用 CPaintDC 画棋盘线的后果是每次 Invalidate 触发 OnPaint窗口先把客户区擦白再逐线画落子、拉伸都闪成一片。双缓冲的本质是先在内存位图上画完所有内容再一次 BitBlt 上屏// GoDlg.cpp —— OnPaint 双缓冲 void CGoDlg::OnPaint() { CPaintDC dc(this); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, m_clientW, m_clientH); CBitmap* pOld memDC.SelectObject(bmp); DrawBoard(memDC); // 画棋盘线、星位、坐标 DrawStones(memDC); // 画黑白棋子 dc.BitBlt(0, 0, m_clientW, m_clientH, memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); // 收尾换回原对象再删资源 bmp.DeleteObject(); memDC.DeleteDC(); }逻辑说明m_clientW / m_clientH 是客户区当前尺寸在 OnSize 里用 GetClientRect 取值。DrawBoard 内部用 MoveTo / LineTo 画横竖各 19 条线按坐标标注 A-S 和 1-19DrawStones 遍历棋盘数组非空格点用 Ellipse 画实心圆。所有绘制全在内存 DC 完成屏幕只做一次 BitBlt 合并输出。参数说明画棋子注意 Ellipse 的矩形边界——中心是 GridToPixel 的返回值左上角是中心减半径右下角是中心加半径。黑棋用黑色实心画笔加浅灰窄边框提亮白棋用白色实心加灰色描边不然白棋在浅色棋盘上会隐身。注意OnPaint 里创建的 GDI 对象必须成对释放先 SelectObject 换回旧对象再 DeleteObject。GDI 泄漏的典型症状是程序运行半小时后棋子画不出来重启又恢复正常排查起来非常痛苦。4.3 悔棋与局面栈vector 做历史记录的完整闭环围棋的悔棋不只是拿掉最后一颗子——还得把提掉的对方棋子按原坐标放回去所以落子时必须把提子信息记录下来// GameDef.h —— 一手棋的完整记录 struct Move { int row, col; // 落子位置 int color; // BLACK / WHITE std::vectorstd::pairint,int captured; // 被提的对方棋子 }; std::vectorMove g_moveStack; // 全局历史栈// Rule.cpp —— 悔棋实现 void UndoLastMove() { if (g_moveStack.empty()) return; Move mv g_moveStack.back(); g_moveStack.pop_back(); board[mv.row][mv.col] EMPTY; // 摘掉这手棋 for (size_t i 0; i mv.captured.size(); i) { int rr mv.captured[i].first; int cc mv.captured[i].second; board[rr][cc] Opponent(mv.color); // 被提的放回去 } g_history.pop_back(); // 局面快照同步回退劫判断才不乱 }逻辑说明captured 里棋子的颜色恒为对手色不用单独记录。悔棋时按对方颜色放回原位再顺手把局面快照 pop 掉——这条最容易漏漏掉之后劫判断会拿旧局面对比当前棋局直接翻车。vector 的 back() 返回末尾引用pop_back() O(1) 删除作为栈用非常顺手。参数说明pairint,int 存坐标足够追求可读性可以换成小结构体。整份历史栈在「新开局」时用 clear() 一次性清空同时清掉 g_history两栈同生共死。悔棋按钮的消息响应里调 UndoLastMove 后记得 Invalidate(FALSE)让棋盘立刻刷新。5. 避坑记录VS2010 编译运行围棋源码的 5 个高频翻车点编译老工程和写新工程完全是两码事。v100 工具集、MBCS/Unicode 字符集、GDI 重绘、MFC 运行库这四个关键词凑在一个围棋项目里就是新手最常见的劝退现场。下面 5 条按出现概率排序每条都是「现象、原因、解决」三段式照着定位能省出大半天。先说两个通用习惯看编译输出窗口的第一条错误而不是最后一条以及把错误全部清完再按 F5别让第一次报错淹没在后续的级联错误里。5.1 .sln 双击打不开提示版本不兼容现象用高版本 VS 打开 VS2010 的 .sln弹窗提示需要进行一次单向升级点确认后代码能看F5 报错 C1083 找不到 stdafx.h 或一堆 MFC 头文件。原因VS2010 的平台工具集是 v100高版本 VS 默认把工程换成自己的工具集但老工程里的附加包含目录没有跟着迁移裸装 VS2010 不带 SP1 时部分 MFC 头文件本身就缺内容换谁打开都编不过。解决坚持用 VS2010 就先装 VS2010 SP1 再开工程换高版本就在项目属性 → 配置属性 → 常规 → 平台工具集里显式改成 v140 / v142改完重新生成解决方案。如果 C1083 还指向某个盘符下的绝对路径头文件把附加包含目录改成相对路径工程挪了位置才不会再次崩塌。提示改工具集前先复制一份 .vcxproj 备份升级过程会改写项目 GUID 和若干属性不备份就没有后悔药。5.2 界面中文全部乱码现象编译链接全部通过运行后对话框按钮上的「落子」「悔棋」「开始」全显示成问号或菱形豆腐块英文正常。原因源码 .cpp/.h 和 .rc 资源文件的保存编码与项目「字符集」设置不一致。VS2010 默认 Unicode 但编辑器可能按系统 ANSI 代码页存盘或者反过来工程用多字节字符集而文件是 UTF-8——编译器按错误的编码读中文字面量出来的就是乱码。解决先统一字符集项目属性 → 配置属性 → 常规 → 字符集选「使用 Unicode 字符集」再把所有 .cpp/.h/.rc 用 VS 菜单「文件 → 高级保存选项」转成 UTF-8 with BOM。注意 .rc 资源文件的乱码必须单独看——资源编译器按文件内声明的代码页解析正文转了 .rc 没转界面上照样是豆腐块。判断文件编码有个土办法记事本打开另存为对话框的编码下拉框会显示当前检测到的编码UTF-8 会明确写出来ANSI 则对应 GBK。5.3 落子瞬间棋盘闪白现象每点一下鼠标棋盘先整个白屏再重新画出来连续落子闪烁肉眼可见拉伸窗口时更严重。原因OnPaint 里直接用 CPaintDC 一块块画棋盘线配合 Invalidate(TRUE) 强制擦除背景重绘路径变成了「擦白 → 画线 → 擦白 → 画子」的循环闪烁是必然结果。解决照第 4 章的内存 DC 双缓冲先在内存位图画完再 BitBlt 上屏Invalidate 参数从 TRUE 改成 FALSE。改完做一个回归测试按住窗口右下角连续拖动缩放棋盘应该平滑跟手全程看不到白闪。如果双缓冲后还闪重写 OnEraseBkgnd 直接 return TRUE告诉系统背景永远不需要擦。5.4 明明围住了对方棋却提不掉现象单测数气函数结果正确但实战里白棋被黑棋围死界面上的白子却始终不消失或者一整块被提了一半。原因visited 静态数组在上一次调用后没归零CollectGroup 第二次运行时带着残留的 true 标记跳过大量棋子连通块收集不全气数虚高。另一个隐蔽情况是 TryPlaceStone 的失败分支先 return 再恢复棋盘顺序反了棋盘上留下一颗删不掉的幽灵棋。解决在 CountLiberties 入口强制 memset(visited, 0, sizeof(visited))并把 TryPlaceStone 每个 return false 分支都改成先恢复棋盘再返回。验证用一个固定棋谱白棋先放 (1,1) 角黑棋连下 (1,2)、(2,1)此刻白棋四邻全是黑棋或边界应被提掉 1 颗打印白棋总数从 1 变 0 才算通过再点悔棋白棋总数应恢复为 1。这套三步棋能同时验证提子和悔棋两条逻辑。5.5 生成的 exe 拷到别的机器启动闪退现象本机 F5 跑得欢把 Release 目录下的 exe 单独拷到另一台 Windows 机器双击报 0xc000007b 或者干脆没反应。原因工程默认动态链接 MFC 运行库VS2010 对应 mfc100u.dll 和 msvcr100.dll目标机器没装对应版本运行库就启动失败。0xc000007b 还经常和 32/64 位不匹配掺在一起工程编的是 x86目标机器没装 x86 运行库只有 x64 的也会报这个码。解决不想在目标机器装运行库就在项目属性 → 常规 → MFC 的使用里改成「在静态库中使用 MFC」exe 体积增大约 1 到 2 MB但完全自包含。同时检查配置管理器里的活动解决方案平台是 Win32 还是 x64与运行环境对齐。改完重新编译用 dumpbin /dependents 查看 exe 依赖列表确认没有 mfc100u.dll 就说明静态链接成功。6. 给 AI 对手加一档「棋感」评分函数与自对弈验证6.1 静态评分落子30 行让 AI 从随机棋手升级成入门棋手规则和界面跑通之后最有成就感的收尾是把只会随机落子的 AI 换成一个有基本棋感的对手。最轻量的改进是给每个空点打静态评分——角、星位给加成随机扰动控制在 10% 以内保证同一局面不是每次都下同一个点// Ai.cpp —— 极简静态评分落子 int EvalGrid(int r, int c) { if (board[r][c] ! EMPTY) return -99999; int score rand() % 10; if ((r 1 || r 19) (c 1 || c 19)) score 50; // 角 if (r 1 || r 19 || c 1 || c 19) score 15; // 边 return score; }实现注意评分只在空点上调用有子的位置直接给极负分随机扰动最后加别让随机数盖过棋型分。调参验证别靠人机试玩的主观感受——让新旧两个版本各执黑白自对弈 30 局统计胜率和平均手数手数明显变长说明策略变保守了胜率稳定过半说明改动真实有效。我个人的习惯是每改一个参数就往历史栈里留一条记录跑完自对弈回看上一版棋谱而不是靠一局胜负下结论。围棋程序的黑匣子不浅但每一层都有具体验证方法局面快照验证规则自对弈统计验证策略最后才轮到人机试玩。希望帮到你。本文还有配套的精品资源点击获取