components/LevelBuilder.js

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

const
    assertLevelPiece = function (piece, label) {
        if (piece == null) {
            throw new Error(`Level Builder: Level piece "${label}" is undefined. Check that it is defined in platypus.game.settings.levels or levelPieces.`);
        }

        if (typeof piece !== 'object') {
            throw new Error(`Level Builder: Level piece "${label}" must be a level definition object, received ${typeof piece}.`);
        }

        if (piece.width == null) {
            throw new Error(`Level Builder: Level piece "${label}" is missing width.`);
        }

        if (piece.height == null) {
            throw new Error(`Level Builder: Level piece "${label}" is missing height.`);
        }

        if (piece.tilewidth == null) {
            throw new Error(`Level Builder: Level piece "${label}" is missing tilewidth.`);
        }

        if (piece.tileheight == null) {
            throw new Error(`Level Builder: Level piece "${label}" is missing tileheight.`);
        }

        if (piece.layers == null) {
            throw new Error(`Level Builder: Level piece "${label}" is missing a layers array.`);
        }

        if (!Array.isArray(piece.layers)) {
            throw new Error(`Level Builder: Level piece "${label}" layers must be an array.`);
        }

        return piece;
    },
    assertLevelTemplate = function (template) {
        if (template == null) {
            throw new Error(`Level Builder: levelTemplate is required but was not provided.`);
        }

        if (!Array.isArray(template)) {
            throw new Error(`Level Builder: levelTemplate must be an array, received ${typeof template}.`);
        }

        if (template.length === 0) {
            throw new Error(`Level Builder: levelTemplate cannot be empty.`);
        }

        let rowLength = null;

        for (let i = 0; i < template.length; i++) {
            const
                row = template[i];

            if (typeof row === 'string') {
                if (!row) {
                    throw new Error(`Level Builder: levelTemplate[${i}] is an empty string.`);
                }

                if (rowLength != null && rowLength !== '1d') {
                    throw new Error(`Level Builder: levelTemplate is not rectangular: row 0 has ${rowLength} columns but row ${i} is a string.`);
                }

                rowLength = '1d';
            } else if (Array.isArray(row)) {
                if (row.length === 0) {
                    throw new Error(`Level Builder: levelTemplate row ${i} is empty.`);
                }

                if (rowLength === '1d') {
                    throw new Error(`Level Builder: levelTemplate is not rectangular: row 0 is a string but row ${i} is an array.`);
                }

                if (rowLength == null) {
                    rowLength = row.length;
                } else if (rowLength !== row.length) {
                    throw new Error(`Level Builder: levelTemplate is not rectangular: row 0 has ${rowLength} columns but row ${i} has ${row.length}.`);
                }

                for (let j = 0; j < row.length; j++) {
                    if (typeof row[j] !== 'string') {
                        throw new Error(`Level Builder: levelTemplate[${i}][${j}] must be a piece id string, received ${typeof row[j]}.`);
                    }

                    if (!row[j]) {
                        throw new Error(`Level Builder: levelTemplate[${i}][${j}] is an empty string.`);
                    }
                }
            } else {
                throw new Error(`Level Builder: levelTemplate[${i}] must be a string or array of strings, received ${typeof row}.`);
            }
        }

        return template;
    },
    assertLevelPieces = function (pieces) {
        if (pieces == null) {
            return;
        }

        if (typeof pieces !== 'object' || Array.isArray(pieces)) {
            throw new Error(`Level Builder: levelPieces must be an object, received ${Array.isArray(pieces) ? 'array' : typeof pieces}.`);
        }

        const
            keys = Object.keys(pieces),
            {length} = keys;

        for (let i = 0; i < length; i++) {
            const
                key = keys[i],
                value = pieces[key];

            if (typeof value === 'string') {
                if (!value) {
                    throw new Error(`Level Builder: levelPieces["${key}"] is an empty string.`);
                }
            } else if (Array.isArray(value)) {
                if (value.length === 0) {
                    throw new Error(`Level Builder: levelPieces["${key}"] is an empty array. Provide at least one level piece id.`);
                }

                for (let j = 0; j < value.length; j++) {
                    if (typeof value[j] !== 'string') {
                        throw new Error(`Level Builder: levelPieces["${key}"][${j}] must be a string level id, received ${typeof value[j]}.`);
                    }

                    if (!value[j]) {
                        throw new Error(`Level Builder: levelPieces["${key}"][${j}] is an empty string.`);
                    }
                }
            } else {
                throw new Error(`Level Builder: levelPieces["${key}"] must be a string or array of strings, received ${typeof value}.`);
            }
        }
    },
    resolveLevelPiece = function (piece, label) {
        if (typeof piece === 'string') {
            const
                levelDefinitions = platypus.game.settings.levels,
                transformIndex = piece.indexOf(':');
            let levelDefinition = levelDefinitions?.[piece];

            if (!levelDefinition && transformIndex >= 0) {
                const
                    baseLabel = piece.substring(0, transformIndex),
                    transform = piece.substring(transformIndex + 1);

                levelDefinition = levelDefinitions?.[baseLabel];

                if (!levelDefinition) {
                    throw new Error(`Level Builder: Level piece "${baseLabel}" (from "${piece}") was not found in platypus.game.settings.levels.`);
                }

                if (transform === 'mirror') {
                    return levelDefinitions[piece] = mirrorSegment(assertLevelPiece(levelDefinition, baseLabel));
                }

                throw new Error(`Level Builder: Unknown transform "${transform}" in level piece "${piece}". Supported transforms: mirror.`);
            }

            if (!levelDefinition) {
                throw new Error(`Level Builder: Level piece "${piece}" was not found in platypus.game.settings.levels.`);
            }

            return assertLevelPiece(levelDefinition, piece);
        }

        return assertLevelPiece(piece, label);
    },
    maskXFlip = 0x80000000,
    decodeString = (str, index) => (((str.charCodeAt(index)) + (str.charCodeAt(index + 1) << 8) + (str.charCodeAt(index + 2) << 16) + (str.charCodeAt(index + 3) << 24 )) >>> 0),
    decodeArray = (arr, index) => ((arr[index] + (arr[index + 1] << 8) + (arr[index + 2] << 16) + (arr[index + 3] << 24 )) >>> 0),
    decodeBase64 = (data, compression) => {
        const
            arr   = [],
            compressed = compression === 'zlib',
            step1 = window.atob(data.replace(/\\/g, '')),
            step2 = compressed ? inflate(step1) : step1,
            decode = compressed ? decodeArray : decodeString;
        let index = 4;
            
        while (index <= step2.length) {
            arr.push(decode(step2, index - 4));
            index += 4;
        }
        
        return arr;
    },
    decodeLayer = function (layer, label) {
        if (layer == null) {
            throw new Error(`Level Builder: Cannot decode layer${label ? ` in "${label}"` : ''}: layer is undefined.`);
        }

        if (layer.encoding === 'base64') {
            layer.data = decodeBase64(layer.data, layer.compression);
            layer.encoding = 'csv'; // So we won't have to decode again.
        }
        return layer;
    },
    cloneLevelDefinition = cloneDefinition,
    copySegmentProperty = function (target, source, key) {
        if (target[key]) {
            return;
        }

        const
            value = source[key];

        if (value != null && typeof value === 'object') {
            target[key] = cloneDefinition(value);
        } else {
            target[key] = value;
        }
    },
    mergeData = function (levelData, levelMergeAxisLength, segmentData, segmentMergeAxisLength, nonMergeAxisLength, mergeAxis) {
        const
            combined = greenSlice(levelData);

        if (mergeAxis === 'horizontal') {
            for (let y = nonMergeAxisLength - 1; y >= 0; y--) {
                for (let 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) {
        const
            list = greenSlice(obj1s);

        for (let i = 0; i < obj2s.length; i++) {
            const
                obj = cloneLevelDefinition(obj2s[i]);

            if (mergeAxis === 'horizontal') {
                obj.x += mergeAxisLength;
            } else if (mergeAxis === 'vertical') {
                obj.y += mergeAxisLength;
            }
            list.push(obj);
        }
        return list;
    },
    mergeSegment  = function (level, segment, mergeAxis, segmentLabel) {
        segment = cloneLevelDefinition(assertLevelPiece(segment, segmentLabel ?? 'segment'));

        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 (let i = 0; i < segment.layers.length; i++) {
            if (segment.layers[i] == null) {
                throw new Error(`Level Builder: Level piece "${segmentLabel ?? 'segment'}" layer ${i} is undefined.`);
            }

            if (!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], segmentLabel);
                
                const
                    layer = level.layers[i] = cloneLevelDefinition(segment.layers[i]);

                // 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], segmentLabel);
                    
                    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.
        {
            const
                keys = Object.keys(segment),
                {length} = keys;

            for (let i = 0; i < length; i++) {
                const
                    key = keys[i];

                copySegmentProperty(level, segment, key);
            }
        }
    },
    mergeLevels = function (levelSegments) {
        const
            level = {
                height: 0,
                width: 0,
                layers: []
            };

        for (let i = 0; i < levelSegments.length; i++) {
            const
                row = {
                    height: 0,
                    width: 0,
                    layers: []
                };

            if (!Array.isArray(levelSegments[i])) {
                throw new Error(`Level Builder: mergeLevels row ${i} must be an array of level pieces, received ${typeof levelSegments[i]}.`);
            }

            for (let j = 0; j < levelSegments[i].length; j++) {
                const
                    piece = levelSegments[i][j],
                    pieceLabel = typeof piece === 'string' ? piece : `row ${i}, column ${j}`;

                //Merge horizontally
                mergeSegment(row, resolveLevelPiece(piece, pieceLabel), 'horizontal', pieceLabel);
            }
            //Then merge vertically
            mergeSegment(level, row, 'vertical', `row ${i}`);
        }
        return level;
    },
    mirrorSegment = function (segment, segmentLabel) {
        segment = cloneLevelDefinition(assertLevelPiece(segment, segmentLabel ?? 'segment'));

        const
            newSegment = {
                layers: []
            },
            width = segment.width * segment.tilewidth;

        for (let i = 0; i < segment.layers.length; i++) {
            if (segment.layers[i] == null) {
                throw new Error(`Level Builder: Level piece "${segmentLabel ?? 'segment'}" layer ${i} is undefined.`);
            }

            decodeLayer(segment.layers[i], segmentLabel);

            const
                fromLayer = segment.layers[i],
                toLayer = newSegment.layers[i] = cloneLevelDefinition(fromLayer);

            if (fromLayer.data) {
                const
                    fromData = fromLayer.data,
                    segmentWidth = segment.width;

                toLayer.data = fromData.map((value, index) => {
                    const
                        cell = fromData[segmentWidth * ((index / segmentWidth) >> 0) + segmentWidth - 1 - (index % segmentWidth)];

                    return 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] = cloneLevelDefinition(fromObject);

                    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.
        {
            const
                keys = Object.keys(segment),
                {length} = keys;

            for (let i = 0; i < length; i++) {
                const
                    key = keys[i];

                copySegmentProperty(newSegment, 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) {
            if (data?.level && typeof data.level === 'object') {
                return;
            }

            const
                piecesToCopy = data?.levelPieces ?? this.levelPieces;
            
            this.levelMessage.persistentData = data;
            this.levelTemplate = data?.levelTemplate ?? this.levelTemplate;
            this.useUniques = data?.useUniques ?? this.useUniques;
            assertLevelPieces(piecesToCopy);

            this.levelPieces = {};
            if (piecesToCopy) {
                const
                    keys = Object.keys(piecesToCopy),
                    {length} = keys;
        
                for (let i = 0; i < length; i++) {
                    const
                        key = keys[i];

                    if (typeof piecesToCopy[key] === "string") {
                        this.levelPieces[key] = piecesToCopy[key];
                    } else if (Array.isArray(piecesToCopy[key])) {
                        this.levelPieces[key] = [
                            ...piecesToCopy[key]
                        ];
                    }
                }
            }

            if (this.levelTemplate) {
                assertLevelTemplate(this.levelTemplate);

                this.levelMessage.level = [];
                for (let i = 0; i < this.levelTemplate.length; i++) {
                    const
                        templateRow = this.levelTemplate[i];

                    if (typeof templateRow === "string") {
                        this.levelMessage.level[i] = this.getLevelPiece(templateRow);
                    } else {
                        this.levelMessage.level[i] = [];
                        for (let j = 0; j < templateRow.length; j++) {
                            this.levelMessage.level[i][j] = this.getLevelPiece(templateRow[j]);
                        }
                    }
                }
            } 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', {
                    level: cloneLevelDefinition(this.levelMessage.level),
                    persistentData: this.levelMessage.persistentData
                });

                /**
                 * 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) {
            if (type == null || type === '') {
                throw new Error(`Level Builder: Template references an invalid piece id: ${type}.`);
            }

            const
                pieces = this.levelPieces[type] ?? type;
            
            if (pieces) {
                if (typeof pieces === "string") {
                    if (this.useUniques) {
                        this.levelPieces[type] = null;
                    }
                    return pieces;
                } else if (pieces.length) {
                    const
                        random = Math.floor(Math.random() * pieces.length);

                    if (this.useUniques) {
                        return greenSplice(this.levelPieces[type], random);
                    } else {
                        return pieces[random];
                    }
                } else {
                    throw new Error(`Level Builder: There are no MORE level pieces of type: ${type}`);
                }
            } else {
                throw new Error(`Level Builder: There are no level pieces of type: ${type}`);
            }
        },
        cloneLevel: function (level) {
            return cloneLevelDefinition(level);
        },
        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, data) {
        const
            assets = arrayCache.setUp(),
            levels = def?.levelPieces ?? props?.levelPieces ?? defaultProps?.levelPieces ?? data?.levelPieces;
        
        if (levels) {
            const
                keys = Object.keys(levels),
                {length} = keys;

            for (let i = 0; i < length; i++) {
                const
                    key = keys[i];

                // Offload to TiledLoader since it has level-parsing handling
                if (Array.isArray(levels[key])) {
                    for (let j = 0; j < levels[key].length; j++) {
                        const
                            arr = TiledLoader.getAssetList({
                                level: levels[key][j]
                            }, props, defaultProps);

                        union(assets, arr);
                        arrayCache.recycle(arr);
                    }
                } else {
                    const
                        arr = TiledLoader.getAssetList({
                            level: levels[key]
                        }, props, defaultProps);

                    union(assets, arr);
                    arrayCache.recycle(arr);
                }
            }
        }
        
        return assets;
    }
});