Source: rmgen/RandomMap.js

/**
 * @file The RandomMap stores the elevation grid, terrain textures and entities that are exported to the engine.
 *
 * @param {number} baseHeight - Initial elevation of the map
 * @param {String|Array} baseTerrain - One or more texture names
 */
function RandomMap(baseHeight, baseTerrain)
{
	this.logger = new RandomMapLogger();

	// Size must be 0 to 1024, divisible by patches
	this.size = g_MapSettings.Size;

	// Create name <-> id maps for textures
	this.nameToID = {};
	this.IDToName = [];

	// Texture 2D array
	this.texture = [];
	for (let x = 0; x < this.size; ++x)
	{
		this.texture[x] = new Uint16Array(this.size);

		for (let z = 0; z < this.size; ++z)
			this.texture[x][z] = this.getTextureID(
				typeof baseTerrain == "string" ? baseTerrain : pickRandom(baseTerrain));
	}

	// Create 2D arrays for terrain objects and areas
	this.terrainEntities = [];

	for (let i = 0; i < this.size; ++i)
	{
		this.terrainEntities[i] = [];
		for (let j = 0; j < this.size; ++j)
			this.terrainEntities[i][j] = undefined;
	}

	// Create 2D array for heightmap
	let mapSize = this.size;
	if (!TILE_CENTERED_HEIGHT_MAP)
		++mapSize;

	this.height = [];
	for (let i = 0; i < mapSize; ++i)
	{
		this.height[i] = new Float32Array(mapSize);

		for (let j = 0; j < mapSize; ++j)
			this.height[i][j] = baseHeight;
	}

	this.entities = [];

	// Starting entity ID, arbitrary number to leave some space for player entities
	this.entityCount = 150;
}

/**
 * Prints a timed log entry to stdout and the logfile.
 */
RandomMap.prototype.log = function(text)
{
	this.logger.print(text);
};

/**
 * Loads an imagefile and uses it as the heightmap for the current map.
 * Scales the map (including height) proportionally with the mapsize.
 */
RandomMap.prototype.LoadMapTerrain = function(filename)
{
	g_Map.log("Loading terrain file " + filename);
	let mapTerrain = Engine.LoadMapTerrain("maps/random/" + filename + ".pmp");

	let heightmapPainter = new HeightmapPainter(convertHeightmap1Dto2D(mapTerrain.height));

	createArea(
		new MapBoundsPlacer(),
		[
			heightmapPainter,
			new TerrainTextureArrayPainter(mapTerrain.textureIDs, mapTerrain.textureNames)
		]);

	return heightmapPainter.getScale();
};

/**
 * Loads PMP terrain file that contains elevation grid and terrain textures created in atlas.
 * Scales the map (including height) proportionally with the mapsize.
 * Notice that the image heights can only be between 0 and 255, but the resulting sizes can exceed that range due to the cubic interpolation.
 */
RandomMap.prototype.LoadHeightmapImage = function(filename, normalMinHeight, normalMaxHeight)
{
	g_Map.log("Loading heightmap " + filename);

	let heightmapPainter = new HeightmapPainter(
		convertHeightmap1Dto2D(Engine.LoadHeightmapImage("maps/random/" + filename)), normalMinHeight, normalMaxHeight);

	createArea(
		new MapBoundsPlacer(),
		heightmapPainter);

	return heightmapPainter.getScale();
};

/**
 * Returns the ID of a texture name.
 * Creates a new ID if there isn't one assigned yet.
 */
RandomMap.prototype.getTextureID = function(texture)
{
	if (texture in this.nameToID)
		return this.nameToID[texture];

	let id = this.IDToName.length;
	this.nameToID[texture] = id;
	this.IDToName[id] = texture;

	return id;
};

/**
 * Returns the next unused entityID.
 */
RandomMap.prototype.getEntityID = function()
{
	return this.entityCount++;
};

RandomMap.prototype.isCircularMap = function()
{
	return !!g_MapSettings.CircularMap;
};

RandomMap.prototype.getSize = function()
{
	return this.size;
};

RandomMap.prototype.getArea = function(size = this.size)
{
	return this.isCircularMap ? diskArea(size / 2) : size * size;
};

/**
 * Returns the center tile coordinates of the map.
 */
RandomMap.prototype.getCenter = function()
{
	return deepfreeze(new Vector2D(this.size / 2, this.size / 2));
};

/**
 * Returns a human-readable reference to the smallest and greatest coordinates of the map.
 */
RandomMap.prototype.getBounds = function()
{
	return deepfreeze({
		"left": 0,
		"right": this.size,
		"top": this.size,
		"bottom": 0
	});
};

/**
 * Determines whether the given coordinates are within the given distance of the map area.
 * Should be used to restrict actor placement.
 * Entity placement should be checked against validTilePassable to exclude the map border.
 * Terrain texture changes should be tested against inMapBounds.
 */
RandomMap.prototype.validTile = function(position, distance = 0)
{
	if (this.isCircularMap())
		return Math.round(position.distanceTo(this.getCenter())) < this.size / 2 - distance - 1;

	return position.x >= distance && position.y >= distance && position.x < this.size - distance && position.y < this.size - distance;
};

