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.
How to paint random tiles with weighted probability (Unity Tilemap)
// 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();
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment