canvas+vue实现60帧FPS的抢金币动画(类天猫红包雨)

5,824 阅读9分钟
先看看我们要做的效果



一、canvas动画核心概念

完全没有canvas基础的同学建议先刷一下[Canvas的基本用法 - Web API 接口参考 | MDN]

重点是理解canvas动画的基本步骤,在[基本的动画 - MDN]中,动画分为4步走


初学者可以再简单一些,我们先不管状态保存,直接两步走: 

  • 清空canvas 
  • 绘制新的一帧动画 

 用定时器或者window.requestAnimationFrame定时重复以上两步即可


二、抢金币核心原理

想象一下整个业务场景,我们先梳理出3个要解决的核心问题: 

  • 1、生成红包,这里有两种解决方案 
    •  统一生成所有的红包对象,从上到下分布在y轴,触发运动后后整体向下运动 
    •  在屏幕上方持续生成新红包对象,红包一旦生成,立刻开始运动(本次选择此方案) 
  • 2、运动,canvas动画原理 
  • 3、用户点击红包,计算是否点中红包(事件只能绑定在canvas这一层,需要根据点击位置进行计算)


三、核心功能

  • 1、预缓存图片/离屏canvas 
  • 2、canvas绘制多图,改变每一帧形成动画 
  • 3、判断点击位置,冒泡+1效果


下面都是基于vue的代码,不能直接跑的,主要用于理解核心功能

最好是自己理解核心原理后亲自动手做个最简单的demo,有助于加深理解

1、预缓存图片/离屏canvas

页面上感觉有很多很多金币在按各种角度掉落 

其实页面上一共就4种金币图片,只是他们的大小、速度不一样,看起来有每一个都不一样 

我们可以先把这4张图片全都加载好

 // 缓存几种金币图片为DOM元素,避免canvas绘制时还需要异步读取图片
loadImgs(arr) {
  return new Promise(resolve => {
    let count = 0;
    // 循环图片数组,每张图片都生成一个新的图片对象
    const len = arr.length;
    for (let i = 0; i < len; i++) {
      // 创建图片对象
      const image = new Image();
      // 成功的异步回调
      image.onload = () => {
        count++;
        arr.splice(i, 1, {
	  // 加载完的图片对象都缓存在这里了,canvas可以直接绘制
          img: image,
	  // 这里可以直接生成并缓存离屏canvas,用于优化性能,但本次不用,只是举个例子
          offScreenCanvas: this.createOffScreenCanvas(image)
        });
	// 这里说明 整个图片数组arr里面的图片全都加载好了
        if (count == len) {
          this.preloaded = true;
          resolve();
        }
      };
      image.src = arr[i].img;
    }
  });
},

创建离屏canvas的方法如下

createOffScreenCanvas(image) {
  const offscreenCanvas = document.createElement("canvas");
  const offscreenContext = offscreenCanvas.getContext("2d");
  // 这里可以是动态宽高
  offscreenContext.width = 30;
  offscreenContext.height = 30;
  offscreenContext.drawImage(
    image,
    0,
    0,
    offscreenContext.width,
    offscreenContext.height
  );
  // return这个offscreenCanvas
  return offscreenCanvas;
},

2、canvas绘制多图,改变每一帧形成动画

首先初始化canvas 

这里我们直接把canvas的上下文ctx存在data里面,方便在各个方法里面读取。 

在vue里面写不像单独的一个JS模块,可以用闭包来封装一个独立的上下文,而在vue里面也不建议声明全局变量

initCanvas() {
  const canvas = document.getElementById("canvas");
  if (canvas.getContext) {
    this.ctx = canvas.getContext("2d");
    // 初始化时同步进行图片预加载
    this.loadImgs(this.imgArr);
  }
},

绘制多图,其实就是循环遍历上面创建好的图片数组imgArr,然后对于每个图片对象,都调用this.ctx.drawImage()方法即可 

下面我们把图片转变化金币对象 

把图片数组imgArr替换成金币对象数组coinArr,这个数组是由一个个的金币对象Coin组成,金币对象自身除了有图片,还有大小、物理位置、下落速度等参数,也就是说,每个金币对象缓存自己的所有绘制信息,这里用的是面向对象的思维

const Coin = {
  x: 'x轴位置',
  y: 'y轴位置', // 运动的关键是在每一帧都改变y
  radius: '金币大小',
  img: '前面缓存好的金币图片',
  speed: '金币的下落速度'
};


