canvas+vue实现60帧每秒的抢金币动画(类天猫红包雨)
amandakelake opened this issue · 4 comments
一、canvas动画核心概念
完全没有canvas基础的同学建议先刷一下Canvas的基本用法 - Web API 接口参考 | MDN
重点是理解canvas动画的基本步骤
在MDN canvas 动画文档上的是4步
初学者可以再简单一些,我们先不管状态保存,直接两步走:
- 清空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);
},
性能测试
到这里,整个运动的核心原理就讲完了,我们测试一下动画的性能
没源码吗
@TigerLiv 源码混杂囤太多其他东西 核心代码都在文章里了 参考摸索做一遍 理解会更深刻一点
请问randomRound这个随机生成金币的方法在哪 没有找到
好东西,点赞,期待彻底开源