Source: BitmapText.js

/**
 * @license GPL-3.0-only
 *
 * @author Mark Mayes / mm-dev
 *
 *
 * @module BitmapText
 *
 * @description
 * ## Low-level text drawing
 * The `Text` class farms out text-drawing operations to this class.
 * - `ImageManager.allImages_ob` stores data on the bitmap font spritesheet images
 * - The data (originating from {@link PD/IMAGE_DATA.js}) lists allowed multiples for each font, so eg a font might be allowed to be displayed at 2x, 4x, 8x of its original size
 * - The spritesheet images store 1-bit fonts at their smallest sizes
 * - The best-fitting multiple is stored as `BitmapText.spritesheetScale` for use by this and other classes
 * - If no font/multiple can be found to fit the desired number of characters in this viewport, use the smallest font and set `BitmapText.useShortVersions = true`
 */

import { PD } from "./PD/CONST.js";
import * as GAME from "./PD/GAME.js";
import * as STRING from "./PD/STRING.js";

import { ImageManager } from "./ImageManager.js";
import { Display } from "./Display.js";
import { Layout } from "./Layout.js";

import { __, getOffscreenOrNormalCanvas } from "./utils.js";

class BitmapText {}

/**
 * @function init
 * @static
 *
 * @description
 * ##### Create some `canvas` elements and references to their `context`s
 * `BitmapText.stringCacheCanvas`
 * - When drawing a string this canvas is used as a buffer before drawing to the main canvas.
 *
 * `BitmapText.coloredStringBufferCanvas`
 * - The bitmap font is black pixels on a transparent background. This canvas is used with `globalCompositeOperation = 'destination-atop'` to re-colour the black pixels, according to the level settings.
 */
BitmapText.init = function () {
  BitmapText.coloredStringBufferCanvas = getOffscreenOrNormalCanvas();
  BitmapText.coloredStringBufferCtx =
    BitmapText.coloredStringBufferCanvas.getContext("2d", {
      willReadFrequently: true,
      desynchronized: true,
    });
  BitmapText.coloredStringBufferCtx.imageSmoothingEnabled = false;

  BitmapText.stringCacheCanvas = getOffscreenOrNormalCanvas();
  BitmapText.stringCacheCtx = BitmapText.stringCacheCanvas.getContext("2d", {
    willReadFrequently: true,
    desynchronized: true,
  });
  BitmapText.stringCacheCtx.imageSmoothingEnabled = false;
};

/**
 * @function recreateCharCacheCanvas
 * @static
 *
 * @description
 * ##### Recreate the temporary canvas used for the cached bitmap characters
 */
BitmapText.recreateCharCacheCanvas = function () {
  if (BitmapText.charCacheCanvas) {
    BitmapText.charCacheCanvas = null;
    delete BitmapText.charCacheCanvas;
  }
  BitmapText.charCacheCanvas = getOffscreenOrNormalCanvas();
  BitmapText.charCacheCanvas.width = BitmapText.drawnCharWidth;
  BitmapText.charCacheCanvas.height = BitmapText.drawnCharHeight;
  BitmapText.charCacheCanvasCtx = BitmapText.charCacheCanvas.getContext("2d", {
    willReadFrequently: true,
    desynchronized: true,
  });
  BitmapText.charCacheCanvasCtx.imageSmoothingEnabled = false;
};

/**
 * @function precacheAllCharacters
 * @static
 *
 * @description
 * ##### Build a cache of all available characters
 * - List of available characters is defined in `GAME.CHARS_IN_SPRITESHEET_AR`
 * - Each character is drawn to a `canvas` and stored in `BitmapText.charCanvasCache`, indexed by its character
 * - So eg the letter 'C' would be stored as `BitmapText.charCanvasCache['C']`
 *
 * ##### Unknown characters
 * We want to gracefully handle characters which don't appear in the
 * spritesheet.
 *
 * STRING.UNKNOWN_CHARACTER is a special string we use to represent them.
 *
 * BitmapText.unknownCharacterSpritesheetCoords are some coordinates outside the
 * bitmap font image, meaning they return an empty image (which is in effect
 * a `space` character).
 *
 * Intentional `space` characters are treated as an unknown character, so we
 * don't have to include them on the spritesheet and waste space.
 */
