按下右側的「點擊預覽」按鈕能夠在當前頁面預覽,點擊連接能夠全屏預覽。javascript
https://codepen.io/comehope/pen/mQYobzcss
此視頻是能夠交互的,你能夠隨時暫停視頻,編輯視頻中的代碼。html
請用 chrome, safari, edge 打開觀看。前端
第 1 部分:
https://scrimba.com/p/pEgDAM/c7Q86ugjava
第 2 部分:
https://scrimba.com/p/pEgDAM/ckgBNADgit
第 3 部分:
https://scrimba.com/p/pEgDAM/cG7bWc8github
第 4 部分:
https://scrimba.com/p/pEgDAM/cez34fpajax
每日前端實戰系列的所有源代碼請從 github 下載:chrome
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.digits
和 dom.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() }
當一局遊戲開始時,打亂全部候選數字,生成一個全局數組變量 digits
,digits
的每一個元素包含 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
,它的參數是一個用於把時間渲染到頁面上的函數,另外 Timer
有 start()
和 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
,它的初始值爲 0
分 0
秒,在 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() { //略,與增長動畫前相同 }
大功告成!
最後,附上交互流程圖,方便你們理解。其中藍色條帶表示動畫,粉色橢圓表示用戶操做,綠色矩形和菱形表示主要的程序邏輯: