Erstellung eines eigenen Schwerkraft- und Weltraumsimulators

Avatar of Darrell Huffman
Darrell Huffman am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Das Weltall ist riesig. Das Weltall ist großartig. Das Weltall ist schwer zu verstehen – oder zumindest denken das die Leute oft. Aber in diesem Tutorial werde ich Ihnen zeigen, dass das nicht der Fall ist. Ganz im Gegenteil; die Gesetze, die die Bewegung der Sterne, Planeten, Asteroiden und sogar ganzer Galaxien bestimmen, sind unglaublich einfach. Man könnte argumentieren, dass wenn unser Universum von einem Entwickler geschaffen wurde, sie sicher darauf bedacht war, sauberen Code zu schreiben, der einfach zu warten und zu skalieren ist.

Wir werden eine Simulation des inneren Bereichs unseres Sonnensystems erstellen, und zwar ausschließlich mit einfachem JavaScript. Es wird sich um eine N-Körper-Simulation handeln, bei der jede Masse die Schwerkraft aller anderen simulierten Massen spürt. Um dem Ganzen etwas Würze zu verleihen, zeige ich Ihnen auch, wie Sie Benutzern Ihres Simulators ermöglichen können, eigene Planeten zur Simulation hinzuzufügen, und das mit ein wenig Mausbewegung, und dabei alle möglichen kosmischen Wirren verursachen können. Ein Schwerkraft- oder Weltraumsimulator wäre seines Namens nicht würdig, wenn er keine Bewegungsspuren hätte, daher zeige ich Ihnen auch, wie Sie einige schicke Spuren erstellen können, zusätzlich zu einigen anderen Späßen, die den Simulator für den durchschnittlichen Benutzer etwas unterhaltsamer machen.

Sehen Sie den Pen
Gravity Simulator Tutorial
von Darrell Huffman (@thehappykoala)
auf CodePen.

Den vollständigen Quellcode für dieses Projekt finden Sie im obigen Pen. Dort passiert nichts Besonderes. Kein Bündeln von Modulen, keine Transpilierung von TypeScript oder JSX in JavaScript; nur HTML-Markup, CSS und eine gesunde Dosis JavaScript.

Die Idee dazu kam mir während der Arbeit an einem Projekt, das mir am Herzen liegt, nämlich Harmony of the Spheres. Harmony of the Spheres ist Open Source und noch stark in Arbeit. Wenn Ihnen dieses Tutorial gefällt und Sie auf den Geschmack von Weltraum und Physik gekommen sind, schauen Sie sich das Repository an und senden Sie einen Pull-Request, wenn Sie einen Fehler finden oder eine coole neue Funktion implementiert sehen möchten.

Für dieses Tutorial wird davon ausgegangen, dass Sie grundlegende Kenntnisse in JavaScript sowie die mit ES6 eingeführten Syntax und Features besitzen. Außerdem wäre es hilfreich, wenn Sie ein Rechteck auf ein Canvas-Element zeichnen könnten. Wenn Sie dieses Wissen noch nicht besitzen, empfehle ich Ihnen, sich an MDN zu wenden und sich über ES6-Klassen, Pfeilfunktionen, Kurzschreibweise zur Definition von Schlüssel-Wert-Paaren für Objektliterale sowie const und let zu informieren. Wenn Sie sich nicht sicher sind, wie Sie eine Canvas-Animation einrichten, schauen Sie sich die Dokumentation zur Canvas-API auf MDN an.

Teil 1: Schreiben eines Gravitations-N-Körper-Algorithmus

Um das oben genannte Ziel zu erreichen, werden wir uns der numerischen Integration bedienen, einem Ansatz zur Lösung von Gravitations-N-Körper-Problemen, bei dem Sie die Positionen und Geschwindigkeiten aller Objekte zu einem gegebenen Zeitpunkt (T) nehmen, die Gravitationskraft berechnen, die sie aufeinander ausüben, und ihre Geschwindigkeiten und Positionen zum Zeitpunkt (T + dt) aktualisieren (dt als Abkürzung für Delta-Zeit), oder anders ausgedrückt, die Zeitänderung zwischen den Iterationen. Indem wir diesen Prozess wiederholen, können wir die Trajektorien einer Menge von Massen durch Raum und Zeit verfolgen.

