有趣的粒子时钟

384 阅读13分钟

最近看到一个使用canvas绘制的粒子动效的时钟,感觉还挺有趣的。正巧最近有点空闲时间,决定实现一下这个时钟并对其进行扩展,封装成插件。实现后的插件支持时钟、计时器、计数器和倒计时等功能,同时插件配置灵活,并支持web应用和微信小程序。大概效果如上图

你可以扫描以下二维码体验在小程序下运行的计时器的例子:

count.png

插件目前已经开源,地址为github.com/prianyu/tim…

本文将以单纯的时钟为例,详细的讲解这个粒子动效的时钟并逐行进行代码的实现,完整的倒计时和计时器的实现仅在时钟的实现基础上,做了少部分的增改,你可以通过github查看完整功能的代码实现,欢迎fork并进行扩展实现更多的功能。

一、需求与基本思路分析

从上面例子的预览图可以看到,整个画布的绘制大致可以分为两个部分,数字的绘制以及运动的粒子的绘制。其中,数字本身也是由基本的粒子组成的,而运动的粒子则是在每一秒计时时,在需要改变的数字位置上生成一个相同的数字(颜色随机),再赋予一个运动的特效来实现。基本实现思路如下:

  1. 初始化画布,设置好各种绘制属性
  2. 获取并解析当前的时间,得到一个用于绘制的内容(如时间、倒计时),并绘制于画布上
  3. 开启定时器,重复获取并解析时间,并与当前画布渲染的时间进行比对
  4. 获得比对后需要渲染的内容差异,计算差异所在的位置,生成将要运动的粒子并绘制在对应的数字位上
  5. 为当前所有的粒子添加运动特效

除以上之外,如何将数字转化为用粒子组成的数字也是需要分析的。对于时间格式的字符串,只由两种字符组成,即数字和冒号,如下图:

grid.png

从上图可以看出,对于每一个不同的数字,我们都可以将其放置在一个7*10个格子的矩形上,通过在不同的格子填充或不填充粒子来模拟每一个数字。而对于冒号,则可以看做是放置在一个4*10的矩形上面。那么怎么才能判定每一个字符的格子是怎么填充的呢?有经验的同学应该很快就能想到,我们可以维护一个三维数组,数组里存放从0到9这10个数字以及冒号(所索引为10)总共11个元素,每一个元素又是一个二维数组,以行和列的方式存储不同字符对应位置是否需要填充粒子,是否需要填充,则可以通过0和1来进行标记,如数字2对照着这上方图片上划分的格子,得到的结果如下:

export default [
  [/*....*/], //数字0
  [/*....*/], //数字1
  [
    [0,1,1,1,1,1,0],
    [1,1,0,0,0,1,1],
    [0,0,0,0,0,1,1],
    [0,0,0,0,1,1,0],
    [0,0,0,1,1,0,0],
    [0,0,1,1,0,0,0],
    [0,1,1,0,0,0,0],
    [1,1,0,0,0,0,0],
    [1,1,0,0,0,1,1],
    [1,1,1,1,1,1,1]
  ], //数字2
  /* ... */ // 数字3~9以及:
]

按照以上思路,实现的重点和难点在于时间的比对、粒子差异位置的计算、粒子的运动特效三个方面。下文将基于此思路一点点实现整个插件。

二、代码实现

1. 数字列表

数字列表即是上方分析的用于通过粒子组成来模拟数字的字符列表,这个不需要特殊的逻辑,仅需将各个字符列举即可。完整字符列表如下:

