cellbuffer.js

Tily.CellBuffer = (function(_super) {
  "use strict";
  Tily.utility.__extends(CellBuffer, _super);

  /**
   * @callback resolveCellFunction
   * @param {Tily.Cell} cell The generated cell.
   */
  /**
   * @callback rejectCellFunction
   * @param {string} reason A string containing the reason that cell generation failed.
   */
  /**
   * @callback cellFunction
   * @param {Tily.CellBuffer} buffer The cell buffer that the resulting cell will belong to.
   * @param {number} x The cell x-coordinate.
   * @param {number} y The cell y-coordinate.
   * @param {resolveCellFunction} resolve A function to call if the cell is generated
   * successfully.
   * @param {rejectCellFunction} reject A function to call if cell generation failed.
   */
  /**
   * @typedef CellBufferOptions
   * @type {BufferOptions}
   * @property {number} [cellWidth=16] The width of each cell measured in tiles.
   * @property {number} [cellHeight=16] The height of each cell measured in tiles.
   * @property {?number} [minimumX=null] The minimum cell x-coordinate. If this is null, the cell
   * buffer will scroll infinitely in the negative-x direction.
   * @property {?number} [minimumY=null] The minimum cell y-coordinate. If this is null, the cell
   * buffer will scroll infinitely in the negative-x direction.
   * @property {?number} [maximumX=null] The maximum cell x-coordinate. If this is null, the cell
   * buffer will scroll infinitely in the positive-x direction.
   * @property {?number} [maximumY=null] The maximum cell y-coordinate. If this is null, the cell
   * buffer will scroll infinitely in the positive-y direction.
   * @property {?cellFunction} [cellFunction=null] A function for generating cells.
   */
  /**
   * Default cell buffer options, used as a fall-back for options passed to the constructor.
   * @type {CellBufferOptions}
   */
  const _defaultCellBufferOptions = {
    cellWidth: 16,
    cellHeight: 16,
    minimumX: null,
    minimumY: null,
    maximumX: null,
    maximumY: null,
    cellFunction: null
  };

  /**
   * A buffer made out of rectangular cells generated by a cell function, used for infinite
   * scrolling buffers and procedurally generated buffers.
   * @class
   * @extends Tily.BufferBase
   * @memberof Tily
   * @param {CellBufferOptions} [options] An optional options object for configuring the buffer.
   */
  function CellBuffer(options) {
    _super.call(this, options);

    /**
     * A cache of loaded cells with hashed cell positions as keys and cell instances as values.
     * @type {Object}
     */
    this.cellCache = {};

    /**
     * Options for configuring this cell buffer.
     * @type {CellBufferOptions}
     */
    this.options = { ..._defaultCellBufferOptions, ...this.options, ...options || {} };
  }

  /**
   * Get a string representation of the specified position for use as a hash.
   * @param {Tily.utility.vec2} p The position to hash.
   * @returns {string} A hash string for the specified position.
   */
  function hash(p) {
    return Tily.utility.vec2.toString(p, "_");
  }

  /**
   * @typedef CellBufferTileInfo
   * @type {BufferBaseTileInfo}
   * @property {Tily.utility.vec2} cell The cell coordinate for the specified tile position.
   * @property {string[]} layers The tile layer characters in z-index order at a tile position.
   */
  /**
   * Get information about the tiles and active tiles at a tile position.
   * @name getTileInfo
   * @function
   * @instance
   * @memberof Tily.CellBuffer
   * @param {number} x The x-coordinate of the tile position.
   * @param {number} y The y-coordinate of the tile position.
   * @returns {CellBufferTileInfo} Information about the tiles at the specified position.
   */
  CellBuffer.prototype.getTileInfo = function(x, y) {
    const tileInfo = _super.prototype.getTileInfo.call(this, x, y),
      cell = Tily.utility.vec2.map(
        Tily.utility.vec2(x / this.options.cellWidth, y / this.options.cellHeight),
        Math.floor
      ),
      cellOffset = Tily.utility.vec2(
        x - cell.x * this.options.cellWidth,
        y - cell.y * this.options.cellHeight
      ),
      h = hash(cell);
    var layers = [];
    if (this.cellCache[h]) {
      layers = this.cellCache[h].layers.map(i => i.getTile(cellOffset.x, cellOffset.y));
    }
    tileInfo.cell = cell;
    tileInfo.layers = layers;
    return tileInfo;
  };

  /**
   * Render this buffer's cells onto the specified context.
   * @name draw
   * @function
   * @instance
   * @memberof Tily.CellBuffer
   * @param {CanvasRenderingContext2D} context The context to render the buffer onto.
   * @param {number} elapsedTime The time elapsed in seconds since the last draw call.
   * @param {number} width The width of the canvas in pixels.
   * @param {number} height The height of the canvas in pixels.
   */
  CellBuffer.prototype.draw = function(context, elapsedTime, width, height) {
    this.canvas.width = width;
    this.canvas.height = height;
    this.context.save();
    // this.context.textBaseline = "top";
    this.context.clearRect(0, 0, width, height);

    // Update transitions
    const offset = this.updateTransitions(elapsedTime);

    // Clamp camera scale
    var lockedAxis = this.options.lockedAxis,
      maximumScale = this.options.maximumScale;

    // Get cell buffer size
    const size = { width: Infinity, height: Infinity };
    if (this.options.minimumX && this.options.maximumX) {
      size.width = (this.options.maximumX - this.options.minimumX) * this.options.cellWidth;
    }
    if (this.options.minimumY && this.options.maximumY) {
      size.height = (this.options.maximumY - this.options.minimumY) * this.options.cellHeight;
    }

    // Camera clamping only clamps scale when the cell buffer has a finite dimension
    if (this.options.clampCamera) {
      maximumScale = Math.min(maximumScale, size.width, size.height);
      if (width > height) {
        lockedAxis = "x";
      } else if (height > width) {
        lockedAxis = "y";
      }
    }
    this.scale = Tily.utility.clamp(
      this.scale,
      Math.max(this.options.minimumScale, 1),  // Minimum scale cannot go below 1 tile
      maximumScale
    );
    this.tileSize = (lockedAxis == "y" ? height : width) / this.scale;
    this.viewSize.width = width / this.tileSize;
    this.viewSize.height = height / this.tileSize;

    // Clamp camera offset
    if (this.options.clampCamera) {
      const centerX = this.viewSize.width * 0.5 - 0.5,
        centerY = this.viewSize.height * 0.5 - 0.5;
      if (isFinite(size.width)) {
        this.offset.x = offset.x = Tily.utility.clamp(offset.x, centerX, size.width - centerX - 1);
      }
      if (isFinite(size.height)) {
        this.offset.y = offset.y = Tily.utility.clamp(offset.y, centerY, size.height - centerY - 1);
      }
    }

    // Translate camera viewport
    this.context.translate(
      width * 0.5 - offset.x * this.tileSize - this.tileSize * 0.5,
      height * 0.5 - offset.y * this.tileSize - this.tileSize * 0.5
    );

    // Update active tiles map
    const halfSize = Tily.utility.vec2(this.viewSize.width * 0.5 + 1, this.viewSize.height * 0.5 + 1),
      tl = Tily.utility.vec2.map(Tily.utility.vec2.sub(offset, halfSize), Math.floor),
      br = Tily.utility.vec2.map(Tily.utility.vec2.add(offset, halfSize), Math.ceil),
      activeTiles = this.updateActiveTilesMap(tl, br);

    // Find the top-left and bottom-right cell positions currently in view
    const cellSize = Tily.utility.vec2(this.options.cellWidth, this.options.cellHeight),
      tlCell = Tily.utility.vec2.map(Tily.utility.vec2.div(tl, cellSize), Math.floor),
      brCell = Tily.utility.vec2.map(Tily.utility.vec2.div(br, cellSize), Math.ceil),
      cache = this.cellCache,
      getResolve = function(x, y) {
        return function(cell) {
          cache[hash(Tily.utility.vec2(x, y))] = cell;
        };
      },
      getReject = function(x, y) {
        return function(reason) {
          console.log("Couldn't generate cell (%i, %i): %s", x, y, reason);
        };
      };
    var h = null;
    for (let x = tlCell.x; x < brCell.x; x++) {
      for (let y = tlCell.y; y < brCell.y; y++) {
        h = hash(Tily.utility.vec2(x, y));

        // If the cell isn't already in the cell cache and there is a cell function,
        // generate a cell and temporarily mark it as loading
        if (this.cellCache[h] === undefined) {
          this.cellCache[h] = true;  // Mark it as currently loading
          if (this.options.cellFunction) {
            this.options.cellFunction(this, x, y, getResolve(x, y), getReject(x, y));
          }

        // Otherwise, if the cell isn't currently loading, render the cell
        } else if (this.cellCache[h] !== true) {
          this.cellCache[h].draw(
            this.context,
            elapsedTime,
            x, y,
            this.tileSize,
            tl, br,
            activeTiles
          );
        }
      }
    }
    this.context.restore();
    context.drawImage(this.canvas, 0, 0);
  };

  /**
   * Serialize this cell buffer and return the serialized JSON data. The cell function will not
   * be serialized and will need to be re-attached when the data is deserialized.
   * @name serialize
   * @function
   * @instance
   * @memberof Tily.CellBuffer
   * @returns {string} This buffer serialized as JSON data.
   */
  CellBuffer.prototype.serialize = function() {
    const cellCache = {};
    for (let i in this.cellCache) {
      if (!this.cellCache.hasOwnProperty(i)) { continue; }
      cellCache[i] = this.cellCache[i].getData();
    }
    return JSON.stringify({
      cellCache: cellCache,
      activeTiles: this.activeTiles.map(i => i.getData()),
      options: this.options,
      offset: this.offset,
      scale: this.scale
    });
  };

  /**
   * Deserialize the JSON data into a cell buffer. The cell function will need to be re-attached
   * to the resulting cell buffer, as it cannot be serialized.
   * @name deserialize
   * @function
   * @static
   * @memberof Tily.CellBuffer
   * @param {string} s The JSON data to deserialize.
   * @returns {Tily.CellBuffer} The deserialized buffer.
   */
  CellBuffer.deserialize = function(s) {
    var data = null;
    const cellCache = {};
    try {
      data = JSON.parse(s);
    } catch (e) {
      console.log("Couldn't deserialize data: %O", e);
      return null;
    }
    for (let i in data.cellCache) {
      if (!data.cellCache.hasOwnProperty(i)) { continue; }
      cellCache[i] = Tily.Cell.fromData(data.cellCache[i]);
    }
    const buffer = new Tily.CellBuffer(data.options);
    buffer.offset = data.offset;
    buffer.scale = data.scale;
    buffer.cellCache = cellCache;
    buffer.activeTiles = data.activeTiles.map(i => Tily.ActiveTile.fromData(i));
    return buffer;
  };
  return CellBuffer;
}(Tily.BufferBase));