17
\$\begingroup\$

What is this?

This is a two-dimensional physics simulator that models gravity and collisions between circular objects (though it doesn't model rotation).

How do you use it?

You can tweak the following parameters of the simulation while it is running:

  • Number of objects: The specified number of gravitational objects will be added to the scene with random sizes and velocities (replacing any existing objects) upon clicking "Reset".
  • Delay: The number of milliseconds to intentionally delay the simulation between steps when it's running on automatic (the better to see what's happening). You can also advance the simulation manually, in which case the delay has no effect.
  • Border Behavior: Determines what happens to objects when they cross the borders of the scene. Options are Annihilate, Unbounded (default), Loop, Ricochet (which is perfectly elastic), and 50% Ricochet (which is less so).
  • Show Fabric of Spacetime: Toggles whether to display visual markers that indicate the strength of gravitational fields by their displacement.
  • Fabric Granularity: A number that determines the number of markers displayed (lower numbers result in higher marker density).
  • Handle Collisions: Toggles whether to perform collision calculations when object velocities would cause them to intersect.
  • Coefficient of Restitution: A number between 0 and 1 that determines the elasticity of collisions. At 1.0, collisions are perfectly elastic. At 0.0, collisions are perfectly inelastic and objects stick to each other upon collision.

Even though it's more simulation than game, there are some aspects of user interaction besides fiddling with the parameters:

  • Click to add an object.
  • Click and drag to add an object with an initial velocity.
  • Right click to remove an object.

What do I care about?

Improving Performance

Anything that can improve performance when no delay is specified.

Based on the massive performance improvements I saw when I refactored my implementation of Conway's Game of Life, I've tried to cache and reuse values in variables wherever possible and limit their recalculation or re-retrieval, to the point of passing retrieved values as parameters to functions that I know will need them, even if they could retrieve or calculate them themselves.

I'll be grateful for any mathematical shortcuts that can reduce computation overhead without sacrificing accuracy.

Conforming to Best Practices

Whether it's readability, maintainability, testability, or any other -bility, I appreciate any insights and recommendations for improvement.

Improving Simulation Accuracy

Some unrealistic behavior seems inherent to gravity simulations. I'd love to know if there are any routine or standard ways to mitigate such behavior.

Specifically: applying linear velocity to move a mass point around means you skip any velocity recalculations that would result from the changes in effective gravity all along the linear path of movement. This results in inaccuracies that are especially apparent when objects are moving at high velocity (and especially when accelerated quickly by a strong gravitational influence).

A similar but different inaccuracy involves collisions; my collision code checks for overlapping objects, but if the relative velocity between two objects is high enough, one of the objects might get "skipped" over by the linear movement of the other object. One solution is to limit the max velocities of objects, but I'd be eager to hear about any other more suitable solutions, like (hypothetically) a way to efficiently calculate the objects' trajectories and check for intersections.

What I don't really care about

Separation of concerns (unless you convince me otherwise)

Being written as a simulation rather than as a game, the calculation and rendering portions of the code are intertwined; since something interesting happens after every calculation, I want those happenings to be redrawn on the screen after every step. I haven't split out rendering to its own virtual thread.

Looks a little funky in Chrome

Chrome seems to handle the globalAlpha of a 2D canvas context differently from Firefox, Edge, and Internet Explorer at the time of this post. The gravity field fabric markers don't fade quickly enough in Chrome, so it looks like there are specks flying around the objects as they float around the screen. If anybody has recomendations for ways to work around that behavior, I'm open to suggestions, but at this point I'm not too concerned.

const G = 6.674 * Math.pow(10, -11), // gravitational constant
  MAX_RND_VEL = 5,
  MAX_RND_MASS = 5 * Math.pow(10, 12),
  MIN_RND_MASS = 50,
  MAX_RND_SIZE = 8,
  PI = Math.PI,
  TWOPI = Math.PI * 2,
  FADE_RATE = 4,
  CANVAS = document.getElementById("canvas"),
  CURSOR_LINE_COLOR = "cyan",
  OBJECT_BORDER_COLOR = "white",
  GRAVITY_FIELD_MARKER_COLOR = "rgb(150,200,150)",
  GRAVITY_FIELD_MARKER_RADIUS = 0.5,
  txtNumObjects = document.getElementById("NumObjects"),
  chkShowFabric = document.getElementById("ShowFabric"),
  ddlFabricGranularity = document.getElementById("FabricGranularity"),
  ddlRestitution = document.getElementById("Restitution"),
  ddlBorderBehavior = document.getElementById("BorderBehavior"),
  chkHandleCollisions = document.getElementById("chkCollisions"),
  btnNextStep = document.getElementById("NextStep"),
  txtDelay = document.getElementById("Delay");
const WIDTH = CANVAS.width,
  HEIGHT = CANVAS.height,
  DOUBLE_SIZE = MAX_RND_SIZE * 2,
  CTX = CANVAS.getContext("2d");
let numObjects = txtNumObjects.value,
  cellSize = ddlFabricGranularity.value,
  borderBehavior = ddlBorderBehavior.value,
  showFabric = chkShowFabric.checked,
  shouldHandleCollisions = chkHandleCollisions.checked,
  Cr = ddlRestitution.value, // coefficient of restitution
  delay = txtDelay.value,
  stellarObjects = [],
  running = false,
  planting = false,
  plantx, planty, mouseX, mouseY, // vars for adding new objects  
  gField = [],
  gHeight, gWidth; // vars for spacetime fabric visualization
let fadeMultiplier = cellSize / FADE_RATE;
reset();
ddlRestitution.addEventListener("change", function() {
  Cr = this.value;
});
chkHandleCollisions.addEventListener("change", function() {
  shouldHandleCollisions = this.checked;
});
CANVAS.addEventListener("mousedown", startDragCursor);
CANVAS.addEventListener("mousemove", dragCursor);
CANVAS.addEventListener("mouseup", releaseCursor);
CANVAS.addEventListener("mouseout", function(event) {
  planting = false;
});
ddlBorderBehavior.addEventListener("change", function() {
  borderBehavior = this.value;
});
document.getElementById("Reset").addEventListener("click", function() {
  reset();
});
document.getElementById("NextStep").addEventListener("click", function() {
  step();
});
ddlFabricGranularity.addEventListener("change", function() {
  cellSize = this.value;
  fadeMultiplier = cellSize / FADE_RATE;
  resetGravityField();
  recalculateGravityField();
  CTX.clearRect(0, 0, WIDTH, HEIGHT);
  if (showFabric) {
    drawGravityField();
  }
  drawObjects();
});
chkShowFabric.addEventListener("change", function() {
  showFabric = this.checked;
});
document.getElementById("AutoStep").addEventListener("click", function() {
  if (running) {
    clearTimeout(running);
    btnNextStep.disabled = false;
    running = null;
    this.value = "Auto";
  } else {
    btnNextStep.disabled = true;
    this.value = "Stop";
    running = setTimeout(loopStep, delay);
  }
});
txtDelay.addEventListener("change", function() {
  delay = +(this.value);
});

function startDragCursor(event) {
  if (event.which === 3 || event.button === 2) {
    event.preventDefault();
    return false;
  } else {
    planting = true;
    plantx = event.pageX - CANVAS.offsetLeft;
    planty = event.pageY - CANVAS.offsetTop;
    mouseX = plantx;
    mouseY = planty;
  }
  event.preventDefault();
}

function dragCursor(event) {
  if (planting) {
    mouseX = event.pageX - CANVAS.offsetLeft;
    mouseY = event.pageY - CANVAS.offsetTop;
    if (!running) {
      CTX.clearRect(0, 0, WIDTH, HEIGHT);
      if (showFabric) {
        drawGravityField();
      }
      drawObjects();
    }
    drawCursorLine();
  }
  event.preventDefault();
}

