Source: Controller.js

/**
 * @license GPL-3.0-only
 *
 * @author Mark Mayes / mm-dev
 *
 *
 * @module Controller
 *
 * @description
 * ## An area of the screen used to control player movement
 * - One axis controls **speed**
 * - The other controls **lateral movement**
 *   - In portrait orientation, lateral is side-to-side
 *   - In landscape it's top-to-bottom
 *
 *
 * This class keeps track of the position of the pointer (or touches on a touchscreen) and converts it to variables which other classes inspect, eg:
 *
 * - `Controller.speedOffset` - Used to calculate speed of forward movement
 * - `Controller.lateralOffset` - Used to calculate amount of lateral movement (steering)
 */

import { ASPECT_RATIO } from "./PD/ENUM.js";
import * as GAME from "./PD/GAME.js";

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

import { Game } from "./Game.js";

import {
  getNearestEvenNumber,
  hexOpacityToRGBA,
  modulo,
  vectorGetMagnitude,
  vectorToDegrees,
} from "./utils.js";

class Controller {}

/**
 * @function init
 * @static
 *
 * @description
 * ##### Initialise the controller
 */
Controller.init = function () {
  Controller.speedOffset = 0;
  Controller.lateralOffset = 0;
  Controller.normalisedPointerDistanceFromCenter = 0.5;
};

/**
 * @function setupForLevel
 * @static
 *
 * @description
 * ##### Level data can alter parameters such as speed ranges and slipperiness
 * - Adjust according to the parameters
 * - Reset some other variables ready for the new level
 */
Controller.setupForLevel = function () {
  // TODO Should this be done on every frame as it takes the (changing) player radius into account?
  //Controller.updateLateralMultiplier();

  Controller.speedDamp =
    Game.curLevelData.controllerSpeedDamp || GAME.CONTROLLER_SPEED_DAMP;

  Controller.slipperiness =
    Game.curLevelData.controllerSlipperiness || GAME.CONTROLLER_SLIPPERINESS;

  Controller.damageAddedSlipperiness = 0;
};

/**
 * @function updateSizes
 * @static
 *
 * @description
 * ##### Called on first run and when viewport changes size
 */
Controller.updateSizes = function () {
  Controller.cornerRadius = Math.round(
    GAME.CONTROLLER_CORNER_RADIUS * Layout.proportionalMultiplier
  );

  Controller.arrowWidth = Math.round(
    GAME.CONTROLLER_ARROW_WIDTH * Layout.proportionalMultiplier
  );
  Controller.arrowThickness = getNearestEvenNumber(
    GAME.CONTROLLER_STICK_THICKNESS * Layout.proportionalMultiplier
  );

  Controller.updateLayout();
};

/**
 * @function updateLateralMultiplier
 * @static
 *
 * @description
 * ##### Adjust steering based on size and aspect ratio of the viewport
 */
Controller.updateLateralMultiplier = function () {
  var playerAllowance = Player.drawnRadius ? Player.drawnRadius * 2 : 0;

  if (Layout.sessionAspectRatio === ASPECT_RATIO.LANDSCAPE) {
    Controller.lateralMultiplier =
      (Layout.gameplayHeight - playerAllowance) /
      Layout.currentAspectUI.controller.height;
  } else {
    Controller.lateralMultiplier =
      (Layout.gameplayWidth - playerAllowance) /
      Layout.currentAspectUI.controller.width;
  }
};

/**
 * @function updatePointerPos
 * @static
 *
 * @description
 * ##### This is where speed and direction get set
 * - `Controller.stickPos` is the important object
 * - Like a real-world joystick, it's the cardinal to refer to to find what is happening in terms of speed and steering
 *
 * @param {number} _x - X coordinate of the pointer or most recent touch event
 * @param {number} _y - Y coordinate of the pointer or most recent touch event
 */
