components/LevelBuilder.js

/* global atob, platypus */
import {arrayCache, greenSlice, greenSplice, union} from '../utils/array.js';
import createComponentClass from '../factory.js';
import {inflate} from 'pako';

const
    maskXFlip = 0x80000000,
    decodeBase64 = (function () {
        var decodeString = function (str, index) {
                return (((str.charCodeAt(index)) + (str.charCodeAt(index + 1) << 8) + (str.charCodeAt(index + 2) << 16) + (str.charCodeAt(index + 3) << 24 )) >>> 0);
            },
            decodeArray = function (arr, index) {
                return ((arr[index] + (arr[index + 1] << 8) + (arr[index + 2] << 16) + (arr[index + 3] << 24 )) >>> 0);
            };
        
        return function (data, compression) {
            var index = 4,
                arr   = [],
                step1 = atob(data.replace(/\\/g, ''));
                
            if (compression === 'zlib') {
                step1 = inflate(step1);
                while (index <= step1.length) {
                    arr.push(decodeArray(step1, index - 4));
                    index += 4;
                }
            } else {
                while (index <= step1.length) {
                    arr.push(decodeString(step1, index - 4));
                    index += 4;
                }
            }
            
            return arr;
        };
    }()),
    decodeLayer = function (layer) {
        if (layer.encoding === 'base64') {
            layer.data = decodeBase64(layer.data, layer.compression);
            layer.encoding = 'csv'; // So we won't have to decode again.
        }
        return layer;
    },
    mergeData = function (levelData, levelMergeAxisLength, segmentData, segmentMergeAxisLength, nonMergeAxisLength, mergeAxis) {
        var x        = 0,
            y        = 0,
            z        = 0,
            combined = greenSlice(levelData);

        if (mergeAxis === 'horizontal') {
            for (y = nonMergeAxisLength - 1; y >= 0; y--) {
                for (x = y * segmentMergeAxisLength, z = 0; x < (y + 1) * segmentMergeAxisLength; x++, z++) {
                    combined.splice(((y + 1) * levelMergeAxisLength) + z, 0, segmentData[x]);
                }
            }
            return combined;
        } else if (mergeAxis === 'vertical') {
            return levelData.concat(segmentData);
        }
        
        return null;
    },
    mergeObjects  = function (obj1s, obj2s, mergeAxisLength, mergeAxis) {
        var i    = 0,
            j    = '',
            list = greenSlice(obj1s),
            obj  = null;

        for (i = 0; i < obj2s.length; i++) {
            obj = {};
            for (j in obj2s[i]) {
                if (obj2s[i].hasOwnProperty(j)) {
                    obj[j] = obj2s[i][j];
                }
            }
            if (mergeAxis === 'horizontal') {
                obj.x += mergeAxisLength;
            } else if (mergeAxis === 'vertical') {
                obj.y += mergeAxisLength;
            }
            list.push(obj);
        }
        return list;
    },
    mergeSegment  = function (level, segment, mergeAxis) {
        var i = 0,
            j = '';

        if (!level.tilewidth && !level.tileheight) {
            //set level tile size data if it's not already set.
            level.tilewidth  = segment.tilewidth;
            level.tileheight = segment.tileheight;
        } else if (level.tilewidth !== segment.tilewidth || level.tileheight !== segment.tileheight) {
            platypus.debug.warn('Component LevelBuilder: Your map has segments with different tile sizes. All tile sizes must match. Segment: ' + segment);
        }

        if (mergeAxis === 'horizontal') {
            if (level.height === 0) {
                level.height = segment.height;
            } else if (level.height !== segment.height) {
                platypus.debug.warn('Component LevelBuilder: You are trying to merge segments with different heights. All segments need to have the same height. Level: ' + level + ' Segment: ' + segment);
            }
        } else if (mergeAxis === 'vertical') {
            if (level.width === 0) {
                level.width = segment.width;
            } else if (level.width !== segment.width) {
                platypus.debug.warn('Component LevelBuilder: You are trying to merge segments with different widths. All segments need to have the same width. Level: ' + level + ' Segment: ' + segment);
            }
        }

        for (i = 0; i < segment.layers.length; i++) {
            if (!level.layers[i]) {
                const layer = level.layers[i] = {};

                //if the level doesn't have a layer yet, we're creating it and then copying it from the segment.
                decodeLayer(segment.layers[i]);
                
                for (j in segment.layers[i]) {
                    if (segment.layers[i].hasOwnProperty(j)) {
                        layer[j] = segment.layers[i][j];
                    }
                }

                // If we're adding objects, make sure that they're offset correctly.
                if (layer.objects) {
                    if (mergeAxis === 'horizontal') {
                        layer.objects = mergeObjects([], layer.objects, level.width * level.tilewidth, mergeAxis);
                    } else if (mergeAxis === 'vertical') {
                        layer.objects = mergeObjects([], layer.objects, level.height * level.tileheight, mergeAxis);
                    }
                }
            } else if (level.layers[i].type === segment.layers[i].type) {
                //if the level does have a layer, we're appending the new data to it.
                if (level.layers[i].data && segment.layers[i].data) {
                    // Make sure we're not trying to merge compressed levels.
                    decodeLayer(segment.layers[i]);
                    
                    if (mergeAxis === 'horizontal') {
                        level.layers[i].data = mergeData(level.layers[i].data, level.width, segment.layers[i].data, segment.width, level.height, mergeAxis);
                        level.layers[i].width += segment.width;
                    } else if (mergeAxis === 'vertical') {
                        level.layers[i].data = mergeData(level.layers[i].data, level.height, segment.layers[i].data, segment.height, level.width, mergeAxis);
                        level.layers[i].height += segment.height;
                    }
                } else if (level.layers[i].objects && segment.layers[i].objects) {
                    if (mergeAxis === 'horizontal') {
                        level.layers[i].objects = mergeObjects(level.layers[i].objects, segment.layers[i].objects, level.width * level.tilewidth, mergeAxis);
                    } else if (mergeAxis === 'vertical') {
                        level.layers[i].objects = mergeObjects(level.layers[i].objects, segment.layers[i].objects, level.height * level.tileheight, mergeAxis);
                    }
                }
            } else {
                platypus.debug.warn('Component LevelBuilder: The layers in your level segments do not match. Level: ' + level + ' Segment: ' + segment);
            }
        }

        if (mergeAxis === 'horizontal') {
            level.width += segment.width;
        } else if (mergeAxis === 'vertical') {
            level.height += segment.height;
        }

        //Go through all the STUFF in segment and copy it to the level if it's not already there.
        for (j in segment) {
            if (segment.hasOwnProperty(j) && !level[j]) {
                level[j] = segment[j];
            }
        }
    },
    mergeLevels = function (levelSegments) {
        var i = 0,
            j = 0,
            levelDefinitions = platypus.game.settings.levels,
            row = {
                height: 0,
                width: 0,
                layers: []
            },
            level = {
                height: 0,
                width: 0,
                layers: []
            };

        for (i = 0; i < levelSegments.length; i++) {
            row = {
                height: 0,
                width: 0,
                layers: []
            };
            for (j = 0; j < levelSegments[i].length; j++) {
                //Merge horizontally
                if (typeof levelSegments[i][j] === 'string') {
                    const
                        levelDefinitionLabel = levelSegments[i][j],
                        transformIndex = levelDefinitionLabel.indexOf(':');
                    let levelDefinition = levelDefinitions[levelDefinitionLabel];
                    
                    // check for transform
                    if (!levelDefinition && (transformIndex >= 0)) {
                        const transform = levelDefinitionLabel.substring(transformIndex + 1);

                        levelDefinition = levelDefinitions[levelDefinitionLabel.substring(0, transformIndex)];
                        if (transform === 'mirror') {
                            levelDefinition = levelDefinitions[levelDefinitionLabel] = mirrorSegment(levelDefinition);
                        }
                    }
                    mergeSegment(row, levelDefinition, 'horizontal');
                } else {
                    mergeSegment(row, levelSegments[i][j], 'horizontal');
                }
            }
            //Then merge vertically
            mergeSegment(level, row, 'vertical');
        }
        return level;
    },
    mirrorSegment = function (segment) {
        const
            newSegment = {
                layers: []
            },
            width = segment.width * segment.tilewidth;

        for (let i = 0; i < segment.layers.length; i++) {
            const
                toLayer = newSegment.layers[i] = {},
                fromLayer = segment.layers[i];

            decodeLayer(fromLayer);
            
            for (const key in fromLayer) {
                if (fromLayer.hasOwnProperty(key)) {
                    toLayer[key] = fromLayer[key];
                }
            }

            if (fromLayer.data) {
                const fromData = fromLayer.data,
                    toData = toLayer.data = [],
                    segmentWidth = segment.width;

                for (let j = 0; j < fromData.length; j++) {
                    const cell = fromData[segmentWidth * ((j / segmentWidth) >> 0) + segmentWidth - 1 - (j % segmentWidth)];

                    toData[j] = cell ? maskXFlip ^ cell : 0;
                }
            }

            // If we're adding objects, make sure that they're mirrored correctly.
            if (fromLayer.objects) {
                const
                    fromObjects = fromLayer.objects,
                    toObjects = toLayer.objects = [];

                for (let j = 0; j < fromObjects.length; j++) {
                    const
                        fromObject = fromObjects[j],
                        toObject = toObjects[j] = {};

                    for (const key in fromObject) {
                        if (fromObject.hasOwnProperty(key)) {
                            toObject[key] = fromObject[key];
                        }
                    }
                    toObject.x = width - fromObject.x - (fromObject.width || 0); // subtract object width since its top-left corner is the origin.
                    if (fromObject.rotation) {
                        toObject.rotation = -fromObject.rotation;
                    }
                    if (fromObject.polygon) {
                        toObject.polygon = mirrorPoints(fromObject.polygon);
                    }
                    if (fromObject.polyline) {
                        toObject.polyline = mirrorPoints(fromObject.polyline);
                    }
                }
            }
        }

        //Go through all the STUFF in segment and copy it to the level if it's not already there.
        for (const key in segment) {
            if (segment.hasOwnProperty(key) && !newSegment[key]) {
                newSegment[key] = segment[key];
            }
        }

        return newSegment;
    },
    mirrorPoints = function (points) {
        const
            arr = [];
        let i = points.length;

        while (i--) {
            arr.push({
                x: -points[i].x,
                y: points[i].y
            });
        }
        arr.unshift(arr.pop()); // so the same point is at the beginning.

        return arr;
    };

