简介:一套基于Visual Studio编写的五子棋游戏完整工程,采用C++结合EasyX图形库实现,覆盖人机对战与人人对战两种模式。项目围绕棋盘状态管理、合法落子判断、五子连珠检测、AI落子策略等核心逻辑展开,面向正在学习Windows窗体程序、游戏算法或C++实战项目的开发者,适合作为课程设计或入门练手参考。压缩包内共49个文件,主体包括cpp源文件与h头文件、用于界面及棋子的jpg/png图像素材、wav背景音乐、以及解决方案工程文件与调试符号文件,整体约107MB,解压后可直接用Visual Studio打开构建。目前已有2655人学习下载。借助附带的素材与工程结构,使用者可以完整跟踪棋盘绘制、事件响应、AI决策及胜负判定等实现细节,还能基于现有框架自行扩展难度或增加悔棋等功能,对理解桌面游戏开发流程很有帮助。
1. Visual Studio 写五子棋:人机对战的核心不是界面,而是棋盘上的那个大脑
想用 Visual Studio 做一套带人机对战的五子棋,很多人第一步就去折腾棋盘的绘制、鼠标落子的响应,结果界面做得挺好看,AI 却只会堵在对手旁边乱下一气。真正让五子棋从「能玩」变成「能对战」的,是棋盘背后那套评分算法——它决定电脑往哪儿下、为什么往那儿下。这套工程最适合正在找课程设计题目、或者想用 C#/C++ 把数据结构与算法真正落地的开发者。把棋盘建模、胜负判定、AI 评分和界面事件串起来,就是一个完整且能演示的桌面应用,而且每一步都能单独验证,难度曲线比想象中平缓。
2. 棋盘建模与落子逻辑:先让数据结构和坐标索引对齐
2.1 棋盘数据结构:用二维数组还是位棋盘
五子棋的棋盘标准是 15×15,落子状态只有空、黑、白三种。最直接的做法是int[15,15],0 表示空、1 表示黑棋、2 表示白棋。选这个结构的原因很朴素:人机 AI 每下一步都要反复扫描棋盘评分,二维数组的随机访问是 O(1),遍历也直观;而位棋盘(用 64 位整数压缩行状态)虽然省内存,但在评分时要频繁做位运算转换,增加了理解成本。对于 Visual Studio 里的这类桌面项目,二维数组的可读性优势明显,而且调试时可以直接看数组内容,不用做进制转换。
棋子坐标和界面像素坐标的映射是另一个容易埋坑的地方。棋盘画出来之后,鼠标点击的位置是像素坐标,比如(137, 223),但落子要落在格子上,需要换算成数组索引。换算公式要同时处理边界和取整:
private Point PixelToGrid(Point p, int margin, int cellSize) { int gx = (int)Math.Round((p.X - margin) / (double)cellSize); int gy = (int)Math.Round((p.Y - margin) / (double)cellSize); if (gx < 0 || gx >= 15 || gy < 0 || gy >= 15) return Point.Empty; // 越界不落子 return new Point(gx, gy); }margin是棋盘边缘留白像素,cellSize是相邻网格线的间距。Math.Round而不是直接int强转,是因为鼠标在格子中间附近点击时,浮点除法会得到 7.6、8.4 这样的值,直接截断会落到错误的格子上。越界判断必须在取整之后做,否则点击棋盘外的空白区域会得到一个负数索引,数组访问直接抛异常。
2.2 落子、悔棋与提子:用栈保存对局历史
落子操作不只是往数组里写一个数字,还要让界面能跟着刷新、让 AI 知道「该我了」。一个常规的做法是维护一个Stack<Point>保存落子历史,黑棋白棋交替落子,悔棋时弹出最后一次落子并把数组对应位置清零。
private Stack<Point> moveHistory = new Stack<Point>(); private int currentPlayer = 1; // 1=黑 2=白 public bool PlaceStone(int gridX, int gridY) { if (board[gridY, gridX] != 0) return false; // 已有棋子 board[gridY, gridX] = currentPlayer; moveHistory.Push(new Point(gridX, gridY)); lblStatus.Text = (currentPlayer == 1) ? "白棋回合" : "黑棋回合"; currentPlayer = (currentPlayer == 1) ? 2 : 1; Invalidate(); // 触发重绘 return true; } public bool UndoLastMove() { if (moveHistory.Count == 0) return false; Point last = moveHistory.Pop(); board[last.Y, last.X] = 0; currentPlayer = (currentPlayer == 1) ? 2 : 1; Invalidate(); return true; }用栈这里有个细节容易被忽略:悔棋后currentPlayer要翻转回来。如果黑棋刚下完、当前轮到白棋,悔棋撤掉的是黑棋,那当前玩家应该是黑棋。翻转逻辑只适用于「悔一步」,如果支持连续悔棋,得在每次Pop后都翻转一次,或者干脆保存一个完整的状态快照。栈的形式直观,但要注意它只保存了坐标,没有保存每一步的棋盘状态,所以 AI 在悔棋后的下一手计算,必须从当前棋盘数组重新扫描,不能依赖任何缓存。
3. 人机 AI 的核心:评分表算法与落子决策
3.1 为什么不是极大极小搜索
做过棋类 AI 的人第一反应可能是极大极小搜索加 Alpha-Beta 剪枝。但在五子棋上,这个方案的性价比不高——15×15 棋盘有 225 个落子点,搜索深度到 4 层时,即使有剪枝,评估节点的数量也在万级到十万级,而每次评估都要对棋盘做多方向扫描;在 WinForms 界面里直接同步执行,界面会卡死数秒,体验极差。另一个问题是:五子棋的棋型评价(活三、冲四、双活三)远比「局势好坏」这种抽象评估更有效。与其搜索几十步之后的局面,不如直接对当前每个空点打分,看谁最能形成威胁。
评分表算法的思路是:对每个空位,假设黑棋落在那里,计算这个点对黑棋的价值;再假设白棋落在那里,计算对白棋的价值;两者加权求和就是该点的最终得分。这个算法不需要递归,只需要一次全棋盘扫描,计算量在几百次棋型识别以内,界面完全扛得住。视觉上 AI 的每一步都「看得懂」——它会优先堵你的活三,能成五连就走五连,行为接近真人下棋的逻辑。
3.2 棋型打分表:把棋型翻译成数字
五子棋的棋型可以拆成「连子数 + 两端状态」的组合。两端状态只有两种:一端被堵(有对方棋子或墙)、两端都开放。一组常用分值如下。
| 棋型 | 含义 | 分值 |
|---|---|---|
| 五连 | 五个连续同色棋子 | 1000000 |
| 活四 | 四个连续且两端开放 | 100000 |
| 冲四 | 四个连续但一端被堵 | 10000 |
| 活三 | 三个连续且两端开放 | 8000 |
| 眠三 | 三个连续但一端被堵 | 1000 |
| 活二 | 两个连续且两端开放 | 500 |
| 眠二 | 两个连续但一端被堵 | 100 |
| 活一 | 单子且两端开放 | 50 |
分值差距设得很大,是为了让 AI 在任何情况下都优先考虑成五连和堵对方的活四。实战里冲四的分值可以再调,因为冲四往往能逼对方应一手,从而腾出另一个进攻点。工程上建议把这些分值做成一个Dictionary<string, int>或者常量数组,方便后面实验参数。
3.3 落子决策:遍历空点、攻守合并、随机打破平局
AI 每次决策时做三件事:遍历所有空点;对每个空点分别按黑棋和白棋角度计算分数;进攻分和防守分加权,取总分最高的点落子。
public Point GetBestMove(int[,] board, int aiPlayer) { int opponent = (aiPlayer == 1) ? 2 : 1; int bestScore = -1; List<Point> candidates = new List<Point>(); for (int y = 0; y < 15; y++) { for (int x = 0; x < 15; x++) { if (board[y, x] != 0) continue; int attack = EvaluatePoint(board, x, y, aiPlayer); int defend = EvaluatePoint(board, x, y, opponent); int total = attack + (int)(defend * 1.1); // 防守权重略高 if (total > bestScore) { bestScore = total; candidates.Clear(); candidates.Add(new Point(x, y)); } else if (total == bestScore) { candidates.Add(new Point(x, y)); // 同分候选 } } } Random rand = new Random(); return candidates[rand.Next(candidates.Count)]; }得分相同的多个点随机选一个,这一步很关键。如果不做随机化,AI 面对对称局面会一直走同一个方向,对局看起来死板;加了随机后,每一局的走势会有变化。防守分乘 1.1 是我调试下来的习惯值,因为五子棋「先防后攻」的容错率更高——漏堵一个活三,下一手可能就是死局。
EvaluatePoint是 AI 的核心函数,它临时把某个点涂成指定玩家的棋子,然后从该点出发沿四个方向数棋型:
private int EvaluatePoint(int[,] board, int x, int y, int player) { board[y, x] = player; // 临时落子 int score = 0; int[,] dirs = { { 1, 0 }, { 0, 1 }, { 1, 1 }, { 1, -1 } }; for (int i = 0; i < 4; i++) { int dx = dirs[i, 0], dy = dirs[i, 1]; int count = 1, openEnds = 0; // 正方向延伸 int nx = x + dx, ny = y + dy; while (nx >= 0 && nx < 15 && ny >= 0 && ny < 15 && board[ny, nx] == player) { count++; nx += dx; ny += dy; } if (nx >= 0 && nx < 15 && ny >= 0 && ny < 15 && board[ny, nx] == 0) openEnds++; // 反方向延伸 nx = x - dx; ny = y - dy; while (nx >= 0 && nx < 15 && ny >= 0 && ny < 15 && board[ny, nx] == player) { count++; nx -= dx; ny -= dy; } if (nx >= 0 && nx < 15 && ny >= 0 && ny < 15 && board[ny, nx] == 0) openEnds++; score += GetPatternScore(count, openEnds); } board[y, x] = 0; // 还原棋盘 return score; }这里count是包含当前点的连续同色棋子数,openEnds记录这个连续序列两端有多少个空位(最多为 2)。GetPatternScore查表返回对应分值。注意函数末尾的board[y, x] = 0必须执行——临时落子后不还原,整个评分就废了。我见过有开发者把还原逻辑写在某个 return 之后,导致棋盘状态被污染,后续评估全错。
4. 工程实施:界面绘制、事件响应与 AI 异步计算
4.1 工程选型:C# WinForms 还是 C++ GDI+
Visual Studio 里做五子棋,C# WinForms 的工程效率最高。事件驱动模型直接对应鼠标点击、定时器刷新,Graphics对象画直线和椭圆都是现成的 API;C++ 用 GDI+ 也能实现,但消息循环和窗口过程要自己维护,项目时间会多花 30% 以上。如果你的目标是把精力放在 AI 算法上,选 WinForms;如果想练底层窗口机制,再考虑 C++。实际调试中 WinForms 的PaintEventArgs可以直接拿来画棋盘和棋子,不需要额外引入第三方控件库。
4.2 棋盘绘制:双缓冲与坐标换算
绘制逻辑统一放在Form_Paint事件里,画的内容包括:棋盘网格线、星位标记、已落棋子。最容易出问题的环节是棋子错位——网格线坐标和数组索引不一致,导致画出来的棋子在两条线中间。
private void Form_Paint(object sender, PaintEventArgs e) { Graphics g = e.Graphics; int margin = 30, cellSize = 40; // 画网格线 for (int i = 0; i < 15; i++) { g.DrawLine(Pens.Black, margin + i * cellSize, margin, margin + i * cellSize, margin + 14 * cellSize); g.DrawLine(Pens.Black, margin, margin + i * cellSize, margin + 14 * cellSize, margin + i * cellSize); } // 画棋子 for (int y = 0; y < 15; y++) { for (int x = 0; x < 15; x++) { if (board[y, x] == 0) continue; int px = margin + x * cellSize; int py = margin + y * cellSize; if (board[y, x] == 1) g.FillEllipse(Brushes.Black, px - 14, py - 14, 28, 28); else g.FillEllipse(Brushes.White, px - 14, py - 14, 28, 28); } } }margin + i * cellSize是画线和画棋盘格的统一基准。PixelToGrid换算时用的也是这套公式,只要两处保持一致,棋子永远不会画偏。还有一步不能省:窗体构造函数里开启双缓冲,否则每落一子整个棋盘会闪烁。
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true);OptimizedDoubleBuffer让所有绘制先写进内存位图、再一次刷到屏幕。没有这行代码,快速落子或拖动窗口时,棋盘会出现明显的残影。
4.3 AI 计算不卡界面:异步落子与状态保护
AI 的GetBestMove在普通局面下耗时在几十毫秒到几百毫秒之间(取决于评分函数实现),但如果直接在 UI 线程里同步跑,落子后界面会短暂无响应。工程上的标准做法是Task.Run把 AI 计算丢到后台线程,计算完成后用Invoke回到 UI 线程更新棋盘。
private void AITurn() { if (isAIThinking) return; isAIThinking = true; int[,] snapshot = (int[,])board.Clone(); // 防止棋盘被悔棋修改 Task.Run(() => { Point move = GetBestMove(snapshot, aiPlayer); BeginInvoke(new Action(() => { PlaceStone(move.X, move.Y); isAIThinking = false; CheckGameOver(move.X, move.Y); })); }); }board.Clone()这一步容易被忽略:用户如果在 AI 思考时点了悔棋,后台线程还在读旧的棋盘数组,就会出现「AI 计算了半天,落子落在了一个已经被悔掉的位置」。快照保证 AI 计算的是发起时刻的局面。isAIThinking作为标志位,避免 AI 还没算完,玩家又触发第二次思考。注意Invoke和BeginInvoke的差别:前者会阻塞后台线程直到 UI 处理完,后者异步排队,不会卡 AI 的后续逻辑。
5. 避坑与排查:五子棋工程里最常见的四个翻车点
5.1 棋子画在线中间,和鼠标点击位置对不上
现象: 鼠标点下去在格子里,棋子却出现在两条网格线之间;或者格子边缘点击时落子落到隔壁。
原因:PixelToGrid的取整逻辑不对,像素坐标到数组索引的换算用了int强转而不是Math.Round;另一处是网格线绘制时的起点和margin不一致,绘制基准就偏了。
解决: 统一用一个函数生成坐标,比如GridToPixel和PixelToGrid都基于margin + index * cellSize计算;PixelToGrid用Math.Round并对x < margin - cellSize/2这类边界做提前过滤。改完后再用鼠标在左上角、右下角各落一子验证映射是否精确。
5.2 AI 不堵活三,专下没用的位置
现象: AI 分数算出来很高,但实际走出来的棋完全不防守,坚持下在离战局很远的地方。
原因: 评分函数里EvaluatePoint只计算了进攻角度的棋型,没有把防守分合进来;或者防守分的权重设成了 0。另一种情况是临时落子后没有还原棋盘,后续所有空点的评估都建立在被污染的棋盘上,分数乱掉。
解决: 检查GetBestMove里是否同时调用了两个EvaluatePoint(一个传aiPlayer,一个传opponent);确认EvaluatePoint里board[y, x] = 0在返回前一定执行。调试时可以打印每个候选点的进攻分、防守分和总分,看 AI 选的点是否在数值上合理。
5.3 白棋赢棋判定失败,五连不弹胜利
现象: 黑棋五个连成一线能判定胜利,白棋同样五连却没有任何反应。
原因: 胜负判定用board[y, x] == 1硬编码了黑棋,或者CheckGameOver里只接收黑棋落子的触发;白棋落子后没有调用判定函数。
解决: 落子后统一调用CheckGameOver(x, y),函数内部取出board[y, x]的值作为当前玩家,再沿四个方向数连子。不要在判定函数里写死 1 或 2,用参数传进去。同一套判定逻辑要覆盖黑棋白棋。
5.4 AI 思考时界面卡死,点哪儿都没反应
现象: AI 落子前整个窗体变成「未响应」状态,过好几秒才恢复。
原因:GetBestMove直接跑在 UI 线程。15×15 棋盘虽然只有 225 个点,但如果棋型识别写得低效——比如每个点都对全盘做四方向全扫描——计算量会膨胀到几十万次循环,UI 线程被占满。
解决: 把 AI 决策放进Task.Run,用快照棋盘,计算完再BeginInvoke回 UI 线程。同时可以优化EvaluatePoint:只扫描以落子点为中心、半径 4 的窗口,五子连珠最长就是五子,超过半径 4 的棋子不可能参与当前点的棋型组合。窗口扫描能把评估耗时降到原来的十分之一左右。
6. 进阶技巧:让 AI 更难缠的三招
基础评分表能让 AI 达到「能下、能防」的水平,但它的走法太机械:同一局面下每次的选择都是固定的,容易被摸透。我在调一个模拟项目 X 的 AI 时用了三招,效果明显。
第一招是给评分加随机扰动:total += rand.Next(-5, 6)。扰动值远小于棋型分值的数量级差距,不会让 AI 放弃一个活四去走边角,但能让几个分数接近的候选点之间有随机变化,对局不再千篇一律。
第二招是候选点剪枝。不要全盘遍历所有空点,只评估「距离已有棋子不超过 2 格」的空点。实战中没人会把第五手打在离战场八格远的地方,剪枝后 AI 的思考时间从几百毫秒降到十几毫秒,这为后面加搜索深度留出了余量。
第三招是验算习惯。每次改完评分表或防守权重,我会固定跑一个自测:先手动布一个对方活三的局面,让 AI 落子,看它是不是第一时间堵住;再布一个自己能冲四的局面,看 AI 是不是直接走冲四而不是去围边角。这两步通过,再进入正常对局。某次我把防守权重从 1.1 调到了 1.5,AI 变得过于保守,明明能直接赢的冲四不走了,反而去堵一个无关紧要的眠二——全靠这套验算流程拦下来。从那以后我每次动 AI 参数,都强制先跑一遍活三拦截和冲四进攻两个场景,再谈后续调优。
这套东西做到最后,一个 Visual Studio 工程文件、几百行核心代码,就能换来一套能演示、能对战、能改进的五子棋人机对战程序。你拿到手后,先把评分表的分值改一版、跑几局看看 AI 风格变化,再按「验算两场景」的流程过一遍,基本就能摸清 AI 的脾气了。希望帮到你。
本文还有配套的精品资源,点击获取