[ 逻辑锻炼] 用 JavaScript 做一个小游戏 ——2048 (详解版)

2,350 阅读8分钟

前言

  • 这次使用了 vue 来编写 2048,主要目的是温习一下 vue。
  • 但是好像没有用到太多 vue 的东西,==! 估计可能习惯了不用框架吧
  • 之前由于时间关系没有对实现过程详细讲解,本次会详细讲解下比较绕的函数
  • 由于篇幅问题简单的函数就不做详解了
  • 代码地址: github.com/yhtx1997/Sm…

实现功能

  1. 数字合并
  2. 当前总分计算
  3. 没有可移动的数字时不进行任何操作
  4. 没有可移动,可合并的数字,并且不能新建时游戏失败
  5. 达到 2048 结束游戏

用到的知识

  1. ES6
  2. vue 部分模板语法
  3. vue 生命周期
  4. 数组方法
    1. reverse()
    2. push()
    3. unshift()
    4. some()
    5. forEach()
    6. reduceRight()
  5. 数学方法
    1. Math.abs()
    2. Math.floor()

具体实现

  • 是否需要将上下操作转换为左右操作
  • 数据初始化
  • 合并数字
  • 判断操作是否无效
  • 渲染到页面
  • 随机创建数字
  • 计算总分
  • 判断成功
  • 判断失败

总体流程如下所示

command (keyCode) { // 总部
      this.WhetherToRotate(keyCode) // 是否需要将上下操作转换为左右操作
      this.Init() // 数据初始化 合并数字
      this.IfInvalid() // 判断是否无效
      this.Rendering(keyCode) // 渲染到页面
    }

初始化

首先先将基本的 HTML 标签跟 CSS 样式写出来

由于用的 vue ,所以渲染 html 部分的代码不用我们去手写

<template>
  <div id='app'>
    <div class='total'>总分: {{this.total}} 分</div> // {{}} 这个中间表示 JavaScript 表达式
    <div class='main'>
      <div class='row' v-for='(items,index) of arr' :key='index'> // v-for表示循环渲染当前元素,具体渲染次数为 arr.length 
        <div
          :class='`c-${item} item`'
          v-for='(item,index) of items'
          :key='index'
        >{{item>0?item:''}}</div> // :class= 表示将 JavaScript 变量作为类名
      </div>
    </div>
    <footer>
        <h2>玩法说明:</h2>
        <p>1.用键盘上下左右键控制数字走向</p>
        <p>2.当点击了一个方向时,格子中的数字会全部往那个方向移动,直到不能再移动,如果有相同的数字则会合并</p>
        <p>3.当格子中不再有可移动和可合并的数字时,游戏结束</p>
    </footer>
  </div>
</template>

css由于太长就不放了跟之前基本没有太多区别

接下来是数据的初始化

data () {
    return {
      arr: [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], // 与页面绑定的数组
      Copyarr: [[], [], [], []], // 用来数据操作的数组
      initData: [], // 包含数字详细坐标的数组
      haveGrouping: false, // 有可以合并的数字
      itIsLeft: false, // 是否为向左合并,默认不是向左合并
      endGap: true, // 判断最边上有没有空隙 默认有空隙
      middleGap: true, // 真 为某行中间有空隙
      haveZero: true, // 当前页面有没有 0
      total: 0, // 总分数
      itIs2048: false, // 是否成功
      max: 2048 // 最高分数
    }
  }

做好初始化看起来应该是这样的效果

init.png

添加事件监听

在 mounted 添加事件监听

为什么在 mounted 添加事件? 我们先了解下vue的生命周期

  • beforeCreate 实例创建之前 在这个阶段我们写的代码还没有被运行
  • created 实例创建之后 在这个阶段我们写的代码已经运行了但是还没有将 HTML 渲染到页面
  • mounted 挂载之后 在这个阶段 html 渲染到页面了,可以取到 dom 节点
  • beforeUpdate 数据更新前 在我们需要重新渲染 html 前调用 类似执行 warp.innerHTML = html; 之前
  • updated 数据更新后 在重新渲染 HTML 后调用
  • destroyed 实例销毁后调用 将我们写的代码丢弃掉后调用
  • errorCaptured 当捕获一个来自子孙组件的错误时被调用 2.5.0+ 新增
  • 注:我说的我们写的代码只是一种代指,是为了方便理解,并不是真正的指我们写的代码

