按下右側的「點擊預覽」按鈕能夠在當前頁面預覽,點擊連接能夠全屏預覽。javascript
https://codepen.io/comehope/pen/LXMzRXcss
此視頻是能夠交互的,你能夠隨時暫停視頻,編輯視頻中的代碼。html
請用 chrome, safari, edge 打開觀看。前端
第 1 部分:
https://scrimba.com/p/pEgDAM/cQK3bSpjava
第 2 部分:
https://scrimba.com/p/pEgDAM/cNJWncRgit
第 3 部分:
https://scrimba.com/p/pEgDAM/cvgP8tdgithub
每日前端實戰系列的所有源代碼請從 github 下載:ajax
https://github.com/comehope/front-end-daily-challengeschrome
多選一的場景是很常見的,瀏覽器自帶的 <input type="radio">
控件就適用於這樣的場景。本項目將設計一個多選一的交互場景,用 css 進行頁面佈局、用 gsap 製做動畫效果、用原生 js 編寫程序邏輯。segmentfault
這個遊戲的邏輯很簡單,在頁面上部展現出一個動物的全身像,請用戶在下面的小圖片中選擇這個動物對應的頭像,若是選對了,就能夠再玩一次。
整個遊戲分紅 3 個步驟開發:靜態的頁面佈局、程序邏輯和動畫效果。
定義 dom 結構,容器中包含標題 h1
、全身像 .whole-body
、當選擇正確時的提示語 .bingo
、「再玩一次」按鈕 .again
、一組選擇按鈕 .selector
。.selector
中包含 5 個展現頭像的 .face
和 1 個標明當前被選中頭像的 .slider
。全身像和頭像沒有使用圖片,都用 unicode 字符代替:
<div class="app"> <h1>Which face is the animal's?</h1> <div class="whole-body">🐄</div> <div class="bingo"> Bingo! <span class="again">Play Again</span> </div> <div class="selector"> <span class="slider"></span> <span class="face">🐭</span> <span class="face">🐶</span> <span class="face">🐷</span> <span class="face">🐮</span> <span class="face">🐯</span> </div> </div>
居中顯示:
body { margin: 0; height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(darkblue, black); }
定義容器中子元素的按縱向佈局,水平居中:
.app { height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: space-between; }
標題是白色文字:
h1 { margin: 0; color: white; }
全身像爲大尺寸的圓形,利用陰影畫一個半透明的粗邊框:
.whole-body { width: 200px; height: 200px; background-color: rgb(180, 220, 255); border-radius: 50%; font-size: 140px; text-align: center; line-height: 210px; margin-top: 20px; box-shadow: 0 0 0 15px rgba(180, 220, 255, 0.2); user-select: none; }
選擇正確時的提示語爲白色:
.bingo { color: white; font-size: 30px; font-family: sans-serif; margin-top: 20px; }
「再玩一次」按鈕的字體稍小,在鼠標懸停和點擊時有交互效果:
.again { display: inline-block; font-size: 20px; background-color: white; color: darkblue; padding: 5px; border-radius: 5px; box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.4); user-select: none; } .again:hover { background-color: rgba(255, 255, 255, 0.8); cursor: pointer; } .again:active { transform: translate(2px, 2px); box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.4); }
5 個頭像爲小尺寸的圓形,橫向排列,半透明背景:
.selector { display: flex; } .face { width: 60px; height: 60px; background-color: rgba(255, 255, 255, 0.2); border-radius: 50%; font-size: 40px; text-align: center; line-height: 70px; cursor: pointer; user-select: none; } .face:not(:last-child) { margin-right: 25px; }
在被選中的頭像下面疊加一個同尺寸的淺藍色色塊:
.selector { position: relative; } .slider { position: absolute; width: 60px; height: 60px; background-color: rgba(180, 220, 255, 0.6); border-radius: 50%; z-index: -1; }
至此,頁面佈局完成。
引入 lodash 工具庫,後面會用到 lodash 提供的一些數組函數:
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.min.js"></script>
在寫程序邏輯以前,咱們先定義 2 個常量。
第一個常量是存儲動物頭像和全身像的數據對象 animals
,它的每一個屬性是 1 種動物,key 是頭像,value 是全身像:
const animals = { '🐭': '🐁', '🐶': '🐕', '🐷': '🐖', '🐮': '🐄', '🐯': '🐅', '🐔': '🐓', '🐵': '🐒', '🐲': '🐉', '🐴': '🐎', '🐰': '🐇', }
第二個常量是存儲 dom 元素引用的數據對象 dom
,它的每一個屬性是一個 dom 元素,key 值與 class 類名保持一致,分別是表明全身像的 dom.wholeBody
、表明選擇正確時的提示信息 dom.bingo
、表明「再玩一次」按鈕的 dom.bingo
、表明頭像列表的 dom.faces
、表明頭像下面的滑塊 dom.slider
:
const dom = { wholeBody: document.querySelector('.whole-body'), bingo: document.querySelector('.bingo'), again: document.querySelector('.again'), faces: Array.from(document.querySelectorAll('.face')), slider: document.querySelector('.slider'), }
接下來定義總體的邏輯結構,當頁面加載完成以後執行 init()
函數,init()
函數會對整個遊戲作些初始化的工做 ———— 令頭像 dom.faces
被點擊時調用 select()
函數,令「再玩一次」按鈕 dom.again
被點擊時調用 newGame()
函數 ———— 最後調用 newGame()
函數開始一局新遊戲:
function newGame() { //... } function select() { //... } function init() { dom.faces.forEach(face => { face.addEventListener('click', select) }) dom.again.addEventListener('click', newGame) newGame() } window.onload = init
在 newGame()
函數中調用 shuffle()
函數。shuffle()
函數的做用是隨機地從 animals
數組中選出 5 個動物,把它們的頭像顯示在 dom.faces
中,再從中選出 1 個動物,把它的全身像顯示在 dom.wholeBody
中。變量 options
表明被選出的 5 個動物,變量 answer
表明顯示全身像的動物,由於後面還會用到 options
和 answer
,因此把它們定義爲全局變量。通過 _.entries()
函數的處理,options
數組的元素和 answer
的數據結構變爲包含 2 個元素的數組 [key, value]
形式,其中第 [0]
個元素是頭像,第 [1]
個元素是全身像:
let options = [] let answer = {} function newGame() { shuffle() } function shuffle() { options = _.slice(_.shuffle(_.entries(animals)), -5) answer = _.sample(_.slice(options, -4)) dom.faces.forEach((face, i) => { face.innerText = options[i][0] }) dom.wholeBody.innerText = answer[1] }
如今,每點擊一次 Play Again
按鈕,就會洗牌、更新圖片。
接下來處理滑塊。在 select()
函數中,首先把滑塊 dom.slider
移動到被點擊的頭像位置:
function select(e) { let position = _.findIndex(options, (o) => o[0] == e.target.innerText) dom.slider.style.left = (25 + 60) * position + 'px' }
而後判斷當前頭像對應的全身像和頁面上方全身像是否一致,若一致,就顯示提示語 dom.bingo
。在此以前,要把提示語隱藏掉:
function newGame() { dom.bingo.style.visibility = 'hidden' shuffle() } function select(e) { let position = _.findIndex(options, (o) => o[0] == e.target.innerText) dom.slider.style.left = (25 + 60) * position + 'px' if (animals[e.target.innerText] == answer[1]) { dom.bingo.style.visibility = 'visible' } }
如今,遊戲開局時是沒有提示語的,只有選對了頭像,纔會出提示語。
不過出現了一個bug,就是當重開新局時,滑塊還停留在上一局的位置,咱們要改爲開局時把滑塊 dom.slider
移到頭像列表的最左側:
function newGame() { dom.bingo.style.visibility = 'hidden' shuffle() dom.slider.style.left = '0px' }
如今,整個程序流程已經能夠跑通了:頁面加載後即開始一局遊戲,任意選擇頭像,在選擇了正確的頭像時出現 Bingo!
字樣,點擊 Play Again
按鈕能夠開始下一局遊戲。
不過,在邏輯上還有一點小瑕疵。當用戶已經選擇了正確的頭像,顯示出提示語以後,不該該還能點選其餘頭像。爲此,咱們引入一個全局變量 canSelect
,它是一個布爾值,表示當前是否能夠選擇頭像,初始值是 false
,在 newGame()
函數的最後一步,它的值被設置爲 true
,在 select()
函數中首先判斷 canSelect
的值,只有當值爲 true
時,才能繼續執行事件處理的後續程序,當用戶選擇了正確的頭像時,canSelect
被設置爲 false
,表示這一局遊戲結束了。
let canSelect = false function newGame() { dom.bingo.style.visibility = 'hidden' shuffle() dom.slider.style.left = '0px' canSelect = true } function select(e) { if (!canSelect) return; let position = _.findIndex(options, x => x[0] == e.target.innerText) dom.slider.style.left = (25 + 60) * position + 'px' if (animals[e.target.innerText] == answer[1]) { canSelect = false dom.bingo.style.visibility = 'visible' } }
至此的所有腳本以下:
const animals = { '🐭': '🐁', '🐶': '🐕', '🐷': '🐖', '🐮': '🐄', '🐯': '🐅', '🐔': '🐓', '🐵': '🐒', '🐲': '🐉', '🐴': '🐎', '🐰': '🐇', } const dom = { wholeBody: document.querySelector('.whole-body'), bingo: document.querySelector('.bingo'), again: document.querySelector('.again'), faces: Array.from(document.querySelectorAll('.face')), slider: document.querySelector('.slider'), } let options = [] let answer = {} let canSelect = false function newGame() { dom.bingo.style.visibility = 'hidden' shuffle() dom.slider.style.left = '0px' canSelect = true } function shuffle() { options = _.slice(_.shuffle(_.entries(animals)), -5) answer = _.sample(_.slice(options, -4)) dom.faces.forEach((face, i) => { face.innerText = options[i][0] }) dom.wholeBody.innerText = answer[1] } function select(e) { if (!canSelect) return; let position = _.findIndex(options, x => x[0] == e.target.innerText) dom.slider.style.left = (25 + 60) * position + 'px' if (animals[e.target.innerText] == answer[1]) { canSelect = false dom.bingo.style.visibility = 'visible' } } function init() { dom.faces.forEach(face => { face.addEventListener('click', select) }) dom.again.addEventListener('click', newGame) newGame() } window.onload = init
遊戲中共有 4 個動畫效果,分別是移動滑塊 dom.slider
、顯示提示語 dom.bingo
、動物(包括頭像列表和全身像)出場、動物入場。爲了集中管理動畫效果,咱們定義一個全局常量 animation
,它有 4 個屬性,每一個屬性是一個函數,實現一個動畫效果,結構以下:
const animation = { moveSlider: () => { //移動滑塊... }, showBingo: () => { //顯示提示語... }, frameOut: () => { //動物出場... }, frameIn: () => { //動物入場... }, }
其實這 4 個動畫的運行時機已經體如今 newGame()
函數和 select()
函數中了:
function newGame() { dom.bingo.style.visibility = 'hidden' //此處改成 動物出場 動畫 shuffle() dom.slider.style.left = '0px' //此處改成 動物入場 動畫 canSelect = true } function select(e) { if (!canSelect) return; let position = _.findIndex(options, (o) => o[0] == e.target.innerText) dom.slider.style.left = (25 + 60) * position + 'px' //此處改成 移動滑塊 動畫 if (animals[e.target.innerText] == answer[1]) { canSelect = false dom.bingo.style.visibility = 'visible' //此處改成 顯示提示語 動畫 } }
因此,咱們就能夠把這 4 行代碼轉移到 animation
中,其中 moveSlider()
還增長了一個指明要移動到什麼位置的 position
參數:
const animation = { moveSlider: (position) => { dom.slider.style.left = (25 + 60) * position + 'px' }, showBingo: () => { dom.bingo.style.visibility = 'visible' }, frameOut: () => { dom.bingo.style.visibility = 'hidden' }, frameIn: () => { dom.slider.style.left = '0px' }, }
同時,newGame()
函數和 select()
函數改成調用 animation
:
function newGame() { animation.frameOut() shuffle() animation.frameIn() canSelect = true } function select(e) { if (!canSelect) return; let position = _.findIndex(options, (o) => o[0] == e.target.innerText) animation.moveSlider(position) if (animals[e.target.innerText] == answer[1]) { canSelect = false animation.showBingo() } }
通過上面的整理,接下來的動畫代碼就能夠集中寫在 animation
對象裏了。
本項目的動畫效果用 gsap 實現,gsap 動畫在之前的 133#項目、134#項目、143#項目 都用到了,你們可參考這些項目瞭解 gsap 的使用方法。
引入 gsap 動畫庫:
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/2.0.2/TweenMax.min.js"></script>
先編寫移動滑塊的動畫 moveSlider
,讓滑塊先縮小,而後移動到目的地,再放大:
const animation = { moveSlider: () => { new TimelineMax() .to(dom.slider, 1, {scale: 0.3}) .to(dom.slider, 1, {left: (25 + 60) * position + 'px'}) .to(dom.slider, 1, {scale: 1}) .timeScale(5) }, //... }
再編寫顯示提示語的動畫 showBingo
,顯示出 dom.bingo
以後,讓它左右晃動一下:
const animation = { //... showBingo: () => { new TimelineMax() .to(dom.bingo, 0, {visibility: 'visible'}) .to(dom.bingo, 1, {rotation: -5}) .to(dom.bingo, 1, {rotation: 5}) .to(dom.bingo, 1, {rotation: 0}) .timeScale(8) }, //... }
再編寫動物出場的動畫,隱藏提示語 dom.bingo
以後,再同時把滑塊 dom.slider
、頭像列表 dom.faces
、全身像 dom.wholeBody
同時縮小到消失:
const animation = { //... frameOut: () => { new TimelineMax() .to(dom.bingo, 0, {visibility: 'hidden'}) .to(dom.slider, 1, {scale: 0}, 't1') .staggerTo(dom.faces, 1, {scale: 0}, 0.25, 't1') .to(dom.wholeBody, 1, {scale: 0}, 't1') .timeScale(5) }, //... }
再編寫動物入場的動畫,把滑塊移到頭像列表最左側以後,再把剛纔出場動畫縮小到消失的那些元素放大到正常尺寸:
const animation = { //... frameIn: () => { new TimelineMax() .to(dom.slider, 0, {left: '0px'}) .to(dom.wholeBody, 2, {scale: 1, delay: 1}) .staggerTo(dom.faces, 1, {scale: 1}, 0.25) .to(dom.slider, 1, {scale: 1}) .timeScale(5) }, }
如今運行一下程序,已經有動畫效果了,可是會以爲有些不協調,那是由於動畫有必定的運行時長,多個動畫連續運行時應該有前後順序,好比應該先出場再入場、先移動滑塊再顯示提示語,但如今它們都是同時運行的。爲了讓它們能順序執行,咱們用 async/await 來改造,先讓動畫函數返回 promise 對象,以 moveSlider
爲例,它被改爲這樣:
const animation = { moveSlider: () => { return new Promise(resolve => { new TimelineMax() .to(dom.slider, 1, {scale: 0.3}) .to(dom.slider, 1, {left: (25 + 60) * position + 'px'}) .to(dom.slider, 1, {scale: 1}) .timeScale(5) .eventCallback('onComplete', resolve) }) }, //... }
而後把 select()
函數改形成 async 函數,並在調用動畫以前加入 await
關鍵字:
async function select(e) { if (!canSelect) return; let position = _.findIndex(options, (o) => o[0] == e.target.innerText) await animation.moveSlider(position) if (animals[e.target.innerText] == answer[1]) { canSelect = false animation.showBingo() } }
如今點擊頭像時,若選擇正確,要等到滑塊動畫結束以後纔會顯示提示語。再用相同的方法,改造其餘幾個動畫和 select()
函數。到這裏,整個遊戲的動畫效果就所有完成了。至此的所有腳本以下:
const animals = { //略,與增長動畫前相同 } const dom = { //略,與增長動畫前相同 } const animation = { frameOut: () => { return new Promise(resolve => { new TimelineMax() .to(dom.bingo, 0, {visibility: 'hidden'}) .to(dom.slider, 1, {scale: 0}, 't1') .staggerTo(dom.faces, 1, {scale: 0}, 0.25, 't1') .to(dom.wholeBody, 1, {scale: 0}, 't1') .timeScale(5) .eventCallback('onComplete', resolve) }) }, frameIn: () => { return new Promise(resolve => { new TimelineMax() .to(dom.slider, 0, {left: '0px'}) .to(dom.wholeBody, 2, {scale: 1, delay: 1}) .staggerTo(dom.faces, 1, {scale: 1}, 0.25) .to(dom.slider, 1, {scale: 1}) .timeScale(5) .eventCallback('onComplete', resolve) }) }, moveSlider: (position) => { return new Promise(resolve => { new TimelineMax() .to(dom.slider, 1, {scale: 0.3}) .to(dom.slider, 1, {left: (25 + 60) * position + 'px'}) .to(dom.slider, 1, {scale: 1}) .timeScale(5) .eventCallback('onComplete', resolve) }) }, showBingo: () => { return new Promise(resolve => { new TimelineMax() .to(dom.bingo, 0, {visibility: 'visible'}) .to(dom.bingo, 1, {rotation: -5}) .to(dom.bingo, 1, {rotation: 5}) .to(dom.bingo, 1, {rotation: 0}) .timeScale(8) .eventCallback('onComplete', resolve) }) }, } let options = [] let answer = {} let canSelect = false async function newGame() { await animation.frameOut() shuffle() await animation.frameIn() canSelect = true } function shuffle() { //略,與增長動畫前相同 } async function select(e) { if (!canSelect) return; let position = _.findIndex(options, (o) => o[0] == e.target.innerText) await animation.moveSlider(position) if (animals[e.target.innerText] == answer[1]) { canSelect = false await animation.showBingo() } } function init() { //略,與增長動畫前相同 } window.onload = init
大功告成!
最後,附上程序流程圖,方便你們理解。其中藍色條帶表示動畫,粉色橢圓表示用戶操做,綠色矩形和菱形表示主要的程序邏輯,橙色平等四邊形表示 canSelect 變量。