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

笔记哥 / 05-18 / 20点赞 / 0评论 / 573阅读
目录 - 前言 - 先画一个圆 - 完善我们的类 - 小球动起来 - 最简单的碰撞计算,接触墙壁反弹 - 向量类的完善 - 检测两小球之间的碰撞 - 完善碰撞的效果 - 重复计算的问题 - 撞击墙壁定格问题 - 内存问题 - 随机数生成多个小球 - 参考资料 ## 前言 在前端开发里,`canvas` 是 HTML5 里最炫酷的工具。我们今天就来搞一个这样的梦幻的效果,学习一下 ES6 的类在开发一个完整项目的思路(即 ES5 的构造函数),还有物理碰撞的程序的实现,当然,效果也很酷炫! [完整代码在此处](https://www.ccgxk.com/cellhtmleditor.html?cellpageid=795326333&codeurl=%2F%2Fgit.ccgxk.com%2FmyWorkSpace%2FSmallballcollision%2F5.html "代码在此处")。 ## 先画一个圆 使用“类”这种被广泛应用的面向对象的概念,我们可以更好的整理我们的代码,做出更大的项目。 所以我们先创建一个 `` 画板的类 `class Canvas { }` ,以便抽象我们之后对 `` 的操作。 然后再向类里添加第一个方法 `drawCircle()` ,作为我们的测试吧,就是先画一个最简单的元素 --- 圆! 完整代码如下 (可以在 [这个编辑器](//www.ccgxk.com/cellhtmleditor.html "这个编辑器") 进行简单调试): ```javascript ``` 在代码里,我们定义了一个圆的属性,即 位置 x y 和半径 、 颜色。通过这种井井有条又优雅的方式,我们的目的就达到了! ![image](https://cdn.res.knowhub.vip/c/2505/19/cc3826c9.png?G1cAAMTsdJxI8iSNbqMO2jvFHc2ARRpBpYT1es9Z%2byb6%2fgZGjs%2bofbb94Te1z0ZiVpSFwFBWhCAulh0CTwGcrmKqznGNBg%3d%3d) 这就是一切的基础,一切从这里开始。 ## 完善我们的类 我们直接使用 `ball` 显然是不够的,小球它们要有自己的思想,我们的 `Canvas` 类要只负责绘制,所以我们需要重新开辟一个类,叫 `Ball` 类,来处理它们自己的“思想”。 而 `canvas` 类也需要更多的可扩展性,今天我们是画圆,明天我们想画圈、方块,我们也要考虑到,所以现在,我们要完善一下。 完整代码如下,这样就完美了 ~ ```html ``` 图像能画出来,那么下一步就是运动了。这个要复杂了,一下子想不到要怎么弄,所以要一步一步来。 ## 小球动起来 我们想一下,小球动起来,必定需要把画板清空,然后更改位置、绘制,再清空,再更改位置、绘制... 一帧一帧来。 所以, 1. 画板需要有一个方法,清空画板 方法 2. 计算小球下一帧的位置 3. 再封装一个 【一键更新数据】,用于操作更新数据的逻辑,以及记录和返回计算的结果(表示当前一帧整个游戏的宏观状态) (第三点的这种思想,可以看[这个文章](https://eloquentjavascript.net/16_game.html#:~:text=We%E2%80%99ll%20use%20a%20State%20class%20to%20track%20the%20state%20of%20a%20running%20game. "这个文章")) 先实现第一条,这个很好搞,`canvas` 只需要使用白色画笔,画一个覆盖全画板的矩形即可: (不过,我们可以不使用纯白,使用 0.4 的透明度,可以一点一点将上一帧给缓缓刷白,效果很好!) ```javascript 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` ) ```javascript // 球类 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 的两个正方向如图所示,所以当前小球的速度是向右下: ![image](https://cdn.res.knowhub.vip/c/2505/19/4bcf134f.png?G1cAAMTsdJzIJxKl26hD2jvFHc2ARRpBpYT1es9Z%2byb6fheIxme0Pn1%2f%2bE3r04lzrgYmgRgMIXDhS5Oo1hQYsKQFKHENBw%3d%3d) 下面就是我们当前的向量类 `Vector` : ```javascript // 向量(可作为位置 和 速度) 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 的介绍](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame "这个 MDN 的介绍") 。 ![image](https://cdn.res.knowhub.vip/c/2505/19/d07e6408.gif?G1cAAMTc2vOlu023tN%2fToCVYBtoMWKQRVEpYr2fvf51E%2fXWB5Hh9bUxfH37TxnRis6pgEohCEQIXLiwASg0srEdKNVv0fgM%3d) 完整的代码如下: ```html ``` ## 最简单的碰撞计算,接触墙壁反弹 这个,还几乎用不到物理碰撞算法之类。其实实现这个功能特别简单,只需要检测到小球到达墙壁边界,然后相应的速度正负转化一下即可! 代码很简单,很易懂,将 `Ball` 类里的 `nextFrameUpdate` 计算下一帧位置 的这个方法添加两个判断即可: ```javascript 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` 完善一下。 ## 向量类的完善 向量是我们中学的学习内容,向量有哪些计算呢? 加减乘除? 加减好说,每个元素分别加减即可。有乘法,但没有除法。还有取模和角度。 乘法有两种,一种是常数与之乘法,每个元素都乘以相同的常数: ```javascript multiply(scalar) { // 逐元素乘法return new Vector(this.x * scalar, this.y * scalar); } ``` 另一种,是向量之间的相乘,我们称其为点积或数量积: ```javascript dotProduct(vector) { // 数量积return this.x * vector.x + this.y * vector.y; } ``` 除了加减乘除,还有取模和取角度,模就是向量的长度(用于计算两个小球之间的距离),角度就是向量的 arctan 值(反正切值)。 怎么取模呢? 根据勾股定理,根号下 x 的平方 加 y 的平方。 ```javascript get magnitude() { // 求模return Math.sqrt(this.x ** 2 + this.y ** 2); } ``` 角度就使用反正切将 x y 搞一下就好: ```javascript get direction() { // 求方向的角度 tanreturn Math.atan2(this.x, this.y); } ``` 完整的代码如下: ```html ``` ## 检测两小球之间的碰撞 我们要先定义两个小球,大绿球、小蓝球,我们的实验就是根据这俩球来进行: ```javascript 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()` 方法里,添加这样一个逻辑。每次都计算所有其他的小球与自己的距离,以判断是否碰到。 ```javascript 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';} } ``` 这样效果就出来了。 ![image](https://cdn.res.knowhub.vip/c/2505/19/076888a1.gif?G1cAAOQ21zjp3a%2b1jQZdgmWgzYBFGkGlhPV69v7XCdDfYGTN19fGjPXhN23MAHI%2fDAkY2dCQAlWqVkVMJTFpQTZXzdFv) ## 完善碰撞的效果 我们现在需要完善这个碰撞的效果。变色,表示我们已经能检测到两个球是否碰到了,但没有视觉效果。 碰撞的效果看起来很简单,一瞬间的事,但实现起来并不简单。 > > > 能量既不会凭空产生,也不会凭空消失,它只能从一种形式转化为另一种形式,或者从一个物体转移到另一个物体,总量保持不变。 ----- 能量守恒定理 > 首先,我们在物理里学过《能量守恒定理》,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}$$ 那么它们的碰撞后的速度变化呢? ![image](https://cdn.res.knowhub.vip/c/2505/19/309e3216.gif?G1YAAMTc2vOl2w63Zr%2bnQUuwDLQZkMgiqJSwXs%2fe%2fzqJ%2bhtglHx9bcxYH%2f7SxgwSs6osBIayInlxsEDdD0nFGF7Uao5%2bAw%3d%3d) [维基百科:弹性碰撞](https://en.wikipedia.org/wiki/Elastic_collision#Two-dimensional "维基百科:弹性碰撞") 里给出了上面这个可视化的图,帮助我们理解速度交互和向量的关系。 在根据上面两个公式的基础上,加入了我们的速度向量,进行了很多行的复杂繁琐的推导,我们得出了碰撞后两个小球的最终速度(仅在二维空间有效): $$\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}$$ 在上面的公式中,双竖线代表向量的模(长度);尖括号表示向量间的点积; X 是位置向量 \(\vec{v}\) ,里面包含了 x y 轴。 现在我们的小球还没有质量 M 这个概念。假设球的密度稳定,我们可以抽象成小球的面积,注意是表面积。表面积的计算公式为 \(S = 4\pi r^2\) ,在我们 `Ball` 里搞出这样一个方法,来表示球的表面积属性 : ```javascript get sphereArea(){ return 4 * Math.PI * this.radius ** 2; } // 计算球表面积(利用球面积,来表示小球的质量) ``` 注意,这里使用了 `get` 这个关键字。`get` 会将返回值变为一个属性,而不加 `get` 则会以方法的形式来表现。什么意思呢?看一下对比图: ```javascript // 调用区别 ball.sphereArea // 使用 get 关键字 ball.sphereArea() // 不使用 get 关键字 ``` 很显然,使用 `get` 关键字更切合我们的使用逻辑。 然后我们要将其转化为我们的程序。这个很头疼,要根据我们实现的向量类 `Vector` 里的向量运算方法,一点点复刻那一大串公式,这是我们复刻完的函数: ```javascript // 碰撞后速度的计算函数,参数为“自己”和“对方”,返回值为计算好的碰撞后“自己”的速度向量 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` 类里面为球球们添加两个属性,`id` 和 `collisions` : ```javascript 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. 在 循环判断碰撞 语句里,写上下面的判断语句: ```javascript // 如果对方小球的 `collisions` 里包含自己的 id,那就跳过 ~ if (this === actor || this.collisions.includes(actor.id + updateId)) { continue; } ``` 1. 记得在 `DisplayState` 类里将上面这个概念传入。这里不再演示。 ## 撞击墙壁定格问题 另外,如果球同时撞击墙壁和另一个小球,会产生 卡 在墙上不再动的效果(因为下一帧的计算值超过了边界),所以我们也要改良一下我们的墙壁碰撞函数: ```javascript /* 碰到墙壁后,反弹 */ 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` 的最开始,我们加上这样一行代码: ```javascript if (this.collisions.length > 10) { this.collisions = this.collisions.slice(this.collisions.length - 3); // 删除无用的 collisions,只保留最后三个 } ``` 每当 `collisions` 元素的数量达到 10 个以上,就只保留最后三个元素。 这样,我们就基本完成碰撞的检测和碰撞的效果了 ~ 我们来实验一下效果吧! 完整代码: ```html ``` ## 随机数生成多个小球 现在,我们就可以写一个循环和随机数结合的脚本,生成一大堆个小球,像开头的那个动画一样的效果了。 ```javascript 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