Thursday, July 30, 2020

The Survive and Thrive Tile Engine

A Tiled World

Creating a tile engine is fun development work.  To start, I wrote down a list of core features I wanted my tile engine to support.  These included:
  • Many different tile types including multiple types of grasses, liquids, soils, and rocks
  • Digging up a tile to remove grass and expose the soil beneath
  • Mining down to an underworld with various ores, minerals, and gems to discover and mine
  • The ability to change tiles from one type to another
  • Random tile quality values
  • Planting and harvesting flora and crops including cultivation to improve harvest quality
In order to implement these features, I decided each tile would consist of top and core sections.  The top of the tile would be able to be planted with various grasses and crops.  Or, it could be packed down for paving and building purposes.  The core of the tile would contain different core types including rare resources.  The soil would be able to be cultivated to produce fertilized soil which would be required for some crops.  It could also be dug out to create a hole in the ground for accessing the underworld.

Since the game is going to be 2-D and top-down, the cores would be mostly hidden from view.  The player would need to dig up the grass to find out what is beneath.  Not all would be soil.  There could be random resources and dangers such as clay, tar, and quicksand.  I felt like having these two tile sections would provide diversity and make digging and exploration fun.

Wild Grass

I decided to use a 9-tile tileset like the one shown above.  With this tileset I can choose the correct tile to display based on comparing adjacent tile types in the North, South, East and West directions.  Diagonals are not considered at this time.  In this example, the edges that do not touch another wild grass tile have transparent Alpha channel edges allowing some of the Core of the tile to be exposed.  This is not quite as evident with the grasses because I adjust their pixel-per-unit size to make them overlap each other like real grass would when it grows.  Whereas, water, sand, rock, lava, moss, lichen, and others do not overlap their tile borders.  As of this writing there are 19 top tilesets and 27 core tilesets.  Here are a few examples of core tilesets.

Rock Core

Quartz Core

Notice the cores have pure black edges.  Whereas above ground tiles are seen, underground tiles are hidden until they are revealed by mining.  Every tile mined out reveals the North, South, East, and West tiles around it.

Unity and the Tilemap System

The Unity Tilemap system is composed of three main components:  the Grid, the Tilemap, and the Tile.  The Grid is the parent component that provides alignment and world-to-map and map-to-world coordinate translation.  The Tilemap is a single plane or layer of tiles positioned via a Z-layer with the Grid.  When I first started using the Tilemap, I envisioned two Tilemaps per game level.  There are three game levels as of this writing, so that would be a total of six Tilemaps.  But when I put my dev cap on, I realized I only really needed two, one for the top tiles and one for the core tiles since only one level would be visible at a time.  This is all having to do with what the user will see.  Behind the scenes, I have an array established for each Tilemap to store the tile object data that is used to determine which tiles to render and where.

A Random World Builder

Many games are designed from the standpoint of making scenes with pre-designed maps.  To that end, Unity has provided the Tile Palette feature that allows designers to paint a Tilemap with tiles using brushes and rule tiles that auto-select the correct tile based on what is being painted.  But my intention from the start has been to create a totally random-generated world and provide the player with options to manipulate the settings of the world generator.  This is an early screenshot of that effort where you can see an input for a seed, the world size, and the various tile types available to generate.

Early Concept For The World Builder

The sliders range from 0 (ignore) to 25 and allow the player to designate the relative presence each type will have when the map is generated.  Tiles with greater numbers take up more overall space on the map.  In order for this to be a useful tool, the player needed to see what the settings would produce.  Therefore, I added a preview feature that would generate the map.

Early 650 x 650 World Map

Clicking on the Generate World button allowed the player to see the game world and tweak the settings until the combination produced a world map worthy of playing in.  The settings above produced the following map.

The Preview

I guess an explanation is in order because 650 x 650 is not 1,690,00 tiles!  My numbers were base on multiplying the Tilemap size by 4 (in this instance) to give a total number of tiles that would exist with all Tilemap layers combined.  Thus, 650 x 650 = 422500 x 4 = 1,690,000 tiles.

The Perlin Noise Algorithm

