Visual Studio五子棋人机对战:C# AI评估函数与搜索降维实现
简介这是一份基于 Visual Studio 和 EasyX 图形库实现的五子棋游戏完整工程资源支持人机对战与人人对战两种模式适合正在学习 C#/.NET 桌面开发、Windows 窗体事件处理或棋类 AI 算法的开发者作为课程设计、毕业设计或自学项目。资源包共 49 个文件压缩包约 107.07MB包含 5 个 cpp 源文件与 1 个头文件把游戏逻辑、AI 决策、界面绘制和音效播放拆分为不同模块还有 11 张 jpg/png 棋盘棋子素材、5 个 wav 音效、VS 工程配置sln/vcxproj/filters以及 pdb、sdf、tlog 等构建与调试中间文件方便直接打开工程定位代码、调整界面或排查运行问题。目前已有 2657 人学习下载。项目内部用二维数组管理棋盘状态落子合法性判断、五子连珠胜负检测、悔棋、重新开始、保存与加载对局均有实现AI 部分涵盖初级搜索思路并给出 Minimax 与 Alpha-Beta 剪枝的优化方向便于对照源码理解不同难度层级。整体从界面素材到代码组织都比较完整可直观看到一款桌游从界面搭建、交互响应到算法决策的落地过程适合在此基础上继续扩展新功能。1. Visual Studio 五子棋就能跑出人机对战先弄清楚它的 AI 到底在算什么很多人第一次打开这个 Visual Studio 五子棋工程时会以为里面用了很玄的神经网络。实际上拆开代码你会发现它没在「学下棋」而是在「算账」——给棋盘上每个空位打分挑分数最高的点落子。整份资源的核心就三件事一个 15×15 的棋盘数据结构、一套棋型评估函数、一个带降维的搜索主循环。只要你本地装了 Visual Studio新建一个 C# Windows 窗体项目把工程文件拖进去就能跑不需要额外配任何第三方库。适合两类人一是刚学完 C# 基础、想拿一个完整项目练手的人二是课程设计需要「人机对战 AI 思路」演示的学生。接下来我会从工程骨架讲到评估函数再讲我实际跑的时候踩过的几个坑每一步都能照着复现。2. 工程骨架与界面选型为什么用 WinForms 而不是控制台2.1 先把棋盘数据结构和落子逻辑定下来打开工程后你会看到整个程序并没有用复杂的游戏引擎就是一个标准的 WinForms 窗口。常见的做法是用一个 15×15 的二维数组做棋盘状态0表示空1表示黑子2表示白子。这样的好处是后续写评估函数时直接对这个数组做遍历就行界面的绘制反而变成次要的事。我一般会把棋盘数组放在单独的类里而不是直接塞进 Form 的事件代码中这样人机对战的逻辑可以独立测试。public class Board { public const int Size 15; public int[,] Cells { get; private set; } public Board() { Cells new int[Size, Size]; } public bool PlaceStone(int row, int col, int player) { if (row 0 || row Size || col 0 || col Size) return false; if (Cells[row, col] ! 0) return false; Cells[row, col] player; return true; } public bool IsEmpty(int row, int col) { return Cells[row, col] 0; } }这里PlaceStone做了两个判断坐标越界和当前位置是否已有棋子。返回值用于界面层提示「这里不能落子」。注意Cells用private set避免外部直接篡改棋盘状态所有改动必须走PlaceStone。棋盘大小设为常量Size15后续评估、绘制、胜负判断都引用这个常量而不是到处写死数字 15。界面层我用了一个自定义控件在OnPaint里把网格线和棋子画出来。没有用 PictureBox 加背景图因为网格线用 GDI 绘制更快而且缩放窗口时不会失真。绘制棋盘的核心是计算格子间距15 条横线、15 条竖线间距取控件宽高较小值除以 16这样四周留出半个格子的边距。棋子直接用FillEllipse画实心圆黑子用黑色白子用白色加一个灰色描边不然白棋在浅色棋盘上看不清。2.2 棋型分值是 AI 的「世界观」五子棋 AI 不靠搜索很深也能赢初学者关键在于棋型评估表。这份资源里的分值表大致是这样的连五 1000000活四 1000000冲四 100000活三 50000眠三 1000活二 500眠二 100。这里有个容易误解的地方连五和活四分数相同因为连五已经赢了不需要再区分优先级而冲四只比活三高一倍这会让 AI 在某些局面下优先堵活三而不是堵冲四实际对战效果反而更自然。初学者常犯的错误是把「活三」分数给得太高导致 AI 疯狂进攻但防不住对手的冲四输棋以后又觉得是搜索深度不够其实纯粹是权重没调对。 我把分值表定义成一个数组方向从「五连」到「单子」依次往下排后面做棋型识别时直接查表。public static class ScoreTable { // 棋型分值连五最高眠二最低 public static readonly Dictionarystring, int Scores new Dictionarystring, int { { FIVE, 1000000 }, // 连五已获胜 { LIVE_FOUR, 1000000 }, // 活四两头都空 { RUSH_FOUR, 100000 }, // 冲四一头被堵 { LIVE_THREE, 50000 }, // 活三再走一步成活四 { SLEEP_THREE, 1000 }, // 眠三只能形成冲四 { LIVE_TWO, 500 }, // 活二 { SLEEP_TWO, 100 } // 眠二 }; }分值是相对大小不是绝对大小。你想调 AI 的进攻风格直接改这份表就行——把LIVE_THREE从 50000 提到 80000AI 会更积极地扩张把RUSH_FOUR从 100000 提到 300000AI 会更 aggressive 地追杀对方的冲四。我刚开始调的时候犯了贪心的毛病把所有进攻分值都调高结果 AI 变成「只进攻不防守」遇到稍微会下一点的对手就被反杀。后来发现防守分即对手棋型带给己方空位的分和进攻分应该用同一套表只是计算时把当前玩家的颜色反过来算一遍再取两者较大值这是后话了。2.3 胜负判断别只查四个方向胜负判断看起来简单——落子后检查横、竖、左上到右下、右上到左下四个方向有没有连续五个同色棋子。但很多初次实现的人会漏掉一个点不能只从落子点向前数而是要从落子点向两个方向同时数再把两段长度加起来减一。比如在位置 (7,7) 落黑子需要从 (7,7) 向左数到最后一个黑子 L1再向右数到最后一个黑子 L2如果 L1L2-1 5 就赢了。写成代码时最好用一个方向数组把四个方向的增量统一处理不然你会写出四段几乎一样的重复代码后续想增加斜向判断时容易漏改。public bool CheckWin(int row, int col) { int player Cells[row, col]; if (player 0) return false; // 四个方向水平、垂直、主对角线、副对角线 int[][] directions new int[][] { new int[] { 0, 1 }, new int[] { 1, 0 }, new int[] { 1, 1 }, new int[] { 1, -1 } }; foreach (var dir in directions) { int count 1; // 正方向数 for (int i 1; i 5; i) { int r row dir[0] * i; int c col dir[1] * i; if (r 0 || r Size || c 0 || c Size) break; if (Cells[r, c] player) count; else break; } // 反方向数 for (int i 1; i 5; i) { int r row - dir[0] * i; int c col - dir[1] * i; if (r 0 || r Size || c 0 || c Size) break; if (Cells[r, c] player) count; else break; } if (count 5) return true; } return false; }directions数组里每一项代表一个方向增量比如{ 0, 1 }表示横向向右。正反两个方向各数到 4 步就够了因为五子连珠最远不会超过落子点左右各 4 格超过 4 格没连上说明中间断了。这里有一个边界细节副对角线{ 1, -1 }在 C# 里用row - dir[1] * i表示反向时实际上变成了row - (-1)*i row i也就是往另一条斜线方向走这是正确的因为副对角线的两个延伸方向本来就是「行增列增」和「行减列减」的镜像。如果你测试时发现斜着五连判断不出来多半是方向数组写成了{ 1, 1 }和{ -1, -1 }等于同一个方向数了两次。3. 评估函数与落子决策让 AI 学会「算账」3.1 评估棋型滑动窗口还是逐点扫描评估函数是五子棋 AI 的核心没有它后面的搜索就是无源之水。常见做法是给每个空位打分假设这个位置放上当前玩家的棋子然后检查以这个位置为中心、半径 4 的范围内所有可能形成五连的「线」统计每一种棋型的分值。更简单的方式是滑动窗口把每行、每列、每条斜线都切成长度为 5 的窗口对每个窗口统计棋子的分布若是「OOO__」两个空位就记一个活三若是「OOO_X」一头被堵就记一个眠三累加到该窗口涉及的空位上。我实践后推荐一个更稳定的方案不滑动窗口而是对每个空位做「四方向延伸计数」。从空位出发沿某个方向数连续的同色棋子再数这个方向末尾是否为空或边界从而判断活眠。这样写更直观调错时也好定位。下面是核心的棋型识别代码它接受一个起点坐标、一个方向增量和当前玩家颜色返回该方向上的棋型描述。// 判断 (row, col) 沿 dir 方向的棋型 // 返回字符串FIVE / LIVE_FOUR / RUSH_FOUR / LIVE_THREE / SLEEP_THREE / LIVE_TWO / SLEEP_TWO / NONE public string EvaluateDirection(int row, int col, int[] dir, int player) { int r row, c col; int count 1; // 当前子 int emptyCountLeft 0; // 左侧空位数 int emptyCountRight 0; // 向正方向延伸 int rr row dir[0]; int cc col dir[1]; while (rr 0 rr Size cc 0 cc Size) { if (Cells[rr, cc] player) { count; rr dir[0]; cc dir[1]; } else break; } // 正方向尽头是否为空 bool openRight (rr 0 rr Size cc 0 cc Size Cells[rr, cc] 0); // 向反方向延伸 rr row - dir[0]; cc col - dir[1]; while (rr 0 rr Size cc 0 cc Size) { if (Cells[rr, cc] player) { count; rr - dir[0]; cc - dir[1]; } else break; } bool openLeft (rr 0 rr Size cc 0 cc Size Cells[rr, cc] 0); if (count 5) return FIVE; // 活四中间四个子两头都通冲四四个子一头通 if (count 4) { if (openLeft openRight) return LIVE_FOUR; if (openLeft || openRight) return RUSH_FOUR; return NONE; } // 活三三个子且两头通眠三三个子且一头通 if (count 3) { if (openLeft openRight) return LIVE_THREE; if (openLeft || openRight) return SLEEP_THREE; return NONE; } if (count 2) { if (openLeft openRight) return LIVE_TWO; if (openLeft || openRight) return SLEEP_TWO; return NONE; } return NONE; }这里我故意只数了连续同色子没有考虑「跳」的情况比如 XO_OXO 这种中间隔一个空位的形状因为跳棋型的判断逻辑会成倍增加复杂度对一个课程设计级别的五子棋 AI 来说先把连续棋型做好棋力已经能胜过大多数初学者。openLeft 和 openRight 是两个布尔值分别表示反方向和正方向尽头是否为空如果越界则视为堵住。NONE表示该方向长度不足 2 或者两头都被堵死不给分。 这套代码有一个隐含假设落子位置本身是玩家的棋子。如果你的主循环是遍历空位、假设 AI 在此落子那么调用前要把空位临时改成当前玩家棋子算完分数再恢复。这里的临时改不通过PlaceStone而是直接改Cells因为PlaceStone有边界和占位校验临时操作不需要这些校验直接索引赋值反而更快。3.2 搜索空间降维别遍历 225 个空位如果每走一步都遍历 225 个空位每个空位再算四个方向那计算量大约是 225×4×延伸长度单层评估成本很低但一旦你想叠加两层搜索AI 走一步、对手走一步评估次数会迅速膨胀到 225×224≈5 万次WinForms 主线程直接卡死。所以资源里的做法是只把「距离最近落子点 2 格以内」的空位列入候选。这个技巧叫局部搜索原理是五子棋的棋步几乎都围绕现有棋子展开棋盘边缘那些离战场很远的空位在前期根本不该考虑。 候选点过滤的逻辑不复杂维护一个棋盘上的坐标集合每次有新落子后把周围半径 2 的未占位置加入 collect 列表AI 决策时只遍历这个列表。实际效果开局时候选点约 20 个中盘约 40 个两层搜索的评估次数降到 40×39≈1560 次肉眼无延迟。如果你把半径扩到 3候选点能达到七八十个搜索时间会明显拉长——所以这个「2 格半径」是一个经验值不是越大越好。 我还试过另一种更激进的做法只保留有棋型特征的候选点比如某个空位周围至少有一个己方或对方棋子才计算。这个过滤能再减少 30% 的候选但在中盘容易漏掉关键防守点最终我没采用。对多数人机对战场景「最近落子点周围 2 格」已经足够省下来的性能可以去换搜索深度。3.3 主决策流程打分、取最大、防守优先有了评估函数和候选点落子决策就是一个循环加一个排序。常见的完整主循环分三步第一步遍历候选点对每个点分别计算「如果 AI 落子」的进攻分第二步对同一个点计算「如果对手落子」的防守分第三步取二者之中较大的值作为该点最终得分然后选全局最高的点落子。为什么不直接用总和而用最大值因为五子棋是零和博弈一步棋要么是进攻好要么是防守好两者同时做到最好很难取最大值会让 AI 在局势危险时优先防守在局势平稳时优先进攻。 下面是决策函数的示意代码用到了一个小技巧计算防守分时把当前玩家颜色取反3 - player这样同一套评估逻辑可以复用来算对手的棋型。public (int, int) GetBestMove(int aiPlayer) { var candidates GetCandidateMoves(); // 距离最近落子点 2 格内的空位 int bestScore -1; int bestRow -1, bestCol -1; foreach (var (r, c) in candidates) { // 模拟 AI 落子算进攻分 Cells[r, c] aiPlayer; int attackScore EvaluatePoint(r, c, aiPlayer); // 模拟对手落子算防守分 Cells[r, c] 3 - aiPlayer; int defendScore EvaluatePoint(r, c, 3 - aiPlayer); // 还原棋盘 Cells[r, c] 0; int finalScore Math.Max(attackScore, defendScore); if (finalScore bestScore) { bestScore finalScore; bestRow r; bestCol c; } } return (bestRow, bestCol); }EvaluatePoint内部对四个方向各调用一次EvaluateDirection返回的分值是该方向棋型对应的ScoreTable值相加。之所以取四个方向之和而不是取最大值是因为五子棋一步棋可能同时形成两个方向上的威胁比如同时形成两个活三这种棋特别难防分值应该叠加。比如一个点既形成横向活三、又形成纵向活二总分是 5000050050500而另一个点只形成单个冲四总分 100000AI 会优先下冲四——这符合直觉因为冲四能直接威胁获胜而活三只是潜在威胁。 在取最大时有一个坑如果进攻分和防守分相等AI 会随机选一个导致同样的开局走法每次不同。我一般会加一条规则优先选进攻分高的点只有进攻分明显低于防守分时才优先防守。具体实现是finalScore attackScore * 2 defendScore ? attackScore : defendScore这个系数 2 代表 AI 的进攻倾向你可以把乘数改成 1.5 或 3 来切换风险偏好。4. 人机对战避坑最容易翻车的 5 个问题4.1 落子后棋盘不刷新界面一直停在旧局面现象AI 已经算出落子坐标并在逻辑棋盘上落子但窗口上看不到新棋子或者只有移动鼠标时才出现。 原因WinForms 的OnPaint重绘时机没有被触发。很多人直接在鼠标点击后修改Cells没有调用Invalidate()。Invalidate通知系统该区域需要重绘但重绘是异步的不会立刻执行。 解决在每次落子后无论是玩家还是 AI调用this.BoardControl.Invalidate()强制刷新整个棋盘控件。如果棋盘控件是自绘的确保OnPaint里每次都完整重新绘制网格线和所有棋子不要用增量绘制。4.2 AI 思考期间界面假死鼠标点了没反应现象点击棋盘后窗口转圈过上几秒才弹出棋子期间无法拖动窗口。 原因AI 的落子计算直接跑在 UI 线程上WinForms 的消息循环被阻塞。搜索层数增加后问题更明显。 解决把GetBestMove放到后台线程执行。用Task.Run是最省事的方案计算结束后用BeginInvoke回到 UI 线程更新棋盘。注意计算期间要把棋盘控件设为不可点击防止玩家在 AI 思考时连续落子导致棋盘状态错乱。我一般用一个bool isAIThinking标志位在点击事件开头判断并直接 return。4.3 评估方向漏了「反方向」活三被算成眠三现象AI 明明三步内能赢的局面偏偏去堵对方一个不疼不痒的冲二然后被翻盘。 原因EvaluateDirection只沿一个方向延伸没有把反方向算进去。比如横排的三个棋子中间位置被评估时向正方向数到边界就停了count 只有 1返回 NONE。棋型识别必须两个方向同时数并把两次的 count 加起来。判断活三活四时两头的空位状态也要分别判断。 解决重写EvaluateDirection像我在第 3.1 节里展示的那样用一个 while 循环正向数、一个 while 循环反向数。写完以后一定要人工验证几个经典棋型竖排的活三、斜线的冲四、上下对称的活二每个方向都摆一遍再测。4.4 悔棋功能让 AI 走了重复步现象玩家悔棋后 AI 又下了和刚才一样的棋或者 AI 直接「短路」不走棋了。 原因悔棋通常只把棋盘上的最后一颗子取消但候选点集合candidates没有随悔棋更新。比如你悔掉了 AI 刚才的落子候选点里还留着那个位置AI 一看这个点分最高又重新下回去陷入循环。 解决悔棋时除了从Cells移除棋子还要移除候选点集合里对应位置的记录并重新计算最近落子点。更简单的方式是每次 AI 决策前都把候选点集合重建一次不要维护增量的候选列表。重建的开销很小过滤后可用的落子点不过几十个完全不需要担心性能。4.5 评估函数只算四个方向斜线漏判现象横着五连能判断竖着五连能判断斜着五连怎么都判不出来AI 输得莫名其妙。 原因CheckWin的方向数组里只有三个方向横、竖、主对角线副对角线方向漏了或者写成了重复的方向。 解决四个方向一定要检查全。我在第 2.3 节的方向数组里写的是{0,1}、{1,0}、{1,1}、{1,-1}四个缺一不可。你可以写一个单元测试方法在棋盘上手工摆出每一种五连形状调用CheckWin断言返回 true这是最笨也最有效的排错方式。5. 验证棋力与调权重用「被动防守测试」跑通全流程人机对战写完以后最怕的是「AI 看起来在动但是下得很蠢」。要确认 AI 真的在防守而不是瞎走我的习惯是做一个被动防守测试让 AI 执黑人类执白且走一个经典的「双三」套路——先在一处形成斜向活二再在另一处形成横向活二下一步就能形成双活三必胜。如果 AI 的评估函数正确它应该能主动堵住其中一个活三是关键。做法是把评估函数里LIVE_THREE的分值单独调高后观察 AI 是否优先防守而不是去远处下无关棋子。测试步骤不复杂第一步写一个人工棋谱脚本按固定坐标列表依次落子模拟一方故意做出双活三第二步让 AI 执黑并记录每一步选点第三步检查 AI 是否在对方形成活三前先堵住其中一个分支方向。这套测试跑通的标志是AI 在面对活三局面时胜率提高——不是随机堵而是选择了对方「活三变活四」最关键的那个缺口。验证完基本功再谈调权重。我常用的技巧是「逐步扰动法」把某一项分值乘以 0.5、1.5、2 分别跑同一局看胜率变化。比如把RUSH_FOUR从 100000 调到 200000AI 会变得爱下冲四而不是留活三。实际测试中我还发现SLEEP_THREE的分值不能给到 5000 以上否则 AI 会很在意那些只能形成冲四的棋型放弃真正的活三机会。想让 AI 更接近「真人棋感」我一般会加一个位置权重棋盘中心区域额外加 20 分边线减 10 分开局时 AI 会优先抢占中心而不会一上来就贴边。最后一个建议是不要迷信深度的数字。我见过有人把搜索层数加到 6 层结果每步等 10 秒棋力反而没有 2 层深度的 AI 强——因为评估函数不够准确时搜索越深越容易在错误的局面上浪费时间。这份 Visual Studio 五子棋项目能在两层搜索的基础上跑出不错的棋力靠的就是评估表足够稳。从那以后我每次调 AI都强制自己先跑一遍被动防守测试再调深度不然无论如何优化搜索都像在黑匣子里瞎猜。希望这套拆解能帮你在自己的棋盘上少走几个弯路。本文还有配套的精品资源点击获取