components/CollisionTiles.js

import AABB from '../AABB.js';
import CollisionShape from '../CollisionShape.js';
import Data from '../Data.js';
import {arrayCache} from '../utils/array.js';
import createComponentClass from '../factory.js';

export default (function () {
    const
        maskJumpThrough = 0x10000000,
        maskRotation = 0x20000000,
        maskXFlip = 0x80000000,
        maskYFlip = 0x40000000,
        maskIndex = 0x0fffffff,
        getDefaultType = function () {
            return this.collisionType;
        },
        getCollisionType = function (index) {
            return this.collisionTypeMap[index & maskIndex] || this.collisionType;
        },
        flipDiagonal = function (num) {
            if (num === 0) {
                return num;
            } else {
                return num ^ maskYFlip ^ maskRotation;
            }
        },
        flipDiagonalInverse = function (num) {
            if (num === 0) {
                return num;
            } else {
                return num ^ maskXFlip ^ maskRotation;
            }
        },
        flipX = function (num) {
            if (num === 0) {
                return num;
            } else {
                return num ^ maskXFlip;
            }
        },
        flipY = function (num) {
            if (num === 0) {
                return num;
            } else {
                return num ^ maskYFlip;
            }
        },
        rotate90 = function (num) {
            if (num === 0) {
                return num;
            } else if (maskRotation & num) {
                return num ^ maskYFlip ^ maskRotation;
            } else {
                return num ^ maskXFlip ^ maskRotation;
            }
        },
        rotate180 = function (num) {
            if (num === 0) {
                return num;
            } else {
                return num ^ maskXFlip ^ maskYFlip;
            }
        },
        rotate270 = function (num) {
            if (num === 0) {
                return num;
            } else if (maskRotation & num) {
                return num ^ maskXFlip ^ maskRotation;
            } else {
                return num ^ maskYFlip ^ maskRotation;
            }
        },
        copySection = function (array, originX, originY, width, height) {
            const
                arr = arrayCache.setUp();

            for (let y = 0; y < height; y++) {
                arr[y] = arrayCache.setUp();
                for (let x = 0; x < width; x++) {
                    arr[y][x] = array[originX + x][originY + y];
                }
            }
            return arr;
        },
        cutSection = function (array, originX, originY, width, height) {
            const
                arr = arrayCache.setUp();

            for (let y = 0; y < height; y++) {
                arr[y] = arrayCache.setUp();
                for (let x = 0; x < width; x++) {
                    arr[y][x] = array[originX + x][originY + y];
                    array[originX + x][originY + y] = -1;
                }
            }
            return arr;
        },
        pasteSection = function (destinationArray, sourceArray, originX, originY, width, height) {
            for (let y = 0; y < height; y++) {
                for (let x = 0; x < width; x++) {
                    destinationArray[originX + x][originY + y] = sourceArray[y][x];
                }
            }
            return destinationArray;
        },
        transforms = {
            "diagonal": function (array, originX, originY, width, height) {
                const
                    arr = copySection(array, originX, originY, width, height),
                    fD = flipDiagonal;

                for (let x = 0; x < width; x++) {
                    for (let y = 0; y < height; y++) {
                        array[originX + x][originY + y] = fD(arr[x][y]);
                    }
                }
                arrayCache.recycle(arr, 2);
                return array;
            },
            "diagonal-inverse": function (array, originX, originY, width, height) {
                const
                    arr = copySection(array, originX, originY, width, height),
                    fDI = flipDiagonalInverse;

                for (let x = 0; x < width; x++) {
                    for (let y = 0; y < height; y++) {
                        array[originX + width - x - 1][originY + height - y - 1] = fDI(arr[x][y]);
                    }
                }
                arrayCache.recycle(arr, 2);
                return array;
            },
            "horizontal": function (array, originX, originY, width, height) {
                const
                    arr = copySection(array, originX, originY, width, height),
                    fX = flipX;

                for (let y = 0; y < height; y++) {
                    for (let x = 0; x < width; x++) {
                        array[originX + width - x - 1][originY + y] = fX(arr[y][x]);
                    }
                }
                arrayCache.recycle(arr, 2);
                return array;
            },
            "vertical": function (array, originX, originY, width, height) {
                const
                    arr = copySection(array, originX, originY, width, height),
                    fY = flipY;

                for (let y = 0; y < height; y++) {
                    for (let x = 0; x < width; x++) {
                        array[originX + x][originY + height - y - 1] = fY(arr[y][x]);
                    }
                }
                arrayCache.recycle(arr, 2);
                return array;
            },
            "rotate-90": function (array, originX, originY, width, height) {
                const
                    arr = copySection(array, originX, originY, width, height),
                    r90 = rotate90;

                for (let y = 0; y < height; y++) {
                    for (let x = 0; x < width; x++) {
                        array[originX + height - y - 1][originY + x] = r90(arr[y][x]);
                    }
                }
                arrayCache.recycle(arr, 2);
                return array;
            },
            "rotate-180": function (array, originX, originY, width, height) {
                const
                    arr = copySection(array, originX, originY, width, height),
                    r180 = rotate180;

                for (let y = 0; y < height; y++) {
                    for (let x = 0; x < width; x++) {
                        array[originX + width - x - 1][originY + height - y - 1] = r180(arr[y][x]);
                    }
                }
                arrayCache.recycle(arr, 2);
                return array;
            },
            "rotate-270": function (array, originX, originY, width, height) {
                const
                    arr = copySection(array, originX, originY, width, height),
                    r270 = rotate270;

                for (let y = 0; y < height; y++) {
                    for (let x = 0; x < width; x++) {
                        array[originX + y][originY + width - x - 1] = r270(arr[y][x]);
                    }
                }
                arrayCache.recycle(arr, 2);
                return array;
            },
            "translate": function (array, originX, originY, width, height, dx, dy) {
                const
                    arr = cutSection(array, originX, originY, width, height);

                for (let y = 0; y < height; y++) {
                    for (let x = 0; x < width; x++) {
                        array[originX + x + dx][originY + y + dy] = arr[y][x];
                    }
                }
                arrayCache.recycle(arr, 2);
                return array;
            }
        };

    return createComponentClass(/** @lends platypus.components.CollisionTiles.prototype */{
        id: 'CollisionTiles',
        
        properties: {
            /**
             * Maps tile indexes to particular collision types. This defaults to a "tiles" collision type for all non-zero values if a particular collision map is not provided.
             *
             * @property collisionTypeMap
             * @type Object
             * @default null
             */
            collisionTypeMap: null,
            
            /**
             * Sets the default collision type for non-zero map tiles.
             *
             * @property collisionType
             * @type String
             * @default "tiles"
             */
            collisionType: 'tiles',
            
            /**
             * The map's top offset.
             *
             * @property top
             * @type Number
             * @default 0
             */
            top: 0,
            
            /**
             * The map's left offset.
             *
             * @property left
             * @type Number
             * @default 0
             */
            left: 0
        },
        
        publicProperties: {
            /**
             * A 3D array describing the layers of the tile-map with off (0) and on (!0) states. The indexes match Tiled map data indexes with an additional bit setting (0x2000000) for jumpthrough tiles. Example: `[[[0, 0, 0], [1, 0, 0], [1, 1, 1]], [[0, 1, 0], [1, 0, 0], [1, 0, 0]]]`. Available on the entity as `entity.collisionMap`.
             *
             * @property collisionMap
             * @type Array
             * @default []
             */
            collisionMap: [],
            
            /**
             * The width of tiles in world coordinates. Available on the entity as `entity.tileWidth`.
             *
             * @property tileWidth
             * @type number
             * @default 10
             */
            tileWidth: 10,

            /**
             * The height of tiles in world coordinates. Available on the entity as `entity.tileHeight`.
             *
             * @property tileWidth
             * @type number
             * @default 10
             */
            tileHeight: 10
        },

        /**
         * This component causes the tile-map to collide with other entities. It must be part of a collision group and will cause "hit-by-tile" messages to fire on colliding entities.
         *
         * @memberof platypus.components
         * @uses platypus.Component
         * @constructs
         * @listens platypus.Entity#transform
         * @listens platypus.Entity#translate
         */
        initialize: function () {
            this.tileOffsetLeft  = this.tileWidth / 2 + this.left;
            this.tileOffsetTop = this.tileHeight / 2 + this.top;
            
            this.columns = this.collisionMap[0].length;
            this.rows = this.collisionMap[0][0].length;
            
            this.shapeDefinition = Data.setUp(
                "x", 0,
                "y", 0,
                "type", 'rectangle',
                "width", this.tileWidth,
                "height", this.tileHeight
            );
            
            this.storedTiles = arrayCache.setUp();
            this.serveTiles = arrayCache.setUp();
            this.storedTileIndex = 0;
            
            this.aabb = AABB.setUp();
            this.aabb.setBounds(this.left, this.top, this.tileWidth * this.columns + this.left, this.tileHeight * this.rows + this.top);
            
            if (this.collisionTypeMap) {
                this.getType = getCollisionType;
            } else {
                this.getType = getDefaultType;
            }
        },
        
        events: {
            "transform": function (transform) {
                this.transform(transform);
            },

            "translate": function (translate) {
                this.translate(translate);
            },
            /**
             * This event adds a layer of tiles to the collision map.
             *
             * @event platypus.Entity#add-collision-tiles
             * @param message.imageMap {Array} This is a 2D mapping of tile indexes to be added to the collision map.
             */
            "add-collision-tiles": function (definition) {
                const map = definition.collisionMap[0],
                    typeMap = definition.collisionTypeMap;
                let tileIndex = 0;

                if (map) {
                    this.collisionMap.push(map);
                }

                if (typeMap) {
                    for (tileIndex in typeMap) {
                        if (this.collisionTypeMap[tileIndex]) {
                            console.warn('Overwriting existing typeMap.');
                        }
                        this.collisionTypeMap[tileIndex] = typeMap[tileIndex];
                    }
                }
            }
        },
        
        methods: {
            getShape: function (x, y, type) {
                const
                    {storedTileIndex: i, storedTiles} = this;
                let shape = null;
                
                if (i === storedTiles.length) {
                    shape = CollisionShape.setUp(null, this.shapeDefinition, type);
                    storedTiles.push(shape);
                } else {
                    shape = storedTiles[i];
                    shape.collisionType = type;
                }
                
                shape.update(x * this.tileWidth + this.tileOffsetLeft, y * this.tileHeight + this.tileOffsetTop);

                this.storedTileIndex += 1;
                
                return shape;
            },
            
            addShape: function (shapes, prevAABB, layer, x, y, collisionType) {
                const
                    xy = this.collisionMap[layer][x][y],
                    index = xy & maskIndex;
                
                if (xy && (this.getType(index) === collisionType)) {
                    const
                        jumpThrough = maskJumpThrough & xy;

                    if (jumpThrough) {
                        const
                            rotation = maskRotation & xy,
                            xFlip = maskXFlip & xy,
                            yFlip = maskYFlip & xy;

                        if (rotation && xFlip) { // Right
                            if (prevAABB.left >= (x + 1) * this.tileWidth + this.left) {
                                shapes.push(this.getShape(x, y, collisionType));
                            }
                        } else if (rotation) { // Left
                            if (prevAABB.right <= x * this.tileWidth + this.left) {
                                shapes.push(this.getShape(x, y, collisionType));
                            }
                        } else if (yFlip) { // Bottom
                            if (prevAABB.top >= (y + 1) * this.tileHeight + this.top) {
                                shapes.push(this.getShape(x, y, collisionType));
                            }
                        } else if (prevAABB.bottom <= y * this.tileHeight + this.top) { // Top
                            shapes.push(this.getShape(x, y, collisionType));
                        }
                    } else {
                        shapes.push(this.getShape(x, y, collisionType));
                    }
                }

                return shapes;
            },
            
            destroy: function () {
                const
                    store = this.storedTiles;
                let i = store.length;
                
                this.shapeDefinition.recycle();
                this.shapeDefinition = null;
                
                while (i--) {
                    store[i].recycle();
                }
                arrayCache.recycle(store);
                this.storedTiles = null;

                arrayCache.recycle(this.serveTiles);
                this.serveTiles = null;
                
                this.aabb.recycle();
                this.aabb = null;
            }
        },
        
        publicMethods: {
            /**
             * Returns the axis-aligned bounding box of the entire map.
             *
             * @method platypus.components.CollisionTiles#getAABB
             * @return aabb {platypus.AABB} The returned object provides the top, left, width, and height of the collision map.
             */
            getAABB: function () {
                return this.aabb;
            },
            
            /**
             * Confirms whether a particular map grid coordinate contains a tile.
             *
             * @method platypus.components.CollisionTiles#isTile
             * @param x {number} Integer specifying the column of tiles in the collision map to check.
             * @param y {number} Integer specifying the row of tiles in the collision map to check.
             * @return {boolean} Returns `true` if the coordinate contains a collision tile, `false` if it does not.
             */
            isTile: function (x, y) {
                let z = 0;

                if ((x < 0) || (y < 0) || (x >= this.columns) || (y >= this.rows)) {
                    return false;
                }

                for (z = 0; z < this.collisionMap.length; z++) {
                    if (this.collisionMap[z][x][y] !== -1) {
                        return true;
                    }
                }

                return false;
            },
            
            /**
             * Returns all the collision tiles within the provided axis-aligned bounding box as an array of shapes.
             *
             * @method platypus.components.CollisionTiles#getTileShapes
             * @param aabb {platypus.AABB} The axis-aligned bounding box for which tiles should be returned.
             * @param prevAABB {platypus.AABB} The axis-aligned bounding box for a previous location to test for jump-through tiles.
             * @param [collisionType] {String} The type of collision to check for. If not specified, "tiles" is used. (Since 0.8.3)
             * @return {Array} Each returned object provides the [CollisionShape](CollisionShape.html) of a tile.
             */
            getTileShapes: function (aabb, prevAABB, collisionType) {
                const
                    colType = collisionType || 'tiles',
                    l = this.left,
                    t = this.top,
                    th = this.tileHeight,
                    tw = this.tileWidth,
                    left   = Math.max(Math.floor((aabb.left - l) / tw),  0),
                    top    = Math.max(Math.floor((aabb.top - t) / th), 0),
                    right  = Math.min(Math.ceil((aabb.right - l) / tw),  this.columns),
                    bottom = Math.min(Math.ceil((aabb.bottom - t) / th), this.rows),
                    shapes = this.serveTiles;
                
                shapes.length = 0;
                this.storedTileIndex = 0;
                for (let z = 0; z < this.collisionMap.length; z++) {
                    for (let x = left; x < right; x++) {
                        for (let y = top; y < bottom; y++) {
                            this.addShape(shapes, prevAABB, z, x, y, colType);
                        }
                    }
                }
                
                
                return shapes;
            },
            
            /**
             * Performs a transform of a subset of the collision tile grid.
             *
             * @method platypus.components.CollisionTiles#transform
             * @param [transform] {Object} A list of key/value pairs describing the transform.
             * @param [transform.layers] {Array.<number>} The layer indexes on which to apply the transform, if not provided transforms all layers. Defaults to the 0th index.
             * @param [transform.type="horizontal"] {String} The type of transform; one of the following: "horizontal", "vertical", "diagonal", "diagonal-inverse", "rotate-90", "rotate-180", "rotate-270". Height and width should match for diagonal flips and 90 degree rotations.
             * @param [transform.left=0] {number} Grid coordinate for the left side of the bounding box.
             * @param [transform.top=0] {number} Grid coordinate for the top of the bounding box.
             * @param [transform.width=grid.width] {number} Cell width of the bounding box.
             * @param [transform.height=grid.height] {number} Cell height of the bounding box.
             */
            transform: function (transform = {}) {
                const {
                    layers = [0],
                    left: x = 0,
                    top: y = 0,
                    width = this.rows,
                    height = this.columns,
                    type = "horizontal"
                } = transform;
                let k = 0;

                if (transforms[type]) {
                    for (k = 0; k < this.collisionMap.length; k++) {
                        if (layers.includes(k)) {
                            transforms[type](this.collisionMap[k], x, y, width, height);
                        }
                    }

                    return this.collisionMap;
                } else {
                    return null;
                }
            },
            
            /**
             * Performs a translation of a subset of the collision tile grid.
             *
             * @method platypus.components.CollisionTiles#translate
             * @param [translate] {Object} A list of key/value pairs describing the translation.
             * @param [translate.layers] {Array.<number>} The layer indexes on which to apply the translate, if not provided translates all layers. Defaults to the 0th index.
             * @param [translate.dx=0] {number} Movement in columns.
             * @param [translate.dy=0] {number} Movement in rows.
             * @param [translate.left=0] {number} Grid coordinate for the left side of the bounding box.
             * @param [translate.top=0] {number} Grid coordinate for the top of the bounding box.
             * @param [translate.width=grid.width] {number} Cell width of the bounding box.
             * @param [translate.height=grid.height] {number} Cell height of the bounding box.
             */
            translate: function (translate = {}) {
                const {
                    layers = [0],
                    left: x = 0, 
                    top: y = 0, 
                    width = this.rows, 
                    height = this.columns, 
                    dx = 0, 
                    dy = 0
                } = translate;
                let k = 0;

                
                for (k = 0; k < this.collisionMap.length; k++) {
                    if (!layers || layers.includes(k)) {
                        transforms.translate(this.collisionMap[k], x, y, width, height, dx, dy);
                    }
                }
                
                return this.collisionMap;
            },
            
            /**
             * Gets a subset of the collision tile grid as a 2D array.
             *
             * @method platypus.components.CollisionTiles#getCollisionMatrix
             * @param layer {number} The collision map index from which to copy. 
             * @param originX {number} Grid coordinate for the left side of the bounding box.
             * @param originY {number} Grid coordinate for the top of the bounding box.
             * @param width {number} Cell width of the bounding box.
             * @param height {number} Cell height of the bounding box.
             * @return {Array}
             */
            getCollisionMatrix: function (layer, originX, originY, width, height) {
                return copySection(this.collisionMap[layer], originX, originY, width, height);
            },
            
            /**
             * Sets a subset of the collision tile grid.
             *
             * @method platypus.components.CollisionTiles#setCollisionMatrix
             * @param sourceArray {Array} A 2D array describing the collision tiles to insert into the collision tile grid.
             * @param layer {number} The collision map index into which to paste the collision data. 
             * @param originX {number} Grid coordinate for the left side of the bounding box.
             * @param originY {number} Grid coordinate for the top of the bounding box.
             * @param width {number} Cell width of the bounding box.
             * @param height {number} Cell height of the bounding box.
             */
            setCollisionMatrix: function (sourceArray, layer, originX, originY, width, height) {
                return pasteSection(this.collisionMap[layer], sourceArray, originX, originY, width, height);
            }
        }
    });
}());