前端canvas粒子動畫背景(帶鼠標跟隨和點擊散開)

目錄

閒聊

看下效果

先貼下代碼吧

大概說一下流程

下面讓我來詳細說一下

1、初始化基礎屬性 

2、添加鼠標移動事件並實時更新鼠標座標 

3、通過隨機數生成粒子的座標和橫縱軸速度

4、渲染粒子並將粒子對象保存在數組中

5、調用requestAnimationFrame啓動動畫,使粒子移動起來

6、通過橫縱座標和速度計算粒子位置

7、計算與鼠標距離進行座標的修正

8、計算與鼠標距離並進行連線

9、計算粒子直接的距離並進行連線

10、添加鼠標點擊事件並調用粒子的散開事件

11、通過與鼠標的距離和相對位置進行計算來重新給粒子添加速度

12、監聽頁面大小變化來初始化畫布

總結


閒聊

一年前覺得別人寫的賊酷賊神奇的canvas粒子動畫背景,一年後自己寫了一個更nb的,hahahaha!

好吧,其實也沒啥難的,前後大概花了倆小時,只不過是最近才正兒八經學了一下canvas,寫個東西來練練手。

言歸正傳,這個粒子背景的粒子移動和粒子直接的連線以及和鼠標的連線都很簡單,兩個難點在於鼠標跟隨和點擊散開,下面的介紹中我將重點說一下這兩個功能點。

看下效果

沒有鼠標?截圖給隱藏掉了,位置就不用我說了吧。

gif好糊啊= =

先貼下代碼吧

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Starry</title>
</head>
<body>
  <div style="position: fixed;top: 0;left:0;bottom: 0;right: 0;z-index: 0">
    <canvas id="canvas" style="background-color: rgb(50,64,87);"></canvas>
  </div>