function releaseCursor(event) {
  if (event.which === 3 || event.button === 2) {
    removeObjectAt(event.pageX - CANVAS.offsetLeft, event.pageY - CANVAS.offsetTop);
    event.preventDefault();
    return false;
  } else {
    if (planting) {
      let thing = getRandomStellarObject(),
        upX = event.pageX - CANVAS.offsetLeft,
        upY = event.pageY - CANVAS.offsetTop;
      if (typeof plantx === "undefined") {
        plantx = upX;
        planty = upY;
      }
      thing.x = upX;
      thing.y = upY;
      thing.velocity.x = MAX_RND_VEL * MAX_RND_VEL * (plantx - upX) / WIDTH;
      thing.velocity.y = MAX_RND_VEL * MAX_RND_VEL * (planty - upY) / HEIGHT;
      stellarObjects.push(thing);
      numObjects++;
      planting = false;
      CTX.clearRect(0, 0, WIDTH, HEIGHT);
      applyObjectGravityToFabric(upX, upY, thing.mass * G);
      if (showFabric) {
        drawGravityField();
      }
      drawObjects();
    }
  }
  event.preventDefault();
}

// Clears all objects and regenerates them based on the chosen number of objects
function reset() {
  resetGravityField();
  numObjects = document.getElementById("NumObjects").value;
  stellarObjects = [];
  generateObjects(numObjects);
  step();
}

// Repeatedly advances time based on the chosen delay
function loopStep() {
  let startTime = +new Date,
    delta;
  step();
  if (delay === 0 || (delta = +new Date - startTime) >= delay) {
    running = setTimeout(loopStep, 0);
  } else {
    running = setTimeout(loopStep, delay - delta);
  }
}

// Advances time forward and recalculates all object positions/velocities as necessary, redrawing the canvas as necessary
function step() {
  CTX.clearRect(0, 0, WIDTH, HEIGHT);
  moveObjects();
  if (showFabric) {
    resetGravityFieldInPlace();
    applyObjectsGravity();
    drawGravityField();
  } else {
    applyObjectsGravity();
  }
  drawObjects();
}

// adds the specified number of objects to the universe
function generateObjects(num) {
  for (let i = 0; i < num; i++) {
    stellarObjects.push(getRandomStellarObject());
  }
}

// Removes any stellar objects that overlap at the given xy coordinates
function removeObjectAt(x, y) {
  for (let i = stellarObjects.length - 1; i >= 0; i--) {
    let obj = stellarObjects[i];
    let difX = obj.x - x,
      difY = obj.y - y;
    if (getDistance(difX, difY) <= obj.size) {
      stellarObjects.splice(i, 1);
    }
  }
  let newLength = stellarObjects.length;
  if (newLength != numObjects) {
    numObjects = newLength;
    CTX.clearRect(0, 0, WIDTH, HEIGHT);
    if (showFabric) {
      resetGravityFieldInPlace();
      recalculateGravityField();
      drawGravityField();
    }
    drawObjects();
  }
}

// Moves all objects based on their current velocities
function moveObjects() {
  if (shouldHandleCollisions) {
    for (let i = 0; i < numObjects; i++) {
      let o = stellarObjects[i];
      let ov = o.velocity;
      checkCollision(o, ov.x, ov.y, []);
    }
  }
  for (let i = 0; i < numObjects; i++) {
    let o = stellarObjects[i];
    let ov = o.velocity;
    let ovx = ov.x,
      ovy = ov.y;
    o.x += ovx;
    o.y += ovy;
    let ox = o.x,
      oy = o.y;
    if (borderBehavior !== "Unbounded") {
      if (borderBehavior === "Loop") {
        if (ox > WIDTH) {
          o.x = 0;
        } else if (ox < 0) {
          o.x = WIDTH;
        }
        if (oy > HEIGHT) {
          o.y = 0;
        } else if (oy < 0) {
          o.y = HEIGHT;
        }
      } else if (borderBehavior === "Ricochet") {
        if (ox > WIDTH || ox < 0) {
          o.velocity.x = ovx * -1;
        }
        if (oy > HEIGHT || oy < 0) {
          o.velocity.y = -1 * ovy;
        }
      } else if (borderBehavior === "HalfRicochet") {
        if (ox > WIDTH || ox < 0) {
          o.velocity.x = ovx * (-0.5);
          if (ox > WIDTH) {
            o.x = WIDTH;
          } else if (ox < 0) {
            o.x = 0;
          }
        }
        if (oy > HEIGHT || oy < 0) {
          o.velocity.y = (-0.5) * ovy;
          if (oy > HEIGHT) {
            o.y = HEIGHT;
          } else if (oy < 0) {
            o.y = 0;
          }
        }
      } else if (borderBehavior === "Annihilate") {
        if (ox > WIDTH || oy > HEIGHT || ox < 0 || oy < 0) {
          removeObjectAt(ox, oy);
        }
      }
    }
  }
}

// Checks whether two objects are about to collide, and adjusts their velocities if necessary
function checkCollision(obj, ovx, ovy, objectsToIgnore) {
  for (let i = 0; i < numObjects; i++) {
    let test = stellarObjects[i],
      shortCircuit = false;
    if (test === obj) {
      continue;
    }
    for (let j = 0, len = objectsToIgnore.length; j < len; j++) {
      if (test === objectsToIgnore[j]) {
        shortCircuit = true;
        break;
      }
    }
    if (shortCircuit) {
      continue;
    }
    let ox = obj.x + ovx,
      oy = obj.y + ovy,
      tv = test.velocity;
    let tvx = tv.x,
      tvy = tv.y;
    let tx = test.x + tvx,
      ty = test.y + tvy;
    let difx = tx - ox,
      dify = ty - oy;
    if (difx > DOUBLE_SIZE || dify > DOUBLE_SIZE || (difx === 0 && dify === 0)) {
      continue;
    }
    let aSize = obj.size,
      bSize = test.size;
    let cumulativeSize = aSize + bSize;
    let distance = getDistance(difx, dify);
    if (distance <= cumulativeSize) {
      handleCollision(obj, test, cumulativeSize, difx, dify, distance, ox, oy, tx, ty);
      checkCollision(test, tvx, tvy, [obj].concat(objectsToIgnore)); // objectsToIgnore prevents a "Night at the Roxbury" collision loop
    }
  }
}

