用canvas 画烟花

开始

最终效果: codepen

一开始都是一个单一的用例,定位在画布中央,再扩展开来

先获取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()
复制代码

说说三角函数

已知坐标起点和坐标终点, 那么问题来了,要怎么知道从起点到终点的每一帧的坐标呢

如图。大概需要做判断的目标有

  1. 线条运动的距离是否超出起点到终点的距离,如超出则需要停止运动
  2. 每一帧运动到达的坐标

计算距离

对于坐标间距离的计算,很明显的可以使用勾股定理完成。
设起点坐标为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()
复制代码

制作过程中衍生出来的比较好玩的效果

  1. codepen
  2. codepen

猜你喜欢

转载自juejin.im/post/5b587f59e51d45191e0d04ae