C#五子棋AI实现:从估值函数到模式匹配的入门指南

1. 项目概述:从零构建一个能“思考”的C#五子棋

最近在整理硬盘,翻出来一个十多年前写的C# WinForm五子棋小游戏,带人机对战功能。当时写它纯粹是为了练手,顺便琢磨一下最简单的AI是怎么“思考”的。没想到现在回头看,这个不到200行核心逻辑的小项目,麻雀虽小,五脏俱全,几乎涵盖了桌面应用开发、游戏逻辑、基础AI算法这几个经典模块。对于想入门C#桌面开发,或者对游戏AI(哪怕是入门级的)感兴趣的朋友来说,这是一个绝佳的练手项目。

这个项目本质上是一个基于估值函数的单步“贪心”AI。它不会像AlphaGo那样“深谋远虑”地推算未来十几步,而是非常“务实”地只考虑当前局面下,走哪一步对自己最有利。听起来很简单,对吧?但正是这种简单,让它成为了理解更复杂博弈算法(比如极大极小值、Alpha-Beta剪枝)的完美跳板。整个项目用C# WinForm实现,UI交互、棋盘绘制、胜负判定、AI决策一应俱全。接下来,我就把这个项目的设计思路、核心代码以及我当年踩过的坑,掰开揉碎了跟大家分享一下。

2. 核心设计思路:如何让电脑“看懂”棋盘?

要让电脑下棋,首先得解决一个根本问题:如何将人类眼中的“棋盘形势”转化为计算机可以量化和比较的“分数”?这就是估值函数(Evaluation Function)的核心任务。

2.1 棋盘与棋子的数据建模

在代码里,棋盘被抽象成一个13x13的二维逻辑网格。但这里有个小技巧:我们并没有用一个二维数组来直接存储,而是用一个List<Pos>来管理所有棋位。Pos是一个自定义的类,记录了每个位置的坐标(X, Y)、是否有棋子(Have_chess)以及棋子类型(Type,0为空,1为白棋,2为黑棋)。

