Canvas实战:使用Canvas 画一个粒子时钟

331 阅读2分钟

先来看下完成的效果:

douyin.gif

实现

1. 一个非粒子化的时钟

index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Canvas-粒子时钟</title>
  <style>
    * {
      margin: 0;
      padding: 0;
    }
    canvas {
      background:radial-gradient(#fff, #8c738c);
      display: block;
      width: 100vw;
      height: 100vh;
    }
  </style>
</head>
<body>
  <canvas></canvas>
  <script src="./index.js"></script>
</body>
</html>

index.js

function getRandom(min, max) {
  return Math.floor(Math.random() * (max + 1 - min) + min)
}
function getCurrentTimeString() {
  return new Date().toTimeString().slice(0, 8)
}
class ParticleClick {
  constructor(el) {
    /** @type {HTMLCanvasElement} */
    this.canvas = document.querySelector(el)
    /** @type {CanvasRenderingContext2D} */
    this.ctx = this.canvas.getContext('2d')
    /** @type {string | null} */
    this.text = null
    this.init()
  }
  init() {
    this.canvas.width = window.innerWidth
    this.canvas.height = window.innerHeight
  }
  clear() {
    this.ctx.clearRect(0, 0, this.canvas.width,this.canvas.height)
  }
  draw() {
    this.clear()
    this.drawText()

    requestAnimationFrame(() => this.draw())
  }
  drawText() {
    const { ctx, canvas: {width, height} } = this
    this.text = getCurrentTimeString()

    // 开始画文本
    ctx.beginPath()
    // 文本样式
    ctx.fillStyle = '#000'
    ctx.textBaseline = 'middle'
    ctx.font = '140px sans-serif'
    // 画文本,位置水平和垂直居中
    ctx.fillText(this.text, (width - ctx.measureText(this.text).width) / 2, height /2)
  }
}

const clock = new ParticleClick('canvas')
clock.draw()

效果:

douyin.gif

2. 粒子化

  1. 初始化 生成粒子,如下:

image.png

  1. 通过移动,粒子到达时间文本上的每个点

image.png

具体实现

  1. 粒子类
// 粒子类
class Particle {
  /**
   * 粒子类的构造函数
   * @param {HTMLCanvasElement} canvas 
   * @param {CanvasRenderingContext2D} ctx 
   */
  constructor(canvas, ctx) {
    this.canvas = canvas
    this.ctx = ctx
    // 初始化粒子位置,以canvas中心为圆心的圆周上
    // 半径
    const r = Math.min(canvas.width, canvas.height) / 2
    // cx, cy 为圆心坐标
    const cx = canvas.width / 2
    const cy = canvas.height / 2
    // 弧度
    const rad = getRandom(0, 360) / 180 *  Math.PI
    this.x = cx + r * Math.cos(rad)
    this.y = cy + r * Math.sin(rad)
    // 粒子半径,随机
    this.size = getRandom(4, 8)
  }
  draw() {
    const { ctx } = this
    ctx.beginPath()
    ctx.fillStyle = '#5445544d'
    ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)
    ctx.fill()
  }
}
  1. 获取时间文本上的像素点信息
class ParticleClick {
  ...原有的代码
  
  getPoints() {
    const gap = 6
    const { ctx, canvas: { width, height } } = this
    const { data } = ctx.getImageData(0, 0, width, height)
    const points = []
    for (let i = 0; i < width; i+= gap) {
      for (let j = 0; j < height; j+= gap) {
        // 这里getImageData 返回的数据是一个像素点由rgba四个数组成
        const index = (i + j * width) * 4
        const r = data[index]
        const g = data[index + 1]
        const b = data[index + 2]
        const a = data[index + 3]
        // 黑色的点的信息
        if ( r === 0 && g === 0 && b === 0 && a === 255) {
          points.push([i, j])
        }
      }
    }
    return points
  }
}
  1. 粒子开始是在圆周上的,需要移动到时间文本位置上,给Particle添加上moveTo方法。

如果是直接移动,太生硬,需要给他加上动画(如下,在500ms内缓慢移动,就有了动画效果)

class Particle {
 ...原有的代码
  
  moveTo(dx, dy) {
    const duration = 500
    const sx = this.x, sy = this.y
    const xSpeed = (dx - sx) / duration
    const ySpeed = (dy - sy) / duration
    const startTime = Date.now()
    const _move = () => {
      const elapsedTime = Date.now() - startTime
      this.x = sx + xSpeed * elapsedTime
      this.y = sy + ySpeed * elapsedTime
      if (elapsedTime >= duration) {
        return
      }
      requestAnimationFrame(_move)
    }
    _move()
  }
}

  1. 此时,我们就可以把粒子绘制到时间文本上了
