How to paint random tiles with weighted probability – Unity Tilemap | 2D Extras

Originally posted on my blog here.

I have written a custom brush script (free code for your projects below the examples) that will allow you to paint random tiles based on a probability. It works with Unity Tilemaps and the 2D Extras package which you can obtain from GitHub here. I’ve also written instructions on how to install 2D extras here. This code should be compatible with Unity 2017, 2018, 2019.4 and 2020.3. I think later versions of Unity use a new package obtained through the package editor, but I have not tried that yet.

This is free code rather than a full tutorial, however, the code is full of comments explaining what it all does. So you can choose to just use it, or you can read the explanations. If you just simply want code for random probability, click here.

I wrote this while working on my latest graphics update for Puzzledorf, as I’m going back to all of the levels and updating the graphics like the video below, and wanted a way to quickly fill the lakes in with randomized water tiles (with probabilities) to speed up the process of making new levels.

What It Does

Here’s what the code does:

  • I’ve overridden the script for the “Random Brush” because I find this more useful. If you want to create large, random tile grids like a tree made up of 4 tiles, it’s just as easy to set that up in the tile palette with random / rule tiles
  • You can assign any tile, whether it’s animated, a rule tile, a normal tile, any kind of unity tile.
  • You can give each tile a probability, and that will affect how often it appears
  • It will work with the paint brush tool, eraser, flood fill and box fill
  • If you are using the ‘Default Brush’, you can colour pick any tiles placed with this custom brush, and then you can use them like normal
  • There is no rotate function (may add that later)
  • I had to create a custom flood fill to make it work

Examples

The example below is how the tool works, allowing you to pick any number of tiles, and assign a probability. Works best if all the values together add up to a round number like 1, 10, 100 or 1000, to make sure you are assigning the kind of probabilities you are expecting. 100 is probably simplest, to think of it in terms of % out of 100, but any values you give it will work, no matter what they add up to.

Examples below, showing how it can flood fill an empty space, or all tiles of the same type.

And below is an example of using it with the brush.

Setup Instructions

  • First, create a new Random Brush in your Assets > Brushes folder or wherever you want to store them.
  • To do that, right click and go Create > 2D > Brushes > Random Brush
  • Click on the brush you want to modify. In my case, I’ve called it Random Test.
  • In the top right of the inspector, there is a little gear icon. Click it, and choose Edit Script.
  • This will bring up your default code editor of choice (for me, Visual Studio), and the base script for that brush
  • Scroll down to my Code Sample at the bottom of this article, copy the entire thing, then replace that entire script. The core functionality will still be there, we’re just improving it.
  • Then, save that script, come back up to this spot, and go back to unity.
  • In the inspector, click on the Tiles drop down and give it a size. This will determine how many random tiles you want
  • Click on the circle / target on the right of the box where it says None (Tile Base) and it will bring up a list of all Unity tiles you have created, including any tiles in your tile palette or outside of it, also including custom tiles like rule or animated tiles
  • Select the tile you want
  • Give it a probability out of 100 (it doesn’t have to be out of 100 but this makes it easier to do the mental maths)
  • Repeat this process for all tiles

Note: To make it easier to do the mental math’s, it helps to think of it in terms of being a chance out of 100%. In which case, all the probabilities combined should equal up to 100. But, you can assign any number you like for the probability and it will still work, with higher numbers having a higher chance of occurring.

  • Now go to your Tile Palette window and look at the bottom left. It most likely has a drop down box that says Default Brush.
  • Click that drop down box and select the new brush you just created, in my case, Random Test.

Now choose the paint brush tool, or whatever tool you like, and start painting with it!

Note: You can repeat this entire process and create as many different brushes as you like using different groupings of tiles. Have fun!

Code

Note: I am not writing a tutorial on how this code works, but there are lots of comments in the code explaining different things, including how the probability works.

using System.Linq;
using UnityEngine;
using UnityEditor;
using UnityEngine.Tilemaps;
using System.Collections.Generic;

namespace UnityEditor.Tilemaps
{
    [CustomGridBrush(false, true, false, "Random Brush")]
    public class RandomBrush : BasePrefabBrush
    {
#pragma warning disable 0649
        [SerializeField] public TileToSpawn[] m_Tiles;
#pragma warning restore 0649

        private bool m_EraseAnyObjects; // Private variable

        // Override the Paint function to place tiles
        public override void Paint(GridLayout grid, GameObject brushTarget, Vector3Int position)
        {
            // Check layer and tile availability
            if (brushTarget.layer == 31 || m_Tiles.Length == 0)
            {
                return;
            }

            // Set the tilemap and make sure it's not null, get the random tile we are about to paint, then 'paint' (set) it
            Tilemap tilemap = brushTarget.GetComponent<Tilemap>();
            if (tilemap != null)
            {
                TileBase tileToPaint = GetRandomTileByProbability();
                tilemap.SetTile(position, tileToPaint);
            }
        }