Wir werden ein kartesisches Koordinatensystem für unsere Simulation verwenden. Das kartesische Koordinatensystem basiert auf drei zueinander senkrechten Koordinatenachsen: der x-Achse, der y-Achse und der z-Achse. Die drei Achsen schneiden sich an einem Punkt, der als **Ursprung** bezeichnet wird, wo x, y und z gleich 0 sind. Ein Objekt im kartesischen Raum hat eine eindeutige Position, die durch seine x-, y- und z-Werte definiert ist. Der Vorteil der Verwendung des kartesischen Koordinatensystems für unsere Simulation ist, dass die Canvas-API, mit der wir unsere Simulation visualisieren werden, es ebenfalls verwendet.

Für die Erstellung eines Algorithmus zur Lösung des Gravitations-N-Körper-Problems ist es notwendig zu verstehen, was unter Geschwindigkeit und Beschleunigung zu verstehen ist. Geschwindigkeit ist die Änderung der Position eines Objekts über die Zeit, während Beschleunigung die Änderung der Geschwindigkeit eines Objekts über die Zeit ist. Newtons erstes Bewegungsgesetz besagt, dass jedes Objekt in Ruhe oder in gleichförmiger Bewegung entlang einer geraden Linie verharrt, es sei denn, es wird durch die Einwirkung einer äußeren Kraft gezwungen, seinen Zustand zu ändern. Die Erde bewegt sich nicht in einer geraden Linie, sondern umkreist die Sonne, also beschleunigt sie offensichtlich, aber was verursacht diese Beschleunigung? Wie Sie wahrscheinlich angesichts des Themas dieses Tutorials vermutet haben, ist die Antwort die Gravitationskräfte, die die Sonne, die anderen Planeten in unserem Sonnensystem und jedes andere Himmelskörper im Universum auf die Erde ausüben.

Bevor wir über Schwerkraft sprechen, schreiben wir einen Pseudocode zum Aktualisieren der Positionen und Geschwindigkeiten einer Menge von Massen im kartesischen Raum. Wir speichern unsere Massen als Objekte in einem Array, wobei jedes Objekt einen Masse mit x-, y- und z-Positions- und Geschwindigkeitsvektoren darstellt. Geschwindigkeitsvektoren werden mit einem v präfixiert – v für velocity!

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

Wenn wir uns den obigen Code ansehen, können wir erkennen, dass – wie in unserer Diskussion über numerische Integration dargelegt – jedes Mal, wenn wir die Simulation um einen bestimmten Zeitschritt dt vorantreiben, wir die Geschwindigkeiten der simulierten Massen aktualisieren und mit diesen Geschwindigkeiten die Positionen der Massen aktualisieren. Die Beziehung zwischen Position und Geschwindigkeit wird ebenfalls im obigen Code deutlich, da wir sehen können, dass in einem Schritt unserer Simulation die Änderung, zum Beispiel des x-Positionsvektors unserer Masse, gleich dem Produkt des x-Geschwindigkeitsvektors der Masse und dt ist. Ebenso können wir die Beziehung zwischen Geschwindigkeit und Beschleunigung erkennen.

Wie erhalten wir dann die x-, y- und z-Beschleunigungsvektoren für eine Masse, damit wir die Änderung ihrer Geschwindigkeitsvektoren berechnen können? Um den Beitrag von MasseJ zur x-Beschleunigung von MasseI zu erhalten, müssen wir die Gravitationskraft berechnen, die MasseJ auf MasseI ausübt, und dann, um den x-Beschleunigungsvektor zu erhalten, berechnen wir einfach das Produkt dieser Kraft und des Abstands zwischen den beiden Massen auf der x-Achse. Um die y- und z-Beschleunigungsvektoren zu erhalten, befolgen wir das gleiche Verfahren. Nun müssen wir nur noch herausfinden, wie wir die Gravitationskraft berechnen, die MasseJ auf MasseI ausübt, um etwas mehr Pseudocode schreiben zu können. Die Formel, an der wir interessiert sind, sieht so aus:

f = g * massJ.m / dSq * (dSq + s)^1/2

Die obige Formel besagt, dass die Gravitationskraft, die MasseJ auf MasseI ausübt, gleich dem Produkt der Gravitationskonstante (g) und der Masse von MasseJ (massJ.m) geteilt durch das Produkt der Summe der Quadrate des Abstands zwischen MasseI und MasseJ auf den x-, y- und z-Achsen (dSq) und der Quadratwurzel von dSq + s ist, wobei s die sogenannte Weichheitskonstante (softeningConstant) ist. Die Einbeziehung einer Weichheitskonstante in unsere Gravitationsberechnungen verhindert eine Situation, in der die von MasseJ ausgeübte Gravitationskraft unendlich wird, weil sie zu nahe an MasseI ist. Dieser "Fehler", wenn Sie so wollen, in der Newtonschen Gravitationstheorie entsteht, weil die Newtonsche Gravitation Massen als Punktobjekte behandelt, was sie in Wirklichkeit nicht sind. Um fortzufahren, um die Netto-Beschleunigung von MasseI entlang, zum Beispiel der x-Achse zu erhalten, summieren wir einfach die Beschleunigung, die durch jede andere Masse in der Simulation auf sie induziert wird.

