C#连连看游戏开发实战:二维数组路径判定与GDI+双缓冲完整解析
2026/9/23 13:42:59 网站建设 项目流程

简介:一套以 C# 编写、带详细注释的连连看游戏源码,适合正在系统学习 C# 或对 Windows Forms 桌面游戏开发感兴趣的读者。项目完整呈现了从棋盘初始化、图片随机布局到点击消除的整个玩法流程,重点覆盖 PictureBox、Button、Label 等控件的界面搭建,Click 事件处理机制,System.Drawing 图像加载与重绘,以及用二维数组或 List 存储棋盘状态的数据结构设计。核心消除判定实现了基于深搜/广搜的连通路径搜索,并融入了洗牌重排、胜负判断等实用游戏逻辑,注释对每段关键代码的作用都做了说明。整个压缩包约 941KB,内部为完整 C# 工程源码及配套图片资源,代码结构清晰、注释密度高,便于按模块逐段研读。资源已有 1108 人学习下载,非常适合作 C# 学习者的首个游戏项目,也可作为高校编程课程中事件驱动与基础算法教学的分析案例。

1. 一份带注释的 C# 连连看代码,到底能让你练到什么

想找一份能照着改、改完还敢拿去答辩的 C# 连连看游戏源代码,其实不单是为了“做游戏”。连连看的体量刚好卡在入门和进阶之间:它有二维数组做棋盘、有 GDI+ 绘图、有鼠标事件和状态切换,还有最核心的“最多两次拐弯”路径判定。这个组合很值钱——你练的是 C# 语法、数组与集合的区别、事件响应,以及一个完整的算法从思路到落地的过程。这篇文章把我自己实现这样一份带详细注释的 C# 连连看代码时,那些必须想清楚的设计选择、函数实现和踩坑记录拆开讲,争取让你拿到手能改、能讲、能继续加功能。

2. 规则先行:棋盘数据结构、图案映射与“最多两次拐弯”判定

连连看的代码看起来在写界面交互,实际上第一步要解决的是数据模型。棋盘长什么样、棋子在内存里怎么存、两个格子能不能消掉——这三件事想清楚了,后面所有功能都是在给这个核心搭壳子。

2.1 用二维数组还是集合:棋盘状态管理的选型

很多人学 C# 时会纠结数组和集合分别是怎么定义的、使用上有什么区别,连连看的棋盘就是一个特别典型的场景。棋盘是一个固定二维空间,每个格子要么有棋子,要么是空位。这种结构用int[,]二维数组最直接,行和列天然对应棋盘坐标,取值和赋值都是 O(1)。如果用List<int>或者Dictionary来存,每次判断某个坐标是什么棋子都要先算索引或查键,既绕又容易在边界上出错。

棋盘数组的设计也有一点讲究:不能只开一个Rows × Cols的数组,而是要在四周各扩一圈。因为连连看规则允许链接线从棋盘外部绕过,如果数组里没有这圈外扩的“空位”,绕外圈的路径就无从判断。常见的做法是:

// 有效棋盘行列数 private const int Rows = 8; private const int Cols = 10; // 外扩一圈,让连线可以绕到棋盘外再拐回来 private const int BorderSize = 1; // 图案类型数 private const int KindCount = 8; // 棋盘数组:0 = 空格,1~KindCount = 不同图案 private int[,] _board; private void InitBoard() { // 实际数组是 (Rows + 2) x (Cols + 2),最外圈全是 0 _board = new int[Rows + BorderSize * 2, Cols + BorderSize * 2]; // 用集合类型生成棋子:每种图案成对放入 List List<int> tiles = new List<int>(Rows * Cols); for (int i = 0; i < Rows * Cols / 2; i++) { tiles.Add(i % KindCount + 1); tiles.Add(i % KindCount + 1); } Shuffle(tiles); // 再把集合里的棋子灌回二维数组 for (int r = 0; r < Rows; r++) { for (int c = 0; c < Cols; c++) { _board[r + BorderSize, c + BorderSize] = tiles[r * Cols + c]; } } }