每一帧,循环这个金币数组,然后绘制出所有的金币对象 

如果要运动起来,每一帧让每个金币的y轴位置往下掉一点,就是这句y: coin.y + coin.speed

那么绘制下一帧时,其他信息都不变,每个金币都往下移动了一点点,连贯起来,这不同的一帧一帧组合起来就成了运动的动画了 

 先看绘制的代码

drawCoins() {
  // 遍历这个金币对象数组
  this.coinArr.forEach((coin, index) => {
    const newCoin = {
      x: coin.x,
      // 运动的关键  每次只有y不一样
      y: coin.y + coin.speed,
      radius: coin.radius,
      img: coin.img,
      speed: coin.speed
    };
    // 绘制某个金币对象时,也同时生成一个新的金币对象,替换掉原来的它,唯一的区别就是它的y变了,下一帧绘制这个金币时,就运动了一点点距离
    this.coinArr.splice(index, 1, newCoin);
    this.ctx.drawImage(
      coin.img,
      coin.x,
      coin.y,
      coin.radius,
      coin.radius * 1.5
    );
  });
},

那么怎么连贯运动起来呢,不断的执行this.drawCoins()方法即可

既然做动画,我们肯定得知道【window.requestAnimationFrame】这个api


还记得刚开始说的动画核心两步走吗 

  • 清空canvas 
  • 绘制新的一帧动画

moveCoins() {
  // 清空canvas
  this.ctx.clearRect(0, 0, this.innerWidth, this.innerHeight);
  // 绘制新的一帧动画
  this.drawCoins();
  // 不断执行绘制,形成动画
  this.moveCoinAnimation = window.requestAnimationFrame(this.moveCoins);
},

到这里,我们其实已经能让金币运动起来了,不过我们要做的是让很多很多金币不断的往下掉,所以我们选择在运动的过程中,不断生成新的金币对象,然后push到this.coinArr

pushCoins() {
  // 每次随机生成1~3个金币
  const random = this.randomRound(3, 6);
  let arr = [];
  for (let i = 0; i < random; i++) {
    // 创建新的金币对象
    const newCoin = {
      x: this.random(
        this.calculatePos(10),
        this.innerWidth - this.calculatePos(150)
      ), // 横向随机  金币不要贴近边边
      y: 0 - this.calculatePos(Math.random() * 150), // -150内高度 随机
      radius: this.calculatePos(120 + Math.random() * 30), // 100宽  大小浮动15
      img: this.coinObjs[this.randomRound(0, 3)].img, // 随机取一个金币图片对象,这几个图片对象在页面初始化时就已经缓存好了
      speed: this.calculatePos(Math.random() * 7 + 5) // 下落速度 随机
    };
    arr.push(newCoin);
  }
  // 每次都插入一批新金币对象arr到运动的金币数组this.coinArr
  this.coinArr = [...this.coinArr, ...arr];
  // 间隔多久生成一批金币
  this.addCoinsTimer = setTimeout(() => {
    this.pushCoins();
  }, 600);
},

因为每个金币的初始y的位置都是屏幕上方,所以看起来都是不断生成金币然后往下掉的 

至于计算大小的方法,这个比较随意了 

 最后,把上面的汇总起来,开启动画的方法是这样的

start() {
  this.pushCoins(); // 不断增加金币
  this.moveCoins(); // 金币开始运动
  // 开始10秒倒计时
  this.runCountdownTimer = setInterval(() => {
  //...倒计时10s后,做一些停止动画的工作
  }, 1000);
},


到这里,运动过程就已经结束了,先总结一下上面的内容 

  • 1、初始化canvas 
  • 2、缓存金币图片,生成金币对象,每个金币对象包含自身信息 
  • 3、不断生成金币对象,并增加到要遍历运动的数组this.coinArr 
  • 4、通过window.requestAnimationFrame,每一帧都用canvas重新遍历绘制this.coinArr,每一帧都改变this.coinArr里面的每一个对象的y值大小,形成运动感


3、判断点击位置,冒泡+1效果

通过上面的效果图,我们可以看到,点击金币时,对应的这个金币会消失(如果有重叠,只会消失最上面的那个金币),而且还会有个+1的效果,并缓慢上移消失


先思考一下逻辑 

  • 1、绑定点击事件 
  • 2、计算位置,遍历当前整个金币数组,看看点击在哪个金币上,找出最上面那个,然后删除这个金币对象 
  • 3、在点击位置上,绘制一个+1效果 