const digits = [
  [
    [0,0,1,1,1,0,0],[0,1,1,0,1,1,0],[1,1,0,0,0,1,1],[1,1,0,0,0,1,1],[1,1,0,0,0,1,1],[1,1,0,0,0,1,1],[1,1,0,0,0,1,1],[1,1,0,0,0,1,1],[0,1,1,0,1,1,0],[0,0,1,1,1,0,0]
  ],//0
  [
    [0,0,0,1,1,0,0],[0,1,1,1,1,0,0],[0,0,0,1,1,0,0],[0,0,0,1,1,0,0],[0,0,0,1,1,0,0],[0,0,0,1,1,0,0],[0,0,0,1,1,0,0],[0,0,0,1,1,0,0],[0,0,0,1,1,0,0],[1,1,1,1,1,1,1]
  ],//1
  [
    [0,1,1,1,1,1,0],[1,1,0,0,0,1,1],[0,0,0,0,0,1,1],[0,0,0,0,1,1,0],[0,0,0,1,1,0,0],[0,0,1,1,0,0,0],[0,1,1,0,0,0,0],[1,1,0,0,0,0,0],[1,1,0,0,0,1,1],[1,1,1,1,1,1,1]
  ],//2
  [
    [1,1,1,1,1,1,1],[0,0,0,0,0,1,1],[0,0,0,0,1,1,0],[0,0,0,1,1,0,0],[0,0,1,1,1,0,0],[0,0,0,0,1,1,0],[0,0,0,0,0,1,1],[0,0,0,0,0,1,1],[1,1,0,0,0,1,1],[0,1,1,1,1,1,0]
  ],//3
  [
    [0,0,0,0,1,1,0],[0,0,0,1,1,1,0],[0,0,1,1,1,1,0],[0,1,1,0,1,1,0],[1,1,0,0,1,1,0],[1,1,1,1,1,1,1],[0,0,0,0,1,1,0],[0,0,0,0,1,1,0],[0,0,0,0,1,1,0],[0,0,0,1,1,1,1]
  ],//4
  [
    [1,1,1,1,1,1,1],[1,1,0,0,0,0,0],[1,1,0,0,0,0,0],[1,1,1,1,1,1,0],[0,0,0,0,0,1,1],[0,0,0,0,0,1,1],[0,0,0,0,0,1,1],[0,0,0,0,0,1,1],[1,1,0,0,0,1,1],[0,1,1,1,1,1,0]
  ],//5
  [
    [0,0,0,0,1,1,0],[0,0,1,1,0,0,0],[0,1,1,0,0,0,0],[1,1,0,0,0,0,0],[1,1,0,1,1,1,0],[1,1,0,0,0,1,1],[1,1,0,0,0,1,1],[1,1,0,0,0,1,1],[1,1,0,0,0,1,1],[0,1,1,1,1,1,0]
  ],//6
  [
    [1,1,1,1,1,1,1],[1,1,0,0,0,1,1],[0,0,0,0,1,1,0],[0,0,0,0,1,1,0],[0,0,0,1,1,0,0],[0,0,0,1,1,0,0],[0,0,1,1,0,0,0],[0,0,1,1,0,0,0],[0,0,1,1,0,0,0],[0,0,1,1,0,0,0]
  ],//7
  [
    [0,1,1,1,1,1,0],[1,1,0,0,0,1,1],[1,1,0,0,0,1,1],[1,1,0,0,0,1,1],[0,1,1,1,1,1,0],[1,1,0,0,0,1,1],[1,1,0,0,0,1,1],[1,1,0,0,0,1,1],[1,1,0,0,0,1,1],[0,1,1,1,1,1,0]
  ],//8
  [
    [0,1,1,1,1,1,0],[1,1,0,0,0,1,1],[1,1,0,0,0,1,1],[1,1,0,0,0,1,1],[0,1,1,1,0,1,1],[0,0,0,0,0,1,1],[0,0,0,0,0,1,1],[0,0,0,0,1,1,0],[0,0,0,1,1,0,0],[0,1,1,0,0,0,0]
  ],//9
  [
    [0,0,0,0],[0,0,0,0],[0,1,1,0],[0,1,1,0],[0,0,0,0],[0,0,0,0],[0,1,1,0],[0,1,1,0],[0,0,0,0],[0,0,0,0]
  ]//:
]

2. 构造函数

构造函数在插件实例化时调用,插件要求可以自由配置,所以构造函数应该支持自定义选项的设置,另外我们希望在插件实例化时能够为实例提供一些可操作的方法,所以实现构造函数的基本思路是接收一个配置对象,并返回可供实例使用的方法列表。

2.1 配置选项列表

配置选项提供的列表定义如下:

// 支持的时间格式列表
const FORMATS = ['h:i:s', 'h:i', 'i:s', 's']
// 默认配置
export const DEFAULT_OPTIONS = {
  // 粒子的色彩列表
  colors: ["#99CCFF","#0099CC","#FF9999","#FF0033", "#FFCC99","#FF6600","#99CC33","#339933","#CCCCFF","#993399","#FFFF66","#FFCC00", "#FF33CC", "#666633"],
  // 内容的颜色
  color: '#0081FF',
  // 画布大小
  width: 320,
  height: 200,
  // 水平与垂直方向的居中配置
  center: true,
  middle: true,
  // 画布的内边距
  padding: 20,
  // 字符大小
  size: 0,
  // 画布中渲染的粒子最大数
  ballCount: 300,
  // 内容的格式
  format: FORMATS[0]
}

2.2 构造函数实现

class Time {
  constructor(id = 'canvas', options = {}) {
    this.options = Object.assign(DEFAULT_OPTIONS, options) // 合并选项
    this.id = id || 'canvas' // 画布id
    this._balls = [] // 用于存储粒子的数组

    // 初始化画布
    const canvas = document.getElementById(this.id)
    canvas.width = this.options.width
    canvas.height = this.options.height
    this.context = canvas.getContext("2d")
    
    // 时间格式合法性校验
    if(!FORMATS.includes(this.options.format)) {
      this.options.format = FORMATS[0]
    }

    // 开始渲染并开启定时器
    this._init()
    // 返回一个可供实例调用的方法列表
    return this._getInstance()
 }

  _init() {
    this.play()
    // 实例化钩子
    this._emit("init", this._currentTime)
  }
}

这里约定,所有以_开头的方法和属性为私有属性,不提供给实例和外部使用

3. 状态方法

时钟在运行的过程中经历了多个状态,如初始化、运行、暂停、销毁、渲染、更新等。其中时钟的渲染和更新应该是实例内部自行执行的,而时钟的启动、暂停、销毁等操作不仅仅可以通过内部执行,也希望时钟可以通过实例在外部进行控制,因此我们需要为实例提供这几个方法方法,分别定义为playpausedestroy具体代码如下:

play() {
  if(!this._currentTime) { // 首次启动
    this._currentTime = this.getCurrentTime() // 获取当前时间
  }
  this._clearTimer() // 如果当前有定时器应该清空
  // 开启定时器
  this._timer = setInterval(() => {
    this._render() // 绘制画布
    this._update() // 更新下一个时间
  }, 50)
}

pause(reserve) {
   if(!this._timer) return
      this._clearTimer()
      if(reserve !== true) { // 是否保留画布运动的粒子状态
        this._timer = setInterval(() => {
        // 所有的粒子继续运动直至离开画布
          this._render()
          this._updateBalls() 
        }, 50)
      }
   }
}

destroy() {
  const { width, height } = this.options
  this._clearTimer()
  this.context.clearRect(0, 0, width, height)
  this._balls = []
}

_clearTimer() {
  clearInterval(this._timer)
  this._timer = null
}


对于pause方法,这里提供了一个reverve参数,用于指定在时钟停止时是否保留当前画布的粒子。什么意思呢?当时钟在运行的过程中,运动的粒子是不断产生并运动至画布的边缘消失的,暂停的时候这些粒子并不总是已经完全从画布上离开了。这就有了这样一个使用场景:当时钟暂停的时候,是希望运动的粒子也跟着停留在画布,保留当前所处的位置状态,还是希望粒子继续运动直至完全离开画布。这就是提供这个参数的意义,供实际的场景需要进行选择何种方式。默认情况下,是会让粒子完全运动至离开画布。

4. 获取当前时间

获取当前时间方法通过获取当前的时间,对时间进行格式化,方便后续的计算以及内容的生成,定义如下:

getCurrentTime(){
  const now = new Date()
  return {
    hours: now.getHours(),
    minutes: now.getMinutes(),
    seconds: now.getSeconds(),
    milliseconds: now.getMilliseconds(),
    now
  }
}

该方法也暴露给实例在外部进行调用。

构造函数、状态方法、获取当前时间的方法在实现单纯的时钟功能是非常简单的,但是如果要包含实现计时器、倒计时等功能则会复杂很多,除了简单的获取数据和处理状态以外,以上方法还需要考虑一些边界处理、状态机维护、格式转换等因素。比如play方法在包含计时器和倒计时时,定义如下:

play(state) {
  if(this._state.ended || !this._state.stopped) return
  if(!this._startTime) {
    this._startTime = this._currentTime = this.getCurrentTime()
    this._emit('start', this._currentTime)
  } else {
    if(state === 0) {
      this._startTime = this._currentTime = this.getCurrentTime()
      this._createState()
    } else if(state === 1) { 
      this._resetStartTime()
    }
  }
  this._state.stopped = false
  this._clearTimer()
  this._timer = setInterval(() => {
    this._render()
    this._update()
  }, 50)
}

可见,完整的play方法相比单纯的时钟的play要复杂不少,其他方法完整的代码可参考github上的源码。

回看构造函数以及_init方法末尾的代码

  /* --construtor末尾代码----- */
  this._init()
  // 返回一个可供实例调用的方法列表
  return this._getInstance()

  /*------_init方法末尾的代码------*/
  this._emit("init", this._currentTime)

构造函数最后会给实例提供一个方法列表以及在特定的时机会提供一个钩子,在实现完以上的方法之后,接下里就可以来实现这两部分的代码了,也是比较简单,代码如下:

_emit(type, ...params) {
  const callback = this.options[type]
  if(typeof callback === 'function') {
    return callback(...params)
  }
}

_getInstance() {
  let obj = {}
  let keys = ['pause', 'play', 'getCurrentTime', 'destroy']
  keys.map(item => {
    obj[item] = this[item].bind(this)
  })
  return obj
}

在使用时可以这样使用

const time = new Time('canvas', {
  init: () => {
    console.log("实例化")
  }
})

setTimeout(() => {
  time.pause(true)
}, 1000)

到此,已经把一些比较简单的逻辑实现完了,接下来将实现诸如画布渲染、位置计算、时钟更新等核心逻辑的代码。

5. _render及其相关方法

_render方法专门用于负责画布的绘制,包括当前时钟产生的字符的绘制以及运动粒子的绘制。在绘制的过程中,主要涉及到的逻辑有内容的获取、绘制的位置计算、绘制内容、绘制运动粒子等逻辑。以下将逐步展开并解析。

5.1 _render方法

_render(){  
  const ctx = this.context
  // 获取画布大小
  const { width, height } = this.options 
  // 负责字符渲染的方法
  const renderDigit = this._renderDigit.bind(this) 
  // 将当前日期接续为渲染的字符串
  const renderData = this._getRenderData(this._currentTime) 
  // 计算绘制的起始绘制和粒子大小
  this._rect = this._getRect(renderData)
  const { top } = this._rect

  ctx.clearRect(0, 0, width, height)

  // 遍历字符,获取字符在画布绘制的位置并绘制
  for(let i = 0; i < renderData.length; i++) {
    renderDigit(this._getOffset(renderData, i), top, renderData[i])
  }

  // 绘制运动的粒子
  this._renderBalls()
}

5.2 _getRenderData方法

_getRenderData方法负责根据定义的时间格式,将时间进行格式化并以数组的方式返回最终需要渲染的内容,定义如下:

const pad = n => n < 10 ? `0${n}` : n // 数字格式化

_getRenderData(time) {
  const { hours, minutes, seconds } = time
  let str = this.options.format
  str = str.replace('h', pad(hours)).replace('i', pad(minutes)).replace('s', pad(seconds))

  return str.split('')
}

同样,该方法在单纯的时钟功能上比较简单,在倒计时和计时器里也会涉及到其他的特殊处理。

5.3 _getRect方法

在实现构造函数时,针对实例时的选项,我们提供了各种配置,比如文字大小、是否居中、内边距等配置。_getRect则是负责根据提供的这些配置,来计算当前的渲染内容应该绘制的起始位置以及绘制的每一个粒子的大小。_getRect计算位置时会按照当前画布的大小作为参考依据,如果配置的文字大小和画布大小比例不合理,可能会导致绘制的内容超出画布,进而导致绘制的不完整的情况。_getRect不负责处理这些异常情况,只负责简单的计算正确的位置。