class Pos { public int X { get; set; } public int Y { get; set; } public bool Have_chess { get; set; } public int Type { get; set; } // ... 构造函数 } private List<Pos> null_chess_pos_list; // 存储所有棋位状态的列表

为什么用List而不是二维数组?在当时,主要是为了遍历和查找空位方便。List配合Linq,写起来更“C#”一些。但回过头看,如果纯粹追求性能,二维数组在内存访问上会更连续、更快。不过对于13x13这种小规模棋盘,差异微乎其微。

实操心得:在小型项目中,代码的清晰度和可维护性往往比极致的性能更重要。用List<Pos>让棋位状态成为一个“对象”,后续要添加新属性(比如这个位置的历史评分、热度等)会非常方便。这是一种面向对象思维的体现。

2.2 胜负判定逻辑:四向递归扫描

五子棋的胜负判定规则很直观:横、竖、左斜、右斜四个方向,任意一方有连续五个同色棋子即获胜。在代码中,这个逻辑被拆分成四个独立的递归函数:horizontal_check,vertical_check,left_up_check,right_up_check

每个函数的逻辑都类似:从一个棋子出发,沿着一个方向(比如横向向右)递归检查下一个位置是否也是同色棋子,累计连续数量。当数量达到5时,触发游戏结束。

private int horizontal_check(Pos pos, int count) { if (count == 5) // 五连珠达成 { // 弹出获胜提示 game_over = true; } // 递归检查下一个位置 if (pos.X + 1 < 13 && pos.Type != 0) { var nextPosIndex = get_pos_index_from_null_chess_list(pos.X + 1, pos.Y); if (null_chess_pos_list[nextPosIndex].Type == pos.Type) horizontal_check(null_chess_pos_list[nextPosIndex], count + 1); } return count; }

这里有一个关键细节:每次玩家或AI落子后,check_win()函数会遍历棋盘上每一个有棋子的位置,并从这个位置出发,向四个方向进行胜负判定。这是一个O(n*m)的操作(n为棋子数量,m为方向数),在棋盘较空时效率尚可,但当棋子布满大半棋盘时,会有不必要的性能开销。

优化思路:更高效的做法是只检查最新落子的那个点。因为新形成的五连珠必然包含这个最新落子。只需从这个新落子点出发,向四个方向延伸检查即可,将复杂度降至常数级O(1)。这是我后来重构时才优化的点,最初的版本采用了全局遍历,是典型的“初学者思维”,虽然逻辑正确但不够高效。

2.3 AI大脑:基于模式匹配的估值函数

这是本项目的灵魂。AI如何决定下一步走哪里?其核心是一个预定义的模式字典和一个评分函数

第一步:定义模式与分数。init_score_dict()函数中,我们预先定义了一系列棋盘模式(用字符串表示,如"22222"代表黑棋五连)及其对应的分数。分数设定是主观的,但遵循一个核心原则:对自己越有利(或对对手威胁越大)的模式,分数越高。

private void init_score_dict() { // 黑棋(AI)五连,直接获胜,分数极高 dict.Add("22222", 122222); // 黑棋活四(两边都没被堵住),离胜利一步之遥 dict.Add("022220", 12222); // 黑棋冲四(一边被堵住),威胁也很大 dict.Add("122220", 5222); // 白1黑2222空0 // 黑棋活三 dict.Add("02220", 2522); // ... 其他模式,如死四、活二、跳三等 }

字符串中的数字含义:0代表空位,1代表白棋(玩家),2代表黑棋(AI)。模式"122220"表示“白棋 | 黑棋黑棋黑棋黑棋 | 空位”,这是一个黑棋的“冲四”局面,非常危险。

第二步:为每个空位打分。AI在决策时,会遍历棋盘上所有空位。对于每一个空位,它会进行一个思维实验:如果我把我的棋子(黑棋)放在这里,会形成什么样的局面?

  1. 模拟落子:创建一个临时的Pos对象,其Type设为2(黑棋)。
  2. 获取四向格局:以这个虚拟落子点为中心,向横、竖、左斜、右斜四个方向,各取一个长度为最多11个位置的字符串(中心点两边各延伸最多5格)。这个字符串代表了该方向上的棋子分布。
    // 例如,获取横向格局 private string get_horizontal_pos_str(Pos tempPos) { // 向左、向右各找最多5个位置,连同中心点自己,组成一个字符串 // 比如可能是 "00122200" }
  3. 模式匹配与计分:将得到的四个方向的字符串,与模式字典中的每一个键进行匹配。如果当前格局包含某个模式(或其反转字符串,因为方向可逆),就把该模式对应的分数累加到当前空位的总分上。
    int score = 0; string horizontalPattern = get_horizontal_pos_str(virtualPos); foreach (var pattern in dict) { if (horizontalPattern.Contains(pattern.Key) || horizontalPattern.Contains(ReverseString(pattern.Key))) score += pattern.Value; } // 对 vertical, leftUp, rightUp 方向做同样操作

第三步:选择最高分。所有空位都打完分后,AI简单地选择分数最高的那个空位落子。这就是“贪心”算法——只选择当前瞬间看起来最好的那一步,不考虑对手的后续反应。

深度解析:为什么模式匹配有效?这个方法的巧妙之处在于,它把复杂的棋盘形势评估,转化为了字符串的模式识别问题。"02220"(活三)比"00220"(活二)分数高,是因为活三的获胜概率远大于活二。通过给不同模式赋予精心设计的分数,我们实际上是将人类棋手的经验(“活三要堵”,“冲四必须防”)编码进了程序。虽然它没有“前瞻性”,但通过评估所有可能的“下一步”所形成的静态局面,它依然能做出相当不错的防守和进攻选择。

3. 关键代码模块拆解与实现细节

3.1 界面绘制与交互:透明按钮的妙用

WinForm绘制棋盘通常有两种方式:1) 在Paint事件中用Graphics画线;2) 使用TableLayoutPanelPanel动态放置控件。这里采用了第一种方式绘制网格线,而棋子则是用动态生成的Button控件来表现。

绘制棋盘网格:draw_chess_grid()中,使用Graphics.DrawLine方法画出13x13的网格线。这里坐标计算是关键:棋盘左上角起点是(50, 50),每个格子宽高50像素。

private void draw_chess_grid() { var graphics = this.CreateGraphics(); var pen = new Pen(Brushes.Black, 2.0f); // 画竖线 for (int x = 0; x < 13; x++) { graphics.DrawLine(pen, new Point(50 + x * 50, 50), new Point(50 + x * 50, 50 + 50 * 12)); } // 画横线(类似逻辑) }

创建棋子落点(透明按钮):为了让玩家能点击落子,我们在每个网格交叉点(共13x13=169个)放置了一个透明的Button。这是UI交互的核心。

private void new_chess_pos(int x, int y) { var button = new Button(); button.Location = new Point(x, y); // 位置对应网格交叉点 button.Size = new Size(40, 40); // 按钮比格子稍小,视觉更舒适 set_btn_style(button); // 关键:设置为透明无边框样式 this.Controls.Add(button); button.Click += new EventHandler(button1_Click); // 绑定点击事件 buttons.Add(button); // 统一管理 } private void set_btn_style(Button btn) { btn.FlatStyle = FlatStyle.Flat; btn.ForeColor = Color.Transparent; btn.BackColor = Color.Transparent; // 背景透明 btn.FlatAppearance.BorderSize = 0; // 无边框 // 鼠标悬浮和按下时也保持透明 btn.FlatAppearance.MouseOverBackColor = Color.Transparent; btn.FlatAppearance.MouseDownBackColor = Color.Transparent; }

踩坑记录:透明按钮的点击区域。最初我把按钮大小设成和格子一样大(50x50),但发现相邻按钮的边缘会重叠,导致点击一个位置可能触发两个按钮的事件。后来将按钮大小调整为40x40,小于格子间距,解决了这个问题。UI设计时,交互控件的热区一定要考虑清楚,避免重叠或歧义。

3.2 游戏状态管理与坐标转换

游戏中有两套坐标系统:

  1. 窗口坐标(像素):按钮的Location属性,用于界面显示。
  2. 逻辑坐标(网格):棋子在13x13棋盘上的(X, Y)索引,用于所有游戏逻辑计算。

两者需要频繁转换:

  • 窗口坐标 -> 逻辑坐标(button.Location.X - 30) / 50。因为棋盘起点是(30, 30),每个格子50像素。
  • 逻辑坐标 -> 窗口坐标pos.X * 50 + 30
private Point location_to_point(Button button) { return new Point((button.Location.X - 30) / 50, (button.Location.Y - 30) / 50); } private Point point_to_location(Pos pos) { return new Point(pos.X * 50 + 30, pos.Y * 50 + 30); }

状态同步null_chess_pos_list列表是核心的状态存储。每当一个按钮被点击(落子),程序会:

  1. 通过location_to_point找到对应的逻辑坐标。
  2. null_chess_pos_list中找到对应的Pos对象。
  3. 更新其Have_chess = trueType(1或2)。
  4. 同时,改变按钮的BackgroundImage来显示白棋或黑棋的图片。

这种数据与视图分离的设计,虽然简单,但概念清晰。所有胜负判断、AI计算都只依赖于null_chess_pos_list这个数据源,UI只是它的一个可视化反映。

3.3 AI决策核心:get_best_pos()函数详解

这是AI的“决策中心”,让我们一步步拆解:

private Pos get_best_pos() { // 1. 初始化一个字典,用于存储每个空位及其评分 var dict_score = new Dictionary<Pos, int>(); // 2. 遍历所有棋位,只处理空位 for (int i = 0; i < null_chess_pos_list.Count; i++) { if (null_chess_pos_list[i].Have_chess == false) { // 3. 关键:为AI(黑棋2)模拟落子在这个空位 // 注意:这里创建了一个新的Pos对象,Type设为2,但并未修改原始棋盘数据 Pos virtualPos = new Pos(null_chess_pos_list[i].X, null_chess_pos_list[i].Y, null_chess_pos_list[i].Have_chess, 2); // 假设AI(黑棋)落子 // 4. 调用评分函数,计算如果AI落子在此,局面的得分 int score = get_pos_score(virtualPos); // 5. 将原始的空位(不是virtualPos)和得分存入字典 dict_score.Add(null_chess_pos_list[i], score); } } // 6. 按分数从高到低排序 var sortedDict = dict_score.OrderByDescending(o => o.Value) .ToDictionary(p => p.Key, o => o.Value); // 7. 返回分数最高的那个空位 return sortedDict.Keys.First(); }

这里有一个极其重要的细节:第3步中,我们创建了一个新的Pos对象virtualPos,并将其Type设为2,用于模拟评分。我们没有直接修改null_chess_pos_list[i]Type。这是因为评分过程是“假设性”的,我们不能因为评估一个位置就污染了真实的棋盘状态。这个virtualPos对象在评分函数get_pos_score中使用后就被丢弃了。

get_pos_score(Pos pos)函数的工作,就是前面提到的,以pos为中心,获取四个方向的格局字符串,并与模式字典匹配,累加分数。

3.4 模式字典的设计哲学与分数设定

模式字典是这个AI的“知识库”。分数不是随便填的,它直接决定了AI的棋风是激进还是保守。我们来分析一下原代码中的一些设定:

dict.Add("22222", 122222); // 己方五连,必胜,分数最高 dict.Add("022220", 12222); // 己方活四,下一手必胜,分数次高 dict.Add("122220", 5222); // 己方冲四(一端被堵),必须应对,分数高 dict.Add("02220", 2522); // 己方活三,有发展潜力,分数中高 dict.Add("00220", 522); // 己方活二,基础连接 dict.Add("211112", 52222); // **注意这个**:这是“白棋五连”?不,仔细看是“白1 | 黑1 | 白1 | 白1 | 黑2”,这似乎是个无效或错误模式。这可能是一个笔误。

常见问题与修正: 原代码中的"211112"等模式令人费解。一个合理的五子棋模式字典,应该专注于描述单一颜色的棋子排列。通常,我们会分别为**AI(黑棋)玩家(白棋)**定义威胁模式,并在评分时同时评估。

一个更清晰的思路是:

  1. 评估AI落子(进攻):计算如果AI下这里,能形成多强的攻击阵型(黑棋模式)。
  2. 评估玩家落子(防守):计算如果玩家下这里,会形成多强的攻击阵型(白棋模式)。AI需要优先阻止玩家的高威胁点位。

因此,更合理的实现是双模式字典,或者在一个字典中同时包含对黑棋和白棋模式的评分。AI的最终得分可能是:进攻分 * 进攻权重 + 防守分 * 防守权重。原代码似乎试图在一个字典里混合两种颜色的模式,这容易导致逻辑混乱和评分失衡。

我的优化方案:我后来重构时,将模式字典分成了两个:attackPatterns(黑棋模式)和defendPatterns(白棋模式)。get_pos_score函数会分别计算“假设我下这里”的进攻分,和“假设对手下这里”的威胁分。AI的最终选择会综合考虑:优先堵死对手的活四、冲四,其次创造自己的活四、活三。这样AI会显得“聪明”很多,攻防兼备。

4. 项目扩展与优化方向

这个200行的Demo是一个完美的起点,但也有很多可以深化和优化的地方。

4.1 从“单步贪心”到“多步搜索”:极大极小值算法

当前AI最大的局限是“短视”。它看不到对手的应对。比如,它可能创造一个自己的“活三”,但没注意到对手在另一边已经有一个“冲四”了,结果只顾进攻而输掉比赛。

引入极大极小值算法可以解决这个问题。其核心思想是:

  • AI(最大化玩家)试图选择让最终分数最大化的走法。
  • 对手(最小化玩家)试图选择让最终分数最小化的走法。
  • 通过递归模拟未来几步(例如3步:AI -> 玩家 -> AI),形成一个搜索树。
  • 在树的叶子节点,用我们现在的估值函数计算局面分数。
  • 通过回溯,选择对AI最有利的路径。

简单来说,就是AI会想:“如果我走这里,对手最好的应对是那里,然后我再这样走……最后局面大概会是多少分?”

结合Alpha-Beta剪枝,可以大幅减少需要评估的节点数,提升搜索深度。我们的单步估值函数get_pos_score,在这里就成为了极大极小值算法中评估叶子节点局面的静态评估函数。这是从“直觉型”AI升级到“思考型”AI的关键一步。

4.2 性能优化实战

  1. 胜负判定优化:如前所述,将全局遍历改为仅检查最新落子点。
  2. AI评分缓存get_pos_score函数计算量较大,涉及大量字符串生成和模式匹配。可以引入一个缓存字典Dictionary<string, int>,键是“位置坐标+当前棋盘快照的哈希值”,值是该位置的评分。当棋盘变化不大时,可以直接使用缓存值。
  3. 空位筛选(启发式搜索):AI不需要评估棋盘上所有169个空位。通常,有意义的落子点都在已有棋子的周围。可以首先收集所有已有棋子周围一格范围内的空位,只对这些“热点区域”进行评分,能极大减少计算量。
  4. 使用位棋盘(Bitboard):对于五子棋这种二元状态(黑、白、空)的游戏,使用位运算(一个64位long型整数表示一种棋子的分布)可以极大提升状态判断、模式匹配的速度。这是高级棋类AI的常用技巧,但实现复杂度较高。

4.3 功能增强与体验提升

  1. 游戏模式:当前有双人、人机、联网(预留)模式。可以完善联网对战,使用Socket或网络库实现双人远程对战。
  2. 难度分级
    • 简单:使用当前的单步贪心算法。
    • 中等:实现2-3层深度的极大极小值搜索。
    • 困难:实现4-5层深度的Alpha-Beta剪枝搜索,并配合更复杂的估值函数。
  3. UI/UX改善
    • 落子动画和音效。
    • 显示最后获胜的五颗棋子(高亮)。
    • 实现游戏回放和保存/加载功能。
  4. 代码重构
    • 将游戏逻辑(Game)、棋盘数据(Board)、AI引擎(AIEngine)、UI控制器(Form)分离,遵循MVC或类似模式。这样代码更清晰,也便于单元测试。
    • 将模式字典、评分函数等配置参数外置到配置文件,方便调整AI难度和棋风。

5. 开发中遇到的典型问题与解决方案

5.1 棋盘坐标偏移与数组越界

这是最初调试时最常见的问题。棋盘逻辑是13x13,索引从0到12。但在向四个方向延伸获取格局字符串时,很容易写出while (head_x - 1 >= 0)这样的边界条件,>=0是对的,但有时会错写成>0,导致漏掉边界上的格子。

解决方案:在get_horizontal_pos_str这类函数中,将边界检查逻辑单独提取成方法,并仔细测试边界情况(如棋子在棋盘四个角上时)。

5.2 模式字典的“漏判”与“误判”

最初设计的模式字典可能不完整,导致某些重要局面(如“跳活三”:020220)没有被识别,或者某些无关紧要的局面被赋予了高分。

解决方案

  1. 系统化枚举:理论上,在5个点的序列中,枚举所有黑棋和白棋的组合情况,并根据其威胁程度人工赋予分数。这是一个繁琐但必要的过程。
  2. 测试驱动:编写单元测试,构造大量典型棋形(活三、冲四、双活三等),验证AI是否能正确识别并走到最佳应对点。
  3. 动态调整:让AI自己跟自己下(自我对弈),记录输赢,用简单的强化学习思路微调模式分数。例如,如果某种模式经常出现在赢棋方,就适当提高其分数。

5.3 AI“智障”时刻:忽视防守

这是单步贪心算法的通病。AI可能只顾着连接自己的两个子,而对玩家即将成五的“冲四”视而不见。

解决方案:如前所述,在评分函数中加入对对手威胁的评估。计算某个空位对白棋(玩家)的潜在分数,并将这个“防守分”以更高的权重加入到总评分中。例如:

// 伪代码 int aiScore = EvaluatePosition(pos, AI_COLOR); // AI下这里的进攻价值 int playerScore = EvaluatePosition(pos, PLAYER_COLOR); // 玩家下这里的威胁程度 int finalScore = aiScore + playerScore * DEFEND_WEIGHT; // 防守权重可以设为1.5或2

这样,AI会优先抢占那些既能发展自己,又能破坏对手的关键点。

5.4 悔棋功能在AI对战中的特殊性

在双人模式下,悔棋一步即可。但在人机对战模式下,玩家悔棋时,需要连续回溯两步:先撤销玩家上一步,再撤销AI对应的上一步。原代码在button_back_Click事件中对此做了处理:

case 1: // 人机对战模式 back_chess(); // 撤销玩家步 back_chess(); // 撤销AI步 break;

这保证了棋盘状态和回合顺序的正确性。

6. 总结与资源

这个C#五子棋项目,虽然代码量不大,但它像一颗棱镜,折射出小型游戏项目开发的多个基本面:UI绘制与交互、核心游戏逻辑、基础AI算法。通过它,你可以实践面向对象设计、事件驱动编程、递归算法、以及最重要的——如何将模糊的人类策略(“这里好像很重要”)转化为精确的计算机指令(“这个模式的分数是12222”)。

给初学者的建议

  1. 先跑起来:把代码复制到Visual Studio中,理解每一行,确保它能运行。
  2. 修改与破坏:尝试修改模式字典的分数,观察AI行为的变化。故意写一个错误的胜负判断,看看会怎样。
  3. 实现一个功能:比如,给游戏加上“提示”功能,高亮显示AI认为当前最好的落子点。
  4. 挑战自己:尝试实现前面提到的“极大极小值算法”的简化版(比如只思考2层)。

这个项目的完整源码,你可以在许多开源代码托管平台(如GitHub)上搜索“C# Gomoku”或“C# Five in a Row”找到类似的实现。我建议不要直接复制,而是参照思路,自己动手从头实现一遍。过程中遇到的每一个bug和每一次优化,都是你实实在在的成长。编程的乐趣,就在于这种从无到有、从笨拙到精巧的创造过程。希望这个小小的五子棋,能成为你探索更广阔编程世界的一个有趣起点。