anarchy.website / Gravity Development Log, Part 4
Toggle Dark Mode

Game Mechanics

By Una Ada, June 06, 2021

IX. Player Inputs

I’m in the final stretch now, at least as far as project requirements go. It’s time to work on the game loop! Going to start with being able to create new Celestial instances on a user input. To make sure the player can adjust the moon before it’s animated, I need to add in some conditionals on the physics methods (also throwing in some spice for upcoming sections):

// In Celestial.js
class Celestial {
  /** ...
   * @arg {boolean} [options.physical=false] Can the object be manipulated?
   * ...
   */
  constructor(options) {
    // ...
    /** @var {boolean} physical Can the object be manipulated? */
    this.physical = options.physical || false;
    // ...

// In Physics#updateVelocities and Physics#updatePositions
  (obj) =>
    obj instanceof Celestial &&
    obj.physical &&
    // ...

This is only on the velocity and position updates, I want to be able to check collisions with objects that aren’t Celestials. Honestly the rest of this is going to be really boring, it’s basically one-for-one writing out what’s in the pseudocode for the game loop regarding mouse events and, in turn, recreating what’s in the original code for the old project with adaptations to the new structure. Technically, I did it all in a single line… oops:

// Handle player input
mouse.isDown
  ? model.isCreating
    ? mouse.change.magnitude > 18
      // Set velocity if mouse outside threshold
      ? (model.newborn.velocity = mouse.change
          .copy()
          .scale(Game.SCALE.velocity)
          .reflectHorizontal()
          .reflectVertical())
      // Customize mass if mouse still in threshold
      : model.customMassAllowed &&
        (model.newborn.mass *= Game.SCALE.mass)
    // Create new Celestial on mouse down
    : model.health > 0 &&
      (model.isCreating = true) &&
      (model.newborn = new Celestial({
        mass: 1.35e23,
        position: this.view.origin
          .copy()
          .subtract(mouse.position)
          .subtract(this.view.offset)
          .scale(0 - this.view.scale),
        size: 0.54e8,
      })) &&
      model.scene.push(model.newborn)
  // Release newborn Celestial on mouse up
  : model.isCreating &&
    (model.newborn.physical = true) &&
    (model.health -= 1) &&
    (model.isCreating = false);

It was at this point that I realized I really, really need to just get this done, so everything from here one was pretty much thrown together in a frenzy and is far from what I want it to be. The rest of this blog entry will just be covering what I did1 to meet the requirements of the assignment in a single night; I will probably continue to work on this project in the future to make it look more like what I want it to be.

X. Areas

While a Celestial is a game object in the scene that acts as a physical object, there are other objects that will need to exist in the scene to define certain aspects in game play. Namely, I needed to be able to define some “Area” in the scene, or view in general, that could be used generally to handle game mechanics such as restricting the user input zone and setting win conditions if the player achieves the desired goal. To that end, I wrote a simple class to define an Area:

class Area {
  /**
   * Create a new scene area game object.
   * @arg {Point} position Position coordinates in meters.
   * @arg {Point} size Size in meters.
   * @arg {Object} options Optional parameters.
   * @arg {string} [options.name="Unnamed Area"] Display name.
   * @arg {string} [options.texture] The path of a texture for rendering.
   */
  constructor(position, size, options){
    this.name = options.name || "Unnamed Area";
    this.birth = +new Date();
    this.position = position;
    this.size = size;
    this.hitBox = "RECTANGLE";
    this.collisions = [];
    this.texture = options.texture || null;
  }
} 

X-1. Rendering

Note that Area#size is a Point rather than a number as in a Celestial#, because it is a rectangle defined by two points: $P_1$, the top-left corner (Area#position), and $P_2$, the bottom-right corner relative to $P_1$ (Area#size). Since this is quite different from Celestial instance styling, I also needed to add in some handling for the different GameData#scene element types in the Renderers like so:

// In Renderer.js
class Renderer {
  // ...
  getPosition(obj) {
    const origin = this.origin,
      position = obj.position.copy().scale(1 / this.scale),
      offset = obj instanceof Celestial ? obj.size / (this.scale * 2) : 0;
    return origin.add(position).subtract(offset);
  }
  // ...

// In DOMRenderer.js
class DOMRenderer extends Renderer {
  // ...
  render() {
    const scene = this.model.scene;
    scene.forEach(
      (obj) => {
        const elem = obj.element || this.generateElement(obj),
          style = elem.style,
          position = this.getPosition(obj);
        [style.left, style.top, style.width, style.height] =
          obj instanceof Celestial
            ? [
                `${position.x}px`,
                `${position.y}px`,
                `${obj.size / this.scale}px`,
                `${obj.size / this.scale}px`,
              ]
            : [
                `${position.x}px`,
                `${position.y}px`,
                `${obj.size.x / this.scale}px`,
                `${obj.size.y / this.scale}px`,
              ];
			    // ...
  generateElement(obj) {
    const type = obj instanceof Celestial ? "celestial" : "viewarea",
      element = document.createElement(type),
      cleanName = obj.name.replace(/\s+/g, "-").toLowerCase();
    element.classList.add(`gravity__${type}_${cleanName}`);
    // ...

X-2. Physics

I also needed to add collision detection for something that isn’t a circle… this is where I got really lazy. I decided that, fuck it, I don’t need to check for actual collisions in this instance, I just need to check that the center of the circle is somewhere in a rectangular area that is orthogonal to the axes. Essentially, I made it trivial by making assumptions, this is not a generalized method in the slightest:2

static circleInRectangle(a, b) {
  let p1 = a.position.copy(),
    p2 = p1.copy().add(a.size),
    c = b.position.copy();
  return c.x > p1.x && c.x < p2.x && c.y > p1.y && c.y < p2.y;
}

But, hey, it works for what I need it to do. Of course this was then references as Physics.INTERSECT__CHECKS.RECTANGLE.CIRCLE and a quick function to reverse the order of the arguments before calling it was used as ...CIRCLE.RECTANGLE. Easy. Though there was some extra debugging at this point to make sure that the physics would work even if there were objects in the scene that it wasn’t supposed to be considering, a simple matter of returning the accumulator without manipulation in Array.reduce().

Then I realized that this function would be necessary for checking if the user’s mouse is in an area, so I generalized it a bit in the opposite direction: pointInRectangle and updated the type of the arguments:

/**
 * @arg {Point} point
 * @arg {Area} rect
 * @returns {boolean}
 */
static pointInRectangle(point, rect) {
  let p1 = rect.position.copy(),
    p2 = p1.copy().add(rect.size);
  return point.x > p1.x && point.x < p2.x && point.y > p1.y && point.y < p2.y;
}

XI. Interface

Once again, this was just implementing the bare minimum: the game message, the “lives” counter, and a link back to the source code, all simply added into index.html:

<div id="lives">x attempts remaining</div>
<div id="message">loading...</div>
<div id="repo">
  <a href="https://github.com/una-ada/gravity">source code</a>
</div>

I did attempt to throw in a little bit of personality by using Una Script as the typeface for all this.3 Styling wasn’t really worth mentioning beyond that, just text size and position: absolute essentially.

Out of desperation, reference to these elements was written in the GameData module rather than DOMRenderer, thus why I wrote the tags out in the first place. Anyway, they were just cached in the constructor:

/** @var {HTMLElement} healthDisplay Remaining attempts display element. */
this.healthDisplay = document.querySelector("div#health");
/** @var {HTMLElement} message Element displaying game message. */
this.message = document.querySelector("div#message");

and then the health display was updated with a simple template string, since GameData#health is already handled:

model.healthDisplay.textContent = `${Math.max(
  0,
  model.health
)} lives remaining.`;

Though do note here that use of Math.max(); for some reason the GameData#health value ended up being -1 when all the attempts were used up, which was not ideal but I didn’t have time to go through and debug that in the game loop, 🙇🏻‍♀️.

The message display was handled similarly with a simple update of the cached element’s innerHTML using a new property called GameData#condition. This is also a shortcut, since in the future I’d like the game messages to be defined per level instead of in general like this. Doing it that way would’ve also continued along my hope to eventually add in different types of levels, but anyway, this all looks something like this:

// In GameData.js
class GameData {
  constructor() {
    // ...
    /** @var {string} condition Current game condition. */
    this.condition = "PLAY";
    // ...

// In DOMRenderer.js
class DOMRenderer extends Renderer {
  // ...
  render() {
    // ...
    model.message.innerHTML =
      model.condition === "PLAY"
        ? `Shoot the moon from the
          <span class="red">red</span> area into the
          <span class="blue">blue</span> area.`
        : model.condition === "WIN"
          ? `You win! Congratulations!`
              : `<span class="red">You lost! Oh no!</span>`;
    // ...

XII. Condition Checks

From there, I just needed to throw together some methods to check for certain win or loss conditions. I figured, given that it would be a bit of a detour and not absolutely necessary at this stage to add a a collision handler that would destroy the smaller Celestial# after two hit each other, the only loss condition that absolutely needed to be implemented for this to function as “minimum viable product” would be a time-based one.4

To make this easier, I first threw some getters into GameData for the Areas that would be game mechanic relevant. This is another instance of not very scalable methods being added in to make everything work right away:

/** @var {Area} target The level target area. */
get target() {
  return this.scene.find(
    (obj) => obj instanceof Area && obj.name.toLowercase() === "target"
  );
}
/** @var {Area} playArea The level play area. */
get playArea() {
  return this.scene.find(
    (obj) => obj instanceof Area && obj.name.toLowerCase() === "play area"
  );
}

These are pretty simple methods that just use Array.find() to search the GameData#scene for the properly named Areas. You could see how this would not work for any sort of deviation in level design from the current setup, but it’s what works for the moment!5

Restricting the player to only creating a new Celestial within the “play area” then was pretty easy to implement. Just add an extra condition that the mouse position is within the correct area to the player input handling in Game#loop:

let scaledClick;
model.condition === "PLAY" &&
  (mouse.isDown
    ? model.isCreating
      ? mouse.change.magnitude > 18
        // ...
      : // Create new Celestial on mouse down
        model.health > 0 &&
        Physics.pointInRectangle(
          (scaledClick = this.view.origin
            .copy()
            .subtract(mouse.position)
            .subtract(this.view.offset)
            .scale(0 - this.view.scale)),
          model.playArea
        ) &&
        // ...

This is saved into the variable scaledClick because it is checking the same Point that would be used to determine the Celestial#position, so saving it here means not repeating all of that later.

Finally, I actually added in the condition checks! The loss condition was already discussed, as far as why it is the way it is, but the way it was implemented is somewhat interesting, I suppose. Rather than using something boring like setTimeout, I used a comparison of the Celestial#birth timestamp with a +new Date() timestamp in the game loop, with a hardcoded 30 second (30e3ms) difference:

// Loss conditions
model.condition === "PLAY" &&
  model.health <= 0 &&
  new Date() - 30e3 >
    model.scene.slice().reverse()
      .find(
        (obj) =>
          obj instanceof Celestial && obj.name.toLowerCase() === "played"
      ).birth &&
  (model.condition = "LOSS");

This is only used when the health value is at or lower than 0, so the player has used up all attempts, and reverses a copy (.slice()) of the Model#scene array to find the last Celestial# created by the player.

The win condition might look somewhat more complicated, but it really isn’t. All I did was use an Array.reduce() on the Model#scene array to essentially implement the || operator over the results of checking if any of the “played” Celestial instances have collided with the “target” Area#:6

// Win conditions
model.condition === "PLAY" &&
  model.scene.reduce(
    (acc, obj) =>
      // Check played Celestial
      obj instanceof Celestial && obj.name === "played"
        ? obj.collisions.find(
            (hit) =>
              // Check if hit target
              hit.who instanceof Area &&
              hit.who.name.toLowerCase() === "target"
          ) || acc
        : acc,
    false
  ) &&
  (model.condition = "WIN");

With those two, annoyingly simplified, conditions in place, this could then be called a “game.” Thus, this project was complete enough to present in my course, with some fixing up of the README something like 15 minutes before presentations began, aha. Not that I’m happy with it in this state at all! I’m almost definitely going to come back later to add in some more graphics, info displays for mass and velocity, and hopefully level loading as well! Regardless, that’s all for now!

Footnotes

  1. I mean to say that I’m writing this in retrospect, I didn’t have time to write it out while I was working/panicking. ↩︎

  2. I’m actually not sure at this point how useful a more generalized method for detecting intersections between a rectangle and a circle would even be. Theoretically, I could say as long as the Celestial touches the Area defined as the target then the player wins the level, but using centers works just fine and doesn’t really contradict any intuitive understanding of how something like this would function. ↩︎

  3. I also quickly added in some actual use of the Celestial#texture property, if only to somewhat mirror the style of the original page. ↩︎

  4. This is primarily to not just skip out on handling more collisions, but also for objects that have flown far off screen or are in a somewhat stable orbit. In the future, the lattermost of these instances will be the only reason such a condition would need to exist. Though, in theory, there are alternative ways to handle that as well, probably. ↩︎

  5. Not entirely worth mentioning in the main narrative here, but this is also terribly handled in the renderer. Since there is absolutely no Area#texture implementation, the only way to distinguish between the target and the play area was to have DOMRenderer style them based on their names:

    obj instanceof Area &&
      (obj.name.toLowerCase() === "target"
        ? (element.style.backgroundColor = `rgba(0, 0, 255, 0.7)`)
        : obj.name.toLowerCase() === "play" &&
          (element.style.backgroundColor = `rgba(255, 0, 0, 0.7)`));
    

    ↩︎

  6. I’m making a point of listing these object so literally because the code itself does checks to make sure the “‘played’ Celestial” is both an instance of Celestial and has the name "played". Similarly that the “‘target’ Area#” is an Area# and has the name "target"↩︎