Source: state-loop.js

/**
 * @file Module that updates state according to clock ticks and user input
 */

/**
 * The animation loop and event handlers communicate via this global object
 * @global
 * @constant {Object} state
 * @property {Sprite[]} sprites - spaceship and 12 asteroids in list
 */
const state = { "sprites": []
              , "missiles": []
              , "lives": 3
              , "score": 0
              , "scale": 1.0
              , "noise": null
              };

/**
 * Local object used by handlers to message the loop
 * @constant {Object} inputStates
 * @property {Boolean} inputStates.isUp - Up arrow pressed or not
 * @property {Boolean} inputStates.isThrust - Don't want to add more rocket noise 60 times a second
 * @property {Boolean} inputStates.isLeft - Left arrow pressed or not
 * @property {Boolean} inputStates.isRight - Right arrow pressed or not
 * @property {Boolean} inputStates.isSpace - Space arrow pressed or not
 * @property {Boolean} inputStates.isLoaded - Space bar needs to be released between missiles
 */
const inputStates = { isUp: false
                    , isThrust: false
                    , isLeft: false
                    , isRight: false
                    , isSpace: false
                    , isLoaded: true
                    };

const button2key = { "pointerdown": "keydown"
                   , "pointerup": "keyup"
                   , "leftButton": "ArrowLeft"
                   , "rightButton": "ArrowRight"
                   , "upButton": "ArrowUp"
                   , "spaceBar": "Spacebar"
                   };

const command = { "keydown": { get ArrowLeft() {return turnLeft(true)}
                             , get ArrowRight() {return turnRight(true)}
                             , get ArrowUp() {return burnRocket(true)}
                             , get Spacebar() {return fireMissile(true)}
                             }
                , "keyup":   { get ArrowLeft() {return turnLeft(false)}
                             , get ArrowRight() {return turnRight(false)}
                             , get ArrowUp() {return burnRocket(false)}
                             , get Spacebar() {return fireMissile(false)}
                             }
                };

// Sprite movement constants
const THRUST_SPEED = 0.1;
const RECOIL = -0.05;
const ROTATE_RATE = 60;
const MISSILE_SPEED = 4;

function collisions(sprite1, type) {
  if (type === "missile") {
    return state.missiles.filter((sprite2) => sprite2.type === type &&
      Math.hypot(sprite1.xCentre - sprite2.xCentre, sprite1.yCentre - sprite2.yCentre) <
        (state.scale * (sprite1.radius + sprite2.radius)));
  }
  return state.sprites.filter((sprite2) => sprite2.type === type &&
    Math.hypot(sprite1.xCentre - sprite2.xCentre, sprite1.yCentre - sprite2.yCentre) <
      (state.scale * (sprite1.radius + sprite2.radius)));
}

// Duplicated from game1.js, check if can be obtained by self.random_distance
function random_distance(sprite, r2, ratio) {
  const x1 = sprite.xCentre + sprite.xDelta;
  const y1 = sprite.yCentre + sprite.yDelta;
  const r1 = sprite.radius;
  const minDistance = ratio * state.scale * (r1 + r2);
  let x2;
  let y2;
  let d;
  do {
    x2 = Math.random() * (canvas.width + 1);
    y2 = Math.random() * (canvas.height + 1);
    d = Math.hypot(x2 - x1, y2 - y1);
  } while (d < minDistance);
  return [x2, y2];
}

/**
 * Prototype of each "thing" on the canvas
 * @namespace {Object} Sprite
 * @property {"spaceship"|"asteroid"|"missile"|"explosion"} type -- key shared with images and sounds dictionaries
 */

/**
 * Initialises a spaceship Sprite
 * @function
 * @returns {Sprite} A spaceship starting in the centre, facing up, stationary
 */
function createSpaceship() {
  return { type: "spaceship"
         , width: 90
         , height: 90
         , row: 0
         , column: 0
         , xCentre: canvas.width/2
         , yCentre: canvas.height/2
         , xDelta: 0
         , yDelta: 0
         , radius: 35
         , angle: -Math.PI/2
         , angleDelta: 0
         , tick: 0
         , lifespan: Infinity
         };
}