/**
 * Determines whether the given coordinates are within the given distance of the passable map area.
 * Should be used to restrict entity placement and path creation.
 */
RandomMap.prototype.validTilePassable = function(position, distance = 0)
{
	return this.validTile(position, distance + MAP_BORDER_WIDTH);
};

/**
 * Determines whether the given coordinates are within the tile grid, passable or not.
 * Should be used to restrict texture painting.
 */
RandomMap.prototype.inMapBounds = function(position)
{
	return position.x >= 0 && position.y >= 0 && position.x < this.size && position.y < this.size;
};

/**
 * Determines whether the given coordinates are within the heightmap grid.
 * Should be used to restrict elevation changes.
 */
RandomMap.prototype.validHeight = function(position)
{
	if (position.x < 0 || position.y < 0)
		return false;

	if (TILE_CENTERED_HEIGHT_MAP)
		return position.x < this.size && position.y < this.size;

	return position.x <= this.size && position.y <= this.size;
};

/**
 * Returns a random point on the map.
 * @param passableOnly - Should be true for entity placement and false for terrain or elevation operations.
 */
RandomMap.prototype.randomCoordinate = function(passableOnly)
{
	let border = passableOnly ? MAP_BORDER_WIDTH : 0;

	if (this.isCircularMap())
		// Polar coordinates
		// Uniformly distributed on the disk
		return Vector2D.add(
			this.getCenter(),
			new Vector2D((this.size / 2 - border) * Math.sqrt(randFloat(0, 1)), 0).rotate(randomAngle()).floor());

	// Rectangular coordinates
	return new Vector2D(
		randIntExclusive(border, this.size - border),
		randIntExclusive(border, this.size - border));
};

/**
 * Returns the name of the texture of the given tile.
 */
RandomMap.prototype.getTexture = function(position)
{
	if (!this.inMapBounds(position))
		throw new Error("getTexture: invalid tile position " + uneval(position));

	return this.IDToName[this.texture[position.x][position.y]];
};

/**
 * Paints the given texture on the given tile.
 */
RandomMap.prototype.setTexture = function(position, texture)
{
	if (position.x < 0 ||
	    position.y < 0 ||
	    position.x >= this.texture.length ||
	    position.y >= this.texture[position.x].length)
		throw new Error("setTexture: invalid tile position " + uneval(position));

	this.texture[position.x][position.y] = this.getTextureID(texture);
};

RandomMap.prototype.getHeight = function(position)
{
	if (!this.validHeight(position))
		throw new Error("getHeight: invalid vertex position " + uneval(position));

	return this.height[position.x][position.y];
};

RandomMap.prototype.setHeight = function(position, height)
{
	if (!this.validHeight(position))
		throw new Error("setHeight: invalid vertex position " + uneval(position));

	this.height[position.x][position.y] = height;
};

/**
 * Adds the given Entity to the map at the location it defines, even if at the impassable map border.
 */
RandomMap.prototype.placeEntityAnywhere = function(templateName, playerID, position, orientation)
{
	let entity = new Entity(this.getEntityID(), templateName, playerID, position, orientation);
	this.entities.push(entity);
	return entity;
};

/**
 * Adds the given Entity to the map at the location it defines, if that area is not at the impassable map border.
 */
RandomMap.prototype.placeEntityPassable = function(templateName, playerID, position, orientation)
{
	if (!this.validTilePassable(position))
		return undefined;

	return this.placeEntityAnywhere(templateName, playerID, position, orientation);
};

/**
 * Returns the Entity that was painted by a Terrain class on the given tile or undefined otherwise.
 */
RandomMap.prototype.getTerrainEntity = function(position)
{
	if (!this.validTilePassable(position))
		throw new Error("getTerrainEntity: invalid tile position " + uneval(position));

	return this.terrainEntities[position.x][position.y];
};

/**
 * Places the Entity on the given tile and allows to later replace it if the terrain was painted over.
 */
RandomMap.prototype.setTerrainEntity = function(templateName, playerID, position, orientation)
{
	let tilePosition = position.clone().floor();
	if (!this.validTilePassable(tilePosition))
		throw new Error("setTerrainEntity: invalid tile position " + uneval(position));

	this.terrainEntities[tilePosition.x][tilePosition.y] =
		new Entity(this.getEntityID(), templateName, playerID, position, orientation);
};

RandomMap.prototype.deleteTerrainEntity = function(position)
{
	let tilePosition = position.clone().floor();
	if (!this.validTilePassable(tilePosition))
		throw new Error("setTerrainEntity: invalid tile position " + uneval(position));

	this.terrainEntities[tilePosition.x][tilePosition.y] = undefined;
};

RandomMap.prototype.createTileClass = function()
{
	return new TileClass(this.size);
};

/**
 * Retrieve interpolated height for arbitrary coordinates within the heightmap grid.
 */
