原生JavaScript实现消消乐游戏:从逻辑到代码详解
本文详细介绍如何使用原生JavaScript从零构建一个消消乐游戏。文章解析了三消、下坠与补充的游戏循环机制,并通过面向对象设计GameMap与Cell类。重点讲解如何利用CSS transition与Promise对象实现移动、消除、下坠及冒出的平滑动画效果,包含完整的代码逻辑与实现步骤。
前言
我对开发小型游戏一直抱有浓厚兴趣,并尝试制作过多种小游戏。开发游戏是提升代码基础能力的有效途径,因此这次我决定挑战一个相对复杂的类型:消消乐。
若仅实现静态的消除逻辑(包含消除与方块补充,无交互和动画),难度并不高,关键在于理清三消原理及方块下落后的位置计算。然而,若要同时实现各种状态下的动画效果,问题则变得复杂许多。
本文将详细阐述该游戏从逻辑设计到具体实现的完整过程。值得注意的是,本游戏完全基于原生JavaScript开发。为了方便分发给不懂编程的朋友,代码未进行模块化拆分,所有逻辑均整合在单一的HTML文件中。
该游戏已部署在CodePen平台,点击查看游戏在线演示。欢迎感兴趣的朋友体验,若能点赞支持更佳。

目前游戏仅实现了最基础的三消功能,若有时间,后续可补充其他交互逻辑。
游戏的准备工作
首先分析游戏机制:游戏包含一个n*m的矩形“棋盘”,其中分布着若干颜色或类型的方块。当同一横行或竖行中,相同类型的方块数量达到3个或以上时,触发消除。
部分方块消除后,其上方的方块会下坠以填补空缺,同时上方会生成新方块以补充下坠方块的位置。完成上述步骤后,即完成一个游戏过程的循环。
总结一下
游戏循环包含三个步骤:消除、下坠、补充。若补充后方块无法自然形成消除条件,循环结束,此时需玩家交换两个相邻方块以人为制造消除情况,从而重新进入循环。
若玩家的交换未能触发新的消除循环,则该交换将被撤销,恢复原状。
基本机制思考完毕,现在开始代码构建:
考虑到方块涉及大量动画(主要包括四种:移动、消除、下坠、冒出),我们采用绝对定位来布局方块,并在行类样式中添加transition属性,利用CSS实现动画。具体实现如下:
移动:通过改变left和top值来控制方块位置。
消除:通过修改transform属性为scale(0),实现方块缩小的消除动画。
下坠:通过改变top值,原理同移动。
冒出:通过修改transform属性,将原本scale(0)的状态更改为scale(1),实现方块出现的动画效果。
由于这些动画需依次执行,我们采用异步方式处理。虽然回调函数也能实现,但可能较为繁琐,因此这里使用Promise对象来简化流程。
介绍完毕!现在开始编写代码。
棋盘
首先实现棋盘的基础结构,简单定义整体框架。需注意给#app元素添加position:relative或position:absolute属性。
<body>
<div id="app">
</div>
</body>接下来,我们采用面向对象的思想,构造棋盘的具体内容:
首先定义棋盘的宽度和高度(x和y),同时定义方块的大小(size)。
matrix用于后续存储不同type的矩阵,types表示所有棋子的种类。
此外还有几个属性,将在后续说明。
class GameMap {
constructor(x, y, size) {
this.x = x;
this.y = y;
this.size = size;
this.matrix = [];
this.useSwap = false;
this.handleable = true;
this.types = emojis.length;
}
}我们进一步构造“棋子”,由于棋子属性较多,我们通过仅将options作为参数,并解构options来赋予棋子这些属性,具体包括:
class Cell {
constructor(options) {
const { position, status, type, left, top, right, bottom, instance } = options;
this.type = type;
this.position = position;
this.status = status;
this.top = top;
this.bottom = bottom;
this.left = left;
this.right = right;
this.instance = instance;
}
}type:类型(颜色),以number类型表示,相同的number被视为同一类型。
position:位置,以形如[m,n]的二维数组存储。
status:状态,分为'common'(普通)、'collapse'(崩塌)、'emerge'(冒出)三种。
top:棋子上方的棋子对象,值为Cell实例;若上方无棋子,则为undefined。
left:棋子的左侧棋子对象。
right:棋子的右侧棋子对象。
bottom:棋子的下方棋子对象。
instance:根据上述属性生成的真实DOM对象,最终这些对象将在GameMap中展示。
在此我们使用emoji表情来展示棋子,定义全局变量emojis:
const emojis = ['😅', '🥵', '🤢', '🥶', '🤡'];渲染画面
拥有棋盘和棋子后,即可渲染棋盘。
首先定义全局变量cells,用于存放棋盘中所有的棋子,即所有Cell类的实例。
随后在GameMap中定义方法genMatrix()以初始化棋盘。根据配置的棋盘宽度和高度(x和y),填充一个x*y的点阵,此时点阵内尚无具体内容。
genMatrix() {
const { x, y } = this;
const row = new Array(x).fill(undefined);
const matrix = new Array(y).fill(undefined).map(item => row);
this.matrix = matrix;
return this;
}接下来的工作是用随机数填满点阵,我们定义方法genRandom()。
genRandom() {
const { x, y } = this;
this.matrix = this.matrix.map(row => row.map(item => Math.floor(Math.random() * this.types)));
return this;
}
如图所示的一个点阵就此生成。我们再利用这些点阵渲染出真实的画面。
定义方法init()
目前看来,init()的写法略显晦涩,这很正常,后续我们将进一步解释。
init() {
cells = [];
const { x, y } = this;
for (let i = 0; i < y; i++) {
for (let j = 0; j < x; j++) {
const type = this.matrix[i][j];
const random = Math.floor(Math.random() * this.types);
cells.push(new Cell({
type: (type == undefined) ? random : type,
position: [j, i],
status: (type == undefined) ? 'emerge' : 'common',
left: undefined,
top: undefined,
right: undefined,
bottom: undefined,
instance: undefined
}));
}
}
cells.forEach(cell => {
const [row, col] = cell.position;
cell.left = cells.find(_cell => {
const [_row, _col] = _cell.position;
return (_row == row - 1) && (_col == col);
});
cell.right = cells.find(_cell => {
const [_row, _col] = _cell.position;
return (_row == row + 1) && (_col == col);
});
cell.top = cells.find(_cell => {
const [_row, _col] = _cell.position;
return (_row == row) && (_col == col - 1);
});
cell.bottom = cells.find(_cell => {
const [_row, _col] = _cell.position;
return (_row == row) && (_col == col + 1);
});
cell.genCell();
});
return this;
}之前定义的全局变量cells,用于存放所有Cell类的实例。
对于一个新生成的棋盘,所有Cell的状态(status)均为common,其他情况将在后续讨论。回顾Cell类的构造过程,这一步完成了Cell实例的塑造,这些棋子将被进一步加工为最终的游戏画面,而最后一步cell.genCell()则将这些抽象类实体化。
genCell()方法是在Cell类中定义的方法,我们根据
genCell() {
const cell = document.createElement('div');
const size = gameMap.size;
const [x, y] = this.position;
cell.type = this.type;
cell.style.cssText =
`
width:${size}px;
height:${size}px;
left:${size * x}px;
top:${size * y}px;
box-sizing:border-box;
border:5px solid transparent;
transition:0.5s;
position:absolute;
transform:scale(${this.status == 'emerge' ? '0' : '1'});
display:flex;
justify-content:center;
align-items:center
`;
cell.innerHTML = `<span style="font-size:40px;cursor:pointer">${emojis[this.type]}</span>`;
this.instance = cell;
}genCell根据init()之前对cell数据的定义和演算生成棋子,但目前棋子尚未渲染到页面中,仅作为DOM对象暂存。
最后我们定义方法genCellMap()以生成真实的游戏画面。
genCellMap() {
app.innerHTML = '';
cells.forEach(cell => {
app.append(cell.instance);
});
return this;
}遍历之前全局变量cells中的内容,找到每个Cell中的instance,再将这些instance挂载到#app上,一个游戏棋盘便呈现出来。

