太空浩瀚无垠。太空令人惊叹。太空难以理解——或者人们倾向于这样认为。但在这个教程中,我将向你展示事实并非如此。恰恰相反;支配恒星、行星、小行星甚至整个星系运动的定律非常简单。你可以说,如果我们的宇宙是由开发者创造的,那么她肯定很关心编写易于维护和扩展的简洁代码。
我们将要做的是使用纯 JavaScript 创建我们太阳系内部区域的模拟。它将是一个引力 n 体模拟,其中每个质量都会感受到所有其他被模拟质量的引力。为了增加趣味性,我还将展示如何让模拟器的用户仅通过简单的鼠标拖动操作就能向模拟中添加他们自己的行星,并在这样做时,引发各种宇宙混乱。如果没有运动轨迹,引力或太空模拟器将不配得上它的名字,因此我将向你展示如何创建一些外观漂亮的轨迹,以及其他一些能让模拟器对普通用户更有乐趣的把戏。
查看 CodePen 上由 Darrell Huffman (@thehappykoala) 编写的
引力模拟器教程
在 CodePen 上。
你将在上面的 CodePen 中找到此项目的完整源代码。那里没有什么花哨的东西。没有模块捆绑,也没有将 TypeScript 或 JSX 转换为 JavaScript;只有 HTML 标记、CSS 和大量的 JavaScript。
我在从事一个我非常喜欢的项目时想到了这个主意,即 Harmony of the Spheres。Harmony of the Spheres 是开源的,并且仍在开发中,所以如果你喜欢本教程并对所有与太空和物理相关的事物产生了兴趣,请查看 存储库,如果你发现任何错误或有任何你想看到的酷炫新功能,请随时提交拉取请求。
在本教程中,假设你已经掌握了 JavaScript 的基本知识,以及 ES6 中引入的语法和特性。此外,如果你能够在画布元素上绘制矩形,那也将有所帮助。如果你还没有掌握这些知识,我建议你访问 MDN 并开始学习 ES6 类、箭头函数、对象字面量定义键值对的简写符号以及 const 和 let。如果你不确定如何设置画布动画,请查看 MDN 上关于 Canvas API 的 文档。
第一部分:编写引力 N 体算法
为了实现上面概述的目标,我们将借鉴数值积分,这是一种解决引力 n 体问题的方法,在这种方法中,你获取给定时间 (T
) 所有物体的位移和速度,计算它们相互施加的引力,并更新它们在时间 (T + dt
,dt
是时间增量的简写) 的速度和位移,或者换句话说,迭代之间的时间变化。重复此过程,我们可以追踪一组质量在空间和时间中的轨迹。
我们将对模拟使用 笛卡尔坐标系。笛卡尔坐标系基于三个相互垂直的坐标轴:x 轴、y 轴和 z 轴。这三个轴相交于称为**原点**的点,在该点处 x、y 和 z 等于 0。笛卡尔空间中的物体具有唯一的位移,该位移由其 x、y 和 z 值定义。对我们的模拟使用笛卡尔坐标系的好处在于,我们将用来可视化模拟的 Canvas API 也使用它。
为了编写解决引力 n 体问题的算法,有必要理解速度和加速度的含义。速度是物体随时间变化的位移,而加速度是物体速度随时间变化的量。牛顿第一 运动定律规定,每个物体都将保持静止或以直线匀速运动,除非受到外力的作用而被迫改变其状态。地球并非以直线运动,而是绕太阳运行,因此显然它正在加速,但是什么导致了这种加速度?正如你可能已经猜到的,鉴于本教程的主题,答案是太阳、太阳系中的其他行星以及宇宙中所有其他天体对地球施加的引力。
在讨论引力之前,让我们编写一些更新笛卡尔空间中一组质量的位移和速度的伪代码。我们将质量存储为数组中的对象,其中每个对象代表一个具有 x、y 和 z 位移以及速度向量的质量。速度向量以 v
为前缀——v 代表速度!
const updatePositionVectors = (masses, dt) => {
const massesLen = masses.length;
for (let i = 0; i < massesLen; i++) {
const massI = masses[i];
mass.x += mass.vx * dt;
mass.y += mass.vy * dt;
mass.z += mass.vz * dt;
}
};
const updateVelocityVectors = (masses, dt) => {
const massesLen = masses.length;
for (let i = 0; i < massesLen; i++) {
const massI = masses[i];
massI.vx += massI.ax * dt;
massI.vy += massI.ay * dt;
massI.vz += massI.az * dt;
}
};
查看上面的代码,我们可以看到——正如我们在关于数值积分的讨论中概述的那样——每次我们以给定的时间步长 dt
推进模拟时,我们都会更新被模拟质量的速度,并用这些速度更新质量的位移。位移和速度之间的关系在上面的代码中也明确表示,因为我们可以看到,在我们模拟的一步中,例如,我们质量的 x 位移向量的变化等于质量的 x 速度向量和 dt
的乘积。类似地,我们可以看出速度和加速度之间的关系。
那么,我们如何获得质量的 x、y 和 z 加速度向量,以便我们可以计算其速度向量的变化?为了获得质量 J 对质量 I 的 x 加速度向量的贡献,我们需要计算质量 J 对质量 I 施加的引力,然后,为了获得 x 加速度向量,我们只需计算此力和两个质量在 x 轴上的距离的乘积。为了获得 y 和 z 加速度向量,我们遵循相同的程序。现在我们只需要弄清楚如何计算质量 J 对质量 I 施加的引力,以便能够编写更多伪代码。我们感兴趣的公式如下所示
f = g * massJ.m / dSq * (dSq + s)^1/2
上面的公式告诉我们,质量 J 对质量 I 施加的引力等于万有引力常数 (g
) 和质量 J 的质量 (massJ.m
) 的乘积除以质量 I 和质量 J 在 x、y 和 z 轴上的距离的平方和 (dSq
) 和 dSq + s
的平方根的乘积,其中 s 是所谓的软化常数 (softeningConstant
)。在我们的引力计算中包含软化常数可以避免质量 J 施加的引力由于距离质量 I 太近而变得无限大的情况。如果你愿意,这种牛顿引力理论中的“错误”是由于牛顿引力将质量视为点对象而产生的,而实际上并非如此。接下来,为了获得质量 I 沿例如 x 轴的净加速度,我们只需将模拟中所有其他质量对其产生的加速度加起来。
让我们将上述内容转换为更新模拟中所有质量的加速度向量的代码。
const updateAccelerationVectors = (masses, g, softeningConstant) => {
const massesLen = masses.length;
for (let i = 0; i < massesLen; i++) {
let ax = 0;
let ay = 0;
let az = 0;
const massI = masses[i];
for (let j = 0; j < massesLen; j++) {
if (i !== j) {
const massJ = masses[j];
const dx = massJ.x - massI.x;
const dy = massJ.y - massI.y;
const dz = massJ.z - massI.z;
const distSq = dx * dx + dy * dy + dz * dz;
f = (g * massJ.m) / (distSq * Math.sqrt(distSq + softeningConstant));
ax += dx * f;
ay += dy * f;
az += dz * f;
}
}
massI.ax = ax;
massI.ay = ay;
massI.az = az;
}
};
我们遍历模拟中的所有质量,并且对于每个质量,我们计算其他质量对其加速度的贡献(在嵌套循环中),并相应地递增加速度向量。一旦我们退出嵌套循环,我们就更新质量 I 的加速度向量,然后我们可以用它来计算其新的速度向量!哇哦。内容很多。我们现在知道如何使用数值积分更新引力模拟中 n 个物体的位移、速度和加速度向量。
但是等等;缺少了一些东西。没错,我们谈到了距离、质量和时间,但我们从未指定应该对这些量使用什么单位。只要我们保持一致,选择是任意的,但一般来说,最好选择适合所考虑尺度的单位,以避免出现尴尬的长数字。在我们的太阳系环境中,科学家倾向于使用天文单位表示距离,太阳质量表示质量,年表示时间。采用这组单位,万有引力常数(公式中计算质量 J 对质量 I 施加的引力的 g)的值为 39.5。对于太阳和太阳系内部行星——水星、金星、地球和火星——的位移和速度向量,我们转向 NASA JPL 的 HORIZONS 网页界面,在其中我们将输出设置更改为向量表,并将单位更改为天文单位和天。出于某种原因,Horizons 不提供以年为时间单位的向量,因此我们必须将速度向量乘以 365.25(一年中的天数),以获得与我们选择年作为时间单位一致的速度向量。

