博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
canvas+vue实现60帧FPS的抢金币动画(类天猫红包雨)
阅读量:7074 次
发布时间:2019-06-28

本文共 7601 字,大约阅读时间需要 25 分钟。

先看看我们要做的效果

一、canvas动画核心概念

完全没有canvas基础的同学建议先刷一下

重点是理解canvas动画的基本步骤,在中,动画分为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()方法即可

既然做动画,我们肯定得知道这个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帧每秒,可以说是性能很不错了 

后话

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

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

转载地址:http://mluml.baihongyu.com/

你可能感兴趣的文章