前端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包,以方便在任何需要的地方使用。

练手项目,难免有一些不足,也欢迎大佬们提出宝贵意见,大家一起交流进步。

学无止境!!

  • 46
    点赞
  • 98
    收藏
    觉得还不错? 一键收藏
  • 9
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值