文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

基于JS实现的消消乐游戏的示例代码

2024-04-02 19:55

关注

前言

一直对小游戏挺感兴趣的,也尝试着做过一些小游戏,实现游戏是一个不错的提高代码基础水平的方式,因此这次挑战了一个较为困难的小游戏:消消乐。

如果仅仅是从消除方面来制作一个静态的消消乐(只有消除和补充方块的过程,但是没有任何交互和动画)其实并不算太难,只要我们能够想通方块消除(主要是三消)的原理和方块下落后的新位置,我们就可以解决这个问题。但如果我们要同时实现各个状态的动画呢?这就是一个比较复杂的问题了。

本文将从逻辑到实现步骤慢慢详细的介绍该游戏的制作过程。对了,这个游戏是完全用原生js实现的,考虑到要将游戏成品发给一些不懂程序的朋友,因此没做代码拆分,所有的代码均在一个单一的html文件上。

该游戏已放在codepen上面,点击查看游戏在线演示。有兴趣的小伙伴可以查看,如果能点一个小爱心更好呢。

游戏目前仅做了最简单的三消功能,如果有时间可以把其他交互也写出来。

游戏的准备工作

首先我们思考游戏的机制: 游戏有一个“棋盘”,是一个n*m的矩形。矩形中有若干个颜色(或者类型)的方块,相同类型的方块,在一个横行或者竖行,有3个或者3个以上时,便会消除。

在部分方块消除后,这些方块上方的方块便会下坠并补充这些消除方块的缺口,同时,上方又会生成新的方块来补充下坠方块的位置,在执行完上述步骤后,便完成了一个游戏过程的循环。

总结一下

一共有3个步骤,将生成一个游戏循环:消除,下坠,补充。在补充后,如果方块们无法自然消除,循环便会结束。这时候就需要玩家来交还两个相邻方块,来人为制造可以消除的情况,以重新进入循环。

如果玩家的交换并不能使得重新进入消除循环呢?那么这个交换将重新换回原样。

基本机制思考完毕,现在开始代码构建:

首先考虑到方块们会进行大量的动画过程(主要是四种:移动,消除,下坠,冒出),于是我们使用绝对定位来安排这些方块,并且在其行类样式当中添加属性:transition,用css来实现这些方块的动画。具体实现如下:

移动:通过left和top值的改变,控制方块的移动。

消除:通过修改transform,修改为scale(0),以实现消除的动画。

下坠:通过top值的改变,同移动。

冒出:通过修改transform,将本来为scale(0)的transform值修改为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消消乐游戏的资料请关注编程网其它相关文章!

阅读原文内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-前端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