math/Grid.js

import Rectangle from "./Rectangle.js";
import clone from "clone";
import mitt from "mitt";
import { getProperty, setProperty, deleteProperty } from "dot-prop";

/**
 * 2D Grid Wrapper
 */
class Grid {
    /**
     * @param {number} width
     * @param {number} height
     * @param {*} [default_element=null] the default element to fill each cell of the grid
     */
    constructor(width, height, default_element = null) {
        this.w = width;
        this.h = height;
        this.size = this.w * this.h;

        this.def = default_element;
        this.grid = new Array(this.size).fill().map(u => this.getDefault());
        this.events = mitt();
    }

    on(event, cb) {
        this.events.on(event, cb);
        return this;
    }

    clone() {
        let g = new Grid(this.w, this.h, this.def);
        g.grid = this.grid;
        return g;
    }

    /**
     * internal function used to fill each cell initially, each cell value is a clone, not a reference
     * @see https://www.npmjs.com/package/clone
     * @return {*} 
     */
    getDefault() {
        return clone(this.def);
    }

    /**
     * get the contents of the cell at x,y
     * if the coordinates are outside the grid, the default element is returned
     * @param {number} x
     * @param {number} y
     * @param {string} prop
     * @return {*} the cell content
     */
    get(x, y, prop = null) {
        let i = this.index(x, y);
        let g = (i < 0 || i > this.size || this.grid[i] == null) ? this.getDefault() : this.grid[i];
        return prop !== null ? getProperty(g, prop) : g;
    }

    /**
     * set the contents of the cell at x,y
     * a fourth parameter can be passed to set the value of an object key instead
     * @example
     * grid.set(4,7,true);
     * grid.set(4,7,"key", true);
     * @param {number} x
     * @param {number} y
     * @param {string|*} a value
     * @param {*} b
     */
    set(x, y, a, b) {
        let i = this.index(x, y);
        let prop = null;
        let val = null;

        if (i < 0 || i > this.size) return;

        if (typeof b !== "undefined") {
            prop = a;
            val = b;
            setProperty(this.grid[i], a, b);
        } else {
            val = a;
            this.grid[i] = a;
        }

        this.events.emit("set", { x, y, cell: this.grid[i], prop, val });
    }

    /**
     * deletes the contents of an object key for the cell at x,y
     * @example
     * grid.delete(4,7,"key");
     * @param {number} x
     * @param {number} y
     * @param {string} prop property key
     */
    delete(x, y, prop) {
        let i = this.index(x, y);
        if (i < 0 || i > this.size || this.grid[i] == null) return;
        deleteProperty(this.grid[i], prop);
        this.events.emit("delete", { x, y, cell: this.grid[i], prop });
    }

    /**
     * get the one-dimensional index of the cell
     * -1 if outside the grid
     * @param {number} x
     * @param {number} y
     * @return {number} 
     */
    index(x, y) {
        if (x < 0 || x > this.w - 1 || y < 0 || y > this.h - 1) return -1;
        return this.w * y + x;
    }

    /**
     * get the two-dimensional coordinates of the cell at this index
     * @param {number} i
     * @return {object} the coordinates
     */
    coords(i) {
        return {
            y: Math.floor(i / this.w),
            x: i % this.w
        }
    }

    /**
     * Loop callback
     * @callback Grid~loopCallback
     * @param {number} x
     * @param {number} y
     * @param {*} cell
     */

    /**
     * loop over each cell (call cb for each cell)
     * @param {function} cb {@link Grid~loopCallback loop callback}
     */
    each(cb) {
        for (let index = 0; index < this.size; index++) {
            let c = this.coords(index);
            cb(c.x, c.y, this.grid[index]);
        }
    }

    /**
     * loop over each cell (call cb for each cell) and replace it
     * the new array is replaced after the callback has been called for each cell
     * @param {function} cb {@link Grid~loopCallback loop callback}
     */
    map(cb) {
        let new_grid = [];

        for (let index = 0; index < this.size; index++) {
            let c = this.coords(index);
            new_grid[index] = cb(c.x, c.y, this.grid[index]);
        }
        this.grid = new_grid;
        this.events.emit("replace", this.grid);
    }