Verwandeln wir dies in Code zur Aktualisierung der Beschleunigungsvektoren aller Massen in der Simulation.

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

Wir iterieren über alle Massen in der Simulation, und für jede Masse berechnen wir den Beitrag zur Beschleunigung durch die anderen Massen in einer verschachtelten Schleife und inkrementieren die Beschleunigungsvektoren entsprechend. Sobald wir aus der verschachtelten Schleife heraus sind, aktualisieren wir die Beschleunigungsvektoren von MasseI, die wir dann verwenden können, um ihre neuen Geschwindigkeitsvektoren zu berechnen! Hui. Das war viel. Wir wissen jetzt, wie man die Positions-, Geschwindigkeits- und Beschleunigungsvektoren von N Körpern in einer Gravitationssimulation mittels numerischer Integration aktualisiert.

Aber warten Sie; etwas fehlt. Das stimmt, wir haben über Abstand, Masse und Zeit gesprochen, aber nie spezifiziert, welche Einheiten wir für diese Größen verwenden sollen. Solange wir konsistent sind, ist die Wahl willkürlich, aber im Allgemeinen ist es eine gute Idee, Einheiten zu wählen, die für die betrachteten Skalen geeignet sind, um umständliche lange Zahlen zu vermeiden. Im Kontext unseres Sonnensystems verwenden Wissenschaftler typischerweise astronomische Einheiten für die Entfernung, Sonnenmassen für die Masse und Jahre für die Zeit. Wenn wir diesen Satz von Einheiten übernehmen, beträgt der Wert der Gravitationskonstante (g in der Formel zur Berechnung der Gravitationskraft von MasseJ auf MasseI) 39,5. Für die Positions- und Geschwindigkeitsvektoren der Sonne und der Planeten des inneren Sonnensystems – Merkur, Venus, Erde und Mars – wenden wir uns an die HORIZONS Web-Interface von NASA JPL, wo wir die Ausgabeeinstellung auf Vektortabellen und die Einheiten auf astronomische Einheiten und Tage ändern. Aus irgendeinem Grund liefert Horizons keine Vektoren mit Jahren als Zeiteinheit, daher müssen wir die Geschwindigkeitsvektoren mit 365,25 multiplizieren, der Anzahl der Tage in einem Jahr, um Geschwindigkeitsvektoren zu erhalten, die mit unserer Wahl von Jahren als Zeiteinheit konsistent sind.

Der Gedanke, dass wir mit den einfachen Gleichungen und Gesetzen, die wir oben besprochen haben, die Bewegung jeder Galaxie, jedes Sterns, jedes Planeten und jedes Mondes berechnen können, die in diesem schillernden kosmischen Panorama enthalten sind, das vom Hubble-Teleskop aufgenommen wurde, ist nichts weniger als Ehrfurcht gebietend. Nicht umsonst wird Newtons Gravitationstheorie als „Newtonsches Gesetz der universellen Gravitation“ bezeichnet.

Eine JavaScript-Klasse scheint eine ausgezeichnete Möglichkeit zu sein, die oben geschriebenen Methoden zusammen mit den Daten der Massen und den für unsere Simulation benötigten Konstanten zu kapseln, also lassen Sie uns etwas Refactoring betreiben.

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

Das sieht schon viel schöner aus! Erstellen wir eine Instanz dieser Klasse. Dazu müssen wir drei Konstanten angeben, nämlich die Gravitationskonstante (g), den Zeitschritt der Simulation (dt) und die Weichheitskonstante (softeningConstant). Wir müssen auch ein Array mit Masseobjekten füllen. Sobald wir all diese haben, können wir eine Instanz der nBodyProblem-Klasse erstellen, die wir innerSolarSystem nennen werden, da unsere Simulation ja das innere Sonnensystem darstellen soll!

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
});