// Given two objects, their combined size, differences in their xy coordinates, distance, and their coordinates
// updates their coordinates to ensure the objects don't overlap and adjusts their velocities
function handleCollision(first, second, cumulativeSize, difX, difY, distance, x1, y1, x2, y2) {
  let mass1 = first.mass,
    mass2 = second.mass;
  let cumulativeMass = mass1 + mass2;
  let v1x = first.velocity.x,
    v1y = first.velocity.y,
    v2x = second.velocity.x,
    v2y = second.velocity.y;
  let v1 = Math.sqrt(v1x * v1x + v1y * v1y),
    v2 = Math.sqrt(v2x * v2x + v2y * v2y);
  let collisionAngle = Math.atan2(difY, difX);
  let dir1 = Math.atan2(v1y, v1x),
    dir2 = Math.atan2(v2y, v2x);
  let d1cA = dir1 - collisionAngle,
    d2cA = dir2 - collisionAngle;
  let newXv1 = v1 * Math.cos(d1cA),
    newYv1 = v1 * Math.sin(d1cA),
    newXv2 = v2 * Math.cos(d2cA),
    newYv2 = v2 * Math.sin(d2cA);
  let massVCalc = mass1 * newXv1 + mass2 * newXv2;
  let finalXv1 = (massVCalc + mass2 * Cr * (newXv2 - newXv1)) / cumulativeMass,
    finalXv2 = (massVCalc + mass1 * Cr * (newXv1 - newXv2)) / cumulativeMass,
    finalYv1 = newYv1,
    finalYv2 = newYv2;
  let cosAngle = Math.cos(collisionAngle),
    sinAngle = Math.sin(collisionAngle);
  first.velocity = {
    x: cosAngle * finalXv1 - sinAngle * finalYv1,
    y: sinAngle * finalXv1 + cosAngle * finalYv1
  };
  second.velocity = {
    x: cosAngle * finalXv2 - sinAngle * finalYv2,
    y: sinAngle * finalXv2 + cosAngle * finalYv2
  };

  // minimum translation difference to prevent overlaps:
  let dx = first.x - second.x,
    dy = first.y - second.y;
  let d = Math.sqrt(dx * dx + dy * dy);
  if (d < cumulativeSize) {
    let mtd_multiplier = ((first.size + second.size - d) / d);
    let mtd_x = mtd_multiplier * dx;
    let mtd_y = mtd_multiplier * dy;
    let im1 = 1 / mass1,
      im2 = 1 / mass2;
    let cumIm = im1 + im2;
    let imCalc1 = (im1 / (cumIm)),
      imCalc2 = (im2 / (cumIm));
    first.x += mtd_x * imCalc1;
    first.y += mtd_y * imCalc1;
    second.x -= mtd_x * imCalc2;
    second.y -= mtd_y * imCalc2;
  }
}

// Applies an object's gravity to all other objects and to the gravity field fabric (if displayed)
function applyObjectsGravity() {
  for (let i = numObjects - 1; i >= 0; i--) {
    let o = stellarObjects[i];
    let ox = o.x,
      oy = o.y,
      om = o.massEffect;
    applyObjectGravityToObjects(o, ox, oy, om, i);
    if (showFabric) {
      applyObjectGravityToFabric(ox, oy, om);
    }
  }
}

// Given an object, its xy coordinates, and its precalculated mass effect, applies given object's gravity to all other objects
function applyObjectGravityToObjects(stellarObject, x, y, massEffect, init) {
  let objVel = stellarObject.velocity;
  for (let i = init; i >= 0; i--) {
    let currentTarget = stellarObjects[i];
    if (currentTarget !== stellarObject) {
      let targetX = currentTarget.x,
        targetY = currentTarget.y,
        targetME = currentTarget.massEffect,
        targetVel = currentTarget.velocity;
      let difY = y - targetY,
        difX = x - targetX;
      let distance = getDistance(difY, difX);
      if (distance !== 0) {
        let distSqr = distance * distance;
        // F = G*m1*m2 / distance^2... acceleration = F / m... the current object's mass cancels out of Force equation to produce acceleration
        let accelTarg = massEffect / distSqr,
          accelObj = targetME / distSqr;
        let yIsNegative = difY < 0;
        let theta = Math.atan(difX / difY);
        targetVel.x += difX === 0 ? 0 : ((yIsNegative ? -1 : 1) * accelTarg * Math.sin(theta));
        targetVel.y += difY === 0 ? 0 : ((yIsNegative ? -1 : 1) * accelTarg * Math.cos(theta));
        objVel.x -= difX === 0 ? 0 : ((yIsNegative ? -1 : 1) * accelObj * Math.sin(theta));
        objVel.y -= difY === 0 ? 0 : ((yIsNegative ? -1 : 1) * accelObj * Math.cos(theta));
      }
    }
  }
}

// For a given object (and its precalculated mass * the gravitational constant) adjusts the gravity field fabric accordingly
function applyObjectGravityToFabric(x, y, massEffect) {
  let xMeasure = x / cellSize,
    yMeasure = y / cellSize;
  for (let i = 0; i < gHeight; i++) {
    let row = gField[i];
    for (let j = 0; j < gWidth; j++) {
      let currentVector = row[j];
      let oX = currentVector[0],
        oY = currentVector[1],
        difX = xMeasure - j,
        difY = yMeasure - i;
      let distance = getDistance(difX, difY);
      if (distance !== 0) {
        let force = (massEffect) / (distance * distance);
        let xIsNegative = difX < 0,
          yIsNegative = difY < 0;
        let theta = Math.atan(difX / difY);
        currentVector[0] += difX === 0 ? 0 : ((yIsNegative ? -1 : 1) * force * Math.sin(theta));
        currentVector[1] += difY === 0 ? 0 : ((yIsNegative ? -1 : 1) * force * Math.cos(theta));
      }
    }
  }
}

// Warps the gravity field fabric based on the mass of each stellar object
function recalculateGravityField() {
  for (let i = 0; i < numObjects; i++) {
    let o = stellarObjects[i];
    let ox = o.x,
      oy = o.y,
      om = o.massEffect;
    applyObjectGravityToFabric(ox, oy, om);
  }
}

// Resets all the gravity field fabric markers to their places
function resetGravityFieldInPlace() {
  for (let i = 0; i < gHeight; i++) {
    let row = gField[i];
    for (let w = 0; w < gWidth; w++) {
      row[w] = [0, 0];
    }
  }
}

// Calculates the number of gravity field fabric markers based on granularity and initializes them
function resetGravityField() {
  gField = [];
  let maxH = HEIGHT / cellSize,
    maxW = WIDTH / cellSize;
  for (let h = 0; h <= maxH; h++) {
    let row = [];
    for (let w = 0; w <= maxW; w++) {
      row.push([0, 0]);
    }
    gField.push(row);
  }
  gHeight = gField.length;
  gWidth = gField[0].length
}

// draws all stellar objects
function drawObjects() {
  for (let i = 0; i < numObjects; i++) {
    let o = stellarObjects[i];
    drawObject(o);
  }
  if (planting) {
    drawCursorLine();
  }
}

// draws a line from the cursor planted point to the cursor
function drawCursorLine() {
  CTX.beginPath();
  CTX.strokeStyle = CURSOR_LINE_COLOR;
  CTX.moveTo(plantx, planty);
  CTX.lineTo(mouseX, mouseY);
  CTX.stroke();
  CTX.closePath();
}

// draws a stellar object on the canvas
function drawObject(o) {
  let x = o.x,
    y = o.y,
    radius = o.size,
    color = o.color;
  CTX.beginPath();
  CTX.arc(x, y, radius, 0, TWOPI);
  CTX.fillStyle = color;
  CTX.fill();
  CTX.strokeStyle = OBJECT_BORDER_COLOR;
  CTX.arc(x, y, radius, 0, TWOPI);
  CTX.stroke();
  CTX.closePath();
}

// Draws the markers for the gravity field fabric
function drawGravityField() {
  CTX.strokeStyle = GRAVITY_FIELD_MARKER_COLOR;
  for (let i = 0; i < gHeight; i++) {
    let row = gField[i],
      iMeasure = i * cellSize;
    for (let j = 0; j < gWidth; j++) {
      drawVectorDot(j * cellSize, iMeasure, row[j]);
    }
  }
}

// Draws a marker for the gravity field fabric, given the XY coordinates of the dot and a vector representing how much it's been warped
function drawVectorDot(x, y, vector) {
  let vx = vector[0],
    vy = vector[1];
  let endX = x + vx,
    endY = y + vy;
  CTX.globalAlpha = fadeMultiplier / Math.sqrt(vx * vx + vy * vy); // the farther the marker is pulled, the more it fades from view
  CTX.beginPath();
  CTX.arc(endX, endY, GRAVITY_FIELD_MARKER_RADIUS, 0, TWOPI);
  CTX.stroke();
  CTX.globalAlpha = 1;
}