这就是一个Cell实例的模样,其中的instance是一个实实在在的div。

游戏画面是一个最简单的棋盘,未做其他美化修饰(主要因为懒得写)。考虑到emoji不像图片加载那样麻烦,且不存在失真问题,因此我们使用emoji来刻画这些棋子。

一个真实的instance的样子。
动画效果
前文已明确游戏循环包含三件事:消除、下坠、补充。因此我们将这三件事分别通过定义GameMap的三个方法来刻画,这三个方法分别为:
genCollapse() genDownfall() genEmerge()
代码如下
genCollapse()
genCollapse() {
return new Promise((resolve, reject) => {
this.handleable = false;
this.markCollapseCells();
setTimeout(() => {
cells.forEach(cell => {
if (cell.status == 'collapse') {
cell.instance.style.transform = 'scale(0)';
}
});
}, 0);
setTimeout(() => {
resolve('ok');
}, 500);
});
}genCollapse的过程中还有一个步骤叫做markCollapseCells(),用于标记即将崩塌的棋子,该方法代码如下:
markCollapseCells() {
cells.forEach((cell) => {
const { left, right, top, bottom, type } = cell;
if (left?.type == type && right?.type == type) {
left.status = "collapse";
cell.status = "collapse";
right.status = "collapse";
}
if (top?.type == type && bottom?.type == type) {
top.status = "collapse";
cell.status = "collapse";
bottom.status = "collapse";
}
});
return this;
}遍历整个cells,若一个棋子的左边、右边和自己均为同一类型,则这三者的状态均被标记为'collapse';同理,若棋子的上边、下边和自己为同一类型,也会被标记。我们不担心重复标记,因为已被标记的棋子再次被标记也无妨。
标记完成后,我们将这些被标记的Cell对象,其instance的style加入transform:scale(0),在transition的作用下,它们会逐步(实际上很快)萎缩至不可见状态,实际上并未消失。在这一逐步萎缩过程中,我们利用Promise的性质阻塞该方法的执行,以等待棋子萎缩完毕,再进入下一个过程:下坠。0.5s后抛出resolve,完成放行,顺利进入下一步。
genDownfall()
genDownfall() {
return new Promise((resolve, reject) => {
setTimeout(() => {
cells.forEach(cell => {
if (cell.status != 'collapse') {
let downfallRange = 0;
let bottom = cell.bottom;
while (bottom) {
if (bottom.status == 'collapse') {
downfallRange += 1;
}
bottom = bottom.bottom;
}
cell.instance.style.top = (parseInt(cell.instance.style.top) + gameMap.size * downfallRange) + 'px';
}
});
}, 0);
setTimeout(() => {
resolve('ok');
}, 500);
});
}genDownfall()的关键在于确定哪些棋子会下坠,以及下坠的距离。得知下坠距离后,再设置这些Cell中新的top值,同样通过transition效果制造下坠动画。
首先明确哪些棋子可能会下坠:
其实很简单,除status为collapse的棋子外,其他棋子均可能下坠,但也不尽然,例如最下方一排的棋子无论如何也不会下坠。
所以下一步就是计算它们下坠的距离:
这一步也不复杂,之前的Cell类中已预先定义了棋子的bottom属性,我们只需知道棋子下方有多少个状态为collapse的棋子,即可知道该棋子会下坠多少距离,距离=棋子的size*下方状态为collapse的棋子数量。
通过while循环,逐一查询该棋子下方棋子的状态,便能得到答案。
我们故技重施,使用Promise的性质阻塞整个过程0.5s,再放行到下一步。
genEmerge()
genEmerge()的过程相对复杂,因为此时棋盘已被打乱,需重塑整个棋盘。重塑完毕后,再将缺失的棋盘补充出来。补充方法前文已提及,即scale(0)->scale(1),以得到一种生机勃勃、万物竞发的效果。
代码如下:
genEmerge() {
return new Promise((resolve, reject) => {
this.regenCellMap();
this.genCellMap();
setTimeout(() => {
cells.forEach(cell => {
if (cell.status == 'emerge') {
cell.instance.style.transform = 'scale(1)';
}
});
}, 0);
setTimeout(() => { resolve('ok'); }, 500);
});
}其中有一个步骤叫做regenCellMap(),该步骤代码如下
regenCellMap() {
const size = gameMap.size;
const findInstance = (x, y) => {
return cells.find(item => {
const { offsetLeft, offsetTop } = item.instance;
return (item.status != 'collapse' && (x == offsetLeft / size) && (y == offsetTop / size));
})?.instance;
};
this.genMatrix();
this.matrix = this.matrix.map((row, rowIndex) => row.map((item, itemIndex) => findInstance(itemIndex, rowIndex)?.type));
this.init();
}这其中关键的一步就是findInstance,我们要重新找到执行了downfall后的棋子它们的position是什么,并将它们的type和位移后的position一一对应,我们用这些重新一一对应的信息,重新构造matrix,以完成对整个棋盘的重新塑造。注意该方法的最后一步,init(),也就是说我们对棋盘重新进行了初始化,下面我们再来看init()的代码,或许你就能理解init()为什么这么写了。
重塑后的matrix长这样。

