Source: Display.js

/**
 * @license GPL-3.0-only
 *
 * @author Mark Mayes / mm-dev
 *
 *
 * @module Display
 *
 * @description
 * ## Drawing to the display (canvas) happens in this class, with some of it being farmed out to sub-classes
 */

import { PD } from "./PD/CONST.js";
import {
  ADDED_LAYER,
  ASPECT_RATIO,
  OBSTACLE_SUBTYPE,
  OBSTACLE_TYPE,
} from "./PD/ENUM.js";
import * as GAME from "./PD/GAME.js";
import * as IMAGE_IDS from "./PD/IMAGE_IDS.js";
import * as STRING from "./PD/STRING.js";

import { AddedLayer } from "./AddedLayer.js";
import { Controller } from "./Controller.js";
import { FullscreenManager } from "./FullscreenManager.js";
import { Game } from "./Game.js";
import { HealthMeter } from "./HealthMeter.js";
import { ImageManager } from "./ImageManager.js";
import { InternalTimer } from "./InternalTimer.js";
import { IntroObstacles } from "./IntroObstacles.js";
import { Layout } from "./Layout.js";
import { ObstacleManager } from "./ObstacleManager.js";
import { OverlayText } from "./OverlayText.js";
import { PipeWalls } from "./PipeWalls.js";
import { Player } from "./Player.js";
import { Shape } from "./Shape.js";
import { Text } from "./Text.js";

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

class Display {}

/**
 * @function init
 * @static
 *
 * @description
 * ##### Various initialisation jobs
 * - Create and store a reference to the main game canvas
 * - Initialise some other companion classes
 */
Display.init = function () {
  __("Display.init()", PD.FMT_DISPLAY);
  if (!Display.canvas) {
    Display.createCanvas();
  }

  Display.titleImage = ImageManager.getImageByID(IMAGE_IDS.MAIN_TITLE).image_el;

  Layout.init();
  Text.init();
  OverlayText.init();
  PipeWalls.init();
};

/**
 * @function setupForLevel
 * @static
 *
 * @description
 * ##### Update some level-specific details
 * - Custom colours
 * - Instruction text if this is the first screen
 * - Which obstacles to show on the level intro screen
 */
Display.setupForLevel = function () {
  __("Display.setupForLevel()", PD.FMT_DISPLAY);

  Display.levelIsDark = Game.curLevelData.isDark;
  Display.textColor = Game.curLevelData.textColor;
  Display.textColorHighlight = Game.curLevelData.textColorHighlight;
  //Display.setBackgroundColor(Game.curLevelData.bgColor);
  //document.body.style.backgroundColor = Game.curLevelData.bgColor;
  Display.bgFadeAlpha = Game.curLevelData.bgFadeAlpha;
  Display.shadowColor =
    Game.curLevelData.textColorShadow || GAME.TEXT_DEFAULT_SHADOW_COLOR;
  if (Game.isOnFrontPage) {
    Display.instruct_text = Game.curLevelData.instruct_text;
  }

  Display.updateLayout();

  IntroObstacles.setupForLevel();

  Display.updateColors({ bgColor: Game.curLevelData.bgColor });
};

// TODO Document
Display.updateColors = function (_data) {
  Text.setupForLevel({
    levelIsDark: Display.levelIsDark,
    defaultColor: Display.textColor,
    accentColor: Display.textColorHighlight,
    shadowColor: Display.shadowColor,
  });

  Display.setBackgroundColor(_data?.bgColor || Display.bgColor);
};

/**
 * @function setBackgroundColor
 * @static
 *
 * @description
 * ##### Update the background colour
 * - For the game itself
 * - For faded backgrounds underneath eg text and the health meter
 * - For other overlays used eg in
 *
 * @param {number} _hexColor - The new colour
 */
Display.setBackgroundColor = function (_hexColor) {
  Display.bgColor = _hexColor;
  Display.overlayBgColor = hexOpacityToRGBA(
    _hexColor,
    GAME.TEXT_BACKGROUND_FILL_ALPHA
  );
  document.body.style.backgroundColor = Game.curLevelData.bgColor;
};

/**
 * @function updateLayout
 * @static
 *
 * @description
 * ##### Make sure sizes for everything are matched to the current viewport dimensions
 * Most of the work is done by calling similar methods in other classes.
 */