<script type="text/javascript">
  const canvas = document.getElementById('canvas')
  const ctx = canvas.getContext('2d')
  let width = window.innerWidth
  let height = window.innerHeight

  let dotsNum = 80 // 點的數量
  let radius = 1 // 圓的半徑,連接線寬度的一半
  let fillStyle = 'rgba(255,255,255,0.5)' // 點的顏色
  let lineWidth = radius * 2
  let connection = 120 // 連線最大距離
  let followLength = 80 // 鼠標跟隨距離

  let dots = []
  let animationFrame = null
  let mouseX = null
  let mouseY = null

  function addCanvasSize () { // 改變畫布尺寸
    width = window.innerWidth
    height = window.innerHeight
    canvas.width = width
    canvas.height = height
    ctx.clearRect(0, 0, width, height)
    dots = []
    if (animationFrame) window.cancelAnimationFrame(animationFrame)
    initDots(dotsNum)
    moveDots()
  }

  function mouseMove (e) {
    mouseX = e.clientX
    mouseY = e.clientY
  }

  function mouseOut (e) {
    mouseX = null
    mouseY = null
  }

  function mouseClick () {
    for (const dot of dots) dot.elastic()
  }

  class Dot {
    constructor(x, y) {
      this.x = x
      this.y = y
      this.speedX = Math.random() * 2 - 1
      this.speedY = Math.random() * 2 - 1
      this.follow = false
    }
    draw () {
      ctx.beginPath()
      ctx.arc(this.x, this.y, radius, 0, 2 * Math.PI)
      ctx.fill()
      ctx.closePath()
    }
    move () {
      if (this.x >= width || this.x <= 0) this.speedX = -this.speedX
      if (this.y >= height || this.y <= 0) this.speedY = -this.speedY
      this.x += this.speedX
      this.y += this.speedY
      if (this.speedX >= 1) this.speedX--
      if (this.speedX <= -1) this.speedX++
      if (this.speedY >= 1) this.speedY--
      if (this.speedY <= -1) this.speedY++
      this.correct()
      this.connectMouse()
      this.draw()
    }
    correct () { // 根據鼠標的位置修正
      if (!mouseX || !mouseY) return
      let lengthX = mouseX - this.x
      let lengthY = mouseY - this.y
      const distance = Math.sqrt(lengthX ** 2 + lengthY ** 2)
      if (distance <= followLength) this.follow = true
      else if (this.follow === true && distance > followLength && distance <= followLength + 8) {
        let proportion = followLength / distance
        lengthX *= proportion
        lengthY *= proportion
        this.x = mouseX - lengthX
        this.y = mouseY - lengthY
      } else this.follow = false
    }
    connectMouse () { // 點與鼠標連線
      if (mouseX && mouseY) {
        let lengthX = mouseX - this.x
        let lengthY = mouseY - this.y
        const distance = Math.sqrt(lengthX ** 2 + lengthY ** 2)
        if (distance <= connection) {
          opacity = (1 - distance / connection) * 0.5
          ctx.strokeStyle = `rgba(255,255,255,${opacity})`
          ctx.beginPath()
          ctx.moveTo(this.x, this.y)
          ctx.lineTo(mouseX, mouseY);
          ctx.stroke();
          ctx.closePath()
        }
      }
    }
    elastic () { // 鼠標點擊後的彈射
      let lengthX = mouseX - this.x
      let lengthY = mouseY - this.y
      const distance = Math.sqrt(lengthX ** 2 + lengthY ** 2)
      if (distance >= connection) return
      const rate = 1 - distance / connection // 距離越小此值約接近1
      this.speedX = 40 * rate * -lengthX / distance
      this.speedY = 40 * rate * -lengthY / distance
    }
  }

  function initDots (num) { // 初始化粒子
    ctx.fillStyle = fillStyle
    ctx.lineWidth = lineWidth
    for (let i = 0; i < num; i++) {
      const x = Math.floor(Math.random() * width)
      const y = Math.floor(Math.random() * height)
      const dot = new Dot(x, y)
      dot.draw()
      dots.push(dot)
    }
  }

  function moveDots () { // 移動並建立點與點之間的連接線
    ctx.clearRect(0, 0, width, height)
    for (const dot of dots) {
      dot.move()
    }
    for (let i = 0; i < dots.length; i++) {
      for (let j = i; j < dots.length; j++) {
        const distance = Math.sqrt((dots[i].x - dots[j].x) ** 2 + (dots[i].y - dots[j].y) ** 2)
        if (distance <= connection) {
          opacity = (1 - distance / connection) * 0.5
          ctx.strokeStyle = `rgba(255,255,255,${opacity})`
          ctx.beginPath()
          ctx.moveTo(dots[i].x, dots[i].y)
          ctx.lineTo(dots[j].x, dots[j].y);
          ctx.stroke();
          ctx.closePath()
        }
      }
    }
    animationFrame = window.requestAnimationFrame(moveDots)
  }

  addCanvasSize()

  initDots(dotsNum)
  moveDots()

  document.onmousemove = mouseMove
  document.onmouseout = mouseOut
  document.onclick = mouseClick
  window.onresize = addCanvasSize
</script>
</body>
</html>

大概說一下流程

 

  1. 初始化基礎屬性
  2. 添加鼠標移動事件並實時更新鼠標座標
  3. 通過隨機數生成粒子的座標和橫縱軸速度
  4. 渲染粒子並將粒子對象保存在數組中
  5.  調用requestAnimationFrame啓動動畫,使粒子移動起來
  6. 通過橫縱座標和速度計算粒子位置
  7. 計算與鼠標距離進行座標的修正
  8. 計算與鼠標距離並進行連線
  9. 計算粒子直接的距離並進行連線
  10. 添加鼠標點擊事件並調用粒子的散開事件
  11. 通過與鼠標的距離和相對位置進行計算來重新給粒子添加速度
  12. 監聽頁面大小變化來初始化畫布

下面讓我來詳細說一下

1、初始化基礎屬性 

在這裏初始化一些基礎屬性,粒子大小啊、顏色啊、數量啊叭啦叭啦的,先過過眼,下面都會用到。

const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')
let width = window.innerWidth
let height = window.innerHeight

let dotsNum = 80 // 點的數量
let radius = 1 // 圓的半徑,連接線寬度的一半
let fillStyle = 'rgba(255,255,255,0.5)' // 點的顏色
let lineWidth = radius * 2
let connection = 120 // 連線最大距離
let followLength = 80 // 鼠標跟隨距離

let dots = [] // 粒子集合
let animationFrame = null
let mouseX = null
let mouseY = null

2、添加鼠標移動事件並實時更新鼠標座標 