        // Override the FloodFill function to fill connected tiles
        public override void FloodFill(GridLayout gridLayout, GameObject brushTarget, Vector3Int position)
        {
            Tilemap tilemap = brushTarget.GetComponent<Tilemap>(); // Pick the current tilemap

            if (tilemap != null && m_Tiles.Length > 0) // Ensure there's a tilemap and that it has tiles
            {
                TileBase clickedTile = tilemap.GetTile(position);

                bool emptyTile = false; // Set whether the tile we clicked is empty or not
                if (clickedTile == null)
                    emptyTile = true;

                StartFill(tilemap, position, emptyTile, clickedTile);
            }
        }

        // Custom flood fill logic
        private void StartFill(Tilemap tilemap, Vector3Int position, bool emptyTile, TileBase clickedTile = null)
        {
            Queue<Vector3Int> tilesToFill = new Queue<Vector3Int>(); // make a list of tiles to check
            HashSet<Vector3Int> visited = new HashSet<Vector3Int>(); // a list of each tile we've checked in our fill loop

            tilesToFill.Enqueue(position); // Get all the tiles based on the position of our click
            visited.Add(position);

            while (tilesToFill.Count > 0)
            {
                // Get the current cell and see if the current tile we are looping over is empty or not
                Vector3Int currentCell = tilesToFill.Dequeue();
                TileBase currentTile = tilemap.GetTile(currentCell);

                // If we are looking for an empty tile, and it's null, the tile is empty and we want to fill it
                if (emptyTile && currentTile == null)
                {
                    TileFill(tilemap, currentCell, visited, tilesToFill);
                }
                else if (!emptyTile && currentTile == clickedTile) // Else, look for a tile that's the same type as what we clicked
                {
                    TileFill(tilemap, currentCell, visited, tilesToFill);
                }
            }
        }

        // Fill a single tile and its neighbors
        private void TileFill(Tilemap tilemap, Vector3Int position, HashSet<Vector3Int> visited, Queue<Vector3Int> tilesToFill)
        {
            // Pick a random tile, based on our probability, then place it on the tilemap
            TileBase tileToFill = GetRandomTileByProbability();
            tilemap.SetTile(position, tileToFill);

            // Then, check the neighboring cells to the one we clicked, and if we haven't checked them previously, 
            // add them to the list to be checked
            Vector3Int[] neighbors = GetNeighborCells(position);
            foreach (Vector3Int neighbor in neighbors)
            {
                if (!visited.Contains(neighbor) && IsInsideBounds(neighbor, tilemap.cellBounds))
                {
                    tilesToFill.Enqueue(neighbor);
                    visited.Add(neighbor);
                }
            }
        }

        // Check if a cell is within bounds
        private bool IsInsideBounds(Vector3Int cell, BoundsInt bounds)
        {
            return bounds.Contains(cell);
        }

        // Get neighboring cells
        private Vector3Int[] GetNeighborCells(Vector3Int cell)
        {
            return new Vector3Int[]
            {
                cell + new Vector3Int(1, 0, 0),
                cell + new Vector3Int(-1, 0, 0),
                cell + new Vector3Int(0, 1, 0),
                cell + new Vector3Int(0, -1, 0)
            };
        }

        // Override the Erase function to remove tiles
        public override void Erase(GridLayout grid, GameObject brushTarget, Vector3Int position)
        {
            // Check layer and tile availability
            if (brushTarget.layer == 31 || m_Tiles.Length == 0)
            {
                return;
            }

            // Set the tilemap and make sure it's not null, then do a reverse 'paint' (set) to erase it, by setting it to null
            Tilemap tilemap = brushTarget.GetComponent<Tilemap>();
            if (tilemap != null)
            {
                tilemap.SetTile(position, null);
            }
        }

        // Override the BoxFill function to paint within a box area
        public override void BoxFill(GridLayout grid, GameObject brushTarget, BoundsInt bounds)
        {
            foreach (Vector3Int tilePosition in bounds.allPositionsWithin)
                Paint(grid, brushTarget, tilePosition);
        }

        // Override the BoxErase function to erase within a box area
        public override void BoxErase(GridLayout grid, GameObject brushTarget, BoundsInt bounds)
        {
            foreach (Vector3Int tilePosition in bounds.allPositionsWithin)
                Erase(grid, brushTarget, tilePosition);
        }

