Source

browser/lib/Game.js

import { Player } from './Entity/Player';
import { Enemy } from './Entity/Enemy';
import { Wall } from './Entity/Wall';
import { Ammo } from './Entity/Pickup/Ammo';
import { Health } from './Entity/Pickup/Health';
import { Stamina } from './Entity/Pickup/Stamina';
import { Ballistics } from './Ballistics/Ballistics';
import { AudioFX } from './Audio/AudioFX';
import { Camera } from './Scene/Camera';
import { Map } from './Scene/Map';
import { config } from '../config';
import { randomColor } from '../util';
import Stats from 'stats.js';

/**
 * Represents the main game class responsible for managing game entities,
 * controls, and rendering.
 * @class
 * @category Game Admin
 */
export class Game
{
  /**
   * Create a new game instance.
   * 
   * @constructor
   * @param {Object} bridge - The execution context bridge e.g. "web" or IPC handler.
   * @param {Object} dispatcher - The dispatcher object for custom game events.
   * @param {Object} context - The canvas rendering context.
   */
  constructor (bridge, dispatcher, context) {
    /**
     * bridge - the execution context bridge
     * @type {string|Object}
     */
    this.bridge = bridge;

    /**
     * dispatcher - the game custom event dispatcher
     * @type {Object}
     */
    this.dispatcher = dispatcher;

    /**
     * frame - the current game frame ID
     * @type {number}
     */
    this.frame = null;

    /**
     * stopped - Whether or not the game loop is stopped
     * @type {boolean}
     */
    this.stopped = false;

    /**
     * handlers - the game handlers e.g. storage
     * @type {Object}
     */
    this.handlers = { storage: null };
    
    /**
     * context - the canvas rendering context
     * @type {CanvasRenderingContext2D}
     */
    this.context = context;

    /**
     * camera - the game camera
     * @type {Camera}
     */
    this.camera = new Camera(this.context);

    /**
     * keyboard - the game default keyboard configuration
     * @type {Objet}
     */
    this.keyboard = config.device.keyboard;

    /**
     * mouse - the game default mouse configuration
     * @type {Objet}
     */
    this.mouse = config.device.mouse;

    /**
     * map - the game map generator
     * @type {Map}
     */
    this.map = new Map();

    /**
     * currentLevel - the current game level
     * @type {number}
     */
    this.currentLevel = 1;
  
    /**
     * gameover - whether or not the current game is over
     * @type {boolean}
     */
    this.gameover = false;

    /**
     * levelPassed - whether or not the current level was passed
     * @type {Objet}
     */
    this.levelPassed = false;

    /**
     * overlay - the game-end overlay
     * @type {HTMLElement}
     */
    this.overlay = document.querySelector('.game-overlay');

    /**
     * Statistics counter for FPS
     * @type {Stats}
     */
    this.stats = new Stats();

    // Setup new game entities
    this.newGameEntities();

    const onResize = () => this.onResize(window.innerWidth, window.innerHeight);
    window.addEventListener('resize', onResize);
    onResize();

    this.onDispatch();

    this.createKeyboardMouseControls();
    this.createVolumeControls();
  }

  /**
   * Create new game entities and params.
   * 
   * @returns {void}
   */
  newGameEntities () {
    /**
     * player - the entity representing the player
     * @type {Player}
     */
    this.player = null;

    /**
     * entities - array containing all game entities
     * @type {array}
     */
    this.entities = [];

    /**
     * walls - array containing all wall entities
     * @type {array}
     */
    this.walls = [];

    /**
     * enemies - array containing all enemy entities
     * @type {array}
     */
    this.enemies = [];

    /**
     * ammoPickups - array containing all ammo pickup item entities
     * @type {array}
     */
    this.ammoPickups = [];
    
    /**
     * healthPickups - array containing all health pickup item entities
     * @type {array}
     */
    this.healthPickups = [];

    /**
     * staminaPickups - array containing all stamina pickup item entities
     * @type {array}
     */
    this.staminaPickups = [];

    /**
     * selectedWeaponIndex - the current selected mapped weapon index
     * @type {number}
     */
    this.selectedWeaponIndex = 0;

    /**
     * ballistics - game ballistics handler
     * @type {Ballistics}
     */
    this.ballistics = new Ballistics();
  }

  /**
   * Attach a handler to the game, e.g. a storage handler
   * 
   * @param {string} handler - the handler key
   * @param {Object} instance - the handler object
   * 
   * @returns {void}
   */
  attach (handler, instance) {
    this.handlers[handler] = instance;

    if (handler === 'storage') {
      // Signal to storage that an attached game instance exists
      this.handlers.storage.setGameInstanceAttached(true);
    }
  }

  /**
   * Start the game loop
   * 
   * @returns {void}
   */
  loop () {
    this.stats.begin();

    AudioFX.soundtrack();

    if (! this.stopped) {
      this.frame = null;
      this.onUpdate();
      this.onRender();
    }
  
    if (this.gameover) {
      this.displayGameEnd();
      this.restart();
    }

    this.stats.end();
  
    this.run();
  }


  /**
   * Run the game frames
   * 
   * @returns {void}
   */
  run () {
    if (! this.frame && ! this.stopped) {
      this.frame = requestAnimationFrame(this.loop.bind(this));
    }
  }

  /**
   * Restart the game
   * 
   * @returns {void}
   */
  restart () {
    if (this.levelPassed) {
      this.stop().then(async (stopped) => await this.start(stopped, true));
    } else {
      this.stop().then(async (stopped) => await this.start(stopped, false));
    }
  }

  /**
   * Start the game
   * 
   * @param {boolean} stopped - whether or not the game is stopped
   * @param {boolean} nextLevel - whether or not to start the next level
   * @param {number|null} savedLevel - whether or not to start from a saved level
   * 
   * @returns {void}
   */
  async start (stopped, nextLevel = false, savedLevel = null) {
    if (stopped && ! this.frame) {
      this.overlay.querySelector('h1').innerHTML = '';

      if (nextLevel) {
        ++this.currentLevel;
      }

      if (savedLevel) {
        this.currentLevel = savedLevel;
      }

      setTimeout(() => {
        this.overlay.style.display = 'none';
        this.setup({
          level: this.currentLevel
        }, true);
      }, 2500);
    }
  }

  /**
   * Pause the game
   * 
   * @returns {void}
   */
  async pause () {
    const hotkey = document.querySelector('span[data-hotkey="P"]');
    const state = document.querySelector('#game-pause-state');
    const cssclass = 'help-block__hotkey--active';
    if (! this.stopped) {
      this.stopped = true;
      cancelAnimationFrame(this.frame);
      hotkey.classList.add(cssclass);
      state.innerHTML = 'Game Paused';
    } else {
      hotkey.classList.remove(cssclass);
      state.innerHTML = 'Pause Game';
      this.stopped = false;
      this.frame = requestAnimationFrame(this.loop.bind(this));
    }

    return this.stopped;
  }


  /**
   * Stop the game
   * 
   * @returns {void}
   */
  async stop () {
    this.stopped = true;
    this.frame = null;
    cancelAnimationFrame(this.loop.bind(this));

    return this.stopped;
  }

  /**
   * Setup a new game
   * 
   * @param {Object} params
   * @param {number} params.level - the level to setup
   * @param {boolean} loop - whether or not to start the game loop immediately
   * 
   * @returns {void}
   */
  setup ({ level = 1 }, loop = false) {
    this.frame = null;
    this.stopped = false;
    this.gameover = false;
    this.currentLevel = level;
    this.levelPassed = false;
    
    this.newGameEntities();

    this.generateMap(level)
      .createPlayer()
      .createEnemies()
      .createWalls()
      .createAmmoPickups()
      .createHealthPickups()
      .createStaminaPickups();

    document.querySelector('#current-level').innerHTML = this.currentLevel;

    this.setWeaponHotKey();

    this.onResize(window.innerWidth, window.innerHeight);

    if (loop) {
      this.loop();
    }
  }

  /**
   * Handle all entity update events during the game loop.
   * 
   * @returns {void}
   */
  onUpdate () {
    this.camera.update(this.player, this.entities);
    this.ballistics.update(this);

    for (let i = 0; i < this.entities.length; i++) {
      if (this.canUpdateEntity(this.entities[i])) {
        this.entities[i].update(this);
      }

      if (this.entities[i].type === 'player') {
        if (this.isPlayerDead(this.entities[i])) {
          this.gameover = true;
        }
      }

      if (this.entities[i].type === 'enemy') {
        if (this.areAllEnemiesDead(this.entities[i])) {
          this.gameover = true;
          this.levelPassed = true;
        }
      }

      if (this.entities[i].type === 'pickup' && this.entities[i].markToDelete) {
        this.entities.splice(i, 1);
      }
    }

    document.querySelector('#enemies-remaining').innerHTML = this.enemies.length;
  }


  /**
   * Handles all entity render events during the game loop.
   * 
   * @returns {void}
   */
  onRender () {
    this.camera.newScene();
    this.camera.preRender(this.player);

    this.ballistics.render();
    
    for (let i = 0; i < this.entities.length; i++) {
      this.entities[i].render(this.context);
    }

    this.camera.postRender();
  }

  /**
   * Handles all custom dispatcher events during the game loop.
   * 
   * @returns {void}
   */
  onDispatch () {
    this.dispatcher.addEventListener('game:load', ({ save }) => {
      this.overlay.style.display = 'flex';
      this.overlay.querySelector('h1').innerHTML = 'Loading game...';
      this.stop().then(async (stopped) => {
        await this.start(stopped, false, save.level);
      });
    });
  }


  /**
   * Handles all resize events during the game loop.
   * 
   * @returns {void}
   */
  onResize (width, height) {
    this.context.canvas.width = width;
    this.context.canvas.height = height;
    
    this.camera.resize();
  }

  /**
   * Generate a new map based on the passed level index.
   * 
   * @param {number} levelIndex - the level to generate the map for
   * 
   * @returns {this}
   */
  generateMap (levelIndex = 0) {
    this.map.newMapConfiguration();
    this.map.generate(levelIndex);

    return this;
  }

  /**
   * Determines whether or not the given entity has an update() method implementation.
   * 
   * @param {Object} entity 
   * 
   * @returns {boolean}
   */
  canUpdateEntity (entity) {
    return typeof entity !== 'undefined' && typeof entity.update === 'function';
  }

  /**
   * Create a new player entity.
   * 
   * @returns {this}
   */
  createPlayer () {
    this.player = new Player(this.map.getPlayerPosition());
    this.entities.push(this.player);

    return this;
  }

  /**
   * Create new enemy entities.
   * 
   * @returns {this}
   */
  createEnemies () {
    for (let i = 0; i < this.map.getEnemyPositions().length; i++) {
      const enemy = new Enemy(this.map.getEnemyPositions()[i], {
        hands: randomColor(),
        feet:  randomColor(),
        torso: randomColor(),
      });

      this.entities.push(enemy);
      this.enemies.push(enemy);
    }

    return this;
  }

  /**
   * Create a new walls for the map.
   * 
   * @returns {this}
   */
  createWalls () {
    for (let i = 0; i < this.map.getWallPositions().length; i++) {
      const wall = new Wall(this.map.getWallPositions()[i], true);
      this.entities.push(wall);
      this.walls.push(wall);
    }

    return this;
  }

  /**
   * Create a new ammo pickup items for the map.
   * 
   * @returns {this}
   */
  createAmmoPickups () {
    for (let i = 0; i < this.map.getAmmoPickupPositions().length; i++) {
      const ammoPickup = new Ammo(this.map.getAmmoPickupPositions()[i]);
      this.entities.push(ammoPickup);
      this.ammoPickups.push(ammoPickup);
    }

    return this;
  }

  /**
   * Create a new health pickup items for the map.
   * 
   * @returns {this}
   */
  createHealthPickups () {
    for (let i = 0; i < this.map.getHealthPickupPositions().length; i++) {
      const healthPickup = new Health(this.map.getHealthPickupPositions()[i]);
      this.entities.push(healthPickup);
      this.healthPickups.push(healthPickup);
    }

    return this;
  }

  /**
   * Create a new stamina pickup items for the map.
   * 
   * @returns {this}
   */
  createStaminaPickups () {
    for (let i = 0; i < this.map.getStaminaPickupPositions().length; i++) {
      const staminaPickup = new Stamina(this.map.getStaminaPickupPositions()[i]);
      this.entities.push(staminaPickup);
      this.staminaPickups.push(staminaPickup);
    }

    return this;
  }

  /**
   * Determine whether or not the player is dead.
   * 
   * @param {Player} entity - the entity representing the player
   * 
   * @returns {boolean}
   */
  isPlayerDead (entity) {
    return entity.type === 'player' && entity.dead;
  }

  /**
   * Determine whether or not all enemies are dead based on the enemy entity's
   * allEnemiesDead property.
   * 
   * @param {Enemy} entity - the entity representing the enemy
   * 
   * @returns {boolean}
   */
  areAllEnemiesDead (entity) {
    return entity.type === 'enemy' && entity.allEnemiesDead;
  }

  /**
   * Display the game-end overlay.
   * 
   * @returns {void}
   */
  displayGameEnd () {
    const overlay = this.overlay;
    setTimeout(() => {
      if (this.levelPassed) {
        overlay.querySelector('h1').innerHTML = 'You Win!';
        overlay.classList.add('pass');
        overlay.classList.remove('fail');
      } else {
        overlay.querySelector('h1').innerHTML = 'You Died!';
        overlay.classList.remove('pass');
        overlay.classList.add('fail');
      }
      overlay.style.display = 'flex';
    }, 500);
  }

  /**
   * Set the active weapon hotkey for the UI.
   * 
   * @returns {void}
   */
  setWeaponHotKey () {
    const hotkeys = document.querySelectorAll('span.help-block__hotkey');
    const cssclass = 'help-block__hotkey--active';
    for (const key of hotkeys) {
      const { hotkey } = key.dataset;
      if (hotkey) {
        if (parseInt(hotkey) !== (this.selectedWeaponIndex+1)) {
          key.classList.remove(cssclass);
        } else {
          key.classList.add(cssclass);
        }
      }
    }

    this.ballistics.setEquippedWeaponDisplayInformation(
      this.selectedWeaponIndex
    );
  }

  /**
   * Create game keyboard-mouse controls and register event listeners.
   * 
   * @returns {void}
   */
  createKeyboardMouseControls () {
    window.addEventListener('keydown', async (e) => {
      if (e.key === 'p') await this.pause();
    });

    document.addEventListener('keydown', (event) => {
      switch (event.key) {
      case 'w': this.keyboard.up = true; break;
      case 's': this.keyboard.down = true; break;
      case 'a': this.keyboard.left = true; break;
      case 'd': this.keyboard.right = true; break;
      case 'h':
        this.player.refillHealth(config.pickups.health, false);
        break;
      case '1':
        this.selectedWeaponIndex = 0;
        this.setWeaponHotKey();
        break;
      case '2':
        this.selectedWeaponIndex = 1;
        this.setWeaponHotKey();
        break;
      case '3':
        this.selectedWeaponIndex = 2;
        this.setWeaponHotKey();
        break;
      case '4':
        this.selectedWeaponIndex = 3;
        this.setWeaponHotKey();
        break;
      case '5':
        this.selectedWeaponIndex = 4;
        this.setWeaponHotKey();
        break;
      case '.':
        this.handlers.storage.saveGame(this);
        break;
      case '*':
        this.toggleStats();
        break;
      }
    });
    
    document.addEventListener('keyup', (event) => {
      switch (event.key) {
      case 'w': this.keyboard.up = false; break;
      case 's': this.keyboard.down = false; break;
      case 'a': this.keyboard.left = false; break;
      case 'd': this.keyboard.right = false; break;
      }
    });
    
    document.addEventListener('mousemove', (event) => {
      this.mouse.x = event.clientX;
      this.mouse.y = event.clientY;
    });
    
    document.addEventListener('mousedown', () => {
      this.mouse.pressed = true;
    });
    
    document.addEventListener('mouseup', () => {
      this.mouse.pressed = false;
    });
  }

  /**
   * Create volume slider controls for game audio.
   * 
   * @returns {void}
   */
  createVolumeControls () {
    const sliders = document.querySelectorAll('.volume-slider');
    for (const slider of sliders) {
      slider.addEventListener('input', (e) => {
        const { target } = e;
        const value = (target.value - target.min) / (target.max - target.min);
        const percent = Math.round(value * 100);
        
        target.style.background = 'linear-gradient(to right, #50ffb0 0%, #50ffb0 ' +
          percent + '%, #fff ' + percent + '%, #fff 100%)';
      
        AudioFX.volume(target.dataset.control, value);

        if (this.handlers.storage) {
          setTimeout(() => {
            this.handlers.storage.setSetting('volumes', AudioFX.volumes, true);
          }, 1000);
        }
      });
    }
  }

  /**
   * Toggle the FPS stats counter.
   * 
   * @param {number} panel - 0 = fps, 1 = ms,  2 = mb, 3+ = custom
   * 
   * @returns {void}
   */
  toggleStats (panel = 0) {
    this.stats.showPanel(panel);

    if (! this.statsShown) {
      this.stats.domElement.style.cssText = 'position:absolute;top:0px;right:0px;';
      this.statsShown = true;
    } else {
      this.stats.domElement.style.cssText = 'display:none;';
      this.statsShown = false;
    }

    const stats = document.querySelector('.stats');
    if (stats) {
      stats.appendChild(this.stats.dom);
    }
  }
}