InputManager.js

import { Command } from '@ud-viz/game_shared';

/**
 * @callback EventCallback
 * @param {Event} event - native event
 */

/**
 * @callback CommandCallback
 * @returns {Command}
 */

/**
 * @class
 */
export class InputManager {
  /**
   * Manage user inputs with a poll system (https://en.wikipedia.org/wiki/Polling_(computer_science))
   *
   * @param {boolean} [isCaseSensitive=false] true so that the inputs are case sensitive
   */
  constructor(isCaseSensitive = false) {
    /**
     * register callback associated to an event + command id
     *
      @type {Object<string,Object<string,function(MouseState):Command>>} */
    this.mouseCommands = {};

    /**
     * store state of the mouse
     *
      @type {MouseState} */
    this.mouseState = new MouseState();

    /**
     * avoid a command to be computed two times if multiple keys are attach to it
     *
      @type {Object<string,boolean>} */
    this.commandsBuffer = {};

    /**
     * register if a key is pressed or not
     *
      @type {Object<string,boolean>} */
    this.keyMap = {};

    /**
     * register if a key state is down
     *
      @type {string[]} */
    this.keyMapKeyDown = [];

    /**
     * register if a key state is up
     *
      @type {string[]} */
    this.keyMapKeyUp = [];

    /**
     * register a callback associated to a key event
     *
      @type {CommandCallback}  */
    this.keyCommands = {};

    /**
     * true so that the inputs are case sensitive
     *
     * @type {boolean}
     */
    this.isCaseSensitive = isCaseSensitive;

    /**
     * register listeners to dispose them
     *
      @type {Array<{element:HTMLElement,id:string,cb:EventCallback,listener:EventCallback}>} */
    this.listeners = [];

    /**
     * element catching mouse event
     *
      @type {HTMLElement} */
    this.element = null;

    /**
     * if true {@link EventCallback} and {@link CommandCallback} are not called
     *
      @type {boolean} */
    this.pause = false;
  }

  /**
   *
   * @param {string} key - key id
   * @returns {boolean} - true if the key state is down
   */
  isKeyDown(key) {
    return this.keyMapKeyDown.includes(key);
  }

  /**
   *
   * @param {string} key - key id
   * @returns {boolean} - true if the key state is up
   */
  isKeyUp(key) {
    return this.keyMapKeyUp.includes(key);
  }

  /**
   * @param {string} key - keyboard input
   * @returns {boolean} - returns true if the key is a letter
   */
  isKeyALetter(key) {
    return key && key.length == 1 && key.toLowerCase() != key.toUpperCase();
  }

  /**
   * Converts a key to lowercase if case sensitivity is not required.
   *
   * @param {string} key - keyboard input
   * @returns {string} the input key in lowercase if the `isCaseSensitive` is `false` and the input key is a letter. Returns the base key otherwise.
   */
  controlSensitivity(key) {
    if (this.isCaseSensitive) return key;

    return this.isKeyALetter(key) ? key.toLowerCase() : key;
  }

  /**
   *
   * @param {boolean} pause - new inputmanager pause value
   */
  setPause(pause) {
    this.pause = pause;
  }

  /**
   * Used this if a key has not been register in addKeyCommand and you need to know if it's isPressed
   *
   * @param {string[]} keys - ids of the key to listen to
   */
  listenKeys(keys) {
    keys.forEach((k) => {
      this.keyMap[k] = false;
    });
  }

  /**
   * Return true if the key is pressed, dont forget to listenKeys if no addKeyCommand has been used for this key
   *
   * @param {string} key - key id
   * @returns {boolean} - true if pressed, false otherwise
   */
  isPressed(key) {
    return this.keyMap[key];
  }

  /**
   * Register a callback for a particular key and event
   *
   * @param {string|null} key - id of the key if null every key trigger the event
   * @param {string} eventID - id of the event (keyup, keydown)
   * @param {EventCallback} cb - callback called for this event
   */
  addKeyInput(key, eventID, cb) {
    key = this.controlSensitivity(key);

    const listener = (event) => {
      const eventKey = this.controlSensitivity(event.key);
      if ((key == eventKey || key == null) && !this.pause) cb(event);
    };
    window.addEventListener(eventID, listener);
    // Register to dispose it
    this.listeners.push({
      element: window,
      cb: cb, // Keep it to make easier the remove
      id: eventID,
      listener: listener,
    });
  }

  /**
   * Add a command for severals keys
   *
   * @param {string} commandID - command id
   * @param {string[]} keys - keys id assigned
   * @param {function():Command} cb - callback called
   */
  addKeyCommand(commandID, keys, cb) {
    this.commandsBuffer[commandID] = false; // Avoid to stack multiple commands if two key of keys are pressed
    keys.forEach((key) => {
      key = this.controlSensitivity(key);

      if (this.keyCommands[key] != undefined) {
        console.error(key, ' is already assign');
        return;
      }

      // Init keymap
      if (this.keyMap[key] == undefined) this.keyMap[key] = false;

      this.keyCommands[key] = () => {
        if (this.commandsBuffer[commandID]) {
          // command have been already produce by another key associated
          return null;
        }
        const cmd = cb(); // The callback must return a command (don't know why jsdoc imply cmd is a function there ??)
        if (cmd) {
          this.commandsBuffer[commandID] = true;
          return cmd;
        }
        return null;
      };
    });
  }