JavaScript 类似乎是封装我们上面编写的这些方法以及我们模拟所需的质量数据和常量的一种极好的方式,所以让我们进行一些重构
class nBodyProblem {
constructor(params) {
this.g = params.g;
this.dt = params.dt;
this.softeningConstant = params.softeningConstant;
this.masses = params.masses;
}
updatePositionVectors() {
const massesLen = this.masses.length;
for (let i = 0; i < massesLen; i++) {
const massI = this.masses[i];
massI.x += massI.vx * this.dt;
massI.y += massI.vy * this.dt;
massI.z += massI.vz * this.dt;
}
return this;
}
updateVelocityVectors() {
const massesLen = this.masses.length;
for (let i = 0; i < massesLen; i++) {
const massI = this.masses[i];
massI.vx += massI.ax * this.dt;
massI.vy += massI.ay * this.dt;
massI.vz += massI.az * this.dt;
}
}
updateAccelerationVectors() {
const massesLen = this.masses.length;
for (let i = 0; i < massesLen; i++) {
let ax = 0;
let ay = 0;
let az = 0;
const massI = this.masses[i];
for (let j = 0; j < massesLen; j++) {
if (i !== j) {
const massJ = this.masses[j];
const dx = massJ.x - massI.x;
const dy = massJ.y - massI.y;
const dz = massJ.z - massI.z;
const distSq = dx * dx + dy * dy + dz * dz;
const f =
(this.g * massJ.m) /
(distSq * Math.sqrt(distSq + this.softeningConstant));
ax += dx * f;
ay += dy * f;
az += dz * f;
}
}
massI.ax = ax;
massI.ay = ay;
massI.az = az;
}
return this;
}
}
看起来好多了!让我们创建一个此类的实例。为此,我们需要指定三个常量,即万有引力常数 (g
)、模拟的时间步长 (dt
) 和软化常数 (softeningConstant
)。我们还需要用质量对象填充一个数组。一旦我们拥有了所有这些,我们就可以创建 nBodyProblem
类的实例,我们将称之为 innerSolarSystem
,因为,好吧,我们的模拟将是太阳系内部!
const g = 39.5;
const dt = 0.008; // 0.008 years is equal to 2.92 days
const softeningConstant = 0.15;
const masses = [{
name: "Sun", // We use solar masses as the unit of mass, so the mass of the Sun is exactly 1
m: 1,
x: -1.50324727873647e-6,
y: -3.93762725944737e-6,
z: -4.86567877183925e-8,
vx: 3.1669325898331e-5,
vy: -6.85489559263319e-6,
vz: -7.90076642683254e-7
}
// Mercury, Venus, Earth and Mars data can be found in the pen for this tutorial
];
const innerSolarSystem = new nBodyProblem({
g,
dt,
masses: JSON.parse(JSON.stringify(masses)),
softeningConstant
});
此刻,你可能正在查看我如何实例化了nBodyProblem
类,并想知道这个 JSON 解析和字符串化的操作有什么意义。我之所以用这种方式将masses
数组中包含的数据传递给nBodyProblem
构造函数,是因为我们希望用户能够重置模拟。但是,如果我们在创建nBodyProblem
类的实例时将masses
数组本身传递给它的构造函数,然后在用户点击重置按钮时将该实例的masses
属性的值设置为等于masses
数组,那么模拟将不会被重置;先前模拟运行结束时masses
的状态仍然存在,用户添加的任何质量也仍然存在。为了解决这个问题,我们需要在实例化nBodyProblem
类或重置模拟时传递masses
数组的克隆,以避免修改masses
数组,我们需要保持它原始且未被触碰的状态,而克隆它的最简单方法就是简单地解析它的字符串化版本。
好的,继续:要将模拟推进一步,我们只需调用
innerSolarSystem.updatePositionVectors()
.updateAccelerationVectors()
.updateVelocityVectors();
恭喜。你现在离获得诺贝尔物理学奖又近了一步!
第二部分:为我们的质量创建视觉表现形式
我们可以用 Canvas API 的 arc
方法创建的可爱的小圆圈来表示我们的质量,但这看起来有点单调,我们也无法感知质量在空间和时间中的轨迹,所以让我们编写一个 JavaScript 类,它将成为我们质量视觉表现形式的模板。它将创建一个圆圈,并在其之前的位置留下预定数量的更小更淡的圆圈,这会向用户传达运动和方向的感觉。离质量当前位置越远,圆圈就越小越淡。这样,我们就为我们的质量创建了一个漂亮的运动轨迹。
构造函数接受三个参数,即画布元素的绘图上下文(ctx
)、运动轨迹的长度(trailLength
),它表示轨迹将可视化的质量先前位置的数量,以及表示质量当前位置的圆圈的半径(radius
)。在构造函数中,我们还将初始化一个空数组,我们将其称为positions
,它将——不出所料——存储运动轨迹中包含的质量的当前位置和先前位置。
此时,我们的表现形式类如下所示
class Manifestation {
constructor(ctx, trailLength, radius) {
this.ctx = ctx;
this.trailLength = trailLength;
this.radius = radius;
this.positions = [];
}
}
我们如何将positions
数组填充位置,并确保我们存储的位置不超过trailLength
属性指定的数量?答案是,我们向类中添加一个方法,该方法接受质量位置的 x 和 y 坐标作为参数,并使用数组的 push
方法将它们存储在数组中的对象中,该方法将元素追加到数组中。这意味着质量的当前位置将是positions
数组中的最后一个元素。为了确保我们存储的位置不超过实例化类时指定的数量,我们检查positions
数组的长度是否大于trailLength
属性。如果是,我们使用数组的 shift
方法删除第一个元素,该元素表示positions
数组中最旧的存储位置。
class Manifestation {
constructor() { /* The code for the constructor outlined above */ }
storePosition(x, y) {
this.positions.push({ x, y });
if (this.positions.length > this.trailLength)
this.positions.shift();
}
}
好的,让我们编写一个绘制运动轨迹的方法。正如你可能猜到的,它将接受两个参数,即我们正在为其绘制轨迹的质量的 x 和 y 位置。我们需要做的第一件事是将新位置存储在positions
数组中,并丢弃其中存储的任何多余位置。然后我们遍历positions
数组,并为每个位置绘制一个圆圈,瞧,我们得到了一个运动轨迹!但它看起来不太好看,我向你保证,我们的轨迹会很漂亮,圆圈会根据它们在时间上离质量当前位置的距离变得越来越小越来越淡。
很明显,我们需要一个比例因子,其大小取决于我们正在绘制的位置在时间上离质量当前位置有多远!对于我们的目的,获得适当比例因子的一个好方法是简单地将正在绘制的圆圈的索引(i
)除以positions
数组的长度。例如,如果positions
数组中允许的元素数量为 25,则该数组中的第 23 个元素将获得 23 / 25 的比例因子,这将得到 0.92。另一方面,第 5 个元素将获得 5 / 25 的比例因子,这将得到 0.2;比例因子随着我们离质量的当前位置越远而减小,这就是我们想要的关系!请注意,我们需要一个条件来确保如果正在绘制的圆圈表示当前位置,则比例因子设置为 1,因为我们不希望该圆圈淡化或缩小。考虑到所有这些,让我们编写Manifestation
类的 draw
方法的代码。
class Manifestation {
constructor() { /* The code for the constructor outlined above */ }
storePosition() { /* The code for the storePosition method discussed above */ }
draw(x, y) {
this.storePosition(x, y);
const positionsLen = this.positions.length;
for (let i = 0; i < positionsLen; i++) {
let transparency;
let circleScaleFactor;
const scaleFactor = i / positionsLen;
if (i === positionsLen - 1) {
transparency = 1;
circleScaleFactor = 1;
} else {
transparency = scaleFactor / 2;
circleScaleFactor = scaleFactor;
}
this.ctx.beginPath();
this.ctx.arc(
this.positions[i].x,
this.positions[i].y,
circleScaleFactor * this.radius,
0,
2 * Math.PI
);
this.ctx.fillStyle = `rgb(0, 12, 153, ${transparency})`;
this.ctx.fill();
}
}
}
第三部分:可视化我们的模拟
让我们编写一些画布样板代码,并将其与万有引力 n 体算法和运动轨迹结合起来,以便我们可以启动并运行我们内部太阳系模拟的动画。如本教程的介绍中所述,我不会深入讨论 Canvas API,因为这不是关于 Canvas API 的入门教程,因此,如果你发现自己看起来相当困惑或茫然,请尽快通过访问 MDN 关于此主题的文档来改变这种情况。
不过,在我们继续之前,以下是模拟器的 HTML 标记
<section id="controls-wrapper">
<label>Mass of Added Planet</label>
<select id="masses-list">
<option value="0.000003003">Earth</option>
<option value="0.0009543">Jupiter</option>
<option value="1">Sun</option>
<option value="0.1">Red Dwarf Star</option>
</select>
<button id="reset-button">Reset</button>
</section>
<canvas id="canvas"></canvas>
现在,我们转向有趣的部分:JavaScript。我们首先获取对画布元素的引用,然后获取其绘图上下文。接下来,我们设置画布元素的尺寸。在 Web 上进行画布动画时,我不会在屏幕空间方面吝啬任何开支,因此让我们分别将画布元素的 width
和 height
属性设置为浏览器窗口的宽度和高度。你会注意到,我在设置画布元素的宽度和高度时使用了特殊的语法,我在一个语句中声明,width
变量等于画布元素的 width
属性,而该属性又等于窗口的宽度。一些开发人员不赞成使用此语法,但我认为它在语义上很美观。如果你没有同样的感受,可以将该语句分解成两个语句。一般来说,做任何让你感觉最舒服的事情,或者如果你发现自己与他人合作,团队已经达成一致的事情。
const canvas = document.querySelector("#canvas");
const ctx = canvas.getContext("2d");
const width = (canvas.width = window.innerWidth);
const height = (canvas.height = window.innerHeight);
此时,我们将为我们的动画声明一些常量。更具体地说,有三个。第一个是圆圈的半径(radius
),它以像素为单位表示质量的当前位置。第二个是运动轨迹的长度(trailLength
),它表示轨迹包含的先前位置的数量。最后但并非最不重要的是,我们有比例常量(scale
),它表示每个天文单位的像素数;地球距离太阳一个天文单位,所以如果我们不引入这个比例因子,我们的内部太阳系至少会看起来非常狭窄。
const scale = 70;
const radius = 4;
const trailLength = 35;
现在让我们转向我们正在模拟的质量的视觉表现形式。我们编写了一个封装其行为的类,但是我们如何在代码中实例化和使用这些表现形式?最方便和优雅的方法是将我们正在模拟的masses
数组的每个元素填充为Manifestation
类的实例,所以让我们编写一个简单的迭代这些质量并执行此操作的方法,然后我们调用它。
const populateManifestations = masses => {
masses.forEach(
mass =>
(mass["manifestation"] = new Manifestation(
ctx,
trailLength,
radius
))
);
};
populateManifestations(innerSolarSystem.masses);
我们的模拟器旨在成为一个有趣的活动,因此用户会在左右两侧产生质量,并且在一分钟左右后,内部太阳系会看起来像一个无法辨认的宇宙混乱,这就是为什么我认为我们为他们提供重置模拟的能力是体面的。为了实现这个目标,我们首先将一个事件监听器附加到重置按钮,然后我们为这个事件监听器编写一个回调函数,该函数将innerSolarSystem
对象的masses
属性的值设置为masses
数组的克隆。由于我们克隆了masses
数组,所以我们不再在其中拥有质量的表现形式,因此我们调用populateManifestations
方法以确保用户在重置模拟后有东西可看。
document.querySelector('#reset-button').addEventListener('click', () => {
innerSolarSystem.masses = JSON.parse(JSON.stringify(masses));
populateManifestations(innerSolarSystem.masses);
}, false);
好的,设置工作足够了。让我们通过编写一个方法来为内部太阳系注入活力,该方法将在requestAnimationFrame
API 的帮助下每秒运行 60 步模拟,并使用运动轨迹和内部太阳系行星和太阳的标签为结果制作动画。
此方法首先将内部太阳系推进一步,它通过更新其质量的位置、加速度和速度向量来做到这一点。然后我们使用 Canvas API 的clearRect
方法清除前一个动画周期中绘制的内容,为下一个动画周期准备画布元素。
接下来,我们遍历masses
数组并调用每个质量表现形式的 draw
方法。此外,如果正在绘制的质量有名称,我们将其绘制到画布上,以便用户在事情变得混乱后可以看到原始行星的位置。查看循环中的代码,你可能会注意到我们没有例如将画布上质量的 x 坐标值设置为massI
乘以scale
,而实际上是将其设置为视口的宽度除以 2 加上massI
乘以scale
。这是为什么?答案是画布坐标系的原点(x = 0,y = 0)设置为画布元素的左上角,因此为了将我们的模拟居中在画布上,以便用户可以清楚地看到它,我们必须包含此偏移量。
循环结束后,在 `animate` 方法的末尾,我们使用 `animate` 方法作为回调函数调用 `requestAnimationFrame`,然后上面讨论的整个过程再次重复,创建另一个帧——并且快速连续运行,这些帧让内太阳系“活”了起来。但是等等,我们好像漏掉了什么!如果你运行我目前为止带你走过的代码,你什么也看不到。幸运的是,要改变这种令人沮丧的状况,我们只需要象征性地给内太阳系“屁股”上踹一脚(不,我不会屈服于插入天王星笑话的诱惑;长大了!),通过调用 `animate` 方法!
const animate = () => {
innerSolarSystem
.updatePositionVectors()
.updateAccelerationVectors()
.updateVelocityVectors();
ctx.clearRect(0, 0, width, height);
const massesLen = innerSolarSystem.masses.length;
for (let i = 0; i < massesLen; i++) {
const massI = innerSolarSystem.masses[i];
const x = width / 2 + massI.x * scale;
const y = height / 2 + massI.y * scale;
massI.manifestation.draw(x, y);
if (massI.name) {
ctx.font = "14px Arial";
ctx.fillText(massI.name, x + 12, y + 4);
ctx.fill();
}
}
requestAnimationFrame(animate);
};
animate();

