前言

好一段时间没写文章了,因为最近在忙着找工作和做些其他有的没的事情,东西多了容易忘记,但写代码还是每天的事,再熟悉了一段时间的公司架构代码之后,撑着闲暇空隙做了这个小游戏锻炼一下玩玩。起因是因为在一次的面试过程中,一个面试官拿着个这个游戏跟我说这个游戏里面的实现和思路,当时也说了一部分,很明显的被遭到了鄙视,但这都无所谓,我也只是简单的讲了一下,具体还是要做的时候去思考,临场的说辞也不能说明什么,只能说思维不敏捷,思考不周到,我始终觉得做过就有印象,即使看的再多没有实战和手写一步步实现,没过多久肯定会忘记。面的时候问了一些无关紧要的问题,我也感觉到各自相互看不上,也就不提了,但这个他拿出来的这个游戏我算是记住了玩法,刚好前几天在刷短视频的时候看到这个小程序,让我想起之前的插曲,撑着这几天的五一放假写了这个小游戏。

了解小游戏玩法

开始前先 在线体验 下,了解一下玩法内容;

colorsort

可以看观察动图可得;

  1. 随机创建四个瓶子并且随机的颜色块
  2. 点击瓶子时把当前颜色块的最上一个放到另外一个瓶子的最上面
  3. 如果当前的颜色大于2是一样的则叠加倒入到另一个瓶子的最上面
  4. 可以返回上一步历史记录操作
  5. 切换难度 0(普通), 1(中等), 2(困难), 3(非常难)
  6. 记录多少步长

整理上面的需求和思路,可以试着写出来。

拆分需求内容

面对上面给出来的问题我这边简单的做一个需求的拆分,以便写起来的时候不会没有思路。首先想到的是这个游戏的核心内容就是颜色的替换和转变,但本质上还是考验数组之间的变化和获取,知道这个了其实不难,难在第一步如果处理数组和获取数组中的数据。

当游戏开始时生成一个数组 const colorArr = [] 维护多个试管(图中就四个,三个有颜色的一个空的),然后再根据固定的颜色(可以是外边传的颜色,也可以写成固定多个颜色)生成十二种颜色,当然这个些颜色需要四个四个一样的,加上最后的透明色 transparent 总共十二种(对于动图而言),再把全部的颜色打乱之后在切割数组成四份,拿到数据就可以做一个 Dom 的循环渲染了,渲染的过程中加上各个 Dom 元素的监听事件和 change 事件,当点击瓶子时可以做一个 Flag 判断是否是第一次点击和第二次点击,在第二次点击的时候再做数组的替换和改变 Dom 重新渲染页面。差不多总体上就这么简单了,具体的功能处理下面细说。

初始化操作

这里我用的 Es6 的 class 写的插件,具体看代码;

class ColorSort {
  // 默认参数
  static DEFAULT_OPTIONS = {
    el: null,    // 游戏的容器
    level: 0,    // 难度
    num: 4    // 初始化的颜色块数量
  }
  constructor(options) {
    this.options = Object.assign({}, ColorSort.DEFAULT_OPTIONS, options)
    this.colorArr = []
    this.init()
  }
  init() {
    // 创建 DOM 元素渲染
    this.options.el.innerHTML = `
                <div class="color-sort__btns">
                    <div class="btn color-sort__prev">上一步</div>
                    <div class="btn color-sort__reset">重置</div>
                    <div class="btn color-sort__restart">新开</div>
                    <div class="btn color-sort__level">
                        <select class="color-sort__select">
                            <option value="0">初级</option>
                            <option value="1">中级</option>
                            <option value="2">困难</option>
                            <option value="3">特难</option>
                        </select>
                    </div>
                </div>
                <div class="color-sort__wrapper"></div>
                <div class="color-sort__step">0</div>
       `
    // 创建随机颜色
    this.colorArr = this.randomColorArr()
    // 渲染界面
    this.render()
  }
}

获取随机颜色

根据分析可知,在渲染 Dom 之前需要去随机获取颜色数据操作,每个颜色随机并分布到三个瓶子内排列,通过拿到的数据可以循环渲染。

