前端逐日实战:164# 视频演示如何用原生 JS 创作一个数独练习小游戏(内含 4 个视频)

《前端逐日实战: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 三个级别:

<div class="app">
    <h1>Sudoku Training</h1>
    <div class="game">
        <!-- 略 -->
    </div>
    <div class="select-level">
        <div class="levels">
            <input type="radio" name="level" id="easy" value="easy" checked="checked">
            <label for="easy">Easy</label>

            <input type="radio" name="level" id="normal" value="normal">
            <label for="normal">Normal</label>

            <input type="radio" name="level" id="hard" value="hard">
            <label for="hard">Hard</label>
        </div>
        <div class="play">Play</div>
    </div>
</div>

为挑选游戏难度容器画一个圆形的外框,子元素纵向规划:

.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

<div class="app">
        <h1>Sudoku Training</h1>
        <div class="game">
            <!-- 略 -->
        </div>
        <div class="select-level">
            <!-- 略 -->
        </div>
        <div class="game-over">
            <h2>Game Over</h2>
            <p>
                Time:
                <span class="final-time">00:00</span>
            </p>
            <p>
                Score:
                <span class="final-score">3000</span>
            </p>
            <div class="again">Play Again</div>
        </div>
    </div>

由于游戏终了界面和挑选游戏难度界面的规划类似,所以借用 .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 {
    visibility: hidden;
}

游戏中,当挑选游戏难度界面 .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.digitsdom.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 个数组,数目不足的元素补空字符,然后把它们随机分配到九宫格中:

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 用来更新九宫格中单个格子的状况。它吸收 2 个参数,text
是格子里的数字,isAnswer 指明这个数字是不是是答案。格子的默许款式是淡色背景深色笔墨,假如传入的数字不是答案,也就是答错了,会为格子加上 wrong 款式,格子被描红边;假如传入的数字是答案,也就是答对了,会在一个空格子里展现这个数字,并为格子加上 correct 款式,格子的款式会改成深色背景淡色笔墨:

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 用来更新时候,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.innerText
        dom.finalScore.innerText = dom.score.innerText
    },
}

接下来定义递次团体的逻辑构造。当页面加载完成以后实行 init() 函数,init() 函数会对全部游戏做些初始化的事情 ———— 令最先游戏按钮 dom.play 被点击时挪用 startGame() 函数,令再玩一次按钮 dom.again 被点击时挪用 playAgain() 函数,令按下键盘时触发事宜处置惩罚递次 pressKey() ———— 末了挪用 newGame() 函数最先新游戏:

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() {
    //...
}

当游戏最先时,令游戏界面变隐约,呼出挑选游戏难度的界面:

function newGame() {
    dom.game.classList.add('stop')
    dom.selectLevel.style.visibility = 'visible'
}

当挑选了游戏难度,点击最先游戏按钮 dom.play 时,隐蔽掉挑选游戏难度的界面,游戏界面恢复一般,然后把依据用户挑选的游戏难度计算出的答案数字个数存储到全局变量 answerCount 中,挪用 newRound() 最先一局游戏:

let answerCount

function startGame() {
    dom.game.classList.remove('stop')
    dom.selectLevel.style.visibility = 'hidden'

    answerCount = ANSWER_COUNT[dom.level().value.toUpperCase()]
    newRound()
}

当一局游戏最先时,打乱一切候选数字,天生一个全局数组变量 digitsdigits 的每一个元素包括 3 个属性,text 属性示意数字文本,isAnswer 属性示意该数字是不是为答案,isPressed 示意该数字是不是被按下过,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))
}

当用户按下键盘时,若按的键不是候选文本,就疏忽此次按键事宜。经由过程按键的文本在 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 = true
    render.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 = true
    render.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 round

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))

    //每局最先时为局数加 1
    round++
    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 = 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()
    }
}

游戏终了时,令游戏界面变隐约,调出游戏终了界面,显现终究结果:

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 = 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()
    }
}

三、计分和计时

接下来处置惩罚得分和时候,先处置惩罚得分。
起首声明一个用于存储得分的全局变量 score,在新游戏最先之前设置它的初始值为 0,在游戏最先时初始化页面中的得分:

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) //初始化页面中的得分

    dom.game.classList.remove('stop')
    dom.selectLevel.style.visibility = 'hidden'

    answerCount = ANSWER_COUNT[dom.level().value.toUpperCase()]
    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 = 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()
    }
}

接下来处置惩罚时候。先建立一个计时器类 Timer,它的参数是一个用于把时候衬着到页面上的函数,别的 Timerstart()stop() 2 个要领用于开启和住手计时器,计时器每秒会实行一次 tickTock() 函数:

function Timer(render) {
    this.render = render
    this.t = {},
    this.start = () => {
        this.t = setInterval(this.tickTock, 1000);
    }
    this.stop = () => {
        clearInterval(this.t)
    }
}

定义一个纪录时候的变量 time,它的初始值为 00 秒,在 tickTock() 函数中把秒数加1,并挪用衬着函数把当前时候写到页面中:

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)
    }
}

在最先游戏时初始化页面中的时候:

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 timer

function newGame() {
    round = 0
    score = 0
    timer = 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,在建立新游戏时它的状况是不可按,游戏最先以后变成可按,游戏终了以后再变成不可按:

let canPress

function newGame() {
    round = 0
    score = 0
    time = {
        minute: 0,
        second: 0
    }
    timer = new Timer()
    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(tickTock)
    canPress = true //游戏最先后,能够按键
}

function gameOver() {
    canPress = false //游戏终了后,不能够再按键
    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()
    }
}

至此,计分计时设想终了,此时的剧本以下:

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()
    }
}

四、动画效果

引入 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 = 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() {
    //九宫格 - 进场

    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 = false
    timer.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 = 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()
}

接下来就最先设想动画效果。
animation.digitsFrameOut 是九宫格的进场动画,各格子离别旋转着消逝。注重,为了与 async/await 语法合营,我们让函数返回了一个 Promise 对象:

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 是九宫格的入场动画,它的动画效果是各格子旋转着涌现,而且各格子的涌现时候稍有耽误:

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, canPress

window.onload = init

function init() {
    //略,与增添动画前雷同
}

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) {
    //略,与增添动画前雷同
}

function tickTock() {
    //略,与增添动画前雷同
}

功德圆满!

末了,附上交互流程图,轻易人人明白。个中蓝色条带示意动画,粉色椭圆示意用户操纵,绿色矩形和菱形示意重要的递次逻辑:

《前端逐日实战:164# 视频演示如何用原生 JS 创作一个数独练习小游戏(内含 4 个视频)》

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