在這裏實時更新全局的鼠標座標值,爲下面與鼠標連線的處理做準備。

function mouseMove (e) {
  mouseX = e.clientX
  mouseY = e.clientY
}

function mouseOut (e) {
  mouseX = null
  mouseY = null
}

document.onmousemove = mouseMove
document.onmouseout = mouseOut

3、通過隨機數生成粒子的座標和橫縱軸速度

這一步比較簡單,在構造函數裏通過隨機數對粒子的橫縱座標和速度進行初始化,這裏注意,速度是有正負值的,我在這把速度限制在-1到1之間,另外有個follow屬性非常重要,在接下來我會講到。

class Dot {
    constructor(x, y) {
      this.x = x
      this.y = y
      this.speedX = Math.random() * 2 - 1
      this.speedY = Math.random() * 2 - 1
      this.follow = false
    }
}

4、渲染粒子並將粒子對象保存在數組中

這一步循環生成粒子對象並調用粒子對象的draw方法進行渲染,然後把粒子存入dots中以備後面使用。到這步完成,頁面上就已經可以出現好多粒子了。

function initDots (num) { // 初始化粒子
  ctx.fillStyle = fillStyle
  ctx.lineWidth = lineWidth
  for (let i = 0; i < num; i++) {
    const x = Math.floor(Math.random() * width)
    const y = Math.floor(Math.random() * height)
    const dot = new Dot(x, y)
    dot.draw()
    dots.push(dot)
   }
}


draw () { // class Dot
  ctx.beginPath()
  ctx.arc(this.x, this.y, radius, 0, 2 * Math.PI)
  ctx.fill()
  ctx.closePath()
}

5、調用requestAnimationFrame啓動動畫,使粒子移動起來

清空畫布並調用粒子的move方法重新計算位置,這裏使用window.requestAnimationFrame來請求動畫幀,這樣實現的動畫要比setIntervel效果要更好,更自然,不瞭解的小夥伴可以自行百度一下。

function moveDots () { // 移動並建立點與點之間的連接線
  ctx.clearRect(0, 0, width, height)
  for (const dot of dots) {
    dot.move()
  }
  animationFrame = window.requestAnimationFrame(moveDots)
}

6、通過橫縱座標和速度計算粒子位置

這一步,主要對粒子進行碰撞檢測,當檢測到粒子貼近窗口邊緣時,需要把碰撞所對應的速度分量改爲其相反值,然後重新得出粒子的橫縱座標;在這個方法下還有四行處理,目的是對速度絕對值大於1的分量進行減速,這個是爲後面鼠標點擊散開而做的處理,後面我會說到。在這些處理都結束之後,會調用一個位置修正和與鼠標連線的處理,這兩個我後面會說,都完成後,掉用draw重新繪製粒子。

move () { // class Dot
  if (this.x >= width || this.x <= 0) this.speedX = -this.speedX
  if (this.y >= height || this.y <= 0) this.speedY = -this.speedY
  this.x += this.speedX
  this.y += this.speedY
  if (this.speedX >= 1) this.speedX--
  if (this.speedX <= -1) this.speedX++
  if (this.speedY >= 1) this.speedY--
  if (this.speedY <= -1) this.speedY++
  this.correct()
  this.connectMouse()
  this.draw()
}

7、計算與鼠標距離進行座標的修正

這一步就是我上面說的難點之一,鼠標跟隨。下面我先儘可能通俗易懂的說一下我的實現思想。

我會設置一個牽引半徑,這個牽引半徑以鼠標爲圓心會形成一個圓形的牽引區域,這個圓形區域內任何試圖逃出的粒子都會被鼠標牽引住,無法逃出這個牽引半徑。

那麼,重點就在如何修正逃出粒子的座標。在這裏,我虛擬了一個區域,我稱之爲修正區域,這個修正區域是在牽引區域外面加了一圈,就像游泳圈一樣,中間是牽引區域,外面的那一圈是修正區域。這個修正區域的作用,就是在發現有粒子從牽引區域逃逸到修正區域時,修正粒子的座標到牽引區域的最外側,這樣就實現了鼠標對粒子的牽引。

