前端每日實戰:163# 視頻演示如何用原生 JS 創做一個多選一場景的交互遊戲(內含 3 個視頻)

圖片描述

效果預覽

按下右側的「點擊預覽」按鈕能夠在當前頁面預覽,點擊連接能夠全屏預覽。javascript

https://codepen.io/comehope/pen/LXMzRXcss

可交互視頻

此視頻是能夠交互的,你能夠隨時暫停視頻,編輯視頻中的代碼。html

請用 chrome, safari, edge 打開觀看。前端

第 1 部分:
https://scrimba.com/p/pEgDAM/cQK3bSpjava

第 2 部分:
https://scrimba.com/p/pEgDAM/cNJWncRgit

第 3 部分:
https://scrimba.com/p/pEgDAM/cvgP8tdgithub

源代碼下載

每日前端實戰系列的所有源代碼請從 github 下載:ajax

https://github.com/comehope/front-end-daily-challengeschrome

代碼解讀

多選一的場景是很常見的,瀏覽器自帶的 <input type="radio"> 控件就適用於這樣的場景。本項目將設計一個多選一的交互場景,用 css 進行頁面佈局、用 gsap 製做動畫效果、用原生 js 編寫程序邏輯。segmentfault

這個遊戲的邏輯很簡單,在頁面上部展現出一個動物的全身像,請用戶在下面的小圖片中選擇這個動物對應的頭像,若是選對了,就能夠再玩一次。

整個遊戲分紅 3 個步驟開發:靜態的頁面佈局、程序邏輯和動畫效果。

1、頁面佈局

定義 dom 結構,容器中包含標題 h1、全身像 .whole-body、當選擇正確時的提示語 .bingo、「再玩一次」按鈕 .again、一組選擇按鈕 .selector.selector 中包含 5 個展現頭像的 .face 和 1 個標明當前被選中頭像的 .slider。全身像和頭像沒有使用圖片,都用 unicode 字符代替:

<div class="app">
        <h1>Which face is the animal's?</h1>
        <div class="whole-body">🐄</div>
        <div class="bingo">
            Bingo!
            <span class="again">Play Again</span>
        </div>
        <div class="selector">
            <span class="slider"></span>

            <span class="face">🐭</span>    
            <span class="face">🐶</span>
            <span class="face">🐷</span>
            <span class="face">🐮</span>
            <span class="face">🐯</span>
        </div>
    </div>

居中顯示:

body {
    margin: 0;
    height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    background: linear-gradient(darkblue, black);
}

定義容器中子元素的按縱向佈局,水平居中:

.app {
    height: 420px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: space-between;
}

標題是白色文字:

h1 {
    margin: 0;
    color: white;
}

全身像爲大尺寸的圓形,利用陰影畫一個半透明的粗邊框:

.whole-body {
    width: 200px;
    height: 200px;
    background-color: rgb(180, 220, 255);
    border-radius: 50%;
    font-size: 140px;
    text-align: center;
    line-height: 210px;
    margin-top: 20px;
    box-shadow: 0 0 0 15px rgba(180, 220, 255, 0.2);
    user-select: none;
}

選擇正確時的提示語爲白色:

.bingo {
    color: white;
    font-size: 30px;
    font-family: sans-serif;
    margin-top: 20px;
}

「再玩一次」按鈕的字體稍小,在鼠標懸停和點擊時有交互效果:

.again {
    display: inline-block;
    font-size: 20px;
    background-color: white;
    color: darkblue;
    padding: 5px;
    border-radius: 5px;
    box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.4);
    user-select: none;
}

.again:hover {
    background-color: rgba(255, 255, 255, 0.8);
    cursor: pointer;
}

.again:active {
    transform: translate(2px, 2px);
    box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.4);
}

5 個頭像爲小尺寸的圓形,橫向排列,半透明背景:

.selector {
    display: flex;
}

.face {
    width: 60px;
    height: 60px;
    background-color: rgba(255, 255, 255, 0.2);
    border-radius: 50%;
    font-size: 40px;
    text-align: center;
    line-height: 70px;
    cursor: pointer;
    user-select: none;
}

