制作一个炫酷的多小球碰碰的 JS 网页特效,入门弹性碰撞模拟和类的应用
<p></p><div class="toc"><div class="toc-container-header">目录</div><ul><li>前言</li><li>先画一个圆</li><li>完善我们的类</li><li>小球动起来</li><li>最简单的碰撞计算,接触墙壁反弹</li><li>向量类的完善</li><li>检测两小球之间的碰撞</li><li>完善碰撞的效果</li><li>重复计算的问题</li><li>撞击墙壁定格问题</li><li>内存问题</li><li>随机数生成多个小球</li><li>参考资料</li></ul></div><p></p><style>.demoVS { width: 450px; max-width: 450px; height: 450px; border: 0 }</style>
<h2 id="前言">前言</h2>
<p>在前端开发里,<code>canvas</code> 是 HTML5 里最炫酷的工具。我们今天就来搞一个这样的梦幻的效果,学习一下 ES6 的类在开发一个完整项目的思路(即 ES5 的构造函数),还有物理碰撞的程序的实现,当然,效果也很酷炫!</p>
<iframe class="demoVS" src="https://git.ccgxk.com/myWorkSpace/Smallballcollision/5.html"></iframe>
<p>完整代码在此处。</p>
<h2 id="先画一个圆">先画一个圆</h2>
<p>使用“类”这种被广泛应用的面向对象的概念,我们可以更好的整理我们的代码,做出更大的项目。</p>
<p>所以我们先创建一个 <code><canvas></code> 画板的类 <code>class Canvas { }</code> ,以便抽象我们之后对 <code><canvas></code> 的操作。</p>
<p>然后再向类里添加第一个方法 <code>drawCircle()</code> ,作为我们的测试吧,就是先画一个最简单的元素 --- 圆!</p>
<p>完整代码如下 (可以在 这个编辑器 进行简单调试):</p>
<pre><code class="language-javascript"><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>
</code></pre>
<p>在代码里,我们定义了一个圆的属性,即 位置 x y 和半径 、 颜色。通过这种井井有条又优雅的方式,我们的目的就达到了!</p>
<p><img src="https://img2024.cnblogs.com/blog/1669501/202505/1669501-20250518164821283-2037965580.png" alt="image" loading="lazy"></p>
<p>这就是一切的基础,一切从这里开始。</p>
<h2 id="完善我们的类">完善我们的类</h2>
<p>我们直接使用 <code>ball</code> 显然是不够的,小球它们要有自己的思想,我们的 <code>Canvas</code> 类要只负责绘制,所以我们需要重新开辟一个类,叫 <code>Ball</code> 类,来处理它们自己的“思想”。</p>
<p>而 <code>canvas</code> 类也需要更多的可扩展性,今天我们是画圆,明天我们想画圈、方块,我们也要考虑到,所以现在,我们要完善一下。</p>
<p>完整代码如下,这样就完美了 ~</p>
<pre><code class="language-html"><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>
</code></pre>
<p>图像能画出来,那么下一步就是运动了。这个要复杂了,一下子想不到要怎么弄,所以要一步一步来。</p>
<h2 id="小球动起来">小球动起来</h2>
<p>我们想一下,小球动起来,必定需要把画板清空,然后更改位置、绘制,再清空,再更改位置、绘制... 一帧一帧来。</p>
<p>所以,</p>
<ol>
<li>画板需要有一个方法,清空画板 方法</li>
<li>计算小球下一帧的位置</li>
<li>再封装一个 【一键更新数据】,用于操作更新数据的逻辑,以及记录和返回计算的结果(表示当前一帧整个游戏的宏观状态)</li>
</ol>
<p>(第三点的这种思想,可以看这个文章)</p>
<p>先实现第一条,这个很好搞,<code>canvas</code> 只需要使用白色画笔,画一个覆盖全画板的矩形即可:</p>
<p>(不过,我们可以不使用纯白,使用 0.4的透明度,可以一点一点将上一帧给缓缓刷白,效果很好!)</p>
<pre><code class="language-javascript">clearDisplay(){// 清空画布
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';// 这个透明度 0.4 是精华,绘制轨迹效果的关键
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
</code></pre>
<p>然后是第二条。</p>
<p>小球如果要运动,必然需要知道要往哪里运动。现在我们引入物理的概念 --- 速度(velocity),这是一个向量值。</p>
<p>而下一帧要去的地方,就是当前的位置,加上当前的速度向量。比如速度是向右 5m/s,那下一秒的位置就是当前位置加上向右 5 米。</p>
<p>这是属于球的个人的“思想”,所以我们写到 <code>Ball</code> 类里面,同时 球 也要加上 速度 这个属性,位置和速度都是向量,都是 x y。</p>
<p>(当然,向量又是一个复杂的个体,所以我们需要再单独开辟一个向量类 <code>Vector</code> )</p>
<pre><code class="language-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
});
}
}
</code></pre>
<p>在 <code>canvas</code> 里,x 和y的两个正方向如图所示,所以当前小球的速度是向右下:</p>
<p><img src="https://img2024.cnblogs.com/blog/1669501/202505/1669501-20250518174324493-1005348008.png" alt="image" loading="lazy"></p>
<p>下面就是我们当前的向量类 <code>Vector</code> :</p>
<pre><code class="language-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);
}
}
</code></pre>
<p>然后,就是使用 js 里用烂了的 <code>requestAnimationFrame</code> 让这个画面一帧一帧动起来,它是根据浏览器的性能实时智能控制帧率的,一般是 100帧/s左右。不熟悉的同学可以看这个 MDN 的介绍 。</p>
<p><img src="https://img2024.cnblogs.com/blog/1669501/202505/1669501-20250518181200089-1215733946.gif" alt="image" loading="lazy"></p>
<p>完整的代码如下:</p>
<pre><code class="language-html"><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 = ;// 我们可能会绘制很多球
let displayState = new DisplayState(displayEle, actors);
function myAnimation(){
displayState = displayState.update();// 一键更新数据
displayEle.sync(displayState); // 根据更新的数据来绘画
requestAnimationFrame(myAnimation)
}
myAnimation();
</script>
</code></pre>
<h2 id="最简单的碰撞计算接触墙壁反弹">最简单的碰撞计算,接触墙壁反弹</h2>
<iframe class="demoVS" src="https://git.ccgxk.com/myWorkSpace/Smallballcollision/3.html"></iframe>
<p>这个,还几乎用不到物理碰撞算法之类。其实实现这个功能特别简单,只需要检测到小球到达墙壁边界,然后相应的速度正负转化一下即可!</p>
<p>代码很简单,很易懂,将 <code>Ball</code> 类里的 <code>nextFrameUpdate</code> 计算下一帧位置 的这个方法添加两个判断即可:</p>
<pre><code class="language-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),
});
}
</code></pre>
<p>注意,判断依据一定是小球的边界,和墙壁的边界,而不是小球的中心。这里就不贴出完整代码了,完善向量后再贴!我们接下来要根据物理公式计算两个小球之间的碰撞,因此我们需要将向量类 <code>Vector</code> 完善一下。</p>
<h2 id="向量类的完善">向量类的完善</h2>
<p>向量是我们中学的学习内容,向量有哪些计算呢?</p>
<p>加减乘除?</p>
<p>加减好说,每个元素分别加减即可。有乘法,但没有除法。还有取模和角度。</p>
<p>乘法有两种,一种是常数与之乘法,每个元素都乘以相同的常数:</p>
<pre><code class="language-javascript">multiply(scalar) {// 逐元素乘法
return new Vector(this.x * scalar, this.y * scalar);
}
</code></pre>
<p>另一种,是向量之间的相乘,我们称其为点积或数量积:</p>
<pre><code class="language-javascript">dotProduct(vector) {// 数量积
return this.x * vector.x + this.y * vector.y;
}
</code></pre>
<p>除了加减乘除,还有取模和取角度,模就是向量的长度(用于计算两个小球之间的距离),角度就是向量的 arctan 值(反正切值)。</p>
<p>怎么取模呢?</p>
<p>根据勾股定理,根号下 x 的平方 加 y 的平方。</p>
<pre><code class="language-javascript">get magnitude() {// 求模
return Math.sqrt(this.x ** 2 + this.y ** 2);
}
</code></pre>
<p>角度就使用反正切将 x y 搞一下就好:</p>
<pre><code class="language-javascript">get direction() {// 求方向的角度 tan
return Math.atan2(this.x, this.y);
}
</code></pre>
<p>完整的代码如下:</p>
<pre><code class="language-html"><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 = ;
let displayState = new DisplayState(displayEle, actors);
function myAnimation(){
displayState = displayState.update();// 数据更新
displayEle.sync(displayState); // 根据更新的数据来绘画
requestAnimationFrame(myAnimation)
}
myAnimation();
</script>
</code></pre>
<h2 id="检测两小球之间的碰撞">检测两小球之间的碰撞</h2>
<p>我们要先定义两个小球,大绿球、小蓝球,我们的实验就是根据这俩球来进行:</p>
<pre><code class="language-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 = ;
</code></pre>
<p>然后,我们在计算下一帧的那个 <code>nextFrameUpdata()</code> 方法里,添加这样一个逻辑。每次都计算所有其他的小球与自己的距离,以判断是否碰到。</p>
<pre><code class="language-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';
}
}
</code></pre>
<p>这样效果就出来了。</p>
<p><img src="https://img2024.cnblogs.com/blog/1669501/202505/1669501-20250518185833543-2147025644.gif" alt="image" loading="lazy"></p>
<h2 id="完善碰撞的效果">完善碰撞的效果</h2>
<p>我们现在需要完善这个碰撞的效果。变色,表示我们已经能检测到两个球是否碰到了,但没有视觉效果。</p>
<p>碰撞的效果看起来很简单,一瞬间的事,但实现起来并不简单。</p>
<blockquote>
<p>能量既不会凭空产生,也不会凭空消失,它只能从一种形式转化为另一种形式,或者从一个物体转移到另一个物体,总量保持不变。 ----- 能量守恒定理</p>
</blockquote>
<p>首先,我们在物理里学过《能量守恒定理》,m 是质量,v 是速度。</p>
<p>$$m_{A} v_{A 1}+m_{B} v_{B 1}=m_{A} v_{A 2}+m_{B} v_{B 2}$$</p>
<p>以及《动能守恒定理》</p>
<p>$$\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}$$</p>
<p>那么它们的碰撞后的速度变化呢?</p>
<p><img src="https://img2024.cnblogs.com/blog/1669501/202505/1669501-20250518201258871-460284569.gif" alt="image" loading="lazy"></p>
<p>维基百科:弹性碰撞 里给出了上面这个可视化的图,帮助我们理解速度交互和向量的关系。</p>
<p>在根据上面两个公式的基础上,加入了我们的速度向量,进行了很多行的复杂繁琐的推导,我们得出了碰撞后两个小球的最终速度(仅在二维空间有效):</p>
<p>$$\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}$$</p>
<p>$$\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}$$</p>
<p>在上面的公式中,双竖线代表向量的模(长度);尖括号表示向量间的点积; X 是位置向量 <span class="math inline">\(\vec{v}\)</span> ,里面包含了 x y 轴。</p>
<p>现在我们的小球还没有质量 M 这个概念。假设球的密度稳定,我们可以抽象成小球的面积,注意是表面积。表面积的计算公式为 <span class="math inline">\(S = 4\pi r^2\)</span> ,在我们 <code>Ball</code> 里搞出这样一个方法,来表示球的表面积属性 :</p>
<pre><code class="language-javascript">get sphereArea(){ return 4 * Math.PI * this.radius ** 2; }// 计算球表面积(利用球面积,来表示小球的质量)
</code></pre>
<p>注意,这里使用了 <code>get</code> 这个关键字。<code>get</code> 会将返回值变为一个属性,而不加 <code>get</code> 则会以方法的形式来表现。什么意思呢?看一下对比图:</p>
<pre><code class="language-javascript">// 调用区别
ball.sphereArea// 使用 get 关键字
ball.sphereArea()// 不使用 get 关键字
</code></pre>
<p>很显然,使用 <code>get</code> 关键字更切合我们的使用逻辑。</p>
<p>然后我们要将其转化为我们的程序。这个很头疼,要根据我们实现的向量类 <code>Vector</code> 里的向量运算方法,一点点复刻那一大串公式,这是我们复刻完的函数:</p>
<pre><code class="language-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))
);
};
</code></pre>
<p>这一大坨很难看,完全没有可读性,但它很准确。没办法,数学公式就是这样。</p>
<h2 id="重复计算的问题">重复计算的问题</h2>
<p>很显然,我们在里面的 <code>for(){}</code> 循环判断碰撞时,同一个碰撞事件会被计算两次,所以我们需要为每个球再创建一个 ID、一个碰撞数组,把有碰撞的球都放进去,更新计算时跳过它。</p>
<ol>
<li>在 <code>Ball</code> 类里面为球球们添加两个属性,<code>id</code> 和 <code>collisions</code> :</li>
</ol>
<pre><code class="language-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);
</code></pre>
<ol start="2">
<li>在 循环判断碰撞 语句里,写上下面的判断语句:</li>
</ol>
<pre><code class="language-javascript">// 如果对方小球的 `collisions` 里包含自己的 id,那就跳过 ~
if (this === actor || this.collisions.includes(actor.id + updateId)) { continue; }
</code></pre>
<ol start="3">
<li>记得在 <code>DisplayState</code> 类里将上面这个概念传入。这里不再演示。</li>
</ol>
<h2 id="撞击墙壁定格问题">撞击墙壁定格问题</h2>
<p>另外,如果球同时撞击墙壁和另一个小球,会产生 卡 在墙上不再动的效果(因为下一帧的计算值超过了边界),所以我们也要改良一下我们的墙壁碰撞函数:</p>
<pre><code class="language-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), });// 最终生成下一帧数据
</code></pre>
<h2 id="内存问题">内存问题</h2>
<p>我们每次碰撞,都会跟踪更新碰撞的数组,这会导致内存增大,如果小球足够多,则会很快将内存耗尽,因此,我们要在适当的时候减少 <code>collisions</code> 数组的元素数量。</p>
<p>在 <code>nextFrameUpdate</code> 的最开始,我们加上这样一行代码:</p>
<pre><code class="language-javascript">if (this.collisions.length > 10) {
this.collisions = this.collisions.slice(this.collisions.length - 3);// 删除无用的 collisions,只保留最后三个
}
</code></pre>
<p>每当 <code>collisions</code> 元素的数量达到 10 个以上,就只保留最后三个元素。</p>
<p>这样,我们就基本完成碰撞的检测和碰撞的效果了 ~ 我们来实验一下效果吧!</p>
<p>完整代码:</p>
<pre><code class="language-html"><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 = ;
let displayState = new DisplayState(displayEle, actors);
function myAnimation(time){// 注意,这里的 time 是requestAnimationFrame回调,可直接使用,是 秒
displayState = displayState.update();// 数据更新
displayEle.sync(displayState); // 根据更新的数据来绘画
requestAnimationFrame(myAnimation);
}
myAnimation();
})();
</script>
</code></pre>
<iframe class="demoVS" src="https://git.ccgxk.com/myWorkSpace/Smallballcollision/6.html"></iframe>
<h2 id="随机数生成多个小球">随机数生成多个小球</h2>
<p>现在,我们就可以写一个循环和随机数结合的脚本,生成一大堆个小球,像开头的那个动画一样的效果了。</p>
<pre><code class="language-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,
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();
</code></pre>
<p>最后的效果如下面这个页内框架所示:</p>
<iframe class="demoVS" src="https://git.ccgxk.com/myWorkSpace/Smallballcollision/5.html"></iframe>
<h2 id="参考资料">参考资料</h2>
<ol>
<li>https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial</li>
<li>https://gist.github.com/joshuabradley012/bd2bc96bbe1909ca8555a792d6a36e04</li>
<li>https://en.wikipedia.org/wiki/Elastic_collision#Two-dimensional</li>
<li>https://eloquentjavascript.net/16_game.html</li>
</ol>
</div>
<div id="MySignature" role="contentinfo">
<p>本文来自博客园,作者:独元殇 (www.ccgxk.com),转载请注明原文链接:https://www.cnblogs.com/duyuanshang/p/18882326</p><br><br>
来源:https://www.cnblogs.com/duyuanshang/p/18882326
頁:
[1]