C# WinForms中国象棋开发:从GDI+绘图到AI算法实现

C# WinForms中国象棋开发:从GDI+绘图到AI算法实现

1. 项目概述与核心价值

最近在整理个人代码仓库时,翻出了一个几年前用C#写的中国象棋项目,当时是为了深入理解Windows窗体应用(WinForms)的图形绘制、事件驱动和游戏逻辑而做的。这个项目麻雀虽小,五脏俱全,从棋盘绘制、棋子移动规则、胜负判定到简单的AI走法生成都实现了。今天把它拿出来,结合现在的开发视角重新梳理一遍,希望能给想用C#做桌面游戏开发、或者对棋类游戏逻辑实现感兴趣的朋友提供一个完整的、可运行的参考案例。这个项目不依赖任何复杂的游戏引擎,纯粹用GDI+绘图和基础的面向对象思想,非常适合初学者理解一个完整桌面应用的架构。

中国象棋的规则大家都不陌生,但要把这些规则用代码精确地表达出来,并且有一个清晰的界面与之交互,这里面涉及的知识点非常综合。它考验了你对C# WinForms控件(尤其是PictureBoxPaint事件)的掌握,对GDI+绘图(画线、画圆、贴图)的熟练度,更核心的是对游戏状态管理、算法逻辑(特别是棋子的移动规则和简单的AI搜索)的设计能力。通过这个项目,你能系统地练习如何将复杂的业务规则(象棋规则)转化为清晰、可维护的代码结构,这是从“写功能”到“设计程序”的关键一步。

2. 项目整体架构与设计思路

2.1 技术选型与开发环境

为什么选择C# WinForms而不是WPF或Unity?对于这类2D棋盘类游戏,WinForms配合GDI+绘图在开发效率和复杂度上取得了很好的平衡。WPF的MVVM模式和数据绑定对于动态UI更强大,但学习曲线稍陡,且对于这种需要频繁自定义绘制的场景,WinForms的OnPaint事件处理起来更直接。Unity则过于“重型”,杀鸡用牛刀。开发环境我使用的是Visual Studio 2022,.NET Framework 4.7.2(项目也可轻松迁移到.NET 6/8的Windows窗体应用),确保良好的兼容性和丰富的控件支持。

核心依赖就是.NET自带的System.Drawing命名空间,用于所有图形绘制。不需要引入第三方图形库或游戏引擎,这保证了项目的纯粹性和可移植性。整个项目的解决方案结构非常清晰:一个主窗体项目,包含棋盘控件、游戏逻辑类、棋子类以及AI逻辑类。

2.2 核心类设计与职责划分

良好的面向对象设计是项目可维护性的基石。我将整个象棋游戏抽象为以下几个核心类:

  1. ChessBoard(棋盘类):这是游戏的核心容器。它不负责直接绘制,而是维护一个10行9列的二维数组(Piece[,]),用于记录当前棋盘上所有棋子的状态(位置、类型、阵营)。它提供了查询某个位置棋子、移动棋子、判断移动是否合法等核心方法。棋盘类是游戏规则的执行者。

  2. Piece(棋子基类及派生类):定义了一个抽象的Piece基类,包含棋子颜色(红/黑)、类型(车、马、炮等)、当前位置等属性。关键是一个抽象的IsValidMove方法。然后,为每种棋子(Rook车,Knight马,Cannon炮,Guard士,Elephant象,King将,Pawn兵)创建派生类,重写IsValidMove方法,实现各自独特的移动规则。这是策略模式的典型应用,新增棋子类型非常方便。

  3. GameManager(游戏管理器):单例或静态类,负责协调整个游戏流程。它持有ChessBoard实例,管理当前行棋方(红先黑后)、游戏状态(进行中、红胜、黑胜、和棋)、记录棋谱。它还作为UI层(窗体)和逻辑层(棋盘、棋子)之间的桥梁,处理用户的点击事件,并调用AI逻辑。

  4. AIPlayer(AI玩家类):实现了一个简单的AI。核心是“极大极小搜索算法”(Minimax)配合“Alpha-Beta剪枝”。AI会模拟未来几步棋,并用一个评估函数给棋盘局面打分(例如,计算双方棋子价值差、棋子位置优势等),然后选择对自己最有利的走法。这部分是项目的算法核心,即使实现一个深度为3-4层的搜索,也能做出有一定思考的走法。

  5. MainForm(主窗体):WinForms的主窗体。主要包含一个PictureBox控件作为画布,在其Paint事件中调用绘制方法,绘制棋盘格子和棋子。它监听鼠标的MouseDownMouseUp事件,用于实现棋子的拖拽或点击选择。