Display.updateLayout = function () {
  if (Layout.sessionAspectRatio === ASPECT_RATIO.LANDSCAPE) {
    Display.isLandscapeAspect = true;
  } else {
    Display.isLandscapeAspect = false;
  }
  Display.canvas.width = Layout.canvasWidth;
  Display.canvas.height = Layout.canvasHeight;

  Player.updateSizes();
  HealthMeter.updateSizes();
  Controller.updateSizes();
  IntroObstacles.updateSizes();
  Shape.updateSizes();
  PipeWalls.updateSizes();
};

/**
 * @function createCanvas
 * @static
 *
 * @description
 * ##### Create the main `canvas` element and add it to the DOM
 * **`Display.canvas` contains everything we see on-screen.**
 */
Display.createCanvas = function () {
  __("Display.createCanvas()", PD.FMT_INFO);

  Display.canvas = document.createElement("canvas");
  Display.canvas.id = PD.EL_IDS.ACTIVEAREA_CANVAS;
  // Declaring that we don't need the canvas itself to use transparency allows
  // the browser to optimise
  Display.ctx = Display.canvas.getContext("2d", {
    alpha: false,
    willReadFrequently: true,
    // Never set `desynchronized: true` on this canvas context!
    // It works on secondary canvases in `PipeWalls` and `Text`, but here it causes blank screens/empty canvas in some browsers TODO Why??
    //desynchronized: true,
  });
  Display.ctx.imageSmoothingEnabled = false;

  Display.ctxDefault = Display.ctx;

  if (Display.ctx.roundRect) {
    Display.hasRoundRect = true;
  } else {
    Display.hasRoundRect = false;
  }

  Game.container_el.appendChild(Display.canvas);

  if (GAME.PIXEL_SCALE !== 1) {
    Display.canvas.style.transform = "scale(" + GAME.PIXEL_SCALE + ")";
  }
};

/**
 * @function update
 * @static
 *
 * @description
 * ##### The main update function
 * - Called once per frame
 * - Manages all updates to the screen (`Display.canvas`), including the game itself and the UI
 * - Decides what to show/hide depending on the current state of the game
 */
Display.update = function () {
  var t1, t2;

  if (Game.doPerfLog) {
    t1 = performance.now();
  }

  Display.drawBackground();
  Display.drawBackgroundObstacles();
  if (!Game.isOnFrontPage) {
    Display.drawBackgroundFade();
    Display.drawForegroundObstacles();
  }

  if (Game.isOnFrontPage) {
    Display.drawTitle();
    Text.drawInstructions(Display.instruct_text);
    Text.drawVersionInfo();
  }

  Player.draw();

  if (!Game.isOnFrontPage) {
    if (Game.doPerfLog) {
      t2 = performance.now();
    }
    PipeWalls.draw();
    if (Game.doPerfLog) {
      __("PipeWalls.draw() " + (performance.now() - t2), PD.FMT_PERFORMANCE);
    }
    Display.drawFloatingObstacles();
  }

  if (!Game.isInGameOver) {
    Controller.draw();
  }

  HealthMeter.draw();

  if (!Game.isOnFrontPage) {
    if (!Game.timeIsLow) {
      Text.drawTimeRemaining();
    } else {
      Text.drawTimeRemaining({ timeIsLow: true });
    }
    Text.drawCollected();
    Text.drawLevel();
  }

  if (OverlayText.content_ar?.length > 0) {
    OverlayText.draw();
  }

  Display.drawSoundToggle();
  Display.drawFullscreenToggle();

  if (!Game.isOnFrontPage && Game.isInLevelIntro) {
    Text.drawVersionInfo({ isInLevelIntro: true, color: Display.textColor });
  }

  //if (Game.textIsFading) {
  //  Game.introTextFadeAlpha += Game.introTextFadeStepSize;
  //  if (Game.introTextFadeStepSize > 0) {
  //    // Fading in
  //    if (Game.introTextFadeAlpha > 1 - Game.introTextFadeStepSize) {
  //      Game.introTextFadeAlpha = 1;
  //    }
  //  } else {
  //    // Fading out
  //    if (Game.introTextFadeAlpha < Game.introTextFadeStepSize) {
  //      Game.introTextFadeAlpha = 0;
  //    }
  //  }
  //}

  if (Game.showFps) {
    Text.drawFps();
  }

  if (Game.doPerfLog) {
    __("Display.update() " + (performance.now() - t1), PD.FMT_PERFORMANCE);
  }
};

