简介:一款基于Visual Studio与EasyX图形库实现的五子棋游戏完整项目,面向正在学习C++桌面开发、图形界面编程或初级AI算法的开发者,也适合课程设计、毕业设计参考。项目围绕人机对战与人人对战双模式展开,人机部分采用基于穷举搜索并融合Minimax剪枝思路的AI,能模拟不同难度落子;人人模式则完整演示了棋盘状态管理、合法落子判断、五子连珠胜负检测、悔棋与重新开始等功能,可从中理解Win32窗口与鼠标事件的配合方式。压缩包共49个文件,以cpp源文件、h头文件、sln与vcxproj工程配置、jpg/png图片素材、wav音效文件为主,同时保留pdb、tlog等构建调试记录,便于直接打开工程复现整个项目。整体约107MB,已有2657人学习下载。对照源码与界面素材,读者可快速掌握在Visual Studio中搭建Win32图形程序、使用EasyX绘制棋盘棋子、实现交互逻辑与简单博弈算法的完整流程,是一份结构清晰、可直接编译运行的学习型项目。
1. 用 VS 写五子棋人机对战:这份资源到底能让你少走多少弯路
做过棋类小游戏的人应该都有体会,五子棋看起来规则简单,真正动手做的时候,棋盘坐标、胜负判断、人机策略、界面刷新这些环节一层套一层,任何一个地方没理顺,都会在后期调试时反复推倒重来。这份 Visual Studio 实现五子棋(含人机对战)资源,核心是给出一套可以在 VS 里直接编译运行的完整工程,覆盖双人对战和人机对战两条主线。我拆完以后的结论是:它更适合两类人,一是刚学完 C# 基础、想做一个完整项目练手的人,二是在校作业或简历项目里需要棋类 Demo 的人。它不追求极致的 AI 棋力,但把“界面 + 逻辑 + 搜索”的骨架搭得清清楚楚,照着复现能节省大量从零开始的时间。
2. 把棋盘和落子逻辑先做扎实:坐标换算与对局状态管理
2.1 棋盘控件与数据结构的选择
五子棋的棋盘通常用 PictureBox 或自绘 UserControl 承载,这个资源用的是自绘控件方式。相比直接用 PictureBox 加背景图,自绘的优势在于鼠标交互和格子对齐更精确,放大缩小时也不会出现图片模糊导致落子偏移。核心数据结构是二维数组,常见的定义是int[,] board = new int[15,15],0 表示空位、1 表示黑子、2 表示白子。
private int[,] board = new int[15, 15]; private bool isBlackTurn = true; private bool isPlayerBlack = true; public void ResetBoard() { Array.Clear(board, 0, board.Length); isBlackTurn = true; // 重绘棋盘 chessBoardControl.Invalidate(); }这段代码里,board是整个对局的核心数据源,所有落子、判断、悔棋操作都围绕它展开。isBlackTurn控制当前轮到谁落子,isPlayerBlack则是人机模式下玩家执黑还是执白。这里有个设计决策值得注意:把棋盘数据和控件分离,UI 只负责渲染和捕获鼠标,逻辑层只读写数组。这样后期如果要加存档、回放或者网络对战,不需要改动渲染代码。
2.2 鼠标坐标到棋盘格子的映射
鼠标点击坐标换算成棋盘行列号,是所有棋类程序里最容易出暗坑的地方。边界情况下差一两个像素,落子就会偏到格子线上,看起来非常别扭。
private void ChessBoard_MouseClick(object sender, MouseEventArgs e) { // 假设每个格子大小为 cellSize,棋盘左上角留出 margin 像素空白 int col = (e.X - margin) / cellSize; int row = (e.Y - margin) / cellSize; if (col < 0 || col >= 15 || row < 0 || row >= 15) return; if (board[row, col] != 0) return; // 执行落子逻辑 PlacePiece(row, col); }这里margin是棋盘外边框的留白,cellSize是每个格子占用的像素数。换算公式的本质是取整运算,但问题是(e.X - margin) / cellSize在鼠标点击到格子边缘附近时,取整结果可能和玩家视觉判断不一致。常见解决做法是在取整前先加上半个格子大小,让判定范围以格子中心为准。我一般会写成int col = (e.X - margin + cellSize / 2) / cellSize;,这样鼠标点在格子左半边时归到当前列,右半边时归到下一列,手感更符合预期。
2.3 对局状态流转:人机切换与回合控制
双人对战和人机对战的区别,本质上是“白方落子权”是交给另一个玩家还是交给算法。资源里用枚举或布尔值控制模式切换,落子后检查当前模式,如果是人机模式且轮到 AI,就启动后台计算并延时落子。
private void PlacePiece(int row, int col) { board[row, col] = isBlackTurn ? 1 : 2; chessBoardControl.Invalidate(); if (CheckWin(row, col)) { // 弹出胜利提示,锁定棋盘 return; } if (isVsComputer && !isBlackTurn) { // 交给 AI 落子,这里用异步避免界面卡死 Task.Run(() => AIThinkAndMove()); } else { isBlackTurn = !isBlackTurn; } }注意这里人机模式下黑色方落完以后,直接把 AI 调用放到异步线程里,这是避免 AI 搜索耗时导致窗口假死。很多人机对战小项目翻车就是卡在这一步:AI 的搜索耗时超过几百毫秒后,界面会变成白屏状态。资源里用的 Task.Run 配合 Invoke 做 UI 更新,在第五章我会详细说这个坑的完整表现和解决办法。
3. 胜负判断与棋型评估:从规则判定到 AI 落子依据
3.1 四方向扫描式胜负判定
五子棋的胜负判断,常规思路是落子后从当前位置出发,沿着水平、垂直、两条对角线共四个方向,分别向两边延伸统计相同棋子的连续数目。这里有个效率细节:不需要每次扫全盘,只需要围绕刚落下的那颗棋子检测即可。
private bool CheckWin(int row, int col) { int[] dx = { 1, 1, 0, -1 }; int[] dy = { 0, 1, 1, 1 }; for (int i = 0; i < 4; i++) { int count = 1; // 正方向统计 for (int step = 1; step < 5; step++) { int nr = row + dx[i] * step; int nc = col + dy[i] * step; if (nr < 0 || nr >= 15 || nc < 0 || nc >= 15) break; if (board[nr, nc] != board[row, col]) break; count++; } // 反方向统计 for (int step = 1; step < 5; step++) { int nr = row - dx[i] * step; int nc = col - dy[i] * step; if (nr < 0 || nr >= 15 || nc < 0 || nc >= 15) break; if (board[nr, nc] != board[row, col]) break; count++; } if (count >= 5) return true; } return false; }四个方向的方向向量(dx, dy)只用了四个组合,这是因为水平和垂直加上两条对角线,总共就是这四个方向。每条方向只需要向两端延伸最多 4 格就能判断是否达到五连,因为超过五格已经没有意义。步长控制为 5,配合越界判断,能避免数组访问越界。这个实现的好处在于它不需要维护额外的连子状态,每次落子后独立计算,逻辑简单不容易出错。
3.2 棋型编码:活三、冲四、眠三到底代表什么
人机对战的 AI 部分,棋力高低核心取决于棋型评估函数。资源里的做法是扫描棋盘上所有可能的连子线段,对每种棋型打一个分数。常见棋型包括五连、活四、冲四、活三、眠三、活二、眠二,每种的威胁程度依次递减。
private int EvaluateLine(int[] line) { // line 表示以某个点为中心,某一方向上的连续棋子片段 int score = 0; // 判断黑白双方在片段中的分布情况 bool hasBlack = false, hasWhite = false; int blackCount = 0, whiteCount = 0; int emptyCount = 0; foreach (int cell in line) { if (cell == 1) { hasBlack = true; blackCount++; } else if (cell == 2) { hasWhite = true; whiteCount++; } else emptyCount++; } if (hasBlack && hasWhite) return 0; // 双方都有子,无威胁 if (blackCount == 5 || whiteCount == 5) return 100000; // 活四:四个己方子 + 两个空位,且两边没有被堵 if (blackCount == 4 && emptyCount == 2) score += 10000; else if (blackCount == 3 && emptyCount == 3) score += 2000; // 白子同理,但权重略低 if (whiteCount == 4 && emptyCount == 2) score += 9000; else if (whiteCount == 3 && emptyCount == 3) score += 1500; return score; }这个评估逻辑是简化版,严格来说棋型判断需要结合具体位置关系,例如活三和眠三的区别在于两端是否都被对方棋子堵住。资源里的处理方式是先扫描一整条线,统计空位数和双方棋子数,粗略判断威胁程度。这种方法在工程上够用,但因为没考虑“断点”形态,对跳三、跳四这类棋型识别不精确。如果你想让 AI 更强,后续可以在这里扩展模式匹配,把连续两子空一位再一子的跳棋型单独编码。
3.3 评估函数如何决定 AI 落子
评估函数在 AI 中的用法,不是对单点打分,而是对“如果 AI 在这里落一颗子”之后的局面打分。常见做法是对每个空位做假设落子,然后重新扫描整个棋盘,计算所有四方向线段的总分。资源的 AI 逻辑把这个过程封装成了GetBestMove,遍历空位并返回得分最高的位置。
private Point GetBestMove() { int bestScore = -1; Point bestMove = Point.Empty; for (int r = 0; r < 15; r++) { for (int c = 0; c < 15; c++) { if (board[r, c] != 0) continue; // 假设 AI 在 (r, c) 落子,计算局面总分 board[r, c] = aiColor; int score = EvaluateBoard(); board[r, c] = 0; if (score > bestScore) { bestScore = score; bestMove = new Point(c, r); } } } return bestMove; }这里EvaluateBoard会对整个棋盘所有方向的线段评分求和,复杂度不算高,15 乘 15 的棋盘遍历所有空位并重新评分,耗时在一百毫秒以内,所以落后在可接受范围。如果你要提速,可以只扫描距离最近落子点两步以内的区域,因为远处棋型在单步评估中不会改变。资源里没做这个优化,但结构已经留好了接口,改起来不算麻烦。
4. 搜索层与剪枝:Alpha-Beta 参数调优和性能边界
4.1 为什么单层评估不够,需要多层搜索
单层评估只考虑了“我下一步走哪最好”,没有考虑“我走了以后对手会怎么回应”。对付新手够用,但想要棋力看起来像一个正常人类玩家,至少需要两层到四层的搜索。资源里给的是 Alpha-Beta 剪枝搜索,这是绝大多数棋类 AI 的基础框架。它的核心思想是维护两个边界值,Alpha 代表当前玩家能保证的最低分,Beta 代表对方能保证的最高分,一旦某个子树的分值落在这个区间之外,就剪掉不再搜索。
private int AlphaBetaSearch(int depth, int alpha, int beta, bool isMaximizing) { if (depth == 0) return EvaluateBoard(); List<Point> moves = GenerateCandidateMoves(); if (isMaximizing) { int maxEval = int.MinValue; foreach (Point move in moves) { board[move.Y, move.X] = aiColor; int eval = AlphaBetaSearch(depth - 1, alpha, beta, false); board[move.Y, move.X] = 0; maxEval = Math.Max(maxEval, eval); alpha = Math.Max(alpha, eval); if (beta <= alpha) break; // 剪枝 } return maxEval; } else { // 对手回合,取最小分值,逻辑对称 } }纸上推导 Alpha-Beta 的时候觉得不难,实际写的时候最容易出问题的点是返回值是在某一层的视角下是“我自己的收益”还是“从根节点视角的收益”。这个资源里的实现,每一层都以当前局面的视角返回分值,然后在回溯时做正负号转换,代码简洁但容易看晕。建议调试时在返回语句处打印深度和分值,确认每一层的正负方向一致。
4.2 候选点生成:全部空位搜索还是局部搜索
老实说,15 乘 15 全空位加四层深度的 Alpha-Beta,在普通机器上大概率会卡到两三秒以上,这对桌面小游戏来说已经明显影响交互体验了。所以资源里做了一层优化:只搜索已有棋子周围一定范围内的空位。
private List<Point> GenerateCandidateMoves() { List<Point> candidates = new List<Point>(); bool[,] visited = new bool[15, 15]; for (int r = 0; r < 15; r++) { for (int c = 0; c < 15; c++) { if (board[r, c] == 0) continue; // 围绕已有棋子的棋盘空位扩展两格 for (int dr = -2; dr <= 2; dr++) { for (int dc = -2; dc <= 2; dc++) { int nr = r + dr; int nc = c + dc; if (nr >= 0 && nr < 15 && nc >= 0 && nc < 15) { if (board[nr, nc] == 0 && !visited[nr, nc]) { visited[nr, nc] = true; candidates.Add(new Point(nc, nr)); } } } } } } // 如果棋盘为空,直接落中心点 if (candidates.Count == 0) candidates.Add(new Point(7, 7)); return candidates; }扩展半径 2 的逻辑是假设影响范围不会超出两格,这个假设在大多数局面下成立,但开局阶段存在特例。实际测试中,如果第一手没有落在中心附近,候选点可能覆盖不到棋盘的另半边,导致 AI 漏掉关键位置。我一般会在候选点生成后再补上几个固定点,比如棋盘的四个角和四条边的中点,确保搜索覆盖完整。
4.3 搜索深度、节点数和响应时间的权衡
资源默认的搜索深度是 4 层,实测单步耗时在一秒到三秒之间波动。这个波动主要取决于候选点的数量和剪枝效果。如果对手下出较多散落的棋子,候选点会变多,搜索时间会明显上升。可以观察的直观指标是候选点数量:超出二十个以后,四层搜索的耗时基本就突破两秒了。
要提速的话,除了缩小扩展半径,还可以用置换表缓存已经计算过的局面,或者把评估函数里的重复计算优化掉。资源自带的评估函数每次重新扫描全盘,这在搜索内层会被反复执行,开销很大。一个更高效的替代方案是增量更新:在递归过程中只重算受影响的行列对角线。
// 增量评估的关键思路:落子和撤销时只更新当前行、列、两条对角线 private void UpdateLines(int row, int col, int delta) { // delta 为 +1 表示落子,-1 表示撤销 // 仅重新计算包含 (row, col) 的所有线段,而不是全盘扫描 }资源里用的还是全量扫描,但你在复现后可以自己加这个增量逻辑,棋力不变的情况下速度至少翻倍。不过要注意,增量更新在悔棋和撤销时要同时维护额外的计分缓存,忘记清理缓存会导致评估分越算越偏,是这类优化最常见的翻车点。
5. 避坑指南:VS 五子棋实现中常见的五个翻车现场
5.1 AI 搜索导致界面卡死
现象:点击落子后,窗口立即进入“无响应”状态,标题栏出现“未响应”,持续 1 到 3 秒后恢复。原因:AI 的搜索逻辑直接写在 UI 线程的事件处理函数里,for循环遍历空位递归调用,UI 线程被阻塞无法处理重绘消息。解决:把 AI 计算移出 UI 线程,用Task.Run或BackgroundWorker,完成后通过Invoke回到 UI 线程更新棋盘。
private void AIThinkAndMove() { Point bestMove = AlphaBetaSearch(4, int.MinValue, int.MaxValue, true); this.Invoke(new Action(() => { PlacePiece(bestMove.Y, bestMove.X); })); }这里Invoke的作用是确保棋盘控件只能在 UI 线程上更新,跨线程直接修改board数组虽然不一定崩,但控件重绘方法Invalidate会抛异常。加入延迟落子的话,可以在Task.Run里先Thread.Sleep(200)模拟思考时间,否则 AI 落子过快,看起来不太自然。注意不要在Task.Run里直接访问chessBoardControl的属性,所有 UI 操作必须回到主线程。
5.2 棋型评估分方向不一致导致 AI 出现“盲区”
现象:AI 在某些局面下明明能四连获胜,却去堵对手的三连,或者不挡对手的冲四导致直接输棋。原因:评估函数对四个方向的扫描方式不对称,某个方向的线段漏掉了跳子形态,导致该方向上的威胁评分偏低。解决:把四个方向的扫描代码提取成同一个方法,只传入方向向量,强制保证四条路径走完全相同的判断逻辑。更彻底的方案是构造单测用例,针对活三、冲四、跳三各摆几个棋形,分别打印EvaluateLine的输出,比对是否符合预期。
5.3 悔棋功能把棋子删错位置
现象:连续悔棋两次后,棋盘出现“幽灵子”或者 AI 认为的棋局和显示不一致。原因:悔棋时只改board数组,没有同步更新 AI 搜索时依赖的历史记录栈;或者棋盘数组与 UI 重绘的顺序不对,数组已经回退但控件还是旧画面。解决:用一个Stack<MoveInfo>保存每步的落子方和坐标,悔棋时先弹栈,再根据栈里的数据恢复棋盘。
Stack<MoveInfo> history = new Stack<MoveInfo>(); private void Undo() { if (history.Count < 2) return; // 先撤销玩家的一步 MoveInfo playerMove = history.Pop(); board[playerMove.Row, playerMove.Col] = 0; // 如果当前是人机模式,再撤销 AI 的一步 if (isVsComputer && history.Count > 0) { MoveInfo aiMove = history.Pop(); board[aiMove.Row, aiMove.Col] = 0; } chessBoardControl.Invalidate(); }悔棋逻辑的复杂度在于人机模式需要一次性回退两步,否则回合制就乱了。另外,悔棋后 AI 的搜索缓存如果没有失效,AI 会基于旧局面做计算,导致落子逻辑错乱。最简单的处理是在悔棋后强制清空所有缓存标志位,或者干脆每一次搜索都不复用历史结果,多花几十毫秒换稳定性。
5.4 窗口缩放后棋盘格子对不上
现象:最大化窗口后鼠标落点偏移,点击的位置和棋子实际出现的位置隔了一格。原因:绘制的cellSize用固定像素,但窗口拉伸后棋盘区域的实际宽度变了,鼠标坐标换算时还用旧的cellSize。解决:每次重绘时根据控件实际尺寸重新计算cellSize,换算鼠标坐标时也使用实时值。
private int GetCellSize() { return Math.Min(chessBoardControl.Width, chessBoardControl.Height) / 15; }注意GetCellSize要在MouseClick事件里调用,不能缓存到字段里然后在Resize时更新,因为Resize事件和实际重绘之间有时间差,快速拖拽窗口时缓存值很容易过期。我一般连背景棋盘线的绘制也用这个实时值,保证棋子、格线、鼠标换算三个层面用的是同一套计算。
5.5 胜负判断出现“斜线赢了却不认”的情况
现象:横线或竖线五连能正常判定,但斜线五连没有反应。原因:方向向量写错了,四条方向中有一条斜率算反,导致斜线方向扫到的不是对角相邻的格子。解决:把四个方向向量统一列在表格里逐一对地排查,重点确认dx和dy的符号组合覆盖了两条对角线。
检查方向向量组合:(1,0)水平向右、(0,1)垂直向下、(1,1)主对角线、(1,-1)副对角线。注意副对角线的dy是-1,如果错写成1,扫描到的位置就是另外一条斜线。这个错在单步调试里很难看出来,因为只有副对角线上的五连才触发,平时下棋很少会注意到。快速排查办法是写一个硬编码棋局:在副对角线上摆五颗同色子,调用CheckWin看返回结果即可。
6. 把 AI 调强一点:评估函数可视化与搜索调参的实操技巧
很多人在拿到资源后,试着跑了几盘,赢了菜鸟水平就放下了。但如果你想把这个作品当成简历项目或者课程设计来交,AI 的棋力表现是区分“套模板”和“理解了实现”的关键。这里分享一个我调 AI 时必做的土办法:把每一步 AI 的九个候选位置及其评分打印出来。
private void DebugScores(List<Point> candidates) { StringBuilder sb = new StringBuilder(); foreach (Point p in candidates) { board[p.Y, p.X] = aiColor; int attackScore = EvaluateBoard(); board[p.Y, p.X] = 0; board[p.Y, p.X] = playerColor; int defendScore = EvaluateBoard(); board[p.Y, p.X] = 0; sb.AppendLine($"({p.X},{p.Y}) 攻:{attackScore} 守:{defendScore}"); } System.Diagnostics.Debug.WriteLine(sb.ToString()); }这段代码的作用是把每个空位“我方落子后”和“对方落子后”两个维度的评分并列输出。排错时你会发现很多看起来不合理的落子,原因都是防守分算得太高,或者进攻分的某种棋型被重复计分。如果发现 AI 老是去堵对手的二连而不顾自己已经形成的活三,多半是EvaluateLine里活三分支返回值设置过低,或者连续棋型判断里把活三错算成了眠三。此时不需要改搜索层参数,只需要回到棋型函数调整分值权重。
调分值的时候建议先把五连设为十万,活四一万,冲四八千,活三两千,眠三五百,活二一百,然后跑几局,观察 AI 是否出现以下行为:能四连时果断下赢棋位置、对方有冲四时必须立刻堵点、对方有活三时优先在活三两端落子而不是下无关位置。如果这些行为都正常,就可以逐级拉高活三和冲四的相对分值,观察 AI 是否更喜欢通过做活三来制造攻势。
另外一个实用技巧是控制搜索深度在 4 和 6 之间切换测试。同一个棋盘状态下,跑一次深度 4,记录落子位置和耗时,再跑一次深度 6,对比两者选的位置是否一致。如果深度 6 选的位置和深度 4 差得很远,说明评估函数的棋型权重存在局部盲区;如果两个深度选的位置一致但耗时差了三倍以上,说明剪枝效率不高,候选点生成器可以进一步缩小范围。
资源里的 AI 模块没有做开局库和残局库,这是它的边界。如果你想要 AI 在开局阶段更像人类棋手,最简单的做法是维护一个固定的前五步落子表,比如第一步中心、第二步根据玩家落子选择角位或侧翼,五步以后才进入搜索。这样实现成本很低,但观感上会明显比纯搜索更自然。我后来在某个跨平台系统里做棋类模块时,沿用了一模一样的思路:先用规则表垫底,再让搜索层接管中后盘,效果好过单纯堆搜索深度。
UI 线程和搜索线程的时间配合也算一个进阶技巧。棋力提升后,单层搜索耗时可能到两三秒,这期间玩家感觉游戏“卡住了”。与其让玩家干等,不如在到达搜索深度上限前先快速跑一个深度 2 的搜索结果垫底,然后后台慢慢加深搜索,等结果算完再覆盖落子。这个“先快后准”的策略我用在多个游戏 Demo 里,效果比加一个进度条更好。实现方式也不复杂:第一次搜索完成后如果还没到限制时间,就自动在当前局面基础上多搜两层。
这套五子棋资源整体来说,不算那种开箱即用到惊艳的成品,但作为学习和复现的底子,代码结构清晰,扩展点够多,适合在拿到后做二次开发。我每回给团队成员交接类似资源时都会强调:先原封不动跑通一遍,再做至少一处自己和需求相关的改动,无论是加一个禁手规则、加一档 AI 难度,还是换成 19 路棋盘。跑通再改,能保证你踩坑时知道是自己引入的,还是框架本身的问题。改完以后,记得把调试用的评分输出代码注释掉或者做成全局开关,不然正式演示时输出窗口刷屏会显得很业余。希望这份拆解能帮你在复现五子棋人机对战时少走几步弯路,也祝你调出来的 AI 在复盘时能让你自己眼前一亮。
本文还有配套的精品资源,点击获取