JS实现羊了个羊小游戏实例

作者:拾光时间:2026-10-26 05:55:50浏览:186

本文介绍如何使用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) =&gt; {
        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羊了个羊小游戏的资料,请关注本站其他相关文章!

作者最新文章