// 重新渲染 colorArr 数组
randomColorArr() {
  // 保存所有瓶子颜色的栈
  let arr = []
  // 根据等级创建多个瓶子,并且保证最后一个瓶子为空
  for (let i = 0; i < this.options.level + 3; i++) {
    const color = this._createEmpty()
    arr = arr.concat(color)
  }
  // 打乱颜色
  const colorShuffle = this._shuffle(arr)
  // 重组颜色
  const colorArr = this._rebuild(colorShuffle)
  // 在后面添加一个空的瓶子
  colorArr.push(this._createEmpty('transparent'))

  return colorArr
}

获取随机的颜色操作,根据等级 this.options.level 创建多个瓶子,并且保证最后一个瓶子为空,通过 createEmpty 方法创建随机的颜色,然后再 _shuffle 打乱颜色。

// 打乱颜色
_shuffle(arr) {
  // 复制一份数组
  let _arr = arr.slice()
  for (let i = 0; i < _arr.length; i++) {
    let j = this._getRandomInt(0, i)
    let t = _arr[i]
    _arr[i] = _arr[j]
    _arr[j] = t
  }
  return _arr
}
// 获取一个随机数,区间是 [min, max)
_getRandomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1) + min)
}

拿到打乱之后的颜色再根据一个瓶子内有多少颜色数用 slice() 方法进行切割(这里传的是num为4),通过 _rebuild 方法切割之后重组。这里可以说一个知识点,拿到的数组需要进行 slice() 或者 ... 方法做一个拷贝,避免传来的数据污染,但是 slice() 里面如果还有引用类型的话就可以用 JSON.parse(JSON.stringify(arr)) 做一个拷贝处理,然后再返回一个新的重组之后的值。

// 重组颜色
_rebuild(arr) {
  const result = []
  const _arr = arr.slice()
  const group = _arr.length / this.options.num
  // 做一下数据切割,保证每个瓶子有 5 个颜色
  for (let i = 0; i < group; i++) {
    result.push(_arr.slice(i * this.options.num, (i + 1) * this.options.num))
  }
  return result
}

重组之后的值还缺少一个空的数据瓶子,所以需要在后面需要加一个 transparent 的空瓶子。做好数据的初次处理之后,就开始下面的页面渲染。

页面渲染

先把 Dom 区域通过 querySelector('.color-sort__wrapper') 获取一下,然后 wrapper.appendChild() 插入到区域内,最后再插入到传进来的 el 中。

在做渲染之前思考一个问题,怎么让瓶子按照一行3个的排列方式往下布局?

其实很简单,可以通过 % 取余的方式拿到当前循环的索引取余数是否等于 0,当结果等于 0 时,可以做一个外部变量 index++ 当然第一次的索引不计算在内,得到的余数的值可以作为 left 的偏移量计算,看下面具体实现。

render() {
  const colorArr = this.colorArr
  // 一行有 3 个
  const col = 3
  // 用于计算的变量值
  let index = 0
  for (let i = 0; i < colorArr.length; i++) {
    const calc = i % col
    if (calc === 0 && i !== 0) {
      index++
    }
    // 创建瓶子 
    const item = document.createElement('div')
    item.className = 'color-sort__item'
    item.style.height = `${height}px`
    // ...
  }
}

在上面的图上也可以看到,到了颜色的过程当中是有一个动画的效果的,就是比喻“瓶子的水减少和增加”的动画。一开始在做的时候没考虑那么多,用的 position: absolute 定位,然后加上瓶内的单个颜色根据 top 值做一个定位排布,但是那样做完之后就发现,在做动画的时候出现了比较麻烦的事,解决方法就是新创建一个dom 元素覆盖到颜色上,在把原本的颜色设置为透明或者去掉,再通过上面的 dom 的高度递减到 height: 0 即可,这样的话就需要多操作几步还比较麻烦,所以想了想,换种思路。

可以通过 z-index 加上 bottom: 0 定位到底部的方式一开始把高度都写好,然后再去高度递减不就可以了嘛,看下面的图的解释效果。

