最近上手了canvas,正好看見一個(gè)知乎粒子束的實(shí)現(xiàn),覺得蠻有意思的,自己就照著做了一遍。原效果是用es6實(shí)現(xiàn)的,我這篇文章也就用es6的語法講了,但是可能有些人對es6的語法不熟悉,我又用es5的語法寫了一遍,一方面加深理解,一方面也可以練習(xí)一下es5繼承的實(shí)現(xiàn),這些都放在倉庫里了,可以根據(jù)需要自己查看。
整體框架
這個(gè)效果大體可以分為兩個(gè)部分:
- 進(jìn)入頁面初始化粒子束
- 當(dāng)鼠標(biāo)進(jìn)入頁面,在當(dāng)前坐標(biāo)畫一個(gè)圓,并和初始化的效果進(jìn)行交互。
具體效果是:
- 在頁面隨機(jī)位置畫圓
- 圓以一定的速度在頁面移動(dòng)
- 當(dāng)兩個(gè)圓靠近時(shí),鏈接一條線
分析完需求之后,無論是初始化還是鼠標(biāo)的交互,都離不開下面那三種具體的效果。唯一不同的地方在于,當(dāng)鼠標(biāo)進(jìn)入頁面的時(shí)候,圓圈產(chǎn)生的位置不是固定的,而是以鼠標(biāo)的坐標(biāo)為準(zhǔn),因此這個(gè)方法對于鼠標(biāo)的行為來說是獨(dú)立的。因此,最開始的結(jié)構(gòu)就可以這樣寫:
class Circle{
// 父類
// Circle的構(gòu)造函數(shù)
constructor() {}
//以下是circle原型上的方法
//方法1 畫圓
drawCircle(){}
//方法2 移動(dòng)
move(){}
//方法3 連線
drawLine(){}
}
class currentCircle extends Circle{
// 鼠標(biāo)的對象,也就是子類
// 繼承父類的構(gòu)造函數(shù)的屬性
constructor(x, y) {}
// 新增一個(gè)自己的方法
// 當(dāng)鼠標(biāo)進(jìn)入頁面,在鼠標(biāo)坐標(biāo)畫圓
drawCircle(){}
}
具體實(shí)現(xiàn)
就這樣,基本的結(jié)構(gòu)就完成了,我們來具體看一下這個(gè)結(jié)構(gòu),在Circle(之后統(tǒng)稱為父類),定義了一個(gè)構(gòu)造函數(shù),這里面都是canvas畫圖用到的相關(guān)屬性,按照我們的需求,這里面需要有圓的x坐標(biāo),y坐標(biāo),圓的半徑,圓每次移動(dòng)的距離,那就可以這樣寫:
// 父類
constructor(x, y) {
this.x = x;
this.y = y;
this.r = Math.random() * 10; //圓的半徑
this._mx = Math.random(); //圓在x軸上移動(dòng)的距離
this._my = Math.random(); //圓在y軸上移動(dòng)的距離
}
這里面,之所以只有x,y需要以參數(shù)的形式定義,先猜猜為什么?
前面提到過,無論是初始化效果還是鼠標(biāo)的交互,只有一個(gè)地方不一樣,就是后者的鼠標(biāo)坐標(biāo)就是新產(chǎn)生的圓的坐標(biāo),而非隨機(jī)的。currentCircle(之后統(tǒng)稱為子類)繼承了父類構(gòu)造函數(shù)中的屬性,所以只有以參數(shù)的形式傳入才能靈活的選擇是隨機(jī)還是鼠標(biāo)坐標(biāo)定義圓的位置。如果現(xiàn)在不好理解的話,等文章結(jié)束,就會明白了。
完成屬性之后,我們就來完善父類的方法。
無論是畫圓還是說連線,都需要用到canvas,因此方法內(nèi)部都要用到canvas的2D上下文對象,這個(gè)既可以用參數(shù)傳入。
連線的方法,不僅要知道線的起始點(diǎn)在哪,還需要知道重點(diǎn)在哪,起始點(diǎn)很好確定,當(dāng)前圓的中心點(diǎn)的坐標(biāo)即可,終點(diǎn)則不好確定,因此我們可以把另一個(gè)圓作為參數(shù)傳入,讀取它的坐標(biāo),因此就是這樣:
//父類
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就是需要產(chǎn)生連線的另一個(gè)圓
let dx = this.x - _circle.x; // 兩個(gè)圓心在x軸上的距離
let dy = this.y - _circle.y; // 兩個(gè)圓心在y軸上的距離
let d = Math.sqrt(dx * dx + dy * dy) // 利用三角函數(shù)計(jì)算出兩個(gè)圓心之間的距離
if (d < 150) {
ctx.beginPath();
ctx.moveTo(this.x, this.y); // 線的起點(diǎn)
ctx.lineTo(_circle.x, _circle.y); // 線的終點(diǎn)
ctx.closePath();
ctx.strokeStyle = 'rgba(204, 204, 204, 0.3)';
ctx.stroke();
}
}
之前我也說過,線的產(chǎn)生是在兩個(gè)圓接近的地方產(chǎn)生,否則就不畫線,因此需要判斷距離,代碼中的距離是150像素,這個(gè)根據(jù)需求可以隨意改。
最后就是移動(dòng)啦:-D
那首先,我們是不是得保證所有效果的實(shí)現(xiàn)都是在canvas里面,不允許有超出的現(xiàn)象發(fā)生,如果碰到邊界了,應(yīng)該返回去。氮素每個(gè)人的電腦屏幕又不一樣大,因此這個(gè)大小就不能是固定的,因此就只能寫成參數(shù)的形式了。
//父類
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)越大,移動(dòng)越快,下同
this.y += this._my / 2;
}
這里面,w和h分別代表畫布的寬和高,我具體想說一下里面對距離的判斷。
根據(jù)寫法可以看出來,會先判斷這個(gè)圓的x坐標(biāo)和y坐標(biāo)是不是在畫布內(nèi)。
如果是,就給一個(gè)正值。
如果不是,就給一個(gè)負(fù)值。
但我也在擔(dān)心,如果圓一開始就向左邊或者上面移動(dòng),那不就移動(dòng)的距離變負(fù)值,飄出頁面了么?不知道有沒有人看出來我這個(gè)想法有多蠢。
首先,無論是初始化的效果,亦或是鼠標(biāo)交互產(chǎn)生的圓,能確定的是他們一定在畫布的范圍內(nèi)。所以一開始對于移動(dòng)距離的判斷就肯定是正值,這樣的話,圓的移動(dòng)方向就是向右或者向下這個(gè)范圍里的一個(gè)方向所以他們的結(jié)果就是一定會先碰到右邊和下邊的邊界,此時(shí),距離為負(fù)值,向相反的方向移動(dòng),下次再碰到左邊和上邊的邊界時(shí),距離為正值,在向相反的方向運(yùn)動(dòng),不斷循環(huán)。因此效果根本不會跑出圈外。
至此,父類的內(nèi)容就寫完了,相比,子類其實(shí)就很簡單了,一個(gè)是繼承屬性,一個(gè)是修改方法。
// 子類
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方法不同的地方在于,前者的圓半徑是固定的,如果說你希望半徑隨機(jī),這個(gè)方法就不必改寫,直接繼承父類的就可以。
父類和子類的問題解決之后,我們來看一些公共的屬性和方法。
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從定義看就是一個(gè)空數(shù)組,那么它的意義是什么呢?
我們最初的目的就是在畫布中畫一個(gè)個(gè)的圓,并且這些圓都按照自己的方向移動(dòng),靠近還會連線,那這每一個(gè)圓就可以看做是一個(gè)對象,每一個(gè)對象都包含這個(gè)圓的x坐標(biāo),y左邊,半徑,移動(dòng)的距離這些基本信息,然后基于這些信息畫圓,移動(dòng),再和另一個(gè)圓交互劃線。
因此這個(gè)circles就是儲存了頁面中所有圓圈對象的一個(gè)集合。那肯定我們得先創(chuàng)建這么一個(gè)集合:
let init = (num)=>{
for(let i =0;i<num;i++){
circles.push(new Circle(Math.random()*w,Math.random()*h))
}
}
num就是頁面中圓的個(gè)數(shù),也是circle的length。至于循環(huán),就是按照你需要的個(gè)數(shù)創(chuàng)建父類的實(shí)例,每一個(gè)實(shí)例都有自己的各種屬性,然后將他們添加到集合中。這樣就完成了對數(shù)組的初始化。
再看后面那句。
這里創(chuàng)建了一個(gè)子類的實(shí)例,這個(gè)實(shí)例是用來進(jìn)行鼠標(biāo)交互的,這里創(chuàng)建實(shí)例的時(shí)候,傳入的x和y都是0,這個(gè)很重要,后面再說為什么。
現(xiàn)在,我們初始化了所有的圓,實(shí)例化了鼠標(biāo)的行為,創(chuàng)建好了畫布,但只是這樣,瀏覽器是不知道我們要干什么的,我們現(xiàn)在還需要一個(gè)方法告訴瀏覽器我們要做什么。
關(guān)于這個(gè)方法,我們得告訴瀏覽器,你需要按照我給定的數(shù)目畫圓,每個(gè)圓按照一定的頻率和距離移動(dòng),然后兩個(gè)圓還得連線?,F(xiàn)在數(shù)組已經(jīng)有了,就這樣寫:
let draw = ()=>{
for(let i=0;i<circle.length;i++){
// 這里遍歷了數(shù)組的每一個(gè)對象
// 那這個(gè)對象先要用方法把自己按照自己的屬性畫出來
// 再按照屬性規(guī)定的方式移動(dòng)
circle[i].drwaCircle(ctx)
circle[i].move(w,h)
for(let j =i+1;j<circle.length;j++){
// 之前說過,劃線需要有一個(gè)起始點(diǎn)和一個(gè)終止點(diǎn)
// 起始點(diǎn)很好解決,就是調(diào)用該方法的圓的坐標(biāo)
// 終止點(diǎn)就可以遍歷數(shù)組中的其他對象,如果這個(gè)對象的距離小于我們規(guī)定的距離,劃線成功,反之就不畫線
circle[i].drawLine(circle[j])
}
}
}
但是這樣夠么?我們這里只是告訴了瀏覽器一開始怎么做,但是沒有告訴瀏覽器鼠標(biāo)進(jìn)入該怎么辦。但是我們得先判斷鼠標(biāo)有沒有進(jìn)入頁面,也就是有沒有x值和y值產(chǎn)生。
記得之前在初始化鼠標(biāo)實(shí)例的時(shí)候傳入了兩個(gè)0么,正好就可以借助這個(gè)判斷一下:
let draw = ()=>{
for(let i=0;i<circle.length;i++){
// 這里遍歷了數(shù)組的每一個(gè)對象
// 那這個(gè)對象先要用方法把自己按照自己的屬性畫出來
// 再按照屬性規(guī)定的方式移動(dòng)
circle[i].drwaCircle(ctx)
circle[i].move(w,h)
for(let j =i+1;j<circle.length;j++){
// 之前說過,劃線需要有一個(gè)起始點(diǎn)和一個(gè)終止點(diǎn)
// 起始點(diǎn)很好解決,就是調(diào)用該方法的圓的坐標(biāo)
// 終止點(diǎn)就可以遍歷數(shù)組中的其他對象,如果這個(gè)對象的距離小于我們規(guī)定的距離,劃線成功,反之就不畫線
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])
}
}
}
這樣告訴瀏覽器該干什么就完成了,但是這個(gè)方法只會執(zhí)行一遍,而我們需要的是動(dòng)畫效果,所以還需要一個(gè)計(jì)時(shí)器,這里推薦使用新的API:requestAnimationFrame。
這個(gè)方法非常適用于動(dòng)畫效果,我們知道,計(jì)時(shí)器并不是那么完美,至少,他不一定會按照你給的時(shí)間間隔運(yùn)行,而這個(gè)方法是按照屏幕的刷新頻率運(yùn)行的,因此動(dòng)畫效果更流暢。
醬紫,這個(gè)方法就寫完了:
let draw = ()=>{
for(let i=0;i<circle.length;i++){
// 這里遍歷了數(shù)組的每一個(gè)對象
// 那這個(gè)對象先要用方法把自己按照自己的屬性畫出來
// 再按照屬性規(guī)定的方式移動(dòng)
circle[i].drwaCircle(ctx)
circle[i].move(w,h)
for(let j =i+1;j<circle.length;j++){
// 之前說過,劃線需要有一個(gè)起始點(diǎn)和一個(gè)終止點(diǎn)
// 起始點(diǎn)很好解決,就是調(diào)用該方法的圓的坐標(biāo)
// 終止點(diǎn)就可以遍歷數(shù)組中的其他對象,如果這個(gè)對象的距離小于我們規(guī)定的距離,劃線成功,反之就不畫線
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)
}
然后把這個(gè)方法寫進(jìn)初始化的方法里:
let init = (num)=>{
for(let i =0;i<num;i++){
circles.push(new Circle(Math.random()*w,Math.random()*h))
}
}
draw();
之后再告訴瀏覽器什么時(shí)候進(jìn)行初始化:
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;
};
然后監(jiān)控鼠標(biāo)何時(shí)進(jìn)入頁面,監(jiān)測其坐標(biāo)并把值附給鼠標(biāo)實(shí)例。
醬紫,整個(gè)效果就完成了,因?yàn)榇a是用es6語法寫的,因此需要了解一些該語法的特性,如果實(shí)在看不明白,可以對照著es5版本的語法一起看。
謝謝大家。