class ParticleClick {
  ...原有的代码
  constructor(el) {
    ...原有的代码
    /** @type {Particle[]} */
    this.particles = []
  }
  draw() {
    this.clear()
    this.drawText()
    this.particles.forEach((p) => p.draw())
    requestAnimationFrame(() => this.draw())
  }
  drawText() {
   const { ctx, canvas: {width, height} } = this
    const newText = getCurrentTimeString()
    if (newText === this.text) {
      return
    }
    this.text = newText

    // 开始画文本
    ctx.beginPath()
    // 文本样式
    ctx.fillStyle = '#000'
    ctx.textBaseline = 'middle'
    ctx.font = '140px sans-serif'
    // 画文本,位置水平和垂直居中
    ctx.fillText(this.text, (width - ctx.measureText(this.text).width) / 2, height /2)

    const points = this.getPoints()
    this.clear()

    for (let i = 0; i < points.length; i++) {
      let p = this.particles[i]
      if (!p) {
        p = new Particle(this.canvas, this.ctx)
        this.particles.push(p)
      }
      const [x, y] = points[i]
      p.moveTo(x, y)
    }
    // 去掉多余的例子
    if (points.length < this.particles.length) {
      this.particles.splice(points.length)
    }
  }
}
  1. 所有代码
function getRandom(min, max) {
  return Math.floor(Math.random() * (max + 1 - min) + min)
}
function getCurrentTimeString() {
  return new Date().toTimeString().slice(0, 8)
}
class ParticleClick {
  constructor(el) {
    /** @type {HTMLCanvasElement} */
    this.canvas = document.querySelector(el)
    /** @type {CanvasRenderingContext2D} */
    this.ctx = this.canvas.getContext('2d', {
      willReadFrequently: true
    })
    /** @type {string | null} */
    this.text = null
    /** @type {Particle[]} */
    this.particles = []
    this.init()
  }
  init() {
    this.canvas.width = window.innerWidth
    this.canvas.height = window.innerHeight
  }
  clear() {
    this.ctx.clearRect(0, 0, this.canvas.width,this.canvas.height)
  }
  draw() {
    this.clear()
    this.drawText()
    this.particles.forEach((p) => p.draw())
    requestAnimationFrame(() => this.draw())
  }
  drawText() {
    const { ctx, canvas: {width, height} } = this
    const newText = getCurrentTimeString()
    if (newText === this.text) {
      return
    }
    this.text = newText

    // 开始画文本
    ctx.beginPath()
    // 文本样式
    ctx.fillStyle = '#000'
    ctx.textBaseline = 'middle'
    ctx.font = '140px sans-serif'
    // 画文本,位置水平和垂直居中
    ctx.fillText(this.text, (width - ctx.measureText(this.text).width) / 2, height /2)

    const points = this.getPoints()
    this.clear()

    for (let i = 0; i < points.length; i++) {
      let p = this.particles[i]
      if (!p) {
        p = new Particle(this.canvas, this.ctx)
        this.particles.push(p)
      }
      const [x, y] = points[i]
      p.moveTo(x, y)
    }

    // 去掉多余的例子
    if (points.length < this.particles.length) {
      this.particles.splice(points.length)
    }
  }

  getPoints() {
    const gap = 6
    const { ctx, canvas: { width, height } } = this
    const { data } = ctx.getImageData(0, 0, width, height)
    const points = []
    for (let i = 0; i < width; i+= gap) {
      for (let j = 0; j < height; j+= gap) {
        const index = (i + j * width) * 4
        const r = data[index]
        const g = data[index + 1]
        const b = data[index + 2]
        const a = data[index + 3]
        // 黑色的点的信息
        if ( r === 0 && g === 0 && b === 0 && a === 255) {
          points.push([i, j])
        }
      }
    }
    return points
  }
}
// 粒子类
class Particle {
  /**
   * 粒子类的构造函数
   * @param {HTMLCanvasElement} canvas 
   * @param {CanvasRenderingContext2D} ctx 
   */
  constructor(canvas, ctx) {
    this.canvas = canvas
    this.ctx = ctx
    // 初始化粒子位置,以canvas中心为圆心的圆周上
    // 半径
    const r = Math.min(canvas.width, canvas.height) / 2
    // cx, cy 为圆心坐标
    const cx = canvas.width / 2
    const cy = canvas.height / 2
    // 弧度
    const rad = getRandom(0, 360) / 180 *  Math.PI
    this.x = cx + r * Math.cos(rad)
    this.y = cy + r * Math.sin(rad)
    // 粒子半径,随机
    this.size = getRandom(4, 8)
  }
  draw() {
    const { ctx } = this
    ctx.beginPath()
    ctx.fillStyle = '#5445544d'
    ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)
    ctx.fill()
  }
  moveTo(dx, dy) {
    const duration = 500
    const sx = this.x, sy = this.y
    const xSpeed = (dx - sx) / duration
    const ySpeed = (dy - sy) / duration
    const startTime = Date.now()
    const _move = () => {
      const elapsedTime = Date.now() - startTime
      this.x = sx + xSpeed * elapsedTime
      this.y = sy + ySpeed * elapsedTime
      if (elapsedTime >= duration) {
        return
      }
      requestAnimationFrame(_move)
    }
    _move()
  }
}

const clock = new ParticleClick('canvas')
clock.draw()

效果:

douyin.gif