
1. 项目概述与核心价值最近在整理一些老项目翻到了几年前用C# WinForm写的一个五子棋游戏。当时写它主要是为了给刚入门的同事演示WinForm的基本开发流程和事件驱动模型没想到后来自己还用它来测试过一些界面美化和自定义控件的想法。五子棋这个题材虽然规则简单但麻雀虽小五脏俱全它几乎涵盖了桌面应用开发中所有最核心的环节窗体布局、控件交互、事件处理、业务逻辑、状态管理甚至还能延伸到简单的AI算法。对于想从控制台转向图形界面或者想夯实C#桌面开发基础的开发者来说亲手实现一个五子棋是一个非常棒的练手项目。这个项目不依赖任何复杂的第三方库纯粹使用.NET Framework自带的WinForm组件。它的核心目标不是做出一个媲美商业游戏的华丽产品而是透彻理解从零搭建一个可交互桌面程序的完整链路。你会清晰地看到鼠标点击如何触发事件事件处理器如何更新数据模型数据模型的变化又如何驱动界面重绘最终形成一个完整的闭环。这个过程远比看十篇理论文章来得深刻。接下来我就把这个项目的实现思路、关键代码以及我踩过的一些“坑”详细拆解一遍希望能给正在学习C# WinForm的你提供一份可以直接“抄作业”的实战指南。2. 整体设计与思路拆解2.1 技术选型为什么是WinForm在开始敲代码之前我们先聊聊选型。C#做桌面开发主流的有WinForm、WPF还有比较新的MAUI等。为什么这个项目选择WinForm原因有几个首先是极低的学习门槛。WinForm采用经典的拖拽控件方式设计界面属性、事件一目了然对于初学者而言上手速度最快能快速获得成就感。其次是足够轻量。我们的核心是理解交互逻辑而不是研究复杂的XAML数据绑定或渲染管线。WinForm的GDI绘图对于棋盘、棋子这种2D图形渲染完全够用且控制粒度很细。最后是广泛的适用性。特别是在工控、上位机等领域WinForm因其稳定性和对传统硬件接口的良好支持依然有大量的存量项目和开发需求。通过这个项目打好基础未来无论是维护老项目还是转向WPF都会更加得心应手。2.2 核心架构MVC模式的轻量级实践虽然是一个小项目但良好的代码结构能让你后续的调试和功能扩展事半功倍。这里我采用了一种简化版的MVCModel-View-Controller模式来组织代码这在实际的WinForm项目中也是一种常见且有效的做法。Model模型负责所有数据和核心游戏规则。它不关心界面长什么样。具体包括棋盘数据用一个二维数组如int[,]表示15x15的格子0为空1为黑子2为白子。当前行棋方记录现在是黑方下还是白方下。游戏状态是否开始、是否结束、获胜者是谁。核心算法判断落子后是否形成五连珠即获胜判断逻辑。View视图就是我们的WinForm窗体。它负责一切与用户看到和操作相关的内容。窗体和棋盘绘制使用Panel控件作为画布在它的Paint事件中用GDI绘制网格和棋子。交互响应在Panel的MouseClick事件中捕获用户点击坐标。Controller控制器作为Model和View之间的桥梁。它监听View传来的用户操作如鼠标点击然后去更新Model在棋盘数组落子、判断胜负最后根据Model的新状态通知View更新显示重绘棋盘。这种分离的好处非常明显业务逻辑Model和界面显示View完全解耦。比如你想把棋盘从15路改成19路只需要修改Model中的数组大小和View中的绘制逻辑判断胜负的算法可能完全不用动。再比如未来你想增加一个网络对战功能只需要替换或增强Controller而Model和View可以大部分复用。2.3 界面布局规划我们的主窗体MainForm布局很简单但需要考虑扩展性。主要分为两个区域棋盘绘制区一个Panel控件将其Dock属性设置为Fill占据窗体主要区域。它的核心任务是响应绘制和点击。控制信息区可以放在窗体一侧或底部使用Panel或GroupBox容器。里面放置一些控件例如Label显示当前行棋方“黑方走棋”或“白方走棋”。Button“开始新游戏”、“悔棋”、“认输”。ListBox用于显示行棋步骤记录。在初始化时我们需要计算每个棋格的实际像素大小。假设我们的Panel大小是450x450棋盘是15路那么每格的大小就是450 / 15 30像素。这个计算会在绘制和坐标转换时反复用到。3. 核心模块实现详解3.1 数据模型Model的实现模型是整个游戏的大脑我们先来实现它。创建一个类比如叫GameBoard。public class GameBoard { // 棋盘大小15x15 public const int BOARD_SIZE 15; // 棋盘数据0-空1-黑子2-白子 private int[,] board new int[BOARD_SIZE, BOARD_SIZE]; // 当前行棋方1-黑2-白 public int CurrentPlayer { get; private set; } 1; // 游戏是否结束 public bool IsGameOver { get; private set; } false; // 获胜方0-未结束1-黑胜2-白胜 public int Winner { get; private set; } 0; // 落子历史记录可用于悔棋 public StackPoint MoveHistory { get; } new StackPoint(); // 在指定位置落子 public bool PlacePiece(int x, int y) { // 1. 检查游戏是否已结束 if (IsGameOver) return false; // 2. 检查坐标是否越界 if (x 0 || x BOARD_SIZE || y 0 || y BOARD_SIZE) return false; // 3. 检查该位置是否已有棋子 if (board[x, y] ! 0) return false; // 4. 落子 board[x, y] CurrentPlayer; MoveHistory.Push(new Point(x, y)); // 记录历史 // 5. 判断是否获胜 if (CheckWin(x, y)) { IsGameOver true; Winner CurrentPlayer; return true; } // 6. 切换行棋方 CurrentPlayer CurrentPlayer 1 ? 2 : 1; return true; } // 悔棋一步 public bool Undo() { if (MoveHistory.Count 0) return false; var lastMove MoveHistory.Pop(); board[lastMove.X, lastMove.Y] 0; // 清空该位置 CurrentPlayer CurrentPlayer 1 ? 2 : 1; // 切换回上一方 IsGameOver false; // 如果因为上一步赢了而结束此时应重置 Winner 0; return true; } // 重置游戏 public void Reset() { board new int[BOARD_SIZE, BOARD_SIZE]; CurrentPlayer 1; IsGameOver false; Winner 0; MoveHistory.Clear(); } // 获取指定位置的棋子状态 public int GetPiece(int x, int y) board[x, y]; // 核心判断落子(x,y)后是否五连 private bool CheckWin(int x, int y) { int player board[x, y]; // 四个方向水平、垂直、左上到右下、右上到左下 int[][] directions new int[][] { new int[] {1, 0}, // 水平右 new int[] {0, 1}, // 垂直下 new int[] {1, 1}, // 右下 new int[] {1, -1} // 左下 }; foreach (var dir in directions) { int count 1; // 当前位置的棋子 // 向正方向搜索 for (int step 1; step 5; step) { int newX x dir[0] * step; int newY y dir[1] * step; if (newX 0 || newX BOARD_SIZE || newY 0 || newY BOARD_SIZE) break; if (board[newX, newY] player) count; else break; } // 向反方向搜索 for (int step 1; step 5; step) { int newX x - dir[0] * step; int newY y - dir[1] * step; if (newX 0 || newX BOARD_SIZE || newY 0 || newY BOARD_SIZE) break; if (board[newX, newY] player) count; else break; } // 如果任意方向连成5子或以上则获胜 if (count 5) return true; } return false; } }注意CheckWin函数是算法的核心。这里采用了一种经典且高效的“双向计数”方法。从落子点向四个方向延伸正反各数4个位置因为加上自身最多数5个。只要任何一个方向上的同色棋子数达到5即判获胜。这种写法比遍历整个棋盘要高效得多。3.2 视图与控制器的联动视图是MainForm控制器逻辑我们可以直接写在窗体类中对于小型项目这样更直观。窗体的主要任务有两个绘制和交互。首先在窗体中初始化模型并设置绘制参数public partial class MainForm : Form { private GameBoard gameBoard new GameBoard(); private int gridSize; // 每个格子的像素大小 private int offset 15; // 棋盘边距为了美观 public MainForm() { InitializeComponent(); // 假设我们有一个名为 chessBoardPanel 的 Panel chessBoardPanel.Paint ChessBoardPanel_Paint; chessBoardPanel.MouseClick ChessBoardPanel_MouseClick; chessBoardPanel.Resize (s, e) chessBoardPanel.Invalidate(); // 窗体大小改变时重绘 CalculateGridSize(); } private void CalculateGridSize() { // 根据Panel的当前大小计算格子尺寸取宽高中较小的值以保证棋盘是正方形 int panelSize Math.Min(chessBoardPanel.Width, chessBoardPanel.Height) - 2 * offset; gridSize panelSize / GameBoard.BOARD_SIZE; } }其次实现绘制事件ChessBoardPanel_Paint这是WinForm GDI绘图的基本功。在PaintEventArgs的Graphics对象上进行绘制。private void ChessBoardPanel_Paint(object sender, PaintEventArgs e) { Graphics g e.Graphics; g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; // 抗锯齿让线条和棋子更平滑 // 1. 绘制棋盘网格 using (Pen gridPen new Pen(Color.Black, 1)) { for (int i 0; i GameBoard.BOARD_SIZE; i) { // 画横线 g.DrawLine(gridPen, offset, offset i * gridSize, offset GameBoard.BOARD_SIZE * gridSize, offset i * gridSize); // 画竖线 g.DrawLine(gridPen, offset i * gridSize, offset, offset i * gridSize, offset GameBoard.BOARD_SIZE * gridSize); } } // 2. 绘制所有棋子 for (int x 0; x GameBoard.BOARD_SIZE; x) { for (int y 0; y GameBoard.BOARD_SIZE; y) { int piece gameBoard.GetPiece(x, y); if (piece ! 0) { Brush brush piece 1 ? Brushes.Black : Brushes.White; // 计算棋子圆心坐标 int centerX offset x * gridSize; int centerY offset y * gridSize; int radius gridSize / 2 - 2; // 棋子半径略小于格子一半 // 画一个实心圆表示棋子 g.FillEllipse(brush, centerX - radius, centerY - radius, radius * 2, radius * 2); // 如果是白子可以加一个黑色的边框使其更清晰 if (piece 2) { g.DrawEllipse(Pens.Black, centerX - radius, centerY - radius, radius * 2, radius * 2); } } } } // 3. 可选高亮显示最后一步落子位置 if (gameBoard.MoveHistory.Count 0) { var lastMove gameBoard.MoveHistory.Peek(); int centerX offset lastMove.X * gridSize; int centerY offset lastMove.Y * gridSize; g.DrawRectangle(Pens.Red, centerX - 3, centerY - 3, 6, 6); } }实操心得using语句用于包裹Pen、Brush等GDI资源对象是个好习惯它能确保在绘制结束后及时释放非托管资源避免内存泄漏。虽然对于这个小程序可能不明显但在复杂的、频繁绘制的应用中这至关重要。最后实现鼠标点击事件ChessBoardPanel_MouseClick这里就是控制器逻辑的核心——将界面交互转化为模型操作。private void ChessBoardPanel_MouseClick(object sender, MouseEventArgs e) { // 1. 将鼠标点击的像素坐标转换为棋盘坐标 int x (e.X - offset gridSize / 2) / gridSize; int y (e.Y - offset gridSize / 2) / gridSize; // 2. 边界检查防止点击在棋盘边框外 if (x 0 || x GameBoard.BOARD_SIZE || y 0 || y GameBoard.BOARD_SIZE) return; // 3. 调用模型落子 bool success gameBoard.PlacePiece(x, y); // 4. 如果落子成功更新界面 if (success) { chessBoardPanel.Invalidate(); // 触发重绘这是更新界面的关键 UpdateStatusLabel(); // 更新状态栏信息例如显示当前行棋方 if (gameBoard.IsGameOver) { MessageBox.Show($游戏结束{(gameBoard.Winner 1 ? 黑方 : 白方)}获胜, 提示); } } } private void UpdateStatusLabel() { // 假设有一个名为 lblStatus 的 Label if (gameBoard.IsGameOver) { lblStatus.Text $游戏结束{(gameBoard.Winner 1 ? 黑方 : 白方)}获胜; } else { lblStatus.Text $当前行棋方{(gameBoard.CurrentPlayer 1 ? 黑方 : 白方)}; } }关键点解析chessBoardPanel.Invalidate()方法是WinForm中通知控件进行重绘的标准方式。它不会立即绘制而是向系统发送一个“需要重绘”的消息系统会在合适的时机调用我们之前定义的Paint事件处理方法。这种基于消息的异步更新机制是Windows桌面编程的基石之一。4. 功能扩展与界面美化基础功能完成后我们可以让它变得更实用、更好看。4.1 添加游戏控制功能在控制信息区添加按钮并绑定事件。// “开始新游戏”按钮 private void btnNewGame_Click(object sender, EventArgs e) { gameBoard.Reset(); chessBoardPanel.Invalidate(); UpdateStatusLabel(); } // “悔棋”按钮 private void btnUndo_Click(object sender, EventArgs e) { if (gameBoard.Undo()) { chessBoardPanel.Invalidate(); UpdateStatusLabel(); } else { MessageBox.Show(没有棋可以悔了, 提示); } }4.2 界面美化技巧原生的WinForm控件风格比较老旧我们可以通过一些简单的方法提升视觉效果。双缓冲绘图在棋盘Panel频繁重绘时可能会闪烁。启用双缓冲可以极大缓解这个问题。最简单的方法是在窗体构造函数或Panel的初始化代码后加上this.DoubleBuffered true; // 对整个窗体启用双缓冲 // 或者更精确地只对Panel启用需要继承Panel创建自定义控件 // public class DoubleBufferedPanel : Panel { public DoubleBufferedPanel() { this.DoubleBuffered true; } }自定义控件外观我们可以重写按钮的OnPaint方法或者使用Button的FlatStyle属性结合背景图制作圆角、渐变色的按钮。例如使用Panel模拟一个美观的按钮private void InitCustomButton(Panel btnPanel, string text) { btnPanel.BackColor Color.LightSteelBlue; btnPanel.BorderStyle BorderStyle.FixedSingle; btnPanel.Cursor Cursors.Hand; Label lbl new Label(); lbl.Text text; lbl.Dock DockStyle.Fill; lbl.TextAlign ContentAlignment.MiddleCenter; lbl.Font new Font(微软雅黑, 10); btnPanel.Controls.Add(lbl); btnPanel.Click (s, e) { /* 按钮点击事件 */ }; // 添加鼠标悬停效果 btnPanel.MouseEnter (s, e) btnPanel.BackColor Color.SteelBlue; btnPanel.MouseLeave (s, e) btnPanel.BackColor Color.LightSteelBlue; }使用第三方UI库如果追求更现代化的界面可以考虑集成像SunnyUI、MaterialSkin这样的开源WinForm UI库。它们提供了一套风格统一、视觉效果更好的控件可以直接替换标准控件大幅提升开发效率和应用颜值。例如使用SunnyUI的UIButton只需从工具箱拖拽属性设置一下就能获得一个漂亮的扁平化按钮。4.3 实现简单的AI对手让游戏支持人机对战能极大增加趣味性。这里实现一个非常基础的“贪心”AI。public class SimpleAI { private Random random new Random(); // 获取一个最佳落子位置这里用随机位置模拟 public Point GetNextMove(int[,] board, int currentPlayer) { ListPoint emptyPoints new ListPoint(); int size board.GetLength(0); // 收集所有空位 for (int i 0; i size; i) { for (int j 0; j size; j) { if (board[i, j] 0) emptyPoints.Add(new Point(i, j)); } } // 简单策略如果有空位随机选一个 if (emptyPoints.Count 0) { return emptyPoints[random.Next(emptyPoints.Count)]; } return new Point(-1, -1); // 无位置可下 } }在窗体中可以添加一个复选框“人机对战”。当勾选且轮到AI时在鼠标点击事件中调用AI的GetNextMove方法然后将返回的坐标传给gameBoard.PlacePiece。进阶思考这个AI太弱了。你可以尝试实现更复杂的算法比如评分表法为不同的棋型活四、冲四、活三等赋予分数AI选择分数最高的空位落子。极小化极大算法Minimax模拟未来几步棋选择对自己最有利、对对手最不利的走法。这是棋类游戏AI的经典算法可以结合Alpha-Beta剪枝优化。蒙特卡洛树搜索MCTS通过随机模拟大量对局来评估每一步的胜率。虽然实现复杂但思路相对直观。5. 常见问题与调试技巧在开发过程中你肯定会遇到一些问题。这里记录几个典型问题及其解决方法。5.1 棋盘绘制闪烁问题问题描述拖动窗体改变大小时或者快速落子时棋盘会明显闪烁。原因分析WinForm控件在重绘时会先擦除背景变为背景色再绘制新内容。这个“擦除-绘制”的过程如果太频繁或太慢人眼就会感觉到闪烁。解决方案启用双缓冲如前所述this.DoubleBuffered true;是最简单有效的方法。它会在内存中先完成所有绘制操作然后一次性输出到屏幕避免中间过程的闪烁。重写控件的OnPaintBackground方法对于自定义绘制的Panel可以重写此方法并留空禁止系统自动擦除背景然后在自己控制的OnPaint中绘制所有内容包括背景。protected override void OnPaintBackground(PaintEventArgs e) { // 什么都不做避免背景擦除闪烁 // base.OnPaintBackground(e); // 注释掉这行 }注意使用此方法时你必须在OnPaint中手动绘制整个背景比如填充一个棋盘底色否则会显示混乱。5.2 鼠标点击坐标转换不准问题描述点击棋盘格子时落子位置有偏差有时点在这个格子棋子却画在隔壁。原因分析坐标转换公式有误。我们的公式(e.X - offset gridSize / 2) / gridSize中gridSize / 2是一个关键补偿。因为鼠标点击的点可能落在格子的任何位置加上半个格子的大小再除以格子宽度相当于做了一个“四舍五入”到最近格子中心的操作。调试技巧在鼠标点击事件开始时将原始的e.X,e.Y和计算后的x,y坐标打印出来可以用Debug.WriteLine输出到输出窗口。同时在绘制代码中将计算出的棋子圆心坐标也画一个小点标记出来直观地对比鼠标落点和实际绘制点是否重合。5.3 游戏逻辑Bug获胜判断错误问题描述有时候明明连成五子了却没判赢或者没连成五子却判赢了。原因分析几乎肯定是CheckWin函数有漏洞。常见错误包括数组越界检查不完整在搜索到棋盘边缘时出错。计数逻辑错误比如只数了一个方向。棋子状态判断错误把对手的棋子也算进去了。排查方法单元测试为GameBoard类编写单元测试针对各种棋型水平五连、垂直五连、两种斜向五连、长连、四连等进行测试。可视化调试在CheckWin函数内部添加详细的日志打印出每个方向搜索的每一步坐标和棋子状态。或者在界面上临时高亮显示出判断时检查的所有格子进行视觉验证。边界条件测试专门测试在棋盘最边缘第0行、第0列、最后一行、最后一列落子并形成五连的情况。5.4 性能问题界面卡顿问题描述当实现复杂AI如深度搜索时计算耗时较长导致界面“卡住”无法响应。原因分析计算密集型任务阻塞了UI线程。WinForm的UI线程负责处理所有消息绘制、点击等如果在这个线程上进行大量计算消息队列就会被阻塞表现为界面无响应。解决方案使用异步编程。将耗时的AI计算放在后台线程如Task.Run中执行计算完成后再通过Control.Invoke方法回到UI线程更新界面。private async void AITurnAsync() { // 禁用界面交互防止用户乱点 chessBoardPanel.Enabled false; lblStatus.Text AI思考中...; Point aiMove await Task.Run(() { // 这里是耗时的AI计算 return ai.GetNextMove(/* ... */); }); // 回到UI线程 if (this.InvokeRequired) { this.Invoke(new Action(() { gameBoard.PlacePiece(aiMove.X, aiMove.Y); chessBoardPanel.Invalidate(); UpdateStatusLabel(); chessBoardPanel.Enabled true; })); } }重要提醒在后台线程中绝对不能直接访问或修改UI控件如chessBoardPanel,lblStatus必须通过Invoke或BeginInvoke封送回UI线程执行。6. 项目总结与进阶方向通过这个完整的五子棋项目我们走完了WinForm桌面应用开发的一个标准流程从需求分析、技术选型、架构设计到核心模块编码、界面绘制、事件处理再到功能扩展、问题调试。你不仅学会了如何画棋盘棋子更重要的是理解了事件驱动编程的精髓——用户操作触发事件事件处理器更新数据模型数据模型的变化请求视图更新形成一个流畅的交互闭环。这个项目完全可以作为你C#桌面开发的一个坚实起点。在此基础上你可以尝试许多有趣的扩展网络对战使用Socket或SignalR实现双人联机对战。这会将项目从单机应用升级为C/S架构你需要设计通信协议如传递坐标、聊天消息、处理网络延迟和断线重连。数据库集成使用SQLite或SQL Server Compact记录每一盘棋的棋谱、玩家胜负信息并提供一个历史战绩查询界面。更强大的AI深入研究并实现极小化极大算法或蒙特卡洛树搜索让你的AI对手变得聪明起来。这会是算法和编程能力的双重挑战。代码重构将MainForm中混杂的控制器和视图逻辑进一步分离定义清晰的接口让代码更符合设计模式便于测试和维护。最后分享一个我自己的小习惯在完成一个功能模块后不要急着往下写。停下来运行一下从用户的角度去玩几把看看交互流不流畅有没有违反直觉的地方。这种“沉浸式测试”往往能发现那些在代码层面容易被忽略的体验问题。桌面开发终究是为了做出好用的软件代码优雅是途径用户体验才是终点。希望这个项目能帮你打开WinForm开发的大门更重要的是享受创造和解决问题的乐趣。