DEV Community

Miklós Horvát
Miklós Horvát

Posted on

Very simple procedural "dungeon" map generation

I started a little project under the name "Gimme' Another Level", which will be a very simple procedural dungeon crawler in a text-based RPG format.

I'm not the best, and I think there are much more efficient and optimal ways out there to make this work, but you can comment below it, because that's how we can "level up" in this profession :)

So let's get started with the first and most important thing: mapgeneration.

Our map's one section is a tile. What do we need for a tile?

  1. The type of it (Nothing, Wall, Enemy, Chest etc.)
  2. The index of the tile

For the simplicity we store the desired types of tiles in an enum:

public enum TileTypes
{
    Nothing,
    Wall,
    Enemy,
    Chest,
    Vendor,
    Exit,
    Key
}
Enter fullscreen mode Exit fullscreen mode

The fallback of this, that if you want to extense the types you will need to write other conditions in the generation, but right now, we keep it simple as I said :)

With this enum our Tile class will have these properties:

[Header("Properties")]
public TileTypes type;
public Vector2 TileIndex { get; set; }
Enter fullscreen mode Exit fullscreen mode

Now that we have a class for a section of the map, we need a map.
What do we need for our map?

  1. The size of it
  2. A two-dimensional array to store the tiles
[Header("Map properties")]
public Vector2 mapSize;
public static Tile[,] map;
Enter fullscreen mode Exit fullscreen mode

In the Awake we initialize the empty Tile type map with the size of mapSize in the first and second dimension.

private void Awake()
{
    map = new Tile[(int)mapSize.x, (int)mapSize.y];
}
Enter fullscreen mode Exit fullscreen mode

We have an empty map, so our next step is to populate. This will need a little more planning.
We will have types that will be initialized more than once and only once. The latter needs flags if it's already there or not, and we need to store their location.
For the Chest, Enemy and Vendor we may set up chances, because we do not need them on every tile.

[Space]
[Header("Initialize chances")]
[Range(0F, 1F)] [SerializeField] private float _chanceToChest;
[Range(0F, 1F)] [SerializeField] private float _chanceToEnemy;
[Range(0F, 1F)] [SerializeField] private float _chanceToVendor;

[Space]
//For debug purposes
[Header("Flags")]
[SerializeField] private bool _isExitInitialized;
[SerializeField] private bool _isKeyInitialized;
[SerializeField] private bool _isVendorInitialized;

private Vector2 _exitLocation;
private Vector2 _keyLocation;
private Vector2 _vendorLocation;
Enter fullscreen mode Exit fullscreen mode

Now we have everything to populate our map!

First I will explain my method then you can see the code below :)

To do this, we need to iterate through our map's x and y coordinates. Then on every [x,y] point we need to initialize a tile, and that tile's index will be the map's x and y position.
After this, to prevent any specific tile to overflow we make boundaries with walls. We want to be them all around, so while we iterate through the two-dimensional array, we need to check if this tile will be the first [0] or the last [mapSize - 1] of the row and column.
So we have walls. And that's where the two paths are splits in two.
First let's check the tile types which can be more than one: chest and enemy.
Check if the actual tile type is wall, if not then there is a chance for every tile between the boundaries to be chest, enemy or nothing.
Then we can make the map's x and y position equal to our configurated tile with the tileIndex of x and y.
Note that I initialized a prefab within this function, it's for debug purposes to visualize my experiment.

public void PopulateMap()
    {
        for (int x = 0; x < mapSize.x; x++)
        {
            for (int y = 0; y < mapSize.y; y++)
            {
                GameObject obj = Instantiate(_tilePrefab, _levelContainer);
                obj.SetActive(false);

                Tile actualTile = obj.GetComponent<Tile>();
                actualTile.TileIndex = new Vector2(x, y);
                obj.transform.position = actualTile.TileIndex;

                if (actualTile.TileIndex.x == 0 || actualTile.TileIndex.x == mapSize.x - 1 || actualTile.TileIndex.y == 0 || actualTile.TileIndex.y == mapSize.y - 1)
                {
                    actualTile.type = TileTypes.Wall;
                }
                else if (actualTile.type != TileTypes.Wall)
                {
                    if (Random.value <= _chanceToChest)
                    {
                        actualTile.type = TileTypes.Chest;
                    }
                    if (Random.value <= _chanceToEnemy)
                    {
                        actualTile.type = TileTypes.Enemy;
                    }
                }

                map[x, y] = actualTile;
                obj.SetActive(true);
            }
        }

        SetExitTile();
        SetKeyTile();
        SetVendorTile();
    }
Enter fullscreen mode Exit fullscreen mode

You can see three methods at the bottom of the function. That's the second path, where we only need one type of this tile. Basically with this we override one-one tile in our array.

First we need to get a random position between our walls (boundaries):

private Vector2 GetRandomLocation()
{
   return new Vector2((int)Random.Range(1, mapSize.x - 1),(int)Random.Range(1, mapSize.y - 1));
}
Enter fullscreen mode Exit fullscreen mode

Then we can initizalize our specific tiles (exit, key, vendor) with a chance. The reason for separate this from the generation is as I mentioned above the enemy and chest has a chance on every tile initialization while the vendor has chance on the whole map to be spawned. And of course we can get a random position for the key and exit in the whole map.

private void SetExitTile()
{
   if (!_isExitInitialized)
   {
       _exitLocation = GetRandomLocation();
       SetSpecificTile(_exitLocation, TileTypes.Exit);
       _isExitInitialized = true;
   }
}

private void SetKeyTile()
{
   _keyLocation = GetRandomLocation();

   if (_keyLocation != _exitLocation && !_isKeyInitialized)
   {
       SetSpecificTile(_keyLocation, TileTypes.Key);
       _isKeyInitialized = true;
   }
   else
   {
       SetKeyTile();
   }
}

private void SetVendorTile()
{
    if (Random.value <= _chanceToVendor && !_isVendorInitialized)
    {
        _vendorLocation = GetRandomLocation();

        if (_vendorLocation != _exitLocation && _vendorLocation != _keyLocation)
        {
            SetSpecificTile(_vendorLocation, TileTypes.Vendor);
            _isVendorInitialized = true;
        }
        else
        {
           SetVendorTile();
         }
    }
}
Enter fullscreen mode Exit fullscreen mode

We have our map and every coordinates type. Next time we will see how to give them functionality :)

If you have any suggestion please comment in the discussion section below. Have a nice day! :)

Top comments (0)