三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

C#五子棋项目实战:从零构建WinForms游戏,详解AI算法与架构设计

C#五子棋项目实战:从零构建WinForms游戏,详解AI算法与架构设计

1. 项目概述与核心价值

五子棋,这个规则简单却蕴含无限策略的棋类游戏,几乎是每个计算机专业学生课程设计的热门选题。但很多同学在拿到“用C#实现一个五子棋游戏”这个题目时,往往会陷入迷茫:是做一个控制台的黑白字符对战,还是做一个带图形界面的?AI要不要做?怎么做?网络对战功能是不是太复杂了?作为一个带过不少课程设计、也评审过无数项目的过来人,我想说,一个优秀的课程设计项目,不在于功能有多炫酷,而在于你是否能用它清晰地展示你对一门编程语言核心特性的掌握,以及解决实际问题的工程化思维。

这次,我们就以“C#五子棋”为蓝本,进行一次深度的项目实战拆解。这不仅仅是一个游戏实现,更是一个综合运用C#面向对象、事件驱动、GDI+绘图、基础算法乃至简单设计模式的微型练兵场。对于初学者,它能帮你巩固语法;对于希望深入理解C#特性的开发者,它能让你看到事件委托、多线程等概念如何落地;对于课程设计,它则是一个能清晰体现你设计能力、编码规范和问题解决能力的绝佳载体。我们将从零开始,探讨如何构建一个结构清晰、可扩展、且具备一定智能对战能力的Windows窗体五子棋程序,并重点分析那些教科书上不会写的“坑”和“技巧”。

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

2.1 技术选型与框架确定

首先明确我们的战场:Windows桌面应用。C#在这方面有天然优势,WinForms和WPF是两个主流选择。对于课程设计而言,WinForms是更合适的选择。原因有三:第一,上手极快,拖拽控件即可搭建界面,能让你快速进入核心逻辑开发;第二,GDI+绘图足够满足五子棋棋盘、棋子的绘制需求,学习曲线平缓;第三,其事件驱动模型与五子棋“点击落子”的交互逻辑完美契合。WPF虽然更现代、强大,但对于一个旨在巩固核心概念的课程设计,WinForms的简洁和直接更能让你聚焦于业务逻辑。

项目的核心架构可以遵循经典的MVC(模型-视图-控制器)或其变体。在这里,我们采用一种更贴近WinForms习惯的分层结构

  1. 数据模型层:纯粹的数据对象,如ChessBoard(棋盘状态)、ChessPiece(棋子)、Player(玩家)。
  2. 游戏逻辑层:核心引擎,负责规则判定(输赢)、落子逻辑、AI计算等。它不关心界面。
  3. 表示层:即WinForms窗体,负责绘制棋盘、接收用户输入、显示信息。它持有逻辑层的引用。

这种分离的好处是显而易见的:逻辑层可以独立测试(比如用单元测试验证输赢算法);表示层更换(比如将来想换成WPF)时,逻辑层几乎不用改动。

2.2 核心类设计与职责划分

在动手写代码前,花点时间设计好核心类,能避免后期大量的重构。以下是我们需要的关键类:

  • MainForm:主窗体。职责包括:

    • 初始化游戏(棋盘、玩家)。
    • 处理鼠标点击事件,将点击坐标转换为棋盘逻辑坐标。
    • 调用GameEngine执行落子,并根据结果更新界面。
    • 使用GDI+在Paint事件中绘制棋盘网格和棋子。
    • 显示当前回合、获胜信息等。
  • GameEngine:游戏引擎,单例或静态类。这是大脑。职责包括:

    • 持有当前ChessBoard实例。
    • 提供PlacePiece(int x, int y)方法,该方法内部会:
      1. 检查目标位置是否为空。
      2. 创建棋子对象并存入棋盘数据。
      3. 调用WinChecker判断是否获胜。
      4. 切换当前玩家。
    • 可能集成或调用AIPlayer
  • ChessBoard:棋盘数据模型。一个二维数组(ChessPiece[,])是经典选择,但使用自定义类更面向对象。它应提供:

    • 属性Size(如15x15)。
    • 索引器,方便通过坐标访问棋子。
    • 方法IsPositionEmpty(int x, int y)GetPiece(int x, int y)等。
  • ChessPiece:棋子模型。非常简单,通常包含XY坐标和PieceColor(枚举:Black, White)属性。

  • WinChecker:胜负判定器。这是一个算法核心。它需要从当前落子点向四个方向(水平、垂直、左上-右下、右上-左下)进行搜索,统计连续的同色棋子数量。一旦某个方向达到5,则判定获胜。

  • AIPlayer(可选但强烈推荐):AI玩家。即使是简单的AI,也能极大提升项目的技术含量。初级AI可以采用“极大极小搜索”或更简单的“启发式评估”算法。我们后文会详细展开一个实用的实现方案。