哇!现在我们已经到了模拟动画化的阶段,质量由精致的小蓝圆圈表示,并伴随着美观的运动轨迹。如果你问我,这本身就很酷;但我确实承诺过还会展示如何通过一些鼠标拖动操作让用户能够向模拟中添加自己的质量,所以我们还没有完成!
第 4 部分:使用鼠标添加质量
这里的想法是,用户应该能够按下鼠标按钮并通过拖动绘制一条线;线条将从用户按下鼠标的位置开始,到鼠标光标的当前位置结束。当用户释放鼠标按钮时,将在用户按下鼠标按钮的屏幕位置生成一个新的质量,并且质量的移动方向由线条的方向决定;线条的长度决定质量的速度向量。那么,我们如何实现这一点呢?让我们一步一步地了解我们需要做什么。步骤一到六的代码位于 `animate` 方法的上方,而步骤七的代码是对 `animate` 方法的一小部分补充。
1. 我们需要两个变量来存储用户在屏幕上按下鼠标按钮的 x 和 y 坐标。
let mousePressX = 0;
let mousePressY = 0;
2. 我们需要两个变量来存储屏幕上鼠标光标的当前 x 和 y 坐标。
let currentMouseX = 0;
let currentMouseY = 0;
3. 我们需要一个变量来跟踪鼠标是否正在拖动。鼠标从用户按下鼠标按钮到释放鼠标按钮的时间段内处于拖动状态。
let dragging = false;
4. 我们需要将一个 `mousedown` 监听器附加到画布元素上,该监听器记录鼠标按下位置的 x 和 y 坐标,并将拖动变量设置为 true。
canvas.addEventListener(
"mousedown",
e => {
mousePressX = e.clientX;
mousePressY = e.clientY;
dragging = true;
},
false
);
5. 我们需要将一个 `mousemove` 监听器附加到画布元素上,该监听器记录鼠标光标的当前 x 和 y 坐标。
canvas.addEventListener(
"mousemove",
e => {
currentMouseX = e.clientX;
currentMouseY = e.clientY;
},
false
);
6. 我们需要将一个 `mouseup` 监听器附加到画布元素上,该监听器将拖动变量设置为 false,并将表示质量的新对象推入 `innerSolarSystem.masses` 数组中,其中 x 和 y 位置向量是用户按下鼠标按钮的位置除以 `scale` 变量的值。
如果我们不将这些向量除以 `scale` 变量,则添加的质量最终会出现在太阳系的外围,这不是我们想要的。z 位置向量设置为零,z 速度向量也设置为零。x 速度向量设置为鼠标释放时的 x 坐标减去鼠标按下时的 x 坐标,然后将此数字除以 35。我必须坦诚地承认,35 是一个 神奇数字,它恰好能够在您使用鼠标向内太阳系添加质量时提供合理的速率。y 速度向量也是同样的过程。我们添加的质量 (m
) 由用户使用一个选择元素设置,我们在 HTML 标记中使用该元素填充了一些著名天体的质量。最后但并非最不重要的是,我们用 `Manifestation` 类的一个实例填充表示我们质量的对象,以便用户可以在屏幕上看到它!
const massesList = document.querySelector("#masses-list");
canvas.addEventListener(
"mouseup",
e => {
const x = (mousePressX - width / 2) / scale;
const y = (mousePressY - height / 2) / scale;
const z = 0;
const vx = (e.clientX - mousePressX) / 35;
const vy = (e.clientY - mousePressY) / 35;
const vz = 0;
innerSolarSystem.masses.push({
m: parseFloat(massesList.value),
x,
y,
z,
vx,
vy,
vz,
manifestation: new Manifestation(ctx, trailLength, radius)
});
dragging = false;
},
false
);
7. 在 `animate` 函数中,在绘制显现的循环之后,以及调用 `requestAnimationFrame` 之前,检查鼠标是否正在拖动。如果是这种情况,我们将绘制一条从鼠标按下位置到鼠标光标当前位置的线。
const animate = () => {
// Preceding code in the animate method down to and including the loop where we draw our mass manifestations
if (dragging) {
ctx.beginPath();
ctx.moveTo(mousePressX, mousePressY);
ctx.lineTo(currentMouseX, currentMouseY);
ctx.strokeStyle = "red";
ctx.stroke();
}
requestAnimationFrame(animate);
};