// returns an object with random color, position, velocity, and mass/size
function getRandomStellarObject() {
  let randomMass = MIN_RND_MASS + Math.random() * (MAX_RND_MASS - MIN_RND_MASS);
  let scale = MAX_RND_SIZE / Math.pow(3 * MAX_RND_MASS / 4 / PI, 1 / 3); // size formula based on volume of a sphere
  let randomSize = Math.pow(3 * randomMass / 4 / PI, 1 / 3) * scale,
    randomColor = getRandomColor();
  let velocity = Math.random() * MAX_RND_VEL;
  let bsq = Math.random() * velocity;
  return {
    color: randomColor,
    size: randomSize,
    mass: randomMass,
    x: WIDTH / 6 + (4 * WIDTH / 6 * Math.random()),
    y: HEIGHT / 6 + (4 * HEIGHT / 6 * Math.random()),
    velocity: {
      x: (Math.random() * 2 > 1 ? -1 : 1) * Math.sqrt(bsq),
      y: (Math.random() * 2 > 1 ? -1 : 1) * Math.sqrt(velocity - bsq)
    },
    massEffect: randomMass * G
  }
}

// Returns the distance between two points given the difference between their x and y values
function getDistance(difX, difY) {
  return Math.sqrt(difX * difX + difY * difY);
}

function getRandomColor() {
  return "rgb(" + (Math.random() * 256 >>> 0) + "," + (Math.random() * 256 >>> 0) + "," + (Math.random() * 256 >>> 0) + ")";
}
#AutoStep {
  font-weight: bold;
}

canvas {
  cursor: crosshair;
  background-color: black;
  border: 1px solid black;
  vertical-align: text-top;
  -ms-touch-action: none;
}

input,
.controls {
  font-size: 12pt;
  font-family: Calibri;
}

input {
  padding: 2px;
}

.controls {
  text-align: center;
  background-color: #bfbfbf;
  display: inline-block;
  vertical-align: text-top;
  border: 1px solid black;
}

label {
  cursor: pointer;
}

.container {
  display: inline-block;
  width: 552px;
}

.controls .inner {
  display: inline-block;
}

.controls .section {
  text-align: left;
  background-color: #dfdfdf;
  border: 1px solid #9f9f9f;
  padding: 2px;
  margin: 1px;
  display: inline-block;
  vertical-align: text-top;
}

input[type="number"] {
  max-width: 2em;
}
<div class="container">
  <div class="controls">
    <div class="section">
      <div class="inner">
        Objects:
        <input id="NumObjects" type="number" value=3 />
      </div>
      <input type="button" id="Reset" value="Reset" />
    </div>
    <div class="section">
      <div class="inner">
        <input type="button" id="NextStep" value="Next" />
        <input type="button" id="AutoStep" value="Auto" />
      </div>
      <div class="inner">Delay:
        <select id="Delay">
          <options>
            <option value=0>none</option>
            <option value=10>10 ms</option>
            <option value=30 selected="selected">30 ms</option>
            <option value=60>60 ms</option>
            <option value=500>500ms</option>
            <option value=1000>1 sec</option>
          </options>
        </select>
      </div>
    </div>
    <div class="section">Border:
      <select id="BorderBehavior">
        <options>
          <option value="Annihilate">Annihilate</option>
          <option selected="selected" value="Unbounded">Unbounded</option>
          <option value="Loop">Loop</option>
          <option value="Ricochet">Ricochet</option>
          <option value="HalfRicochet">50% Ricochet</option>
        </options>
      </select>
    </div>
    <div class="section">
      <label>
        <input type="checkbox" id="ShowFabric" checked="checked" />Show Fabric</label>
      <div>Granularity:
        <select id="FabricGranularity">
          <options>
            <option value="5">5</option>
            <option value="7">7</option>
            <option value="10">10</option>
            <option value="13">13</option>
            <option value="15">15</option>
            <option value="20" selected="selected">20</option>
            <option value="30">30</option>
          </options>
        </select>
      </div>
    </div>

    <div class="section">
      <div class="inner">
        <label for="chkCollisions">
          <input type="checkbox" checked="checked" id="chkCollisions" />Handle Collisions</label>
      </div>
      <div>Restitution:
        <select id="Restitution">
          <options>
            <option value=1>1.0</option>
            <option value=0.9 selected="selected">0.9</option>
            <option value=0.8>0.8</option>
            <option value=0.7>0.7</option>
            <option value=0.6>0.6</option>
            <option value=0.5>0.5</option>
            <option value=0.4>0.4</option>
            <option value=0.3>0.3</option>
            <option value=0.2>0.2</option>
            <option value=0.1>0.1</option>
            <option value=0.0>0.0</option>
          </options>
        </select>
      </div>
    </div>

  </div>
  <canvas id="canvas" height="550" oncontextmenu="return false;" width="550">no canvas available</canvas>
</div>

\$\endgroup\$
  • \$\begingroup\$ As a design improvement, I'd use some OOP. Even better using Ecmascript 2015 (with transpiling, and browser polyfills) \$\endgroup\$ – Zorgatone Jan 10 '17 at 7:42
  • \$\begingroup\$ Note to self: explore the velocity verlet algorithm from this answer to improve the accuracy of velocity calculations \$\endgroup\$ – Thriggle Jan 11 '17 at 21:37
  • \$\begingroup\$ I don't think you realize that separation of concerns is coupled with best practices -- that is, encapsulation and abstraction are best practices. \$\endgroup\$ – Charles Addis Apr 20 '17 at 3:28
  • \$\begingroup\$ @CharlesAddis Thanks for the comment! My note was to indicate that I intentionally sacrificed separation of concerns for the performance gain that comes from integrating the object velocity calculations and the object rendering into the same logical loop. I'm absolutely open to any suggestions that can reconcile separation of concerns with performance. \$\endgroup\$ – Thriggle Apr 20 '17 at 13:49
10
\$\begingroup\$

I spent quite some time looking at this:

High Level

I love what you did here, a great showcase of using JavaScript in a powerful way. But from a readability and maintainability perspective, this code is not very good.

Globals, globals everywhere

This is good reading. Your code is suffering from (1) and (2)

Naming

  • Your constants are not consistently named, I would advise to only use ALLCAPS for number and string constants, it just looks wrong to see CANVAS
  • I am all for Spartan coding, but Cr goes a bit too far
  • What is planting? (See comments)
  • plantx, planty, mouseX, mouseY, <- Some variables are not consistently named
  • let ox = o.x, oy = o.y; <- Lost for words ;) Does this really speed up the code that much?

Robustness

  • Imagine someone maintains this code, and wants to call recalculateGravityField. That person has to know to first call resetGravityField or must face a bit of debugging time. Let recalculateGravityField call resetGravityField and your code will be more robust.
  • Too much duplicate code, related to your not wanting to implement MVC, look at the code in dragCursor and ddlFabricGranularity.addEventListener share a ton of code, imagine having an ui.update()

    ddlFabricGranularity.addEventListener("change", function ddlFabricGranularityChanged() {
      fadeMultiplier = this.value / FADE_RATE;
      recalculateGravityField(); //Now calls resetGravityField();
      ui.update();
    });
    

Functions are first class citizens

This

document.getElementById("Reset").addEventListener("click", reset );

looks so much better than

document.getElementById("Reset").addEventListener("click", function() {
  reset();
});

Unnamed functions ruin debugging

You have a ton of anonymous functions, making the stack-trace harder to read, just name those functions.

Speed