colorsort.png

这样之后需要改变对应的第一个的高度即可实现动画效果,如果使用第一种方案的话操作起来相对麻烦。

// 单个色块的 宽度
static WIDTH = 40
// 单个色块的 高度
static HEIGHT = 30
// 渲染
render() {
  const colorArr = this.colorArr
  const wrapper = this.options.el.querySelector('.color-sort__wrapper')
  // 计算需要有多少列,如果超出颜色数组长度大于 6 就改成一行 4 个(纯粹是为了好看点)
  const col = colorArr.length > 6 ? 4 : 3
  // 单个瓶子的间距
  const space = 40
  // 单个瓶子上方的空白间隙
  const padding = 10
  // 计算一个瓶子的高度
  const height = this.options.num * ColorSort.HEIGHT + padding
  // 用于计算的变量值
  let index = 0

  // 创建瓶子
  for (let i = 0; i < colorArr.length; i++) {
    const calc = i % col
    if (calc === 0 && i !== 0) {
      index++
    }
    // 创建一个瓶子
    const item = document.createElement('div')
    item.className = 'color-sort__item'
    item.style.height = `${height}px`
    item.style.top = `${(height * index) + (space * index)}px`
    item.style.left = `${(calc * ColorSort.WIDTH) + (calc * space)}px`

    // 绑定点击事件
    this._addEvent(item, 'click', this.handleClick.bind(this, item, i))
    for (let j = 0; j < this.options.num; j++) {
      const color = document.createElement('div')
      color.className = 'color'
      color.style.height = `${(this.options.num - j) * ColorSort.HEIGHT}px`
      color.style.zIndex = j
      color.style.backgroundColor = colorArr[i][j]
      item.appendChild(color)
    }
    wrapper.appendChild(item)
  }
  // 给父级元素添加宽高
  wrapper.style.width = `${ColorSort.WIDTH * col + (col - 1) * space}px`
  wrapper.style.height = `${height * (index + 1) + index * space}px`
  // 最后插入到 dom 中
  this.options.el.appendChild(wrapper)
  // 结束动画状态
  ColorSort.isMoving = false
}

最后在给其父级加上宽高实现动态的上下左右居中,当然父级还要加上下面css 代码;