/**
 * @function drawTitle
 * @static
 *
 * @description
 * ##### Draw the **Pipe Dream** title image on the intro page
 */
Display.drawTitle = function () {
  Display.ctx.drawImage(
    Display.titleImage,
    0,
    0,
    Display.titleImage.width,
    Display.titleImage.height,
    Layout.mainTitle_rect.left,
    Layout.mainTitle_rect.top,
    Layout.mainTitle_rect.right - Layout.mainTitle_rect.left,
    Layout.mainTitle_rect.bottom - Layout.mainTitle_rect.top
  );
};

/**
 * @function drawFullscreenToggle
 * @static
 *
 * @description
 * ##### The enter/exit fullscreen toggle icon
 */
Display.drawFullscreenToggle = function () {
  Text.draw({
    text: STRING.FULLSCREEN,
    color: FullscreenManager.isFullscreen
      ? Display.textColorHighlight
      : Display.textColor,
    drawBackground: true,
    alignH: Layout.currentAspectUI.fullscreenToggle.alignH,
    alignV: Layout.currentAspectUI.fullscreenToggle.alignV,
  });
};

/**
 * @function drawSoundToggle
 * @static
 *
 * @description
 * ##### The sound on/off toggle icon
 */
Display.drawSoundToggle = function () {
  Text.draw({
    text: Game.soundIsEnabled ? STRING.SOUND_ENABLED : STRING.SOUND_DISABLED,
    color: Game.soundIsEnabled ? Display.textColorHighlight : Display.textColor,
    drawBackground: true,
    alignH: Layout.currentAspectUI.soundToggle.alignH,
    alignV: Layout.currentAspectUI.soundToggle.alignV,
    offsetV:
      Layout.currentAspectUI.soundToggle.offsetByCharsV * Text.drawnCharHeight,
  });
};

/**
 * @function flashIsOff
 * @static
 *
 * @description
 * ##### For flashing items, decide whether on this frame the item should be 'off' (hidden) rather than an 'on' (displayed)
 * As the display is redrawn for every frame, if we want an item to periodically flash we need a way of calculating when to show it and when to hide it.
 * - The frame rate may be inconsistent, so it makes sense to specify the period of the on/off cycle using seconds
 * - `InternalTimer.currentFps` can be used to roughly convert seconds to frames
 * - `InternalTimer.frameCount` is a simple counter of how many frames have been drawn
 *
 * @param {number} _secsOn - How many seconds or fractions of a second the 'on' part of the flash cycle should last
 * @param {number} _secsOff - As above but for the 'off' part of the cycle
 *
 * @returns {boolean} Whether the item should be 'flashed off'. or hidden during this frame
 */
// TODO Make the calculations more robust to prevent clashes
Display.flashIsOff = function (_secsOn, _secsOff) {
  var i,
    framesOn = Math.ceil(_secsOn * InternalTimer.currentFps),
    framesOff = Math.ceil(_secsOff * InternalTimer.currentFps),
    fullCycleFrames = framesOn + framesOff;

  for (i = 0; i < framesOff; i++) {
    if ((InternalTimer.frameCount + i) % fullCycleFrames === 0) {
      return true;
    }
  }
};

/**
 * @function drawBackground
 * @static
 *
 * @description
 * ##### The solid fill background colour
 */
Display.drawBackground = function () {
  Display.ctx.fillStyle = Display.bgColor;
  Display.ctx.fillRect(0, 0, Display.canvas.width, Display.canvas.height);
};

/**
 * @function swapContext
 * @static
 *
 * @description
 * ##### Swap to a different canvas context
 * - Used eg by OverlayText to temporarily swap to its own special canvas
 * - While swapped, all canvas operations such as in `Text` or other classes will be performed on the swapped canvas
 * - Can be used to set back to the default canvas
 *
 * @param {object} _data - [TODO:description]
 * @param {boolean} [_data.backToDefault] - If `true`, context is switched back to the standard context and any transforms are restored
 * @param {CanvasRenderingContext2D} [_data.contextToSwapTo] - The context to swap to
 */