BitmapText.precacheAllCharacters = function () {
  var i,
    currentChar,
    spritesheetRows =
      (BitmapText.spritesheetImage.height / BitmapText.drawnCharHeight) *
      BitmapText.spritesheetScale,
    spritesheetTotalChars = GAME.BITMAPFONT_SPRITESHEET_COLS * spritesheetRows;

  BitmapText.charCanvasCache = {};

  BitmapText.unknownCharacterSpritesheetCoords = {
    x: 0 - BitmapText.drawnCharWidth,
    y: 0 - BitmapText.drawnCharHeight,
  };

  for (i = 0; i < spritesheetTotalChars; i++) {
    currentChar = GAME.CHARS_IN_SPRITESHEET_AR[i];
    if (currentChar !== undefined) {
      BitmapText.cacheCharacter(currentChar);
    }
  }
  BitmapText.cacheCharacter(STRING.UNKNOWN_CHARACTER);
};

/**
 * @function chooseBestFittingFont
 * @static
 *
 * @description
 * ##### Choose a font and scaling based on viewport dimensions
 * Calculate character sizes that allow us to fit the desired number of characters (`GAME.CHARS_IN_CANVAS_WIDTH` and `GAME.CHARS_IN_CANVAS_HEIGHT`) on the main canvas.
 */
BitmapText.chooseBestFittingFont = function () {
  var i,
    currentFontKey,
    currentFontData,
    bestFittingFontData,
    fullLineWidth,
    fullLineHeight,
    characterArea,
    smallestFontData,
    currentMultiple,
    multiples,
    default_multiples = [1],
    largestArea = 0;

  for (currentFontKey in ImageManager.allImages_ob) {
    currentFontData = ImageManager.allImages_ob[currentFontKey];
    if (currentFontData.hasOwnProperty("letterSpacing")) {
      multiples = currentFontData.multiples || default_multiples;
      for (i = 0; i < multiples.length; i++) {
        currentMultiple = multiples[i];

        fullLineWidth =
          (currentFontData.width + currentFontData.letterSpacing) *
          GAME.CHARS_IN_CANVAS_WIDTH;
        fullLineHeight =
          (currentFontData.height + currentFontData.lineSpacing) *
          GAME.CHARS_IN_CANVAS_HEIGHT;
        characterArea = currentFontData.width * currentFontData.height;

        fullLineWidth *= currentMultiple;
        fullLineHeight *= currentMultiple;
        characterArea *= currentMultiple;

        if (currentFontData.isSmallestFont) {
          smallestFontData = currentFontData;
        }

        if (
          fullLineWidth <= Layout.canvasWidth &&
          fullLineHeight <= Layout.canvasHeight &&
          characterArea > largestArea
        ) {
          bestFittingFontData = currentFontData;
          largestArea = characterArea;
          BitmapText.spritesheetScale = currentMultiple;
        }
      }
    }
  }

  if (!bestFittingFontData) {
    __("CANVAS TOO SMALL FOR ALL TEXT", PD.FMT_TEXT);
    BitmapText.useShortVersions = true;
    bestFittingFontData = smallestFontData;
    BitmapText.spritesheetScale = 1;
  } else {
    BitmapText.useShortVersions = false;
  }
  __(
    "BitmapText.useShortVersions: " + BitmapText.useShortVersions,
    PD.FMT_TEXT
  );

  BitmapText.drawnCharWidth = bestFittingFontData.width;
  BitmapText.letterSpacing = bestFittingFontData.letterSpacing;
  BitmapText.fullCharWidth =
    bestFittingFontData.width + bestFittingFontData.letterSpacing;

  BitmapText.drawnCharHeight = bestFittingFontData.height;
  BitmapText.lineSpacing = bestFittingFontData.lineSpacing;
  BitmapText.lineHeight =
    bestFittingFontData.height + bestFittingFontData.lineSpacing;

  BitmapText.drawnCharWidth *= BitmapText.spritesheetScale;
  BitmapText.letterSpacing = Math.ceil(
    BitmapText.spritesheetScale / GAME.SCALED_TEXT_SPACINGROWTH_DIVISOR
  );
  BitmapText.fullCharWidth *= BitmapText.spritesheetScale;

  BitmapText.drawnCharHeight *= BitmapText.spritesheetScale;
  BitmapText.lineSpacing = Math.ceil(
    BitmapText.spritesheetScale / GAME.SCALED_TEXT_SPACINGROWTH_DIVISOR
  );
  BitmapText.lineHeight *= BitmapText.spritesheetScale;

  BitmapText.spritesheetImage = ImageManager.getImageByID(
    bestFittingFontData.id
  ).image_el;

  __(
    "BitmapText.spritesheetImage.width: " + BitmapText.spritesheetImage.width,
    PD.FMT_TEXT
  );

  __(
    "BitmapText.spritesheetImage.height: " + BitmapText.spritesheetImage.height,
    PD.FMT_TEXT
  );

  __(
    "BitmapText.spritesheetScale: " + BitmapText.spritesheetScale,
    PD.FMT_TEXT
  );

  __("BitmapText.drawnCharWidth: " + BitmapText.drawnCharWidth, PD.FMT_TEXT);
  __("BitmapText.letterSpacing: " + BitmapText.letterSpacing, PD.FMT_TEXT);
  __("BitmapText.fullCharWidth: " + BitmapText.fullCharWidth, PD.FMT_TEXT);

  __("BitmapText.drawnCharHeight: " + BitmapText.drawnCharHeight, PD.FMT_TEXT);
  __("BitmapText.lineSpacing: " + BitmapText.lineSpacing, PD.FMT_TEXT);
  __("BitmapText.lineHeight: " + BitmapText.lineHeight, PD.FMT_TEXT);

  __("bestFittingFontData.id: " + bestFittingFontData.id, PD.FMT_TEXT);

  __(
    "Max chars in this width: " +
      Layout.canvasWidth /
        (bestFittingFontData.width + bestFittingFontData.letterSpacing),
    PD.FMT_TEXT
  );
  __(
    "Max chars in this height: " +
      Layout.canvasHeight /
        (bestFittingFontData.height + bestFittingFontData.lineSpacing),
    PD.FMT_TEXT
  );
};

