
砖石消消看源码图解:3步搞定匹配逻辑,面试不再慌
上周去一家大厂做二面,面试官盯着我的简历问:“你那个休闲游戏项目里的‘消除’算法是怎么实现的?如果有三连、四连、L型消除,你的状态机怎么流转的?”
我卡壳了。
明明自己用 React 和 Canvas 写过 Demo,但真让拆解核心逻辑,脑子里只剩下一堆 if-else 和定时器。那种“知其然不知其所以然”的尴尬,比直接写不出代码更让人脸红。
后来我翻遍了一个 GitHub 上的高星开源仓库,结合源码逐行拆解,才把这套逻辑彻底吃透。今天就把这套砖石消消看的图解原理掰开揉碎讲给你听。不整虚的,直接上硬核逻辑,保证你看完就能在面试里把这块讲得头头是道。
入口定位:从点击到消除的数据流
很多人一上来就想写消除算法,结果发现根本不知道数据从哪来、到哪去。
在典型的消消看架构中,核心数据流是这样的:用户交互层:Canvas 捕获鼠标/触摸事件,计算坐标,映射到网格索引 (row, col)。
状态管理层:接收索引,更新当前选中块的状态(选中/交换/待消除)。
逻辑判定层:核心!调用匹配算法,判断是否满足消除条件。
渲染层:根据状态变化,重绘 Canvas,播放动画。关键点:逻辑判定层必须是纯函数(Pure Function),不依赖 DOM 或 Canvas。这样才好测试,也好在面试中单独拆解。
我看过一个 GitHub 开源仓库(github.com/xxx/match-3-core,注:此处指代一类典型的匹配核心库结构),它的 core.ts 文件只有 200 行,却把逻辑封装得极其干净。这种解耦思维,正是面试加分项。
核心片段:匹配算法的真相
消除的核心不是“消除”,而是**“识别”**。
很多初学者喜欢用递归或者深度遍历去找连续块,性能差且难维护。工业级做法是:水平扫描 + 垂直扫描 + 合并去重。
下面这段 TypeScript 代码,是我从上述开源仓库中提炼并加注释的核心逻辑。别嫌它长,每一行都有讲究。
/*** 核心匹配算法* @param grid 二维数组,存储每个格子的宝石类型 ID,0 表示空* @returns 需要消除的坐标列表*/
function findMatches(grid: number[][]): [number, number][] {const rows = grid.length;const cols = grid[0].length;const matches = new Setstring(); // 用 Set 去重,避免重复消除同一块// 1. 水平方向扫描:寻找连续 3 个及以上相同宝石for (let r = 0; r rows; r++) {for (let c = 0; c cols - 2; c++) {const val = grid[r][c];// 跳过空位或不同值if (val === 0 || val !== grid[r][c + 1] || val !== grid[r][c + 2]) {continue;}// 找到一个起点,向右扩展寻找最长连续段let end = c;while (end cols - 1 grid[r][end + 1] === val) {end++;}// 只有长度 = 3 才加入匹配集合if (end - c + 1 = 3) {for (let i = c; i = end; i++) {matches.add(`${r},${i}`);}}// 优化:跳过已扫描的段,避免重复计算c = end; }}// 2. 垂直方向扫描:逻辑同上,这里省略重复代码,实际开发中应抽取为通用函数for (let c = 0; c cols; c++) {for (let r = 0; r rows - 2; r++) {const val = grid[r][c];if (val === 0 || val !== grid[r + 1][c] || val !== grid[r + 2][c]) {continue;}let end = r;while (end rows - 1 grid[end + 1][c] === val) {end++;}if (end - r + 1 = 3) {for (let i = r; i = end; i++) {matches.add(`${i},${c}`);}}r = end;}}// 3. 将 Set 转换为数组返回,方便后续遍历return Array.from(matches).map(str = {const [r, c] = str.split(',').map(Number);return [r, c];});
}逐行解析重点:Setstring 去重:这是最容易被忽略的细节。水平扫出的块和垂直扫出的块可能在交叉点重叠。如果用数组,交叉点会被消除两次,导致分数计算错误或动画异常。
c = end 跳跃优化:在水平扫描中,一旦找到一段连续块,指针直接跳到段尾。这避免了 O(N^2) 的无效循环,在 10x10 的网格中,性能提升明显。
纯函数设计:输入 grid,输出坐标。不修改原数组,不依赖外部状态。面试官问“怎么测试”时,你可以直接说:“传入固定网格,断言输出坐标即可,单元测试覆盖率轻松 100%。”设计思想:状态机驱动动画
光能识别还不够,消消看的灵魂在于流畅的动画。
这里引入**状态机(State Machine)**概念。每个格子不是简单的“有/无”,而是有状态:状态
含义
触发动作IDLE
静止
无SELECTED
选中
高亮显示SWAPPING
交换中
播放交换动画MATCHING
匹配中
播放爆炸/消失动画FALLING
下落中
重力下落动画REFILLING
补充中
新宝石从上方掉落设计核心:逻辑与表现分离。
findMatches 只负责算出哪些格子要消,不负责播放动画。动画由渲染层根据状态变化驱动。
这种设计的好处是什么?可扩展性:如果未来要加“四连消除生成炸弹”,只需在逻辑层加规则,动画层只需加一种 BOMB 状态,互不干扰。
可调试性:你可以单独运行逻辑层,在控制台打印匹配结果,不用盯着 Canvas 看。
面试加分:当面试官问“怎么保证动画和逻辑同步”时,你可以回答:“逻辑层是同步的,动画层是异步的,通过状态队列(Queue)保证顺序执行,避免竞态条件。”手写简化版:5分钟写出最小可行原型
面试现场不一定能掏出完整项目,但能现场写个简化版,杀伤力极大。
下面是一个极简版,只实现“交换+水平消除”,去掉垂直和下落,但逻辑完整:
class SimpleMatch3 {private grid: number[][];private rows: number;private cols: number;constructor(rows: number, cols: number) {this.rows = rows;this.cols = cols;this.grid = Array.from({ length: rows }, () = Array(cols).fill(1)); // 初始全填1}// 模拟用户交换两个相邻格子swap(r1: number, c1: number, r2: number, c2: number): boolean {// 1. 边界检查if (this.isAdjacent(r1, c1, r2, c2) === false) return false;// 2. 执行交换const temp = this.grid[r1][c1];this.grid[r1][c1] = this.grid[r2][c2];this.grid[r2][c2] = temp;// 3. 检查是否产生匹配const matches = this.findHorizontalMatches();if (matches.length 0) {// 4. 执行消除(简化版:直接置0,不处理下落)matches.forEach(([r, c]) = {this.grid[r][c] = 0;});return true; // 交换成功} else {// 5. 无匹配,回滚交换this.grid[r2][c2] = this.grid[r1][c1];this.grid[r1][c1] = temp;return false; // 交换无效}}private isAdjacent(r1: number, c1: number, r2: number, c2: number): boolean {return Math.abs(r1 - r2) + Math.abs(c1 - c2) === 1;}private findHorizontalMatches(): [number, number][] {const matches: [number, number][] = [];for (let r = 0; r this.rows; r++) {for (let c = 0; c this.cols - 2; c++) {const val = this.grid[r][c];if (val !== 0 val === this.grid[r][c + 1] val === this.grid[r][c + 2]) {matches.push([r, c], [r, c + 1], [r, c + 2]);c += 2; // 跳过已处理}}}return matches;}
}// 测试
const game = new SimpleMatch3(5, 5);
game.grid[0] = [1, 1, 1, 2, 3]; // 第一行前三块相同
console.log(game.swap(0, 3, 0, 4)); // 交换后可能触发消除
console.log(game.grid[0]); // 观察结果这个简化版的价值:展示闭环思维:从输入(swap)到输出(grid 变化),完整链路。
突出边界处理:isAdjacent 和回滚逻辑,体现严谨性。
代码量少:50 行以内,现场手敲毫无压力。应用场景与避坑指南
这套逻辑不仅用于游戏,在很多场景中都有影子:图像识别:连续像素点检测。
数据清洗:连续异常值过滤。
UI 组件:连续选中项高亮。常见坑点:死循环:如果消除后没有新宝石补充,且无法再交换,游戏卡死。解决方案:每次交换前,用 hasValidMove() 函数预判是否存在可行操作,否则重排棋盘。
动画不同步:逻辑层算出消除,但动画还没播完,用户又点了下一格。解决方案:引入 isProcessing 锁,动画期间禁止输入。
内存泄漏:Canvas 重绘频繁,DOM 节点未清理。解决方案:使用对象池(Object Pool)复用宝石对象,避免频繁 GC。进阶技巧:引入“优先级队列”
当存在多个消除组合时(比如同时有三连和四连),消除顺序会影响后续下落和连锁反应。工业级做法是用优先级队列,先消高分组合,再消低分。这需要修改 findMatches 的返回值,加入权重计算。
面试时,如果你能主动提到“连锁消除的优先级问题”,面试官会眼前一亮。这说明你不只会抄代码,还思考过真实场景的复杂性。
写在最后
砖石消消看看似简单,实则涵盖了状态机、纯函数设计、性能优化、边界处理等多个前端核心考点。
别再把它当成“玩具项目”了。用工程化的思维去重构它,用源码级的理解去剖析它,它就是你简历上最亮眼的实战案例。
你公司项目里是怎么处理类似的状态同步和动画队列的?有没有遇到过“逻辑和动画打架”的坑?欢迎在评论区聊聊你的真实经历,咱们一起避坑。