.face:not(:last-child) {
    margin-right: 25px;
}

在被選中的頭像下面疊加一個同尺寸的淺藍色色塊:

.selector {
    position: relative;
}

.slider {
    position: absolute;
    width: 60px;
    height: 60px;
    background-color: rgba(180, 220, 255, 0.6);
    border-radius: 50%;
    z-index: -1;
}

至此,頁面佈局完成。

2、程序邏輯

引入 lodash 工具庫,後面會用到 lodash 提供的一些數組函數:

<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.min.js"></script>

在寫程序邏輯以前,咱們先定義 2 個常量。
第一個常量是存儲動物頭像和全身像的數據對象 animals,它的每一個屬性是 1 種動物,key 是頭像,value 是全身像:

const animals = {
    '🐭': '🐁',
    '🐶': '🐕',
    '🐷': '🐖',
    '🐮': '🐄',
    '🐯': '🐅',
    '🐔': '🐓',
    '🐵': '🐒',
    '🐲': '🐉',
    '🐴': '🐎',
    '🐰': '🐇',
}

第二個常量是存儲 dom 元素引用的數據對象 dom,它的每一個屬性是一個 dom 元素,key 值與 class 類名保持一致,分別是表明全身像的 dom.wholeBody、表明選擇正確時的提示信息 dom.bingo、表明「再玩一次」按鈕的 dom.bingo、表明頭像列表的 dom.faces、表明頭像下面的滑塊 dom.slider

const dom = {
    wholeBody: document.querySelector('.whole-body'),
    bingo: document.querySelector('.bingo'),
    again: document.querySelector('.again'),
    faces: Array.from(document.querySelectorAll('.face')),
    slider: document.querySelector('.slider'),
}

接下來定義總體的邏輯結構,當頁面加載完成以後執行 init() 函數,init() 函數會對整個遊戲作些初始化的工做 ———— 令頭像 dom.faces 被點擊時調用 select() 函數,令「再玩一次」按鈕 dom.again 被點擊時調用 newGame() 函數 ———— 最後調用 newGame() 函數開始一局新遊戲:

function newGame() {
    //...
}

function select() {
    //...
}

function init() {
    dom.faces.forEach(face => {
        face.addEventListener('click', select)
    })
    dom.again.addEventListener('click', newGame)
    newGame()
}

window.onload = init

newGame() 函數中調用 shuffle() 函數。shuffle() 函數的做用是隨機地從 animals 數組中選出 5 個動物,把它們的頭像顯示在 dom.faces 中,再從中選出 1 個動物,把它的全身像顯示在 dom.wholeBody 中。變量 options 表明被選出的 5 個動物,變量 answer 表明顯示全身像的動物,由於後面還會用到 optionsanswer,因此把它們定義爲全局變量。通過 _.entries() 函數的處理,options 數組的元素和 answer 的數據結構變爲包含 2 個元素的數組 [key, value] 形式,其中第 [0] 個元素是頭像,第 [1] 個元素是全身像:

let options = []
let answer = {}

function newGame() {
    shuffle()
}

function shuffle() {
    options = _.slice(_.shuffle(_.entries(animals)), -5)
    answer = _.sample(_.slice(options, -4))

    dom.faces.forEach((face, i) => {
        face.innerText = options[i][0]
    })
    dom.wholeBody.innerText = answer[1]
}

如今,每點擊一次 Play Again 按鈕,就會洗牌、更新圖片。
接下來處理滑塊。在 select() 函數中,首先把滑塊 dom.slider 移動到被點擊的頭像位置:

function select(e) {
    let position = _.findIndex(options, (o) => o[0] == e.target.innerText)
    dom.slider.style.left = (25 + 60) * position + 'px'
}

而後判斷當前頭像對應的全身像和頁面上方全身像是否一致,若一致,就顯示提示語 dom.bingo。在此以前,要把提示語隱藏掉:

function newGame() {
    dom.bingo.style.visibility = 'hidden'
    shuffle()
}

function select(e) {
    let position = _.findIndex(options, (o) => o[0] == e.target.innerText)
    dom.slider.style.left = (25 + 60) * position + 'px'

    if (animals[e.target.innerText] == answer[1]) {
        dom.bingo.style.visibility = 'visible'
    }
}

