
简介一份基于VC实现的五子棋人机对战完整工程面向C初学者及人工智能算法入门者可用于理解搜索算法在真实游戏中的落地过程。包内共29个文件压缩后约367KB以cpp、h源码为主体配合bmp、ico、rc等界面资源文件另含可直接运行的MyChess.exe与doc说明文档便于对照代码查看棋盘绘制与交互逻辑。已有133人学习浏览。项目重点运用Minimax搜索并引入Alpha-Beta剪枝配合启发式评估函数来判断棋形优劣同时通过MFC搭建图形界面示例了从算法设计到程序打包的完整流程。下载后既可运行试玩也可逐一阅读源码适合用来补强博弈树搜索、剪枝优化和Windows界面编程等实践技能。1. 一份 VC 五子棋人机源码能编译、能对弈还藏着一整套博弈搜索这份 wuziqi.rar 五子棋人机源码包解压后能看到MyChess.exe、一组 C 源文件以及一个来源站点的说明文本。它解决的是很多人的真实诉求想要一个能跑起来的五子棋人机对战程序最好代码还能改、能看懂 AI 为什么这么落子。对做课程设计、研究博弈搜索入门、或者想快速看一遍 MFC 图形界面怎么写的人这个包比刚从网上下个 exe 要值得多因为你拿到的是完整的决策链路。程序本身是老 VC 时代的产物界面朴素但该有的东西都在15 路棋盘、人机交替落子、胜负判定、可执行文件直接能玩。更关键的是从源码里能看到一条清晰的 AI 决策链路——用搜索评估棋盘状态、用剪枝省计算量、用启发式函数给棋形打分最后通过 MFC 把棋盘画出来并接收鼠标输入。这篇笔记把这四块拆开讲附带我重编译时踩过的几个坑。2. 把压缩包里的文件认全pudn 文档、yua源代码3 与 MyChess.exe 各管什么老工程包的文件命名常常很随意甚至在解压前会让人误以为包有问题。这个wuziqi.rar里同时出现www.pudn.com.txt、yua源代码3、chess目录、MyChess.exe拿到手别急着点开 exe先花五分钟把文件职责认清楚后面排查问题会省很多时间。2.1 一份典型老工程包的组成有效代码、可执行文件与说明文本这类从源码站下载的压缩包通常不是一个人维护的结构比较零散。我解压后一般先列一个清单按“说明类、代码类、产物类”分类压缩包条目典型内容职责定位www.pudn.com.txt下载站点的资源说明页文本来源信息。它会写资源介绍和下载时间对理解工程结构帮助有限但可以作为文件完整性的参照yua源代码3看名字是拼写错乱的“源代码3”通常是源码单元或目录名这个包的核心代码可能就散在这个条目里文件名乱是因为早年上传者用拼音输入法起名chess工程源码目录含 .cpp、.h、.dsp 等棋盘逻辑、AI 搜索、界面绘制的代码主体MyChess.exe编译后的可执行文件直接运行就能下棋。它是判断工程能否“出活”的最终凭证需要说明的是不同渠道拿到的包yua源代码3对应的实际文件会有出入。我拿到的那份里它和chess目录内容重合相当于一个冗余备份。所以别被条目名吓到优先找两类东西.dspVC 6.0 工程文件和包含OnPaint/Evaluate/AlphaBeta这类函数的关键源文件。2.2 拿到压缩包后的标准读取顺序用五个步骤判断工程价值解压后不建议直接双击 exe 开玩先按下面顺序过一遍能判断这个包是不是完整、值不值得投时间进去。我一般用 7-Zip 解压保持目录结构不丢7z x wuziqi.rar -owuziqi_src解压是第一步。然后依次做五件事第一看根目录下有没有.dsp或.dsw文件有就说明这是 VC 6.0 工程而不是散装代码第二打开www.pudn.com.txt看资源描述里是否提到算法名称很多老资源会在站点说明里写“含 Minimax 算法”之类的关键词第三统计.cpp文件数量一个完整的 MFC 五子棋程序通常不低于三个源文件分别是界面类、逻辑类、AI 类第四直接运行MyChess.exe下两三步确认棋盘响应正常、AI 会落子第五用文本编辑器打开最大的那个.cpp搜索AlphaBeta或Minimax确认搜索算法真的在源码里。这五步走完包的基本面就清楚了。如果只搜到Evaluate搜不到搜索函数说明这份源码可能是贪心版本棋力会弱不少如果.dsp缺失就需要自己新建工程把源文件拖进去编译。这个包我按上面的流程过了一遍可执行文件能正常开局源码里搜索函数完整属于可以直接拿来改的那一类。从第二件事的处理经验看老资源的文本说明里经常混着上传者自己的笔记比如“这个电脑下棋太蠢了”之类的话不要当真。真正要信的是源码里的搜索深度和评估逻辑这是下一章的重点。3. Minimax 与 Alpha-Beta 剪枝五子棋人机是怎么想下一步的五子棋人机的核心不在界面在决策。当玩家落一子后程序要回答一个问题我下在哪最有利最简单的做法是扫描全部空位挑一个“当前局面分最高”的落子但这是贪心会被对手一步反杀。实际工程里普遍做法是用 Minimax 搜索假设对手也足够聪明双方轮流推演若干步再选一个最坏情况下也能保证局面不崩的落点。3.1 为什么必须搜索而不是只看一步五子棋状态空间比三子棋大得多但比围棋小15 路棋盘上搜索深度设到 4 到 6 层配合剪枝老机器上也能做到单步思考秒级返回。Minimax 的核心思想是零和博弈我的收益等于对方的损失所以对方一定会走对我最不利的一步。对每个候选落点都递归地假设对方也会选一个让局面分最小化的点如此轮流推演。这种“我最大、对手最小”的交替过程在代码里长这样轮到电脑时评估结果取最大值轮到玩家时评估结果取最小值。搜索深度越深电脑对局势的预见性越好但计算量指数级增长。这就是为什么后面必须要剪枝。3.2 递归搜索主过程与 alpha-beta 的参数语义这个包里的搜索函数我读到的实现结构和大多数五子棋程序一样是负极大值写法。负极大值把 Minimax 里的“我取最大、对手取最小”统一成“每次递归都取负数最大值”代码更短。剪枝方面标准的 Alpha-Beta 剪枝用两个边界值alpha 是当前已知的最优下界beta 是当前已知的最坏上界。当某个分支的返回值不可能优于 alpha 或不可能低于 beta 时直接跳过不再深入。int AlphaBeta(int depth, int alpha, int beta, int who) { // depth: 当前剩余搜索深度depth 到 0 就停止递归 // alpha: 已知最优下界beta: 已知最坏上界用于剪枝 // who: 当前轮到谁1 表示 AI-1 表示玩家 if (depth 0) return Evaluate(m_board, who); // 深度耗尽用评估函数打分 vectorMove moves GenerateMoves(m_board); // 生成候选落子点 if (moves.empty()) return 0; // 棋盘满了平局 SortMovesByHeuristic(moves); // 关键按启发式评分降序排列 for (const Move mv : moves) { m_board[mv.row][mv.col] who; // 模拟落子 int score -AlphaBeta(depth - 1, -beta, -alpha, -who); m_board[mv.row][mv.col] EMPTY; // 撤销落子 if (score alpha) alpha score; // 更新下界 if (alpha beta) break; // beta 剪枝直接退出循环 } return alpha; }这段代码有两个地方值得细看。第一是-AlphaBeta(depth - 1, -beta, -alpha, -who)里的正负号翻转负极大值写法下每次递归都把盘面交给对方返回值取负这样“我的最优”和“对方的最优”统一成一个逻辑。第二是GenerateMoves不会生成全部空位而是只看有邻居棋子的空位把候选压到几十个以内否则第一手有 200 多个空位搜索根本跑不动。SortMovesByHeuristic这一步最容易忽略但对性能影响巨大。如果候选点按乱序搜索剪枝发生得晚节点数会成倍增加。按启发式分数从高到低搜好的着法先被验证坏的着法很快触发剪枝。我第一次看到这个工程时没注意排序调用自己重写时漏了结果同样的深度速度差了四五倍。3.3 启发式评估函数把棋形折算成可比较的分数搜索函数只做“取最大值”的调度真正给每步棋打分的是评估函数Evaluate。它扫描棋盘每一个点统计以该点为中心的四条线——横、竖、两个斜向——上的连子情况然后把棋形映射成分数。这个包里的分值表和我见过的多数实现接近棋形分数说明成五100000已经五子连珠封顶分数活四50000四子连珠且两端都开放必胜棋冲四10000四子连珠且一端被堵对手只能被迫应对活三5000三子连珠两端开放潜在威胁眠三1000三子连珠一端被堵活二500二子连珠早期布局用Evaluate本身不复杂核心是分方向扫描把当前 AI 的棋形分数减去玩家的棋形分数得到盘面净差值。这样 AI 不仅在考虑自己的进攻也在评估对方潜在威胁。具体到代码常见做法是定义四个方向向量(1,0)、(0,1)、(1,1)、(1,-1)对每个空位、每个方向数连续同色棋子时带上两端的封堵状态再查上面的分值表。int Evaluate(const int board[15][15], int who) { int score 0; int dirs[4][2] {{1,0},{0,1},{1,1},{1,-1}}; for (int dir 0; dir 4; dir) for (int row 0; row 15; row) for (int col 0; col 15; col) score EvalPoint(board, row, col, dirs[dir][0], dirs[dir][1], who); return score; // 分数同时包含进攻棋形和防守棋形的差值 }这里的who是当前评估视角EvalPoint负责处理一个点在一个方向上的连子长度、两端是否被堵、是否有断点。一个容易写错的地方是同一个方向上的五连会被五个起点各计一次分导致成五的分数被重复累加所以老代码里通常会加一个“只对每段连子的起点计分”的判断按起点计分而不是按当前点计分否则评估会虚高。评估参数决定棋风把活三的分值调高AI 会更主动进攻把眠三和冲四的分值调高AI 会更倾向堵对方。调试时推荐只动这几个分档阈值不要改成连续函数否则很难解释 AI 的某一步为什么这么走。4. MFC 界面的落子链路棋盘绘制、鼠标响应与胜负判定算法层想清楚后还得有一层界面把棋盘画出来、把鼠标点击转成数组坐标。老工程用的是 MFC主窗口通常是CDialog对话框子类棋盘画在OnPaint里鼠标点击走OnLButtonDown人机双方共用同一个二维数组m_board[15][15]0 表示空、1 表示玩家、2 表示 AI。4.1 OnPaint 里的棋盘与棋子绘制MFC 对话框的绘制不走OnDraw而是走OnPaint这是新上手的人容易找错函数的地方。绘制逻辑分三步画棋盘网格线、画落子点上的棋子、刷新界面。代码结构一般是这样void CMyChessDlg::OnPaint() { CPaintDC dc(this); // 设备上下文生命周期结束自动释放 // m_startX/m_startY 是棋盘左上角像素坐标m_cell 是每格边长 for (int i 0; i 15; i) { dc.MoveTo(m_startX, m_startY i * m_cell); dc.LineTo(m_startX 14 * m_cell, m_startY i * m_cell); dc.MoveTo(m_startX i * m_cell, m_startY); dc.LineTo(m_startX i * m_cell, m_startY 14 * m_cell); } for (int row 0; row 15; row) { for (int col 0; col 15; col) { if (m_board[row][col] 0) continue; CBrush* pOldBrush dc.SelectObject(m_brush[m_board[row][col]]); dc.Ellipse(m_startX col * m_cell - m_radius, m_startY row * m_cell - m_radius, m_startX col * m_cell m_radius, m_startY row * m_cell m_radius); dc.SelectObject(pOldBrush); // 画完要恢复旧画刷否则 GDI 对象泄漏 } } }这里有个细节值得留意每次SelectObject一个画刷画完必须SelectObject回原来的对象。MFC 的CPaintDC析构时会释放设备上下文但选进去的 GDI 对象如果不恢复程序反复重绘后会有明显的资源泄漏表现为棋盘上出现黑色块或者重绘变慢。老工程里这段代码可能是用CBrush直接创建的看代码时留意有没有配套的删除逻辑。4.2 鼠标点击到 AI 回落的调用链鼠标处理函数里核心动作是“像素坐标转数组下标”。很多新手写的版本直接把point.x / m_cell当列坐标忽略了棋盘起点偏移结果点击棋盘左下角会落到数组外。稳一点的做法是先把像素坐标减掉棋盘原点加上半个格宽做四舍五入再整除格子边长void CMyChessDlg::OnLButtonDown(UINT nFlags, CPoint point) { int row (point.y - m_startY m_cell / 2) / m_cell; int col (point.x - m_startX m_cell / 2) / m_cell; if (row 0 || row 15 || col 0 || col 15) return; // 点在棋盘外 if (m_board[row][col] ! 0) return; // 已有棋子忽略本次点击 m_board[row][col] PLAYER; // 玩家落子 if (CheckWin(row, col, PLAYER)) { MessageBox(_T(玩家获胜)); return; } int aiRow 0, aiCol 0; SearchBestMove(aiRow, aiCol); // 内部调用 AlphaBeta m_board[aiRow][aiCol] AI; if (CheckWin(aiRow, aiCol, AI)) { MessageBox(_T(电脑获胜)); return; } Invalidate(FALSE); // 触发重绘 }调用链的顺序是固定的先落玩家子再判玩家胜负再调 AI 搜索再判 AI 胜负最后Invalidate刷新界面。Invalidate(FALSE)的FALSE参数表示不擦除背景直接重画速度更快但如果你改了棋盘的背景色这里要改成TRUE否则旧棋子残影会留在屏幕上。SearchBestMove内部把棋面复制一份传给AlphaBeta避免搜索过程中的试走干扰正式棋盘这一步老代码可能直接用了全局棋盘所以看到搜索结束后棋盘没变乱说明实现有做保存和恢复。4.3 胜负判定与先手设置的实现细节胜负判定比直觉上麻烦因为要处理四个方向还要处理“五子以上算赢”还是“恰好五子算赢”。标准做法是从刚落子的点出发沿某个方向往前往后数相同棋子的个数总数大于等于 5 即判胜。bool CMyChessDlg::CheckWin(int row, int col, int player) { int dirs[4][2] {{1,0},{0,1},{1,1},{1,-1}}; for (int d 0; d 4; d) { int count 1; for (int step 1; ; step) { int r row dirs[d][0] * step; int c col dirs[d][1] * step; if (r 0 || r 15 || c 0 || c 15 || m_board[r][c] ! player) break; count; } for (int step -1; ; step--) { int r row dirs[d][0] * step; int c col dirs[d][1] * step; if (r 0 || r 15 || c 0 || c 15 || m_board[r][c] ! player) break; count; } if (count 5) return true; } return false; }两个循环分别向正方向和负方向数初始 count 从 1 开始是因为当前落子点本身算一个。边界判断不能省否则会越界读到棋盘外的随机内存。五子棋判定用 5而不是 5因为黑棋有时候出现长连按正式规则要禁手但多数练习程序和课程设计不做禁手实现直接算赢。先手规则上这个程序默认玩家执黑先手AI 白棋后手如果想改成 AI 先手需要在初始化时让m_board先放一个中心子或者把搜索调用放到玩家落子之前。5. 重编译与运行时避坑从乱码到“电脑突然不落子”的四个排查记录老代码重编译必然踩坑。我按“人机料法环测”的思路排查过代码本身的逻辑、编译器环境、运行参数、操作流程逐个过一遍问题基本都能定位到具体环节。下面四条是这份五子棋源码包里最典型的现象、原因、解决一次写清。现象 1用新版 Visual Studio 打开工程编译时冒出几百行 C4819 警告中文注释全是乱码。原因是老源码的注释是 GBK/GB2312 编码而 VS2015 以后的编译器默认按当前系统代码页解读源文件中文注释被拆成非法字符产生 C4819 警告严重的会把后面的代码解析错直接编译失败。解决方法是给工程加编译选项让编译器按 GBK 读源文件右键工程属性在“命令行”里加/source-charset:GB2312。另一个办法是省事路线用 Notepad 把每个源文件从 ANSI 转成 UTF-8 无 BOM 格式顺便把注释里的中文删掉或改成拼音但批量转码后要重新确认字符串字面量有没有被破坏比如_T(玩家获胜)这类宽字符串转码后偶尔会变成L鐜╁这种乱码。现象 2双击 MyChess.exe 能进入界面但点击棋盘任意位置都没反应。这种问题多出现在我自己重写界面时不属于原包的缺陷但因为它太常见还是值得列出来。原因是像素坐标转数组下标时没减去棋盘左上角偏移量或者没做棋盘外点击的边界判断。另一个隐蔽点是消息映射缺失MFC 对话框里如果没在消息映射表里加ON_WM_LBUTTONDOWN()OnLButtonDown永远不会被调用。排查时先确认函数里有没有执行断点再检查坐标转换。解决方式就是代码里先减m_startX和m_startY四舍五入到最近格子再校验 row/col 落在 0 到 14 之间。现象 3AI 在开局阶段思考很久甚至十几秒都不落子。原因是搜索深度设得偏高并且候选点没有按启发式分值排序。开局时棋盘空位多可落点接近两百个如果每个点都进入递归深度 6 的情况下节点数轻松过千万。解决方法是两步第一GenerateMoves只生成距离已有棋子两格以内的空位开局阶段候选点立刻降到几十个第二参考 3.2 节里的SortMovesByHeuristic把候选点按评估分数降序排列再进入递归这样剪枝能尽早发生。如果机器确实太老可以把搜索深度从 6 降到 4棋力会弱一点但每步能在 1 秒内返回。现象 4用 VC 6.0 原版能编译的资源文件拿到新版 VS 里自动转换后对话框资源打不开。原因是.rc资源文件的格式在 VS2008 之后有过一次大版本更替老的.rc里某些控件声明新编译器不再兼容自动转换常常失败。解决方法是不要依赖工程向导的“转换”而是新建一个 MFC 对话框工程手动把旧工程的resource.h里的控件 ID 定义复制过来把对话框.rc里的控件布局按原样画一遍再把核心的 C 逻辑文件拖进新工程。这个过程会损失一部分可视化资源编辑的便利但代码逻辑完全保留AI 部分不用动。如果想省事也可以直接装一个 Windows XP 模式虚拟机在里面跑 VC 6.0 原版环境那个最省心。这四条里现象 3 对棋力影响最大也最不容易一眼看出来。很多人以为 AI 慢是因为搜索函数写错了其实绝大多数时候都是候选点没剪裁 未排序。改完这两处深度 4 到 6 都跑得动棋力提升反而比单纯加深度更明显。6. 让搜索过程可见计数节点、调权重、摆棋局验证棋力源码读完后最后一个值得做的动作是给搜索函数打“探针”把黑匣子变成可见过程。五子棋人机的棋力是由搜索深度、评估权重、裁剪策略三个参数共同决定的不实际验证你很难知道哪一行代码真正起了作用。验证剪枝效果最直接的办法是在AlphaBeta函数入口加一个静态计数器统计节点访问总量。我一般会在剪枝前先跑一遍大步数搜索再打开剪枝分支跑一遍对比两者的节点数差距。代码如下static int g_nodeCount 0; int AlphaBeta(int depth, int alpha, int beta, int who) { g_nodeCount; // 每进入一个节点就加一 // ……原有搜索逻辑不变…… } void CMyChessDlg::SearchBestMove(int outRow, int outCol) { g_nodeCount 0; // 搜索前清零 // 调用 AlphaBeta 并记录最佳落点 TRACE(_T(节点数: %d\n), g_nodeCount); // 输出到调试窗口 }同一盘棋、同一个落子点开剪枝和关剪枝的节点数至少差一个数量级。如果你看到节点数没有明显减少说明剪枝条件写错了最常见的问题是alpha beta的判断写成了score beta导致剪枝没法传播到上层递归。这一步验证做完AI 代码的逻辑你就有了实际数据支撑而不只是“感觉能跑”。权重调整方面我习惯先改两个最敏感的参数观察棋风把活三从 5000 调到 8000AI 会更积极地做棋而不是被动堵人把眠三从 1000 调到 3000AI 会更多地下在“看似不起眼但能限制对方”的位置。注意每次只改一个参数否则你没法判断棋风变化是哪个数值引起的。最后一关是手工摆棋局回归测试。在OnInitDialog里临时往m_board塞几个固定棋子比如摆出一个斜线活三和一个冲四启动程序看 AI 是否优先下在冲四的延伸位。这个测试成本极低但能快速暴露评估函数的方向向量有没有写错。我有一次调权重时把活四的分值误调成低于眠三AI 放着必胜棋不下跑去堵一个无关紧要的眠三就是这个固定棋局帮我一眼抓出来的。从那以后我每次改完评估函数都强制自己先摆三个固定棋局跑一遍再谈其他优化。希望这套排查和验证方法帮到你。本文还有配套的精品资源点击获取