Display.swapContext = function (_data) {
  if (_data.backToDefault) {
    Display.ctx = Display.ctxDefault;
    Display.ctx.restore();
  } else {
    Display.ctx.save();
    Display.ctx = _data.contextToSwapTo;
  }
};

/**
 * @function drawBackgroundFade
 * @static
 *
 * @description
 * ##### Fade out part of the background
 * - Used to dim the inside of the pipe to suggest the glass material is absorbing light from things behind it
 * - As each level has a different combination of colours for the background fill and background obstacles, the level config contains a `bgFadeAlpha` variable to tweak the opacity of this fade
 */
Display.drawBackgroundFade = function () {
  var x, y, width, height;

  if (Display.isLandscapeAspect) {
    x = Layout.gameplay_rect.left;
    y = Controller.lateralOffset + Layout.gameAreaOffsetLateral;
  } else {
    x = Controller.lateralOffset + Layout.gameAreaOffsetLateral;
    y = Layout.gameplay_rect.top;
  }
  width = Layout.gameplayWidth;
  height = Layout.gameplayHeight;

  Display.ctx.fillStyle = hexOpacityToRGBA(
    Display.bgColor,
    Display.bgFadeAlpha
  );
  Display.ctx.fillRect(x, y, width, height);
};

/**
 * @function drawBackgroundObstacles
 * @static
 *
 * @description
 * ##### Background obstacles
 * - Are below and do not interact with the player
 * - Conceptually are on the **bottom layer**
 */
Display.drawBackgroundObstacles = function () {
  var i, obstacle;
  for (i = 0; i < ObstacleManager.obstacles.length; i++) {
    obstacle = ObstacleManager.obstacles[i];
    if (obstacle.type === OBSTACLE_TYPE.BACKGROUND) {
      Display.drawObstacle(obstacle);
    }
  }
};

/**
 * @function drawForegroundObstacles
 * @static
 *
 * @description
 * ##### Foreground obstacles
 * - Can interact with the player by being collected or causing health damage
 * - Conceptually are on the **middle layer**
 */
Display.drawForegroundObstacles = function () {
  var i, obstacle;
  for (i = 0; i < ObstacleManager.obstacles.length; i++) {
    obstacle = ObstacleManager.obstacles[i];
    if (
      !obstacle.isDeleted &&
      (obstacle.type === OBSTACLE_TYPE.COLLECT ||
        obstacle.type === OBSTACLE_TYPE.AVOID)
    ) {
      Display.drawObstacle(obstacle);
    }
  }
};

/**
 * @function drawFloatingObstacles
 * @static
 *
 * @description
 * ##### Floating obstacles
 * - Are above and do not interact with the player
 * - Conceptually are on the **top layer**
 */
Display.drawFloatingObstacles = function () {
  var i, obstacle;
  for (i = 0; i < ObstacleManager.obstacles.length; i++) {
    obstacle = ObstacleManager.obstacles[i];
    if (obstacle.type === OBSTACLE_TYPE.FLOATING) {
      Display.drawObstacle(obstacle);
    }
  }
};

/**
 * @function drawObstacle
 * @static
 *
 * @description
 * ##### Draw an individual obstacle
 * Decide what kind of primitive shape this obstacle is and farm out the drawing of it to the `Shape` class.
 * - Although obstacles have their own `pos.x`/`pos.y` properties, sometimes they are drawn offset to suggest depth/parallax
 * - So `displayX` and `displayY` are calculated here, and may not be the same as the `pos` properties
 *
 * @param {object} _obstacle - The obstacle to be drawn
 */