/**
 * @function getCharCoordsInSpritesheet
 * @static
 *
 * @description
 * ##### Find the index of a character in the spritesheet, then use the index to get its coordinates
 * Charactes are indexed in the spritesheet in left-to-right, top-to-bottom order so eg:
 * ```
 * ABCDEFGH
 * IJKLMNOP
 * ```
 * are indexed as:
 * ```
 * 0, 1, 2, 3, 4, 5, 6, 7, 8,
 * 9, 10, 11, 12, 13, 14, 15, 16
 * ```
 *
 * @param {string} _char - The character to find
 *
 * @returns {object} Coordinates `{x, y}` of where the character appears in the spritesheet
 */
BitmapText.getCharCoordsInSpritesheet = function (_char) {
  var indexInSpritesheet = GAME.CHARS_IN_SPRITESHEET_AR.indexOf(_char),
    coords = BitmapText.unknownCharacterSpritesheetCoords;
  if (indexInSpritesheet !== -1) {
    coords = BitmapText.charCoordsFromSpritesheetIndex(indexInSpritesheet);
  }
  return coords;
};

/**
 * @function charCoordsFromSpritesheetIndex
 * @static
 *
 * @description
 * ##### Given an index, find the coordinates of a character in the spritesheet
 *
 * @param {number} _index - The index of the character in the spritesheet
 *
 * @returns {object} Coordinates `{x, y}` of where the character appears in the spritesheet
 */
BitmapText.charCoordsFromSpritesheetIndex = function (_index) {
  var col = _index % GAME.BITMAPFONT_SPRITESHEET_COLS,
    row = Math.floor(_index / GAME.BITMAPFONT_SPRITESHEET_COLS);

  return {
    x: (col * BitmapText.drawnCharWidth) / BitmapText.spritesheetScale,
    y: (row * BitmapText.drawnCharHeight) / BitmapText.spritesheetScale,
  };
};

/**
 * @function lineToBitmap
 * @static
 *
 * @description
 * ##### Convert a line of text to a bitmap and draw it to the main canvas
 * - First it is drawn 3 times at different offsets to form the text 'shadow'
 * - Then a coloured version is made and stacked on top to create the main coloured text
 *
 * @param {string} _str - The single-line string to be converted to a bitmap
 * @param {number} _x - X coordinate where it should be drawn to
 * @param {number} _y - Y coordinate where it should be drawn to
 * @param {string} _color - Desired colour
 * @param {string} _shadowColor - Colour for shadow
 */
BitmapText.lineToBitmap = function (_str, _x, _y, _color, _shadowColor) {
  var i,
    stringWidth = BitmapText.fullCharWidth * _str.length;

  BitmapText.stringCacheCanvas.width = stringWidth;
  BitmapText.stringCacheCanvas.height = BitmapText.drawnCharHeight;

  BitmapText.coloredStringBufferCanvas.width = stringWidth;
  BitmapText.coloredStringBufferCanvas.height = BitmapText.drawnCharHeight;

  // Fill up `BitmapText.stringCacheCanvas` with this line of text, rendered in
  // the black bitmap font
  for (i = 0; i < _str.length; i++) {
    BitmapText.drawCharacterToStringCache(
      _str.charAt(i),
      i * BitmapText.fullCharWidth,
      0
    );
  }

  BitmapText.drawLineShadow(_x, _y, _shadowColor);
  BitmapText.drawLineColor(_x, _y, _color);
};