首先,canvas本身就是一个DOM对象,绘制在它上面的金币并不是dom对象,无法绑定点击事件,所以只能绑定在canvas上面,通过event拿到点击位置,有点事件代理的味道吧

    listenClick() {      const canvas = document.getElementById("canvas");      canvas.addEventListener("click", e => {        const pos = {          x: e.clientX,          y: e.clientY        };      });    },


既然拿到此刻的点击位置,而当前的金币数组this.coinArr也知道,数组里面的每个金币对象都维护了自身的信息,其中就包括了位置和金币大小 

那么,只要遍历一下,如果点击位置在这个金币的大小范围之内,那么是不是可以认为点击中了这个金币?

// 判断点击位置  是否处于某个coin之中
isIntersect(point, coin) {
  const distanceX = point.x - coin.x;
  const distanceY = point.y - coin.y;
  const withinX = distanceX > 0 && distanceX < coin.radius;
  // 金币图片是长方形的 我们只计算下半部的正方形  不计算金币尾巴
  const withinY =
    distanceY > 0 &&
    distanceY > coin.radius * 0.5 &&
    distanceY < coin.radius * 1.5;
  return withinX && withinY;
},


但,同一时刻,有可能点中了很多个重叠的金币,那么我们遍历时,把这几个金币都拿出来,只要最上面那个就好了

listenClick() {
  const canvas = document.getElementById("canvas");
  canvas.addEventListener("click", e => {
    // 点击位置
    const pos = {
      x: e.clientX,
      y: e.clientY
    };
    // 所有点中的金币都存这
    const clickedCoins = [];
    this.coinArr.forEach((coin, index) => {
      // 判断点击位置是否在该金币范围内
      if (this.isIntersect(pos, coin)) {
        clickedCoins.push({
          x: e.clientX,
          y: e.clientY,
	  // 索引很重要,用于删除this.coinArr内的该金币
          index: index
        });
      }
    });
    // 如果点击中了重叠的金币,只取第一个即可  也只删除第一个金币  count也只增加一次
    if (clickedCoins.length > 0) {
      this.count += 1;
      const bubble = {
        x: clickedCoins[0].x,
        y: clickedCoins[0].y,
        opacity: 1
      };
      // 这跟生成+1冒泡效果相关,下面马上讲
      this.bubbleArr.push(bubble);
      // 移除被点中的第一个金币对象
      this.coinArr.splice(clickedCoins[0].index, 1);
    }
  });
},


既然拿到了此刻的位置,在当前位置绘制一个冒泡效果应该不是难事,只要处理好冒泡的移动和消失即可,本质上就跟上面绘制金币是一样的

  • 1、存一个this.bubbleArr数组,动画中循环遍历绘制它里面的对象bubble
  • 2、bubble有位置信息,加多一个透明度opacity,运动的过程中,不断减小透明度,直到变为0,就把这个bubble从数组上删除即可

drawBubble() {
  this.bubbleArr.forEach((ele, index) => {
    if (ele.opacity > 0) {
      // 透明度渐变
      this.ctxBubble.globalAlpha = ele.opacity;
      this.ctxBubble.drawImage(
        this.bubbleImage,
        ele.x,
        ele.y,
        this.calculatePos(60),
        this.calculatePos(60)
      );
      // 更新:每次画完就减少0.02透明度,同时位置移动
      const newEle = {
        x: ele.x + this.calculatePos(1),
        y: ele.y - this.calculatePos(2),
        opacity: ele.opacity - 0.02
      };
      this.bubbleArr.splice(index, 1, newEle);
    }
  });
},
keepDrawBubble() {
  this.ctxBubble.clearRect(0, 0, this.innerWidth, this.innerHeight);
  // 把opacity为0的全部清除
  this.bubbleArr.forEach((ele, index) => {
    if (ele.opacity < 0) {
      this.bubbleArr.splice(index, 1);
    }
  });
  this.drawBubble();
  this.bubbleAnimation = window.requestAnimationFrame(this.keepDrawBubble);
},


四、性能测试

到这里,整个运动的核心原理就讲完了,我们测试一下动画的性能 

在chrome的性能测试里面可以看到,整个运动过程的fps稳稳保持在60帧每秒,可以说是性能很不错了 



后话

感谢您耐心看到这里,希望有所收获!

我在学习过程中喜欢做记录,分享的是自己在前端之路上的一些积累和思考,希望能跟大家一起交流与进步,更多文章请看【amandakelake的Github博客】