Following your logic and general wisdom,

  for (let i = 0; i < numObjects; i++) {
    let o = stellarObjects[i];
    applyObjectGravityToFabric(o.x, o.y, o.massEffect);
  }

is probably faster than

  for (let i = 0; i < numObjects; i++) {
    let o = stellarObjects[i];
    let ox = o.x,
      oy = o.y,
      om = o.massEffect;
    applyObjectGravityToFabric(ox, oy, om);
  }

it might turn out to be the same due to optimization by the JS engine.

From a robustness perspective, I would just throw the whole object to applyObjectGravityToFabric and rework applyObjectGravityToFabric.

  for (let i = 0; i < numObjects; i++) {
    applyObjectGravityToFabric(stellarObjects[i]);
  }

A plea for MVC

Read this: https://addyosmani.com/resources/essentialjsdesignpatterns/book/#detailmvc

And give it a shot, your code could become amazing AND readable.

\$\endgroup\$
  • \$\begingroup\$ Thanks for the review! Regarding global variables, both JSFiddle and stack snippets already wrap all the JS code in an IEFE behind the scenes... I probably should have explicitly wrapped it another one for the sake of the code review. \$\endgroup\$ – Thriggle Jan 10 '17 at 18:59
  • 2
    \$\begingroup\$ That is why I linked to the URL. It's not about namespace pollution, it's about declaring your variables way up in the scope where they don't belong. For example, why can every single function access/change planting \$\endgroup\$ – konijn Jan 10 '17 at 19:08
  • \$\begingroup\$ Followup question: if I were to implement an MV* pattern, do you think I'd need to use an existing framework or would it be enough to bundle my logic into distinct functional scopes (e.g. a Model object that would track the stellar objects and allow subscriptions, a View object that would monitor the model and draw changes on the canvas as well as monitor DOM events and invoke a Controller as appropriate, and a Controller object to handle complicated logic like advancing time and handling collisions)? \$\endgroup\$ – Thriggle Jan 11 '17 at 21:17
  • \$\begingroup\$ Anything existing would be overkill, do you own thing. \$\endgroup\$ – konijn Jan 11 '17 at 22:11
2
\$\begingroup\$

I made a few improvements to this based both on Konijn's suggestions and my own observations, although I never was quite able to reconcile the simulation design with any sort of MVC approach.

The most significant improvement is to the velocity algorithm.

Improving Readability (Variable and Function Names)

Only used ALL_CAPS for primitive and string constants, changing the canvas and the canvas context variable names to lowercase.

Changed most anonymous in-line functions (such as the event listeners attached to HTML controls) to named functions.

Renamed the ambiguous resetGravityField function to reinitializeGravityField

Changed the running field to isRunning to reflect its expected Boolean type value.

Removed some unnecessary intermediary variables pointed out by Konijn.

Improving Calculation Accuracy (Verlet Integration)

I incorporated the velocity verlet algorithm into the moveObjects function to produce a more accurate simulation than that achieved through the original Euler algorithm. This required me to track the acceleration of each object, rather than just its velocity, and incorporate a TIMESTEP variable.

The updated algorithm more accurately simulates stable orbits, and doesn't produce the wildly inaccurate results that the old algorithm does when objects are under high gravitational acceleration. It's still an imperfect simulation of gravity, but the stability is more natural.

Updated Simulation:

const G = 6.674 * Math.pow(10, -11), // gravitational constant
  MAX_RND_VEL = 5,
  MAX_RND_MASS = 5 * Math.pow(10, 12),
  MIN_RND_MASS = 50,
  MAX_RND_SIZE = 8,
  PI = Math.PI,
  TWOPI = Math.PI * 2,
  FADE_RATE = 4,
  CURSOR_LINE_COLOR = "cyan",
  OBJECT_BORDER_COLOR = "white",
  GRAVITY_FIELD_MARKER_COLOR = "rgb(150,200,150)",
  GRAVITY_FIELD_MARKER_RADIUS = 0.5,
  TIMESTEP = 1,
  MAX_FIELD_DISTANCE = 30*30,
  canvas = document.getElementById("canvas"),
  txtNumObjects = document.getElementById("NumObjects"),
  chkShowFabric = document.getElementById("ShowFabric"),
  ddlFabricGranularity = document.getElementById("FabricGranularity"),
  ddlRestitution = document.getElementById("Restitution"),
  ddlBorderBehavior = document.getElementById("BorderBehavior"),
  chkHandleCollisions = document.getElementById("chkCollisions"),
  btnNextStep = document.getElementById("NextStep"),
  txtDelay = document.getElementById("Delay");
const WIDTH = canvas.width,
  HEIGHT = canvas.height,
  DOUBLE_SIZE = MAX_RND_SIZE * 2,
  ctx = canvas.getContext("2d");
let numObjects = txtNumObjects.value,
  cellSize = ddlFabricGranularity.value,
  borderBehavior = ddlBorderBehavior.value,
  showFabric = chkShowFabric.checked,
  shouldHandleCollisions = chkHandleCollisions.checked,
  Cr = ddlRestitution.value, // coefficient of restitution
  delay = txtDelay.value,
  time = 0,
  stellarObjects = [],
  isRunning = false,
  isPlanting = false,
  plantX, plantY, mouseX, mouseY, // vars for adding new objects  
  gField = [],
  gHeight, gWidth; // vars for spacetime fabric visualization
let fadeMultiplier = cellSize / FADE_RATE;
ddlRestitution.addEventListener("change", changeCoefficientOfRestitution);
chkHandleCollisions.addEventListener("change", changeHandleCollisions);
canvas.addEventListener("mousedown", touchDown);
canvas.addEventListener("mousemove", touchMove);
canvas.addEventListener("mouseup", touchUp);
canvas.addEventListener("mouseout", stopPlanting);
ddlBorderBehavior.addEventListener("change", changeBorderBehavior)
document.getElementById("Reset").addEventListener("click",reset);
document.getElementById("NextStep").addEventListener("click", step);
ddlFabricGranularity.addEventListener("change",changeFabricGranularity);
chkShowFabric.addEventListener("change",changeShowFabric);
document.getElementById("AutoStep").addEventListener("click", toggleAutoStep);
txtDelay.addEventListener("change", changeDelay);
reset();
function changeCoefficientOfRestitution() {
  Cr = ddlRestitution.value;
}
function changeHandleCollisions() {
  shouldHandleCollisions = chkHandleCollisions.checked;
}
function stopPlanting() {
	isPlanting = false;
	if (!isRunning) {
		drawFieldAndObjects();
	}
}
function changeBorderBehavior() {
  borderBehavior = ddlBorderBehavior.value;
}
function changeFabricGranularity() {
  cellSize = ddlFabricGranularity.value;
  fadeMultiplier = cellSize / FADE_RATE;
  reinitializeGravityField();
  recalculateGravityField();
  drawFieldAndObjects();
}
function changeShowFabric() {
  showFabric = chkShowFabric.checked;
}
function toggleAutoStep() {
  if (isRunning) {
    clearTimeout(isRunning);
    btnNextStep.disabled = false;
    isRunning = null;
    this.value = "Auto";
  } else {
    btnNextStep.disabled = true;
    this.value = "Stop";
    isRunning = setTimeout(loopStep, delay);
  }
}
function changeDelay () {
	delay = +(txtDelay.value);
}
function touchDown(event) {
  if (event.which === 3 || event.button === 2) {
    event.preventDefault();
    return false;
  } else {
    isPlanting = true;
    plantX = event.pageX - canvas.offsetLeft;
    plantY = event.pageY - canvas.offsetTop;
    mouseX = plantX;
    mouseY = plantY;
  }
  event.preventDefault();
}