_getRect(arr) {
  const { width, height, padding, size, center, middle } = this.options
  // 去除内边距,计算有效的绘制大小
  const validWidth = width - padding * 2
  const validHeight = height - padding * 2

  // count用于统计当前会绘制的内容水平方向包含多少个粒子半径
  let left, top, radius, count = 0

  for(let i = 0; i < arr.length; i++) {
    if(arr[i] !== ':') {
      count += 14 // 数字有7个粒子,所以是14个半径
      if(arr[i + 1] && arr[i + 1] !== ':') {
        // 如果数字后面还是数字,则多出一个粒子的宽度作为两数字之间间距
        count += 2
      }
    } else {
      // :号占据9个半径(含左右间距)
      count += 9
    }	
  }

  // 粒子半径,指定了字符大小按照字符大小计算,否则按照画布宽度平分,另外为每个粒子留出1px的间隙
  if(size > 0) {
    radius = size / 14 - 1
  } else {
    radius = validWidth / count - 1
  }
  radius = Math.max(1, radius)

  // 坐标原点,按照是否居中计算内容绘制的起始位置
  if(center) {
    left = padding + validWidth / 2 - count / 2 * (radius + 1)
  } else {
    left = padding
  }

  if(middle) {
    top = padding + validHeight / 2 - 10 * (radius + 1)
  } else {
    top = padding
  }

  return {
    left, 
    top,
    radius
  }
}

5.4 _getOffset方法

确定了内容绘制的起始位置以及粒子的大小后,内容上每一个字符的位置就可以确定了。_getOffset方法就是专门用来计算每一个字符绘制的起始位置。

// 绘制的内容,字符索引
_getOffset(arr, index) {
  // 内容最左端的位置
  let offset = this._rect.left
  // 上面粒子半径计算时留了1px的间隙,实际整个粒子占用的半径是radius+1
  const size = this._rect.radius + 1
  for(let i = 0; i <= index; i++) {
    const last = arr[i - 1]
    if(last) {
      // 计算偏移量每个数字15个占位,冒号9个
      offset += (last === ':' ? 9 : 15) * size
    }
  }
  return offset
}

5.5 _renderDigit方法

_renderDigit就是用来绘制字符的方法,主要是根据传入的字符,从digits三维数组中取出对应的字符,并计算每一个粒子的位置并将其绘制在画布上。逻辑还是比较简单。

_renderDigit(x, y, num){
  const ctx = this.context
  const radius = this._rect.radius
  const size = radius + 1

  // 冒号的索引是10
  num = num === ":" ? 10 : parseInt(num)
  ctx.fillStyle = this.options.color

  for(let i = 0; i < digit[num].length; i++){ // 遍历每行
    for(let j = 0; j < digit[num][i].length; j++){ // 遍历每行的每列
      if(digit[num][i][j] == 1){
        ctx.beginPath()
        // 绘制圆点
        ctx.arc(x + size + j * 2 * size, y + size + i * 2 * size, radius, 0, 2 * Math.PI)
        ctx.closePath()
        ctx.fill()
      }
    }
  }
}

5.6 _renderBalls方法

每更新一次时间都会产生不同的字符,前后有差异的字符需要在上方绘制一个同样大小的数字,并开启动画。_renderBalls则用来负责绘制这个字符的方法。其基本的逻辑就是从_balls中取出所有的粒子,绘制在画布上。

_renderBalls() {
  const balls = this._balls
  const radius = this._rect.radius
  const ctx = this.context
  for( let i = 0; i < balls.length; i ++ ){
    ctx.fillStyle = balls[i].color
    ctx.beginPath()
    ctx.arc( balls[i].x , balls[i].y , radius , 0 , 2 * Math.PI , true )
    ctx.closePath()
    ctx.fill()
  }
}

6. _update及其相关方法

6.1 _update方法

绘制好每一帧时间后,需要不断的更新时间并且更新画布。_update方法就是用来更新当前时间,找出前后两帧具有差异的字符,然后往_balls里添加待绘制的粒子,在下一次render时,根据_udpate方法更新后的最新数据来更新画布,从而实现时间的流逝和粒子的运动。

