原生JavaScript实现消消乐游戏:从逻辑到代码详解

作者:半夏时间:2026-10-25 09:00:03浏览:605

本文详细介绍如何使用原生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消消乐游戏的资料请关注本站其它相关文章!

作者最新文章