function touchMove(event) {
  if (isPlanting) {
    mouseX = event.pageX - canvas.offsetLeft;
    mouseY = event.pageY - canvas.offsetTop;
    if (!isRunning) {
     drawFieldAndObjects();
    }
    drawCursorLine();
  }
  event.preventDefault();
}

function touchUp(event) {
  if (event.which === 3 || event.button === 2) {
    removeObjectAt(event.pageX - canvas.offsetLeft, event.pageY - canvas.offsetTop);
    event.preventDefault();
    return false;
  } else {
    if (isPlanting) {
      let thing = getRandomStellarObject(),
        upX = event.pageX - canvas.offsetLeft,
        upY = event.pageY - canvas.offsetTop;
      if (typeof plantX === "undefined") {
        plantX = upX;
        plantY = upY;
      }
      thing.x = upX;
      thing.y = upY;
      thing.velocity.x = MAX_RND_VEL * MAX_RND_VEL * (plantX - upX) / WIDTH;
      thing.velocity.y = MAX_RND_VEL * MAX_RND_VEL * (plantY - upY) / HEIGHT;
      stellarObjects.push(thing);
      numObjects++;
      isPlanting = false;      
      applyObjectGravityToFabric(upX, upY, thing.mass * G);
      drawFieldAndObjects();      
    }
  }
  event.preventDefault();
}

// Clears all objects and regenerates them based on the chosen number of objects
function reset() {
  reinitializeGravityField();
  numObjects = +(document.getElementById("NumObjects").value);
  numObjects = numObjects < 0 ? 0 : numObjects >>> 0;
  stellarObjects = [];
  generateObjects(numObjects);
  step();
}

// Repeatedly advances time based on the chosen delay
function loopStep() {
  let startTime = +new Date,
    delta;
  step();
  if (delay === 0 || (delta = +new Date - startTime) >= delay) {
    isRunning = setTimeout(loopStep, 0);
  } else {
    isRunning = setTimeout(loopStep, delay - delta);
  }
}

// Advances time forward and recalculates all object positions/velocities as necessary, redrawing the canvas as necessary
function step() {
  ctx.clearRect(0, 0, WIDTH, HEIGHT);
  if(showFabric){
  	resetGravityFieldInPlace();
    moveObjects();
    drawGravityField();
  }else{
  	moveObjects();
  }
  drawObjects();
}

// adds the specified number of objects to the universe
function generateObjects(num) {
  for (let i = 0; i < num; i++) {
    stellarObjects.push(getRandomStellarObject());
  }
}

// Removes any stellar objects that overlap at the given xy coordinates
function removeObjectAt(x, y) {
  for (let i = stellarObjects.length - 1; i >= 0; i--) {
    let obj = stellarObjects[i];
    if (getDistance(obj.x - x, obj.y - y) <= obj.size) {
      stellarObjects.splice(i, 1);
    }
  }
  let newLength = stellarObjects.length;
  if (newLength != numObjects) {
    numObjects = newLength;
    ctx.clearRect(0, 0, WIDTH, HEIGHT);
    if (showFabric) {
      resetGravityFieldInPlace();
      recalculateGravityField();
      drawGravityField();
    }
    drawObjects();
  }
}

// Moves all objects based on their current velocities
function moveObjects() {
	time += TIMESTEP;
  if (shouldHandleCollisions) {
    for (let i = 0; i < numObjects; i++) {
      let o = stellarObjects[i];
      let ov = o.velocity, oa = o.acc;
      checkCollision(o, TIMESTEP*(ov.x + TIMESTEP*oa.x/2), TIMESTEP*(ov.y + TIMESTEP*oa.y/2), []);
    }
  }
  for (let i = 0; i < numObjects; i++) {
    let o = stellarObjects[i];
    let ov = o.velocity, oa = o.acc, ooa = o.oldAcc;
    let ovx = ov.x,ovy = ov.y, oax = oa.x, oay = oa.y;
    o.x += TIMESTEP*(ovx + TIMESTEP * oa.x/2);
    o.y += TIMESTEP*(ovy + TIMESTEP * oa.y/2);
    ooa.x = oax, ooa.y = oay;
    oa.x = 0, oa.y = 0;
    let ox = o.x,
      oy = o.y;
    if (borderBehavior !== "Unbounded") {
      if (borderBehavior === "Loop") {
        if (ox > WIDTH) {
          o.x = 0;
        } else if (ox < 0) {
          o.x = WIDTH;
        }
        if (oy > HEIGHT) {
          o.y = 0;
        } else if (oy < 0) {
          o.y = HEIGHT;
        }
      } else if (borderBehavior === "Ricochet") {
        if (ox > WIDTH || ox < 0) {
          o.velocity.x = ovx * -1;
        }
        if (oy > HEIGHT || oy < 0) {
          o.velocity.y = -1 * ovy;
        }
      } else if (borderBehavior === "HalfRicochet") {
        if (ox > WIDTH || ox < 0) {
          o.velocity.x = ovx * (-0.5);
          if (ox > WIDTH) {
            o.x = WIDTH;
          } else if (ox < 0) {
            o.x = 0;
          }
        }
        if (oy > HEIGHT || oy < 0) {
          o.velocity.y = (-0.5) * ovy;
          if (oy > HEIGHT) {
            o.y = HEIGHT;
          } else if (oy < 0) {
            o.y = 0;
          }
        }
      } else if (borderBehavior === "Annihilate") {
        if (ox > WIDTH || oy > HEIGHT || ox < 0 || oy < 0) {
          removeObjectAt(ox, oy);
        }
      }
    }
  }
  applyObjectsGravity();
  for (let i = 0; i < numObjects; i++) {
    let o = stellarObjects[i];
    o.velocity.x += TIMESTEP * (o.acc.x + o.oldAcc.x) / 2;
    o.velocity.y += TIMESTEP * (o.acc.y + o.oldAcc.y) / 2;
  }
}

// Checks whether two objects are about to collide, and adjusts their velocities if necessary
function checkCollision(obj, ovx, ovy, objectsToIgnore) {
  for (let i = 0; i < numObjects; i++) {
    let test = stellarObjects[i],
      shortCircuit = false;
    if (test === obj) {
      continue;
    }
    for (let j = 0, len = objectsToIgnore.length; j < len; j++) {
      if (test === objectsToIgnore[j]) {
        shortCircuit = true;
        break;
      }
    }
    if (shortCircuit) {
      continue;
    }
    let oa = obj.acc, ta = test.acc;
    let ox = obj.x + TIMESTEP*(ovx + TIMESTEP * oa.x/2),
      oy = obj.y + TIMESTEP*(ovy + TIMESTEP * oa.y/2),
      tv = test.velocity;
    let tvx = tv.x,
      tvy = tv.y;
    let tx = test.x + TIMESTEP*(tvx + TIMESTEP * ta.x/2),
      ty = test.y + TIMESTEP*(tvy + TIMESTEP * ta.y/2);
    let difx = tx - ox,
      dify = ty - oy;
    if (difx === 0 && dify === 0){
      continue;
    }
    let aSize = obj.size,
      bSize = test.size;
    let cumulativeSize = aSize + bSize;
    let distance = getDistance(difx, dify);
    if (distance < cumulativeSize) {
      handleCollision(obj, test, cumulativeSize, difx, dify, distance, ox, oy, tx, ty);
      checkCollision(test, tvx, tvy, [obj].concat(objectsToIgnore)); // objectsToIgnore prevents a "Night at the Roxbury" collision loop // [obj].concat(objectsToIgnore)
      checkCollision(obj, ovx, ovy, [test].concat(objectsToIgnore));
    }
  }
}