Controller.updatePointerPos = function (_pos) {
  var aimDiffX,
    aimDiffY,
    right = Controller.activeArea_rect.right,
    left = Controller.activeArea_rect.left,
    top = Controller.activeArea_rect.top,
    bottom = Controller.activeArea_rect.bottom;
  
  Controller.updateLateralMultiplier();

  Controller.globalPointerPos = {
    x: _pos.x,
    y: _pos.y,
  };

  Controller.stickAimPos = {
    x: _pos.x,
    y: _pos.y,
  };

  // Cap movement to boundaries
  if (Controller.stickAimPos.x > right) {
    Controller.stickAimPos.x = right;
  } else if (Controller.stickAimPos.x < left) {
    Controller.stickAimPos.x = left;
  }
  if (Controller.stickAimPos.y > bottom) {
    Controller.stickAimPos.y = bottom;
  } else if (Controller.stickAimPos.y < top) {
    Controller.stickAimPos.y = top;
  }

  // Don't move stick to pointer immediately, apply some friction
  aimDiffX =
    (Controller.stickAimPos.x - Controller.stickPos.x) /
    (Controller.slipperiness + Controller.damageAddedSlipperiness);
  aimDiffY =
    (Controller.stickAimPos.y - Controller.stickPos.y) /
    (Controller.slipperiness + Controller.damageAddedSlipperiness);

  Controller.stickPos.x += aimDiffX;
  Controller.stickPos.y += aimDiffY;

  // Axes swap depending on whether game orientation is landscape or portrait
  if (Layout.sessionAspectRatio === ASPECT_RATIO.LANDSCAPE) {
    Controller.speedOffset =
      (Controller.stickPos.x - Controller.stickOrigin.x) / Controller.speedDamp;
    Controller.lateralOffset =
      (Controller.stickOrigin.y - Controller.stickPos.y) *
      Controller.lateralMultiplier;
  } else {
    Controller.speedOffset =
      (Controller.stickPos.y - Controller.stickOrigin.y) / Controller.speedDamp;
    Controller.lateralOffset =
      (Controller.stickOrigin.x - Controller.stickPos.x) *
      Controller.lateralMultiplier;
  }

  Controller.updateDirectionInfo();

  Controller.updatePointerDistanceFromCenter();
};

/**
 * @function updateDirectionInfo
 * @static
 *
 * @description
 * ##### Keep some direction-related variables up to date
 * Other classes refer to them to understand what the player is doing.
 * - `Controller.scalarVectorOfTravel` - A vector representing the direction the player is moving in
 * - `Controller.normalisedVectorOfTravel` - The same vector as above, only normalised to give `x` and `y` values between `-1` and `1` (removing the speed and just maintaining the angle)
 * - `Controller.vectorOfTravelMagnitude` - The speed alone
 * - `Controller.angleOfTravel` - The angle in degrees
 */
Controller.updateDirectionInfo = function () {
// TODO Global replace to `velocityVectorOfTravel`
  Controller.scalarVectorOfTravel = {
    x: Controller.stickPos.x - Controller.stickOrigin.x,
    y: Controller.stickPos.y - Controller.stickOrigin.y,
  };

  Controller.vectorOfTravelMagnitude = vectorGetMagnitude(
    Controller.scalarVectorOfTravel
  );

// TODO Global replace to `unitVectorOfTravel`
  Controller.normalisedVectorOfTravel = {
    x: Controller.scalarVectorOfTravel.x / Controller.vectorOfTravelMagnitude,
    y: Controller.scalarVectorOfTravel.y / Controller.vectorOfTravelMagnitude,
  };

  Controller.angleOfTravel = vectorToDegrees(Controller.scalarVectorOfTravel);
  // Rotate by 90 as that makes sense in our game space
  Controller.angleOfTravel += 90;
  Controller.angleOfTravel = modulo(Controller.angleOfTravel, 360);
};

/**
 * @function updatePointerDistanceFromCenter
 * @static
 *
 * @description
 * ##### The pointer distance from the centre of the control area is used to decide on opacity for the controller background
 * - The further away the pointer gets from the controller the more opaque the background becomes
 * - When the pointer is over the control area the background disappears
 * - This is intended to draw attention to the control area and educate the player on how it works
 */
