wcanvas.js

/**
 * @file A Canvas Wrapper for JavaScript
 * @author
 * Marco4413 <{@link https://github.com/Marco4413}>
 * @license
 * Copyright (c) 2022 Marco4413 ({@link https://github.com/Marco4413/wCanvas})
 * 
 * Permission is hereby granted, free of charge, to any person
 * obtaining a copy of this software and associated documentation
 * files (the "Software"), to deal in the Software without
 * restriction, including without limitation the rights to use,
 * copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following
 * conditions:
 * 
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
 * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
 * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
 * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
 * OTHER DEALINGS IN THE SOFTWARE.
 */

/**
 * The version of the library
 * @constant
 * @type {String}
 */
export const version = "0.3.0";

/**
 * Used to compare two versions of the library
 * @function
 * @param {String} v1 - The version that the return value is based on
 * @param {String} v2 - The version to compare the first one to
 * @returns {Number}
 *  - -1 if v1 is lower than v2;
 *  - 0 if v1 is equal to v2;
 *  - 1 if v1 is higher than v2.
 */
export const compVersions = (v1, v2) => {
    // If the two versions are equal then they must be equal
    // (Who would have thought)
    if (v1 === v2) { return 0; }

    // Splitting the two versions and parsing their numbers
    const val1 = v1.split(".").map(v => Number(v));
    const val2 = v2.split(".").map(v => Number(v));

    // Getting the max length of the versions
    const maxLen = Math.max(val1.length, val2.length);
    // Looping from the most significant digit to the least
    for (let i = 0; i < maxLen; i++) {
        const num1 = val1[i] === undefined ? 0 : val1[i];
        const num2 = val2[i] === undefined ? 0 : val2[i];

        // If this digit is higher then it's a newer version
        if (num1 > num2) {
            return 1;
        
        // Else the opposite
        } else if (num1 < num2) {
            return -1;
        }
    }

    // If it didn't return then the versions should be equal
    // Even though we should never get down here
    return 0;
}

/**
 * Generates an UUID used for all auto generated stuff from this library
 * @function
 * @returns {Number} An UUID within the library
 */
export const generateUUID = (function () {
    let uuid = 0;
    return () => { return uuid++; }
})();

/**
 * Formats a string by replacing `{i}` with `formats[i]`
 * @function
 * @example formatString("const {0} = {1};", "variableName", "value") => "const variableName = value;"
 * @param {String} str - The string to format
 * @param {...any} [formats] - The things to replace `{i}` with
 * @returns {String} The formatted string
 */
export const formatString = (str, ...formats) => {
    formats.forEach(
        (format, i) => {
            str = str.replace("{" + i + "}", String(format));
        }
    );

    return str;
}

/**
 * A namespace that provides some useful math functions
 * @namespace
 */
export class UMath {

    constructor() {
        throw new Error("This class is supposed to be a namespace, you can't call its constructor!");
    }

    /**
     * Constrains a number between the specified range
     * @method
     * @static
     * @param {Number} val - The number to be constrained
     * @param {Number} start - The minimum number that the specified one can be
     * @param {Number} end - The maximum number that the specified one can be
     * @returns {Number} The constrained number
     */
    static constrain(val, start, end) {
        return Math.max(Math.min(val, end), start);
    }

    /**
     * Linearly interpolates the specified number to the specified target
     * @method
     * @static
     * @param {Number} val - The number to interpolate
     * @param {Number} target - The target of the specified value
     * @param {Number} perc - How near value should be to target [0; 1]
     * @returns {Number} The interpolated value
     */
    static lerp(val, target, perc) {
        return val + (target - val) * UMath.constrain(perc, 0, 1);
    }

    /**
     * Maps the specified value that is in the range [start1; end1] to the new range [start2; end2]
     * @method
     * @static
     * @param {Number} val - The value to be mapped
     * @param {Number} start1 - The start of the value's range
     * @param {Number} end1 - The end of the value's range
     * @param {Number} start2 - The start of the value's new range
     * @param {Number} end2 - The end of the value's new range
     * @param {Boolean} constrain - Whether or not the mapped value should be contrained to the new range
     * @returns {Number} The mapped value
     */
    static map(val, start1, end1, start2, end2, constrain = false) {
        const mappedValue = (val - start1) / (end1 - start1) * (end2 - start2) + start2;
        return constrain ? UMath.constrain(mappedValue, start2, end2) : mappedValue;
    }

    /**
     * Returns the squared distance between two points (See: {@link UMath.dist})
     * @method
     * @static
     * @param {Number} x1 - The x of the first point
     * @param {Number} y1 - The y of the first point
     * @param {Number} x2 - The x of the second point
     * @param {Number} y2 - The y of the second point
     * @returns {Number} The squared distance between the two points
     */
    static distSq(x1, y1, x2, y2) {
        const x = x2 - x1;
        const y = y2 - y1;
        return x * x + y * y;
    }

    /**
     * Returns the distance between the specified points (See: {@link UMath.distSq})
     * @method
     * @static
     * @param {Number} x1 - The x of the first point
     * @param {Number} y1 - The y of the first point
     * @param {Number} x2 - The x of the second point
     * @param {Number} y2 - The y of the second point
     * @returns {Number} The distance between the two points
     */
    static dist(x1, y1, x2, y2) {
        return Math.sqrt(UMath.distSq(x1, y1, x2, y2));
    }

}

/**
 * @typedef {Object} Vec2Object - An object representation of a 2D Vector
 * @property {Number} x - The x component of the Vector
 * @property {Number} y - The y component of the Vector
 */

/**
 * A 2D Vector Class
 * @class
 */
UMath.Vec2 = class {

    /**
     * @constructor
     * @param {Number} [x] - The x component of the new Vector
     * @param {Number} [y] - The y component of the new Vector
     */
    constructor(x = 0, y = 0) {
        this.x = x;
        this.y = y;
    }

    /**
     * Creates a new Vector that is equal to this
     * @method
     * @returns {UMath.Vec2} A copy of this Vector
     */
    copy() {
        return new UMath.Vec2(this.x, this.y);
    }

    /**
     * Returns this Vector as a String (e.g. "(0, 0)")
     * (See {@link UMath.Vec2.fromString} to get a Vector from a String)
     * @method
     * @returns {String} This Vector as a String
     */
    toString() {
        return `(${this.x}, ${this.y})`;
    }

    /**
     * Returns the squared magnitude of the Vector (See {@link UMath.Vec2#mag})
     * @method
     * @returns {Number} The squared magnitude
     */
    magSq() {
        return this.x * this.x + this.y * this.y;
    }

    /**
     * Returns the magnitude of the Vector (See {@link UMath.Vec2#magSq})
     * @method
     * @returns {Number} The magnitude
     */
    mag() {
        return Math.sqrt(this.magSq());
    }

    /**
     * Returns the squared distance between this and the specified Vector (See {@link UMath.Vec2#dist})
     * @method
     * @param {UMath.Vec2|Vec2Object} other - The other Vector
     * @returns {Number} The squared distance between the two vectors
     */
    distSq(other) {
        return UMath.distSq(this.x, this.y, other.x, other.y);
    }

    /**
     * Returns the distance between this and the specified Vector (See {@link UMath.Vec2#distSq})
     * @method
     * @param {UMath.Vec2|Vec2Object} other - The other Vector
     * @returns {Number} The distance between the two vectors
     */
    dist(other) {
        return UMath.dist(this.x, this.y, other.x, other.y);
    }

    /**
     * Calculates the dot product between this and the specified Vector
     * @method
     * @param {UMath.Vec2} other - The other Vector to calculate the dot product with
     * @returns {Number} The dot product between this and the specified Vector
     */
    dot(other) {
        return this.x * other.x + this.y * other.y;
    }

    /**
     * Calculates the Z value of the 3D cross product Vector between this and the specified Vector
     * @method
     * @param {UMath.Vec2} other - The other Vector to calculate the cross product with
     * @returns {Number} The Z value of the 3D cross product Vector
     */
    cross(other) {
        return this.x * other.y - this.y * other.x;
    }
    
    /**
     * Adds the specified Vector or scalar to this
     * (See {@link UMath.Vec2.add} for a static version of this method)
     * @method
     * @param {Number|UMath.Vec2|Vec2Object} other - The Vector or scalar to add
     * @returns {UMath.Vec2} this
     */
    add(other) {
        if (typeof other === "object") {
            this.x += other.x;
            this.y += other.y;
        } else {
            this.x += other;
            this.y += other;   
        }
        return this;
    }

    /**
     * Subtracts the specified Vector or scalar to this
     * (See {@link UMath.Vec2.sub} for a static version of this method)
     * @method
     * @param {Number|UMath.Vec2|Vec2Object} other - The Vector or scalar to subtract
     * @returns {UMath.Vec2} this
     */
    sub(other) {
        if (typeof other === "object") {
            this.x -= other.x;
            this.y -= other.y;
        } else {
            this.x -= other;
            this.y -= other;   
        }
        return this;
    }

    /**
     * Multiplies this by the specified scalar
     * (See {@link UMath.Vec2.mul} for a static version of this method)
     * @method
     * @param {Number} other - The scalar to add
     * @returns {UMath.Vec2} this
     */
    mul(scalar) {
        this.x *= scalar;
        this.y *= scalar;
        return this;
    }

    /**
     * Divides this by the specified scalar
     * (See {@link UMath.Vec2.div} for a static version of this method)
     * @method
     * @param {Number} other - The scalar to add
     * @returns {UMath.Vec2} this
     */
    div(scalar) {
        this.x /= scalar;
        this.y /= scalar;
        return this;
    }

    /**
     * Rotates this by the specified angle
     * (See {@link UMath.Vec2.rotate} for a static version of this method)
     * @method
     * @param {Number} angle - The angle to rotate this Vector by
     * @returns {UMath.Vec2} this
     */
    rotate(angle) {
        const cos = Math.cos(angle);
        const sin = Math.sin(angle);
        const newX = cos * this.x - sin * this.y;
        const newY = sin * this.x + cos * this.y;
        this.x = newX;
        this.y = newY;
        return this;
    }

    /**
     * Makes the magnitude of this Vector equal to 1
     * (See {@link UMath.Vec2.normalize} for a static version of this method)
     * @method
     * @returns {UMath.Vec2} this
     */
    normalize() {
        return this.div(this.mag());
    }

    /**
     * Returns a new Vector parsed from the specified String (of type "x,y", it removes parenthesis and extra spaces automatically)
     * (See {@link UMath.Vec2#toString} to get a String from a Vector)
     * @method
     * @static
     * @param {String} str - The String to parse
     * @returns {UMath.Vec2} The Vector parsed from the String
     */
    static fromString(str) {
        const components = str.replace(/[( )]/g, "").split(",");
        const [x, y] = [parseInt(components[0]), parseInt(components[1])];

        return new UMath.Vec2(Number.isNaN(x) ? undefined : x, Number.isNaN(y) ? undefined : y);
    }

    /**
     * Returns a new Vector which is the sum of the two specified elements
     * (See {@link UMath.Vec2#add} for a non-static version of this method)
     * @method
     * @static
     * @param {UMath.Vec2|Vec2Object} v - The Vector to add to
     * @param {Number|UMath.Vec2|Vec2Object} other - The Vector or scalar to add
     * @returns {UMath.Vec2} The sum of the two elements
     */
    static add(v, other) {
        if (typeof other === "object") {
            return new UMath.Vec2(v.x + other.x, v.y + other.y);
        }
        return new UMath.Vec2(v.x + other, v.y + other);
    }

    /**
     * Returns a new Vector which is the subtraction of the two specified elements
     * (See {@link UMath.Vec2#sub} for a non-static version of this method)
     * @method
     * @static
     * @param {UMath.Vec2|Vec2Object} v - The Vector to subtract from
     * @param {Number|UMath.Vec2|Vec2Object} other - The Vector or scalar to subtract
     * @returns {UMath.Vec2} The subtraction of the two elements
     */
    static sub(v, other) {
        if (typeof other === "object") {
            return new UMath.Vec2(v.x - other.x, v.y - other.y);
        }
        return new UMath.Vec2(v.x - other, v.y - other);
    }

    /**
     * Returns a new Vector which is the multiplication of the specified Vector by the specified scalar
     * (See {@link UMath.Vec2#mul} for a non-static version of this method)
     * @method
     * @static
     * @param {UMath.Vec2|Vec2Object} v - The Vector to multiply
     * @param {Number} scalar - The scalar to multiply the Vector by
     * @returns {UMath.Vec2} The multiplication between the specified Vector and the scalar
     */
    static mul(v, scalar) {
        return new UMath.Vec2(v.x * scalar, v.y * scalar);
    }

    /**
     * Returns a new Vector which is the division of the specified Vector by the specified scalar
     * (See {@link UMath.Vec2#div} for a non-static version of this method)
     * @method
     * @static
     * @param {UMath.Vec2|Vec2Object} v - The Vector to divide
     * @param {Number} scalar - The scalar to divide the Vector by
     * @returns {UMath.Vec2} The division between the specified Vector and the scalar
     */
    static div(v, scalar) {
        return new UMath.Vec2(v.x / scalar, v.y / scalar);
    }

    /**
     * Returns a new Vector equal to the specified Vector rotated by the specified angle
     * (See {@link UMath.Vec2#rotate} for a non-static version of this method)
     * @method
     * @static
     * @param {UMath.Vec2|Vec2Object} v - The Vector to rotate
     * @param {Number} angle - The angle to rotate the specified Vector by
     * @returns {UMath.Vec2} A new Vector equal to the specified Vector rotated by the specified angle
     */
    static rotate(v, angle) {
        const cos = Math.cos(angle);
        const sin = Math.sin(angle);
        return new UMath.Vec2(cos * v.x - sin * v.y, sin * v.x + cos * v.y);
    }

    /**
     * Returns a new Vector which is the specified one with a magnitude of 1
     * (See {@link UMath.Vec2#normalize} for a non-static version of this method)
     * @method
     * @static
     * @param {UMath.Vec2|Vec2Object} v - The Vector to normalize
     * @returns {UMath.Vec2} The normalized equivalent of the specified Vector
     */
    static normalize(v) {
        const magnitude = Math.sqrt(v.x * v.x + v.y * v.y);
        return new UMath.Vec2(v.x / magnitude, v.y / magnitude);
    }

}

