尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C# Winform五子棋开发实战:从零构建桌面游戏应用

C# Winform五子棋开发实战:从零构建桌面游戏应用 1. 项目概述与核心价值最近在整理硬盘翻出来一个十多年前用C# Winform写的五子棋小游戏。当时写它纯粹是为了练手想看看自己能不能把大学里学的那些面向对象、事件驱动、绘图这些零散的知识点串成一个能跑起来的、带点交互趣味的小东西。没想到这个项目后来成了我带新人、面试初级开发者时最爱用的“标本”。为什么因为它麻雀虽小五脏俱全。一个看似简单的五子棋背后涉及了Winform窗体应用的核心骨架、GDI绘图、鼠标事件处理、简单的游戏逻辑胜负判定、状态管理甚至还能引申出AI人机对弈的雏形比如最基础的“堵棋”算法。对于刚接触C#桌面开发或者想从控制台程序迈向图形界面GUI的朋友来说这是一个绝佳的练手项目。它没有复杂的网络通信没有庞大的数据库但能让你亲手触摸到从零构建一个完整可交互应用的每一个环节。今天我就把这个项目的核心思路、关键代码和当年踩过的坑掰开揉碎了讲给你听。无论你是想学习C# Winform还是单纯想找个有趣的小项目练练手这篇文章都能给你一份清晰的“地图”。2. 整体架构与设计思路拆解2.1 为什么选择Winform和GDI很多新手会问现在WPF、MAUI不是更“现代”吗为什么还用“古老”的Winform我的回答很直接入门门槛和专注度。Winform的控件拖拽式开发能让你快速搭建起程序界面把精力集中在游戏逻辑本身而不是纠结于XAML语法或复杂的绑定机制。对于五子棋这种棋盘固定、交互相对简单的2D棋盘类游戏Winform配合GDISystem.Drawing命名空间进行自定义绘图是完全够用且高效的。GDI提供了在窗体或控件上绘制直线、矩形、圆形、文本以及图像的能力我们用它来画棋盘格子和棋子再合适不过。整个项目的技术栈非常纯粹C#语言基础、Winform窗体程序基础、GDI绘图基础、事件处理模型。掌握这些你就掌握了传统Windows桌面开发的一大块核心。2.2 核心类的职责划分面向对象设计写代码最怕一锅粥所有逻辑都塞在Form1.cs里。好的设计是成功的一半。在这个五子棋项目中我主要划分了以下几个核心的类各司其职主窗体类 (MainForm)程序的入口和总指挥。它负责初始化游戏如创建棋盘、棋子管理器。承载棋盘绘制控件一个自定义的Panel或PictureBox。响应菜单栏事件如“开始新游戏”、“悔棋”、“退出”。显示游戏状态信息当前轮到谁、胜负结果。棋盘绘制与交互控件类 (ChessBoardControl)这是游戏的核心交互界面。我通常继承自Panel控件并重写它的OnPaint方法和处理鼠标事件。它的职责是使用GDI在Paint事件中绘制19x19的棋盘网格。监听鼠标点击事件将点击的像素坐标转换为棋盘上的逻辑坐标第几行第几列。将合法的落子请求传递给游戏逻辑核心进行处理。游戏逻辑核心类 (GameEngine)这是整个游戏的“大脑”与UI呈现完全分离。它负责维护一个二维数组如int[,] board来表示棋盘状态0为空1为黑子2为白子。判断落子位置是否合法是否在棋盘内、该位置是否已有棋子。执行落子操作并更新棋盘状态数组。最关键的功能胜负判定。在每次落子后以该子为中心向四个方向横、竖、左斜、右斜进行扫描判断是否有连续五个同色棋子。管理当前行棋方黑方还是白方。记录棋步序列为实现“悔棋”功能提供数据支持。棋子类 (ChessPiece)这是一个简单的数据类用于封装棋子的信息如颜色、所在棋盘位置行、列。虽然在这个简单版本中用int值表示棋子可能就够了但封装成类有利于未来扩展比如给棋子加个“高亮”状态或者支持不同的棋子皮肤。设计心得将GameEngine与UI控件分离是至关重要的。这符合MVC模型-视图-控制器或类似的思想。ChessBoardControl是视图View负责显示GameEngine是模型Model负责数据和规则主窗体某种程度上承担了控制器Controller的部分角色。这样设计的好处是你以后如果想换一个UI框架比如控制台显示或者移植到WPF只需要重写视图部分游戏的核心逻辑GameEngine几乎不用动。2.3 关键数据结构选择棋盘状态存储是核心。我选择了最直接的int[,] board new int[19, 19]。用012分别表示空、黑子、白子。为什么不用bool或者更复杂的枚举在初期int的扩展性更好。比如未来如果你想加入“禁手”规则可能需要标记“三三”、“四四”等特殊点位用int可以定义更多的状态值。二维数组的访问速度非常快board[row, col]即可直接获取或设置状态这对于需要频繁进行棋盘扫描的胜负判定算法来说效率很高。3. 核心模块实现详解3.1 棋盘绘制让GDI画出标准网格棋盘绘制是在自定义控件ChessBoardControl的OnPaint方法中完成的。这里有几个细节需要注意protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g e.Graphics; // 获取绘图对象 g.SmoothingMode SmoothingMode.AntiAlias; // 开启抗锯齿让线条和棋子更平滑 int boardSize 19; // 标准五子棋棋盘是19路 int cellWidth this.ClientSize.Width / (boardSize 1); // 计算每个格子的宽度左右留边 int cellHeight this.ClientSize.Height / (boardSize 1); // 计算每个格子的高度上下留边 int offset cellWidth; // 边距通常等于一个格子的宽度 // 1. 绘制棋盘背景可选比如木质纹理图片 // g.DrawImage(Properties.Resources.WoodBoard, this.ClientRectangle); // 2. 绘制棋盘网格线 using (Pen gridPen new Pen(Color.Black, 1.5f)) { // 绘制横线 for (int i 0; i boardSize; i) { int y offset i * cellHeight; g.DrawLine(gridPen, offset, y, offset (boardSize - 1) * cellWidth, y); } // 绘制竖线 for (int i 0; i boardSize; i) { int x offset i * cellWidth; g.DrawLine(gridPen, x, offset, x, offset (boardSize - 1) * cellHeight); } } // 3. 绘制棋盘上的五个“天元”和“星”位小点标准棋盘有9个星位这里简化画中心和中四边的 int[] starPoints { 3, 9, 15 }; // 第41016路从1开始计数对应数组索引3915 using (Brush starBrush new SolidBrush(Color.Black)) { int starRadius cellWidth / 5; foreach (int row in starPoints) { foreach (int col in starPoints) { int centerX offset col * cellWidth; int centerY offset row * cellHeight; g.FillEllipse(starBrush, centerX - starRadius, centerY - starRadius, starRadius * 2, starRadius * 2); } } } // 4. 绘制棋子这部分通常由GameEngine驱动根据board数组来画 DrawAllChessPieces(g, cellWidth, cellHeight, offset); }关键点与避坑指南坐标计算ClientSize是控件内部可绘制区域的大小。计算cellWidth和cellHeight时我用了(boardSize 1)做分母这样能在棋盘四周留出均匀的边距使棋盘看起来更美观。资源释放GDI对象如Pen,Brush,Font是非托管资源必须及时释放。使用using语句块是最佳实践能确保在离开块时自动调用Dispose()。双缓冲直接绘图在棋子快速移动或棋盘复杂时可能出现闪烁。Winform提供了简单的双缓冲支持。你可以在控件的构造函数中设置this.DoubleBuffered true;或者在OnPaint开始时调用e.Graphics.SmoothingMode等设置都能有效减少闪烁。绘制顺序先画背景再画网格线最后画棋子。棋子是顶层元素必须最后绘制否则会被网格线盖住。3.2 鼠标交互从点击像素到棋盘坐标玩家点击鼠标程序需要知道他想把棋子下在哪个交叉点上。这是一个坐标转换的过程。protected override void OnMouseClick(MouseEventArgs e) { base.OnMouseClick(e); if (this.gameEngine.GameStatus ! GameStatus.InProgress) return; // 游戏未开始或已结束不处理 Point clickPoint e.Location; // 获取鼠标点击的像素坐标 int boardSize 19; int cellWidth this.ClientSize.Width / (boardSize 1); int cellHeight this.ClientSize.Height / (boardSize 1); int offset cellWidth; // 将像素坐标转换为棋盘行列索引 // 思路计算点击位置距离左上角第一个交叉点offset, offset的偏移量然后除以格子大小。 // 使用四舍五入让点击位置自动“吸附”到最近的交叉点。 int col (int)Math.Round((clickPoint.X - offset) / (double)cellWidth); int row (int)Math.Round((clickPoint.Y - offset) / (double)cellHeight); // 检查转换后的坐标是否在棋盘有效范围内 (0 到 18) if (row 0 row boardSize col 0 col boardSize) { // 将落子请求提交给游戏引擎 bool moveSuccess this.gameEngine.PlacePiece(row, col); if (moveSuccess) { this.Invalidate(); // 落子成功重绘棋盘触发OnPaint显示新棋子 // 检查游戏是否结束 if (this.gameEngine.CheckWin(row, col)) { // 游戏结束显示获胜信息 MessageBox.Show(${this.gameEngine.CurrentPlayer} 获胜); this.gameEngine.GameStatus GameStatus.Finished; } else { // 未结束切换行棋方 this.gameEngine.SwitchPlayer(); // 更新状态栏显示当前玩家 UpdateStatusLabel(); } } else { // 落子失败位置已有棋子可以给个提示音或视觉反馈 System.Media.SystemSounds.Beep.Play(); } } }注意事项吸附算法使用Math.Round进行四舍五入是实现“点击格子附近即落子到交叉点”的关键。这比判断点击是否在某个精确范围内更符合用户直觉。输入验证转换后的row和col必须进行范围检查防止数组越界。UI更新游戏状态改变后一定要调用this.Invalidate()。这个方法会通知Windows系统这个控件区域需要重绘从而异步触发OnPaint方法。不要直接在其中调用Graphics对象绘图。3.3 游戏逻辑核心胜负判定算法这是五子棋程序的“灵魂”。算法效率直接影响游戏的流畅度。我采用的是方向增量扫描法这是最直观高效的方法之一。在GameEngine类中public bool CheckWin(int lastRow, int lastCol) { int currentColor this.board[lastRow, lastCol]; // 刚下的棋子的颜色 // 定义四个方向的增量数组横、竖、左斜\、右斜/ int[,] directions { { 0, 1 }, { 1, 0 }, { 1, 1 }, { 1, -1 } }; // 对应的反方向增量用于双向计数 int[,] oppositeDirs { { 0, -1 }, { -1, 0 }, { -1, -1 }, { -1, 1 } }; for (int dirIndex 0; dirIndex 4; dirIndex) { int count 1; // 初始计数为1代表刚下的这颗子本身 // 正向扫描 count CountDirection(lastRow, lastCol, directions[dirIndex, 0], directions[dirIndex, 1], currentColor); // 反向扫描 count CountDirection(lastRow, lastCol, oppositeDirs[dirIndex, 0], oppositeDirs[dirIndex, 1], currentColor); // 如果任意方向连续子数达到5则获胜 if (count 5) { return true; } } return false; } private int CountDirection(int startRow, int startCol, int deltaRow, int deltaCol, int color) { int count 0; int currentRow startRow deltaRow; int currentCol startCol deltaCol; // 沿着(deltaRow, deltaCol)方向一直找直到棋盘边界或棋子颜色不同 while (currentRow 0 currentRow BoardSize currentCol 0 currentCol BoardSize this.board[currentRow, currentCol] color) { count; currentRow deltaRow; currentCol deltaCol; } return count; }算法解析与优化点效率这个算法只围绕最后落子的位置进行扫描而不是每次判定都遍历整个19x19的棋盘。最坏情况下每个方向扫描不超过4格因为要连五所以时间复杂度是常数级的O(1)非常高效。方向定义(0,1)代表水平向右(1,0)代表垂直向下(1,1)代表右下对角线(1,-1)代表左下对角线。它们的反方向就是(0,-1),(-1,0),(-1,-1),(-1,1)。边界处理CountDirection方法中的while循环条件包含了棋盘边界检查防止数组索引越界。扩展思考这是无禁手规则。如果要实现有禁手针对黑棋你需要在此基础上增加更复杂的规则判断比如“三三禁手”、“四四禁手”、“长连禁手”等这需要扫描整个棋盘或更复杂的模式识别算法难度会大大增加。3.4 棋子绘制与状态管理在ChessBoardControl的DrawAllChessPieces方法中我们需要遍历GameEngine中的board数组将棋子画出来。private void DrawAllChessPieces(Graphics g, int cellWidth, int cellHeight, int offset) { int boardSize this.gameEngine.BoardSize; int[,] board this.gameEngine.GetBoard(); // 假设GameEngine提供了获取棋盘副本的方法 for (int row 0; row boardSize; row) { for (int col 0; col boardSize; col) { int piece board[row, col]; if (piece ! 0) // 0代表空位 { Brush pieceBrush (piece 1) ? Brushes.Black : Brushes.White; // 1黑2白 Pen piecePen (piece 1) ? Pens.Black : Pens.Gray; // 白棋通常用灰色描边更清晰 // 计算棋子圆心在控件上的像素坐标 int centerX offset col * cellWidth; int centerY offset row * cellHeight; int radius (int)(Math.Min(cellWidth, cellHeight) * 0.4); // 棋子半径约为格子大小的40% // 绘制棋子有立体感的画法 // 1. 填充主体颜色 g.FillEllipse(pieceBrush, centerX - radius, centerY - radius, radius * 2, radius * 2); // 2. 绘制边框 g.DrawEllipse(piecePen, centerX - radius, centerY - radius, radius * 2, radius * 2); // 3. 可选为白棋添加一点高光增加立体感 if (piece 2) { int highlightRadius radius / 3; using (Brush highlightBrush new SolidBrush(Color.FromArgb(100, 255, 255, 255))) // 半透明白色 { g.FillEllipse(highlightBrush, centerX - radius/2, centerY - radius/2, highlightRadius, highlightRadius); } } } } } }绘制技巧棋子大小半径取格子长宽的较小值的40%这样能确保棋子不会画到格子外面去视觉上也更协调。立体感简单的填充加描边就有基础效果。为白棋添加一个小的、半透明的白色高光圆可以模拟出棋子的反光效果立刻让棋子变得“立体”起来。这是提升UI质感的一个小窍门。性能如果棋子很多且需要频繁重绘可以考虑只重绘发生变化的区域而不是整个棋盘。可以通过Invalidate(Rectangle)方法指定需要重绘的矩形区域。但对于19x19的棋盘全盘重绘的性能开销在现代计算机上完全可以忽略不计。4. 功能扩展与进阶实现4.1 实现“悔棋”功能悔棋是棋类游戏的基本功能。实现的关键在于记录每一步的“棋步”。我们在GameEngine中增加一个栈StackMove来保存历史记录。public class Move { public int Row { get; set; } public int Col { get; set; } public int Player { get; set; } // 1 或 2 } public class GameEngine { private StackMove moveHistory new StackMove(); // ... 其他字段 public bool PlacePiece(int row, int col) { if (board[row, col] ! 0) return false; board[row, col] CurrentPlayer; // 记录这一步 moveHistory.Push(new Move { Row row, Col col, Player CurrentPlayer }); return true; } public bool Undo() { if (moveHistory.Count 0) return false; Move lastMove moveHistory.Pop(); board[lastMove.Row, lastMove.Col] 0; // 清空该位置 // 切换回上一步的玩家注意悔棋后应该轮到对方下所以这里需要再切换一次取决于规则 // 通常悔棋是悔掉一步轮到对方下。所以CurrentPlayer应该变为lastMove.Player。 // 但更常见的逻辑是悔棋后游戏状态回退到上一步之前行棋方是上一步的玩家。 // 我们用一个简单规则悔棋后当前行棋方设置为被悔掉的那步棋的玩家。 CurrentPlayer lastMove.Player; return true; } }在主窗体的“悔棋”按钮点击事件中调用gameEngine.Undo()然后调用棋盘控件的Invalidate()重绘即可。悔棋逻辑的细节悔棋步数通常可以允许悔多步直到棋盘为空。Stack数据结构完美契合了“后进先出”的悔棋操作。行棋方切换悔棋后行棋方的确定需要仔细设计。上述代码是一种简单实现悔掉哪一步当前行棋方就设为那一步的玩家。这意味着如果黑方下了一步白方悔棋那么当前行棋方又变回黑方。这符合“悔掉对方刚下的那一步”的直觉。你也可以设计成悔棋后行棋方不变这需要根据你的游戏规则来定。4.2 添加简单的人机对战AI雏形实现一个完整的五子棋AI如基于博弈树和Alpha-Beta剪枝非常复杂。但我们可以实现一个极其简单的“防守型”AI它只做一件事堵住玩家即将形成的“活四”或“冲四”。这至少能让游戏有点互动性。思路遍历棋盘所有空位。模拟如果玩家假设为黑棋在这个空位落子会形成怎样的棋型如活二、活三、冲四、活四。如果发现某个空位能让玩家形成“活四”下一步必赢则AI必须落子在此处防守。如果没有必杀点则寻找能让玩家形成“冲四”的点进行防守。如果还没有可以随机选择一个空位落子或者选择一个靠近棋盘中心或已有棋子的位置。这里给出一个极度简化的版本只判断“四子连线且一端被封住”的情况即“冲四”public Move GetAIMove(int aiPlayerColor) { int humanPlayerColor (aiPlayerColor 1) ? 2 : 1; ListMove candidateMoves new ListMove(); // 遍历所有空位 for (int row 0; row BoardSize; row) { for (int col 0; col BoardSize; col) { if (board[row, col] 0) { // 模拟人类在此落子 board[row, col] humanPlayerColor; // 检查人类是否会赢简化只检查这个子是否构成五连 if (CheckWin(row, col)) { board[row, col] 0; // 恢复棋盘 // 这是必须堵的点 return new Move { Row row, Col col, Player aiPlayerColor }; } board[row, col] 0; // 恢复棋盘 // 如果不是必杀点加入候选列表可以加入简单的评分比如靠近中心得分高 candidateMoves.Add(new Move { Row row, Col col, Player aiPlayerColor }); } } } // 如果没有必须堵的点从候选点中随机选择一个或按评分选择最高的 if (candidateMoves.Count 0) { Random rnd new Random(); return candidateMoves[rnd.Next(candidateMoves.Count)]; } return null; // 棋盘已满 }这个AI非常弱智但实现了从0到1的突破。你可以在此基础上引入棋型识别库给不同的棋型打分如活三、冲四、双三等并实现一个搜索深度为1或2的极大极小值搜索AI的强度就会显著提升。这将是另一个有趣的、更深层次的学习课题。4.3 游戏状态保存与加载如果需要保存棋局以便下次继续可以将棋盘状态board数组、当前行棋方CurrentPlayer、历史步数moveHistory序列化到文件如JSON或二进制格式。using System.IO; using System.Runtime.Serialization.Formatters.Binary; // 或者使用 Newtonsoft.Json (Json.NET) public void SaveGame(string filePath) { GameSaveData data new GameSaveData { Board this.board, CurrentPlayer this.CurrentPlayer, MoveHistory this.moveHistory.ToArray() // 栈转数组 }; // 使用 BinaryFormatter 进行二进制序列化简单但需标记[Serializable] BinaryFormatter formatter new BinaryFormatter(); using (FileStream stream new FileStream(filePath, FileMode.Create)) { formatter.Serialize(stream, data); } // 或者使用 JSON 序列化可读性好 // string json JsonConvert.SerializeObject(data); // File.WriteAllText(filePath, json); } public void LoadGame(string filePath) { // 反序列化过程... // 加载后需要调用棋盘控件的Invalidate()来刷新显示 }注意BinaryFormatter在.NET Core/.NET 5中出于安全考虑默认不被支持建议使用System.Text.Json或Newtonsoft.Json进行JSON序列化或者自定义二进制格式。5. 常见问题与调试技巧实录5.1 棋盘绘制闪烁问题问题描述拖动窗口或快速操作时棋盘和棋子会明显闪烁。原因Winform控件在重绘时默认是先擦除背景白色再绘制新内容。这个擦除和绘制的过程如果不同步人眼就会看到闪烁。解决方案启用双缓冲在自定义棋盘控件的构造函数中设置this.DoubleBuffered true;。这是最简单有效的方法它会在内存中先完成所有绘制操作然后一次性输出到屏幕。重写OnPaintBackground方法对于自定义绘制的控件可以避免不必要的背景擦除。protected override void OnPaintBackground(PaintEventArgs e) { // 不调用 base.OnPaintBackground(e); 避免默认的背景擦除 // 或者如果你有自定义背景如图片可以在这里绘制 if (this.BackgroundImage ! null) { e.Graphics.DrawImage(this.BackgroundImage, this.ClientRectangle); } }注意如果控件背景不是纯色需要自己处理背景绘制。5.2 鼠标点击坐标不准确问题描述点击棋盘边缘或格子线时落子位置有偏差或者没反应。排查步骤打印坐标在OnMouseClick事件中将转换前后的row和col打印出来或用Debug.WriteLine检查计算逻辑是否正确。确认offset、cellWidth、cellHeight的计算是否与OnPaint中的一致。检查控件大小确保在窗体大小改变时棋盘控件能正确重新计算格子大小。你可能需要监听控件的Resize事件并调用Invalidate()。吸附容错检查Math.Round的逻辑。有时因为浮点数精度问题边缘点击的坐标除以格子大小可能得到18.9999四舍五入后变成19导致数组越界。确保在转换后进行了严格的边界检查row 0 row boardSize。5.3 胜负判定算法漏判或误判问题描述明明连成五子了却没判定赢或者没连成五子却判赢了。调试方法单元测试为CheckWin方法编写简单的单元测试。构造一些特定的棋盘局面如横向五连、纵向五连、斜向五连、四连但不是五连等验证方法的返回值是否正确。可视化调试在CheckWin方法中当判定获胜时除了返回true还可以将被判定为五连的棋子坐标记录下来并在棋盘上用特殊颜色如红色高亮显示出来。这样你能直观地看到算法认为哪五颗子连成了线。检查方向增量仔细核对directions和oppositeDirs数组的定义确保八个方向四正四反都覆盖到了。检查计数逻辑CountDirection方法中的循环条件是否包含了边界检查(currentRow 0 ...)和颜色匹配检查(board[...] color)。确保计数是从1开始包含刚落下的那颗子。5.4 游戏状态管理混乱问题描述悔棋后行棋方错了或者游戏结束后还能落子。解决思路明确状态变量在GameEngine中定义一个GameStatus枚举如NotStarted,InProgress,BlackWin,WhiteWin,Draw。任何操作落子、悔棋、新开局前都先检查当前状态是否允许。状态转换清晰在PlacePiece方法中落子成功后立即调用CheckWin。如果获胜则将GameStatus设置为对应的获胜状态。在OnMouseClick中首先判断if(gameEngine.GameStatus GameStatus.InProgress)否则不处理点击。悔棋状态回退Undo操作不仅要回退棋盘还要回退游戏状态。如果悔棋后棋盘回到了未分胜负的状态应将GameStatus设回InProgress。同时要处理好CurrentPlayer的切换确保与规则一致。5.5 内存与资源泄漏问题描述长时间运行或频繁开始新游戏后程序内存占用持续增长。重点关注GDI对象Pen,Brush,Font,Image的释放。务必使用using语句或在不再使用时手动调用Dispose()。正确示例using (Pen myPen new Pen(Color.Red)) { ... }静态笔刷对于常用的纯色笔刷可以使用Brushes.Black、Pens.Red等静态属性这些由系统管理无需释放。图片资源如果使用了背景图片在窗体销毁时Dispose方法中要确保将其释放。这个五子棋项目虽然小但几乎涵盖了小型桌面应用开发的所有基础环节。从设计到实现再到调试和优化每一步都能学到实实在在的东西。我建议你不要止步于看懂这篇文章而是亲手把它敲出来运行它然后尝试去添加我上面提到的扩展功能比如一个更强一点的AI或者一个联网对战模块用TCP Socket。当你亲手解决了遇到的那些“坑”你对C#和Winform的理解会深刻得多。编程最好的学习方式永远是在动手实践中遇到问题、解决问题。
返回列表