Source: Game.js

import {
    GameShape,
    GameElement,
    Point,
    GameComposite,
    GameButton,
    GameTextInput,
    GameCanvas,
    GameGrid,
    GameRangeSlider
} from "./index.js";

/**
 * Game class. It manages the game state and elements within it.
 * @property {Array<GameElement>} elements Array of GameElement objects
 * @property {Array<GameGrid>} grids Array of grids
 * @property {GameElement} selectedElement Element that is being dragged or held
 * @property {Point} selectedDelta Distance from mouse to center of dragged element
 * @property {Array<string>} pressedKeys Array of currently pressed keys
 * @property {{mousePos:Point,tempContext:CanvasRenderingContext2D}} shared Last recorded mouse position and temporary context shared with elements
 * @property {HTMLCanvasElement} canvas HTML canvas on which the game is played
 * @property {CanvasRenderingContext2D} context Rendering context for the canvas
 * @property {Array<function>} onClear What happens on clear() in addition to removing elements
 * @property {Array<function>} onClick Array of functions triggered on click
 * @property {Array<function>} onMove Array of functions triggered on drag
 * @property {Array<function>} onMouseUp Array of functions triggered on finish dragging
 */
class Game {
    #animationInterval = undefined
    #elements = []
    get elements() {
        return [...this.#elements]
    }
    #selectedElement = undefined
    get selectedElement() {
        return this.#selectedElement
    }
    #selectedDelta = undefined
    get selectedDelta() {
        return this.#selectedDelta.copy()
    }
    #pressedKeys = []
    get pressedKeys() {
        return [...this.#pressedKeys]
    }
    #shared = {
        tempContext: undefined,
        mousePos: undefined
    }
    get shared() {
        return this.#shared
    }
    #grids = []
    get grids() {
        return [...this.#grids]
    }
    #onClear = []
    get onClear() {
        return [...this.#onClear]
    }
    #onClick = []
    get onClick() {
        return [...this.#onClick]
    }
    #onMove = []
    get onMove() {
        return [...this.#onMove]
    }
    #onMouseUp = []
    get onMouseUp() {
        return [...this.#onMouseUp]
    }

    /**
     * Constructor of the Game class
     * @param {HTMLCanvasElement} canvas Canvas on which the game is played
     * @param {HTMLCanvasElement} tempCanvas (optional) Canvas used for click detection of elements
     */
    constructor(canvas,tempCanvas=undefined) {
        canvas.addEventListener("contextmenu",e=>e.preventDefault()) //prevent context menu
        this.canvas = canvas;
        this.context = canvas.getContext('2d');

        tempCanvas = tempCanvas || document.createElement('canvas')
        tempCanvas.width = this.canvas.width
        tempCanvas.height = this.canvas.height
        this.#shared.tempContext = tempCanvas.getContext('2d');

        canvas.addEventListener('mousedown',(ev => this.#mouseDown(ev)))
        canvas.addEventListener('touchstart',(ev => this.#mouseDown(ev)),false)
        canvas.addEventListener('mousemove',(ev => this.#mouseMove(ev)))
        canvas.addEventListener('touchmove',(ev => this.#mouseMove(ev)),false)
        canvas.addEventListener('mouseup',(ev => this.#mouseUp(ev)))
        canvas.addEventListener('touchend',(ev => this.#mouseUp(ev)),false)
        document.addEventListener('keydown',(ev => this.#keyDown(ev)))
        document.addEventListener('keyup',(ev => this.#keyUp(ev)))

        setInterval(()=>this.#keyHoldLoop(),30)

        this.animate()
    }

    /**
     * Returns center of the game canvas
     * @returns {Point} center of canvas
     */
    getCenter() {
        return new Point(
            this.canvas.width/2,
            this.canvas.height/2
        )
    }

    /**
     * Creates, adds and returns a blank element
     * @param {Object} attrs Element attributes
     * @returns {GameElement} New instance
     */
    createElement(attrs={}) {
        const el = new GameElement(this.getCenter(),[],attrs)
        this.addElement(el)
        return el
    }

    /**
     * Creates, adds and returns a button element
     * @param {Object} attrs Element attributes
     * @returns {GameButton} New instance
     */
    createButton(attrs={}) {
        const button = new GameButton(this.getCenter(),attrs)
        this.addElement(button)
        return button
    }

    /**
     * Creates, adds and returns a text input element
     * @param {Object} attrs Element attributes
     * @returns {GameTextInput} New instance
     */
    createTextInput(attrs={}) {
        const input = new GameTextInput(this.getCenter(),attrs)
        this.addElement(input)
        return input
    }

    /**
     * Creates, adds and returns a canvas element
     * @param {Object} attrs Element attributes
     * @returns {GameCanvas} New instance
     */
    createCanvas(attrs={}) {
        const canvas = new GameCanvas(this.getCenter(),attrs)
        this.addElement(canvas)
        return canvas
    }

    /**
     * Creates, adds and returns a range slider element
     * @param {Object} attrs Element attributes
     * @returns {GameRangeSlider} New instance
     */
    createRangeSlider(attrs={}) {
        const slider = new GameRangeSlider(this.getCenter(),attrs)
        this.addElement(slider)
        return slider
    }

    /**
     * Creates, adds and returns a blank composite object at (0,0)
     * @param {Object} attrs Element attributes
     * @returns {GameComposite} New instance
     */
    createComposite(attrs={}) {
        const composite = new GameComposite([],attrs)
        this.addElement(composite)
        return composite
    }

    /**
     * Calls the copy function of element and adds it to game
     * @param {GameElement|GameCanvas|GameButton|GameComposite|GameTextInput} element Element to copy
     * @param {string} newName Name of the newly created copy
     * @returns {GameElement|GameCanvas|GameButton|GameComposite|GameTextInput} New instance
     */
    copyElement(element,newName=undefined) {
        const copy = element.copy(newName)
        this.addElement(copy)

        if (copy instanceof GameComposite) {
            //also add its children elements
            copy.addToGame(this)
        }

        return copy
    }

    /**
     * Creates,adds and returns a new grid instance
     * @param {number} dx Deviation from right
     * @param {number} dy Deviation from top
     * @param {number} width Width of the grid
     * @param {number} height Height of the grid
     * @param {number} cols Number of columns
     * @param {number} rows Number of rows
     * @returns {GameGrid} New instance
     */
    createGrid(dx=0,dy=0,width=undefined,height=undefined,cols=undefined,rows=undefined) {
        const grid = new GameGrid(new Point(dx,dy),width,height,cols,rows)
        this.#grids.push(grid)
        return grid
    }

    /**
     * Sorts array of elements by level
     */
    updateLevels() {
        this.#elements = this.#elements.sort(((a, b) => a.level - b.level))

        this.#elements
            .filter(el=>el instanceof GameComposite)
            .forEach(el => el.sortElements())
    }

    /**
     * Moves element to top
     * @param {GameElement} element
     */
    moveElementToTop(element) {
        this.changeLevelOfElement(element,this.highestLevel()+1)
    }

    /**
     * Moves element to bottom
     * @param {GameElement} element
     */
    moveElementToBottom(element) {
        this.changeLevelOfElement(element,this.lowestLevel()-1)
    }

    /**
     * Returns the highest current level value
     * @returns {number} Currently highest level
     */
    highestLevel() {
        return Math.max(
            ...this.#elements
                .filter(e => !isInteractive(e))
                .map(el => el.level)
                .filter(l => l !== Number.POSITIVE_INFINITY)
        )
    }

    /**
     * Returns the lowest current level value
     * @returns {number} Currently lowest level
     */
    lowestLevel() {
        return Math.min(
            ...this.#elements
                .filter(e => !isInteractive(e))
                .map(el => el.level)
                .filter(l => l !== Number.NEGATIVE_INFINITY)
        )
    }

    /**
     * Changes the level of element and sorts elements by level
     * @param {GameElement} element Element with level to be changed
     * @param {number} newLevel New level value of the element
     */
    changeLevelOfElement(element,newLevel) {
        element.level = newLevel
        this.updateLevels()
    }

    /**
     * Adds a listener to the array onClickCallbacks
     * @param {function} callback function to be called
     */
    addOnMouseDownListener(callback) {
        this.#onClick.push(callback)
    }

    /**
     * Removes listener for the onClick event
     * @param {function} callback function you want to remove
     */
    removeOnMouseDownListener(callback) {
        this.#onClick = this.#onClick.filter(item=>item!==callback)
    }

    /**
     * @private
     * Handler for mouse click. Passes the event to relevant elements.
     * @param {MouseEvent|TouchEvent} event Mouse event passed
     */
    #mouseDown(event) {
        // one click at a time
        if (this.#selectedElement) {return}
        // prevent scrolling on touch
        if (!(event instanceof MouseEvent)) {
            event.preventDefault()
        }
        //get correct mouse position
        const mousePos = this.getMousePos(event)
        if (!this.mouseInBounds(mousePos)) {return}
        // get clicked element
        let el = this.getElementAtPos(mousePos)
        if (el === null) {
            //call generic onClick functions for game
            for (const callback of this.#onClick) {
                callback.call(this,event)
            }
        }
        // check if onClick created new element
        el = this.getElementAtPos(mousePos)
        if (el === null) {return}

        if (el.clickable) {
            el.click(event)
        }
        if (el.draggable || el.holdable) {
            this.#selectedElement = el
            if (el.draggable) {
                el.startDragging(event)
                this.#selectedDelta = mousePos.subtract(el.center)
            }
            if (el.holdable) {
                el.startMouseHold(event)
            }
        }
    }

    /**
     * Keeps dragged element on top of group
     * Adds or replaces the listener for starting dragging
     * @param {GameElement[]} elementArray Array of elements in a group
     */
    moveToTopWhenDragging(elementArray) {
        const game = this
        function moveToTop() {
            const maxLevel = Math.max(...elementArray.map(el=>el.level))
            const topLevelElements = elementArray.filter(el=>el.level === maxLevel)
            if (!topLevelElements.includes(this) || topLevelElements.length > 1) {
                game.changeLevelOfElement(this, maxLevel+1)
            }
        }

        for (const element of elementArray) {
            element.removeOnStartDraggingListener(moveToTop)
            element.addOnStartDraggingListener(moveToTop)
        }
    }

    /**
     * Adds a listener to the array of listeners for onDrag
     * @param {function} callback function to be called
     */
    addOnMouseMoveListener(callback) {
        this.#onMove.push(callback)
    }

    /**
     * Removes listener for the onDrag event
     * @param {function} callback function you want to remove
     */
    removeOnMouseMoveListener(callback) {
        this.#onMove = this.#onMove.filter(item=>item!==callback)
    }

    /**
     * @private
     * Handler for dragging. Triggers every time the mouse is dragged and will pass the event to the selected element (if one exists)
     * @param {MouseEvent|TouchEvent} event Mouse event passed
     */
    #mouseMove(event) {
        if (!(event instanceof MouseEvent)) {
            event.preventDefault()
        }
        for (const callback of this.#onMove) {
            callback.call(this,event)
        }
        this.#shared.mousePos = this.getMousePos(event)
        if (this.#selectedElement === undefined || !this.#selectedElement.draggable) {
            return
        }
        this.#selectedElement.drag(this.#shared.mousePos,this.#selectedDelta,event)
    }

    /**
     * Adds a listener to the array of listeners for onFinishDragging
     * @param {function} callback function to be called
     */
    addOnMouseUpListener(callback) {
        this.#onMouseUp.push(callback)
    }

    /**
     * Removes listener for the onFinishDragging event
     * @param {function} callback function you want to remove
     */
    removeOnMouseUpListener(callback) {
        this.#onMouseUp = this.#onMouseUp.filter(item=>item!==callback)
    }

    /**
     * @private
     * Handler for finishing dragging. Triggers on mouseup and will pass the event to the selected element (if one exists)
     * @param {MouseEvent|TouchEvent} event Mouse event passed
     */
    #mouseUp(event) {
        if (!(event instanceof MouseEvent)) {
            event.preventDefault()
        }
        for (const callback of this.#onMouseUp) {
            callback.call(this,event)
        }
        if (this.#selectedElement === undefined) {
            return
        }
        if (this.#selectedElement.draggable) {
            this.#selectedElement.finishDragging(event)
        }
        if (this.#selectedElement.holdable) {
            this.#selectedElement.finishMouseHold(event)
        }
        this.#selectedElement = undefined
        this.#selectedDelta = undefined
    }

    /**
     * @private
     * Handler for key press. Allows for multiple keys to be pressed. Triggers keyHold() function in relevant elements
     */
    #keyHoldLoop() {
        if (this.#pressedKeys.length === 0) {
            return
        }
        for (const key of this.#pressedKeys) {
            const objects = this.#elements.filter((el) => Object.keys(el.onKeyHold).includes(key))
            for (const element of objects) {
                element.keyHold(key)
            }
        }
    }

    /**
     * @private
     * Handler for key press. Adds key to the pressedKeys array and triggers keyPress() function in relevant elements
     * @param {KeyboardEvent} event Event with pressed key
     */
    #keyDown(event) {
        const key = event.key

        if (this.#pressedKeys.includes(key)) {
            return
        }

        this.#pressedKeys.push(key)

        //first press
        const listeners = this.#elements.filter((el) => {
            const keys = Object.keys(el.onKeyPress)
            return keys.filter(value => key === value)
        })

        for (const el of listeners) {
            el.keyPress(key,event)
        }
    }

    /**
     * @private
     * Handler for key press. Removes key from the pressedKeys array
     * @param {KeyboardEvent} event Event with pressed key
     */
    #keyUp(event) {
        const key = event.key
        const indexInArray = this.#pressedKeys.indexOf(key)
        if (indexInArray !== -1) {
            //if pressed remove from array of pressed keys
            this.#pressedKeys.splice(indexInArray,1)
        }
        for (const element of this.#elements.filter(el=>el.pressable)) {
            element.keyUp(event)
        }
    }

    /**
     * Adds element to the array of elements
     * @param {GameElement} element Added element
     * @param {boolean} sort Passing false improves the speed of adding elements, but requires later sorting (by level) for correct display
     */
    addElement(element,sort=true) {
        if (!(element instanceof GameElement)) {
            throw new TypeError("Incorrect element instance!")
        }
        if (this.#elements.some(el=>el===element)) {
            throw new Error("Trying to add already added object!")
        }

        const nameIsUsed = this.#elements.filter(c => c.name === element.name && element.name !== undefined).length > 0
        if (nameIsUsed) {
            throw new Error(`Used name "${element.name}"`);
        }
        element.shared = this.shared
        element.game = this
        this.#elements.push(element)
        if (sort) {
            this.updateLevels()
        }
    }

    /**
     * Adds multiple elements to game
     * @param {GameElement} elements
     */
    addElements(...elements) {
        for (const element of elements) {
            this.addElement(element,false)
        }
        this.updateLevels()
    }

    /**
     * Returns element with matching name or throws an error
     * @param {string} name Name of searched element
     * @returns {GameElement} Found element
     */
    getElementByName(name) {
        let el = this.#elements.filter(e => e.name === name)
        if (el.length === 0) {
            throw new Error(`No element has name "${name}"`)
        } else if (el.length > 1) {
            throw new Error(`There are multiple elements with name:"${name}".`)
        }
        return el[0]
    }

    /**
     * Removes element from elements array and returns it, or throws an error
     * @param {string} name Name of element to be removed
     * @returns {GameElement} Removed element
     */
    popElementByName(name) {
        const el = this.getElementByName(name)
        this.#elements = this.#elements.filter(e => e.name !== name)
        return el
    }

    /**
     * Removes element from elements array
     * @param {GameElement} element Element instance
     */
    removeElement(element) {
        this.#elements
            .filter(e => e instanceof GameComposite)
            .forEach(e => e.removeElement(element))

        this.#elements = this.#elements.filter(e => e !== element)
    }

    /**
     * Removes multiple elements from elements array
     * @param {GameElement} elements Element instances
     */
    removeElements(...elements) {
        this.#elements = this.#elements.filter(e => !elements.includes(e))
    }

    /**
     * @private
     * Calls the draw function of all elements
     */
    #draw() {
        this.context.setTransform(1,0,0,1,0,0);
        this.context.clearRect(0,0,this.canvas.width,this.canvas.width)

        for (const grid of this.#grids) {
            grid.draw(this.context)
        }

        this.#elements  // draw elements
            .filter(e => !isInteractive(e))
            .forEach(e => e.draw(this.context))

        this.#elements  // draw interactive elements (buttons and so on)
            .filter(e => isInteractive(e))
            .forEach(e => e.draw(this.context))
    }

    /**
     * Starts the animation loop
     */
    animate() {
        if (this.#animationInterval) {
            this.stopAnimation()
        }
        this.#animationInterval = setInterval(this.#animationLoop.bind(this),30)
    }

    /**
     * Stops the animation loop
     */
    stopAnimation() {
        clearInterval(this.#animationInterval)
    }

    /**
     * @private
     * Calls this.draw() on next animation frame, also calls animate() on Gif drawables
     */
    #animationLoop() {
        const animation = function () {
            this.#draw()

            this.#elements
                .forEach(obj => {
                    obj.animate()
                })
        }

        window.requestAnimationFrame(animation.bind(this))
    }

    /**
     * Sets current mouse position in the shared object and returns it
     * @param {MouseEvent|TouchEvent} event Mouse event passed
     * @returns {Point} Current mouse position
     */
    getMousePos(event) {
        const rect = this.canvas.getBoundingClientRect();
        let e = event
        if (!(event instanceof MouseEvent)) {
            // TouchEvent not defined in desktop browser
            e = event.touches.item(0)
            if (e === null) {
                return undefined
            }
        }
        this.#shared.mousePos = new Point(
            e.clientX - rect.left,
            e.clientY - rect.top
        )
        return this.#shared.mousePos
    }

    mouseInBounds(mousePos) {
        return mousePos.xWithin(0,this.canvas.width) && mousePos.yWithin(0,this.canvas.height)
    }

    /**
     * Returns element on position or null
     * @param {Point} position Searched position
     * @returns {null|GameElement} Element at position or null
     */
    getElementAtPos(position) {
        const interactive = this.#elements.filter(e => isInteractive(e))
        const uninteractive = this.#elements.filter(e => !isInteractive(e))
        function isInside(array) {
            for (const i in array) {
                const el = array[array.length-1-i]
                if (!el.clickable && !el.draggable && !el.holdable) {
                    continue
                }
                if (el.isInside(position)) {
                    return el
                }
            }
            return null
        }
        return isInside(interactive) || isInside(uninteractive)
    }


    /**
     * Additional functions to call on clear
     * @param callback function to call on clear
     */
    addOnClearListener(callback) {
        this.#onClear.push(callback)
    }

    /**
     * Resets game to initial state
     */
    clear() {
        this.#grids = []
        this.#elements = []
        this.#pressedKeys = []
        for (const callback of this.#onClear) {
            callback.call(this)
        }
        this.#onClear = []
    }

    /**
     * Returns an array of elements that collide with input element
     * @param {GameElement} element Element for which the collisions are checked
     * @returns {Array<GameElement>} Array of elements that collide with input element
     */
    checkCollisions(element) {
        const collisions = []
        for (const other of this.#elements) {
            if (element === other) {
                continue
            }
            if (element.collidesWith(other)) {
                collisions.push(other)
            }
        }
        return collisions
    }

    /**
     * Downloads a screenshot of the game area
     */
    screenShot() {
        const blankImg = new Image()
        blankImg.src = this.canvas.toDataURL('image/png')

        blankImg.addEventListener("load",()=>{
            const tctx = this.shared.tempContext
            tctx.setTransform(1,0,0,1,0,0)

            tctx.fillStyle = "white"
            tctx.fillRect(0,0,this.canvas.width,this.canvas.height)

            tctx.drawImage(blankImg,0,0)
            const image = tctx.canvas.toDataURL('image/png')

            tctx.clearRect(0,0,this.canvas.width,this.canvas.height)

            const element = document.createElement("a")
            element.setAttribute("href",image)
            element.setAttribute("download","screenshot"+Date.now()+".png")
            document.body.appendChild(element)
            element.click()
            document.body.removeChild(element)
        })
    }
}

function isInteractive(element) {
    return element instanceof GameButton || element instanceof GameRangeSlider || element instanceof GameTextInput
}

export { Game }