ARTICLE DETAIL

资讯详情

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

C#连连看源码精讲:WinForms棋盘、寻路算法与事件驱动全解析

C#连连看源码精讲:WinForms棋盘、寻路算法与事件驱动全解析 简介这是一份以C#语言完整实现“连连看”游戏的可运行源代码面向刚接触WinForms或想用实际项目巩固C#语法、数据结构与算法的开发者。它解决从零搭建连连看时最常遇到的界面绘制、图片匹配、消除判定与状态管理问题。代码注释详细覆盖PictureBox棋盘布局、Click事件交互、System.Drawing图像加载与绘制、二维数组维护棋盘状态以及基于DFS/BFS的路径搜索、无解重排和胜利检测等核心模块大量注释和模块化写法也有助于养成规范调试习惯。资源包约941KB以源代码文件和游戏素材为主平台未显示具体文件数量下载后可按目录查看工程结构。当前已有1108人学习下载适合边读注释边调试既能理解连连看背后的图论搜索思路也能掌握C#窗体应用从界面搭建到完整游戏流程的实现路径。1. 带注释的C#连连看源码为什么值得逐行读一遍“C# 连连看”是我拆过的源码包里最适合拿来练手的一类界面不复杂算法有深度恰好卡在“简单”和“无聊”之间。这套源码用Windows Forms实现完整棋盘覆盖图片资源加载、Click事件驱动、连通消除判断、无解检测与洗牌逻辑注释也细到初学者能一路读懂。对刚学完C#语法的人来说它是从“会写控制台程序”跨到“能做GUI项目”的桥梁对要交课程设计或面试作品的人它又是一套可以改造成难度分档、计时闯关的底子。我会按代码阅读顺序拆解界面结构、寻路算法、游戏循环再补上跑这套代码时踩过的坑。2. 从Form到PictureBox界面结构、图像加载与事件绑定2.1 棋盘为什么用二维数组数据结构决定算法写法代码里棋盘状态的核心存储是二维数组常见定义是这样的private int[,] board; // 棋盘逻辑状态0表示空位非0表示图片种类编号 private PictureBox[,] boxes; // 棋盘上的PictureBox控件与board一一对应board存的是逻辑状态boxes存的是界面控件。为什么分开因为判断连通路径时只需要操作board不需要碰控件只有确认消除时才去操作boxes修改界面。逻辑与界面分离是WinForms项目里最容易养成的好习惯。注意数组维度不是直接开成ROWS * COLS而是四周多留一圈空位private const int ROWS 10; // 有效棋盘行数不含外圈 private const int COLS 14; // 有效棋盘列数不含外圈 private const int TILE_SIZE 40; // 每格图片的像素尺寸 private const int TOTAL_KINDS 8; // 图片种类数必须能成对铺满棋盘棋盘实际开成[ROWS 2, COLS 2]下标从 1 开始0 行、0 列、ROWS 1行、COLS 1列全部保留为 0。这样做的直接好处是寻路扫描边界时不用写一堆“是否越界”的判断外圈空位本身就是天然通路。比如从最左边的图片连到棋盘外再绕回最右边这在很多连连看规则里是允许的只有外圈留白了才能实现。2.2 PictureBox控件布局与ImageList资源管理棋盘上的图片用PictureBox展示常见做法是循环创建控件而不是在窗体设计器里一个个拖private void CreateBoardControls() { for (int row 1; row ROWS; row) { for (int col 1; col COLS; col) { PictureBox pb new PictureBox(); pb.Size new Size(TILE_SIZE, TILE_SIZE); pb.Location new Point(col * TILE_SIZE, row * TILE_SIZE); pb.SizeMode PictureBoxSizeMode.StretchImage; pb.Tag new Point(row, col); // 用Tag记住行列坐标 pb.Click Cell_Click; // 统一绑定点击事件 this.Controls.Add(pb); boxes[row, col] pb; } } }这段代码有几个关键点。Tag属性在这里不是存字符串而是直接存了一个Point(row, col)因为Click事件的参数EventArgs里不带控件位置信息必须通过Tag把坐标带回事件处理函数。StretchImage是设置图片缩放模式保证不同尺寸的图片资源都能铺满格子。坐标计算用col * TILE_SIZE而不是累加变量是为了避免控件边框Margin误差导致棋盘格子错位这也是WinForms布局里常见的细节坑。图片本身通过ImageList或Resources资源文件加载。若用ImageList图片编号与board里的数值一一对应private ImageList imgList new ImageList(); private void LoadImages() { imgList.ImageSize new Size(TILE_SIZE, TILE_SIZE); imgList.ColorDepth ColorDepth.Depth32Bit; // 假设资源里放了8张图片命名 pic_00.png ~ pic_07.png for (int i 0; i TOTAL_KINDS; i) { imgList.Images.Add(Image.FromFile($pic_{i:D2}.png)); } }我这里用D2格式化文件名保证pic_00到pic_07按数字顺序加载。这种做法比手动写8行Images.Add更不容易漏图也方便以后把总数改成 12、16 时只动常量。注意imgList.ImageSize必须在Add之前设置否则图片会被当成原始尺寸塞进去显示时就会出现格子错位或图片拉伸变形。2.3 事件驱动下的选中状态从第一次点击到消除确认连线消除是一个典型的状态机第一次点击进入“选中”状态第二次点击尝试消除或取消选中。状态变量只需要一个private PictureBox selectedBox null; // 当前选中的第一个PictureBox private Point selectedPos; // 选中位置的行列坐标对应的事件处理函数逻辑是这样的private void Cell_Click(object sender, EventArgs e) { PictureBox pb sender as PictureBox; if (pb null || pb.Image null) return; Point pos (Point)pb.Tag; if (selectedBox null) { // 第一次点击记录选中并给图片加个高亮边框 selectedBox pb; selectedPos pos; pb.BorderStyle BorderStyle.FixedSingle; return; } if (selectedBox pb) { // 点同一个格子取消选中 pb.BorderStyle BorderStyle.None; selectedBox null; return; } // 第二次点击尝试消除 if (board[selectedPos.X, selectedPos.Y] board[pos.X, pos.Y] CanConnect(selectedPos, pos)) { ClearPair(selectedBox, pb, selectedPos, pos); } else { // 消除失败恢复第一个格子的边框然后把当前点击的格子设为选中 selectedBox.BorderStyle BorderStyle.None; selectedBox pb; selectedPos pos; pb.BorderStyle BorderStyle.FixedSingle; } }这段代码解决了三个问题防止空图片被点击、处理点击同一个格子的取消逻辑、失败后自动把第二次点击的格子作为新的选中项。连玩过几把的人都知道连连看最烦人的交互是选错了还要再点一下取消这里失败后直接切换选中状态手感和主流版本一致。selectedBox pb这句不能省掉否则连续点同一个格子会触发消除逻辑而CanConnect里已经排除了同一个点结果就是白白翻转边框。3. 核心寻路算法直线、单拐角与双拐角的消除判定3.1 连通规则拆解把口头规则变成可执行条件连线消除的规则业界基本统一两点之间最多通过两条线段连接每条线段只能经过空位不能穿过非空图片。拆开来看就是三种情况直线连接两点在同一行或同一列中间没有其他图片阻挡。单拐角连接两条直线在一个拐点相交拐点处必须是空位。双拐角连接三条直线通过两个拐点相连两个拐点都必须是空位。很多人在写这个算法时直接用DFS或BFS搜索路径理论上没问题但棋盘只有 10×14逐层枚举拐点反而更直观、更容易调试。下面这段CanConnect是入口函数private bool CanConnect(Point a, Point b) { // 两个点必须是不同的非空格 if (a b) return false; if (board[a.X, a.Y] 0 || board[b.X, b.Y] 0) return false; // 两个点的图片种类必须相同 if (board[a.X, a.Y] ! board[b.X, b.Y]) return false; // 路径1直线 if (IsStraightClear(a, b)) return true; // 路径2单拐角 if (CanConnectOneCorner(a, b)) return true; // 路径3双拐角 if (CanConnectTwoCorners(a, b)) return true; return false; }Point的X是行下标Y是列下标写的时候前后保持一致否则调试时很容易把行列弄反。board数组开成[ROWS2, COLS2]后所有寻路扫描可以直接在 0 到ROWS1、0 到COLS1范围内进行不需要额外判断越界。3.2 拐点扫描法用循环代替递归的实现直线判断最简单但要注意“不包含起点和终点”private bool IsStraightClear(Point a, Point b) { // 同一行检查左右之间 if (a.X b.X) { int minY Math.Min(a.Y, b.Y); int maxY Math.Max(a.Y, b.Y); for (int y minY 1; y maxY; y) if (board[a.X, y] ! 0) return false; return true; } // 同一列检查上下之间 if (a.Y b.Y) { int minX Math.Min(a.X, b.X); int maxX Math.Max(a.X, b.X); for (int x minX 1; x maxX; x) if (board[x, a.Y] ! 0) return false; return true; } return false; // 不在同一行也不在同一列直线直接不成立 }起点和终点上必然有图片如果循环包含了自己就会误判。用min 1和max作为边界刚好跳过两个端点的格子。单拐角需要枚举两个可能的拐点private bool CanConnectOneCorner(Point a, Point b) { // 拐点1与a同行与b同列 Point c1 new Point(a.X, b.Y); if (board[c1.X, c1.Y] 0 IsStraightClear(a, c1) IsStraightClear(c1, b)) return true; // 拐点2与b同行与a同列 Point c2 new Point(b.X, a.Y); if (board[c2.X, c2.Y] 0 IsStraightClear(a, c2) IsStraightClear(c2, b)) return true; return false; }这里有个容易被忽略的点当a和b在同一行或同一列时c1可能与a重合或与b重合。比如a (2, 3)、b (2, 5)那么c1 (2, 5)就是b本身board[c1.X, c1.Y]不为 0自然会被过滤掉。所以单拐角函数不会误判成直线的情况两种路径判定可以并行存在。双拐角的代码稍微长一点但思路仍然是枚举private bool CanConnectTwoCorners(Point a, Point b) { // 情况1先水平再垂直——中间扫过某一空行 for (int row 0; row ROWS 1; row) { Point p1 new Point(row, a.Y); // 拐点1在a所在列 Point p2 new Point(row, b.Y); // 拐点2在b所在列 // 若row恰好等于a.X或b.Xp1或p2会落在有图片的格子上这里的非0判断会拦截掉 if (board[row, a.Y] ! 0) continue; if (board[row, b.Y] ! 0) continue; if (IsStraightClear(a, p1) IsStraightClear(p1, p2) IsStraightClear(p2, b)) return true; } // 情况2先垂直再水平——中间扫过某一空列 for (int col 0; col COLS 1; col) { Point p1 new Point(a.X, col); // 拐点1在a所在行 Point p2 new Point(b.X, col); // 拐点2在b所在行 if (board[a.X, col] ! 0) continue; if (board[b.X, col] ! 0) continue; if (IsStraightClear(a, p1) IsStraightClear(p1, p2) IsStraightClear(p2, b)) return true; } return false; }注意循环范围从 0 到ROWS 1而不是从 1 到ROWS原因就是棋盘外圈保留的空位可以参与连通。外圈board[0, a.Y] 0恒成立所以当两条图片都靠近边缘时可以先向外绕再连到另一侧。若把外圈去掉边缘图片的可消除路径会少很多游戏难度会明显变大这也解释了为什么棋盘数组必须比可见区域多两行两列。3.3 算法复杂度与棋盘尺寸的取舍这套算法的时间开销主要在最坏情况每次点击都要跑一遍CanConnect里面包含双拐角的两轮循环每轮循环检查ROWS 2或COLS 2个候选拐点每个拐点又要验证三段直线是否畅通。整体复杂度大约O((ROWS COLS) * (ROWS COLS))在 10×14 的棋盘上一次完整判定最多几千次数组访问对WinForms程序来说完全可忽略。真正要关注的是无解检测HasValidMove。它需要遍历所有剩余图片对每一对都调一次CanConnect复杂度直接翻到O(N^2 * 路径检查)N 是棋盘图片总数。在 140 个格子的棋盘上最多 9690 对每对几千次操作总耗时可能在几十毫秒级别作为按钮触发没问题但不要放在Paint事件或鼠标Move事件里连续调用否则界面会肉眼可见地卡顿。这段代码我会在下一章详细展开。4. 完整游戏循环初始化棋盘、消除响应、无解检测与洗牌4.1 初始化棋盘成对图片的随机分布初始化逻辑的目标是让每种图片恰好出现偶数次然后随机打乱填入有效区域private void InitBoard() { board new int[ROWS 2, COLS 2]; Listint items new Listint(); int total ROWS * COLS; for (int i 0; i total / TOTAL_KINDS; i) { for (int kind 1; kind TOTAL_KINDS; kind) { items.Add(kind); items.Add(kind); } } // Fisher-Yates洗牌避免Random重复带来的分布不均 Random rnd new Random(); for (int i items.Count - 1; i 0; i--) { int j rnd.Next(i 1); int tmp items[i]; items[i] items[j]; items[j] tmp; } int idx 0; for (int row 1; row ROWS; row) for (int col 1; col COLS; col) board[row, col] items[idx]; RefreshBoard(); }这里total / TOTAL_KINDS的意思是每种图片出现两次外面再套一层循环保证总数铺满。10×14 共 140 格8 种图片就要 70 对。如果total不能被TOTAL_KINDS整除就会出现最后几个格子填充不上的情况所以初始化前最好断言total % (TOTAL_KINDS * 2) 0。RefreshBoard是刷新界面图片的公共方法它把所有格子按board值设置PictureBox图像或清空消除动画也可以挂在这里做。4.2 消除响应从判断到界面刷新的完整链路确认消除后要同时更新逻辑数组和界面控件缺一个就会出现“图片消失了但逻辑上还在”或“界面还在但已经判空”的脏状态private void ClearPair(PictureBox pb1, PictureBox pb2, Point p1, Point p2) { // 更新逻辑状态 board[p1.X, p1.Y] 0; board[p2.X, p2.Y] 0; // 更新界面 pb1.Image null; pb2.Image null; pb1.BorderStyle BorderStyle.None; pb2.BorderStyle BorderStyle.None; if (selectedBox ! null) { selectedBox.BorderStyle BorderStyle.None; selectedBox null; } // 消除后检查剩余数量与无解情况 remainingPairs--; if (remainingPairs 0) { MessageBox.Show(恭喜过关); } else if (!HasValidMove()) { MessageBox.Show(没有可消除的配对自动洗牌); ShuffleRemaining(); RefreshBoard(); } }remainingPairs在初始化时设为total / 2每消除一对减一。判断胜利只需要看它是否为 0不需要再遍历数组数剩余图片。HasValidMove返回 false 时自动洗牌这里有个选择有些版本会直接洗有些版本会先弹窗询问玩家“是否提示/洗牌”代码里用了自动洗牌对应的是街机版的“无解自动重排”策略。如果你想做成有挑战性的版本可以改成弹窗确认后再洗改动量很小。4.3 无解检测与洗牌游戏可持续玩的关键无解检测的实现就是穷举所有未消除的图片对private bool HasValidMove() { for (int r1 1; r1 ROWS; r1) for (int c1 1; c1 COLS; c1) { if (board[r1, c1] 0) continue; for (int r2 r1; r2 ROWS; r2) for (int c2 1; c2 COLS; c2) { if (r1 r2 c1 c2) continue; if (board[r1, c1] ! board[r2, c2]) continue; if (CanConnect(new Point(r1, c1), new Point(r2, c2))) return true; } } return false; }注意内层循环的起点r2从r1开始c2从 1 开始这样做的目的是避免重复检查同一对图片。外层的r1 * c1已经定义了“第一个点”内层只需要枚举“第一个点之后的所有点”就行。虽然重复检查也不会出错但会让无解检测耗时翻一倍在棋盘中后期图片稀疏时没必要浪费这个时间。洗牌函数只打乱剩余图片不碰已经消除的空位private void ShuffleRemaining() { Listint remaining new Listint(); for (int row 1; row ROWS; row) for (int col 1; col COLS; col) if (board[row, col] ! 0) remaining.Add(board[row, col]); Random rnd new Random(); for (int i remaining.Count - 1; i 0; i--) { int j rnd.Next(i 1); int tmp remaining[i]; remaining[i] remaining[j]; remaining[j] tmp; } int idx 0; for (int row 1; row ROWS; row) for (int col 1; col COLS; col) if (board[row, col] ! 0) board[row, col] remaining[idx]; }洗牌后必须调用RefreshBoard()重新设置所有PictureBox的Image否则棋盘界面还停留在洗牌前的样子玩家点击后发现逻辑状态和看到的不一样这是最容易让人以为是程序出bug的细节。另外洗牌后最好再跑一次HasValidMove()因为随机洗牌可能会再次洗出无解局面严谨的做法是循环洗牌直到有解为止但要注意加一个最大尝试次数防止极端情况下死循环。5. 常见问题排查事件绑定、边界判断与界面刷新5.1 点击图片没反应事件没绑定或Tag被覆盖现象棋盘显示正常但鼠标点任意格子都没有选中边框点击完全无效。原因最常见的两种情况。一是Cell_Click只绑定到了部分PictureBox比如在CreateBoardControls里创建控件后没有加pb.Click Cell_Click;二是Tag被后续代码覆盖了例如有人把Tag用来存储图片种类编号导致(Point)pb.Tag运行时抛InvalidCastException异常又被全局吞掉表现为“没反应”。解决事件绑定只写一处不要分散到多个方法里。Tag的使用要统一建议开一个字典DictionaryPictureBox, Point存坐标Tag留给其他用途。检查事件是否绑定的最快方法是在Cell_Click第一行加断点如果根本没进入断点就是绑定或控件层级的问题。5.2 报“索引超出范围”边界判断漏掉外圈现象游戏玩到中后期随机点两三个格子之后程序突然抛IndexOutOfRangeException堆栈指向IsStraightClear或CanConnectTwoCorners。原因棋盘开的是[ROWS 2, COLS 2]但有些人在判断时把循环范围写成了1到ROWS却让拐点坐标参与寻路时访问row 0或row ROWS 1。反过来也一样数组没开外圈但寻路逻辑里默认有外圈。两种写法不匹配就会越界。解决数组声明和寻路循环必须共用同一套约定。我的习惯是把数组维度抽成常量private const int BOARD_ROWS ROWS 2; // 含外圈的总行数 private const int BOARD_COLS COLS 2; // 含外圈的总列数然后所有for循环都写成for (int row 0; row BOARD_ROWS; row)。凡是涉及图片填充的循环再从 1 到ROWS清晰区分“逻辑棋盘”和“存储数组”。5.3 消除后棋盘出现“鬼影”逻辑数组和界面不同步现象两张图片被消除后图片不见了但再次点击那个空位却发现还能选中某张看不见的图片或者另一张图片消失了但旁边残留半截边框。原因ClearPair只把board[p1.X, p1.Y]置 0没有同步把 PictureBox 的Image设为 null或反过来只清了界面数组没改。常见于后来加“消除动画”时把pb.Image null挪到了动画回调里动画还没跑完玩家又点了其他格子。解决清除操作必须写成一个独立方法同时更新board、Image、BorderStyle和selectedBox。不要在其他事件处理函数里直接改这两块状态。界面刷新统一走RefreshBoard()它遍历整个board数组重设图片虽然比单点刷新慢一点但能保证逻辑和界面永远一致private void RefreshBoard() { for (int row 1; row ROWS; row) for (int col 1; col COLS; col) { PictureBox pb boxes[row, col]; if (board[row, col] 0) pb.Image null; else pb.Image imgList.Images[board[row, col] - 1]; } }5.4 洗牌后画面没变但逻辑已经变了忘记刷新界面现象弹出“自动洗牌”提示后棋盘看起来还是原样但点击原来有图片的位置却发现能消除或者消除失败。原因ShuffleRemaining()只修改了board数组没有调用RefreshBoard()。逻辑数组已经换了图片界面上却还是旧图于是出现点击结果和视觉完全不匹配。这个问题在中后期特别容易出现因为此时空位多玩家会频繁触发无解检测。解决洗牌函数末尾强制调用RefreshBoard()。如果你还想提示玩家哪些位置被移动了可以在洗牌后加一个短暂的边框闪烁但这属于进阶效果基础版先保证刷新即可。从那以后我每次写这类逻辑棋盘项目都会在改完board后立刻检查是否同步调用了界面刷新已经养成习惯了。6. 进阶改造难度分档、计时挑战与消除动画基础版能跑通后下一步就是把它改成真正能拿出手的作品。三个比较实用的改造方向是难度分档、计时器和消除动画难度都不高但能显著提升完整度。难度分档可以直接通过调整棋盘大小实现把ROWS、COLS、TOTAL_KINDS做成运行时可变参数而不是常量private void SetDifficulty(int rows, int cols, int kinds) { ROWS rows; COLS cols; TOTAL_KINDS kinds; // 先清空旧控件再重建 foreach (Control c in this.Controls) if (c is PictureBox) c.Dispose(); boxes new PictureBox[ROWS 2, COLS 2]; CreateBoardControls(); InitBoard(); }注意Control.Dispose()必须调用否则旧控件还挂在窗体的控件树里界面会出现残影。难度档位建议是“简单 8×10 用 6 种图片”“普通 10×14 用 8 种”“困难 12×18 用 10 种”图片种类越少消除概率越高玩起来越轻松。计时器用WinForms自带的System.Windows.Forms.Timer就够别用Thread.Sleep做倒计时那会把UI线程卡死。Timer的Interval设为 1000每秒更新一次Label显示已用时间加了难度分档后还可以顺便做成“限时闯关”倒计时归零时结算当前得分。消除动画最省事的做法是用Timer做两步淡出而不是引入动画库。思路是消除确定后把两个 PictureBox 的Visible设为 false 之前先用一个短周期 Timer 改变Opacity或直接交替Visibleprivate void PlayClearAnimation(PictureBox pb1, PictureBox pb2) { Timer anim new Timer(); anim.Interval 80; int count 0; anim.Tick (s, e) { count; if (count % 2 0) pb1.Visible false; else pb2.Visible false; if (count 4) { anim.Stop(); anim.Dispose(); pb1.Visible false; pb2.Visible false; } }; anim.Start(); }这里有点取巧不是真正的平滑淡出但胜在代码短、不依赖第三方组件而且视觉上有“闪两下再消失”的反馈。如果想要真正的透明度渐变需要把PictureBox替换成自定义控件并重写OnPaint或者直接把图片绘制到窗体画布上。最后如果你要拿这套源码去交作业或面试建议把注释再强化一遍在CanConnectTwoCorners的循环前用多行注释画出拐点路径示意图在ShuffleRemaining前注释说明“仅重排非空格避免移动空位”。这种“文档级doxygen注释”风格在源码资源里特别加分读者一眼能看到你的设计意图。我从那以后每次拿到新项目都会给自己定一条规矩凡是写过一遍以上还要回头查的代码段必须补上带示例的多行注释不给自己留第二次翻车的机会。希望帮到你。本文还有配套的精品资源点击获取
返回列表