按下右侧的“点击预览”按钮能够在当前页面预览,点击连接能够全屏预览。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 变量。