开始
最终效果: https://codepen.io/starriness/pen/XBJXGv
一开始都是一个单一的用例, 定位在画布中央, 再扩展开来
先获取 canvas 元素及可视宽高
- let canvas = document.querySelector('#canvas')
- let context = canvas.getContext('2d')
- let cw = canvas.width = window.innerWidth
- let ch = canvas.height = window.innerHeight
复制代码
开始绘制
第一部分 - 定位的用的闪烁的圆
- // 创建一个闪烁圆的类
- class Kirakira {
- constructor(){
- // 目标点, 这里先指定为屏幕中央
- this.targetLocation = {x: cw/2, y: ch/2}
- this.radius = 1
- }
- draw() {
- // 绘制一个圆
- context.beginPath()
- context.arc(this.targetLocation.x, this.targetLocation.y, 5, 0, Math.PI * 2)
- context.lineWidth = 2
- context.strokeStyle = '#FFFFFF';
- context.stroke()
- }
- update(){
- if(this.radius <5){
- this.radius += 0.3
- }else{
- this.radius = 1
- }
- }
- init() {
- this.draw()
- }
- }
- class Animate {
- run() {
- window.requestAnimationFrame(this.run.bind(this))
- if(o){
- o.init()
- }
- }
- }
- let o = new Kirakira()
- let a = new Animate()
- a.run()
复制代码
由此, 可以看到一个由小到大扩张的圆. 由于没有擦除上一帧, 每一帧的绘制结果都显示出来, 所以呈现出来的是一个实心的圆. 我想绘制的是一个闪烁的圆, 那么可以把上一帧给擦除.
context.clearRect(0, 0, cw, ch)
复制代码
第二部分 - 画射线
首先, 先画一由底部到画布中央的延伸线. 既然是运动的延伸线条, 那起码会有一个起点坐标和一个终点坐标
- class Biubiubiu {
- constructor(startX, startY, targetX, targetY){
- this.startLocation = {x: startX, y: startY}
- // 运动当前的坐标, 初始默认为起点坐标
- this.nowLoaction = {x: startX, y: startY}
- this.targetLocation = {x: targetX, y: targetY}
- }
- draw(){
- context.beginPath()
- context.moveTo(this.startLocation.x, this.startLocation.y)
- context.lineWidth = 3
- context.lineCap = 'round'
- // 线条需要定位到当前的运动坐标, 才能使线条运动起来
- context.lineTo(this.nowLoaction.x, this.nowLoaction.y)
- context.strokeStyle = '#FFFFFF'
- context.stroke()
- }
- update(){}
- init(){
- this.draw()
- this.update()
- }
- }
- class Animate {
- run() {
- window.requestAnimationFrame(this.run.bind(this))
- context.clearRect(0, 0, cw, ch)
- if(b){
- b.init()
- }
- }
- }
- // 这里的打算是定位起点在画布的底部随机位置, 终点在画布中央.
- let b = new Biubiubiu(Math.random()*(cw/2), ch, cw/2, ch/2)
- let a = new Animate()
- a.run()
复制代码
说说三角函数
已知坐标起点和坐标终点, 那么问题来了, 要怎么知道从起点到终点的每一帧的坐标呢
如图. 大概需要做判断的目标有
线条运动的距离是否超出起点到终点的距离, 如超出则需要停止运动
每一帧运动到达的坐标
计算距离
对于坐标间距离的计算, 很明显的可以使用勾股定理完成.
设起点坐标为 x0, y0, 终点坐标为 x1, y1 , 即可得
distance = (x1-x0)² + (y1-y0)²
, 用代码表示则是
Math.sqrt(Math.pow((x1-x0), 2) + Math.pow((y1-y0), 2))
计算坐标
上一帧的总距离(d) + 当前帧下走过的路程(v) = 当前帧的距离(D)
假设一个速度 speed = 2, 起点和终点形成的角度为 (θ), 路程(v) 的坐标分别为 vx, vy
那么
vx = cos(θ) * speed, vy = sin(θ) * speed
由于起点 (x0, y0) 和终点 (x1, y1) 已知, 由图可知, 通过三角函数中的 tan 可以取到两点成线和水平线之间的夹角角度, 代码表示为
Math.atan2(y1 - y0, x1 - x0)
回到绘制延伸线的代码. 给 Biubiubiu 类添加上角度和距离的计算,
- class Biubiubiu {
- constructor(startX, startY, targetX, targetY){
- ...
- // 到目标点的距离
- this.targetDistance = this.getDistance(this.startLocation.x, this.startLocation.y, this.targetLocation.x, this.targetLocation.y);
- // 速度
- this.speed = 2
- // 角度
- this.angle = Math.atan2(this.targetLocation.y - this.startLocation.y, this.targetLocation.x - this.startLocation.x)
- // 是否到达目标点
- this.arrived = false
- }
- draw(){ ... }
- update(){
- // 计算当前帧的路程 v
- let vx = Math.cos(this.angle) * this.speed
- let vy = Math.sin(this.angle) * this.speed
- // 计算当前运动距离
- let nowDistance = this.getDistance(this.startLocation.x, this.startLocation.y, this.nowLoaction.x+vx, this.nowLoaction.y+vy)
- // 如果当前运动的距离超出目标点距离, 则不需要继续运动
- if(nowDistance>= this.targetDistance){
- this.arrived = true
- }else{
- this.nowLoaction.x += vx
- this.nowLoaction.y += vy
- this.arrived = false
- }
- }
- getDistance(x0, y0, x1, y1) {
- // 计算两坐标点之间的距离
- let locX = x1 - x0
- let locY = y1 - y0
- // 勾股定理
- return Math.sqrt(Math.pow(locX, 2) + Math.pow(locY, 2))
- }
- init(){
- this.draw()
- this.update()
- }
- }
- class Animate { ... }
- // 这里的打算是定位起点在画布的底部随机位置, 终点在画布中央.
- let b = new Biubiubiu(Math.random()*(cw/2), ch, cw/2, ch/2)
- let a = new Animate()
- a.run()
复制代码
由于 speed 是固定的, 这里呈现的是匀速运动. 可以加个加速度 ``, 使其改变为变速运动. 我的目标效果并不是一整条线条, 而是当前运行的一截线段轨迹. 这里有个思路, 把一定量的坐标点存为一个数组, 在绘制的时候可以由数组内的坐标指向当前运动的坐标, 并在随着帧数变化不停对数组进行数据更替, 由此可以绘制出一小截的运动线段
实现代码:
- class Biubiubiu {
- constructor(startX, startY, targetX, targetY) {
- ...
- // 线段集合, 每次存 10 个, 取 10 个帧的距离
- this.collection = new Array(10)
- }
- draw() {
- context.beginPath()
- // 这里改为由集合的第一位开始定位
- try{
- context.moveTo(this.collection[0][0], this.collection[0][1])
- }catch(e){
- context.moveTo(this.nowLoaction.x, this.nowLoaction.y)
- }
- ...
- }
- update(){
- // 对集合进行数据更替, 弹出数组第一个数据, 并把当前运动的坐标 push 到集合. 只要取数组的头尾两个坐标相连, 则是 10 个帧的长度
- this.collection.shift()
- this.collection.push([this.nowLoaction.x, this.nowLoaction.y])
- // 给 speed 添加加速度
- this.speed *= this.acceleration
- ...
- }
- }
复制代码
第三部分 - 画一个爆炸的效果
由上面的延伸线的代码, 扩展开来, 如果不取 10 帧, 取个两三帧的小线段, 然后改变延伸方向, 多条射线组合, 就可以形成了爆炸效果. 火花是会受重力, 摩擦力等影响到, 扩散趋势是偏向下的, 所以需要加上一些重力, 摩擦力系数
- class Boom {
- // 爆炸物是没有确定的结束点坐标, 这个可以通过设定一定的阀值来限定
- constructor(startX, startY){
- this.startLocation = {x: startX, y: startY}
- this.nowLocation = {x: startX, y: startY}
- // 速度
- this.speed = Math.random()*10+2
- // 加速度
- this.acceleration = 0.95
- // 没有确定的结束点, 所以没有固定的角度, 可以随机角度扩散
- this.angle = Math.random()*Math.PI*2
- // 这里设置阀值为 100
- this.targetCount = 100
- // 当前计算为 1, 用于判断是否会超出阀值
- this.nowNum = 1
- // 透明度
- this.alpha = 1
- // 重力系数
- this.gravity = 0.98
- this.decay = 0.015
- // 线段集合, 每次存 10 个, 取 10 个帧的距离
- this.collection = new Array(CONFIG.boomCollectionCont)
- // 是否到达目标点
- this.arrived = false
- }
- draw(){
- context.beginPath()
- try{
- context.moveTo(this.collection[0][0], this.collection[0][1])
- }catch(e){
- context.moveTo(this.nowLocation.x, this.nowLocation.y)
- }
- context.lineWidth = 3
- context.lineCap = 'round'
- context.lineTo(this.nowLocation.x, this.nowLocation.y)
- // 设置由透明度减小产生的渐隐效果, 看起来没这么突兀
- context.strokeStyle = `rgba(255, 255, 255, ${this.alpha})`
- context.stroke()
- }
- update(){
- this.collection.shift()
- this.collection.push([this.nowLocation.x, this.nowLocation.y])
- this.speed *= this.acceleration
- let vx = Math.cos(this.angle) * this.speed
- // 加上重力系数, 运动轨迹会趋向下
- let vy = Math.sin(this.angle) * this.speed + this.gravity
- // 当前计算大于阀值的时候的时候, 开始进行渐隐处理
- if(this.nowNum>= this.targetCount){
- this.alpha -= this.decay
- }else{
- this.nowLocation.x += vx
- this.nowLocation.y += vy
- this.nowNum++
- }
- // 透明度为 0 的话, 可以进行移除处理, 释放空间
- if(this.alpha <= 0){
- this.arrived = true
- }
- }
- init(){
- this.draw()
- this.update()
- }
- }
- class Animate {
- constructor(){
- // 定义一个数组做为爆炸点的集合
- this.booms = []
- // 避免每帧都进行绘制导致的过量绘制, 设置阀值, 到达阀值的时候再进行绘制
- this.timerTarget = 80
- this.timerNum = 0
- }
- pushBoom(){
- // 实例化爆炸效果, 随机条数的射线扩散
- for(let bi = Math.random()*10+20; bi>0; bi--){
- this.booms.push(new Boom(cw/2, ch/2))
- }
- }
- run() {
- window.requestAnimationFrame(this.run.bind(this))
- context.clearRect(0, 0, cw, ch)
- let bnum = this.booms.length
- while(bnum--){
- // 触发动画
- this.booms[bnum].init()
- if(this.booms[bnum].arrived){
- // 到达目标透明度后, 把炸点给移除, 释放空间
- this.booms.splice(bnum, 1)
- }
- }
- if(this.timerNum>= this.timerTarget){
- // 到达阀值, 进行爆炸效果的实例化
- this.pushBoom()
- this.timerNum = 0
- }else{
- this.timerNum ++
- }
- }
- }
- let a = new Animate()
- a.run()
复制代码
第四部分 - 合并代码, 并且由一到多
合并代码的话, 主要是个顺序问题.
地点上, 闪烁圆的坐标点即是射线的目标终点, 同时也是爆炸效果的坐标起点. 时间上, 在和射线到达终点后, 再触发爆炸方法即可.
- let canvas = document.querySelector('#canvas')
- let context = canvas.getContext('2d')
- let cw = canvas.width = window.innerWidth
- let ch = canvas.height = window.innerHeight
- function randomColor(){
- // 返回一个 0-255 的数值, 三个随机组合为一起可定位一种 rgb 颜色
- let num = 3
- let color = []
- while(num--){
- color.push(Math.floor(Math.random()*254+1))
- }
- return color.join(',')
- }
- class Kirakira {
- constructor(targetX, targetY){
- // 指定产生的坐标点
- this.targetLocation = {x: targetX, y: targetY}
- this.radius = 1
- }
- draw() {
- // 绘制一个圆
- context.beginPath()
- context.arc(this.targetLocation.x, this.targetLocation.y, this.radius, 0, Math.PI * 2)
- context.lineWidth = 2
- context.strokeStyle = `rgba(${randomColor()}, 1)`;
- context.stroke()
- }
- update(){
- // 让圆进行扩张, 实现闪烁效果
- if(this.radius <5){
- this.radius += 0.3
- }else{
- this.radius = 1
- }
- }
- init() {
- this.draw()
- this.update()
- }
- }
- class Biubiubiu {
- constructor(startX, startY, targetX, targetY) {
- this.startLocation = {x: startX, y: startY}
- this.targetLocation = {x: targetX, y: targetY}
- // 运动当前的坐标, 初始默认为起点坐标
- this.nowLoaction = {x: startX, y: startY}
- // 到目标点的距离
- this.targetDistance = this.getDistance(this.startLocation.x, this.startLocation.y, this.targetLocation.x, this.targetLocation.y);
- // 速度
- this.speed = 2
- // 加速度
- this.acceleration = 1.02
- // 角度
- this.angle = Math.atan2(this.targetLocation.y - this.startLocation.y, this.targetLocation.x - this.startLocation.x)
- // 线段集合
- this.collection = []
- // 线段集合, 每次存 10 个, 取 10 个帧的距离
- this.collection = new Array(CONFIG.biuCollectionCont)
- // 是否到达目标点
- this.arrived = false
- }
- draw() {
- context.beginPath()
- try{
- context.moveTo(this.collection[0][0], this.collection[0][1])
- }catch(e){
- context.moveTo(this.nowLoaction.x, this.nowLoaction.y)
- }
- context.lineWidth = 3
- context.lineCap = 'round'
- context.lineTo(this.nowLoaction.x, this.nowLoaction.y)
- context.strokeStyle = `rgba(${randomColor()}, 1)`;
- context.stroke()
- }
- update() {
- this.collection.shift()
- this.collection.push([this.nowLoaction.x, this.nowLoaction.y])
- this.speed *= this.acceleration
- let vx = Math.cos(this.angle) * this.speed
- let vy = Math.sin(this.angle) * this.speed
- let nowDistance = this.getDistance(this.startLocation.x, this.startLocation.y, this.nowLoaction.x+vx, this.nowLoaction.y+vy)
- if(nowDistance>= this.targetDistance){
- this.arrived = true
- }else{
- this.nowLoaction.x += vx
- this.nowLoaction.y += vy
- this.arrived = false
- }
- }
- getDistance(x0, y0, x1, y1) {
- // 计算两坐标点之间的距离
- let locX = x1 - x0
- let locY = y1 - y0
- // 勾股定理
- return Math.sqrt(Math.pow(locX, 2) + Math.pow(locY, 2))
- }
- init() {
- this.draw()
- this.update()
- }
- }
- class Boom {
- // 爆炸物是没有确定的结束点坐标, 这个可以通过设定一定的阀值来限定
- constructor(startX, startY){
- this.startLocation = {x: startX, y: startY}
- this.nowLocation = {x: startX, y: startY}
- // 速度
- this.speed = Math.random()*10+2
- // 加速度
- this.acceleration = 0.95
- // 没有确定的结束点, 所以没有固定的角度, 可以随机角度扩散
- this.angle = Math.random()*Math.PI*2
- // 这里设置阀值为 100
- this.targetCount = 100
- // 当前计算为 1, 用于判断是否会超出阀值
- this.nowNum = 1
- // 透明度
- this.alpha = 1
- // 透明度减少梯度
- this.grads = 0.015
- // 重力系数
- this.gravity = 0.98
- // 线段集合, 每次存 10 个, 取 10 个帧的距离
- this.collection = new Array(10)
- // 是否到达目标点
- this.arrived = false
- }
- draw(){
- context.beginPath()
- try{
- context.moveTo(this.collection[0][0], this.collection[0][1])
- }catch(e){
- context.moveTo(this.nowLoaction.x, this.nowLoaction.y)
- }
- context.lineWidth = 3
- context.lineCap = 'round'
- context.lineTo(this.nowLocation.x, this.nowLocation.y)
- // 设置由透明度减小产生的渐隐效果, 看起来没这么突兀
- context.strokeStyle = `rgba(${randomColor()}, ${this.alpha})`
- context.stroke()
- }
- update(){
- this.collection.shift()
- this.collection.push([this.nowLocation.x, this.nowLocation.y])
- this.speed *= this.acceleration
- let vx = Math.cos(this.angle) * this.speed
- // 加上重力系数, 运动轨迹会趋向下
- let vy = Math.sin(this.angle) * this.speed + this.gravity
- // 当前计算大于阀值的时候的时候, 开始进行渐隐处理
- if(this.nowNum>= this.targetCount){
- this.alpha -= this.grads
- }else{
- this.nowLocation.x += vx
- this.nowLocation.y += vy
- this.nowNum++
- }
- // 透明度为 0 的话, 可以进行移除处理, 释放空间
- if(this.alpha <= 0){
- this.arrived = true
- }
- }
- init(){
- this.draw()
- this.update()
- }
- }
- class Animate {
- constructor(){
- // 用于记录当前实例化的坐标点
- this.startX = null
- this.startY = null
- this.targetX = null
- this.targetY = null
- // 定义一个数组做为闪烁球的集合
- this.kiras = []
- // 定义一个数组做为射线类的集合
- this.bius = []
- // 定义一个数组做为爆炸类的集合
- this.booms = []
- // 避免每帧都进行绘制导致的过量绘制, 设置阀值, 到达阀值的时候再进行绘制
- this.timerTarget = 80
- this.timerNum = 0
- }
- pushBoom(x, y){
- // 实例化爆炸效果, 随机条数的射线扩散
- for(let bi = Math.random()*10+20; bi>0; bi--){
- this.booms.push(new Boom(x, y))
- }
- }
- run() {
- window.requestAnimationFrame(this.run.bind(this))
- context.clearRect(0, 0, cw, ch)
- let biuNum = this.bius.length
- while(biuNum-- ){
- this.bius[biuNum].init()
- this.kiras[biuNum].init()
- if(this.bius[biuNum].arrived){
- // 到达目标后, 可以开始绘制爆炸效果, 当前线条的目标点则是爆炸实例的起始点
- this.pushBoom(this.bius[biuNum].nowLoaction.x, this.bius[biuNum].nowLoaction.y)
- // 到达目标后, 把当前类给移除, 释放空间
- this.bius.splice(biuNum, 1)
- this.kiras.splice(biuNum, 1)
- }
- }
- let bnum = this.booms.length
- while(bnum--){
- // 触发动画
- this.booms[bnum].init()
- if(this.booms[bnum].arrived){
- // 到达目标透明度后, 把炸点给移除, 释放空间
- this.booms.splice(bnum, 1)
- }
- }
- if(this.timerNum>= this.timerTarget){
- // 到达阀值后开始绘制实例化射线
- this.startX = Math.random()*(cw/2)
- this.startY = ch
- this.targetX = Math.random()*cw
- this.targetY = Math.random()*(ch/2)
- let exBiu = new Biubiubiu(this.startX, this.startY, this.targetX, this.targetY)
- let exKira = new Kirakira(this.targetX, this.targetY)
- this.bius.push(exBiu)
- this.kiras.push(exKira)
- // 到达阀值后把当前计数重置一下
- this.timerNum = 0
- }else{
- this.timerNum ++
- }
- }
- }
- let a = new Animate()
- a.run()
复制代码
制作过程中衍生出来的比较好玩的效果
- https://codepen.io/starriness/pen/EpaPqa
- https://codepen.io/starriness/pen/oMgxzZ
来源: https://juejin.im/post/5b587f59e51d45191e0d04ae