如今,遊戲開局時是沒有提示語的,只有選對了頭像,纔會出提示語。
不過出現了一個bug,就是當重開新局時,滑塊還停留在上一局的位置,咱們要改爲開局時把滑塊 dom.slider 移到頭像列表的最左側:

function newGame() {
    dom.bingo.style.visibility = 'hidden'
    shuffle()
    dom.slider.style.left = '0px'
}

如今,整個程序流程已經能夠跑通了:頁面加載後即開始一局遊戲,任意選擇頭像,在選擇了正確的頭像時出現 Bingo! 字樣,點擊 Play Again 按鈕能夠開始下一局遊戲。
不過,在邏輯上還有一點小瑕疵。當用戶已經選擇了正確的頭像,顯示出提示語以後,不該該還能點選其餘頭像。爲此,咱們引入一個全局變量 canSelect,它是一個布爾值,表示當前是否能夠選擇頭像,初始值是 false,在 newGame() 函數的最後一步,它的值被設置爲 true,在 select() 函數中首先判斷 canSelect 的值,只有當值爲 true 時,才能繼續執行事件處理的後續程序,當用戶選擇了正確的頭像時,canSelect 被設置爲 false,表示這一局遊戲結束了。

let canSelect = false

function newGame() {
    dom.bingo.style.visibility = 'hidden'
    shuffle()
    dom.slider.style.left = '0px'
    canSelect = true
}

function select(e) {
    if (!canSelect) return;
    
    let position = _.findIndex(options, x => x[0] == e.target.innerText)
    dom.slider.style.left = (25 + 60) * position + 'px'
    
    if (animals[e.target.innerText] == answer[1]) {
        canSelect = false
        dom.bingo.style.visibility = 'visible'
    }
}

至此的所有腳本以下:

const animals = {
    '🐭': '🐁',
    '🐶': '🐕',
    '🐷': '🐖',
    '🐮': '🐄',
    '🐯': '🐅',
    '🐔': '🐓',
    '🐵': '🐒',
    '🐲': '🐉',
    '🐴': '🐎',
    '🐰': '🐇',
}

const dom = {
    wholeBody: document.querySelector('.whole-body'),
    bingo: document.querySelector('.bingo'),
    again: document.querySelector('.again'),
    faces: Array.from(document.querySelectorAll('.face')),
    slider: document.querySelector('.slider'),
}

let options = []
let answer = {}
let canSelect = false

function newGame() {
    dom.bingo.style.visibility = 'hidden'
    shuffle()
    dom.slider.style.left = '0px'
    canSelect = true
}

function shuffle() {
    options = _.slice(_.shuffle(_.entries(animals)), -5)
    answer = _.sample(_.slice(options, -4))

    dom.faces.forEach((face, i) => {
        face.innerText = options[i][0]
    })
    dom.wholeBody.innerText = answer[1]
}

function select(e) {
    if (!canSelect) return;
    
    let position = _.findIndex(options, x => x[0] == e.target.innerText)
    dom.slider.style.left = (25 + 60) * position + 'px'
    
    if (animals[e.target.innerText] == answer[1]) {
        canSelect = false
        dom.bingo.style.visibility = 'visible'
    }
}

function init() {
    dom.faces.forEach(face => {
        face.addEventListener('click', select)
    })
    dom.again.addEventListener('click', newGame)
    newGame()
}

window.onload = init

3、動畫效果

遊戲中共有 4 個動畫效果,分別是移動滑塊 dom.slider、顯示提示語 dom.bingo、動物(包括頭像列表和全身像)出場、動物入場。爲了集中管理動畫效果,咱們定義一個全局常量 animation,它有 4 個屬性,每一個屬性是一個函數,實現一個動畫效果,結構以下:

const animation = {
    moveSlider: () => {
        //移動滑塊...
    },
    showBingo: () => {
        //顯示提示語...
    },
    frameOut: () => {
        //動物出場...
    },
    frameIn: () => {
        //動物入場...
    },
}

其實這 4 個動畫的運行時機已經體如今 newGame() 函數和 select() 函數中了:

function newGame() {
    dom.bingo.style.visibility = 'hidden' //此處改成 動物出場 動畫
    shuffle()
    dom.slider.style.left = '0px' //此處改成 動物入場 動畫
    canSelect = true
}

function select(e) {
    if (!canSelect) return;

    let position = _.findIndex(options, (o) => o[0] == e.target.innerText)
    dom.slider.style.left = (25 + 60) * position + 'px' //此處改成 移動滑塊 動畫

    if (animals[e.target.innerText] == answer[1]) {
        canSelect = false
        dom.bingo.style.visibility = 'visible' //此處改成 顯示提示語 動畫
    }
}

因此,咱們就能夠把這 4 行代碼轉移到 animation 中,其中 moveSlider() 還增長了一個指明要移動到什麼位置的 position 參數:

const animation = {
    moveSlider: (position) => {
        dom.slider.style.left = (25 + 60) * position + 'px'
    },
    showBingo: () => {
        dom.bingo.style.visibility = 'visible'
    },
    frameOut: () => {
        dom.bingo.style.visibility = 'hidden'
    },
    frameIn: () => {
        dom.slider.style.left = '0px'
    },
}

同時,newGame() 函數和 select() 函數改成調用 animation

function newGame() {
    animation.frameOut()
    shuffle()
    animation.frameIn()
    canSelect = true
}

function select(e) {
    if (!canSelect) return;
    
    let position = _.findIndex(options, (o) => o[0] == e.target.innerText)
    animation.moveSlider(position)
    
    if (animals[e.target.innerText] == answer[1]) {
        canSelect = false
        animation.showBingo()
    }
}

通過上面的整理,接下來的動畫代碼就能夠集中寫在 animation 對象裏了。
本項目的動畫效果用 gsap 實現,gsap 動畫在之前的 133#項目134#項目143#項目 都用到了,你們可參考這些項目瞭解 gsap 的使用方法。

引入 gsap 動畫庫:

<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/2.0.2/TweenMax.min.js"></script>

先編寫移動滑塊的動畫 moveSlider,讓滑塊先縮小,而後移動到目的地,再放大:

const animation = {
    moveSlider: () => {
        new TimelineMax()
            .to(dom.slider, 1, {scale: 0.3})
            .to(dom.slider, 1, {left: (25 + 60) * position + 'px'})
            .to(dom.slider, 1, {scale: 1})
            .timeScale(5)
    },
    //...
}

再編寫顯示提示語的動畫 showBingo,顯示出 dom.bingo 以後,讓它左右晃動一下:

const animation = {
    //...
    showBingo: () => {
        new TimelineMax()
            .to(dom.bingo, 0, {visibility: 'visible'})
            .to(dom.bingo, 1, {rotation: -5})
            .to(dom.bingo, 1, {rotation: 5})
            .to(dom.bingo, 1, {rotation: 0})
            .timeScale(8)
    },
    //...
}

再編寫動物出場的動畫,隱藏提示語 dom.bingo 以後,再同時把滑塊 dom.slider、頭像列表 dom.faces、全身像 dom.wholeBody 同時縮小到消失:

const animation = {
    //...
    frameOut: () => {
        new TimelineMax()
            .to(dom.bingo, 0, {visibility: 'hidden'})
            .to(dom.slider, 1, {scale: 0}, 't1')
            .staggerTo(dom.faces, 1, {scale: 0}, 0.25, 't1')
            .to(dom.wholeBody, 1, {scale: 0}, 't1')
            .timeScale(5)
    },
    //...
}

再編寫動物入場的動畫,把滑塊移到頭像列表最左側以後,再把剛纔出場動畫縮小到消失的那些元素放大到正常尺寸:

const animation = {
    //...
    frameIn: () => {
        new TimelineMax()
            .to(dom.slider, 0, {left: '0px'})
            .to(dom.wholeBody, 2, {scale: 1, delay: 1})
            .staggerTo(dom.faces, 1, {scale: 1}, 0.25)
            .to(dom.slider, 1, {scale: 1})
            .timeScale(5)
    },
}

