前端逐日实战:163# 视频演示如何用原生 JS 创作一个多选一场景的交互游戏(内含 3 个视频)

《前端逐日实战:163# 视频演示如何用原生 JS 创作一个多选一场景的交互游戏(内含 3 个视频)》

结果预览

按下右边的“点击预览”按钮能够在当前页面预览,点击链接能够全屏预览。

https://codepen.io/comehope/pen/LXMzRX

可交互视频

此视频是能够交互的,你能够随时停息视频,编辑视频中的代码。

请用 chrome, safari, edge 翻开寓目。

第 1 部份:
https://scrimba.com/p/pEgDAM/cQK3bSp

第 2 部份:
https://scrimba.com/p/pEgDAM/cNJWncR

第 3 部份:
https://scrimba.com/p/pEgDAM/cvgP8td

源代码下载

逐日前端实战系列的悉数源代码请从 github 下载:

https://github.com/comehope/front-end-daily-challenges

代码解读

多选一的场景是很罕见的,浏览器自带的 <input type="radio"> 控件就适用于如许的场景。本项目将设想一个多选一的交互场景,用 css 举行页面规划、用 gsap 制造动画结果、用原生 js 编写递次逻辑。

这个游戏的逻辑很简单,在页面上部展现出一个动物的全身像,请用户鄙人面的小图片中挑选这个动物对应的头像,假如选对了,就能够再玩一次。

全部游戏分红 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 代表显现全身像的动物,由于背面还会用到 optionsanswer,所以把它们定义为全局变量。经由 _.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 变量。

《前端逐日实战:163# 视频演示如何用原生 JS 创作一个多选一场景的交互游戏(内含 3 个视频)》

    原文作者:comehope
    原文地址: https://segmentfault.com/a/1190000017212162
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