/**
 * @function drawLineColor
 * @static
 *
 * @description
 * ##### The main, coloured text
 *
 * @param {number} _x - X coordinate for the line
 * @param {number} _y - Y coordinate for the line
 * @param {string} _color - The fill colour for the text
 */
BitmapText.drawLineColor = function (_x, _y, _color) {
  // Fill a buffer with the solid color we want the text to be
  BitmapText.coloredStringBufferCtx.clearRect(
    0,
    0,
    BitmapText.coloredStringBufferCanvas.width,
    BitmapText.coloredStringBufferCanvas.height,
  );
  BitmapText.coloredStringBufferCtx.fillStyle = _color;
  BitmapText.coloredStringBufferCtx.fillRect(
    0,
    0,
    BitmapText.coloredStringBufferCanvas.width,
    BitmapText.coloredStringBufferCanvas.height,
  );

  // Draw buffered text on the colour in a way ('destination-atop') that chops
  // off all the transparent pixels, so ends up looking like a solid mask
  // in the shape of the text
  BitmapText.coloredStringBufferCtx.globalCompositeOperation =
    "destination-atop";
  BitmapText.coloredStringBufferCtx.drawImage(
    BitmapText.stringCacheCanvas,
    0,
    0
  );

  // Draw the solid-colour-filled bitmap text in the main context
  Display.ctx.drawImage(BitmapText.coloredStringBufferCanvas, _x, _y);

  // Back to default blend mode
  Display.ctx.globalCompositeOperation = "source-over";
};

/**
 * @function drawLineShadow
 * @static
 *
 * @description
 * ##### Draw the shadow underneath the line of text
 *
 * @param {number} _x - X coordinate for the line
 * @param {number} _y - Y coordinate for the line
 * @param {string} _color - The fill colour for the shadow
 */
BitmapText.drawLineShadow = function (_x, _y, _color) {
  // In the main context, draw the (black) cached string at 3 offsets to form the drop shadow
  BitmapText.drawLineColor(
    _x + BitmapText.spritesheetScale,
    _y + BitmapText.spritesheetScale,
    _color
  );
  BitmapText.drawLineColor(
    _x,
    _y + BitmapText.spritesheetScale,
    _color
  );
  BitmapText.drawLineColor(
    _x + BitmapText.spritesheetScale,
    _y,
    _color
  );
};

/**
 * @function drawCharacterToStringCache
 * @static
 *
 * @description
 * ##### Get a character from the cache and add it to `BitmapText.stringCacheCanvas`
 *
 * @param {string} _char - The character to add
 * @param {number} _x - X coordinate where it should be drawn to
 * @param {number} _y - Y coordinate where it should be drawn to
 */
BitmapText.drawCharacterToStringCache = function (_char, _x, _y) {
  BitmapText.stringCacheCtx.drawImage(
    BitmapText.charCanvasCache[_char] ||
      BitmapText.charCanvasCache[STRING.UNKNOWN_CHARACTER],
    _x,
    _y,
    BitmapText.drawnCharWidth,
    BitmapText.drawnCharHeight
  );
};

/**
 * @function cacheCharacter
 * @static
 *
 * @description
 * ##### Get a character from the spritesheet and cache it to `BitmapText.charCanvasCache[_char]`
 *
 * @param {string} _char - The character to cache
 */
BitmapText.cacheCharacter = function (_char) {
  //__("BitmapText.cacheCharacter: " + _char);
  var spritesheetPos = BitmapText.getCharCoordsInSpritesheet(_char);
  //__("spritesheetPos: " + JSON.stringify(spritesheetPos));

  BitmapText.recreateCharCacheCanvas();
  BitmapText.charCacheCanvasCtx.drawImage(
    BitmapText.spritesheetImage,
    spritesheetPos.x,
    spritesheetPos.y,
    BitmapText.drawnCharWidth / BitmapText.spritesheetScale,
    BitmapText.drawnCharHeight / BitmapText.spritesheetScale,
    0,
    0,
    BitmapText.drawnCharWidth,
    BitmapText.drawnCharHeight
  );
  BitmapText.charCanvasCache[_char] = BitmapText.charCacheCanvas;
  __(
    "\tBitmapText.charCanvasCache[_char]: " + BitmapText.charCanvasCache[_char],
    PD.FMT_TEXT
  );
};

export { BitmapText };