三消游戏消除算法优化:从O(n²)到O(1)的增量检测实现

发布时间:2026/8/25 11:19:29
三消游戏消除算法优化:从O(n²)到O(1)的增量检测实现 1. 项目概述从“三消”到“巧判”的核心跃迁做游戏开发的朋友尤其是接触过休闲益智品类的对“消消乐”这类三消游戏肯定不陌生。表面上看它规则简单玩家交换相邻的两个元素如果交换后能在横竖方向凑齐三个或更多相同的就触发消除。但当你真正动手去实现时第一个拦路虎往往不是华丽的特效或流畅的动画而是那个最基础、最核心的“消除条件判别算法”。为什么说它是个“坑”因为它的实现直接决定了游戏的“手感”和“智商”。一个低效的算法在玩家快速操作时可能导致卡顿一个逻辑有瑕疵的算法则会出现该消的不消、不该消的乱消让玩家觉得游戏有BUG体验极差。网上能找到的很多入门教程给出的往往是“暴力扫描全盘”的朴素实现这在棋盘较小比如8x8时勉强能用一旦棋盘变大或者需要支持“L型”、“T型”等复杂消除形状时性能瓶颈和逻辑复杂性就会指数级上升。今天要分享的正是我在多个项目迭代后沉淀下来的一套“巧妙的消除条件判别算法”。它不依赖于每步操作后的全盘扫描而是以“变化点”为核心进行最小范围的、增量式的条件检测。这套算法的价值在于它将判别的时间复杂度从 O(n²)n为棋盘边长降到了接近 O(1) 的常数级别并且逻辑清晰极易扩展支持“十字消”、“五连消”等特殊规则。无论你是用 Cocos Creator、Unity 还是其他引擎这套核心逻辑都是通用的。接下来我们就抛开引擎外壳直击算法内核看看如何优雅地解决这个经典问题。2. 算法核心思想从“全盘扫描”到“增量检测”的范式转变在深入代码之前我们必须先统一思想。传统的“消除条件判别”通常发生在玩家操作交换两个格子之后流程是这样的交换两个格子的数据。遍历整个棋盘的所有行和所有列检查是否存在连续三个或以上相同的元素。如果找到记录这些格子的位置准备消除。如果没有找到则执行“回退”操作将两个格子交换回来。这个方法的问题显而易见效率低下。无论玩家交换的是左上角还是右下角的格子算法都要检查棋盘上每一个位置。在一个10x10的棋盘上就是100个格子的检查而且每次操作后都要进行。当游戏需要每帧处理多个逻辑判断时这会成为性能热点。更关键的是逻辑容易遗漏。考虑一个“十字形”消除一个棋子同时参与横向和纵向的消除简单的行列遍历可能会在记录消除列表时去重不当导致后续计算奖励分数或触发连锁消除时出错。我们提出的“增量检测”算法其核心思想是一次有效的操作其影响范围是有限的。玩家交换了两个棋子A和B那么可能产生新消除的只可能是与A、B棋子相关的行和列。具体来说是棋子A所在的行和列以及棋子B所在的行和列。绝大多数的消除情况都发生在这四条线上。因此算法的第一步从“扫描全世界”缩小为“侦查四条线”。但这还不够我们还需要在这四条线上以交换点为中心向两端进行“扩散检查”以找出所有可能的连续组合。这就是算法的骨架定位变化点 - 锁定检测线 - 双向扩散寻找连续区间。3. 数据结构与准备工作为高效判别打下基础在实现算法前我们需要设计好棋盘的数据结构。这里不依赖任何特定引擎的组件用一个二维数组来代表棋盘逻辑状态是最清晰的。// 假设我们的棋盘是 8x8用数字代表不同的宝石类型0代表空位 const BOARD_SIZE 8; let gameBoard Array.from({ length: BOARD_SIZE }, () new Array(BOARD_SIZE).fill(0)); // 初始化棋盘随机生成宝石例如1-6种类型 function initBoard() { for (let r 0; r BOARD_SIZE; r) { for (let c 0; c BOARD_SIZE; c) { // 避免初始状态就出现可消除的情况需要一个简单的校验 gameBoard[r][c] getRandomTypeWithoutMatch(r, c); } } }这里有一个新手容易忽略的关键点棋盘的初始化。你不能简单地用完全随机数填充棋盘否则极大概率一开局就存在大量可消除项这不符合游戏设计。因此getRandomTypeWithoutMatch需要实现一个“无匹配生成”逻辑。通常的做法是在为当前位置(r, c)随机选择一个类型时检查其左侧两个格子(r, c-1), (r, c-2)和上方两个格子(r-1, c), (r-2, c)的类型。如果即将生成的类型与它们连续相同则重新随机直到找到一个不会造成初始匹配的类型。这是一个细节但决定了游戏的基础体验。接下来我们需要定义“交换操作”。交换不仅仅是交换数组中的数据在判别之前我们还需要记录这次交换的“元信息”即两个棋子的坐标这是我们进行增量检测的输入。/** * 尝试交换两个格子 * param {number} r1 格子1的行 * param {number} c1 格子1的列 * param {number} r2 格子2的行 * param {number} c2 格子2的列 * returns {Array} 返回一个数组第一个元素是布尔值是否成功消除第二个元素是消除的格子坐标列表 */ function trySwap(r1, c1, r2, c2) { // 1. 校验是否相邻上下或左右 if (!((Math.abs(r1 - r2) 1 c1 c2) || (Math.abs(c1 - c2) 1 r1 r2))) { return [false, []]; } // 2. 执行逻辑上的交换 [gameBoard[r1][c1], gameBoard[r2][c2]] [gameBoard[r2][c2], gameBoard[r1][c1]]; // 3. 核心增量检测消除条件 let matchCells checkForMatchesAfterSwap(r1, c1, r2, c2); // 4. 如果没有消除交换回来 if (matchCells.length 0) { [gameBoard[r1][c1], gameBoard[r2][c2]] [gameBoard[r2][c2], gameBoard[r1][c1]]; return [false, []]; } // 5. 返回成功及消除列表 return [true, matchCells]; }4. 核心判别算法实现四线扫描与双向扩散现在来到最核心的部分checkForMatchesAfterSwap函数。它的任务是根据两个交换棋子的新位置检查四条线A的行、A的列、B的行、B的列上是否形成了新的连续匹配。注意这里有一个极其重要的思维转换。检查的不是“棋盘上所有匹配”而是“因这次交换而新产生的匹配”。因此我们的检查必须围绕交换后的新棋子进行。function checkForMatchesAfterSwap(r1, c1, r2, c2) { // 使用Set来存储消除格子的坐标避免重复比如一个棋子同时参与横竖消除 let matchSet new Set(); // 检查第一个棋子新位置所在的行和列 findMatchesInLine(r1, c1, true, matchSet); // 检查行 findMatchesInLine(r1, c1, false, matchSet); // 检查列 // 检查第二个棋子新位置所在的行和列 findMatchesInLine(r2, c2, true, matchSet); findMatchesInLine(r2, c2, false, matchSet); // 将Set转换为数组返回 return Array.from(matchSet); }关键的findMatchesInLine函数实现了“双向扩散”查找。它的思路是给定一个中心点(centerR, centerC)和一个方向isRow为 true 表示检查行从中心点分别向左/右或上/下延伸找到所有与中心点类型相同的连续格子从而确定一个连续的“区间”。/** * 在一条线上查找包含中心点的所有匹配 * param {number} centerR 中心点行坐标 * param {number} centerC 中心点列坐标 * param {boolean} isRow true表示检查行false表示检查列 * param {Set} matchSet 用于存储结果的集合 */ function findMatchesInLine(centerR, centerC, isRow, matchSet) { const targetType gameBoard[centerR][centerC]; if (targetType 0) return; // 空位不参与匹配 let startIndex, endIndex; if (isRow) { // 检查行固定行号centerR变化列号 // 向左找起点 startIndex centerC; while (startIndex - 1 0 gameBoard[centerR][startIndex - 1] targetType) { startIndex--; } // 向右找终点 endIndex centerC; while (endIndex 1 BOARD_SIZE gameBoard[centerR][endIndex 1] targetType) { endIndex; } // 判断连续长度是否3 if (endIndex - startIndex 1 3) { for (let c startIndex; c endIndex; c) { matchSet.add(${centerR},${c}); } } } else { // 检查列固定列号centerC变化行号 // 向上找起点 startIndex centerR; while (startIndex - 1 0 gameBoard[startIndex - 1][centerC] targetType) { startIndex--; } // 向下找终点 endIndex centerR; while (endIndex 1 BOARD_SIZE gameBoard[endIndex 1][centerC] targetType) { endIndex; } // 判断连续长度是否3 if (endIndex - startIndex 1 3) { for (let r startIndex; r endIndex; r) { matchSet.add(${r},${centerC}); } } } }这个算法的精妙之处在于高效它只检查了最多4条线每条线的检查通过双指针startIndex和endIndex一次遍历完成复杂度是O(n)n是棋盘边长。相比全盘扫描的O(n²)在棋盘稍大时优势巨大。准确双向扩散的方式确保了只要中心点位于一个连续序列中无论它在序列的哪个位置开头、中间、结尾都能被完整地找出来。无重复使用Set存储坐标字符串如“3,5”自动处理了一个棋子同时存在于横向和纵向消除组的情况避免了后续逻辑的复杂性。5. 算法扩展支持特殊消除形状与连锁反应基础的三消逻辑实现了但现代消消乐游戏还有更多花样比如“L型”、“T型”消除通常有额外奖励以及消除后空位掉落新棋子引发的“连锁反应”。我们的算法框架可以很好地支持这些扩展。5.1 支持“L型”和“T型”消除所谓“L/T型”消除本质上是一个棋子同时参与了一个横向消除组长度3和一个纵向消除组长度3。在我们的算法中这个棋子会被matchSet记录两次来自行检查和列检查但由于Set的去重特性它只出现一次。我们需要在判断“特殊消除”时识别出这类棋子。可以在checkForMatchesAfterSwap函数返回后增加一个后处理步骤function getSpecialMatches(matchCellsArray) { let specialMatches []; let cellCountMap new Map(); // 记录每个坐标被匹配到的方向数 // 重新检查四条线这次记录每个格子被匹配到的“方向” let tempSet new Set(matchCellsArray); // ... 这里需要重构 findMatchesInLine使其不仅能加入Set还能记录某个格子是因行匹配还是列匹配被加入的。 // 简化逻辑如果一个格子的坐标在 matchCellsArray 中 // 并且我们通过查找发现它同时存在于一个横向匹配组长度3和一个纵向匹配组长度3中 // 那么它就是特殊消除棋子。 // 这需要更精细的数据结构来记录匹配组信息而非单个格子。 }更实用的方法是修改findMatchesInLine让它除了向matchSet添加单元格外还向一个matchGroups数组添加信息记录每一个匹配组的起始、结束坐标和方向。然后遍历所有匹配组寻找那些在横、纵方向上有交集且交集点相同的组该交点即为特殊消除棋子。5.2 连锁反应检测连锁反应是消除游戏的乐趣来源。实现它的关键在于当本轮消除的格子被清空设为0后上方的格子会“掉落”填补空位然后需要检查这些“新掉落”的棋子是否形成了新的可消除组合。这个过程是一个循环消除并掉落将matchCells中的格子清空然后模拟物理掉落让上方非空的格子逐行下落。生成新棋子在棋盘顶部空缺的位置生成新的随机棋子。再次检测注意这里不能再用增量检测了。因为掉落和生成影响了整个棋盘的多列影响范围很大。此时一个可靠且简单的方法是进行一次全盘扫描。由于连锁反应通常不会无限进行一般2-3轮且发生在消除动画之后玩家感知不强一次全盘扫描的性能开销是可以接受的。循环如果全盘扫描又发现了新的可消除组合则重复步骤1-3直到棋盘稳定无新匹配。function cascadeCheck() { let hasNewMatch true; let allMatches []; while (hasNewMatch) { hasNewMatch false; // 进行一次全盘扫描查找所有匹配 let newMatches findAllMatchesOnBoard(); if (newMatches.length 0) { allMatches allMatches.concat(newMatches); // 消除这些格子 removeCells(newMatches); // 执行掉落和新棋子生成 applyGravityAndFill(); hasNewMatch true; } } return allMatches; // 返回连锁消除的所有格子 } // 全盘扫描函数仅在连锁检测时使用 function findAllMatchesOnBoard() { let matchSet new Set(); // 检查所有行 for (let r 0; r BOARD_SIZE; r) { // 使用类似 findMatchesInLine 的逻辑但以每个格子为起点进行检查优化 // 更高效的方式是遍历每行/每列使用“滑动窗口”一次找出所有连续段 let count 1; for (let c 1; c BOARD_SIZE; c) { if (c BOARD_SIZE gameBoard[r][c] gameBoard[r][c-1] gameBoard[r][c] ! 0) { count; } else { if (count 3) { for (let k c - count; k c; k) { matchSet.add(${r},${k}); } } count 1; } } } // 检查所有列逻辑类似 // ... return Array.from(matchSet); }实操心得在连锁检测中使用全盘扫描是业界常见做法它逻辑简单可靠避免了增量检测在复杂掉落局面下可能出现的边界情况遗漏。将“玩家操作后的即时判别”和“连锁反应检测”采用不同策略增量 vs 全盘是性能与鲁棒性之间的一个很好平衡。6. 性能优化与边界情况处理即使算法核心很高效在实际项目中仍需注意一些优化点和坑。6.1 预计算与缓存对于需要频繁判断的操作比如“提示系统”寻找当前棋盘所有可交换的对如果每次都模拟交换并调用判别算法开销很大。可以引入一个“潜在匹配”的缓存机制。例如遍历棋盘只检查每个棋子与其右方、下方棋子交换后是否可能产生消除。将结果缓存起来当玩家一段时间无操作时直接从这个缓存里取一个结果作为提示。棋盘变化后消除、掉落再更新缓存。6.2 边界情况空位与不可交换棋子我们的算法假设棋盘是充满的。但在消除后会有空位值为0。findMatchesInLine函数开头已经判断了targetType 0则直接返回这是正确的因为空位不应该参与匹配。同时有些游戏有“障碍物”或“冰块”等不可交换的棋子类型在交换校验 (trySwap) 和匹配判断时都需要将它们排除在外。6.3 交换回退的细节在trySwap中如果检测没有产生消除我们需要交换回来。这里要确保用于检测的gameBoard状态是交换后的而回退操作必须精确地还原。在复杂的项目里棋盘数据可能关联着视图组件需要同时更新数据层和视图层确保状态同步。6.4 关于“同时消除”的判断我们的算法使用Set存储坐标自动处理了一个格子同时处于横竖两个消除组的情况。但在计算得分、播放特效时你可能需要知道这是一个“十字消”还是普通的两个消除。这就需要如前所述记录更详细的匹配组信息而不仅仅是单个格子集合。7. 在Cocos Creator中的集成要点虽然算法是引擎无关的但在 Cocos Creator 中集成时有一些实践细节数据与视图分离gameBoard二维数组是你的数据模型。每个棋盘格子对应一个cc.Node例如一个Sprite组件显示宝石图片这是视图。所有逻辑判断基于数据模型。操作成功后再同步更新视图节点的位置、精灵帧和播放动画。操作响应在trySwap函数中不要直接执行视图交换。应该先进行逻辑判断。如果返回[true, matches]再执行播放两个棋子交换的动画。播放matches中所有棋子的消除动画如缩放、淡出。在消除动画结束后触发掉落逻辑更新数据模型并播放棋子掉落的动画。掉落完成后调用cascadeCheck进行连锁检测。使用定时器管理流程消除、掉落、连锁是一个序列化的动画过程。使用setTimeout或schedule来管理这些步骤的时序让玩家能清晰地看到每一步反馈而不是所有变化瞬间完成。资源管理预加载消除、掉落等音效和粒子特效资源在适当时机播放能极大提升游戏体验。这套“增量检测判别算法”是我从早期全盘扫描的卡顿到后来各种边界BUG的修复中逐步提炼出来的。它的优势不在于用了多高深的数据结构而在于它精准地抓住了问题域的特点——局部性并以此设计了高效的解决方案。希望这次深入的拆解能帮你下次实现自己的三消游戏时直接绕开那些深坑写出既高效又健壮的代码。记住好的游戏手感往往就藏在这些基础算法的细节里。