【前端 Canvas 训练营】第一期:鼠标交互粒子背景效果

在这里插入图片描述

一、引言

这是一个全新的系列,在这个系列中,我将通过一系列实际案例,和你一起学习、研究Canvas,从而掌握编写 Canvas 特效的能力。

HTML5 canvas 元素用于图形的绘制,通过脚本 (通常是JavaScript)来完成。
canvas 标签只是图形容器,您必须使用脚本来绘制图形。
你可以通过多种方法使用 canvas 绘制路径,盒、圆、字符以及添加图像。

这是一个注重实际案例的系列教程,我希望能够通过具体的案例,而非抽象的文档和解释带你学习 Canvas 这项技术。那么废话不多说,我们直接看这次的案例吧。

二、案例介绍在这里插入图片描述

这是在很多博客网站上都非常常见的背景特效,在空旷的画布上,有非常多的随机运动的点,这些点在运动时如果距离足够近,则会产生连线。当鼠标移入时,也会为周围的点创建连线,同时与鼠标相连的点会受到鼠标的牵引。
我们可以将这个案例轻松地划分为三个由易到难的实现阶段:

  1. 简单效果:即完成点的自由运动、以及连线的自动产生。
  2. 复杂效果:鼠标移入时,完成鼠标与周围点的连线。
  3. 最终效果:鼠标移动时,与鼠标相连的点受到牵引。

在学习完这篇文章后,我建议你至少掌握到第二阶段,最终效果是否能够掌握取决于你是否有兴趣。

三、逐步实现

1. 文件创建

首先,创建一个html文件:

<!DOCTYPE html>
<html lang="cn">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="style.css">
    <script src="script.js" defer></script>
    <title>Document</title>
</head>
<body>
    <canvas id="canvas"></canvas>
</body>
</html>

同时,创建对应的CSS文件:

*{
    margin: 0;
    padding: 0;
}

body{
    background-color: #333;
    overflow: hidden;
}

#canvas{
    position:fixed;
    left:0;
    top:0;
}

最后,创建一个js文件,接下来,我们将在js文件中进行编辑。

2. 简单效果的实现

因为这是本系列的第一期,因此我会比较啰嗦地讲一些 Canvas 的方法调用。
首先,我们需要创建一个 canvas 对象。

