制作一個(gè)炫酷的多小球碰碰的 JS 網(wǎng)頁特效,入門彈性碰撞模擬和類的應(yīng)用
前言
在前端開發(fā)里,canvas 是 HTML5 里最炫酷的工具。我們今天就來搞一個(gè)這樣的夢幻的效果,學(xué)習(xí)一下 ES6 的類在開發(fā)一個(gè)完整項(xiàng)目的思路(即 ES5 的構(gòu)造函數(shù)),還有物理碰撞的程序的實(shí)現(xiàn),當(dāng)然,效果也很酷炫!
先畫一個(gè)圓
使用“類”這種被廣泛應(yīng)用的面向?qū)ο蟮母拍睿覀兛梢愿玫恼砦覀兊拇a,做出更大的項(xiàng)目。
所以我們先創(chuàng)建一個(gè) <canvas> 畫板的類 class Canvas { } ,以便抽象我們之后對 <canvas> 的操作。
然后再向類里添加第一個(gè)方法 drawCircle() ,作為我們的測試吧,就是先畫一個(gè)最簡單的元素 --- 圓!
完整代碼如下 (可以在 這個(gè)編輯器 進(jìn)行簡單調(diào)試):
<body></body>
<script>
class Canvas {
constructor(parent = document.body, width = 400, height = 400){
this.canvas = document.createElement('canvas');
this.canvas.width = width; // canvas 的高
this.canvas.height = height; // canvas 的高
parent.appendChild(this.canvas); // 向 DOM 中添加 canvas
this.ctx = this.canvas.getContext('2d'); // 畫筆
}
drawCircle(actor){ // 畫一個(gè)圓
this.ctx.strokeStyle = 'black';
this.ctx.strokeRect(0, 0, this.canvas.width, this.canvas.height); // 畫出邊框
this.ctx.beginPath();
this.ctx.arc(actor.position.x, actor.position.y, actor.radius, 0, Math.PI * 2);
this.ctx.closePath();
this.ctx.fillStyle = actor.color;
this.ctx.fill();
}
}
// ------------ 測試
const draw = new Canvas(); // 聲明一個(gè)畫布類
const ball = { // 定義一個(gè) 圓
position : {
x : 100,
y : 100,
},
radius : 25,
color : 'blue',
};
draw.drawCircle(ball); // 繪制
</script>
在代碼里,我們定義了一個(gè)圓的屬性,即 位置 x y 和半徑 、 顏色。通過這種井井有條又優(yōu)雅的方式,我們的目的就達(dá)到了!