Controller.updatePointerDistanceFromCenter = function () {
  var xDist = Math.abs(Controller.midX - Controller.globalPointerPos.x),
    yDist = Math.abs(Controller.midY - Controller.globalPointerPos.y);

  Controller.pointerDistanceFromCenter = Math.sqrt(
    xDist * xDist + yDist * yDist
  );

  // Represent distance as number between 0 and 1
  Controller.normalisedPointerDistanceFromCenter =
    Controller.pointerDistanceFromCenter /
    Controller.maxPossibleDistanceFromCenter;
};

/**
 * @function setOrigin
 * @static
 *
 * @description
 * ##### The origin of the stick is used to work out how far it is being pushed and calculate steering/speed
 * - The origin is not necessarily the centre of the control area
 * - When viewpoort layout changes, the origin must be recalculated
 * - Although there is no visible stick, it exists conceptually (the arrow represents the top of the stick)
 */
Controller.setOrigin = function () {
  var ui = Layout.currentAspectUI;

  if (Layout.sessionAspectRatio === ASPECT_RATIO.LANDSCAPE) {
    Controller.stickOrigin = {
      x:
        Controller.posX +
        ui.controller.width * GAME.CONTROLLER_STICK_MIDPOS_SPEED,
      y:
        Controller.posY +
        ui.controller.height * GAME.CONTROLLER_STICK_MIDPOS_LATERAL,
    };
  } else {
    Controller.stickOrigin = {
      x:
        Controller.posX +
        ui.controller.width * GAME.CONTROLLER_STICK_MIDPOS_LATERAL,
      y:
        Controller.posY +
        ui.controller.height * GAME.CONTROLLER_STICK_MIDPOS_SPEED,
    };
  }

  // return stick to origin
  Controller.stickPos = {
    x: Controller.stickOrigin.x,
    y: Controller.stickOrigin.y,
  };
};

/**
 * @function updateLayout
 * @static
 *
 * @description
 * ##### Whenever the viewport size/aspecct ratio changes, we must recalibrate the controller
 */
Controller.updateLayout = function () {
  var xDistMax,
    yDistMax,
    ui = Layout.currentAspectUI,
    pos = Layout.getAlignedPos(
      ui.controller.alignH,
      ui.controller.alignV,
      ui.controller.width,
      ui.controller.height
    );

  Controller.posX = pos.x;
  Controller.posY = pos.y;

  Controller.setOrigin();

  Controller.activeArea_rect = {
    left: pos.x - GAME.CONTROLLER_ACTIVE_PADDING_PX,
    top: pos.y - GAME.CONTROLLER_ACTIVE_PADDING_PX,
    right: pos.x + ui.controller.width + GAME.CONTROLLER_ACTIVE_PADDING_PX,
    bottom: pos.y + ui.controller.height + GAME.CONTROLLER_ACTIVE_PADDING_PX,
  };

  Controller.midX =
    Controller.activeArea_rect.left +
    (Controller.activeArea_rect.right - Controller.activeArea_rect.left) / 2;
  Controller.midY =
    Controller.activeArea_rect.top +
    (Controller.activeArea_rect.bottom - Controller.activeArea_rect.top) / 2;

  // Calculate largest possible distance pointer can be from the center of the
  // controller
  xDistMax = Math.abs(Layout.canvas_rect.left - Controller.midX);
  yDistMax = Math.abs(Layout.canvas_rect.top - Controller.midY);
  //__("xDistMax: " + xDistMax);
  //__("yDistMax: " + yDistMax);
  Controller.maxPossibleDistanceFromCenter = Math.sqrt(
    xDistMax * xDistMax + yDistMax * yDistMax
  );

  // Calculate largest possible distance stick can be from its origin
  xDistMax = Math.abs(
    Controller.activeArea_rect.left - Controller.stickOrigin.x
  );
  yDistMax = Math.abs(
    Controller.activeArea_rect.top - Controller.stickOrigin.y
  );
  Controller.maxPossibleStickDistanceFromOrigin = Math.sqrt(
    xDistMax * xDistMax + yDistMax * yDistMax
  );
};

