ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C#连连看游戏开发实战:二维数组路径判定与GDI+双缓冲完整解析

C#连连看游戏开发实战:二维数组路径判定与GDI+双缓冲完整解析 简介一套以 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)。如果用Listint或者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 Listint tiles new Listint(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 到Rows1、Cols1。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.PointX 表示列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 形路径需要先绕出棋盘、再从旁边绕回来也会被正确识别。这个细节特别容易忽略很多人习惯只遍历Rows和Cols结果绕外圈的路径全部判失败。复杂度方面棋盘只有 8×10 或者 10×14双拐点扫描每次最多几十行几十列完全不需要优化。如果将来做很大的棋盘才需要考虑维护可通行行列索引来加速。3. WinForms 交互层GDI 自绘棋盘与鼠标点击状态机算法再漂亮没有界面就是黑匣子。连连看的界面层核心职责有两个把棋盘画出来把鼠标点击换算成棋盘坐标再交给逻辑层。这一章我基于 WinForms 讲所用的 GDI 知识可以平移延伸到 WPF 的自绘控件。3.1 自绘棋盘 vs 控件数组谁更适合连连看很多初学者拿到题目第一反应是摆一排PictureBox每个按钮放一个棋子图片。这在一开始写起来确实快但后续会有两个问题一是 80 个甚至更多控件同时挂在窗体上渲染性能明显下降二是你需要自己维护每个控件的坐标、图片、选中状态代码量和隐藏的 bug 一点不比自绘少。我一般会把整个棋盘放在一个自定义控件里重写它的Paint事件用 GDI 的Graphics.DrawImage或DrawRectangle把整个画面画出来。棋盘是静态背景棋子是图案选中态是高亮边框三者分层绘制改动哪一层都只影响那一层。这样界面层和逻辑层的边界也非常清楚鼠标事件做坐标换算然后调用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); }两种写法等价。DoubleBuffered是Control类受保护属性在构造函数里可以直接赋值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 Dictionaryint, Image _tileImages; private void LoadTiles() { _tileImages new Dictionaryint, 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(); }_remainingCount在InitBoard()里初始化为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; }这段代码的循环顺序保证每对棋子只被检查一次避免重复计算。r2从r1开始c2在同行时从c1 1开始这样不会出现对称重复的比较。当IsNoValidMoves返回 true调用方执行洗牌逻辑。4.3 重排的洗牌策略直接随机打乱反而翻车死局出现后的洗牌很多直接复制代码的初学者会选择把整个数组重新随机填充一遍。这种做法会立刻破坏棋子的成对性——每个图案的数量必须保持偶数否则最后一定会剩下一对无法配对的棋子游戏永远无法胜利。正确做法是把棋盘上还剩下的棋子全部取出来打乱顺序再填回去。因为剩余的棋子本身就是成对的打乱后仍然是成对的private void Reshuffle() { // 1. 收集所有未消除的棋子 Listint remaining new Listint(); 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列但后续在鼠标事件或消除逻辑里直接用逻辑坐标row、col去下标访问_board[row, col]导致数组维度不匹配。比如逻辑坐标(7, 9)对应数组下标应该是(8, 10)直接访问[7, 9]就会偏到其他地方。解决在所有访问_board的地方统一使用“逻辑坐标 BorderSize”的写法例如_board[row BorderSize, col BorderSize]。建议在Point转换时一次性加上偏移后续逻辑全部使用数组坐标不要在代码里到处写 BorderSize容易漏。5.2 开了双缓冲还是闪属性设置和实际绘制方式对不上现象棋盘重绘时仍然有轻微闪烁尤其是快速连续点击时。原因单独设置DoubleBuffered true对Form生效但如果你把棋盘放在一个Panel或普通UserControl上且没有把绘制代码写进控件的OnPaint那么双缓冲只对窗体本身生效控件内容依然是直接绘制。解决把棋盘绘制全部移到一个继承自UserControl的BoardControl中在它自己的OnPaint里画并在构造函数里调用SetStyle声明三种绘制样式。不要在窗体的Paint事件里画棋盘因为窗体垫底的控件会先绘制再被窗体内容覆盖产生撕裂感。5.3 图片资源重复加载内存只涨不降现象游戏运行几分钟后任务管理器里内存占用从 50MB 涨到 300MB画面也开始卡顿。原因绘制方法里直接写Image.FromFile(...)每次重绘都创建一个新的Image对象。GDI 对象没有及时销毁系统 GDI 句柄耗尽后就会出现绘制异常。解决参照 3.4 的做法在初始化阶段把所有图片加载进Dictionary绘制时只做查找。同时重写Dispose方法在窗体关闭时释放所有图像资源。如果你使用了Graphics.DrawImage不要对同一个Image执行多次Image.FromFile赋值。5.4 快速双击连消两对点击状态被二次进入现象玩家手速较快时一次双击会连续消除两对棋子逻辑状态混乱。原因鼠标点击触发的CanConnect和RemovePair之间没有加防重入保护。第二次点击发生时第一次点击的处理流程还没完成_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上跑一遍确认逻辑再调回正式尺寸。因为棋盘越小死局出现得越频繁正好能测试洗牌逻辑的边界。这个习惯帮我避开了很多只在极限局面下才暴露的问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表