  /**
   * Dispose a command associated to keys
   *
   * @param {string} commandID - id command
   * @param {string[]} keys - keys id
   */
  removeKeyCommand(commandID, keys) {
    delete this.commandsBuffer[commandID];
    keys.forEach((key) => {
      delete this.keyCommands[key];
      delete this.keyMap[key];
    });
  }

  /**
   * Add a command for mouse
   *
   * @param {string} commandID - id of the command
   * @param {string} eventID - id of the mouse to listen to
   * @param {function(Event):Command} cb - callback called at event
   */
  addMouseCommand(commandID, eventID, cb) {
    if (!this.mouseCommands[eventID]) {
      this.mouseCommands[eventID] = {}; // Init
    }
    if (this.mouseCommands[eventID][commandID])
      console.warn('there is already cb ' + commandID, eventID);
    this.mouseCommands[eventID][commandID] = cb;
  }

  /**
   *
   * @param {string} commandID - command id
   * @param {string} eventID - mouse event id {@link MOUSE_STATE_EVENTS}
   */
  removeMouseCommand(commandID, eventID) {
    if (!this.mouseCommands[eventID][commandID])
      console.warn('nothing to remove ', commandID, eventID);
    delete this.mouseCommands[eventID][commandID];
  }

  /**
   * Register a callback for a particular mouse event
   *
   * @param {HTMLElement} element - element listened
   * @param {string} eventID - id of the event (mousedown, mouseup, mousemove)
   * @param {EventCallback} cb - callback called for this event
   */
  addMouseInput(element, eventID, cb) {
    const listener = (event) => {
      if (!this.pause) {
        cb(event);
      }
    };

    element.addEventListener(eventID, listener);
    // Register to dispose it
    this.listeners.push({
      element: element,
      cb: cb, // Keep it to make easier the remove
      id: eventID,
      listener: listener,
    });
  }

  /**
   * Start listening
   *
   * @param {HTMLElement} element - element listened by mouse
   */
  startListening(element) {
    if (this.element) {
      this.dispose(); // was listening dispose old listener and start listening this element
    }

    this.element = element;

    // Start listening key state
    const keydown = (event) => {
      const key = this.controlSensitivity(event.key);

      if (this.keyMap[key] == false) {
        this.keyMap[key] = true;
        this.keyMapKeyDown.push(key);
      }
    };
    window.addEventListener('keydown', keydown);
    this.listeners.push({ element: window, listener: keydown, id: 'keydown' });

    const keyup = (event) => {
      const key = this.controlSensitivity(event.key);

      if (this.keyMap[key] == true) {
        this.keyMap[key] = false;
        this.keyMapKeyUp.push(key);
      }
    };
    window.addEventListener('keyup', keyup);
    this.listeners.push({ element: window, listener: keyup, id: 'keyup' });

    // Start listening mouse state
    this.mouseState.startListening(element);

    this.initPointerLock();
  }

  /**
   * Initialize pointer lock management
   * On keypress keyup and click event try to request pointer lock on this.element if this.pointerLock is true
   */
  initPointerLock() {
    this.element.requestPointerLock =
      this.element.requestPointerLock || this.element.mozRequestPointerLock;
    document.exitPointerLock =
      document.exitPointerLock || document.mozExitPointerLock;

    // Gesture require to enter the pointerLock mode are click mousemove keypress keyup
    const checkPointerLock = () => {
      if (this.pointerLock && this.element) {
        try {
          // Enter pointerLock
          this.element.requestPointerLock();
        } catch (error) {
          console.error('cant request pointer lock');
        }
      }
    };
    //
    this.addKeyInput(null, 'keypress', checkPointerLock);
    this.addKeyInput(null, 'keyup', checkPointerLock);
    this.addMouseInput(this.element, 'click', checkPointerLock);
  }

  /**
   * If value is true pointerLock mode is activated else it's exited
   *
   * @param {boolean} value - new pointerlock value
   */
  setPointerLock(value) {
    this.pointerLock = value;
    if (!value) document.exitPointerLock(); // Exit since this not require a gesture
  }

  /**
   *
   * @returns {boolean} - input manager pointer lock value
   */
  getPointerLock() {
    return this.pointerLock;
  }

  /**
   * Remove event listener with the callback used to register it
   *
   * @param {EventCallback} cb - listener pass at the registration
   */
  removeInputListener(cb) {
    for (let index = 0; index < this.listeners.length; index++) {
      const o = this.listeners[index];
      if (o.cb == cb) {
        o.element.removeEventListener(o.id, o.listener);
        this.listeners.splice(index, 1);
        break;
      }
    }
  }

