☰
VS人机五子棋实战:C# WinForms与启发式AI评分
2026/10/20 16:48:18 网站建设 项目流程

简介:这是一份基于 Visual Studio 与 EasyX 图形库开发的五子棋完整工程,适合 C++/Windows 窗体初学者、游戏编程爱好者以及想了解人机对战逻辑的开发者。项目同时实现人机对战与人人对战,覆盖棋盘状态管理、合法落子判断、胜负检测以及基础 AI 算法(含穷举与简单搜索思路),并附带悔棋、重新开始、保存与加载等扩展功能,可帮助读者系统理解桌面小游戏的完整开发流程。资源共 49 个文件,压缩包 107.07MB,内含 5 个 C++ 源码文件及配套头文件、VS 解决方案与工程文件(sln、vcxproj、filters),以及棋盘、棋子、背景等 jpg/png 图片素材和 wav 音效,工程还包含 Debug 目录下的 pdb 调试信息,便于直接编译运行与二次修改。目前已有 2657 人学习下载,特别适合课程设计、毕业设计或自学项目参考。

1. VS 人机五子棋项目拆解:棋盘、AI与检测到底谁先写

用 Visual Studio 做一套能玩的人机五子棋,不少人会先画棋盘、写落子,最后才补AI,结果发现棋盘画得很顺手,AI却像个只有三秒钟记忆的复读机:你堵左边它堵左边,你堵右边它堵右边,根本藏不住任何进攻意图。这个项目的关键不是把棋子画出来,而是把坐标换算、胜负检测、棋型评分这三件事串进同一个流程里。这篇按我实际调试顺序来拆:工程骨架、落子检测、AI评分、避坑,最后给一套验证AI强度的方法。适合两类人:一类是想用VS完整跑通棋类游戏的新手,另一类是手里已有双人版、想给AI换脑子的开发者。

2. 工程骨架与UI:WinForms选型、双缓冲和落子坐标换算

2.1 为什么这个项目我选了WinForms而不是WPF

做五子棋Demo,选WinForms是最不纠结的。WinForms控件模型直白,Form上挂一个MouseClick事件,然后在OnPaint里用GDI+画线画圆,不需要理解依赖属性、数据模板这些额外概念。WPF在样式上确实好看,棋盘可以用Path画得很精致,但为了一个单窗口的棋类小工具引入绑定和资源字典,往往让排查问题变成一场解谜,对新手不算友好。VS里新建项目时直接选Windows Forms应用,.NET Framework 4.7.2或.NET 6以上都能跑,不会影响后面的代码。

另一个决定性因素是双缓冲。WinForms窗体自带DoubleBuffered属性,一行代码就能抑制棋盘重绘的闪烁。WPF虽然是保留模式渲染,但只要你在Canvas里频繁改元素位置,反而要操心更多细节。这个项目的核心是逻辑和AI,UI只要稳定、不闪、好改就行。

2.2 棋盘数据结构和UI常量:二维数组是唯一真相

我一般会先把棋盘逻辑抽成一个GameBoard类,而不是把数组塞在Form里。下面是最初的骨架:

public class GameBoard { public const int BoardSize = 15; // 标准五子棋15路 public const int CellSize = 30; // 每个格子的像素宽度 public const int Margin = 24; // 棋盘最外侧留白,给星位和边界留空间 public int[,] Grid = new int[BoardSize, BoardSize]; // 0空,1黑,2白 public int CurrentPlayer = 1; // 1黑先手,2白后手 }

为什么是15路而不是10路?标准五子棋棋盘是15×15,黑白双方总落子上限225手,这个规模对后续AI扫描非常友好。CellSize=30是常见经验值,鼠标点击不容易误触;如果屏幕小,可以改成25,但小于20之后棋子视觉上会叠得很密。Margin=24是为了让最外一圈棋格不顶在窗体边缘,同时给坐标换算留出基准偏移量。

二维数组是这个项目的唯一真实状态。用List 存棋子也能遍历,但要知道某个点是否被占、要撤回一步、要检查某方向连续几子,二维数组能在固定时间内完成随机访问。后面AI评估函数能高效模拟落子,也是依赖这个数据结构。

2.3 双缓冲画棋盘:不闪的GDI

Form窗体默认重绘会闪烁,尤其在Invalidate触发频繁时,棋盘线会像水波纹一样抖。最省事的解法是在构造函数里把DoubleBuffered打开:

this.DoubleBuffered = true;

接下来重写OnPaint。棋盘绘制不复杂:先画15×15网格线,再画星位,最后根据Grid画棋子。用SmoothingMode.AntiAlias让圆形棋子边缘平滑:

protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g = e.Graphics; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; // 画网格线 for (int i = 0; i < GameBoard.BoardSize; i++) { int start = GameBoard.Margin + i * GameBoard.CellSize; int end = GameBoard.Margin + (GameBoard.BoardSize - 1) * GameBoard.CellSize; g.DrawLine(Pens.Black, start, GameBoard.Margin, start, end); // 竖线 g.DrawLine(Pens.Black, GameBoard.Margin, start, end, start); // 横线 } // 画棋子 for (int r = 0; r < GameBoard.BoardSize; r++) { for (int c = 0; c < GameBoard.BoardSize; c++) { if (board.Grid[r, c] == 0) continue; int x = GameBoard.Margin + c * GameBoard.CellSize; int y = GameBoard.Margin + r * GameBoard.CellSize; Brush brush = board.Grid[r, c] == 1 ? Brushes.Black : Brushes.White; g.FillEllipse(brush, x - 12, y - 12, 24, 24); g.DrawEllipse(Pens.Gray, x - 12, y - 12, 24, 24); // 灰色边让白棋在亮背景下看得清 } } }

这段代码里最容易看漏的是x - 12。棋子的圆心应该落在网格交叉点上,所以要用交叉点坐标减去半径12。如果直接把FillEllipse的坐标当成交叉点,整盘棋会向右下偏移,手感非常别扭。星位画法不展开,五个星位固定在下标(3,3)、(3,11)、(7,7)、(11,3)、(11,11)对应的交叉点。

重绘用Invalidate就行,不要主动调Refresh。Invalidate会合并重绘请求到下一帧,Refresh是强制同步,但也在UI线程上,落子频繁时更容易闪。

2.4 鼠标坐标换算:从像素到棋格

鼠标点击事件拿到的e.X和e.Y是客户区坐标,单位是像素,要换算成行列。直接做除法会让点击格子右半部分时落到下一列,所以我一般会加上半格偏移再整除:

private void FormBoard_MouseClick(object sender, MouseEventArgs e) { int col = (e.X - GameBoard.Margin + GameBoard.CellSize / 2) / GameBoard.CellSize; int row = (e.Y - GameBoard.Margin + GameBoard.CellSize / 2) / GameBoard.CellSize; if (row < 0 || row >= GameBoard.BoardSize || col < 0 || col >= GameBoard.BoardSize) return; if (board.Grid[row, col] != 0) return; MakeMove(row, col, board.CurrentPlayer); }

这个公式有一个前提:Margin必须等于棋盘左上角到窗体客户区左上角的距离。如果之后你把棋盘画在Panel里,e.Y就是Panel的坐标而不是Form的坐标,Margin和Panel的Location要一起调整。另一个容易翻车的地方是客户区坐标与屏幕坐标混用,MouseEventArgs里拿到的已经是客户区,别用Control.MousePosition去减,那会偏一个标题栏高度。

换算完行列后必须做两个防御:边界判断防止算出负数和超界索引;空位判断防止重复落子。之后统一走MakeMove,AI也走同一个入口,后面悔棋和状态切换才不会散落各处。

3. 五连检测与状态管理:从落子点向四个方向数子

3.1 为什么从落子点检测,而不是全盘扫描

新手写胜负判定时,最容易想成“每次落子后遍历整个棋盘,遇到棋子就朝八个方向数”。这么做代码量大,而且会在多个方向同时出现连子时误判。棋局里真正会被这次落子影响的,只有包含当前落点的四条线:水平、垂直、主对角线、副对角线。在这四条线上各向两头数连续同色子,总数达到5就能赢。

这种检测方式还有一个好处:把四个方向抽成可复用函数后,AI评估棋型时也能直接调用。比如评估某个空位落白子后能不能形成活三,其实就是在那个位置用同一套方向数组数一遍。所以这章代码不只是胜负检测,也是后面AI的地基。

3.2 方向数组与CheckWin实现

标准做法是定义方向偏移数组,只定义四个正方向,反向由调用时传负号,避免写八次if:

private static readonly int[,] Dirs = new int[,] { { 0, 1 }, // 水平向右 { 1, 0 }, // 垂直向下 { 1, 1 }, // 主对角线右下 { 1, -1 } // 副对角线右上 }; public bool CheckWin(int row, int col, int player) { for (int i = 0; i < 4; i++) { int dr = Dirs[i, 0]; int dc = Dirs[i, 1]; int count = 1; // 当前落子本身 count += CountInDirection(row, col, dr, dc, player); count += CountInDirection(row, col, -dr, -dc, player); if (count >= 5) return true; } return false; } private int CountInDirection(int row, int col, int dr, int dc, int player) { int count = 0; int r = row + dr; int c = col + dc; while (r >= 0 && r < GameBoard.BoardSize && c >= 0 && c < GameBoard.BoardSize && board.Grid[r, c] == player) { count++; r += dr; c += dc; } return count; }

逻辑说明:count初始化为1,是因为当前落点一定是player自己的子。CountInDirection向一侧一直数到非己方棋子或出界为止,返回这一侧连续同色子数。两个方向加起来就是当前落点所在直线的连子总数。四个方向只要有一个方向达到5,就判当前玩家胜。

参数说明:如果以后要支持黑棋“长连禁手”,可以把count >= 5改成count == 5。普通教学项目通常不做禁手区分,但你要在说明里写清楚,否则懂规则的人会追问。白棋没有禁手限制,长连也算胜。

3.3 把检测挂进统一入口MakeMove

有了检测函数后,最怕每个调用点各写各的判断。我习惯让鼠标落子和AI落子都走同一个MakeMove,胜负判断、换手、历史记录全部在一个方法里完成:

private GameBoard board = new GameBoard(); private Stack<MoveRecord> history = new Stack<MoveRecord>(); private void MakeMove(int row, int col, int player) { if (row < 0 || row >= GameBoard.BoardSize || col < 0 || col >= GameBoard.BoardSize || board.Grid[row, col] != 0) return; board.Grid[row, col] = player; if (CheckWin(row, col, player)) { GameOver(player); return; } history.Push(new MoveRecord(row, col, player)); board.CurrentPlayer = (player == 1) ? 2 : 1; Invalidate(); }

这里有个顺序问题:CheckWin必须先于CurrentPlayer切换调用。如果先换手,CheckWin里如果读取CurrentPlayer就会判到对方头上,这是很容易踩的隐蔽坑。history.Push放在胜负检测之后,意味着终局那一手也在历史里,悔棋时可以把最后一子撤掉。如果你想做成“终局后禁止悔棋”,就把Push移进GameOver分支前,但玩家体验会差一些。

重开一局时,把Grid清零、清空历史、CurrentPlayer恢复为1,再Invalidate。人机模式还要让AI内部状态重新初始化,不能沿用上一局残留的临时数据。

3.4 悔棋与状态衔接:双人模式至少能反悔

双人模式悔棋只需弹出一步,恢复棋子颜色:

public void Undo(int steps) { for (int i = 0; i < steps && history.Count > 0; i++) { MoveRecord m = history.Pop(); board.Grid[m.Row, m.Col] = 0; } Invalidate(); }

人机模式会复杂一点,玩家悔棋时AI已经下了一步,所以要连续撤销两步。判断条件可以看history中存的数量,如果AI还没落子,只需要撤销一步。这个坑在第五章会单独展开。这里的核心是:悔棋必须保证“棋盘数组”和“当前玩家”同步变化,只恢复棋盘不同步CurrentPlayer,下一步肯定错乱。

4. 人机对战AI:启发式评分、攻防权重和候选剪枝

4.1 为什么先写评分函数,而不是直接上Minimax

提到人机对战,很多人第一反应是Minimax。五子棋确实能用,但15×15空棋盘每一步可选点有两百多个,哪怕搜索深度只有4,也会让普通PC明显卡顿。工程型项目要的是“下起来有威胁、还能在弹指间回应”,所以先用启发式评分:给每个棋型打分,AI遍历所有空点,根据落子后对己方和对对方的潜在收益选分最高的点。

这个方案有个大优势:逻辑透明。你可以把每个空点的得分打出来,看AI为什么选这里,方便调参。后期想要更强,可以在候选榜单前几名上再叠一层搜索,而不是推翻整个AI重写。

4.2 棋型评分表:活四为什么比冲四分数高一截

AI评估的核心是对棋型打分。所谓棋型,就是沿一条线的连续同色子形状,以及两端是否开放。我用的评分表如下:

棋型分值说明
活四100000两端都空,必成五
冲四10000只有一端空或被堵一端
活三8000能形成活四的三连
眠三3000只能形成冲四的三连
活二1500两端都有空位的两连
眠二500一端被封的两连
单子0孤立棋子

为什么活四要远高于冲四?活四已经必胜,冲四对手还能挡,分值差距必须拉大,AI才会在“优先成型”和“破坏对手”之间做出正确判断。活三又必须略高于两个活二之和,否则AI会只盯散点而错过一次性成型的威胁。眠二给最低正分,保证局面没有明显棋型时AI不胡下。

4.3 AI决策框架:攻击分加防守分

AI下子时既要让自己形成好棋型,也要破坏对方的好棋型。常见做法是在每个空点分别模拟下AI棋子和人类棋子,然后取两部分分数之和:

private (int row, int col) AiMove() { int bestScore = int.MinValue; int bestRow = -1; int bestCol = -1; int aiPlayer = 2; // 这套工程AI固定执白 int humanPlayer = 1; for (int r = 0; r < GameBoard.BoardSize; r++) { for (int c = 0; c < GameBoard.BoardSize; c++) { if (board.Grid[r, c] != 0) continue; // 模拟AI落子后自己的收益 int attackScore = EvaluatePoint(r, c, aiPlayer); // 模拟人类玩家落在这里的收益,作为防守需求 int defendScore = EvaluatePoint(r, c, humanPlayer); // 攻击和防守权重都先设为1 int total = attackScore * 1 + defendScore * 1; if (total > bestScore) { bestScore = total; bestRow = r; bestCol = c; } } } if (bestRow < 0 || bestCol < 0) return (-1, -1); // 棋盘满,无空点 return (bestRow, bestCol); }

逻辑说明:attackScore是假设AI落子在此处后对AI棋型的提升,defendScore是假设人类玩家落子在此处后对人类棋型的提升。两者相加,AI既会进攻也会防守,不会只堵不攻。

参数说明:如果AI总是自己活三不管对手,可以把防守分乘1.1或1.2;如果AI太弱,总跟在人后面堵,就把攻击分乘1.2。这套工程默认两个权重都是1,但乘法已经留在代码里,方便你直接调参。

4.4 EvaluatePoint与棋型识别:模拟落子要记得回滚

EvaluatePoint是核心。做法是在当前空点临时放入player棋子,沿四个方向统计连续长度和两端开闭状态,查表累加分数,最后移走临时棋子:

private int EvaluatePoint(int r, int c, int player) { int score = 0; board.Grid[r, c] = player; // 临时落子 for (int i = 0; i < 4; i++) { int dr = Dirs[i, 0]; int dc = Dirs[i, 1]; int count = 1; bool openLeft = IsOpen(r, c, -dr, -dc, player); bool openRight = IsOpen(r, c, dr, dc, player); count += CountInDirection(r, c, dr, dc, player); count += CountInDirection(r, c, -dr, -dc, player); score += GetPatternScore(count, openLeft, openRight); } board.Grid[r, c] = 0; // 回滚,避免污染真实棋盘 return score; }

openLeft和openRight分别表示方向两端是否有空位。两端都开放就是“活”型,只有一端开放就是“眠”型。GetPatternScore接收连续长度和两端状态,返回评分表里对应的分值。

这里最关键的是回滚。如果忘记把Grid[r,c]重置为0,AI评估完一个点后棋盘上就多一枚临时棋子,后续所有空点判断都会错。更危险的是这个错不会当场崩溃,而会让AI连续几次都选同一个位置,看起来像死循环。深度搜索加到两层之后,这种漏回滚会在递归里越滚越严重。

4.5 候选剪枝:只在前几名里二次搜索

225个空点全部算一遍,单次打分做四个方向,实际耗时很小。但如果你想做两层搜索让AI更有远见,就不能对每个空点都展开下一层,否则计算量会爆炸。常见做法是取当前总得分靠前的8个点,只对它们做下一步搜索:

List<(int r, int c, int score)> candidates = new List<(int, int, int)>(); // 打分循环里把total超过0的点加入candidates candidates.Sort((a, b) => b.score.CompareTo(a.score)); int take = Math.Min(8, candidates.Count); int best = int.MinValue; for (int i = 0; i < take; i++) { // 在candidates[i]位置落子,然后递归调一层AiMove int val = candidateMiniSearch(candidates[i]); if (val > best) best = val; }

候选列表能过滤掉大量无效计算。第一次评估分数很低的点,在一层搜索里多半不会翻盘。这个工程默认只做一层评分,但接口设计允许你不破坏现有逻辑就升级到两层搜索。take从8调到12会稍微提升棋力,但也会增加耗时,普通机器建议不超过12。

5. 避坑与常见问题:坐标偏移、模拟落子回滚、悔棋丢子的四记闷棍

5.1 坐标四舍五入:为什么你最右一列老是点不上

现象:点击棋盘最右边一列,棋子落到倒数第二列;点击靠近上边缘的位置,有时直接数组越界闪退。

原因:直接把鼠标坐标减Margin后除以CellSize,整除默认向下取整。格子右半部分会归到下一列,留白区域还可能算出负数索引。

解决:先加半格对齐再做整除,并做边界判断。

int col = (e.X - GameBoard.Margin + GameBoard.CellSize / 2) / GameBoard.CellSize; int row = (e.Y - GameBoard.Margin + GameBoard.CellSize / 2) / GameBoard.CellSize; if (row < 0 || row >= GameBoard.BoardSize || col < 0 || col >= GameBoard.BoardSize) return;

注意:如果棋盘画在Panel上,e.Y只相对Panel,不再相对Form。把Panel的Location偏移或调整Margin都可以,但不要重复叠加两个偏移,否则又会出现整盘平移。

5.2 模拟落子没回滚:AI为什么总是重复点同一个位置

现象:人机对战中AI第一步正常,但后面开始反复评估同一个空点,棋盘却一直不落子,界面像卡死。

原因:EvaluatePoint里临时把Grid[r,c]设成player棋子,但函数返回前没有改回0。等扫描后续空点时,那个点已经不是空位,候选集合会莫名变小,甚至只剩一个点。

解决:用try-finally保证回滚,或者复制棋盘再评估。更稳妥的写法是:

private int EvaluatePoint(int r, int c, int player) { int score = 0; board.Grid[r, c] = player; try { // 四个方向统计和查表 } finally { board.Grid[r, c] = 0; } return score; }

我在写这种临时落子时会加一句DEBUG断言,确认调用结束后Grid里没有残留临时棋子。别小看这个坑,搜索深度到两层以后,回滚遗漏会变成最难查的隐形Bug。

5.3 悔棋没同步:人机模式悔一步后AI对着空气下棋

现象:玩家点悔棋,自己刚下的黑棋被撤销了,但白棋还留在棋盘上;下一轮AI落子时状态对不上,有时AI直接走到奇怪的位置。

原因:人机模式的回合是玩家和AI交替,玩家悔棋应该退回两步,把棋盘恢复到玩家上次落子之前的局面。只退一步后,玩家的黑棋没了,棋盘上却留着AI的白棋,CurrentPlayer也没切回来,整个状态就乱了。

解决:人机模式调用Undo(2),并强制把CurrentPlayer重置为人类玩家:

public void UndoForHumanVsAI() { if (history.Count >= 2) { Undo(2); // 撤销AI一步和玩家一步 board.CurrentPlayer = 1; } else { Undo(1); board.CurrentPlayer = 1; } Invalidate(); }

重开一局时记得清空历史。不清空的话,第二局悔棋会把上一局的棋子弹出来,棋盘上出现“幽灵子”,复盘时根本没法看。

5.4 黑棋赢了却提示白棋胜

现象:黑棋连成五子,弹窗却显示白棋胜利;白棋连成五子,游戏还在继续。

原因:MakeMove里先切换了CurrentPlayer,再调用CheckWin。而CheckWin内部如果读取CurrentPlayer而不是使用传入的player参数,就会把赢家判断成切换后的新玩家。

解决:CheckWin只认传入的player参数,不读CurrentPlayer;并且在CurrentPlayer切换之前调用CheckWin。

board.Grid[row, col] = player; if (CheckWin(row, col, player)) { GameOver(player); return; } board.CurrentPlayer = (player == 1) ? 2 : 1;

建议把胜负检测和CurrentPlayer切换写在同一个方法里,鼠标路径和AI路径都用它,不要在外面各写一遍,否则顺序不一致很难查。

5.5 DoubleBuffered 设置了还是闪烁

现象:窗体构造函数里已经设置DoubleBuffered = true,但拖动窗口边缘或连续落子时,棋盘仍然有残影和闪烁。

原因:如果你用的是Panel承载绘制,窗体级双缓冲不会自动作用到普通Panel上,Panel有自己的绘制逻辑。

解决:继承Panel并开启双缓冲,然后把窗体上的Panel替换成这个类型。

public class BufferedPanel : Panel { public BufferedPanel() { DoubleBuffered = true; } }

如果仍闪烁,检查是否在OnPaint里创建了大量Brush对象没有释放。虽然GC能回收,但高频重绘时GDI对象压力很大,建议能用using就用using,或者用缓存Brush。

6. 进阶验证:自对弈日志与AI强度调参的核对技巧

AI写完总觉得“时灵时不灵”,单靠人肉试棋很难判断改动是变强还是变弱。我通常会把棋局逻辑抽成不依赖窗体的GameCore,然后在控制台工程里让AI自己和自己下,把对局过程逐手写进日志:

while (step < 225) { var move = (current == aiPlayer) ? AiMove() : AiMove2(); MakeMove(move.row, move.col, current); log.AppendLine($"{gameId},{step},{move.row},{move.col},{current}"); if (CheckWin(move.row, move.col, current)) break; step++; }

日志文件用逗号分隔,放进Excel就能筛选。重点看三组数据:AI先手和后手胜率是否稳定;单步思考时间有没有突然变大;有没有连续一两百步都没分胜负的对局。第三种情况通常说明“活三”的权重不够,双方一直在互堵,没人能率先成型。

调参时不要两个权重一起改。先把防守权重从1.0调到1.2,连跑50局看胜率变化,再单独调攻击权重。同时可以看AI开局第一步:如果永远是天元,说明评分对中心点没有加成;给靠近棋盘中心的空点加1到2分小奖励,开局会更自然,也不至于破坏原评分结构。

从那以后,我每次改完评估函数都会强制走一遍100局自对弈,看统计结果而不是凭一局手感下结论。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询