所以如果太早的话可能找不到 dom 节点,太晚的话,可能不能第一时间进行事件的响应

  mounted () {
    window.onkeydown = e => {
      switch (e.keyCode) {
        case 37:
          //  ←
          console.log('←')
          this.Command(e.keyCode)
          break
        case 38:
          //  ↑
          console.log('↑')
          this.Command(e.keyCode)
          break
        case 39:
          //  →
          this.Command(e.keyCode)
          console.log('→')
          break
        case 40:
          //  ↓
          console.log('↓')
          this.Command(e.keyCode)
          break
      }
    }
  }

将操作简化为只有左右

这段代码我是某天半梦半醒想到的,可能思维不好转过来,可以看看代码下面的图

这样一来就将向上的操作转换成了向左的操作
向下的操作就转换成了向右的操作
这样折腾下可以少写一半的数字合并代码

    WhetherToRotate (keyCode) { // 是否需要将上下操作转换为左右操作
      if (keyCode === 38 || keyCode === 40) { // 38 是上 40 是下
        this.Copyarr = this.ToRotate(this.arr)
      } else if (keyCode === 37 || keyCode === 39) { // 37 是左 39 是右
        [...this.Copyarr] = this.arr
      }
      // 将当前操作做一个标识
      if (keyCode === 37 || keyCode === 38) { // 数据转换后只有左右操作
        this.itIsLeft = true
      } else if (keyCode === 39 || keyCode === 40) {
        this.itIsLeft = false
      }
    }

转换代码

    ToRotate (arr) { // 将数据从 x 到 y  y 到 x 相互转换
      let afterCopyingArr = [[], [], [], []]
      for (let i = 0; i < arr.length; i++) {
        for (let j = 0; j < arr[i].length; j++) {
          afterCopyingArr[i][j] = arr[j][i]
        }
      }
      return afterCopyingArr
    }

zhuanhuan.png

数据初始化

  • 数组中的 0 在这个小作品中仅用作占位,视为垃圾数据,所以开始前需要处理掉,在结束后再加上
  • 两种数据格式,一种是包含详细信息的,用来做一些判断; 一种是纯数字的二维数组,之后用来从新渲染页面
 Init () { // 数据初始化
      this.initData = this.DataDetails() // 非零数字详情
      this.Copyarr = this.NumberMerger() // 数字合并
    }

判断是否无效

 IfInvalid () { // 判断是否无效
      // 判断每行中间有没有空隙
      this.MiddleGap() // 真 为某行中间有空隙
      this.EndPointGap() // 在没有中间空隙的条件下去判断最边上有没有空隙
    }
  • 判断两个数字之间有没有空隙
    MiddleGap () { // 检查每行中间有没有空隙
      // 当所有的数都是挨着的,那么 x 下标两两相减并除以组数得到的绝对数是 1 ,比他大说明中间有空隙
      // 先将 x 下标两两相减 并添加到新的数组
      let subarr = [[], [], [], []] // 两两相减的数据
      let sumarr = [] // 处理后的最终数据
      this.initData.forEach((items, index) => {
        items.forEach((item, i) => {
          if (typeof items[i + 1] !== 'undefined') {
            subarr[index].push(item.col - items[i + 1].col)
          }
        })
      })
      // 将每一行的结果相加得到总和 然后除以每一行结果的长度
      subarr.forEach((items) => {
        sumarr.push(items.reduceRight((a, b) => a + b, 0))
      })
      sumarr = sumarr.map((item, index) => Math.abs(item / subarr[index].length))
      // 最后判断有没有比 1 大的值
      sumarr.some(item => item > 1)
      this.middleGap = sumarr.some(item => item > 1) // 真 为 有中间空隙
    }
  • 判断数字有没有到最边上
   EndPointGap () { // 检查最边上有没有空隙
     // 判断是向左还是向右 因为左右的判断是不一样的
     this.endGap = true
     let end
     let initData = this.initData
     if (this.itIsLeft) {
       end = 0
       this.endGap = initData.some(items => items.length !== 0 ? items[0].col !== end : false)
     } else {
       end = 3
       this.endGap = initData.some(items => items.length !== 0 ? items[items.length - 1].col !== end : false)
     }
     // 取出每行的第一个数的 x 下标
     // 判断是不是最边上
     // 有不是的 说明边上 至少有一个空隙
     // 是的话说明边上没有空隙
   }