/**
 * @callback wCanvas_onResize - Function that gets called on window resize
 * @param {wCanvas} canvas - The canvas it's attached to
 * @param {Number} targetWidth - The width that this canvas should have
 * @param {Number} targetHeight - The height that this canvas should have
 * @returns {undefined}
 */

/**
 * @callback wCanvas_onSetup - Function that gets called after the class was constructed
 * @param {wCanvas} canvas - The canvas it's attached to
 * @returns {undefined}
 */

/**
 * @callback wCanvas_onDraw - Function that gets called every frame
 * @param {wCanvas} canvas - The canvas it's attached to
 * @param {Number} deltaTime - The time elapsed between frames in seconds
 * @returns {undefined}
 */

/**
 * @typedef {Object} wCanvasConfig - wCanvas's config
 * @property {String} [id] - The id of the canvas you want to wrap
 * @property {HTMLCanvasElement} [canvas] - The canvas you want to wrap
 * @property {Number} [width] - The desired width of the canvas if onResize is undefined
 * @property {Number} [height] - The desired height of the canvas if onResize is undefined
 * @property {wCanvas_onResize} [onResize] - Called when canvas needs to be resized
 * @property {wCanvas_onSetup} [onSetup] - Called when canvas is fully initialized
 * @property {wCanvas_onDraw} [onDraw] - Called every frame
 * @property {Number} [targetFPS] - The FPS target of the canvas (If negative it will draw every time the browser lets it)
 */

/**
 * @typedef {Object} ShapeConfig - Shapes' config
 * @property {Boolean} [noStroke] - Whether or not stroke should be applied
 * @property {Boolean} [noFill] - Whether or not the shape should be filled
 */

/**
 * @typedef {Object} Alignment - Alignment options
 * @property {"left"|"center"|"right"} [horizontal] - Where the origin of the shape should be relative to it horizontally
 * @property {"top"|"center"|"bottom"} [vertical] - Where the origin of the shape should be relative to it vertically
 */

/**
 * @typedef {Object} RectConfig - Rectangles' config
 * @property {Boolean} [noStroke] - Whether or not stroke should be applied
 * @property {Boolean} [noFill] - Whether or not the shape should be filled
 * @property {Alignment} [alignment] - Specifies the position of the origin of the rectangle
 * @property {Object} [rounded] - If not undefined the rectangle will be drawn as if it has round corners and its x and y will be the center of it
 * @property {Array<Boolean>} [rounded.corners] - Corners that should be drawn (clockwise starting from the top-left one), if undefined all corners will be drawn
 * @property {"percentage"|"pixels"} [rounded.radiusMode] - How {@link RectConfig}#rounded.radius should be handled, if undefined it defaults to "percentage".<br>
 *  "percentage": Takes the smallest between the width and the height and uses it to calculate the radius of the corner in pixels;<br>
 *  "pixels": It uses the specified number as the radius.
 * @property {Number} [rounded.radius] - Its behaviour depends on {@link RectConfig}#rounded.radiusMode
 */

/**
 * @typedef {Object} PathConfig - Paths' config
 * @property {Boolean} [noStroke] - Whether or not stroke should be applied
 * @property {Boolean} [noFill] - Whether or not the shape should be filled
 * @property {Boolean} [noClose] - Whether or not the path should be closed
 * @property {Boolean} [round] - Whether or not corners should be round
 */

/**
 * @typedef {Object} TextConfig - Texts' config
 * @property {Alignment} [alignment] - Specifies the position of the origin of the text
 * @property {Boolean} [noStroke] - Whether or not stroke should be applied
 * @property {Boolean} [noFill] - Whether or not the shape should be filled
 * @property {Number} [maxWidth] - Text's max width
 * @property {Boolean} [returnWidth] - Whether or not to return text's width after it was drawn
 */

/**
 * @typedef {Array<Number>} RGBColor - An RGB Color that could include Alpha (An Array of 3/4 Numbers)
 */

/**
 * @typedef {Object} ColorType - An Object containing info about a Color's type (Used for {@link Color.getType})
 * @property {"Color"|"rgb"|"hex"|"css"|"grayscale"|"unknown"} type - The type of the Color
 * @property {Boolean} isValid - Whether or not the Color is valid
 */

/**
 * Stores font informations (To be used with {@link wCanvas#textFont})
 * @class
 */
export class Font {
    /**
     * The font family
     * @field
     * @type {String}
     */
    fontFamily;
    /**
     * The size of the font
     * @field
     * @type {Number}
     */
    fontSize;
    /**
     * Other font attributes like "italic", "oblique" and others
     * @field
     * @type {String[]}
     */
    attributes;

    /**
     * @constructor
     * @default
     * @param {String} [fontFamily] - The Font Family to use
     * @param {Number} [fontSize] - The size of the font
     * @param {...String} [attributes] - All the extra CSS Attributes for the font
     */
    constructor(fontFamily = "Arial", fontSize = 12, ...attributes) {
        this.fontFamily = fontFamily;
        this.fontSize = fontSize;
        this.attributes = attributes;
    }

    /**
     * Returns this font as a CSS property
     * @method
     * @returns {String} The font as a CSS property
     */
    toCSSProperty() {
        return formatString("{0} {1}px \"{2}\"", this.attributes.join(" "), this.fontSize, this.fontFamily);
    }
}

/**
 * Error given when a Color is invalid
 * @class
 * @extends {Error}
 */
export class InvalidColor extends Error {
    /**
     * @constructor
     * @param {any} color - The color that generated this error
     * @param {"Color"|"rgb"|"hex"|"css"|"grayscale"|"any"} type - The type of the color expected
     */
    constructor(color, type) {
        super(
            type === "any" || type === "Color" ?
                formatString("The color \"{0}\" isn't a valid one."      , String(color)      ) :
                formatString("The color \"{0}\" isn't a valid {1} color.", String(color), type)
        );
        this.name = "InvalidColor";
    }
}

