制作一个炫酷的多小球碰碰的 JS 网页特效,入门弹性碰撞模拟和类的应用

前言

在前端开发里,canvas 是 HTML5 里最炫酷的工具。我们今天就来搞一个这样的梦幻的效果,学习一下 ES6 的类在开发一个完整项目的思路(即 ES5 的构造函数),还有物理碰撞的程序的实现,当然,效果也很酷炫!

完整代码在此处

先画一个圆

使用“类”这种被广泛应用的面向对象的概念,我们可以更好的整理我们的代码,做出更大的项目。

所以我们先创建一个 <canvas> 画板的类 class Canvas { } ,以便抽象我们之后对 <canvas> 的操作。

然后再向类里添加第一个方法 drawCircle() ,作为我们的测试吧,就是先画一个最简单的元素 --- 圆!

完整代码如下 (可以在 这个编辑器 进行简单调试):

<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){  // 画一个圆             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();  // 声明一个画布类          const ball = {  // 定义一个 圆         position : {             x : 100,             y : 100,         },         radius : 25,         color : 'blue',     };      draw.drawCircle(ball);  // 绘制 </script> 

在代码里,我们定义了一个圆的属性,即 位置 x y 和半径 、 颜色。通过这种井井有条又优雅的方式,我们的目的就达到了!

制作一个炫酷的多小球碰碰的 JS 网页特效,入门弹性碰撞模拟和类的应用

这就是一切的基础,一切从这里开始。

完善我们的类

我们直接使用 ball 显然是不够的,小球它们要有自己的思想,我们的 Canvas 类要只负责绘制,所以我们需要重新开辟一个类,叫 Ball 类,来处理它们自己的“思想”。

canvas 类也需要更多的可扩展性,今天我们是画圆,明天我们想画圈、方块,我们也要考虑到,所以现在,我们要完善一下。

完整代码如下,这样就完美了 ~

<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){  // 画一个圆             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> 

图像能画出来,那么下一步就是运动了。这个要复杂了,一下子想不到要怎么弄,所以要一步一步来。

小球动起来

我们想一下,小球动起来,必定需要把画板清空,然后更改位置、绘制,再清空,再更改位置、绘制... 一帧一帧来。

所以,

  1. 画板需要有一个方法,清空画板 方法
  2. 计算小球下一帧的位置
  3. 再封装一个 【一键更新数据】,用于操作更新数据的逻辑,以及记录和返回计算的结果(表示当前一帧整个游戏的宏观状态)

(第三点的这种思想,可以看这个文章

先实现第一条,这个很好搞,canvas 只需要使用白色画笔,画一个覆盖全画板的矩形即可:

(不过,我们可以不使用纯白,使用 0.4 的透明度,可以一点一点将上一帧给缓缓刷白,效果很好!)

clearDisplay(){  // 清空画布 	this.ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';  // 这个透明度 0.4 是精华,绘制轨迹效果的关键 	this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); } 

然后是第二条。

小球如果要运动,必然需要知道要往哪里运动。现在我们引入物理的概念 --- 速度(velocity),这是一个向量值。

而下一帧要去的地方,就是当前的位置,加上当前的速度向量。比如速度是向右 5m/s,那下一秒的位置就是当前位置加上向右 5 米。

这是属于球的个人的“思想”,所以我们写到 Ball 类里面,同时 球 也要加上 速度 这个属性,位置和速度都是向量,都是 x y。

(当然,向量又是一个复杂的个体,所以我们需要再单独开辟一个向量类 Vector

// 球类 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(){  // 计算下一帧,小球的位置 		return new Ball({ 			...this,  // 其他属性保持不变 			position: this.position.add(this.velocity),  // 所谓的计算,其实就是根据向量 +1 		}); 	}  } 

canvas 里,x 和 y 的两个正方向如图所示,所以当前小球的速度是向右下:

制作一个炫酷的多小球碰碰的 JS 网页特效,入门弹性碰撞模拟和类的应用

下面就是我们当前的向量类 Vector

// 向量(可作为位置 和 速度) class Vector { 	constructor(x, y) { 		this.x = x; 		this.y = y; 	}  	add(vector) {  // 两个向量相加,就是这样 		return new Vector(this.x + vector.x, this.y + vector.y); 	} } 

然后,就是使用 js 里用烂了的 requestAnimationFrame 让这个画面一帧一帧动起来,它是根据浏览器的性能实时智能控制帧率的,一般是 100帧/s 左右。不熟悉的同学可以看这个 MDN 的介绍

制作一个炫酷的多小球碰碰的 JS 网页特效,入门弹性碰撞模拟和类的应用

完整的代码如下:

<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){  // 执行下一帧的绘图(或称 在画板上同步已经计算好的下一帧的数据)             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){  // 画一个角色,比如画一个圆             for (const actor of actors) {                 if(actor.type === 'circle'){                     this.drawCircle(actor);                 }             }         }          drawCircle(actor){  // 画一个圆             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(){  // 计算下一帧,小球的位置             return new Ball({                 ...this,  // 其他属性保持不变,ES6 的写法                 position: this.position.add(this.velocity),  // 所谓的计算,其实就是根据向量 +1             });         }      }      // 向量(可作为位置 和 速度)     class Vector {         constructor(x, y) {             this.x = x;             this.y = y;         }         /* 向量的各种运算 */         add(vector) {  // 加             return new Vector(this.x + vector.x, this.y + vector.y);         }     }      // 宏观状态,可理解为【一键更新数据】     class DisplayState {         constructor(displayEle, actors) {             this.displayEle = displayEle;             this.actors = actors;         }          update() {             const new_actors = this.actors.map(actor => {  // 获取下一帧的位置数据                 return actor.nextFrameUpdate();             });              return new DisplayState(this.displayEle, new_actors);  // 把 DisplayState 类的属性更新后,把最新数据再返回         }     }      // ---------- 测试      const displayEle = new Canvas();     const ball = new Ball();     const actors = [ball];  // 我们可能会绘制很多球      let displayState = new DisplayState(displayEle, actors);      function myAnimation(){         displayState = displayState.update();  // 一键更新数据         displayEle.sync(displayState);     // 根据更新的数据来绘画         requestAnimationFrame(myAnimation)     }      myAnimation(); </script> 

最简单的碰撞计算,接触墙壁反弹

这个,还几乎用不到物理碰撞算法之类。其实实现这个功能特别简单,只需要检测到小球到达墙壁边界,然后相应的速度正负转化一下即可!

代码很简单,很易懂,将 Ball 类里的 nextFrameUpdate 计算下一帧位置 的这个方法添加两个判断即可:

nextFrameUpdate(displayState){  // 计算下一帧,小球的位置  	// 如果小球左右到达边界,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); 	}  	// 如果小球上下到达边界,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), 	}); } 

注意,判断依据一定是小球的边界,和墙壁的边界,而不是小球的中心。这里就不贴出完整代码了,完善向量后再贴!我们接下来要根据物理公式计算两个小球之间的碰撞,因此我们需要将向量类 Vector 完善一下。

向量类的完善

向量是我们中学的学习内容,向量有哪些计算呢?

加减乘除?

加减好说,每个元素分别加减即可。有乘法,但没有除法。还有取模和角度。

乘法有两种,一种是常数与之乘法,每个元素都乘以相同的常数:

multiply(scalar) {  // 逐元素乘法 	return new Vector(this.x * scalar, this.y * scalar); } 

另一种,是向量之间的相乘,我们称其为点积或数量积:

dotProduct(vector) {  // 数量积 	return this.x * vector.x + this.y * vector.y; } 

除了加减乘除,还有取模和取角度,模就是向量的长度(用于计算两个小球之间的距离),角度就是向量的 arctan 值(反正切值)。

怎么取模呢?

根据勾股定理,根号下 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){  // 执行下一帧的绘图(或称 在画板上同步已经计算好的下一帧的数据)             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){  // 画一个角色,比如画一个圆             for (const actor of actors) {                 if(actor.type === 'circle'){                     this.drawCircle(actor);                 }             }         }          drawCircle(actor){  // 画一个圆             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){  // 计算下一帧,小球的位置              // 如果小球左右到达边界,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);             }              // 如果小球上下到达边界,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;         }          /* 向量的各种运算 */         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;         }          get magnitude() {  // 求模             return Math.sqrt(this.x ** 2 + this.y ** 2);         }          get direction() {  // 求方向的角度 tan             return Math.atan2(this.x, this.y);         }     }      // 宏观状态     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();  // 数据更新         displayEle.sync(displayState);     // 根据更新的数据来绘画          requestAnimationFrame(myAnimation)     }      myAnimation(); </script> 

检测两小球之间的碰撞

我们要先定义两个小球,大绿球、小蓝球,我们的实验就是根据这俩球来进行:

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]; 

然后,我们在计算下一帧的那个 nextFrameUpdata() 方法里,添加这样一个逻辑。每次都计算所有其他的小球与自己的距离,以判断是否碰到。

for (const actor of displayState.actors) {  // 把其他球都计算一次 	if (this === actor) {  // 无需计算自己 		continue; 	} 	const distance = this.position.subtract(actor.position).magnitude;  // 计算俩球的距离 	if (distance <= this.radius + actor.radius) {  // 如果俩球距离小于两球半径,就都变灰 		this.color = 'grey'; 		actor.color = 'grey'; 	} } 

这样效果就出来了。

制作一个炫酷的多小球碰碰的 JS 网页特效,入门弹性碰撞模拟和类的应用

完善碰撞的效果

我们现在需要完善这个碰撞的效果。变色,表示我们已经能检测到两个球是否碰到了,但没有视觉效果。

碰撞的效果看起来很简单,一瞬间的事,但实现起来并不简单。

能量既不会凭空产生,也不会凭空消失,它只能从一种形式转化为另一种形式,或者从一个物体转移到另一个物体,总量保持不变。 ----- 能量守恒定理

首先,我们在物理里学过《能量守恒定理》,m 是质量,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}$$

那么它们的碰撞后的速度变化呢?

制作一个炫酷的多小球碰碰的 JS 网页特效,入门弹性碰撞模拟和类的应用

维基百科:弹性碰撞 里给出了上面这个可视化的图,帮助我们理解速度交互和向量的关系。

在根据上面两个公式的基础上,加入了我们的速度向量,进行了很多行的复杂繁琐的推导,我们得出了碰撞后两个小球的最终速度(仅在二维空间有效):

$$begin{array}{l} mathrm{v}_{1}^{prime}=mathrm{v}_{1}-frac{2 m_{2}}{m_{1}+m_{2}} frac{leftlanglemathrm{v}_{1}-mathrm{v}_{2}, mathrm{x}_{1}-mathrm{x}_{2}rightrangle}{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{leftlanglemathrm{v}_{2}-mathrm{v}_{1}, mathrm{x}_{2}-mathrm{x}_{1}rightrangle}{left|mathrm{x}_{2}-mathrm{x}_{1}right|^{2}}left(mathrm{x}_{2}-mathrm{x}_{1}right) end{array}$$

在上面的公式中,双竖线代表向量的模(长度);尖括号表示向量间的点积; X 是位置向量 (vec{v}) ,里面包含了 x y 轴。

现在我们的小球还没有质量 M 这个概念。假设球的密度稳定,我们可以抽象成小球的面积,注意是表面积。表面积的计算公式为 (S = 4pi r^2) ,在我们 Ball 里搞出这样一个方法,来表示球的表面积属性 :

get sphereArea(){ return 4 * Math.PI * this.radius ** 2; }  // 计算球表面积(利用球面积,来表示小球的质量) 

注意,这里使用了 get 这个关键字。get 会将返回值变为一个属性,而不加 get 则会以方法的形式来表现。什么意思呢?看一下对比图:

// 调用区别 ball.sphereArea  // 使用 get 关键字 ball.sphereArea()  // 不使用 get 关键字 

很显然,使用 get 关键字更切合我们的使用逻辑。

然后我们要将其转化为我们的程序。这个很头疼,要根据我们实现的向量类 Vector 里的向量运算方法,一点点复刻那一大串公式,这是我们复刻完的函数:

// 碰撞后速度的计算函数,参数为“自己”和“对方”,返回值为计算好的碰撞后“自己”的速度向量 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)) 	); }; 

这一大坨很难看,完全没有可读性,但它很准确。没办法,数学公式就是这样。

重复计算的问题

很显然,我们在里面的 for(){} 循环判断碰撞时,同一个碰撞事件会被计算两次,所以我们需要为每个球再创建一个 ID、一个碰撞数组,把有碰撞的球都放进去,更新计算时跳过它。

  1. Ball 类里面为球球们添加两个属性,idcollisions
Object.assign(this,{     id: Math.floor(Math.random() * 1000000),  // 根据随机数生成的 ID     type : 'circle',     position : new Vector(100, 100),     velocity : new Vector(5, 3),     color : 'blue',     radius : 25,     collisions: [],  // 与之碰撞的小球们组成的数组 },config); 
  1. 在 循环判断碰撞 语句里,写上下面的判断语句:
// 如果对方小球的 `collisions` 里包含自己的 id,那就跳过 ~ if (this === actor || this.collisions.includes(actor.id + updateId)) { continue; } 
  1. 记得在 DisplayState 类里将上面这个概念传入。这里不再演示。

撞击墙壁定格问题

另外,如果球同时撞击墙壁和另一个小球,会产生 卡 在墙上不再动的效果(因为下一帧的计算值超过了边界),所以我们也要改良一下我们的墙壁碰撞函数:

/* 碰到墙壁后,反弹 */ 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); }  // 墙挤压发生在球同时撞击墙壁和另一个球时,球可能会卡在墙上 // 下面这两行,通过判断,能确保球不会卡到墙壁外 // 确保下一帧,始终在墙内(min 计算右边界,max 计算左边界) 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), });  // 最终生成下一帧数据 

内存问题

我们每次碰撞,都会跟踪更新碰撞的数组,这会导致内存增大,如果小球足够多,则会很快将内存耗尽,因此,我们要在适当的时候减少 collisions 数组的元素数量。

nextFrameUpdate 的最开始,我们加上这样一行代码:

if (this.collisions.length > 10) {      this.collisions = this.collisions.slice(this.collisions.length - 3);  // 删除无用的 collisions,只保留最后三个 } 

每当 collisions 元素的数量达到 10 个以上,就只保留最后三个元素。

这样,我们就基本完成碰撞的检测和碰撞的效果了 ~ 我们来实验一下效果吧!

完整代码:

<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){  // 执行下一帧的绘图(或称 在画板上同步已经计算好的下一帧的数据)                 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){  // 画一个角色,比如画一个圆                 for (const actor of actors) { if(actor.type === 'circle'){ this.drawCircle(actor); } }             }             drawCircle(actor){  // 画一个圆                 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),  // 根据随机数生成的 ID                     type : 'circle',                     position : new Vector(100, 100),                     velocity : new Vector(5, 3),                     color : 'blue',                     radius : 25,                     collisions: [],  // 与之碰撞的小球们组成的数组                 },config);             }             nextFrameUpdate(displayState, time, updateId){  // 计算下一帧,小球的位置                 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);                 } 				 				// 墙挤压发生在球同时撞击墙壁和另一个球时,球可能会卡在墙上 				// 下面这两行,通过判断,能确保球不会卡到墙壁外                 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), });  // 最终生成下一帧数据             }             get sphereArea(){ return 4 * Math.PI * this.radius ** 2; }  // 计算球表面积(利用球面积,来表示小球的质量)         }         class Vector {  // 类:向量(可作为位置 和 速度)             constructor(x, y) { this.x = x; this.y = y; }             /* 向量的各种运算 */             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; }  // 数量积             get magnitude() { return Math.sqrt(this.x ** 2 + this.y ** 2); }  // 求模             get direction() { return Math.atan2(this.x, this.y); }  // 求方向的角度 tan         }         class DisplayState {  // 类:宏观状态             constructor(displayEle, actors) { this.displayEle = displayEle; this.actors = actors; }             update(time) {                 const updateId = Math.floor(Math.random() * 1000000);  // 小球的身份证号(而且还能改,尽量不重复)                 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回调,可直接使用,是 秒             displayState = displayState.update();  // 数据更新             displayEle.sync(displayState);     // 根据更新的数据来绘画             requestAnimationFrame(myAnimation);         }         myAnimation();     })(); </script> 

随机数生成多个小球

现在,我们就可以写一个循环和随机数结合的脚本,生成一大堆个小球,像开头的那个动画一样的效果了。

const displayEle = new Canvas(); // 生成某个范围内的随机数 const random = (max = 9, min = 0) => { return Math.floor(Math.random() * (max - min + 1) + min); }; const colors = ['red', 'green', 'blue', 'purple', 'orange'];  // 可供随机挑选的颜色 const balls = []; const count = 30;  // 球的数量 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();  // 数据更新 	displayEle.sync(displayState);     // 根据更新的数据来绘画 	requestAnimationFrame(myAnimation); } myAnimation(); 

最后的效果如下面这个页内框架所示:

参考资料

  1. https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial
  2. https://gist.github.com/joshuabradley012/bd2bc96bbe1909ca8555a792d6a36e04
  3. https://en.wikipedia.org/wiki/Elastic_collision#Two-dimensional
  4. https://eloquentjavascript.net/16_game.html
发表评论

评论已关闭。

相关文章