    /**
     * Get a copy of a part of the grid
     * @param {Number} x offset on the x-axis
     * @param {Number} y offset on the y-axis
     * @param {Number} w width of the slice
     * @param {Number} h height of the slice
     * @return {Grid}
     */
    slice(x, y, w, h) {
        let ret = new Grid(w, h, this.getDefault());

        for (let i = 0; i < w; i++) {
            for (let j = 0; j < h; j++) {
                ret.set(i, j, this.get(x + i, y + j));
            }
        }

        return ret;
    }

    /**
     * Start a flood fill from the given begin cell
     * @param {Object} begin x and y
     * @param {function} cellCallback return true if the neighbours of the cell should be checked
     * @param {function} neighbourCallback return true if the neighbour should be checked (is the same as cellCallback by default)
     * @return {Array} flooded cells
     */
    floodfill(begin, cellCallback, neighbourCallback = null) {
        let open = [begin];
        let closed = new Map();

        if (neighbourCallback === null) neighbourCallback = cellCallback;

        while (open.length) {
            let f = open.pop();

            let cell = this.get(f.x, f.y);
            closed.set(f.x + "-" + f.y, f);

            if (!cellCallback(cell, f.x, f.y)) continue;

            for (let x = -1; x <= 1; x++) {
                for (let y = -1; y <= 1; y++) {
                    if (x == 0 && y == 0) continue;

                    let nx = f.x + x;
                    let ny = f.y + y;

                    if (nx < 0 || nx >= this.w || ny < 0 || ny >= this.h) continue;

                    if (closed.has(nx + "-" + ny) || open.find(o => o.x === nx && o.y === ny)) continue;
                    if (neighbourCallback(this.get(nx, ny), nx, ny)) {
                        open.push({ x: nx, y: ny });
                    }
                }
            }
        }

        let ret = Array.from(closed.values());

        if (ret.length === 1 && !cellCallback(this.get(begin.x, begin.y), begin.x, begin.y)) {
            return [];
        }

        return ret;
    }

    /**
     * get the neighbours cells of a cell
     * @param {number} x
     * @param {number} y
     * @param {string} [prop=null]
     * @return {Array} 
     */
    neighbours(x, y, prop = null) {
        let n = {
            ...this.directNeighbours(x, y, prop),
            nw: this.get(x - 1, y - 1, prop),
            ne: this.get(x + 1, y - 1, prop),
            sw: this.get(x - 1, y + 1, prop),
            se: this.get(x + 1, y + 1, prop)
        };

        return n;
    }

    /**
     * get the direct neighbours cells of a cell, cells outside the grid are the default
     * @param {number} x
     * @param {number} y
     * @param {string} [prop=null]
     * @return {Array} 
     */
    directNeighbours(x, y, prop = null) {
        let n = {
            n: this.get(x, y - 1, prop),
            e: this.get(x + 1, y, prop),
            s: this.get(x, y + 1, prop),
            w: this.get(x - 1, y, prop)
        };

        return n;
    }

    /**
     * get the direct neighbours cells of a cell, ignore those outside the grid
     * @param {number} x
     * @param {number} y
     * @param {string} [prop=null]
     * @return {Array} 
     */
    strictDirectNeighbours(x, y, prop = null) {
        let n = {};

        if (y > 0) n.n = this.get(x, y - 1, prop);
        if (x < this.w - 1) n.e = this.get(x + 1, y, prop);
        if (y < this.h - 1) n.s = this.get(x, y + 1, prop);
        if (x > 0) n.w = this.get(x - 1, y, prop);

        return n;
    }


    bitmask(x, y, tags, prop = null) {
        let m = 0;
        let n = this.neighbours(x, y, prop);

        for (const key in n) {
            if (n[key] === null) continue;

            if (n[key].tags) {
                n[key] = n[key].hasAnyTag(tags);
            } else {
                n[key] = tags.includes(n[key]);
            }
        }

        if (n.n) {
            m |= 1;
            if (n.ne && n.e) m |= 2;
        }
        if (n.e) {
            m |= 4;
            if (n.se && n.s) m |= 8;
        }
        if (n.s) {
            m |= 16;
            if (n.sw && n.w) m |= 32;
        }
        if (n.w) {
            m |= 64;
            if (n.nw && n.n) m |= 128;
        }

        return m;
    }