/**
 * Initialises an asteroid Sprite
 * @function
 * @returns {Sprite} Selects random start (not on top of spaceship), speed and rotation
 */
function createAsteroid() {
  let [x, y] = random_distance(state.sprites[0], 40, 1.5);
  const velocity = state.scale * (Math.random() - 0.5);
  const direction = Math.random() * 2 * Math.PI;
  return { type: "asteroid"
         , width: 90
         , height: 90
         , row: 0
         , column: 0
         , xCentre: x
         , yCentre: y
         , xDelta: velocity * Math.cos(direction)
         , yDelta: velocity * Math.sin(direction)
         , radius: 40
         , angle: Math.random() * 2 * Math.PI
         , angleDelta: (Math.random() - 0.5) * Math.PI/ROTATE_RATE
         , tick: 0
         , lifespan: Infinity
         };
}

/**
 * Initialises a missile Sprite, shoots from tip of spaceship
 * @function
 * @returns {Sprite} Missile shot from spaceship, lives 2 seconds (120 ticks)
 */
function createMissile() {
  let spaceship = state.sprites[0];
  return { type: "missile"
         , width: 10
         , height: 10
         , row: 0
         , column: 0
         , xCentre: spaceship.xCentre + (state.scale * spaceship.height/2 * Math.cos(spaceship.angle))
         , yCentre: spaceship.yCentre + (state.scale * spaceship.height/2 * Math.sin(spaceship.angle))
         , xDelta: spaceship.xDelta + (state.scale * MISSILE_SPEED * Math.cos(spaceship.angle))
         , yDelta: spaceship.yDelta + (state.scale * MISSILE_SPEED * Math.sin(spaceship.angle))
         , radius: 3
         , angle: spaceship.angle
         , angleDelta: 0
         , tick: 0
         , lifespan: 120
         };
}

function explode(sprite, lifespan) {
  sprite.was = sprite.type;
  sprite.type = "explosion";
  sprite.width = 128;
  sprite.height = 128;
  sprite.row = 0;
  sprite.column = 0;
  sprite.tick = 0;
  sprite.lifespan = lifespan;
}

function unexplode(sprite) {
  switch (sprite.was) {
    case "spaceship":
      sprite.type = "spaceship";
      sprite.width = 90;
      sprite.height = 90;
      sprite.row = 0;
      sprite.column = 0;
      sprite.tick = 0;
      sprite.lifespan = Infinity;
      return;
    case "asteroid":
      Object.assign(sprite, createAsteroid());
      return;
  }
}

/**
 * The physics/game engine
 * @function nextTick
 * @param {Sprite} sprite Position and movement parameters to be updated
 * @returns {undefined} Mutates the sprite object
 */