In diesem Moment schauen Sie wahrscheinlich darauf, wie ich die nBodyProblem-Klasse instanziiert habe, und fragen sich, was es mit dem JSON-Parsing und Stringifying-Unsinn auf sich hat. Der Grund, warum ich die Daten aus dem Masses-Array auf diese Weise an den Konstruktor von nBodyProblem übergeben habe, ist, dass wir möchten, dass unsere Benutzer die Simulation zurücksetzen können. Wenn wir jedoch das Masses-Array selbst an den Konstruktor der nBodyProblem-Klasse übergeben, wenn wir eine Instanz davon erstellen, und dann den Wert der Masseneigenschaft dieser Instanz auf das Masses-Array setzen, wenn der Benutzer auf die Zurücksetzen-Schaltfläche klickt, wäre die Simulation nicht zurückgesetzt worden; der Zustand der Massen vom Ende des vorherigen Simulationslaufs wäre immer noch da, ebenso wie alle Massen, die der Benutzer hinzugefügt hätte. Um dieses Problem zu lösen, müssen wir beim Instanziieren der nBodyProblem-Klasse oder beim Zurücksetzen der Simulation eine Kopie des Masses-Arrays übergeben, um das Masses-Array nicht zu verändern, das wir makellos und unberührt halten müssen, und der einfachste Weg, es zu klonen, ist, einfach eine stringifizierte Version davon zu parsen.

Okay, weiter geht's: Um die Simulation um einen Schritt voranzutreiben, rufen wir einfach auf:

innerSolarSystem.updatePositionVectors()
                .updateAccelerationVectors()
                .updateVelocityVectors();

Herzlichen Glückwunsch. Sie sind jetzt einen Schritt näher an der Verleihung eines Nobelpreises für Physik!

Teil 2: Erstellung einer visuellen Manifestation für unsere Massen

Wir könnten unsere Massen durch niedliche kleine Kreise darstellen, die mit der arc-Methode der Canvas-API erstellt wurden, aber das sähe ziemlich langweilig aus, und wir würden kein Gefühl für die Trajektorien unserer Massen durch Raum und Zeit bekommen, also schreiben wir eine JavaScript-Klasse, die unsere Vorlage dafür ist, wie sich unsere Massen visuell manifestieren. Sie erstellt einen Kreis, der eine vorgegebene Anzahl kleinerer und verblasster Kreise hinterlässt, wo er zuvor war, was dem Benutzer ein Gefühl von Bewegung und Richtung vermittelt. Je weiter Sie sich von der aktuellen Position der Masse entfernen, desto kleiner und verblasster werden die Kreise. Auf diese Weise haben wir eine hübsch aussehende Bewegungsspur für unsere Massen geschaffen.

Der Konstruktor akzeptiert drei Argumente, nämlich den Zeichenkontext für unser Canvas-Element (ctx), die Länge der Bewegungsspur (trailLength), die die Anzahl der vorherigen Positionen darstellt, die die Spur visualisieren wird, und schließlich den Radius (radius) des Kreises, der die aktuelle Position unserer Masse darstellt. Im Konstruktor werden wir auch ein leeres Array initialisieren, das wir positions nennen werden, das – wenig überraschend – die aktuellen und vorherigen Positionen der Masse speichert, die in die Bewegungsspur aufgenommen werden.

An diesem Punkt sieht unsere Manifestationsklasse so aus:

class Manifestation {

  constructor(ctx, trailLength, radius) {
    this.ctx = ctx;
    
    this.trailLength = trailLength;

    this.radius = radius;

    this.positions = [];
  }
  
}

Wie gehen wir vor, um das positions-Array mit Positionen zu füllen und sicherzustellen, dass wir nicht mehr Positionen speichern, als durch die Eigenschaft trailLength angegeben ist? Die Antwort ist, dass wir unserer Klasse eine Methode hinzufügen, die die x- und y-Koordinaten der Position der Masse als Argumente akzeptiert und sie in einem Objekt im Array mit der push-Methode des Arrays speichert, die ein Element an ein Array anhängt. Das bedeutet, dass die aktuelle Position der Masse das letzte Element im positions-Array ist. Um sicherzustellen, dass wir nicht mehr speichern, als beim Instanziieren der Klasse angegeben, prüfen wir, ob die Länge des positions-Arrays größer ist als die Eigenschaft trailLength. Wenn ja, verwenden wir die shift-Methode des Arrays, um das erste Element zu entfernen, das die älteste gespeicherte Position im positions-Array darstellt.

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();
  }
  
}

Okay, schreiben wir eine Methode, die unsere Bewegungsspur zeichnet. Wie Sie wahrscheinlich bereits vermutet haben, akzeptiert sie zwei Argumente, nämlich die x- und y-Positionen der Masse, für die wir die Spur zeichnen. Das erste, was wir tun müssen, ist, die neue Position im positions-Array zu speichern und alle überflüssigen darin gespeicherten Positionen zu verwerfen. Dann iterieren wir über das positions-Array und zeichnen für jede Position einen Kreis und voilà, wir haben eine Bewegungsspur! Aber sie sieht nicht sehr schön aus, und ich habe Ihnen versprochen, dass unsere Spur hübsch sein würde, mit Kreisen, die immer kleiner und verblasster werden, je nachdem, wie nah sie der aktuellen Position unserer Masse zeitlich sind.