/**
 * Used as a lookup to convert css colors to hex
 * @private
 */
const _Color_CSSToHex = {
    "aliceblue": "#f0f8ff",
    "antiquewhite": "#faebd7",
    "aqua": "#00ffff",
    "aquamarine": "#7fffd4",
    "azure": "#f0ffff",
    "beige": "#f5f5dc",
    "bisque": "#ffe4c4",
    "black": "#000000",
    "blanchedalmond": "#ffebcd",
    "blue": "#0000ff",
    "blueviolet": "#8a2be2",
    "brown": "#a52a2a",
    "burlywood": "#deb887",
    "cadetblue": "#5f9ea0",
    "chartreuse": "#7fff00",
    "chocolate": "#d2691e",
    "coral": "#ff7f50",
    "cornflowerblue": "#6495ed",
    "cornsilk": "#fff8dc",
    "crimson": "#dc143c",
    "cyan": "#00ffff",
    "darkblue": "#00008b",
    "darkcyan": "#008b8b",
    "darkgoldenrod": "#b8860b",
    "darkgray": "#a9a9a9",
    "darkgreen": "#006400",
    "darkgrey": "#a9a9a9",
    "darkkhaki": "#bdb76b",
    "darkmagenta": "#8b008b",
    "darkolivegreen": "#556b2f",
    "darkorange": "#ff8c00",
    "darkorchid": "#9932cc",
    "darkred": "#8b0000",
    "darksalmon": "#e9967a",
    "darkseagreen": "#8fbc8f",
    "darkslateblue": "#483d8b",
    "darkslategray": "#2f4f4f",
    "darkslategrey": "#2f4f4f",
    "darkturquoise": "#00ced1",
    "darkviolet": "#9400d3",
    "deeppink": "#ff1493",
    "deepskyblue": "#00bfff",
    "dimgray": "#696969",
    "dimgrey": "#696969",
    "dodgerblue": "#1e90ff",
    "firebrick": "#b22222",
    "floralwhite": "#fffaf0",
    "forestgreen": "#228b22",
    "fuchsia": "#ff00ff",
    "gainsboro": "#dcdcdc",
    "ghostwhite": "#f8f8ff",
    "gold": "#ffd700",
    "goldenrod": "#daa520",
    "gray": "#808080",
    "green": "#008000",
    "greenyellow": "#adff2f",
    "grey": "#808080",
    "honeydew": "#f0fff0",
    "hotpink": "#ff69b4",
    "indianred": "#cd5c5c",
    "indigo": "#4b0082",
    "ivory": "#fffff0",
    "khaki": "#f0e68c",
    "lavender": "#e6e6fa",
    "lavenderblush": "#fff0f5",
    "lawngreen": "#7cfc00",
    "lemonchiffon": "#fffacd",
    "lightblue": "#add8e6",
    "lightcoral": "#f08080",
    "lightcyan": "#e0ffff",
    "lightgoldenrodyellow": "#fafad2",
    "lightgray": "#d3d3d3",
    "lightgreen": "#90ee90",
    "lightgrey": "#d3d3d3",
    "lightpink": "#ffb6c1",
    "lightsalmon": "#ffa07a",
    "lightseagreen": "#20b2aa",
    "lightskyblue": "#87cefa",
    "lightslategray": "#778899",
    "lightslategrey": "#778899",
    "lightsteelblue": "#b0c4de",
    "lightyellow": "#ffffe0",
    "lime": "#00ff00",
    "limegreen": "#32cd32",
    "linen": "#faf0e6",
    "magenta": "#ff00ff",
    "maroon": "#800000",
    "mediumaquamarine": "#66cdaa",
    "mediumblue": "#0000cd",
    "mediumorchid": "#ba55d3",
    "mediumpurple": "#9370db",
    "mediumseagreen": "#3cb371",
    "mediumslateblue": "#7b68ee",
    "mediumspringgreen": "#00fa9a",
    "mediumturquoise": "#48d1cc",
    "mediumvioletred": "#c71585",
    "midnightblue": "#191970",
    "mintcream": "#f5fffa",
    "mistyrose": "#ffe4e1",
    "moccasin": "#ffe4b5",
    "navajowhite": "#ffdead",
    "navy": "#000080",
    "oldlace": "#fdf5e6",
    "olive": "#808000",
    "olivedrab": "#6b8e23",
    "orange": "#ffa500",
    "orangered": "#ff4500",
    "orchid": "#da70d6",
    "palegoldenrod": "#eee8aa",
    "palegreen": "#98fb98",
    "paleturquoise": "#afeeee",
    "palevioletred": "#db7093",
    "papayawhip": "#ffefd5",
    "peachpuff": "#ffdab9",
    "peru": "#cd853f",
    "pink": "#ffc0cb",
    "plum": "#dda0dd",
    "powderblue": "#b0e0e6",
    "purple": "#800080",
    "red": "#ff0000",
    "rosybrown": "#bc8f8f",
    "royalblue": "#4169e1",
    "saddlebrown": "#8b4513",
    "salmon": "#fa8072",
    "sandybrown": "#f4a460",
    "seagreen": "#2e8b57",
    "seashell": "#fff5ee",
    "sienna": "#a0522d",
    "silver": "#c0c0c0",
    "skyblue": "#87ceeb",
    "slateblue": "#6a5acd",
    "slategray": "#708090",
    "slategrey": "#708090",
    "snow": "#fffafa",
    "springgreen": "#00ff7f",
    "steelblue": "#4682b4",
    "tan": "#d2b48c",
    "teal": "#008080",
    "thistle": "#d8bfd8",
    "tomato": "#ff6347",
    "turquoise": "#40e0d0",
    "violet": "#ee82ee",
    "wheat": "#f5deb3",
    "white": "#ffffff",
    "whitesmoke": "#f5f5f5",
    "yellow": "#ffff00",
    "yellowgreen": "#9acd32"
};

/**
 * Used as a lookup to convert hex colors to css
 * @private
 */
const _Color_hexToCSS = { }; { Object.keys(_Color_CSSToHex).forEach(k => _Color_hexToCSS[_Color_CSSToHex[k]] = k); }

/**
 * Used internally to fill {@link Color#value} in {@link Color#toRGB}
 * and to complete colors from {@link Color.HexToRGB}
 * @private
 * @default
 * @param {Array<Number>} rgb - An incomplete RGB color
 * @param {Boolean} [includeAlpha] - Whether or not to include Alpha in the return value
 * @returns {RGBColor} The complete RGB equivalent to the one specified
 * @throws {InvalidColor}
 */
function fillRGB(rgb, includeAlpha = true) {
    switch (rgb.length) {
        case 1: {
            const grayScale = rgb[0];
            rgb = [ grayScale, grayScale, grayScale, 255 ];
            break;
        }
        case 2: {
            const grayScale = rgb[0];
            rgb = [ grayScale, grayScale, grayScale, rgb[1] ];
            break;
        }
        case 3: {
            rgb = [ rgb[0], rgb[1], rgb[2], 255 ];
            break;
        }
        case 4: {
            rgb = rgb.slice();
            break;
        }
        default: {
            throw new InvalidColor(rgb, "rgb");
        }
    }

    if (!includeAlpha) {
        // If we don't need the alpha we pop it from the Array
        rgb.pop();
    }
    return rgb;
}

/**
 * Holds a color that can be retrieved with {@link Color#toRGB}
 * @class
 */
export class Color {
    
    /**
     * The currently stored color
     * @field
     * @type {Array<Number>}
     */
    value;

    /**
     * @constructor
     * @param {Color|String|Number|RGBColor} color - Can either be a CSS, Hex or RGB Color
     * @throws {InvalidColor}
     */
    constructor(color) {

        const colorType = Color.getType(color);

        switch (colorType.type) {
            case "Color": {
                if (colorType.isValid) {
                    this.value = color.value.slice();
                } else {
                    throw new InvalidColor(color, "Color");
                }
                break;
            }
            case "rgb": {
                if (colorType.isValid) {
                    this.value = fillRGB(color, true);
                } else {
                    throw new InvalidColor(color, "rgb");
                }
                break;
            }
            case "hex": {
                if (colorType.isValid) {
                    this.value = Color.HexToRGB(color, true);
                } else {
                    throw new InvalidColor(color, "hex");
                }
                break;
            }
            case "css": {
                if (colorType.isValid) {
                    this.value = Color.CSSToRGB(color, true);
                } else {
                    throw new InvalidColor(color, "css");
                }
                break;
            }
            case "grayscale": {
                if (colorType.isValid) {
                    this.value = [ color, color, color, 255 ];
                } else {
                    throw new InvalidColor(color, "grayscale");
                }
                break;
            }
            default: {
                throw new InvalidColor(color, "any");
            }
        }
    }

    /**
     * Returns {@link Color#value} as a valid RGB color
     * @method
     * @default
     * @param {Boolean} [includeAlpha] - Whether or not to include Alpha in the return value
     * @returns {RGBColor} {@link Color#value} as a valid RGB color
     * @throws {InvalidColor}
     */
    toRGB(includeAlpha = true) {
        for (let i = 0; i < this.value.length; i++) {
            if (typeof this.value[i] !== "number" || this.value[i] < 0 || this.value[i] > 255) {
                throw new InvalidColor(this.value, "rgb");
            }
        }

        return fillRGB(this.value, includeAlpha);
    }

    /**
     * Returns {@link Color#value} as a valid hex color
     * @method
     * @default
     * @param {Boolean} [includeAlpha] - Whether or not to include Alpha in the return value
     * @returns {String} {@link Color#value} as a valid hex color
     * @throws {InvalidColor}
     */
    toHex(includeAlpha = true) {
        return "#" + this.toRGB(includeAlpha).map(
            v => {
                const hex = v.toString(16);
                return hex.length === 1 ? "0" + hex : hex;
            }
        ).join("");
    }

    /**
     * Returns {@link Color#value} as a CSS color
     * @method
     * @returns {String|undefined} {@link Color#value} as a CSS color
     * @throws {InvalidColor}
     */
    toCSS() {
        return _Color_hexToCSS[this.toHex(false)];
    }

    /**
     * Returns a copy of this Color
     * @method
     * @returns {Color} A copy of this Color
     * @throws {InvalidColor}
     */
    copy() {
        return new Color(this.value);
    }

    /**
     * Converts the specified hex color to an RGB one
     * @method
     * @static
     * @default
     * @param {String} hex - The hex color to convert
     * @param {Boolean} [includeAlpha] - Whether or not to include Alpha in the return value
     * @returns {RGBColor} The specified hex color as an RGB one
     * @throws {InvalidColor}
     */
    static HexToRGB(hex, includeAlpha = true) {

        // NOTE: If this function throws an InvalidColor Error of type "rgba" then something went wrong

        if (typeof hex === "string" && hex.startsWith("#") && hex.search(/[^0-f#]/g) < 0) {
            hex = hex.substr(1);

            // "#g", "#RGB", "#RGBA"
            // "#f", "#f0f", "#f0f0"
            if (hex.length >= 1 && hex.length <= 4 && hex.length !== 2) {
                const color = hex.match(/[0-f]{1}/g).map(v => parseInt(v, 16) / 15 * 255);
                return fillRGB(color, includeAlpha);

            // "#gg", "#RRGGBB", "#RRGGBBAA"
            // "#ff", "#ff00ff", "#ff00ff00"
            } else if (hex.length === 2 || hex.length === 6 || hex.length === 8) {
                const color = hex.match(/[0-f]{2}/g).map(v => parseInt(v, 16));
                return fillRGB(color, includeAlpha);

            }

            hex = "#" + hex;
        }

        throw new InvalidColor(hex, "hex");
    }
    
    /**
     * Converts the specified css color to an RGB one
     * @method
     * @static
     * @default
     * @param {String} css - The name of the color to convert
     * @param {Boolean} [includeAlpha] - Whether or not to include Alpha in the return value
     * @returns {RGBColor} The specified css color as an RGB one
     * @throws {InvalidColor}
     */
    static CSSToRGB(css, includeAlpha = true) {
        try {
            return Color.HexToRGB(_Color_CSSToHex[css.toLowerCase()], includeAlpha);
        } catch (err) {
            throw new InvalidColor(css, "css");
        }
    }

    /**
     * Returns the type of the specified color and whether or not it is valid
     * @method
     * @static
     * @param {Color|String|Number|RGBColor} color - The color to analyze
     * @returns {ColorType} An Object containing the color's type and whether or not it's valid
     */
    static getType(color) {
        if (typeof color === "string") {

            if (color.startsWith("#")) {
                return {
                    "type": "hex",
                    "isValid": (color.length >= 2 && color.length <= 5 || color.length === 7 || color.length === 9) && color.search(/[^#0-f]/g) < 0
                };
            }

            return {
                "type": "css",
                "isValid": _Color_CSSToHex[color] !== undefined
            };

        } else if (typeof color === "number") {

            return {
                "type": "grayscale",
                "isValid": color >= 0 && color <= 255
            };

        } else if (color instanceof Array) {

            let isValid = color.length >= 1 && color.length <= 4;

            if (isValid) {
                for (let i = 0; i < color.length; i++) {
                    if (typeof color[i] !== "number" || color[i] < 0 || color[i] > 255) {
                        isValid = false;
                        break;
                    }
                }
            }

            return {
                "type": "rgb",
                isValid
            };

        } else if (color instanceof Color) {

            // color#value should be an instance of Array
            // This also prevents recursion from happening if color#value is assigned to a Color
            // Which shouldn't happen in the first place
            if (!(color.value instanceof Array)) {
                return {
                    "type": "Color",
                    "isValid": false
                };
            }

            // From Color.getType we only want to get whether or not Color#value is valid
            const type = Color.getType(color.value);
            // So we change its type to Color
            type.type = "Color";
            return type;

        }

        return {
            "type": "unknown",
            "isValid": false
        };
    }
}

/**
 * Error given when a Canvas ID is invalid
 * @class
 * @extends {Error}
 */
export class InvalidCanvasIDException extends Error {
    /**
     * @constructor
     * @param {String} id - The id that generated this error
     */
    constructor(id) {
        super(formatString("No canvas with ID \"{0}\" was found!", id));
        this.name = "InvalidCanvasIDException";
    }
}

/**
 * Wraps a Canvas and provides useful functions
 * @class
 */
export class wCanvas {
    /**
     * The target FPS of the canvas (If it's a negative value it will try to draw every time it's possible)
     * @field
     * @type {Number}
     */
    targetFPS;
    
    /**
     * The native canvas element
     * @field
     * @constant
     * @type {HTMLCanvasElement} 
     */
    element;

    /**
     * The 2D drawing context of {@link wCanvas#element}
     * @field
     * @constant
     * @type {CanvasRenderingContext2D}
     */
    context;

    /**
     * The function to call on setup event
     * @field
     * @type {wCanvas_onSetup}
     */
    onSetup;
    
    /**
     * The function to call on draw event
     * @field
     * @type {wCanvas_onDraw}
     */
    onDraw;
    
    /**
     * The function to call on resize event
     * @field
     * @type {wCanvas_onResize}
     */
    onResize;
    
    /**
     * @field
     * @private
     * @type {Number}
     */
    _lastFrameTimeStamp;
    
    /**
     * @field
     * @private
     * @type {Boolean}
     */
    _isLooping;

    /**
     * @field
     * @private
     * @type {Number|undefined}
     */
    _desiredWidth;
    
    /**
     * @field
     * @private
     * @type {Number|undefined}
     */
    _desiredHeight;

    /**
     * @field
     * @private
     * @type {() => undefined}
     */
    _resize;
    
    /**
     * @field
     * @private
     * @type {() => undefined}
     */
    _setup;
    
    /**
     * @field
     * @private
     * @type {() => undefined}
     */
    _draw;

    /**
     * @constructor
     * @param {wCanvasConfig} [config] - The config to create the wCanvas with
     * @throws {InvalidCanvasIDException}
     */
    constructor(config = {}) {
        // Check if a Canvas was specified
        if (config.canvas === undefined || config.canvas === null) {
            // Check if an ID was specified
            if (config.id === undefined) {
                // If no ID was given then create a new canvas using an UUID
                this.element = document.createElement("canvas");
                this.element.id = String(generateUUID());
                document.body.appendChild(this.element);
            } else {
                // Get the canvas with the given ID
                this.element = document.getElementById(config.id);
                if (this.element === null) {
                    throw new InvalidCanvasIDException(config.id);
                }
            }
        } else {
            // Use the specified canvas
            this.element = config.canvas;
        }

        // Get the context of the created Canvas
        this.context = this.element.getContext("2d");

        this._desiredWidth  = config.width;
        this._desiredHeight = config.height;

        this.onSetup  = config.onSetup ;
        this.onDraw   = config.onDraw  ;
        this.onResize = config.onResize;

        this._resize = (...args) => {
            const targetWidth  = this._desiredWidth  === undefined ? (window.innerWidth  + 1) : this._desiredWidth ;
            const targetHeight = this._desiredHeight === undefined ? (window.innerHeight + 1) : this._desiredHeight;
            if (this.onResize === undefined) {
                this.element.width  = targetWidth ;
                this.element.height = targetHeight;
            } else {
                this.onResize(this, targetWidth, targetHeight, ...args);
            }
        }

        this._resize();

        // Add an event listener to the resize event
        window.addEventListener("resize", this._resize);

        // A negative number of FPS means "Draw every time the browser lets you"
        this.targetFPS = config.targetFPS === undefined ? -1 : config.targetFPS;

        this._setup = (...args) => {
            if (this.onSetup === undefined) {
                this.startLoop();
            } else {
                this.onSetup(this, ...args);
            }
        };

        this._draw = (deltaTime, ...args) => {
            if (this.onDraw !== undefined) {
                this.onDraw(this, deltaTime, ...args);
            }
        };

        this._lastFrameTimeStamp = 0;
        this._isLooping = false;
        this._setup();
    }

    /**
     * Starts draw loop (Doesn't do that if one was already started)
     * @method
     * @returns {undefined}
     */
    startLoop() {
        // If it's already looping then nothing should be done
        if (this._isLooping) {
            return;
        }

        this._isLooping = true;

        const callDraw = () => {
            if (this.targetFPS !== 0) {
                this.context.save();
                
                const newTimestamp = Date.now();
                this._draw((newTimestamp - this._lastFrameTimeStamp) / 1_000);
                this._lastFrameTimeStamp = newTimestamp;

                this.context.restore();
            }

            if (!this._isLooping) {
                return;
            }

            if (this.targetFPS <= 0) {
                requestAnimationFrame(callDraw);
            } else {
                setTimeout(callDraw, 1 / this.targetFPS * 1_000);
            }
        };

        this._lastFrameTimeStamp = Date.now();
        callDraw();
    }
    
    /**
     * Stops draw loop
     * @method
     * @returns {undefined}
     */
    stopLoop() {
        this._isLooping = false;
    }

    /**
     * Whether or not this canvas's draw loop is running
     * @method
     * @returns {Boolean}
     */
    isLooping() {
        return this._isLooping;
    }

    /**
     * Saves canvas context to be restored at a later date
     * (Using `{@link wCanvas#context}#save` is better if you don't need to save the context multiple times)
     * @method
     * @default
     * @param {Number} [n] - How many times the context should be saved
     * @returns {undefined}
     */
    save(n = 1) {
        if (n > 1) {
            for (let i = 0; i < n; i++) {
                this.context.save();
            }
        } else {
            this.context.save();
        }
    }

    /**
     * Restores canvas context from last save
     * (Using `{@link wCanvas#context}#restore` is better if you don't need to restore the context multiple times)
     * @method
     * @default
     * @param {Number} [n] - How many times the context should be restored
     * @returns {undefined}
     */
    restore(n = 1) {
        if (n > 1) {
            for (let i = 0; i < n; i++) {
                this.context.restore();
            }
        } else {
            this.context.restore();
        }
    }

    /**
     * Translates every next shape by the specified offset
     * (Using `{@link wCanvas#context}#translate` is better if you don't need the default values)
     * @method
     * @default
     * @param {Number} [x] - X translation
     * @param {Number} [y] - Y translation (Same as X if `undefined`)
     * @returns {undefined}
     */
    translate(x = 0, y) {
        this.context.translate(x, y === undefined ? x : y);
    }
    
    /**
     * Rotates every next shape by the specified angle in radians
     * (Using `{@link wCanvas#context}#rotate` is better if you don't need the default values)
     * @method
     * @default
     * @param {Number} [angle] - Angle in radians
     * @returns {undefined}
     */
    rotate(angle = 0) {
        this.context.rotate(angle);
    }

    /**
     * Scales every next shape by the specified values
     * (Using `{@link wCanvas#context}#scale` is better if you don't need the default values)
     * @method
     * @default
     * @param {Number} [x] - Horizontal Scale
     * @param {Number} [y] - Vertical Scale (Same as X if `undefined`)
     * @returns {undefined}
     */
    scale(x = 1, y) {
        this.context.scale(x, y === undefined ? x : y);
    }

    /**
     * Clears the whole canvas
     * @method
     * @returns {undefined}
     */
    clear() {
        this.context.clearRect(0, 0, this.element.width, this.element.height);
    }

    /**
     * Draws a rectangle that fills the entire canvas using the specified color
     * @method
     * @default
     * @param {Number|Color} [r] - Red [0, 255] if not a number it's treated as a {@link Color}
     * @param {Number} [g] - Green [0, 255]
     * @param {Number} [b] - Blue [0, 255]
     * @param {Number} [a] - Alpha [0, 255]
     * @returns {undefined}
     */
    background(r = 0, g = 0, b = 0, a = 255) {
        this.context.save();
        
        this.context.resetTransform();
        this.fill(r, g, b, a);
        this.rect(0, 0, this.element.width, this.element.height, { "noStroke": true });
        
        this.context.restore();
    }

    /**
     * Sets the color to be used to fill shapes
     * @method
     * @default
     * @param {Number|Color} [r] - Red [0, 255] if not a number it's treated as a {@link Color}
     * @param {Number} [g] - Green [0, 255]
     * @param {Number} [b] - Blue [0, 255]
     * @param {Number} [a] - Alpha [0, 255]
     * @returns {undefined}
     */
    fill(r = 255, g = 255, b = 255, a = 255) {
        if (typeof r !== "number") {
            [ r, g, b, a ] = r.toRGB(true);
        }

        this.context.fillStyle = `rgba(${ r }, ${ g }, ${ b }, ${ a / 255 })`;
    }

    /**
     * Sets the color to be used for shapes contours
     * @method
     * @default
     * @param {Number|Color} [r] - Red [0, 255] if not a number it's treated as a {@link Color}
     * @param {Number} [g] - Green [0, 255]
     * @param {Number} [b] - Blue [0, 255]
     * @param {Number} [a] - Alpha [0, 255]
     * @returns {undefined}
     */
    stroke(r = 0, g = 0, b = 0, a = 255) {
        if (typeof r !== "number") {
            [ r, g, b, a ] = r.toRGB(true);
        }

        this.context.strokeStyle = `rgba(${ r }, ${ g }, ${ b }, ${ a / 255 })`;
    }

    /**
     * Changes stroke diameter
     * (Setting `{@link wCanvas#context}#lineWidth` is better if you don't need the default values)
     * @method
     * @default
     * @param {Number} [d] - The diameter of the stroke
     * @returns {undefined}
     */
    strokeWeight(d = 1) {
        this.context.lineWidth = d;
    }

    /**
     * Draws a rectangle at the specified location with the specified properties
     * @method
     * @param {Number} x - The x coordinate where the rectangle should be drawn
     * @param {Number} y - The y coordinate where the rectangle should be drawn
     * @param {Number} w - The width of the rectangle
     * @param {Number} h - The height of the rectangle
     * @param {RectConfig} [config] - Other options
     * @returns {undefined}
     */
    rect(x, y, w, h, config = {}) {
        const alignmentSettings = config.alignment === undefined ? {} : config.alignment;
        switch (alignmentSettings.horizontal) {
            case "center": {
                x -= w / 2;
                break;
            }
            case "right": {
                x -= w;
                break;
            }
        }

        switch (alignmentSettings.vertical) {
            case "center": {
                y -= h / 2;
                break;
            }
            case "top": {
                y -= h;
                break;
            }
        }

        if (config.rounded) {
            // Afraid of bind's performance, kept just in case I change my mind
            // const goTo = (config.noFill ? this.context.moveTo : this.context.lineTo).bind(this.context);

            const radiusPixels = config.rounded.radiusMode === "pixels" && config.rounded.radius !== undefined ?
                config.rounded.radius :
                Math.min(w, h) / 2 * ( config.rounded.radius === undefined ? 1 : config.rounded.radius );
            const corners = config.rounded.corners === undefined ? [ true, true, true, true ] : config.rounded.corners;
    
            const halfWidth = (w - radiusPixels * 2) / 2;
            const halfHeight = (h - radiusPixels * 2) / 2;
    
            this.context.beginPath();
            
            // Corners are drawn clockwise
            // Corners don't end at the center of the rectangle, so if only the two opposite corners are enabled the shape is going to be weird
            // Top-left corner
            if (corners[0]) {
                if (config.noFill) {
                    this.context.moveTo(x, y + radiusPixels + halfHeight);
                } else {
                    this.context.lineTo(x, y + radiusPixels + halfHeight);
                }
                this.context.lineTo(x, y + radiusPixels);
                this.context.quadraticCurveTo(x, y, x + radiusPixels, y);
                this.context.lineTo(x + radiusPixels + halfWidth, y);
            }
    
            // Top-right corner
            if (corners[1]) {
                if (config.noFill) {
                    this.context.moveTo(x + radiusPixels + halfWidth, y);
                } else {
                    this.context.lineTo(x + radiusPixels + halfWidth, y);
                }
                this.context.lineTo(x + radiusPixels + halfWidth * 2, y);
                this.context.quadraticCurveTo(x + w, y, x + w, y + radiusPixels);
                this.context.lineTo(x + w, y + radiusPixels + halfHeight);
            }
    
            // Bottom-right corner
            if (corners[2]) {
                if (config.noFill) {
                    this.context.moveTo(x + w, y + radiusPixels + halfHeight);
                } else {
                    this.context.lineTo(x + w, y + radiusPixels + halfHeight);
                }
                this.context.lineTo(x + w, y + radiusPixels + halfHeight * 2);
                this.context.quadraticCurveTo(x + w, y + h, x + radiusPixels + halfWidth * 2, y + h);
                this.context.lineTo(x + radiusPixels + halfWidth, y + h);
            }
    
            // Bottom-left corner
            if (corners[3]) {
                if (config.noFill) {
                    this.context.moveTo(x + radiusPixels + halfWidth, y + h);
                } else {
                    this.context.lineTo(x + radiusPixels + halfWidth, y + h);
                }
                this.context.lineTo(x + radiusPixels, y + h);
                this.context.quadraticCurveTo(x, y + h, x, y + radiusPixels + halfHeight * 2);
                this.context.lineTo(x, y + radiusPixels + halfHeight);
            }

            if (!config.noFill) {
                this.context.closePath();
                this.context.fill();
            }
    
            if (!config.noStroke) {
                this.context.stroke();
            }

        } else {
            if (!config.noFill) {
                this.context.fillRect(x, y, w, h);
            }
    
            if (!config.noStroke) {
                this.context.strokeRect(x, y, w, h);
            }
        }
    }

    /**
     * Draws a circle at the specified location with the specified properties
     * @method
     * @param {Number} x - The x coordinate where the circle should be drawn
     * @param {Number} y - The y coordinate where the circle should be drawn
     * @param {Number} r - The radius of the circle
     * @param {ShapeConfig} [config] - Other options
     * @returns {undefined}
     */
    circle(x, y, r, config = {}) {
        this.context.beginPath();

        this.context.arc(x, y, r, 0, Math.PI * 2);
        
        if (!config.noFill) {
            this.context.fill();
        }

        if (!config.noStroke) {
            this.context.stroke();
        }
    }

    /**
     * Draws an ellipse at the specified location with the specified properties
     * @method
     * @param {Number} x - The x coordinate where the ellipse should be drawn
     * @param {Number} y - The y coordinate where the ellipse should be drawn
     * @param {Number} rX - The radius on the x axis of the ellipse
     * @param {Number} [rY] - The radius on the y axis of the ellipse (Same as rX if `undefined`)
     * @param {ShapeConfig} [config] - Other options
     * @returns {undefined}
     */
    ellipse(x, y, rX, rY, config = {}) {
        this.context.beginPath();

        this.context.ellipse(x, y, rX, rY === undefined ? rX : rY, 0, 0, Math.PI * 2);
        
        if (!config.noFill) {
            this.context.fill();
        }

        if (!config.noStroke) {
            this.context.stroke();
        }
    }

    /**
     * Draws a line from x1, y1 to x2, y2
     * @method
     * @default
     * @param {Number} x1 - The starting x coordinate 
     * @param {Number} y1 - The starting y coordinate 
     * @param {Number} x2 - The end x coordinate 
     * @param {Number} y2 - The end y coordinate 
     * @param {Boolean} [round] - Whether or not line ends should be rounded
     * @returns {undefined}
     */
    line(x1, y1, x2, y2, round = true) {
        const oldLineCap = this.context.lineCap;
        this.context.lineCap = round ? "round" : "square";
        
        this.context.beginPath();
        this.context.moveTo(x1, y1);
        this.context.lineTo(x2, y2);
        this.context.stroke();

        this.context.lineCap = oldLineCap;
    }

    /**
     * Draws a shape using the specified vertices (Tries to draw only if there are 2 or more vertices specified)
     * @method
     * @param {Number} [x] - The x pos from where the path should be drawn
     * @param {Number} [y] - The y pos from where the path should be drawn
     * @param {Array<Array<Number>>|Array<UMath.Vec2>|Array<Vec2Object>} vertices - Array of Vertices (A vertex is this kind of array [x, y])
     * @param {PathConfig} [config] - Other options
     * @returns {undefined}
     */
    path(x = 0, y = 0, vertices, config = {}) {
        if (vertices.length <= 1) {
            return;
        }

        this.context.beginPath();

        const firstVertex = vertices[0];
        const isVec2Array = !Array.isArray(firstVertex);

        if (isVec2Array) {
            this.context.moveTo(
                firstVertex.x + x,
                firstVertex.y + y
            );
        } else {
            this.context.moveTo(
                firstVertex[0] + x,
                firstVertex[1] + y
            );
        }

        for (let i = 1; i < vertices.length; i++) {
            const vertex = vertices[i];
            if (isVec2Array) {
                this.context.lineTo(
                    vertex.x + x,
                    vertex.y + y
                );
            } else {
                this.context.lineTo(
                    vertex[0] + x,
                    vertex[1] + y
                );
            }
        }

        if (!config.noClose) {
            this.context.closePath();
        }

        if (!config.noFill) {
            this.context.fill();
        }

        if (!config.noStroke) {
            const oldLineJoin = this.context.lineJoin;
            const oldLineCap  = this.context.lineCap;

            this.context.lineJoin = config.round || config.round === undefined ? "round" : "miter";
            this.context.lineCap  = config.round || config.round === undefined ? "round" : "square";

            this.context.stroke();
            
            this.context.lineJoin = oldLineJoin;
            this.context.lineCap  = oldLineCap;
        }
    }

    /**
     * Changes font
     * @method
     * @param {Font} [font] - The new font to use
     * @returns {undefined}
     */
    textFont(font = new Font()) {
        this.context.font = font.toCSSProperty();
    }

    /**
     * Changes font's size
     * @method
     * @default
     * @param {Number} [size] - The new size for the font
     * @returns {undefined}
     */
    textSize(size = 12) {
        this.context.font = this.context.font.replace(/\d*\.?\d*px/g, String(size) + "px");
    }

    /**
     * Draws the specified text at the specified coordinates
     * @method
     * @param {String} text - The text to be written
     * @param {Number} x - The x coordinate where the text should be drawn
     * @param {Number} y - The y coordinate where the text should be drawn
     * @param {TextConfig} [config] - Other options
     * @returns {undefined|Number} If config.returnWidth is true it returns the text's width
     */
    text(text, x, y, config = {}) {
        const alignmentSettings = config.alignment === undefined ? {} : config.alignment;
        const textMeasures =
            config.returnWidth || alignmentSettings.horizontal === "center" || alignmentSettings.horizontal === "right"
                               || alignmentSettings.vertical   === "center" || alignmentSettings.vertical   === "top"
            ? this.context.measureText(text) : undefined
        ;
        
        switch (alignmentSettings.horizontal) {
            case "center": {
                x -= textMeasures.width / 2;
                break;
            }
            case "right": {
                x -= textMeasures.width;
                break;
            }
        }

        switch (alignmentSettings.vertical) {
            case "top": {
                y += textMeasures.actualBoundingBoxAscent + textMeasures.actualBoundingBoxDescent;
                break;
            }
            case "center": {
                y += (textMeasures.actualBoundingBoxAscent + textMeasures.actualBoundingBoxDescent) / 2;
                break;
            }
        }

        if (!config.noFill) {
            this.context.fillText(text, x, y, config.maxWidth);
        }

        if (!(config.noStroke || config.noStroke === undefined)) {
            this.context.strokeText(text, x, y, config.maxWidth);
        }

        if (config.returnWidth) {
            return textMeasures.width;
        }
    }
}