export default createComponentClass(/** @lends platypus.components.LevelBuilder.prototype */{
    id: 'LevelBuilder',
    
    properties: {
        /**
         * If true, no single map piece is used twice in the creation of the combined map.
         *
         * @property useUniques
         * @type Boolean
         * @default true
         */
        useUniques: true,

        /**
         * A 1D or 2D array of level piece ids. The template defines how the pieces will be arranged and which pieces can be used where. The template must be rectangular in dimensions.
         *
         *      "levelTemplate": [ ["start", "forest"], ["forest", "end"] ]
         *
         * @property levelTemplate
         * @type Array
         * @default null
         */
        levelTemplate: null,

        /**
         * This is an object of key/value pairs listing the pieces that map to an id in the level template. The value can be specified as a string or array. A piece will be randomly chosen from an array when that idea is used. If levelPieces is not defined, ids in the template map directly to level names.
         *
         *      "levelPieces": {
         *          "start"  : "start-map",
         *          "end"      : "end-map",
         *          "forest" : ["forest-1", "forest-2", "forest-3"],
         *          "river": ["river-1", "river-1:mirror"] // adding ":mirror" takes the referenced map and flips it horizontally to add variety.
         *      }
         *
         * @property levelPieces
         * @type Object
         * @default null
         */
        levelPieces: null
    },

    publicProperties: {
    },
    
    /**
     * This component works in tandem with `TiledLoader` by taking several Tiled maps and combining them before `TiledLoader` processes them. Tiled maps must use the same tilesets for this to function correctly.
     *
     * Note: Set "manuallyLoad" to `true` in the `TiledLoader` component JSON definition so that it will wait for this component's "load-level" call.
     *
     * @memberof platypus.components
     * @uses platypus.Component
     * @constructs
     * @listens platypus.Entity#layer-loaded
     * @fires platypus.Entity#created-level
     * @fires platypus.Entity#load-level
     */
    initialize: function () {
        this.levelMessage = {level: null, persistentData: null};
    },

    events: {
        "layer-loaded": function (data) {
            var templateRow  = null,
                piecesToCopy = null,
                x            = '',
                y            = 0,
                i            = 0,
                j            = 0;
            
            this.levelMessage.persistentData = data;

            this.levelTemplate = (data && data.levelTemplate) || this.levelTemplate;
            this.useUniques = (data && data.useUniques) || this.useUniques;
            piecesToCopy = (data && data.levelPieces) || this.levelPieces;
            this.levelPieces = {};
            if (piecesToCopy) {
                for (x in piecesToCopy) {
                    if (piecesToCopy.hasOwnProperty(x)) {
                        if (typeof piecesToCopy[x] === "string") {
                            this.levelPieces[x] = piecesToCopy[x];
                        } else if (piecesToCopy[x].length) {
                            this.levelPieces[x] = [];
                            for (y = 0; y < piecesToCopy[x].length; y++) {
                                this.levelPieces[x].push(piecesToCopy[x][y]);
                            }
                        } else {
                            throw ('Level Builder: Level pieces of incorrect type: ' + piecesToCopy[x]);
                        }
                    }
                }
            }

            if (this.levelTemplate) {
                if (this.levelTemplate) {
                    this.levelMessage.level = [];
                    for (i = 0; i < this.levelTemplate.length; i++) {
                        templateRow = this.levelTemplate[i];
                        if (typeof templateRow === "string") {
                            this.levelMessage.level[i] = this.getLevelPiece(templateRow);
                        } else if (templateRow.length) {
                            this.levelMessage.level[i] = [];
                            for (j = 0; j < templateRow.length; j++) {
                                this.levelMessage.level[i][j] = this.getLevelPiece(templateRow[j]);
                            }
                        } else {
                            throw ('Level Builder: Template row is neither a string or array. What is it?');
                        }
                    }
                } else {
                    platypus.debug.warn('Level Builder: Template is not defined');
                }
            } else {
                platypus.debug.warn('Level Builder: There is no level template.');
            }
            
            if (this.levelMessage.level) {
                this.levelMessage.level = mergeLevels(this.levelMessage.level);
                /**
                 * Dispatched when the scene has loaded and the level has been composited. This occurs before "load-level" to send out level before it's loaded in case it needs to be saved or edited before being loaded.
                 *
                 * @event platypus.Entity#created-level
                 * @param data {Object}
                 * @param data.level {Object} An object describing the level dimensions, tiles, and entities.
                 * @param data.persistentData {Object} The persistent data passed from the last scene. We add levelBuilder data to it to pass on.
                 * @param data.persistentData.levelTemplate {Object} A 1D or 2D array of level piece ids. The template defines how the pieces will be arranged and which pieces can be used where. The template must be rectangular in dimensions.
                 * @param data.persistentData.levelPieces {Object} An object of key/value pairs listing the pieces that map to an id in the level template.
                 * @param data.persistentData.useUniques {Boolean} If true, no single map piece is used twice in the creation of the combined map.
                 */
                this.owner.triggerEvent('created-level', this.levelMessage);

                /**
                 * Dispatched when the scene has loaded and the level has been composited so TileLoader can begin loading the level.
                 *
                 * @event platypus.Entity#load-level
                 * @param data {Object}
                 * @param data.level {Object} An object describing the level dimensions, tiles, and entities.
                 * @param data.persistentData {Object} The persistent data passed from the last scene. We add levelBuilder data to it to pass on.
                 * @param data.persistentData.levelTemplate {Object} A 1D or 2D array of level piece ids. The template defines how the pieces will be arranged and which pieces can be used where. The template must be rectangular in dimensions.
                 * @param data.persistentData.levelPieces {Object} An object of key/value pairs listing the pieces that map to an id in the level template.
                 * @param data.persistentData.useUniques {Boolean} If true, no single map piece is used twice in the creation of the combined map.
                 */
                this.owner.triggerEvent('load-level', this.levelMessage);
            }
        }
    },
    
    methods: {// These are methods that are called by this component.
        getLevelPiece: function (type) {
            var pieces = this.levelPieces[type] || type,
                temp   = null,
                random = 0;
            
            if (pieces) {
                if (typeof pieces === "string") {
                    if (this.useUniques) {
                        temp = pieces;
                        this.levelPieces[type] = null;
                        return temp;
                    } else {
                        return pieces;
                    }
                } else if (pieces.length) {
                    random = Math.floor(Math.random() * pieces.length);
                    if (this.useUniques) {
                        return greenSplice(this.levelPieces[type], random);
                    } else {
                        return pieces[random];
                    }
                } else {
                    throw ('Level Builder: There are no MORE level pieces of type: ' + type);
                }
            } else {
                throw ('Level Builder: There are no level pieces of type: ' + type);
            }
        },
        destroy: function () {
            this.levelMessage.level = null;
            this.levelMessage.persistentData = null;
            this.levelMessage = null;
        }
    },
    
    publicMethods: {
        /**
         * Accepts a list of levels to be merged and returns a level definition with the references combined.
         *
         * @memberof LevelBuilder.prototype
         * @param {Array} levels
         * @return {Object}
         */
        mergeLevels: function (levels) {
            return mergeLevels(levels);
        }
    },
    
    getAssetList: function (def, props, defaultProps) {
        var i = 0,
            arr = null,
            assets = arrayCache.setUp(),
            key = '',
            levels = null;
        
        if (def && def.levelPieces) {
            levels = def.levelPieces;
        } else if (props && props.levelPieces) {
            levels = props.levelPieces;
        } else if (defaultProps && defaultProps.levelPieces) {
            levels = defaultProps.levelPieces;
        }
        
        if (levels) {
            for (key in levels) {
                if (levels.hasOwnProperty(key)) {
                    // Offload to TiledLoader since it has level-parsing handling
                    if (Array.isArray(levels[key])) {
                        for (i = 0; i < levels[key].length; i++) {
                            arr = platypus.components.TiledLoader.getAssetList({
                                level: levels[key][i]
                            }, props, defaultProps);
                            union(assets, arr);
                            arrayCache.recycle(arr);
                        }
                    } else {
                        arr = platypus.components.TiledLoader.getAssetList({
                            level: levels[key]
                        }, props, defaultProps);
                        union(assets, arr);
                        arrayCache.recycle(arr);
                    }
                }
            }
        }
        
        return assets;
    }
});