Skip to content

Instantly share code, notes, and snippets.

@unitycoder
Created August 31, 2023 11:01
Show Gist options
  • Select an option

  • Save unitycoder/71c0c1dcdf5300be42191a2911a79a50 to your computer and use it in GitHub Desktop.

Select an option

Save unitycoder/71c0c1dcdf5300be42191a2911a79a50 to your computer and use it in GitHub Desktop.

Revisions

  1. unitycoder created this gist Aug 31, 2023.
    263 changes: 263 additions & 0 deletions PrefabRandomBrush.cs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,263 @@
    // source https://stuartspixelgames.com/2023/08/31/how-to-paint-random-tiles-with-weighted-probability-unity-tilemap-2d-extras/
    // Working script, with flood fill

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

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

    bool m_EraseAnyObjects;

    // 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 neighbouring 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 that the sum of all tiles probabilities adds up to either 100, 1, or 1000.

    // The code works by:
    // - Adding the probabilities of all tiles together.
    // - Generating a random number between 0 and 1, which helps to normalise 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 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 PrefabRandomBrush class to provide a custom inspector interface in the Unity Editor.
    [CustomEditor(typeof(PrefabRandomBrush))]
    public class PrefabRandomBrushEditor : BasePrefabBrushEditor
    {
    // Reference to the PrefabRandomBrush being edited.
    private PrefabRandomBrush prefabRandomBrush => target as PrefabRandomBrush;
    // Serialized property to access the array of TileToSpawn objects in PrefabRandomBrush.
    private SerializedProperty m_Tiles;

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

    // Override the OnPaintInspectorGUI() method to create a custom inspector GUI for the PrefabRandomBrush.
    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 PrefabRandomBrush.
    prefabRandomBrush.m_EraseAnyObjects = EditorGUILayout.Toggle("Erase Any Objects", prefabRandomBrush.m_EraseAnyObjects);

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

    }