如今運行一下程序,已經有動畫效果了,可是會以爲有些不協調,那是由於動畫有必定的運行時長,多個動畫連續運行時應該有前後順序,好比應該先出場再入場、先移動滑塊再顯示提示語,但如今它們都是同時運行的。爲了讓它們能順序執行,咱們用 async/await 來改造,先讓動畫函數返回 promise 對象,以 moveSlider 爲例,它被改爲這樣:

const animation = {
    moveSlider: () => {
        return new Promise(resolve => {
            new TimelineMax()
                .to(dom.slider, 1, {scale: 0.3})
                .to(dom.slider, 1, {left: (25 + 60) * position + 'px'})
                .to(dom.slider, 1, {scale: 1})
                .timeScale(5)
                .eventCallback('onComplete', resolve)
        })
    },
    //...
}

而後把 select() 函數改形成 async 函數,並在調用動畫以前加入 await 關鍵字:

async function select(e) {
    if (!canSelect) return;
    
    let position = _.findIndex(options, (o) => o[0] == e.target.innerText)
    await animation.moveSlider(position)
    
    if (animals[e.target.innerText] == answer[1]) {
        canSelect = false
        animation.showBingo()
    }
}

如今點擊頭像時,若選擇正確,要等到滑塊動畫結束以後纔會顯示提示語。再用相同的方法,改造其餘幾個動畫和 select() 函數。到這裏,整個遊戲的動畫效果就所有完成了。至此的所有腳本以下:

const animals = {
    //略,與增長動畫前相同
}

const dom = {
    //略,與增長動畫前相同
}

const animation = {
    frameOut: () => {
        return new Promise(resolve => {
            new TimelineMax()
                .to(dom.bingo, 0, {visibility: 'hidden'})
                .to(dom.slider, 1, {scale: 0}, 't1')
                .staggerTo(dom.faces, 1, {scale: 0}, 0.25, 't1')
                .to(dom.wholeBody, 1, {scale: 0}, 't1')
                .timeScale(5)
                .eventCallback('onComplete', resolve)
        })
    },
    frameIn: () => {
        return new Promise(resolve => {
            new TimelineMax()
                .to(dom.slider, 0, {left: '0px'})
                .to(dom.wholeBody, 2, {scale: 1, delay: 1})
                .staggerTo(dom.faces, 1, {scale: 1}, 0.25)
                .to(dom.slider, 1, {scale: 1})
                .timeScale(5)
                .eventCallback('onComplete', resolve)
        })
    },
    moveSlider: (position) => {
        return new Promise(resolve => {
            new TimelineMax()
                .to(dom.slider, 1, {scale: 0.3})
                .to(dom.slider, 1, {left: (25 + 60) * position + 'px'})
                .to(dom.slider, 1, {scale: 1})
                .timeScale(5)
                .eventCallback('onComplete', resolve)
        })
    },
    showBingo: () => {
        return new Promise(resolve => {
            new TimelineMax()
                .to(dom.bingo, 0, {visibility: 'visible'})
                .to(dom.bingo, 1, {rotation: -5})
                .to(dom.bingo, 1, {rotation: 5})
                .to(dom.bingo, 1, {rotation: 0})
                .timeScale(8)
                .eventCallback('onComplete', resolve)
        })
    },
}

let options = []
let answer = {}
let canSelect = false

async function newGame() {
    await animation.frameOut()
    shuffle()
    await animation.frameIn()
    canSelect = true
}

function shuffle() {
    //略,與增長動畫前相同
}

async function select(e) {
    if (!canSelect) return;
    
    let position = _.findIndex(options, (o) => o[0] == e.target.innerText)
    await animation.moveSlider(position)
    
    if (animals[e.target.innerText] == answer[1]) {
        canSelect = false
        await animation.showBingo()
    }
}

function init() {
    //略,與增長動畫前相同
}

window.onload = init

大功告成!

4、程序流程圖

最後,附上程序流程圖,方便你們理解。其中藍色條帶表示動畫,粉色橢圓表示用戶操做,綠色矩形和菱形表示主要的程序邏輯,橙色平等四邊形表示 canSelect 變量。

圖片描述

相關文章
相關標籤/搜索