Was wir brauchen, ist eindeutig ein Skalierungsfaktor, dessen Größe davon abhängt, wie weit die zu zeichnende Position von der aktuellen Position unserer Masse zeitlich entfernt ist! Eine ausgezeichnete Möglichkeit, einen geeigneten Skalierungsfaktor für unsere Zwecke zu erhalten, ist einfach die Division des Index (i) des zu zeichnenden Kreises durch die Länge des positions-Arrays. Wenn beispielsweise die Anzahl der zulässigen Elemente im positions-Array 25 beträgt, erhält das Element Nummer 23 in diesem Array einen Skalierungsfaktor von 23 / 25, was uns 0,92 ergibt. Element Nummer 5 erhält dagegen einen Skalierungsfaktor von 5 / 25, was uns 0,2 ergibt; der Skalierungsfaktor nimmt ab, je weiter wir uns von der aktuellen Position unserer Masse entfernen, was die gewünschte Beziehung ist! Beachten Sie, dass wir eine Bedingung benötigen, die sicherstellt, dass, wenn der zu zeichnende Kreis die aktuelle Position darstellt, der Skalierungsfaktor auf 1 gesetzt wird, da wir nicht möchten, dass dieser Kreis verblasst oder kleiner ist, was das betrifft. Mit all dem im Hinterkopf, schreiben wir den Code für die draw-Methode unserer Manifestation-Klasse.

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();
    }
  }
  
}

Teil 3: Visualisierung unserer Simulation

Schreiben wir etwas Canvas-Boilerplate und binden es mit dem Gravitations-N-Körper-Algorithmus und den Bewegungsspuren zusammen, damit wir eine Animation unserer inneren Sonnensystemsimulation zum Laufen bringen können. Wie in der Einleitung dieses Tutorials erwähnt, gehe ich nicht tief auf die Canvas-API ein, da dies kein Einführungstutorial zur Canvas-API ist. Wenn Sie sich also ratlos und/oder verwirrt fühlen, machen Sie sich schnell daran, diesen Zustand zu ändern, indem Sie sich die Dokumentation zu diesem Thema auf MDN ansehen.

Bevor wir fortfahren, hier ist das HTML-Markup für unseren Simulator:

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

Nun wenden wir uns dem interessanten Teil zu: dem JavaScript. Wir beginnen damit, eine Referenz auf das Canvas-Element zu erhalten und fahren dann damit fort, seinen Zeichenkontext zu erhalten. Als nächstes stellen wir die Abmessungen unseres Canvas-Elements ein. Wenn es um Canvas-Animationen im Web geht, spare ich nicht an Bildschirmplatz, also setzen wir die Breite und Höhe des Canvas-Elements auf die Breite und Höhe des Browserfensters. Sie werden bemerken, dass ich eine seltsame Syntax für das Festlegen der Breite und Höhe des Canvas-Elements verwendet habe, indem ich in einer Anweisung deklariert habe, dass die Variable Breite gleich der Eigenschaft Breite des Canvas-Elements ist, die wiederum gleich der Breite des Fensters ist. Manche Entwickler missbilligen die Verwendung dieser Syntax, aber ich finde sie semantisch schön. Wenn Sie nicht dasselbe empfinden, können Sie diese Anweisung in zwei Anweisungen aufteilen. Im Allgemeinen tun Sie, was sich für Sie am besten anfühlt, oder wenn Sie mit anderen zusammenarbeiten, was das Team vereinbart hat.

const canvas = document.querySelector("#canvas");
const ctx = canvas.getContext("2d");

const width = (canvas.width = window.innerWidth);
const height = (canvas.height = window.innerHeight);

An diesem Punkt deklarieren wir einige Konstanten für unsere Animation. Genauer gesagt, es gibt drei davon. Die erste ist der Radius (radius) des Kreises, der die aktuelle Position einer Masse in Pixeln darstellt. Die zweite ist die Länge unserer Bewegungsspur (trailLength), die die Anzahl der vorherigen Positionen ist, die sie enthält. Last, but not least, haben wir die Skalenkonstante (scale), die die Anzahl der Pixel pro astronomischer Einheit darstellt; die Erde ist eine astronomische Einheit von der Sonne entfernt, also wenn wir diesen Skalierungsfaktor nicht einführen würden, würde unser inneres Sonnensystem, gelinde gesagt, sehr klaustrophobisch aussehen.