// Given two objects, their combined size, differences in their xy coordinates, distance, and their coordinates
// updates their coordinates to ensure the objects don't overlap and adjusts their velocities
function handleCollision(first, second, cumulativeSize, difX, difY, distance, x1, y1, x2, y2) {
  let mass1 = first.mass,
    mass2 = second.mass;
  let cumulativeMass = mass1 + mass2;
  let v1x = first.velocity.x,
    v1y = first.velocity.y,
    v2x = second.velocity.x,
    v2y = second.velocity.y;
  let v1 = Math.sqrt(v1x * v1x + v1y * v1y),
    v2 = Math.sqrt(v2x * v2x + v2y * v2y);
  let collisionAngle = Math.atan2(difY, difX);
  let dir1 = Math.atan2(v1y, v1x),
    dir2 = Math.atan2(v2y, v2x);
  let d1cA = dir1 - collisionAngle,
    d2cA = dir2 - collisionAngle;
  let newXv1 = v1 * Math.cos(d1cA),
    newYv1 = v1 * Math.sin(d1cA),
    newXv2 = v2 * Math.cos(d2cA),
    newYv2 = v2 * Math.sin(d2cA);
  let massVCalc = mass1 * newXv1 + mass2 * newXv2;
  let finalXv1 = (massVCalc + mass2 * Cr * (newXv2 - newXv1)) / cumulativeMass,
    finalXv2 = (massVCalc + mass1 * Cr * (newXv1 - newXv2)) / cumulativeMass,
    finalYv1 = newYv1,
    finalYv2 = newYv2;
  let cosAngle = Math.cos(collisionAngle),
    sinAngle = Math.sin(collisionAngle);
  first.velocity = {
    x: cosAngle * finalXv1 - sinAngle * finalYv1,
    y: sinAngle * finalXv1 + cosAngle * finalYv1
  };  
  second.velocity = {
    x: cosAngle * finalXv2 - sinAngle * finalYv2,
    y: sinAngle * finalXv2 + cosAngle * finalYv2
  };  
  // minimum translation difference to prevent overlaps:
  let dx = first.x - second.x,
    dy = first.y - second.y;
  if(dx === 0 && dy === 0){ // special case for shared centers of gravity, offsets objects in random directions before continuing
    	let xOffset = (Math.random()*1);
      let yOffset = (1 - xOffset)*(Math.random()*2 > 1 ? -1 : 1 );
      xOffset *= (Math.random()*2 > 1 ? -1 : 1 );
      first.x += xOffset / mass1;
      first.y += yOffset / mass1;
      second.x -= xOffset / mass2;
      second.y -= yOffset / mass2;
      dx = first.x - second.x;
      dy = first.y - second.y;
  }
  let d_squared = (dx * dx + dy * dy);
  if (d_squared <= cumulativeSize*cumulativeSize) {  	
    let d = Math.sqrt(d_squared);
    let mtd_multiplier = ((first.size + second.size - d) / d);
    let mtd_x = mtd_multiplier * dx;
    let mtd_y = mtd_multiplier * dy;
    let im1 = 1 / mass1,
        im2 = 1 / mass2;
    let cumIm = im1 + im2;
    let imCalc1 = (im1 / (cumIm)),
        imCalc2 = (im2 / (cumIm));
    first.x += mtd_x * imCalc1;
    first.y += mtd_y * imCalc1;
    second.x -= mtd_x * imCalc2;
    second.y -= mtd_y * imCalc2;
    first.acc = {x:0,y:0};
    first.oldAcc = {x:0,y:0};
		second.acc = {x:0,y:0};
    second.oldAcc = {x:0,y:0};
  }
}

// Applies an object's gravity to all other objects and to the gravity field fabric (if displayed)
function applyObjectsGravity() {
  for (let i = numObjects-1; i >= 0; i--) {
    let o = stellarObjects[i];
    let ox = o.x,
      oy = o.y,
      om = o.massEffect;
    applyObjectGravityToObjects(o, ox, oy, om, i);
    if (showFabric) {
      applyObjectGravityToFabric(ox, oy, om);
    }
  }
}

// Given an object, its xy coordinates, and its precalculated mass effect, applies given object's gravity to all other objects
function applyObjectGravityToObjects(stellarObject, x, y, massEffect, init) {
	let objAcc = stellarObject.acc, objOldAcc = stellarObject.oldAcc;
  for (let i = init; i >= 0; i--) {
    let currentTarget = stellarObjects[i];
    if (currentTarget !== stellarObject) {
      let targetX = currentTarget.x,
        targetY = currentTarget.y,
        targetME = currentTarget.massEffect,
        targetAcc = currentTarget.acc, targetOldAcc = currentTarget.oldAcc;
      let difY = y - targetY,
        difX = x - targetX;
      let distance = getDistance(difY, difX);
      if (distance !== 0) {
      	let distSqr = distance * distance;
        // F = G*m1*m2 / distance^2... acceleration = F / m... the current object's mass cancels out of Force equation to produce acceleration
        let accelTarg = massEffect / distSqr,
        	accelObj = targetME / distSqr;
        let yIsNegative = difY < 0;
        let theta = Math.atan(difX / difY);
        targetAcc.x += difX === 0 ? 0 : ((yIsNegative ? -1 : 1) * accelTarg * Math.sin(theta));
        targetAcc.y += difY === 0 ? 0 : ((yIsNegative ? -1 : 1) * accelTarg * Math.cos(theta));
        objAcc.x -= difX === 0 ? 0 : ((yIsNegative ? -1 : 1) * accelObj * Math.sin(theta));
        objAcc.y -= difY === 0 ? 0 : ((yIsNegative ? -1 : 1) * accelObj * Math.cos(theta));
      }
    }
  }
}

// For a given object (and its precalculated mass * the gravitational constant) adjusts the gravity field fabric accordingly
function applyObjectGravityToFabric(x, y, massEffect) {
	let xMeasure = x/cellSize, yMeasure = y/cellSize;
  for (let i = 0; i < gHeight; i++) {
    let row = gField[i];
    for (let j = 0; j < gWidth; j++) {
      let currentVector = row[j];
      let oX = currentVector[0],
        oY = currentVector[1],
        difX = xMeasure - j,
        difY = yMeasure - i;
      if(difX*difX + difY*difY < MAX_FIELD_DISTANCE ){
      let distance = getDistance(difX, difY);
      if ( distance !== 0) {
        let force = (massEffect) / (distance * distance);
        let xIsNegative = difX < 0,
          yIsNegative = difY < 0;
        let theta = Math.atan(difX / difY);
        currentVector[0] += difX === 0 ? 0 : ((yIsNegative ? -1 : 1) * force * Math.sin(theta));
        currentVector[1] += difY === 0 ? 0 : ((yIsNegative ? -1 : 1) * force *  Math.cos(theta));
      }
      }
    }
  }
}

// Warps the gravity field fabric based on the mass of each stellar object
function recalculateGravityField() {
  for (let i = 0; i < numObjects; i++) {
    let o = stellarObjects[i];
    applyObjectGravityToFabric(o.x, o.y, o.massEffect);
  }
}

// Resets all the gravity field fabric markers to their places
function resetGravityFieldInPlace() {
  for (let i = 0; i < gHeight; i++) {
    let row = gField[i];
    for (let w = 0; w < gWidth; w++) {
      row[w] = [0,0];
    }
  }
}

// Calculates the number of gravity field fabric markers based on granularity and initializes them
function reinitializeGravityField() {
  gField = [];
  let maxH = HEIGHT / cellSize,
    maxW = WIDTH / cellSize;
  for (let h = 0; h <= maxH; h++) {
    let row = [];    
    for (let w = 0; w <= maxW; w++) {
      row.push([0,0]);
    }
    gField.push(row);
  }
  gHeight = gField.length;
  gWidth = gField[0].length
}