_update(){
  // 获取当前时间,并格式化
  const nextTime = this.getCurrentTime() 
  const nextRenderData = this._getRenderData(nextTime)
  // 获取上一次绘制的时间
  const lastRenderData = this._getRenderData(this._currentTime)
  const { top } = this._rect
  
  // 时间改变的钩子
  this._emit('change', nextTime)

  // 如果前后两次时间格式化后存在有差异的字符,添加待绘制的粒子
  if(nextRenderData.join("") != lastRenderData.join("")){
    for(let i = 0; i < lastRenderData.length; i++) {
      if(nextRenderData[i] != lastRenderData[i]) {
        // 找出差异的字符所在的起始位置
        this._addBall(this._getOffset(lastRenderData, i), top, lastRenderData[i])
      }
    }
    // 更新当前时间
    this._currentTime = nextTime
  }
  // 更新所有粒子
  this._updateBalls(nextTime)
}

6.2 _addBall方法

_addBall方法用于产生每一个需要绘制到画布的粒子。

_addBall( x , y , num ){
  const { radius } = this._rect
  const size = radius + 1
  const colors = this.options.colors
  
  for(let i = 0; i < digit[num].length; i ++)
    for(let j = 0; j < digit[num][i].length; j++)
      if( digit[num][i][j] == 1){
        let ball = {
          // 粒子的位置
          x: x + j * 2 * size + size,
          y: y + i * 2 * size+ size, 
          // 重力加速度
          g: 1.5 + Math.random(), 
          // 水平和垂直方向的运动速度
          vx: Math.random() > 0.5 ? 5 : -5,
          vy: -5,
          // 随机颜色
          color: colors[Math.floor(Math.random() * colors.length)]
        }
        this._balls.push( ball )
    }
}

6.3 _updateBalls方法

产生的粒子在第一帧绘制以后,需要不断的更新位置,不断绘制,让粒子有运动起来的效果。因此需要一个运动的算法。此处,定义粒子不断的往画布右下方(左右随机)的方向运动,当触达右侧及底部边缘的时候,粒子反弹向反方向运动,运动至画布左侧时即将粒子从画布移除。这是一个比较简单的运动算法,做过一些运动算法的同学应该并不陌生,逻辑其实也不是特别复杂,主要的逻辑在于加速度的定义。以下是实现的一种方案,实际可以不断的去调各个参数,达到满意的运动状态。

_updateBalls(){
  const { width, height } = this.options
  const balls = this._balls
  const radius = this._rect.radius

  for(let i = 0; i < balls.length; i++ ){
    // 按照初始的各种属性进行位置更新
    balls[i].x += balls[i].vx
    balls[i].y += balls[i].vy
    balls[i].vy += balls[i].g

    // 达到画布底部或者右侧反弹(改变加速度方向)
    if( balls[i].y >= height - radius){
      balls[i].y = height - radius
      balls[i].vy = -balls[i].vy * 0.75 // 触底反弹
    } else if(balls[i].x >= width -radius){
      balls[i].x = width -radius
      balls[i].vx = -balls[i].vx * 3 // 右侧反弹,速度为原来的3倍
    }
  }

  // 保留还在画布内的粒子,移除在画布左侧以外的粒子
  let cnt = 0
  for(let i = 0; i < balls.length; i++){
    if(balls[i].x + radius > 0 && balls[i].x - radius < width) {
      balls[cnt++] = balls[i]
    }
  }
  while(balls.length > Math.min(this.options.ballCount, cnt)){
    balls.pop()
  }
}

三、总结

到此,整个粒子时钟的代码就已经实现完毕了,从整体上来讲,单纯的时钟实现起来逻辑还是比较简单的。总体按照“初始化时间->绘制时间->开启定时器->比对时间->生成时间串差异的字符->生成运动粒子并绘制->开启粒子运动..."这样的逻辑来完成整个插件的代码。如果要实现倒计时和计时器,则需要针对几个核心的方法进行扩展,情况就稍微复杂一点。含有时钟、倒计时、计时器的代码可以fork仓库上的代码,地址为github.com/prianyu/tim…。这个仓库实现了web和小程序下两个完整的版本,也提供了所有的例子。以下为小程序下使用插件后的效果预览:

你也可以通过扫描以下二维码体验