const scale = 70;
const radius = 4;
const trailLength = 35;

Wenden wir uns nun den visuellen Manifestationen der Massen zu, die wir simulieren. Wir haben eine Klasse geschrieben, die ihr Verhalten kapselt, aber wie instanziieren und arbeiten wir mit diesen Manifestationen in unserem Code? Die bequemste und eleganteste Methode wäre, jedes Element des Masses-Arrays, das wir simulieren, mit einer Instanz der Manifestation-Klasse zu füllen, also schreiben wir eine einfache Methode, die über diese Massen iteriert und genau das tut, und rufen diese dann auf.

const populateManifestations = masses => {
  masses.forEach(
    mass =>
    (mass["manifestation"] = new Manifestation(
      ctx,
      trailLength,
      radius
    ))
  );
};

populateManifestations(innerSolarSystem.masses);

Unser Simulator soll eine spielerische Angelegenheit sein, daher ist es nur zu erwarten, dass Benutzer Massen links und rechts erzeugen und dass nach einer Minute oder so das innere Sonnensystem wie ein unerkennbares kosmisches Durcheinander aussehen wird, weshalb ich denke, dass es anständig von uns wäre, ihnen die Möglichkeit zu geben, die Simulation zurückzusetzen. Um dieses Ziel zu erreichen, beginnen wir damit, einen Event-Listener an die Reset-Schaltfläche anzuhängen, und schreiben dann einen Callback für diesen Event-Listener, der den Wert der Masseneigenschaft des innerSolarSystem-Objekts auf eine Kopie des Masses-Arrays setzt. Da wir das Masses-Array geklont haben, haben wir die Manifestationen unserer Massen nicht mehr darin, also rufen wir die Methode populateManifestations auf, um sicherzustellen, dass unsere Benutzer nach dem Zurücksetzen der Simulation etwas zum Anschauen haben.

document.querySelector('#reset-button').addEventListener('click', () => {
  innerSolarSystem.masses = JSON.parse(JSON.stringify(masses));
  populateManifestations(innerSolarSystem.masses);       
}, false);

Okay, genug mit der Einrichtung. Bringen wir Leben in das innere Sonnensystem, indem wir eine Methode schreiben, die mit Hilfe der requestAnimationFrame API 60 Schritte unserer Simulation pro Sekunde ausführt und die Ergebnisse mit Bewegungsspuren und Beschriftungen für die Planeten des inneren Sonnensystems und die Sonne animiert.

Das erste, was diese Methode tut, ist, das innere Sonnensystem um einen Schritt voranzutreiben, und zwar durch Aktualisierung der Positions-, Beschleunigungs- und Geschwindigkeitsvektoren seiner Massen. Dann bereiten wir das Canvas-Element auf den nächsten Animationszyklus vor, indem wir es von dem, was im vorherigen Animationszyklus gezeichnet wurde, mit der clearRect-Methode der Canvas-API löschen.

Als nächstes iterieren wir über das Masses-Array und rufen die draw-Methode jeder Massenmanifestation auf. Darüber hinaus, wenn die gezeichnete Masse einen Namen hat, zeichnen wir ihn auf das Canvas, damit der Benutzer sehen kann, wo sich die ursprünglichen Planeten nach einem Haywire befinden. Wenn Sie sich den Code in der Schleife ansehen, werden Sie wahrscheinlich bemerken, dass wir zum Beispiel nicht den Wert der x-Koordinate der Masse auf dem Canvas auf masseI mal scale setzen, und dass wir ihn tatsächlich auf die Breite des Viewports geteilt durch zwei plus masseI mal scale setzen. Warum ist das so? Die Antwort ist, dass der Ursprung (x = 0, y = 0) des Canvas-Koordinatensystems in der oberen linken Ecke des Canvas-Elements liegt. Um unsere Simulation auf dem Canvas zu zentrieren, wo sie für den Benutzer gut sichtbar ist, müssen wir diesen Offset einbeziehen.

