Canvas 粒子时钟

1,549 阅读3分钟

我正在参加「码上掘金挑战赛」详情请看:码上掘金挑战赛来了!

码上掘金 Demo 展示

目录

问题来了,绘制粒子时钟一共分几步?

第一步,创建一个隐藏的文本canvas

因为需要绘制出表示时间的粒子效果。所以我们需要一个隐藏的 canvas,先绘制出表示当前时间的文本内容。

通过 Date 对象获取当前时间的时、分、秒

const now = new Date()
const h = now.getHours().toString().padStart(2, '0')
const m = now.getMinutes().toString().padStart(2, '0')
const s = now.getSeconds().toString().padStart(2, '0')
const text = `${h}:${m}:${s}`

创建 canvas 绘制时间文本

const textCanvas = document.createElement('canvas')
const textCtx = textCanvas.getContext('2d')
textCanvas.width = cWdith
textCanvas.height = cHeight
textCtx.font = '280px SimSun, Songti SC '
textCtx.textAlign = 'center'
textCtx.textBaseline = 'middle'
textCtx.fillStyle = 'rgb(243, 15, 15)'
textCtx.fillText(text, cWdith / 2, cHeight / 2)

通过 context.getImageData() 来获取时间文本的像素点。配置 distance 是为了使粒子展示的时候分散一些

const imgData = textCtx.getImageData(0, 0, cWdith, cHeight).data
// 粒子点位数组
const curDotArr = []
for (let x = 0; x < cWdith; x += distance) {
  for (let y = 0; y < cHeight; y += distance) {
    const i = ((y * cWdith) + x) * 4;
    // 243 对应的是上面设置的字体颜色 rgb(243, 15, 15)
    if (imgData[i] === 243) {
      curDotArr.push({ x, y })
    }
  }
}

这样通过第一步,我们就拿到了一个时间的所有粒子点位。接下来就是来绘制粒子

第二步,绘制粒子, 创建递归延时器

所谓的‘粒子’其实不过就是 canvas 绘制出来的实心圆而已

封装 Particle 粒子对象

function Particle(opt) {
  this.ctx = opt.ctx
  this.canvas = opt.ctx.canvas
  this.x = opt.x // 坐标
  this.y = opt.y // 坐标
  this.color = opt.color // 颜色
  this.radius = opt.radius // 半径
}

Particle.prototype.draw = function() {
  this.ctx.beginPath()
  this.ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2)
  this.ctx.fillStyle = this.color
  this.ctx.closePath()
  this.ctx.fill()
}

创建递归延时器,绘制每一秒时间文本的粒子。给粒子设置点位 Id,可避免一些粒子实例的重复创建

// 循环粒子集合,创建粒子实例
function computed(dotArr) {
  const arr = []
  dotArr.forEach(dot => {
    let particle = null
    const id = `${dot.x}-${dot.y}`
    const tX = dot.x + utils.getRandom(-15, 15)
    const tY = dot.y + utils.getRandom(-15, 15)
    if (particleList.length) {
      particle = particleList.shift()
      if (particle.id !== id) {
        particle.id = id
        particle.x = tX
        particle.y = tY
      }
    } else {
      particle = new window.Particle({
        ctx,
        x: tX,
        y: tY,
        color: utils.getRandomColor(),
        radius: 2
      })
      particle.id = id
    }
    arr.push(particle)
  })
  particleList = arr
}

// 递归绘制时间,获取时间文本粒子集合
function timer() {
  setTimeout(() => {
    textCtx.clearRect(0, 0, cWdith, cHeight)
    const now = new Date()
    const h = now.getHours().toString().padStart(2, '0')
    const m = now.getMinutes().toString().padStart(2, '0')
    const s = now.getSeconds().toString().padStart(2, '0')
    const text = `${h}:${m}:${s}`
    textCtx.fillStyle = 'rgb(243, 15, 15)';
    textCtx.fillText(text, cWdith / 2, cHeight / 2)
    const imgData = textCtx.getImageData(0, 0, cWdith, cHeight).data
    const curDotArr = []
    for (let x = 0; x < cWdith; x += distance) {
      for (let y = 0; y < cHeight; y += distance) {
        const i = ((y * cWdith) + x) * 4;
        if (imgData[i] === 243) {
          curDotArr.push({ x, y })
        }
      }
    }
    computed(curDotArr)
    draw()
    timer()
  }, 1000)
}

// 绘制粒子
function draw() {
  ctx.clearRect(0, 0, cWdith, cHeight)
  particleList.forEach(i => {
    i.draw()
  })
}

timer()

这样通过第二步,我们已经实现了绘制时间的粒子文本了

tt0.top-009200.gif

第三步,走两步,没病走两步

让粒子‘走’起来。这一步也是最关键的一步。

扩展粒子钩子函数的能力

function Particle(opt) {
  this.ctx = opt.ctx
  this.canvas = opt.ctx.canvas
  this.x = opt.x // 坐标
  this.y = opt.y // 坐标
  this.color = opt.color // 颜色
  this.radius = opt.radius // 半径
  // ------ 新能力 -------
  this.destroyed = false
  this.directionDeg = opt.directionDeg || Math.random() * 360 // 运动角度
  this.speed = opt.speed || 1 // 速度
  this.speedDis = opt.speedDis || 1 // 速度变化系数
  this.computedDirectionSpeed()
  // -----------------
}