var canvas = document.getElementById("canvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
var ctx = canvas.getContext("2d");

首先,我们需要找到 Canvas 对象,然后为 Canvas 对象设置宽高。
接下来,我们需要获取到 Canvascontext 对象。getContext("2d") 对象是内建的 HTML5 对象,拥有多种绘制路径、矩形、圆形、字符以及添加图像的方法。我们在进行绘制时,需要调用它上面的属性和方法。

var particles = []
var count = parseInt(canvas.height/150*canvas.width/150);
var maxDis = 150;

接下来,我们创建一个particles数组,用来存储所有的粒子对象,然后规定粒子的数目在每150*150像素一颗,最后定义连线的最大距离是150像素。
在完成了上面一系列准备动作以后,我们需要设计一个 Particle 类。

class Particle{
    constructor(x,y) {
        this.x = x;
        this.y = y;
        this.directionY = 0.5 - Math.random();
        this.directionX = 0.5 - Math.random();
    }
}

我们首先定义了Particle类的构造方法,它定义了位置xy,速度变量directionXdirectionY
接下来,我们需要设计一个更新方法,用于更新粒子下一步的状态:

    update() {
        this.x += this.directionX;
        this.y += this.directionY;
    }

最后,我们需要为粒子对象设计一个draw方法,draw函数里直接调用了canvas的方法,用于在视图上绘制粒子:

    draw() {
        ctx.beginPath();
        ctx.arc(this.x,this.y,2,0,Math.PI*2);
        ctx.fillStyle = "white";
        ctx.fill();
    }

这个方法中,我们使用了一开始拿到的context对象,利用这个对象上的属性和方法绘制粒子。在绘制粒子时,我们首先调用ctx.beginPath();表示开始绘制,接下来调用ctx.arc(this.x,this.y,2,0,Math.PI*2);在粒子所在的位置绘制一个半径为2的圆,最后使用ctx.stroke();表明绘制结束。

在canvas中绘制圆形, 我们将使用以下方法:
arc(x,y,r,start,stop)

通过以上几个方法,我们就完成了Particle类的定义,完整的代码如下:

class Particle{
    constructor(x,y) {
        this.x = x;
        this.y = y;
        this.directionY = 0.5 - Math.random();
        this.directionX = 0.5 - Math.random();
    }
    update() {
        this.x += this.directionX;
        this.y += this.directionY;
    }
    draw() {
        ctx.beginPath();
        ctx.arc(this.x,this.y,2,0,Math.PI*2);
        ctx.fillStyle = "white";
        ctx.fill();
    }
}

接下来,我们需要实现一个创建粒子的方法:

function createParticle(){
    let x = Math.random() * canvas.width;
    let y = Math.random() * canvas.height;
    particles.push(new Particle(x, y));
}

我们每创建一个粒子,就将粒子放进我们的particles数组里。
下面,我们实现一个处理粒子的方法:

function handleParticle(){
    particles.forEach((element,index) => {
        element.update();
        element.draw();
        if(element.x < 0 || element.x > canvas.width){
            element.directionX = - element.directionX;
        }
        if(element.y < 0 || element.y > canvas.height) {
            element.directionY = - element.directionY;
        }
        particles.forEach((aElement,index) => {
    		distance = Math.sqrt( Math.pow(element.x - aElement.x,2) + Math.pow(element.y - aElement.y,2) );
    		if(distance < maxDis) {
        		ctx.beginPath();
        		ctx.strokeStyle = "rgba(255,255,255," + (1 - distance / maxDis) + ")";
        		ctx.moveTo(element.x,element.y);
        		ctx.lineTo(aElement.x,aElement.y);
        		ctx.lineWidth = 1;
        		ctx.stroke();
    		}
		})
    }); 
}

在粒子的处理函数中,我们通过forEach函数遍历每个粒子,然后分别调用粒子的update方法和draw方法,更新粒子的状态,并且绘制粒子。之后再做检查,如果粒子超出了canvas元素的边界,则通过改变它的速度方向使其反弹回去。

之后,我们还需要再进行一次内部遍历,检查当前粒子与其他粒子的距离,如果距离小于maxDis,则绘制粒子之间的线段。绘制的颜色可以将透明度设置为与距离有关,绘制的方法与上面的draw函数有异曲同工之妙,这里就不再啰嗦。

最后,我们通过调用一个定时器来完成粒子的动态效果:

function draw(){
    ctx.clearRect(0,0,canvas.width,canvas.height);
    //清空画布内容
    if(particles.length < count) {
        createParticle();
    }
    handleParticle();
}

setInterval(draw,10);

至此,我们已经完成了简单效果,即完成点的自由运动、以及连线的自动产生。这是实现了简单效果的完整代码:

var canvas = document.getElementById("canvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
var ctx = canvas.getContext("2d");
var particles = []
var count = parseInt(canvas.height/150*canvas.width/150);
var maxDis = 150;

class Particle{
    constructor(x,y) {
        this.x = x;
        this.y = y;
        this.directionY = 0.5 - Math.random();
        this.directionX = 0.5 - Math.random();
    }
    update() {
        this.x += this.directionX;
        this.y += this.directionY;
    }
    draw() {
        ctx.beginPath();
        ctx.arc(this.x,this.y,2,0,Math.PI*2);
        ctx.fillStyle = "white";
        ctx.fill();
    }
}

function createParticle(){
    let x = Math.random() * canvas.width;
    let y = Math.random() * canvas.height;
    particles.push(new Particle(x, y));
}

function handleParticle(){
    particles.forEach((element,index) => {
        element.update();
        element.draw();
        if(element.x < 0 || element.x > canvas.width){
            element.directionX = - element.directionX;
        }
        if(element.y < 0 || element.y > canvas.height) {
            element.directionY = - element.directionY;
        }
        particles.forEach((aElement,index) => {
            distance = Math.sqrt( Math.pow(element.x - aElement.x,2) + Math.pow(element.y - aElement.y,2) );
            if(distance < maxDis) {
                ctx.beginPath();
                ctx.strokeStyle = "rgba(255,255,255," + (1 - distance / maxDis) + ")";
                ctx.moveTo(element.x,element.y);
                ctx.lineTo(aElement.x,aElement.y);
                ctx.lineWidth = 1;
                ctx.stroke();
            }
        })
    }); 
}

function draw(){
    ctx.clearRect(0,0,canvas.width,canvas.height);
    if(particles.length < count) {
        createParticle();
    }
    handleParticle();
}

setInterval(draw,10);

3. 复杂效果的实现

实现简单效果以后,接下来,我们将演示如何实现复杂一些的效果,即鼠标移入时,完成鼠标与周围点的连线。
这个功能其实不难,在实现时最重要的在于获取当前的鼠标位置
这里我们选择使用全局变量mouseXmouseY来保存鼠标信息。同时设定一个鼠标捕获最大距离maxMouseDis

var mouseX = -1,mouseY = -1;
var maxMouseDis = 250;

同时,通过为canvas对象添加如下的鼠标事件,

canvas.addEventListener("mousemove",function(e) {
    mouseX = e.clientX;
    mouseY = e.clientY;
})
canvas.addEventListener("mouseout",function(){
    mouseX = -1;
    mouseY = -1;
})

当鼠标在canvas元素上移动时,会实时更新鼠标位置,一旦鼠标移出canvas元素,我们就将mouseXmouseY置为-1作为标记。
添加完鼠标事件后,我们还需要设计一个鼠标处理函数:

function handleMouse(){
    if(mouseX == -1 || mouseY == -1) return;
    particles.forEach((element,index) => {
        let distance = Math.sqrt( Math.pow(element.x - mouseX,2) + Math.pow(element.y - mouseY,2) );
        if(distance < maxMouseDis) {
            ctx.beginPath();
            ctx.strokeStyle = "rgba(255,255,255," + (1 - distance / maxMouseDis) + ")";
            ctx.moveTo(element.x,element.y);
            ctx.lineTo(mouseX,mouseY);
            ctx.lineWidth = 1;
            ctx.stroke();
        }
    })
}

handleMouse函数中,我们需要遍历所有的粒子,一旦鼠标与粒子之间的距离小于设定的maxMouseDis,则开始连线,连线方式与上面handleParticle中一致。
最后,不要忘记在定时器函数draw中添加handleMouse

function draw(){
    ctx.clearRect(0,0,canvas.width,canvas.height);
    if(particles.length < count) {
        createParticle();
    }
    handleParticle();
    handleMouse();
}

通过上面一系列操作,我们已经完成了复杂效果,即鼠标移入时,完成鼠标与周围点的连线。这是完整的代码:

var canvas = document.getElementById("canvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
var ctx = canvas.getContext("2d");
var particles = []
var count = parseInt(canvas.height/150*canvas.width/150);
var maxDis = 150;
var maxMouseDis = 250;
var mouseX = -1,mouseY = -1;

class Particle{
    constructor(x,y) {
        this.x = x;
        this.y = y;
        this.directionY = 0.5 - Math.random();
        this.directionX = 0.5 - Math.random();
    }
    update() {
        this.x += this.directionX;
        this.y += this.directionY;
    }
    draw() {
        ctx.beginPath();
        ctx.arc(this.x,this.y,2,0,Math.PI*2);
        ctx.fillStyle = "white";
        ctx.fill();
    }
}

function createParticle(){
    let x = Math.random() * canvas.width;
    let y = Math.random() * canvas.height;
    particles.push(new Particle(x, y));
}

function handleParticle(){
    particles.forEach((element,index) => {
        element.update();
        element.draw();
        if(element.x < 0 || element.x > canvas.width){
            element.directionX = - element.directionX;
        }
        if(element.y < 0 || element.y > canvas.height) {
            element.directionY = - element.directionY;
        }
        particles.forEach((aElement,index) => {
            distance = Math.sqrt( Math.pow(element.x - aElement.x,2) + Math.pow(element.y - aElement.y,2) );
            if(distance < maxDis) {
                ctx.beginPath();
                ctx.strokeStyle = "rgba(255,255,255," + (1 - distance / maxDis) + ")";
                ctx.moveTo(element.x,element.y);
                ctx.lineTo(aElement.x,aElement.y);
                ctx.lineWidth = 1;
                ctx.stroke();
            }
        })
    }); 
}

function handleMouse(){
    if(mouseX == -1 || mouseY == -1) return;
    particles.forEach((element,index) => {
        let distance = Math.sqrt( Math.pow(element.x - mouseX,2) + Math.pow(element.y - mouseY,2) );
        if(distance < maxMouseDis) {
            ctx.beginPath();
            ctx.strokeStyle = "rgba(255,255,255," + (1 - distance / maxMouseDis) + ")";
            ctx.moveTo(element.x,element.y);
            ctx.lineTo(mouseX,mouseY);
            ctx.lineWidth = 1;
            ctx.stroke();
        }
    })
}

function draw(){
    ctx.clearRect(0,0,canvas.width,canvas.height);
    if(particles.length < count) {
        createParticle();
    }
    handleParticle();
    handleMouse();
}

setInterval(draw,10);

canvas.addEventListener("mousemove",function(e) {
    mouseX = e.clientX;
    mouseY = e.clientY;
})

canvas.addEventListener("mouseout",function(){
    mouseX = -1;
    mouseY = -1;
})

4. 最终效果的实现

最终效果是要实现鼠标移动时,与鼠标相连的点受到牵引。听起来有点困难,但实际只需要在handleMouse函数中做文章。
考虑这样一个场景:当鼠标移入Canvas元素时,与之相邻的粒子会形成与鼠标的连线,可以把连线考虑为有弹性的绳子。但由于连线本身并没有力的作用,因此粒子会继续沿着它原来的方向移动。直到粒子与鼠标间的距离大于某个值,粒子会受到线的牵引,从而在一瞬间受到一个连线方向的力,粒子的移动速度也发生了变化。
因此我们规定,连线在maxMouseDis / 3 * 2以内不具有弹性,在maxMouseDis / 3 * 2maxMouseDis之间具有弹性,会将粒子向鼠标牵引。
因此,我们需要在handleMouse中添加如下代码段:

let velocity = Math.sqrt( Math.pow(element.directionX,2) + Math.pow(element.directionY,2) );
if(distance > maxMouseDis / 3 * 2) {
     element.directionX = - velocity * (element.x - mouseX) / distance * 1;
     element.directionY = - velocity * (element.y - mouseY) / distance * 1;
}

当达到牵引的条件时,我们就保持粒子的速度不变,改变它的速度方向为朝向鼠标的方向。
这样一番修改以后,已经实现了粒子被鼠标捕获并受到牵引的效果。

但这样的效果让人感觉不太真实。因为仅仅是改变了粒子原有速度的方向,当鼠标移动时,就没法实现粒子被鼠标拉动的效果。因此还可以这样优化:

var maxVelocity = 0.6;
var lastMouseX = -1,lastMouseY = -1;
var mouseVelocity = 0;

首先添加最大粒子速度、上一刻的鼠标位置和鼠标移速三个全局变量。

    if(lastMouseX == -1 || lastMouseY == -1) {
        mouseVelocity = 0;
    } else {
        mouseVelocity = Math.sqrt( Math.pow(lastMouseX - mouseX,2) + Math.pow(lastMouseY - mouseY,2) )
    }
    lastMouseX = mouseX;
    lastMouseY = mouseY;

随后在鼠标移动的事件侦听器中,添加如上代码,即可得到当前的鼠标移速。

if(distance > maxMouseDis / 3 * 2) {
       element.directionX = - Math.min(Math.max(mouseVelocity,velocity),maxVelocity) * (element.x - mouseX) / distance * 1;
       element.directionY = - Math.min(Math.max(mouseVelocity,velocity),maxVelocity) * (element.y - mouseY) / distance * 1;
}

最后在handleMouse函数中通过如上的代码对牵引效果进行改进。牵引时的粒子速度将取鼠标速度和粒子本身速度的较大者,但不能超过预定的最大速度。
通过上面一系列修改,我们完成了最终效果,这是完整的代码:

var canvas = document.getElementById("canvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
var ctx = canvas.getContext("2d");
var particles = []
var count = parseInt(canvas.height/150*canvas.width/150);
var maxDis = 150;
var maxMouseDis = 250;
var maxVelocity = 0.6;
var mouseX = -1,mouseY = -1;
var lastMouseX = -1,lastMouseY = -1;
var mouseVelocity = 0;

class Particle{
    constructor(x,y) {
        this.x = x;
        this.y = y;
        this.directionY = 0.5 - Math.random();
        this.directionX = 0.5 - Math.random();
    }
    update() {
        this.x += this.directionX;
        this.y += this.directionY;
    }
    draw() {
        ctx.beginPath();
        ctx.arc(this.x,this.y,2,0,Math.PI*2);
        ctx.fillStyle = "white";
        ctx.fill();
    }
}

function createParticle(){
    let x = Math.random() * canvas.width;
    let y = Math.random() * canvas.height;
    particles.push(new Particle(x, y));
}

function handleParticle(){
    particles.forEach((element,index) => {
        element.update();
        element.draw();
        if(element.x < 0 || element.x > canvas.width){
            element.directionX = - element.directionX;
        }
        if(element.y < 0 || element.y > canvas.height) {
            element.directionY = - element.directionY;
        }
        particles.forEach((aElement,index) => {
            distance = Math.sqrt( Math.pow(element.x - aElement.x,2) + Math.pow(element.y - aElement.y,2) );
            if(distance < maxDis) {
                ctx.beginPath();
                ctx.strokeStyle = "rgba(255,255,255," + (1 - distance / maxDis) + ")";
                ctx.moveTo(element.x,element.y);
                ctx.lineTo(aElement.x,aElement.y);
                ctx.lineWidth = 1;
                ctx.stroke();
            }
        })
    }); 
}

function handleMouse(){
    if(mouseX == -1 || mouseY == -1) return;
    particles.forEach((element,index) => {
        let distance = Math.sqrt( Math.pow(element.x - mouseX,2) + Math.pow(element.y - mouseY,2) );
        if(distance < maxMouseDis) {
            ctx.beginPath();
            ctx.strokeStyle = "rgba(255,255,255," + (1 - distance / maxMouseDis) + ")";
            ctx.moveTo(element.x,element.y);
            ctx.lineTo(mouseX,mouseY);
            ctx.lineWidth = 1;
            ctx.stroke();
            
            let velocity = Math.sqrt( Math.pow(element.directionX,2) + Math.pow(element.directionY,2) );
            if(distance > maxMouseDis / 3 * 2) {
                element.directionX = - Math.min(Math.max(mouseVelocity,velocity),maxVelocity) * (element.x - mouseX) / distance * 1;
                element.directionY = - Math.min(Math.max(mouseVelocity,velocity),maxVelocity) * (element.y - mouseY) / distance * 1;
            }
        }
    })
}

function draw(){
    ctx.clearRect(0,0,canvas.width,canvas.height);
    if(particles.length < count) {
        createParticle();
    }
    handleParticle();
    handleMouse();
}

setInterval(draw,10);

canvas.addEventListener("mousemove",function(e) {
    mouseX = e.clientX;
    mouseY = e.clientY;
    if(lastMouseX == -1 || lastMouseY == -1) {
        mouseVelocity = 0;
    } else {
        mouseVelocity = Math.sqrt( Math.pow(lastMouseX - mouseX,2) + Math.pow(lastMouseY - mouseY,2) )
    }
    lastMouseX = mouseX;
    lastMouseY = mouseY;
})

canvas.addEventListener("mouseout",function(){
    mouseX = -1;
    mouseY = -1;
})

四、简单总结

这次,我们实现了这个粒子背景效果。也了解了Canvas的强大之处。
利用Canvas实现连续动画往往参考以下思路:
使用一个计时器,每刻完成对应的清除画布更新元素状态重绘工作。通过这样的反复计算渲染,即可实现连续的Canvas动效。
相信通过这一期的教学,你已经掌握了Canvas的大致用法,建议马上趁热打铁去自己动手试一试!
这个系列将持续更新下去。下一期我将继续借助实战案例与你一起研究Canvas前端特效,每周六准时更新,欢迎关注本专栏!

  • 12
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 10
    评论
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

没头发的米糊

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值