这段代码把数组和集合的配合用得很直白:tiles是动态集合,因为我们需要往里面反复添加棋子,然后用Shuffle打乱;_board是定长二维数组,因为棋盘规格固定,后续算法要高频读取坐标值。两种数据结构在不同阶段各干各的活,不是谁替代谁的关系。

注意BorderSize = 1这行注释:外扩出来的格子存 0,它们不能参与消除,但可以充当绕行路径的一部分。之后所有算法函数都基于这个“带边框”的完整数组写,索引从 0 到Rows+1Cols+1

2.2 连线规则拆解:直连、单拐点、双拐点的三层判断

连连看的连线规则一句话就能说完:两个同样图案的格子之间,用一条最多拐两次弯的折线连通,折线不能穿过其他未消除的棋子。注意“不能穿过”指的是所有中间点都必须是空格,包括你现在棋盘外侧扩展出的那一圈 0。

这个规则拆成三个层次,从简单到复杂:

第一层,直连。两个格子在同一行或同一列,中间没有其他棋子。这是最简单的情况,但也最容易写错——很多人判断时忘了排除起点和终点本身。

第二层,单拐点。两个格子不在同一直线上,但可以通过一个转折点连接,形成一条 L 形路径。这要求我们枚举两个可能的拐点坐标,分别检查“起点评到拐点”和“拐点到终点”两段是否都为空。

第三层,双拐点。两个格子无法用直连或单拐点连接,但可以通过两个转折点连成一条 Z 形路径。常见做法是枚举所有可能作为中间通道的行和列,把三段路径全部检查一遍。

有了这个分层思路,代码就可以按部就班地写。先写一个最基础的直线检查函数,它会被上层反复调用:

// 检查 from 到 to 之间(不含两端)是否全部为空格 private bool IsLineEmpty(Point from, Point to) { if (from == to) return true; // 同一列:纵向遍历 if (from.X == to.X) { int step = Math.Sign(to.Y - from.Y); for (int y = from.Y + step; y != to.Y; y += step) { if (_board[y, from.X] != 0) return false; } return true; } // 同一行:横向遍历 if (from.Y == to.Y) { int step = Math.Sign(to.X - from.X); for (int x = from.X + step; x != to.X; x += step) { if (_board[from.Y, x] != 0) return false; } return true; } return false; // 不在同行同列,直线检查失败 }

这里的Point用的是System.Drawing.Point,X 表示列,Y 表示行。Math.Sign用来判断遍历方向,确保 from 和 to 的先后顺序不影响结果。循环起始是from + step,终止条件是y != to.Y,这样两个端点本身都不参与判断。注意from == to的情况返回 true,因为单拐点检查时 corner 可能和 from 重合。

2.3 完整的 CanConnect 函数:注释版实现与参数说明

三层判定函数合在一起,就是整个游戏最核心的CanConnect。这是我推荐你重点阅读并默写的函数:

// 判断两个格子是否可以通过 <=2 次拐弯的路径连通 private bool CanConnect(Point p1, Point p2) { // 1. 同一个格子无效 if (p1 == p2) return false; // 2. 两个格子必须有棋子且图案相同 int v1 = _board[p1.Y, p1.X]; int v2 = _board[p2.Y, p2.X]; if (v1 == 0 || v2 == 0) return false; if (v1 != v2) return false; // 3. 直连 if (IsLineEmpty(p1, p2)) return true; // 4. 单拐点:枚举两个可能的拐角 Point corner1 = new Point(p1.X, p2.Y); if (IsLineEmpty(p1, corner1) && IsLineEmpty(corner1, p2)) return true; Point corner2 = new Point(p2.X, p1.Y); if (IsLineEmpty(p1, corner2) && IsLineEmpty(corner2, p2)) return true; // 5. 双拐点:枚举每一行作为中间通道 // 条件:p1 竖直向上/下到该行,p2 竖直向上/下到该行, // 并且两个中间点之间水平方向全部为空 for (int r = 0; r < _board.GetLength(0); r++) { Point mid1 = new Point(p1.X, r); Point mid2 = new Point(p2.X, r); if (IsLineEmpty(p1, mid1) && IsLineEmpty(p2, mid2) && IsLineEmpty(mid1, mid2)) { return true; } } // 6. 双拐点:枚举每一列作为中间通道(同理) for (int c = 0; c < _board.GetLength(1); c++) { Point mid1 = new Point(c, p1.Y); Point mid2 = new Point(c, p2.Y); if (IsLineEmpty(p1, mid1) && IsLineEmpty(p2, mid2) && IsLineEmpty(mid1, mid2)) { return true; } } return false; }

