前端每日實戰:164# 視頻演示如何用原生 JS 和 GSAP 創作一個數獨訓練小遊戲(內含 4 個視頻)

圖片描述

效果預覽

按下右側的“點擊預覽”按鈕可以在當前頁面預覽,點擊鏈接可以全屏預覽。

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

可交互視頻

此視頻是可以交互的,你可以隨時暫停視頻,編輯視頻中的代碼。

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

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

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

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

第 4 部分:
https://scrimba.com/p/pEgDAM/cez34fp

源代碼下載

每日前端實戰系列的全部源代碼請從 github 下載:

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

代碼解讀

解數獨的一項基本功是能迅速判斷一行、一列或一個九宮格中缺少哪幾個數字,本項目就是一個訓練判斷九宮格中缺少哪個數字的小遊戲。遊戲的流程是:先選擇遊戲難度,有 Easy、Normal、Hard 三檔,分別對應着九宮格中缺少 1 個、2 個、3 個數字。開始遊戲後,用鍵盤輸入九宮格中缺少的數字,如果全答出來了,就會進入下一局,一共 5 局,5 局結束之後這一次遊戲就結束了。在遊戲過程中,九宮格的左上角會計時,右上角會計分。

整個遊戲分成 4 個步驟開發:靜態頁面佈局、程序邏輯、計分計時和動畫效果。

一、頁面佈局

定義 dom 結構,.app 是整個應用的容器,h1 是遊戲標題,.game 是遊戲的主界面。.game 中的子元素包括 .message.digits.message 用來提示遊戲時間 .time、遊戲的局數 .round、得分 .score.digits 裏是 9 個數字:

<div class="app">
    <h1>Sudoku Training</h1>
    <div class="game">
        <div class="message">
            <p>
                Time:
                <span class="time">00:00</span>
            </p>
            <p class="round">1/5</p>
            <p>
                Score:
                <span class="score">100</span>
            </p>
        </div>
        <div class="digits">
            <span>1</span>
            <span>2</span>
            <span>3</span>
            <span>4</span>
            <span>5</span>
            <span>6</span>
            <span>7</span>
            <span>8</span>
            <span>9</span>
        </div>
    </div>
</div>

居中顯示:

body {
    margin: 0;
    height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    background: silver;
    overflow: hidden;
}

定義應用的寬度,子元素縱向佈局:

.app {
    width: 300px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: space-between;
    user-select: none;
}

標題爲棕色字:

h1 {
    margin: 0;
    color: sienna;
}

提示信息是橫向佈局,重點內容加粗:

.game .message {
    width: inherit;
    display: flex;
    justify-content: space-between;
    font-size: 1.2em;
    font-family: sans-serif;
}

.game .message span {
    font-weight: bold;
}

九宮格用 grid 佈局,外框棕色,格子用杏白色背景:

.game .digits {
    box-sizing: border-box;
    width: 300px;
    height: 300px;
    padding: 10px;
    border: 10px solid sienna;
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    grid-gap: 10px;
}

.game .digits span {
    width: 80px;
    height: 80px;
    background-color: blanchedalmond;
    font-size: 30px;
    font-family: sans-serif;
    text-align: center;
    line-height: 2.5em;
    color: sienna;
    position: relative;
}

至此,遊戲區域佈局完成,接下來佈局選擇遊戲難度的界面。
在 html 文件中增加 .select-level dom 結構,它包含一個難度列表 levels 和一個開始遊戲的按鈕 .play,遊戲難度分爲 .easy.normal.hard 三個級別:

<div class="app">
    <h1>Sudoku Training</h1>
    <div class="game">
        <!-- 略 -->
    </div>
    <div class="select-level">
        <div class="levels">
            <input type="radio" name="level" id="easy" value="easy" checked="checked">
            <label for="easy">Easy</label>

            <input type="radio" name="level" id="normal" value="normal">
            <label for="normal">Normal</label>

            <input type="radio" name="level" id="hard" value="hard">
            <label for="hard">Hard</label>
        </div>
        <div class="play">Play</div>
    </div>
</div>

爲選擇遊戲難度容器畫一個圓形的外框,子元素縱向佈局:

.select-level {
    z-index: 2;
    box-sizing: border-box;
    width: 240px;
    height: 240px;
    border: 10px solid rgba(160, 82, 45, 0.8);
    border-radius: 50%;
    box-shadow: 
        0 0 0 0.3em rgba(255, 235, 205, 0.8),
        0 0 1em 0.5em rgba(160, 82, 45, 0.8);
    display: flex;
    flex-direction: column;
    align-items: center;
    font-family: sans-serif;
}

佈局 3 個難度選項,橫向排列:

.select-level .levels {
    margin-top: 60px;
    width: 190px;
    display: flex;
    justify-content: space-between;
}

input 控件隱藏起來,只顯示它們對應的 label

.select-level .levels {
    position: relative;
}

.select-level input[type=radio] {
    visibility: hidden;
    position: absolute;
    left: 0;
}

設置 label 的樣式,爲圓形按鈕:

.select-level label {
    width: 56px;
    height: 56px;
    background-color: rgba(160, 82, 45, 0.8);
    border-radius: 50%;
    text-align: center;
    line-height: 56px;
    color: blanchedalmond;
    cursor: pointer;
}

當某個 label 對應的 input 被選中時,令 label 背景色加深,以示區別:

.select-level input[type=radio]:checked + label {
    background-color: sienna;
}

設置開始遊戲按鈕 .play 的樣式,以及交互效果:

.select-level .play {
    width: 120px;
    height: 30px;
    background-color: sienna;
    color: blanchedalmond;
    text-align: center;
    line-height: 30px;
    border-radius: 30px;
    text-transform: uppercase;
    cursor: pointer;
    margin-top: 30px;
    font-size: 20px;
    letter-spacing: 2px;
}

.select-level .play:hover {
    background-color: saddlebrown;
}

.select-level .play:active {
    transform: translate(2px, 2px);
}

至此,選擇遊戲難度的界面佈局完成,接下來佈局遊戲結束界面。
遊戲結束區 .game-over 包含一個 h2 標題,二行顯示最終結果的段落 p 和一個再玩一次的按鈕 .again。最終結果包括最終耗時 .final-time 和最終得分 .final-score

<div class="app">
        <h1>Sudoku Training</h1>
        <div class="game">
            <!-- 略 -->
        </div>
        <div class="select-level">
            <!-- 略 -->
        </div>
        <div class="game-over">
            <h2>Game Over</h2>
            <p>
                Time:
                <span class="final-time">00:00</span>
            </p>
            <p>
                Score:
                <span class="final-score">3000</span>
            </p>
            <div class="again">Play Again</div>
        </div>
    </div>

因爲遊戲結束界面和選擇遊戲難度界面的佈局相似,所以借用 .select-level 的代碼:

.select-level,
.game-over {
    z-index: 2;
    box-sizing: border-box;
    width: 240px;
    height: 240px;
    border: 10px solid rgba(160, 82, 45, 0.8);
    border-radius: 50%;
    box-shadow: 
        0 0 0 0.3em rgba(255, 235, 205, 0.8),
        0 0 1em 0.5em rgba(160, 82, 45, 0.8);
    display: flex;
    flex-direction: column;
    align-items: center;
    font-family: sans-serif;
}

標題和最終結果都用棕色字:

.game-over h2 {
    margin-top: 40px;
    color: sienna;
}

.game-over p {
    margin: 3px;
    font-size: 20px;
    color: sienna;
}

“再玩一次”按鈕 .again 的樣式與開始遊戲 .play 的樣式相似,所以也借用 .play 的代碼:

.select-level .play,
.game-over .again {
    width: 120px;
    height: 30px;
    background-color: sienna;
    color: blanchedalmond;
    text-align: center;
    line-height: 30px;
    border-radius: 30px;
    text-transform: uppercase;
    cursor: pointer;
}

.select-level .play {
    margin-top: 30px;
    font-size: 20px;
    letter-spacing: 2px;
}

.select-level .play:hover,
.game-over .again:hover {
    background-color: saddlebrown;
}

.select-level .play:active,
.game-over .again:active {
    transform: translate(2px, 2px);
}

.game-over .again {
    margin-top: 10px;
}

把選擇遊戲難度界面 .select-level 和遊戲結束界面 .game-over 定位到遊戲容器的中間位置:

.app {
    position: relative;
}

.select-level,
.game-over {
    position: absolute;
    bottom: 40px;
}

至此,遊戲界面 .game、選擇遊戲難度界面 .select-level 和遊戲結束界面 .game-over 均已佈局完成。接下來爲動態程序做些準備工作。
把選擇遊戲難度界面 .select-level 和遊戲結束界面 .game-over 隱藏起來,當需要它們呈現時,會在腳本中設置它們的 visibility 屬性:

.select-level,
.game-over {
    visibility: hidden;
}

遊戲中,當選擇遊戲難度界面 .select-level 和遊戲結束界面 .game-over 出現時,應該令遊戲界面 .game 變模糊,並且加一個緩動時間,.game.stop 會在腳本中調用:

.game {
    transition: 0.3s;
}

.game.stop {
    filter: blur(10px);
}

遊戲中,當填錯了數字時,要把錯誤的數字描一個紅邊;當填對了數字時,把數字的背景色改爲巧克力色。.game .digits span.wrong.game .digits span.correct 會在腳本中調用:

.game .digits span.wrong {
    border: 2px solid crimson;
}

.game .digits span.correct {
    background-color: chocolate;
    color: gold;
}

至此,完成全部佈局和樣式設計。

二、程序邏輯

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

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

在寫程序邏輯之前,先定義幾個存儲業務數據的常量。ALL_DIGITS 存儲了全部備選的數字,也就是從 1 到 9;ANSWER_COUNT 存儲的是不同難度要回答的數字個數,easy 難度要回答 1 個數字,normal 難度要回答 2 個數字,hard 難度要回答 3 個數字;ROUND_COUNT 存儲的是每次遊戲的局數,默認是 5 局;SCORE_RULE 存儲的是答對和答錯時分數的變化,答對加 100 分,答錯扣 10 分。定義這些常量的好處是避免在程序中出現魔法數字,提高程序可讀性:

const ALL_DIGITS = ['1','2','3','4','5','6','7','8','9']
const ANSWER_COUNT = {EASY: 1, NORMAL: 2, HARD: 3}
const ROUND_COUNT = 5
const SCORE_RULE = {CORRECT: 100, WRONG: -10}

