Generating a Random Landscape in JavaScript

The JavaScript code below will generate a random topological map. To see it in action, copy it into an .html file and open that file in a web browser that runs JavaScript.  Or, for an online version, visit http://thiscouldbebetter.neocities.org/landscape.html.

When run, the program will show each step involved in interpolating the landscape as a separate image.  Note that the example shown below was generated by an earlier version of the program, in which the final landscape always had a 2-pixel black line running down it.  This bug has since been fixed.

Landscape-Generated


<html>
<body>
<script type='text/javascript'>

// main

function TopographyGeneratorTest()
{
	this.main = function()
	{
		var terrainSet = new TerrainSet
		(
			"TerrainSet0",
			[
				new Terrain("OceanDeep", "#0000ff", 0),
				new Terrain("OceanShallow", "#0080ff", .4),
				new Terrain("Beach", "#cccc40", .5),
				new Terrain("Midlands", "#00aa00", .6),
				new Terrain("Foothills", "#008000", .8),
				new Terrain("Mountains", "#ffffff", .9),
			]
		);	

		var map = new Map
		(
			"Map0",
			8, // depthMax
			terrainSet
		);

		map.generateRandom();

		var mapAsImage = map.toImage();
	}
}

// classes

function Coords(x, y)
{
	this.x = x;
	this.y = y;
}
{
	Coords.prototype.add = function(other)
	{
		this.x += other.x;
		this.y += other.y;

		return this;
	}

	Coords.prototype.clone = function()
	{
		return new Coords(this.x, this.y);
	}

	Coords.prototype.isWithinRange = function(range)
	{
		var returnValue =
		(
			this.x >= 0 
			&& this.x <= range.x
			&& this.y >= 0 
			&& this.y <= range.y
		);

		return returnValue;

	}

	Coords.prototype.multiplyScalar = function(scalar)
	{
		this.x *= scalar;
		this.y *= scalar;

		return this;
	}

	Coords.prototype.overwriteWith = function(other)
	{
		this.x = other.x;
		this.y = other.y;

		return this;
	}

	Coords.prototype.wrapToRange = function(range)
	{
		while (this.x < 0)
		{
			this.x += range.x;
		}
		while (this.x > range.x)
		{
			this.x -= range.x;
		}

		while (this.y < 0)
		{
			this.y += range.y;
		}
		while (this.y > range.y)
		{
			this.y -= range.y;
		}

		return this;
	}
}

