Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
amandakelake opened this issue Mar 12, 2019 · 4 comments
Labels

Comments

@amandakelake
Copy link
Owner

先看看我们要做的效果
抢金币

一、canvas动画核心概念

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

重点是理解canvas动画的基本步骤
MDN canvas 动画文档上的是4步
8A68437D-5D23-4FB3-935F-F8FC3976FAC9

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

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

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

二、抢金币核心原理

想象一下整个业务场景,我们先梳理出3个要解决的核心问题:
1、生成红包,这里有两种解决方案
* 1、统一生成所有的红包对象,从上到下分布在y轴,触发运动后后整体向下运动
* 2、在屏幕上方持续生成新红包对象,红包一旦生成,立刻开始运动(本次选择此方案)
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里面,方便在各个方法里面读取。
毕竟这里不像单独的一个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,不知道的可以看看文档window.requestAnimationFrame - Web API 接口参考 | MDN

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

  • 清空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拿到点击位置,有点事件代理的味道吧

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帧每秒,可以说是性能很不错了
0A405F1A-F6CD-48CB-AAC8-84CFA1FFD806

@TigerLiv
Copy link

TigerLiv commented Feb 6, 2020

没源码吗

@amandakelake
Copy link
Owner Author

@TigerLiv 源码混杂囤太多其他东西 核心代码都在文章里了 参考摸索做一遍 理解会更深刻一点

@a1034438308
Copy link

请问randomRound这个随机生成金币的方法在哪 没有找到

@lauheeliu
Copy link

好东西,点赞,期待彻底开源

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

4 participants