
1. 项目概述与核心价值最近在整理个人代码仓库时翻出了一个几年前用C#写的中国象棋项目当时是为了深入理解Windows窗体应用WinForms的图形绘制、事件驱动和游戏逻辑而做的。这个项目麻雀虽小五脏俱全从棋盘绘制、棋子移动规则、胜负判定到简单的AI走法生成都实现了。今天把它拿出来结合现在的开发视角重新梳理一遍希望能给想用C#做桌面游戏开发、或者对棋类游戏逻辑实现感兴趣的朋友提供一个完整的、可运行的参考案例。这个项目不依赖任何复杂的游戏引擎纯粹用GDI绘图和基础的面向对象思想非常适合初学者理解一个完整桌面应用的架构。中国象棋的规则大家都不陌生但要把这些规则用代码精确地表达出来并且有一个清晰的界面与之交互这里面涉及的知识点非常综合。它考验了你对C# WinForms控件尤其是PictureBox和Paint事件的掌握对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 核心类设计与职责划分良好的面向对象设计是项目可维护性的基石。我将整个象棋游戏抽象为以下几个核心类ChessBoard棋盘类这是游戏的核心容器。它不负责直接绘制而是维护一个10行9列的二维数组Piece[,]用于记录当前棋盘上所有棋子的状态位置、类型、阵营。它提供了查询某个位置棋子、移动棋子、判断移动是否合法等核心方法。棋盘类是游戏规则的执行者。Piece棋子基类及派生类定义了一个抽象的Piece基类包含棋子颜色红/黑、类型车、马、炮等、当前位置等属性。关键是一个抽象的IsValidMove方法。然后为每种棋子Rook车,Knight马,Cannon炮,Guard士,Elephant象,King将,Pawn兵创建派生类重写IsValidMove方法实现各自独特的移动规则。这是策略模式的典型应用新增棋子类型非常方便。GameManager游戏管理器单例或静态类负责协调整个游戏流程。它持有ChessBoard实例管理当前行棋方红先黑后、游戏状态进行中、红胜、黑胜、和棋、记录棋谱。它还作为UI层窗体和逻辑层棋盘、棋子之间的桥梁处理用户的点击事件并调用AI逻辑。AIPlayerAI玩家类实现了一个简单的AI。核心是“极大极小搜索算法”Minimax配合“Alpha-Beta剪枝”。AI会模拟未来几步棋并用一个评估函数给棋盘局面打分例如计算双方棋子价值差、棋子位置优势等然后选择对自己最有利的走法。这部分是项目的算法核心即使实现一个深度为3-4层的搜索也能做出有一定思考的走法。MainForm主窗体WinForms的主窗体。主要包含一个PictureBox控件作为画布在其Paint事件中调用绘制方法绘制棋盘格子和棋子。它监听鼠标的MouseDown和MouseUp事件用于实现棋子的拖拽或点击选择。设计心得将绘制UI、规则逻辑、控制流程分离是关键。MainForm只关心“怎么画”和“用户点了哪里”GameManager关心“点了之后要做什么”ChessBoard和Piece关心“这么做符不符合规则”。这种分离使得单元测试、更换UI比如控制台界面或增强AI都变得容易。3. 核心模块实现细节解析3.1 棋盘与棋子的绘制绘制是所有视觉反馈的基础。我们在MainForm的PictureBox控件的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事件中重复使用。另外可以通过设置PictureBox的DoubleBuffered属性为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 ListPoint _validMoves new ListPoint(); 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; // 简单的子力价值评估 DictionaryPieceType, int pieceValues new DictionaryPieceType, 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); } ListMove 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剪枝。它的原理是在搜索过程中维护两个值alpha和beta。alpha代表当前层最大化层已经找到的最好值beta代表上一层最小化层已经找到的最差值。如果在搜索一个分支时发现它的值对于当前层来说已经“太差”以至于上一层不可能选择它那么就可以提前终止对这个分支的搜索剪枝从而大幅减少需要评估的节点数。在代码中if (beta alpha) break;就是剪枝条件。要最大化剪枝效果走法顺序至关重要。应该优先搜索那些看起来最好的走法例如吃子、将军、走到中心位置这样更容易触发剪枝条件。这就是上面代码中OrderMoves函数的作用。4.3 AI走法决策入口最后AI的入口函数就是遍历当前所有可能走法调用Minimax进行评估选择评分最高的走法。public static Move FindBestMove(ChessBoard board, int searchDepth) { ListMove 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中维护一个ListMoveRecord每次走棋后记录起点、终点、棋子类型。复盘功能则需要能够按顺序重新执行这些走法。可以增加两个按钮“上一步”、“下一步”点击时根据棋谱列表回退或重走棋步并重绘棋盘。这需要棋盘支持“无规则验证”的走子直接放置棋子和“撤销”操作。5.2 网络对战支持将项目升级为网络对战象棋是一个质的飞跃。这涉及到客户端-服务器C/S架构。可以设计一个简单的TCP通信协议定义消息类型如登录、请求对战、走棋、聊天、认输。服务器负责匹配玩家、转发走棋信息、验证走棋顺序防止作弊、维护房间状态。客户端需要将本地UI操作点击走棋编码为网络消息发送并接收服务器消息来更新远端棋盘。这里的关键是多线程处理网络接收不能阻塞UI和状态同步。5.3 AI算法的深度优化基础的MinimaxAlpha-Beta可以继续优化置换表Transposition Table将搜索过的棋盘局面及其评估值缓存起来。当再次遇到相同局面时直接查表避免重复计算。这需要为棋盘生成一个唯一的哈希值如Zobrist Hashing。开局库与残局库预先存储大量经典开局和定式残局的走法。在游戏开始和结束时直接查询库表能极大提升AI在开局和残局阶段的表现。更精细的评估函数除了子力价值加入位置价值车占肋、马跳卧槽、炮置中路、棋子灵活性、将帅安全性、兵卒过河数量等因素让AI的评估更接近人类高手。并行搜索利用多核CPU将搜索树的不同分支分配到不同线程并行计算最后汇总结果可以显著减少搜索时间。5.4 用户体验提升音效与动画为走棋、吃子、将军、胜利等事件添加音效。实现棋子的平滑移动动画使用Timer控制棋子位移动画而非瞬间跳转。悔棋功能允许玩家悔棋一步或多步。这需要栈Stack来保存每一步走棋前的棋盘状态。注意如果是网络对战或与AI对弈悔棋逻辑需要协商如AI是否同意。难度选择提供不同难度的AI本质上是调整搜索深度、是否使用开局库、以及评估函数的复杂度。6. 开发中常见问题与调试技巧在开发这类项目时肯定会遇到各种“坑”。下面分享几个我踩过的和调试经验问题一棋子绘制位置偏移或闪烁。原因计算棋子中心坐标时行列索引与像素坐标转换错误或者频繁的Invalidate()导致重绘过于频繁。解决仔细检查col * cellSize cellSize / 2这类坐标计算。对于闪烁确保PictureBox的DoubleBuffered属性已设置为true可能需要继承PictureBox自定义一个控件来设置。问题二移动规则判断异常例如马可以蹩腿走炮可以无炮架吃子。原因IsValidMove方法中的逻辑错误特别是边界条件如马腿检查的坐标计算或计数错误炮的中间棋子数。解决为每个棋子的移动规则编写单元测试覆盖正常、吃子、非法各种阻挡等情况。使用调试器在移动时打印出关键的中间变量如blockRow,blockCol,piecesBetween观察其值是否符合预期。问题三AI思考时间过长界面卡死。原因Minimax搜索深度太大或生成所有走法GenerateAllMoves的效率太低例如每次都要遍历整个棋盘并验证每个位置的合法性。解决优化走法生成为每个棋子预计算其可能的移动方向而不是对棋盘每个空位都进行合法性验证。限制搜索深度和时间使用迭代加深并设置一个计时器时间到了就返回当前找到的最好走法。使用后台线程将FindBestMove放在Task.Run中执行在AI思考时UI可以显示“思考中...”的提示并保持响应。问题四游戏状态判断不准特别是“将死”和“困毙”。原因“将死”判断逻辑不完整。不能只判断“将”是否被攻击还要判断“将”的所有可能逃逸位置是否都被攻击并且是否可以通过吃掉攻击子或垫将等方式解将。解决实现一个IsInCheck函数判断某一方是否被将军。然后在CheckGameState中先判断当前行棋方是否被将军如果是则生成其所有合法走法执行每步走法后检查是否仍被将军。如果所有走法都无法解除将军则为“将死”。如果当前行棋方未被将军但没有任何合法走法可走则为“困毙”和棋的一种。这个C#中国象棋项目从零到一实现下来对WinForms编程、GDI绘图、面向对象设计、算法设计特别是搜索和评估都是一个非常全面的锻炼。它不仅仅是一个游戏更是一个经典的软件工程实践案例。你可以基于这个基础框架不断添加新功能优化代码和算法这个过程本身带来的提升远比单纯复制代码要大得多。