ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C#连连看源码详解:从棋盘建模到算法与界面交互

C#连连看源码详解:从棋盘建模到算法与界面交互 简介C#连连看游戏源代码附带详细注释面向正在学习C#、Windows Forms桌面开发或休闲小游戏实现的读者也适合作为课程设计与毕业设计参考。源码覆盖完整游戏流程棋盘初始化、图片加载与绘制、点击事件响应、消除判定、胜负判断、剩余图片重排重点演示了常见控件的使用、System.Drawing图像处理、二维数组或列表容器存储棋盘状态以及深度优先搜索/广度优先搜索在路径查找中的应用便于按模块逐段阅读。资源打包为RAR压缩包整体仅941KB轻量易下载适合快速对照练习。已有1108人学习下载说明该资源在C#入门实践中被反复使用。通过逐行理解注释并动手运行可以掌握从界面搭建到核心算法落地的完整开发思路积累可复用的连连看实现经验。1. 为什么 C# 连连看源码带详细注释这件事值得反复看你照着网上的 C# 连连看游戏源代码敲过一遍就会知道真正卡住你的不是“连连看”三个字而是游戏源代码里那几十个没有注释的整数、循环和事件。要么棋盘怎么点都没反应要么消除判定时灵时不灵最气人的是连不上时连调试都无从下手。带详细注释的 C# 连连看源码解决的不只是“能跑”而是让你看懂坐标是怎么映射的、连通路径是怎么搜索的、界面刷新为什么必须在主线程里做学完这一套你改任何 2D 小游戏都会顺手很多。这篇笔记按我平时看源码的习惯从棋盘建模一路讲到消除算法、界面交互和发版前的那些坑适合刚学完 C# 语法想拿小游戏练手的人也适合拿它做课程设计或上位机项目练手的人。2. 棋盘与坐标建模先把地图和窗口一一对应拆明白2.1 为什么要建虚拟地图而不是直接摆一排 PictureBox很多刚上手的人第一反应是把每个方块做成一个 PictureBox拖动窗体时再手动同步坐标。这种做法在 WinForms 里不是不能做但地图一旦到 10×12 以上控件数量就是上百个内存占用、Tab 顺序、焦点事件全来凑热闹最常见的问题是一不小心点到了控件边框导致选中状态丢失。更关键的是连连看的核心是“路径搜索”搜索的是逻辑格子而不是控件对象。所以常见的做法是用一个二维数组保存棋盘数据界面上只有一个大的显示区鼠标点击后根据像素坐标换算成数组下标。逻辑和界面彻底分离的另外一个好处是棋盘数据可以被单元测试直接调用不需要启动窗口就能验证消除算法对不对。我一般把棋盘数据单独放在 Board 类里界面上的 Form 只管画图和转发鼠标事件。2.2 用二维数组建棋盘并初始化成“成对可消”的局下面这段代码是我常用的棋盘初始化方式它把图片编号按对放入数组然后整体洗牌保证每个图片编号出现偶数次这是“能消完”的必要条件。public class Board { public int Rows { get; } public int Cols { get; } public int[,] Grid { get; private set; } // 0 表示空格大于 0 表示图片编号 public Board(int rows, int cols, int typeCount) { Rows rows; Cols cols; // 棋盘外围多留一圈虚拟空格路径可以绕出边界 Grid new int[Rows, Cols]; Listint pairs new Listint(); int totalCells rows * cols; int maxTypes totalCells / 2; if (typeCount maxTypes) typeCount maxTypes; // 每种图片出现 2 次先保证数量成对 for (int i 0; i totalCells; i) { pairs.Add(i % typeCount 1); } Shuffle(pairs); // 洗牌结果填进数组 for (int i 0; i totalCells; i) { int row i / Cols; int col i % Cols; Grid[row, col] pairs[i]; } } private void Shuffle(Listint list) { Random rng new Random(); for (int i list.Count - 1; i 0; i--) { int j rng.Next(i 1); (list[i], list[j]) (list[j], list[i]); } } public bool IsEmpty(int row, int col) { if (row 0 || col 0 || row Rows || col Cols) return true; return Grid[row, col] 0; } }填充数组时先让图片编号成对出现再用 Fisher-Yates 洗牌把位置打乱这套逻辑比“随机放图再检查漏网之鱼”简单得多也更容易保证可解性。注意 typeCount 不能大于格子总数的一半否则后面必然有找不到配对的图片编号。Random 默认使用当前时间作为种子同一秒内多次 New 会导致重复序列如果你的源码里有 New Random 放在循环体里的写法建议把它提到构造函数里只创建一次。2.3 像素坐标与逻辑坐标的换算是第一个计算题窗口里一个格子画多大决定了鼠标点击位置的换算规则。假设格子边长是 CellSize棋盘左上角从 (OffsetX, OffsetY) 开始画那么鼠标坐标 (mouseX, mouseY) 对应的格子坐标是public Point GetCellFromPixel(int mouseX, int mouseY) { int col (mouseX - OffsetX) / CellSize; int row (mouseY - OffsetY) / CellSize; // 超出棋盘范围统一返回 -1避免越界访问 if (row 0 || col 0 || row Rows || col Cols) return new Point(-1, -1); return new Point(col, row); }这里最容易翻车的点是WinForms 里鼠标事件给的是相对于当前控件的坐标如果你把事件挂在 Panel 上就直接用如果挂在 Form 上还要减去 Panel 的 Location。另一个隐蔽问题是 DPI 缩放系统显示比例是 125% 或 150% 时Control 的 MouseClick 坐标已经被系统换算过了但你自己算格子边长时如果用写死的 40 像素点击位置就会整体偏移。处理方式是在窗体加载时读取当前 DPI 缩放值把 CellSize 乘上缩放系数而不是等用户报告“点不到那块牌”再去加偏移量。3. 核心算法零拐点、一拐点、二拐点怎么判断3.1 连通判定的本质是路径搜索而不是“眼睛看直不直”连连看里两个格子能消除指的是存在一条不穿过任何图片块的折线路径这条路径最多转两个弯。判断“能不能消”不能只比较图片编号是否相同还要检查路径是否畅通。初学者最容易做成“两张图一样就消掉”结果隔着三块牌也能消整局游戏变成鼠标点击游戏。路径搜索的核心对象是格子而不是像素每个格子只有“空”和“非空”两种状态。为了允许路径绕出棋盘搜索时会在真实棋盘外面包一层虚拟空区域这也是为什么上一章我让越界位置返回 true。零拐点就是直线路径一拐点就是两条直线拼成 L 形二拐点就是两条直线加一条中间线段搜索时枚举中间节点复杂度可以接受。3.2 先把直连判断写对后面才能少走弯路public bool CanConnectDirectly(Point start, Point end) { if (start end) return false; // 起点和终点必须在一条水平线或垂直线上 if (start.X ! end.X start.Y ! end.Y) return false; int minRow Math.Min(start.Y, end.Y); int maxRow Math.Max(start.Y, end.Y); int minCol Math.Min(start.X, end.X); int maxCol Math.Max(start.X, end.X); // 从起点走到终点沿途必须全是空格 if (start.Y end.Y) // 同一行水平方向检查 { for (int col minCol 1; col maxCol; col) { if (!IsEmpty(start.Y, col)) return false; } } else // 同一列垂直方向检查 { for (int row minRow 1; row maxRow; row) { if (!IsEmpty(row, start.X)) return false; } } return true; }直连判断的关键是“开区间”还是“闭区间”循环从 min 1 到 max - 1也就是不检查起点和终点本身因为终点上放着待消除的图片块它本来就该是非空的。代码里先判断是否同行或同列因为只有这两种情况才可能直连。如果两个点既不在同一行也不在同一列直接返回 false 不会进入后续逻辑。3.3 一拐点和二拐点先枚举拐点再验证两条线段一拐点其实可以看作两个坐标夹出来的矩形对角点。假设起点是 (x1, y1)终点是 (x2, y2)可能的拐点是 (x1, y2) 和 (x2, y1)只要其中一个拐点是空的并且起点到拐点、拐点到终点分别直连就说明能用一个直角连起来。public bool CanConnectOneCorner(Point start, Point end) { Point c1 new Point(start.X, end.Y); // 起点同列终点同行 Point c2 new Point(end.X, start.Y); // 起点同行终点同列 if (IsEmpty(c1.Y, c1.X) CanConnectDirectly(start, c1) CanConnectDirectly(c1, end)) return true; if (IsEmpty(c2.Y, c2.X) CanConnectDirectly(start, c2) CanConnectDirectly(c2, end)) return true; return false; }这个写法里 IsEmpty 的入参我故意写成了 (row, col)和前面 Board 的接口保持一致避免搞混。关键点是拐点本身必须为空因为路径要从这里经过。如果起点和终点相邻比如 (0,0) 和 (0,1)c1 和 c2 会落在这两个格子本身上IsEmpty 判断会返回 false但这不影响结果因为相邻格子本来就符合直连条件会在 CanConnectDirectly 里提前通过。二拐点就是在一拐点外面再套一层。思路是枚举所有同行或同列的中间点让起点到中间点直连、中间点到终点一拐点。实际写的时候还可以反过来起点到中间点一拐点、中间点到终点直连两种写法的搜索范围不同但结果等价。为了避免重复代码我一般会把二拐点实现成遍历所有空格作为中转点。public bool CanConnectTwoCorners(Point start, Point end) { // 以横向路径为例枚举中间列 for (int col 0; col Cols; col) { if (col start.X || col end.X) continue; Point mid new Point(col, start.Y); if (!IsEmpty(mid.Y, mid.X)) continue; // 起点到 mid 直连mid 到终点一拐点 if (CanConnectDirectly(start, mid) CanConnectOneCorner(mid, end)) return true; } // 再枚举中间行 for (int row 0; row Rows; row) { if (row start.Y || row end.Y) continue; Point mid new Point(start.X, row); if (!IsEmpty(mid.Y, mid.X)) continue; if (CanConnectDirectly(start, mid) CanConnectOneCorner(mid, end)) return true; } return false; }这里有个性能和逻辑上的双重陷阱枚举时必须跳过起点和终点所在的行或列否则 mid 可能落在图片块本身上面导致判断失败。还有一点容易被忽略中间的 mid 是空格只是必要条件mid 到 end 的一拐点路径上不能再有别的图片块所以最终判断结果还是要靠递归调用来保证。整个流程跑完都没有找到路径就返回 false。3.4 路径搜索的边界外圈虚拟通道和路径步数连连看经典玩法允许路径绕过整个棋盘外侧比如左上角的一张牌和右下角的一张牌可以从棋盘外圈绕一个大 C 字形连通。这个特性要求搜索时不能只用 Grid 数组判断还必须把越界位置当成空格。我在 Board 类的 IsEmpty 方法里已经允许了越界返回 true这就是在给外圈通道开口子。public bool CanConnect(Point start, Point end) { if (Grid[start.Y, start.X] ! Grid[end.Y, end.X]) return false; if (Grid[start.Y, start.X] 0) return false; return CanConnectDirectly(start, end) || CanConnectOneCorner(start, end) || CanConnectTwoCorners(start, end); }顺序是先判断图片编号相同再判断两个位置都非空最后依次尝试零拐、一拐、二拐。搜索深度不要超过两层递归因为三层拐点就已经不符合连连看规则了。实际游戏里如果玩家连不上优先提示“找一对离得近的”就是因为两拐点搜索虽然能覆盖所有可消对但路径太长时玩家视觉上根本认不出这条线是怎么连上的。4. 界面与交互把点击、选中、消除刷到屏幕上4.1 自绘控件还是放置一排 PictureBox为什么我选自绘如果只是做几十个格子的 DemoPictureBox 数组确实写起来更快每张图片一个控件点击事件分开挂就好。但一旦做到 10 行 12 列加上选中高亮、消除动画、重排洗牌控件方案就要频繁修改每个 PictureBox 的 Image 属性界面刷新时所有控件各自重绘闪烁和卡顿会一起出现。自绘方案里整个棋盘是同一个控件的 Paint 输出刷新一次只重画一张画布性能完全够用处理高亮和动画也更统一。我一般用一个继承自 Panel 的自定义控件把 DoubleBuffered 打开然后在 OnPaint 里画所有格子。4.2 用 Paint 事件画棋盘双缓冲先打开public class GameBoardPanel : Panel { private Board _board; private Image[] _images; private Point _selected new Point(-1, -1); public GameBoardPanel() { DoubleBuffered true; // 先开双缓冲不然重绘闪到眼花 ResizeRedraw true; } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); if (_board null || _images null) return; Graphics g e.Graphics; int cell 40; // 每格 40 像素可按窗口大小调整 for (int row 0; row _board.Rows; row) { for (int col 0; col _board.Cols; col) { int type _board.Grid[row, col]; if (type 0) continue; Rectangle rect new Rectangle(col * cell 1, row * cell 1, cell - 2, cell - 2); g.DrawImage(_images[type - 1], rect); if (_selected.X col _selected.Y row) { g.DrawRectangle(Pens.Red, rect); // 选中框用红色肉眼最容易识别 } } } } }代码里最关键的是在构造函数里设好 DoubleBuffered true这句不做消除动画发生时会出现明显的“闪白”。绘制时给每个矩形留了 1 像素间距避免相邻图片边缘重叠产生视觉粘连。如果图片本身带透明背景还要在绘制前把 g.SmoothingMode 设为 HighQuality否则缩略图边缘会有锯齿。4.3 鼠标点击与选中状态第一次点击选中第二次点击尝试消除protected override void OnMouseClick(MouseEventArgs e) { Point cell GetCellFromPixel(e.X, e.Y); if (cell.X 0 || cell.Y 0) return; int type _board.Grid[cell.Y, cell.X]; if (type 0) return; // 点空格没有反应 if (_selected.X 0) // 当前没有选中任何格子 { _selected cell; Invalidate(); return; } // 已经选中了一个格子 if (_selected cell) { _selected new Point(-1, -1); // 再点一次取消选中 Invalidate(); return; } if (_board.CanConnect(_selected, cell)) { _board.Grid[_selected.Y, _selected.X] 0; _board.Grid[cell.Y, cell.X] 0; _selected new Point(-1, -1); Invalidate(); // 这里可以继续触发胜负判断、计分等后续逻辑 } else { // 连不上就把选中移到新位置给玩家重新选择的机会 _selected cell; Invalidate(); } }这段交互逻辑的关键是“第一次点击选中第二次点击尝试消除连不上就把选中移动到新格子”。很多源码把连不上的情况直接清空选中玩家会觉得“我明明点到了第二张牌它却把我的选择吞了”体验很差。这里 Invalidate 不要加参数直接刷新整个面板因为棋盘不大全量重绘的性能开销可以接受但如果你做了消除动画就不要在动画期间允许下一次点击否则状态会错乱。4.4 消除反馈与胜负判定先改数据再刷新界面消除的本质顺序一定是先改 Board.Grid 数组再刷新界面。反过来先刷新界面再改数据界面上会残留已经被消掉的图片块。如果要做“闪现消失”的效果可以先把两张牌置空然后启动一个短暂 Timer在 Timer 的 Tick 事件里做透明度变化最后再 Invalidate。注意 Timer 的事件回调运行在 UI 线程可以直接操作控件状态不会触发跨线程异常。胜负判断最简单的方法是在每次消除后扫描整个棋盘只要还有两个相同图片编号的格子就认为游戏能继续。但“能继续”和“能消完”是两回事棋盘上可能出现没有可连对的死局这时要靠第 6 章讲的洗牌策略来救场。首次进游戏时棋盘数据生成之后直接判断一次有没有可消对如果没有就重新洗牌这个检查可以避免玩家开局的黑色三分钟。5. 源码注释背后的四个坑从编译报错到运行翻车5.1 图片资源加载失败运行时直接抛异常现象程序编译通过一运行就报“System.ArgumentException: 参数无效”断点定位到 Image.FromFile 这一行。原因图片路径写的是相对路径但 WinForms 的工作目录不一定是 exe 所在目录。你从 Visual Studio 按 F5 运行时工作目录是项目根目录直接双击 exe 时工作目录变成 exe 所在目录如果图片放在项目的 Resources 文件夹两边的相对路径对不上。解决不要赌相对路径要么用 Application.StartupPath 拼绝对路径要么把图片加进项目资源文件直接用 Properties.Resources 读取。前者的坑是发布后目录结构变了又要改代码后者是编译时就把图片嵌进程序集换目录也不会失效。提示在源码注释里看到 Image.FromFile 时打起精神凡是能在设计期放进资源文件的内容就不要留到运行期去磁盘上找。5.2 方向枚举顺序影响消除结果同样的牌时通时不通现象两张明显能连的牌有时候能消有时候提示不能消重启程序后结果还会变。原因二拐点搜索中如果中间节点是空格就继续但方向搜索先枚举行还是先枚举列会影响是否选中了一条视觉上“绕远”的路径。如果某条路径被判定为可消但中间会擦过另一张牌的边缘说明你的直连检查用了“闭区间”也就是把起点和终点之外的所有格子都检查了但少检查了拐点所在那条线上紧邻图片块的格子边界差一个字符都会导致错判。解决给两个端点加上“越界即空”的统一函数然后所有直连检查都用同一个 IsEmpty不要在一个方法里用 Grid[row, col] 0在另一个方法里又用越界返回 true。我见过一半检查用越界、一半不用的源码排查这种玄学问题花了整整一个晚上。5.3 跨线程修改控件界面直接崩掉现象点击消除后播放音效或者用 BackgroundWorker 做开局校验运行到一半报“线程间操作无效它正被其他线程控件使用”。原因WinForms 的控件只能在创建它的线程里操作。开了后台线程读棋盘数据没问题但后台线程里一行 GameBoardPanel.Invalidate() 就会炸。很多注释不完整的源码没有处理这个细节。解决后台线程只计算算完用 Invoke 把结果封送回 UI 线程。简单做法是设置 Control.CheckForIllegalCrossThreadCalls false但这是掩耳盗铃真到了上位机项目里你会被这个坑咬第二次。正确姿势是在后台线程里写成这样this.Invoke((MethodInvoker)delegate { _boardPanel.Invalidate(); });5.4 重绘闪烁和残影肉眼可见的掉价现象消除一对牌之后棋盘上出现半透明的残留边缘或者整个面板闪白一下。原因默认 Panel 没有开启双缓冲每次 Invalidate 都会先擦背景再重新绘制两张牌的消失和周围格子的重绘不同步视觉上就是闪。如果格子边缘用 DrawRectangle 绘制且坐标计算有小数误差残影就出现了。解决自定义 Panel 继承类里设置 DoubleBuffered true这是最省事的一行代码。格子坐标全部用整数运算不要用 float 憋出来的自动布局。如果你用的是 WPF 则不存在这个问题但 C# 题库里大量源码是 WinForms这个经验适用性更广。5.5 胜负判断的时机不对最后一对牌消完却弹不出胜利现象消除最后一对牌时计分和胜利界面没有出现或者多弹了一次胜利提示。原因胜负判断放在 OnMouseClick 里消除逻辑之前或者放在消除逻辑之后但没有用新数据再扫描一遍。还有一种写法是消除后立刻判断 Grid 是否全空但遗漏了还有两个非空格子没消完的情况判断条件写成了“非空总数 2”而不是“非空总数为 0”空棋盘判成未结束。解决统一在每次消除成功并且刷新界面后调用 CheckGameOver 方法方法里扫描整个数组统计非空格子数量等于 0 才弹出胜利。注意弹窗要放在 Invalidate 之前或者之后但不要在动画播放中途弹否则动画结束那一刻棋盘又被重绘一次弹窗就被挤掉了。提示这四条坑几乎覆盖了网上源码里八成“跑起来但不好玩”的原因你在别人的注释里看到“这里有个坑”的标注时可以先对应到这几个方向去排查。6. 给源码加值的三个常用技巧从能玩到不难玩6.1 开局可解性检查用广度优先代替人眼洗牌算法只保证图片编号成对不保证存在至少一对可消的牌。最简单可用的做法是开局后扫描所有候选对只要找到一对可连接的立即停止扫描过程放在后台线程避免阻塞界面加载。public bool HasValidMove() { for (int r1 0; r1 Rows; r1) for (int c1 0; c1 Cols; c1) { int type Grid[r1, c1]; if (type 0) continue; for (int r2 r1; r2 Rows; r2) for (int c2 c1 (r2 r1 ? 1 : 0); c2 Cols; c2) { if (Grid[r2, c2] type CanConnect(new Point(c1, r1), new Point(c2, r2))) return true; } } return false; }这个双层循环的时间复杂度是 O(n²)10×12 棋盘是 120 格随手一算循环也才 7140 次每次 CanConnect 最多再跑几十次格子判断单线程足够在 100 毫秒内跑完。真正需要注意的不是性能而是 c2 的起点要在同行时从 c1 1 开始避免把自己和自己配对。6.2 无解时自动洗牌但别把所有牌推倒重来洗牌最容易翻车的点是洗完后仍然无解于是一进洗牌就死循环。更稳的做法是只洗图片类型相同的牌或者把所有剩余牌收集起来重新成对打乱。打乱后如果还是没有可行解就再做一次相邻交换再判断最多尝试 5 次还不行就把棋盘数据保持原样给玩家一个提示“本轮已无解建议重开”。强制洗到有解的计算时间理论上存在但工程上不值得赌。6.3 计时与得分放进游戏循环而不是 Timer Tick很多源码在 Timer 里做剩余时间递减顺便刷新分数界面。这个写法最大的问题是 Timer 的精度只有几十毫秒长时间挂机后界面显示的时间会越走越慢。常见做法是保存一个 startTime在 Timer Tick 里用 DateTime.Now 减去 startTime 计算剩余时间然后显示出来这样定时器只负责触发刷新不负责累积时间值。我会把上面三个技巧称为给连连看源码做“最后三公里”新手改到这里才算真的把这份带详细注释的源代码消化成了自己的东西。我自己每次接手别人的小游戏源码都会先找棋盘数据结构和消除判断入口这两个位置看不明白就说明注释质量不过关还不如从头自己写。希望能帮到你。本文还有配套的精品资源点击获取
返回列表