function Map(name, depthMax, terrainSet)
{
	this.name = name;
	this.depthMax = depthMax;
	this.terrainSet = terrainSet;

	var dimensionInCells = Math.pow(2, this.depthMax) + 1;
	this.sizeInCells = new Coords(dimensionInCells, dimensionInCells);	
	this.sizeInCellsMinusOnes = this.sizeInCells.clone().add
	(
		new Coords(-1, -1)
	)

	this.cellAltitudes = [];
}
{
	Map.prototype.indexOfCellAtPos = function(cellPos)
	{
		return cellPos.y * this.sizeInCells.x + cellPos.x;
	}

	Map.prototype.generateRandom = function()
	{
		var cornerCellPositions =
		[
			new Coords(0, 0), // nw
			new Coords(this.sizeInCellsMinusOnes.x, 0), // ne
			new Coords(this.sizeInCellsMinusOnes.x, this.sizeInCellsMinusOnes.y), // se
			new Coords(0, this.sizeInCellsMinusOnes.y), // sw
		];

		for (var i = 0; i < cornerCellPositions.length; i++)
		{
			var cornerPos = cornerCellPositions[i];
			var cellIndex = this.indexOfCellAtPos(cornerPos);	
			this.cellAltitudes[cellIndex] = 0;
		}

		var parentPos = new Coords(0, 0);
		var childPos = new Coords(0, 0);

		var neighborDatas =
		[
			// directionToNeighbor, neighborIndicesContributing, altitudeVariationMultiplier
			new NeighborData(new Coords(1, 0), [0], 1),
			new NeighborData(new Coords(0, 1), [1], 1),
			new NeighborData(new Coords(1, 1), [0, 1, 2], Math.sqrt(2)),
		];	

		for (var d = 0; d < this.depthMax; d++)
		{
			this.generateRandom_1
			(
				parentPos,
				childPos,
				neighborDatas,
				d
			);
		}
	}

	Map.prototype.generateRandom_1 = function
	(
		parentPos,
		childPos,
		neighborDatas,
		d
	)
	{
		var stepSizeInCells = Math.pow(2, this.depthMax - d);
		var stepSizeInCellsHalf = stepSizeInCells / 2;
		var altitudeVariationRange = stepSizeInCells / this.sizeInCellsMinusOnes.x;

		for (var y = 0; y < this.sizeInCells.y; y += stepSizeInCells)
		{
			parentPos.y = y;

			for (var x = 0; x < this.sizeInCells.x; x += stepSizeInCells)
			{
				parentPos.x = x;

				this.generateRandom_2
				(
					parentPos,
					childPos,
					neighborDatas,
					stepSizeInCells,
					stepSizeInCellsHalf,
					altitudeVariationRange 
				);
			}
		}

		document.body.appendChild(this.toImage());
	}

	Map.prototype.generateRandom_2 = function
	(
		parentPos,
		childPos,
		neighborDatas,
		stepSizeInCells,
		stepSizeInCellsHalf,
		altitudeVariationRange 
	)
	{
		var parentIndex = this.indexOfCellAtPos(parentPos);
		var parentAltitude = this.cellAltitudes[parentIndex];

		for (var n = 0; n < neighborDatas.length; n++)
		{
			var neighborData = neighborDatas[n];

			var neighborPos = neighborData.pos;

			neighborPos.overwriteWith
			(
				neighborData.directionToNeighbor
			).multiplyScalar
			(
				stepSizeInCells
			).add
			(
				parentPos
			);

			if (neighborPos.isWithinRange(this.sizeInCellsMinusOnes) == false)
			{
				neighborPos.wrapToRange(this.sizeInCellsMinusOnes);
			}
		}

		for (var n = 0; n < neighborDatas.length; n++)
		{
			var neighborData = neighborDatas[n];

			childPos.overwriteWith
			(
				neighborData.directionToNeighbor
			).multiplyScalar
			(
				stepSizeInCellsHalf
			).add
			(
				parentPos
			);

			if (childPos.isWithinRange(this.sizeInCellsMinusOnes) == true)
			{						
				var childIndex = this.indexOfCellAtPos(childPos);

				var sumOfNeighborsContributingSoFar = parentAltitude;
	
				var neighborIndicesContributing = neighborData.neighborIndicesContributing;

				for (var c = 0; c < neighborIndicesContributing.length; c++)
				{
					var neighborIndex = neighborIndicesContributing[c];
					neighborPos = neighborDatas[neighborIndex].pos;
					var neighborIndex = this.indexOfCellAtPos(neighborPos);
					var neighborAltitude = this.cellAltitudes[neighborIndex];
	
					sumOfNeighborsContributingSoFar += neighborAltitude;
				}

				var childAltitude = 
					sumOfNeighborsContributingSoFar 
					/ (neighborIndicesContributing.length + 1)
					+ (Math.random() * 2 - 1)
					* altitudeVariationRange
					* neighborData.altitudeVariationMultiplier;

				childAltitude = NumberHelper.reflectNumberOffRange
				(
					childAltitude, 0, 1
				);

				this.cellAltitudes[childIndex] = childAltitude;
			}
		}
	}

	Map.prototype.toImage = function()
	{
		var canvas = document.createElement("canvas");
		canvas.width = this.sizeInCells.x;
		canvas.height = this.sizeInCells.y;

		var graphics = canvas.getContext("2d");

		var cellPos = new Coords(0, 0);

		for (var y = 0; y < this.sizeInCells.y; y++)
		{
			cellPos.y = y;

			for (var x = 0; x < this.sizeInCells.x; x++)
			{
				cellPos.x = x;			

				var cellIndex = this.indexOfCellAtPos(cellPos);
				var cellAltitude = this.cellAltitudes[cellIndex];
				var terrainForAltitude = this.terrainSet.getTerrainForAltitude
				(
					cellAltitude
				);

				graphics.fillStyle = 
				(
					terrainForAltitude == null 
					? "#000000" 
					: terrainForAltitude.color
				);

				graphics.fillRect
				(
					cellPos.x, 
					cellPos.y, 
					1,
					1
				);				
			}
		}

		var imageFromCanvasURL = canvas.toDataURL("image/png");
		var htmlImageFromCanvas = document.createElement("img");
		htmlImageFromCanvas.width = canvas.width;
		htmlImageFromCanvas.height = canvas.height;
		htmlImageFromCanvas.src = imageFromCanvasURL;

		htmlImageFromCanvas.style.margin = "8px";

		return htmlImageFromCanvas;
	}
}

