一、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帧每秒,可以说是性能很不错了
后话
感谢您耐心看到这里,希望有所收获!
我在学习过程中喜欢做记录,分享的是自己在前端之路上的一些积累和思考,希望能跟大家一起交流与进步,更多文章请看