        // CASE SCENARIO - PROBABILITIES:
        // Let's say we had 5 tiles, each with the tile probabilities: 5, 10, 100, 12, and 15.
        // Here's a step by step example of how the code works and what would happen:
        // 1: INITIALIZATION
        //      A:  Calculate the total probability: 5 + 10 + 100 + 12 + 15 = 142.
        //      B:  Generate a random value between 0 and 1, multiplied by 142. Let's say the random value is 37.
        // 2: LOOP ITERATION 1
        //      A: Enter the loop and take the first tileInfo (with a probability of 5).
        //      B: Subtract the probability of the current tile from the random value: 37 - 5 = 32.
        //      C: The result (32) is not less than or equal to 0, so we move on.
        // 3: LOOP ITERATION 2
        //      A: Take the second tileInfo (with a probability of 10).
        //      B: Subtract the probability of the current tile from the updated random value: 32 - 10 = 22.
        //      C: The result (22) is not less than or equal to 0, so we continue.
        // 4: LOOP ITERATION 3
        //      A: Take the third tileInfo (with a probability of 100).
        //      B: Subtract the probability of the current tile from the updated random value: 22 - 100 = -78.
        //      C: The result (-78) is less than 0, which means the random value has fallen within the range of this tile's probability.
        // 5: TILE SELECTION
        //      A: The loop condition is met, and the method returns the tileInfo.tile associated with the
        //      current tileInfo (with a probability of 100).

        // EXPLANATION
        // The higher the probability of a tile, the more "chances" that it will be landed on.
        // It's harder, though, to mentally imagine what the probability is of each tile, if we don't manually
        // divide them up into 100. So, the easiest way to ensure accurate probabilities, is to try and
        // make sure the sum of all tiles probabilities adds up to either 100, 1, or 1000. 

        // The code works by:
        // - Adding the probabilities of all available tiles together.
        // - Generating a random number between 0 and 1, which helps to normalize our range of possibilities, then multiplying
        // it by 100, to make sure that we end up with a random number within the range of our tiles' probabilities.
        // - Looping over our list of random tiles, and each time we do, subtracting the probability of the current tile's probability
        // from our random value. Subtracting the random value is what helps us select a tile.
        // - If we reach 0 or less than 0, we select that tile.
        // If you find this confusing, look over the Case Scenario above.

        // Get a random tile based on probability
        private TileBase GetRandomTileByProbability()
        {
            // Calculate the total probability of all available tiles, by adding them all together.
            // We then create a random number between 0 and 1, and multiply that by the total probabilities
            // to make sure our random value falls within the range of our probabilities
            // This new randomValue will be used to select a tile based on its probability.
            float totalProbability = m_Tiles.Sum(tileInfo => tileInfo.probability);
            float randomValue = Random.value * totalProbability;

            // Now loop over each tile
            // As we loop, we reduce 'randomValue' by the probability of each individual tile's own probability
            // This step simulates the process of selecting a tile. As the loop iterates,
            // it subtracts the probability of each tile from the random value, moving closer to zero.
            // If the random value is <= 0, that indicates that the random value has fallen within the range of a specific tile's probability.
            // In other words, we've reached our randomly selected tile, so we then return the tile
            foreach (var tileInfo in m_Tiles)
            {
                randomValue -= tileInfo.probability;
                if (randomValue <= 0f)
                {
                    return tileInfo.tile;
                }
            }

            // Fallback if probabilities are not properly set
            return m_Tiles[0].tile;
        }

        // Define a serializable class to store information about a tile that can be spawned.
        // Serialization refers to the process of converting an object's state into a format that can be easily stored, transmitted, or reconstructed. 
        // Often used to help make data appear in the Unity editor. This helps us to view the values in our custom class in the editor.
        [System.Serializable]
        public class TileToSpawn
        {
            // The tile we will paint and probability of it spawning
            public TileBase tile;
            public float probability;
        }

        // Define a custom editor for the RandomBrush class to provide a custom inspector interface in the Unity Editor.
        [CustomEditor(typeof(RandomBrush))]
        public class RandomBrushEditor : BasePrefabBrushEditor
        {
            // Reference to the RandomBrush being edited.
            private RandomBrush randomBrush => target as RandomBrush;
            // Serialized property to access the array of TileToSpawn objects in RandomBrush.
            private SerializedProperty m_Tiles;
            private SerializedProperty m_EraseAnyObjects;

            protected override void OnEnable()
            {
                base.OnEnable();
                // Find the serialized property for the 'm_Tiles' field in RandomBrush.
                m_Tiles = m_SerializedObject.FindProperty("m_Tiles");
                m_EraseAnyObjects = m_SerializedObject.FindProperty("m_EraseAnyObjects");
            }

            // Override the OnPaintInspectorGUI() method to create a custom inspector GUI for the RandomBrush.
            public override void OnPaintInspectorGUI()
            {
                base.OnPaintInspectorGUI(); // Use the base code for this function, as well as the extra code below
                m_SerializedObject.UpdateIfRequiredOrScript();

                // Display the 'm_Tiles' array as a property field in the inspector.
                EditorGUILayout.PropertyField(m_Tiles, true);

                // Display a toggle field for 'm_EraseAnyObjects' property in RandomBrush.
                randomBrush.m_EraseAnyObjects = EditorGUILayout.Toggle("Erase Any Objects", randomBrush.m_EraseAnyObjects);

                // Apply any modified properties to the serialized object without triggering an undo operation.
                m_SerializedObject.ApplyModifiedPropertiesWithoutUndo();
            }
        }
    }
}

Leave a comment

Blog at WordPress.com.

Up ↑