最近上手了canvas,正好看见一个知乎粒子束的实现,觉得蛮有意思的,自己就照着做了一遍。原效果是用es6实现的,我这篇文章也就用es6的语法讲了,但是可能有些人对es6的语法不熟悉,我又用es5的语法写了一遍,一方面加深理解,一方面也可以练习一下es5继承的实现,这些都放在仓库里了,可以根据需要自己查看。

仓库地址
效果地址

整体框架

这个效果大体可以分为两个部分:

  1. 进入页面初始化粒子束
  2. 当鼠标进入页面,在当前坐标画一个圆,并和初始化的效果进行交互。

具体效果是:

  1. 在页面随机位置画圆
  2. 圆以一定的速度在页面移动
  3. 当两个圆靠近时,链接一条线

分析完需求之后,无论是初始化还是鼠标的交互,都离不开下面那三种具体的效果。唯一不同的地方在于,当鼠标进入页面的时候,圆圈产生的位置不是固定的,而是以鼠标的坐标为准,因此这个方法对于鼠标的行为来说是独立的。因此,最开始的结构就可以这样写:

class Circle{
// 父类

    // Circle的构造函数
    constructor() {}
    
    //以下是circle原型上的方法
    //方法1 画圆
    drawCircle(){}
    
    //方法2 移动
    move(){}
    
    //方法3 连线
    drawLine(){}

}

class currentCircle extends Circle{
// 鼠标的对象,也就是子类

    // 继承父类的构造函数的属性
    constructor(x, y) {}
    
    // 新增一个自己的方法
    // 当鼠标进入页面,在鼠标坐标画圆
    drawCircle(){}
}

具体实现

就这样,基本的结构就完成了,我们来具体看一下这个结构,在Circle(之后统称为父类),定义了一个构造函数,这里面都是canvas画图用到的相关属性,按照我们的需求,这里面需要有圆的x坐标,y坐标,圆的半径,圆每次移动的距离,那就可以这样写:

// 父类
constructor(x, y) {
    this.x = x;
    this.y = y;
    this.r = Math.random() * 10; //圆的半径
    this._mx = Math.random(); //圆在x轴上移动的距离
    this._my = Math.random(); //圆在y轴上移动的距离
}

这里面,之所以只有x,y需要以参数的形式定义,先猜猜为什么?

前面提到过,无论是初始化效果还是鼠标的交互,只有一个地方不一样,就是后者的鼠标坐标就是新产生的圆的坐标,而非随机的。currentCircle(之后统称为子类)继承了父类构造函数中的属性,所以只有以参数的形式传入才能灵活的选择是随机还是鼠标坐标定义圆的位置。如果现在不好理解的话,等文章结束,就会明白了。

完成属性之后,我们就来完善父类的方法。

无论是画圆还是说连线,都需要用到canvas,因此方法内部都要用到canvas的2D上下文对象,这个既可以用参数传入。

连线的方法,不仅要知道线的起始点在哪,还需要知道重点在哪,起始点很好确定,当前圆的中心点的坐标即可,终点则不好确定,因此我们可以把另一个圆作为参数传入,读取它的坐标,因此就是这样:

//父类
drawCircle(ctx) {
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2, false);
    ctx.closePath();
    ctx.fillStyle = 'rgba(204, 204, 204, 0.3)';
    ctx.fill();
}

drawLine(ctx, _circle) {
    // _circle就是需要产生连线的另一个圆
    let dx = this.x - _circle.x; // 两个圆心在x轴上的距离
    let dy = this.y - _circle.y; // 两个圆心在y轴上的距离
    let d = Math.sqrt(dx * dx + dy * dy) // 利用三角函数计算出两个圆心之间的距离
    if (d < 150) {
        ctx.beginPath();
        ctx.moveTo(this.x, this.y); // 线的起点
        ctx.lineTo(_circle.x, _circle.y); // 线的终点
        ctx.closePath();
        ctx.strokeStyle = 'rgba(204, 204, 204, 0.3)';
        ctx.stroke();
    }
}