    neighbourCount(x, y, prop, val) {
        let n = this.neighbours(x, y, prop);

        return Object.values(n).reduce((a, c) => {
            return a + (c == val ? 1 : 0);
        }, 0);
    }

    getCollisionBoxes(type, prop = "type") {
        let rectangles = [];

        for (let x = 0; x < this.w; x++) {
            let start_y = null;
            let end_y = null;

            for (let y = 0; y <= this.h; y++) {
                let wall = this.get(x, y, prop) == type;

                if (wall && y < this.h) {
                    if (start_y == null) start_y = y;
                    end_y = y;
                } else if (start_y !== null) {
                    if (y == this.h - 1) {
                        end_y = y - 1;
                    }

                    let overlaps = [];

                    for (const r in rectangles) {
                        let rect = rectangles[r];

                        if ((rect.end_x == x) && (start_y <= rect.y) && (end_y >= rect.end_y)) {
                            overlaps.push(rect);
                        }
                    }

                    overlaps.sort((a, b) => a.y - b.y);

                    for (const o in overlaps) {
                        let rect = overlaps[o];

                        if (start_y < rect.y) {
                            rectangles.push({
                                x: x,
                                y: start_y,
                                end_x: x + 1,
                                end_y: rect.y - 1
                            });
                            start_y = rect.y;
                        }

                        if (start_y == rect.y) {
                            rect.end_x++;

                            if (end_y == rect.end_y) {
                                start_y = null;
                                end_y == null;
                            } else if (end_y > rect.end_y) {
                                start_y = rect.end_y + 1;
                            }
                        }
                    }

                    if (start_y !== null) {
                        rectangles.push({
                            x: x,
                            y: start_y,
                            end_x: x + 1,
                            end_y: end_y
                        });

                        start_y = null;
                        end_y = null;
                    }
                }
            }

            if (start_y !== null) {
                rectangles.push({
                    x: x,
                    y: start_y,
                    end_x: x + 1,
                    end_y: end_y
                });
                start_y = null;
                end_y = null;
            }
        }

        return rectangles.map(box => {
            return new Rectangle(box.x, box.y, box.end_x - box.x, box.end_y - box.y + 1);
        });
    }

    /**
     * Serialize the grid into a compact object structure
     * requires each cell to be an object
     * the return value is ready to be send over network or further serialized with JSON.stringify
     * @return {object} 
     */
    serialize() {
        let data = {
            width: this.w,
            height: this.h,
            palette: [],
            states: []
        };

        this.each((x, y, t) => {
            let s = JSON.stringify(t);
            let i = data.palette.indexOf(s);

            if (i == -1) {
                data.palette.push(s);
                i = data.palette.length - 1;
            }
            data.states.push(i);
        });

        let last_index = null;
        let count = 0;
        let indices = [];

        data.states.forEach(s => {
            if (last_index == null) {
                last_index = s;
                count = 1;
                return;
            }

            if (last_index !== s) {
                indices.push(count == 1 ? last_index : last_index + "-" + count);
                last_index = s;
                count = 1;
                return
            }

            count++;
        });

        indices.push(count == 1 ? last_index : last_index + "-" + count);

        data.states = indices.join(",");
        data.palette = data.palette.map(p => JSON.parse(p));

        return data;
    }

    /**
     * unserialize a data structure generated by {@link serialize}
     * @param {object} data
     */
    unserialize(data) {
        let p = data.palette;
        let s = data.states.split(",");
        let current_index = 0;

        s.forEach((i, j) => {
            let d = i.split("-");
            let type = d[0];
            let count = d.length == 1 ? 1 : d[1];

            for (let c = 0; c < count; c++) {
                this.grid[current_index] = clone(p[type]);

                current_index++;
            }
        });
    }
}

export default Grid;