RandomMap.prototype.getExactHeight = function(position)
{
	let xi = Math.min(Math.floor(position.x), this.size);
	let zi = Math.min(Math.floor(position.y), this.size);
	let xf = position.x - xi;
	let zf = position.y - zi;

	let h00 = this.height[xi][zi];
	let h01 = this.height[xi][zi + 1];
	let h10 = this.height[xi + 1][zi];
	let h11 = this.height[xi + 1][zi + 1];

	return (1 - zf) * ((1 - xf) * h00 + xf * h10) + zf * ((1 - xf) * h01 + xf * h11);
};

// Converts from the tile centered height map to the corner based height map, used when TILE_CENTERED_HEIGHT_MAP = true
RandomMap.prototype.cornerHeight = function(position)
{
	let count = 0;
	let sumHeight = 0;

	for (let vertex of g_TileVertices)
	{
		let pos = Vector2D.sub(position, vertex);
		if (this.validHeight(pos))
		{
			++count;
			sumHeight += this.getHeight(pos);
		}
	}

	if (!count)
		return 0;

	return sumHeight / count;
};

RandomMap.prototype.getAdjacentPoints = function(position)
{
	let adjacentPositions = [];

	for (let adjacentCoordinate of g_AdjacentCoordinates)
	{
		let adjacentPos = Vector2D.add(position, adjacentCoordinate).round();
		if (this.inMapBounds(adjacentPos))
			adjacentPositions.push(adjacentPos);
	}

	return adjacentPositions;
};

/**
 * Returns the average height of adjacent tiles, helpful for smoothing.
 */
RandomMap.prototype.getAverageHeight = function(position)
{
	let adjacentPositions = this.getAdjacentPoints(position);
	if (!adjacentPositions.length)
		return 0;

	return adjacentPositions.reduce((totalHeight, pos) => totalHeight + this.getHeight(pos), 0) / adjacentPositions.length;
};

/**
 * Returns the steepness of the given location, defined as the average height difference of the adjacent tiles.
 */
RandomMap.prototype.getSlope = function(position)
{
	let adjacentPositions = this.getAdjacentPoints(position);
	if (!adjacentPositions.length)
		return 0;

	return adjacentPositions.reduce((totalSlope, adjacentPos) =>
		totalSlope + Math.abs(this.getHeight(adjacentPos) - this.getHeight(position)), 0) / adjacentPositions.length;
};

/**
 * Retrieve an array of all Entities placed on the map.
 */
RandomMap.prototype.exportEntityList = function()
{
	let nonTerrainCount = this.entities.length;

	// Change rotation from simple 2d to 3d befor giving to engine
	for (let entity of this.entities)
		entity.rotation.y = Math.PI / 2 - entity.rotation.y;

	// Terrain objects e.g. trees
	for (let x = 0; x < this.size; ++x)
		for (let z = 0; z < this.size; ++z)
			if (this.terrainEntities[x][z])
				this.entities.push(this.terrainEntities[x][z]);

	this.logger.printDirectly(
		"Total entities: " + this.entities.length + ", " +
		"Terrain entities: " + (this.entities.length - nonTerrainCount) + ", " +
		"Textures: " + this.IDToName.length + ".\n");

	return this.entities;
};

/**
 * Convert the elevation grid to a one-dimensional array.
 */
RandomMap.prototype.exportHeightData = function()
{
	let heightmapSize = this.size + 1;
	let heightmap = new Uint16Array(Math.square(heightmapSize));

	for (let x = 0; x < heightmapSize; ++x)
		for (let z = 0; z < heightmapSize; ++z)
		{
			let position = new Vector2D(x, z);
			let currentHeight = TILE_CENTERED_HEIGHT_MAP ? this.cornerHeight(position) : this.getHeight(position);

			// Correct height by SEA_LEVEL and prevent under/overflow in terrain data
			heightmap[z * heightmapSize + x] = Math.max(0, Math.min(0xFFFF, Math.floor((currentHeight + SEA_LEVEL) * HEIGHT_UNITS_PER_METRE)));
		}

	return heightmap;
};

/**
 * Assemble terrain textures in a one-dimensional array.
 */
RandomMap.prototype.exportTerrainTextures = function()
{
	let tileIndex = new Uint16Array(Math.square(this.size));
	let tilePriority = new Uint16Array(Math.square(this.size));

	for (let x = 0; x < this.size; ++x)
		for (let z = 0; z < this.size; ++z)
		{
			// TODO: For now just use the texture's index as priority, might want to do this another way
			tileIndex[z * this.size + x] = this.texture[x][z];
			tilePriority[z * this.size + x] = this.texture[x][z];
		}

	return {
		"index": tileIndex,
		"priority": tilePriority
	};
};

RandomMap.prototype.ExportMap = function()
{
	if (g_Environment.Water.WaterBody.Height === undefined)
		g_Environment.Water.WaterBody.Height = SEA_LEVEL - 0.1;

	this.logger.close();

	Engine.ExportMap({
		"entities": this.exportEntityList(),
		"height": this.exportHeightData(),
		"seaLevel": SEA_LEVEL,
		"size": this.size,
		"textureNames": this.IDToName,
		"tileData": this.exportTerrainTextures(),
		"Camera": g_Camera,
		"Environment": g_Environment
	});
};