/**
 * @function draw
 * @static
 *
 * @description
 * ##### Draw the controller to the canvas
 * - Set the opacity of the background based on how far away the pointer is
 * - Hide the background when the pointer is over the control area (if this is true, the [human] player must be controlling the [in-game] player)
 * - Call another method to draw the arrow
 */
Controller.draw = function () {
  var fillColor,
    fillAlpha,
    ui = Layout.currentAspectUI,
    itemPos = Layout.getAlignedPos(
      ui.controller.alignH,
      ui.controller.alignV,
      ui.controller.width,
      ui.controller.height
    );

  // bg
  if (!Game.pointerIsOverActiveArea) {
    Display.ctx.globalCompositeOperation = "luminosity";
    fillAlpha = Math.min(
      GAME.CONTROLAREA_OVERLAY_ALPHA_MAX,
      GAME.CONTROLAREA_OVERLAY_ALPHA_MIN +
        (GAME.CONTROLAREA_OVERLAY_ALPHA_MAX -
          GAME.CONTROLAREA_OVERLAY_ALPHA_MIN) *
          Controller.normalisedPointerDistanceFromCenter
    );
    fillColor = hexOpacityToRGBA(
      GAME.CONTROLAREA_OVERLAY_SOLID_COLOR,
      fillAlpha
    );
    Display.ctx.fillStyle = fillColor;

    if (Display.hasRoundRect) {
      Display.ctx.roundRect(
        itemPos.x,
        itemPos.y,
        ui.controller.width,
        ui.controller.height,
        Controller.cornerRadius
      );
      Display.ctx.fill();
    } else {
      Display.ctx.fillRect(
        itemPos.x,
        itemPos.y,
        ui.controller.width,
        ui.controller.height
      );
    }
    Display.ctx.globalCompositeOperation = "source-over";
  }
  Controller.drawArrow();
};

/**
 * @function drawArrow
 * @static
 *
 * @description
 * ##### Draw the arrow
 * - It gets more pointed to represent faster speeds
 * - The colour also changes to indicate speed
 */
Controller.drawArrow = function () {
  var arrowTipPos,
    stickX = Math.round(Controller.stickPos.x),
    stickY = Math.round(Controller.stickPos.y);

  Display.ctx.lineWidth = Controller.arrowThickness;
  Display.ctx.lineCap = GAME.CONTROLLER_STICK_LINECAP;
  Display.ctx.beginPath();
  if (Display.isLandscapeAspect) {
    arrowTipPos = Math.round(
      stickX + Controller.speedOffset * GAME.CONTROLLER_SPEED_ARROW_MULTIPLIER
    );
    Display.ctx.moveTo(stickX, stickY - Controller.arrowWidth);
    Display.ctx.lineTo(arrowTipPos, stickY);
    Display.ctx.lineTo(stickX, stickY + Controller.arrowWidth);
  } else {
    arrowTipPos = Math.round(
      stickY + Controller.speedOffset * GAME.CONTROLLER_SPEED_ARROW_MULTIPLIER
    );
    Display.ctx.moveTo(stickX - Controller.arrowWidth, stickY);
    Display.ctx.lineTo(stickX, arrowTipPos);
    Display.ctx.lineTo(stickX + Controller.arrowWidth, stickY);
  }

  Display.ctx.strokeStyle = Display.textColorHighlight;
  Display.ctx.stroke();
  if (Controller.speedOffset > 0) {
    Display.ctx.strokeStyle = hexOpacityToRGBA(
      Display.textColor,
      Controller.speedOffset * GAME.CONTROLLER_SPEED_ALPHA_MULTIPLIER
    );
  }
  Display.ctx.stroke();

  // Fill inside with narrow overlay of level background colour
  Display.ctx.lineWidth = Controller.arrowThickness / 2;
  Display.ctx.strokeStyle = Display.bgColor;
  Display.ctx.stroke();
};

export { Controller };