用canvas 畫煙花

Suven發表於2018-07-25

開始

最終效果: 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()
複製程式碼

說說三角函式

已知座標起點和座標終點, 那麼問題來了,要怎麼知道從起點到終點的每一幀的座標呢

用canvas 畫煙花
如圖。大概需要做判斷的目標有

  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

相關文章