设计心得:将绘制(UI)、规则(逻辑)、控制(流程)分离是关键。MainForm只关心“怎么画”和“用户点了哪里”;GameManager关心“点了之后要做什么”;ChessBoardPiece关心“这么做符不符合规则”。这种分离使得单元测试、更换UI(比如控制台界面)或增强AI都变得容易。

3. 核心模块实现细节解析

3.1 棋盘与棋子的绘制

绘制是所有视觉反馈的基础。我们在MainFormPictureBox控件的Paint事件处理程序中完成所有绘制工作。

private void pictureBox_ChessBoard_Paint(object sender, PaintEventArgs e) { Graphics g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; // 开启抗锯齿,让线条更平滑 g.Clear(Color.BurlyWood); // 设置棋盘背景色 // 1. 绘制棋盘网格 (10条横线,9条竖线) Pen gridPen = new Pen(Color.Black, 2); int cellSize = 60; // 每个格子的像素大小 int boardWidth = 9 * cellSize; int boardHeight = 10 * cellSize; // 画横线 for (int i = 0; i <= 9; i++) { int y = i * cellSize; g.DrawLine(gridPen, 0, y, boardWidth, y); } // 画竖线 for (int j = 0; j <= 8; j++) { int x = j * cellSize; g.DrawLine(gridPen, x, 0, x, boardHeight); } // 2. 绘制九宫格斜线(将/帅的活动范围) // 左上角九宫格斜线 g.DrawLine(gridPen, 3 * cellSize, 0, 5 * cellSize, 2 * cellSize); g.DrawLine(gridPen, 5 * cellSize, 0, 3 * cellSize, 2 * cellSize); // 右下角九宫格斜线(同理,坐标需计算) // 3. 绘制棋子 Piece[,] board = GameManager.Instance.Board.Pieces; for (int row = 0; row < 10; row++) { for (int col = 0; col < 9; col++) { Piece piece = board[row, col]; if (piece != null) { DrawPiece(g, piece, col, row, cellSize); } } } // 4. 如果当前有选中的棋子,高亮其位置和可走位置 if (_selectedPiece != null) { // 高亮选中棋子(画一个红色圆圈) // 计算并高亮所有_isValidMove返回true的位置 } }

DrawPiece方法负责绘制单个棋子。我们可以用Graphics.DrawEllipse画圆,用Graphics.DrawString在圆中心写上“车”、“马”等文字。更美观的做法是使用棋子图片(Bitmap),根据棋子类型和阵营加载不同的资源。

private void DrawPiece(Graphics g, Piece piece, int col, int row, int cellSize) { int centerX = col * cellSize + cellSize / 2; int centerY = row * cellSize + cellSize / 2; int radius = cellSize / 2 - 5; // 绘制圆形棋子背景 Color bgColor = piece.Color == PieceColor.Red ? Color.Red : Color.Black; Color textColor = piece.Color == PieceColor.Red ? Color.White : Color.White; using (Brush brush = new SolidBrush(bgColor)) { g.FillEllipse(brush, centerX - radius, centerY - radius, radius * 2, radius * 2); } using (Pen pen = new Pen(Color.Goldenrod, 2)) // 金色边框 { g.DrawEllipse(pen, centerX - radius, centerY - radius, radius * 2, radius * 2); } // 绘制棋子文字 string pieceChar = GetPieceChar(piece.Type); // 根据棋子类型返回“車”、“馬”等 using (Font font = new Font("楷体", 20, FontStyle.Bold)) using (Brush textBrush = new SolidBrush(textColor)) { SizeF textSize = g.MeasureString(pieceChar, font); g.DrawString(pieceChar, font, textBrush, centerX - textSize.Width / 2, centerY - textSize.Height / 2); } }

实操要点:绘制性能优化。如果棋子很多且频繁重绘,频繁创建Pen,Brush,Font对象会导致GC(垃圾回收)压力。一个常见的优化是,将这些对象在类级别缓存起来,在窗体加载时创建,在Paint事件中重复使用。另外,可以通过设置PictureBoxDoubleBuffered属性为true(或自定义控件重写该属性)来启用双缓冲,能有效减少绘制时的闪烁。

3.2 棋子移动规则的精确实现

这是游戏逻辑的核心,每一种棋子的IsValidMove方法都必须严格符合中国象棋规则。我们以最复杂的“马”和“炮”为例。

马的走法(马走日,蹩马腿)

public override bool IsValidMove(ChessBoard board, int toRow, int toCol) { int rowDiff = Math.Abs(toRow - CurrentRow); int colDiff = Math.Abs(toCol - CurrentCol); // 必须走“日”字: (2,1) 或 (1,2) if (!((rowDiff == 2 && colDiff == 1) || (rowDiff == 1 && colDiff == 2))) { return false; } // 检查“蹩马腿” int blockRow, blockCol; if (rowDiff == 2) // 竖向走日 { blockRow = CurrentRow + (toRow - CurrentRow) / 2; // 马腿的竖直方向位置 blockCol = CurrentCol; // 马腿的水平方向位置与马同列 } else // 横向走日 { blockRow = CurrentRow; // 马腿的竖直方向位置与马同行 blockCol = CurrentCol + (toCol - CurrentCol) / 2; // 马腿的水平方向位置 } // 如果马腿位置有任意棋子,则被蹩腿,不能走 if (board.GetPieceAt(blockRow, blockCol) != null) { return false; } // 目标位置无棋子或有敌方棋子,则可以走(吃子) Piece targetPiece = board.GetPieceAt(toRow, toCol); return targetPiece == null || targetPiece.Color != this.Color; }

炮的走法(隔山打牛)

public override bool IsValidMove(ChessBoard board, int toRow, int toCol) { // 炮必须直线移动(横或竖) if (CurrentRow != toRow && CurrentCol != toCol) { return false; } int piecesBetween = CountPiecesBetween(board, CurrentRow, CurrentCol, toRow, toCol); Piece targetPiece = board.GetPieceAt(toRow, toCol); // 情况1:目标位置为空(移动) if (targetPiece == null) { // 移动时,中间必须没有棋子 return piecesBetween == 0; } // 情况2:目标位置有棋子(吃子) else if (targetPiece.Color != this.Color) { // 吃子时,中间必须恰好有一个棋子(作为“炮架”) return piecesBetween == 1; } // 情况3:目标位置是己方棋子,不能吃 return false; } // 辅助方法:计算两点之间直线上的棋子数(不包括起点和终点) private int CountPiecesBetween(ChessBoard board, int fromRow, int fromCol, int toRow, int toCol) { int count = 0; int rowStep = Math.Sign(toRow - fromRow); int colStep = Math.Sign(toCol - fromCol); int curRow = fromRow + rowStep; int curCol = fromCol + colStep; while (curRow != toRow || curCol != toCol) { if (board.GetPieceAt(curRow, curCol) != null) { count++; } curRow += rowStep; curCol += colStep; } return count; }

避坑指南:规则实现的测试。象棋规则边界情况很多,比如“将帅不能照面”、“兵过河前只能前进,过河后可以横移”、“士不出九宫”等。务必为每个棋子的移动规则编写单元测试,覆盖正常移动、吃子、非法移动(如马被蹩腿、炮无炮架吃子)等情况。手动测试很容易遗漏。可以使用NUnit或xUnit框架,这是保证逻辑正确的关键。

3.3 游戏流程与用户交互控制

游戏流程由GameManager控制。主窗体通过鼠标事件与用户交互。

// 在主窗体中 private Piece _selectedPiece = null; private List<Point> _validMoves = new List<Point>(); private void pictureBox_ChessBoard_MouseDown(object sender, MouseEventArgs e) { int cellSize = 60; int col = e.X / cellSize; int row = e.Y / cellSize; // 边界检查 if (col < 0 || col >= 9 || row < 0 || row >= 10) return; Piece clickedPiece = GameManager.Instance.Board.GetPieceAt(row, col); // 如果当前没有选中棋子,且点击位置有己方棋子,则选中它 if (_selectedPiece == null) { if (clickedPiece != null && clickedPiece.Color == GameManager.Instance.CurrentPlayer) { _selectedPiece = clickedPiece; _validMoves = GameManager.Instance.Board.GetValidMovesForPiece(_selectedPiece); pictureBox_ChessBoard.Invalidate(); // 触发重绘,高亮选中状态 } } // 如果已有选中棋子,则尝试移动 else { // 检查目标位置是否在可走位置列表中 if (_validMoves.Contains(new Point(col, row))) { // 执行移动 bool moveSuccess = GameManager.Instance.MakeMove(_selectedPiece, row, col); if (moveSuccess) { // 移动成功,切换玩家,清空选中状态 _selectedPiece = null; _validMoves.Clear(); pictureBox_ChessBoard.Invalidate(); // 检查游戏是否结束(将死、困毙) GameState state = GameManager.Instance.CheckGameState(); if (state != GameState.Playing) { MessageBox.Show($"游戏结束!{state}"); } // 如果是AI回合,触发AI走棋 if (GameManager.Instance.CurrentPlayer == PieceColor.Black && GameManager.Instance.IsAIPlaying) { AIPlayer.MakeAIMove(); // AI走完后,同样需要重绘和检查游戏状态 pictureBox_ChessBoard.Invalidate(); GameState newState = GameManager.Instance.CheckGameState(); // ... 处理游戏状态 } } } // 如果点击的是另一个己方棋子,则切换选中 else if (clickedPiece != null && clickedPiece.Color == GameManager.Instance.CurrentPlayer) { _selectedPiece = clickedPiece; _validMoves = GameManager.Instance.Board.GetValidMovesForPiece(_selectedPiece); pictureBox_ChessBoard.Invalidate(); } // 点击其他无效位置,取消选中 else { _selectedPiece = null; _validMoves.Clear(); pictureBox_ChessBoard.Invalidate(); } } }

GameManager.MakeMove方法是核心,它内部会调用board.MovePiece,而board.MovePiece会验证走法是否合法,如果合法则更新棋盘数组,并记录棋步。

4. 简单AI算法的实现与优化

4.1 极大极小搜索与评估函数

一个最简单的AI可以使用随机走法,但毫无挑战性。我们实现一个基于极大极小算法(Minimax)的AI。其核心思想是:AI(黑方)试图最大化局面评分,而假设玩家(红方)会试图最小化局面评分。AI通过递归地模拟未来几步棋,来选择最优走法。

首先,需要一个评估函数EvaluateBoard,给任何棋盘局面打一个分。分数越高对黑方(AI)越有利。

private int EvaluateBoard(ChessBoard board) { int score = 0; // 简单的子力价值评估 Dictionary<PieceType, int> pieceValues = new Dictionary<PieceType, int>() { {PieceType.King, 10000}, {PieceType.Rook, 500}, {PieceType.Knight, 300}, {PieceType.Cannon, 300}, {PieceType.Guard, 200}, {PieceType.Elephant, 200}, {PieceType.Pawn, 100} }; for (int row = 0; row < 10; row++) { for (int col = 0; col < 9; col++) { Piece piece = board.GetPieceAt(row, col); if (piece != null) { int value = pieceValues[piece.Type]; // 黑棋(AI)的棋子加正分,红棋(玩家)的棋子减分 score += (piece.Color == PieceColor.Black) ? value : -value; // (可选)位置价值加成:鼓励棋子走到中心或有利位置 // score += GetPositionBonus(piece, row, col); } } } return score; // 正数对黑方有利,负数对红方有利 }

然后,实现Minimax搜索的核心递归函数:

// depth: 搜索深度,当前层数 // isMaximizingPlayer: 当前层是最大化玩家(AI)还是最小化玩家(玩家) private int Minimax(ChessBoard board, int depth, int alpha, int beta, bool isMaximizingPlayer) { // 终止条件:达到搜索深度,或游戏结束 if (depth == 0 || board.IsGameOver()) { return EvaluateBoard(board); } List<Move> allMoves = GenerateAllMoves(board, isMaximizingPlayer ? PieceColor.Black : PieceColor.Red); // 对走法进行排序(好的走法先搜索)可以提升Alpha-Beta剪枝效率 // OrderMoves(allMoves, board); if (isMaximizingPlayer) // AI层,取最大值 { int maxEval = int.MinValue; foreach (Move move in allMoves) { // 尝试走这一步 board.MakeMove(move.FromRow, move.FromCol, move.ToRow, move.ToCol); int eval = Minimax(board, depth - 1, alpha, beta, false); board.UndoMove(); // 撤销这一步,回溯 maxEval = Math.Max(maxEval, eval); alpha = Math.Max(alpha, eval); if (beta <= alpha) { break; // Beta剪枝 } } return maxEval; } else // 玩家层,取最小值 { int minEval = int.MaxValue; foreach (Move move in allMoves) { board.MakeMove(move.FromRow, move.FromCol, move.ToRow, move.ToCol); int eval = Minimax(board, depth - 1, alpha, beta, true); board.UndoMove(); minEval = Math.Min(minEval, eval); beta = Math.Min(beta, eval); if (beta <= alpha) { break; // Alpha剪枝 } } return minEval; } }

GenerateAllMoves函数需要生成当前局面下,某一方所有可能的合法走法。这是AI性能的关键,生成走法要快且全。

4.2 Alpha-Beta剪枝优化

上面的Minimax函数已经包含了Alpha-Beta剪枝。它的原理是:在搜索过程中,维护两个值alphabetaalpha代表当前层(最大化层)已经找到的最好值,beta代表上一层(最小化层)已经找到的最差值。如果在搜索一个分支时,发现它的值对于当前层来说已经“太差”,以至于上一层不可能选择它,那么就可以提前终止对这个分支的搜索(剪枝),从而大幅减少需要评估的节点数。

在代码中,if (beta <= alpha) break;就是剪枝条件。要最大化剪枝效果,走法顺序至关重要。应该优先搜索那些看起来最好的走法(例如,吃子、将军、走到中心位置),这样更容易触发剪枝条件。这就是上面代码中OrderMoves函数的作用。

4.3 AI走法决策入口

最后,AI的入口函数就是遍历当前所有可能走法,调用Minimax进行评估,选择评分最高的走法。

public static Move FindBestMove(ChessBoard board, int searchDepth) { List<Move> possibleMoves = GenerateAllMoves(board, PieceColor.Black); Move bestMove = null; int bestValue = int.MinValue; foreach (Move move in possibleMoves) { board.MakeMove(move.FromRow, move.FromCol, move.ToRow, move.ToCol); int moveValue = Minimax(board, searchDepth - 1, int.MinValue, int.MaxValue, false); board.UndoMove(); if (moveValue > bestValue) { bestValue = moveValue; bestMove = move; } } return bestMove ?? possibleMoves[0]; // 如果没有找到(理论上不会),返回第一步 }

性能与体验权衡:搜索深度searchDepth直接决定了AI的强度和思考时间。深度为3-4时,AI已有不错的棋力,且思考时间在可接受范围内(几秒)。深度再增加,时间会呈指数级增长。在实际项目中,可以添加一个“思考时间”限制,使用迭代加深搜索(Iterative Deepening),在规定时间内尽可能搜索更深。同时,将耗时的AI计算放在后台线程(Task.Run)中,避免阻塞UI线程导致界面卡死。

5. 项目扩展与高级功能探讨

一个基础版本完成后,可以考虑以下方向进行扩展,让项目更具挑战性和实用性:

5.1 棋谱记录与复盘功能

实现棋谱记录(PGN格式或自定义格式)并不复杂。在GameManager中维护一个List<MoveRecord>,每次走棋后记录起点、终点、棋子类型。复盘功能则需要能够按顺序重新执行这些走法。可以增加两个按钮“上一步”、“下一步”,点击时根据棋谱列表回退或重走棋步,并重绘棋盘。这需要棋盘支持“无规则验证”的走子(直接放置棋子)和“撤销”操作。

5.2 网络对战支持

将项目升级为网络对战象棋,是一个质的飞跃。这涉及到客户端-服务器(C/S)架构。可以设计一个简单的TCP通信协议,定义消息类型(如:登录、请求对战、走棋、聊天、认输)。服务器负责匹配玩家、转发走棋信息、验证走棋顺序(防止作弊)、维护房间状态。客户端需要将本地UI操作(点击走棋)编码为网络消息发送,并接收服务器消息来更新远端棋盘。这里的关键是多线程处理(网络接收不能阻塞UI)和状态同步。

5.3 AI算法的深度优化

基础的Minimax+Alpha-Beta可以继续优化:

  • 置换表(Transposition Table):将搜索过的棋盘局面及其评估值缓存起来。当再次遇到相同局面时,直接查表,避免重复计算。这需要为棋盘生成一个唯一的哈希值(如Zobrist Hashing)。
  • 开局库与残局库:预先存储大量经典开局和定式残局的走法。在游戏开始和结束时,直接查询库表,能极大提升AI在开局和残局阶段的表现。
  • 更精细的评估函数:除了子力价值,加入位置价值(车占肋、马跳卧槽、炮置中路)、棋子灵活性、将帅安全性、兵卒过河数量等因素,让AI的评估更接近人类高手。
  • 并行搜索:利用多核CPU,将搜索树的不同分支分配到不同线程并行计算,最后汇总结果,可以显著减少搜索时间。

5.4 用户体验提升

  • 音效与动画:为走棋、吃子、将军、胜利等事件添加音效。实现棋子的平滑移动动画(使用Timer控制棋子位移动画),而非瞬间跳转。
  • 悔棋功能:允许玩家悔棋一步或多步。这需要栈(Stack)来保存每一步走棋前的棋盘状态。注意,如果是网络对战或与AI对弈,悔棋逻辑需要协商(如AI是否同意)。
  • 难度选择:提供不同难度的AI,本质上是调整搜索深度、是否使用开局库、以及评估函数的复杂度。

6. 开发中常见问题与调试技巧

在开发这类项目时,肯定会遇到各种“坑”。下面分享几个我踩过的和调试经验:

问题一:棋子绘制位置偏移或闪烁。

  • 原因:计算棋子中心坐标时,行列索引与像素坐标转换错误;或者频繁的Invalidate()导致重绘过于频繁。
  • 解决:仔细检查col * cellSize + cellSize / 2这类坐标计算。对于闪烁,确保PictureBoxDoubleBuffered属性已设置为true(可能需要继承PictureBox自定义一个控件来设置)。

问题二:移动规则判断异常,例如马可以蹩腿走,炮可以无炮架吃子。

  • 原因IsValidMove方法中的逻辑错误,特别是边界条件(如马腿检查的坐标计算)或计数错误(炮的中间棋子数)。
  • 解决:为每个棋子的移动规则编写单元测试,覆盖正常、吃子、非法(各种阻挡)等情况。使用调试器,在移动时打印出关键的中间变量(如blockRow,blockCol,piecesBetween),观察其值是否符合预期。

问题三:AI思考时间过长,界面卡死。

  • 原因:Minimax搜索深度太大,或生成所有走法GenerateAllMoves的效率太低(例如每次都要遍历整个棋盘并验证每个位置的合法性)。
  • 解决
    1. 优化走法生成:为每个棋子预计算其可能的移动方向,而不是对棋盘每个空位都进行合法性验证。
    2. 限制搜索深度和时间:使用迭代加深,并设置一个计时器,时间到了就返回当前找到的最好走法。
    3. 使用后台线程:将FindBestMove放在Task.Run中执行,在AI思考时,UI可以显示“思考中...”的提示,并保持响应。

问题四:游戏状态判断不准,特别是“将死”和“困毙”。

  • 原因:“将死”判断逻辑不完整。不能只判断“将”是否被攻击,还要判断“将”的所有可能逃逸位置是否都被攻击,并且是否可以通过吃掉攻击子或垫将等方式解将。
  • 解决:实现一个IsInCheck函数,判断某一方是否被将军。然后,在CheckGameState中,先判断当前行棋方是否被将军,如果是,则生成其所有合法走法,执行每步走法后检查是否仍被将军。如果所有走法都无法解除将军,则为“将死”。如果当前行棋方未被将军,但没有任何合法走法可走,则为“困毙”(和棋的一种)。

这个C#中国象棋项目,从零到一实现下来,对WinForms编程、GDI+绘图、面向对象设计、算法设计(特别是搜索和评估)都是一个非常全面的锻炼。它不仅仅是一个游戏,更是一个经典的软件工程实践案例。你可以基于这个基础框架,不断添加新功能,优化代码和算法,这个过程本身带来的提升,远比单纯复制代码要大得多。