下坠的棋子填了消除棋子的坑,那上面的空隙也就出现了,数组的find方法,找不到内容,便会返回undefined,因此findInstance()在找不到棋子的时候,便会返回undefined,也因此将这一结果作用到了重塑的matrix上。
我们重新来看init()
init() {
cells = [];
const { x, y } = this;
for (let i = 0; i < y; i++) {
for (let j = 0; j < x; j++) {
const type = this.matrix[i][j];
const random = Math.floor(Math.random() * this.types);
cells.push(new Cell({
type: (type == undefined) ? random : type,
position: [j, i],
status: (type == undefined) ? 'emerge' : 'common',
left: undefined,
top: undefined,
right: undefined,
bottom: undefined,
instance: undefined
}));
}
}
cells.forEach(cell => {
const [row, col] = cell.position;
cell.left = cells.find(_cell => {
const [_row, _col] = _cell.position;
return (_row == row - 1) && (_col == col);
});
cell.right = cells.find(_cell => {
const [_row, _col] = _cell.position;
return (_row == row + 1) && (_col == col);
});
cell.top = cells.find(_cell => {
const [_row, _col] = _cell.position;
return (_row == row) && (_col == col - 1);
});
cell.bottom = cells.find(_cell => {
const [_row, _col] = _cell.position;
return (_row == row) && (_col == col + 1);
});
cell.genCell();
});
return this;
}注意这两句代码:
type: (type == undefined) ? random : type
status: (type == undefined) ? 'emerge' : 'common'
重塑后的棋盘,空隙的部位得以补充:首先它们的类型为随机生成,它们的状态也有别于common,而是emerge,在genEmerge()过程中它们将会出现。
整合效果
最复杂的工作目前已经完成,我们已经刻画出了整个游戏的核心循环,现在我们再用genLoop方法来整合这一系列过程,代码如下:
genLoop()
async genLoop() {
await gameMap.genCollapse();
let status = cells.some(cell => cell.status == 'collapse');
while (cells.some(cell => cell.status == 'collapse')) {
await gameMap.genDownfall();
await gameMap.genEmerge();
await gameMap.genCollapse();
}
gameMap.handleable = true;
return status;
}考虑到我们使用了Promise,所以我们将genLoop弄成异步函数。while循环将循环往复的执行这些过程,直到我们无法将任何的棋子状态标记为collapse。之前我们有个变量没提到,就是handleable,它决定了我们是否可以对棋盘进行互动。
genSwap()
最后我们考虑的是我们操纵棋盘的过程。实际上消消乐游戏中,需要我们交互的场景是很少的,大多数时间都是在观看动画,genSwap是我们交换两个棋子的过程。
genSwap(firstCell, secondCell) {
return new Promise((resolve, reject) => {
const { instance: c1, type: t1 } = firstCell;
const { instance: c2, type: t2 } = secondCell;
const { left: x1, top: y1 } = c1.style;
const { left: x2, top: y2 } = c2.style;
setTimeout(() => {
c1.style.left = x2;
c1.style.top = y2;
c2.style.left = x1;
c2.style.top = y1;
}, 0);
setTimeout(() => {
firstCell.instance = c2;
firstCell.type = t2;
secondCell.instance = c1;
secondCell.type = t1;
resolve('ok');
}, 500);
});
}套路还是很明确的,先改变两枚棋子的left和top值,获得动画效果,0.5s后再重构Cell对象。
最后是游戏的执行代码:
const app = document.getElementById('app');
const btn = document.getElementById('btn');
const emojis = ['😅', '🥵', '🤡', '🥶', '🤢'];
let cells = [];
let gameMap = new GameMap(6, 10, 50);
gameMap.genMatrix().genRandom();
gameMap.init().genCellMap();
gameMap.genLoop();
let cell1 = null;
let cell2 = null;
app.onclick = () => {
if (gameMap.handleable) {
const target = event.target.parentNode;
const { left: x, top: y } = target.style;
const _cell = cells.find(item => item.instance == target);
if (!gameMap.useSwap) {
target.className = 'active';
cell1 = _cell;
} else {
cell2 = _cell;
cell1.instance.className = '';
if (['left', 'top', 'bottom', 'right'].some(item => cell1[item] == cell2)) {
(async () => {
await gameMap.genSwap(cell1, cell2);
let res = await gameMap.genLoop();
if (!res) {
await gameMap.genSwap(cell1, cell2);
}
})();
}
}
gameMap.useSwap = !gameMap.useSwap;
}
};实际上主要的工作是花在构造GameMap和Cell两个类上面,清楚的构造了两个类之后,剩下的工作就不那么复杂了。
这里的点击事件稍微复杂一点,因为实际上消消乐当中只有相邻的两个单元格才能交换,并且交换后还需与判断是否有单元格会因此崩塌,如果没有,这个交换将被重置。app.onclick当中便刻画了这个效果。
以上就是基于JS实现的消消乐游戏的示例代码的详细内容,更多关于JS消消乐游戏的资料请关注本站其它相关文章!
JavaScript闭包与类(Class)的区别及适用场景对比
2026-11-05 15:48:57
JavaScript入门要学ES6吗?let与箭头函数等新特性解析
2026-11-05 14:49:16
JavaScript 8种数据类型详解:基本类型与引用类型的区别
2026-11-05 14:25:15
怎么在JavaScript中使用async/await优化Ajax异步流程
2026-11-05 13:56:15
JavaScript replace回调函数实现动态正则替换逻辑
2026-11-05 13:52:13
