简介:这份资源面向想通过实战项目入门或巩固C++图形编程的开发者,提供一套基于Visual Studio实现的五子棋完整工程,涵盖人人对战与人机对战两种模式。压缩包共49个文件、约107MB,包含5个cpp源文件与1个头文件承载棋盘管理、落子判断、AI搜索与胜负检测等核心逻辑,另有jpg/png棋盘与棋子素材、wav音效、sln与vcxproj工程配置及txt存档文件,可直接用VS打开编译运行。资源已有2659人学习下载,适合作为课程设计或练手参考。读者可从中获得事件驱动界面搭建、二维数组棋盘状态维护、穷举与Minimax类AI思路、五子连珠判定以及悔棋、存档等扩展功能的实现范例,便于对照理解游戏从界面到算法的完整组织方式。
1. 五子棋人机对战:一份能直接跑起来的 VS 工程长什么样
很多人第一次在 Visual Studio 里做五子棋,卡住的不是棋盘怎么画,而是「人机对战」这四个字——机器落子到底怎么算?是随机下、还是真有一套评估逻辑?这份工程给的是一个完整可编译的 WinForms 方案:15×15 棋盘、双缓冲绘制、胜负判定、以及一个基于棋型评分的极小化极大搜索(带 Alpha-Beta 剪枝)的电脑对手。它解决的是「从零搭一个能玩、能看懂、能改的棋类 AI 骨架」这件事,适合两类人:一是刚学完 C# 基础、想找个有真实算法含量的练手项目的新手;二是想拿一个干净的棋型评估模板去改五子棋、改四子棋、甚至改其他落子类博弈的熟手。下面按「工程结构 → 界面与落子 → 胜负判定 → 评估函数 → 搜索剪枝 → 调参与避坑」的顺序拆,每一步都能对着代码复现。
2. 工程结构与棋盘数据结构:先把地基打对
2.1 为什么用二维 int 数组而不是 List
棋盘状态是整个程序的地基,选错容器后面全是返工。常见做法是用int[15,15],0 表示空、1 表示黑、2 表示白。理由很直接:搜索算法要频繁做「假设落子 → 评估 → 撤销」的回溯,数组按下标读写是 O(1),而且撤销只需把值改回 0,不需要像 List 那样增删元素、维护顺序。用 List 或 Dictionary 存棋子坐标,在深度 4 的搜索里会多出大量查找开销,血泪经验是:搜索一慢,界面就卡成幻灯片。
工程里通常把棋盘数据放在一个独立的Board类里,界面层只负责画和收鼠标事件,逻辑层不碰控件。这样做的另一个好处是评估函数和搜索函数可以脱离窗体单独测试——写个控制台程序喂几个固定局面,看评分对不对,比在界面上点半天高效得多。
public class Board { public const int Size = 15; public const int Empty = 0, Black = 1, White = 2; private int[,] grid = new int[Size, Size]; public int this[int r, int c] { get => grid[r, c]; set => grid[r, c] = value; } public bool InBounds(int r, int c) => r >= 0 && r < Size && c >= 0 && c < Size; // 落子并返回是否合法,非法落子直接拒绝,避免搜索时污染棋盘 public bool Place(int r, int c, int player) { if (!InBounds(r, c) || grid[r, c] != Empty) return false; grid[r, c] = player; return true; } public void Undo(int r, int c) => grid[r, c] = Empty; }这段代码的关键在Place和Undo成对出现:搜索树每深入一层就 Place 一次,回溯时 Undo 一次,棋盘始终只有一份,不复制。参数上Size定死 15 是标准五子棋盘面,改成 19 也能跑,但评估函数里的连子窗口长度要跟着检查,别只改一个常量就以为万事大吉。
2.2 窗体与双缓冲:别让重绘拖慢搜索
WinForms 默认的Paint事件在频繁刷新时会闪烁,尤其电脑思考时如果每评估一个节点就刷新界面,画面会抖得没法看。工程里的做法是给Panel或窗体开双缓冲,并且把「搜索」和「重绘」彻底分开:搜索在后台算完,只把最终落子点Invalidate()一次。
// 在窗体构造函数或 Load 事件里开启双缓冲 this.SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true); this.UpdateStyles();OptimizedDoubleBuffer让绘制先写进内存位图再整体贴到屏幕,AllPaintingInWmPaint把擦除和绘制合并,UserPaint表示自己处理绘制。三个标志一起开才有效,只开第一个在部分场景下仍会闪。注意:如果棋盘画在子控件Panel上,这段要写在Panel的派生类里,写在主窗体上没用——这是新手最常翻的车之一。
3. 落子交互与胜负判定:五个方向一次判完
3.1 鼠标坐标到棋盘格点的换算
界面上的棋盘是一张网格图,鼠标点下去拿到的是像素坐标,必须换算成行列号。工程里一般先定好边距margin和格子边长cellSize,然后(e.X - margin) / cellSize取整。这里有个细节:除法要用浮点再四舍五入,否则点在格子边缘会偏一格。
private void boardPanel_MouseDown(object sender, MouseEventArgs e) { int col = (int)Math.Round((e.X - margin) / (double)cellSize); int row = (int)Math.Round((e.Y - margin) / (double)cellSize); if (!board.InBounds(row, col)) return; if (!board.Place(row, col, humanColor)) return; // 非法或已占位直接返回 boardPanel.Invalidate(); // 只重绘一次 if (CheckWin(row, col, humanColor)) { ShowResult("你赢了"); return; } StartComputerTurn(); // 交给电脑 }Math.Round而不是直接截断,是因为玩家习惯点在交叉点上,截断会让靠右下的点系统性偏移半格。humanColor单独存一个变量,是为了支持「玩家执白后手」的切换,别把颜色写死成 1。
3.2 胜负判定:以落子点为中心的八向扫描
判定不需要遍历全盘,只需要从刚落的这颗子出发,沿横、竖、两条斜线四个轴、正反两个方向数连子。任意方向连成 5 即胜。这样单次判定最多扫 4×8 个格子,开销可以忽略。
private static readonly int[][] Dirs = { new[]{0,1}, new[]{1,0}, new[]{1,1}, new[]{1,-1} }; public bool CheckWin(int r, int c, int player) { foreach (var d in Dirs) { int count = 1; for (int s = -1; s <= 1; s += 2) // 正反两个方向 { int rr = r + d[0] * s, cc = c + d[1] * s; while (board.InBounds(rr, cc) && board[rr, cc] == player) { count++; rr += d[0] * s; cc += d[1] * s; } } if (count >= 5) return true; } return false; }Dirs只存四个轴,每个轴用s = -1和s = 1走两边,避免写八段重复循环。count从 1 起算是因为落子点本身算一颗。注意五子棋正式规则里长连(六子及以上)有的赛制算禁手,这份工程按「≥5 即胜」处理,做休闲对战够用;如果要严格按禁手规则,得在落子前额外判断黑棋的三三、四四、长连,那是另一个量级的工作,别在初版里硬塞。
4. 棋型评估函数:人机对战的核心在这里
4.1 把一条线切成窗口打分
电脑要选点,就得给每个候选点打分。工程采用的思路是「棋型评分」:对每个空点,假设自己下一子,统计四个方向上形成的连子形态,按活四、冲四、活三、眠三、活二等等级给分,再减去对手在同点成型的威胁分。这样既鼓励进攻也兼顾防守。
实现上不必对全盘每个空点都算,常见优化是只评估「已有棋子周围两格内」的候选点,空盘附近没棋的地方分数都一样,算了也是浪费。
// 棋型分值表,可按需调整,数值差距要拉开量级 private static readonly Dictionary<string, int> PatternScore = new() { { "11111", 1000000 }, // 五连 { "011110", 100000 }, // 活四 { "011112", 10000 }, // 冲四(一端被堵) { "011100", 5000 }, // 活三 { "001110", 5000 }, { "011010", 5000 }, // 跳活三 { "001112", 500 }, // 眠三 { "010110", 500 }, // 眠三变体 { "001100", 100 }, // 活二 { "001010", 100 }, { "000100", 10 } // 单子 };这里的字符串用1表示己方、0表示空、2表示对方或边界。评估时把某方向前后各取 4 格拼成字符串,去表里查最长匹配。分值设计的原则是:五连远大于活四,活四远大于冲四加活三之和,否则 AI 会为了凑两个活三而放掉对手的活四,翻车现场就是「明明能赢却被反杀」。数值不必照抄,但量级关系必须守住。
4.2 攻防合一的打分方式
单看自己的成型分不够,电脑还得知道「这个点对手下了会怎样」。工程里的做法是对同一候选点算两次:一次假设自己落子得myScore,一次假设对手落子得oppScore,综合分取myScore + oppScore * 0.8之类的加权。系数小于 1 表示略微偏进攻,等于 1 表示完全防守优先。
public int EvaluatePoint(int r, int c, int me) { int opp = me == Board.Black ? Board.White : Board.Black; int myScore = ScoreAt(r, c, me); int oppScore = ScoreAt(r, c, opp); return myScore + (int)(oppScore * 0.8); // 0.8 是攻防权重,可调 }ScoreAt内部就是沿四轴取窗口、查表、累加。权重 0.8 不是玄学,调大了 AI 变得保守、错失胜机;调小了容易被对手的活三牵着走。实际调参时建议固定几个典型局面(比如对手活三、自己冲四)反复跑,看 AI 选点是否符合直觉,比盲调快得多。
5. 极小化极大搜索与 Alpha-Beta 剪枝:让电脑会算几步
5.1 搜索框架与深度选择
评估函数只解决「当前这一步好不好」,要会算几步就得靠搜索。工程用的是带 Alpha-Beta 剪枝的极小化极大:电脑层取最大值,玩家层取最小值,交替递归到设定深度后返回评估分。深度 4 在 15×15 盘面上配合候选点裁剪,普通机器上单步思考通常在几百毫秒到一两秒,体验可接受;深度 6 会明显变慢,除非再加置换表或迭代加深。
private int Minimax(Board b, int depth, int alpha, int beta, bool isMax, int me) { if (depth == 0) return EvaluateBoard(b, me); var moves = GenerateCandidates(b); // 只取棋子附近两格内的空点 if (moves.Count == 0) return EvaluateBoard(b, me); if (isMax) { int best = int.MinValue; foreach (var (r, c) in moves) { b.Place(r, c, me); best = Math.Max(best, Minimax(b, depth - 1, alpha, beta, false, me)); b.Undo(r, c); alpha = Math.Max(alpha, best); if (beta <= alpha) break; // 剪枝:对手不会走更差的分支 } return best; } else { int opp = me == Board.Black ? Board.White : Board.Black; int best = int.MaxValue; foreach (var (r, c) in moves) { b.Place(r, c, opp); best = Math.Min(best, Minimax(b, depth - 1, alpha, beta, true, me)); b.Undo(r, c); beta = Math.Min(beta, best); if (beta <= alpha) break; } return best; } }alpha是当前最大层已知的最好分,beta是最小层已知的最好分,一旦beta <= alpha说明这条分支不可能被选中,直接砍掉。GenerateCandidates是性能关键:如果对全盘 225 个点都展开,深度 4 的分支数会爆炸;只取已有棋子周围两格,候选点通常降到几十个,剪枝效果才明显。注意Place和Undo必须严格配对,递归里漏一次 Undo,棋盘就被污染,后面所有评估都是错的——这种 bug 不会报错,只会让 AI 下出莫名其妙的位置,排查起来很折磨。
5.2 顶层选点与先手必胜的短路
搜索返回的是分数,真正落子还要在顶层再遍历一次候选点,取分数最高的那个。这里有个实用技巧:如果某个点能直接成五,或者能堵住对手的成五,就不必进搜索,直接下,省时间也避免搜索深度不够时漏判。
public (int r, int c) FindBestMove(Board b, int me, int depth) { var moves = GenerateCandidates(b); int bestScore = int.MinValue; (int r, int c) bestMove = moves[0]; foreach (var (r, c) in moves) { b.Place(r, c, me); if (CheckWin(b, r, c, me)) { b.Undo(r, c); return (r, c); } // 能赢直接赢 int score = Minimax(b, depth - 1, int.MinValue + 1, int.MaxValue - 1, false, me); b.Undo(r, c); if (score > bestScore) { bestScore = score; bestMove = (r, c); } } return bestMove; }int.MinValue + 1和int.MaxValue - 1是为了避免取负或取反时溢出,虽然这里没做取反,但养成习惯没坏处。短路判断放在 Place 之后、Undo 之前,顺序别写反,否则判定的是空盘。
6. 避坑与排查:这几处翻车我见过太多次
6.1 界面卡死,点一下要等好几秒
现象:电脑回合时窗体无响应,鼠标变沙漏。原因:搜索跑在 UI 线程上,深度一大就阻塞消息循环。解决:把FindBestMove放进Task.Run,算完用Invoke回主线程落子并重绘;同时搜索期间禁用棋盘点击,防止玩家插队。
6.2 AI 明明能赢却去堵对手
现象:自己已有冲四,电脑却去防对手的活三。原因:评估表里冲四分值低于对手活三的防守加权,或攻防系数设得太保守。解决:把「己方能成五」的短路判断放在搜索之前(见 5.2),并检查分值表里五连、活四、冲四的量级是否拉开。
6.3 棋盘画出来偏格,点不准
现象:棋子落在交叉点旁边,越往右下偏得越多。原因:坐标换算用了整数截断,或margin与绘制时的起始偏移不一致。解决:统一用Math.Round换算,并把绘制和点击换算抽成同一个CellToPixel/PixelToCell方法,只维护一份边距常量。
6.4 搜索越深越慢,还伴随内存上涨
现象:深度调到 6 后思考时间成倍增长,任务管理器内存缓慢爬升。原因:候选点没裁剪,或递归里复制了棋盘对象。解决:确认GenerateCandidates只取棋子邻域,确认全程复用同一个Board实例、只用 Place/Undo,不 new 新棋盘。
6.5 双缓冲开了还是闪
现象:SetStyle写了,重绘仍抖。原因:样式开在了主窗体,实际绘制在子Panel上。解决:把三行SetStyle移到承载棋盘的控件派生类构造函数里,或在Panel的Paint里先画到BufferedGraphics再输出。
7. 调参与验证:把 AI 从「能下」推到「像个人」
工程跑通只是起点,真正决定体验的是参数。我一般会固定一组测试局面做回归:空盘开局、对手活三、自己冲四、双方各两个活二,每次改完评估表或搜索深度就跑一遍,看选点是否稳定。下面这张表是我常用的调参起点,数值可以按手感微调,但量级关系别动。
| 参数 | 建议起点 | 调大后的效果 | 调小后的效果 |
|---|---|---|---|
| 搜索深度 | 4 | 更强但更慢 | 快但短视 |
| 攻防权重 | 0.8 | 更偏防守 | 更偏进攻 |
| 候选邻域半径 | 2 | 更全但更慢 | 快但可能漏点 |
| 活三分值 | 5000 | 更重视做三 | 容易放活三 |
| 冲四分值 | 10000 | 更重视冲四威胁 | 易被反杀 |
验证方法上,除了人工对弈,我会写一个简单的自对弈循环:让同一套 AI 执黑执白各跑若干局,统计先手胜率和平均步数。如果先手胜率异常高(比如接近全胜),多半是评估函数对先手优势过度放大,或者防守权重太低;如果平均步数很短就分胜负,检查是不是胜负判定或短路逻辑有误判。这个循环不需要界面,控制台里跑就行,比盯着屏幕点快得多。
还有一个容易被忽略的点:随机性。纯确定性搜索在相同局面下永远走同一步,玩家摸清套路后会觉得「这 AI 好蠢」。常见做法是在分数接近的候选点里按概率挑一个,或者开局前几步加一点随机扰动,让对局有变化。扰动幅度别太大,否则 AI 会下出明显臭棋,反而显得不专业。
从那以后我每次拿到这类棋类工程,都强制先跑一遍「固定局面回归 + 自对弈统计」再谈调参,因为凭感觉改分值表,十次有八次是把一个能用的 AI 改废了。希望这份拆解帮到你,把工程拉下来先让它在自己机器上跑起来,再对着第 4、5 章的代码逐段改,比通读一遍强得多。
本文还有配套的精品资源,点击获取