知乎粒子束的实现

最近上手了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版本的语法一起看。

谢谢大家。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,568评论 25 707
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,100评论 18 139
  • 《校园这个江湖》 深沉的不一定被珍惜如万古 轻快的不一定如阳光耀及暮 如花的不一定让容颜逆年驻 心机者不一定总暗无...
    天镜泊兴阅读 155评论 2 5
  • 大家好,我是刚从医院回来的蛋蛋~ 就在一个小时之前,我经历了人生中的第一次磁共振检查。这对想象力丰富的我来说是一次...
    Anciens安森阅读 51,253评论 3 4
  • 《读了多少本书,我推荐多少本》,听说这是一个套路。我以前之所以没有用,那是因为前三个月的每一个月,我还读不上10本...
    安之腾阅读 3,377评论 40 64