原生JS三消游戏开发:数据结构与消除算法详解

发布时间:2026/10/28 22:59:10
原生JS三消游戏开发:数据结构与消除算法详解 三消游戏大概是每个前端新手都绕不开的练手项目我见过太多人一上来就扎进引擎和框架里结果连最基础的棋盘数据结构都没想明白就卡住了。其实用原生Web技术做一个能玩的消除游戏核心代码量并不大难点从来不在写多少行而在于几个关键决策棋盘用什么结构存、消除判定怎么写得既准又不啰嗦、下落逻辑怎么保证不留空洞。这篇内容就是把我自己反复做过好几遍的一套方案完整拆开从数据结构选型到消除算法、从下落补位到连锁反应处理每一步都给出可以直接抄的代码和参数说明。适合刚学完JavaScript基础、想找一个完整项目练手的朋友也适合做过一版但总觉得逻辑别扭、想看看别人怎么组织代码的开发者。读完你至少能拿到一个跑得起来的完整Demo并且清楚每一行代码为什么这么写。1. 整体设计与技术选型拆解动手写代码之前先把几个绕不过去的决策想清楚比写到一半推倒重来要省事得多。消除游戏看着简单但它同时涉及数据建模、状态判定、动画呈现三块内容任何一块选错方案后面都会别扭。1.1 为什么用二维数组而不是DOM节点当数据源新手最容易犯的错是把棋盘上的方块直接当成DOM元素来管理判断消除的时候去读元素的类名或者位置属性。这条路一开始能跑但很快就会崩DOM操作是异步渲染的你读到的样式和真实数据之间永远隔着一层连锁消除的时候状态会乱。正确的做法是数据与视图分离用一个二维数组存棋盘状态数组的每个元素代表一个格子的方块类型用数字或字符串标识DOM只负责把这个数组渲染出来。所有判定、消除、下落都在数组上完成改完数组再统一刷新视图。// 棋盘数据8行8列0表示空格1-5表示五种方块 const ROWS 8; const COLS 8; const TYPES 5; let board []; for (let r 0; r ROWS; r) { board[r] []; for (let c 0; c COLS; c) { board[r][c] Math.floor(Math.random() * TYPES) 1; } }这个结构的好处是显而易见的判断三个连续相同就是比较数组里相邻三个值消除就是把对应位置置0下落就是从下往上扫描遇到0就把上面的值挪下来。全部是纯数据操作没有任何渲染副作用逻辑清晰得多。提示方块类型用从1开始的整数而不是0是为了让0专门表示“空格”。这样判断一个格子是否为空只需要board[r][c] 0不用额外维护一个空标记。1.2 渲染方案绝对定位还是CSS Grid数据有了怎么把它画到屏幕上也有讲究。常见两种方案一是每个方块用绝对定位通过left和top计算位置二是用CSS Grid布局方块按顺序塞进网格容器。我推荐绝对定位原因是消除游戏需要做下落动画方块从上一行滑到下一行绝对定位可以直接改top值配合CSS transition动画平滑且可控。CSS Grid虽然布局省事但方块在网格里的位置是文档流决定的做位移动画很别扭得用transform硬凑代码反而更绕。#board { position: relative; width: 480px; height: 480px; } .tile { position: absolute; width: 56px; height: 56px; border-radius: 8px; transition: top 0.2s ease, left 0.2s ease; }每个方块的left和top由它的行列号乘以格子尺寸算出来格子尺寸 棋盘宽 / 列数。8列480px的棋盘每格60px方块留4px边距就是56px。这个换算关系后面渲染函数里会反复用到先记住。1.3 交互方式点击交换还是拖拽消除游戏的主流交互是交换相邻两个方块。实现上有两种点击两次分别选中两个方块或者按住拖拽到相邻格。点击方案实现简单、容错高适合教程和入门拖拽体验更顺滑但需要处理触摸事件和边界判定。我的建议是先做点击交换跑通全流程后再加拖拽。点击逻辑就是维护一个“当前选中格”第一次点击记录坐标第二次点击判断是否相邻相邻就交换并触发判定不相邻就把选中状态挪到新格子。这套逻辑不到三十行先把核心玩法验证了交互体验后面再打磨。2. 核心算法细节与实操要点棋盘能画出来只是第一步真正决定游戏好不好玩的是消除判定和连锁反应。这两块逻辑写得好玩起来才顺写得糙就会出现该消的没消、不该消的乱消。2.1 横向与纵向扫描的判定写法消除判定的本质是找出所有横向或纵向连续三个及以上相同类型的格子。最直白的写法是双重循环逐格检查但那样代码冗长还容易漏边界。更清爽的做法是按行和按列分别扫描用游程统计。以横向为例遍历每一行维护一个“当前连续类型”和“连续计数”遇到不同类型就重置。当计数达到3时把这三个位置标记为待消除。这里有个细节——连续四个、五个的情况要一并处理不能只标记前三个。function findMatches(board) { const matched new Set(); // 横向扫描 for (let r 0; r ROWS; r) { let runType 0, runLen 0; for (let c 0; c COLS; c) { const cur c COLS ? board[r][c] : -1; // 哨兵强制结算最后一段 if (cur runType cur ! 0) { runLen; } else { if (runLen 3) { for (let k c - runLen; k c; k) matched.add(r * COLS k); } runType cur; runLen 1; } } } // 纵向扫描同理把 r 和 c 对调即可 // ... return matched; }用Set存待消除位置是关键技巧。横向和纵向扫描可能命中同一个格子比如十字形消除用Set自动去重不用手动判断。位置编码用r * COLS c转成一维索引比存对象或字符串更省事后面取用时再拆回来。注意哨兵值-1的作用是让循环在走到行尾时强制结算最后一段连续。如果不加这个哨兵最后几个格子如果刚好构成三连就会因为循环结束而漏判。这是新手写扫描逻辑最常见的坑。2.2 交换合法性判定与回退机制玩家交换两个方块后必须判断这次交换是否产生了消除。如果没产生就得把两个方块换回去——这个“回退”是很多实现里容易出bug的地方。流程应该是交换数组中的两个值 → 调用findMatches→ 如果结果为空再交换回来并给出提示如果不为空进入消除流程。注意回退时不要重新生成方块只是把刚才交换的两个值换回原位否则棋盘会莫名其妙变化。function trySwap(r1, c1, r2, c2) { // 先交换 [board[r1][c1], board[r2][c2]] [board[r2][c2], board[r1][c1]]; const matches findMatches(board); if (matches.size 0) { // 没有消除换回去 [board[r1][c1], board[r2][c2]] [board[r2][c2], board[r1][c1]]; return false; } return true; }这里有个体验细节回退时最好加一个短暂的动画延迟让玩家看到“换过去又弹回来”的过程而不是瞬间跳回。实现上可以先渲染交换后的状态等200毫秒再渲染回退后的状态视觉上就自然了。2.3 消除后的下落补位逻辑消除只是把格子置0棋盘上会留下一堆空洞。下落补位的目标是让每个非空格子下方没有空洞空出来的位置从顶部补新方块。实现思路是按列从下往上压实。对每一列维护一个“写入指针”从底部开始从下往上扫描遇到非空格子就写到指针位置并上移指针扫描完后指针以上的位置全部填新方块。function applyGravity(board) { for (let c 0; c COLS; c) { let writeRow ROWS - 1; for (let r ROWS - 1; r 0; r--) { if (board[r][c] ! 0) { board[writeRow][c] board[r][c]; if (writeRow ! r) board[r][c] 0; writeRow--; } } // 剩余空位补新方块 for (let r writeRow; r 0; r--) { board[r][c] Math.floor(Math.random() * TYPES) 1; } } }这个“双指针压实”的写法比“遇到空位就把上面所有格子下移一格”高效得多一次遍历就能完成不会出现反复挪动的性能问题。而且逻辑上很直观非空格子依次往下堆堆完剩下的就是空位。2.4 连锁反应的循环控制消除游戏的精髓在于连锁消除后下落的方块可能又凑成新的三连继续消除直到棋盘稳定。这个循环必须有终止条件否则遇到死循环就卡死了。标准流程是一个while循环找匹配 → 如果没有匹配就退出 → 有匹配就消除、下落、再找。每一轮循环对应一次“连锁”可以记录连锁次数用于计分加成。function resolveBoard(board) { let chain 0; while (true) { const matches findMatches(board); if (matches.size 0) break; chain; // 消除 matches.forEach(idx { const r Math.floor(idx / COLS); const c idx % COLS; board[r][c] 0; }); applyGravity(board); } return chain; }循环一定会终止因为每次消除至少减少三个方块补位虽然会加新方块但新方块是随机的理论上存在“补位后立刻又三连”的情况但那也是有限次的——每次循环棋盘上的方块总数不变而消除是净减少补位是净增加两者抵消后棋盘状态在有限状态空间内不会无限循环。实际跑下来连锁超过十次的情况极其罕见。3. 完整实操流程与关键代码实现前面拆的是零件这一节把它们组装成一台能跑的车。从初始化到渲染、从交互到计分按顺序走一遍每一步都给出可直接用的代码。3.1 初始化棋盘与首屏渲染初始化要做两件事生成一个没有初始三连的棋盘以及把数据渲染成DOM。为什么强调“没有初始三连”因为如果开局就有三连玩家还没操作就自动消除了体验很怪。生成无三连棋盘的方法是逐格随机生成但生成时检查左边两个和上边两个是否已经构成三连如果是就重新随机。这个“拒绝采样”的写法简单可靠。function initBoard() { board []; for (let r 0; r ROWS; r) { board[r] []; for (let c 0; c COLS; c) { let type; do { type Math.floor(Math.random() * TYPES) 1; } while ( (c 2 board[r][c-1] type board[r][c-2] type) || (r 2 board[r-1][c] type board[r-2][c] type) ); board[r][c] type; } } }渲染函数负责把二维数组映射成DOM。为了支持动画不要每次清空重建而是维护一个方块元素池按需更新位置和颜色。不过入门阶段用“清空重建”也够用代码简单等要加动画时再优化。const COLORS [, #e74c3c, #3498db, #2ecc71, #f1c40f, #9b59b6]; const CELL 60; function render() { const boardEl document.getElementById(board); boardEl.innerHTML ; for (let r 0; r ROWS; r) { for (let c 0; c COLS; c) { const type board[r][c]; if (type 0) continue; const tile document.createElement(div); tile.className tile; tile.style.left c * CELL 4 px; tile.style.top r * CELL 4 px; tile.style.background COLORS[type]; boardEl.appendChild(tile); } } }颜色数组第0位留空让类型1-5直接对应索引省去减一的操作。方块位置加4px是为了留边距视觉上更透气。3.2 点击选中与交换的完整交互链路交互状态用一个变量记录当前选中的格子坐标点击时判断是“首次选中”还是“第二次点击”。let selected null; function onTileClick(r, c) { if (!selected) { selected { r, c }; highlight(r, c); return; } const { r: sr, c: sc } selected; clearHighlight(); // 判断是否相邻 const adjacent Math.abs(sr - r) Math.abs(sc - c) 1; if (adjacent) { const ok trySwap(sr, sc, r, c); if (ok) { resolveBoard(board); render(); } else { render(); // 回退后重绘 } } selected null; }相邻判定用曼哈顿距离等于1比分别判断上下左右四种情况简洁。注意交换失败时也要调用render()因为trySwap内部已经交换又换回了数据没变但视图可能因为高亮状态需要刷新。3.3 计分与连锁加成的参数设计计分规则直接影响游戏手感。最基础的规则是每消除一个方块得10分连锁时给加成。加成系数我试过几种最终觉得“连锁次数作为倍数”比较合理第一次消除1倍第二次2倍第三次3倍以此类推。let score 0; function addScore(matchCount, chain) { score matchCount * 10 * chain; document.getElementById(score).textContent score; }这个设计的好处是鼓励玩家追求连锁而不是无脑消三个。连锁到第五次时一次消除的收益是基础的五倍爽感明显。参数上10分和倍数递增都是可调的你可以根据想要的节奏调整比如把基础分改成5分让节奏更慢。3.4 动画过渡与状态同步的处理入门版用“清空重建”渲染动画基本没有。要加动画核心思路是数据变化后不立即重建DOM而是先更新已有元素的位置和透明度等transition结束再同步数据。一个折中方案是给消除加淡出、给下落加位移。消除时先给待消除方块加一个fade-out类等200毫秒后再执行数据层的消除和下落然后重新渲染。这样视觉上有“先消失再落下”的层次感代码改动也不大。function animateRemove(matches, callback) { const tiles document.querySelectorAll(.tile); matches.forEach(idx { const r Math.floor(idx / COLS); const c idx % COLS; // 找到对应DOM元素加类 // 简化处理按位置匹配 }); setTimeout(callback, 200); }实际项目中更稳妥的做法是给每个方块元素加>function debugBoard(board) { console.table(board.map(row row.map(v v || .))); }用console.table打印二维数组空格显示为点非空显示数字哪一列压实不对一目了然。我遇到过最常见的问题是写入指针的初始值写成了ROWS而不是ROWS - 1导致第一格永远写不进去整列错位。另一个容易忽略的点是压实过程中如果writeRow r说明这个格子本来就在正确位置不需要置0。如果无脑置0会把刚写进去的值又清掉。代码里那个if (writeRow ! r)的判断就是干这个的别省。4.3 连锁死循环与性能问题的应对理论上连锁循环会终止但如果你在补位时用了“保证不产生三连”的生成策略反而可能陷入死循环——因为补位时不断拒绝采样而棋盘上又确实需要这个位置有三连才能继续逻辑就打架了。我的建议是补位时纯随机不做任何三连规避。让连锁自然发生循环靠“匹配为空”退出。这样逻辑简单也不会死循环。规避三连只在初始化时做因为开局不该有自动消除。性能方面8x8的棋盘完全不用担心一次完整连锁的耗时在毫秒级。如果棋盘扩大到20x20以上findMatches的复杂度是 O(ROWS*COLS)依然很轻。真正可能拖慢的是DOM重建方块多了之后每次innerHTML 再重建会有明显卡顿那时候再上元素池优化。4.4 交互手感优化的几个实操心得最后分享几个提升手感的小技巧都是实际调出来的经验选中高亮要明显给选中方块加一个白色描边或者放大1.1倍玩家才知道自己选了哪个。描边用box-shadow: 0 0 0 3px #fff比border好不影响布局。无效交换要有反馈换回去的时候加个轻微抖动动画比无声无息地弹回体验好很多。消除动画别太长200毫秒左右刚好超过300毫秒连锁起来会显得拖沓玩家等得不耐烦。计分数字变化加个缩放分数跳动时给数字加一个短暂的放大再恢复正反馈更明显。这些细节单个看都很小但叠在一起就是“这个游戏做得挺精致”和“能玩但有点糙”的区别。我自己的习惯是核心逻辑跑通后专门留半天时间只调手感不写新功能效果往往比加新玩法还明显。棋盘数据结构和消除判定这两块我建议你亲手从零写一遍不要复制粘贴。写的过程中卡住的地方往往就是理解不到位的地方。等你能不看参考把findMatches和applyGravity默写出来这个项目对你来说就真正吃透了。后面想扩展的话加特殊方块比如消一行、消一列或者限时模式都是在现有结构上叠加改动量不大。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询