  /**
   * Remove listeners and reset variables
   */
  dispose() {
    this.listeners.forEach(function (l) {
      l.element.removeEventListener(l.id, l.listener);
    });
    this.mouseState.dispose();

    // Reset variables
    this.keyMap = {};
    this.keyCommands = {};
    this.mouseCommands = {};
    this.commandsBuffer = {};
    this.listeners = [];
    this.element = null;
  }

  /**
   * Compute Commands with the last state stored of keys and mouse
   *
   * @returns {Command[]} - commands computed
   */
  computeCommands() {
    if (this.pause) return []; // If pause no command should be return

    const result = [];

    // Compute key commands
    for (const id in this.keyCommands) {
      // Notify on down press and up
      if (this.keyMap[id] || this.isKeyUp(id)) {
        const cmd = this.keyCommands[id]();
        if (cmd) result.push(cmd);
      }
    }

    // Compute mouse commands
    for (const eventID in this.mouseCommands) {
      if (this.mouseState.isTrigger(eventID)) {
        const map = this.mouseCommands[eventID];

        for (const commandID in map) {
          const cmd = map[commandID].call(
            undefined, // no context pass
            this.mouseState.mouseEvent[eventID]
          );
          if (cmd) result.push(cmd);
        }
      }
    }

    // Reset
    this.mouseState.reset();
    for (const id in this.commandsBuffer) {
      this.commandsBuffer[id] = false;
    }

    /**
     * @todo maybe this is not the right place to do this (try in keyup event keydown event maybe keymap is useless)
     */
    this.keyMapKeyDown.length = 0;
    this.keyMapKeyUp.length = 0;

    return result;
  }

  /**
   *
   * @returns {HTMLElement} - input manager element
   */
  getElement() {
    return this.element;
  }
}

/**
 * List of mouse event handle by MouseState
 */
const MOUSE_STATE_EVENTS = {
  MOUSE_UP: 'mouseup',
  MOUSE_DOWN: 'mousedown',
  MOUSE_MOVE: 'mousemove',
  MOUSE_CLICK: 'click',
};

/**
 * @class
 */
export class MouseState {
  /**
   * Poll system (https://en.wikipedia.org/wiki/Polling_(computer_science))
   * Listen to the MOUSE_STATE_EVENTS and store the mouse state to then be access synchronously
   */
  constructor() {
    /**
     * register if a mouse event is trigger or not
     *
      @type {Object<string,boolean>} */
    this.mouseMap = {};

    /**
     * register event native js to pass it later synchronously
     *
      @type {Object<string,Event>} */
    this.mouseEvent = {};

    /**
     * true if the mouse is dragging
     *
      @type {boolean} */
    this.dragging = false;

    /**
     * register all listeners to well dispose them on dipose
     *
      @type {Array<{element:HTMLElement,listener:EventCallback,id:string}>} */
    this.listeners = [];
  }

  /**
   *
   * @returns {boolean} - true if the mouse is dragging, false otherwise
   */
  isDragging() {
    return this.dragging;
  }

  /**
   * Remove listeners and reset variables
   */
  dispose() {
    // Reset variables
    this.mouseMap = {};
    this.mouseEvent = {};
    this.dragging = false;

    this.listeners.forEach(function (l) {
      l.element.removeEventListener(l.id, l.listener);
    });

    this.listeners = [];
  }

  /**
   * Start listening {@link MOUSE_STATE_EVENTS} on the element
   *
   * @param {HTMLElement} element - html catching events
   */
  startListening(element) {
    for (const id in MOUSE_STATE_EVENTS) {
      this.listeners.push({
        element: element,
        listener: this.addEvent(element, MOUSE_STATE_EVENTS[id]),
        id: MOUSE_STATE_EVENTS[id],
      });
    }
  }

  /**
   * Add a listener for a particular event on element
   *
   * @param {HTMLElement} element - element to listen to
   * @param {string} idEvent - mouse events
   * @returns {EventCallback} - Callback call for this event
   */
  addEvent(element, idEvent) {
    const listener = (event) => {
      if (idEvent === MOUSE_STATE_EVENTS.MOUSE_DOWN) {
        this.dragging = true;
      } else if (idEvent === MOUSE_STATE_EVENTS.MOUSE_UP) this.dragging = false;
      this.mouseMap[idEvent] = true; // Is trigger
      this.mouseEvent[idEvent] = event;
    };
    element.addEventListener(idEvent, listener);
    this.mouseMap[idEvent] = false;

    return listener;
  }

  /**
   * Access the last Event stored for eventID
   *
   * @param {string} eventID - id of the mouse event
   * @returns {Event} - The last event store for this event
   */
  event(eventID) {
    return this.mouseEvent[eventID];
  }

  /**
   * Return true if this event has been triggered on the last poll
   *
   * @param {string} eventID - event id
   * @returns {boolean} - true if the eventID has been triggered
   */
  isTrigger(eventID) {
    return this.mouseMap[eventID];
  }

  /**
   * Reset Event triggered
   */
  reset() {
    // Reset trigger mousemap
    for (const idEvent in this.mouseMap) {
      this.mouseMap[idEvent] = false;
    }
  }
}