再定義一個 dom 對象,用於引用 dom 元素,它的每個屬性是一個 dom 元素,key 值與 class 類名保持一致。其中大部分 dom 元素是一個 element 對象,只有 dom.digitsdom.levels 是包含多個 element 對象的數組;另外 dom.level 用於獲取被選中的難度,因爲它的值隨用戶選擇而變化,所以用函數來返回實時結果:

const $ = (selector) => document.querySelectorAll(selector)
const dom = {
    game: $('.game')[0],
    digits: Array.from($('.game .digits span')),
    time: $('.game .time')[0],
    round: $('.game .round')[0],
    score: $('.game .score')[0],
    selectLevel: $('.select-level')[0],
    level: () => {return $('input[type=radio]:checked')[0]},
    play: $('.select-level .play')[0],
    gameOver: $('.game-over')[0],
    again: $('.game-over .again')[0],
    finalTime: $('.game-over .final-time')[0],
    finalScore: $('.game-over .final-score')[0],
}

在遊戲過程中需要根據遊戲進展隨時修改 dom 元素的內容,這些修改過程我們也把它們先定義在 render 對象中,這樣程序主邏輯就不用關心具體的 dom 操作了。render 對象的每個屬性是一個 dom 操作,結構如下:

const render = {
    initDigits: () => {},
    updateDigitStatus: () => {},
    updateTime: () => {},
    updateScore: () => {},
    updateRound: () => {},
    updateFinal: () => {},
}

下面我們把這些 dom 操作逐個寫下來。
render.initDigits 用來初始化九宮格。它接收一個文本數組,根據不同的難度級別,數組的長度可能是 8 個(easy 難度)、7 個(normal 難度)或 6 個(hard 難度),先把它補全爲長度爲 9 個數組,數量不足的元素補空字符,然後把它們隨機分配到九宮格中:

const render = {
    initDigits: (texts) => {
        allTexts = texts.concat(_.fill(Array(ALL_DIGITS.length - texts.length), ''))
        _.shuffle(dom.digits).forEach((digit, i) => {
            digit.innerText = allTexts[i]
            digit.className = ''
        })
    },
    //...
}

render.updateDigitStatus 用來更新九宮格中單個格子的狀態。它接收 2 個參數,text
是格子裏的數字,isAnswer 指明這個數字是不是答案。格子的默認樣式是淺色背景深色文字,如果傳入的數字不是答案,也就是答錯了,會爲格子加上 wrong 樣式,格子被描紅邊;如果傳入的數字是答案,也就是答對了,會在一個空格子裏展示這個數字,併爲格子加上 correct 樣式,格子的樣式會改爲深色背景淺色文字:

const render = {
    //...
    updateDigitStatus: (text, isAnswer) => {
        if (isAnswer) {
            let digit = _.find(dom.digits, x => (x.innerText == ''))
            digit.innerText = text
            digit.className = 'correct'
        }
        else {
            _.find(dom.digits, x => (x.innerText == text)).className = 'wrong'
        }
    },
    //...
}

render.updateTime 用來更新時間,render.updateScore 用來更新得分:

const render = {
    //...
    updateTime: (value) => {
        dom.time.innerText = value.toString()
    },
    updateScore: (value) => {
        dom.score.innerText = value.toString()
    },
    //...
}

render.updateRound 用來更新當前局數,顯示爲 “n/m” 的格式:

const render = {
    //...
    updateRound: (currentRound) => {
        dom.round.innerText = [
            currentRound.toString(),
            '/',
            ROUND_COUNT.toString(),
        ].join('')
    },
    //...
}

render.updateFinal 用來更新遊戲結束界面裏的最終成績:

const render = {
    //...
    updateFinal: () => {
        dom.finalTime.innerText = dom.time.innerText
        dom.finalScore.innerText = dom.score.innerText
    },
}

接下來定義程序整體的邏輯結構。當頁面加載完成之後執行 init() 函數,init() 函數會對整個遊戲做些初始化的工作 ———— 令開始遊戲按鈕 dom.play 被點擊時調用 startGame() 函數,令再玩一次按鈕 dom.again 被點擊時調用 playAgain() 函數,令按下鍵盤時觸發事件處理程序 pressKey() ———— 最後調用 newGame() 函數開始新遊戲:

window.onload = init

function init() {
    dom.play.addEventListener('click', startGame)
    dom.again.addEventListener('click', playAgain)
    window.addEventListener('keyup', pressKey)

    newGame()
}

function newGame() {
    //...
}

function startGame() {
    //...
}

function playAgain() {
    //...
}

function pressKey() {
    //...
}

當遊戲開始時,令遊戲界面變模糊,呼出選擇遊戲難度的界面:

function newGame() {
    dom.game.classList.add('stop')
    dom.selectLevel.style.visibility = 'visible'
}

當選擇了遊戲難度,點擊開始遊戲按鈕 dom.play 時,隱藏掉選擇遊戲難度的界面,遊戲界面恢復正常,然後把根據用戶選擇的遊戲難度計算出的答案數字個數存儲到全局變量 answerCount 中,調用 newRound() 開始一局遊戲:

let answerCount

function startGame() {
    dom.game.classList.remove('stop')
    dom.selectLevel.style.visibility = 'hidden'

    answerCount = ANSWER_COUNT[dom.level().value.toUpperCase()]
    newRound()
}

當一局遊戲開始時,打亂所有候選數字,生成一個全局數組變量 digitsdigits 的每個元素包含 3 個屬性,text 屬性表示數字文本,isAnswer 屬性表示該數字是否爲答案,isPressed 表示該數字是否被按下過,isPressed 的初始值均爲 false,緊接着把 digits 渲染到九宮格中:

let digits

function newRound() {
    digits = _.shuffle(ALL_DIGITS).map((x, i) => {
        return {
            text: x,
            isAnwser: (i < answerCount),
            isPressed: false
        }
    })
    render.initDigits(_.filter(digits, x => !x.isAnwser).map(x => x.text))
}

當用戶按下鍵盤時,若按的鍵不是候選文本,就忽略這次按鍵事件。通過按鍵的文本在 digits 數組中找到對應的元素 digit,判斷該鍵是否被按過,若被按過,也退出事件處理。接下來,就是針對沒按過的鍵,在對應的 digit 對象上標明該鍵已按過,並且更新這個鍵的顯示狀態,如果用戶按下的不是答案數字,就把該數字所在的格子描紅,如果用戶按下的是答案數字,就突出顯示這個數字:

function pressKey(e) {
    if (!ALL_DIGITS.includes(e.key)) return;

    let digit = _.find(digits, x => (x.text == e.key))
    if (digit.isPressed) return;

    digit.isPressed = true
    render.updateDigitStatus(digit.text, digit.isAnwser)
}

當用戶已經按下了所有的答案數字,這一局就結束了,開始新一局:

function pressKey(e) {
    if (!ALL_DIGITS.includes(e.key)) return;

    let digit = _.find(digits, x => (x.text == e.key))
    if (digit.isPressed) return;

    digit.isPressed = true
    render.updateDigitStatus(digit.text, digit.isAnwser)

    //判斷用戶是否已經按下所有的答案數字
    let hasPressedAllAnswerDigits = (_.filter(digits, (x) => (x.isAnwser && x.isPressed)).length == answerCount)
    if (!hasPressedAllAnswerDigits) return;

    newRound()
}

增加一個記錄當前局數的全局變量 round,在遊戲開始時它的初始值爲 0,每局遊戲開始時,它的值就加1,並更新遊戲界面中的局數 dom.round

let round

function newGame() {
    round = 0 //初始化局數

    dom.game.classList.add('stop')
    dom.selectLevel.style.visibility = 'visible'
}

function startGame() {
    render.updateRound(1) //初始化頁面中的局數
    
    dom.game.classList.remove('stop')
    dom.selectLevel.style.visibility = 'hidden'

    answerCount = ANSWER_COUNT[dom.level().value.toUpperCase()]
    newRound()
}

function newRound() {
    digits = _.shuffle(ALL_DIGITS).map((x, i) => {
        return {
            text: x,
            isAnwser: (i < answerCount),
            isPressed: false
        }
    })
    render.initDigits(_.filter(digits, x => !x.isAnwser).map(x => x.text))

    //每局開始時爲局數加 1
    round++
    render.updateRound(round)
}

當前局數 round 增加到常量 ROUND_COUNT 定義的遊戲總局數,本次遊戲結束,調用 gameOver() 函數,否則調用 newRound() 函數開始新一局:

function pressKey(e) {
    if (!ALL_DIGITS.includes(e.key)) return;

    let digit = _.find(digits, x => (x.text == e.key))
    if (digit.isPressed) return;

    digit.isPressed = true
    render.updateDigitStatus(digit.text, digit.isAnwser)

    let hasPressedAllAnswerDigits = (_.filter(digits, (x) => (x.isAnwser && x.isPressed)).length == answerCount)
    if (!hasPressedAllAnswerDigits) return;
    
    //判斷是否玩夠了總局數
    let hasPlayedAllRounds = (round == ROUND_COUNT)
    if (hasPlayedAllRounds) {
        gameOver()
    } else {
        newRound()
    }
}

遊戲結束時,令遊戲界面變模糊,調出遊戲結束界面,顯示最終成績:

function gameOver() {
    render.updateFinal()
    
    dom.game.classList.add('stop')
    dom.gameOver.style.visibility = 'visible'
}

在遊戲結束界面,用戶可以點擊再玩一次按鈕 dom.again,若點擊了此按鈕,就把遊戲結束界面隱藏起來,開始一局新遊戲,這就回到 newGame() 的流程了:

function playAgain() {
    dom.game.classList.remove('stop')
    dom.gameOver.style.visibility = 'hidden'

    newGame()
}

至此,整個遊戲的流程已經跑通了,此時的腳本如下:

const ALL_DIGITS = ['1','2','3','4','5','6','7','8','9']
const ANSWER_COUNT = {EASY: 1, NORMAL: 2, HARD: 3}
const ROUND_COUNT = 3
const SCORE_RULE = {CORRECT: 100, WRONG: -10}

const $ = (selector) => document.querySelectorAll(selector)
const dom = {
    game: $('.game')[0],
    digits: Array.from($('.game .digits span')),
    time: $('.game .time')[0],
    round: $('.game .round')[0],
    score: $('.game .score')[0],
    selectLevel: $('.select-level')[0],
    level: () => {return $('input[type=radio]:checked')[0]},
    play: $('.select-level .play')[0],
    gameOver: $('.game-over')[0],
    again: $('.game-over .again')[0],
    finalTime: $('.game-over .final-time')[0],
    finalScore: $('.game-over .final-score')[0],
}