.color-sort .color-sort__wrapper {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

上面在创建瓶子的 dom 时就做了绑定事件 handleClick 的处理,接下来开始做点击切换数据的操作了,针对第一次点击和第二次点击分别做不同的处理。

点击瓶子操作

分析一下需求点击操作:

  1. 判断第二次点的瓶子是否满格(满格为 this.options.num 次),如果满格,置换成第一次点击
  2. 第一次点击的最上面的瓶子内颜色应该, 加入到第二次点击的瓶子最上面的颜色中
  3. 如果第一次点击的瓶子上面同时有两个或者两个以上的相同的颜色,则再第二次点击过程中把相同的颜色都倒入到下个瓶子上面
  4. 点击之后数据改变,重新渲染操作
  5. 保存历史记录
// 点击的次数,是否是第一次点击(为 0)或者第二次点击(为 1)
static flag = 0
static firstDom = null
static secondDom = null
static firstIndex = 0
static secondIndex = 0
static firstPostion = {}

// 点击事件
handleClick(dom, index) {
  // 动画正在运行时不能点击
  if (ColorSort.isMoving) return
  // 第一次点击
  if (ColorSort.flag === 0) {
    // 如果点击的是空瓶子,则不做任何操作
    let isOk = this.colorArr[index].find((item) => item !== 'transparent')
    if (!isOk) {
      alert('第一次不能点击的是空瓶子哦~')
      return
    }
    this.setFirstCommon(dom, index)
  }
  // 第二次点击
  else if (ColorSort.flag === 1) {
    ColorSort.isMoving = true

    ColorSort.secondIndex = index
    ColorSort.flag = 0
    ColorSort.secondDom = dom

    ColorSort.firstDom.style.transform = 'scale(1)'
        // ...
  }
}

// 设置初始属性
setFirstCommon(dom, index) {
  ColorSort.isMoving = false

  ColorSort.firstIndex = index
  ColorSort.flag = 1
  ColorSort.firstDom = dom
  // 保存第一次点击的位置
  ColorSort.firstPostion = {
    top: dom.offsetTop,
    left: dom.offsetLeft,
  }

  ColorSort.firstDom.style.transform = 'scale(1.08)'
}

处理第二次点击的时候单独说一下,在确保可以点击的条件有一个,第二次点击的瓶子内透明色块的个数一定是大于或者等于第一次点击的瓶子内首个有颜色的个数值且颜色相同,除此之外的所有情况都可点击,当然不包括边界条件(动画中或者成功后)。

如果想要处理颜色之间的转换,就需要找到两个瓶子中从上到下首次出现色块的位置,即色块的索引值,相同色块的个数和色块的颜色,三个关键的数据。

简单来说,如何要在一个数组里面找出第一个相同的数据那一项 ?

比如 const arr = ['transparent', 'red', 'red', 'pink', 'green'],找出 red 项位于数组的多少索引,后面跟了多少个 red 的个数,以及 red 这个颜色。

// 获取点击时瓶子内的最上层颜色(**关键代码**)
_getColor(arr) {
  const obj = {
    index: arr.length,
    count: arr.length,
    color: 'transparent'
  }
  for (let i = 0; i < arr.length;) {
    if (arr[i] === 'transparent') {
      i++
      continue
    }
    let count = 0
    for (let j = i; j < arr.length; j++) {
      if (arr[i] === arr[j]) {
        count++
      } else {
        break
      }
    }
    obj.color = arr[i] // 透明色之后出现的第一个颜色值
    obj.count = count // 连续出现相同颜色的次数
    obj.index = i // 可以代表透明色之后出现的第一个颜色的索引,也可以代表透明的颜色的个数
    break
  }
  return obj
}

有了上面的工具函数之后就可以做下面的数据操作了

// 取出第一次点击的数组
const firstArr = this.colorArr[ColorSort.firstIndex]
// 取出第二次点击的数组
const secondArr = this.colorArr[ColorSort.secondIndex]

// 第一次点击的数组下首个有颜色的色块的 索引,个数和颜色值
const firstColor = this._getColor(firstArr)
const secondColor = this._getColor(secondArr)

if (ColorSort.firstIndex === ColorSort.secondIndex) {
  this.setFirstCommon(dom, index)
  return
}

// 当第一次点击的颜色个数大于第二次点击的瓶子内的透明颜色个数时,判断是超出的,则重新设置成第一次点击的瓶子
if (firstColor.count > secondColor.index) {
  this.setFirstCommon(dom, index)
  return
}

// 先取出第二次点击的透明颜色的个数,即,等于第一次点击的相同颜色的个数
const transparentArr = secondArr.slice(0, firstColor.count)

const firstArrDel = firstArr.splice(
  firstColor.index,
  firstColor.count,
  ...transparentArr
)

secondArr.splice(
  secondColor.index - firstColor.count,
  firstColor.count,
  ...firstArrDel
)

// 给第一次的操作的数据修改
this.colorArr.splice(ColorSort.secondIndex, 1, firstArr)
// 给第二次的操作的数据修改
this.colorArr.splice(ColorSort.secondIndex, 1, secondArr)

// 保存历史记录
this.addHistory()
// 添加动画
this.animate(firstColor, secondColor)

至此所有点击操作相关的切换数据已完成,然后再加上瓶子移动的动画和颜色高度为 0 的动画处理。

执行动画

第二次可以点击的瓶子的条件是必须包含有一个或者多个 transparent 色块,想要添加新的色块到第二个瓶子中有三种情况;

  • 第一,相同颜色的添加,就是第一次和第二次的瓶子内第一个颜色都相同
  • 第二,不同颜色的添加
  • 第三,添加到空瓶中

第一中的做法很简单就是在第二个瓶子中找到第一个颜色块,给其高度改变就可以,第二种如果要改变高度的话颜色就对不上了,第三个需要重新设置颜色和高度。所以综合考虑的想还是直接去第二次点击瓶子中的最开始的一项 transparent 给其高度设置为 0,然后设置颜色,再延迟设置高度改变实现动画。

// 执行动画
animate(firstColor, secondColor) {
  // 可以直接取第一个颜色作为动画的改变,因为不管怎样第一个颜色始终会是 transparent
  // 设置第二次点击瓶子中第一个颜色的初始高度
  const secondColorsDom = ColorSort.secondDom.querySelectorAll('.color')
  // 设置第二个颜色的初始高度
  secondColorsDom[0].style.height = 0

  ColorSort.firstDom.style.zIndex = 100
  ColorSort.firstDom.style.top = `${ColorSort.secondDom.offsetTop - 90}px`
  ColorSort.firstDom.style.left = `${ColorSort.secondDom.offsetLeft - 70}px`
  ColorSort.firstDom.style.transform = 'rotate(75deg)'

  setTimeout(() => {
    // 获取点击第一次下面的全部色块
    const firstColorsDom = ColorSort.firstDom.querySelectorAll('.color')
    // 如果超过 1 个 就没有后面的相同,超过一个就去除掉
    // 去掉除第一个颜色下所有相同的颜色
    if (firstColor.count > 1) {
      const j = firstColor.index + firstColor.count - 1
      for (let i = firstColor.index; i <= j; i++) {
        if (i !== firstColor.index) {
          firstColorsDom[i].classList.add('remove')
        }
      }
    }
    // 给其添加样式,让高度为 0
    firstColorsDom[firstColor.index].classList.add('active')

    // 设置动画色块的初始颜色
    secondColorsDom[0].style.backgroundColor = firstColor.color
    // 开始加入动画(向上溢出),实际高度 = (第一次点击的颜色个数 + 瓶子总格数 - 第二次点击的颜色索引)* 色块高度
    secondColorsDom[0].style.height = `${(firstColor.count + (this.options.num - secondColor.index)) * ColorSort.HEIGHT}px`
  }, 800)

  // 监听动画完成
  setTimeout(this.listenAnimate.bind(this), 1200)
}

做完动画之后,再返回到初始位置,因为在第一次点击的时候已经记录过了 firstPostion 这个值,所以在返回的时候只需要重新设置 firstDom.styletopleft 就可以了。具体代码实现可以看源码。

添加历史记录

最后加上一个历史记录的操作,只要每次都保存一下第二次改变数据之后的值,放到一个数组里缓存,待点击上一步时可以使用 pop 方法删除最后,再取出最后一个数据,重新赋值给 colorArr 即可

// 添加历史记录
addHistory() {
  ColorSort.history.push(JSON.parse(JSON.stringify(this.colorArr)))
  // 如果历史记录超过 10 条,则删除最早的一条
  if (ColorSort.history.length > ColorSort.MAX_HISTORY_LENGTH) {
    ColorSort.history.splice(0, 1)
  }
}
// 撤销
prevHistory() {
  if (ColorSort.history.length === 1) {
    alert('没有上一步了')
    return
  }
  ColorSort.history.pop()
  const color = ColorSort.history[ColorSort.history.length - 1]
  this.colorArr = JSON.parse(JSON.stringify(color))
  this.render()
}

算是把上面的基本功能都搞了一遍了。

总结

核心思想还是做数据的改变,然后再通过数据改变之后的值重新去渲染瓶子和颜色,瓶子抬起和倾斜的动画是用的 css3transform: rotate(75deg) 处理当然 transition 给的属性应该是 all ,再就是处理动画中间的临界点,在瓶子动画的过程中是不能继续下次点击,这样会导致点击的判断错乱,所以加了一个isMoving 的判断,再就是细节瓶内的颜色块高度和 z-index 处理,让颜色平铺瓶内,利用 bottom: 0 的属性都居在底部,再实现高度改变为 0 的动画,最后的几个功能就是加入难度的调整和返回上一步等。

项目本身不复杂,但做出来还是蛮好玩的,当锻炼一下。如有什么问题和bug,欢迎下面留言。