本文介绍如何使用HTML5、Cascading Style Sheets和JavaScript实现一款类似《羊了个羊》的消除游戏。文章详细讲解了基于rem的移动端适配方案、自定义弹窗插件的使用、动态DOM生成、游戏核心逻辑及消除判定机制,为前端开发者提供完整的项目实现思路。
引言
近期《羊了个羊》游戏热度极高,相信不少读者已经体验过。在游玩过程中,大家或许会好奇其背后的实现原理。本文将通过HTML5、Cascading Style Sheets和JavaScript技术,带领读者实现一款动物主题的消除类游戏。
项目首先引入了两个辅助插件。第一个是flexible.js,该插件主要用于根据不同设备屏幕尺寸动态设置根元素的字体大小,从而实现移动端页面的自适应布局。
鉴于项目采用了rem布局方案以适配移动端设备,因此整体样式设计均基于此方案进行构建。
rem布局方案
此外,项目还使用了一个早期自行实现的弹窗插件popbox.js。本文不深入探讨其内部实现原理,仅说明其调用方式。
popbox.js使用原理
ewConfirm({
title: "温馨提示", //弹框标题
content: "游戏结束,别灰心,你能行的!", //弹框内容
sureText: "重新开始", //确认按钮文本
isClickModal:false, //点击遮罩层是否关闭弹框
sure(context) {
context.close();
//点击确认按钮执行的逻辑
},//点击确认的事件回调
})
引入该插件后,window对象上会绑定一个ewConfirm方法。调用该方法时需传入一个配置对象,其属性包括title,content,sureText,cancelText,cancel,sure,isClickModal等。由于本例未使用取消按钮,故不作详细说明。
各属性含义已在代码注释中明确,此处不再赘述。
html代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>羊了个羊《动物版》</title>
<link rel="stylesheet" href="./style.css" rel="external nofollow" >
</head>
<body>
</body>
<script src="https://www.eveningwater.com/static/plugin/popbox.min.js"></script>
<script src="https://www.eveningwater.com/test/demo/flexible.js"></script>
<script src="./script.js"></script>
</html>
初始HTML结构中并未包含任何DOM元素,所有页面元素均通过JavaScript动态生成。因此,script.js文件中的代码是整个项目的核心,下文将详细解析。接下来介绍样式代码,这部分相对简单。
样式代码
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body,
html {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
background: url('https://www.eveningwater.com/my-web-projects/js/21/img/2.gif') no-repeat center / cover;
display: flex;
justify-content: center;
align-items: center;
}
.ew-box {
position: absolute;
width: 8rem;
height: 8rem;
}
.ew-box-item {
width: 1.6rem;
height: 1.6rem;
border-radius: 4px;
border: 1px solid #535455;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
cursor: pointer;
transition: all .4s cubic-bezier(0.075, 0.82, 0.165, 1);
}
.ew-collection {
width: 8rem;
height: 2.4rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0 1rem;
background: url('https://www.eveningwater.com/static/dist/20d6c430c2496590f224.jpg') no-repeat center/cover;
position: fixed;
margin: auto;
overflow: auto;
bottom: 10px;
}
.ew-collection > .ew-box-item {
margin-right: 0.3rem;
}
.ew-left-source,
.ew-right-source {
width: 2.6rem;
height: 1.2rem;
position: absolute;
top: 0;
}
.ew-left-source {
left: 0;
}
.ew-right-source {
right: 0;
}
.ew-shadow {
box-shadow: 0 0 50px 10px #535455 inset;
}
首先使用通配符选择器'*'对所有元素进行样式重置。随后,将html和body元素的宽高设置为100%,并隐藏溢出内容。为body元素设置背景图片,并采用弹性盒子布局实现内容的水平垂直居中。
中间用于消除的盒子元素box,通过设置定位属性及固定宽高为8rem来实现。
box-item代表每个单独的块元素,即消除游戏中的每一块。游戏底部设有存储已选中块元素的收集区域ew-collection,两侧还有不可见的卡牌容器元素。
最后,通过添加阴影效果来增强块元素的层叠视觉感。
javascript代码
Cascading Style Sheets核心样式较为简单,接下来重点分析JavaScript代码。
导入图片素材列表
在代码执行前,需先导入图片素材列表,具体配置如下。
const globalImageList = [
'https://www.eveningwater.com/my-web-projects/jQuery/7/img/1.jpg',
'https://www.eveningwater.com/my-web-projects/jQuery/7/img/2.jpg',
'https://www.eveningwater.com/my-web-projects/jQuery/7/img/3.jpg',
'https://www.eveningwater.com/my-web-projects/jQuery/7/img/4.jpg',
'https://www.eveningwater.com/my-web-projects/jQuery/7/img/5.jpg',
'https://www.eveningwater.com/my-web-projects/jQuery/7/img/6.jpg',
'https://www.eveningwater.com/my-web-projects/jQuery/7/img/7.jpg',
'https://www.eveningwater.com/my-web-projects/jQuery/7/img/8.jpg',
'https://www.eveningwater.com/my-web-projects/jQuery/7/img/9.jpg',
'https://www.eveningwater.com/my-web-projects/jQuery/7/img/10.jpg'
]
随后在onload页面加载事件中调用封装好的Game类,并将素材列表作为参数传入。
window.onload = () => {
const game = new Game(globalImageList);
}
接下来分析Game类的核心代码,首先定义该类。
class Game {
constructor(originSource, bindElement){
//核心代码
}
}
Game类构造函数接收两个参数:第一个是素材列表,第二个是绑定的DOM元素。若未传入第二个参数,则默认绑定到document.body。构造函数内部会初始化后续所需的变量。
//constructor内部
this.doc = document;
this.originSource = originSource;
this.bindElement = bindElement || this.doc.body;
// 存储随机打乱的元素
this.source = [];
// 存储点击的元素
this.temp = {};
// dom元素
this.box = null; //存储消消乐块元素的容器盒子元素
this.leftSource = null; //左边素材容器元素
this.rightSource = null; //右边素材容器元素
this.collection = null; //收集素材的容器元素
// 需要调用bind方法修改this指向
this.init().then(this.startHandler.bind(this)); //startHandler为游戏开始的核心逻辑函数,init初始化方法
代码中存储了document对象、原始素材列表以及绑定的DOM元素。此外,定义了source变量用于存储打乱后的素材列表,temp变量用于存储点击的元素,以便进行消除和添加阴影等操作。
另有四个变量用于存储DOM元素,具体含义如代码注释所示。
init方法负责执行初始化操作,该方法返回一个Promise对象,以便后续调用then方法。startHandler是游戏开始的核心逻辑函数,将在后文详述。需要注意的是,在then方法内部,this的指向并非Game实例,因此需要调用bind方法修改this绑定。接下来分析init方法的具体实现。
init() {
return new Promise(resolve => {
const template = `<div class="ew-box" id="ew-box"></div>
<div class="ew-left-source" id="ew-left-source"></div>
<div class="ew-right-source" id="ew-right-source"></div>
<div class="ew-collection" id="ew-collection"></div>`;
const div = this.create('div');
this.bindElement.insertBefore(div, document.body.firstChild);
this.createElement(div, template);
div.remove();
resolve();
})
}
如前所述,该方法返回一个Promise。内部定义了template模板代码以构建页面结构,随后调用create方法创建容器元素,并将其插入到body元素的第一个子元素之前。接着,在容器元素前插入创建好的页面结构,删除临时容器元素,并通过resolve返回,从而将页面元素添加到body内部。此过程涉及两个工具函数,分别分析如下。
create(name) {
return this.doc.createElement(name);
}
create方法通过调用createElement方法创建DOM元素,this.doc指向document对象。因此,create方法实质上是document.createElement的封装。接下来分析createElement方法。
createElement(el, str) {
return el.insertAdjacentHTML('beforebegin', str);
}
createElement方法接收两个参数:第一个是DOM元素,第二个是表示模板元素的字符串,用于在第一个DOM元素之前插入模板元素。具体实现可参考代码片段。
startHandler函数实现
init方法主要实现动态创建元素的功能,接下来分析startHandler函数的具体实现。
startHandler() {
this.box = this.$('#ew-box');
this.leftSource = this.$('#ew-left-source');
this.rightSource = this.$('#ew-right-source');
this.collection = this.$('#ew-collection');
this.resetHandler();
//后续还有逻辑
}
startHandler是核心逻辑,代码量较大,此处分步拆解。上述代码主要完成获取DOM元素和重置状态两项逻辑。其中涉及一个$方法,具体定义如下。
$(selector, el = this.doc) {
return el.querySelector(selector);
}
$方法接收两个参数:第一个是选择器字符串,第二个是DOM元素,实质上是document.querySelector的封装。此外还有一个$$方法,功能类似,定义如下。
$$(selector, el = this.doc) {
return el.querySelectorAll(selector);
}
接下来是resetHandler方法,定义如下。
resetHandler() {
this.box.innerHTML = '';
this.leftSource.innerHTML = '';
this.rightSource.innerHTML = '';
this.collection.innerHTML = '';
this.temp = {};
this.source = [];
}
resetHandler方法确实如其名,负责重置相关数据及DOM元素的子节点。
继续分析,在startHandler(即resetHandler之后)添加如下代码。
startHandler() {
this.box = this.$('#ew-box');
this.leftSource = this.$('#ew-left-source');
this.rightSource = this.$('#ew-right-source');
this.collection = this.$('#ew-collection');
this.resetHandler();
for (let i = 0; i < 12; i++) {
this.originSource.forEach((src, index) => {
this.source.push({
src,
index
})
})
}
this.source = this.randomList(this.source);
//后续还有逻辑
}
上述代码主要对素材数据进行添加和转换操作。randomList方法顾名思义,用于打乱素材列表的顺序。
randomList 工具方法
接下来分析该工具方法的源码。
/**
* 打乱顺序
* @param {*} arr
* @returns
*/
randomList(arr) {
const newArr = [...arr];
for (let i = newArr.length - 1; i >= 0; i--) {
const index = Math.floor(Math.random() * i + 1);
const next = newArr[index];
newArr[index] = newArr[i];
newArr[i] = next;
}
return newArr;
}
该方法的作用是将素材列表随机打乱,以实现随机性。随后继续分析后续逻辑。
startHandler() {
this.box = this.$('#ew-box');
this.leftSource = this.$('#ew-left-source');
this.rightSource = this.$('#ew-right-source');
this.collection = this.$('#ew-collection');
this.resetHandler();
for (let i = 0; i < 12; i++) {
this.originSource.forEach((src, index) => {
this.source.push({
src,
index
})
})
}
this.source = this.randomList(this.source);
//后续还有逻辑
for (let k = 5; k > 0; k--) {
for (let i = 0; i < 5; i++) {
for (let j = 0; j < k; j++) {
const item = this.create('div');
item.setAttribute('x', i);
item.setAttribute('y', j);
item.setAttribute('z', k);
item.className = `ew-box-item ew-box-${i}-${j}-${k}`;
item.style.position = 'absolute';
const image = this.source.splice(0, 1);
// 1.44为item设置的宽度与高度
item.style.left = 1.44 * j + Math.random() * .1 * k + 'rem';
item.style.top = 1.44 * i + Math.random() * .1 * k + 'rem';
item.setAttribute('index', image[0].index);
item.style.backgroundImage = `url(${image[0].src})`;
const clickHandler = () => {
// 如果是在收集框里是不能够点击的
if(item.parentElement.className === 'ew-collection'){
return;
}
// 没有阴影效果的元素才能够点击
if (!item.classList.contains('ew-shadow')) {
const currentIndex = item.getAttribute('index');
if (this.temp[currentIndex]) {
this.temp[currentIndex] += 1;
} else {
this.temp[currentIndex] = 1;
}
item.style.position = 'static';
this.collection.appendChild(item);
// 重置阴影效果
this.$$('.ew-box-item',this.box).forEach(item => item.classList.remove('ew-shadow'));
this.createShadow();
// 等于3个就消除掉
if (this.temp[currentIndex] === 3) {
this.$$(`div[index="${currentIndex}"]`, this.collection).forEach(item => item.remove());
this.temp[currentIndex] = 0;
}
let num = 0;
for (let i in this.temp) {
num += this.temp[i];
}
if (num > 7) {
item.removeEventListener('click', clickHandler);
this.gameOver();
}
}
}
item.addEventListener('click', clickHandler)
this.box.append(item);
}
}
}
}
这段代码较长,但核心可总结为两点:添加块元素,并为每个块元素绑定点击事件。在《羊了个羊》游戏中,消除的块元素具有层叠效果,本项目也采用相同原理实现。
实现层叠效果的关键在于定位。CSS定位具有层级关系,层级越高显示在上方。本项目利用这一特性,通过三层循环实现盒子元素5行5列的布局,循环次数为5。
在循环内部,创建每个块元素,并为每个元素设置x、y、z三个属性,同时添加ew-box-${i}-${j}-${k}类名。显然,x、y、z属性与该类名相关联,便于后续对元素进行操作。
每个块元素还设置了样式,类名为'ew-box-item',并统一设置为绝对定位。
注:读者可能好奇为何每个元素都添加'ew-'前缀。这仅是个人编码习惯,用于标识自己编写的代码。
随后从素材列表中取出单个素材,数据结构为{ src:'图片路径',index:'索引值' }。将该元素设置背景图为素材列表中的图片路径,并设置index属性以及left和top偏移值。left和top偏移值之所以随机,是因为每个块元素的位置是随机分布的。
接下来是clickHandler,即点击块元素时的回调函数。此处暂不详细展开,继续分析后续逻辑:利用addEventListener方法为该元素添加事件,并将块元素添加到box盒子元素中。
clickHandler函数内部
接下来分析clickHandler函数内部的具体逻辑。
首先进行如下判断。
if(item.parentElement.className === 'ew-collection'){
return;
}
逻辑很简单:当点击的元素位于收集框内时,不触发点击事件,因此需要在此处进行判断。
接着进行另一项判断:带有阴影效果的元素会多一个类名'ew-shadow'。拥有阴影效果意味着该元素层级最低,被其他元素遮挡,因此无法被点击。
随后获取当前点击块元素的index索引值,这也是在添加块元素前设置index属性的原因。
接着判断点击次数:如果点击的是同一个元素,则在temp对象中存储点击的索引值;如果点击的是不同元素,则索引值重置为1。
随后将该元素的定位设置为静态定位(默认值),并将其添加到收集框容器元素中。
createShadow方法
接下来是重置阴影效果并重新添加阴影效果。这里涉及一个createShadow方法,下面详细解析。
createShadow(){
this.$$('.ew-box-item',this.box).forEach((item,index) => {
let x = item.getAttribute('x'),
y = item.getAttribute('y'),
z = item.getAttribute('z'),
ele = this.$$(`.ew-box-${x}-${y}-${z - 1}`),
eleOther = this.$$(`.ew-box-${x + 1}-${y + 1}-${z - 1}`);
if (ele.length || eleOther.length) {
item.classList.add('ew-shadow');
}
})
}
该方法通过获取x、y、z属性设置的类名来判断是否需要添加阴影。因为通过这三个属性值可以确定元素的层级,若非最上层,则能获取到该元素,从而添加阴影。注意$$方法返回的是NodeList集合,因此可以获取length属性。
随后通过存储的索引值判断是否等于3个。若选中了3个相同的块,则从收集框中移除该三个块元素,并重置对应的index索引值为0。
接下来的for...in循环用于统计收集框内的块元素。若达到7个,代表槽位已满,游戏结束,并移除块元素的点击事件。接下来分析游戏结束方法的实现。
gameOver() {
const self = this;
ewConfirm({
title: "温馨提示",
content: "游戏结束,别灰心,你能行的!",
sureText: "重新开始",
isClickModal:false,
sure(context) {
context.close();
self.startHandler();
}
})
}
这即是前文提到的弹窗插件用法。在点击确认的回调中调用startHandler方法,表示重新开始游戏,此处不再赘述。
至此,实现了中间盒子元素与槽位容器元素的对应逻辑。接下来还有两点内容:被遮盖不可见的两侧块元素集合。继续分析startHandler后续逻辑。
startHandler后续的逻辑
startHandler() {
this.box = this.$('#ew-box');
this.leftSource = this.$('#ew-left-source');
this.rightSource = this.$('#ew-right-source');
this.collection = this.$('#ew-collection');
this.resetHandler();
for (let i = 0; i < 12; i++) {
this.originSource.forEach((src, index) => {
this.source.push({
src,
index
})
})
}
this.source = this.randomList(this.source);
for (let k = 5; k > 0; k--) {
for (let i = 0; i < 5; i++) {
for (let j = 0; j < k; j++) {
const item = this.create('div');
item.setAttribute('x', i);
item.setAttribute('y', j);
item.setAttribute('z', k);
item.className = `ew-box-item ew-box-${i}-${j}-${k}`;
item.style.position = 'absolute';
const image = this.source.splice(0, 1);
// 1.44为item设置的宽度与高度
item.style.left = 1.44 * j + Math.random() * .1 * k + 'rem';
item.style.top = 1.44 * i + Math.random() * .1 * k + 'rem';
item.setAttribute('index', image[0].index);
item.style.backgroundImage = `url(${image[0].src})`;
const clickHandler = () => {
// 如果是在收集框里是不能够点击的
if(item.parentElement.className === 'ew-collection'){
return;
}
// 没有阴影效果的元素才能够点击
if (!item.classList.contains('ew-shadow')) {
const currentIndex = item.getAttribute('index');
if (this.temp[currentIndex]) {
this.temp[currentIndex] += 1;
} else {
this.temp[currentIndex] = 1;
}
item.style.position = 'static';
this.collection.appendChild(item);
// 重置阴影效果
this.$$('.ew-box-item',this.box).forEach(item => item.classList.remove('ew-shadow'));
this.createShadow();
// 等于3个就消除掉
if (this.temp[currentIndex] === 3) {
this.$$(`div[index="${currentIndex}"]`, this.collection).forEach(item => item.remove());
this.temp[currentIndex] = 0;
}
let num = 0;
for (let i in this.temp) {
num += this.temp[i];
}
if (num >= 7) {
item.removeEventListener('click', clickHandler);
this.gameOver();
}
}
}
item.addEventListener('click', clickHandler)
this.box.append(item);
}
}
}
//从这里开始分析
let len = Math.ceil(this.source.length / 2);
this.source.forEach((item, index) => {
let div = this.create('div');
div.classList.add('ew-box-item')
div.setAttribute('index', item.index);
div.style.backgroundImage = `url(${item.src})`;
div.style.position = 'absolute';
div.style.top = 0;
if (index > len) {
div.style.right = `${(5 * (index - len)) / 100}rem`;
this.rightSource.appendChild(div);
} else {
div.style.left = `${(5 * index) / 100}rem`;
this.leftSource.appendChild(div)
}
const clickHandler = () => {
if(div.parentElement.className === 'ew-collection'){
return;
}
const currentIndex = div.getAttribute('index');
if (this.temp[currentIndex]) {
this.temp[currentIndex] += 1;
} else {
this.temp[currentIndex] = 1;
}
div.style.position = 'static';
this.collection.appendChild(div);
if (this.temp[currentIndex] === 3) {
this.$$(`div[index="${currentIndex}"]`, this.collection).forEach(item => item.remove());
this.temp[currentIndex] = 0;
}
let num = 0;
for (let i in this.temp) {
num += this.temp[i];
}
if (num >= 7) {
div.removeEventListener('click', clickHandler);
this.gameOver();
}
}
div.addEventListener('click', clickHandler);
});
this.createShadow();
}
此处主要获取source素材列表的一半,分别生成对应的左右素材列表。同理,两侧块元素的点击事件逻辑与块容器元素内的逻辑相似,此处不再详述。主要关注以下代码段。
let div = this.create('div');
div.classList.add('ew-box-item');
div.setAttribute('index', item.index);
div.style.backgroundImage = `url(${item.src})`;
div.style.position = 'absolute';
div.style.top = 0;
if (index > len) {
div.style.right = `${(5 * (index - len)) / 100}rem`;
this.rightSource.appendChild(div);
} else {
div.style.left = `${(5 * index) / 100}rem`;
this.leftSource.appendChild(div)
}
这段代码逻辑清晰:同样创建块元素,根据index > len判断是添加到右侧素材容器还是左侧素材元素。它们的top偏移量应保持一致,主要区别在于left和right的计算方式,计算逻辑较为简单。
注意,此处无需设置x、y、z属性,因为不需要调用设置阴影的函数。
至此,一个《羊了个羊——动物版》的小游戏便完成了。
如有兴趣,可参考源码。
更多关于JavaScript羊了个羊小游戏的资料,请关注本站其他相关文章!