使用鼠标向模拟中添加质量并不比这更难了!现在,拿起你的鼠标,在内太阳系中释放一些混乱吧。
第 5 部分:为内太阳系围栏
您可能会注意到,在向模拟中添加一些质量后,天体很容易发生“恶作剧”,它们倾向于跳出视口,尤其是在添加的质量非常大或速度过高的情况下,这有点烦人。当然,解决这个问题的自然方法是为内太阳系围栏,以便如果质量到达视口的边缘,它就会弹回来!实施此功能听起来像是一个相当大的项目,但幸运的是,这样做其实很简单。在 `animate` 方法中,我们迭代质量并绘制它们的循环结束时,我们需要插入两个条件:一个检查我们的质量是否在 x 轴的视口边界之外,另一个对 y 轴执行相同的检查。如果质量的位置在 x 轴的视口之外,我们反转其 x 速度向量,使其弹回视口,如果质量在 y 轴的视口之外,则应用相同的逻辑。有了这两个条件,`animate` 方法将如下所示
const animate = () => {
// Advance the simulation by one step; clear the canvas
for (let i = 0; i < massesLen; i++) {
// Preceding loop code
if (x < radius || x > width - radius) massI.vx = -massI.vx;
if (y < radius || y > height - radius) massI.vy = -massI.vy;
}
requestAnimationFrame(animate);
};