之前我也说过,线的产生是在两个圆接近的地方产生,否则就不画线,因此需要判断距离,代码中的距离是150像素,这个根据需求可以随意改。

最后就是移动啦:-D

那首先,我们是不是得保证所有效果的实现都是在canvas里面,不允许有超出的现象发生,如果碰到边界了,应该返回去。氮素每个人的电脑屏幕又不一样大,因此这个大小就不能是固定的,因此就只能写成参数的形式了。

//父类
move(w, h) {
    this._mx = (this.x < w && this.x > 0) ? this._mx : (-this._mx);
    this._my = (this.y < h && this.y > 0) ? this._my : (-this._my);
    this.x += this._mx / 2; // (this._mx / 2)越大,移动越快,下同
    this.y += this._my / 2;       
}

这里面,w和h分别代表画布的宽和高,我具体想说一下里面对距离的判断。

根据写法可以看出来,会先判断这个圆的x坐标和y坐标是不是在画布内。
如果是,就给一个正值。
如果不是,就给一个负值。

但我也在担心,如果圆一开始就向左边或者上面移动,那不就移动的距离变负值,飘出页面了么?不知道有没有人看出来我这个想法有多蠢。

首先,无论是初始化的效果,亦或是鼠标交互产生的圆,能确定的是他们一定在画布的范围内。所以一开始对于移动距离的判断就肯定是正值,这样的话,圆的移动方向就是向右或者向下这个范围里的一个方向所以他们的结果就是一定会先碰到右边和下边的边界,此时,距离为负值,向相反的方向移动,下次再碰到左边和上边的边界时,距离为正值,在向相反的方向运动,不断循环。因此效果根本不会跑出圈外。

至此,父类的内容就写完了,相比,子类其实就很简单了,一个是继承属性,一个是修改方法。

// 子类

constructor(x, y) {
    super(x, y)
}

drwaCircle(ctx) {
    ctx.beginPath();
    this.r = 8
    ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2, false)
    ctx.fillStyle = 'rgba(255, 77, 54, 0.6)'
    ctx.fill();
}

子类的drwaCircle方法和父类的drwaCircle方法不同的地方在于,前者的圆半径是固定的,如果说你希望半径随机,这个方法就不必改写,直接继承父类的就可以。

父类和子类的问题解决之后,我们来看一些公共的属性和方法。

let canvas = document.createElement('canvas')
document.body.appendChild(canvas)
let ctx = canvas.getContext('2d');
let w = canvas.width = canvas.offsetWidth;
let h = canvas.height = canvas.offsetHeight;
let circles = [];
let current_circle = new currentCircle(0, 0)

这里面我主要说一下这两句

let circles = [];
let current_circle = new currentCircle(0, 0)

circles从定义看就是一个空数组,那么它的意义是什么呢?

我们最初的目的就是在画布中画一个个的圆,并且这些圆都按照自己的方向移动,靠近还会连线,那这每一个圆就可以看做是一个对象,每一个对象都包含这个圆的x坐标,y左边,半径,移动的距离这些基本信息,然后基于这些信息画圆,移动,再和另一个圆交互划线。

因此这个circles就是储存了页面中所有圆圈对象的一个集合。那肯定我们得先创建这么一个集合:

let init = (num)=>{
    for(let i =0;i<num;i++){
        circles.push(new Circle(Math.random()*w,Math.random()*h))
    }
}

num就是页面中圆的个数,也是circle的length。至于循环,就是按照你需要的个数创建父类的实例,每一个实例都有自己的各种属性,然后将他们添加到集合中。这样就完成了对数组的初始化。

再看后面那句。

这里创建了一个子类的实例,这个实例是用来进行鼠标交互的,这里创建实例的时候,传入的x和y都是0,这个很重要,后面再说为什么。

现在,我们初始化了所有的圆,实例化了鼠标的行为,创建好了画布,但只是这样,浏览器是不知道我们要干什么的,我们现在还需要一个方法告诉浏览器我们要做什么。

关于这个方法,我们得告诉浏览器,你需要按照我给定的数目画圆,每个圆按照一定的频率和距离移动,然后两个圆还得连线。现在数组已经有了,就这样写:

let draw = ()=>{
    for(let i=0;i<circle.length;i++){
        // 这里遍历了数组的每一个对象
        // 那这个对象先要用方法把自己按照自己的属性画出来
        // 再按照属性规定的方式移动
        circle[i].drwaCircle(ctx)
        circle[i].move(w,h)
        for(let j =i+1;j<circle.length;j++){
            // 之前说过,划线需要有一个起始点和一个终止点
            // 起始点很好解决,就是调用该方法的圆的坐标
            // 终止点就可以遍历数组中的其他对象,如果这个对象的距离小于我们规定的距离,划线成功,反之就不画线
            circle[i].drawLine(circle[j])
        }
    }
}

但是这样够么?我们这里只是告诉了浏览器一开始怎么做,但是没有告诉浏览器鼠标进入该怎么办。但是我们得先判断鼠标有没有进入页面,也就是有没有x值和y值产生。

记得之前在初始化鼠标实例的时候传入了两个0么,正好就可以借助这个判断一下:

let draw = ()=>{
    for(let i=0;i<circle.length;i++){
        // 这里遍历了数组的每一个对象
        // 那这个对象先要用方法把自己按照自己的属性画出来
        // 再按照属性规定的方式移动
        circle[i].drwaCircle(ctx)
        circle[i].move(w,h)
        for(let j =i+1;j<circle.length;j++){
            // 之前说过,划线需要有一个起始点和一个终止点
            // 起始点很好解决,就是调用该方法的圆的坐标
            // 终止点就可以遍历数组中的其他对象,如果这个对象的距离小于我们规定的距离,划线成功,反之就不画线
            circle[i].drawLine(ctx,circle[j])
        }
    }
    if(current_circle.x){
       current_circle.drawCircle(ctx) 
       for(let i=0;i<circle.length;i++){
            current_circle.drawLine(ctx,circle[i]) 
       }
    }
}

这样告诉浏览器该干什么就完成了,但是这个方法只会执行一遍,而我们需要的是动画效果,所以还需要一个计时器,这里推荐使用新的API:requestAnimationFrame

这个方法非常适用于动画效果,我们知道,计时器并不是那么完美,至少,他不一定会按照你给的时间间隔运行,而这个方法是按照屏幕的刷新频率运行的,因此动画效果更流畅。

酱紫,这个方法就写完了:

let draw = ()=>{
    for(let i=0;i<circle.length;i++){
        // 这里遍历了数组的每一个对象
        // 那这个对象先要用方法把自己按照自己的属性画出来
        // 再按照属性规定的方式移动
        circle[i].drwaCircle(ctx)
        circle[i].move(w,h)
        for(let j =i+1;j<circle.length;j++){
            // 之前说过,划线需要有一个起始点和一个终止点
            // 起始点很好解决,就是调用该方法的圆的坐标
            // 终止点就可以遍历数组中的其他对象,如果这个对象的距离小于我们规定的距离,划线成功,反之就不画线
            circle[i].drawLine(ctx,circle[j])
        }
    }
    if(current_circle.x){
       current_circle.drawCircle(ctx) 
       for(let i=0;i<circle.length;i++){
            current_circle.drawLine(ctx,circle[i]) 
       }
    }
    requestAnimationFrame(draw)
}

然后把这个方法写进初始化的方法里:

let init = (num)=>{
    for(let i =0;i<num;i++){
        circles.push(new Circle(Math.random()*w,Math.random()*h))
    }
}
draw();

之后再告诉浏览器什么时候进行初始化:

window.addEventListener('load', init(200));

window.onmousemove = function (e) {
    e = e || window.event;
    current_circle.x = e.clientX;
    current_circle.y = e.clientY;
}
window.onmouseout = function () {
    current_circle.x = null;
    current_circle.y = null;

};

然后监控鼠标何时进入页面,监测其坐标并把值附给鼠标实例。

酱紫,整个效果就完成了,因为代码是用es6语法写的,因此需要了解一些该语法的特性,如果实在看不明白,可以对照着es5版本的语法一起看。

谢谢大家。


Aria
962 声望49 粉丝