Source: GameGrid.js

import {Point} from "./Misc.js";
import {loadImage} from "./drawables/GameDrawable.js";

class EmptyError extends Error {
    constructor(message) {
        super(message);
        this.name = "EmptyError"
    }
}
class FullError extends Error {
    constructor(message) {
        super(message);
        this.name = "FullError"
    }
}

/**
 * Class GameGrid.
 * Elements can snap to rows|columns
 *
 * @property {Array<Array<GameElement>>} elements 2D array[col][row] of elements which belong to the grid
 * @property {Point} position Position of grid
 * @property {number} width Width of grid in pixels
 * @property {number} height Height of grid in pixels
 * @property {number} columns Number of columns (X axis)
 * @property {number} rows Number of rows (Y axis)
 * @property {Image} img Grid image
 */
class GameGrid {
    EmptyError = EmptyError
    FullError = FullError

    #elements = []
    get elements() {
        return [...this.#elements]
    }
    #img = undefined
    get img() {
        return this.#img
    }
    #position
    set position(newPos) {
        if (!(newPos instanceof Point)) {
            throw new TypeError("Incorrect instance for position setter. Must be Point!")
        }
        this.#position = newPos
        this.#img = undefined
    }
    get position() {
        return this.#position
    }
    #width
    set width(newWidth) {
        if (isNaN(newWidth)) {
            throw new TypeError("Entered width value is not a number!")
        }
        if (newWidth <= 0) {
            throw new RangeError(`Trying to set width to ${newWidth}`)
        }
        this.#width = newWidth
        this.#img = undefined
    }
    get width() {
        return this.#width
    }
    #height
    set height(newHeight) {
        if (isNaN(newHeight)) {
            throw new TypeError("Entered height value is not a number!")
        }
        if (newHeight <= 0) {
            throw new RangeError(`Trying to set height to ${newHeight}`)
        }
        this.#height = newHeight
        this.#img = undefined
    }
    get height() {
        return this.#height
    }
    #columns
    set columns(newCols) {
        if (isNaN(newCols)) {
            throw new TypeError("Entered columns value is not a number!")
        }
        if (newCols <= 0) {
            throw new RangeError(`Trying to set number of columns to ${newCols}`)
        }
        this.#columns = newCols
        this.#img = undefined
    }
    get columns() {
        return this.#columns
    }
    #rows
    set rows(newRows) {
        if (isNaN(newRows)) {
            throw new TypeError("Entered rows value is not a number!")
        }
        if (newRows <= 0) {
            throw new RangeError(`Trying to set number of rows to ${newRows}`)
        }
        this.#rows = newRows
        this.#img = undefined
    }
    get rows() {
        return this.#rows
    }

    /**
     * @private
     * Creates grid image
     * @param {CanvasRenderingContext2D} ctx Context
     */
    #createImg(ctx) {
        const left = this.position.x
        const right = this.position.x + this.width
        const top = this.position.y
        const bottom = this.position.y + this.height
        const hStep = this.columnWidth()
        const vStep = this.rowHeight()

        ctx.beginPath()

        //top to bottom
        for (let x = left; x <= right; x+=hStep) {
            ctx.moveTo(x,top)
            ctx.lineTo(x,bottom)
        }
        //left to right
        for (let y = top; y <= bottom; y+=vStep) {
            ctx.moveTo(left,y)
            ctx.lineTo(right,y)
        }
        ctx.stroke()

        const dataURL = ctx.canvas.toDataURL()
        this.#img = new Image()
        this.#img.src = dataURL
    }