function nextTick(sprite) { // best to bring whole object
  let hitlist = [];
  sprite.xCentre += sprite.xDelta;
  sprite.yCentre += sprite.yDelta;
  sprite.angle += sprite.angleDelta;
  // space is toroidal
  if (sprite.xCentre < 0) {
    sprite.xCentre = canvas.width;
  }
  if (sprite.xCentre > canvas.width) {
    sprite.xCentre = 0;
  }
  if (sprite.yCentre < 0) {
    sprite.yCentre = canvas.height;
  }
  if (sprite.yCentre > canvas.height) {
    sprite.yCentre = 0;
  }
  // specific to type updates
  switch (sprite.type) {
    case "spaceship":
      if (inputStates.isLeft) {
        sprite.angleDelta = -Math.PI/ROTATE_RATE;
      }
      if (inputStates.isRight) {
        sprite.angleDelta = Math.PI/ROTATE_RATE;
      }
      if (!inputStates.isRight && !inputStates.isLeft) {
        sprite.angleDelta = 0;
      }
      if (inputStates.isUp) {
        sprite.column = 1;
        sprite.xDelta = sprite.xDelta + (state.scale * THRUST_SPEED * Math.cos(sprite.angle));
        sprite.yDelta = sprite.yDelta + (state.scale * THRUST_SPEED * Math.sin(sprite.angle));
      } else {
        sprite.column = 0;
      }
      hitlist = collisions(sprite, "asteroid");
      if (hitlist.length > 0) {
        if (inputStates.isThrust === true) {
          state.noise = "thrustStop";
          inputStates.isThrust = false;
        }
        state.noise = "explosion";
        state.lives--;
        explode(sprite, 30);
        hitlist.forEach((sprite2) => explode(sprite2, 120));
      }
      return;
    case "asteroid":
      hitlist = collisions(sprite, "missile");
      if (hitlist.length > 0) {
        state.noise = "explosion";
        state.score++;
        hitlist[0].tick = hitlist[0].lifespan; // only the first missile kills and gets killed
        explode(sprite, 60);
      }
      return;
    case "explosion":
      sprite.column = Math.floor((sprite.tick/sprite.lifespan) * 24);
      if ((sprite.lifespan - 1) === sprite.tick) {
        unexplode(sprite, state.sprites[0], canvas.width, canvas.height, state.scale);
      }
      sprite.tick++;
      return;
    case "missile":
      sprite.tick++;
      return;
  }
}


function turnLeft(bool) {
  inputStates.isLeft = bool;
}

function turnRight(bool) {
  inputStates.isRight = bool;
}

function burnRocket(bool) {
  inputStates.isUp = bool;
  if (inputStates.isUp && !inputStates.isThrust) {
    state.noise = "thrustStart";
    inputStates.isThrust = true;
  }
  if (!inputStates.isUp && inputStates.isThrust) {
    state.noise = "thrustStop";
    inputStates.isThrust = false;
  }
}

function fireMissile(bool) {
  inputStates.isSpace = bool;
  if (inputStates.isSpace && inputStates.isLoaded) {
    state.missiles.push(createMissile());
    state.sprites[0].xDelta = state.sprites[0].xDelta
      + (state.scale * RECOIL * Math.cos(state.sprites[0].angle));
    state.sprites[0].yDelta = state.sprites[0].yDelta
      + (state.scale * RECOIL * Math.sin(state.sprites[0].angle));
    state.noise = "missile";
    inputStates.isLoaded = false;
  }
  if (!inputStates.isSpace) {
    inputStates.isLoaded = true;
  }
}

/**
 * The user input event handler
 * @function uiListener
 * @param {KeyboardEvent|PointerEvent} event - Object sent by target.addEventListener(type, (event) => uiListener(inputStates, event));
 * @returns {undefined} Mutates the inputStates global object
 * @property {DOMString} event.type - Inherited from [Event]{@link https://developer.mozilla.org/en-US/docs/Web/API/Event#Properties}
 * @property {DOMString} event.key - A [Key Value]{@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values}
 * @property {DOMString} event.target - document since that's what called this function
 */
function uiListener(event) {
  if (["keydown", "keyup"].includes(event.type)) {
    if (event.key === " ") {
      command[event.type]["Spacebar"];
    }
    command[event.type][event.key];
    return;
  }
  if (["pointerdown", "pointerup"].includes(event.type)) {
    command[button2key[event.type]][button2key[event.target.id]];
    return;
  }
  if (event.key === "F12") { // allow debug screen
    event.target.dispatchEvent(event);
    return;
  }
  if (!Object.values(button2key).includes(key)) {  // pressing "non-game" key froze game
    event.preventDefault();
    return;
  }
}

function initState() {
  state.sprites[0] = createSpaceship();
  for (let idx = 1; idx <= 13; idx++) {
    state.sprites[idx] = createAsteroid();
  }
}

function updateState() {
  state.sprites.forEach((sprite) => nextTick(sprite));
  state.missiles.forEach((missile) => nextTick(missile));
  state.missiles = state.missiles.filter((missile) => missile.tick < missile.lifespan);
}

// export { initState, updateState, uiListener };