So how did I turn those sliders into that map?  The real workhorse of the transformation was Perlin noise!  You can read about it here.  It is not difficult to find code snippets for a Perlin noise generator.  In fact, I believe I found perhaps the best C# implementations of it from a very talented developer on YouTube.  Here is the original code and a very well-deserved shout-out to Sebastian Lague and his video series on this subject.  The specific video where this snippet is discussed can be found here.

public static float[,] OriginalGeneratePerlinNoiseMap(int seed, int mapWidth, int mapHeight, int octaves, float scale, float persistence, float lacunarity)
{
    float[,] noiseMap = new float[mapWidth, mapHeight];
    System.Random prng = new System.Random(seed);
    Vector2[] octaveOffsets = new Vector2[octaves];
    for (int i = 0; i < octaves; i++)
    {
     float offsetX = prng.Next(-100000, 100000);
     float offsetY = prng.Next(-100000, 100000);
     octaveOffsets[i] = new Vector2(offsetX, offsetY);
    }
    if (scale <= 0)
     scale = 0.0001f;

    float maxNoiseHeight = float.MinValue;
    float minNoiseHeight = float.MaxValue;
    float halfWidth = mapWidth / 2f;
    float halfHeight = mapHeight / 2f;
    
    for (int y = 0; y < mapHeight; y++)
    {
     for (int x = 0; x < mapWidth; x++)
     {
     float amplitude = 1;
     float frequency = 1;
     float noiseHeight = 0f;
     for (int i = 0; i < octaves; i++)
     {
         float sampleX = (x - halfWidth) / scale * frequency + octaveOffsets[i].x;
    float sampleY = (y - halfHeight) / scale * frequency + octaveOffsets[i].y;
    float perlinValue = Mathf.PerlinNoise(sampleX, sampleY) * 2 - 1;
    noiseHeight += perlinValue * amplitude;
    amplitude *= persistence;
    frequency *= lacunarity;
                }
if (noiseHeight > maxNoiseHeight)
    maxNoiseHeight = noiseHeight;
else if (noiseHeight < minNoiseHeight)
    minNoiseHeight = noiseHeight;
noiseMap[x, y] = noiseHeight;
}
    }
    //normalize map back down to values between 0 and 1
    for (int y = 0; y < mapHeight; y++)
    {
     for (int x = 0; x < mapWidth; x++)
     {
         noiseMap[x, y] = Mathf.InverseLerp(minNoiseHeight, maxNoiseHeight, noiseMap[x, y]);
}
    }
    return noiseMap;
}

I did make some tweaks to Sebastian's code, but not much.  The resulting noise map array provided  values for each tile in a range from 0.0 to 1.0.  I then gathered the slider values for each terrain type and gave them a weight value based on their slider value.  Here is a snippet from my code that does this.

public static void DistributeTileTopAssetsByWeight(List<STTileAsset> tileAssetList)
{                            
float value = 0.0f;
int frequencyTotal = tileAssetList.Where(x => x.AssetType == STTileAssetType.Top && x.Scale != 0 && x.DistributionMethod == STTileAssetDistributionType.Perlin).Sum(x => x.Scale);
foreach (STTileAsset ta in tileAssetList.OrderBy(o => o.Sort).Where(x => x.AssetType == STTileAssetType.Top && x.Scale != 0 && x.DistributionMethod == STTileAssetDistributionType.Perlin))
{
float weight = (float)ta.Scale / (float)frequencyTotal;
value += weight;                
ta.Weight = value; 
if (ta.Weight > .9999)
ta.Weight = 1.0f;
}
}

The STTileAsset class stores information about each tile type.  The scale value is the slider value.  By summing up the scale values and dividing each one by the total I could establish a weight value for each tile type. Once weight was known, all I had to do was work through my map array and pick the first STTileAsset where the weight was >= the Perlin array value and that would be the tile that belonged in that position of the Tilemap.

Well, I guess that does it for this post.  In my next post I will delve deeper into some of the other features of the map engine and why I added a chunk engine to manage the maps.


No comments:

Post a Comment

Feel free to leave a comment. All comments are moderated.