這就是一切的基礎(chǔ),一切從這里開始。
完善我們的類
我們直接使用 ball 顯然是不夠的,小球它們要有自己的思想,我們的 Canvas 類要只負(fù)責(zé)繪制,所以我們需要重新開辟一個(gè)類,叫 Ball 類,來處理它們自己的“思想”。
而 canvas 類也需要更多的可擴(kuò)展性,今天我們是畫圓,明天我們想畫圈、方塊,我們也要考慮到,所以現(xiàn)在,我們要完善一下。
完整代碼如下,這樣就完美了 ~
<body></body>
<script>
class Canvas{
constructor(parent = document.body, width = 400, height = 400){
this.canvas = document.createElement('canvas');
this.canvas.width = width; // canvas 的高
this.canvas.height = height; // canvas 的高
parent.appendChild(this.canvas); // 向 DOM 中添加 canvas
this.ctx = this.canvas.getContext('2d'); // 畫筆
}
drawCircle(actor){ // 畫一個(gè)圓
this.ctx.strokeStyle = 'black';
this.ctx.strokeRect(0, 0, this.canvas.width, this.canvas.height); // 畫出邊框
this.ctx.beginPath();
this.ctx.arc(actor.position.x, actor.position.y, actor.radius, 0, Math.PI * 2);
this.ctx.closePath();
this.ctx.fillStyle = actor.color;
this.ctx.fill();
}
drawActor(actors){ // 畫角色,可選擇畫圓等等
for (const actor of actors) {
if(actor.type === 'circle'){
this.drawCircle(actor);
}
}
}
}
class Ball{
constructor(config){
Object.assign(this,{ // 類 自身的屬性,在這里定義
type : 'circle',
position : {x : 100, y : 100},
color : 'blue',
radius : 25,
},config);
}
}
// ---------- 測試
const draw = new Canvas();
const ball = new Ball();
draw.drawCircle(ball);
</script>
圖像能畫出來,那么下一步就是運(yùn)動了。這個(gè)要復(fù)雜了,一下子想不到要怎么弄,所以要一步一步來。
小球動起來
我們想一下,小球動起來,必定需要把畫板清空,然后更改位置、繪制,再清空,再更改位置、繪制... 一幀一幀來。
所以,
- 畫板需要有一個(gè)方法,清空畫板 方法
- 計(jì)算小球下一幀的位置
- 再封裝一個(gè) 【一鍵更新數(shù)據(jù)】,用于操作更新數(shù)據(jù)的邏輯,以及記錄和返回計(jì)算的結(jié)果(表示當(dāng)前一幀整個(gè)游戲的宏觀狀態(tài))
(第三點(diǎn)的這種思想,可以看這個(gè)文章)
先實(shí)現(xiàn)第一條,這個(gè)很好搞,canvas 只需要使用白色畫筆,畫一個(gè)覆蓋全畫板的矩形即可:
(不過,我們可以不使用純白,使用 0.4 的透明度,可以一點(diǎn)一點(diǎn)將上一幀給緩緩刷白,效果很好!)
clearDisplay(){ // 清空畫布
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'; // 這個(gè)透明度 0.4 是精華,繪制軌跡效果的關(guān)鍵
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
然后是第二條。
小球如果要運(yùn)動,必然需要知道要往哪里運(yùn)動。現(xiàn)在我們引入物理的概念 --- 速度(velocity),這是一個(gè)向量值。
而下一幀要去的地方,就是當(dāng)前的位置,加上當(dāng)前的速度向量。比如速度是向右 5m/s,那下一秒的位置就是當(dāng)前位置加上向右 5 米。
這是屬于球的個(gè)人的“思想”,所以我們寫到 Ball 類里面,同時(shí) 球 也要加上 速度 這個(gè)屬性,位置和速度都是向量,都是 x y。
(當(dāng)然,向量又是一個(gè)復(fù)雜的個(gè)體,所以我們需要再單獨(dú)開辟一個(gè)向量類 Vector )
// 球類
class Ball {
constructor(config){
Object.assign(this,{
type : 'circle',
position : new Vector(100, 100), // 位置也是向量
velocity : new Vector(5, 3), // 當(dāng)前的速度
color : 'blue',
radius : 25,
},config);
}
nextFrameUpdate(){ // 計(jì)算下一幀,小球的位置
return new Ball({
...this, // 其他屬性保持不變
position: this.position.add(this.velocity), // 所謂的計(jì)算,其實(shí)就是根據(jù)向量 +1
});
}
}
在 canvas 里,x 和 y 的兩個(gè)正方向如圖所示,所以當(dāng)前小球的速度是向右下:

下面就是我們當(dāng)前的向量類 Vector :
// 向量(可作為位置 和 速度)
class Vector {
constructor(x, y) {
this.x = x;
this.y = y;
}
add(vector) { // 兩個(gè)向量相加,就是這樣
return new Vector(this.x + vector.x, this.y + vector.y);
}
}
然后,就是使用 js 里用爛了的 requestAnimationFrame 讓這個(gè)畫面一幀一幀動起來,它是根據(jù)瀏覽器的性能實(shí)時(shí)智能控制幀率的,一般是 100幀/s 左右。不熟悉的同學(xué)可以看這個(gè) MDN 的介紹 。

完整的代碼如下:
<body></body>
<script>
// 畫圖 類
class Canvas {
constructor(parent = document.body, width = 400, height = 400){
this.canvas = document.createElement('canvas');
this.canvas.width = width;
this.canvas.height = height;
parent.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
}
sync(state){ // 執(zhí)行下一幀的繪圖(或稱 在畫板上同步已經(jīng)計(jì)算好的下一幀的數(shù)據(jù))
this.clearDisplay();
this.drawActor(state.actors);
}
clearDisplay(){ // 清空畫布
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
drawActor(actors){ // 畫一個(gè)角色,比如畫一個(gè)圓
for (const actor of actors) {
if(actor.type === 'circle'){
this.drawCircle(actor);
}
}
}
drawCircle(actor){ // 畫一個(gè)圓
this.ctx.strokeStyle = 'black';
this.ctx.strokeRect(0, 0, this.canvas.width, this.canvas.height); // 畫出邊框
this.ctx.beginPath();
this.ctx.arc(actor.position.x, actor.position.y, actor.radius, 0, Math.PI * 2);
this.ctx.closePath();
this.ctx.fillStyle = actor.color;
this.ctx.fill();
}
}
// 球類
class Ball {
constructor(config){
Object.assign(this,{
type : 'circle',
position : new Vector(100, 100),
velocity : new Vector(5, 3),
color : 'blue',
radius : 25,
},config);
}
nextFrameUpdate(){ // 計(jì)算下一幀,小球的位置
return new Ball({
...this, // 其他屬性保持不變,ES6 的寫法
position: this.position.add(this.velocity), // 所謂的計(jì)算,其實(shí)就是根據(jù)向量 +1
});
}
}
// 向量(可作為位置 和 速度)
class Vector {
constructor(x, y) {
this.x = x;
this.y = y;
}
/* 向量的各種運(yùn)算 */
add(vector) { // 加
return new Vector(this.x + vector.x, this.y + vector.y);
}
}
// 宏觀狀態(tài),可理解為【一鍵更新數(shù)據(jù)】
class DisplayState {
constructor(displayEle, actors) {
this.displayEle = displayEle;
this.actors = actors;
}
update() {
const new_actors = this.actors.map(actor => { // 獲取下一幀的位置數(shù)據(jù)
return actor.nextFrameUpdate();
});
return new DisplayState(this.displayEle, new_actors); // 把 DisplayState 類的屬性更新后,把最新數(shù)據(jù)再返回
}
}
// ---------- 測試
const displayEle = new Canvas();
const ball = new Ball();
const actors = [ball]; // 我們可能會繪制很多球
let displayState = new DisplayState(displayEle, actors);
function myAnimation(){
displayState = displayState.update(); // 一鍵更新數(shù)據(jù)
displayEle.sync(displayState); // 根據(jù)更新的數(shù)據(jù)來繪畫
requestAnimationFrame(myAnimation)
}
myAnimation();
</script>
最簡單的碰撞計(jì)算,接觸墻壁反彈
這個(gè),還幾乎用不到物理碰撞算法之類。其實(shí)實(shí)現(xiàn)這個(gè)功能特別簡單,只需要檢測到小球到達(dá)墻壁邊界,然后相應(yīng)的速度正負(fù)轉(zhuǎn)化一下即可!
代碼很簡單,很易懂,將 Ball 類里的 nextFrameUpdate 計(jì)算下一幀位置 的這個(gè)方法添加兩個(gè)判斷即可:
nextFrameUpdate(displayState){ // 計(jì)算下一幀,小球的位置
// 如果小球左右到達(dá)邊界,X 速度取反
if (this.position.x >= displayState.displayEle.canvas.width - this.radius || this.position.x <= this.radius) {
this.velocity = new Vector(-this.velocity.x, this.velocity.y);
}
// 如果小球上下到達(dá)邊界,Y 速度取反
if (this.position.y >= displayState.displayEle.canvas.height - this.radius || this.position.y <= this.radius) {
this.velocity = new Vector(this.velocity.x, -this.velocity.y);
}
return new Ball({
...this, // 其他屬性保持不變
position: this.position.add(this.velocity),
});
}
注意,判斷依據(jù)一定是小球的邊界,和墻壁的邊界,而不是小球的中心。這里就不貼出完整代碼了,完善向量后再貼!我們接下來要根據(jù)物理公式計(jì)算兩個(gè)小球之間的碰撞,因此我們需要將向量類 Vector 完善一下。
向量類的完善
向量是我們中學(xué)的學(xué)習(xí)內(nèi)容,向量有哪些計(jì)算呢?
加減乘除?
加減好說,每個(gè)元素分別加減即可。有乘法,但沒有除法。還有取模和角度。
乘法有兩種,一種是常數(shù)與之乘法,每個(gè)元素都乘以相同的常數(shù):
multiply(scalar) { // 逐元素乘法
return new Vector(this.x * scalar, this.y * scalar);
}
另一種,是向量之間的相乘,我們稱其為點(diǎn)積或數(shù)量積:
dotProduct(vector) { // 數(shù)量積
return this.x * vector.x + this.y * vector.y;
}
除了加減乘除,還有取模和取角度,模就是向量的長度(用于計(jì)算兩個(gè)小球之間的距離),角度就是向量的 arctan 值(反正切值)。
怎么取模呢?
根據(jù)勾股定理,根號下 x 的平方 加 y 的平方。
get magnitude() { // 求模
return Math.sqrt(this.x ** 2 + this.y ** 2);
}
角度就使用反正切將 x y 搞一下就好:
get direction() { // 求方向的角度 tan
return Math.atan2(this.x, this.y);
}
完整的代碼如下:
<body></body>
<script>
class Canvas {
constructor(parent = document.body, width = 400, height = 400){
this.canvas = document.createElement('canvas');
this.canvas.width = width;
this.canvas.height = height;
parent.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
}
sync(state){ // 執(zhí)行下一幀的繪圖(或稱 在畫板上同步已經(jīng)計(jì)算好的下一幀的數(shù)據(jù))
this.clearDisplay();
this.drawActor(state.actors);
}
clearDisplay(){
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
drawActor(actors){ // 畫一個(gè)角色,比如畫一個(gè)圓
for (const actor of actors) {
if(actor.type === 'circle'){
this.drawCircle(actor);
}
}
}
drawCircle(actor){ // 畫一個(gè)圓
this.ctx.strokeStyle = 'black';
this.ctx.strokeRect(0, 0, this.canvas.width, this.canvas.height); // 畫出邊框
this.ctx.beginPath();
this.ctx.arc(actor.position.x, actor.position.y, actor.radius, 0, Math.PI * 2);
this.ctx.closePath();
this.ctx.fillStyle = actor.color;
this.ctx.fill();
}
}
// 球類
class Ball {
constructor(config){
Object.assign(this,{
type : 'circle',
position : new Vector(100, 100),
velocity : new Vector(5, 3),
color : 'blue',
radius : 25,
},config);
}
nextFrameUpdate(displayState){ // 計(jì)算下一幀,小球的位置
// 如果小球左右到達(dá)邊界,X 速度取反
if (this.position.x >= displayState.displayEle.canvas.width - this.radius || this.position.x <= this.radius) {
this.velocity = new Vector(-this.velocity.x, this.velocity.y);
}
// 如果小球上下到達(dá)邊界,Y 速度取反
if (this.position.y >= displayState.displayEle.canvas.height - this.radius || this.position.y <= this.radius) {
this.velocity = new Vector(this.velocity.x, -this.velocity.y);
}
return new Ball({
...this, // 其他屬性保持不變
position: this.position.add(this.velocity),
});
}
}
// 向量(可作為位置 和 速度)
class Vector {
constructor(x, y) {
this.x = x;
this.y = y;
}
/* 向量的各種運(yùn)算 */
add(vector) { // 加
return new Vector(this.x + vector.x, this.y + vector.y);
}
subtract(vector) { // 減
return new Vector(this.x - vector.x, this.y - vector.y);
}
multiply(scalar) { // 逐元素乘法
return new Vector(this.x * scalar, this.y * scalar);
}
dotProduct(vector) { // 數(shù)量積
return this.x * vector.x + this.y * vector.y;
}
get magnitude() { // 求模
return Math.sqrt(this.x ** 2 + this.y ** 2);
}
get direction() { // 求方向的角度 tan
return Math.atan2(this.x, this.y);
}
}
// 宏觀狀態(tài)
class DisplayState {
constructor(displayEle, actors) {
this.displayEle = displayEle;
this.actors = actors;
}
update() {
const new_actors = this.actors.map(actor => {
return actor.nextFrameUpdate(this);
});
return new DisplayState(this.displayEle, new_actors);
}
}
// ---------- 測試
const displayEle = new Canvas();
const ball = new Ball();
const actors = [ball];
let displayState = new DisplayState(displayEle, actors);
function myAnimation(){
displayState = displayState.update(); // 數(shù)據(jù)更新
displayEle.sync(displayState); // 根據(jù)更新的數(shù)據(jù)來繪畫
requestAnimationFrame(myAnimation)
}
myAnimation();
</script>
檢測兩小球之間的碰撞
我們要先定義兩個(gè)小球,大綠球、小藍(lán)球,我們的實(shí)驗(yàn)就是根據(jù)這倆球來進(jìn)行:
const ball1 = new Ball({ // 小球一
position: new Vector(40, 100),
velocity: new Vector(1, 0),
radius: 20,
color: 'green',
});
const ball2 = new Ball({ // 小球二
position: new Vector(200, 100),
velocity: new Vector(-1, 0),
color: 'blue',
});
const actors = [ball1, ball2];
然后,我們在計(jì)算下一幀的那個(gè) nextFrameUpdata() 方法里,添加這樣一個(gè)邏輯。每次都計(jì)算所有其他的小球與自己的距離,以判斷是否碰到。
for (const actor of displayState.actors) { // 把其他球都計(jì)算一次
if (this === actor) { // 無需計(jì)算自己
continue;
}
const distance = this.position.subtract(actor.position).magnitude; // 計(jì)算倆球的距離
if (distance <= this.radius + actor.radius) { // 如果倆球距離小于兩球半徑,就都變灰
this.color = 'grey';
actor.color = 'grey';
}
}
這樣效果就出來了。

完善碰撞的效果
我們現(xiàn)在需要完善這個(gè)碰撞的效果。變色,表示我們已經(jīng)能檢測到兩個(gè)球是否碰到了,但沒有視覺效果。
碰撞的效果看起來很簡單,一瞬間的事,但實(shí)現(xiàn)起來并不簡單。
能量既不會憑空產(chǎn)生,也不會憑空消失,它只能從一種形式轉(zhuǎn)化為另一種形式,或者從一個(gè)物體轉(zhuǎn)移到另一個(gè)物體,總量保持不變。 ----- 能量守恒定理
首先,我們在物理里學(xué)過《能量守恒定理》,m 是質(zhì)量,v 是速度。
$$m_{A} v_{A 1}+m_{B} v_{B 1}=m_{A} v_{A 2}+m_{B} v_{B 2}$$
以及《動能守恒定理》
$$\frac{1}{2} m_{A} v_{A 1}^{2}+\frac{1}{2} m_{B} v_{B 1}^{2}=\frac{1}{2} m_{A} v_{A 2}^{2}+\frac{1}{2} m_{B} v_{B 2}^{2}$$
那么它們的碰撞后的速度變化呢?

維基百科:彈性碰撞 里給出了上面這個(gè)可視化的圖,幫助我們理解速度交互和向量的關(guān)系。
在根據(jù)上面兩個(gè)公式的基礎(chǔ)上,加入了我們的速度向量,進(jìn)行了很多行的復(fù)雜繁瑣的推導(dǎo),我們得出了碰撞后兩個(gè)小球的最終速度(僅在二維空間有效):
$$\begin{array}{l} \mathrm{v}_{1}^{\prime}=\mathrm{v}_{1}-\frac{2 m_{2}}{m_{1}+m_{2}} \frac{\left\langle\mathrm{v}_{1}-\mathrm{v}_{2}, \mathrm{x}_{1}-\mathrm{x}_{2}\right\rangle}{\left\|\mathrm{x}_{1}-\mathrm{x}_{2}\right\|^{2}}\left(\mathrm{x}_{1}-\mathrm{x}_{2}\right)\\ \end{array}$$
$$\begin{array}{l} \mathrm{v}_{2}^{\prime}=\mathrm{v}_{2}-\frac{2 m_{1}}{m_{1}+m_{2}} \frac{\left\langle\mathrm{v}_{2}-\mathrm{v}_{1}, \mathrm{x}_{2}-\mathrm{x}_{1}\right\rangle}{\left\|\mathrm{x}_{2}-\mathrm{x}_{1}\right\|^{2}}\left(\mathrm{x}_{2}-\mathrm{x}_{1}\right) \end{array}$$
在上面的公式中,雙豎線代表向量的模(長度);尖括號表示向量間的點(diǎn)積; X 是位置向量 \(\vec{v}\) ,里面包含了 x y 軸。
現(xiàn)在我們的小球還沒有質(zhì)量 M 這個(gè)概念。假設(shè)球的密度穩(wěn)定,我們可以抽象成小球的面積,注意是表面積。表面積的計(jì)算公式為 \(S = 4\pi r^2\) ,在我們 Ball 里搞出這樣一個(gè)方法,來表示球的表面積屬性 :
get sphereArea(){ return 4 * Math.PI * this.radius ** 2; } // 計(jì)算球表面積(利用球面積,來表示小球的質(zhì)量)
注意,這里使用了 get 這個(gè)關(guān)鍵字。get 會將返回值變?yōu)橐粋€(gè)屬性,而不加 get 則會以方法的形式來表現(xiàn)。什么意思呢?看一下對比圖:
// 調(diào)用區(qū)別
ball.sphereArea // 使用 get 關(guān)鍵字
ball.sphereArea() // 不使用 get 關(guān)鍵字
很顯然,使用 get 關(guān)鍵字更切合我們的使用邏輯。
然后我們要將其轉(zhuǎn)化為我們的程序。這個(gè)很頭疼,要根據(jù)我們實(shí)現(xiàn)的向量類 Vector 里的向量運(yùn)算方法,一點(diǎn)點(diǎn)復(fù)刻那一大串公式,這是我們復(fù)刻完的函數(shù):
// 碰撞后速度的計(jì)算函數(shù),參數(shù)為“自己”和“對方”,返回值為計(jì)算好的碰撞后“自己”的速度向量
const collisionVector = (particle1, particle2) => {
return particle1.velocity.subtract(particle1.position
.subtract(particle2.position).multiply(particle1.velocity.subtract(particle2.velocity)
.dotProduct(particle1.position.subtract(particle2.position))
/ particle1.position.subtract(particle2.position).magnitude ** 2)
.multiply((2 * particle2.sphereArea) / (particle1.sphereArea + particle2.sphereArea))
);
};
這一大坨很難看,完全沒有可讀性,但它很準(zhǔn)確。沒辦法,數(shù)學(xué)公式就是這樣。
重復(fù)計(jì)算的問題
很顯然,我們在里面的 for(){} 循環(huán)判斷碰撞時(shí),同一個(gè)碰撞事件會被計(jì)算兩次,所以我們需要為每個(gè)球再創(chuàng)建一個(gè) ID、一個(gè)碰撞數(shù)組,把有碰撞的球都放進(jìn)去,更新計(jì)算時(shí)跳過它。
- 在
Ball類里面為球球們添加兩個(gè)屬性,id和collisions:
Object.assign(this,{
id: Math.floor(Math.random() * 1000000), // 根據(jù)隨機(jī)數(shù)生成的 ID
type : 'circle',
position : new Vector(100, 100),
velocity : new Vector(5, 3),
color : 'blue',
radius : 25,
collisions: [], // 與之碰撞的小球們組成的數(shù)組
},config);
- 在 循環(huán)判斷碰撞 語句里,寫上下面的判斷語句:
// 如果對方小球的 `collisions` 里包含自己的 id,那就跳過 ~
if (this === actor || this.collisions.includes(actor.id + updateId)) { continue; }
- 記得在
DisplayState類里將上面這個(gè)概念傳入。這里不再演示。
撞擊墻壁定格問題
另外,如果球同時(shí)撞擊墻壁和另一個(gè)小球,會產(chǎn)生 卡 在墻上不再動的效果(因?yàn)橄乱粠挠?jì)算值超過了邊界),所以我們也要改良一下我們的墻壁碰撞函數(shù):
/* 碰到墻壁后,反彈 */
const upperLimit = new Vector(displayState.displayEle.canvas.width - this.radius, displayState.displayEle.canvas.height - this.radius); // canvas 的右下邊界
const lowerLimit = new Vector(0 + this.radius, 0 + this.radius); // canvas 的左上邊界
if (this.position.x >= upperLimit.x || this.position.x <= lowerLimit.x) {
this.velocity = new Vector(-this.velocity.x, this.velocity.y);
}
if (this.position.y >= upperLimit.y || this.position.y <= lowerLimit.y) {
this.velocity = new Vector(this.velocity.x, -this.velocity.y);
}
// 墻擠壓發(fā)生在球同時(shí)撞擊墻壁和另一個(gè)球時(shí),球可能會卡在墻上
// 下面這兩行,通過判斷,能確保球不會卡到墻壁外
// 確保下一幀,始終在墻內(nèi)(min 計(jì)算右邊界,max 計(jì)算左邊界)
const newX = Math.max(Math.min(this.position.x + this.velocity.x, upperLimit.x), lowerLimit.x);
const newY = Math.max(Math.min(this.position.y + this.velocity.y, upperLimit.y), lowerLimit.y);
return new Ball({ ...this, position: new Vector(newX, newY), }); // 最終生成下一幀數(shù)據(jù)
內(nèi)存問題
我們每次碰撞,都會跟蹤更新碰撞的數(shù)組,這會導(dǎo)致內(nèi)存增大,如果小球足夠多,則會很快將內(nèi)存耗盡,因此,我們要在適當(dāng)?shù)臅r(shí)候減少 collisions 數(shù)組的元素?cái)?shù)量。
在 nextFrameUpdate 的最開始,我們加上這樣一行代碼:
if (this.collisions.length > 10) {
this.collisions = this.collisions.slice(this.collisions.length - 3); // 刪除無用的 collisions,只保留最后三個(gè)
}
每當(dāng) collisions 元素的數(shù)量達(dá)到 10 個(gè)以上,就只保留最后三個(gè)元素。
這樣,我們就基本完成碰撞的檢測和碰撞的效果了 ~ 我們來實(shí)驗(yàn)一下效果吧!
完整代碼:
<body></body>
<script>
(function() {
const thisExampleParent = document.body;
class Canvas { // 類:畫圖
constructor(parent = thisExampleParent, width = 400, height = 400){
this.canvas = document.createElement('canvas');
this.canvas.width = width;
this.canvas.height = height;
parent.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
}
sync(state){ // 執(zhí)行下一幀的繪圖(或稱 在畫板上同步已經(jīng)計(jì)算好的下一幀的數(shù)據(jù))
this.clearDisplay();
this.drawActor(state.actors);
}
clearDisplay(){ // 清除畫板(以方便繪制下一幀)
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
drawActor(actors){ // 畫一個(gè)角色,比如畫一個(gè)圓
for (const actor of actors) { if(actor.type === 'circle'){ this.drawCircle(actor); } }
}
drawCircle(actor){ // 畫一個(gè)圓
this.ctx.strokeStyle = 'black';
this.ctx.strokeRect(0, 0, this.canvas.width, this.canvas.height); // 畫出邊框
this.ctx.beginPath();
this.ctx.arc(actor.position.x, actor.position.y, actor.radius, 0, Math.PI * 2);
this.ctx.closePath();
this.ctx.fillStyle = actor.color;
this.ctx.fill();
}
}
class Ball { // 類:球類
constructor(config){
Object.assign(this,{
id: Math.floor(Math.random() * 1000000), // 根據(jù)隨機(jī)數(shù)生成的 ID
type : 'circle',
position : new Vector(100, 100),
velocity : new Vector(5, 3),
color : 'blue',
radius : 25,
collisions: [], // 與之碰撞的小球們組成的數(shù)組
},config);
}
nextFrameUpdate(displayState, time, updateId){ // 計(jì)算下一幀,小球的位置
for (const actor of displayState.actors) {
if (this === actor || this.collisions.includes(actor.id + updateId)) { continue; }
const distanceNext = this.position.add(this.velocity).subtract(actor.position.add(actor.velocity)).magnitude;
if (distanceNext <= this.radius + actor.radius) {
const v1 = collisionVector(this, actor);
const v2 = collisionVector(actor, this);
this.velocity = v1; actor.velocity = v2;
this.collisions.push(actor.id + updateId);
actor.collisions.push(this.id + updateId);
}
}
/* 碰到墻壁后,反彈 */
const upperLimit = new Vector(displayState.displayEle.canvas.width - this.radius, displayState.displayEle.canvas.height - this.radius);
const lowerLimit = new Vector(0 + this.radius, 0 + this.radius);
if (this.position.x >= upperLimit.x || this.position.x <= lowerLimit.x) {
this.velocity = new Vector(-this.velocity.x, this.velocity.y);
}
if (this.position.y >= upperLimit.y || this.position.y <= lowerLimit.y) {
this.velocity = new Vector(this.velocity.x, -this.velocity.y);
}
// 墻擠壓發(fā)生在球同時(shí)撞擊墻壁和另一個(gè)球時(shí),球可能會卡在墻上
// 下面這兩行,通過判斷,能確保球不會卡到墻壁外
const newX = Math.max(Math.min(this.position.x + this.velocity.x, upperLimit.x), lowerLimit.x);
const newY = Math.max(Math.min(this.position.y + this.velocity.y, upperLimit.y), lowerLimit.y);
return new Ball({ ...this, position: new Vector(newX, newY), }); // 最終生成下一幀數(shù)據(jù)
}
get sphereArea(){ return 4 * Math.PI * this.radius ** 2; } // 計(jì)算球表面積(利用球面積,來表示小球的質(zhì)量)
}
class Vector { // 類:向量(可作為位置 和 速度)
constructor(x, y) { this.x = x; this.y = y; }
/* 向量的各種運(yùn)算 */
add(vector) { return new Vector(this.x + vector.x, this.y + vector.y); } // 加
subtract(vector) { return new Vector(this.x - vector.x, this.y - vector.y); } // 減
multiply(scalar) { return new Vector(this.x * scalar, this.y * scalar); } // 逐元素乘法
dotProduct(vector) { return this.x * vector.x + this.y * vector.y; } // 數(shù)量積
get magnitude() { return Math.sqrt(this.x ** 2 + this.y ** 2); } // 求模
get direction() { return Math.atan2(this.x, this.y); } // 求方向的角度 tan
}
class DisplayState { // 類:宏觀狀態(tài)
constructor(displayEle, actors) { this.displayEle = displayEle; this.actors = actors; }
update(time) {
const updateId = Math.floor(Math.random() * 1000000); // 小球的身份證號(而且還能改,盡量不重復(fù))
const new_actors = this.actors.map(actor => { return actor.nextFrameUpdate(this, time, updateId); });
return new DisplayState(this.displayEle, new_actors);
}
}
const collisionVector = (particle1, particle2) => {
return particle1.velocity.subtract(particle1.position
.subtract(particle2.position).multiply(particle1.velocity.subtract(particle2.velocity)
.dotProduct(particle1.position.subtract(particle2.position))
/ particle1.position.subtract(particle2.position).magnitude ** 2)
.multiply((2 * particle2.sphereArea) / (particle1.sphereArea + particle2.sphereArea))
);
};
// ---------- demo 測試
const displayEle = new Canvas();
const ball1 = new Ball({
position: new Vector(40, 100),
velocity: new Vector(2, 3),
radius: 20,
color: 'blue',
});
const ball2 = new Ball({
position: new Vector(200, 100),
velocity: new Vector(-1, 3),
color: 'red',
});
const actors = [ball1, ball2];
let displayState = new DisplayState(displayEle, actors);
function myAnimation(time){ // 注意,這里的 time 是requestAnimationFrame回調(diào),可直接使用,是 秒
displayState = displayState.update(); // 數(shù)據(jù)更新
displayEle.sync(displayState); // 根據(jù)更新的數(shù)據(jù)來繪畫
requestAnimationFrame(myAnimation);
}
myAnimation();
})();
</script>
隨機(jī)數(shù)生成多個(gè)小球
現(xiàn)在,我們就可以寫一個(gè)循環(huán)和隨機(jī)數(shù)結(jié)合的腳本,生成一大堆個(gè)小球,像開頭的那個(gè)動畫一樣的效果了。
const displayEle = new Canvas();
// 生成某個(gè)范圍內(nèi)的隨機(jī)數(shù)
const random = (max = 9, min = 0) => { return Math.floor(Math.random() * (max - min + 1) + min); };
const colors = ['red', 'green', 'blue', 'purple', 'orange']; // 可供隨機(jī)挑選的顏色
const balls = [];
const count = 30; // 球的數(shù)量
for (let i = 0; i < count; i++) {
balls.push(new Ball({
radius: random(8, 3) + Math.random(),
color: colors[random(colors.length - 1)],
position: new Vector(random(400 - 10, 10), random(400 - 10, 10)),
velocity: new Vector(random(3, -3), random(3, -3)),
}));
}
let displayState = new DisplayState(displayEle, balls);
function myAnimation(time){
displayState = displayState.update(); // 數(shù)據(jù)更新
displayEle.sync(displayState); // 根據(jù)更新的數(shù)據(jù)來繪畫
requestAnimationFrame(myAnimation);
}
myAnimation();
最后的效果如下面這個(gè)頁內(nèi)框架所示:

浙公網(wǎng)安備 33010602011771號