function drawFieldAndObjects(){
  ctx.clearRect(0, 0, WIDTH, HEIGHT);
      if (showFabric) {
        drawGravityField();
      }
      drawObjects();
}

// draws all stellar objects
function drawObjects() {
  for (let i = 0; i < numObjects; i++) {
    drawObject(stellarObjects[i]);
  }
  if (isPlanting) {
    drawCursorLine();
  }
}

// draws a line from the cursor planted point to the cursor
function drawCursorLine() {
  ctx.beginPath();
  ctx.strokeStyle = CURSOR_LINE_COLOR;
  ctx.moveTo(plantX, plantY);
  ctx.lineTo(mouseX, mouseY);
  ctx.stroke();
  ctx.closePath();
}

// draws a stellar object on the canvas
function drawObject(o) {
  let x = o.x,
    y = o.y,
    radius = o.size;
  ctx.beginPath();
  ctx.arc(x, y, radius, 0, TWOPI);
  ctx.fillStyle = o.color;
  ctx.fill();
  ctx.strokeStyle = OBJECT_BORDER_COLOR;
  ctx.arc(x, y, radius, 0, TWOPI);
  ctx.stroke();
  ctx.closePath();
}

// Draws the markers for the gravity field fabric
function drawGravityField() {
  ctx.strokeStyle = GRAVITY_FIELD_MARKER_COLOR;
  for (let i = 0; i < gHeight; i++) {
    let row = gField[i], iMeasure = i * cellSize;
    for (let j = 0; j < gWidth; j++) {
      drawVectorDot(j * cellSize, iMeasure, row[j]);
    }
  }
}

// Draws a marker for the gravity field fabric, given the XY coordinates of the dot and a vector representing how much it's been warped
function drawVectorDot(x, y, vector) {
  let vx = vector[0],
    vy = vector[1];  
  ctx.globalAlpha = fadeMultiplier / Math.sqrt(vx*vx + vy*vy); // the farther the marker is pulled, the more it fades from view
  ctx.beginPath();
  ctx.arc(x + vx, y + vy, GRAVITY_FIELD_MARKER_RADIUS, 0, TWOPI);
  ctx.stroke();
  ctx.globalAlpha = 1;
}

// returns an object with random color, position, velocity, and mass/size
function getRandomStellarObject() {
  let randomMass = MIN_RND_MASS + Math.random() * (MAX_RND_MASS - MIN_RND_MASS);
  let scale = MAX_RND_SIZE / Math.pow(3 * MAX_RND_MASS / 4 / PI, 1 / 3); // size formula based on volume of a sphere
  let randomSize = Math.pow(3 * randomMass / 4 / PI, 1 / 3) * scale,
    randomColor = getRandomColor();
  let velocity = Math.random() * MAX_RND_VEL;
  let xComponent = Math.random() * velocity;
  return {
    color: randomColor,
    size: randomSize,
    mass: randomMass,
    x: WIDTH / 6 + (2 * WIDTH / 3 * Math.random()),
    y: HEIGHT / 6 + (2 * HEIGHT / 3 * Math.random()),
    velocity: {
      x: (Math.random() * 2 > 1 ? -1 : 1) * Math.sqrt(xComponent),
      y: (Math.random() * 2 > 1 ? -1 : 1) * Math.sqrt(velocity - xComponent)
    },
    acc:{
    	x:0,y:0,xh:0,yh:0
    },
    oldAcc: {
    	x:0,y:0
    },
    massEffect: randomMass * G
  }
}

// Returns the distance between two points given the difference between their x and y values
function getDistance(difX, difY) {
  return Math.sqrt(difX * difX + difY * difY);
}

function getRandomColor() {
  return "rgb(" + (Math.random() * 256 >>> 0) + "," + (Math.random() * 256 >>> 0) + "," + (Math.random() * 256 >>> 0) + ")";
}
#AutoStep {
  font-weight: bold;
}

canvas {
  cursor: crosshair;
  background-color: black;
  border: 1px solid black;
  vertical-align: text-top;
  -ms-touch-action: none;
}

input,
.controls {
  font-size: 12pt;
  font-family: Calibri;
}

input {
  padding: 2px;
}

.controls {
  text-align: center;
  background-color: #bfbfbf;
  display: inline-block;
  vertical-align: text-top;
  border: 1px solid black;
}

label {
  cursor: pointer;
}

.container {
  display: inline-block;
  width: 552px;
}

.controls .inner {
  display: inline-block;
}

.controls .section {
  text-align: left;
  background-color: #dfdfdf;
  border: 1px solid #9f9f9f;
  padding: 2px;
  margin: 1px;
  display: inline-block;
  vertical-align: text-top;
}

input[type="number"] {
  max-width: 2em;
}
<div class="container">
  <div class="controls">
    <div class="section">
      <div class="inner">
        Objects:
        <input id="NumObjects" type="number" value=3 />
      </div>
      <input type="button" id="Reset" value="Reset" />
    </div>
    <div class="section">
      <div class="inner">
        <input type="button" id="NextStep" value="Next" />
        <input type="button" id="AutoStep" value="Auto" />
      </div>
      <div class="inner">Delay:
        <select id="Delay">
          <options>
            <option value=0>none</option>
            <option value=10>10 ms</option>
            <option value=30 selected="selected">30 ms</option>
            <option value=60>60 ms</option>
            <option value=500>500ms</option>
            <option value=1000>1 sec</option>
          </options>
        </select>
      </div>
    </div>
    <div class="section">Border:
      <select id="BorderBehavior">
        <options>
          <option value="Annihilate">Annihilate</option>
          <option selected="selected" value="Unbounded">Unbounded</option>
          <option value="Loop">Loop</option>
          <option value="Ricochet">Ricochet</option>
          <option value="HalfRicochet">50% Ricochet</option>
        </options>
      </select>
    </div>
    <div class="section">
      <label>
        <input type="checkbox" id="ShowFabric" checked="checked" />Show Fabric</label>
      <div>Granularity:
        <select id="FabricGranularity">
          <options>
            <option value="5">5</option>
            <option value="7">7</option>
            <option value="10">10</option>
            <option value="13">13</option>
            <option value="15">15</option>
            <option value="20" selected="selected">20</option>
            <option value="30">30</option>
          </options>
        </select>
      </div>
    </div>

    <div class="section">
      <div class="inner">
        <label for="chkCollisions">
          <input type="checkbox" checked="checked" id="chkCollisions" />Handle Collisions</label>
      </div>
      <div>Restitution:
        <select id="Restitution">
          <options>
            <option value=1>1.0</option>
            <option value=0.95 selected="selected">0.95</option>
            <option value=0.9>0.9</option>
            <option value=0.8>0.8</option>
            <option value=0.7>0.7</option>
            <option value=0.6>0.6</option>
            <option value=0.5>0.5</option>
            <option value=0.4>0.4</option>
            <option value=0.3>0.3</option>
            <option value=0.2>0.2</option>
            <option value=0.1>0.1</option>
            <option value=0.0>0.0</option>
          </options>
        </select>
      </div>
    </div>

  </div>
  <canvas id="canvas" height="550" oncontextmenu="return false;" width="550">no canvas available</canvas>
</div>

\$\endgroup\$
2
\$\begingroup\$

Use scientific notation for numbers:

const G = 6.674e-11;

Use an automatic code formatter. Currently your code is indented inconsistently.

Remove redundant parentheses, such as in a = (b * (c)).

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.