首页 / 资讯中心 / 文章详情

C# WinForms五子棋:AI评分算法与坐标换算实战解析

C# WinForms五子棋:AI评分算法与坐标换算实战解析 ★ FEATURED ARTICLE
简介这是一份基于Visual Studio与EasyX图形库实现的五子棋游戏完整源码面向正在学习C或Windows桌面开发、需要课程设计或实战练手的读者。项目同时包含人机对战和人人对战模式覆盖棋盘二维数组状态管理、合法落子判断、五子连珠检测、悔棋与重新开始等核心逻辑并配有界面图片与音效素材AI部分还提供了从穷举搜索到Minimax/Alpha-Beta剪枝的扩展思路。资源共49个文件压缩包约107MB主要包含cpp/h源码工程、sln/vcxproj工程配置、jpg/png界面与棋盘素材、wav音效以及pdb/tlog等调试记录文件下载后可打开完整工程对照阅读。已有2657人学习适合借此理解Windows窗体事件驱动编程、EasyX绘图交互和基础博弈算法也可作为五子棋课程设计、期末项目或自学C游戏开发的参考实现。1. 先用三分钟看懂这个五子棋项目双人对战和人机对战的完整闭环你在 Visual Studio 里按 F5 跑起来窗口弹出 15×15 棋盘鼠标点下去落子AI 几乎不用等你就能在 200 毫秒内回一手赢不了它的时候你甚至想掀桌子。这就是这个五子棋项目最直观的体验一个不依赖任何外部引擎、纯靠算法决策的 VS 人机对战五子棋。它适合三类人——拿它当课程设计交差的学生、第一次接触 WinForms 图形编程想找个完整练手项目的开发者、以及好奇「AI 到底是怎么想下一步棋」的棋类爱好者。项目代码量不大但把图形绘制、事件响应、输赢判定、AI 搜索这几块拼在了一起读完你能看到一条完整的数据流鼠标点击事件如何被转成棋盘坐标再变成胜负判定和 AI 决策的输入。2. 技术选型与工程结构WinForms GDI 的取舍2.1 为什么选 C# WinForms 而不是 C MFC在 Visual Studio 里写五子棋最古典的方案是 C 配 MFC但这类项目有一个通病非核心代码占比高消息映射、资源文件、对话框模板会吃掉大量时间真正研究棋盘逻辑的时间反而被压缩。我最初也尝试过 MFC 版本翻车点在于坐标换算和窗口消息循环纠缠在一起调试一个鼠标点击事件要在头文件和实现文件之间来回跳体验很分裂。换成 C# WinForms 之后整个实现思路清晰了很多。WinForms 帮我们封装了消息循环和事件模型你只需要关注 MouseClick、Paint 这些事件本身怎么写。GDI 的 Graphics 对象画直线、画椭圆、画实心圆都很直接不需要手动管理设备上下文。对于 15×15 的棋盘GDI 的绘制压力完全可以忽略不需要引入 DirectX 或者 Unity 级别的渲染方案。这个项目的核心复杂度集中在两块一是棋盘数据结构和胜负判定二是 AI 的落子决策图形层只是薄薄一层壳。我一般把这种项目的代码分成三层界面层Form1 负责事件和绘制、逻辑层GameBoard 负责棋盘状态和规则、AI 层AIPlayer 负责决策。三层之间用接口解耦以后你想把 WinForms 换成 WPF或者把 AI 改成网络对战只需要替换对应层不用动其他代码。2.2 工程目录结构与核心数据流整个解决方案的结构保持轻量核心文件就四个剩下的都是 VS 自动生成的模板文件FiveInARow/ ├── FiveInARow.sln └── FiveInARow/ ├── Program.cs // 程序入口 ├── Form1.cs // 主窗体绘制、鼠标事件、回合控制 ├── Form1.Designer.cs // 窗体设计器生成的初始化代码 ├── GameBoard.cs // 棋盘数据落子、胜负判定、重置 ├── AIPlayer.cs // AI 决策候选点生成、评分、选点 └── Properties/ └── AssemblyInfo.csGameBoard.cs 是整个项目的数据中枢内部维护一个int[,]二维数组表示棋盘状态对外暴露 TryPlaceStone、CheckWin、Reset 这些方法界面层和 AI 层都只跟它打交道。Form1 拿到鼠标点击坐标后先换算成行列编号再调用 GameBoard.TryPlaceStone 尝试落子如果落子合法且这一手形成了五连游戏结束否则轮到 AIForm1 调用 AIPlayer.GetNextMove 获取落子位置再落下去如此循环直到分出胜负或和棋。数据流清晰之后后续做悔棋、复盘、存档都只是在这条链路上加中间环节不需要改架构。这也是为什么我在下面讲核心代码时会从 GameBoard 开始讲起因为它决定了整个项目的运行逻辑。3. 核心模块拆解从鼠标点击到棋子落定的完整链路3.1 棋盘数据结构与坐标换算棋盘的物理形态是 15×15 的交叉点阵对应到代码里就是一个二维数组。数组的值只有三种状态0 表示空位1 表示黑子玩家2 表示白子AI。为什么用int而不是bool或者枚举因为后面 AI 评分时要区分「空位」「己方棋子」「对方棋子」三种情况int最直观扩展禁手规则时再补一个状态位也不难。public class GameBoard { public const int BoardSize 15; public int[,] Grid new int[BoardSize, BoardSize]; // 尝试在 (row, col) 落子color: 1黑 2白 public bool TryPlaceStone(int row, int col, int color) { if (row 0 || row BoardSize || col 0 || col BoardSize) return false; // 越界直接拒绝 if (Grid[row, col] ! 0) return false; // 已有棋子非法落子 Grid[row, col] color; return true; } public void Reset() { for (int i 0; i BoardSize; i) for (int j 0; j BoardSize; j) Grid[i, j] 0; } }这段代码定义了棋盘空间的基本规则。越界检查和重复落子检查是落子合法性的两道闸门缺一不可。很多初学者只做数组赋值不做检查导致后面 AI 把子下到已有棋子上或者点击棋盘边缘时直接抛 IndexOutOfRangeException。TryPlaceStone 返回 bool 的设计是有意的——调用方需要通过返回值判断这步棋是否被接受而不是自己再去查一次数组。坐标换算在 Form1 里完成核心是一段简单的除法运算private const int CellSize 30; // 每个格子的像素宽度 private const int Margin 20; // 棋盘边缘到第一根线的距离 private bool TryGetCell(MouseEventArgs e, out int row, out int col) { // 鼠标坐标减去边距后除以格子宽度得到行列索引 int x e.X - Margin; int y e.Y - Margin; row y / CellSize; col x / CellSize; // 校验坐标是否在有效范围内并且靠近交叉点而不是格子中央 if (row 0 || row GameBoard.BoardSize || col 0 || col GameBoard.BoardSize) return false; if (x % CellSize 12 x % CellSize 18) // 粗略过滤避免点偏 return false; if (y % CellSize 12 y % CellSize 18) return false; return true; }TryGetCell里的第二个判断是血泪教训换来的。棋子画在交叉点上但鼠标点击没有那么精准玩家点到格子中间时如果不加过滤就会出现「棋子落在两根线之间」的违和感。不过这个过滤条件不是硬性规则如果你觉得手感太严格可以把 12 和 18 改成 8 和 22容错范围更大。核心思路是坐标换算不追求数学上的精确而是追求手感上的合理。3.2 胜负判定四方向扫描的边界处理五子棋的胜负规则很简单同一颜色棋子在横、竖、左斜、右斜四个方向之一连成五子。public bool CheckWin(int row, int col, int color) { // 四个方向向量(行偏移, 列偏移) int[,] directions { { 0, 1 }, // 横向 { 1, 0 }, // 纵向 { 1, 1 }, // 左斜 { 1, -1 } // 右斜 }; for (int d 0; d 4; d) { int count 1; // 当前棋子计 1 // 向正方向数 for (int step 1; step 5; step) { int nr row directions[d, 0] * step; int nc col directions[d, 1] * step; if (nr 0 || nr BoardSize || nc 0 || nc BoardSize) break; if (Grid[nr, nc] ! color) break; count; } // 向反方向数 for (int step 1; step 5; step) { int nr row - directions[d, 0] * step; int nc col - directions[d, 1] * step; if (nr 0 || nr BoardSize || nc 0 || nc BoardSize) break; if (Grid[nr, nc] ! color) break; count; } if (count 5) return true; } return false; }这里最容易踩的坑是只写四个方向忽略了每个方向需要双向累计。比如左斜方向既要往右上数也要往左下数两边的子都要统计进来否则你会出现「明明连成五子了却判不出胜负」的尴尬情况。另外一个常见问题是数组越界数到边界时要用 break 跳出而不是 continue否则访问越界直接抛异常。这段代码的时间复杂度是 O(1)每步落子最多数 32 个位置实际运行几乎无感。4. AI 落子模块拆解棋型评分与一层前瞻搜索4.1 棋型评分表AI 的「局势理解力」AI 的核心问题只有一个给定一个空位置我在这里落子到底值多少分这个值靠棋型评分来量化。棋型是五子棋领域对「连子形态」的统称比如活三、冲四、活四每种棋型威胁程度完全不同我整理了一张权重表棋型含义分值五连已经形成五子连珠100000活四两端都开放的四连必赢50000冲四一端被堵的四连10000活三两端开放的三连发展性强5000眠三一端被堵的三连1000活二两端开放的二连500眠二一端被堵的二连100这个表的数值是我实际调过的不是随便拍的。五连和活四的分数差距设置得很大是为了让 AI 优先追胜而不是贪图布子——如果两个分数只差几倍AI 可能为了做三连而放弃已经到手的必胜手。活三比冲四低一档是因为在实战中冲四往往能逼迫对方防守而活三还能再发展一步。具体权重你可以改改完之后 AI 的棋风会明显变化——把活三的分数提到 20000AI 会变得更加激进更喜欢做棋型威胁而不是稳健防守。4.2 候选点生成与落子决策AI 决策算法不用一上来就上蒙特卡洛或者完整 minmax 搜索五子棋的搜索空间是 15×15225 个位置直接全盘遍历加上深度搜索在单线程下必然卡顿。项目里采用的是「启发式候选点 一层前瞻」的组合方案效果和速度兼顾public class AIPlayer { private GameBoard _board; public (int row, int col) GetNextMove() { // 候选点生成只在有棋子的周围找空位 var candidates GenerateCandidatePoints(); if (candidates.Count 0) return (7, 7); // 棋盘全空时下天元 (int row, int col) bestMove (0, 0); int bestScore int.MinValue; foreach (var move in candidates) { // 我方落子 _board.Grid[move.row, move.col] 2; int myScore EvaluatePosition(move.row, move.col); // 模拟对方最强应手计算对方的威胁分 var opponentMove GetOpponentBestMove(move); int opponentThreat 0; if (opponentMove.row ! -1) { opponentThreat EvaluatePosition(opponentMove.row, opponentMove.col); } // 综合分 我方收益 - 对方威胁 int totalScore myScore - opponentThreat * 2; // 撤销落子 _board.Grid[move.row, move.col] 0; if (totalScore bestScore) { bestScore totalScore; bestMove move; } } return bestMove; } private List(int row, int col) GenerateCandidatePoints() { var points new List(int row, int col)(); for (int r 0; r GameBoard.BoardSize; r) { for (int c 0; c GameBoard.BoardSize; c) { if (_board.Grid[r, c] ! 0) continue; // 只找空位 // 距离这个空位两格内有棋子才考虑它 bool nearStone false; for (int dr -2; dr 2 !nearStone; dr) { for (int dc -2; dc 2 !nearStone; dc) { int nr r dr, nc c dc; if (nr 0 nr GameBoard.BoardSize nc 0 nc GameBoard.BoardSize _board.Grid[nr, nc] ! 0) { nearStone true; } } } if (nearStone) points.Add((r, c)); } } return points; } }这段代码的逻辑分为三步。第一步GenerateCandidatePoints 用「两格邻域」把候选点的数量从 225 降到通常的 3050 个这一步大幅压缩了后续评分的时间。为什么是两格不是三格因为五子棋中距离已有棋子两格以上的空位在一层前瞻内几乎不可能产生决定性的棋型变化过滤掉不会影响 AI 水平。第二步对每个候选点先假设 AI 落子用 EvaluatePosition 算这步棋能形成多好的棋型。然后把 AI 的落子撤销换成模拟对手下最强应手——这个最强应手用同样的 EvaluatePosition 来评估所有可落点取最大值。第三步是综合评分的关键myScore - opponentThreat * 2。这里把对手威胁的权重乘了 2意思是 AI 会更保守——当我的进攻收益和对手的反击威胁一样大时AI 会选择不冒险先堵住对手的路。这个系数也是调出来的乘 2 时 AI 防守倾向明显乘 1 时 AI 更嗜血但容易被反杀。你可以在实际对战中去感受这个参数的变化。EvaluatePosition 的实现细节同样值得推敲它对横竖斜四个方向分别扫描每一条线上统计连子数、两端是否有空位映射到棋型表private int EvaluatePosition(int row, int col) { if (_board.Grid[row, col] 0) return 0; int color _board.Grid[row, col]; int score 0; int[,] directions { { 0, 1 }, { 1, 0 }, { 1, 1 }, { 1, -1 } }; for (int d 0; d 4; d) { int count 1; int openEnds 0; // 两端开放的空位数量 // 正方延伸 int nr row directions[d, 0], nc col directions[d, 1]; if (nr 0 nr GameBoard.BoardSize nc 0 nc GameBoard.BoardSize) { if (_board.Grid[nr, nc] color) count; else if (_board.Grid[nr, nc] 0) openEnds; } // 反方延伸 nr row - directions[d, 0]; nc col - directions[d, 1]; if (nr 0 nr GameBoard.BoardSize nc 0 nc GameBoard.BoardSize) { if (_board.Grid[nr, nc] color) count; else if (_board.Grid[nr, nc] 0) openEnds; } // 根据连子数和开放端数查棋型表 if (count 5) score 100000; else if (count 4 openEnds 2) score 50000; else if (count 4 openEnds 1) score 10000; else if (count 3 openEnds 2) score 5000; else if (count 3 openEnds 1) score 1000; else if (count 2 openEnds 2) score 500; else if (count 2 openEnds 1) score 100; } return score; }这是一个简化版本的评估它只统计当前落子点所在方向的连子数量没有对整条线上的所有棋型做全局分析。够不够用对于课程设计和入门级人机对战够用。它能识别出常见的活三冲四威胁在大多数对局里能走出合理的攻防。缺陷是它对「跳活三」这类中间空一格的棋型感知偏弱如果你发现 AI 漏防了跳三可以在 EvaluatePosition 里增加一个「中间隔一空位」的检测分支数连子时跳过空位继续数。5. 避坑记录五个最容易翻车的细节五子棋这个项目看着简单实际写起来有不少隐蔽的坑。我把自己踩过的记录下来每个都按「现象 → 原因 → 解决」写清楚。5.1 落子时窗口疯狂闪烁现象拖动窗口或连续快速落子时棋盘区域闪得厉害棋子像在跳踢踏舞。原因WinForms 默认的 Paint 机制是清空背景再重绘高频刷新时残帧交替就产生闪烁。解决在 Form1 构造函数里设置DoubleBuffered true让控件先在内存缓冲区绘制完整画面再一次性输出如果还闪可以重写OnPaintBackground让它不做任何事。5.2 AI 计算导致窗体假死现象点下鼠标后整个窗口变成「未响应」状态过上几秒才恢复。原因AI 的评分逻辑是同步跑在 UI 线程里的计算量大时会阻塞消息处理。解决把 AI 计算放到后台线程Task.Run(() aIPlayer.GetNextMove())算完再通过Invoke回到 UI 线程落子。注意在计算期间要设置一个_isAIThinking标志禁止玩家在这段时间内继续落子否则会出现玩家连点两下、AI 还没响应的情况。5.3 明明连成五子却判不出胜利现象棋子已经在棋盘上形成五连但程序没有任何反应下一个玩家还能继续落子。原因CheckWin 只检查了落子点本身延伸出去的方向没有做双向累计。比如你在横向上有左 3 子右 1 子count 只数到 2。解决按 3.2 节的做法每个方向向正反两个方向分别延伸统计后相加再判断 count 是否大于等于 5。另外每步落子后只检查新落子点的四个方向就足够不需要全盘扫描。5.4 AI 把子下到已有棋子上现象AI 返回的落子位置跟棋盘上已有的棋子重合棋子覆盖在一起。原因GenerateCandidatePoints 里没有过滤空位把已经落子的位置也当成了候选点或者评估之后忘了撤销模拟落子把棋盘的网格状态改坏了。解决候选点生成时先做Grid[r, c] ! 0的空位检查每次模拟落子和撤销之间用 try-finally 包裹保证异常路径下棋盘也能恢复原状。我还在调试时加过一个防御if (_board.Grid[move.row, move.col] ! 0) continue;5.5 鼠标点击位置和棋子落点对不齐现象鼠标点在线交叉点旁边棋子落到旁边的交叉点上视觉上差半个格子。原因坐标换算是用e.X / CellSize直接取整当鼠标实际点击位置落到格子内部而不是交叉点上时取整结果会落到相邻的交叉点。解决如 3.1 节所示加一个余数过滤落在格子中央附近的点击直接忽略玩家自然会刻意往交叉点点手感就顺了。另一种变通方案是画棋子前做四舍五入但那样会让棋子「跳」到最近的点上玩起来反而容易误触。6. 改造思路把五子棋扩展成自己的项目五子棋这个项目的改造空间比表面看起来大得多。最直接的一档是调 AI 行为——把 AIPlayer 里的棋型分值、前瞻深度、威胁权重抽出来做成配置项放到窗体的某个区域让玩家自己拉滑块。选手通过调节活三和冲四的权重比能明显感觉到 AI 从「稳健防守型」变成「激进进攻型」这个交互效果对演示项目来说是很大的加分项。具体做法是把权重表放进一个Dictionarystring, intAI 每次评分前从配置读取不需要重新编译。第二档是加悔棋和复盘功能。悔棋在数据结构上只需要一个栈每次落子前把当前棋盘状态压进去private Stackint[,] _history new Stackint[,](); // 落子前保存快照 _history.Push((int[,])_board.Grid.Clone()); _board.TryPlaceStone(row, col, currentPlayer); // 悔棋时恢复 if (_history.Count 0) { _board.Grid _history.Pop(); // 如果悔的是 AI 的棋需要连续弹两次 }这里的坑是Clone()必须做深拷贝直接把Grid的引用压栈的话后面所有修改都会同步改动栈里的「快照」悔棋就失效了。复盘功能可以在这个基础上扩展把每一手棋的行列存成一个 List界面加一个播放按钮就能实现简单的棋谱回放。第三档是网络对战。WinForms 的消息驱动模型天然适合做客户端把 Form1 的落子事件里加一个SendMove(row, col)调用另一端用 TcpListener 接收消息后在自己的棋盘上落子双方棋盘状态就能同步。服务端的挑战是并发处理客户端断开时要做异常处理不然主线程会卡死。这套改造会超出五子棋本身的逻辑范围但对想要学习网络编程的开发者来说是个很好的过渡项目。最后如果你想把 AI 再往深做可以尝试在现有评分函数上叠加一层递归搜索。把 GetNextMove 里的单层前瞻改成两层即「我方落子 → 对方最佳应手 → 我方再次应手」评分方式从每个候选点评估一次变成每个候选点评估对方的所有可能应手时间复杂度会上涨一个数量级但 AI 的棋力肉眼可见地提高能防住一些靠单层前瞻看不出来的陷阱。做这个改造时注意一定要保留当前胜负判定作为剪枝条件——搜索到一方已经五连时直接返回极值否则搜索深度稍微加深就会卡顿。这个项目我后来自己重写过好几遍每次换一种 UI 框架或者加一个功能都会重新踩一遍坐标换算和双缓冲这些基础的坑。从那以后我每次写绘图类项目都强制把双缓冲、越界检查、状态恢复这三件事先写好再开始画第一根线省下的调试时间远超写这些代码的时间。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站