Vue + Canvas绘制炫丽时钟

139 阅读6分钟

炫酷时钟.gif 我正在参加「码上掘金挑战赛」详情请看:码上掘金挑战赛来了!

大家好,我是小七月,今天我为大家带来了绚丽时钟的实现。最近我正在学习使用canvas,于是在慕课上自己找了一个教程来学习,看到了这个绚丽时钟的效果,于是自己便也来实现。相信Vue和Canvas能够碰撞出不一样的火花。下面我来分析一下实现思路。

数据分析

首先,我们要实现数字的绘制,来代表时钟。仔细看,数字是由一个个小球组成的。课程中提供了一份数据用于描述点的位置,我们先来分析一下数据。部分数据如下所示:

 [
    [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

image.png

仔细分析,我们只要将所有的1的位置连接起来就形成了一个零。即我们如果把每一个1的位置都绘制成一个小球,那么它们组合起来就是一个零的数字。其他的数字也是如此的道理。 使用canvas绘制一个小球的方式如下:

<canvas id="canvas"></canvas>
const ctx = document.querySelector('#canvas').getContext('2d');
ctx.arc(x,y,r,starAngel,endAngel,isReverse)

参数详情如下:

  • x 圆心的横坐标
  • y 圆心的纵坐标
  • r 圆的半径
  • startAngel 使用Math.PI来表示,画弧的起点
  • endAngel 使用Math.PI来表示,画弧的终点
  • isReverse boolean值,为true表示逆时针,默认为顺时针

弧度相关的知识:

image.png

无论如何变化,圆所在的弧度不变。

绘制数字

首先我们先定义一些全局变量,比如canvas画布的宽高,时钟绘制的起点,组成数字的小圆球的半径等。

const WINDOW_WIDTH = 1024; // 画布宽
const WINDOW_HEIGHT = 508; // 画布高
const drawKeys = ['hours', 'minutes', 'secends']; // 只有时分秒的数字需要绘制
const MAX_BALLS = 200; // 屏幕中滚动小球的最大个数
const TIME_COLOR = 'pink';// 时钟数字的颜色
const colors = [ // 滚动小球的颜色组
  '#33B5E5',
  '#0099CC',
  '#AA66CC',
  '#9933CC',
  '#99CC00',
  '#669900',
  '#FFBB33',
  '#FF8800',
  '#FF4444',
  '#CC0000'
];

小球绘制思路:我们需要遍历前面提到的digit数字,在为1的位置上画一个小球。下面是我在慕课网上得来的一张小球位置分析的图片,本来想要画一下的,但是无奈画的太丑,不如这里清晰,我便用上了,若侵权,联系必删。

ee62cb6deb1716e27d2f92a40dc000e.jpg

 // 绘制数字
    drawFigure(figure, originX, originY) {
    // figure表示要绘制的数字,后面两位表示绘制数字的起点
      let position = digit[figure];
      position.forEach((item, i) => {
        item.forEach((dot, j) => {
          if (dot === 1) {
          // 此处根据上图的分析,计算小球的圆心,加1是为了使小球之间有一些间隙
            const x = originX + (j * 2 + 1) * (this.r + 1);
            const y = originY + (i * 2 + 1) * (this.r + 1);
            this.ctx.fillStyle = TIME_COLOR;
            this.ctx.beginPath();
            this.ctx.arc(x, y, this.r, 0, 2 * Math.PI);
            this.ctx.closePath();
            this.ctx.fill();
          }
        });
      });
    },

获取当前的时间,并且解析出需要绘制的数字,代码如下:

 // 获取当前的时间
    getCurrentTime() {
      let date = new Date();
      let currentTime = {
        year: date.getFullYear(),
        month: date.getMonth(),
        day: date.getDate(),
        hours: date.getHours(),
        minutes: date.getMinutes(),
        secends: date.getSeconds()
      };
      return currentTime;
    },
    // 绘制时间
    drawTime() {
      let originX = this.origin.x;
      for (let key in this.currentTime) {
        if (drawKeys.includes(key)) {
          let figure = this.currentTime[key];
          let first = Math.floor(figure / 10);
          let next = figure % 10;
          this.drawFigure(first, originX, this.origin.y);
          originX += 14 * (this.r + 2);
          this.drawFigure(next, originX, this.origin.y);
          originX += 14 * (this.r + 2);
          if (key !== 'secends') this.drawFigure(10, originX, this.origin.y);
          originX += 8 * (this.r + 2);
        }
      }
    },

注意:在这个drawTime里面,我们需要每绘制完一个数字,就要计算一次初始值,因为数字是由7 * 10的格子组成,所以一个数字所占的宽度应该为14 * r。所以我们每次 originx应该要加一个数字的宽度,再留2 * 14的间隙,最后开始下一个数字的绘制。最后,时,分,秒之间是需要冒号来分割的,冒号是由 4 * 10的格子绘制,所以需要originX += 8 * (this.r + 2);

随着时间变动,改变数字并且产生五颜六色的小球

只有当数字变化才产生小球,就意味着我们需要将已经绘制的时间和现在需要绘制的时间进行对比,然后找到需要产生小球的位置,即要绘制的数字与已有数字不一样时的位置。详情看下面代码注释

data() {
 origin: {// 小时的第一个数字的起始位置
    x: 4,
    y: 4
  },
  addBalls:[],
  currentTime:null // 已经在屏幕上绘制的时间
}
update() {
      // 获取当前的时间信息
      let nextTime = this.getCurrentTime();
      
      let originX = this.origin.x;
      for (let i in drawKeys) {
        let key = drawKeys[i];
        let curFigure = this.currentTime[key];
        let nextFigure = nextTime[key];

        if (curFigure !== nextFigure) {
          let curFirst = Math.floor(curFigure / 10); // 十位数字
          let curNext = curFigure % 10; // 个位数字
          let nextFirst = Math.floor(nextFigure / 10);
          let nextSecond = nextFigure % 10;
          // 若是数字不相同则需要添加小球
          if (curFirst !== nextFirst) {
            this.addBalls(originX, this.origin.y, nextFirst);
          }
          // 注意:此处无论是否添加球,都得加上前面那个数字所占的宽度和冒号所占的宽度,因为小球的位置和数字位置一致
          originX += 14 * (this.r + 2);
          if (curNext !== nextSecond) {
            this.addBalls(originX, this.origin.y, nextSecond);
          }
          originX += 14 * (this.r + 2);
          if (i !== 1) {
            originX += 8 * (this.r + 2);
          }
        } else {// 注意:此处无论是否添加球,都得加上前面那个数字所占的宽度和冒号所占的宽度
          originX += 14 * (this.r + 2);
          originX += 14 * (this.r + 2);
          if (i !== 1) {
            originX += 8 * (this.r + 2);
          }
        }
      }
      // 计算完小球后将已经绘制的时间赋给
      this.currentTime = nextTime;
      // 更新小球的位置
      this.updateBalls();
    }

绘制滚动的小球

看时钟发现,只有当数字发生变化的时候,我们才会在数字上面产生小球并且滚动下来。产生小球的坐标与上面相同,但是若是滚动,我们需要不断更新小球的圆心坐标,所以需要给它速度。我们根据代码来分析。

drawBall() {
  this.balls.forEach((ball) => {
    this.ctx.fillStyle = ball.color;
    this.ctx.beginPath();
    this.ctx.arc(ball.x, ball.y, 2 * this.r, 0, 2 * Math.PI, true);
    this.ctx.closePath();
    this.ctx.fill();
  });
},
addBalls(x, y, num) {
  let figure = digit[num];
  figure.forEach((item, i) => {
    item.forEach((dot, j) => {
      if (dot === 1) {
        let aBar = {
          x: x + (j * 2 + 1) * (this.r + 1),
          y: y + (i * 2 + 1) * (this.r + 1),
          g: 1.5 + Math.random(),
          vx: Math.pow(-1, Math.ceil(Math.random() * 1000)) * 4,
          vy: -5,
          color: colors[Math.floor(Math.random() * colors.length)]
        };
        this.balls.push(aBar);
      }
    });
  });
},
updateBalls() {
  this.balls.forEach((ball) => {
    ball.x += ball.vx;
    ball.y += ball.vy;
    ball.vy += ball.g;
    // 为小球添加回弹效果
    if (ball.y >= WINDOW_HEIGHT - this.r) {
      ball.y = WINDOW_HEIGHT - this.r;
      ball.vy = -ball.vy * 0.65;
    }
  });
  var cnt = 0;
  // 移除掉落在屏幕外的小球
  for (let i = 0; i < this.balls.length; i++)
    if (this.balls[i].x + this.r > 0 && this.balls[i].x - this.r < WINDOW_WIDTH) 
        this.balls[cnt++] = this.balls[i];
        
    // 性能优化,控制小球在屏幕中最多出现的个数
    while (this.balls.length > Math.min(cnt, MAX_BALLS)) {
       this.balls.pop();
    }
},

执行动画

对于该动画,我一开始使用的是setInterval(() => {},50),但是发现有时候数字变化并没有产生小球,排查发现是因为这个函数有误差,不一定50ms就会执行一次,可能会因为逻辑复杂而延迟若干时间,所以最后我换成了requestAnimationFrame,大约是16ms会执行方法一次。这个方法对于动画来说是经常用到的。

mounted() {
    this.ctx = this.$refs.canvas.getContext('2d');
    this.$refs.canvas.width = WINDOW_WIDTH;
    this.$refs.canvas.height = WINDOW_HEIGHT;
    this.initData();
    this.currentTime = this.getCurrentTime();
    this.startAnimation();
},
destroyed() {
    // 组件销毁,关闭定时执行
    cancelAnimationFrame(this.timer);
},
startAnimation() {
  this.initData();
  this.update();
  this.timer = window.requestAnimationFrame(this.startAnimation);
},
initData() {
  this.ctx.clearRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT);
  this.drawTime();
  this.drawBall();
},