這裏要注意的是,一定是從牽引區域移動到修正區域的粒子纔會被牽引,從外部區域進入修正區域的粒子是不能觸發牽引的,要不路過修正區域的粒子就會被吸進來(好像也挺有意思0.0)。

看到這可能有人會想,這樣的話粒子一出去就會被修正回來,會不會永遠都逃不出這個牽引半徑呢?答案是不會,因爲我們知道通過requestAnimationFrame請求的動畫最快爲60幀,也就是說,只要你鼠標移動的速度夠快,在下一幀到來的時候原本在牽引區域的粒子沒有經過修正區域直接在修正區域和牽引區域以外,那麼這個粒子就不再會被鼠標牽引。

大體的邏輯就是這樣,下面將一下實現。

首先,排除鼠標不在頁面裏的情況,我在之前的鼠標移出頁面的時候加了一個方法,會把鼠標的橫縱座標值都設爲null,這裏做下判斷return一下就行了。

接下來就要計算粒子與鼠標的相對距離,先計算橫縱座標的相對距離,再用其縱座標的相對距離計算出距離鼠標的位置。

計算出鼠標與粒子的相對距離後,就需要對其進行判斷,這裏分三種情況:

  1. 在牽引區域
  2. 在外部區域
  3. 在修正區域

1、在牽引區域:將粒子的follow屬性設爲true,說明這個粒子現在處在牽引區域,這個屬性是爲了對粒子是否從牽引區域進入的修正區域做判斷用的。

2、在外部區域:將粒子的follow屬性設爲false,作用同上。

3、在修正區域:首先判斷粒子的follow屬性,若爲false,則說明粒子是從外部區域進入的,則不做處理;我在這把游泳圈的寬度設置爲8,就是之前講的,鼠標很小的速度就能使粒子脫離牽引。當粒子的follow屬性爲true也就是說需要進行修正時,通過粒子與鼠標的相對距離與牽引半徑的比值,乘粒子與鼠標橫縱座標的相對距離,得到修正後的粒子座標與鼠標位置的橫縱偏移量,然後通過鼠標的橫縱座標減去剛剛得到的偏移量,就得到了修正後粒子的橫縱座標。

對上面的第三種情況打個比方,就好像一個矩形,矩形的長和寬就是鼠標和粒子的橫縱相對距離,對角線就是粒子與鼠標的相對距離,然後我們需要把這個矩形等比例縮小,縮小到對角線距離等於牽引距離。簡單畫了個圖:

correct () { // 根據鼠標的位置修正 class dot
  if (!mouseX || !mouseY) return
  let lengthX = mouseX - this.x
  let lengthY = mouseY - this.y
  const distance = Math.sqrt(lengthX ** 2 + lengthY ** 2)
  if (distance <= followLength) this.follow = true
  else if (this.follow === true && distance > followLength && distance <= followLength + 8) {
    let proportion = followLength / distance
    lengthX *= proportion
    lengthY *= proportion
    this.x = mouseX - lengthX
    this.y = mouseY - lengthY
  } else this.follow = false
}

8、計算與鼠標距離並進行連線

這一步就是通過粒子的座標和鼠標的座標計算出相對距離,判斷其如果小於連線距離,那麼就與鼠標直接繪製連線,值得一提的是,這條連接線的透明度是隨着粒子與鼠標的距離改變而改變的,距離越大,越趨近於透明,透明度最大爲0.5,具體計算很簡單,我就不說了,大家直接看代碼吧。

if (mouseX && mouseY) {
  let lengthX = mouseX - this.x
  let lengthY = mouseY - this.y
  const distance = Math.sqrt(lengthX ** 2 + lengthY ** 2)
  if (distance <= connection) {
    opacity = (1 - distance / connection) * 0.5
    ctx.strokeStyle = `rgba(255,255,255,${opacity})`
    ctx.beginPath()
    ctx.moveTo(this.x, this.y)
    ctx.lineTo(mouseX, mouseY);
    ctx.stroke();
    ctx.closePath()
  }
}

9、計算粒子直接的距離並進行連線

這一步和上一步同理,只不過是變成了判斷兩個粒子之間的距離進行連線,在這裏用了一個雙重for循環,值得注意的是裏面的哪層for循環的起始值不是0,要不每條連接線會繪製兩次,就不對了。和上一步一樣,粒子之間的連接線也是隨着距離變化透明度變化的。