Display.drawObstacle = function (_obstacle) {
  var displayX,
    displayY,
    lateralOffset = 0,
    parallaxMultiplier = 1,
    ignore = false;

  if (_obstacle.type === OBSTACLE_TYPE.BACKGROUND) {
    parallaxMultiplier = GAME.BACKGROUND_LATERAL_MULTIPLIER;
  } else if (_obstacle.type === OBSTACLE_TYPE.FLOATING) {
    parallaxMultiplier = GAME.FLOATING_LATERAL_MULTIPLIER;
  }
  if (_obstacle.type !== OBSTACLE_TYPE.LEVELINTRO) {
    lateralOffset =
      (Controller.lateralOffset + Layout.gameAreaOffsetLateral) *
      parallaxMultiplier;
  }

  if (Display.isLandscapeAspect) {
    displayX = _obstacle.pos.x;
    displayY = _obstacle.pos.y + lateralOffset;
  } else {
    displayX = _obstacle.pos.x + lateralOffset;
    displayY = _obstacle.pos.y;
  }

  //if (_obstacle.type === OBSTACLE_TYPE.AVOID) {
  //  // AVOIDABLE
  //  _obstacle.damageSafetyCounter = 0;
  if (
    _obstacle.type === OBSTACLE_TYPE.AVOID &&
    _obstacle.damageSafetyCounter > 0 &&
    Display.flashIsOff(
      GAME.DAMAGE_FLASH_ON_SECS,
      GAME.DAMAGE_FLASH_OFF_SECS
    )
  ) {
    ignore = true;
  }

  //displayX = Math.round(displayX);
  //displayY = Math.round(displayY);

  if (!ignore) {
    switch (_obstacle.subtype) {
      case OBSTACLE_SUBTYPE.FLOWER:
        Shape.drawFlower(displayX, displayY, _obstacle);
        break;
      case OBSTACLE_SUBTYPE.STAR:
        Shape.drawStar(displayX, displayY, _obstacle);
        break;
      case OBSTACLE_SUBTYPE.SQUARCLE:
        Shape.drawSquarcle(displayX, displayY, _obstacle);
        break;
      case OBSTACLE_SUBTYPE.SKEWED_CIRCLE:
        Shape.drawSkewedCircle(displayX, displayY, _obstacle);
        break;
      default:
        Shape.drawCircle(displayX, displayY, {
          radius: _obstacle.radius,
          // TODO color/explodingColor are not used, always gradient?
          color: _obstacle.explodingColor || _obstacle.color,
          gradientFadePoint: _obstacle.gradientFadePoint,
          gradient: _obstacle.gradient,
          rotation: _obstacle.rotation,
          useDefaultStroke: _obstacle.useDefaultStroke,
        });
        break;
    }

    if (_obstacle.addedLayer_ar) {
      Display.drawAddedLayers(displayX, displayY, _obstacle);
    }
  }
};

/**
 * @function drawAddedLayers
 * @static
 *
 * @description
 * ##### Some obstacles have special added details eg:
 * - Hats
 * - Lines of detail
 *
 * @param {number} _x - X coordinate of the obstacle
 * @param {number} _y - Y coordinate of the obstacle
 * @param {object} _obstacle - The obstacle to be drawn on top of
 */
Display.drawAddedLayers = function (_x, _y, _obstacle) {
  var i, addedLayer;

  for (i = 0; i < _obstacle.addedLayer_ar.length; i++) {
    addedLayer = _obstacle.addedLayer_ar[i];

    // Add layer offsets if needed
    _x += addedLayer.offsetX ? addedLayer.offsetX * _obstacle.radius : 0;
    _y += addedLayer.offsetY ? addedLayer.offsetY * _obstacle.radius : 0;

    switch (addedLayer.type) {
      case ADDED_LAYER.SPOKES:
        AddedLayer.drawSpokes(_x, _y, _obstacle, addedLayer);
        break;
      case ADDED_LAYER.MOUTH:
        AddedLayer.drawMouth(_x, _y, _obstacle, addedLayer);
        break;
      case ADDED_LAYER.EYES:
        AddedLayer.drawEyes(_x, _y, _obstacle, addedLayer);
        break;
      case ADDED_LAYER.STETSON:
        AddedLayer.drawStetson(_x, _y, _obstacle, addedLayer);
        break;
      case ADDED_LAYER.STEM:
        AddedLayer.drawStem(_x, _y, _obstacle, addedLayer);
        break;
      case ADDED_LAYER.BOWLER:
        AddedLayer.drawBowler(_x, _y, _obstacle, addedLayer);
        break;
      case ADDED_LAYER.DOT:
        AddedLayer.drawDot(_x, _y, _obstacle, addedLayer);
        break;
      case ADDED_LAYER.TRIANGLES:
        AddedLayer.drawTriangles(_x, _y, _obstacle, addedLayer);
        break;
      default:
        break;
    }
  }
};

export { Display };