    /**
     * @private
     * Checks correctness of input
     * @param {number} col
     * @param {number} row
     */
    #checkColRowRange(col,row) {
        if (col < 0 || col >= this.columns) {
            throw new RangeError(`Column value (${col}) out of range!`)
        }
        if (row < 0 || row >= this.rows) {
            throw new RangeError(`Row value (${row}) out of range!`)
        }
    }

    /**
     * Constructor of GameGrid
     * @param {Point} position Top left position on cavas
     * @param {number} width Width in pixels
     * @param {number} height Height in pixels
     * @param {number} cols Number of columns
     * @param {number} rows Number of rows
     */
    constructor(position,width,height,cols,rows) {
        this.position = position || new Point(0,0)
        this.width = width || 200
        this.height = height || 200
        this.columns = cols || 10
        this.rows = rows || 10

        this.#elements = new Array(this.columns)
        for (let i = 0; i < this.columns; i++) {
            this.#elements[i] = new Array(this.rows).fill(undefined)
        }
    }

    /**
     * Sets absolute position of top left corner in pixels
     * @param {number} x Position
     * @param {number} y Position
     */
    setPosition(x,y) {
        this.position.x = x
        this.position.y = y

        this.#img = undefined
    }

    /**
     * Adds element to grid
     * @param {number} col Starts at 0
     * @param {number} row Starts at 0
     * @param {GameElement} element Element to add to grid
     */
    addElement(col,row,element) {
        this.#checkColRowRange(col,row)
        try {
            const pos = this.getElementPosition(element)
            throw new FullError(`Element already in grid at position ${pos.asString()}!`)
        } catch (e) {
            if (e instanceof RangeError) {
                //element is not in grid
            } else {
                throw e
            }
        }
        if (this.getElementAtPos(col,row)) {
            throw new FullError(`Position [${col},${row}] is occupied!`)
        }
        this.#elements[col][row] = element
        element.center = this.getBoxCenter(col,row)
        element.grid = this
    }

    /**
     * Removes element at position from grid and returns it
     * @param {number} col
     * @param {number} row
     * @returns {GameElement}
     */
    removeElementAtPosition(col, row) {
        this.#checkColRowRange(col,row)
        if (!this.getElementAtPos(col,row)) {
            throw new EmptyError(`Position [${col},${row}] is empty!`)
        }
        const element = this.#elements[col][row]
        this.#elements[col][row] = undefined
        element.grid = undefined
        return element
    }

    /**
     * Removes an element and returns it
     * @param {GameElement} element
     * @returns {GameElement}
     */
    removeElement(element) {
        const pos = this.getElementPosition(element)
        return this.removeElementAtPosition(pos.x,pos.y)
    }

    /**
     * Removes multiple elements
     * @param {GameElement} elements
     */
    removeElements(...elements) {
        for (let col = 0; col < this.columns; col++) {
            for (let row = 0; row < this.rows; row++) {
                if (elements.includes(this.#elements[col][row])) {
                    this.removeElementAtPosition(col,row)
                }
            }
        }
    }

    /**
     * Moves element from current position to target position
     * @param {number} targetCol Position
     * @param {number} targetRow Position
     * @param {GameElement} element
     */
    moveElement(targetCol,targetRow,element) {
        this.#checkColRowRange(targetCol,targetRow)
        if (this.#elements[targetCol][targetRow]) {
            throw new FullError(`Position (${targetRow},${targetCol}) is occupied!`)
        }
        const currentPos = this.getElementPosition(element)
        this.#elements[currentPos.x][currentPos.y] = undefined
        this.#elements[targetCol][targetRow] = element
        element.center = this.getBoxCenter(targetCol,targetRow)
    }

    /**
     * Returns element at grid position
     * @param {number} col Starts at 0
     * @param {number} row Starts at 0
     * @returns {GameElement} Element at position
     */
    getElementAtPos(col,row) {
        this.#checkColRowRange(col,row)
        return this.#elements[col][row]
    }

    /**
     * Returns absolute position of box center
     * @param col Starts at 0
     * @param row Starts at 0
     * @returns {Point} Center of box at (col,row)
     */
    getBoxCenter(col,row) {
        this.#checkColRowRange(col,row)
        return new Point(
            this.position.x + col * this.columnWidth() + this.columnWidth()/2,
            this.position.y + row * this.rowHeight() + this.rowHeight()/2,
        )
    }

    /**
     * Returns column and row value as Point
     * @param {number} x In pixels
     * @param {number} y In pixels
     * @returns {Point} (Col,Row)
     */
    getPosFromPixels(x,y) {
        if (!this.isInside(new Point(x,y))) {
            throw new RangeError("Not inside grid!")
        }
        const col = Math.floor((x - this.position.x) / this.columnWidth())
        const row = Math.floor((y - this.position.y) / this.rowHeight())
        return new Point(col,row)
    }

    /**
     * Returns the element at (absolute pixel) position
     * @param {number} x In pixels
     * @param {number} y In pixels
     * @returns {GameElement} Element
     */
    getElementAtPixels(x,y) {
        const pos = this.getPosFromPixels(x,y)
        return this.getElementAtPos(pos.x,pos.y)
    }

    /**
     * Returns (col,row) value of element or throws an error
     * @param {GameElement} element Element to find position of
     * @returns {Point} (Col,Row)
     */
    getElementPosition(element) {
        for (let col = 0; col < this.columns; col++) {
            for (let row = 0; row < this.rows; row++) {
                if (this.#elements[col][row] === element) {
                    return new Point(col,row)
                }
            }
        }
        throw new RangeError("Element not in grid!")
    }

    /**
     * Snaps element to its position
     * @param element
     */
    snapElement(element) {
        const pos = this.getElementPosition(element)
        element.center = this.getBoxCenter(pos.x,pos.y)
    }

    /**
     * Width of grid column
     * @returns {number} Width
     */
    columnWidth() {
        return this.width/this.columns
    }
    /**
     * Height of grid row
     * @returns {number} Height
     */
    rowHeight() {
        return this.height/this.rows
    }

    /**
     * Draws generated image or generates a new one and draws
     * @param {CanvasRenderingContext2D} ctx
     */
    draw(ctx) {
        if (this.#img === undefined) {
            this.#createImg(ctx)
        }

        ctx.drawImage(this.#img,0,0)
    }

    /**
     * Returns true when input position lies on the grid
     * @param {Point} position position in pixels
     * @returns {boolean}
     */
    isInside(position) {
        return (this.position.x <= position.x && position.x <= this.position.x + this.width ) &&
            (this.position.y <= position.y && position.y <= this.position.y + this.height)
    }

    /**
     * Randomly finds a free position on grid
     * @returns {Point}
     */
    randomFreePosition() {
        const col = Math.floor(Math.random() * this.columns)
        const row = Math.floor(Math.random() * this.rows)
        if (!this.getElementAtPos(col,row)) {
            return new Point(col,row)
        }
        for (const column of this.#elements) {
            if (column.includes(undefined)) {
                return this.randomFreePosition()
            }
        }
        throw new FullError("Grid appears to be full!")
    }

    /**
     * Moves or adds element to a position on this grid
     * @param {number} col
     * @param {number} row
     * @param {GameElement} element
     */
    placeElement(col,row,element) {
        if (element.grid === this) {
            this.moveElement(col,row,element)
            return
        }
        if (element.grid) {
            element.grid.removeElement(element)
        }
        this.addElement(col,row,element)
    }


}

export {GameGrid}