我做休闲小游戏也有些年头了,每次聊到消消乐,大家的第一反应往往集中在“三消判定怎么写、交换逻辑怎么判断横竖连线”。可真到动手实现的时候,你会发现消除判定反而是最简单的一环,真正让人头疼的,是消除完之后的棋盘补位:坐标被清空了,上面的块要往下掉,顶部还要补进新块,补完新块又可能接着形成新的三连,还得继续触发消除。这一整套流程的核心,就是消消乐简易填充算法。
这篇文章我就把自己沉淀下来的一套做法,从头到尾完整拆开讲一讲。适合正在做休闲游戏原型、课程设计Demo,或者刚入行想做游戏逻辑的同学参考。我会先从数据结构和方案选型讲起,再给出一份可以照着抄的实现流程,最后把动画同步、死局检测、填充分布这类容易出问题的环节逐个排查一遍。文章里的代码风格接近JavaScript伪代码,你换成自己项目的语言也完全没有问题。
1. 从需求到设计:填充算法到底在解决什么问题
1.1 一次消除之后,棋盘发生了什么
先想象一个标准8行8列的棋盘,里面装满了不同颜色的方块。玩家交换两个相邻块后,如果形成横向或纵向不低于3个的同色连线,这些方块就会被标记为待消除。等到消除动画播完,这些格子对应的坐标就变成了空位,也就是所谓的“空洞”。
此时棋盘的状态看似不复杂,但有几个规律非常关键。第一是纵向悬空:上面有方块、下面落空了,按照正常重力感受,上方方块必须往下掉。第二是列残缺:可能一整列缺了好几个方块,也可能某一列几乎全空。第三是空位不能参与交互:玩家是通过交换相邻两个方块来操作的,棋盘上有空位的时候,我们不能把空位当作常规方块去参与交换判定。
曾经有个非主流做法是让空位原地消失,把右侧所有列整体向左平移,类似俄罗斯方块消行后整行收缩的思路。但消消乐的主流体验不是这样,方块只能做垂直下落,列与列之间不能左右平移。因为左右平移会打乱玩家对棋盘空间布局的预判,后续的连击、特殊技能触发也都会变得不可控。所以填充算法的第一条设计原则就是:垂直重排、列独立、行原地。
1.2 “简易”两个字背后的取舍与边界
我强调简易填充算法,并不是指代码写得简陋,而是指设计范围刻意收缩。一个玩具级填充算法,只负责三件事:把剩余方块落到列的底部、把顶部空位补上新方块、循环检查是否形成新的可消除连线。至于消除特效、落地弹跳、方块滑入的缓动曲线、粒子反馈,那都属于表现层的另一套逻辑,不在算法核心范围内。
这种拆分在实际项目中非常有用。逻辑层和表现层一旦混合,排查就会变得很费劲。比如我看到很多初学者的代码把坐标算偏移、播放动画、消除判断全都写在一个update函数里,结果出现“方块卡在半空”或者“动画还没播完,逻辑层已经算出新棋盘”这种问题。真正靠谱的做法,是先把逻辑棋盘算到稳定状态,再去让表现层追赶这个状态。
所以我这篇文章说的简易,指的是逻辑聚焦、边界清晰。你先把这一层吃透,后面再去做滑动插帧、连消爆炸特效,都会轻松很多。
2. 核心思路与方案选型:下落和补新到底谁先谁后
2.1 两条最常见的填充路线
实现填充算法,业内基本分成两条路线。第一条叫“逐列下压,顶部补齐”,我也叫它原地重排法。它保持每个方块原来的归属列不变,只是把列内所有未消除方块向底部压实,然后在空出来的顶部坐标里生成随机新方块。第二条叫“整局重开盘面”,也就是消除之后把当前剩余块信息导出,通过某种配方控制,生成一张完全不包含三连的新棋盘。
第二条路线听起来很聪明,因为它可以直接复用开局生成的棋盘算法,填出来的盘面非常干净,没有二次消除的连环爆炸。但它的缺点也很致命:当前剩余方块在盘面上的位置会全部错乱,玩家看到上一步还很完整的布局瞬间被彻底打乱,失去视觉连续性。消消乐这类游戏非常讲究“棋盘延续感”,所以这条路线除了在死局重置时会用到,平时几乎没人用它来处理普通消除。
因此我们常规选用第一条路线:按列处理,每列内部垂直重排。它的核心美感在于,每次消除后棋盘看起来只是“塌陷”而不是“推倒重建”,玩家对场上剩余方块的规划不会被完全破坏。
2.2 为什么先处理下落,再生成新块
所有学过数据结构的同学可能都会直觉性地认为,生成新块和下落操作可以合并成一步:遍历整个二维数组,遇到空位就随机填一个,然后把非空块“顶上去”。逻辑上没错,但实现时很容易出现覆盖冲突。
想象一下,我们从上往下扫描某列,遇到一个空格就先在顶部随机生成一个新块,继续往下扫描时又遇到一个旧方块,它需要下移,但目标坐标很可能已经被刚才生成的新块占据了。这样就会造成旧方块顶替了新块,或者新块被旧方块覆盖。这个问题的本质是“同一坐标被两次写入,且第二次写入时没有清理前一个值”。
所以更稳妥的顺序是严格分两步:第一步,先把该列所有非空方块全部沉到列底;第二步,列底上面剩余的空位再填充随机新块。这样就不会存在旧方块和新方块争抢坐标的问题。
这里我给出一个很接地气的类比:一个竖着的玻璃管里混着装了一些彩色小球,有些小球被抽走了,你要做的就是先摇晃管子让剩余小球全部沉到底部,再从管子顶部逐个塞进新的小球填满。你不能边摇边塞,因为边摇边塞时新塞入的小球会被正在下沉的小球顶出去。
2.3 数据结构怎么选:二维数组与状态标记
数据结构的选择,直接影响填充算法的写法和排查难度。最直接的方式是使用二维数组board[row][col],用row表示行,col表示列,数组中存储颜色编号,空位用一个特殊值EMPTY表示,通常设成-1。
也有团队喜欢用一维数组加索引换算,比如board[col + row * COLS]。这种做法的优势是连续内存、遍历快,尤其适合需要频繁整体拷贝的场景。但劣势也明显:涉及行列坐标转换时,心智负担会升高,一不留神就把row * COLS + col写成了col * ROWS + row,这在非正方形棋盘上会直接越界崩溃。
我个人的经验是,在项目原型阶段,直接上二维数组最稳,可读性最高。性能上的那点差异,对8x8或9x9的棋盘来说几乎可以忽略。后续如果真的要做非常极致的性能优化,再用一维数组也不迟。关键不是数据结构本身有多高级,而是你能不能快速定位bug。
在填充算法中,除了棋盘本体,我还会维护一个布尔数组needClear[ROWS][COLS],用来标记哪些格子已经被消除。这个标记非常重要,因为它把“消除判定”和“填充处理”两个阶段解耦了。消除判定只负责写标记,填充处理只负责读标记,两个阶段各管各的,不会互相污染。
3. 具体实现:一个可直接复现的简易填充流程
3.1 消除标记与碰撞检测
在进入填充之前,我们先得知道哪些坐标被消除。常见的做法是遍历所有格子,对每个格子的颜色分别向右、向下检查延伸长度。如果向右延伸长度大于等于3,就把这一行连续格子都标记成needClear = true;向下同理。
这里有一个很容易踩的坑,就是只标记一条连续线段的端点,没有标记中间部分。比如一行里有4个同色块,你扫描到第一个块时算出长度为4,然后只在needClear里标记了第一个和第四个,中间两个没标记。到了填充阶段,中间两个坐标就不是空位,旧块不会下落,最终导致棋盘上出现孤立悬空块,视觉上极其诡异。
正确的做法是,标记整条连续线段的所有坐标。可以用一个循环从起点到终点逐个赋值。我给一个简单的代码片段:
function markClear(board, needClear, rows, cols) { for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { let lenRight = 1; while (c + lenRight < cols && board[r][c + lenRight] === board[r][c]) { lenRight++; } if (lenRight >= 3) { for (let k = 0; k < lenRight; k++) { needClear[r][c + k] = true; } } let lenDown = 1; while (r + lenDown < rows && board[r + lenDown][c] === board[r][c]) { lenDown++; } if (lenDown >= 3) { for (let k = 0; k < lenDown; k++) { needClear[r + k][c] = true; } } } } }这个实现正确性很高,因为即使同一个格子被横向标记又被纵向标记,也只是重复赋值true,不会引入额外错误。
3.2 三步搞定下落处理的核心循环
我这里的下落处理,核心思想是每列单独维护一个“写入指针”,指针从列底部开始向上移动。扫描这一列时,如果遇到非空且未标记消除的方块,就把它写到指针位置,指针上移一格;如果遇到空位或者待消除方块,就跳过不处理。
这个算法最大的好处是,不需要额外创建临时数组存一列数据,也不需要复杂的交换操作,直接在原数组上完成压缩。不少初学者会问,为什么这里要从下往上扫描,而不是从上往下扫描?因为从下往上扫描时,当前扫描位置永远比写入指针更靠上,我们只会把上方方块写到下方更稳定的位置,不会破坏还没扫描的上方数据。如果从上往下扫描,则可能把上方方块写到下面后,覆盖了后面还要读取的数据。
具体代码如下:
function dropColumn(board, needClear, col, rows) { let writeRow = rows - 1; for (let row = rows - 1; row >= 0; row--) { if (board[row][col] !== EMPTY && !needClear[row][col]) { if (writeRow !== row) { board[writeRow][col] = board[row][col]; board[row][col] = EMPTY; } writeRow--; } else if (board[row][col] !== EMPTY) { board[row][col] = EMPTY; } } return writeRow; // 返回当前列第一个空位的行号 }注意,缩进中的writeRow它指向的是最后一个未被占用的位置。经历完整个扫描之后,它停在哪里,哪一行就成了本列第一个需要填充新块的位置。
3.3 填充随机新块时的两个细节
拿到每列的writeRow之后,填充本身非常简单:从writeRow到第0行,逐个坐标赋上随机颜色编号。但有两个细节值得展开讲。
第一个是颜色生成范围。假设棋盘有5种颜色,编号0到4,那就直接用Math.floor(Math.random() * 5)。有的项目为了让新块更容易形成三连、增加刺激感,会刻意调高某一种颜色的出现概率。我这里不建议在简易填充里引入概率偏移,因为这会影响后续的连消平衡测算。先把完全随机跑通,再去做权重策略,排查起来才不容易糊涂。
第二个是“新块之间不得立即形成三连”的硬约束。如果你完全随机生成,刚补完的棋盘极有可能立刻产生新的三连。这本身不是错误,在真实消消乐里就是常见的连消玩法,意味着你可以继续调用消除标记函数,再进入下一轮下落。但如果你的目标是做一个“开局不自动消”的盘面,就需要补齐逻辑变复杂:生成新块时,逐个检查它和左右已经生成的新块、同列上下层的颜色关系,一旦形成三连就重新随机。
对于入门版本,我强烈建议保留“填充后可能继续消”这个设定。因为连消判定正好可以复用同一个循环,代码复用度高,也让玩家觉得盘面是活的。
3.4 完整流程串联:落盘到稳定状态
把上面的函数组合起来,就得到了一个完整的填充流程。每一次玩家消除触发后,我们执行这么几步:
function refillBoard(board, rows, cols) { let needClear = createBoolArray(rows, cols); markClear(board, needClear, rows, cols); let hasClear = anyTrue(needClear); while (hasClear) { for (let col = 0; col < cols; col++) { dropColumn(board, needClear, col, rows); } fillTopGaps(board, needClear, rows, cols); needClear = createBoolArray(rows, cols); markClear(board, needClear, rows, cols); hasClear = anyTrue(needClear); } }fillTopGaps就是遍历每一列,根据dropColumn返回的空位起点填入随机颜色。这整套流程看起来并不长,但只要你试验过就会发现,它覆盖了90%以上“跑起来不对劲”的坑。
这里要特别提一下循环终止条件。如果消除标记一直存在,说明棋盘还没稳定,循环将继续。正常游戏里,这种连消循环不应该无限跑下去,因为每次消除都会减少方块数量,整体颜色分布会变化,最终一定会进入一个没有三连的稳定状态。除非你生成随机新块时故意设计了某种概率陷阱,否则不需要担心死循环。
4. 进阶细节:动画表现、连击判定与性能边界
4.1 先有逻辑,再补动画坐标
我们在第3节实现的逻辑层,处理的是二维数组内部的数据变化。但是真正好玩的消消乐,方块从原位置滑落到新位置的过程,是需要过渡动画的,不然画面就是瞬移,观感非常生硬。
一个常见的实现方式是,在逻辑层算出每个方块的“最终落点”之后,表现层的每个方块对象记录三个关键数据:当前坐标、目标坐标、移动速度。动画更新时,让方块当前位置线性插值到目标坐标。不要尝试在逻辑层数组里存浮点坐标,因为逻辑层每帧都需要用整数坐标做匹配判断,浮点坐标会带来大量取整错误。
我给一个简单的做法:逻辑层更新之前,先为每个方块保存一个fromRow;逻辑层更新之后,得到新位置toRow。表现层拿到前后两个状态,就能计算出该方块应该从哪个行索引移动到哪个行索引。然后渲染时乘以每个方块的像素尺寸,加上列偏移,就是真实的屏幕坐标。
4.2 连击判定:等动画停还是不等
这里有一个老少皆宜的问题:新块补进来后,如果形成了新的三连,你是应该立刻在逻辑层继续消除,还是等玩家看到动画落定后再消?
答案是在逻辑层立刻继续,表现层做一个“排队播放”。逻辑层不管动画,它只负责快速把棋盘稳定到一个没有三连的状态。表现层则把这一轮里发生的所有位移、新块生成、消除事件记录成一个个“步骤”,然后依次播放。如果逻辑层已经算出接下来还会连消两次,表现层就按顺序播放第一次落块动画、第一次消除动画、第二次落块动画、第二次消除动画。
这样做的好处是,不管连消多少次,逻辑层的循环都在一帧内完成,不会出现玩家快速操作时逻辑和表现互相打架的情况。坏处是如果没有任何限制,一次连消可能播很久,高手局里容易拖节奏。所以市面上消消乐通常会给连消次数加一个上限,或者用加速播放来压缩动画总时长。
4.3 性能边界:8x8棋盘到底需要多小心
很多人担心这个双重循环的复杂度会不会爆炸。我们简单算一笔账:8x8棋盘一共64个格子,markClear函数里每个格子向右和向下各扫描一次,最坏情况是某种颜色铺满整行整列,那么单个格子最多扫描16个位置。64乘以16,就算再乘一个常系数,也就是千级别操作量。填充下落函数对每个格子也只访问常数次。
所以在常规棋盘尺寸下,这个算法性能完全不需要优化。真正要注意的反而是函数调用频率。如果你的update函数每帧都调用一次refillBoard,那就非常浪费,因为大部分帧根本没有新的消除事件。正确做法是只在玩家交换成功、并且确认出现消除的时候才触发一次refillBoard。另外,needClear数组每次循环都要重新创建,这里我建议直接复用两块布尔数组轮流交换,避免反复分配内存,虽然现代语言的垃圾回收很快,但在低端手机上仍然会造成可感知的卡顿。
5. 常见问题与排查技巧实录
5.1 棋盘卡死与死局检测
简易填充做完之后,你很快会发现一个新的问题:填充本身没问题,但棋盘上可能没有可交换的相邻块能够形成三连,整个局面就“死”了。玩家无论怎么交换都没法触发消除,游戏嘎然而止。
这一步不属于填充算法的必做内容,但却是配套逻辑里必须有的。死局检测有个最简单版本:遍历所有相邻交换对,每交换一次就临时检查这个位置是否能形成3连,如果所有交换对都不行,就判定为死局。8x8棋盘所有相邻交换对数量有限,检查一遍也就上百次操作,完全可以接受。
检测到死局后的处理,一般是用重新生成棋盘把局面洗掉。我更推荐的方式是保留棋盘上每种颜色出现数量不变,但重新洗牌,避免玩家产生“我的进度被推平”的感觉。
5.2 填充顺序导致的不均匀分布
有同学会问,明明是用完全随机生成新块,为什么玩几局后发现某个颜色特别多,某种颜色几乎消失?这是正常的概率波动。完全独立随机在样本量不大的情况下,确实可能出现分布不均。如果你的项目里需要让颜色比例稳定,可以在每次填充前统计当前棋盘各颜色数量,生成新块时优先补充占比最少的那几种颜色。
我在实际项目中会再加一个“连续相邻限制”:随机生成新块时,如果它左边两个颜色已经相同,就重新随机一次,减少连续同色的概率。这个技巧可以在不破坏随机性的前提下,让盘面更不容易产生极端局面,大幅降低卡死的概率。
5.3 动画与逻辑不同步的排查思路
关于动画不同步,我踩过的坑比较深,在这里重点提一下。最典型的现象是,玩家明明看着方块已经落到最底下了,但再进行一次消除后,棋盘里有两列的下落结果和逻辑层不一致,就好像有个方块穿越了另一列。
排查第一步,先确认你动画用的坐标是行索引还是像素坐标。如果逻辑层用二维数组,表现层用像素坐标画方块,一定要在代码里把这两个概念分开。我曾经见过一个参测项目,表现层把像素坐标直接写回逻辑数组,导致填充算法彻底混乱。
排查第二步,查表现层下拉落步骤队列是否被多次触发。如果你的消除动画回调里再次调用了填充函数,而填充函数本身是同步计算,就会产生重入问题。最简单的修法是在填充函数入口加一个互斥锁标志位,动画播放期间不响应新的消除请求。
这里我整理一个常见排查表,遇到类似问题可以照着查:
| 现象 | 可能原因 | 优先排查点 |
|---|---|---|
| 方块悬空不落下 | 逻辑层空位标记缺失 | 检查needClear标记是否覆盖了完整连续线段 |
| 方块瞬移无动画 | 表现层未读取前后帧坐标 | 检查每个方块对象的fromRow和toRow |
| 填充后产生非预期三连 | 未触发二次消除循环 | 检查while (hasClear)是否执行 |
| 新块颜色极端集中 | 完全随机概率波动 | 统计颜色占比,做弱化随机 |
| 动画播放时棋盘闪跳 | 填充函数重入 | 增加动画互斥锁 |
5.4 我的落地心得与避坑清单
最后分享几条我在实操中的经验。第一条,永远先用纯逻辑层自测,再做表现层。不要在写完refillBoard之后立刻把所有动画都接上,否则一旦出bug,你根本分不清是算法问题还是动画回调问题。正确顺序是先用命令行打印棋盘,运行几百次自测,确认逻辑稳定后再接画面。
第二条,空位标记值一定要跟方块颜色值区分开。有的项目因为EMPTY用了0,而颜色编号也用了0,导致把空位当成了红色方块来渲染。这个问题听上去很蠢,实际发生的频率不低。建议把颜色编号从0到4,空位标记用-1,并且写死一个常量,不要靠直觉到处写魔数。
第三条,每一轮填充结束后,打印一张人类可读的棋盘状态。比如用数字代表颜色,用点代表空位,这样你能在控制台里一眼看懂下落是否正确。调试可视化虽然土,但比盯着游戏界面猜来猜去高效太多。
第四条,随机生成新块时,我习惯先跑一次模拟填充,把整轮回合并消完成后,再做最终的棋盘渲染。这样做虽然会有一点点额外计算量,但在表现层实现上,你可以把所有过程帧都做成逐帧回放,体验会显得特别流畅顺滑。
写在最后
做这种小功能算法,很多时候不是它本身有多难,而是细节坑太多,一个不注意就是满盘皆输。我个人觉得,填充算法最值得投入时间的不是写出第一版能跑的代码,而是把“下落、补新、再次消除、稳定”这套状态流转想明白。想明白了之后,后续所有动画、特效、音效其实都只是给这套稳定状态做包装而已。
如果你正在做自己的消消乐原型,我建议先把今天讲的第3节流程完整跑通,再回头补第4节的动画和第5节的死局检测。跑通之后你会发现,最开始的那些所谓思路阻塞,其实都只是因为“从下往上扫”和“先下落再补新”这两件事没有在脑子里形成习惯。希望这篇实操拆解能省掉你跟调试器较劲的几天时间。