Nach der Schleife, am Ende der animate-Methode, rufen wir requestAnimationFrame mit der animate-Methode als Callback auf, und dann wird der gesamte oben diskutierte Prozess wiederholt, was einen weiteren Frame erzeugt – und in schneller Folge ausgeführt, haben diese Frames das innere Sonnensystem zum Leben erweckt. Aber warten Sie, wir haben etwas vergessen! Wenn Sie den Code ausführen würden, den ich bisher durchgegangen bin, würden Sie nichts sehen. Glücklicherweise müssen wir nur den Zustand dieser traurigen Angelegenheit ändern, indem wir dem inneren Sonnensystem sprichwörtlich einen Tritt in den Hintern geben (nein, ich werde der Versuchung nicht widerstehen, hier einen Uranus-Witz einzufügen; erwachsen Sie!) indem wir die animate-Methode aufrufen!

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();
Unsere Visualisierung von Merkur, Venus, Erde und Mars, die ihren alltäglichen Geschäften nachgehen und Kreise um die Sonne ziehen. Sieht ziemlich gut aus.

Wow! Wir sind jetzt an dem Punkt angelangt, an dem unsere Simulation animiert ist, mit den Massen, die durch zierliche kleine blaue Kreise repräsentiert werden, verfolgt von wunderbar aussehenden Bewegungsspuren. Das ist an sich schon ziemlich cool, wenn Sie mich fragen; aber ich habe versprochen, auch zu zeigen, wie Sie dem Benutzer ermöglichen können, eigene Massen zur Simulation hinzuzufügen, mit ein wenig Mausbewegung, also sind wir noch nicht ganz fertig!

Teil 4: Hinzufügen von Massen mit der Maus

Die Idee ist, dass der Benutzer die Maustaste gedrückt halten und durch Ziehen eine Linie zeichnen kann; die Linie beginnt dort, wo der Benutzer gedrückt hat, und endet an der aktuellen Position des Mauszeigers. Wenn der Benutzer die Maustaste loslässt, wird eine neue Masse an der Position auf dem Bildschirm erzeugt, an der der Benutzer die Maustaste gedrückt hat, und die Richtung, in die sich die Masse bewegen wird, wird durch die Richtung der Linie bestimmt; die Länge der Linie bestimmt die Geschwindigkeitsvektoren der Masse. Wie gehen wir also bei der Implementierung vor? Lassen Sie uns Schritt für Schritt durchgehen, was wir tun müssen. Der Code für die Schritte eins bis sechs kommt über die Methode animate, während der Code für Schritt sieben eine kleine Ergänzung zur animate-Methode ist.

1. Wir benötigen zwei Variablen, die die x- und y-Koordinaten speichern, an denen der Benutzer die Maustaste auf dem Bildschirm gedrückt hat.

let mousePressX = 0;
let mousePressY = 0;

2. Wir benötigen zwei Variablen, die die aktuellen x- und y-Koordinaten des Mauszeigers auf dem Bildschirm speichern.

let currentMouseX = 0;
let currentMouseY = 0;

3. Wir benötigen eine Variable, die verfolgt, ob die Maus gerade gezogen wird oder nicht. Die Maus wird gezogen in der Zeit, die vergeht, von dem Moment, in dem der Benutzer die Maustaste gedrückt hat, bis zu dem Moment, an dem er sie loslässt.

let dragging = false;

4. Wir müssen einen mousedown-Listener an das Canvas-Element anhängen, der die x- und y-Koordinaten des Klickens protokolliert und die Variable dragging auf true setzt.

canvas.addEventListener(
  "mousedown",
  e => {
    mousePressX = e.clientX;
    mousePressY = e.clientY;
    dragging = true;
  },
  false
);

5. Wir müssen einen mousemove-Listener an das Canvas-Element anhängen, der die aktuellen x- und y-Koordinaten des Mauszeigers protokolliert.

canvas.addEventListener(
  "mousemove",
  e => {
    currentMouseX = e.clientX;
    currentMouseY = e.clientY;
  },
  false
);

6. Wir müssen einen mouseup-Listener an das Canvas-Element anhängen, der die Variable dragging auf false setzt und ein neues Objekt, das eine Masse darstellt, in das Array innerSolarSystem.masses pusht, wobei die x- und y-Positionsvektoren der Punkt sind, an dem der Benutzer die Maustaste gedrückt hat, geteilt durch den Wert der scale-Variable.

