Created
August 31, 2023 11:01
-
-
Save unitycoder/71c0c1dcdf5300be42191a2911a79a50 to your computer and use it in GitHub Desktop.
How to paint random tiles with weighted probability (Unity Tilemap)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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