这样就将基本的判断是否有效,是否失败的条件都得到了
至于是否有可合并数字已经在数据初始化时就得到了

现在所有数据应该是这样的

1.png

渲染页面

Rendering (keyCode) {
      this.AddZero() // 先将占位符加上
      // 因为之前的数据都处理好了 所以只需要将上下的数据转换回去就好了
      if (keyCode === 38 || keyCode === 40) { // 38 是上 40 是下
        this.Copyarr = this.ToRotate(this.Copyarr)
      }
      if (this.haveGrouping || this.endGap || this.middleGap) { // 满足任一条件就说明可以新建随机数字
        this.RandomlyCreate(this.Copyarr)
      } else if (this.haveZero) {
        // 都不满足 但是有空位不做失败判断
      } else {
      // 以上都不满足视为没有空位,不可合并
        if (this.itIs2048) { // 判断是否达成2048
          this.RandomlyCreate(this.Copyarr)
          alert('恭喜达成2048!')
          // 下面注释掉的可让游戏在点击弹框按钮后重新开始新游戏
          // this.arr = [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]
          // this.RandomlyCreate(this.arr)
        } else { //以上都不满足视为失败
          this.RandomlyCreate(this.Copyarr)
          alert('游戏结束!')
          // this.arr = [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]
          // this.RandomlyCreate(this.arr)
        }
      }
      if (this.itIs2048) { // 每次页面渲染完,都判断是否达成2048
        this.RandomlyCreate(this.Copyarr)
        alert('恭喜达成2048!')
        // this.arr = [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]
        // this.RandomlyCreate(this.arr)
      }
    }
  • 随机空白处创建数字

这里之前是用递归函数的形式去判断,但是用递归函数的话会有很多问题,最大的问题就是可能会堆栈溢出,或者卡死(递归函数就是在函数的最后还会去调用自己,如果不给出 return 的条件,很容易堆栈溢出或卡死)
所以这次改成抽奖的模式,将所有的空位的坐标取到,放入一个数组,然后取这个数组的随机下标,这样我们会得到一个空位的坐标,然后再对这个空位进行处理

    RandomlyCreate (Copyarr) { // 随机空白处创建新数字
      // 判断有没有可以新建的地方
      let max = this.max
      let copyarr = Copyarr
      let zero = [] // 做一个抽奖的箱子
      let subscript = 0 // 做一个拿到的奖品号
      let number = 0 // 奖品号兑换的物品
      // 找到所有的 0 将下标添加到新的数组
      copyarr.forEach((items, index) => {
        items.forEach((item, i) => {
          if (item === 0) {
            zero.push({ x: index, y: i })
          }
        })
      })
      // 取随机数 然后在空白坐标集合中找到它
      subscript = Math.floor(Math.random() * zero.length)
      if (Math.floor(Math.random() * 10) % 3 === 0) {
        number = 4 // 三分之一的机会
      } else {
        number = 2 // 三分之二的机会
      }
      if (zero.length) {
        Copyarr[zero[subscript].x][zero[subscript].y] = number
        this.arr = Copyarr
      }
      this.total = 0
      this.arr.forEach(items => {
        items.forEach(item => {
          if (item === max && !this.itIs2048) {
            this.itIs2048 = true
          }
          this.total += item
        })
      })
    }

以上就是本次 2048 的主要代码
最后,因为随机出现4的几率我改的比较大,所以相应的降低了一些难度,具体体现在当所有数字都在左边(最边上),且数字与数字间没有空隙,再按左也会生成数字