首頁 > 軟體

JS實現羊了個羊小遊戲範例

2022-09-18 22:01:08

引言

這兩天火爆全場的《羊了個羊》遊戲,相信大家都玩過了,那麼在玩這個遊戲的同時,我想大家都會好奇這個遊戲的實現,本文就帶大家使用css,html,js來實現一個動物版的遊戲。

首先我用到了2個外掛,第一個外掛就是flexible.js,這個外掛就是對不同裝置設定根元素字型大小,也就是一個行動端的適配方案。

因為這裡使用了rem佈局,針對行動端做了自適應,所以這裡選擇採用rem佈局方案。

rem佈局方案

還有一個彈框外掛,我很早自行實現的,就是popbox.js,關於這個外掛,本文不打算講解實現原理,只講解一下使用原理:

popbox.js使用原理

ewConfirm({
    title: "溫馨提示", //彈框標題
    content: "遊戲結束,別灰心,你能行的!", //彈框內容
    sureText: "重新開始", //確認按鈕文字
    isClickModal:false, //點選遮罩層是否關閉彈框
    sure(context) {
        context.close();
        //點選確認按鈕執行的邏輯
    },//點選確認的事件回撥
})

引入了這個js之後,會在window物件上繫結一個ewConfirm方法,這個方法傳入一個自定義物件,物件的屬性有title,content,sureText,cancelText,cancel,sure,isClickModal這幾個屬性,當然這裡沒有用到cancel按鈕,所以不細講。

正如註釋所說,每個屬性代表的意思,這裡不做贅述。

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元素,我們都放在js程式碼裡面動態生成了,所以script.js這裡的程式碼是核心,這個後續會講到,接下來看樣式程式碼,也比較簡單。

樣式程式碼

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}
body,
html {
    height: 100%;
    width: 100%;
    overflow: hidden;
}
body {
    background: url('https://s3.ap-northeast-1.wasabisys.com/img.it145.com/202209/2lgjuz4qehtw.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://s3.ap-northeast-1.wasabisys.com/img.it145.com/202209/20d6c430c2496590f224jpauo13zbbb.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元素設定了一個背景圖,並且body元素採用彈性盒子佈局,水平垂直居中。

接下來是中間消除的盒子元素box,也很簡單就是設定定位,和固定寬高為8rem。

接下來是box-item,代表每一個塊元素,也就是消消樂的每一塊元素,接著羊了個羊底部有一個儲存選中塊元素的收集盒子元素,也就是ew-collection,然後是左右的看不到層級的卡牌容器元素。

最後就是為了讓塊元素看起來有層疊效果而新增的陰影效果。

javascript程式碼

css核心程式碼也就比較簡單,接下來我們來看javascript程式碼。

匯入圖片素材列表

在開始之前,我們需要先匯入圖片素材列表,這裡如下:

const globalImageList = [
    'https://s3.ap-northeast-1.wasabisys.com/img.it145.com/202209/1423tu3aossh.jpg',
    'https://s3.ap-northeast-1.wasabisys.com/img.it145.com/202209/2gy4tx143gcj.jpg',
    'https://s3.ap-northeast-1.wasabisys.com/img.it145.com/202209/3ue42jklflzp.jpg',
    'https://s3.ap-northeast-1.wasabisys.com/img.it145.com/202209/4qe04rwszqzq.jpg',
    'https://s3.ap-northeast-1.wasabisys.com/img.it145.com/202209/5whuqbyxocjj.jpg',
    'https://s3.ap-northeast-1.wasabisys.com/img.it145.com/202209/6qwfi1qxf3ar.jpg',
    'https://s3.ap-northeast-1.wasabisys.com/img.it145.com/202209/7oi0wk1ffmn3.jpg',
    'https://s3.ap-northeast-1.wasabisys.com/img.it145.com/202209/8t4dgatfgkq2.jpg',
    'https://s3.ap-northeast-1.wasabisys.com/img.it145.com/202209/9dubdoy5ztuo.jpg',
    'https://s3.ap-northeast-1.wasabisys.com/img.it145.com/202209/100im34fho4bg.jpg'
]

然後在onload也就是頁面載入時呼叫我們封裝好的Game類,將這個素材列表傳入其中。如下:

window.onload = () => {
    const game = new Game(globalImageList);
}

接下來,我們來看Game類的核心程式碼吧,首先定義這個類:

class Game {
    constructor(originSource, bindElement){
        //核心程式碼
    }
}

這個類名有2個引數,第一個引數就是素材列表,第二個引數則是繫結的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是遊戲開始的核心邏輯函數,這個後面會講到,注意這裡有一個有意思的點,那就是bind(this),因為在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方法傳入2個引數,第一個引數是一個DOM元素,第二個引數是一個DOM元素字串,表示在第一個DOM元素之前插入傳入的模板元素。這個方法可以參考code-segment

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是核心實現,所以不可能只有上面那麼點程式碼,但是我們要寫一步步的拆分,以上的程式碼就做了2個邏輯,獲取DOM元素和重置。這裡涉及到了一個$方法,如下:

$(selector, el = this.doc) {
    return el.querySelector(selector);
}

$方法傳入2個引數,第一個引數為選擇器,是一個字串,第二個引數為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);
            }
        }
    }
}

這裡的程式碼很長,但是總結下來就二點,新增塊元素,併為每個塊元素繫結點選事件。我們知道羊了個羊每一個消除的塊元素都會有層疊的效果,那麼我們這裡也要實現同樣的效果,如何實現呢?

答案就是定位,我們應該知道定位會分為層級關係,層級越高就會佔上面,這裡也是採用同樣的道理,這裡之所以用3個迴圈,就是盒子元素是分成5行5列的,所以也就是為什麼迴圈是5的原因。

然後在迴圈內部,我們就是建立每一個塊元素,每個元素都設定了x,y,z三個屬性,並且還新增了ew-box-${i}-${j}-${k}類名,很顯然這裡的x,y,z屬性和這個類名關聯上了,這方便我們後續對元素進行操作。

同樣的每個塊元素我們也設定了樣式,類名是'ew-box-item',同樣的每個塊元素也設定為絕對定位。

PS: 大家可能有些好奇為什麼每個元素我都加一個'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方法表示重新開始遊戲,這沒什麼好說的。

到這裡,我們實現了中間盒子元素的每一個塊元素與槽位容器元素的對應邏輯,接下來還有2點,那就是被遮蓋看不到層級的兩邊塊元素集合。所以繼續看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屬性的,因為不需要用到設定陰影的函數。

到此為止,我們一個《羊了個羊——動物版》的小遊戲就完成了。

如有興趣可以參考原始碼

更多關於jS 羊了個羊小遊戲的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com