/* 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;
}
});