for (let i = 0; i < dots.length; i++) {
  for (let j = i; j < dots.length; j++) {
    const distance = Math.sqrt((dots[i].x - dots[j].x) ** 2 + (dots[i].y - dots[j].y) ** 2)
    if (distance <= connection) {
      opacity = (1 - distance / connection) * 0.5
      ctx.strokeStyle = `rgba(255,255,255,${opacity})`
      ctx.beginPath()
      ctx.moveTo(dots[i].x, dots[i].y)
      ctx.lineTo(dots[j].x, dots[j].y);
      ctx.stroke();
      ctx.closePath()
    }
  }
}

10、添加鼠標點擊事件並調用粒子的散開事件

在這一步添加一個全局的點擊事件,這個事件會調用所有粒子的elastic方法,並判斷是否執行散開動作。

function mouseClick () {
  for (const dot of dots) dot.elastic()
}

document.onclick = mouseClick

11、通過與鼠標的距離和相對位置進行計算來重新給粒子添加速度

這是第二個難點,點擊散開的具體實現。

簡單來說,點擊散開就是在鼠標點擊的時候,判斷粒子與鼠標的相對距離,如果小於某個閾(yu四聲,這我老是念錯= =)值,就會被以鼠標相反的方向彈開,而且距離越小彈開的速度越快。還記得在之前計算粒子位置的move方法那裏,我寫的對速度絕對值大於1的判斷麼,這就用到了。當我彈開粒子時,粒子速度可能很快,所以要對粒子進行減速處理,這就是那四行代碼的作用。

下面來說實現。

首先算出粒子與鼠標的橫縱相對距離以及相對距離,然後判斷其相對距離,當小於連接距離時,重新計算粒子的速度。

可以看到,我在下面通過1減去粒子與鼠標的相對距離與連接距離的比值,計算出了一個rate參數,鼠標距離魚粒子距離越近,這個值約接近於1,然後用這個值去乘40(這個40是我設置的粒子彈開的速度上限),就得到了粒子的速度。

然後我們需要拿這個速度去計算粒子的橫縱方向的分速度,我們用粒子的速度分別去乘負的粒子與鼠標的橫縱相對距離與粒子與鼠標的相對距離的比值,就得到了粒子的橫縱方向的分速度,這個比值就是所謂的正弦和餘弦。

另外,爲什麼是負的,因爲粒子需要向鼠標點擊相反的反向彈開,所以在這裏要取下反。

到這,這個粒子動畫背景就基本算是完成了。

elastic () { // 鼠標點擊後的彈射
  let lengthX = mouseX - this.x
  let lengthY = mouseY - this.y
  const distance = Math.sqrt(lengthX ** 2 + lengthY ** 2)
  if (distance >= connection) return
  const rate = 1 - distance / connection // 距離越小此值約接近1
  this.speedX = 40 * rate * -lengthX / distance
  this.speedY = 40 * rate * -lengthY / distance
}

// 上面粒子減速的處理
if (this.speedX >= 1) this.speedX--
if (this.speedX <= -1) this.speedX++
if (this.speedY >= 1) this.speedY--
if (this.speedY <= -1) this.speedY++

12、監聽頁面大小變化來初始化畫布

最後,這是一個優化,因爲用戶改變瀏覽器窗口大小的時候,使畫布可以根據改變後的大小重新加載,具體操作大家直接看代碼吧,寫博客的時候纔想起來這個地方應該加個防抖的,懶得改了,有心的小夥伴們自己加一下吧哈哈。

function addCanvasSize () { // 改變畫布尺寸
  width = window.innerWidth
  height = window.innerHeight
  canvas.width = width
  canvas.height = height
  ctx.clearRect(0, 0, width, height)
  dots = []
  if (animationFrame) window.cancelAnimationFrame(animationFrame)
  initDots(dotsNum)
  moveDots()
}

window.onresize = addCanvasSize

總結

在實現的時候,我並沒有借鑑任何資料,這種經過獨立思考實現的小玩意還是非常令人高興的,有空我會把它封裝成js包,以方便在任何需要的地方使用。

練手項目,難免有一些不足,也歡迎大佬們提出寶貴意見,大家一起交流進步。

學無止境!!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章