const render = {
    initDigits: (texts) => {
        allTexts = texts.concat(_.fill(Array(ALL_DIGITS.length - texts.length), ''))
        _.shuffle(dom.digits).forEach((digit, i) => {
            digit.innerText = allTexts[i]
            digit.className = ''
        })
    },
    updateDigitStatus: (text, isAnswer) => {
        if (isAnswer) {
            let digit = _.find(dom.digits, x => (x.innerText == ''))
            digit.innerText = text
            digit.className = 'correct'
        }
        else {
            _.find(dom.digits, x => (x.innerText == text)).className = 'wrong'
        }
    },
    updateTime: (value) => {
        dom.time.innerText = value.toString()
    },
    updateScore: (value) => {
        dom.score.innerText = value.toString()
    },
    updateRound: (currentRound) => {
        dom.round.innerText = [
            currentRound.toString(),
            '/',
            ROUND_COUNT.toString(),
        ].join('')
    },
    updateFinal: () => {
        dom.finalTime.innerText = dom.time.innerText
        dom.finalScore.innerText = dom.score.innerText
    },
}

let answerCount, digits, round

window.onload = init

function init() {
    dom.play.addEventListener('click', startGame)
    dom.again.addEventListener('click', playAgain)
    window.addEventListener('keyup', pressKey)

    newGame()
}

function newGame() {
    round = 0

    dom.game.classList.add('stop')
    dom.selectLevel.style.visibility = 'visible'
}

function startGame() {
    render.updateRound(1)
    
    dom.game.classList.remove('stop')
    dom.selectLevel.style.visibility = 'hidden'

    answerCount = ANSWER_COUNT[dom.level().value.toUpperCase()]
    newRound()
}

function newRound() {
    digits = _.shuffle(ALL_DIGITS).map((x, i) => {
        return {
            text: x,
            isAnwser: (i < answerCount),
            isPressed: false
        }
    })
    render.initDigits(_.filter(digits, x => !x.isAnwser).map(x => x.text))

    round++
    render.updateRound(round)
}

function gameOver() {
    render.updateFinal()
    
    dom.game.classList.add('stop')
    dom.gameOver.style.visibility = 'visible'
}

function playAgain() {
    dom.game.classList.remove('stop')
    dom.gameOver.style.visibility = 'hidden'

    newGame()
}

function pressKey(e) {
    if (!ALL_DIGITS.includes(e.key)) return;

    let digit = _.find(digits, x => (x.text == e.key))
    if (digit.isPressed) return;

    digit.isPressed = true
    render.updateDigitStatus(digit.text, digit.isAnwser)

    let hasPressedAllAnswerDigits = (_.filter(digits, (x) => (x.isAnwser && x.isPressed)).length == answerCount)
    if (!hasPressedAllAnswerDigits) return;
    
    let hasPlayedAllRounds = (round == ROUND_COUNT)
    if (hasPlayedAllRounds) {
        gameOver()
    } else {
        newRound()
    }
}

三、計分和計時

接下來處理得分和時間,先處理得分。
首先聲明一個用於存儲得分的全局變量 score,在新遊戲開始之前設置它的初始值爲 0,在遊戲開始時初始化頁面中的得分:

let score

function newGame() {
    round = 0
    score = 0 //初始化得分

    dom.game.classList.add('stop')
    dom.selectLevel.style.visibility = 'visible'
}

function startGame() {
    render.updateRound(1)
    render.updateScore(0) //初始化頁面中的得分

    dom.game.classList.remove('stop')
    dom.selectLevel.style.visibility = 'hidden'

    answerCount = ANSWER_COUNT[dom.level().value.toUpperCase()]
    newRound()
}

在用戶按鍵事件中根據按下的鍵是否爲答案記錄不同的分值:

function pressKey(e) {
    if (!ALL_DIGITS.includes(e.key)) return;

    let digit = _.find(digits, x => (x.text == e.key))
    if (digit.isPressed) return;

    digit.isPressed = true
    render.updateDigitStatus(digit.text, digit.isAnwser)

    //累積得分
    score += digit.isAnwser ? SCORE_RULE.CORRECT : SCORE_RULE.WRONG
    render.updateScore(score)

    let hasPressedAllAnswerDigits = (_.filter(digits, (x) => (x.isAnwser && x.isPressed)).length == answerCount)
    if (!hasPressedAllAnswerDigits) return;
    
    let hasPlayedAllRounds = (round == ROUND_COUNT)
    if (hasPlayedAllRounds) {
        gameOver()
    } else {
        newRound()
    }
}

接下來處理時間。先創建一個計時器類 Timer,它的參數是一個用於把時間渲染到頁面上的函數,另外 Timerstart()stop() 2 個方法用於開啓和停止計時器,計時器每秒會執行一次 tickTock() 函數:

function Timer(render) {
    this.render = render
    this.t = {},
    this.start = () => {
        this.t = setInterval(this.tickTock, 1000);
    }
    this.stop = () => {
        clearInterval(this.t)
    }
}

定義一個記錄時間的變量 time,它的初始值爲 00 秒,在 tickTock() 函數中把秒數加1,並調用渲染函數把當前時間寫到頁面中:

function Timer(render) {
    this.render = render
    this.t = {}
    this.time = {
        minute: 0,
        second: 0,
    }
    this.tickTock = () => {
        this.time.second ++;
        if (this.time.second == 60) {
            this.time.minute ++
            this.time.second = 0
        }

        render([
            this.time.minute.toString().padStart(2, '0'),
            ':',
            this.time.second.toString().padStart(2, '0'),
        ].join(''))
    }
    this.start = () => {
        this.t = setInterval(this.tickTock, 1000)
    }
    this.stop = () => {
        clearInterval(this.t)
    }
}

在開始遊戲時初始化頁面中的時間:

function startGame() {
    render.updateRound(1)
    render.updateScore(0)
    render.updateTime('00:00') //初始化頁面中的時間

    dom.game.classList.remove('stop')
    dom.selectLevel.style.visibility = 'hidden'

    answerCount = ANSWER_COUNT[dom.level().value.toUpperCase()]
    newRound()
}

定義一個存儲定時器的全局變量 timer,在創建遊戲時初始化定時器,在遊戲開始時啓動計時器,在遊戲結束時停止計時器:

let timer

function newGame() {
    round = 0
    score = 0
    timer = new Timer(render.updateTime) //創建定時器

    dom.game.classList.add('stop')
    dom.selectLevel.style.visibility = 'visible'
}

function startGame() {
    render.updateRound(1)
    render.updateScore(0)
    render.updateTime('00:00')

    dom.game.classList.remove('stop')
    dom.selectLevel.style.visibility = 'hidden'

    answerCount = ANSWER_COUNT[dom.level().value.toUpperCase()]
    newRound()
    timer.start()  //開始計時
}

function gameOver() {
    timer.stop()  //停止計時
    render.updateFinal()
    
    dom.game.classList.add('stop')
    dom.gameOver.style.visibility = 'visible'
}

至此,時鐘已經可以運行了,在遊戲開始時從 0 分 0 秒開始計時,在遊戲結束時停止計時。
最後一個環節,當遊戲結束之後,不應再響應用戶的按鍵事件。爲此,我們定義一個標明是否可按鍵的變量 canPress,在創建新遊戲時它的狀態是不可按,遊戲開始之後變爲可按,遊戲結束之後再變爲不可按:

let canPress

function newGame() {
    round = 0
    score = 0
    time = {
        minute: 0,
        second: 0
    }
    timer = new Timer()
    canPress = false  //初始化是否可按鍵的標誌

    dom.game.classList.add('stop')
    dom.selectLevel.style.visibility = 'visible'
}

function startGame() {
    render.updateRound(1)
    render.updateScore(0)
    render.updateTime(0, 0)

    dom.game.classList.remove('stop')
    dom.selectLevel.style.visibility = 'hidden'

    answerCount = ANSWER_COUNT[dom.level().value.toUpperCase()]
    newRound()
    timer.start(tickTock)
    canPress = true //遊戲開始後,可以按鍵
}

function gameOver() {
    canPress = false //遊戲結束後,不可以再按鍵
    timer.stop()
    render.updateFinal()
    
    dom.game.classList.add('stop')
    dom.gameOver.style.visibility = 'visible'
}

在按鍵事件處理程序中,首先判斷是否允許按鍵,若不允許,就退出事件處理程序:

function pressKey(e) {
    if (!canPress) return; //判斷是否允許按鍵
    if (!ALL_DIGITS.includes(e.key)) return;

    let digit = _.find(digits, x => (x.text == e.key))
    if (digit.isPressed) return;

    digit.isPressed = true
    render.updateDigitStatus(digit.text, digit.isAnwser)

    score += digit.isAnwser ? SCORE_RULE.CORRECT : SCORE_RULE.WRONG
    render.updateScore(score)

    let hasPressedAllAnswerDigits = (_.filter(digits, (x) => (x.isAnwser && x.isPressed)).length == answerCount)
    if (hasPressedAllAnswerDigits) {
        newRound()
    }
}

至此,計分計時設計完畢,此時的腳本如下:

const ALL_DIGITS = ['1','2','3','4','5','6','7','8','9']
const ANSWER_COUNT = {EASY: 1, NORMAL: 2, HARD: 3}
const ROUND_COUNT = 3
const SCORE_RULE = {CORRECT: 100, WRONG: -10}

const $ = (selector) => document.querySelectorAll(selector)
const dom = {
    //略,與此前代碼相同
}

const render = {
    //略,與此前代碼相同
}

let answerCount, digits, round, score, timer, canPress

window.onload = init

function init() {
    //略,與此前代碼相同
}

function newGame() {
    round = 0
    score = 0
    timer = new Timer(render.updateTime)
    canPress = false

    dom.game.classList.add('stop')
    dom.selectLevel.style.visibility = 'visible'
}

function startGame() {
    render.updateRound(1)
    render.updateScore(0)
    render.updateTime(0, 0)

    dom.game.classList.remove('stop')
    dom.selectLevel.style.visibility = 'hidden'

    answerCount = ANSWER_COUNT[dom.level().value.toUpperCase()]
    newRound()
    timer.start()
    canPress = true
}

function newRound() {
    //略,與此前代碼相同
}

function gameOver() {
    canPress = false
    timer.stop()
    render.updateFinal()
    
    dom.game.classList.add('stop')
    dom.gameOver.style.visibility = 'visible'
}

function playAgain() {
    //略,與此前代碼相同
}

