camera.js

import { vectors }  from "./vector.js"

/** Camera for use in gameify
 * @example // Use via gameify
 * import { gameify } from "./gameify/gameify.js"
 * let myScreen = new gameify.Screen(canvas, 600, 400);
 * myScreen.camera.translate(50, 70);
 * @global
 */
export let camera = {
    /** A camera (controls the viewport of the game)
     * @alias gameify.Camera
     * @constructor
     * @example
     * import { gameify } from "./gameify/gameify.js"
     * let myScreen = new gameify.Screen(canvas, 600, 400);
     * myScreen.camera.translationSpeed = 0.7;
     * myScreen.camera.translate(50, 70);
     * @arg {CanvasRenderingContext2D} context - The canvas context
     */
    Camera: class {
        constructor (context) {
            this.#context = context;
            this.maxDistance = this.#context.canvas.height * 0.5
        }

        /** The translation speed of the camera. 1 is instant, 0 is stationary.
         * @default 1
         * @type {Number}
         */
        translationSpeed = 1;
        /** The maximum distance the camera can be from its target position, in pixels.
         * If the camera goes too far away, it will speed up to stay close to the target.
         * (Note, this distance is measured as a square, not a circle)
         * @example // Set speed to zero to disable interpolation
         * camera.translationSpeed = 0; // or camera.setSpeed(0)
         * // Create a 100 px "dead zone" in the center of the screen before
         * // the camera starts following
         * camera.maxDistance = 100;
         * @default canvas height * 0.5
         * @type {Number}
         */
        maxDistance;
        /** The minimum distance the camera must be from its target position, in pixels,
         * before the camera starts to move. (Note, this distance is measured as a square, not a circle)
         * @default 1
         * @type {Number}
         */
        minDistance = 1;

        #context;
        #translationTarget = new vectors.Vector2d(0, 0);
        #currentTranslation = new vectors.Vector2d(0, 0);

        /** Set the translation speed of the camera. 1 is instant, 0 is stationary.
         * @method
         * @param {Number} speed - The speed of camera translations
         */
        setSpeed = (speed) => {
            this.translationSpeed = speed;
        }

        /** Get the current position of the camera
         * @method
         * @returns {gameify.Vector2d}
         */
        getPosition = () => {
            return this.#currentTranslation
        }

        /** Convert screen coordinates to world coordinates
         * @method
         * @arg {Number} x - The x screen coordinate
         * @arg {Number} y - The y screen coordinate
         * @returns {gameify.Vector2d}
         *//** Convert screen coordinates to world coordinates
          * @param {gameify.Vector2d} position - The position to convert
          * @returns {gameify.Vector2d}
          */
        screenToWorld = (screenx, screeny) => {
            if (screenx.x != undefined && screenx.y != undefined) {
                screeny = screenx.y;
                screenx = screenx.x;
            }
            return new vectors.Vector2d(screenx, screeny).subtract(this.#currentTranslation);
        }

        /** Translate the camera (relative to the current transform)
         * @method
         * @param {Number} x - The x amount, in pixels, to translate
         * @param {Number} y - The y amount, in pixels, to translate
         *//** Translate the camera (relative to the current transform)
         * @method
         * @param {gameify.Vector2d} amount - The amount, in pixels, to translate
         */
        translate = (x, y) => {
            if (x.x != undefined && x.y != undefined) {
                // Convert vector to x and y
                y = x.y;
                x = x.x;
            }
            if (typeof x != 'number' || typeof y != 'number') {
                throw new Error("X and Y position passed to camera.translate are not numbers!");
            }
            this.#translationTarget.x += x;
            this.#translationTarget.y += y;
        }

        /** Translate the camera (using absolute positioning)
         * @method
         * @param {Number} x - The x position to translate to
         * @param {Number} y - The y position to translate to
         *//** Translate the camera (using absolute positioning)
         * @method
         * @param {gameify.Vector2d} position - The position to translate to
         */
        translateAbsolute = (x, y) => {
            if (x.x != undefined && x.y != undefined) {
                // Convert vector to x and y
                y = x.y;
                x = x.x;
            }
            if (typeof x != 'number' || typeof y != 'number') {
                throw new Error("X and Y position passed to camera.translateAbsolute are not numbers!");
            }
            this.#translationTarget.x = x;
            this.#translationTarget.y = y;
        }

        /** Position the center of the screen (using absolute position). Useful for following a player/sprite
         * @method
         * @param {Number} x - The x position to translate to
         * @param {Number} y - The y position to translate to
         * @param {Number} offsetx - Y offset (from given position)
         * @param {Number} offsety - X offset (from given position)
         *//** Position the center of the screen (using absolute position). Useful for following a player/sprite
         * @method
         * @param {gameify.Vector2d} position - The position to translate to
         * @param {gameify.Vector2d} [offset=new gameify.Vector2d(0, 0)] - Offset by an amount
         */
        focus = (x, y, ox = 0, oy = 0) => {
            let position = new vectors.Vector2d(x);
            let offset = new vectors.Vector2d(y);

            if (x.x == undefined && x.y == undefined) {
                // not vectors, convert
                position = new vectors.Vector2d(x, y);
                offset = new vectors.Vector2d(ox, oy);
            }

            const screenSize = new vectors.Vector2d(this.#context.canvas.width, this.#context.canvas.height);
            let screenOffset = position.subtract(screenSize.multiply(.5)).truncated();
            screenOffset = screenOffset.add(offset);

            try {
                this.translateAbsolute(screenOffset.multiply(-1));
            } catch (e) {
                throw new Error(`Bad values passed to camera.focus (could not translate). position=${position} offset=${offset}`);
            }
        }

        /** Reset the camera's (canvas's) transformation
         * @method
         */
        resetTransform = () => {
            this.#context.setTransform(1, 0, 0, 1, 0, 0);
        }

        /** Sets the camera draw mode.
         * Set to 'ui' mode before drawing UI elements, set to 'world' for drawing everything else.
         * (draw mode is reset to 'world' each frame).
         * Equivalent to calling camera.resetTransform() or camera.update(0)
         * @tutorial camera
         * @method
         * @param {String} mode - 'world' or 'ui'
         */
        setDrawMode = (mode) => {
            mode = mode.toLowerCase();
            if (mode === 'ui') this.resetTransform();
            else if (mode === 'world') this.update(0); // move to position, without actually moving the camera
            else console.warn('Unknown draw mode: ' + mode);
        }

        /** Update the camera. If you're using the a gameify.Screen's camera, this will be called automatically.
         * This function rounds translation vectors to whole numbers for better rendering
         * @method
         * @param {Number} delta - The time, in milliseconds, since the last frame
         */
        update = (delta) => {
            this.resetTransform();

            const distX = this.#translationTarget.x - this.#currentTranslation.x;
            const distXAbs = Math.abs(distX);
            const distY = this.#translationTarget.y - this.#currentTranslation.y;
            const distYAbs = Math.abs(distY);

            const frameTargetPos = this.#translationTarget.copy();

            // If x or y is w/in min distance, 
            if (distXAbs < this.minDistance) frameTargetPos.x = this.#currentTranslation.x;
            if (distYAbs < this.minDistance) frameTargetPos.y = this.#currentTranslation.y;

            if (this.translationSpeed >= 1) {
                // Move instantly, speed is set to instant
                this.#currentTranslation = frameTargetPos.copy();

            } else if (distXAbs > this.maxDistance || distYAbs > this.maxDistance) {
                // Stay inside square boundary w/ side length of 2 * maxDistance
                if (distXAbs > this.maxDistance) {
                    this.#currentTranslation.x = frameTargetPos.x - (Math.sign(distX) * this.maxDistance);
                }
                if (distYAbs > this.maxDistance) {
                    this.#currentTranslation.y = frameTargetPos.y - (Math.sign(distY) * this.maxDistance);
                }

            } else {
                // Move by linear interpolation
                const lerpAmount = 1 - Math.pow(1 - this.translationSpeed, delta);
                this.#currentTranslation = this.#currentTranslation.linearInterpolate(frameTargetPos, lerpAmount);
            }

            const position = this.#currentTranslation.truncated();
            this.#context.translate(position.x, position.y);
        }
    }
}