设计心得:在课程设计中,清晰合理的类设计比实现一个复杂但混乱的功能更能赢得高分。在项目报告里,用UML类图展示你的设计思路,是绝对的加分项。

3. 核心模块实现与关键技术点

3.1 棋盘绘制与交互:GDI+的精准控制

WinForms中,绘图主要在窗体的Paint事件处理程序中进行,或使用自定义控件重写OnPaint方法。

绘制棋盘网格

private void MainForm_Paint(object sender, PaintEventArgs e) { Graphics g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; // 抗锯齿,让线条更平滑 Pen gridPen = new Pen(Color.Black, 1.5f); int cellSize = 30; // 每个格子的像素大小 int boardSize = 15; // 15路棋盘 int offset = 20; // 边距 // 画横线 for (int i = 0; i < boardSize; i++) { int y = offset + i * cellSize; g.DrawLine(gridPen, offset, y, offset + (boardSize - 1) * cellSize, y); } // 画竖线 for (int i = 0; i < boardSize; i++) { int x = offset + i * cellSize; g.DrawLine(gridPen, x, offset, x, offset + (boardSize - 1) * cellSize); } // 绘制五个小黑点(天元和星) Brush dotBrush = Brushes.Black; int[] starPoints = { 3, 7, 11 }; // 以15路为例,星位坐标 foreach (int x in starPoints) { foreach (int y in starPoints) { g.FillEllipse(dotBrush, offset + x * cellSize - 3, offset + y * cellSize - 3, 6, 6); } } }

关键点cellSizeoffset是核心参数。所有后续的坐标计算(如鼠标点击转逻辑坐标)都必须基于它们。务必保证计算精度,否则会出现棋子画在交叉点旁边的尴尬情况。

绘制棋子: 棋子需要画在交叉点上。我们根据ChessBoard中的数据来绘制。

private void DrawPieces(Graphics g) { int cellSize = 30; int offset = 20; int pieceRadius = 13; for (int x = 0; x < _board.Size; x++) { for (int y = 0; y < _board.Size; y++) { ChessPiece piece = _board.GetPiece(x, y); if (piece != null) { Brush pieceBrush = piece.Color == PieceColor.Black ? Brushes.Black : Brushes.White; Pen piecePen = piece.Color == PieceColor.Black ? Pens.White : Pens.Gray; // 白棋加个灰边更清晰 int drawX = offset + x * cellSize - pieceRadius; int drawY = offset + y * cellSize - pieceRadius; g.FillEllipse(pieceBrush, drawX, drawY, pieceRadius * 2, pieceRadius * 2); g.DrawEllipse(piecePen, drawX, drawY, pieceRadius * 2, pieceRadius * 2); } } } }

鼠标交互: 在窗体的MouseClick事件中,将屏幕坐标转换为棋盘坐标。

private void MainForm_MouseClick(object sender, MouseEventArgs e) { int cellSize = 30; int offset = 20; // 计算点击位置最近的交叉点 int boardX = (int)Math.Round((e.X - offset) / (double)cellSize); int boardY = (int)Math.Round((e.Y - offset) / (double)cellSize); // 边界检查 if (boardX < 0 || boardX >= _board.Size || boardY < 0 || boardY >= _board.Size) return; // 调用游戏引擎落子 if (_gameEngine.PlacePiece(boardX, boardY)) { this.Invalidate(); // 触发重绘 // 检查游戏状态,更新界面信息... } }

踩坑实录Math.Round的取整方式可能导致边界误差。一个更稳健的方法是计算点击位置与所有交叉点的距离,取最近的那个,并设置一个最大容差(如cellSize/3),超过则认为没点在交叉点上。

3.2 胜负判定算法:效率与清晰的平衡

胜负判定是五子棋的核心算法。最直观的方法是从最新落子点(x, y)开始,向四个方向搜索连续的同色棋子。

四方向扫描法

public class WinChecker { public static bool CheckWin(ChessBoard board, ChessPiece lastPiece) { if (lastPiece == null) return false; 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; // 包含当前棋子 // 正向搜索 count += CountContinuousPieces(board, lastPiece, dir[0], dir[1]); // 反向搜索 count += CountContinuousPieces(board, lastPiece, -dir[0], -dir[1]); if (count >= 5) { return true; } } return false; } private static int CountContinuousPieces(ChessBoard board, ChessPiece piece, int dx, int dy) { int count = 0; int x = piece.X + dx; int y = piece.Y + dy; while (x >= 0 && x < board.Size && y >= 0 && y < board.Size) { ChessPiece current = board.GetPiece(x, y); if (current != null && current.Color == piece.Color) { count++; x += dx; y += dy; } else { break; } } return count; } }

算法解析:这个算法的时间复杂度是O(1),因为无论棋盘多大,它只检查固定方向上的有限格子(最多8个方向各4格)。它高效且易于理解和实现,非常适合课程设计。

优化思考:对于更复杂的AI(如需要评估整个棋盘局面),我们可能需要一个“棋型判断”功能,即判断某个位置周围形成的形状(如“活四”、“冲四”、“活三”)。这需要更复杂的模式匹配,但基本原理仍是方向扫描和状态统计。

3.3 人工智能对手实现:从零到一的突破

为你的五子棋添加一个AI,是项目从“普通”走向“优秀”的关键一步。我们实现一个基于启发式评估的简单AI,它不进行深度搜索,而是为每个空位打分,选择分数最高的位置落子。

第一步:定义棋型与分数我们需要定义一些基本的棋型,并赋予它们分数。分数越高,威胁越大。

棋型描述示例(O为己方,X为对方,_为空)分数
成五连续五个子OOOOO100000
活四两边都空的四连子_OOOO_10000
冲四一边被堵的四连子XOOOO__OOOOX1000
活三两边都空的三连子_OOO_1000
眠三一边被堵的三连子XOOO_100
活二两边都空的二连子_OO_10

第二步:实现棋盘评估函数这个函数遍历整个棋盘,为指定颜色(AI颜色)评估一个总分。更实用的方法是,我们为每一个空位评估如果在此落子,能形成多好的棋型。

public class SimpleAI { private ChessBoard _board; private PieceColor _aiColor; public Point GetBestMove(ChessBoard board, PieceColor aiColor) { _board = board; _aiColor = aiColor; int bestScore = int.MinValue; Point bestMove = new Point(-1, -1); // 遍历所有空位 for (int x = 0; x < board.Size; x++) { for (int y = 0; y < board.Size; y++) { if (board.IsPositionEmpty(x, y)) { // 评估在此落子对AI的收益 int score = EvaluatePosition(x, y, aiColor); // 同时考虑阻止对手(评估对手在此落子的威胁) int opponentScore = EvaluatePosition(x, y, GetOpponentColor(aiColor)); int totalScore = score + opponentScore * 0.8; // 阻止对手的权重略低 if (totalScore > bestScore) { bestScore = totalScore; bestMove = new Point(x, y); } } } } return bestMove; } private int EvaluatePosition(int x, int y, PieceColor color) { // 模拟在此落子 _board.SetPiece(x, y, new ChessPiece(x, y, color)); // 需要一个临时设置的方法 int score = 0; // 从(x,y)出发,检查四个方向,匹配预定义的棋型模式 // 这里简化处理:检查是否能形成活四、冲四等 // 实际实现需要复杂的模式匹配,可以使用之前的方向扫描法,检查特定长度的连续子。 // 例如:检查是否形成“活三”(_OOO_),需要检查两边都是空位。 // 这是一个简化的示意: score += EvaluateLine(x, y, color, 1, 0); // 水平 score += EvaluateLine(x, y, color, 0, 1); // 垂直 score += EvaluateLine(x, y, color, 1, 1); // 左上-右下 score += EvaluateLine(x, y, color, 1, -1); // 右上-左下 // 撤销模拟落子 _board.SetPiece(x, y, null); return score; } private int EvaluateLine(int x, int y, PieceColor color, int dx, int dy) { // 这里需要实现具体的棋型匹配逻辑,返回该方向上的得分。 // 由于实现较长,此处省略。核心是向两个方向扩展,统计连续同色子数量,并检查两端情况。 // 可以预先定义一系列“模式”(用数组表示),然后进行匹配。 return 0; // 示例返回 } }

AI策略解析:这个AI虽然简单,但已经具备了攻防意识(score + opponentScore * 0.8)。它优先自己成五、活四,同时也会去堵对方的活四、冲四。在课程设计中,实现到这个程度,并能在报告里清晰阐述其原理和局限性(比如没有前瞻性,是“贪婪”算法),就足够出色了。

性能与优化:全盘遍历所有空位进行评估,在15x15棋盘上(225个点)是可以接受的。但如果评估函数EvaluatePosition很复杂,可能会感到延迟。一个优化策略是只评估有棋子周围的“气”点(相邻的空位),这能大幅减少评估点数。

4. 项目进阶与功能扩展

完成基础的双人对战和简单AI后,你的项目已经达标。但如果想冲击更高分,或者作为个人练手,可以考虑以下扩展方向,每一个都能深入挖掘C#的某个特性。

4.1 网络对战功能:Socket编程实战

实现一个简单的局域网对战。这需要你理解客户端-服务器模型和Socket编程

  • 设计:可以设计一个“主机”和一个“客户端”。主机创建服务器,监听端口;客户端连接主机。双方通过发送简单的字符串命令(如"MOVE 7,7""RESTART")来同步棋盘状态。
  • 关键技术
    • TcpListener/TcpClient:用于建立连接。
    • NetworkStream:用于读写数据。
    • 多线程或异步编程:网络通信必须是异步的,不能阻塞UI线程。可以使用async/await(C# 5.0+)来优雅地处理,这是展示你现代C#编程能力的好机会。
    // 服务端监听示例(异步) private async void StartListening() { TcpListener listener = new TcpListener(IPAddress.Any, 12345); listener.Start(); while (true) { TcpClient client = await listener.AcceptTcpClientAsync(); // 处理客户端连接,通常在新任务或异步方法中 _ = HandleClientAsync(client); } }
  • 挑战:状态同步、断线重连、回合控制。你需要设计一个简单的应用层协议来定义消息格式。

4.2 游戏状态持久化:数据序列化

实现保存游戏、加载存档功能。这涉及到对象的序列化与反序列化

  • 方案选择
    • 二进制序列化:简单,但跨版本兼容性差。使用BinaryFormatter(已过时,不推荐用于新项目)。
    • XML序列化:可读性好,但体积大。使用XmlSerializer
    • JSON序列化:轻量,可读,现代首选。使用Newtonsoft.Json(Json.NET)或.NET Core内置的System.Text.Json
  • 实现:将GameEngine的整个状态(棋盘、当前玩家、步数历史等)封装到一个可序列化的GameState类中。
    public class GameState { public ChessBoard Board { get; set; } public PieceColor CurrentPlayer { get; set; } public List<MoveHistory> History { get; set; } } // 保存 string json = JsonConvert.SerializeObject(gameState, Formatting.Indented); File.WriteAllText("savegame.json", json); // 加载 string json = File.ReadAllText("savegame.json"); GameState loadedState = JsonConvert.DeserializeObject<GameState>(json);
  • 注意:确保你的ChessBoardChessPiece等类都是可序列化的(有公共的无参构造函数,属性有公共getter/setter)。

4.3 音效与动画:提升用户体验

为落子、获胜等事件添加音效,为棋子落下添加简单的动画(如由小变大),能极大提升程序的质感。

  • 音效:使用System.Media.SoundPlayer播放WAV文件,或使用更强大的库如NAudio支持更多格式。
    SoundPlayer dropSound = new SoundPlayer("drop.wav"); dropSound.Play(); // 同步播放,可能卡顿 // 或使用Play()的异步重载 dropSound.Play();
  • 简单动画:在WinForms中实现动画,通常是在一个定时器TimerTick事件中,逐步改变某个属性(如棋子绘制半径),然后调用this.Invalidate()重绘。虽然不如WPF的动画系统强大,但实现一个棋子“弹入”效果是可行的。

5. 调试、测试与项目报告撰写

5.1 常见问题与调试技巧

  1. 棋子绘制偏移:这是最常见的问题。根本原因是逻辑坐标与像素坐标转换错误。务必反复检查offsetcellSize和绘制/点击事件中的计算逻辑。调试时,可以在鼠标点击事件中打印出计算出的boardX, boardY,并与预期对比。
  2. AI卡顿或无响应:如果AI思考时间过长,检查评估函数是否过于复杂或存在死循环。使用Stopwatch类来测量关键函数的执行时间。对于启发式AI,单步思考时间不应超过1秒。如果超时,考虑优化评估范围(只评估有子区域)或简化棋型判断。
  3. 内存泄漏:如果你使用了大量临时绘图对象(如Pen,Brush,Graphics),务必确保在using语句中创建或手动Dispose()。特别是在Paint事件中,PaintEventArgs提供的Graphics对象不要手动释放。
  4. 多线程UI更新冲突:在网络或AI计算线程中,绝对不能直接更新UI控件(如修改Label的Text)。必须使用Control.InvokeControl.BeginInvoke方法将更新操作封送回UI线程执行。
    // 在后台线程中 this.BeginInvoke(new Action(() => { lblStatus.Text = "AI正在思考..."; }));

5.2 单元测试的引入

即使对于课程设计,引入简单的单元测试也能体现你的工程素养。为WinCheckerAIPlayer的核心算法编写测试。

  • 测试框架:使用Visual Studio自带的MSTest,或者更流行的xUnit/NUnit。
  • 测试什么
    • WinChecker.CheckWin:测试各种获胜棋型(横、竖、斜)、非获胜棋型、边界情况。
    • AIPlayer.GetBestMove:测试在特定简单局面下(如自己马上成五,或对手有活四),AI是否能做出正确选择。
  • 示例
    [TestMethod] public void CheckWin_HorizontalFive_ShouldReturnTrue() { var board = new ChessBoard(15); // 在(0,0)到(0,4)放置5个黑子 for(int i=0; i<5; i++) board.PlacePiece(0, i, PieceColor.Black); var lastPiece = board.GetPiece(0, 4); bool result = WinChecker.CheckWin(board, lastPiece); Assert.IsTrue(result); }

5.3 项目报告与代码规范

一份优秀的项目报告和清晰的代码,是你能力的直接体现。

  • 项目报告结构

    1. 需求分析:简述项目目标、功能列表(单人vsAI、双人、网络、存读档等)。
    2. 系统设计UML类图(展示核心类及其关系)、模块划分图、关键算法流程图(如胜负判定、AI决策)。
    3. 详细设计与实现:分模块阐述,配合核心代码片段(不要贴全部代码)。重点解释为什么这么设计,比如为什么选择二维数组表示棋盘,为什么用启发式评估而非极大极小。
    4. 测试与运行结果:展示程序界面截图、描述测试用例(如与AI对战不同难度下的胜率)、单元测试结果。
    5. 总结与心得体会不要写空话。写你遇到的最大技术挑战是什么,如何解决的(比如坐标转换的Bug);写你对C#事件委托、GDI+绘图的新理解;写如果重做一次,会在架构上做什么改进(比如引入依赖注入容器来管理GameEngineAIPlayer)。
  • 代码规范

    • 命名:使用有意义的英文命名(ChessBoard,PlacePiece),避免拼音。
    • 注释:在类、方法、复杂逻辑处添加XML注释或行内注释,解释“做什么”和“为什么”。
    • 结构:一个类一个文件。将窗体代码(MainForm.Designer.cs)与逻辑代码分离。
    • 异常处理:对可能出错的操作(如文件读写、网络连接)使用try-catch

我个人在带学生做类似项目时发现,最大的差距往往不在于功能的多少,而在于代码的可读性可维护性。一个MouseClick事件处理函数写了300行,和将逻辑清晰地拆分到GameEngineWinChecker等类中,体现的是完全不同的编程思维层次。这个五子棋项目虽小,但足以成为一个检验你C#综合运用能力的试金石。从清晰的架构开始,逐步实现功能,耐心调试,最后别忘了用一份条理清晰的报告来为你的作品画上句号。当你看到自己编写的AI能和你进行有来有回的对弈时,那种成就感,正是编程最大的乐趣之一。

← 返回列表