// 计算 x y 加速度
Particle.prototype.computedDirectionSpeed = function() {
  this.speedX = this.speed * Math.cos(Particle.utils.radian(this.directionDeg)) // x轴的加速度
  this.speedY = this.speed * Math.sin(Particle.utils.radian(this.directionDeg)) // y轴的加速度
}

Particle.prototype.draw = function() {
  this.ctx.beginPath()
  this.ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2)
  this.ctx.fillStyle = this.color
  this.ctx.closePath()
  this.ctx.fill()
}

// 更新粒子坐标
Particle.prototype.update = function() {
  this.x += this.speedX
  this.y += this.speedY
  this.radius += this.radiusDis
  this.speedX *= this.speedDis
  this.speedY *= this.speedDis
  if (this.x > this.canvas.width || this.x < 0 || this.y < 0 || this.y > this.canvas.height) {
    this.destroyed = true
  }
  if (this.radius < 1) {
    this.destroyed = true
  }
}

在递归中,给存在的粒子设置目标位置,并计算粒子运动角度

function computed(dotArr) {
  const arr = []
  dotArr.forEach(dot => {
    let particle = null
    const id = `${dot.x}-${dot.y}`
    const tX = dot.x + utils.getRandom(-15, 15)
    const tY = dot.y + utils.getRandom(-15, 15)
    if (particleList.length) {
      particle = particleList.shift()
      if (particle.id !== id) {
        particle.id = id
        // particle.x = tX
        // particle.y = tY
        particle.targetX = tX
        particle.targetY = tY
        // 重新计算运动方向
        particle.directionDeg = utils.pointsToAngle(particle.x, particle.y, tX, tY)
        particle.computedDirectionSpeed()
      }
    } else {
      const cX = dot.x + utils.getRandom(-15, 15)
      const cY = dot.y + utils.getRandom(-15, 15)
      particle = new window.Particle({
        ctx,
        x: cX,
        y: cY,
        color: utils.getRandomColor(),
        radius: 2,
        speed: 5,
        directionDeg: utils.pointsToAngle(cX, cY, tX, tY)
      })
      particle.id = id
      particle.targetX = tX
      particle.targetY = tY
    }
    arr.push(particle)
  })
  particleList = arr
}

在绘画粒子的函数中,通过 requestAnimationFrame()更新粒子坐标,绘制粒子动画

// 绘制粒子
function draw() {
  ctx.clearRect(0, 0, cWdith, cHeight)
  particleList.forEach(i => {
    if (!i.destroyed) {
      i.update()
      if ((i.speedX > 0 && i.x >= i.targetX) || (i.speedX < 0 && i.x <= i.targetX)) {
        i.x = i.targetX
        if ((i.speedY > 0 && i.y >= i.targetY) || (i.speedY < 0 && i.y <= i.targetY)) {
          i.destroyed = true
          i.y = i.targetY
        }
      } else if ((i.speedY > 0 && i.y >= i.targetY) || (i.speedY < 0 && i.y <= i.targetY)) {
        i.y = i.targetY
      }
    }
    i.draw()
  })
  const length = particleList.filter(i => i.destroyed).length
  if (length === particleList.length) {
    window.cancelAnimationFrame(requestId)
    return
  }
  requestId = window.requestAnimationFrame(draw)
}

到这里,粒子时钟的动画已经实现了。

核心运动算法

上面的三大步时粒子的动画的主要逻辑。相信认真观看的同学已经发现了,代码中最关键的粒子运动的计算在上文中并没有讲到。

上课啦!!!三角函数的计算,你还记得么?

canvas 中相对于坐标轴的角度永远是90°,所以三角函数算法是 Canvas 最基础的算法。

sin.jpg

sin1.jpg

JavaScript 中三角函数的计算

Math 对象的 sincos 中只接收弧度,所以需要先计算角度的弧度。

// sin 根据角度计算点位
CanvasUtils.sin = function(deg) {
return Math.sin(CanvasUtils.radian(deg))
}

// cos 根据角度计算点位
CanvasUtils.cos = function(deg) {
return Math.cos(CanvasUtils.radian(deg))
}

// 跟据角度计算弧度
CanvasUtils.radian = function(deg) {
return Math.PI * deg / 180
}

通过粒子的两个坐标,计算粒子的运动角度

// 根据点位 计算角度
CanvasUtils.pointsToAngle = function(x1, y1, x2, y2) {
const a = Math.abs(y2 - y1)
const b = Math.abs(x2 - x1)
let angle = Math.atan(a / b) * 180 / Math.PI

if (x1 > x2) {
  angle = 180 - angle
  if (y1 > y2) {
    angle = 180 - angle + 180
  }
} else if (y1 > y2) {
  angle = 360 - angle
}
return angle
}

// ----------- particle -----
particle.directionDeg = CanvasUtils.pointsToAngle(particle.x, particle.y, tX, tY)

Ending

canvas 的绘制能力十分强大,更多的效果需要深入学习。欢迎感兴趣的同学一起学习交流

传送门