简介:《C#语言连连看》源代码包以RAR压缩包形式提供,压缩包大小约941KB,文件目录及类型明细未单独标注,解压后可直接查看源码与配套资源。这份资源面向正在学习C#、Windows Forms界面开发或入门游戏编程的人群,适合用作课程设计、毕业设计或自学实战案例。源码配有详细注释,在实现连连看对局时涉及GUI布局与PictureBox/Button控件交互、Click事件驱动机制、System.Drawing图像加载与绘制、二维数组/List 棋盘状态存储,以及基于DFS/BFS的路径查找和图片配对消除逻辑,同时包含棋盘初始化、胜负判定、洗牌重排等完整流程。通过逐行阅读注释,初学者能快速理解图形界面程序从界面搭建到算法落地的全过程,有经验的开发者也能从中借鉴游戏逻辑拆分与代码组织方式。该资源目前已获得1108人次浏览学习,轻量体积与细致注释,可作为C#游戏编程的练手素材。
1. C#语言连连看游戏源代码:一份带注释的 WinForms 项目到底值不值得读
很多人搜“C#语言连连看”的动机非常具体:课程设计要交一个能跑的 WinForms 小程序,或者面试前想找个像样的桌面项目撑场面。标题里的五个字——C#语言连连看——背后其实是一套很标准的桌面小游戏写法:用二维数组当棋盘、用路径搜索做消除判定、用 GDI+ 画图块、用鼠标事件驱动游戏逻辑。它能解决的问题也不止“交作业”这么简单,而是让你一口气摸清 C# 桌面开发里最常被问到的四个知识点。这篇文章按这类源码的常见结构,把棋盘建模、消除算法、绘制交互和排错经验拆开讲,末尾再给你一个能立刻加进去的提示与测试方案。读完你不仅能看懂注释,还能自己动手改出一版能玩、能测、能拿得出手的小游戏。
2. 棋盘数据结构与初始化:用 C# 二维数组把一局棋盘装进内存
2.1 为什么棋盘选 int[,] 而不是 List<List >
翻开这类源码,最先出现的不是绘图代码,而是棋盘数据结构。绝大多数有效的 C# 连连看实现都会选int[,]或int[][],而不是List<List<int>>。原因是棋盘永远是矩形、尺寸固定,二维数组按下标访问时没有列表那层间接寻址开销,代码读起来也最贴近你脑子里那张网格。每个格子存 0 或一个正整数类型编号:0 代表已消除的空格,1 到 N 对应不同图块。
// GameBoard.cs —— 棋盘核心类 public class GameBoard { private readonly int[,] _grid; private readonly int _rows; private readonly int _cols; public const int EMPTY = 0; // 0 永远代表空格,禁止其他含义 public GameBoard(int rows, int cols) { _rows = rows; _cols = cols; _grid = new int[rows, cols]; // new 出来默认全是 0,也就是全空 } public int Rows => _rows; public int Cols => _cols; // 用索引器暴露只读访问,调用方拿值容易,改值只能通过专门方法 public int this[int r, int c] { get { return _grid[r, c]; } } public bool IsEmpty(int r, int c) { if (r < 0 || r >= _rows || c < 0 || c >= _cols) throw new ArgumentOutOfRangeException("棋盘坐标越界"); return _grid[r, c] == EMPTY; } }逻辑说明:构造参数rows和cols决定棋盘有效区域,索引器this[r, c]让调用方可以直接取图块值,却没法随意写入非法值;EMPTY统一管理“空”的语义。以后如果要做“冰块块”“锁块”这类扩展,只需要约定新的占位值,不用到处改魔法数字。
参数说明:rows和cols一般取 10 和 12,也就是 10 行 12 列共 120 个格子,太小了对路径判定不友好,太大了首屏装不下;EMPTY=0是约定,所有新增逻辑都必须遵守这个约定,否则后面LineClear里判断!= EMPTY就会把不该算障碍的格子算进去。
这里还有一个值得注意的工程习惯:把棋盘类拆成独立文件,不要写到Form1.cs里。带详细注释的源码通常都会强调这一点,因为逻辑层与界面层分离后,后面用控制台、单元测试去调引擎会非常顺手。做 C# 上位机或者别的 WinForms 项目时也该守住这条边界——事件处理器只做转发,不做业务判断。
2.2 “先组牌再洗牌”的生成逻辑:保证成对且开局不卡死
棋盘初始化最容易踩两个坑:一是总块数不是偶数,消到最后会剩单块;二是虽然成对,但开局就找不到任何可消除的一对。常见的解法是“先组牌、再洗牌”的两段式做法。
// 初始化一局:把牌组打散后填入棋盘 public void ResetBoard(int rows, int cols, int typeCount) { int totalCells = rows * cols; if (totalCells % 2 != 0) throw new InvalidOperationException("棋盘格子数必须是偶数"); List<int> pieces = new List<int>(totalCells); // 第一步:按类型造牌,保证每种类型出现偶数次 for (int type = 1; type <= typeCount; type++) { int count = totalCells / typeCount; if (count % 2 != 0) count--; // 奇数就砍掉一个,保持偶数 for (int i = 0; i < count; i++) pieces.Add(type); } // 除不尽时剩下的格子用已有的牌补上,保证总牌数 = 总格子数 while (pieces.Count < totalCells) pieces.Add(pieces[0]); // 第二步:费雪-耶茨洗牌,O(n) 且分布均匀 Random rng = new Random(); for (int i = pieces.Count - 1; i > 0; i--) { int j = rng.Next(i + 1); (pieces[i], pieces[j]) = (pieces[j], pieces[i]); } // 第三步:按行优先填入二维数组 int idx = 0; for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) _grid[r, c] = pieces[idx++]; } // 第四步:死局检测,无解就重新洗牌 while (!HasAnyValidMove()) ShuffleInPlace(); }逻辑说明:先拼一个 List,每种类型放totalCells / typeCount个,偶数配平后再洗牌。费雪-耶茨洗牌用rng.Next(i + 1)保证均匀分布,注意千万别写OrderBy(x => rng.Next()),那种写法既不均匀又会为整个序列创建大量临时对象,在 10×12 这种小棋盘上其实无所谓,但坏习惯一旦带到上位机项目里就是性能隐患。
参数说明:typeCount决定游戏难度。总数 120 格、类型数 12 时,每种类型 10 块;类型数越多,同型块分布越散,可消除对越少,游戏越难。末尾那个while (!HasAnyValidMove()) ShuffleInPlace();是保底逻辑,下章讲完CanConnect后再回头理解会非常顺。
2.3 类型数与棋盘尺寸如何搭配
这里的参数调整最直接地影响手感,也是这类源码里注释最多的部分。常见搭配如下:
| 棋盘尺寸 | 格子总数 | 推荐类型数 | 同型块均值 | 实际节奏 |
|---|---|---|---|---|
| 8×8 | 64 | 8~10 | 6.4~8 | 偏快,新手容易上手 |
| 10×12 | 120 | 12~16 | 7.5~10 | 经典配置,难度均衡 |
| 12×14 | 168 | 16~20 | 8.4~10.5 | 偏难,死局出现频率高,必须做好重洗 |
实际源码里如果看到const int ROWS = 10; const int COLS = 12;,那配套的typeCount大概率落在 12 到 16 之间。如果类型数调到 20 以上,你很快会发现一局游戏频繁触发“无解重洗”,玩家体验会很差;调低到 8 以下又太简单,开局随便点都能消。所以表格里的推荐值是按“每种类型大概 8~10 块”反推的,这也是这个品类的通用设计经验。
要注意ShuffleInPlace不能在死局后无限重试。更稳的写法是限制重洗次数,比如 50 次后仍然无解就调用ResetBoard(rows, cols, typeCount + 2),把类型数调高一点重新生成,避免极端情况下Random连续给出恶意分布导致死循环。这个细节在代码里不显眼,但实际运行久了一定会碰到。
3. 消除判定核心算法:两个拐点以内的路径搜索怎么写
3.1 直线检查 LineClear:边界与端点的三个坑
连连看的规则一句话能说清:两个图块可消除,当且仅当存在一条不穿越其他图块的路径连接它们,且路径至多拐两次弯。拆开就是直线(0 拐)、一次折角(1 拐)、两次折角(2 拐)三种形态。路径永远不能穿过非空格子,但可以贴着棋盘外沿走——这一点在实现里经常被漏掉。
先写最底层的直线检查:
// 判断 a、b 之间的直线上是否有障碍 // 前置条件:a、b 必须同行或同列;端点本身不算障碍 private bool LineClear(Point a, Point b) { if (a.X == b.X) // 垂直方向:行相同,检查列之间 { int low = Math.Min(a.Y, b.Y); int high = Math.Max(a.Y, b.Y); for (int y = low + 1; y < high; y++) { if (_grid[a.X, y] != EMPTY) return false; } return true; } if (a.Y == b.Y) // 水平方向:列相同,检查行之间 { int low = Math.Min(a.X, b.X); int high = Math.Max(a.X, b.X); for (int x = low + 1; x < high; x++) { if (_grid[x, a.Y] != EMPTY) return false; } return true; } return false; // 不同行也不同列,直线检查直接不通过 }逻辑说明:LineClear只处理同行或同列的a、b。循环从low + 1到high - 1,恰好避开两个端点本身——端点属于正在比较的两个图块,当然不算障碍。如果把范围写成low到high,两个紧挨着的同款块会永远判定为“中间有障碍”,这是这个函数里最常见的翻车点。
参数说明:a、b是System.Drawing.Point,X存行号、Y存列号,这只是个约定,你完全可以改成两个整数参数。注意Point是System.Drawing下的值类型,绘图代码共用确实方便,但如果想把这个核心类拆成不依赖 WinForms 的类库做单元测试,就需要改成自建结构体或者直接传四个int,避免测试工程被迫引用System.Drawing。
3.2 0拐、1拐、2拐的完整连接判定:CanConnect 的实现
直线之外,一次拐弯等价于“两个块位于一个矩形的对角位置,找一个邻角点,若它为空且两端到它的连线都畅通,就能连”。两次拐弯则要麻烦一点,穷举所有可能的中介行或中介列:
// 核心判定:两个格子能否通过不超过两次拐弯的路径连接 public bool CanConnect(Point a, Point b) { if (a == b) return false; if (_grid[a.X, a.Y] != _grid[b.X, b.Y]) return false; // 图块类型不同 if (_grid[a.X, a.Y] == EMPTY) return false; // 空格不能消 // 0 拐:直线 if ((a.X == b.X || a.Y == b.Y) && LineClear(a, b)) return true; // 1 拐:矩形对角上的两个角点 Point corner1 = new Point(a.X, b.Y); Point corner2 = new Point(b.X, a.Y); if (IsVirtualEmpty(corner1) && LineClear(a, corner1) && LineClear(corner1, b)) return true; if (IsVirtualEmpty(corner2) && LineClear(a, corner2) && LineClear(corner2, b)) return true; // 2 拐:枚举所有中介行(注意 -1 和 _rows 是外沿虚拟行) for (int r = -1; r <= _rows; r++) { Point mid1 = new Point(r, a.Y); Point mid2 = new Point(r, b.Y); if (IsVirtualEmpty(mid1) && IsVirtualEmpty(mid2) && LineClear(a, mid1) && LineClear(mid1, mid2) && LineClear(mid2, b)) return true; } // 2 拐:枚举所有中介列(同样包含外沿虚拟列) for (int c = -1; c <= _cols; c++) { Point mid1 = new Point(a.X, c); Point mid2 = new Point(b.X, c); if (IsVirtualEmpty(mid1) && IsVirtualEmpty(mid2) && LineClear(a, mid1) && LineClear(mid1, mid2) && LineClear(mid2, b)) return true; } return false; }逻辑说明:r从 -1 扫到_rows,c从 -1 扫到_cols,就是为了包含棋盘外沿的虚拟空位——连连看允许路径贴着棋盘外沿走,拐点落在棋盘外面完全合法。IsVirtualEmpty的判断很简单:坐标越界视为空,坐标在界内则看_grid里对应位置是否为EMPTY。中间的LineClear(a, mid1)检查起点到拐点的路径,LineClear(mid1, mid2)检查拐点之间的长距离,三步缺一不可,顺序也不能换,否则会漏掉路径。
性能说明:10×12 的棋盘一共 120 格,最坏情况做一次全盘可消性扫描也就是约 7000 对组合,每对组合里CanConnect最坏循环几十次,总共几十万次整数比较,在 C# 里是毫秒级的事。完全不需要上 BFS 或者 A*,用 BFS 去解这个“最多两拐弯”的约束反而容易得出让人看不懂的绕路路径,跟玩家预期不一致。
3.3 死局检测与重洗:别让一局游戏卡在无解状态
ResetBoard末尾那句HasAnyValidMove现在能实现了:
// 全局可消检测:任意两个非空且类型相同的块能连上,就说明没有死局 public bool HasAnyValidMove() { for (int r1 = 0; r1 < _rows; r1++) { for (int c1 = 0; c1 < _cols; c1++) { int type = _grid[r1, c1]; if (type == EMPTY) continue; for (int r2 = r1; r2 < _rows; r2++) { for (int c2 = (r2 == r1) ? c1 + 1 : 0; c2 < _cols; c2++) { if (_grid[r2, c2] == type && CanConnect(new Point(r1, c1), new Point(r2, c2))) return true; } } } } return false; }逻辑说明:外层两个循环固定“第一块”,内层两个循环遍历它后面所有格子,避免同一对检查两遍。只要类型相同且CanConnect返回 true,就说明当前棋盘还有解。每次玩家成功消除一对后,也应该调用它做一次终局判定,如果返回 false 就该弹出提示并提供“重洗”按钮。
参数说明:这里的_rows、_cols是棋盘有效行列数,不含外沿虚拟区域。遍历范围必须是有效区域,因为虚拟区域没有真实图块,参与配对只会制造假的可消除对。重洗按钮的逻辑可以直接复用ShuffleInPlace(),但注意洗牌后仍然要再做一次HasAnyValidMove(),否则可能洗出一个新的死局棋盘,玩家会以为程序卡死了。
4. GDI+ 渲染与鼠标交互:从贴图到高亮反馈的完整绘制流程
4.1 双缓冲绘制:三步让界面告别闪烁
WinForms 里画棋盘最常见的问题是闪烁。闪烁的根源是默认情况下控件收到WM_PAINT后是边擦背景边画内容,每一步都显示在屏幕上;只要绘图内容多,就会看到明显的刷屏抖动。解法是启用双缓冲:
// GamePanel.cs —— 一个自绘面板 public class GamePanel : Panel { public GamePanel() { // 双缓冲三件套:先画到后台缓冲,再一次性拷贝到屏幕 this.SetStyle( ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true); this.ResizeRedraw = true; // 尺寸变化时自动重绘 } private Image[] _tiles; // 每种图块的纹理,构造时加载一次 private int _cellSize = 48; // 每个格子的边长像素 private GameBoard _board; protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g = e.Graphics; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; g.Clear(Color.White); for (int r = 0; r < _board.Rows; r++) { for (int c = 0; c < _board.Cols; c++) { Rectangle rect = new Rectangle( c * _cellSize, r * _cellSize, _cellSize, _cellSize); g.DrawRectangle(Pens.LightGray, rect); int val = _board[r, c]; if (val != GameBoard.EMPTY && _tiles[val] != null) { g.DrawImage(_tiles[val], rect); // 把图块贴进格子 } } } } }逻辑说明:SetStyle三件套中OptimizedDoubleBuffer是关键,它让控件先把所有绘图内容画到内存缓冲,再一次性复制到屏幕;AllPaintingInWmPaint避免背景擦除造成白闪;UserPaint则让控件完全由自绘逻辑负责。ResizeRedraw = true使得窗口拉伸时立即触发重绘,而不是残留旧像素。
参数说明:_cellSize = 48是个视觉与点击精度都折中的值,再小到 32 以下图块看不清,大到 64 以上同等棋盘会超出屏幕。图片纹理务必在构造函数里一次加载好,不要放进OnPaint里,否则每一次重绘都会去读磁盘或反复解析图片,帧率一掉,玩家第一反应就是“这个 C# 程序好卡”。
4.2 鼠标坐标映射与选中高亮
鼠标事件在 WinForms 里通常注册在Panel.MouseDown上,核心工作是像素坐标换算成棋盘行列:
// 鼠标按下:换算棋盘坐标并处理两次点选逻辑 private void GamePanel_MouseDown(object sender, MouseEventArgs e) { int col = e.X / _cellSize; // 像素坐标 → 列号 int row = e.Y / _cellSize; // 像素坐标 → 行号 if (row < 0 || row >= _board.Rows || col < 0 || col >= _board.Cols) return; // 点在棋盘外,忽略 if (_board[row, col] == GameBoard.EMPTY) return; // 点在空格上,同样忽略 if (!_selected.HasValue) { _selected = new Point(row, col); // 第一次点选,记录并高亮 Invalidate(); return; } // 第二次点选:尝试消除 Point second = new Point(row, col); if (_board.CanConnect(_selected.Value, second)) { _board.Eliminate(_selected.Value, second); // 将两个格子置空 _selected = null; Invalidate(); } else { // 消除失败,把选中状态切到当前点击的块上(常见交互习惯) _selected = second; Invalidate(); } }逻辑说明:e.X / _cellSize用的是整数除法,48 像素格子里 x 从 0 到 47 都返回列 0,正好匹配绘制时的矩形边界。_selected采用Point?可空类型,第一次点击记录位置,第二次点击尝试连接,失败则把选中切到新块——这是主流连连看交互,比“失败后清空选中”更有连续性。
参数说明:e.Location是相对于触发事件的 Panel 的坐标,不是窗体坐标。千万不要用Cursor.Position或PointToClient去二次换算,否则面板一旦有边框或偏移,高亮就会整体偏一格。还有一个隐藏细节:第二次点击如果点到了第一次相同的块,必须忽略,否则玩家快速双击时程序会尝试“自己连自己”,导致逻辑错乱。
选中高亮的绘制放在OnPaint末尾:
if (_selected.HasValue) { int r = _selected.Value.X; int c = _selected.Value.Y; using (Pen pen = new Pen(Color.OrangeRed, 3f)) { Rectangle selRect = new Rectangle( c * _cellSize, r * _cellSize, _cellSize, _cellSize); g.DrawRectangle(pen, selRect); } }注意using包裹Pen,GDI+ 对象用完要释放。WinForms 的Pen、Brush都是 IDisposable,频繁new不释放会积累 GDI 句柄,程序跑久了画图会突然变慢甚至抛异常——这种问题在任务管理器里很难看出来,属于典型的“看起来没内存泄漏,但句柄数一直在涨”。
4.3 可选增强:临时路径连线与提示动画
做到这里,核心逻辑已经完整,但想让游戏更直观,可以加一条“连接预览线”:当第二次点击的目标和当前选中块满足CanConnect时,在消除前先画出路径。这需要把CanConnect的判定结果和路径一起返回:
// 试着找一条 <=2 拐的路径,找不到返回 null public List<Point> TryFindPath(Point a, Point b) { if (a == b) return null; if (_grid[a.X, a.Y] != _grid[b.X, b.Y]) return null; if (_grid[a.X, a.Y] == EMPTY) return null; // 直线路径 if ((a.X == b.X || a.Y == b.Y) && LineClear(a, b)) return new List<Point> { a, b }; // 1 拐路径 Point corner1 = new Point(a.X, b.Y); Point corner2 = new Point(b.X, a.Y); if (IsVirtualEmpty(corner1) && LineClear(a, corner1) && LineClear(corner1, b)) return new List<Point> { a, corner1, b }; if (IsVirtualEmpty(corner2) && LineClear(a, corner2) && LineClear(corner2, b)) return new List<Point> { a, corner2, b }; // 2 拐路径:这里保留中介点,绘制时可以直接连成折线 for (int r = -1; r <= _rows; r++) { Point mid1 = new Point(r, a.Y); Point mid2 = new Point(r, b.Y); if (IsVirtualEmpty(mid1) && IsVirtualEmpty(mid2) && LineClear(a, mid1) && LineClear(mid1, mid2) && LineClear(mid2, b)) return new List<Point> { a, mid1, mid2, b }; } // 列方向同理,略 return null; }逻辑说明:这份代码和CanConnect几乎同构,区别只是把判定成功的路径点收集起来。绘图时拿到List<Point>后用Graphics.DrawLines依次连线即可。注意IsVirtualEmpty返回 true 的虚拟拐点坐标可能在棋盘外,绘制时要先 clip 到 Panel 区域,否则线条会画出控件边界。
5. 避坑指南:连连看开发里最容易翻车的5个细节
5.1 现象:两个相邻图块却永远无法消除
开始时我遇到过这种怪事:两个同款块并肩挨着,点击后程序却提示无法连接。检查半天发现LineClear的循环写成了:
for (int y = low; y < high; y++)这里把端点也包含进循环了。两个相邻块low和high只差 1,循环体执行一次,检查的正好是起点位置的格子,它当然不等于EMPTY,于是直线永远“被阻挡”。
原因总结:循环边界没有排除端点自身。
解决:统一改成low + 1到high - 1,并写一个最小用例验证——用 2×4 棋盘,放两对相邻同款块,跑一次CanConnect,true 就对了。
5.2 现象:快速双击时同一个块被连续吞掉或状态错乱
连续快速点击时,第一次点选和第二次点选之间鼠标事件已经触发了多次,导致程序把同一个点既当作“第一次选中”又当作“第二次消除目标”,最后同一块被连消两次,棋盘上出现一个莫名其妙的空格。
原因总结:交互状态机没做防抖,两次点击间隔内没有检查目标块是否还是原来那个非空块。
解决:在消除分支里,强制判断_selected.Value != second,同时用Eliminate方法内部再检查一次两个格子是否都非空。也就是说,不能只依赖界面层的判断,核心逻辑层也要做防御,双保险才不会被快速点击钻空子。
5.3 现象:拖动窗体时画面闪烁得像幻灯片
WinForms 自绘控件默认是不开双缓冲的。如果你直接在Form1的Paint事件里画棋盘,并且每次画之前又从磁盘加载图片,拖动窗口时会一帧一帧地擦背景、读文件、画图块,看起来就像幻灯片。
原因总结:没启用双缓冲,且OnPaint里做了耗时的图片加载。
解决:把自绘逻辑放到自定义 Panel 里,启用OptimizedDoubleBuffer | AllPaintingInWmPaint | UserPaint,图片资源放到构造函数里加载。这个坑在更换到高 DPI 屏幕或 4K 显示器时更容易暴露,因为每次重绘的像素量成倍增长。
5.4 现象:棋盘后半程找不到可消对,整局卡死
一局玩到只剩十几块时,突然怎么点都消不了,而且程序也没有任何提示。这是因为开发时只做了开局死局检测,没做“每消一对之后”的再次检测。
原因总结:消除后棋盘状态变化,原先有解的布局可能变成死局;如果代码只在ResetBoard时检测一次,后半程就没人管了。
解决:每次Eliminate成功之后调用一次HasAnyValidMove(),返回 false 时就触发“无解重洗”,弹出一个提示框并调用ShuffleInPlace()。重洗后必须再做一次可消检测,这个逻辑写成循环,最多重试 50 次,再不行就加类型数重新生成。这属于典型的运行时防护,别指望开局检测能覆盖后面所有局面。
5.5 现象:点击格子高亮位置和鼠标相差一格
看起来像“鼠标点到左边格,高亮却出现在右边格”。问题通常出在坐标换算上,常见两种写法:
int col = (e.X - panel.Left) / _cellSize; // panel.Left 是相对窗体的坐标 int col = e.X / (_cellSize + 1); // 多加了边框宽度第一种错在e.X本身已经是相对 Panel 的坐标,再减panel.Left就把坐标往左推了;第二种错在把 1 像素边框也当成格子边长的一部分,导致边界区域永远映射到前一格。
原因总结:坐标空间混用、边框宽度参与计算。
解决:统一用e.X / _cellSize和e.Y / _cellSize,绝对不要再用 Panel 的Left、Top或Location做二次偏移。面板如果有边框,要把边框宽度单独考虑,把绘图区域和点击区域共用同一个_cellSize常量,一处定义,所有地方都引用它,就不会出现绘制和交互两套标准。
6. 进阶技巧:提示、计分与核心算法的自动化验证
6.1 提示功能怎么实现:扫描第一对可消对
提示按钮的逻辑本质就是调一次HasAnyValidMove的变体,并且把找到的那一对格子返回出来:
// 返回当前棋盘任意一对可消块,找不到返回 null public (Point first, Point second)? FindHint() { for (int r1 = 0; r1 < _rows; r1++) { for (int c1 = 0; c1 < _cols; c1++) { if (_grid[r1, c1] == EMPTY) continue; for (int r2 = r1; r2 < _rows; r2++) { for (int c2 = (r2 == r1) ? c1 + 1 : 0; c2 < _cols; c2++) { if (_grid[r2, c2] == _grid[r1, c1] && CanConnect(new Point(r1, c1), new Point(r2, c2))) { return (new Point(r1, c1), new Point(r2, c2)); } } } } } return null; }拿到返回值后,把两个坐标高亮两秒,再自动清除高亮。计时计分就更直接了:在Eliminate调用处加一个计数器,每消一对加 10 分,连续消三对以上给连击加成;计时用System.Windows.Forms.Timer,设置Interval = 1000,每秒触发一次把剩余时间减一,归零弹窗结束游戏。这一套加完,项目就具备了完整游戏闭环,拿去面试演示完全够用。
6.2 用单元测试锁死 CanConnect 的正确性
开发这类逻辑时我最大的教训是把判定代码直接写在窗体事件里,结果没法单独验证,出了问题只能靠肉眼盯着屏幕猜。后来把GameBoard拆成不依赖System.Drawing的纯逻辑类,用int行号列号参数替代Point,事情就简单了。你完全可以建一个控制台项目,引用同一个GameBoard.cs,写几个断言跑一遍:
// 核心算法验证:直接调用,不涉及任何窗体绘制 public void TestLineClear() { GameBoard gb = new GameBoard(4, 4); gb.SetCell(0, 0, 1); gb.SetCell(0, 2, 1); bool ok1 = gb.CanConnect(0, 0, 0, 2); // 中间无阻挡,应为 true gb.SetCell(0, 1, 2); // 在中间放一个障碍块 bool ok2 = gb.CanConnect(0, 0, 0, 2); // 中间有阻挡,应为 false Console.WriteLine($"直连无阻挡={ok1}, 中间挡块={ok2}"); if (ok1 != true || ok2 != false) throw new Exception("LineClear 逻辑异常"); }逻辑说明:CanConnect(0,0,0,2)这种重载直接吃行列号,避免测试工程引用System.Drawing。给GameBoard补一个SetCell方法用于测试数据准备,就能把所有边界情况都打出来。我把LineClear端点排除、1 拐角点为空、2 拐虚拟外沿这三类最容易写错的分支各测一遍,只要这些用例通过,游戏核心逻辑基本就稳了,剩下的事情交给界面层。
这也是我想强调的收尾习惯:源码带详细注释是好事,但只有当你把核心算法从窗体里拆出来、能独立测试、能重构、能加新功能时,这份源码才真正变成你自己的能力。我一开始把全部判断都塞进MouseDown事件里,代码写了三百行没法测,后来痛定思痛拆成引擎类和界面类,整个项目的可维护性完全不一样。这个教训让我后来做 C# 上位机项目时也一直坚持逻辑层与界面层分离。希望帮到你。
本文还有配套的精品资源,点击获取