双拐点的两个循环,遍历范围是_board.GetLength(0)_board.GetLength(1),也就是包含了外扩出来的那圈 0。这样如果一条 Z 形路径需要先绕出棋盘、再从旁边绕回来,也会被正确识别。这个细节特别容易忽略,很多人习惯只遍历RowsCols,结果绕外圈的路径全部判失败。

复杂度方面,棋盘只有 8×10 或者 10×14,双拐点扫描每次最多几十行几十列,完全不需要优化。如果将来做很大的棋盘,才需要考虑维护可通行行列索引来加速。

3. WinForms 交互层:GDI+ 自绘棋盘与鼠标点击状态机

算法再漂亮,没有界面就是黑匣子。连连看的界面层核心职责有两个:把棋盘画出来,把鼠标点击换算成棋盘坐标再交给逻辑层。这一章我基于 WinForms 讲,所用的 GDI+ 知识可以平移延伸到 WPF 的自绘控件。

3.1 自绘棋盘 vs 控件数组:谁更适合连连看

很多初学者拿到题目第一反应是摆一排PictureBox,每个按钮放一个棋子图片。这在一开始写起来确实快,但后续会有两个问题:一是 80 个甚至更多控件同时挂在窗体上,渲染性能明显下降;二是你需要自己维护每个控件的坐标、图片、选中状态,代码量和隐藏的 bug 一点不比自绘少。

我一般会把整个棋盘放在一个自定义控件里,重写它的Paint事件,用 GDI+ 的Graphics.DrawImageDrawRectangle把整个画面画出来。棋盘是静态背景,棋子是图案,选中态是高亮边框,三者分层绘制,改动哪一层都只影响那一层。这样界面层和逻辑层的边界也非常清楚:鼠标事件做坐标换算,然后调用CanConnect,得到结果后再触发Invalidate()让界面重绘。

// 自定义棋盘控件:所有绘制集中在 OnPaint public class BoardControl : UserControl { private int _cellSize = 44; // 每格像素数 private int _originX = 20; // 棋盘左上角的像素偏移 private int _originY = 20; protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g = e.Graphics; // 绘制背景网格 for (int r = 0; r < Rows; r++) { for (int c = 0; c < Cols; c++) { int x = _originX + c * _cellSize; int y = _originY + r * _cellSize; g.DrawRectangle(Pens.LightGray, x, y, _cellSize, _cellSize); } } } }

OnPaint的参数PaintEventArgs里装着当前控件的画布,所有绘制调用都从e.Graphics发起。注意每次触发Invalidate()后 Windows 会重新调用OnPaint,所以绘制逻辑要尽量轻,图片索引查找之类的工作不要放在这里。

3.2 双缓冲绘制:消除闪烁的固定写法

自绘控件最容易遇到的问题是闪烁。快速重绘时画面会一帧一帧地闪,严重的还会看到绘制过程的半成品。这个问题在 WinForms 里的标准解法是双缓冲:先把所有内容画到一张内存位图上,再一次复制到屏幕。WinForms 的控件基类内置了这个能力,只需要设置一个属性:

public BoardControl() { // 开启双缓冲,消除重绘闪烁 DoubleBuffered = true; // 或者用 SetStyle 显式声明绘制方式 SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true); }

两种写法等价。DoubleBufferedControl类受保护属性,在构造函数里可以直接赋值;SetStyle则更底层,明确告诉 Windows 用缓冲绘制。如果你把棋盘作为一个普通 Form 直接画,记得设置this.DoubleBuffered = true;,否则拖动窗体边框时会看到明显的白块闪动。

3.3 像素坐标到棋盘坐标换算:点击处理的入口

鼠标点击抬起时,MouseClick事件给出的MouseEventArgs里是相对于控件的像素坐标。要把像素坐标换算成棋盘的逻辑坐标,只需要做一次反向计算:

private Point _selectedCell; // 当前选中的格子,Point.Empty 表示未选中 protected override void OnMouseClick(MouseEventArgs e) { base.OnMouseClick(e); // 1. 像素坐标换算成棋盘逻辑坐标(不含外扩边框) int col = (e.X - _originX) / _cellSize; int row = (e.Y - _originY) / _cellSize; // 2. 越界或者点在棋盘外,忽略 if (row < 0 || row >= Rows || col < 0 || col >= Cols) return; // 3. 转换成包含外扩边框的棋盘数组坐标 Point logicalCell = new Point(col + BorderSize, row + BorderSize); if (_selectedCell == Point.Empty) { // 没有选中任何棋子:如果点击的是棋子,则选中它 if (_board[logicalCell.Y, logicalCell.X] != 0) { _selectedCell = logicalCell; Invalidate(); // 触发重绘,画选中高亮 } } else { // 已经选中了一个棋子 if (_selectedCell == logicalCell) { // 再次点击同一个棋子:取消选中 _selectedCell = Point.Empty; } else if (_board[logicalCell.Y, logicalCell.X] == 0) { // 点击空白格子:换选到空白格子上(实际上不会选中空白) // 更合理的做法是保留原选中,这里直接切空即可 _selectedCell = Point.Empty; } else if (CanConnect(_selectedCell, logicalCell)) { // 可以连接:消除 RemovePair(_selectedCell, logicalCell); _selectedCell = Point.Empty; } else { // 不能连接:换选新的棋子 _selectedCell = logicalCell; } Invalidate(); } }

这段代码里最需要注意的是像素坐标向数组坐标的转换。e.X减掉_originX再整除_cellSize,得到的是 0 到Cols-1的逻辑列号;加回BorderSize才是_board的实际列索引。很多人把_board的下标和画布逻辑坐标混为一谈,导致点击一个格子却选中了它右下角的格子——这种错位在调试时非常隐蔽。

3.4 棋子图片的加载与释放:内存管理的基本功

棋子的图案如果从外部图片文件加载,需要注意加载时机和释放时机。最常见的错误是在OnPaint里每次绘制都调用一次Image.FromFile,这样每帧重绘都会向操作系统申请文件句柄,游戏跑几十秒内存就涨到几百兆。

正确做法是在游戏启动时把所有图片加载到一个字典里,后续绘制只做索引查找:

private Dictionary<int, Image> _tileImages; private void LoadTiles() { _tileImages = new Dictionary<int, Image>(); for (int i = 1; i <= KindCount; i++) { string filePath = Path.Combine(Application.StartupPath, "tiles", $"{i}.png"); if (File.Exists(filePath)) { _tileImages[i] = Image.FromFile(filePath); } } } private void DrawTiles(Graphics g) { for (int r = 0; r < Rows; r++) { for (int c = 0; c < Cols; c++) { int value = _board[r + BorderSize, c + BorderSize]; if (value == 0 || !_tileImages.ContainsKey(value)) continue; // 按格子大小缩放绘制 int x = _originX + c * _cellSize; int y = _originY + r * _cellSize; g.DrawImage(_tileImages[value], x, y, _cellSize, _cellSize); } } }

注意LoadTiles放在构造函数或Form_Load里,只执行一次。窗体关闭时统一释放_tileImages里的所有Image对象。这套代码还隐含了一个约定:图片文件名必须和图案编号一一对应,1.png代表_board中值为 1 的棋子,不满足这个约定的素材文件会自动跳过——这样至少不会让游戏崩溃,最多是显示空白格子。

4. 把游戏流程串起来:消除响应、胜负判断、死局重排

棋盘数据结构和连通判定是两条腿,但游戏真正跑起来还需要一个“大脑”把点击、消除、胜利、重开这些环节串起来。这一章讲的是从点击到消除的完整调用链,以及两个最容易让新手困惑的问题:什么时候算赢了、什么时候该洗牌。

4.1 从 MouseClick 到棋子消失的调用链

CanConnect判定成功之后,下一步不只是把两个格子置 0,还需要做三件事:更新剩余棋子数、检查是否胜利、检查是否出现死局。我建议把这几步封装到一个RemovePair方法里,让点击事件处理函数保持精简:

private void RemovePair(Point p1, Point p2) { // 1. 把两个格子清空 _board[p1.Y, p1.X] = 0; _board[p2.Y, p2.X] = 0; // 2. 更新剩余棋子计数 _remainingCount -= 2; // 3. 胜利判断:所有棋子被消除 if (_remainingCount == 0) { MessageBox.Show("你赢了!"); ResetGame(); return; } // 4. 死局判断:如果还有棋子但找不到任何可消除的对,自动洗牌 if (IsNoValidMoves()) { Reshuffle(); } // 5. 触发重绘,把消除结果显示出来 Invalidate(); }

_remainingCountInitBoard()里初始化为Rows * Cols。每次消除两个就减 2,减到 0 说明棋盘清空。这里不要写一个遍历数组数非零格子的方法替代计数器——8×10 的棋盘遍历一次没性能问题,但计数器一眼就能看出逻辑,代码更清爽。

4.2 胜负判定与死局检测:为什么不能只看棋盘是否填满

胜负判定的关键是计数器减到 0,这一点不用多说。真正容易被忽略的是死局检测。游戏进行到中后期,棋盘上可能还剩二三十个棋子,但任何一对都无法连通。这种局面玩家无论怎么点都消不掉,必须由程序自动触发洗牌。

实现死局检测最简单的办法是全盘扫描:枚举所有棋子对,只要有一对CanConnect返回 true,就说明还有解:

// 返回 true 表示当前棋盘无任何可消除的对 private bool IsNoValidMoves() { for (int r1 = 0; r1 < Rows; r1++) { for (int c1 = 0; c1 < Cols; c1++) { Point p1 = new Point(c1 + BorderSize, r1 + BorderSize); if (_board[p1.Y, p1.X] == 0) continue; for (int r2 = r1; r2 < Rows; r2++) { for (int c2 = (r2 == r1 ? c1 + 1 : 0); c2 < Cols; c2++) { Point p2 = new Point(c2 + BorderSize, r2 + BorderSize); if (_board[p2.Y, p2.X] == 0) continue; if (_board[p1.Y, p1.X] != _board[p2.Y, p2.X]) continue; if (CanConnect(p1, p2)) { return false; // 找到一对可消除的,说明没死 } } } } } return true; }

这段代码的循环顺序保证每对棋子只被检查一次,避免重复计算。r2r1开始,c2在同行时从c1 + 1开始,这样不会出现对称重复的比较。当IsNoValidMoves返回 true,调用方执行洗牌逻辑。

4.3 重排的洗牌策略:直接随机打乱反而翻车

死局出现后的洗牌,很多直接复制代码的初学者会选择把整个数组重新随机填充一遍。这种做法会立刻破坏棋子的成对性——每个图案的数量必须保持偶数,否则最后一定会剩下一对无法配对的棋子,游戏永远无法胜利。

正确做法是把棋盘上还剩下的棋子全部取出来,打乱顺序再填回去。因为剩余的棋子本身就是成对的,打乱后仍然是成对的:

private void Reshuffle() { // 1. 收集所有未消除的棋子 List<int> remaining = new List<int>(); for (int r = 0; r < Rows; r++) { for (int c = 0; c < Cols; c++) { int v = _board[r + BorderSize, c + BorderSize]; if (v != 0) remaining.Add(v); } } // 2. 打乱并填回 Shuffle(remaining); int idx = 0; for (int r = 0; r < Rows; r++) { for (int c = 0; c < Cols; c++) { _board[r + BorderSize, c + BorderSize] = remaining[idx++]; } } // 3. 洗完之后可能仍然是死局,最多重试几次 for (int i = 0; i < 5 && IsNoValidMoves(); i++) { Shuffle(remaining); idx = 0; for (int r = 0; r < Rows; r++) { for (int c = 0; c < Cols; c++) { _board[r + BorderSize, c + BorderSize] = remaining[idx++]; } } } Invalidate(); }

Shuffle建议实现 Fisher-Yates 算法,而不是用Guid.NewGuid()作为排序键这种偷懒写法——前者是 O(n),后者引入不必要的内存分配和排序开销。填回时remaining的 Count 必须等于当前棋盘剩余棋子数,否则idx会越界,这属于洗牌逻辑里最容易被改出 bug 的地方。

洗牌后如果连续 5 次仍然死局,说明这盘棋的棋子布局确实极端,此时即使不继续洗,玩家也可以手动点击提示按钮触发同样的逻辑,这个循环只是给自动洗牌加一层保险。

5. 实战避坑:连连看源码里最常见的 5 个翻车现场

这一章写的是我自己在实现和辅导别人时遇到的高频问题。每个问题都按“现象 → 原因 → 解决”的顺序讲,你可以对照自己的代码排查。

5.1 数组越界:外扩棋盘之后,所有索引都得加偏移

现象:点击棋盘边缘附近时抛出IndexOutOfRangeException,程序直接闪退。

原因:初始化时_board被定义成Rows + 2行、Cols + 2列,但后续在鼠标事件或消除逻辑里,直接用逻辑坐标rowcol去下标访问_board[row, col],导致数组维度不匹配。比如逻辑坐标(7, 9)对应数组下标应该是(8, 10),直接访问[7, 9]就会偏到其他地方。

解决:在所有访问_board的地方统一使用“逻辑坐标 + BorderSize”的写法,例如_board[row + BorderSize, col + BorderSize]。建议在Point转换时一次性加上偏移,后续逻辑全部使用数组坐标,不要在代码里到处写+ BorderSize容易漏。

5.2 开了双缓冲还是闪:属性设置和实际绘制方式对不上

现象:棋盘重绘时仍然有轻微闪烁,尤其是快速连续点击时。

原因:单独设置DoubleBuffered = trueForm生效,但如果你把棋盘放在一个Panel或普通UserControl上,且没有把绘制代码写进控件的OnPaint,那么双缓冲只对窗体本身生效,控件内容依然是直接绘制。

解决:把棋盘绘制全部移到一个继承自UserControlBoardControl中,在它自己的OnPaint里画,并在构造函数里调用SetStyle声明三种绘制样式。不要在窗体的Paint事件里画棋盘,因为窗体垫底的控件会先绘制,再被窗体内容覆盖,产生撕裂感。

5.3 图片资源重复加载:内存只涨不降

现象:游戏运行几分钟后任务管理器里内存占用从 50MB 涨到 300MB,画面也开始卡顿。

原因:绘制方法里直接写Image.FromFile(...),每次重绘都创建一个新的Image对象。GDI+ 对象没有及时销毁,系统 GDI 句柄耗尽后就会出现绘制异常。

解决:参照 3.4 的做法,在初始化阶段把所有图片加载进Dictionary,绘制时只做查找。同时重写Dispose方法,在窗体关闭时释放所有图像资源。如果你使用了Graphics.DrawImage,不要对同一个Image执行多次Image.FromFile赋值。

5.4 快速双击连消两对:点击状态被二次进入

现象:玩家手速较快时,一次双击会连续消除两对棋子,逻辑状态混乱。

原因:鼠标点击触发的CanConnectRemovePair之间没有加防重入保护。第二次点击发生时,第一次点击的处理流程还没完成,_selectedCell已经被清空了,但事件处理函数的执行栈又进入了新的一轮判断。

解决:在棋盘事件处理顶层加一个_isAnimating_isProcessing锁:

private bool _isProcessing; protected override void OnMouseClick(MouseEventArgs e) { if (_isProcessing) return; _isProcessing = true; try { // 原有处理逻辑 } finally { _isProcessing = false; } base.OnMouseClick(e); }

锁的作用是把点击处理变成串行,一次事件没处理完,后续点击全部忽略。如果后面加了消除动画,动画播放期间也可以把这个锁置为 true,禁止玩家在动画期间点击。

5.5 绕外圈路径判定失败:双拐点循环没有遍历到外扩区域

现象:两个棋子明明可以从棋盘外绕一圈连上,但程序始终提示不能消除。

原因:CanConnect中双拐点的行、列扫描写的是for (int r = 0; r < Rows; r++),只遍历了可见棋盘的行列。而外扩的那一圈 0 没有参与扫描,绕外圈的路径自然找不到。

解决:把循环边界从Rows改为_board.GetLength(0),从Cols改为_board.GetLength(1)。这两个属性返回的是数组的实际维度,包含了外扩边框。代码里不要写死 8 和 10,用GetLength能在将来调整棋盘尺寸时自动适配。

6. 加分项加法顺序:提示、消除动画与计分怎么做最省事

棋盘能正常消除之后,游戏已经可以玩了,但离“像个产品”还有距离。这个阶段我强烈建议按“提示 → 动画 → 计分”的顺序加东西,因为每一步都建立在前一步的基础上,不会出现做到一半推翻重来的情况。

提示功能最简单也最有价值。它和死局检测几乎共用一套逻辑,都是全盘扫描找可行对。区别只是死局检测找到一对就停,提示功能需要返回那一对的具体坐标:

// 自动找出任意一对可消除的棋子 private bool FindHintPair(out Point first, out Point second) { for (int r1 = 0; r1 < Rows; r1++) { for (int c1 = 0; c1 < Cols; c1++) { Point p1 = new Point(c1 + BorderSize, r1 + BorderSize); if (_board[p1.Y, p1.X] == 0) continue; for (int r2 = r1; r2 < Rows; r2++) { for (int c2 = (r2 == r1 ? c1 + 1 : 0); c2 < Cols; c2++) { Point p2 = new Point(c2 + BorderSize, r2 + BorderSize); if (_board[p2.Y, p2.X] == 0) continue; if (_board[p1.Y, p1.X] != _board[p2.Y, p2.X]) continue; if (CanConnect(p1, p2)) { first = p1; second = p2; return true; } } } } } first = Point.Empty; second = Point.Empty; return false; }

提示按钮点击后,用Timer让这两个格子闪烁几次。注意闪烁结束后要重新检测一次是否仍然可消除,因为玩家手快可能在提示过程中已经消掉了其中一对。

消除动画放在提示之后。用System.Windows.Forms.Timer控制淡出或缩小效果,每 30 毫秒触发一次,更新透明度和缩放比例,画完再Invalidate。让动画可被打断——如果玩家点击了另一对,当前动画立即结束,否则操作手感会拖沓。

计分系统最简单,放在最后:基础分 10 分,每增加一个拐点扣 2 分,这样能鼓励玩家找更短的路径。连击奖励可以简单记一个_comboCount,连续消除无失败时分数翻倍。

最后说我自己的一个习惯:每加一个功能,先在最小棋盘(比如 4×4)上跑一遍,确认逻辑再调回正式尺寸。因为棋盘越小,死局出现得越频繁,正好能测试洗牌逻辑的边界。这个习惯帮我避开了很多只在极限局面下才暴露的问题。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询