function NeighborData
(
	directionToNeighbor, 
	neighborIndicesContributing, 
	altitudeVariationMultiplier
)
{
	this.directionToNeighbor = directionToNeighbor;
	this.neighborIndicesContributing = neighborIndicesContributing;
	this.altitudeVariationMultiplier = altitudeVariationMultiplier;

	this.pos = new Coords(0, 0);
}

function NumberHelper()
{}
{
	NumberHelper.reflectNumberOffRange = function(numberToReflect, rangeMin, rangeMax)
	{
		while (numberToReflect < rangeMin)
		{
			numberToReflect = rangeMin + rangeMin - numberToReflect;
		}

		while (numberToReflect > rangeMax)
		{
			numberToReflect = rangeMax - (numberToReflect - rangeMax);
		}

		return NumberHelper.trimNumberToRange(numberToReflect, rangeMin, rangeMax);
	}

	NumberHelper.trimNumberToRange = function(numberToTrim, rangeMin, rangeMax)
	{
		if (numberToTrim < rangeMin)
		{
			numberToTrim = rangeMin;
		}
		else if (numberToTrim > rangeMax)
		{
			numberToTrim = rangeMax;
		}

		return numberToTrim;
	}

}

function Terrain(name, color, altitudeStart)
{
	this.name = name;
	this.color = color;
	this.altitudeStart = altitudeStart;
}

function TerrainSet(name, terrains)
{
	this.name = name;
	this.terrains = terrains;
}
{
	TerrainSet.prototype.getTerrainForAltitude = function(altitudeToGet)
	{
		var returnValue = null;

		for (var i = this.terrains.length - 1; i >= 0; i--)
		{
			var terrain = this.terrains[i];
			if (altitudeToGet >= terrain.altitudeStart)
			{
				returnValue = terrain;
				break;
			}
		}

		return returnValue;
	}
}

// run

new TopographyGeneratorTest().main();

</script>
</body>
</html>

Advertisements
This entry was posted in Uncategorized and tagged , , . Bookmark the permalink.

2 Responses to Generating a Random Landscape in JavaScript

  1. childAltitude calculation sometimes ends up with NaN – I suppose that’s not expected, and probably the cause of the black line?

    • I realize it’s been almost a year since you made this comment, but I thought I’d respond since I just got around to fixing the bug, and I’d like to document the fix for posterity.

      The immediate cause of the black line was indeed because childAltitude was being set to NaN. This, in turn, was caused by the Coords.isWithinRange() method returning true when it should have been returning false, because through a quirk of JavaScript interpretation it was only evaluating the first condition of a multi-line boolean expression. This was fixed by assigning the return value to a variable and then returning that variable, rather than just attempting to return the expression directly.

      There were also some other geometry-related bugs that I have fixed, and I split up the various loops in generateRandom() among three distinct functions, in hopes of improving readability.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s