前端每日实战:164# 视频演示如何用原生 JS 创作一个数独训练小游戏(内含 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 三個級別:
為選擇游戲難度容器畫一個圓形的外框,子元素縱向布局:
.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:
因為游戲結束界面和選擇游戲難度界面的布局相似,所以借用 .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 出現時,應該令游戲界面 .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 個數組,數量不足的元素補空字符,然后把它們隨機分配到九宮格中:
render.updateDigitStatus 用來更新九宮格中單個格子的狀態。它接收 2 個參數,text
是格子里的數字,isAnswer 指明這個數字是不是答案。格子的默認樣式是淺色背景深色文字,如果傳入的數字不是答案,也就是答錯了,會為格子加上 wrong 樣式,格子被描紅邊;如果傳入的數字是答案,也就是答對了,會在一個空格子里展示這個數字,并為格子加上 correct 樣式,格子的樣式會改為深色背景淺色文字:
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.innerTextdom.finalScore.innerText = dom.score.innerText}, }接下來定義程序整體的邏輯結構。當頁面加載完成之后執行 init() 函數,init() 函數會對整個游戲做些初始化的工作 ———— 令開始游戲按鈕 dom.play 被點擊時調用 startGame() 函數,令再玩一次按鈕 dom.again 被點擊時調用 playAgain() 函數,令按下鍵盤時觸發事件處理程序 pressKey() ———— 最后調用 newGame() 函數開始新游戲:
window.onload = initfunction 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 answerCountfunction 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 digitsfunction 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 = truerender.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 = truerender.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 roundfunction 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))//每局開始時為局數加 1round++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 = truerender.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 = textdigit.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.innerTextdom.finalScore.innerText = dom.score.innerText}, }let answerCount, digits, roundwindow.onload = initfunction init() {dom.play.addEventListener('click', startGame)dom.again.addEventListener('click', playAgain)window.addEventListener('keyup', pressKey)newGame() }function newGame() {round = 0dom.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 = truerender.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,在游戲開始時初始化頁面中的得分:
在用戶按鍵事件中根據按下的鍵是否為答案記錄不同的分值:
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 = truerender.updateDigitStatus(digit.text, digit.isAnwser)//累積得分score += digit.isAnwser ? SCORE_RULE.CORRECT : SCORE_RULE.WRONGrender.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 = renderthis.t = {},this.start = () => {this.t = setInterval(this.tickTock, 1000);}this.stop = () => {clearInterval(this.t)} }定義一個記錄時間的變量 time,它的初始值為 0 分 0 秒,在 tickTock() 函數中把秒數加1,并調用渲染函數把當前時間寫到頁面中:
function Timer(render) {this.render = renderthis.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 timerfunction newGame() {round = 0score = 0timer = 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,在創建新游戲時它的狀態是不可按,游戲開始之后變為可按,游戲結束之后再變為不可按:
在按鍵事件處理程序中,首先判斷是否允許按鍵,若不允許,就退出事件處理程序:
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 = truerender.updateDigitStatus(digit.text, digit.isAnwser)score += digit.isAnwser ? SCORE_RULE.CORRECT : SCORE_RULE.WRONGrender.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, canPresswindow.onload = initfunction init() {//略,與此前代碼相同 }function newGame() {round = 0score = 0timer = new Timer(render.updateTime)canPress = falsedom.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 = falsetimer.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 = truerender.updateDigitStatus(digit.text, digit.isAnwser)score += digit.isAnwser ? SCORE_RULE.CORRECT : SCORE_RULE.WRONGrender.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 = 0score = 0timer = 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 = falsetimer.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 = 0score = 0timer = 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 = falsetimer.stop()render.updateFinal()// 游戲結束界面 - 顯示await animation.showUI(dom.gameOver) }async function playAgain() {// 游戲結束界面 - 隱藏await animation.hideUI(dom.gameOver)newGame() }接下來就開始設計動畫效果。
animation.digitsFrameOut 是九宮格的出場動畫,各格子分別旋轉著消失。注意,為了與 async/await 語法配合,我們讓函數返回了一個 Promise 對象:
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, canPresswindow.onload = initfunction init() {//略,與增加動畫前相同 }async function newGame() {round = 0score = 0timer = new Timer(render.updateTime)canPress = falseawait 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 = falsetimer.stop()render.updateFinal()await animation.showUI(dom.gameOver) }async function playAgain() {await animation.hideUI(dom.gameOver)newGame() }function pressKey(e) {//略,與增加動畫前相同 }function tickTock() {//略,與增加動畫前相同 }大功告成!
最后,附上交互流程圖,方便大家理解。其中藍色條帶表示動畫,粉色橢圓表示用戶操作,綠色矩形和菱形表示主要的程序邏輯:
《新程序員》:云原生和全面數字化實踐50位技術專家共同創作,文字、視頻、音頻交互閱讀
總結
以上是生活随笔為你收集整理的前端每日实战:164# 视频演示如何用原生 JS 创作一个数独训练小游戏(内含 4 个视频)...的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: nodejs事务使用总结
- 下一篇: 关于下载