state_Interpolator.js

const State = require('./State');
const Diff = require('./Diff');

/** @class */
class Interpolator {
  /**
   * Handle reception of states over time and can on demand compute current states that need to be processed.
   * Current states are delivered/computed with a delay and can be interpolated in order to "smooth" them
   * see {@link State} to have a better understanding
   *
   * Very inspired (quite identical) from there {@link https://victorzhou.com/blog/build-an-io-game-part-1/#7-client-state}
   *
   * @param {number} [delay=100] - delay between state received and state delivered/computed
   * @param {boolean} [computeBandWidth=false] - compute bandwidth of state
   */
  constructor(delay, computeBandWidth = false) {
    /**
     * delay between state received and state delivered
     *
     * @type {number}
     */
    this.delay = delay || 100;

    /**
     * buffer of states received
     *
     * @type {State[]}
     */
    this.states = [];

    /**
     * interpolator start time
     *
     * @type {number}
     */
    this.startTimestamp = 0;

    /**
     * time of the first state received
     *
     * @type {number}
     */
    this.firstStateTimestamp = 0;

    /**
     * buffer of states deprecated (out of time) but not treated (not deliver/compute)
     *
     * @type {State[]}
     */
    this._notConsumedStates = [];

    /**
     * time of the last state received
     *
     * @type {number}
     */
    this.lastTimeState = 0;

    /**
     * @type {object}
     */
    this.bandWidthStateValue = null;

    /** @type {boolean} */
    this.computeBandWidth = computeBandWidth;

    /**
     * time between last state received and the previous one
     *
     * @type {object}
     */
    this.ping = null;
  }

  /**
   *
   * @returns {number} - interpolator ping
   */
  getPing() {
    return this.ping;
  }

  /**
   * Add a new state to interpolator
   *
   * @param {State} state - new state receive
   */
  onNewState(state) {
    if (!state) {
      throw new Error('no state');
    }

    // Compute ping
    const now = Date.now();
    this.ping = { timestamp: now, number: now - this.lastTimeState };
    this.lastTimeState = now;

    if (this.computeBandWidth) {
      const size = new TextEncoder().encode(JSON.stringify(state)).length;
      const kiloBytes = size / 1024;
      this.bandWidthStateValue = { number: kiloBytes, timestamp: now };
    }

    this.states.push(state);

    // Keep only one state before the current server time
    const index = this._computeIndexBaseState();

    if (index > 0) {
      const stateDeleted = this.states.splice(0, index);
      for (let iStateDel = 0; iStateDel < stateDeleted.length; iStateDel++) {
        const element = stateDeleted[iStateDel];
        if (!element.hasBeenConsumed()) this._notConsumedStates.push(element); // Register states not consumed
      }
    }
  }

  /**
   *
   * @returns {State} - the last state received
   */
  _getLastStateReceived() {
    return this.states[this.states.length - 1];
  }

  /**
   * Compute current server time, server is the entity where states are computed
   *
   * @returns {number} - current server time
   */
  _computeCurrentServerTime() {
    return (
      this.firstStateTimestamp + Date.now() - this.startTimestamp - this.delay
    );
  }

  /**
   *
   * @returns {number} - the index of the first state before server time
   */
  _computeIndexBaseState() {
    const serverTime = this._computeCurrentServerTime();
    for (let i = this.states.length - 1; i >= 0; i--) {
      if (this.states[i].getTimestamp() <= serverTime) {
        return i;
      }
    }
    return -1;
  }

  /**
   * Compute the next state based on a {@link Diff}
   *
   * @param {Diff} diff - diff received
   */
  onNewDiff(diff) {
    const last = this._getLastStateReceived();
    if (!last) {
      console.log('no last state');
      return;
    }
    const newState = last.add(diff);
    this.onNewState(newState);
  }

  /**
   * Init interpolator attributes with a first state
   *
   * @param {State} state - first state received
   */
  onFirstState(state) {
    this.firstStateTimestamp = state.getTimestamp();
    this.startTimestamp = Date.now();
    this.states.length = 0;
    this.lastTimeState = 0;
    this.onNewState(state);
  }

  /**
   * Deliver/Compute current state
   *
   * @returns {State} - current state
   */
  computeCurrentState() {
    if (!this.firstStateTimestamp) {
      return null;
    }

    let result;

    const index = this._computeIndexBaseState();

    const serverTime = this._computeCurrentServerTime();

    // If base is the most recent update we have, use its state.
    // Otherwise, interpolate between its state and the state of (base + 1).
    if (index < 0 || index === this.states.length - 1) {
      result = this._getLastStateReceived();
      result.setConsumed(true);
    } else {
      const baseState = this.states[index];
      baseState.setConsumed(true);
      const nextState = this.states[index + 1];
      const ratio =
        (serverTime - baseState.getTimestamp()) /
        (nextState.getTimestamp() - baseState.getTimestamp());
      result = State.interpolate(baseState, nextState, ratio);
    }

    return result;
  }

  /**
   * Deliver/Compute current state + add the ones not treated/consumed
   *
   * @returns {State[]} - current states
   */
  computeCurrentStates() {
    if (!this.states.length) {
      console.log('no state received yet');
      return [];
    }

    const result = this._notConsumedStates;
    this._notConsumedStates = [];
    result.push(this.computeCurrentState());

    return result;
  }
}

module.exports = Interpolator;