摘要:第部分第部分第部分第部分源代碼下載每日前端實(shí)戰(zhàn)系列的全部源代碼請(qǐng)從下載代碼解讀解數(shù)獨(dú)的一項(xiàng)基本功是能迅速判斷一行一列或一個(gè)九宮格中缺少哪幾個(gè)數(shù)字,本項(xiàng)目就是一個(gè)訓(xùn)練判斷九宮格中缺少哪個(gè)數(shù)字的小游戲。
效果預(yù)覽
按下右側(cè)的“點(diǎn)擊預(yù)覽”按鈕可以在當(dāng)前頁(yè)面預(yù)覽,點(diǎn)擊鏈接可以全屏預(yù)覽。
https://codepen.io/comehope/pen/mQYobz
可交互視頻此視頻是可以交互的,你可以隨時(shí)暫停視頻,編輯視頻中的代碼。
請(qǐng)用 chrome, safari, edge 打開(kāi)觀看。
第 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
每日前端實(shí)戰(zhàn)系列的全部源代碼請(qǐng)從 github 下載:
https://github.com/comehope/front-end-daily-challenges
代碼解讀解數(shù)獨(dú)的一項(xiàng)基本功是能迅速判斷一行、一列或一個(gè)九宮格中缺少哪幾個(gè)數(shù)字,本項(xiàng)目就是一個(gè)訓(xùn)練判斷九宮格中缺少哪個(gè)數(shù)字的小游戲。游戲的流程是:先選擇游戲難度,有 Easy、Normal、Hard 三檔,分別對(duì)應(yīng)著九宮格中缺少 1 個(gè)、2 個(gè)、3 個(gè)數(shù)字。開(kāi)始游戲后,用鍵盤輸入九宮格中缺少的數(shù)字,如果全答出來(lái)了,就會(huì)進(jìn)入下一局,一共 5 局,5 局結(jié)束之后這一次游戲就結(jié)束了。在游戲過(guò)程中,九宮格的左上角會(huì)計(jì)時(shí),右上角會(huì)計(jì)分。
整個(gè)游戲分成 4 個(gè)步驟開(kāi)發(fā):靜態(tài)頁(yè)面布局、程序邏輯、計(jì)分計(jì)時(shí)和動(dòng)畫效果。
一、頁(yè)面布局定義 dom 結(jié)構(gòu),.app 是整個(gè)應(yīng)用的容器,h1 是游戲標(biāo)題,.game 是游戲的主界面。.game 中的子元素包括 .message 和 .digits,.message 用來(lái)提示游戲時(shí)間 .time、游戲的局?jǐn)?shù) .round、得分 .score,.digits 里是 9 個(gè)數(shù)字:
Sudoku Training
1 2 3 4 5 6 7 8 9
居中顯示:
body { margin: 0; height: 100vh; display: flex; align-items: center; justify-content: center; background: silver; overflow: hidden; }
定義應(yīng)用的寬度,子元素縱向布局:
.app { width: 300px; display: flex; flex-direction: column; align-items: center; justify-content: space-between; user-select: none; }
標(biāo)題為棕色字:
h1 { margin: 0; color: sienna; }
提示信息是橫向布局,重點(diǎn)內(nèi)容加粗:
.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; }
至此,游戲區(qū)域布局完成,接下來(lái)布局選擇游戲難度的界面。
在 html 文件中增加 .select-level dom 結(jié)構(gòu),它包含一個(gè)難度列表 levels 和一個(gè)開(kāi)始游戲的按鈕 .play,游戲難度分為 .easy、.normal 和 .hard 三個(gè)級(jí)別:
Sudoku Training
Play
為選擇游戲難度容器畫一個(gè)圓形的外框,子元素縱向布局:
.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 個(gè)難度選項(xiàng),橫向排列:
.select-level .levels { margin-top: 60px; width: 190px; display: flex; justify-content: space-between; }
把 input 控件隱藏起來(lái),只顯示它們對(duì)應(yīng)的 label:
.select-level .levels { position: relative; } .select-level input[type=radio] { visibility: hidden; position: absolute; left: 0; }
設(shè)置 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; }
當(dāng)某個(gè) label 對(duì)應(yīng)的 input 被選中時(shí),令 label 背景色加深,以示區(qū)別:
.select-level input[type=radio]:checked + label { background-color: sienna; }
設(shè)置開(kāi)始游戲按鈕 .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); }
至此,選擇游戲難度的界面布局完成,接下來(lái)布局游戲結(jié)束界面。
游戲結(jié)束區(qū) .game-over 包含一個(gè) h2 標(biāo)題,二行顯示最終結(jié)果的段落 p 和一個(gè)再玩一次的按鈕 .again。最終結(jié)果包括最終耗時(shí) .final-time 和最終得分 .final-score:
Sudoku Training
Game Over
Time: 00:00
Score: 3000
Play Again
因?yàn)橛螒蚪Y(jié)束界面和選擇游戲難度界面的布局相似,所以借用 .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; }
標(biāo)題和最終結(jié)果都用棕色字:
.game-over h2 { margin-top: 40px; color: sienna; } .game-over p { margin: 3px; font-size: 20px; color: sienna; }
“再玩一次”按鈕 .again 的樣式與開(kāi)始游戲 .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 和游戲結(jié)束界面 .game-over 定位到游戲容器的中間位置:
.app { position: relative; } .select-level, .game-over { position: absolute; bottom: 40px; }
至此,游戲界面 .game、選擇游戲難度界面 .select-level 和游戲結(jié)束界面 .game-over 均已布局完成。接下來(lái)為動(dòng)態(tài)程序做些準(zhǔn)備工作。
把選擇游戲難度界面 .select-level 和游戲結(jié)束界面 .game-over 隱藏起來(lái),當(dāng)需要它們呈現(xiàn)時(shí),會(huì)在腳本中設(shè)置它們的 visibility 屬性:
.select-level, .game-over { visibility: hidden; }
游戲中,當(dāng)選擇游戲難度界面 .select-level 和游戲結(jié)束界面 .game-over 出現(xiàn)時(shí),應(yīng)該令游戲界面 .game 變模糊,并且加一個(gè)緩動(dòng)時(shí)間,.game.stop 會(huì)在腳本中調(diào)用:
.game { transition: 0.3s; } .game.stop { filter: blur(10px); }
游戲中,當(dāng)填錯(cuò)了數(shù)字時(shí),要把錯(cuò)誤的數(shù)字描一個(gè)紅邊;當(dāng)填對(duì)了數(shù)字時(shí),把數(shù)字的背景色改為巧克力色。.game .digits span.wrong 和 .game .digits span.correct 會(huì)在腳本中調(diào)用:
.game .digits span.wrong { border: 2px solid crimson; } .game .digits span.correct { background-color: chocolate; color: gold; }
至此,完成全部布局和樣式設(shè)計(jì)。
二、程序邏輯引入 lodash 工具庫(kù),后面會(huì)用到 lodash 提供的一些數(shù)組函數(shù):
在寫程序邏輯之前,先定義幾個(gè)存儲(chǔ)業(yè)務(wù)數(shù)據(jù)的常量。ALL_DIGITS 存儲(chǔ)了全部備選的數(shù)字,也就是從 1 到 9;ANSWER_COUNT 存儲(chǔ)的是不同難度要回答的數(shù)字個(gè)數(shù),easy 難度要回答 1 個(gè)數(shù)字,normal 難度要回答 2 個(gè)數(shù)字,hard 難度要回答 3 個(gè)數(shù)字;ROUND_COUNT 存儲(chǔ)的是每次游戲的局?jǐn)?shù),默認(rèn)是 5 局;SCORE_RULE 存儲(chǔ)的是答對(duì)和答錯(cuò)時(shí)分?jǐn)?shù)的變化,答對(duì)加 100 分,答錯(cuò)扣 10 分。定義這些常量的好處是避免在程序中出現(xiàn)魔法數(shù)字,提高程序可讀性:
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}
再定義一個(gè) dom 對(duì)象,用于引用 dom 元素,它的每個(gè)屬性是一個(gè) dom 元素,key 值與 class 類名保持一致。其中大部分 dom 元素是一個(gè) element 對(duì)象,只有 dom.digits 和 dom.levels 是包含多個(gè) element 對(duì)象的數(shù)組;另外 dom.level 用于獲取被選中的難度,因?yàn)樗闹惦S用戶選擇而變化,所以用函數(shù)來(lái)返回實(shí)時(shí)結(jié)果:
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], }
在游戲過(guò)程中需要根據(jù)游戲進(jìn)展隨時(shí)修改 dom 元素的內(nèi)容,這些修改過(guò)程我們也把它們先定義在 render 對(duì)象中,這樣程序主邏輯就不用關(guān)心具體的 dom 操作了。render 對(duì)象的每個(gè)屬性是一個(gè) dom 操作,結(jié)構(gòu)如下:
const render = { initDigits: () => {}, updateDigitStatus: () => {}, updateTime: () => {}, updateScore: () => {}, updateRound: () => {}, updateFinal: () => {}, }
下面我們把這些 dom 操作逐個(gè)寫下來(lái)。
render.initDigits 用來(lái)初始化九宮格。它接收一個(gè)文本數(shù)組,根據(jù)不同的難度級(jí)別,數(shù)組的長(zhǎng)度可能是 8 個(gè)(easy 難度)、7 個(gè)(normal 難度)或 6 個(gè)(hard 難度),先把它補(bǔ)全為長(zhǎng)度為 9 個(gè)數(shù)組,數(shù)量不足的元素補(bǔ)空字符,然后把它們隨機(jī)分配到九宮格中:
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 用來(lái)更新九宮格中單個(gè)格子的狀態(tài)。它接收 2 個(gè)參數(shù),text
是格子里的數(shù)字,isAnswer 指明這個(gè)數(shù)字是不是答案。格子的默認(rèn)樣式是淺色背景深色文字,如果傳入的數(shù)字不是答案,也就是答錯(cuò)了,會(huì)為格子加上 wrong 樣式,格子被描紅邊;如果傳入的數(shù)字是答案,也就是答對(duì)了,會(huì)在一個(gè)空格子里展示這個(gè)數(shù)字,并為格子加上 correct 樣式,格子的樣式會(huì)改為深色背景淺色文字:
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 用來(lái)更新時(shí)間,render.updateScore 用來(lái)更新得分:
const render = { //... updateTime: (value) => { dom.time.innerText = value.toString() }, updateScore: (value) => { dom.score.innerText = value.toString() }, //... }
render.updateRound 用來(lái)更新當(dāng)前局?jǐn)?shù),顯示為 “n/m” 的格式:
const render = { //... updateRound: (currentRound) => { dom.round.innerText = [ currentRound.toString(), "/", ROUND_COUNT.toString(), ].join("") }, //... }
render.updateFinal 用來(lái)更新游戲結(jié)束界面里的最終成績(jī):
const render = { //... updateFinal: () => { dom.finalTime.innerText = dom.time.innerText dom.finalScore.innerText = dom.score.innerText }, }
接下來(lái)定義程序整體的邏輯結(jié)構(gòu)。當(dāng)頁(yè)面加載完成之后執(zhí)行 init() 函數(shù),init() 函數(shù)會(huì)對(duì)整個(gè)游戲做些初始化的工作 ———— 令開(kāi)始游戲按鈕 dom.play 被點(diǎn)擊時(shí)調(diào)用 startGame() 函數(shù),令再玩一次按鈕 dom.again 被點(diǎn)擊時(shí)調(diào)用 playAgain() 函數(shù),令按下鍵盤時(shí)觸發(fā)事件處理程序 pressKey() ———— 最后調(diào)用 newGame() 函數(shù)開(kāi)始新游戲:
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() { //... }
當(dāng)游戲開(kāi)始時(shí),令游戲界面變模糊,呼出選擇游戲難度的界面:
function newGame() { dom.game.classList.add("stop") dom.selectLevel.style.visibility = "visible" }
當(dāng)選擇了游戲難度,點(diǎn)擊開(kāi)始游戲按鈕 dom.play 時(shí),隱藏掉選擇游戲難度的界面,游戲界面恢復(fù)正常,然后把根據(jù)用戶選擇的游戲難度計(jì)算出的答案數(shù)字個(gè)數(shù)存儲(chǔ)到全局變量 answerCount 中,調(diào)用 newRound() 開(kāi)始一局游戲:
let answerCount function startGame() { dom.game.classList.remove("stop") dom.selectLevel.style.visibility = "hidden" answerCount = ANSWER_COUNT[dom.level().value.toUpperCase()] newRound() }
當(dāng)一局游戲開(kāi)始時(shí),打亂所有候選數(shù)字,生成一個(gè)全局?jǐn)?shù)組變量 digits,digits 的每個(gè)元素包含 3 個(gè)屬性,text 屬性表示數(shù)字文本,isAnswer 屬性表示該數(shù)字是否為答案,isPressed 表示該數(shù)字是否被按下過(guò),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)) }
當(dāng)用戶按下鍵盤時(shí),若按的鍵不是候選文本,就忽略這次按鍵事件。通過(guò)按鍵的文本在 digits 數(shù)組中找到對(duì)應(yīng)的元素 digit,判斷該鍵是否被按過(guò),若被按過(guò),也退出事件處理。接下來(lái),就是針對(duì)沒(méi)按過(guò)的鍵,在對(duì)應(yīng)的 digit 對(duì)象上標(biāo)明該鍵已按過(guò),并且更新這個(gè)鍵的顯示狀態(tài),如果用戶按下的不是答案數(shù)字,就把該數(shù)字所在的格子描紅,如果用戶按下的是答案數(shù)字,就突出顯示這個(gè)數(shù)字:
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) }
當(dāng)用戶已經(jīng)按下了所有的答案數(shù)字,這一局就結(jié)束了,開(kāi)始新一局:
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) //判斷用戶是否已經(jīng)按下所有的答案數(shù)字 let hasPressedAllAnswerDigits = (_.filter(digits, (x) => (x.isAnwser && x.isPressed)).length == answerCount) if (!hasPressedAllAnswerDigits) return; newRound() }
增加一個(gè)記錄當(dāng)前局?jǐn)?shù)的全局變量 round,在游戲開(kāi)始時(shí)它的初始值為 0,每局游戲開(kāi)始時(shí),它的值就加1,并更新游戲界面中的局?jǐn)?shù) dom.round:
let round function newGame() { round = 0 //初始化局?jǐn)?shù) dom.game.classList.add("stop") dom.selectLevel.style.visibility = "visible" } function startGame() { render.updateRound(1) //初始化頁(yè)面中的局?jǐn)?shù) 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)) //每局開(kāi)始時(shí)為局?jǐn)?shù)加 1 round++ render.updateRound(round) }
當(dāng)前局?jǐn)?shù) round 增加到常量 ROUND_COUNT 定義的游戲總局?jǐn)?shù),本次游戲結(jié)束,調(diào)用 gameOver() 函數(shù),否則調(diào)用 newRound() 函數(shù)開(kāi)始新一局:
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; //判斷是否玩夠了總局?jǐn)?shù) let hasPlayedAllRounds = (round == ROUND_COUNT) if (hasPlayedAllRounds) { gameOver() } else { newRound() } }
游戲結(jié)束時(shí),令游戲界面變模糊,調(diào)出游戲結(jié)束界面,顯示最終成績(jī):
function gameOver() { render.updateFinal() dom.game.classList.add("stop") dom.gameOver.style.visibility = "visible" }
在游戲結(jié)束界面,用戶可以點(diǎn)擊再玩一次按鈕 dom.again,若點(diǎn)擊了此按鈕,就把游戲結(jié)束界面隱藏起來(lái),開(kāi)始一局新游戲,這就回到 newGame() 的流程了:
function playAgain() { dom.game.classList.remove("stop") dom.gameOver.style.visibility = "hidden" newGame() }
至此,整個(gè)游戲的流程已經(jīng)跑通了,此時(shí)的腳本如下:
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() } }三、計(jì)分和計(jì)時(shí)
接下來(lái)處理得分和時(shí)間,先處理得分。
首先聲明一個(gè)用于存儲(chǔ)得分的全局變量 score,在新游戲開(kāi)始之前設(shè)置它的初始值為 0,在游戲開(kāi)始時(shí)初始化頁(yè)面中的得分:
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) //初始化頁(yè)面中的得分 dom.game.classList.remove("stop") dom.selectLevel.style.visibility = "hidden" answerCount = ANSWER_COUNT[dom.level().value.toUpperCase()] newRound() }
在用戶按鍵事件中根據(jù)按下的鍵是否為答案記錄不同的分值:
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() } }
接下來(lái)處理時(shí)間。先創(chuàng)建一個(gè)計(jì)時(shí)器類 Timer,它的參數(shù)是一個(gè)用于把時(shí)間渲染到頁(yè)面上的函數(shù),另外 Timer 有 start() 和 stop() 2 個(gè)方法用于開(kāi)啟和停止計(jì)時(shí)器,計(jì)時(shí)器每秒會(huì)執(zhí)行一次 tickTock() 函數(shù):
function Timer(render) { this.render = render this.t = {}, this.start = () => { this.t = setInterval(this.tickTock, 1000); } this.stop = () => { clearInterval(this.t) } }
定義一個(gè)記錄時(shí)間的變量 time,它的初始值為 0 分 0 秒,在 tickTock() 函數(shù)中把秒數(shù)加1,并調(diào)用渲染函數(shù)把當(dāng)前時(shí)間寫到頁(yè)面中:
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) } }
在開(kāi)始游戲時(shí)初始化頁(yè)面中的時(shí)間:
function startGame() { render.updateRound(1) render.updateScore(0) render.updateTime("00:00") //初始化頁(yè)面中的時(shí)間 dom.game.classList.remove("stop") dom.selectLevel.style.visibility = "hidden" answerCount = ANSWER_COUNT[dom.level().value.toUpperCase()] newRound() }
定義一個(gè)存儲(chǔ)定時(shí)器的全局變量 timer,在創(chuàng)建游戲時(shí)初始化定時(shí)器,在游戲開(kāi)始時(shí)啟動(dòng)計(jì)時(shí)器,在游戲結(jié)束時(shí)停止計(jì)時(shí)器:
let timer function newGame() { round = 0 score = 0 timer = new Timer(render.updateTime) //創(chuàng)建定時(shí)器 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() //開(kāi)始計(jì)時(shí) } function gameOver() { timer.stop() //停止計(jì)時(shí) render.updateFinal() dom.game.classList.add("stop") dom.gameOver.style.visibility = "visible" }
至此,時(shí)鐘已經(jīng)可以運(yùn)行了,在游戲開(kāi)始時(shí)從 0 分 0 秒開(kāi)始計(jì)時(shí),在游戲結(jié)束時(shí)停止計(jì)時(shí)。
最后一個(gè)環(huán)節(jié),當(dāng)游戲結(jié)束之后,不應(yīng)再響應(yīng)用戶的按鍵事件。為此,我們定義一個(gè)標(biāo)明是否可按鍵的變量 canPress,在創(chuàng)建新游戲時(shí)它的狀態(tài)是不可按,游戲開(kāi)始之后變?yōu)榭砂矗螒蚪Y(jié)束之后再變?yōu)椴豢砂矗?/p>
let canPress function newGame() { round = 0 score = 0 time = { minute: 0, second: 0 } timer = new Timer() canPress = false //初始化是否可按鍵的標(biāo)志 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 //游戲開(kāi)始后,可以按鍵 } function gameOver() { canPress = false //游戲結(jié)束后,不可以再按鍵 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() } }
至此,計(jì)分計(jì)時(shí)設(shè)計(jì)完畢,此時(shí)的腳本如下:
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() } }四、動(dòng)畫效果
引入 gsap 動(dòng)畫庫(kù):
游戲中一共有 6 個(gè)動(dòng)畫效果,分別是九宮格的出場(chǎng)與入場(chǎng)、選擇游戲難度界面的顯示與隱藏、游戲結(jié)束界面的顯示與隱藏。為了集中管理動(dòng)畫效果,我們定義一個(gè)全局常量 animation,它的每個(gè)屬性是一個(gè)函數(shù),實(shí)現(xiàn)一個(gè)動(dòng)畫效果,結(jié)構(gòu)如下,注意因?yàn)檫x擇游戲難度界面和游戲結(jié)束界面的樣式相似,所以它們共享了相同的動(dòng)畫效果,在調(diào)用函數(shù)時(shí)要傳入一個(gè)參數(shù) element 指定動(dòng)畫的 dom 對(duì)象:
const animation = { digitsFrameOut: () => { //九宮格出場(chǎng) }, digitsFrameIn: () => { //九宮格入場(chǎng) }, showUI: (element) => { //顯示選擇游戲難度界面和游戲結(jié)束界面 }, frameOut: (element) => { //隱藏選擇游戲難度界面和游戲結(jié)束界面 }, }
確定下這幾個(gè)動(dòng)畫的時(shí)機(jī):
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() { //九宮格 - 出場(chǎng) 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)) //九宮格 - 入場(chǎng) round++ render.updateRound(round) } function gameOver() { canPress = false timer.stop() render.updateFinal() //游戲結(jié)束界面 - 顯示 dom.game.classList.add("stop") dom.gameOver.style.visibility = "visible" } function playAgain() { //游戲結(jié)束界面 - 隱藏 dom.game.classList.remove("stop") dom.gameOver.style.visibility = "hidden" newGame() }
把目前動(dòng)畫時(shí)機(jī)所在位置的代碼移到 animation 對(duì)象中,九宮格出場(chǎng)和入場(chǎng)的動(dòng)畫目前是空的:
const animation = { digitsFrameOut: () => { //九宮格出場(chǎng) }, digitsFrameIn: () => { //九宮格入場(chǎng) }, showUI: (element) => { //顯示選擇游戲難度界面和游戲結(jié)束界面 dom.game.classList.add("stop") element.style.visibility = "visible" }, hideUI: (element) => { //隱藏選擇游戲難度界面和游戲結(jié)束界面 dom.game.classList.remove("stop") element.style.visibility = "hidden" }, }
在動(dòng)畫時(shí)機(jī)的位置調(diào)用 animation 對(duì)應(yīng)的動(dòng)畫函數(shù),因?yàn)閯?dòng)畫是有執(zhí)行時(shí)長(zhǎng)的,下一個(gè)動(dòng)畫要等到上一個(gè)動(dòng)畫結(jié)束之后再開(kāi)始,所以我們采用了 async/await 的語(yǔ)法,讓相鄰的動(dòng)畫順序執(zhí)行:
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() { //九宮格 - 出場(chǎng) 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)) //九宮格 - 入場(chǎng) await animation.digitsFrameIn() round++ render.updateRound(round) } async function gameOver() { canPress = false timer.stop() render.updateFinal() // 游戲結(jié)束界面 - 顯示 await animation.showUI(dom.gameOver) } async function playAgain() { // 游戲結(jié)束界面 - 隱藏 await animation.hideUI(dom.gameOver) newGame() }
接下來(lái)就開(kāi)始設(shè)計(jì)動(dòng)畫效果。
animation.digitsFrameOut 是九宮格的出場(chǎng)動(dòng)畫,各格子分別旋轉(zhuǎn)著消失。注意,為了與 async/await 語(yǔ)法配合,我們讓函數(shù)返回了一個(gè) Promise 對(duì)象:
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 是九宮格的入場(chǎng)動(dòng)畫,它的動(dòng)畫效果是各格子旋轉(zhuǎn)著出現(xiàn),而且各格子的出現(xiàn)時(shí)間稍有延遲:
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 是顯示擇游戲難度界面和游戲結(jié)束界面的動(dòng)畫,它的效果是從高處落下,并在底部小幅反彈,模擬物體跌落的效果:
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 是隱藏選擇游戲難度界面和游戲結(jié)束界面的動(dòng)畫,它從正常位置向右移出畫面:
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) }) }, }
至此,整個(gè)游戲的動(dòng)畫效果就完成了,全部代碼如下:
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 = { //略,與增加動(dòng)畫前相同 } const render = { //略,與增加動(dòng)畫前相同 } 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() { //略,與增加動(dòng)畫前相同 } 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) { //略,與增加動(dòng)畫前相同 } function tickTock() { //略,與增加動(dòng)畫前相同 }
大功告成!
最后,附上交互流程圖,方便大家理解。其中藍(lán)色條帶表示動(dòng)畫,粉色橢圓表示用戶操作,綠色矩形和菱形表示主要的程序邏輯:
文章版權(quán)歸作者所有,未經(jīng)允許請(qǐng)勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請(qǐng)注明本文地址:http://specialneedsforspecialkids.com/yun/117030.html
摘要:過(guò)往項(xiàng)目年月份項(xiàng)目匯總共個(gè)項(xiàng)目年月份項(xiàng)目匯總共個(gè)項(xiàng)目年月份項(xiàng)目匯總共個(gè)項(xiàng)目年月份項(xiàng)目匯總共個(gè)項(xiàng)目年月份項(xiàng)目匯總共個(gè)項(xiàng)目年月份項(xiàng)目匯總共個(gè)項(xiàng)目年月至年月發(fā)布的項(xiàng)目前端每日實(shí)戰(zhàn)專欄每天分解一個(gè)前端項(xiàng)目,用視頻記錄編碼過(guò)程,再配合詳細(xì)的代碼解讀, 過(guò)往項(xiàng)目 2018 年 9 月份項(xiàng)目匯總(共 26 個(gè)項(xiàng)目) 2018 年 8 月份項(xiàng)目匯總(共 29 個(gè)項(xiàng)目) 2018 年 7 月份項(xiàng)目匯總(...
摘要:第部分第部分第部分第部分源代碼下載每日前端實(shí)戰(zhàn)系列的全部源代碼請(qǐng)從下載代碼解讀解數(shù)獨(dú)的一項(xiàng)基本功是能迅速判斷一行一列或一個(gè)九宮格中缺少哪幾個(gè)數(shù)字,本項(xiàng)目就是一個(gè)訓(xùn)練判斷九宮格中缺少哪個(gè)數(shù)字的小游戲。 showImg(https://segmentfault.com/img/bVbkNGa?w=400&h=300); 效果預(yù)覽 按下右側(cè)的點(diǎn)擊預(yù)覽按鈕可以在當(dāng)前頁(yè)面預(yù)覽,點(diǎn)擊鏈接可以全屏預(yù)...
摘要:本項(xiàng)目將設(shè)計(jì)一個(gè)多選一的交互場(chǎng)景,用進(jìn)行頁(yè)面布局用制作動(dòng)畫效果用原生編寫程序邏輯。中包含個(gè)展示頭像的和個(gè)標(biāo)明當(dāng)前被選中頭像的。 showImg(https://segmentfault.com/img/bVbknOW?w=400&h=302); 效果預(yù)覽 按下右側(cè)的點(diǎn)擊預(yù)覽按鈕可以在當(dāng)前頁(yè)面預(yù)覽,點(diǎn)擊鏈接可以全屏預(yù)覽。 https://codepen.io/comehope/pen/L...
閱讀 2631·2021-11-19 09:56
閱讀 878·2021-09-24 10:25
閱讀 1639·2021-09-09 09:34
閱讀 2201·2021-09-09 09:33
閱讀 1056·2019-08-30 15:54
閱讀 547·2019-08-29 18:33
閱讀 1269·2019-08-29 17:19
閱讀 511·2019-08-29 14:19