function pressKey(e) {
    if (!canPress) return;
    if (!ALL_DIGITS.includes(e.key)) return;

    let digit = _.find(digits, x => (x.text == e.key))
    if (digit.isPressed) return;

    digit.isPressed = true
    render.updateDigitStatus(digit.text, digit.isAnwser)

    score += digit.isAnwser ? SCORE_RULE.CORRECT : SCORE_RULE.WRONG
    render.updateScore(score)

    let hasPressedAllAnswerDigits = (_.filter(digits, (x) => (x.isAnwser && x.isPressed)).length == answerCount)
    if (!hasPressedAllAnswerDigits) return;
    
    let hasPlayedAllRounds = (round == ROUND_COUNT)
    if (hasPlayedAllRounds) {
        gameOver()
    } else {
        newRound()
    }
}

四、動畫效果

引入 gsap 動畫庫:

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

遊戲中一共有 6 個動畫效果,分別是九宮格的出場與入場、選擇遊戲難度界面的顯示與隱藏、遊戲結束界面的顯示與隱藏。爲了集中管理動畫效果,我們定義一個全局常量 animation,它的每個屬性是一個函數,實現一個動畫效果,結構如下,注意因爲選擇遊戲難度界面和遊戲結束界面的樣式相似,所以它們共享了相同的動畫效果,在調用函數時要傳入一個參數 element 指定動畫的 dom 對象:

const animation = {
    digitsFrameOut: () => {
        //九宮格出場
    },
    digitsFrameIn: () => {
        //九宮格入場
    },
    showUI: (element) => {
        //顯示選擇遊戲難度界面和遊戲結束界面
    },
    frameOut: (element) => {
        //隱藏選擇遊戲難度界面和遊戲結束界面
    },
}

確定下這幾個動畫的時機:

function newGame() {
    round = 0
    score = 0
    timer = new Timer(render.updateTime)
    canPress = false

    //選擇遊戲難度界面 - 顯示
    dom.game.classList.add('stop')
    dom.selectLevel.style.visibility = 'visible'
}

function startGame() {
    render.updateRound(1)
    render.updateScore(0)
    render.updateTime('00:00')

    //選擇遊戲難度界面 - 隱藏
    dom.game.classList.remove('stop')
    dom.selectLevel.style.visibility = 'hidden'

    answerCount = ANSWER_COUNT[dom.level().value.toUpperCase()]
    newRound()
    timer.start()
    canPress = true
}

function newRound() {
    //九宮格 - 出場

    digits = _.shuffle(ALL_DIGITS).map((x, i) => {
        return {
            text: x,
            isAnwser: (i < answerCount),
            isPressed: false
        }
    })
    render.initDigits(_.filter(digits, x => !x.isAnwser).map(x => x.text))

    //九宮格 - 入場

    round++
    render.updateRound(round)
}

function gameOver() {
    canPress = false
    timer.stop()
    render.updateFinal()
    
    //遊戲結束界面 - 顯示
    dom.game.classList.add('stop')
    dom.gameOver.style.visibility = 'visible'
}

function playAgain() {
    //遊戲結束界面 - 隱藏
    dom.game.classList.remove('stop')
    dom.gameOver.style.visibility = 'hidden'

    newGame()
}

把目前動畫時機所在位置的代碼移到 animation 對象中,九宮格出場和入場的動畫目前是空的:

const animation = {
    digitsFrameOut: () => {
        //九宮格出場
    },
    digitsFrameIn: () => {
        //九宮格入場
    },
    showUI: (element) => {
        //顯示選擇遊戲難度界面和遊戲結束界面
        dom.game.classList.add('stop')
        element.style.visibility = 'visible'
    },
    hideUI: (element) => {
        //隱藏選擇遊戲難度界面和遊戲結束界面
        dom.game.classList.remove('stop')
        element.style.visibility = 'hidden'
    },
}

在動畫時機的位置調用 animation 對應的動畫函數,因爲動畫是有執行時長的,下一個動畫要等到上一個動畫結束之後再開始,所以我們採用了 async/await 的語法,讓相鄰的動畫順序執行:

async function newGame() {
    round = 0
    score = 0
    timer = new Timer(render.updateTime)
    canPress = false

    // 選擇遊戲難度界面 - 顯示
    await animation.showUI(dom.selectLevel)
}

async function startGame() {
    render.updateRound(1)
    render.updateScore(0)
    render.updateTime('00:00')

    // 選擇遊戲難度界面 - 隱藏
    await animation.hideUI(dom.selectLevel)

    answerCount = ANSWER_COUNT[dom.level().value.toUpperCase()]
    newRound()
    timer.start()
    canPress = true
}

async function newRound() {
    //九宮格 - 出場
    await animation.digitsFrameOut()

    digits = _.shuffle(ALL_DIGITS).map((x, i) => {
        return {
            text: x,
            isAnwser: (i < answerCount),
            isPressed: false
        }
    })
    render.initDigits(_.filter(digits, x => !x.isAnwser).map(x => x.text))

    //九宮格 - 入場
    await animation.digitsFrameIn()

    round++
    render.updateRound(round)
}

async function gameOver() {
    canPress = false
    timer.stop()
    render.updateFinal()
    
    // 遊戲結束界面 - 顯示
    await animation.showUI(dom.gameOver)
}

async function playAgain() {
    // 遊戲結束界面 - 隱藏
    await animation.hideUI(dom.gameOver)

    newGame()
}

接下來就開始設計動畫效果。
animation.digitsFrameOut 是九宮格的出場動畫,各格子分別旋轉着消失。注意,爲了與 async/await 語法配合,我們讓函數返回了一個 Promise 對象:

const animation = {
    digitsFrameOut: () => {
        return new Promise(resolve => {
            new TimelineMax()
                .staggerTo(dom.digits, 0, {rotation: 0})
                .staggerTo(dom.digits, 1, {rotation: 360, scale: 0, delay: 0.5})
                .timeScale(2)
                .eventCallback('onComplete', resolve)
        })
    },
    //...
}

animation.digitsFrameIn 是九宮格的入場動畫,它的動畫效果是各格子旋轉着出現,而且各格子的出現時間稍有延遲:

const animation = {
    //...
    digitsFrameIn: () => {
        return new Promise(resolve => {
            new TimelineMax()
                .staggerTo(dom.digits, 0, {rotation: 0})
                .staggerTo(dom.digits, 1, {rotation: 360, scale: 1}, 0.1)
                .timeScale(2)
                .eventCallback('onComplete', resolve)
        })
    },
    //...
}

animation.showUI 是顯示擇遊戲難度界面和遊戲結束界面的動畫,它的效果是從高處落下,並在底部小幅反彈,模擬物體跌落的效果:

const animation = {
    //...
    showUI: (element) => {
        dom.game.classList.add('stop')
        return new Promise(resolve => {
            new TimelineMax()
                .to(element, 0, {visibility: 'visible', x: 0})
                .from(element, 1, {y: '-300px', ease: Elastic.easeOut.config(1, 0.3)})
                .timeScale(1)
                .eventCallback('onComplete', resolve)
        })
    },
    //...
}

animation.hideUI 是隱藏選擇遊戲難度界面和遊戲結束界面的動畫,它從正常位置向右移出畫面:

const animation = {
    //...
    hideUI: (element) => {
        dom.game.classList.remove('stop')
        return new Promise(resolve => {
            new TimelineMax()
                .to(element, 1, {x: '300px', ease: Power4.easeIn})
                .to(element, 0, {visibility: 'hidden'})
                .timeScale(2)
                .eventCallback('onComplete', resolve)
        })
    },
}

至此,整個遊戲的動畫效果就完成了,全部代碼如下:

const ALL_DIGITS = ['1','2','3','4','5','6','7','8','9']
const ANSWER_COUNT = {EASY: 1, NORMAL: 2, HARD: 3}
const ROUND_COUNT = 3
const SCORE_RULE = {CORRECT: 100, WRONG: -10}

const $ = (selector) => document.querySelectorAll(selector)
const dom = {
    //略,與增加動畫前相同
}

const render = {
    //略,與增加動畫前相同
}

const animation = {
    digitsFrameOut: () => {
        return new Promise(resolve => {
            new TimelineMax()
                .staggerTo(dom.digits, 0, {rotation: 0})
                .staggerTo(dom.digits, 1, {rotation: 360, scale: 0, delay: 0.5})
                .timeScale(2)
                .eventCallback('onComplete', resolve)
        })
    },
    digitsFrameIn: () => {
        return new Promise(resolve => {
            new TimelineMax()
                .staggerTo(dom.digits, 0, {rotation: 0})
                .staggerTo(dom.digits, 1, {rotation: 360, scale: 1}, 0.1)
                .timeScale(2)
                .eventCallback('onComplete', resolve)
        })
    },
    showUI: (element) => {
        dom.game.classList.add('stop')
        return new Promise(resolve => {
            new TimelineMax()
                .to(element, 0, {visibility: 'visible', x: 0})
                .from(element, 1, {y: '-300px', ease: Elastic.easeOut.config(1, 0.3)})
                .timeScale(1)
                .eventCallback('onComplete', resolve)
        })
    },
    hideUI: (element) => {
        dom.game.classList.remove('stop')
        return new Promise(resolve => {
            new TimelineMax()
                .to(element, 1, {x: '300px', ease: Power4.easeIn})
                .to(element, 0, {visibility: 'hidden'})
                .timeScale(2)
                .eventCallback('onComplete', resolve)
        })
    },
}

let answerCount, digits, round, score, timer, canPress

window.onload = init

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

async function newGame() {
    round = 0
    score = 0
    timer = new Timer(render.updateTime)
    canPress = false

    await animation.showUI(dom.selectLevel)
}

async function startGame() {
    render.updateRound(1)
    render.updateScore(0)
    render.updateTime('00:00')

    await animation.hideUI(dom.selectLevel)

    answerCount = ANSWER_COUNT[dom.level().value.toUpperCase()]
    newRound()
    timer.start()
    canPress = true
}

async function newRound() {
    await animation.digitsFrameOut()

    digits = _.shuffle(ALL_DIGITS).map((x, i) => {
        return {
            text: x,
            isAnwser: (i < answerCount),
            isPressed: false
        }
    })
    render.initDigits(_.filter(digits, x => !x.isAnwser).map(x => x.text))

    await animation.digitsFrameIn()

    round++
    render.updateRound(round)
}

async function gameOver() {
    canPress = false
    timer.stop()
    render.updateFinal()
    
    await animation.showUI(dom.gameOver)
}

async function playAgain() {
    await animation.hideUI(dom.gameOver)

    newGame()
}

function pressKey(e) {
    //略,與增加動畫前相同
}

function tickTock() {
    //略,與增加動畫前相同
}

大功告成!

最後,附上交互流程圖,方便大家理解:

圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章