Wenn wir diese Vektoren nicht durch die scale-Variable dividieren würden, würden die hinzugefügten Massen weit im Sonnensystem landen, was wir nicht wollen. Der z-Positionsvektor wird auf null gesetzt, ebenso der z-Geschwindigkeitsvektor. Der x-Geschwindigkeitsvektor wird auf die x-Koordinate gesetzt, an der die Maus losgelassen wurde, minus die x-Koordinate, an der die Maus gedrückt wurde, und dann dividieren Sie diese Zahl durch 35. Ich werde ehrlich sein und zugeben, dass 35 eine magische Zahl ist, die zufällig vernünftige Geschwindigkeiten ergibt, wenn Sie mit der Maus Massen zum inneren Sonnensystem hinzufügen. Gleiches Verfahren für den y-Geschwindigkeitsvektor. Die Masse (m) der hinzuzufügenden Masse wird vom Benutzer mit einem select-Element festgelegt, das wir im HTML-Markup mit den Massen einiger berühmter Himmelskörper gefüllt haben. Last, but not least, füllen wir das Objekt, das unsere Masse darstellt, mit einer Instanz der Manifestation-Klasse, damit der Benutzer sie auf dem Bildschirm sehen kann!

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. In der Funktion animate, nach der Schleife, in der wir unsere Manifestationen zeichnen, und bevor wir requestAnimationFrame aufrufen, prüfen wir, ob die Maus gezogen wird. Wenn das der Fall ist, zeichnen wir eine Linie zwischen der Position, an der die Maus gedrückt wurde, und der aktuellen Position des Mauszeigers.

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);
};
Das innere Sonnensystem wird bald wesentlich interessanter – wir können jetzt Massen zu unserer Simulation hinzufügen!

Massen mit der Maus zu unserer Simulation hinzuzufügen ist nicht schwieriger als das! Greifen Sie jetzt zu Ihrer Maus und entfesseln Sie etwas Chaos im inneren Sonnensystem.

Teil 5: Abschottung des inneren Sonnensystems

Wie Sie wahrscheinlich nach dem Hinzufügen einiger Massen zur Simulation bemerkt haben, sind Himmelskörper sehr unberechenbar und neigen dazu, aus dem Ansichtsfenster herauszutanzen, insbesondere wenn die hinzugefügten Massen sehr massereich sind oder zu hohe Geschwindigkeiten haben, was ziemlich ärgerlich ist. Die natürliche Lösung für dieses Problem besteht natürlich darin, das innere Sonnensystem abzuriegeln, so dass eine Masse, wenn sie den Rand des Ansichtsfensters erreicht, zurückprallt! Das klingt nach einem ziemlichen Projekt, diese Funktionalität zu implementieren, aber glücklicherweise ist dies eine eher einfache Angelegenheit. Am Ende der Schleife, in der wir über die Massen iterieren und sie in der animate Methode zeichnen, müssen wir zwei Bedingungen einfügen: eine, die prüft, ob sich unsere Masse außerhalb der Grenzen des Ansichtsfensters auf der x-Achse befindet, und eine andere, die dieselbe Prüfung für die y-Achse durchführt. Wenn sich die Position unserer Masse außerhalb des Ansichtsfensters auf der x-Achse befindet, kehren wir ihren Geschwindigkeitsvektor um, damit sie zurück in das Ansichtsfenster prallt, und dieselbe Logik gilt, wenn sich unsere Masse außerhalb des Ansichtsfensters auf der y-Achse befindet. Mit diesen beiden Bedingungen sieht die animate Methode wie folgt aus

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);
};
Absoluter Wahnsinn! Venus, du alberner Planet, was machst du da draußen?! Du sollst die Sonne umkreisen!

Ping, Pong! Es ist fast so, als würden wir eine Partie kosmische Billardkugeln spielen, mit all diesen Massen, die von der Barriere abprallen, die wir für das innere Sonnensystem errichtet haben!

Schlussbemerkungen

Die Leute neigen dazu, Orbitalmechanik – was wir in diesem Tutorial behandelt haben – als etwas zu betrachten, das für einfache Sterbliche wie mich jenseits des Verständnisses liegt. Die Wahrheit ist jedoch, dass die Orbitalmechanik einem sehr einfachen und eleganten Regelwerk folgt, wie dieses Tutorial beweist. Mit ein wenig JavaScript und Mathematik und Physik der Highschool haben wir das innere Sonnensystem mit einem vernünftigen Grad an Genauigkeit rekonstruiert und sind darüber hinausgegangen, um die Dinge etwas aufzupeppen und somit interessanter zu gestalten. Mit diesem Simulator können Sie alberne Was-wäre-wenn-Fragen beantworten, wie z. B. „Was würde passieren, wenn ich einen Stern mit der Masse der Sonne in unser inneres Sonnensystem schleudern würde?“ oder ein Gefühl für Keplersche Gesetze der Planetenbewegung entwickeln, indem Sie zum Beispiel den Zusammenhang zwischen der Entfernung einer Masse von der Sonne und ihrer Geschwindigkeit beobachten.

Ich hatte auf jeden Fall Spaß beim Schreiben dieses Tutorials und hoffe aufrichtig, dass Sie genauso viel Spaß beim Lesen hatten!