乒乓!这几乎就像我们在玩一场宇宙台球游戏,所有那些质量都从我们为内太阳系建造的围栏上弹开!
总结
人们倾向于认为轨道力学——这是我们在本教程中所探讨的内容——是凡人(比如像我这样的人)无法理解的东西。然而,事实是,轨道力学遵循一套非常简单而优雅的规则,正如本教程所证明的那样。通过一点 JavaScript 以及高中数学和物理知识,我们已经以相当精确的程度重建了内太阳系,并在此基础上进一步增加了趣味性,从而使其更加有趣。借助这个模拟器,您可以回答一些愚蠢的假设问题,例如,“如果我把一个质量与太阳相当的恒星扔进我们的内太阳系会发生什么?”,或者通过观察例如质量与太阳的距离与其速度之间的关系来培养对 开普勒行星运动定律 的理解。
我写这个教程玩得很开心,我由衷地希望您阅读它也同样开心!
Darrell,
这太棒了!!!这在游戏和设计中都会带来很多乐趣。
感谢您撰写这篇精彩的文章!
嘿,Tim!
非常感谢您的赞美,不客气!
很棒的教程!我一直想做类似的东西,但无法将数学转换为代码。感谢您提供这个教程!
很高兴听到您发现它有用,谢谢!
行星不是以逆时针方向绕太阳旋转吗?只是有点吹毛求疵。当我借鉴 Dudley Storey 的地球-金星共振动画时,也注意到了同样的问题,所以我的木卫三-木卫四-木卫一共振动画是反方向的……
嘿,Chris!
首先,我要称赞你制作的木卫一、木卫二和木卫三拉普拉斯共振动画:非常棒!
至于你的问题,是的,也可能不是!太阳系行星是顺时针还是逆时针旋转,完全取决于您是从其北极还是南极观察太阳系。因为观察点是一个任意的选择,在其他条件相同的情况下,我会用简单的“否”来回答你的问题,但事实是,惯例是从太阳系的北极观察它,在这种情况下,太阳系中的大多数天体确实以逆时针方向运行,但也有一些例外,比如哈雷彗星,它们的轨道被称为逆行轨道,换句话说就是它们的轨道倾角超过 90 度。所以从这个意义上说,我对你的问题的答案是肯定的。
那么,在本教程中创建的模拟中,行星为什么以顺时针方向运行呢?原因很简单,Horizons 网络界面输出的状态向量具有顺时针方向(您可以获取木星的状态向量并将其添加到模拟中,看看它就像所有其他行星一样以顺时针方向运行)。如果您不喜欢从太阳系南极观察,您可以简单地反转速度向量的符号来恢复秩序,例如 vx: 3.2 变为 vx: -3.2 等等 :D。
这是一个模拟金星轨道在以地球为坐标系中心(由于13:8的平均运动共振)时所描绘出的五角星:https://thehappykoala.github.io/Harmony-of-the-Spheres/#/scenario/Pentagram%20of%20Venus
您可以使用轨道控制从太阳系南北极上方观察系统,从而了解正如我提到的,构成太阳系的质量的旋转方向取决于您的观察角度。
希望这能回答你的问题!
我喜欢这种东西。天体物理学和JavaScript!一个非常棒的教程,我实际上理解了大部分内容……
哇哦……很高兴听到这个!如果你有任何不理解的地方,请提问,我会尽力澄清!
这里面的趣味性让我的一天都变得美好。感谢分享。
我的荣幸,如果你要问我,这是最好的一种乐趣:D!
这真的很酷,谢谢!
不过,我在浏览过程中注意到一个小问题,JavaScript和HTML中的重置按钮名称不匹配,所以当我在我自己的CodePen上浏览时,它最初没有渲染……但这让我彻底检查了所有代码,所以如果这是你的意图……做得好!
还应该提到我已经相应地更新了标记!
不客气!我希望这是一个我设计出来的技巧,让大家真正学习代码并认真努力理解其中的每一个字符,但不是……如果我说这是真的,那就是在夸大其词:这是我忘记在改变重置按钮的id后更新教程本身的HTML标记的结果!——还应该提到我刚刚更新了教程中的标记!