anarchy.website / Gravity Dev Log / Part 3
Toggle Dark Mode

Gravity Dev Log III

Rendering and Animation

VI. Math

I mentioned in a previous section that the way I was saving positions (simply saving the values as properties in an Object, see below) may be better than the very rudimentary implementation with Arrays but it’s still quite unsatisfactory. So I’m going to work on that a bit!

position: {
  x: 0,
  y: 0,
},

This is terrible because I’m going to be doing a lot of math in the physics engine, so having to access things like this is pretty annoying. What I need is some sort of utility module that will contain a Point class as well as a Vector class that extends it, at least:

/** @module Utils - A collection of utility classes. */
export class Point { /* ... */ }
export class Vector extends Point { /* ... */ }

How is a vector a subclass of a point? You might ask. Simple, where a point is defined by an array of positions of length $n$ in $n$-dimensional space, a vector is defined by an array of lengths of length $n$. The primary difference is that we can do much more with a these values in a vector, such as finding their magnitude, that we wouldn’t do with points.

VI-1. Theory

Basically, with vectors we have these “lengths” which we can call components, but at some point we’re going to want the magnitude and direction. This is fundamentally equivalent to converting a point at the coordinates set by the components into polar coordinates from Cartesian coordinates. If we define this vector, let’s call it $\vec{a}$, by its components, we’d have

\[\vec{a} = a_x\hat{\imath} + a_y\hat{\jmath}, \tag1\]

which we can then get the magnitude, $r$, from with the formula

\[a = \sqrt{a_x^2 + a_y^2}. \tag2\]

Getting the direction, $\phi$, is a bit more complicated, because angles are just weird when working with different quadrants of the Cartesian plane:

\[\phi = \begin{cases} \arctan{\left(\frac{a_y}{a_x} \right)}, & \text{if $a_x\gt0$} \\ \arctan{\left(\frac{a_y}{a_x} \right)} + \pi, & \text{if $a_x\lt0$ and $a_y\ge0$} \\ \arctan{\left(\frac{a_y}{a_x} \right)} - \pi, & \text{if $a_x\lt0$ and $a_y\lt0$} \\ \frac{\pi}2, & \text{if $a_x=0$ and $a_y\gt0$} \\ -\frac{\pi}2, & \text{if $a_x=0$ and $a_y\lt0$} \\ \text{undefined}, & \text{if $a_x=0$ and $a_y=0$} \end{cases} \tag3\]

Though, apparently, there’s a simpler way to do this using $r$. Despite my decent handle on trigonometry, I hadn’t considered this in the previous version of this project, but it seems like a good strategy:

\[\phi = \begin{cases} \arccos{\left(\frac{a_x}r \right)}, & \text{if $a_y\ge0$ and $r\neq0$} \\ -\arccos{\left(\frac{a_x}r \right)}, & \text{if $a_y\lt0$} \\ \text{undefined}, & \text{if $r=0$.} \end{cases} \tag4\]

The reverse is rather trivial in comparison, since it’s the sort of thing trigonometric functions are designed to do:

\[\begin{align} a_x &= r\cos{\phi}, \tag{5a} \\ a_y &= r\sin{\phi}. \tag{5b} \\ \end{align}\]

Because this is so much simpler, I’ll implement this first before dealing with the piecewise function stuff.

VI-2. Implementation

There is something I’d like to test out with this: using setters and getters. Rather than having functions like Vector#getMagnitude and Vector#setMagnitude, I’ll just have Vector#magnitude defined by a getter and a setter:

/** @type {number} */
get magnitude() {
  return Math.sqrt(this.x ** 2 + this.y ** 2);
}
set magnitude(magnitude) {
  let direction = this.direction;
  this.x = magnitude * Math.cos(direction);
  this.y = magnitude * Math.sin(direction);
}

Setting the lengths along the axes is dependent on both magnitude and direction, so to set either of them I first need to cache the other one. Which is basically all there is to it. On the other hand, I would like to keep angles within a single rotation for consistency, so setting direction needs an extra little bit of fixing up just in case:

set direction(direction) {
  let magnitude = this.magnitude;
  // Keep direction within [-pi, pi]
  if(Math.abs(direction) > Math.PI) direction %= Math.PI;
  this.x = magnitude * Math.cos(direction);
  this.y = magnitude * Math.sin(direction);
}

I’m ignoring this for now, but I’m pretty sure these angles would not properly translate into CSS properties. I mean, sure, I can just set the angle in radian units, but that’s not the problem. The $y$-coordinates in CSS are all reflected over the $x$-axis because they are measured from the top rather than the bottom. I’ll need to get more testing done before I can determine a) if this is a real issue and b) if it should be addressed in the utility functions or in the rendering functions. I’m going to sleep now, so I’ll just push what I’ve gotten done so far here.

Good morning! I still haven’t written a getter for direction, which is really important! So here we go:

get direction() {
  let magnitude = this.magnitude;
  if(magnitude = 0) return NaN;
  return (this.y < 0 ? -1 : 1) * Math.acos(Math.PI / magnitude);
}

I cut a lot of the annoying piecewise-ness of the function down with simply returning NaN if magnitude ($r$) is zero and using a ternary operator to determine if the angle should be positive or negative. And that should be everything for the math utilities (for now)!

VI-3. Testing

Ok, so I thew together a little test for these utilities like so:

export default class Game {
  // ...
  handleMouseDown(e) {
    // ...
    let vec = new Vector(mouse.position.x, mouse.position.y);
    console.log(vec.magnitude, (vec.direction / Math.PI));
    // ...

and the first issue that became immediately obvious was that I fucked up with the conditional in get Vector#direction: magnitude = 0 should be magnitude == 0, but even after fixing that, the angles are all… uhh. Pretty much always the same??? Oh! Fuck! Shit! I copied the formula down wrong the first time, the return should be:

return (this.y < 0 ? -1 : 1) * Math.acos(this.x / magnitude);

Also, no reason to bother with the whole / Math.PI part, I tested the values on transform: rotate(...rad); declarations in CSS and they perfectly reflect the direction of the vector in what is theoretically the first quadrant of the plane. This still needs to be tested on the other quadrants tho, since that tends to be when shit gets really weird with the reflected $y$-axis.

VII. Rendering Objects

Before I get to use any of these fun math utilities in the physics, I need objects to manipulate, so onto some actual rendering I go. First, I want a way to make sure that everything is being rendered in the correct position, based on the bounds of the game view. Easy function (based on a StackOverflow answer):

/** @type {number[]} */
get bounds() {
  const { top, left, height, width } = this.container.getBoundingClientRect();
  return { top, left, height, width };
}

VII-1. Defining the Celestial

More importantly, creating a Celestial class for game objects, what properties does it need? For physics: position, velocity, mass, and a hitbox; for rendering: position, size, and texture (things such as Elements will be set in the Renderer); then some metadata like name and “birthday.” This class will be a very basic list of declarations, essentially:

/** @module Celestial - Celestial game objects. */
export default class Celestial {
  constructor(options) {
    /*----- Metadata ---------------------------------------------------------*/
    this.name = options.name || "Unnamed Celestial";
    /** @var {number} birth Creation timestamp. */
    this.birth = +new Date();
    /*----- Physics ----------------------------------------------------------*/
    this.position = options.positions || new Point(0, 0);
    this.velocity = options.velocity || new Vector(0, 0);
    this.mass = options.mass || 0;
    /** @TODO Add HitBox class */
    this.hitBox = null;
    /*----- Rendering --------------------------------------------------------*/
    this.size = options.size || 0;
    this.texture = options.texture || null;
  }
}

At the moment, I’m probably not going to be using the texture property, I’ll save that for when everything is more functional. It’s just about the last day to work on this so I need to work on the minimum viable product (MVP), just making sure everything functions to the minimum requirements of the project before I do the Una thing and focus much more on the graphics than on the code. Not going to make a whole new font for this project, probably.

That in mind, having added this module, I’m just going to try to recreate the original before adding in the new stuff. First, need an array for objects in the level:

/** @var {Celestial[]} scene Array of game objects in current scene. */
this.scene = [];

Then add in a couple instances in the main.js for now:

let saturn = new Celestial({
    name: "Saturn",
    mass: 5.683e26,
    size: 102, // not to scale
  }),
  titan = new Celestial({
    name: "Titan",
    mass: 1.35e23,
    position: new Point(0, -389), // not to scale
    velocity: new Vector(15.26e3, 0),
    size: 18, // not to scale
  });
model.scene.push(saturn);
model.scene.push(titan);

VII-2. Renderer#render()

Since I haven’t done any of the position or size scaling logic yet, these are just going to be literal pixel values for now so I can test out the rendering. So onto that rendering, first defining a Renderer#render function, default in the superclass to make sure there’s always something there:

class Renderer {
  // ...
  /** Render the game scene. */
  render() {
    console.error("Renderer#render() has not been overwritten by subclass!");
  }
  // ...

Then in our DOMRenderer, we make sure that each object in the scene has an Element to render in an overwriting DOMRenderer#render() method:

/** Render the game scene. */
render() {
  const scene = this.model.scene;
  scene.forEach(
    (obj) => obj.element || (obj.element = document.createElement("div"))
  );
}

A quick test shows that this works fine! So moving on to putting these in the scene by adding && this.container.append(obj.element) into the loop. I don’t like how these are just <div> Elements, though, so going to create a function to generate the appropriate Element instead:

/**
 * Create a rendering Element for a Celestial and append it to the container.
 * @arg {Celestial} celestial
 * @returns {HTMLElement} The rendering Element for the Celestial.
 */
generateElement(celestial) {
  const element = document.createElement("celestial"),
    // Clean up the name for adding into a CSS class
    cleanName = celestial.name.replace(/\s+/g, "-").toLowerCase();
  element.classList.add(`gravity__celestial_${cleanName}`);
  celestial.element = element;
  this.container.append(celestial.element);
  return element;
}

This properly adds the Elements into the container,1 and returns them so I can use them inline like const elem = obj.element || this.generateElement(obj);, but I’m going to need to make sure all the elements are prepared with CSS before I can do anything further with them:2

gravity {
  height: 100%;
  left: 0;
  position: absolute;
  top: 0;
  width: 100%;
}
gravity > celestial {
  position: absolute;
}

Now I can go in and apply all the position data to the Elements, which is pretty boring if I’m being honest! But I did it!:

render() {
  const scene = this.model.scene,
    bounds = this.bounds,
    origin = {
      x: bounds.width / 2,
      y: bounds.height / 2,
    };
  scene.forEach(
    /** @arg obj {Celestial} */
    (obj) => {
      // Make sure each Celestial has an Element
      const elem = obj.element || this.generateElement(obj),
        // Avoid repeating elem.style a whole bunch.
        style = elem.style,
        offset = 0 - obj.size / 2;
      style.top = `${origin.y + obj.position.y + offset}px`;
      style.left = `${origin.x + obj.position.x + offset}px`;
      style.width = `${obj.size}px`;
      style.height = `${obj.size}px`;
    }
  );
}

VII-3. Animation

With the Renderer#render() method done, I need it to actually animate. Which is surprisingly simple, just loop it on each animation frame. I wish I had more to say about this, I guess I’m also adding in a boolean to tell it to stop if I want to stop it? Whatever, just adding in Renderer#loop():

class Renderer {
  constructor(model) {
    // ...
    /** @var {boolean} running Should the renderer continue running? */
    this.running = true;
  // ...
  /** Render on animationFrame */
  loop() {
    if (this.running) this.render();
    requestAnimationFrame(this.loop);
  }

A quick test showed some binding issues, so I guess I gotta do a little bit of manual binding:

requestAnimationFrame(this.loop.bind(this));

And now it works, cool!

VIII. Physics

As long as I’m making loops, I’ll throw in a little loop for the physics engine. Remember, physics gets weird if you calculate it based on the animation frames rather than a constant time, so this will be using window.setInterval():

class Physics {
  constructor(model) {
    // ...
    /** @var {number} _intervalId Holds the ID of the loop interval. */
    this._intervalId = null;
  // ...
  /** @const {number} interval Interval time for physics loop. */
  static interval = 1e3 / 120;
  // ...
  /** Calculate physics on a set interval. */
  loop() {
    this._intervalId = window.setInterval(
      this.step.bind(this),
      Physics.interval
    );
  }
  /** Stop the physics engine loop. */
  stop() {
    window.clearInterval(this._intervalId);
  }
  /** Calculate the physics for objects in the scene and apply to model. */
  step() {
    // ...

VIII-1. Update Positions

Basic concept of this is to add the velocity vector multiplied by the time scale to the position vector of each object. I don’t have a utility for adding vectors or for multiplying them by scalars. So going to throw those into the Utils.js module:

class Point {
  // ...
  add(point) {
    this.x += point.x;
    this.y += point.y;
    return this;
  }
  // ...
class Vector extends Point {
  // ...
  scale(scalar) {
    this.magnitude = this.magnitude * scalar;
    return this;
  }

With those functions, I can add in the position updates pretty trivially:

/** Update `Celestial#position`s based on `Celestial#velocity` */
updatePositions() {
  this.model.scene.forEach(
    /** @arg obj {Celestial} */
    (obj) => obj.position.add(obj.velocity.copy().scale(Physics.timeScale))
  );
}

Testing this would show a huge flaw: the velocities are way too fast! Because they’re based on the actual physics and not the scale of the view, which hasn’t been implemented yet, so I need to scale the position rendering based on the space scale… which was previously in Physics but is really only important for Renderer:

export default class Renderer {
  constructor(model) {
    // ...
    /** @var {number} scale Space scale (meters per pixel). */
    this.scale = 0.3e7;
  // ...

Then to create a function for calculating the view position based on the model position, which I can do in the Renderer superclass rather than the DOMRenderer subclass, because all renderers would need this. First, I need to also add a scaling function for points, and, for convenience, modify the Point#add() function to accept a single argument and add that to both components.3

class Point {
  // ...
  /**
   * Add another point to this point.
   * @arg {Point|number} addend A point to add to this point
   * @returns {Point} The updated point.
   */
  add(addend) {
    this.x += addend.x || addend;
    this.y += addend.y || addend;
    return this;
  }
  /**
   * Multiply point components by a scalar
   * @arg {number} scalar Multiplication scale
   * @returns {Point} The updated point.
   */
  scale(scalar) {
    this.x *= scalar;
    this.y *= scalar;
    return this;
  }

Now I can actually get the position with the function specifically for that in Renderer and use that function in my DOMRenderer:

// In Renderer.js
class Renderer {
  // ...
  getPosition(celestial) {
    const bounds = this.bounds,
      // View origin point (0, 0)
      origin = new Point(
        bounds.width / 2,
        bounds.height / 2
      ),
      // View position from scaled Celestial position
      position = celestial.position.copy().scale(1 / this.scale),
      // View offset position from Celestial origin
      offset = celestial.size / 2;
    // Return offset view position
    return origin.add(position).subtract(offset);
  }
  // ...

// In DOMRenderer.js
class DOMRenderer extends Renderer {
  // ...
  render() {
    const scene = this.model.scene;
    scene.forEach(
      (obj) => {
        // ...
        const position = this.getPosition(obj);
        style.left = `${position.x}px`;
        style.top = `${position.y}px`;
        // ...

And, with this added in and the test object scales updated, everything works as expected.

VIII-2. Gravity

Updating position based based on velocity is the most basic of basics when it comes to the physics, now I need to update the velocity based on acceleration. Technically, gravity is a force, so I should update acceleration based on force, but I’m just going to calculate the acceleration from the gravity and apply that directly to the velocity. A fun shortcut. So how do we calculate gravitational acceleration? Well, Newton’s law of universal gravitation is given as:

\[F = G\cdot\frac{m_1m_2}{r^2}, \tag6\]

where $m_1$ and $m_2$ are the masses of two objects, $G$ is the gravitational constant (already defined as Physics.G in the module), and $r$ is the distance between the two “point-like masses.” This “point-like masses” thing can basically be interpreted as the distance between their centers of mass; for convenience, I’m calling the Celestial#position the center of mass of the Celestial instance. Then, to get the acceleration applied by $m_2$ on $m_1$, we substitute Newton’s second law of motion, $F=ma$, in for $F$ and simplify:

\[\begin{align} m_1a &= G\cdot\frac{m_1m_2}{r^2}, \tag{7a} \\ a &= G\cdot\frac{m_1m_2}{m_1r^2}, \tag{7b} \\ a &= G\cdot\frac{m_2}{r^2}. \tag{7c} \end{align}\]

We can get this as a vector by simply multiplying it by the unit vector directed from $m_1$ to $m_2$, $\hat{\mathbf{r}}$:4

\[\mathbf{a} = \frac{Gm_2}{r^2}\cdot\hat{\mathbf{r}}. \tag8 \label8\]

Now to actually implement this! First, I’m going to want a way to get the vector between two Points ($P_1$ and $P_2$), which is just subtracting the components of $P_1$ from the components of $P_2$:

/**
 * Get a copy of this Point as a Vector.
 * @returns {Vector} Copy of this Point as a Vector.
 */
copyAsVector() {
  return new Vector(this.x, this.y);
}
/**
 * Get a Vector between two points.
 * @param {Point} point 
 * @returns {Vector} A Vector between this Point and another Point.
 */
vectorTo(point) {
  return point.copyAsVector().subtract(this);
}

Having added that, I can fairly easily use Eq. $\eqref{8}$ to get the acceleration between two instances of Celestial:

/**
 * Calculate gravitational acceleration between two Celestial instances
 * @param {Celestial} m_1 Celestial to apply acceleration to.
 * @param {Celestial} m_2 Celestial applying the acceleration.
 * @returns {Vector} Gravitational acceleration Vector.
 */
gravitate(m_1, m_2) {
  // Vector between m_1 and m_2
  const between = m_1.position.vectorTo(m_2.position),
    // Distance between m_1 and m_2
    r = between.magnitude;
  // Make `between` a unit vector
  between.magnitude = 1;
  return between.scale(Physics.G * m_2.mass / (r ** 2));
}

With the formula for gravitational acceleration implemented like this, I just need to run it on each celestial for each other celestial, adding all the accelerations up, then updating the velocity based on that sum. I want to try doing this with Array iterator functions rather than for loops. Because this is a method running on each celestial, the outer “loop” will be a .forEach(), but the inner “loop” is a function, so it would be .reduce():

updateVelocities() {
  this.model.scene.forEach(
    /** @arg m_1 {Celestial} */
    (m_1) =>
      // Add to the velocity
      m_1.velocity.add(
        this.model.scene
          // Total acceleration
          .reduce(
            /**
             * @arg {Vector} acc Total acceleration (accumulator)
             * @arg {Celestial} m_2 Celestial applying acceleration
             */
            (acc, m_2) =>
              // Add gravitational acceleration if not same celestial
              m_1 === m_2 ? acc : acc.add(this.gravitate(m_1, m_2)),
            // Initialize an zero acceleration vector
            new Vector(0, 0)
          )
          // Velocity from acceleration
          .scale(Physics.timeScale)
      )
  );
}

This looks fine, and it almost works. But my test moon keeps getting away, like it’s moving too fast? I don’t think it is, though? Oh, it definitely is, it’s set to a velocity of 15.26e3 when it should be 5.47e3? Weird! Probably something to do with the scaling in the original JavaScript, but this works now so that’s it for the calculating gravity!

VIII-3. Collisions

I never got around to this stuff in the original, but for game mechanics it’s absolutely necessary to have a way to check collisions. Doing this for arbitrary shapes is fucking impossible, I think. There’s probably a way to do it, but for the moment, I’m just going to use circles as the hit boxes, so the math is like:

\[(r_1+r_2)^2 \gt (x_1+x_2)^2 + (y_1+y_2)^2,\tag9\]

where $r_1$ and $r_2$ are the radii of the two circles and $(x_1,y_1)$ and $(x_2,y_2)$ are the coordinated of their centers respectively. However, since I have all this vector math handled already, I can just check this using a vector, $\mathbf{v}$, that represents the position of $(x_2,y_2)$ relative to $(x_1,y_1)$:

\[r_1 + r_2 \gt \vert\mathbf{v}\rvert.\tag{10}\]

Implementation time! First, make sure that I’m future proofing this a bit by creating a Celestial#hitBox property that, at least for now, is simply a string describing the shape:

class Celestial {
  constructor(options) {
    // ...
    /** @var {string} hitBox Shape of the hit box */
    this.hitBox = options.hitBox || "CIRCLE";
  // ...

Then make an Object for storing intersect check function. This is going to exist purely because I don’t want to put in the effort of making a HitBox Class at the moment, essentially. This way the functions can be accessed as Physics.INTERSECT_CHECKS[type][type],5 so I implemented it as such:

static INTERSECT_CHECKS = {
  CIRCLE: {
    CIRCLE:
      (a, b) => a.position.vectorTo(b.position).magnitude < a.size + b.size,
  },
};
checkIntersection(a, b) {
  return Physics.INTERSECT_CHECKS[a.hitBox][b.hitBox](a, b);
}

Okay, now time to do something about these collisions, for the time being, I’d like to log them in the Celestial object. Keeping a list of collisions like this is more general and allows me to add different means of using the information rather than simply writing a single handler right now. So, here’s that:

// In Celestial.js
class Celestial {
  constructor(options) {
    // ...
    /** @var {Object[]} collisions Log of collisions with other objects. */
    this.collisions = [];
    // ...

// In Physics.js
updateCollisions() {
  this.model.scene.forEach(
    /** @arg a {Celestial} */
    (a) =>
      this.model.scene.forEach(
        /** @arg b {Celestial} */
        (b) =>
          a !== b &&
          this.checkIntersection(a, b) &&
          a.collisions.push({
            time: +new Date(),
            who: b,
          })
      )
  );
}

But wait! Testing this by changing the color if the collision array is longer than zero shows absolutely no changes! Why? It’s simple, the Celestial#size is in view scale, not model scale! What are the chances that the objects just so happen to be within such a tiny range on any given iteration anyway? Too low, also not what I want. So, all references to Celestial#size in a Renderer need to be divided by Renderer#scale, and the tests need to be scaled up.

That works better, but now they change before they actually hit! Because Celestial#size is a diameter not a radius, duh! God, how did I fuck that up in the first place? Whatever, fixed that. And that’s everything for the basic physics… next to make a game out of it…

Footnotes

  1. I have no idea how to document this in JSDoc, I would really like to declare that Celestial#element is now @type {HTMLElement} but only when DOMRenderer adds it! ↩︎

  2. I’m really tempted to write this out as actual SCSS so I can do some nesting and imports, but I don’t know if that’s allowed in this project or if I can do that on GitHub Pages without having a Jekyll Theme set… I probably can, but I’ll leave that for future work, I suppose. ↩︎

  3. I forgot 0 is falsey the first time I wrote this, so I just did += addend.x || addend; which would add and the Point to the x value if Point#x == 0, so this needs to be a type check. ↩︎

  4. Typically, one would define the attraction force that a larger mass, $M$, applies to a smaller mass, $m$, and so the force applied to $m$ would be defined using the negation of the unit vector pointing from $M$ to $m$: $-\hat{\mathbf{r}}$; however, since I’m just applying the force as acceleration to the velocity of $m_1$, I’m defining the vector $\hat{\mathbf{r}}$ in the opposite direction. ↩︎

  5. Note that this is when I decided that, since I’m calling them “constants,” I will adopt a constants naming convention for static properties of classes. There was some refactoring involved. ↩︎