Source: game.js

/**
 * Defines the main game controller object. Manages game state, user input and
 * game objects.
 */
class Game {
  /**
   * Creates a game
   *  @param {Object} canvas - The canvas element for rendering
   */
  constructor(canvas) {
    this._canvas = canvas;
    this._thisFrameTime = 0;
    this._lastFrameTime = 0;
    this._demo = false;

    this._setupGame();
    this._setupEvents();

    // Start the game loop
    window.requestAnimationFrame((time)=>this.loop(time));
  }

  // Internal function to setup user interaction and events
  _setupEvents() {
    // Bind events
    document.addEventListener("keydown", (event)=>this.keyDown(event));
    document.addEventListener("keyup", (event)=>this.keyUp(event));
    document.addEventListener("mousemove", (event)=>this.mouseMove(event));

    // Maps game functions to input commands
    this._actionMap = {
      left: {down: false, up: false},
      right: {down: false, up: false},
      demo: {down: false, up: false}
    };
    this._actionMap.left.action = (...args)=>this._paddle.moveLeft(...args);
    this._actionMap.right.action = (...args)=>this._paddle.moveRight(...args);
    this._actionMap.demo.action = ()=>this._demo=!this._demo;

    // Maps keys to game input commands
    this._keyMap = new Map();
    this._keyMap.set("ArrowLeft", "left");
    this._keyMap.set("ArrowRight", "right");
    this._keyMap.set("KeyD", "demo");
  }

  // Internal function, Creates the game objects and starts the game loop
  _setupGame() {
    // Setup game variables
    this._lives = 3;
    this._score = 0;
    this._won = false;

    // Ensure the canvas context width matches it's dom width
    this._canvas.width = this._canvas.scrollWidth;
    this._canvas.height = this._canvas.scrollHeight;
    // Create the screen bounding box
    this._bounds = new BoundingBox(0, 0, this._canvas.width, this._canvas.height);

    // Create the player paddle
    this._paddle = new Paddle(
      this,
      new BoundingBox(
        (this._canvas.width / 2) - 75,
        this._canvas.height - 30,
        150, 25
      ), 500
    );

    // Create the ball
    this._ball = new Ball(
      this,
      new BoundingBox(
        (this._canvas.width / 2) - 12.5, this._canvas.height - 55, 25, 25
      ),
      new Vector2D(150,-300)
    );

    // Calculate how many blocks per row
    /* The minimum width of a block is 100px with 2px margins.
       Calculate the maximum number of minimum sized blocks that
       can fit on a row */
    const blocksPerRow = Math.floor(this._canvas.width / 104);

    /* Calculate the actual width the blocks have to be to fill
       the screen */
    const blockWidth = (this._canvas.width / blocksPerRow) - 4;

    // Create the blocks
    this._blocks = new Array(3);
    for (let row = 0; row < 3; row++) {
      this._blocks[row] = new Array(blocksPerRow);

      for (let column = 0; column < blocksPerRow; column++) {
        this._blocks[row][column] = new Block(
          this,
          new BoundingBox(
            2 + (blockWidth + 4) * column,  // X position
            100 + (54 * row),               // Y position
            blockWidth, 50                  // Width and height
          )
        );
      }
    }
  }

  /**
   * Gets the paddle game object
   *  @return {Object} The current game's paddle object
   */
  get paddle() {
    return this._paddle;
  }
  /**
   * Gets the ball game object
   *  @return {Object} The current game's ball object
   */
  get ball() {
    return this._ball;
  }
  /**
   * Gets the blocks game object
   *  @return {Object} The current game's blocks object
   */
  get blocks() {
    return this._blocks;
  }

  // Internal Function. Starts the frame and logs the frame time
  _startFrame(time) {
    // Calculate the time since the last frame
    this._thisFrameTime = time - this._lastFrameTime;
    return this._thisFrameTime;
  }

  // Internal Function. Checks victory condition
  _checkVictory() {
    let blockCount = 0;
    for (let row = 0; row < this._blocks.length; row++) {
      for (let column = 0; column < this._blocks[row].length; column++) {
        blockCount += this._blocks[row][column].isAlive;
      }
    }
    this._won = (blockCount <= 0);
  }

  // Internal Function. Updates the game state on each frame
  _update(time) {
    if (this._lives > 0 && !this._won) {
      // React to user input
      if (this._actionMap.left.down) {
        this._actionMap.left.action(this._bounds, time);
      }
      if (this._actionMap.right.down) {
        this._actionMap.right.action(this._bounds, time);
      }
      if (this._actionMap.demo.down) {
        this._actionMap.demo.action();
      }
      // If in demo mode move the paddle to the ball
      if (this._demo) {
        const x = this._ball.dimensions.x - this._paddle.dimensions.width / 2;
        this._paddle.setPosInBounds(this._bounds, x);
      }
      // Move the ball
      this._ball.move(this._bounds, time);
      // Check for victory
      this._checkVictory();
    }
  }

  // Internal Function. Draws the frame to the screen
  _drawFrame(time) {
    // Get the drawing context
    const ctx = this._canvas.getContext("2d");
    // Clear the screen
    ctx.fillStyle = "black";
    ctx.rect(0,0,this._canvas.width,this._canvas.height);
    ctx.fill();

    // Set the screen draw colour
    ctx.fillStyle = "white";

    // Draw the blocks
    for (let row = 0; row < this._blocks.length; row++) {
      for (let column = 0; column < this._blocks[row].length; column++) {
        this._blocks[row][column].draw(ctx);
      }
    }

    // Draw the Paddle and Ball
    this._paddle.draw(ctx);
    // Hide the ball if the game isn't actively playing
    if (this._lives > 0 && !this._won) this._ball.draw(ctx);

    // Draw score
    ctx.font = "48px 'Press Start 2P'";
    ctx.textAlign = "start";
    ctx.fillText(this._score, 15, 58);

    // Draw remaining lives
    for (let i = 0; i < this._lives; i++) {
      let x = (this._canvas.width - 15) - (30 * i);
      ctx.beginPath();
      ctx.arc(x, 25, 12.5, 0, 2 * Math.PI);
      ctx.fill();
    }

    if (this._lives <= 0) {
      // Draw game lost text
      ctx.textAlign = "center";
      ctx.font = "32px 'Press Start 2P'";
      ctx.fillText("Game over!", this._canvas.width / 2, this._canvas.height / 2);
    } else if (this._won) {
      // Draw the game won text
      ctx.textAlign = "center";
      ctx.font = "32px 'Press Start 2P'";
      ctx.fillText("You Won!", this._canvas.width / 2, this._canvas.height / 2);
    }
  }
  // Internal Function. Ends this frame and logs end time
  _endFrame(time) {
    this._lastFrameTime = time;
  }

  /*
   * Game state
   */
  /**
   * Decrements the lives counter and sets the ball and paddle
   * back to default position.
   */
  loseALife() {
    this._lives--;
    if (this._lives > 0) {
      // Reset paddle position
      this._paddle.dimensions.x = (this._canvas.width / 2) - 75;
      this._paddle.dimensions.y = this._canvas.height - 30;
      // Reset the ball position and vector
      this._ball.dimensions.x = (this._canvas.width / 2) - 12.5;
      this._ball.dimensions.y = this._canvas.height - 55;
      this._ball.vector.x = 150;
      this._ball.vector.y = -300;
    }
  }

  /**
   * Increments the score counter
   */
  increaseScore() {
    this._score++;
  }

  /**
   * Runs the game loop. Should only be called via requestAnimationFrame
   *  @param {number} time - current time in miliseconds
   */
  loop(time) {
    // If the screen size has changed restart the game
    if (this._canvas.width != this._canvas.scrollWidth ||
        this._canvas.height != this._canvas.scrollHeight) {
      this._setupGame();
    // Otherwise _update the game state and draw the frame
    } else {
      // Time since the last frame in seconds
      let timeDelta = this._startFrame(time) / 1000;

        this._update(timeDelta);
        this._drawFrame(timeDelta);

      this._endFrame(time);
    }
    window.requestAnimationFrame((time)=>this.loop(time));
  }

  /*
   * Player interaction
   */
  keyDown(event) {
    const key = this._keyMap.get(event.code);
    if (key) {
      this._actionMap[key].down = true;
      this._actionMap[key].up = false;
    }
  }

  keyUp(event) {
    const key = this._keyMap.get(event.code);
    if (key) {
      this._actionMap[key].down = false;
      this._actionMap[key].up = true;
    }
  }

  mouseMove(event) {
    if (this._lives > 0 && !this._won && !this._demo) {
      const x = event.clientX - this._paddle.dimensions.width / 2;
      this._paddle.setPosInBounds(this._bounds, x);
    }
  }
}